cozyclay 3.0.0 → 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 (101) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/THIRD_PARTY_NOTICES.md +10 -0
  3. package/bin/agent/agent-routes.mjs +3 -3
  4. package/bin/agent/agent-runner.mjs +5 -5
  5. package/bin/agent/pi-tools.mjs +12 -1
  6. package/bin/agent/session-store.mjs +2 -2
  7. package/bin/agent/studio-context-budget.mjs +99 -0
  8. package/bin/agent/studio-prompt.mjs +1 -1
  9. package/bin/agent/studio-tools.mjs +1 -1
  10. package/dist/app/index.html +4 -4
  11. package/dist/assets/analytics-DbKG0NgD.js +6 -0
  12. package/dist/assets/app-34i-iUy1.js +5088 -0
  13. package/dist/assets/compile-worker-DYpa2nvO.js +5 -0
  14. package/dist/assets/{first-shot-handoff-CC4xe4HM.js → first-shot-handoff-CrGZ3n_O.js} +1 -1
  15. package/dist/assets/helvetiker-bold.typeface-BUlyMz0-.js +13 -0
  16. package/dist/assets/{landing-DjHD8u7k.js → landing-mq5bnqFE.js} +1 -1
  17. package/dist/assets/scene-asset-sync-7J-w5oNh.js +20 -0
  18. package/dist/assets/workflow-dUhurYUc.js +179 -0
  19. package/dist/cozyclay-package.json +1 -1
  20. package/dist/index.html +3 -3
  21. package/dist/workflow/index.html +3 -3
  22. package/package.json +1 -1
  23. package/src/App.jsx +1 -1
  24. package/src/agent-progress.js +3 -2
  25. package/src/agent-turn-mark.js +33 -0
  26. package/src/app-context.js +7 -2
  27. package/src/command-bus.js +10 -11
  28. package/src/commands/elements/character.js +2 -1
  29. package/src/commands/elements/motion.js +7 -1
  30. package/src/commands/elements/scene.js +3 -0
  31. package/src/commands/elements/stage.js +2 -1
  32. package/src/commands/elements/take-read.js +31 -0
  33. package/src/commands/elements.js +16 -2
  34. package/src/commands/motion3.js +15 -6
  35. package/src/commands/objects.js +2 -2
  36. package/src/commands/shot.js +6 -2
  37. package/src/domains/motion/take-source.js +4 -2
  38. package/src/draft-ghost.js +3 -2
  39. package/src/motion/beat-board-model.js +1 -1
  40. package/src/motion/beat-board.jsx +2 -1
  41. package/src/motion3/compile-job.js +4 -0
  42. package/src/motion3/compiler/anchors.js +68 -4
  43. package/src/motion3/compiler/compile.js +5 -1
  44. package/src/motion3/compiler/grip.js +8 -2
  45. package/src/motion3/compiler/limbs.js +35 -8
  46. package/src/motion3/compiler/pose.js +17 -2
  47. package/src/motion3/compiler/socket-grip.js +107 -0
  48. package/src/motion3/compiler/validate-objects.js +3 -2
  49. package/src/motion3/compiler/validate-parts.js +9 -2
  50. package/src/motion3/compiler/validate.js +3 -3
  51. package/src/motion3/compiler/vocab.js +23 -16
  52. package/src/motion3/describe.js +5 -2
  53. package/src/motion3/docs.js +1 -1
  54. package/src/motion3/free-motion.js +54 -15
  55. package/src/motion3/object-keys.js +36 -5
  56. package/src/motion3/object-pose.js +39 -6
  57. package/src/motion3/people.js +82 -0
  58. package/src/motion3/qa/checks.js +6 -2
  59. package/src/motion3/qa/hints-contact.js +17 -1
  60. package/src/motion3/qa/index.js +6 -4
  61. package/src/motion3/qa/people-checks.js +37 -0
  62. package/src/motion3/qa/style.js +14 -4
  63. package/src/motion3/refs.js +10 -1
  64. package/src/motion3/scene.js +4 -3
  65. package/src/motion3/schema/fields.js +4 -4
  66. package/src/motion3/schema/topics.js +9 -6
  67. package/src/motion3/schema/types.js +11 -9
  68. package/src/motion3/settle.js +136 -0
  69. package/src/motion3/skills/basic.js +10 -3
  70. package/src/motion3/skills/flip.js +200 -0
  71. package/src/motion3/skills/impact.js +109 -0
  72. package/src/motion3/skills/index.js +3 -1
  73. package/src/motion3/skills/info.js +19 -4
  74. package/src/motion3/sockets.js +104 -0
  75. package/src/motion3/take-edit.js +1 -1
  76. package/src/motion3/take.js +6 -4
  77. package/src/object-catalog.jsx +3 -1
  78. package/src/object-grasp.js +38 -0
  79. package/src/object-look.js +2 -0
  80. package/src/object-travel.js +6 -3
  81. package/src/panels/ObjectLookPanel.jsx +24 -3
  82. package/src/props.jsx +5 -5
  83. package/src/qa-label.js +25 -0
  84. package/src/scene-objects.js +19 -11
  85. package/src/shell/details-selection.js +2 -1
  86. package/src/studio-actions.js +4 -0
  87. package/src/studio-agent-protocol.js +11 -12
  88. package/src/studio-app-binding.js +3 -3
  89. package/src/studio-elements.js +3 -1
  90. package/src/studio-error-codes.js +8 -1
  91. package/src/text/fonts/helvetiker-LICENSE.txt +13 -0
  92. package/src/text/fonts/helvetiker-bold.typeface.json +1 -0
  93. package/src/text/text-object.jsx +106 -0
  94. package/src/text/text-record.js +122 -0
  95. package/src/workflow/AgentProgressCard.jsx +6 -4
  96. package/tools/run-tests.mjs +2 -2
  97. package/dist/assets/analytics-C9sUnndd.js +0 -1
  98. package/dist/assets/app-CCh--1Hc.js +0 -5087
  99. package/dist/assets/compile-worker-CkxiD6lH.js +0 -5
  100. package/dist/assets/scene-asset-sync-Hx_FzdOH.js +0 -20
  101. package/dist/assets/workflow-C4yKH1p4.js +0 -179
