dazpy 2.6.0__tar.gz → 2.7.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. {dazpy-2.6.0 → dazpy-2.7.0}/PKG-INFO +1 -1
  2. {dazpy-2.6.0 → dazpy-2.7.0}/dazpy/__init__.py +18 -2
  3. {dazpy-2.6.0 → dazpy-2.7.0}/dazpy/_camera.py +27 -0
  4. {dazpy-2.6.0 → dazpy-2.7.0}/dazpy/_client.py +141 -9
  5. dazpy-2.7.0/dazpy/_dforce.py +54 -0
  6. {dazpy-2.6.0 → dazpy-2.7.0}/dazpy/_element.py +34 -0
  7. {dazpy-2.6.0 → dazpy-2.7.0}/dazpy/_interaction.py +61 -1
  8. {dazpy-2.6.0 → dazpy-2.7.0}/dazpy/_node.py +194 -16
  9. {dazpy-2.6.0 → dazpy-2.7.0}/dazpy/_pose.py +56 -22
  10. {dazpy-2.6.0 → dazpy-2.7.0}/dazpy/_property.py +81 -0
  11. dazpy-2.7.0/dazpy/_render.py +573 -0
  12. {dazpy-2.6.0 → dazpy-2.7.0}/dazpy/_render_api.py +1 -1
  13. {dazpy-2.6.0 → dazpy-2.7.0}/dazpy/_scene.py +358 -0
  14. dazpy-2.7.0/dazpy/_scene_events.py +151 -0
  15. dazpy-2.7.0/dazpy/_scene_state.py +239 -0
  16. dazpy-2.7.0/dazpy/_viewport.py +352 -0
  17. {dazpy-2.6.0 → dazpy-2.7.0}/dazpy/exceptions.py +12 -1
  18. {dazpy-2.6.0 → dazpy-2.7.0}/dazpy.egg-info/PKG-INFO +1 -1
  19. {dazpy-2.6.0 → dazpy-2.7.0}/dazpy.egg-info/SOURCES.txt +4 -0
  20. {dazpy-2.6.0 → dazpy-2.7.0}/pyproject.toml +1 -1
  21. {dazpy-2.6.0 → dazpy-2.7.0}/tests/test_dazpy.py +993 -4
  22. {dazpy-2.6.0 → dazpy-2.7.0}/tests/test_dazpy_integration.py +218 -0
  23. dazpy-2.7.0/tests/test_dsp_runner.py +137 -0
  24. dazpy-2.6.0/dazpy/_render.py +0 -185
  25. dazpy-2.6.0/dazpy/_viewport.py +0 -190
  26. {dazpy-2.6.0 → dazpy-2.7.0}/README.md +0 -0
  27. {dazpy-2.6.0 → dazpy-2.7.0}/dazpy/_animation.py +0 -0
  28. {dazpy-2.6.0 → dazpy-2.7.0}/dazpy/_batch.py +0 -0
  29. {dazpy-2.6.0 → dazpy-2.7.0}/dazpy/_bone.py +0 -0
  30. {dazpy-2.6.0 → dazpy-2.7.0}/dazpy/_geometry.py +0 -0
  31. {dazpy-2.6.0 → dazpy-2.7.0}/dazpy/_light.py +0 -0
  32. {dazpy-2.6.0 → dazpy-2.7.0}/dazpy/_material.py +0 -0
  33. {dazpy-2.6.0 → dazpy-2.7.0}/dazpy/_modifier.py +0 -0
  34. {dazpy-2.6.0 → dazpy-2.7.0}/dazpy/_morph.py +0 -0
  35. {dazpy-2.6.0 → dazpy-2.7.0}/dazpy/_polling.py +0 -0
  36. {dazpy-2.6.0 → dazpy-2.7.0}/dazpy/_result.py +0 -0
  37. {dazpy-2.6.0 → dazpy-2.7.0}/dazpy/_script_builder.py +0 -0
  38. {dazpy-2.6.0 → dazpy-2.7.0}/dazpy/_skeleton.py +0 -0
  39. {dazpy-2.6.0 → dazpy-2.7.0}/dazpy/_timeline.py +0 -0
  40. {dazpy-2.6.0 → dazpy-2.7.0}/dazpy/_undo.py +0 -0
  41. {dazpy-2.6.0 → dazpy-2.7.0}/dazpy/math3.py +0 -0
  42. {dazpy-2.6.0 → dazpy-2.7.0}/dazpy/py.typed +0 -0
  43. {dazpy-2.6.0 → dazpy-2.7.0}/dazpy.egg-info/dependency_links.txt +0 -0
  44. {dazpy-2.6.0 → dazpy-2.7.0}/dazpy.egg-info/requires.txt +0 -0
  45. {dazpy-2.6.0 → dazpy-2.7.0}/dazpy.egg-info/top_level.txt +0 -0
  46. {dazpy-2.6.0 → dazpy-2.7.0}/setup.cfg +0 -0
  47. {dazpy-2.6.0 → dazpy-2.7.0}/tests/test_api.py +0 -0
  48. {dazpy-2.6.0 → dazpy-2.7.0}/tests/test_comfyui_client.py +0 -0
  49. {dazpy-2.6.0 → dazpy-2.7.0}/tests/test_comfyui_enhance_integration.py +0 -0
  50. {dazpy-2.6.0 → dazpy-2.7.0}/tests/test_file_watcher.py +0 -0
  51. {dazpy-2.6.0 → dazpy-2.7.0}/tests/test_performance.py +0 -0
  52. {dazpy-2.6.0 → dazpy-2.7.0}/tests/test_workflow_builder.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dazpy
