Source code for dazpy._skeleton

from __future__ import annotations

import json
from typing import TYPE_CHECKING

from ._node import DazNode, NodeIdentifier
from ._script_builder import ScriptBuilder

if TYPE_CHECKING:
    from ._interaction import LimbAlignmentResult


[docs] class DazSkeleton(DazNode): """Proxy for a ``DzSkeleton`` (a rigged figure such as Genesis 9). Extends :class:`~dazpy.DazNode` with bone-access helpers. """ def _skeleton_body(self, body: str) -> str: # Scene.findNode() returns DzNode which lacks DzSkeleton methods like # findBone(). Retrieve a properly typed DzSkeleton by iterating # getSkeletonList(), which is the only proven-typed accessor in the API. kind = self._identifier.kind value = json.dumps(self._identifier.value) if kind == "label": match = f"_skels[_i].getLabel() === {value}" else: match = f"_skels[_i].getName() === {value}" lookup = ( f"var _node = null;" f" var _skels = Scene.getSkeletonList();" f" for (var _i = 0; _i < _skels.length; _i++) {{" f" if ({match}) {{ _node = _skels[_i]; break; }} }}" ) return ScriptBuilder.iife(f"{lookup}\nif (!_node) return null;\n{body}") def _bone_locator(self, bone_name: str) -> str: """Build a JS locator that resolves a bone through this specific skeleton. Uses the skeleton list rather than Scene.findNode() so that two figures with the same internal name (e.g. two Genesis 9 figures) are kept distinct. """ kind = self._identifier.kind value = json.dumps(self._identifier.value) match = ( f"_skels[_i].getLabel() === {value}" if kind == "label" else f"_skels[_i].getName() === {value}" ) return ( f"(function(){{" f"var _skel=null,_skels=Scene.getSkeletonList();" f"for(var _i=0;_i<_skels.length;_i++){{if({match}){{_skel=_skels[_i];break;}}}}" f"return _skel?_skel.findBone({json.dumps(bone_name)}):null;" f"}})()" )
[docs] def bones(self) -> list["DazBone"]: # noqa: F821 """Return all bones in this skeleton.""" from ._bone import DazBone script = self._skeleton_body( """ var bones = _node.getAllBones(); var names = []; for (var i = 0; i < bones.length; i++) { names.push(bones[i].getName()); } return names; """ ) names = self._client.execute(script).value or [] return [DazBone._from_locator(self._client, self._bone_locator(n), n) for n in names]
[docs] def bone_metadata(self) -> list[dict]: """Return bulk metadata for every bone in one HTTP call. This is the live-facing efficiency path used by the interaction adapter. It avoids one request per bone when building rig profiles. """ script = self._skeleton_body( """ var bones = _node.getAllBones(); var result = []; for (var i = 0; i < bones.length; i++) { var b = bones[i]; var parent = b.getNodeParent(); var parent_name = null; if (parent && parent.className && parent.className() === "DzBone") { parent_name = parent.getName(); } var pos = b.getLocalPos(); var wpos = b.getWSPos(); result.push({ name: b.getName(), label: b.getLabel(), parent_name: parent_name, rotation_order: b.getRotationOrder(), local_position: {x: pos.x, y: pos.y, z: pos.z}, world_position: {x: wpos.x, y: wpos.y, z: wpos.z}, local_euler: { x: b.getXRotControl().getValue(), y: b.getYRotControl().getValue(), z: b.getZRotControl().getValue(), } }); } return result; """ ) return self._client.execute(script).value or []
[docs] def find_bone(self, name: str) -> "DazBone": # noqa: F821 """Find a bone by its internal name. Args: name: The ``getName()`` string of the bone. Naming conventions differ by figure generation: Genesis 9 uses snake_case (e.g. ``"r_forearm"``); Genesis 3/8 uses ``"rForearmBend"``/``"lForearmBend"``; Genesis 1/2 uses ``"rForeArm"``/``"lForeArm"``. Use :meth:`bones` to list every bone name for the loaded figure. Returns: A :class:`~dazpy.DazBone` proxy. Raises: NodeNotFoundError: If no bone with that name exists. """ from ._bone import DazBone from .exceptions import NodeNotFoundError script = self._skeleton_body( f"var b = _node.findBone({ScriptBuilder.escape_string(name)}); return b ? b.getName() : null;" ) result = self._client.execute(script).value if result is None: raise NodeNotFoundError( f"Bone not found: {name!r}. " f"Bone naming differs by figure generation — e.g. Genesis 9 uses " f"'r_forearm', Genesis 3/8 uses 'rForearmBend', Genesis 1/2 uses 'rForeArm'. " f"Call figure.bones() to list every bone name for this figure." ) return DazBone._from_locator(self._client, self._bone_locator(result), result)
[docs] def find_bone_by_label(self, label: str) -> "DazBone": # noqa: F821 """Find a bone by its user-visible label. Args: label: The ``getLabel()`` string of the bone. Returns: A :class:`~dazpy.DazBone` proxy. Raises: NodeNotFoundError: If no bone with that label exists. """ from ._bone import DazBone from .exceptions import NodeNotFoundError script = self._skeleton_body( f"var b = _node.findBoneByLabel({ScriptBuilder.escape_string(label)}); return b ? b.getName() : null;" ) result = self._client.execute(script).value if result is None: raise NodeNotFoundError(f"Bone with label not found: {label!r}") return DazBone._from_locator(self._client, self._bone_locator(result), result)
[docs] def num_bones(self) -> int: """Return the total number of bones in this skeleton.""" script = self._skeleton_body("return _node.getAllBones().length;") return self._client.execute(script).value or 0
[docs] def bone_rotations(self) -> dict[str, tuple[float, float, float]]: """Return Euler rotations for every bone in one HTTP call. Equivalent to calling :attr:`~dazpy.DazBone.local_euler` on every bone returned by :meth:`bones`, but rounds-trips only once. Returns: ``{bone_name: (x, y, z)}`` in degrees for every bone. """ script = self._skeleton_body(""" var _bones = _node.getAllBones(); var _result = {}; for (var i = 0; i < _bones.length; i++) { var _b = _bones[i]; _result[_b.getName()] = [ _b.getXRotControl().getValue(), _b.getYRotControl().getValue(), _b.getZRotControl().getValue() ]; } return _result; """) raw = self._client.execute(script).value or {} return {name: (v[0], v[1], v[2]) for name, v in raw.items()}
[docs] def set_bone_rotations(self, data: dict[str, tuple | list]) -> None: """Set Euler rotations for any subset of bones in one HTTP call. Only the bones named in *data* are modified; all others are unchanged. Equivalent to calling :meth:`~dazpy.DazBone.set_local_rotation` per bone, but rounds-trips only once. Args: data: ``{bone_name: (x, y, z)}`` or ``{bone_name: [x, y, z]}``, angles in degrees. Bones not in this dict are left as-is. """ data_json = json.dumps({k: list(v) for k, v in data.items()}) script = self._skeleton_body(f""" var _data = {data_json}; var _bones = _node.getAllBones(); for (var i = 0; i < _bones.length; i++) {{ var _b = _bones[i]; var _n = _b.getName(); if (_data.hasOwnProperty(_n)) {{ var _r = _data[_n]; _b.getXRotControl().setValue(_r[0]); _b.getYRotControl().setValue(_r[1]); _b.getZRotControl().setValue(_r[2]); }} }} """) self._client.execute(script)
[docs] def evaluate_pose( self, rotations: dict[str, tuple | list], effector_bone_names: list[str], ) -> dict[str, tuple[float, float, float]]: """Apply candidate rotations, read effector world positions, then restore. The figure is left in its original state after this call. Use this to ask "if I posed these bones this way, where would the effectors end up?" without committing anything to the scene. Args: rotations: ``{bone_name: (x, y, z)}`` candidate rotation set in degrees. Only the listed bones are temporarily modified. effector_bone_names: Bone names whose world-space position should be returned after applying *rotations*. Returns: ``{bone_name: (x, y, z)}`` world-space positions for each requested effector bone. Bones not found in the skeleton are omitted. """ rotations_json = json.dumps({k: list(v) for k, v in rotations.items()}) effectors_json = json.dumps(effector_bone_names) script = self._skeleton_body(f""" var _data = {rotations_json}; var _effNames = {effectors_json}; var _allBones = _node.getAllBones(); var _originals = {{}}; for (var i = 0; i < _allBones.length; i++) {{ var _b = _allBones[i]; var _n = _b.getName(); if (_data.hasOwnProperty(_n)) {{ _originals[_n] = [ _b.getXRotControl().getValue(), _b.getYRotControl().getValue(), _b.getZRotControl().getValue() ]; var _r = _data[_n]; _b.getXRotControl().setValue(_r[0]); _b.getYRotControl().setValue(_r[1]); _b.getZRotControl().setValue(_r[2]); }} }} var _result = {{}}; var _effSet = {{}}; for (var j = 0; j < _effNames.length; j++) {{ _effSet[_effNames[j]] = true; }} for (var i = 0; i < _allBones.length; i++) {{ var _b = _allBones[i]; var _n = _b.getName(); if (_effSet.hasOwnProperty(_n)) {{ var _p = _b.getWSPos(); _result[_n] = [_p.x, _p.y, _p.z]; }} }} for (var i = 0; i < _allBones.length; i++) {{ var _b = _allBones[i]; var _n = _b.getName(); if (_originals.hasOwnProperty(_n)) {{ var _r = _originals[_n]; _b.getXRotControl().setValue(_r[0]); _b.getYRotControl().setValue(_r[1]); _b.getZRotControl().setValue(_r[2]); }} }} return _result; """) raw = self._client.execute(script).value or {} return {name: (v[0], v[1], v[2]) for name, v in raw.items()}
[docs] def evaluate_pose_jacobian( self, chain_bone_names: list[str], effector_bone_name: str, step_degrees: float = 1.0, ) -> dict: """Compute the IK Jacobian for a bone chain server-side in one HTTP call. For each bone in *chain_bone_names* and each of its three rotation axes, this perturbs the bone by *step_degrees*, reads the effector world position, and restores the original rotation. The figure is left unchanged. Args: chain_bone_names: Ordered list of bone names in the IK chain (root → effector direction). effector_bone_name: The bone whose world position is the IK goal. step_degrees: Perturbation size in degrees (default 1.0). Returns: A dict with keys: - ``"base_position"`` — ``[x, y, z]`` world position of the effector before any perturbation. - ``"columns"`` — list of ``[dx, dy, dz]`` Jacobian columns, one per (bone × axis) pair, ordered as ``bone0_x, bone0_y, bone0_z, bone1_x, …``. Each column is the normalized position change ``(trial_pos - base_pos) / step_degrees``. Returns ``None`` if the effector bone cannot be found. """ chain_json = json.dumps(chain_bone_names) effector_json = json.dumps(effector_bone_name) step_js = float(step_degrees) script = self._skeleton_body(f""" var _chain = {chain_json}; var _effName = {effector_json}; var _step = {step_js}; var _allBones = _node.getAllBones(); var _boneMap = {{}}; for (var i = 0; i < _allBones.length; i++) {{ _boneMap[_allBones[i].getName()] = _allBones[i]; }} var _eff = _boneMap[_effName]; if (!_eff) return null; var _bp = _eff.getWSPos(); var _base = [_bp.x, _bp.y, _bp.z]; var _columns = []; for (var c = 0; c < _chain.length; c++) {{ var _b = _boneMap[_chain[c]]; if (!_b) {{ _columns.push([0,0,0]); _columns.push([0,0,0]); _columns.push([0,0,0]); continue; }} var _ctrls = [_b.getXRotControl(), _b.getYRotControl(), _b.getZRotControl()]; for (var axis = 0; axis < 3; axis++) {{ var _ctrl = _ctrls[axis]; var _orig = _ctrl.getValue(); _ctrl.setValue(_orig + _step); var _tp = _eff.getWSPos(); _ctrl.setValue(_orig); _columns.push([ (_tp.x - _base[0]) / _step, (_tp.y - _base[1]) / _step, (_tp.z - _base[2]) / _step ]); }} }} return {{base_position: _base, columns: _columns}}; """) return self._client.execute(script).value
[docs] def morph_values(self, nonzero_only: bool = False) -> dict[str, float]: """Return the current value of every DzMorph modifier in one HTTP call. Args: nonzero_only: When ``True``, exclude morphs whose value is effectively zero (``abs(v) <= 0.0001``). Useful for sparse logging. Returns: ``{morph_name: float}`` for all (or non-zero) morphs. """ nz_js = "true" if nonzero_only else "false" script = self._skeleton_body(f""" var _obj = _node.getObject(); if (!_obj) return {{}}; var _result = {{}}; var _nz = {nz_js}; for (var i = 0; i < _obj.getNumModifiers(); i++) {{ var _m = _obj.getModifier(i); if (_m.className() === "DzMorph") {{ var _v = _m.getValueChannel().getValue(); if (!_nz || Math.abs(_v) > 0.0001) {{ _result[_m.getName()] = _v; }} }} }} return _result; """) return self._client.execute(script).value or {}
[docs] def set_morph_values(self, data: dict[str, float]) -> None: """Set the value of any subset of morphs in one HTTP call. Only morphs named in *data* are modified; all others are unchanged. Args: data: ``{morph_name: float}`` mapping. Morphs not in this dict are left at their current value. """ data_json = json.dumps(data) script = self._skeleton_body(f""" var _data = {data_json}; var _obj = _node.getObject(); if (!_obj) return null; for (var i = 0; i < _obj.getNumModifiers(); i++) {{ var _m = _obj.getModifier(i); if (_m.className() === "DzMorph" && _data.hasOwnProperty(_m.getName())) {{ _m.getValueChannel().setValue(_data[_m.getName()]); }} }} """) self._client.execute(script)
# ── keyframe baking ───────────────────────────────────────────────────────
[docs] def hand_to_target( self, target_point: object | None, *, source_anchor: str = "r_hand", max_iterations: int = 12, step_degrees: float = 1.0, damping: float = 0.25, tolerance: float = 0.15, ) -> "LimbAlignmentResult": """Align a hand anchor toward a world-space target point.""" from ._interaction import align_hand_target return align_hand_target( self, target_point, source_anchor=source_anchor, max_iterations=max_iterations, step_degrees=step_degrees, damping=damping, tolerance=tolerance, )
[docs] def foot_to_target( self, target_point: object | None, *, source_anchor: str = "r_foot", max_iterations: int = 12, step_degrees: float = 1.0, damping: float = 0.25, tolerance: float = 0.15, ) -> "LimbAlignmentResult": """Align a foot anchor toward a world-space target point.""" from ._interaction import align_foot_target return align_foot_target( self, target_point, source_anchor=source_anchor, max_iterations=max_iterations, step_degrees=step_degrees, damping=damping, tolerance=tolerance, )
[docs] def bake_bone_rotations( self, start: int | None = None, end: int | None = None, bone_names: list[str] | None = None, ) -> dict: """Bake bone rotation keyframes for every frame in the range. Scrubs the timeline server-side. For each frame, the current evaluated X/Y/Z rotation of every bone (including IK, constraints, and driven keys) is stamped as an explicit keyframe via ``insertKey``. After baking, the animation plays back without requiring any of the original drivers. The original frame is restored before the call returns. Args: start: First frame to bake. ``None`` → play-range start. end: Last frame to bake. ``None`` → play-range end. bone_names: Subset of bone names to bake. ``None`` → all bones. Returns: ``{"frames_baked": int, "bones_baked": int}`` Raises: ~dazpy.exceptions.NodeNotFoundError: If the skeleton is not found. """ start_js = str(start) if start is not None else "null" end_js = str(end) if end is not None else "null" bone_filter_js = json.dumps({n: True for n in bone_names}) if bone_names is not None else "null" script = self._skeleton_body(f""" var _step = Scene.getTimeStep(); var _pr = Scene.getPlayRange(); var _prStart = Math.round(_pr.start / _step); var _prEnd = Math.round(_pr.end / _step); var _bkStart = ({start_js} !== null) ? {start_js} : _prStart; var _bkEnd = ({end_js} !== null) ? {end_js} : _prEnd; var _filter = {bone_filter_js}; var _all = _node.getAllBones(); var _bones = []; for (var i = 0; i < _all.length; i++) {{ if (_filter === null || _filter.hasOwnProperty(_all[i].getName())) _bones.push(_all[i]); }} var _origFrame = Scene.getFrame(); for (var f = _bkStart; f <= _bkEnd; f++) {{ Scene.setFrame(f); var _t = f * _step; for (var i = 0; i < _bones.length; i++) {{ var _b = _bones[i]; _b.getXRotControl().insertKey(_t, _b.getXRotControl().getValue()); _b.getYRotControl().insertKey(_t, _b.getYRotControl().getValue()); _b.getZRotControl().insertKey(_t, _b.getZRotControl().getValue()); }} }} Scene.setFrame(_origFrame); return {{frames_baked: _bkEnd - _bkStart + 1, bones_baked: _bones.length}}; """) return self._client.execute(script).value or {}
[docs] def bake_morphs( self, start: int | None = None, end: int | None = None, morph_names: list[str] | None = None, ) -> dict: """Bake morph channel keyframes for every frame in the range. For each frame, the current value of every ``DzMorph`` modifier is stamped as an explicit keyframe via ``insertKey``. The original frame is restored before the call returns. Args: start: First frame to bake. ``None`` → play-range start. end: Last frame to bake. ``None`` → play-range end. morph_names: Subset of morph names to bake. ``None`` → all morphs. Returns: ``{"frames_baked": int, "morphs_baked": int}`` """ start_js = str(start) if start is not None else "null" end_js = str(end) if end is not None else "null" morph_filter_js = json.dumps({n: True for n in morph_names}) if morph_names is not None else "null" script = self._skeleton_body(f""" var _step = Scene.getTimeStep(); var _pr = Scene.getPlayRange(); var _prStart = Math.round(_pr.start / _step); var _prEnd = Math.round(_pr.end / _step); var _bkStart = ({start_js} !== null) ? {start_js} : _prStart; var _bkEnd = ({end_js} !== null) ? {end_js} : _prEnd; var _filter = {morph_filter_js}; var _obj = _node.getObject(); if (!_obj) return {{frames_baked: 0, morphs_baked: 0}}; var _channels = []; for (var i = 0; i < _obj.getNumModifiers(); i++) {{ var _m = _obj.getModifier(i); if (_m.className() === "DzMorph" && (_filter === null || _filter.hasOwnProperty(_m.getName()))) _channels.push(_m.getValueChannel()); }} var _origFrame = Scene.getFrame(); for (var f = _bkStart; f <= _bkEnd; f++) {{ Scene.setFrame(f); var _t = f * _step; for (var i = 0; i < _channels.length; i++) {{ _channels[i].insertKey(_t, _channels[i].getValue()); }} }} Scene.setFrame(_origFrame); return {{frames_baked: _bkEnd - _bkStart + 1, morphs_baked: _channels.length}}; """) return self._client.execute(script).value or {}
[docs] def bake( self, start: int | None = None, end: int | None = None, bone_names: list[str] | None = None, include_morphs: bool = False, morph_names: list[str] | None = None, ) -> dict: """Bake bone rotations and optionally morphs in a single HTTP call. Combines :meth:`bake_bone_rotations` and :meth:`bake_morphs` into one server-side loop so the timeline is only scrubbed once. Args: start: First frame to bake. ``None`` → play-range start. end: Last frame to bake. ``None`` → play-range end. bone_names: Bones to bake. ``None`` → all bones. include_morphs: Also bake ``DzMorph`` channels. morph_names: Morphs to bake (only used when *include_morphs* is ``True``). ``None`` → all morphs. Returns: ``{"frames_baked": int, "bones_baked": int, "morphs_baked": int}`` """ start_js = str(start) if start is not None else "null" end_js = str(end) if end is not None else "null" bone_filter_js = json.dumps({n: True for n in bone_names}) if bone_names is not None else "null" morph_filter_js = json.dumps({n: True for n in morph_names}) if morph_names is not None else "null" with_morphs_js = "true" if include_morphs else "false" script = self._skeleton_body(f""" var _step = Scene.getTimeStep(); var _pr = Scene.getPlayRange(); var _prStart = Math.round(_pr.start / _step); var _prEnd = Math.round(_pr.end / _step); var _bkStart = ({start_js} !== null) ? {start_js} : _prStart; var _bkEnd = ({end_js} !== null) ? {end_js} : _prEnd; var _boneFilter = {bone_filter_js}; var _mFilter = {morph_filter_js}; var _withMorphs = {with_morphs_js}; var _all = _node.getAllBones(); var _bones = []; for (var i = 0; i < _all.length; i++) {{ if (_boneFilter === null || _boneFilter.hasOwnProperty(_all[i].getName())) _bones.push(_all[i]); }} var _mChannels = []; if (_withMorphs) {{ var _obj = _node.getObject(); if (_obj) {{ for (var i = 0; i < _obj.getNumModifiers(); i++) {{ var _m = _obj.getModifier(i); if (_m.className() === "DzMorph" && (_mFilter === null || _mFilter.hasOwnProperty(_m.getName()))) _mChannels.push(_m.getValueChannel()); }} }} }} var _origFrame = Scene.getFrame(); for (var f = _bkStart; f <= _bkEnd; f++) {{ Scene.setFrame(f); var _t = f * _step; for (var i = 0; i < _bones.length; i++) {{ var _b = _bones[i]; _b.getXRotControl().insertKey(_t, _b.getXRotControl().getValue()); _b.getYRotControl().insertKey(_t, _b.getYRotControl().getValue()); _b.getZRotControl().insertKey(_t, _b.getZRotControl().getValue()); }} for (var i = 0; i < _mChannels.length; i++) {{ _mChannels[i].insertKey(_t, _mChannels[i].getValue()); }} }} Scene.setFrame(_origFrame); return {{ frames_baked: _bkEnd - _bkStart + 1, bones_baked: _bones.length, morphs_baked: _mChannels.length, }}; """) return self._client.execute(script).value or {}
[docs] def follow_target(self) -> "DazSkeleton | None": """Return the IK follow-target skeleton, or ``None`` if not set.""" script = self._skeleton_body( "var t = _node.getFollowTarget(); return t ? t.getName() : null;" ) name = self._client.execute(script).value if name is None: return None return DazSkeleton(self._client, NodeIdentifier(name))