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
@@ -0,0 +1,108 @@
1
+ // The sculpt shelf (#740 S9 + S13): sculpt cards in the shared asset store
2
+ // (`cozyclay.assets`, scene-assets.js) under `sculpt-` ids. The store belongs to
3
+ // this browser, not to a project, so every project and every scene sees the
4
+ // same cards: that is the cross-project library. Nothing here leaves the
5
+ // machine; a card travels only as a `.cozysculpt.json` the user saves.
6
+ //
7
+ // The Content pane's Sculpts folder subscribes here; Save to shelf, import and
8
+ // delete publish the new list. `openDb` is injectable for the node tests.
9
+ import { deleteAsset, getAsset, listAssetIds, openAssetDb, putAsset } from "./scene-assets.js";
10
+ import { isSculptAssetId, parseCard, readSculptCard, sculptCardFileName, sculptCardRecord, SCULPT_CARD_TYPE } from "./sculpt-shelf-file.js";
11
+ import { renderSculptThumb } from "./sculpt-thumb.js";
12
+ import { saveExport } from "./export-workspace.js";
13
+ import { ko } from "./locale.js";
14
+
15
+ const listeners = new Set();
16
+ // null until the first read: the folder shows skeletons, not "no cards".
17
+ let cards = null;
18
+
19
+ function publish(next) {
20
+ cards = Object.freeze(next);
21
+ for (const listener of listeners) listener();
22
+ }
23
+
24
+ /** The cards, newest name order: [{ id, name, recipe, thumb }] or null before the first read. */
25
+ export const sculptShelfCards = () => cards;
26
+ export function subscribeSculptShelf(listener) {
27
+ listeners.add(listener);
28
+ return () => listeners.delete(listener);
29
+ }
30
+
31
+ async function withDb(openDb, work) {
32
+ const db = await openDb();
33
+ try {
34
+ return await work(db);
35
+ } finally {
36
+ db?.close?.();
37
+ }
38
+ }
39
+
40
+ /** Re-read every card from the store. Without IndexedDB the shelf is empty. */
41
+ export async function refreshSculptShelf({ openDb = openAssetDb } = {}) {
42
+ try {
43
+ const read = await withDb(openDb, async (db) => {
44
+ const ids = (await listAssetIds(db)).filter(isSculptAssetId);
45
+ return (await Promise.all(ids.map((id) => getAsset(db, id)))).map(readSculptCard).filter(Boolean);
46
+ });
47
+ publish(read.sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id)));
48
+ } catch {
49
+ publish([]);
50
+ }
51
+ return cards;
52
+ }
53
+
54
+ /** Store one card { name, recipe, thumb }; the same recipe overwrites its card. Answers its id. */
55
+ export async function putSculptCard(card, { openDb = openAssetDb, subtle } = {}) {
56
+ const record = await sculptCardRecord(card, subtle);
57
+ await withDb(openDb, (db) => putAsset(db, record));
58
+ await refreshSculptShelf({ openDb });
59
+ return record.id;
60
+ }
61
+
62
+ export async function deleteSculptCard(id, { openDb = openAssetDb } = {}) {
63
+ if (!isSculptAssetId(id)) return false;
64
+ await withDb(openDb, (db) => deleteAsset(db, id));
65
+ await refreshSculptShelf({ openDb });
66
+ return true;
67
+ }
68
+
69
+ /** The stored `.cozysculpt.json` text of a card, or null when it is gone. */
70
+ async function sculptCardText(id, { openDb = openAssetDb } = {}) {
71
+ const record = await withDb(openDb, (db) => getAsset(db, id));
72
+ return record ? new TextDecoder().decode(record.bytes) : null;
73
+ }
74
+
75
+ /** Sculpt panel's Save to shelf: a copy of the recipe, its name and a thumbnail. Answers a toast line. */
76
+ export async function saveSculptToShelf(sculpt, { openDb = openAssetDb, renderThumb = renderSculptThumb } = {}) {
77
+ if (!sculpt?.recipe) return ko("Select a sculpt to save it to the shelf.", "선반에 저장할 조형물을 선택하세요.");
78
+ try {
79
+ const thumb = await renderThumb(sculpt.recipe);
80
+ await putSculptCard({ name: sculpt.name, recipe: sculpt.recipe, thumb }, { openDb });
81
+ return ko(`${sculpt.name} saved to the shelf — Content › Sculpts`, `${sculpt.name} 선반에 저장됨 — 콘텐츠 › 조형물`, `${sculpt.name} 已保存到架子 — 内容 › 雕塑`);
82
+ } catch (error) {
83
+ return ko(`Could not save to the shelf: ${error.message}`, `선반에 저장하지 못했어요: ${error.message}`, `无法保存到架子:${error.message}`);
84
+ }
85
+ }
86
+
87
+ /** Import `.cozysculpt.json` files as cards. Answers a toast line naming what came in or why not. */
88
+ export async function importSculptCardFiles(files, { openDb = openAssetDb } = {}) {
89
+ const names = [], problems = [];
90
+ for (const file of Array.from(files ?? [])) {
91
+ const parsed = parseCard(await file.text());
92
+ if (!parsed.ok) problems.push(`${file.name}: ${parsed.error}`);
93
+ else {
94
+ await putSculptCard(parsed.card, { openDb });
95
+ names.push(parsed.card.name);
96
+ }
97
+ }
98
+ if (problems.length) return ko(`Not imported — ${problems.join("; ")}`, `가져오지 못했어요 — ${problems.join("; ")}`, `未导入 — ${problems.join("; ")}`);
99
+ return names.length ? ko(`Imported ${names.join(", ")}`, `가져옴: ${names.join(", ")}`, `已导入 ${names.join("、")}`) : "";
100
+ }
101
+
102
+ /** Save one card as `<name>.cozysculpt.json` through the export workspace (or a download). */
103
+ export async function exportSculptCard(card, { openDb = openAssetDb } = {}) {
104
+ const text = await sculptCardText(card.id, { openDb });
105
+ if (!text) return ko("That card is no longer on the shelf.", "이 카드는 이미 선반에 없어요.");
106
+ const result = await saveExport(new Blob([text], { type: SCULPT_CARD_TYPE }), sculptCardFileName(card.name));
107
+ return ko(`Saved ${result.name ?? sculptCardFileName(card.name)}`, `${result.name ?? sculptCardFileName(card.name)} 저장됨`, `已保存 ${result.name ?? sculptCardFileName(card.name)}`);
108
+ }
@@ -5,6 +5,9 @@ import { useFrame, useThree } from "@react-three/fiber";
5
5
  import { OrbitControls, PerspectiveCamera } from "@react-three/drei";