3
- Version: 2.6.0
3
+ Version: 2.7.0
4
4
  Summary: Python SDK for the DAZ Studio Script Server
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/bluemoonfoundry/daz-script-server
@@ -13,7 +13,7 @@ Typical usage::
13
13
  figure.find_bone("r_forearm").set_local_rotation(0, 0, 45)
14
14
  """
15
15
 
16
- __version__ = "2.6.0"
16
+ __version__ = "2.7.0"
17
17
 
18
18
  from ._client import DazClient
19
19
  from ._scene import DazScene
@@ -25,8 +25,9 @@ from ._light import DazLight
25
25
  from ._material import DazMaterial
26
26
  from ._modifier import DazModifier
27
27
  from ._morph import DazMorph
28
+ from ._dforce import DazDForce
28
29
  from ._geometry import DazGeometry
29
- from ._render import DazRenderSettings
30
+ from ._render import DazRenderSettings, Canvas, RenderOutcome
30
31
  from ._viewport import DazViewport
31
32
  from ._timeline import DazTimeline
32
33
  from ._property import DazProperty
@@ -57,6 +58,9 @@ from ._interaction import (
57
58
  build_rig_profile,
58
59
  build_rig_profiles_from_snapshot,
59
60
  build_fight_recipe,
61
+ build_face_each_other_recipe,
62
+ build_handshake_recipe,
63
+ build_hug_recipe,
60
64
  build_kiss_recipe,
61
65
  build_sit_recipe,
62
66
  build_touch_recipe,
@@ -70,6 +74,7 @@ from ._interaction import (
70
74
  )
71
75
  from ._undo import UndoGroup
72
76
  from ._pose import DazPose
77
+ from ._scene_state import DazSceneState
73
78
  from ._animation import DazAnimation
74
79
  from .math3 import Vec3, Quat, BoundingBox
75
80
  from ._result import ExecutionResult
@@ -83,6 +88,7 @@ from ._render_api import (
83
88
  render_variants,
84
89
  )
85
90
  from .exceptions import RenderError
91
+ from ._scene_events import SceneEvent, watch_scene_events, wait_for_scene_event
86
92
  from . import exceptions
87
93
 
88
94
  __all__ = [
@@ -97,8 +103,11 @@ __all__ = [
97
103
  "DazMaterial",
98
104
  "DazModifier",
99
105
  "DazMorph",
106
+ "DazDForce",
100
107
  "DazGeometry",
101
108
  "DazRenderSettings",
109
+ "Canvas",
110
+ "RenderOutcome",
102
111
  "DazViewport",
103
112
  "DazTimeline",
104
113
  "DazProperty",
@@ -129,6 +138,9 @@ __all__ = [
129
138
  "build_rig_profile",
130
139
  "build_rig_profiles_from_snapshot",
131
140
  "build_fight_recipe",
141
+ "build_face_each_other_recipe",
142
+ "build_handshake_recipe",
143
+ "build_hug_recipe",
132
144
  "build_kiss_recipe",
133
145
  "build_sit_recipe",
134
146
  "build_touch_recipe",
@@ -141,6 +153,7 @@ __all__ = [
141
153
  "resolve_interaction_target",
142
154
  "UndoGroup",
143
155
  "DazPose",
156
+ "DazSceneState",
144
157
  "DazAnimation",
145
158
  "Vec3",
146
159
  "Quat",
@@ -154,5 +167,8 @@ __all__ = [
154
167
  "RenderError",
155
168
  "render",
156
169
  "render_variants",
170
+ "SceneEvent",
171
+ "watch_scene_events",
172
+ "wait_for_scene_event",
157
173
  "exceptions",
158
174
  ]
@@ -45,6 +45,33 @@ class DazCamera(DazNode):
45
45
  def depth_of_field(self, value: bool) -> None:
46
46
  self.set_property("Depth of Field", value)
47
47
 
48
+ @property
49
+ def lens_shift_x(self) -> float | None:
50
+ """Horizontal lens shift in millimetres (read/write).
51
+
52
+ Confirmed against a live instance: backed by ``DzCamera``'s
53
+ "Lens Shift X (mm)" property (``getLensShiftXControl()``), the same
54
+ value the render engine uses -- needed to match depth-based effects
55
+ (fog, DOF-driven line weight) to the actual render.
56
+ """
57
+ return self.get_property("Lens Shift X (mm)")
58
+
59
+ @lens_shift_x.setter
60
+ def lens_shift_x(self, value: float) -> None:
61
+ self.set_property("Lens Shift X (mm)", float(value))
62
+
63
+ @property
64
+ def lens_shift_y(self) -> float | None:
65
+ """Vertical lens shift in millimetres (read/write).
66
+
67
+ See :attr:`lens_shift_x`.
68
+ """
69
+ return self.get_property("Lens Shift Y (mm)")
70
+
71
+ @lens_shift_y.setter
72
+ def lens_shift_y(self, value: float) -> None:
73
+ self.set_property("Lens Shift Y (mm)", float(value))
74
+
48
75
  @property
49
76
  def frame_width(self) -> float | None:
50
77
  """Sensor / film-gate width in millimetres (read-only)."""
@@ -37,11 +37,12 @@ def _map_response(resp: _requests.Response, script: str = "") -> ExecutionResult
37
37
 
38
38
  if not data.get("success", True):
39
39
  error_msg = data.get("error", "Script failed")
40
+ output = data.get("output", [])
40
41
  # SyntaxError comes from the parser; runtime errors (TypeError, ReferenceError,
41
42
  # Error, etc.) also include "Line N:" but never say "SyntaxError" explicitly.
42
43
  if "SyntaxError" in error_msg:
43
- raise ScriptSyntaxError(error_msg, script=script, request_id=request_id)
44
- raise ScriptRuntimeError(error_msg, script=script, request_id=request_id)
44
+ raise ScriptSyntaxError(error_msg, script=script, request_id=request_id, output=output)
45
+ raise ScriptRuntimeError(error_msg, script=script, request_id=request_id, output=output)
45
46
 
46
47
  return ExecutionResult(
47
48
  value=data.get("result"),
@@ -228,6 +229,25 @@ class DazClient:
228
229
  return {"status": "not_found"}
229
230
  return resp.json()
230
231
 
232
+ def list_requests(self, status: str | None = None) -> dict:
233
+ """List all tracked async requests (script and render) with their status.
234
+
235
+ Args:
236
+ status: Optional filter, one of ``"queued"``, ``"running"``,
237
+ ``"completed"``, ``"failed"``, ``"cancelled"``. Omit to list
238
+ requests in every status.
239
+
240
+ Returns:
241
+ A dict with a ``"requests"`` list (each item has ``request_id``,
242
+ ``status``, ``progress``, ``submitted_at``) plus ``total`` and a
243
+ per-status count for every status value.
244
+ """
245
+ params = {"status": status} if status else None
246
+ resp = self._get("/requests", params=params)
247
+ if resp.status_code in (401, 403):
248
+ raise AuthenticationError(f"HTTP {resp.status_code}: {resp.text[:200]}")
249
+ return resp.json()
250
+
231
251
  def cancel_request(self, request_id: str) -> bool:
232
252
  """Cancel a queued or running async request.
