cozyclay 3.1.6 → 3.2.1

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 (224) hide show
  1. package/CHANGELOG.md +145 -0
  2. package/INSTALL_FOR_AGENTS.md +4 -4
  3. package/README.md +4 -2
  4. package/bin/agent/agent-failure-codes.mjs +34 -8
  5. package/bin/agent/agent-routes.mjs +6 -6
  6. package/bin/agent/agent-runner.mjs +25 -17
  7. package/bin/agent/agent-stream.mjs +26 -3
  8. package/bin/agent/main-prompt.mjs +1 -1
  9. package/bin/agent/openai-compatible-provider.mjs +24 -1
  10. package/bin/agent/studio-prompt.mjs +2 -2
  11. package/bin/agent/supervisor-followup.mjs +34 -0
  12. package/bin/agent/supervisor.mjs +6 -6
  13. package/bin/cozyclay.mjs +23 -60
  14. package/bin/star-prompt.mjs +15 -14
  15. package/bin/studio-routes.mjs +6 -3
  16. package/bin/telemetry-state.mjs +5 -7
  17. package/bin/update-check.mjs +61 -7
  18. package/bin/update-install.mjs +16 -3
  19. package/bin/update-restart.mjs +95 -0
  20. package/bin/update-routes.mjs +25 -3
  21. package/dist/app/index.html +13 -7
  22. package/dist/assets/TaskBoard-tgO0MNKL.js +1 -0
  23. package/dist/assets/analytics-CaTUuLPM.js +1 -0
  24. package/dist/assets/{app-CEP7jzXB.css → app-CnDkB5WO.css} +1 -1
  25. package/dist/assets/app-DJt06Oyb.js +5144 -0
  26. package/dist/assets/compile-worker-D1pl6G7x.js +6 -0
  27. package/dist/assets/landing-2Fy7DU41.js +1 -0
  28. package/dist/assets/modulepreload-polyfill-P2Xu9kJm.js +1 -0
  29. package/dist/assets/motion3-compile-BxaM0Xwl.js +1 -0
  30. package/dist/assets/scene-asset-sync-CLGNbEBq.js +13 -0
  31. package/dist/assets/task-board-UVGTZLRF.js +15 -0
  32. package/dist/assets/workflow-CSiBlX1s.js +179 -0
  33. package/dist/cozyclay-package.json +1 -1
  34. package/dist/index.html +4 -1
  35. package/dist/privacy/index.html +9 -6
  36. package/dist/sitemap.xml +2 -2
  37. package/dist/workflow/index.html +5 -4
  38. package/mcp/README.md +1 -1
  39. package/mcp/live-hub.mjs +4 -4
  40. package/mcp/tool-handlers.mjs +14 -16
  41. package/package.json +3 -1
  42. package/src/App.jsx +42 -75
  43. package/src/analytics-controls.js +25 -3
  44. package/src/analytics-strict.js +16 -4
  45. package/src/analytics.js +32 -26
  46. package/src/app-version.js +13 -0
  47. package/src/asset-drop.js +39 -0
  48. package/src/asset-pane.css +34 -0
  49. package/src/asset-pane.jsx +4 -35
  50. package/src/bus-tasks.js +1 -1
  51. package/src/character-kind.js +8 -6
  52. package/src/click-friction.js +5 -4
  53. package/src/command-bus-telemetry.js +8 -7
  54. package/src/command-bus.js +2 -16
  55. package/src/commands/cast.js +2 -1
  56. package/src/commands/index.js +2 -1
  57. package/src/commands/motion3.js +3 -1
  58. package/src/commands/objects.js +2 -37
  59. package/src/commands/physics.js +21 -0
  60. package/src/commands/project.js +3 -3
  61. package/src/commands/sculpt-compare.js +53 -0
  62. package/src/content-tile.jsx +36 -0
  63. package/src/domains/cast.js +7 -6
  64. package/src/domains/motion.js +13 -14
  65. package/src/domains/scenes.js +41 -31
  66. package/src/execution-telemetry.js +7 -3
  67. package/src/export-menu-telemetry.js +26 -0
  68. package/src/finger-handles.jsx +128 -0
  69. package/src/friction-hint.js +82 -0
  70. package/src/live-control.js +2 -1
  71. package/src/locale-zh/panels.js +20 -0
  72. package/src/locale-zh/shell.js +4 -0
  73. package/src/locale-zh/workspace.js +6 -0
  74. package/src/motion/center-of-mass.js +5 -23
  75. package/src/motion/contact-project.js +143 -0
  76. package/src/motion/find-bone.js +13 -0
  77. package/src/motion/fingers.js +170 -0
  78. package/src/motion/fingertip-pick.js +21 -0
  79. package/src/motion/fix-collisions.js +1 -1
  80. package/src/motion/ik-drag.js +93 -1
  81. package/src/motion/ik-exposure.js +48 -0
  82. package/src/motion/ik.js +4 -11
  83. package/src/motion/timeline.css +12 -0
  84. package/src/motion/timeline.jsx +2 -2
  85. package/src/motion3/compile-job.js +3 -2
  86. package/src/motion3/compiler/compile.js +3 -3
  87. package/src/motion3/compiler/head-lead.js +4 -2
  88. package/src/motion3/compiler/spring.js +95 -0
  89. package/src/motion3/compiler/validate-objects.js +18 -2
  90. package/src/motion3/compiler/validate-parts.js +1 -0
  91. package/src/motion3/compiler/vocab.js +1 -1
  92. package/src/motion3/fix/index.js +8 -4
  93. package/src/motion3/fix/person.js +110 -0
  94. package/src/motion3/free-motion.js +49 -11
  95. package/src/motion3/hybrid-hint.js +19 -0
  96. package/src/motion3/load.js +19 -12
  97. package/src/motion3/object-pose.js +44 -2
  98. package/src/motion3/people.js +16 -1
  99. package/src/motion3/physics-frames.js +72 -0
  100. package/src/motion3/physics-trail.js +45 -0
  101. package/src/motion3/physics-view.js +79 -0
  102. package/src/motion3/pipeline.js +5 -1
  103. package/src/motion3/qa/ballistic.js +67 -0
  104. package/src/motion3/qa/com.js +140 -0
  105. package/src/motion3/qa/ground.js +10 -3
  106. package/src/motion3/qa/hints-contact.js +21 -7
  107. package/src/motion3/qa/hints-motion.js +38 -1
  108. package/src/motion3/qa/key-checks.js +5 -4
  109. package/src/motion3/qa/patch.js +32 -4
  110. package/src/motion3/qa/people-checks.js +1 -1
  111. package/src/motion3/qa/spring-pivot.js +23 -0
  112. package/src/motion3/qa/style.js +13 -30
  113. package/src/motion3/qa/verdict.js +7 -2
  114. package/src/motion3/repair.js +28 -8
  115. package/src/motion3/schema/fields.js +4 -2
  116. package/src/motion3/schema/topics.js +15 -11
  117. package/src/motion3/schema/types.js +13 -9
  118. package/src/motion3/skills/catch.js +2 -1
  119. package/src/panels/FingerPanel.jsx +38 -0
  120. package/src/panels/RigControlPanel.jsx +9 -1
  121. package/src/panels/SculptPanel.jsx +12 -12
  122. package/src/panels/SculptPartTree.jsx +46 -0
  123. package/src/panels/SubjectsPanel.jsx +1 -1
  124. package/src/panels/sculpt.css +11 -0
  125. package/src/physics-overlay-settings.js +41 -0
  126. package/src/physics-overlay.js +152 -0
  127. package/src/physics-overlay.jsx +54 -0
  128. package/src/physics-toggle.jsx +20 -0
  129. package/src/pose-fade.js +7 -3
  130. package/src/posestudio.jsx +15 -28
  131. package/src/project-browser.css +6 -0
  132. package/src/project-browser.jsx +9 -18
  133. package/src/project-start-choices.jsx +45 -0
  134. package/src/project.js +16 -12
  135. package/src/proxy-figure-shape.js +11 -0
  136. package/src/proxy-figure.jsx +25 -18
  137. package/src/scene-assets.js +17 -13
  138. package/src/scenes.js +22 -11
  139. package/src/sculpt-blueprint.js +56 -0
  140. package/src/sculpt-mode.js +5 -0
  141. package/src/sculpt-part-edit.js +115 -0
  142. package/src/sculpt-part-gizmo.jsx +159 -0
  143. package/src/sculpt-part-outline.jsx +72 -0
  144. package/src/sculpt-pick.js +53 -0
  145. package/src/sculpt-run-text.js +51 -0
  146. package/src/sculpt-run.js +10 -46
  147. package/src/sculpt-shelf-file.js +92 -0
  148. package/src/sculpt-shelf-tiles.jsx +69 -0
  149. package/src/sculpt-shelf.js +108 -0
  150. package/src/sculpt-stage.jsx +39 -2
  151. package/src/sculpt-thumb.js +72 -0
  152. package/src/sculpt-views.js +75 -0
  153. package/src/semantic-edit.js +0 -87
  154. package/src/settings-menu.jsx +2 -2
  155. package/src/shell/DetailsSlot.jsx +5 -6
  156. package/src/shell/LibrarySlot.jsx +8 -10
  157. package/src/shell/MenuBar.jsx +6 -6
  158. package/src/shell/PreferencesDialog.jsx +2 -3
  159. package/src/shell/ViewportToolbar.jsx +15 -18
  160. package/src/shell/add-character-entries.js +13 -0
  161. package/src/shell/pose-controls.js +32 -0
  162. package/src/shell/preference-pages.jsx +2 -11
  163. package/src/shell/storyboard-caption.js +21 -0
  164. package/src/shell/studio-shell-context.js +1 -0
  165. package/src/shortcut-friction.js +2 -1
  166. package/src/site/landing-analytics.js +28 -0
  167. package/src/studio-actions.js +3 -2
  168. package/src/studio-agent-protocol.js +4 -4
  169. package/src/studio-app-binding.js +4 -3
  170. package/src/update/UpdateChip.jsx +21 -2
  171. package/src/update/UpdateDialog.jsx +26 -6
  172. package/src/update/update-client.js +123 -17
  173. package/src/update/update-telemetry.js +15 -3
  174. package/src/use-sculpt-mode.js +62 -8
  175. package/src/workflow/agent-failure-codes.js +28 -9
  176. package/src/workflow/agent-turn-telemetry.js +1 -1
  177. package/src/workflow/task-board.js +2 -2
  178. package/tools/dev/page-shell.py +1 -1
  179. package/tools/dev/pages/greybox-to-video.html +21 -2
  180. package/tools/dev/pages/previs-software.html +3 -3
  181. package/tools/dev/pages/privacy.html +9 -6
  182. package/tools/dev/pages/seedance-camera-control.html +2 -2
  183. package/tools/loc-baseline.json +10 -10
  184. package/tools/run-tests.mjs +53 -84
  185. package/tools/select-tests.mjs +119 -0
  186. package/tools/test-durations.json +455 -0
  187. package/tools/test-graph.mjs +181 -0
  188. package/tools/test-shards.mjs +78 -0
  189. package/tools/tutorial/README.md +74 -0
  190. package/tools/tutorial/assemble.mjs +43 -0
  191. package/tools/tutorial/captions.mjs +43 -0
  192. package/tools/tutorial/cdp.mjs +55 -0
  193. package/tools/tutorial/cursor.mjs +159 -0
  194. package/tools/tutorial/episode.mjs +173 -0
  195. package/tools/tutorial/episodes/beginner-guide/00-install.mjs +42 -0
  196. package/tools/tutorial/episodes/beginner-guide/01-start.mjs +50 -0
  197. package/tools/tutorial/episodes/beginner-guide/02-layout.mjs +67 -0
  198. package/tools/tutorial/episodes/beginner-guide/03-stage.mjs +58 -0
  199. package/tools/tutorial/episodes/beginner-guide/04-motion.mjs +98 -0
  200. package/tools/tutorial/episodes/beginner-guide/05-camera.mjs +47 -0
  201. package/tools/tutorial/episodes/beginner-guide/06-export.mjs +59 -0
  202. package/tools/tutorial/episodes/beginner-guide/steps.mjs +32 -0
  203. package/tools/tutorial/episodes/beginner-guide.mjs +17 -0
  204. package/tools/tutorial/episodes/cast.mjs +12 -0
  205. package/tools/tutorial/episodes/first-video.mjs +121 -0
  206. package/tools/tutorial/episodes/stub-tour.mjs +97 -0
  207. package/tools/tutorial/launch.mjs +63 -0
  208. package/tools/tutorial/mux.mjs +61 -0
  209. package/tools/tutorial/overlay.mjs +142 -0
  210. package/tools/tutorial/record.mjs +156 -0
  211. package/tools/tutorial/scenes/terminal.html +63 -0
  212. package/tools/tutorial/steps.mjs +111 -0
  213. package/tools/tutorial/terminal.mjs +43 -0
  214. package/tools/tutorial/voice.mjs +49 -0
  215. package/tools/update-test-durations.mjs +42 -0
  216. package/tools/verify-package-tarball.mjs +18 -12
  217. package/dist/assets/TaskBoard-DwXhsJXz.js +0 -1
  218. package/dist/assets/app-BLS1Zaru.js +0 -5144
  219. package/dist/assets/compile-worker-or7djI2v.js +0 -6
  220. package/dist/assets/motion3-compile-Du7UXrWn.js +0 -1
  221. package/dist/assets/rolldown-runtime-hePW80VL.js +0 -1
  222. package/dist/assets/scene-asset-sync-t45pWCyq.js +0 -13
  223. package/dist/assets/task-board-CkLZGPkv.js +0 -15
  224. package/dist/assets/workflow-DJwOUzqJ.js +0 -179
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The fingertip a press grabs. Candidates are `{ id, gap, reach, ... }`: the
3
+ * press's pixel distance to the tip's projected centre, and how far the tip
4
+ * reaches on screen. In range tips are tried nearest first, and the first one
5
+ * `isExposed(candidate)` accepts wins, so a tip the body hides (behind the palm
6
+ * or other fingers) is skipped for the next visible one. Null when no exposed
7
+ * tip is in range: the press is left for the IK handles (#913).
8
+ */
9
+ export function pickFingertip(candidates, isExposed) {
10
+ return candidates
11
+ .filter((candidate) => candidate.gap <= candidate.reach)
12
+ .sort((a, b) => a.gap - b.gap)
13
+ .find((candidate) => isExposed(candidate)) ?? null;
14
+ }
15
+
16
+ /** Pick for a pointerdown and claim the event only when a tip is picked; otherwise it passes on untouched. */
17
+ export function claimFingertip(event, candidates, isExposed) {
18
+ const picked = pickFingertip(candidates, isExposed);
19
+ if (picked) { event.stopImmediatePropagation(); event.stopPropagation(); event.preventDefault(); }
20
+ return picked;
21
+ }
@@ -287,7 +287,7 @@ function restPairOverlaps(rig, radii = null) {
287
287
  /** Closest points between two segments (Ericson, Real-Time Collision
288
288
  * Detection — the textbook segment/segment distance). Writes into outA/outB
289
289
  * and returns the squared distance. */
290
- function closestPointsSegmentSegment(p1, q1, p2, q2, outA, outB) {
290
+ export function closestPointsSegmentSegment(p1, q1, p2, q2, outA, outB) {
291
291
  const d1 = q1.clone().sub(p1);
292
292
  const d2 = q2.clone().sub(p2);
293
293
  const r = p1.clone().sub(p2);
@@ -1,4 +1,10 @@
1
- import { createIkState, ikBakeKeyframe, ikTouch } from "./ik.js";
1
+ import * as THREE from "three";
2
+ import { clampIkTargetToFloor, createIkState, ikBakeKeyframe, ikTouch, MID_TRACKS, solveIk, solveMidJoint } from "./ik.js";
3
+ import { buildCollisionCapsules } from "./fix-collisions.js";
4
+ import { blockerCharacterId, characterBlockers } from "./collision-blockers.js";
5
+ import { createContactProjector } from "./contact-project.js";
6
+ import { allowance } from "../motion3/qa/checks.js";
7
+ import { ko } from "../locale.js";
2
8
 
3
9
  /**
4
10
  * Per-drag bookkeeping for manual IK drags over a loaded motion.
@@ -16,6 +22,92 @@ export function ikDragRecord(current, frame) {
16
22
  return current && current.frame === frame ? current : { frame, ids: new Set() };
17
23
  }
18
24
 
25
+ // Told once per drag, the first time the projection moves the handle.
26
+ const contactHint = () => ko("Stopped at the other character's body. Hold Alt while dragging to pass through",
27
+ "다른 인물의 몸에 닿아 멈췄어요. Alt를 누른 채 드래그하면 통과해요", "已停在另一个角色的身体上。按住 Alt 拖动即可穿过");
28
+ // Studio capsule id -> the motion3 QA part an allow entry names (qa/geometry.js CAPSULES).
29
+ const QA_PART = { UpperArm: "uarm", ForeArm: "farm", Hand: "hand", Thigh: "thigh", Shin: "shin", Foot: "foot" };
30
+ const qaPart = (capsuleId) => capsuleId.replace(/^(left|right)(\w+)$/, (_, side, part) => `${QA_PART[part] ?? part}_${side[0]}`);
31
+
32
+ /** The contact side of a drag, built once when it opens: the other cast members'
33
+ * capsules as they stand now (they do not move during a drag), the dragged
34
+ * chain's own capsules (upper arm, forearm, hand / thigh, shin, foot; no
35
+ * fingers) and the active take's person allowances (spec.checks.allow
36
+ * `{ part, object: "person:<id>", depth }`, read as QA reads them). */
37
+ function openDragContact(kind, chain, { rigs, activeId, cast = null, allow = null, notify = null }) {
38
+ const blockers = characterBlockers(rigs, activeId, { characterIds: cast });
39
+ const own = blockers.length ? buildCollisionCapsules(chain.rig) : null;
40
+ if (!own) return null;
41
+ const capsules = [...own.values()].filter((c) => c.def.movable?.chain === chain.track.id && !c.def.handId && !c.def.optional);
42
+ const allowFor = (probeId, blockerId) => allowance(allow, qaPart(probeId), `person:${blockerCharacterId(blockerId)}`);
43
+ const reads = capsules.map((c) => ({ id: c.def.id, start: c.bones[0], end: c.bones[1] ?? c.bones[0], radius: c.radius, a: c.a.clone(), b: c.b.clone() }));
44
+ const contact = {
45
+ projector: createContactProjector({ blockers, allow: allowFor }),
46
+ pose: null, // this event's starting locals, set by clampDragTarget
47
+ // the limb's capsules as the drag will DISPLAY them with its handle at
48
+ // `candidate`: the drag's own solve, from this event's starting pose
49
+ probe: (candidate) => {
50
+ restorePose(chain, contact.pose);
51
+ solveDragTarget(kind, chain, candidate);
52
+ return contact.shown();
53
+ },
54
+ // the limb's capsules as they stand (the pose on screen at event start)
55
+ shown: () => {
56
+ for (const read of reads) { read.start.getWorldPosition(read.a); read.end.getWorldPosition(read.b); }
57
+ return reads;
58
+ },
59
+ handle: chain.bones[kind === "mid" ? 1 : 2],
60
+ lastGood: chain.bones[kind === "mid" ? 1 : 2].getWorldPosition(new THREE.Vector3()),
61
+ notify,
62
+ };
63
+ return contact;
64
+ }
65
+
66
+ function restorePose(chain, pose) {
67
+ chain.bones.forEach((bone, i) => bone.quaternion.copy(pose[i]));
68
+ chain.bones[0].updateMatrixWorld(true);
69
+ }
70
+
71
+ /** The solve a chain or mid drag applies toward its handle (domains/motion.js
72
+ * ikSolve). The contact probe runs this same call from the same starting pose,
73
+ * so the pose it checks is exactly the pose the drag displays. (A probe on a
74
+ * different solve, e.g. exactHinge, lands the elbow and hand elsewhere from a
75
+ * bent pose and passed targets whose displayed limb sat inside the other body.) */
76
+ export function solveDragTarget(kind, chain, target) {
77
+ if (kind === "mid") solveMidJoint(chain, target);
78
+ else solveIk(chain, target);
79
+ }
80
+
81
+ /**
82
+ * Where a chain or mid drag may put its handle: on the floor with Body contact,
83
+ * then out of the other characters (contact-project.js). `open()` returns
84
+ * `{ rigs, activeId, cast, allow }` and is read once, on the first event of the
85
+ * drag (`record` keeps the result). `bypass` (Alt held) skips the contact
86
+ * projection for that event and leaves the side to keep untouched.
87
+ */
88
+ export function clampDragTarget({ kind, trackId, targetWorld, bodyContact, chains, record = null, open = null, bypass = false }) {
89
+ const chain = chains?.get(kind === "mid" ? MID_TRACKS.find((t) => t.id === trackId)?.chain : trackId);
90
+ const heights = chain?.contactHeights ?? chains?.values().next().value?.contactHeights;
91
+ const floored = bodyContact ? clampIkTargetToFloor(trackId, targetWorld, 0, heights) : targetWorld;
92
+ if (!chain || !record || !open || (kind !== "chain" && kind !== "mid")) return floored;
93
+ if (record.contact === undefined) record.contact = openDragContact(kind, chain, open());
94
+ const contact = record.contact;
95
+ if (!contact || bypass) return floored;
96
+ // every probe starts from this event's pose, and the limb goes back to it
97
+ // so the caller's solveDragTarget starts there too
98
+ contact.pose = chain.bones.map((bone) => bone.quaternion.clone());
99
+ // the sweep starts from the limb as displayed, not from a re-solve toward lastGood
100
+ const from = { at: contact.handle.getWorldPosition(new THREE.Vector3()), capsules: contact.shown() };
101
+ const { target, depth } = contact.projector.project(floored, contact.probe, contact.lastGood, from);
102
+ restorePose(chain, contact.pose);
103
+ if (depth === 0) contact.lastGood = target.clone();
104
+ if (!target.equals(floored) && !contact.told) {
105
+ contact.told = true;
106
+ contact.notify?.(contactHint());
107
+ }
108
+ return target;
109
+ }
110
+
19
111
  /** Mark `id` as written by this drag and touch it into the live layer. */
20
112
  export function ikDragTouch(ikState, record, id) {
21
113
  ikTouch(ikState, id);
@@ -0,0 +1,48 @@
1
+ import { Raycaster, Vector3 } from "three";
2
+ import { ikControlIsExposed } from "./ik.js";
3
+
4
+ /**
5
+ * Distance from the camera to the first opaque surface on the way to a control
6
+ * `targetDistance` away along `direction`, or undefined when nothing is in front.
7
+ * Static blockers are raycast; a skinned mesh is read off its cached cloud of
8
+ * skin-surface spheres ({ points, radius }), which keeps the animated silhouette
9
+ * without skinning every triangle once per control ray.
10
+ */
11
+ export function blockerDistanceTo(ray, cameraPos, direction, targetDistance, staticBlockers, skinnedProxies) {
12
+ ray.set(cameraPos, direction);
13
+ ray.near = 0.001;
14
+ ray.far = Math.max(0.001, targetDistance - 0.001);
15
+ let blockerDistance = ray.intersectObjects(staticBlockers, false)[0]?.distance;
16
+ for (const proxy of skinnedProxies) {
17
+ const radiusSq = proxy.radius * proxy.radius;
18
+ for (const point of proxy.points) {
19
+ const dx = point.x - cameraPos.x, dy = point.y - cameraPos.y, dz = point.z - cameraPos.z;
20
+ const along = dx * direction.x + dy * direction.y + dz * direction.z;
21
+ if (along < 0 || along > targetDistance + proxy.radius) continue;
22
+ const perpendicularSq = dx * dx + dy * dy + dz * dz - along * along;
23
+ if (perpendicularSq > radiusSq) continue;
24
+ const distance = Math.max(0, along - Math.sqrt(radiusSq - perpendicularSq));
25
+ if (distance < (blockerDistance ?? Infinity)) blockerDistance = distance;
26
+ }
27
+ }
28
+ return blockerDistance;
29
+ }
30
+
31
+ /**
32
+ * An exposure probe over the blockers of the last IkHandles exposure pass:
33
+ * `(worldPoint, visibilityDepth) => boolean`, the same rule the IK controls use.
34
+ * A fingertip is smaller than the 4 cm skin-proxy spheres (neighbouring fingers
35
+ * read as blockers), so the probe raycasts the skinned meshes themselves; it runs
36
+ * once per press, not per frame. Before any pass nothing is in front: exposed.
37
+ */
38
+ export function exposureProbe(camera, occluders) {
39
+ const ray = new Raycaster(), cameraPos = new Vector3(), direction = new Vector3();
40
+ ray.layers.set(0);
41
+ return (point, visibilityDepth) => {
42
+ camera.getWorldPosition(cameraPos);
43
+ const targetDistance = cameraPos.distanceTo(point);
44
+ direction.copy(point).sub(cameraPos).normalize();
45
+ const blockerDistance = blockerDistanceTo(ray, cameraPos, direction, targetDistance, occluders.current?.blockers ?? [], []);
46
+ return ikControlIsExposed(targetDistance, blockerDistance, visibilityDepth);
47
+ };
48
+ }
package/src/motion/ik.js CHANGED
@@ -1,5 +1,8 @@
1
1
  import * as THREE from "three";
2
2
  import { normalizeBoneName, POSE_BONES, DEFAULT_POSE } from "../poses.js";
3
+ import { resolveFingers } from "./fingers.js";
4
+ import { findBone } from "./find-bone.js";
5
+ export { findBone };
3
6
 
4
7
  /**
5
8
  * Frame-based IK layer for direct character posing, following the DCC
@@ -412,16 +415,6 @@ const CHAINS = {
412
415
  leg: (side) => [`mixamorig${side}UpLeg`, `mixamorig${side}Leg`, `mixamorig${side}Foot`],
413
416
  };
414
417
 
415
- export function findBone(root, name) {
416
- const target = normalizeBoneName(name);
417
- let found = null;
418
- root.traverse((object) => {
419
- if (found || !object.isBone) return;
420
- const norm = normalizeBoneName(object.name);
421
- if (norm === target || norm.endsWith(target)) found = object;
422
- });
423
- return found;
424
- }
425
418
 
426
419
  /**
427
420
  * Resolve the IK rig against a character: the four two-bone chains, plus
@@ -508,7 +501,7 @@ export function resolveIkRig(rig) {
508
501
  })(),
509
502
  });
510
503
  }
511
- return { chains: out, fkJoints, contactRadii, contactHeights: measureContactHeights(rig) };
504
+ return { chains: out, fkJoints, fingers: resolveFingers(rig, fkJoints), contactRadii, contactHeights: measureContactHeights(rig) };
512
505
  }
513
506
 
514
507
  /**
@@ -1427,3 +1427,15 @@
1427
1427
  .v2-sequencer .objmo .sg { position: absolute; inset: 0; }
1428
1428
  .v2-sequencer .objmo .sg-head { padding: 3px 10px 2px; }
1429
1429
  .v2-sequencer .objmo .sg-body { min-height: 36px; margin: 0 0 6px; }
1430
+
1431
+ /* The motion controls' Physics box (physics-toggle.jsx) */
1432
+ .v2-sequencer .tl-btn.physics {
1433
+ display: inline-flex;
1434
+ align-items: center;
1435
+ gap: 4px;
1436
+ cursor: pointer;
1437
+ }
1438
+
1439
+ .v2-sequencer .tl-btn.physics input {
1440
+ margin: 0;
1441
+ }
@@ -10,7 +10,7 @@ import { insertPathMark, pathMarks, pathMetrics, pathProgressAt, MAX_PATH_LEAN }
10
10
  import { NumberField } from "../ui.jsx";
11
11
  import { flatTiming, timingIsFlat, envelopeDrag, insertCut, removeCut, CUT_MIN_GAP } from "../speed-envelope.js";
12
12
  import { checkShotAgainstPreset, presetById } from "../model-presets.js";
13
- import { TakeSegmentClips, TakeSegmentTools } from "./take-segments.jsx"; import { BeatBoard } from "./beat-board.jsx";
13
+ import { TakeSegmentClips, TakeSegmentTools } from "./take-segments.jsx"; import { BeatBoard } from "./beat-board.jsx"; import { PhysicsToggle } from "../physics-toggle.jsx";
14
14
  import "./timeline.css";
15
15
  export { timelineTake } from "./take-segments.jsx";
16
16
 
@@ -1708,7 +1708,7 @@ export default function Timeline({
1708
1708
  </div>
1709
1709
  {onClearMotion && (
1710
1710
  <div className="tl-head-group tl-motion-tools" role="group" aria-label={ko("Motion controls", "모션 컨트롤")}>
1711
- {motion?.segments && motionTools && <TakeSegmentTools motion={motion} frame={frame} />}
1711
+ {motion?.segments && motionTools && <TakeSegmentTools motion={motion} frame={frame} />}{motionTools && <PhysicsToggle />}
1712
1712
  <button
1713
1713
  type="button"
1714
1714
  className="tl-btn clear"
@@ -65,8 +65,9 @@ export function runCompileJob({ model, scene, spec: asked, probes: sentProbes, c
65
65
  // ms.repair: the whole repair's wall time, so a sweep that repairs every variant shows what it costs
66
66
  const r = fixed.result, label = fixed.spec?.label ?? null, ms = fixed.repair ? { ...r.ms, repair: Math.round(performance.now() - t0) } : r.ms;
67
67
  const take = bake ? bakeTake(sk, r.out, { position: bake.position, yaw: bake.yaw }, { spec: r.stored, prompt: ("prompt" in bake ? bake.prompt : label) ?? "" }) : undefined;
68
- // a person anchor's joints ride with the take, so its saved source compiles the same without the others' takes (people.js)
69
- const people = take && keptPeople(scene, [r.expanded, r.stored]);
68
+ // a person anchor's joints (and the bodies fix.person pushed an arm out of) ride with the take, so its saved source
69
+ // compiles the same without the others' takes (people.js)
70
+ const people = take && keptPeople(scene, [r.expanded, r.stored], r.out);
70
71
  if (people) take.people = people;
71
72
  return { stored: r.stored, qa: r.qa, observations: r.observations, used: r.used, fixed: r.fixed, search: r.search, timeline: r.timeline,
72
73
  ...(r.probes ? { probes: r.probes } : {}), ...(r.notes || corrected.length ? { notes: [...corrected, ...(r.notes ?? [])] } : {}), ms, frameCount: r.out.frameCount, label,
@@ -20,7 +20,7 @@
20
20
  // ctx = { sk, scene: { objects: { id: { pos, R, half, ancestors } } } }; out.ctx.scene keeps the scene (object-pose.js)
21
21
  import { add, sub, scale, norm, len, dot, apply, axisRot } from "./math.js";
22
22
  import { isCarried, isFollow, hingePose, movesRecord, compose, relative } from "../carried.js";
23
- import { isFree, freePoseAt } from "../free-motion.js";
23
+ import { isFree, freeSwungAt } from "../free-motion.js";
24
24
  import { groupsOf } from "../scene.js";
25
25
  import { pose, SIDES } from "./pose.js";
26
26
  import { graspAnchor } from "./socket-grip.js";
@@ -46,7 +46,7 @@ const REACH_SLACK = 0.02;
46
46
 
47
47
  /**
48
48
  * Scene boxes by frame. A body moves with its own spec.objects entry or its nearest group's (a
49
- * switch entry moves nothing; hinge: turned; free (C17): where its keys and falls put it, under the time map `mapOf()`;
49
+ * switch entry moves nothing; hinge: turned; free (C17): where its keys and falls put it, under the time map `mapOf()`, swung by its spring;
50
50
  * carry and follow: left at its scene pose and marked `carried` with the moving id). `obstacles` drops the carried
51
51
  * ones: QA, refs and object anchors read a carry where it rides (object-pose.js objectPoseAt, qa/object-checks.js);
52
52
  * a follow entry rides the body and is no obstacle.
@@ -63,7 +63,7 @@ function objectTracks(spec, scene, baked, mapOf = () => undefined) {
63
63
  const mover = [id, ...(o.ancestors ?? [])].find(k => movesRecord(spec.objects?.[k])), entry = spec.objects?.[mover];
64
64
  if (!entry) out[id] = o;
65
65
  else if (isCarried(entry) || isFollow(entry)) out[id] = { ...o, carried: mover };
66
- else if (isFree(entry)) out[id] = { ...o, ...compose(freePoseAt({ spec, scene, timeMap: map }, mover, f), relative(scene.objects[mover] ?? groupsOf(scene)[mover], o)) };
66
+ else if (isFree(entry)) out[id] = { ...o, ...compose(freeSwungAt({ spec, scene, timeMap: map }, mover, f), relative(scene.objects[mover] ?? groupsOf(scene)[mover], o)) };
67
67
  else out[id] = { ...o, ...hingePose(entry, o, baked, f) };
68
68
  }
69
69
  cache.set(f, out);
@@ -5,10 +5,12 @@
5
5
  // step, head-steady.js walkSpans) and around every turn of the trunk faster than TURN deg/frame (from LEAD + FADE
6
6
  // frames before it, so the head starts first), faded in and out over FADE frames, and never where the spec's own keys move
7
7
  // head_yaw during that span (keys on it, or on either side of it, that differ): a keyed look wins; a head held still
8
- // (a stance's head_yaw 0) leads.
8
+ // (a stance's head_yaw 0) leads. A skill's own look that rides a turn (NOT_A_LOOK: exitCar's head as it swivels out of
9
+ // the seat) is not a keyed look: the lead goes on top of it. The agent's keys, and every other skill's, still win.
9
10
  import { walkSpans } from "./head-steady.js";
10
11
 
11
12
  const MAX = 30, TURN = 0.3, FADE = 8, STRIDE = 33;
13
+ const NOT_A_LOOK = /\bwalk(To)? (start|step \d+|end)$|^use\[\d+\] exitCar$/; // walk.js keys, OWN_LOOK skills (skills/car.js)
12
14
 
13
15
  /** Inside a walk the trunk's heading, its sway each step taken out: the median over about a stride keeps a turn's timing. */
14
16
  function heading(trunk, walks) {
@@ -45,7 +47,7 @@ function merged(spans) {
45
47
 
46
48
  /** The keys outside walks that move head_yaw during [f0, f1]: those in it and the nearest on either side differ. */
47
49
  function looked(keys, f0, f1) {
48
- const looks = keys.filter(k => k.set?.head_yaw !== undefined && !/\bwalk(To)? (start|step \d+|end)$/.test(k.src ?? "")).sort((a, b) => a.f - b.f);
50
+ const looks = keys.filter(k => k.set?.head_yaw !== undefined && !NOT_A_LOOK.test(k.src ?? "")).sort((a, b) => a.f - b.f);
49
51
  const near = [looks.filter(k => k.f < f0).at(-1), ...looks.filter(k => k.f >= f0 && k.f <= f1), looks.find(k => k.f > f1)].filter(Boolean);
50
52
  return near.some(k => JSON.stringify(k.set.head_yaw) !== JSON.stringify(near[0].set.head_yaw));
51
53
  }
@@ -0,0 +1,95 @@
1
+ // Spring secondary motion for a carried or free object (spec.objects[id].spring = { length, stiffness, damping }): a damped
2
+ // pendulum hung on the object's anchor lags the anchor's horizontal acceleration, swings and settles. Computed once at
3
+ // compile time (object-pose.js rootPoseAt); playback, scrub and export read the baked track only.
4
+ //
5
+ // Determinism: a fixed step dt = 1 / fps from frame 0 on, semi-implicit Euler, and the solver update uses
6
+ // only + - * / and Math.sqrt, so every engine computes the same bits. Only the last step, the swing angles to a turn,
7
+ // goes through the quaternion helpers (quat.js), as every other baked rotation does.
8
+ //
9
+ // State: two swing angles th = [tx, tz] (rad), how far the object's far end swings toward world +X and +Z, and their
10
+ // rates w. Per frame f: a = (p[f] - 2 p[f-1] + p[f-2]) * fps^2 (0 on frames 0 and 1), then
11
+ // w = (w + (-k th - a_h / L) dt) / (1 + c dt) ; th += w dt ; c = 2 d sqrt(k)
12
+ // with k the stiffness (1/s^2), d the damping ratio (0..1, 1 critical) and L the pendulum length (m): spring.length, else
13
+ // half the object's longest side (springLength). Without a stiffness k = G / L, a real pendulum's sqrt(g / L). The damping is
14
+ // implicit, so no damping ratio makes it ring up (an explicit c dt > 1 diverged); the stiffness step stays stable while
15
+ // sqrt(k) dt < 2, and the schema caps k at SPRING.stiffness.max (500) so that sqrt(k) dt <= 1 from 24 fps up.
16
+ import { qexp, qmul, qnorm } from "./quat.js";
17
+ import { quatFromMat, matFromQuat } from "./math.js";
18
+
19
+ /** The schema's ranges and defaults (schema/types.js reads them; validate-objects.js checks them). No stiffness default: G / length. */
20
+ export const SPRING = { length: { min: 0.05, max: 2 }, stiffness: { min: 0, max: 500 }, damping: { min: 0, max: 1, default: 0.5 } };
21
+ /** Gravity, m/s^2 (qa/style.js and skills/impact.js use the same value). */
22
+ const G = 9.81;
23
+ /** The largest swing drawn, rad (60 degrees). */
24
+ const SPRING_MAX = Math.PI / 3;
25
+ /** The shortest pendulum, m: a tiny prop swings as if it were this long. */
26
+ const SPRING_MIN_LENGTH = 0.05;
27
+
28
+ const clampSwing = v => (v > SPRING_MAX ? SPRING_MAX : v < -SPRING_MAX ? -SPRING_MAX : v);
29
+
30
+ /** The resting state: no swing, no anchor history. */
31
+ export const springRest = () => ({ th: [0, 0], w: [0, 0], p1: null, p2: null });
32
+
33
+ /**
34
+ * One frame: the state after anchor position p (world [x, y, z]) is fed to `state` (the state after the frame before).
35
+ * Pure: returns a new state. opts = { fps, stiffness, damping, length }.
36
+ */
37
+ export function springStep(state, p, { fps, stiffness, damping, length }) {
38
+ const dt = 1 / fps, k = stiffness, c = 2 * damping * Math.sqrt(stiffness), L = length > SPRING_MIN_LENGTH ? length : SPRING_MIN_LENGTH;
39
+ let ax = 0, az = 0;
40
+ if (state.p1 && state.p2) {
41
+ const f2 = fps * fps;
42
+ ax = (p[0] - 2 * state.p1[0] + state.p2[0]) * f2;
43
+ az = (p[2] - 2 * state.p1[2] + state.p2[2]) * f2;
44
+ }
45
+ const wx = (state.w[0] + (-k * state.th[0] - ax / L) * dt) / (1 + c * dt);
46
+ const wz = (state.w[1] + (-k * state.th[1] - az / L) * dt) / (1 + c * dt);
47
+ return { th: [state.th[0] + wx * dt, state.th[1] + wz * dt], w: [wx, wz], p1: [p[0], p[1], p[2]], p2: state.p1 };
48
+ }
49
+
50
+ /**
51
+ * The solver options for spring entry `spring` ({ length?, stiffness?, damping? }) at `fps`: the length given, else
52
+ * `fallback` (springLength of the object); the stiffness given, else G / length; the damping given, else 0.5.
53
+ */
54
+ export function springOpts(spring, fps, fallback = SPRING_MIN_LENGTH) {
55
+ const length = Math.max(SPRING_MIN_LENGTH, spring?.length ?? fallback);
56
+ return { fps, stiffness: spring?.stiffness ?? G / length, damping: spring?.damping ?? SPRING.damping.default, length };
57
+ }
58
+
59
+ /**
60
+ * The swing of every frame for anchor positions `anchors` (one world [x, y, z] per frame from frame 0):
61
+ * [[tx, tz], ...] rad, each clamped to +-60 degrees. A still anchor gives 0 on every frame. Defaults as springOpts.
62
+ */
63
+ export function springAngles({ anchors, fps, stiffness, damping, length }) {
64
+ const opts = springOpts({ stiffness, damping, length }, fps), out = [];
65
+ let state = springRest();
66
+ for (const p of anchors) {
67
+ state = springStep(state, p, opts);
68
+ out.push([clampSwing(state.th[0]), clampSwing(state.th[1])]);
69
+ }
70
+ return out;
71
+ }
72
+
73
+ /** The clamped swing of a state. */
74
+ export const swingOf = state => [clampSwing(state.th[0]), clampSwing(state.th[1])];
75
+
76
+ /**
77
+ * Pose { pos, R } turned by swing th = [tx, tz] about its own base (position unchanged): +tx swings what hangs below
78
+ * toward +X (a turn about +Z), +tz toward +Z (a turn about -X). The turn is applied in world axes on top of R.
79
+ */
80
+ export function swungPose(pose, th) {
81
+ if (th[0] === 0 && th[1] === 0) return pose;
82
+ const turn = qexp([-th[1], 0, th[0]]);
83
+ return { ...pose, pos: pose.pos, R: matFromQuat(qnorm(qmul(turn, quatFromMat(pose.R)))) };
84
+ }
85
+
86
+ /** Pendulum length of a body or group from its boxes [{ pos, R, half }]'s corners: half the longest side of their bounding box, at least 5 cm. */
87
+ export function springLength(corners) {
88
+ if (!corners.length) return SPRING_MIN_LENGTH;
89
+ let longest = 0;
90
+ for (let i = 0; i < 3; i++) {
91
+ const vs = corners.map(c => c[i]);
92
+ longest = Math.max(longest, Math.max(...vs) - Math.min(...vs));
93
+ }
94
+ return Math.max(SPRING_MIN_LENGTH, longest / 2);
95
+ }
@@ -9,7 +9,8 @@
9
9
  // or from its release on. Fields: schema/fields.js OBJECT_FIELDS, OBJECT_KEY_FIELDS, FALL_FIELDS. See validate.js.
10
10
  import { isVec3, fmt, didYouMean, GRASPS } from "./vocab.js";
11
11
  import { objectOk } from "./validate-common.js";
12
- import { OBJECT_FIELDS, OBJECT_KEY_FIELDS, FALL_FIELDS } from "../schema/fields.js";
12
+ import { OBJECT_FIELDS, OBJECT_KEY_FIELDS, FALL_FIELDS, SPRING_FIELDS } from "../schema/fields.js";
13
+ import { SPRING } from "./spring.js";
13
14
  import { isSwitch, isFollow, movesRecord } from "../carried.js";
14
15
  import { isFree } from "../free-motion.js";
15
16
  import { checkRef } from "../refs.js";
@@ -99,6 +100,18 @@ function validateFalls(err, unknown, at, o, { frames, scene, carry }) {
99
100
  });
100
101
  }
101
102
 
103
+ /** objects.<id>.spring: { length?, stiffness?, damping? }, each a number in its SPRING range (a larger stiffness would blow the fixed-step solver up). */
104
+ function validateSpring(err, unknown, at, o) {
105
+ const sp = o.spring, SPR = "{ length?, stiffness?, damping? }";
106
+ if (!sp || typeof sp !== "object" || Array.isArray(sp)) return err(`${at}.spring must be ${SPR}, got ${fmt(sp)}`);
107
+ for (const k of Object.keys(sp)) if (!SPRING_FIELDS.includes(k)) unknown(`${at}.spring has unknown field "${k}"${didYouMean(k, SPRING_FIELDS)}; a spring is ${SPR}`);
108
+ const what = { length: "string or rod length, m", stiffness: "how fast it swings back, 1/s^2", damping: "how soon it settles, 1 = no overshoot" };
109
+ for (const k of SPRING_FIELDS) {
110
+ const v = sp[k], { min, max } = SPRING[k];
111
+ if (v !== undefined && !(Number.isFinite(v) && v >= min && v <= max)) err(`${at}.spring.${k} must be ${min}..${max} (${what[k]}), got ${fmt(v)}`);
112
+ }
113
+ }
114
+
102
115
  /** A grasp that starts like the given one (4+ letters: "sphere" -> spherical), else the nearest by edit distance. */
103
116
  const prefixLen = (a, b) => { let i = 0; while (i < a.length && a[i] === b[i]) i += 1; return i; };
104
117
  function graspHint(given) {
@@ -122,7 +135,8 @@ export function validateObjects(spec, err, { sk, scene, frames = spec.frames, un
122
135
  const at = `objects.${id}`, kind = o?.prop !== undefined ? "carry" : isFollow(o) ? "follow" : isFree(o) ? "free" : isSwitch(o) ? "switch" : "hinge", fields = OBJECT_FIELDS[kind];
123
136
  // a switch and a follow take any record (a light, an effect); the others a body or a group
124
137
  if ((kind !== "switch" && kind !== "follow") || !scene?.others?.[id]) objectOk(err, scene, at, id, { groups: true });
125
- for (const f of Object.keys(o ?? {})) if (!fields.includes(f) && !(kind === "hinge" && (f === "keys" || f === "fall"))) unknown(`${at} (${kind}) has unknown field "${f}"${didYouMean(f, fields)}; fields: ${fields.join(", ")}`);
138
+ if (kind === "hinge" && o?.spring !== undefined) err(`${at}.spring: spring is not for hinged objects; a hinge turns only by its channel (give spring to a carry or a free entry)`);
139
+ for (const f of Object.keys(o ?? {})) if (!fields.includes(f) && !(kind === "hinge" && (f === "keys" || f === "fall" || f === "spring"))) unknown(`${at} (${kind}) has unknown field "${f}"${didYouMean(f, fields)}; fields: ${fields.join(", ")}`);
126
140
  massOk(err, at, o);
127
141
  if (kind === "follow") {
128
142
  validateFollow(spec, err, { sk, scene }, at, o);
@@ -137,6 +151,7 @@ export function validateObjects(spec, err, { sk, scene, frames = spec.frames, un
137
151
  if (kind === "free") {
138
152
  if (o.keys !== undefined) validateKeys(err, unknown, at, o, frames, true);
139
153
  if (o.fall !== undefined) validateFalls(err, unknown, at, o, { frames, scene, carry: false });
154
+ if (o.spring !== undefined) validateSpring(err, unknown, at, o);
140
155
  channelsOk(err, at, o);
141
156
  continue;
142
157
  }
@@ -149,6 +164,7 @@ export function validateObjects(spec, err, { sk, scene, frames = spec.frames, un
149
164
  && o.grab !== undefined && o.release !== undefined && !(o.grab < o.release)) err(`${at}: grab (${o.grab}) must come before release (${o.release})`);
150
165
  if (o.keys !== undefined) validateKeys(err, unknown, at, o, frames);
151
166
  if (o.fall !== undefined) validateFalls(err, unknown, at, o, { frames, scene, carry: true });
167
+ if (o.spring !== undefined) validateSpring(err, unknown, at, o);
152
168
  continue;
153
169
  }
154
170
  if (o?.keys !== undefined || o?.fall !== undefined) err(`${at}: a hinge turns by its channel "${o.channel}", keyed in spec.keys; keys and fall move an object with no pivot, axis or channel (C17), or a carried one (prop)`);
@@ -114,6 +114,7 @@ function validateChecks(spec, err, { sk, scene, frames, unknown = err, past = ""
114
114
  if (fix !== undefined && fix !== false && (typeof fix !== "object" || fix === null)) err(`spec.fix must be false or { ${FIX_KEYS.join(", ")} }`);
115
115
  else for (const k of Object.keys(fix || {})) if (!FIX_KEYS.includes(k)) unknown(`spec.fix.${k} is not a fix${didYouMean(k, FIX_KEYS)} (fixes: ${FIX_KEYS.join(", ")})`);
116
116
  if (fix && fix.grasp !== undefined && typeof fix.grasp !== "boolean") err(`spec.fix.grasp must be true (fingers close on what a hand holds, the default) or false (the preset grip, the object at its grab-frame offset), got ${fmt(fix.grasp)}`);
117
+ if (fix && fix.person !== undefined && typeof fix.person !== "boolean") err(`spec.fix.person must be true (arms kept out of the other characters, the default) or false, got ${fmt(fix.person)}`);
117
118
  }
118
119
 
119
120
  /** opts = { sk, scene, frames, lenient, unknown: err for an unknown field (a no-op when lenient), past: the
@@ -27,7 +27,7 @@ const RANGES = [
27
27
  [/^wflex_[lr]$/, -70, 70, "flex about the knuckle line, deg"],
28
28
  [/^fpitch_[lr]$/, -60, 90, "toe-down pitch, deg: + lifts the heel (toes flat), - lifts the toes"],
29
29
  // the whole-body pitch and roll have none: they turn in full (flips, cartwheels; pose.js tiltFrame)
30
- [/^(spine_pitch|head_pitch)$/, -120, 120, "degrees, + bends forward"],
30
+ [/^(spine_pitch|head_pitch)$/, -120, 120, "deg, + bends forward"],
31
31
  [/^(spine_roll|head_roll)$/, -90, 90, "degrees"],
32
32
  ];
33
33
  export const rangeOf = ch => RANGES.find(([re]) => re.test(ch)) ?? null;
@@ -2,7 +2,7 @@
2
2
  // the baked channels or the per-frame IK offsets and re-solves only the frames it touched, so QA
3
3
  // still measures the final frames; every fix reports what it changed (counts, worst amount).
4
4
  //
5
- // spec.fix = { reach: true, slide: true, penetration: 0.02, self: true, retime: false } (false = all off)
5
+ // spec.fix = { reach: true, slide: true, penetration: 0.02, self: true, retime: false, person: true } (false = all off)
6
6
  // reach wrist targets past the arm's reach go onto it through a soft knee (no snap);
7
7
  // ankle targets past the leg's reach lower the hips just enough, eased in and out; a hand
8
8
  // on a pinned target (a world or a scene object's anchor) that the straight arm misses brings
@@ -18,6 +18,8 @@
18
18
  // retime speed spikes get more frames, borrowed from the neighbouring keys
19
19
  // stance a standIdle stance whose legs, feet or hips are in a scene body slides out of it, the least
20
20
  // distance (skills/stance.js; applied when the skills expand, reported in observations.placed)
21
+ // person arms out of the other characters (scene.people, their installed takes; QA PERSON): the wrist
22
+ // target moves out by the depth, back the side the hand came from, eased in and out (person.js)
21
23
  // grasp a holding hand's fingers close on what it holds and the object rests in the hand (#816);
22
24
  // false keeps the preset grip and the grab-frame offset
23
25
  import { lowerHips, armReach } from "./reach.js";
@@ -28,15 +30,16 @@ import { liftSoles } from "./ground.js";
28
30
  import { pushOut } from "./push.js";
29
31
  import { retime } from "./retime.js";
30
32
  import { selfClear } from "./self.js";
33
+ import { personOut } from "./person.js";
31
34
 
32
- const FIX_KEYS = ["reach", "slide", "penetration", "self", "retime", "stance", "grasp"];
35
+ const FIX_KEYS = ["reach", "slide", "penetration", "self", "retime", "stance", "grasp", "person"];
33
36
  export { FIX_KEYS };
34
37
 
35
38
  export function fixOptions(fix) {
36
- if (fix === false) return { reach: false, slide: false, penetration: 0, self: false, retime: false, stance: false, grasp: false };
39
+ if (fix === false) return { reach: false, slide: false, penetration: 0, self: false, retime: false, stance: false, grasp: false, person: false };
37
40
  const f = fix ?? {};
38
41
  const pen = f.penetration === true ? 0.02 : Math.min(0.05, Math.max(0, Number(f.penetration) || 0));
39
- return { reach: f.reach !== false, slide: f.slide !== false, penetration: pen, self: f.self !== false, retime: f.retime === true, stance: f.stance !== false, grasp: f.grasp !== false };
42
+ return { reach: f.reach !== false, slide: f.slide !== false, penetration: pen, self: f.self !== false, retime: f.retime === true, stance: f.stance !== false, grasp: f.grasp !== false, person: f.person !== false };
40
43
  }
41
44
 
42
45
  /** The slide report with the flat feet whose turn waited (turn.js): turns { frames, max deg }. */
@@ -55,6 +58,7 @@ export function applyFixes(out, fix, engine, { whole = true } = {}) {
55
58
  if (fix.reach && fixed.slide?.pivots) put("reachLegs", mergeReach(fixed.reachLegs, lowerHips(out, engine)));
56
59
  if (fix.penetration > 0) put("penetration", pushOut(out, engine, fix.penetration));
57
60
  if (fix.self) put("self", selfClear(out, engine));
61
+ if (fix.person) put("person", personOut(out, engine));
58
62
  if (fix.reach) put("reachArms", armReach(out));
59
63
  return fixed;
60
64
  }