cozyclay 3.1.6 → 3.2.0

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 (65) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/bin/agent/agent-runner.mjs +9 -4
  3. package/bin/agent/openai-compatible-provider.mjs +24 -1
  4. package/dist/app/index.html +5 -5
  5. package/dist/assets/TaskBoard-BUjjuvm5.js +1 -0
  6. package/dist/assets/{app-CEP7jzXB.css → app-Dige7q3X.css} +1 -1
  7. package/dist/assets/app-DxoI941o.js +5144 -0
  8. package/dist/assets/compile-worker-DRWRa9pr.js +6 -0
  9. package/dist/assets/{motion3-compile-Du7UXrWn.js → motion3-compile-B4SX4TGx.js} +1 -1
  10. package/dist/assets/scene-asset-sync-LeI3eSnO.js +13 -0
  11. package/dist/assets/task-board-5PKn7ZaA.js +15 -0
  12. package/dist/assets/{workflow-DJwOUzqJ.js → workflow-Dg9jCKOq.js} +3 -3
  13. package/dist/cozyclay-package.json +1 -1
  14. package/dist/index.html +1 -1
  15. package/dist/workflow/index.html +3 -3
  16. package/package.json +2 -1
  17. package/src/App.jsx +5 -5
  18. package/src/commands/index.js +2 -1
  19. package/src/commands/physics.js +21 -0
  20. package/src/domains/motion.js +7 -9
  21. package/src/motion/center-of-mass.js +5 -23
  22. package/src/motion/contact-project.js +143 -0
  23. package/src/motion/fix-collisions.js +1 -1
  24. package/src/motion/ik-drag.js +93 -1
  25. package/src/motion/timeline.css +12 -0
  26. package/src/motion/timeline.jsx +2 -2
  27. package/src/motion3/compile-job.js +3 -2
  28. package/src/motion3/compiler/compile.js +3 -3
  29. package/src/motion3/compiler/spring.js +95 -0
  30. package/src/motion3/compiler/validate-objects.js +18 -2
  31. package/src/motion3/compiler/validate-parts.js +1 -0
  32. package/src/motion3/compiler/vocab.js +1 -1
  33. package/src/motion3/fix/index.js +8 -4
  34. package/src/motion3/fix/person.js +110 -0
  35. package/src/motion3/free-motion.js +49 -11
  36. package/src/motion3/object-pose.js +44 -2
  37. package/src/motion3/people.js +16 -1
  38. package/src/motion3/physics-frames.js +72 -0
  39. package/src/motion3/physics-trail.js +45 -0
  40. package/src/motion3/physics-view.js +79 -0
  41. package/src/motion3/pipeline.js +5 -1
  42. package/src/motion3/qa/ballistic.js +67 -0
  43. package/src/motion3/qa/com.js +140 -0
  44. package/src/motion3/qa/ground.js +10 -3
  45. package/src/motion3/qa/hints-contact.js +9 -7
  46. package/src/motion3/qa/hints-motion.js +38 -1
  47. package/src/motion3/qa/key-checks.js +5 -4
  48. package/src/motion3/qa/patch.js +28 -3
  49. package/src/motion3/qa/people-checks.js +1 -1
  50. package/src/motion3/qa/spring-pivot.js +23 -0
  51. package/src/motion3/qa/style.js +13 -30
  52. package/src/motion3/schema/fields.js +4 -2
  53. package/src/motion3/schema/topics.js +15 -11
  54. package/src/motion3/schema/types.js +13 -9
  55. package/src/physics-overlay-settings.js +41 -0
  56. package/src/physics-overlay.js +152 -0
  57. package/src/physics-overlay.jsx +54 -0
  58. package/src/physics-toggle.jsx +20 -0
  59. package/src/posestudio.jsx +1 -1
  60. package/tools/run-tests.mjs +9 -16
  61. package/dist/assets/TaskBoard-DwXhsJXz.js +0 -1
  62. package/dist/assets/app-BLS1Zaru.js +0 -5144
  63. package/dist/assets/compile-worker-or7djI2v.js +0 -6
  64. package/dist/assets/scene-asset-sync-t45pWCyq.js +0 -13
  65. package/dist/assets/task-board-CkLZGPkv.js +0 -15
