cozyclay 3.0.1 → 3.0.2

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 (67) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/THIRD_PARTY_NOTICES.md +10 -0
  3. package/bin/agent/pi-tools.mjs +12 -1
  4. package/bin/agent/studio-context-budget.mjs +8 -2
  5. package/bin/agent/studio-prompt.mjs +1 -1
  6. package/bin/agent/studio-tools.mjs +1 -1
  7. package/dist/app/index.html +4 -4
  8. package/dist/assets/analytics-DbKG0NgD.js +6 -0
  9. package/dist/assets/app-34i-iUy1.js +5088 -0
  10. package/dist/assets/compile-worker-DYpa2nvO.js +5 -0
  11. package/dist/assets/{first-shot-handoff-DNO8kv_N.js → first-shot-handoff-CrGZ3n_O.js} +1 -1
  12. package/dist/assets/helvetiker-bold.typeface-BUlyMz0-.js +13 -0
  13. package/dist/assets/{landing-yfZaJkWc.js → landing-mq5bnqFE.js} +1 -1
  14. package/dist/assets/scene-asset-sync-7J-w5oNh.js +20 -0
  15. package/dist/assets/{workflow-D78eiKTa.js → workflow-dUhurYUc.js} +1 -1
  16. package/dist/cozyclay-package.json +1 -1
  17. package/dist/index.html +3 -3
  18. package/dist/workflow/index.html +3 -3
  19. package/package.json +1 -1
  20. package/src/agent-turn-mark.js +33 -0
  21. package/src/command-bus.js +8 -9
  22. package/src/commands/elements/character.js +2 -1
  23. package/src/commands/elements/motion.js +7 -1
  24. package/src/commands/elements/scene.js +3 -0
  25. package/src/commands/elements/stage.js +2 -1
  26. package/src/commands/elements/take-read.js +31 -0
  27. package/src/commands/elements.js +16 -2
  28. package/src/commands/motion3.js +3 -1
  29. package/src/commands/objects.js +2 -2
  30. package/src/motion3/compiler/anchors.js +60 -4
  31. package/src/motion3/compiler/compile.js +5 -1
  32. package/src/motion3/compiler/grip.js +8 -2
  33. package/src/motion3/compiler/socket-grip.js +107 -0
  34. package/src/motion3/compiler/validate-parts.js +7 -2
  35. package/src/motion3/compiler/vocab.js +6 -0
  36. package/src/motion3/free-motion.js +12 -4
  37. package/src/motion3/object-keys.js +36 -5
  38. package/src/motion3/object-pose.js +38 -5
  39. package/src/motion3/scene.js +4 -3
  40. package/src/motion3/schema/fields.js +1 -1
  41. package/src/motion3/schema/topics.js +3 -2
  42. package/src/motion3/schema/types.js +2 -2
  43. package/src/motion3/skills/basic.js +8 -2
  44. package/src/motion3/skills/info.js +4 -4
  45. package/src/motion3/sockets.js +104 -0
  46. package/src/motion3/take-edit.js +1 -1
  47. package/src/motion3/take.js +6 -4
  48. package/src/object-catalog.jsx +3 -1
  49. package/src/object-grasp.js +38 -0
  50. package/src/object-look.js +2 -0
  51. package/src/object-travel.js +6 -3
  52. package/src/panels/ObjectLookPanel.jsx +24 -3
  53. package/src/props.jsx +5 -5
  54. package/src/scene-objects.js +19 -11
  55. package/src/shell/details-selection.js +2 -1
  56. package/src/studio-agent-protocol.js +5 -6
  57. package/src/studio-app-binding.js +1 -1
  58. package/src/studio-elements.js +3 -1
  59. package/src/text/fonts/helvetiker-LICENSE.txt +13 -0
  60. package/src/text/fonts/helvetiker-bold.typeface.json +1 -0
  61. package/src/text/text-object.jsx +106 -0
  62. package/src/text/text-record.js +122 -0
  63. package/tools/run-tests.mjs +1 -1
  64. package/dist/assets/analytics-BWCqdgWA.js +0 -1
  65. package/dist/assets/app-DfTCxaKk.js +0 -5087
  66. package/dist/assets/compile-worker-BxKXh07s.js +0 -5
  67. package/dist/assets/scene-asset-sync-U3kS2rvK.js +0 -20
@@ -29,7 +29,9 @@ import { shapeOf, airPath, settlePath, settledAt, spinOf, spinEnergy, TIP } from
29
29
  export const G = 9.8;
30
30
  /** The highest a bounce goes, metres. */
31
31
  export const BOUNCE_MAX = 0.03;
32
- const BOUNCE = 0.25, TOUCH = 0.005;
32
+ const BOUNCE = 0.25;
33
+ /** A support's top may sit this far above the lowest point of what rests on it (m). */
34
+ export const TOUCH = 0.005;
33
35
  const HELD_OR_HINGED = ["prop", "follow", "pivot", "axis", "channel"];
34
36
 
35
37
  /** A free entry (C17): no prop, follow or hinge field, and keys or fall. */
@@ -41,14 +43,14 @@ const restOf = (scene, id) => scene?.objects?.[id] ?? groupsOf(scene)[id] ?? nul
41
43
  const partsIn = (scene, id) => Object.keys(scene?.objects ?? {}).filter(k => k === id || scene.objects[k].ancestors?.includes(id));
42
44
 
43
45
  /** The 8 corners of a base-pivot box { pos, R, half }. */
