Merge branch 'blender2.7'
[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 pathlib
24 import subprocess
25 import sys
26 import unittest
27
28 from modules.test_utils import (
29     with_tempdir,
30     AbstractBlenderRunnerTest,
31 )
32
33
34 class AbcPropError(Exception):
35     """Raised when AbstractAlembicTest.abcprop() finds an error."""
36
37
38 class AbstractAlembicTest(AbstractBlenderRunnerTest):
39     @classmethod
40     def setUpClass(cls):
41         import re
42
43         cls.blender = args.blender
44         cls.testdir = pathlib.Path(args.testdir)
45         cls.alembic_root = pathlib.Path(args.alembic_root)
46
47         # 'abcls' outputs ANSI colour codes, even when stdout is not a terminal.
48         # See https://github.com/alembic/alembic/issues/120
49         cls.ansi_remove_re = re.compile(rb'\x1b[^m]*m')
50
51         # 'abcls' array notation, like "name[16]"
52         cls.abcls_array = re.compile(r'^(?P<name>[^\[]+)(\[(?P<arraysize>\d+)\])?$')
53
54     def abcprop(self, filepath: pathlib.Path, proppath: str) -> dict:
55         """Uses abcls to obtain compound property values from an Alembic object.
56
57         A dict of subproperties is returned, where the values are Python values.
58
59         The Python bindings for Alembic are old, and only compatible with Python 2.x,
60         so that's why we can't use them here, and have to rely on other tooling.
61         """
62         import collections
63
64         abcls = self.alembic_root / 'bin' / 'abcls'
65
66         command = (str(abcls), '-vl', '%s%s' % (filepath, proppath))
67         proc = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
68                               timeout=30)
69
70         coloured_output = proc.stdout
71         output = self.ansi_remove_re.sub(b'', coloured_output).decode('utf8')
72
73         # Because of the ANSI colour codes, we need to remove those first before
74         # decoding to text. This means that we cannot use the universal_newlines
75         # parameter to subprocess.run(), and have to do the conversion ourselves
76         output = output.replace('\r\n', '\n').replace('\r', '\n')
77
78         if proc.returncode:
79             raise AbcPropError('Error %d running abcls:\n%s' % (proc.returncode, output))
80
81         # Mapping from value type to callable that can convert a string to Python values.
82         converters = {
83             'bool_t': int,
84             'uint8_t': int,
85             'int16_t': int,
86             'int32_t': int,
87             'uint64_t': int,
88             'float64_t': float,
89             'float32_t': float,
90         }
91
92         result = {}
93
94         # Ideally we'd get abcls to output JSON, see https://github.com/alembic/alembic/issues/121
95         lines = collections.deque(output.split('\n'))
96         while lines:
97             info = lines.popleft()
98             if not info:
99                 continue
100             parts = info.split()
101             proptype = parts[0]
102
103             if proptype == 'CompoundProperty':
104                 # To read those, call self.abcprop() on it.
105                 continue
106             if len(parts) < 2:
107                 raise ValueError('Error parsing result from abcprop: %s', info.strip())
108             valtype_and_arrsize, name_and_extent = parts[1:]
109
110             # Parse name and extent
111             m = self.abcls_array.match(name_and_extent)
112             if not m:
113                 self.fail('Unparsable name/extent from abcls: %s' % name_and_extent)
114             name, extent = m.group('name'), m.group('arraysize')
115
116             if extent != '1':
117                 self.fail('Unsupported extent %s for property %s/%s' % (extent, proppath, name))
118
119             # Parse type
120             m = self.abcls_array.match(valtype_and_arrsize)
121             if not m:
122                 self.fail('Unparsable value type from abcls: %s' % valtype_and_arrsize)
123             valtype, scalarsize = m.group('name'), m.group('arraysize')
124
125             # Convert values
126             try:
127                 conv = converters[valtype]
128             except KeyError:
129                 self.fail('Unsupported type %s for property %s/%s' % (valtype, proppath, name))
130
131             def convert_single_line(linevalue):
132                 try:
133                     if scalarsize is None:
134                         return conv(linevalue)
135                     else:
136                         return [conv(v.strip()) for v in linevalue.split(',')]
137                 except ValueError as ex:
138                     return str(ex)
139
140             if proptype == 'ScalarProperty':
141                 value = lines.popleft()
142                 result[name] = convert_single_line(value)
143             elif proptype == 'ArrayProperty':
144                 arrayvalue = []
145                 # Arrays consist of a variable number of items, and end in a blank line.
146                 while True:
147                     linevalue = lines.popleft()
148                     if not linevalue:
149                         break
150                     arrayvalue.append(convert_single_line(linevalue))
151                 result[name] = arrayvalue
152             else:
153                 self.fail('Unsupported type %s for property %s/%s' % (proptype, proppath, name))
154
155         return result
156
157     def assertAlmostEqualFloatArray(self, actual, expect, places=6, delta=None):
158         """Asserts that the arrays of floats are almost equal."""
159
160         self.assertEqual(len(actual), len(expect),
161                          'Actual array has %d items, expected %d' % (len(actual), len(expect)))
162
163         for idx, (act, exp) in enumerate(zip(actual, expect)):
164             self.assertAlmostEqual(act, exp, places=places, delta=delta,
165                                    msg='%f != %f at index %d' % (act, exp, idx))
166
167
168 class HierarchicalAndFlatExportTest(AbstractAlembicTest):
169     @with_tempdir
170     def test_hierarchical_export(self, tempdir: pathlib.Path):
171         abc = tempdir / 'cubes_hierarchical.abc'
172         script = "import bpy; bpy.ops.wm.alembic_export(filepath='%s', start=1, end=1, " \
173                  "renderable_only=True, visible_layers_only=True, flatten=False)" % abc.as_posix()
174         self.run_blender('cubes-hierarchy.blend', script)
175
176         # Now check the resulting Alembic file.
177         xform = self.abcprop(abc, '/Cube/Cube_002/Cube_012/.xform')
178         self.assertEqual(1, xform['.inherits'])
179         self.assertAlmostEqualFloatArray(
180             xform['.vals'],
181             [1.0, 0.0, 0.0, 0.0,
182              0.0, 1.0, 0.0, 0.0,
183              0.0, 0.0, 1.0, 0.0,
184              3.07484, -2.92265, 0.0586434, 1.0]
185         )
186
187     @with_tempdir
188     def test_flat_export(self, tempdir: pathlib.Path):
189         abc = tempdir / 'cubes_flat.abc'
190         script = "import bpy; bpy.ops.wm.alembic_export(filepath='%s', start=1, end=1, " \
191                  "renderable_only=True, visible_layers_only=True, flatten=True)" % abc.as_posix()
192         self.run_blender('cubes-hierarchy.blend', script)
193
194         # Now check the resulting Alembic file.
195         xform = self.abcprop(abc, '/Cube_012/.xform')
196         self.assertEqual(0, xform['.inherits'])
197
198         self.assertAlmostEqualFloatArray(
199             xform['.vals'],
200             [0.343134, 0.485243, 0.804238, 0,
201              0.0, 0.856222, -0.516608, 0,
202              -0.939287, 0.177266, 0.293799, 0,
203              1, 3, 4, 1],
204         )
205
206
207 class DupliGroupExportTest(AbstractAlembicTest):
208     @with_tempdir
209     def test_hierarchical_export(self, tempdir: pathlib.Path):
210         abc = tempdir / 'dupligroup_hierarchical.abc'
211         script = "import bpy; bpy.ops.wm.alembic_export(filepath='%s', start=1, end=1, " \
212                  "renderable_only=True, visible_layers_only=True, flatten=False)" % abc.as_posix()
213         self.run_blender('dupligroup-scene.blend', script)
214
215         # Now check the resulting Alembic file.
216         xform = self.abcprop(abc, '/Real_Cube/Linked_Suzanne/Cylinder/Suzanne/.xform')
217         self.assertEqual(1, xform['.inherits'])
218         self.assertAlmostEqualFloatArray(
219             xform['.vals'],
220             [1.0, 0.0, 0.0, 0.0,
221              0.0, 1.0, 0.0, 0.0,
222              0.0, 0.0, 1.0, 0.0,
223              0.0, 2.0, 0.0, 1.0]
224         )
225
226     @with_tempdir
227     def test_flat_export(self, tempdir: pathlib.Path):
228         abc = tempdir / 'dupligroup_hierarchical.abc'
229         script = "import bpy; bpy.ops.wm.alembic_export(filepath='%s', start=1, end=1, " \
230                  "renderable_only=True, visible_layers_only=True, flatten=True)" % abc.as_posix()
231         self.run_blender('dupligroup-scene.blend', script)
232
233         # Now check the resulting Alembic file.
234         xform = self.abcprop(abc, '/Suzanne/.xform')
235         self.assertEqual(0, xform['.inherits'])
236
237         self.assertAlmostEqualFloatArray(
238             xform['.vals'],
239             [1.5, 0.0, 0.0, 0.0,
240              0.0, 1.5, 0.0, 0.0,
241              0.0, 0.0, 1.5, 0.0,
242              2.0, 3.0, 0.0, 1.0]
243         )
244
245
246 class CurveExportTest(AbstractAlembicTest):
247     @with_tempdir
248     def test_export_single_curve(self, tempdir: pathlib.Path):
249         abc = tempdir / 'single-curve.abc'
250         script = "import bpy; bpy.ops.wm.alembic_export(filepath='%s', start=1, end=1, " \
251                  "renderable_only=True, visible_layers_only=True, flatten=False)" % abc.as_posix()
252         self.run_blender('single-curve.blend', script)
253
254         # Now check the resulting Alembic file.
255         abcprop = self.abcprop(abc, '/NurbsCurve/NurbsCurveShape/.geom')
256         self.assertEqual(abcprop['.orders'], [4])
257
258         abcprop = self.abcprop(abc, '/NurbsCurve/NurbsCurveShape/.geom/.userProperties')
259         self.assertEqual(abcprop['blender:resolution'], 10)
260
261
262 class HairParticlesExportTest(AbstractAlembicTest):
263     """Tests exporting with/without hair/particles.
264
265     Just a basic test to ensure that the enabling/disabling works, and that export
266     works at all. NOT testing the quality/contents of the exported file.
267     """
268
269     def _do_test(self, tempdir: pathlib.Path, export_hair: bool, export_particles: bool) -> pathlib.Path:
270         abc = tempdir / 'hair-particles.abc'
271         script = "import bpy; bpy.ops.wm.alembic_export(filepath='%s', start=1, end=1, " \
272                  "renderable_only=True, visible_layers_only=True, flatten=False, " \
273                  "export_hair=%r, export_particles=%r, as_background_job=False)" \
274                  % (abc.as_posix(), export_hair, export_particles)
275         self.run_blender('hair-particles.blend', script)
276         return abc
277
278     @with_tempdir
279     def test_with_both(self, tempdir: pathlib.Path):
280         abc = self._do_test(tempdir, True, True)
281
282         abcprop = self.abcprop(abc, '/Suzanne/Hair system/.geom')
283         self.assertIn('nVertices', abcprop)
284
285         abcprop = self.abcprop(abc, '/Suzanne/Non-hair particle system/.geom')
286         self.assertIn('.velocities', abcprop)
287
288         abcprop = self.abcprop(abc, '/Suzanne/SuzanneShape/.geom')
289         self.assertIn('.faceIndices', abcprop)
290
291     @with_tempdir
292     def test_with_hair_only(self, tempdir: pathlib.Path):
293         abc = self._do_test(tempdir, True, False)
294
295         abcprop = self.abcprop(abc, '/Suzanne/Hair system/.geom')
296         self.assertIn('nVertices', abcprop)
297
298         self.assertRaises(AbcPropError, self.abcprop, abc,
299                           '/Suzanne/Non-hair particle system/.geom')
300
301         abcprop = self.abcprop(abc, '/Suzanne/SuzanneShape/.geom')
302         self.assertIn('.faceIndices', abcprop)
303
304     @with_tempdir
305     def test_with_particles_only(self, tempdir: pathlib.Path):
306         abc = self._do_test(tempdir, False, True)
307
308         self.assertRaises(AbcPropError, self.abcprop, abc, '/Suzanne/Hair system/.geom')
309
310         abcprop = self.abcprop(abc, '/Suzanne/Non-hair particle system/.geom')
311         self.assertIn('.velocities', abcprop)
312
313         abcprop = self.abcprop(abc, '/Suzanne/SuzanneShape/.geom')
314         self.assertIn('.faceIndices', abcprop)
315
316     @with_tempdir
317     def test_with_neither(self, tempdir: pathlib.Path):
318         abc = self._do_test(tempdir, False, False)
319
320         self.assertRaises(AbcPropError, self.abcprop, abc, '/Suzanne/Hair system/.geom')
321         self.assertRaises(AbcPropError, self.abcprop, abc,
322                           '/Suzanne/Non-hair particle system/.geom')
323
324         abcprop = self.abcprop(abc, '/Suzanne/SuzanneShape/.geom')
325         self.assertIn('.faceIndices', abcprop)
326
327
328 class LongNamesExportTest(AbstractAlembicTest):
329     @with_tempdir
330     def test_export_long_names(self, tempdir: pathlib.Path):
331         abc = tempdir / 'long-names.abc'
332         script = "import bpy; bpy.ops.wm.alembic_export(filepath='%s', start=1, end=1, " \
333                  "renderable_only=False, visible_layers_only=False, flatten=False)" % abc.as_posix()
334         self.run_blender('long-names.blend', script)
335
336         name_parts = [
337             'foG9aeLahgoh5goacee1dah6Hethaghohjaich5pasizairuWigee1ahPeekiGh',
338             'yoNgoisheedah2ua0eigh2AeCaiTee5bo0uphoo7Aixephah9racahvaingeeH4',
339             'zuthohnoi1thooS3eezoo8seuph2Boo5aefacaethuvee1aequoonoox1sookie',
340             'wugh4ciTh3dipiepeequait5uug7thiseek5ca7Eijei5ietaizokohhaecieto',
341             'up9aeheenein9oteiX6fohP3thiez6Ahvah0oohah1ep2Eesho4Beboechaipoh',
342             'coh4aehiacheTh0ue0eegho9oku1lohl4loht9ohPoongoow7dasiego6yimuis',
343             'lohtho8eigahfeipohviepajaix4it2peeQu6Iefee1nevihaes4cee2soh4noy',
344             'kaht9ahv0ieXaiyih7ohxe8bah7eeyicahjoa2ohbu7Choxua7oongah6sei4bu',
345             'deif0iPaechohkee5nahx6oi2uJeeN7ze3seunohJibe4shai0mah5Iesh3Quai',
346             'ChohDahshooNee0NeNohthah0eiDeese3Vu6ohShil1Iey9ja0uebi2quiShae6',
347             'Dee1kai7eiph2ahh2nufah3zai3eexeengohQue1caj0eeW0xeghi3eshuadoot',
348             'aeshiup3aengajoog0AhCoo5tiu3ieghaeGhie4Tu1ohh1thee8aepheingah1E',
349             'ooRa6ahciolohshaifoopeo9ZeiGhae2aech4raisheiWah9AaNga0uas9ahquo',
350             'thaepheip2aip6shief4EaXopei8ohPo0ighuiXah2ashowai9nohp4uach6Mei',
351             'ohph4yaev3quieji3phophiem3OoNuisheepahng4waithae3Naichai7aw3noo',
352             'aibeawaneBahmieyuph8ieng8iopheereeD2uu9Uyee5bei2phahXeir8eeJ8oo',
353             'ooshahphei2hoh3uth5chaen7ohsai6uutiesucheichai8ungah9Gie1Aiphie',
354             'eiwohchoo7ere2iebohn4Aapheichaelooriiyaoxaik7ooqua7aezahx0aeJei',
355             'Vah0ohgohphiefohTheshieghichaichahch5moshoo0zai5eeva7eisi4yae8T',
356             'EibeeN0fee0Gohnguz8iec6yeigh7shuNg4eingu3siph9joucahpeidoom4ree',
357             'iejiu3shohheeZahHusheimeefaihoh5eecachu5eeZie9ceisugu9taidohT3U',
358             'eex6dilakaix5Eetai7xiCh5Jaa8aiD4Ag3tuij1aijohv5fo0heevah8hohs3m',
359             'ohqueeNgahraew6uraemohtoo5qua3oojiex6ohqu6Aideibaithaiphuriquie',
360             'cei0eiN4Shiey7Aeluy3unohboo5choiphahc2mahbei5paephaiKeso1thoog1',
361             'ieghif4ohKequ7ong0jah5ooBah0eiGh1caechahnahThae9Shoo0phopashoo4',
362             'roh9er3thohwi5am8iequeequuSh3aic0voocai3ihi5nie2abahphupiegh7vu',
363             'uv3Quei7wujoo5beingei2aish5op4VaiX0aebai7iwoaPee5pei8ko9IepaPig',
364             'co7aegh5beitheesi9lu7jeeQu3johgeiphee9cheichi8aithuDehu2gaeNein',
365             'thai3Tiewoo4nuir1ohy4aithiuZ7shae1luuwei5phibohriepe2paeci1Ach8',
366             'phoi3ribah7ufuvoh8eigh1oB6deeBaiPohphaghiPieshahfah5EiCi3toogoo',
367             'aiM8geil7ooreinee4Cheiwea4yeec8eeshi7Sei4Shoo3wu6ohkaNgooQu1mai',
368             'agoo3faciewah9ZeesiXeereek7am0eigaeShie3Tisu8haReeNgoo0ci2Hae5u',
369             'Aesatheewiedohshaephaenohbooshee8eu7EiJ8isal1laech2eiHo0noaV3ta',
370             'liunguep3ooChoo4eir8ahSie8eenee0oo1TooXu8Cais8Aimo4eir6Phoo3xei',
371             'toe9heepeobein3teequachemei0Cejoomef9ujie3ohwae9AiNgiephi3ep0de',
372             'ua6xooY9uzaeB3of6sheiyaedohoiS5Eev0Aequ9ahm1zoa5Aegh3ooz9ChahDa',
373             'eevasah6Bu9wi7EiwiequumahkaeCheegh6lui8xoh4eeY4ieneavah8phaibun',
374             'AhNgei2sioZeeng6phaecheemeehiShie5eFeiTh6ooV8iiphabud0die4siep4',
375             'kushe6Xieg6ahQuoo9aex3aipheefiec1esa7OhBuG0ueziep9phai5eegh1vie',
376             'Jie5yu8aafuQuoh9shaep3moboh3Pooy7och8oC6obeik6jaew2aiLooweib3ch',
377             'ohohjajaivaiRail3odaimei6aekohVaicheip2wu7phieg5Gohsaing2ahxaiy',
378             'hahzaht6yaiYu9re9jah9loisiit4ahtoh2quoh9xohishioz4oo4phofu3ogha',
379             'pu4oorea0uh2tahB8aiZoonge1aophaes6ogaiK9ailaigeej4zoVou8ielotee',
380             'cae2thei3Luphuqu0zeeG8leeZuchahxaicai4ui4Eedohte9uW6gae8Geeh0ea',
381             'air7tuy7ohw5sho2Tahpai8aep4so5ria7eaShus5weaqu0Naquei2xaeyoo2ae',
382             'vohge4aeCh7ahwoo7Jaex6sohl0Koong4Iejisei8Coir0iemeiz9uru9Iebaep',
383             'aepeidie8aiw6waish9gie4Woolae2thuj5phae4phexux7gishaeph4Deu7ooS',
384             'vahc5ia0xohHooViT0uyuxookiaquu2ogueth0ahquoudeefohshai8aeThahba',
385             'mun3oagah2eequaenohfoo8DaigeghoozaV2eiveeQuee7kah0quaa6tiesheet',
386             'ooSet4IdieC4ugow3za0die4ohGoh1oopoh6luaPhaeng4Eechea1hae0eimie5',
387             'iedeimadaefu2NeiPaey2jooloov5iehiegeakoo4ueso7aeK9ahqu2Thahkaes',
388             'nahquah9Quuu2uuf0aJah7eishi2siegh8ue5eiJa2EeVu8ebohkepoh4dahNgo',
389             'io1bie7chioPiej5ae2oohe2fee6ooP2thaeJohjohb9Se8tang3eipaifeimai',
390             'oungoqu6dieneejiechez1xeD2Zi9iox2Ahchaiy9ithah3ohVoolu2euQuuawo',
391             'thaew0veigei4neishohd8mecaixuqu7eeshiex1chaigohmoThoghoitoTa0Eo',
392             'ahroob2phohvaiz0Ohteik2ohtakie6Iu1vitho8IyiyeeleeShae9defaiw9ki',
393             'DohHoothohzeaxolai3Toh5eJie7ahlah9reF0ohn1chaipoogain2aibahw4no',
394             'aif8lo5she4aich5cho2rie8ieJaujeem2Joongeedae4vie3tah1Leequaix1O',
395             'Aang0Shaih6chahthie1ahZ7aewei9thiethee7iuThah3yoongi8ahngiobaa5',
396             'iephoBuayoothah0Ru6aichai4aiw8deg1umongauvaixai3ohy6oowohlee8ei',
397             'ohn5shigoameer0aejohgoh8oChohlaecho9jie6shu0ahg9Bohngau6paevei9',
398             'edahghaishak0paigh1eecuich3aad7yeB0ieD6akeeliem2beifufaekee6eat',
399             'hiechahgheloh2zo7Ieghaiph0phahhu8aeyuiKie1xeipheech9zai4aeme0ee',
400             'Cube'
401         ]
402         name = '/' + '/'.join(name_parts)
403
404         # Now check the resulting Alembic file.
405         abcprop = self.abcprop(abc, '%s/.xform' % name)
406         self.assertEqual(abcprop['.vals'], [
407             1.0, 0.0, 0.0, 0.0,
408             0.0, 1.0, 0.0, 0.0,
409             0.0, 0.0, 1.0, 0.0,
410             0.0, 3.0, 0.0, 1.0,
411         ])
412
413         abcprop = self.abcprop(abc, '%s/CubeShape/.geom' % name)
414         self.assertIn('.faceCounts', abcprop)
415
416
417 if __name__ == '__main__':
418     parser = argparse.ArgumentParser()
419     parser.add_argument('--blender', required=True)
420     parser.add_argument('--testdir', required=True)
421     parser.add_argument('--alembic-root', required=True)
422     args, remaining = parser.parse_known_args()
423
424     unittest.main(argv=sys.argv[0:1] + remaining)