from __future__ import annotations
import json
from pathlib import Path
from typing import TYPE_CHECKING
from ._script_builder import ScriptBuilder
if TYPE_CHECKING:
from ._skeleton import DazSkeleton
[docs]
class DazPose:
"""A snapshot of a figure's complete pose state.
Stores bone rotations (Euler XYZ degrees), geometry morph values, and
node-level numeric properties. Sparse by default — zero values are omitted
so the object stays compact for morph-heavy figures.
Typical workflow::
from dazpy import DazScene, DazPose
scene = DazScene()
figure = scene.find_skeleton_by_label("Genesis 9")
neutral = DazPose.capture(figure)
neutral.save("neutral.json")
smile = DazPose.load("smile.json")
neutral.lerp(smile, t=0.5).apply(figure)
JSON schema (same as ``character_state.py`` output)::
{
"figure": "Genesis 9",
"bones": {"hip": [0, 2.3, 0], "rForeArm": [0, 0, -45]},
"morphs": {"PHMSmileFull": 0.8},
"props": {"facs_ctrl_SmileFullFace": 0.5}
}
Args:
figure: The label of the figure this pose belongs to.
bones: Bone name → ``[x, y, z]`` Euler angles in degrees.
morphs: Morph name → blend value (typically 0–1).
props: Node property name → numeric value.
"""
def __init__(
self,
figure: str,
bones: dict[str, list[float]],
morphs: dict[str, float],
props: dict[str, float],
) -> None:
self.figure = figure
self.bones = bones
self.morphs = morphs
self.props = props
# ── construction ──────────────────────────────────────────────────────────
[docs]
@classmethod
def capture(cls, skeleton: "DazSkeleton") -> "DazPose":
"""Capture the current pose of *skeleton* in a single HTTP call.
Records all non-zero bone rotations, morph values, and node-level
numeric properties. Zero values are omitted (sparse storage); they are
implied as 0.0 during :meth:`lerp` and :meth:`apply_full`.
Args:
skeleton: The figure to capture.
Returns:
A new :class:`DazPose`.
Raises:
~dazpy.exceptions.NodeNotFoundError: If the skeleton is not found.
"""
lookup = ScriptBuilder.skeleton_lookup(skeleton._identifier)
script = f"""(function(){{
{lookup}
if (!_skel) return null;
// Bone rotation controls can themselves be ERC targets (e.g.
// auto-follow bend/twist ratios common on Genesis figures, where
// one bone's rotation is driven off another's), so read raw
// values here for the same reason as morphs/props below.
var bones = {{}};
var all = _skel.getAllBones();
for (var i = 0; i < all.length; i++) {{
var b = all[i];
var xc = b.getXRotControl(), yc = b.getYRotControl(), zc = b.getZRotControl();
var x = (typeof xc.getRawValue === "function") ? xc.getRawValue() : xc.getValue();
var y = (typeof yc.getRawValue === "function") ? yc.getRawValue() : yc.getValue();
var z = (typeof zc.getRawValue === "function") ? zc.getRawValue() : zc.getValue();
if (Math.abs(x) > 0.0001 || Math.abs(y) > 0.0001 || Math.abs(z) > 0.0001)
bones[b.getName()] = [x, y, z];
}}
// Morphs can themselves be ERC targets (e.g. a master "Character"
// dial that fans out to dozens of shape morphs via DzERCLink),
// so read raw values here for the same reason as props below.
var morphs = {{}};
var obj = _skel.getObject();
if (obj) {{
for (var i = 0; i < obj.getNumModifiers(); i++) {{
var m = obj.getModifier(i);
if (m.className() === "DzMorph") {{
var ch = m.getValueChannel();
var v = (typeof ch.getRawValue === "function") ? ch.getRawValue() : ch.getValue();
if (Math.abs(v) > 0.0001) morphs[m.getName()] = v;
}}
}}
}}
// Read the property's own dial setting rather than getValue()'s
// post-ERC computed total. Properties driven by DzERCLink
// controllers (e.g. a "Scale" dial fed by dozens of linked
// morphs) return a getValue() that already includes every
// controller's contribution; capturing that and writing it back
// via setValue() on apply() would re-add the same contributions
// on top, inflating the property a little more on every
// capture/apply cycle. getRawValue() reads only the property's
// own baseline and round-trips correctly regardless of ERC links.
var props = {{}};
for (var i = 0; i < _skel.getNumProperties(); i++) {{
var p = _skel.getProperty(i);
if (p && p.getValue) {{
var v = (typeof p.getRawValue === "function") ? p.getRawValue() : p.getValue();
if (typeof v === "number" && Math.abs(v) > 0.0001)
props[p.getName()] = v;
}}
}}
return {{bones: bones, morphs: morphs, props: props}};
}})()"""
result = skeleton._client.execute(script).value
if result is None:
from .exceptions import NodeNotFoundError
raise NodeNotFoundError(f"Skeleton not found: {skeleton._identifier.value!r}")
return cls(
figure=skeleton._identifier.value,
bones=result["bones"],
morphs=result["morphs"],
props=result["props"],
)
[docs]
@classmethod
def load(cls, path: str | Path) -> "DazPose":
"""Load a pose from a JSON file.
Accepts files produced by :meth:`save` and by the ``character_state.py``
example script.
Args:
path: Path to the JSON file.
Returns:
A new :class:`DazPose`.
"""
with open(path, encoding="utf-8") as f:
data = json.load(f)
return cls(
figure=data.get("figure", ""),
bones=data.get("bones", {}),
morphs=data.get("morphs", {}),
props=data.get("props", {}),
)
# ── serialisation ─────────────────────────────────────────────────────────
[docs]
def save(self, path: str | Path) -> None:
"""Write this pose to a JSON file.
Args:
path: Destination path. Parent directories must exist.
"""
with open(path, "w", encoding="utf-8") as f:
json.dump(self.to_dict(), f, indent=2)
[docs]
def to_dict(self) -> dict:
"""Return the pose as a plain dict (same schema as the JSON file)."""
return {
"figure": self.figure,
"bones": self.bones,
"morphs": self.morphs,
"props": self.props,
}
# ── interpolation ─────────────────────────────────────────────────────────
[docs]
def lerp(self, other: "DazPose", t: float) -> "DazPose":
"""Linearly interpolate between this pose and *other*.
Missing keys in either pose are treated as zero. The result uses the
figure label from *self*.
This is a pure-Python operation — no HTTP round-trip.
Args:
other: The target pose (``t=1.0`` yields an exact copy of *other*).
t: Blend factor. 0.0 = this pose, 1.0 = *other*. Values outside
[0, 1] extrapolate.
Returns:
A new :class:`DazPose` at the interpolated position.
"""
def _l(a: float, b: float) -> float:
return a + (b - a) * t
bones = {
k: [
_l(self.bones.get(k, _ZERO3)[i], other.bones.get(k, _ZERO3)[i])
for i in range(3)
]
for k in set(self.bones) | set(other.bones)
}
morphs = {
k: _l(self.morphs.get(k, 0.0), other.morphs.get(k, 0.0))
for k in set(self.morphs) | set(other.morphs)
}
props = {
k: _l(self.props.get(k, 0.0), other.props.get(k, 0.0))
for k in set(self.props) | set(other.props)
}
return DazPose(figure=self.figure, bones=bones, morphs=morphs, props=props)
# ── apply ─────────────────────────────────────────────────────────────────
[docs]
def apply(
self, skeleton: "DazSkeleton", *, retry_on_busy: bool = True, max_wait: float = 30.0
) -> None:
"""Apply this pose to *skeleton* in a single HTTP call.
Only channels present in the pose are changed. Bones and morphs not
stored in the pose are left at their current values. Use
:meth:`apply_full` when you need a clean, authoritative reset to exactly
this pose.
Args:
skeleton: The figure to pose.
retry_on_busy: If ``True`` (the default), transparently retry with
backoff when the server reports ``StudioBusyError`` for the
HTTP call itself, up to *max_wait* seconds total. This does not
protect against DAZ Studio's main thread bailing partway
through the script under contention (see dpi-mxq) -- callers
that need correctness guarantees on the result should verify
with an independent read-back after applying.
max_wait: Maximum total seconds to retry when *retry_on_busy* is
``True``.
"""
lookup = ScriptBuilder.skeleton_lookup(skeleton._identifier)
bones_json = json.dumps({k: [round(v, 6) for v in xyz] for k, xyz in self.bones.items()})
morphs_json = json.dumps({k: round(v, 6) for k, v in self.morphs.items()})
props_json = json.dumps({k: round(v, 6) for k, v in self.props.items()})
script = f"""(function(){{
{lookup}
if (!_skel) return false;
var _bones = {bones_json};
var _morphs = {morphs_json};
var _props = {props_json};
var all = _skel.getAllBones();
for (var i = 0; i < all.length; i++) {{
var b = all[i];
var xyz = _bones[b.getName()];
if (xyz !== undefined) {{
var xc = b.getXRotControl(), yc = b.getYRotControl(), zc = b.getZRotControl();
if (typeof xc.setRawValue === "function") {{ xc.setRawValue(xyz[0]); }} else {{ xc.setValue(xyz[0]); }}
if (typeof yc.setRawValue === "function") {{ yc.setRawValue(xyz[1]); }} else {{ yc.setValue(xyz[1]); }}
if (typeof zc.setRawValue === "function") {{ zc.setRawValue(xyz[2]); }} else {{ zc.setValue(xyz[2]); }}
}}
}}
// Write back to the raw dial slot, mirroring capture()'s use of
// getRawValue() -- see the comment there for why setValue()
// would double-apply ERC-controller contributions on properties
// like morphs or a "Scale" dial that are DzERCLink targets.
var obj = _skel.getObject();
if (obj) {{
for (var i = 0; i < obj.getNumModifiers(); i++) {{
var m = obj.getModifier(i);
if (m.className() === "DzMorph") {{
var v = _morphs[m.getName()];
if (v !== undefined) {{
var ch = m.getValueChannel();
if (typeof ch.setRawValue === "function") {{ ch.setRawValue(v); }}
else {{ ch.setValue(v); }}
}}
}}
}}
}}
for (var i = 0; i < _skel.getNumProperties(); i++) {{
var p = _skel.getProperty(i);
if (p && p.setValue) {{
var v = _props[p.getName()];
if (v !== undefined) {{
if (typeof p.setRawValue === "function") {{ p.setRawValue(v); }}
else {{ p.setValue(v); }}
}}
}}
}}
return true;
}})()"""
skeleton._client.execute(script, retry_on_busy=retry_on_busy, max_wait=max_wait)
[docs]
def apply_full(
self, skeleton: "DazSkeleton", *, retry_on_busy: bool = True, max_wait: float = 30.0
) -> None:
"""Apply this pose and zero every channel not present in the pose.
Unlike :meth:`apply`, every bone rotation, morph, and node property on
the skeleton is explicitly set — channels absent from the pose are driven
to zero. Use this to restore a known baseline cleanly.
Args:
skeleton: The figure to pose.
retry_on_busy: If ``True`` (the default), transparently retry with
backoff when the server reports ``StudioBusyError`` for the
HTTP call itself, up to *max_wait* seconds total. This only
covers a busy error raised for the whole call -- it does not
detect DAZ Studio's main thread bailing partway through the
script under contention and leaving some writes unapplied
while the HTTP call still reports success (see dpi-mxq).
Callers that need correctness guarantees (e.g. checkpoint
restore) must independently verify the result with a fresh
:meth:`capture` read-back, such as
:meth:`~dazpy.DazSceneState.apply` does.
max_wait: Maximum total seconds to retry when *retry_on_busy* is
``True``.
"""
lookup = ScriptBuilder.skeleton_lookup(skeleton._identifier)
bones_json = json.dumps({k: [round(v, 6) for v in xyz] for k, xyz in self.bones.items()})
morphs_json = json.dumps({k: round(v, 6) for k, v in self.morphs.items()})
props_json = json.dumps({k: round(v, 6) for k, v in self.props.items()})
script = f"""(function(){{
{lookup}
if (!_skel) return false;
var _bones = {bones_json};
var _morphs = {morphs_json};
var _props = {props_json};
var all = _skel.getAllBones();
for (var i = 0; i < all.length; i++) {{
var b = all[i];
var xyz = _bones[b.getName()] || [0, 0, 0];
var xc = b.getXRotControl(), yc = b.getYRotControl(), zc = b.getZRotControl();
if (typeof xc.setRawValue === "function") {{ xc.setRawValue(xyz[0]); }} else {{ xc.setValue(xyz[0]); }}
if (typeof yc.setRawValue === "function") {{ yc.setRawValue(xyz[1]); }} else {{ yc.setValue(xyz[1]); }}
if (typeof zc.setRawValue === "function") {{ zc.setRawValue(xyz[2]); }} else {{ zc.setValue(xyz[2]); }}
}}
// Write back to the raw dial slot -- see the comment in
// capture() for why setValue() would double-apply ERC-controller
// contributions on properties like morphs or a "Scale" dial that
// are DzERCLink targets.
var obj = _skel.getObject();
if (obj) {{
for (var i = 0; i < obj.getNumModifiers(); i++) {{
var m = obj.getModifier(i);
if (m.className() === "DzMorph") {{
var v = _morphs[m.getName()];
var _v = (v !== undefined) ? v : 0;
var ch = m.getValueChannel();
if (typeof ch.setRawValue === "function") {{ ch.setRawValue(_v); }}
else {{ ch.setValue(_v); }}
}}
}}
}}
for (var i = 0; i < _skel.getNumProperties(); i++) {{
var p = _skel.getProperty(i);
if (p && p.setValue) {{
var v = _props[p.getName()];
var _v = (v !== undefined) ? v : 0;
if (typeof p.setRawValue === "function") {{ p.setRawValue(_v); }}
else {{ p.setValue(_v); }}
}}
}}
return true;
}})()"""
skeleton._client.execute(script, retry_on_busy=retry_on_busy, max_wait=max_wait)
# ── dunder ────────────────────────────────────────────────────────────────
def __repr__(self) -> str:
return (
f"DazPose(figure={self.figure!r}, "
f"bones={len(self.bones)}, morphs={len(self.morphs)}, props={len(self.props)})"
)
_ZERO3 = [0.0, 0.0, 0.0]