44
- function corners(box) {
46
+ export function corners(box) {
45
47
  const out = [];
46
48
  for (const x of [-1, 1]) for (const y of [0, 2]) for (const z of [-1, 1]) out.push(add(box.pos, apply(box.R, [x * box.half[0], y * box.half[1], z * box.half[2]])));
47
49
  return out;
48
50
  }
49
- const topOf = box => Math.max(...corners(box).map(p => p[1]));
51
+ export const topOf = box => Math.max(...corners(box).map(p => p[1]));
50
52
  /** True when the vertical through x/z point p crosses the footprint of box. */
51
- const under = (box, p) => { const q = apply(T(box.R), sub([p[0], box.pos[1] + box.half[1], p[2]], box.pos)); return Math.abs(q[0]) <= box.half[0] && Math.abs(q[2]) <= box.half[2]; };
53
+ export const under = (box, p) => { const q = apply(T(box.R), sub([p[0], box.pos[1] + box.half[1], p[2]], box.pos)); return Math.abs(q[0]) <= box.half[0] && Math.abs(q[2]) <= box.half[2]; };
52
54
 
53
55
  /** Bodies a spec.objects entry moves (itself or a group above it): they hold nothing up. */
54
56
  function moversIn(spec, scene) {
@@ -195,6 +197,12 @@ export function afterRelease(ctx, id, o, f, release, releasePose, beforePose = n
195
197
  return events.length ? walk(events, f, releasePose, plan, false) : null;
196
198
  }
197
199
 
200
+ /** True when carry entry o falls at or after its release (absolute frame; afterRelease then moves it). */
201
+ export function fallsAfter(ctx, o, release) {
202
+ const map = ctx.timeMap ?? (f => f);
203
+ return fallsOf(o).some(fl => map(fl.at) >= release);
204
+ }
205
+
198
206
  /** The falls of entry `id` as planned under the current time map, for readers (timeline, tests): [{ t0, land, end, h, support, top }]. */
199
207
  export function fallPlans(ctx, id) {
200
208
  const o = ctx.spec.objects?.[id];
@@ -4,7 +4,8 @@
4
4
  // between keys a cubic Hermite on pos and the same in rotation-vector space on rot (squad
5
5
  // like), Catmull-Rom tangents through inner keys, zero at the first and last key
6
6
  // around the span the hand blends from the plain carry into the keyed span over KEY_BLEND frames
7
- // (inside grab..release; smoothstep), so nothing jumps
7
+ // (inside grab..release; smoothstep), so nothing jumps; keys that start within KEY_BLEND frames
8
+ // of the grab with a grip off where the hand is turn the hand onto it before (holdHook approach)
8
9
  // the object leads the hand: compile.js asks holdHook for the palm frame the keys put the grip
9
10
  // at, palm = key o attach^-1, and pose.js solves the hand to it (compiler/hold.js)
10
11
  // The object itself is still drawn in the hand (object-pose.js): the residual is what the arm
@@ -20,11 +21,14 @@ import { polePlan, smoothPath, smoothed } from "./hold-plan.js";
20
21
  import { secondGrip, secondHold, gripGate } from "./second-hand.js";
21
22
  import { SIDES } from "./compiler/pose.js";
22
23
  import { chooseGrip, between, thinBody } from "./grip-choice.js";
24
+ import { graspedBy } from "./compiler/socket-grip.js";
23
25
 
24
26
  /** Frames over which the hand blends into and out of the keyed span. */
25
27
  export const KEY_BLEND = 8;
26
28
  // the frames a hand takes to turn into a chosen grip before its grab and out of it after its release (grip-choice.js)
27
29
  const GRAB_EASE = 12;
30
+ // how far (m, deg) the grip keys put the hand on at the grab may be from where the reach brought it before the hand turns onto it first
31
+ const OFF_GRIP = { m: 0.01, deg: 5 };
28
32
 
29
33
  const smooth = x => x * x * (3 - 2 * x);
30
34
  /** [grab, release] in absolute frames of `out` (object-pose.js spanOf, kept here to stay out of its import cycle). */
@@ -108,8 +112,8 @@ export function holdHook(ctx, out, solvePlain, solveLed) {
108
112
  if (!held.length) return null;
109
113
  const palmOf = (o, s) => carriedPose(sk, spec, s, { prop: o.prop }), attached = new WeakMap(), attaching = new Set();
110
114
  const restAt = (id, f) => ctx.restingAt?.(id, f) ?? scene?.objects?.[id] ?? groupsOf(scene)[id];
111
- // a keyed carry of a thin body (a cup, a can, a bar) whose grip is free (no at/along, no orient: false, no keyed wrist at
112
- // the grab) keeps the grip its keyed path holds most comfortably (grip-choice.js), solved on the plain frames with the
115
+ // 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
+ // wrist at the grab) keeps the grip its keyed path holds most comfortably (grip-choice.js), solved on the plain frames with the
113
117
  // body read at rest; a box keeps the grip grip.js gives it at the grab
114
118
  const choices = new WeakMap(), mapped = f => (out.timeMap ? Math.round(out.timeMap(f)) : f);
115
119
  // chosen once per compile (the first bake): the fixes' rebakes move the body a little, the carry's keys not at all
@@ -118,7 +122,7 @@ export function holdHook(ctx, out, solvePlain, solveLed) {
118
122
  const cache = choices.get(out), o = spec.objects[id], k = spec.props?.[o.prop]?.hand, grab = spanIn(out, o)[0], b = out.baked ?? {};
119
123
  if (cache.has(id)) return cache.get(id);
120
124
  cache.set(id, null);
121
- const free = k && o.at === undefined && o.along === undefined && (o.grab !== undefined || !scene?.objects?.[id]) && thinBody(scene, id) && !(spec.fits ?? []).some(fit => fit.side === k && fit.orient === false)
125
+ const free = k && o.at === undefined && o.along === undefined && (o.grab !== undefined || !scene?.objects?.[id]) && thinBody(scene, id) && !(spec.fits ?? []).some(fit => fit.side === k && fit.orient === false) && !graspedBy(spec, scene, id, k)
122
126
  && !["wtwist", "wflex"].some(ch => Math.abs(b[`${ch}_${k}`]?.[grab] ?? 0) > 1e-3)
123
127
  // keys that start after the grab: the body rides the hand from where it rests, which only today's grip meets exactly
124
128
  && mapped(Math.min(...o.keys.map(key => key.f))) <= grab;
@@ -203,6 +207,33 @@ export function holdHook(ctx, out, solvePlain, solveLed) {
203
207
  const h = heldAt(id, o, f);
204
208
  return { w: 1, palm: h ? { pos: add(scale(grip.pos, 1 - h.w), scale(h.palm.pos, h.w)), R: between(grip.R, h.palm.R, h.w) } : grip, measure, pole };
205
209
  };
210
+ // keys that lead from the grab frame, or from fewer than KEY_BLEND frames after it, put the hand on their grip at once (or
211
+ // within those frames: palm = first key o attach^-1). Where that grip is not where the hand would be at the first key (an
212
+ // at/along offset of the agent's choosing: bench session cb2503b5's cup, 13.6 cm in the grab frame; keys two frames after
213
+ // the grab: 7146050f's box), the hand turns onto it over the GRAB_EASE frames up to the first key instead, as onto a chosen
214
+ // grip (gripAround): before the grab with the object at rest, after it carrying the object there. A grip within OFF_GRIP
215
+ // of where the hand would be changes nothing.
216
+ const offGrips = new WeakMap();
217
+ const offGrip = (id, o, grip) => {
218
+ if (!offGrips.has(out.baked)) offGrips.set(out.baked, new Map());
219
+ const cache = offGrips.get(out.baked), had = cache.get(id);
220
+ if (had && had.map === out.timeMap) return had.off;
221
+ attaching.add(id);
222
+ let palm;
223
+ try { palm = palmOf(o, solvePlain(Math.max(spanIn(out, o)[0], Math.round(curveIn(out, id).t[0]))).s); } finally { attaching.delete(id); }
224
+ const turn = relative(palm, grip).R, deg = Math.acos(Math.min(1, Math.max(-1, (turn[0][0] + turn[1][1] + turn[2][2] - 1) / 2))) * 180 / Math.PI;
225
+ const off = Math.hypot(...sub(grip.pos, palm.pos)) > OFF_GRIP.m || deg > OFF_GRIP.deg;
226
+ cache.set(id, { map: out.timeMap, off });
227
+ return off;
228
+ };
229
+ const approach = (id, o, f) => {
230
+ const curve = curveIn(out, id), [grab, release] = spanIn(out, o), t0 = Math.max(grab, Math.round(curve.t[0]));
231
+ if (!(f < t0 && f >= t0 - GRAB_EASE) || t0 - grab >= KEY_BLEND || t0 > release) return null;
232
+ const grip = compose(keyedPose(curve, t0), invert(attach(id)));
233
+ if (!offGrip(id, o, grip)) return null;
234
+ const w = smooth(1 - (t0 - f) / GRAB_EASE);
235
+ return w > 1e-3 ? { w, palm: grip, measure: s => palmOf(o, s), pole: planOf(id, o).get(t0) } : null;
236
+ };
206
237
  // a release within KEY_BLEND frames of the last key: the hands finish easing out of the keys after it (weight from the last
207
238
  // key), the holding hand from the palm they gave it at the release (the object stays where it was let go), each hand with
208
239
  // the elbow pole (and the second hand the roll) planned for the release frame
@@ -217,7 +248,7 @@ export function holdHook(ctx, out, solvePlain, solveLed) {
217
248
  // the hand that holds a keyed carry follows its keys
218
249
  const lead = (k, f, s, c) => {
219
250
  for (const [id, o] of held) {
220
- const pre = spec.props?.[o.prop]?.hand === k ? gripAround(id, o, f) ?? easeOut(id, o, f) : null;
251
+ const pre = spec.props?.[o.prop]?.hand === k ? gripAround(id, o, f) ?? approach(id, o, f) ?? easeOut(id, o, f) : null;
221
252
  if (pre) return pre;
222
253
  const h = spec.props?.[o.prop]?.hand === k ? heldAt(id, o, f) : null;
223
254
  if (h) return { ...h, pole: planOf(id, o).get(f) };
@@ -14,7 +14,9 @@
14
14
  // f < grab the scene pose
15
15
  // grab <= f <= release follows the prop's hand; without at/along it keeps the transform it had
16
16
  // against the palm axes at grab (no jump); at/along pin an offset (a group: its root, C15)
17
- // f > release frozen at the release pose
17
+ // f > release frozen at the release pose; let go over a body that moves on its own (a seat of a truck the spec
18
+ // drives with keys, a hinged lid), it rides that body's mover from the release on, keeping its world
19
+ // pose at the release (riderOf): a lighter put down in the cab leaves with the truck
18
20
  // no grab: in hand from frame 0; no release: in hand to the end (PR #14 behaviour)
19
21
  // keys (C14, object-keys.js): the object leads and the hand follows; the object is still drawn
20
22
  // in the hand, so what the hand cannot reach shows as a residual (QA)
@@ -29,7 +31,8 @@ import { groupsOf } from "./scene.js";
29
31
  import { solveFrame } from "./compiler/compile.js";
30
32
  import { channelsAt } from "./compiler/channels.js";
31
33
  import { resolveBone } from "./bones.js";
32
- import { isFree, freePoseAt, beforeGrab, afterRelease } from "./free-motion.js";
34
+ import { isFree, freePoseAt, beforeGrab, afterRelease, fallsAfter, corners, topOf, under, TOUCH } from "./free-motion.js";
35
+ import { add } from "./compiler/math.js";
33
36
 
34
37
  /**
35
38
  * Body ids that make up `id` in a motionScene: [id] for a body (plus any bodies parented under
@@ -68,7 +71,7 @@ export function spanOf(out, o) {
68
71
  /** Without at/along a grabbed body, and a group, keeps its grab-frame palm offset; at/along pin one (a kit root, C15). */
69
72
  const keepsOffset = (scene, id, o) => o.at === undefined && o.along === undefined && ((!scene?.objects?.[id] && !!groupsOf(scene)[id]) || o.grab !== undefined);
70
73
 
71
- const solved = new WeakMap(), poses = new WeakMap();
74
+ const solved = new WeakMap(), poses = new WeakMap(), riders = new WeakMap();
72
75
 
73
76
  /** What free-motion.js reads of a compile result: the spec, the scene and the time map. */
74
77
  const freeCtx = out => ({ spec: out.ctx.spec, scene: out.ctx.scene, timeMap: out.timeMap });
@@ -84,8 +87,36 @@ export function restingAt(out, id, f) {
84
87
  return root && (mover === id ? root : compose(root, relative(restPose(scene, mover), restPose(scene, id))));
85
88
  }
86
89
 
87
- /** The solved frame at absolute f: out.frames, or solved once on demand outside a window. */
88
- function frameAt(out, f) {
90
+ /**
91
+ * The spec.objects key a carry `id` (entry o) rides after its release: the key that moves the highest body under the
92
+ * record's centre at the release frame, when that key moves on its own (keys, a fall or a hinge; never a hand or a follow)
93
+ * and is not the record itself. null when the carry falls after its release, is never let go, or nothing under it moves.
94
+ */
95
+ export function riderOf(out, id) {
96
+ if (!riders.has(out)) riders.set(out, new Map());
97
+ const cache = riders.get(out);
98
+ if (cache.has(id)) return cache.get(id);
99
+ const { spec, scene } = out.ctx, o = spec.objects?.[id], rest = restPose(scene, id), parts = partsOf(scene, id);
100
+ let best = null;
101
+ if (isCarried(o) && o.release !== undefined && rest && parts.length) {
102
+ const release = spanOf(out, o)[1];
103
+ if (release < out.frameCount - 1 && !fallsAfter(freeCtx(out), o, release)) {
104
+ const at = carriedAt(out, id, o, release), pts = parts.flatMap(p => corners({ ...compose(at, relative(rest, scene.objects[p])), half: scene.objects[p].half }));
105
+ const low = Math.min(...pts.map(p => p[1])), c = pts.reduce((a, p) => add(a, p), [0, 0, 0]).map(v => v / pts.length);
106
+ for (const [k, body] of Object.entries(scene.objects)) {
107
+ const mover = parts.includes(k) ? null : moverOf(spec, scene, k), entry = spec.objects?.[mover];
108
+ if (!mover || mover === id || isCarried(entry) || isFollow(entry)) continue;
109
+ const box = { ...objectPoseAt(out, k, release), half: body.half }, top = topOf(box);
110
+ if (under(box, c) && top <= low + TOUCH && (!best || top > best.top)) best = { mover, top };
111
+ }
112
+ }
113
+ }
114
+ cache.set(id, best?.mover ?? null);
115
+ return best?.mover ?? null;
116
+ }
117
+
118
+ /** The solved frame at absolute f: out.frames, or solved once on demand outside a window (or ahead of the frame being solved). */
119
+ export function frameAt(out, f) {
89
120
  const own = out.frames[f - (out.f0 ?? 0)];
90
121
  if (own) return own;
91
122
  if (!solved.has(out)) solved.set(out, new Map());
@@ -129,6 +160,8 @@ export function rootPoseAt(out, id, f, now = null) {
129
160
  if (f < grab && rest) return restingAt(out, id, f);
130
161
  const after = f > release ? afterRelease(freeCtx(out), id, o, f, release, carriedAt(out, id, o, release), release > 0 ? carriedAt(out, id, o, release - 1) : null) : null;
131
162
  if (after) return after;
163
+ const by = f > release ? riderOf(out, id) : null;
164
+ if (by) { const at = carriedAt(out, id, o, release); return compose(rootPoseAt(out, by, f), relative(rootPoseAt(out, by, release), at)); }
132
165
  return now && g === f ? carriedOn(out, id, o, f, now.s) : carriedAt(out, id, o, g);
133
166
  }
134
167
  if (!rest) return null;
@@ -7,7 +7,8 @@
7
7
  // and scene.others (take-source.js), so the groups are rebuilt from the entries: groupsOf.
8
8
  // `others` lists the records with no box (an Empty, a light, an effect, a plane) by id with their
9
9
  // renderer and ancestors: a switch entry (C15) may name them, and a skill finds a kit's lit parts
10
- // there. A body that glows (record.glow) says so (`glow: true`).
10
+ // there. A body that glows (record.glow) says so (`glow: true`); a record's grasp sockets (record.grasp,
11
+ // ../object-grasp.js) ride along for the hand (sockets.js).
11
12
  import { objectSize } from "../object-size.js";
12
13
  import { mul, rx, ry, rz } from "./compiler/math.js";
13
14
 
@@ -50,11 +51,11 @@ export function motionScene(objects = []) {
50
51
  if (!o || o.hidden) continue;
51
52
  const size = objectSize(o), up = ancestorsOf(o, byId);
52
53
  if (NO_BODY.has(o.renderer) || !(size.width > 0 && size.height > 0 && size.depth > 0)) {
53
- others[o.id] = { renderer: o.renderer, ancestors: up.map(p => p.id) };
54
+ others[o.id] = { renderer: o.renderer, ancestors: up.map(p => p.id), ...(o.grasp ? { grasp: o.grasp } : {}) };
54
55
  continue;
55
56
  }
56
57
  out[o.id] = { name: o.name, renderer: o.renderer, ...poseOf(o), half: [size.width / 2, size.height / 2, size.depth / 2],
57
- ancestors: up.map(p => p.id), ...(up.length ? { ancestorPoses: up.map(poseOf) } : {}), ...(o.glow ? { glow: true } : {}) };
58
+ ancestors: up.map(p => p.id), ...(up.length ? { ancestorPoses: up.map(poseOf) } : {}), ...(o.glow ? { glow: true } : {}), ...(o.grasp ? { grasp: o.grasp } : {}) };
58
59
  }
59
60
  return { objects: out, groups: groupsOf({ objects: out }), others };
60
61
  }
@@ -11,7 +11,7 @@ export const KEY_FIELDS = ["f", "set", "step", "src"];
11
11
  export const USE_FIELDS = ["skill", "args", "f0"];
12
12
  /** Anchor fields by anchor kind (besides `kind`). */
13
13
  export const ANCHOR_FIELDS = { world: ["p"], body: ["p"], bone: ["bone", "offset"], segment: ["from", "to", "t", "tMin", "offset", "reach"],
14
- object: ["object", "local"], landmark: ["name"], prop: ["prop", "point", "offset"], person: ["person", "bone", "offset"] };
14
+ object: ["object", "local", "socket", "grasp"], landmark: ["name"], prop: ["prop", "point", "offset"], person: ["person", "bone", "offset"] };
15
15
  export const PROP_FIELDS = ["hand", "origin", "points"];
16
16
  export const FIT_FIELDS = ["side", "anchor", "effector", "offset", "orient"];
17
17
  /** spec.objects[id]: a hinge or a carry (a body or a group id), see C3; carry keys: C14; a switch (any record, C15) moves nothing; a follow (any record, C16) rides a point of the body; a free entry (C17) moves by keys and falls. */
@@ -48,7 +48,7 @@ export const EXAMPLES = {
48
48
  };
49
49
 
50
50
  const UNITS = `World metres, +Y up; ${CONVENTIONS.yaw}; the character's left is +X at yaw 0. Angles in degrees.`;
51
- const NO_JUMP = "Without at/along it keeps its grab-frame offset to the palm: no jump.";
51
+ const NO_JUMP = "Without at/along it keeps its grab-frame palm offset: no jump.";
52
52
  /** The objects example's carry: a grip prop and the object it holds. */
53
53
  const CARRY = { props: EXAMPLES.objects.props, objects: { cup: EXAMPLES.objects.objects.cup } };
54
54
 
@@ -65,13 +65,14 @@ export const NOTES = {
65
65
  anchors: ["An anchor is a named point a hand can go to: key w_<anchor>_l / w_<anchor>_r from 0 to 1 with armw_<side> 1 (the IK arm).",
66
66
  "An anchor name may not end in _l or _r.", "object anchors (a body or a group; local in its own axes) follow the object where it is (hinges, carries): put the other hand on a carried object with one; the carrying hand's own anchor stays where it took it. prop anchors are a point of a prop the other hand holds.",
67
67
  "segment with reach slides from t toward tMin until the arm reaches it.",
68
+ "An object anchor with grasp (power, pinch, hook, palm) takes the hand by a socket of its object and the compiler turns the hand: power and hook put the knuckle line along the socket's axis with the palm on it, pinch holds at the fingertips, palm presses flat on the nearest face. socket names one (object.grasp sockets the user or you stored, or a thin part by its id: a bar, a grip, a magazine); without it the socket nearest local, or the shoulder. Two hands on one carried object: each hand an object anchor with grasp on its part (a rifle's grip and magazine). reachGrip on such an anchor closes the fingers to the grasp. Never key the wrist for it; a wrist you key (wtwist/wflex) wins.",
68
69
  "person { person: <character id>, bone, offset? } is another character's joint where its installed take has it that frame (a hand on a shoulder, a push on the chest): install that character's take first; reachGrip on it allows the hand to touch that person."],
69
70
  props: ["A prop is a set of points fixed to one hand, [d, l, n] along the palm axes; prop:<prop>.origin is the mean of its origin bones.",
70
71
  "fits: the hand target moves so the effector (a prop point or the mean of joints), not the wrist, lands on the anchor.",
71
72
  "A fit on a scene body also turns the hand about the forearm into a grip: a thin body (a can, a bar, a handle, a neck) across the palm, else thumb up; never past a comfortable forearm turn. A wrist you turn (wtwist/wflex) wins; fit.orient: false (reachGrip args.orient: false) turns it off.",
72
73
  "reachGrip adds props.<anchor>Grip and its fit. Carry a scene object with objects.<id>.prop (topic objects)."],
73
74
  objects: ["A hinge turns the object (a body or a group) about pivot/axis by the angle channel your keys set (degrees).",
74
- "A carry follows the prop's hand: before grab the scene pose, grab..release in the hand, after release frozen where it was let go.",
75
+ "A carry follows the prop's hand: before grab the scene pose, grab..release in the hand, after release where it was let go, riding a body under it that keys or a hinge move.",
75
76
  `No grab: in hand from frame 0; no release: to the end. ${NO_JUMP}`,
76
77
  "A group (Empty) id moves all its parts; at/along pin a group's root to the palm too (an object.createKit kit). A carried object is an obstacle for the body and the scene.",
77
78
  "A switch { visible?, level? } (no prop, no hinge field) moves nothing: it shows or hides any record (a part, a light, an effect) and sets its level, 0..4 light, glow and effect strength "
@@ -3,7 +3,7 @@
3
3
  // looks each field up here, and throws when one is missing, so a new field needs a type here.
4
4
  // Shared shapes are $refs into DEFS (describe.js adds the ones an answer uses).
5
5
  import { REF_KINDS, OBJECT_KEY_FIELDS } from "./fields.js";
6
- import { CONVENTIONS } from "../compiler/vocab.js";
6
+ import { CONVENTIONS, GRASPS } from "../compiler/vocab.js";
7
7
  import { QA_LIMITS } from "../qa/checks.js";
8
8
  import { CAPSULES } from "../qa/geometry.js";
9
9
 
@@ -64,7 +64,7 @@ export const ANCHOR_TYPES = {
64
64
  from: str("bone"), to: str("bone"), t: num("0 = from .. 1 = to", { default: 0.5 }), tMin: num("with reach: the lowest t to slide back to", { default: 0.15 }),
65
65
  reach: side("slide toward `from` until this arm reaches"), object: str("scene object id"), local: ref("vec3", "point in the object's frame, m"),
66
66
  name: { const: "mouth" }, prop: str("a prop held by the other hand"), point: str("a point of that prop, or origin"),
67
- person: str("a character id"),
67
+ person: str("a character id"), socket: str("a socket of the object"), grasp: { enum: GRASPS, description: "the hand takes it by a socket" },
68
68
  };
69
69
 
70
70
  const palm = d => ref("vec3", `${d} [d, l, n] on the palm axes (d to the middle finger, l to the thumb side, n = d x l), m`);
@@ -4,6 +4,10 @@ import { add, apply, ry } from "../compiler/math.js";
4
4
  import { walk } from "../compiler/walk.js";
5
5
  import { keyBook, skillError } from "./book.js";
6
6
  import { walkEnd } from "./info.js";
7
+ import { GRASP_FINGERS, GRASP_POINTS } from "../compiler/vocab.js";
8
+
9
+ /** The fingers' point a reachGrip fits onto its anchor: the finger middles, or where a grasp holds the thing (the palm is -n on the right hand). */
10
+ const graspPoint = (grasp, k) => (GRASP_POINTS[grasp] ? GRASP_POINTS[grasp].map((v, i) => (i === 2 && k === "r" ? -v : v)) : [0, 0, 0]);
7
11
 
8
12
  /** standIdle({ at: [x, z], heading = 0, until }): a neutral standing pose, arms down (FK). */
9
13
  export function standIdle(ctx) {
@@ -59,7 +63,9 @@ export function reachGrip(ctx) {
59
63
  const { args, state } = ctx, k = args.side, name = args.anchor;
60
64
  if (k !== "l" && k !== "r") skillError(ctx.tag, `args.side must be "l" or "r", got ${JSON.stringify(k)}`);
61
65
  if (typeof name !== "string" || /_[lr]$/.test(name)) skillError(ctx.tag, "args.anchor must name an anchor (not ending in _l/_r)");
62
- const reach = args.reach ?? 20, hold = args.hold ?? 22, release = args.release ?? 12, preset = args.fingers ?? "grip";
66
+ const reach = args.reach ?? 20, hold = args.hold ?? 22, release = args.release ?? 12;
67
+ // an anchor that takes a grasp closes the hand to that grasp's fingers (../sockets.js)
68
+ const grasp = (args.define ?? ctx.anchors?.[name])?.grasp, preset = args.fingers ?? GRASP_FINGERS[grasp] ?? "grip";
63
69
  const w = `w_${name}_${k}`, fg = `fingers_${k}`, armw = `armw_${k}`, el = `elbow_${k}`, wasFk = !(state[armw] > 0.5);
64
70
  // a take that ends after the hold but before the hand lets go cuts the release: the grip holds to the last frame
65
71
  const last = ctx.frames - 1, cut = ctx.f0 + reach + hold <= last && ctx.f0 + reach + hold + release > last;
@@ -75,7 +81,7 @@ export function reachGrip(ctx) {
75
81
  const a = args.define ?? ctx.anchors?.[name], obj = a?.kind === "object" ? a.object : a?.kind === "person" ? `person:${a.person}` : null;
76
82
  return {
77
83
  keys: b.keys, end: cut ? last : ctx.f0 + reach + hold + release, ...(cut ? { cut: `release cut at f${last} (hold to the end)` } : {}), anchors: def,
78
- props: { [prop]: { hand: k, origin: [`${S}HandIndex2`, `${S}HandMiddle2`], points: { fingers: [0, 0, 0] } } },
84
+ props: { [prop]: { hand: k, origin: [`${S}HandIndex2`, `${S}HandMiddle2`], points: { fingers: graspPoint(grasp, k) } } },
79
85
  fits: [{ side: k, anchor: name, effector: { prop, point: "fingers" }, ...(args.orient === false ? { orient: false } : {}) }],
80
86
  checks: { contacts: [{ name: `fingers on ${name}`, point: `prop:${prop}.fingers`, target: `anchor:${name}`, tol: 0.04, when: w }],
81
87
  allow: obj ? [{ part: "hand", object: obj, depth: 0.04 }] : [] },
@@ -34,10 +34,10 @@ export const SKILL_INFO = {
34
34
  },
35
35
  reachGrip: {
36
36
  args: { anchor: str("not ending in _l/_r"), side: side("the hand"), reach: int("frames to reach", { default: 20 }), hold: int("frames held", { default: 22 }),
37
- release: int("frames to let go", { default: 12 }), elbow: ref("vec3", "elbow pole"), fingers: { enum: FINGER_PRESETS, default: "grip" },
38
- define: { type: "object", description: "an anchor (topic anchors) named args.anchor" }, orient: { type: "boolean", description: "false: no grip turn" } },
39
- required: ["anchor", "side"], needs: "args.anchor in spec.anchors or args.define", frames: "reach + hold + release (54 by default)",
40
- adds: ["props.<anchor>Grip (fingers point)", "fits: fingers onto the anchor", "checks.contacts \"fingers on <anchor>\"", "checks.allow hand on the anchor's object",
37
+ release: int("frames to let go", { default: 12 }), elbow: ref("vec3", "pole"), fingers: { enum: FINGER_PRESETS, default: "grip" },
38
+ define: { type: "object", description: "defines anchor args.anchor" }, orient: { type: "boolean", description: "false: no grip turn" } },
39
+ required: ["anchor", "side"], needs: "args.anchor in anchors or define", frames: "reach + hold + release (54)",
40
+ adds: ["props.<anchor>Grip (fingers point)", "fits: fingers on the anchor", "checks.contacts \"fingers on <anchor>\"", "checks.allow hand on its object",
41
41
  "keys: w_<anchor>_<side>, fingers_<side>, armw_<side>"],
42
42
  example: { f0: 10, args: { anchor: "bar", side: "r", define: { kind: "world", p: [-0.2, 1.2, 0.33] } } },
43
43
  tune: { reach: 2, release: 2 },
@@ -0,0 +1,104 @@
1
+ // Grasp sockets (lane socket): where and how a hand takes hold of an object, so the agent names the part and the
2
+ // grasp and the compiler turns the hand. The grasp is the object's (sessions 02263581, 38322b5d: the agent computed
3
+ // the wrist by hand and held a barbell 90 deg off, the bar through the palm; a rifle's second hand was measured off
4
+ // the first). A socket is, in its record's own axes (metres from the base pivot, as an object anchor's `local`):
5
+ // at the point the fingers close on
6
+ // axis the line that runs across the palm, the knuckle line along it (a bar, a handle, a grip); none: any
7
+ // from the side the hand comes from: the palm faces against it; none: the roll the arm holds most easily
8
+ // (compiler/socket-grip.js), or on a body with no axis the face nearest the point
9
+ // thumb the end of the axis the thumb takes; none: the end-state-comfort end (socketPalm)
10
+ // A record stores named sockets in `grasp` (src/object-grasp.js); every thin body (two half sizes under THIN m: a
11
+ // bar, a can, a handle) is one too, named by its id, through its centre along its long axis. An object anchor with
12
+ // `grasp` (power, pinch, hook, palm) takes the hand by a socket of its object: `socket` names it, else the one
13
+ // nearest the anchor's `local` point or, without one, nearest the shoulder of the hand, picked once per compile.
14
+ import { add, sub, scale, norm, cross, dot, len, apply, ry, T } from "./compiler/math.js";
15
+ import { partsOf } from "./object-pose.js";
16
+
17
+ const THIN = 0.045, AXES = [[1, 0, 0], [0, 1, 0], [0, 0, 1]];
18
+ const flat = (v, n) => sub(v, scale(n, dot(v, n)));
19
+
20
+ /** The long axis index of a thin body's half sizes, else -1. */
21
+ function thinAxis(half) {
22
+ if (!half) return -1;
23
+ const long = [0, 1, 2].reduce((a, i) => (half[i] > half[a] ? i : a), 0);
24
+ return [0, 1, 2].filter(i => i !== long).every(i => half[i] < THIN) ? long : -1;
25
+ }
26
+
27
+ /** The record's stored sockets: a body's in scene.objects, a group's (an Empty) in scene.others. */
28
+ const stored = (scene, id) => scene?.objects?.[id]?.grasp ?? scene?.others?.[id]?.grasp ?? null;
29
+
30
+ /**
31
+ * Every socket of body or group `id`: [{ name, owner, at, axis?, from?, thumb? }], each in its owner's axes. The record's
32
+ * own sockets by name; a part's as "<part>.<name>"; a thin part's derived one by the part id.
33
+ */
34
+ export function socketsOf(scene, id) {
35
+ const out = [], own = (owner, prefix) => Object.entries(stored(scene, owner) ?? {}).forEach(([name, s]) => out.push({ ...s, name: prefix + name, owner }));
36
+ own(id, "");
37
+ for (const part of partsOf(scene, id)) {
38
+ if (part !== id) own(part, `${part}.`);
39
+ const half = scene.objects[part].half, long = thinAxis(half);
40
+ if (long >= 0) out.push({ name: part, owner: part, at: [0, half[1], 0], axis: AXES[long] });
41
+ }
42
+ return out;
43
+ }
44
+
45
+ /** The socket of `id` named `name`, or undefined. */
46
+ export const socketNamed = (scene, id, name) => socketsOf(scene, id).find(s => s.name === name);
47
+
48
+ /** A socket in world space on its owner's pose { pos, R }: { p, axis?, from?, thumb? }. */
49
+ export function socketWorld(socket, pose) {
50
+ const dir = v => (v ? norm(apply(pose.R, v)) : undefined);
51
+ return { p: add(pose.pos, apply(pose.R, socket.at ?? [0, 0, 0])), axis: dir(socket.axis), from: dir(socket.from), thumb: dir(socket.thumb) };
52
+ }
53
+
54
+ /** The outward normal of the face of the box (of boxes [{ pos, R, half }], base pivots) nearest world point p, or undefined. */
55
+ export function faceFrom(boxes, p) {
56
+ let best;
57
+ for (const b of boxes) {
58
+ const q = apply(T(b.R), sub(p, add(b.pos, apply(b.R, [0, b.half[1], 0])))), out = [0, 1, 2].map(i => Math.abs(q[i]) - b.half[i]);
59
+ const i = [0, 1, 2].reduce((m, j) => (out[j] > out[m] ? j : m), 0), gap = Math.max(...out);
60
+ if (!best || gap < best.gap) best = { gap, n: scale([b.R[0][i], b.R[1][i], b.R[2][i]], q[i] < 0 ? -1 : 1) };
61
+ }
62
+ return best?.n;
63
+ }
64
+
65
+ /** How far world point q is from a world socket: from its axis line through p, else from p. */
66
+ export function socketDistance(w, q) {
67
+ const v = sub(q, w.p);
68
+ return len(w.axis ? flat(v, w.axis) : v);
69
+ }
70
+
71
+ /**
72
+ * The palm frame (columns d, l, n of compiler/limbs.js palmAxes: d to the middle finger, l to the thumb side, n = d x
73
+ * l; the palm is -n on the right hand, +n on the left) a grasp of `type` takes on world socket w, the forearm along fwd,
74
+ * the body turned yawDeg. Along an axis (not a flat palm): with `from` the palm faces against it and the knuckle line
75
+ * runs along the axis; without, the fingers stay on the forearm's line across the axis (a power grip turns the forearm,
76
+ * not the wrist) and the palm closes on it. The thumb takes `thumb`'s end, else the end-state-comfort end (people take
77
+ * the grip that ends comfortable, Rosenbaum): up when the forearm is level, toward the midline when it points down or
78
+ * up. Without an axis the palm faces against `from` (anchorSocket gives a box face's), the fingers on the forearm's line.
79
+ */
80
+ export function socketPalm(side, w, type, fwd, yawDeg = 0) {
81
+ const right = side === "Right", sign = v => (right ? v : scale(v, -1));
82
+ // a side along the axis leaves no knuckle line across it: the palm then just faces against the side (below)
83
+ if (w.axis && type !== "palm" && !(w.from && len(flat(w.axis, w.from)) < 1e-3)) {
84
+ if (w.from) {
85
+ const n = sign(w.from), l0 = norm(flat(w.axis, n)), pick = l => (w.thumb ? dot(l, w.thumb) : dot(cross(l, n), fwd));
86
+ const l = pick(l0) >= pick(scale(l0, -1)) ? l0 : scale(l0, -1);
87
+ return cols(cross(l, n), l, n);
88
+ }
89
+ const d = across(fwd, w.axis), medial = apply(ry(yawDeg), [right ? 1 : -1, 0, 0]), vy = Math.abs(d[1]);
90
+ const want = w.thumb ?? flat(add(scale([0, 1, 0], 1 - vy), scale(medial, vy)), d), l0 = norm(flat(w.axis, d));
91
+ const l = dot(l0, want) >= 0 ? l0 : scale(l0, -1);
92
+ return cols(d, l, cross(d, l));
93
+ }
94
+ const n = sign(w.from ?? scale(fwd, -1)), d = across(fwd, n);
95
+ return cols(d, cross(n, d), n);
96
+ }
97
+
98
+ /** fwd made perpendicular to v (unit); any perpendicular when fwd runs along v. */
99
+ function across(fwd, v) {
100
+ const d = flat(fwd, v);
101
+ return norm(len(d) > 1e-3 ? d : flat(Math.abs(v[1]) < 0.9 ? [0, 1, 0] : [1, 0, 0], v));
102
+ }
103
+
104
+ const cols = (a, b, c) => [[a[0], b[0], c[0]], [a[1], b[1], c[1]], [a[2], b[2], c[2]]];
@@ -148,7 +148,7 @@ export function renderTakeEdit(take) {
148
148
  if (track.pos) { next.pos = new Float32Array(D * 3); at.forEach(({ i0, i1, t }, f) => lerp(next.pos, f * 3, track.pos, i0 * 3, track.pos, i1 * 3, t, 3)); }
149
149
  if (track.quat) { next.quat = new Float32Array(D * 4); at.forEach(({ i0, i1, t }, f) => nlerp(next.quat, f * 4, track.quat, i0 * 4, track.quat, i1 * 4, t)); }
150
150
  if (track.lvl) { next.lvl = new Float32Array(D); at.forEach(({ i0, i1, t }, f) => lerp(next.lvl, f, track.lvl, i0, track.lvl, i1, t, 1)); }
151
- for (const key of ["vis", "hold"]) if (track[key]) { next[key] = new Uint8Array(D); at.forEach(({ near }, f) => { next[key][f] = track[key][near]; }); }
151
+ for (const key of ["vis", "hold", "world"]) if (track[key]) { next[key] = new Uint8Array(D); at.forEach(({ near }, f) => { next[key][f] = track[key][near]; }); }
152
152
  return [id, next];
153
153
  }));
154
154
  return { ...take, frameCount: D, frames: D, rot, hips, objects, edit, editRendered: true };
@@ -6,12 +6,13 @@
6
6
  // world space: one track per spec.objects key (a group: its root only). A switch entry (C15)
7
7
  // bakes only vis and lvl, no pos/quat: its record keeps its own place (object-travel.js). A follow
8
8
  // entry (C16) bakes pos/quat with hold on every frame: its track is world space throughout. A free entry
9
- // (C17) bakes pos/quat (and vis/lvl when it names them), world space, no hold.
9
+ // (C17) bakes pos/quat (and vis/lvl when it names them), world space, no hold. A carry that rides a moving body after
10
+ // its release (object-pose.js riderOf) marks those frames `world`: world space too, though no hand holds it.
10
11
  import { toLocal } from "./compiler/skeleton.js";
11
12
  import { sub, apply, ry, quatFromMat, mul } from "./compiler/math.js";
12
13
  import { isCarried, isFollow, isSwitch } from "./carried.js";
13
14
  import { isFree } from "./free-motion.js";
14
- import { rootPoseAt, spanOf } from "./object-pose.js";
15
+ import { rootPoseAt, spanOf, riderOf } from "./object-pose.js";
15
16
 
16
17
  const TAKE_SCHEMA = "cozyclay.take.v3";
17
18
 
@@ -36,7 +37,7 @@ export function bakeTake(sk, out, placement = { position: [0, 0, 0], yaw: 0 }, m
36
37
  const pos = moves ? new Float32Array(F * 3) : null, quat = moves ? new Float32Array(F * 4) : null;
37
38
  const named = rides || !moves || isFree(spec), vis = named && spec.visible ? new Uint8Array(F) : null, hold = rides ? new Uint8Array(F) : null;
38
39
  const lvl = named && spec.level ? new Float32Array(F) : null;
39
- const [grab, release] = carried ? spanOf(out, spec) : [0, 0];
40
+ const [grab, release] = carried ? spanOf(out, spec) : [0, 0], world = carried && riderOf(out, id) ? new Uint8Array(F) : null;
40
41
  for (let f = 0; f < F; f++) {
41
42
  if (vis) vis[f] = (out.baked?.[spec.visible]?.[f + f0] ?? 0) > 0.5 ? 1 : 0;
42
43
  if (lvl) lvl[f] = Math.max(0, Math.min(4, out.baked?.[spec.level]?.[f + f0] ?? 1));
@@ -45,8 +46,9 @@ export function bakeTake(sk, out, placement = { position: [0, 0, 0], yaw: 0 }, m
45
46
  if (!o) continue;
46
47
  pos.set(o.pos, f * 3); quat.set(quatFromMat(o.R), f * 4);
47
48
  if (hold) hold[f] = follows || (f + f0 >= grab && f + f0 <= release) ? 1 : 0;
49
+ if (world) world[f] = f + f0 > release ? 1 : 0;
48
50
  }
49
- objects[id] = { ...(moves ? { pos, quat } : {}), ...(vis ? { vis } : {}), ...(hold ? { hold } : {}), ...(lvl ? { lvl } : {}) };
51
+ objects[id] = { ...(moves ? { pos, quat } : {}), ...(vis ? { vis } : {}), ...(hold ? { hold } : {}), ...(world ? { world } : {}), ...(lvl ? { lvl } : {}) };
50
52
  }
51
53
  return { schema: TAKE_SCHEMA, fps: out.fps, frameCount: F, rig: sk.model, bones: sk.names.slice(), rot, hips, objects, placement, ...meta };
52
54
  }
@@ -7,6 +7,7 @@ const GROUP_LABELS_KO = {
7
7
  "Set pieces": "세트 소품",
8
8
  Lights: "조명",
9
9
  Effects: "효과",
10
+ Text: "텍스트",
10
11
  };
11
12
 
12
13
  const OBJECT_LABELS_KO = {
@@ -24,6 +25,7 @@ const OBJECT_LABELS_KO = {
24
25
  "Spot light": "스포트 조명",
25
26
  Light: "조명",
26
27
  Effect: "효과",
28
+ Text: "텍스트",
27
29
  };
28
30
 
29
31
  function displayObjectGroupName(name) {
@@ -73,7 +75,7 @@ export function CatalogueEntries({ onPick }) {
73
75
  <span className={`add-object-swatch ${entry.kind}`} style={{ background: entry.color }} aria-hidden="true" />
74
76
  <span>{displayObjectLabel(entry.label)}</span>
75
77
  {/* An empty has no size to quote: say what it is instead of "0 × 0 m". */}
76
- <small>{entry.light ? ko("practical light", "실제 조명") : entry.kind === EMPTY_KIND ? ko("node only", "노드 전용") : entry.kind === "effect" ? ko("smoke, fire, rain…", "연기, 불, 비…") : `${entry.footprint.width} × ${entry.footprint.depth} m`}</small>
78
+ <small>{entry.light ? ko("practical light", "실제 조명") : entry.kind === EMPTY_KIND ? ko("node only", "노드 전용") : entry.kind === "effect" ? ko("smoke, fire, rain…", "연기, 불, 비…") : entry.kind === "text" ? ko("words, signs, logos", "글자, 간판, 로고") : `${entry.footprint.width} × ${entry.footprint.depth} m`}</small>
77
79
  </button>
78
80
  ))}
79
81
  </div>
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Grasp sockets on a record (`grasp`): where and how a hand takes hold of it, in the record's own axes, so they move
3
+ * with it and save with it. { <name>: { at: [x, y, z] m from the base pivot, axis?: the line across the palm (a bar,
4
+ * a handle), from?: the side the hand comes from (the palm faces against it), thumb?: the axis end the thumb takes } }.
5
+ * A motion spec's object anchor with `grasp` takes a hand by one (src/motion3/sockets.js), and a thin body is a socket
6
+ * of its own without one. At most MAX sockets, names of letters, digits, _ and -.
7
+ */
8
+ const MAX = 8, NAME = /^[A-Za-z0-9_-]{1,32}$/, REACH = 10;
9
+
10
+ const vec = (v, limit) => (Array.isArray(v) && v.length === 3 && v.every(Number.isFinite) && v.every(x => Math.abs(x) <= limit) ? v.map(x => Math.round(x * 1e4) / 1e4) : null);
11
+ const dir = v => { const u = vec(v, 1e3), n = u && Math.hypot(...u); return n > 1e-6 ? u.map(x => Math.round((x / n) * 1e4) / 1e4) : null; };
12
+
13
+ /** The repaired sockets of a record, or null for none (invalid entries are dropped). */
14
+ function normalizeObjectGrasp(value) {
15
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
16
+ const out = {};
17
+ for (const [name, s] of Object.entries(value)) {
18
+ if (Object.keys(out).length >= MAX || !NAME.test(name) || !s || typeof s !== "object") continue;
19
+ const at = vec(s.at, REACH);
20
+ if (!at) continue;
21
+ out[name] = { at };
22
+ for (const k of ["axis", "from", "thumb"]) { const d = dir(s[k]); if (d) out[name][k] = d; }
23
+ }
24
+ return Object.keys(out).length ? out : null;
25
+ }
26
+
27
+ /** The grasp field of a stored record (normalizeSceneObject): kept when it has a socket. */
28
+ export function objectGraspFields(record) {
29
+ const grasp = normalizeObjectGrasp(record?.grasp);
30
+ return grasp ? { grasp } : {};
31
+ }
32
+
33
+ /** The grasp part of an object.update patch: the sockets replace the record's own; `grasp: null` removes them. Only a change is returned. */
34
+ export function objectGraspPatch(object, patch) {
35
+ if (patch?.grasp === undefined) return {};
36
+ const next = patch.grasp === null ? null : normalizeObjectGrasp(patch.grasp);
37
+ return JSON.stringify(next) === JSON.stringify(object.grasp ?? null) ? {} : { grasp: next };
38
+ }
@@ -194,3 +194,5 @@ export function withShadowCapWarnings(result, objects) {
194
194
  const warnings = shadowCapWarnings(objects, result?.affectedIds ?? []);
195
195
  return warnings.length ? { ...result, warnings: [...(result.warnings ?? []), ...warnings] } : result;
196
196
  }
197
+ // A record's grasp sockets ride the same normalize and patch paths (object-grasp.js).
198
+ export { objectGraspFields, objectGraspPatch } from "./object-grasp.js";
@@ -115,12 +115,15 @@ export function trackLevel(id, frame, take) {
115
115
  }
116
116
 
117
117
  /**
118
- * True while a compiled take holds the record in a hand (a grabbed cup): its track is in world
119
- * space then, so the carry from its own parents (the table it stood on) is cut, like `attach`.
118
+ * True while a compiled take holds the record in a hand (a grabbed cup), or the record rides a
119
+ * moving body after its release (`world`, take.js): its track is in world space then, so the
120
+ * carry from its own parents (the table it stood on) is cut, like `attach`.
120
121
  */
121
122
  function held(id, frame, take) {
122
123
  const track = take?.objectTracks?.get?.(id);
123
- return !!track?.hold && track.hold[trackFrame(track, frame)] === 1;
124
+ if (!track) return false;
125
+ const at = trackFrame(track, frame);
126
+ return track.hold?.[at] === 1 || track.world?.[at] === 1;
124
127
  }
125
128
 
126
129
  /** The record's own route sample (or compiled track) as a matrix, or null when it does not travel. */