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 AbcPropError(Exception):
58 """Raised when AbstractAlembicTest.abcprop() finds an error."""
61 class AbstractAlembicTest(unittest.TestCase):
66 cls.blender = args.blender
67 cls.testdir = pathlib.Path(args.testdir)
68 cls.alembic_root = pathlib.Path(args.alembic_root)
70 # 'abcls' outputs ANSI colour codes, even when stdout is not a terminal.
71 # See https://github.com/alembic/alembic/issues/120
72 cls.ansi_remove_re = re.compile(rb'\x1b[^m]*m')
74 # 'abcls' array notation, like "name[16]"
75 cls.abcls_array = re.compile(r'^(?P<name>[^\[]+)(\[(?P<arraysize>\d+)\])?$')
77 def run_blender(self, filepath: str, python_script: str, timeout: int=300) -> str:
78 """Runs Blender by opening a blendfile and executing a script.
80 Returns Blender's stdout + stderr combined into one string.
82 :param filepath: taken relative to self.testdir.
83 :param timeout: in seconds
86 blendfile = self.testdir / filepath
96 '--python-exit-code', '47',
97 '--python-expr', python_script,
100 proc = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
102 output = proc.stdout.decode('utf8')
104 self.fail('Error %d running Blender:\n%s' % (proc.returncode, output))
108 def abcprop(self, filepath: pathlib.Path, proppath: str) -> dict:
109 """Uses abcls to obtain compound property values from an Alembic object.
111 A dict of subproperties is returned, where the values are Python values.
113 The Python bindings for Alembic are old, and only compatible with Python 2.x,
114 so that's why we can't use them here, and have to rely on other tooling.
118 abcls = self.alembic_root / 'bin' / 'abcls'
120 command = (str(abcls), '-vl', '%s%s' % (filepath, proppath))
121 proc = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
124 coloured_output = proc.stdout
125 output = self.ansi_remove_re.sub(b'', coloured_output).decode('utf8')
128 raise AbcPropError('Error %d running abcls:\n%s' % (proc.returncode, output))
130 # Mapping from value type to callable that can convert a string to Python values.
143 # Ideally we'd get abcls to output JSON, see https://github.com/alembic/alembic/issues/121
144 lines = collections.deque(output.split('\n'))
146 info = lines.popleft()
152 if proptype == 'CompoundProperty':
153 # To read those, call self.abcprop() on it.
155 valtype_and_arrsize, name_and_extent = parts[1:]
157 # Parse name and extent
158 m = self.abcls_array.match(name_and_extent)
160 self.fail('Unparsable name/extent from abcls: %s' % name_and_extent)
161 name, extent = m.group('name'), m.group('arraysize')
164 self.fail('Unsupported extent %s for property %s/%s' % (extent, proppath, name))
167 m = self.abcls_array.match(valtype_and_arrsize)
169 self.fail('Unparsable value type from abcls: %s' % valtype_and_arrsize)
170 valtype, scalarsize = m.group('name'), m.group('arraysize')
174 conv = converters[valtype]
176 self.fail('Unsupported type %s for property %s/%s' % (valtype, proppath, name))
178 def convert_single_line(linevalue):
180 if scalarsize is None:
181 return conv(linevalue)
183 return [conv(v.strip()) for v in linevalue.split(',')]
184 except ValueError as ex:
187 if proptype == 'ScalarProperty':
188 value = lines.popleft()
189 result[name] = convert_single_line(value)
190 elif proptype == 'ArrayProperty':
192 # Arrays consist of a variable number of items, and end in a blank line.
194 linevalue = lines.popleft()
197 arrayvalue.append(convert_single_line(linevalue))
198 result[name] = arrayvalue
200 self.fail('Unsupported type %s for property %s/%s' % (proptype, proppath, name))
204 def assertAlmostEqualFloatArray(self, actual, expect, places=6, delta=None):
205 """Asserts that the arrays of floats are almost equal."""
207 self.assertEqual(len(actual), len(expect),
208 'Actual array has %d items, expected %d' % (len(actual), len(expect)))
210 for idx, (act, exp) in enumerate(zip(actual, expect)):
211 self.assertAlmostEqual(act, exp, places=places, delta=delta,
212 msg='%f != %f at index %d' % (act, exp, idx))
215 class HierarchicalAndFlatExportTest(AbstractAlembicTest):
217 def test_hierarchical_export(self, tempdir: pathlib.Path):
218 abc = tempdir / 'cubes_hierarchical.abc'
219 script = "import bpy; bpy.ops.wm.alembic_export(filepath='%s', start=1, end=1, " \
220 "renderable_only=True, visible_layers_only=True, flatten=False)" % abc
221 self.run_blender('cubes-hierarchy.blend', script)
223 # Now check the resulting Alembic file.
224 xform = self.abcprop(abc, '/Cube/Cube_002/Cube_012/.xform')
225 self.assertEqual(1, xform['.inherits'])
226 self.assertAlmostEqualFloatArray(
231 3.07484, -2.92265, 0.0586434, 1.0]
235 def test_flat_export(self, tempdir: pathlib.Path):
236 abc = tempdir / 'cubes_flat.abc'
237 script = "import bpy; bpy.ops.wm.alembic_export(filepath='%s', start=1, end=1, " \
238 "renderable_only=True, visible_layers_only=True, flatten=True)" % abc
239 self.run_blender('cubes-hierarchy.blend', script)
241 # Now check the resulting Alembic file.
242 xform = self.abcprop(abc, '/Cube_012/.xform')
243 self.assertEqual(0, xform['.inherits'])
245 self.assertAlmostEqualFloatArray(
247 [0.343134, 0.485243, 0.804238, 0,
248 0.0, 0.856222, -0.516608, 0,
249 -0.939287, 0.177266, 0.293799, 0,
254 class DupliGroupExportTest(AbstractAlembicTest):
256 def test_hierarchical_export(self, tempdir: pathlib.Path):
257 abc = tempdir / 'dupligroup_hierarchical.abc'
258 script = "import bpy; bpy.ops.wm.alembic_export(filepath='%s', start=1, end=1, " \
259 "renderable_only=True, visible_layers_only=True, flatten=False)" % abc
260 self.run_blender('dupligroup-scene.blend', script)
262 # Now check the resulting Alembic file.
263 xform = self.abcprop(abc, '/Real_Cube/Linked_Suzanne/Cylinder/Suzanne/.xform')
264 self.assertEqual(1, xform['.inherits'])
265 self.assertAlmostEqualFloatArray(
274 def test_flat_export(self, tempdir: pathlib.Path):
275 abc = tempdir / 'dupligroup_hierarchical.abc'
276 script = "import bpy; bpy.ops.wm.alembic_export(filepath='%s', start=1, end=1, " \
277 "renderable_only=True, visible_layers_only=True, flatten=True)" % abc
278 self.run_blender('dupligroup-scene.blend', script)
280 # Now check the resulting Alembic file.
281 xform = self.abcprop(abc, '/Suzanne/.xform')
282 self.assertEqual(0, xform['.inherits'])
284 self.assertAlmostEqualFloatArray(
293 class CurveExportTest(AbstractAlembicTest):
295 def test_export_single_curve(self, tempdir: pathlib.Path):
296 abc = tempdir / 'single-curve.abc'
297 script = "import bpy; bpy.ops.wm.alembic_export(filepath='%s', start=1, end=1, " \
298 "renderable_only=True, visible_layers_only=True, flatten=False)" % abc
299 self.run_blender('single-curve.blend', script)
301 # Now check the resulting Alembic file.
302 abcprop = self.abcprop(abc, '/NurbsCurve/NurbsCurveShape/.geom')
303 self.assertEqual(abcprop['.orders'], [4])
305 abcprop = self.abcprop(abc, '/NurbsCurve/NurbsCurveShape/.geom/.userProperties')
306 self.assertEqual(abcprop['blender:resolution'], 10)
309 class HairParticlesExportTest(AbstractAlembicTest):
310 """Tests exporting with/without hair/particles.
312 Just a basic test to ensure that the enabling/disabling works, and that export
313 works at all. NOT testing the quality/contents of the exported file.
316 def _do_test(self, tempdir: pathlib.Path, export_hair: bool, export_particles: bool) -> pathlib.Path:
317 abc = tempdir / 'hair-particles.abc'
318 script = "import bpy; bpy.ops.wm.alembic_export(filepath='%s', start=1, end=1, " \
319 "renderable_only=True, visible_layers_only=True, flatten=False, " \
320 "export_hair=%r, export_particles=%r)" % (abc, export_hair, export_particles)
321 self.run_blender('hair-particles.blend', script)
325 def test_with_both(self, tempdir: pathlib.Path):
326 abc = self._do_test(tempdir, True, True)
328 abcprop = self.abcprop(abc, '/Suzanne/Hair system/.geom')
329 self.assertIn('nVertices', abcprop)
331 abcprop = self.abcprop(abc, '/Suzanne/Non-hair particle system/.geom')
332 self.assertIn('.velocities', abcprop)
334 abcprop = self.abcprop(abc, '/Suzanne/SuzanneShape/.geom')
335 self.assertIn('.faceIndices', abcprop)
338 def test_with_hair_only(self, tempdir: pathlib.Path):
339 abc = self._do_test(tempdir, True, False)
341 abcprop = self.abcprop(abc, '/Suzanne/Hair system/.geom')
342 self.assertIn('nVertices', abcprop)
344 self.assertRaises(AbcPropError, self.abcprop, abc,
345 '/Suzanne/Non-hair particle system/.geom')
347 abcprop = self.abcprop(abc, '/Suzanne/SuzanneShape/.geom')
348 self.assertIn('.faceIndices', abcprop)
351 def test_with_particles_only(self, tempdir: pathlib.Path):
352 abc = self._do_test(tempdir, False, True)
354 self.assertRaises(AbcPropError, self.abcprop, abc, '/Suzanne/Hair system/.geom')
356 abcprop = self.abcprop(abc, '/Suzanne/Non-hair particle system/.geom')
357 self.assertIn('.velocities', abcprop)
359 abcprop = self.abcprop(abc, '/Suzanne/SuzanneShape/.geom')
360 self.assertIn('.faceIndices', abcprop)
363 def test_with_neither(self, tempdir: pathlib.Path):
364 abc = self._do_test(tempdir, False, False)
366 self.assertRaises(AbcPropError, self.abcprop, abc, '/Suzanne/Hair system/.geom')
367 self.assertRaises(AbcPropError, self.abcprop, abc,
368 '/Suzanne/Non-hair particle system/.geom')
370 abcprop = self.abcprop(abc, '/Suzanne/SuzanneShape/.geom')
371 self.assertIn('.faceIndices', abcprop)
374 if __name__ == '__main__':
375 parser = argparse.ArgumentParser()
376 parser.add_argument('--blender', required=True)
377 parser.add_argument('--testdir', required=True)
378 parser.add_argument('--alembic-root', required=True)
379 args, remaining = parser.parse_known_args()
381 unittest.main(argv=sys.argv[0:1] + remaining)