1# ##### BEGIN GPL LICENSE BLOCK #####
2#
3#  This program is free software; you can redistribute it and/or
4#  modify it under the terms of the GNU General Public License
5#  as published by the Free Software Foundation; either version 2
6#  of the License, or (at your option) any later version.
7#
8#  This program is distributed in the hope that it will be useful,
9#  but WITHOUT ANY WARRANTY; without even the implied warranty of
10#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11#  GNU General Public License for more details.
12#
13#  You should have received a copy of the GNU General Public License
14#  along with this program; if not, write to the Free Software Foundation,
15#  Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16#
17# ##### END GPL LICENSE BLOCK #####
18
19# <pep8 compliant>
20
21"""
22./blender.bin --background -noaudio --factory-startup --python tests/python/bl_alembic_io_test.py -- --testdir /path/to/lib/tests/alembic
23"""
24
25import math
26import pathlib
27import sys
28import tempfile
29import unittest
30
31import bpy
32from mathutils import Euler, Matrix, Vector
33
34args = None
35
36
37class AbstractAlembicTest(unittest.TestCase):
38    @classmethod
39    def setUpClass(cls):
40        cls.testdir = args.testdir
41
42    def setUp(self):
43        self.assertTrue(self.testdir.exists(),
44                        'Test dir %s should exist' % self.testdir)
45
46        # Make sure we always start with a known-empty file.
47        bpy.ops.wm.open_mainfile(filepath=str(self.testdir / "empty.blend"))
48
49    def assertAlmostEqualFloatArray(self, actual, expect, places=6, delta=None):
50        """Asserts that the arrays of floats are almost equal."""
51
52        self.assertEqual(len(actual), len(expect),
53                         'Actual array has %d items, expected %d' % (len(actual), len(expect)))
54
55        for idx, (act, exp) in enumerate(zip(actual, expect)):
56            self.assertAlmostEqual(act, exp, places=places, delta=delta,
57                                   msg='%f != %f at index %d' % (act, exp, idx))
58
59
60class SimpleImportTest(AbstractAlembicTest):
61    def test_import_cube_hierarchy(self):
62        res = bpy.ops.wm.alembic_import(
63            filepath=str(self.testdir / "cubes-hierarchy.abc"),
64            as_background_job=False)
65        self.assertEqual({'FINISHED'}, res)
66
67        # The objects should be linked to scene.collection in Blender 2.8,
68        # and to scene in Blender 2.7x.
69        objects = bpy.context.scene.collection.objects
70        self.assertEqual(13, len(objects))
71
72        # Test the hierarchy.
73        self.assertIsNone(objects['Cube'].parent)
74        self.assertEqual(objects['Cube'], objects['Cube_001'].parent)
75        self.assertEqual(objects['Cube'], objects['Cube_002'].parent)
76        self.assertEqual(objects['Cube'], objects['Cube_003'].parent)
77        self.assertEqual(objects['Cube_003'], objects['Cube_004'].parent)
78        self.assertEqual(objects['Cube_003'], objects['Cube_005'].parent)
79        self.assertEqual(objects['Cube_003'], objects['Cube_006'].parent)
80
81    def test_inherit_or_not(self):
82        res = bpy.ops.wm.alembic_import(
83            filepath=str(self.testdir / "T52022-inheritance.abc"),
84            as_background_job=False)
85        self.assertEqual({'FINISHED'}, res)
86
87        # The objects should be linked to scene.collection in Blender 2.8,
88        # and to scene in Blender 2.7x.
89        objects = bpy.context.scene.collection.objects
90
91        # ABC parent is top-level object, which translates to nothing in Blender
92        self.assertIsNone(objects['locator1'].parent)
93
94        # ABC parent is locator1, but locator2 has "inherits Xforms" = false, which
95        # translates to "no parent" in Blender.
96        self.assertIsNone(objects['locator2'].parent)
97
98        depsgraph = bpy.context.evaluated_depsgraph_get()
99
100        # Shouldn't have inherited the ABC parent's transform.
101        loc2 = depsgraph.id_eval_get(objects['locator2'])
102        x, y, z = objects['locator2'].matrix_world.to_translation()
103        self.assertAlmostEqual(0, x)
104        self.assertAlmostEqual(0, y)
105        self.assertAlmostEqual(2, z)
106
107        # ABC parent is inherited and translates to normal parent in Blender.
108        self.assertEqual(objects['locator2'], objects['locatorShape2'].parent)
109
110        # Should have inherited its ABC parent's transform.
111        locshp2 = depsgraph.id_eval_get(objects['locatorShape2'])
112        x, y, z = locshp2.matrix_world.to_translation()
113        self.assertAlmostEqual(0, x)
114        self.assertAlmostEqual(0, y)
115        self.assertAlmostEqual(2, z)
116
117    def test_select_after_import(self):
118        # Add a sphere, so that there is something in the scene, selected, and active,
119        # before we do the Alembic import.
120        bpy.ops.mesh.primitive_uv_sphere_add()
121        sphere = bpy.context.active_object
122        self.assertEqual('Sphere', sphere.name)
123        self.assertEqual([sphere], bpy.context.selected_objects)
124
125        bpy.ops.wm.alembic_import(
126            filepath=str(self.testdir / "cubes-hierarchy.abc"),
127            as_background_job=False)
128
129        # The active object is probably the first one that was imported, but this
130        # behaviour is not defined. At least it should be one of the cubes, and
131        # not the sphere.
132        self.assertNotEqual(sphere, bpy.context.active_object)
133        self.assertTrue('Cube' in bpy.context.active_object.name)
134
135        # All cubes should be selected, but the sphere shouldn't be.
136        for ob in bpy.data.objects:
137            self.assertEqual('Cube' in ob.name, ob.select_get())
138
139    def test_change_path_constraint(self):
140        fname = 'cube-rotating1.abc'
141        abc = self.testdir / fname
142        relpath = bpy.path.relpath(str(abc))
143
144        res = bpy.ops.wm.alembic_import(filepath=str(abc), as_background_job=False)
145        self.assertEqual({'FINISHED'}, res)
146        cube = bpy.context.active_object
147
148        depsgraph = bpy.context.evaluated_depsgraph_get()
149
150        # Check that the file loaded ok.
151        bpy.context.scene.frame_set(10)
152        cube = depsgraph.id_eval_get(cube)
153        x, y, z = cube.matrix_world.to_euler('XYZ')
154        self.assertAlmostEqual(x, 0)
155        self.assertAlmostEqual(y, 0)
156        self.assertAlmostEqual(z, math.pi / 2, places=5)
157
158        # Change path from absolute to relative. This should not break the animation.
159        bpy.context.scene.frame_set(1)
160        bpy.data.cache_files[fname].filepath = relpath
161        bpy.context.scene.frame_set(10)
162
163        cube = depsgraph.id_eval_get(cube)
164        x, y, z = cube.matrix_world.to_euler('XYZ')
165        self.assertAlmostEqual(x, 0)
166        self.assertAlmostEqual(y, 0)
167        self.assertAlmostEqual(z, math.pi / 2, places=5)
168
169        # Replace the Alembic file; this should apply new animation.
170        bpy.data.cache_files[fname].filepath = relpath.replace('1.abc', '2.abc')
171        depsgraph.update()
172
173        cube = depsgraph.id_eval_get(cube)
174        x, y, z = cube.matrix_world.to_euler('XYZ')
175        self.assertAlmostEqual(x, math.pi / 2, places=5)
176        self.assertAlmostEqual(y, 0)
177        self.assertAlmostEqual(z, 0)
178
179    def test_change_path_modifier(self):
180        fname = 'animated-mesh.abc'
181        abc = self.testdir / fname
182        relpath = bpy.path.relpath(str(abc))
183
184        res = bpy.ops.wm.alembic_import(filepath=str(abc), as_background_job=False)
185        self.assertEqual({'FINISHED'}, res)
186        plane = bpy.context.active_object
187
188        depsgraph = bpy.context.evaluated_depsgraph_get()
189
190        # Check that the file loaded ok.
191        bpy.context.scene.frame_set(6)
192        scene = bpy.context.scene
193        plane_eval = plane.evaluated_get(depsgraph)
194        mesh = plane_eval.to_mesh()
195        self.assertAlmostEqual(-1, mesh.vertices[0].co.x)
196        self.assertAlmostEqual(-1, mesh.vertices[0].co.y)
197        self.assertAlmostEqual(0.5905638933181763, mesh.vertices[0].co.z)
198        plane_eval.to_mesh_clear()
199
200        # Change path from absolute to relative. This should not break the animation.
201        scene.frame_set(1)
202        bpy.data.cache_files[fname].filepath = relpath
203        scene.frame_set(6)
204
205        plane_eval = plane.evaluated_get(depsgraph)
206        mesh = plane_eval.to_mesh()
207        self.assertAlmostEqual(1, mesh.vertices[3].co.x)
208        self.assertAlmostEqual(1, mesh.vertices[3].co.y)
209        self.assertAlmostEqual(0.5905638933181763, mesh.vertices[3].co.z)
210        plane_eval.to_mesh_clear()
211
212    def test_import_long_names(self):
213        # This file contains very long names. The longest name is 4047 chars.
214        bpy.ops.wm.alembic_import(
215            filepath=str(self.testdir / "long-names.abc"),
216            as_background_job=False)
217
218        self.assertIn('Cube', bpy.data.objects)
219        self.assertEqual('CubeShape', bpy.data.objects['Cube'].data.name)
220
221
222class VertexColourImportTest(AbstractAlembicTest):
223    def test_import_from_houdini(self):
224        # Houdini saved "face-varying", and as RGB.
225        res = bpy.ops.wm.alembic_import(
226            filepath=str(self.testdir / "vertex-colours-houdini.abc"),
227            as_background_job=False)
228        self.assertEqual({'FINISHED'}, res)
229
230        ob = bpy.context.active_object
231        layer = ob.data.vertex_colors['Cf']  # MeshLoopColorLayer
232
233        # Test some known-good values.
234        self.assertAlmostEqualFloatArray(layer.data[0].color, (0, 0, 0, 1.0))
235        self.assertAlmostEqualFloatArray(layer.data[98].color, (0.9019607, 0.4745098, 0.2666666, 1.0))
236        self.assertAlmostEqualFloatArray(layer.data[99].color, (0.8941176, 0.4705882, 0.2627451, 1.0))
237
238    def test_import_from_blender(self):
239        # Blender saved per-vertex, and as RGBA.
240        res = bpy.ops.wm.alembic_import(
241            filepath=str(self.testdir / "vertex-colours-blender.abc"),
242            as_background_job=False)
243        self.assertEqual({'FINISHED'}, res)
244
245        ob = bpy.context.active_object
246        layer = ob.data.vertex_colors['Cf']  # MeshLoopColorLayer
247
248        # Test some known-good values.
249        self.assertAlmostEqualFloatArray(layer.data[0].color, (1.0, 0.0156862, 0.3607843, 1.0))
250        self.assertAlmostEqualFloatArray(layer.data[98].color, (0.0941176, 0.1215686, 0.9137254, 1.0))
251        self.assertAlmostEqualFloatArray(layer.data[99].color, (0.1294117, 0.3529411, 0.7529411, 1.0))
252
253
254class CameraExportImportTest(unittest.TestCase):
255    names = [
256        'CAM_Unit_Transform',
257        'CAM_Look_+Y',
258        'CAM_Static_Child_Left',
259        'CAM_Static_Child_Right',
260        'Static_Child',
261        'CAM_Animated',
262        'CAM_Animated_Child_Left',
263        'CAM_Animated_Child_Right',
264        'Animated_Child',
265    ]
266
267    def setUp(self):
268        self._tempdir = tempfile.TemporaryDirectory()
269        self.tempdir = pathlib.Path(self._tempdir.name)
270
271    def tearDown(self):
272        # Unload the current blend file to release the imported Alembic file.
273        # This is necessary on Windows in order to be able to delete the
274        # temporary ABC file.
275        bpy.ops.wm.read_homefile()
276        self._tempdir.cleanup()
277
278    def test_export_hierarchy(self):
279        self.do_export_import_test(flatten=False)
280
281        # Double-check that the export was hierarchical.
282        objects = bpy.context.scene.collection.objects
283        for name in self.names:
284            if 'Child' in name:
285                self.assertIsNotNone(objects[name].parent)
286            else:
287                self.assertIsNone(objects[name].parent)
288
289    def test_export_flattened(self):
290        self.do_export_import_test(flatten=True)
291
292        # Double-check that the export was flat.
293        objects = bpy.context.scene.collection.objects
294        for name in self.names:
295            self.assertIsNone(objects[name].parent)
296
297    def do_export_import_test(self, *, flatten: bool):
298        bpy.ops.wm.open_mainfile(filepath=str(args.testdir / "camera_transforms.blend"))
299
300        abc_path = self.tempdir / "camera_transforms.abc"
301        self.assertIn('FINISHED', bpy.ops.wm.alembic_export(
302            filepath=str(abc_path),
303            renderable_only=False,
304            flatten=flatten,
305        ))
306
307        # Re-import what we just exported into an empty file.
308        bpy.ops.wm.open_mainfile(filepath=str(args.testdir / "empty.blend"))
309        self.assertIn('FINISHED', bpy.ops.wm.alembic_import(filepath=str(abc_path)))
310
311        # Test that the import was ok.
312        bpy.context.scene.frame_set(1)
313        self.loc_rot_scale('CAM_Unit_Transform', (0, 0, 0), (0, 0, 0))
314
315        self.loc_rot_scale('CAM_Look_+Y', (2, 0, 0), (90, 0, 0))
316        self.loc_rot_scale('CAM_Static_Child_Left', (2 - 0.15, 0, 0), (90, 0, 0))
317        self.loc_rot_scale('CAM_Static_Child_Right', (2 + 0.15, 0, 0), (90, 0, 0))
318        self.loc_rot_scale('Static_Child', (2, 0, 1), (90, 0, 0))
319
320        self.loc_rot_scale('CAM_Animated', (4, 0, 0), (90, 0, 0))
321        self.loc_rot_scale('CAM_Animated_Child_Left', (4 - 0.15, 0, 0), (90, 0, 0))
322        self.loc_rot_scale('CAM_Animated_Child_Right', (4 + 0.15, 0, 0), (90, 0, 0))
323        self.loc_rot_scale('Animated_Child', (4, 0, 1), (90, 0, 0))
324
325        bpy.context.scene.frame_set(10)
326
327        self.loc_rot_scale('CAM_Animated', (4, 1, 2), (90, 0, 25))
328        self.loc_rot_scale('CAM_Animated_Child_Left', (3.864053, 0.936607, 2), (90, 0, 25))
329        self.loc_rot_scale('CAM_Animated_Child_Right', (4.135946, 1.063392, 2), (90, 0, 25))
330        self.loc_rot_scale('Animated_Child', (4, 1, 3), (90, -45, 25))
331
332    def loc_rot_scale(self, name: str, expect_loc, expect_rot_deg):
333        """Assert world loc/rot/scale is OK."""
334
335        objects = bpy.context.scene.collection.objects
336        depsgraph = bpy.context.evaluated_depsgraph_get()
337        ob_eval = objects[name].evaluated_get(depsgraph)
338
339        actual_loc = ob_eval.matrix_world.to_translation()
340        actual_rot = ob_eval.matrix_world.to_euler('XYZ')
341        actual_scale = ob_eval.matrix_world.to_scale()
342
343        # Precision of the 'almost equal' comparisons.
344        delta_loc = delta_scale = 1e-6
345        delta_rot = math.degrees(1e-6)
346
347        self.assertAlmostEqual(expect_loc[0], actual_loc.x, delta=delta_loc)
348        self.assertAlmostEqual(expect_loc[1], actual_loc.y, delta=delta_loc)
349        self.assertAlmostEqual(expect_loc[2], actual_loc.z, delta=delta_loc)
350
351        self.assertAlmostEqual(expect_rot_deg[0], math.degrees(actual_rot.x), delta=delta_rot)
352        self.assertAlmostEqual(expect_rot_deg[1], math.degrees(actual_rot.y), delta=delta_rot)
353        self.assertAlmostEqual(expect_rot_deg[2], math.degrees(actual_rot.z), delta=delta_rot)
354
355        # This test doesn't use scale.
356        self.assertAlmostEqual(1, actual_scale.x, delta=delta_scale)
357        self.assertAlmostEqual(1, actual_scale.y, delta=delta_scale)
358        self.assertAlmostEqual(1, actual_scale.z, delta=delta_scale)
359
360
361def main():
362    global args
363    import argparse
364
365    if '--' in sys.argv:
366        argv = [sys.argv[0]] + sys.argv[sys.argv.index('--') + 1:]
367    else:
368        argv = sys.argv
369
370    parser = argparse.ArgumentParser()
371    parser.add_argument('--testdir', required=True, type=pathlib.Path)
372    args, remaining = parser.parse_known_args(argv)
373
374    unittest.main(argv=remaining)
375
376
377if __name__ == "__main__":
378    main()
379