cozyclay 3.2.0 → 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 (185) hide show
  1. package/CHANGELOG.md +87 -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 +16 -13
  7. package/bin/agent/agent-stream.mjs +26 -3
  8. package/bin/agent/main-prompt.mjs +1 -1
  9. package/bin/agent/studio-prompt.mjs +2 -2
  10. package/bin/agent/supervisor-followup.mjs +34 -0
  11. package/bin/agent/supervisor.mjs +6 -6
  12. package/bin/cozyclay.mjs +23 -60
  13. package/bin/star-prompt.mjs +15 -14
  14. package/bin/studio-routes.mjs +6 -3
  15. package/bin/telemetry-state.mjs +5 -7
  16. package/bin/update-check.mjs +61 -7
  17. package/bin/update-install.mjs +16 -3
  18. package/bin/update-restart.mjs +95 -0
  19. package/bin/update-routes.mjs +25 -3
  20. package/dist/app/index.html +13 -7
  21. package/dist/assets/TaskBoard-tgO0MNKL.js +1 -0
  22. package/dist/assets/analytics-CaTUuLPM.js +1 -0
  23. package/dist/assets/app-CnDkB5WO.css +1 -0
  24. package/dist/assets/app-DJt06Oyb.js +5144 -0
  25. package/dist/assets/{compile-worker-DRWRa9pr.js → compile-worker-D1pl6G7x.js} +5 -5
  26. package/dist/assets/landing-2Fy7DU41.js +1 -0
  27. package/dist/assets/modulepreload-polyfill-P2Xu9kJm.js +1 -0
  28. package/dist/assets/motion3-compile-BxaM0Xwl.js +1 -0
  29. package/dist/assets/scene-asset-sync-CLGNbEBq.js +13 -0
  30. package/dist/assets/task-board-UVGTZLRF.js +15 -0
  31. package/dist/assets/workflow-CSiBlX1s.js +179 -0
  32. package/dist/cozyclay-package.json +1 -1
  33. package/dist/index.html +4 -1
  34. package/dist/privacy/index.html +9 -6
  35. package/dist/sitemap.xml +2 -2
  36. package/dist/workflow/index.html +5 -4
  37. package/mcp/README.md +1 -1
  38. package/mcp/live-hub.mjs +4 -4
  39. package/mcp/tool-handlers.mjs +14 -16
  40. package/package.json +2 -1
  41. package/src/App.jsx +37 -70
  42. package/src/analytics-controls.js +25 -3
  43. package/src/analytics-strict.js +16 -4
  44. package/src/analytics.js +32 -26
  45. package/src/app-version.js +13 -0
  46. package/src/asset-drop.js +39 -0
  47. package/src/asset-pane.css +34 -0
  48. package/src/asset-pane.jsx +4 -35
  49. package/src/bus-tasks.js +1 -1
  50. package/src/character-kind.js +8 -6
  51. package/src/click-friction.js +5 -4
  52. package/src/command-bus-telemetry.js +8 -7
  53. package/src/command-bus.js +2 -16
  54. package/src/commands/cast.js +2 -1
  55. package/src/commands/motion3.js +3 -1
  56. package/src/commands/objects.js +2 -37
  57. package/src/commands/project.js +3 -3
  58. package/src/commands/sculpt-compare.js +53 -0
  59. package/src/content-tile.jsx +36 -0
  60. package/src/domains/cast.js +7 -6
  61. package/src/domains/motion.js +6 -5
  62. package/src/domains/scenes.js +41 -31
  63. package/src/execution-telemetry.js +7 -3
  64. package/src/export-menu-telemetry.js +26 -0
  65. package/src/finger-handles.jsx +128 -0
  66. package/src/friction-hint.js +82 -0
  67. package/src/live-control.js +2 -1
  68. package/src/locale-zh/panels.js +20 -0
  69. package/src/locale-zh/shell.js +4 -0
  70. package/src/locale-zh/workspace.js +6 -0
  71. package/src/motion/find-bone.js +13 -0
  72. package/src/motion/fingers.js +170 -0
  73. package/src/motion/fingertip-pick.js +21 -0
  74. package/src/motion/ik-exposure.js +48 -0
  75. package/src/motion/ik.js +4 -11
  76. package/src/motion3/compiler/head-lead.js +4 -2
  77. package/src/motion3/hybrid-hint.js +19 -0
  78. package/src/motion3/load.js +19 -12
  79. package/src/motion3/qa/hints-contact.js +12 -0
  80. package/src/motion3/qa/patch.js +4 -1
  81. package/src/motion3/qa/verdict.js +7 -2
  82. package/src/motion3/repair.js +28 -8
  83. package/src/motion3/skills/catch.js +2 -1
  84. package/src/panels/FingerPanel.jsx +38 -0
  85. package/src/panels/RigControlPanel.jsx +9 -1
  86. package/src/panels/SculptPanel.jsx +12 -12
  87. package/src/panels/SculptPartTree.jsx +46 -0
  88. package/src/panels/SubjectsPanel.jsx +1 -1
  89. package/src/panels/sculpt.css +11 -0
  90. package/src/pose-fade.js +7 -3
  91. package/src/posestudio.jsx +14 -27
  92. package/src/project-browser.css +6 -0
  93. package/src/project-browser.jsx +9 -18
  94. package/src/project-start-choices.jsx +45 -0
  95. package/src/project.js +16 -12
  96. package/src/proxy-figure-shape.js +11 -0
  97. package/src/proxy-figure.jsx +25 -18
  98. package/src/scene-assets.js +17 -13
  99. package/src/scenes.js +22 -11
  100. package/src/sculpt-blueprint.js +56 -0
  101. package/src/sculpt-mode.js +5 -0
  102. package/src/sculpt-part-edit.js +115 -0
  103. package/src/sculpt-part-gizmo.jsx +159 -0
  104. package/src/sculpt-part-outline.jsx +72 -0
  105. package/src/sculpt-pick.js +53 -0
  106. package/src/sculpt-run-text.js +51 -0
  107. package/src/sculpt-run.js +10 -46
  108. package/src/sculpt-shelf-file.js +92 -0
  109. package/src/sculpt-shelf-tiles.jsx +69 -0
  110. package/src/sculpt-shelf.js +108 -0
  111. package/src/sculpt-stage.jsx +39 -2
  112. package/src/sculpt-thumb.js +72 -0
  113. package/src/sculpt-views.js +75 -0
  114. package/src/semantic-edit.js +0 -87
  115. package/src/settings-menu.jsx +2 -2
  116. package/src/shell/DetailsSlot.jsx +5 -6
  117. package/src/shell/LibrarySlot.jsx +8 -10
  118. package/src/shell/MenuBar.jsx +6 -6
  119. package/src/shell/PreferencesDialog.jsx +2 -3
  120. package/src/shell/ViewportToolbar.jsx +15 -18
  121. package/src/shell/add-character-entries.js +13 -0
  122. package/src/shell/pose-controls.js +32 -0
  123. package/src/shell/preference-pages.jsx +2 -11
  124. package/src/shell/storyboard-caption.js +21 -0
  125. package/src/shell/studio-shell-context.js +1 -0
  126. package/src/shortcut-friction.js +2 -1
  127. package/src/site/landing-analytics.js +28 -0
  128. package/src/studio-actions.js +3 -2
  129. package/src/studio-agent-protocol.js +4 -4
  130. package/src/studio-app-binding.js +4 -3
  131. package/src/update/UpdateChip.jsx +21 -2
  132. package/src/update/UpdateDialog.jsx +26 -6
  133. package/src/update/update-client.js +123 -17
  134. package/src/update/update-telemetry.js +15 -3
  135. package/src/use-sculpt-mode.js +62 -8
  136. package/src/workflow/agent-failure-codes.js +28 -9
  137. package/src/workflow/agent-turn-telemetry.js +1 -1
  138. package/src/workflow/task-board.js +2 -2
  139. package/tools/dev/page-shell.py +1 -1
  140. package/tools/dev/pages/greybox-to-video.html +21 -2
  141. package/tools/dev/pages/previs-software.html +3 -3
  142. package/tools/dev/pages/privacy.html +9 -6
  143. package/tools/dev/pages/seedance-camera-control.html +2 -2
  144. package/tools/loc-baseline.json +10 -10
  145. package/tools/run-tests.mjs +45 -69
  146. package/tools/select-tests.mjs +119 -0
  147. package/tools/test-durations.json +455 -0
  148. package/tools/test-graph.mjs +181 -0
  149. package/tools/test-shards.mjs +78 -0
  150. package/tools/tutorial/README.md +74 -0
  151. package/tools/tutorial/assemble.mjs +43 -0
  152. package/tools/tutorial/captions.mjs +43 -0
  153. package/tools/tutorial/cdp.mjs +55 -0
  154. package/tools/tutorial/cursor.mjs +159 -0
  155. package/tools/tutorial/episode.mjs +173 -0
  156. package/tools/tutorial/episodes/beginner-guide/00-install.mjs +42 -0
  157. package/tools/tutorial/episodes/beginner-guide/01-start.mjs +50 -0
  158. package/tools/tutorial/episodes/beginner-guide/02-layout.mjs +67 -0
  159. package/tools/tutorial/episodes/beginner-guide/03-stage.mjs +58 -0
  160. package/tools/tutorial/episodes/beginner-guide/04-motion.mjs +98 -0
  161. package/tools/tutorial/episodes/beginner-guide/05-camera.mjs +47 -0
  162. package/tools/tutorial/episodes/beginner-guide/06-export.mjs +59 -0
  163. package/tools/tutorial/episodes/beginner-guide/steps.mjs +32 -0
  164. package/tools/tutorial/episodes/beginner-guide.mjs +17 -0
  165. package/tools/tutorial/episodes/cast.mjs +12 -0
  166. package/tools/tutorial/episodes/first-video.mjs +121 -0
  167. package/tools/tutorial/episodes/stub-tour.mjs +97 -0
  168. package/tools/tutorial/launch.mjs +63 -0
  169. package/tools/tutorial/mux.mjs +61 -0
  170. package/tools/tutorial/overlay.mjs +142 -0
  171. package/tools/tutorial/record.mjs +156 -0
  172. package/tools/tutorial/scenes/terminal.html +63 -0
  173. package/tools/tutorial/steps.mjs +111 -0
  174. package/tools/tutorial/terminal.mjs +43 -0
  175. package/tools/tutorial/voice.mjs +49 -0
  176. package/tools/update-test-durations.mjs +42 -0
  177. package/tools/verify-package-tarball.mjs +18 -12
  178. package/dist/assets/TaskBoard-BUjjuvm5.js +0 -1
  179. package/dist/assets/app-Dige7q3X.css +0 -1
  180. package/dist/assets/app-DxoI941o.js +0 -5144
  181. package/dist/assets/motion3-compile-B4SX4TGx.js +0 -1
  182. package/dist/assets/rolldown-runtime-hePW80VL.js +0 -1
  183. package/dist/assets/scene-asset-sync-LeI3eSnO.js +0 -13
  184. package/dist/assets/task-board-5PKn7ZaA.js +0 -15
  185. package/dist/assets/workflow-Dg9jCKOq.js +0 -179
