cozyclay 3.1.0 → 3.1.2

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 (154) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/bin/agent/agent-routes.mjs +12 -11
  3. package/bin/agent/main-agent.mjs +21 -7
  4. package/bin/agent/main-prompt.mjs +7 -6
  5. package/bin/agent/plan-tool.mjs +114 -0
  6. package/bin/agent/script-tool.mjs +4 -1
  7. package/bin/agent/sculpt-sketch.mjs +190 -0
  8. package/bin/agent/session-store.mjs +2 -1
  9. package/bin/agent/sketch-code.mjs +151 -0
  10. package/bin/agent/sketch-sandbox.mjs +102 -0
  11. package/bin/agent/studio-prompt.mjs +1 -1
  12. package/bin/agent/studio-rebase.mjs +5 -4
  13. package/bin/agent/studio-task-routes.mjs +1 -1
  14. package/bin/agent/studio-tasks.mjs +116 -25
  15. package/bin/agent/studio-tools.mjs +3 -2
  16. package/bin/agent/turn-frames.mjs +4 -0
  17. package/bin/cozyclay.mjs +4 -2
  18. package/bin/live-endpoint.mjs +2 -0
  19. package/dist/app/index.html +6 -6
  20. package/dist/assets/{TaskBoard-DdDXMgZm.js → TaskBoard-GR8xZm5c.js} +1 -1
  21. package/dist/assets/{app-BlwOpest.css → app-Bhe0TpGz.css} +1 -1
  22. package/dist/assets/app-C4QTcDcb.js +5144 -0
  23. package/dist/assets/compile-worker-oy_P9HHh.js +6 -0
  24. package/dist/assets/motion3-compile-dgGhrvPK.js +1 -0
  25. package/dist/assets/scene-asset-sync-BEjjhNEo.css +1 -0
  26. package/dist/assets/scene-asset-sync-qoxcdrhj.js +13 -0
  27. package/dist/assets/task-board-BqReHzOi.js +15 -0
  28. package/dist/assets/workflow-C87p6E1E.js +179 -0
  29. package/dist/cozyclay-package.json +1 -1
  30. package/dist/index.html +1 -1
  31. package/dist/sitemap.xml +1 -1
  32. package/dist/workflow/index.html +4 -4
  33. package/mcp/README.md +1 -1
  34. package/mcp/live-hub.mjs +5 -3
  35. package/mcp/tool-handlers.mjs +7 -7
  36. package/package.json +3 -1
  37. package/src/App.jsx +44 -66
  38. package/src/bus-task-scopes.js +59 -11
  39. package/src/bus-task-targets.js +35 -9
  40. package/src/bus-tasks.js +23 -10
  41. package/src/command-bus.js +7 -4
  42. package/src/commands/ai.js +1 -1
  43. package/src/commands/elements.js +4 -3
  44. package/src/commands/export.js +1 -1
  45. package/src/commands/motion3.js +27 -7
  46. package/src/commands/objects.js +1 -1
  47. package/src/export-workspace.js +244 -0
  48. package/src/keyframe-pack.js +5 -1
  49. package/src/live-control.js +14 -7
  50. package/src/material-look.js +189 -0
  51. package/src/material-presets.js +78 -0
  52. package/src/material-textures.js +157 -0
  53. package/src/motion3/compile-job.js +15 -3
  54. package/src/motion3/compiler/channels.js +74 -24
  55. package/src/motion3/compiler/compile.js +2 -1
  56. package/src/motion3/compiler/idle.js +2 -0
  57. package/src/motion3/compiler/overlap.js +180 -0
  58. package/src/motion3/compiler/pose.js +6 -4
  59. package/src/motion3/compiler/validate-common.js +1 -0
  60. package/src/motion3/compiler/validate-parts.js +5 -1
  61. package/src/motion3/compiler/validate.js +30 -10
  62. package/src/motion3/correct.js +236 -0
  63. package/src/motion3/docs.js +8 -6
  64. package/src/motion3/face-lean.js +31 -0
  65. package/src/motion3/fix/index.js +5 -1
  66. package/src/motion3/fix/reach-body.js +172 -0
  67. package/src/motion3/hint-check.js +44 -0
  68. package/src/motion3/json-patch.js +120 -12
  69. package/src/motion3/lenient.js +46 -4
  70. package/src/motion3/object-keys.js +17 -5
  71. package/src/motion3/pipeline.js +46 -9
  72. package/src/motion3/qa/arm-checks.js +96 -0
  73. package/src/motion3/qa/asked.js +113 -0
  74. package/src/motion3/qa/goals.js +37 -8
  75. package/src/motion3/qa/hints-goal.js +6 -5
  76. package/src/motion3/qa/index.js +5 -2
  77. package/src/motion3/qa/key-checks.js +4 -3
  78. package/src/motion3/qa/motion-life.js +243 -0
  79. package/src/motion3/qa/spec-checks.js +15 -9
  80. package/src/motion3/qa/verdict.js +50 -0
  81. package/src/motion3/refs.js +38 -14
  82. package/src/motion3/repair-watch.js +132 -0
  83. package/src/motion3/repair.js +52 -19
  84. package/src/motion3/report.js +50 -9
  85. package/src/motion3/schema/topics.js +6 -4
  86. package/src/motion3/schema/types.js +1 -1
  87. package/src/motion3/skills/basic.js +15 -8
  88. package/src/motion3/skills/index.js +52 -18
  89. package/src/motion3/skills/info.js +1 -1
  90. package/src/motion3/skills/stance.js +6 -0
  91. package/src/motion3/skills/walk-fit.js +170 -0
  92. package/src/object-look.js +9 -5
  93. package/src/panels/PoseImagePanel.jsx +3 -2
  94. package/src/panels/SculptPanel.jsx +2 -2
  95. package/src/primitive-look.js +38 -0
  96. package/src/props.jsx +20 -28
  97. package/src/render-passes.js +7 -1
  98. package/src/result-modal.jsx +3 -3
  99. package/src/scene-objects.js +2 -2
  100. package/src/sculpt-colours.js +65 -0
  101. package/src/sculpt-compare.js +13 -50
  102. package/src/sculpt-csg.js +112 -0
  103. package/src/sculpt-curve.js +123 -0
  104. package/src/sculpt-cutters.js +43 -0
  105. package/src/sculpt-detail.js +207 -0
  106. package/src/sculpt-geometry-loft-convex.js +100 -0
  107. package/src/sculpt-geometry-shapes.js +250 -0
  108. package/src/sculpt-geometry.js +33 -15
  109. package/src/sculpt-loft-rings.js +225 -0
  110. package/src/sculpt-noise.js +65 -0
  111. package/src/sculpt-part-fields.js +27 -0
  112. package/src/sculpt-recipe-read.js +65 -0
  113. package/src/sculpt-recipe.js +33 -75
  114. package/src/sculpt-run.js +1 -1
  115. package/src/sculpt-scatter-items.js +183 -0
  116. package/src/sculpt-scatter-shape.js +57 -0
  117. package/src/sculpt-scatter.js +124 -0
  118. package/src/sculpt-shapes-curved.js +211 -0
  119. package/src/sculpt-shapes-loft-convex.js +218 -0
  120. package/src/sculpt-shapes.js +138 -0
  121. package/src/sculpt-sketch-state.js +76 -0
  122. package/src/sculpt-sketch.jsx +124 -0
  123. package/src/sculpt-surface-fields.js +65 -0
  124. package/src/shell/ExportFolderPreference.jsx +103 -0
  125. package/src/shell/PreferencesDialog.jsx +3 -166
  126. package/src/shell/StatusBar.jsx +1 -1
  127. package/src/shell/preference-controls.jsx +114 -0
  128. package/src/shell/preference-pages.jsx +72 -0
  129. package/src/shell/preferences.css +46 -0
  130. package/src/shot-prompt.js +29 -9
  131. package/src/studio-actions.js +2 -2
  132. package/src/studio-agent-motion.js +19 -9
  133. package/src/studio-agent-protocol.js +1 -1
  134. package/src/studio-app-binding.js +3 -2
  135. package/src/studio-elements.js +5 -1
  136. package/src/workflow/AgentPanel.jsx +6 -6
  137. package/src/workflow/AgentPlanCard.jsx +41 -0
  138. package/src/workflow/CozySceneNode.jsx +1 -1
  139. package/src/workflow/WorkflowBuilder.jsx +2 -2
  140. package/src/workflow/agent-client.js +14 -11
  141. package/src/workflow/agent-plan.css +93 -0
  142. package/src/workflow/agent-plan.js +59 -0
  143. package/src/workflow/keyframe-pack-request.js +3 -10
  144. package/src/workflow/task-board.css +1 -1
  145. package/src/workflow/task-board.js +7 -5
  146. package/tools/loc-baseline.json +1 -2
  147. package/tools/run-tests.mjs +8 -8
  148. package/dist/assets/app-B2BpfCl2.js +0 -5088
  149. package/dist/assets/compile-worker-D0pKioa0.js +0 -5
  150. package/dist/assets/motion3-compile-B887n77J.js +0 -1
  151. package/dist/assets/scene-asset-sync-COGOlF5T.css +0 -1
  152. package/dist/assets/scene-asset-sync-CQzEdsqu.js +0 -13
  153. package/dist/assets/task-board-BKaM7d-9.js +0 -14
  154. package/dist/assets/workflow-B8Dhzexj.js +0 -179
