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,128 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { useFrame, useThree } from "@react-three/fiber";
3
+ import * as THREE from "three";
4
+ import { POSER_LAYER } from "./dualview.jsx";
5
+ import { fingerTip, fingersOf } from "./motion/fingers.js";
6
+ import { claimFingertip } from "./motion/fingertip-pick.js";
7
+ import { handleColor } from "./pose-gizmo.js";
8
+
9
+ // A fingertip handle is a real sphere in the scene, a little wider than the finger, so it sits on the tip,
10
+ // shades with the lights and hides behind the body by depth like the mesh does.
11
+ const TIP_R = 0.009;
12
+ // The tip bone sits inside the fingertip; the sphere rides this far further along the last bone so most of
13
+ // it shows past the skin.
14
+ const TIP_PUSH = 0.008;
15
+ // Picking reaches a few pixels past the sphere's projected edge, nearest tip first.
16
+ const PICK_SLACK_PX = 6;
17
+ // A tip may sit this far under the first body surface and still be grabbed: the IK controls' exposure rule
18
+ // (ikControlIsExposed) with their default allowance, twice the handle radius (#913).
19
+ const TIP_VISIBILITY_DEPTH = TIP_R * 2;
20
+
21
+ /** The focused hand's fingers, and fingertip editing: a mode of the focused hand, on only while a hand is focused. */
22
+ export function useFingerEdit(ikFocus, fkJoints) {
23
+ const [fingerEdit, setFingerEdit] = useState(false);
24
+ // IkHandles fills this with its exposure probe; the fingertip pick asks it before taking a press.
25
+ const exposureProbeRef = useRef(null);
26
+ const side = ikFocus === "leftHand" ? "Left" : ikFocus === "rightHand" ? "Right" : null;
27
+ const handFingers = side ? [...(fingersOf(fkJoints)?.values() ?? [])].filter((finger) => finger.side === side) : [];
28
+ return { handFingers, fingerEditing: fingerEdit && handFingers.length > 0, setFingerEdit, exposureProbeRef };
29
+ }
30
+
31
+ /**
32
+ * Fingertip handles for the focused hand. Dragging a tip solves that finger toward the pointer on a
33
+ * camera-facing plane (onSolve("finger", fingerId, point)); release bakes like any IK drag (onDragEnd).
34
+ * A tip the body hides cannot be grabbed (exposureProbeRef, the IK controls' rule); that press goes to IkHandles.
35
+ * The press is claimed in the window capture phase, ahead of IkHandles, so it never reads as an empty
36
+ * click that would drop the hand's focus.
37
+ */
38
+ export function FingerHandles({ fingers: handFingers, enabled, onSolve, onDragEnd, exposureProbeRef }) {
39
+ const { camera, gl } = useThree();
40
+ const meshes = useRef(new Map());
41
+ const solveRef = useRef(onSolve), endRef = useRef(onDragEnd);
42
+ useEffect(() => { solveRef.current = onSolve; endRef.current = onDragEnd; });
43
+ const fingers = enabled ? handFingers : [];
44
+
45
+ useFrame(() => {
46
+ const along = new THREE.Vector3();
47
+ for (const finger of fingers) {
48
+ const mesh = meshes.current.get(finger.id);
49
+ if (!mesh) continue;
50
+ fingerTip(finger, mesh.position);
51
+ finger.bones[2].getWorldPosition(along);
52
+ mesh.userData.push = along.subVectors(mesh.position, along).normalize().multiplyScalar(TIP_PUSH).clone();
53
+ mesh.position.add(mesh.userData.push);
54
+ }
55
+ });
56
+
57
+ const active = fingers.length > 0;
58
+ useEffect(() => {
59
+ if (!active) return undefined;
60
+ const el = gl.domElement, ndc = new THREE.Vector2(), hit = new THREE.Vector3(), ray = new THREE.Raycaster();
61
+ let drag = null;
62
+ const toNdc = (ev) => {
63
+ const rect = el.getBoundingClientRect();
64
+ return ndc.set(((ev.clientX - rect.left) / rect.width) * 2 - 1, -((ev.clientY - rect.top) / rect.height) * 2 + 1);
65
+ };
66
+ const onMove = (ev) => {
67
+ if (!drag) return;
68
+ ray.setFromCamera(toNdc(ev), camera);
69
+ if (!ray.ray.intersectPlane(drag.plane, hit)) return;
70
+ drag.moved = true;
71
+ solveRef.current?.("finger", drag.id, hit.clone().sub(drag.push));
72
+ };
73
+ const release = () => {
74
+ window.removeEventListener("pointermove", onMove, true);
75
+ window.removeEventListener("pointerup", onUp, true);
76
+ window.removeEventListener("pointercancel", onUp, true);
77
+ };
78
+ const onUp = () => {
79
+ const moved = drag?.moved;
80
+ drag = null;
81
+ release();
82
+ if (moved) endRef.current?.();
83
+ };
84
+ const onDown = (ev) => {
85
+ if (ev.button !== 0 || ev.target !== el) return;
86
+ const rect = el.getBoundingClientRect(), centre = new THREE.Vector3(), edge = new THREE.Vector3();
87
+ const right = new THREE.Vector3().setFromMatrixColumn(camera.matrixWorld, 0);
88
+ const toScreen = (point) => { point.project(camera); return [rect.left + ((point.x + 1) / 2) * rect.width, rect.top + ((1 - point.y) / 2) * rect.height]; };
89
+ const candidates = [...meshes.current].filter(([, mesh]) => mesh).map(([id, mesh]) => {
90
+ const [cx, cy] = toScreen(centre.copy(mesh.position));
91
+ const [ex] = toScreen(edge.copy(mesh.position).addScaledVector(right, TIP_R));
92
+ return { id, mesh, gap: Math.hypot(cx - ev.clientX, cy - ev.clientY), reach: Math.abs(ex - cx) + PICK_SLACK_PX };
93
+ });
94
+ const exposed = exposureProbeRef?.current;
95
+ const picked = claimFingertip(ev, candidates, ({ mesh }) => !exposed || exposed(mesh.position, TIP_VISIBILITY_DEPTH));
96
+ if (!picked) return;
97
+ const normal = camera.getWorldDirection(new THREE.Vector3());
98
+ drag = { id: picked.id, moved: false, push: picked.mesh.userData.push?.clone() ?? new THREE.Vector3(), plane: new THREE.Plane().setFromNormalAndCoplanarPoint(normal, picked.mesh.position) };
99
+ window.addEventListener("pointermove", onMove, true);
100
+ window.addEventListener("pointerup", onUp, true);
101
+ window.addEventListener("pointercancel", onUp, true);
102
+ };
103
+ window.addEventListener("pointerdown", onDown, true);
104
+ return () => {
105
+ window.removeEventListener("pointerdown", onDown, true);
106
+ release();
107
+ };
108
+ }, [active, camera, gl, exposureProbeRef]);
109
+
110
+ if (!active) return null;
111
+ return (
112
+ <group>
113
+ {fingers.map((finger) => (
114
+ <mesh
115
+ key={finger.id}
116
+ raycast={() => {}}
117
+ ref={(mesh) => {
118
+ if (mesh) { mesh.layers.set(POSER_LAYER); meshes.current.set(finger.id, mesh); }
119
+ else meshes.current.delete(finger.id);
120
+ }}
121
+ >
122
+ <sphereGeometry args={[TIP_R, 20, 14]} />
123
+ <meshStandardMaterial color={handleColor(finger.trackIds[0])} roughness={0.45} metalness={0} />
124
+ </mesh>
125
+ ))}
126
+ </group>
127
+ );
128
+ }
@@ -0,0 +1,82 @@
1
+ // Say why a click or a key did nothing (#973). Chrome shows no title tooltip on a
2
+ // disabled button, so the reason a control already carries in `title` was never
3
+ // seen; a click on it now shows that reason in a short toast. Keys that need a
4
+ // selection (Delete, Ctrl/Cmd+D, F) say so once in a while. Plain DOM, so the
5
+ // capture-phase friction listeners can call it without a React round trip.
6
+ import { ko } from "./locale.js";
7
+
8
+ const SHOW_MS = 2200, SAME_HINT_GAP_MS = 4000, SELECTION_HINT_GAP_MS = 60_000;
9
+
10
+ const FALLBACK = {
11
+ tool_pin: () => ko("Select a bone to pin first", "먼저 고정할 뼈를 선택하세요", "请先选择要固定的骨骼"),
12
+ tool_ik: () => ko("Select a character first", "먼저 캐릭터를 선택하세요", "请先选择角色"),
13
+ mode_pose: () => ko("Select a character to pose first", "먼저 포즈를 잡을 캐릭터를 선택하세요", "请先选择要摆姿势的角色"),
14
+ agent_send: () => ko("Type a message first", "먼저 메시지를 입력하세요", "请先输入消息"),
15
+ };
16
+ const HOSTED_AGENT = () => ko("The agent connects in the local app: npx cozyclay", "에이전트는 로컬 앱에서 연결돼요: npx cozyclay", "智能体需在本地应用中连接:npx cozyclay");
17
+
18
+ let node = null, hideTimer = null, last = { text: "", at: -Infinity }, lastSelectionHintAt = -Infinity;
19
+ let now = () => performance.now(), mount = (text) => showToast(text);
20
+
21
+ /** The public cozyclay.org site, where no local agent server can run. */
22
+ function hostedSite() {
23
+ const runtime = globalThis.__COZYCLAY_RUNTIME__;
24
+ if (runtime?.distribution === "npm") return false;
25
+ return /(^|\.)cozyclay\.org$/.test(globalThis.location?.hostname ?? "");
26
+ }
27
+
28
+ function showToast(text) {
29
+ const doc = globalThis.document;
30
+ if (!doc?.body) return;
31
+ if (!node || !node.isConnected) {
32
+ node = doc.createElement("div");
33
+ node.className = "toast friction-hint";
34
+ node.setAttribute("role", "status");
35
+ node.style.bottom = "64px";
36
+ doc.body.appendChild(node);
37
+ }
38
+ node.textContent = text;
39
+ node.hidden = false;
40
+ clearTimeout(hideTimer);
41
+ hideTimer = setTimeout(() => { if (node) node.hidden = true; }, SHOW_MS);
42
+ }
43
+
44
+ function show(text) {
45
+ if (!text) return;
46
+ const at = now();
47
+ if (text === last.text && at - last.at < SAME_HINT_GAP_MS) return;
48
+ last = { text, at };
49
+ mount(text);
50
+ }
51
+
52
+ /** The reason a disabled control gives: the hosted agent note, else its own title, else the per-control fallback. */
53
+ function disabledReason(element, control) {
54
+ if (control === "agent_connect" && hostedSite()) return HOSTED_AGENT();
55
+ // data-disabled-reason is sometimes a machine id (no-shots, export-running); only a sentence is shown.
56
+ const reason = (element?.getAttribute?.("data-disabled-reason") || "").trim();
57
+ const own = (/\s/.test(reason) ? reason : element?.getAttribute?.("title") || "").trim().slice(0, 140);
58
+ return own || FALLBACK[control]?.() || null;
59
+ }
60
+
61
+ /** A click on a disabled control (click-friction.js). */
62
+ export function hintDisabledClick(element, control) {
63
+ try { show(disabledReason(element, control)); } catch { /* a hint never affects a click */ }
64
+ }
65
+
66
+ /** Delete, Duplicate or Frame with nothing selected (shortcut-friction.js); at most once a minute. */
67
+ export function hintNoSelection() {
68
+ try {
69
+ const at = now();
70
+ if (at - lastSelectionHintAt < SELECTION_HINT_GAP_MS) return;
71
+ lastSelectionHintAt = at;
72
+ show(ko("Select something first", "먼저 무언가를 선택하세요", "请先选择对象"));
73
+ } catch { /* advisory */ }
74
+ }
75
+
76
+ /** Tests only: capture shown hints and drive the clock; returns the restore. */
77
+ export function frictionHintSeam({ capture, clock }) {
78
+ const previous = [mount, now];
79
+ mount = capture; now = clock;
80
+ last = { text: "", at: -Infinity }; lastSelectionHintAt = -Infinity;
81
+ return () => { [mount, now] = previous; last = { text: "", at: -Infinity }; lastSelectionHintAt = -Infinity; };
82
+ }
@@ -185,7 +185,8 @@ export function createLiveControl({
185
185
  const correlationId = props.request_id;
186
186
  if (!correlationId) return;
187
187
  if (EXECUTION_TELEMETRY_EVENTS.includes(payload.event)
188
- && EXECUTION_TELEMETRY_PROPERTY_KEYS[payload.event]?.some((key) => !Object.hasOwn(props, key))) return;
188
+ // A hub older than tool_name still sends a complete frame without it.
189
+ && EXECUTION_TELEMETRY_PROPERTY_KEYS[payload.event]?.some((key) => key !== "tool_name" && !Object.hasOwn(props, key))) return;
189
190
  const key = `${correlationId}:${payload.event}`;
190
191
  if (capturedStages.has(key)) return;
191
192
  capturedStages.add(key);
@@ -222,4 +222,24 @@ export const ZH_PANELS = {
222
222
  "Subjects": "角色",
223
223
  "Subject": "角色",
224
224
  "Add second subject": "添加第二个角色",
225
+ "Clear pose edits": "清除姿势修改",
226
+ "Reset to default pose": "重置为默认姿势",
227
+ "Remove every IK edit; the motion plays as it was": "移除所有 IK 修改;动作按原样播放",
228
+ "Left hand fingers": "左手手指",
229
+ "Right hand fingers": "右手手指",
230
+ "Edit fingertips": "编辑指尖",
231
+ "Editing fingertips": "正在编辑指尖",
232
+ "Drag a fingertip dot to bend that finger. The hand's move and rotate tools come back when you leave fingertip editing.": "拖动指尖圆点即可弯曲该手指。退出指尖编辑后,手的移动和旋转工具会恢复。",
233
+ "Turn on fingertip editing to drag fingertips in the viewport.": "开启指尖编辑后即可在视口中拖动指尖。",
234
+ "Open": "张开",
235
+ "Fist": "握拳",
236
+ "Thumb": "拇指",
237
+ "Index": "食指",
238
+ "Middle": "中指",
239
+ "Ring": "无名指",
240
+ "Pinky": "小指",
241
+ "Keep a copy on the asset shelf (Content › Sculpts), for any scene or project": "在素材架(内容 › 雕塑)上保存一份副本,可用于任何场景或项目",
242
+ "No sculpts on the shelf yet. Press Save to shelf in Sculpt mode (5), or import a .cozysculpt.json.": "素材架上还没有雕塑。在雕塑模式(5)中按“保存到素材架”,或导入 .cozysculpt.json 文件。",
243
+ "Select a sculpt to save it to the shelf.": "请选择一个雕塑以保存到素材架。",
244
+ "That card is no longer on the shelf.": "该卡片已不在素材架上。",
225
245
  };
@@ -58,6 +58,7 @@ export const ZH_SHELL = {
58
58
  "Untitled Scene": "未命名场景",
59
59
  "Couldn't find the selected pose — pick again": "找不到所选姿势 — 请重新选择",
60
60
  "Back to the default pose": "已返回默认姿势",
61
+ "Pose edits cleared — the motion stays": "已清除姿势修改 — 动作保持不变",
61
62
  "When set, exports are written here instead of your browser's Downloads folder.": "设置后,导出文件会写入此文件夹,而不是浏览器的下载文件夹。",
62
63
  "Folder access was denied. Choose another folder or use Downloads.": "文件夹访问被拒绝。请选择其他文件夹或使用下载文件夹。",
63
64
  "Could not choose the export folder.": "无法选择导出文件夹。",
@@ -227,4 +228,7 @@ export const ZH_SHELL = {
227
228
  "Help improve CozyClay with anonymous product events.": "通过匿名产品事件帮助改进 CozyClay。",
228
229
  "Use a dark reference grid instead of the clay deck.": "使用深色参考网格替代黏土台面。",
229
230
  "Shortcuts are read-only here. They stay active whenever focus is outside a text field.": "此处快捷键为只读。只要焦点不在文本输入框内,快捷键始终生效。",
231
+ "Front view (with a part selected, W moves it)": "前视图(选中部件时,W 移动部件)",
232
+ "Side view (with a part selected, E rotates it)": "侧视图(选中部件时,E 旋转部件)",
233
+ "Back view (with a part selected, R scales it)": "后视图(选中部件时,R 缩放部件)",
230
234
  };
@@ -73,6 +73,12 @@ export const ZH_WORKSPACE = {
73
73
  "Choose project": "选择项目",
74
74
  "Project navigation": "项目导航",
75
75
  "Project mode": "项目模式",
76
+ "Cast style": "角色方式",
77
+ "Humanoid": "人形",
78
+ "Detailed previs with X/Y Bot: pose, IK and motion.": "用 X/Y Bot 做精细预演:姿势、IK 与动作。",
79
+ "Simple previs with capsule figures: path and posture.": "用胶囊人物做简单预演:路径与姿态。",
80
+ "Hybrid": "混合",
81
+ "Mix both; the agent may use capsules for hard shots.": "两者混用;难拍的镜头智能体可改用胶囊人物。",
76
82
  "Block a shot in three moves": "三步完成镜头摆位",
77
83
  "Choose a template, name the project, then place a character and frame the camera in the editor.": "选择模板、命名项目,然后在编辑器中放置角色并为摄影机取景。",
78
84
  "Start a new project": "开始新项目",
@@ -1,20 +1,14 @@
1
1
  import * as THREE from "three";
2
2
  import { findBone } from "./ik.js";
3
+ import { SEGMENT_FRACTIONS } from "../motion3/qa/com.js";
3
4
 
4
5
  // Body segment masses and the centre of mass of a posed Mixamo rig. Read-only:
5
6
  // motion verification and the support diagnostics measure with it.
6
7
 
7
8
  /**
8
- * Segment mass fractions of total body mass, from Dempster's cadaver study
9
- * (Dempster, W.T., "Space Requirements of the Seated Operator", WADC TR
10
- * 55-159, 1955), in the tabulation popularised by Winter, "Biomechanics and
11
- * Motor Control of Human Movement". The trunk (0.497) is split into pelvis
12
- * (0.142) and thorax+abdomen (0.355); distal pairs are merged because their
13
- * combined centroid still lands near the segment midpoint and Mixamo's hand
14
- * and toe bones are too short to place reliably:
15
- *
16
- * head+neck 0.081 · trunk 0.497 · upper arm 0.028 · forearm 0.016 +
17
- * hand 0.006 = 0.022 · thigh 0.100 · shank 0.0465 + foot 0.0145 = 0.061
9
+ * Segment mass fractions of total body mass (Dempster, via Winter), on Mixamo
10
+ * bone names. The table itself lives with motion3 QA (motion3/qa/com.js
11
+ * SEGMENT_FRACTIONS), which measures balance on the same segments.
18
12
  *
19
13
  * Each entry names the two Mixamo bones whose MIDPOINT carries the fraction.
20
14
  * Dempster's true centres of mass sit ~43% down each segment rather than at
@@ -24,19 +18,7 @@ import { findBone } from "./ik.js";
24
18
  *
25
19
  * The fractions sum to 1, so the weighted average below is a true CoM.
26
20
  */
27
- export const SEGMENT_MASSES = [
28
- { id: "pelvis", start: "mixamorigHips", end: "mixamorigSpine1", mass: 0.142 },
29
- { id: "torso", start: "mixamorigSpine1", end: "mixamorigNeck", mass: 0.355 },
30
- { id: "head", start: "mixamorigNeck", end: "mixamorigHead", mass: 0.081 },
31
- { id: "leftUpperArm", start: "mixamorigLeftArm", end: "mixamorigLeftForeArm", mass: 0.028 },
32
- { id: "leftForeArmHand", start: "mixamorigLeftForeArm", end: "mixamorigLeftHand", mass: 0.022 },
33
- { id: "rightUpperArm", start: "mixamorigRightArm", end: "mixamorigRightForeArm", mass: 0.028 },
34
- { id: "rightForeArmHand", start: "mixamorigRightForeArm", end: "mixamorigRightHand", mass: 0.022 },
35
- { id: "leftThigh", start: "mixamorigLeftUpLeg", end: "mixamorigLeftLeg", mass: 0.100 },
36
- { id: "leftShankFoot", start: "mixamorigLeftLeg", end: "mixamorigLeftFoot", mass: 0.061 },
37
- { id: "rightThigh", start: "mixamorigRightUpLeg", end: "mixamorigRightLeg", mass: 0.100 },
38
- { id: "rightShankFoot", start: "mixamorigRightLeg", end: "mixamorigRightFoot", mass: 0.061 },
39
- ];
21
+ export const SEGMENT_MASSES = SEGMENT_FRACTIONS.map(({ id, start, end, mass }) => ({ id, start: `mixamorig${start}`, end: `mixamorig${end}`, mass }));
40
22
 
41
23
  /**
42
24
  * World-space centre of mass of the rig's CURRENT pose: the mass-weighted
@@ -0,0 +1,143 @@
1
+ import * as THREE from "three";
2
+ import { closestPointsSegmentSegment } from "./fix-collisions.js";
3
+
4
+ /**
5
+ * Contact projection for a Studio IK drag: the dragged target is moved out of
6
+ * the OTHER characters' capsules before the chain solves toward it, so a hand
7
+ * pushed into someone's chest stops on the chest instead of going through.
8
+ *
9
+ * One-sided by design: the blockers never move (the other character is never
10
+ * pushed; what they do is their own take). Fixed pass count, fixed order
11
+ * (probe capsules in the order given, blockers in the order given, the first
12
+ * deepest pair wins a tie), no clock, no randomness: the same target, the same
13
+ * blockers and the same `lastGood` give the same result.
14
+ *
15
+ * KEEP THE SIDE. The plain escape normal (probe point minus blocker point)
16
+ * flips once a deep target crosses the blocker's axis and then pushes the hand
17
+ * out of the BACK of the body. The escape keeps the side the hand came from:
18
+ * when the normal points away from `lastGood` (the last clean target of this
19
+ * drag), the push goes toward `lastGood` instead. Of the escape rules tried in
20
+ * the research prototype this was the only one that never tunnelled.
21
+ */
22
+
23
+ const EPS = 1e-9;
24
+
25
+ /** Push distance along unit `dir` that takes point `w` (relative to the
26
+ * blocker point) to distance `reach` from it: the far root of |w + t dir| = reach. */
27
+ function exitDistance(w, dir, reach) {
28
+ const along = w.dot(dir);
29
+ const inside = w.lengthSq() - reach * reach;
30
+ return -along + Math.sqrt(Math.max(0, along * along - inside));
31
+ }
32
+
33
+ /**
34
+ * @param {object} options
35
+ * @param {Array<{id:string,a:THREE.Vector3,b:THREE.Vector3,radius:number}>} options.blockers
36
+ * the other characters' capsules, built once per drag; never moved
37
+ * @param {number|((probeId:string, blockerId:string) => number|null)} [options.allow]
38
+ * depth (m) a probe may go into a blocker: a number for every pair, or a
39
+ * lookup (null = 0) for an intended touch (spec.checks.allow person entries)
40
+ * @param {number} [options.margin] how far outside the surface a pushed target lands
41
+ * @param {number} [options.passes] fixed number of probe/push passes
42
+ * @param {number} [options.maxSteps] cap on the sweep's substeps per event
43
+ */
44
+ export function createContactProjector({ blockers = [], allow = 0, margin = 0.004, passes = 4, maxSteps = 32 } = {}) {
45
+ // sweep spacing: no substep longer than the thinnest blocker's radius
46
+ const thinnest = Math.max(0.005, Math.min(...blockers.map((b) => b.radius)));
47
+ const allowed = typeof allow === "function" ? (p, b) => Math.max(0, allow(p, b) ?? 0) : () => Math.max(0, allow ?? 0);
48
+ const onProbe = new THREE.Vector3(), onBlocker = new THREE.Vector3(), w = new THREE.Vector3(), dir = new THREE.Vector3(), side = new THREE.Vector3();
49
+ const best = { depth: 0, reach: 0, probe: new THREE.Vector3(), blocker: new THREE.Vector3() };
50
+
51
+ /** The deepest probe/blocker pair past its allowance, written into `best`; false when none. */
52
+ function deepest(capsules) {
53
+ best.depth = 0;
54
+ for (const capsule of capsules) {
55
+ for (const blocker of blockers) {
56
+ const reach = capsule.radius + blocker.radius - allowed(capsule.id, blocker.id);
57
+ const dist = Math.sqrt(closestPointsSegmentSegment(capsule.a, capsule.b, blocker.a, blocker.b, onProbe, onBlocker));
58
+ if (reach - dist <= best.depth) continue;
59
+ best.depth = reach - dist;
60
+ best.reach = reach;
61
+ best.probe.copy(onProbe);
62
+ best.blocker.copy(onBlocker);
63
+ }
64
+ }
65
+ return best.depth > 0;
66
+ }
67
+
68
+ /**
69
+ * SWEEP. A clean endpoint is not enough: one pointer event can carry the
70
+ * limb clean across a thin blocker (a forearm, a hand). Walk from the
71
+ * handle as displayed (`from.at`) to the target in equal substeps no
72
+ * longer than the thinnest blocker's radius (at most `maxSteps`; a longer
73
+ * jump gets longer substeps) and stop at the first substep that touches
74
+ * after a clean one. Returns that substep (the keep-the-side push then runs
75
+ * there, with the clean substep before it as the side), `{ at: null }`
76
+ * when the target is clean and so was the way there, or null when the path
77
+ * never got clean (the limb as displayed is in contact, e.g. the drag
78
+ * opened touching, and a start in contact only stops once clean).
79
+ *
80
+ * The start is judged on the limb AS DISPLAYED (`from.capsules`), not by
81
+ * solving toward lastGood again: the drag's solve does not land its handle
82
+ * on the target in one call, so a second solve toward the same clean
83
+ * target moves the limb on, into the blocker it had stopped short of, and
84
+ * the whole crossing then read as a start in contact allowed to leave.
85
+ */
86
+ function firstTouch(target, probe, from) {
87
+ const steps = Math.min(maxSteps, Math.max(1, Math.ceil(from.at.distanceTo(target) / thinnest)));
88
+ let wasClean = !deepest(from.capsules);
89
+ const clean = from.at.clone();
90
+ for (let k = 1; k <= steps; k++) {
91
+ const at = k === steps ? target.clone() : from.at.clone().lerp(target, k / steps);
92
+ if (!deepest(probe(at))) {
93
+ wasClean = true;
94
+ clean.copy(at);
95
+ } else if (wasClean) return { at, clean };
96
+ }
97
+ return wasClean ? { at: null, clean } : null;
98
+ }
99
+
100
+ /**
101
+ * Move `target` out of the blockers.
102
+ * @param {THREE.Vector3} target the dragged point (not mutated)
103
+ * @param {(candidate:THREE.Vector3) => Array<{id:string,a:THREE.Vector3,b:THREE.Vector3,radius:number}>} probe
104
+ * the dragged limb's capsules when its handle sits at `candidate`
105
+ * @param {THREE.Vector3|null} lastGood the last clean target of this drag (the side to keep)
106
+ * @param {{at:THREE.Vector3, capsules:Array}|null} [from] the handle and the limb's
107
+ * capsules as displayed when the event began: where the sweep starts
108
+ * (default: lastGood, with the limb probed there)
109
+ * @returns {{ target: THREE.Vector3, contact: boolean, depth: number }}
110
+ * `contact` = a push happened; `depth` = what is left after the last pass (0 = clean)
111
+ */
112
+ function project(target, probe, lastGood = null, from = null) {
113
+ let out = target.clone();
114
+ let contact = false;
115
+ if (!blockers.length) return { target: out, contact, depth: 0 };
116
+ const hit = lastGood ? firstTouch(out, probe, from ?? { at: lastGood, capsules: probe(lastGood) }) : null;
117
+ if (hit && !hit.at) return { target: out, contact, depth: 0 };
118
+ if (hit) out = hit.at;
119
+ const keep = hit?.clean ?? lastGood;
120
+ for (let pass = 0; pass < passes; pass++) {
121
+ if (!deepest(probe(out))) return { target: out, contact, depth: 0 };
122
+ contact = true;
123
+ w.subVectors(best.probe, best.blocker);
124
+ dir.copy(w);
125
+ if (keep) {
126
+ side.subVectors(keep, best.blocker);
127
+ // a normal pointing away from the side the hand came from is the
128
+ // far side of the body: escape toward lastGood instead
129
+ if (side.lengthSq() > EPS && (dir.lengthSq() <= EPS || dir.dot(side) < 0)) dir.copy(side);
130
+ }
131
+ if (dir.lengthSq() <= EPS) dir.set(0, 1, 0);
132
+ dir.normalize();
133
+ out.addScaledVector(dir, exitDistance(w, dir, best.reach + margin));
134
+ }
135
+ const left = deepest(probe(out)) ? best.depth : 0;
136
+ // the passes did not get the probed (displayed) limb out: fall back to
137
+ // the last clean substep of the sweep, on the near side
138
+ if (left > 0 && hit) return { target: hit.clean.clone(), contact: true, depth: 0 };
139
+ return { target: out, contact, depth: left };
140
+ }
141
+
142
+ return { project };
143
+ }
@@ -0,0 +1,13 @@
1
+ import { normalizeBoneName } from "../poses.js";
2
+
3
+ /** A bone by Mixamo name; the matcher accepts the `mixamorig:` prefix and prefix-less rigs. */
4
+ export function findBone(root, name) {
5
+ const target = normalizeBoneName(name);
6
+ let found = null;
7
+ root.traverse((object) => {
8
+ if (found || !object.isBone) return;
9
+ const norm = normalizeBoneName(object.name);
10
+ if (norm === target || norm.endsWith(target)) found = object;
11
+ });
12
+ return found;
13
+ }
@@ -0,0 +1,170 @@
1
+ import * as THREE from "three";
2
+ import { findBone } from "./find-bone.js";
3
+
4
+ /**
5
+ * Finger posing for the IK layer. Every finger bone (Thumb/Index/Middle/Ring/Pinky 1..3, per side) is an
6
+ * FK joint track ("leftIndex2"), so keys, playback, take corrections and Undo reuse the joint path whole.
7
+ * A finger bends only around one hinge axis per bone, measured off the hand: the axis is perpendicular to
8
+ * the bone and to the palm normal, so a positive angle curls toward the palm. Angles are relative to the
9
+ * bind pose and clamped per joint.
10
+ */
11
+ const FINGER_NAMES = ["Thumb", "Index", "Middle", "Ring", "Pinky"];
12
+ const DEG = Math.PI / 180;
13
+ // [min, max] bend in degrees per joint (base, middle, tip). The thumb folds across the palm, less far.
14
+ const LIMITS = { Thumb: [[-20, 50], [-10, 60], [-10, 75]], other: [[-15, 90], [0, 100], [0, 80]] };
15
+ const limitsOf = (name) => LIMITS[name] ?? LIMITS.other;
16
+
17
+ const fingerTrackId = (side, name, joint) => `${side.toLowerCase()}${name}${joint}`;
18
+ export const FINGER_TRACK_IDS = ["Left", "Right"].flatMap((side) => FINGER_NAMES.flatMap((name) => [1, 2, 3].map((joint) => fingerTrackId(side, name, joint))));
19
+
20
+ const hingeCache = new WeakMap();
21
+ /** The fingers of an FK joint map (resolveIkRig's fkJoints): each finger joint record points at its finger. */
22
+ export const fingersOf = (fkJoints) => new Map([...(fkJoints?.values() ?? [])].filter((joint) => joint.finger).map((joint) => [joint.finger.id, joint.finger]));
23
+ const tmp = { a: new THREE.Vector3(), b: new THREE.Vector3(), c: new THREE.Vector3(), q: new THREE.Quaternion() };
24
+
25
+ function bindQuatOf(rig, bone) {
26
+ const saved = rig.userData?.poseBind?.get(bone);
27
+ return saved ? new THREE.Quaternion(saved.x, saved.y, saved.z, saved.w) : bone.quaternion.clone();
28
+ }
29
+
30
+ /** The palm normal, pointing to the side the fingers curl toward (the thumb's side of the knuckle plane). */
31
+ function palmNormal(hand, index1, pinky1, middle1, thumb2) {
32
+ const at = (bone) => bone.getWorldPosition(new THREE.Vector3());
33
+ const handPos = at(hand), forward = at(middle1).sub(handPos), across = at(index1).sub(at(pinky1));
34
+ const normal = forward.clone().cross(across).normalize();
35
+ const knuckles = at(index1).add(at(pinky1)).multiplyScalar(0.5);
36
+ if (thumb2 && at(thumb2).sub(knuckles).dot(normal) < 0) normal.negate();
37
+ return normal;
38
+ }
39
+
40
+ /** The bone's hinge axis in its own local frame, measured once per bone (a hinge turn leaves it fixed). */
41
+ function hingeAxis(bone, child, normal) {
42
+ let axis = hingeCache.get(bone);
43
+ if (axis) return axis;
44
+ const dir = child.getWorldPosition(tmp.a).sub(bone.getWorldPosition(tmp.b)).normalize();
45
+ const world = dir.clone().cross(normal);
46
+ if (world.lengthSq() < 1e-8) return null;
47
+ axis = world.normalize().applyQuaternion(bone.getWorldQuaternion(tmp.q).invert());
48
+ hingeCache.set(bone, axis);
49
+ return axis;
50
+ }
51
+
52
+ /**
53
+ * Resolve the fingers of a rig: returns a Map of "leftIndex" to its three bones, hinge axes, bind rotations
54
+ * and limits, and adds each bone to `fkJoints` under its track id (resolveIkRig's FK joint record shape).
55
+ * A rig without finger bones, or a finger missing one, simply has no entry.
56
+ */
57
+ export function resolveFingers(rig, fkJoints) {
58
+ const fingers = new Map();
59
+ rig.updateMatrixWorld(true);
60
+ for (const side of ["Left", "Right"]) {
61
+ const bone = (name) => findBone(rig, `mixamorig${side}${name}`);
62
+ const hand = bone("Hand"), index1 = bone("HandIndex1"), pinky1 = bone("HandPinky1"), middle1 = bone("HandMiddle1");
63
+ if (!hand || !index1 || !pinky1 || !middle1) continue;
64
+ const normal = palmNormal(hand, index1, pinky1, middle1, bone("HandThumb2"));
65
+ for (const name of FINGER_NAMES) {
66
+ const bones = [1, 2, 3].map((joint) => bone(`Hand${name}${joint}`));
67
+ if (bones.some((entry) => !entry)) continue;
68
+ const tip = bone(`Hand${name}4`);
69
+ const axes = bones.map((entry, i) => hingeAxis(entry, bones[i + 1] ?? tip ?? entry.children[0], normal));
70
+ if (axes.some((axis) => !axis)) continue;
71
+ const id = `${side.toLowerCase()}${name}`, trackIds = [1, 2, 3].map((joint) => fingerTrackId(side, name, joint));
72
+ const finger = { id, side, name, bones, tip, axes, bindQuats: bones.map((entry) => bindQuatOf(rig, entry)), limits: limitsOf(name), trackIds };
73
+ fingers.set(id, finger);
74
+ bones.forEach((entry, i) => {
75
+ const saved = rig.userData?.poseBind?.get(entry)?.position;
76
+ fkJoints.set(trackIds[i], {
77
+ track: { id: trackIds[i], label: `${side} ${name} ${i + 1}`, bone: entry.name, child: (bones[i + 1] ?? tip)?.name ?? null, finger: true, group: "finger", visibilityDepth: 0.05 },
78
+ bone: entry, child: bones[i + 1] ?? tip ?? null, finger,
79
+ bindPos: saved ? new THREE.Vector3(saved.x, saved.y, saved.z) : entry.position.clone(),
80
+ });
81
+ });
82
+ }
83
+ }
84
+ return fingers;
85
+ }
86
+
87
+ /** The joint's current bend around its hinge, in radians from bind. */
88
+ function jointAngle(finger, i) {
89
+ const rel = finger.bindQuats[i].clone().invert().multiply(finger.bones[i].quaternion);
90
+ const axis = finger.axes[i];
91
+ return 2 * Math.atan2(rel.x * axis.x + rel.y * axis.y + rel.z * axis.z, rel.w);
92
+ }
93
+
94
+ // Sets the joint's bend around its hinge and keeps whatever else the pose had on the bone (a take's spread
95
+ // toward a held ball, a thumb's wrap): the rotation from bind splits into its turn about the hinge (twist)
96
+ // and the rest (swing), and only the twist is replaced.
97
+ function setJointAngle(finger, i, angle) {
98
+ const [min, max] = finger.limits[i], axis = finger.axes[i];
99
+ const clamped = Math.min(max * DEG, Math.max(min * DEG, angle));
100
+ const rel = finger.bindQuats[i].clone().invert().multiply(finger.bones[i].quaternion);
101
+ const along = rel.x * axis.x + rel.y * axis.y + rel.z * axis.z;
102
+ const twist = new THREE.Quaternion(axis.x * along, axis.y * along, axis.z * along, rel.w);
103
+ const swing = twist.lengthSq() < 1e-12 ? rel : rel.clone().multiply(twist.normalize().invert());
104
+ finger.bones[i].quaternion.copy(finger.bindQuats[i]).multiply(swing).multiply(tmp.q.setFromAxisAngle(axis, clamped));
105
+ finger.bones[i].updateMatrixWorld(true);
106
+ }
107
+
108
+ /** The fingertip in world space: the tip bone, or the last bone extended by its own length. */
109
+ export function fingerTip(finger, out = new THREE.Vector3()) {
110
+ if (finger.tip) return finger.tip.getWorldPosition(out);
111
+ const end = finger.bones[2].getWorldPosition(out), mid = finger.bones[1].getWorldPosition(tmp.c);
112
+ return end.add(end.clone().sub(mid));
113
+ }
114
+
115
+ /**
116
+ * Curl the whole finger to `amount` (0 straight, 1 every joint at its maximum bend, as fingerCurl reads it). A finger
117
+ * already bent keeps its shape: each joint moves the same fraction of the way to straight (less curl) or to its maximum
118
+ * (more), so reading the curl and setting it back moves nothing, and 0 and 1 stay straight and fully bent.
119
+ */
120
+ export function solveFingerCurl(finger, amount) {
121
+ const t = Math.min(1, Math.max(0, amount)), now = fingerCurl(finger);
122
+ const share = (i) => Math.max(0, jointAngle(finger, i)) / (finger.limits[i][1] * DEG);
123
+ const shares = finger.bones.map((_, i) => (t <= now ? share(i) * (now > 1e-6 ? t / now : 0) : share(i) + (1 - share(i)) * ((t - now) / (1 - now))));
124
+ shares.forEach((value, i) => setJointAngle(finger, i, value * finger.limits[i][1] * DEG));
125
+ }
126
+
127
+ /** Every finger of an FK joint map back at its bind rotation: a re-pose (a reset, a pose, a take) that names no finger
128
+ * leaves them straight, not at the last curl they were given. */
129
+ export function restFingers(fkJoints) {
130
+ for (const finger of fingersOf(fkJoints).values()) finger.bones.forEach((bone, i) => { bone.quaternion.copy(finger.bindQuats[i]); bone.updateMatrixWorld(true); });
131
+ }
132
+
133
+ /** How curled the finger is now, 0..1 (mean of the joints' share of their maximum bend). */
134
+ export function fingerCurl(finger) {
135
+ const shares = finger.bones.map((_, i) => Math.max(0, jointAngle(finger, i)) / (finger.limits[i][1] * DEG));
136
+ return Math.min(1, shares.reduce((sum, value) => sum + value, 0) / shares.length);
137
+ }
138
+
139
+ /** Fingertip IK: cyclic coordinate descent over the three hinges toward `target`, inside each joint's limits. */
140
+ export function solveFingerReach(finger, target, iterations = 20) {
141
+ const joint = new THREE.Vector3(), tip = new THREE.Vector3(), axis = new THREE.Vector3(), toTip = new THREE.Vector3(), toTarget = new THREE.Vector3();
142
+ for (let pass = 0; pass < iterations; pass += 1) {
143
+ for (let i = 2; i >= 0; i -= 1) {
144
+ const bone = finger.bones[i];
145
+ bone.getWorldPosition(joint);
146
+ fingerTip(finger, tip);
147
+ axis.copy(finger.axes[i]).applyQuaternion(bone.getWorldQuaternion(tmp.q)).normalize();
148
+ toTip.copy(tip).sub(joint).projectOnPlane(axis);
149
+ toTarget.copy(target).sub(joint).projectOnPlane(axis);
150
+ if (toTip.lengthSq() < 1e-12 || toTarget.lengthSq() < 1e-12) continue;
151
+ const delta = Math.atan2(toTip.clone().cross(toTarget).dot(axis), toTip.dot(toTarget));
152
+ setJointAngle(finger, i, jointAngle(finger, i) + delta);
153
+ }
154
+ if (fingerTip(finger, tip).distanceTo(target) < 1e-4) break;
155
+ }
156
+ }
157
+
158
+ // One pose-mode gesture on a finger: { curl } bends the whole finger (panel), a world point is the
159
+ // fingertip's IK target. Every joint of the finger is keyed.
160
+ export function solveFingerGesture(finger, target, touch) {
161
+ if (!finger) return;
162
+ if (typeof target?.curl === "number") solveFingerCurl(finger, target.curl); else solveFingerReach(finger, target);
163
+ for (const id of finger.trackIds) touch(id);
164
+ }
165
+
166
+ export const FINGER_PRESETS = {
167
+ open: { Thumb: 0, Index: 0, Middle: 0, Ring: 0, Pinky: 0 },
168
+ fist: { Thumb: 0.7, Index: 1, Middle: 1, Ring: 1, Pinky: 1 },
169
+ point: { Thumb: 0.7, Index: 0, Middle: 1, Ring: 1, Pinky: 1 },
170
+ };