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')
127 # Because of the ANSI colour codes, we need to remove those first before
128 # decoding to text. This means that we cannot use the universal_newlines
129 # parameter to subprocess.run(), and have to do the conversion ourselves
130 output = output.replace('\r\n', '\n').replace('\r', '\n')
133 raise AbcPropError('Error %d running abcls:\n%s' % (proc.returncode, output))
135 # Mapping from value type to callable that can convert a string to Python values.
148 # Ideally we'd get abcls to output JSON, see https://github.com/alembic/alembic/issues/121
149 lines = collections.deque(output.split('\n'))
151 info = lines.popleft()
157 if proptype == 'CompoundProperty':
158 # To read those, call self.abcprop() on it.
160 valtype_and_arrsize, name_and_extent = parts[1:]
162 # Parse name and extent
163 m = self.abcls_array.match(name_and_extent)
165 self.fail('Unparsable name/extent from abcls: %s' % name_and_extent)
166 name, extent = m.group('name'), m.group('arraysize')
169 self.fail('Unsupported extent %s for property %s/%s' % (extent, proppath, name))
172 m = self.abcls_array.match(valtype_and_arrsize)
174 self.fail('Unparsable value type from abcls: %s' % valtype_and_arrsize)
175 valtype, scalarsize = m.group('name'), m.group('arraysize')
179 conv = converters[valtype]
181 self.fail('Unsupported type %s for property %s/%s' % (valtype, proppath, name))
183 def convert_single_line(linevalue):
185 if scalarsize is None:
186 return conv(linevalue)
188 return [conv(v.strip()) for v in linevalue.split(',')]
189 except ValueError as ex:
192 if proptype == 'ScalarProperty':
193 value = lines.popleft()
194 result[name] = convert_single_line(value)
195 elif proptype == 'ArrayProperty':
197 # Arrays consist of a variable number of items, and end in a blank line.
199 linevalue = lines.popleft()
202 arrayvalue.append(convert_single_line(linevalue))
203 result[name] = arrayvalue
205 self.fail('Unsupported type %s for property %s/%s' % (proptype, proppath, name))
209 def assertAlmostEqualFloatArray(self, actual, expect, places=6, delta=None):
210 """Asserts that the arrays of floats are almost equal."""
212 self.assertEqual(len(actual), len(expect),
213 'Actual array has %d items, expected %d' % (len(actual), len(expect)))
215 for idx, (act, exp) in enumerate(zip(actual, expect)):
216 self.assertAlmostEqual(act, exp, places=places, delta=delta,
217 msg='%f != %f at index %d' % (act, exp, idx))
220 class HierarchicalAndFlatExportTest(AbstractAlembicTest):
222 def test_hierarchical_export(self, tempdir: pathlib.Path):
223 abc = tempdir / 'cubes_hierarchical.abc'
224 script = "import bpy; bpy.ops.wm.alembic_export(filepath='%s', start=1, end=1, " \
225 "renderable_only=True, visible_layers_only=True, flatten=False)" % abc.as_posix()
226 self.run_blender('cubes-hierarchy.blend', script)
228 # Now check the resulting Alembic file.
229 xform = self.abcprop(abc, '/Cube/Cube_002/Cube_012/.xform')
230 self.assertEqual(1, xform['.inherits'])
231 self.assertAlmostEqualFloatArray(
236 3.07484, -2.92265, 0.0586434, 1.0]
240 def test_flat_export(self, tempdir: pathlib.Path):
241 abc = tempdir / 'cubes_flat.abc'
242 script = "import bpy; bpy.ops.wm.alembic_export(filepath='%s', start=1, end=1, " \
243 "renderable_only=True, visible_layers_only=True, flatten=True)" % abc.as_posix()
244 self.run_blender('cubes-hierarchy.blend', script)
246 # Now check the resulting Alembic file.
247 xform = self.abcprop(abc, '/Cube_012/.xform')
248 self.assertEqual(0, xform['.inherits'])
250 self.assertAlmostEqualFloatArray(
252 [0.343134, 0.485243, 0.804238, 0,
253 0.0, 0.856222, -0.516608, 0,
254 -0.939287, 0.177266, 0.293799, 0,
259 class DupliGroupExportTest(AbstractAlembicTest):
261 def test_hierarchical_export(self, tempdir: pathlib.Path):
262 abc = tempdir / 'dupligroup_hierarchical.abc'
263 script = "import bpy; bpy.ops.wm.alembic_export(filepath='%s', start=1, end=1, " \
264 "renderable_only=True, visible_layers_only=True, flatten=False)" % abc.as_posix()
265 self.run_blender('dupligroup-scene.blend', script)
267 # Now check the resulting Alembic file.
268 xform = self.abcprop(abc, '/Real_Cube/Linked_Suzanne/Cylinder/Suzanne/.xform')
269 self.assertEqual(1, xform['.inherits'])
270 self.assertAlmostEqualFloatArray(
279 def test_flat_export(self, tempdir: pathlib.Path):
280 abc = tempdir / 'dupligroup_hierarchical.abc'
281 script = "import bpy; bpy.ops.wm.alembic_export(filepath='%s', start=1, end=1, " \
282 "renderable_only=True, visible_layers_only=True, flatten=True)" % abc.as_posix()
283 self.run_blender('dupligroup-scene.blend', script)
285 # Now check the resulting Alembic file.
286 xform = self.abcprop(abc, '/Suzanne/.xform')
287 self.assertEqual(0, xform['.inherits'])
289 self.assertAlmostEqualFloatArray(
298 class CurveExportTest(AbstractAlembicTest):
300 def test_export_single_curve(self, tempdir: pathlib.Path):
301 abc = tempdir / 'single-curve.abc'
302 script = "import bpy; bpy.ops.wm.alembic_export(filepath='%s', start=1, end=1, " \
303 "renderable_only=True, visible_layers_only=True, flatten=False)" % abc.as_posix()
304 self.run_blender('single-curve.blend', script)
306 # Now check the resulting Alembic file.
307 abcprop = self.abcprop(abc, '/NurbsCurve/NurbsCurveShape/.geom')
308 self.assertEqual(abcprop['.orders'], [4])
310 abcprop = self.abcprop(abc, '/NurbsCurve/NurbsCurveShape/.geom/.userProperties')
311 self.assertEqual(abcprop['blender:resolution'], 10)
314 class HairParticlesExportTest(AbstractAlembicTest):
315 """Tests exporting with/without hair/particles.
317 Just a basic test to ensure that the enabling/disabling works, and that export
318 works at all. NOT testing the quality/contents of the exported file.
321 def _do_test(self, tempdir: pathlib.Path, export_hair: bool, export_particles: bool) -> pathlib.Path:
322 abc = tempdir / 'hair-particles.abc'
323 script = "import bpy; bpy.ops.wm.alembic_export(filepath='%s', start=1, end=1, " \
324 "renderable_only=True, visible_layers_only=True, flatten=False, " \
325 "export_hair=%r, export_particles=%r, as_background_job=False)" \
326 % (abc.as_posix(), export_hair, export_particles)
327 self.run_blender('hair-particles.blend', script)
331 def test_with_both(self, tempdir: pathlib.Path):
332 abc = self._do_test(tempdir, True, True)
334 abcprop = self.abcprop(abc, '/Suzanne/Hair system/.geom')
335 self.assertIn('nVertices', abcprop)
337 abcprop = self.abcprop(abc, '/Suzanne/Non-hair particle system/.geom')
338 self.assertIn('.velocities', abcprop)
340 abcprop = self.abcprop(abc, '/Suzanne/SuzanneShape/.geom')
341 self.assertIn('.faceIndices', abcprop)
344 def test_with_hair_only(self, tempdir: pathlib.Path):
345 abc = self._do_test(tempdir, True, False)
347 abcprop = self.abcprop(abc, '/Suzanne/Hair system/.geom')
348 self.assertIn('nVertices', abcprop)
350 self.assertRaises(AbcPropError, self.abcprop, abc,
351 '/Suzanne/Non-hair particle system/.geom')
353 abcprop = self.abcprop(abc, '/Suzanne/SuzanneShape/.geom')
354 self.assertIn('.faceIndices', abcprop)
357 def test_with_particles_only(self, tempdir: pathlib.Path):
358 abc = self._do_test(tempdir, False, True)
360 self.assertRaises(AbcPropError, self.abcprop, abc, '/Suzanne/Hair system/.geom')
362 abcprop = self.abcprop(abc, '/Suzanne/Non-hair particle system/.geom')
363 self.assertIn('.velocities', abcprop)
365 abcprop = self.abcprop(abc, '/Suzanne/SuzanneShape/.geom')
366 self.assertIn('.faceIndices', abcprop)
369 def test_with_neither(self, tempdir: pathlib.Path):
370 abc = self._do_test(tempdir, False, False)
372 self.assertRaises(AbcPropError, self.abcprop, abc, '/Suzanne/Hair system/.geom')
373 self.assertRaises(AbcPropError, self.abcprop, abc,
374 '/Suzanne/Non-hair particle system/.geom')
376 abcprop = self.abcprop(abc, '/Suzanne/SuzanneShape/.geom')
377 self.assertIn('.faceIndices', abcprop)
380 class LongNamesExportTest(AbstractAlembicTest):
382 def test_export_long_names(self, tempdir: pathlib.Path):
383 abc = tempdir / 'long-names.abc'
384 script = "import bpy; bpy.ops.wm.alembic_export(filepath='%s', start=1, end=1, " \
385 "renderable_only=False, visible_layers_only=False, flatten=False)" % abc.as_posix()
386 self.run_blender('long-names.blend', script)
389 'foG9aeLahgoh5goacee1dah6Hethaghohjaich5pasizairuWigee1ahPeekiGh',
390 'yoNgoisheedah2ua0eigh2AeCaiTee5bo0uphoo7Aixephah9racahvaingeeH4',
391 'zuthohnoi1thooS3eezoo8seuph2Boo5aefacaethuvee1aequoonoox1sookie',
392 'wugh4ciTh3dipiepeequait5uug7thiseek5ca7Eijei5ietaizokohhaecieto',
393 'up9aeheenein9oteiX6fohP3thiez6Ahvah0oohah1ep2Eesho4Beboechaipoh',
394 'coh4aehiacheTh0ue0eegho9oku1lohl4loht9ohPoongoow7dasiego6yimuis',
395 'lohtho8eigahfeipohviepajaix4it2peeQu6Iefee1nevihaes4cee2soh4noy',
396 'kaht9ahv0ieXaiyih7ohxe8bah7eeyicahjoa2ohbu7Choxua7oongah6sei4bu',
397 'deif0iPaechohkee5nahx6oi2uJeeN7ze3seunohJibe4shai0mah5Iesh3Quai',
398 'ChohDahshooNee0NeNohthah0eiDeese3Vu6ohShil1Iey9ja0uebi2quiShae6',
399 'Dee1kai7eiph2ahh2nufah3zai3eexeengohQue1caj0eeW0xeghi3eshuadoot',
400 'aeshiup3aengajoog0AhCoo5tiu3ieghaeGhie4Tu1ohh1thee8aepheingah1E',
401 'ooRa6ahciolohshaifoopeo9ZeiGhae2aech4raisheiWah9AaNga0uas9ahquo',
402 'thaepheip2aip6shief4EaXopei8ohPo0ighuiXah2ashowai9nohp4uach6Mei',
403 'ohph4yaev3quieji3phophiem3OoNuisheepahng4waithae3Naichai7aw3noo',
404 'aibeawaneBahmieyuph8ieng8iopheereeD2uu9Uyee5bei2phahXeir8eeJ8oo',
405 'ooshahphei2hoh3uth5chaen7ohsai6uutiesucheichai8ungah9Gie1Aiphie',
406 'eiwohchoo7ere2iebohn4Aapheichaelooriiyaoxaik7ooqua7aezahx0aeJei',
407 'Vah0ohgohphiefohTheshieghichaichahch5moshoo0zai5eeva7eisi4yae8T',
408 'EibeeN0fee0Gohnguz8iec6yeigh7shuNg4eingu3siph9joucahpeidoom4ree',
409 'iejiu3shohheeZahHusheimeefaihoh5eecachu5eeZie9ceisugu9taidohT3U',
410 'eex6dilakaix5Eetai7xiCh5Jaa8aiD4Ag3tuij1aijohv5fo0heevah8hohs3m',
411 'ohqueeNgahraew6uraemohtoo5qua3oojiex6ohqu6Aideibaithaiphuriquie',
412 'cei0eiN4Shiey7Aeluy3unohboo5choiphahc2mahbei5paephaiKeso1thoog1',
413 'ieghif4ohKequ7ong0jah5ooBah0eiGh1caechahnahThae9Shoo0phopashoo4',
414 'roh9er3thohwi5am8iequeequuSh3aic0voocai3ihi5nie2abahphupiegh7vu',
415 'uv3Quei7wujoo5beingei2aish5op4VaiX0aebai7iwoaPee5pei8ko9IepaPig',
416 'co7aegh5beitheesi9lu7jeeQu3johgeiphee9cheichi8aithuDehu2gaeNein',
417 'thai3Tiewoo4nuir1ohy4aithiuZ7shae1luuwei5phibohriepe2paeci1Ach8',
418 'phoi3ribah7ufuvoh8eigh1oB6deeBaiPohphaghiPieshahfah5EiCi3toogoo',
419 'aiM8geil7ooreinee4Cheiwea4yeec8eeshi7Sei4Shoo3wu6ohkaNgooQu1mai',
420 'agoo3faciewah9ZeesiXeereek7am0eigaeShie3Tisu8haReeNgoo0ci2Hae5u',
421 'Aesatheewiedohshaephaenohbooshee8eu7EiJ8isal1laech2eiHo0noaV3ta',
422 'liunguep3ooChoo4eir8ahSie8eenee0oo1TooXu8Cais8Aimo4eir6Phoo3xei',
423 'toe9heepeobein3teequachemei0Cejoomef9ujie3ohwae9AiNgiephi3ep0de',
424 'ua6xooY9uzaeB3of6sheiyaedohoiS5Eev0Aequ9ahm1zoa5Aegh3ooz9ChahDa',
425 'eevasah6Bu9wi7EiwiequumahkaeCheegh6lui8xoh4eeY4ieneavah8phaibun',
426 'AhNgei2sioZeeng6phaecheemeehiShie5eFeiTh6ooV8iiphabud0die4siep4',
427 'kushe6Xieg6ahQuoo9aex3aipheefiec1esa7OhBuG0ueziep9phai5eegh1vie',
428 'Jie5yu8aafuQuoh9shaep3moboh3Pooy7och8oC6obeik6jaew2aiLooweib3ch',
429 'ohohjajaivaiRail3odaimei6aekohVaicheip2wu7phieg5Gohsaing2ahxaiy',
430 'hahzaht6yaiYu9re9jah9loisiit4ahtoh2quoh9xohishioz4oo4phofu3ogha',
431 'pu4oorea0uh2tahB8aiZoonge1aophaes6ogaiK9ailaigeej4zoVou8ielotee',
432 'cae2thei3Luphuqu0zeeG8leeZuchahxaicai4ui4Eedohte9uW6gae8Geeh0ea',
433 'air7tuy7ohw5sho2Tahpai8aep4so5ria7eaShus5weaqu0Naquei2xaeyoo2ae',
434 'vohge4aeCh7ahwoo7Jaex6sohl0Koong4Iejisei8Coir0iemeiz9uru9Iebaep',
435 'aepeidie8aiw6waish9gie4Woolae2thuj5phae4phexux7gishaeph4Deu7ooS',
436 'vahc5ia0xohHooViT0uyuxookiaquu2ogueth0ahquoudeefohshai8aeThahba',
437 'mun3oagah2eequaenohfoo8DaigeghoozaV2eiveeQuee7kah0quaa6tiesheet',
438 'ooSet4IdieC4ugow3za0die4ohGoh1oopoh6luaPhaeng4Eechea1hae0eimie5',
439 'iedeimadaefu2NeiPaey2jooloov5iehiegeakoo4ueso7aeK9ahqu2Thahkaes',
440 'nahquah9Quuu2uuf0aJah7eishi2siegh8ue5eiJa2EeVu8ebohkepoh4dahNgo',
441 'io1bie7chioPiej5ae2oohe2fee6ooP2thaeJohjohb9Se8tang3eipaifeimai',
442 'oungoqu6dieneejiechez1xeD2Zi9iox2Ahchaiy9ithah3ohVoolu2euQuuawo',
443 'thaew0veigei4neishohd8mecaixuqu7eeshiex1chaigohmoThoghoitoTa0Eo',
444 'ahroob2phohvaiz0Ohteik2ohtakie6Iu1vitho8IyiyeeleeShae9defaiw9ki',
445 'DohHoothohzeaxolai3Toh5eJie7ahlah9reF0ohn1chaipoogain2aibahw4no',
446 'aif8lo5she4aich5cho2rie8ieJaujeem2Joongeedae4vie3tah1Leequaix1O',
447 'Aang0Shaih6chahthie1ahZ7aewei9thiethee7iuThah3yoongi8ahngiobaa5',
448 'iephoBuayoothah0Ru6aichai4aiw8deg1umongauvaixai3ohy6oowohlee8ei',
449 'ohn5shigoameer0aejohgoh8oChohlaecho9jie6shu0ahg9Bohngau6paevei9',
450 'edahghaishak0paigh1eecuich3aad7yeB0ieD6akeeliem2beifufaekee6eat',
451 'hiechahgheloh2zo7Ieghaiph0phahhu8aeyuiKie1xeipheech9zai4aeme0ee',
454 name = '/' + '/'.join(name_parts)
456 # Now check the resulting Alembic file.
457 abcprop = self.abcprop(abc, '%s/.xform' % name)
458 self.assertEqual(abcprop['.vals'], [
465 abcprop = self.abcprop(abc, '%s/CubeShape/.geom' % name)
466 self.assertIn('.faceCounts', abcprop)
469 if __name__ == '__main__':
470 parser = argparse.ArgumentParser()
471 parser.add_argument('--blender', required=True)
472 parser.add_argument('--testdir', required=True)
473 parser.add_argument('--alembic-root', required=True)
474 args, remaining = parser.parse_known_args()
476 unittest.main(argv=sys.argv[0:1] + remaining)