@@ -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
  });
@@ -0,0 +1,225 @@
1
+ /**
2
+ * The rings a `loft` sculpt part is skinned through. Pure: no three.js, so the
3
+ * silhouette compare samples exactly the surface sculpt-geometry draws.
4
+ *
5
+ * A section (sculpt-shapes-loft-convex.js reads them) is either an outline,
6
+ * { y, points: [[x, z]...] }, or a rounded rectangle, { y, w, d, x, z,
7
+ * roundness }; all of it fractions of the part's size. Every section becomes a
8
+ * ring of RING_POINTS samples that starts at its front, on the line through
9
+ * its centroid, and runs the same way round, so sections with different point
10
+ * counts skin without a twist.
11
+ *
12
+ * `smooth` turns an outline into a closed centripetal Catmull-Rom curve and
13
+ * joins sections with a monotone cubic in y. The curve is fitted back into its
14
+ * outline's own extents and the monotone cubic never leaves the range of the
15
+ * sections it joins, so no vertex can overshoot the declared size.
16
+ */
17
+
18
+ /** Samples per ring. */
19
+ const RING_POINTS = 48;
20
+ /** Rings per section-to-section span when smooth. */
21
+ const SPAN_STEPS = 6;
22
+ /** Curve samples per outline edge before resampling. */
23
+ const CURVE_STEPS = 8;
24
+ /** Segments per rounded corner of a rectangle section. */
25
+ const CORNER_STEPS = 6;
26
+ /** A corner that turns more than 30 degrees is kept as a sample (cosine). */
27
+ const SHARP_TURN = Math.cos(Math.PI / 6);
28
+
29
+ const lerp = (a, b, t) => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
30
+ const distance = (a, b) => Math.hypot(b[0] - a[0], b[1] - a[1]);
31
+
32
+ /** Twice the signed area in (x, z): positive runs from +x toward +z. */
33
+ export function signedArea(ring) {
34
+ let sum = 0;
35
+ for (let i = 0; i < ring.length; i += 1) {
36
+ const a = ring[i], b = ring[(i + 1) % ring.length];
37
+ sum += a[0] * b[1] - b[0] * a[1];
38
+ }
39
+ return sum;
40
+ }
41
+
42
+ function centroid(ring) {
43
+ let cx = 0, cz = 0;
44
+ for (let i = 0; i < ring.length; i += 1) {
45
+ const a = ring[i], b = ring[(i + 1) % ring.length];
46
+ const cross = a[0] * b[1] - b[0] * a[1];
47
+ cx += (a[0] + b[0]) * cross;
48
+ cz += (a[1] + b[1]) * cross;
49
+ }
50
+ const area = signedArea(ring);
51
+ return [cx / (3 * area), cz / (3 * area)];
52
+ }
53
+
54
+ /** Drops repeated points, including a last point that repeats the first. */
55
+ function dedupe(ring) {
56
+ const out = ring.filter((point, i) => i === 0 || distance(point, ring[i - 1]) > 1e-9);
57
+ while (out.length > 1 && distance(out[0], out.at(-1)) <= 1e-9) out.pop();
58
+ return out;
59
+ }
60
+
61
+ /** w by d, centred on (x, z), corners rounded by roundness × half the short side. */
62
+ function roundedRect({ w, d, x, z, roundness }) {
63
+ const hx = w / 2, hz = d / 2;
64
+ const r = roundness * Math.min(hx, hz);
65
+ const steps = r > 0 ? CORNER_STEPS : 0;
66
+ const points = [];
67
+ for (const [cx, cz, start] of [[hx - r, hz - r, 0], [r - hx, hz - r, 1], [r - hx, r - hz, 2], [hx - r, r - hz, 3]]) {
68
+ for (let i = 0; i <= steps; i += 1) {
69
+ const angle = (start + (steps ? i / steps : 0.5)) * (Math.PI / 2);
70
+ points.push([x + cx + r * Math.cos(angle), z + cz + r * Math.sin(angle)]);
71
+ }
72
+ }
73
+ return points;
74
+ }
75
+
76
+ /** A closed centripetal Catmull-Rom curve through `points` (three.js's formulation). */
77
+ function catmullRom(points) {
78
+ const n = points.length;
79
+ const out = [];
80
+ for (let i = 0; i < n; i += 1) {
81
+ const p0 = points[(i - 1 + n) % n], p1 = points[i], p2 = points[(i + 1) % n], p3 = points[(i + 2) % n];
82
+ let dt1 = Math.sqrt(distance(p1, p2));
83
+ let dt0 = Math.sqrt(distance(p0, p1));
84
+ let dt2 = Math.sqrt(distance(p2, p3));
85
+ if (dt1 < 1e-4) dt1 = 1;
86
+ if (dt0 < 1e-4) dt0 = dt1;
87
+ if (dt2 < 1e-4) dt2 = dt1;
88
+ const axis = (k) => {
89
+ const t1 = ((p1[k] - p0[k]) / dt0 - (p2[k] - p0[k]) / (dt0 + dt1) + (p2[k] - p1[k]) / dt1) * dt1;
90
+ const t2 = ((p2[k] - p1[k]) / dt1 - (p3[k] - p1[k]) / (dt1 + dt2) + (p3[k] - p2[k]) / dt2) * dt1;
91
+ return [p1[k], t1, -3 * p1[k] + 3 * p2[k] - 2 * t1 - t2, 2 * p1[k] - 2 * p2[k] + t1 + t2];
92
+ };
93
+ const cx = axis(0), cz = axis(1);
94
+ for (let s = 0; s < CURVE_STEPS; s += 1) {
95
+ const t = s / CURVE_STEPS;
96
+ out.push([cx[0] + t * (cx[1] + t * (cx[2] + t * cx[3])), cz[0] + t * (cz[1] + t * (cz[2] + t * cz[3]))]);
97
+ }
98
+ }
99
+ return out;
100
+ }
101
+
102
+ const extent = (ring, k) => [Math.min(...ring.map((p) => p[k])), Math.max(...ring.map((p) => p[k]))];
103
+
104
+ /** Maps the curve's box onto the outline's box. The curve passes through every
105
+ * outline point, so this only ever shrinks it: the extents written are drawn. */
106
+ function fitInto(curve, outline) {
107
+ const ranges = [0, 1].map((k) => [extent(curve, k), extent(outline, k)]);
108
+ return curve.map((point) => point.map((value, k) => {
109
+ const [[from0, from1], [to0, to1]] = ranges[k];
110
+ return from1 - from0 > 1e-12 ? to0 + ((value - from0) * (to1 - to0)) / (from1 - from0) : value;
111
+ }));
112
+ }
113
+
114
+ /** The ring rotated to start where the line x = centroid x leaves its front
115
+ * (+z). The rule is its own mirror image, so a mirrored loft pairs the same
116
+ * points across its sections and comes out as the exact reflection. */
117
+ function startAtFront(ring) {
118
+ const cx = centroid(ring)[0];
119
+ let best = null;
120
+ for (let i = 0; i < ring.length; i += 1) {
121
+ const a = ring[i], b = ring[(i + 1) % ring.length];
122
+ if ((a[0] - cx) * (b[0] - cx) > 0) continue;
123
+ const t = a[0] === b[0] ? 0 : (cx - a[0]) / (b[0] - a[0]);
124
+ const z = a[1] + (b[1] - a[1]) * t;
125
+ if (!best || z > best.z) best = { i, t, z };
126
+ }
127
+ if (!best) return ring;
128
+ const start = lerp(ring[best.i], ring[(best.i + 1) % ring.length], best.t);
129
+ return dedupe([start, ...ring.slice(best.i + 1), ...ring.slice(0, best.i + 1)]);
130
+ }
131
+
132
+ /** `count` samples spaced evenly by arc length, measured with the ring scaled
133
+ * to a unit box: every rectangle then has its corners at the same fractions
134
+ * whatever its aspect, so a square base meets a thin ridge side to side and
135
+ * the gable stays flat. Each sharp corner then takes the place of its nearest
136
+ * sample, so a box section keeps its edges. */
137
+ function resample(ring, count) {
138
+ const n = ring.length;
139
+ const span = [0, 1].map((k) => Math.max(1e-9, extent(ring, k)[1] - extent(ring, k)[0]));
140
+ const lengths = ring.map((point, i) => Math.hypot((ring[(i + 1) % n][0] - point[0]) / span[0], (ring[(i + 1) % n][1] - point[1]) / span[1]));
141
+ const total = lengths.reduce((sum, length) => sum + length, 0);
142
+ const out = [];
143
+ let edge = 0, walked = 0;
144
+ for (let j = 0; j < count; j += 1) {
145
+ const target = (j / count) * total;
146
+ while (edge < n - 1 && walked + lengths[edge] < target) walked += lengths[edge++];
147
+ out.push(lerp(ring[edge], ring[(edge + 1) % n], lengths[edge] > 0 ? Math.min(1, (target - walked) / lengths[edge]) : 0));
148
+ }
149
+ const taken = new Set();
150
+ walked = 0;
151
+ ring.forEach((point, i) => {
152
+ const before = ring[(i - 1 + n) % n], after = ring[(i + 1) % n];
153
+ const turn = ((point[0] - before[0]) * (after[0] - point[0]) + (point[1] - before[1]) * (after[1] - point[1])) / (distance(before, point) * distance(point, after));
154
+ const k = Math.round((walked / total) * count) % count;
155
+ if (turn < SHARP_TURN && !taken.has(k)) {
156
+ taken.add(k);
157
+ out[k] = point;
158
+ }
159
+ walked += lengths[i];
160
+ });
161
+ return out;
162
+ }
163
+
164
+ /** One section as RING_POINTS [x, z] fractions, wound the way LoftGeometry
165
+ * faces outward (negative signed area in x, z). */
166
+ function sectionRing(section, smooth) {
167
+ const outline = section.points ?? roundedRect(section);
168
+ let ring = dedupe(section.points && smooth ? fitInto(catmullRom(outline), outline) : outline);
169
+ if (signedArea(ring) > 0) ring = ring.slice().reverse();
170
+ return resample(startAtFront(ring), RING_POINTS);
171
+ }
172
+
173
+ /** Fritsch-Carlson tangents: Catmull-Rom slopes, limited so each span stays
174
+ * monotone and the curve never passes its two end values. */
175
+ function monotoneSlopes(ys, values) {
176
+ const n = ys.length;
177
+ const delta = values.slice(1).map((value, k) => (value - values[k]) / (ys[k + 1] - ys[k]));
178
+ const slopes = values.map((_, k) => (k === 0 ? delta[0] : k === n - 1 ? delta[n - 2] : delta[k - 1] * delta[k] <= 0 ? 0 : (delta[k - 1] + delta[k]) / 2));
179
+ delta.forEach((d, k) => {
180
+ if (d === 0) {
181
+ slopes[k] = 0;
182
+ slopes[k + 1] = 0;
183
+ return;
184
+ }
185
+ const a = slopes[k] / d, b = slopes[k + 1] / d, s = a * a + b * b;
186
+ if (s > 9) {
187
+ slopes[k] = (3 * a * d) / Math.sqrt(s);
188
+ slopes[k + 1] = (3 * b * d) / Math.sqrt(s);
189
+ }
190
+ });
191
+ return slopes;
192
+ }
193
+
194
+ function hermite(y0, y1, v0, v1, m0, m1, y) {
195
+ const h = y1 - y0, t = (y - y0) / h, t2 = t * t, t3 = t2 * t;
196
+ return (2 * t3 - 3 * t2 + 1) * v0 + (t3 - 2 * t2 + t) * h * m0 + (-2 * t3 + 3 * t2) * v1 + (t3 - t2) * h * m1;
197
+ }
198
+
199
+ /**
200
+ * The loft's rings, bottom to top, as [x, y, z] metres in the part's frame,
201
+ * plus `at`: the index of the ring each authored section became.
202
+ */
203
+ export function loftRings(part) {
204
+ const [sx, sy, sz] = part.size;
205
+ const ys = part.sections.map((section) => section.y);
206
+ const sections = part.sections.map((section) => sectionRing(section, part.smooth));
207
+ const levels = [];
208
+ const at = [];
209
+ if (!part.smooth) {
210
+ sections.forEach((ring, k) => { at.push(levels.length); levels.push({ y: ys[k], ring }); });
211
+ } else {
212
+ const slopes = sections[0].map((_, j) => [0, 1].map((axis) => monotoneSlopes(ys, sections.map((ring) => ring[j][axis]))));
213
+ for (let k = 0; k < ys.length; k += 1) {
214
+ at.push(levels.length);
215
+ levels.push({ y: ys[k], ring: sections[k] });
216
+ if (k === ys.length - 1) break;
217
+ for (let s = 1; s < SPAN_STEPS; s += 1) {
218
+ const y = ys[k] + ((ys[k + 1] - ys[k]) * s) / SPAN_STEPS;
219
+ levels.push({ y, ring: sections[k].map((_, j) => [0, 1].map((axis) => hermite(ys[k], ys[k + 1], sections[k][j][axis], sections[k + 1][j][axis], slopes[j][axis][k], slopes[j][axis][k + 1], y))) });
220
+ }
221
+ }
222
+ }
223
+ const rings = levels.map(({ y, ring }) => ring.map(([x, z]) => [x * sx, (y - 0.5) * sy, z * sz]));
224
+ return { rings, at };
225
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Seeded randomness for sculpt surfaces and scatters. The same recipe always
3
+ * draws the same lumps and the same blades (nothing here reads Math.random),
4
+ * and a mirrored copy is seeded by its source part's id, so it draws the
5
+ * source's pattern reflected instead of a new one. Pure: no three.js.
6
+ */
7
+
8
+ import { SCULPT_MIRROR_SUFFIX } from "./sculpt-recipe.js";
9
+
10
+ /** Whether a drawn part is the reflected copy of a `mirror: true` part. */
11
+ export const isMirrorCopy = (part) => part.id.endsWith(SCULPT_MIRROR_SUFFIX);
12
+
13
+ function mix(value) {
14
+ let h = Math.imul(value ^ (value >>> 16), 0x7feb352d);
15
+ h = Math.imul(h ^ (h >>> 15), 0x846ca68b);
16
+ return (h ^ (h >>> 16)) >>> 0;
17
+ }
18
+
19
+ /** A 32-bit seed from the part's source id (FNV-1a) and a recipe seed. */
20
+ export function partSeed(part, seed) {
21
+ const id = isMirrorCopy(part) ? part.id.slice(0, -SCULPT_MIRROR_SUFFIX.length) : part.id;
22
+ let hash = 0x811c9dc5;
23
+ for (let i = 0; i < id.length; i += 1) hash = Math.imul(hash ^ id.charCodeAt(i), 0x01000193);
24
+ return mix(hash ^ Math.imul(seed + 1, 0x9e3779b1));
25
+ }
26
+
27
+ /** mulberry32: a uniform [0, 1) stream from a 32-bit seed. */
28
+ export function seededRandom(seed) {
29
+ let state = seed >>> 0;
30
+ return () => {
31
+ state = (state + 0x6d2b79f5) >>> 0;
32
+ let t = Math.imul(state ^ (state >>> 15), state | 1);
33
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
34
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
35
+ };
36
+ }
37
+
38
+ const GRADIENTS = [1, 1, 0, -1, 1, 0, 1, -1, 0, -1, -1, 0, 1, 0, 1, -1, 0, 1, 1, 0, -1, -1, 0, -1, 0, 1, 1, 0, -1, 1, 0, 1, -1, 0, -1, -1];
39
+ const fade = (t) => t * t * t * (t * (t * 6 - 15) + 10);
40
+ const lerp = (a, b, t) => a + (b - a) * t;
41
+
42
+ /** Gradient (Perlin) noise, about −1..1, zero on the integer lattice. */
43
+ function noise3(x, y, z, seed) {
44
+ const ix = Math.floor(x), iy = Math.floor(y), iz = Math.floor(z);
45
+ const fx = x - ix, fy = y - iy, fz = z - iz;
46
+ const corner = (dx, dy, dz) => {
47
+ const g = (mix(Math.imul(ix + dx, 0x8da6b343) ^ Math.imul(iy + dy, 0xd8163841) ^ Math.imul(iz + dz, 0xcb1ab31f) ^ seed) % 12) * 3;
48
+ return GRADIENTS[g] * (fx - dx) + GRADIENTS[g + 1] * (fy - dy) + GRADIENTS[g + 2] * (fz - dz);
49
+ };
50
+ const u = fade(fx), v = fade(fy), w = fade(fz);
51
+ return lerp(
52
+ lerp(lerp(corner(0, 0, 0), corner(1, 0, 0), u), lerp(corner(0, 1, 0), corner(1, 1, 0), u), v),
53
+ lerp(lerp(corner(0, 0, 1), corner(1, 0, 1), u), lerp(corner(0, 1, 1), corner(1, 1, 1), u), v),
54
+ w,
55
+ );
56
+ }
57
+
58
+ /** Four octaves of noise3: broad lumps with smaller lumps on them, about −1..1. */
59
+ export function fbm3(x, y, z, seed) {
60
+ let sum = 0;
61
+ for (let octave = 0, amplitude = 1, frequency = 1; octave < 4; octave += 1, amplitude /= 2, frequency *= 2) {
62
+ sum += amplitude * noise3(x * frequency, y * frequency, z * frequency, seed + octave);
63
+ }
64
+ return sum / 1.875;
65
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Optional sculpt part fields that any shape takes (its look, its surface
3
+ * detail). Each entry is { key, read(raw, part, where) }: `read` runs only
4
+ * when raw[key] is present, refuses a bad value with RecipeError
5
+ * (sculpt-recipe-read.js) and writes the normalized value onto the part.
6
+ * An edit that sets a field to null clears it. Pure: no three.js.
7
+ */
8
+ import { SCULPT_OP_FIELD } from "./sculpt-cutters.js";
9
+ import { SURFACE_FIELDS } from "./sculpt-surface-fields.js";
10
+ import { RecipeError } from "./sculpt-recipe-read.js";
11
+ import { MATERIAL_FINISHES, MATERIAL_PRESETS } from "./material-presets.js";
12
+
13
+ /** A name from `names`; null clears the field (back to the default look),
14
+ * which is how an edit `{ set: id, material: null }` undoes a preset. */
15
+ const readName = (key, names) => (raw, part, where) => {
16
+ if (raw[key] === null) return;
17
+ if (!names.includes(raw[key])) throw new RecipeError(`${where}.${key}`, `must be one of ${names.join(", ")}; got ${JSON.stringify(raw[key])}`);
18
+ part[key] = raw[key];
19
+ };
20
+
21
+ export const SCULPT_PART_FIELDS = Object.freeze([
22
+ // Surface presets (material-presets.js); color stays the tint.
23
+ { key: "material", read: readName("material", MATERIAL_PRESETS) },
24
+ { key: "finish", read: readName("finish", MATERIAL_FINISHES) },
25
+ ...SURFACE_FIELDS,
26
+ SCULPT_OP_FIELD,
27
+ ]);