@@ -4,10 +4,14 @@
4
4
  // origin) at frame f, on the curve held keys use (object-keys.js keyedPose, C14). aim turns the
5
5
  // scene pose's +Y onto aim, then roll degrees about it. Before the first key of a run it stays
6
6
  // where it was (its scene pose, or where it landed), after the last it holds the last key.
7
- // fall { at, onto?, bounce? } or a list from where it is at frame `at` it drops straight down
8
- // under gravity (G m/s^2, no tumbling) onto the top of the highest still body under the centre of
9
- // its parts (onto: that object's top; nothing below: the floor, y 0), bounces at most once (bounce:
10
- // the restitution 0..0.6, default 0.25, never higher than BOUNCE_MAX m) and rests there.
7
+ // fall { at, onto?, bounce?, tumble? } or a list from where it is at frame `at` it falls under gravity
8
+ // (G m/s^2) onto the top of the highest still body under the centre of its parts (onto: that object's
9
+ // top; nothing below: the floor, y 0) and comes to rest on a stable face (settle.js): it lands on its
10
+ // lowest point, turning at the spin it was let go with, rolls flat and over an edge onto a lower face
11
+ // while the drop has the energy (a phone falls flat, a can lies down). tumble: false drops it straight
12
+ // down as it was, bouncing at most once (bounce: the restitution 0..0.6, default 0.25, never higher
13
+ // than BOUNCE_MAX m); one that lands on a stable face as it falls bounces the same way, and one that
14
+ // rolls spends its landing on the roll and does not bounce (bounce is not read for it).
11
15
  // Keys and falls chain in frame order: a fall starts from the pose the keys before it leave (a key on the
12
16
  // fall's own frame comes first), keys after a fall take over at their first frame.
13
17
  // A carry (C3) takes fall too: before its grab (the hand finds it where it landed) or from its release on.
@@ -18,13 +22,16 @@
18
22
  import { isCarried, compose, relative, movesRecord } from "./carried.js";
19
23
  import { groupsOf } from "./scene.js";
20
24
  import { curveOf, keyedPose } from "./object-keys.js";
21
- import { add, apply, sub, T, quatFromMat } from "./compiler/math.js";
25
+ import { add, apply, sub, T, quatFromMat, I3 } from "./compiler/math.js";
26
+ import { shapeOf, airPath, settlePath, settledAt, spinOf, spinEnergy, TIP } from "./settle.js";
22
27
 
23
28
  /** Gravity, m/s^2. */
24
29
  export const G = 9.8;
25
30
  /** The highest a bounce goes, metres. */
26
31
  export const BOUNCE_MAX = 0.03;
27
- 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;
28
35
  const HELD_OR_HINGED = ["prop", "follow", "pivot", "axis", "channel"];
29
36
 
30
37
  /** A free entry (C17): no prop, follow or hinge field, and keys or fall. */
@@ -36,14 +43,14 @@ const restOf = (scene, id) => scene?.objects?.[id] ?? groupsOf(scene)[id] ?? nul
36
43
  const partsIn = (scene, id) => Object.keys(scene?.objects ?? {}).filter(k => k === id || scene.objects[k].ancestors?.includes(id));
37
44
 
38
45
  /** The 8 corners of a base-pivot box { pos, R, half }. */
39
- function corners(box) {
46
+ export function corners(box) {
40
47
  const out = [];
41
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]])));
42
49
  return out;
43
50
  }
44
- const topOf = box => Math.max(...corners(box).map(p => p[1]));
51
+ export const topOf = box => Math.max(...corners(box).map(p => p[1]));
45
52
  /** True when the vertical through x/z point p crosses the footprint of box. */
46
- 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]; };
47
54
 