233
253
 
@@ -273,7 +293,7 @@ class DazClient:
273
293
  width: Image width in pixels (must be paired with *height*).
274
294
  height: Image height in pixels (must be paired with *width*).
275
295
  camera: Camera label to render from.
276
- engine: Render engine (``"iray"``, ``"3delight"``, ``"filament"``).
296
+ engine: Render engine (``"iray"``, ``"filament"``).
277
297
  iray_samples: iRay sample count (0 = use scene default).
278
298
  reset_morphs: If ``True``, reset all morphs to defaults before applying.
279
299
 
@@ -331,6 +351,63 @@ class DazClient:
331
351
  raise AuthenticationError(f"HTTP {resp.status_code}: {resp.text[:200]}")
332
352
  return resp.json()
333
353
 
354
+ def render_animation_submit(
355
+ self,
356
+ output_path: str,
357
+ start_frame: int,
358
+ end_frame: int,
359
+ *,
360
+ frame_padding: int = 4,
361
+ width: int = 0,
362
+ height: int = 0,
363
+ camera: str = "",
364
+ engine: str = "",
365
+ ) -> dict:
366
+ """Submit an animation render job spanning a frame range and return immediately.
367
+
368
+ Renders each frame in ``[start_frame, end_frame]`` to a separate file,
369
+ as a single trackable async request (mirrors :meth:`render_submit`'s
370
+ request-tracking shape, not :meth:`render_batch_submit`'s fan-out).
371
+
372
+ Args:
373
+ output_path: Output path pattern containing the literal token
374
+ ``"{frame}"``, e.g. ``r"C:\\tmp\\anim\\frame_{frame}.png"``.
375
+ The token is replaced with the frame number, zero-padded to
376
+ *frame_padding* digits.
377
+ start_frame: First frame to render (inclusive).
378
+ end_frame: Last frame to render (inclusive).
379
+ frame_padding: Zero-padding width for the frame number (default ``4``).
380
+ width: Image width in pixels (must be paired with *height*).
381
+ height: Image height in pixels (must be paired with *width*).
382
+ camera: Camera label to render from.
383
+ engine: Render engine (``"iray"``, ``"filament"``).
384
+
385
+ Returns:
386
+ A dict with ``request_id``, ``status`` (``"queued"``), and
387
+ ``submitted_at`` keys.
388
+
389
+ Raises:
390
+ ConnectionError: If the server cannot be reached.
391
+ AuthenticationError: On HTTP 401/403.
392
+ """
393
+ payload: dict = {
394
+ "output_path": output_path,
395
+ "start_frame": start_frame,
396
+ "end_frame": end_frame,
397
+ "frame_padding": frame_padding,
398
+ }
399
+ if width and height:
400
+ payload["width"] = width
401
+ payload["height"] = height
402
+ if camera:
403
+ payload["camera"] = camera
404
+ if engine:
405
+ payload["engine"] = engine
406
+ resp = self._post("/render/animation", payload)
407
+ if resp.status_code in (401, 403):
408
+ raise AuthenticationError(f"HTTP {resp.status_code}: {resp.text[:200]}")
409
+ return resp.json()
410
+
334
411
  def cancel_render(self, request_id: str) -> bool:
