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.
- package/CHANGELOG.md +65 -0
- package/bin/agent/agent-routes.mjs +12 -11
- package/bin/agent/main-agent.mjs +21 -7
- package/bin/agent/main-prompt.mjs +7 -6
- package/bin/agent/plan-tool.mjs +114 -0
- package/bin/agent/script-tool.mjs +4 -1
- package/bin/agent/sculpt-sketch.mjs +190 -0
- package/bin/agent/session-store.mjs +2 -1
- package/bin/agent/sketch-code.mjs +151 -0
- package/bin/agent/sketch-sandbox.mjs +102 -0
- package/bin/agent/studio-prompt.mjs +1 -1
- package/bin/agent/studio-rebase.mjs +5 -4
- package/bin/agent/studio-task-routes.mjs +1 -1
- package/bin/agent/studio-tasks.mjs +116 -25
- package/bin/agent/studio-tools.mjs +3 -2
- package/bin/agent/turn-frames.mjs +4 -0
- package/bin/cozyclay.mjs +4 -2
- package/bin/live-endpoint.mjs +2 -0
- package/dist/app/index.html +6 -6
- package/dist/assets/{TaskBoard-DdDXMgZm.js → TaskBoard-GR8xZm5c.js} +1 -1
- package/dist/assets/{app-BlwOpest.css → app-Bhe0TpGz.css} +1 -1
- package/dist/assets/app-C4QTcDcb.js +5144 -0
- package/dist/assets/compile-worker-oy_P9HHh.js +6 -0
- package/dist/assets/motion3-compile-dgGhrvPK.js +1 -0
- package/dist/assets/scene-asset-sync-BEjjhNEo.css +1 -0
- package/dist/assets/scene-asset-sync-qoxcdrhj.js +13 -0
- package/dist/assets/task-board-BqReHzOi.js +15 -0
- package/dist/assets/workflow-C87p6E1E.js +179 -0
- package/dist/cozyclay-package.json +1 -1
- package/dist/index.html +1 -1
- package/dist/sitemap.xml +1 -1
- package/dist/workflow/index.html +4 -4
- package/mcp/README.md +1 -1
- package/mcp/live-hub.mjs +5 -3
- package/mcp/tool-handlers.mjs +7 -7
- package/package.json +3 -1
- package/src/App.jsx +44 -66
- package/src/bus-task-scopes.js +59 -11
- package/src/bus-task-targets.js +35 -9
- package/src/bus-tasks.js +23 -10
- package/src/command-bus.js +7 -4
- package/src/commands/ai.js +1 -1
- package/src/commands/elements.js +4 -3
- package/src/commands/export.js +1 -1
- package/src/commands/motion3.js +27 -7
- package/src/commands/objects.js +1 -1
- package/src/export-workspace.js +244 -0
- package/src/keyframe-pack.js +5 -1
- package/src/live-control.js +14 -7
- package/src/material-look.js +189 -0
- package/src/material-presets.js +78 -0
- package/src/material-textures.js +157 -0
- package/src/motion3/compile-job.js +15 -3
- package/src/motion3/compiler/channels.js +74 -24
- package/src/motion3/compiler/compile.js +2 -1
- package/src/motion3/compiler/idle.js +2 -0
- package/src/motion3/compiler/overlap.js +180 -0
- package/src/motion3/compiler/pose.js +6 -4
- package/src/motion3/compiler/validate-common.js +1 -0
- package/src/motion3/compiler/validate-parts.js +5 -1
- package/src/motion3/compiler/validate.js +30 -10
- package/src/motion3/correct.js +236 -0
- package/src/motion3/docs.js +8 -6
- package/src/motion3/face-lean.js +31 -0
- package/src/motion3/fix/index.js +5 -1
- package/src/motion3/fix/reach-body.js +172 -0
- package/src/motion3/hint-check.js +44 -0
- package/src/motion3/json-patch.js +120 -12
- package/src/motion3/lenient.js +46 -4
- package/src/motion3/object-keys.js +17 -5
- package/src/motion3/pipeline.js +46 -9
- package/src/motion3/qa/arm-checks.js +96 -0
- package/src/motion3/qa/asked.js +113 -0
- package/src/motion3/qa/goals.js +37 -8
- package/src/motion3/qa/hints-goal.js +6 -5
- package/src/motion3/qa/index.js +5 -2
- package/src/motion3/qa/key-checks.js +4 -3
- package/src/motion3/qa/motion-life.js +243 -0
- package/src/motion3/qa/spec-checks.js +15 -9
- package/src/motion3/qa/verdict.js +50 -0
- package/src/motion3/refs.js +38 -14
- package/src/motion3/repair-watch.js +132 -0
- package/src/motion3/repair.js +52 -19
- package/src/motion3/report.js +50 -9
- package/src/motion3/schema/topics.js +6 -4
- package/src/motion3/schema/types.js +1 -1
- package/src/motion3/skills/basic.js +15 -8
- package/src/motion3/skills/index.js +52 -18
- package/src/motion3/skills/info.js +1 -1
- package/src/motion3/skills/stance.js +6 -0
- package/src/motion3/skills/walk-fit.js +170 -0
- package/src/object-look.js +9 -5
- package/src/panels/PoseImagePanel.jsx +3 -2
- package/src/panels/SculptPanel.jsx +2 -2
- package/src/primitive-look.js +38 -0
- package/src/props.jsx +20 -28
- package/src/render-passes.js +7 -1
- package/src/result-modal.jsx +3 -3
- package/src/scene-objects.js +2 -2
- package/src/sculpt-colours.js +65 -0
- package/src/sculpt-compare.js +13 -50
- package/src/sculpt-csg.js +112 -0
- package/src/sculpt-curve.js +123 -0
- package/src/sculpt-cutters.js +43 -0
- package/src/sculpt-detail.js +207 -0
- package/src/sculpt-geometry-loft-convex.js +100 -0
- package/src/sculpt-geometry-shapes.js +250 -0
- package/src/sculpt-geometry.js +33 -15
- package/src/sculpt-loft-rings.js +225 -0
- package/src/sculpt-noise.js +65 -0
- package/src/sculpt-part-fields.js +27 -0
- package/src/sculpt-recipe-read.js +65 -0
- package/src/sculpt-recipe.js +33 -75
- package/src/sculpt-run.js +1 -1
- package/src/sculpt-scatter-items.js +183 -0
- package/src/sculpt-scatter-shape.js +57 -0
- package/src/sculpt-scatter.js +124 -0
- package/src/sculpt-shapes-curved.js +211 -0
- package/src/sculpt-shapes-loft-convex.js +218 -0
- package/src/sculpt-shapes.js +138 -0
- package/src/sculpt-sketch-state.js +76 -0
- package/src/sculpt-sketch.jsx +124 -0
- package/src/sculpt-surface-fields.js +65 -0
- package/src/shell/ExportFolderPreference.jsx +103 -0
- package/src/shell/PreferencesDialog.jsx +3 -166
- package/src/shell/StatusBar.jsx +1 -1
- package/src/shell/preference-controls.jsx +114 -0
- package/src/shell/preference-pages.jsx +72 -0
- package/src/shell/preferences.css +46 -0
- package/src/shot-prompt.js +29 -9
- package/src/studio-actions.js +2 -2
- package/src/studio-agent-motion.js +19 -9
- package/src/studio-agent-protocol.js +1 -1
- package/src/studio-app-binding.js +3 -2
- package/src/studio-elements.js +5 -1
- package/src/workflow/AgentPanel.jsx +6 -6
- package/src/workflow/AgentPlanCard.jsx +41 -0
- package/src/workflow/CozySceneNode.jsx +1 -1
- package/src/workflow/WorkflowBuilder.jsx +2 -2
- package/src/workflow/agent-client.js +14 -11
- package/src/workflow/agent-plan.css +93 -0
- package/src/workflow/agent-plan.js +59 -0
- package/src/workflow/keyframe-pack-request.js +3 -10
- package/src/workflow/task-board.css +1 -1
- package/src/workflow/task-board.js +7 -5
- package/tools/loc-baseline.json +1 -2
- package/tools/run-tests.mjs +8 -8
- package/dist/assets/app-B2BpfCl2.js +0 -5088
- package/dist/assets/compile-worker-D0pKioa0.js +0 -5
- package/dist/assets/motion3-compile-B887n77J.js +0 -1
- package/dist/assets/scene-asset-sync-COGOlF5T.css +0 -1
- package/dist/assets/scene-asset-sync-CQzEdsqu.js +0 -13
- package/dist/assets/task-board-BKaM7d-9.js +0 -14
- 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
|
+
}
|
package/src/sculpt-geometry.js
CHANGED
|
@@ -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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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 (
|
|
111
|
-
*
|
|
112
|
-
*
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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 =
|
|
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
|
+
]);
|