cozyclay 3.1.6 → 3.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +145 -0
- package/INSTALL_FOR_AGENTS.md +4 -4
- package/README.md +4 -2
- package/bin/agent/agent-failure-codes.mjs +34 -8
- package/bin/agent/agent-routes.mjs +6 -6
- package/bin/agent/agent-runner.mjs +25 -17
- package/bin/agent/agent-stream.mjs +26 -3
- package/bin/agent/main-prompt.mjs +1 -1
- package/bin/agent/openai-compatible-provider.mjs +24 -1
- package/bin/agent/studio-prompt.mjs +2 -2
- package/bin/agent/supervisor-followup.mjs +34 -0
- package/bin/agent/supervisor.mjs +6 -6
- package/bin/cozyclay.mjs +23 -60
- package/bin/star-prompt.mjs +15 -14
- package/bin/studio-routes.mjs +6 -3
- package/bin/telemetry-state.mjs +5 -7
- package/bin/update-check.mjs +61 -7
- package/bin/update-install.mjs +16 -3
- package/bin/update-restart.mjs +95 -0
- package/bin/update-routes.mjs +25 -3
- package/dist/app/index.html +13 -7
- package/dist/assets/TaskBoard-tgO0MNKL.js +1 -0
- package/dist/assets/analytics-CaTUuLPM.js +1 -0
- package/dist/assets/{app-CEP7jzXB.css → app-CnDkB5WO.css} +1 -1
- package/dist/assets/app-DJt06Oyb.js +5144 -0
- package/dist/assets/compile-worker-D1pl6G7x.js +6 -0
- package/dist/assets/landing-2Fy7DU41.js +1 -0
- package/dist/assets/modulepreload-polyfill-P2Xu9kJm.js +1 -0
- package/dist/assets/motion3-compile-BxaM0Xwl.js +1 -0
- package/dist/assets/scene-asset-sync-CLGNbEBq.js +13 -0
- package/dist/assets/task-board-UVGTZLRF.js +15 -0
- package/dist/assets/workflow-CSiBlX1s.js +179 -0
- package/dist/cozyclay-package.json +1 -1
- package/dist/index.html +4 -1
- package/dist/privacy/index.html +9 -6
- package/dist/sitemap.xml +2 -2
- package/dist/workflow/index.html +5 -4
- package/mcp/README.md +1 -1
- package/mcp/live-hub.mjs +4 -4
- package/mcp/tool-handlers.mjs +14 -16
- package/package.json +3 -1
- package/src/App.jsx +42 -75
- package/src/analytics-controls.js +25 -3
- package/src/analytics-strict.js +16 -4
- package/src/analytics.js +32 -26
- package/src/app-version.js +13 -0
- package/src/asset-drop.js +39 -0
- package/src/asset-pane.css +34 -0
- package/src/asset-pane.jsx +4 -35
- package/src/bus-tasks.js +1 -1
- package/src/character-kind.js +8 -6
- package/src/click-friction.js +5 -4
- package/src/command-bus-telemetry.js +8 -7
- package/src/command-bus.js +2 -16
- package/src/commands/cast.js +2 -1
- package/src/commands/index.js +2 -1
- package/src/commands/motion3.js +3 -1
- package/src/commands/objects.js +2 -37
- package/src/commands/physics.js +21 -0
- package/src/commands/project.js +3 -3
- package/src/commands/sculpt-compare.js +53 -0
- package/src/content-tile.jsx +36 -0
- package/src/domains/cast.js +7 -6
- package/src/domains/motion.js +13 -14
- package/src/domains/scenes.js +41 -31
- package/src/execution-telemetry.js +7 -3
- package/src/export-menu-telemetry.js +26 -0
- package/src/finger-handles.jsx +128 -0
- package/src/friction-hint.js +82 -0
- package/src/live-control.js +2 -1
- package/src/locale-zh/panels.js +20 -0
- package/src/locale-zh/shell.js +4 -0
- package/src/locale-zh/workspace.js +6 -0
- package/src/motion/center-of-mass.js +5 -23
- package/src/motion/contact-project.js +143 -0
- package/src/motion/find-bone.js +13 -0
- package/src/motion/fingers.js +170 -0
- package/src/motion/fingertip-pick.js +21 -0
- package/src/motion/fix-collisions.js +1 -1
- package/src/motion/ik-drag.js +93 -1
- package/src/motion/ik-exposure.js +48 -0
- package/src/motion/ik.js +4 -11
- 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/head-lead.js +4 -2
- 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/hybrid-hint.js +19 -0
- package/src/motion3/load.js +19 -12
- 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 +21 -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 +32 -4
- 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/qa/verdict.js +7 -2
- package/src/motion3/repair.js +28 -8
- 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/motion3/skills/catch.js +2 -1
- package/src/panels/FingerPanel.jsx +38 -0
- package/src/panels/RigControlPanel.jsx +9 -1
- package/src/panels/SculptPanel.jsx +12 -12
- package/src/panels/SculptPartTree.jsx +46 -0
- package/src/panels/SubjectsPanel.jsx +1 -1
- package/src/panels/sculpt.css +11 -0
- 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/pose-fade.js +7 -3
- package/src/posestudio.jsx +15 -28
- package/src/project-browser.css +6 -0
- package/src/project-browser.jsx +9 -18
- package/src/project-start-choices.jsx +45 -0
- package/src/project.js +16 -12
- package/src/proxy-figure-shape.js +11 -0
- package/src/proxy-figure.jsx +25 -18
- package/src/scene-assets.js +17 -13
- package/src/scenes.js +22 -11
- package/src/sculpt-blueprint.js +56 -0
- package/src/sculpt-mode.js +5 -0
- package/src/sculpt-part-edit.js +115 -0
- package/src/sculpt-part-gizmo.jsx +159 -0
- package/src/sculpt-part-outline.jsx +72 -0
- package/src/sculpt-pick.js +53 -0
- package/src/sculpt-run-text.js +51 -0
- package/src/sculpt-run.js +10 -46
- package/src/sculpt-shelf-file.js +92 -0
- package/src/sculpt-shelf-tiles.jsx +69 -0
- package/src/sculpt-shelf.js +108 -0
- package/src/sculpt-stage.jsx +39 -2
- package/src/sculpt-thumb.js +72 -0
- package/src/sculpt-views.js +75 -0
- package/src/semantic-edit.js +0 -87
- package/src/settings-menu.jsx +2 -2
- package/src/shell/DetailsSlot.jsx +5 -6
- package/src/shell/LibrarySlot.jsx +8 -10
- package/src/shell/MenuBar.jsx +6 -6
- package/src/shell/PreferencesDialog.jsx +2 -3
- package/src/shell/ViewportToolbar.jsx +15 -18
- package/src/shell/add-character-entries.js +13 -0
- package/src/shell/pose-controls.js +32 -0
- package/src/shell/preference-pages.jsx +2 -11
- package/src/shell/storyboard-caption.js +21 -0
- package/src/shell/studio-shell-context.js +1 -0
- package/src/shortcut-friction.js +2 -1
- package/src/site/landing-analytics.js +28 -0
- package/src/studio-actions.js +3 -2
- package/src/studio-agent-protocol.js +4 -4
- package/src/studio-app-binding.js +4 -3
- package/src/update/UpdateChip.jsx +21 -2
- package/src/update/UpdateDialog.jsx +26 -6
- package/src/update/update-client.js +123 -17
- package/src/update/update-telemetry.js +15 -3
- package/src/use-sculpt-mode.js +62 -8
- package/src/workflow/agent-failure-codes.js +28 -9
- package/src/workflow/agent-turn-telemetry.js +1 -1
- package/src/workflow/task-board.js +2 -2
- package/tools/dev/page-shell.py +1 -1
- package/tools/dev/pages/greybox-to-video.html +21 -2
- package/tools/dev/pages/previs-software.html +3 -3
- package/tools/dev/pages/privacy.html +9 -6
- package/tools/dev/pages/seedance-camera-control.html +2 -2
- package/tools/loc-baseline.json +10 -10
- package/tools/run-tests.mjs +53 -84
- package/tools/select-tests.mjs +119 -0
- package/tools/test-durations.json +455 -0
- package/tools/test-graph.mjs +181 -0
- package/tools/test-shards.mjs +78 -0
- package/tools/tutorial/README.md +74 -0
- package/tools/tutorial/assemble.mjs +43 -0
- package/tools/tutorial/captions.mjs +43 -0
- package/tools/tutorial/cdp.mjs +55 -0
- package/tools/tutorial/cursor.mjs +159 -0
- package/tools/tutorial/episode.mjs +173 -0
- package/tools/tutorial/episodes/beginner-guide/00-install.mjs +42 -0
- package/tools/tutorial/episodes/beginner-guide/01-start.mjs +50 -0
- package/tools/tutorial/episodes/beginner-guide/02-layout.mjs +67 -0
- package/tools/tutorial/episodes/beginner-guide/03-stage.mjs +58 -0
- package/tools/tutorial/episodes/beginner-guide/04-motion.mjs +98 -0
- package/tools/tutorial/episodes/beginner-guide/05-camera.mjs +47 -0
- package/tools/tutorial/episodes/beginner-guide/06-export.mjs +59 -0
- package/tools/tutorial/episodes/beginner-guide/steps.mjs +32 -0
- package/tools/tutorial/episodes/beginner-guide.mjs +17 -0
- package/tools/tutorial/episodes/cast.mjs +12 -0
- package/tools/tutorial/episodes/first-video.mjs +121 -0
- package/tools/tutorial/episodes/stub-tour.mjs +97 -0
- package/tools/tutorial/launch.mjs +63 -0
- package/tools/tutorial/mux.mjs +61 -0
- package/tools/tutorial/overlay.mjs +142 -0
- package/tools/tutorial/record.mjs +156 -0
- package/tools/tutorial/scenes/terminal.html +63 -0
- package/tools/tutorial/steps.mjs +111 -0
- package/tools/tutorial/terminal.mjs +43 -0
- package/tools/tutorial/voice.mjs +49 -0
- package/tools/update-test-durations.mjs +42 -0
- package/tools/verify-package-tarball.mjs +18 -12
- package/dist/assets/TaskBoard-DwXhsJXz.js +0 -1
- package/dist/assets/app-BLS1Zaru.js +0 -5144
- package/dist/assets/compile-worker-or7djI2v.js +0 -6
- package/dist/assets/motion3-compile-Du7UXrWn.js +0 -1
- package/dist/assets/rolldown-runtime-hePW80VL.js +0 -1
- package/dist/assets/scene-asset-sync-t45pWCyq.js +0 -13
- package/dist/assets/task-board-CkLZGPkv.js +0 -15
- package/dist/assets/workflow-DJwOUzqJ.js +0 -179
package/src/motion3/repair.js
CHANGED
|
@@ -12,13 +12,16 @@
|
|
|
12
12
|
// else is kept, and the applied entry says so (movedBody). A patch tried and not kept is not tried
|
|
13
13
|
// again, so the groups after it get their turn. A round that keeps nothing ends the repair. The
|
|
14
14
|
// compiler is deterministic and the candidates come in a fixed order, so the repair is too; no new
|
|
15
|
-
// compile starts after 20 s.
|
|
15
|
+
// compile starts after 20 s. A group whose hint has candidate patches (g.candidates, qa/patch.js: the
|
|
16
|
+
// faces of a box a second hand can take, load.js) gets them all compiled, at most CANDIDATES, and the
|
|
17
|
+
// one with the lowest summed severity stands for its patch: a hint compiles nothing, so only here
|
|
18
|
+
// can the cheapest be found.
|
|
16
19
|
import { applyPatch } from "./json-patch.js";
|
|
17
20
|
import { groupKey } from "./qa/severity.js";
|
|
18
21
|
import { tuneCandidates } from "./repair-tune.js";
|
|
19
22
|
import { compareQa } from "./qa/verdict.js";
|
|
20
23
|
|
|
21
|
-
const TRIES = 3, TUNE_GROUPS = 2, LIMIT_MS = 20000;
|
|
24
|
+
const TRIES = 3, TUNE_GROUPS = 2, LIMIT_MS = 20000, CANDIDATES = 4;
|
|
22
25
|
const groupsOf = r => r?.qa?.groups ?? [];
|
|
23
26
|
const totalOf = r => groupsOf(r).reduce((sum, g) => sum + (g.severity ?? 0), 0);
|
|
24
27
|
const failedOf = r => (r?.qa?.goals ?? []).filter(g => !g.ok).length;
|
|
@@ -38,14 +41,14 @@ const MOVED = "the body moved: no change that keeps it in place fixed this arm's
|
|
|
38
41
|
/**
|
|
39
42
|
* repairSpec(spec, { rounds }, compile) -> { spec, result, repair }
|
|
40
43
|
* @param {object} spec the agent's spec (patches address it)
|
|
41
|
-
* @param {{ rounds?: number }} opts 1..8 (default 4)
|
|
44
|
+
* @param {{ rounds?: number, limitMs?: number }} opts rounds 1..8 (default 4); limitMs, the wall-clock stop (default 20 s)
|
|
42
45
|
* @param {(spec: object) => object} compile spec -> pipeline.js compileSpec result (qa.groups[i].patch)
|
|
43
46
|
* @returns {{ spec: object, result: object, repair: { rounds: number, compiles: number,
|
|
44
47
|
* applied: { kind: string, subject: string, patch: object[], severity: number[], movedBody?: string }[], rejected: number } }}
|
|
45
48
|
*/
|
|
46
49
|
export function repairSpec(spec, opts, compile) {
|
|
47
50
|
const want = Math.min(8, Math.max(1, Math.round(Number(opts?.rounds ?? 4)) || 4)), t0 = performance.now();
|
|
48
|
-
const late = () => performance.now() - t0 >= LIMIT_MS;
|
|
51
|
+
const late = () => performance.now() - t0 >= (opts?.limitMs > 0 ? opts.limitMs : LIMIT_MS);
|
|
49
52
|
let current = spec, result = compile(spec), compiles = 1, rejected = 0, rounds = 0;
|
|
50
53
|
const refused = new Set();
|
|
51
54
|
const applied = [];
|
|
@@ -60,6 +63,23 @@ export function repairSpec(spec, opts, compile) {
|
|
|
60
63
|
return null;
|
|
61
64
|
}
|
|
62
65
|
};
|
|
66
|
+
/** g's patch compiled, or of its candidates ranked by summed severity the first that better() keeps ({ next, r, patch });
|
|
67
|
+
* with none kept, the cheapest (the caller refuses it); null when none compiles. A cheaper face that breaks a goal
|
|
68
|
+
* that held does not hide a dearer one that is kept. */
|
|
69
|
+
const cheapest = g => {
|
|
70
|
+
const tried = [];
|
|
71
|
+
for (const patch of (g.candidates ?? [g.patch]).slice(0, CANDIDATES)) {
|
|
72
|
+
if (tried.length && late()) break;
|
|
73
|
+
const t = attempt(patch);
|
|
74
|
+
if (!t) continue;
|
|
75
|
+
tried.push({ ...t, patch });
|
|
76
|
+
if (totalOf(t.r) === 0 && !failedOf(t.r) && better(t.r, result)) break;
|
|
77
|
+
}
|
|
78
|
+
tried.sort((a, b) => totalOf(a.r) - totalOf(b.r));
|
|
79
|
+
const pick = tried.find(t => better(t.r, result)) ?? tried[0] ?? null;
|
|
80
|
+
rejected += tried.filter(t => t !== pick).length;
|
|
81
|
+
return pick;
|
|
82
|
+
};
|
|
63
83
|
const keep = (g, key, patch, { next, r }) => {
|
|
64
84
|
const moved = armLocal(g) && movesBody(patch);
|
|
65
85
|
applied.push({ kind: g.kind, subject: g.subject, patch, severity: [g.severity, groupIn(r, key)?.severity ?? 0], ...(moved ? { movedBody: MOVED } : {}) });
|
|
@@ -97,8 +117,8 @@ export function repairSpec(spec, opts, compile) {
|
|
|
97
117
|
if (!g?.patch || late()) continue;
|
|
98
118
|
// an arm's failure whose patch moves the body waits until nothing else is kept this round
|
|
99
119
|
if (armLocal(g) && movesBody(g.patch)) { deferred.push(key); continue; }
|
|
100
|
-
const t =
|
|
101
|
-
if (t && better(t.r, result)) { keep(g, key,
|
|
120
|
+
const t = cheapest(g);
|
|
121
|
+
if (t && better(t.r, result)) { keep(g, key, t.patch, t); kept += 1; } else { refused.add(JSON.stringify(g.patch)); if (t) rejected += 1; }
|
|
102
122
|
}
|
|
103
123
|
for (const key of kept ? [] : near) {
|
|
104
124
|
const g = groupIn(result, key);
|
|
@@ -107,8 +127,8 @@ export function repairSpec(spec, opts, compile) {
|
|
|
107
127
|
for (const key of kept ? [] : deferred) {
|
|
108
128
|
const g = groupIn(result, key);
|
|
109
129
|
if (!g?.patch || late()) continue;
|
|
110
|
-
const t =
|
|
111
|
-
if (t && better(t.r, result)) { keep(g, key,
|
|
130
|
+
const t = cheapest(g);
|
|
131
|
+
if (t && better(t.r, result)) { keep(g, key, t.patch, t); kept += 1; break; } else { refused.add(JSON.stringify(g.patch)); if (t) rejected += 1; }
|
|
112
132
|
}
|
|
113
133
|
if (!kept) break;
|
|
114
134
|
}
|
|
@@ -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 = {
|
|
@@ -42,7 +42,8 @@ export function catchObject(ctx) {
|
|
|
42
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
43
|
const contact = Math.max(pick.f, ctx.f0 + REACH + ARRIVE), p = pick.p;
|
|
44
44
|
const incoming = scale(sub(at(pick.f), at(pick.f - 1)), fps), speed = len(incoming), mass = massOf({ objects: ctx.objects }, scene, id);
|
|
45
|
-
|
|
45
|
+
// the give's first frame takes 24% of it (1 - (5/6)^1.5): a cubic's 42% of a 3 kg ball's 0.15 m jolted the hands, POP (#819)
|
|
46
|
+
const give = clamp(0.08 + 0.02 * mass * speed, 0.08, 0.15), u = norm(incoming), ease = t => 1 - (1 - t) ** 1.5;
|
|
46
47
|
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
48
|
const start = Math.max(ctx.f0, contact - ARRIVE - LONG), both = side === "both" || mass > HEAVY;
|
|
48
49
|
// the left hand (both hands, or a heavy thing) on its left side, its finger middles across the thing from the right's
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { ko } from "../locale.js";
|
|
2
|
+
import { FINGER_PRESETS, fingerCurl } from "../motion/fingers.js";
|
|
3
|
+
|
|
4
|
+
const FINGER_LABELS = { Thumb: ["Thumb", "엄지"], Index: ["Index", "검지"], Middle: ["Middle", "중지"], Ring: ["Ring", "약지"], Pinky: ["Pinky", "새끼"] };
|
|
5
|
+
const PRESET_LABELS = { open: ["Open", "펴기"], fist: ["Fist", "주먹"], point: ["Pointing", "가리키기"] };
|
|
6
|
+
|
|
7
|
+
/** A slider previews while it moves and bakes once on release, the same gesture shape as a viewport drag. */
|
|
8
|
+
export default function FingerPanel({ fingers, tick, editing = false, onToggleEdit, onCurl, onCommit, onPreset }) {
|
|
9
|
+
if (!fingers.length) return null;
|
|
10
|
+
const side = fingers[0].side;
|
|
11
|
+
return (
|
|
12
|
+
<section className="pose-section" data-testid="finger-panel">
|
|
13
|
+
<h4>{side === "Left" ? ko("Left hand fingers", "왼손 손가락") : ko("Right hand fingers", "오른손 손가락")}</h4>
|
|
14
|
+
<div className="pose-actions">
|
|
15
|
+
<button type="button" className={"btn" + (editing ? " primary" : "")} aria-pressed={editing} data-testid="finger-edit-toggle" onClick={onToggleEdit}>
|
|
16
|
+
{editing ? ko("Editing fingertips", "손끝 편집 중") : ko("Edit fingertips", "손끝 편집")}
|
|
17
|
+
</button>
|
|
18
|
+
</div>
|
|
19
|
+
<div className="pose-actions">
|
|
20
|
+
{Object.keys(FINGER_PRESETS).map((name) => (
|
|
21
|
+
<button key={name} type="button" className="btn" data-testid={`finger-preset-${name}`} onClick={() => onPreset(name)}>{ko(...PRESET_LABELS[name])}</button>
|
|
22
|
+
))}
|
|
23
|
+
</div>
|
|
24
|
+
{fingers.map((finger) => (
|
|
25
|
+
<label key={`${finger.id}:${tick}`} className="pose-row pose-object-opacity">
|
|
26
|
+
<span>{ko(...FINGER_LABELS[finger.name])}</span>
|
|
27
|
+
<input type="range" min={0} max={100} step={1} defaultValue={Math.round(fingerCurl(finger) * 100)}
|
|
28
|
+
data-testid={`finger-curl-${finger.name.toLowerCase()}`} aria-label={ko(`${finger.name} bend`, `${FINGER_LABELS[finger.name][1]} 굽힘`)}
|
|
29
|
+
onChange={(event) => onCurl(finger.id, Number(event.target.value) / 100)}
|
|
30
|
+
onPointerUp={onCommit} onKeyUp={onCommit} />
|
|
31
|
+
</label>
|
|
32
|
+
))}
|
|
33
|
+
<p className="inspector-hint">{editing
|
|
34
|
+
? ko("Drag a fingertip dot to bend that finger. The hand's move and rotate tools come back when you leave fingertip editing.", "손끝 점을 끌면 그 손가락이 굽어요. 손끝 편집을 끄면 손 이동·회전 도구가 돌아와요.")
|
|
35
|
+
: ko("Turn on fingertip editing to drag fingertips in the viewport.", "손끝 편집을 켜면 뷰포트에서 손끝을 끌 수 있어요.")}</p>
|
|
36
|
+
</section>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -2,12 +2,13 @@ import Foldout from "./Foldout.jsx";
|
|
|
2
2
|
import { ko } from "../locale.js";
|
|
3
3
|
import { HIERARCHY_INSPECTOR_TITLES } from "../app-stage.jsx";
|
|
4
4
|
import { RangePinPanel } from "../range-pin-panel.jsx";
|
|
5
|
+
import FingerPanel from "./FingerPanel.jsx";
|
|
5
6
|
|
|
6
7
|
import "./pose.css";
|
|
7
8
|
|
|
8
9
|
export default function RigControlPanel({
|
|
9
10
|
isRigSelection, rigSelection, ikChains, ikFocus, footSnap, ikMode, poseObjectOpacity = 0.2, setPoseObjectOpacity,
|
|
10
|
-
motion, tlFrame, ikEditTool, setIkEditTool, pendingIkEdit = null, applyPendingIkEdit, cancelPendingIkEdit,
|
|
11
|
+
motion, tlFrame, ikEditTool, setIkEditTool, pendingIkEdit = null, applyPendingIkEdit, cancelPendingIkEdit, onResetPose, fingerPanel = null,
|
|
11
12
|
rangePins = [], rangePinResiduals = new Map(), rangePinSelection = null, rangePinPartPick = null,
|
|
12
13
|
rangePinPreview = null, objects = [], setRangePinSelection, setRangePinPartPick,
|
|
13
14
|
previewRangePinDraft, applyRangePinDraft, deleteRangePin,
|
|
@@ -27,6 +28,12 @@ export default function RigControlPanel({
|
|
|
27
28
|
onChange={(event) => setPoseObjectOpacity(Number(event.target.value) / 100)} />
|
|
28
29
|
<b>{Math.round(poseObjectOpacity * 100)}%</b>
|
|
29
30
|
</div>}
|
|
31
|
+
{onResetPose && <div className="pose-actions">
|
|
32
|
+
<button type="button" className="btn" data-testid="pose-reset-default" onClick={onResetPose}
|
|
33
|
+
title={motion ? ko("Remove every IK edit; the motion plays as it was", "IK 수정을 모두 지워요. 모션은 원래대로 재생돼요") : ko("Back to the default pose", "기본 포즈로 되돌려요")}>
|
|
34
|
+
{motion ? ko("Clear pose edits", "포즈 수정 지우기") : ko("Reset to default pose", "기본 포즈로")}
|
|
35
|
+
</button>
|
|
36
|
+
</div>}
|
|
30
37
|
<p className="inspector-hint">{rigSelection && rigSelection.token !== "rig" ? ko(`${HIERARCHY_INSPECTOR_TITLES[rigSelection.token]} is the active control group.`, `${HIERARCHY_INSPECTOR_TITLES[rigSelection.token]}이 활성 제어 그룹입니다.`, `${HIERARCHY_INSPECTOR_TITLES[rigSelection.token]}是当前控制组。`) : ko("Select a body group to pose it in the viewport.", "몸 그룹을 선택해 뷰포트에서 포즈를 잡으세요.")}</p>
|
|
31
38
|
</section>
|
|
32
39
|
{ikMode && <section className="pose-section" data-testid="pose-active-tool">
|
|
@@ -46,6 +53,7 @@ export default function RigControlPanel({
|
|
|
46
53
|
{ikEditTool === "pin" && <RangePinPanel active motion={motion} frame={tlFrame} frameCount={motion?.frames ?? 1} fps={motion?.fps ?? 24} pins={rangePins} residuals={rangePinResiduals} objects={objects} selectedPinId={rangePinSelection} partPick={rangePinPartPick} conflictFrames={rangePinPreview?.conflictFrames ?? []} overlapError={rangePinPreview?.overlapError ?? ""} onSelectPin={(id) => { setRangePinSelection?.(id); if (id) setIkEditTool("pin"); }} onApply={applyRangePinDraft} onCancel={() => { setRangePinSelection?.(null); setIkEditTool("ik"); }} onDelete={deleteRangePin} onPreviewTarget={previewRangePinDraft} />}
|
|
47
54
|
{ikEditTool === "ik" && <p className="inspector-hint">{ko("Grab a hand, foot, elbow or knee handle in the viewport.", "뷰포트에서 손·발·팔꿈치·무릎 핸들을 잡으세요.")}</p>}
|
|
48
55
|
</section>}
|
|
56
|
+
{ikMode && ikEditTool === "ik" && fingerPanel && <FingerPanel {...fingerPanel} />}
|
|
49
57
|
</div>
|
|
50
58
|
</Foldout>;
|
|
51
59
|
}
|
|
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
|
|
|
2
2
|
import Foldout from "./Foldout.jsx";
|
|
3
3
|
import { ko } from "../locale.js";
|
|
4
4
|
import { effortOptions } from "../workflow/agent-client.js"; import { saveExport } from "../export-workspace.js";
|
|
5
|
+
import SculptPartTree from "./SculptPartTree.jsx";
|
|
5
6
|
import "./sculpt.css";
|
|
6
7
|
|
|
7
8
|
// Sculpt mode side panel (#739). Pure view: the run controller owns the run state and the
|
|
@@ -202,8 +203,7 @@ function ModelPicker({ picker, locked }) {
|
|
|
202
203
|
function BlueprintSection({ blueprint, disabled }) {
|
|
203
204
|
if (!blueprint) return null;
|
|
204
205
|
const { state = "idle", pose = "keep", notes = "", prompt = "", sheet = null, error = null, startedAt = null, refusal = null, onPose, onNotes, onGenerate, onCancel, layout = [] } = blueprint;
|
|
205
|
-
const busy = state === "generating";
|
|
206
|
-
const waited = busy && startedAt ? Math.floor((Date.now() - startedAt) / 1000) : 0;
|
|
206
|
+
const busy = state === "generating", waited = busy && startedAt ? Math.floor((Date.now() - startedAt) / 1000) : 0;
|
|
207
207
|
return (
|
|
208
208
|
<div className="sculpt-blueprint" data-testid="sculpt-blueprint" data-state={state}>
|
|
209
209
|
<div className="sculpt-label"><span>{ko("Blueprint (model sheet)", "도면 (모델 시트)")}</span><small>{ko("front · side · back · top", "정면 · 측면 · 뒷면 · 윗면")}</small></div>
|
|
@@ -224,13 +224,15 @@ function BlueprintSection({ blueprint, disabled }) {
|
|
|
224
224
|
<span className="sculpt-now-expect">{ko("usually ~40–90s", "보통 ~40–90초")}</span>
|
|
225
225
|
<button type="button" className="btn ghost small" data-testid="sculpt-blueprint-cancel" onClick={() => onCancel?.()}>{ko("Cancel", "취소")}</button>
|
|
226
226
|
</div>
|
|
227
|
-
) : (
|
|
228
|
-
|
|
227
|
+
) : !refusal && (
|
|
228
|
+
// Without a ChatGPT token there is no button: the refusal below says why (#970).
|
|
229
|
+
<button type="button" className="btn full" data-testid="sculpt-blueprint-generate" disabled={disabled} onClick={() => onGenerate?.()}>
|
|
229
230
|
{sheet ? ko("Generate again", "다시 생성") : ko("Generate blueprint", "도면 생성")}
|
|
230
231
|
</button>
|
|
231
232
|
)}
|
|
232
233
|
{refusal && !busy && <p className="inspector-hint" role="status" data-testid="sculpt-blueprint-refusal">{refusal}</p>}
|
|
233
234
|
{error && !busy && <p className="inspector-hint sculpt-error" role="alert" data-testid="sculpt-blueprint-error">{error}</p>}
|
|
235
|
+
{sheet?.notices?.length > 0 && !busy && <p className="inspector-hint" role="status" data-testid="sculpt-blueprint-notices">{sheet.notices.join(" ")}</p>}
|
|
234
236
|
{sheet && (
|
|
235
237
|
<figure className="sculpt-sheet-figure" data-testid="sculpt-blueprint-sheet">
|
|
236
238
|
<a href={sheet.dataUrl} target="_blank" rel="noreferrer" title={ko("Open full size", "원본 크기로 열기")}>
|
|
@@ -255,7 +257,7 @@ function BlueprintSection({ blueprint, disabled }) {
|
|
|
255
257
|
);
|
|
256
258
|
}
|
|
257
259
|
|
|
258
|
-
export default function SculptPanel({ run, reference, onReference, onStart, onCancel, onPlaceInScene, canStart, startRefusal, sculptName, hidden, modelPicker = null, blueprint = null }) {
|
|
260
|
+
export default function SculptPanel({ run, reference, onReference, onStart, onCancel, onPlaceInScene, onSaveToShelf, canStart, startRefusal, sculptName, hidden, modelPicker = null, blueprint = null, parts = null }) {
|
|
259
261
|
const fileRef = useRef(null);
|
|
260
262
|
const [dragging, setDragging] = useState(false);
|
|
261
263
|
// The sheet is the clearest picture of what is off, so it starts open.
|
|
@@ -266,8 +268,6 @@ export default function SculptPanel({ run, reference, onReference, onStart, onCa
|
|
|
266
268
|
const stageIndex = STAGES.findIndex(([id]) => id === stage);
|
|
267
269
|
const iou = Number.isFinite(run?.iou) ? run.iou : null;
|
|
268
270
|
const target = Number.isFinite(run?.targetIou) ? run.targetIou : null;
|
|
269
|
-
const showProgress = stage !== "idle";
|
|
270
|
-
const startDisabled = !canStart || Boolean(startRefusal);
|
|
271
271
|
const hasLog = Boolean(run?.activity?.length);
|
|
272
272
|
useTicking((running && Boolean(run?.waiting)) || blueprint?.state === "generating");
|
|
273
273
|
|
|
@@ -327,14 +327,15 @@ export default function SculptPanel({ run, reference, onReference, onStart, onCa
|
|
|
327
327
|
{ko("Cancel", "취소")}
|
|
328
328
|
</button>
|
|
329
329
|
) : (
|
|
330
|
-
<button type="button" className="btn primary full" data-testid="sculpt-start" disabled={
|
|
330
|
+
<button type="button" className="btn primary full" data-testid="sculpt-start" disabled={!canStart || Boolean(startRefusal)}
|
|
331
331
|
aria-describedby={startRefusal ? "sculpt-start-refusal" : undefined} onClick={() => onStart?.()}>
|
|
332
332
|
{ko("Start (fast draft)", "시작 (빠른 초안)")}
|
|
333
333
|
</button>
|
|
334
334
|
)}
|
|
335
335
|
{!running && startRefusal && <p className="inspector-hint sculpt-refusal" id="sculpt-start-refusal" role="status" data-testid="sculpt-start-refusal">{startRefusal}</p>}
|
|
336
|
+
{sculptName && <SculptPartTree {...parts} />}
|
|
336
337
|
|
|
337
|
-
{
|
|
338
|
+
{stage !== "idle" && (
|
|
338
339
|
<div className="sculpt-progress" data-testid="sculpt-progress">
|
|
339
340
|
<ol className="sculpt-stepper" aria-label={ko("Sculpt stages", "조형 단계")}>
|
|
340
341
|
{STAGES.map(([id, label], index) => {
|
|
@@ -392,9 +393,8 @@ export default function SculptPanel({ run, reference, onReference, onStart, onCa
|
|
|
392
393
|
|
|
393
394
|
<button type="button" className="btn full" data-testid="sculpt-place" disabled={!sculptName || running}
|
|
394
395
|
aria-label={sculptName ? ko(`Place ${sculptName} in scene`, `${sculptName} 장면에 배치`, `将 ${sculptName} 放入场景`) : ko("Place in scene", "장면에 배치")}
|
|
395
|
-
onClick={() => onPlaceInScene?.()}>
|
|
396
|
-
|
|
397
|
-
</button>
|
|
396
|
+
onClick={() => onPlaceInScene?.()}>{ko("Place in scene", "장면에 배치")}</button>
|
|
397
|
+
<button type="button" className="btn ghost full" data-testid="sculpt-save-shelf" disabled={!sculptName || running} title={ko("Keep a copy on the asset shelf (Content › Sculpts), for any scene or project", "에셋 선반(콘텐츠 › 조형물)에 사본을 보관해 어느 씬·프로젝트에서나 씁니다")} onClick={() => onSaveToShelf?.()}>{ko("Save to shelf", "선반에 저장", "保存到架子")}</button>
|
|
398
398
|
|
|
399
399
|
<p className="inspector-hint sculpt-hint" data-testid="sculpt-hint">
|
|
400
400
|
{ko("Fix details by chatting with the Agent (⌘B) while the sculpt is selected.", "조형물을 선택한 채 에이전트(⌘B)와 대화해 디테일을 고치세요.")}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
import { ko } from "../locale.js";
|
|
3
|
+
|
|
4
|
+
// The sculpt's parts as an indented tree (#740 S7). Pure view: Sculpt mode
|
|
5
|
+
// owns the selection; a row press selects that part, the same state a click
|
|
6
|
+
// on the turntable sets. A mirrored part is listed once, as the recipe has it.
|
|
7
|
+
// `notice` is the part gizmo's last refusal (#740 S8), shown under the list.
|
|
8
|
+
export default function SculptPartTree({ tree, selected, onSelect, notice = null }) {
|
|
9
|
+
const listRef = useRef(null);
|
|
10
|
+
const selectedId = selected?.partId ?? null;
|
|
11
|
+
// A pick on the turntable brings its row into view.
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
if (selectedId) listRef.current?.querySelector("[aria-pressed='true']")?.scrollIntoView?.({ block: "nearest" });
|
|
14
|
+
}, [selectedId]);
|
|
15
|
+
if (!tree?.length) return null;
|
|
16
|
+
return (
|
|
17
|
+
<div className="sculpt-parts" data-testid="sculpt-part-tree">
|
|
18
|
+
<div className="sculpt-label">
|
|
19
|
+
<span>{ko("Parts", "부품", "部件")}</span>
|
|
20
|
+
<small title={selectedId ? ko("W move · E rotate · R scale (Shift: uniform) · Esc to deselect", "W 이동 · E 회전 · R 크기 (Shift: 균등) · Esc로 선택 해제", "W 移动 · E 旋转 · R 缩放(Shift:等比)· 按 Esc 取消选择") : undefined}>
|
|
21
|
+
{selectedId ? ko("W move · E rotate · R scale", "W 이동 · E 회전 · R 크기", "W 移动 · E 旋转 · R 缩放") : ko(`${tree.length} parts`, `부품 ${tree.length}개`, `${tree.length} 个部件`)}
|
|
22
|
+
</small>
|
|
23
|
+
</div>
|
|
24
|
+
<ul className="sculpt-part-list" ref={listRef} aria-label={ko("Sculpt parts", "조형 부품", "雕塑部件")}>
|
|
25
|
+
{tree.map((row) => {
|
|
26
|
+
const on = row.id === selectedId;
|
|
27
|
+
return (
|
|
28
|
+
<li key={row.id}>
|
|
29
|
+
<button type="button" className="sculpt-part-row" data-testid="sculpt-part-row" data-part={row.id} aria-pressed={on}
|
|
30
|
+
data-mirrored={on && selected.mirrored ? "true" : undefined} style={{ "--sculpt-part-depth": row.depth }}
|
|
31
|
+
// A cutter is never drawn (it cuts its parent): nothing on the turntable to pick or move.
|
|
32
|
+
disabled={row.cutter} data-cutter={row.cutter || undefined}
|
|
33
|
+
title={row.cutter ? ko("A cutter is not drawn: it cuts its parent. Ask the agent to change it.", "커터는 그려지지 않아요. 부모 부품을 깎아요. 바꾸려면 에이전트에게 요청하세요.", "切割体不会被绘制,它用于切削父部件。如需修改,请让智能体处理。") : undefined}
|
|
34
|
+
onClick={() => onSelect?.({ partId: row.id, mirrored: false })}>
|
|
35
|
+
<span className="sculpt-part-id">{row.id}</span>
|
|
36
|
+
{on && selected.mirrored && <span className="sculpt-part-tag">{ko("mirror copy", "미러 사본", "镜像副本")}</span>}
|
|
37
|
+
<span className="sculpt-part-shape">{row.mirror ? `${row.shape} ×2` : row.shape}</span>
|
|
38
|
+
</button>
|
|
39
|
+
</li>
|
|
40
|
+
);
|
|
41
|
+
})}
|
|
42
|
+
</ul>
|
|
43
|
+
{notice && <p className="inspector-hint sculpt-error" role="alert" data-testid="sculpt-part-notice">{notice}</p>}
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -17,7 +17,7 @@ export default function SubjectsPanel({
|
|
|
17
17
|
label={ko(`Subject ${index + 1}`, `인물 ${index + 1}`, `角色 ${index + 1}`)}
|
|
18
18
|
onPose={() => openStudio(entry.id)}
|
|
19
19
|
posing={posing === entry.id}
|
|
20
|
-
onRemove={
|
|
20
|
+
onRemove={() => run('character.remove', { characterId: entry.id })}
|
|
21
21
|
color={entry.tint ?? defaultCharacterTint(entry, index)}
|
|
22
22
|
/* A colour picker streams values while it is open, so the
|
|
23
23
|
whole picking session is one Ctrl+Z entry. */
|
package/src/panels/sculpt.css
CHANGED
|
@@ -111,6 +111,17 @@
|
|
|
111
111
|
.sculpt-race-row[data-leader="true"] .sculpt-race-label::before { content: "\2605 "; color: var(--ok); }
|
|
112
112
|
.sculpt-race-row[data-refused="true"] { opacity: .5; text-decoration: line-through; }
|
|
113
113
|
|
|
114
|
+
/* Part tree (#740 S7) */
|
|
115
|
+
.sculpt-parts { display: grid; gap: 4px; }
|
|
116
|
+
.sculpt-part-list { display: grid; gap: 1px; max-height: 200px; margin: 0; padding: 2px; list-style: none; overflow-y: auto; overscroll-behavior: contain; border-radius: var(--r-2); border: 1px solid var(--row-divider); background: var(--field); }
|
|
117
|
+
.sculpt-part-row { display: flex; align-items: center; gap: 6px; width: 100%; min-height: 24px; padding: 2px 6px 2px calc(6px + var(--sculpt-part-depth) * 12px); border: 0; border-radius: var(--r-1); background: transparent; color: var(--text-2); font: inherit; font-size: 11px; text-align: left; cursor: pointer; }
|
|
118
|
+
.sculpt-part-row:hover { background: var(--surface-active); color: var(--text-1); }
|
|
119
|
+
.sculpt-part-row:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--select-tint); }
|
|
120
|
+
.sculpt-part-row[aria-pressed="true"] { background: var(--select-tint); color: var(--text-1); box-shadow: inset 2px 0 0 var(--select); }
|
|
121
|
+
.sculpt-part-id { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--mono); }
|
|
122
|
+
.sculpt-part-tag { flex: none; padding: 0 4px; border-radius: var(--r-1); background: var(--select); color: var(--surface-raised); font-size: 10px; }
|
|
123
|
+
.sculpt-part-shape { flex: none; margin-left: auto; color: var(--text-3); }
|
|
124
|
+
|
|
114
125
|
/* Activity log */
|
|
115
126
|
.sculpt-log { display: grid; gap: 2px; max-height: 168px; margin: 0; padding: 4px 2px; list-style: none; overflow-y: auto; overscroll-behavior: contain; scroll-behavior: smooth; }
|
|
116
127
|
.sculpt-log-row { display: grid; grid-template-columns: 30px 8px minmax(0, 1fr); align-items: baseline; gap: 6px; font-size: 11px; line-height: 1.4; color: var(--text-2); animation: sculpt-in .35s var(--ease) both; }
|
|
@@ -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);
|