@@ -0,0 +1,45 @@
1
+ // The physics overlay's motion trail of spring objects (spec.objects[id].spring, compiler/spring.js): the path of
2
+ // each one's tip over the last TRAIL_FRAMES frames, read from the take's own track (what playback draws). The tip
3
+ // is the corner of the record's boxes (its parts, for a group) farthest from its anchor, the pose's origin the
4
+ // spring swings it about (compiler/spring.js swungPose). An object without a spring has no trail.
5
+ // Pure and cached per view (physics-view.js).
6
+ import { add, apply, sub, len, T } from "./compiler/math.js";
7
+ import { trackPose } from "./physics-frames.js";
8
+ import { groupsOf } from "./scene.js";
9
+ import { corners } from "./free-motion.js";
10
+ import { partsOf } from "./object-pose.js";
11
+
12
+ /** How many frames back a trail reaches (the playhead's frame included). */
13
+ export const TRAIL_FRAMES = 24;
14
+ const tips = new WeakMap();
15
+
16
+ /** [{ id, local }]: each spring object the take tracks, with its tip in its own frame. */
17
+ function springTips(view) {
18
+ if (tips.has(view)) return tips.get(view);
19
+ const { scene, spec } = view.out.ctx, groups = groupsOf(scene), list = [];
20
+ for (const [id, o] of Object.entries(spec.objects ?? {})) {
21
+ const rest = scene.objects[id] ?? groups[id];
22
+ if (!o?.spring || !rest || !view.take.objects?.[id]?.pos) continue;
23
+ let local = null;
24
+ for (const c of partsOf(scene, id).flatMap(part => corners(scene.objects[part]))) {
25
+ const q = apply(T(rest.R), sub(c, rest.pos));
26
+ if (!local || len(q) > len(local) + 1e-9) local = q;
27
+ }
28
+ if (local) list.push({ id, local });
29
+ }
30
+ tips.set(view, list);
31
+ return list;
32
+ }
33
+
34
+ /** [{ id, points: [[x, y, z], ...] }]: each spring object's tip from `length` - 1 frames before `frame` up to it, oldest first. */
35
+ export function tipTrails(view, frame, length = TRAIL_FRAMES) {
36
+ const last = Math.max(0, Math.min(view.out.frames.length - 1, Math.round(frame) || 0));
37
+ return springTips(view).map(({ id, local }) => {
38
+ const points = [];
39
+ for (let f = Math.max(0, last - length + 1); f <= last; f++) {
40
+ const pose = trackPose(view.take.objects[id], f);
41
+ if (pose) points.push(add(pose.pos, apply(pose.R, local)));
42
+ }
43
+ return { id, points };
44
+ });
45
+ }
@@ -0,0 +1,79 @@
1
+ // What the physics overlay draws (src/physics-overlay.js), frame by frame, measured by QA's own functions on the
2
+ // installed take (physics-frames.js takeOut), so the overlay's colours are QA's verdicts:
3
+ // comAt the mass-weighted centre of mass (qa/com.js) and its state: "outside" when it sits farther outside
4
+ // the support polygon than the balance limit (checks.limits.balance, else QA_LIMITS.balance: what
5
+ // BALANCE reports), "inside" otherwise, "air" with no foot on the ground, "supported" where a declared
6
+ // support carries the body (qa/style.js balanceFrames)
7
+ // supportAt the convex hull of the sole points on the ground (qa/com.js balanceAt feet), each corner on
8
+ // the ground QA measured that sole point from (balanceAt ground)
9
+ // airArcs per air run of 4 frames or more (qa/style.js airRuns, the runs floatChecks judges): the hips path
10
+ // from the frame before takeoff to the frame after landing, and the gravity parabola through the same
11
+ // two anchors (qa/ballistic.js); float marks the runs QA calls FLOAT
12
+ // Positions are world metres where the take was compiled (take.placement). Pure and cached per take.
13
+ import { takeOut } from "./physics-frames.js";
14
+ import { balanceFrames, airRuns, floatChecks } from "./qa/style.js";
15
+ import { balanceAt, bodyCentre, convexHull } from "./qa/com.js";
16
+ import { groundY } from "./qa/ground.js";
17
+ import { ballisticArc } from "./qa/ballistic.js";
18
+ import { QA_LIMITS } from "./qa/checks.js";
19
+
20
+ /** floatChecks judges runs of this many frames or more. */
21
+ const MIN_RUN = 4;
22
+ const views = new WeakMap();
23
+
24
+ /** The overlay's view of take v3 `take` over the authored records `objects`, or null when it has none. */
25
+ export function physicsView(take, objects = []) {
26
+ const out = takeOut(take, objects);
27
+ if (!out) return null;
28
+ if (!views.has(out)) {
29
+ const checks = out.ctx.spec.checks ?? {};
30
+ views.set(out, { take, out, checks, limit: checks.limits?.balance ?? QA_LIMITS.balance, balance: null, arcs: null });
31
+ }
32
+ return views.get(out);
33
+ }
34
+
35
+ /** The take frame shown at timeline frame `frame` (clamped to the take). */
36
+ const viewFrame = (view, frame) => Math.max(0, Math.min(view.out.frames.length - 1, Math.round(frame) || 0));
37
+
38
+ const balanceOf = view => (view.balance ??= balanceFrames(view.out, view.checks.supports));
39
+
40
+ /** { frame, p: [x, y, z], ground: the ground height under p, state, margin (m, null off the ground) } at `frame`. */
41
+ export function comAt(view, frame) {
42
+ const f = viewFrame(view, frame), b = balanceOf(view)[f], measured = b && b !== "air";
43
+ const p = measured ? b.com : bodyCentre(view.out.ctx.sk, view.out.frames[f].s);
44
+ const state = b === null ? "supported" : b === "air" ? "air" : -b.margin > view.limit ? "outside" : "inside";
45
+ return { frame: f, p, ground: groundY(view.out, f, p), state, margin: measured ? b.margin : null };
46
+ }
47
+
48
+ /**
49
+ * The support polygon at `frame`: its corners [x, y, z] counter-clockwise in (x, z), y the ground QA measured that
50
+ * sole point from (balanceAt ground); [] with no foot down. convexHull keeps the feet's own points, so each corner
51
+ * finds its ground by identity.
52
+ */
53
+ export function supportAt(view, frame) {
54
+ const f = viewFrame(view, frame), b = balanceOf(view)[f] ?? balanceAt(view.out, f);
55
+ if (b === "air") return [];
56
+ return convexHull(b.feet).map(p => [p[0], b.ground[b.feet.indexOf(p)], p[1]]);
57
+ }
58
+
59
+ /**
60
+ * [{ a, b, from, to, float, path, fit, worst }]: per air run [a, b], the hips path from anchor `from` (a - 1) to `to`
61
+ * (b + 1) and the parabola `fit` over the same frames (the hips' x and z, the arc's height); worst = the largest
62
+ * fit - actual height (m). A run that touches either end of the take has no anchor and no arc.
63
+ */
64
+ export function airArcs(view) {
65
+ if (view.arcs) return view.arcs;
66
+ const { out, checks } = view, I = out.ctx.sk.index, hips = out.frames.map(fr => fr.s.P[I.Hips]), y = hips.map(p => p[1]);
67
+ const floats = new Set(floatChecks(out, checks).map(issue => issue.frame)), g = 9.81 / out.fps ** 2;
68
+ view.arcs = [];
69
+ for (const [a, b] of airRuns(out, checks.supports)) {
70
+ const arc = b - a + 1 >= MIN_RUN ? ballisticArc(y, a, b, g, Infinity) : null;
71
+ if (!arc) continue;
72
+ const path = hips.slice(arc.from, arc.to + 1).map(p => [...p]);
73
+ const fit = path.map((p, i) => (i === 0 || i === path.length - 1 ? [...p] : [p[0], p[1] + arc.delta[i - 1], p[2]]));
74
+ let float = true;
75
+ for (let f = a; f <= b; f++) float &&= floats.has(f);
76
+ view.arcs.push({ a, b, from: arc.from, to: arc.to, float, path, fit, worst: arc.delta[arc.worst - a] });
77
+ }
78
+ return view.arcs;
79
+ }
@@ -7,7 +7,8 @@
7
7
  // heldReadings; each hand and foot against the nearest object, qa/near.js observeNear; each holding
