Source code for dazpy._scene

from __future__ import annotations

import json

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


[docs] class DazScene: """High-level proxy for the active DAZ Studio scene (``Scene`` global). This is the primary entry point for inspecting and manipulating the scene. All methods execute DazScript on the server and return Python objects. Args: client: Optional :class:`~dazpy.DazClient` to use. A default client connecting to ``127.0.0.1:18811`` is created when omitted. Example:: from dazpy import DazScene scene = DazScene() print(scene.num_nodes(), "nodes in scene") figure = scene.find_skeleton_by_label("Genesis 9") """ def __init__(self, client: DazClient | None = None): self._client = client or DazClient()
[docs] def nodes(self) -> list[DazNode]: """Return all top-level and child nodes in the scene. The returned list contains typed subclass instances: :class:`~dazpy.DazSkeleton` for figures, :class:`~dazpy.DazCamera`, :class:`~dazpy.DazLight`, and :class:`~dazpy.DazNode` for everything else. Returns: Ordered list of scene nodes (same order as DAZ Studio's scene panel). """ # Use QObject::inherits() for robust subclass detection (e.g. DzFigure extends DzSkeleton). script = ScriptBuilder.iife(""" var result = []; for (var i = 0; i < Scene.getNumNodes(); i++) { var n = Scene.getNode(i); var nodeType = "DzNode"; if (n.inherits("DzSkeleton")) { nodeType = "DzSkeleton"; } else if (n.inherits("DzCamera")) { nodeType = "DzCamera"; } else if (n.inherits("DzLight")) { nodeType = "DzLight"; } result.push({name: n.getName(), className: nodeType}); } return result; """) items = self._client.execute(script).value or [] from ._skeleton import DazSkeleton from ._camera import DazCamera from ._light import DazLight _NODE_CLASS_MAP = { "DzSkeleton": DazSkeleton, "DzCamera": DazCamera, "DzLight": DazLight, } return [ _NODE_CLASS_MAP.get(item["className"], DazNode)(self._client, NodeIdentifier(item["name"])) for item in items ]
[docs] def find_node(self, name: str) -> DazNode: """Find a scene node by its internal name. Args: name: The ``getName()`` string of the node. Returns: A :class:`~dazpy.DazNode` proxy for the node. Raises: NodeNotFoundError: If no node with that name exists in the scene. """ from .exceptions import NodeNotFoundError node = DazNode(self._client, NodeIdentifier(name, kind="name")) exists = ScriptBuilder.iife( f"return !!Scene.findNode({ScriptBuilder.escape_string(name)});" ) if not self._client.execute(exists).value: raise NodeNotFoundError(f"Node not found: {name!r}") return node
[docs] def find_node_by_label(self, label: str) -> DazNode: """Find a scene node by its user-visible label. The returned proxy is anchored to the node's internal *name* so it remains stable if the label is later changed. Args: label: The ``getLabel()`` string shown in the Scene panel. Returns: A :class:`~dazpy.DazNode` proxy. Raises: NodeNotFoundError: If no node with that label exists. """ from .exceptions import NodeNotFoundError exists = ScriptBuilder.iife( f"return !!Scene.findNodeByLabel({ScriptBuilder.escape_string(label)});" ) if not self._client.execute(exists).value: raise NodeNotFoundError(f"Node with label not found: {label!r}") # Keep kind="label" — nodes with the same asset type share the same internal # name, so resolving to getName() would collapse distinct nodes into one. return DazNode(self._client, NodeIdentifier(label, kind="label"))
[docs] def num_nodes(self) -> int: """Return the total number of nodes in the scene.""" script = ScriptBuilder.iife("return Scene.getNumNodes();") return self._client.execute(script).value or 0
[docs] def cameras(self) -> list["DazCamera"]: # noqa: F821 """Return all camera nodes in the scene.""" from ._camera import DazCamera script = ScriptBuilder.iife(""" var names = []; for (var i = 0; i < Scene.getNumCameras(); i++) { names.push(Scene.getCamera(i).getName()); } return names; """) names = self._client.execute(script).value or [] return [DazCamera(self._client, NodeIdentifier(n)) for n in names]
[docs] def lights(self) -> list["DazLight"]: # noqa: F821 """Return all light nodes in the scene.""" from ._light import DazLight script = ScriptBuilder.iife(""" var names = []; for (var i = 0; i < Scene.getNumLights(); i++) { names.push(Scene.getLight(i).getName()); } return names; """) names = self._client.execute(script).value or [] return [DazLight(self._client, NodeIdentifier(n)) for n in names]
[docs] def find_camera_by_label(self, label: str) -> "DazCamera": # noqa: F821 """Find a camera by its user-visible label. Args: label: The label shown in the Scene panel (e.g. ``"Camera 1"``). This is the same value accepted by the ``camera`` parameter of :func:`~dazpy._render_api.render`. Returns: A :class:`~dazpy.DazCamera` proxy. Raises: NodeNotFoundError: If no camera with that label exists. """ from ._camera import DazCamera from .exceptions import NodeNotFoundError exists = ScriptBuilder.iife( f"return !!Scene.findCameraByLabel({ScriptBuilder.escape_string(label)});" ) if not self._client.execute(exists).value: raise NodeNotFoundError(f"Camera with label not found: {label!r}") return DazCamera(self._client, NodeIdentifier(label, kind="label"))
[docs] def find_light_by_label(self, label: str) -> "DazLight": # noqa: F821 """Find a light by its user-visible label. Args: label: The label shown in the Scene panel (e.g. ``"Distant Light 1"``). Returns: A :class:`~dazpy.DazLight` proxy. Raises: NodeNotFoundError: If no light with that label exists. """ from ._light import DazLight from .exceptions import NodeNotFoundError exists = ScriptBuilder.iife( f"return !!Scene.findLightByLabel({ScriptBuilder.escape_string(label)});" ) if not self._client.execute(exists).value: raise NodeNotFoundError(f"Light with label not found: {label!r}") return DazLight(self._client, NodeIdentifier(label, kind="label"))
[docs] def skeletons(self) -> list["DazSkeleton"]: # noqa: F821 """Return all skeleton (figure) nodes in the scene.""" from ._skeleton import DazSkeleton script = ScriptBuilder.iife(""" var names = []; var skels = Scene.getSkeletonList(); for (var i = 0; i < skels.length; i++) { names.push(skels[i].getName()); } return names; """) names = self._client.execute(script).value or [] return [DazSkeleton(self._client, NodeIdentifier(n)) for n in names]
[docs] def find_skeleton(self, name: str) -> "DazSkeleton": # noqa: F821 """Find a skeleton by its internal name. Args: name: Internal name of the skeleton node (e.g. ``"Genesis9"``). To look up by the user-visible label shown in the Scene panel (e.g. ``"Genesis 9"``), use :meth:`find_skeleton_by_label`. Returns: A :class:`~dazpy.DazSkeleton` proxy. Raises: NodeNotFoundError: If no skeleton with that name exists. """ from ._skeleton import DazSkeleton from .exceptions import NodeNotFoundError lookup = ScriptBuilder.iife(f""" var skels = Scene.getSkeletonList(); for (var i = 0; i < skels.length; i++) {{ if (skels[i].getName() === {ScriptBuilder.escape_string(name)}) return true; }} return false; """) if not self._client.execute(lookup).value: hint = ScriptBuilder.iife(""" var info = []; var skels = Scene.getSkeletonList(); for (var i = 0; i < skels.length; i++) { info.push(skels[i].getName() + "|" + skels[i].getLabel()); } return info; """) pairs = self._client.execute(hint).value or [] if pairs: available = ", ".join( f"{n!r} (label: {l!r})" for entry in pairs for n, _, l in [entry.partition("|")] ) raise NodeNotFoundError( f"Skeleton not found: {name!r}. " f"Available skeletons: {available}. " f"Tip: use find_skeleton_by_label() to search by the Scene-panel label." ) raise NodeNotFoundError(f"Skeleton not found: {name!r} (no skeletons in scene).") return DazSkeleton(self._client, NodeIdentifier(name))
[docs] def find_skeleton_by_label(self, label: str) -> "DazSkeleton": # noqa: F821 """Find a skeleton by its user-visible label. Args: label: Label shown in the Scene panel. Returns: A :class:`~dazpy.DazSkeleton` proxy. Raises: NodeNotFoundError: If no skeleton with that label exists. """ from ._skeleton import DazSkeleton from .exceptions import NodeNotFoundError exists_script = ScriptBuilder.iife( f"return !!Scene.findSkeletonByLabel({ScriptBuilder.escape_string(label)});" ) if not self._client.execute(exists_script).value: raise NodeNotFoundError(f"Skeleton with label not found: {label!r}") # Keep kind="label" so _skeleton_body matches on getLabel(), not getName(). # Multiple figures of the same type share the same internal name (e.g. both # Genesis 9 figures have getName() == "Genesis 9"), so name-based lookup # would silently resolve both to the first figure in the scene. return DazSkeleton(self._client, NodeIdentifier(label, kind="label"))
[docs] def num_skeletons(self) -> int: """Return the total number of skeleton nodes in the scene.""" script = ScriptBuilder.iife("return Scene.getNumSkeletons();") return self._client.execute(script).value or 0
[docs] def scene_snapshot( self, skeleton_labels: list[str] | None = None, ) -> list[dict]: """Return full skeleton and bone metadata for the scene in one HTTP call. Each entry in the returned list represents one skeleton and contains: ``name``, ``label``, and ``bones`` — a list of dicts with ``name``, ``label``, ``parent_name``, ``rotation_order``, ``local_position``, ``world_position``, and ``local_euler``. Pass to :func:`~dazpy.build_rig_profiles_from_snapshot` to build solver-ready :class:`~dazpy.FigureRigProfile` objects without any additional HTTP calls. Args: skeleton_labels: Optional list of skeleton names or labels to include. ``None`` returns every skeleton in the scene. Returns: List of skeleton snapshot dicts, one per matching skeleton. """ filter_js = json.dumps(skeleton_labels) if skeleton_labels is not None else "null" script = ScriptBuilder.iife(f""" var _filter = {filter_js}; var _skels = Scene.getSkeletonList(); var _result = []; for (var _s = 0; _s < _skels.length; _s++) {{ var _skel = _skels[_s]; if (_filter !== null) {{ var _found = false; for (var _f = 0; _f < _filter.length; _f++) {{ if (_filter[_f] === _skel.getName() || _filter[_f] === _skel.getLabel()) {{ _found = true; break; }} }} if (!_found) continue; }} var _bones = _skel.getAllBones(); var _boneList = []; for (var _i = 0; _i < _bones.length; _i++) {{ var _b = _bones[_i]; var _parent = _b.getNodeParent(); var _parentName = null; if (_parent && _parent.className && _parent.className() === "DzBone") {{ _parentName = _parent.getName(); }} var _lpos = _b.getLocalPos(); var _wpos = _b.getWSPos(); _boneList.push({{ name: _b.getName(), label: _b.getLabel(), parent_name: _parentName, rotation_order: _b.getRotationOrder(), local_position: {{x: _lpos.x, y: _lpos.y, z: _lpos.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() }} }}); }} _result.push({{ name: _skel.getName(), label: _skel.getLabel(), bones: _boneList }}); }} return _result; """) return self._client.execute(script).value or []
[docs] def all_node_transforms(self) -> list[dict]: """Return world-space transforms for every node in a single call. Returns: A list of dicts, each with keys ``"name"``, ``"label"``, ``"position"`` (``[x, y, z]``), ``"rotation"`` (``[x, y, z]``), and ``"visible"``. """ script = ScriptBuilder.iife(""" var result = []; for (var i = 0; i < Scene.getNumNodes(); i++) { var n = Scene.getNode(i); var pos = n.getWSPos(); var rot = n.getWSRot(); result.push({ name: n.getName(), label: n.getLabel(), position: [pos.x, pos.y, pos.z], rotation: [rot.x, rot.y, rot.z], visible: n.isVisible() }); } return result; """) return self._client.execute(script).value or []
[docs] def node_tree(self) -> list[dict]: """Return the full scene hierarchy as a nested list of dicts. Returns: Root-level nodes, each a dict with ``"name"``, ``"label"``, and ``"children"`` (recursively nested). """ script = ScriptBuilder.iife(""" function nodeToDict(n) { var children = []; for (var i = 0; i < n.getNumNodeChildren(); i++) { children.push(nodeToDict(n.getNodeChild(i))); } return {name: n.getName(), label: n.getLabel(), children: children}; } var roots = []; for (var i = 0; i < Scene.getNumNodes(); i++) { var n = Scene.getNode(i); if (!n.getNodeParent()) roots.push(nodeToDict(n)); } return roots; """) return self._client.execute(script).value or []
[docs] def selected_nodes(self) -> list[DazNode]: """Return the currently selected nodes.""" script = ScriptBuilder.iife(""" var nodes = Scene.getSelectedNodeList(); var names = []; for (var i = 0; i < nodes.length; i++) { names.push(nodes[i].getName()); } return names; """) names = self._client.execute(script).value or [] return [DazNode(self._client, NodeIdentifier(n)) for n in names]
[docs] def primary_selection(self) -> DazNode | None: """Return the primary selected node, or ``None`` if nothing is selected.""" script = ScriptBuilder.iife( "var n = Scene.getPrimarySelection(); return n ? n.getName() : null;" ) name = self._client.execute(script).value if name is None: return None return DazNode(self._client, NodeIdentifier(name))
[docs] def set_primary_selection(self, node: DazNode) -> None: """Set the primary selection to *node*. Args: node: The node to select. """ find_expr = ScriptBuilder.find_node_expr(node._identifier) script = ScriptBuilder.iife(f"Scene.setPrimarySelection({find_expr});") self._client.execute(script)
[docs] def apply_interaction_recipe( self, recipe: "InteractionRecipe", # noqa: F821 *, rig_profiles: dict[str, "FigureRigProfile"] | None = None, # noqa: F821 align_limb_targets: bool = False, max_iterations: int | None = None, step_degrees: float = 1.0, damping: float = 0.25, tolerance: float = 0.15, ) -> "PreparedInteractionResult": # noqa: F821 """Prepare and apply an interaction recipe against the current scene.""" from ._interaction import apply_interaction_recipe_to_scene return apply_interaction_recipe_to_scene( self, recipe, rig_profiles=rig_profiles, align_limb_targets=align_limb_targets, max_iterations=max_iterations, step_degrees=step_degrees, damping=damping, tolerance=tolerance, )
[docs] def select_all(self, on: bool = True) -> None: """Select or deselect all nodes. Args: on: ``True`` to select all, ``False`` to deselect all. """ flag = "true" if on else "false" script = ScriptBuilder.iife(f"Scene.selectAllNodes({flag});") self._client.execute(script)
[docs] def undo(self, label: str) -> "UndoGroup": # noqa: F821 """Return a context manager that groups all enclosed changes into a single undo step. Args: label: The label shown in DAZ Studio's Edit > Undo menu. Returns: A :class:`~dazpy.UndoGroup` context manager. Example:: with scene.undo("Move figure"): node.set_position(100, 0, 0) """ from ._undo import UndoGroup return UndoGroup(self._client, label)
[docs] def frame(self) -> int: """Return the current timeline frame number.""" script = ScriptBuilder.iife("return Scene.getFrame();") return self._client.execute(script).value or 0
[docs] def set_frame(self, frame: int) -> None: """Jump to a specific timeline frame. Args: frame: Zero-based frame number. """ script = ScriptBuilder.iife(f"Scene.setFrame({int(frame)});") self._client.execute(script)
# ── Scene I/O ──────────────────────────────────────────────────────────────
[docs] def load(self, path: str) -> None: """Load a scene file (merge mode — does not clear the existing scene). Args: path: Absolute path to the ``.daz`` or ``.duf`` file on the server host. """ script = ScriptBuilder.iife( f"Scene.loadScene({ScriptBuilder.escape_string(path)}, 0);" ) self._client.execute(script)
[docs] def save(self, path: str) -> None: """Save the scene to a file. Args: path: Absolute destination path on the server host. """ script = ScriptBuilder.iife( f"Scene.saveScene({ScriptBuilder.escape_string(path)});" ) self._client.execute(script)
[docs] def save_copy(self, path: str) -> dict: """Save a copy of the scene to *path* without changing the scene's current file or dirty state — equivalent to a "Save a Copy As…" operation. For clean scenes the plugin performs a simple file copy (zero DAZ state change). For scenes with unsaved changes it serialises via ``Scene.saveScene()`` and immediately restores the original filename; as a side effect unsaved changes are also written to the original file (unavoidable without the ``doSave(saveOnly=true)`` API, which is compiled out of current DAZ Studio 4.x builds). Args: path: Absolute destination path on the server host (e.g. ``"C:/backups/scene_v2.duf"``). Returns: ``{"ok": True, "path": str, "source": str, "method": str}`` where *method* is ``"copy"`` (file copy, no serialisation) or ``"serialize"`` / ``"serialize+restore"`` (required when the scene has unsaved changes). Raises: :class:`~dazpy.exceptions.DazError`: on server-side failure (non-2xx response). :class:`~dazpy.exceptions.AuthenticationError`: on HTTP 401/403. :class:`~dazpy.exceptions.ConnectionError`: if the server is unreachable. """ from .exceptions import AuthenticationError, DazError resp = self._client._post("/scene/save-copy", {"path": path}) if resp.status_code in (401, 403): raise AuthenticationError(f"HTTP {resp.status_code}: {resp.text[:200]}") if not resp.ok: body = resp.json() if resp.content else {} msg = body.get("error") or body.get("detail") or resp.text[:200] raise DazError(f"save_copy failed ({resp.status_code}): {msg}") return resp.json()
[docs] def filename(self) -> str: """Return the file path of the currently loaded scene, or an empty string.""" script = ScriptBuilder.iife("return Scene.getFilename();") return self._client.execute(script).value or ""
[docs] def needs_save(self) -> bool: """Return ``True`` if the scene has unsaved changes.""" script = ScriptBuilder.iife("return Scene.needsSave();") return bool(self._client.execute(script).value)
# ── Playback range ─────────────────────────────────────────────────────────
[docs] def play_range(self) -> dict: """Return the playback range as ``{"start": int, "end": int}`` (frames).""" script = ScriptBuilder.iife( "var r = Scene.getPlayRange();" "var step = Scene.getTimeStep();" "return {start: Math.round(r.start / step), end: Math.round(r.end / step)};" ) return self._client.execute(script).value or {"start": 0, "end": 0}
[docs] def set_play_range(self, start: int, end: int) -> None: """Set the playback range in frames. Args: start: First frame of the play range. end: Last frame of the play range. """ script = ScriptBuilder.iife( f"var step = Scene.getTimeStep();" f"Scene.setPlayRange(new DzTimeRange({int(start)} * step, {int(end)} * step));" ) self._client.execute(script)
[docs] def anim_range(self) -> dict: """Return the animation range as ``{"start": int, "end": int}`` (frames).""" script = ScriptBuilder.iife( "var r = Scene.getAnimRange();" "var step = Scene.getTimeStep();" "return {start: Math.round(r.start / step), end: Math.round(r.end / step)};" ) return self._client.execute(script).value or {"start": 0, "end": 0}
[docs] def set_anim_range(self, start: int, end: int) -> None: """Set the animation range in frames. Args: start: First frame. end: Last frame. """ script = ScriptBuilder.iife( f"var step = Scene.getTimeStep();" f"Scene.setAnimRange(new DzTimeRange({int(start)} * step, {int(end)} * step));" ) self._client.execute(script)
# ── Playback state ─────────────────────────────────────────────────────────
[docs] def is_playing(self) -> bool: """Return ``True`` if the scene is currently playing back.""" script = ScriptBuilder.iife("return Scene.isPlaying();") return bool(self._client.execute(script).value)
[docs] def loop_playback(self, on: bool) -> None: """Enable or disable looping playback. Args: on: ``True`` to enable looping, ``False`` to disable. """ flag = "true" if on else "false" script = ScriptBuilder.iife(f"Scene.loopPlayback({flag});") self._client.execute(script)
# ── Undo / Redo ────────────────────────────────────────────────────────────
[docs] def undo_last(self) -> None: """Step back one level in DAZ Studio's undo stack. Equivalent to pressing Ctrl+Z / Edit > Undo in DAZ Studio. Has no effect if the undo stack is empty. Note: this steps the global undo stack. To group a series of changes into a single undoable operation use :meth:`undo` (the context manager). """ script = ScriptBuilder.iife("App.getUndoStack().undo();") self._client.execute(script)
[docs] def redo_last(self) -> None: """Step forward one level in DAZ Studio's undo stack. Equivalent to pressing Ctrl+Y / Edit > Redo in DAZ Studio. Has no effect if there is nothing to redo. """ script = ScriptBuilder.iife("App.getUndoStack().redo();") self._client.execute(script)
# ── dForce simulation ──────────────────────────────────────────────────────
[docs] def is_simulating(self) -> bool: """Return ``True`` if a dForce simulation is currently running.""" script = ScriptBuilder.iife("return App.getSimulationMgr().isSimulating();") return bool(self._client.execute(script).value)
[docs] def clear_dforce_simulation(self) -> None: """Discard all cached dForce simulation data for the active engine.""" script = ScriptBuilder.iife("App.getSimulationMgr().clearSimulation();") self._client.execute(script)
[docs] def run_dforce_simulation( self, nodes: list[DazNode] | None = None, *, wait: bool = True, timeout: float = 300.0, ) -> str | None: """Run a dForce simulation using the active simulation engine. Args: nodes: Optional subset of nodes to simulate via ``DzSimulationEngine.customSimulate()``. ``None`` (default) simulates the whole scene via ``DzSimulationMgr.simulate()``, which follows the frame range configured in the Simulation Settings pane. wait: If ``True`` (default), block until the simulation finishes, using the async execute-and-poll endpoint since dForce runs can take minutes. If ``False``, submit the job and return immediately. timeout: Maximum seconds to wait when *wait* is ``True``. Returns: ``None`` when *wait* is ``True`` (the simulation already finished by the time this call returns). When *wait* is ``False``, the ``request_id`` of the submitted async job — poll it with :meth:`~dazpy.DazClient.get_request_status` / :meth:`~dazpy.DazClient.get_request_result`. Raises: :class:`~dazpy.exceptions.ScriptRuntimeError`: If the simulation engine reports an error. """ if nodes: node_exprs = ",".join(ScriptBuilder.find_node_expr(n._identifier) for n in nodes) body = f""" var mgr = App.getSimulationMgr(); var engine = mgr.getActiveSimulationEngine(); if (!engine) return {{"error": "no_active_engine"}}; var err = engine.customSimulate([{node_exprs}]); return {{"error": err ? String(err) : null}}; """ else: body = """ var mgr = App.getSimulationMgr(); var err = mgr.simulate(); return {"error": err ? String(err) : null}; """ script = ScriptBuilder.iife(body) if not wait: return self._client.execute_async_submit(script) from ._polling import execute_long from .exceptions import ScriptRuntimeError result = execute_long(self._client, script, timeout=timeout) data = result.value or {} if data.get("error"): raise ScriptRuntimeError(f"dForce simulation failed: {data['error']}") return None