6
6
  import * as THREE from "three";
7
7
  import { SCULPT_LAYER, isolatedObjectIds, sculptFraming } from "./sculpt-mode.js";
8
+ import { pickSculptPart } from "./sculpt-pick.js";
9
+ import { SculptPartOutline } from "./sculpt-part-outline.jsx";
10
+ import { SculptPartGizmo } from "./sculpt-part-gizmo.jsx";
8
11
 
9
12
  const SCULPT_FOV = 35;
10
13
  // Where a new sculpt lands (object.sculpt without placement): the origin.
@@ -87,9 +90,11 @@ function ClayLump({ at }) {
87
90
 
88
91
  // How long a changed part glows after an edit lands.
89
92
  const FLASH_MS = 1400;
93
+ // A press that travels further than this is an orbit, not a click.
94
+ const CLICK_SLOP_PX = 4;
90
95
 
91
- export function SculptTurntable({ camRef, objects, target, size, view, light = false, busy = false, changedParts = null }) {
92
- const { scene, raycaster, invalidate } = useThree();
96
+ export function SculptTurntable({ camRef, objects, target, size, view, light = false, busy = false, changedParts = null, parts = null }) {
97
+ const { scene, raycaster, invalidate, gl } = useThree();
93
98
  const [camera, setCamera] = useState(null);
94
99
  // The key light aims at the sculpt; its target must sit in the scene graph.
95
100
  const [lightTarget] = useState(() => new THREE.Object3D());
@@ -146,6 +151,36 @@ export function SculptTurntable({ camRef, objects, target, size, view, light = f
146
151
  return () => { raycaster.layers.mask = mask; };
147
152
  }, [raycaster]);
148
153
 
154
+ // Part picking (#740 S7): a left click (not an orbit drag) on the sculpt
155
+ // selects the part under it, on empty space clears. The pointer maps into
156
+ // the main pane, the rect the sculpt camera's projection spans.
157
+ const partsRef = useRef(parts);
158
+ partsRef.current = parts;
159
+ const [pickRay] = useState(() => { const ray = new THREE.Raycaster(); ray.layers.set(SCULPT_LAYER); return ray; });
160
+ // The part gizmo's controls (#740 S8): a still click on one of its handles
161
+ // picks the part under it, but never clears the selection.
162
+ const gizmoRef = useRef(null);
163
+ useEffect(() => {
164
+ if (!camera) return undefined;
165
+ const canvas = gl.domElement;
166
+ let down = null;
167
+ const onDown = (event) => { down = event.button === 0 && event.target === canvas ? [event.clientX, event.clientY, Boolean(gizmoRef.current?.axis)] : null; };
168
+ const onUp = (event) => {
169
+ const start = down;
170
+ down = null;
171
+ if (!start || !partsRef.current?.onSelect || Math.hypot(event.clientX - start[0], event.clientY - start[1]) > CLICK_SLOP_PX) return;
172
+ const pane = (document.querySelector(".vp-pane.vp-main") ?? canvas).getBoundingClientRect();
173
+ if (pane.width < 2 || pane.height < 2) return;
174
+ camera.updateMatrixWorld();
175
+ pickRay.setFromCamera(new THREE.Vector2(((event.clientX - pane.left) / pane.width) * 2 - 1, -((event.clientY - pane.top) / pane.height) * 2 + 1), camera);
176
+ const hit = pickSculptPart(pickRay, nodesRef.current);
177
+ if (hit || !start[2]) partsRef.current.onSelect(hit);
178
+ };
179
+ canvas.addEventListener("pointerdown", onDown);
180
+ window.addEventListener("pointerup", onUp);
181
+ return () => { canvas.removeEventListener("pointerdown", onDown); window.removeEventListener("pointerup", onUp); };
182
+ }, [camera, gl, pickRay]);
183
+
149
184
  useEffect(() => () => {
150
185
  for (const node of taggedRef.current) node.layers.disable(SCULPT_LAYER);
151
186
  taggedRef.current = new Set();
@@ -192,6 +227,8 @@ export function SculptTurntable({ camRef, objects, target, size, view, light = f
192
227
  <PerspectiveCamera ref={(node) => { camRef.current = node; setCamera(node); }} makeDefault fov={SCULPT_FOV} near={0.05} far={100} />
193
228
  {camera && <OrbitControls ref={controlsRef} camera={camera} enableDamping={false} minDistance={0.3} maxDistance={40} autoRotate={spinning} autoRotateSpeed={1.4}
194
229
  onStart={() => setGrabbed(true)} onChange={() => invalidate()} />}
230
+ <SculptPartOutline nodesRef={nodesRef} selected={parts?.selected ?? null} />
231
+ <SculptPartGizmo camera={camera} orbitRef={controlsRef} nodesRef={nodesRef} gizmoRef={gizmoRef} recipe={target?.recipe} parts={parts} />
195
232
  <group ref={furnitureRef}>
196
233
  <hemisphereLight args={["#ffffff", "#3a3c42", 0.9]} />
197
234
  <primitive object={lightTarget} position={framing.target} />
@@ -0,0 +1,72 @@
1
+ // The sculpt card's picture (#740 S9): one 256 px frame of the recipe from the
2
+ // front three-quarter view, the turntable's look without the turntable. The
3
+ // camera fit is pure (sculptThumbCamera, node-tested); the render needs WebGL.
4
+ //
5
+ // Each render gets its own small WebGLRenderer and loses its context right
6
+ // after, so a save never holds a second context next to the viewport's. Renders
7
+ // are serialized: two quick saves never build two scatter-heavy groups at once.
8
+ import * as THREE from "three";
9
+ import { buildSculptGroup, disposeSculptGroup } from "./sculpt-geometry.js";
10
+
11
+ const SCULPT_THUMB_SIZE = 256;
12
+ // Target values: SCULPT_VIEWS (sculpt-mode.js) has front, side and back only.
13
+ const THUMB_YAW = 35;
14
+ const THUMB_ELEVATION = 15;
15
+ const THUMB_FOV = 30;
16
+
17
+ /**
18
+ * Where the thumbnail camera stands for a bounding box { min: [x,y,z], max }.
19
+ * Yaw turns toward the subject's own left (+x) from the front (+z), elevation
20
+ * lifts it; the distance fits the box's bounding sphere inside the field of
21
+ * view with a small margin, so any recipe size fills the same frame.
22
+ */
23
+ export function sculptThumbCamera(box, { yaw = THUMB_YAW, elevation = THUMB_ELEVATION, fov = THUMB_FOV } = {}) {
24
+ const min = box?.min ?? [0, 0, 0], max = box?.max ?? [0, 0, 0];
25
+ const target = [0, 1, 2].map((axis) => (min[axis] + max[axis]) / 2);
26
+ const radius = Math.max(0.05, Math.hypot(max[0] - min[0], max[1] - min[1], max[2] - min[2]) / 2);
27
+ const distance = (radius / Math.sin(THREE.MathUtils.degToRad(fov) / 2)) * 1.08;
28
+ const y = THREE.MathUtils.degToRad(yaw), e = THREE.MathUtils.degToRad(elevation);
29
+ const position = [target[0] + distance * Math.sin(y) * Math.cos(e), target[1] + distance * Math.sin(e), target[2] + distance * Math.cos(y) * Math.cos(e)];
30
+ return { position, target, fov, near: Math.max(0.01, distance - radius * 2), far: distance + radius * 2 };
31
+ }
32
+
33
+ let queue = Promise.resolve();
34
+
35
+ function renderNow(recipe) {
36
+ const canvas = document.createElement("canvas");
37
+ canvas.width = SCULPT_THUMB_SIZE;
38
+ canvas.height = SCULPT_THUMB_SIZE;
39
+ const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true, preserveDrawingBuffer: true });
40
+ const group = buildSculptGroup(recipe);
41
+ try {
42
+ renderer.setSize(SCULPT_THUMB_SIZE, SCULPT_THUMB_SIZE, false);
43
+ renderer.setClearColor(0x000000, 0);
44
+ renderer.outputColorSpace = THREE.SRGBColorSpace;
45
+ const scene = new THREE.Scene();
46
+ scene.add(new THREE.HemisphereLight(0xfff6ea, 0x5a5048, 1.6));
47
+ const key = new THREE.DirectionalLight(0xffffff, 2.2);
48
+ key.position.set(2, 4, 3);
49
+ scene.add(key, group);
50
+ const box = new THREE.Box3().setFromObject(group);
51
+ const fit = sculptThumbCamera(box.isEmpty() ? null : { min: box.min.toArray(), max: box.max.toArray() });
52
+ const camera = new THREE.PerspectiveCamera(fit.fov, 1, fit.near, fit.far);
53
+ camera.position.fromArray(fit.position);
54
+ camera.lookAt(...fit.target);
55
+ renderer.render(scene, camera);
56
+ return canvas.toDataURL("image/png");
57
+ } finally {
58
+ disposeSculptGroup(group);
59
+ renderer.dispose();
60
+ renderer.forceContextLoss();
61
+ }
62
+ }
63
+
64
+ /** A PNG data URL of the recipe, or null when this browser cannot draw it. */
65
+ export function renderSculptThumb(recipe) {
66
+ const run = queue.then(() => renderNow(recipe));
67
+ queue = run.catch(() => {});
68
+ return run.catch((error) => {
69
+ console.warn("[cozyclay] could not draw the sculpt card thumbnail", error);
70
+ return null;
71
+ });
72
+ }
@@ -0,0 +1,75 @@
1
+ // Multi-view sculpt comparison (#970): one sculpt scored against several views
2
+ // of its subject (the blueprint's front, side and back crops), each by
3
+ // compareSculptToReference, then blended by weight into one IoU.
4
+ import { compareSculptToReference } from "./sculpt-compare.js";
5
+ import { patchSculptRecipe } from "./sculpt-recipe.js";
6
+
7
+ /** The default weight of each view: the front carries the likeness, the side the depth, the back the rest. */
8
+ export const VIEW_WEIGHTS = Object.freeze({ front: 0.6, side: 0.25, back: 0.15 });
9
+ /** A view whose subject's height:width differs from the front's by more than this is drawn at another scale or pose: weight 0. */
10
+ export const VIEW_RATIO_TOLERANCE = 0.35;
11
+
12
+ /** front (|yaw| <= 45), back (|yaw| >= 135) or side, from the camera yaw in degrees. */
13
+ export function viewOfYaw(yaw) {
14
+ const turn = Math.abs(((Number(yaw) % 360) + 540) % 360 - 180);
15
+ return turn <= 45 ? "front" : turn >= 135 ? "back" : "side";
16
+ }
17
+
18
+ /**
19
+ * The weight each view gets. `views` are [{ yaw, weight?, heightToWidth }]:
20
+ * an explicit weight replaces the table's; then any view whose height:width
21
+ * differs from the front's by more than VIEW_RATIO_TOLERANCE gets weight 0 with
22
+ * the reason. The front is the front view nearest yaw 0; without one the ratio
23
+ * rule is skipped. Returns [{ weight, excluded? }] in the views' order.
24
+ */
25
+ export function viewWeights(views) {
26
+ const fronts = views.map((view, index) => ({ index, yaw: Math.abs(Number(view.yaw)) })).filter(({ index }) => viewOfYaw(views[index].yaw) === "front");
27
+ const front = fronts.sort((a, b) => a.yaw - b.yaw)[0];
28
+ const frontRatio = front ? views[front.index].heightToWidth : null;
29
+ return views.map((view, index) => {
30
+ const weight = view.weight ?? VIEW_WEIGHTS[viewOfYaw(view.yaw)];
31
+ if (!front || index === front.index || !(frontRatio > 0)) return { weight };
32
+ const difference = Math.abs(view.heightToWidth / frontRatio - 1);
33
+ if (difference <= VIEW_RATIO_TOLERANCE) return { weight };
34
+ return { weight: 0, excluded: `height:width ${view.heightToWidth.toFixed(2)} differs from the front's ${frontRatio.toFixed(2)} by ${Math.round(difference * 100)}% (over ${VIEW_RATIO_TOLERANCE * 100}%)` };
35
+ });
36
+ }
37
+
38
+ const round = value => Number(value.toFixed(3));
39
+
40
+ /**
41
+ * Score `recipe` against every view ([{ yaw, image, weight? }], images RGBA) and
42
+ * blend the IoUs by weight. The report is the heaviest view's (its sheet too),
43
+ * with `iou` the weighted one and `views` one row per view.
44
+ */
45
+ function scoreViews(recipe, views, elevation, weights) {
46
+ const results = views.map(view => compareSculptToReference(recipe, view.image, { yaw: view.yaw, elevation }));
47
+ const used = weights ?? viewWeights(views.map((view, index) => ({ yaw: view.yaw, weight: view.weight, heightToWidth: 1 / results[index].report.widthToHeight.reference })));
48
+ const total = used.reduce((sum, row) => sum + row.weight, 0);
49
+ if (!(total > 0)) throw new Error("No view carries any weight: every view was excluded or given weight 0.");
50
+ const primary = used.reduce((best, row, index) => (row.weight > used[best].weight ? index : best), 0);
51
+ const iou = results.reduce((sum, result, index) => sum + result.report.iou * used[index].weight, 0) / total;
52
+ const rows = results.map(({ report }, index) => ({ view: viewOfYaw(views[index].yaw), yaw: views[index].yaw, weight: round(used[index].weight / total), iou: report.iou,
53
+ widthToHeight: report.widthToHeight, missingByBand: report.missingByBand, extraByPart: report.extraByPart.slice(0, 3), ...(used[index].excluded ? { excluded: used[index].excluded } : {}) }));
54
+ return { result: { report: { ...results[primary].report, iou: round(iou), views: rows }, sheet: results[primary].sheet }, weights: used };
55
+ }
56
+
57
+ /**
58
+ * The multi-view twin of scoreSculptAgainstReference: same return shape
59
+ * ({ result, ranked?, refused? }), so sculpt.compare and compare_sculpt answer
60
+ * one way. Candidates are scored on the same views with the same weights.
61
+ */
62
+ export function scoreSculptAgainstViews(recipe, views, { elevation = 0, candidates } = {}) {
63
+ if (!Array.isArray(views) || !views.length) throw new Error("views needs at least one { yaw, reference }.");
64
+ const { result, weights } = scoreViews(recipe, views, elevation);
65
+ if (!candidates) return { result };
66
+ const ranked = [{ label: "current", result }];
67
+ const refused = [];
68
+ for (const candidate of candidates) {
69
+ const patched = patchSculptRecipe(recipe, candidate.edits);
70
+ if (!patched.ok) refused.push({ label: candidate.label, error: patched.error });
71
+ else ranked.push({ label: candidate.label, result: scoreViews(patched.recipe, views, elevation, weights).result });
72
+ }
73
+ ranked.sort((a, b) => b.result.report.iou - a.result.report.iou);
74
+ return { result, ranked, refused };
75
+ }
@@ -1,6 +1,3 @@
1
- import { createCameraBlock } from "./camera-block.js";
2
- import { isBusRunActive } from "./command-bus.js";
3
-
4
1
  // 2: counted from the user's own command-bus receipts (#922); 1 diffed domain values and
5
2
  // missed shot, camera and object edits in the real Studio.
6
3
  export const FIRST_EDIT_VERSION = 2;
@@ -10,62 +7,6 @@ export const FIRST_EDIT_KINDS = Object.freeze([
10
7
  "prompt_block_add", "prompt_block_edit",
11
8
  ]);
12
9
 
13
- // Compare authored values, not object identities. Solvers and pointer callbacks
14
- // can reconstruct the same pose with floating-point roundoff. Values stay local;
15
- // only the closed edit-kind vocabulary ever crosses the analytics boundary.
16
- function equal(a, b) {
17
- if (a === b) return true;
18
- if (typeof a === "number" && typeof b === "number") return Math.abs(a - b) < 1e-8;
19
- if (!a || !b || typeof a !== "object" || typeof b !== "object") return false;
20
- if (a instanceof Map || b instanceof Map) {
21
- return a instanceof Map && b instanceof Map && a.size === b.size
22
- && [...a].every(([key, value]) => b.has(key) && equal(value, b.get(key)));
23
- }
24
- const keys = Object.keys(a);
25
- return keys.length === Object.keys(b).length && keys.every((key) => Object.hasOwn(b, key) && equal(a[key], b[key]));
26
- }
27
- const poseValue = (pose) => ({ bones: pose?.bones ?? {}, rootY: pose?.rootY ?? 0 });
28
-
29
- const keyValues = (keys = []) => keys.map(({ frame, framing }) => ({ frame, framing }));
30
- const shotValue = (shot) => ({ ...shot, camera: createCameraBlock(shot.camera), cameraKeys: keyValues(shot.cameraKeys) });
31
-
32
- export function semanticEditKind(domain, before, after) {
33
- if (before === after) return null;
34
- if (domain === "pose") return equal(before, after) ? null : "pose_edit";
35
-
36
- if (domain === "objects") {
37
- const added = after.find((item) => !before.some((previous) => previous.id === item.id));
38
- if (added) return added.renderer === "cutout" ? "cutout_insert" : "object_insert";
39
- return equal(before, after) ? null : "object_transform";
40
- }
41
- if (domain === "characters") {
42
- if (after.some((item) => !before.some((previous) => previous.id === item.id))) return "object_insert";
43
- for (const item of after) {
44
- const previous = before.find((entry) => entry.id === item.id);
45
- if (previous && !equal(poseValue(previous.pose), poseValue(item.pose))) return "pose_edit";
46
- }
47
- // Motion buffers/layer synchronization are passive, not cast transforms.
48
- const placement = (items) => items.map(({ id, x, y = 0, z, rot, scale = 1, hidden = false, model, tint, subject, identityImage }) => ({ id, x, y, z, rot, scale, hidden, model, tint, subject, identityImage }));
49
- return equal(placement(before), placement(after)) ? null : "object_transform";
50
- }
51
- if (domain === "shots") {
52
- if (after.some((item) => !before.some((previous) => previous.id === item.id))) return "shot_add";
53
- for (const item of after) {
54
- const previous = before.find((entry) => entry.id === item.id);
55
- if (!previous) continue;
56
- const camera = createCameraBlock(item.camera), oldCamera = createCameraBlock(previous.camera);
57
- if (["cameraRail", "craneHeight", "railFollow", "dollyTiming"].some((key) => !equal(camera[key], oldCamera[key]))) return "rail_edit";
58
- const keys = item.cameraKeys ?? [], oldKeys = previous.cameraKeys ?? [];
59
- if (keys.length > oldKeys.length || keys.some((key) => {
60
- const old = oldKeys.find((entry) => entry.frame === key.frame);
61
- return old && !equal(old.framing, key.framing);
62
- })) return "camera_key_record";
63
- }
64
- return equal(before.map(shotValue), after.map(shotValue)) ? null : "shot_edit";
65
- }
66
- return null;
67
- }
68
-
69
10
  /** (surface, kind) -> the surface's first edit, once: `${surface}:first_edit` with a closed kind. */
70
11
  export function createFirstEditRecorder(emit) {
71
12
  // Dedupe once per editor App mount, independently for Studio and Playground.
@@ -80,31 +21,3 @@ export function createFirstEditRecorder(emit) {
80
21
  record.seen = surface => seen.has(surface);
81
22
  return record;
82
23
  }
83
-
84
- /** The same dedupe over a domain diff (semanticEditKind), for callers that hold before/after values. */
85
- export function createFirstEditTracker(emit) {
86
- const record = createFirstEditRecorder(emit);
87
- return (surface, domain, before, after) => {
88
- if (record.seen(surface)) return false;
89
- const kind = semanticEditKind(domain, before, after);
90
- return kind ? record(surface, kind) : false;
91
- };
92
- }
93
-
94
- // Synchronous mutation owner shared by UI and live commands. Keeping the before
95
- // value here avoids stale React closures and analytics inside replayable updater
96
- // functions. Passive writes (load, history, navigation) advance the same value
97
- // without reporting; a rejected reducer never publishes or reports anything.
98
- export function createSemanticState(initial, publish, observe, domain) {
99
- let current = initial;
100
- const apply = (update, authored) => {
101
- const before = current;
102
- const after = typeof update === "function" ? update(before) : update;
103
- current = after;
104
- if (authored && !isBusRunActive()) console.warn(`[bus] semantic edit for ${domain} called outside a bus run`);
105
- publish(after);
106
- if (authored) observe(domain, before, after);
107
- return after;
108
- };
109
- return { set: (update) => apply(update, false), edit: (update) => apply(update, true) };
110
- }
@@ -2,7 +2,7 @@ import { useCallback, useRef, useState } from "react";
2
2
  import { ko, localeChosen } from "./locale.js";
3
3
  import PreferencesDialog from "./shell/PreferencesDialog.jsx";
4
4
 
5
- // App settings — language and analytics — behind one labelled topbar trigger
5
+ // App settings — language — behind one labelled topbar trigger
6
6
  // (#193, docs/studio-ui-ia.md R4). Neither item edits the document, so they do
7
7
  // not belong on the Save/Export row as bare toggles.
8
8
  //
@@ -38,7 +38,7 @@ export default function SettingsMenu() {
38
38
  data-testid="settings-menu-trigger"
39
39
  aria-expanded={open}
40
40
  aria-haspopup="dialog"
41
- title={ko("Language and analytics", "언어 및 사용 통계")}
41
+ title={ko("Language settings", "언어 설정", "语言设置")}
42
42
  ref={triggerRef}
43
43
  onClick={() => (open ? close() : setOpen(true))}
44
44
  >
@@ -15,7 +15,7 @@ import PropsPanel from "../panels/PropsPanel.jsx";
15
15
  import ObjectTransformPanel from "../panels/ObjectTransformPanel.jsx";
16
16
  import ObjectLookPanel from "../panels/ObjectLookPanel.jsx";
17
17
  import { PoseStudioPanel } from "../posestudio.jsx";
18
- import { DEFAULT_POSE } from "../poses.js";
18
+ import { posePanelControls } from "./pose-controls.js";
19
19
  import "../panels/details.css";
20
20
 
21
21
  export default function DetailsSlot() {
@@ -50,6 +50,7 @@ export default function DetailsSlot() {
50
50
  savePose, beginWorkspaceResize,
51
51
  } = useStudioShell();
52
52
  const selection = detailsSelection({ selectedHierarchyId, selectedSceneObject, rigSelection, characters, shot, inspectorHasContent });
53
+ const { resetPose, fingerPanel } = posePanelControls(useStudioShell());
53
54
  return (
54
55
  <aside className="panel hierarchy-sidebar inspector-sidebar" data-inspector={selectedHierarchyId}>
55
56
  {/* Save failures live above the tab content, not inside the Props
@@ -194,6 +195,8 @@ export default function DetailsSlot() {
194
195
  pendingIkEdit={pendingIkEdit}
195
196
  applyPendingIkEdit={applyPendingIkEdit}
196
197
  cancelPendingIkEdit={cancelPendingIkEdit}
198
+ onResetPose={() => resetPose(activeChar.id)}
199
+ fingerPanel={fingerPanel}
197
200
  rangePins={rangePins}
198
201
  rangePinResiduals={rangePinResiduals}
199
202
  rangePinSelection={rangePinSelection}
@@ -334,11 +337,7 @@ export default function DetailsSlot() {
334
337
  setToast(ko("Couldn't find the selected pose — pick again", "선택한 포즈를 찾지 못했어요. 다시 골라 주세요"));
335
338
  }
336
339
  }}
337
- onReset={() => {
338
- if (posingChar) castDomain.run('character.setPose', { characterId: posingChar.id, pose: DEFAULT_POSE.id, clearMotion: Boolean(motion) });
339
- setStudioPick(DEFAULT_POSE.id);
340
- setToast(ko("Back to the default pose", "기본 포즈로 돌아왔어요"));
341
- }}
340
+ onReset={() => { if (posingChar) resetPose(posingChar.id); }}
342
341
  onSave={savePose}
343
342
  onDelete={removePose}
344
343
  onClose={closeStudio}
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
2
2
  import { useStudioShell } from "./studio-shell-context.js";
3
3
  import { ko } from "../locale.js";
4
4
  import AssetPane from "../asset-pane.jsx";
5
+ import { dropAssetAt } from "../asset-drop.js";
5
6
 
6
7
  const CONTENT_COLLAPSED_KEY = "cozyclay.dock.content-collapsed.v1";
7
8
 
@@ -29,7 +30,7 @@ export default function LibrarySlot({ active = true }) {
29
30
  manageAssetStorage, setManageAssetStorage, unusedAssetIds, usedAssetIds, usageCounts,
30
31
  projectAssetGraphSignature, assetTrash, deleteUnusedAsset, undoDeletedAsset, deletingAssetId,
31
32
  projectManifest, shots, selectTimelineShot, allPoses,
32
- spawnCharacter, addSceneObject, runStudioAction,
33
+ spawnCharacter, addSceneObject, runStudioAction, setSelectedHierarchyId,
33
34
  } = useStudioShell();
34
35
  const [collapsed, setCollapsed] = useState(readContentCollapsed);
35
36
  const [shelfShown, setShelfShown] = useState(false);
@@ -41,15 +42,12 @@ export default function LibrarySlot({ active = true }) {
41
42
  }, [active, shelfShown, setBottomTab]);
42
43
 
43
44
  // Double-click in Content: the same payloads the drag carries, placed at
44
- // the origin through the named domain actions.
45
- function placeAssetAtOrigin(payload) {
46
- const origin = { x: 0, z: 0 };
47
- if (payload.kind === "character") spawnCharacter(payload.id, origin.x, origin.z);
48
- else if (payload.kind === "object") addSceneObject(payload.objectKind, origin);
49
- else if (payload.kind === "image" || payload.kind === "mesh") {
50
- runStudioAction("asset.import", { assetId: payload.assetId, placeAs: payload.kind === "mesh" ? "mesh" : "cutout", placement: origin });
51
- }
52
- }
45
+ // the origin through the same drop table (asset-drop.js).
46
+ const placeAssetAtOrigin = (payload) => dropAssetAt(payload, { x: 0, z: 0 }, {
47
+ spawnCharacter, addSceneObject, runStudioAction, select: setSelectedHierarchyId,
48
+ spawnCutoutAt: (assetId, placement) => runStudioAction("asset.import", { assetId, placeAs: "cutout", placement }),
49
+ spawnMeshAt: (assetId, placement) => runStudioAction("asset.import", { assetId, placeAs: "mesh", placement }),
50
+ });
53
51
 
54
52
  return (
55
53
  <section className="assets-pane studio-library" aria-label={ko("Library", "라이브러리")} data-content-collapsed={collapsed || undefined} hidden={!active}>
@@ -4,6 +4,8 @@ import { useStudioShell } from "./studio-shell-context.js";
4
4
  import SourceOffer from "../source-offer.jsx";
5
5
  import { ko } from "../locale.js";
6
6
  import { noteShortcutsOpened } from "../shortcut-friction.js";
7
+ import { appVersion } from "../app-version.js";
8
+ import { useExportMenuTelemetry } from "../export-menu-telemetry.js";
7
9
 
8
10
  const MAC = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform ?? "");
9
11
  const MOD = MAC ? "⌘" : "Ctrl+";
@@ -197,10 +199,9 @@ export default function MenuBar({ preferences }) {
197
199
  ? panelCount ? "" : noPanels
198
200
  : shots.length ? "" : ko("Add a shot first — a pack describes one cut", "샷을 먼저 추가하세요 — 팩은 컷 하나를 설명합니다");
199
201
 
200
- // Export's flyout lives in File, so an open Export menu opens File.
201
- useEffect(() => {
202
- if (exportMenuOpen) setOpen("file");
203
- }, [exportMenuOpen]);
202
+ // Export's flyout lives in File, so an open Export menu opens File; each open is counted.
203
+ useEffect(() => { if (exportMenuOpen) setOpen("file"); }, [exportMenuOpen]);
204
+ useExportMenuTelemetry(exportMenuOpen, shell);
204
205
 
205
206
  const closeMenus = (focusTrigger) => {
206
207
  const was = open;
@@ -632,7 +633,6 @@ function MenuDialog({ kind, onClose }) {
632
633
  document.addEventListener("keydown", onKey);
633
634
  return () => document.removeEventListener("keydown", onKey);
634
635
  }, [onClose]);
635
- const version = import.meta.env.VITE_APP_VERSION;
636
636
  return createPortal(
637
637
  <div className="menubar-dialog-backdrop" onPointerDown={(event) => { if (event.target === event.currentTarget) onClose(); }}>
638
638
  <div className="menubar-dialog" role="dialog" aria-modal="true" aria-labelledby={titleId} data-dialog={kind}>
@@ -645,7 +645,7 @@ function MenuDialog({ kind, onClose }) {
645
645
  {kind === "about" ? (
646
646
  <div className="menubar-dialog-body">
647
647
  <p>{ko("Previs studio: stage, pose, camera and motion.", "프리비즈 스튜디오: 배치, 포즈, 카메라, 모션.")}</p>
648
- {version && <p className="menubar-dialog-meta">{ko("Version", "버전")} {version}</p>}
648
+ <p className="menubar-dialog-meta">{ko("Version", "버전")} {appVersion()}</p>
649
649
  <SourceOffer />
650
650
  </div>
651
651
  ) : (
@@ -1,7 +1,7 @@
1
1
  import { useEffect, useMemo, useRef, useState } from "react";
2
2
  import { createPortal } from "react-dom";
3
3
  import { ko } from "../locale.js";
4
- import { getAnalyticsOptOut, loadAutoColor, preferenceSection, readStoredGridView } from "./preference-pages.jsx";
4
+ import { loadAutoColor, preferenceSection, readStoredGridView } from "./preference-pages.jsx";
5
5
  import "./preferences.css";
6
6
 
7
7
  const NAV_GROUPS = [
@@ -24,7 +24,6 @@ export default function PreferencesDialog({ open, onClose, initialSection = "App
24
24
  closeRef.current = onClose;
25
25
  const [activeSection, setActiveSection] = useState(initialSection);
26
26
  const [query, setQuery] = useState("");
27
- const [analyticsOptOut, setAnalyticsOptOutState] = useState(getAnalyticsOptOut);
28
27
  const [autoColor, setAutoColor] = useState(() => loadAutoColor());
29
28
  const [gridView, setGridView] = useState(() => readStoredGridView(globalThis.localStorage));
30
29
 
@@ -69,7 +68,7 @@ export default function PreferencesDialog({ open, onClose, initialSection = "App
69
68
  }, [open, initialSection]);
70
69
 
71
70
  if (!open || typeof document === "undefined") return null;
72
- const currentProps = { autoColor, setAutoColor, analyticsOptOut, setAnalyticsOptOutState, gridView, setGridView };
71
+ const currentProps = { autoColor, setAutoColor, gridView, setGridView };
73
72
 
74
73
  return createPortal(
75
74
  <div className="v2-preferences" data-testid="preferences-dialog-backdrop" onPointerDown={(event) => { if (event.target === event.currentTarget) closeRef.current?.(); }}>