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 cls.blender = args.blender
63 cls.testdir = pathlib.Path(args.testdir)
64 cls.alembic_root = pathlib.Path(args.alembic_root)
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')
70 # 'abcls' array notation, like "name[16]"
71 cls.abcls_array = re.compile(r'^(?P<name>[^\[]+)(\[(?P<arraysize>\d+)\])?$')
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.
76 Returns Blender's stdout + stderr combined into one string.
78 :param filepath: taken relative to self.testdir.
79 :param timeout: in seconds
82 blendfile = self.testdir / filepath
92 '--python-exit-code', '47',
93 '--python-expr', python_script,
96 proc = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
98 output = proc.stdout.decode('utf8')
100 self.fail('Error %d running Blender:\n%s' % (proc.returncode, output))
104 def abcprop(self, filepath: pathlib.Path, proppath: str) -> dict:
105 """Uses abcls to obtain compound property values from an Alembic object.
107 A dict of subproperties is returned, where the values are just strings
108 as returned by abcls.
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.
115 abcls = self.alembic_root / 'bin' / 'abcls'
117 command = (str(abcls), '-vl', '%s%s' % (filepath, proppath))
118 proc = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
121 coloured_output = proc.stdout
122 output = self.ansi_remove_re.sub(b'', coloured_output).decode('utf8')
125 self.fail('Error %d running abcls:\n%s' % (proc.returncode, output))
127 # Mapping from value type to callable that can convert a string to Python values.
139 # Ideally we'd get abcls to output JSON, see https://github.com/alembic/alembic/issues/121
140 lines = collections.deque(output.split('\n'))
142 info = lines.popleft()
148 if proptype == 'CompoundProperty':
149 # To read those, call self.abcprop() on it.
151 valtype_and_arrsize, name_and_extent = parts[1:]
153 # Parse name and extent
154 m = self.abcls_array.match(name_and_extent)
156 self.fail('Unparsable name/extent from abcls: %s' % name_and_extent)
157 name, extent = m.group('name'), m.group('arraysize')
160 self.fail('Unsupported extent %s for property %s/%s' % (extent, proppath, name))
163 m = self.abcls_array.match(valtype_and_arrsize)
165 self.fail('Unparsable value type from abcls: %s' % valtype_and_arrsize)
166 valtype, scalarsize = m.group('name'), m.group('arraysize')
170 conv = converters[valtype]
172 self.fail('Unsupported type %s for property %s/%s' % (valtype, proppath, name))
174 def convert_single_line(linevalue):
176 if scalarsize is None:
177 return conv(linevalue)
179 return [conv(v.strip()) for v in linevalue.split(',')]
180 except ValueError as ex:
183 if proptype == 'ScalarProperty':
184 value = lines.popleft()
185 result[name] = convert_single_line(value)
186 elif proptype == 'ArrayProperty':
188 # Arrays consist of a variable number of items, and end in a blank line.
190 linevalue = lines.popleft()
193 arrayvalue.append(convert_single_line(linevalue))
194 result[name] = arrayvalue
196 self.fail('Unsupported type %s for property %s/%s' % (proptype, proppath, name))
200 def assertAlmostEqualFloatArray(self, actual, expect, places=6, delta=None):
201 """Asserts that the arrays of floats are almost equal."""
203 self.assertEqual(len(actual), len(expect),
204 'Actual array has %d items, expected %d' % (len(actual), len(expect)))
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))
211 class HierarchicalAndFlatExportTest(AbstractAlembicTest):
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)
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(
227 3.07484, -2.92265, 0.0586434, 1.0]
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)
237 # Now check the resulting Alembic file.
238 xform = self.abcprop(abc, '/Cube_012/.xform')
239 self.assertEqual(0, xform['.inherits'])
241 self.assertAlmostEqualFloatArray(
243 [0.343134, 0.485243, 0.804238, 0,
244 0.0, 0.856222, -0.516608, 0,
245 -0.939287, 0.177266, 0.293799, 0,
250 class DupliGroupExportTest(AbstractAlembicTest):
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)
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(
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)
276 # Now check the resulting Alembic file.
277 xform = self.abcprop(abc, '/Suzanne/.xform')
278 self.assertEqual(0, xform['.inherits'])
280 self.assertAlmostEqualFloatArray(
289 class CurveExportTest(AbstractAlembicTest):
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)
297 # Now check the resulting Alembic file.
298 abcprop = self.abcprop(abc, '/NurbsCurve/NurbsCurveShape/.geom')
299 self.assertEqual(abcprop['.orders'], [4])
301 abcprop = self.abcprop(abc, '/NurbsCurve/NurbsCurveShape/.geom/.userProperties')
302 self.assertEqual(abcprop['blender:resolution'], 10)
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()
312 unittest.main(argv=sys.argv[0:1] + remaining)