cozyclay 3.1.6 → 3.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (224) hide show
  1. package/CHANGELOG.md +145 -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 +25 -17
  7. package/bin/agent/agent-stream.mjs +26 -3
  8. package/bin/agent/main-prompt.mjs +1 -1
  9. package/bin/agent/openai-compatible-provider.mjs +24 -1
  10. package/bin/agent/studio-prompt.mjs +2 -2
  11. package/bin/agent/supervisor-followup.mjs +34 -0
  12. package/bin/agent/supervisor.mjs +6 -6
  13. package/bin/cozyclay.mjs +23 -60
  14. package/bin/star-prompt.mjs +15 -14
  15. package/bin/studio-routes.mjs +6 -3
  16. package/bin/telemetry-state.mjs +5 -7
  17. package/bin/update-check.mjs +61 -7
  18. package/bin/update-install.mjs +16 -3
  19. package/bin/update-restart.mjs +95 -0
  20. package/bin/update-routes.mjs +25 -3
  21. package/dist/app/index.html +13 -7
  22. package/dist/assets/TaskBoard-tgO0MNKL.js +1 -0
  23. package/dist/assets/analytics-CaTUuLPM.js +1 -0
  24. package/dist/assets/{app-CEP7jzXB.css → app-CnDkB5WO.css} +1 -1
  25. package/dist/assets/app-DJt06Oyb.js +5144 -0
  26. package/dist/assets/compile-worker-D1pl6G7x.js +6 -0
  27. package/dist/assets/landing-2Fy7DU41.js +1 -0
  28. package/dist/assets/modulepreload-polyfill-P2Xu9kJm.js +1 -0
  29. package/dist/assets/motion3-compile-BxaM0Xwl.js +1 -0
  30. package/dist/assets/scene-asset-sync-CLGNbEBq.js +13 -0
  31. package/dist/assets/task-board-UVGTZLRF.js +15 -0
  32. package/dist/assets/workflow-CSiBlX1s.js +179 -0
  33. package/dist/cozyclay-package.json +1 -1
  34. package/dist/index.html +4 -1
  35. package/dist/privacy/index.html +9 -6
  36. package/dist/sitemap.xml +2 -2
  37. package/dist/workflow/index.html +5 -4
  38. package/mcp/README.md +1 -1
  39. package/mcp/live-hub.mjs +4 -4
  40. package/mcp/tool-handlers.mjs +14 -16
  41. package/package.json +3 -1
  42. package/src/App.jsx +42 -75
  43. package/src/analytics-controls.js +25 -3
  44. package/src/analytics-strict.js +16 -4
  45. package/src/analytics.js +32 -26
  46. package/src/app-version.js +13 -0
  47. package/src/asset-drop.js +39 -0
  48. package/src/asset-pane.css +34 -0
  49. package/src/asset-pane.jsx +4 -35
  50. package/src/bus-tasks.js +1 -1
  51. package/src/character-kind.js +8 -6
  52. package/src/click-friction.js +5 -4
  53. package/src/command-bus-telemetry.js +8 -7
  54. package/src/command-bus.js +2 -16
  55. package/src/commands/cast.js +2 -1
  56. package/src/commands/index.js +2 -1
  57. package/src/commands/motion3.js +3 -1
  58. package/src/commands/objects.js +2 -37
  59. package/src/commands/physics.js +21 -0
  60. package/src/commands/project.js +3 -3
  61. package/src/commands/sculpt-compare.js +53 -0
  62. package/src/content-tile.jsx +36 -0
  63. package/src/domains/cast.js +7 -6
  64. package/src/domains/motion.js +13 -14
  65. package/src/domains/scenes.js +41 -31
  66. package/src/execution-telemetry.js +7 -3
  67. package/src/export-menu-telemetry.js +26 -0
  68. package/src/finger-handles.jsx +128 -0
  69. package/src/friction-hint.js +82 -0
  70. package/src/live-control.js +2 -1
  71. package/src/locale-zh/panels.js +20 -0
  72. package/src/locale-zh/shell.js +4 -0
  73. package/src/locale-zh/workspace.js +6 -0
  74. package/src/motion/center-of-mass.js +5 -23
  75. package/src/motion/contact-project.js +143 -0
  76. package/src/motion/find-bone.js +13 -0
  77. package/src/motion/fingers.js +170 -0
  78. package/src/motion/fingertip-pick.js +21 -0
  79. package/src/motion/fix-collisions.js +1 -1
  80. package/src/motion/ik-drag.js +93 -1
  81. package/src/motion/ik-exposure.js +48 -0
  82. package/src/motion/ik.js +4 -11
  83. package/src/motion/timeline.css +12 -0
  84. package/src/motion/timeline.jsx +2 -2
  85. package/src/motion3/compile-job.js +3 -2
  86. package/src/motion3/compiler/compile.js +3 -3
  87. package/src/motion3/compiler/head-lead.js +4 -2
  88. package/src/motion3/compiler/spring.js +95 -0
  89. package/src/motion3/compiler/validate-objects.js +18 -2
  90. package/src/motion3/compiler/validate-parts.js +1 -0
  91. package/src/motion3/compiler/vocab.js +1 -1
  92. package/src/motion3/fix/index.js +8 -4
  93. package/src/motion3/fix/person.js +110 -0
  94. package/src/motion3/free-motion.js +49 -11
  95. package/src/motion3/hybrid-hint.js +19 -0
  96. package/src/motion3/load.js +19 -12
  97. package/src/motion3/object-pose.js +44 -2
  98. package/src/motion3/people.js +16 -1
  99. package/src/motion3/physics-frames.js +72 -0
  100. package/src/motion3/physics-trail.js +45 -0
  101. package/src/motion3/physics-view.js +79 -0
  102. package/src/motion3/pipeline.js +5 -1
  103. package/src/motion3/qa/ballistic.js +67 -0
  104. package/src/motion3/qa/com.js +140 -0
  105. package/src/motion3/qa/ground.js +10 -3
  106. package/src/motion3/qa/hints-contact.js +21 -7
  107. package/src/motion3/qa/hints-motion.js +38 -1
  108. package/src/motion3/qa/key-checks.js +5 -4
  109. package/src/motion3/qa/patch.js +32 -4
  110. package/src/motion3/qa/people-checks.js +1 -1
  111. package/src/motion3/qa/spring-pivot.js +23 -0
  112. package/src/motion3/qa/style.js +13 -30
  113. package/src/motion3/qa/verdict.js +7 -2
  114. package/src/motion3/repair.js +28 -8
  115. package/src/motion3/schema/fields.js +4 -2
  116. package/src/motion3/schema/topics.js +15 -11
  117. package/src/motion3/schema/types.js +13 -9
  118. package/src/motion3/skills/catch.js +2 -1
  119. package/src/panels/FingerPanel.jsx +38 -0
  120. package/src/panels/RigControlPanel.jsx +9 -1
  121. package/src/panels/SculptPanel.jsx +12 -12
  122. package/src/panels/SculptPartTree.jsx +46 -0
  123. package/src/panels/SubjectsPanel.jsx +1 -1
  124. package/src/panels/sculpt.css +11 -0
  125. package/src/physics-overlay-settings.js +41 -0
  126. package/src/physics-overlay.js +152 -0
  127. package/src/physics-overlay.jsx +54 -0
  128. package/src/physics-toggle.jsx +20 -0
  129. package/src/pose-fade.js +7 -3
  130. package/src/posestudio.jsx +15 -28
  131. package/src/project-browser.css +6 -0
  132. package/src/project-browser.jsx +9 -18
  133. package/src/project-start-choices.jsx +45 -0
  134. package/src/project.js +16 -12
  135. package/src/proxy-figure-shape.js +11 -0
  136. package/src/proxy-figure.jsx +25 -18
  137. package/src/scene-assets.js +17 -13
  138. package/src/scenes.js +22 -11
  139. package/src/sculpt-blueprint.js +56 -0
  140. package/src/sculpt-mode.js +5 -0
  141. package/src/sculpt-part-edit.js +115 -0
  142. package/src/sculpt-part-gizmo.jsx +159 -0
  143. package/src/sculpt-part-outline.jsx +72 -0
  144. package/src/sculpt-pick.js +53 -0
  145. package/src/sculpt-run-text.js +51 -0
  146. package/src/sculpt-run.js +10 -46
  147. package/src/sculpt-shelf-file.js +92 -0
  148. package/src/sculpt-shelf-tiles.jsx +69 -0
  149. package/src/sculpt-shelf.js +108 -0
  150. package/src/sculpt-stage.jsx +39 -2
  151. package/src/sculpt-thumb.js +72 -0
  152. package/src/sculpt-views.js +75 -0
  153. package/src/semantic-edit.js +0 -87
  154. package/src/settings-menu.jsx +2 -2
  155. package/src/shell/DetailsSlot.jsx +5 -6
  156. package/src/shell/LibrarySlot.jsx +8 -10
  157. package/src/shell/MenuBar.jsx +6 -6
  158. package/src/shell/PreferencesDialog.jsx +2 -3
  159. package/src/shell/ViewportToolbar.jsx +15 -18
  160. package/src/shell/add-character-entries.js +13 -0
  161. package/src/shell/pose-controls.js +32 -0
  162. package/src/shell/preference-pages.jsx +2 -11
  163. package/src/shell/storyboard-caption.js +21 -0
  164. package/src/shell/studio-shell-context.js +1 -0
  165. package/src/shortcut-friction.js +2 -1
  166. package/src/site/landing-analytics.js +28 -0
  167. package/src/studio-actions.js +3 -2
  168. package/src/studio-agent-protocol.js +4 -4
  169. package/src/studio-app-binding.js +4 -3
  170. package/src/update/UpdateChip.jsx +21 -2
  171. package/src/update/UpdateDialog.jsx +26 -6
  172. package/src/update/update-client.js +123 -17
  173. package/src/update/update-telemetry.js +15 -3
  174. package/src/use-sculpt-mode.js +62 -8
  175. package/src/workflow/agent-failure-codes.js +28 -9
  176. package/src/workflow/agent-turn-telemetry.js +1 -1
  177. package/src/workflow/task-board.js +2 -2
  178. package/tools/dev/page-shell.py +1 -1
  179. package/tools/dev/pages/greybox-to-video.html +21 -2
  180. package/tools/dev/pages/previs-software.html +3 -3
  181. package/tools/dev/pages/privacy.html +9 -6
  182. package/tools/dev/pages/seedance-camera-control.html +2 -2
  183. package/tools/loc-baseline.json +10 -10
  184. package/tools/run-tests.mjs +53 -84
  185. package/tools/select-tests.mjs +119 -0
  186. package/tools/test-durations.json +455 -0
  187. package/tools/test-graph.mjs +181 -0
  188. package/tools/test-shards.mjs +78 -0
  189. package/tools/tutorial/README.md +74 -0
  190. package/tools/tutorial/assemble.mjs +43 -0
  191. package/tools/tutorial/captions.mjs +43 -0
  192. package/tools/tutorial/cdp.mjs +55 -0
  193. package/tools/tutorial/cursor.mjs +159 -0
  194. package/tools/tutorial/episode.mjs +173 -0
  195. package/tools/tutorial/episodes/beginner-guide/00-install.mjs +42 -0
  196. package/tools/tutorial/episodes/beginner-guide/01-start.mjs +50 -0
  197. package/tools/tutorial/episodes/beginner-guide/02-layout.mjs +67 -0
  198. package/tools/tutorial/episodes/beginner-guide/03-stage.mjs +58 -0
  199. package/tools/tutorial/episodes/beginner-guide/04-motion.mjs +98 -0
  200. package/tools/tutorial/episodes/beginner-guide/05-camera.mjs +47 -0
  201. package/tools/tutorial/episodes/beginner-guide/06-export.mjs +59 -0
  202. package/tools/tutorial/episodes/beginner-guide/steps.mjs +32 -0
  203. package/tools/tutorial/episodes/beginner-guide.mjs +17 -0
  204. package/tools/tutorial/episodes/cast.mjs +12 -0
  205. package/tools/tutorial/episodes/first-video.mjs +121 -0
  206. package/tools/tutorial/episodes/stub-tour.mjs +97 -0
  207. package/tools/tutorial/launch.mjs +63 -0
  208. package/tools/tutorial/mux.mjs +61 -0
  209. package/tools/tutorial/overlay.mjs +142 -0
  210. package/tools/tutorial/record.mjs +156 -0
  211. package/tools/tutorial/scenes/terminal.html +63 -0
  212. package/tools/tutorial/steps.mjs +111 -0
  213. package/tools/tutorial/terminal.mjs +43 -0
  214. package/tools/tutorial/voice.mjs +49 -0
  215. package/tools/update-test-durations.mjs +42 -0
  216. package/tools/verify-package-tarball.mjs +18 -12
  217. package/dist/assets/TaskBoard-DwXhsJXz.js +0 -1
  218. package/dist/assets/app-BLS1Zaru.js +0 -5144
  219. package/dist/assets/compile-worker-or7djI2v.js +0 -6
  220. package/dist/assets/motion3-compile-Du7UXrWn.js +0 -1
  221. package/dist/assets/rolldown-runtime-hePW80VL.js +0 -1
  222. package/dist/assets/scene-asset-sync-t45pWCyq.js +0 -13
  223. package/dist/assets/task-board-CkLZGPkv.js +0 -15
  224. package/dist/assets/workflow-DJwOUzqJ.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
  }
