From a824933e3ebd6966252048067f991607d5a01091 Mon Sep 17 00:00:00 2001 From: Daniel Dresser Date: Thu, 1 Aug 2024 20:29:10 -0700 Subject: [PATCH] MergeMeshes : Added proxy implementation to allow testing --- include/GafferScene/MergeMeshes.h | 65 ++ include/GafferScene/TypeIds.h | 1 + python/GafferSceneTest/MergeMeshesTest.py | 965 ++++++++++++++++++ python/GafferSceneTest/__init__.py | 1 + python/GafferSceneTest/usdFiles/merged.usd | Bin 0 -> 7599 bytes python/GafferSceneUI/MergeMeshesUI.py | 59 ++ python/GafferSceneUI/__init__.py | 1 + src/GafferScene/MergeMeshes.cpp | 128 +++ .../ObjectProcessorBinding.cpp | 2 + startup/gui/menus.py | 1 + 10 files changed, 1223 insertions(+) create mode 100644 include/GafferScene/MergeMeshes.h create mode 100644 python/GafferSceneTest/MergeMeshesTest.py create mode 100644 python/GafferSceneTest/usdFiles/merged.usd create mode 100644 python/GafferSceneUI/MergeMeshesUI.py create mode 100644 src/GafferScene/MergeMeshes.cpp diff --git a/include/GafferScene/MergeMeshes.h b/include/GafferScene/MergeMeshes.h new file mode 100644 index 00000000000..2e630017368 --- /dev/null +++ b/include/GafferScene/MergeMeshes.h @@ -0,0 +1,65 @@ +////////////////////////////////////////////////////////////////////////// +// +// Copyright (c) 2024, Image Engine Design Inc. All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are +// met: +// +// * Redistributions of source code must retain the above +// copyright notice, this list of conditions and the following +// disclaimer. +// +// * Redistributions in binary form must reproduce the above +// copyright notice, this list of conditions and the following +// disclaimer in the documentation and/or other materials provided with +// the distribution. +// +// * Neither the name of John Haddon nor the names of +// any other contributors to this software may be used to endorse or +// promote products derived from this software without specific prior +// written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS +// IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, +// THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +// PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +// CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +// EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +// PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +// PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +// LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +// NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +// +////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include "GafferScene/MergeObjects.h" + +namespace GafferScene +{ + +class GAFFERSCENE_API MergeMeshes : public MergeObjects +{ + + public : + GAFFER_NODE_DECLARE_TYPE( GafferScene::MergeMeshes, MergeMeshesTypeId, MergeObjects ); + + explicit MergeMeshes( const std::string &name=defaultName() ); + ~MergeMeshes() override; + + protected : + + IECore::ConstObjectPtr mergeObjects( const std::vector< std::pair< IECore::ConstObjectPtr, Imath::M44f > > &sourcePaths, const Gaffer::Context *context ) const override; + + private : + + static size_t g_firstPlugIndex; + +}; + +IE_CORE_DECLAREPTR( MergeObjects ) + +} // namespace GafferScene diff --git a/include/GafferScene/TypeIds.h b/include/GafferScene/TypeIds.h index cc698345ddd..5f306dec4e3 100644 --- a/include/GafferScene/TypeIds.h +++ b/include/GafferScene/TypeIds.h @@ -184,6 +184,7 @@ enum TypeId RenderPassShaderTypeId = 110640, ShaderTweakProxyTypeId = 110641, MergeObjectsTypeId = 110642, + MergeMeshesTypeId = 110643, PreviewPlaceholderTypeId = 110647, PreviewGeometryTypeId = 110648, diff --git a/python/GafferSceneTest/MergeMeshesTest.py b/python/GafferSceneTest/MergeMeshesTest.py new file mode 100644 index 00000000000..8fb10f75c25 --- /dev/null +++ b/python/GafferSceneTest/MergeMeshesTest.py @@ -0,0 +1,965 @@ +########################################################################## +# +# Copyright (c) 2024, Image Engine Design Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# * Redistributions of source code must retain the above +# copyright notice, this list of conditions and the following +# disclaimer. +# +# * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following +# disclaimer in the documentation and/or other materials provided with +# the distribution. +# +# * Neither the name of John Haddon nor the names of +# any other contributors to this software may be used to endorse or +# promote products derived from this software without specific prior +# written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS +# IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, +# THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +########################################################################## + +import unittest +import imath +import inspect +import pathlib +import random + +import IECore +import IECoreScene + +import Gaffer +import GafferScene +import GafferSceneTest +import GafferTest + +class MergeMeshesTest( GafferSceneTest.SceneTestCase ) : + # ( Copied from GafferSceneTest/IECoreScenePreviewTest/MeshAlgoTessellateTest.py, might be time to find a central + # place for this ) + # We begin with a bunch of machinery to support assertMeshesPracticallyEqual. + # This is a bit overkill for what's actually needed here, but it does seem quite useful in general to be + # able to compare meshes that have floating point precision differences, or have different vertex or face + # orders, but are still effectively the same mesh. We haven't decided on a central place for this to live, + # hopefully we remember it exists next time we need it. + def reindexPrimvar( self, var, reindex ): + if var.indices: + return IECoreScene.PrimitiveVariable( var.interpolation, var.data, IECore.IntVectorData( [ var.indices[i] for i in reindex ] ) ) + else: + data = type( var.data )( [ var.data[i] for i in reindex ] ) + IECore.setGeometricInterpretation( data, IECore.getGeometricInterpretation( var.data ) ) + return IECoreScene.PrimitiveVariable( var.interpolation, data ) + + def reorderVertsToMatch( self, m, ref ): + tree = IECore.V3fTree( ref["P"].data ) + + numVerts = m.variableSize( IECoreScene.PrimitiveVariable.Interpolation.Vertex ) + + sortIndices = sorted( range( numVerts ), key = lambda i : tree.nearestNeighbour( m["P"].data[i] ) ) + + reverseSort = [-1] * numVerts + for i in range( len( sortIndices ) ): + reverseSort[ sortIndices[i] ] = i + + result = IECoreScene.MeshPrimitive( m.verticesPerFace, IECore.IntVectorData( [ reverseSort[i] for i in m.vertexIds ] ), m.interpolation ) + + for k in m.keys(): + if m[k].interpolation == IECoreScene.PrimitiveVariable.Interpolation.Vertex: + result[k] = self.reindexPrimvar( m[k], sortIndices ) + else: + result[k] = m[k] + return result + + def canonicalizeFaceOrders( self, m ): + offset = 0 + vertices = [] + for n in m.verticesPerFace: + origIndices = range( offset, offset+n ) + ids = [ m.vertexIds[i] for i in origIndices ] + rotate = ids.index( min( ids ) ) + vertices.append( list( origIndices )[rotate:] + list( origIndices )[:rotate] ) + offset += n + + faceReorder = sorted( range( m.numFaces() ), key = lambda i : [ m.vertexIds[ j ] for j in vertices[i] ] ) + + faceVertexReorder = sum( [ vertices[i] for i in faceReorder ], [] ) + + result = IECoreScene.MeshPrimitive( IECore.IntVectorData( m.verticesPerFace[i] for i in faceReorder ), IECore.IntVectorData( [ m.vertexIds[i] for i in faceVertexReorder ] ), m.interpolation ) + + for k in m.keys(): + if m[k].interpolation == IECoreScene.PrimitiveVariable.Interpolation.FaceVarying: + result[k] = self.reindexPrimvar( m[k], faceVertexReorder ) + elif m[k].interpolation == IECoreScene.PrimitiveVariable.Interpolation.Uniform: + result[k] = self.reindexPrimvar( m[k], faceReorder ) + else: + result[k] = m[k] + return result + + def betterAssertAlmostEqual( self, a, b, tolerance = 0, msg = "" ): + if hasattr( a, "min" ): + # Assume it's a box + self.betterAssertAlmostEqual( a.min(), b.min(), tolerance, msg ) + self.betterAssertAlmostEqual( a.max(), b.max(), tolerance, msg ) + return + elif hasattr( a, "v" ): + # Assume it's a quat + self.betterAssertAlmostEqual( a.r(), b.r(), tolerance, msg ) + self.betterAssertAlmostEqual( a.v(), b.v(), tolerance, msg ) + return + elif type( a ) == imath.Color4f: + # Annoying that imath doesn't have equalWithAbsError on Color4f + self.betterAssertAlmostEqual( a.r, b.r, tolerance, msg ) + self.betterAssertAlmostEqual( a.g, b.g, tolerance, msg ) + self.betterAssertAlmostEqual( a.b, b.b, tolerance, msg ) + self.betterAssertAlmostEqual( a.a, b.a, tolerance, msg ) + return + + if type( a ) == str: + match = a == b + elif hasattr( a, "equalWithAbsError" ): + match = a.equalWithAbsError( b, tolerance ) + else: + match = abs( a - b ) <= tolerance + + if not match: + raise AssertionError( ( msg + " : " if msg else "" ) + "%s != %s" % ( repr( a ), repr( b ) ) ) + + def assertPrimvarsPracticallyEqual( self, a, b, name, tolerance = 0 ): + self.assertEqual( a.interpolation, b.interpolation ) + expandedVarA = a.expandedData() + expandedVarB = b.expandedData() + + if not hasattr( expandedVarA, "size" ): + self.betterAssertAlmostEqual( expandedVarA.value, expandedVarB.value, tolerance, "Primvar %s" % name ) + return + + self.assertEqual( len( expandedVarA ), len( expandedVarB ) ) + + hasAbsError = hasattr( expandedVarA[0], "equalWithAbsError" ) + for i in range( len( expandedVarA ) ): + self.betterAssertAlmostEqual( + expandedVarA[i], expandedVarB[i], tolerance, 'Primvar "%s" element %i' % ( name, i ) + ) + + def assertMeshesPracticallyEqual( self, a, b, tolerance = 0 ): + compareA = self.canonicalizeFaceOrders( a ) + compareB = self.canonicalizeFaceOrders( self.reorderVertsToMatch( b, a ) ) + + self.assertPrimvarsPracticallyEqual( compareA["P"], compareB["P"], "P", tolerance ) + self.assertEqual( compareA.verticesPerFace, compareB.verticesPerFace ) + self.assertEqual( compareA.vertexIds, compareB.vertexIds ) + self.assertEqual( compareA.interpolation, compareB.interpolation ) + + self.assertEqual( compareA.keys(), compareB.keys() ) + + for k in compareA.keys(): + self.assertPrimvarsPracticallyEqual( compareA[k], compareB[k], k, tolerance ) + + def listLocations( self, scenePlug ): + + result = [] + + def visitLoc( path ): + if path: + result.append( path ) + for i in scenePlug.childNames( path ): + childPath = path.copy() + childPath.append( i ) + visitLoc( childPath ) + + visitLoc( IECore.InternedStringVectorData() ) + return result + + def assertBoundingBoxesValid( self, scenePlug ): + for i in self.listLocations( scenePlug ): + o = scenePlug.object( i ) + refBound = scenePlug.childBounds( i ) + if type( o ) != IECore.NullObject: + refBound.extendBy( GafferScene.SceneAlgo.bound( o ) ) + + # Weird way of testing if scenePlug.bound is a superset of refBound + targetBound = scenePlug.bound( i ) + testBox = targetBound + testBox.extendBy( refBound ) + self.assertEqual( testBox, targetBound, + msg = "For location %s" % GafferScene.ScenePlug.pathToString( i ) + ) + + + def testBasic( self ) : + sphere = GafferScene.Sphere() + + sphereFilter = GafferScene.PathFilter() + sphereFilter["paths"].setValue( IECore.StringVectorData( [ '/sphere' ] ) ) + + duplicate = GafferScene.Duplicate() + duplicate["in"].setInput( sphere["out"] ) + duplicate["filter"].setInput( sphereFilter["out"] ) + duplicate["copies"].setValue( 10 ) + + testScene = GafferScene.Group() + testScene["in"][0].setInput( duplicate["out"] ) + + toMerge = [ '/group/sphere2', '/group/sphere3', '/group/sphere4' ] + + chooseFilter = GafferScene.PathFilter() + chooseFilter["paths"].setValue( IECore.StringVectorData( toMerge ) ) + + mergeMeshes = GafferScene.MergeMeshes() + mergeMeshes["in"].setInput( testScene["out"] ) + mergeMeshes["filter"].setInput( chooseFilter["out"] ) + + # With the destination left at the default "scene:path", every mesh goes back to its current location, + # so no changes are made + self.assertScenesEqual( mergeMeshes["out"], mergeMeshes["in"] ) + + # If we set the `source`, we're no longer operating in-place, and all the meshes get duplicated. + mergeMeshes["source"].setInput( testScene["out"] ) + + refDuplicate = GafferScene.Duplicate() + refDuplicate["in"].setInput( testScene["out"] ) + refDuplicate["filter"].setInput( chooseFilter["out"] ) + + self.assertScenesEqual( mergeMeshes["out"], refDuplicate["out"] ) + + # Test some invalid destinations + mergeMeshes["destination"].setValue( "/" ) + with self.assertRaisesRegex( RuntimeError, "Empty destination not allowed." ): + GafferSceneTest.traverseScene( mergeMeshes["out"] ) + mergeMeshes["destination"].setValue( "/*" ) + + # Note this regex matches any /group/sphere* - the check for a valid destination happens while we're + # multithreading over the sources, and it's random which location we first notice has a bogus destination. + # I think this little bit of non-determinism is probably tolerable? ( It always correctly identifies one + # of the errors in the users setup, just not always the same one, if there are multiple errors to choose from. ) + with self.assertRaisesRegex( RuntimeError, r"Invalid destination `/\*` for source location '/group/sphere.'. Name `\*` is invalid \(because it contains filter wildcards\)" ): + GafferSceneTest.traverseScene( mergeMeshes["out"] ) + + # Merge everything into one location + mergeMeshes["destination"].setValue( "/merged" ) + + refObjectToScene = GafferScene.ObjectToScene() + refObjectToScene["name"].setValue( "merged" ) + refObjectToScene["object"].setValue( IECoreScene.MeshAlgo.merge( [ testScene["out"].object( i ) for i in toMerge ] ) ) + + refParent = GafferScene.Parent() + refParent["parent"].setValue( "/" ) + refParent["in"].setInput( testScene["out"] ) + refParent["child"][0].setInput( refObjectToScene["out"] ) + + self.assertScenesEqual( mergeMeshes["out"], refParent["out"] ) + + mergeMeshes["destination"].setValue( "/group/merged" ) + refParent["parent"].setValue( "/group" ) + + self.assertScenesEqual( mergeMeshes["out"], refParent["out"] ) + + mergeMeshes["destination"].setValue( "/group/foo/merged" ) + + refNewGroup = GafferScene.Group() + refNewGroup["name"].setValue( "foo" ) + refNewGroup["in"][0].setInput( refObjectToScene["out"] ) + + refParent["child"][0].setInput( refNewGroup["out"] ) + refParent["parent"].setValue( "/group/" ) + + self.assertScenesEqual( mergeMeshes["out"], refParent["out"] ) + + refParent["child"][0].setInput( refObjectToScene["out"] ) + mergeMeshes["destination"].setValue( "/group" ) + refParent["parent"].setValue( "/" ) + refObjectToScene["name"].setValue( "group" ) + + self.assertScenesEqual( mergeMeshes["out"], refParent["out"] ) + + # Test that attributes are carried through from locations that already existed + allFilter = GafferScene.PathFilter() + allFilter["paths"].setValue( IECore.StringVectorData( [ '/...' ] ) ) + + customAttributes = GafferScene.CustomAttributes() + customAttributes["attributes"].addChild( Gaffer.NameValuePlug( "test", Gaffer.StringPlug( "value", defaultValue = '${scene:path}' ) ) ) + customAttributes["in"].setInput( duplicate["out"] ) + customAttributes["filter"].setInput( allFilter["out"] ) + + testScene["in"][0].setInput( customAttributes["out"] ) + mergeMeshes["destination"].setValue( "/merged" ) + + for i in self.listLocations( mergeMeshes["out"] ): + if mergeMeshes["in"].exists( i ): + self.assertEqual( mergeMeshes["out"].attributes( i ), mergeMeshes["in"].attributes( i ) ) + + # One of the more obvious very weird cases: two meshes, where one contains the other, and the destination + # plug for each mesh is set to overwrite the other mesh. + def testSwap( self ): + + bigCube = GafferScene.Cube() + bigCube["name"].setValue( 'bigCube' ) + bigCube["transform"]["translate"].setValue( imath.V3f( 4, 5, 6 ) ) + bigCube["transform"]["rotate"].setValue( imath.V3f( 90, 0, 0 ) ) + bigCube["dimensions"].setValue( imath.V3f( 3, 3, 3 ) ) + + smallCube = GafferScene.Cube() + smallCube["name"].setValue( 'smallCube' ) + smallCube["transform"]["translate"].setValue( imath.V3f( 1, 2, 3 ) ) + smallCube["transform"]["rotate"].setValue( imath.V3f( 0, 90, 0 ) ) + + parent = GafferScene.Parent() + parent["in"].setInput( bigCube["out"] ) + parent["children"][0].setInput( smallCube["out"] ) + parent["parent"].setValue( '/bigCube' ) + + filterAll = GafferScene.PathFilter() + filterAll["paths"].setValue( IECore.StringVectorData( [ '/...' ] ) ) + + mergeMeshes = GafferScene.MergeMeshes() + mergeMeshes["in"].setInput( parent["out"] ) + mergeMeshes["filter"].setInput( filterAll["out"] ) + mergeMeshes["destExpression"] = Gaffer.Expression() + mergeMeshes["destExpression"].setExpression( + 'parent["destination"] = "/bigCube" if (context.get( "scene:path", [] ) or [ "" ])[-1] == "smallCube" else "/bigCube/smallCube"', + "python" + ) + + self.assertEqual( self.listLocations( mergeMeshes["out"] ), self.listLocations( mergeMeshes["in"] ) ) + + # After swapping, the bounds get mixed together so that both locations have the total bound + self.betterAssertAlmostEqual( + mergeMeshes["out"].bound( "/bigCube" ), mergeMeshes["in"].bound( "/bigCube" ), + tolerance = 0.000001 + ) + # This is just the total bound in the local space of this location + self.betterAssertAlmostEqual( + mergeMeshes["out"].bound( "/bigCube/smallCube" ), + imath.Box3f( imath.V3f( -0.5, -3.5, -2.5 ), imath.V3f( 4.5, 0.5, 0.5 ) ), + tolerance = 0.000001 + ) + + freezeBefore = GafferScene.FreezeTransform() + freezeBefore["in"].setInput( parent["out"] ) + freezeBefore["filter"].setInput( filterAll["out"] ) + + freezeAfter = GafferScene.FreezeTransform() + freezeAfter["in"].setInput( mergeMeshes["out"] ) + freezeAfter["filter"].setInput( filterAll["out"] ) + + self.assertEqual( freezeBefore["out"].object( "/bigCube" ), freezeAfter["out"].object( "/bigCube/smallCube" ) ) + self.assertEqual( freezeBefore["out"].object( "/bigCube/smallCube" ), freezeAfter["out"].object( "/bigCube" ) ) + + + # Compute the bound we expect for the given sources tranformed to the given destination. + # childBoundPlug may be set to the output plug without making this test overly circular - + # we don't depend on the bound at this location, only at child locations + def referenceBound( self, destPath, sources, inPlug, childBoundPlug ): + + result = childBoundPlug.childBounds( destPath ) + + while not inPlug.exists( destPath ): + destPath = IECore.InternedStringVectorData( destPath[:-1] ) + + toDest = inPlug.fullTransform( destPath ).inverse() + + for s in sources: + result.extendBy( + inPlug.bound( s ) * ( inPlug.fullTransform( s ) * toDest ) + ) + + return result + + # Check that the output sets match the input sets, with any paths that no longer exist pruned. + def assertSetsMatchWithPruning( self, outPlug, inPlug, filterMatcher, destMatcher ): + self.assertEqual( outPlug.setNames(), inPlug.setNames() ) + for sn in inPlug.setNames(): + # The set we expect is the input set, without any paths that are filtered out, but not used as destinations + refSet = [ + i for i in inPlug.set( sn ).value.paths() + if destMatcher.match( i ) & ( IECore.PathMatcher.Result.ExactMatch | IECore.PathMatcher.Result.DescendantMatch ) or not filterMatcher.match( i ) & ( IECore.PathMatcher.Result.AncestorMatch | IECore.PathMatcher.Result.ExactMatch ) + ] + self.assertEqual( outPlug.set( sn ).value.paths(), refSet, msg = "Set %s" % sn ) + + def testWeirdestCornerCases( self ): + bigSphere = GafferScene.Sphere() + bigSphere["radius"].setValue( 2 ) + bigSphere["name"].setValue( "bigSphere" ) + + smallSphere = GafferScene.Sphere() + smallSphere["name"].setValue( "smallSphere" ) + + parent = GafferScene.Parent() + parent["in"].setInput( bigSphere["out"] ) + parent["child"][0].setInput( smallSphere["out"] ) + parent["parent"].setValue( "/" ) + + f = GafferScene.PathFilter() + f["paths"].setValue( IECore.StringVectorData( [ '/smallSphere' ] ) ) + + mergeMeshes = GafferScene.MergeMeshes() + mergeMeshes["in"].setInput( parent["out"] ) + mergeMeshes["filter"].setInput( f["out"] ) + mergeMeshes["destination"].setValue( '/bigSphere/merged' ) + + # Check that we don't discard the bound of a object at a location when adding new children ( this + # unfortunately requires a pretty ugly special case in the code ) + self.assertEqual( mergeMeshes["out"].bound( "/bigSphere" ), imath.Box3f( imath.V3f( -2 ), imath.V3f( 2 ) ) ) + + parent["parent"].setValue( "/bigSphere" ) + f["paths"].setValue( IECore.StringVectorData( [ '/bigSphere/smallSphere' ] ) ) + mergeMeshes["destination"].setValue( '/merged' ) + + # This is definitely incorrect - by pruning a location under this location with both children and a mesh, + # we end up with a bounding box that is incorrectly set empty. + # + # We accept this incorrect behaviour because Prune does the same thing when adjustBounds is set, and + # there isn't really any good way of fixes this, without switching to storing the object bound as a + # completely separate plug to the child bounds. + self.assertEqual( mergeMeshes["out"].bound( "/bigSphere" ), imath.Box3f() ) + + + def makePathUnique( self, path, allLocations, filterMatcher, usedDestinations ): + + # ChildNamesMap isn't bound in Python, so hack up the bit of it we need + + while ( path in allLocations and + not ( filterMatcher.match( path ) & + ( IECore.PathMatcher.Result.AncestorMatch | IECore.PathMatcher.Result.ExactMatch ) ) + ) or path in usedDestinations: + leafName = path[-1].value() + if leafName[-1] == "1": + leafName = leafName[:-1] + "2" + elif leafName[-1] == "2": + leafName = leafName[:-1] + "3" + elif leafName[-1] == "3": + leafName = leafName[:-1] + "4" + else: + leafName = leafName + "1" + path = IECore.InternedStringVectorData( list( path )[:-1] + [leafName] ) + usedDestinations.append( path ) + + return path + + def testReferenceSetup( self ) : + + random.seed( 42 ) + + # Set up a moderately sized hierarchy using a Loop + leafCube = GafferScene.Cube() + + loop = Gaffer.Loop() + loop.setup( GafferScene.ScenePlug() ) + loop["in"].setInput( leafCube["out"] ) + + rootFilter = GafferScene.PathFilter() + rootFilter["paths"].setValue( IECore.StringVectorData( [ '/*' ] ) ) + + transformLeft = GafferScene.Transform() + transformLeft["in"].setInput( loop["previous"] ) + transformLeft["filter"].setInput( rootFilter["out"] ) + transformLeft["space"].setValue( GafferScene.Transform.Space.World ) + transformLeft["transform"]["rotate"].setValue( imath.V3f( -30, 0, 0 ) ) + + transformRight = GafferScene.Transform() + transformRight["in"].setInput( loop["previous"] ) + transformRight["filter"].setInput( rootFilter["out"] ) + transformRight["space"].setValue( GafferScene.Transform.Space.World ) + transformRight["transform"]["rotate"].setValue( imath.V3f( 30, 0, 0 ) ) + + stemCube = GafferScene.Cube() + + group = GafferScene.Group() + group["in"][0].setInput( transformLeft["out"] ) + group["in"][1].setInput( transformRight["out"] ) + group["in"][2].setInput( stemCube["out"] ) + group["transform"]["translate"].setValue( imath.V3f( 0, 1.26999998, 0 ) ) + group["transform"]["rotate"].setValue( imath.V3f( 0, 90, 0 ) ) + + loop["next"].setInput( group["out"] ) + loop["iterations"].setValue( 5 ) + + pathFilterAll = GafferScene.PathFilter( "PathFilterAll" ) + pathFilterAll["paths"].setValue( IECore.StringVectorData( [ '/...' ] ) ) + + # Delete all primvars but P so the reference file stored on disk is smaller. + deletePrimitiveVariables = GafferScene.DeletePrimitiveVariables() + deletePrimitiveVariables["in"].setInput( loop["out"] ) + deletePrimitiveVariables["filter"].setInput( pathFilterAll["out"] ) + deletePrimitiveVariables["names"].setValue( 'P' ) + deletePrimitiveVariables["invertNames"].setValue( True ) + + mergeMeshes = GafferScene.MergeMeshes() + mergeMeshes["in"].setInput( deletePrimitiveVariables["out"] ) + mergeMeshes["filter"].setInput( pathFilterAll["out"] ) + mergeMeshes["destination"].setValue( '/merged' ) + + reference = GafferScene.SceneReader() + reference["fileName"].setValue( pathlib.Path( __file__ ).parent / "usdFiles" / "merged.usd" ) + + self.assertScenesEqual( mergeMeshes["out"], reference["out"], checks = self.allSceneChecks - { "sets" } ) + + allLocations = self.listLocations( mergeMeshes["in"] ) + possiblePruneLocations = [ i for i in allLocations if len( i ) > 3 ] + + # Define some sets + setAll = GafferScene.Set() + setAll["in"].setInput( deletePrimitiveVariables["out"]) + setAll["filter"].setInput( pathFilterAll["out"] ) + setAll["name"].setValue( "setAll" ) + + pathFilterA = GafferScene.PathFilter( "PathFilterA" ) + pathFilterA["paths"].setValue( IECore.StringVectorData( + random.sample( [ GafferScene.ScenePlug.pathToString(i) for i in possiblePruneLocations ], len( possiblePruneLocations ) // 3 ) + ) ) + + setA = GafferScene.Set() + setA["in"].setInput( setAll["out"]) + setA["filter"].setInput( pathFilterA["out"] ) + setA["name"].setValue( "setA" ) + + pathFilterB = GafferScene.PathFilter( "PathFilterB" ) + pathFilterB["paths"].setValue( IECore.StringVectorData( + random.sample( [ GafferScene.ScenePlug.pathToString(i) for i in possiblePruneLocations ], len( possiblePruneLocations ) // 6 ) + ) ) + + setB = GafferScene.Set() + setB["in"].setInput( setA["out"]) + setB["filter"].setInput( pathFilterB["out"] ) + setB["name"].setValue( "setB" ) + + mergeMeshes["in"].setInput( setB["out"] ) + + mergeMeshes["filter"].setInput( pathFilterA["out"] ) + + refPrune = GafferScene.Prune() + refPrune["in"].setInput( setB["out"] ) + refPrune["filter"].setInput( pathFilterA["out"] ) + refPrune["adjustBounds"].setValue( True ) + + pathFilterMerged = GafferScene.PathFilter() + pathFilterMerged["paths"].setValue( IECore.StringVectorData( [ "/merged" ] ) ) + + mergedPrune = GafferScene.Prune() + mergedPrune["in"].setInput( mergeMeshes["out"] ) + mergedPrune["filter"].setInput( pathFilterMerged["out"] ) + mergedPrune["adjustBounds"].setValue( True ) + + # If we remove the location we merged everything to, then MergeMeshes has the same effect as a Prune node: + # it removes all the filtered locations from the scene ( in particular, this exercises that we handle sets + # correctly by removing the locations that no longer exist ). + self.assertScenesEqual( mergedPrune["out"], refPrune["out"] ) + + pathFilterALeaves = GafferScene.PathFilter() + pathFilterALeaves["paths"].setValue( IECore.StringVectorData( + [ i for i in pathFilterA["paths"].getValue() if i.split( "/" )[-1].startswith( "cube" ) ] + ) ) + + refIsolate = GafferScene.Isolate() + refIsolate["in"].setInput( setB["out"] ) + refIsolate["filter"].setInput( pathFilterALeaves["out"] ) + + mergeAfterIsolate = GafferScene.MergeMeshes() + mergeAfterIsolate["in"].setInput( refIsolate["out"] ) + mergeAfterIsolate["filter"].setInput( pathFilterAll["out"] ) + mergeAfterIsolate["destination"].setValue( "merged" ) + + # The merged result with a filter is the same as if you isolated all the targeted leaves, and then merged + # everything + self.assertEqual( mergeMeshes["out"].object( "/merged" ), mergeAfterIsolate["out"].object( "/merged" ) ) + + # Repeat those 2 tests using pathFilterB + mergeMeshes["filter"].setInput( pathFilterB["out"] ) + refPrune["filter"].setInput( pathFilterB["out"] ) + self.assertScenesEqual( mergedPrune["out"], refPrune["out"] ) + + pathFilterBLeaves = GafferScene.PathFilter() + pathFilterBLeaves["paths"].setValue( IECore.StringVectorData( + [ i for i in pathFilterB["paths"].getValue() if i.split( "/" )[-1].startswith( "cube" ) ] + ) ) + + refIsolate["filter"].setInput( pathFilterBLeaves["out"] ) + self.assertEqual( mergeMeshes["out"].object( "/merged" ), mergeAfterIsolate["out"].object( "/merged" ) ) + + # If the source is set, and we're not operating in place, the sets won't be modified at all + mergeMeshes["source"].setInput( setB["out"] ) + + self.assertEqual( mergeMeshes["out"].set( "setAll" ), mergeMeshes["in"].set( "setAll" ) ) + self.assertEqual( mergeMeshes["out"].set( "setA" ), mergeMeshes["in"].set( "setA" ) ) + self.assertEqual( mergeMeshes["out"].set( "setB" ), mergeMeshes["in"].set( "setB" ) ) + + mergeMeshes["source"].setInput( None ) + + mergeMeshes["filter"].setInput( pathFilterAll["out"] ) + + # Test that parenting under an existing location should keep the transform of that location, and + # position the vertices so that applying the transform leads to everything being correctly placed. + # We test this at several different locations throughout the hierarchy, or in a brand new hierarchy. + applyTransform = GafferScene.FreezeTransform() + applyTransform["in"].setInput( mergeMeshes["out"] ) + applyTransform["filter"].setInput( pathFilterAll["out"] ) + + # Check the expected result for a MergeMeshes with a constant destination. + def validateSimple( path, refPath ): + pathTokens = GafferScene.ScenePlug.stringToPath( path ) + + self.assertEqual( + self.listLocations( mergeMeshes["out"] ), + [ IECore.InternedStringVectorData( pathTokens[:i + 1] ) for i in range( len( pathTokens ) ) ] + ) + self.assertEqual( + mergeMeshes["out"].fullTransform( path ), + mergeMeshes["in"].fullTransform( refPath ) + ) + + currentBound = reference["out"].bound( "/merged" ) * mergeMeshes["out"].fullTransform( path ).inverse() + + for i in range( len( pathTokens ), 1, -1 ): + self.betterAssertAlmostEqual( + mergeMeshes["out"].bound( pathTokens[:i] ), + currentBound, + tolerance = 0.00001 + ) + currentBound = currentBound * mergeMeshes["out"].transform( pathTokens[:i] ) + + self.assertMeshesPracticallyEqual( + applyTransform["out"].object( path ), + reference["out"].object( "/merged" ), tolerance = 0.000001 + ) + + mergeMeshes["destination"].setValue( '/group/group/group/group/group/cube/merged' ) + validateSimple( '/group/group/group/group/group/cube/merged', '/group/group/group/group/group/cube' ) + + mergeMeshes["destination"].setValue( '/group/group/group/group/group/cube' ) + validateSimple( '/group/group/group/group/group/cube', '/group/group/group/group/group/cube' ) + + mergeMeshes["destination"].setValue( '/group/group/group' ) + validateSimple( '/group/group/group', '/group/group/group' ) + + mergeMeshes["destination"].setValue( '/group/group/group/merged' ) + validateSimple( '/group/group/group/merged', '/group/group/group' ) + + mergeMeshes["destination"].setValue( '/foo/bar/merged' ) + self.assertEqual( self.listLocations( mergeMeshes["out"] ), [ GafferScene.ScenePlug.stringToPath(i) for i in [ '/foo', '/foo/bar', '/foo/bar/merged' ] ] ) + self.assertEqual( mergeMeshes["out"].fullTransform( '/foo/bar/merged' ), imath.M44f() ) + self.assertEqual( applyTransform["out"].object( '/foo/bar/merged' ), reference["out"].object( "/merged" ) ) + self.assertEqual( mergeMeshes["out"].bound( '/foo/bar/merged' ), reference["out"].bound( "/merged" ) ) + self.assertEqual( mergeMeshes["out"].bound( '/foo/bar' ), reference["out"].bound( "/merged" ) ) + self.assertEqual( mergeMeshes["out"].bound( '/foo' ), reference["out"].bound( "/merged" ) ) + + mergeMeshes["destination"].setValue( '${scene:path}' ) + self.assertScenesEqual( mergeMeshes["out"], mergeMeshes["in"] ) + + # If we connect source, then we're no longer operating in-place, and only add new locations, + # without removing existing ones. + mergeMeshes["source"].setInput( setB["out"] ) + + # If we filter to everything, We get everything duplicated, except not the root, and the groups + # become empty locations. Maybe it was silly to make a reference for this using a duplicate ... + # we need to tweak it a fair bit to match - the difference with MergeObjects is that the newly + # created locations will have no transforms or children. + refDuplicate = GafferScene.Duplicate() + refDuplicate["in"].setInput( setB["out"] ) + refDuplicate["filter"].setInput( pathFilterAll["out"] ) + + bogusChildrenFilter = GafferScene.PathFilter() + bogusChildrenFilter["paths"].setValue( IECore.StringVectorData( [ + '/root1', '/group1/*', '/.../group2/*', '/.../group3/*' + ] ) ) + + refPruneBogusChildren = GafferScene.Prune() + refPruneBogusChildren["in"].setInput( refDuplicate["out"] ) + refPruneBogusChildren["filter"].setInput( bogusChildrenFilter["out"] ) + + refFreeezeFilter = GafferScene.PathFilter() + refFreeezeFilter["paths"].setValue( IECore.StringVectorData( [ '.../cube3', '.../cube4', '.../group2', '.../group3', '/group1' ] ) ) + + refFreeze = GafferScene.FreezeTransform() + refFreeze["in"].setInput( refPruneBogusChildren["out"] ) + refFreeze["filter"].setInput( refFreeezeFilter["out"] ) + + self.assertScenesEqual( mergeMeshes["out"], refFreeze["out"], checks = self.allSceneChecks - { "bound", "object", "transform", "sets" } ) + + # If we fixed the precision of Duplicate, then we could probably just use the default checks of + # assertScenesEqual, instead of using tolerances here + for l in self.listLocations( mergeMeshes["out"] ): + self.betterAssertAlmostEqual( + mergeMeshes["out"].transform( l ), refFreeze["out"].transform( l ), tolerance = 0.000001 + ) + self.betterAssertAlmostEqual( + mergeMeshes["out"].bound( l ), refFreeze["out"].bound( l ), tolerance = 0.000001 + ) + if not ( type( mergeMeshes["out"].object( l ) ) == IECore.NullObject ): + self.assertMeshesPracticallyEqual( + mergeMeshes["out"].object( l ), refFreeze["out"].object( l ), tolerance = 0.000001 + ) + + # Merge everything to one location while not operating in place + + mergeMeshes["destination"].setValue( '/merged' ) + + postPruneFilter = GafferScene.PathFilter() + postPruneFilter["paths"].setValue( IECore.StringVectorData( [ '/group' ] ) ) + + postPrune = GafferScene.Prune() + postPrune["in"].setInput( mergeMeshes["out"] ) + postPrune["filter"].setInput( postPruneFilter["out"] ) + + # The new location has everything in it + self.assertScenesEqual( postPrune["out"], reference["out"], checks = self.allSceneChecks - { "sets" } ) + + # The existing locations are all still there + postPruneFilter["paths"].setValue( IECore.StringVectorData( [ '/merged' ] ) ) + self.assertScenesEqual( postPrune["out"], mergeMeshes["in"], checks = self.allSceneChecks - { "bound" } ) + self.assertBoundingBoxesValid( mergeMeshes["out"] ) + + + # Now go back to operating in-place + mergeMeshes["source"].setInput( None ) + + # Start prepping for some more complex merges, where each source location gets a different destination. + mergeMeshes["testDestinations"] = Gaffer.StringVectorDataPlug() + + mergeMeshes["destExpression"] = Gaffer.Expression() + mergeMeshes["destExpression"].setExpression( inspect.cleandoc( """ + td = parent["testDestinations"] + parent["destination"] = td[ context.get( "scene:path" ).hash().h1() % len( td ) ] + """ ) ) + + # One of the biggest tools here for testing complex merges is that in most cases, final vertex positions + # are preserved, so if we merge everything down, we should still get the same mesh. Even if the hierarchy + # is totally scrambled, merging everything to one location will still come out the same ... if things are + # working. + remerge = GafferScene.MergeMeshes() + remerge["in"].setInput( mergeMeshes["out"] ) + remerge["filter"].setInput( pathFilterAll["out"] ) + remerge["destination"].setValue( "/merged" ) + + # Test pseudo-randomly splitting to 2 different locations + mergeMeshes["testDestinations"].setValue( IECore.StringVectorData( [ "/A", "/B" ] ) ) + + self.assertEqual( mergeMeshes["out"].object( "/A" ).numFaces(), 180 ) + self.assertEqual( mergeMeshes["out"].object( "/B" ).numFaces(), 198 ) + self.assertMeshesPracticallyEqual( + remerge["out"].object( "/merged" ), + reference["out"].object( "/merged" ), tolerance = 0.000001 + ) + self.assertBoundingBoxesValid( mergeMeshes["out"] ) + + offsetFilter = GafferScene.PathFilter() + offsetFilter["paths"].setValue( IECore.StringVectorData( [ '/*' ] ) ) + + + # To get matching reference for some of our tests, we need to compare to a merged mesh that + # doesn't include meshes from some locations. To get this reference with minimal reliance + # on MergeMeshes, we can DeleteObjects any locations we don't want. + prunedReferenceFilter = GafferScene.PathFilter() + + prunedReferenceDelete = GafferScene.DeleteObject() + prunedReferenceDelete["in"].setInput( setB["out"] ) + prunedReferenceDelete["filter"].setInput( prunedReferenceFilter["out"] ) + + prunedReferenceMerge = GafferScene.MergeMeshes() + prunedReferenceMerge["in"].setInput( prunedReferenceDelete["out"] ) + prunedReferenceMerge["destination"].setValue( "/merged" ) + prunedReferenceMerge["filter"].setInput( pathFilterAll["out"] ) + + + # Prep a reference where all the filtered meshes are present twice. + # ( We use an offset to distinguish meshes connected to source from meshes connected to in, + # because assertMeshesPracticallyEqual doesn't deal well with overlapping verts ). + prunedReferenceOffset = GafferScene.Transform() + prunedReferenceOffset["filter"].setInput( offsetFilter["out"] ) + prunedReferenceOffset["space"].setValue( GafferScene.Transform.Space.World ) + prunedReferenceOffset["in"].setInput( prunedReferenceMerge["out"] ) + prunedReferenceOffset["transform"]["translate"].setValue( imath.V3f( 5, 0, 0 ) ) + + doubleRefGroup = GafferScene.Group() + doubleRefGroup["in"][0].setInput( reference["out"] ) + doubleRefGroup["in"][1].setInput( prunedReferenceOffset["out"] ) + + doubleRef = GafferScene.MergeMeshes() + doubleRef["in"].setInput( doubleRefGroup["out"] ) + doubleRef["filter"].setInput( pathFilterAll["out"] ) + doubleRef["destination"].setValue( "/merged" ) + + sourceOffset = GafferScene.Transform() + sourceOffset["filter"].setInput( offsetFilter["out"] ) + sourceOffset["space"].setValue( GafferScene.Transform.Space.World ) + sourceOffset["in"].setInput( setB["out"] ) + sourceOffset["transform"]["translate"].setValue( imath.V3f( 5, 0, 0 ) ) + + # Returned if a filter match value corresponds to being pruned by the parent, but not included in the filter + def parentPruned( m ): + return ( m & IECore.PathMatcher.Result.AncestorMatch ) and not ( m & IECore.PathMatcher.Result.ExactMatch ) + + # Now getting to the real meat of this test - we'll test with several different shuffles of destinations, + # and with different filters, and make sure things always come out reasonably. + for subsetSize in [ 3, 7, len( allLocations ) ]: + mergeMeshes["testDestinations"].setValue( IECore.StringVectorData( + [ "/new", "/new/loc", "/group/group/group/group/group/cube", "/group/group1/group/group/group/cube/merged" ] + + random.sample( [ GafferScene.ScenePlug.pathToString(i) for i in allLocations ], subsetSize ) + ) ) + + for f in [ pathFilterAll, pathFilterA, pathFilterB ]: + + if f == pathFilterAll: + filteredLocations = allLocations + else: + filteredLocations = [ GafferScene.ScenePlug.stringToPath(i) for i in f["paths"].getValue() ] + + filterMatcher = IECore.PathMatcher( filteredLocations ) + + mergeMeshes["filter"].setInput( f["out"] ) + + destinationMap = {} + + # Prep a map of destinations and sources, so we can prepare our expected reference results + c = Gaffer.Context( Gaffer.Context.current() ) + with c: + for l in filteredLocations: + c["scene:path"] = l + destinationMap.setdefault( mergeMeshes["destination"].getValue(), [] ).append( GafferScene.ScenePlug.pathToString( l ) ) + + destinationMatcher = IECore.PathMatcher( [ GafferScene.ScenePlug.stringToPath(i) for i in destinationMap.keys() ] ) + + # "Abandoned" locations are locations that are not filtered, but their parents are. + # They will not be merged to new location, but they will be removed from their original + # locations + abandonedLocations = [ GafferScene.ScenePlug.pathToString(i) for i in allLocations if parentPruned( filterMatcher.match( i ) ) ] + prunedReferenceFilter["paths"].setValue( IECore.StringVectorData( abandonedLocations ) ) + + # When we want a reference to compare to, we need a reference without abandoned locations, + # so we have to use a reference path that also involves a merge. We can partially validate this + # by validating against the reference file when there are no abandoned locations. + if f == pathFilterAll: + self.assertMeshesPracticallyEqual( + prunedReferenceMerge["out"].object( "/merged" ), + reference["out"].object( "/merged" ), tolerance = 0.00001 + ) + # We can't compare to the remerge the reference mesh when there is a filter - it won't match + # due to abandoned locations. But we can compare to a one-step reference merge that just skips + # abandoned locations. + self.assertMeshesPracticallyEqual( + remerge["out"].object( "/merged" ), + prunedReferenceMerge["out"].object( "/merged" ), tolerance = 0.00001 + ) + + self.assertBoundingBoxesValid( mergeMeshes["out"] ) + + self.assertSetsMatchWithPruning( mergeMeshes["out"], mergeMeshes["in"], filterMatcher, destinationMatcher ) + + # Test the bounds of the actual destination locations against our reference function. + usedDestinations = [] + for dest, sources in destinationMap.items(): + + # We need to figure out where this destination actually ends up in the hierarchy - + # we will deduplicate names if they overlap with original locations that aren't filtered. + uniqueDest = self.makePathUnique( + GafferScene.ScenePlug.stringToPath( dest ), allLocations, filterMatcher, usedDestinations + ) + + refBound = self.referenceBound( uniqueDest, sources, mergeMeshes["in"], mergeMeshes["out"] ) + + self.betterAssertAlmostEqual( mergeMeshes["out"].bound( uniqueDest ), refBound, tolerance = 0.00001 ) + + # If we attach a source so we're no longer working in place, all the target destinations should + # get uniquified so that we end up with 2 full copies of the mesh. + mergeMeshes["source"].setInput( sourceOffset["out"] ) + + if f == pathFilterAll: + prunedReferenceFilter["paths"].setValue( IECore.StringVectorData( [] ) ) + else: + prunedReferenceFilter["paths"].setValue( IECore.StringVectorData( [ i for i in [ GafferScene.ScenePlug.pathToString( j ) for j in allLocations ] if not i in f["paths"].getValue()] ) ) + + self.assertMeshesPracticallyEqual( + remerge["out"].object( "/merged" ), + doubleRef["out"].object( "/merged" ), tolerance = 0.00001 + ) + self.assertBoundingBoxesValid( mergeMeshes["out"] ) + mergeMeshes["source"].setInput( None ) + + # Reset values from the last loop iteration + mergeMeshes["filter"].setInput( pathFilterAll["out"] ) + prunedReferenceFilter["paths"].setValue( IECore.StringVectorData( [] ) ) + + # Replace Group locations with a location that contains the mesh itself. This isn't how we usually + # expect scenes to be laid out - usually locations have either a mesh, or children, but not both. + # But we do support it, and it does exercise some weird corner cases to test it. + stemCube["name"].setValue( "group" ) + stemCube["transform"]["translate"].setValue( imath.V3f( 0, 1.26999998, 0 ) ) + stemCube["transform"]["rotate"].setValue( imath.V3f( 0, 90, 0 ) ) + + parentToMesh = GafferScene.Parent() + parentToMesh["parent"].setValue( "/group" ) + parentToMesh["in"].setInput( stemCube["out"] ) + parentToMesh["child"][0].setInput( transformLeft["out"] ) + parentToMesh["child"][1].setInput( transformRight["out"] ) + + loop["next"].setInput( parentToMesh["out"] ) + + mergeMeshes["testDestinations"].setValue( IECore.StringVectorData( [ "/merged" ] ) ) + + self.assertMeshesPracticallyEqual( mergeMeshes["out"].object( "/merged" ), reference["out"].object( "/merged" ) ) + self.assertBoundingBoxesValid( mergeMeshes["out"] ) + + # A few more weird random scrambles for good luck + for subsetSize in [ 3, 7, len( allLocations ) ]: + mergeMeshes["testDestinations"].setValue( IECore.StringVectorData( + [ "/new", "/new/loc", "/group/group/group/group/group/cube", "/group/group1/group/group/group/cube/merged" ] + + random.sample( [ GafferScene.ScenePlug.pathToString(i) for i in allLocations ], subsetSize ) + ) ) + self.assertMeshesPracticallyEqual( + remerge["out"].object( "/merged" ), + reference["out"].object( "/merged" ), tolerance = 0.000002 + ) + self.assertBoundingBoxesValid( mergeMeshes["out"] ) + mergeMeshes["filter"].setInput( pathFilterA["out"] ) + self.assertBoundingBoxesValid( mergeMeshes["out"] ) + mergeMeshes["filter"].setInput( pathFilterB["out"] ) + self.assertBoundingBoxesValid( mergeMeshes["out"] ) + mergeMeshes["filter"].setInput( pathFilterAll["out"] ) + + mergeMeshes["source"].setInput( sourceOffset["out"] ) + self.assertMeshesPracticallyEqual( + remerge["out"].object( "/merged" ), + doubleRef["out"].object( "/merged" ), tolerance = 0.00001 + ) + self.assertBoundingBoxesValid( mergeMeshes["out"] ) + + @unittest.skipIf( True, "MergeMeshes currently too slow to run perf tests" ) + @GafferTest.TestRunner.PerformanceTestMethod() + def testPerformance( self ) : + sphere = GafferScene.Sphere() + + sphereFilter = GafferScene.PathFilter() + sphereFilter["paths"].setValue( IECore.StringVectorData( [ '/sphere' ] ) ) + + duplicate = GafferScene.Duplicate() + duplicate["in"].setInput( sphere["out"] ) + duplicate["filter"].setInput( sphereFilter["out"] ) + duplicate["copies"].setValue( 1000 ) + + allFilter = GafferScene.PathFilter() + allFilter["paths"].setValue( IECore.StringVectorData( [ "*" ] ) ) + + mergeMeshes = GafferScene.MergeMeshes() + mergeMeshes["in"].setInput( duplicate["out"] ) + mergeMeshes["filter"].setInput( allFilter["out"] ) + + mergeMeshes["destination"].setValue( "/merged" ) + + with GafferTest.TestRunner.PerformanceScope(): + mergeMeshes["out"].object( "/merged" ) + +if __name__ == "__main__": + unittest.main() diff --git a/python/GafferSceneTest/__init__.py b/python/GafferSceneTest/__init__.py index da9ecb37aa6..1b418e52cf0 100644 --- a/python/GafferSceneTest/__init__.py +++ b/python/GafferSceneTest/__init__.py @@ -183,6 +183,7 @@ from .RenderPassTypeAdaptorTest import RenderPassTypeAdaptorTest from .RenderPassShaderTest import RenderPassShaderTest from .RenderPassAdaptorTest import RenderPassAdaptorTest +from .MergeMeshesTest import MergeMeshesTest from .IECoreScenePreviewTest import * from .IECoreGLPreviewTest import * diff --git a/python/GafferSceneTest/usdFiles/merged.usd b/python/GafferSceneTest/usdFiles/merged.usd new file mode 100644 index 0000000000000000000000000000000000000000..0efa1736dc98ae748b41f22b9d61fd8c15588a42 GIT binary patch literal 7599 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 literal 0 HcmV?d00001 diff --git a/python/GafferSceneUI/MergeMeshesUI.py b/python/GafferSceneUI/MergeMeshesUI.py new file mode 100644 index 00000000000..16a03a659f0 --- /dev/null +++ b/python/GafferSceneUI/MergeMeshesUI.py @@ -0,0 +1,59 @@ +########################################################################## +# +# Copyright (c) 2024, Image Engine Design Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# * Redistributions of source code must retain the above +# copyright notice, this list of conditions and the following +# disclaimer. +# +# * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following +# disclaimer in the documentation and/or other materials provided with +# the distribution. +# +# * Neither the name of John Haddon nor the names of +# any other contributors to this software may be used to endorse or +# promote products derived from this software without specific prior +# written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS +# IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, +# THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +########################################################################## + +import IECore + +import Gaffer +import GafferUI + +import GafferScene +import GafferSceneUI + +########################################################################## +# Metadata +########################################################################## + +Gaffer.Metadata.registerNode( + + GafferScene.MergeMeshes, + + "description", + """ + Merge meshes from all filtered location into a single mesh, or into + multiple destinations. + """, + +) diff --git a/python/GafferSceneUI/__init__.py b/python/GafferSceneUI/__init__.py index 4a9091cc3d0..b159226dd04 100644 --- a/python/GafferSceneUI/__init__.py +++ b/python/GafferSceneUI/__init__.py @@ -198,6 +198,7 @@ from . import RenderPassTypeAdaptorUI from . import RenderPassShaderUI from . import MergeObjectsUI +from . import MergeMeshesUI # then all the PathPreviewWidgets. note that the order # of import controls the order of display. diff --git a/src/GafferScene/MergeMeshes.cpp b/src/GafferScene/MergeMeshes.cpp new file mode 100644 index 00000000000..de3168694ea --- /dev/null +++ b/src/GafferScene/MergeMeshes.cpp @@ -0,0 +1,128 @@ +////////////////////////////////////////////////////////////////////////// +// +// Copyright (c) 2024, Image Engine Design Inc. All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are +// met: +// +// * Redistributions of source code must retain the above +// copyright notice, this list of conditions and the following +// disclaimer. +// +// * Redistributions in binary form must reproduce the above +// copyright notice, this list of conditions and the following +// disclaimer in the documentation and/or other materials provided with +// the distribution. +// +// * Neither the name of John Haddon nor the names of +// any other contributors to this software may be used to endorse or +// promote products derived from this software without specific prior +// written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS +// IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, +// THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +// PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +// CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +// EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +// PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +// PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +// LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +// NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +// +////////////////////////////////////////////////////////////////////////// + +#include "GafferScene/MergeMeshes.h" + +#include "tbb/task_arena.h" + +#include "IECoreScene/MeshAlgo.h" +#include "IECoreScene/MeshPrimitive.h" +#include "IECoreScene/TransformOp.h" // TODO +#include "IECore/TypeTraits.h" +#include "IECore/DataAlgo.h" +#include "IECore/NullObject.h" + +using namespace std; +using namespace Imath; +using namespace IECore; +using namespace IECoreScene; +using namespace Gaffer; +using namespace GafferScene; + +GAFFER_NODE_DEFINE_TYPE( MergeMeshes ); + +size_t MergeMeshes::g_firstPlugIndex = 0; + +MergeMeshes::MergeMeshes( const std::string &name ) + : MergeObjects( name ) +{ + storeIndexOfNextChild( g_firstPlugIndex ); +} + +MergeMeshes::~MergeMeshes() +{ +} + +IECore::ConstObjectPtr MergeMeshes::mergeObjects( const std::vector< std::pair< IECore::ConstObjectPtr, Imath::M44f > > &sources, const Gaffer::Context *context ) const +{ + std::vector< IECoreScene::ConstMeshPrimitivePtr > meshes; + std::vector< const IECoreScene::MeshPrimitive * > meshPointers; + for( unsigned int i = 0; i < sources.size(); i++ ) + { + if( runTimeCast( sources[i].first.get() ) ) + { + // Ignore null objects ( this would often happen because the filter includes parent locations. + continue; + } + + const IECoreScene::MeshPrimitive * m = IECore::runTimeCast< const IECoreScene::MeshPrimitive >( sources[i].first.get() ); + if( !m ) + { + // \todo - we don't really want this logic to depend on the source paths, so it's nice to not + // have them passed in ... but that prevents us from throwing a useful exception here. + // Actually kinda feels like the most elegant approach would be a custom exception type, + // like MergeObjectException( "Source is not mesh", i ), and then MergeObjects would translate + // that to a regular exception with the source path included. We don't really do that elsewhere + // though, would that be weird? + throw IECore::Exception( + fmt::format( + "Source must be mesh, got {}.", sources[i].first->typeName() + ) + ); + } + + vector primVarNames; + for( IECoreScene::PrimitiveVariableMap::const_iterator it = m->variables.begin(), eIt = m->variables.end(); it != eIt; ++it ) + { + if( IECore::trait( it->second.data.get() ) ) + { + primVarNames.push_back( it->first ); + } + } + + MeshPrimitivePtr outputMesh = m->copy(); + TransformOpPtr transformOp = new TransformOp; + transformOp->inputParameter()->setValue( outputMesh ); + transformOp->copyParameter()->setTypedValue( false ); + transformOp->matrixParameter()->setValue( new M44fData( sources[i].second ) ); + transformOp->primVarsParameter()->setTypedValue( primVarNames ); + transformOp->operate(); + + meshes.push_back( outputMesh ); + meshPointers.push_back( outputMesh.get() ); + } + + if( !meshPointers.size() ) + { + return IECore::NullObject::defaultNullObject(); + } + + return tbb::this_task_arena::isolate( + [&]() { + return IECoreScene::MeshAlgo::merge( meshPointers, context->canceller() ); + } + ); +} diff --git a/src/GafferSceneModule/ObjectProcessorBinding.cpp b/src/GafferSceneModule/ObjectProcessorBinding.cpp index 01b2af9f207..edd84afe704 100644 --- a/src/GafferSceneModule/ObjectProcessorBinding.cpp +++ b/src/GafferSceneModule/ObjectProcessorBinding.cpp @@ -46,6 +46,7 @@ #include "GafferScene/DeletePoints.h" #include "GafferScene/LightToCamera.h" #include "GafferScene/MergeObjects.h" +#include "GafferScene/MergeMeshes.h" #include "GafferScene/MeshDistortion.h" #include "GafferScene/MeshNormals.h" #include "GafferScene/MeshSegments.h" @@ -91,6 +92,7 @@ void GafferSceneModule::bindObjectProcessor() GafferBindings::DependencyNodeClass(); GafferBindings::DependencyNodeClass(); GafferBindings::DependencyNodeClass(); + GafferBindings::DependencyNodeClass(); { scope s = GafferBindings::DependencyNodeClass(); diff --git a/startup/gui/menus.py b/startup/gui/menus.py index 00f3f938d87..452a95cd7bf 100644 --- a/startup/gui/menus.py +++ b/startup/gui/menus.py @@ -280,6 +280,7 @@ def __lightCreator( nodeName, shaderName, shape ) : nodeMenu.append( "/Scene/Object/Mesh Distortion", GafferScene.MeshDistortion, searchText = "MeshDistortion" ) nodeMenu.append( "/Scene/Object/Mesh Segments", GafferScene.MeshSegments, searchText = "MeshSegments" ) nodeMenu.append( "/Scene/Object/Mesh Split", GafferScene.MeshSplit, searchText = "MeshSplit" ) +nodeMenu.append( "/Scene/Object/Merge Meshes", GafferScene.MergeMeshes, searchText = "MergeMeshes" ) nodeMenu.append( "/Scene/Object/Mesh Subdivide", GafferScene.MeshTessellate, searchText = "MeshTessellate" ) nodeMenu.append( "/Scene/Object/Camera Tweaks", GafferScene.CameraTweaks, searchText = "CameraTweaks" ) nodeMenu.append( "/Scene/Object/Curve Sampler", GafferScene.CurveSampler, searchText = "CurveSampler" )