cozyclay 3.0.3 → 3.1.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 (246) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/INSTALL_FOR_AGENTS.md +146 -0
  3. package/README.md +74 -192
  4. package/THIRD_PARTY_NOTICES.md +0 -21
  5. package/bin/agent/agent-routes.mjs +29 -62
  6. package/bin/agent/agent-runner.mjs +5 -3
  7. package/bin/agent/main-agent.mjs +70 -0
  8. package/bin/agent/main-prompt.mjs +17 -0
  9. package/bin/agent/plan-tool.mjs +114 -0
  10. package/bin/agent/script-tool.mjs +2 -0
  11. package/bin/agent/sculpt-sketch.mjs +190 -0
  12. package/bin/agent/session-store.mjs +4 -3
  13. package/bin/agent/sketch-code.mjs +151 -0
  14. package/bin/agent/sketch-sandbox.mjs +102 -0
  15. package/bin/agent/studio-prompt.mjs +1 -1
  16. package/bin/agent/studio-task-routes.mjs +185 -0
  17. package/bin/agent/studio-tasks.mjs +156 -0
  18. package/bin/agent/studio-tools.mjs +3 -2
  19. package/bin/agent/studio-turn-transaction.mjs +76 -0
  20. package/bin/agent/turn-frames.mjs +4 -0
  21. package/bin/cozyclay.mjs +5 -7
  22. package/dist/app/index.html +7 -6
  23. package/dist/assets/TaskBoard-ngdqiu7u.js +1 -0
  24. package/dist/assets/app-BlwOpest.css +1 -0
  25. package/dist/assets/app-DmR7jBkz.js +5144 -0
  26. package/dist/assets/compile-worker-D0pKioa0.js +5 -0
  27. package/dist/assets/landing-y3EqDgSZ.js +1 -0
  28. package/dist/assets/modulepreload-polyfill-P2Xu9kJm.js +1 -0
  29. package/dist/assets/motion3-compile-BB3_1-EB.js +1 -0
  30. package/dist/assets/scene-asset-sync-CBx7XRti.css +1 -0
  31. package/dist/assets/scene-asset-sync-Cgdtr1Yw.js +13 -0
  32. package/dist/assets/task-board-ErQvfoaz.js +14 -0
  33. package/dist/assets/workflow-zQM-EKW1.js +179 -0
  34. package/dist/assets/x-bot-tpose-body-BusyR3eg.json +1 -0
  35. package/dist/assets/y-bot-tpose-body-Dy1VKChZ.json +1 -0
  36. package/dist/cozyclay-package.json +1 -1
  37. package/dist/greybox-to-video/index.html +2 -3
  38. package/dist/index.html +7 -57
  39. package/dist/llms.txt +2 -1
  40. package/dist/previs-software/index.html +2 -2
  41. package/dist/privacy/index.html +3 -7
  42. package/dist/seedance-camera-control/index.html +2 -2
  43. package/dist/sitemap.xml +5 -5
  44. package/dist/sw.js +5 -1
  45. package/dist/thumbs/blank-stage-dark.webp +0 -0
  46. package/dist/thumbs/blank-stage-light.webp +0 -0
  47. package/dist/workflow/index.html +5 -4
  48. package/mcp/mesh-file.mjs +3 -1
  49. package/mcp/server.mjs +3 -0
  50. package/mcp/tool-handlers.mjs +2 -2
  51. package/package.json +5 -3
  52. package/src/App.jsx +51 -282
  53. package/src/analytics.js +3 -148
  54. package/src/app-stage.jsx +1 -1
  55. package/src/asset-pane.jsx +5 -10
  56. package/src/auto-flat.js +10 -0
  57. package/src/bus-tasks.js +11 -5
  58. package/src/command-bus.js +1 -1
  59. package/src/commands/elements.js +4 -3
  60. package/src/commands/motion3-compile.js +51 -27
  61. package/src/commands/motion3.js +3 -3
  62. package/src/commands/objects.js +1 -1
  63. package/src/commands/project.js +1 -1
  64. package/src/commands/shot.js +16 -4
  65. package/src/controls.jsx +0 -11
  66. package/src/cuts.js +11 -0
  67. package/src/domains/cast.js +3 -2
  68. package/src/domains/motion.js +3 -10
  69. package/src/domains/playback.js +32 -0
  70. package/src/domains/scenes.js +3 -22
  71. package/src/domains/shots.js +5 -5
  72. package/src/dualview.jsx +11 -15
  73. package/src/hierarchy-panel.css +18 -4
  74. package/src/hierarchy-panel.jsx +12 -11
  75. package/src/kits/held.js +3 -2
  76. package/src/light-blockers.js +2 -2
  77. package/src/main.jsx +5 -0
  78. package/src/material-look.js +189 -0
  79. package/src/material-presets.js +78 -0
  80. package/src/material-textures.js +157 -0
  81. package/src/motion/ground.js +5 -14
  82. package/src/motion/playback-frame-store.js +79 -0
  83. package/src/motion/playback-probe.js +16 -0
  84. package/src/motion/playback-subject.js +7 -0
  85. package/src/motion/timeline.css +43 -5
  86. package/src/motion/timeline.jsx +4 -3
  87. package/src/motion3/body.js +37 -5
  88. package/src/motion3/carried.js +11 -3
  89. package/src/motion3/compile-job.js +1 -1
  90. package/src/motion3/compiler/compile.js +7 -1
  91. package/src/motion3/compiler/finger-close.js +137 -0
  92. package/src/motion3/compiler/limbs.js +18 -3
  93. package/src/motion3/compiler/pose.js +6 -0
  94. package/src/motion3/compiler/validate-objects.js +17 -2
  95. package/src/motion3/compiler/validate-parts.js +2 -1
  96. package/src/motion3/compiler/vocab.js +4 -4
  97. package/src/motion3/describe.js +2 -2
  98. package/src/motion3/fix/index.js +5 -3
  99. package/src/motion3/flight.js +38 -0
  100. package/src/motion3/free-motion.js +10 -7
  101. package/src/motion3/load.js +125 -0
  102. package/src/motion3/mass.js +23 -0
  103. package/src/motion3/object-keys.js +11 -6
  104. package/src/motion3/object-pose.js +11 -3
  105. package/src/motion3/pipeline.js +3 -0
  106. package/src/motion3/qa/explain.js +3 -1
  107. package/src/motion3/qa/grasp-checks.js +123 -0
  108. package/src/motion3/qa/hints-grasp.js +71 -0
  109. package/src/motion3/qa/index.js +3 -1
  110. package/src/motion3/qa/object-checks.js +7 -2
  111. package/src/motion3/qa/patch.js +2 -1
  112. package/src/motion3/qa/spec-checks.js +10 -1
  113. package/src/motion3/schema/fields.js +3 -3
  114. package/src/motion3/schema/topics.js +9 -8
  115. package/src/motion3/schema/types.js +4 -1
  116. package/src/motion3/seat.js +84 -0
  117. package/src/motion3/settle.js +11 -6
  118. package/src/motion3/shapes.js +70 -0
  119. package/src/motion3/skills/catch.js +71 -0
  120. package/src/motion3/skills/index.js +10 -3
  121. package/src/motion3/skills/info.js +13 -0
  122. package/src/motion3/skills/throw.js +72 -0
  123. package/src/object-catalog.jsx +0 -4
  124. package/src/object-gizmo.jsx +2 -1
  125. package/src/object-look.js +9 -5
  126. package/src/object-path.js +1 -1
  127. package/src/panels/ProjectPanel.jsx +42 -21
  128. package/src/panels/details.css +15 -4
  129. package/src/popover.js +37 -0
  130. package/src/posestudio.jsx +15 -10
  131. package/src/primitive-look.js +38 -0
  132. package/src/project-browser-art.jsx +19 -0
  133. package/src/project-browser.css +74 -121
  134. package/src/project-browser.jsx +36 -60
  135. package/src/props.jsx +24 -35
  136. package/src/render-passes.js +7 -1
  137. package/src/retired-kinds.js +37 -0
  138. package/src/scene-objects.js +5 -9
  139. package/src/scenes.js +4 -5
  140. package/src/sculpt-colours.js +65 -0
  141. package/src/sculpt-compare.js +13 -50
  142. package/src/sculpt-csg.js +112 -0
  143. package/src/sculpt-curve.js +123 -0
  144. package/src/sculpt-cutters.js +43 -0
  145. package/src/sculpt-detail.js +207 -0
  146. package/src/sculpt-geometry-loft-convex.js +100 -0
  147. package/src/sculpt-geometry-shapes.js +250 -0
  148. package/src/sculpt-geometry.js +33 -15
  149. package/src/sculpt-loft-rings.js +225 -0
  150. package/src/sculpt-noise.js +65 -0
  151. package/src/sculpt-part-fields.js +27 -0
  152. package/src/sculpt-recipe-read.js +65 -0
  153. package/src/sculpt-recipe.js +33 -75
  154. package/src/sculpt-run.js +1 -1
  155. package/src/sculpt-scatter-items.js +183 -0
  156. package/src/sculpt-scatter-shape.js +57 -0
  157. package/src/sculpt-scatter.js +124 -0
  158. package/src/sculpt-shapes-curved.js +211 -0
  159. package/src/sculpt-shapes-loft-convex.js +218 -0
  160. package/src/sculpt-shapes.js +138 -0
  161. package/src/sculpt-sketch-state.js +76 -0
  162. package/src/sculpt-sketch.jsx +124 -0
  163. package/src/sculpt-surface-fields.js +65 -0
  164. package/src/settings-menu.jsx +1 -20
  165. package/src/shell/BottomDock.jsx +9 -9
  166. package/src/shell/MenuBar.jsx +1 -9
  167. package/src/shell/PreferencesDialog.jsx +3 -4
  168. package/src/shell/ViewportToolbar.jsx +3 -3
  169. package/src/shell/add-camera-shot.js +11 -0
  170. package/src/shell/glass-regions.css +3 -2
  171. package/src/shell/glass.css +0 -5
  172. package/src/shell/lens-scrub-math.js +51 -0
  173. package/src/shell/lens-scrub.css +73 -0
  174. package/src/shell/lens-scrub.jsx +98 -0
  175. package/src/shell/mode.css +6 -5
  176. package/src/shell/preferences.css +1 -32
  177. package/src/shell/shell.css +18 -1
  178. package/src/shell/studio-shell-context.js +5 -0
  179. package/src/shot-frame.js +26 -0
  180. package/src/stage-canvas-rigs.jsx +7 -8
  181. package/src/stage-characters.jsx +26 -0
  182. package/src/stage-gizmo.js +43 -0
  183. package/src/stage-labels-ko.js +0 -6
  184. package/src/stage-startup-defaults.js +3 -9
  185. package/src/starter-scenes.js +5 -11
  186. package/src/studio-agent-commands.js +2 -2
  187. package/src/studio-agent-protocol.js +2 -7
  188. package/src/studio-app-binding.js +1 -1
  189. package/src/studio-elements.js +6 -2
  190. package/src/styles/themes.css +13 -0
  191. package/src/styles.css +6 -674
  192. package/src/text/text-object.jsx +1 -1
  193. package/src/ui.jsx +8 -16
  194. package/src/use-embed-bridge.js +3 -6
  195. package/src/use-render-activity.js +3 -1
  196. package/src/view-ghosts.jsx +5 -3
  197. package/src/viewport-visible-rect.js +37 -0
  198. package/src/workflow/AgentPanel.jsx +79 -46
  199. package/src/workflow/AgentPlanCard.jsx +41 -0
  200. package/src/workflow/AgentProgressCard.jsx +1 -1
  201. package/src/workflow/TaskBoard.jsx +102 -0
  202. package/src/workflow/TaskInline.jsx +114 -0
  203. package/src/workflow/agent-client.js +25 -36
  204. package/src/workflow/agent-panel.css +14 -1
  205. package/src/workflow/agent-plan.css +93 -0
  206. package/src/workflow/agent-plan.js +59 -0
  207. package/src/workflow/attachment-image.js +10 -3
  208. package/src/workflow/attachment-video.js +91 -0
  209. package/src/workflow/task-board.css +98 -0
  210. package/src/workflow/task-board.js +169 -0
  211. package/tools/check-eslint.mjs +2 -1
  212. package/tools/check-knip.mjs +2 -1
  213. package/tools/dev/pages/greybox-to-video.html +2 -3
  214. package/tools/dev/pages/previs-software.html +2 -2
  215. package/tools/dev/pages/privacy.html +3 -7
  216. package/tools/dev/pages/seedance-camera-control.html +2 -2
  217. package/tools/dev-full.mjs +11 -2
  218. package/tools/eslint-baseline.json +6 -6
  219. package/tools/knip-baseline.json +0 -11
  220. package/tools/loc-baseline.json +30 -31
  221. package/tools/metrics-snapshot.mjs +43 -5
  222. package/tools/render-start-thumbs.mjs +175 -0
  223. package/tools/run-tests.mjs +10 -17
  224. package/tools/sitemap.mjs +1 -1
  225. package/dist/assets/analytics-BibyE5ar.js +0 -6
  226. package/dist/assets/app-DhRJ-PBz.js +0 -5088
  227. package/dist/assets/app-f-fkV5uP.css +0 -1
  228. package/dist/assets/compile-worker-DqiA4AaN.js +0 -5
  229. package/dist/assets/first-shot-handoff-Qx7SKl38.js +0 -1
  230. package/dist/assets/landing-DYg87MDY.js +0 -1
  231. package/dist/assets/scene-asset-sync-DBYQBR0T.js +0 -20
  232. package/dist/assets/scene-asset-sync-_S8NQl5B.css +0 -1
  233. package/dist/assets/workflow-BSA22Ofr.js +0 -179
  234. package/dist/scenes/city-block.cclayproject +0 -1
  235. package/src/burn-in.js +0 -91
  236. package/src/camera-tutorial.jsx +0 -520
  237. package/src/facing-marks.js +0 -88
  238. package/src/first-shot-handoff.js +0 -44
  239. package/src/panels/motion.css +0 -197
  240. package/src/set-pieces.jsx +0 -183
  241. package/src/tutorial-analytics.js +0 -86
  242. package/src/tutorial-walk.js +0 -17
  243. package/src/use-camera-tutorial.js +0 -50
  244. package/src/use-semantic-state.js +0 -12
  245. package/tools/metrics/archive-traffic.mjs +0 -138
  246. package/tools/perf/ik-camera-drag.mjs +0 -138