8
8
  // wrist's bend, qa/wrist-obs.js observeWrist; how mechanical it reads against the agent's own
9
9
  // keys, qa/motion-life.js observeLife; a skill
10
- // paced to fit a short span: observations.paced; a skill whose release or
10
+ // paced to fit a short span: observations.paced; a carried spring whose base is far from the hand:
11
+ // qa/spring-pivot.js observations.spring; a skill whose release or
11
12
  // settle the last frame cut: observations.cut; what the spec asked against what it got, per skill,
12
13
  // contact and goal: qa/asked.js observeAsked), and the timeline and the
13
14
  // requested probes read the result.
@@ -33,6 +34,7 @@ import { observeWrist, WRIST_UNITS } from "./qa/wrist-obs.js";
33
34
  import { observeLoad, LOAD_UNITS } from "./load.js";
34
35
  import { observeLife, LIFE_UNITS } from "./qa/motion-life.js";
35
36
  import { observeGait, GAIT_UNITS } from "./qa/gait-observe.js";
37
+ import { springPivots } from "./qa/spring-pivot.js";
36
38
  import { planWalks, applyWalkPlans } from "./walk-plan.js";
37
39
  import { relaxSpec } from "./lenient.js";
38
40
  import { observeAsked } from "./qa/asked.js";