@@ -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": "开始新项目",
@@ -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
+ };
@@ -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
+ }
@@ -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
  /**
@@ -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,19 @@
1
+ // A hybrid project's rig take that stopped paying: the compile result says so in one line, so the
2
+ // agent proposes capsule figures before more repairs instead of reading output.stuck as advisory
3
+ // (bin/agent/studio-prompt.mjs, the hybrid cast gate).
4
+
5
+ /** QA group kinds that a rig rarely repairs by more compiles: contact, collision, balance, body. */
6
+ export const HYBRID_HARD_KINDS = Object.freeze(["LOAD", "COLLIDE", "BODY", "SELF", "PERSON"]);
7
+ export const HYBRID_CAPSULE_HINT = "hybrid project: propose capsule figures to the user before more repairs";
8
+
9
+ /**
10
+ * The hint for a compile in a project with this castStyle, or null. It fires in a hybrid project
11
+ * only on a failing take (qa.ok false), when the repair watch said stuck or cap, or when the worst
12
+ * non-GOAL group (qa.groups is ranked) is hard. A stuck note on a passing take (the watch's
13
+ * "already passed QA" polish) is no reason to leave the rig.
14
+ */
15
+ export function hybridCapsuleHint(castStyle, { stuck, qa } = {}) {
16
+ if (castStyle !== "hybrid" || qa?.ok !== false) return null;
17
+ const top = (qa?.groups ?? []).find(group => group.kind !== "GOAL");
18
+ return stuck || HYBRID_HARD_KINDS.includes(top?.kind) ? HYBRID_CAPSULE_HINT : null;
19
+ }
@@ -7,7 +7,8 @@
7
7
  // observe output.observations.load: each hand that carries something, { object, mass, maxNm, at, frames }
