cozyclay 3.0.3 → 3.1.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 +67 -0
- package/INSTALL_FOR_AGENTS.md +146 -0
- package/README.md +74 -192
- package/THIRD_PARTY_NOTICES.md +0 -21
- package/bin/agent/agent-routes.mjs +22 -56
- package/bin/agent/agent-runner.mjs +5 -3
- package/bin/agent/main-agent.mjs +70 -0
- package/bin/agent/main-prompt.mjs +17 -0
- package/bin/agent/session-store.mjs +2 -2
- package/bin/agent/studio-prompt.mjs +1 -1
- package/bin/agent/studio-task-routes.mjs +185 -0
- package/bin/agent/studio-tasks.mjs +156 -0
- package/bin/agent/studio-tools.mjs +3 -2
- package/bin/agent/studio-turn-transaction.mjs +76 -0
- package/bin/cozyclay.mjs +5 -7
- package/dist/app/index.html +7 -6
- package/dist/assets/TaskBoard-DdDXMgZm.js +1 -0
- package/dist/assets/app-B2BpfCl2.js +5088 -0
- package/dist/assets/app-BlwOpest.css +1 -0
- package/dist/assets/compile-worker-D0pKioa0.js +5 -0
- package/dist/assets/landing-y3EqDgSZ.js +1 -0
- package/dist/assets/modulepreload-polyfill-P2Xu9kJm.js +1 -0
- package/dist/assets/motion3-compile-B887n77J.js +1 -0
- package/dist/assets/scene-asset-sync-COGOlF5T.css +1 -0
- package/dist/assets/scene-asset-sync-CQzEdsqu.js +13 -0
- package/dist/assets/task-board-BKaM7d-9.js +14 -0
- package/dist/assets/workflow-B8Dhzexj.js +179 -0
- package/dist/assets/x-bot-tpose-body-BusyR3eg.json +1 -0
- package/dist/assets/y-bot-tpose-body-Dy1VKChZ.json +1 -0
- package/dist/cozyclay-package.json +1 -1
- package/dist/greybox-to-video/index.html +2 -3
- package/dist/index.html +7 -57
- package/dist/llms.txt +2 -1
- package/dist/previs-software/index.html +2 -2
- package/dist/privacy/index.html +3 -7
- package/dist/seedance-camera-control/index.html +2 -2
- package/dist/sitemap.xml +5 -5
- package/dist/sw.js +5 -1
- package/dist/thumbs/blank-stage-dark.webp +0 -0
- package/dist/thumbs/blank-stage-light.webp +0 -0
- package/dist/workflow/index.html +5 -4
- package/mcp/mesh-file.mjs +3 -1
- package/mcp/server.mjs +3 -0
- package/package.json +3 -3
- package/src/App.jsx +50 -281
- package/src/analytics.js +3 -148
- package/src/app-stage.jsx +1 -1
- package/src/asset-pane.jsx +5 -10
- package/src/auto-flat.js +10 -0
- package/src/bus-tasks.js +11 -5
- package/src/command-bus.js +1 -1
- package/src/commands/motion3-compile.js +51 -27
- package/src/commands/motion3.js +3 -3
- package/src/commands/project.js +1 -1
- package/src/commands/shot.js +16 -4
- package/src/controls.jsx +0 -11
- package/src/cuts.js +11 -0
- package/src/domains/cast.js +3 -2
- package/src/domains/motion.js +3 -10
- package/src/domains/playback.js +32 -0
- package/src/domains/scenes.js +3 -22
- package/src/domains/shots.js +5 -5
- package/src/dualview.jsx +11 -15
- package/src/hierarchy-panel.css +18 -4
- package/src/hierarchy-panel.jsx +12 -11
- package/src/kits/held.js +3 -2
- package/src/light-blockers.js +2 -2
- package/src/main.jsx +5 -0
- package/src/motion/ground.js +5 -14
- package/src/motion/playback-frame-store.js +79 -0
- package/src/motion/playback-probe.js +16 -0
- package/src/motion/playback-subject.js +7 -0
- package/src/motion/timeline.css +43 -5
- package/src/motion/timeline.jsx +4 -3
- package/src/motion3/body.js +37 -5
- package/src/motion3/carried.js +11 -3
- package/src/motion3/compile-job.js +1 -1
- package/src/motion3/compiler/compile.js +7 -1
- package/src/motion3/compiler/finger-close.js +137 -0
- package/src/motion3/compiler/limbs.js +18 -3
- package/src/motion3/compiler/pose.js +6 -0
- package/src/motion3/compiler/validate-objects.js +17 -2
- package/src/motion3/compiler/validate-parts.js +2 -1
- package/src/motion3/compiler/vocab.js +4 -4
- package/src/motion3/describe.js +2 -2
- package/src/motion3/fix/index.js +5 -3
- package/src/motion3/flight.js +38 -0
- package/src/motion3/free-motion.js +10 -7
- package/src/motion3/load.js +125 -0
- package/src/motion3/mass.js +23 -0
- package/src/motion3/object-keys.js +11 -6
- package/src/motion3/object-pose.js +11 -3
- package/src/motion3/pipeline.js +3 -0
- package/src/motion3/qa/explain.js +3 -1
- package/src/motion3/qa/grasp-checks.js +123 -0
- package/src/motion3/qa/hints-grasp.js +71 -0
- package/src/motion3/qa/index.js +3 -1
- package/src/motion3/qa/object-checks.js +7 -2
- package/src/motion3/qa/patch.js +2 -1
- package/src/motion3/qa/spec-checks.js +10 -1
- package/src/motion3/schema/fields.js +3 -3
- package/src/motion3/schema/topics.js +9 -8
- package/src/motion3/schema/types.js +4 -1
- package/src/motion3/seat.js +84 -0
- package/src/motion3/settle.js +11 -6
- package/src/motion3/shapes.js +70 -0
- package/src/motion3/skills/catch.js +71 -0
- package/src/motion3/skills/index.js +10 -3
- package/src/motion3/skills/info.js +13 -0
- package/src/motion3/skills/throw.js +72 -0
- package/src/object-catalog.jsx +0 -4
- package/src/object-gizmo.jsx +2 -1
- package/src/object-path.js +1 -1
- package/src/panels/ProjectPanel.jsx +42 -21
- package/src/panels/details.css +15 -4
- package/src/popover.js +37 -0
- package/src/posestudio.jsx +15 -10
- package/src/project-browser-art.jsx +19 -0
- package/src/project-browser.css +74 -121
- package/src/project-browser.jsx +36 -60
- package/src/props.jsx +4 -7
- package/src/retired-kinds.js +37 -0
- package/src/scene-objects.js +3 -7
- package/src/scenes.js +4 -5
- package/src/settings-menu.jsx +1 -20
- package/src/shell/BottomDock.jsx +9 -9
- package/src/shell/MenuBar.jsx +1 -9
- package/src/shell/PreferencesDialog.jsx +3 -4
- package/src/shell/ViewportToolbar.jsx +3 -3
- package/src/shell/add-camera-shot.js +11 -0
- package/src/shell/glass-regions.css +3 -2
- package/src/shell/glass.css +0 -5
- package/src/shell/lens-scrub-math.js +51 -0
- package/src/shell/lens-scrub.css +73 -0
- package/src/shell/lens-scrub.jsx +98 -0
- package/src/shell/mode.css +6 -5
- package/src/shell/preferences.css +1 -32
- package/src/shell/shell.css +18 -1
- package/src/shell/studio-shell-context.js +5 -0
- package/src/shot-frame.js +26 -0
- package/src/stage-canvas-rigs.jsx +7 -8
- package/src/stage-characters.jsx +26 -0
- package/src/stage-gizmo.js +43 -0
- package/src/stage-labels-ko.js +0 -6
- package/src/stage-startup-defaults.js +3 -9
- package/src/starter-scenes.js +5 -11
- package/src/studio-agent-commands.js +2 -2
- package/src/studio-agent-protocol.js +2 -7
- package/src/studio-app-binding.js +1 -1
- package/src/studio-elements.js +1 -1
- package/src/styles/themes.css +13 -0
- package/src/styles.css +6 -674
- package/src/text/text-object.jsx +1 -1
- package/src/ui.jsx +8 -16
- package/src/use-embed-bridge.js +3 -6
- package/src/use-render-activity.js +3 -1
- package/src/view-ghosts.jsx +5 -3
- package/src/viewport-visible-rect.js +37 -0
- package/src/workflow/AgentPanel.jsx +75 -42
- package/src/workflow/AgentProgressCard.jsx +1 -1
- package/src/workflow/TaskBoard.jsx +102 -0
- package/src/workflow/TaskInline.jsx +114 -0
- package/src/workflow/agent-client.js +11 -25
- package/src/workflow/agent-panel.css +14 -1
- package/src/workflow/attachment-image.js +10 -3
- package/src/workflow/attachment-video.js +91 -0
- package/src/workflow/task-board.css +98 -0
- package/src/workflow/task-board.js +169 -0
- package/tools/check-eslint.mjs +2 -1
- package/tools/check-knip.mjs +2 -1
- package/tools/dev/pages/greybox-to-video.html +2 -3
- package/tools/dev/pages/previs-software.html +2 -2
- package/tools/dev/pages/privacy.html +3 -7
- package/tools/dev/pages/seedance-camera-control.html +2 -2
- package/tools/dev-full.mjs +11 -2
- package/tools/eslint-baseline.json +6 -6
- package/tools/knip-baseline.json +0 -11
- package/tools/loc-baseline.json +30 -31
- package/tools/metrics-snapshot.mjs +43 -5
- package/tools/render-start-thumbs.mjs +175 -0
- package/tools/run-tests.mjs +7 -14
- package/tools/sitemap.mjs +1 -1
- package/dist/assets/analytics-BibyE5ar.js +0 -6
- package/dist/assets/app-DhRJ-PBz.js +0 -5088
- package/dist/assets/app-f-fkV5uP.css +0 -1
- package/dist/assets/compile-worker-DqiA4AaN.js +0 -5
- package/dist/assets/first-shot-handoff-Qx7SKl38.js +0 -1
- package/dist/assets/landing-DYg87MDY.js +0 -1
- package/dist/assets/scene-asset-sync-DBYQBR0T.js +0 -20
- package/dist/assets/scene-asset-sync-_S8NQl5B.css +0 -1
- package/dist/assets/workflow-BSA22Ofr.js +0 -179
- package/dist/scenes/city-block.cclayproject +0 -1
- package/src/burn-in.js +0 -91
- package/src/camera-tutorial.jsx +0 -520
- package/src/facing-marks.js +0 -88
- package/src/first-shot-handoff.js +0 -44
- package/src/panels/motion.css +0 -197
- package/src/set-pieces.jsx +0 -183
- package/src/tutorial-analytics.js +0 -86
- package/src/tutorial-walk.js +0 -17
- package/src/use-camera-tutorial.js +0 -50
- package/src/use-semantic-state.js +0 -12
- package/tools/metrics/archive-traffic.mjs +0 -138
- package/tools/perf/ik-camera-drag.mjs +0 -138
|
@@ -48,7 +48,7 @@ export const EXAMPLES = {
|
|
|
48
48
|
};
|
|
49
49
|
|
|
50
50
|
const UNITS = `World metres, +Y up; ${CONVENTIONS.yaw}; the character's left is +X at yaw 0. Angles in degrees.`;
|
|
51
|
-
const NO_JUMP = "
|
|
51
|
+
const NO_JUMP = "No at/along: it rests in the palm, fingers closed on it. Throw/catch: throwObject, catchObject.";
|
|
52
52
|
/** The objects example's carry: a grip prop and the object it holds. */
|
|
53
53
|
const CARRY = { props: EXAMPLES.objects.props, objects: { cup: EXAMPLES.objects.objects.cup } };
|
|
54
54
|
|
|
@@ -70,19 +70,20 @@ export const NOTES = {
|
|
|
70
70
|
props: ["A prop is a set of points fixed to one hand, [d, l, n] along the palm axes; prop:<prop>.origin is the mean of its origin bones.",
|
|
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
|
-
"reachGrip adds props.<anchor>Grip and its fit. Carry a scene object with objects.<id>.prop (topic objects)."
|
|
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."],
|
|
74
75
|
objects: ["A hinge turns the object (a body or a group) about pivot/axis by the angle channel your keys set (degrees).",
|
|
75
76
|
"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.",
|
|
76
|
-
|
|
77
|
+
"No grab: in hand from frame 0; no release: to the end. No at/along: it rests in the palm, fingers closed on it.",
|
|
77
78
|
"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.",
|
|
78
79
|
"A switch { visible?, level? } (no prop, no hinge field) 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 "
|
|
79
80
|
+ "(an unkeyed level reads 1). Hiding a group hides its parts. A carry takes level too.",
|
|
80
|
-
"keys pose a carried object in the world while
|
|
81
|
-
+ "or aim [x, y, z]
|
|
82
|
-
+ `The object leads and the
|
|
81
|
+
"keys pose a carried object in the world while in hand: pos its base (a group: its root), rot a quaternion [x, y, z, w], "
|
|
82
|
+
+ "or aim [x, y, z] where its +Y points (a bat's barrel) plus roll degrees about it (none: the roll the arm holds most easily). "
|
|
83
|
+
+ `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); `
|
|
83
84
|
+ "a left hand on a prop anchor of it turns with it; "
|
|
84
|
-
+ `the hand eases in and out over ${KEY_BLEND} frames.
|
|
85
|
-
+ "
|
|
85
|
+
+ `the hand eases in and out over ${KEY_BLEND} frames. REACH "<id>: n cm off its keys" is what the hand could not reach; `
|
|
86
|
+
+ "observations.held measures each key as drawn.",
|
|
86
87
|
"free { keys?, fall? } moves what nobody holds: keys at any frame; fall drops it at frame at onto the still top below (onto: that one; else the floor) "
|
|
87
88
|
+ "to rest on a stable face, one small bounce (0: none). A carry falls before grab or after release.",
|
|
88
89
|
"A follow { follow: <ref>, offset?, aim?, visible?, level? } puts any record (an effect, a light, a part) on a body point every frame: "
|
|
@@ -86,7 +86,8 @@ export const FIT_TYPES = {
|
|
|
86
86
|
const OBJECT_KEY_TYPES = { f: int(), pos: ref("vec3"), rot: { type: "array", items: { type: "number" }, minItems: 4, maxItems: 4, description: "quat x, y, z, w" },
|
|
87
87
|
aim: ref("vec3", "or rot: +Y dir"), roll: num("deg about aim"), lean: num("aim tilt", { minimum: 0, maximum: 45 }) };
|
|
88
88
|
|
|
89
|
-
const FALL = { type: "object", properties: { at: int(), onto: str(), bounce: num(null, { default: 0.25, minimum: 0, maximum: 0.6 }), tumble: { type: "boolean" }
|
|
89
|
+
const FALL = { type: "object", properties: { at: int(), onto: str(), bounce: num(null, { default: 0.25, minimum: 0, maximum: 0.6 }), tumble: { type: "boolean" },
|
|
90
|
+
v: { oneOf: [{ $ref: "#/$defs/vec3" }, { const: "hand" }], description: "m/s" } }, required: ["at"], additionalProperties: false };
|
|
90
91
|
/** A free entry's keys and fall (C17) are a carry's, at any frame: said once, not twice. */
|
|
91
92
|
const AS_CARRY = { description: "as carry; keys at any frame" };
|
|
92
93
|
export const FREE_TYPES = { keys: AS_CARRY, fall: AS_CARRY };
|
|
@@ -98,6 +99,7 @@ export const OBJECT_TYPES = {
|
|
|
98
99
|
keys: { type: "array", items: { type: "object", properties: Object.fromEntries(OBJECT_KEY_FIELDS.map(f => [f, OBJECT_KEY_TYPES[f]])), required: ["f", "pos"], additionalProperties: false },
|
|
99
100
|
description: "world pose at f; hand follows" },
|
|
100
101
|
fall: { ...FALL, description: "or a list; topic objects" },
|
|
102
|
+
grasp: { enum: GRASPS }, mass: num("kg", { minimum: 0.01, maximum: 60 }),
|
|
101
103
|
follow: ref("ref"), offset: ref("vec3"), aim: { oneOf: [{ enum: ["face", "up"] }, { $ref: "#/$defs/vec3" }, { $ref: "#/$defs/ref" }], default: "up" },
|
|
102
104
|
};
|
|
103
105
|
|
|
@@ -141,6 +143,7 @@ export const FIX_TYPES = {
|
|
|
141
143
|
self: bool("arms kept out of the body's skin (wrists, elbows)", { default: true }),
|
|
142
144
|
retime: bool("speed spikes borrow frames from the neighbouring keys", { default: false }),
|
|
143
145
|
stance: bool("a standIdle stance in a scene body slides out of it (observations.placed)", { default: true }),
|
|
146
|
+
grasp: bool("fingers close on what the hand holds; it rests in the hand", { default: true }),
|
|
144
147
|
};
|
|
145
148
|
|
|
146
149
|
export const REF_DOCS = {
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// A carried object rests in the hand that holds it (#816). Without at/along it kept the transform it had against the
|
|
2
|
+
// prop origin, the finger middles, at the grab frame: session 7f15b4ae's ball rode 2.4 cm off them, the index and middle
|
|
3
|
+
// 3 cm inside it, and closing the fingers would move that origin with them. On the grasp path (a carry with no at/along,
|
|
4
|
+
// or naming a carry grasp) with fix.grasp on:
|
|
5
|
+
// frame the palm frame stands on the Hand bone: the prop origin where the bind pose has it against the hand, so
|
|
6
|
+
// closing fingers move nothing they hold (carried.js carriedPose `fixed`)
|
|
7
|
+
// seat the grab-frame offset slides along the palm normal until the palm and the knuckles just touch the object
|
|
8
|
+
// (their skin TOUCH m off it): found once per offset by bisection on the bind hand, no frame is solved for it
|
|
9
|
+
// settle over SETTLE frames from the grab the object eases from where it rests into its seat (smoothstep)
|
|
10
|
+
import { add, sub, scale, apply, T } from "./compiler/math.js";
|
|
11
|
+
import { restState } from "./compiler/skeleton.js";
|
|
12
|
+
import { SIDES } from "./compiler/pose.js";
|
|
13
|
+
import { carriedPose, compose, relative } from "./carried.js";
|
|
14
|
+
import { capsuleDepth, shapeOf } from "./shapes.js";
|
|
15
|
+
|
|
16
|
+
const TOUCH = 0.002, PALM = 0.022, KNUCKLE = 0.012, NEAR = 0.04, FAR = 0.15, STEPS = 12;
|
|
17
|
+
export const SETTLE = 4;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* True when carry entry o of body or group `id` rests in its hand: grasp physics on, and a carry grasp named, or no pinned
|
|
21
|
+
* at/along on one round body (a ball, a can: shapes.js). A box or a kit carried without a grasp keeps its grab-frame offset
|
|
22
|
+
* (a lantern hangs from its handle; sliding it onto the palm would push it off the fingers).
|
|
23
|
+
*/
|
|
24
|
+
export const seatsInHand = (spec, o, scene, id) => spec?.fix !== false && spec?.fix?.grasp !== false && !!o?.prop
|
|
25
|
+
&& (o.grasp !== undefined || (o.at === undefined && o.along === undefined && roundBody(scene, id)));
|
|
26
|
+
function roundBody(scene, id) {
|
|
27
|
+
const parts = Object.entries(scene?.objects ?? {}).filter(([k, e]) => k === id || e.ancestors?.includes(id));
|
|
28
|
+
return parts.length === 1 && shapeOf(parts[0][1]).kind !== "box";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const seats = new WeakMap();
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The offset (in the palm frame of carry o's prop) that seats body or group `id` against the palm: `offset` moved along
|
|
35
|
+
* the palm normal until the palm (Hand to Middle1) and the knuckles (Index1 to Pinky1) touch it from outside.
|
|
36
|
+
*/
|
|
37
|
+
export function seatOffset(sk, spec, scene, id, o, offset) {
|
|
38
|
+
if (!seats.has(scene)) seats.set(scene, new Map());
|
|
39
|
+
const cache = seats.get(scene), key = `${id}|${o.prop}|${offset.pos.map(v => v.toFixed(6)).join(",")}`;
|
|
40
|
+
if (cache.has(key)) return cache.get(key);
|
|
41
|
+
const prop = spec.props[o.prop], side = SIDES[prop.hand], s0 = restState(sk), frame = carriedPose(sk, spec, s0, { prop: o.prop }, true);
|
|
42
|
+
const local = p => apply(T(frame.R), sub(p, frame.pos)), J = n => local(s0.P[sk.index[`${side}${n}`]]);
|
|
43
|
+
const hand = [[J("Hand"), J("HandMiddle1"), PALM], [J("HandIndex1"), J("HandPinky1"), KNUCKLE]];
|
|
44
|
+
const root = restOf(scene, id), parts = Object.entries(scene.objects).filter(([k, e]) => k === id || e.ancestors?.includes(id));
|
|
45
|
+
// slide along the palm normal (the frame's y column): out of the hand when the object is in it, in toward it when it
|
|
46
|
+
// floats off it, whichever way the depth falls; stop where the skin is TOUCH off it
|
|
47
|
+
const y = [0, 1, 0], depthAt = u => {
|
|
48
|
+
const at = { pos: add(offset.pos, scale(y, u)), R: offset.R };
|
|
49
|
+
let d = -Infinity;
|
|
50
|
+
for (const [, e] of parts) {
|
|
51
|
+
const entry = { ...e, ...compose(at, relative(root, e)) };
|
|
52
|
+
for (const [a, b, r] of hand) d = Math.max(d, capsuleDepth(entry, a, b, r));
|
|
53
|
+
}
|
|
54
|
+
return d;
|
|
55
|
+
};
|
|
56
|
+
// out of the hand is the palm's side: -n on the right hand, +n on the left (limbs.js palmAxes), the frame's y column n
|
|
57
|
+
const d0 = depthAt(0), outward = prop.hand === "l" ? 1 : -1, inside = d0 > -TOUCH;
|
|
58
|
+
// inside: the answer lies outward within FAR; floating: inward within NEAR (never past the touch)
|
|
59
|
+
let lo = 0, hi = inside ? FAR : NEAR;
|
|
60
|
+
const sign = inside ? outward : -outward;
|
|
61
|
+
if (inside || depthAt(sign * hi) > -TOUCH) {
|
|
62
|
+
for (let i = 0; i < STEPS; i++) { const mid = (lo + hi) / 2; if ((depthAt(sign * mid) > -TOUCH) === inside) lo = mid; else hi = mid; }
|
|
63
|
+
} else lo = 0;
|
|
64
|
+
const seated = { pos: add(offset.pos, scale(y, sign * (inside ? hi : lo))), R: offset.R };
|
|
65
|
+
cache.set(key, seated);
|
|
66
|
+
return seated;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The rest pose { pos, R } of a body or a group root (a group: its parts' ancestor pose). */
|
|
70
|
+
export function restOf(scene, id) {
|
|
71
|
+
const e = scene.objects[id];
|
|
72
|
+
if (e) return { pos: e.pos, R: e.R };
|
|
73
|
+
const g = Object.values(scene.objects).find(x => x.ancestors?.[0] === id);
|
|
74
|
+
return g?.ancestorPoses?.[0] ?? { pos: [0, 0, 0], R: [[1, 0, 0], [0, 1, 0], [0, 0, 1]] };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** How far into its seat a carried object is at absolute frame f (0 at the grab, 1 from SETTLE frames on). */
|
|
78
|
+
export function settleAt(grab, f) {
|
|
79
|
+
const t = Math.min(1, Math.max(0, (f - grab) / SETTLE));
|
|
80
|
+
return t * t * (3 - 2 * t);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The world pose between `from` and `to` (same turn: the seat only slides), t of the way. */
|
|
84
|
+
export const between = (from, to, t) => ({ pos: add(from.pos, scale(sub(to.pos, from.pos), t)), R: to.R });
|
package/src/motion3/settle.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// pivot on the support, paced by gravity: closed form and deterministic.
|
|
11
11
|
import { add, sub, scale, dot, cross, len, norm, mul, apply, axisRot, clamp } from "./compiler/math.js";
|
|
12
12
|
import { G } from "./free-motion.js";
|
|
13
|
+
import { flightTime } from "./flight.js";
|
|
13
14
|
|
|
14
15
|
/** The share of the drop height a landing spends tipping the object over an edge. */
|
|
15
16
|
export const TIP = 0.3;
|
|
@@ -54,17 +55,21 @@ export function spinOf(Ra, Rb, fps) {
|
|
|
54
55
|
return len(axis) > 0 ? scale(axis, rate) : [0, 0, 0];
|
|
55
56
|
}
|
|
56
57
|
|
|
57
|
-
/**
|
|
58
|
-
|
|
59
|
-
|
|
58
|
+
/**
|
|
59
|
+
* The fall through the air from `start` onto the support top: { tl (s), at(t) } with the centre on a parabola, turning at
|
|
60
|
+
* `spin`, leaving at v0 m/s (a throw, flight.js; zero: straight down, as before).
|
|
61
|
+
*/
|
|
62
|
+
export function airPath(s, start, spin, top, fps, v0 = [0, 0, 0]) {
|
|
63
|
+
const rate = len(spin), com0 = world(start, s.com), still = !v0.some(v => v !== 0);
|
|
60
64
|
const at = t => {
|
|
61
65
|
const R = rate > 0 ? mul(axisRot(spin, deg(rate * t)), start.R) : start.R;
|
|
62
|
-
|
|
66
|
+
const fall = still ? [0, -0.5 * G * t * t, 0] : [v0[0] * t, v0[1] * t - 0.5 * G * t * t, v0[2] * t];
|
|
67
|
+
return { pos: sub(add(com0, fall), apply(R, s.com)), R };
|
|
63
68
|
};
|
|
64
69
|
const h = Math.max(0, lowest(s, start) - top);
|
|
65
|
-
if (rate === 0) return { tl: Math.sqrt((2 * h) / G), at };
|
|
70
|
+
if (rate === 0) return { tl: still ? Math.sqrt((2 * h) / G) : flightTime(h, v0[1], G), at };
|
|
66
71
|
// 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;
|
|
72
|
+
const dt = 1 / (fps * 8), cap = (still ? Math.sqrt((2 * (h + len(sub(s.hi, s.lo)))) / G) : flightTime(h + len(sub(s.hi, s.lo)), v0[1], G)) + dt;
|
|
68
73
|
let a = 0, b = dt;
|
|
69
74
|
while (b < cap && lowest(s, at(b)) > top) { a = b; b += dt; }
|
|
70
75
|
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; }
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// The shape of a motion-scene entry to a hand (#816): a sphere and a cylinder are round, not the box they sit in. A
|
|
2
|
+
// 7.4 cm ball in its box is off by up to 30% of its radius at the corners, so fingers closing on the box would stop in
|
|
3
|
+
// the air. The shape is derived from what every entry already carries (renderer, half: scene.js), so no field is added
|
|
4
|
+
// to an entry and a saved take source, which keeps whole entries, reads the same shapes. DOM-free: the compile worker
|
|
5
|
+
// runs it. Everything else (GRAB, COLLIDE, landing) keeps the oriented box.
|
|
6
|
+
// sphere renderer "sphere" whose three halves agree within EVEN: r = their mean (an uneven one is an ellipsoid:
|
|
7
|
+
// its box)
|
|
8
|
+
// cylinder renderer "cylinder": its axis along the entry's local +Y, r the smaller cross half, h = 2 half[1]
|
|
9
|
+
// box anything else: the oriented box on the base pivot (qa/geometry.js sdBox)
|
|
10
|
+
// An entry's pos is its base pivot (the bottom centre); its centre is pos + R [0, half[1], 0].
|
|
11
|
+
import { add, sub, scale, len, apply, T } from "./compiler/math.js";
|
|
12
|
+
import { sdBox, boxNormal, segPoints } from "./qa/geometry.js";
|
|
13
|
+
|
|
14
|
+
const EVEN = 0.05;
|
|
15
|
+
|
|
16
|
+
/** { kind: "sphere", r } | { kind: "cylinder", r, h } | { kind: "box" } of a motion-scene entry. */
|
|
17
|
+
export function shapeOf(entry) {
|
|
18
|
+
const h = entry?.half;
|
|
19
|
+
if (!h) return { kind: "box" };
|
|
20
|
+
if (entry.renderer === "sphere") {
|
|
21
|
+
const r = (h[0] + h[1] + h[2]) / 3;
|
|
22
|
+
return Math.max(...h) - Math.min(...h) <= EVEN * r ? { kind: "sphere", r } : { kind: "box" };
|
|
23
|
+
}
|
|
24
|
+
if (entry.renderer === "cylinder") return { kind: "cylinder", r: Math.min(h[0], h[2]), h: 2 * h[1] };
|
|
25
|
+
return { kind: "box" };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const local = (entry, p) => { const q = apply(T(entry.R), sub(p, entry.pos)); q[1] -= entry.half[1]; return q; };
|
|
29
|
+
const centre = entry => add(entry.pos, apply(entry.R, [0, entry.half[1], 0]));
|
|
30
|
+
|
|
31
|
+
/** Signed distance (m) from p to the entry's shape: negative inside. */
|
|
32
|
+
export function signedDistance(entry, p) {
|
|
33
|
+
const s = shapeOf(entry);
|
|
34
|
+
if (s.kind === "sphere") return len(sub(p, centre(entry))) - s.r;
|
|
35
|
+
if (s.kind === "cylinder") {
|
|
36
|
+
const q = local(entry, p), radial = Math.hypot(q[0], q[2]) - s.r, axial = Math.abs(q[1]) - s.h / 2;
|
|
37
|
+
return Math.hypot(Math.max(radial, 0), Math.max(axial, 0)) + Math.min(Math.max(radial, axial), 0);
|
|
38
|
+
}
|
|
39
|
+
return sdBox(p, entry);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Outward unit normal of the entry's shape at p (the nearest surface's from inside). */
|
|
43
|
+
export function normalAt(entry, p) {
|
|
44
|
+
const s = shapeOf(entry);
|
|
45
|
+
if (s.kind === "sphere") { const d = sub(p, centre(entry)), l = len(d); return l > 1e-12 ? scale(d, 1 / l) : apply(entry.R, [0, 1, 0]); }
|
|
46
|
+
if (s.kind === "cylinder") {
|
|
47
|
+
const q = local(entry, p), rq = Math.hypot(q[0], q[2]), radial = rq - s.r, axial = Math.abs(q[1]) - s.h / 2;
|
|
48
|
+
const out = rq > 1e-12 ? [q[0] / rq, 0, q[2] / rq] : [1, 0, 0], cap = [0, Math.sign(q[1]) || 1, 0];
|
|
49
|
+
let n;
|
|
50
|
+
if (radial <= 0 && axial <= 0) n = radial > axial ? out : cap;
|
|
51
|
+
else n = add(scale(out, Math.max(radial, 0)), scale(cap, Math.max(axial, 0)));
|
|
52
|
+
const l = len(n);
|
|
53
|
+
return apply(entry.R, l > 1e-12 ? scale(n, 1 / l) : out);
|
|
54
|
+
}
|
|
55
|
+
return boxNormal(p, entry);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function closestPoint(entry, p) {
|
|
59
|
+
return sub(p, scale(normalAt(entry, p), signedDistance(entry, p)));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* How far a capsule (segment a-b, `radius` m) goes into the entry's shape, m: positive is the depth of its skin inside,
|
|
64
|
+
* negative the gap between its skin and the surface. Sampled on the segment as the QA capsules are (segPoints).
|
|
65
|
+
*/
|
|
66
|
+
export function capsuleDepth(entry, a, b, radius) {
|
|
67
|
+
let depth = -Infinity;
|
|
68
|
+
for (const p of segPoints(a, b)) depth = Math.max(depth, radius - signedDistance(entry, p));
|
|
69
|
+
return depth;
|
|
70
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// catchObject: a hand meets a flying thing where it can reach and gives with it (#816). The thing flies as a free entry
|
|
2
|
+
// does, objects.<id>.fall { at, v } from its pose at `at` (flight.js); the skill samples that flight and picks the first
|
|
3
|
+
// frame its point is in front of the chest (FRONT m ahead, between the hips and the head) and SHELL m from the catching
|
|
4
|
+
// shoulder, no earlier than the hand can get there from f0 (REACH frames). The open hand arrives ARRIVE frames before it,
|
|
5
|
+
// the finger middles on the point (a fit on a world anchor there); the fingers close and the carry takes it at
|
|
6
|
+
// that frame (fills: prop, grab, grasp), so it rests in the hand (seat.js) and the fingers close on it (finger-close.js).
|
|
7
|
+
// Then the hand gives with it: along the incoming velocity by clamp(0.08 + 0.02 x mass x speed, 0.08, 0.15) m over ABSORB
|
|
8
|
+
// frames, slowing to a stop, and eases back. side "both", or a thing over HEAVY kg, adds the left hand on its left side
|
|
9
|
+
// along the same path (world anchors: the right hand holds the carry, the left meets it). A flight that never comes within
|
|
10
|
+
// reach is refused, naming its nearest frame and point; a hand that comes too late (f0 past the reach) misses: QA CATCH.
|
|
11
|
+
import { add, sub, scale, apply, ry, len, norm, dot } from "../compiler/math.js";
|
|
12
|
+
import { freePoseAt } from "../free-motion.js";
|
|
13
|
+
import { massOf } from "../mass.js";
|
|
14
|
+
import { skillError } from "./book.js";
|
|
15
|
+
import { transportKeys } from "./reach-human.js";
|
|
16
|
+
|
|
17
|
+
const SHELL = [0.35, 0.65], FRONT = 0.15, REACH = 10, LONG = 20, ARRIVE = 2, ABSORB = 6, BACK = 8, HEAVY = 1;
|
|
18
|
+
const r3 = v => v.map(x => Math.round(x * 1000) / 1000);
|
|
19
|
+
const clamp = (x, a, b) => Math.min(b, Math.max(a, x));
|
|
20
|
+
|
|
21
|
+
/** The shoulder of side S and the body's forward on the keyed root and yaw of state. */
|
|
22
|
+
function bodyAt(sk, state, S) {
|
|
23
|
+
const P = sk.restWorldPos, hips = state.root ?? P[sk.index.Hips], R = ry(state.yaw ?? 0);
|
|
24
|
+
return { hips, shoulder: add(hips, apply(R, sub(P[sk.index[`${S}Arm`]], P[sk.index.Hips]))), head: add(hips, apply(R, sub(P[sk.index.Head], P[sk.index.Hips]))), fwd: apply(R, [0, 0, 1]) };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function catchObject(ctx) {
|
|
28
|
+
const { args, state, sk, scene, tag } = ctx, id = args.object, side = args.side ?? "r", o = ctx.objects?.[id], fps = ctx.fps ?? 24;
|
|
29
|
+
if (side !== "r" && side !== "both") skillError(tag, `args.side must be "r" or "both", got ${JSON.stringify(side)}`);
|
|
30
|
+
const fall = Array.isArray(o?.fall) ? o.fall[0] : o?.fall;
|
|
31
|
+
if (!Array.isArray(fall?.v)) skillError(tag, `args.object "${id}" must fly: objects.${id}.fall { at, v: [vx, vy, vz] }`);
|
|
32
|
+
const body = bodyAt(sk, state, "Right"), flight = { spec: { objects: { [id]: { fall: o.fall } }, fps }, scene, timeMap: null };
|
|
33
|
+
const at = f => freePoseAt(flight, id, f)?.pos;
|
|
34
|
+
// the first frame of the flight in front of the chest within the shell around the shoulder; else the nearest one
|
|
35
|
+
let pick = null, near = null;
|
|
36
|
+
for (let f = fall.at + 1; f < ctx.frames; f++) {
|
|
37
|
+
const p = at(f), d = len(sub(p, body.shoulder)), ahead = dot(sub(p, body.hips), body.fwd), off = Math.max(0, SHELL[0] - d, d - SHELL[1], FRONT - ahead, body.hips[1] - 0.1 - p[1], p[1] - body.head[1]);
|
|
38
|
+
if (!near || off < near.off) near = { f, p, off };
|
|
39
|
+
if (off === 0) { pick = { f, p }; break; }
|
|
40
|
+
}
|
|
41
|
+
if (!pick) skillError(tag, `${id} never comes within reach of the right hand (in front of the chest, ${SHELL[0]}-${SHELL[1]} m from the shoulder): nearest at f${near.f}, [${r3(near.p).join(", ")}], ${near.off.toFixed(2)} m out; move the body (standIdle at) or the throw`);
|
|
42
|
+
// the hand needs REACH frames from f0: a later pick it would get to in time; else it comes late to where the thing was
|
|
43
|
+
const contact = Math.max(pick.f, ctx.f0 + REACH + ARRIVE), p = pick.p;
|
|
44
|
+
const incoming = scale(sub(at(pick.f), at(pick.f - 1)), fps), speed = len(incoming), mass = massOf({ objects: ctx.objects }, scene, id);
|
|
45
|
+
const give = clamp(0.08 + 0.02 * mass * speed, 0.08, 0.15), u = norm(incoming), ease = t => 1 - (1 - t) ** 3;
|
|
46
|
+
const path = [p, ...Array.from({ length: ABSORB }, (_, i) => add(p, scale(u, give * ease((i + 1) / ABSORB)))), add(p, scale(u, give * 0.4))];
|
|
47
|
+
const start = Math.max(ctx.f0, contact - ARRIVE - LONG), both = side === "both" || mass > HEAVY;
|
|
48
|
+
// the left hand (both hands, or a heavy thing) on its left side, its finger middles across the thing from the right's
|
|
49
|
+
const radius = Math.max(0, ...Object.values(scene.objects).filter(e => e === scene.objects[id] || e.ancestors?.includes(id)).map(e => Math.max(...e.half)));
|
|
50
|
+
const left = apply(ry(state.yaw ?? 0), [1, 0, 0]), part = { keys: [], end: contact + ABSORB + BACK, props: {}, anchors: {}, fits: [], channels: [],
|
|
51
|
+
fills: { [id]: { prop: `${id}Catch`, grab: contact, grasp: "spherical" } }, catch: { contact, point: r3(p), speed: Math.round(speed * 100) / 100, give: Math.round(give * 1000) / 1000, hands: both ? 2 : 1 } };
|
|
52
|
+
for (const k of both ? ["r", "l"] : ["r"]) {
|
|
53
|
+
const S = k === "l" ? "Left" : "Right", prop = k === "r" ? `${id}Catch` : `${id}CatchL`, off = k === "r" ? [0, 0, 0] : scale(left, 2 * radius + 0.04);
|
|
54
|
+
const armw = `armw_${k}`, fg = `fingers_${k}`, open = { ...state[fg], spherical: 0 }, shut = { ...state[fg], spherical: 1 };
|
|
55
|
+
// the finger middles (the prop's origin) on the point at the catch, then on each point of the give: world anchors
|
|
56
|
+
// with a fit each, the weight handed from one to the next frame by frame (the fits solve the hand onto them exactly)
|
|
57
|
+
const names = path.map((_, i) => `${prop}${i}`), w = i => `w_${names[i]}_${k}`;
|
|
58
|
+
part.keys.push(
|
|
59
|
+
// the elbow down and out (body axes), so it swings out round the side, not in across the body
|
|
60
|
+
{ f: start, set: { [armw]: state[armw] ?? 0, [w(0)]: 0, [fg]: open, [`elbow_${k}`]: k === "r" ? [-1, -0.5, -0.5] : [1, -0.5, -0.5] } },
|
|
61
|
+
// the reach's early-peaked transport, the arm's weight ahead of the aim's (reach-human.js; as reachGrip)
|
|
62
|
+
...transportKeys(contact - ARRIVE - start).map(([f, t, ta]) => ({ f: start + f, set: { [w(0)]: t, [armw]: (state[armw] ?? 0) + (1 - (state[armw] ?? 0)) * ta } })),
|
|
63
|
+
{ f: contact - ARRIVE, set: { [armw]: 1, [w(0)]: 1, [fg]: open } },
|
|
64
|
+
{ f: contact, set: { [w(0)]: 1, [fg]: shut } },
|
|
65
|
+
...path.slice(1).flatMap((_, j) => { const i = j + 1, f = i <= ABSORB ? contact + i : contact + ABSORB + BACK; return [{ f: i <= ABSORB ? f - 1 : contact + ABSORB, set: { [w(i - 1)]: 1, [w(i)]: 0 } }, { f, set: { [w(i - 1)]: 0, [w(i)]: 1 } }]; }),
|
|
66
|
+
);
|
|
67
|
+
part.props[prop] = { hand: k, origin: [`${S}HandIndex2`, `${S}HandMiddle2`], points: { fingers: [0, 0, 0] } };
|
|
68
|
+
names.forEach((n, i) => { part.anchors[n] = { kind: "world", p: r3(add(path[i], off)) }; part.fits.push({ side: k, anchor: n, effector: { prop, point: "fingers" } }); part.channels.push(w(i)); });
|
|
69
|
+
}
|
|
70
|
+
return part;
|
|
71
|
+
}
|
|
@@ -13,8 +13,10 @@ import { sitInSeat, exitCar } from "./car.js";
|
|
|
13
13
|
import { smokeCigarette } from "./smoke.js";
|
|
14
14
|
import { flip } from "./flip.js";
|
|
15
15
|
import { hitReaction } from "./impact.js";
|
|
16
|
+
import { throwObject } from "./throw.js";
|
|
17
|
+
import { catchObject } from "./catch.js";
|
|
16
18
|
|
|
17
|
-
const SKILLS = { standIdle, sitInSeat, exitCar, walkTo, reachGrip, smokeCigarette, flip, hitReaction };
|
|
19
|
+
const SKILLS = { standIdle, sitInSeat, exitCar, walkTo, reachGrip, smokeCigarette, flip, hitReaction, throwObject, catchObject };
|
|
18
20
|
export const SKILL_NAMES = Object.keys(SKILLS);
|
|
19
21
|
const bad = msg => { throw new Error(`MOTION_SPEC_INVALID: ${msg}`); };
|
|
20
22
|
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
@@ -53,7 +55,7 @@ export function expandUse(spec, { sk, scene, lenient = false, auto = false }) {
|
|
|
53
55
|
if (!spec.use?.length) return { spec, used: [] };
|
|
54
56
|
if (!Array.isArray(spec.use)) bad("spec.use must be an array of { skill, args, f0 }");
|
|
55
57
|
const own = (spec.keys ?? []).map((k, i) => ({ ...k, src: `keys[${i}]` }));
|
|
56
|
-
const merged = { anchors: {}, props: {}, objects: {}, fits: [], checks: {}, channels: [] }, skillKeys = [], used = [];
|
|
58
|
+
const merged = { anchors: {}, props: {}, objects: {}, fits: [], checks: {}, channels: [], fills: {}, goals: [] }, skillKeys = [], used = [];
|
|
57
59
|
spec.use.forEach((u, i) => {
|
|
58
60
|
const tag = `use[${i}] ${u?.skill}`;
|
|
59
61
|
if (!SKILLS[u?.skill]) bad(`use[${i}].skill "${u?.skill}" is not a skill${didYouMean(String(u?.skill), SKILL_NAMES)} (skills: ${SKILL_NAMES.join(", ")})`);
|
|
@@ -61,7 +63,7 @@ export function expandUse(spec, { sk, scene, lenient = false, auto = false }) {
|
|
|
61
63
|
argsOk(u, i, lenient);
|
|
62
64
|
if (!Number.isInteger(u.f0) || u.f0 < 0 || u.f0 > spec.frames - 1) bad(`use[${i}].f0 must be an integer frame within 0..${spec.frames - 1}, got ${JSON.stringify(u.f0)}`);
|
|
63
65
|
const keys = [...skillKeys, ...own];
|
|
64
|
-
const ctx = { sk, scene, frames: spec.frames, auto, props: { ...merged.props, ...spec.props }, self: spec.fix !== false && spec.fix?.self !== false, f0: u.f0, args: structuredClone(u.args ?? {}), tag, keys,
|
|
66
|
+
const ctx = { sk, scene, frames: spec.frames, fps: spec.fps ?? 24, auto, props: { ...merged.props, ...spec.props }, self: spec.fix !== false && spec.fix?.self !== false, f0: u.f0, args: structuredClone(u.args ?? {}), tag, keys,
|
|
65
67
|
use: spec.use, index: i, place: fixOptions(spec.fix).stance, state: structuredClone(stateAt(keys, u.f0)), anchors: { ...merged.anchors, ...spec.anchors }, objects: { ...merged.objects, ...spec.objects } };
|
|
66
68
|
const part = SKILLS[u.skill](ctx), last = Math.max(part.end ?? u.f0, ...part.keys.map(k => k.f));
|
|
67
69
|
if (last > spec.frames - 1) bad(`${tag} needs ${last - u.f0} frames from f${u.f0} (to f${last}), past the last frame f${spec.frames - 1}; set frames >= ${last + 1} or frames: "auto"`);
|
|
@@ -72,6 +74,9 @@ export function expandUse(spec, { sk, scene, lenient = false, auto = false }) {
|
|
|
72
74
|
for (const fit of part.fits ?? []) if (!merged.fits.some(x => same(x, fit))) merged.fits.push(fit);
|
|
73
75
|
for (const ch of part.channels ?? []) if (!merged.channels.includes(ch)) merged.channels.push(ch);
|
|
74
76
|
mergeChecks(merged.checks, part.checks);
|
|
77
|
+
// fields a skill fills into an entry when it leaves them out (throwObject: a carry's release and fall)
|
|
78
|
+
for (const [id, f] of Object.entries(part.fills ?? {})) merged.fills[id] = { ...merged.fills[id], ...f };
|
|
79
|
+
merged.goals.push(...(part.goals ?? []));
|
|
75
80
|
used.push({ skill: u.skill, f0: u.f0, end: part.end, ...(part.paced ? { paced: part.paced } : {}), ...(part.cut ? { cut: part.cut } : {}), ...(part.placed ? { placed: part.placed } : {}) });
|
|
76
81
|
});
|
|
77
82
|
const out = { ...spec, keys: [...skillKeys, ...own], anchors: { ...merged.anchors, ...spec.anchors }, props: { ...merged.props, ...spec.props },
|
|
@@ -82,6 +87,8 @@ export function expandUse(spec, { sk, scene, lenient = false, auto = false }) {
|
|
|
82
87
|
out.fits = [...merged.fits.filter(f => !mine.some(m => m.side === f.side && m.anchor === f.anchor)), ...mine];
|
|
83
88
|
mergeChecks(merged.checks, spec.checks);
|
|
84
89
|
out.checks = merged.checks;
|
|
90
|
+
for (const [id, f] of Object.entries(merged.fills)) if (out.objects[id]) out.objects[id] = { ...f, ...out.objects[id] };
|
|
91
|
+
if (merged.goals.length) out.goals = [...merged.goals, ...(spec.goals ?? [])];
|
|
85
92
|
delete out.use;
|
|
86
93
|
return { spec: out, used };
|
|
87
94
|
}
|
|
@@ -77,4 +77,17 @@ export const SKILL_INFO = {
|
|
|
77
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
78
|
needs: "a pose; dir or from", frames: "~40", adds: ["keys: thrown to rest"], example: { f0: 10, args: { dir: [0, -1], speed: 3 } },
|
|
79
79
|
},
|
|
80
|
+
throwObject: {
|
|
81
|
+
args: { object: str("the carried object's id (objects.<id>.prop on the hand's prop)"), side: side("the throwing hand (default r)"), to: { type: "array", items: { type: "number" }, minItems: 3, maxItems: 3, description: "[x, y, z] world m: where it lands" },
|
|
82
|
+
arc: { enum: ["low", "high"], description: "launch angle: low 20 deg, high 50 deg (default low)" } },
|
|
83
|
+
required: ["object", "to"], needs: "the object in that hand (its objects entry on the hand's prop)", frames: "24 (wind-up, swing, release at f0+18, follow-through)",
|
|
84
|
+
adds: ["keys: wind-up, swing, follow-through", "objects.<id>: release and fall { v: \"hand\" } when the entry leaves them out", "goals: <id> lands at the target"],
|
|
85
|
+
example: { f0: 40, args: { object: "ball", to: [0, 0, 5] } },
|
|
86
|
+
},
|
|
87
|
+
catchObject: {
|
|
88
|
+
args: { object: str("the flying object's id (objects.<id>.fall { at, v })"), side: { enum: ["r", "both"], description: "the right hand, or both (a thing over 1 kg takes both)" } },
|
|
89
|
+
required: ["object"], needs: "the object flying: objects.<id>.fall { at, v }", frames: "to the catch (the first frame in reach, at least 10 from f0) + 14",
|
|
90
|
+
adds: ["keys: reach, close, give with it", "objects.<id>: prop, grab, grasp when the entry leaves them out", "props.<id>Catch"],
|
|
91
|
+
example: { f0: 0, args: { object: "ball" } },
|
|
92
|
+
},
|
|
80
93
|
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// throwObject: the hand throws what it carries at a point (#816). From the pose at f0 the arm winds up behind the shoulder,
|
|
2
|
+
// swings forward and lets go, then follows through. The launch is closed form: the arc's launch angle (low LOW deg, high
|
|
3
|
+
// HIGH deg) and the speed that carries the object from where the hand lets it go to args.to under G (free-motion.js). The
|
|
4
|
+
// last frame before the release and the release frame put the wrist one frame's travel apart along that velocity, so the
|
|
5
|
+
// wrist moves at the launch velocity there; the fall the skill writes leaves at that velocity, { at: release, v } (flight.js).
|
|
6
|
+
// Not v: "hand": the hand's turn as the elbow opens adds a whip to the object (about 2 m/s at the release, so the throw
|
|
7
|
+
// carried 2 m long), which only the solved frames know; "hand" stays for an agent's own release. It writes the carry's release and fall (fills: fields the agent's own entry leaves out) and a goal: the
|
|
8
|
+
// object near args.to at its landing. A release with no fall stays where the hand let it go (C3), so the skill writes one.
|
|
9
|
+
import { add, sub, scale, apply, ry, len } from "../compiler/math.js";
|
|
10
|
+
import { G } from "../free-motion.js";
|
|
11
|
+
import { skillError } from "./book.js";
|
|
12
|
+
import { GRASP_FINGERS } from "../compiler/vocab.js";
|
|
13
|
+
|
|
14
|
+
const LOW = 20, HIGH = 50, WIND = 10, SWING = 8, FOLLOW = 10, SETTLE_FRAMES = 8, TOL = 0.25;
|
|
15
|
+
// the wrist at the release: ahead of the shoulder along the throw and up; the thing held rides PALM m ahead of the wrist
|
|
16
|
+
const AHEAD = 0.42, UP = 0.22, PALM = 0.09, BACK = 0.12, SIDE = 0.25, LOWER = 0.4;
|
|
17
|
+
const r3 = v => v.map(x => Math.round(x * 1000) / 1000);
|
|
18
|
+
|
|
19
|
+
/** How far the root of scene body or group `id` sits above its lowest point at rest (m). */
|
|
20
|
+
function liftOf(scene, id) {
|
|
21
|
+
const parts = Object.entries(scene?.objects ?? {}).filter(([k, e]) => k === id || e.ancestors?.includes(id)).map(([, e]) => e);
|
|
22
|
+
const root = scene?.objects?.[id]?.pos ?? parts[0]?.ancestorPoses?.[0]?.pos;
|
|
23
|
+
if (!root || !parts.length) return 0;
|
|
24
|
+
return root[1] - Math.min(...parts.map(e => e.pos[1]));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function throwObject(ctx) {
|
|
28
|
+
const { args, state, sk, scene, tag } = ctx, id = args.object, k = args.side ?? "r", o = ctx.objects?.[id];
|
|
29
|
+
if (k !== "l" && k !== "r") skillError(tag, `args.side must be "l" or "r", got ${JSON.stringify(k)}`);
|
|
30
|
+
if (!o?.prop || ctx.props?.[o.prop]?.hand !== k) skillError(tag, `args.object "${id}" must be carried by the ${k === "r" ? "right" : "left"} hand (objects.${id}.prop on a prop with hand "${k}")`);
|
|
31
|
+
if (!Array.isArray(args.to) || args.to.length !== 3) skillError(tag, "args.to must be [x, y, z] world m (where it lands)");
|
|
32
|
+
const theta = ((args.arc === "high" ? HIGH : LOW) * Math.PI) / 180, fps = ctx.fps ?? 24, S = k === "l" ? "Left" : "Right";
|
|
33
|
+
// the shoulder where the body stands at f0: the bind pose's offset from the hips, turned by the yaw, on the keyed root
|
|
34
|
+
const P = sk.restWorldPos, hips = state.root ?? P[sk.index.Hips], R = ry(state.yaw ?? 0);
|
|
35
|
+
const shoulder = add(hips, apply(R, sub(P[sk.index[`${S}Arm`]], P[sk.index.Hips])));
|
|
36
|
+
const flat = [args.to[0] - shoulder[0], 0, args.to[2] - shoulder[2]], dist = len(flat);
|
|
37
|
+
if (dist < 0.5) skillError(tag, `args.to is ${dist.toFixed(2)} m from the shoulder; throw it at least 0.5 m`);
|
|
38
|
+
const dir = scale(flat, 1 / dist), wrist = add(shoulder, add(scale(dir, AHEAD), [0, UP, 0])), from = add(wrist, scale(dir, PALM));
|
|
39
|
+
const land = args.to[1] + liftOf(scene, id), d = len([args.to[0] - from[0], 0, args.to[2] - from[2]]), dy = land - from[1];
|
|
40
|
+
const den = 2 * Math.cos(theta) ** 2 * (d * Math.tan(theta) - dy);
|
|
41
|
+
if (!(den > 0)) skillError(tag, `args.to is too high for a ${args.arc ?? "low"} arc from the hand; use arc "high" or a lower point`);
|
|
42
|
+
const speed = Math.sqrt((G * d * d) / den), v = add(scale(dir, speed * Math.cos(theta)), [0, speed * Math.sin(theta), 0]);
|
|
43
|
+
const flight = (v[1] + Math.sqrt(v[1] ** 2 + 2 * G * Math.max(0, from[1] - land))) / G;
|
|
44
|
+
const release = ctx.f0 + WIND + SWING, landed = Math.min(ctx.frames - 1, release + Math.ceil(flight * fps) + SETTLE_FRAMES);
|
|
45
|
+
// the wind-up goes back out on the hand's side (not behind the back), the follow-through on down across the body
|
|
46
|
+
const out = scale([dir[2], 0, -dir[0]], k === "r" ? -1 : 1), hand = `hand_${k}`, armw = `armw_${k}`;
|
|
47
|
+
const wind = add(shoulder, add(add(scale(dir, -BACK), scale(out, SIDE)), [0, -0.05, 0]));
|
|
48
|
+
// mid-swing the wrist stays out from the shoulder (a target near it folds the elbow past its stop); after the release it
|
|
49
|
+
// slows over three frames, going on and down, then crosses low in front
|
|
50
|
+
const mid = add(shoulder, add(add(scale(out, 0.32), scale(dir, 0.12)), [0, 0.08, 0]));
|
|
51
|
+
const on = add(wrist, scale(v, 0.45 / fps)), through = add(shoulder, add(scale(dir, 0.46), [0, -0.1, 0])), follow = add(shoulder, add(add(scale(dir, 0.3), scale(out, -0.15)), [0, -LOWER, 0]));
|
|
52
|
+
// the hand keeps its grip on the thing through the swing and opens as it lets go
|
|
53
|
+
const fg = `fingers_${k}`, preset = GRASP_FINGERS[o.grasp] ?? "grip", grip = { ...state[fg], [preset]: 1 }, open = { ...state[fg], [preset]: 0 };
|
|
54
|
+
const keys = [
|
|
55
|
+
{ f: ctx.f0, set: { [armw]: state[armw] ?? 0, [fg]: grip, ...(state[hand] ? { [hand]: state[hand] } : {}) } },
|
|
56
|
+
{ f: release, set: { [fg]: grip } }, { f: release + 3, set: { [fg]: open } },
|
|
57
|
+
{ f: ctx.f0 + WIND, set: { [armw]: 1, [hand]: r3(wind) } },
|
|
58
|
+
{ f: ctx.f0 + WIND + Math.round(SWING / 2), set: { [armw]: 1, [hand]: r3(mid) } },
|
|
59
|
+
{ f: release - 1, set: { [armw]: 1, [hand]: r3(sub(wrist, scale(v, 1 / fps))) } },
|
|
60
|
+
{ f: release, set: { [armw]: 1, [hand]: r3(wrist) } },
|
|
61
|
+
{ f: release + 1, set: { [armw]: 1, [hand]: r3(on) } },
|
|
62
|
+
{ f: release + 3, set: { [armw]: 1, [hand]: r3(through) } },
|
|
63
|
+
{ f: release + FOLLOW, set: { [armw]: 1, [hand]: r3(follow) } },
|
|
64
|
+
];
|
|
65
|
+
const target = `${id}Target`;
|
|
66
|
+
return {
|
|
67
|
+
keys, end: release + FOLLOW, anchors: { [target]: { kind: "world", p: r3([args.to[0], land, args.to[2]]) } },
|
|
68
|
+
fills: { [id]: { release, fall: { at: release, v: r3(v) } } },
|
|
69
|
+
goals: [{ name: `${id} lands at the target`, near: [`object:${id}`, `anchor:${target}`], at: landed, tol: TOL }],
|
|
70
|
+
throw: { release, speed: Math.round(speed * 100) / 100, v: r3(v), flight: Math.round(flight * 100) / 100 },
|
|
71
|
+
};
|
|
72
|
+
}
|
package/src/object-catalog.jsx
CHANGED
|
@@ -4,7 +4,6 @@ import { EMPTY_KIND, OBJECT_MENU_ENTRIES } from "./scene-objects.js";
|
|
|
4
4
|
|
|
5
5
|
const GROUP_LABELS_KO = {
|
|
6
6
|
Primitives: "기본 도형",
|
|
7
|
-
"Set pieces": "세트 소품",
|
|
8
7
|
Lights: "조명",
|
|
9
8
|
Effects: "효과",
|
|
10
9
|
Text: "텍스트",
|
|
@@ -18,9 +17,6 @@ const OBJECT_LABELS_KO = {
|
|
|
18
17
|
Cylinder: "원기둥",
|
|
19
18
|
Cone: "원뿔",
|
|
20
19
|
Plane: "평면",
|
|
21
|
-
Chair: "의자",
|
|
22
|
-
Car: "자동차",
|
|
23
|
-
"Plane (aircraft)": "비행기",
|
|
24
20
|
"Point light": "점 조명",
|
|
25
21
|
"Spot light": "스포트 조명",
|
|
26
22
|
Light: "조명",
|
package/src/object-gizmo.jsx
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
2
|
import { useFrame, useThree } from "@react-three/fiber";
|
|
3
3
|
import * as THREE from "three";
|
|
4
|
-
import { GIZMO_LAYER, SHOT_ASPECT
|
|
4
|
+
import { GIZMO_LAYER, SHOT_ASPECT } from "./dualview.jsx";
|
|
5
|
+
import { fitAspect } from "./shot-frame.js";
|
|
5
6
|
import { HANDLE_PROXY_FLAG, claimsPress } from "./gizmo-claim.js";
|
|
6
7
|
import { CUTOUT_KIND, rotatePatch, scalePatch, screenRotatePatch, translatePatch, wrapAngle } from "./scene-objects.js";
|
|
7
8
|
import { dragTravel, dragTurn } from "./held-object-pose.js";
|
package/src/object-path.js
CHANGED
|
@@ -464,4 +464,4 @@ export function strokeToPathPoints(stroke, simplify, { maxPoints = STROKE_MAX_PO
|
|
|
464
464
|
return points.map((point) => ({ x: point.x, y: 0, z: point.z }));
|
|
465
465
|
}
|
|
466
466
|
|
|
467
|
-
export { MAX_PATH_POINTS, MAX_PATH_MARKS, MARK_GAP, MAX_LEAN as MAX_PATH_LEAN,
|
|
467
|
+
export { MAX_PATH_POINTS, MAX_PATH_MARKS, MARK_GAP, MAX_LEAN as MAX_PATH_LEAN, STROKE_MAX_POINTS };
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import { useLayoutEffect, useRef, useState } from "react";
|
|
2
|
+
import { createPortal } from "react-dom";
|
|
1
3
|
import { ko } from "../locale.js";
|
|
4
|
+
import { usePopover } from "../popover.js";
|
|
2
5
|
import ResourceStatus from "../resource-status.jsx";
|
|
3
6
|
import { useBus } from "../app-context.js";
|
|
4
7
|
import "./details.css";
|
|
@@ -7,27 +10,45 @@ export default function ProjectPanel({
|
|
|
7
10
|
projectMenuOpen, setProjectMenuOpen, projectDirty, projectName, projectStartupOpen, projectManifest,
|
|
8
11
|
}) {
|
|
9
12
|
const { run } = useBus();
|
|
13
|
+
const triggerRef = useRef(null);
|
|
14
|
+
const [menuBox, setMenuBox] = useState(null);
|
|
15
|
+
usePopover(projectMenuOpen, () => setProjectMenuOpen(false));
|
|
16
|
+
// The menu is portaled and fixed under the trigger: the glass column clips
|
|
17
|
+
// and blurs its children, so an in-place menu was cut at the column edge
|
|
18
|
+
// and read the column's transparent panel fill. App.jsx closes it on an
|
|
19
|
+
// outside pointerdown via `.closest(".project-menu-wrap")`, so the portal
|
|
20
|
+
// layer carries that class (display:contents, no box of its own).
|
|
21
|
+
useLayoutEffect(() => {
|
|
22
|
+
const rect = projectMenuOpen ? triggerRef.current?.getBoundingClientRect() : null;
|
|
23
|
+
setMenuBox(rect ? { left: rect.left, top: rect.bottom + 6 } : null);
|
|
24
|
+
}, [projectMenuOpen]);
|
|
25
|
+
// Hidden until the layout effect has placed it, so it never paints at the
|
|
26
|
+
// wrong spot; without a document (server render) it stays in place.
|
|
27
|
+
const menu = projectMenuOpen && (
|
|
28
|
+
<div className="project-menu-wrap v2-project-menu project-menu-layer">
|
|
29
|
+
<div className="project-menu" role="menu" style={menuBox ?? { visibility: "hidden" }} onClick={() => setProjectMenuOpen(false)}>
|
|
30
|
+
<button type="button" role="menuitem" onClick={() => run("project.new")}>{ko("New Project", "새 프로젝트")}</button>
|
|
31
|
+
<button type="button" role="menuitem" onClick={() => run("project.browse")}>{ko("Open Project…", "프로젝트 열기…")}</button>
|
|
32
|
+
<button type="button" role="menuitem" onClick={() => run("project.save")}>{ko("Save Project", "프로젝트 저장")}</button>
|
|
33
|
+
<button type="button" role="menuitem" onClick={() => run("project.saveAs")}>{ko("Save Project As…", "다른 이름으로 저장…")}</button>
|
|
34
|
+
<ResourceStatus manifest={projectManifest} compact />
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
);
|
|
10
38
|
return (
|
|
11
|
-
<div className="project-menu-wrap v2-project-menu">
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
<button type="button" role="menuitem" onClick={() => run("project.browse")}>{ko("Open Project…", "프로젝트 열기…")}</button>
|
|
26
|
-
<button type="button" role="menuitem" onClick={() => run("project.save")}>{ko("Save Project", "프로젝트 저장")}</button>
|
|
27
|
-
<button type="button" role="menuitem" onClick={() => run("project.saveAs")}>{ko("Save Project As…", "다른 이름으로 저장…")}</button>
|
|
28
|
-
<ResourceStatus manifest={projectManifest} compact />
|
|
29
|
-
</div>
|
|
30
|
-
)}
|
|
31
|
-
</div>
|
|
39
|
+
<div className="project-menu-wrap v2-project-menu">
|
|
40
|
+
<button
|
|
41
|
+
type="button"
|
|
42
|
+
className="project-menu-trigger"
|
|
43
|
+
ref={triggerRef}
|
|
44
|
+
aria-expanded={projectMenuOpen}
|
|
45
|
+
onClick={() => setProjectMenuOpen((open) => !open)}
|
|
46
|
+
>
|
|
47
|
+
{projectDirty && <i className="project-dirty-dot" aria-label={ko("Unsaved changes", "저장되지 않은 변경사항")} />}
|
|
48
|
+
{projectName ?? (projectStartupOpen ? ko("Choose Project", "프로젝트 선택") : ko("Untitled Project", "제목 없는 프로젝트"))}
|
|
49
|
+
<span className="caret">▾</span>
|
|
50
|
+
</button>
|
|
51
|
+
{menu && (globalThis.document ? createPortal(menu, globalThis.document.body) : menu)}
|
|
52
|
+
</div>
|
|
32
53
|
);
|
|
33
54
|
}
|
package/src/panels/details.css
CHANGED
|
@@ -1032,14 +1032,25 @@
|
|
|
1032
1032
|
background: var(--select, #e8a33d);
|
|
1033
1033
|
}
|
|
1034
1034
|
|
|
1035
|
+
/* The menu is portaled to <body> (ProjectPanel.jsx); the layer that carries
|
|
1036
|
+
the wrap classes there has no box of its own. Surface and layer are the
|
|
1037
|
+
shared popover tokens (themes.css). */
|
|
1038
|
+
.v2-project-menu.project-menu-layer {
|
|
1039
|
+
display: contents;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1035
1042
|
.v2-project-menu.v2-project-menu .project-menu {
|
|
1043
|
+
position: fixed;
|
|
1044
|
+
z-index: var(--z-popover, 90);
|
|
1036
1045
|
box-sizing: border-box;
|
|
1037
1046
|
min-width: var(--details-menu-width, 200px);
|
|
1038
1047
|
padding: var(--details-gap, 4px);
|
|
1039
|
-
border: var(--details-border-width, 1px) solid var(--row-divider, #1d1d20);
|
|
1040
|
-
border-radius: var(--
|
|
1041
|
-
background: var(--surface-
|
|
1042
|
-
|
|
1048
|
+
border: var(--details-border-width, 1px) solid var(--popover-border, var(--row-divider, #1d1d20));
|
|
1049
|
+
border-radius: var(--popover-radius, 6px);
|
|
1050
|
+
background: var(--popover-surface, var(--surface-raised, #1e1e21));
|
|
1051
|
+
-webkit-backdrop-filter: var(--popover-blur, none);
|
|
1052
|
+
backdrop-filter: var(--popover-blur, none);
|
|
1053
|
+
box-shadow: var(--popover-shadow, 0 8px 24px rgba(0, 0, 0, .45));
|
|
1043
1054
|
}
|
|
1044
1055
|
|
|
1045
1056
|
.v2-project-menu.v2-project-menu .project-menu button {
|