@@ -140,6 +142,8 @@ function compilePrepared(prepared, { sk, scene }, opts, notes, refusals) {
140
142
  const placed = used.filter(u => u.placed).map(u => `${u.skill} f${u.f0}: ${u.placed.why}: at [${u.placed.at.join(", ")}], heading ${u.placed.heading}`);
141
143
  if (placed.length) observations.placed = placed;
142
144
  if (plans.length) observations.planned = plans.map(p => p.note);
145
+ const pivots = springPivots(out);
146
+ if (pivots) observations.spring = pivots;
143
147
  const got = observeAsked(out, { used, use: resolved?.use, checks: measured.checks, goals: measured.goals, results: qa.goals });
144
148
  if (got) observations.asked = got;
145
149
  const timeline = timelineOf(out, { used, spec: expanded }), probes = asked && runProbes(out, asked, { spec: expanded, sk, scene, notes });
@@ -0,0 +1,67 @@
1
+ // The ballistic arc a FLOAT run should follow (qa/style.js floatChecks reads the run, this file
2
+ // says how to fix it). Off the ground the hips ride a gravity parabola: anchored at the frame
3
+ // just before takeoff (A = a - 1) and the frame just after landing (B = b + 1), with gravity
4
+ // fixed (g = 9.81 / fps^2 m per frame^2), the launch speed v0 is the one closed form that makes
5
+ // the parabola pass through both anchors:
6
+ // y(f) = yA + v0 (f - A) - g/2 (f - A)^2, v0 = (yB - yA) / T + g T / 2, T = B - A.
7
+ // The fix is that parabola minus the hips height, per frame, vertical only. No anchor (the run
8
+ // touches either end of the take) or a correction past MAX_FIX (the run is too long for the
9
+ // height it has: the arc would throw the body half a metre or more) means no fix; past MAX_FIX
10
+ // arcPrescription gives the apex the run needs, or the run the hips' own apex fits.
11
+ // Pure: numbers in, numbers out.
12
+
13
+ /** A correction past this (m) is not a fix: the run needs fewer frames, not a higher arc. */
14
+ export const MAX_FIX = 0.5;
15
+
16
+ /**
17
+ * ballisticArc(y, a, b, g) -> { from, to, v0, delta, worst } | null
18
+ * @param {ArrayLike<number>} y hips height per frame (index = frame of the same take)
19
+ * @param {number} a first airborne frame (index into y)
20
+ * @param {number} b last airborne frame (index into y)
21
+ * @param {number} g gravity in m per frame^2 (9.81 / fps^2)
22
+ * @returns delta[i] = fitted - actual height at frame a + i; worst = the frame (index into y)
23
+ * of the largest |delta|; from/to = the anchor frames; v0 = the launch speed (m per frame)
24
+ */
25
+ export function ballisticArc(y, a, b, g, maxFix = MAX_FIX) {
26
+ if (!Number.isInteger(a) || !Number.isInteger(b) || b < a || !(g > 0)) return null;
27
+ const A = a - 1, B = b + 1;
28
+ if (A < 0 || B > y.length - 1) return null;
29
+ const yA = y[A], yB = y[B], T = B - A;
30
+ if (!Number.isFinite(yA) || !Number.isFinite(yB)) return null;
31
+ const v0 = (yB - yA) / T + g * T / 2, delta = [];
32
+ let worst = a;
33
+ for (let f = a; f <= b; f++) {
34
+ if (!Number.isFinite(y[f])) return null;
35
+ const t = f - A, d = yA + v0 * t - g / 2 * t * t - y[f];
36
+ delta.push(d);
37
+ if (Math.abs(d) > Math.abs(delta[worst - a])) worst = f;
38
+ }
39
+ if (Math.abs(delta[worst - a]) > maxFix) return null;
40
+ return { from: A, to: B, v0, delta, worst };
41
+ }
42
+
43
+ /**
44
+ * The frames (indexes into y) at 1/4, 1/2 and 3/4 of the way between the anchors, each once,
45
+ * inside the run [a, b] of arc.
46
+ */
47
+ export function arcFrames(arc) {
48
+ const { from: A, to: B } = arc, a = A + 1, b = B - 1, out = [];
49
+ for (const q of [0.25, 0.5, 0.75]) {
50
+ const f = Math.min(b, Math.max(a, Math.round(A + (B - A) * q)));
51
+ if (!out.includes(f)) out.push(f);
52
+ }
53
+ return out;
54
+ }
55
+
56
+ /**
57
+ * arcPrescription(y, a, b, g) -> { off, frames, apex, height, fits } | null: for an arc past MAX_FIX, the numbers that
58
+ * fix the run instead. A flight of N frames (anchor to anchor) peaks g N^2 / 8 above takeoff (apex, g per frame^2:
59
+ * 9.81 (N / fps)^2 / 8); the hips' own apex h above takeoff fits N = sqrt(8 h / g) frames (fits, rounded; 0 when the
60
+ * hips never rise). off = the arc minus the hips at the worst frame. Null when there is no anchor or the arc fits.
61
+ */
62
+ export function arcPrescription(y, a, b, g) {
63
+ const arc = ballisticArc(y, a, b, g, Infinity);
64
+ if (!arc || Math.abs(arc.delta[arc.worst - a]) <= MAX_FIX) return null;
65
+ const frames = arc.to - arc.from, height = Math.max(...Array.from(arc.delta, (_, i) => y[a + i])) - y[arc.from];
66
+ return { off: arc.delta[arc.worst - a], frames, apex: g * frames * frames / 8, height, fits: height > 0 ? Math.round(Math.sqrt(8 * height / g)) : 0 };
67
+ }
@@ -0,0 +1,140 @@
1
+ // Balance measurements on one frame's pose: the mass-weighted centre of mass of the body and the
2
+ // support polygon of the feet on the ground. QA's BALANCE check and observations.balance
3
+ // (style.js) and the BALANCE hint (hints-contact.js) all read them from here, so the issue, the
4
+ // observation and the fix agree on the same point and the same polygon.
5
+ // centre of mass the segment-mass-weighted average of the segment midpoints (SEGMENT_FRACTIONS),
6
+ // from the frame's joint positions; a skeleton missing a segment bone falls back
7
+ // to the average of the hips, the spine and the head.
8
+ // support polygon the 2D convex hull (x, z) of the sole points (heel, ball, tip) of the feet on
9
+ // the ground (ground.js): one foot's points when only one is down. While the hips
10
+ // travel every foot counts (a step is about to land).
11
+ // margin how far the centre of mass's ground projection sits inside the polygon (m, the
12
+ // distance to its nearest edge), < 0 outside it (minus the distance to it).
13
+ import { groundSole } from "./ground.js";
14
+
15
+ /**
16
+ * Segment mass fractions of total body mass (Dempster, WADC TR 55-159, 1955, as tabulated by
17
+ * Winter, "Biomechanics and Motor Control of Human Movement"). The trunk (0.497) is split into
18
+ * pelvis (0.142) and thorax+abdomen (0.355); the hand rides on the forearm and the foot on the
19
+ * shank. Each entry's fraction sits at the midpoint of its two joints (bone names without a rig
20
+ * prefix). The fractions sum to 1. src/motion/center-of-mass.js reads the same table.
21
+ */
22
+ export const SEGMENT_FRACTIONS = [
23
+ { id: "pelvis", start: "Hips", end: "Spine1", mass: 0.142 },
24
+ { id: "torso", start: "Spine1", end: "Neck", mass: 0.355 },
25
+ { id: "head", start: "Neck", end: "Head", mass: 0.081 },
26
+ { id: "leftUpperArm", start: "LeftArm", end: "LeftForeArm", mass: 0.028 },
27
+ { id: "leftForeArmHand", start: "LeftForeArm", end: "LeftHand", mass: 0.022 },
28
+ { id: "rightUpperArm", start: "RightArm", end: "RightForeArm", mass: 0.028 },
29
+ { id: "rightForeArmHand", start: "RightForeArm", end: "RightHand", mass: 0.022 },
30
+ { id: "leftThigh", start: "LeftUpLeg", end: "LeftLeg", mass: 0.100 },
31
+ { id: "leftShankFoot", start: "LeftLeg", end: "LeftFoot", mass: 0.061 },
32
+ { id: "rightThigh", start: "RightUpLeg", end: "RightLeg", mass: 0.100 },
33
+ { id: "rightShankFoot", start: "RightLeg", end: "RightFoot", mass: 0.061 },
34
+ ];
35
+
36
+ /** The joints whose plain average stands in for the centre of mass on a skeleton missing a segment bone. */
37
+ const TRUNK = ["Hips", "Spine1", "Spine2", "Head"];
38
+
39
+ /** Mass-weighted centre of mass [x, y, z] of pose s (world joint positions s.P), or null when sk lacks a segment bone. */
40
+ export function centerOfMass(sk, s) {
41
+ const I = sk.index, c = [0, 0, 0];
42
+ let total = 0;
43
+ for (const { start, end, mass } of SEGMENT_FRACTIONS) {
44
+ const a = s.P[I[start]], b = s.P[I[end]];
45
+ if (I[start] === undefined || I[end] === undefined || !a || !b) return null;
46
+ for (let k = 0; k < 3; k++) c[k] += mass * (a[k] + b[k]) / 2;
47
+ total += mass;
48
+ }
49
+ return c.map(v => v / total);
50
+ }
51
+
52
+ /** centerOfMass, or the average of the hips, the spine and the head where the skeleton cannot give it. */
53
+ export function bodyCentre(sk, s) {
54
+ const com = centerOfMass(sk, s);
55
+ if (com) return com;
56
+ const js = TRUNK.filter(j => sk.index[j] !== undefined);
57
+ return [0, 1, 2].map(k => js.reduce((acc, j) => acc + s.P[sk.index[j]][k], 0) / js.length);
58
+ }
59
+
60
+ const cross = (o, a, b) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
61
+
62
+ /** Convex hull of 2D points [x, z], counter-clockwise in (x, z), collinear points dropped (monotone chain). */
63
+ export function convexHull(points) {
64
+ const p = [...points].sort((a, b) => a[0] - b[0] || a[1] - b[1]);
65
+ if (p.length < 3) return p;
66
+ const half = list => {
67
+ const h = [];
68
+ for (const q of list) {
69
+ while (h.length >= 2 && cross(h[h.length - 2], h[h.length - 1], q) <= 1e-12) h.pop();
70
+ h.push(q);
71
+ }
72
+ h.pop();
73
+ return h;
74
+ };
75
+ return [...half(p), ...half([...p].reverse())];
76
+ }
77
+
78
+ /** Nearest point to c on segment a-b (2D). */
79
+ function onSegment(c, a, b) {
80
+ const d = [b[0] - a[0], b[1] - a[1]], l2 = d[0] * d[0] + d[1] * d[1];
81
+ const t = l2 > 0 ? Math.max(0, Math.min(1, ((c[0] - a[0]) * d[0] + (c[1] - a[1]) * d[1]) / l2)) : 0;
82
+ return [a[0] + t * d[0], a[1] + t * d[1]];
83
+ }
84
+
85
+ /**
86
+ * Where 2D point c ([x, z]) sits against the convex polygon of points: { margin, near }. near is
87
+ * the nearest point of the polygon's boundary; margin is the distance to it, > 0 inside the
88
+ * polygon, < 0 outside (a polygon of one or two points has no inside).
89
+ */
90
+ export function supportMargin(c, points) {
91
+ const H = convexHull(points);
92
+ let near = H[0], best = Infinity;
93
+ for (let i = 0; i < (H.length > 1 ? H.length : 1); i++) {
94
+ const q = H.length > 1 ? onSegment(c, H[i], H[(i + 1) % H.length]) : H[0];
95
+ const d = Math.hypot(q[0] - c[0], q[1] - c[1]);
96
+ if (d < best) { best = d; near = q; }
97
+ }
98
+ const inside = H.length >= 3 && H.every((a, i) => cross(a, H[(i + 1) % H.length], c) >= 0);
99
+ return { margin: inside ? best : -best, near };
100
+ }
101
+
102
+ /**
103
+ * Balance at frame index i of out (out.frames[i]): "air" with no foot on the ground, else
104
+ * { margin, com, near, feet }: the centre of mass (bodyCentre), its margin against the support
105
+ * polygon (supportMargin of its ground projection) and the polygon's nearest point near ([x, z]).
106
+ * feet: the sole points [x, z] that made the polygon; ground: the ground height (groundSole) each
107
+ * of them was measured from, in the same order. Declared supports are style.js's.
108
+ */
109
+ export function balanceAt(out, i) {
110
+ const F = out.frames, P = F[i].s.P, I = out.ctx.sk.index;
111
+ const moving = i > 0 && i < F.length - 1 && Math.hypot(F[i + 1].s.P[I.Hips][0] - F[i - 1].s.P[I.Hips][0], F[i + 1].s.P[I.Hips][2] - F[i - 1].s.P[I.Hips][2]) / 2 > 0.006;
112
+ const feet = [], ground = [];
113
+ for (const S of ["Left", "Right"]) {
114
+ const sp = groundSole(out, i, S);
115
+ if (!moving && Math.min(sp.heel[1], sp.ball[1]) >= 0.02) continue;
116
+ for (const key of ["heel", "ball", "tip"]) { feet.push([sp[key][0], sp[key][2]]); ground.push(sp.ground[key]); }
117
+ }
118
+ if (!feet.length) return "air";
119
+ const com = bodyCentre(out.ctx.sk, F[i].s), { margin, near } = supportMargin([com[0], com[2]], feet);
120
+ return { margin, com, near, feet, ground, facing: facing(P, I) };
121
+ }
122
+
123
+ /** The body's horizontal forward [x, z] from the hip axis (left hip minus right hip, turned a quarter about up). */
124
+ function facing(P, I) {
125
+ const sx = P[I.LeftUpLeg][0] - P[I.RightUpLeg][0], sz = P[I.LeftUpLeg][2] - P[I.RightUpLeg][2], l = Math.hypot(sx, sz) || 1;
126
+ return [-sz / l, sx / l];
127
+ }
128
+
129
+ /**
130
+ * Words for where the centre of mass is off the support polygon: "0.12 m ahead of the feet
131
+ * (toward +Z)". b = balanceAt (outside: margin < 0). Ahead, behind, left and right are the
132
+ * body's (its hips); the axis in brackets is the world's.
133
+ */
134
+ export function offsetWords(b) {
135
+ const o = [b.com[0] - b.near[0], b.com[2] - b.near[1]], [fx, fz] = b.facing;
136
+ const ahead = o[0] * fx + o[1] * fz, left = o[0] * fz - o[1] * fx;
137
+ const side = Math.abs(ahead) >= Math.abs(left) ? (ahead >= 0 ? "ahead of" : "behind") : (left >= 0 ? "left of" : "right of");
138
+ const axis = Math.abs(o[0]) >= Math.abs(o[1]) ? `${o[0] >= 0 ? "+" : "-"}X` : `${o[1] >= 0 ? "+" : "-"}Z`;
139
+ return `${(-b.margin).toFixed(2)} m ${side} the feet (toward ${axis})`;
140
+ }
@@ -52,9 +52,16 @@ export function groundY(out, i, p) {
52
52
  return g;
53
53
  }
54
54
 
55
- /** solePoints of frame index i with y the height above the ground under each point (x, z are world). */
55
+ /**
56
+ * solePoints of frame index i with y the height above the ground under each point (x, z are world);
57
+ * ground: { heel, ball, tip }, the ground heights those were measured from.
58
+ */
56
59
  export function groundSole(out, i, side) {
57
- const sp = solePoints(out.ctx.sk, out.frames[i].s, side);
58
- for (const key of ["heel", "ball", "tip"]) sp[key] = [sp[key][0], sp[key][1] - groundY(out, i, sp[key]), sp[key][2]];
60
+ const sp = solePoints(out.ctx.sk, out.frames[i].s, side), ground = {};
61
+ for (const key of ["heel", "ball", "tip"]) {
62
+ ground[key] = groundY(out, i, sp[key]);
63
+ sp[key] = [sp[key][0], sp[key][1] - ground[key], sp[key][2]];
64
+ }
65
+ sp.ground = ground;
59
66
  return sp;
60
67
  }
@@ -2,7 +2,8 @@
2
2
  // other limb. Each answers a world move (direction x amount) for whatever drives that part.
3
3
  import { add, sub, scale, norm, dot, len, clamp, apply, ry, T } from "../compiler/math.js";
4
4
  import { bonePoint } from "../compiler/skeleton.js";
5
- import { CAPSULES, segPoints, sdBox, boxNormal, headDepth, segDist, solePoints, ankleY } from "./geometry.js";
5
+ import { CAPSULES, segPoints, sdBox, boxNormal, headDepth, segDist, ankleY } from "./geometry.js";
6
+ import { balanceAt, offsetWords } from "./com.js";
6
7
  import { vec, m3 } from "./fmt.js";
7
8
  import { kneeFoldHint, kneeWhy } from "./hints-knee.js";
8
9
  import { objectFrame } from "../compiler/anchors.js";
@@ -184,12 +185,13 @@ export const contactHints = {
184
185
  fix: { channel: ch, f: g.worstFrame, value: clean } };
185
186
  },