8
8
  // LOAD "<side> holds <id> at <x> N m": past QA_LIMITS-style ONE_HAND N m with no second hand on the object; its
9
9
  // hint asks for the other hand, and when the right hand holds, a patch adds a left reachGrip that arrives at
10
- // the grab on the object's face nearest the left shoulder (second-hand.js holds only a right-hand carry)
10
+ // the grab on the object's face nearest the left shoulder, with the other faces as candidates repair compiles
11
+ // and picks from (repair.js; second-hand.js holds only a right-hand carry)
11
12
  // fix.grasp false turns all three off: the take of before.
12
13
  import { add, sub, scale, norm, apply, T, len } from "./compiler/math.js";
13
14
  import { SIDES } from "./compiler/pose.js";
@@ -18,6 +19,9 @@ import { handOf, spanOf, partsOf, objectPoseAt } from "./object-pose.js";
18
19
  import { SETTLE, restOf } from "./seat.js";
19
20
 
20
21
  const REACH = 10, G = 9.81, LIGHT = 2, SAG_MAX = 0.06, SAG_PER_KG = 0.006, ONE_HAND = 25, SECOND = 0.5;
22
+ // the left palm's axis sits a palm radius (qa/grasp-checks.js PALM) out of the face, so its skin is on it, not its bone;
23
+ // on the 12 kg box at 0 the forearm grazed the edge (+X face: QA 42), at 0.022 none (QA 0); at most FACES candidates
24
+ const STANDOFF = 0.022, FACES = 4;
21
25
  export const LOAD_UNITS = "load: what each hand carries, kg, and its most N m (mass x 9.81 x the shoulder-to-wrist distance across) at f; frames [first, last] held";
