aboutsummaryrefslogtreecommitdiffstats
path: root/tests/test_scene.py
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-28 10:32:19 +0100
committerDanilo M. <danix@danix.xyz>2026-10-28 10:32:31 +0100
commit2f037b14ee5fe799b19bfaf838c13dca649574e1 (patch)
tree3919b550128a8fc1d244aa26934d39e67f3e39c2 /tests/test_scene.py
parent1402ca7d6c2bb0cea2cf52a4ecce7b809ebf31b1 (diff)
downloadgenerat3D-2f037b14ee5fe799b19bfaf838c13dca649574e1.tar.gz
generat3D-2f037b14ee5fe799b19bfaf838c13dca649574e1.zip
Load .duf scenes: figure, bones, morphs, dials and formulas
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Diffstat (limited to 'tests/test_scene.py')
-rw-r--r--tests/test_scene.py125
1 files changed, 125 insertions, 0 deletions
diff --git a/tests/test_scene.py b/tests/test_scene.py
new file mode 100644
index 0000000..0e52216
--- /dev/null
+++ b/tests/test_scene.py
@@ -0,0 +1,125 @@
+# generat3D - evaluate Daz DSON scenes and write .dbz files
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+import contextlib
+import io
+import json
+import tempfile
+import unittest
+from pathlib import Path
+
+from generat3d import Error
+from generat3d.library import Library
+from generat3d.scene import load
+
+DATA = Path(__file__).parent / "data"
+
+
+def load_quiet(duf, extra_roots=()):
+ err = io.StringIO()
+ with contextlib.redirect_stderr(err):
+ scene = load(duf, Library([DATA / "lib", *extra_roots]))
+ return scene, err.getvalue()
+
+
+class SceneTest(unittest.TestCase):
+ def setUp(self):
+ self.tmp = tempfile.TemporaryDirectory()
+ self.dir = Path(self.tmp.name)
+
+ def tearDown(self):
+ self.tmp.cleanup()
+
+ def duf(self, scene):
+ p = self.dir / "s.duf"
+ p.write_text(json.dumps({"scene": scene}))
+ return p
+
+ def base_nodes(self):
+ return [{"id": "Fig", "url": "/data/Test/Fig/Fig.dsf#Fig", "name": "Fig"}]
+
+ def test_figure_and_nodes(self):
+ scene, _ = load_quiet(DATA / "scene.duf")
+ fig = scene.figure
+ self.assertEqual((fig.id, fig.name, fig.label), ("Fig", "Fig", "Test Figure"))
+ self.assertEqual([n.id for n in fig.nodes], ["Fig", "hip"])
+ self.assertEqual(fig.nodes[1].rotation_order, "YZX")
+ self.assertEqual(fig.material_groups, ["Skin"])
+ self.assertEqual(fig.verts.tolist(), [[0, 0, 0], [1, 0, 0], [0, 1, 0]])
+
+ def test_morphs_auto_loaded_and_broken_file_warns(self):
+ scene, err = load_quiet(DATA / "scene.duf")
+ self.assertEqual(sorted(scene.figure.morphs), ["Fig:Tall?value", "Fig:Wide?value"])
+ self.assertIn("Broken.dsf", err)
+
+ def test_dial_from_animations(self):
+ scene, _ = load_quiet(DATA / "scene.duf")
+ self.assertEqual(scene.graph.props["Fig:Tall?value"].value, 1)
+
+ def test_dial_from_modifiers(self):
+ scene, _ = load_quiet(self.duf({"nodes": self.base_nodes(), "modifiers": [
+ {"id": "Tall", "url": "/data/Test/Fig/Morphs/Test/Tall.dsf#Tall",
+ "parent": "#Fig", "channel": {"id": "value", "current_value": 0.25}}]}))
+ self.assertEqual(scene.graph.props["Fig:Tall?value"].value, 0.25)
+
+ def test_bone_channels_are_props(self):
+ scene, _ = load_quiet(DATA / "scene.duf")
+ self.assertEqual(scene.graph.props["hip:hip?center_point/y"].value, 5)
+ self.assertEqual(scene.graph.props["Fig:Fig?end_point/y"].value, 10)
+
+ def test_formulas_rewritten_to_keys(self):
+ scene, _ = load_quiet(DATA / "scene.duf")
+ ops = [f[1] for f in scene.graph.formulas["Fig:Wide?value"]]
+ self.assertEqual(ops[0][0], {"op": "push", "key": "Fig:Tall?value"})
+ self.assertEqual(ops[1][0], {"op": "push", "val": 0.0})
+
+ def test_missing_dialed_morphs_listed_and_fatal(self):
+ mods = [{"id": m, "url": f"/data/Test/Fig/Morphs/Test/{m}.dsf#{m}", "parent": "#Fig",
+ "channel": {"id": "value", "current_value": 1}} for m in ("Gone1", "Gone2")]
+ with self.assertRaisesRegex(Error, "(?s)Gone1.dsf.*Gone2.dsf"):
+ load_quiet(self.duf({"nodes": self.base_nodes(), "modifiers": mods}))
+
+ def test_missing_undialed_morph_is_fine(self):
+ mods = [{"id": "Gone", "url": "/data/Test/Fig/Morphs/Test/Gone.dsf#Gone",
+ "parent": "#Fig", "channel": {"id": "value", "current_value": 0}}]
+ load_quiet(self.duf({"nodes": self.base_nodes(), "modifiers": mods}))
+
+ def test_missing_figure_is_fatal(self):
+ with self.assertRaisesRegex(Error, "Nope.dsf"):
+ load_quiet(self.duf({"nodes": [{"id": "X", "url": "/data/Nope.dsf#X"}]}))
+
+ def test_no_figure_is_fatal(self):
+ with self.assertRaisesRegex(Error, "no figure"):
+ load_quiet(self.duf({"nodes": []}))
+
+ def test_two_figures_is_fatal(self):
+ nodes = self.base_nodes() + [{"id": "Fig2", "url": "/data/Test/Fig/Fig.dsf#Fig"}]
+ with self.assertRaisesRegex(Error, "one figure"):
+ load_quiet(self.duf({"nodes": nodes}))
+
+ def test_follower_skipped_with_warning(self):
+ nodes = self.base_nodes() + [{"id": "Shirt", "url": "/data/Test/Fig/Fig.dsf#Fig",
+ "name": "Shirt", "conform_target": "#Fig"}]
+ scene, err = load_quiet(self.duf({"nodes": nodes}))
+ self.assertEqual(scene.figure.id, "Fig")
+ self.assertIn("Shirt", err)
+
+ def test_pose_ignored_with_warning(self):
+ nodes = self.base_nodes() + [{"id": "hip", "url": "/data/Test/Fig/Fig.dsf#hip",
+ "rotation": [{"id": "x", "current_value": 30}]}]
+ _, err = load_quiet(self.duf({"nodes": nodes}))
+ self.assertIn("pose", err)