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
@@ -0,0 +1,51 @@
1
+ // The text of a sculpt run's turns (sculpt-run.js): the first turn's draft
2
+ // request, each refinement round's request, and the recipe rules both carry.
3
+
4
+ // The reference as sculpt.compare takes it: the stored asset when there is one.
5
+ export const referenceArg = reference => (reference?.assetId ? { assetId: reference.assetId } : { dataUrl: reference?.dataUrl });
6
+
7
+ // The recipe rules the agent needs to get object.sculpt right the first time (sculpt-recipe.js).
8
+ const RECIPE_RULES = "Recipe: { \"version\": 1, \"parts\": [{ \"id\", \"shape\", \"size\": [x,y,z] metres, \"position\": [x,y,z] centre, \"rotation\": [x,y,z] degrees, \"color\": \"#rrggbb\", \"parent\"?, \"mirror\"?, \"roundness\"? 0-1, \"taper\"? }] }. id: lowercase a-z, 0-9 and -, starting with a letter or digit, at most 40 characters. shape is exactly one of blob, box, cylinder, torus, frame, lathe, tube, extrude, loft, convex, scatter (no cone, sphere or other names): lathe { \"profile\": [[r 0-1, y 0-1], ...] } for turned forms (pot, bottle, bowl), tube { \"path\": [[x,y,z] each -0.5..0.5 of size], \"thickness\" m } for ropes, tails, handles and limbs that bend, extrude { \"outline\": [[x,y] -0.5..0.5] } for flat cut shapes (ears, fins, leaves, signs), loft { \"sections\": [{ \"y\", \"w\", \"d\" }, ...] } for bodies whose cross-section changes, convex { \"seed\" } for rocks. Any part may add \"detail\": { \"amount\": 0-1 } for a lumpy surface, \"gradient\": { \"color\" } for a two-tone blend, or \"op\": \"subtract\" to cut its parent (a mouth, a hollow). Up to 48 parts after mirrors. Floor is y=0 (feet touch it), front is +z, the subject's left is +x, mirror: true adds the reflection across x=0. roundness works backwards from its name: 0 is a smooth ellipsoid and 1 is a rounded box. roundness belongs only on blob, box and extrude, taper only on blob, cylinder and tube: leave both off the other shapes. Keep roundness at 0.2 or below on every organic part (body, belly, head, face, limbs); go above 0.3 only for hard-edged objects. Use box only for hard-edged things.";
9
+
10
+ // A compact score for the next turn: what is missing where, and which parts stick out.
11
+ function reportText(report) {
12
+ if (!report) return "";
13
+ const extra = Array.isArray(report.extraByPart) ? report.extraByPart.slice(0, 4) : report.extraByPart;
14
+ return ` Score detail: ${JSON.stringify({ widthToHeight: report.widthToHeight, missingByBand: report.missingByBand, extraByPart: extra })}.`;
15
+ }
16
+
17
+ function outlineText(outline) {
18
+ if (!outline) return "";
19
+ return ` The harness measured the reference outline in pixels (front view; x is a fraction of the subject's height from its centre, + toward image right = the subject's own left; y is a fraction of height from the head top; spans are the filled runs across each slice): ${JSON.stringify(outline)}. Put parts on these measurements.`;
20
+ }
21
+
22
+ // The blueprint views attached after the reference (#970), named the way sculpt.compare reads yaw.
23
+ const VIEW_WORDS = { side: "camera on the subject's own left, so it faces image left", back: "camera behind the subject" };
24
+ function viewsText(views) {
25
+ if (!views?.length) return "";
26
+ return ` Also attached, after the reference: the blueprint's ${views.map(view => `${view.view} view (yaw ${view.yaw}, ${VIEW_WORDS[view.view] ?? "orthographic"})`).join(" and ")}. Take depth and what the front hides from them; the harness scores against the front and these views together, by weight.`;
27
+ }
28
+
29
+ export function makeTurnText(reference, sculptId, continuation, outline = null, views = []) {
30
+ const ref = JSON.stringify(referenceArg(reference));
31
+ if (continuation) {
32
+ return [
33
+ `Round ${continuation.round} of ${continuation.maxRounds}. The harness scored sculpt ${sculptId} at IoU ${continuation.iou ?? "unknown"} against the reference${views.length ? ` and the blueprint's ${views.map(view => view.view).join(" and ")} views (weighted)` : ""}.${reportText(continuation.report)}`,
34
+ `Write 3-6 candidate edit sets that fix the biggest mismatch (proportion first, then placement), rank them with ONE run_action sculpt.compare { "id": "${sculptId}", "reference": ${ref}, "candidates": [{ "label", "edits" }] },`,
35
+ `apply the best-ranked set with ONE run_action object.sculpt { "id": "${sculptId}", "edits": [...] }, then end the turn with one short line. Do not inspect first; the harness scores the result after your turn.`,
36
+ RECIPE_RULES,
37
+ ].join(" ");
38
+ }
39
+ return [
40
+ `Sculpt the subject of the attached reference image as a clay recipe.${viewsText(views)}`,
41
+ outline
42
+ ? `Use the measured outline below, scaled to a real-world height in metres.${outlineText(outline)}`
43
+ : "First measure the visible outline in the image: the subject's bounding box, then where the head top, each limb tip and the feet sit as fractions of its height, and convert them to metres for a real-world height.",
44
+ sculptId
45
+ ? `Then rewrite sculpt ${sculptId} with ONE run_action object.sculpt { "id": "${sculptId}", "recipe": ... }, keeping parts on the measured landmarks.`
46
+ : "Then create it with ONE run_action object.sculpt { \"name\", \"recipe\" }, keeping parts on the measured landmarks.",
47
+ "No inspect calls are needed and do not call sculpt.compare: the harness scores the sculpt after your turn and tells you the result.",
48
+ "End the turn with one short line naming what the image hides.",
49
+ RECIPE_RULES,
50
+ ].join(" ");
51
+ }
package/src/sculpt-run.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { makeTurnText, referenceArg } from "./sculpt-run-text.js";
2
+
1
3
  const INITIAL_MESSAGE = "Measure the reference landmarks before placing parts.";
