155cbd545f03a4d24dd0c9ef435db4e248227a38
[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 AbstractAlembicTest(unittest.TestCase):
58     @classmethod
59     def setUpClass(cls):
60         import re
61
62         cls.blender = args.blender
63         cls.testdir = pathlib.Path(args.testdir)
64         cls.alembic_root = pathlib.Path(args.alembic_root)
65
66         # 'abcls' outputs ANSI colour codes, even when stdout is not a terminal.
67         # See https://github.com/alembic/alembic/issues/120
68         cls.ansi_remove_re = re.compile(rb'\x1b[^m]*m')
69
70         # 'abcls' array notation, like "name[16]"
71         cls.abcls_array = re.compile(r'^(?P<name>[^\[]+)(\[(?P<arraysize>\d+)\])?$')
72
73     def run_blender(self, filepath: str, python_script: str, timeout: int=300) -> str:
74         """Runs Blender by opening a blendfile and executing a script.
75
76         Returns Blender's stdout + stderr combined into one string.
77
78         :param filepath: taken relative to self.testdir.
79         :param timeout: in seconds
80         """
81
82         blendfile = self.testdir / filepath
83
84         command = (
85             self.blender,
86             '--background',
87             '-noaudio',
88             '--factory-startup',
89             '--enable-autoexec',
90             str(blendfile),
91             '-E', 'CYCLES',
92             '--python-exit-code', '47',
93             '--python-expr', python_script,
94         )
95
96         proc = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
97                               timeout=timeout)
98         output = proc.stdout.decode('utf8')
99         if proc.returncode:
100             self.fail('Error %d running Blender:\n%s' % (proc.returncode, output))
101
102         return output
103
104     def abcprop(self, filepath: pathlib.Path, proppath: str) -> dict:
105         """Uses abcls to obtain compound property values from an Alembic object.
106
107         A dict of subproperties is returned, where the values are just strings
108         as returned by abcls.
109
110         The Python bindings for Alembic are old, and only compatible with Python 2.x,
111         so that's why we can't use them here, and have to rely on other tooling.
112         """
113         import collections
114
115         abcls = self.alembic_root / 'bin' / 'abcls'
116
117         command = (str(abcls), '-vl', '%s%s' % (filepath, proppath))
118         proc = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
119                               timeout=30)
120
121         coloured_output = proc.stdout
122         output = self.ansi_remove_re.sub(b'', coloured_output).decode('utf8')
123
124         if proc.returncode:
125             self.fail('Error %d running abcls:\n%s' % (proc.returncode, output))
126
127         # Mapping from value type to callable that can convert a string to Python values.
128         converters = {
129             'bool_t': int,
130             'uint8_t': int,
131             'int16_t': int,
132             'int32_t': int,
133             'float64_t': float,
134             'float32_t': float,
135         }
136
137         result = {}
138
139         # Ideally we'd get abcls to output JSON, see https://github.com/alembic/alembic/issues/121
140         lines = collections.deque(output.split('\n'))
141         while lines:
142             info = lines.popleft()
143             if not info:
144                 continue
145             parts = info.split()
146             proptype = parts[0]
147
148             if proptype == 'CompoundProperty':
149                 # To read those, call self.abcprop() on it.
150                 continue
151             valtype_and_arrsize, name_and_extent = parts[1:]
152
153             # Parse name and extent
154             m = self.abcls_array.match(name_and_extent)
155             if not m:
156                 self.fail('Unparsable name/extent from abcls: %s' % name_and_extent)
157             name, extent = m.group('name'), m.group('arraysize')
158
159             if extent != '1':
160                 self.fail('Unsupported extent %s for property %s/%s' % (extent, proppath, name))
161
162             # Parse type
163             m = self.abcls_array.match(valtype_and_arrsize)
164             if not m:
165                 self.fail('Unparsable value type from abcls: %s' % valtype_and_arrsize)
166             valtype, scalarsize = m.group('name'), m.group('arraysize')
167
168             # Convert values
169             try:
170                 conv = converters[valtype]
171             except KeyError:
172                 self.fail('Unsupported type %s for property %s/%s' % (valtype, proppath, name))
173
174             def convert_single_line(linevalue):
175                 try:
176                     if scalarsize is None:
177                         return conv(linevalue)
178                     else:
179                         return [conv(v.strip()) for v in linevalue.split(',')]
180                 except ValueError as ex:
181                     return str(ex)
182
183             if proptype == 'ScalarProperty':
184                 value = lines.popleft()
185                 result[name] = convert_single_line(value)
186             elif proptype == 'ArrayProperty':
187                 arrayvalue = []
188                 # Arrays consist of a variable number of items, and end in a blank line.
189                 while True:
190                     linevalue = lines.popleft()
191                     if not linevalue:
192                         break
193                     arrayvalue.append(convert_single_line(linevalue))
194                 result[name] = arrayvalue
195             else:
196                 self.fail('Unsupported type %s for property %s/%s' % (proptype, proppath, name))
197
198         return result
199
200     def assertAlmostEqualFloatArray(self, actual, expect, places=6, delta=None):
201         """Asserts that the arrays of floats are almost equal."""
202
203         self.assertEqual(len(actual), len(expect),
204                          'Actual array has %d items, expected %d' % (len(actual), len(expect)))
205
206         for idx, (act, exp) in enumerate(zip(actual, expect)):
207             self.assertAlmostEqual(act, exp, places=places, delta=delta,
208                                    msg='%f != %f at index %d' % (act, exp, idx))
209
210
211 class HierarchicalAndFlatExportTest(AbstractAlembicTest):
212     @with_tempdir
213     def test_hierarchical_export(self, tempdir: pathlib.Path):
214         abc = tempdir / 'cubes_hierarchical.abc'
215         script = "import bpy; bpy.ops.wm.alembic_export(filepath='%s', start=1, end=1, " \
216                  "renderable_only=True, visible_layers_only=True, flatten=False)" % abc
217         self.run_blender('cubes-hierarchy.blend', script)
218
219         # Now check the resulting Alembic file.
220         xform = self.abcprop(abc, '/Cube/Cube_002/Cube_012/.xform')
221         self.assertEqual(1, xform['.inherits'])
222         self.assertAlmostEqualFloatArray(
223             xform['.vals'],
224             [1.0, 0.0, 0.0, 0.0,
225              0.0, 1.0, 0.0, 0.0,
226              0.0, 0.0, 1.0, 0.0,
227              3.07484, -2.92265, 0.0586434, 1.0]
228         )
229
230     @with_tempdir
231     def test_flat_export(self, tempdir: pathlib.Path):
232         abc = tempdir / 'cubes_flat.abc'
233         script = "import bpy; bpy.ops.wm.alembic_export(filepath='%s', start=1, end=1, " \
234                  "renderable_only=True, visible_layers_only=True, flatten=True)" % abc
235         self.run_blender('cubes-hierarchy.blend', script)
236
237         # Now check the resulting Alembic file.
238         xform = self.abcprop(abc, '/Cube_012/.xform')
239         self.assertEqual(0, xform['.inherits'])
240
241         self.assertAlmostEqualFloatArray(
242             xform['.vals'],
243             [0.343134, 0.485243, 0.804238, 0,
244              0.0, 0.856222, -0.516608, 0,
245              -0.939287, 0.177266, 0.293799, 0,
246              1, 3, 4, 1],
247         )
248
249
250 class DupliGroupExportTest(AbstractAlembicTest):
251     @with_tempdir
252     def test_hierarchical_export(self, tempdir: pathlib.Path):
253         abc = tempdir / 'dupligroup_hierarchical.abc'
254         script = "import bpy; bpy.ops.wm.alembic_export(filepath='%s', start=1, end=1, " \
255                  "renderable_only=True, visible_layers_only=True, flatten=False)" % abc
256         self.run_blender('dupligroup-scene.blend', script)
257
258         # Now check the resulting Alembic file.
259         xform = self.abcprop(abc, '/Real_Cube/Linked_Suzanne/Cylinder/Suzanne/.xform')
260         self.assertEqual(1, xform['.inherits'])
261         self.assertAlmostEqualFloatArray(
262             xform['.vals'],
263             [1.0, 0.0, 0.0, 0.0,
264              0.0, 1.0, 0.0, 0.0,
265              0.0, 0.0, 1.0, 0.0,
266              0.0, 2.0, 0.0, 1.0]
267         )
268
269     @with_tempdir
270     def test_flat_export(self, tempdir: pathlib.Path):
271         abc = tempdir / 'dupligroup_hierarchical.abc'
272         script = "import bpy; bpy.ops.wm.alembic_export(filepath='%s', start=1, end=1, " \
273                  "renderable_only=True, visible_layers_only=True, flatten=True)" % abc
274         self.run_blender('dupligroup-scene.blend', script)
275
276         # Now check the resulting Alembic file.
277         xform = self.abcprop(abc, '/Suzanne/.xform')
278         self.assertEqual(0, xform['.inherits'])
279
280         self.assertAlmostEqualFloatArray(
281             xform['.vals'],
282             [1.5, 0.0, 0.0, 0.0,
283              0.0, 1.5, 0.0, 0.0,
284              0.0, 0.0, 1.5, 0.0,
285              2.0, 3.0, 0.0, 1.0]
286         )
287
288
289 class CurveExportTest(AbstractAlembicTest):
290     @with_tempdir
291     def test_export_single_curve(self, tempdir: pathlib.Path):
292         abc = tempdir / 'single-curve.abc'
293         script = "import bpy; bpy.ops.wm.alembic_export(filepath='%s', start=1, end=1, " \
294                  "renderable_only=True, visible_layers_only=True, flatten=False)" % abc
295         self.run_blender('single-curve.blend', script)
296
297         # Now check the resulting Alembic file.
298         abcprop = self.abcprop(abc, '/NurbsCurve/NurbsCurveShape/.geom')
299         self.assertEqual(abcprop['.orders'], [4])
300
301         abcprop = self.abcprop(abc, '/NurbsCurve/NurbsCurveShape/.geom/.userProperties')
302         self.assertEqual(abcprop['blender:resolution'], 10)
303
304
305 if __name__ == '__main__':
306     parser = argparse.ArgumentParser()
307     parser.add_argument('--blender', required=True)
308     parser.add_argument('--testdir', required=True)
309     parser.add_argument('--alembic-root', required=True)
310     args, remaining = parser.parse_known_args()
311
312     unittest.main(argv=sys.argv[0:1] + remaining)