@@ -0,0 +1,137 @@
1
+ // Fingers close on what the hand holds (#816). A preset curls each phalanx a fixed angle, which fits one size: on a
2
+ // ball the index and middle sank 3 cm into it while the little finger stayed in the air (session 7f15b4ae). After the
3
+ // arm, the hold and the elbow's swivel have placed the hand (pose.js), each finger closes phalanx by phalanx, from the
4
+ // knuckle out: the joint turns further about the axis it curls on until the rest of the finger first touches a held
5
+ // shape (shapes.js; its skin within TOUCH m of the surface), never past the fist's curl; a finger that cannot reach it
6
+ // keeps the preset's curl (closing on air would curl it into whatever lies under the palm). The thumb keeps the preset's
7
+ // turn at its base and flexes its outer joints the same way. Found by bisection (STEPS evaluations a joint) on the joints' positions,
8
+ // then turned once; weighted by how far the hand holds (a fit's weight), so the fingers close over the reach and open
9
+ // on the release. A joint the preset curled into the body straightens when that leaves the finger shallower; a
10
+ // joint whose sub-chain already touches stays, and the next joint out closes on: the finger wraps the body.
11
+ import { add, sub, scale, norm, cross, apply, axisRot } from "./math.js";
12
+ import { rotateSubtree } from "./skeleton.js";
13
+ import { palmAxes, fingerCurl, fistCurl } from "./limbs.js";
14
+ import { capsuleDepth } from "../shapes.js";
15
+ import { partsOf } from "../object-pose.js";
16
+ import { seatsInHand } from "../seat.js";
17
+
18
+ const PHALANX = 0.009, TOUCH = 0.002, STEPS = 8, THUMB_FLEX = 70;
19
+ const FINGERS = ["Index", "Middle", "Ring", "Pinky"];
20
+ // the hand stays open for most of a reach and closes as it arrives: from OPEN_UNTIL of the fit's weight to CLOSED_AT
21
+ const OPEN_UNTIL = 0.6, CLOSED_AT = 0.9;
22
+ const closing = w => { const t = Math.min(1, Math.max(0, (w - OPEN_UNTIL) / (CLOSED_AT - OPEN_UNTIL))); return t * t * (3 - 2 * t); };
23
+
24
+ function chainDepth(points, entries) {
25
+ let depth = -Infinity;
26
+ for (let i = 0; i + 1 < points.length; i++) for (const e of entries) depth = Math.max(depth, capsuleDepth(e, points[i], points[i + 1], PHALANX));
27
+ return depth;
28
+ }
29
+
30
+ /** The turn (deg, 0..limit) about unit `axis` at `pivot` at which the chain first touches: 0 when it already does, null when it never does. */
31
+ function touchTurn(points, pivot, axis, limit, entries) {
32
+ if (limit <= 0 || chainDepth(points, entries) >= -TOUCH) return 0;
33
+ const at = deg => { const R = axisRot(axis, deg); return points.map(p => add(pivot, apply(R, sub(p, pivot)))); };
34
+ if (chainDepth(at(limit), entries) < -TOUCH) return null;
35
+ let lo = 0, hi = limit;
36
+ for (let i = 0; i < STEPS; i++) { const mid = (lo + hi) / 2; if (chainDepth(at(mid), entries) >= -TOUCH) hi = mid; else lo = mid; }
37
+ return hi;
38
+ }
39
+
40
+ /** The turn (deg, 0..limit) back about `axis` that takes a chain inside the body just out of it (0 when it is out, or when
41
+ * no opening leaves it shallower). */
42
+ function clearTurn(points, pivot, axis, limit, entries) {
43
+ if (chainDepth(points, entries) < -TOUCH) return 0;
44
+ const at = deg => { const R = axisRot(axis, -deg); return points.map(p => add(pivot, apply(R, sub(p, pivot)))); };
45
+ // a chain that stays in the body however far it opens opens only if that leaves it shallower
46
+ if (chainDepth(at(limit), entries) >= -TOUCH) return chainDepth(at(limit), entries) < chainDepth(points, entries) ? limit : 0;
47
+ let lo = 0, hi = limit;
48
+ for (let i = 0; i < STEPS; i++) { const mid = (lo + hi) / 2; if (chainDepth(at(mid), entries) < -TOUCH) hi = mid; else lo = mid; }
49
+ return hi;
50
+ }
51
+
52
+ /** Turns joint j by the touching turn about `axis` (or `limit` when it never touches, unless `air` is false), weighted; true when it touched. */
53
+ function closeJoint(sk, s, j, chain, axis, limit, entries, weight, air = true) {
54
+ const turn = touchTurn(chain.map(i => s.P[i]), s.P[j], axis, limit, entries), deg = turn ?? (air ? limit : 0);
55
+ if (deg > 0) rotateSubtree(sk, s, j, s.P[j].slice(), axisRot(axis, deg * weight));
56
+ return turn !== null;
57
+ }
58
+
59
+ /**
60
+ * closeOnShape(sk, s, side, { entries, weight, preset }): the hand on pose s ("Left" | "Right") closes on the shape
61
+ * entries (motion-scene entries at this frame) by `weight` 0..1; `preset` is the hand's finger weights (its fingers_<k>
62
+ * channel), from which each joint may close as far as the fist's curl. Answers { touched: the fingers that reached it }.
63
+ */
64
+ export function closeOnShape(sk, s, side, { entries: held, stops = [], weight, preset }) {
65
+ if (!held?.length || !(weight > 0)) return { touched: [] };
66
+ // a finger stops on what it holds or on what that rests against (a table under a ball)
67
+ const entries = [...held, ...stops];
68
+ const sign = side === "Left" ? -1 : 1, J = name => sk.index[`${side}Hand${name}`], touched = [];
69
+ for (const f of FINGERS) {
70
+ const joints = [1, 2, 3, 4].map(i => J(`${f}${i}`));
71
+ if (joints.some(j => j === undefined)) continue;
72
+ let tip = false;
73
+ // a finger the preset curled into the body straightens, joint by joint, when the straight finger is shallower (a
74
+ // finger lying across a box would go deeper); each joint then closes until the finger touches
75
+ const pts = joints.map(i => s.P[i]), l0 = scale(palmAxes(sk, s, side).l, sign);
76
+ const straight = [0, 1, 2].reduce((p, k) => { const R = axisRot(l0, -fingerCurl(preset, f, k)); return p.map((q, i) => (i > k ? add(p[k], apply(R, sub(q, p[k]))) : q)); }, pts);
77
+ const opens = chainDepth(pts, held) >= -TOUCH && chainDepth(straight, held) < chainDepth(pts, held);
78
+ for (let k = 0; k < 3; k++) {
79
+ const axis = scale(palmAxes(sk, s, side).l, sign), curl = fingerCurl(preset, f, k), open = opens ? curl : 0;
80
+ if (open > 0) rotateSubtree(sk, s, joints[k], s.P[joints[k]].slice(), axisRot(axis, -open * weight));
81
+ tip = closeJoint(sk, s, joints[k], joints.slice(k), axis, Math.max(0, fistCurl(f, k) - curl + open), entries, weight, false);
82
+ }
83
+ if (tip) touched.push(f);
84
+ }
85
+ const thumb = [1, 2, 3, 4].map(i => J(`Thumb${i}`));
86
+ if (thumb.every(j => j !== undefined)) {
87
+ // the thumb keeps the preset's turn at its base (wrapThumb); its two outer joints back out of the body when the
88
+ // preset wrapped them into it, then flex until the thumb touches (one that cannot reach it keeps the preset's flex)
89
+ const { n } = palmAxes(sk, s, side);
90
+ let reached = false;
91
+ for (const k of [1, 2]) {
92
+ const axis = scale(norm(cross(norm(sub(s.P[thumb[k + 1]], s.P[thumb[k]])), n)), sign);
93
+ const out = clearTurn(thumb.slice(k).map(i => s.P[i]), s.P[thumb[k]], axis, THUMB_FLEX, entries);
94
+ if (out > 0) rotateSubtree(sk, s, thumb[k], s.P[thumb[k]].slice(), axisRot(axis, -out * weight));
95
+ reached = closeJoint(sk, s, thumb[k], thumb.slice(k), axis, THUMB_FLEX, entries, weight, false);
96
+ }
97
+ if (reached) touched.push("Thumb");
98
+ }
99
+ return { touched };
100
+ }
101
+
102
+ /**
103
+ * What hand k closes on at frame f with channels c: the object of its heaviest fit on an object anchor with grasp, while
104
+ * that object is not in its hand yet (a carry's hand holds it by its seat, seat.js), as { entries, weight, preset }; null
105
+ * when it reaches for nothing.
106
+ */
107
+ export function heldFor(ctx, c, f, k, s) {
108
+ let best = null;
109
+ for (const fit of ctx.spec.fits ?? []) {
110
+ const a = ctx.spec.anchors?.[fit.anchor], w = c[`w_${fit.anchor}_${k}`] ?? 0;
111
+ if (fit.side !== k || a?.kind !== "object" || !a.grasp || !(w > 1e-4) || (best && best.weight >= w) || ctx.objectHand(a.object, f) === k) continue;
112
+ // a disc's rim socket (a steering wheel) is a ring the scene draws as a solid disc: the hand keeps its rim grip
113
+ if (ctx.sockets?.get(fit.anchor)?.rim) continue;
114
+ const at = ctx.objectsAt(f), entries = partsOf(ctx.scene, a.object).map(p => at[p]).filter(Boolean);
115
+ // as far as the skill has the hand closing (reachGrip keys its grip late in the reach: skills/reach-human.js)
116
+ const grip = Math.min(1, Object.entries(c[`fingers_${k}`] ?? {}).reduce((acc, [p, v]) => acc + (p === "relaxed" ? 0 : v || 0), 0));
117
+ if (entries.length) best = { entries, weight: closing(w) * grip, preset: c[`fingers_${k}`] };
118
+ }
119
+ return best ?? carriedFor(ctx, c, f, k, s);
120
+ }
121
+
122
+ /** A carry on the grasp path hand k holds at frame f, at its seat on pose s (seat.js), as { entries, weight, preset }. */
123
+ function carriedFor(ctx, c, f, k, s) {
124
+ const { spec, scene } = ctx;
125
+ for (const [id, o] of Object.entries(spec.objects ?? {})) {
126
+ if (!seatsInHand(spec, o, scene, id) || spec.props?.[o.prop]?.hand !== k || !s) continue;
127
+ // the hand holds it from the grab on (the object eases into its seat meanwhile, settleAt)
128
+ const [grab, release] = ctx.carrySpan(o), weight = 1;
129
+ // while the keyed offset is being taken (its grab frame solved without the keys) the hand has nothing seated yet
130
+ if (f < grab || f > release || ctx.hold?.attaching(id)) continue;
131
+ // where it is on this pose (object-pose.js: easing into its seat over the settle, as it is drawn), not the full seat
132
+ const entries = partsOf(scene, id).map(p => ({ ...scene.objects[p], ...ctx.objectPose(p, f, { s }) }));
133
+ const near = Object.entries(ctx.objectsAt(f)).filter(([key, e]) => !partsOf(scene, id).includes(key) && e.half && !e.carried && entries.some(h => Math.hypot(...h.pos.map((v, i) => v - e.pos[i])) < 0.25 + Math.hypot(...e.half))).map(([, e]) => e);
134
+ if (entries.length) return { entries, stops: near, weight, preset: c[`fingers_${k}`] };
135
+ }
136
+ return null;
137
+ }
@@ -158,12 +158,27 @@ const FINGER_PRESETS = {
158
158
  grip: { Index: [55, 60, 35], Middle: [60, 62, 35], Ring: [62, 62, 35], Pinky: [65, 62, 35], Thumb: [15, 10, 8] },
159
159
  cigarette: { Index: [8, 10, 6], Middle: [10, 12, 8], Ring: [60, 75, 45], Pinky: [65, 80, 45], Thumb: [35, 25, 20] },
160
160
  point: { Index: [0, 0, 0], Middle: [80, 95, 60], Ring: [85, 95, 60], Pinky: [85, 95, 60], Thumb: [40, 30, 30] },
161
+ spherical: { Index: [35, 40, 25], Middle: [38, 42, 25], Ring: [40, 42, 25], Pinky: [42, 44, 25], Thumb: [15, 10, 8] },
161
162
  };
