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