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.
Files changed (185) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/INSTALL_FOR_AGENTS.md +4 -4
  3. package/README.md +4 -2
  4. package/bin/agent/agent-failure-codes.mjs +34 -8
  5. package/bin/agent/agent-routes.mjs +6 -6
  6. package/bin/agent/agent-runner.mjs +16 -13
  7. package/bin/agent/agent-stream.mjs +26 -3
  8. package/bin/agent/main-prompt.mjs +1 -1
  9. package/bin/agent/studio-prompt.mjs +2 -2
  10. package/bin/agent/supervisor-followup.mjs +34 -0
  11. package/bin/agent/supervisor.mjs +6 -6
  12. package/bin/cozyclay.mjs +23 -60
  13. package/bin/star-prompt.mjs +15 -14
  14. package/bin/studio-routes.mjs +6 -3
  15. package/bin/telemetry-state.mjs +5 -7
  16. package/bin/update-check.mjs +61 -7
  17. package/bin/update-install.mjs +16 -3
  18. package/bin/update-restart.mjs +95 -0
  19. package/bin/update-routes.mjs +25 -3
  20. package/dist/app/index.html +13 -7
  21. package/dist/assets/TaskBoard-tgO0MNKL.js +1 -0
  22. package/dist/assets/analytics-CaTUuLPM.js +1 -0
  23. package/dist/assets/app-CnDkB5WO.css +1 -0
  24. package/dist/assets/app-DJt06Oyb.js +5144 -0
  25. package/dist/assets/{compile-worker-DRWRa9pr.js → compile-worker-D1pl6G7x.js} +5 -5
  26. package/dist/assets/landing-2Fy7DU41.js +1 -0
  27. package/dist/assets/modulepreload-polyfill-P2Xu9kJm.js +1 -0
  28. package/dist/assets/motion3-compile-BxaM0Xwl.js +1 -0
  29. package/dist/assets/scene-asset-sync-CLGNbEBq.js +13 -0
  30. package/dist/assets/task-board-UVGTZLRF.js +15 -0
  31. package/dist/assets/workflow-CSiBlX1s.js +179 -0
  32. package/dist/cozyclay-package.json +1 -1
  33. package/dist/index.html +4 -1
  34. package/dist/privacy/index.html +9 -6
  35. package/dist/sitemap.xml +2 -2
  36. package/dist/workflow/index.html +5 -4
  37. package/mcp/README.md +1 -1
  38. package/mcp/live-hub.mjs +4 -4
  39. package/mcp/tool-handlers.mjs +14 -16
  40. package/package.json +2 -1
  41. package/src/App.jsx +37 -70
  42. package/src/analytics-controls.js +25 -3
  43. package/src/analytics-strict.js +16 -4
  44. package/src/analytics.js +32 -26
  45. package/src/app-version.js +13 -0
  46. package/src/asset-drop.js +39 -0
  47. package/src/asset-pane.css +34 -0
  48. package/src/asset-pane.jsx +4 -35
  49. package/src/bus-tasks.js +1 -1
  50. package/src/character-kind.js +8 -6
  51. package/src/click-friction.js +5 -4
  52. package/src/command-bus-telemetry.js +8 -7
  53. package/src/command-bus.js +2 -16
  54. package/src/commands/cast.js +2 -1
  55. package/src/commands/motion3.js +3 -1
  56. package/src/commands/objects.js +2 -37
  57. package/src/commands/project.js +3 -3
  58. package/src/commands/sculpt-compare.js +53 -0
  59. package/src/content-tile.jsx +36 -0
  60. package/src/domains/cast.js +7 -6
  61. package/src/domains/motion.js +6 -5
  62. package/src/domains/scenes.js +41 -31
  63. package/src/execution-telemetry.js +7 -3
  64. package/src/export-menu-telemetry.js +26 -0
  65. package/src/finger-handles.jsx +128 -0
  66. package/src/friction-hint.js +82 -0
  67. package/src/live-control.js +2 -1
  68. package/src/locale-zh/panels.js +20 -0
  69. package/src/locale-zh/shell.js +4 -0
  70. package/src/locale-zh/workspace.js +6 -0
  71. package/src/motion/find-bone.js +13 -0
  72. package/src/motion/fingers.js +170 -0
  73. package/src/motion/fingertip-pick.js +21 -0
  74. package/src/motion/ik-exposure.js +48 -0
  75. package/src/motion/ik.js +4 -11
  76. package/src/motion3/compiler/head-lead.js +4 -2
  77. package/src/motion3/hybrid-hint.js +19 -0
  78. package/src/motion3/load.js +19 -12
  79. package/src/motion3/qa/hints-contact.js +12 -0
  80. package/src/motion3/qa/patch.js +4 -1
  81. package/src/motion3/qa/verdict.js +7 -2
  82. package/src/motion3/repair.js +28 -8
  83. package/src/motion3/skills/catch.js +2 -1
  84. package/src/panels/FingerPanel.jsx +38 -0
  85. package/src/panels/RigControlPanel.jsx +9 -1
  86. package/src/panels/SculptPanel.jsx +12 -12
  87. package/src/panels/SculptPartTree.jsx +46 -0
  88. package/src/panels/SubjectsPanel.jsx +1 -1
  89. package/src/panels/sculpt.css +11 -0
  90. package/src/pose-fade.js +7 -3
  91. package/src/posestudio.jsx +14 -27
  92. package/src/project-browser.css +6 -0
  93. package/src/project-browser.jsx +9 -18
  94. package/src/project-start-choices.jsx +45 -0
  95. package/src/project.js +16 -12
  96. package/src/proxy-figure-shape.js +11 -0
  97. package/src/proxy-figure.jsx +25 -18
  98. package/src/scene-assets.js +17 -13
  99. package/src/scenes.js +22 -11
  100. package/src/sculpt-blueprint.js +56 -0
  101. package/src/sculpt-mode.js +5 -0
  102. package/src/sculpt-part-edit.js +115 -0
  103. package/src/sculpt-part-gizmo.jsx +159 -0
  104. package/src/sculpt-part-outline.jsx +72 -0
  105. package/src/sculpt-pick.js +53 -0
  106. package/src/sculpt-run-text.js +51 -0
  107. package/src/sculpt-run.js +10 -46
  108. package/src/sculpt-shelf-file.js +92 -0
  109. package/src/sculpt-shelf-tiles.jsx +69 -0
  110. package/src/sculpt-shelf.js +108 -0
  111. package/src/sculpt-stage.jsx +39 -2
  112. package/src/sculpt-thumb.js +72 -0
  113. package/src/sculpt-views.js +75 -0
  114. package/src/semantic-edit.js +0 -87
  115. package/src/settings-menu.jsx +2 -2
  116. package/src/shell/DetailsSlot.jsx +5 -6
  117. package/src/shell/LibrarySlot.jsx +8 -10
  118. package/src/shell/MenuBar.jsx +6 -6
  119. package/src/shell/PreferencesDialog.jsx +2 -3
  120. package/src/shell/ViewportToolbar.jsx +15 -18
  121. package/src/shell/add-character-entries.js +13 -0
  122. package/src/shell/pose-controls.js +32 -0
  123. package/src/shell/preference-pages.jsx +2 -11
  124. package/src/shell/storyboard-caption.js +21 -0
  125. package/src/shell/studio-shell-context.js +1 -0
  126. package/src/shortcut-friction.js +2 -1
  127. package/src/site/landing-analytics.js +28 -0
  128. package/src/studio-actions.js +3 -2
  129. package/src/studio-agent-protocol.js +4 -4
  130. package/src/studio-app-binding.js +4 -3
  131. package/src/update/UpdateChip.jsx +21 -2
  132. package/src/update/UpdateDialog.jsx +26 -6
  133. package/src/update/update-client.js +123 -17
  134. package/src/update/update-telemetry.js +15 -3
  135. package/src/use-sculpt-mode.js +62 -8
  136. package/src/workflow/agent-failure-codes.js +28 -9
  137. package/src/workflow/agent-turn-telemetry.js +1 -1
  138. package/src/workflow/task-board.js +2 -2
  139. package/tools/dev/page-shell.py +1 -1
  140. package/tools/dev/pages/greybox-to-video.html +21 -2
  141. package/tools/dev/pages/previs-software.html +3 -3
  142. package/tools/dev/pages/privacy.html +9 -6
  143. package/tools/dev/pages/seedance-camera-control.html +2 -2
  144. package/tools/loc-baseline.json +10 -10
  145. package/tools/run-tests.mjs +45 -69
  146. package/tools/select-tests.mjs +119 -0
  147. package/tools/test-durations.json +455 -0
  148. package/tools/test-graph.mjs +181 -0
  149. package/tools/test-shards.mjs +78 -0
  150. package/tools/tutorial/README.md +74 -0
  151. package/tools/tutorial/assemble.mjs +43 -0
  152. package/tools/tutorial/captions.mjs +43 -0
  153. package/tools/tutorial/cdp.mjs +55 -0
  154. package/tools/tutorial/cursor.mjs +159 -0
  155. package/tools/tutorial/episode.mjs +173 -0
  156. package/tools/tutorial/episodes/beginner-guide/00-install.mjs +42 -0
  157. package/tools/tutorial/episodes/beginner-guide/01-start.mjs +50 -0
  158. package/tools/tutorial/episodes/beginner-guide/02-layout.mjs +67 -0
  159. package/tools/tutorial/episodes/beginner-guide/03-stage.mjs +58 -0
  160. package/tools/tutorial/episodes/beginner-guide/04-motion.mjs +98 -0
  161. package/tools/tutorial/episodes/beginner-guide/05-camera.mjs +47 -0
  162. package/tools/tutorial/episodes/beginner-guide/06-export.mjs +59 -0
  163. package/tools/tutorial/episodes/beginner-guide/steps.mjs +32 -0
  164. package/tools/tutorial/episodes/beginner-guide.mjs +17 -0
  165. package/tools/tutorial/episodes/cast.mjs +12 -0
  166. package/tools/tutorial/episodes/first-video.mjs +121 -0
  167. package/tools/tutorial/episodes/stub-tour.mjs +97 -0
  168. package/tools/tutorial/launch.mjs +63 -0
  169. package/tools/tutorial/mux.mjs +61 -0
  170. package/tools/tutorial/overlay.mjs +142 -0
  171. package/tools/tutorial/record.mjs +156 -0
  172. package/tools/tutorial/scenes/terminal.html +63 -0
  173. package/tools/tutorial/steps.mjs +111 -0
  174. package/tools/tutorial/terminal.mjs +43 -0
  175. package/tools/tutorial/voice.mjs +49 -0
  176. package/tools/update-test-durations.mjs +42 -0
  177. package/tools/verify-package-tarball.mjs +18 -12
  178. package/dist/assets/TaskBoard-BUjjuvm5.js +0 -1
  179. package/dist/assets/app-Dige7q3X.css +0 -1
  180. package/dist/assets/app-DxoI941o.js +0 -5144
  181. package/dist/assets/motion3-compile-B4SX4TGx.js +0 -1
  182. package/dist/assets/rolldown-runtime-hePW80VL.js +0 -1
  183. package/dist/assets/scene-asset-sync-LeI3eSnO.js +0 -13
  184. package/dist/assets/task-board-5PKn7ZaA.js +0 -15
  185. package/dist/assets/workflow-Dg9jCKOq.js +0 -179
