cozyclay 3.2.0 → 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 +87 -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 +16 -13
- package/bin/agent/agent-stream.mjs +26 -3
- package/bin/agent/main-prompt.mjs +1 -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-CnDkB5WO.css +1 -0
- package/dist/assets/app-DJt06Oyb.js +5144 -0
- package/dist/assets/{compile-worker-DRWRa9pr.js → compile-worker-D1pl6G7x.js} +5 -5
- 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 +2 -1
- package/src/App.jsx +37 -70
- 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/motion3.js +3 -1
- package/src/commands/objects.js +2 -37
- 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 +6 -5
- 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/find-bone.js +13 -0
- package/src/motion/fingers.js +170 -0
- package/src/motion/fingertip-pick.js +21 -0
- package/src/motion/ik-exposure.js +48 -0
- package/src/motion/ik.js +4 -11
- package/src/motion3/compiler/head-lead.js +4 -2
- package/src/motion3/hybrid-hint.js +19 -0
- package/src/motion3/load.js +19 -12
- package/src/motion3/qa/hints-contact.js +12 -0
- package/src/motion3/qa/patch.js +4 -1
- package/src/motion3/qa/verdict.js +7 -2
- package/src/motion3/repair.js +28 -8
- 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/pose-fade.js +7 -3
- package/src/posestudio.jsx +14 -27
- 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 +45 -69
- 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-BUjjuvm5.js +0 -1
- package/dist/assets/app-Dige7q3X.css +0 -1
- package/dist/assets/app-DxoI941o.js +0 -5144
- package/dist/assets/motion3-compile-B4SX4TGx.js +0 -1
- package/dist/assets/rolldown-runtime-hePW80VL.js +0 -1
- package/dist/assets/scene-asset-sync-LeI3eSnO.js +0 -13
- package/dist/assets/task-board-5PKn7ZaA.js +0 -15
- package/dist/assets/workflow-Dg9jCKOq.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
|
}
|
|
@@ -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; }
|
package/src/pose-fade.js
CHANGED
|
@@ -15,6 +15,8 @@ export function writePoseFadeOpacity(value, storage = globalThis.localStorage) {
|
|
|
15
15
|
// Keyed by material: a material shared by several meshes is recorded once, so the restore is exact.
|
|
16
16
|
const originals = new WeakMap();
|
|
17
17
|
const noRaycast = () => {};
|
|
18
|
+
// Keyed by mesh, not userData: R3F replaces an inline userData prop on re-render (#974).
|
|
19
|
+
const savedRaycasts = new WeakMap();
|
|
18
20
|
|
|
19
21
|
function materialsOf(mesh) {
|
|
20
22
|
return Array.isArray(mesh.material) ? mesh.material : mesh.material ? [mesh.material] : [];
|
|
@@ -67,11 +69,13 @@ export function applyPoseFade(root, fade, own = 1) {
|
|
|
67
69
|
}
|
|
68
70
|
}
|
|
69
71
|
if (unpickable && node.raycast !== noRaycast) {
|
|
70
|
-
node
|
|
72
|
+
if (Object.hasOwn(node, "raycast")) savedRaycasts.set(node, node.raycast);
|
|
71
73
|
node.raycast = noRaycast;
|
|
72
74
|
} else if (!unpickable && node.raycast === noRaycast) {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
+
// Nothing saved means the prototype's method was in use: drop the own property.
|
|
76
|
+
if (savedRaycasts.has(node)) node.raycast = savedRaycasts.get(node);
|
|
77
|
+
else delete node.raycast;
|
|
78
|
+
savedRaycasts.delete(node);
|
|
75
79
|
}
|
|
76
80
|
});
|
|
77
81
|
}
|
package/src/posestudio.jsx
CHANGED
|
@@ -6,6 +6,7 @@ import { POSE_BONES, normalizeBoneName, primeBindPose } from "./poses.js";
|
|
|
6
6
|
import { poseThumbnail, warmThumbnailModels } from "./pose-thumbs.js";
|
|
7
7
|
import { IK_TRACKS, MID_TRACKS, ikControlIsExposed } from "./motion/ik.js";
|
|
8
8
|
import { pickIkHit } from "./motion/ik-pick.js";
|
|
9
|
+
import { blockerDistanceTo, exposureProbe } from "./motion/ik-exposure.js";
|
|
9
10
|
import { shouldRefreshIkExposure } from "./use-render-activity.js";
|
|
10
11
|
import { POSER_LAYER } from "./dualview.jsx";
|
|
11
12
|
import { ko } from "./locale.js";
|
|
@@ -283,7 +284,7 @@ const FK_ROTATE_SPEED = 5;
|
|
|
283
284
|
* Every drag move calls `onSolve(kind, trackId, targetWorld)`; the drag end
|
|
284
285
|
* calls `onDragEnd(trackId)` so the caller can key it.
|
|
285
286
|
*/
|
|
286
|
-
export function IkHandles({ chains, fkJoints, ikState, enabled, focus, onFocus, onSolve, onDragEnd, selectionOnly = false, onPartPick }) {
|
|
287
|
+
export function IkHandles({ chains, fkJoints, ikState, enabled, focus, onFocus, onSolve, onDragEnd, selectionOnly = false, onPartPick, focusToolsHidden = false, exposureProbeRef }) {
|
|
287
288
|
const { camera, gl, raycaster, scene } = useThree();
|
|
288
289
|
const handleRefs = useRef({}); // id -> mesh (all sphere handles)
|
|
289
290
|
const handleMetaRef = useRef(new Map()); // id -> { track, radius }
|
|
@@ -298,6 +299,9 @@ export function IkHandles({ chains, fkJoints, ikState, enabled, focus, onFocus,
|
|
|
298
299
|
const blockerRefs = useRef([]);
|
|
299
300
|
const skinnedBlockerProxiesRef = useRef(new Map());
|
|
300
301
|
const occlusionRay = useRef(new THREE.Raycaster()).current;
|
|
302
|
+
// The last pass's blockers, so the fingertip handles can ask the same exposure question (#913).
|
|
303
|
+
const occludersRef = useRef(null);
|
|
304
|
+
useEffect(() => { if (exposureProbeRef) exposureProbeRef.current = exposureProbe(camera, occludersRef); }, [exposureProbeRef, camera]);
|
|
301
305
|
const gizmoRef = useRef(null);
|
|
302
306
|
const ringRef = useRef(null); // rotation rings around the focused joint
|
|
303
307
|
const readoutRef = useRef(null);
|
|
@@ -679,28 +683,9 @@ export function IkHandles({ chains, fkJoints, ikState, enabled, focus, onFocus,
|
|
|
679
683
|
mesh.getWorldPosition(tmp.viewPoint);
|
|
680
684
|
const targetDistance = tmp.cameraPos.distanceTo(tmp.viewPoint);
|
|
681
685
|
tmp.rayDirection.copy(tmp.viewPoint).sub(tmp.cameraPos).normalize();
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
let blockerDistance = occlusionRay.intersectObjects(staticBlockers, false)[0]?.distance;
|
|
686
|
-
// A coarse cloud of baked skin-surface spheres preserves the
|
|
687
|
-
// actual animated silhouette without asking Three.js to skin
|
|
688
|
-
// every triangle once per control ray. Camera-only changes now
|
|
689
|
-
// test a few cached points; pose changes rebuild the cloud once.
|
|
690
|
-
for (const proxy of skinnedProxies) {
|
|
691
|
-
const radiusSq = proxy.radius * proxy.radius;
|
|
692
|
-
for (const point of proxy.points) {
|
|
693
|
-
const dx = point.x - tmp.cameraPos.x;
|
|
694
|
-
const dy = point.y - tmp.cameraPos.y;
|
|
695
|
-
const dz = point.z - tmp.cameraPos.z;
|
|
696
|
-
const along = dx * tmp.rayDirection.x + dy * tmp.rayDirection.y + dz * tmp.rayDirection.z;
|
|
697
|
-
if (along < 0 || along > targetDistance + proxy.radius) continue;
|
|
698
|
-
const perpendicularSq = dx * dx + dy * dy + dz * dz - along * along;
|
|
699
|
-
if (perpendicularSq > radiusSq) continue;
|
|
700
|
-
const distance = Math.max(0, along - Math.sqrt(radiusSq - perpendicularSq));
|
|
701
|
-
if (distance < (blockerDistance ?? Infinity)) blockerDistance = distance;
|
|
702
|
-
}
|
|
703
|
-
}
|
|
686
|
+
// A coarse cloud of baked skin-surface spheres preserves the animated
|
|
687
|
+
// silhouette; camera-only changes test a few cached points, pose changes rebuild the cloud once.
|
|
688
|
+
const blockerDistance = blockerDistanceTo(occlusionRay, tmp.cameraPos, tmp.rayDirection, targetDistance, staticBlockers, skinnedProxies);
|
|
704
689
|
const visibilityDepth = meta?.track.visibilityDepth ?? (meta?.radius ?? JOINT_R) * 2;
|
|
705
690
|
const isExposed = ikControlIsExposed(targetDistance, blockerDistance, visibilityDepth);
|
|
706
691
|
visibilityDetailRef.current.set(id, {
|
|
@@ -719,6 +704,7 @@ export function IkHandles({ chains, fkJoints, ikState, enabled, focus, onFocus,
|
|
|
719
704
|
tmp.viewPoint.applyMatrix4(camera.matrixWorldInverse);
|
|
720
705
|
exposureDepthRef.current.set(id, -tmp.viewPoint.z);
|
|
721
706
|
}
|
|
707
|
+
occludersRef.current = { blockers: [...blockers] };
|
|
722
708
|
exposurePerfRef.current.passes += 1;
|
|
723
709
|
exposurePerfRef.current.raycasts += Object.keys(handleRefs.current).length;
|
|
724
710
|
exposurePerfRef.current.lastPassMs = performance.now() - passStartedAt;
|
|
@@ -757,7 +743,8 @@ export function IkHandles({ chains, fkJoints, ikState, enabled, focus, onFocus,
|
|
|
757
743
|
}
|
|
758
744
|
// A focused control keeps its gizmo and rings even when the body hides it:
|
|
759
745
|
// the user picked it, and while focused nothing else can take a press.
|
|
760
|
-
|
|
746
|
+
// Finger editing hands the focused hand's space to the fingertip handles: no gizmo, rings or readout.
|
|
747
|
+
const focusExposed = !!focus && !focusToolsHidden;
|
|
761
748
|
if (gizmoRef.current) gizmoRef.current.visible = focusExposed;
|
|
762
749
|
if (ringRef.current) ringRef.current.visible = focusExposed;
|
|
763
750
|
|
|
@@ -947,7 +934,7 @@ const CLICK_PX = 4;
|
|
|
947
934
|
handleRefs.current[p.track.id]?.visible &&
|
|
948
935
|
(handleRefs.current[p.track.id]?.userData.ikExposed !== false || !p.part || p.track.id === focused)
|
|
949
936
|
);
|
|
950
|
-
if (focused && !selectionOnly) picks = picks.filter((p) => p.track.id === focused);
|
|
937
|
+
if (focused && !selectionOnly) picks = picks.filter((p) => p.track.id === focused && !(focusToolsHidden && (p.axisDir || p.part)));
|
|
951
938
|
else picks = picks.filter((p) => !p.axisDir); // spheres only
|
|
952
939
|
if (!picks.length) {
|
|
953
940
|
if (focused) beginDrag("empty", { id: "empty" }, null, null, downXY);
|
|
@@ -1059,7 +1046,7 @@ const CLICK_PX = 4;
|
|
|
1059
1046
|
<group>
|
|
1060
1047
|
{IK_TRACKS.map((track) => (chains.has(track.id) ? sphereFor(track, "chain", sideColor(track.id), HANDLE_R) : null))}
|
|
1061
1048
|
{MID_TRACKS.map((track) => (chains.has(track.chain) ? sphereFor(track, "mid", sideColor(track.id), JOINT_R) : null))}
|
|
1062
|
-
{[...(fkJoints ?? [])].map(([id, joint]) => sphereFor(joint.track, id === "hips" ? "body" : "fk", sideColor(id), id === "hips" ? HANDLE_R : JOINT_R))}
|
|
1049
|
+
{[...(fkJoints ?? [])].filter(([, joint]) => !joint.track.finger).map(([id, joint]) => sphereFor(joint.track, id === "hips" ? "body" : "fk", sideColor(id), id === "hips" ? HANDLE_R : JOINT_R))}
|
|
1063
1050
|
|
|
1064
1051
|
{gizmoTrack && (
|
|
1065
1052
|
<group ref={gizmoRef} renderOrder={999}>
|
|
@@ -1141,7 +1128,7 @@ const CLICK_PX = 4;
|
|
|
1141
1128
|
})}
|
|
1142
1129
|
</group>
|
|
1143
1130
|
)}
|
|
1144
|
-
{rotateTrack && rotationDeg && (
|
|
1131
|
+
{rotateTrack && rotationDeg && !focusToolsHidden && (
|
|
1145
1132
|
<group ref={readoutRef}>
|
|
1146
1133
|
<Html zIndexRange={[20, 0]} style={{ pointerEvents: "none" }}>
|
|
1147
1134
|
<RotationReadout readout={rotationReadout(rotationDeg, rotationDrag)} />
|
package/src/project-browser.css
CHANGED
|
@@ -215,6 +215,12 @@
|
|
|
215
215
|
box-shadow: inset 0 0 0 var(--start-border-width, 1px) var(--row-divider, #1d1d20);
|
|
216
216
|
}
|
|
217
217
|
|
|
218
|
+
/* Cast style: a three-up switch under the mode switch. */
|
|
219
|
+
.v2-start-screen .v2-start-mode.cast {
|
|
220
|
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
221
|
+
margin-top: var(--sp-1, 8px);
|
|
222
|
+
}
|
|
223
|
+
|
|
218
224
|
.v2-start-screen .v2-start-mode-option {
|
|
219
225
|
min-width: var(--start-template-min-width, 0);
|
|
220
226
|
display: flex;
|
package/src/project-browser.jsx
CHANGED
|
@@ -13,8 +13,10 @@ import {
|
|
|
13
13
|
requestHandlePermission,
|
|
14
14
|
storeProjectsDirectory,
|
|
15
15
|
DEFAULT_PREVIS_MODE,
|
|
16
|
+
DEFAULT_CAST_STYLE,
|
|
16
17
|
} from "./project.js";
|
|
17
18
|
import { BrandMark, TemplateThumb } from "./project-browser-art.jsx";
|
|
19
|
+
import { CAST_OPTIONS, StartChoiceGroup } from "./project-start-choices.jsx";
|
|
18
20
|
import "./project-browser.css";
|
|
19
21
|
|
|
20
22
|
const APP_VERSION = packageInfo.version;
|
|
@@ -105,6 +107,7 @@ export function ProjectNameDialog({ open, initialName = "My Project", onCancel,
|
|
|
105
107
|
export default function ProjectBrowser({ currentName, onOpen, onOpenFile, onNew, onClose, startup = false, starters = [], onStarter }) {
|
|
106
108
|
const starterTemplates = useMemo(() => starters.map(sampleTemplate), [starters]);
|
|
107
109
|
const [previsMode, setPrevisMode] = useState(DEFAULT_PREVIS_MODE);
|
|
110
|
+
const [castStyle, setCastStyle] = useState(DEFAULT_CAST_STYLE);
|
|
108
111
|
const templates = previsMode === "storyboard" ? [] : starterTemplates;
|
|
109
112
|
const defaultTemplate = starterTemplates[0] ?? NEW_PROJECT;
|
|
110
113
|
const [activeNav, setActiveNav] = useState(startup ? "new" : "recent");
|
|
@@ -211,11 +214,11 @@ export default function ProjectBrowser({ currentName, onOpen, onOpenFile, onNew,
|
|
|
211
214
|
const projectName = name.trim();
|
|
212
215
|
if (!projectName) return;
|
|
213
216
|
if (selectedTemplate?.starterId) {
|
|
214
|
-
onStarter?.(selectedTemplate.starterId, projectName, { previsMode: "animation" });
|
|
217
|
+
onStarter?.(selectedTemplate.starterId, projectName, { previsMode: "animation", castStyle });
|
|
215
218
|
return;
|
|
216
219
|
}
|
|
217
220
|
stagePendingProjectName(projectName);
|
|
218
|
-
onNew(projectName, { previsMode });
|
|
221
|
+
onNew(projectName, { previsMode, castStyle });
|
|
219
222
|
};
|
|
220
223
|
|
|
221
224
|
const recentEntries = [
|
|
@@ -260,22 +263,10 @@ export default function ProjectBrowser({ currentName, onOpen, onOpenFile, onNew,
|
|
|
260
263
|
{activeNav !== "new" && <button type="button" className="v2-start-close x" onClick={onClose} aria-label={ko("Close", "닫기")}>×</button>}
|
|
261
264
|
</div>
|
|
262
265
|
{activeNav === "new" && (
|
|
263
|
-
|
|
264
|
-
{
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
key={option.id}
|
|
268
|
-
role="radio"
|
|
269
|
-
aria-checked={previsMode === option.id}
|
|
270
|
-
className={`v2-start-mode-option${previsMode === option.id ? " active" : ""}`}
|
|
271
|
-
data-previs-mode={option.id}
|
|
272
|
-
onClick={() => choosePrevisMode(option.id)}
|
|
273
|
-
>
|
|
274
|
-
<strong>{option.label()}</strong>
|
|
275
|
-
<span>{option.blurb()}</span>
|
|
276
|
-
</button>
|
|
277
|
-
))}
|
|
278
|
-
</div>
|
|
266
|
+
<>
|
|
267
|
+
<StartChoiceGroup label={ko("Project mode", "프로젝트 모드")} testId="start-previs-mode" dataKey="previs-mode" options={MODE_OPTIONS} value={previsMode} onChange={choosePrevisMode} />
|
|
268
|
+
<StartChoiceGroup label={ko("Cast style", "캐스트 방식")} testId="start-cast-style" dataKey="cast-style" options={CAST_OPTIONS} value={castStyle} onChange={setCastStyle} className="cast" />
|
|
269
|
+
</>
|
|
279
270
|
)}
|
|
280
271
|
</div>
|
|
281
272
|
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { ko } from "./locale.js";
|
|
2
|
+
|
|
3
|
+
// Which bodies a new project is cast with (castStyle in project.js). The rig
|
|
4
|
+
// is the detailed humanoid, the capsule the quick stand-in; hybrid mixes both
|
|
5
|
+
// and is the default. Either body stays reachable from Add in every style.
|
|
6
|
+
export const CAST_OPTIONS = [
|
|
7
|
+
{
|
|
8
|
+
id: "rig",
|
|
9
|
+
label: () => ko("Humanoid", "휴머노이드"),
|
|
10
|
+
blurb: () => ko("Detailed previs with X/Y Bot: pose, IK and motion.", "X/Y Bot으로 디테일한 프리비즈: 포즈, IK, 모션."),
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
id: "capsule",
|
|
14
|
+
label: () => ko("Capsule", "캡슐"),
|
|
15
|
+
blurb: () => ko("Simple previs with capsule figures: path and posture.", "캡슐 인물로 간단한 프리비즈: 경로와 자세."),
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
id: "hybrid",
|
|
19
|
+
label: () => ko("Hybrid", "하이브리드"),
|
|
20
|
+
blurb: () => ko("Mix both; the agent may use capsules for hard shots.", "둘을 섞어 씁니다. 어려운 샷은 에이전트가 캡슐을 쓸 수 있어요."),
|
|
21
|
+
},
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
// One segmented single-choice control on the New view. Each option carries
|
|
25
|
+
// `data-<dataKey>` with its id so tests and styles can address it.
|
|
26
|
+
export function StartChoiceGroup({ label, testId, dataKey, options, value, onChange, className = "" }) {
|
|
27
|
+
return (
|
|
28
|
+
<div className={`v2-start-mode${className ? ` ${className}` : ""}`} role="radiogroup" aria-label={label} data-testid={testId}>
|
|
29
|
+
{options.map((option) => (
|
|
30
|
+
<button
|
|
31
|
+
type="button"
|
|
32
|
+
key={option.id}
|
|
33
|
+
role="radio"
|
|
34
|
+
aria-checked={value === option.id}
|
|
35
|
+
className={`v2-start-mode-option${value === option.id ? " active" : ""}`}
|
|
36
|
+
{...{ [`data-${dataKey}`]: option.id }}
|
|
37
|
+
onClick={() => onChange(option.id)}
|
|
38
|
+
>
|
|
39
|
+
<strong>{option.label()}</strong>
|
|
40
|
+
<span>{option.blurb()}</span>
|
|
41
|
+
</button>
|
|
42
|
+
))}
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
}
|