2 # ##### BEGIN GPL LICENSE BLOCK #####
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.
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.
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.
18 # ##### END GPL LICENSE BLOCK #####
32 def with_tempdir(wrapped):
33 """Creates a temporary directory for the function, cleaning up after it returns normally.
35 When the wrapped function raises an exception, the contents of the temporary directory
36 remain available for manual inspection.
38 The wrapped function is called with an extra positional argument containing
39 the pathlib.Path() of the temporary directory.
42 @functools.wraps(wrapped)
43 def decorator(*args, **kwargs):
44 dirname = tempfile.mkdtemp(prefix='blender-alembic-test')
46 retval = wrapped(*args, pathlib.Path(dirname), **kwargs)
48 print('Exception in %s, not cleaning up temporary directory %s' % (wrapped, dirname))
51 shutil.rmtree(dirname)
57 class AbstractAlembicTest(unittest.TestCase):
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()
68 cls.blender = args.blender
69 cls.testdir = pathlib.Path(args.testdir)
70 cls.alembic_root = pathlib.Path(args.alembic_root)
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')
76 # 'abcls' array notation, like "name[16]"
77 cls.abcls_array = re.compile(r'^(?P<name>[^\[]+)(\[(?P<arraysize>\d+)\])?$')
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.
82 Returns Blender's stdout + stderr combined into one string.
84 :param filepath: taken relative to self.testdir.
85 :param timeout: in seconds
88 blendfile = self.testdir / filepath
98 '--python-exit-code', '47',
99 '--python-expr', python_script,
102 proc = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
104 output = proc.stdout.decode('utf8')
106 self.fail('Error %d running Blender:\n%s' % (proc.returncode, output))
110 def abcprop(self, filepath: pathlib.Path, proppath: str) -> dict:
111 """Uses abcls to obtain compound property values from an Alembic object.
113 A dict of subproperties is returned, where the values are just strings
114 as returned by abcls.
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.
121 abcls = self.alembic_root / 'bin' / 'abcls'
123 command = (str(abcls), '-vl', '%s%s' % (filepath, proppath))
124 proc = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
127 coloured_output = proc.stdout
128 output = self.ansi_remove_re.sub(b'', coloured_output).decode('utf8')
131 self.fail('Error %d running abcls:\n%s' % (proc.returncode, output))
133 # Mapping from value type to callable that can convert a string to Python values.
144 # Ideally we'd get abcls to output JSON, see https://github.com/alembic/alembic/issues/121
145 lines = collections.deque(output.split('\n'))
147 info = lines.popleft()
150 proptype, valtype_and_arrsize, name_and_extent = info.split()
152 # Parse name and extent
153 m = self.abcls_array.match(name_and_extent)
155 self.fail('Unparsable name/extent from abcls: %s' % name_and_extent)
156 name, extent = m.group('name'), m.group('arraysize')
159 self.fail('Unsupported extent %s for property %s/%s' % (extent, proppath, name))
162 m = self.abcls_array.match(valtype_and_arrsize)
164 self.fail('Unparsable value type from abcls: %s' % valtype_and_arrsize)
165 valtype, scalarsize = m.group('name'), m.group('arraysize')
169 conv = converters[valtype]
171 self.fail('Unsupported type %s for property %s/%s' % (valtype, proppath, name))
173 def convert_single_line(linevalue):
175 if scalarsize is None:
176 return conv(linevalue)
178 return [conv(v.strip()) for v in linevalue.split(',')]
179 except ValueError as ex:
182 if proptype == 'ScalarProperty':
183 value = lines.popleft()
184 result[name] = convert_single_line(value)
185 elif proptype == 'ArrayProperty':
187 # Arrays consist of a variable number of items, and end in a blank line.
189 linevalue = lines.popleft()
192 arrayvalue.append(convert_single_line(linevalue))
193 result[name] = arrayvalue
195 self.fail('Unsupported type %s for property %s/%s' % (proptype, proppath, name))
199 def assertAlmostEqualFloatArray(self, actual, expect, places=6, delta=None):
200 """Asserts that the arrays of floats are almost equal."""
202 self.assertEqual(len(actual), len(expect),
203 'Actual array has %d items, expected %d' % (len(actual), len(expect)))
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))
210 class HierarchicalAndFlatExportTest(AbstractAlembicTest):
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)
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(
226 3.07484, -2.92265, 0.0586434, 1.0]
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)
236 # Now check the resulting Alembic file.
237 xform = self.abcprop(abc, '/Cube_012/.xform')
238 self.assertEqual(0, xform['.inherits'])
240 self.assertAlmostEqualFloatArray(
242 [0.343134, 0.485243, 0.804238, 0,
243 0.0, 0.856222, -0.516608, 0,
244 -0.939287, 0.177266, 0.293799, 0,
249 class DupliGroupExportTest(AbstractAlembicTest):
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)
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(
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)
275 # Now check the resulting Alembic file.
276 xform = self.abcprop(abc, '/Suzanne/.xform')
277 self.assertEqual(0, xform['.inherits'])
279 self.assertAlmostEqualFloatArray(
288 class CurveExportTest(AbstractAlembicTest):
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)
296 # Now check the resulting Alembic file.
297 abcprop = self.abcprop(abc, '/NurbsCurve/NurbsCurveShape/.geom')
298 self.assertEqual(abcprop['.orders'], [4])
301 if __name__ == '__main__':
302 unittest.main(argv=sys.argv[0:1])