1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
|
# 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}")
def _number(url, v):
if isinstance(v, bool) or not isinstance(v, (int, float)):
raise Error(f"{url}: dial value is not a number")
return v
class _Loader:
def __init__(self, path, library):
self.path = Path(path)
self.library = library
self.doc = read_dson(self.path)
self.graph = Graph()
self.loaded = set()
self.hd = 0
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') or n.get('id')!r} ({kind or 'no type'})")
elif "conform_target" in n:
warn(f"skipped follower {n.get('name') or n.get('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")
if self.hd:
warn(f"{self.hd} HD morphs: detail ignored (v1)")
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:
self.hd += 1
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)
if (arr.ndim != 2 or arr.shape[1] != 4
or (arr[:, 0] != np.floor(arr[:, 0])).any()
or (arr[:, 0] < 0).any() or (arr[:, 0] >= nverts).any()):
raise Error(f"{path}#{mod['id']}: bad morph deltas")
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 _load_file(self, path):
if path.lower() in self.loaded:
return
self.loaded.add(path.lower())
doc = self.library.load(path)
try:
self._modifiers(path, doc)
except (KeyError, TypeError, ValueError, IndexError, AttributeError) as e:
raise Error(f"{path}: malformed morph data: {e!r}") from None
def _morphs(self, fpath):
morph_dir = posixpath.join(posixpath.dirname(fpath), "Morphs")
for path in self.library.walk(morph_dir):
try:
self.library.load(path)
except Error as e:
warn(f"skipped unreadable morph file: {e}")
continue
self._load_file(path)
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 = _number(m["url"], 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._load_file(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 = _number(a["url"], 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."""
try:
return _Loader(path, library).run()
except (KeyError, TypeError, IndexError, ValueError, AttributeError) as e:
raise Error(f"{path}: malformed scene data: {e!r}") from None
|