48
55
  /** Bodies a spec.objects entry moves (itself or a group above it): they hold nothing up. */
49
56
  function moversIn(spec, scene) {
@@ -76,9 +83,31 @@ export function landingOf(spec, scene, id, start, onto = null) {
76
83
  return { ...best, h: Math.max(0, low - best.top) };
77
84
  }
78
85
 
79
- /** A fall from root pose `start` at (mapped) frame t0: its timing, its pose at a frame, where it rests. */
80
- function fallPlan(spec, scene, id, start, t0, fall, fps) {
81
- const land = landingOf(spec, scene, id, start, fall.onto ?? null), h = land.h, tl = Math.sqrt(2 * h / G);
86
+ const shapes = new WeakMap();
87
+ /** The shape of `id` in its root frame (settle.js), once per scene. */
88
+ function shapeIn(scene, id) {
89
+ if (!shapes.has(scene)) shapes.set(scene, new Map());
90
+ const cache = shapes.get(scene);
91
+ if (!cache.has(id)) cache.set(id, shapeOf(boxesAtRoot(scene, id, { pos: [0, 0, 0], R: I3() })));
92
+ return cache.get(id);
93
+ }
94
+
95
+ /**
96
+ * A fall from root pose `start` at (mapped) frame t0: its timing, its pose at a frame, where it rests. `before` is its pose
97
+ * a frame earlier (a release from the hand), for the spin it is let go with.
98
+ */
99
+ function fallPlan(spec, scene, id, start, t0, fall, fps, before = null) {
100
+ const land = landingOf(spec, scene, id, start, fall.onto ?? null), shape = fall.tumble === false ? null : shapeIn(scene, id);
101
+ if (shape) {
102
+ const spin = before ? spinOf(before.R, start.R, fps) : [0, 0, 0], air = airPath(shape, start, spin, land.top, fps);
103
+ // a spin tips it the way its top moves (spin x up); a spin about the vertical tips nothing
104
+ 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)) {
106
+ const at = f => { const t = (f - t0) / fps; return t <= 0 ? start : t <= air.tl ? air.at(t) : settledAt(path, t - air.tl); };
107
+ 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
+ }
109
+ }
110
+ const h = land.h, tl = Math.sqrt(2 * h / G);
82
111
  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;
83
112
  const rest = { pos: [start.pos[0], start.pos[1] - h, start.pos[2]], R: start.R };
84
113
  const at = f => {
@@ -157,11 +186,21 @@ export function beforeGrab(ctx, id, o, f, grab) {
157
186
  return walk(timelineOf({ ...ctx, grab }, id, o, ctx.timeMap, "before"), f, { pos: rest.pos, R: rest.R }, planner(ctx, id));
158
187
  }
159
188
 
160
- /** Where carry `id` (entry o) is at absolute f after its release, from its release pose; null without a fall from the release on. */
161
- export function afterRelease(ctx, id, o, f, release, releasePose) {
189
+ /**
190
+ * Where carry `id` (entry o) is at absolute f after its release, from its release pose; null without a fall from the release on.
191
+ * `beforePose` (a frame before the release) gives the spin it is let go with to a fall that starts right at the release.
192
+ */
193
+ export function afterRelease(ctx, id, o, f, release, releasePose, beforePose = null) {
162
194
  const events = fallsOf(o).length ? timelineOf({ ...ctx, release }, id, o, ctx.timeMap, "after") : [];
163
195
  // the release pose comes from the solved body, which a rebake changes: plan these falls afresh
164
- return events.length ? walk(events, f, releasePose, planner(ctx, id), false) : null;
196
+ const plan = (start, fall) => fallPlan(ctx.spec, ctx.scene, id, start, fall.t, fall, ctx.spec.fps ?? 30, fall.t <= release + 1 ? beforePose : null);
197
+ return events.length ? walk(events, f, releasePose, plan, false) : null;
198
+ }
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);
165
204
  }
166
205
 
167
206
  /** The falls of entry `id` as planned under the current time map, for readers (timeline, tests): [{ t0, land, end, h, support, top }]. */
@@ -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());
@@ -127,8 +158,10 @@ export function rootPoseAt(out, id, f, now = null) {
127
158
  if (isCarried(o) && (rest || !keepsOffset(scene, id, o))) {
128
159
  const [grab, release] = spanOf(out, o), g = Math.max(grab, Math.min(f, release));
129
160
  if (f < grab && rest) return restingAt(out, id, f);
130
- const after = f > release ? afterRelease(freeCtx(out), id, o, f, release, carriedAt(out, id, o, release)) : null;
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;
@@ -0,0 +1,82 @@
1
+ // The other people in a compile: every other character with an installed take v3, sampled once per take frame into
2
+ // world joint positions (the take's local rotations by FK, at the character's stage placement, as playback draws it).
3
+ // scene.people[id] = { name, rig, frames, names, P: Float64Array(frames * names.length * 3) } (scene.js motionScene
4
+ // leaves it out; commands/motion3.js adds it). QA measures the compiled body against theirs (qa/people-checks.js,
5
+ // PERSON), an anchor can sit on one of their bones ({ kind: "person" }, compiler/anchors.js) and a ref can name one
6
+ // (person:<id>.<Bone>, refs.js). Frames are timeline frames; past a take's end its last frame holds.
7
+ import { skeletonFor } from "./rigs/index.js";
8
+ import { fk } from "./compiler/skeleton.js";
9
+ import { add, apply, ry } from "./compiler/math.js";
10
+ import { resolveBone } from "./bones.js";
11
+
12
+ const sampled = new WeakMap();
13
+ // joints are kept to 1e-5 m: what a saved take keeps of them (keptPeople) is then exactly what its compile read
14
+ const keep = v => Math.round(v * 1e5) / 1e5;
15
+
16
+ /** World joints of an installed take at a placement { position, yaw }, every frame (cached per take and placement). */
17
+ function sampleTake(take, placement) {
18
+ const key = JSON.stringify(placement), hit = sampled.get(take);
19
+ if (hit?.key === key) return hit.value;
20
+ const sk = skeletonFor(take.rig), B = take.bones.length, n = take.frameCount, names = sk.names, J = names.length;
21
+ const order = names.map(name => take.bones.indexOf(name)), R = ry(placement.yaw), P = new Float64Array(n * J * 3);
22
+ for (let f = 0; f < n; f++) {
23
+ const quats = order.map(b => (b < 0 ? [0, 0, 0, 1] : Array.from(take.rot.subarray((f * B + b) * 4, (f * B + b) * 4 + 4))));
24
+ const s = fk(sk, quats, Array.from(take.hips.subarray(f * 3, f * 3 + 3)));
25
+ for (let j = 0; j < J; j++) P.set(add(placement.position, apply(R, s.P[j])).map(keep), (f * J + j) * 3);
26
+ }
27
+ const value = { rig: take.rig, frames: n, names, P };
28
+ sampled.set(take, { key, value });
29
+ return value;
30
+ }
31
+
32
+ /**
33
+ * scene.people for a compile of character `selfId`: characters = the cast ({ id, subject, model, x, y, z, rot, hidden }),
34
+ * takeOf(id) = its installed take v3 or nothing. Hidden characters and ones without a take are left out.
35
+ */
36
+ export function peopleOf(characters, selfId, takeOf) {
37
+ const out = {};
38
+ for (const c of characters ?? []) {
39
+ if (!c || c.id === selfId || c.hidden) continue;
40
+ const take = takeOf(c.id);
41
+ if (take?.schema !== "cozyclay.take.v3" || !take.frameCount || !skeletonFor(take.rig)) continue;
42
+ out[c.id] = { name: c.subject || c.name || c.id, ...sampleTake(take, { position: [c.x ?? 0, c.y ?? 0, c.z ?? 0], yaw: c.rot ?? 0 }) };
43
+ }
44
+ return out;
45
+ }
46
+
47
+ /** The person's joint `bone` at timeline frame f as [x, y, z], or null (unknown person or bone). */
48
+ export function personJoint(scene, id, bone, f) {
49
+ const p = scene?.people?.[id];
50
+ if (!p) return null;
51
+ const j = p.names.indexOf(resolveBone(skeletonFor(p.rig), bone).bone ?? "");
52
+ if (j < 0) return null;
53
+ const at = (Math.max(0, Math.min(p.frames - 1, Math.round(f))) * p.names.length + j) * 3;
54
+ return [p.P[at], p.P[at + 1], p.P[at + 2]];
55
+ }
56
+
57
+ /** The ids and names a person ref or anchor may name, for an error. */
58
+ export const peopleList = scene => Object.entries(scene?.people ?? {}).map(([id, p]) => `${id} (${p.name})`).join(", ") || "none: no other character has an installed take";
59
+
60
+ /**
61
+ * What a take keeps of the people so that its saved source compiles the same again without the others' takes
62
+ * (take-source.js): only the joints its person anchors and refs name (anywhere in the spec: anchors, a skill's
63
+ * define, goals, probes; a bare person:<id> in a skill's from is that person's Hips), as plain arrays (a saved project is JSON), or null.
64
+ */
65
+ export function keptPeople(scene, spec) {
66
+ const want = new Map(), keep = (id, b) => {
67
+ const p = scene?.people?.[id], bone = p && resolveBone(skeletonFor(p.rig), b).bone;
68
+ if (bone) want.set(id, new Set([...(want.get(id) ?? []), bone]));
69
+ };
70
+ const walk = (v, key) => {
71
+ if (typeof v === "string") { const m = /^person:([^.]+)(?:\.(.+))?$/.exec(v); if (m && (m[2] || key === "from")) keep(m[1], m[2] ?? "Hips"); }
72
+ else if (Array.isArray(v)) v.forEach(x => walk(x));
73
+ else if (v && typeof v === "object") { if (v.kind === "person") keep(v.person, v.bone); for (const [k, x] of Object.entries(v)) walk(x, k); }
74
+ };
75
+ walk(spec);
76
+ if (!want.size) return null;
77
+ return Object.fromEntries([...want].map(([id, bones]) => {
78
+ const p = scene.people[id], names = [...bones], J = p.names.length, P = [];
79
+ for (let f = 0; f < p.frames; f++) for (const b of names) { const i = (f * J + p.names.indexOf(b)) * 3; P.push(p.P[i], p.P[i + 1], p.P[i + 2]); }
80
+ return [id, { name: p.name, rig: p.rig, frames: p.frames, names, P }];
81
+ }));
82
+ }
@@ -58,11 +58,15 @@ export function generalChecks(sk, out, opts = {}) {
58
58
  for (const [nm, p, r] of probes) { const d = headDepth(sk, s, p) + r; if (d > L.headAllow) push("FACE", `${nm} into head`, f, d, L.headAllow); }
59
59
  if (segDist(s.P[I.LeftLeg], s.P[I.LeftFoot], s.P[I.RightLeg], s.P[I.RightFoot]) < 0.1 + L.self) push("SELF", "shins touch", f, 0.1, 0.1);
60
60
  if (segDist(s.P[I.LeftUpLeg], s.P[I.LeftLeg], s.P[I.RightUpLeg], s.P[I.RightLeg]) < 0.135 + L.self) push("SELF", "thighs overlap", f, 0.135, 0.135);
61
+ const tilted = norm(sub(s.P[I.Spine], s.P[I.Hips]))[1] < 0.5;
61
62
  for (const S of ["Left", "Right"]) {
62
63
  const th = s.P[I[`${S}UpLeg`]], kn = s.P[I[`${S}Leg`]], an = s.P[I[`${S}Foot`]];
63
64
  const bend = angle(sub(kn, th), sub(an, kn));
64
- // the foot's heading on the ground; the toe sits below the ankle, which must not count
65
- const toeDir = sub(s.P[I[`${S}ToeBase`]], an); toeDir[1] = 0;
65
+ // the foot's heading on the ground; the toe sits below the ankle, which must not count. With the pelvis turned past
66
+ // 60 deg from upright (a flip, a cartwheel, lying) the ground says nothing about the leg: the foot's front is the toe
67
+ // across the shin, which an upright leg reads the same way
68
+ let toeDir = sub(s.P[I[`${S}ToeBase`]], an);
69
+ if (tilted) { const shin = norm(sub(an, kn)); toeDir = sub(toeDir, scale(shin, dot(toeDir, shin))); } else toeDir[1] = 0;
66
70
  const mid = sub(kn, [(th[0] + an[0]) / 2, (th[1] + an[1]) / 2, (th[2] + an[2]) / 2]);
67
71
  if (bend > 3 && dot(mid, toeDir) < -0.002) push("KNEE", `${S} knee bends backward`, f, bend, 0);
68
72
  // a knee folded past its stop puts the shin in the thigh (the solver stops at KNEE_STOP)
@@ -8,6 +8,7 @@ import { kneeFoldHint, kneeWhy } from "./hints-knee.js";
8
8
  import { objectFrame } from "../compiler/anchors.js";
9
9
  import { bodyFor, bodyNormal, limbDepth } from "../body.js";
10
10
  import { footProbes, footDepths } from "./foot-checks.js";
11
+ import { personCapsules } from "./people-checks.js";
11
12
 
12
13
  const MARGIN = 0.003;
13
14
  /** The most a foot hint lifts a foot over a body it went into through a side (a stair riser); higher, it backs out. */
@@ -19,7 +20,7 @@ function anchorFix(X, name, f, delta, k) {
19
20
  const a = X.spec.anchors?.[name], path = field => `/anchors/${name}/${field}`;
20
21
  if (a?.kind === "world") return { path: path("p"), delta };
21
22
  if (a?.kind === "body") return { path: path("p"), delta: apply(ry(-(X.fr(f).c.yaw ?? 0)), delta) };
22
- if (["bone", "segment", "prop"].includes(a?.kind)) return { path: path("offset"), delta };
23
+ if (["bone", "segment", "prop", "person"].includes(a?.kind)) return { path: path("offset"), delta };
23
24
  if (a?.kind !== "object") return undefined;
24
25
  // local is in the object's axes where that hand's target read it (anchors.js objectFrame)
25
26
  const fr = X.fr(f), o = objectFrame(X.out.ctx, a, name, { ...fr, diag: { ...fr.diag, frame: f } }, k);
@@ -105,6 +106,21 @@ export const contactHints = {
105
106
  }
106
107
  return r;
107
108
  },
109
+ /** PERSON "<part> x person:<id>": the part moves out of the other person's deepest capsule, along the line between their nearest points. */
110
+ PERSON(X, g) {
111
+ const [, part, id] = /^(\S+) x person:(.+)$/.exec(g.subject) ?? [], mine = capsule(part), p0 = X.out?.ctx?.scene?.people?.[id];
112
+ if (!mine || !p0) return null;
113
+ const f = g.worstFrame, { s } = X.fr(f), pts = segPoints(s.P[X.I[mine[1]]], s.P[X.I[mine[2]]]);
114
+ const near = ([other, C, D, q]) => {
115
+ const cd = sub(D, C), dd = Math.max(dot(cd, cd), 1e-12);
116
+ return pts.map(p => { const at = add(C, scale(cd, clamp(dot(sub(p, C), cd) / dd, 0, 1))); return { other, p, q: at, depth: mine[3] + q - len(sub(p, at)) }; })
117
+ .reduce((m, x) => (x.depth > m.depth ? x : m));
118
+ };
119
+ const hit = personCapsules(p0, f).map(near).reduce((m, x) => (x.depth > m.depth ? x : m));
120
+ const away = len(sub(hit.p, hit.q)) > 1e-6 ? norm(sub(hit.p, hit.q)) : norm([hit.p[0] - hit.q[0] || 1, 0, hit.p[2] - hit.q[2]]);
121
+ const r = moveHint(X, part, f, scale(away, g.worst - g.limit + MARGIN), ` (out of ${p0.name}'s ${hit.other})`);
122
+ return { ...r, hint: `${r.hint}; or, for a touch you mean, checks.allow [{ part: "${part}", object: "person:${id}", depth: ${m3(g.worst + MARGIN)} }]` };
123
+ },
108
124
  CLEARANCE(X, g) {
109
125
  const k = (X.spec.checks?.clearance ?? []).find(c => g.subject.startsWith(`${c.name}:`));
110
126
  return k && boxHint(X, g, k.part, k.object, k.min - g.worst + MARGIN);
@@ -1,13 +1,15 @@
1
1
  // Motion QA entry: general checks on every take, the checks the spec declares, the objects it
2
- // moves (object-checks.js), its goals (goals.js) and the style checks it opted into by naming
2
+ // moves (object-checks.js), the other people in the scene (people-checks.js, PERSON), its goals (goals.js) and the style checks it opted into by naming
3
3
  // their limit (style.js; otherwise style is measured, not judged: observeMotion). Groups are
4
- // ranked by severity.js.
4
+ // ranked by severity.js. total counts frame-level issues (a goal held on "all" frames adds one per failing frame); problems
5
+ // counts the groups, which is what a person reads (src/qa-label.js).
5
6
  import { generalChecks, QA_LIMITS } from "./checks.js";
6
7
  import { styleChecks, floatChecks } from "./style.js";
7
8
  import { specChecks } from "./spec-checks.js";
8
9
  import { objectChecks } from "./object-checks.js";
9
10
  import { goalChecks } from "./goals.js";
10
11
  import { wristIssues } from "./wrist-checks.js";
12
+ import { peopleChecks } from "./people-checks.js";
11
13
  import { explainGroups } from "./explain.js";
12
14
  import { LOW_IS_BAD, groupKey, rankGroups } from "./severity.js";
13
15
 
@@ -34,9 +36,9 @@ export function runMotionQa(sk, out, spec, opts = {}) {
34
36
  const checks = spec.checks ?? {}, goals = goalChecks(out, spec.goals ?? []);
35
37
  const issues = [...generalChecks(sk, out, { allow: checks.allow, limits: checks.limits, mouthPoints: checks.mouthPoints }), ...specChecks(out, checks),
36
38
  ...objectChecks(sk, out, spec), ...floatChecks(out, checks),
37
- ...styleChecks(sk, out, checks, { ...QA_LIMITS, ...checks.limits }), ...wristIssues(sk, out), ...goals.issues];
39
+ ...styleChecks(sk, out, checks, { ...QA_LIMITS, ...checks.limits }), ...wristIssues(sk, out), ...peopleChecks(sk, out, checks), ...goals.issues];
38
40
  if (out.f0) for (const i of issues) i.frame += out.f0;
39
41
  const groups = summarizeIssues(issues);
40
42
  if (opts.explain !== false) explainGroups(sk, out, spec, groups, { userSpec: opts.userSpec });
41
- return { ok: issues.length === 0, total: issues.length, ...(spec.goals ? { goals: goals.results } : {}), groups };
43
+ return { ok: issues.length === 0, total: issues.length, problems: groups.length, ...(spec.goals ? { goals: goals.results } : {}), groups };
42
44
  }
@@ -0,0 +1,37 @@
1
+ // PERSON: the compiled body against the other people in the scene (people.js), capsule against capsule (geometry.js
2
+ // CAPSULES on both bodies, at the same timeline frame). "<part> x person:<id>": how deep the part goes into the
3
+ // other body (its deepest capsule there; the hint names which), past QA_LIMITS.allow. An intended touch (a push, a hand
4
+ // on a shoulder) is allowed like any contact: checks.allow [{ part, object: "person:<id>", depth }].
5
+ import { segDist, CAPSULES } from "./geometry.js";
6
+ import { allowance, QA_LIMITS } from "./checks.js";
7
+ import { dist } from "../compiler/math.js";
8
+
9
+ /** Two bodies whose hips are this far apart (m) cannot touch: a body is under 1 m from its hips to any skin. */
10
+ const APART = 2.2;
11
+
12
+ /** The person's capsule endpoints at timeline frame f: [part, A, B, r] with the joints read from P. */
13
+ export function personCapsules(p, f) {
14
+ const at = Math.max(0, Math.min(p.frames - 1, f)) * p.names.length, J = name => { const j = p.names.indexOf(name), i = (at + j) * 3; return [p.P[i], p.P[i + 1], p.P[i + 2]]; };
15
+ return CAPSULES.map(([part, a, b, r]) => [part, J(a), J(b), r]);
16
+ }
17
+
18
+ /** PERSON issues of compile result out (frames indexed in out.frames; reported at their absolute frame by qa/index.js). */
19
+ export function peopleChecks(sk, out, checks = {}) {
20
+ const people = Object.entries(out.ctx?.scene?.people ?? {}), issues = [];
21
+ if (!people.length) return issues;
22
+ const I = sk.index, f0 = out.f0 ?? 0, lim = { ...QA_LIMITS, ...checks.limits }.allow;
23
+ out.frames.forEach(({ s }, i) => {
24
+ const f = i + f0, mine = CAPSULES.map(([part, a, b, r]) => [part, s.P[I[a]], s.P[I[b]], r]);
25
+ for (const [id, p] of people) {
26
+ // a saved take keeps only the joints its anchors read (people.js keptPeople): no body to collide with there
27
+ if (!CAPSULES.every(([, a, b]) => p.names.includes(a) && p.names.includes(b))) continue;
28
+ const theirs = personCapsules(p, f), hips = theirs.find(c => c[0] === "hips")[1];
29
+ if (dist(s.P[I.Hips], hips) > APART) continue;
30
+ for (const [part, A, B, r] of mine) {
31
+ const depth = Math.max(...theirs.map(([, C, D, q]) => r + q - segDist(A, B, C, D))), allow = allowance(checks.allow, part, `person:${id}`) ?? lim;
32
+ if (depth > allow) issues.push({ kind: "PERSON", msg: `${part} x person:${id}`, frame: i, value: +depth.toFixed(4), limit: allow });
33
+ }
34
+ }
35
+ });
36
+ return issues;
37
+ }
@@ -10,7 +10,7 @@
10
10
  // See docs/motion-api-v3.1.md C13.
11
11
  import { add, sub, scale, len, dot, angle } from "../compiler/math.js";
12
12
  import { CAPSULES, segPoints, sdBox } from "./geometry.js";
13
- import { groundSole } from "./ground.js";
13
+ import { groundSole, groundY } from "./ground.js";
14
14
 
15
15
  const SPEED_JOINTS = ["Hips", "Head", "LeftHand", "RightHand", "LeftFoot", "RightFoot", "LeftLeg", "RightLeg", "LeftForeArm", "RightForeArm"];
16
16
  const CORE = new Set(["Hips", "Head"]);
@@ -68,9 +68,12 @@ export function popChecks(sk, out, L, push) {
68
68
  }
69
69
  }
70
70
 
71
- /** True when a declared support (checks.supports) carries the body at frame f. */
71
+ /** Trunk capsules that rest a body on the floor (lying, sitting on it): within this of the floor, the floor carries it. */
72
+ const FLOOR_REST = ["hips", "torso_lo", "torso_hi"], FLOOR_TOL = 0.04;
73
+ /** True when the floor under the trunk or a declared support (checks.supports) carries the body at frame f. */
72
74
  function supported(out, f, supports) {
73
75
  const { P } = out.frames[f].s, I = out.ctx.sk.index;
76
+ if (CAPSULES.some(([part, a, b, r]) => FLOOR_REST.includes(part) && Math.min(P[I[a]][1], P[I[b]][1]) - r < FLOOR_TOL)) return true;
74
77
  return supports.some(sp => {
75
78
  const box = out.objectsAt(f)[sp.object], cap = CAPSULES.find(c => c[0] === (sp.part ?? "hips"));
76
79
  return box && cap && Math.min(...segPoints(P[I[cap[1]]], P[I[cap[2]]]).map(p => sdBox(p, box))) - cap[3] < (sp.tol ?? 0.04);
@@ -101,11 +104,18 @@ function balanceMargins(out, supports = []) {
101
104
  });
102
105
  }
103
106
 
104
- /** Runs [a, b] (indexes of out.frames) with both soles 2 cm or more above the ground (ground.js) and no declared support. */
107
+ /** True when a hand rests on the ground at frame f: its wrist or knuckles within HAND_DOWN of the ground under them. */
108
+ const HAND_DOWN = 0.06;
109
+ function handDown(out, f) {
110
+ const { P } = out.frames[f].s, I = out.ctx.sk.index;
111
+ return ["Left", "Right"].some(S => [`${S}Hand`, `${S}HandMiddle1`].some(j => I[j] !== undefined && P[I[j]][1] - groundY(out, f, P[I[j]]) < HAND_DOWN));
112
+ }
113
+
114
+ /** Runs [a, b] (indexes of out.frames) with both soles 2 cm or more above the ground (ground.js), no hand on it and no declared support. */
105
115
  function airRuns(out, supports = []) {
106
116
  const n = out.frames.length, runs = [];
107
117
  const air = out.frames.map((_, f) => !supported(out, f, supports)
108
- && ["Left", "Right"].every(S => { const sp = groundSole(out, f, S); return Math.min(sp.heel[1], sp.ball[1]) >= 0.02; }));
118
+ && ["Left", "Right"].every(S => { const sp = groundSole(out, f, S); return Math.min(sp.heel[1], sp.ball[1]) >= 0.02; }) && !handDown(out, f));
109
119
  for (let a = 0; a < n; a++) {
110
120
  if (!air[a]) continue;
111
121
  let b = a;
@@ -4,6 +4,7 @@
4
4
  // prop:<prop>.<point> a point of a held prop (spec.props; "origin" always exists)
5
5
  // anchor:<name> a spec anchor, resolved as the compiler resolves it
6
6
  // landmark:mouth the measured face landmark
7
+ // person:<id>.<Bone> another character's joint where its installed take has it (people.js)
7
8
  // object:<id>[.center|.top|.base] a body or a group (object-pose.js objectBoxesAt): the centre,
8
9
  // top-face centre or bottom-face centre of the AABB of its
9
10
  // part boxes' corners (default center)
@@ -17,8 +18,9 @@ import { bonePoint } from "./compiler/skeleton.js";
17
18
  import { didYouMean } from "./compiler/vocab.js";
18
19
  import { add, apply, sub, len } from "./compiler/math.js";
19
20
  import { sdBox } from "./qa/geometry.js";
21
+ import { personJoint, peopleList } from "./people.js";
20
22
 
21
- const SYNTAX = { joint: "joint:<Bone>", bone: "bone:<Bone>", prop: "prop:<prop>.<point>", anchor: "anchor:<name>", landmark: "landmark:mouth", object: "object:<id>[.center|.top|.base]" };
23
+ const SYNTAX = { joint: "joint:<Bone>", bone: "bone:<Bone>", prop: "prop:<prop>.<point>", anchor: "anchor:<name>", landmark: "landmark:mouth", object: "object:<id>[.center|.top|.base]", person: "person:<id>.<Bone>" };
22
24
 
23
25
  function parse(ref) {
24
26
  const m = /^([a-z]+):(.+)$/s.exec(String(ref));
@@ -26,6 +28,7 @@ function parse(ref) {
26
28
  const [, kind, rest] = m;
27
29
  if (kind === "prop") { const [name, point] = rest.split("."); return { kind, name, point: point ?? "" }; }
28
30
  if (kind === "object") { const o = /^(.+)\.(center|top|base)$/s.exec(rest); return { kind, name: o ? o[1] : rest, point: o ? o[2] : null }; }
31
+ if (kind === "person") { const dot = rest.lastIndexOf("."); return { kind, name: dot > 0 ? rest.slice(0, dot) : rest, point: dot > 0 ? rest.slice(dot + 1) : "" }; }
29
32
  return { kind, name: rest, point: null };
30
33
  }
31
34
 
@@ -50,6 +53,7 @@ export function refPoint(out, f, ref) {
50
53
  case "anchor": return out.ctx.spec.anchors?.[r.name] ? resolveAnchor(out.ctx, r.name, s, { ...diag, frame: f }, c) : null;
51
54
  case "landmark": return sk.landmarks?.[r.name] ? bonePoint(s, sk.index.Head, sk.landmarks[r.name]) : null;
52
55
  case "object": { const boxes = objectBoxesAt(out, r.name, f); return boxes ? boxesPoint(boxes, r.point) : null; }
56
+ case "person": return personJoint(out.ctx.scene, r.name, r.point, f);
53
57
  default: return null;
54
58
  }
55
59
  }
@@ -116,5 +120,10 @@ export function checkRef(spec, err, { sk, scene }, at, ref, kinds = REF_KINDS) {
116
120
  if (r.name !== "mouth") err(`${tag}: the only landmark is "mouth"`);
117
121
  return r.name === "mouth";
118
122
  }
123
+ if (r.kind === "person") {
124
+ const ok = Boolean(personJoint(scene, r.name, r.point, 0));
125
+ if (!ok) err(`${tag}: no person "${r.name}" with joint "${r.point}" (people with an installed take: ${peopleList(scene)})`);
126
+ return ok;
127
+ }
119
128
  return objectOk(err, scene, tag, r.name, { groups: true });
120
129
  }
@@ -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
  }