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.
- package/CHANGELOG.md +93 -0
- package/INSTALL_FOR_AGENTS.md +146 -0
- package/README.md +74 -192
- package/THIRD_PARTY_NOTICES.md +0 -21
- package/bin/agent/agent-routes.mjs +29 -62
- package/bin/agent/agent-runner.mjs +5 -3
- package/bin/agent/main-agent.mjs +70 -0
- package/bin/agent/main-prompt.mjs +17 -0
- package/bin/agent/plan-tool.mjs +114 -0
- package/bin/agent/script-tool.mjs +2 -0
- package/bin/agent/sculpt-sketch.mjs +190 -0
- package/bin/agent/session-store.mjs +4 -3
- package/bin/agent/sketch-code.mjs +151 -0
- package/bin/agent/sketch-sandbox.mjs +102 -0
- package/bin/agent/studio-prompt.mjs +1 -1
- package/bin/agent/studio-task-routes.mjs +185 -0
- package/bin/agent/studio-tasks.mjs +156 -0
- package/bin/agent/studio-tools.mjs +3 -2
- package/bin/agent/studio-turn-transaction.mjs +76 -0
- package/bin/agent/turn-frames.mjs +4 -0
- package/bin/cozyclay.mjs +5 -7
- package/dist/app/index.html +7 -6
- package/dist/assets/TaskBoard-ngdqiu7u.js +1 -0
- package/dist/assets/app-BlwOpest.css +1 -0
- package/dist/assets/app-DmR7jBkz.js +5144 -0
- package/dist/assets/compile-worker-D0pKioa0.js +5 -0
- package/dist/assets/landing-y3EqDgSZ.js +1 -0
- package/dist/assets/modulepreload-polyfill-P2Xu9kJm.js +1 -0
- package/dist/assets/motion3-compile-BB3_1-EB.js +1 -0
- package/dist/assets/scene-asset-sync-CBx7XRti.css +1 -0
- package/dist/assets/scene-asset-sync-Cgdtr1Yw.js +13 -0
- package/dist/assets/task-board-ErQvfoaz.js +14 -0
- package/dist/assets/workflow-zQM-EKW1.js +179 -0
- package/dist/assets/x-bot-tpose-body-BusyR3eg.json +1 -0
- package/dist/assets/y-bot-tpose-body-Dy1VKChZ.json +1 -0
- package/dist/cozyclay-package.json +1 -1
- package/dist/greybox-to-video/index.html +2 -3
- package/dist/index.html +7 -57
- package/dist/llms.txt +2 -1
- package/dist/previs-software/index.html +2 -2
- package/dist/privacy/index.html +3 -7
- package/dist/seedance-camera-control/index.html +2 -2
- package/dist/sitemap.xml +5 -5
- package/dist/sw.js +5 -1
- package/dist/thumbs/blank-stage-dark.webp +0 -0
- package/dist/thumbs/blank-stage-light.webp +0 -0
- package/dist/workflow/index.html +5 -4
- package/mcp/mesh-file.mjs +3 -1
- package/mcp/server.mjs +3 -0
- package/mcp/tool-handlers.mjs +2 -2
- package/package.json +5 -3
- package/src/App.jsx +51 -282
- package/src/analytics.js +3 -148
- package/src/app-stage.jsx +1 -1
- package/src/asset-pane.jsx +5 -10
- package/src/auto-flat.js +10 -0
- package/src/bus-tasks.js +11 -5
- package/src/command-bus.js +1 -1
- package/src/commands/elements.js +4 -3
- package/src/commands/motion3-compile.js +51 -27
- package/src/commands/motion3.js +3 -3
- package/src/commands/objects.js +1 -1
- package/src/commands/project.js +1 -1
- package/src/commands/shot.js +16 -4
- package/src/controls.jsx +0 -11
- package/src/cuts.js +11 -0
- package/src/domains/cast.js +3 -2
- package/src/domains/motion.js +3 -10
- package/src/domains/playback.js +32 -0
- package/src/domains/scenes.js +3 -22
- package/src/domains/shots.js +5 -5
- package/src/dualview.jsx +11 -15
- package/src/hierarchy-panel.css +18 -4
- package/src/hierarchy-panel.jsx +12 -11
- package/src/kits/held.js +3 -2
- package/src/light-blockers.js +2 -2
- package/src/main.jsx +5 -0
- package/src/material-look.js +189 -0
- package/src/material-presets.js +78 -0
- package/src/material-textures.js +157 -0
- package/src/motion/ground.js +5 -14
- package/src/motion/playback-frame-store.js +79 -0
- package/src/motion/playback-probe.js +16 -0
- package/src/motion/playback-subject.js +7 -0
- package/src/motion/timeline.css +43 -5
- package/src/motion/timeline.jsx +4 -3
- package/src/motion3/body.js +37 -5
- package/src/motion3/carried.js +11 -3
- package/src/motion3/compile-job.js +1 -1
- package/src/motion3/compiler/compile.js +7 -1
- package/src/motion3/compiler/finger-close.js +137 -0
- package/src/motion3/compiler/limbs.js +18 -3
- package/src/motion3/compiler/pose.js +6 -0
- package/src/motion3/compiler/validate-objects.js +17 -2
- package/src/motion3/compiler/validate-parts.js +2 -1
- package/src/motion3/compiler/vocab.js +4 -4
- package/src/motion3/describe.js +2 -2
- package/src/motion3/fix/index.js +5 -3
- package/src/motion3/flight.js +38 -0
- package/src/motion3/free-motion.js +10 -7
- package/src/motion3/load.js +125 -0
- package/src/motion3/mass.js +23 -0
- package/src/motion3/object-keys.js +11 -6
- package/src/motion3/object-pose.js +11 -3
- package/src/motion3/pipeline.js +3 -0
- package/src/motion3/qa/explain.js +3 -1
- package/src/motion3/qa/grasp-checks.js +123 -0
- package/src/motion3/qa/hints-grasp.js +71 -0
- package/src/motion3/qa/index.js +3 -1
- package/src/motion3/qa/object-checks.js +7 -2
- package/src/motion3/qa/patch.js +2 -1
- package/src/motion3/qa/spec-checks.js +10 -1
- package/src/motion3/schema/fields.js +3 -3
- package/src/motion3/schema/topics.js +9 -8
- package/src/motion3/schema/types.js +4 -1
- package/src/motion3/seat.js +84 -0
- package/src/motion3/settle.js +11 -6
- package/src/motion3/shapes.js +70 -0
- package/src/motion3/skills/catch.js +71 -0
- package/src/motion3/skills/index.js +10 -3
- package/src/motion3/skills/info.js +13 -0
- package/src/motion3/skills/throw.js +72 -0
- package/src/object-catalog.jsx +0 -4
- package/src/object-gizmo.jsx +2 -1
- package/src/object-look.js +9 -5
- package/src/object-path.js +1 -1
- package/src/panels/ProjectPanel.jsx +42 -21
- package/src/panels/details.css +15 -4
- package/src/popover.js +37 -0
- package/src/posestudio.jsx +15 -10
- package/src/primitive-look.js +38 -0
- package/src/project-browser-art.jsx +19 -0
- package/src/project-browser.css +74 -121
- package/src/project-browser.jsx +36 -60
- package/src/props.jsx +24 -35
- package/src/render-passes.js +7 -1
- package/src/retired-kinds.js +37 -0
- package/src/scene-objects.js +5 -9
- package/src/scenes.js +4 -5
- package/src/sculpt-colours.js +65 -0
- package/src/sculpt-compare.js +13 -50
- package/src/sculpt-csg.js +112 -0
- package/src/sculpt-curve.js +123 -0
- package/src/sculpt-cutters.js +43 -0
- package/src/sculpt-detail.js +207 -0
- package/src/sculpt-geometry-loft-convex.js +100 -0
- package/src/sculpt-geometry-shapes.js +250 -0
- package/src/sculpt-geometry.js +33 -15
- package/src/sculpt-loft-rings.js +225 -0
- package/src/sculpt-noise.js +65 -0
- package/src/sculpt-part-fields.js +27 -0
- package/src/sculpt-recipe-read.js +65 -0
- package/src/sculpt-recipe.js +33 -75
- package/src/sculpt-run.js +1 -1
- package/src/sculpt-scatter-items.js +183 -0
- package/src/sculpt-scatter-shape.js +57 -0
- package/src/sculpt-scatter.js +124 -0
- package/src/sculpt-shapes-curved.js +211 -0
- package/src/sculpt-shapes-loft-convex.js +218 -0
- package/src/sculpt-shapes.js +138 -0
- package/src/sculpt-sketch-state.js +76 -0
- package/src/sculpt-sketch.jsx +124 -0
- package/src/sculpt-surface-fields.js +65 -0
- package/src/settings-menu.jsx +1 -20
- package/src/shell/BottomDock.jsx +9 -9
- package/src/shell/MenuBar.jsx +1 -9
- package/src/shell/PreferencesDialog.jsx +3 -4
- package/src/shell/ViewportToolbar.jsx +3 -3
- package/src/shell/add-camera-shot.js +11 -0
- package/src/shell/glass-regions.css +3 -2
- package/src/shell/glass.css +0 -5
- package/src/shell/lens-scrub-math.js +51 -0
- package/src/shell/lens-scrub.css +73 -0
- package/src/shell/lens-scrub.jsx +98 -0
- package/src/shell/mode.css +6 -5
- package/src/shell/preferences.css +1 -32
- package/src/shell/shell.css +18 -1
- package/src/shell/studio-shell-context.js +5 -0
- package/src/shot-frame.js +26 -0
- package/src/stage-canvas-rigs.jsx +7 -8
- package/src/stage-characters.jsx +26 -0
- package/src/stage-gizmo.js +43 -0
- package/src/stage-labels-ko.js +0 -6
- package/src/stage-startup-defaults.js +3 -9
- package/src/starter-scenes.js +5 -11
- package/src/studio-agent-commands.js +2 -2
- package/src/studio-agent-protocol.js +2 -7
- package/src/studio-app-binding.js +1 -1
- package/src/studio-elements.js +6 -2
- package/src/styles/themes.css +13 -0
- package/src/styles.css +6 -674
- package/src/text/text-object.jsx +1 -1
- package/src/ui.jsx +8 -16
- package/src/use-embed-bridge.js +3 -6
- package/src/use-render-activity.js +3 -1
- package/src/view-ghosts.jsx +5 -3
- package/src/viewport-visible-rect.js +37 -0
- package/src/workflow/AgentPanel.jsx +79 -46
- package/src/workflow/AgentPlanCard.jsx +41 -0
- package/src/workflow/AgentProgressCard.jsx +1 -1
- package/src/workflow/TaskBoard.jsx +102 -0
- package/src/workflow/TaskInline.jsx +114 -0
- package/src/workflow/agent-client.js +25 -36
- package/src/workflow/agent-panel.css +14 -1
- package/src/workflow/agent-plan.css +93 -0
- package/src/workflow/agent-plan.js +59 -0
- package/src/workflow/attachment-image.js +10 -3
- package/src/workflow/attachment-video.js +91 -0
- package/src/workflow/task-board.css +98 -0
- package/src/workflow/task-board.js +169 -0
- package/tools/check-eslint.mjs +2 -1
- package/tools/check-knip.mjs +2 -1
- package/tools/dev/pages/greybox-to-video.html +2 -3
- package/tools/dev/pages/previs-software.html +2 -2
- package/tools/dev/pages/privacy.html +3 -7
- package/tools/dev/pages/seedance-camera-control.html +2 -2
- package/tools/dev-full.mjs +11 -2
- package/tools/eslint-baseline.json +6 -6
- package/tools/knip-baseline.json +0 -11
- package/tools/loc-baseline.json +30 -31
- package/tools/metrics-snapshot.mjs +43 -5
- package/tools/render-start-thumbs.mjs +175 -0
- package/tools/run-tests.mjs +10 -17
- package/tools/sitemap.mjs +1 -1
- package/dist/assets/analytics-BibyE5ar.js +0 -6
- package/dist/assets/app-DhRJ-PBz.js +0 -5088
- package/dist/assets/app-f-fkV5uP.css +0 -1
- package/dist/assets/compile-worker-DqiA4AaN.js +0 -5
- package/dist/assets/first-shot-handoff-Qx7SKl38.js +0 -1
- package/dist/assets/landing-DYg87MDY.js +0 -1
- package/dist/assets/scene-asset-sync-DBYQBR0T.js +0 -20
- package/dist/assets/scene-asset-sync-_S8NQl5B.css +0 -1
- package/dist/assets/workflow-BSA22Ofr.js +0 -179
- package/dist/scenes/city-block.cclayproject +0 -1
- package/src/burn-in.js +0 -91
- package/src/camera-tutorial.jsx +0 -520
- package/src/facing-marks.js +0 -88
- package/src/first-shot-handoff.js +0 -44
- package/src/panels/motion.css +0 -197
- package/src/set-pieces.jsx +0 -183
- package/src/tutorial-analytics.js +0 -86
- package/src/tutorial-walk.js +0 -17
- package/src/use-camera-tutorial.js +0 -50
- package/src/use-semantic-state.js +0 -12
- package/tools/metrics/archive-traffic.mjs +0 -138
- 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
|
|
166
|
-
const
|
|
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(
|
|
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 = [
|
package/src/motion3/describe.js
CHANGED
|
@@ -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
|
|
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) {
|
package/src/motion3/fix/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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") {
|
|
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
|
|
147
|
-
|
|
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(
|
|
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
|
}
|