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
package/src/props.jsx
CHANGED
|
@@ -19,6 +19,7 @@ import { CUTOUT_KIND, MESH_KIND, EMPTY_KIND, EMPTY_MARKER_SIZE, SCULPT_KIND } fr
|
|
|
19
19
|
import { TEXT_KIND } from "./text/text-record.js";
|
|
20
20
|
import { TextObject } from "./text/text-object.jsx";
|
|
21
21
|
import { buildSculptGroup, disposeSculptGroup } from "./sculpt-geometry.js";
|
|
22
|
+
import { depthRankProps, usePrimitiveLook } from "./primitive-look.js";
|
|
22
23
|
import { subscribeToAssetTexture } from "./scene-asset-cache.js";
|
|
23
24
|
import { ImportedMesh } from "./imported-mesh.jsx";
|
|
24
25
|
import { EffectMarker, EffectsLayer } from "./effects/EffectsLayer.jsx";
|
|
@@ -30,29 +31,23 @@ import { LIGHT_KIND, capPracticalShadows } from "./object-look.js";
|
|
|
30
31
|
import { SceneLight, useLookRefresh } from "./scene-lights.jsx";
|
|
31
32
|
import { placeObjectLook } from "./scene-light-place.js";
|
|
32
33
|
|
|
33
|
-
// Parts of an assembly can share a plane with an earlier part (hood top and
|
|
34
|
-
// grille top). The depth test can't order them and the winner flips as the
|
|
35
|
-
// camera moves, so a ranked part is pulled toward the camera by `rank` steps
|
|
36
|
-
// (negative = nearer). Rank 0 gets nothing: those materials stay untouched.
|
|
37
|
-
function depthRankProps(rank) {
|
|
38
|
-
return rank > 0 ? { polygonOffset: true, polygonOffsetFactor: -rank, polygonOffsetUnits: -rank } : null;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
34
|
/**
|
|
42
35
|
* The creatable primitives, Unity's 3D Object menu in clay. Each one is built
|
|
43
36
|
* with its base on the local floor (y = 0), so an object's `y` reads as height
|
|
44
|
-
* above the deck rather than "half of me is underground".
|
|
37
|
+
* above the deck rather than "half of me is underground". A surface preset
|
|
38
|
+
* (primitive-look.js) replaces the clay; glass casts no shadow.
|
|
45
39
|
*/
|
|
46
|
-
function Primitive({ kind, color, autoColor, depthRank = 0 }) {
|
|
40
|
+
function Primitive({ kind, object, color, autoColor, depthRank = 0 }) {
|
|
47
41
|
const side = kind === "plane" ? THREE.DoubleSide : THREE.FrontSide;
|
|
48
|
-
const
|
|
42
|
+
const look = usePrimitiveLook(object, kind, side, depthRank), cast = !(look?.transmission > 0);
|
|
43
|
+
const material = look ? <primitive object={look} attach="material" /> : autoColor ? (
|
|
49
44
|
<meshStandardMaterial {...autoFlat(autoColor)} side={side} {...depthRankProps(depthRank)} />
|
|
50
45
|
) : (
|
|
51
46
|
<meshStandardMaterial color={color} roughness={0.82} side={side} {...depthRankProps(depthRank)} />
|
|
52
47
|
);
|
|
53
48
|
if (kind === "sphere") {
|
|
54
49
|
return (
|
|
55
|
-
<mesh position={[0, 0.5, 0]} castShadow receiveShadow>
|
|
50
|
+
<mesh position={[0, 0.5, 0]} castShadow={cast} receiveShadow>
|
|
56
51
|
<sphereGeometry args={[0.5, 28, 18]} />
|
|
57
52
|
{material}
|
|
58
53
|
</mesh>
|
|
@@ -60,7 +55,7 @@ function Primitive({ kind, color, autoColor, depthRank = 0 }) {
|
|
|
60
55
|
}
|
|
61
56
|
if (kind === "capsule") {
|
|
62
57
|
return (
|
|
63
|
-
<mesh position={[0, 0.7, 0]} castShadow receiveShadow>
|
|
58
|
+
<mesh position={[0, 0.7, 0]} castShadow={cast} receiveShadow>
|
|
64
59
|
<capsuleGeometry args={[0.35, 0.7, 6, 18]} />
|
|
65
60
|
{material}
|
|
66
61
|
</mesh>
|
|
@@ -68,7 +63,7 @@ function Primitive({ kind, color, autoColor, depthRank = 0 }) {
|
|
|
68
63
|
}
|
|
69
64
|
if (kind === "cylinder") {
|
|
70
65
|
return (
|
|
71
|
-
<mesh position={[0, 0.5, 0]} castShadow receiveShadow>
|
|
66
|
+
<mesh position={[0, 0.5, 0]} castShadow={cast} receiveShadow>
|
|
72
67
|
<cylinderGeometry args={[0.5, 0.5, 1, 26]} />
|
|
73
68
|
{material}
|
|
74
69
|
</mesh>
|
|
@@ -76,7 +71,7 @@ function Primitive({ kind, color, autoColor, depthRank = 0 }) {
|
|
|
76
71
|
}
|
|
77
72
|
if (kind === "cone") {
|
|
78
73
|
return (
|
|
79
|
-
<mesh position={[0, 0.5, 0]} castShadow receiveShadow>
|
|
74
|
+
<mesh position={[0, 0.5, 0]} castShadow={cast} receiveShadow>
|
|
80
75
|
<coneGeometry args={[0.5, 1, 26]} />
|
|
81
76
|
{material}
|
|
82
77
|
</mesh>
|
|
@@ -91,7 +86,7 @@ function Primitive({ kind, color, autoColor, depthRank = 0 }) {
|
|
|
91
86
|
);
|
|
92
87
|
}
|
|
93
88
|
return (
|
|
94
|
-
<mesh position={[0, 0.5, 0]} castShadow receiveShadow>
|
|
89
|
+
<mesh position={[0, 0.5, 0]} castShadow={cast} receiveShadow>
|
|
95
90
|
<boxGeometry args={[1, 1, 1]} />
|
|
96
91
|
{material}
|
|
97
92
|
</mesh>
|
|
@@ -162,20 +157,17 @@ function Cutout({ object }) {
|
|
|
162
157
|
|
|
163
158
|
/**
|
|
164
159
|
* A sculpt: the clay parts its recipe describes, rebuilt only when the recipe
|
|
165
|
-
* or the look changes.
|
|
166
|
-
*
|
|
160
|
+
* or the look changes. The shaded look is buildSculptGroup's own clay (part
|
|
161
|
+
* colours, gradients, scatter tints); auto-colour paints the whole sculpt flat
|
|
162
|
+
* like any other prop, because it is one object. Depth rank rides on either.
|
|
167
163
|
*/
|
|
168
164
|
function Sculpt({ object, depthRank = 0 }) {
|
|
169
165
|
const { recipe, autoColor } = object;
|
|
170
|
-
const group = useMemo(
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
}),
|
|
176
|
-
}),
|
|
177
|
-
[recipe, autoColor, depthRank],
|
|
178
|
-
);
|
|
166
|
+
const group = useMemo(() => {
|
|
167
|
+
const built = buildSculptGroup(recipe, autoColor ? { materialFor: () => new THREE.MeshStandardMaterial(autoFlat(autoColor)) } : {});
|
|
168
|
+
built.traverse((node) => node.material && Object.assign(node.material, depthRankProps(depthRank)));
|
|
169
|
+
return built;
|
|
170
|
+
}, [recipe, autoColor, depthRank]);
|
|
179
171
|
useEffect(() => () => disposeSculptGroup(group), [group]);
|
|
180
172
|
return <primitive object={group} />;
|
|
181
173
|
}
|
|
@@ -242,7 +234,7 @@ function SceneObjectContent({ object, depthRank = 0 }) {
|
|
|
242
234
|
if (renderer === MESH_KIND) return <ImportedMesh object={object} />;
|
|
243
235
|
if (renderer === SCULPT_KIND) return <Sculpt object={object} depthRank={depthRank} />;
|
|
244
236
|
if (renderer === TEXT_KIND) return <TextObject object={object} />;
|
|
245
|
-
if (PRIMITIVE_KINDS.has(renderer)) return <Primitive kind={renderer} color={color} autoColor={autoColor} depthRank={depthRank} />;
|
|
237
|
+
if (PRIMITIVE_KINDS.has(renderer)) return <Primitive kind={renderer} object={object} color={color} autoColor={autoColor} depthRank={depthRank} />;
|
|
246
238
|
return null;
|
|
247
239
|
}
|
|
248
240
|
|
package/src/render-passes.js
CHANGED
|
@@ -47,10 +47,16 @@ export function passFileName(kind) {
|
|
|
47
47
|
// it spreads the value over four channels and reads as colour noise). This
|
|
48
48
|
// shader is the plain depth-to-grey the deliverable wants, and the range it
|
|
49
49
|
// normalises over is the stage's, not the projection's.
|
|
50
|
+
// An InstancedMesh (a sculpt's scatter) draws every instance through its
|
|
51
|
+
// instanceMatrix; without it all of them would land on the mesh's origin.
|
|
50
52
|
const DEPTH_VERTEX = /* glsl */ `
|
|
51
53
|
varying float vViewDepth;
|
|
52
54
|
void main() {
|
|
53
|
-
vec4
|
|
55
|
+
vec4 local = vec4(position, 1.0);
|
|
56
|
+
#ifdef USE_INSTANCING
|
|
57
|
+
local = instanceMatrix * local;
|
|
58
|
+
#endif
|
|
59
|
+
vec4 viewPosition = modelViewMatrix * local;
|
|
54
60
|
vViewDepth = -viewPosition.z;
|
|
55
61
|
gl_Position = projectionMatrix * viewPosition;
|
|
56
62
|
}
|
package/src/scene-objects.js
CHANGED
|
@@ -28,7 +28,7 @@ import { retireObjectKind } from "./retired-kinds.js";
|
|
|
28
28
|
import { objectSize } from "./object-size.js";
|
|
29
29
|
import { EFFECT_ENTRY, effectFields } from "./effects/effect-record.js";
|
|
30
30
|
import { TEXT_ENTRY, TEXT_KIND, textFields, textPatch } from "./text/text-record.js";
|
|
31
|
-
import { LIGHT_KIND, LIGHT_MENU_ENTRIES, lightLibraryEntry, objectLookFields, objectLookPatch, objectGraspFields, objectGraspPatch } from "./object-look.js";
|
|
31
|
+
import { LIGHT_KIND, LIGHT_MENU_ENTRIES, lightLibraryEntry, objectLookFields, objectLookPatch, objectGraspFields, objectGraspPatch, objectMaterialKeys } from "./object-look.js";
|
|
32
32
|
export { OBJECT_COLORS, normalizeObjectColor, rememberObjectColor, readStoredObjectColors, writeStoredObjectColors } from "./object-colors.js";
|
|
33
33
|
export { isEffectivelyHidden };
|
|
34
34
|
|
|
@@ -547,7 +547,7 @@ const TRANSFORM_LIMITS = {
|
|
|
547
547
|
/** The patch keys updateSceneObject reads for this object; any other key is
|
|
548
548
|
* silently ignored there, so callers that must not be silent check against this. */
|
|
549
549
|
export function objectPatchFields(object) {
|
|
550
|
-
const fields = [...Object.keys(TRANSFORM_LIMITS), "name", "color", "hidden", "opacity", "path", "glow", "grasp"];
|
|
550
|
+
const fields = [...Object.keys(TRANSFORM_LIMITS), "name", "color", "hidden", "opacity", "path", "glow", "grasp", ...objectMaterialKeys(object?.renderer)];
|
|
551
551
|
if (object?.renderer === LIGHT_KIND) fields.push("light");
|
|
552
552
|
if (object?.renderer === CUTOUT_KIND) fields.push("assetId", "sourceAssetId", "matteAssetId", "matteScale", "height", "aspect", "width", "stretch");
|
|
553
553
|
if (object?.renderer === MESH_KIND) fields.push("clay", "assetId", "height");
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sculpt colours for the shaded look, applied once per part by
|
|
3
|
+
* buildSculptGroup after the part's material exists:
|
|
4
|
+
*
|
|
5
|
+
* - a `gradient` (sculpt-surface-fields.js) becomes a per-vertex `color`
|
|
6
|
+
* attribute blending the part's colour into the gradient's;
|
|
7
|
+
* - a scatter's items each get a small seeded hue and brightness jitter (and
|
|
8
|
+
* the gradient, by where the item sits), so a field is not one flat swatch.
|
|
9
|
+
*
|
|
10
|
+
* The colour then lives in the vertex or instance colours, so the material's
|
|
11
|
+
* own colour goes white and `vertexColors` turns on (a texture map still
|
|
12
|
+
* multiplies in). A flat display mode (auto-colour, through materialFor)
|
|
13
|
+
* skips this, so it ignores gradients and jitter.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import * as THREE from "three";
|
|
17
|
+
import { isMirrorCopy, partSeed, seededRandom } from "./sculpt-noise.js";
|
|
18
|
+
|
|
19
|
+
const AXES = { x: 0, y: 1, z: 2 };
|
|
20
|
+
/** Item-to-item spread: hue ± this (of the wheel), brightness × 1 ± this. */
|
|
21
|
+
const JITTER_HUE = 0.025;
|
|
22
|
+
const JITTER_SHADE = 0.16;
|
|
23
|
+
|
|
24
|
+
/** How far a local point is along the gradient: 0 at `from` (part colour), 1 at `to`. */
|
|
25
|
+
function blend(part, x, y, z) {
|
|
26
|
+
const { axis, from, to } = part.gradient;
|
|
27
|
+
const i = AXES[axis];
|
|
28
|
+
// A mirrored copy reads its x reversed, so an x gradient reflects with the part.
|
|
29
|
+
const along = [isMirrorCopy(part) ? -x : x, y, z][i] / part.size[i] + 0.5;
|
|
30
|
+
if (to === from) return along >= to ? 1 : 0;
|
|
31
|
+
return THREE.MathUtils.clamp((along - from) / (to - from), 0, 1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function applySculptColours(mesh, part) {
|
|
35
|
+
if (!part.gradient && !mesh.isInstancedMesh) return;
|
|
36
|
+
const base = new THREE.Color(part.color);
|
|
37
|
+
const end = part.gradient ? new THREE.Color(part.gradient.color) : null;
|
|
38
|
+
const colour = new THREE.Color();
|
|
39
|
+
if (mesh.isInstancedMesh) {
|
|
40
|
+
const random = seededRandom(partSeed(part, part.seed + 1));
|
|
41
|
+
const matrix = new THREE.Matrix4(), at = new THREE.Vector3();
|
|
42
|
+
for (let i = 0; i < mesh.count; i += 1) {
|
|
43
|
+
colour.copy(base);
|
|
44
|
+
if (end) {
|
|
45
|
+
at.setFromMatrixPosition(mesh.getMatrixAt(i, matrix));
|
|
46
|
+
colour.lerp(end, blend(part, at.x, at.y, at.z));
|
|
47
|
+
}
|
|
48
|
+
colour.offsetHSL((random() * 2 - 1) * JITTER_HUE, 0, 0).multiplyScalar(1 + (random() * 2 - 1) * JITTER_SHADE);
|
|
49
|
+
mesh.setColorAt(i, colour);
|
|
50
|
+
}
|
|
51
|
+
mesh.instanceColor.needsUpdate = true;
|
|
52
|
+
} else {
|
|
53
|
+
const position = mesh.geometry.getAttribute("position");
|
|
54
|
+
const colours = new Float32Array(position.count * 3);
|
|
55
|
+
for (let i = 0; i < position.count; i += 1) {
|
|
56
|
+
colour.copy(base).lerp(end, blend(part, position.getX(i), position.getY(i), position.getZ(i))).toArray(colours, 3 * i);
|
|
57
|
+
}
|
|
58
|
+
mesh.geometry.setAttribute("color", new THREE.BufferAttribute(colours, 3));
|
|
59
|
+
}
|
|
60
|
+
for (const material of [mesh.material].flat()) {
|
|
61
|
+
material.color?.set(0xffffff);
|
|
62
|
+
material.vertexColors = !!mesh.geometry.getAttribute("color");
|
|
63
|
+
material.needsUpdate = true;
|
|
64
|
+
}
|
|
65
|
+
}
|
package/src/sculpt-compare.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* Pure: image buffers in, numbers and RGBA out. The MCP tool owns the files.
|
|
17
17
|
*/
|
|
18
18
|
import { expandSculptParts, patchSculptRecipe } from "./sculpt-recipe.js";
|
|
19
|
+
import { SCULPT_SHAPE_SPECS } from "./sculpt-shapes.js";
|
|
19
20
|
|
|
20
21
|
const COMPARE_SIZE = 256;
|
|
21
22
|
|
|
@@ -117,37 +118,6 @@ function applyMatrix(m, [x, y, z]) {
|
|
|
117
118
|
return [m[0] * x + m[4] * y + m[8] * z + m[12], m[1] * x + m[5] * y + m[9] * z + m[13], m[2] * x + m[6] * y + m[10] * z + m[14]];
|
|
118
119
|
}
|
|
119
120
|
|
|
120
|
-
function partSurfaceSamples(part) {
|
|
121
|
-
const [sx, sy, sz] = part.size.map((v) => v / 2);
|
|
122
|
-
const points = [];
|
|
123
|
-
if (part.shape === "box") {
|
|
124
|
-
for (const x of [-sx, sx]) for (const y of [-sy, sy]) for (const z of [-sz, sz]) points.push([x, y, z]);
|
|
125
|
-
return points;
|
|
126
|
-
}
|
|
127
|
-
const p = 2 + 6 * (part.roundness ?? 0);
|
|
128
|
-
const taper = part.taper ?? 0;
|
|
129
|
-
for (let i = 0; i <= 16; i += 1) {
|
|
130
|
-
const v = -1 + (2 * i) / 16;
|
|
131
|
-
const k = taper > 0 ? 1 - taper * 0.6 * ((v + 1) / 2) : taper < 0 ? 1 + taper * 0.6 * ((1 - v) / 2) : 1;
|
|
132
|
-
for (let j = 0; j < 24; j += 1) {
|
|
133
|
-
const t = (j / 24) * 2 * Math.PI;
|
|
134
|
-
if (part.shape === "cylinder") {
|
|
135
|
-
const r = 1 - Math.max(0, part.taper) * ((v + 1) / 2) - Math.max(0, -part.taper) * ((1 - v) / 2);
|
|
136
|
-
points.push([Math.cos(t) * sx * r, v * sy, Math.sin(t) * sz * r]);
|
|
137
|
-
} else if (part.shape === "torus" || part.shape === "frame") {
|
|
138
|
-
points.push([Math.cos(t) * sx, Math.sin(t) * sy, v * sz]);
|
|
139
|
-
if (part.shape === "frame") points.push([Math.sign(Math.cos(t)) * sx, Math.sign(Math.sin(t)) * sy, v * sz]);
|
|
140
|
-
} else {
|
|
141
|
-
const ring = Math.max(0, 1 - Math.abs(v) ** p) ** (1 / p);
|
|
142
|
-
const c = Math.cos(t), s = Math.sin(t);
|
|
143
|
-
const norm = (Math.abs(c) ** p + Math.abs(s) ** p) ** (1 / p);
|
|
144
|
-
points.push([(c / norm) * sx * ring * k, v * sy, (s / norm) * sz * ring * k]);
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
return points;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
121
|
function convexHull(points) {
|
|
152
122
|
const pts = points.slice().sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
|
153
123
|
const cross = (o, a, b) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
|
|
@@ -157,26 +127,19 @@ function convexHull(points) {
|
|
|
157
127
|
return lower.slice(0, -1).concat(upper.slice(0, -1));
|
|
158
128
|
}
|
|
159
129
|
|
|
160
|
-
/** Projected convex outline of every drawn part, in view metres. Frames keep their hole. */
|
|
130
|
+
/** Projected convex outline of every drawn part, in view metres. Frames keep their hole. A cutter (op) adds no area and cuts no hole: the reference mask fills enclosed holes anyway, and an intersect's parent keeps its uncut outline. */
|
|
161
131
|
export function sculptViewPolygons(recipe, { yaw = 0, elevation = 0 } = {}) {
|
|
162
|
-
return expandSculptParts(recipe).map((part) => {
|
|
163
|
-
const
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
// face's opening alone is a slightly generous stand-in.
|
|
174
|
-
const opening = convexHull(projected.filter((_, i) => i % 2 === 0));
|
|
175
|
-
hole = opening.length >= 3 ? opening : null;
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
return { id: part.id, polygon, hole };
|
|
179
|
-
});
|
|
132
|
+
return expandSculptParts(recipe).filter((part) => !part.op).map((part) => {
|
|
133
|
+
const project = (point) => viewProject(applyMatrix(part.matrix, point), yaw, elevation);
|
|
134
|
+
const spec = SCULPT_SHAPE_SPECS[part.shape];
|
|
135
|
+
// See-through only where the front and back openings overlap; the front
|
|
136
|
+
// face's opening alone is a slightly generous stand-in.
|
|
137
|
+
const opening = spec.hole?.(part);
|
|
138
|
+
const holeHull = opening ? convexHull(opening.map(project)) : [];
|
|
139
|
+
const hole = holeHull.length >= 3 ? holeHull : null;
|
|
140
|
+
// One polygon per sample group, all carrying the part's id.
|
|
141
|
+
return spec.samples(part).map((group) => ({ id: part.id, polygon: convexHull(group.map(project)), hole }));
|
|
142
|
+
}).flat();
|
|
180
143
|
}
|
|
181
144
|
|
|
182
145
|
function insidePolygon(polygon, x, y) {
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sculpt booleans: the cutters a recipe declares (`op`, sculpt-cutters.js) run
|
|
3
|
+
* on their parent part's geometry with three-bvh-csg.
|
|
4
|
+
*
|
|
5
|
+
* The cut happens in the parent's own frame. The cutter's geometry is moved by
|
|
6
|
+
* inverse(parent) × cutter, both object-frame matrices from expandSculptParts,
|
|
7
|
+
* so the result drops straight back into the parent's mesh, and the mesh keeps
|
|
8
|
+
* its transform, name, shadows and material. A cut only removes (subtract) or
|
|
9
|
+
* keeps less (intersect), so the stored standing box stays an honest bound.
|
|
10
|
+
*
|
|
11
|
+
* A boolean that throws, yields non-finite points or leaves nothing is skipped
|
|
12
|
+
* with a warning. The part is then drawn as it was before that cut instead of
|
|
13
|
+
* vanishing or breaking the build, and the same recipe always skips the same
|
|
14
|
+
* cut.
|
|
15
|
+
*/
|
|
16
|
+
import * as THREE from "three";
|
|
17
|
+
import { Brush, Evaluator, HalfEdgeMap, INTERSECTION, SUBTRACTION } from "three-bvh-csg";
|
|
18
|
+
import { MeshBVH } from "three-mesh-bvh";
|
|
19
|
+
|
|
20
|
+
const OPERATIONS = { subtract: SUBTRACTION, intersect: INTERSECTION };
|
|
21
|
+
/** Carried through a cut when both sides have them at the same item size. The
|
|
22
|
+
* evaluator throws on an attribute one side lacks, so the rest are dropped. */
|
|
23
|
+
const CARRIED = ["position", "normal", "uv", "color"];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* three-bvh-csg 0.0.18 prepares a brush with the BVH option `maxLeafSize`, and
|
|
27
|
+
* three-mesh-bvh 0.9 warns about it on every build: two warnings per cut that
|
|
28
|
+
* would bury a real one. This is the same preparation with the current option.
|
|
29
|
+
* A sculpt brush is prepared once and evaluated synchronously, so it also
|
|
30
|
+
* skips the SharedArrayBuffer copy that only the worker path needs.
|
|
31
|
+
*/
|
|
32
|
+
class SculptBrush extends Brush {
|
|
33
|
+
prepareGeometry() {
|
|
34
|
+
const { geometry } = this;
|
|
35
|
+
geometry.boundsTree = new MeshBVH(geometry, { targetLeafSize: 3, indirect: true });
|
|
36
|
+
geometry.halfEdges = new HalfEdgeMap();
|
|
37
|
+
geometry.halfEdges.updateFrom(geometry);
|
|
38
|
+
// Every triangle in group 0: the evaluator runs with useGroups off.
|
|
39
|
+
geometry.groupIndices = new Uint16Array((geometry.index ?? geometry.getAttribute("position")).count / 3);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A parent with vertex colours (a gradient look) would lose them against a
|
|
44
|
+
* cutter without any, so the cutter borrows their mean. The parent keeps its
|
|
45
|
+
* colours, and the cut faces take its average tone. */
|
|
46
|
+
function lendColours(geometry, cutter) {
|
|
47
|
+
const colour = geometry.getAttribute("color");
|
|
48
|
+
if (!colour || cutter.hasAttribute("color")) return;
|
|
49
|
+
const { count, itemSize } = colour;
|
|
50
|
+
const mean = Array.from({ length: itemSize }, (_, k) => {
|
|
51
|
+
let sum = 0;
|
|
52
|
+
for (let i = 0; i < count; i += 1) sum += colour.getComponent(i, k);
|
|
53
|
+
return sum / count;
|
|
54
|
+
});
|
|
55
|
+
const lent = new THREE.BufferAttribute(new colour.array.constructor(cutter.getAttribute("position").count * itemSize), itemSize, colour.normalized);
|
|
56
|
+
for (let i = 0; i < lent.count; i += 1) for (let k = 0; k < itemSize; k += 1) lent.setComponent(i, k, mean[k]);
|
|
57
|
+
cutter.setAttribute("color", lent);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** `geometry` cut by `cutter` (already in the same frame), as a new geometry. Throws when the cut fails. */
|
|
61
|
+
function evaluate(geometry, cutter, operation) {
|
|
62
|
+
lendColours(geometry, cutter);
|
|
63
|
+
const evaluator = new Evaluator();
|
|
64
|
+
evaluator.useGroups = false;
|
|
65
|
+
// Constrained Delaunay clipping leaves far fewer T-junctions than the legacy
|
|
66
|
+
// splitter and handles coplanar faces, such as a cutter exactly as deep as its wall.
|
|
67
|
+
evaluator.useCDTClipping = true;
|
|
68
|
+
evaluator.attributes = CARRIED.filter((name) => geometry.hasAttribute(name) && geometry.getAttribute(name).itemSize === cutter.getAttribute(name)?.itemSize);
|
|
69
|
+
const a = new SculptBrush(geometry), b = new SculptBrush(cutter);
|
|
70
|
+
try {
|
|
71
|
+
a.updateMatrixWorld();
|
|
72
|
+
b.updateMatrixWorld();
|
|
73
|
+
const result = evaluator.evaluate(a, b, operation).geometry;
|
|
74
|
+
const points = result.getAttribute("position")?.array ?? [];
|
|
75
|
+
const reason = result.drawRange.count < 3 ? "it leaves nothing" : points.every(Number.isFinite) ? null : "it yields non-finite points";
|
|
76
|
+
if (reason) {
|
|
77
|
+
result.dispose();
|
|
78
|
+
throw new Error(reason);
|
|
79
|
+
}
|
|
80
|
+
if (!result.hasAttribute("normal")) result.computeVertexNormals();
|
|
81
|
+
return result;
|
|
82
|
+
} finally {
|
|
83
|
+
a.disposeCacheData();
|
|
84
|
+
b.disposeCacheData();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* `geometry` (built for `part`, in its own frame) cut by every cutter whose
|
|
90
|
+
* parent is `part`, in recipe order. `parts` is expandSculptParts' list and
|
|
91
|
+
* `build(cutter)` makes a cutter's geometry. Whatever the cuts replace is
|
|
92
|
+
* disposed here; the geometry returned belongs to the caller.
|
|
93
|
+
*/
|
|
94
|
+
export function cutSculptGeometry(geometry, part, parts, build) {
|
|
95
|
+
let cut = geometry;
|
|
96
|
+
let parentInverse = null;
|
|
97
|
+
for (const cutter of parts) {
|
|
98
|
+
if (!cutter.op || cutter.parent !== part.id) continue;
|
|
99
|
+
parentInverse ??= new THREE.Matrix4().fromArray(part.matrix).invert();
|
|
100
|
+
const tool = build(cutter).applyMatrix4(new THREE.Matrix4().fromArray(cutter.matrix).premultiply(parentInverse));
|
|
101
|
+
try {
|
|
102
|
+
const next = evaluate(cut, tool, OPERATIONS[cutter.op]);
|
|
103
|
+
cut.dispose();
|
|
104
|
+
cut = next;
|
|
105
|
+
} catch (error) {
|
|
106
|
+
console.warn(`sculpt: skipped ${cutter.op} "${cutter.id}" on "${part.id}" (${error.message}); the part is drawn without that cut`);
|
|
107
|
+
} finally {
|
|
108
|
+
tool.dispose();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return cut;
|
|
112
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Curve and outline math for the sculpt shapes that follow points (lathe,
|
|
3
|
+
* tube, extrude). Pure: no three.js. The recipe reader, the silhouette compare
|
|
4
|
+
* and the geometry builder all sample through here, so the drawn shape and the
|
|
5
|
+
* scored shape cannot drift apart.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const EPSILON = 1e-9;
|
|
9
|
+
|
|
10
|
+
/** Fritsch–Butland slope at a knot: zero where the axis turns, otherwise the
|
|
11
|
+
* harmonic mean of the two secants, which is never more than twice either. */
|
|
12
|
+
function knotSlope(before, after) {
|
|
13
|
+
if (before === null) return after;
|
|
14
|
+
if (after === null) return before;
|
|
15
|
+
return before * after <= 0 ? 0 : (2 * before * after) / (before + after);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A smooth curve through `points` (arrays of any length), `steps` samples per
|
|
20
|
+
* segment. Each axis is a monotone cubic: it passes through every point and,
|
|
21
|
+
* between two points, never leaves the box they span. So a profile with r <= 1
|
|
22
|
+
* stays <= 1 and a path inside its box stays inside, with no clamping. A
|
|
23
|
+
* Catmull-Rom spline overshoots instead. `closed` joins the last point back to
|
|
24
|
+
* the first and returns no repeated end sample.
|
|
25
|
+
*/
|
|
26
|
+
export function monotoneCurve(points, steps, closed = false) {
|
|
27
|
+
const n = points.length;
|
|
28
|
+
const at = (i) => points[closed ? (i + n) % n : i];
|
|
29
|
+
const tangents = points.map((point, k) => point.map((value, axis) => knotSlope(
|
|
30
|
+
closed || k > 0 ? value - at(k - 1)[axis] : null,
|
|
31
|
+
closed || k < n - 1 ? at(k + 1)[axis] - value : null,
|
|
32
|
+
)));
|
|
33
|
+
const out = [];
|
|
34
|
+
for (let segment = 0; segment < (closed ? n : n - 1); segment += 1) {
|
|
35
|
+
const a = at(segment), b = at(segment + 1), ta = tangents[segment], tb = tangents[(segment + 1) % n];
|
|
36
|
+
for (let i = 0; i < steps; i += 1) {
|
|
37
|
+
const t = i / steps, t2 = t * t, t3 = t2 * t;
|
|
38
|
+
const h00 = 2 * t3 - 3 * t2 + 1, h10 = t3 - 2 * t2 + t, h01 = 3 * t2 - 2 * t3, h11 = t3 - t2;
|
|
39
|
+
out.push(a.map((value, axis) => h00 * value + h10 * ta[axis] + h01 * b[axis] + h11 * tb[axis]));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (!closed) out.push([...points[n - 1]]);
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Signed area: positive when the loop runs counter-clockwise. */
|
|
47
|
+
export function signedArea(loop) {
|
|
48
|
+
let sum = 0;
|
|
49
|
+
for (let i = 0, j = loop.length - 1; i < loop.length; j = i, i += 1) sum += loop[j][0] * loop[i][1] - loop[i][0] * loop[j][1];
|
|
50
|
+
return sum / 2;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const cross = (o, a, b) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
|
|
54
|
+
const side = (o, a, b) => {
|
|
55
|
+
const value = cross(o, a, b);
|
|
56
|
+
return Math.abs(value) <= EPSILON ? 0 : Math.sign(value);
|
|
57
|
+
};
|
|
58
|
+
const within = (a, b, p) => Math.min(a[0], b[0]) - EPSILON <= p[0] && p[0] <= Math.max(a[0], b[0]) + EPSILON
|
|
59
|
+
&& Math.min(a[1], b[1]) - EPSILON <= p[1] && p[1] <= Math.max(a[1], b[1]) + EPSILON;
|
|
60
|
+
|
|
61
|
+
function segmentsTouch(a, b, c, d) {
|
|
62
|
+
const o1 = side(a, b, c), o2 = side(a, b, d), o3 = side(c, d, a), o4 = side(c, d, b);
|
|
63
|
+
if (o1 !== o2 && o3 !== o4) return true;
|
|
64
|
+
return (o1 === 0 && within(a, b, c)) || (o2 === 0 && within(a, b, d)) || (o3 === 0 && within(c, d, a)) || (o4 === 0 && within(c, d, b));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The first two edges that touch or cross, across every loop, as
|
|
69
|
+
* `{ a: { loop, edge }, b: { loop, edge } }`, or null when the loops are simple
|
|
70
|
+
* and apart. Neighbouring edges of one loop share a point by design; they only
|
|
71
|
+
* count when the second folds back along the first.
|
|
72
|
+
*/
|
|
73
|
+
export function loopCrossing(loops) {
|
|
74
|
+
const edges = loops.flatMap((loop, index) => loop.map((p, edge) => ({ loop: index, edge, n: loop.length, a: p, b: loop[(edge + 1) % loop.length] })));
|
|
75
|
+
for (let i = 0; i < edges.length; i += 1) {
|
|
76
|
+
for (let j = i + 1; j < edges.length; j += 1) {
|
|
77
|
+
const e = edges[i], f = edges[j];
|
|
78
|
+
const follows = e.loop === f.loop && (e.edge + 1) % e.n === f.edge;
|
|
79
|
+
const precedes = e.loop === f.loop && (f.edge + 1) % f.n === e.edge;
|
|
80
|
+
let touch;
|
|
81
|
+
if (follows || precedes) {
|
|
82
|
+
const [shared, p, q] = follows ? [e.b, e.a, f.b] : [e.a, e.b, f.a];
|
|
83
|
+
touch = side(shared, p, q) === 0 && (p[0] - shared[0]) * (q[0] - shared[0]) + (p[1] - shared[1]) * (q[1] - shared[1]) > 0;
|
|
84
|
+
} else touch = segmentsTouch(e.a, e.b, f.a, f.b);
|
|
85
|
+
if (touch) return { a: { loop: e.loop, edge: e.edge }, b: { loop: f.loop, edge: f.edge } };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Even-odd test of a point against a closed loop. */
|
|
92
|
+
export function insideLoop([x, y], loop) {
|
|
93
|
+
let inside = false;
|
|
94
|
+
for (let i = 0, j = loop.length - 1; i < loop.length; j = i, i += 1) {
|
|
95
|
+
const [xi, yi] = loop[i], [xj, yj] = loop[j];
|
|
96
|
+
if ((yi > y) !== (yj > y) && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside;
|
|
97
|
+
}
|
|
98
|
+
return inside;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Ear clipping: index triples covering a simple loop of either winding. n is
|
|
103
|
+
* at most 64, so the quadratic search is cheap. A loop left with only
|
|
104
|
+
* collinear corners is clipped anyway, so the loop always ends.
|
|
105
|
+
*/
|
|
106
|
+
export function earClip(loop) {
|
|
107
|
+
const ring = loop.map((_, index) => index);
|
|
108
|
+
if (signedArea(loop) < 0) ring.reverse();
|
|
109
|
+
const triangles = [];
|
|
110
|
+
while (ring.length > 3) {
|
|
111
|
+
const corner = (i) => [ring[(i + ring.length - 1) % ring.length], ring[i], ring[(i + 1) % ring.length]];
|
|
112
|
+
let cut = ring.findIndex((_, i) => {
|
|
113
|
+
const [a, b, c] = corner(i).map((k) => loop[k]);
|
|
114
|
+
if (cross(a, b, c) <= EPSILON) return false;
|
|
115
|
+
return !ring.some((k) => !corner(i).includes(k) && cross(a, b, loop[k]) >= 0 && cross(b, c, loop[k]) >= 0 && cross(c, a, loop[k]) >= 0);
|
|
116
|
+
});
|
|
117
|
+
if (cut < 0) cut = 0;
|
|
118
|
+
triangles.push(corner(cut));
|
|
119
|
+
ring.splice(cut, 1);
|
|
120
|
+
}
|
|
121
|
+
triangles.push(ring);
|
|
122
|
+
return triangles;
|
|
123
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sculpt cutters: a part with `op` is never drawn. It cuts its `parent` part,
|
|
3
|
+
* which is how a recipe gets windows, doors and arches in a wall, a hollow
|
|
4
|
+
* bowl or cup, or a notch in a plank (subtract), and a half-dome or a sliced
|
|
5
|
+
* fruit (intersect). This module is the pure half: it reads `op` and refuses
|
|
6
|
+
* the recipes a cutter cannot serve. sculpt-csg.js runs the boolean on the
|
|
7
|
+
* parent's geometry. Pure: no three.js.
|
|
8
|
+
*
|
|
9
|
+
* Several cutters on one parent apply in recipe order, and a mirrored cutter
|
|
10
|
+
* cuts both sides. A cutter's colour is never seen: the cut faces wear the
|
|
11
|
+
* parent's look. Each rule below is refused with its path, never bent into a
|
|
12
|
+
* different shape:
|
|
13
|
+
* - a cutter needs a parent, because a cutter at the object level would have
|
|
14
|
+
* nothing to cut;
|
|
15
|
+
* - a cutter carries no parts. A drawn child would ride a frame nobody sees,
|
|
16
|
+
* and a cutter that cuts another cutter is not supported, so two cutters
|
|
17
|
+
* that build one opening (an arched door) both cut the wall.
|
|
18
|
+
*/
|
|
19
|
+
import { RecipeError } from "./sculpt-recipe-read.js";
|
|
20
|
+
|
|
21
|
+
const SCULPT_OPS = ["subtract", "intersect"];
|
|
22
|
+
|
|
23
|
+
export const SCULPT_OP_FIELD = Object.freeze({
|
|
24
|
+
key: "op",
|
|
25
|
+
read(raw, part, where) {
|
|
26
|
+
if (!SCULPT_OPS.includes(raw.op)) throw new RecipeError(`${where}.op`, `must be "subtract" or "intersect", got ${JSON.stringify(raw.op)}`);
|
|
27
|
+
if (part.parent === undefined) throw new RecipeError(`${where}.op`, "a cutter needs a parent: the part it cuts");
|
|
28
|
+
part.op = raw.op;
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/** Refuses any part that names a cutter as its parent. Run it once parents resolve. */
|
|
33
|
+
export function checkSculptCutters(parts) {
|
|
34
|
+
const byId = new Map(parts.map((part) => [part.id, part]));
|
|
35
|
+
for (const part of parts) {
|
|
36
|
+
const parent = byId.get(part.parent);
|
|
37
|
+
if (part.op && parent?.shape === "scatter") throw new RecipeError(`parts[${part.id}].op`, `"${parent.id}" is a scatter, which is drawn as instances and cannot be cut`);
|
|
38
|
+
if (!parent?.op) continue;
|
|
39
|
+
throw new RecipeError(`parts[${part.id}].parent`, part.op
|
|
40
|
+
? `"${parent.id}" is a cutter, and a cutter only cuts drawn parts; give this one parent "${parent.parent}" so both cut it`
|
|
41
|
+
: `"${parent.id}" is a cutter (op), which is not drawn and cannot carry parts; use parent "${parent.parent}" instead`);
|
|
42
|
+
}
|
|
43
|
+
}
|