cozyclay 3.0.3 → 3.1.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 (246) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/INSTALL_FOR_AGENTS.md +146 -0
  3. package/README.md +74 -192
  4. package/THIRD_PARTY_NOTICES.md +0 -21
  5. package/bin/agent/agent-routes.mjs +29 -62
  6. package/bin/agent/agent-runner.mjs +5 -3
  7. package/bin/agent/main-agent.mjs +70 -0
  8. package/bin/agent/main-prompt.mjs +17 -0
  9. package/bin/agent/plan-tool.mjs +114 -0
  10. package/bin/agent/script-tool.mjs +2 -0
  11. package/bin/agent/sculpt-sketch.mjs +190 -0
  12. package/bin/agent/session-store.mjs +4 -3
  13. package/bin/agent/sketch-code.mjs +151 -0
  14. package/bin/agent/sketch-sandbox.mjs +102 -0
  15. package/bin/agent/studio-prompt.mjs +1 -1
  16. package/bin/agent/studio-task-routes.mjs +185 -0
  17. package/bin/agent/studio-tasks.mjs +156 -0
  18. package/bin/agent/studio-tools.mjs +3 -2
  19. package/bin/agent/studio-turn-transaction.mjs +76 -0
  20. package/bin/agent/turn-frames.mjs +4 -0
  21. package/bin/cozyclay.mjs +5 -7
  22. package/dist/app/index.html +7 -6
  23. package/dist/assets/TaskBoard-ngdqiu7u.js +1 -0
  24. package/dist/assets/app-BlwOpest.css +1 -0
  25. package/dist/assets/app-DmR7jBkz.js +5144 -0
  26. package/dist/assets/compile-worker-D0pKioa0.js +5 -0
  27. package/dist/assets/landing-y3EqDgSZ.js +1 -0
  28. package/dist/assets/modulepreload-polyfill-P2Xu9kJm.js +1 -0
  29. package/dist/assets/motion3-compile-BB3_1-EB.js +1 -0
  30. package/dist/assets/scene-asset-sync-CBx7XRti.css +1 -0
  31. package/dist/assets/scene-asset-sync-Cgdtr1Yw.js +13 -0
  32. package/dist/assets/task-board-ErQvfoaz.js +14 -0
  33. package/dist/assets/workflow-zQM-EKW1.js +179 -0
  34. package/dist/assets/x-bot-tpose-body-BusyR3eg.json +1 -0
  35. package/dist/assets/y-bot-tpose-body-Dy1VKChZ.json +1 -0
  36. package/dist/cozyclay-package.json +1 -1
  37. package/dist/greybox-to-video/index.html +2 -3
  38. package/dist/index.html +7 -57
  39. package/dist/llms.txt +2 -1
  40. package/dist/previs-software/index.html +2 -2
  41. package/dist/privacy/index.html +3 -7
  42. package/dist/seedance-camera-control/index.html +2 -2
  43. package/dist/sitemap.xml +5 -5
  44. package/dist/sw.js +5 -1
  45. package/dist/thumbs/blank-stage-dark.webp +0 -0
  46. package/dist/thumbs/blank-stage-light.webp +0 -0
  47. package/dist/workflow/index.html +5 -4
  48. package/mcp/mesh-file.mjs +3 -1
  49. package/mcp/server.mjs +3 -0
  50. package/mcp/tool-handlers.mjs +2 -2
  51. package/package.json +5 -3
  52. package/src/App.jsx +51 -282
  53. package/src/analytics.js +3 -148
  54. package/src/app-stage.jsx +1 -1
  55. package/src/asset-pane.jsx +5 -10
  56. package/src/auto-flat.js +10 -0
  57. package/src/bus-tasks.js +11 -5
  58. package/src/command-bus.js +1 -1
  59. package/src/commands/elements.js +4 -3
  60. package/src/commands/motion3-compile.js +51 -27
  61. package/src/commands/motion3.js +3 -3
  62. package/src/commands/objects.js +1 -1
  63. package/src/commands/project.js +1 -1
  64. package/src/commands/shot.js +16 -4
  65. package/src/controls.jsx +0 -11
  66. package/src/cuts.js +11 -0
  67. package/src/domains/cast.js +3 -2
  68. package/src/domains/motion.js +3 -10
  69. package/src/domains/playback.js +32 -0
  70. package/src/domains/scenes.js +3 -22
  71. package/src/domains/shots.js +5 -5
  72. package/src/dualview.jsx +11 -15
  73. package/src/hierarchy-panel.css +18 -4
  74. package/src/hierarchy-panel.jsx +12 -11
  75. package/src/kits/held.js +3 -2
  76. package/src/light-blockers.js +2 -2
  77. package/src/main.jsx +5 -0
  78. package/src/material-look.js +189 -0
  79. package/src/material-presets.js +78 -0
  80. package/src/material-textures.js +157 -0
  81. package/src/motion/ground.js +5 -14
  82. package/src/motion/playback-frame-store.js +79 -0
  83. package/src/motion/playback-probe.js +16 -0
  84. package/src/motion/playback-subject.js +7 -0
  85. package/src/motion/timeline.css +43 -5
  86. package/src/motion/timeline.jsx +4 -3
  87. package/src/motion3/body.js +37 -5
  88. package/src/motion3/carried.js +11 -3
  89. package/src/motion3/compile-job.js +1 -1
  90. package/src/motion3/compiler/compile.js +7 -1
  91. package/src/motion3/compiler/finger-close.js +137 -0
  92. package/src/motion3/compiler/limbs.js +18 -3
  93. package/src/motion3/compiler/pose.js +6 -0
  94. package/src/motion3/compiler/validate-objects.js +17 -2
  95. package/src/motion3/compiler/validate-parts.js +2 -1
  96. package/src/motion3/compiler/vocab.js +4 -4
  97. package/src/motion3/describe.js +2 -2
  98. package/src/motion3/fix/index.js +5 -3
  99. package/src/motion3/flight.js +38 -0
  100. package/src/motion3/free-motion.js +10 -7
  101. package/src/motion3/load.js +125 -0
  102. package/src/motion3/mass.js +23 -0
  103. package/src/motion3/object-keys.js +11 -6
  104. package/src/motion3/object-pose.js +11 -3
  105. package/src/motion3/pipeline.js +3 -0
  106. package/src/motion3/qa/explain.js +3 -1
  107. package/src/motion3/qa/grasp-checks.js +123 -0
  108. package/src/motion3/qa/hints-grasp.js +71 -0
  109. package/src/motion3/qa/index.js +3 -1
  110. package/src/motion3/qa/object-checks.js +7 -2
  111. package/src/motion3/qa/patch.js +2 -1
  112. package/src/motion3/qa/spec-checks.js +10 -1
  113. package/src/motion3/schema/fields.js +3 -3
  114. package/src/motion3/schema/topics.js +9 -8
  115. package/src/motion3/schema/types.js +4 -1
  116. package/src/motion3/seat.js +84 -0
  117. package/src/motion3/settle.js +11 -6
  118. package/src/motion3/shapes.js +70 -0
  119. package/src/motion3/skills/catch.js +71 -0
  120. package/src/motion3/skills/index.js +10 -3
  121. package/src/motion3/skills/info.js +13 -0
  122. package/src/motion3/skills/throw.js +72 -0
  123. package/src/object-catalog.jsx +0 -4
  124. package/src/object-gizmo.jsx +2 -1
  125. package/src/object-look.js +9 -5
  126. package/src/object-path.js +1 -1
  127. package/src/panels/ProjectPanel.jsx +42 -21
  128. package/src/panels/details.css +15 -4
  129. package/src/popover.js +37 -0
  130. package/src/posestudio.jsx +15 -10
  131. package/src/primitive-look.js +38 -0
  132. package/src/project-browser-art.jsx +19 -0
  133. package/src/project-browser.css +74 -121
  134. package/src/project-browser.jsx +36 -60
  135. package/src/props.jsx +24 -35
  136. package/src/render-passes.js +7 -1
  137. package/src/retired-kinds.js +37 -0
  138. package/src/scene-objects.js +5 -9
  139. package/src/scenes.js +4 -5
  140. package/src/sculpt-colours.js +65 -0
  141. package/src/sculpt-compare.js +13 -50
  142. package/src/sculpt-csg.js +112 -0
  143. package/src/sculpt-curve.js +123 -0
  144. package/src/sculpt-cutters.js +43 -0
  145. package/src/sculpt-detail.js +207 -0
  146. package/src/sculpt-geometry-loft-convex.js +100 -0
  147. package/src/sculpt-geometry-shapes.js +250 -0
  148. package/src/sculpt-geometry.js +33 -15
  149. package/src/sculpt-loft-rings.js +225 -0
  150. package/src/sculpt-noise.js +65 -0
  151. package/src/sculpt-part-fields.js +27 -0
  152. package/src/sculpt-recipe-read.js +65 -0
  153. package/src/sculpt-recipe.js +33 -75
  154. package/src/sculpt-run.js +1 -1
  155. package/src/sculpt-scatter-items.js +183 -0
  156. package/src/sculpt-scatter-shape.js +57 -0
  157. package/src/sculpt-scatter.js +124 -0
  158. package/src/sculpt-shapes-curved.js +211 -0
  159. package/src/sculpt-shapes-loft-convex.js +218 -0
  160. package/src/sculpt-shapes.js +138 -0
  161. package/src/sculpt-sketch-state.js +76 -0
  162. package/src/sculpt-sketch.jsx +124 -0
  163. package/src/sculpt-surface-fields.js +65 -0
  164. package/src/settings-menu.jsx +1 -20
  165. package/src/shell/BottomDock.jsx +9 -9
  166. package/src/shell/MenuBar.jsx +1 -9
  167. package/src/shell/PreferencesDialog.jsx +3 -4
  168. package/src/shell/ViewportToolbar.jsx +3 -3
  169. package/src/shell/add-camera-shot.js +11 -0
  170. package/src/shell/glass-regions.css +3 -2
  171. package/src/shell/glass.css +0 -5
  172. package/src/shell/lens-scrub-math.js +51 -0
  173. package/src/shell/lens-scrub.css +73 -0
  174. package/src/shell/lens-scrub.jsx +98 -0
  175. package/src/shell/mode.css +6 -5
  176. package/src/shell/preferences.css +1 -32
  177. package/src/shell/shell.css +18 -1
  178. package/src/shell/studio-shell-context.js +5 -0
  179. package/src/shot-frame.js +26 -0
  180. package/src/stage-canvas-rigs.jsx +7 -8
  181. package/src/stage-characters.jsx +26 -0
  182. package/src/stage-gizmo.js +43 -0
  183. package/src/stage-labels-ko.js +0 -6
  184. package/src/stage-startup-defaults.js +3 -9
  185. package/src/starter-scenes.js +5 -11
  186. package/src/studio-agent-commands.js +2 -2
  187. package/src/studio-agent-protocol.js +2 -7
  188. package/src/studio-app-binding.js +1 -1
  189. package/src/studio-elements.js +6 -2
  190. package/src/styles/themes.css +13 -0
  191. package/src/styles.css +6 -674
  192. package/src/text/text-object.jsx +1 -1
  193. package/src/ui.jsx +8 -16
  194. package/src/use-embed-bridge.js +3 -6
  195. package/src/use-render-activity.js +3 -1
  196. package/src/view-ghosts.jsx +5 -3
  197. package/src/viewport-visible-rect.js +37 -0
  198. package/src/workflow/AgentPanel.jsx +79 -46
  199. package/src/workflow/AgentPlanCard.jsx +41 -0
  200. package/src/workflow/AgentProgressCard.jsx +1 -1
  201. package/src/workflow/TaskBoard.jsx +102 -0
  202. package/src/workflow/TaskInline.jsx +114 -0
  203. package/src/workflow/agent-client.js +25 -36
  204. package/src/workflow/agent-panel.css +14 -1
  205. package/src/workflow/agent-plan.css +93 -0
  206. package/src/workflow/agent-plan.js +59 -0
  207. package/src/workflow/attachment-image.js +10 -3
  208. package/src/workflow/attachment-video.js +91 -0
  209. package/src/workflow/task-board.css +98 -0
  210. package/src/workflow/task-board.js +169 -0
  211. package/tools/check-eslint.mjs +2 -1
  212. package/tools/check-knip.mjs +2 -1
  213. package/tools/dev/pages/greybox-to-video.html +2 -3
  214. package/tools/dev/pages/previs-software.html +2 -2
  215. package/tools/dev/pages/privacy.html +3 -7
  216. package/tools/dev/pages/seedance-camera-control.html +2 -2
  217. package/tools/dev-full.mjs +11 -2
  218. package/tools/eslint-baseline.json +6 -6
  219. package/tools/knip-baseline.json +0 -11
  220. package/tools/loc-baseline.json +30 -31
  221. package/tools/metrics-snapshot.mjs +43 -5
  222. package/tools/render-start-thumbs.mjs +175 -0
  223. package/tools/run-tests.mjs +10 -17
  224. package/tools/sitemap.mjs +1 -1
  225. package/dist/assets/analytics-BibyE5ar.js +0 -6
  226. package/dist/assets/app-DhRJ-PBz.js +0 -5088
  227. package/dist/assets/app-f-fkV5uP.css +0 -1
  228. package/dist/assets/compile-worker-DqiA4AaN.js +0 -5
  229. package/dist/assets/first-shot-handoff-Qx7SKl38.js +0 -1
  230. package/dist/assets/landing-DYg87MDY.js +0 -1
  231. package/dist/assets/scene-asset-sync-DBYQBR0T.js +0 -20
  232. package/dist/assets/scene-asset-sync-_S8NQl5B.css +0 -1
  233. package/dist/assets/workflow-BSA22Ofr.js +0 -179
  234. package/dist/scenes/city-block.cclayproject +0 -1
  235. package/src/burn-in.js +0 -91
  236. package/src/camera-tutorial.jsx +0 -520
  237. package/src/facing-marks.js +0 -88
  238. package/src/first-shot-handoff.js +0 -44
  239. package/src/panels/motion.css +0 -197
  240. package/src/set-pieces.jsx +0 -183
  241. package/src/tutorial-analytics.js +0 -86
  242. package/src/tutorial-walk.js +0 -17
  243. package/src/use-camera-tutorial.js +0 -50
  244. package/src/use-semantic-state.js +0 -12
  245. package/tools/metrics/archive-traffic.mjs +0 -138
  246. package/tools/perf/ik-camera-drag.mjs +0 -138
