alembic_tests.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425
  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. # <pep8 compliant>
  20. import argparse
  21. import pathlib
  22. import subprocess
  23. import sys
  24. import unittest
  25. from modules.test_utils import (
  26. with_tempdir,
  27. AbstractBlenderRunnerTest,
  28. )
  29. class AbcPropError(Exception):
  30. """Raised when AbstractAlembicTest.abcprop() finds an error."""
  31. class AbstractAlembicTest(AbstractBlenderRunnerTest):
  32. @classmethod
  33. def setUpClass(cls):
  34. import re
  35. cls.blender = args.blender
  36. cls.testdir = pathlib.Path(args.testdir)
  37. cls.alembic_root = pathlib.Path(args.alembic_root)
  38. # 'abcls' outputs ANSI colour codes, even when stdout is not a terminal.
  39. # See https://github.com/alembic/alembic/issues/120
  40. cls.ansi_remove_re = re.compile(rb'\x1b[^m]*m')
  41. # 'abcls' array notation, like "name[16]"
  42. cls.abcls_array = re.compile(r'^(?P<name>[^\[]+)(\[(?P<arraysize>\d+)\])?$')
  43. def abcprop(self, filepath: pathlib.Path, proppath: str) -> dict:
  44. """Uses abcls to obtain compound property values from an Alembic object.
  45. A dict of subproperties is returned, where the values are Python values.
  46. The Python bindings for Alembic are old, and only compatible with Python 2.x,
  47. so that's why we can't use them here, and have to rely on other tooling.
  48. """
  49. import collections
  50. abcls = self.alembic_root / 'bin' / 'abcls'
  51. command = (str(abcls), '-vl', '%s%s' % (filepath, proppath))
  52. proc = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
  53. timeout=30)
  54. coloured_output = proc.stdout
  55. output = self.ansi_remove_re.sub(b'', coloured_output).decode('utf8')
  56. # Because of the ANSI colour codes, we need to remove those first before
  57. # decoding to text. This means that we cannot use the universal_newlines
  58. # parameter to subprocess.run(), and have to do the conversion ourselves
  59. output = output.replace('\r\n', '\n').replace('\r', '\n')
  60. if proc.returncode:
  61. raise AbcPropError('Error %d running abcls:\n%s' % (proc.returncode, output))
  62. # Mapping from value type to callable that can convert a string to Python values.
  63. converters = {
  64. 'bool_t': int,
  65. 'uint8_t': int,
  66. 'int16_t': int,
  67. 'int32_t': int,
  68. 'uint64_t': int,
  69. 'float64_t': float,
  70. 'float32_t': float,
  71. }
  72. result = {}
  73. # Ideally we'd get abcls to output JSON, see https://github.com/alembic/alembic/issues/121
  74. lines = collections.deque(output.split('\n'))
  75. while lines:
  76. info = lines.popleft()
  77. if not info:
  78. continue
  79. parts = info.split()
  80. proptype = parts[0]
  81. if proptype == 'CompoundProperty':
  82. # To read those, call self.abcprop() on it.
  83. continue
  84. if len(parts) < 2:
  85. raise ValueError('Error parsing result from abcprop: %s', info.strip())
  86. valtype_and_arrsize, name_and_extent = parts[1:]
  87. # Parse name and extent
  88. m = self.abcls_array.match(name_and_extent)
  89. if not m:
  90. self.fail('Unparsable name/extent from abcls: %s' % name_and_extent)
  91. name, extent = m.group('name'), m.group('arraysize')
  92. if extent != '1':
  93. self.fail('Unsupported extent %s for property %s/%s' % (extent, proppath, name))
  94. # Parse type
  95. m = self.abcls_array.match(valtype_and_arrsize)
  96. if not m:
  97. self.fail('Unparsable value type from abcls: %s' % valtype_and_arrsize)
  98. valtype, scalarsize = m.group('name'), m.group('arraysize')
  99. # Convert values
  100. try:
  101. conv = converters[valtype]
  102. except KeyError:
  103. self.fail('Unsupported type %s for property %s/%s' % (valtype, proppath, name))
  104. def convert_single_line(linevalue):
  105. try:
  106. if scalarsize is None:
  107. return conv(linevalue)
  108. else:
  109. return [conv(v.strip()) for v in linevalue.split(',')]
  110. except ValueError as ex:
  111. return str(ex)
  112. if proptype == 'ScalarProperty':
  113. value = lines.popleft()
  114. result[name] = convert_single_line(value)
  115. elif proptype == 'ArrayProperty':
  116. arrayvalue = []
  117. # Arrays consist of a variable number of items, and end in a blank line.
  118. while True:
  119. linevalue = lines.popleft()
  120. if not linevalue:
  121. break
  122. arrayvalue.append(convert_single_line(linevalue))
  123. result[name] = arrayvalue
  124. else:
  125. self.fail('Unsupported type %s for property %s/%s' % (proptype, proppath, name))
  126. return result
  127. def assertAlmostEqualFloatArray(self, actual, expect, places=6, delta=None):
  128. """Asserts that the arrays of floats are almost equal."""
  129. self.assertEqual(len(actual), len(expect),
  130. 'Actual array has %d items, expected %d' % (len(actual), len(expect)))
  131. for idx, (act, exp) in enumerate(zip(actual, expect)):
  132. self.assertAlmostEqual(act, exp, places=places, delta=delta,
  133. msg='%f != %f at index %d' % (act, exp, idx))
  134. class HierarchicalAndFlatExportTest(AbstractAlembicTest):
  135. @with_tempdir
  136. def test_hierarchical_export(self, tempdir: pathlib.Path):
  137. abc = tempdir / 'cubes_hierarchical.abc'
  138. script = "import bpy; bpy.ops.wm.alembic_export(filepath='%s', start=1, end=1, " \
  139. "renderable_only=True, visible_layers_only=True, flatten=False)" % abc.as_posix()
  140. self.run_blender('cubes-hierarchy.blend', script)
  141. # Now check the resulting Alembic file.
  142. xform = self.abcprop(abc, '/Cube/Cube_002/Cube_012/.xform')
  143. self.assertEqual(1, xform['.inherits'])
  144. self.assertAlmostEqualFloatArray(
  145. xform['.vals'],
  146. [1.0, 0.0, 0.0, 0.0,
  147. 0.0, 1.0, 0.0, 0.0,
  148. 0.0, 0.0, 1.0, 0.0,
  149. 3.07484, -2.92265, 0.0586434, 1.0]
  150. )
  151. @with_tempdir
  152. def test_flat_export(self, tempdir: pathlib.Path):
  153. abc = tempdir / 'cubes_flat.abc'
  154. script = "import bpy; bpy.ops.wm.alembic_export(filepath='%s', start=1, end=1, " \
  155. "renderable_only=True, visible_layers_only=True, flatten=True)" % abc.as_posix()
  156. self.run_blender('cubes-hierarchy.blend', script)
  157. # Now check the resulting Alembic file.
  158. xform = self.abcprop(abc, '/Cube_012/.xform')
  159. self.assertEqual(0, xform['.inherits'])
  160. self.assertAlmostEqualFloatArray(
  161. xform['.vals'],
  162. [0.343134, 0.485243, 0.804238, 0,
  163. 0.0, 0.856222, -0.516608, 0,
  164. -0.939287, 0.177266, 0.293799, 0,
  165. 1, 3, 4, 1],
  166. )
  167. class DupliGroupExportTest(AbstractAlembicTest):
  168. @with_tempdir
  169. def test_hierarchical_export(self, tempdir: pathlib.Path):
  170. abc = tempdir / 'dupligroup_hierarchical.abc'
  171. script = "import bpy; bpy.ops.wm.alembic_export(filepath='%s', start=1, end=1, " \
  172. "renderable_only=True, visible_layers_only=True, flatten=False)" % abc.as_posix()
  173. self.run_blender('dupligroup-scene.blend', script)
  174. # Now check the resulting Alembic file.
  175. xform = self.abcprop(abc, '/Real_Cube/Linked_Suzanne/Cylinder/Suzanne/.xform')
  176. self.assertEqual(1, xform['.inherits'])
  177. self.assertAlmostEqualFloatArray(
  178. xform['.vals'],
  179. [1.0, 0.0, 0.0, 0.0,
  180. 0.0, 1.0, 0.0, 0.0,
  181. 0.0, 0.0, 1.0, 0.0,
  182. 0.0, 2.0, 0.0, 1.0]
  183. )
  184. @with_tempdir
  185. def test_flat_export(self, tempdir: pathlib.Path):
  186. abc = tempdir / 'dupligroup_hierarchical.abc'
  187. script = "import bpy; bpy.ops.wm.alembic_export(filepath='%s', start=1, end=1, " \
  188. "renderable_only=True, visible_layers_only=True, flatten=True)" % abc.as_posix()
  189. self.run_blender('dupligroup-scene.blend', script)
  190. # Now check the resulting Alembic file.
  191. xform = self.abcprop(abc, '/Suzanne/.xform')
  192. self.assertEqual(0, xform['.inherits'])
  193. self.assertAlmostEqualFloatArray(
  194. xform['.vals'],
  195. [1.5, 0.0, 0.0, 0.0,
  196. 0.0, 1.5, 0.0, 0.0,
  197. 0.0, 0.0, 1.5, 0.0,
  198. 2.0, 3.0, 0.0, 1.0]
  199. )
  200. class CurveExportTest(AbstractAlembicTest):
  201. @with_tempdir
  202. def test_export_single_curve(self, tempdir: pathlib.Path):
  203. abc = tempdir / 'single-curve.abc'
  204. script = "import bpy; bpy.ops.wm.alembic_export(filepath='%s', start=1, end=1, " \
  205. "renderable_only=True, visible_layers_only=True, flatten=False)" % abc.as_posix()
  206. self.run_blender('single-curve.blend', script)
  207. # Now check the resulting Alembic file.
  208. abcprop = self.abcprop(abc, '/NurbsCurve/NurbsCurveShape/.geom')
  209. self.assertEqual(abcprop['.orders'], [4])
  210. abcprop = self.abcprop(abc, '/NurbsCurve/NurbsCurveShape/.geom/.userProperties')
  211. self.assertEqual(abcprop['blender:resolution'], 10)
  212. class HairParticlesExportTest(AbstractAlembicTest):
  213. """Tests exporting with/without hair/particles.
  214. Just a basic test to ensure that the enabling/disabling works, and that export
  215. works at all. NOT testing the quality/contents of the exported file.
  216. """
  217. def _do_test(self, tempdir: pathlib.Path, export_hair: bool, export_particles: bool) -> pathlib.Path:
  218. abc = tempdir / 'hair-particles.abc'
  219. script = "import bpy; bpy.ops.wm.alembic_export(filepath='%s', start=1, end=1, " \
  220. "renderable_only=True, visible_layers_only=True, flatten=False, " \
  221. "export_hair=%r, export_particles=%r, as_background_job=False)" \
  222. % (abc.as_posix(), export_hair, export_particles)
  223. self.run_blender('hair-particles.blend', script)
  224. return abc
  225. @with_tempdir
  226. def test_with_both(self, tempdir: pathlib.Path):
  227. abc = self._do_test(tempdir, True, True)
  228. abcprop = self.abcprop(abc, '/Suzanne/Hair system/.geom')
  229. self.assertIn('nVertices', abcprop)
  230. abcprop = self.abcprop(abc, '/Suzanne/Non-hair particle system/.geom')
  231. self.assertIn('.velocities', abcprop)
  232. abcprop = self.abcprop(abc, '/Suzanne/SuzanneShape/.geom')
  233. self.assertIn('.faceIndices', abcprop)
  234. @with_tempdir
  235. def test_with_hair_only(self, tempdir: pathlib.Path):
  236. abc = self._do_test(tempdir, True, False)
  237. abcprop = self.abcprop(abc, '/Suzanne/Hair system/.geom')
  238. self.assertIn('nVertices', abcprop)
  239. self.assertRaises(AbcPropError, self.abcprop, abc,
  240. '/Suzanne/Non-hair particle system/.geom')
  241. abcprop = self.abcprop(abc, '/Suzanne/SuzanneShape/.geom')
  242. self.assertIn('.faceIndices', abcprop)
  243. @with_tempdir
  244. def test_with_particles_only(self, tempdir: pathlib.Path):
  245. abc = self._do_test(tempdir, False, True)
  246. self.assertRaises(AbcPropError, self.abcprop, abc, '/Suzanne/Hair system/.geom')
  247. abcprop = self.abcprop(abc, '/Suzanne/Non-hair particle system/.geom')
  248. self.assertIn('.velocities', abcprop)
  249. abcprop = self.abcprop(abc, '/Suzanne/SuzanneShape/.geom')
  250. self.assertIn('.faceIndices', abcprop)
  251. @with_tempdir
  252. def test_with_neither(self, tempdir: pathlib.Path):
  253. abc = self._do_test(tempdir, False, False)
  254. self.assertRaises(AbcPropError, self.abcprop, abc, '/Suzanne/Hair system/.geom')
  255. self.assertRaises(AbcPropError, self.abcprop, abc,
  256. '/Suzanne/Non-hair particle system/.geom')
  257. abcprop = self.abcprop(abc, '/Suzanne/SuzanneShape/.geom')
  258. self.assertIn('.faceIndices', abcprop)
  259. class LongNamesExportTest(AbstractAlembicTest):
  260. @with_tempdir
  261. def test_export_long_names(self, tempdir: pathlib.Path):
  262. abc = tempdir / 'long-names.abc'
  263. script = "import bpy; bpy.ops.wm.alembic_export(filepath='%s', start=1, end=1, " \
  264. "renderable_only=False, visible_layers_only=False, flatten=False)" % abc.as_posix()
  265. self.run_blender('long-names.blend', script)
  266. name_parts = [
  267. 'foG9aeLahgoh5goacee1dah6Hethaghohjaich5pasizairuWigee1ahPeekiGh',
  268. 'yoNgoisheedah2ua0eigh2AeCaiTee5bo0uphoo7Aixephah9racahvaingeeH4',
  269. 'zuthohnoi1thooS3eezoo8seuph2Boo5aefacaethuvee1aequoonoox1sookie',
  270. 'wugh4ciTh3dipiepeequait5uug7thiseek5ca7Eijei5ietaizokohhaecieto',
  271. 'up9aeheenein9oteiX6fohP3thiez6Ahvah0oohah1ep2Eesho4Beboechaipoh',
  272. 'coh4aehiacheTh0ue0eegho9oku1lohl4loht9ohPoongoow7dasiego6yimuis',
  273. 'lohtho8eigahfeipohviepajaix4it2peeQu6Iefee1nevihaes4cee2soh4noy',
  274. 'kaht9ahv0ieXaiyih7ohxe8bah7eeyicahjoa2ohbu7Choxua7oongah6sei4bu',
  275. 'deif0iPaechohkee5nahx6oi2uJeeN7ze3seunohJibe4shai0mah5Iesh3Quai',
  276. 'ChohDahshooNee0NeNohthah0eiDeese3Vu6ohShil1Iey9ja0uebi2quiShae6',
  277. 'Dee1kai7eiph2ahh2nufah3zai3eexeengohQue1caj0eeW0xeghi3eshuadoot',
  278. 'aeshiup3aengajoog0AhCoo5tiu3ieghaeGhie4Tu1ohh1thee8aepheingah1E',
  279. 'ooRa6ahciolohshaifoopeo9ZeiGhae2aech4raisheiWah9AaNga0uas9ahquo',
  280. 'thaepheip2aip6shief4EaXopei8ohPo0ighuiXah2ashowai9nohp4uach6Mei',
  281. 'ohph4yaev3quieji3phophiem3OoNuisheepahng4waithae3Naichai7aw3noo',
  282. 'aibeawaneBahmieyuph8ieng8iopheereeD2uu9Uyee5bei2phahXeir8eeJ8oo',
  283. 'ooshahphei2hoh3uth5chaen7ohsai6uutiesucheichai8ungah9Gie1Aiphie',
  284. 'eiwohchoo7ere2iebohn4Aapheichaelooriiyaoxaik7ooqua7aezahx0aeJei',
  285. 'Vah0ohgohphiefohTheshieghichaichahch5moshoo0zai5eeva7eisi4yae8T',
  286. 'EibeeN0fee0Gohnguz8iec6yeigh7shuNg4eingu3siph9joucahpeidoom4ree',
  287. 'iejiu3shohheeZahHusheimeefaihoh5eecachu5eeZie9ceisugu9taidohT3U',
  288. 'eex6dilakaix5Eetai7xiCh5Jaa8aiD4Ag3tuij1aijohv5fo0heevah8hohs3m',
  289. 'ohqueeNgahraew6uraemohtoo5qua3oojiex6ohqu6Aideibaithaiphuriquie',
  290. 'cei0eiN4Shiey7Aeluy3unohboo5choiphahc2mahbei5paephaiKeso1thoog1',
  291. 'ieghif4ohKequ7ong0jah5ooBah0eiGh1caechahnahThae9Shoo0phopashoo4',
  292. 'roh9er3thohwi5am8iequeequuSh3aic0voocai3ihi5nie2abahphupiegh7vu',
  293. 'uv3Quei7wujoo5beingei2aish5op4VaiX0aebai7iwoaPee5pei8ko9IepaPig',
  294. 'co7aegh5beitheesi9lu7jeeQu3johgeiphee9cheichi8aithuDehu2gaeNein',
  295. 'thai3Tiewoo4nuir1ohy4aithiuZ7shae1luuwei5phibohriepe2paeci1Ach8',
  296. 'phoi3ribah7ufuvoh8eigh1oB6deeBaiPohphaghiPieshahfah5EiCi3toogoo',
  297. 'aiM8geil7ooreinee4Cheiwea4yeec8eeshi7Sei4Shoo3wu6ohkaNgooQu1mai',
  298. 'agoo3faciewah9ZeesiXeereek7am0eigaeShie3Tisu8haReeNgoo0ci2Hae5u',
  299. 'Aesatheewiedohshaephaenohbooshee8eu7EiJ8isal1laech2eiHo0noaV3ta',
  300. 'liunguep3ooChoo4eir8ahSie8eenee0oo1TooXu8Cais8Aimo4eir6Phoo3xei',
  301. 'toe9heepeobein3teequachemei0Cejoomef9ujie3ohwae9AiNgiephi3ep0de',
  302. 'ua6xooY9uzaeB3of6sheiyaedohoiS5Eev0Aequ9ahm1zoa5Aegh3ooz9ChahDa',
  303. 'eevasah6Bu9wi7EiwiequumahkaeCheegh6lui8xoh4eeY4ieneavah8phaibun',
  304. 'AhNgei2sioZeeng6phaecheemeehiShie5eFeiTh6ooV8iiphabud0die4siep4',
  305. 'kushe6Xieg6ahQuoo9aex3aipheefiec1esa7OhBuG0ueziep9phai5eegh1vie',
  306. 'Jie5yu8aafuQuoh9shaep3moboh3Pooy7och8oC6obeik6jaew2aiLooweib3ch',
  307. 'ohohjajaivaiRail3odaimei6aekohVaicheip2wu7phieg5Gohsaing2ahxaiy',
  308. 'hahzaht6yaiYu9re9jah9loisiit4ahtoh2quoh9xohishioz4oo4phofu3ogha',
  309. 'pu4oorea0uh2tahB8aiZoonge1aophaes6ogaiK9ailaigeej4zoVou8ielotee',
  310. 'cae2thei3Luphuqu0zeeG8leeZuchahxaicai4ui4Eedohte9uW6gae8Geeh0ea',
  311. 'air7tuy7ohw5sho2Tahpai8aep4so5ria7eaShus5weaqu0Naquei2xaeyoo2ae',
  312. 'vohge4aeCh7ahwoo7Jaex6sohl0Koong4Iejisei8Coir0iemeiz9uru9Iebaep',
  313. 'aepeidie8aiw6waish9gie4Woolae2thuj5phae4phexux7gishaeph4Deu7ooS',
  314. 'vahc5ia0xohHooViT0uyuxookiaquu2ogueth0ahquoudeefohshai8aeThahba',
  315. 'mun3oagah2eequaenohfoo8DaigeghoozaV2eiveeQuee7kah0quaa6tiesheet',
  316. 'ooSet4IdieC4ugow3za0die4ohGoh1oopoh6luaPhaeng4Eechea1hae0eimie5',
  317. 'iedeimadaefu2NeiPaey2jooloov5iehiegeakoo4ueso7aeK9ahqu2Thahkaes',
  318. 'nahquah9Quuu2uuf0aJah7eishi2siegh8ue5eiJa2EeVu8ebohkepoh4dahNgo',
  319. 'io1bie7chioPiej5ae2oohe2fee6ooP2thaeJohjohb9Se8tang3eipaifeimai',
  320. 'oungoqu6dieneejiechez1xeD2Zi9iox2Ahchaiy9ithah3ohVoolu2euQuuawo',
  321. 'thaew0veigei4neishohd8mecaixuqu7eeshiex1chaigohmoThoghoitoTa0Eo',
  322. 'ahroob2phohvaiz0Ohteik2ohtakie6Iu1vitho8IyiyeeleeShae9defaiw9ki',
  323. 'DohHoothohzeaxolai3Toh5eJie7ahlah9reF0ohn1chaipoogain2aibahw4no',
  324. 'aif8lo5she4aich5cho2rie8ieJaujeem2Joongeedae4vie3tah1Leequaix1O',
  325. 'Aang0Shaih6chahthie1ahZ7aewei9thiethee7iuThah3yoongi8ahngiobaa5',
  326. 'iephoBuayoothah0Ru6aichai4aiw8deg1umongauvaixai3ohy6oowohlee8ei',
  327. 'ohn5shigoameer0aejohgoh8oChohlaecho9jie6shu0ahg9Bohngau6paevei9',
  328. 'edahghaishak0paigh1eecuich3aad7yeB0ieD6akeeliem2beifufaekee6eat',
  329. 'hiechahgheloh2zo7Ieghaiph0phahhu8aeyuiKie1xeipheech9zai4aeme0ee',
  330. 'Cube'
  331. ]
  332. name = '/' + '/'.join(name_parts)
  333. # Now check the resulting Alembic file.
  334. abcprop = self.abcprop(abc, '%s/.xform' % name)
  335. self.assertEqual(abcprop['.vals'], [
  336. 1.0, 0.0, 0.0, 0.0,
  337. 0.0, 1.0, 0.0, 0.0,
  338. 0.0, 0.0, 1.0, 0.0,
  339. 0.0, 3.0, 0.0, 1.0,
  340. ])
  341. abcprop = self.abcprop(abc, '%s/CubeShape/.geom' % name)
  342. self.assertIn('.faceCounts', abcprop)
  343. if __name__ == '__main__':
  344. parser = argparse.ArgumentParser()
  345. parser.add_argument('--blender', required=True)
  346. parser.add_argument('--testdir', required=True)
  347. parser.add_argument('--alembic-root', required=True)
  348. args, remaining = parser.parse_known_args()
  349. unittest.main(argv=sys.argv[0:1] + remaining)