diff options
| -rw-r--r-- | generat3d/scene.py | 231 | ||||
| -rw-r--r-- | tests/data/lib/data/Test/Fig/Fig.dsf | 21 | ||||
| -rw-r--r-- | tests/data/lib/data/Test/Fig/Morphs/Test/Broken.dsf | 1 | ||||
| -rw-r--r-- | tests/data/lib/data/Test/Fig/Morphs/Test/Tall.dsf | 18 | ||||
| -rw-r--r-- | tests/data/lib/data/Test/Fig/Morphs/Test/Wide.dsf | 8 | ||||
| -rw-r--r-- | tests/data/scene.duf | 12 | ||||
| -rw-r--r-- | tests/test_scene.py | 125 |
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) |
