Skip to content

Commit 3037ea1

Browse files
ghirparaclaude
andcommitted
feat(dazpy): add dForce simulation control
Add DazDForce modifier proxy (freeze_simulation/freeze/unfreeze, backed by the "Freeze Simulation" property DAZ exposes on DzDForceModifier), wire it into DazNode's modifier type discrimination and a new dforce_modifiers() filter, and add DazScene.run_dforce_simulation()/ is_simulating()/clear_dforce_simulation() driven by DzSimulationMgr. Long-running simulations use the async execute-and-poll path via execute_long. Closes daz-script-server-23o. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 663a4e6 commit 3037ea1

8 files changed

Lines changed: 350 additions & 16 deletions

File tree

.beads/interactions.jsonl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,3 +107,4 @@
107107
{"id":"int-5a43b6c7","kind":"field_change","created_at":"2026-06-25T02:24:17.8197462Z","actor":"G.Hirpara","issue_id":"daz-script-server-017","extra":{"field":"status","new_value":"in_progress","old_value":"open"}}
108108
{"id":"int-8d6b4894","kind":"field_change","created_at":"2026-06-27T16:13:46.4041403Z","actor":"G.Hirpara","issue_id":"daz-script-server-4kg","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Updated all four .github/workflows/*.md docs to reflect 6-job DS4+DS6 build matrix, new artifact naming, private SDK repo, and Qt6 caching"}}
109109
{"id":"int-596487b9","kind":"field_change","created_at":"2026-06-27T16:19:25.0398042Z","actor":"G.Hirpara","issue_id":"daz-script-server-v4b","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented: build-dazpy now builds wheel+sdist, release-tagged.yml has publish-pypi job using OIDC, artifact renamed to dazpy-dist everywhere, docs updated"}}
110+
{"id":"int-31bf68ff","kind":"field_change","created_at":"2026-07-13T11:50:35.0064285Z","actor":"G.Hirpara","issue_id":"daz-script-server-23o","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Added DazDForce modifier class (freeze/unfreeze), DazNode.dforce_modifiers()/find_modifier() type discrimination, and DazScene.run_dforce_simulation()/is_simulating()/clear_dforce_simulation() backed by DzSimulationMgr. Unit + integration tests added."}}

.beads/issues.jsonl

Lines changed: 9 additions & 0 deletions
Large diffs are not rendered by default.

dazpy/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from ._material import DazMaterial
2626
from ._modifier import DazModifier
2727
from ._morph import DazMorph
28+
from ._dforce import DazDForce
2829
from ._geometry import DazGeometry
2930
from ._render import DazRenderSettings
3031
from ._viewport import DazViewport
@@ -97,6 +98,7 @@
9798
"DazMaterial",
9899
"DazModifier",
99100
"DazMorph",
101+
"DazDForce",
100102
"DazGeometry",
101103
"DazRenderSettings",
102104
"DazViewport",