186
187
  BALANCE(X, g) {
187
- const f = g.worstFrame, { s } = X.fr(f), P = s.P, I = X.I;
188
+ const f = g.worstFrame;
188
189
  if (g.subject === "no foot on the ground") return { cause: X.cause("root", f), hint: `at f${f} lower foot_l or foot_r onto the ground, the floor or the top of an object under it (the ankle stands ${ankleY(X.sk)} m above it), or declare checks.supports for the seat the hips rest on` };
189
- const com = scale(["Hips", "Spine1", "Spine2", "Head"].reduce((acc, j) => add(acc, P[I[j]]), [0, 0, 0]), 0.25);
190
- const feet = ["Left", "Right"].flatMap(S => Object.values(solePoints(X.sk, s, S)));
191
- const xs = feet.map(p => p[0]), zs = feet.map(p => p[2]), mg = 0.08 - 0.01;
192
- const to = [clamp(com[0], Math.min(...xs) - mg, Math.max(...xs) + mg), com[1], clamp(com[2], Math.min(...zs) - mg, Math.max(...zs) + mg)];
193
- return { cause: X.cause("root", f), hint: `root: move ${vec(sub(to, com))} m at f${f} (centre of mass over the feet), or step a foot under it`, fix: { channel: "root", f, delta: sub(to, com) } };
190
+ // the mass-weighted centre of mass toward the support polygon's nearest point, to within limit - 1 cm of it (com.js)
191
+ const b = balanceAt(X.out, Math.max(0, Math.min(X.out.frames.length - 1, f - (X.out.f0 ?? 0))));
192
+ if (b === "air" || b.margin >= 0) return null;
193
+ const d = -b.margin, keep = Math.min(d, Math.max(0, (g.limit ?? 0.08) - 0.01)), t = (d - keep) / d;
194
+ const delta = [(b.near[0] - b.com[0]) * t, 0, (b.near[1] - b.com[2]) * t];
195
+ return { cause: X.cause("root", f), hint: `root: move ${vec(delta)} m at f${f} (centre of mass ${offsetWords(b)}, toward the nearest edge of the support polygon), or step a foot under it`, fix: { channel: "root", f, delta } };
194
196
  },