335
412
  """Cancel a queued or running render job.
336
413
 
@@ -369,6 +446,40 @@ class DazClient:
369
446
  except _requests.exceptions.RequestException:
370
447
  return None
371
448
 
449
+ def stream_scene_events(
450
+ self,
451
+ categories: "list[str] | None" = None,
452
+ stream_timeout: "float | None" = None,
453
+ ) -> "object | None":
454
+ """Open the SSE stream for general scene-change events (GET /scene/events).
455
+
456
+ Args:
457
+ categories: Optional subset of event categories to subscribe to
458
+ (``"node"``, ``"skeleton"``, ``"light"``, ``"camera"``,
459
+ ``"selection"``, ``"scene"``, ``"time"``, ``"render"``).
460
+ ``None`` (default) subscribes to all categories.
461
+ stream_timeout: Socket timeout in seconds. ``None`` (default)
462
+ waits indefinitely — the server sends a keepalive comment
463
+ every 15 seconds, so the connection never idles out.
464
+
465
+ Returns:
466
+ A streaming :class:`requests.Response` on success, or ``None``
467
+ if the endpoint is unavailable. Callers must close the response
468
+ when done (e.g. via a ``with`` statement).
469
+ """
470
+ try:
471
+ params = {"filter": ",".join(categories)} if categories else None
472
+ resp = _requests.get(
473
+ f"{self._base}/scene/events",
474
+ headers=self._headers,
475
+ params=params,
476
+ stream=True,
477
+ timeout=stream_timeout,
478
+ )
479
+ return resp if resp.status_code == 200 else None
480
+ except _requests.exceptions.RequestException:
481
+ return None
482
+
372
483
  # ── USD export ────────────────────────────────────────────────────────────
373
484
 
374
485
  def export_usd_submit(
@@ -440,13 +551,34 @@ class DazClient:
440
551
  # ── Server health ─────────────────────────────────────────────────────────
441
552
 
442
553
  def status(self) -> dict:
443
- """Return the server status dict from ``GET /status``."""
444
- return self._get("/status").json()
554
+ """Return the server status dict from ``GET /status``.
555
+
556
+ Raises:
557
+ AuthenticationError: On HTTP 401/403.
558
+ """
559
+ resp = self._get("/status")
560
+ if resp.status_code in (401, 403):
561
+ raise AuthenticationError(f"HTTP {resp.status_code}: {resp.text[:200]}")
562
+ return resp.json()
445
563
 
446
564
  def health(self) -> dict:
447
- """Return the health check dict from ``GET /health``."""
448
- return self._get("/health").json()
565
+ """Return the health check dict from ``GET /health``.
566
+
567
+ Raises:
568
+ AuthenticationError: On HTTP 401/403.
569
+ """
570
+ resp = self._get("/health")
571
+ if resp.status_code in (401, 403):
572
+ raise AuthenticationError(f"HTTP {resp.status_code}: {resp.text[:200]}")
573
+ return resp.json()
449
574
 
450
575
  def metrics(self) -> dict:
451
- """Return the metrics dict from ``GET /metrics``."""
452
- return self._get("/metrics").json()
576
+ """Return the metrics dict from ``GET /metrics``.
577
+
578
+ Raises:
579
+ AuthenticationError: On HTTP 401/403.
580
+ """
581
+ resp = self._get("/metrics")
582
+ if resp.status_code in (401, 403):
583
+ raise AuthenticationError(f"HTTP {resp.status_code}: {resp.text[:200]}")
584
+ return resp.json()
@@ -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
@@ -78,6 +78,40 @@ class DazElement:
78
78
  """)