dazpy/_dforce.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
from __future__ import annotations
2+
3+
from ._modifier import DazModifier
4+
from ._script_builder import ScriptBuilder
5+
6+
7+
class DazDForce(DazModifier):
8+
"""Proxy for a ``DzDForceModifier`` (cloth/hair dForce simulation modifier).
9+
10+
Returned by :meth:`DazNode.modifiers`, :meth:`DazNode.find_modifier`,
11+
:meth:`DazNode.find_modifier_by_label`, and :meth:`DazNode.dforce_modifiers`
12+
when the underlying DAZ modifier is a ``DzDForceModifier``.
13+
14+
Simulation tunables not covered by :attr:`freeze_simulation` (e.g.
15+
``"Dynamics Strength"``, ``"Contraction-Expansion Ratio"``, the various
16+
stiffness weight maps) are still reachable through the inherited
17+
:meth:`~dazpy.DazElement.get_property` / :meth:`~dazpy.DazElement.set_property`,
18+
which look properties up by their Parameters-pane label.
19+
"""
20+
21+
@property
22+
def freeze_simulation(self) -> bool | None:
23+
"""Whether the simulated result is frozen onto the mesh (read/write).
24+
25+
Freezing detaches the mesh from the live dForce solve so it holds its
26+
current shape without needing to re-simulate — this is DAZ Studio's
27+
equivalent of "baking" a dForce result.
28+
"""
29+
script = ScriptBuilder.iife(f"""
30+
var m = {self._locator};
31+
if (!m) return null;
32+
var p = m.findPropertyByLabel("Freeze Simulation");
33+
return p ? p.getValue() : null;
34+
""")
35+
return self._client.execute(script).value
36+
37+
@freeze_simulation.setter
38+
def freeze_simulation(self, value: bool) -> None:
39+
flag = "true" if value else "false"
40+
script = ScriptBuilder.iife(f"""
41+
var m = {self._locator};
42+
if (!m) return;
43+
var p = m.findPropertyByLabel("Freeze Simulation");
44+
if (p) p.setValue({flag});
45+
""")
46+
self._client.execute(script)
47+
48+
def freeze(self) -> None:
49+
"""Bake the current simulated result onto the mesh (sets ``freeze_simulation`` on)."""
50+
self.freeze_simulation = True
51+
52+
def unfreeze(self) -> None:
53+
"""Release a frozen simulation so it resumes following the dForce solve."""
54+
self.freeze_simulation = False

dazpy/_node.py

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from ._material import DazMaterial
1212
from ._modifier import DazModifier
1313
from ._morph import DazMorph
14+
from ._dforce import DazDForce
1415

1516

1617
@dataclass
@@ -167,6 +168,17 @@ def children(self) -> list[DazNode]:
167168
names = self._client.execute(script).value or []
168169
return [DazNode(self._client, NodeIdentifier(n)) for n in names]
169170