@@ -0,0 +1,37 @@
1
+ // Object kinds an old project can carry that this build no longer ships.
2
+
3
+ /**
4
+ * Kinds an earlier build shipped and this one does not (hand-built set
5
+ * pieces), with the standing size they had. They exist only so an old project
6
+ * still opens: each record becomes a plain cube with the same transform and
7
+ * the same size, so its blocking, collisions, route and children survive.
8
+ */
9
+ const RETIRED_KINDS = Object.freeze({
10
+ chair: { width: 0.6, depth: 0.6, height: 1.15 },
11
+ car: { width: 1.8, depth: 4.5, height: 1.4 },
12
+ "small-plane": { width: 3.4, depth: 3.6, height: 1.4 },
13
+ });
14
+
15
+ /** A stored record of a retired kind as the cube that stands in for it; any
16
+ * other record is returned untouched. Scale absorbs the retired footprint, the
17
+ * same single-`scale` fallback normalizeSceneObject reads applies first. */
18
+ export function retireObjectKind(record) {
19
+ const size = record && typeof record === "object" ? RETIRED_KINDS[record.renderer] : undefined;
20
+ if (!size) return record;
21
+ const single = record.scaleX === undefined && record.scaleY === undefined && record.scaleZ === undefined ? Number(record.scale) : NaN;
22
+ const axis = (value) => {
23
+ const n = value === undefined ? (Number.isFinite(single) ? single : 1) : Number(value);
24
+ return Number.isFinite(n) ? n : 1;
25
+ };
26
+ const { scale: _scale, ...rest } = record;
27
+ return {
28
+ ...rest,
29
+ renderer: "cube",
30
+ scaleX: axis(record.scaleX) * size.width,
31
+ scaleY: axis(record.scaleY) * size.height,
32
+ scaleZ: axis(record.scaleZ) * size.depth,
33
+ footprint: { width: 1, depth: 1 },
34
+ height: 1,
35
+ supportY: 1,
36
+ };
37
+ }
@@ -24,10 +24,11 @@ import { MESH_DEFAULT_HEIGHT, MESH_HEIGHT_MIN } from "./scene-mesh.js";
24
24
  import { normalizeSculptRecipe, sculptStandingBox } from "./sculpt-recipe.js";
