cozyclay 3.1.5 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +76 -0
- package/bin/agent/agent-runner.mjs +9 -4
- package/bin/agent/openai-compatible-provider.mjs +24 -1
- package/dist/app/index.html +5 -5
- package/dist/assets/TaskBoard-BUjjuvm5.js +1 -0
- package/dist/assets/{app-CEP7jzXB.css → app-Dige7q3X.css} +1 -1
- package/dist/assets/app-DxoI941o.js +5144 -0
- package/dist/assets/compile-worker-DRWRa9pr.js +6 -0
- package/dist/assets/{motion3-compile-Du7UXrWn.js → motion3-compile-B4SX4TGx.js} +1 -1
- package/dist/assets/scene-asset-sync-LeI3eSnO.js +13 -0
- package/dist/assets/task-board-5PKn7ZaA.js +15 -0
- package/dist/assets/{workflow-DJwOUzqJ.js → workflow-Dg9jCKOq.js} +3 -3
- package/dist/cozyclay-package.json +1 -1
- package/dist/index.html +1 -1
- package/dist/workflow/index.html +3 -3
- package/mcp/project-camera.mjs +43 -0
- package/package.json +3 -1
- package/src/App.jsx +5 -5
- package/src/commands/index.js +2 -1
- package/src/commands/physics.js +21 -0
- package/src/domains/motion.js +7 -9
- package/src/motion/center-of-mass.js +5 -23
- package/src/motion/contact-project.js +143 -0
- package/src/motion/fix-collisions.js +1 -1
- package/src/motion/ik-drag.js +93 -1
- package/src/motion/timeline.css +12 -0
- package/src/motion/timeline.jsx +2 -2
- package/src/motion3/compile-job.js +3 -2
- package/src/motion3/compiler/compile.js +3 -3
- package/src/motion3/compiler/spring.js +95 -0
- package/src/motion3/compiler/validate-objects.js +18 -2
- package/src/motion3/compiler/validate-parts.js +1 -0
- package/src/motion3/compiler/vocab.js +1 -1
- package/src/motion3/fix/index.js +8 -4
- package/src/motion3/fix/person.js +110 -0
- package/src/motion3/free-motion.js +49 -11
- package/src/motion3/object-pose.js +44 -2
- package/src/motion3/people.js +16 -1
- package/src/motion3/physics-frames.js +72 -0
- package/src/motion3/physics-trail.js +45 -0
- package/src/motion3/physics-view.js +79 -0
- package/src/motion3/pipeline.js +5 -1
- package/src/motion3/qa/ballistic.js +67 -0
- package/src/motion3/qa/com.js +140 -0
- package/src/motion3/qa/ground.js +10 -3
- package/src/motion3/qa/hints-contact.js +9 -7
- package/src/motion3/qa/hints-motion.js +38 -1
- package/src/motion3/qa/key-checks.js +5 -4
- package/src/motion3/qa/patch.js +28 -3
- package/src/motion3/qa/people-checks.js +1 -1
- package/src/motion3/qa/spring-pivot.js +23 -0
- package/src/motion3/qa/style.js +13 -30
- package/src/motion3/schema/fields.js +4 -2
- package/src/motion3/schema/topics.js +15 -11
- package/src/motion3/schema/types.js +13 -9
- package/src/physics-overlay-settings.js +41 -0
- package/src/physics-overlay.js +152 -0
- package/src/physics-overlay.jsx +54 -0
- package/src/physics-toggle.jsx +20 -0
- package/src/posestudio.jsx +1 -1
- package/tools/run-tests.mjs +9 -16
- package/tools/verify-package-tarball.mjs +29 -1
- package/dist/assets/TaskBoard-DwXhsJXz.js +0 -1
- package/dist/assets/app-CTKcyPM-.js +0 -5144
- package/dist/assets/compile-worker-or7djI2v.js +0 -6
- package/dist/assets/scene-asset-sync-t45pWCyq.js +0 -13
- package/dist/assets/task-board-CkLZGPkv.js +0 -15
package/src/motion3/qa/style.js
CHANGED
|
@@ -8,9 +8,10 @@
|
|
|
8
8
|
// QA error, and so is a FLOAT: the body off the ground without falling (floatChecks). A pop is a
|
|
9
9
|
// spike, not a large value: a hard but continuous acceleration (a swing's launch) is style.
|
|
10
10
|
// See docs/motion-api-v3.1.md C13.
|
|
11
|
-
import {
|
|
11
|
+
import { sub, len, dot, angle } from "../compiler/math.js";
|
|
12
12
|
import { CAPSULES, segPoints, sdBox } from "./geometry.js";
|
|
13
13
|
import { groundSole, groundY } from "./ground.js";
|
|
14
|
+
import { balanceAt } from "./com.js";
|
|
14
15
|
|
|
15
16
|
const SPEED_JOINTS = ["Hips", "Head", "LeftHand", "RightHand", "LeftFoot", "RightFoot", "LeftLeg", "RightLeg", "LeftForeArm", "RightForeArm"];
|
|
16
17
|
const CORE = new Set(["Hips", "Head"]);
|
|
@@ -81,28 +82,12 @@ function supported(out, f, supports) {
|
|
|
81
82
|
}
|
|
82
83
|
|
|
83
84
|
/**
|
|
84
|
-
* Per frame: null where a declared support carries the body, "air" with
|
|
85
|
-
*
|
|
86
|
-
* the
|
|
87
|
-
*
|
|
85
|
+
* Per frame: null where a declared support carries the body, else balanceAt (com.js): "air" with
|
|
86
|
+
* no foot on the ground, or { margin, ... } with margin in metres: how far the mass-weighted
|
|
87
|
+
* centre of mass sits inside the support polygon of the feet on the ground, the floor or an
|
|
88
|
+
* object's top (ground.js; < 0: outside it).
|
|
88
89
|
*/
|
|
89
|
-
|
|
90
|
-
const sk = out.ctx.sk, I = sk.index, F = out.frames;
|
|
91
|
-
return F.map(({ s }, f) => {
|
|
92
|
-
if (supported(out, f, supports)) return null;
|
|
93
|
-
const { P } = s;
|
|
94
|
-
const moving = f > 0 && f < F.length - 1 && Math.hypot(F[f + 1].s.P[I.Hips][0] - F[f - 1].s.P[I.Hips][0], F[f + 1].s.P[I.Hips][2] - F[f - 1].s.P[I.Hips][2]) / 2 > 0.006;
|
|
95
|
-
const feet = [];
|
|
96
|
-
for (const S of ["Left", "Right"]) {
|
|
97
|
-
const sp = groundSole(out, f, S);
|
|
98
|
-
if (moving || Math.min(sp.heel[1], sp.ball[1]) < 0.02) feet.push(sp.heel, sp.ball, sp.tip);
|
|
99
|
-
}
|
|
100
|
-
if (!feet.length) return "air";
|
|
101
|
-
const com = scale(["Hips", "Spine1", "Spine2", "Head"].reduce((acc, j) => add(acc, P[I[j]]), [0, 0, 0]), 0.25);
|
|
102
|
-
const xs = feet.map(p => p[0]), zs = feet.map(p => p[2]);
|
|
103
|
-
return Math.min(com[0] - Math.min(...xs), Math.max(...xs) - com[0], com[2] - Math.min(...zs), Math.max(...zs) - com[2]);
|
|
104
|
-
});
|
|
105
|
-
}
|
|
90
|
+
export const balanceFrames = (out, supports = []) => out.frames.map((_, f) => (supported(out, f, supports) ? null : balanceAt(out, f)));
|
|
106
91
|
|
|
107
92
|
/** True when a hand rests on the ground at frame f: its wrist or knuckles within HAND_DOWN of the ground under them. */
|
|
108
93
|
const HAND_DOWN = 0.06;
|
|
@@ -112,7 +97,7 @@ function handDown(out, f) {
|
|
|
112
97
|
}
|
|
113
98
|
|
|
114
99
|
/** Runs [a, b] (indexes of out.frames) with both soles 2 cm or more above the ground (ground.js), no hand on it and no declared support. */
|
|
115
|
-
function airRuns(out, supports = []) {
|
|
100
|
+
export function airRuns(out, supports = []) {
|
|
116
101
|
const n = out.frames.length, runs = [];
|
|
117
102
|
const air = out.frames.map((_, f) => !supported(out, f, supports)
|
|
118
103
|
&& ["Left", "Right"].every(S => { const sp = groundSole(out, f, S); return Math.min(sp.heel[1], sp.ball[1]) >= 0.02; }) && !handDown(out, f));
|
|
@@ -169,12 +154,10 @@ export function styleChecks(sk, out, checks, L) {
|
|
|
169
154
|
if (deg > L.knee) push("KNEE", `${bend[0] === "knee_l" ? "Left" : "Right"} knee bent ${deg.toFixed(0)}deg`, f, deg, L.knee);
|
|
170
155
|
}
|
|
171
156
|
});
|
|
172
|
-
if (on("balance"))
|
|
173
|
-
if (
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
push("BALANCE", `centre of mass ${com[0].toFixed(2)},${com[2].toFixed(2)} outside the feet`, f, -m, L.balance);
|
|
177
|
-
}
|
|
157
|
+
if (on("balance")) balanceFrames(out, checks.supports).forEach((b, f) => {
|
|
158
|
+
if (b === "air") push("BALANCE", "no foot on the ground", f, 0, 0);
|
|
159
|
+
// one subject whatever the direction: a lean that swings from ahead to left stays one group; the hint says how far and which way
|
|
160
|
+
else if (b && -b.margin > L.balance) push("BALANCE", "centre of mass outside the feet", f, -b.margin, L.balance);
|
|
178
161
|
});
|
|
179
162
|
return issues;
|
|
180
163
|
}
|
|
@@ -192,7 +175,7 @@ export function observeMotion(sk, out, checks = {}) {
|
|
|
192
175
|
return [Math.round(arr[at] * k * 100) / 100, at + f0];
|
|
193
176
|
};
|
|
194
177
|
const peaks = Object.fromEntries(kinematics(sk, out).map(({ j, v, a }) => [j, { speed: peak(v, fps), accel: peak(a, fps * fps) }]).filter(([, p]) => p.speed[0] >= STILL));
|
|
195
|
-
const margins =
|
|
178
|
+
const margins = balanceFrames(out, checks.supports).map(b => (b && b !== "air" ? b.margin : b)), span = test => {
|
|
196
179
|
const fs = margins.map((m, f) => (test(m) ? f + f0 : -1)).filter(f => f >= 0);
|
|
197
180
|
return fs.length ? [fs[0], fs[fs.length - 1]] : null;
|
|
198
181
|
}, air = airRuns(out, checks.supports).slice(0, 4).map(([a, b]) => [a + f0, b + f0]);
|
|
@@ -15,8 +15,10 @@ export const ANCHOR_FIELDS = { world: ["p"], body: ["p"], bone: ["bone", "offset
|
|
|
15
15
|
export const PROP_FIELDS = ["hand", "origin", "points"];
|
|
16
16
|
export const FIT_FIELDS = ["side", "anchor", "effector", "offset", "orient"];
|
|
17
17
|
/** spec.objects[id]: a hinge or a carry (a body or a group id), see C3; carry keys: C14; a switch (any record, C15) moves nothing; a follow (any record, C16) rides a point of the body; a free entry (C17) moves by keys and falls. */
|
|
18
|
-
export const OBJECT_FIELDS = { hinge: ["pivot", "axis", "channel"], carry: ["prop", "at", "along", "visible", "level", "grab", "release", "keys", "fall", "grasp", "mass"], switch: ["visible", "level"],
|
|
19
|
-
follow: ["follow", "offset", "aim", "visible", "level"], free: ["keys", "fall", "visible", "level"] };
|
|
18
|
+
export const OBJECT_FIELDS = { hinge: ["pivot", "axis", "channel"], carry: ["prop", "at", "along", "visible", "level", "grab", "release", "keys", "fall", "grasp", "mass", "spring"], switch: ["visible", "level"],
|
|
19
|
+
follow: ["follow", "offset", "aim", "visible", "level"], free: ["keys", "fall", "visible", "level", "spring"] };
|
|
20
|
+
/** spec.objects[id].spring (a carry or a free entry): a damped swing behind its anchor's acceleration, compiler/spring.js. */
|
|
21
|
+
export const SPRING_FIELDS = ["length", "stiffness", "damping"];
|
|
20
22
|
/** One spec.objects[id].fall entry (C17): the frame it starts to fall, what it lands on, how much it bounces, whether it rolls to rest, the velocity it leaves with. */
|
|
21
23
|
export const FALL_FIELDS = ["at", "onto", "bounce", "tumble", "v"];
|
|
22
24
|
/** One spec.objects[id].keys entry (C14): the held object's world pose at frame f; aim (+ roll) in place of rot (C16). */
|
|
@@ -55,7 +55,7 @@ const CARRY = { props: EXAMPLES.objects.props, objects: { cup: EXAMPLES.objects.
|
|
|
55
55
|
export const NOTES = {
|
|
56
56
|
essentials: ["types: ? optional, =v default, a..b range, A | B one of, [key]: T map, a|b?: T both, x_l|r each side, (text) meaning.",
|
|
57
57
|
`A carried object: ${literal(CARRY)}. ${NO_JUMP}`,
|
|
58
|
-
"motion.describe { topic } or { topic: 'skills', name }:
|
|
58
|
+
"motion.describe { topic } or { topic: 'skills', name }: more."],
|
|
59
59
|
spec: [UNITS,
|
|
60
60
|
"frames: an integer, or \"auto\" (the last skill end, generator end or key f, plus 1); the stored spec keeps the number.",
|
|
61
61
|
"Any number may be { range: [lo, hi] } (int: true for frames): the compiler searches it, QA 0 first, at most search.budget compiles.",
|
|
@@ -71,15 +71,18 @@ export const NOTES = {
|
|
|
71
71
|
"fits: the hand target moves so the effector (a prop point or the mean of joints), not the wrist, lands on the anchor.",
|
|
72
72
|
"A fit on a scene body also turns the hand about the forearm into a grip: a thin body (a can, a bar, a handle, a neck) across the palm, else thumb up; never past a comfortable forearm turn. A wrist you turn (wtwist/wflex) wins; fit.orient: false (reachGrip args.orient: false) turns it off.",
|
|
73
73
|
"reachGrip adds props.<anchor>Grip and its fit. Carry a scene object with objects.<id>.prop (topic objects).",
|
|
74
|
-
"Do not pin a carried thing on a knuckle point: without at/along it rests in the palm and the fingers close on it (fix.grasp). objects.<id>.grasp names how: power, pinch, hook, palm, spherical (a ball). objects.<id>.mass (kg) weighs on the arm; past one hand QA says LOAD and patches in the other hand."
|
|
75
|
-
|
|
74
|
+
"Do not pin a carried thing on a knuckle point: without at/along it rests in the palm and the fingers close on it (fix.grasp). objects.<id>.grasp names how: power, pinch, hook, palm, spherical (a ball). objects.<id>.mass (kg) weighs on the arm; past one hand QA says LOAD and patches in the other hand.",
|
|
75
|
+
"objects.<id>.spring { length, stiffness, damping } (a carry, keyed included, or a free entry) swings it a beat behind the hand or its keys, then settles: a bag, a lantern. "
|
|
76
|
+
+ "length (m, default half its longest side) is the string or rod; stiffness (1/s^2) defaults to 9.81 / length, a real pendulum; damping (default 0.5; 1: no overshoot) how soon it stops. "
|
|
77
|
+
+ "A natural swing: length 0.4..0.6, damping 0.5..0.7. It turns, at most 60 deg about its base, never moves: put the base at the hand with at/along; a still hand leaves it still."],
|
|
78
|
+
objects: ["A hinge turns the object (a body or a group) about pivot/axis by the angle channel your keys set.",
|
|
76
79
|
"A carry follows the prop's hand: before grab the scene pose, grab..release in the hand, after release where it was let go, riding a body under it that keys or a hinge move.",
|
|
77
|
-
"No
|
|
80
|
+
"No at/along: it rests in the palm, fingers closed on it.",
|
|
78
81
|
"A group (Empty) id moves all its parts; at/along pin a group's root to the palm too (an object.createKit kit). A carried object is an obstacle for the body and the scene.",
|
|
79
|
-
"A switch { visible?, level? }
|
|
82
|
+
"A switch { visible?, level? } moves nothing: it shows or hides any record (a part, a light, an effect) and sets its level, 0..4 light, glow and effect strength "
|
|
80
83
|
+ "(an unkeyed level reads 1). Hiding a group hides its parts. A carry takes level too.",
|
|
81
|
-
"keys pose a carried object in the world while in hand: pos its base (a group: its root), rot a quaternion
|
|
82
|
-
+ "or aim [x, y, z] where its +Y points (a bat's barrel) plus roll degrees about it (none: the roll
|
|
84
|
+
"keys pose a carried object in the world while in hand: pos its base (a group: its root), rot a quaternion, "
|
|
85
|
+
+ "or aim [x, y, z] where its +Y points (a bat's barrel) plus roll degrees about it (none: the easiest roll). "
|
|
83
86
|
+ `The object leads and the arm follows (elbow, forearm, then the wrist within ${Object.entries(WRIST_LIMITS).map(([k, [lo, hi]]) => `${k} ${lo}..${hi}`).join(", ")} deg; dev + toward the thumb); `
|
|
84
87
|
+ "a left hand on a prop anchor of it turns with it; "
|
|
85
88
|
+ `the hand eases in and out over ${KEY_BLEND} frames. REACH "<id>: n cm off its keys" is what the hand could not reach; `
|
|
@@ -88,12 +91,12 @@ export const NOTES = {
|
|
|
88
91
|
+ "to rest on a stable face, one small bounce (0: none). A carry falls before grab or after release.",
|
|
89
92
|
"A follow { follow: <ref>, offset?, aim?, visible?, level? } puts any record (an effect, a light, a part) on a body point every frame: "
|
|
90
93
|
+ "aim sets its +Y (an effect emits along it): up (default), face (forward), a direction or a ref (toward it); offset is metres along its axes after aim. It is no obstacle.",
|
|
91
|
-
"The editor's motion.keyObject
|
|
94
|
+
"The editor's motion.keyObject keys a drag (a hinge: its channel)."],
|
|
92
95
|
checks: ["Each entry adds QA groups: contacts CONTACT, orient ORIENT, below BELOW, clearance CLEARANCE, appear APPEAR; supports mark where a part rests on an object (no FLOAT, no balance there); a foot on an object's top is on the ground without one.",
|
|
93
96
|
"when: a weight channel (active above 0.99) or [f0, f1]; clearance uses frames.", "Check points take joint:, prop: and anchor: only; goals and probes take every ref (topic refs).",
|
|
94
97
|
"allow excuses a body part inside an object up to depth; limits override the QA limits. Unnamed entries are named by place, contacts[0].",
|
|
95
98
|
"Other characters with an installed take are bodies too: PERSON \"<part> x person:<id>\" is a part inside that person; a touch you mean (a push, a hug) is allow { part, object: \"person:<id>\", depth }.",
|
|
96
|
-
"Speed, acceleration, knee bend and balance are style
|
|
99
|
+
"Speed, acceleration, knee bend and balance are style (output.observations), not judged. Naming one in limits checks it: SPEED, ACC, KNEE, BALANCE (mass-weighted centre of mass outside the support polygon of the feet, +8 cm margin). observations.near: each hand's and foot's nearest object and gap (< 0 inside), measured, not judged.",
|
|
97
100
|
"motion.qa { checks } replaces every check for a re-measure."],
|
|
98
101
|
goals: ["Exactly one of near (distance <= tol), above (a.y - b.y >= by), moved (an object's base moved at least by from the scene pose; any other point ref, e.g. prop:bat.tip, at least by from where it is at at[0], at: [f0, f1]), still (stays within tol, default 0.01 m, of where moved measures from) or speed (the point reaches by m/s).",
|
|
99
102
|
"at is an absolute frame or [f0, f1]; mode all (every frame; the default) or any (one frame; speed's default). A failed goal is a GOAL group first in qa.groups and makes qa.ok false.",
|
|
@@ -111,7 +114,8 @@ export const NOTES = {
|
|
|
111
114
|
"object:<id> takes a body or a group (the box around its parts); in a distance a bare object:<id> means its surface.",
|
|
112
115
|
"Goals and probes take every kind; spec.checks keep joint:, prop: and anchor:."],
|
|
113
116
|
fix: [`spec.fix keys: ${FIX_KEYS.join(", ")}; false turns all off. output.fixed reports what each one changed.`,
|
|
114
|
-
"reach, slide, self and
|
|
117
|
+
"reach, slide, self, stance and person are on by default; penetration and retime are opt-in.",
|
|
115
118
|
"reach also brings the body to a hand on a world or scene-object anchor the straight arm misses: a lean, a hip shift, lower hips, the feet kept (fixed.reachBody lists each move); farther, place the body. retime is kept only when QA is no worse with it.",
|
|
116
|
-
"self keeps the arms out of the body on its fitted skin (QA BODY measures the same skin): wrist targets inside it go onto it, elbows turn about the shoulder-wrist line, a hand a little inside is pushed out. A fit's target (the mouth) is never moved; what is left is BODY, with checks.allow { part, object: <body part>, depth } for a contact meant to touch (a hand on a knee)."
|
|
119
|
+
"self keeps the arms out of the body on its fitted skin (QA BODY measures the same skin): wrist targets inside it go onto it, elbows turn about the shoulder-wrist line, a hand a little inside is pushed out. A fit's target (the mouth) is never moved; what is left is BODY, with checks.allow { part, object: <body part>, depth } for a contact meant to touch (a hand on a knee).",
|
|
120
|
+
"person keeps the arms out of the other characters (QA PERSON): the wrist target moves out back the side the hand came from, however deep; an FK arm is left (fixed.person.skipped). A touch you mean: checks.allow { part, object: \"person:<id>\", depth }."],
|
|
117
121
|
};
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { REF_KINDS, OBJECT_KEY_FIELDS } from "./fields.js";
|
|
6
6
|
import { CONVENTIONS, GRASPS } from "../compiler/vocab.js";
|
|
7
7
|
import { QA_LIMITS } from "../qa/checks.js";
|
|
8
|
+
import { SPRING } from "../compiler/spring.js";
|
|
8
9
|
import { CAPSULES } from "../qa/geometry.js";
|
|
9
10
|
|
|
10
11
|
const def = (d, extra) => ({ ...extra, ...(d ? { description: d } : {}) });
|
|
@@ -83,23 +84,25 @@ export const FIT_TYPES = {
|
|
|
83
84
|
offset: ref("vec3", "world offset, m"), orient: { type: "boolean", description: "false: no grip turn" },
|
|
84
85
|
};
|
|
85
86
|
|
|
86
|
-
const OBJECT_KEY_TYPES = { f: int(), pos: ref("vec3"), rot: { type: "array", items: { type: "number" }, minItems: 4, maxItems: 4, description: "quat
|
|
87
|
+
const OBJECT_KEY_TYPES = { f: int(), pos: ref("vec3"), rot: { type: "array", items: { type: "number" }, minItems: 4, maxItems: 4, description: "quat xyzw" },
|
|
87
88
|
aim: ref("vec3", "or rot: +Y dir"), roll: num("deg about aim"), lean: num("aim tilt", { minimum: 0, maximum: 45 }) };
|
|
88
89
|
|
|
89
90
|
const FALL = { type: "object", properties: { at: int(), onto: str(), bounce: num(null, { default: 0.25, minimum: 0, maximum: 0.6 }), tumble: { type: "boolean" },
|
|
90
91
|
v: { oneOf: [{ $ref: "#/$defs/vec3" }, { const: "hand" }], description: "m/s" } }, required: ["at"], additionalProperties: false };
|
|
91
92
|
/** A free entry's keys and fall (C17) are a carry's, at any frame: said once, not twice. */
|
|
92
|
-
const AS_CARRY = { description: "as carry
|
|
93
|
-
export const FREE_TYPES = { keys: AS_CARRY, fall: AS_CARRY };
|
|
93
|
+
const AS_CARRY = { description: "as carry, any frame" };
|
|
94
|
+
export const FREE_TYPES = { keys: AS_CARRY, fall: AS_CARRY, spring: AS_CARRY };
|
|
95
|
+
const SPRING_TYPE = { type: "object", properties: Object.fromEntries(Object.entries(SPRING).map(([k, r]) => [k, num(null, { default: r.default, minimum: r.min, maximum: r.max })])),
|
|
96
|
+
additionalProperties: false };
|
|
94
97
|
|
|
95
98
|
export const OBJECT_TYPES = {
|
|
96
|
-
pivot: ref("vec3", "world
|
|
97
|
-
prop: str("spec.props name
|
|
98
|
-
visible: str("channel: shown > 0.5"), level: str("channel:
|
|
99
|
+
pivot: ref("vec3", "world"), axis: ref("vec3", "default +Y"), channel: str("angle, deg"),
|
|
100
|
+
prop: str("spec.props name"), at: ref("vec3", "palm offset [d, l, n]"), along: ref("vec3", "+Y on palm"),
|
|
101
|
+
visible: str("channel: shown > 0.5"), level: str("channel: 0..4"), grab: int("hand takes it", { default: 0 }), release: int("let go, else end"),
|
|
99
102
|
keys: { type: "array", items: { type: "object", properties: Object.fromEntries(OBJECT_KEY_FIELDS.map(f => [f, OBJECT_KEY_TYPES[f]])), required: ["f", "pos"], additionalProperties: false },
|
|
100
|
-
description: "world pose
|
|
101
|
-
fall: { ...FALL, description: "or a list
|
|
102
|
-
grasp: { enum: GRASPS }, mass: num("kg", { minimum: 0.01, maximum: 60 }),
|
|
103
|
+
description: "world pose; hand follows" },
|
|
104
|
+
fall: { ...FALL, description: "or a list" },
|
|
105
|
+
grasp: { enum: GRASPS }, mass: num("kg", { minimum: 0.01, maximum: 60 }), spring: SPRING_TYPE,
|
|
103
106
|
follow: ref("ref"), offset: ref("vec3"), aim: { oneOf: [{ enum: ["face", "up"] }, { $ref: "#/$defs/vec3" }, { $ref: "#/$defs/ref" }], default: "up" },
|
|
104
107
|
};
|
|
105
108
|
|
|
@@ -144,6 +147,7 @@ export const FIX_TYPES = {
|
|
|
144
147
|
retime: bool("speed spikes borrow frames from the neighbouring keys", { default: false }),
|
|
145
148
|
stance: bool("a standIdle stance in a scene body slides out of it (observations.placed)", { default: true }),
|
|
146
149
|
grasp: bool("fingers close on what the hand holds; it rests in the hand", { default: true }),
|
|
150
|
+
person: bool("arms kept out of the other characters (QA PERSON)", { default: true }),
|
|
147
151
|
};
|
|
148
152
|
|
|
149
153
|
export const REF_DOCS = {
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Which parts of the physics overlay (physics-overlay.js) the viewer wants: the centre of mass, the support
|
|
2
|
+
// polygon, the air runs' arcs and the spring objects' trails. Off by default; a viewer setting like the onion
|
|
3
|
+
// skin's (view-ghosts.js): never saved in the scene, never undone, gone with the session. motion.physicsOverlay
|
|
4
|
+
// (commands/physics.js) and the motion controls' Physics box (physics-toggle.jsx) both set it here.
|
|
5
|
+
import { useSyncExternalStore } from "react";
|
|
6
|
+
|
|
7
|
+
const PHYSICS_PARTS = Object.freeze(["com", "support", "arc", "trail"]);
|
|
8
|
+
const OFF = Object.freeze({ com: false, support: false, arc: false, trail: false });
|
|
9
|
+
|
|
10
|
+
let settings = OFF;
|
|
11
|
+
const listeners = new Set();
|
|
12
|
+
|
|
13
|
+
/** The current setting { com, support, arc, trail } (booleans). */
|
|
14
|
+
export const physicsOverlaySettings = () => settings;
|
|
15
|
+
/** True when any part is on. */
|
|
16
|
+
export const physicsOverlayOn = (value = settings) => PHYSICS_PARTS.some(part => value[part]);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Apply args { all?, com?, support?, arc?, trail? }: `all` sets every part first, then each named part is set;
|
|
20
|
+
* no field at all turns every part on. Answers the new setting.
|
|
21
|
+
*/
|
|
22
|
+
export function setPhysicsOverlay(args = {}) {
|
|
23
|
+
const named = PHYSICS_PARTS.filter(part => typeof args[part] === "boolean");
|
|
24
|
+
const base = typeof args.all === "boolean" ? Object.fromEntries(PHYSICS_PARTS.map(part => [part, args.all])) : named.length ? settings : { com: true, support: true, arc: true, trail: true };
|
|
25
|
+
const next = Object.freeze({ ...base, ...Object.fromEntries(named.map(part => [part, args[part]])) });
|
|
26
|
+
if (PHYSICS_PARTS.every(part => next[part] === settings[part])) return settings;
|
|
27
|
+
settings = next;
|
|
28
|
+
for (const listener of [...listeners]) listener();
|
|
29
|
+
return settings;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** "Physics overlay: centre of mass, support polygon on; arcs, trails off." */
|
|
33
|
+
export function describePhysicsOverlay(value = settings) {
|
|
34
|
+
const words = { com: "centre of mass", support: "support polygon", arc: "air arcs", trail: "spring trails" };
|
|
35
|
+
const on = PHYSICS_PARTS.filter(part => value[part]).map(part => words[part]), off = PHYSICS_PARTS.filter(part => !value[part]).map(part => words[part]);
|
|
36
|
+
if (!on.length) return "Physics overlay off.";
|
|
37
|
+
return `Physics overlay: ${on.join(", ")} on${off.length ? `; ${off.join(", ")} off` : ""}.`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const subscribe = listener => { listeners.add(listener); return () => listeners.delete(listener); };
|
|
41
|
+
export const usePhysicsOverlaySettings = () => useSyncExternalStore(subscribe, physicsOverlaySettings, physicsOverlaySettings);
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// The physics overlay as three.js objects (motion3/physics-view.js and physics-trail.js give the numbers): the
|
|
2
|
+
// centre of mass as a 4 cm ball with a line down to the ground, the support polygon on the ground, per air run the
|
|
3
|
+
// hips path (white, dashed) against the gravity parabola (orange), and each spring object's tip trail. Lines are
|
|
4
|
+
// thin tubes of a fixed width in metres, so a capture or an export at any size draws them as the viewport does. Every
|
|
5
|
+
// node takes no ray and draws over the body (the centre of mass sits inside it). The body's marks are computed where
|
|
6
|
+
// the take was compiled and ride the character's placement; the trails are world space, as the object tracks are.
|
|
7
|
+
// The viewport (physics-overlay.jsx) and the export path (syncPhysicsOverlays, App's applyExportFrame) pose it for a
|
|
8
|
+
// frame the same way, so scrub, playback and export draw the same overlay.
|
|
9
|
+
import * as THREE from "three";
|
|
10
|
+
import { comAt, supportAt, airArcs } from "./motion3/physics-view.js";
|
|
11
|
+
import { tipTrails, TRAIL_FRAMES } from "./motion3/physics-trail.js";
|
|
12
|
+
|
|
13
|
+
const PHYSICS_COLORS = Object.freeze({ inside: "#2ee66b", outside: "#ff2d2d", air: "#9aa0a6", supported: "#9aa0a6", support: "#ffd84a", path: "#ffffff", fit: "#ff8a1f", trail: "#3cc8ff" });
|
|
14
|
+
const ORDER = 70, LINE = 0.006, DOT = 0.011, COM_RADIUS = 0.02, LIFT = 0.004;
|
|
15
|
+
const noRay = () => {};
|
|
16
|
+
const UP = new THREE.Vector3(0, 1, 0), a3 = new THREE.Vector3(), b3 = new THREE.Vector3(), d3 = new THREE.Vector3(), m4 = new THREE.Matrix4(), q4 = new THREE.Quaternion(), s3 = new THREE.Vector3();
|
|
17
|
+
const tube = new THREE.CylinderGeometry(1, 1, 1, 8, 1, false), dot = new THREE.SphereGeometry(1, 12, 8);
|
|
18
|
+
|
|
19
|
+
function claim(node, layer) {
|
|
20
|
+
node.traverse((n) => { n.layers.set(layer); n.raycast = noRay; n.castShadow = n.receiveShadow = false; n.renderOrder = ORDER; n.frustumCulled = false; });
|
|
21
|
+
return node;
|
|
22
|
+
}
|
|
23
|
+
const material = (color) => new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.95, depthTest: false, depthWrite: false, toneMapped: false, fog: false });
|
|
24
|
+
|
|
25
|
+
/** A polyline drawn as tube segments (one instance each); dashed draws every other one. */
|
|
26
|
+
class Tubes {
|
|
27
|
+
constructor(color, capacity, radius = LINE) {
|
|
28
|
+
this.mesh = new THREE.InstancedMesh(tube, material(color), capacity);
|
|
29
|
+
this.mesh.count = 0;
|
|
30
|
+
this.radius = radius;
|
|
31
|
+
}
|
|
32
|
+
set(points, { closed = false, dashed = false } = {}) {
|
|
33
|
+
const n = points.length, segments = closed && n > 2 ? n : n - 1;
|
|
34
|
+
let count = 0;
|
|
35
|
+
for (let i = 0; i < segments && count < this.mesh.instanceMatrix.count; i++) {
|
|
36
|
+
if (dashed && i % 2) continue;
|
|
37
|
+
a3.fromArray(points[i]); b3.fromArray(points[(i + 1) % n]);
|
|
38
|
+
const length = d3.subVectors(b3, a3).length();
|
|
39
|
+
if (length < 1e-6) continue;
|
|
40
|
+
q4.setFromUnitVectors(UP, d3.divideScalar(length));
|
|
41
|
+
m4.compose(a3.add(b3).multiplyScalar(0.5), q4, s3.set(this.radius, length, this.radius));
|
|
42
|
+
this.mesh.setMatrixAt(count++, m4);
|
|
43
|
+
}
|
|
44
|
+
this.mesh.count = count;
|
|
45
|
+
this.mesh.instanceMatrix.needsUpdate = true;
|
|
46
|
+
}
|
|
47
|
+
dispose() { this.mesh.material.dispose(); this.mesh.dispose(); }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A dot per point (a frame of an arc: their spacing is the speed; a still path is one dot). */
|
|
51
|
+
class Dots extends Tubes {
|
|
52
|
+
constructor(color, capacity, radius = DOT) {
|
|
53
|
+
super(color, capacity, radius);
|
|
54
|
+
this.mesh.geometry = dot;
|
|
55
|
+
}
|
|
56
|
+
set(points) {
|
|
57
|
+
points.forEach((p, i) => this.mesh.setMatrixAt(i, m4.makeScale(this.radius, this.radius, this.radius).setPosition(p[0], p[1], p[2])));
|
|
58
|
+
this.mesh.count = points.length;
|
|
59
|
+
this.mesh.instanceMatrix.needsUpdate = true;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export class PhysicsOverlay {
|
|
64
|
+
constructor({ layer }) {
|
|
65
|
+
this.layer = layer;
|
|
66
|
+
this.object = new THREE.Group();
|
|
67
|
+
this.object.name = "physics-overlay";
|
|
68
|
+
this.body = new THREE.Group();
|
|
69
|
+
this.body.matrixAutoUpdate = false;
|
|
70
|
+
this.world = new THREE.Group();
|
|
71
|
+
this.object.add(this.body, this.world);
|
|
72
|
+
this.ball = new THREE.Mesh(new THREE.SphereGeometry(COM_RADIUS, 20, 14), material(PHYSICS_COLORS.inside));
|
|
73
|
+
this.drop = new Tubes(PHYSICS_COLORS.inside, 1, LINE * 0.6);
|
|
74
|
+
this.drop.mesh.material.dispose();
|
|
75
|
+
this.drop.mesh.material = this.ball.material;
|
|
76
|
+
this.support = new Tubes(PHYSICS_COLORS.support, 64);
|
|
77
|
+
this.arcs = new THREE.Group();
|
|
78
|
+
this.body.add(this.ball, this.drop.mesh, this.support.mesh, this.arcs);
|
|
79
|
+
this.trails = [];
|
|
80
|
+
this.arcView = null;
|
|
81
|
+
this.arcTubes = [];
|
|
82
|
+
claim(this.object, layer);
|
|
83
|
+
this.last = null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Draw the air arcs of `view` (rebuilt only when the view changes). */
|
|
87
|
+
setArcs(view) {
|
|
88
|
+
if (this.arcView === view) return;
|
|
89
|
+
this.arcView = view;
|
|
90
|
+
for (const t of this.arcTubes) { this.arcs.remove(t.mesh); t.dispose(); }
|
|
91
|
+
this.arcTubes = [];
|
|
92
|
+
for (const arc of view ? airArcs(view) : []) {
|
|
93
|
+
const path = new Tubes(PHYSICS_COLORS.path, arc.path.length), fit = new Tubes(PHYSICS_COLORS.fit, arc.fit.length);
|
|
94
|
+
const pathDots = new Dots(PHYSICS_COLORS.path, arc.path.length, DOT * 0.8), fitDots = new Dots(PHYSICS_COLORS.fit, arc.fit.length);
|
|
95
|
+
path.set(arc.path, { dashed: true });
|
|
96
|
+
fit.set(arc.fit);
|
|
97
|
+
pathDots.set(arc.path);
|
|
98
|
+
fitDots.set(arc.fit);
|
|
99
|
+
for (const t of [fit, fitDots, path, pathDots]) { this.arcs.add(claim(t.mesh, this.layer)); this.arcTubes.push(t); }
|
|
100
|
+
// the path over the arc: where the hips stay put, the white dot shows inside the orange one
|
|
101
|
+
path.mesh.renderOrder = pathDots.mesh.renderOrder = ORDER + 1;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
setTrails(trails) {
|
|
106
|
+
while (this.trails.length < trails.length) {
|
|
107
|
+
const t = new Tubes(PHYSICS_COLORS.trail, TRAIL_FRAMES, LINE * 0.75);
|
|
108
|
+
this.world.add(claim(t.mesh, this.layer));
|
|
109
|
+
this.trails.push(t);
|
|
110
|
+
}
|
|
111
|
+
this.trails.forEach((t, i) => { t.mesh.visible = !!trails[i]; if (trails[i]) t.set(trails[i].points); });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Pose the overlay for take frame `frame` of `view` (physics-view.js; null hides it) with parts `settings`
|
|
116
|
+
* ({ com, support, arc, trail }). placement: the matrix from where the take was compiled to where the character
|
|
117
|
+
* stands now. Answers what it drew (QA hooks read it).
|
|
118
|
+
*/
|
|
119
|
+
update({ view, frame, placement, settings }) {
|
|
120
|
+
const on = !!view && (settings.com || settings.support || settings.arc || settings.trail);
|
|
121
|
+
this.object.visible = on;
|
|
122
|
+
if (!on) { this.last = null; return null; }
|
|
123
|
+
if (placement) { this.body.matrix.copy(placement); this.body.matrixWorldNeedsUpdate = true; }
|
|
124
|
+
const com = comAt(view, frame), poly = settings.support ? supportAt(view, frame) : [], trails = settings.trail ? tipTrails(view, frame) : [];
|
|
125
|
+
this.ball.visible = this.drop.mesh.visible = settings.com;
|
|
126
|
+
if (settings.com) {
|
|
127
|
+
this.ball.material.color.set(PHYSICS_COLORS[com.state]);
|
|
128
|
+
this.ball.position.fromArray(com.p);
|
|
129
|
+
this.drop.set([com.p, [com.p[0], com.ground + LIFT, com.p[2]]]);
|
|
130
|
+
}
|
|
131
|
+
this.support.mesh.visible = poly.length > 0;
|
|
132
|
+
this.support.set(poly.map(([x, y, z]) => [x, y + LIFT, z]), { closed: true });
|
|
133
|
+
this.arcs.visible = settings.arc;
|
|
134
|
+
this.setArcs(settings.arc ? view : null);
|
|
135
|
+
this.setTrails(trails);
|
|
136
|
+
this.last = { frame: com.frame, com, support: poly, arcs: settings.arc ? airArcs(view).map(({ a, b, float, worst }) => ({ a, b, float, worst })) : [], trails: trails.map(t => ({ id: t.id, points: t.points.length })) };
|
|
137
|
+
return this.last;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
dispose() {
|
|
141
|
+
this.setArcs(null);
|
|
142
|
+
for (const t of [this.drop, this.support, ...this.trails]) t.dispose();
|
|
143
|
+
this.ball.geometry.dispose();
|
|
144
|
+
this.trails = [];
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const live = new Set();
|
|
149
|
+
/** Register a viewport overlay's sync(frame) (physics-overlay.jsx); answers the unregister. */
|
|
150
|
+
export function addPhysicsOverlaySync(sync) { live.add(sync); return () => live.delete(sync); }
|
|
151
|
+
/** Pose every mounted overlay for timeline frame `frame` (the export path, before it renders), or the playhead with null. */
|
|
152
|
+
export function syncPhysicsOverlays(frame = null) { for (const sync of live) sync(frame); }
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// The physics overlay (physics-overlay.js) over the active character's take v3. It lives on SHOT_LAYER: the shot
|
|
2
|
+
// camera, the editor and pose views draw it, and so do captures, contact sheets and exports (the capture camera
|
|
3
|
+
// drops only GIZMO_LAYER), never the top view. Off by default; motion.physicsOverlay and the motion controls'
|
|
4
|
+
// Physics box turn it on (physics-overlay-settings.js). Every rendered frame poses it for the playhead; the export
|
|
5
|
+
// path poses it for the frame it records (syncPhysicsOverlays), so scrub, playback and export agree.
|
|
6
|
+
import { useEffect, useMemo, useRef } from "react";
|
|
7
|
+
import * as THREE from "three";
|
|
8
|
+
import { useFrame, useThree } from "@react-three/fiber";
|
|
9
|
+
import { SHOT_LAYER } from "./dualview.jsx";
|
|
10
|
+
import { PhysicsOverlay, addPhysicsOverlaySync } from "./physics-overlay.js";
|
|
11
|
+
import { physicsView } from "./motion3/physics-view.js";
|
|
12
|
+
import { isTakeV3 } from "./motion3/playback.js";
|
|
13
|
+
import { playbackFrame } from "./motion/playback-frame-store.js";
|
|
14
|
+
import { physicsOverlayOn, physicsOverlaySettings, usePhysicsOverlaySettings } from "./physics-overlay-settings.js";
|
|
15
|
+
|
|
16
|
+
const compiled = new THREE.Matrix4(), placement = new THREE.Matrix4();
|
|
17
|
+
|
|
18
|
+
/** From where `take` was compiled (take.placement) to where the rig's parent (the character's placement) stands now. */
|
|
19
|
+
function placementOf(rig, take) {
|
|
20
|
+
const at = take.placement?.position ?? [0, 0, 0];
|
|
21
|
+
compiled.makeRotationY(((take.placement?.yaw ?? 0) * Math.PI) / 180).setPosition(at[0], at[1], at[2]).invert();
|
|
22
|
+
rig.parent.updateWorldMatrix(true, false);
|
|
23
|
+
return placement.multiplyMatrices(rig.parent.matrixWorld, compiled);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** rig: the active character's live rig; take: its installed motion; objects: the authored records. */
|
|
27
|
+
export function PhysicsOverlayLayer({ rig, take, objects }) {
|
|
28
|
+
const settings = usePhysicsOverlaySettings();
|
|
29
|
+
const scene = useThree((state) => state.scene), invalidate = useThree((state) => state.invalidate);
|
|
30
|
+
const overlay = useMemo(() => new PhysicsOverlay({ layer: SHOT_LAYER }), []);
|
|
31
|
+
const on = physicsOverlayOn(settings) && !!rig && isTakeV3(take);
|
|
32
|
+
const view = useMemo(() => (on ? physicsView(take, objects) : null), [on, take, objects]);
|
|
33
|
+
const latest = useRef({});
|
|
34
|
+
useEffect(() => { latest.current = { rig, take, view }; });
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (!on) return undefined;
|
|
37
|
+
scene.add(overlay.object);
|
|
38
|
+
return () => { scene.remove(overlay.object); };
|
|
39
|
+
}, [on, scene, overlay]);
|
|
40
|
+
useEffect(() => () => overlay.dispose(), [overlay]);
|
|
41
|
+
useEffect(() => { invalidate(); }, [invalidate, settings, view]);
|
|
42
|
+
const sync = useMemo(() => (frame) => {
|
|
43
|
+
const { rig: body, take: shown, view: measured } = latest.current;
|
|
44
|
+
const ready = measured && body?.parent;
|
|
45
|
+
return overlay.update({ view: ready ? measured : null, frame: frame ?? playbackFrame(), placement: ready ? placementOf(body, shown) : null, settings: physicsOverlaySettings() });
|
|
46
|
+
}, [overlay]);
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
// QA hook: the overlay, what it drew last, and a way to pose it for a frame
|
|
49
|
+
if (typeof window !== "undefined") window.__cozyclayPhysics = { overlay, sync, drawn: () => overlay.last };
|
|
50
|
+
return addPhysicsOverlaySync(sync);
|
|
51
|
+
}, [overlay, sync]);
|
|
52
|
+
useFrame(() => { sync(null); });
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// The motion controls' Physics box: the whole physics overlay on or off, through motion.physicsOverlay (the agent's
|
|
2
|
+
// door to the same setting, physics-overlay-settings.js).
|
|
3
|
+
import { ko } from "./locale.js";
|
|
4
|
+
import { useStudioShell } from "./shell/studio-shell-context.js";
|
|
5
|
+
import { physicsOverlayOn, setPhysicsOverlay, usePhysicsOverlaySettings } from "./physics-overlay-settings.js";
|
|
6
|
+
|
|
7
|
+
export function PhysicsToggle() {
|
|
8
|
+
const shell = useStudioShell();
|
|
9
|
+
const on = physicsOverlayOn(usePhysicsOverlaySettings());
|
|
10
|
+
const toggle = () => (shell?.runStudioAction ? shell.runStudioAction("motion.physicsOverlay", { all: !on }) : setPhysicsOverlay({ all: !on }));
|
|
11
|
+
return (
|
|
12
|
+
<label className={"tl-btn ik physics" + (on ? " on" : "")} data-testid="physics-overlay-toggle"
|
|
13
|
+
title={ko("Physics — draw the centre of mass (green inside the feet, red past the balance limit, grey in the air), the support polygon, the gravity arc of each jump against the hips' path, and spring trails",
|
|
14
|
+
"물리 — 무게중심(발 안이면 초록, 균형 한계 밖이면 빨강, 공중이면 회색), 지지 다각형, 점프마다 중력 포물선과 엉덩이 궤적, 스프링 궤적을 그립니다",
|
|
15
|
+
"物理 — 绘制重心(脚内为绿色,超出平衡极限为红色,空中为灰色)、支撑多边形、每次跳跃的重力抛物线与髋部轨迹,以及弹簧轨迹")}>
|
|
16
|
+
<input type="checkbox" checked={on} onChange={toggle} aria-label={ko("Physics overlay", "물리 오버레이", "物理叠加层")} />
|
|
17
|
+
{ko("Physics", "물리", "物理")}
|
|
18
|
+
</label>
|
|
19
|
+
);
|
|
20
|
+
}
|
package/src/posestudio.jsx
CHANGED
|
@@ -405,7 +405,7 @@ export function IkHandles({ chains, fkJoints, ikState, enabled, focus, onFocus,
|
|
|
405
405
|
tmp.target.copy(tmp.hit).add(d.offset);
|
|
406
406
|
}
|
|
407
407
|
if (Math.hypot(ev.clientX - d.downXY[0], ev.clientY - d.downXY[1]) > CLICK_PX) d.moved = true;
|
|
408
|
-
solveRef.current?.(d.kind, d.trackId, tmp.target);
|
|
408
|
+
solveRef.current?.(d.kind, d.trackId, tmp.target, { bypass: ev.altKey });
|
|
409
409
|
};
|
|
410
410
|
const onUp = (ev) => {
|
|
411
411
|
// Detach this drag's listeners but KEEP the handler pair — nulling
|
package/tools/run-tests.mjs
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
|
-
import { mkdtempSync, readdirSync, rmSync } from "node:fs";
|
|
4
|
-
import { createRequire } from "node:module";
|
|
3
|
+
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
|
5
4
|
import { availableParallelism, tmpdir } from "node:os";
|
|
6
5
|
import { join, relative } from "node:path";
|
|
7
6
|
|
|
@@ -14,7 +13,7 @@ if (!process.env.COZYCLAY_AGENT_SESSIONS_DIR) {
|
|
|
14
13
|
}
|
|
15
14
|
|
|
16
15
|
const NODE_FILES = [
|
|
17
|
-
"test/motion3/verify-motion3-core.mjs", "test/motion3/verify-playback-frame-store.mjs", "test/verify-playback-render-isolation.mjs", "test/motion3/verify-motion3-idle.mjs", "test/motion3/verify-motion3-life.mjs", "test/motion3/verify-motion3-headlead.mjs", "test/motion3/verify-motion3-gait.mjs", "test/motion3/verify-motion3-overlap.mjs", "test/motion3/verify-motion3-flip.mjs", "test/motion3/verify-motion3-timing.mjs", "test/motion3/verify-motion3-self.mjs", "test/motion3/verify-motion3-commands.mjs", "test/motion3/verify-motion3-exitcar.mjs", "test/motion3/verify-motion3-agentloop.mjs", "test/motion3/verify-motion3-search.mjs", "test/motion3/verify-motion3-skills.mjs", "test/motion3/verify-motion3-carried.mjs", "test/motion3/verify-motion3-people.mjs", "test/motion3/verify-motion3-hit.mjs", "test/motion3/verify-motion3-socket.mjs", "test/motion3/verify-motion3-shapes.mjs", "test/motion3/verify-motion3-grasp-fields.mjs", "test/motion3/verify-motion3-finger-contact.mjs", "test/motion3/verify-motion3-finger-pose.mjs", "test/motion3/verify-motion3-grasp-qa.mjs", "test/motion3/verify-motion3-load.mjs", "test/motion3/verify-motion3-throw.mjs", "test/motion3/verify-motion3-catch.mjs", "test/motion3/verify-motion3-grasp-hints.mjs", "test/motion3/verify-motion3-reach-human.mjs", "test/motion3/verify-motion3-stance-grasp.mjs", "test/motion3/verify-motion3-grasp-friction.mjs", "test/motion3/verify-motion3-grasp-measure.mjs", "test/motion3/verify-motion3-contracts.mjs", "test/motion3/verify-motion3-describe.mjs", "test/motion3/verify-motion3-timeline.mjs", "test/motion3/verify-motion3-probes.mjs", "test/motion3/verify-motion3-grab.mjs", "test/motion3/verify-motion3-grab-blend.mjs", "test/motion3/verify-motion3-patch.mjs", "test/motion3/verify-motion3-patch-keys.mjs", "test/motion3/verify-motion3-stuck.mjs", "test/motion3/verify-motion3-repair.mjs", "test/motion3/verify-motion3-specstore.mjs", "test/motion3/verify-motion3-visual.mjs", "test/motion3/verify-motion3-goals.mjs", "test/motion3/verify-motion3-prompt.mjs", "test/motion3/verify-motion3-cup.mjs", "test/motion3/verify-motion3-arm.mjs", "test/verify-proxy-figure.mjs", "test/verify-text-object.mjs",
|
|
16
|
+
"test/motion3/verify-motion3-core.mjs", "test/motion3/verify-playback-frame-store.mjs", "test/verify-playback-render-isolation.mjs", "test/motion3/verify-motion3-idle.mjs", "test/motion3/verify-motion3-life.mjs", "test/motion3/verify-motion3-headlead.mjs", "test/motion3/verify-motion3-gait.mjs", "test/motion3/verify-motion3-overlap.mjs", "test/motion3/verify-motion3-flip.mjs", "test/motion3/verify-motion3-float-fix.mjs", "test/motion3/verify-motion3-timing.mjs", "test/motion3/verify-motion3-self.mjs", "test/motion3/verify-motion3-commands.mjs", "test/motion3/verify-motion3-exitcar.mjs", "test/motion3/verify-motion3-agentloop.mjs", "test/motion3/verify-motion3-search.mjs", "test/motion3/verify-motion3-skills.mjs", "test/motion3/verify-motion3-carried.mjs", "test/motion3/verify-motion3-people.mjs", "test/motion3/verify-motion3-hit.mjs", "test/motion3/verify-motion3-person-fix.mjs", "test/motion3/verify-motion3-socket.mjs", "test/motion3/verify-motion3-shapes.mjs", "test/motion3/verify-motion3-grasp-fields.mjs", "test/motion3/verify-motion3-finger-contact.mjs", "test/motion3/verify-motion3-finger-pose.mjs", "test/motion3/verify-motion3-grasp-qa.mjs", "test/motion3/verify-motion3-load.mjs", "test/motion3/verify-motion3-throw.mjs", "test/motion3/verify-motion3-catch.mjs", "test/motion3/verify-motion3-spring.mjs", "test/motion3/verify-motion3-grasp-hints.mjs", "test/motion3/verify-motion3-reach-human.mjs", "test/motion3/verify-motion3-stance-grasp.mjs", "test/motion3/verify-motion3-grasp-friction.mjs", "test/motion3/verify-motion3-grasp-measure.mjs", "test/motion3/verify-motion3-contracts.mjs", "test/motion3/verify-motion3-describe.mjs", "test/motion3/verify-motion3-timeline.mjs", "test/motion3/verify-motion3-probes.mjs", "test/motion3/verify-motion3-grab.mjs", "test/motion3/verify-motion3-grab-blend.mjs", "test/motion3/verify-motion3-patch.mjs", "test/motion3/verify-motion3-patch-keys.mjs", "test/motion3/verify-motion3-stuck.mjs", "test/motion3/verify-motion3-repair.mjs", "test/motion3/verify-motion3-specstore.mjs", "test/motion3/verify-motion3-visual.mjs", "test/motion3/verify-motion3-goals.mjs", "test/motion3/verify-motion3-prompt.mjs", "test/motion3/verify-motion3-cup.mjs", "test/motion3/verify-motion3-arm.mjs", "test/motion3/verify-motion3-balance.mjs", "test/motion3/verify-physics-overlay.mjs", "test/verify-proxy-figure.mjs", "test/verify-text-object.mjs",
|
|
18
17
|
"test/verify-proxy-figure-bounds.mjs",
|
|
19
18
|
"test/verify-character-capabilities.mjs",
|
|
20
19
|
"test/verify-root-path.mjs",
|
|
@@ -29,7 +28,7 @@ const NODE_FILES = [
|
|
|
29
28
|
"test/ardy/verify-timeline-coordinates.mjs",
|
|
30
29
|
"test/verify-tokens.mjs", "test/verify-lens-scrub.mjs",
|
|
31
30
|
"test/demo/verify-api-routes.mjs",
|
|
32
|
-
"test/ik/verify-collision-capsules.mjs", "test/ik/verify-ik-pick.mjs",
|
|
31
|
+
"test/ik/verify-collision-capsules.mjs", "test/ik/verify-ik-pick.mjs", "test/ik/verify-contact-project.mjs",
|
|
33
32
|
"test/ik/verify-ground.mjs",
|
|
34
33
|
"test/ik/verify-physics-review.mjs",
|
|
35
34
|
"test/ik/verify-physics-support.mjs",
|
|
@@ -211,7 +210,7 @@ const NODE_FILES = [
|
|
|
211
210
|
|
|
212
211
|
const BROWSER_FILES = [
|
|
213
212
|
"test/qa-proxy-entry-browser.mjs",
|
|
214
|
-
"test/qa-previs-start-browser.mjs", "test/qa-shortcut-friction-browser.mjs",
|
|
213
|
+
"test/qa-previs-start-browser.mjs", "test/qa-shortcut-friction-browser.mjs", "test/qa-ik-contact-browser.mjs",
|
|
215
214
|
"test/qa-storyboard-board-browser.mjs",
|
|
216
215
|
"test/qa-storyboard-export-browser.mjs", "test/qa-playback-guide-browser.mjs",
|
|
217
216
|
"test/qa-proxy-figure-browser.mjs", "test/qa-click-friction-browser.mjs", "test/qa-update-notice-browser.mjs",
|
|
@@ -259,7 +258,7 @@ const EXTRA_INVENTORY = ["test/ik/translation-step.mjs", "test/qa-scenes-v5-migr
|
|
|
259
258
|
|
|
260
259
|
EXTRA_INVENTORY.push("test/qa-proxy-figure-browser.mjs", "test/qa-click-friction-browser.mjs", "test/qa-update-notice-browser.mjs");
|
|
261
260
|
EXTRA_INVENTORY.push("test/qa-proxy-entry-browser.mjs");
|
|
262
|
-
EXTRA_INVENTORY.push("test/qa-previs-start-browser.mjs", "test/qa-shortcut-friction-browser.mjs");
|
|
261
|
+
EXTRA_INVENTORY.push("test/qa-previs-start-browser.mjs", "test/qa-shortcut-friction-browser.mjs", "test/qa-ik-contact-browser.mjs");
|
|
263
262
|
EXTRA_INVENTORY.push("test/qa-storyboard-board-browser.mjs");
|
|
264
263
|
EXTRA_INVENTORY.push("test/qa-storyboard-export-browser.mjs", "test/qa-playback-guide-browser.mjs");
|
|
265
264
|
|
|
@@ -401,17 +400,11 @@ const hasNodeSqlite = meetsMinimumNodeVersion(NODE_SQLITE_MIN);
|
|
|
401
400
|
// fresh clone those suites must degrade to an actionable EXCLUDE instead of
|
|
402
401
|
// a startup timeout that swallows the real ERR_MODULE_NOT_FOUND (CI installs
|
|
403
402
|
// them explicitly with `npm --prefix mcp ci`, so its coverage is unchanged).
|
|
404
|
-
//
|
|
403
|
+
// Every mcp/package.json dependency must be in mcp/node_modules itself: most of
|
|
404
|
+
// them also resolve from the root tree, which would hide a missing mcp install.
|
|
405
405
|
function hasMcpRuntimeDeps() {
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
for (const dependency of ["@modelcontextprotocol/sdk/server/mcp.js", "three", "ws", "zod"]) {
|
|
409
|
-
requireFromMcp.resolve(dependency);
|
|
410
|
-
}
|
|
411
|
-
return true;
|
|
412
|
-
} catch {
|
|
413
|
-
return false;
|
|
414
|
-
}
|
|
406
|
+
const { dependencies = {} } = JSON.parse(readFileSync(new URL("../mcp/package.json", import.meta.url), "utf8"));
|
|
407
|
+
return Object.keys(dependencies).every((name) => existsSync(new URL(`../mcp/node_modules/${name}/package.json`, import.meta.url)));
|
|
415
408
|
}
|
|
416
409
|
|
|
417
410
|
const mcpDepsInstalled = hasMcpRuntimeDeps();
|
|
@@ -2,12 +2,37 @@
|
|
|
2
2
|
import { execFileSync } from "node:child_process";
|
|
3
3
|
import { existsSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync } from "node:fs";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
|
-
import { join } from "node:path";
|
|
5
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
6
6
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
7
7
|
|
|
8
8
|
const packageRoot = fileURLToPath(new URL("..", import.meta.url));
|
|
9
9
|
const temporaryRoot = mkdtempSync(join(tmpdir(), "cozyclay-package-check-"));
|
|
10
10
|
const extractionRoot = join(temporaryRoot, "extracted");
|
|
11
|
+
// Static, side-effect and dynamic imports with a relative specifier.
|
|
12
|
+
const RELATIVE_IMPORT = /(?:\bfrom\s*|\bimport\s*\(\s*|\bimport\s+)["'](\.\.?\/[^"']+)["']/g;
|
|
13
|
+
|
|
14
|
+
function* modulesUnder(dir) {
|
|
15
|
+
if (!existsSync(dir)) return;
|
|
16
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
17
|
+
if (entry.name === "node_modules") continue;
|
|
18
|
+
const path = join(dir, entry.name);
|
|
19
|
+
if (entry.isDirectory()) yield* modulesUnder(path);
|
|
20
|
+
else if (entry.name.endsWith(".mjs")) yield path;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Every relative import of a shipped bin/ or mcp/ module must resolve inside the
|
|
25
|
+
// tarball: a dev checkout has every file, an npm install only what `files` lists.
|
|
26
|
+
function missingImports(packedRoot) {
|
|
27
|
+
const missing = [];
|
|
28
|
+
for (const file of [...modulesUnder(join(packedRoot, "bin")), ...modulesUnder(join(packedRoot, "mcp"))]) {
|
|
29
|
+
for (const [, specifier] of readFileSync(file, "utf8").matchAll(RELATIVE_IMPORT)) {
|
|
30
|
+
const target = resolve(dirname(file), specifier);
|
|
31
|
+
if (!existsSync(target)) missing.push(`${relative(packedRoot, file)} imports ${specifier} (${relative(packedRoot, target)} is not in the tarball)`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return missing;
|
|
35
|
+
}
|
|
11
36
|
|
|
12
37
|
try {
|
|
13
38
|
mkdirSync(extractionRoot);
|
|
@@ -24,6 +49,9 @@ try {
|
|
|
24
49
|
execFileSync("tar", ["-xzf", tarball, "-C", extractionRoot]);
|
|
25
50
|
|
|
26
51
|
const packedRoot = join(extractionRoot, "package");
|
|
52
|
+
const missing = missingImports(packedRoot);
|
|
53
|
+
if (missing.length > 0) throw new Error(`npm tarball is missing modules its bin/ and mcp/ code imports:\n ${missing.join("\n ")}`);
|
|
54
|
+
console.log("package tarball imports PASS (every relative import in bin/ and mcp/ resolves inside the tarball)");
|
|
27
55
|
const metadataPath = join(packedRoot, "package.json");
|
|
28
56
|
const markerPath = join(packedRoot, "dist", "cozyclay-package.json");
|
|
29
57
|
const signatureModulePath = join(packedRoot, "bin", "package-signature.mjs");
|