22
26
 
23
27
  const graspOn = spec => spec?.fix !== false && spec?.fix?.grasp !== false;
@@ -91,19 +95,21 @@ export function loadChecks(sk, out) {
91
95
  }
92
96
 
93
97
  /**
94
- * The anchor point (object local) for the left hand on carry id at frame f: the centre of the face nearest the left
95
- * shoulder, of the faces other than the bottom and the one the right palm is on (the box's other side, as two hands hold it).
98
+ * The anchor points (object local) for the left hand on carry id at frame f, nearest the left shoulder first: the
99
+ * centres of the faces other than the bottom and the one the right palm is on (the box's other side, as two hands hold
100
+ * it), each STANDOFF out of its face. Which face is cheapest the distance does not tell (on the 12 kg box the top won
101
+ * by 0.3 mm and sent the hand through the edge: QA 121, the +X face 42), so repair compiles them all (repair.js).
96
102
  */
97
- function otherFace(out, id, f) {
103
+ function otherFaces(out, id, f) {
98
104
  const { sk, scene } = out.ctx, s = out.frames[f - (out.f0 ?? 0)]?.s, pose = objectPoseAt(out, id, f), rest = restOf(scene, id);
99
105
  const part = scene.objects[partsOf(scene, id)[0]] ?? scene.objects[id];
100
- if (!s || !pose || !part?.half) return null;
106
+ if (!s || !pose || !part?.half) return [];
101
107
  const toLocal = p => apply(T(pose.R), sub(p, pose.pos)), c = apply(T(rest.R), sub(add(part.pos, apply(part.R, [0, part.half[1], 0])), rest.pos));
102
108
  const palm = toLocal(scale(add(s.P[sk.index.RightHand], s.P[sk.index.RightHandMiddle1]), 0.5)), shoulder = toLocal(s.P[sk.index.LeftArm]);
103
109
  const faces = [0, 1, 2].flatMap(i => [-1, 1].map(sg => { const p = c.slice(); p[i] += sg * part.half[i]; return { p, i, sg }; })).filter(x => !(x.i === 1 && x.sg < 0));
104
110
  const palmFace = faces.reduce((m, x) => (Math.abs(palm[x.i] - x.p[x.i]) < Math.abs(palm[m.i] - m.p[m.i]) ? x : m));
105
- const best = faces.filter(x => x !== palmFace).reduce((m, x) => (len(sub(x.p, shoulder)) < len(sub(m.p, shoulder)) ? x : m));
106
- return best.p.map(x => Math.round(x * 1000) / 1000);
111
+ const off = x => x.p.map((v, i) => Math.round((i === x.i ? v + x.sg * STANDOFF : v) * 1000) / 1000);
112
+ return faces.filter(x => x !== palmFace).map(x => ({ x, d: len(sub(x.p, shoulder)) })).sort((a, b) => a.d - b.d).slice(0, FACES).map(({ x }) => off(x));
107
113
  }
108
114
 
109
115
  export const loadHints = {
@@ -114,12 +120,13 @@ export const loadHints = {
114
120
  const k = side === "left" ? "l" : "r", other = k === "r" ? "left" : "right", [grab] = spanOf(X.out, o), kg = massOf(X.spec, X.out.ctx.scene, id);
115
121
  const plain = { cause: `objects.${id} (${kg.toFixed(1)} kg)`, hint: `${id} weighs ${kg.toFixed(1)} kg: ${g.worst.toFixed(0)} N m on the ${side} arm at f${g.worstFrame} (one hand holds ${ONE_HAND}); put the ${other} hand on it too (a reachGrip on an object anchor on ${id} from f${grab}), or carry it closer to the body` };
116
122
  if (k !== "r") return plain;
117
- const local = otherFace(X.out, id, Math.max(grab, Math.min(g.worstFrame, spanOf(X.out, o)[1])));
118
- if (!local) return plain;
123
+ const faces = otherFaces(X.out, id, Math.max(grab, Math.min(g.worstFrame, spanOf(X.out, o)[1])));
124
+ if (!faces.length) return plain;
119
125
  const name = `${id}Other`;
120
126
  // a big face takes the flat palm against it (a power grip's thumb would go into the box)
121
- const anchor = { kind: "object", object: id, local, grasp: "palm" };
122
- return { ...plain, fix: { ops: user => [user.anchors ? { op: "add", path: `/anchors/${name}`, value: anchor } : { op: "add", path: "/anchors", value: { [name]: anchor } },
123
- { op: "add", path: "/use/-", value: { skill: "reachGrip", f0: Math.max(0, grab - REACH), args: { anchor: name, side: "l", reach: Math.min(REACH, grab) || 1, hold: Math.max(1, spanOf(X.out, o)[1] - grab), release: 1 } } }] } };
127
+ const ops = local => user => { const anchor = { kind: "object", object: id, local, grasp: "palm" };
128
+ return [user.anchors ? { op: "add", path: `/anchors/${name}`, value: anchor } : { op: "add", path: "/anchors", value: { [name]: anchor } },
129
+ { op: "add", path: "/use/-", value: { skill: "reachGrip", f0: Math.max(0, grab - REACH), args: { anchor: name, side: "l", reach: Math.min(REACH, grab) || 1, hold: Math.max(1, spanOf(X.out, o)[1] - grab), release: 1 } } }]; };
130
+ return { ...plain, fix: { ops: ops(faces[0]), candidates: faces.map(ops) } };
124
131
  },
125
132
  };
@@ -96,10 +96,22 @@ function probes(X, f, nm) {
96
96
  return Object.entries(diag.props?.[nm.slice(5)] ?? {}).filter(([k, p]) => Array.isArray(p) && k !== "origin").map(([, p]) => p);
97
97
  }
98
98
 
99
+ /** The most a hand's reachGrip world anchor rises over a scene box's top it went into (#819: past ~3.5 cm the fingers miss the thing). */
100
+ const GRIP_LIFT = 0.035;
101
+ /** A hand whose reachGrip world anchor sits too low over a scene box's top: that anchor rises by what the hit lacks. */
102
+ function gripLift(X, g, part, id, need) {
103
+ const f = g.worstFrame, k = sideOf(part), box = X.out.objectsAt(X.rel(f))[id], d = X.hand(k, f);
104
+ const grip = d.anchor && X.keys(d.ch).some(key => / reachGrip$/.test(key.src));
105
+ if (!/^hand_[lr]$/.test(part) || !box || X.spec.objects?.[id] || !grip || X.spec.anchors?.[d.anchor]?.kind !== "world" || !(need > 0 && need <= GRIP_LIFT)) return null;
106
+ return deepest(X, f, part, box).n[1] > 0.5 ? moveHint(X, part, f, [0, need, 0], ` (over ${id}'s top)`) : null;
107
+ }
108
+
99
109
  export const contactHints = {
100
110
  COLLIDE(X, g) {
101
111
  const [, part, id] = /^(\S+) x (.+)$/.exec(g.subject) ?? [];
102
112
  if (/^foot_[lr]$/.test(part ?? "")) return footHint(X, g, part, id);
113
+ const lift = gripLift(X, g, part, id, g.worst - g.limit + MARGIN);
114
+ if (lift) return lift;
103
115
  const r = boxHint(X, g, part, id, g.worst - g.limit + MARGIN);
104
116
  if (r && /^(hand|farm)_/.test(part) && !X.spec.fix?.penetration && g.worst - g.limit <= 0.05) {
105
117
  r.hint += "; or fix.penetration: 0.02 pushes shallow hand hits out";
@@ -7,6 +7,8 @@
7
7
  // { path: "/anchors/cupHandle/p", delta: [dx, dy, dz] } move a spec value by delta
8
8
  // { channel: "root", f, delta, hold: true } move the body that stands at f
9
9
  // { ops: userSpec => [{ op, path, value }] } the operations for the agent's spec (a second hand: load.js)
10
+ // + candidates: [ops, ...] other ways to the same end, which compile repair alone can
11
+ // price: g.candidates (non-enumerable, never in the output)
10
12
  // Each becomes g.patch = [{ op, path, value }] against opts.userSpec (the spec as the agent sent
11
13
  // it, use[] not expanded): a user key that sets the channel within f +-2 -> replace
12
14
  // /keys/<i>/set/<ch> (a delta: with the agent's keys next to it that hold the same value, so a held
@@ -177,8 +179,9 @@ export function attachPatches(groups, { X, userSpec } = {}) {
177
179
  for (const g of groups) {
178
180
  if (!g.fix) continue;
179
181
  try {
180
- const patch = patchOf(g.fix, X, userSpec);
182
+ const patch = patchOf(g.fix, X, userSpec), more = patch && (g.fix.candidates ?? []).map(ops => patchOf({ ops }, X, userSpec)).filter(Boolean);
181
183
  if (patch) g.patch = patch;
184
+ if (more?.length > 1) Object.defineProperty(g, "candidates", { value: more, enumerable: false, configurable: true });
182
185
  } catch {
183
186
  // a fix the context cannot place (no frame, no key table) gets no patch
184
187
  }
@@ -2,7 +2,8 @@
2
2
  // (hint-check.js) offers a patch by. Better means all of
3
3
  // less the summed severity (severity.js) drops
4
4
  // goals no more goals fail, and every goal that held still holds
5
- // problems no more issue groups than before
5
+ // problems no more issue groups than before, unless a HARD group is gone and every new group is SMALL: a second
6
+ // hand that ends a LOAD may leave a forearm grazing the box, which the next repair round can finish
6
7
  // trade what got worse (a new group, or a group whose severity rose) costs at most half of what got better
7
8
  // Logs: a hint patch applied as sent made the take worse 49% of the time, and repair moved the root and broke the arms;
8
9
  // a lower sum alone let a change through that traded one failure for several new ones.
@@ -13,6 +14,9 @@ const sevOf = qa => groupsOf(qa).reduce((sum, g) => sum + (g.severity ?? 0), 0);
13
14
  const heldOf = qa => (qa?.goals ?? []).filter(g => g.ok).map(g => g.name);
14
15
  const failedOf = qa => (qa?.goals ?? []).filter(g => !g.ok).length;
15
16
  const TRADE = 0.5;
17
+ // failures the take cannot keep (a hand that cannot hold its load), and the severity (severity.js) a new group may have
18
+ // for a change that ends one to pass with more groups: 5 is a few frames just past a limit, never a held failure
19
+ const HARD = new Set(["LOAD"]), SMALL = 5;
16
20
  const r2 = x => Math.round(x * 100) / 100;
17
21
 
18
22
  /** The group severities by key (severity.js groupKey). */
@@ -35,9 +39,10 @@ export function compareQa(next, base) {
35
39
  worse.sort((x, y) => y.severity[1] - y.severity[0] - (x.severity[1] - x.severity[0]));
36
40
  const sev = [r2(sevOf(base)), r2(sevOf(next))], problems = [groupsOf(base).length, groupsOf(next).length];
37
41
  const lost = heldOf(base).filter(n => !heldOf(next).includes(n));
42
+ const ends = groupsOf(base).some(g => HARD.has(g.kind) && !b.has(groupKey(g))) && [...b].every(([k, v]) => a.has(k) || v <= SMALL);
38
43
  const why = !(sevOf(next) < sevOf(base) - 1e-9) ? `summed severity ${sev[0]} -> ${sev[1]}`
39
44
  : failedOf(next) > failedOf(base) || lost.length ? `goal ${lost.length ? `"${lost[0]}" no longer holds` : "failures rose"}`
40
- : problems[1] > problems[0] ? `problems ${problems[0]} -> ${problems[1]}`
45
+ : problems[1] > problems[0] && !ends ? `problems ${problems[0]} -> ${problems[1]}`
41
46
  : rise > TRADE * drop + 1e-9 ? `${worse[0]?.key} got worse (${worse[0]?.severity.join(" -> ")})` : "";
42
47
  return { better: !why, why, sev, problems, worse: worse.slice(0, 3) };
43
48
  }