25
25
  import { GREY_BOX } from "./object-colors.js";
26
26
  import { isEffectivelyHidden } from "./object-hidden.js";
27
+ import { retireObjectKind } from "./retired-kinds.js";
27
28
  import { objectSize } from "./object-size.js";
28
29
  import { EFFECT_ENTRY, effectFields } from "./effects/effect-record.js";
29
30
  import { TEXT_ENTRY, TEXT_KIND, textFields, textPatch } from "./text/text-record.js";
30
- import { LIGHT_KIND, LIGHT_MENU_ENTRIES, lightLibraryEntry, objectLookFields, objectLookPatch, objectGraspFields, objectGraspPatch } from "./object-look.js";
31
+ import { LIGHT_KIND, LIGHT_MENU_ENTRIES, lightLibraryEntry, objectLookFields, objectLookPatch, objectGraspFields, objectGraspPatch, objectMaterialKeys } from "./object-look.js";
31
32
  export { OBJECT_COLORS, normalizeObjectColor, rememberObjectColor, readStoredObjectColors, writeStoredObjectColors } from "./object-colors.js";
32
33
  export { isEffectivelyHidden };
33
34
 
@@ -65,8 +66,7 @@ const snapTo = (value, step) => Number((step > 0 ? Math.round(value / step) * st
65
66
  * object stands (used for the selection box and the gizmo's height).
66
67
  *
67
68
  * Primitives are grey-box grey on purpose: a blockout stands in for something
68
- * else, and a tinted maquette reads as a finished prop. The hand-built set
69
- * pieces keep their clay colours because they ARE the thing they depict.
69
+ * else, and a tinted maquette reads as a finished prop.
70
70
  */
71
71
  export const OBJECT_LIBRARY = [
72
72
  { kind: "cube", label: "Cube", group: "Primitives", footprint: { width: 1, depth: 1 }, height: 1, color: GREY_BOX },
@@ -75,11 +75,6 @@ export const OBJECT_LIBRARY = [
75
75
  { kind: "cylinder", label: "Cylinder", group: "Primitives", footprint: { width: 1, depth: 1 }, height: 1, color: GREY_BOX },
76
76
  { kind: "cone", label: "Cone", group: "Primitives", footprint: { width: 1, depth: 1 }, height: 1, color: GREY_BOX },
77
77
  { kind: "plane", label: "Plane", group: "Primitives", footprint: { width: 2, depth: 2 }, height: 0, color: GREY_BOX },
78
- // supportY is the walkable surface, which can differ from the object's
79
- // bounding height (a chair's back rises above its seat).
80
- { kind: "chair", label: "Chair", group: "Set pieces", footprint: { width: 0.6, depth: 0.6 }, height: 1.15, supportY: 0.495, color: "#b9855d" },
81
- { kind: "car", label: "Car", group: "Set pieces", footprint: { width: 1.8, depth: 4.5 }, height: 1.4, color: "#d98770" },
82
- { kind: "small-plane", label: "Plane (aircraft)", group: "Set pieces", footprint: { width: 3.4, depth: 3.6 }, height: 1.4, color: "#7896a4" },
83
78
  ];
84
79
 
85
80
  /**
@@ -552,7 +547,7 @@ const TRANSFORM_LIMITS = {
552
547
  /** The patch keys updateSceneObject reads for this object; any other key is
553
548
  * silently ignored there, so callers that must not be silent check against this. */
554
549
  export function objectPatchFields(object) {
555
- const fields = [...Object.keys(TRANSFORM_LIMITS), "name", "color", "hidden", "opacity", "path", "glow", "grasp"];
550
+ const fields = [...Object.keys(TRANSFORM_LIMITS), "name", "color", "hidden", "opacity", "path", "glow", "grasp", ...objectMaterialKeys(object?.renderer)];
556
551
  if (object?.renderer === LIGHT_KIND) fields.push("light");
557
552
  if (object?.renderer === CUTOUT_KIND) fields.push("assetId", "sourceAssetId", "matteAssetId", "matteScale", "height", "aspect", "width", "stretch");
558
553
  if (object?.renderer === MESH_KIND) fields.push("clay", "assetId", "height");
@@ -994,6 +989,7 @@ function objectOpacity(value) {
994
989
 
995
990
  export function normalizeSceneObject(record) {
996
991
  if (!record || typeof record !== "object" || Array.isArray(record)) return null;
992
+ record = retireObjectKind(record);
997
993
  const entry = objectLibraryEntry(record.renderer);
998
994
  if (!entry) return null;
999
995
  if (typeof record.id !== "string" || !record.id) return null;
package/src/scenes.js CHANGED
@@ -6,7 +6,7 @@ import { DEFAULT_SENSOR_FORMAT, SENSOR_FORMATS } from "./shot.js";
6
6
  import { normalizeStableItems } from "./stable-items.js";
7
7
 
8
8
  import { elementByPath } from "./studio-elements.js";
9
- import { wrapAngle } from "./scene-objects.js";
9
+ import { wrapAngle } from "./scene-objects.js"; import { retireObjectKind } from "./retired-kinds.js";
10
10
  import { normalizeTakeSource } from "./domains/motion/take-source.js";
11
11
  import { createKeyLight, normalizeLighting } from "./stage-lighting.js";
12
12
  // The timeline clock lives in timeline-fps.js (three-free, so the agent
@@ -115,8 +115,6 @@ export function normalizeReferenceImage(value) {
115
115
 
116
116
  /** Stature band for a cast member: the gizmo's scale handles are a deliberate
117
117
  * artistic ask, and a previs giant or child is legitimate. */
118
- export const CHARACTER_SCALE_MIN = CHARACTER_SCALE_LIMITS.min;
119
- export const CHARACTER_SCALE_MAX = CHARACTER_SCALE_LIMITS.max;
120
118
  const clampScale = (value) => (Number.isFinite(value) && value > 0
121
119
  ? Math.max(CHARACTER_SCALE_LIMITS.min, Math.min(CHARACTER_SCALE_LIMITS.max, value))
122
120
  : 1);
@@ -373,7 +371,8 @@ function repairScene(record, existing, fallbackNumber, fallbackStage, legacyCloc
373
371
  return {
374
372
  id: record.id,
375
373
  name: uniqueName(typeof record.name === "string" ? record.name : `SCENE ${String(fallbackNumber).padStart(2, "0")}`, existing),
376
- objects: record.objects.filter(plainObject).map((object) => cloneValue(object)),
374
+ // A kind this build no longer ships opens as the cube that stands in for it.
375
+ objects: record.objects.filter(plainObject).map((object) => retireObjectKind(cloneValue(object))),
377
376
  // The shot document is a sealed envelope with its OWN version, and it
378
377
  // migrates its own frames when its reader opens it.
379
378
  shotDocument: cloneValue(record.shotDocument ?? null),
@@ -412,7 +411,7 @@ export function readSceneDocument(raw, legacyRaw = null) {
412
411
  return { status: "corrupt", document: createSceneDocument(), dropped: 0, quarantineRaw: legacyRaw };
413
412
  }
414
413
  const document = createSceneDocument();
415
- document.scenes[0].objects = legacy.objects.filter(plainObject).map((object) => cloneValue(object));
414
+ document.scenes[0].objects = legacy.objects.filter(plainObject).map((object) => retireObjectKind(cloneValue(object)));
416
415
  return { status: "migrated", document, dropped: legacy.objects.length - document.scenes[0].objects.length };
417
416
  }
418
417
  return { status: "absent", document: createSceneDocument(), dropped: 0 };
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Sculpt colours for the shaded look, applied once per part by
3
+ * buildSculptGroup after the part's material exists:
4
+ *
5
+ * - a `gradient` (sculpt-surface-fields.js) becomes a per-vertex `color`
6
+ * attribute blending the part's colour into the gradient's;
7
+ * - a scatter's items each get a small seeded hue and brightness jitter (and
8
+ * the gradient, by where the item sits), so a field is not one flat swatch.
9
+ *
10
+ * The colour then lives in the vertex or instance colours, so the material's
11
+ * own colour goes white and `vertexColors` turns on (a texture map still
12
+ * multiplies in). A flat display mode (auto-colour, through materialFor)
13
+ * skips this, so it ignores gradients and jitter.
14
+ */
15
+
16
+ import * as THREE from "three";
17
+ import { isMirrorCopy, partSeed, seededRandom } from "./sculpt-noise.js";
18
+
19
+ const AXES = { x: 0, y: 1, z: 2 };
20
+ /** Item-to-item spread: hue ± this (of the wheel), brightness × 1 ± this. */
21
+ const JITTER_HUE = 0.025;
22
+ const JITTER_SHADE = 0.16;
23
+
24
+ /** How far a local point is along the gradient: 0 at `from` (part colour), 1 at `to`. */
25
+ function blend(part, x, y, z) {
26
+ const { axis, from, to } = part.gradient;
27
+ const i = AXES[axis];
28
+ // A mirrored copy reads its x reversed, so an x gradient reflects with the part.
29
+ const along = [isMirrorCopy(part) ? -x : x, y, z][i] / part.size[i] + 0.5;
30
+ if (to === from) return along >= to ? 1 : 0;
31
+ return THREE.MathUtils.clamp((along - from) / (to - from), 0, 1);
32
+ }
33
+
34
+ export function applySculptColours(mesh, part) {
35
+ if (!part.gradient && !mesh.isInstancedMesh) return;
36
+ const base = new THREE.Color(part.color);
37
+ const end = part.gradient ? new THREE.Color(part.gradient.color) : null;
38
+ const colour = new THREE.Color();
39
+ if (mesh.isInstancedMesh) {
40
+ const random = seededRandom(partSeed(part, part.seed + 1));
41
+ const matrix = new THREE.Matrix4(), at = new THREE.Vector3();
42
+ for (let i = 0; i < mesh.count; i += 1) {
43
+ colour.copy(base);
44
+ if (end) {
45
+ at.setFromMatrixPosition(mesh.getMatrixAt(i, matrix));
46
+ colour.lerp(end, blend(part, at.x, at.y, at.z));
47
+ }
48
+ colour.offsetHSL((random() * 2 - 1) * JITTER_HUE, 0, 0).multiplyScalar(1 + (random() * 2 - 1) * JITTER_SHADE);
49
+ mesh.setColorAt(i, colour);
50
+ }
51
+ mesh.instanceColor.needsUpdate = true;
52
+ } else {
53
+ const position = mesh.geometry.getAttribute("position");
54
+ const colours = new Float32Array(position.count * 3);
55
+ for (let i = 0; i < position.count; i += 1) {
56
+ colour.copy(base).lerp(end, blend(part, position.getX(i), position.getY(i), position.getZ(i))).toArray(colours, 3 * i);
57
+ }
58
+ mesh.geometry.setAttribute("color", new THREE.BufferAttribute(colours, 3));
59
+ }
60
+ for (const material of [mesh.material].flat()) {
61
+ material.color?.set(0xffffff);
62
+ material.vertexColors = !!mesh.geometry.getAttribute("color");
63
+ material.needsUpdate = true;
64
+ }
65
+ }
@@ -16,6 +16,7 @@
16
16
  * Pure: image buffers in, numbers and RGBA out. The MCP tool owns the files.
17
17
  */
18
18
  import { expandSculptParts, patchSculptRecipe } from "./sculpt-recipe.js";
19
+ import { SCULPT_SHAPE_SPECS } from "./sculpt-shapes.js";
19
20
 
20
21
  const COMPARE_SIZE = 256;
21
22
 
@@ -117,37 +118,6 @@ function applyMatrix(m, [x, y, z]) {
117
118
  return [m[0] * x + m[4] * y + m[8] * z + m[12], m[1] * x + m[5] * y + m[9] * z + m[13], m[2] * x + m[6] * y + m[10] * z + m[14]];
118
119
  }
119
120
 
120
- function partSurfaceSamples(part) {
121
- const [sx, sy, sz] = part.size.map((v) => v / 2);
122
- const points = [];
123
- if (part.shape === "box") {
124
- for (const x of [-sx, sx]) for (const y of [-sy, sy]) for (const z of [-sz, sz]) points.push([x, y, z]);
125
- return points;
126
- }
127
- const p = 2 + 6 * (part.roundness ?? 0);
128
- const taper = part.taper ?? 0;
129
- for (let i = 0; i <= 16; i += 1) {
130
- const v = -1 + (2 * i) / 16;
131
- const k = taper > 0 ? 1 - taper * 0.6 * ((v + 1) / 2) : taper < 0 ? 1 + taper * 0.6 * ((1 - v) / 2) : 1;
132
- for (let j = 0; j < 24; j += 1) {
133
- const t = (j / 24) * 2 * Math.PI;
134
- if (part.shape === "cylinder") {
135
- const r = 1 - Math.max(0, part.taper) * ((v + 1) / 2) - Math.max(0, -part.taper) * ((1 - v) / 2);
136
- points.push([Math.cos(t) * sx * r, v * sy, Math.sin(t) * sz * r]);
137
- } else if (part.shape === "torus" || part.shape === "frame") {
138
- points.push([Math.cos(t) * sx, Math.sin(t) * sy, v * sz]);
139
- if (part.shape === "frame") points.push([Math.sign(Math.cos(t)) * sx, Math.sign(Math.sin(t)) * sy, v * sz]);
140
- } else {
141
- const ring = Math.max(0, 1 - Math.abs(v) ** p) ** (1 / p);
142
- const c = Math.cos(t), s = Math.sin(t);
143
- const norm = (Math.abs(c) ** p + Math.abs(s) ** p) ** (1 / p);
144
- points.push([(c / norm) * sx * ring * k, v * sy, (s / norm) * sz * ring * k]);
145
- }
146
- }
147
- }
148
- return points;
149
- }
150
-
151
121
  function convexHull(points) {
152
122
  const pts = points.slice().sort((a, b) => a[0] - b[0] || a[1] - b[1]);
153
123
  const cross = (o, a, b) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
@@ -157,26 +127,19 @@ function convexHull(points) {
157
127
  return lower.slice(0, -1).concat(upper.slice(0, -1));
158
128
  }
159
129
 
160
- /** Projected convex outline of every drawn part, in view metres. Frames keep their hole. */
130
+ /** Projected convex outline of every drawn part, in view metres. Frames keep their hole. A cutter (op) adds no area and cuts no hole: the reference mask fills enclosed holes anyway, and an intersect's parent keeps its uncut outline. */
161
131
  export function sculptViewPolygons(recipe, { yaw = 0, elevation = 0 } = {}) {
162
- return expandSculptParts(recipe).map((part) => {
163
- const local = partSurfaceSamples(part);
164
- const polygon = convexHull(local.map((point) => viewProject(applyMatrix(part.matrix, point), yaw, elevation)));
165
- let hole = null;
166
- if (part.shape === "frame" || part.shape === "torus") {
167
- const ix = part.size[0] / 2 - (part.shape === "frame" ? part.border : part.size[2]);
168
- const iy = part.size[1] / 2 - (part.shape === "frame" ? part.border : part.size[2]);
169
- if (ix > 0 && iy > 0) {
170
- const corners = [[-ix, -iy], [ix, -iy], [ix, iy], [-ix, iy]].flatMap(([x, y]) => [[x, y, -part.size[2] / 2], [x, y, part.size[2] / 2]]);
171
- const projected = corners.map((point) => viewProject(applyMatrix(part.matrix, point), yaw, elevation));
172
- // See-through only where the front and back openings overlap; the back
173
- // face's opening alone is a slightly generous stand-in.
174
- const opening = convexHull(projected.filter((_, i) => i % 2 === 0));
175
- hole = opening.length >= 3 ? opening : null;
176
- }
177
- }
178
- return { id: part.id, polygon, hole };
179
- });
132
+ return expandSculptParts(recipe).filter((part) => !part.op).map((part) => {
133
+ const project = (point) => viewProject(applyMatrix(part.matrix, point), yaw, elevation);
134
+ const spec = SCULPT_SHAPE_SPECS[part.shape];
135
+ // See-through only where the front and back openings overlap; the front
136
+ // face's opening alone is a slightly generous stand-in.
137
+ const opening = spec.hole?.(part);
138
+ const holeHull = opening ? convexHull(opening.map(project)) : [];
139
+ const hole = holeHull.length >= 3 ? holeHull : null;
140
+ // One polygon per sample group, all carrying the part's id.
141
+ return spec.samples(part).map((group) => ({ id: part.id, polygon: convexHull(group.map(project)), hole }));
142
+ }).flat();
180
143
  }
181
144
 
182
145
  function insidePolygon(polygon, x, y) {
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Sculpt booleans: the cutters a recipe declares (`op`, sculpt-cutters.js) run
3
+ * on their parent part's geometry with three-bvh-csg.
4
+ *
5
+ * The cut happens in the parent's own frame. The cutter's geometry is moved by
6
+ * inverse(parent) × cutter, both object-frame matrices from expandSculptParts,
7
+ * so the result drops straight back into the parent's mesh, and the mesh keeps
8
+ * its transform, name, shadows and material. A cut only removes (subtract) or
9
+ * keeps less (intersect), so the stored standing box stays an honest bound.
10
+ *
11
+ * A boolean that throws, yields non-finite points or leaves nothing is skipped
12
+ * with a warning. The part is then drawn as it was before that cut instead of
13
+ * vanishing or breaking the build, and the same recipe always skips the same
14
+ * cut.
15
+ */
16
+ import * as THREE from "three";
17
+ import { Brush, Evaluator, HalfEdgeMap, INTERSECTION, SUBTRACTION } from "three-bvh-csg";
18
+ import { MeshBVH } from "three-mesh-bvh";
19
+
20
+ const OPERATIONS = { subtract: SUBTRACTION, intersect: INTERSECTION };
21
+ /** Carried through a cut when both sides have them at the same item size. The
22
+ * evaluator throws on an attribute one side lacks, so the rest are dropped. */
23
+ const CARRIED = ["position", "normal", "uv", "color"];
24
+
25
+ /**
26
+ * three-bvh-csg 0.0.18 prepares a brush with the BVH option `maxLeafSize`, and
27
+ * three-mesh-bvh 0.9 warns about it on every build: two warnings per cut that
28
+ * would bury a real one. This is the same preparation with the current option.
29
+ * A sculpt brush is prepared once and evaluated synchronously, so it also
30
+ * skips the SharedArrayBuffer copy that only the worker path needs.
31
+ */
32
+ class SculptBrush extends Brush {
33
+ prepareGeometry() {
34
+ const { geometry } = this;
35
+ geometry.boundsTree = new MeshBVH(geometry, { targetLeafSize: 3, indirect: true });
36
+ geometry.halfEdges = new HalfEdgeMap();
37
+ geometry.halfEdges.updateFrom(geometry);
38
+ // Every triangle in group 0: the evaluator runs with useGroups off.
39
+ geometry.groupIndices = new Uint16Array((geometry.index ?? geometry.getAttribute("position")).count / 3);
40
+ }
41
+ }
42
+
43
+ /** A parent with vertex colours (a gradient look) would lose them against a
44
+ * cutter without any, so the cutter borrows their mean. The parent keeps its
45
+ * colours, and the cut faces take its average tone. */
46
+ function lendColours(geometry, cutter) {
47
+ const colour = geometry.getAttribute("color");
48
+ if (!colour || cutter.hasAttribute("color")) return;
49
+ const { count, itemSize } = colour;
50
+ const mean = Array.from({ length: itemSize }, (_, k) => {
51
+ let sum = 0;
52
+ for (let i = 0; i < count; i += 1) sum += colour.getComponent(i, k);
53
+ return sum / count;
54
+ });
55
+ const lent = new THREE.BufferAttribute(new colour.array.constructor(cutter.getAttribute("position").count * itemSize), itemSize, colour.normalized);
56
+ for (let i = 0; i < lent.count; i += 1) for (let k = 0; k < itemSize; k += 1) lent.setComponent(i, k, mean[k]);
57
+ cutter.setAttribute("color", lent);
58
+ }
59
+
60
+ /** `geometry` cut by `cutter` (already in the same frame), as a new geometry. Throws when the cut fails. */
61
+ function evaluate(geometry, cutter, operation) {
62
+ lendColours(geometry, cutter);
63
+ const evaluator = new Evaluator();
64
+ evaluator.useGroups = false;
65
+ // Constrained Delaunay clipping leaves far fewer T-junctions than the legacy
66
+ // splitter and handles coplanar faces, such as a cutter exactly as deep as its wall.
67
+ evaluator.useCDTClipping = true;
68
+ evaluator.attributes = CARRIED.filter((name) => geometry.hasAttribute(name) && geometry.getAttribute(name).itemSize === cutter.getAttribute(name)?.itemSize);
69
+ const a = new SculptBrush(geometry), b = new SculptBrush(cutter);
70
+ try {
71
+ a.updateMatrixWorld();
72
+ b.updateMatrixWorld();
73
+ const result = evaluator.evaluate(a, b, operation).geometry;
74
+ const points = result.getAttribute("position")?.array ?? [];
75
+ const reason = result.drawRange.count < 3 ? "it leaves nothing" : points.every(Number.isFinite) ? null : "it yields non-finite points";
76
+ if (reason) {
77
+ result.dispose();
78
+ throw new Error(reason);
79
+ }
80
+ if (!result.hasAttribute("normal")) result.computeVertexNormals();
81
+ return result;
82
+ } finally {
83
+ a.disposeCacheData();
84
+ b.disposeCacheData();
85
+ }
86
+ }
87
+
88
+ /**
89
+ * `geometry` (built for `part`, in its own frame) cut by every cutter whose
90
+ * parent is `part`, in recipe order. `parts` is expandSculptParts' list and
91
+ * `build(cutter)` makes a cutter's geometry. Whatever the cuts replace is
92
+ * disposed here; the geometry returned belongs to the caller.
93
+ */
94
+ export function cutSculptGeometry(geometry, part, parts, build) {
95
+ let cut = geometry;
96
+ let parentInverse = null;
97
+ for (const cutter of parts) {
98
+ if (!cutter.op || cutter.parent !== part.id) continue;
99
+ parentInverse ??= new THREE.Matrix4().fromArray(part.matrix).invert();
100
+ const tool = build(cutter).applyMatrix4(new THREE.Matrix4().fromArray(cutter.matrix).premultiply(parentInverse));
101
+ try {
102
+ const next = evaluate(cut, tool, OPERATIONS[cutter.op]);
103
+ cut.dispose();
104
+ cut = next;
105
+ } catch (error) {
106
+ console.warn(`sculpt: skipped ${cutter.op} "${cutter.id}" on "${part.id}" (${error.message}); the part is drawn without that cut`);
107
+ } finally {
108
+ tool.dispose();
109
+ }
110
+ }
111
+ return cut;
112
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Curve and outline math for the sculpt shapes that follow points (lathe,
3
+ * tube, extrude). Pure: no three.js. The recipe reader, the silhouette compare
4
+ * and the geometry builder all sample through here, so the drawn shape and the
5
+ * scored shape cannot drift apart.
6
+ */
7
+
8
+ const EPSILON = 1e-9;
9
+
10
+ /** Fritsch–Butland slope at a knot: zero where the axis turns, otherwise the
11
+ * harmonic mean of the two secants, which is never more than twice either. */
12
+ function knotSlope(before, after) {
13
+ if (before === null) return after;
14
+ if (after === null) return before;
15
+ return before * after <= 0 ? 0 : (2 * before * after) / (before + after);
16
+ }
17
+
18
+ /**
19
+ * A smooth curve through `points` (arrays of any length), `steps` samples per
20
+ * segment. Each axis is a monotone cubic: it passes through every point and,
21
+ * between two points, never leaves the box they span. So a profile with r <= 1
22
+ * stays <= 1 and a path inside its box stays inside, with no clamping. A
23
+ * Catmull-Rom spline overshoots instead. `closed` joins the last point back to
24
+ * the first and returns no repeated end sample.
25
+ */
26
+ export function monotoneCurve(points, steps, closed = false) {
27
+ const n = points.length;
28
+ const at = (i) => points[closed ? (i + n) % n : i];
29
+ const tangents = points.map((point, k) => point.map((value, axis) => knotSlope(
30
+ closed || k > 0 ? value - at(k - 1)[axis] : null,
31
+ closed || k < n - 1 ? at(k + 1)[axis] - value : null,
32
+ )));
33
+ const out = [];
34
+ for (let segment = 0; segment < (closed ? n : n - 1); segment += 1) {
35
+ const a = at(segment), b = at(segment + 1), ta = tangents[segment], tb = tangents[(segment + 1) % n];
36
+ for (let i = 0; i < steps; i += 1) {
37
+ const t = i / steps, t2 = t * t, t3 = t2 * t;
38
+ const h00 = 2 * t3 - 3 * t2 + 1, h10 = t3 - 2 * t2 + t, h01 = 3 * t2 - 2 * t3, h11 = t3 - t2;
39
+ out.push(a.map((value, axis) => h00 * value + h10 * ta[axis] + h01 * b[axis] + h11 * tb[axis]));
40
+ }
41
+ }
42
+ if (!closed) out.push([...points[n - 1]]);
43
+ return out;
44
+ }
45
+
46
+ /** Signed area: positive when the loop runs counter-clockwise. */
47
+ export function signedArea(loop) {
48
+ let sum = 0;
49
+ for (let i = 0, j = loop.length - 1; i < loop.length; j = i, i += 1) sum += loop[j][0] * loop[i][1] - loop[i][0] * loop[j][1];
50
+ return sum / 2;
51
+ }
52
+
53
+ const cross = (o, a, b) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
54
+ const side = (o, a, b) => {
55
+ const value = cross(o, a, b);
56
+ return Math.abs(value) <= EPSILON ? 0 : Math.sign(value);
57
+ };
58
+ const within = (a, b, p) => Math.min(a[0], b[0]) - EPSILON <= p[0] && p[0] <= Math.max(a[0], b[0]) + EPSILON
59
+ && Math.min(a[1], b[1]) - EPSILON <= p[1] && p[1] <= Math.max(a[1], b[1]) + EPSILON;
60
+
61
+ function segmentsTouch(a, b, c, d) {
62
+ const o1 = side(a, b, c), o2 = side(a, b, d), o3 = side(c, d, a), o4 = side(c, d, b);
63
+ if (o1 !== o2 && o3 !== o4) return true;
64
+ return (o1 === 0 && within(a, b, c)) || (o2 === 0 && within(a, b, d)) || (o3 === 0 && within(c, d, a)) || (o4 === 0 && within(c, d, b));
65
+ }
66
+
67
+ /**
68
+ * The first two edges that touch or cross, across every loop, as
69
+ * `{ a: { loop, edge }, b: { loop, edge } }`, or null when the loops are simple
70
+ * and apart. Neighbouring edges of one loop share a point by design; they only
71
+ * count when the second folds back along the first.
72
+ */
73
+ export function loopCrossing(loops) {
74
+ const edges = loops.flatMap((loop, index) => loop.map((p, edge) => ({ loop: index, edge, n: loop.length, a: p, b: loop[(edge + 1) % loop.length] })));
75
+ for (let i = 0; i < edges.length; i += 1) {
76
+ for (let j = i + 1; j < edges.length; j += 1) {
77
+ const e = edges[i], f = edges[j];
78
+ const follows = e.loop === f.loop && (e.edge + 1) % e.n === f.edge;
79
+ const precedes = e.loop === f.loop && (f.edge + 1) % f.n === e.edge;
80
+ let touch;
81
+ if (follows || precedes) {
82
+ const [shared, p, q] = follows ? [e.b, e.a, f.b] : [e.a, e.b, f.a];
83
+ touch = side(shared, p, q) === 0 && (p[0] - shared[0]) * (q[0] - shared[0]) + (p[1] - shared[1]) * (q[1] - shared[1]) > 0;
84
+ } else touch = segmentsTouch(e.a, e.b, f.a, f.b);
85
+ if (touch) return { a: { loop: e.loop, edge: e.edge }, b: { loop: f.loop, edge: f.edge } };
86
+ }
87
+ }
88
+ return null;
89
+ }
90
+
91
+ /** Even-odd test of a point against a closed loop. */
92
+ export function insideLoop([x, y], loop) {
93
+ let inside = false;
94
+ for (let i = 0, j = loop.length - 1; i < loop.length; j = i, i += 1) {
95
+ const [xi, yi] = loop[i], [xj, yj] = loop[j];
96
+ if ((yi > y) !== (yj > y) && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside;
97
+ }
98
+ return inside;
99
+ }
100
+
101
+ /**
102
+ * Ear clipping: index triples covering a simple loop of either winding. n is
103
+ * at most 64, so the quadratic search is cheap. A loop left with only
104
+ * collinear corners is clipped anyway, so the loop always ends.
105
+ */
106
+ export function earClip(loop) {
107
+ const ring = loop.map((_, index) => index);
108
+ if (signedArea(loop) < 0) ring.reverse();
109
+ const triangles = [];
110
+ while (ring.length > 3) {
111
+ const corner = (i) => [ring[(i + ring.length - 1) % ring.length], ring[i], ring[(i + 1) % ring.length]];
112
+ let cut = ring.findIndex((_, i) => {
113
+ const [a, b, c] = corner(i).map((k) => loop[k]);
114
+ if (cross(a, b, c) <= EPSILON) return false;
115
+ return !ring.some((k) => !corner(i).includes(k) && cross(a, b, loop[k]) >= 0 && cross(b, c, loop[k]) >= 0 && cross(c, a, loop[k]) >= 0);
116
+ });
117
+ if (cut < 0) cut = 0;
118
+ triangles.push(corner(cut));
119
+ ring.splice(cut, 1);
120
+ }
121
+ triangles.push(ring);
122
+ return triangles;
123
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Sculpt cutters: a part with `op` is never drawn. It cuts its `parent` part,
3
+ * which is how a recipe gets windows, doors and arches in a wall, a hollow
4
+ * bowl or cup, or a notch in a plank (subtract), and a half-dome or a sliced
5
+ * fruit (intersect). This module is the pure half: it reads `op` and refuses
6
+ * the recipes a cutter cannot serve. sculpt-csg.js runs the boolean on the
7
+ * parent's geometry. Pure: no three.js.
8
+ *
9
+ * Several cutters on one parent apply in recipe order, and a mirrored cutter
10
+ * cuts both sides. A cutter's colour is never seen: the cut faces wear the
11
+ * parent's look. Each rule below is refused with its path, never bent into a
12
+ * different shape:
13
+ * - a cutter needs a parent, because a cutter at the object level would have
14
+ * nothing to cut;
15
+ * - a cutter carries no parts. A drawn child would ride a frame nobody sees,
16
+ * and a cutter that cuts another cutter is not supported, so two cutters
17
+ * that build one opening (an arched door) both cut the wall.
18
+ */
19
+ import { RecipeError } from "./sculpt-recipe-read.js";
20
+
21
+ const SCULPT_OPS = ["subtract", "intersect"];
22
+
23
+ export const SCULPT_OP_FIELD = Object.freeze({
24
+ key: "op",
25
+ read(raw, part, where) {
26
+ if (!SCULPT_OPS.includes(raw.op)) throw new RecipeError(`${where}.op`, `must be "subtract" or "intersect", got ${JSON.stringify(raw.op)}`);
27
+ if (part.parent === undefined) throw new RecipeError(`${where}.op`, "a cutter needs a parent: the part it cuts");
28
+ part.op = raw.op;
29
+ },
30
+ });
31
+
32
+ /** Refuses any part that names a cutter as its parent. Run it once parents resolve. */
33
+ export function checkSculptCutters(parts) {
34
+ const byId = new Map(parts.map((part) => [part.id, part]));
35
+ for (const part of parts) {
36
+ const parent = byId.get(part.parent);
37
+ if (part.op && parent?.shape === "scatter") throw new RecipeError(`parts[${part.id}].op`, `"${parent.id}" is a scatter, which is drawn as instances and cannot be cut`);
38
+ if (!parent?.op) continue;
39
+ throw new RecipeError(`parts[${part.id}].parent`, part.op
40
+ ? `"${parent.id}" is a cutter, and a cutter only cuts drawn parts; give this one parent "${parent.parent}" so both cut it`
41
+ : `"${parent.id}" is a cutter (op), which is not drawn and cannot carry parts; use parent "${parent.parent}" instead`);
42
+ }
43
+ }