2 # ##### BEGIN GPL LICENSE BLOCK #####
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software Foundation,
16 # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 # ##### END GPL LICENSE BLOCK #####
32 def with_tempdir(wrapped):
33 """Creates a temporary directory for the function, cleaning up after it returns normally.
35 When the wrapped function raises an exception, the contents of the temporary directory
36 remain available for manual inspection.
38 The wrapped function is called with an extra positional argument containing
39 the pathlib.Path() of the temporary directory.
42 @functools.wraps(wrapped)
43 def decorator(*args, **kwargs):
44 dirname = tempfile.mkdtemp(prefix='blender-alembic-test')
46 retval = wrapped(*args, pathlib.Path(dirname), **kwargs)
48 print('Exception in %s, not cleaning up temporary directory %s' % (wrapped, dirname))
51 shutil.rmtree(dirname)
57 class AbstractAlembicTest(unittest.TestCase):
62 parser = argparse.ArgumentParser()
63 parser.add_argument('--blender', required=True)
64 parser.add_argument('--testdir', required=True)
65 parser.add_argument('--alembic-root', required=True)
66 args, _ = parser.parse_known_args()
68 cls.blender = args.blender
69 cls.testdir = pathlib.Path(args.testdir)
70 cls.alembic_root = pathlib.Path(args.alembic_root)
72 # 'abcls' outputs ANSI colour codes, even when stdout is not a terminal.
73 # See https://github.com/alembic/alembic/issues/120
74 cls.ansi_remove_re = re.compile(rb'\x1b[^m]*m')
76 # 'abcls' array notation, like "name[16]"
77 cls.abcls_array = re.compile(r'^(?P<name>[^\[]+)(\[(?P<arraysize>\d+)\])?$')
79 def run_blender(self, filepath: str, python_script: str, timeout: int=300) -> str:
80 """Runs Blender by opening a blendfile and executing a script.
82 Returns Blender's stdout + stderr combined into one string.
84 :param filepath: taken relative to self.testdir.
85 :param timeout: in seconds
88 blendfile = self.testdir / filepath
98 '--python-exit-code', '47',
99 '--python-expr', python_script,
102 proc = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
104 output = proc.stdout.decode('utf8')
106 self.fail('Error %d running Blender:\n%s' % (proc.returncode, output))
110 def abcprop(self, filepath: pathlib.Path, proppath: str) -> dict:
111 """Uses abcls to obtain compound property values from an Alembic object.
113 A dict of subproperties is returned, where the values are just strings
114 as returned by abcls.
116 The Python bindings for Alembic are old, and only compatible with Python 2.x,
117 so that's why we can't use them here, and have to rely on other tooling.
120 abcls = self.alembic_root / 'bin' / 'abcls'
122 command = (str(abcls), '-vl', '%s%s' % (filepath, proppath))
123 proc = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
126 coloured_output = proc.stdout
127 output = self.ansi_remove_re.sub(b'', coloured_output).decode('utf8')
130 self.fail('Error %d running abcls:\n%s' % (proc.returncode, output))
132 # Mapping from value type to callable that can convert a string to Python values.
141 # Ideally we'd get abcls to output JSON, see https://github.com/alembic/alembic/issues/121
142 lines = output.split('\n')
143 for info, value in zip(lines[0::2], lines[1::2]):
144 proptype, valtype_and_arrsize, name_and_extent = info.split()
146 # Parse name and extent
147 m = self.abcls_array.match(name_and_extent)
149 self.fail('Unparsable name/extent from abcls: %s' % name_and_extent)
150 name, extent = m.group('name'), m.group('arraysize')
153 self.fail('Unsupported extent %s for property %s/%s' % (extent, proppath, name))
155 # Parse type and convert values
156 m = self.abcls_array.match(valtype_and_arrsize)
158 self.fail('Unparsable value type from abcls: %s' % valtype_and_arrsize)
159 valtype, arraysize = m.group('name'), m.group('arraysize')
162 conv = converters[valtype]
164 self.fail('Unsupported type %s for property %s/%s' % (valtype, proppath, name))
166 if arraysize is None:
167 result[name] = conv(value)
169 values = [conv(v.strip()) for v in value.split(',')]
170 result[name] = values
174 def assertAlmostEqualFloatArray(self, actual, expect, places=6, delta=None):
175 """Asserts that the arrays of floats are almost equal."""
177 self.assertEqual(len(actual), len(expect),
178 'Actual array has %d items, expected %d' % (len(actual), len(expect)))
180 for idx, (act, exp) in enumerate(zip(actual, expect)):
181 self.assertAlmostEqual(act, exp, places=places, delta=delta,
182 msg='%f != %f at index %d' % (act, exp, idx))
185 class HierarchicalAndFlatExportTest(AbstractAlembicTest):
187 def test_hierarchical_export(self, tempdir: pathlib.Path):
188 abc = tempdir / 'cubes_hierarchical.abc'
189 script = "import bpy; bpy.ops.wm.alembic_export(filepath='%s', start=1, end=1, " \
190 "renderable_only=True, visible_layers_only=True, flatten=False)" % abc
191 self.run_blender('cubes-hierarchy.blend', script)
193 # Now check the resulting Alembic file.
194 xform = self.abcprop(abc, '/Cube/Cube_002/Cube_012/.xform')
195 self.assertEqual(1, xform['.inherits'])
196 self.assertAlmostEqualFloatArray(
201 3.07484, -2.92265, 0.0586434, 1.0]
205 def test_flat_export(self, tempdir: pathlib.Path):
206 abc = tempdir / 'cubes_flat.abc'
207 script = "import bpy; bpy.ops.wm.alembic_export(filepath='%s', start=1, end=1, " \
208 "renderable_only=True, visible_layers_only=True, flatten=True)" % abc
209 self.run_blender('cubes-hierarchy.blend', script)
211 # Now check the resulting Alembic file.
212 xform = self.abcprop(abc, '/Cube_012/.xform')
213 self.assertEqual(0, xform['.inherits'])
215 self.assertAlmostEqualFloatArray(
217 [0.343134, 0.485243, 0.804238, 0,
218 0.0, 0.856222, -0.516608, 0,
219 -0.939287, 0.177266, 0.293799, 0,
224 class DupliGroupExportTest(AbstractAlembicTest):
226 def test_hierarchical_export(self, tempdir: pathlib.Path):
227 abc = tempdir / 'dupligroup_hierarchical.abc'
228 script = "import bpy; bpy.ops.wm.alembic_export(filepath='%s', start=1, end=1, " \
229 "renderable_only=True, visible_layers_only=True, flatten=False)" % abc
230 self.run_blender('dupligroup-scene.blend', script)
232 # Now check the resulting Alembic file.
233 xform = self.abcprop(abc, '/Real_Cube/Linked_Suzanne/Cylinder/Suzanne/.xform')
234 self.assertEqual(1, xform['.inherits'])
235 self.assertAlmostEqualFloatArray(
244 def test_flat_export(self, tempdir: pathlib.Path):
245 abc = tempdir / 'dupligroup_hierarchical.abc'
246 script = "import bpy; bpy.ops.wm.alembic_export(filepath='%s', start=1, end=1, " \
247 "renderable_only=True, visible_layers_only=True, flatten=True)" % abc
248 self.run_blender('dupligroup-scene.blend', script)
250 # Now check the resulting Alembic file.
251 xform = self.abcprop(abc, '/Suzanne/.xform')
252 self.assertEqual(0, xform['.inherits'])
254 self.assertAlmostEqualFloatArray(
265 if __name__ == '__main__':
266 unittest.main(argv=sys.argv[0:1])