2
4
 
3
5
  const initialState = ({ targetIou, maxRounds }) => ({
@@ -45,48 +47,6 @@ function dataUrlFromSheet(sheet) {
45
47
  return null;
46
48
  }
47
49
 
48
- // The reference as sculpt.compare takes it: the stored asset when there is one.
49
- const referenceArg = reference => (reference?.assetId ? { assetId: reference.assetId } : { dataUrl: reference?.dataUrl });
50
-
51
- // The recipe rules the agent needs to get object.sculpt right the first time (sculpt-recipe.js).
52
- const RECIPE_RULES = "Recipe: { \"version\": 1, \"parts\": [{ \"id\", \"shape\", \"size\": [x,y,z] metres, \"position\": [x,y,z] centre, \"rotation\": [x,y,z] degrees, \"color\": \"#rrggbb\", \"parent\"?, \"mirror\"?, \"roundness\"? 0-1, \"taper\"? }] }. id: lowercase a-z, 0-9 and -, starting with a letter or digit, at most 40 characters. shape is exactly one of blob, box, cylinder, torus, frame, lathe, tube, extrude, loft, convex, scatter (no cone, sphere or other names): lathe { \"profile\": [[r 0-1, y 0-1], ...] } for turned forms (pot, bottle, bowl), tube { \"path\": [[x,y,z] each -0.5..0.5 of size], \"thickness\" m } for ropes, tails, handles and limbs that bend, extrude { \"outline\": [[x,y] -0.5..0.5] } for flat cut shapes (ears, fins, leaves, signs), loft { \"sections\": [{ \"y\", \"w\", \"d\" }, ...] } for bodies whose cross-section changes, convex { \"seed\" } for rocks. Any part may add \"detail\": { \"amount\": 0-1 } for a lumpy surface, \"gradient\": { \"color\" } for a two-tone blend, or \"op\": \"subtract\" to cut its parent (a mouth, a hollow). Up to 48 parts after mirrors. Floor is y=0 (feet touch it), front is +z, the subject's left is +x, mirror: true adds the reflection across x=0. roundness works backwards from its name: 0 is a smooth ellipsoid and 1 is a rounded box. roundness belongs only on blob, box and extrude, taper only on blob, cylinder and tube: leave both off the other shapes. Keep roundness at 0.2 or below on every organic part (body, belly, head, face, limbs); go above 0.3 only for hard-edged objects. Use box only for hard-edged things.";
53
-
54
- // A compact score for the next turn: what is missing where, and which parts stick out.
55
- function reportText(report) {
56
- if (!report) return "";
57
- const extra = Array.isArray(report.extraByPart) ? report.extraByPart.slice(0, 4) : report.extraByPart;
58
- return ` Score detail: ${JSON.stringify({ widthToHeight: report.widthToHeight, missingByBand: report.missingByBand, extraByPart: extra })}.`;
59
- }
60
-
61
- function outlineText(outline) {
62
- if (!outline) return "";
63
- return ` The harness measured the reference outline in pixels (front view; x is a fraction of the subject's height from its centre, + toward image right = the subject's own left; y is a fraction of height from the head top; spans are the filled runs across each slice): ${JSON.stringify(outline)}. Put parts on these measurements.`;
64
- }
65
-
66
- function makeTurnText(reference, sculptId, continuation, outline = null) {
67
- const ref = JSON.stringify(referenceArg(reference));
68
- if (continuation) {
69
- return [
70
- `Round ${continuation.round} of ${continuation.maxRounds}. The harness scored sculpt ${sculptId} at IoU ${continuation.iou ?? "unknown"} against the reference.${reportText(continuation.report)}`,
71
- `Write 3-6 candidate edit sets that fix the biggest mismatch (proportion first, then placement), rank them with ONE run_action sculpt.compare { "id": "${sculptId}", "reference": ${ref}, "candidates": [{ "label", "edits" }] },`,
72
- `apply the best-ranked set with ONE run_action object.sculpt { "id": "${sculptId}", "edits": [...] }, then end the turn with one short line. Do not inspect first; the harness scores the result after your turn.`,
73
- RECIPE_RULES,
74
- ].join(" ");
75
- }
76
- return [
77
- "Sculpt the subject of the attached reference image as a clay recipe.",
78
- outline
79
- ? `Use the measured outline below, scaled to a real-world height in metres.${outlineText(outline)}`
80
- : "First measure the visible outline in the image: the subject's bounding box, then where the head top, each limb tip and the feet sit as fractions of its height, and convert them to metres for a real-world height.",
81
- sculptId
82
- ? `Then rewrite sculpt ${sculptId} with ONE run_action object.sculpt { "id": "${sculptId}", "recipe": ... }, keeping parts on the measured landmarks.`
83
- : "Then create it with ONE run_action object.sculpt { \"name\", \"recipe\" }, keeping parts on the measured landmarks.",
84
- "No inspect calls are needed and do not call sculpt.compare: the harness scores the sculpt after your turn and tells you the result.",
85
- "End the turn with one short line naming what the image hides.",
86
- RECIPE_RULES,
87
- ].join(" ");
88
- }
89
-
90
50
  /**
91
51
  * Adapt the callback-based Studio client into the small port consumed by the
92
52
  * sculpt controller. The controller deliberately owns the session/turn loop.
@@ -131,6 +91,8 @@ export function createSculptRun({
131
91
  score = null,
132
92
  // Optional (reference) -> measureReferenceOutline result, put into the first turn's text.
133
93
  measure = null,
94
+ // The blueprint's cropped views ([{ view, yaw, dataUrl, name? }], #970): attached next to the reference and scored with it.
95
+ views = [],
134
96
  // The model every turn runs on (a /agent/models key) and its effort; omitted = the sidecar's defaults.
135
97
  // Measured on the Snorlax reference: effort "high" made the draft twice as slow for no better score.
136
98
  model = undefined,
@@ -252,7 +214,8 @@ export function createSculptRun({
252
214
  };
253
215
  const turnId = crypto.randomUUID();
254
216
  ownTurns.add(turnId);
255
- const returned = agent.sendTurn({ turnId, text, attachments: reference?.dataUrl ? [{ dataUrl: reference.dataUrl, name: reference.name }] : [], signal: controller.signal, ...(model ? { model } : {}), ...(effort ? { effort } : {}) }, callback);
217
+ const attachments = [...(reference?.dataUrl ? [{ dataUrl: reference.dataUrl, name: reference.name }] : []), ...views.map(view => ({ dataUrl: view.dataUrl, name: view.name ?? `blueprint-${view.view}.png` }))];
218
+ const returned = agent.sendTurn({ turnId, text, attachments, signal: controller.signal, ...(model ? { model } : {}), ...(effort ? { effort } : {}) }, callback);
256
219
  if (isAsyncIterable(returned)) {
257
220
  for await (const event of returned) callback(event);
258
221
  } else await returned;
@@ -274,7 +237,8 @@ export function createSculptRun({
274
237
  } catch (error) { note("warn", `Could not measure the reference: ${shortError(error?.message || error)}`, { message: `Could not measure the reference: ${String(error?.message || error)}` }); }
275
238
  }
276
239
  if (controller.signal.aborted) { note("warn", "Cancelled", { stage: "cancelled", message: "Sculpt run cancelled.", waiting: null }); settled = true; return; }
277
- let text = makeTurnText(reference, sculptId, false, outline);
240
+ if (views.length) note("ok", `Using the blueprint's ${views.map(view => view.view).join(" and ")} view${views.length > 1 ? "s" : ""} next to the reference`);
241
+ let text = makeTurnText(reference, sculptId, false, outline, views);
278
242
  while (!controller.signal.aborted) {
279
243
  if (elapsed() >= timeBudgetMs) break;
280
244
  const roundBefore = state.round;
@@ -304,7 +268,7 @@ export function createSculptRun({
304
268
  }
305
269
  retryCount = 0;
306
270
  if (!turnScored && score && state.sculptId) {
307
- const args = { id: state.sculptId, reference: referenceArg(reference) };
271
+ const args = views.length ? { id: state.sculptId, views: [{ yaw: 0, reference: referenceArg(reference) }, ...views.map(view => ({ yaw: view.yaw, reference: { dataUrl: view.dataUrl } }))] } : { id: state.sculptId, reference: referenceArg(reference) };
308
272
  publish({ waiting: waitFor("score", "Scoring the silhouette against the reference") });
309
273
  try {
310
274
  const value = await score(args);
@@ -319,7 +283,7 @@ export function createSculptRun({
319
283
  const nextRound = Math.max(1, state.round + 1);
320
284
  publish({ stage: "refine", round: nextRound, message: `Preparing refinement round ${nextRound} of ${maxRounds}.` });
321
285
  // The next turn's note says what was asked; nothing to log in between.
322
- text = makeTurnText(reference, state.sculptId, { round: nextRound, maxRounds, iou: state.iou, report: lastReport });
286
+ text = makeTurnText(reference, state.sculptId, { round: nextRound, maxRounds, iou: state.iou, report: lastReport }, null, views);
323
287
  }
324
288
  if (controller.signal.aborted) {
325
289
  note("warn", "Cancelled", { stage: "cancelled", message: "Sculpt run cancelled.", waiting: null });
@@ -0,0 +1,92 @@
1
+ // A sculpt card on the asset shelf (#740 S9 + S13), and the `.cozysculpt.json`
2
+ // file that carries one between browsers. Pure: no IndexedDB, no three.js, so
3
+ // scene-assets.js, the node tests and the shelf all read one truth.
4
+ //
5
+ // A card is a record in the shared asset store (`cozyclay.assets`) under a
6
+ // `sculpt-` id. Its bytes ARE the file: the UTF-8 JSON
7
+ // { "format": "cozysculpt", "version": 1, "name", "recipe", "thumb"? }
8
+ // so export writes the stored bytes and import stores what it parsed. The id is
9
+ // the SHA-256 of the normalized recipe, so saving the same sculpt twice keeps
10
+ // one card (the later name and thumbnail win). The store is origin-wide, so a
11
+ // card shows in every project; it is never part of a project export.
12
+ import { normalizeSculptRecipe } from "./sculpt-recipe.js";
13
+
14
+ const SCULPT_CARD_FORMAT = "cozysculpt";
15
+ const SCULPT_CARD_VERSION = 1;
16
+ export const SCULPT_CARD_TYPE = "application/vnd.cozyclay.sculpt+json";
17
+ export const SCULPT_FILE_SUFFIX = ".cozysculpt.json";
18
+ const SCULPT_ID = /^sculpt-[0-9a-f]{32}$/;
19
+ // A card file is a recipe and a 256 px picture; anything far bigger is not one.
20
+ const MAX_FILE_CHARS = 4 * 1024 * 1024;
21
+ const MAX_THUMB_CHARS = 1024 * 1024;
22
+ const THUMB = /^data:image\/(png|jpeg|webp);base64,[A-Za-z0-9+/]+=*$/;
23
+ const MAX_NAME = 120;
24
+
25
+ export const isSculptAssetId = (value) => typeof value === "string" && SCULPT_ID.test(value);
26
+
27
+ const cleanName = (name) => (typeof name === "string" ? name.trim().slice(0, MAX_NAME) : "");
28
+ const cleanThumb = (thumb) => (typeof thumb === "string" && thumb.length <= MAX_THUMB_CHARS && THUMB.test(thumb) ? thumb : null);
29
+
30
+ /** The `.cozysculpt.json` text of a card. Throws when the recipe is refused. */
31
+ export function serializeCard({ name, recipe, thumb = null }) {
32
+ const normalized = normalizeSculptRecipe(recipe);
33
+ if (!normalized.ok) throw new TypeError(`Recipe refused at ${normalized.error}`);
34
+ const picture = cleanThumb(thumb);
35
+ return JSON.stringify({ format: SCULPT_CARD_FORMAT, version: SCULPT_CARD_VERSION, name: cleanName(name) || "Sculpt", recipe: normalized.recipe, ...(picture ? { thumb: picture } : {}) });
36
+ }
37
+
38
+ /**
39
+ * Read a `.cozysculpt.json` text: { ok: true, card: { name, recipe, thumb } }
40
+ * or { ok: false, error } with a sentence for the user. The recipe is checked
41
+ * as a whole, never repaired; a bad thumbnail is dropped, not fatal.
42
+ */
43
+ export function parseCard(text) {
44
+ if (typeof text !== "string" || !text.trim()) return { ok: false, error: "the file is empty" };
45
+ if (text.length > MAX_FILE_CHARS) return { ok: false, error: "the file is too large to be a sculpt card" };
46
+ let data;
47
+ try {
48
+ data = JSON.parse(text);
49
+ } catch {
50
+ return { ok: false, error: "the file is not JSON" };
51
+ }
52
+ if (!data || typeof data !== "object" || Array.isArray(data) || data.format !== SCULPT_CARD_FORMAT) return { ok: false, error: "the file is not a .cozysculpt.json sculpt card" };
53
+ if (data.version !== SCULPT_CARD_VERSION) return { ok: false, error: `card version ${JSON.stringify(data.version)} is not supported (expected ${SCULPT_CARD_VERSION})` };
54
+ const normalized = normalizeSculptRecipe(data.recipe);
55
+ if (!normalized.ok) return { ok: false, error: `the recipe is refused at ${normalized.error}` };
56
+ return { ok: true, card: { name: cleanName(data.name) || "Sculpt", recipe: normalized.recipe, thumb: cleanThumb(data.thumb) } };
57
+ }
58
+
59
+ async function sha256Hex(text, subtle) {
60
+ if (!subtle?.digest) throw new Error("SubtleCrypto is unavailable — a secure context is required to save a sculpt card");
61
+ const digest = await subtle.digest("SHA-256", new TextEncoder().encode(text));
62
+ return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
63
+ }
64
+
65
+ /** The asset-store record of a card: the file text as bytes, under its recipe's id. */
66
+ export async function sculptCardRecord(card, subtle = globalThis.crypto?.subtle) {
67
+ const text = serializeCard(card);
68
+ const { name, recipe } = JSON.parse(text);
69
+ const id = `sculpt-${(await sha256Hex(JSON.stringify(recipe), subtle)).slice(0, 32)}`;
70
+ const bytes = new TextEncoder().encode(text);
71
+ return { id, type: SCULPT_CARD_TYPE, name, bytes: bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) };
72
+ }
73
+
74
+ /** normalizeAsset's branch for a `sculpt-` id: the bytes must parse as a card. */
75
+ export function normalizeSculptAsset(record, bytes) {
76
+ if (record.type !== SCULPT_CARD_TYPE) return null;
77
+ const parsed = parseCard(new TextDecoder().decode(bytes));
78
+ return parsed.ok ? { id: record.id, type: SCULPT_CARD_TYPE, bytes, name: parsed.card.name } : null;
79
+ }
80
+
81
+ /** A stored card record as the shelf shows it, or null when it does not read. */
82
+ export function readSculptCard(record) {
83
+ if (!isSculptAssetId(record?.id) || !(record.bytes instanceof ArrayBuffer)) return null;
84
+ const parsed = parseCard(new TextDecoder().decode(record.bytes));
85
+ return parsed.ok ? { id: record.id, ...parsed.card } : null;
86
+ }
87
+
88
+ /** A file name for the card's export: its name, kept to safe characters. */
89
+ export function sculptCardFileName(name) {
90
+ const stem = cleanName(name).replace(/[^\p{L}\p{N} _-]+/gu, "").trim().replace(/\s+/g, "-") || "sculpt";
91
+ return `${stem}${SCULPT_FILE_SUFFIX}`;
92
+ }
@@ -0,0 +1,69 @@
1
+ // The Content pane's Sculpts folder (#740 S9 + S13): one tile per sculpt card
2
+ // on the shelf (sculpt-shelf.js). A tile drags into the scene like any other
3
+ // asset and carries its recipe, so a drop needs no store read. A selected card
4
+ // can be exported as `.cozysculpt.json` or deleted; Import (or dropping card
5
+ // files on the folder) brings cards in.
6
+ import { useEffect, useRef, useState, useSyncExternalStore } from "react";
7
+ import { ko } from "./locale.js";
8
+ import { ContentTile, grabProps } from "./content-tile.jsx";
9
+ import { SearchEmpty } from "./search-friction.js";
10
+ import { deleteSculptCard, exportSculptCard, importSculptCardFiles, refreshSculptShelf, sculptShelfCards, subscribeSculptShelf } from "./sculpt-shelf.js";
11
+ import { SCULPT_FILE_SUFFIX } from "./sculpt-shelf-file.js";
12
+
13
+ const sculptKey = (id) => `sculpt:${id}`;
14
+
15
+ export default function SculptShelf({ query, selectedKey, onSelect, onAssetGrab, onAssetPlace }) {
16
+ const cards = useSyncExternalStore(subscribeSculptShelf, sculptShelfCards, sculptShelfCards);
17
+ const [note, setNote] = useState("");
18
+ const [confirmDelete, setConfirmDelete] = useState(null);
19
+ const fileRef = useRef(null);
20
+ useEffect(() => { void refreshSculptShelf(); }, []);
21
+ const selected = cards?.find((card) => sculptKey(card.id) === selectedKey) ?? null;
22
+ const visible = (cards ?? []).filter((card) => !query || card.name.toLowerCase().includes(query));
23
+ const importFiles = async (files) => setNote(await importSculptCardFiles(files));
24
+ const tiles = cards === null
25
+ ? [0, 1, 2].map((n) => <span className="content-tile is-loading" key={`loading-${n}`} aria-hidden="true"><span className="content-tile-thumb"><span className="content-tile-skeleton" /></span></span>)
26
+ : visible.map((card) => {
27
+ const key = sculptKey(card.id);
28
+ const payload = { kind: "sculpt", id: card.id, label: card.name, name: card.name, recipe: card.recipe, thumb: card.thumb };
29
+ return (
30
+ <ContentTile key={key} assetKey={key} label={card.name} type={ko("Sculpt", "조형물", "雕塑")}
31
+ title={ko(`Drag ${card.name} into the scene, or double-click to place it at the origin`, `${card.name}을(를) 씬에 드래그하거나 더블클릭해 원점에 놓으세요`, `将 ${card.name} 拖入场景,或双击将其放在原点`)}
32
+ preview={card.thumb ? <img className="content-tile-img" src={card.thumb} alt="" draggable={false} /> : <span className="content-tile-skeleton" aria-hidden="true" />}
33
+ selected={selectedKey === key} onSelect={onSelect} grab={grabProps(onAssetGrab, payload)} onPlace={() => onAssetPlace?.(payload)} />
34
+ );
35
+ });
36
+ return (
37
+ <div className="sculpt-shelf" data-testid="sculpt-shelf"
38
+ onDragOver={(event) => { if (event.dataTransfer?.types?.includes("Files")) event.preventDefault(); }}
39
+ onDrop={(event) => {
40
+ const files = [...(event.dataTransfer?.files ?? [])].filter((file) => file.name.toLowerCase().endsWith(".json"));
41
+ if (!files.length) return;
42
+ event.preventDefault();
43
+ event.stopPropagation();
44
+ void importFiles(files);
45
+ }}>
46
+ <div className="sculpt-shelf-actions">
47
+ <button type="button" data-testid="sculpt-shelf-import" onClick={() => fileRef.current?.click()}>{ko("Import…", "가져오기…", "导入…")}</button>
48
+ <input ref={fileRef} type="file" hidden multiple accept={`${SCULPT_FILE_SUFFIX},.json,application/json`} data-testid="sculpt-shelf-file"
49
+ onChange={(event) => { const files = [...event.target.files]; event.target.value = ""; void importFiles(files); }} />
50
+ {selected && <>
51
+ <button type="button" data-testid="sculpt-shelf-export" onClick={async () => setNote(await exportSculptCard(selected))}>{ko("Export", "내보내기", "导出")}</button>
52
+ {confirmDelete === selected.id ? <>
53
+ <button type="button" className="sculpt-shelf-delete" data-testid="sculpt-shelf-delete-confirm"
54
+ onClick={async () => { setConfirmDelete(null); onSelect(null); await deleteSculptCard(selected.id); setNote(ko(`Deleted ${selected.name} from the shelf`, `${selected.name} 선반에서 삭제함`, `已从架子删除 ${selected.name}`)); }}>
55
+ {ko(`Delete ${selected.name}?`, `${selected.name} 삭제할까요?`, `删除 ${selected.name}?`)}
56
+ </button>
57
+ <button type="button" onClick={() => setConfirmDelete(null)}>{ko("Cancel", "취소")}</button>
58
+ </> : <button type="button" data-testid="sculpt-shelf-delete" onClick={() => setConfirmDelete(selected.id)}>{ko("Delete", "삭제")}</button>}
59
+ </>}
60
+ {note && <span className="sculpt-shelf-note" role="status" data-testid="sculpt-shelf-note">{note}</span>}
61
+ </div>
62
+ {cards !== null && !cards.length
63
+ ? <p className="content-empty">{ko("No sculpts on the shelf yet. Press Save to shelf in Sculpt mode (5), or import a .cozysculpt.json.", "선반에 조형물이 아직 없어요. 조형 모드(5)에서 선반에 저장을 누르거나 .cozysculpt.json을 가져오세요.")}</p>
64
+ : query && !visible.length
65
+ ? <SearchEmpty surface="assets" query={query}><p className="content-empty">{ko("Nothing matches this search.", "검색 결과가 없어요.")}</p></SearchEmpty>
66
+ : <div className="content-grid" role="list">{tiles}</div>}
67
+ </div>
68
+ );
69
+ }
@@ -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
+ }