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"))
_LIGHT_TYPE_CLASSES = {
"spot": "DzSpotLight",
"point": "DzPointLight",
"distant": "DzDistantLight",
}
[docs]
def create_camera(self, name: str | None = None) -> "DazCamera": # noqa: F821
"""Create a new basic camera node and add it to the scene.
Args:
name: Optional internal name for the new camera. When omitted,
DAZ Studio assigns its default name (e.g. ``"Camera"``,
de-duplicated as ``"Camera 2"`` etc.).
Returns:
A :class:`~dazpy.DazCamera` proxy for the newly created camera.
"""
from ._camera import DazCamera
name_expr = ScriptBuilder.escape_string(name) if name is not None else "null"
script = ScriptBuilder.iife(f"""
var cam = new DzBasicCamera();
if ({name_expr} !== null) cam.setName({name_expr});
Scene.addNode(cam);
return cam.getName();
""")
created_name = self._client.execute(script).value
return DazCamera(self._client, NodeIdentifier(created_name))
[docs]
def create_light(self, light_type: str, name: str | None = None) -> "DazLight": # noqa: F821
"""Create a new light node and add it to the scene.
Args:
light_type: One of ``"spot"``, ``"point"``, or ``"distant"``.
name: Optional internal name for the new light. When omitted,
DAZ Studio assigns its default name.
Returns:
A :class:`~dazpy.DazLight` proxy for the newly created light.
Raises:
ValueError: If *light_type* is not a recognised light type.
"""
from ._light import DazLight
class_name = self._LIGHT_TYPE_CLASSES.get(light_type)
if class_name is None:
raise ValueError(
f"Unknown light_type {light_type!r}; expected one of "
f"{sorted(self._LIGHT_TYPE_CLASSES)}"
)
name_expr = ScriptBuilder.escape_string(name) if name is not None else "null"
script = ScriptBuilder.iife(f"""
var light = new {class_name}();
if ({name_expr} !== null) light.setName({name_expr});
Scene.addNode(light);
return light.getName();
""")
created_name = self._client.execute(script).value
return DazLight(self._client, NodeIdentifier(created_name))
[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, *, retry_attempts: int = 3, retry_delay: float = 0.15
) -> "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`.
retry_attempts: Number of ``Scene.getSkeletonList()`` lookups to
try before concluding the skeleton is really absent.
``Scene.getSkeletonList()`` has been observed to transiently
omit a skeleton that is present under load from a burst of
other main-thread script calls (see daz-script-server-xtkd)
-- a momentary miss is retried rather than immediately
raised. Set to ``1`` to disable retrying.
retry_delay: Base seconds to sleep between retries (each attempt
after the first waits ``retry_delay * attempt_number``).
Returns:
A :class:`~dazpy.DazSkeleton` proxy.
Raises:
NodeNotFoundError: If no skeleton with that name exists after
*retry_attempts* lookups.
"""
import time
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;
""")
found = False
for attempt in range(retry_attempts):
if self._client.execute(lookup).value:
found = True
break
if attempt < retry_attempts - 1:
time.sleep(retry_delay * (attempt + 1))
if not found:
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 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 node_hierarchy(self, root: str | None = None, max_depth: int | None = None) -> dict:
"""Return the descendant tree rooted at a single node, in one HTTP call.
Unlike :meth:`node_tree` (every root-level node in the scene), this
starts at one named node -- typically a figure -- and includes a
``type`` (DazScript class name) on every entry and an optional
recursion-depth limit, which matters for deep skeletons (Genesis
figures have 100+ bones).
Args:
root: Display label or internal name of the root node. Searched
by label first, then internal name.
max_depth: Maximum recursion depth. ``None`` or ``0`` means
unlimited.
Returns:
``{"node": label, "hierarchy": {...}, "total_descendants": int}``
where ``hierarchy`` is ``{"label", "name", "type", "children": [...]}``
(``children`` omitted for leaf nodes).
Raises:
NodeNotFoundError: If *root* cannot be found.
"""
from .exceptions import NodeNotFoundError
root_json = json.dumps(root)
depth_js = str(int(max_depth)) if max_depth else "0"
script = ScriptBuilder.iife(f"""
var _rootLabel = {root_json};
var _node = Scene.findNodeByLabel(_rootLabel);
if (!_node) _node = Scene.findNode(_rootLabel);
if (!_node) return null;
var _maxDepth = {depth_js};
var _totalDescendants = 0;
function _build(n, depth) {{
if (_maxDepth > 0 && depth >= _maxDepth) return null;
var info = {{label: n.getLabel(), name: n.getName(), type: n.className()}};
var children = [];
for (var i = 0; i < n.getNumNodeChildren(); i++) {{
_totalDescendants++;
var childInfo = _build(n.getNodeChild(i), depth + 1);
if (childInfo) children.push(childInfo);
}}
if (children.length > 0) info.children = children;
return info;
}}
var hierarchy = _build(_node, 0);
return {{node: _node.getLabel(), hierarchy: hierarchy, total_descendants: _totalDescendants}};
""")
result = self._client.execute(script).value
if result is None:
raise NodeNotFoundError(f"Node not found: {root!r}")
return result
[docs]
def overview(self) -> dict:
"""Return a lightweight, top-level snapshot of the scene in one HTTP call.
Root-level figures, all cameras, all lights, the open scene file,
and the primary selection -- enough to orient an agent without
enumerating every node (use :meth:`nodes` or :meth:`node_tree` for
that).
Returns:
``{"scene_file", "selected_node", "figures", "cameras", "lights", "total_nodes"}``.
``figures``/``cameras``/``lights`` are lists of
``{"name", "label", "type"}`` (``type`` omitted for cameras).
Follower figures (eyelashes, tear surfaces, etc. parented under
another figure rather than the scene root) are excluded from
``figures``.
"""
script = ScriptBuilder.iife("""
var figures = [];
for (var i = 0; i < Scene.getNumSkeletons(); i++) {
var s = Scene.getSkeleton(i);
var parent = s.getNodeParent();
if (parent && parent.inherits("DzFigure")) continue;
figures.push({name: s.getName(), label: s.getLabel(), type: s.className()});
}
var cameras = [];
for (var i = 0; i < Scene.getNumCameras(); i++) {
var c = Scene.getCamera(i);
cameras.push({name: c.getName(), label: c.getLabel()});
}
var lights = [];
for (var i = 0; i < Scene.getNumLights(); i++) {
var l = Scene.getLight(i);
lights.push({name: l.getName(), label: l.getLabel(), type: l.className()});
}
var sel = Scene.getPrimarySelection();
return {
scene_file: Scene.getFilename(),
selected_node: sel ? sel.getLabel() : null,
figures: figures,
cameras: cameras,
lights: lights,
total_nodes: Scene.getNumNodes()
};
""")
return self._client.execute(script).value or {
"scene_file": "", "selected_node": None, "figures": [], "cameras": [], "lights": [], "total_nodes": 0,
}
[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()
def _export_via_native_exporter(self, exporter_class: str, path: str, overrides: dict) -> None:
"""Run one of DAZ Studio's built-in file exporters (``DzExportMgr``).
Confirmed against a live instance: ``App.getExportMgr()`` registers
exporters by class name (e.g. ``"DzFbxExporter"``, ``"DzObjExporter"``)
and each exposes ``getDefaultOptions(DzFileIOSettings*)`` /
``writeFile(path, settings)``. Defaults are seeded from the exporter
itself, then *overrides* are applied by Python type (bool -> setBoolValue,
int -> setIntValue, float -> setFloatValue, else -> setStringValue).
``RunSilent`` must be forced to 1 or the native exporter shows a modal
options dialog, which would hang the main thread the HTTP handler
blocks on.
"""
settings_calls = []
for key, value in overrides.items():
js_key = json.dumps(key)
if isinstance(value, bool):
settings_calls.append(f"settings.setBoolValue({js_key}, {'true' if value else 'false'});")
elif isinstance(value, int):
settings_calls.append(f"settings.setIntValue({js_key}, {value});")
elif isinstance(value, float):
settings_calls.append(f"settings.setFloatValue({js_key}, {value});")
else:
settings_calls.append(f"settings.setStringValue({js_key}, {json.dumps(str(value))});")
script = ScriptBuilder.iife(f"""
var mgr = App.getExportMgr();
var exp = mgr.findExporterByClassName({json.dumps(exporter_class)});
if (!exp) return;
var settings = new DzFileIOSettings();
exp.getDefaultOptions(settings);
{' '.join(settings_calls)}
exp.writeFile({json.dumps(path)}, settings);
""")
self._client.execute(script)
[docs]
def export_fbx(
self,
path: str,
*,
selected_only: bool = False,
include_figures: bool = True,
include_props: bool = False,
include_lights: bool = False,
include_cameras: bool = False,
include_animations: bool = False,
embed_textures: bool = True,
options: dict | None = None,
) -> None:
"""Export the scene to an FBX file via DAZ Studio's built-in FBX exporter.
This is a plain synchronous call, unlike
:meth:`~dazpy.DazClient.export_usd_submit` -- FBX export is a native
DAZ Studio exporter (``DzFbxExporter``) rather than a custom C++
pipeline, so there's no async job to poll.
Args:
path: Absolute destination path on the DAZ Studio host (should
end in ``.fbx``).
selected_only: Export only the selected node(s).
include_figures: Include figures (default ``True``).
include_props: Include prop nodes.
include_lights: Include scene lights.
include_cameras: Include cameras.
include_animations: Include the current animation take.
embed_textures: Embed texture maps into the FBX file (default ``True``).
options: Additional raw ``DzFileIOSettings`` overrides (e.g.
``{"Format": "FBX 2014 -- Binary"}``), applied on top of the
exporter's own defaults and the named args above. See
``DzFbxExporter.getDefaultOptions()`` for the full set of keys.
"""
overrides = {
"IncludeSelectedOnly": selected_only,
"IncludeFigures": include_figures,
"IncludeProps": include_props,
"IncludeLights": include_lights,
"IncludeCameras": include_cameras,
"IncludeAnimations": include_animations,
"EmbedTextures": embed_textures,
}
overrides.update(options or {})
overrides["RunSilent"] = 1
self._export_via_native_exporter("DzFbxExporter", path, overrides)
[docs]
def export_obj(
self,
path: str,
*,
selected_only: bool = False,
ignore_invisible: bool = True,
include_normals: bool = False,
collect_maps: bool = False,
options: dict | None = None,
) -> None:
"""Export the scene to an OBJ file via DAZ Studio's built-in OBJ exporter.
This is a plain synchronous call -- see :meth:`export_fbx` for why
this doesn't follow the USD export module's async job pattern.
Args:
path: Absolute destination path on the DAZ Studio host (should
end in ``.obj``).
selected_only: Export only the selected node(s).
ignore_invisible: Skip nodes hidden in the viewport (default
``True``, matches the exporter's own default).
include_normals: Write vertex normals (``WriteVN``).
collect_maps: Copy referenced texture maps next to the
``.obj``/``.mtl`` instead of referencing their original paths.
options: Additional raw ``DzFileIOSettings`` overrides, applied
on top of the exporter's own defaults and the named args
above. See ``DzObjExporter.getDefaultOptions()`` for the
full set of keys.
"""
overrides = {
"SelectedOnly": selected_only,
"IgnoreInvisible": ignore_invisible,
"WriteVN": include_normals,
"CollectMaps": collect_maps,
}
overrides.update(options or {})
overrides["RunSilent"] = 1
self._export_via_native_exporter("DzObjExporter", path, overrides)
[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