195
197
  };
@@ -13,6 +13,7 @@ import { vec, m3 } from "./fmt.js";
13
13
  import { moveHint } from "./hints-contact.js";
14
14
  import { kneeFoldHint, kneeWhy } from "./hints-knee.js";
15
15
  import { bodyMove } from "./hints-goal.js";
16
+ import { ballisticArc, arcFrames, arcPrescription } from "./ballistic.js";
16
17
 
17
18
  const LIMB = { Leg: ["UpLeg", "Leg", "Foot", "knee"], ForeArm: ["Arm", "ForeArm", "Hand", "elbow"] };
18
19
  const sideOf = s => (s.startsWith("Left") ? "l" : "r");
@@ -63,6 +64,33 @@ function probe(X, f, tries, measure) {
63
64
  return { best, base, c0 };
64
65
  }
65
66
 
67
+ /** The ballistic arc of a FLOAT group's run (its subject names f<a>-f<b>): the worst frame, the correction at any frame, the keys that follow it; { presc } past MAX_FIX; null without anchors. */
68
+ function floatArc(X, g) {
69
+ const f0 = X.out.f0 ?? 0, [, a = g.first, b = g.last] = / f(\d+)-f(\d+)/.exec(g.subject)?.map(Number) ?? [];
70
+ const y = X.out.frames.map(fr => fr.s.P[X.I.Hips][1]), G = 9.81 / (X.out.fps ?? 24) ** 2, arc = ballisticArc(y, a - f0, b - f0, G);
71
+ if (!arc) { const p = arcPrescription(y, a - f0, b - f0, G); return p && { presc: { ...p, from: a - 1, to: b + 1 } }; }
72
+ const dyAt = f => Math.round((arc.delta[f - f0 - arc.from - 1] ?? 0) * 1e4) / 1e4, at = q => ({ f: q + f0, dy: dyAt(q + f0) });
73
+ return { ...at(arc.worst), from: arc.from + f0, to: arc.to + f0, dyAt, keys: arcFrames(arc).map(at) };
74
+ }
75
+
76
+ /** Root keys moved up or down by the arc: the agent's root key within 2 frames moves by the arc at its own frame, else a key is added; the anchors get their own height unless a root key sits within 2 frames. */
77
+ function arcOps(X, userSpec, arc) {
78
+ const ops = Array.isArray(userSpec.keys) ? [] : [{ op: "add", path: "/keys", value: [] }], used = new Set(), r4 = v => Math.round(v * 1e4) / 1e4;
79
+ for (const { f, dy } of arc.keys) {
80
+ const i = (userSpec.keys ?? []).findIndex(k => Array.isArray(k?.set?.root) && k.set.root.length === 3 && Math.abs(k.f - f) <= 2 && k.f > arc.from && k.f < arc.to);
81
+ if (i >= 0 && used.has(i)) continue;
82
+ const r = i >= 0 ? userSpec.keys[i].set.root : X.fr(f).c.root, d = i >= 0 ? arc.dyAt(userSpec.keys[i].f) : dy;
83
+ if (!Array.isArray(r)) continue;
84
+ if (i >= 0) used.add(i);
85
+ const value = [r4(r[0]), r4(r[1] + d), r4(r[2])];
86
+ ops.push(i >= 0 ? { op: "replace", path: `/keys/${i}/set/root`, value } : { op: "add", path: "/keys/-", value: { f, set: { root: value } } });
87
+ }
88
+ // the anchors keep their height: without a root key near takeoff and landing the channel would ease into the arc early and hold its last key after
89
+ const near = f => (userSpec.keys ?? []).some(k => Array.isArray(k?.set?.root) && Math.abs(k.f - f) <= 2);
90
+ for (const f of [arc.from, arc.to]) if (!near(f) && Array.isArray(X.fr(f).c.root)) ops.push({ op: "add", path: "/keys/-", value: { f, set: { root: X.fr(f).c.root.map(r4) } } });
91
+ return ops;
92
+ }
93
+
66
94
  const hipOrShoulder = (X, k, f, limb) => X.fr(f).s.P[X.I[`${k === "l" ? "Left" : "Right"}${LIMB[limb][0]}`]];