@@ -15,8 +15,10 @@ export const ANCHOR_FIELDS = { world: ["p"], body: ["p"], bone: ["bone", "offset
15
15
  export const PROP_FIELDS = ["hand", "origin", "points"];
16
16
  export const FIT_FIELDS = ["side", "anchor", "effector", "offset", "orient"];
17
17
  /** spec.objects[id]: a hinge or a carry (a body or a group id), see C3; carry keys: C14; a switch (any record, C15) moves nothing; a follow (any record, C16) rides a point of the body; a free entry (C17) moves by keys and falls. */
18
- export const OBJECT_FIELDS = { hinge: ["pivot", "axis", "channel"], carry: ["prop", "at", "along", "visible", "level", "grab", "release", "keys", "fall", "grasp", "mass"], switch: ["visible", "level"],
19
- follow: ["follow", "offset", "aim", "visible", "level"], free: ["keys", "fall", "visible", "level"] };
18
+ export const OBJECT_FIELDS = { hinge: ["pivot", "axis", "channel"], carry: ["prop", "at", "along", "visible", "level", "grab", "release", "keys", "fall", "grasp", "mass", "spring"], switch: ["visible", "level"],
19
+ follow: ["follow", "offset", "aim", "visible", "level"], free: ["keys", "fall", "visible", "level", "spring"] };
20
+ /** spec.objects[id].spring (a carry or a free entry): a damped swing behind its anchor's acceleration, compiler/spring.js. */
21
+ export const SPRING_FIELDS = ["length", "stiffness", "damping"];
20
22
  /** One spec.objects[id].fall entry (C17): the frame it starts to fall, what it lands on, how much it bounces, whether it rolls to rest, the velocity it leaves with. */
21
23
  export const FALL_FIELDS = ["at", "onto", "bounce", "tumble", "v"];
22
24
  /** One spec.objects[id].keys entry (C14): the held object's world pose at frame f; aim (+ roll) in place of rot (C16). */
@@ -55,7 +55,7 @@ const CARRY = { props: EXAMPLES.objects.props, objects: { cup: EXAMPLES.objects.
55
55
  export const NOTES = {
56
56
  essentials: ["types: ? optional, =v default, a..b range, A | B one of, [key]: T map, a|b?: T both, x_l|r each side, (text) meaning.",
57
57
  `A carried object: ${literal(CARRY)}. ${NO_JUMP}`,
58
- "motion.describe { topic } or { topic: 'skills', name }: the rest."],
58
+ "motion.describe { topic } or { topic: 'skills', name }: more."],
59
59
  spec: [UNITS,
60
60
  "frames: an integer, or \"auto\" (the last skill end, generator end or key f, plus 1); the stored spec keeps the number.",
61
61
  "Any number may be { range: [lo, hi] } (int: true for frames): the compiler searches it, QA 0 first, at most search.budget compiles.",
@@ -71,15 +71,18 @@ export const NOTES = {
71
71
  "fits: the hand target moves so the effector (a prop point or the mean of joints), not the wrist, lands on the anchor.",
72
72
  "A fit on a scene body also turns the hand about the forearm into a grip: a thin body (a can, a bar, a handle, a neck) across the palm, else thumb up; never past a comfortable forearm turn. A wrist you turn (wtwist/wflex) wins; fit.orient: false (reachGrip args.orient: false) turns it off.",
73
73
  "reachGrip adds props.<anchor>Grip and its fit. Carry a scene object with objects.<id>.prop (topic objects).",
74
- "Do not pin a carried thing on a knuckle point: without at/along it rests in the palm and the fingers close on it (fix.grasp). objects.<id>.grasp names how: power, pinch, hook, palm, spherical (a ball). objects.<id>.mass (kg) weighs on the arm; past one hand QA says LOAD and patches in the other hand."],
75
- objects: ["A hinge turns the object (a body or a group) about pivot/axis by the angle channel your keys set (degrees).",
74
+ "Do not pin a carried thing on a knuckle point: without at/along it rests in the palm and the fingers close on it (fix.grasp). objects.<id>.grasp names how: power, pinch, hook, palm, spherical (a ball). objects.<id>.mass (kg) weighs on the arm; past one hand QA says LOAD and patches in the other hand.",
75
+ "objects.<id>.spring { length, stiffness, damping } (a carry, keyed included, or a free entry) swings it a beat behind the hand or its keys, then settles: a bag, a lantern. "
76
+ + "length (m, default half its longest side) is the string or rod; stiffness (1/s^2) defaults to 9.81 / length, a real pendulum; damping (default 0.5; 1: no overshoot) how soon it stops. "
77
+ + "A natural swing: length 0.4..0.6, damping 0.5..0.7. It turns, at most 60 deg about its base, never moves: put the base at the hand with at/along; a still hand leaves it still."],
78
+ objects: ["A hinge turns the object (a body or a group) about pivot/axis by the angle channel your keys set.",
76
79
  "A carry follows the prop's hand: before grab the scene pose, grab..release in the hand, after release where it was let go, riding a body under it that keys or a hinge move.",
77
- "No grab: in hand from frame 0; no release: to the end. No at/along: it rests in the palm, fingers closed on it.",
80
+ "No at/along: it rests in the palm, fingers closed on it.",
78
81
  "A group (Empty) id moves all its parts; at/along pin a group's root to the palm too (an object.createKit kit). A carried object is an obstacle for the body and the scene.",
79
- "A switch { visible?, level? } (no prop, no hinge field) moves nothing: it shows or hides any record (a part, a light, an effect) and sets its level, 0..4 light, glow and effect strength "
82
+ "A switch { visible?, level? } moves nothing: it shows or hides any record (a part, a light, an effect) and sets its level, 0..4 light, glow and effect strength "
80
83
  + "(an unkeyed level reads 1). Hiding a group hides its parts. A carry takes level too.",
81
- "keys pose a carried object in the world while in hand: pos its base (a group: its root), rot a quaternion [x, y, z, w], "
82
- + "or aim [x, y, z] where its +Y points (a bat's barrel) plus roll degrees about it (none: the roll the arm holds most easily). "
84
+ "keys pose a carried object in the world while in hand: pos its base (a group: its root), rot a quaternion, "
85
+ + "or aim [x, y, z] where its +Y points (a bat's barrel) plus roll degrees about it (none: the easiest roll). "
83
86
  + `The object leads and the arm follows (elbow, forearm, then the wrist within ${Object.entries(WRIST_LIMITS).map(([k, [lo, hi]]) => `${k} ${lo}..${hi}`).join(", ")} deg; dev + toward the thumb); `
84
87
  + "a left hand on a prop anchor of it turns with it; "
85
88
  + `the hand eases in and out over ${KEY_BLEND} frames. REACH "<id>: n cm off its keys" is what the hand could not reach; `
@@ -88,12 +91,12 @@ export const NOTES = {
88
91
  + "to rest on a stable face, one small bounce (0: none). A carry falls before grab or after release.",
89
92
  "A follow { follow: <ref>, offset?, aim?, visible?, level? } puts any record (an effect, a light, a part) on a body point every frame: "
90
93
  + "aim sets its +Y (an effect emits along it): up (default), face (forward), a direction or a ref (toward it); offset is metres along its axes after aim. It is no obstacle.",
91
- "The editor's motion.keyObject writes one key from a drag (a hinge: its channel key)."],
94
+ "The editor's motion.keyObject keys a drag (a hinge: its channel)."],
92
95
  checks: ["Each entry adds QA groups: contacts CONTACT, orient ORIENT, below BELOW, clearance CLEARANCE, appear APPEAR; supports mark where a part rests on an object (no FLOAT, no balance there); a foot on an object's top is on the ground without one.",
93
96
  "when: a weight channel (active above 0.99) or [f0, f1]; clearance uses frames.", "Check points take joint:, prop: and anchor: only; goals and probes take every ref (topic refs).",
94
97
  "allow excuses a body part inside an object up to depth; limits override the QA limits. Unnamed entries are named by place, contacts[0].",
95
98
  "Other characters with an installed take are bodies too: PERSON \"<part> x person:<id>\" is a part inside that person; a touch you mean (a push, a hug) is allow { part, object: \"person:<id>\", depth }.",
96
- "Speed, acceleration, knee bend and balance are style: measured in output.observations, not judged. Naming speedCore/speedLimb, accCore/accLimb, knee or balance in limits makes it a check (SPEED, ACC, KNEE, BALANCE). observations.near: each hand's and foot's nearest object and gap (< 0 inside), measured, not judged.",
99
+ "Speed, acceleration, knee bend and balance are style (output.observations), not judged. Naming one in limits checks it: SPEED, ACC, KNEE, BALANCE (mass-weighted centre of mass outside the support polygon of the feet, +8 cm margin). observations.near: each hand's and foot's nearest object and gap (< 0 inside), measured, not judged.",
97
100
  "motion.qa { checks } replaces every check for a re-measure."],
98
101
  goals: ["Exactly one of near (distance <= tol), above (a.y - b.y >= by), moved (an object's base moved at least by from the scene pose; any other point ref, e.g. prop:bat.tip, at least by from where it is at at[0], at: [f0, f1]), still (stays within tol, default 0.01 m, of where moved measures from) or speed (the point reaches by m/s).",
99
102
  "at is an absolute frame or [f0, f1]; mode all (every frame; the default) or any (one frame; speed's default). A failed goal is a GOAL group first in qa.groups and makes qa.ok false.",
@@ -111,7 +114,8 @@ export const NOTES = {
111
114
  "object:<id> takes a body or a group (the box around its parts); in a distance a bare object:<id> means its surface.",
112
115
  "Goals and probes take every kind; spec.checks keep joint:, prop: and anchor:."],
113
116
  fix: [`spec.fix keys: ${FIX_KEYS.join(", ")}; false turns all off. output.fixed reports what each one changed.`,
114
- "reach, slide, self and stance are on by default; penetration and retime are opt-in.",
117
+ "reach, slide, self, stance and person are on by default; penetration and retime are opt-in.",
115
118
  "reach also brings the body to a hand on a world or scene-object anchor the straight arm misses: a lean, a hip shift, lower hips, the feet kept (fixed.reachBody lists each move); farther, place the body. retime is kept only when QA is no worse with it.",
116
- "self keeps the arms out of the body on its fitted skin (QA BODY measures the same skin): wrist targets inside it go onto it, elbows turn about the shoulder-wrist line, a hand a little inside is pushed out. A fit's target (the mouth) is never moved; what is left is BODY, with checks.allow { part, object: <body part>, depth } for a contact meant to touch (a hand on a knee)."],
119
+ "self keeps the arms out of the body on its fitted skin (QA BODY measures the same skin): wrist targets inside it go onto it, elbows turn about the shoulder-wrist line, a hand a little inside is pushed out. A fit's target (the mouth) is never moved; what is left is BODY, with checks.allow { part, object: <body part>, depth } for a contact meant to touch (a hand on a knee).",
120
+ "person keeps the arms out of the other characters (QA PERSON): the wrist target moves out back the side the hand came from, however deep; an FK arm is left (fixed.person.skipped). A touch you mean: checks.allow { part, object: \"person:<id>\", depth }."],
117
121
  };
@@ -5,6 +5,7 @@
5
5
  import { REF_KINDS, OBJECT_KEY_FIELDS } from "./fields.js";
6
6
  import { CONVENTIONS, GRASPS } from "../compiler/vocab.js";
7
7
  import { QA_LIMITS } from "../qa/checks.js";
8
+ import { SPRING } from "../compiler/spring.js";
8
9
  import { CAPSULES } from "../qa/geometry.js";
9
10
 
10
11
  const def = (d, extra) => ({ ...extra, ...(d ? { description: d } : {}) });
@@ -83,23 +84,25 @@ export const FIT_TYPES = {
83
84
  offset: ref("vec3", "world offset, m"), orient: { type: "boolean", description: "false: no grip turn" },
84
85
  };
85
86
 
86
- const OBJECT_KEY_TYPES = { f: int(), pos: ref("vec3"), rot: { type: "array", items: { type: "number" }, minItems: 4, maxItems: 4, description: "quat x, y, z, w" },
87
+ const OBJECT_KEY_TYPES = { f: int(), pos: ref("vec3"), rot: { type: "array", items: { type: "number" }, minItems: 4, maxItems: 4, description: "quat xyzw" },
87
88
  aim: ref("vec3", "or rot: +Y dir"), roll: num("deg about aim"), lean: num("aim tilt", { minimum: 0, maximum: 45 }) };
88
89
 
89
90
  const FALL = { type: "object", properties: { at: int(), onto: str(), bounce: num(null, { default: 0.25, minimum: 0, maximum: 0.6 }), tumble: { type: "boolean" },
90
91
  v: { oneOf: [{ $ref: "#/$defs/vec3" }, { const: "hand" }], description: "m/s" } }, required: ["at"], additionalProperties: false };
91
92
  /** A free entry's keys and fall (C17) are a carry's, at any frame: said once, not twice. */
92
- const AS_CARRY = { description: "as carry; keys at any frame" };
93
- export const FREE_TYPES = { keys: AS_CARRY, fall: AS_CARRY };
93
+ const AS_CARRY = { description: "as carry, any frame" };
94
+ export const FREE_TYPES = { keys: AS_CARRY, fall: AS_CARRY, spring: AS_CARRY };
95
+ const SPRING_TYPE = { type: "object", properties: Object.fromEntries(Object.entries(SPRING).map(([k, r]) => [k, num(null, { default: r.default, minimum: r.min, maximum: r.max })])),
96
+ additionalProperties: false };
94
97
 
95
98
  export const OBJECT_TYPES = {
96
- pivot: ref("vec3", "world hinge point"), axis: ref("vec3", "default +Y"), channel: str("angle channel, deg"),
97
- prop: str("spec.props name: it rides that hand"), at: ref("vec3", "palm offset [d, l, n]"), along: ref("vec3", "+Y on the palm, fixed"),
98
- visible: str("channel: shown > 0.5"), level: str("channel: strength 0..4"), grab: int("hand takes it", { default: 0 }), release: int("let go (else end)"),
99
+ pivot: ref("vec3", "world"), axis: ref("vec3", "default +Y"), channel: str("angle, deg"),
100
+ prop: str("spec.props name"), at: ref("vec3", "palm offset [d, l, n]"), along: ref("vec3", "+Y on palm"),
101
+ visible: str("channel: shown > 0.5"), level: str("channel: 0..4"), grab: int("hand takes it", { default: 0 }), release: int("let go, else end"),
99
102
  keys: { type: "array", items: { type: "object", properties: Object.fromEntries(OBJECT_KEY_FIELDS.map(f => [f, OBJECT_KEY_TYPES[f]])), required: ["f", "pos"], additionalProperties: false },
100
- description: "world pose at f; hand follows" },
101
- fall: { ...FALL, description: "or a list; topic objects" },
102
- grasp: { enum: GRASPS }, mass: num("kg", { minimum: 0.01, maximum: 60 }),
103
+ description: "world pose; hand follows" },
104
+ fall: { ...FALL, description: "or a list" },
105
+ grasp: { enum: GRASPS }, mass: num("kg", { minimum: 0.01, maximum: 60 }), spring: SPRING_TYPE,
103
106
  follow: ref("ref"), offset: ref("vec3"), aim: { oneOf: [{ enum: ["face", "up"] }, { $ref: "#/$defs/vec3" }, { $ref: "#/$defs/ref" }], default: "up" },
104
107
  };
105
108
 
@@ -144,6 +147,7 @@ export const FIX_TYPES = {
144
147
  retime: bool("speed spikes borrow frames from the neighbouring keys", { default: false }),
145
148
  stance: bool("a standIdle stance in a scene body slides out of it (observations.placed)", { default: true }),
146
149
  grasp: bool("fingers close on what the hand holds; it rests in the hand", { default: true }),
150
+ person: bool("arms kept out of the other characters (QA PERSON)", { default: true }),
147
151
  };
148
152
 
149
153
  export const REF_DOCS = {
@@ -42,7 +42,8 @@ export function catchObject(ctx) {
42
42
  // the hand needs REACH frames from f0: a later pick it would get to in time; else it comes late to where the thing was
43
43
  const contact = Math.max(pick.f, ctx.f0 + REACH + ARRIVE), p = pick.p;
44
44
  const incoming = scale(sub(at(pick.f), at(pick.f - 1)), fps), speed = len(incoming), mass = massOf({ objects: ctx.objects }, scene, id);
45
- 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; }
@@ -0,0 +1,41 @@
1
+ // Which parts of the physics overlay (physics-overlay.js) the viewer wants: the centre of mass, the support
2
+ // polygon, the air runs' arcs and the spring objects' trails. Off by default; a viewer setting like the onion
3
+ // skin's (view-ghosts.js): never saved in the scene, never undone, gone with the session. motion.physicsOverlay
4
+ // (commands/physics.js) and the motion controls' Physics box (physics-toggle.jsx) both set it here.
5
+ import { useSyncExternalStore } from "react";
6
+
7
+ const PHYSICS_PARTS = Object.freeze(["com", "support", "arc", "trail"]);
8
+ const OFF = Object.freeze({ com: false, support: false, arc: false, trail: false });
9
+
10
+ let settings = OFF;
11
+ const listeners = new Set();
12
+
13
+ /** The current setting { com, support, arc, trail } (booleans). */
14
+ export const physicsOverlaySettings = () => settings;
15
+ /** True when any part is on. */
16
+ export const physicsOverlayOn = (value = settings) => PHYSICS_PARTS.some(part => value[part]);
17
+
18
+ /**
19
+ * Apply args { all?, com?, support?, arc?, trail? }: `all` sets every part first, then each named part is set;
20
+ * no field at all turns every part on. Answers the new setting.
21
+ */
22
+ export function setPhysicsOverlay(args = {}) {
23
+ const named = PHYSICS_PARTS.filter(part => typeof args[part] === "boolean");
24
+ const base = typeof args.all === "boolean" ? Object.fromEntries(PHYSICS_PARTS.map(part => [part, args.all])) : named.length ? settings : { com: true, support: true, arc: true, trail: true };
25
+ const next = Object.freeze({ ...base, ...Object.fromEntries(named.map(part => [part, args[part]])) });
26
+ if (PHYSICS_PARTS.every(part => next[part] === settings[part])) return settings;
27
+ settings = next;
28
+ for (const listener of [...listeners]) listener();
29
+ return settings;
30
+ }
31
+
32
+ /** "Physics overlay: centre of mass, support polygon on; arcs, trails off." */
33
+ export function describePhysicsOverlay(value = settings) {
34
+ const words = { com: "centre of mass", support: "support polygon", arc: "air arcs", trail: "spring trails" };
35
+ const on = PHYSICS_PARTS.filter(part => value[part]).map(part => words[part]), off = PHYSICS_PARTS.filter(part => !value[part]).map(part => words[part]);
36
+ if (!on.length) return "Physics overlay off.";
37
+ return `Physics overlay: ${on.join(", ")} on${off.length ? `; ${off.join(", ")} off` : ""}.`;
38
+ }
39
+
40
+ const subscribe = listener => { listeners.add(listener); return () => listeners.delete(listener); };
41
+ export const usePhysicsOverlaySettings = () => useSyncExternalStore(subscribe, physicsOverlaySettings, physicsOverlaySettings);