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.
Files changed (69) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/bin/agent/agent-routes.mjs +8 -7
  3. package/bin/agent/plan-tool.mjs +114 -0
  4. package/bin/agent/script-tool.mjs +2 -0
  5. package/bin/agent/sculpt-sketch.mjs +190 -0
  6. package/bin/agent/session-store.mjs +2 -1
  7. package/bin/agent/sketch-code.mjs +151 -0
  8. package/bin/agent/sketch-sandbox.mjs +102 -0
  9. package/bin/agent/studio-prompt.mjs +1 -1
  10. package/bin/agent/turn-frames.mjs +4 -0
  11. package/dist/app/index.html +5 -5
  12. package/dist/assets/TaskBoard-ngdqiu7u.js +1 -0
  13. package/dist/assets/app-DmR7jBkz.js +5144 -0
  14. package/dist/assets/{motion3-compile-B887n77J.js → motion3-compile-BB3_1-EB.js} +1 -1
  15. package/dist/assets/{scene-asset-sync-COGOlF5T.css → scene-asset-sync-CBx7XRti.css} +1 -1
  16. package/dist/assets/scene-asset-sync-Cgdtr1Yw.js +13 -0
  17. package/dist/assets/task-board-ErQvfoaz.js +14 -0
  18. package/dist/assets/workflow-zQM-EKW1.js +179 -0
  19. package/dist/cozyclay-package.json +1 -1
  20. package/dist/workflow/index.html +4 -4
  21. package/mcp/tool-handlers.mjs +2 -2
  22. package/package.json +3 -1
  23. package/src/App.jsx +2 -2
  24. package/src/commands/elements.js +4 -3
  25. package/src/commands/objects.js +1 -1
  26. package/src/material-look.js +189 -0
  27. package/src/material-presets.js +78 -0
  28. package/src/material-textures.js +157 -0
  29. package/src/object-look.js +9 -5
  30. package/src/primitive-look.js +38 -0
  31. package/src/props.jsx +20 -28
  32. package/src/render-passes.js +7 -1
  33. package/src/scene-objects.js +2 -2
  34. package/src/sculpt-colours.js +65 -0
  35. package/src/sculpt-compare.js +13 -50
  36. package/src/sculpt-csg.js +112 -0
  37. package/src/sculpt-curve.js +123 -0
  38. package/src/sculpt-cutters.js +43 -0
  39. package/src/sculpt-detail.js +207 -0
  40. package/src/sculpt-geometry-loft-convex.js +100 -0
  41. package/src/sculpt-geometry-shapes.js +250 -0
  42. package/src/sculpt-geometry.js +33 -15
  43. package/src/sculpt-loft-rings.js +225 -0
  44. package/src/sculpt-noise.js +65 -0
  45. package/src/sculpt-part-fields.js +27 -0
  46. package/src/sculpt-recipe-read.js +65 -0
  47. package/src/sculpt-recipe.js +33 -75
  48. package/src/sculpt-run.js +1 -1
  49. package/src/sculpt-scatter-items.js +183 -0
  50. package/src/sculpt-scatter-shape.js +57 -0
  51. package/src/sculpt-scatter.js +124 -0
  52. package/src/sculpt-shapes-curved.js +211 -0
  53. package/src/sculpt-shapes-loft-convex.js +218 -0
  54. package/src/sculpt-shapes.js +138 -0
  55. package/src/sculpt-sketch-state.js +76 -0
  56. package/src/sculpt-sketch.jsx +124 -0
  57. package/src/sculpt-surface-fields.js +65 -0
  58. package/src/studio-elements.js +5 -1
  59. package/src/workflow/AgentPanel.jsx +4 -4
  60. package/src/workflow/AgentPlanCard.jsx +41 -0
  61. package/src/workflow/agent-client.js +14 -11
  62. package/src/workflow/agent-plan.css +93 -0
  63. package/src/workflow/agent-plan.js +59 -0
  64. package/tools/run-tests.mjs +3 -3
  65. package/dist/assets/TaskBoard-DdDXMgZm.js +0 -1
  66. package/dist/assets/app-B2BpfCl2.js +0 -5088
  67. package/dist/assets/scene-asset-sync-CQzEdsqu.js +0 -13
  68. package/dist/assets/task-board-BKaM7d-9.js +0 -14
  69. 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 material = autoColor ? (
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. Each part keeps its own clay colour; auto-colour paints
166
- * the whole sculpt flat like any other prop, because it is one object.
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
- () => buildSculptGroup(recipe, {
172
- materialFor: (part) => new THREE.MeshStandardMaterial({
173
- ...(autoColor ? autoFlat(autoColor) : { color: part.color, roughness: 0.82, metalness: 0 }),
174
- ...depthRankProps(depthRank),
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
 
@@ -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 viewPosition = modelViewMatrix * vec4(position, 1.0);
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
  }
@@ -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
+ }
@@ -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 local = partSurfaceSamples(part);
164
- const polygon = convexHull(local.map((point) => viewProject(applyMatrix(part.matrix, point), yaw, elevation)));
165
- let hole = null;
166
- if (part.shape === "frame" || part.shape === "torus") {
167
- const ix = part.size[0] / 2 - (part.shape === "frame" ? part.border : part.size[2]);
168
- const iy = part.size[1] / 2 - (part.shape === "frame" ? part.border : part.size[2]);
169
- if (ix > 0 && iy > 0) {
170
- const corners = [[-ix, -iy], [ix, -iy], [ix, iy], [-ix, iy]].flatMap(([x, y]) => [[x, y, -part.size[2] / 2], [x, y, part.size[2] / 2]]);
171
- const projected = corners.map((point) => viewProject(applyMatrix(part.matrix, point), yaw, elevation));
172
- // See-through only where the front and back openings overlap; the back
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
+ }