aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--generat3d/scene.py231
-rw-r--r--tests/data/lib/data/Test/Fig/Fig.dsf21
-rw-r--r--tests/data/lib/data/Test/Fig/Morphs/Test/Broken.dsf1
-rw-r--r--tests/data/lib/data/Test/Fig/Morphs/Test/Tall.dsf18
-rw-r--r--tests/data/lib/data/Test/Fig/Morphs/Test/Wide.dsf8
-rw-r--r--tests/data/scene.duf12
-rw-r--r--tests/test_scene.py125
7 files changed, 416 insertions, 0 deletions
diff --git a/generat3d/scene.py b/generat3d/scene.py
new file mode 100644
index 0000000..49cc4c9
--- /dev/null
+++ b/generat3d/scene.py
@@ -0,0 +1,231 @@
+# 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 math
+import posixpath
+from dataclasses import dataclass, field
+from pathlib import Path
+
+import numpy as np
+
+from . import Error, warn
+from .formula import Graph
+from .library import parse_url, read_dson
+
+AXES = "xyz"
+REST_CHANNELS = ("center_point", "end_point", "orientation")
+POSE_REST = {"rotation": 0.0, "translation": 0.0, "scale": 1.0, "general_scale": 1.0}
+
+
+def key(node, ident, channel):
+ return f"{node}:{ident}?{channel}"
+
+
+@dataclass
+class Node:
+ """The figure root or one of its bones."""
+ id: str
+ name: str
+ label: str
+ rotation_order: str
+
+
+@dataclass
+class Figure:
+ id: str
+ name: str
+ label: str
+ verts: np.ndarray
+ material_groups: list
+ nodes: list # nodes[0] is the figure root, then bones in file order
+ morphs: dict = field(default_factory=dict) # value key -> (indices, deltas)
+
+
+@dataclass
+class Scene:
+ path: Path
+ figure: Figure
+ graph: Graph
+
+
+def _channel(node, name):
+ comps = {c["id"]: c.get("current_value", c.get("value", 0.0)) for c in node.get(name, [])}
+ return [comps.get(a, 0.0) for a in AXES]
+
+
+def _lookup(library, doc, url, section):
+ """Resolve url to (file path, entry in that file's section). Local refs use doc."""
+ _, path, ident, _ = parse_url(url)
+ src = library.load(path) if path else doc
+ for entry in src.get(section, []):
+ if entry.get("id") == ident:
+ return path, entry
+ raise Error(f"{url}: no {section} entry {ident!r}")
+
+
+class _Loader:
+ def __init__(self, path, library):
+ self.path = Path(path)
+ self.library = library
+ self.doc = read_dson(self.path)
+ self.graph = Graph()
+
+ def run(self):
+ nodes = self.doc.get("scene", {}).get("nodes", [])
+ figs = []
+ posed = False
+ for n in nodes:
+ if "url" not in n:
+ continue
+ fpath, asset = _lookup(self.library, self.doc, n["url"], "node_library")
+ kind = asset.get("type")
+ if kind == "bone":
+ posed = posed or any(v for ch in ("rotation", "translation")
+ for v in _channel(n, ch))
+ elif kind != "figure":
+ warn(f"skipped scene node {n.get('name', n['id'])!r} ({kind or 'no type'})")
+ elif "conform_target" in n:
+ warn(f"skipped follower {n.get('name', n['id'])!r} (not handled in v1)")
+ else:
+ figs.append((n, fpath, asset))
+ if not figs:
+ raise Error(f"{self.path}: no figure in scene")
+ if len(figs) > 1:
+ raise Error(f"{self.path}: v1 handles one figure per scene, found {len(figs)}")
+ self.figure = self._figure(*figs[0])
+ self.ids = {n.id for n in self.figure.nodes} | {n.name for n in self.figure.nodes}
+ self._morphs(figs[0][1])
+ posed = self._dials() or posed
+ if posed:
+ warn("pose in scene ignored: the .dbz holds rest data")
+ return Scene(self.path, self.figure, self.graph)
+
+ def _figure(self, inst, fpath, asset):
+ fdoc = self.library.load(fpath)
+ geoms = inst.get("geometries", [])
+ if geoms and "url" in geoms[0]:
+ _, geom = _lookup(self.library, fdoc, geoms[0]["url"], "geometry_library")
+ elif fdoc.get("geometry_library"):
+ geom = fdoc["geometry_library"][0]
+ else:
+ raise Error(f"{fpath}: figure has no geometry")
+ bones = [n for n in fdoc.get("node_library", []) if n.get("type") == "bone"]
+ nodes = []
+ for n in [asset] + bones:
+ nodes.append(Node(n["id"], n.get("name", n["id"]), n.get("label", n["id"]),
+ n.get("rotation_order", "XYZ")))
+ for ch in REST_CHANNELS:
+ for a, v in zip(AXES, _channel(n, ch)):
+ self.graph.add_prop(key(n["id"], n["id"], f"{ch}/{a}"), v)
+ for f in n.get("formulas", []):
+ self._formula(f, fpath)
+ return Figure(asset["id"], inst.get("name", asset.get("name", asset["id"])),
+ inst.get("label", asset.get("label", asset["id"])),
+ np.array(geom["vertices"]["values"], dtype=float),
+ list(geom.get("polygon_material_groups", {}).get("values", [])),
+ nodes)
+
+ def _key(self, url):
+ node, _, ident, channel = parse_url(url)
+ if node not in self.ids or node == self.figure.name:
+ node = self.figure.id
+ return key(node, ident, channel)
+
+ def _formula(self, f, source):
+ ops = []
+ for op in f.get("operations", []):
+ if op.get("op") == "push" and "url" in op:
+ channel = parse_url(op["url"])[3].split("/")[0]
+ if channel in POSE_REST:
+ ops.append({"op": "push", "val": POSE_REST[channel]})
+ else:
+ ops.append({"op": "push", "key": self._key(op["url"])})
+ else:
+ ops.append(op)
+ self.graph.add_formula(self._key(f["output"]), ops, f.get("stage", "sum"), source)
+
+ def _modifiers(self, path, doc):
+ nverts = len(self.figure.verts)
+ for mod in doc.get("modifier_library", []):
+ parent = parse_url(mod.get("parent", ""))[2]
+ if parent and parent != self.figure.id:
+ continue
+ ch = mod.get("channel", {})
+ k = key(self.figure.id, mod["id"], "value")
+ self.graph.add_prop(k, ch.get("value", 0.0), ch.get("min", -math.inf),
+ ch.get("max", math.inf), ch.get("clamped", False))
+ morph = mod.get("morph")
+ if morph:
+ if "hd_url" in morph:
+ warn(f"{path}#{mod['id']}: HD morph detail ignored")
+ d = morph.get("deltas", {}).get("values", [])
+ if morph.get("vertex_count", nverts) != nverts:
+ warn(f"{path}#{mod['id']}: vertex count mismatch, morph skipped")
+ elif d:
+ arr = np.array(d, dtype=float)
+ self.figure.morphs[k] = (arr[:, 0].astype(int), arr[:, 1:4])
+ for f in mod.get("formulas", []):
+ self._formula(f, f"{path}#{mod['id']}")
+
+ def _morphs(self, fpath):
+ morph_dir = posixpath.join(posixpath.dirname(fpath), "Morphs")
+ for path in self.library.walk(morph_dir):
+ try:
+ doc = self.library.load(path)
+ except Error as e:
+ warn(f"skipped unreadable morph file: {e}")
+ continue
+ self._modifiers(path, doc)
+
+ def _dials(self):
+ """Apply .duf dial values. Returns True if the scene animates a pose channel."""
+ scene = self.doc.get("scene", {})
+ values, missing, posed = {}, [], False
+ for m in scene.get("modifiers", []):
+ if "url" not in m:
+ continue
+ k = key(self.figure.id, parse_url(m["url"])[2], "value")
+ v = m.get("channel", {}).get("current_value", 0.0)
+ if k not in self.graph.props:
+ path = parse_url(m["url"])[1]
+ if path and self.library.find(path):
+ self._modifiers(path, self.library.load(path))
+ if k in self.graph.props:
+ values[k] = v
+ elif v:
+ missing.append(m["url"])
+ for a in scene.get("animations", []):
+ channel = parse_url(a["url"])[3]
+ v = a.get("keys", [[0, 0.0]])[0][1]
+ if channel.split("/")[0] in POSE_REST:
+ posed = posed or bool(v)
+ elif channel == "value":
+ k = self._key(a["url"])
+ if k in self.graph.props:
+ values[k] = v
+ elif v:
+ missing.append(a["url"])
+ if missing:
+ raise Error("missing morphs used by the scene:\n " + "\n ".join(missing))
+ for k, v in values.items():
+ self.graph.set_value(k, v)
+ return posed
+
+
+def load(path, library):
+ """Load a .duf scene and everything its figure needs."""
+ return _Loader(path, library).run()
diff --git a/tests/data/lib/data/Test/Fig/Fig.dsf b/tests/data/lib/data/Test/Fig/Fig.dsf
new file mode 100644
index 0000000..5589a23
--- /dev/null
+++ b/tests/data/lib/data/Test/Fig/Fig.dsf
@@ -0,0 +1,21 @@
+{
+ "file_version": "0.6.0.0",
+ "node_library": [
+ {"id": "Fig", "name": "Fig", "label": "Test Figure", "type": "figure",
+ "rotation_order": "XYZ",
+ "center_point": [{"id": "x", "value": 0}, {"id": "y", "value": 0}, {"id": "z", "value": 0}],
+ "end_point": [{"id": "x", "value": 0}, {"id": "y", "value": 10}, {"id": "z", "value": 0}],
+ "orientation": [{"id": "x", "value": 0}, {"id": "y", "value": 0}, {"id": "z", "value": 0}]},
+ {"id": "hip", "name": "hip", "label": "Hip", "type": "bone", "parent": "#Fig",
+ "rotation_order": "YZX",
+ "center_point": [{"id": "x", "value": 0}, {"id": "y", "value": 5}, {"id": "z", "value": 0}],
+ "end_point": [{"id": "x", "value": 0}, {"id": "y", "value": 8}, {"id": "z", "value": 0}],
+ "orientation": [{"id": "x", "value": 0}, {"id": "y", "value": 0}, {"id": "z", "value": 0}]}
+ ],
+ "geometry_library": [
+ {"id": "Fig-geom",
+ "vertices": {"count": 3, "values": [[0, 0, 0], [1, 0, 0], [0, 1, 0]]},
+ "polygon_material_groups": {"count": 1, "values": ["Skin"]},
+ "polylist": {"count": 1, "values": [[0, 0, 0, 1, 2]]}}
+ ]
+}
diff --git a/tests/data/lib/data/Test/Fig/Morphs/Test/Broken.dsf b/tests/data/lib/data/Test/Fig/Morphs/Test/Broken.dsf
new file mode 100644
index 0000000..e0c93d7
--- /dev/null
+++ b/tests/data/lib/data/Test/Fig/Morphs/Test/Broken.dsf
@@ -0,0 +1 @@
+{not json
diff --git a/tests/data/lib/data/Test/Fig/Morphs/Test/Tall.dsf b/tests/data/lib/data/Test/Fig/Morphs/Test/Tall.dsf
new file mode 100644
index 0000000..dff777e
--- /dev/null
+++ b/tests/data/lib/data/Test/Fig/Morphs/Test/Tall.dsf
@@ -0,0 +1,18 @@
+{
+ "file_version": "0.6.0.0",
+ "modifier_library": [
+ {"id": "Tall", "parent": "/data/Test/Fig/Fig.dsf#Fig",
+ "channel": {"id": "value", "type": "float", "value": 0, "min": 0, "max": 1, "clamped": true},
+ "morph": {"vertex_count": 3, "deltas": {"count": 1, "values": [[1, 0, 2, 0]]}},
+ "formulas": [
+ {"output": "hip:/data/Test/Fig/Fig.dsf#hip?center_point/y",
+ "operations": [{"op": "push", "url": "Fig:/data/Test/Fig/Morphs/Test/Tall.dsf#Tall?value"},
+ {"op": "push", "val": 2}, {"op": "mult"}]},
+ {"output": "Fig:/data/Test/Fig/Morphs/Test/Wide.dsf#Wide?value",
+ "operations": [{"op": "push", "url": "Fig:/data/Test/Fig/Morphs/Test/Tall.dsf#Tall?value"},
+ {"op": "push", "val": 0.5}, {"op": "mult"}]},
+ {"output": "Fig:/data/Test/Fig/Morphs/Test/Wide.dsf#Wide?value",
+ "operations": [{"op": "push", "url": "hip:/data/Test/Fig/Fig.dsf#hip?rotation/x"}]}
+ ]}
+ ]
+}
diff --git a/tests/data/lib/data/Test/Fig/Morphs/Test/Wide.dsf b/tests/data/lib/data/Test/Fig/Morphs/Test/Wide.dsf
new file mode 100644
index 0000000..721606c
--- /dev/null
+++ b/tests/data/lib/data/Test/Fig/Morphs/Test/Wide.dsf
@@ -0,0 +1,8 @@
+{
+ "file_version": "0.6.0.0",
+ "modifier_library": [
+ {"id": "Wide", "parent": "/data/Test/Fig/Fig.dsf#Fig",
+ "channel": {"id": "value", "type": "float", "value": 0, "min": 0, "max": 1, "clamped": true},
+ "morph": {"vertex_count": 3, "deltas": {"count": 2, "values": [[0, -1, 0, 0], [1, 1, 0, 0]]}}}
+ ]
+}
diff --git a/tests/data/scene.duf b/tests/data/scene.duf
new file mode 100644
index 0000000..a753b53
--- /dev/null
+++ b/tests/data/scene.duf
@@ -0,0 +1,12 @@
+{
+ "file_version": "0.6.0.0",
+ "scene": {
+ "nodes": [
+ {"id": "Fig", "url": "/data/Test/Fig/Fig.dsf#Fig", "name": "Fig", "label": "Test Figure"},
+ {"id": "hip", "url": "/data/Test/Fig/Fig.dsf#hip", "name": "hip", "parent": "#Fig"}
+ ],
+ "animations": [
+ {"url": "Fig:/data/Test/Fig/Morphs/Test/Tall.dsf#Tall?value", "keys": [[0, 1]]}
+ ]
+ }
+}
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)