cozyclay 3.1.0 → 3.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/bin/agent/agent-routes.mjs +12 -11
  3. package/bin/agent/main-agent.mjs +21 -7
  4. package/bin/agent/main-prompt.mjs +7 -6
  5. package/bin/agent/plan-tool.mjs +114 -0
  6. package/bin/agent/script-tool.mjs +4 -1
  7. package/bin/agent/sculpt-sketch.mjs +190 -0
  8. package/bin/agent/session-store.mjs +2 -1
  9. package/bin/agent/sketch-code.mjs +151 -0
  10. package/bin/agent/sketch-sandbox.mjs +102 -0
  11. package/bin/agent/studio-prompt.mjs +1 -1
  12. package/bin/agent/studio-rebase.mjs +5 -4
  13. package/bin/agent/studio-task-routes.mjs +1 -1
  14. package/bin/agent/studio-tasks.mjs +116 -25
  15. package/bin/agent/studio-tools.mjs +3 -2
  16. package/bin/agent/turn-frames.mjs +4 -0
  17. package/bin/cozyclay.mjs +4 -2
  18. package/bin/live-endpoint.mjs +2 -0
  19. package/dist/app/index.html +6 -6
  20. package/dist/assets/{TaskBoard-DdDXMgZm.js → TaskBoard-GR8xZm5c.js} +1 -1
  21. package/dist/assets/{app-BlwOpest.css → app-Bhe0TpGz.css} +1 -1
  22. package/dist/assets/app-C4QTcDcb.js +5144 -0
  23. package/dist/assets/compile-worker-oy_P9HHh.js +6 -0
  24. package/dist/assets/motion3-compile-dgGhrvPK.js +1 -0
  25. package/dist/assets/scene-asset-sync-BEjjhNEo.css +1 -0
  26. package/dist/assets/scene-asset-sync-qoxcdrhj.js +13 -0
  27. package/dist/assets/task-board-BqReHzOi.js +15 -0
  28. package/dist/assets/workflow-C87p6E1E.js +179 -0
  29. package/dist/cozyclay-package.json +1 -1
  30. package/dist/index.html +1 -1
  31. package/dist/sitemap.xml +1 -1
  32. package/dist/workflow/index.html +4 -4
  33. package/mcp/README.md +1 -1
  34. package/mcp/live-hub.mjs +5 -3
  35. package/mcp/tool-handlers.mjs +7 -7
  36. package/package.json +3 -1
  37. package/src/App.jsx +44 -66
  38. package/src/bus-task-scopes.js +59 -11
  39. package/src/bus-task-targets.js +35 -9
  40. package/src/bus-tasks.js +23 -10
  41. package/src/command-bus.js +7 -4
  42. package/src/commands/ai.js +1 -1
  43. package/src/commands/elements.js +4 -3
  44. package/src/commands/export.js +1 -1
  45. package/src/commands/motion3.js +27 -7
  46. package/src/commands/objects.js +1 -1
  47. package/src/export-workspace.js +244 -0
  48. package/src/keyframe-pack.js +5 -1
  49. package/src/live-control.js +14 -7
  50. package/src/material-look.js +189 -0
  51. package/src/material-presets.js +78 -0
  52. package/src/material-textures.js +157 -0
  53. package/src/motion3/compile-job.js +15 -3
  54. package/src/motion3/compiler/channels.js +74 -24
  55. package/src/motion3/compiler/compile.js +2 -1
  56. package/src/motion3/compiler/idle.js +2 -0
  57. package/src/motion3/compiler/overlap.js +180 -0
  58. package/src/motion3/compiler/pose.js +6 -4
  59. package/src/motion3/compiler/validate-common.js +1 -0
  60. package/src/motion3/compiler/validate-parts.js +5 -1
  61. package/src/motion3/compiler/validate.js +30 -10
  62. package/src/motion3/correct.js +236 -0
  63. package/src/motion3/docs.js +8 -6
  64. package/src/motion3/face-lean.js +31 -0
  65. package/src/motion3/fix/index.js +5 -1
  66. package/src/motion3/fix/reach-body.js +172 -0
  67. package/src/motion3/hint-check.js +44 -0
  68. package/src/motion3/json-patch.js +120 -12
  69. package/src/motion3/lenient.js +46 -4
  70. package/src/motion3/object-keys.js +17 -5
  71. package/src/motion3/pipeline.js +46 -9
  72. package/src/motion3/qa/arm-checks.js +96 -0
  73. package/src/motion3/qa/asked.js +113 -0
  74. package/src/motion3/qa/goals.js +37 -8
  75. package/src/motion3/qa/hints-goal.js +6 -5
  76. package/src/motion3/qa/index.js +5 -2
  77. package/src/motion3/qa/key-checks.js +4 -3
  78. package/src/motion3/qa/motion-life.js +243 -0
  79. package/src/motion3/qa/spec-checks.js +15 -9
  80. package/src/motion3/qa/verdict.js +50 -0
  81. package/src/motion3/refs.js +38 -14
  82. package/src/motion3/repair-watch.js +132 -0
  83. package/src/motion3/repair.js +52 -19
  84. package/src/motion3/report.js +50 -9
  85. package/src/motion3/schema/topics.js +6 -4
  86. package/src/motion3/schema/types.js +1 -1
  87. package/src/motion3/skills/basic.js +15 -8
  88. package/src/motion3/skills/index.js +52 -18
  89. package/src/motion3/skills/info.js +1 -1
  90. package/src/motion3/skills/stance.js +6 -0
  91. package/src/motion3/skills/walk-fit.js +170 -0
  92. package/src/object-look.js +9 -5
  93. package/src/panels/PoseImagePanel.jsx +3 -2
  94. package/src/panels/SculptPanel.jsx +2 -2
  95. package/src/primitive-look.js +38 -0
  96. package/src/props.jsx +20 -28
  97. package/src/render-passes.js +7 -1
  98. package/src/result-modal.jsx +3 -3
  99. package/src/scene-objects.js +2 -2
  100. package/src/sculpt-colours.js +65 -0
  101. package/src/sculpt-compare.js +13 -50
  102. package/src/sculpt-csg.js +112 -0
  103. package/src/sculpt-curve.js +123 -0
  104. package/src/sculpt-cutters.js +43 -0
  105. package/src/sculpt-detail.js +207 -0
  106. package/src/sculpt-geometry-loft-convex.js +100 -0
  107. package/src/sculpt-geometry-shapes.js +250 -0
  108. package/src/sculpt-geometry.js +33 -15
  109. package/src/sculpt-loft-rings.js +225 -0
  110. package/src/sculpt-noise.js +65 -0
  111. package/src/sculpt-part-fields.js +27 -0
  112. package/src/sculpt-recipe-read.js +65 -0
  113. package/src/sculpt-recipe.js +33 -75
  114. package/src/sculpt-run.js +1 -1
  115. package/src/sculpt-scatter-items.js +183 -0
  116. package/src/sculpt-scatter-shape.js +57 -0
  117. package/src/sculpt-scatter.js +124 -0
  118. package/src/sculpt-shapes-curved.js +211 -0
  119. package/src/sculpt-shapes-loft-convex.js +218 -0
  120. package/src/sculpt-shapes.js +138 -0
  121. package/src/sculpt-sketch-state.js +76 -0
  122. package/src/sculpt-sketch.jsx +124 -0
  123. package/src/sculpt-surface-fields.js +65 -0
  124. package/src/shell/ExportFolderPreference.jsx +103 -0
  125. package/src/shell/PreferencesDialog.jsx +3 -166
  126. package/src/shell/StatusBar.jsx +1 -1
  127. package/src/shell/preference-controls.jsx +114 -0
  128. package/src/shell/preference-pages.jsx +72 -0
  129. package/src/shell/preferences.css +46 -0
  130. package/src/shot-prompt.js +29 -9
  131. package/src/studio-actions.js +2 -2
  132. package/src/studio-agent-motion.js +19 -9
  133. package/src/studio-agent-protocol.js +1 -1
  134. package/src/studio-app-binding.js +3 -2
  135. package/src/studio-elements.js +5 -1
  136. package/src/workflow/AgentPanel.jsx +6 -6
  137. package/src/workflow/AgentPlanCard.jsx +41 -0
  138. package/src/workflow/CozySceneNode.jsx +1 -1
  139. package/src/workflow/WorkflowBuilder.jsx +2 -2
  140. package/src/workflow/agent-client.js +14 -11
  141. package/src/workflow/agent-plan.css +93 -0
  142. package/src/workflow/agent-plan.js +59 -0
  143. package/src/workflow/keyframe-pack-request.js +3 -10
  144. package/src/workflow/task-board.css +1 -1
  145. package/src/workflow/task-board.js +7 -5
  146. package/tools/loc-baseline.json +1 -2
  147. package/tools/run-tests.mjs +8 -8
  148. package/dist/assets/app-B2BpfCl2.js +0 -5088
  149. package/dist/assets/compile-worker-D0pKioa0.js +0 -5
  150. package/dist/assets/motion3-compile-B887n77J.js +0 -1
  151. package/dist/assets/scene-asset-sync-COGOlF5T.css +0 -1
  152. package/dist/assets/scene-asset-sync-CQzEdsqu.js +0 -13
  153. package/dist/assets/task-board-BKaM7d-9.js +0 -14
  154. package/dist/assets/workflow-B8Dhzexj.js +0 -179
