209b34a8634cc1b0bc12f0fd3f13cefcd81237cd
[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
120         abcls = self.alembic_root / 'bin' / 'abcls'
121
122         command = (str(abcls), '-vl', '%s%s' % (filepath, proppath))
123         proc = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
124                               timeout=30)
125
126         coloured_output = proc.stdout
127         output = self.ansi_remove_re.sub(b'', coloured_output).decode('utf8')
128
129         if proc.returncode:
130             self.fail('Error %d running abcls:\n%s' % (proc.returncode, output))
131
132         # Mapping from value type to callable that can convert a string to Python values.
133         converters = {
134             'bool_t': int,
135             'uint8_t': int,
136             'float64_t': float,
137         }
138
139         result = {}
140
141         # Ideally we'd get abcls to output JSON, see https://github.com/alembic/alembic/issues/121
142         lines = output.split('\n')
143         for info, value in zip(lines[0::2], lines[1::2]):
144             proptype, valtype_and_arrsize, name_and_extent = info.split()
145
146             # Parse name and extent
147             m = self.abcls_array.match(name_and_extent)
148             if not m:
149                 self.fail('Unparsable name/extent from abcls: %s' % name_and_extent)
150             name, extent = m.group('name'), m.group('arraysize')
151
152             if extent != '1':
153                 self.fail('Unsupported extent %s for property %s/%s' % (extent, proppath, name))
154
155             # Parse type and convert values
156             m = self.abcls_array.match(valtype_and_arrsize)
157             if not m:
158                 self.fail('Unparsable value type from abcls: %s' % valtype_and_arrsize)
159             valtype, arraysize = m.group('name'), m.group('arraysize')
160
161             try:
162                 conv = converters[valtype]
163             except KeyError:
164                 self.fail('Unsupported type %s for property %s/%s' % (valtype, proppath, name))
165
166             if arraysize is None:
167                 result[name] = conv(value)
168             else:
169                 values = [conv(v.strip()) for v in value.split(',')]
170                 result[name] = values
171
172         return result
173
174     def assertAlmostEqualFloatArray(self, actual, expect, places=6, delta=None):
175         """Asserts that the arrays of floats are almost equal."""
176
177         self.assertEqual(len(actual), len(expect),
178                          'Actual array has %d items, expected %d' % (len(actual), len(expect)))
179
180         for idx, (act, exp) in enumerate(zip(actual, expect)):
181             self.assertAlmostEqual(act, exp, places=places, delta=delta,
182                                    msg='%f != %f at index %d' % (act, exp, idx))
183
184
185 class HierarchicalAndFlatExportTest(AbstractAlembicTest):
186     @with_tempdir
187     def test_hierarchical_export(self, tempdir: pathlib.Path):
188         abc = tempdir / 'cubes_hierarchical.abc'
189         script = "import bpy; bpy.ops.wm.alembic_export(filepath='%s', start=1, end=1, " \
190                  "renderable_only=True, visible_layers_only=True, flatten=False)" % abc
191         self.run_blender('cubes-hierarchy.blend', script)
192
193         # Now check the resulting Alembic file.
194         xform = self.abcprop(abc, '/Cube/Cube_002/Cube_012/.xform')
195         self.assertEqual(1, xform['.inherits'])
196         self.assertAlmostEqualFloatArray(
197             xform['.vals'],
198             [1.0, 0.0, 0.0, 0.0,
199              0.0, 1.0, 0.0, 0.0,
200              0.0, 0.0, 1.0, 0.0,
201              3.07484, -2.92265, 0.0586434, 1.0]
202         )
203
204     @with_tempdir
205     def test_flat_export(self, tempdir: pathlib.Path):
206         abc = tempdir / 'cubes_flat.abc'
207         script = "import bpy; bpy.ops.wm.alembic_export(filepath='%s', start=1, end=1, " \
208                  "renderable_only=True, visible_layers_only=True, flatten=True)" % abc
209         self.run_blender('cubes-hierarchy.blend', script)
210
211         # Now check the resulting Alembic file.
212         xform = self.abcprop(abc, '/Cube_012/.xform')
213         self.assertEqual(0, xform['.inherits'])
214
215         self.assertAlmostEqualFloatArray(
216             xform['.vals'],
217             [0.343134, 0.485243, 0.804238, 0,
218              0.0, 0.856222, -0.516608, 0,
219              -0.939287, 0.177266, 0.293799, 0,
220              1, 3, 4, 1],
221         )
222
223
224 class DupliGroupExportTest(AbstractAlembicTest):
225     @with_tempdir
226     def test_hierarchical_export(self, tempdir: pathlib.Path):
227         abc = tempdir / 'dupligroup_hierarchical.abc'
228         script = "import bpy; bpy.ops.wm.alembic_export(filepath='%s', start=1, end=1, " \
229                  "renderable_only=True, visible_layers_only=True, flatten=False)" % abc
230         self.run_blender('dupligroup-scene.blend', script)
231
232         # Now check the resulting Alembic file.
233         xform = self.abcprop(abc, '/Real_Cube/Linked_Suzanne/Cylinder/Suzanne/.xform')
234         self.assertEqual(1, xform['.inherits'])
235         self.assertAlmostEqualFloatArray(
236             xform['.vals'],
237             [1.0, 0.0, 0.0, 0.0,
238              0.0, 1.0, 0.0, 0.0,
239              0.0, 0.0, 1.0, 0.0,
240              0.0, 2.0, 0.0, 1.0]
241         )
242
243     @with_tempdir
244     def test_flat_export(self, tempdir: pathlib.Path):
245         abc = tempdir / 'dupligroup_hierarchical.abc'
246         script = "import bpy; bpy.ops.wm.alembic_export(filepath='%s', start=1, end=1, " \
247                  "renderable_only=True, visible_layers_only=True, flatten=True)" % abc
248         self.run_blender('dupligroup-scene.blend', script)
249
250         # Now check the resulting Alembic file.
251         xform = self.abcprop(abc, '/Suzanne/.xform')
252         self.assertEqual(0, xform['.inherits'])
253
254         self.assertAlmostEqualFloatArray(
255             xform['.vals'],
256             [1.5, 0.0, 0.0, 0.0,
257              0.0, 1.5, 0.0, 0.0,
258              0.0, 0.0, 1.5, 0.0,
259              2.0, 3.0, 0.0, 1.0]
260         )
261
262
263
264
265 if __name__ == '__main__':
266     unittest.main(argv=sys.argv[0:1])