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