cozyclay 3.0.0 → 3.0.1
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 +28 -0
- package/bin/agent/agent-routes.mjs +3 -3
- package/bin/agent/agent-runner.mjs +5 -5
- package/bin/agent/session-store.mjs +2 -2
- package/bin/agent/studio-context-budget.mjs +93 -0
- package/bin/agent/studio-prompt.mjs +1 -1
- package/dist/app/index.html +4 -4
- package/dist/assets/analytics-BWCqdgWA.js +1 -0
- package/dist/assets/app-DfTCxaKk.js +5087 -0
- package/dist/assets/compile-worker-BxKXh07s.js +5 -0
- package/dist/assets/{first-shot-handoff-CC4xe4HM.js → first-shot-handoff-DNO8kv_N.js} +1 -1
- package/dist/assets/{landing-DjHD8u7k.js → landing-yfZaJkWc.js} +1 -1
- package/dist/assets/scene-asset-sync-U3kS2rvK.js +20 -0
- package/dist/assets/workflow-D78eiKTa.js +179 -0
- package/dist/cozyclay-package.json +1 -1
- package/dist/index.html +3 -3
- package/dist/workflow/index.html +3 -3
- package/package.json +1 -1
- package/src/App.jsx +1 -1
- package/src/agent-progress.js +3 -2
- package/src/app-context.js +7 -2
- package/src/command-bus.js +2 -2
- package/src/commands/motion3.js +12 -5
- package/src/commands/shot.js +6 -2
- package/src/domains/motion/take-source.js +4 -2
- package/src/draft-ghost.js +3 -2
- package/src/motion/beat-board-model.js +1 -1
- package/src/motion/beat-board.jsx +2 -1
- package/src/motion3/compile-job.js +4 -0
- package/src/motion3/compiler/anchors.js +8 -0
- package/src/motion3/compiler/limbs.js +35 -8
- package/src/motion3/compiler/pose.js +17 -2
- package/src/motion3/compiler/validate-objects.js +3 -2
- package/src/motion3/compiler/validate-parts.js +2 -0
- package/src/motion3/compiler/validate.js +3 -3
- package/src/motion3/compiler/vocab.js +17 -16
- package/src/motion3/describe.js +5 -2
- package/src/motion3/docs.js +1 -1
- package/src/motion3/free-motion.js +42 -11
- package/src/motion3/object-pose.js +1 -1
- package/src/motion3/people.js +82 -0
- package/src/motion3/qa/checks.js +6 -2
- package/src/motion3/qa/hints-contact.js +17 -1
- package/src/motion3/qa/index.js +6 -4
- package/src/motion3/qa/people-checks.js +37 -0
- package/src/motion3/qa/style.js +14 -4
- package/src/motion3/refs.js +10 -1
- package/src/motion3/schema/fields.js +4 -4
- package/src/motion3/schema/topics.js +6 -4
- package/src/motion3/schema/types.js +10 -8
- package/src/motion3/settle.js +136 -0
- package/src/motion3/skills/basic.js +2 -1
- package/src/motion3/skills/flip.js +200 -0
- package/src/motion3/skills/impact.js +109 -0
- package/src/motion3/skills/index.js +3 -1
- package/src/motion3/skills/info.js +15 -0
- package/src/qa-label.js +25 -0
- package/src/studio-actions.js +4 -0
- package/src/studio-agent-protocol.js +8 -8
- package/src/studio-app-binding.js +2 -2
- package/src/studio-error-codes.js +8 -1
- package/src/workflow/AgentProgressCard.jsx +6 -4
- package/tools/run-tests.mjs +2 -2
- package/dist/assets/analytics-C9sUnndd.js +0 -1
- package/dist/assets/app-CCh--1Hc.js +0 -5087
- package/dist/assets/compile-worker-CkxiD6lH.js +0 -5
- package/dist/assets/scene-asset-sync-Hx_FzdOH.js +0 -20
- package/dist/assets/workflow-C4yKH1p4.js +0 -179
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// How a falling object (C17, free-motion.js) comes to rest. It falls with its centre on a parabola, turning at the spin it
|
|
2
|
+
// was let go with, and lands on its lowest point. Then it rolls about what touches the support. Landing on an edge or a
|
|
3
|
+
// corner it rocks back onto the face nearest down, or falls over onto the face it leans to when it leans past that edge.
|
|
4
|
+
// Then it goes over an edge onto a lower face while the landing has the energy (a share TIP of the drop; only one that hit
|
|
5
|
+
// tilted or spinning, since a flat landing has nothing to tip it), or at once from a precarious face (one it can tilt less
|
|
6
|
+
// than PRECARIOUS before its centre passes the edge). So a phone on its edge falls flat, a tilted box comes down on a face,
|
|
7
|
+
// a can let go at a slant lies down, and a can or cup dropped upright stays upright. It only rolls onto a face that lowers
|
|
8
|
+
// its centre by at least LOWER of its height, so a near cube stays the way it lands. The shape is the box around the
|
|
9
|
+
// object's parts in its own frame, and its centre is the parts' volume-weighted centre. Every step is a rotation about a
|
|
10
|
+
// pivot on the support, paced by gravity: closed form and deterministic.
|
|
11
|
+
import { add, sub, scale, dot, cross, len, norm, mul, apply, axisRot, clamp } from "./compiler/math.js";
|
|
12
|
+
import { G } from "./free-motion.js";
|
|
13
|
+
|
|
14
|
+
/** The share of the drop height a landing spends tipping the object over an edge. */
|
|
15
|
+
export const TIP = 0.3;
|
|
16
|
+
const LOWER = 0.2, FLAT = 0.5, PRECARIOUS = 15, MIN_SPIN = 1, MAX_SPIN = 6 * Math.PI, MAX_STEPS = 3;
|
|
17
|
+
const DOWN = [0, -1, 0];
|
|
18
|
+
const AXES = [[1, 0, 0], [-1, 0, 0], [0, 1, 0], [0, -1, 0], [0, 0, 1], [0, 0, -1]];
|
|
19
|
+
const deg = r => (r * 180) / Math.PI;
|
|
20
|
+
|
|
21
|
+
/** The shape of part boxes { pos, R, half } given in the root frame: { com, lo, hi } (box around the corners); null without volume. */
|
|
22
|
+
export function shapeOf(boxes) {
|
|
23
|
+
const solid = boxes.filter(b => b.half[0] * b.half[1] * b.half[2] > 1e-12);
|
|
24
|
+
if (!solid.length) return null;
|
|
25
|
+
let vol = 0, c = [0, 0, 0];
|
|
26
|
+
const corners = [];
|
|
27
|
+
for (const b of solid) {
|
|
28
|
+
const v = b.half[0] * b.half[1] * b.half[2];
|
|
29
|
+
vol += v;
|
|
30
|
+
c = add(c, scale(add(b.pos, apply(b.R, [0, b.half[1], 0])), v));
|
|
31
|
+
for (const x of [-1, 1]) for (const y of [0, 2]) for (const z of [-1, 1]) corners.push(add(b.pos, apply(b.R, [x * b.half[0], y * b.half[1], z * b.half[2]])));
|
|
32
|
+
}
|
|
33
|
+
return { com: scale(c, 1 / vol), lo: [0, 1, 2].map(i => Math.min(...corners.map(p => p[i]))), hi: [0, 1, 2].map(i => Math.max(...corners.map(p => p[i]))) };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const boxCorners = s => [0, 1, 2, 3, 4, 5, 6, 7].map(i => [i & 1 ? s.hi[0] : s.lo[0], i & 2 ? s.hi[1] : s.lo[1], i & 4 ? s.hi[2] : s.lo[2]]);
|
|
37
|
+
const world = (pose, p) => add(pose.pos, apply(pose.R, p));
|
|
38
|
+
const lowest = (s, pose) => Math.min(...boxCorners(s).map(p => world(pose, p)[1]));
|
|
39
|
+
/** The pose moved up or down so its lowest corner sits on the support top. */
|
|
40
|
+
const snap = (s, pose, top) => ({ pos: [pose.pos[0], pose.pos[1] + top - lowest(s, pose), pose.pos[2]], R: pose.R });
|
|
41
|
+
const about = (pose, pivot, Q) => ({ pos: add(pivot, apply(Q, sub(pose.pos, pivot))), R: mul(Q, pose.R) });
|
|
42
|
+
/** The face whose outward normal points most nearly down. */
|
|
43
|
+
const downFace = R => AXES.reduce((best, n, k) => (dot(apply(R, n), DOWN) > dot(apply(R, AXES[best]), DOWN) ? k : best), 0);
|
|
44
|
+
/** Where the plane of face k lies along its axis, and the centre's distance to it (its height when it rests on k). */
|
|
45
|
+
const facePlane = (s, k) => { const a = k >> 1; return AXES[k][a] > 0 ? s.hi[a] : s.lo[a]; };
|
|
46
|
+
const heightOn = (s, k) => Math.abs(facePlane(s, k) - s.com[k >> 1]);
|
|
47
|
+
|
|
48
|
+
/** The spin (rad/s, world axis times rate) that takes rotation Ra to Rb in one frame at fps; capped at MAX_SPIN, none under MIN_SPIN (a hand's jitter). */
|
|
49
|
+
export function spinOf(Ra, Rb, fps) {
|
|
50
|
+
const D = mul(Rb, [[Ra[0][0], Ra[1][0], Ra[2][0]], [Ra[0][1], Ra[1][1], Ra[2][1]], [Ra[0][2], Ra[1][2], Ra[2][2]]]);
|
|
51
|
+
const a = Math.acos(clamp((D[0][0] + D[1][1] + D[2][2] - 1) / 2, -1, 1));
|
|
52
|
+
if (a * fps < MIN_SPIN) return [0, 0, 0];
|
|
53
|
+
const axis = norm([D[2][1] - D[1][2], D[0][2] - D[2][0], D[1][0] - D[0][1]]), rate = Math.min(MAX_SPIN, a * fps);
|
|
54
|
+
return len(axis) > 0 ? scale(axis, rate) : [0, 0, 0];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The fall through the air from `start` onto the support top: { tl (s), at(t) } with the centre on a parabola, turning at `spin`. */
|
|
58
|
+
export function airPath(s, start, spin, top, fps) {
|
|
59
|
+
const rate = len(spin), com0 = world(start, s.com);
|
|
60
|
+
const at = t => {
|
|
61
|
+
const R = rate > 0 ? mul(axisRot(spin, deg(rate * t)), start.R) : start.R;
|
|
62
|
+
return { pos: sub(add(com0, [0, -0.5 * G * t * t, 0]), apply(R, s.com)), R };
|
|
63
|
+
};
|
|
64
|
+
const h = Math.max(0, lowest(s, start) - top);
|
|
65
|
+
if (rate === 0) return { tl: Math.sqrt((2 * h) / G), at };
|
|
66
|
+
// spinning: the first sub-frame step whose lowest corner reaches the top, then halved down to the touch
|
|
67
|
+
const dt = 1 / (fps * 8), cap = Math.sqrt((2 * (h + len(sub(s.hi, s.lo)))) / G) + dt;
|
|
68
|
+
let a = 0, b = dt;
|
|
69
|
+
while (b < cap && lowest(s, at(b)) > top) { a = b; b += dt; }
|
|
70
|
+
for (let i = 0; i < 24 && b - a > 1e-7; i++) { const m = (a + b) / 2; if (lowest(s, at(m)) > top) a = m; else b = m; }
|
|
71
|
+
return { tl: b, at };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A step's duration (s): gravity-paced by the centre's height and the angle it rolls through. */
|
|
75
|
+
const stepTime = (height, angle) => 0.12 + 0.9 * Math.sqrt(Math.max(height, 1e-4) / G) * Math.sqrt(angle / 90);
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* From the pose it lands in, how it rolls to rest on the support top with `energy` (m of drop) to tip over edges:
|
|
79
|
+
* { steps: [{ from, pivot, axis, angle, time }], rest, time }. `lean` (horizontal) is the way a spin tips it: it gives a flat
|
|
80
|
+
* landing the energy and breaks a tie between edges.
|
|
81
|
+
*/
|
|
82
|
+
export function settlePath(s, impact, top, energy, lean = [0, 0, 0]) {
|
|
83
|
+
const steps = [];
|
|
84
|
+
let pose = snap(s, impact, top), time = 0;
|
|
85
|
+
const push = (pivot, axis, angle) => {
|
|
86
|
+
const height = world(pose, s.com)[1] - top, t = stepTime(height, angle);
|
|
87
|
+
steps.push({ from: pose, pivot, axis, angle, time: t });
|
|
88
|
+
pose = snap(s, about(pose, pivot, axisRot(axis, angle)), top);
|
|
89
|
+
time += t;
|
|
90
|
+
};
|
|
91
|
+
// landed on an edge or a corner: back onto the face nearest down, or over onto the face it leans to when it leans past
|
|
92
|
+
// that edge (more than the angle its centre needs to pass it), rolling about the corners on the support
|
|
93
|
+
let k = downFace(pose.R);
|
|
94
|
+
const tiltOf = j => deg(Math.acos(clamp(dot(apply(pose.R, AXES[j]), DOWN), -1, 1))), tilt = tiltOf(k);
|
|
95
|
+
if (tilt > FLAT) {
|
|
96
|
+
const side = [0, 1, 2, 3, 4, 5].filter(j => j >> 1 !== k >> 1).reduce((a, j) => (tiltOf(j) < tiltOf(a) ? j : a), k === 0 || k === 1 ? 2 : 0);
|
|
97
|
+
if (tilt > deg(Math.atan2(heightOn(s, side), heightOn(s, k)))) k = side;
|
|
98
|
+
const touching = boxCorners(s).map(p => world(pose, p)).filter(p => p[1] <= top + 1e-4);
|
|
99
|
+
const n = apply(pose.R, AXES[k]);
|
|
100
|
+
push(scale(touching.reduce(add, [0, 0, 0]), 1 / touching.length), norm(cross(n, DOWN)), tiltOf(k));
|
|
101
|
+
}
|
|
102
|
+
// over an edge onto a lower face while the landing has the energy, at once from a precarious face (or a centre past the
|
|
103
|
+
// edge); a flat landing with no spin has none
|
|
104
|
+
let left = tilt > FLAT || len(lean) > 0 ? energy : 0;
|
|
105
|
+
for (let i = 0; i < MAX_STEPS; i++) {
|
|
106
|
+
const com = world(pose, s.com), cK = com[1] - top;
|
|
107
|
+
let best = null;
|
|
108
|
+
for (let j = 0; j < 6; j++) {
|
|
109
|
+
if (j >> 1 === k >> 1) continue;
|
|
110
|
+
const out = apply(pose.R, AXES[j]), edge = world(pose, AXES[j].map((v, a) => (v ? facePlane(s, j) : a === k >> 1 ? facePlane(s, k) : s.com[a])));
|
|
111
|
+
const w = -dot(sub(com, edge), out), cJ = heightOn(s, j);
|
|
112
|
+
if (!(w < 0 || cJ < cK * (1 - LOWER))) continue;
|
|
113
|
+
const cost = w < 0 || deg(Math.atan2(w, cK)) < PRECARIOUS ? 0 : Math.hypot(cK, w) - cK, side = dot(out, lean);
|
|
114
|
+
if (!best || cost < best.cost - 1e-9 || (Math.abs(cost - best.cost) <= 1e-9 && side > best.side)) best = { j, out, edge, cost, side };
|
|
115
|
+
}
|
|
116
|
+
if (!best || best.cost > left) break;
|
|
117
|
+
left -= best.cost;
|
|
118
|
+
const touching = boxCorners(s).map(p => world(pose, p)).filter(p => p[1] <= top + 1e-4 && dot(sub(p, best.edge), best.out) >= -1e-4);
|
|
119
|
+
push(touching.length ? scale(touching.reduce(add, [0, 0, 0]), 1 / touching.length) : [best.edge[0], top, best.edge[2]], norm(cross(best.out, DOWN)), 90);
|
|
120
|
+
k = best.j;
|
|
121
|
+
}
|
|
122
|
+
return { steps, rest: pose, time };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The pose `u` seconds after it landed, along the settle path: each step rolls about its pivot, accelerating like a fall. */
|
|
126
|
+
export function settledAt(path, u) {
|
|
127
|
+
let t = u;
|
|
128
|
+
for (const st of path.steps) {
|
|
129
|
+
if (t < st.time) { const s = t / st.time; return about(st.from, st.pivot, axisRot(st.axis, st.angle * s * s)); }
|
|
130
|
+
t -= st.time;
|
|
131
|
+
}
|
|
132
|
+
return path.rest;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The energy (m of drop) a spin adds: its kinetic energy over m g, for the box's radius of gyration about its centre. */
|
|
136
|
+
export const spinEnergy = (s, spin) => (len(spin) ** 2 * ((s.hi[0] - s.lo[0]) ** 2 + (s.hi[1] - s.lo[1]) ** 2 + (s.hi[2] - s.lo[2]) ** 2)) / (24 * G);
|
|
@@ -71,7 +71,8 @@ export function reachGrip(ctx) {
|
|
|
71
71
|
if (!cut) b.at(reach + hold + release, { [w]: 0, [fg]: { ...state[fg], [preset]: 0 }, ...(wasFk ? { [armw]: 0 } : {}) });
|
|
72
72
|
const S = k === "l" ? "Left" : "Right", prop = `${name}Grip`;
|
|
73
73
|
const def = args.define ? { [name]: args.define } : {};
|
|
74
|
-
|
|
74
|
+
// the hand may touch what it grips: an object, or the other person (people.js) whose joint the anchor is on
|
|
75
|
+
const a = args.define ?? ctx.anchors?.[name], obj = a?.kind === "object" ? a.object : a?.kind === "person" ? `person:${a.person}` : null;
|
|
75
76
|
return {
|
|
76
77
|
keys: b.keys, end: cut ? last : ctx.f0 + reach + hold + release, ...(cut ? { cut: `release cut at f${last} (hold to the end)` } : {}), anchors: def,
|
|
77
78
|
props: { [prop]: { hand: k, origin: [`${S}HandIndex2`, `${S}HandMiddle2`], points: { fingers: [0, 0, 0] } } },
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// flip: a standing back or front tuck, or a cartwheel, from the standing pose keyed before f0. The root and the feet (and
|
|
2
|
+
// the cartwheel's hands) are keyed every frame from closed-form curves whose speeds join without a kink (no POP); the
|
|
3
|
+
// arms, spine and head are keyed at the phase edges and the channels' own interpolation carries them between.
|
|
4
|
+
// - back/front: crouch, push-off (the heels roll up over the balls), a ballistic flight (the hips on a parabola under
|
|
5
|
+
// gravity, so QA reads a jump, not a float) with the knees tucked to the chest while the whole body turns 360 deg a turn
|
|
6
|
+
// (pitch, - backward), a landing that absorbs the fall, and the stand.
|
|
7
|
+
// - cartwheel: the lead foot steps out, then the body rolls 360 deg toward args.side like a wheel whose spokes are the
|
|
8
|
+
// straddled legs and the arms overhead: each frame the hips sit as high as the lowest spoke touching the floor allows,
|
|
9
|
+
// and a hand or foot whose spoke is down stays planted where it touched.
|
|
10
|
+
// Both end with the turn's full angle and then 0 on the last two frames (the same pose), so a later key at 0 does not
|
|
11
|
+
// unwind it. Poles are given so the knees and elbows bend toward the turning body's front (pose.js tiltFrame).
|
|
12
|
+
import { add, sub, scale, apply, mul, rx, ry, rz, dist, T } from "../compiler/math.js";
|
|
13
|
+
import { pivot } from "../compiler/walk-pelvis.js";
|
|
14
|
+
import { tiltFrame } from "../compiler/pose.js";
|
|
15
|
+
import { skillError } from "./book.js";
|
|
16
|
+
|
|
17
|
+
const G = 9.81 / (24 * 24); // m per frame^2 at the timeline's 24 fps
|
|
18
|
+
const CROUCH = 10, PUSH = 5, LAND = 6, RISE = 14, TOE_OFF = 50;
|
|
19
|
+
const clamp01 = u => Math.min(1, Math.max(0, u));
|
|
20
|
+
const ease = u => { const t = clamp01(u); return t * t * (3 - 2 * t); };
|
|
21
|
+
const mix = (a, b, t) => a + (b - a) * t;
|
|
22
|
+
const mix3 = (a, b, t) => a.map((v, i) => v + (b[i] - v) * t);
|
|
23
|
+
const window = (u, a, b, c, d) => ease((u - a) / (b - a)) - ease((u - c) / (d - c));
|
|
24
|
+
const SIDE = { l: 1, r: -1 };
|
|
25
|
+
const bodyFrame = (h, pitch, roll) => mul(ry(h), mul(rx(pitch), rz(roll)));
|
|
26
|
+
/** A pole the turning body (B) holds as `local`, written as the channel pose.js turns by its own frame. */
|
|
27
|
+
const poleFor = (h, pitch, roll, B, local) => apply(T(tiltFrame(h, pitch, roll)?.R ?? ry(h)), apply(B, local));
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* fyaw/fpitch that make pose.js aim the foot (orientFoot, in its tilt frame) where the turning body B holds it at fpitch
|
|
31
|
+
* `fp` in its own frame: the toe direction solved back through the frame pose.js reads the channels in (clamped to fpitch's
|
|
32
|
+
* range). Nearer upright pose.js reads them on the level, as for any foot, and they pass as they are.
|
|
33
|
+
*/
|
|
34
|
+
function footAim(b, k, pitch, roll, B, fp) {
|
|
35
|
+
const tilt = tiltFrame(b.h, pitch, roll);
|
|
36
|
+
if (!tilt) return { fyaw: b.h, fpitch: fp };
|
|
37
|
+
const rest = b.toe[k], want = apply(B, apply(rx(fp), rest)), d = apply(T(tilt.R), want);
|
|
38
|
+
const phi = Math.atan2(-rest[1], rest[2]), deg = 180 / Math.PI;
|
|
39
|
+
const p = Math.asin(Math.max(-1, Math.min(1, -d[1] / Math.hypot(...d)))) - phi;
|
|
40
|
+
return { fyaw: b.h + Math.atan2(d[0], d[2]) * deg, fpitch: Math.max(-60, Math.min(90, p * deg)) };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function base(ctx) {
|
|
44
|
+
const { sk, args, state } = ctx, I = sk.index, P = sk.restWorldPos;
|
|
45
|
+
if (!state.foot_l || !state.foot_r || !state.root) skillError(ctx.tag, "needs a standing pose keyed before f0 (use standIdle or another skill first)");
|
|
46
|
+
const h = args.heading ?? state.yaw ?? 0, R = ry(h), hips = P[I.Hips];
|
|
47
|
+
const leg = dist(P[I.LeftUpLeg], P[I.LeftLeg]) + dist(P[I.LeftLeg], P[I.LeftFoot]), arm = dist(P[I.LeftArm], P[I.LeftForeArm]) + dist(P[I.LeftForeArm], P[I.LeftHand]);
|
|
48
|
+
return { h, R, F: apply(R, [0, 0, 1]), L: apply(R, [1, 0, 0]), ank: P[I.LeftFoot][1], y0: hips[1] - 0.01, leg, arm,
|
|
49
|
+
feet: { l: state.foot_l.slice(), r: state.foot_r.slice() }, root: state.root.slice(),
|
|
50
|
+
hipOff: { l: sub(P[I.LeftUpLeg], hips), r: sub(P[I.RightUpLeg], hips) }, shoulder: { l: sub(P[I.LeftArm], hips), r: sub(P[I.RightArm], hips) },
|
|
51
|
+
toe: { l: sub(P[I.LeftToeBase], P[I.LeftFoot]), r: sub(P[I.RightToeBase], P[I.RightFoot]) } };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function tuck(ctx, b, kind) {
|
|
55
|
+
// one turn from a standing jump whose hips rise H at the top: the curves below are measured clean for that flip (a
|
|
56
|
+
// higher jump or more turns would need its own tuck timing, so the skill takes neither)
|
|
57
|
+
const { args } = ctx, s = kind === "back" ? -1 : 1, turn = s * 360, H = 0.5;
|
|
58
|
+
const travel = args.travel ?? (kind === "back" ? -0.25 : 0.4);
|
|
59
|
+
const yT = b.y0 + 0.05, yL = b.y0 - 0.02; // the hips at take-off (toes pointed) and at touch-down
|
|
60
|
+
const Tf = Math.max(10, Math.round(Math.sqrt(2 * (H - 0.05) / G) + Math.sqrt(2 * (yT + H - 0.05 - yL) / G)));
|
|
61
|
+
const v0 = (yL - yT + (G * Tf * Tf) / 2) / Tf, vL = v0 - G * Tf, yc = yT - (v0 * PUSH) / 2, bottom = yL + (vL * LAND) / 2;
|
|
62
|
+
const vx = travel / (PUSH / 2 + Tf + LAND / 2);
|
|
63
|
+
const t0 = ctx.f0, tP = t0 + CROUCH, tA = tP + PUSH, tD = tA + Tf, tL = tD + LAND, tE = tL + RISE;
|
|
64
|
+
const land = k => add(b.feet[k], scale(b.F, travel)), ball = k => add(b.feet[k], apply(b.R, b.toe[k]));
|
|
65
|
+
const hipsAt = f => {
|
|
66
|
+
let y, x;
|
|
67
|
+
if (f <= tP) { y = mix(b.root[1], yc, ease((f - t0) / CROUCH)); x = 0; }
|
|
68
|
+
else if (f <= tA) { const t = f - tP; y = yc + (v0 / PUSH) * t * t / 2; x = (vx / PUSH) * t * t / 2; }
|
|
69
|
+
else if (f <= tD) { const t = f - tA; y = yT + v0 * t - (G * t * t) / 2; x = (vx * PUSH) / 2 + vx * t; }
|
|
70
|
+
else if (f <= tL) { const t = f - tD; y = yL + vL * t - (vL / LAND) * t * t / 2; x = (vx * PUSH) / 2 + vx * Tf + vx * t - (vx / LAND) * t * t / 2; }
|
|
71
|
+
else { y = mix(bottom, b.y0, ease((f - tL) / RISE)); x = travel; }
|
|
72
|
+
return add([b.root[0], y, b.root[2]], scale(b.F, x));
|
|
73
|
+
};
|
|
74
|
+
// the turn starts 2 frames before take-off and ends 3 after touch-down
|
|
75
|
+
const pitchAt = f => turn * ease((f - tA + 2) / (Tf + 5)), frameAt = f => bodyFrame(b.h, pitchAt(f), 0);
|
|
76
|
+
// in the air the feet ride the body: from where they left the floor, through the tuck, to where they land, all in its frame
|
|
77
|
+
const local = (f, p) => apply(T(frameAt(f)), sub(p, hipsAt(f)));
|
|
78
|
+
const offL = { l: local(tA, pivot(ball("l"), b.h, TOE_OFF, b.feet.l)), r: local(tA, pivot(ball("r"), b.h, TOE_OFF, b.feet.r)) };
|
|
79
|
+
const onL = { l: local(tD, land("l")), r: local(tD, land("r")) };
|
|
80
|
+
const keys = [];
|
|
81
|
+
for (let f = t0; f <= tE + 1; f++) {
|
|
82
|
+
const pitch = f > tE ? 0 : pitchAt(f), B = bodyFrame(b.h, pitch, 0), hips = hipsAt(f), u = (f - tA) / Tf;
|
|
83
|
+
const tk = f > tA && f < tD ? window(u, 0.12, 0.34, 0.6, 0.82) : 0;
|
|
84
|
+
const set = { root: hips, yaw: b.h, pitch, roll: 0, armw_l: 0, armw_r: 0, fyaw_l: b.h, fyaw_r: b.h };
|
|
85
|
+
for (const k of ["l", "r"]) {
|
|
86
|
+
let foot, fp;
|
|
87
|
+
if (f <= tP) { foot = b.feet[k]; fp = 0; }
|
|
88
|
+
else if (f <= tA) { fp = TOE_OFF * ease((f - tP) / PUSH); foot = pivot(ball(k), b.h, fp, b.feet[k]); }
|
|
89
|
+
else if (f < tD) {
|
|
90
|
+
const a = 1 - ease(u / 0.2), c = ease((u - 0.72) / 0.28);
|
|
91
|
+
const mid = add(b.hipOff[k], mix3([0, -b.leg * 0.97, 0], [SIDE[k] * 0.02, -0.24, 0.16], tk));
|
|
92
|
+
foot = add(hips, apply(B, add(add(scale(offL[k], a), scale(onL[k], c)), scale(mid, 1 - a - c))));
|
|
93
|
+
fp = mix(mix(TOE_OFF, 30, ease(u / 0.2)), 0, ease((u - 0.7) / 0.3));
|
|
94
|
+
} else { foot = land(k); fp = 0; }
|
|
95
|
+
const aim = f > tA && f < tD ? footAim(b, k, pitch, 0, B, fp) : { fyaw: b.h, fpitch: fp };
|
|
96
|
+
Object.assign(set, { [`foot_${k}`]: foot, [`fpitch_${k}`]: aim.fpitch, [`fyaw_${k}`]: aim.fyaw, [`knee_${k}`]: poleFor(b.h, pitch, 0, B, [SIDE[k] * 0.05, 0.5 * tk, 1]) });
|
|
97
|
+
}
|
|
98
|
+
keys.push({ f, set });
|
|
99
|
+
}
|
|
100
|
+
// the arms, spine and head at the phase edges: back in the crouch, overhead at take-off, to the shins in the tuck,
|
|
101
|
+
// forward for the landing, then down
|
|
102
|
+
const head = kind === "back" ? [-12, 18] : [10, 25];
|
|
103
|
+
for (const [f, sw, bd, sp, hd] of [[t0, 0, 0, 0, 0], [tP, -45, 15, 20, 0], [tA, 160, 5, -8, head[0]], [Math.round(tA + 0.3 * Tf), 55, 85, 32, head[1]],
|
|
104
|
+
[Math.round(tA + 0.62 * Tf), 55, 85, 32, head[1]], [tD, 75, 25, 22, 0], [tL, 70, 25, 28, 0], [tE, 0, 0, 0, 0]]) {
|
|
105
|
+
keys.push({ f, set: { aswing_l: sw, aswing_r: sw, abend_l: bd, abend_r: bd, spine_pitch: sp, head_pitch: hd } });
|
|
106
|
+
}
|
|
107
|
+
return { keys, end: tE + 1 };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function cartwheel(ctx, b) {
|
|
111
|
+
const { args } = ctx, dir = args.side === "r" ? -1 : 1, rs = -dir; // toward the character's left the head goes to +x: roll -
|
|
112
|
+
const W = 12, ROT = 34, REC = 14, t0 = ctx.f0, tR = t0 + W, tC = tR + ROT, tE = tC + REC;
|
|
113
|
+
const lead = dir > 0 ? "l" : "r", trail = lead === "l" ? "r" : "l";
|
|
114
|
+
// spokes from the hips in the body's frame (x the body's left): the legs straddled, the arms overhead and apart
|
|
115
|
+
const spoke = { foot: k => add(b.hipOff[k], [SIDE[k] * 0.3, -b.leg * 0.93, 0]), hand: k => add(b.shoulder[k], [SIDE[k] * 0.32, b.arm * 0.88, 0]) };
|
|
116
|
+
const floor = { foot: b.ank, hand: 0.1 };
|
|
117
|
+
// the order the ends touch down rolling toward the lead side; the body pivots on each in turn like a wheel on its spokes
|
|
118
|
+
const order = [["foot", lead], ["hand", lead], ["hand", trail], ["foot", trail], ["foot", lead]];
|
|
119
|
+
const rollAt = f => f <= tR ? 0 : f <= tC ? rs * 360 * ease((f - tR) / ROT) : rs * 360;
|
|
120
|
+
const end = (f, hips, part, k) => add(hips, apply(bodyFrame(b.h, 0, rollAt(f)), spoke[part](k)));
|
|
121
|
+
// start: the trail foot stays, the lead foot steps out so both stand on their spokes; the hips over them
|
|
122
|
+
const B0 = bodyFrame(b.h, 0, 0), hips0 = sub(b.feet[trail], apply(B0, spoke.foot(trail))), stepTo = end(tR, hips0, "foot", lead);
|
|
123
|
+
stepTo[1] = b.ank;
|
|
124
|
+
const raw = [], plants = { foot: { l: [], r: [] }, hand: { l: [], r: [] } };
|
|
125
|
+
let at = 0, pivot = b.feet[lead].slice(), from = tR;
|
|
126
|
+
plants.foot[trail].push({ a: t0, b: tR, p: b.feet[trail] });
|
|
127
|
+
for (let f = t0; f <= tE + 1; f++) {
|
|
128
|
+
if (f <= tR) { raw.push(mix3(b.root, hips0, ease((f - t0) / W))); continue; }
|
|
129
|
+
if (f === tR + 1) pivot = stepTo.slice();
|
|
130
|
+
let hips = sub(pivot, apply(bodyFrame(b.h, 0, rollAt(f)), spoke[order[at][0]](order[at][1])));
|
|
131
|
+
const next = order[at + 1];
|
|
132
|
+
if (next && f <= tC) {
|
|
133
|
+
const e = end(f, hips, ...next);
|
|
134
|
+
if (e[1] <= floor[next[0]]) {
|
|
135
|
+
// the end touched between the frames: pivot where it crossed the floor, so the hips do not jump
|
|
136
|
+
const ep = end(f - 1, raw.at(-1), ...next), h1 = ep[1] - floor[next[0]], h2 = e[1] - floor[next[0]];
|
|
137
|
+
const cross = mix3(ep, e, h1 > 0 ? h1 / (h1 - h2) : 1);
|
|
138
|
+
plants[order[at][0]][order[at][1]].push({ a: from, b: f, p: pivot });
|
|
139
|
+
at++; from = f; pivot = [cross[0], floor[next[0]], cross[2]];
|
|
140
|
+
hips = sub(pivot, apply(bodyFrame(b.h, 0, rollAt(f)), spoke[next[0]](next[1])));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
raw.push(hips);
|
|
144
|
+
}
|
|
145
|
+
plants[order[at][0]][order[at][1]].push({ a: from, b: tE + 1, p: pivot });
|
|
146
|
+
// the trail foot that landed stays down to the end too
|
|
147
|
+
const last = order[order.length - 2];
|
|
148
|
+
if (at >= order.length - 1) plants[last[0]][last[1]].at(-1).b = tE + 1;
|
|
149
|
+
// the pivot path turns at each touch-down: blurred over a few frames (the planted ends absorb it), then the stand
|
|
150
|
+
const blur = raw.map((p, i) => { let s = [0, 0, 0], w = 0; for (let d = -3; d <= 3; d++) { const j = Math.min(raw.length - 1, Math.max(0, i + d)), g = Math.exp(-(d * d) / 4); s = add(s, scale(raw[j], g)); w += g; } return scale(s, 1 / w); });
|
|
151
|
+
const hipsAt = f => f <= tC ? blur[f - t0] : add(blur[tC - t0], [0, (b.y0 - blur[tC - t0][1]) * ease((f - tC) / REC), 0]);
|
|
152
|
+
// a planted end the turning body has rolled away from is drawn toward its hip or shoulder until the limb reaches it
|
|
153
|
+
const reach = (p, from, n) => { const v = sub(p, from), d = Math.hypot(...v); return d > n ? add(from, scale(v, n / d)) : p; };
|
|
154
|
+
// a hand or foot near its plant: easing onto it over the 6 frames before, held, then let go by following the body from
|
|
155
|
+
// where it left the floor (its place in the body's frame then) toward its spoke, so it lifts without a jolt
|
|
156
|
+
const touch = (part, k, f, free) => {
|
|
157
|
+
for (const r of plants[part][k]) {
|
|
158
|
+
if (f >= r.a - 6 && f <= r.b) return mix3(free, r.p, ease((f - r.a + 6) / 6));
|
|
159
|
+
if (f > r.b && f <= r.b + 10) {
|
|
160
|
+
const at = bodyFrame(b.h, 0, rollAt(r.b)), left = apply(T(at), sub(r.p, hipsAt(r.b)));
|
|
161
|
+
const follow = add(hipsAt(f), apply(bodyFrame(b.h, 0, rollAt(f)), mix3(left, spoke[part](k), ease((f - r.b - 1) / 9))));
|
|
162
|
+
return mix3(r.p, follow, ease((f - r.b) / 8));
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return free;
|
|
166
|
+
};
|
|
167
|
+
const planted = (part, k, f) => plants[part][k].some(r => f >= r.a && f <= r.b);
|
|
168
|
+
// where the feet stand after the roll: a planted one where it is, else under its spoke at the roll's end
|
|
169
|
+
const landed = Object.fromEntries(["l", "r"].map(k => { const p = planted("foot", k, tC) ? plants.foot[k].at(-1).p.slice() : end(tC, hipsAt(tC), "foot", k); p[1] = b.ank; return [k, p]; }));
|
|
170
|
+
const keys = [];
|
|
171
|
+
for (let f = t0; f <= tE + 1; f++) {
|
|
172
|
+
const roll = f === tE + 1 ? 0 : rollAt(f), B = bodyFrame(b.h, 0, rollAt(f)), hips = hipsAt(f);
|
|
173
|
+
const set = { root: hips, yaw: b.h, pitch: 0, roll };
|
|
174
|
+
for (const k of ["l", "r"]) {
|
|
175
|
+
let foot;
|
|
176
|
+
if (f <= tR && k === lead) { const w = ease((f - t0 - 1) / (W - 4)); foot = add(mix3(b.feet[k], stepTo, w), [0, 0.07 * Math.sin(Math.PI * w), 0]); }
|
|
177
|
+
else if (f > tC) foot = landed[k];
|
|
178
|
+
else {
|
|
179
|
+
const down = ease((f - tC + 4) / 4);
|
|
180
|
+
foot = reach(mix3(touch("foot", k, f, end(f, hips, "foot", k)), landed[k], down), add(hips, apply(B, b.hipOff[k])), b.leg * 0.995);
|
|
181
|
+
}
|
|
182
|
+
const aim = f > tR && f <= tC ? footAim(b, k, 0, rollAt(f), B, 0) : { fyaw: b.h, fpitch: 0 };
|
|
183
|
+
Object.assign(set, { [`foot_${k}`]: foot, [`fyaw_${k}`]: aim.fyaw, [`fpitch_${k}`]: aim.fpitch, [`knee_${k}`]: poleFor(b.h, 0, roll, B, [SIDE[k] * 0.05, 0, 1]),
|
|
184
|
+
[`hand_${k}`]: reach(f > tR && f <= tC ? touch("hand", k, f, end(f, hips, "hand", k)) : end(f, hips, "hand", k), add(hips, apply(B, b.shoulder[k])), b.arm * 0.995), [`elbow_${k}`]: poleFor(b.h, 0, roll, B, [SIDE[k] * 0.4, 0, -1]),
|
|
185
|
+
[`armw_${k}`]: f <= tR ? 0 : f <= tC ? ease((f - tR) / 6) : 1 - ease((f - tC) / 8) });
|
|
186
|
+
}
|
|
187
|
+
// the FK arms overhead along the hand spokes, so the hand eases onto its IK target without a jump
|
|
188
|
+
set.arm_rest = f <= tR ? mix(72, -56, ease((f - t0) / W)) : f <= tC ? -56 : mix(-56, 72, ease((f - tC) / REC));
|
|
189
|
+
keys.push({ f, set });
|
|
190
|
+
}
|
|
191
|
+
return { keys, end: tE + 1 };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** flip({ kind: "back" | "front" | "cartwheel", travel, side, heading }) */
|
|
195
|
+
export function flip(ctx) {
|
|
196
|
+
const kind = ctx.args.kind ?? "back";
|
|
197
|
+
if (!["back", "front", "cartwheel"].includes(kind)) skillError(ctx.tag, `kind must be "back", "front" or "cartwheel", got ${JSON.stringify(kind)}`);
|
|
198
|
+
const b = base(ctx);
|
|
199
|
+
return kind === "cartwheel" ? cartwheel(ctx, b) : tuck(ctx, b, kind);
|
|
200
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// hitReaction: a body thrown by a hit (a car, a shove, a blow) and coming to rest on the floor, from the pose at f0.
|
|
2
|
+
// Deterministic physics on the root: launched along the push at `speed` (m/s) and up (from the speed), it flies under gravity, lands
|
|
3
|
+
// when the hips reach lying height, then slides to a stop on floor friction. The body turns to fall along the push
|
|
4
|
+
// (on its back when pushed from the front, "land"), the trunk and head whip on a damped spring against the shove,
|
|
5
|
+
// the arms flail and trail, the legs follow the hips (feet held off the floor) and the whole body lies still at the end.
|
|
6
|
+
import { add, apply, ry, rx } from "../compiler/math.js";
|
|
7
|
+
import { tiltFrame } from "../compiler/pose.js";
|
|
8
|
+
import { personJoint } from "../people.js";
|
|
9
|
+
import { skillError } from "./book.js";
|
|
10
|
+
|
|
11
|
+
// LIE: the root's height lying on the back or face down, the trunk's skin just on the floor (X/Y Bot)
|
|
12
|
+
const G = 9.81, FPS = 24, MU = 0.75, LIE = { back: 0.16, front: 0.11 }, TURN = 0.25, ONSET = 0.12, LIFT_OFF = 0.25;
|
|
13
|
+
/** Distance covered by a speed that builds up over ONSET s (a hit pushes for about a tenth of a second, not one frame). */
|
|
14
|
+
const ramp = t => t - ONSET * (1 - Math.exp(-t / ONSET));
|
|
15
|
+
/** A foot held off the floor eases onto it (softplus over w m) instead of being cut at it. */
|
|
16
|
+
const softFloor = (y, lie, w) => lie + w * Math.log1p(Math.exp((y - lie) / w));
|
|
17
|
+
/** The landing: the fall stops over CUSHION s at one deceleration (spread over frames, not one), the trunk sinking SINK m
|
|
18
|
+
* into its own give past lying height, and springs back to it over SETTLE s. */
|
|
19
|
+
const CUSHION = 3 / FPS, SINK = 0.03, SETTLE = 0.25;
|
|
20
|
+
const smooth = x => { const t = Math.min(1, Math.max(0, x)); return t * t * t * (t * (6 * t - 15) + 10); };
|
|
21
|
+
const wrap = d => ((d + 540) % 360) - 180;
|
|
22
|
+
const r3 = v => v.map(x => Math.round(x * 1000) / 1000);
|
|
23
|
+
const r1 = x => Math.round(x * 10) / 10;
|
|
24
|
+
|
|
25
|
+
/** Where the earlier keys have a vec3 channel at frame f, read straight between the keys around it (state holds the last key). */
|
|
26
|
+
function track(keys, ch, f) {
|
|
27
|
+
let a = null, b = null;
|
|
28
|
+
for (const k of keys) {
|
|
29
|
+
const v = k.set?.[ch];
|
|
30
|
+
if (!Array.isArray(v)) continue;
|
|
31
|
+
if (k.f <= f && (!a || k.f >= a.f)) a = { f: k.f, v };
|
|
32
|
+
if (k.f > f && (!b || k.f < b.f)) b = { f: k.f, v };
|
|
33
|
+
}
|
|
34
|
+
if (!a || !b) return a?.v;
|
|
35
|
+
const w = (f - a.f) / (b.f - a.f);
|
|
36
|
+
return a.v.map((x, i) => x + (b.v[i] - x) * w);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The push direction [x, z] (unit) from args.dir, or from args.from (object:<id> | person:<id>) toward the hips. */
|
|
40
|
+
function pushOf(ctx, root) {
|
|
41
|
+
const { args, scene } = ctx;
|
|
42
|
+
let d = args.dir;
|
|
43
|
+
if (!d && typeof args.from === "string") {
|
|
44
|
+
const [kind, id] = args.from.split(/:(.*)/s);
|
|
45
|
+
const at = kind === "object" ? scene?.objects?.[id]?.pos : kind === "person" ? personJoint(scene, id, "Hips", ctx.f0) : null;
|
|
46
|
+
if (!at) skillError(ctx.tag, `args.from "${args.from}" must be object:<id> of a scene body or person:<id> of a character with an installed take`);
|
|
47
|
+
d = [root[0] - at[0], root[2] - at[2]];
|
|
48
|
+
}
|
|
49
|
+
if (!Array.isArray(d) || d.length !== 2 || !(Math.hypot(d[0], d[1]) > 1e-6)) skillError(ctx.tag, "needs args.dir: [x, z] (the way the body is thrown) or args.from: object:<id> | person:<id> (what hits it)");
|
|
50
|
+
const n = Math.hypot(d[0], d[1]);
|
|
51
|
+
return [d[0] / n, d[1] / n];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function hitReaction(ctx) {
|
|
55
|
+
const { sk, args, state } = ctx, I = sk.index, P = sk.restWorldPos;
|
|
56
|
+
if (!state.root) skillError(ctx.tag, "needs a pose keyed before f0 (standIdle, a walk, ...)");
|
|
57
|
+
// the hit takes the body where the earlier motion has it at f0, a foot in the air going on at its speed
|
|
58
|
+
const root0 = track(ctx.keys, "root", ctx.f0) ?? state.root.slice(), from = {};
|
|
59
|
+
for (const k of ["l", "r"]) {
|
|
60
|
+
const at = track(ctx.keys, `foot_${k}`, ctx.f0), was = track(ctx.keys, `foot_${k}`, ctx.f0 - 1);
|
|
61
|
+
if (at) from[k] = { at, v: was ? at.map((x, i) => x - was[i]) : [0, 0, 0] };
|
|
62
|
+
}
|
|
63
|
+
const yaw0 = state.yaw ?? 0, [dx, dz] = pushOf(ctx, root0);
|
|
64
|
+
const speed = args.speed ?? 4;
|
|
65
|
+
if (!(Number.isFinite(speed) && speed > 0)) skillError(ctx.tag, `args.speed must be a launch speed above 0 m/s, got ${JSON.stringify(args.speed)}`);
|
|
66
|
+
const up = Math.min(2.5, Math.max(0.8, 0.3 * speed));
|
|
67
|
+
// pushed against the way it faces (a hit from the front) it lands on its back; "land" overrides
|
|
68
|
+
const facing = apply(ry(yaw0), [0, 0, 1]), back = (args.land ?? (dx * facing[0] + dz * facing[2] <= 0 ? "back" : "front")) === "back";
|
|
69
|
+
const head = Math.atan2(dx, dz) * 180 / Math.PI, yawT = back ? head + 180 : head, turn = wrap(yawT - yaw0), pitchT = back ? -90 : 90;
|
|
70
|
+
const lie = back ? LIE.back : LIE.front, y0 = root0[1], fall = t => y0 + up * ramp(t) - 0.5 * G * t * t, vel = t => up * (1 - Math.exp(-t / ONSET)) - G * t;
|
|
71
|
+
// the cushion starts at t1 where the fall, slowed evenly to rest over CUSHION, ends just at lying height
|
|
72
|
+
let lo = 0, hi = (up + Math.sqrt(up * up + 2 * G * Math.max(0, y0 - lie))) / G + 1;
|
|
73
|
+
for (let i = 0; i < 60; i++) { const m = (lo + hi) / 2; if (fall(m) + vel(m) * CUSHION / 2 > lie - SINK || vel(m) > 0) lo = m; else hi = m; }
|
|
74
|
+
const t1 = lo, v1 = Math.min(0, vel(t1)), y1 = fall(t1), T = t1 + CUSHION, tSlide = speed / (MU * G), tRest = T + tSlide;
|
|
75
|
+
const height = t => t < t1 ? fall(t) : t < T ? y1 + v1 * (t - t1) - v1 / (2 * CUSHION) * (t - t1) ** 2 : lie - SINK * (1 - smooth((t - T) / SETTLE));
|
|
76
|
+
const ankle = P[I.LeftFoot][1], hipsY = P[I.Hips][1], sign = back ? 1 : -1, LEG = 0.9 * Math.hypot(...[0, 1, 2].map(i => P[I.LeftFoot][i] - P[I.Hips][i]));
|
|
77
|
+
const end = ctx.f0 + Math.ceil((tRest + 0.6) * FPS), keys = [];
|
|
78
|
+
for (let f = ctx.f0; f <= end; f++) {
|
|
79
|
+
const t = (f - ctx.f0) / FPS, flying = t < T, ts = Math.min(Math.max(0, t - T), tSlide);
|
|
80
|
+
const run = flying ? speed * ramp(t) : speed * ramp(T) + speed * ts - 0.5 * MU * G * ts * ts;
|
|
81
|
+
const y = height(t);
|
|
82
|
+
const root = [root0[0] + dx * run, y, root0[2] + dz * run];
|
|
83
|
+
const tilt = Math.max(0.35, 0.9 * T), yaw = yaw0 + turn * smooth(t / Math.max(TURN, tilt)), pitch = pitchT * smooth(t / tilt), R = (v => apply(ry(yaw), apply(rx(pitch), v)));
|
|
84
|
+
// the trunk and head whip against the shove (a damped spring), the arms flail and trail, all settling by the rest
|
|
85
|
+
const ease = smooth(t / ONSET), whip = ease * Math.exp(-t / 0.18) * Math.sin(2 * Math.PI * t / 0.55), flail = smooth(t / 0.3) * Math.exp(-t / 0.45) * Math.sin(2 * Math.PI * t / 0.6);
|
|
86
|
+
const lying = smooth((t - T) / 0.3);
|
|
87
|
+
// legs bend in the air and lie out: on the back a little bent, knees up, toes up; face down straight, toes down
|
|
88
|
+
const legY = -(hipsY - ankle) + 0.06 * (1 - lying) + (back ? 0.03 : 0.01) * lying, off = smooth(t / LIFT_OFF);
|
|
89
|
+
// face down the feet ride a little off the floor while the body slides, so they are not taken for planted feet
|
|
90
|
+
const floor = ankle + 0.01 + (!back && !flying && ts < tSlide ? 0.04 : 0);
|
|
91
|
+
// on the back the heels stay below the hips (a soft cap), so the legs slope down to the feet and the knees bend up
|
|
92
|
+
const foot = (k, x, z) => { const p = add(root, R([x, legY, z])), cap = root[1] - 0.05; if (back) p[1] = cap - 0.05 * Math.log1p(Math.exp((cap - p[1]) / 0.05)); p[1] = softFloor(p[1], floor, 0.03); const o = from[k]; if (!o) return p; const q = o.at.map((x, i) => x + o.v[i] * (f - ctx.f0) * (1 - off)); q[1] = Math.max(o.at[1], q[1]); return reach(p.map((v, i) => q[i] + (v - q[i]) * off)); };
|
|
93
|
+
// a foot left behind is drawn in within the leg's reach of the hips (a soft cap, in as the foot lifts), so the leg is pulled, not torn
|
|
94
|
+
const reach = p => { const d = p.map((v, i) => v - root[i]), n = Math.hypot(...d), m = LEG - 0.02 * Math.log1p(Math.exp((LEG - n) / 0.02)); return n > 1e-9 ? root.map((v, i) => v + d[i] * (1 + (m / n - 1) * off)) : p; };
|
|
95
|
+
// the knee points along the body's forward pitched with it, square to the leg (no flip lying down); a pole is read
|
|
96
|
+
// in the yaw frame, tilted past 60 degrees of pitch (pose.js tiltFrame), so that tilt (lean) is taken back out of
|
|
97
|
+
// the knee poles (and half of it, face down, out of the foot pitch: the toes rest on the floor, not in it)
|
|
98
|
+
const tf = tiltFrame(0, pitch, 0), back0 = tf ? apply(tf.R, [0, 1, 0]) : [0, 1, 0], lean = Math.atan2(back0[2], back0[1]) * 180 / Math.PI;
|
|
99
|
+
const knee = apply(rx(-lean), [0, -Math.sin(pitch * Math.PI / 180), Math.max(0.05, Math.cos(pitch * Math.PI / 180))]);
|
|
100
|
+
const set = { root: r3(root), yaw: r1(yaw), pitch: r1(pitch), roll: 0,
|
|
101
|
+
foot_l: r3(foot("l", 0.11, 0.04 + 0.1 * flail * off - (back ? 0.1 : 0) * lying)), foot_r: r3(foot("r", -0.11, 0.04 - 0.1 * flail * off - (back ? 0.1 : 0) * lying)), fyaw_l: r1(yaw), fyaw_r: r1(yaw), fpitch_l: r1(back ? -50 * lying : -0.5 * lean), fpitch_r: r1(back ? -50 * lying : -0.5 * lean),
|
|
102
|
+
knee_l: r3([0.05, knee[1], knee[2]]), knee_r: r3([-0.05, knee[1], knee[2]]),
|
|
103
|
+
spine_pitch: r1(sign * 22 * whip * Math.min(1, speed / 4)), spine_yaw: 0, spine_roll: r1(10 * flail * (1 - lying)), head_pitch: r1(sign * 30 * whip * Math.min(1, speed / 4) + (back ? -8 : 8) * lying), head_roll: r1(12 * lying),
|
|
104
|
+
arm_rest: r1(72 - 22 * lying), aswing_l: r1(sign * 45 * flail + 8 * lying), aswing_r: r1(-sign * 35 * flail - 8 * lying), abend_l: r1(25 + 30 * flail * flail), abend_r: r1(30 + 25 * flail * flail) };
|
|
105
|
+
if (f === ctx.f0) Object.assign(set, { armw_l: 0, armw_r: 0, fingers_l: { relaxed: 1 }, fingers_r: { relaxed: 1 } });
|
|
106
|
+
keys.push({ f, set });
|
|
107
|
+
}
|
|
108
|
+
return { keys, end };
|
|
109
|
+
}
|
|
@@ -10,8 +10,10 @@ import { SKILL_INFO } from "./info.js";
|
|
|
10
10
|
import { standIdle, walkTo, reachGrip } from "./basic.js";
|
|
11
11
|
import { sitInSeat, exitCar } from "./car.js";
|
|
12
12
|
import { smokeCigarette } from "./smoke.js";
|
|
13
|
+
import { flip } from "./flip.js";
|
|
14
|
+
import { hitReaction } from "./impact.js";
|
|
13
15
|
|
|
14
|
-
const SKILLS = { standIdle, sitInSeat, exitCar, walkTo, reachGrip, smokeCigarette };
|
|
16
|
+
const SKILLS = { standIdle, sitInSeat, exitCar, walkTo, reachGrip, smokeCigarette, flip, hitReaction };
|
|
15
17
|
export const SKILL_NAMES = Object.keys(SKILLS);
|
|
16
18
|
const bad = msg => { throw new Error(`MOTION_SPEC_INVALID: ${msg}`); };
|
|
17
19
|
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
@@ -53,6 +53,16 @@ export const SKILL_INFO = {
|
|
|
53
53
|
adds: ["objects: door and handle hinges", "anchors handle, lthigh, rthigh", "props.grip", "fits, checks.contacts, checks.allow"],
|
|
54
54
|
example: { f0: 0, args: { car: "Car" } },
|
|
55
55
|
},
|
|
56
|
+
flip: {
|
|
57
|
+
args: { kind: { enum: ["back", "front", "cartwheel"], default: "back", description: "a standing back or front tuck, or a cartwheel" },
|
|
58
|
+
travel: num("back/front: m along the heading at landing (default back -0.25, front 0.4)"),
|
|
59
|
+
side: side("cartwheel: toward the character's l (default) or r"), heading: num("degrees (yaw); default the keyed yaw") },
|
|
60
|
+
needs: "a standing pose keyed before f0", frames: "back/front 58 (one turn, the hips 0.5 m up); cartwheel 68",
|
|
61
|
+
adds: ["keys every frame: back/front crouch, push-off, a ballistic tucked flight turning the whole body (pitch to -360 back, +360 front) and a landing that stands up",
|
|
62
|
+
"cartwheel: a side step, then the whole body rolls 360 like a wheel on the straddled legs and the arms overhead, the hands planted in turn",
|
|
63
|
+
"whole-body pitch and roll take full turns in any key (no range); past 60 deg the knee and elbow poles and the feet turn with the body"],
|
|
64
|
+
example: { f0: 10, args: { kind: "back" } },
|
|
65
|
+
},
|
|
56
66
|
smokeCigarette: {
|
|
57
67
|
args: { until: int("hold to this frame; default the last frame; under f0+264 the beats are paced to fit"),
|
|
58
68
|
kit: { type: "object", properties: { cig: str("the cigarette kit's rootId (object.createKit)"), lighter: str("the lighter kit's rootId"), exhale: str("the exhale kit's rootId (or an effect): it follows the mouth") },
|
|
@@ -62,4 +72,9 @@ export const SKILL_INFO = {
|
|
|
62
72
|
"checks contacts, orient, below, appear, mouthPoints"],
|
|
63
73
|
example: { f0: 0, args: {} },
|
|
64
74
|
},
|
|
75
|
+
hitReaction: {
|
|
76
|
+
args: { dir: xz("[x, z] the way the body is thrown"), from: str("or what hits it: object:<id> | person:<id> (thrown away from it)"),
|
|
77
|
+
speed: num("launch speed, m/s (a shove 2, a car at 30 km/h about 8)", { default: 4, exclusiveMinimum: 0 }), land: { enum: ["back", "front"], description: "default: back when hit from the front" } },
|
|
78
|
+
needs: "a pose; dir or from", frames: "~40", adds: ["keys: thrown to rest"], example: { f0: 10, args: { dir: [0, -1], speed: 3 } },
|
|
79
|
+
},
|
|
65
80
|
};
|
package/src/qa-label.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// How a QA result reads to a person. QA records one issue per frame and groups them by kind and subject (qa/index.js),
|
|
2
|
+
// so one goal missed on 321 frames is 321 issues but one problem: the user saw "QA 549" for two long groups. People read
|
|
3
|
+
// problems (the groups); qa.total stays the frame-level count the agent, repair and search work with.
|
|
4
|
+
import { ko } from "./locale.js";
|
|
5
|
+
|
|
6
|
+
/** Problems in a QA result: its groups (qa.problems from runMotionQa; an older result counts its groups). A failed result
|
|
7
|
+
* with neither, or with no groups, falls back to its frame-level count, so it never reads as clean. */
|
|
8
|
+
export function qaProblems(qa) {
|
|
9
|
+
const n = qa?.problems ?? (Array.isArray(qa?.groups) ? qa.groups.length : 0);
|
|
10
|
+
return n || (qa?.ok ? 0 : (qa?.total ?? 0));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** "QA clean" | "2 problems" ("QA 통과" | "문제 2개"). */
|
|
14
|
+
export function qaLabel(qa) {
|
|
15
|
+
const n = qaProblems(qa);
|
|
16
|
+
if (qa?.ok || !n) return ko("QA clean", "QA 통과");
|
|
17
|
+
return ko(`${n} problem${n === 1 ? "" : "s"}`, `문제 ${n}개`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A receipt's QA phrase: "clean" | "2 problems (321 frame issues)". */
|
|
21
|
+
export function qaSay(qa) {
|
|
22
|
+
if (qa?.ok) return "clean";
|
|
23
|
+
const n = qaProblems(qa), total = qa?.total ?? 0;
|
|
24
|
+
return `${n} problem${n === 1 ? "" : "s"} (${total} frame issue${total === 1 ? "" : "s"})`;
|
|
25
|
+
}
|
package/src/studio-actions.js
CHANGED
|
@@ -112,6 +112,10 @@ export const STUDIO_ACTIONS = freezeStudioData([
|
|
|
112
112
|
description: "Lay a shot's dolly rail through points (world floor x/z metres, in travel order) and put the shot's camera on it, like drawing the rail in the Top-View: the camera runs the rail at an even speed over the whole shot (or its authored rail-follow range), so a push-in, pull-out, orbit, track or crane is one call with 2-6 points (a line, or an arc through a few points; the rail is a smooth curve through them). height is the lens height in metres, one number or crane marks [{ t: 0..1 along the rail, height }] (default the follow height, 1.6). aim is what the camera holds: up to 4 character or object ids (their middle, frame by frame: a character's chest, an object's middle) or a world point { x, y, z }; without an aim it follows the scene's first character. aimHeight puts a cast aim at that world height instead of the chest, e.g. 0.9 to hold a whole standing body, 1.6 for faces. focalMm is the shot's lens (default the scene lens). It replaces any rail the shot had and keeps any height, aim or lens you leave out (null clears aim and lens), and it removes the camera keys the rail covers. Answers the rail's length, speed, height, aim and lens; check the framing with verify_result { targets, checks: [\"framing\"], range: the shot's frames } and a contact sheet. Read rails with inspect_studio { scope: \"shot\" }." },
|
|
113
113
|
{ id: "shot.clearCameraRail", label: "Delete camera rail", kind: "mutation", undoDomain: "shot", input: input(shotId),
|
|
114
114
|
description: "Delete a shot's camera rail; the shot's camera goes back to following its subject at the current distance." },
|
|
115
|
+
{ id: "shot.setCameraMode", label: "Set shot camera mode", kind: "mutation", undoDomain: "shot", input: input({ ...shotId, mode: { type: "string", enum: ["keys", "follow", "rail"] } }),
|
|
116
|
+
description: "Set how a shot's camera moves: keys plays the shot's camera keys (frame_shot frames them; one framing holds the whole shot), follow keeps the captured distance from the subject, rail rides the shot's rail (lay one first with shot.setCameraRail). Deleting a rail leaves follow; set keys to hold a framed camera instead." },
|
|
117
|
+
{ id: "shot.rename", label: "Rename shot", kind: "mutation", undoDomain: "shot", input: input({ ...shotId, name: { type: "string", maxLength: 240 } }),
|
|
118
|
+
description: "Rename a shot: its label on the timeline and in shot lists." },
|
|
115
119
|
{ id: "object.attach", label: "Attach to character", kind: "mutation", undoDomain: "objects",
|
|
116
120
|
input: input({ objectId: idSchema, ...characterId }, { bone: { type: "string", enum: STUDIO_ATTACH_BONES } }),
|
|
117
121
|
description: "Make a scene object ride a character, like dropping it on the character's Hierarchy row: on one bone (a cup in the right hand: bone \"rightHand\") or, with bone omitted, on the character's animated root so it travels with the body. The object keeps its place on screen: its position, rotation and scale are rewritten into the new frame (local to that bone while attached), and it leaves any group. Needs the character's rig on stage." },
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Shared by the browser and sidecar. No Node, React, renderer or provider imports.
|
|
2
2
|
import { STUDIO_ELEMENTS, isSettableElement } from "./studio-elements.js";
|
|
3
|
-
import { STUDIO_ERROR_CODES } from "./studio-error-codes.js";
|
|
3
|
+
import { STUDIO_ERROR_CODES, refusalCode } from "./studio-error-codes.js";
|
|
4
4
|
import { EFFECT_VALUE_SCHEMA } from "./effects/effect-record.js";
|
|
5
5
|
import { wrongType, unknownField, missingField, arrayRefusal, duplicateItem, stringRefusal, enumRefusal, numberRefusal, unionRefusal } from "./studio-schema-errors.js";
|
|
6
6
|
// Mirrors PREVIS_MODES (project.js) and POSTURES (scenes.js). Neither is imported:
|
|
@@ -30,7 +30,7 @@ export const STUDIO_TOOLS = Object.freeze(Object.keys(STUDIO_TOOL_LABELS));
|
|
|
30
30
|
* object→objects, shot→shot, stage→stage. */
|
|
31
31
|
export const STUDIO_PATCH_KINDS = Object.freeze(["character", "object", "shot", "stage"]);
|
|
32
32
|
export const STUDIO_PATCH_DOMAINS = Object.freeze({ character: "cast", object: "objects", shot: "shot", stage: "stage" });
|
|
33
|
-
export { STUDIO_ERROR_CODES };
|
|
33
|
+
export { STUDIO_ERROR_CODES, refusalCode };
|
|
34
34
|
export const STUDIO_VARIANTS = freezeStudioData({
|
|
35
35
|
selectionKinds: ["scene", "object", "character", "rig", "camera"], modes: ["scene", "pose", "camera", "motion"], shotModes: ["keys", "follow", "rail"],
|
|
36
36
|
framingSizes: ["extreme close-up", "close-up", "medium close-up", "medium shot", "medium-wide shot", "wide shot", "extreme wide shot"],
|
|
@@ -146,9 +146,9 @@ export function patchValueSchema(element) {
|
|
|
146
146
|
if (element.type === "color") return { ...text(32), pattern: "^#[0-9a-fA-F]{6}$" };
|
|
147
147
|
if (element.type === "image") return nullable(dataImage);
|
|
148
148
|
if (element.type === "vec3") {
|
|
149
|
-
const
|
|
150
|
-
if (
|
|
151
|
-
return object({ x: number(
|
|
149
|
+
const { min, max } = element; // the element's own range; a gizmo envelope only limits the UI drag
|
|
150
|
+
if (min && typeof min === "object" && max && typeof max === "object") {
|
|
151
|
+
return object({ x: number(min.x, max.x), y: number(min.y, max.y), z: number(min.z, max.z) });
|
|
152
152
|
}
|
|
153
153
|
return vec3;
|
|
154
154
|
}
|
|
@@ -160,10 +160,10 @@ export function patchValueSchema(element) {
|
|
|
160
160
|
* derived from the same declaration table as `patchValueSchema` so the two
|
|
161
161
|
* can never drift apart. */
|
|
162
162
|
export function buildPatchDescriptors(elements) {
|
|
163
|
-
return elements.filter(isSettableElement).map(({ path, type, min, max, enum: enumValues,
|
|
163
|
+
return elements.filter(isSettableElement).map(({ path, type, min, max, enum: enumValues, note }) => ({
|
|
164
164
|
path, type,
|
|
165
|
-
...(
|
|
166
|
-
...(
|
|
165
|
+
...(min !== undefined ? { min: min && typeof min === "object" ? { ...min } : min } : {}),
|
|
166
|
+
...(max !== undefined ? { max: max && typeof max === "object" ? { ...max } : max } : {}),
|
|
167
167
|
...(enumValues ? { enum: [...enumValues] } : {}),
|
|
168
168
|
...(note ? { note } : {}),
|
|
169
169
|
}));
|
|
@@ -12,7 +12,7 @@ import { buildStudioContext, physicsFingerprintInput, studioEntityCursor, valida
|
|
|
12
12
|
import { createStudioCommandJournal, framingChecks, localTransformReadback, placementChecks, sampledFramingResult, studioObjectCatalogue } from "./studio-agent-commands.js";
|
|
13
13
|
import { verifyInstalledTake } from "./studio-agent-motion.js"; import { objectRowBounds } from "./studio-entity-bounds.js"; import { exportFramingInto } from "./studio-export-framing.js";
|
|
14
14
|
import { shotGeometryFacts } from "./studio-geometry-facts.js"; import { KIT_CATALOGUE } from "./kits/index.js";
|
|
15
|
-
import { STUDIO_TOOL_FAMILIES, StudioProtocolError, validateReceipt, validateStudioCommand, validateStudioIdentity } from "./studio-agent-protocol.js";
|
|
15
|
+
import { STUDIO_TOOL_FAMILIES, StudioProtocolError, validateReceipt, validateStudioCommand, validateStudioIdentity, refusalCode } from "./studio-agent-protocol.js";
|
|
16
16
|
import { studioVisual, verifyLighting } from "./studio-motion-visual.js";
|
|
17
17
|
import { withEffectState } from "./effects/state.js"; import { hdrEnvironmentCatalogue } from "./stage-hdr-library.js";
|
|
18
18
|
|
|
@@ -165,7 +165,7 @@ export function createStudioAppBinding(ports) {
|
|
|
165
165
|
// the first 120 characters the protocol carries.
|
|
166
166
|
const words = [...String(error?.message ?? "").trim()];
|
|
167
167
|
return validateReceipt({ ok: false, commandId: request.commandId, host: request.host ?? request.binding?.host,
|
|
168
|
-
code: error
|
|
168
|
+
code: refusalCode(error), phase, affectedIds: [], expectedTargets: [], currentTargets: [], mutated: false,
|
|
169
169
|
preserved: { authoredState: "unchanged" }, recovery: { action: "inspect", retryAllowed: false },
|
|
170
170
|
...(words.length ? { message: words.length > 120 ? `${words.slice(0, 119).join("")}…` : words.join("") } : {}) });
|
|
171
171
|
}
|
|
@@ -4,5 +4,12 @@ export const STUDIO_ERROR_CODES = Object.freeze([
|
|
|
4
4
|
"INVALID_ARGUMENT", "INVALID_CONTEXT", "INVALID_IDENTITY", "INVALID_TURN_ID", "INVALID_SESSION_ID", "INVALID_RECEIPT", "INVALID_RANGE", "INVALID_REQUEST",
|
|
5
5
|
"UNKNOWN_TOOL", "UNKNOWN_VARIANT", "DUPLICATE_NAME", "CONTEXT_LIMIT", "CONTEXT_TOO_LARGE", "AMBIGUOUS_TARGET", "AMBIGUOUS_BASIS", "TARGET_NOT_READY", "TARGET_BUSY",
|
|
6
6
|
"STALE_TARGET", "STALE_SCENE", "STALE_ENVIRONMENT", "STALE_CURSOR", "CAPABILITY_MISSING", "LIVE_HUB_UNAVAILABLE", "AUTH_REQUIRED", "GENERATION_LIMIT", "RATE_LIMITED", "BACKEND_UNAVAILABLE",
|
|
7
|
-
"VERIFICATION_FAILED", "REPAIR_REGRESSED", "CANCELLED", "UNCERTAIN_APPLY", "UNDO_CONFLICT", "CONFIRMATION_REQUIRED", "TIMEOUT", "UNDO_EXPIRED",
|
|
7
|
+
"VERIFICATION_FAILED", "REPAIR_REGRESSED", "CANCELLED", "UNCERTAIN_APPLY", "UNDO_CONFLICT", "CONFIRMATION_REQUIRED", "TIMEOUT", "UNDO_EXPIRED", "INTERNAL_ERROR",
|
|
8
8
|
]);
|
|
9
|
+
/**
|
|
10
|
+
* A refusal's code. A protocol error carries its own; a JavaScript runtime error (TypeError, ReferenceError, RangeError,
|
|
11
|
+
* SyntaxError) is a fault in the editor, never the caller's input, so it reads INTERNAL_ERROR; any other plain Error is
|
|
12
|
+
* the validation message of a command and stays INVALID_ARGUMENT.
|
|
13
|
+
*/
|
|
14
|
+
export const refusalCode = error => error?.code
|
|
15
|
+
?? (["TypeError", "ReferenceError", "RangeError", "SyntaxError"].includes(error?.name) ? "INTERNAL_ERROR" : "INVALID_ARGUMENT");
|