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
@@ -19,6 +19,8 @@
19
19
  * adapter is the only part that needs a browser.
20
20
  */
21
21
  import { isHdrAssetId, normalizeHdrAsset } from "./stage-hdr.js";
22
+ // A `sculpt-` id is a sculpt card on the shelf: the user's own library, never swept.
23
+ import { isSculptAssetId, normalizeSculptAsset } from "./sculpt-shelf-file.js";
22
24
 
23
25
  /** Content-addressed, so the same bytes always land on the same id. */
24
26
  export const ASSET_ID_PREFIX = "img-";
@@ -86,7 +88,7 @@ export function isMeshAssetId(value) {
86
88
  }
87
89
 
88
90
  export function isAssetId(value) {
89
- return isImageAssetId(value) || isMeshAssetId(value) || isHdrAssetId(value);
91
+ return isImageAssetId(value) || isMeshAssetId(value) || isHdrAssetId(value) || isSculptAssetId(value);
90
92
  }
91
93
 
92
94
  async function sha256Hex(bytes, subtle) {
@@ -152,6 +154,7 @@ export function normalizeAsset(record) {
152
154
  if (!bytes || !bytes.byteLength) return null;
153
155
  // An `hdr-` id is an HDR environment file (stage-hdr.js), stored verbatim.
154
156
  if (isHdrAssetId(record.id)) return normalizeHdrAsset(record, bytes);
157
+ if (isSculptAssetId(record.id)) return normalizeSculptAsset(record, bytes);
155
158
  // Prefix and MIME must agree: a mesh id with an image type (or the reverse)
156
159
  // is a cross-wired record and cannot be drawn as either kind.
157
160
  if (isMeshAssetId(record.id)) {
@@ -378,14 +381,23 @@ export async function deleteAssetWithGraphGuard({
378
381
  return false;
379
382
  }
380
383
 
381
- /** Stored ids that nothing points at any more, ready to be swept. */
384
+ // Stored ids that nothing points at any more, ready to be swept. A sculpt card
385
+ // is referenced by no scene on purpose: the shelf, not Manage storage, deletes it.
382
386
  export function unreachableAssetIds(storedIds, scenes) {
383
387
  const reachable = referencedAssetIds(scenes);
384
- return (Array.isArray(storedIds) ? storedIds : []).filter((id) => isAssetId(id) && !reachable.has(id));
388
+ return (Array.isArray(storedIds) ? storedIds : []).filter((id) => isAssetId(id) && !isSculptAssetId(id) && !reachable.has(id));
385
389
  }
386
390
 
387
391
  /* ------------------------------------------------------------ storage ---- */
388
392
 
393
+ // A readwrite transaction settles on complete, error or abort.
394
+ const committed = (tx) =>
395
+ new Promise((resolve, reject) => {
396
+ tx.oncomplete = resolve;
397
+ tx.onerror = () => reject(tx.error);
398
+ tx.onabort = () => reject(tx.error);
399
+ });
400
+
389
401
  const request = (req) =>
390
402
  new Promise((resolve, reject) => {
391
403
  req.onsuccess = () => resolve(req.result);
@@ -416,11 +428,7 @@ export async function putAsset(db, record) {
416
428
  if (!asset) throw new TypeError("putAsset needs a normalizable asset record");
417
429
  const tx = db.transaction(ASSET_STORE_NAME, "readwrite");
418
430
  tx.objectStore(ASSET_STORE_NAME).put(asset);
419
- await new Promise((resolve, reject) => {
420
- tx.oncomplete = resolve;
421
- tx.onerror = () => reject(tx.error);
422
- tx.onabort = () => reject(tx.error);
423
- });
431
+ await committed(tx);
424
432
  return asset;
425
433
  }
426
434
 
@@ -440,10 +448,6 @@ export async function deleteAsset(db, id) {
440
448
  if (!isAssetId(id)) return false;
441
449
  const tx = db.transaction(ASSET_STORE_NAME, "readwrite");
442
450
  tx.objectStore(ASSET_STORE_NAME).delete(id);
443
- await new Promise((resolve, reject) => {
444
- tx.oncomplete = resolve;
445
- tx.onerror = () => reject(tx.error);
446
- tx.onabort = () => reject(tx.error);
447
- });
451
+ await committed(tx);
448
452
  return true;
449
453
  }
package/src/scenes.js CHANGED
@@ -52,10 +52,12 @@ export function isProxyFigure(character) {
52
52
  export const DEFAULT_SUBJECT_ONE = "a young woman in a tan coat";
53
53
  export const DEFAULT_SUBJECT_TWO = "a man in a dark coat";
54
54
 
55
+ // The one-character cast every scene used to open with. A new scene now
56
+ // starts on an empty stage; legacy documents that never stored a cast, and
57
+ // fixtures that want a body to work with, still seed this one.
58
+ const SEED_CHARACTER = Object.freeze({ id: "char-a", model: DEFAULT_CHARACTER_MODEL, x: 0, z: 0, rot: 0, hidden: false, pose: null, subject: DEFAULT_SUBJECT_ONE });
55
59
  const DEFAULT_SCENE_STAGE = Object.freeze({
56
- characters: Object.freeze([
57
- Object.freeze({ id: "char-a", model: DEFAULT_CHARACTER_MODEL, x: 0, z: 0, rot: 0, hidden: false, pose: null, subject: DEFAULT_SUBJECT_ONE }),
58
- ]),
60
+ characters: Object.freeze([]),
59
61
  hasCharSheet: false,
60
62
  // The look reference for the location: a data URL so it survives save/load
61
63
  environmentImage: null,
@@ -241,16 +243,17 @@ function migrateLegacyCast(source) {
241
243
  return cast;
242
244
  }
243
245
 
244
- const STAGE_ENVELOPE_KEYS = new Set([
246
+ const LEGACY_CAST_KEYS = Object.freeze(["charA", "charB", "showB", "poseA", "poseB", "subject", "subject2"]), STAGE_ENVELOPE_KEYS = new Set([
245
247
  "characters", "hasCharSheet", "environmentImage", "environment", "style", "hasEnvSheet", "shotAspect", "cameraPresetId", "sensorId", "keyLight", "lighting",
246
- "charA", "charB", "showB", "poseA", "poseB", "subject", "subject2",
248
+ ...LEGACY_CAST_KEYS,
247
249
  ]);
248
250
 
249
251
  export function createSceneStage(stage = null) {
250
252
  const source = plainObject(stage) ? stage : {};
251
- const characters = Array.isArray(source.characters) && source.characters.length
253
+ // With no cast list, a pre-v3 envelope folds its fixed cast in; anything else is an empty stage.
254
+ const characters = Array.isArray(source.characters)
252
255
  ? source.characters.map((entry, index) => createCharacterEntry(entry, index))
253
- : migrateLegacyCast(source);
256
+ : LEGACY_CAST_KEYS.some((key) => Object.hasOwn(source, key)) ? migrateLegacyCast(source) : [];
254
257
  // Unknown extra keys (shotAspect was one, once) ride along so the envelope
255
258
  // stays sealed-but-lossless; legacy cast keys are deliberately dropped.
256
259
  const extras = {};
@@ -359,8 +362,13 @@ export function activeScene(scenes, activeSceneId) {
359
362
  return index < 0 ? null : scenes[index];
360
363
  }
361
364
 
362
- export function createSceneDocument() {
363
- const scene = createScene();
365
+ // The stage a scene opened with before empty stages: one standing Y Bot.
366
+ export const createSeededSceneStage = () => createSceneStage({ characters: [SEED_CHARACTER] });
367
+
368
+ // A one-scene document on an empty stage. `{ seedCharacter: true }` gives
369
+ // the scene the old one-character cast instead.
370
+ export function createSceneDocument({ seedCharacter = false } = {}) {
371
+ const scene = { ...createScene(), ...(seedCharacter === true ? { stage: createSeededSceneStage() } : {}) };
364
372
  return { version: SCENES_VERSION, activeSceneId: scene.id, scenes: [scene] };
365
373
  }
366
374
 
@@ -410,7 +418,8 @@ export function readSceneDocument(raw, legacyRaw = null) {
410
418
  if (!plainObject(legacy) || legacy.version !== 1 || !Array.isArray(legacy.objects)) {
411
419
  return { status: "corrupt", document: createSceneDocument(), dropped: 0, quarantineRaw: legacyRaw };
412
420
  }
413
- const document = createSceneDocument();
421
+ // A v1 scene had no stored cast; it always opened with the seed character.
422
+ const document = createSceneDocument({ seedCharacter: true });
414
423
  document.scenes[0].objects = legacy.objects.filter(plainObject).map((object) => retireObjectKind(cloneValue(object)));
415
424
  return { status: "migrated", document, dropped: legacy.objects.length - document.scenes[0].objects.length };
416
425
  }
@@ -432,7 +441,9 @@ export function readSceneDocument(raw, legacyRaw = null) {
432
441
  // v4 → v5 needs no step of its own: every cast member passes through
433
442
  // createCharacterEntry, which fills the missing posture with "stand".
434
443
  const legacyClock = payload.version < 4;
435
- const repaired = repairDocument(payload, createSceneStage(payload.stage), legacyClock);
444
+ // Before v3 the cast lived on one global stage, and a document without one
445
+ // opened with the seed character.
446
+ const repaired = repairDocument(payload, payload.version < 3 && !plainObject(payload.stage) ? createSeededSceneStage() : createSceneStage(payload.stage), legacyClock);
436
447
  return { status: migrating ? "migrated" : "valid", ...repaired };
437
448
  }
438
449
 
@@ -2,6 +2,7 @@
2
2
  // reference picture into an orthographic model sheet, the drawing a sculpt is
3
3
  // built from. The template names no subject: everything subject-specific comes
4
4
  // from the attached reference and the operator's optional notes.
5
+ import { referenceMask } from "./sculpt-compare.js";
5
6
 
6
7
  /** The sheet's fixed layout, as fractions of the image: a 2 x 2 grid. FRONT and
7
8
  * SIDE share the top row so their head and foot lines are the same pixels;
@@ -63,3 +64,58 @@ export function blueprintErrorMessage(error, fallback) {
63
64
  }
64
65
  return fallback;
65
66
  }
67
+
68
+ /** The camera yaw each compared view was drawn from (sculpt-compare.js: 0 in
69
+ * front, positive toward the subject's own left). TOP looks straight down and is
70
+ * cropped but not compared: sculpt.compare's elevation stops at 60 degrees. */
71
+ export const BLUEPRINT_VIEW_YAWS = Object.freeze({ front: 0, side: 90, back: 180 });
72
+
73
+ /** One panel of an RGBA sheet, cut on whole pixels from the layout fractions. */
74
+ export function cropBlueprintPanel(image, { x, y, width, height }) {
75
+ const left = Math.round(x * image.width), top = Math.round(y * image.height);
76
+ const w = Math.round((x + width) * image.width) - left, h = Math.round((y + height) * image.height) - top;
77
+ const data = new Uint8Array(w * h * 4);
78
+ for (let row = 0; row < h; row += 1) {
79
+ const start = ((top + row) * image.width + left) * 4;
80
+ data.set(image.data.subarray(start, start + w * 4), row * w * 4);
81
+ }
82
+ return { width: w, height: h, data };
83
+ }
84
+
85
+ /**
86
+ * Cut a generated sheet (RGBA) into its views by BLUEPRINT_LAYOUT and mask each
87
+ * one. A view whose mask fails (no subject, no background, or a subject that
88
+ * touches the panel edge and so was cut or spills into its neighbour) is dropped
89
+ * with a notice. Returns { views: [{ view, label, yaw, image, mask }], notices }.
90
+ */
91
+ export function cropBlueprintViews(sheet, layout = BLUEPRINT_LAYOUT) {
92
+ const views = [], notices = [];
93
+ for (const panel of layout) {
94
+ const image = cropBlueprintPanel(sheet, panel);
95
+ try {
96
+ const { width, height, mask, source } = referenceMask(image);
97
+ let minX = width, maxX = -1, minY = height, maxY = -1;
98
+ for (let i = 0; i < mask.length; i += 1) if (mask[i]) {
99
+ const px = i % width, py = (i - px) / width;
100
+ minX = Math.min(minX, px); maxX = Math.max(maxX, px); minY = Math.min(minY, py); maxY = Math.max(maxY, py);
101
+ }
102
+ if (minX === 0 || minY === 0 || maxX === width - 1 || maxY === height - 1) throw new Error("the subject touches the panel edge");
103
+ const box = { x: minX / width, y: minY / height, width: (maxX - minX + 1) / width, height: (maxY - minY + 1) / height };
104
+ views.push({ view: panel.view, label: panel.label, yaw: BLUEPRINT_VIEW_YAWS[panel.view] ?? null, image, mask: { source, box } });
105
+ } catch (error) {
106
+ notices.push(`${panel.label} view dropped: ${String(error?.message || error).replace(/\.$/, "")}.`);
107
+ }
108
+ }
109
+ return { views, notices };
110
+ }
111
+
112
+ /** The cropped views of a generated sheet as PNG data URLs, through an image
113
+ * port ({ decode, encodePng }: commands/sculpt-images.js in the browser). */
114
+ export async function blueprintViewImages(sheetDataUrl, images) {
115
+ const { views, notices } = cropBlueprintViews(await images.decode({ dataUrl: sheetDataUrl }));
116
+ return { notices, views: await Promise.all(views.map(async ({ view, label, yaw, image, mask }) => ({ view, label, yaw, mask, width: image.width, height: image.height, dataUrl: await images.encodePng(image) }))) };
117
+ }
118
+
119
+ /** What a sculpt run takes from a sheet: its side and back views (the front is the reference itself). */
120
+ export const blueprintRunViews = (sheet) => (sheet?.views ?? []).filter((view) => view.view === "side" || view.view === "back")
121
+ .map(({ view, yaw, dataUrl }) => ({ view, yaw, dataUrl, name: `blueprint-${view}.png` }));
@@ -115,6 +115,11 @@ export function sculptAgentClient(transport, readContext) {
115
115
  * signed in, or a provider key held by the sidecar). */
116
116
  export const agentSessionReady = (status) => Boolean(status?.signedIn || status?.providersConfigured > 0);
117
117
 
118
+ /** Whether the sidecar holds a ChatGPT token: /oauth/status `signedIn` is the
119
+ * ChatGPT login alone (a provider key only counts in providersConfigured). The
120
+ * blueprint's image model is ChatGPT-only, so its button needs this (#970). */
121
+ export const chatgptSignedIn = (status) => status?.signedIn === true;
122
+
118
123
  /** A stored image asset as a data URL (the bytes the store holds, so the run
119
124
  * scores against exactly the stored, possibly downscaled, picture). */
120
125
  export function assetDataUrl(asset) {
@@ -0,0 +1,115 @@
1
+ // The part gizmo's arithmetic (#740 S8). Pure: no React, no DOM. A part's
2
+ // `position` and `rotation` are written in its PARENT's frame (the object
3
+ // frame for a root part), so a gizmo that moves the part in the world is
4
+ // read back through the parent: world = objectWorld × parent × local. A
5
+ // mirrored copy is edited through the part the recipe holds: the copy's own
6
+ // local values reflected across x = 0 of the same frame, which is how
7
+ // expandSculptParts derived the copy in the first place.
8
+ import * as THREE from "three";
9
+ import { SCULPT_MIRROR_SUFFIX, expandSculptParts } from "./sculpt-recipe.js";
10
+ import { tidy } from "./sculpt-recipe-read.js";
11
+
12
+ const DEG = Math.PI / 180;
13
+ // The recipe keeps 1e-4 (tidy); a field the drag left within that is unchanged.
14
+ const SAME = 1e-4;
15
+
16
+ const reflect = (local) => ({ position: [-local.position[0], local.position[1], local.position[2]], rotation: [local.rotation[0], -local.rotation[1], -local.rotation[2]] });
17
+ const wrap = (deg) => { const out = ((((deg + 180) % 360) + 360) % 360) - 180; return out === -180 ? 180 : out; };
18
+ const angleGap = (a, b) => Math.abs(wrap(a - b));
19
+
20
+ /**
21
+ * Where the selected part is drawn, as the gizmo needs it: the drawn mesh id,
22
+ * the object-frame matrix of the frame its values are written in (`parent`,
23
+ * column-major), and its local position, rotation (deg, XYZ) and size. A
24
+ * mirrored selection gives the copy's frame and values. Null when the recipe
25
+ * has no such part.
26
+ */
27
+ export function sculptPartFrame(recipe, selected) {
28
+ if (!recipe?.parts || !selected?.partId) return null;
29
+ // A cutter (op) is never drawn, so it has no mesh for the gizmo to sit on (the tree does not offer it).
30
+ if (recipe.parts.find((part) => part.id === selected.partId)?.op) return null;
31
+ const drawn = expandSculptParts(recipe);
32
+ const meshId = selected.mirrored ? `${selected.partId}${SCULPT_MIRROR_SUFFIX}` : selected.partId;
33
+ const row = drawn.find((entry) => entry.id === meshId);
34
+ if (!row) return null;
35
+ const parent = row.parent === undefined ? new THREE.Matrix4().toArray() : drawn.find((entry) => entry.id === row.parent).matrix;
36
+ return { meshId, parent, position: [...row.position], rotation: [...row.rotation], size: [...row.size] };
37
+ }
38
+
39
+ /** A local transform (position, rotation in degrees XYZ, scale) as a matrix. */
40
+ function localMatrix({ position, rotation, scale = [1, 1, 1] }) {
41
+ const quaternion = new THREE.Quaternion().setFromEuler(new THREE.Euler(rotation[0] * DEG, rotation[1] * DEG, rotation[2] * DEG, "XYZ"));
42
+ return new THREE.Matrix4().compose(new THREE.Vector3(...position), quaternion, new THREE.Vector3(...scale));
43
+ }
44
+
45
+ /** The world matrix of the frame a part's values are written in: objectWorld × parent. */
46
+ export function partParentWorld(objectWorld, frame) {
47
+ return objectWorld.clone().multiply(new THREE.Matrix4().fromArray(frame.parent));
48
+ }
49
+
50
+ /** Parent space → world: the matrix a part with `local` values draws at. */
51
+ export function partWorldMatrix(objectWorld, frame, local = frame) {
52
+ return partParentWorld(objectWorld, frame).multiply(localMatrix(local));
53
+ }
54
+
55
+ /** World → parent space: the position, rotation (deg) and scale `world` has in the part's frame. */
56
+ export function partLocalFromWorld(objectWorld, frame, world) {
57
+ const local = partParentWorld(objectWorld, frame).invert().multiply(world);
58
+ const position = new THREE.Vector3(), quaternion = new THREE.Quaternion(), scale = new THREE.Vector3();
59
+ local.decompose(position, quaternion, scale);
60
+ return partLocalOf({ position, quaternion, scale });
61
+ }
62
+
63
+ /** An Object3D-like transform (position, quaternion, scale) as recipe-style local values. */
64
+ export function partLocalOf({ position, quaternion, scale }) {
65
+ const euler = new THREE.Euler().setFromQuaternion(quaternion, "XYZ");
66
+ return { position: position.toArray(), rotation: [euler.x / DEG, euler.y / DEG, euler.z / DEG], scale: scale.toArray() };
67
+ }
68
+
69
+ /** Shift's uniform scale: every axis takes the factor of the axis that moved furthest from 1. */
70
+ export function uniformScale(scale) {
71
+ const lead = scale.reduce((best, value) => (Math.abs(value - 1) > Math.abs(best - 1) ? value : best), 1);
72
+ return [lead, lead, lead];
73
+ }
74
+
75
+ /**
76
+ * The one `object.sculpt` edit a finished drag writes: `{ set: partId,
77
+ * position?, rotation?, size? }` with only the fields the drag changed, or
78
+ * null when it changed nothing. `local` is the gizmo's result in the frame
79
+ * sculptPartFrame gave (scale multiplies the size). A mirrored selection
80
+ * writes the reflected values onto the part the recipe holds, so the pair
81
+ * moves together. Nothing is clamped: an undrawable size goes to the command,
82
+ * which refuses it with the reason.
83
+ */
84
+ export function sculptPartEdit(recipe, selected, local) {
85
+ const frame = sculptPartFrame(recipe, selected);
86
+ if (!frame) return null;
87
+ const moved = selected.mirrored ? reflect(local) : local;
88
+ const authored = selected.mirrored ? reflect(frame) : frame;
89
+ const edit = { set: selected.partId };
90
+ const position = moved.position.map(tidy);
91
+ if (position.some((value, axis) => Math.abs(value - authored.position[axis]) >= SAME)) edit.position = position;
92
+ const rotation = moved.rotation.map((deg) => tidy(wrap(deg)));
93
+ if (rotation.some((value, axis) => angleGap(value, authored.rotation[axis]) >= SAME)) edit.rotation = rotation;
94
+ const size = frame.size.map((value, axis) => tidy(value * (local.scale?.[axis] ?? 1)));
95
+ if (size.some((value, axis) => Math.abs(value - frame.size[axis]) >= SAME)) edit.size = size;
96
+ return Object.keys(edit).length > 1 ? edit : null;
97
+ }
98
+
99
+ /**
100
+ * The drag's live preview without rebuilding geometry: every drawn part's
101
+ * object-frame matrix after `edit`, and how much the edited part's size
102
+ * grew per axis (its meshes scale by that until the rebuilt sculpt lands).
103
+ * `Map(meshId → { matrix, ratio })`.
104
+ */
105
+ export function previewSculptParts(recipe, edit) {
106
+ const parts = recipe.parts.map((part) => (part.id === edit?.set ? { ...part, ...edit } : part));
107
+ const sizes = new Map(recipe.parts.map((part) => [part.id, part.size]));
108
+ const out = new Map();
109
+ for (const row of expandSculptParts({ ...recipe, parts })) {
110
+ const source = row.id.endsWith(SCULPT_MIRROR_SUFFIX) ? row.id.slice(0, -SCULPT_MIRROR_SUFFIX.length) : row.id;
111
+ const before = sizes.get(source) ?? row.size;
112
+ out.set(row.id, { matrix: row.matrix, ratio: row.size.map((value, axis) => (before[axis] ? value / before[axis] : 1)) });
113
+ }
114
+ return out;
115
+ }
@@ -0,0 +1,159 @@
1
+ // The selected sculpt part's gizmo on the turntable (#740 S8). three.js
2
+ // TransformControls drives a proxy that sits in the part's PARENT frame, so
3
+ // its position, rotation and scale read straight back as the recipe's values
4
+ // (sculpt-part-edit.js). While a drag runs, the meshes follow a preview (no
5
+ // geometry rebuild); the drag's end writes one object.sculpt edit, one undo.
6
+ // A refused edit puts the meshes back and the reason shows under the tree.
7
+ // The gizmo lives on SCULPT_LAYER alone: the shot preview and exports never
8
+ // see it, and its picks use the main pane's rect, as part picking does.
9
+ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
10
+ import { useFrame, useThree } from "@react-three/fiber";
11
+ import * as THREE from "three";
12
+ import { TransformControls } from "three/examples/jsm/controls/TransformControls.js";
13
+ import { SCULPT_LAYER } from "./sculpt-mode.js";
14
+ import { partLocalOf, partParentWorld, previewSculptParts, sculptPartEdit, sculptPartFrame, uniformScale } from "./sculpt-part-edit.js";
15
+
16
+ const MODES = { move: "translate", rotate: "rotate", scale: "scale" };
17
+ const DEG = Math.PI / 180;
18
+
19
+ function partMeshes(nodes) {
20
+ const meshes = [];
21
+ for (const node of nodes) node.traverse((child) => { if (child.isMesh && child.userData?.sculptPart) meshes.push(child); });
22
+ return meshes;
23
+ }
24
+
25
+ export function SculptPartGizmo({ camera, orbitRef, nodesRef, gizmoRef, recipe, parts }) {
26
+ const { gl, scene, invalidate } = useThree();
27
+ const selected = parts?.selected ?? null;
28
+ const frame = useMemo(() => sculptPartFrame(recipe, selected), [recipe, selected]);
29
+ const [rig] = useState(() => {
30
+ const base = new THREE.Object3D();
31
+ base.matrixAutoUpdate = false;
32
+ const proxy = new THREE.Object3D();
33
+ base.add(proxy);
34
+ return { base, proxy };
35
+ });
36
+ const live = useRef(null);
37
+ useLayoutEffect(() => { live.current = { recipe, selected, commit: parts?.commit }; });
38
+ // { meshes: [[mesh, position, quaternion, scale]] } while a drag runs; { held } after a write until its recipe lands.
39
+ const dragRef = useRef(null);
40
+ // The controls' restore, for the selection effect: a selection change mid-drag (Esc) puts the meshes back first.
41
+ const restoreRef = useRef(() => {});
42
+
43
+ useEffect(() => {
44
+ if (!camera) return undefined;
45
+ const made = new TransformControls(camera, gl.domElement);
46
+ const canvas = gl.domElement, orbit = orbitRef;
47
+ // The sculpt camera spans the main pane, not the whole canvas.
48
+ made._getPointer = (event) => {
49
+ const pane = (document.querySelector(".vp-pane.vp-main") ?? canvas).getBoundingClientRect();
50
+ return { x: ((event.clientX - pane.left) / pane.width) * 2 - 1, y: -((event.clientY - pane.top) / pane.height) * 2 + 1, button: event.button };
51
+ };
52
+ const ray = made.getRaycaster();
53
+ const mask = ray.layers.mask;
54
+ ray.layers.set(SCULPT_LAYER);
55
+ const helper = made.getHelper();
56
+ helper.traverse((node) => node.layers.set(SCULPT_LAYER));
57
+ scene.add(rig.base, helper);
58
+ let shift = false;
59
+ const restore = () => {
60
+ for (const [mesh, position, quaternion, scale] of dragRef.current?.meshes ?? []) {
61
+ mesh.position.copy(position);
62
+ mesh.quaternion.copy(quaternion);
63
+ mesh.scale.copy(scale);
64
+ }
65
+ dragRef.current = null;
66
+ invalidate();
67
+ };
68
+ restoreRef.current = restore;
69
+ const editNow = () => {
70
+ const { recipe: current, selected: part } = live.current;
71
+ if (made.mode === "scale" && shift) rig.proxy.scale.fromArray(uniformScale(rig.proxy.scale.toArray()));
72
+ return sculptPartEdit(current, part, partLocalOf(rig.proxy));
73
+ };
74
+ const onDown = () => {
75
+ dragRef.current = { meshes: partMeshes(nodesRef.current).map((mesh) => [mesh, mesh.position.clone(), mesh.quaternion.clone(), mesh.scale.clone()]) };
76
+ };
77
+ const onChange = () => {
78
+ if (!dragRef.current?.meshes) return;
79
+ const preview = previewSculptParts(live.current.recipe, editNow());
80
+ const matrix = new THREE.Matrix4();
81
+ for (const [mesh] of dragRef.current.meshes) {
82
+ const entry = preview.get(mesh.userData.sculptPart);
83
+ if (!entry) continue;
84
+ matrix.fromArray(entry.matrix).decompose(mesh.position, mesh.quaternion, mesh.scale);
85
+ mesh.scale.fromArray(entry.ratio);
86
+ }
87
+ invalidate();
88
+ };
89
+ const onUp = () => {
90
+ if (!dragRef.current?.meshes) return;
91
+ const edit = editNow();
92
+ if (edit && live.current.commit?.(edit)) dragRef.current = { held: true };
93
+ else restore();
94
+ };
95
+ const onDragging = (event) => {
96
+ if (orbitRef.current) orbitRef.current.enabled = !event.value;
97
+ invalidate();
98
+ };
99
+ // A press on a handle must not also start an orbit: the orbit is off before its own listener runs.
100
+ const onPress = () => { if (made.axis !== null && orbitRef.current) orbitRef.current.enabled = false; };
101
+ const onRelease = () => { if (!made.dragging && orbitRef.current) orbitRef.current.enabled = true; };
102
+ const onShift = (event) => { shift = event.shiftKey; };
103
+ made.addEventListener("mouseDown", onDown);
104
+ made.addEventListener("objectChange", onChange);
105
+ made.addEventListener("mouseUp", onUp);
106
+ made.addEventListener("dragging-changed", onDragging);
107
+ made.addEventListener("change", invalidate);
108
+ canvas.addEventListener("pointerdown", onPress, true);
109
+ window.addEventListener("pointerup", onRelease);
110
+ window.addEventListener("pointermove", onShift, true);
111
+ window.addEventListener("keydown", onShift, true);
112
+ window.addEventListener("keyup", onShift, true);
113
+ gizmoRef.current = made;
114
+ return () => {
115
+ if (dragRef.current?.meshes) restore();
116
+ canvas.removeEventListener("pointerdown", onPress, true);
117
+ window.removeEventListener("pointerup", onRelease);
118
+ window.removeEventListener("pointermove", onShift, true);
119
+ window.removeEventListener("keydown", onShift, true);
120
+ window.removeEventListener("keyup", onShift, true);
121
+ if (orbit.current) orbit.current.enabled = true;
122
+ gizmoRef.current = null;
123
+ made.detach();
124
+ made.dispose();
125
+ scene.remove(rig.base, helper);
126
+ ray.layers.mask = mask;
127
+ };
128
+ }, [camera, gl, scene, rig, nodesRef, orbitRef, gizmoRef, invalidate]);
129
+
130
+ // Declared after the controls' own effect, so they exist when these run.
131
+ useEffect(() => { gizmoRef.current?.setMode(MODES[parts?.tool] ?? "translate"); invalidate(); }, [camera, gizmoRef, parts?.tool, invalidate]);
132
+ // A new recipe (the write landed, or any other edit) or selection ends the hold.
133
+ useEffect(() => {
134
+ const controls = gizmoRef.current;
135
+ if (!controls) return;
136
+ if (dragRef.current?.held) dragRef.current = null;
137
+ // A drag the selection change interrupted (Esc before the release) writes nothing and puts the meshes back.
138
+ if (dragRef.current?.meshes) { restoreRef.current(); if (orbitRef.current) orbitRef.current.enabled = true; }
139
+ if (frame) controls.attach(rig.proxy);
140
+ else controls.detach();
141
+ invalidate();
142
+ }, [camera, gizmoRef, frame, rig, orbitRef, invalidate]);
143
+
144
+ // Between drags the proxy follows the part where it is drawn now.
145
+ useFrame(() => {
146
+ const controls = gizmoRef.current;
147
+ if (!controls || !frame || controls.dragging || dragRef.current) return;
148
+ const mesh = partMeshes(nodesRef.current).find((node) => node.userData.sculptPart === frame.meshId);
149
+ if (!mesh?.parent) return;
150
+ mesh.parent.updateWorldMatrix(true, false);
151
+ rig.base.matrix.copy(partParentWorld(mesh.parent.matrixWorld, frame));
152
+ rig.base.updateMatrixWorld(true);
153
+ rig.proxy.position.fromArray(frame.position);
154
+ rig.proxy.rotation.set(frame.rotation[0] * DEG, frame.rotation[1] * DEG, frame.rotation[2] * DEG, "XYZ");
155
+ rig.proxy.scale.set(1, 1, 1);
156
+ rig.proxy.updateMatrixWorld(true);
157
+ });
158
+ return null;
159
+ }
@@ -0,0 +1,72 @@
1
+ // The selected sculpt part's highlight on the turntable (#740 S7): per part
2
+ // mesh, an inverted hull pushed out along the normals and drawn back-faces
3
+ // only (a rim of the selection colour), and a thin see-through tint over its
4
+ // visible surface, which still reads where the part sinks into another. The
5
+ // shells live on SCULPT_LAYER alone: the sculpt camera sees them, the shot
6
+ // preview and every export never do.
7
+ import { useEffect, useState } from "react";
8
+ import { useFrame, useThree } from "@react-three/fiber";
9
+ import * as THREE from "three";
10
+ import { SCULPT_LAYER } from "./sculpt-mode.js";
11
+ import { sculptPartMeshIds } from "./sculpt-pick.js";
12
+
13
+ // The panel's selection colour (--select), so the part and its tree row match;
14
+ // the dark theme's value stands in until the stylesheet is read.
15
+ const OUTLINE_COLOR = "#e8a33d";
16
+ const HEX_COLOR = /^#[0-9a-f]{6}$/i;
17
+ const RIM_WIDTH = 0.02;
18
+ const TINT_WIDTH = 0.002;
19
+
20
+ function inflated(width, options) {
21
+ const material = new THREE.MeshBasicMaterial({ color: OUTLINE_COLOR, ...options });
22
+ material.onBeforeCompile = (shader) => {
23
+ shader.vertexShader = shader.vertexShader.replace("#include <begin_vertex>", `#include <begin_vertex>\n\ttransformed += normal * ${width};`);
24
+ };
25
+ return material;
26
+ }
27
+
28
+ function highlightMaterials() {
29
+ return [
30
+ inflated(RIM_WIDTH, { side: THREE.BackSide }),
31
+ inflated(TINT_WIDTH, { transparent: true, opacity: 0.3, depthWrite: false }),
32
+ ];
33
+ }
34
+
35
+ function shellFor(source, material) {
36
+ const shell = source.isInstancedMesh ? new THREE.InstancedMesh(source.geometry, material, source.count) : new THREE.Mesh(source.geometry, material);
37
+ if (source.isInstancedMesh) shell.instanceMatrix = source.instanceMatrix;
38
+ shell.userData = { source, noInk: true };
39
+ shell.matrixAutoUpdate = false;
40
+ shell.frustumCulled = false;
41
+ // Never a pick target: a click goes through the rim to the part.
42
+ shell.raycast = () => {};
43
+ shell.layers.set(SCULPT_LAYER);
44
+ return shell;
45
+ }
46
+
47
+ export function SculptPartOutline({ nodesRef, selected }) {
48
+ const invalidate = useThree((state) => state.invalidate);
49
+ const [group] = useState(() => new THREE.Group());
50
+ const [materials] = useState(highlightMaterials);
51
+ useEffect(() => () => { for (const material of materials) material.dispose(); }, [materials]);
52
+ const ids = sculptPartMeshIds(selected);
53
+ const key = ids.join(" ");
54
+ useEffect(() => {
55
+ const css = getComputedStyle(document.documentElement).getPropertyValue("--select").trim();
56
+ for (const material of materials) material.color.set(HEX_COLOR.test(css) ? css : OUTLINE_COLOR);
57
+ invalidate();
58
+ }, [key, materials, invalidate]);
59
+ // The sculpt rebuilds its meshes when the recipe changes, so the shells
60
+ // follow whichever meshes carry the selected ids this frame.
61
+ useFrame(() => {
62
+ const sources = [];
63
+ for (const node of nodesRef.current) node.traverse((child) => { if (child.isMesh && ids.includes(child.userData?.sculptPart)) sources.push(child); });
64
+ const shells = group.children;
65
+ if (shells.length !== sources.length * materials.length || shells.some((shell, index) => shell.userData.source !== sources[Math.floor(index / materials.length)])) {
66
+ group.clear();
67
+ for (const source of sources) for (const material of materials) group.add(shellFor(source, material));
68
+ }
69
+ for (const shell of group.children) shell.matrix.copy(shell.userData.source.matrixWorld);
70
+ });
71
+ return <primitive object={group} />;
72
+ }
@@ -0,0 +1,53 @@
1
+ // Part picking in Sculpt mode (#740 S7). Pure: no React, no DOM. A sculpt's
2
+ // meshes carry their part id as `userData.sculptPart` (sculpt-geometry.js); a
3
+ // mirrored copy's id ends in SCULPT_MIRROR_SUFFIX and resolves to the part the
4
+ // recipe actually holds, because that is the part an edit writes.
5
+ import { SCULPT_MIRROR_SUFFIX } from "./sculpt-recipe.js";
6
+
7
+ /** `{ partId, mirrored }` for a mesh's part id, or null when it has none. */
8
+ function resolveSculptPart(name) {
9
+ if (typeof name !== "string" || !name) return null;
10
+ const mirrored = name.endsWith(SCULPT_MIRROR_SUFFIX);
11
+ return { partId: mirrored ? name.slice(0, -SCULPT_MIRROR_SUFFIX.length) : name, mirrored };
12
+ }
13
+
14
+ /**
15
+ * The part nearest along the raycaster's ray among `roots` and their
16
+ * descendants, or null on a miss. Only meshes tagged as sculpt parts count:
17
+ * a hit on anything else (the turntable, an outline shell) is passed over.
18
+ */
19
+ export function pickSculptPart(raycaster, roots) {
20
+ const hits = raycaster.intersectObjects(roots, true);
21
+ const hit = hits.find((entry) => entry.object.userData?.sculptPart);
22
+ return hit ? resolveSculptPart(hit.object.userData.sculptPart) : null;
23
+ }
24
+
25
+ /**
26
+ * The recipe's authored parts as an indented tree, parents before children
27
+ * in recipe order: `{ id, shape, depth, parent, mirror }`. `mirror` is true
28
+ * when the part is drawn twice, by its own flag or a mirrored ancestor's.
29
+ */
30
+ export function sculptPartTree(recipe) {
31
+ const parts = Array.isArray(recipe?.parts) ? recipe.parts : [];
32
+ const rows = [];
33
+ const visit = (parent, depth, mirrored) => {
34
+ for (const part of parts) {
35
+ if ((part.parent ?? null) !== parent) continue;
36
+ const mirror = mirrored || part.mirror === true;
37
+ rows.push({ id: part.id, shape: part.shape, depth, parent, mirror, cutter: Boolean(part.op) });
38
+ visit(part.id, depth + 1, mirror);
39
+ }
40
+ };
41
+ visit(null, 0, false);
42
+ return rows;
43
+ }
44
+
45
+ /** The mesh ids a selected part draws as: the part and its mirrored copy. */
46
+ export function sculptPartMeshIds(selected) {
47
+ return selected ? [selected.partId, `${selected.partId}${SCULPT_MIRROR_SUFFIX}`] : [];
48
+ }
49
+
50
+ /** An open menu or modal dialog owns Escape: the part selection waits until it has closed. */
51
+ export function overlayOwnsEscape(root = globalThis.document) {
52
+ return Boolean(root?.querySelector?.('[role="menu"], [role="dialog"][aria-modal="true"], dialog[open]'));
53
+ }