# generat3D - evaluate Daz DSON scenes and write .dbz files # Copyright (C) 2026 Danilo M. # # 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) def root(self, rel, doc): """Extra library root holding one hand-written file.""" root = self.dir / "extra" p = root / rel p.parent.mkdir(parents=True, exist_ok=True) p.write_text(json.dumps(doc)) return root def morph_doc(self, *mods): return {"modifier_library": list(mods)} def mod(self, ident, morph=None): m = {"id": ident, "parent": "/data/Test/Fig/Fig.dsf#Fig", "channel": {"id": "value", "value": 0}} if morph: m["morph"] = morph return m MORPHS = "data/Test/Fig/Morphs/Test/" def test_file_loaded_once(self): scene, _ = load_quiet(self.duf({"nodes": self.base_nodes(), "animations": [ {"url": "Fig:/data/Test/Fig/Morphs/Test/Tall.dsf#Tall?value", "keys": [[0, 1]]}], "modifiers": [{"id": "Typo", "url": "/data/Test/Fig/Morphs/Test/Tall.dsf#Typo", "parent": "#Fig", "channel": {"id": "value", "current_value": 0}}]})) self.assertEqual(len(scene.graph.formulas["hip:hip?center_point/y"]), 1) self.assertEqual(scene.graph.evaluate()["hip:hip?center_point/y"], 7) def test_malformed_morph_file_is_fatal(self): root = self.root(self.MORPHS + "NoId.dsf", {"modifier_library": [{"channel": {"value": 0}}]}) with self.assertRaisesRegex(Error, "NoId.dsf: malformed morph data"): load_quiet(DATA / "scene.duf", [root]) def test_bad_deltas_fatal(self): root = self.root(self.MORPHS + "BadDelta.dsf", self.morph_doc( self.mod("BadDelta", {"vertex_count": 3, "deltas": {"values": [[7, 1, 1, 1]]}}))) with self.assertRaisesRegex(Error, "bad morph deltas"): load_quiet(DATA / "scene.duf", [root]) def test_malformed_scene_data(self): for scene in ({"nodes": self.base_nodes(), "animations": [ {"url": "Fig:#Tall?value", "keys": []}]}, {"nodes": None}): with self.assertRaisesRegex(Error, "malformed scene data"): load_quiet(self.duf(scene)) def test_dial_not_a_number(self): with self.assertRaisesRegex(Error, "dial value is not a finite number"): 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": "a"}}]})) def test_hd_warning_once(self): hd = {"vertex_count": 3, "hd_url": "x", "deltas": {"values": [[0, 1, 0, 0]]}} root = self.root(self.MORPHS + "Hd.dsf", self.morph_doc( self.mod("Hd1", hd), self.mod("Hd2", hd))) _, err = load_quiet(DATA / "scene.duf", [root]) self.assertEqual(err.count("2 HD morphs"), 1) def test_on_demand_morph_outside_morphs_dir(self): root = self.root("data/Test/Extra/Bulk.dsf", self.morph_doc( self.mod("Bulk", {"vertex_count": 3, "deltas": {"values": [[2, 0, 0, 1]]}}))) scene, _ = load_quiet(self.duf({"nodes": self.base_nodes(), "modifiers": [ {"id": "Bulk", "url": "/data/Test/Extra/Bulk.dsf#Bulk", "parent": "#Fig", "channel": {"id": "value", "current_value": 1}}]}), [root]) self.assertIn("Fig:Bulk?value", scene.figure.morphs) self.assertEqual(scene.graph.props["Fig:Bulk?value"].value, 1) def tall(self, parent="#Fig", value=1, url="/data/Test/Fig/Morphs/Test/Tall.dsf#Tall"): return {"id": url.split("#")[1], "url": url, "parent": parent, "channel": {"id": "value", "current_value": value}} def test_other_node_modifier_ignored(self): scene, _ = load_quiet(self.duf({"nodes": self.base_nodes(), "modifiers": [ self.tall(), self.tall("#Shirt", 0, "/data/Test/Shirt/Shirt.dsf#Tall")]})) self.assertEqual(scene.graph.props["Fig:Tall?value"].value, 1) def test_other_node_animation_ignored(self): scene, _ = load_quiet(self.duf({"nodes": self.base_nodes(), "animations": [ {"url": "Fig:#Tall?value", "keys": [[0, 1]]}, {"url": "Shirt:#Tall?value", "keys": [[0, 0]]}]})) self.assertEqual(scene.graph.props["Fig:Tall?value"].value, 1) def test_follower_modifier_not_reported_missing(self): load_quiet(self.duf({"nodes": self.base_nodes(), "modifiers": [ self.tall("#Shirt", 1, "/data/Test/Shirt/Shirt.dsf#Loose")]})) def fig_doc(self, hip_extra=None, verts=None): """A figure file with root Fig2 and a bone hip (id), optionally tweaked.""" xyz = lambda *v: [{"id": a, "value": x} for a, x in zip("xyz", v)] hip = {"id": "hip", "name": "hip", "type": "bone", "center_point": xyz(0, 5, 0), "end_point": xyz(0, 8, 0), "orientation": xyz(0, 0, 0)} hip.update(hip_extra or {}) root = {"id": "Fig2", "name": "Fig2", "type": "figure", "center_point": xyz(0, 0, 0), "end_point": xyz(0, 10, 0), "orientation": xyz(0, 0, 0)} return {"node_library": [root, hip], "geometry_library": [ {"id": "g", "vertices": {"values": verts or [[0, 0, 0], [1, 0, 0], [0, 1, 0]]}}]} def fig2(self, **kw): """Load a scene of the temp figure; returns (scene, stderr).""" root = self.root("data/Test/Fig2/Fig2.dsf", self.fig_doc(**kw)) nodes = [{"id": "Fig2", "url": "/data/Test/Fig2/Fig2.dsf#Fig2", "name": "Fig2"}] return load_quiet(self.duf({"nodes": nodes}), [root]) def test_figure_node_formulas_registered(self): scene, _ = self.fig2(hip_extra={"formulas": [{ "output": "hip:/data/Test/Fig2/Fig2.dsf#hip?center_point/y", "operations": [{"op": "push", "val": 1}]}]}) self.assertEqual(len(scene.graph.formulas["hip:hip?center_point/y"]), 1) def raw_fig2(self, text): """Load the temp figure from raw JSON text (for literals json.dumps rejects).""" root = self.dir / "raw" p = root / "data/Test/Fig2/Fig2.dsf" p.parent.mkdir(parents=True) p.write_text(text) nodes = [{"id": "Fig2", "url": "/data/Test/Fig2/Fig2.dsf#Fig2", "name": "Fig2"}] return load_quiet(self.duf({"nodes": nodes}), [root]) def test_bone_value_not_a_number(self): xyz = [{"id": "x", "value": "a"}] with self.assertRaisesRegex(Error, "hip: center_point is not a finite number"): self.fig2(hip_extra={"center_point": xyz}) def test_bone_value_not_finite(self): text = json.dumps(self.fig_doc()).replace( '"orientation": [{"id": "x", "value": 0}', '"orientation": [{"id": "x", "value": 1e400}', 1) self.assertIn("1e400", text) with self.assertRaisesRegex(Error, "is not a finite number"): self.raw_fig2(text) def test_modifier_value_nan(self): root = self.root(self.MORPHS + "Nan.dsf", self.morph_doc(self.mod("Nan"))) p = root / (self.MORPHS + "Nan.dsf") p.write_text(p.read_text().replace('"value": 0', '"value": NaN')) with self.assertRaisesRegex(Error, "Nan: value is not a finite number"): load_quiet(DATA / "scene.duf", [root]) def test_bad_vertices(self): with self.assertRaisesRegex(Error, "bad vertices"): self.fig2(verts=[[0, 0], [1, 0], [0, 1]]) def test_bone_name_mapped_to_id(self): scene, _ = self.fig2(hip_extra={"name": "Hip", "formulas": [{ "output": "Hip:/data/Test/Fig2/Fig2.dsf#hip?center_point/y", "operations": [{"op": "push", "val": 1}]}]}) self.assertEqual(len(scene.graph.formulas["hip:hip?center_point/y"]), 1) def test_dial_not_finite(self): for v in ("1e400", "10**400"): text = json.dumps({"scene": {"nodes": self.base_nodes(), "animations": [ {"url": "Fig:#Tall?value", "keys": [[0, 0]]}]}}).replace( '"keys": [[0, 0]]', '"keys": [[0, %s]]' % ("1e400" if v == "1e400" else "1" + "0" * 400)) p = self.dir / "s.duf" p.write_text(text) with self.assertRaisesRegex(Error, "dial value is not a finite number"): load_quiet(p)