79
79
  return self._client.execute(script).value or []
80
80
 
81
+ def numeric_properties(self) -> dict[str, object]:
82
+ """Return every numeric property on this element as ``{label: value}``.
83
+
84
+ Unlike :meth:`list_properties`, this fetches labels and current
85
+ values for all numeric (float/int/bool) properties in a single HTTP
86
+ round-trip — use it instead of calling :meth:`get_property` in a
87
+ loop over ``list_properties()`` results.
88
+
89
+ Returns:
90
+ A dict mapping each numeric property's display label to its
91
+ current value.
92
+ """
93
+ script = ScriptBuilder.iife(f"""
94
+ var obj = {self._locator};
95
+ if (!obj) return null;
96
+ var result = {{}};
97
+ for (var i = 0; i < obj.getNumProperties(); i++) {{
98
+ var p = obj.getProperty(i);
99
+ if (p.inherits("DzNumericProperty")) {{
100
+ result[p.getLabel()] = p.getValue();
101
+ }}
102
+ }}
103
+ return result;
104
+ """)
105
+ return self._client.execute(script).value or {}
106
+
107
+ @property
108
+ def class_name(self) -> str | None:
109
+ """The DazScript class name of this element (e.g. ``"DzFigure"``, ``"DzSpotLight"``)."""
110
+ script = ScriptBuilder.iife(
111
+ f"var obj = {self._locator}; return obj ? obj.className() : null;"
112
+ )
113
+ return self._client.execute(script).value
114
+
81
115
  def snapshot(self, fields: list[str]) -> dict:
82
116
  """Read and cache a set of property values in a single call.
83
117
 
@@ -27,7 +27,7 @@ AnchorRole = Literal[
27
27
  "custom",
28
28
  ]
29
29
  InteractionKind = Literal["PoseTarget", "ContactTarget", "LookAtTarget", "BalanceTarget", "HandTarget", "FootTarget"]
30
- RecipeKind = Literal["sit", "touch", "kiss", "fight", "custom"]
30
+ RecipeKind = Literal["sit", "touch", "kiss", "fight", "handshake", "hug", "face_each_other", "custom"]
31
31
 
32
32
 
33
33
  def _normalize_name(value: str | None) -> str:
@@ -1815,6 +1815,66 @@ def build_kiss_recipe(
1815
1815
  )
1816
1816
 
1817
1817
 
1818
+ def build_handshake_recipe(
1819
+ actor_a: str,
1820
+ actor_b: str,
1821
+ *,
1822
+ a_anchor: str = "r_hand",
1823
+ b_anchor: str = "r_hand",
1824
+ ) -> InteractionRecipe:
1825
+ """Build a mirrored hand-to-hand greeting preset for two figures."""
1826
+
1827
+ return InteractionRecipe(
1828
+ kind="handshake",
1829
+ actors=[actor_a, actor_b],
1830
+ constraints=[
1831
+ LookAtTarget(actor_a, "head", (0.0, 0.0, 0.0)),
1832
+ LookAtTarget(actor_b, "head", (0.0, 0.0, 0.0)),
1833
+ HandTarget(actor_a, a_anchor, target_figure=actor_b, target_anchor=b_anchor),
1834
+ HandTarget(actor_b, b_anchor, target_figure=actor_a, target_anchor=a_anchor),
1835
+ ],
1836
+ metadata={"interaction": "handshake"},
1837
+ )
1838
+
1839
+
1840
+ def build_hug_recipe(
1841
+ actor_a: str,
1842
+ actor_b: str,
1843
+ *,
1844
+ a_anchor: str = "r_hand",
1845
+ b_anchor: str = "r_hand",
1846
+ ) -> InteractionRecipe:
1847
+ """Build a mutual arms-around-each-other embrace preset for two figures."""
1848
+
1849
+ a_far_shoulder = "l_shoulder" if _side_from_name(a_anchor) == "right" else "r_shoulder"
1850
+ b_far_shoulder = "l_shoulder" if _side_from_name(b_anchor) == "right" else "r_shoulder"
1851
+ return InteractionRecipe(
1852
+ kind="hug",
1853
+ actors=[actor_a, actor_b],
1854
+ constraints=[
1855
+ LookAtTarget(actor_a, "head", (0.0, 0.0, 0.0)),
1856
+ LookAtTarget(actor_b, "head", (0.0, 0.0, 0.0)),
1857
+ HandTarget(actor_a, a_anchor, target_figure=actor_b, target_anchor=a_far_shoulder),
1858
+ HandTarget(actor_b, b_anchor, target_figure=actor_a, target_anchor=b_far_shoulder),
1859
+ ],
1860
+ metadata={"interaction": "hug"},
1861
+ )
1862
+
1863
+
1864
+ def build_face_each_other_recipe(actor_a: str, actor_b: str) -> InteractionRecipe:
1865
+ """Build a mutual eye-contact preset for two figures, with no limb contact."""
1866
+
1867
+ return InteractionRecipe(
1868
+ kind="face_each_other",
1869
+ actors=[actor_a, actor_b],
1870
+ constraints=[
1871
+ LookAtTarget(actor_a, "head", (0.0, 0.0, 0.0)),
1872
+ LookAtTarget(actor_b, "head", (0.0, 0.0, 0.0)),
1873
+ ],
1874
+ metadata={"interaction": "face_each_other"},
1875
+ )
1876
+
1877
+
1818
1878
  def build_fight_recipe(
1819
1879
  attacker: str,
1820
1880
  defender: str,