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,207 @@
1
+ /**
2
+ * Surface detail: a part's `detail` (sculpt-surface-fields.js) turns a smooth
3
+ * primitive into a lumpy cloud, bush, rock or loaf by pushing its surface out
4
+ * along the normals with seeded, billowed fBm noise (|fBm|: rounded lumps with
5
+ * creases between them, which reads as puffs or stone where plain fBm reads as
6
+ * a dented ball). It is a post-process on any part's
7
+ * BufferGeometry, so a shape added later gets it without a line of its own.
8
+ *
9
+ * - Bounds: the base is shrunk by the bump height first and no vertex then
10
+ * moves further than that, so the lumps end inside the declared size and
11
+ * the record's measured box (which trusts size) still holds.
12
+ * - Seams: vertices are welded by position before anything moves, so both
13
+ * sides of a sphere's seam (or a box's edge) move together and never split.
14
+ * - Tessellation: a box or a one-segment cylinder has too few vertices to
15
+ * show a bump, so long edges are halved until they are a fraction of a bump.
16
+ * The split test reads only the edge, so both triangles on an edge agree
17
+ * and no T-junction cracks open once the surface moves.
18
+ */
19
+
20
+ import * as THREE from "three";
21
+ import { DETAIL_RELIEF } from "./sculpt-surface-fields.js";
22
+ import { fbm3, isMirrorCopy, partSeed, seededRandom } from "./sculpt-noise.js";
23
+
24
+ /** A detailed part stops splitting edges past this many triangles. */
25
+ const MAX_TRIANGLES = 40000;
26
+ /** Edges are split until a bump spans about this many of them. */
27
+ const EDGES_PER_BUMP = 5;
28
+ /** Faces meeting more sharply than this stay creased (a box edge); lumps shade smooth. */
29
+ const CREASE_COS = Math.cos((65 * Math.PI) / 180);
30
+
31
+ /** Vertex → id shared by every vertex at the same position (within `eps`), via
32
+ * an open-addressed table on the quantized position. */
33
+ function weld(position, eps) {
34
+ const count = position.length / 3;
35
+ const ids = new Int32Array(count);
36
+ let size = 1;
37
+ while (size < count * 2) size <<= 1;
38
+ const table = new Int32Array(size).fill(-1);
39
+ const keys = new Int32Array(count * 3);
40
+ let unique = 0;
41
+ for (let i = 0; i < count; i += 1) {
42
+ const qx = Math.round(position[3 * i] / eps), qy = Math.round(position[3 * i + 1] / eps), qz = Math.round(position[3 * i + 2] / eps);
43
+ let slot = (Math.imul(qx, 73856093) ^ Math.imul(qy, 19349663) ^ Math.imul(qz, 83492791)) & (size - 1);
44
+ for (;;) {
45
+ const id = table[slot];
46
+ if (id < 0) {
47
+ table[slot] = unique;
48
+ keys[3 * unique] = qx; keys[3 * unique + 1] = qy; keys[3 * unique + 2] = qz;
49
+ ids[i] = unique++;
50
+ break;
51
+ }
52
+ if (keys[3 * id] === qx && keys[3 * id + 1] === qy && keys[3 * id + 2] === qz) {
53
+ ids[i] = id;
54
+ break;
55
+ }
56
+ slot = (slot + 1) & (size - 1);
57
+ }
58
+ }
59
+ return { ids, count: unique };
60
+ }
61
+
62
+ /** Local corners per split mask (bit 0 = edge ab, 1 = bc, 2 = ca); 3, 4, 5 are
63
+ * the midpoints of ab, bc, ca. Every pattern keeps the source winding. */
64
+ const PATTERNS = [
65
+ [0, 1, 2],
66
+ [0, 3, 2, 3, 1, 2],
67
+ [1, 4, 0, 4, 2, 0],
68
+ [3, 1, 4, 0, 3, 4, 0, 4, 2],
69
+ [2, 5, 1, 5, 0, 1],
70
+ [5, 0, 3, 2, 5, 3, 2, 3, 1],
71
+ [4, 2, 5, 1, 4, 5, 1, 5, 0],
72
+ [0, 3, 5, 3, 1, 4, 5, 4, 2, 3, 4, 5],
73
+ ];
74
+
75
+ /** Non-indexed attributes ([position, ...others]) with every edge longer
76
+ * than `maxEdge` halved, pass by pass, within MAX_TRIANGLES. */
77
+ function subdivide(attributes, maxEdge) {
78
+ const limit = maxEdge * maxEdge;
79
+ for (let pass = 0; pass < 10; pass += 1) {
80
+ const position = attributes[0].array;
81
+ const triangles = position.length / 9;
82
+ const masks = new Uint8Array(triangles);
83
+ let next = 0;
84
+ for (let t = 0; t < triangles; t += 1) {
85
+ for (let e = 0; e < 3; e += 1) {
86
+ const a = 9 * t + 3 * e, b = 9 * t + 3 * ((e + 1) % 3);
87
+ const dx = position[a] - position[b], dy = position[a + 1] - position[b + 1], dz = position[a + 2] - position[b + 2];
88
+ if (dx * dx + dy * dy + dz * dz > limit) masks[t] |= 1 << e;
89
+ }
90
+ next += PATTERNS[masks[t]].length / 3;
91
+ }
92
+ if (next === triangles || next > MAX_TRIANGLES) break;
93
+ attributes = attributes.map(({ name, size, array }) => {
94
+ const out = new Float64Array(next * 3 * size);
95
+ let o = 0;
96
+ for (let t = 0; t < triangles; t += 1) {
97
+ const base = 3 * t * size;
98
+ for (const corner of PATTERNS[masks[t]]) {
99
+ // (a + b) / 2 is the same bits from either side, so neighbours share the midpoint exactly.
100
+ const a = corner < 3 ? corner : corner - 3, b = corner < 3 ? corner : (corner - 2) % 3;
101
+ for (let k = 0; k < size; k += 1) out[o++] = (array[base + a * size + k] + array[base + b * size + k]) / 2;
102
+ }
103
+ }
104
+ return { name, size, array: out };
105
+ });
106
+ }
107
+ return attributes;
108
+ }
109
+
110
+ /** Area-weighted (unnormalized) normal of every triangle. */
111
+ function faceNormals(position) {
112
+ const normals = new Float64Array(position.length / 3);
113
+ for (let f = 0; f < position.length; f += 9) {
114
+ const ax = position[f + 3] - position[f], ay = position[f + 4] - position[f + 1], az = position[f + 5] - position[f + 2];
115
+ const bx = position[f + 6] - position[f], by = position[f + 7] - position[f + 1], bz = position[f + 8] - position[f + 2];
116
+ normals[f / 3] = ay * bz - az * by;
117
+ normals[f / 3 + 1] = az * bx - ax * bz;
118
+ normals[f / 3 + 2] = ax * by - ay * bx;
119
+ }
120
+ return normals;
121
+ }
122
+
123
+ /** Per-vertex normals: the faces around the welded vertex that meet the
124
+ * vertex's own face within the crease angle, area-weighted. */
125
+ function creasedNormals(position, ids, count) {
126
+ const faces = faceNormals(position);
127
+ const unit = faces.map((value, i) => value / (Math.hypot(faces[i - (i % 3)], faces[i - (i % 3) + 1], faces[i - (i % 3) + 2]) || 1));
128
+ const start = new Int32Array(count + 1);
129
+ for (const id of ids) start[id + 1] += 1;
130
+ for (let id = 0; id < count; id += 1) start[id + 1] += start[id];
131
+ const fill = start.slice(0, count), around = new Int32Array(ids.length);
132
+ ids.forEach((id, v) => { around[fill[id]++] = Math.floor(v / 3); });
133
+ const normals = new Float32Array(position.length);
134
+ for (let v = 0; v < ids.length; v += 1) {
135
+ const own = 3 * Math.floor(v / 3);
136
+ const flat = unit[own] === 0 && unit[own + 1] === 0 && unit[own + 2] === 0;
137
+ let x = 0, y = 0, z = 0;
138
+ for (let k = start[ids[v]]; k < start[ids[v] + 1]; k += 1) {
139
+ const g = 3 * around[k];
140
+ if (!flat && unit[g] * unit[own] + unit[g + 1] * unit[own + 1] + unit[g + 2] * unit[own + 2] < CREASE_COS) continue;
141
+ x += faces[g]; y += faces[g + 1]; z += faces[g + 2];
142
+ }
143
+ const length = Math.hypot(x, y, z);
144
+ if (length > 0) normals.set([x / length, y / length, z / length], 3 * v);
145
+ else normals[3 * v + 1] = 1;
146
+ }
147
+ return normals;
148
+ }
149
+
150
+ /**
151
+ * The part's geometry with its `detail` applied, or the geometry untouched
152
+ * when the part has none. The result is non-indexed and keeps every other
153
+ * attribute (uv, colour), interpolated where edges were split.
154
+ */
155
+ export function applySculptDetail(geometry, part) {
156
+ const amount = part.detail?.amount ?? 0;
157
+ if (!(amount > 0)) return geometry;
158
+ const half = part.size.map((value) => value / 2);
159
+ const longest = Math.max(...part.size);
160
+ const relief = amount * DETAIL_RELIEF * Math.min(...half);
161
+ geometry.scale(...half.map((h) => (h - relief) / h));
162
+ const source = geometry.index ? geometry.toNonIndexed() : geometry;
163
+ const names = ["position", ...Object.keys(source.attributes).filter((name) => name !== "position" && name !== "normal")];
164
+ let attributes = names.map((name) => ({ name, size: source.attributes[name].itemSize, array: Float64Array.from(source.attributes[name].array) }));
165
+ geometry.dispose();
166
+ source.dispose();
167
+ // Snap every vertex onto its welded twin so split decisions and midpoints match bit for bit.
168
+ const eps = longest * 1e-5;
169
+ let { ids, count } = weld(attributes[0].array, eps);
170
+ const first = new Int32Array(count).fill(-1);
171
+ ids.forEach((id, v) => {
172
+ if (first[id] < 0) first[id] = v;
173
+ else for (let k = 0; k < 3; k += 1) attributes[0].array[3 * v + k] = attributes[0].array[3 * first[id] + k];
174
+ });
175
+ attributes = subdivide(attributes, longest / part.detail.scale / EDGES_PER_BUMP);
176
+ const position = attributes[0].array;
177
+ ({ ids, count } = weld(position, eps));
178
+ const faces = faceNormals(position);
179
+ const normal = new Float64Array(count * 3);
180
+ ids.forEach((id, v) => {
181
+ const f = 3 * Math.floor(v / 3);
182
+ for (let k = 0; k < 3; k += 1) normal[3 * id + k] += faces[f + k];
183
+ });
184
+ // Noise per welded vertex. A mirrored copy reads it at −x, so it lumps as its source's reflection.
185
+ const seed = partSeed(part, part.detail.seed);
186
+ const random = seededRandom(seed);
187
+ const offset = [random() * 64, random() * 64, random() * 64];
188
+ const frequency = part.detail.scale / longest, flip = isMirrorCopy(part) ? -1 : 1;
189
+ const lift = new Float64Array(count).fill(NaN);
190
+ ids.forEach((id, v) => {
191
+ if (Number.isNaN(lift[id])) lift[id] = Math.abs(fbm3(flip * position[3 * v] * frequency + offset[0], position[3 * v + 1] * frequency + offset[1], position[3 * v + 2] * frequency + offset[2], seed));
192
+ });
193
+ let low = Infinity, high = -Infinity;
194
+ for (const value of lift) { low = Math.min(low, value); high = Math.max(high, value); }
195
+ // Rescaled to 0..1, so the highest lump reaches the declared size and the deepest stays on the shrunk base.
196
+ const span = high - low || 1;
197
+ ids.forEach((id, v) => {
198
+ const n = 3 * id, length = Math.hypot(normal[n], normal[n + 1], normal[n + 2]);
199
+ if (!(length > 0)) return;
200
+ const push = (relief * (lift[id] - low)) / span / length;
201
+ for (let k = 0; k < 3; k += 1) position[3 * v + k] += normal[n + k] * push;
202
+ });
203
+ const detailed = new THREE.BufferGeometry();
204
+ for (const { name, size, array } of attributes) detailed.setAttribute(name, new THREE.BufferAttribute(Float32Array.from(array), size));
205
+ detailed.setAttribute("normal", new THREE.BufferAttribute(creasedNormals(position, ids, count), 3));
206
+ return detailed;
207
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Geometry for the `loft` and `convex` sculpt shapes. The points come from the
3
+ * pure modules (sculpt-loft-rings.js, sculpt-shapes-loft-convex.js), so the
4
+ * silhouette compare hulls exactly what is drawn; three.js only skins them.
5
+ */
6
+
7
+ import * as THREE from "three";
8
+ import { ConvexGeometry } from "three/examples/jsm/geometries/ConvexGeometry.js";
9
+ import { LoftGeometry } from "three/examples/jsm/geometries/LoftGeometry.js";
10
+ import { loftRings } from "./sculpt-loft-rings.js";
11
+ import { convexPoints } from "./sculpt-shapes-loft-convex.js";
12
+
13
+ /** An unsmoothed loft keeps edges sharper than this crisp; a rounded corner's
14
+ * small steps still shade as one curve. */
15
+ const LOFT_CREASE = (40 * Math.PI) / 180;
16
+
17
+ /**
18
+ * Normals for a non-indexed mesh: each corner averages the area-weighted
19
+ * normals of the faces that share its exact position and turn less than
20
+ * `crease` from its own face. (BufferGeometryUtils.toCreasedNormals hashes
21
+ * positions to 1 cm, which merges the corners of a small gem.)
22
+ */
23
+ function creaseNormals(geometry, crease) {
24
+ const position = geometry.getAttribute("position");
25
+ const key = (i) => `${position.getX(i)},${position.getY(i)},${position.getZ(i)}`;
26
+ const a = new THREE.Vector3(), b = new THREE.Vector3(), c = new THREE.Vector3();
27
+ const faceNormals = [];
28
+ const facesAt = new Map();
29
+ for (let f = 0; f < position.count / 3; f += 1) {
30
+ a.fromBufferAttribute(position, 3 * f);
31
+ b.fromBufferAttribute(position, 3 * f + 1);
32
+ c.fromBufferAttribute(position, 3 * f + 2);
33
+ faceNormals.push(new THREE.Vector3().subVectors(c, b).cross(a.sub(b)));
34
+ for (let k = 0; k < 3; k += 1) {
35
+ const at = key(3 * f + k);
36
+ if (!facesAt.has(at)) facesAt.set(at, []);
37
+ facesAt.get(at).push(f);
38
+ }
39
+ }
40
+ const limit = Math.cos(crease);
41
+ const previous = geometry.getAttribute("normal");
42
+ const normals = new Float32Array(position.count * 3);
43
+ const own = new THREE.Vector3(), other = new THREE.Vector3(), sum = new THREE.Vector3();
44
+ for (let i = 0; i < position.count; i += 1) {
45
+ own.copy(faceNormals[Math.floor(i / 3)]).normalize();
46
+ sum.set(0, 0, 0);
47
+ for (const f of facesAt.get(key(i))) if (other.copy(faceNormals[f]).normalize().dot(own) >= limit) sum.add(faceNormals[f]);
48
+ // A zero-area face has no direction of its own: it keeps the one it had.
49
+ if (sum.lengthSq() === 0) sum.fromBufferAttribute(previous, i);
50
+ sum.normalize().toArray(normals, 3 * i);
51
+ }
52
+ geometry.setAttribute("normal", new THREE.BufferAttribute(normals, 3));
53
+ }
54
+
55
+ /** Box-projected uvs per triangle: each facet maps the two axes it faces
56
+ * most squarely, scaled by size, so a facet's texture is never smeared. */
57
+ function boxUvs(geometry, size) {
58
+ const position = geometry.getAttribute("position");
59
+ const uvs = new Float32Array(position.count * 2);
60
+ const a = new THREE.Vector3(), b = new THREE.Vector3(), c = new THREE.Vector3();
61
+ for (let f = 0; f < position.count / 3; f += 1) {
62
+ a.fromBufferAttribute(position, 3 * f);
63
+ b.fromBufferAttribute(position, 3 * f + 1);
64
+ c.fromBufferAttribute(position, 3 * f + 2);
65
+ const n = c.sub(b).cross(a.sub(b));
66
+ const facing = [Math.abs(n.x), Math.abs(n.y), Math.abs(n.z)];
67
+ const axis = facing.indexOf(Math.max(...facing));
68
+ const [u, v] = [[2, 1], [0, 2], [0, 1]][axis];
69
+ for (let k = 0; k < 3; k += 1) {
70
+ uvs[(3 * f + k) * 2] = position.getComponent(3 * f + k, u) / size[u] + 0.5;
71
+ uvs[(3 * f + k) * 2 + 1] = position.getComponent(3 * f + k, v) / size[v] + 0.5;
72
+ }
73
+ }
74
+ geometry.setAttribute("uv", new THREE.BufferAttribute(uvs, 2));
75
+ }
76
+
77
+ function loftGeometry(part) {
78
+ const sections = loftRings(part).rings.map((ring) => ring.map(([x, y, z]) => new THREE.Vector3(x, y, z)));
79
+ const geometry = new LoftGeometry(sections, { capStart: true, capEnd: true });
80
+ // The caps come last, one vertex per ring point each, and are level (every
81
+ // section is horizontal). Earcut leaves zero-area slivers between collinear
82
+ // samples, whose computed normals can be zero, so the caps are set outright.
83
+ const normal = geometry.getAttribute("normal"), first = normal.count - 2 * sections[0].length;
84
+ for (let i = first; i < normal.count; i += 1) normal.setXYZ(i, 0, i < first + sections[0].length ? -1 : 1, 0);
85
+ if (part.smooth) return geometry;
86
+ const faceted = geometry.toNonIndexed();
87
+ geometry.dispose();
88
+ creaseNormals(faceted, LOFT_CREASE);
89
+ return faceted;
90
+ }
91
+
92
+ /** ConvexGeometry gives flat facet normals; `facets: false` smooths them all. */
93
+ function convexGeometry(part) {
94
+ const geometry = new ConvexGeometry(convexPoints(part).map(([x, y, z]) => new THREE.Vector3(x, y, z)));
95
+ boxUvs(geometry, part.size);
96
+ if (!part.facets) creaseNormals(geometry, Math.PI);
97
+ return geometry;
98
+ }
99
+
100
+ export const LOFT_CONVEX_BUILDERS = Object.freeze({ loft: loftGeometry, convex: convexGeometry });
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Geometry for the sculpt shapes that follow points (sculpt-shapes-curved.js):
3
+ * lathe, tube and extrude, each one indexed mesh with a uv in metres, so a
4
+ * texture keeps one scale across parts of different sizes.
5
+ *
6
+ * Normals are welded per piece: inside a piece, every vertex at one position
7
+ * shares one normal (a lathe's seam, a tube's round end); between pieces they
8
+ * stay apart, so a pot's bottom edge and a sign's face edge stay crisp. Like
9
+ * every sculpt shape, nothing is drawn outside the part's declared size.
10
+ */
11
+
12
+ import * as THREE from "three";
13
+ import { signedArea } from "./sculpt-curve.js";
14
+ import { latheRows, tubeCentre, tubeRadii } from "./sculpt-shapes-curved.js";
15
+
16
+ const AROUND = 48;
17
+ const TUBE_SIDES = 20;
18
+ const CAP_ROWS = 6;
19
+ const BEVEL_ROWS = 4;
20
+ /** An outline corner that turns more than this keeps a hard edge on the side wall. */
21
+ const CREASE = (35 * Math.PI) / 180;
22
+
23
+ const sub = (a, b) => a.map((value, axis) => value - b[axis]);
24
+ const dot = (a, b) => a.reduce((sum, value, axis) => sum + value * b[axis], 0);
25
+ const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
26
+ const unit = (v) => {
27
+ const length = Math.hypot(...v);
28
+ return length > 1e-12 ? v.map((value) => value / length) : null;
29
+ };
30
+ const clamp = (value, limit) => Math.min(limit, Math.max(-limit, value));
31
+
32
+ class Pieces {
33
+ constructor() {
34
+ this.position = [];
35
+ this.uv = [];
36
+ this.index = [];
37
+ this.weld = [];
38
+ }
39
+
40
+ /** A rows × cols piece from `at(row, col)` → [x, y, z, u, v]. The column step
41
+ * crossed with the row step must point out of the solid. */
42
+ grid(rows, cols, at) {
43
+ const keys = new Map();
44
+ const ids = [];
45
+ for (let i = 0; i < rows; i += 1) for (let j = 0; j < cols; j += 1) ids.push(this.vertex(keys, at(i, j)));
46
+ for (let i = 0; i + 1 < rows; i += 1) {
47
+ for (let j = 0; j + 1 < cols; j += 1) {
48
+ const a = ids[i * cols + j], b = ids[i * cols + j + 1], c = ids[(i + 1) * cols + j], d = ids[(i + 1) * cols + j + 1];
49
+ this.index.push(a, b, c, b, d, c);
50
+ }
51
+ }
52
+ }
53
+
54
+ /** A flat piece: points [x, y, z, u, v] and triangles over them, wound outward. */
55
+ fan(points, triangles) {
56
+ const keys = new Map();
57
+ const ids = points.map((point) => this.vertex(keys, point));
58
+ for (const triangle of triangles) this.index.push(...triangle.map((k) => ids[k]));
59
+ }
60
+
61
+ vertex(keys, [x, y, z, u, v]) {
62
+ const id = this.position.length / 3;
63
+ const key = `${x},${y},${z}`;
64
+ if (!keys.has(key)) keys.set(key, id);
65
+ this.weld.push(keys.get(key));
66
+ this.position.push(x, y, z);
67
+ this.uv.push(u, v);
68
+ return id;
69
+ }
70
+
71
+ /** Area-weighted face normals, summed on each welded position. */
72
+ build() {
73
+ const p = this.position, sum = new Float64Array(p.length);
74
+ for (let t = 0; t < this.index.length; t += 3) {
75
+ const [a, b, c] = [0, 1, 2].map((k) => this.index[t + k]);
76
+ const corner = (k) => [p[k * 3], p[k * 3 + 1], p[k * 3 + 2]];
77
+ const normal = cross(sub(corner(b), corner(a)), sub(corner(c), corner(a)));
78
+ for (const k of [a, b, c]) for (let axis = 0; axis < 3; axis += 1) sum[this.weld[k] * 3 + axis] += normal[axis];
79
+ }
80
+ const normals = new Float32Array(p.length);
81
+ this.weld.forEach((w, i) => {
82
+ const n = unit([sum[w * 3], sum[w * 3 + 1], sum[w * 3 + 2]]) ?? [0, 1, 0];
83
+ normals.set(n, i * 3);
84
+ });
85
+ const geometry = new THREE.BufferGeometry();
86
+ geometry.setAttribute("position", new THREE.Float32BufferAttribute(p, 3));
87
+ geometry.setAttribute("normal", new THREE.BufferAttribute(normals, 3));
88
+ geometry.setAttribute("uv", new THREE.Float32BufferAttribute(this.uv, 2));
89
+ geometry.setIndex(this.index);
90
+ return geometry;
91
+ }
92
+ }
93
+
94
+ /** The profile turned round y with an elliptical section; caps close the ends
95
+ * that stand off the axis, so a pot has a bottom. */
96
+ export function latheGeometry(part) {
97
+ const [hx, , hz] = part.size.map((value) => value / 2);
98
+ const rows = latheRows(part, Math.min(12, Math.ceil(120 / (part.profile.length - 1))));
99
+ const arc = [0];
100
+ for (let k = 1; k < rows.length; k += 1) arc.push(arc[k - 1] + Math.hypot(((rows[k][0] - rows[k - 1][0]) * (hx + hz)) / 2, rows[k][1] - rows[k - 1][1]));
101
+ // `j % AROUND` makes the seam column the very same numbers as the first, so it welds.
102
+ const ring = ([r, y], j) => {
103
+ const angle = ((j % AROUND) / AROUND) * 2 * Math.PI;
104
+ return [r * hx * Math.sin(angle), y, r * hz * Math.cos(angle)];
105
+ };
106
+ const mesh = new Pieces();
107
+ const wall = (from, to) => mesh.grid(to - from + 1, AROUND + 1, (i, j) => [...ring(rows[from + i], j), (j / AROUND) * Math.PI * (hx + hz), arc[from + i]]);
108
+ if (part.smooth) wall(0, rows.length - 1);
109
+ else for (let k = 0; k + 1 < rows.length; k += 1) wall(k, k + 1);
110
+ const cap = (rim, top) => {
111
+ const order = top ? [rim, [0, rim[1]]] : [[0, rim[1]], rim];
112
+ mesh.grid(2, AROUND + 1, (i, j) => {
113
+ const point = ring(order[i], j);
114
+ return [...point, point[0], point[2]];
115
+ });
116
+ };
117
+ if (rows[0][0] > 0) cap(rows[0], false);
118
+ if (rows.at(-1)[0] > 0) cap(rows.at(-1), true);
119
+ return mesh.build();
120
+ }
121
+
122
+ /** Parallel-transport frames: the ring does not twist where the path bends. On
123
+ * a loop the leftover twist is spread along it, so the seam meets itself. */
124
+ function tubeFrames(tangents, closed) {
125
+ const across = (t) => unit(cross(t, Math.abs(t[0]) < 0.9 ? [1, 0, 0] : [0, 1, 0]));
126
+ let normal = across(tangents[0]);
127
+ const frames = tangents.map((t) => {
128
+ normal = unit(sub(normal, t.map((value) => value * dot(normal, t)))) ?? across(t);
129
+ return [normal, cross(t, normal)];
130
+ });
131
+ if (!closed) return frames;
132
+ const [n0, b0] = frames[0], t0 = tangents[0], last = frames.at(-1)[0];
133
+ const end = unit(sub(last, t0.map((value) => value * dot(last, t0)))) ?? n0;
134
+ const twist = Math.atan2(dot(end, b0), dot(end, n0));
135
+ return frames.map(([n, b], k) => {
136
+ const angle = (-twist * k) / frames.length;
137
+ const turned = n.map((value, axis) => Math.cos(angle) * value + Math.sin(angle) * b[axis]);
138
+ return [turned, cross(tangents[k], turned)];
139
+ });
140
+ }
141
+
142
+ /** A round tube along the path; an open one ends in half-balls. */
143
+ export function tubeGeometry(part) {
144
+ const segments = part.closed ? part.path.length : part.path.length - 1;
145
+ const centre = tubeCentre(part, Math.min(24, Math.max(4, Math.ceil(128 / segments))));
146
+ const radii = tubeRadii(part, centre);
147
+ const n = centre.length;
148
+ const near = (k) => centre[part.closed ? (k + n) % n : Math.min(n - 1, Math.max(0, k))];
149
+ let previous = [0, 1, 0];
150
+ const tangents = centre.map((_, k) => (previous = unit(sub(near(k + 1), near(k - 1))) ?? previous));
151
+ const frames = tubeFrames(tangents, part.closed);
152
+ const run = [0];
153
+ for (let k = 1; k < n; k += 1) run.push(run[k - 1] + Math.hypot(...sub(centre[k], centre[k - 1])));
154
+ const rows = centre.map((c, k) => ({ c, r: radii[k], frame: frames[k], u: run[k] }));
155
+ if (part.closed) rows.push({ ...rows[0], u: run[n - 1] + Math.hypot(...sub(centre[0], centre[n - 1])) });
156
+ else {
157
+ // Rows of a half-ball from the pole to just short of the end ring.
158
+ const cap = (k, sign) => Array.from({ length: CAP_ROWS }, (_, i) => {
159
+ const angle = (((CAP_ROWS - i) / CAP_ROWS) * Math.PI) / 2;
160
+ const reach = radii[k] * Math.sin(angle);
161
+ return { c: centre[k].map((value, axis) => value + sign * tangents[k][axis] * reach), r: i ? radii[k] * Math.cos(angle) : 0, frame: frames[k], u: run[k] + sign * reach };
162
+ });
163
+ rows.unshift(...cap(0, -1));
164
+ rows.push(...cap(n - 1, 1).reverse());
165
+ }
166
+ const mesh = new Pieces();
167
+ mesh.grid(rows.length, TUBE_SIDES + 1, (i, j) => {
168
+ const { c, r, frame: [normal, binormal], u } = rows[i];
169
+ const angle = ((j % TUBE_SIDES) / TUBE_SIDES) * 2 * Math.PI;
170
+ const cos = Math.cos(angle), sin = Math.sin(angle);
171
+ return [...c.map((value, axis) => value + r * (cos * normal[axis] + sin * binormal[axis])), u, (j / TUBE_SIDES) * Math.PI * part.thickness];
172
+ });
173
+ return mesh.build();
174
+ }
175
+
176
+ /** Each corner's inward offset per metre of inset (a mitre, capped at 2× so a
177
+ * spike cannot shoot far); the solid is on every loop's left. */
178
+ function mitres(loop) {
179
+ const left = (a, b) => unit([a[1] - b[1], b[0] - a[0]]);
180
+ return loop.map((p, i) => {
181
+ const n1 = left(loop[(i + loop.length - 1) % loop.length], p), n2 = left(p, loop[(i + 1) % loop.length]);
182
+ const m = unit([n1[0] + n2[0], n1[1] + n2[1]]) ?? n1;
183
+ const scale = 1 / Math.max(0.5, dot(m, n1));
184
+ return [m[0] * scale, m[1] * scale];
185
+ });
186
+ }
187
+
188
+ /** The deepest inset before an edge of the loop turns round, which is where a
189
+ * thin spike would fold through itself. */
190
+ function insetLimit(loop, offsets) {
191
+ return loop.reduce((limit, p, i) => {
192
+ const k = (i + 1) % loop.length, edge = sub(loop[k], p), shrink = dot(sub(offsets[k], offsets[i]), edge);
193
+ return shrink < 0 ? Math.min(limit, -dot(edge, edge) / shrink) : limit;
194
+ }, Infinity);
195
+ }
196
+
197
+ /** The outline pushed through size z, with an optional rounded bevel and holes. */
198
+ export function extrudeGeometry(part) {
199
+ const [hx, hy, hz] = part.size.map((value) => value / 2);
200
+ const metres = (loop, ccw) => {
201
+ const points = loop.map(([x, y]) => [x * part.size[0], y * part.size[1]]);
202
+ return signedArea(points) > 0 === ccw ? points : points.reverse();
203
+ };
204
+ const loops = [metres(part.outline, true), ...(part.holes ?? []).map((hole) => metres(hole, false))];
205
+ const span = (axis) => Math.max(...loops[0].map((p) => p[axis])) - Math.min(...loops[0].map((p) => p[axis]));
206
+ const bevel = (part.roundness * Math.min(part.size[2], span(0), span(1))) / 2;
207
+ const offsets = loops.map(mitres);
208
+ // Star points and thin arms take a shallower inset than the bevel's depth, so the face never folds.
209
+ const inset = Math.min(bevel, 0.5 * Math.min(...loops.map((loop, l) => insetLimit(loop, offsets[l]))));
210
+ // The side's section [inset, z] from the back face round to the front face.
211
+ const quarter = Array.from({ length: bevel > 0 ? BEVEL_ROWS + 1 : 1 }, (_, i) => {
212
+ const angle = (i / BEVEL_ROWS) * (Math.PI / 2);
213
+ return [inset * (1 - Math.sin(angle)), hz - bevel * (1 - Math.cos(angle))];
214
+ });
215
+ const back = quarter.map(([offset, z]) => [offset, -z]), front = [...quarter].reverse();
216
+ const section = back.at(-1)[1] === front[0][1] ? [...back, ...front.slice(1)] : [...back, ...front];
217
+ // A deep bevel on a thin spike can push a mitre past the outline; the clamp keeps the box honest.
218
+ const at = (l, i, depth) => [clamp(loops[l][i][0] + offsets[l][i][0] * depth, hx), clamp(loops[l][i][1] + offsets[l][i][1] * depth, hy)];
219
+ const mesh = new Pieces();
220
+ loops.forEach((loop, l) => {
221
+ const n = loop.length;
222
+ const along = [0];
223
+ for (let i = 1; i <= n; i += 1) along.push(along[i - 1] + Math.hypot(...sub(loop[i % n], loop[i - 1])));
224
+ const corners = loop.flatMap((p, i) => {
225
+ const turn = Math.acos(Math.max(-1, Math.min(1, dot(unit(sub(p, loop[(i + n - 1) % n])), unit(sub(loop[(i + 1) % n], p))))));
226
+ return turn > CREASE ? [i] : [];
227
+ });
228
+ const runs = corners.length ? corners.map((from, k) => [from, k + 1 < corners.length ? corners[k + 1] : corners[0] + n]) : [[0, n]];
229
+ for (const [from, to] of runs) {
230
+ mesh.grid(section.length, to - from + 1, (i, j) => {
231
+ const k = from + j;
232
+ return [...at(l, k % n, section[i][0]), section[i][1], along[k % n] + (k >= n ? along[n] : 0), section[i][1]];
233
+ });
234
+ }
235
+ });
236
+ const flat = loops.flatMap((loop, l) => loop.map((_, i) => [l, i]));
237
+ const vectors = loops.map((loop) => loop.map(([x, y]) => new THREE.Vector2(x, y)));
238
+ const faces = THREE.ShapeUtils.triangulateShape(vectors[0], vectors.slice(1)).map((face) => {
239
+ const [a, b, c] = face.map((k) => loops[flat[k][0]][flat[k][1]]);
240
+ return signedArea([a, b, c]) >= 0 ? face : [face[0], face[2], face[1]];
241
+ });
242
+ for (const sign of [1, -1]) {
243
+ const points = flat.map(([l, i]) => {
244
+ const [x, y] = at(l, i, inset);
245
+ return [x, y, sign * hz, x, y];
246
+ });
247
+ mesh.fan(points, sign > 0 ? faces : faces.map(([a, b, c]) => [a, c, b]));
248
+ }
249
+ return mesh.build();
250
+ }
@@ -12,11 +12,14 @@
12
12
  import * as THREE from "three";
13
13
  import { RoundedBoxGeometry } from "three/examples/jsm/geometries/RoundedBoxGeometry.js";
14
14
  import { expandSculptParts } from "./sculpt-recipe.js";
15
-
16
- const CLAY_ROUGHNESS = 0.82;
17
- /** How far a full taper narrows a blob's end. A blob at 1.0 would come to a
18
- * point, which reads as a cone; 0.6 keeps a pear a pear. */
19
- const BLOB_TAPER = 0.6;
15
+ import { BLOB_TAPER } from "./sculpt-shapes.js";
16
+ import { cutSculptGeometry } from "./sculpt-csg.js";
17
+ import { LOFT_CONVEX_BUILDERS } from "./sculpt-geometry-loft-convex.js";
18
+ import { extrudeGeometry, latheGeometry, tubeGeometry } from "./sculpt-geometry-shapes.js";
19
+ import { applySculptDetail } from "./sculpt-detail.js";
20
+ import { applySculptColours } from "./sculpt-colours.js";
21
+ import { scatterMesh } from "./sculpt-scatter.js";
22
+ import { lookMaterial } from "./material-look.js";
20
23
 
21
24
  /** roundness 0..1 → superellipse exponent 2..8 */
22
25
  const blobExponent = (roundness) => 2 + 6 * roundness;
@@ -97,32 +100,44 @@ function frameGeometry(part) {
97
100
  return geometry;
98
101
  }
99
102
 
100
- const BUILDERS = { blob: blobGeometry, box: boxGeometry, cylinder: cylinderGeometry, torus: torusGeometry, frame: frameGeometry };
103
+ const BUILDERS = { blob: blobGeometry, box: boxGeometry, cylinder: cylinderGeometry, torus: torusGeometry, frame: frameGeometry, lathe: latheGeometry, tube: tubeGeometry, extrude: extrudeGeometry, ...LOFT_CONVEX_BUILDERS };
104
+ /** Shapes that draw their own mesh (many instances) instead of one geometry. */
105
+ const MESH_BUILDERS = { scatter: scatterMesh };
101
106
 
107
+ /** The part's geometry, with its surface `detail` applied. */
102
108
  export function sculptPartGeometry(part) {
103
109
  const build = BUILDERS[part.shape];
104
110
  if (!build) throw new Error(`unknown sculpt shape "${part.shape}"`);
105
- return build(part);
111
+ return applySculptDetail(build(part), part);
106
112
  }
107
113
 
108
114
  /**
109
115
  * The drawable object for a normalized recipe. `materialFor(part)` lets the
110
- * caller decide the look (clay, auto-colour, depth rank); the default is the
111
- * matte clay every primitive wears. Meshes carry their part id as the name and
112
- * as `userData.sculptPart`, so a capture or a raycast can say which part it hit.
116
+ * caller decide the look (auto-colour, depth rank); the default is the part's
117
+ * own preset (material-look.js), clay when it names none, with the part's
118
+ * gradient and a scatter's per-item tint. Meshes (a scatter's InstancedMesh
119
+ * too) carry their part id as the name and as `userData.sculptPart`, so a
120
+ * capture or a raycast can say which part it hit. Glass casts no shadow: light
121
+ * passes through it.
113
122
  */
114
123
  export function buildSculptGroup(recipe, { materialFor } = {}) {
115
124
  const group = new THREE.Group();
116
125
  group.userData.sculpt = true;
117
126
  const matrix = new THREE.Matrix4();
118
- for (const part of expandSculptParts(recipe)) {
119
- const material = materialFor ? materialFor(part) : new THREE.MeshStandardMaterial({ color: part.color, roughness: CLAY_ROUGHNESS, metalness: 0 });
120
- const mesh = new THREE.Mesh(sculptPartGeometry(part), material);
127
+ const parts = expandSculptParts(recipe);
128
+ for (const part of parts) {
129
+ // A cutter (op) is never drawn: it cuts its parent's geometry instead.
130
+ if (part.op) continue;
131
+ const material = materialFor ? materialFor(part) : lookMaterial(part);
132
+ // A scatter draws its own InstancedMesh and is never cut; every other part is cut by its cutters.
133
+ const mesh = MESH_BUILDERS[part.shape]?.(part, material) ?? new THREE.Mesh(cutSculptGeometry(sculptPartGeometry(part), part, parts, sculptPartGeometry), material);
134
+ // Gradients and scatter tints belong to the shaded look; a flat display mode (materialFor) ignores them.
135
+ if (!materialFor) applySculptColours(mesh, part);
121
136
  mesh.name = part.id;
122
137
  mesh.userData.sculptPart = part.id;
123
138
  matrix.fromArray(part.matrix);
124
139
  matrix.decompose(mesh.position, mesh.quaternion, mesh.scale);
125
- mesh.castShadow = true;
140
+ mesh.castShadow = !(material.transmission > 0);
126
141
  mesh.receiveShadow = true;
127
142
  group.add(mesh);
128
143
  }
@@ -130,11 +145,14 @@ export function buildSculptGroup(recipe, { materialFor } = {}) {
130
145
  }
131
146
 
132
147
  /** Frees what buildSculptGroup allocated. Materials are disposed too, because
133
- * buildSculptGroup (or the caller's materialFor) minted one per part. */
148
+ * buildSculptGroup (or the caller's materialFor) minted one per part; a
149
+ * scatter's instance buffers go with its InstancedMesh. The preset pattern
150
+ * textures they sample are shared and stay. */
134
151
  export function disposeSculptGroup(group) {
135
152
  group?.traverse((node) => {
136
153
  if (!node.isMesh) return;
137
154
  node.geometry.dispose();
155
+ if (node.isInstancedMesh) node.dispose();
138
156
  const materials = Array.isArray(node.material) ? node.material : [node.material];
139
157
  for (const material of materials) material.dispose();
140
158
  });