@@ -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
+ }
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Surface detail: a part's `detail` (sculpt-surface-fields.js) turns a smooth
3
+ * primitive into a lumpy cloud, bush, rock or loaf by pushing its surface out
4
+ * along the normals with seeded, billowed fBm noise (|fBm|: rounded lumps with
5
+ * creases between them, which reads as puffs or stone where plain fBm reads as
6
+ * a dented ball). It is a post-process on any part's
7
+ * BufferGeometry, so a shape added later gets it without a line of its own.
8
+ *
9
+ * - Bounds: the base is shrunk by the bump height first and no vertex then
10
+ * moves further than that, so the lumps end inside the declared size and
11
+ * the record's measured box (which trusts size) still holds.
12
+ * - Seams: vertices are welded by position before anything moves, so both
13
+ * sides of a sphere's seam (or a box's edge) move together and never split.
14
+ * - Tessellation: a box or a one-segment cylinder has too few vertices to
15
+ * show a bump, so long edges are halved until they are a fraction of a bump.
16
+ * The split test reads only the edge, so both triangles on an edge agree
17
+ * and no T-junction cracks open once the surface moves.
18
+ */
19
+
20
+ import * as THREE from "three";
21
+ import { DETAIL_RELIEF } from "./sculpt-surface-fields.js";
22
+ import { fbm3, isMirrorCopy, partSeed, seededRandom } from "./sculpt-noise.js";
23
+
24
+ /** A detailed part stops splitting edges past this many triangles. */
25
+ const MAX_TRIANGLES = 40000;
26
+ /** Edges are split until a bump spans about this many of them. */
27
+ const EDGES_PER_BUMP = 5;
28
+ /** Faces meeting more sharply than this stay creased (a box edge); lumps shade smooth. */
29
+ const CREASE_COS = Math.cos((65 * Math.PI) / 180);
30
+
31
+ /** Vertex → id shared by every vertex at the same position (within `eps`), via
32
+ * an open-addressed table on the quantized position. */
33
+ function weld(position, eps) {
34
+ const count = position.length / 3;
35
+ const ids = new Int32Array(count);
36
+ let size = 1;
37
+ while (size < count * 2) size <<= 1;
38
+ const table = new Int32Array(size).fill(-1);
39
+ const keys = new Int32Array(count * 3);
40
+ let unique = 0;
41
+ for (let i = 0; i < count; i += 1) {
42
+ const qx = Math.round(position[3 * i] / eps), qy = Math.round(position[3 * i + 1] / eps), qz = Math.round(position[3 * i + 2] / eps);
43
+ let slot = (Math.imul(qx, 73856093) ^ Math.imul(qy, 19349663) ^ Math.imul(qz, 83492791)) & (size - 1);
44
+ for (;;) {
45
+ const id = table[slot];
46
+ if (id < 0) {
47
+ table[slot] = unique;
48
+ keys[3 * unique] = qx; keys[3 * unique + 1] = qy; keys[3 * unique + 2] = qz;
49
+ ids[i] = unique++;
50
+ break;
51
+ }
52
+ if (keys[3 * id] === qx && keys[3 * id + 1] === qy && keys[3 * id + 2] === qz) {
53
+ ids[i] = id;
54
+ break;
55
+ }
56
+ slot = (slot + 1) & (size - 1);
57
+ }
58
+ }
59
+ return { ids, count: unique };
60
+ }
61
+
62
+ /** Local corners per split mask (bit 0 = edge ab, 1 = bc, 2 = ca); 3, 4, 5 are
63
+ * the midpoints of ab, bc, ca. Every pattern keeps the source winding. */
64
+ const PATTERNS = [
65
+ [0, 1, 2],
66
+ [0, 3, 2, 3, 1, 2],
67
+ [1, 4, 0, 4, 2, 0],
68
+ [3, 1, 4, 0, 3, 4, 0, 4, 2],
69
+ [2, 5, 1, 5, 0, 1],
70
+ [5, 0, 3, 2, 5, 3, 2, 3, 1],
71
+ [4, 2, 5, 1, 4, 5, 1, 5, 0],
72
+ [0, 3, 5, 3, 1, 4, 5, 4, 2, 3, 4, 5],
73
+ ];
74
+
75
+ /** Non-indexed attributes ([position, ...others]) with every edge longer
76
+ * than `maxEdge` halved, pass by pass, within MAX_TRIANGLES. */
77
+ function subdivide(attributes, maxEdge) {
78
+ const limit = maxEdge * maxEdge;
79
+ for (let pass = 0; pass < 10; pass += 1) {
80
+ const position = attributes[0].array;
81
+ const triangles = position.length / 9;
82
+ const masks = new Uint8Array(triangles);
83
+ let next = 0;
84
+ for (let t = 0; t < triangles; t += 1) {
85
+ for (let e = 0; e < 3; e += 1) {
86
+ const a = 9 * t + 3 * e, b = 9 * t + 3 * ((e + 1) % 3);
87
+ const dx = position[a] - position[b], dy = position[a + 1] - position[b + 1], dz = position[a + 2] - position[b + 2];
88
+ if (dx * dx + dy * dy + dz * dz > limit) masks[t] |= 1 << e;
89
+ }
90
+ next += PATTERNS[masks[t]].length / 3;
91
+ }
92
+ if (next === triangles || next > MAX_TRIANGLES) break;
93
+ attributes = attributes.map(({ name, size, array }) => {
94
+ const out = new Float64Array(next * 3 * size);
95
+ let o = 0;
96
+ for (let t = 0; t < triangles; t += 1) {
97
+ const base = 3 * t * size;
98
+ for (const corner of PATTERNS[masks[t]]) {
99
+ // (a + b) / 2 is the same bits from either side, so neighbours share the midpoint exactly.
100
+ const a = corner < 3 ? corner : corner - 3, b = corner < 3 ? corner : (corner - 2) % 3;
101
+ for (let k = 0; k < size; k += 1) out[o++] = (array[base + a * size + k] + array[base + b * size + k]) / 2;
102
+ }
103
+ }
104
+ return { name, size, array: out };
105
+ });
106
+ }
107
+ return attributes;
108
+ }
109
+
110
+ /** Area-weighted (unnormalized) normal of every triangle. */
111
+ function faceNormals(position) {
112
+ const normals = new Float64Array(position.length / 3);
113
+ for (let f = 0; f < position.length; f += 9) {
114
+ const ax = position[f + 3] - position[f], ay = position[f + 4] - position[f + 1], az = position[f + 5] - position[f + 2];
115
+ const bx = position[f + 6] - position[f], by = position[f + 7] - position[f + 1], bz = position[f + 8] - position[f + 2];
116
+ normals[f / 3] = ay * bz - az * by;
117
+ normals[f / 3 + 1] = az * bx - ax * bz;
118
+ normals[f / 3 + 2] = ax * by - ay * bx;
119
+ }
120
+ return normals;
121
+ }
122
+
123
+ /** Per-vertex normals: the faces around the welded vertex that meet the
124
+ * vertex's own face within the crease angle, area-weighted. */
125
+ function creasedNormals(position, ids, count) {
126
+ const faces = faceNormals(position);
127
+ const unit = faces.map((value, i) => value / (Math.hypot(faces[i - (i % 3)], faces[i - (i % 3) + 1], faces[i - (i % 3) + 2]) || 1));
128
+ const start = new Int32Array(count + 1);
129
+ for (const id of ids) start[id + 1] += 1;
130
+ for (let id = 0; id < count; id += 1) start[id + 1] += start[id];
131
+ const fill = start.slice(0, count), around = new Int32Array(ids.length);
132
+ ids.forEach((id, v) => { around[fill[id]++] = Math.floor(v / 3); });
133
+ const normals = new Float32Array(position.length);
134
+ for (let v = 0; v < ids.length; v += 1) {
135
+ const own = 3 * Math.floor(v / 3);
136
+ const flat = unit[own] === 0 && unit[own + 1] === 0 && unit[own + 2] === 0;
137
+ let x = 0, y = 0, z = 0;
138
+ for (let k = start[ids[v]]; k < start[ids[v] + 1]; k += 1) {
139
+ const g = 3 * around[k];
140
+ if (!flat && unit[g] * unit[own] + unit[g + 1] * unit[own + 1] + unit[g + 2] * unit[own + 2] < CREASE_COS) continue;
141
+ x += faces[g]; y += faces[g + 1]; z += faces[g + 2];
142
+ }
143
+ const length = Math.hypot(x, y, z);
144
+ if (length > 0) normals.set([x / length, y / length, z / length], 3 * v);
145
+ else normals[3 * v + 1] = 1;
146
+ }
147
+ return normals;
148
+ }
149
+
150
+ /**
151
+ * The part's geometry with its `detail` applied, or the geometry untouched
152
+ * when the part has none. The result is non-indexed and keeps every other
153
+ * attribute (uv, colour), interpolated where edges were split.
154
+ */
155
+ export function applySculptDetail(geometry, part) {
156
+ const amount = part.detail?.amount ?? 0;
157
+ if (!(amount > 0)) return geometry;
158
+ const half = part.size.map((value) => value / 2);
159
+ const longest = Math.max(...part.size);
160
+ const relief = amount * DETAIL_RELIEF * Math.min(...half);
161
+ geometry.scale(...half.map((h) => (h - relief) / h));
162
+ const source = geometry.index ? geometry.toNonIndexed() : geometry;
163
+ const names = ["position", ...Object.keys(source.attributes).filter((name) => name !== "position" && name !== "normal")];
164
+ let attributes = names.map((name) => ({ name, size: source.attributes[name].itemSize, array: Float64Array.from(source.attributes[name].array) }));
165
+ geometry.dispose();
166
+ source.dispose();
167
+ // Snap every vertex onto its welded twin so split decisions and midpoints match bit for bit.
168
+ const eps = longest * 1e-5;
169
+ let { ids, count } = weld(attributes[0].array, eps);
170
+ const first = new Int32Array(count).fill(-1);
171
+ ids.forEach((id, v) => {
172
+ if (first[id] < 0) first[id] = v;
173
+ else for (let k = 0; k < 3; k += 1) attributes[0].array[3 * v + k] = attributes[0].array[3 * first[id] + k];
174
+ });
175
+ attributes = subdivide(attributes, longest / part.detail.scale / EDGES_PER_BUMP);
176
+ const position = attributes[0].array;
177
+ ({ ids, count } = weld(position, eps));
178
+ const faces = faceNormals(position);
179
+ const normal = new Float64Array(count * 3);
180
+ ids.forEach((id, v) => {
181
+ const f = 3 * Math.floor(v / 3);
182
+ for (let k = 0; k < 3; k += 1) normal[3 * id + k] += faces[f + k];
183
+ });
184
+ // Noise per welded vertex. A mirrored copy reads it at −x, so it lumps as its source's reflection.
185
+ const seed = partSeed(part, part.detail.seed);
186
+ const random = seededRandom(seed);
187
+ const offset = [random() * 64, random() * 64, random() * 64];
188
+ const frequency = part.detail.scale / longest, flip = isMirrorCopy(part) ? -1 : 1;
189
+ const lift = new Float64Array(count).fill(NaN);
190
+ ids.forEach((id, v) => {
191
+ if (Number.isNaN(lift[id])) lift[id] = Math.abs(fbm3(flip * position[3 * v] * frequency + offset[0], position[3 * v + 1] * frequency + offset[1], position[3 * v + 2] * frequency + offset[2], seed));
192
+ });
193
+ let low = Infinity, high = -Infinity;
194
+ for (const value of lift) { low = Math.min(low, value); high = Math.max(high, value); }
195
+ // Rescaled to 0..1, so the highest lump reaches the declared size and the deepest stays on the shrunk base.
196
+ const span = high - low || 1;
197
+ ids.forEach((id, v) => {
198
+ const n = 3 * id, length = Math.hypot(normal[n], normal[n + 1], normal[n + 2]);
199
+ if (!(length > 0)) return;
200
+ const push = (relief * (lift[id] - low)) / span / length;
201
+ for (let k = 0; k < 3; k += 1) position[3 * v + k] += normal[n + k] * push;
202
+ });
203
+ const detailed = new THREE.BufferGeometry();
204
+ for (const { name, size, array } of attributes) detailed.setAttribute(name, new THREE.BufferAttribute(Float32Array.from(array), size));
205
+ detailed.setAttribute("normal", new THREE.BufferAttribute(creasedNormals(position, ids, count), 3));
206
+ return detailed;
207
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Geometry for the `loft` and `convex` sculpt shapes. The points come from the
3
+ * pure modules (sculpt-loft-rings.js, sculpt-shapes-loft-convex.js), so the
4
+ * silhouette compare hulls exactly what is drawn; three.js only skins them.
5
+ */
6
+
7
+ import * as THREE from "three";
8
+ import { ConvexGeometry } from "three/examples/jsm/geometries/ConvexGeometry.js";
9
+ import { LoftGeometry } from "three/examples/jsm/geometries/LoftGeometry.js";
10
+ import { loftRings } from "./sculpt-loft-rings.js";
11
+ import { convexPoints } from "./sculpt-shapes-loft-convex.js";
12
+
13
+ /** An unsmoothed loft keeps edges sharper than this crisp; a rounded corner's
14
+ * small steps still shade as one curve. */
15
+ const LOFT_CREASE = (40 * Math.PI) / 180;
16
+
17
+ /**
18
+ * Normals for a non-indexed mesh: each corner averages the area-weighted
19
+ * normals of the faces that share its exact position and turn less than
20
+ * `crease` from its own face. (BufferGeometryUtils.toCreasedNormals hashes
21
+ * positions to 1 cm, which merges the corners of a small gem.)
22
+ */
23
+ function creaseNormals(geometry, crease) {
24
+ const position = geometry.getAttribute("position");
25
+ const key = (i) => `${position.getX(i)},${position.getY(i)},${position.getZ(i)}`;
26
+ const a = new THREE.Vector3(), b = new THREE.Vector3(), c = new THREE.Vector3();
27
+ const faceNormals = [];
28
+ const facesAt = new Map();
29
+ for (let f = 0; f < position.count / 3; f += 1) {
30
+ a.fromBufferAttribute(position, 3 * f);
31
+ b.fromBufferAttribute(position, 3 * f + 1);
32
+ c.fromBufferAttribute(position, 3 * f + 2);
33
+ faceNormals.push(new THREE.Vector3().subVectors(c, b).cross(a.sub(b)));
34
+ for (let k = 0; k < 3; k += 1) {
35
+ const at = key(3 * f + k);
36
+ if (!facesAt.has(at)) facesAt.set(at, []);
37
+ facesAt.get(at).push(f);
38
+ }
39
+ }
40
+ const limit = Math.cos(crease);
41
+ const previous = geometry.getAttribute("normal");
42
+ const normals = new Float32Array(position.count * 3);
43
+ const own = new THREE.Vector3(), other = new THREE.Vector3(), sum = new THREE.Vector3();
44
+ for (let i = 0; i < position.count; i += 1) {
45
+ own.copy(faceNormals[Math.floor(i / 3)]).normalize();
46
+ sum.set(0, 0, 0);
47
+ for (const f of facesAt.get(key(i))) if (other.copy(faceNormals[f]).normalize().dot(own) >= limit) sum.add(faceNormals[f]);
48
+ // A zero-area face has no direction of its own: it keeps the one it had.
49
+ if (sum.lengthSq() === 0) sum.fromBufferAttribute(previous, i);
50
+ sum.normalize().toArray(normals, 3 * i);
51
+ }
52
+ geometry.setAttribute("normal", new THREE.BufferAttribute(normals, 3));
53
+ }
54
+
55
+ /** Box-projected uvs per triangle: each facet maps the two axes it faces
56
+ * most squarely, scaled by size, so a facet's texture is never smeared. */
57
+ function boxUvs(geometry, size) {
58
+ const position = geometry.getAttribute("position");
59
+ const uvs = new Float32Array(position.count * 2);
60
+ const a = new THREE.Vector3(), b = new THREE.Vector3(), c = new THREE.Vector3();
61
+ for (let f = 0; f < position.count / 3; f += 1) {
62
+ a.fromBufferAttribute(position, 3 * f);
63
+ b.fromBufferAttribute(position, 3 * f + 1);
64
+ c.fromBufferAttribute(position, 3 * f + 2);
65
+ const n = c.sub(b).cross(a.sub(b));
66
+ const facing = [Math.abs(n.x), Math.abs(n.y), Math.abs(n.z)];
67
+ const axis = facing.indexOf(Math.max(...facing));
68
+ const [u, v] = [[2, 1], [0, 2], [0, 1]][axis];
69
+ for (let k = 0; k < 3; k += 1) {
70
+ uvs[(3 * f + k) * 2] = position.getComponent(3 * f + k, u) / size[u] + 0.5;
71
+ uvs[(3 * f + k) * 2 + 1] = position.getComponent(3 * f + k, v) / size[v] + 0.5;
72
+ }
73
+ }
74
+ geometry.setAttribute("uv", new THREE.BufferAttribute(uvs, 2));
75
+ }
76
+
77
+ function loftGeometry(part) {
78
+ const sections = loftRings(part).rings.map((ring) => ring.map(([x, y, z]) => new THREE.Vector3(x, y, z)));
79
+ const geometry = new LoftGeometry(sections, { capStart: true, capEnd: true });
80
+ // The caps come last, one vertex per ring point each, and are level (every
81
+ // section is horizontal). Earcut leaves zero-area slivers between collinear
82
+ // samples, whose computed normals can be zero, so the caps are set outright.
83
+ const normal = geometry.getAttribute("normal"), first = normal.count - 2 * sections[0].length;
84
+ for (let i = first; i < normal.count; i += 1) normal.setXYZ(i, 0, i < first + sections[0].length ? -1 : 1, 0);
85
+ if (part.smooth) return geometry;
86
+ const faceted = geometry.toNonIndexed();
87
+ geometry.dispose();
88
+ creaseNormals(faceted, LOFT_CREASE);
89
+ return faceted;
90
+ }
91
+
92
+ /** ConvexGeometry gives flat facet normals; `facets: false` smooths them all. */
93
+ function convexGeometry(part) {
94
+ const geometry = new ConvexGeometry(convexPoints(part).map(([x, y, z]) => new THREE.Vector3(x, y, z)));
95
+ boxUvs(geometry, part.size);
96
+ if (!part.facets) creaseNormals(geometry, Math.PI);
97
+ return geometry;
98
+ }
99
+
100
+ export const LOFT_CONVEX_BUILDERS = Object.freeze({ loft: loftGeometry, convex: convexGeometry });