@@ -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 = attempt(g.patch);
101
- if (t && better(t.r, result)) { keep(g, key, g.patch, t); kept += 1; } else { refused.add(JSON.stringify(g.patch)); if (t) rejected += 1; }
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 = attempt(g.patch);
111
- if (t && better(t.r, result)) { keep(g, key, g.patch, t); kept += 1; break; } else { refused.add(JSON.stringify(g.patch)); if (t) rejected += 1; }
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
- const give = clamp(0.08 + 0.02 * mass * speed, 0.08, 0.15), u = norm(incoming), ease = t => 1 - (1 - t) ** 3;
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
- <button type="button" className="btn full" data-testid="sculpt-blueprint-generate" disabled={disabled || Boolean(refusal)} onClick={() => onGenerate?.()}>
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={startDisabled}
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
- {showProgress && (
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
- {ko("Place in scene", "장면에 배치")}
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={index > 0 ? () => run('character.remove', { characterId: entry.id }) : undefined}
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. */
@@ -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.userData.poseFadeRaycast = node.raycast;
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
- node.raycast = node.userData.poseFadeRaycast;
74
- delete node.userData.poseFadeRaycast;
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
  }
@@ -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
- occlusionRay.set(tmp.cameraPos, tmp.rayDirection);
683
- occlusionRay.near = 0.001;
684
- occlusionRay.far = Math.max(0.001, targetDistance - 0.001);
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
- const focusExposed = !!focus;
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)} />
@@ -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;
@@ -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
- <div className="v2-start-mode" role="radiogroup" aria-label={ko("Project mode", "프로젝트 모드")} data-testid="start-previs-mode">
264
- {MODE_OPTIONS.map((option) => (
265
- <button
266
- type="button"
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
+ }