cozyclay 3.1.0 → 3.1.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.
- package/CHANGELOG.md +65 -0
- package/bin/agent/agent-routes.mjs +12 -11
- package/bin/agent/main-agent.mjs +21 -7
- package/bin/agent/main-prompt.mjs +7 -6
- package/bin/agent/plan-tool.mjs +114 -0
- package/bin/agent/script-tool.mjs +4 -1
- package/bin/agent/sculpt-sketch.mjs +190 -0
- package/bin/agent/session-store.mjs +2 -1
- package/bin/agent/sketch-code.mjs +151 -0
- package/bin/agent/sketch-sandbox.mjs +102 -0
- package/bin/agent/studio-prompt.mjs +1 -1
- package/bin/agent/studio-rebase.mjs +5 -4
- package/bin/agent/studio-task-routes.mjs +1 -1
- package/bin/agent/studio-tasks.mjs +116 -25
- package/bin/agent/studio-tools.mjs +3 -2
- package/bin/agent/turn-frames.mjs +4 -0
- package/bin/cozyclay.mjs +4 -2
- package/bin/live-endpoint.mjs +2 -0
- package/dist/app/index.html +6 -6
- package/dist/assets/{TaskBoard-DdDXMgZm.js → TaskBoard-GR8xZm5c.js} +1 -1
- package/dist/assets/{app-BlwOpest.css → app-Bhe0TpGz.css} +1 -1
- package/dist/assets/app-C4QTcDcb.js +5144 -0
- package/dist/assets/compile-worker-oy_P9HHh.js +6 -0
- package/dist/assets/motion3-compile-dgGhrvPK.js +1 -0
- package/dist/assets/scene-asset-sync-BEjjhNEo.css +1 -0
- package/dist/assets/scene-asset-sync-qoxcdrhj.js +13 -0
- package/dist/assets/task-board-BqReHzOi.js +15 -0
- package/dist/assets/workflow-C87p6E1E.js +179 -0
- package/dist/cozyclay-package.json +1 -1
- package/dist/index.html +1 -1
- package/dist/sitemap.xml +1 -1
- package/dist/workflow/index.html +4 -4
- package/mcp/README.md +1 -1
- package/mcp/live-hub.mjs +5 -3
- package/mcp/tool-handlers.mjs +7 -7
- package/package.json +3 -1
- package/src/App.jsx +44 -66
- package/src/bus-task-scopes.js +59 -11
- package/src/bus-task-targets.js +35 -9
- package/src/bus-tasks.js +23 -10
- package/src/command-bus.js +7 -4
- package/src/commands/ai.js +1 -1
- package/src/commands/elements.js +4 -3
- package/src/commands/export.js +1 -1
- package/src/commands/motion3.js +27 -7
- package/src/commands/objects.js +1 -1
- package/src/export-workspace.js +244 -0
- package/src/keyframe-pack.js +5 -1
- package/src/live-control.js +14 -7
- package/src/material-look.js +189 -0
- package/src/material-presets.js +78 -0
- package/src/material-textures.js +157 -0
- package/src/motion3/compile-job.js +15 -3
- package/src/motion3/compiler/channels.js +74 -24
- package/src/motion3/compiler/compile.js +2 -1
- package/src/motion3/compiler/idle.js +2 -0
- package/src/motion3/compiler/overlap.js +180 -0
- package/src/motion3/compiler/pose.js +6 -4
- package/src/motion3/compiler/validate-common.js +1 -0
- package/src/motion3/compiler/validate-parts.js +5 -1
- package/src/motion3/compiler/validate.js +30 -10
- package/src/motion3/correct.js +236 -0
- package/src/motion3/docs.js +8 -6
- package/src/motion3/face-lean.js +31 -0
- package/src/motion3/fix/index.js +5 -1
- package/src/motion3/fix/reach-body.js +172 -0
- package/src/motion3/hint-check.js +44 -0
- package/src/motion3/json-patch.js +120 -12
- package/src/motion3/lenient.js +46 -4
- package/src/motion3/object-keys.js +17 -5
- package/src/motion3/pipeline.js +46 -9
- package/src/motion3/qa/arm-checks.js +96 -0
- package/src/motion3/qa/asked.js +113 -0
- package/src/motion3/qa/goals.js +37 -8
- package/src/motion3/qa/hints-goal.js +6 -5
- package/src/motion3/qa/index.js +5 -2
- package/src/motion3/qa/key-checks.js +4 -3
- package/src/motion3/qa/motion-life.js +243 -0
- package/src/motion3/qa/spec-checks.js +15 -9
- package/src/motion3/qa/verdict.js +50 -0
- package/src/motion3/refs.js +38 -14
- package/src/motion3/repair-watch.js +132 -0
- package/src/motion3/repair.js +52 -19
- package/src/motion3/report.js +50 -9
- package/src/motion3/schema/topics.js +6 -4
- package/src/motion3/schema/types.js +1 -1
- package/src/motion3/skills/basic.js +15 -8
- package/src/motion3/skills/index.js +52 -18
- package/src/motion3/skills/info.js +1 -1
- package/src/motion3/skills/stance.js +6 -0
- package/src/motion3/skills/walk-fit.js +170 -0
- package/src/object-look.js +9 -5
- package/src/panels/PoseImagePanel.jsx +3 -2
- package/src/panels/SculptPanel.jsx +2 -2
- package/src/primitive-look.js +38 -0
- package/src/props.jsx +20 -28
- package/src/render-passes.js +7 -1
- package/src/result-modal.jsx +3 -3
- package/src/scene-objects.js +2 -2
- package/src/sculpt-colours.js +65 -0
- package/src/sculpt-compare.js +13 -50
- package/src/sculpt-csg.js +112 -0
- package/src/sculpt-curve.js +123 -0
- package/src/sculpt-cutters.js +43 -0
- package/src/sculpt-detail.js +207 -0
- package/src/sculpt-geometry-loft-convex.js +100 -0
- package/src/sculpt-geometry-shapes.js +250 -0
- package/src/sculpt-geometry.js +33 -15
- package/src/sculpt-loft-rings.js +225 -0
- package/src/sculpt-noise.js +65 -0
- package/src/sculpt-part-fields.js +27 -0
- package/src/sculpt-recipe-read.js +65 -0
- package/src/sculpt-recipe.js +33 -75
- package/src/sculpt-run.js +1 -1
- package/src/sculpt-scatter-items.js +183 -0
- package/src/sculpt-scatter-shape.js +57 -0
- package/src/sculpt-scatter.js +124 -0
- package/src/sculpt-shapes-curved.js +211 -0
- package/src/sculpt-shapes-loft-convex.js +218 -0
- package/src/sculpt-shapes.js +138 -0
- package/src/sculpt-sketch-state.js +76 -0
- package/src/sculpt-sketch.jsx +124 -0
- package/src/sculpt-surface-fields.js +65 -0
- package/src/shell/ExportFolderPreference.jsx +103 -0
- package/src/shell/PreferencesDialog.jsx +3 -166
- package/src/shell/StatusBar.jsx +1 -1
- package/src/shell/preference-controls.jsx +114 -0
- package/src/shell/preference-pages.jsx +72 -0
- package/src/shell/preferences.css +46 -0
- package/src/shot-prompt.js +29 -9
- package/src/studio-actions.js +2 -2
- package/src/studio-agent-motion.js +19 -9
- package/src/studio-agent-protocol.js +1 -1
- package/src/studio-app-binding.js +3 -2
- package/src/studio-elements.js +5 -1
- package/src/workflow/AgentPanel.jsx +6 -6
- package/src/workflow/AgentPlanCard.jsx +41 -0
- package/src/workflow/CozySceneNode.jsx +1 -1
- package/src/workflow/WorkflowBuilder.jsx +2 -2
- package/src/workflow/agent-client.js +14 -11
- package/src/workflow/agent-plan.css +93 -0
- package/src/workflow/agent-plan.js +59 -0
- package/src/workflow/keyframe-pack-request.js +3 -10
- package/src/workflow/task-board.css +1 -1
- package/src/workflow/task-board.js +7 -5
- package/tools/loc-baseline.json +1 -2
- package/tools/run-tests.mjs +8 -8
- package/dist/assets/app-B2BpfCl2.js +0 -5088
- package/dist/assets/compile-worker-D0pKioa0.js +0 -5
- package/dist/assets/motion3-compile-B887n77J.js +0 -1
- package/dist/assets/scene-asset-sync-COGOlF5T.css +0 -1
- package/dist/assets/scene-asset-sync-CQzEdsqu.js +0 -13
- package/dist/assets/task-board-BKaM7d-9.js +0 -14
- package/dist/assets/workflow-B8Dhzexj.js +0 -179
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// Reach by the body (spec.fix.reach): a hand on a pinned target (a world anchor, or an anchor on a scene object no
|
|
2
|
+
// hand carries, at half weight or more) that the straight arm misses by more than QA's reach limit brings the body to
|
|
3
|
+
// it instead of leaving REACH to the agent. Over each run of such frames the body leans toward the target (spine_pitch
|
|
4
|
+
// forward, only for a target ahead and not above the shoulder), the hips shift toward it and, for a low target, go
|
|
5
|
+
// down: one amount t in (0, 1] of LEAN deg, SHIFT m and DROP m, the least that brings the hand within GOOD m at the
|
|
6
|
+
// run's worst frame (one the hand is fully on) with the elbow still bent (the target within BENT of the arm's reach),
|
|
7
|
+
// eased in and out over EASE frames and following the weights that take the hand there (armw and the anchor's). The feet stay on their keyed targets (the
|
|
8
|
+
// legs' IK takes the hips; fix.slide holds them after), so a contact keyed elsewhere does not move: a t is refused
|
|
9
|
+
// when it puts a leg past its reach, takes the other hand, on its own target, further off it, or puts the body deeper
|
|
10
|
+
// into the scene's bodies (QA's capsules against the boxes there: a lean into the machine the hand reaches into). The
|
|
11
|
+
// whole move is undone when it adds a one-frame pop (QA's own detector) where it eases in or out, or when the misses
|
|
12
|
+
// over the run do not drop. A run goes on for up to EASE frames while the hand stays short (it takes the object from
|
|
13
|
+
// there). One pass, each run once, a run's amount scaled down by what an earlier run already added at its worst frame
|
|
14
|
+
// (both hands on one box), so it cannot cascade. Reported as fixed.reachBody: per run the side, frames, lean (deg),
|
|
15
|
+
// shift [x, z] (m), drop (m) and the worst miss where the hand is fully on, before -> after (the larger of a fit's
|
|
16
|
+
// effector to the target and the wrist's). Sessions: REACH arm_miss_l 66 times, the agent moving the root by hand.
|
|
17
|
+
import { sub, len, norm, dot } from "../compiler/math.js";
|
|
18
|
+
import { SIDES } from "../compiler/pose.js";
|
|
19
|
+
import { resolveAnchor, armReach } from "../compiler/anchors.js";
|
|
20
|
+
import { round4 } from "./ease.js";
|
|
21
|
+
import { CAPSULES, segPoints, sdBox } from "../qa/geometry.js";
|
|
22
|
+
import { QA_LIMITS } from "../qa/checks.js";
|
|
23
|
+
import { popChecks } from "../qa/style.js";
|
|
24
|
+
|
|
25
|
+
const GOOD = 0.01, BENT = 0.97, LEAN = 25, SHIFT = 0.08, DROP = 0.15, EASE = 10, GAP = 2, SLACK = 0.002, TRIES = [0.25, 0.5, 0.75, 1];
|
|
26
|
+
const smooth = x => x * x * (3 - 2 * x);
|
|
27
|
+
|
|
28
|
+
/** The anchor that leads hand k at frame f when it is pinned (world, or a scene object no hand carries), else null. */
|
|
29
|
+
function pinned(ctx, c, k, f) {
|
|
30
|
+
if (!(c[`armw_${k}`] >= 0.5)) return null;
|
|
31
|
+
let best = null;
|
|
32
|
+
for (const [ch, w] of Object.entries(c)) {
|
|
33
|
+
const m = /^w_(.+)_([lr])$/.exec(ch);
|
|
34
|
+
if (m && m[2] === k && w >= 0.5 && (!best || w > best.w)) best = { w, name: m[1] };
|
|
35
|
+
}
|
|
36
|
+
const a = best && ctx.spec.anchors?.[best.name];
|
|
37
|
+
return a && (a.kind === "world" || (a.kind === "object" && !ctx.objectHand?.(a.object, f))) ? best.name : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** How deep the body is in the scene at frame f (the sum over QA's capsules), m; the reaching hand against the object it reaches for is left out. */
|
|
41
|
+
function depthIn(out, f, k, object) {
|
|
42
|
+
const { s } = out.frames[f - out.f0], I = out.ctx.sk.index, scene = out.ctx.scene?.objects ?? {};
|
|
43
|
+
const boxes = Object.entries(out.objectsAt(f - out.f0) ?? {}).filter(([, b]) => b.half && !b.ignore);
|
|
44
|
+
const target = id => object !== undefined && (id === object || scene[id]?.ancestors?.includes(object));
|
|
45
|
+
let sum = 0;
|
|
46
|
+
for (const [part, a, b, r] of CAPSULES) {
|
|
47
|
+
if (I[a] === undefined || I[b] === undefined) continue;
|
|
48
|
+
const pts = segPoints(s.P[I[a]], s.P[I[b]], 5);
|
|
49
|
+
for (const [id, box] of boxes) if (!(part === `hand_${k}` && target(id))) sum += Math.max(0, r - Math.min(...pts.map(p => sdBox(p, box))));
|
|
50
|
+
}
|
|
51
|
+
return sum;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** QA's one-frame pops (qa/style.js popChecks) over a run of frames: their summed excess past the floor. */
|
|
55
|
+
function popsIn(sk, frames, L) {
|
|
56
|
+
let sum = 0;
|
|
57
|
+
popChecks(sk, { frames }, L, (kind, msg, f, value, limit) => { sum += value - limit; });
|
|
58
|
+
return sum;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The point a fit's effector names: a prop point, or the joints' middle. */
|
|
62
|
+
function effector(sk, s, diag, eff) {
|
|
63
|
+
if (eff?.prop) return diag.props?.[eff.prop]?.[eff.point] ?? null;
|
|
64
|
+
const js = eff?.joints ?? ["RightHandIndex2", "RightHandMiddle2"], ps = js.map(j => s.P[sk.index[j]]).filter(Boolean);
|
|
65
|
+
return ps.length ? ps.reduce((m, p) => m.map((x, i) => x + p[i] / ps.length), [0, 0, 0]) : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Runs [a, b] (absolute frames) where hand k misses a pinned target by more than limit (gaps of GAP frames merge). */
|
|
69
|
+
function runsOf(out, k, limit) {
|
|
70
|
+
const runs = [];
|
|
71
|
+
out.frames.forEach((fr, i) => {
|
|
72
|
+
const f = out.f0 + i;
|
|
73
|
+
if (!(fr.diag[`arm_miss_${k}`] > limit) || fr.diag[`arm_fold_${k}`] || !pinned(out.ctx, fr.c, k, f)) return;
|
|
74
|
+
const last = runs.at(-1);
|
|
75
|
+
if (last && f - last[1] <= GAP + 1) last[1] = f; else runs.push([f, f]);
|
|
76
|
+
});
|
|
77
|
+
// the hand is still short right after (it takes the object from there): the run goes on, up to EASE frames more
|
|
78
|
+
const short = f => f >= out.f0 && f < out.f0 + out.frames.length && out.frames[f - out.f0].diag[`arm_miss_${k}`] > limit && !out.frames[f - out.f0].diag[`arm_fold_${k}`];
|
|
79
|
+
for (const r of runs) for (let i = 0; i < EASE && short(r[1] + 1); i++) r[1] += 1;
|
|
80
|
+
return runs;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function reachBody(out, engine) {
|
|
84
|
+
const { ctx } = out, { sk } = ctx, I = sk.index, n = out.frameCount, baked = out.baked;
|
|
85
|
+
const limit = ctx.spec.checks?.limits?.reach ?? 0.01, at = f => out.frames[f - out.f0];
|
|
86
|
+
const lo = out.f0, hi = out.f0 + out.frames.length - 1, report = [];
|
|
87
|
+
// what this fix added per frame, so overlapping runs (both hands) stay within one full amount
|
|
88
|
+
const added = { pitch: new Float64Array(n), move: new Float64Array(n) }, made = [];
|
|
89
|
+
for (const k of ["r", "l"]) for (const [a, b] of runsOf(out, k, limit)) {
|
|
90
|
+
// the target is the anchor's point (a fit winds the wrist target past it, so the miss does not say where it is);
|
|
91
|
+
// the run's worst frame is where it is farthest from the shoulder
|
|
92
|
+
const sh = f => at(f).s.P[I[`${SIDES[k]}Arm`]], target = f => { const { s, diag, c } = at(f), name = pinned(ctx, c, k, f); return name ? resolveAnchor(ctx, name, s, { ...diag, frame: f }, c) : null; };
|
|
93
|
+
// measured where the hand is fully on it (armw and the anchor's weight at 1) when the run has such frames: before, it is still on its way
|
|
94
|
+
const full = f => at(f).c[`armw_${k}`] >= 0.99 && at(f).c[`w_${pinned(ctx, at(f).c, k, f)}_${k}`] >= 0.99;
|
|
95
|
+
const span = Array.from({ length: b - a + 1 }, (_, i) => a + i), pick = span.some(full) ? span.filter(full) : span;
|
|
96
|
+
let fw = -1, T = null;
|
|
97
|
+
for (const f of pick) { const q = target(f); if (q && (!T || len(sub(q, sh(f))) > len(sub(T, sh(fw))))) { fw = f; T = q; } }
|
|
98
|
+
if (!T) continue;
|
|
99
|
+
const { s, diag, c } = at(fw), other = k === "l" ? "r" : "l";
|
|
100
|
+
const fit = (ctx.spec.fits ?? []).find(ft => ft.side === k && ft.anchor === pinned(ctx, c, k, fw)), eff = fit && effector(sk, s, diag, fit.effector);
|
|
101
|
+
const miss0 = Math.max(eff ? len(sub(eff, T)) : 0, diag[`arm_miss_${k}`] ?? 0);
|
|
102
|
+
// what the arm reaches with: the straight arm, and the effector's offset from the wrist when a fit names one
|
|
103
|
+
const reach = armReach(sk, k) + (eff ? len(sub(eff, s.P[I[`${SIDES[k]}Hand`]])) : 0);
|
|
104
|
+
const S = sh(fw), yaw = (c.yaw ?? 0) * Math.PI / 180, fwd = [Math.sin(yaw), 0, Math.cos(yaw)];
|
|
105
|
+
const flat = [T[0] - S[0], 0, T[2] - S[2]], hd = len(flat) > 1e-3 ? norm(flat) : fwd;
|
|
106
|
+
const lean = dot(hd, fwd) > 0.3 && T[1] < S[1] + 0.1 ? LEAN : 0, drop = DROP * Math.min(1, Math.max(0, (S[1] - T[1] - 0.2) / 0.4));
|
|
107
|
+
const free = Math.max(0, 1 - Math.max(added.pitch[fw] / LEAN, added.move[fw] / SHIFT));
|
|
108
|
+
if (!free) continue;
|
|
109
|
+
if (!baked.root) { baked.root = Array.from({ length: n }, () => sk.restWorldPos[I.Hips].slice()); made.push("root"); }
|
|
110
|
+
if (!baked.spine_pitch) { baked.spine_pitch = new Array(n).fill(0); made.push("spine_pitch"); }
|
|
111
|
+
const root0 = baked.root.map(r => r.slice()), pitch0 = baked.spine_pitch.slice();
|
|
112
|
+
const set = (f, t) => {
|
|
113
|
+
baked.root[f] = [root0[f][0] + hd[0] * SHIFT * t, root0[f][1] - drop * t, root0[f][2] + hd[2] * SHIFT * t];
|
|
114
|
+
baked.spine_pitch[f] = pitch0[f] + lean * t;
|
|
115
|
+
};
|
|
116
|
+
const legOk = (d, d0) => ["l", "r"].every(q => !((d[`leg_miss_${q}`] ?? 0) > Math.max(d0[`leg_miss_${q}`] ?? 0, 5e-4) + SLACK));
|
|
117
|
+
const otherOk = (d, d0) => !(at(fw).c[`armw_${other}`] > 0.5) || (d[`arm_miss_${other}`] ?? 0) <= (d0[`arm_miss_${other}`] ?? 0) + SLACK;
|
|
118
|
+
const object = ctx.spec.anchors[pinned(ctx, c, k, fw)]?.object, saved = at(fw), depth0 = depthIn(out, fw, k, object);
|
|
119
|
+
const trial = t => {
|
|
120
|
+
set(fw, t);
|
|
121
|
+
engine.solve([fw]);
|
|
122
|
+
// enough: the target within BENT of the arm's reach, so the elbow stays a little bent (a straight arm pops as it locks)
|
|
123
|
+
// the miss: a fit's effector to the target (the wrist reaches a soft-clamped target, fix.reach), else the wrist's
|
|
124
|
+
// (and the wrist's own miss too: a fit's target wound past a straight arm is what QA reads as REACH)
|
|
125
|
+
const { s: s1, diag: d } = at(fw), e1 = fit && effector(sk, s1, d, fit.effector), miss = Math.max(e1 ? len(sub(e1, T)) : 0, d[`arm_miss_${k}`] ?? 0);
|
|
126
|
+
const r = { t, miss, ok: legOk(d, diag) && otherOk(d, diag) && depthIn(out, fw, k, object) <= depth0 + SLACK, bent: len(sub(T, sh(fw))) <= BENT * reach };
|
|
127
|
+
baked.root[fw] = root0[fw]; baked.spine_pitch[fw] = pitch0[fw];
|
|
128
|
+
out.frames[fw - out.f0] = saved;
|
|
129
|
+
return r;
|
|
130
|
+
};
|
|
131
|
+
let best = null, prev = 0;
|
|
132
|
+
for (const t of TRIES.map(x => x * free)) {
|
|
133
|
+
const r = trial(t);
|
|
134
|
+
if (!r.ok) break;
|
|
135
|
+
if (!best || r.miss < best.miss) best = r;
|
|
136
|
+
if (r.miss <= GOOD && r.bent) {
|
|
137
|
+
// the least amount that is enough
|
|
138
|
+
let x = prev, y = t;
|
|
139
|
+
for (let it = 0; it < 4; it++) { const m = trial((x + y) / 2); if (m.ok && m.miss <= GOOD && m.bent) { y = m.t; best = m; } else x = m.t; }
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
prev = t;
|
|
143
|
+
}
|
|
144
|
+
if (!best || !(best.miss < miss0 - GOOD)) continue;
|
|
145
|
+
// the body comes as the hand does, by the weights that take it to the target (the reach's own timing: a torso leaning
|
|
146
|
+
// over an arm still hanging swings it into the hips, and one leaning early changes the reach's speed profile)
|
|
147
|
+
const w01 = (ch, f) => Math.min(1, Math.max(0, baked[ch]?.[f] ?? 1)), name = pinned(ctx, c, k, fw);
|
|
148
|
+
const touched = [], weightAt = f => w01(`armw_${k}`, f) * w01(`w_${name}_${k}`, f) * (f >= a && f <= b ? 1 : smooth(1 - (f < a ? a - f : f - b) / (EASE + 1)));
|
|
149
|
+
for (let f = Math.max(lo, a - EASE); f <= Math.min(hi, b + EASE); f++) { set(f, best.t * weightAt(f)); touched.push(f); }
|
|
150
|
+
const before = touched.map(f => at(f)), depthBefore = touched.map(f => depthIn(out, f, k, object));
|
|
151
|
+
engine.solve(touched);
|
|
152
|
+
// kept when QA's reach (the wrist's misses over the run) and the worst miss where the hand is fully on drop, and no leg or other hand lost its target
|
|
153
|
+
const was = f => before[f - touched[0]], gapAt = (fr, f) => { const q = target(f), e = fit && q && effector(sk, fr.s, fr.diag, fit.effector); return Math.max(e ? len(sub(e, q)) : 0, fr.diag[`arm_miss_${k}`] ?? 0); };
|
|
154
|
+
const sumOf = get => span.reduce((m, f) => m + (get(f).diag[`arm_miss_${k}`] ?? 0), 0), worstOf = get => Math.max(...pick.map(f => gapAt(get(f), f)));
|
|
155
|
+
const depths = touched.map(f => depthIn(out, f, k, object)), L = { ...QA_LIMITS, ...ctx.spec.checks?.limits };
|
|
156
|
+
// the frames around the move, as they were and as they are: the move may not add a pop where it eases in or out
|
|
157
|
+
const near = Array.from({ length: Math.min(hi, touched.at(-1) + 3) - Math.max(lo, touched[0] - 3) + 1 }, (_, i) => Math.max(lo, touched[0] - 3) + i);
|
|
158
|
+
const popped = popsIn(sk, near.map(f => at(f)), L) > popsIn(sk, near.map(f => (f >= touched[0] && f <= touched.at(-1) ? before[f - touched[0]] : at(f))), L) + 1e-4;
|
|
159
|
+
const legsHeld = touched.every((f, i) => legOk(at(f).diag, before[i].diag) && otherOk(at(f).diag, before[i].diag) && depths[i] <= depthBefore[i] + SLACK);
|
|
160
|
+
const worst0 = worstOf(was), worst1 = worstOf(at);
|
|
161
|
+
if (!legsHeld || popped || !(sumOf(at) < sumOf(was) - 1e-6) || !(worst1 < worst0 - GOOD / 2)) {
|
|
162
|
+
for (const f of touched) { baked.root[f] = root0[f]; baked.spine_pitch[f] = pitch0[f]; }
|
|
163
|
+
touched.forEach((f, i) => { out.frames[f - out.f0] = before[i]; });
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
for (const f of touched) { added.pitch[f] += lean * best.t * weightAt(f); added.move[f] += SHIFT * best.t * weightAt(f); }
|
|
167
|
+
report.push({ side: k, frames: [a, b], lean: round4(lean * best.t), shift: [round4(hd[0] * SHIFT * best.t), round4(hd[2] * SHIFT * best.t)], drop: round4(drop * best.t), miss: [round4(worst0), round4(worst1)] });
|
|
168
|
+
}
|
|
169
|
+
// a channel this fix made for a move it undid goes again
|
|
170
|
+
if (!report.length) for (const ch of made) delete baked[ch];
|
|
171
|
+
return report.length ? { runs: report, how: "an out-of-reach hand on a pinned target: the body leaned (spine_pitch deg), shifted its hips (x, z m) and lowered them (m) toward it, eased in and out; the feet kept their targets" } : null;
|
|
172
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// C6. A hint's patch is offered only when it was compiled and helped. The logs: a patch applied as sent made the take
|
|
2
|
+
// worse 49% of the time (better 40%), and the agent spent compiles finding that out. So the first CHECKED groups that
|
|
3
|
+
// carry a patch (qa.groups order) each get one compile of the spec with their patch, and the patch stays on the group
|
|
4
|
+
// only when its own group gets lower and qa/verdict.js compareQa calls the whole result better (no goal lost, no more
|
|
5
|
+
// problems, nothing that got worse costs more than half of what got better); the group then says what it measured
|
|
6
|
+
// (patchChecked). Otherwise the patch is taken off and patchTried says what it did; the hint's words stay. A patch past
|
|
7
|
+
// the first CHECKED is not compiled and not offered. A spec repair or an earlier check already
|
|
8
|
+
// compiled is not compiled again (`compile` may answer from its cache).
|
|
9
|
+
import { applyPatch } from "./json-patch.js";
|
|
10
|
+
import { groupKey } from "./qa/severity.js";
|
|
11
|
+
import { compareQa, severityIn } from "./qa/verdict.js";
|
|
12
|
+
|
|
13
|
+
const CHECKED = 2;
|
|
14
|
+
const r2 = x => Math.round(x * 100) / 100;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* checkHints(spec, qa, compile) -> qa (its groups changed in place)
|
|
18
|
+
* @param {object} spec the spec the patches address (qa's own)
|
|
19
|
+
* @param {object} qa runMotionQa's answer for spec
|
|
20
|
+
* @param {(spec: object) => object} compile spec -> its qa
|
|
21
|
+
*/
|
|
22
|
+
export function checkHints(spec, qa, compile) {
|
|
23
|
+
let n = 0;
|
|
24
|
+
for (const g of qa?.groups ?? []) {
|
|
25
|
+
if (!g.patch) continue;
|
|
26
|
+
if (n >= CHECKED) { delete g.patch; continue; }
|
|
27
|
+
n += 1;
|
|
28
|
+
const key = groupKey(g);
|
|
29
|
+
let next;
|
|
30
|
+
try { next = compile(applyPatch(spec, g.patch)); } catch (error) { next = { error: String(error?.message ?? error).slice(0, 160) }; }
|
|
31
|
+
const tried = g.patch.map(o => `${o.op} ${o.path}`).join(", "), patch = g.patch;
|
|
32
|
+
delete g.patch;
|
|
33
|
+
if (next?.error || !next?.groups) { g.patchTried = `${tried}: does not compile (${next?.error ?? "no QA"})`; continue; }
|
|
34
|
+
const own = [r2(severityIn(qa, key)), r2(severityIn(next, key))], v = compareQa(next, qa);
|
|
35
|
+
if (own[1] < own[0] && v.better) {
|
|
36
|
+
g.patch = patch;
|
|
37
|
+
g.patchChecked = { severity: own, total: v.sev, problems: v.problems };
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const why = !(own[1] < own[0]) ? `this group ${own[0]} -> ${own[1]}` : v.why;
|
|
41
|
+
g.patchTried = `${tried}: compiled, did not help (${why}${why.startsWith("problems") ? "" : `; problems ${v.problems[0]} -> ${v.problems[1]}`})`;
|
|
42
|
+
}
|
|
43
|
+
return qa;
|
|
44
|
+
}
|
|
@@ -1,11 +1,22 @@
|
|
|
1
|
-
// JSON Patch (RFC 6902) on a motion spec: add, remove, replace and test, with "-" to append to
|
|
1
|
+
// JSON Patch (RFC 6902) on a motion spec: add, remove, replace, move and test, with "-" to append to
|
|
2
2
|
// an array in add. Used for motion.compile { base, patch } (C7) and by repair (C6). Errors are
|
|
3
3
|
// MOTION_SPEC_INVALID (INVALID_ARGUMENT to the agent) and name the op and what is there:
|
|
4
|
-
// patch[1] (replace /keys/12/f): /keys/12 does not exist (keys has 9 entries)
|
|
4
|
+
// patch[1] (replace /keys/12/f): /keys/12 does not exist (keys has 9 entries; by frame: /keys/@f=<f>, f 0, 24, 40, ...)
|
|
5
5
|
// With a notes list (motion.compile, lenient.js), replace on a member an existing object lacks adds it
|
|
6
6
|
// and says so; repair (no notes) stays strict.
|
|
7
|
+
//
|
|
8
|
+
// Stable addressing: an array step may be a selector "@field=value" ("&" joins more), the element whose
|
|
9
|
+
// field reads value, instead of an index. An index edit goes wrong silently when an earlier entry is
|
|
10
|
+
// added or removed, and the agent's own logs show index paths are most of what it patches, so:
|
|
11
|
+
// /keys/@f=120/set/hand_l the key at frame 120 (several there: the last, the one that wins the bake)
|
|
12
|
+
// /objects/cup/keys/@f=40/pos an object key by frame
|
|
13
|
+
// /use/@skill=reach&f0=40/args/at a skill by name and first frame; /goals/@name=cup lifted; /fits/@anchor=cupHandle
|
|
14
|
+
// add (and a lenient replace) through a selector that matches nothing creates the element ({ f: 120, set: {} }):
|
|
15
|
+
// one op sets a channel on a key whether the key exists or not. remove /keys/@f=120 removes every key at 120;
|
|
16
|
+
// a key moves with { op: "move", from: "/keys/@f=120", path: "/keys/@f=132" } (its f becomes 132; refused when
|
|
17
|
+
// 132 is keyed). A selector that matches nothing lists the values there, nearest frames first, so a retry lands.
|
|
7
18
|
|
|
8
|
-
const OPS = ["add", "remove", "replace", "test"];
|
|
19
|
+
const OPS = ["add", "remove", "replace", "move", "test"];
|
|
9
20
|
const bad = msg => { throw new Error(`MOTION_SPEC_INVALID: ${msg}`); };
|
|
10
21
|
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
11
22
|
|
|
@@ -13,10 +24,68 @@ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
|
13
24
|
const tokens = path => path.split("/").slice(1).map(t => t.replaceAll("~1", "/").replaceAll("~0", "~"));
|
|
14
25
|
const pointer = parts => parts.map(t => `/${String(t).replaceAll("~", "~0").replaceAll("/", "~1")}`).join("");
|
|
15
26
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
27
|
+
const SELECTOR = /^@[A-Za-z_]\w*=[^&]*(&[A-Za-z_]\w*=[^&]*)*$/;
|
|
28
|
+
/** "@skill=reach&f0=40" -> [["skill", "reach"], ["f0", "40"]], or null for any other step. */
|
|
29
|
+
const conditions = t => (SELECTOR.test(t) ? t.slice(1).split("&").map(c => [c.slice(0, c.indexOf("=")), c.slice(c.indexOf("=") + 1)]) : null);
|
|
30
|
+
const matches = (el, conds) => el !== null && typeof el === "object" && conds.every(([k, v]) => Object.hasOwn(el, k) && String(el[k]) === v);
|
|
31
|
+
const isMap = v => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
32
|
+
const numeric = v => (/^-?\d+(\.\d+)?$/.test(v) ? Number(v) : v);
|
|
33
|
+
// the fields that tell entries apart, in the order an error offers them
|
|
34
|
+
const NAMES = ["name", "skill", "anchor", "f", "f0", "side", "type"];
|
|
35
|
+
|
|
36
|
+
/** What an array holds, for an error: its length, and how to address it by a field its entries carry. */
|
|
37
|
+
function what(container, name, want) {
|
|
38
|
+
if (!Array.isArray(container)) {
|
|
39
|
+
const keys = Object.keys(container);
|
|
40
|
+
return keys.length ? `${name} has ${keys.slice(0, 8).join(", ")}${keys.length > 8 ? ", ..." : ""}` : `${name} is empty`;
|
|
41
|
+
}
|
|
42
|
+
const field = want?.[0] ?? NAMES.find(k => container.some(el => el && typeof el === "object" && Object.hasOwn(el, k)));
|
|
43
|
+
if (!field) return `${name} has ${container.length} entries`;
|
|
44
|
+
const all = [...new Set(container.filter(el => el && Object.hasOwn(el, field)).map(el => el[field]))];
|
|
45
|
+
// frames: the nearest to the one asked for, shown in order
|
|
46
|
+
const target = Number(want?.[1]);
|
|
47
|
+
const values = all.every(v => typeof v === "number")
|
|
48
|
+
? (Number.isFinite(target) ? [...all].sort((a, b) => Math.abs(a - target) - Math.abs(b - target)) : all).slice(0, 8).sort((a, b) => a - b) : all.slice(0, 8);
|
|
49
|
+
return `${name} has ${container.length} entries; by ${field}: /${name}/@${field}=<${field}>, ${field} ${values.join(", ")}${all.length > values.length ? ", ..." : ""}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Index (as a string) of the element of arr that step t selects; creates it when `create`, else -1 when none. */
|
|
53
|
+
function select(arr, t, rest, tag, name, create) {
|
|
54
|
+
const conds = conditions(t), hits = [];
|
|
55
|
+
arr.forEach((el, i) => matches(el, conds) && hits.push(i));
|
|
56
|
+
const frameOnly = conds.length === 1 && conds[0][0] === "f";
|
|
57
|
+
if (hits.length > 1 && !frameOnly) {
|
|
58
|
+
const field = NAMES.find(k => !conds.some(c => c[0] === k) && new Set(hits.map(i => arr[i]?.[k])).size === hits.length);
|
|
59
|
+
bad(`${tag}: ${hits.length} ${name} entries have ${t.slice(1)}${field ? ` (${field} ${hits.map(i => arr[i][field]).join(", ")}; add &${field}=<${field}>)` : ` (indices ${hits.join(", ")})`}`);
|
|
60
|
+
}
|
|
61
|
+
if (hits.length) {
|
|
62
|
+
// keys at one frame merge, the last winning: prefer the last that holds the rest of the path, then its parent
|
|
63
|
+
const has = (i, path) => { let n = arr[i]; for (const p of path) { if (n === null || typeof n !== "object" || !Object.hasOwn(n, p)) return false; n = n[p]; } return true; };
|
|
64
|
+
return String(hits.findLast(i => has(i, rest)) ?? hits.findLast(i => has(i, rest.slice(0, -1))) ?? hits.at(-1));
|
|
65
|
+
}
|
|
66
|
+
if (!create) return -1;
|
|
67
|
+
const el = Object.fromEntries(conds.map(([k, v]) => [k, numeric(v)]));
|
|
68
|
+
if (rest.length > 1) el[rest[0]] = {};
|
|
69
|
+
arr.push(el);
|
|
70
|
+
return String(arr.length - 1);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** parts with every selector step but the last turned into an index; creates elements when `create`. */
|
|
74
|
+
function resolve(doc, parts, tag, create, notes) {
|
|
75
|
+
let node = doc;
|
|
76
|
+
const out = [];
|
|
77
|
+
for (let i = 0; i < parts.length; i++) {
|
|
78
|
+
let t = parts[i];
|
|
79
|
+
if (Array.isArray(node) && conditions(t) && i < parts.length - 1) {
|
|
80
|
+
const name = i ? out[i - 1] : "the spec", k = select(node, t, parts.slice(i + 1), tag, name, false);
|
|
81
|
+
if (k === -1 && !create) bad(`${tag}: ${pointer([...out, t])} matches nothing (${what(node, name, conditions(t)[0])})`);
|
|
82
|
+
t = k === -1 ? select(node, t, parts.slice(i + 1), tag, name, true) : k;
|
|
83
|
+
if (k === -1) { out.created = true; notes?.push(`${tag}: created ${pointer(out)}/${t} (${parts[i].slice(1)})`); }
|
|
84
|
+
}
|
|
85
|
+
out.push(t);
|
|
86
|
+
if (i < parts.length - 1) node = node !== null && typeof node === "object" ? node[Array.isArray(node) ? Number(t) : t] : undefined;
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
20
89
|
}
|
|
21
90
|
|
|
22
91
|
/** The container holding the last token, walking every token before it. */
|
|
@@ -32,18 +101,57 @@ function parentOf(doc, parts, tag) {
|
|
|
32
101
|
return node;
|
|
33
102
|
}
|
|
34
103
|
|
|
104
|
+
/** The last step on an array, a selector: add appends (its fields set), remove drops every match, the rest take the one match. */
|
|
105
|
+
function bySelector(node, last, op, value, tag, name) {
|
|
106
|
+
const conds = conditions(last), hits = node.flatMap((el, i) => (matches(el, conds) ? [i] : []));
|
|
107
|
+
if (op === "add") {
|
|
108
|
+
if (hits.length) bad(`${tag}: ${name} already has an entry with ${last.slice(1)}; replace its fields or remove it first`);
|
|
109
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) bad(`${tag}: add through a selector takes an object value`);
|
|
110
|
+
node.push({ ...structuredClone(value), ...Object.fromEntries(conds.map(([k, v]) => [k, numeric(v)])) });
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
if (!hits.length) bad(`${tag}: ${name} has no entry with ${last.slice(1)} (${what(node, name, conds[0])})`);
|
|
114
|
+
if (op === "remove") { for (const i of hits.reverse()) node.splice(i, 1); return null; }
|
|
115
|
+
return select(node, last, [], tag, name, false);
|
|
116
|
+
}
|
|
117
|
+
|
|
35
118
|
function applyOne(doc, op, i, notes) {
|
|
36
119
|
if (!op || typeof op !== "object" || !OPS.includes(op.op)) bad(`patch[${i}]: op must be ${OPS.join(", ")}, got ${JSON.stringify(op?.op)}`);
|
|
37
120
|
const tag = `patch[${i}] (${op.op} ${op.path})`;
|
|
38
|
-
if (typeof op.path !== "string" || (op.path !== "" && !op.path.startsWith("/"))) bad(`${tag}: path must be a JSON Pointer such as "/keys/0/f"`);
|
|
121
|
+
if (typeof op.path !== "string" || (op.path !== "" && !op.path.startsWith("/"))) bad(`${tag}: path must be a JSON Pointer such as "/keys/0/f" or "/keys/@f=24/set/hand_l"`);
|
|
122
|
+
if (op.op === "move") {
|
|
123
|
+
if (typeof op.from !== "string" || !op.from.startsWith("/")) bad(`${tag}: move needs from, a JSON Pointer such as "/keys/@f=120"`);
|
|
124
|
+
const parts = resolve(doc, tokens(op.from), tag, false), last = parts.at(-1), node = parentOf(doc, parts, tag), name = parts.at(-2) ?? "the spec";
|
|
125
|
+
let value;
|
|
126
|
+
if (Array.isArray(node) && conditions(last)) {
|
|
127
|
+
// every key at that frame moves, merged into one (later entries win, as in the bake)
|
|
128
|
+
const hits = node.flatMap((el, j) => (matches(el, conditions(last)) ? [j] : []));
|
|
129
|
+
if (!hits.length) bad(`${tag}: ${name} has no entry with ${last.slice(1)} (${what(node, name, conditions(last)[0])})`);
|
|
130
|
+
value = hits.map(j => node[j]).reduce((a, b) => Object.fromEntries([...new Set([...Object.keys(a), ...Object.keys(b)])].map(f => [f,
|
|
131
|
+
isMap(a[f]) && isMap(b[f]) ? { ...a[f], ...b[f] } : Array.isArray(a[f]) && Array.isArray(b[f]) && f === "step" ? [...new Set([...a[f], ...b[f]])] : Object.hasOwn(b, f) ? b[f] : a[f]])));
|
|
132
|
+
for (const j of hits.reverse()) node.splice(j, 1);
|
|
133
|
+
} else {
|
|
134
|
+
if (!Object.hasOwn(node, Array.isArray(node) ? Number(last) : last)) bad(`${tag}: ${op.from} does not exist (${what(node, name)})`);
|
|
135
|
+
value = node[Array.isArray(node) ? Number(last) : last];
|
|
136
|
+
if (Array.isArray(node)) node.splice(Number(last), 1); else delete node[last];
|
|
137
|
+
}
|
|
138
|
+
return applyOne(doc, { op: "add", path: op.path, value }, i, notes);
|
|
139
|
+
}
|
|
39
140
|
if (op.op !== "remove" && !Object.hasOwn(op, "value")) bad(`${tag}: needs a value`);
|
|
40
141
|
if (op.path === "") {
|
|
41
142
|
if (op.op === "replace") return structuredClone(op.value);
|
|
42
143
|
if (op.op === "test") { if (!same(doc, op.value)) bad(`${tag}: the spec differs from the test value`); return doc; }
|
|
43
144
|
bad(`${tag}: ${op.op} needs a path below the spec`);
|
|
44
145
|
}
|
|
45
|
-
const
|
|
146
|
+
const create = op.op === "add" || (op.op === "replace" && Boolean(notes));
|
|
147
|
+
const parts = resolve(doc, tokens(op.path), tag, create, notes), node = parentOf(doc, parts, tag);
|
|
148
|
+
let last = parts[parts.length - 1];
|
|
46
149
|
const name = parts.length > 1 ? parts[parts.length - 2] : "the spec";
|
|
150
|
+
if (Array.isArray(node) && conditions(last)) {
|
|
151
|
+
if (op.op === "replace" && notes && !node.some(el => matches(el, conditions(last)))) { notes.push(`${tag}: nothing had ${last.slice(1)}; added`); return applyOne(doc, { ...op, op: "add" }, i); }
|
|
152
|
+
last = bySelector(node, last, op.op, op.value, tag, name);
|
|
153
|
+
if (last === null) return doc;
|
|
154
|
+
}
|
|
47
155
|
if (Array.isArray(node)) {
|
|
48
156
|
if (last === "-") { if (op.op !== "add") bad(`${tag}: "-" (append) works with add only`); node.push(structuredClone(op.value)); return doc; }
|
|
49
157
|
const k = /^\d+$/.test(last) ? Number(last) : NaN;
|
|
@@ -55,7 +163,7 @@ function applyOne(doc, op, i, notes) {
|
|
|
55
163
|
else if (!same(node[k], op.value)) bad(`${tag}: value is ${JSON.stringify(node[k])?.slice(0, 60)}, not ${JSON.stringify(op.value)?.slice(0, 60)}`);
|
|
56
164
|
return doc;
|
|
57
165
|
}
|
|
58
|
-
if (op.op === "replace" && notes && !Object.hasOwn(node, last)) notes.push(`${tag}: ${op.path} did not exist (${what(node, name)}); added`);
|
|
166
|
+
if (op.op === "replace" && notes && !Object.hasOwn(node, last)) { if (!parts.created) notes.push(`${tag}: ${op.path} did not exist (${what(node, name)}); added`); }
|
|
59
167
|
else if (op.op !== "add" && !Object.hasOwn(node, last)) bad(`${tag}: ${op.path} does not exist (${what(node, name)})`);
|
|
60
168
|
if (op.op === "remove") delete node[last];
|
|
61
169
|
else if (op.op === "test") { if (!same(node[last], op.value)) bad(`${tag}: value is ${JSON.stringify(node[last])?.slice(0, 60)}, not ${JSON.stringify(op.value)?.slice(0, 60)}`); }
|
|
@@ -66,9 +174,9 @@ function applyOne(doc, op, i, notes) {
|
|
|
66
174
|
/**
|
|
67
175
|
* Applies ops in order to a copy of doc (doc is never changed) and returns the copy; the first
|
|
68
176
|
* failing op throws MOTION_SPEC_INVALID and nothing is applied. notes (optional) turns a replace on a
|
|
69
|
-
* missing object member into an add and collects a line for it.
|
|
177
|
+
* missing object member (or a selector that matches nothing) into an add and collects a line for it.
|
|
70
178
|
* @param {object} doc
|
|
71
|
-
* @param {{ op: "add" | "remove" | "replace" | "test", path: string, value?: any }[]} ops
|
|
179
|
+
* @param {{ op: "add" | "remove" | "replace" | "move" | "test", path: string, from?: string, value?: any }[]} ops
|
|
72
180
|
* @param {string[]} [notes]
|
|
73
181
|
*/
|
|
74
182
|
export function applyPatch(doc, ops, notes) {
|
package/src/motion3/lenient.js
CHANGED
|
@@ -4,16 +4,22 @@
|
|
|
4
4
|
// relaxSpec on the expanded spec before validation (pipeline.js): a goal window partly past the
|
|
5
5
|
// clip is cut to it; an object key with only pos takes the turn of the key before it
|
|
6
6
|
// (else after it, else its scene pose: rot for a free entry, aim for a carry so the
|
|
7
|
-
// arm picks the roll); a rot of length strictly between 0.5 and 2 is normalized
|
|
7
|
+
// arm picks the roll); a rot of length strictly between 0.5 and 2 is normalized; a knee
|
|
8
|
+
// pole that points backward in the body frame but forward read as a world direction at
|
|
9
|
+
// the root yaw of its frame (a body turned 90 deg or more) is that world direction; a bare
|
|
10
|
+
// name in goals near / above that is exactly one ref (refs.js bareRefs) is that ref
|
|
11
|
+
// (spelling and placement slips in the sent spec: correct.js, before it is stored)
|
|
8
12
|
// clampProbe a probe window partly past the take is cut to it (probes.js)
|
|
9
13
|
// fitEvery a position probe's every grows to fit its 16 rows (probes.js)
|
|
10
14
|
// clampFrames contact-sheet frames past the range are pulled into it (studio-motion-visual.js)
|
|
11
15
|
// normalizeCompileArgs install spellings ("always", "if_clean", ...) and repair: { rounds: 0 } / true
|
|
12
16
|
// (the registry's normalizeArgs, before the schema check)
|
|
13
|
-
import { quatFromMat } from "./compiler/math.js";
|
|
17
|
+
import { quatFromMat, apply, ry } from "./compiler/math.js";
|
|
18
|
+
import { stateAt } from "./compiler/walk.js";
|
|
14
19
|
import { groupsOf } from "./scene.js";
|
|
15
20
|
import { isCarried } from "./carried.js";
|
|
16
21
|
import { isFree } from "./free-motion.js";
|
|
22
|
+
import { bareRefs } from "./refs.js";
|
|
17
23
|
|
|
18
24
|
const fmtRange = r => `[${r[0]},${r[1]}]`;
|
|
19
25
|
/** Adds note to notes once (search trials expand the same spec again). */
|
|
@@ -72,13 +78,49 @@ function normalizeRots(id, o, notes) {
|
|
|
72
78
|
});
|
|
73
79
|
}
|
|
74
80
|
|
|
81
|
+
const r3 = v => v.map(x => Math.round(x * 1000) / 1000);
|
|
82
|
+
/** A knee pole sent as a world direction (agents facing -Z write [0, 0, -1] for "forward"): its body-frame reading at the root yaw. */
|
|
83
|
+
function worldPoles(spec, notes) {
|
|
84
|
+
if (!Array.isArray(spec.keys)) return;
|
|
85
|
+
spec.keys.forEach((key, i) => {
|
|
86
|
+
for (const ch of ["knee_l", "knee_r"]) {
|
|
87
|
+
const v = key?.set?.[ch];
|
|
88
|
+
if (!(Array.isArray(v) && v.length === 3 && v.every(Number.isFinite))) continue;
|
|
89
|
+
const l = Math.hypot(...v);
|
|
90
|
+
if (!(l > 1e-6 && v[2] < -0.7 * l)) continue;
|
|
91
|
+
const yaw = stateAt(spec.keys, key.f).yaw;
|
|
92
|
+
if (!(Number.isFinite(yaw) && Math.abs(((yaw % 360) + 540) % 360 - 180) >= 90)) continue;
|
|
93
|
+
const body = r3(apply(ry(-yaw), v));
|
|
94
|
+
if (body[2] < 0.7 * l) continue;
|
|
95
|
+
key.set[ch] = body;
|
|
96
|
+
note(notes, `corrected: ${key.src?.startsWith("keys[") ? key.src : `keys[${i}]`} (f${key.f}) ${ch} [${r3(v)}] is a world direction at root yaw ${Math.round(yaw)}: body frame [${body}] (a pole is +z forward)`);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** goals[i].near / above: a bare name with one reading in the expanded spec, rig and scene takes it ("LeftHand" -> joint:LeftHand). */
|
|
102
|
+
function bareGoalRefs(spec, ctx, notes) {
|
|
103
|
+
if (!Array.isArray(spec.goals)) return;
|
|
104
|
+
spec.goals.forEach((g, i) => {
|
|
105
|
+
for (const kind of ["near", "above"]) (Array.isArray(g?.[kind]) ? g[kind] : []).forEach((ref, j) => {
|
|
106
|
+
if (typeof ref !== "string" || /^[a-z]+:/.test(ref)) return;
|
|
107
|
+
const known = bareRefs(spec, ctx, ref);
|
|
108
|
+
if (known.length !== 1) return;
|
|
109
|
+
g[kind][j] = known[0];
|
|
110
|
+
note(notes, `corrected: goals[${i}].${kind}[${j}] "${ref}" -> "${known[0]}"`);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
75
115
|
/**
|
|
76
|
-
* relaxSpec(spec, scene, notes): the fixes above, in place on the expanded spec (a plain JSON copy the
|
|
116
|
+
* relaxSpec(spec, scene, notes, sk): the fixes above, in place on the expanded spec (a plain JSON copy the
|
|
77
117
|
* compile measures; the stored spec stays as sent). notes collects one line per fix.
|
|
78
118
|
*/
|
|
79
|
-
export function relaxSpec(spec, scene, notes) {
|
|
119
|
+
export function relaxSpec(spec, scene, notes, sk) {
|
|
80
120
|
if (!spec || typeof spec !== "object") return spec;
|
|
81
121
|
relaxGoals(spec, notes);
|
|
122
|
+
bareGoalRefs(spec, { sk, scene }, notes);
|
|
123
|
+
worldPoles(spec, notes);
|
|
82
124
|
for (const [id, o] of Object.entries(spec.objects ?? {})) {
|
|
83
125
|
if (!o || typeof o !== "object" || !Array.isArray(o.keys) || !(isCarried(o) || isFree(o))) continue;
|
|
84
126
|
fillTurns(id, o, scene, notes);
|
|
@@ -13,10 +13,11 @@
|
|
|
13
13
|
import { isCarried, carriedPose, compose, relative } from "./carried.js";
|
|
14
14
|
import { seatsInHand, seatOffset, settleAt, between as slide } from "./seat.js";
|
|
15
15
|
import { groupsOf } from "./scene.js";
|
|
16
|
-
import { restState } from "./compiler/skeleton.js";
|
|
16
|
+
import { restState, bonePoint } from "./compiler/skeleton.js";
|
|
17
17
|
import { add, scale, sub, apply, T, matFromQuat, quatFromMat } from "./compiler/math.js";
|
|
18
18
|
import { qmul, qconj, qlog, qexp, qrot, qdot, qnorm } from "./compiler/quat.js";
|
|
19
19
|
import { keyRots, leanPos } from "./key-aim.js";
|
|
20
|
+
import { faceLeaned } from "./face-lean.js";
|
|
20
21
|
import { holdCost } from "./compiler/hold.js";
|
|
21
22
|
import { polePlan, smoothPath, smoothed } from "./hold-plan.js";
|
|
22
23
|
import { secondGrip, secondHold, gripGate } from "./second-hand.js";
|
|
@@ -60,7 +61,7 @@ export function curveIn(out, id) {
|
|
|
60
61
|
if (!had || had.map !== map || had.baked !== baked) {
|
|
61
62
|
const hold = out.ctx.hold, solved = new Map(), at = t => { const f = Math.round(t); if (!solved.has(f)) solved.set(f, hold.plainFrame(f).s); return solved.get(f); };
|
|
62
63
|
const plain = t => quatFromMat(hold.carryOn(id, at(t)).R), strain = (t, pos, q, opts) => hold.strainOn(id, at(t), { pos, R: matFromQuat(q) }, opts);
|
|
63
|
-
cache.set(id, { map, baked, curve: curveOf(out.ctx.spec.objects[id], f => (map ? map(f) : f), plain, strain, 2 * (out.ctx.scene?.objects?.[id]?.half?.[1] ?? 0)) });
|
|
64
|
+
cache.set(id, { map, baked, curve: curveOf(hold.entry?.(id) ?? out.ctx.spec.objects[id], f => (map ? map(f) : f), plain, strain, 2 * (out.ctx.scene?.objects?.[id]?.half?.[1] ?? 0)) });
|
|
64
65
|
}
|
|
65
66
|
return cache.get(id).curve;
|
|
66
67
|
}
|
|
@@ -112,6 +113,17 @@ export function holdHook(ctx, out, solvePlain, solveLed) {
|
|
|
112
113
|
const { sk, spec, scene } = ctx, held = keyedEntries(spec);
|
|
113
114
|
if (!held.length) return null;
|
|
114
115
|
const seated = new Set(held.map(([id]) => id).filter(id => seatsInHand(spec, spec.objects[id], scene, id)).map(id => spec.objects[id]));
|
|
116
|
+
// the entry as the compile holds it: mouth keys leaned (faceLeaned), read once per compile on the frames without keys
|
|
117
|
+
const entries = new WeakMap(), mapped0 = f => (out.timeMap ? Math.round(out.timeMap(f)) : f);
|
|
118
|
+
const entry = id => {
|
|
119
|
+
if (!entries.has(out)) entries.set(out, new Map());
|
|
120
|
+
const cache = entries.get(out);
|
|
121
|
+
if (!cache.has(id)) {
|
|
122
|
+
const top = 2 * (scene?.objects?.[id]?.half?.[1] ?? 0);
|
|
123
|
+
cache.set(id, faceLeaned(spec.objects[id], top, f => bonePoint(solvePlain(mapped0(f)).s, sk.index.Head, sk.landmarks.mouth)));
|
|
124
|
+
}
|
|
125
|
+
return cache.get(id);
|
|
126
|
+
};
|
|
115
127
|
const palmOf = (o, s) => carriedPose(sk, spec, s, { prop: o.prop }, seated.has(o)), attached = new WeakMap(), attaching = new Set();
|
|
116
128
|
const restAt = (id, f) => ctx.restingAt?.(id, f) ?? scene?.objects?.[id] ?? groupsOf(scene)[id];
|
|
117
129
|
// 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
|
|
@@ -130,7 +142,7 @@ export function holdHook(ctx, out, solvePlain, solveLed) {
|
|
|
130
142
|
&& mapped(Math.min(...o.keys.map(key => key.f))) <= grab;
|
|
131
143
|
if (!free) return null;
|
|
132
144
|
attaching.add(id);
|
|
133
|
-
try { cache.set(id, chooseGrip({ sk, o, side: SIDES[k], grab, rest: restAt(id, grab), plain: f => solvePlain(f).s, palmOf: s => palmOf(o, s), map: mapped })); } finally { attaching.delete(id); }
|
|
145
|
+
try { cache.set(id, chooseGrip({ sk, o: entry(id), side: SIDES[k], grab, rest: restAt(id, grab), plain: f => solvePlain(f).s, palmOf: s => palmOf(o, s), map: mapped })); } finally { attaching.delete(id); }
|
|
134
146
|
return cache.get(id);
|
|
135
147
|
};
|
|
136
148
|
const attach = id => {
|
|
@@ -156,7 +168,7 @@ export function holdHook(ctx, out, solvePlain, solveLed) {
|
|
|
156
168
|
const strainOn = (id, s, pose, opts) => { const o = spec.objects[id]; return holdCost(sk, s, SIDES[spec.props[o.prop].hand], compose(pose, invert(attach(id))), palmOf(o, s), opts); };
|
|
157
169
|
const heldAt = (id, o, f) => {
|
|
158
170
|
const curve = curveIn(out, id), w = leadWeight(curve, spanIn(out, o), f);
|
|
159
|
-
return w > 0 ? { w, palm: compose(keyedPose(curve, f), invert(attachAt(id, f))), measure: s => palmOf(o, s), comfort:
|
|
171
|
+
return w > 0 ? { w, palm: compose(keyedPose(curve, f), invert(attachAt(id, f))), measure: s => palmOf(o, s), comfort: entry(id).keys.some(k => k.lean > 0) } : null;
|
|
160
172
|
};
|
|
161
173
|
// the elbow's planned path over the frames the keys lead (hold-plan.js), once per baked channels and time map
|
|
162
174
|
const plans = new WeakMap();
|
|
@@ -268,5 +280,5 @@ export function holdHook(ctx, out, solvePlain, solveLed) {
|
|
|
268
280
|
const firstKey = id => { const t = Math.min(...spec.objects[id].keys.map(k => k.f)); return out.timeMap ? Math.round(out.timeMap(t)) : t; };
|
|
269
281
|
// the grips chosen for keyed carries (observations.wrist reads them): [{ id, hand, name, cost, today }]
|
|
270
282
|
const grips = () => held.map(([id, o]) => { const p = chosen(id); return p && { id, hand: spec.props[o.prop].hand, name: p.name, cost: p.cost, today: p.today }; }).filter(Boolean);
|
|
271
|
-
return { lead, attach, attachAt, attaching: id => attaching.has(id), plainFrame: solvePlain, carryOn, strainOn, span: id => spanIn(out, spec.objects[id]), firstKey, grips };
|
|
283
|
+
return { lead, attach, attachAt, attaching: id => attaching.has(id), plainFrame: solvePlain, entry, carryOn, strainOn, span: id => spanIn(out, spec.objects[id]), firstKey, grips };
|
|
272
284
|
}
|