cozyclay 3.1.0 → 3.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/bin/agent/agent-routes.mjs +8 -7
- package/bin/agent/plan-tool.mjs +114 -0
- package/bin/agent/script-tool.mjs +2 -0
- 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/turn-frames.mjs +4 -0
- package/dist/app/index.html +5 -5
- package/dist/assets/TaskBoard-ngdqiu7u.js +1 -0
- package/dist/assets/app-DmR7jBkz.js +5144 -0
- package/dist/assets/{motion3-compile-B887n77J.js → motion3-compile-BB3_1-EB.js} +1 -1
- package/dist/assets/{scene-asset-sync-COGOlF5T.css → scene-asset-sync-CBx7XRti.css} +1 -1
- package/dist/assets/scene-asset-sync-Cgdtr1Yw.js +13 -0
- package/dist/assets/task-board-ErQvfoaz.js +14 -0
- package/dist/assets/workflow-zQM-EKW1.js +179 -0
- package/dist/cozyclay-package.json +1 -1
- package/dist/workflow/index.html +4 -4
- package/mcp/tool-handlers.mjs +2 -2
- package/package.json +3 -1
- package/src/App.jsx +2 -2
- package/src/commands/elements.js +4 -3
- package/src/commands/objects.js +1 -1
- package/src/material-look.js +189 -0
- package/src/material-presets.js +78 -0
- package/src/material-textures.js +157 -0
- package/src/object-look.js +9 -5
- package/src/primitive-look.js +38 -0
- package/src/props.jsx +20 -28
- package/src/render-passes.js +7 -1
- 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/studio-elements.js +5 -1
- package/src/workflow/AgentPanel.jsx +4 -4
- package/src/workflow/AgentPlanCard.jsx +41 -0
- 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/tools/run-tests.mjs +3 -3
- package/dist/assets/TaskBoard-DdDXMgZm.js +0 -1
- package/dist/assets/app-B2BpfCl2.js +0 -5088
- 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,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The scatter items (sculpt-scatter-shape.js names them): small closed clay
|
|
3
|
+
* shapes, each built once and cached; a scatter part draws a clone of one as
|
|
4
|
+
* an InstancedMesh. Standing items (blade, tuft, flower) are about one unit
|
|
5
|
+
* tall from y = 0; lying items (leaf, pebble, twig) are about one unit long
|
|
6
|
+
* and lie on y = 0. All are centred on x = 0, so a reflected copy fits the
|
|
7
|
+
* same box. Each carries a grey `color` ramp that darkens its foot, so a
|
|
8
|
+
* field reads with depth (it multiplies the per-item colour in the shaded
|
|
9
|
+
* look; flat display modes ignore it).
|
|
10
|
+
*
|
|
11
|
+
* stand true: sized by the part's height on the ground
|
|
12
|
+
* tilt the most an item leans from its surface's normal, in degrees
|
|
13
|
+
* cover size against the spacing `count` leaves (lying items, shells)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import * as THREE from "three";
|
|
17
|
+
import { mergeGeometries } from "three/examples/jsm/utils/BufferGeometryUtils.js";
|
|
18
|
+
import { fbm3, seededRandom } from "./sculpt-noise.js";
|
|
19
|
+
|
|
20
|
+
/** `sides` points around (cx, y, cz), ordered so a loft's faces point outward. */
|
|
21
|
+
function ring(sides, rx, rz, y, cx = 0, cz = 0, twist = 0) {
|
|
22
|
+
const points = [];
|
|
23
|
+
for (let i = 0; i < sides; i += 1) {
|
|
24
|
+
const angle = twist + (i / sides) * 2 * Math.PI;
|
|
25
|
+
points.push([cx + rx * Math.cos(angle), y, cz - rz * Math.sin(angle)]);
|
|
26
|
+
}
|
|
27
|
+
return points;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A closed tube up through `rings` (bottom capped; a one-point last ring is
|
|
31
|
+
* a tip), shaded grey by `shade(0 bottom .. 1 top)`. */
|
|
32
|
+
function loft(rings, shade) {
|
|
33
|
+
const positions = [], colours = [], index = [], starts = [];
|
|
34
|
+
rings.forEach((points, k) => {
|
|
35
|
+
starts.push(positions.length / 3);
|
|
36
|
+
const grey = shade(k / (rings.length - 1));
|
|
37
|
+
for (const point of points) {
|
|
38
|
+
positions.push(...point);
|
|
39
|
+
colours.push(grey, grey, grey);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
for (let k = 0; k + 1 < rings.length; k += 1) {
|
|
43
|
+
const a = starts[k], b = starts[k + 1], n = rings[k].length;
|
|
44
|
+
for (let i = 0; i < n; i += 1) {
|
|
45
|
+
const j = (i + 1) % n;
|
|
46
|
+
if (rings[k + 1].length === 1) index.push(a + i, a + j, b);
|
|
47
|
+
else index.push(a + i, a + j, b + j, a + i, b + j, b + i);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
for (let i = 1; i + 1 < rings[0].length; i += 1) index.push(starts[0], starts[0] + i + 1, starts[0] + i);
|
|
51
|
+
const geometry = new THREE.BufferGeometry();
|
|
52
|
+
geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
|
|
53
|
+
geometry.setAttribute("color", new THREE.Float32BufferAttribute(colours, 3));
|
|
54
|
+
geometry.setIndex(index);
|
|
55
|
+
return geometry;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const footShade = (t) => 0.5 + 0.5 * Math.min(1, t * 1.4);
|
|
59
|
+
|
|
60
|
+
/** A blade of grass: a flat three-sided spike curving toward +z. */
|
|
61
|
+
function blade() {
|
|
62
|
+
const rings = [];
|
|
63
|
+
for (let k = 0; k < 4; k += 1) {
|
|
64
|
+
const t = k / 4, width = 0.04 * (1 - t) ** 0.7;
|
|
65
|
+
rings.push(ring(3, width, width * 0.4, t, 0, 0.18 * t * t, Math.PI / 2));
|
|
66
|
+
}
|
|
67
|
+
rings.push([[0, 1, 0.18]]);
|
|
68
|
+
return loft(rings, footShade);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Six blades fanned out from one root. */
|
|
72
|
+
function tuft() {
|
|
73
|
+
const random = seededRandom(0x7a1f);
|
|
74
|
+
const blades = [];
|
|
75
|
+
for (let i = 0; i < 6; i += 1) {
|
|
76
|
+
const one = blade();
|
|
77
|
+
one.scale(1.3, 0.7 + 0.3 * random(), 1.3);
|
|
78
|
+
one.rotateX(0.2 + 0.35 * random());
|
|
79
|
+
one.rotateY((i / 6) * 2 * Math.PI + random() * 0.6);
|
|
80
|
+
blades.push(one);
|
|
81
|
+
}
|
|
82
|
+
return mergeGeometries(blades);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** A stem with a five-petal head facing +y. */
|
|
86
|
+
function flower() {
|
|
87
|
+
const stem = loft([ring(4, 0.02, 0.02, 0), ring(4, 0.018, 0.018, 0.45, 0.01, 0.02), ring(4, 0.016, 0.016, 0.8, 0, 0.03)], () => 0.55);
|
|
88
|
+
const petals = [];
|
|
89
|
+
for (let i = 0; i < 15; i += 1) {
|
|
90
|
+
const angle = (i / 15) * 2 * Math.PI, r = 0.24 * (0.7 + 0.3 * Math.cos(5 * angle));
|
|
91
|
+
petals.push([r * Math.cos(angle), 0.86, 0.03 - r * Math.sin(angle)]);
|
|
92
|
+
}
|
|
93
|
+
const head = loft([ring(15, 0.05, 0.05, 0.78, 0, 0.03), petals, ring(15, 0.09, 0.09, 0.93, 0, 0.03), [[0, 0.97, 0.03]]], () => 1);
|
|
94
|
+
return mergeGeometries([stem, head]);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** A leaf lying on y = 0: a thin arched lens one unit long along z. */
|
|
98
|
+
function leaf() {
|
|
99
|
+
const rings = [];
|
|
100
|
+
for (let k = 0; k < 6; k += 1) {
|
|
101
|
+
const t = k / 6, half = Math.max(0.015, 0.21 * Math.sin(Math.PI * Math.min(1, 0.15 + t)) ** 0.8);
|
|
102
|
+
rings.push(ring(6, half, 0.02, t, 0, -0.07 * Math.sin(Math.PI * t)));
|
|
103
|
+
}
|
|
104
|
+
rings.push([[0, 1, 0]]);
|
|
105
|
+
const geometry = loft(rings, (t) => 0.8 + 0.2 * t);
|
|
106
|
+
geometry.rotateX(Math.PI / 2);
|
|
107
|
+
return geometry;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** A flattened, faceted stone; the facets read as gravel. */
|
|
111
|
+
function pebble() {
|
|
112
|
+
const geometry = new THREE.IcosahedronGeometry(0.5, 1);
|
|
113
|
+
const position = geometry.getAttribute("position");
|
|
114
|
+
for (let i = 0; i < position.count; i += 1) {
|
|
115
|
+
const x = position.getX(i), y = position.getY(i), z = position.getZ(i);
|
|
116
|
+
const k = 1 + 0.2 * fbm3(x * 2.4 + 3.1, y * 2.4 + 1.7, z * 2.4 + 5.3, 11);
|
|
117
|
+
position.setXYZ(i, x * k, y * k * 0.5, z * k * 0.8);
|
|
118
|
+
}
|
|
119
|
+
geometry.deleteAttribute("uv");
|
|
120
|
+
geometry.setAttribute("color", new THREE.Float32BufferAttribute(new Array(position.count * 3).fill(1), 3));
|
|
121
|
+
return geometry;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** A crooked stick with one side branch, lying along x. */
|
|
125
|
+
function twig() {
|
|
126
|
+
const stick = (length, radius) => loft(Array.from({ length: 6 }, (_, k) => {
|
|
127
|
+
const t = k / 5;
|
|
128
|
+
return ring(5, radius * (1 - 0.5 * t), radius * (1 - 0.5 * t), t * length, 0.04 * Math.sin(t * 7.3), 0.03 * Math.sin(t * 4.1 + 1));
|
|
129
|
+
}), () => 0.85);
|
|
130
|
+
const branch = stick(0.35, 0.018);
|
|
131
|
+
branch.rotateX(-0.7);
|
|
132
|
+
branch.translate(0, 0.45, 0);
|
|
133
|
+
const geometry = mergeGeometries([stick(1, 0.03), branch]);
|
|
134
|
+
geometry.rotateZ(-Math.PI / 2);
|
|
135
|
+
return geometry;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const ITEMS = {
|
|
139
|
+
blade: { build: blade, stand: true, tilt: 16, cover: 2.2 },
|
|
140
|
+
tuft: { build: tuft, stand: true, tilt: 6, cover: 1.4 },
|
|
141
|
+
flower: { build: flower, stand: true, tilt: 10, cover: 1.4 },
|
|
142
|
+
leaf: { build: leaf, stand: false, tilt: 18, cover: 1.6 },
|
|
143
|
+
pebble: { build: pebble, stand: false, tilt: 20, cover: 1 },
|
|
144
|
+
twig: { build: twig, stand: false, tilt: 5, cover: 2 },
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const built = new Map();
|
|
148
|
+
|
|
149
|
+
/** The item's distinct vertices plus their reflections across x = 0, flat
|
|
150
|
+
* [x, y, z, ...]: what an item's fit is measured on, so a turned item fits by
|
|
151
|
+
* its real outline (not its looser box) and its mirrored twin fits too. */
|
|
152
|
+
function outline(geometry) {
|
|
153
|
+
const position = geometry.getAttribute("position");
|
|
154
|
+
const seen = new Set(), points = [];
|
|
155
|
+
for (let i = 0; i < position.count; i += 1) {
|
|
156
|
+
const x = position.getX(i), y = position.getY(i), z = position.getZ(i);
|
|
157
|
+
for (const sx of [x, -x]) {
|
|
158
|
+
const key = `${sx},${y},${z}`;
|
|
159
|
+
if (!seen.has(key)) {
|
|
160
|
+
seen.add(key);
|
|
161
|
+
points.push(sx, y, z);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return Float64Array.from(points);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The item's cached template ({ geometry, points, stand, tilt, cover });
|
|
169
|
+
* clone the geometry before handing it to a mesh, which will dispose its own. */
|
|
170
|
+
export function scatterItem(name) {
|
|
171
|
+
if (!built.has(name)) {
|
|
172
|
+
const geometry = ITEMS[name].build();
|
|
173
|
+
geometry.computeBoundingBox();
|
|
174
|
+
const { min, max } = geometry.boundingBox;
|
|
175
|
+
// Centred on x, standing on y = 0, a lying item centred on z too.
|
|
176
|
+
geometry.translate(-(min.x + max.x) / 2, -min.y, ITEMS[name].stand ? 0 : -(min.z + max.z) / 2);
|
|
177
|
+
geometry.computeVertexNormals();
|
|
178
|
+
geometry.computeBoundingBox();
|
|
179
|
+
geometry.computeBoundingSphere();
|
|
180
|
+
built.set(name, { ...ITEMS[name], geometry, points: outline(geometry) });
|
|
181
|
+
}
|
|
182
|
+
return built.get(name);
|
|
183
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The scatter shape's recipe half (registered in sculpt-shapes.js): `count`
|
|
3
|
+
* small copies of one item, drawn as ONE instanced mesh by sculpt-scatter.js.
|
|
4
|
+
* A grass field built from hundreds of cone parts still looked sparse; as one
|
|
5
|
+
* scatter part it is thousands of blades and counts once toward maxParts
|
|
6
|
+
* (the whole recipe's items are capped by SCULPT_LIMITS.maxScatterItems).
|
|
7
|
+
*
|
|
8
|
+
* item blade | tuft | flower stand up; leaf | pebble | twig lie down
|
|
9
|
+
* count 1..SCATTER_MAX_COUNT items
|
|
10
|
+
* seed picks another layout of the same items (default 0)
|
|
11
|
+
* spread "ground" (default): items stand on the bottom face of the size
|
|
12
|
+
* box, anywhere in its x/z rectangle. Standing items are up to
|
|
13
|
+
* size[1] tall; lying items are sized so `count` of them cover
|
|
14
|
+
* the rectangle.
|
|
15
|
+
* "shell": items sit on the ellipsoid inscribed in the size box,
|
|
16
|
+
* facing outward (leaves on a tree crown, flowers on a bush),
|
|
17
|
+
* sized so `count` of them cover it.
|
|
18
|
+
*
|
|
19
|
+
* Every item is fitted inside the size box. A mirrored copy draws its
|
|
20
|
+
* source's layout reflected (sculpt-scatter.js), so no `mirror` hook is needed.
|
|
21
|
+
* Pure: no three.js.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { readChoice, readInteger, readSeed } from "./sculpt-recipe-read.js";
|
|
25
|
+
|
|
26
|
+
export const SCATTER_ITEMS = Object.freeze(["blade", "tuft", "flower", "leaf", "pebble", "twig"]);
|
|
27
|
+
const SCATTER_SPREADS = ["ground", "shell"];
|
|
28
|
+
const SCATTER_MAX_COUNT = 4000;
|
|
29
|
+
|
|
30
|
+
/** Ground: the box the items fill. Shell: the ellipsoid they cover, as rings. */
|
|
31
|
+
function scatterSamples(part) {
|
|
32
|
+
const [sx, sy, sz] = part.size.map((value) => value / 2);
|
|
33
|
+
const points = [];
|
|
34
|
+
if (part.spread === "ground") {
|
|
35
|
+
for (const x of [-sx, sx]) for (const y of [-sy, sy]) for (const z of [-sz, sz]) points.push([x, y, z]);
|
|
36
|
+
return [points];
|
|
37
|
+
}
|
|
38
|
+
for (let i = 0; i <= 16; i += 1) {
|
|
39
|
+
const polar = (i / 16) * Math.PI;
|
|
40
|
+
for (let j = 0; j < 24; j += 1) {
|
|
41
|
+
const t = (j / 24) * 2 * Math.PI;
|
|
42
|
+
points.push([Math.sin(polar) * Math.cos(t) * sx, Math.cos(polar) * sy, Math.sin(polar) * Math.sin(t) * sz]);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return [points];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const SCATTER_SHAPE = {
|
|
49
|
+
keys: ["item", "count", "seed", "spread"],
|
|
50
|
+
read(raw, part, where) {
|
|
51
|
+
part.item = readChoice(raw.item, `${where}.item`, undefined, SCATTER_ITEMS);
|
|
52
|
+
part.count = readInteger(raw.count, `${where}.count`, undefined, 1, SCATTER_MAX_COUNT);
|
|
53
|
+
part.seed = readSeed(raw.seed, `${where}.seed`);
|
|
54
|
+
part.spread = readChoice(raw.spread, `${where}.spread`, "ground", SCATTER_SPREADS);
|
|
55
|
+
},
|
|
56
|
+
samples: scatterSamples,
|
|
57
|
+
};
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scatter geometry: `count` copies of one small item (sculpt-scatter-items.js)
|
|
3
|
+
* as ONE InstancedMesh, laid out from the part's seed. The contract is in
|
|
4
|
+
* sculpt-scatter-shape.js.
|
|
5
|
+
*
|
|
6
|
+
* - Ground items take a low-discrepancy (R2) spread with a little jitter, so
|
|
7
|
+
* a field is even without looking planted in rows.
|
|
8
|
+
* - Shell items take an area-uniform spread over the ellipsoid, leaning
|
|
9
|
+
* further off its normal than ground items so a crown looks leafy, not scaly.
|
|
10
|
+
* - Every item is fitted inside the size box by its turned vertices: one that
|
|
11
|
+
* would poke out is pulled in, one larger than the box is shrunk. The
|
|
12
|
+
* record's measured box trusts size, so it holds.
|
|
13
|
+
* - A mirrored copy is seeded like its source and reflects each item's place
|
|
14
|
+
* and turn (position x and the quaternion's y, z), so the pair is symmetric.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import * as THREE from "three";
|
|
18
|
+
import { scatterItem } from "./sculpt-scatter-items.js";
|
|
19
|
+
import { isMirrorCopy, partSeed, seededRandom } from "./sculpt-noise.js";
|
|
20
|
+
|
|
21
|
+
const UP = new THREE.Vector3(0, 1, 0);
|
|
22
|
+
const SIDE = new THREE.Vector3(1, 0, 0);
|
|
23
|
+
const DEG = Math.PI / 180;
|
|
24
|
+
/** Extra lean on a shell, in degrees. */
|
|
25
|
+
const SHELL_LEAN = 25;
|
|
26
|
+
/** The R2 sequence's steps (1/g, 1/g² for the plastic number g). */
|
|
27
|
+
const R2 = [0.7548776662466927, 0.5698402909980532];
|
|
28
|
+
|
|
29
|
+
/** Surface area of an ellipsoid (Knud Thomsen, within about 1%). */
|
|
30
|
+
function ellipsoidArea(a, b, c) {
|
|
31
|
+
const p = 1.6075;
|
|
32
|
+
return 4 * Math.PI * (((a * b) ** p + (a * c) ** p + (b * c) ** p) / 3) ** (1 / p);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A point on the ellipsoid with semi-axes `axes`, area-uniform, and its outward normal. */
|
|
36
|
+
function shellPoint(axes, random, point, normal) {
|
|
37
|
+
const [a, b, c] = axes, least = Math.min(a, b, c);
|
|
38
|
+
for (let tries = 0; ; tries += 1) {
|
|
39
|
+
const y = random() * 2 - 1, angle = random() * 2 * Math.PI, r = Math.sqrt(1 - y * y);
|
|
40
|
+
const dx = r * Math.cos(angle), dz = r * Math.sin(angle);
|
|
41
|
+
normal.set(dx / a, y / b, dz / c);
|
|
42
|
+
// Stretching a sphere into an ellipsoid stretches area by |normal| · abc; keep points in proportion.
|
|
43
|
+
if (random() <= normal.length() * least || tries >= 32) {
|
|
44
|
+
point.set(dx * a, y * b, dz * c);
|
|
45
|
+
normal.normalize();
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The box (low, high per axis) of `points` turned by `q` and scaled, around the item's origin. */
|
|
52
|
+
function turnedExtent(points, q, scale, low, high) {
|
|
53
|
+
low.fill(Infinity);
|
|
54
|
+
high.fill(-Infinity);
|
|
55
|
+
// Quaternion rotation written out: this runs for every vertex of every item.
|
|
56
|
+
const { x: qx, y: qy, z: qz, w: qw } = q;
|
|
57
|
+
for (let i = 0; i < points.length; i += 3) {
|
|
58
|
+
const x = points[i], y = points[i + 1], z = points[i + 2];
|
|
59
|
+
const tx = 2 * (qy * z - qz * y), ty = 2 * (qz * x - qx * z), tz = 2 * (qx * y - qy * x);
|
|
60
|
+
const rx = (x + qw * tx + qy * tz - qz * ty) * scale, ry = (y + qw * ty + qz * tx - qx * tz) * scale, rz = (z + qw * tz + qx * ty - qy * tx) * scale;
|
|
61
|
+
if (rx < low[0]) low[0] = rx;
|
|
62
|
+
if (rx > high[0]) high[0] = rx;
|
|
63
|
+
if (ry < low[1]) low[1] = ry;
|
|
64
|
+
if (ry > high[1]) high[1] = ry;
|
|
65
|
+
if (rz < low[2]) low[2] = rz;
|
|
66
|
+
if (rz > high[2]) high[2] = rz;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function scatterMesh(part, material) {
|
|
71
|
+
const item = scatterItem(part.item);
|
|
72
|
+
const mesh = new THREE.InstancedMesh(item.geometry.clone(), material, part.count);
|
|
73
|
+
const half = part.size.map((value) => value / 2);
|
|
74
|
+
const random = seededRandom(partSeed(part, part.seed));
|
|
75
|
+
const shell = part.spread === "shell";
|
|
76
|
+
const { min, max } = item.geometry.boundingBox;
|
|
77
|
+
const height = max.y - min.y, length = Math.max(max.x - min.x, max.z - min.z);
|
|
78
|
+
const points = item.points;
|
|
79
|
+
const spacing = Math.sqrt((shell ? ellipsoidArea(...half) : part.size[0] * part.size[2]) / part.count);
|
|
80
|
+
const nominal = !shell && item.stand ? part.size[1] / height : (item.cover * spacing) / (item.stand ? height : length);
|
|
81
|
+
const lean = (item.tilt + (shell ? SHELL_LEAN : 0)) * DEG;
|
|
82
|
+
// Shell anchors sit inside the ellipsoid by about an item's outward reach, so the items end near its surface.
|
|
83
|
+
const outward = nominal * (item.stand ? height : height + 0.5 * length * Math.sin(lean)) * 0.85;
|
|
84
|
+
const axes = half.map((h) => Math.max(h - outward, h * 0.35));
|
|
85
|
+
const start = [random(), random()], jitter = 0.35 / Math.sqrt(part.count);
|
|
86
|
+
const point = new THREE.Vector3(), up = new THREE.Vector3();
|
|
87
|
+
const tilt = new THREE.Quaternion(), spin = new THREE.Quaternion(), turn = new THREE.Quaternion();
|
|
88
|
+
const matrix = new THREE.Matrix4(), scaled = new THREE.Vector3();
|
|
89
|
+
const low = [0, 0, 0], high = [0, 0, 0], bounds = new THREE.Box3();
|
|
90
|
+
for (let i = 0; i < part.count; i += 1) {
|
|
91
|
+
if (shell) shellPoint(axes, random, point, up);
|
|
92
|
+
else {
|
|
93
|
+
const u = (start[0] + i * R2[0]) % 1 + (random() - 0.5) * jitter, v = (start[1] + i * R2[1]) % 1 + (random() - 0.5) * jitter;
|
|
94
|
+
point.set((u - 0.5) * part.size[0], -half[1], (v - 0.5) * part.size[2]);
|
|
95
|
+
up.copy(UP);
|
|
96
|
+
}
|
|
97
|
+
let scale = nominal * (0.6 + 0.4 * random());
|
|
98
|
+
spin.setFromAxisAngle(UP, random() * 2 * Math.PI);
|
|
99
|
+
tilt.setFromAxisAngle(SIDE, random() * lean);
|
|
100
|
+
turn.setFromUnitVectors(UP, up).multiply(tilt).multiply(spin);
|
|
101
|
+
turnedExtent(points, turn, scale, low, high);
|
|
102
|
+
const fit = Math.min(1, ...half.map((h, axis) => (2 * h * 0.999) / (high[axis] - low[axis])));
|
|
103
|
+
scale *= fit;
|
|
104
|
+
for (let axis = 0; axis < 3; axis += 1) {
|
|
105
|
+
const lo = low[axis] * fit, hi = high[axis] * fit;
|
|
106
|
+
// Ground items stand on the bottom face; everything else is pulled inside the box.
|
|
107
|
+
const value = !shell && axis === 1 ? -half[1] - lo : THREE.MathUtils.clamp(point.getComponent(axis), -half[axis] - lo, half[axis] - hi);
|
|
108
|
+
point.setComponent(axis, value);
|
|
109
|
+
bounds.min.setComponent(axis, Math.min(bounds.min.getComponent(axis), value + lo));
|
|
110
|
+
bounds.max.setComponent(axis, Math.max(bounds.max.getComponent(axis), value + hi));
|
|
111
|
+
}
|
|
112
|
+
if (isMirrorCopy(part)) {
|
|
113
|
+
point.x = -point.x;
|
|
114
|
+
turn.set(turn.x, -turn.y, -turn.z, turn.w);
|
|
115
|
+
}
|
|
116
|
+
mesh.setMatrixAt(i, matrix.compose(point, turn, scaled.setScalar(scale)));
|
|
117
|
+
}
|
|
118
|
+
mesh.instanceMatrix.needsUpdate = true;
|
|
119
|
+
// The fitted outline, not three's box-of-boxes (which turns each item's box and overshoots size).
|
|
120
|
+
if (isMirrorCopy(part)) [bounds.min.x, bounds.max.x] = [-bounds.max.x, -bounds.min.x];
|
|
121
|
+
mesh.boundingBox = bounds;
|
|
122
|
+
mesh.boundingSphere = bounds.getBoundingSphere(new THREE.Sphere());
|
|
123
|
+
return mesh;
|
|
124
|
+
}
|
|
@@ -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 });
|