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,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sculpt shapes that follow points (SCULPT_SHAPE_SPECS entries, same
|
|
3
|
+
* contract as sculpt-shapes.js). Pure: no three.js.
|
|
4
|
+
*
|
|
5
|
+
* lathe profile [[r, y]] turned round the part's y axis: vase, pot, bottle
|
|
6
|
+
* tube a round tube swept along path [[x, y, z]]: rope, vine, frame tube
|
|
7
|
+
* extrude a flat outline [[x, y]] pushed through size z: sign, leaf, gable
|
|
8
|
+
*
|
|
9
|
+
* Every point is a fraction of the part's size, so `size` stays the box the
|
|
10
|
+
* record trusts: a tube's centreline is drawn in the box shrunk by its
|
|
11
|
+
* thickness, and the curves never overshoot their points (sculpt-curve.js).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { RecipeError, isFiniteNumber, readUnit, tidy } from "./sculpt-recipe-read.js";
|
|
15
|
+
import { earClip, insideLoop, loopCrossing, monotoneCurve, signedArea } from "./sculpt-curve.js";
|
|
16
|
+
|
|
17
|
+
/** The thinnest tube: the same floor as a part's extent (SCULPT_LIMITS.minExtent). */
|
|
18
|
+
const MIN_THICKNESS = 0.005;
|
|
19
|
+
const RING = 24;
|
|
20
|
+
const half = (part) => part.size.map((value) => value / 2);
|
|
21
|
+
|
|
22
|
+
function readFlag(value, path, fallback) {
|
|
23
|
+
if (value === undefined) return fallback;
|
|
24
|
+
if (typeof value !== "boolean") throw new RecipeError(path, `must be true or false, got ${JSON.stringify(value)}`);
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A fresh list of points, each `label`-shaped with every component in [lo, hi]. */
|
|
29
|
+
function readPoints(value, path, label, [min, max], [lo, hi]) {
|
|
30
|
+
if (!Array.isArray(value) || value.length < min || value.length > max) {
|
|
31
|
+
throw new RecipeError(path, `must be a list of ${min}-${max} points ${label}, got ${Array.isArray(value) ? `${value.length} points` : JSON.stringify(value)}`);
|
|
32
|
+
}
|
|
33
|
+
const dims = label.split(",").length;
|
|
34
|
+
return value.map((point, i) => {
|
|
35
|
+
if (!Array.isArray(point) || point.length !== dims || !point.every(isFiniteNumber)) {
|
|
36
|
+
throw new RecipeError(`${path}[${i}]`, `must be ${dims} numbers ${label}, got ${JSON.stringify(point)}`);
|
|
37
|
+
}
|
|
38
|
+
point.forEach((component, axis) => {
|
|
39
|
+
if (component < lo || component > hi) throw new RecipeError(`${path}[${i}][${axis}]`, `must be from ${lo} to ${hi}, got ${component}`);
|
|
40
|
+
});
|
|
41
|
+
return point.map(tidy);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** An outline or hole: x and y as fractions of size, any winding, no repeated point. */
|
|
46
|
+
function readLoop(value, path, counts) {
|
|
47
|
+
const loop = readPoints(value, path, "[x, y]", counts, [-0.5, 0.5]);
|
|
48
|
+
loop.forEach(([x, y], i) => {
|
|
49
|
+
const [px, py] = loop[(i + loop.length - 1) % loop.length];
|
|
50
|
+
if (x === px && y === py) throw new RecipeError(`${path}[${i}]`, i ? "repeats the point before it" : "repeats the last point; the loop closes by itself");
|
|
51
|
+
});
|
|
52
|
+
return loop;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/* ------------------------------------------------------------- lathe ---- */
|
|
56
|
+
|
|
57
|
+
/** The drawn profile: [r fraction, y metres from the part's centre]. */
|
|
58
|
+
export function latheRows(part, steps) {
|
|
59
|
+
const rows = part.smooth ? monotoneCurve(part.profile, steps) : part.profile;
|
|
60
|
+
return rows.map(([r, y]) => [r, (y - 0.5) * part.size[1]]);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Rings on the drawn profile, one hull per band, so a vase keeps its neck. */
|
|
64
|
+
function latheSamples(part) {
|
|
65
|
+
const [hx, , hz] = half(part);
|
|
66
|
+
const rings = latheRows(part, Math.ceil(24 / (part.profile.length - 1))).map(([r, y]) => Array.from({ length: RING }, (_, j) => {
|
|
67
|
+
const angle = (j / RING) * 2 * Math.PI;
|
|
68
|
+
return [r * hx * Math.sin(angle), y, r * hz * Math.cos(angle)];
|
|
69
|
+
}));
|
|
70
|
+
return rings.slice(1).map((ring, k) => [...rings[k], ...ring]);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const lathe = {
|
|
74
|
+
keys: ["profile", "smooth"],
|
|
75
|
+
read(raw, part, where) {
|
|
76
|
+
const profile = readPoints(raw.profile, `${where}.profile`, "[r, y]", [3, 32], [0, 1]);
|
|
77
|
+
profile.forEach(([, y], i) => {
|
|
78
|
+
if (i && y < profile[i - 1][1]) throw new RecipeError(`${where}.profile[${i}][1]`, `y must not go down the profile (${y} after ${profile[i - 1][1]}); list it from bottom (0) to top (1)`);
|
|
79
|
+
});
|
|
80
|
+
if (profile.at(-1)[1] === profile[0][1]) throw new RecipeError(`${where}.profile`, "must climb: the last y must be above the first");
|
|
81
|
+
if (profile.every(([r]) => r === 0)) throw new RecipeError(`${where}.profile`, "has every r at 0, so nothing would be drawn");
|
|
82
|
+
part.profile = profile;
|
|
83
|
+
part.smooth = readFlag(raw.smooth, `${where}.smooth`, true);
|
|
84
|
+
},
|
|
85
|
+
samples: latheSamples,
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/* -------------------------------------------------------------- tube ---- */
|
|
89
|
+
|
|
90
|
+
/** The path's points in metres: the box shrunk by the thickness, so the round wall fits. */
|
|
91
|
+
const tubeKnots = (part) => part.path.map((point) => point.map((value, axis) => value * (part.size[axis] - part.thickness)));
|
|
92
|
+
|
|
93
|
+
export const tubeCentre = (part, steps) => monotoneCurve(tubeKnots(part), steps, part.closed);
|
|
94
|
+
|
|
95
|
+
/** Radius at each centreline sample; taper runs by length, so uneven points taper evenly. */
|
|
96
|
+
export function tubeRadii(part, centre) {
|
|
97
|
+
const radius = part.thickness / 2;
|
|
98
|
+
if (!part.taper) return centre.map(() => radius);
|
|
99
|
+
const run = [0];
|
|
100
|
+
for (let k = 1; k < centre.length; k += 1) run.push(run[k - 1] + Math.hypot(...centre[k].map((value, axis) => value - centre[k - 1][axis])));
|
|
101
|
+
return run.map((length) => {
|
|
102
|
+
const s = length / run.at(-1);
|
|
103
|
+
return radius * (1 - Math.max(0, part.taper) * s - Math.max(0, -part.taper) * (1 - s));
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** 26 unit directions (a cube's faces, edges and corners): a ball the hull can trace. */
|
|
108
|
+
const BALL = [];
|
|
109
|
+
for (const x of [-1, 0, 1]) for (const y of [-1, 0, 1]) for (const z of [-1, 0, 1]) {
|
|
110
|
+
const length = Math.hypot(x, y, z);
|
|
111
|
+
if (length) BALL.push([x / length, y / length, z / length]);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** One hull per segment between path points, so a U-shaped rope stays open inside. */
|
|
115
|
+
function tubeSamples(part) {
|
|
116
|
+
const steps = 4;
|
|
117
|
+
const centre = tubeCentre(part, steps);
|
|
118
|
+
const radii = tubeRadii(part, centre);
|
|
119
|
+
const segments = part.closed ? part.path.length : part.path.length - 1;
|
|
120
|
+
return Array.from({ length: segments }, (_, segment) => {
|
|
121
|
+
const points = [];
|
|
122
|
+
for (let i = segment * steps; i <= (segment + 1) * steps; i += 1) {
|
|
123
|
+
const k = i % centre.length;
|
|
124
|
+
for (const direction of BALL) points.push(centre[k].map((value, axis) => value + direction[axis] * radii[k]));
|
|
125
|
+
}
|
|
126
|
+
return points;
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const tube = {
|
|
131
|
+
keys: ["path", "thickness", "taper", "closed"],
|
|
132
|
+
read(raw, part, where) {
|
|
133
|
+
part.path = readPoints(raw.path, `${where}.path`, "[x, y, z]", [2, 32], [-0.5, 0.5]);
|
|
134
|
+
const smallest = Math.min(...part.size);
|
|
135
|
+
if (!isFiniteNumber(raw.thickness) || raw.thickness < MIN_THICKNESS || raw.thickness > smallest) {
|
|
136
|
+
throw new RecipeError(`${where}.thickness`, `must be the tube's diameter in metres, from ${MIN_THICKNESS} to the part's smallest size (${tidy(smallest)} m), got ${JSON.stringify(raw.thickness)}`);
|
|
137
|
+
}
|
|
138
|
+
part.thickness = tidy(raw.thickness);
|
|
139
|
+
part.taper = readUnit(raw.taper, `${where}.taper`, 0, -1, 1);
|
|
140
|
+
part.closed = readFlag(raw.closed, `${where}.closed`, false);
|
|
141
|
+
if (part.closed && part.taper) throw new RecipeError(`${where}.taper`, "a closed tube cannot taper: its two ends meet");
|
|
142
|
+
if (part.closed && part.path.length < 3) throw new RecipeError(`${where}.path`, "a closed tube needs at least 3 points");
|
|
143
|
+
const knots = tubeKnots(part);
|
|
144
|
+
knots.forEach((point, i) => {
|
|
145
|
+
if (!i && !part.closed) return;
|
|
146
|
+
const before = knots[(i + knots.length - 1) % knots.length];
|
|
147
|
+
if (Math.hypot(...point.map((value, axis) => value - before[axis])) < 1e-6) {
|
|
148
|
+
throw new RecipeError(`${where}.path[${i}]`, i ? "sits on the point before it once fitted to the size" : "repeats the last point; a closed path joins its ends by itself");
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
},
|
|
152
|
+
mirror(copy) {
|
|
153
|
+
copy.path = copy.path.map(([x, y, z]) => [tidy(-x), y, z]);
|
|
154
|
+
},
|
|
155
|
+
samples: tubeSamples,
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
/* ----------------------------------------------------------- extrude ---- */
|
|
159
|
+
|
|
160
|
+
/** One prism per ear-clipped triangle of the outline. The bevel is ignored. */
|
|
161
|
+
function extrudeSamples(part) {
|
|
162
|
+
const [w, h, d] = part.size;
|
|
163
|
+
return earClip(part.outline).map((triangle) => triangle.flatMap((k) => {
|
|
164
|
+
const [x, y] = part.outline[k];
|
|
165
|
+
return [[x * w, y * h, d / 2], [x * w, y * h, -d / 2]];
|
|
166
|
+
}));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The compare cuts one opening: the largest hole, as its convex hull. */
|
|
170
|
+
function extrudeHole(part) {
|
|
171
|
+
if (!part.holes) return null;
|
|
172
|
+
const largest = part.holes.reduce((best, hole) => (Math.abs(signedArea(hole)) > Math.abs(signedArea(best)) ? hole : best));
|
|
173
|
+
return largest.map(([x, y]) => [x * part.size[0], y * part.size[1], 0]);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const flipX = (loop) => loop.map(([x, y]) => [tidy(-x), y]);
|
|
177
|
+
|
|
178
|
+
const extrude = {
|
|
179
|
+
keys: ["outline", "holes", "roundness"],
|
|
180
|
+
read(raw, part, where) {
|
|
181
|
+
part.outline = readLoop(raw.outline, `${where}.outline`, [3, 64]);
|
|
182
|
+
if (raw.holes !== undefined && (!Array.isArray(raw.holes) || raw.holes.length > 4)) {
|
|
183
|
+
throw new RecipeError(`${where}.holes`, `must be a list of at most 4 hole outlines, got ${JSON.stringify(raw.holes).slice(0, 60)}`);
|
|
184
|
+
}
|
|
185
|
+
const holes = (raw.holes ?? []).map((hole, i) => readLoop(hole, `${where}.holes[${i}]`, [3, 32]));
|
|
186
|
+
const names = [`${where}.outline`, ...holes.map((_, i) => `${where}.holes[${i}]`)];
|
|
187
|
+
const crossing = loopCrossing([part.outline, ...holes]);
|
|
188
|
+
if (crossing) {
|
|
189
|
+
const { a, b } = crossing;
|
|
190
|
+
const other = a.loop === b.loop ? "another of its edges" : `${names[b.loop]} (edge ${b.edge})`;
|
|
191
|
+
throw new RecipeError(names[a.loop], `edge ${a.edge} (point ${a.edge} to the next) touches or crosses ${other}; an outline must not cross itself or its holes`);
|
|
192
|
+
}
|
|
193
|
+
[part.outline, ...holes].forEach((loop, i) => {
|
|
194
|
+
if (Math.abs(signedArea(loop)) < 1e-5) throw new RecipeError(names[i], "encloses no area");
|
|
195
|
+
});
|
|
196
|
+
holes.forEach((hole, i) => {
|
|
197
|
+
if (!insideLoop(hole[0], part.outline)) throw new RecipeError(names[i + 1], "must lie inside the outline");
|
|
198
|
+
if (holes.some((other, j) => j !== i && insideLoop(hole[0], other))) throw new RecipeError(names[i + 1], "must not lie inside another hole");
|
|
199
|
+
});
|
|
200
|
+
if (holes.length) part.holes = holes;
|
|
201
|
+
part.roundness = readUnit(raw.roundness, `${where}.roundness`, 0.1, 0, 1);
|
|
202
|
+
},
|
|
203
|
+
mirror(copy) {
|
|
204
|
+
copy.outline = flipX(copy.outline);
|
|
205
|
+
if (copy.holes) copy.holes = copy.holes.map(flipX);
|
|
206
|
+
},
|
|
207
|
+
samples: extrudeSamples,
|
|
208
|
+
hole: extrudeHole,
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
export const CURVED_SHAPE_SPECS = Object.freeze({ lathe, tube, extrude });
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recipe rules for the `loft` and `convex` sculpt shapes (the contract is the
|
|
3
|
+
* header of sculpt-shapes.js). Pure: no three.js.
|
|
4
|
+
*
|
|
5
|
+
* loft a skin through 2-12 horizontal sections stacked up the part's own
|
|
6
|
+
* y, for things whose section changes along their length: a boat
|
|
7
|
+
* hull, a car body, a squared bottle, a loaf. Lay it on its side with
|
|
8
|
+
* rotation for a hull or a car. Rings: sculpt-loft-rings.js.
|
|
9
|
+
* convex the convex hull of 4-48 points, flat facets by default, for rocks,
|
|
10
|
+
* gems and crystals; or `seed` alone for a random rock that fills its
|
|
11
|
+
* size, so "a boulder" is one line.
|
|
12
|
+
*
|
|
13
|
+
* Every coordinate is a fraction of size (-0.5..0.5, loft y 0..1), so a part
|
|
14
|
+
* never draws outside its declared box and resizing never rewrites points.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { RecipeError, isFiniteNumber, readUnit, tidy } from "./sculpt-recipe-read.js";
|
|
18
|
+
import { loftRings, signedArea } from "./sculpt-loft-rings.js";
|
|
19
|
+
|
|
20
|
+
const LOFT_SECTIONS = [2, 12];
|
|
21
|
+
const OUTLINE_POINTS = [3, 24];
|
|
22
|
+
const CONVEX_POINTS = [4, 48];
|
|
23
|
+
const FACET_COUNTS = [6, 40];
|
|
24
|
+
const DEFAULT_FACETS = 16;
|
|
25
|
+
const SECTION_ROUNDNESS = 0.25;
|
|
26
|
+
/** A hull thinner than this (as a fraction of size) in any direction is flat. */
|
|
27
|
+
const MIN_DEPTH = 0.02;
|
|
28
|
+
const MAX_SEED = 2147483647;
|
|
29
|
+
|
|
30
|
+
function readNumber(value, path, min, max) {
|
|
31
|
+
if (!isFiniteNumber(value) || value < min || value > max) {
|
|
32
|
+
throw new RecipeError(path, `must be a number from ${min} to ${max}, got ${JSON.stringify(value)}`);
|
|
33
|
+
}
|
|
34
|
+
return tidy(value);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function readBoolean(value, path, fallback) {
|
|
38
|
+
if (value === undefined) return fallback;
|
|
39
|
+
if (typeof value !== "boolean") throw new RecipeError(path, "must be true or false");
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function readList(value, path, [min, max], what) {
|
|
44
|
+
if (!Array.isArray(value) || value.length < min || value.length > max) {
|
|
45
|
+
throw new RecipeError(path, `must be ${min}-${max} ${what}, got ${Array.isArray(value) ? value.length : JSON.stringify(value)}`);
|
|
46
|
+
}
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function readPoint(value, path, axes) {
|
|
51
|
+
if (!Array.isArray(value) || value.length !== axes.length || !value.every(isFiniteNumber)) {
|
|
52
|
+
throw new RecipeError(path, `must be [${axes.join(", ")}] fractions of size, got ${JSON.stringify(value)}`);
|
|
53
|
+
}
|
|
54
|
+
return value.map((component, k) => readNumber(component, `${path}[${k}]`, -0.5, 0.5));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const cross2 = (o, a, b) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
|
|
58
|
+
|
|
59
|
+
/** An outline that crosses itself has no inside to skin. */
|
|
60
|
+
function crossingEdges(points) {
|
|
61
|
+
const n = points.length;
|
|
62
|
+
for (let i = 0; i < n; i += 1) {
|
|
63
|
+
for (let j = i + 2; j < n; j += 1) {
|
|
64
|
+
if (i === 0 && j === n - 1) continue;
|
|
65
|
+
const [a, b, c, d] = [points[i], points[(i + 1) % n], points[j], points[(j + 1) % n]];
|
|
66
|
+
if (cross2(a, b, c) * cross2(a, b, d) < 0 && cross2(c, d, a) * cross2(c, d, b) < 0) return [i, j];
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function readSection(raw, path) {
|
|
73
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new RecipeError(path, "must be { y, points } or { y, w, d }");
|
|
74
|
+
const outline = raw.points !== undefined;
|
|
75
|
+
const allowed = outline ? ["y", "points"] : ["y", "w", "d", "x", "z", "roundness"];
|
|
76
|
+
for (const key of Object.keys(raw)) {
|
|
77
|
+
if (!allowed.includes(key)) throw new RecipeError(`${path}.${key}`, `unknown key (a section is { y, points } or { y, w, d, x, z, roundness })`);
|
|
78
|
+
}
|
|
79
|
+
const y = readNumber(raw.y, `${path}.y`, 0, 1);
|
|
80
|
+
if (outline) {
|
|
81
|
+
const points = readList(raw.points, `${path}.points`, OUTLINE_POINTS, "[x, z] points").map((point, i) => readPoint(point, `${path}.points[${i}]`, ["x", "z"]));
|
|
82
|
+
const crossing = crossingEdges(points);
|
|
83
|
+
if (crossing) throw new RecipeError(`${path}.points`, `edges ${crossing[0]} and ${crossing[1]} cross; list the outline in order around the section`);
|
|
84
|
+
if (Math.abs(signedArea(points)) < 1e-6) throw new RecipeError(`${path}.points`, "the outline encloses no area");
|
|
85
|
+
return { y, points };
|
|
86
|
+
}
|
|
87
|
+
const section = { y, w: readNumber(raw.w, `${path}.w`, 0.01, 1), d: readNumber(raw.d, `${path}.d`, 0.01, 1) };
|
|
88
|
+
for (const [key, span] of [["x", "w"], ["z", "d"]]) {
|
|
89
|
+
section[key] = readUnit(raw[key], `${path}.${key}`, 0, -0.5, 0.5);
|
|
90
|
+
if (Math.abs(section[key]) + section[span] / 2 > 0.5 + 1e-9) {
|
|
91
|
+
throw new RecipeError(`${path}.${key}`, `the section reaches past the part's side: |${key}| + ${span}/2 must be at most 0.5`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
section.roundness = readUnit(raw.roundness, `${path}.roundness`, SECTION_ROUNDNESS, 0, 1);
|
|
95
|
+
return section;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function readSections(value, where) {
|
|
99
|
+
const path = `${where}.sections`;
|
|
100
|
+
const sections = readList(value, path, LOFT_SECTIONS, "sections").map((section, k) => readSection(section, `${path}[${k}]`));
|
|
101
|
+
sections.forEach((section, k) => {
|
|
102
|
+
if (k > 0 && section.y <= sections[k - 1].y) throw new RecipeError(`${path}[${k}].y`, "must be above the section before it (sections run bottom to top)");
|
|
103
|
+
});
|
|
104
|
+
if (sections[0].y !== 0 || sections.at(-1).y !== 1) {
|
|
105
|
+
throw new RecipeError(path, "the first section must sit at y 0 and the last at y 1 (size[1] is the loft's full height)");
|
|
106
|
+
}
|
|
107
|
+
return sections;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Points [x, y, z] that span no volume would hull into a flat, broken mesh. */
|
|
111
|
+
function hullDepth(points) {
|
|
112
|
+
const sub = (a, b) => a.map((value, k) => value - b[k]);
|
|
113
|
+
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]];
|
|
114
|
+
const length = (a) => Math.hypot(...a);
|
|
115
|
+
const spread = (k) => Math.max(...points.map((p) => p[k])) - Math.min(...points.map((p) => p[k]));
|
|
116
|
+
const axis = [1, 2].reduce((best, k) => (spread(k) > spread(best) ? k : best), 0);
|
|
117
|
+
const a = points.reduce((best, p) => (p[axis] < best[axis] ? p : best));
|
|
118
|
+
const b = points.reduce((best, p) => (p[axis] > best[axis] ? p : best));
|
|
119
|
+
const line = sub(b, a);
|
|
120
|
+
const off = (p) => length(cross(line, sub(p, a))) / (length(line) || 1);
|
|
121
|
+
const c = points.reduce((best, p) => (off(p) > off(best) ? p : best));
|
|
122
|
+
const normal = cross(line, sub(c, a));
|
|
123
|
+
const plane = (p) => Math.abs(sub(p, a).reduce((sum, value, k) => sum + value * normal[k], 0)) / (length(normal) || 1);
|
|
124
|
+
return Math.min(length(line), off(c), Math.max(...points.map(plane)));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** mulberry32: a small seeded PRNG, so a seed is the same rock everywhere. */
|
|
128
|
+
function seededRandom(seed) {
|
|
129
|
+
let state = seed >>> 0;
|
|
130
|
+
return () => {
|
|
131
|
+
state = (state + 0x6d2b79f5) >>> 0;
|
|
132
|
+
let t = Math.imul(state ^ (state >>> 15), state | 1);
|
|
133
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
134
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* A rock of about `facetCount` triangles: a jittered Fibonacci sphere of
|
|
140
|
+
* facetCount / 2 + 2 points (a hull of n points on a sphere has 2n - 4
|
|
141
|
+
* triangles), tipped to a random pole, pulled in by up to 12%, then stretched
|
|
142
|
+
* per axis so it touches all six sides of its box: size is the rock's extent.
|
|
143
|
+
*/
|
|
144
|
+
function rockPoints(seed, facetCount) {
|
|
145
|
+
const random = seededRandom(seed);
|
|
146
|
+
const count = Math.floor(facetCount / 2) + 2;
|
|
147
|
+
const tilt = Math.acos(1 - 2 * random()), spin = 2 * Math.PI * random();
|
|
148
|
+
const golden = Math.PI * (3 - Math.sqrt(5));
|
|
149
|
+
const raw = [];
|
|
150
|
+
for (let i = 0; i < count; i += 1) {
|
|
151
|
+
const y = Math.max(-1, Math.min(1, 1 - (2 * (i + 0.5)) / count + (random() - 0.5) / count));
|
|
152
|
+
const angle = i * golden + (random() - 0.5) * golden * 0.5;
|
|
153
|
+
const ring = Math.sqrt(1 - y * y), radius = 0.88 + 0.12 * random();
|
|
154
|
+
const [x0, y0, z0] = [Math.cos(angle) * ring, y, Math.sin(angle) * ring];
|
|
155
|
+
const [y1, z1] = [y0 * Math.cos(tilt) - z0 * Math.sin(tilt), y0 * Math.sin(tilt) + z0 * Math.cos(tilt)];
|
|
156
|
+
raw.push([(x0 * Math.cos(spin) + z1 * Math.sin(spin)) * radius, y1 * radius, (z1 * Math.cos(spin) - x0 * Math.sin(spin)) * radius]);
|
|
157
|
+
}
|
|
158
|
+
const ranges = [0, 1, 2].map((k) => [Math.min(...raw.map((p) => p[k])), Math.max(...raw.map((p) => p[k]))]);
|
|
159
|
+
return raw.map((point) => point.map((value, k) => tidy((value - (ranges[k][0] + ranges[k][1]) / 2) / (ranges[k][1] - ranges[k][0]))));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const convexFractions = (part) => part.points ?? rockPoints(part.seed, part.facetCount);
|
|
163
|
+
|
|
164
|
+
/** The hull's points in metres, in the part's frame. */
|
|
165
|
+
export function convexPoints(part) {
|
|
166
|
+
return convexFractions(part).map((point) => point.map((value, k) => value * part.size[k]));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export const LOFT_CONVEX_SPECS = Object.freeze({
|
|
170
|
+
loft: {
|
|
171
|
+
keys: ["sections", "smooth"],
|
|
172
|
+
read(raw, part, where) {
|
|
173
|
+
part.sections = readSections(raw.sections, where);
|
|
174
|
+
part.smooth = readBoolean(raw.smooth, `${where}.smooth`, true);
|
|
175
|
+
},
|
|
176
|
+
// x flips, and an outline is listed backwards so it keeps its winding.
|
|
177
|
+
mirror(copy) {
|
|
178
|
+
copy.sections = copy.sections.map((section) => (section.points
|
|
179
|
+
? { y: section.y, points: section.points.map(([x, z]) => [tidy(-x), z]).reverse() }
|
|
180
|
+
: { ...section, x: tidy(-section.x) }));
|
|
181
|
+
},
|
|
182
|
+
// One hull per span, so a waist between two wide sections stays a waist.
|
|
183
|
+
samples(part) {
|
|
184
|
+
const { rings, at } = loftRings(part);
|
|
185
|
+
return at.slice(1).map((end, k) => rings.slice(at[k], end + 1).flat());
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
convex: {
|
|
189
|
+
keys: ["points", "seed", "facetCount", "facets"],
|
|
190
|
+
read(raw, part, where) {
|
|
191
|
+
// null reads as absent, so an edit can swap a seeded rock for points;
|
|
192
|
+
// seed: null also clears the facetCount that belonged to it.
|
|
193
|
+
const has = (key) => raw[key] !== undefined && raw[key] !== null;
|
|
194
|
+
if (has("points") === has("seed")) throw new RecipeError(`${where}.points`, "give either points: [[x, y, z]...] or seed (a random rock), not both");
|
|
195
|
+
if (has("points")) {
|
|
196
|
+
if (has("facetCount") && raw.seed !== null) throw new RecipeError(`${where}.facetCount`, "applies to a seeded rock; points already set the facets");
|
|
197
|
+
part.points = readList(raw.points, `${where}.points`, CONVEX_POINTS, "[x, y, z] points").map((point, i) => readPoint(point, `${where}.points[${i}]`, ["x", "y", "z"]));
|
|
198
|
+
if (hullDepth(part.points) < MIN_DEPTH) throw new RecipeError(`${where}.points`, "the points lie (nearly) on one line or plane; a hull needs depth in all three directions");
|
|
199
|
+
} else {
|
|
200
|
+
if (!Number.isInteger(raw.seed) || raw.seed < 0 || raw.seed > MAX_SEED) throw new RecipeError(`${where}.seed`, `must be a whole number from 0 to ${MAX_SEED}, got ${JSON.stringify(raw.seed)}`);
|
|
201
|
+
part.seed = raw.seed;
|
|
202
|
+
const facetCount = has("facetCount") ? raw.facetCount : DEFAULT_FACETS;
|
|
203
|
+
if (!Number.isInteger(facetCount) || facetCount < FACET_COUNTS[0] || facetCount > FACET_COUNTS[1]) {
|
|
204
|
+
throw new RecipeError(`${where}.facetCount`, `must be a whole number from ${FACET_COUNTS[0]} to ${FACET_COUNTS[1]}, got ${JSON.stringify(raw.facetCount)}`);
|
|
205
|
+
}
|
|
206
|
+
part.facetCount = facetCount;
|
|
207
|
+
}
|
|
208
|
+
part.facets = readBoolean(raw.facets, `${where}.facets`, true);
|
|
209
|
+
},
|
|
210
|
+
// A seeded rock is not symmetric either: its copy carries the reflected points.
|
|
211
|
+
mirror(copy) {
|
|
212
|
+
copy.points = convexFractions(copy).map(([x, y, z]) => [tidy(-x), y, z]);
|
|
213
|
+
delete copy.seed;
|
|
214
|
+
delete copy.facetCount;
|
|
215
|
+
},
|
|
216
|
+
samples: (part) => [convexPoints(part)],
|
|
217
|
+
},
|
|
218
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-shape rules for sculpt recipes: the keys a shape takes and how they are
|
|
3
|
+
* read, how a mirrored copy reflects the shape's own data, and the local
|
|
4
|
+
* points the silhouette compare hulls. Pure: no three.js.
|
|
5
|
+
*
|
|
6
|
+
* A new shape is one entry here plus a builder in sculpt-geometry.js (a
|
|
7
|
+
* geometry builder, or a mesh builder for an instanced shape like scatter).
|
|
8
|
+
* The shapes that follow points (lathe, tube, extrude) live in sculpt-shapes-curved.js.
|
|
9
|
+
*
|
|
10
|
+
* keys shape-only part keys; any other shape refuses them
|
|
11
|
+
* read (raw, part, where) fills the normalized part's shape fields
|
|
12
|
+
* mirror (copy) optional: reflect shape data across the part's own x = 0,
|
|
13
|
+
* for shapes that are not symmetric about their own x
|
|
14
|
+
* samples (part) local sample points, as groups; each group is hulled
|
|
15
|
+
* separately, so a bent shape can stay concave in silhouette
|
|
16
|
+
* hole (part) optional: the local front-face outline of a see-through hole
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { RecipeError, isFiniteNumber, readUnit, tidy } from "./sculpt-recipe-read.js";
|
|
20
|
+
import { LOFT_CONVEX_SPECS } from "./sculpt-shapes-loft-convex.js";
|
|
21
|
+
import { CURVED_SHAPE_SPECS } from "./sculpt-shapes-curved.js";
|
|
22
|
+
import { SCATTER_SHAPE } from "./sculpt-scatter-shape.js";
|
|
23
|
+
|
|
24
|
+
/** How far a full taper narrows a blob's end; sculpt-geometry.js draws the same. */
|
|
25
|
+
export const BLOB_TAPER = 0.6;
|
|
26
|
+
|
|
27
|
+
const half = (part) => part.size.map((value) => value / 2);
|
|
28
|
+
|
|
29
|
+
/** Sample rings from bottom (v = −1) to top (v = 1), `point(v, t)` per ring. */
|
|
30
|
+
function rings(point) {
|
|
31
|
+
const points = [];
|
|
32
|
+
for (let i = 0; i <= 16; i += 1) {
|
|
33
|
+
const v = -1 + (2 * i) / 16;
|
|
34
|
+
for (let j = 0; j < 24; j += 1) points.push(...point(v, (j / 24) * 2 * Math.PI));
|
|
35
|
+
}
|
|
36
|
+
return points;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function blobSamples(part) {
|
|
40
|
+
const [sx, sy, sz] = half(part);
|
|
41
|
+
const p = 2 + 6 * part.roundness;
|
|
42
|
+
const taper = part.taper;
|
|
43
|
+
return [rings((v, t) => {
|
|
44
|
+
const k = taper > 0 ? 1 - taper * BLOB_TAPER * ((v + 1) / 2) : taper < 0 ? 1 + taper * BLOB_TAPER * ((1 - v) / 2) : 1;
|
|
45
|
+
const ring = Math.max(0, 1 - Math.abs(v) ** p) ** (1 / p);
|
|
46
|
+
const c = Math.cos(t), s = Math.sin(t);
|
|
47
|
+
const norm = (Math.abs(c) ** p + Math.abs(s) ** p) ** (1 / p);
|
|
48
|
+
return [[(c / norm) * sx * ring * k, v * sy, (s / norm) * sz * ring * k]];
|
|
49
|
+
})];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function boxSamples(part) {
|
|
53
|
+
const [sx, sy, sz] = half(part);
|
|
54
|
+
const points = [];
|
|
55
|
+
for (const x of [-sx, sx]) for (const y of [-sy, sy]) for (const z of [-sz, sz]) points.push([x, y, z]);
|
|
56
|
+
return [points];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function cylinderSamples(part) {
|
|
60
|
+
const [sx, sy, sz] = half(part);
|
|
61
|
+
return [rings((v, t) => {
|
|
62
|
+
const r = 1 - Math.max(0, part.taper) * ((v + 1) / 2) - Math.max(0, -part.taper) * ((1 - v) / 2);
|
|
63
|
+
return [[Math.cos(t) * sx * r, v * sy, Math.sin(t) * sz * r]];
|
|
64
|
+
})];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function ringSamples(part) {
|
|
68
|
+
const [sx, sy, sz] = half(part);
|
|
69
|
+
return [rings((v, t) => {
|
|
70
|
+
const points = [[Math.cos(t) * sx, Math.sin(t) * sy, v * sz]];
|
|
71
|
+
if (part.shape === "frame") points.push([Math.sign(Math.cos(t)) * sx, Math.sign(Math.sin(t)) * sy, v * sz]);
|
|
72
|
+
return points;
|
|
73
|
+
})];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The opening of a torus or frame, as the four corners of its front face. */
|
|
77
|
+
function ringHole(part) {
|
|
78
|
+
const inset = part.shape === "frame" ? part.border : part.size[2];
|
|
79
|
+
const ix = part.size[0] / 2 - inset, iy = part.size[1] / 2 - inset;
|
|
80
|
+
if (ix <= 0 || iy <= 0) return null;
|
|
81
|
+
const z = -part.size[2] / 2;
|
|
82
|
+
return [[-ix, -iy, z], [ix, -iy, z], [ix, iy, z], [-ix, iy, z]];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const SCULPT_SHAPE_SPECS = Object.freeze({
|
|
86
|
+
blob: {
|
|
87
|
+
keys: ["roundness", "taper"],
|
|
88
|
+
read(raw, part, where) {
|
|
89
|
+
part.roundness = readUnit(raw.roundness, `${where}.roundness`, 0.5, 0, 1);
|
|
90
|
+
part.taper = readUnit(raw.taper, `${where}.taper`, 0, -1, 1);
|
|
91
|
+
},
|
|
92
|
+
samples: blobSamples,
|
|
93
|
+
},
|
|
94
|
+
box: {
|
|
95
|
+
keys: ["roundness"],
|
|
96
|
+
read(raw, part, where) {
|
|
97
|
+
part.roundness = readUnit(raw.roundness, `${where}.roundness`, 0.1, 0, 1);
|
|
98
|
+
},
|
|
99
|
+
samples: boxSamples,
|
|
100
|
+
},
|
|
101
|
+
cylinder: {
|
|
102
|
+
keys: ["taper"],
|
|
103
|
+
read(raw, part, where) {
|
|
104
|
+
part.taper = readUnit(raw.taper, `${where}.taper`, 0, -1, 1);
|
|
105
|
+
},
|
|
106
|
+
samples: cylinderSamples,
|
|
107
|
+
},
|
|
108
|
+
torus: {
|
|
109
|
+
keys: [],
|
|
110
|
+
read(raw, part, where) {
|
|
111
|
+
if (part.size[2] >= Math.min(part.size[0], part.size[1])) {
|
|
112
|
+
throw new RecipeError(`${where}.size`, "a torus's tube (size[2]) must be thinner than its outer diameter");
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
samples: ringSamples,
|
|
116
|
+
hole: ringHole,
|
|
117
|
+
},
|
|
118
|
+
frame: {
|
|
119
|
+
keys: ["border"],
|
|
120
|
+
read(raw, part, where) {
|
|
121
|
+
const limit = Math.min(part.size[0], part.size[1]) / 2;
|
|
122
|
+
const border = raw.border === undefined ? tidy(Math.min(part.size[0], part.size[1]) * 0.15) : raw.border;
|
|
123
|
+
if (!isFiniteNumber(border) || border <= 0 || border >= limit) {
|
|
124
|
+
throw new RecipeError(`${where}.border`, `must be above 0 and below half the frame's smaller side (${tidy(limit)} m), got ${JSON.stringify(raw.border)}`);
|
|
125
|
+
}
|
|
126
|
+
part.border = tidy(border);
|
|
127
|
+
},
|
|
128
|
+
samples: ringSamples,
|
|
129
|
+
hole: ringHole,
|
|
130
|
+
},
|
|
131
|
+
...CURVED_SHAPE_SPECS,
|
|
132
|
+
...LOFT_CONVEX_SPECS,
|
|
133
|
+
scatter: SCATTER_SHAPE,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
export const SCULPT_SHAPES = Object.freeze(Object.keys(SCULPT_SHAPE_SPECS));
|
|
137
|
+
/** Every shape-only key, in first-seen order. */
|
|
138
|
+
export const SCULPT_SHAPE_KEYS = Object.freeze([...new Set(Object.values(SCULPT_SHAPE_SPECS).flatMap((spec) => spec.keys))]);
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// The live sketch of the running agent turn: the sculpt parts a tool call has written so far, as the sidecar's
|
|
2
|
+
// sculpt.sketch frames carry them (bin/agent/sculpt-sketch.mjs), for sculpt-sketch.jsx to draw as ghost clay.
|
|
3
|
+
// In-page only and per turn: nothing here is saved, undone, exported or sent to the agent. A call's sketch goes
|
|
4
|
+
// when its tool.done arrives (the real objects are in the scene by then, or were refused), and everything goes
|
|
5
|
+
// when the turn ends, fails or is stopped (agent-client.js clears it), so no ghost outlives its turn.
|
|
6
|
+
import { expandSculptParts, normalizeSculptRecipe } from "./sculpt-recipe.js";
|
|
7
|
+
|
|
8
|
+
const MAX_PARTS = 300;
|
|
9
|
+
const EMPTY = Object.freeze([]);
|
|
10
|
+
const sketches = new Map(), finished = new Set(), listeners = new Set();
|
|
11
|
+
let snapshot = EMPTY;
|
|
12
|
+
|
|
13
|
+
function emit() {
|
|
14
|
+
snapshot = Object.freeze([...sketches].flatMap(([call, groups]) => groups.map((group) => ({ ...group, key: `${call}/${group.key}` }))));
|
|
15
|
+
for (const listener of [...listeners]) {
|
|
16
|
+
try { listener(); } catch { /* a broken view never stops the turn */ }
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* sculptSketchFrame(event) takes every frame of the running turn: sculpt.sketch sets (or, with no groups, drops)
|
|
22
|
+
* one call's sketch; tool.done drops that call's sketch and ignores any sketch of it that arrives later (the final
|
|
23
|
+
* one can trail a quick tool); done and error end the turn's sketches.
|
|
24
|
+
*/
|
|
25
|
+
export function sculptSketchFrame(event) {
|
|
26
|
+
if (event?.type === "sculpt.sketch") {
|
|
27
|
+
const call = typeof event.callId === "string" && event.callId ? event.callId : `#${event.index}`;
|
|
28
|
+
if (finished.has(call)) return;
|
|
29
|
+
// The same call sketched before its id was known (bin/agent/sculpt-sketch.mjs).
|
|
30
|
+
const unnamed = call !== `#${event.index}` && sketches.delete(`#${event.index}`);
|
|
31
|
+
const groups = (Array.isArray(event.groups) ? event.groups : [])
|
|
32
|
+
.filter((group) => group && typeof group.key === "string" && Array.isArray(group.parts) && group.parts.length);
|
|
33
|
+
if (groups.length) sketches.set(call, groups);
|
|
34
|
+
else if (!sketches.delete(call) && !unnamed) return;
|
|
35
|
+
emit();
|
|
36
|
+
} else if (event?.type === "tool.done" && typeof event.callId === "string") {
|
|
37
|
+
finished.add(event.callId);
|
|
38
|
+
if (sketches.delete(event.callId)) emit();
|
|
39
|
+
} else if (event?.type === "done" || event?.type === "error") clearSculptSketch();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Forget every sketch of the turn (it ended, failed or was stopped). */
|
|
43
|
+
export function clearSculptSketch() {
|
|
44
|
+
finished.clear();
|
|
45
|
+
if (!sketches.size) return;
|
|
46
|
+
sketches.clear();
|
|
47
|
+
emit();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** subscribeSculptSketch(listener) -> unsubscribe; with sculptSketchSnapshot it fits React's useSyncExternalStore. */
|
|
51
|
+
export function subscribeSculptSketch(listener) {
|
|
52
|
+
listeners.add(listener);
|
|
53
|
+
return () => { listeners.delete(listener); };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Every group being sketched, [{ key, name?, target?, placement?, parts }]; the same array until a frame changes it. */
|
|
57
|
+
export const sculptSketchSnapshot = () => snapshot;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* sketchDrawnParts(parts) -> the parts a ghost draws, as expandSculptParts gives them (object-frame matrix, mirrors
|
|
61
|
+
* expanded). Lenient where the real recipe is strict: each part is checked on its own and a bad one is dropped, not
|
|
62
|
+
* the whole recipe, and a part whose parent is not written yet waits, because a sketch is a recipe half written.
|
|
63
|
+
*/
|
|
64
|
+
export function sketchDrawnParts(parts) {
|
|
65
|
+
const drawable = [], ids = new Set();
|
|
66
|
+
for (const raw of Array.isArray(parts) ? parts.slice(0, MAX_PARTS) : []) {
|
|
67
|
+
if (!raw || typeof raw !== "object" || ids.has(raw.id)) continue;
|
|
68
|
+
const { parent, mirror, ...own } = raw;
|
|
69
|
+
const checked = normalizeSculptRecipe({ version: 1, parts: [own] });
|
|
70
|
+
if (!checked.ok || checked.recipe.parts[0].op) continue;
|
|
71
|
+
ids.add(raw.id);
|
|
72
|
+
drawable.push({ ...checked.recipe.parts[0], ...(typeof parent === "string" && parent ? { parent } : {}), ...(mirror === true ? { mirror: true } : {}) });
|
|
73
|
+
}
|
|
74
|
+
// expandSculptParts walks down from the roots, so an orphan (or a parent cycle) is never reached.
|
|
75
|
+
return expandSculptParts({ version: 1, parts: drawable });
|
|
76
|
+
}
|