67
95
 
68
96
  export const motionHints = {
@@ -149,7 +177,16 @@ export const motionHints = {
149
177
  SPEED: (X, g) => speedHint(X, g, 1),
150
178
  POP: (X, g) => speedHint(X, g, 0.5),
151
179
  ACC: (X, g) => speedHint(X, g, 0.5),
152
- FLOAT: (X, g) => ({ cause: X.cause("root", g.worstFrame), hint: `over f${g.first}-f${g.last} lower the root or key foot_l/foot_r back onto the ground, the floor or the top of an object under the foot (the ankle stands ${ankleY(X.sk)} m above it); a jump needs the hips to rise and fall; a seat or ledge the hips rest on goes in checks.supports (a foot on an object's top needs none)` }),
180
+ FLOAT(X, g) {
181
+ const base = `over f${g.first}-f${g.last} lower the root or key foot_l/foot_r back onto the ground, the floor or the top of an object under the foot (the ankle stands ${ankleY(X.sk)} m above it); a jump needs the hips to rise and fall; a seat or ledge the hips rest on goes in checks.supports (a foot on an object's top needs none)`;
182
+ const arc = floatArc(X, g);
183
+ if (!arc) return { cause: X.cause("root", g.worstFrame), hint: base };
184
+ const p = arc.presc, m2 = v => (Math.round(v * 100) / 100).toFixed(2), other = p && p.fits >= 2 && p.fits !== p.frames;
185
+ if (p) return { cause: X.cause("root", g.worstFrame), hint: `hips held ${m2(Math.abs(p.off))} m ${p.off > 0 ? "below" : "above"} a ballistic arc over ${p.frames} frames (f${p.from}-f${p.to}): either ${p.apex > p.height ? "raise" : "lower"} the apex to ${m2(p.apex)} m above takeoff${other ? `, or ${p.fits < p.frames ? "shorten" : "lengthen"} the air run to ${p.fits} frames for this apex (${m2(p.height)} m)` : ""}; or ${base}` };
186
+ const { f, dy } = arc;
187
+ return { cause: X.cause("root", f), hint: `hips should be ${Math.round(Math.abs(dy) * 100)} cm ${dy < 0 ? "lower" : "higher"} at f${f} (ballistic arc from f${arc.from} to f${arc.to}): root ${vec([0, dy, 0])} m there${arc.keys.length > 1 ? ` (keys at ${arc.keys.map(k => `f${k.f} ${vec([0, k.dy, 0])}`).join(", ")} follow the arc)` : ""}; or ${base}`,
188
+ fix: { channel: "root", f, delta: [0, dy, 0], ops: userSpec => arcOps(X, userSpec, arc) } };
189
+ },
153
190
  LENGTH: () => ({ cause: "compiler", hint: "a bone changed length: report the spec, this is a compiler fault" }),
154
191
  CONTACT(X, g) {
155
192
  const ck = (X.spec.checks?.contacts ?? []).find(c => g.subject.startsWith(`${c.name}:`)), f = g.worstFrame, i = X.rel(f);
@@ -8,9 +8,10 @@
8
8
  // wrist turn past its limit after the elbow swung and the forearm turned (lane K8: the whole arm
9
9
  // follows the object, compiler/arm-orient.js).
10
10
  // Measured, not judged (C16, output.observations.held): every key as drawn, so the agent sees how
11
- // far each one is from what the hand can hold without a probe.
11
+ // far each one is from what the hand can hold without a probe. Both read the pose before a spring swings it
12
+ // (object-pose.js basePoseAt): the swing rides on the keys, it is not the hand falling short.
12
13
  import { keyedEntries, curveIn, keyedPose } from "../object-keys.js";
13
- import { rootPoseAt, partsOf } from "../object-pose.js";
14
+ import { basePoseAt, partsOf } from "../object-pose.js";
14
15
  import { relative } from "../carried.js";
15
16
  import { groupsOf } from "../scene.js";
16
17
  import { WRIST_LIMITS } from "../compiler/arm-orient.js";
@@ -36,7 +37,7 @@ export function keyIssues(out, L, push) {
36
37
  const curve = curveIn(out, id), corners = cornersOf(scene, id);
37
38
  if (!corners.length) continue;
38
39
  for (let f = Math.max(f0, Math.ceil(curve.t[0])); f <= Math.min(f0 + out.frames.length - 1, Math.floor(curve.t[curve.t.length - 1])); f++) {
39
- const want = keyedPose(curve, f), got = rootPoseAt(out, id, f);
40
+ const want = keyedPose(curve, f), got = basePoseAt(out, id, f);
40
41
  const off = Math.max(...corners.map(c => dist(add(want.pos, apply(want.R, c)), add(got.pos, apply(got.R, c)))));
41
42
  if (off > L.reach) push("REACH", `${id}: ${(off * 100).toFixed(1)} cm off its keys`, f - f0, off, L.reach);
42
43
  }
@@ -75,7 +76,7 @@ export function heldReadings(out) {
75
76
  // the keys as the compile holds them: a mouth key the compile leaned says so (object-keys.js faceLeaned)
76
77
  const curve = curveIn(out, id), keys = [...(out.ctx.hold?.entry?.(id) ?? o).keys].sort((a, b) => a.f - b.f);
77
78
  return [id, keys.map((k, i) => {
78
- const got = rootPoseAt(out, id, Math.round(curve.t[i])), y = [got.R[0][1], got.R[1][1], got.R[2][1]];
79
+ const got = basePoseAt(out, id, Math.round(curve.t[i])), y = [got.R[0][1], got.R[1][1], got.R[2][1]];
79
80
  const want = k.aim ? norm(k.aim) : apply(matFromQuat(curve.q[i]), [0, 1, 0]), deg = r1(angle(want, y));
80
81
  return { f: k.f, deg, cm: r1(dist(k.pos, got.pos) * 100), ...(deg >= 1 ? { got: y.map(r2) } : {}), ...(k.aim ? { rot: curve.rots[i] } : {}), ...(k.leaned ? { lean: k.lean } : {}) };
81
82
  })];
@@ -9,7 +9,8 @@
9
9
  // { ops: userSpec => [{ op, path, value }] } the operations for the agent's spec (a second hand: load.js)
10
10
  // Each becomes g.patch = [{ op, path, value }] against opts.userSpec (the spec as the agent sent
11
11
  // it, use[] not expanded): a user key that sets the channel within f +-2 -> replace
12
- // /keys/<i>/set/<ch>; a standIdle stance moved sideways -> /use/<i>/args/at; otherwise add
12
+ // /keys/<i>/set/<ch> (a delta: with the agent's keys next to it that hold the same value, so a held
13
+ // pose moves whole instead of sliding from one end to the other); a standIdle stance moved sideways -> /use/<i>/args/at; otherwise add
13
14
  // /keys/- with the frame's value moved. A path fix replaces the value, or adds it when only the
14
15
  // last step is missing (or the top-level /fix object). A hold fix moves what placed the body
15
16
  // at f by the delta's x and z: a standIdle args.at, a walkTo's length (firstStep with 2 steps,
@@ -50,6 +51,27 @@ function userKey(userSpec, ch, f) {
50
51
  return best;
51
52
  }
52
53
 
54
+ /** a and b the same channel value (a number or a vector) within 1e-6. */
55
+ const same = (a, b) => (isNum(a) && isNum(b) ? Math.abs(a - b) <= 1e-6
56
+ : Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((x, j) => isNum(x) && isNum(b[j]) && Math.abs(x - b[j]) <= 1e-6));
57
+
58
+ /**
59
+ * User key i and the agent's keys next to it in frame order (no other source's key of ch between) that set
60
+ * ch to the same value: the hold a delta moves. The order is X.keys(ch) when the context has it, else the
61
+ * agent's own keys by frame.
62
+ */
63
+ function heldSpan(X, userSpec, ch, i) {
64
+ const keys = userSpec.keys, held = j => j >= 0 && same(keys[j]?.set?.[ch], keys[i].set[ch]);
65
+ const order = X?.keys ? X.keys(ch).map(k => Number(/^keys\[(\d+)\]$/.exec(k.src)?.[1] ?? -1))
66
+ : keys.map((k, j) => j).filter(j => keys[j]?.set && Object.hasOwn(keys[j].set, ch) && isNum(keys[j].f)).sort((a, b) => keys[a].f - keys[b].f);
67
+ const at = order.indexOf(i);
68
+ if (at < 0) return [i];
69
+ let a = at, b = at;
70
+ while (a > 0 && held(order[a - 1])) a -= 1;
71
+ while (b < order.length - 1 && held(order[b + 1])) b += 1;
72
+ return order.slice(a, b + 1);
73
+ }
74
+
53
75
  /** A standIdle stance moved by the root's sideways delta: /use/<i>/args/at. */
54
76
  function standPatch(X, userSpec, fix, near) {
55
77
  const m = /^use\[(\d+)\] standIdle$/.exec(near?.src ?? ""), d = fix.delta;
@@ -105,8 +127,11 @@ function channelPatch(X, userSpec, fix) {
105
127
  const to = v => (fix.value !== undefined ? fix.value : moved(v, fix.delta));
106
128
  const i = userKey(userSpec, ch, f);
107
129
  if (i >= 0) {
108
- const value = to(userSpec.keys[i].set[ch]);
109
- return value === undefined ? null : [{ op: "replace", path: pointer(["keys", i, "set", ch]), value: round4(value) }];
130
+ const ops = (fix.delta !== undefined ? heldSpan(X, userSpec, ch, i) : [i]).map(j => {
131
+ const value = to(userSpec.keys[j].set[ch]);
132
+ return value === undefined ? null : { op: "replace", path: pointer(["keys", j, "set", ch]), value: round4(value) };
133
+ });
134
+ return ops.includes(null) ? null : ops;
110
135
  }
111
136
  if (!X?.fr) return null;
112
137
  const { prev, next } = X.bracket(ch, f);
@@ -7,7 +7,7 @@ import { allowance, QA_LIMITS } from "./checks.js";
7
7
  import { dist } from "../compiler/math.js";
8
8
 
9
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;
10
+ export const APART = 2.2;
11
11
 
12
12
  /** The person's capsule endpoints at timeline frame f: [part, A, B, r] with the joints read from P. */
13
13
  export function personCapsules(p, f) {
@@ -0,0 +1,23 @@
1
+ // Measured, not judged (output.observations.spring): a carried spring object swings about its base
2
+ // (compiler/spring.js), so a base far from the hand puts the pivot out there and every arm swing and
3
+ // wrist turn shakes it harder (a bag carried by its far end: the drive about 1.9x). One line per carry
4
+ // whose base sits PIVOT_FAR m or more from its prop origin (the hand socket), measured mid-carry.
5
+ import { isCarried, propOrigin } from "../carried.js";
6
+ import { basePoseAt, frameAt, spanOf } from "../object-pose.js";
7
+ import { dist } from "../compiler/math.js";
8
+
9
+ /** How far from the hand a spring object's base may sit before the observation, m. */
10
+ const PIVOT_FAR = 0.15;
11
+
12
+ /** observations.spring: ["SPRING_PIVOT <id>: base is 0.30 m from the hand; ..."], or null when every spring carry pivots at the hand. */
13
+ export function springPivots(out) {
14
+ const { sk, spec } = out.ctx, lines = [];
15
+ for (const [id, o] of Object.entries(spec.objects ?? {})) {
16
+ if (!o?.spring || !isCarried(o) || !spec.props?.[o.prop]) continue;
17
+ const [grab, release] = spanOf(out, o), f = Math.round((grab + release) / 2), base = basePoseAt(out, id, f);
18
+ if (!base) continue;
19
+ const d = dist(base.pos, propOrigin(sk, spec, frameAt(out, f).s, o));
20
+ if (d >= PIVOT_FAR) lines.push(`SPRING_PIVOT ${id}: base is ${d.toFixed(2)} m from the hand; the swing pivots there, so put the base at the hand with at/along`);
21
+ }
22
+ return lines.length ? lines : null;
23
+ }