Hide object center and other info when drawing sets
[blender.git] / tests / python / alembic_tests.py
1 #!/usr/bin/env python3
2 # ##### BEGIN GPL LICENSE BLOCK #####
3 #
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.
8 #
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.
13 #
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.
17 #
18 # ##### END GPL LICENSE BLOCK #####
19
20 # <pep8 compliant>
21
22 import argparse
23 import functools
24 import shutil
25 import pathlib
26 import subprocess
27 import sys
28 import tempfile
29 import unittest
30
31
32 def with_tempdir(wrapped):
33     """Creates a temporary directory for the function, cleaning up after it returns normally.
34
35     When the wrapped function raises an exception, the contents of the temporary directory
36     remain available for manual inspection.
37
38     The wrapped function is called with an extra positional argument containing
39     the pathlib.Path() of the temporary directory.
40     """
41
42     @functools.wraps(wrapped)
43     def decorator(*args, **kwargs):
44         dirname = tempfile.mkdtemp(prefix='blender-alembic-test')
45         try:
46             retval = wrapped(*args, pathlib.Path(dirname), **kwargs)
47         except:
48             print('Exception in %s, not cleaning up temporary directory %s' % (wrapped, dirname))
49             raise
50         else:
51             shutil.rmtree(dirname)
52         return retval
53
54     return decorator
55
56
57 class AbcPropError(Exception):
58     """Raised when AbstractAlembicTest.abcprop() finds an error."""
59
60
61 class AbstractAlembicTest(unittest.TestCase):
62     @classmethod
63     def setUpClass(cls):
64         import re
65
66         cls.blender = args.blender
67         cls.testdir = pathlib.Path(args.testdir)
68         cls.alembic_root = pathlib.Path(args.alembic_root)
69
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')
73
74         # 'abcls' array notation, like "name[16]"
75         cls.abcls_array = re.compile(r'^(?P<name>[^\[]+)(\[(?P<arraysize>\d+)\])?$')
76
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.
79
80         Returns Blender's stdout + stderr combined into one string.
81
82         :param filepath: taken relative to self.testdir.
83         :param timeout: in seconds
84         """
85
86         blendfile = self.testdir / filepath
87
88         command = (
89             self.blender,
90             '--background',
91             '-noaudio',
92             '--factory-startup',
93             '--enable-autoexec',
94             str(blendfile),
95             '-E', 'CYCLES',
96             '--python-exit-code', '47',
97             '--python-expr', python_script,
98         )
99
100         proc = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
101                               timeout=timeout)
102         output = proc.stdout.decode('utf8')
103         if proc.returncode:
104             self.fail('Error %d running Blender:\n%s' % (proc.returncode, output))
105
106         return output
107
108     def abcprop(self, filepath: pathlib.Path, proppath: str) -> dict:
109         """Uses abcls to obtain compound property values from an Alembic object.
110
111         A dict of subproperties is returned, where the values are Python values.
112
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.
115         """
116         import collections
117
118         abcls = self.alembic_root / 'bin' / 'abcls'
119
120         command = (str(abcls), '-vl', '%s%s' % (filepath, proppath))
121         proc = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
122                               timeout=30)
123
124         coloured_output = proc.stdout
125         output = self.ansi_remove_re.sub(b'', coloured_output).decode('utf8')
126
127         if proc.returncode:
128             raise AbcPropError('Error %d running abcls:\n%s' % (proc.returncode, output))
129
130         # Mapping from value type to callable that can convert a string to Python values.
131         converters = {
132             'bool_t': int,
133             'uint8_t': int,
134             'int16_t': int,
135             'int32_t': int,
136             'uint64_t': int,
137             'float64_t': float,
138             'float32_t': float,
139         }
140
141         result = {}
142
143         # Ideally we'd get abcls to output JSON, see https://github.com/alembic/alembic/issues/121
144         lines = collections.deque(output.split('\n'))
145         while lines:
146             info = lines.popleft()
147             if not info:
148                 continue
149             parts = info.split()
150             proptype = parts[0]
151
152             if proptype == 'CompoundProperty':
153                 # To read those, call self.abcprop() on it.
154                 continue
155             valtype_and_arrsize, name_and_extent = parts[1:]
156
157             # Parse name and extent
158             m = self.abcls_array.match(name_and_extent)
159             if not m:
160                 self.fail('Unparsable name/extent from abcls: %s' % name_and_extent)
161             name, extent = m.group('name'), m.group('arraysize')
162
163             if extent != '1':
164                 self.fail('Unsupported extent %s for property %s/%s' % (extent, proppath, name))
165
166             # Parse type
167             m = self.abcls_array.match(valtype_and_arrsize)
168             if not m:
169                 self.fail('Unparsable value type from abcls: %s' % valtype_and_arrsize)
170             valtype, scalarsize = m.group('name'), m.group('arraysize')
171
172             # Convert values
173             try:
174                 conv = converters[valtype]
175             except KeyError:
176                 self.fail('Unsupported type %s for property %s/%s' % (valtype, proppath, name))
177
178             def convert_single_line(linevalue):
179                 try:
180                     if scalarsize is None:
181                         return conv(linevalue)
182                     else:
183                         return [conv(v.strip()) for v in linevalue.split(',')]
184                 except ValueError as ex:
185                     return str(ex)
186
187             if proptype == 'ScalarProperty':
188                 value = lines.popleft()
189                 result[name] = convert_single_line(value)
190             elif proptype == 'ArrayProperty':
191                 arrayvalue = []
192                 # Arrays consist of a variable number of items, and end in a blank line.
193                 while True:
194                     linevalue = lines.popleft()
195                     if not linevalue:
196                         break
197                     arrayvalue.append(convert_single_line(linevalue))
198                 result[name] = arrayvalue
199             else:
200                 self.fail('Unsupported type %s for property %s/%s' % (proptype, proppath, name))
201
202         return result
203
204     def assertAlmostEqualFloatArray(self, actual, expect, places=6, delta=None):
205         """Asserts that the arrays of floats are almost equal."""
206
207         self.assertEqual(len(actual), len(expect),
208                          'Actual array has %d items, expected %d' % (len(actual), len(expect)))
209
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))
213
214
215 class HierarchicalAndFlatExportTest(AbstractAlembicTest):
216     @with_tempdir
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)
222
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(
227             xform['.vals'],
228             [1.0, 0.0, 0.0, 0.0,
229              0.0, 1.0, 0.0, 0.0,
230              0.0, 0.0, 1.0, 0.0,
231              3.07484, -2.92265, 0.0586434, 1.0]
232         )
233
234     @with_tempdir
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)
240
241         # Now check the resulting Alembic file.
242         xform = self.abcprop(abc, '/Cube_012/.xform')
243         self.assertEqual(0, xform['.inherits'])
244
245         self.assertAlmostEqualFloatArray(
246             xform['.vals'],
247             [0.343134, 0.485243, 0.804238, 0,
248              0.0, 0.856222, -0.516608, 0,
249              -0.939287, 0.177266, 0.293799, 0,
250              1, 3, 4, 1],
251         )
252
253
254 class DupliGroupExportTest(AbstractAlembicTest):
255     @with_tempdir
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)
261
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(
266             xform['.vals'],
267             [1.0, 0.0, 0.0, 0.0,
268              0.0, 1.0, 0.0, 0.0,
269              0.0, 0.0, 1.0, 0.0,
270              0.0, 2.0, 0.0, 1.0]
271         )
272
273     @with_tempdir
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)
279
280         # Now check the resulting Alembic file.
281         xform = self.abcprop(abc, '/Suzanne/.xform')
282         self.assertEqual(0, xform['.inherits'])
283
284         self.assertAlmostEqualFloatArray(
285             xform['.vals'],
286             [1.5, 0.0, 0.0, 0.0,
287              0.0, 1.5, 0.0, 0.0,
288              0.0, 0.0, 1.5, 0.0,
289              2.0, 3.0, 0.0, 1.0]
290         )
291
292
293 class CurveExportTest(AbstractAlembicTest):
294     @with_tempdir
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)
300
301         # Now check the resulting Alembic file.
302         abcprop = self.abcprop(abc, '/NurbsCurve/NurbsCurveShape/.geom')
303         self.assertEqual(abcprop['.orders'], [4])
304
305         abcprop = self.abcprop(abc, '/NurbsCurve/NurbsCurveShape/.geom/.userProperties')
306         self.assertEqual(abcprop['blender:resolution'], 10)
307
308
309 class HairParticlesExportTest(AbstractAlembicTest):
310     """Tests exporting with/without hair/particles.
311
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.
314     """
315
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, as_background_job=False)" \
321                  % (abc, export_hair, export_particles)
322         self.run_blender('hair-particles.blend', script)
323         return abc
324
325     @with_tempdir
326     def test_with_both(self, tempdir: pathlib.Path):
327         abc = self._do_test(tempdir, True, True)
328
329         abcprop = self.abcprop(abc, '/Suzanne/Hair system/.geom')
330         self.assertIn('nVertices', abcprop)
331
332         abcprop = self.abcprop(abc, '/Suzanne/Non-hair particle system/.geom')
333         self.assertIn('.velocities', abcprop)
334
335         abcprop = self.abcprop(abc, '/Suzanne/SuzanneShape/.geom')
336         self.assertIn('.faceIndices', abcprop)
337
338     @with_tempdir
339     def test_with_hair_only(self, tempdir: pathlib.Path):
340         abc = self._do_test(tempdir, True, False)
341
342         abcprop = self.abcprop(abc, '/Suzanne/Hair system/.geom')
343         self.assertIn('nVertices', abcprop)
344
345         self.assertRaises(AbcPropError, self.abcprop, abc,
346                           '/Suzanne/Non-hair particle system/.geom')
347
348         abcprop = self.abcprop(abc, '/Suzanne/SuzanneShape/.geom')
349         self.assertIn('.faceIndices', abcprop)
350
351     @with_tempdir
352     def test_with_particles_only(self, tempdir: pathlib.Path):
353         abc = self._do_test(tempdir, False, True)
354
355         self.assertRaises(AbcPropError, self.abcprop, abc, '/Suzanne/Hair system/.geom')
356
357         abcprop = self.abcprop(abc, '/Suzanne/Non-hair particle system/.geom')
358         self.assertIn('.velocities', abcprop)
359
360         abcprop = self.abcprop(abc, '/Suzanne/SuzanneShape/.geom')
361         self.assertIn('.faceIndices', abcprop)
362
363     @with_tempdir
364     def test_with_neither(self, tempdir: pathlib.Path):
365         abc = self._do_test(tempdir, False, False)
366
367         self.assertRaises(AbcPropError, self.abcprop, abc, '/Suzanne/Hair system/.geom')
368         self.assertRaises(AbcPropError, self.abcprop, abc,
369                           '/Suzanne/Non-hair particle system/.geom')
370
371         abcprop = self.abcprop(abc, '/Suzanne/SuzanneShape/.geom')
372         self.assertIn('.faceIndices', abcprop)
373
374
375 class LongNamesExportTest(AbstractAlembicTest):
376     @with_tempdir
377     def test_export_long_names(self, tempdir: pathlib.Path):
378         abc = tempdir / 'long-names.abc'
379         script = "import bpy; bpy.ops.wm.alembic_export(filepath='%s', start=1, end=1, " \
380                  "renderable_only=False, visible_layers_only=False, flatten=False)" % abc
381         self.run_blender('long-names.blend', script)
382
383         name_parts = [
384             'foG9aeLahgoh5goacee1dah6Hethaghohjaich5pasizairuWigee1ahPeekiGh',
385             'yoNgoisheedah2ua0eigh2AeCaiTee5bo0uphoo7Aixephah9racahvaingeeH4',
386             'zuthohnoi1thooS3eezoo8seuph2Boo5aefacaethuvee1aequoonoox1sookie',
387             'wugh4ciTh3dipiepeequait5uug7thiseek5ca7Eijei5ietaizokohhaecieto',
388             'up9aeheenein9oteiX6fohP3thiez6Ahvah0oohah1ep2Eesho4Beboechaipoh',
389             'coh4aehiacheTh0ue0eegho9oku1lohl4loht9ohPoongoow7dasiego6yimuis',
390             'lohtho8eigahfeipohviepajaix4it2peeQu6Iefee1nevihaes4cee2soh4noy',
391             'kaht9ahv0ieXaiyih7ohxe8bah7eeyicahjoa2ohbu7Choxua7oongah6sei4bu',
392             'deif0iPaechohkee5nahx6oi2uJeeN7ze3seunohJibe4shai0mah5Iesh3Quai',
393             'ChohDahshooNee0NeNohthah0eiDeese3Vu6ohShil1Iey9ja0uebi2quiShae6',
394             'Dee1kai7eiph2ahh2nufah3zai3eexeengohQue1caj0eeW0xeghi3eshuadoot',
395             'aeshiup3aengajoog0AhCoo5tiu3ieghaeGhie4Tu1ohh1thee8aepheingah1E',
396             'ooRa6ahciolohshaifoopeo9ZeiGhae2aech4raisheiWah9AaNga0uas9ahquo',
397             'thaepheip2aip6shief4EaXopei8ohPo0ighuiXah2ashowai9nohp4uach6Mei',
398             'ohph4yaev3quieji3phophiem3OoNuisheepahng4waithae3Naichai7aw3noo',
399             'aibeawaneBahmieyuph8ieng8iopheereeD2uu9Uyee5bei2phahXeir8eeJ8oo',
400             'ooshahphei2hoh3uth5chaen7ohsai6uutiesucheichai8ungah9Gie1Aiphie',
401             'eiwohchoo7ere2iebohn4Aapheichaelooriiyaoxaik7ooqua7aezahx0aeJei',
402             'Vah0ohgohphiefohTheshieghichaichahch5moshoo0zai5eeva7eisi4yae8T',
403             'EibeeN0fee0Gohnguz8iec6yeigh7shuNg4eingu3siph9joucahpeidoom4ree',
404             'iejiu3shohheeZahHusheimeefaihoh5eecachu5eeZie9ceisugu9taidohT3U',
405             'eex6dilakaix5Eetai7xiCh5Jaa8aiD4Ag3tuij1aijohv5fo0heevah8hohs3m',
406             'ohqueeNgahraew6uraemohtoo5qua3oojiex6ohqu6Aideibaithaiphuriquie',
407             'cei0eiN4Shiey7Aeluy3unohboo5choiphahc2mahbei5paephaiKeso1thoog1',
408             'ieghif4ohKequ7ong0jah5ooBah0eiGh1caechahnahThae9Shoo0phopashoo4',
409             'roh9er3thohwi5am8iequeequuSh3aic0voocai3ihi5nie2abahphupiegh7vu',
410             'uv3Quei7wujoo5beingei2aish5op4VaiX0aebai7iwoaPee5pei8ko9IepaPig',
411             'co7aegh5beitheesi9lu7jeeQu3johgeiphee9cheichi8aithuDehu2gaeNein',
412             'thai3Tiewoo4nuir1ohy4aithiuZ7shae1luuwei5phibohriepe2paeci1Ach8',
413             'phoi3ribah7ufuvoh8eigh1oB6deeBaiPohphaghiPieshahfah5EiCi3toogoo',
414             'aiM8geil7ooreinee4Cheiwea4yeec8eeshi7Sei4Shoo3wu6ohkaNgooQu1mai',
415             'agoo3faciewah9ZeesiXeereek7am0eigaeShie3Tisu8haReeNgoo0ci2Hae5u',
416             'Aesatheewiedohshaephaenohbooshee8eu7EiJ8isal1laech2eiHo0noaV3ta',
417             'liunguep3ooChoo4eir8ahSie8eenee0oo1TooXu8Cais8Aimo4eir6Phoo3xei',
418             'toe9heepeobein3teequachemei0Cejoomef9ujie3ohwae9AiNgiephi3ep0de',
419             'ua6xooY9uzaeB3of6sheiyaedohoiS5Eev0Aequ9ahm1zoa5Aegh3ooz9ChahDa',
420             'eevasah6Bu9wi7EiwiequumahkaeCheegh6lui8xoh4eeY4ieneavah8phaibun',
421             'AhNgei2sioZeeng6phaecheemeehiShie5eFeiTh6ooV8iiphabud0die4siep4',
422             'kushe6Xieg6ahQuoo9aex3aipheefiec1esa7OhBuG0ueziep9phai5eegh1vie',
423             'Jie5yu8aafuQuoh9shaep3moboh3Pooy7och8oC6obeik6jaew2aiLooweib3ch',
424             'ohohjajaivaiRail3odaimei6aekohVaicheip2wu7phieg5Gohsaing2ahxaiy',
425             'hahzaht6yaiYu9re9jah9loisiit4ahtoh2quoh9xohishioz4oo4phofu3ogha',
426             'pu4oorea0uh2tahB8aiZoonge1aophaes6ogaiK9ailaigeej4zoVou8ielotee',
427             'cae2thei3Luphuqu0zeeG8leeZuchahxaicai4ui4Eedohte9uW6gae8Geeh0ea',
428             'air7tuy7ohw5sho2Tahpai8aep4so5ria7eaShus5weaqu0Naquei2xaeyoo2ae',
429             'vohge4aeCh7ahwoo7Jaex6sohl0Koong4Iejisei8Coir0iemeiz9uru9Iebaep',
430             'aepeidie8aiw6waish9gie4Woolae2thuj5phae4phexux7gishaeph4Deu7ooS',
431             'vahc5ia0xohHooViT0uyuxookiaquu2ogueth0ahquoudeefohshai8aeThahba',
432             'mun3oagah2eequaenohfoo8DaigeghoozaV2eiveeQuee7kah0quaa6tiesheet',
433             'ooSet4IdieC4ugow3za0die4ohGoh1oopoh6luaPhaeng4Eechea1hae0eimie5',
434             'iedeimadaefu2NeiPaey2jooloov5iehiegeakoo4ueso7aeK9ahqu2Thahkaes',
435             'nahquah9Quuu2uuf0aJah7eishi2siegh8ue5eiJa2EeVu8ebohkepoh4dahNgo',
436             'io1bie7chioPiej5ae2oohe2fee6ooP2thaeJohjohb9Se8tang3eipaifeimai',
437             'oungoqu6dieneejiechez1xeD2Zi9iox2Ahchaiy9ithah3ohVoolu2euQuuawo',
438             'thaew0veigei4neishohd8mecaixuqu7eeshiex1chaigohmoThoghoitoTa0Eo',
439             'ahroob2phohvaiz0Ohteik2ohtakie6Iu1vitho8IyiyeeleeShae9defaiw9ki',
440             'DohHoothohzeaxolai3Toh5eJie7ahlah9reF0ohn1chaipoogain2aibahw4no',
441             'aif8lo5she4aich5cho2rie8ieJaujeem2Joongeedae4vie3tah1Leequaix1O',
442             'Aang0Shaih6chahthie1ahZ7aewei9thiethee7iuThah3yoongi8ahngiobaa5',
443             'iephoBuayoothah0Ru6aichai4aiw8deg1umongauvaixai3ohy6oowohlee8ei',
444             'ohn5shigoameer0aejohgoh8oChohlaecho9jie6shu0ahg9Bohngau6paevei9',
445             'edahghaishak0paigh1eecuich3aad7yeB0ieD6akeeliem2beifufaekee6eat',
446             'hiechahgheloh2zo7Ieghaiph0phahhu8aeyuiKie1xeipheech9zai4aeme0ee',
447             'Cube'
448         ]
449         name = '/' + '/'.join(name_parts)
450
451         # Now check the resulting Alembic file.
452         abcprop = self.abcprop(abc, '%s/.xform' % name)
453         self.assertEqual(abcprop['.vals'], [
454             1.0, 0.0, 0.0, 0.0,
455             0.0, 1.0, 0.0, 0.0,
456             0.0, 0.0, 1.0, 0.0,
457             0.0, 3.0, 0.0, 1.0,
458         ])
459
460         abcprop = self.abcprop(abc, '%s/CubeShape/.geom' % name)
461         self.assertIn('.faceCounts', abcprop)
462
463
464 if __name__ == '__main__':
465     parser = argparse.ArgumentParser()
466     parser.add_argument('--blender', required=True)
467     parser.add_argument('--testdir', required=True)
468     parser.add_argument('--alembic-root', required=True)
469     args, remaining = parser.parse_known_args()
470
471     unittest.main(argv=sys.argv[0:1] + remaining)