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"""
23Alembic Export Tests
24
25This test suite runs outside of Blender. Tests run Blender to call the exporter,
26and then use the Alembic CLI tools to inspect the exported Alembic files.
27"""
28
29
30import argparse
31import pathlib
32import subprocess
33import sys
34import unittest
35from typing import Tuple
36
37from modules.test_utils import (
38    with_tempdir,
39    AbstractBlenderRunnerTest,
40)
41
42
43class AbcPropError(Exception):
44    """Raised when AbstractAlembicTest.abcprop() finds an error."""
45
46
47class AbstractAlembicTest(AbstractBlenderRunnerTest):
48    @classmethod
49    def setUpClass(cls):
50        import re
51
52        cls.blender = args.blender
53        cls.testdir = pathlib.Path(args.testdir)
54        cls.alembic_root = pathlib.Path(args.alembic_root)
55
56        # 'abcls' outputs ANSI colour codes, even when stdout is not a terminal.
57        # See https://github.com/alembic/alembic/issues/120
58        cls.ansi_remove_re = re.compile(rb'\x1b[^m]*m')
59
60        # 'abcls' array notation, like "name[16]"
61        cls.abcls_array = re.compile(r'^(?P<name>[^\[]+)(\[(?P<arraysize>\d+)\])?$')
62
63    def abcls(self, *arguments) -> Tuple[int, str]:
64        """Uses abcls and return its output.
65
66        :return: tuple (process exit status code, stdout)
67        """
68
69        command = (self.alembic_root / 'bin' / 'abcls', *arguments)
70        # Convert Path to str; Path works fine on Linux, but not on Windows.
71        command_str = [str(arg) for arg in command]
72        proc = subprocess.run(command_str, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
73                              timeout=30)
74
75        coloured_output = proc.stdout
76        output = self.ansi_remove_re.sub(b'', coloured_output).decode('utf8')
77
78        # Because of the ANSI colour codes, we need to remove those first before
79        # decoding to text. This means that we cannot use the universal_newlines
80        # parameter to subprocess.run(), and have to do the conversion ourselves
81        output = output.replace('\r\n', '\n').replace('\r', '\n')
82
83        if proc.returncode:
84            str_command = " ".join(str(c) for c in command)
85            print(f'command {str_command} failed with status {proc.returncode}')
86
87        return (proc.returncode, output)
88
89    def abcprop(self, filepath: pathlib.Path, proppath: str) -> dict:
90        """Uses abcls to obtain compound property values from an Alembic object.
91
92        A dict of subproperties is returned, where the values are Python values.
93
94        The Python bindings for Alembic are old, and only compatible with Python 2.x,
95        so that's why we can't use them here, and have to rely on other tooling.
96        """
97        import collections
98
99        command = ('-vl', '%s%s' % (filepath, proppath))
100        returncode, output = self.abcls(*command)
101        if returncode:
102            raise AbcPropError('Error %d running abcls:\n%s' % (returncode, output))
103
104        # Mapping from value type to callable that can convert a string to Python values.
105        converters = {
106            'bool_t': int,
107            'uint8_t': int,
108            'int16_t': int,
109            'int32_t': int,
110            'uint32_t': int,
111            'uint64_t': int,
112            'float64_t': float,
113            'float32_t': float,
114            'string': str,
115        }
116
117        result = {}
118
119        # Ideally we'd get abcls to output JSON, see https://github.com/alembic/alembic/issues/121
120        lines = collections.deque(output.split('\n'))
121        while lines:
122            info = lines.popleft()
123            if not info:
124                continue
125            parts = info.split()
126            proptype = parts[0]
127
128            if proptype == 'CompoundProperty':
129                # To read those, call self.abcprop() on it.
130                continue
131
132            try:
133                valtype_and_arrsize, name_and_extent = parts[1:]
134            except ValueError as ex:
135                raise ValueError(f'Error parsing result from abcprop "{info.strip()}": {ex}') from ex
136
137            # Parse name and extent
138            m = self.abcls_array.match(name_and_extent)
139            if not m:
140                self.fail('Unparsable name/extent from abcls: %s' % name_and_extent)
141            name, extent = m.group('name'), m.group('arraysize')
142
143            if extent != '1':
144                self.fail('Unsupported extent %s for property %s/%s' % (extent, proppath, name))
145
146            # Parse type
147            m = self.abcls_array.match(valtype_and_arrsize)
148            if not m:
149                self.fail('Unparsable value type from abcls: %s' % valtype_and_arrsize)
150            valtype, scalarsize = m.group('name'), m.group('arraysize')
151
152            # Convert values
153            try:
154                conv = converters[valtype]
155            except KeyError:
156                self.fail('Unsupported type %s for property %s/%s' % (valtype, proppath, name))
157
158            def convert_single_line(linevalue):
159                try:
160                    if scalarsize is None:
161                        return conv(linevalue)
162                    else:
163                        return [conv(v.strip()) for v in linevalue.split(',')]
164                except ValueError as ex:
165                    return str(ex)
166
167            if proptype == 'ScalarProperty':
168                value = lines.popleft()
169                result[name] = convert_single_line(value)
170            elif proptype == 'ArrayProperty':
171                arrayvalue = []
172                # Arrays consist of a variable number of items, and end in a blank line.
173                while True:
174                    linevalue = lines.popleft()
175                    if not linevalue:
176                        break
177                    arrayvalue.append(convert_single_line(linevalue))
178                result[name] = arrayvalue
179            else:
180                self.fail('Unsupported type %s for property %s/%s' % (proptype, proppath, name))
181
182        return result
183
184    def assertAlmostEqualFloatArray(self, actual, expect, places=6, delta=None):
185        """Asserts that the arrays of floats are almost equal."""
186
187        self.assertEqual(len(actual), len(expect),
188                         'Actual array has %d items, expected %d' % (len(actual), len(expect)))
189
190        for idx, (act, exp) in enumerate(zip(actual, expect)):
191            self.assertAlmostEqual(act, exp, places=places, delta=delta,
192                                   msg='%f != %f at index %d' % (act, exp, idx))
193
194
195class HierarchicalAndFlatExportTest(AbstractAlembicTest):
196    @with_tempdir
197    def test_hierarchical_export(self, tempdir: pathlib.Path):
198        abc = tempdir / 'cubes_hierarchical.abc'
199        script = "import bpy; bpy.ops.wm.alembic_export(filepath='%s', start=1, end=1, " \
200                 "renderable_only=True, visible_objects_only=True, flatten=False)" % abc.as_posix()
201        self.run_blender('cubes-hierarchy.blend', script)
202
203        # Now check the resulting Alembic file.
204        xform = self.abcprop(abc, '/Cube/Cube_002/Cube_012/.xform')
205        self.assertEqual(1, xform['.inherits'])
206        self.assertAlmostEqualFloatArray(
207            xform['.vals'],
208            [1.0, 0.0, 0.0, 0.0,
209             0.0, 1.0, 0.0, 0.0,
210             0.0, 0.0, 1.0, 0.0,
211             3.07484, -2.92265, 0.0586434, 1.0]
212        )
213
214    @with_tempdir
215    def test_flat_export(self, tempdir: pathlib.Path):
216        abc = tempdir / 'cubes_flat.abc'
217        script = "import bpy; bpy.ops.wm.alembic_export(filepath='%s', start=1, end=1, " \
218                 "renderable_only=True, visible_objects_only=True, flatten=True)" % abc.as_posix()
219        self.run_blender('cubes-hierarchy.blend', script)
220
221        # Now check the resulting Alembic file.
222        xform = self.abcprop(abc, '/Cube_012/.xform')
223        self.assertEqual(1, xform['.inherits'], "Blender transforms always inherit")
224
225        self.assertAlmostEqualFloatArray(
226            xform['.vals'],
227            [0.343134, 0.485243, 0.804238, 0,
228             0.0, 0.856222, -0.516608, 0,
229             -0.939287, 0.177266, 0.293799, 0,
230             1, 3, 4, 1],
231        )
232
233
234class DupliGroupExportTest(AbstractAlembicTest):
235    @with_tempdir
236    def test_hierarchical_export(self, tempdir: pathlib.Path):
237        abc = tempdir / 'dupligroup_hierarchical.abc'
238        script = "import bpy; bpy.ops.wm.alembic_export(filepath='%s', start=1, end=1, " \
239                 "renderable_only=True, visible_objects_only=True, flatten=False)" % abc.as_posix()
240        self.run_blender('dupligroup-scene.blend', script)
241
242        # Now check the resulting Alembic file.
243        xform = self.abcprop(abc, '/Real_Cube/Linked_Suzanne/Cylinder-0/Suzanne-1/.xform')
244        self.assertEqual(1, xform['.inherits'])
245        self.assertAlmostEqualFloatArray(
246            xform['.vals'],
247            [1.0, 0.0, 0.0, 0.0,
248             0.0, 1.0, 0.0, 0.0,
249             0.0, 0.0, 1.0, 0.0,
250             0.0, 2.0, 0.0, 1.0]
251        )
252
253    @with_tempdir
254    def test_flat_export(self, tempdir: pathlib.Path):
255        abc = tempdir / 'dupligroup_hierarchical.abc'
256        script = "import bpy; bpy.ops.wm.alembic_export(filepath='%s', start=1, end=1, " \
257                 "renderable_only=True, visible_objects_only=True, flatten=True)" % abc.as_posix()
258        self.run_blender('dupligroup-scene.blend', script)
259
260        # Now check the resulting Alembic file.
261        xform = self.abcprop(abc, '/Suzanne-1/.xform')
262        self.assertEqual(1, xform['.inherits'])
263
264        self.assertAlmostEqualFloatArray(
265            xform['.vals'],
266            [1.5, 0.0, 0.0, 0.0,
267             0.0, 1.5, 0.0, 0.0,
268             0.0, 0.0, 1.5, 0.0,
269             2.0, 3.0, 0.0, 1.0]
270        )
271
272    @with_tempdir
273    def test_multiple_duplicated_hierarchies(self, tempdir: pathlib.Path):
274        abc = tempdir / "multiple-duplicated-hierarchies.abc"
275        script = "import bpy; bpy.ops.wm.alembic_export(filepath='%s', start=1, end=1)" % abc.as_posix()
276        self.run_blender('multiple-duplicated-hierarchies.blend', script)
277
278        # This is the expected hierarchy:
279        # ABC
280        #  `--Triangle
281        #      |--Triangle
282        #      |--Empty-1
283        #      |    `--Pole-1-0
284        #      |        |--Pole
285        #      |        `--Block-1-1
286        #      |            `--Block
287        #      |--Empty
288        #      |    `--Pole-0
289        #      |        |--Pole
290        #      |        `--Block-1
291        #      |            `--Block
292        #      |--Empty-2
293        #      |    `--Pole-2-0
294        #      |        |--Pole
295        #      |        `--Block-2-1
296        #      |            `--Block
297        #      `--Empty-0
298        #          `--Pole-0-0
299        #              |--Pole
300        #              `--Block-0-1
301        #                  `--Block
302
303        # Now check the resulting Alembic file.
304        xform = self.abcprop(abc, '/Triangle/Empty-1/Pole-1-0/Block-1-1/.xform')
305        self.assertEqual(1, xform['.inherits'])
306        self.assertAlmostEqualFloatArray(
307            xform['.vals'],
308            [1.0, 0.0, 0.0, 0.0,
309             0.0, 1.0, 0.0, 0.0,
310             0.0, 0.0, 1.0, 0.0,
311             0.0, 2.0, 0.0, 1.0]
312        )
313
314        # If the property can be gotten, the hierarchy is okay. No need to actually check each xform.
315        self.abcprop(abc, '/Triangle/.xform')
316        self.abcprop(abc, '/Triangle/Empty-1/.xform')
317        self.abcprop(abc, '/Triangle/Empty-1/Pole-1-0/.xform')
318        self.abcprop(abc, '/Triangle/Empty-1/Pole-1-0/Block-1-1/.xform')
319        self.abcprop(abc, '/Triangle/Empty/.xform')
320        self.abcprop(abc, '/Triangle/Empty/Pole-0/.xform')
321        self.abcprop(abc, '/Triangle/Empty/Pole-0/Block-1/.xform')
322        self.abcprop(abc, '/Triangle/Empty-2/.xform')
323        self.abcprop(abc, '/Triangle/Empty-2/Pole-2-0/.xform')
324        self.abcprop(abc, '/Triangle/Empty-2/Pole-2-0/Block-2-1/.xform')
325        self.abcprop(abc, '/Triangle/Empty-0/.xform')
326        self.abcprop(abc, '/Triangle/Empty-0/Pole-0-0/.xform')
327        self.abcprop(abc, '/Triangle/Empty-0/Pole-0-0/Block-0-1/.xform')
328
329
330class CurveExportTest(AbstractAlembicTest):
331    @with_tempdir
332    def test_export_single_curve(self, tempdir: pathlib.Path):
333        abc = tempdir / 'single-curve.abc'
334        script = "import bpy; bpy.ops.wm.alembic_export(filepath='%s', start=1, end=1, " \
335                 "renderable_only=True, visible_objects_only=True, flatten=False)" % abc.as_posix()
336        self.run_blender('single-curve.blend', script)
337
338        # Now check the resulting Alembic file.
339        abcprop = self.abcprop(abc, '/NurbsCurve/CurveData/.geom')
340        self.assertEqual(abcprop['.orders'], [4])
341
342        abcprop = self.abcprop(abc, '/NurbsCurve/CurveData/.geom/.userProperties')
343        self.assertEqual(abcprop['blender:resolution'], 10)
344
345
346class HairParticlesExportTest(AbstractAlembicTest):
347    """Tests exporting with/without hair/particles.
348
349    Just a basic test to ensure that the enabling/disabling works, and that export
350    works at all. NOT testing the quality/contents of the exported file.
351    """
352
353    def _do_test(self, tempdir: pathlib.Path, export_hair: bool, export_particles: bool) -> pathlib.Path:
354        abc = tempdir / 'hair-particles.abc'
355        script = "import bpy; bpy.ops.wm.alembic_export(filepath='%s', start=1, end=1, " \
356                 "renderable_only=True, visible_objects_only=True, flatten=False, " \
357                 "export_hair=%r, export_particles=%r, as_background_job=False)" \
358                 % (abc.as_posix(), export_hair, export_particles)
359        self.run_blender('hair-particles.blend', script)
360        return abc
361
362    @with_tempdir
363    def test_with_both(self, tempdir: pathlib.Path):
364        abc = self._do_test(tempdir, True, True)
365
366        abcprop = self.abcprop(abc, '/Suzanne/Hair_system/.geom')
367        self.assertIn('nVertices', abcprop)
368
369        abcprop = self.abcprop(abc, '/Suzanne/Non-hair_particle_system/.geom')
370        self.assertIn('.velocities', abcprop)
371
372        abcprop = self.abcprop(abc, '/Suzanne/MonkeyMesh/.geom')
373        self.assertIn('.faceIndices', abcprop)
374
375    @with_tempdir
376    def test_with_hair_only(self, tempdir: pathlib.Path):
377        abc = self._do_test(tempdir, True, False)
378
379        abcprop = self.abcprop(abc, '/Suzanne/Hair_system/.geom')
380        self.assertIn('nVertices', abcprop)
381
382        self.assertRaises(AbcPropError, self.abcprop, abc,
383                          '/Suzanne/Non-hair_particle_system/.geom')
384
385        abcprop = self.abcprop(abc, '/Suzanne/MonkeyMesh/.geom')
386        self.assertIn('.faceIndices', abcprop)
387
388    @with_tempdir
389    def test_with_particles_only(self, tempdir: pathlib.Path):
390        abc = self._do_test(tempdir, False, True)
391
392        self.assertRaises(AbcPropError, self.abcprop, abc, '/Suzanne/Hair_system/.geom')
393
394        abcprop = self.abcprop(abc, '/Suzanne/Non-hair_particle_system/.geom')
395        self.assertIn('.velocities', abcprop)
396
397        abcprop = self.abcprop(abc, '/Suzanne/MonkeyMesh/.geom')
398        self.assertIn('.faceIndices', abcprop)
399
400    @with_tempdir
401    def test_with_neither(self, tempdir: pathlib.Path):
402        abc = self._do_test(tempdir, False, False)
403
404        self.assertRaises(AbcPropError, self.abcprop, abc, '/Suzanne/Hair_system/.geom')
405        self.assertRaises(AbcPropError, self.abcprop, abc,
406                          '/Suzanne/Non-hair_particle_system/.geom')
407
408        abcprop = self.abcprop(abc, '/Suzanne/MonkeyMesh/.geom')
409        self.assertIn('.faceIndices', abcprop)
410
411
412class UVMapExportTest(AbstractAlembicTest):
413    @with_tempdir
414    def test_uvmap_export(self, tempdir: pathlib.Path):
415        """Minimal test for exporting multiple UV maps on an animated mesh.
416
417        This covers the issue reported in T77021.
418        """
419        basename = 'T77021-multiple-uvmaps-animated-mesh'
420        abc = tempdir / f'{basename}.abc'
421        script = f"import bpy; bpy.ops.wm.alembic_export(filepath='{abc.as_posix()}', start=1, end=1, " \
422                 f"renderable_only=True, visible_objects_only=True, flatten=False)"
423        self.run_blender(f'{basename}.blend', script)
424
425        self.maxDiff = 1000
426
427        # The main UV map should be written to .geom
428        abcprop = self.abcprop(abc, '/Cube/Cube/.geom/uv')
429        self.assertEqual(abcprop['.vals'], [
430            [0.625, 0.75],
431            [0.875, 0.75],
432            [0.875, 0.5],
433            [0.625, 0.5],
434            [0.375, 1.0],
435            [0.625, 1.0],
436            [0.375, 0.75],
437            [0.375, 0.25],
438            [0.625, 0.25],
439            [0.625, 0.0],
440            [0.375, 0.0],
441            [0.125, 0.75],
442            [0.375, 0.5],
443            [0.125, 0.5],
444        ])
445
446        # The second UV map should be written to .arbGeomParams
447        abcprop = self.abcprop(abc, '/Cube/Cube/.geom/.arbGeomParams/Secondary')
448        self.assertEqual(abcprop['.vals'], [
449            [0.75, 0.375],
450            [0.75, 0.125],
451            [0.5, 0.125],
452            [0.5, 0.375],
453            [1.0, 0.625],
454            [1.0, 0.375],
455            [0.75, 0.625],
456            [0.25, 0.625],
457            [0.25, 0.375],
458            [0.0, 0.375],
459            [0.0, 0.625],
460            [0.75, 0.875],
461            [0.5, 0.625],
462            [0.5, 0.875],
463        ])
464
465
466class LongNamesExportTest(AbstractAlembicTest):
467    @with_tempdir
468    def test_export_long_names(self, tempdir: pathlib.Path):
469        abc = tempdir / 'long-names.abc'
470        script = "import bpy; bpy.ops.wm.alembic_export(filepath='%s', start=1, end=1, " \
471                 "renderable_only=False, visible_objects_only=False, flatten=False)" % abc.as_posix()
472        self.run_blender('long-names.blend', script)
473
474        name_parts = [
475            'foG9aeLahgoh5goacee1dah6Hethaghohjaich5pasizairuWigee1ahPeekiGh',
476            'yoNgoisheedah2ua0eigh2AeCaiTee5bo0uphoo7Aixephah9racahvaingeeH4',
477            'zuthohnoi1thooS3eezoo8seuph2Boo5aefacaethuvee1aequoonoox1sookie',
478            'wugh4ciTh3dipiepeequait5uug7thiseek5ca7Eijei5ietaizokohhaecieto',
479            'up9aeheenein9oteiX6fohP3thiez6Ahvah0oohah1ep2Eesho4Beboechaipoh',
480            'coh4aehiacheTh0ue0eegho9oku1lohl4loht9ohPoongoow7dasiego6yimuis',
481            'lohtho8eigahfeipohviepajaix4it2peeQu6Iefee1nevihaes4cee2soh4noy',
482            'kaht9ahv0ieXaiyih7ohxe8bah7eeyicahjoa2ohbu7Choxua7oongah6sei4bu',
483            'deif0iPaechohkee5nahx6oi2uJeeN7ze3seunohJibe4shai0mah5Iesh3Quai',
484            'ChohDahshooNee0NeNohthah0eiDeese3Vu6ohShil1Iey9ja0uebi2quiShae6',
485            'Dee1kai7eiph2ahh2nufah3zai3eexeengohQue1caj0eeW0xeghi3eshuadoot',
486            'aeshiup3aengajoog0AhCoo5tiu3ieghaeGhie4Tu1ohh1thee8aepheingah1E',
487            'ooRa6ahciolohshaifoopeo9ZeiGhae2aech4raisheiWah9AaNga0uas9ahquo',
488            'thaepheip2aip6shief4EaXopei8ohPo0ighuiXah2ashowai9nohp4uach6Mei',
489            'ohph4yaev3quieji3phophiem3OoNuisheepahng4waithae3Naichai7aw3noo',
490            'aibeawaneBahmieyuph8ieng8iopheereeD2uu9Uyee5bei2phahXeir8eeJ8oo',
491            'ooshahphei2hoh3uth5chaen7ohsai6uutiesucheichai8ungah9Gie1Aiphie',
492            'eiwohchoo7ere2iebohn4Aapheichaelooriiyaoxaik7ooqua7aezahx0aeJei',
493            'Vah0ohgohphiefohTheshieghichaichahch5moshoo0zai5eeva7eisi4yae8T',
494            'EibeeN0fee0Gohnguz8iec6yeigh7shuNg4eingu3siph9joucahpeidoom4ree',
495            'iejiu3shohheeZahHusheimeefaihoh5eecachu5eeZie9ceisugu9taidohT3U',
496            'eex6dilakaix5Eetai7xiCh5Jaa8aiD4Ag3tuij1aijohv5fo0heevah8hohs3m',
497            'ohqueeNgahraew6uraemohtoo5qua3oojiex6ohqu6Aideibaithaiphuriquie',
498            'cei0eiN4Shiey7Aeluy3unohboo5choiphahc2mahbei5paephaiKeso1thoog1',
499            'ieghif4ohKequ7ong0jah5ooBah0eiGh1caechahnahThae9Shoo0phopashoo4',
500            'roh9er3thohwi5am8iequeequuSh3aic0voocai3ihi5nie2abahphupiegh7vu',
501            'uv3Quei7wujoo5beingei2aish5op4VaiX0aebai7iwoaPee5pei8ko9IepaPig',
502            'co7aegh5beitheesi9lu7jeeQu3johgeiphee9cheichi8aithuDehu2gaeNein',
503            'thai3Tiewoo4nuir1ohy4aithiuZ7shae1luuwei5phibohriepe2paeci1Ach8',
504            'phoi3ribah7ufuvoh8eigh1oB6deeBaiPohphaghiPieshahfah5EiCi3toogoo',
505            'aiM8geil7ooreinee4Cheiwea4yeec8eeshi7Sei4Shoo3wu6ohkaNgooQu1mai',
506            'agoo3faciewah9ZeesiXeereek7am0eigaeShie3Tisu8haReeNgoo0ci2Hae5u',
507            'Aesatheewiedohshaephaenohbooshee8eu7EiJ8isal1laech2eiHo0noaV3ta',
508            'liunguep3ooChoo4eir8ahSie8eenee0oo1TooXu8Cais8Aimo4eir6Phoo3xei',
509            'toe9heepeobein3teequachemei0Cejoomef9ujie3ohwae9AiNgiephi3ep0de',
510            'ua6xooY9uzaeB3of6sheiyaedohoiS5Eev0Aequ9ahm1zoa5Aegh3ooz9ChahDa',
511            'eevasah6Bu9wi7EiwiequumahkaeCheegh6lui8xoh4eeY4ieneavah8phaibun',
512            'AhNgei2sioZeeng6phaecheemeehiShie5eFeiTh6ooV8iiphabud0die4siep4',
513            'kushe6Xieg6ahQuoo9aex3aipheefiec1esa7OhBuG0ueziep9phai5eegh1vie',
514            'Jie5yu8aafuQuoh9shaep3moboh3Pooy7och8oC6obeik6jaew2aiLooweib3ch',
515            'ohohjajaivaiRail3odaimei6aekohVaicheip2wu7phieg5Gohsaing2ahxaiy',
516            'hahzaht6yaiYu9re9jah9loisiit4ahtoh2quoh9xohishioz4oo4phofu3ogha',
517            'pu4oorea0uh2tahB8aiZoonge1aophaes6ogaiK9ailaigeej4zoVou8ielotee',
518            'cae2thei3Luphuqu0zeeG8leeZuchahxaicai4ui4Eedohte9uW6gae8Geeh0ea',
519            'air7tuy7ohw5sho2Tahpai8aep4so5ria7eaShus5weaqu0Naquei2xaeyoo2ae',
520            'vohge4aeCh7ahwoo7Jaex6sohl0Koong4Iejisei8Coir0iemeiz9uru9Iebaep',
521            'aepeidie8aiw6waish9gie4Woolae2thuj5phae4phexux7gishaeph4Deu7ooS',
522            'vahc5ia0xohHooViT0uyuxookiaquu2ogueth0ahquoudeefohshai8aeThahba',
523            'mun3oagah2eequaenohfoo8DaigeghoozaV2eiveeQuee7kah0quaa6tiesheet',
524            'ooSet4IdieC4ugow3za0die4ohGoh1oopoh6luaPhaeng4Eechea1hae0eimie5',
525            'iedeimadaefu2NeiPaey2jooloov5iehiegeakoo4ueso7aeK9ahqu2Thahkaes',
526            'nahquah9Quuu2uuf0aJah7eishi2siegh8ue5eiJa2EeVu8ebohkepoh4dahNgo',
527            'io1bie7chioPiej5ae2oohe2fee6ooP2thaeJohjohb9Se8tang3eipaifeimai',
528            'oungoqu6dieneejiechez1xeD2Zi9iox2Ahchaiy9ithah3ohVoolu2euQuuawo',
529            'thaew0veigei4neishohd8mecaixuqu7eeshiex1chaigohmoThoghoitoTa0Eo',
530            'ahroob2phohvaiz0Ohteik2ohtakie6Iu1vitho8IyiyeeleeShae9defaiw9ki',
531            'DohHoothohzeaxolai3Toh5eJie7ahlah9reF0ohn1chaipoogain2aibahw4no',
532            'aif8lo5she4aich5cho2rie8ieJaujeem2Joongeedae4vie3tah1Leequaix1O',
533            'Aang0Shaih6chahthie1ahZ7aewei9thiethee7iuThah3yoongi8ahngiobaa5',
534            'iephoBuayoothah0Ru6aichai4aiw8deg1umongauvaixai3ohy6oowohlee8ei',
535            'ohn5shigoameer0aejohgoh8oChohlaecho9jie6shu0ahg9Bohngau6paevei9',
536            'edahghaishak0paigh1eecuich3aad7yeB0ieD6akeeliem2beifufaekee6eat',
537            'hiechahgheloh2zo7Ieghaiph0phahhu8aeyuiKie1xeipheech9zai4aeme0ee',
538            'Cube'
539        ]
540        name = '/' + '/'.join(name_parts)
541
542        # Now check the resulting Alembic file.
543        abcprop = self.abcprop(abc, '%s/.xform' % name)
544        self.assertEqual(abcprop['.vals'], [
545            1.0, 0.0, 0.0, 0.0,
546            0.0, 1.0, 0.0, 0.0,
547            0.0, 0.0, 1.0, 0.0,
548            0.0, 3.0, 0.0, 1.0,
549        ])
550
551        abcprop = self.abcprop(abc, '%s/Cube/.geom' % name)
552        self.assertIn('.faceCounts', abcprop)
553
554
555class InvisibleObjectExportTest(AbstractAlembicTest):
556    """Export an object which is invisible.
557
558    This test only tests a small subset of the functionality that is required to
559    export invisible objects. It just tests that the visibility property is
560    written, and that it has the correct initial value. This is a limitation
561    caused by these tests relying on `abcls`.
562    """
563
564    @with_tempdir
565    def test_hierarchical_export(self, tempdir: pathlib.Path):
566        abc = tempdir / 'visibility.abc'
567        script = "import bpy; bpy.ops.wm.alembic_export(filepath='%s', start=1, end=2, " \
568                 "renderable_only=False, visible_objects_only=False)" % abc.as_posix()
569        self.run_blender('visibility.blend', script)
570
571        def test(cube_name: str, expect_visible: bool):
572            returncode, output = self.abcls('-va', f'{abc}/{cube_name}')
573            if returncode:
574                self.fail(f"abcls failed: {output}")
575            output = output.strip()
576            self.assertEqual(f'Cube   .xform   visible   {int(expect_visible)}', output)
577
578        # This cube is always visible.
579        test('VisibleCube', True)
580
581        # This cube is never visible, and thus will not be pulled into the
582        # depsgraph by the standard builder, only by the all-objects builder.
583        test('InvisibleCube', False)
584
585        # This cube has animated visibility, and thus will be pulled into the
586        # depsgraph by the standard builder as well as the all-objects builder.
587        test('InvisibleAnimatedCube', False)
588
589
590class CustomPropertiesExportTest(AbstractAlembicTest):
591    """Test export of custom properties."""
592
593    def _run_export(self, tempdir: pathlib.Path) -> pathlib.Path:
594        abc = tempdir / 'custom-properties.abc'
595        script = (
596            "import bpy; bpy.context.scene.frame_set(1); "
597            "bpy.ops.wm.alembic_export(filepath='%s', start=1, end=1)" % abc.as_posix()
598        )
599        self.run_blender('custom-properties.blend', script)
600        return abc
601
602    @with_tempdir
603    def test_xform_props(self, tempdir: pathlib.Path) -> None:
604        abc = self._run_export(tempdir)
605        abcprop = self.abcprop(abc, '/Cube/.xform/.userProperties')
606
607        # Simple, single values.
608        self.assertEqual(abcprop['static_int'], [327])
609        self.assertEqual(abcprop['static_float'], [47.01])
610        self.assertEqual(abcprop['static_string'], ['Agents'])
611        self.assertEqual(abcprop['keyed_float'], [-1])
612        self.assertEqual(abcprop['keyed_int'], [-47])
613
614        # Arrays.
615        self.assertEqual(abcprop['keyed_array_float'], [-1.000, 0.000, 1.000])
616        self.assertEqual(abcprop['keyed_array_int'], [42, 47, 327])
617
618        # Multi-dimensional arrays.
619        self.assertEqual(abcprop['array_of_strings'], ['ผัดไทย', 'Pad Thai'])
620        self.assertEqual(
621            abcprop['matrix_tuple'],
622            [1.0, 0.0, 0.0, 3.33333, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0])
623        self.assertEqual(
624            abcprop['static_matrix'],
625            [1.0, 0.0, 0.0, 3.33333, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0])
626        self.assertEqual(
627            abcprop['nonuniform_array'],
628            [10, 20, 30, 1, 2, 47])
629
630    @with_tempdir
631    def test_mesh_props(self, tempdir: pathlib.Path) -> None:
632        abc = self._run_export(tempdir)
633        abcprop = self.abcprop(abc, '/Cube/Cube/.geom/.userProperties')
634        self.assertEqual(abcprop['mesh_tags'], ['cube', 'box', 'low-poly-sphere'])
635
636    @with_tempdir
637    def test_camera_props(self, tempdir: pathlib.Path) -> None:
638        abc = self._run_export(tempdir)
639        abcprop = self.abcprop(abc, '/Camera/Hasselblad/.geom/.userProperties')
640        self.assertEqual(abcprop['type'], ['500c/m'])
641
642    @with_tempdir
643    def test_disabled_export_option(self, tempdir: pathlib.Path) -> None:
644        abc = tempdir / 'custom-properties.abc'
645        script = (
646            "import bpy; bpy.context.scene.frame_set(1); "
647            "bpy.ops.wm.alembic_export(filepath='%s', start=1, end=1, export_custom_properties=False)" % abc.as_posix()
648        )
649        self.run_blender('custom-properties.blend', script)
650
651        abcprop = self.abcprop(abc, '/Camera/Hasselblad/.geom/.userProperties')
652        self.assertIn('eyeSeparation', abcprop, 'Regular non-standard properties should still be written')
653        self.assertNotIn('type', abcprop, 'Custom properties should not be written')
654
655
656if __name__ == '__main__':
657    parser = argparse.ArgumentParser()
658    parser.add_argument('--blender', required=True)
659    parser.add_argument('--testdir', required=True)
660    parser.add_argument('--alembic-root', required=True)
661    args, remaining = parser.parse_known_args()
662
663    unittest.main(argv=sys.argv[0:1] + remaining)
664