171+
@staticmethod
172+
def _modifier_class_for(class_name: str):
173+
from ._modifier import DazModifier
174+
from ._morph import DazMorph
175+
from ._dforce import DazDForce
176+
if class_name == "DzMorph":
177+
return DazMorph
178+
if class_name == "DzDForceModifier":
179+
return DazDForce
180+
return DazModifier
181+
170182
def _modifier_locator(self, modifier_name: str) -> str:
171183
return (
172184
f"(function(){{"
@@ -183,8 +195,6 @@ def modifiers(self) -> list["DazModifier"]:
183195
A list of :class:`~dazpy.DazMorph` and :class:`~dazpy.DazModifier`
184196
instances.
185197
"""
186-
from ._modifier import DazModifier
187-
from ._morph import DazMorph
188198
script = ScriptBuilder.node_body(
189199
self._identifier,
190200
"""
@@ -202,10 +212,8 @@ def modifiers(self) -> list["DazModifier"]:
202212
result = []
203213
for item in items:
204214
loc = self._modifier_locator(item["name"])
205-
if item["className"] == "DzMorph":
206-
result.append(DazMorph(self._client, loc))
207-
else:
208-
result.append(DazModifier(self._client, loc))
215+
cls = self._modifier_class_for(item["className"])
216+
result.append(cls(self._client, loc))
209217
return result
210218

211219
def find_modifier(self, name: str) -> "DazModifier | None":
@@ -218,8 +226,6 @@ def find_modifier(self, name: str) -> "DazModifier | None":
218226
A :class:`~dazpy.DazMorph` or :class:`~dazpy.DazModifier`, or
219227
``None`` if not found.
220228
"""
221-
from ._modifier import DazModifier
222-
from ._morph import DazMorph
223229
script = ScriptBuilder.node_body(
224230
self._identifier,
225231
f"""
@@ -233,9 +239,7 @@ def find_modifier(self, name: str) -> "DazModifier | None":
233239
if result is None:
234240
return None
235241
loc = self._modifier_locator(result["name"])
236-
if result["className"] == "DzMorph":
237-
return DazMorph(self._client, loc)
238-
return DazModifier(self._client, loc)
242+
return self._modifier_class_for(result["className"])(self._client, loc)
239243

240244
def _material_locator(self, material_name: str) -> str:
241245
return (
@@ -309,8 +313,6 @@ def find_modifier_by_label(self, label: str) -> "DazModifier | None":
309313
A :class:`~dazpy.DazMorph` or :class:`~dazpy.DazModifier`, or
310314
``None`` if no modifier with that label exists.
311315
"""
312-
from ._modifier import DazModifier
313-
from ._morph import DazMorph
314316
script = ScriptBuilder.node_body(
315317
self._identifier,
316318
f"""
@@ -329,9 +331,7 @@ def find_modifier_by_label(self, label: str) -> "DazModifier | None":
329331
if result is None:
330332
return None
331333
loc = self._modifier_locator(result["name"])
332-
if result["className"] == "DzMorph":
333-
return DazMorph(self._client, loc)
334-
return DazModifier(self._client, loc)
334+
return self._modifier_class_for(result["className"])(self._client, loc)
335335

336336
def find_property(self, name: str) -> "DazProperty | None": # noqa: F821
337337
"""Find a node-level property by its internal name.
@@ -389,6 +389,11 @@ def morphs(self) -> list["DazMorph"]:
389389
from ._morph import DazMorph
390390
return [m for m in self.modifiers() if isinstance(m, DazMorph)]
391391

392+
def dforce_modifiers(self) -> list["DazDForce"]:
393+
"""Return only the dForce simulation modifiers on this node (convenience filter)."""
394+
from ._dforce import DazDForce
395+
return [m for m in self.modifiers() if isinstance(m, DazDForce)]
396+
392397
def set_rotation(self, x: float, y: float, z: float) -> None:
393398
"""Set the world-space rotation using Euler angles in degrees.
394399

dazpy/_scene.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -656,3 +656,75 @@ def redo_last(self) -> None:
656656
"""
657657
script = ScriptBuilder.iife("App.getUndoStack().redo();")
658658
self._client.execute(script)
659+
660+
# ── dForce simulation ──────────────────────────────────────────────────────
661+
662+
def is_simulating(self) -> bool:
663+
"""Return ``True`` if a dForce simulation is currently running."""
664+
script = ScriptBuilder.iife("return App.getSimulationMgr().isSimulating();")
665+
return bool(self._client.execute(script).value)
666+
667+
def clear_dforce_simulation(self) -> None:
668+
"""Discard all cached dForce simulation data for the active engine."""
669+
script = ScriptBuilder.iife("App.getSimulationMgr().clearSimulation();")
670+
self._client.execute(script)
671+
672+
def run_dforce_simulation(
673+
self,
674+
nodes: list[DazNode] | None = None,
675+
*,
676+
wait: bool = True,
677+
timeout: float = 300.0,
678+
) -> str | None:
679+
"""Run a dForce simulation using the active simulation engine.
680+
681+
Args:
682+
nodes: Optional subset of nodes to simulate via
683+
``DzSimulationEngine.customSimulate()``. ``None`` (default)
684+
simulates the whole scene via ``DzSimulationMgr.simulate()``,
685+
which follows the frame range configured in the Simulation
686+
Settings pane.
687+
wait: If ``True`` (default), block until the simulation finishes,
688+
using the async execute-and-poll endpoint since dForce runs can
689+
take minutes. If ``False``, submit the job and return
690+
immediately.
691+
timeout: Maximum seconds to wait when *wait* is ``True``.
692+
693+
Returns:
694+
``None`` when *wait* is ``True`` (the simulation already finished
695+
by the time this call returns). When *wait* is ``False``, the
696+
``request_id`` of the submitted async job — poll it with
697+
:meth:`~dazpy.DazClient.get_request_status` /
698+
:meth:`~dazpy.DazClient.get_request_result`.
699+
700+
Raises:
701+
:class:`~dazpy.exceptions.ScriptRuntimeError`: If the simulation
702+
engine reports an error.
703+
"""
704+
if nodes:
705+
node_exprs = ",".join(ScriptBuilder.find_node_expr(n._identifier) for n in nodes)
706+
body = f"""
707+
var mgr = App.getSimulationMgr();
708+
var engine = mgr.getActiveSimulationEngine();
709+
if (!engine) return {{"error": "no_active_engine"}};
710+
var err = engine.customSimulate([{node_exprs}]);
711+
return {{"error": err ? String(err) : null}};
712+
"""
713+
else:
714+
body = """
715+
var mgr = App.getSimulationMgr();
716+
var err = mgr.simulate();
717+
return {"error": err ? String(err) : null};
718+
"""
719+
script = ScriptBuilder.iife(body)
720+
721+
if not wait:
722+
return self._client.execute_async_submit(script)
723+
724+
from ._polling import execute_long
725+
from .exceptions import ScriptRuntimeError
726+
result = execute_long(self._client, script, timeout=timeout)
727+
data = result.value or {}
728+
if data.get("error"):
729+
raise ScriptRuntimeError(f"dForce simulation failed: {data['error']}")
730+
return None

tests/test_dazpy.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1640,6 +1640,63 @@ def test_modifier_locator_uses_findModifier(self):
16401640
self.assertIn("MyMorph", loc)
16411641
self.assertIn("getObject", loc)
16421642

1643+
def test_find_modifier_returns_daz_dforce_when_class_is_dzdforcemodifier(self):
1644+
from dazpy._dforce import DazDForce
1645+
client = _make_client({"name": "MyCloth", "className": "DzDForceModifier"})
1646+
node = DazNode(client, NodeIdentifier("Genesis9"))
1647+
mod = node.find_modifier("MyCloth")
1648+
self.assertIsInstance(mod, DazDForce)
1649+
1650+
def test_dforce_modifiers_filters_to_only_dzdforcemodifier(self):
1651+
from dazpy._dforce import DazDForce
1652+
client = _make_client([
1653+
{"name": "SomeMod", "className": "DzSubDivisionModifier"},
1654+
{"name": "MyMorph", "className": "DzMorph"},
1655+
{"name": "MyCloth", "className": "DzDForceModifier"},
1656+
])
1657+
node = DazNode(client, NodeIdentifier("Genesis9"))
1658+
mods = node.dforce_modifiers()
1659+
self.assertEqual(len(mods), 1)
1660+
self.assertIsInstance(mods[0], DazDForce)
1661+
1662+
1663+
class TestDazDForceScriptGeneration(unittest.TestCase):
1664+
def _make_dforce(self, return_value=None):
1665+
from dazpy._dforce import DazDForce
1666+
client = _make_client(return_value)
1667+
locator = 'Scene.findNode("Genesis9").getObject().findModifier("MyCloth")'
1668+
mod = DazDForce(client, locator)
1669+
return mod, client
1670+
1671+
def test_freeze_simulation_getter_calls_findPropertyByLabel(self):
1672+
mod, client = self._make_dforce(True)
1673+
val = mod.freeze_simulation
1674+
self.assertTrue(val)
1675+
script = client.execute.call_args[0][0]
1676+
self.assertIn("findPropertyByLabel", script)
1677+
self.assertIn("Freeze Simulation", script)
1678+
1679+
def test_freeze_simulation_setter_calls_setValue(self):
1680+
mod, client = self._make_dforce(None)
1681+
mod.freeze_simulation = True
1682+
script = client.execute.call_args[0][0]
1683+
self.assertIn("setValue", script)
1684+
self.assertIn("true", script)
1685+
1686+
def test_freeze_sets_freeze_simulation_true(self):
1687+
mod, client = self._make_dforce(None)
1688+
mod.freeze()
1689+
script = client.execute.call_args[0][0]
1690+
self.assertIn("Freeze Simulation", script)
1691+
self.assertIn("true", script)
1692+
1693+
def test_unfreeze_sets_freeze_simulation_false(self):
1694+
mod, client = self._make_dforce(None)
1695+
mod.unfreeze()
1696+
script = client.execute.call_args[0][0]
1697+
self.assertIn("Freeze Simulation", script)
1698+
self.assertIn("false", script)
1699+
16431700

16441701
class TestDazMaterialScriptGeneration(unittest.TestCase):
16451702
def _make_material(self, return_value=None):
@@ -2123,6 +2180,72 @@ def test_loop_playback_off(self):
21232180
self.assertIn("false", script)
21242181

21252182

2183+
class TestDazSceneDForceSimulation(unittest.TestCase):
2184+
def _scene(self, return_value=None):
2185+
return DazScene(_make_client(return_value))
2186+
2187+
def test_is_simulating_true(self):
2188+
scene = self._scene(True)
2189+
self.assertTrue(scene.is_simulating())
2190+
script = scene._client.execute.call_args[0][0]
2191+
self.assertIn("getSimulationMgr", script)
2192+
self.assertIn("isSimulating", script)
2193+
2194+
def test_is_simulating_false(self):
2195+
scene = self._scene(False)
2196+
self.assertFalse(scene.is_simulating())
2197+
2198+
def test_clear_dforce_simulation_calls_clearSimulation(self):
2199+
scene = self._scene(None)
2200+
scene.clear_dforce_simulation()
2201+
script = scene._client.execute.call_args[0][0]
2202+
self.assertIn("getSimulationMgr", script)
2203+
self.assertIn("clearSimulation", script)
2204+
2205+
def test_run_dforce_simulation_wait_false_submits_async(self):
2206+
scene = self._scene()
2207+
scene._client.execute_async_submit.return_value = "req-123"
2208+
request_id = scene.run_dforce_simulation(wait=False)
2209+
self.assertEqual(request_id, "req-123")
2210+
script = scene._client.execute_async_submit.call_args[0][0]
2211+
self.assertIn("getSimulationMgr", script)
2212+
self.assertIn("mgr.simulate()", script)
2213+
2214+
def test_run_dforce_simulation_with_nodes_uses_customSimulate(self):
2215+
scene = self._scene()
2216+
scene._client.execute_async_submit.return_value = "req-456"
2217+
node = DazNode(scene._client, NodeIdentifier("Skirt"))
2218+
scene.run_dforce_simulation(nodes=[node], wait=False)
2219+
script = scene._client.execute_async_submit.call_args[0][0]
2220+
self.assertIn("customSimulate", script)
2221+
self.assertIn("getActiveSimulationEngine", script)
2222+
self.assertIn("Skirt", script)
2223+
2224+
def test_run_dforce_simulation_wait_true_returns_none_on_success(self):
2225+
scene = self._scene()
2226+
scene._client.execute_async_submit.return_value = "req-789"
2227+
scene._client.get_request_result.return_value = {
2228+
"success": True,
2229+
"result": {"error": None},
2230+
"output": [],
2231+
"duration_ms": 12.0,
2232+
}
2233+
result = scene.run_dforce_simulation(wait=True)
2234+
self.assertIsNone(result)
2235+
2236+
def test_run_dforce_simulation_wait_true_raises_on_engine_error(self):
2237+
scene = self._scene()
2238+
scene._client.execute_async_submit.return_value = "req-999"
2239+
scene._client.get_request_result.return_value = {
2240+
"success": True,
2241+
"result": {"error": "DZ_ERROR_SOMETHING"},
2242+
"output": [],
2243+
"duration_ms": 12.0,
2244+
}
2245+
with self.assertRaises(exceptions.ScriptRuntimeError):
2246+
scene.run_dforce_simulation(wait=True)
2247+
2248+
21262249
class TestDazLightScriptGeneration(unittest.TestCase):
21272250
def setUp(self):
21282251
from dazpy._light import DazLight

0 commit comments

Comments
 (0)