163
+ // A ball's fingers fan out round it (#816): each finger turns about the palm normal at its knuckle, degrees toward the
164
+ // thumb side (+), by preset weight; the index leads the thumb round one side, the little finger the other.
165
+ const SPREAD = { spherical: { Index: 9, Middle: 2, Ring: -5, Pinky: -12 } };
166
+
167
+ /** Degrees phalanx k (0..2) of finger f curls at preset weights `weights` (blended over relaxed). */
168
+ export function fingerCurl(weights, f, k) {
169
+ const base = FINGER_PRESETS.relaxed;
170
+ return base[f][k] + Object.entries(weights ?? {}).reduce((acc, [p, w]) => acc + (w || 0) * ((FINGER_PRESETS[p]?.[f]?.[k] ?? base[f][k]) - base[f][k]), 0);
171
+ }
172
+ /** The fist's curl of phalanx k of finger f: as far as a finger closes. */
173
+ export const fistCurl = (f, k) => FINGER_PRESETS.fist[f][k];
162
174
 
163
175
  /** Blend presets by weight over `relaxed` and curl every finger toward the palm. */
164
176
  export function fingers(sk, s, side, weights) {
165
- const base = FINGER_PRESETS.relaxed, sign = side === "Left" ? -1 : 1;
166
- const curl = (f, k) => base[f][k] + Object.entries(weights ?? {}).reduce((acc, [p, w]) => acc + (w || 0) * ((FINGER_PRESETS[p]?.[f]?.[k] ?? base[f][k]) - base[f][k]), 0);
177
+ const sign = side === "Left" ? -1 : 1, curl = (f, k) => fingerCurl(weights, f, k);
178
+ for (const f of ["Index", "Middle", "Ring", "Pinky"]) {
179
+ const spread = Object.entries(weights ?? {}).reduce((acc, [p, w]) => acc + (w || 0) * (SPREAD[p]?.[f] ?? 0), 0), j = sk.index[`${side}Hand${f}1`];
180
+ if (spread && j !== undefined) rotateSubtree(sk, s, j, s.P[j].slice(), axisRot(palmAxes(sk, s, side).n, spread));
181
+ }
167
182
  for (const f of ["Index", "Middle", "Ring", "Pinky", "Thumb"]) {
168
183
  for (let k = 0; k < 3; k++) {
169
184
  const j = sk.index[`${side}Hand${f}${k + 1}`];
@@ -181,7 +196,7 @@ export function fingers(sk, s, side, weights) {
181
196
  // palm and across it, then flexed at its two joints about their own axes (bone x palm normal). The fist's thumb lies over
182
197
  // the index and middle fingers' middle phalanges; the grip's closes around a handle against the index finger. Searched on
183
198
  // both rigs within the joints' ranges (thumb tip to the index's middle phalanx: fist 8.4 -> 3.7 cm Y Bot, 6.9 -> 2.4 X Bot).
184
- const THUMB_WRAP = { fist: [-70, -5, 5, 80], grip: [-30, -30, 30, 10] };
199
+ const THUMB_WRAP = { fist: [-70, -5, 5, 80], grip: [-30, -30, 30, 10], spherical: [-40, -20, 20, 10] };
185
200
  function wrapThumb(sk, s, side, [oppose, across, flex2, flex3]) {
186
201
  const sign = side === "Left" ? -1 : 1, J = k => sk.index[`${side}HandThumb${k}`];
187
202
  if (J(1) === undefined) return;
@@ -6,6 +6,7 @@ import { add, sub, scale, norm, len, apply, mul, rx, ry, rz } from "./math.js";
6
6
  import { restState, rotateSubtree } from "./skeleton.js";
7
7
  import { twoBone, orientFoot, wrist, fingers, limitFold, askedFold, swivelElbow, ELBOW_FOLD, KNEE_FOLD } from "./limbs.js";
8
8
  import { holdHand } from "./hold.js";
9
+ import { closeOnShape } from "./finger-close.js";
9
10
  import { clampWrist } from "./wrist-clamp.js";
10
11
  import { ELBOW_REST } from "./arm-orient.js";
11
12
  import { wristBody, bodyDistance } from "../body.js";
@@ -147,6 +148,9 @@ export function pose(sk, c, hooks = {}) {
147
148
  if (hooks.keepOut) target = hooks.keepOut(k, s, target);
148
149
  const push = hooks.push?.(k);
149
150
  if (push) target = add(target, push);
151
+ // what the hand carries weighs it down (load.js)
152
+ const sag = hooks.sag?.(k, s, target);
153
+ if (sag) target = add(target, sag);
150
154
  // the self fix's push rides the IK as far as the arm is on it (an arm coming in from FK would jump)
151
155
  const skinPush = hooks.skinPush?.(k);
152
156
  if (skinPush) target = add(target, scale(skinPush, turnWeight(w)));
@@ -171,6 +175,8 @@ export function pose(sk, c, hooks = {}) {
171
175
  // the turn keeps the hand's world turn; a wrist it takes past its limits stops there and the hand turns with the elbow
172
176
  // (wrist-clamp.js), unless keys lead what the hand holds: that hand keeps the object's grip (hold.js limits its wrist)
173
177
  if (turn) { swivelElbow(sk, s, side, turn * turnWeight(w)); if (!held) clampWrist(sk, s, side); }
178
+ const grasped = hooks.heldShapes?.(k, s);
179
+ if (grasped) diag[`closed_${k}`] = closeOnShape(sk, s, side, grasped).touched;
174
180
  hooks.afterArm?.(k, s, diag);
175
181
  }
176
182
  return { s, diag };
@@ -7,7 +7,7 @@
7
7
  // of the body. A free entry { keys?, fall?, visible?, level? } (C17: no prop, no follow, no hinge field) moves a body or a
8
8
  // group on its own: world pose keys over the whole take, and gravity falls; a carry takes fall too, before its grab
9
9
  // or from its release on. Fields: schema/fields.js OBJECT_FIELDS, OBJECT_KEY_FIELDS, FALL_FIELDS. See validate.js.
10
- import { isVec3, fmt, didYouMean } from "./vocab.js";
10
+ import { isVec3, fmt, didYouMean, GRASPS } from "./vocab.js";
11
11
  import { objectOk } from "./validate-common.js";
12
12
  import { OBJECT_FIELDS, OBJECT_KEY_FIELDS, FALL_FIELDS } from "../schema/fields.js";
13
13
  import { isSwitch, isFollow, movesRecord } from "../carried.js";
@@ -15,7 +15,7 @@ import { isFree } from "../free-motion.js";
15
15
  import { checkRef } from "../refs.js";
16
16
  import { partsOf } from "../object-pose.js";
17
17
 
18
- const KEY = "{ f, pos: [x, y, z], rot: [x, y, z, w] } or { f, pos, aim: [x, y, z], roll? }", FALL = "{ at, onto?, bounce?, tumble? }";
18
+ const KEY = "{ f, pos: [x, y, z], rot: [x, y, z, w] } or { f, pos, aim: [x, y, z], roll? }", FALL = "{ at, onto?, bounce?, tumble?, v? }";
19
19
  const isQuat = q => Array.isArray(q) && q.length === 4 && q.every(Number.isFinite) && Math.abs(Math.hypot(...q) - 1) <= 0.01;
20
20
  const isDir = v => isVec3(v) && Math.hypot(...v) > 1e-9;
21
21
 
@@ -94,10 +94,23 @@ function validateFalls(err, unknown, at, o, { frames, scene, carry }) {
94
94
  }
95
95
  if (fl.onto !== undefined) objectOk(err, scene, `${fat}.onto`, fl.onto, { groups: true });
96
96
  if (fl.bounce !== undefined && !(Number.isFinite(fl.bounce) && fl.bounce >= 0 && fl.bounce <= 0.6)) err(`${fat}.bounce must be 0..0.6 (the share of its landing speed it bounces back with; 0 lands dead), got ${fmt(fl.bounce)}`);
97
+ if (fl.v !== undefined && !((carry && fl.v === "hand") || isVec3(fl.v))) err(`${fat}.v must be [vx, vy, vz] m/s (the velocity it leaves with)${carry ? " or \"hand\" (the velocity the hand gives it at the release)" : ""}, got ${fmt(fl.v)}`);
97
98
  if (fl.tumble !== undefined && typeof fl.tumble !== "boolean") err(`${fat}.tumble must be true (it rolls to rest on a stable face, the default) or false (it drops straight down as it was), got ${fmt(fl.tumble)}`);
98
99
  });
99
100
  }
100
101
 
102
+ /** A grasp that starts like the given one (4+ letters: "sphere" -> spherical), else the nearest by edit distance. */
103
+ const prefixLen = (a, b) => { let i = 0; while (i < a.length && a[i] === b[i]) i += 1; return i; };
104
+ function graspHint(given) {
105
+ const text = String(given), best = GRASPS.reduce((a, b) => (prefixLen(b, text) > prefixLen(a, text) ? b : a));
106
+ return prefixLen(best, text) >= 4 ? `; did you mean "${best}"?` : didYouMean(text, GRASPS);
107
+ }
108
+
109
+ /** mass (kg): what the object weighs, for the arm that holds it (#816). */
110
+ function massOk(err, at, o) {
111
+ if (o.mass !== undefined && !(Number.isFinite(o.mass) && o.mass >= 0.01 && o.mass <= 60)) err(`${at}.mass must be kg in 0.01..60, got ${fmt(o.mass)}`);
112
+ }
113
+
101
114
  /** visible and level name channels (C15): visible shows the record above 0.5, level scales its light, glow and effect rate. */
102
115
  function channelsOk(err, at, o) {
103
116
  if (o.visible !== undefined && (typeof o.visible !== "string" || !o.visible)) err(`${at}.visible must name a channel (shown above 0.5)`);
@@ -110,6 +123,7 @@ export function validateObjects(spec, err, { sk, scene, frames = spec.frames, un
110
123
  // a switch and a follow take any record (a light, an effect); the others a body or a group
111
124
  if ((kind !== "switch" && kind !== "follow") || !scene?.others?.[id]) objectOk(err, scene, at, id, { groups: true });
112
125
  for (const f of Object.keys(o ?? {})) if (!fields.includes(f) && !(kind === "hinge" && (f === "keys" || f === "fall"))) unknown(`${at} (${kind}) has unknown field "${f}"${didYouMean(f, fields)}; fields: ${fields.join(", ")}`);
126
+ massOk(err, at, o);
113
127
  if (kind === "follow") {
114
128
  validateFollow(spec, err, { sk, scene }, at, o);
115
129
  channelsOk(err, at, o);
@@ -129,6 +143,7 @@ export function validateObjects(spec, err, { sk, scene, frames = spec.frames, un
129
143
  if (kind === "carry") {
130
144
  if (!spec.props?.[o.prop]) err(`${at}.prop "${o.prop}" is not in spec.props`);
131
145
  for (const f of ["at", "along"]) if (o[f] !== undefined && !isVec3(o[f])) err(`${at}.${f} must be [d, l, n] along the palm axes, got ${fmt(o[f])}`);
146
+ if (o.grasp !== undefined && !GRASPS.includes(o.grasp)) err(`${at}.grasp must be one of ${GRASPS.join(", ")}${graspHint(o.grasp)}, got ${fmt(o.grasp)}`);
132
147
  channelsOk(err, at, o);
133
148
  if (frameOk(err, `${at}.grab`, o.grab, frames) && frameOk(err, `${at}.release`, o.release, frames)
134
149
  && o.grab !== undefined && o.release !== undefined && !(o.grab < o.release)) err(`${at}: grab (${o.grab}) must come before release (${o.release})`);
@@ -107,8 +107,9 @@ function validateChecks(spec, err, { sk, scene, frames, unknown = err, past = ""
107
107
  each("allow");
108
108
  for (const name of Object.keys(c.limits ?? {})) if (!(name in QA_LIMITS)) unknown(`checks.limits.${name} is not a QA limit${didYouMean(name, Object.keys(QA_LIMITS))}`);
109
109
  const fix = spec.fix;
110
- if (fix !== undefined && fix !== false && (typeof fix !== "object" || fix === null)) err("spec.fix must be false or { reach, slide, penetration, self, retime }");
110
+ if (fix !== undefined && fix !== false && (typeof fix !== "object" || fix === null)) err(`spec.fix must be false or { ${FIX_KEYS.join(", ")} }`);
111
111
  else for (const k of Object.keys(fix || {})) if (!FIX_KEYS.includes(k)) unknown(`spec.fix.${k} is not a fix${didYouMean(k, FIX_KEYS)} (fixes: ${FIX_KEYS.join(", ")})`);
112
+ if (fix && fix.grasp !== undefined && typeof fix.grasp !== "boolean") err(`spec.fix.grasp must be true (fingers close on what a hand holds, the default) or false (the preset grip, the object at its grab-frame offset), got ${fmt(fix.grasp)}`);
112
113
  }
113
114
 
114
115
  /** opts = { sk, scene, frames, lenient, unknown: err for an unknown field (a no-op when lenient), past: the
@@ -10,13 +10,13 @@ export const NUM_CHANNELS = new Set(["yaw", "pitch", "roll", "spine_yaw", "spine
10
10
  export const MAP_CHANNELS = new Set(sided(["fingers"]));
11
11
  // Channels the skills key (standIdle, walks): valid in a spec, left out of motion.describe so a first spec need not read them.
12
12
  export const SKILL_CHANNELS = new Set(["arm_hang"]);
13
- export const FINGER_PRESETS = ["relaxed", "fist", "grip", "cigarette", "point"];
13
+ export const FINGER_PRESETS = ["relaxed", "fist", "grip", "cigarette", "point", "spherical"];
14
14
  /** The grasps an object anchor takes a hand by (../sockets.js), and the finger preset each closes the hand to by default. */
15
- export const GRASPS = ["power", "pinch", "hook", "palm"];
16
- export const GRASP_FINGERS = { power: "grip", pinch: "cigarette", hook: "grip", palm: "relaxed" };
15
+ export const GRASPS = ["power", "pinch", "hook", "palm", "spherical"];
16
+ export const GRASP_FINGERS = { power: "grip", pinch: "cigarette", hook: "grip", palm: "relaxed", spherical: "spherical" };
17
17
  /** Where the held thing sits in the hand, [d, l, n] on the palm axes from the finger middles (n toward the palm; m): a bar in a
18
18
  * power grip or a hook lies across the palm under the finger middles, a pinch is held at the fingertips, a flat palm presses with its centre. */
19
- export const GRASP_POINTS = { power: [0, 0, 0.02], hook: [0, 0, 0.02], pinch: [0.025, 0, 0.01], palm: [-0.045, 0, 0.015] };
19
+ export const GRASP_POINTS = { power: [0, 0, 0.02], hook: [0, 0, 0.02], pinch: [0.025, 0, 0.01], palm: [-0.045, 0, 0.015], spherical: [-0.015, 0, 0.035] };
20
20
 
21
21
  // [min, max, convention] for numeric channels whose values have a hard range.
22
22
  const RANGES = [
@@ -103,8 +103,8 @@ function describeSkill(name) {
103
103
  const MEANT = { topics: ["objects", "channels"], skills: ["reachGrip"] };
104
104
  /** Topics no round-2 session needed for a first spec: essentials names their parts, the full topic has the fields. */
105
105
  const SHALLOW = ["checks"];
106
- /** Skills essentials leaves to topic skills: a flip or cartwheel is no first spec's need, and essentials has no room. */
107
- const FIRST_SPEC_SKIP = ["flip"];
106
+ /** Skills essentials leaves to topic skills: a flip, a cartwheel or a body thrown by a hit is no first spec's need, and essentials has no room. */
107
+ const FIRST_SPEC_SKIP = ["flip", "hitReaction", "throwObject", "catchObject"];
108
108
 
109
109
  /** A shared shape whose fields are mostly a skill's args (walk, walkTo): its own fields and `...<skill> args`. */
110
110
  function shapeLine(d) {
@@ -16,6 +16,8 @@
16
16
  // retime speed spikes get more frames, borrowed from the neighbouring keys
17
17
  // stance a standIdle stance whose legs, feet or hips are in a scene body slides out of it, the least
18
18
  // distance (skills/stance.js; applied when the skills expand, reported in observations.placed)
19
+ // grasp a holding hand's fingers close on what it holds and the object rests in the hand (#816);
20
+ // false keeps the preset grip and the grab-frame offset
19
21
  import { lowerHips, armReach } from "./reach.js";
20
22
  import { lockFeet } from "./slide.js";
21
23
  import { holdTurns } from "./turn.js";
@@ -24,14 +26,14 @@ import { pushOut } from "./push.js";
24
26
  import { retime } from "./retime.js";
25
27
  import { selfClear } from "./self.js";
26
28
 
27
- const FIX_KEYS = ["reach", "slide", "penetration", "self", "retime", "stance"];
29
+ const FIX_KEYS = ["reach", "slide", "penetration", "self", "retime", "stance", "grasp"];
28
30
  export { FIX_KEYS };
29
31
 
30
32
  export function fixOptions(fix) {
31
- if (fix === false) return { reach: false, slide: false, penetration: 0, self: false, retime: false, stance: false };
33
+ if (fix === false) return { reach: false, slide: false, penetration: 0, self: false, retime: false, stance: false, grasp: false };
32
34
  const f = fix ?? {};
33
35
  const pen = f.penetration === true ? 0.02 : Math.min(0.05, Math.max(0, Number(f.penetration) || 0));
34
- return { reach: f.reach !== false, slide: f.slide !== false, penetration: pen, self: f.self !== false, retime: f.retime === true, stance: f.stance !== false };
36
+ return { reach: f.reach !== false, slide: f.slide !== false, penetration: pen, self: f.self !== false, retime: f.retime === true, stance: f.stance !== false, grasp: f.grasp !== false };
35
37
  }
36
38
 
37
39
  /** The slide report with the flat feet whose turn waited (turn.js): turns { frames, max deg }. */
@@ -0,0 +1,38 @@
1
+ // A fall that leaves with a velocity (#816): a thrown thing. fall.v is the velocity its root leaves with, m/s, or "hand"
2
+ // on a carry: the velocity the hand gives it at the release, its pose a frame after minus a frame before, x fps (the
3
+ // frames beforePose gives the spin from, free-motion.js afterRelease). Without v, and with v zero, a fall is as before.
4
+ // It flies on a parabola (G of free-motion.js) and lands on the first surface its path comes down onto: the highest top
5
+ // whose point under the path at that height lies on that body (closed form per top, no stepping).
6
+ import { add, scale, sub } from "./compiler/math.js";
7
+
8
+ const ZERO = [0, 0, 0];
9
+
10
+ /** The velocity fall leaves with from root pose `start` (`before`: its pose a frame earlier, a hand's release), m/s. */
11
+ export function fallVelocity(fall, start, before, fps) {
12
+ if (Array.isArray(fall?.v)) return fall.v;
13
+ if (fall?.v === "hand" && before) return scale(sub(start.pos, before.pos), fps);
14
+ return ZERO;
15
+ }
16
+
17
+ export const moves = v0 => v0.some(v => v !== 0);
18
+
19
+ /** Seconds until a point h m above its landing, leaving upward at vy m/s, comes down to it (G m/s^2). */
20
+ export const flightTime = (h, vy, G) => (vy + Math.sqrt(vy * vy + 2 * G * Math.max(0, h))) / G;
21
+
22
+ /** The x/z drift of a flight t s in (at most tl s, where it lands). */
23
+ export const drift = (v0, t, tl) => { const u = Math.min(t, tl); return [v0[0] * u, 0, v0[2] * u]; };
24
+
25
+ /**
26
+ * landingOf (free-motion.js) for a fall from `start` leaving at v0: { support, top, h } under where it comes down. `land`
27
+ * is landingOf(spec, scene, id, pose, onto), `tops` the heights of the bodies it may come down on, G gravity. Coming down,
28
+ * it meets the highest surface whose point under its path at that height is on that surface: each top from the highest
29
+ * down, the drift to that height, and the support there kept when it is that top (else the next lower; the floor last).
30
+ */
31
+ export function landingWith(land, start, v0, G, tops = []) {
32
+ const here = land(start), low = here.top + here.h, at = top => ({ pos: add(start.pos, drift(v0, flightTime(low - top, v0[1], G), Infinity)), R: start.R });
33
+ for (const top of [...new Set(tops)].filter(t => t <= low).sort((a, b) => b - a)) {
34
+ const there = land(at(top));
35
+ if (Math.abs(there.top - top) < 1e-9) return there;
36
+ }
37
+ return land(at(0));
38
+ }
@@ -24,6 +24,7 @@ import { groupsOf } from "./scene.js";
24
24
  import { curveOf, keyedPose } from "./object-keys.js";
25
25
  import { add, apply, sub, T, quatFromMat, I3 } from "./compiler/math.js";
26
26
  import { shapeOf, airPath, settlePath, settledAt, spinOf, spinEnergy, TIP } from "./settle.js";
27
+ import { fallVelocity, moves, flightTime, drift, landingWith } from "./flight.js";
27
28
 
28
29
  /** Gravity, m/s^2. */
29
30
  export const G = 9.8;
@@ -97,24 +98,26 @@ function shapeIn(scene, id) {
97
98
  * a frame earlier (a release from the hand), for the spin it is let go with.
98
99
  */
99
100
  function fallPlan(spec, scene, id, start, t0, fall, fps, before = null) {
100
- const land = landingOf(spec, scene, id, start, fall.onto ?? null), shape = fall.tumble === false ? null : shapeIn(scene, id);
101
+ // a throw (fall.v, flight.js) lands where it comes down
102
+ const v0 = fallVelocity(fall, start, before, fps), thrown = moves(v0), landAt = pose => landingOf(spec, scene, id, pose, fall.onto ?? null);
103
+ const land = thrown ? landingWith(landAt, start, v0, G, Object.values(scene.objects).map(topOf)) : landAt(start), shape = fall.tumble === false ? null : shapeIn(scene, id);
101
104
  if (shape) {
102
- const spin = before ? spinOf(before.R, start.R, fps) : [0, 0, 0], air = airPath(shape, start, spin, land.top, fps);
105
+ const spin = before ? spinOf(before.R, start.R, fps) : [0, 0, 0], air = airPath(shape, start, spin, land.top, fps, v0);
103
106
  // a spin tips it the way its top moves (spin x up); a spin about the vertical tips nothing
104
107
  const path = settlePath(shape, air.at(air.tl), land.top, TIP * land.h + spinEnergy(shape, spin), [-spin[2], 0, spin[0]]);
105
- if (path.steps.length || spin.some(v => v)) {
108
+ if (path.steps.length || spin.some(v => v) || thrown) {
106
109
  const at = f => { const t = (f - t0) / fps; return t <= 0 ? start : t <= air.tl ? air.at(t) : settledAt(path, t - air.tl); };
107
110
  return { t0, land: t0 + air.tl * fps, end: t0 + (air.tl + path.time) * fps, h: land.h, support: land.support, top: land.top, at, rest: path.rest };
108
111
  }
109
112
  }
110
- const h = land.h, tl = Math.sqrt(2 * h / G);
113
+ const h = land.h, tl = thrown ? flightTime(h, v0[1], G) : Math.sqrt(2 * h / G), off = drift(v0, Infinity, tl);
111
114
  const e = Math.min(0.6, Math.max(0, fall.bounce ?? BOUNCE)), vb = Math.min(e * G * tl, Math.sqrt(2 * G * BOUNCE_MAX)), tb = 2 * vb / G;
112
- const rest = { pos: [start.pos[0], start.pos[1] - h, start.pos[2]], R: start.R };
115
+ const rest = { pos: [start.pos[0] + off[0], start.pos[1] - h, start.pos[2] + off[2]], R: start.R };
113
116
  const at = f => {
114
117
  const t = (f - t0) / fps;
115
118
  if (t <= 0) return start;
116
- const dy = t <= tl ? -0.5 * G * t * t : t <= tl + tb ? -h + vb * (t - tl) - 0.5 * G * (t - tl) ** 2 : -h;
117
- return { pos: [start.pos[0], start.pos[1] + Math.min(0, dy), start.pos[2]], R: start.R };
119
+ const dy = t <= tl ? (thrown ? v0[1] * t : 0) - 0.5 * G * t * t : t <= tl + tb ? -h + vb * (t - tl) - 0.5 * G * (t - tl) ** 2 : -h, d = drift(v0, t, tl);
120
+ return { pos: [start.pos[0] + d[0], start.pos[1] + (thrown ? dy : Math.min(0, dy)), start.pos[2] + d[2]], R: start.R };
118
121
  };
119
122
  return { t0, land: t0 + tl * fps, end: t0 + (tl + tb) * fps, h, support: land.support, top: land.top, at, rest };
120
123
  }
@@ -0,0 +1,125 @@
1
+ // What a hand holds weighs on its arm (#816). The load on a hand is the held object's mass (mass.js) x G x the
2
+ // horizontal distance from its shoulder to its wrist (N m), read on the solved frames:
3
+ // sag a carry whose load can reach LIGHT N m (its mass at the arm's full reach) lowers the wrist target of a
4
+ // plain IK hand (a hand_k target: not a fit, whose passes re-solve it, nor a keyed carry, whose keys lead
5
+ // it) by min(SAG_MAX, SAG_PER_KG x mass) and draws it as far in toward the shoulder, so the elbow bends
6
+ // more; eased in over the settle from the grab and out the same before the release (seat.js SETTLE)
7
+ // observe output.observations.load: each hand that carries something, { object, mass, maxNm, at, frames }
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
+ // 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)
11
+ // fix.grasp false turns all three off: the take of before.
12
+ import { add, sub, scale, norm, apply, T, len } from "./compiler/math.js";
13
+ import { SIDES } from "./compiler/pose.js";
14
+ import { armReach } from "./compiler/anchors.js";
15
+ import { isCarried } from "./carried.js";
16
+ import { massOf } from "./mass.js";
17
+ import { handOf, spanOf, partsOf, objectPoseAt } from "./object-pose.js";
18
+ import { SETTLE, restOf } from "./seat.js";
19
+
20
+ const REACH = 10, G = 9.81, LIGHT = 2, SAG_MAX = 0.06, SAG_PER_KG = 0.006, ONE_HAND = 25, SECOND = 0.5;
21
+ 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
+
23
+ const graspOn = spec => spec?.fix !== false && spec?.fix?.grasp !== false;
24
+ const ease = t => { const u = Math.min(1, Math.max(0, t)); return u * u * (3 - 2 * u); };
25
+
26
+ /** The carries of spec, [id, entry, hand] (hand "l" | "r"). */
27
+ const carries = spec => Object.entries(spec.objects ?? {}).filter(([, o]) => isCarried(o) && spec.props?.[o.prop]).map(([id, o]) => [id, o, spec.props[o.prop].hand]);
28
+
29
+ /**
30
+ * The world offset (m) the wrist target of hand k takes at frame f for what it carries, or null: ctx is the compile
31
+ * context (its spec, scene, sk, carrySpan), s the frame's pose so far, target the wrist target, free the share of the
32
+ * arm on a plain target.
33
+ */
34
+ export function sagOf(ctx, k, f, s, target, free) {
35
+ const { spec, scene, sk } = ctx;
36
+ if (!graspOn(spec) || !(free > 1e-3)) return null;
37
+ for (const [id, o, hand] of carries(spec)) {
38
+ if (hand !== k || o.keys?.length) continue;
39
+ const mass = massOf(spec, scene, id), [grab, release] = ctx.carrySpan(o);
40
+ if (f < grab || f > release || mass * G * armReach(sk, k) < LIGHT) continue;
41
+ const w = ease((f - grab) / SETTLE) * ease((release - f) / SETTLE) * free, drop = Math.min(SAG_MAX, SAG_PER_KG * mass) * w;
42
+ if (!(drop > 0)) return null;
43
+ const toShoulder = sub(s.P[sk.index[`${SIDES[k]}Arm`]], target);
44
+ const across = len([toShoulder[0], 0, toShoulder[2]]) > 1e-6 ? norm([toShoulder[0], 0, toShoulder[2]]) : [0, 0, 0];
45
+ return add([0, -drop, 0], scale(across, drop));
46
+ }
47
+ return null;
48
+ }
49
+
50
+ /** N m on hand k on pose s holding `mass` kg. */
51
+ const nm = (sk, s, k, mass) => { const d = sub(s.P[sk.index[`${SIDES[k]}Hand`]], s.P[sk.index[`${SIDES[k]}Arm`]]); return mass * G * Math.hypot(d[0], d[2]); };
52
+
53
+ /** fn({ id, o, k, mass, f, Nm }) for each carry on each frame of out its hand holds it (grasp physics on). */
54
+ function eachHeld(sk, out, fn) {
55
+ const { spec, scene } = out.ctx, f0 = out.f0 ?? 0;
56
+ if (!graspOn(spec)) return;
57
+ const list = carries(spec).map(([id, o, k]) => ({ id, o, k, mass: massOf(spec, scene, id) }));
58
+ if (!list.length) return;
59
+ out.frames.forEach(({ s }, i) => {
60
+ const f = i + f0;
61
+ for (const c of list) if (handOf(out, c.id, f) === c.k) fn({ ...c, f, Nm: nm(sk, s, c.k, c.mass) });
62
+ });
63
+ }
64
+
65
+ /** observeLoad(sk, out) -> { hand_l?, hand_r? } (each { object, mass, maxNm, at, frames: [first, last] }) or null. */
66
+ export function observeLoad(sk, out) {
67
+ const res = {};
68
+ eachHeld(sk, out, ({ id, k, mass, f, Nm }) => {
69
+ const row = res[`hand_${k}`] ??= { object: id, mass: Math.round(mass * 1000) / 1000, maxNm: 0, at: f, frames: [f, f] };
70
+ if (Nm > row.maxNm) { row.maxNm = Nm; row.at = f; }
71
+ row.frames[1] = f;
72
+ });
73
+ for (const row of Object.values(res)) row.maxNm = Math.round(row.maxNm * 10) / 10;
74
+ return Object.keys(res).length ? res : null;
75
+ }
76
+
77
+ /** True when the hand other than k is on carry id (an object anchor on it or a prop anchor on its prop) at f. */
78
+ function secondOn(out, id, o, k, f) {
79
+ const j = k === "r" ? "l" : "r", { spec, scene } = out.ctx, parts = new Set([id, ...partsOf(scene, id)]);
80
+ return Object.entries(spec.anchors ?? {}).some(([name, a]) => (out.baked?.[`w_${name}_${j}`]?.[f] ?? 0) > SECOND
81
+ && ((a.kind === "object" && parts.has(a.object)) || (a.kind === "prop" && a.prop === o.prop)));
82
+ }
83
+
84
+ /** QA LOAD issues: a hand carrying past ONE_HAND N m with no second hand on the object. */
85
+ export function loadChecks(sk, out) {
86
+ const issues = [];
87
+ eachHeld(sk, out, ({ id, o, k, f, Nm }) => {
88
+ if (Nm > ONE_HAND && !secondOn(out, id, o, k, f)) issues.push({ kind: "LOAD", msg: `${SIDES[k].toLowerCase()} holds ${id} at ${Nm.toFixed(0)} N m`, frame: f, value: Math.round(Nm * 10) / 10, limit: ONE_HAND });
89
+ });
90
+ return issues;
91
+ }
92
+
93
+ /**
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).
96
+ */
97
+ function otherFace(out, id, f) {
98
+ const { sk, scene } = out.ctx, s = out.frames[f - (out.f0 ?? 0)]?.s, pose = objectPoseAt(out, id, f), rest = restOf(scene, id);
99
+ const part = scene.objects[partsOf(scene, id)[0]] ?? scene.objects[id];
100
+ if (!s || !pose || !part?.half) return null;
101
+ 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
+ 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
+ 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
+ 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);
107
+ }
108
+
109
+ export const loadHints = {
110
+ /** LOAD(X, g) -> { cause, hint, fix? }: the other hand on it; a patch when the right hand holds. */
111
+ LOAD(X, g) {
112
+ const [, side, id] = /^(left|right) holds (\S+) at/.exec(g.subject) ?? [], o = X.spec?.objects?.[id];
113
+ if (!o || !X.out) return null;
114
+ 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
+ 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
+ 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;
119
+ const name = `${id}Other`;
120
+ // 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 } } }] } };
124
+ },
125
+ };
@@ -0,0 +1,23 @@
1
+ // What a scene object weighs, kg, for the arm that holds it (#816). The spec says it (spec.objects[id].mass), else a
2
+ // small sphere is a ball (BALL kg: a baseball is 0.145), else its parts' volumes at DENSITY (wood, a filled box). A
3
+ // group weighs what its bodies do. Never a scene record field: motion-scene entries are saved with a take (shapes.js).
4
+ import { partsOf } from "./object-pose.js";
5
+ import { shapeOf } from "./shapes.js";
6
+
7
+ const BALL = 0.15, BALL_SIZE = 0.12, DENSITY = 400, MIN = 0.02, MAX = 60;
8
+
9
+ function volumeOf(entry) {
10
+ const s = shapeOf(entry), h = entry.half;
11
+ if (s.kind === "sphere") return (4 / 3) * Math.PI * s.r ** 3;
12
+ if (s.kind === "cylinder") return Math.PI * s.r * s.r * s.h;
13
+ return 8 * h[0] * h[1] * h[2];
14
+ }
15
+
16
+ export function massOf(spec, scene, id) {
17
+ const given = spec?.objects?.[id]?.mass;
18
+ if (Number.isFinite(given)) return given;
19
+ const parts = partsOf(scene, id).map(k => scene.objects[k]);
20
+ if (parts.length === 1 && shapeOf(parts[0]).kind === "sphere" && 2 * shapeOf(parts[0]).r < BALL_SIZE) return BALL;
21
+ const kg = parts.reduce((sum, e) => sum + volumeOf(e) * DENSITY, 0);
22
+ return Math.min(MAX, Math.max(MIN, kg));
23
+ }
@@ -11,6 +11,7 @@
11
11
  // The object itself is still drawn in the hand (object-pose.js): the residual is what the arm
12
12
  // and the wrist could not reach, and QA reports it (qa/key-checks.js).
13
13
  import { isCarried, carriedPose, compose, relative } from "./carried.js";
14
+ import { seatsInHand, seatOffset, settleAt, between as slide } from "./seat.js";
14
15
  import { groupsOf } from "./scene.js";
15
16
  import { restState } from "./compiler/skeleton.js";
16
17
  import { add, scale, sub, apply, T, matFromQuat, quatFromMat } from "./compiler/math.js";
@@ -110,7 +111,8 @@ export const keyedEntries = spec => Object.entries(spec.objects ?? {}).filter(([
110
111
  export function holdHook(ctx, out, solvePlain, solveLed) {
111
112
  const { sk, spec, scene } = ctx, held = keyedEntries(spec);
112
113
  if (!held.length) return null;
113
- const palmOf = (o, s) => carriedPose(sk, spec, s, { prop: o.prop }), attached = new WeakMap(), attaching = new Set();
114
+ const seated = new Set(held.map(([id]) => id).filter(id => seatsInHand(spec, spec.objects[id], scene, id)).map(id => spec.objects[id]));
115
+ const palmOf = (o, s) => carriedPose(sk, spec, s, { prop: o.prop }, seated.has(o)), attached = new WeakMap(), attaching = new Set();
114
116
  const restAt = (id, f) => ctx.restingAt?.(id, f) ?? scene?.objects?.[id] ?? groupsOf(scene)[id];
115
117
  // a keyed carry of a thin body (a cup, a can, a bar) whose grip is free (no at/along, no orient: false, no grasp fit, no keyed
116
118
  // wrist at the grab) keeps the grip its keyed path holds most comfortably (grip-choice.js), solved on the plain frames with the
@@ -136,22 +138,25 @@ export function holdHook(ctx, out, solvePlain, solveLed) {
136
138
  const cache = attached.get(out.baked), o = spec.objects[id];
137
139
  if (cache.has(id)) return cache.get(id);
138
140
  const picked = chosen(id);
139
- if (picked && picked.name !== "today") { cache.set(id, picked.offset); return picked.offset; }
141
+ if (picked && picked.name !== "today") { const a = seated.has(o) ? seatOffset(sk, spec, scene, id, o, picked.offset) : picked.offset; cache.set(id, a); cache.set(`${id}|taken`, picked.offset); return a; }
140
142
  const rest = scene?.objects?.[id] ?? groupsOf(scene)[id], group = !scene?.objects?.[id] && !!rest;
141
143
  const keeps = o.at === undefined && o.along === undefined && (group || o.grab !== undefined);
142
144
  // the grab frame solved without the keys: a left hand anchored on id reads it there (object-pose.js), where this offset puts it
143
145
  attaching.add(id);
144
146
  let s;
145
147
  try { s = keeps ? solvePlain(spanIn(out, o)[0]).s : restState(sk); } finally { attaching.delete(id); }
146
- const a = relative(palmOf(o, s), keeps ? ctx.restingAt?.(id, spanIn(out, o)[0]) ?? { pos: rest.pos, R: rest.R } : carriedPose(sk, spec, s, o));
147
- cache.set(id, a);
148
+ const taken = relative(palmOf(o, s), keeps ? ctx.restingAt?.(id, spanIn(out, o)[0]) ?? { pos: rest.pos, R: rest.R } : carriedPose(sk, spec, s, o));
149
+ const a = keeps && seated.has(o) ? seatOffset(sk, spec, scene, id, o, taken) : taken;
150
+ cache.set(id, a); cache.set(`${id}|taken`, taken);
148
151
  return a;
149
152
  };
153
+ // on the grasp path a keyed carry's hand slides from where it took the object into its seat over the settle (seat.js)
154
+ const attachAt = (id, f) => { const a = attach(id), t = attached.get(out.baked)?.get(`${id}|taken`); return t && t !== a && f !== undefined ? slide(t, a, settleAt(spanIn(out, spec.objects[id])[0], f)) : a; };
150
155
  const carryOn = (id, s) => compose(palmOf(spec.objects[id], s), attach(id));
151
156
  const strainOn = (id, s, pose, opts) => { const o = spec.objects[id]; return holdCost(sk, s, SIDES[spec.props[o.prop].hand], compose(pose, invert(attach(id))), palmOf(o, s), opts); };
152
157
  const heldAt = (id, o, f) => {
153
158
  const curve = curveIn(out, id), w = leadWeight(curve, spanIn(out, o), f);
154
- return w > 0 ? { w, palm: compose(keyedPose(curve, f), invert(attach(id))), measure: s => palmOf(o, s), comfort: o.keys.some(k => k.lean > 0) } : null;
159
+ return w > 0 ? { w, palm: compose(keyedPose(curve, f), invert(attachAt(id, f))), measure: s => palmOf(o, s), comfort: o.keys.some(k => k.lean > 0) } : null;
155
160
  };
156
161
  // the elbow's planned path over the frames the keys lead (hold-plan.js), once per baked channels and time map
157
162
  const plans = new WeakMap();
@@ -263,5 +268,5 @@ export function holdHook(ctx, out, solvePlain, solveLed) {
263
268
  const firstKey = id => { const t = Math.min(...spec.objects[id].keys.map(k => k.f)); return out.timeMap ? Math.round(out.timeMap(t)) : t; };
264
269
  // the grips chosen for keyed carries (observations.wrist reads them): [{ id, hand, name, cost, today }]
265
270
  const grips = () => held.map(([id, o]) => { const p = chosen(id); return p && { id, hand: spec.props[o.prop].hand, name: p.name, cost: p.cost, today: p.today }; }).filter(Boolean);
266
- return { lead, attach, attaching: id => attaching.has(id), plainFrame: solvePlain, carryOn, strainOn, span: id => spanIn(out, spec.objects[id]), firstKey, grips };
271
+ return { lead, attach, attachAt, attaching: id => attaching.has(id), plainFrame: solvePlain, carryOn, strainOn, span: id => spanIn(out, spec.objects[id]), firstKey, grips };
267
272
  }