cozyclay 3.1.5 → 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.
- package/CHANGELOG.md +76 -0
- package/bin/agent/agent-runner.mjs +9 -4
- package/bin/agent/openai-compatible-provider.mjs +24 -1
- package/dist/app/index.html +5 -5
- package/dist/assets/TaskBoard-BUjjuvm5.js +1 -0
- package/dist/assets/{app-CEP7jzXB.css → app-Dige7q3X.css} +1 -1
- package/dist/assets/app-DxoI941o.js +5144 -0
- package/dist/assets/compile-worker-DRWRa9pr.js +6 -0
- package/dist/assets/{motion3-compile-Du7UXrWn.js → motion3-compile-B4SX4TGx.js} +1 -1
- package/dist/assets/scene-asset-sync-LeI3eSnO.js +13 -0
- package/dist/assets/task-board-5PKn7ZaA.js +15 -0
- package/dist/assets/{workflow-DJwOUzqJ.js → workflow-Dg9jCKOq.js} +3 -3
- package/dist/cozyclay-package.json +1 -1
- package/dist/index.html +1 -1
- package/dist/workflow/index.html +3 -3
- package/mcp/project-camera.mjs +43 -0
- package/package.json +3 -1
- package/src/App.jsx +5 -5
- package/src/commands/index.js +2 -1
- package/src/commands/physics.js +21 -0
- package/src/domains/motion.js +7 -9
- package/src/motion/center-of-mass.js +5 -23
- package/src/motion/contact-project.js +143 -0
- package/src/motion/fix-collisions.js +1 -1
- package/src/motion/ik-drag.js +93 -1
- package/src/motion/timeline.css +12 -0
- package/src/motion/timeline.jsx +2 -2
- package/src/motion3/compile-job.js +3 -2
- package/src/motion3/compiler/compile.js +3 -3
- package/src/motion3/compiler/spring.js +95 -0
- package/src/motion3/compiler/validate-objects.js +18 -2
- package/src/motion3/compiler/validate-parts.js +1 -0
- package/src/motion3/compiler/vocab.js +1 -1
- package/src/motion3/fix/index.js +8 -4
- package/src/motion3/fix/person.js +110 -0
- package/src/motion3/free-motion.js +49 -11
- package/src/motion3/object-pose.js +44 -2
- package/src/motion3/people.js +16 -1
- package/src/motion3/physics-frames.js +72 -0
- package/src/motion3/physics-trail.js +45 -0
- package/src/motion3/physics-view.js +79 -0
- package/src/motion3/pipeline.js +5 -1
- package/src/motion3/qa/ballistic.js +67 -0
- package/src/motion3/qa/com.js +140 -0
- package/src/motion3/qa/ground.js +10 -3
- package/src/motion3/qa/hints-contact.js +9 -7
- package/src/motion3/qa/hints-motion.js +38 -1
- package/src/motion3/qa/key-checks.js +5 -4
- package/src/motion3/qa/patch.js +28 -3
- package/src/motion3/qa/people-checks.js +1 -1
- package/src/motion3/qa/spring-pivot.js +23 -0
- package/src/motion3/qa/style.js +13 -30
- package/src/motion3/schema/fields.js +4 -2
- package/src/motion3/schema/topics.js +15 -11
- package/src/motion3/schema/types.js +13 -9
- package/src/physics-overlay-settings.js +41 -0
- package/src/physics-overlay.js +152 -0
- package/src/physics-overlay.jsx +54 -0
- package/src/physics-toggle.jsx +20 -0
- package/src/posestudio.jsx +1 -1
- package/tools/run-tests.mjs +9 -16
- package/tools/verify-package-tarball.mjs +29 -1
- package/dist/assets/TaskBoard-DwXhsJXz.js +0 -1
- package/dist/assets/app-CTKcyPM-.js +0 -5144
- package/dist/assets/compile-worker-or7djI2v.js +0 -6
- package/dist/assets/scene-asset-sync-t45pWCyq.js +0 -13
- 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
|
+
}
|
package/src/motion3/pipeline.js
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/src/motion3/qa/ground.js
CHANGED
|
@@ -52,9 +52,16 @@ export function groundY(out, i, p) {
|
|
|
52
52
|
return g;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
/**
|
|
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"])
|
|
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,
|
|
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
|
|
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
|
-
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
const
|
|
193
|
-
|
|
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
|
|
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 {
|
|
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 =
|
|
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 =
|
|
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
|
})];
|
package/src/motion3/qa/patch.js
CHANGED
|
@@ -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
|
|
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
|
|
109
|
-
|
|
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
|
+
}
|