cozyclay 3.0.3 → 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 (246) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/INSTALL_FOR_AGENTS.md +146 -0
  3. package/README.md +74 -192
  4. package/THIRD_PARTY_NOTICES.md +0 -21
  5. package/bin/agent/agent-routes.mjs +29 -62
  6. package/bin/agent/agent-runner.mjs +5 -3
  7. package/bin/agent/main-agent.mjs +70 -0
  8. package/bin/agent/main-prompt.mjs +17 -0
  9. package/bin/agent/plan-tool.mjs +114 -0
  10. package/bin/agent/script-tool.mjs +2 -0
  11. package/bin/agent/sculpt-sketch.mjs +190 -0
  12. package/bin/agent/session-store.mjs +4 -3
  13. package/bin/agent/sketch-code.mjs +151 -0
  14. package/bin/agent/sketch-sandbox.mjs +102 -0
  15. package/bin/agent/studio-prompt.mjs +1 -1
  16. package/bin/agent/studio-task-routes.mjs +185 -0
  17. package/bin/agent/studio-tasks.mjs +156 -0
  18. package/bin/agent/studio-tools.mjs +3 -2
  19. package/bin/agent/studio-turn-transaction.mjs +76 -0
  20. package/bin/agent/turn-frames.mjs +4 -0
  21. package/bin/cozyclay.mjs +5 -7
  22. package/dist/app/index.html +7 -6
  23. package/dist/assets/TaskBoard-ngdqiu7u.js +1 -0
  24. package/dist/assets/app-BlwOpest.css +1 -0
  25. package/dist/assets/app-DmR7jBkz.js +5144 -0
  26. package/dist/assets/compile-worker-D0pKioa0.js +5 -0
  27. package/dist/assets/landing-y3EqDgSZ.js +1 -0
  28. package/dist/assets/modulepreload-polyfill-P2Xu9kJm.js +1 -0
  29. package/dist/assets/motion3-compile-BB3_1-EB.js +1 -0
  30. package/dist/assets/scene-asset-sync-CBx7XRti.css +1 -0
  31. package/dist/assets/scene-asset-sync-Cgdtr1Yw.js +13 -0
  32. package/dist/assets/task-board-ErQvfoaz.js +14 -0
  33. package/dist/assets/workflow-zQM-EKW1.js +179 -0
  34. package/dist/assets/x-bot-tpose-body-BusyR3eg.json +1 -0
  35. package/dist/assets/y-bot-tpose-body-Dy1VKChZ.json +1 -0
  36. package/dist/cozyclay-package.json +1 -1
  37. package/dist/greybox-to-video/index.html +2 -3
  38. package/dist/index.html +7 -57
  39. package/dist/llms.txt +2 -1
  40. package/dist/previs-software/index.html +2 -2
  41. package/dist/privacy/index.html +3 -7
  42. package/dist/seedance-camera-control/index.html +2 -2
  43. package/dist/sitemap.xml +5 -5
  44. package/dist/sw.js +5 -1
  45. package/dist/thumbs/blank-stage-dark.webp +0 -0
  46. package/dist/thumbs/blank-stage-light.webp +0 -0
  47. package/dist/workflow/index.html +5 -4
  48. package/mcp/mesh-file.mjs +3 -1
  49. package/mcp/server.mjs +3 -0
  50. package/mcp/tool-handlers.mjs +2 -2
  51. package/package.json +5 -3
  52. package/src/App.jsx +51 -282
  53. package/src/analytics.js +3 -148
  54. package/src/app-stage.jsx +1 -1
  55. package/src/asset-pane.jsx +5 -10
  56. package/src/auto-flat.js +10 -0
  57. package/src/bus-tasks.js +11 -5
  58. package/src/command-bus.js +1 -1
  59. package/src/commands/elements.js +4 -3
  60. package/src/commands/motion3-compile.js +51 -27
  61. package/src/commands/motion3.js +3 -3
  62. package/src/commands/objects.js +1 -1
  63. package/src/commands/project.js +1 -1
  64. package/src/commands/shot.js +16 -4
  65. package/src/controls.jsx +0 -11
  66. package/src/cuts.js +11 -0
  67. package/src/domains/cast.js +3 -2
  68. package/src/domains/motion.js +3 -10
  69. package/src/domains/playback.js +32 -0
  70. package/src/domains/scenes.js +3 -22
  71. package/src/domains/shots.js +5 -5
  72. package/src/dualview.jsx +11 -15
  73. package/src/hierarchy-panel.css +18 -4
  74. package/src/hierarchy-panel.jsx +12 -11
  75. package/src/kits/held.js +3 -2
  76. package/src/light-blockers.js +2 -2
  77. package/src/main.jsx +5 -0
  78. package/src/material-look.js +189 -0
  79. package/src/material-presets.js +78 -0
  80. package/src/material-textures.js +157 -0
  81. package/src/motion/ground.js +5 -14
  82. package/src/motion/playback-frame-store.js +79 -0
  83. package/src/motion/playback-probe.js +16 -0
  84. package/src/motion/playback-subject.js +7 -0
  85. package/src/motion/timeline.css +43 -5
  86. package/src/motion/timeline.jsx +4 -3
  87. package/src/motion3/body.js +37 -5
  88. package/src/motion3/carried.js +11 -3
  89. package/src/motion3/compile-job.js +1 -1
  90. package/src/motion3/compiler/compile.js +7 -1
  91. package/src/motion3/compiler/finger-close.js +137 -0
  92. package/src/motion3/compiler/limbs.js +18 -3
  93. package/src/motion3/compiler/pose.js +6 -0
  94. package/src/motion3/compiler/validate-objects.js +17 -2
  95. package/src/motion3/compiler/validate-parts.js +2 -1
  96. package/src/motion3/compiler/vocab.js +4 -4
  97. package/src/motion3/describe.js +2 -2
  98. package/src/motion3/fix/index.js +5 -3
  99. package/src/motion3/flight.js +38 -0
  100. package/src/motion3/free-motion.js +10 -7
  101. package/src/motion3/load.js +125 -0
  102. package/src/motion3/mass.js +23 -0
  103. package/src/motion3/object-keys.js +11 -6
  104. package/src/motion3/object-pose.js +11 -3
  105. package/src/motion3/pipeline.js +3 -0
  106. package/src/motion3/qa/explain.js +3 -1
  107. package/src/motion3/qa/grasp-checks.js +123 -0
  108. package/src/motion3/qa/hints-grasp.js +71 -0
  109. package/src/motion3/qa/index.js +3 -1
  110. package/src/motion3/qa/object-checks.js +7 -2
  111. package/src/motion3/qa/patch.js +2 -1
  112. package/src/motion3/qa/spec-checks.js +10 -1
  113. package/src/motion3/schema/fields.js +3 -3
  114. package/src/motion3/schema/topics.js +9 -8
  115. package/src/motion3/schema/types.js +4 -1
  116. package/src/motion3/seat.js +84 -0
  117. package/src/motion3/settle.js +11 -6
  118. package/src/motion3/shapes.js +70 -0
  119. package/src/motion3/skills/catch.js +71 -0
  120. package/src/motion3/skills/index.js +10 -3
  121. package/src/motion3/skills/info.js +13 -0
  122. package/src/motion3/skills/throw.js +72 -0
  123. package/src/object-catalog.jsx +0 -4
  124. package/src/object-gizmo.jsx +2 -1
  125. package/src/object-look.js +9 -5
  126. package/src/object-path.js +1 -1
  127. package/src/panels/ProjectPanel.jsx +42 -21
  128. package/src/panels/details.css +15 -4
  129. package/src/popover.js +37 -0
  130. package/src/posestudio.jsx +15 -10
  131. package/src/primitive-look.js +38 -0
  132. package/src/project-browser-art.jsx +19 -0
  133. package/src/project-browser.css +74 -121
  134. package/src/project-browser.jsx +36 -60
  135. package/src/props.jsx +24 -35
  136. package/src/render-passes.js +7 -1
  137. package/src/retired-kinds.js +37 -0
  138. package/src/scene-objects.js +5 -9
  139. package/src/scenes.js +4 -5
  140. package/src/sculpt-colours.js +65 -0
  141. package/src/sculpt-compare.js +13 -50
  142. package/src/sculpt-csg.js +112 -0
  143. package/src/sculpt-curve.js +123 -0
  144. package/src/sculpt-cutters.js +43 -0
  145. package/src/sculpt-detail.js +207 -0
  146. package/src/sculpt-geometry-loft-convex.js +100 -0
  147. package/src/sculpt-geometry-shapes.js +250 -0
  148. package/src/sculpt-geometry.js +33 -15
  149. package/src/sculpt-loft-rings.js +225 -0
  150. package/src/sculpt-noise.js +65 -0
  151. package/src/sculpt-part-fields.js +27 -0
  152. package/src/sculpt-recipe-read.js +65 -0
  153. package/src/sculpt-recipe.js +33 -75
  154. package/src/sculpt-run.js +1 -1
  155. package/src/sculpt-scatter-items.js +183 -0
  156. package/src/sculpt-scatter-shape.js +57 -0
  157. package/src/sculpt-scatter.js +124 -0
  158. package/src/sculpt-shapes-curved.js +211 -0
  159. package/src/sculpt-shapes-loft-convex.js +218 -0
  160. package/src/sculpt-shapes.js +138 -0
  161. package/src/sculpt-sketch-state.js +76 -0
  162. package/src/sculpt-sketch.jsx +124 -0
  163. package/src/sculpt-surface-fields.js +65 -0
  164. package/src/settings-menu.jsx +1 -20
  165. package/src/shell/BottomDock.jsx +9 -9
  166. package/src/shell/MenuBar.jsx +1 -9
  167. package/src/shell/PreferencesDialog.jsx +3 -4
  168. package/src/shell/ViewportToolbar.jsx +3 -3
  169. package/src/shell/add-camera-shot.js +11 -0
  170. package/src/shell/glass-regions.css +3 -2
  171. package/src/shell/glass.css +0 -5
  172. package/src/shell/lens-scrub-math.js +51 -0
  173. package/src/shell/lens-scrub.css +73 -0
  174. package/src/shell/lens-scrub.jsx +98 -0
  175. package/src/shell/mode.css +6 -5
  176. package/src/shell/preferences.css +1 -32
  177. package/src/shell/shell.css +18 -1
  178. package/src/shell/studio-shell-context.js +5 -0
  179. package/src/shot-frame.js +26 -0
  180. package/src/stage-canvas-rigs.jsx +7 -8
  181. package/src/stage-characters.jsx +26 -0
  182. package/src/stage-gizmo.js +43 -0
  183. package/src/stage-labels-ko.js +0 -6
  184. package/src/stage-startup-defaults.js +3 -9
  185. package/src/starter-scenes.js +5 -11
  186. package/src/studio-agent-commands.js +2 -2
  187. package/src/studio-agent-protocol.js +2 -7
  188. package/src/studio-app-binding.js +1 -1
  189. package/src/studio-elements.js +6 -2
  190. package/src/styles/themes.css +13 -0
  191. package/src/styles.css +6 -674
  192. package/src/text/text-object.jsx +1 -1
  193. package/src/ui.jsx +8 -16
  194. package/src/use-embed-bridge.js +3 -6
  195. package/src/use-render-activity.js +3 -1
  196. package/src/view-ghosts.jsx +5 -3
  197. package/src/viewport-visible-rect.js +37 -0
  198. package/src/workflow/AgentPanel.jsx +79 -46
  199. package/src/workflow/AgentPlanCard.jsx +41 -0
  200. package/src/workflow/AgentProgressCard.jsx +1 -1
  201. package/src/workflow/TaskBoard.jsx +102 -0
  202. package/src/workflow/TaskInline.jsx +114 -0
  203. package/src/workflow/agent-client.js +25 -36
  204. package/src/workflow/agent-panel.css +14 -1
  205. package/src/workflow/agent-plan.css +93 -0
  206. package/src/workflow/agent-plan.js +59 -0
  207. package/src/workflow/attachment-image.js +10 -3
  208. package/src/workflow/attachment-video.js +91 -0
  209. package/src/workflow/task-board.css +98 -0
  210. package/src/workflow/task-board.js +169 -0
  211. package/tools/check-eslint.mjs +2 -1
  212. package/tools/check-knip.mjs +2 -1
  213. package/tools/dev/pages/greybox-to-video.html +2 -3
  214. package/tools/dev/pages/previs-software.html +2 -2
  215. package/tools/dev/pages/privacy.html +3 -7
  216. package/tools/dev/pages/seedance-camera-control.html +2 -2
  217. package/tools/dev-full.mjs +11 -2
  218. package/tools/eslint-baseline.json +6 -6
  219. package/tools/knip-baseline.json +0 -11
  220. package/tools/loc-baseline.json +30 -31
  221. package/tools/metrics-snapshot.mjs +43 -5
  222. package/tools/render-start-thumbs.mjs +175 -0
  223. package/tools/run-tests.mjs +10 -17
  224. package/tools/sitemap.mjs +1 -1
  225. package/dist/assets/analytics-BibyE5ar.js +0 -6
  226. package/dist/assets/app-DhRJ-PBz.js +0 -5088
  227. package/dist/assets/app-f-fkV5uP.css +0 -1
  228. package/dist/assets/compile-worker-DqiA4AaN.js +0 -5
  229. package/dist/assets/first-shot-handoff-Qx7SKl38.js +0 -1
  230. package/dist/assets/landing-DYg87MDY.js +0 -1
  231. package/dist/assets/scene-asset-sync-DBYQBR0T.js +0 -20
  232. package/dist/assets/scene-asset-sync-_S8NQl5B.css +0 -1
  233. package/dist/assets/workflow-BSA22Ofr.js +0 -179
  234. package/dist/scenes/city-block.cclayproject +0 -1
  235. package/src/burn-in.js +0 -91
  236. package/src/camera-tutorial.jsx +0 -520
  237. package/src/facing-marks.js +0 -88
  238. package/src/first-shot-handoff.js +0 -44
  239. package/src/panels/motion.css +0 -197
  240. package/src/set-pieces.jsx +0 -183
  241. package/src/tutorial-analytics.js +0 -86
  242. package/src/tutorial-walk.js +0 -17
  243. package/src/use-camera-tutorial.js +0 -50
  244. package/src/use-semantic-state.js +0 -12
  245. package/tools/metrics/archive-traffic.mjs +0 -138
  246. package/tools/perf/ik-camera-drag.mjs +0 -138
@@ -0,0 +1,225 @@
1
+ /**
2
+ * The rings a `loft` sculpt part is skinned through. Pure: no three.js, so the
3
+ * silhouette compare samples exactly the surface sculpt-geometry draws.
4
+ *
5
+ * A section (sculpt-shapes-loft-convex.js reads them) is either an outline,
6
+ * { y, points: [[x, z]...] }, or a rounded rectangle, { y, w, d, x, z,
7
+ * roundness }; all of it fractions of the part's size. Every section becomes a
8
+ * ring of RING_POINTS samples that starts at its front, on the line through
9
+ * its centroid, and runs the same way round, so sections with different point
10
+ * counts skin without a twist.
11
+ *
12
+ * `smooth` turns an outline into a closed centripetal Catmull-Rom curve and
13
+ * joins sections with a monotone cubic in y. The curve is fitted back into its
14
+ * outline's own extents and the monotone cubic never leaves the range of the
15
+ * sections it joins, so no vertex can overshoot the declared size.
16
+ */
17
+
18
+ /** Samples per ring. */
19
+ const RING_POINTS = 48;
20
+ /** Rings per section-to-section span when smooth. */
21
+ const SPAN_STEPS = 6;
22
+ /** Curve samples per outline edge before resampling. */
23
+ const CURVE_STEPS = 8;
24
+ /** Segments per rounded corner of a rectangle section. */
25
+ const CORNER_STEPS = 6;
26
+ /** A corner that turns more than 30 degrees is kept as a sample (cosine). */
27
+ const SHARP_TURN = Math.cos(Math.PI / 6);
28
+
29
+ const lerp = (a, b, t) => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
30
+ const distance = (a, b) => Math.hypot(b[0] - a[0], b[1] - a[1]);
31
+
32
+ /** Twice the signed area in (x, z): positive runs from +x toward +z. */
33
+ export function signedArea(ring) {
34
+ let sum = 0;
35
+ for (let i = 0; i < ring.length; i += 1) {
36
+ const a = ring[i], b = ring[(i + 1) % ring.length];
37
+ sum += a[0] * b[1] - b[0] * a[1];
38
+ }
39
+ return sum;
40
+ }
41
+
42
+ function centroid(ring) {
43
+ let cx = 0, cz = 0;
44
+ for (let i = 0; i < ring.length; i += 1) {
45
+ const a = ring[i], b = ring[(i + 1) % ring.length];
46
+ const cross = a[0] * b[1] - b[0] * a[1];
47
+ cx += (a[0] + b[0]) * cross;
48
+ cz += (a[1] + b[1]) * cross;
49
+ }
50
+ const area = signedArea(ring);
51
+ return [cx / (3 * area), cz / (3 * area)];
52
+ }
53
+
54
+ /** Drops repeated points, including a last point that repeats the first. */
55
+ function dedupe(ring) {
56
+ const out = ring.filter((point, i) => i === 0 || distance(point, ring[i - 1]) > 1e-9);
57
+ while (out.length > 1 && distance(out[0], out.at(-1)) <= 1e-9) out.pop();
58
+ return out;
59
+ }
60
+
61
+ /** w by d, centred on (x, z), corners rounded by roundness × half the short side. */
62
+ function roundedRect({ w, d, x, z, roundness }) {
63
+ const hx = w / 2, hz = d / 2;
64
+ const r = roundness * Math.min(hx, hz);
65
+ const steps = r > 0 ? CORNER_STEPS : 0;
66
+ const points = [];
67
+ for (const [cx, cz, start] of [[hx - r, hz - r, 0], [r - hx, hz - r, 1], [r - hx, r - hz, 2], [hx - r, r - hz, 3]]) {
68
+ for (let i = 0; i <= steps; i += 1) {
69
+ const angle = (start + (steps ? i / steps : 0.5)) * (Math.PI / 2);
70
+ points.push([x + cx + r * Math.cos(angle), z + cz + r * Math.sin(angle)]);
71
+ }
72
+ }
73
+ return points;
74
+ }
75
+
76
+ /** A closed centripetal Catmull-Rom curve through `points` (three.js's formulation). */
77
+ function catmullRom(points) {
78
+ const n = points.length;
79
+ const out = [];
80
+ for (let i = 0; i < n; i += 1) {
81
+ const p0 = points[(i - 1 + n) % n], p1 = points[i], p2 = points[(i + 1) % n], p3 = points[(i + 2) % n];
82
+ let dt1 = Math.sqrt(distance(p1, p2));
83
+ let dt0 = Math.sqrt(distance(p0, p1));
84
+ let dt2 = Math.sqrt(distance(p2, p3));
85
+ if (dt1 < 1e-4) dt1 = 1;
86
+ if (dt0 < 1e-4) dt0 = dt1;
87
+ if (dt2 < 1e-4) dt2 = dt1;
88
+ const axis = (k) => {
89
+ const t1 = ((p1[k] - p0[k]) / dt0 - (p2[k] - p0[k]) / (dt0 + dt1) + (p2[k] - p1[k]) / dt1) * dt1;
90
+ const t2 = ((p2[k] - p1[k]) / dt1 - (p3[k] - p1[k]) / (dt1 + dt2) + (p3[k] - p2[k]) / dt2) * dt1;
91
+ return [p1[k], t1, -3 * p1[k] + 3 * p2[k] - 2 * t1 - t2, 2 * p1[k] - 2 * p2[k] + t1 + t2];
92
+ };
93
+ const cx = axis(0), cz = axis(1);
94
+ for (let s = 0; s < CURVE_STEPS; s += 1) {
95
+ const t = s / CURVE_STEPS;
96
+ out.push([cx[0] + t * (cx[1] + t * (cx[2] + t * cx[3])), cz[0] + t * (cz[1] + t * (cz[2] + t * cz[3]))]);
97
+ }
98
+ }
99
+ return out;
100
+ }
101
+
102
+ const extent = (ring, k) => [Math.min(...ring.map((p) => p[k])), Math.max(...ring.map((p) => p[k]))];
103
+
104
+ /** Maps the curve's box onto the outline's box. The curve passes through every
105
+ * outline point, so this only ever shrinks it: the extents written are drawn. */
106
+ function fitInto(curve, outline) {
107
+ const ranges = [0, 1].map((k) => [extent(curve, k), extent(outline, k)]);
108
+ return curve.map((point) => point.map((value, k) => {
109
+ const [[from0, from1], [to0, to1]] = ranges[k];
110
+ return from1 - from0 > 1e-12 ? to0 + ((value - from0) * (to1 - to0)) / (from1 - from0) : value;
111
+ }));
112
+ }
113
+
114
+ /** The ring rotated to start where the line x = centroid x leaves its front
115
+ * (+z). The rule is its own mirror image, so a mirrored loft pairs the same
116
+ * points across its sections and comes out as the exact reflection. */
117
+ function startAtFront(ring) {
118
+ const cx = centroid(ring)[0];
119
+ let best = null;
120
+ for (let i = 0; i < ring.length; i += 1) {
121
+ const a = ring[i], b = ring[(i + 1) % ring.length];
122
+ if ((a[0] - cx) * (b[0] - cx) > 0) continue;
123
+ const t = a[0] === b[0] ? 0 : (cx - a[0]) / (b[0] - a[0]);
124
+ const z = a[1] + (b[1] - a[1]) * t;
125
+ if (!best || z > best.z) best = { i, t, z };
126
+ }
127
+ if (!best) return ring;
128
+ const start = lerp(ring[best.i], ring[(best.i + 1) % ring.length], best.t);
129
+ return dedupe([start, ...ring.slice(best.i + 1), ...ring.slice(0, best.i + 1)]);
130
+ }
131
+
132
+ /** `count` samples spaced evenly by arc length, measured with the ring scaled
133
+ * to a unit box: every rectangle then has its corners at the same fractions
134
+ * whatever its aspect, so a square base meets a thin ridge side to side and
135
+ * the gable stays flat. Each sharp corner then takes the place of its nearest
136
+ * sample, so a box section keeps its edges. */
137
+ function resample(ring, count) {
138
+ const n = ring.length;
139
+ const span = [0, 1].map((k) => Math.max(1e-9, extent(ring, k)[1] - extent(ring, k)[0]));
140
+ const lengths = ring.map((point, i) => Math.hypot((ring[(i + 1) % n][0] - point[0]) / span[0], (ring[(i + 1) % n][1] - point[1]) / span[1]));
141
+ const total = lengths.reduce((sum, length) => sum + length, 0);
142
+ const out = [];
143
+ let edge = 0, walked = 0;
144
+ for (let j = 0; j < count; j += 1) {
145
+ const target = (j / count) * total;
146
+ while (edge < n - 1 && walked + lengths[edge] < target) walked += lengths[edge++];
147
+ out.push(lerp(ring[edge], ring[(edge + 1) % n], lengths[edge] > 0 ? Math.min(1, (target - walked) / lengths[edge]) : 0));
148
+ }
149
+ const taken = new Set();
150
+ walked = 0;
151
+ ring.forEach((point, i) => {
152
+ const before = ring[(i - 1 + n) % n], after = ring[(i + 1) % n];
153
+ const turn = ((point[0] - before[0]) * (after[0] - point[0]) + (point[1] - before[1]) * (after[1] - point[1])) / (distance(before, point) * distance(point, after));
154
+ const k = Math.round((walked / total) * count) % count;
155
+ if (turn < SHARP_TURN && !taken.has(k)) {
156
+ taken.add(k);
157
+ out[k] = point;
158
+ }
159
+ walked += lengths[i];
160
+ });
161
+ return out;
162
+ }
163
+
164
+ /** One section as RING_POINTS [x, z] fractions, wound the way LoftGeometry
165
+ * faces outward (negative signed area in x, z). */
166
+ function sectionRing(section, smooth) {
167
+ const outline = section.points ?? roundedRect(section);
168
+ let ring = dedupe(section.points && smooth ? fitInto(catmullRom(outline), outline) : outline);
169
+ if (signedArea(ring) > 0) ring = ring.slice().reverse();
170
+ return resample(startAtFront(ring), RING_POINTS);
171
+ }
172
+
173
+ /** Fritsch-Carlson tangents: Catmull-Rom slopes, limited so each span stays
174
+ * monotone and the curve never passes its two end values. */
175
+ function monotoneSlopes(ys, values) {
176
+ const n = ys.length;
177
+ const delta = values.slice(1).map((value, k) => (value - values[k]) / (ys[k + 1] - ys[k]));
178
+ const slopes = values.map((_, k) => (k === 0 ? delta[0] : k === n - 1 ? delta[n - 2] : delta[k - 1] * delta[k] <= 0 ? 0 : (delta[k - 1] + delta[k]) / 2));
179
+ delta.forEach((d, k) => {
180
+ if (d === 0) {
181
+ slopes[k] = 0;
182
+ slopes[k + 1] = 0;
183
+ return;
184
+ }
185
+ const a = slopes[k] / d, b = slopes[k + 1] / d, s = a * a + b * b;
186
+ if (s > 9) {
187
+ slopes[k] = (3 * a * d) / Math.sqrt(s);
188
+ slopes[k + 1] = (3 * b * d) / Math.sqrt(s);
189
+ }
190
+ });
191
+ return slopes;
192
+ }
193
+
194
+ function hermite(y0, y1, v0, v1, m0, m1, y) {
195
+ const h = y1 - y0, t = (y - y0) / h, t2 = t * t, t3 = t2 * t;
196
+ return (2 * t3 - 3 * t2 + 1) * v0 + (t3 - 2 * t2 + t) * h * m0 + (-2 * t3 + 3 * t2) * v1 + (t3 - t2) * h * m1;
197
+ }
198
+
199
+ /**
200
+ * The loft's rings, bottom to top, as [x, y, z] metres in the part's frame,
201
+ * plus `at`: the index of the ring each authored section became.
202
+ */
203
+ export function loftRings(part) {
204
+ const [sx, sy, sz] = part.size;
205
+ const ys = part.sections.map((section) => section.y);
206
+ const sections = part.sections.map((section) => sectionRing(section, part.smooth));
207
+ const levels = [];
208
+ const at = [];
209
+ if (!part.smooth) {
210
+ sections.forEach((ring, k) => { at.push(levels.length); levels.push({ y: ys[k], ring }); });
211
+ } else {
212
+ const slopes = sections[0].map((_, j) => [0, 1].map((axis) => monotoneSlopes(ys, sections.map((ring) => ring[j][axis]))));
213
+ for (let k = 0; k < ys.length; k += 1) {
214
+ at.push(levels.length);
215
+ levels.push({ y: ys[k], ring: sections[k] });
216
+ if (k === ys.length - 1) break;
217
+ for (let s = 1; s < SPAN_STEPS; s += 1) {
218
+ const y = ys[k] + ((ys[k + 1] - ys[k]) * s) / SPAN_STEPS;
219
+ levels.push({ y, ring: sections[k].map((_, j) => [0, 1].map((axis) => hermite(ys[k], ys[k + 1], sections[k][j][axis], sections[k + 1][j][axis], slopes[j][axis][k], slopes[j][axis][k + 1], y))) });
220
+ }
221
+ }
222
+ }
223
+ const rings = levels.map(({ y, ring }) => ring.map(([x, z]) => [x * sx, (y - 0.5) * sy, z * sz]));
224
+ return { rings, at };
225
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Seeded randomness for sculpt surfaces and scatters. The same recipe always
3
+ * draws the same lumps and the same blades (nothing here reads Math.random),
4
+ * and a mirrored copy is seeded by its source part's id, so it draws the
5
+ * source's pattern reflected instead of a new one. Pure: no three.js.
6
+ */
7
+
8
+ import { SCULPT_MIRROR_SUFFIX } from "./sculpt-recipe.js";
9
+
10
+ /** Whether a drawn part is the reflected copy of a `mirror: true` part. */
11
+ export const isMirrorCopy = (part) => part.id.endsWith(SCULPT_MIRROR_SUFFIX);
12
+
13
+ function mix(value) {
14
+ let h = Math.imul(value ^ (value >>> 16), 0x7feb352d);
15
+ h = Math.imul(h ^ (h >>> 15), 0x846ca68b);
16
+ return (h ^ (h >>> 16)) >>> 0;
17
+ }
18
+
19
+ /** A 32-bit seed from the part's source id (FNV-1a) and a recipe seed. */
20
+ export function partSeed(part, seed) {
21
+ const id = isMirrorCopy(part) ? part.id.slice(0, -SCULPT_MIRROR_SUFFIX.length) : part.id;
22
+ let hash = 0x811c9dc5;
23
+ for (let i = 0; i < id.length; i += 1) hash = Math.imul(hash ^ id.charCodeAt(i), 0x01000193);
24
+ return mix(hash ^ Math.imul(seed + 1, 0x9e3779b1));
25
+ }
26
+
27
+ /** mulberry32: a uniform [0, 1) stream from a 32-bit seed. */
28
+ export function seededRandom(seed) {
29
+ let state = seed >>> 0;
30
+ return () => {
31
+ state = (state + 0x6d2b79f5) >>> 0;
32
+ let t = Math.imul(state ^ (state >>> 15), state | 1);
33
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
34
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
35
+ };
36
+ }
37
+
38
+ const GRADIENTS = [1, 1, 0, -1, 1, 0, 1, -1, 0, -1, -1, 0, 1, 0, 1, -1, 0, 1, 1, 0, -1, -1, 0, -1, 0, 1, 1, 0, -1, 1, 0, 1, -1, 0, -1, -1];
39
+ const fade = (t) => t * t * t * (t * (t * 6 - 15) + 10);
40
+ const lerp = (a, b, t) => a + (b - a) * t;
41
+
42
+ /** Gradient (Perlin) noise, about −1..1, zero on the integer lattice. */
43
+ function noise3(x, y, z, seed) {
44
+ const ix = Math.floor(x), iy = Math.floor(y), iz = Math.floor(z);
45
+ const fx = x - ix, fy = y - iy, fz = z - iz;
46
+ const corner = (dx, dy, dz) => {
47
+ const g = (mix(Math.imul(ix + dx, 0x8da6b343) ^ Math.imul(iy + dy, 0xd8163841) ^ Math.imul(iz + dz, 0xcb1ab31f) ^ seed) % 12) * 3;
48
+ return GRADIENTS[g] * (fx - dx) + GRADIENTS[g + 1] * (fy - dy) + GRADIENTS[g + 2] * (fz - dz);
49
+ };
50
+ const u = fade(fx), v = fade(fy), w = fade(fz);
51
+ return lerp(
52
+ lerp(lerp(corner(0, 0, 0), corner(1, 0, 0), u), lerp(corner(0, 1, 0), corner(1, 1, 0), u), v),
53
+ lerp(lerp(corner(0, 0, 1), corner(1, 0, 1), u), lerp(corner(0, 1, 1), corner(1, 1, 1), u), v),
54
+ w,
55
+ );
56
+ }
57
+
58
+ /** Four octaves of noise3: broad lumps with smaller lumps on them, about −1..1. */
59
+ export function fbm3(x, y, z, seed) {
60
+ let sum = 0;
61
+ for (let octave = 0, amplitude = 1, frequency = 1; octave < 4; octave += 1, amplitude /= 2, frequency *= 2) {
62
+ sum += amplitude * noise3(x * frequency, y * frequency, z * frequency, seed + octave);
63
+ }
64
+ return sum / 1.875;
65
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Optional sculpt part fields that any shape takes (its look, its surface
3
+ * detail). Each entry is { key, read(raw, part, where) }: `read` runs only
4
+ * when raw[key] is present, refuses a bad value with RecipeError
5
+ * (sculpt-recipe-read.js) and writes the normalized value onto the part.
6
+ * An edit that sets a field to null clears it. Pure: no three.js.
7
+ */
8
+ import { SCULPT_OP_FIELD } from "./sculpt-cutters.js";
9
+ import { SURFACE_FIELDS } from "./sculpt-surface-fields.js";
10
+ import { RecipeError } from "./sculpt-recipe-read.js";
11
+ import { MATERIAL_FINISHES, MATERIAL_PRESETS } from "./material-presets.js";
12
+
13
+ /** A name from `names`; null clears the field (back to the default look),
14
+ * which is how an edit `{ set: id, material: null }` undoes a preset. */
15
+ const readName = (key, names) => (raw, part, where) => {
16
+ if (raw[key] === null) return;
17
+ if (!names.includes(raw[key])) throw new RecipeError(`${where}.${key}`, `must be one of ${names.join(", ")}; got ${JSON.stringify(raw[key])}`);
18
+ part[key] = raw[key];
19
+ };
20
+
21
+ export const SCULPT_PART_FIELDS = Object.freeze([
22
+ // Surface presets (material-presets.js); color stays the tint.
23
+ { key: "material", read: readName("material", MATERIAL_PRESETS) },
24
+ { key: "finish", read: readName("finish", MATERIAL_FINISHES) },
25
+ ...SURFACE_FIELDS,
26
+ SCULPT_OP_FIELD,
27
+ ]);
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Readers shared by the sculpt recipe modules (sculpt-recipe.js, sculpt-shapes.js,
3
+ * sculpt-part-fields.js). Each one refuses a bad value with the field's path
4
+ * instead of clamping it. Pure: no three.js.
5
+ */
6
+
7
+ export const tidy = (value) => {
8
+ const rounded = Math.round(value * 1e4) / 1e4;
9
+ return rounded === 0 ? 0 : rounded;
10
+ };
11
+ export const isFiniteNumber = (value) => typeof value === "number" && Number.isFinite(value);
12
+
13
+ export class RecipeError extends Error {
14
+ constructor(path, message) {
15
+ super(`${path}: ${message}`);
16
+ this.path = path;
17
+ }
18
+ }
19
+
20
+ export function readVector(value, path, fallback, check) {
21
+ if (value === undefined && fallback) return [...fallback];
22
+ if (!Array.isArray(value) || value.length !== 3 || !value.every(isFiniteNumber)) {
23
+ throw new RecipeError(path, `must be three numbers [x, y, z], got ${JSON.stringify(value)}`);
24
+ }
25
+ value.forEach((component, axis) => check(component, `${path}[${axis}]`));
26
+ return value.map(tidy);
27
+ }
28
+
29
+ export function readUnit(value, path, fallback, min, max) {
30
+ if (value === undefined) return fallback;
31
+ if (!isFiniteNumber(value) || value < min || value > max) {
32
+ throw new RecipeError(path, `must be a number from ${min} to ${max}, got ${JSON.stringify(value)}`);
33
+ }
34
+ return tidy(value);
35
+ }
36
+
37
+ export function readInteger(value, path, fallback, min, max) {
38
+ if (value === undefined && fallback !== undefined) return fallback;
39
+ if (!Number.isInteger(value) || value < min || value > max) {
40
+ throw new RecipeError(path, `must be a whole number from ${min} to ${max}, got ${JSON.stringify(value)}`);
41
+ }
42
+ return value;
43
+ }
44
+
45
+ /** A pattern seed (detail bumps, scatter layout): 0 unless the recipe picks another. */
46
+ export const readSeed = (value, path) => readInteger(value, path, 0, 0, 999999);
47
+
48
+ export function readChoice(value, path, fallback, choices) {
49
+ if (value === undefined && fallback !== undefined) return fallback;
50
+ if (!choices.includes(value)) throw new RecipeError(path, `must be one of ${choices.join(", ")}; got ${JSON.stringify(value)}`);
51
+ return value;
52
+ }
53
+
54
+ const HEX_SHORT = /^#?([0-9a-f]{3})$/i;
55
+ const HEX_LONG = /^#?([0-9a-f]{6})$/i;
56
+
57
+ /** `#rgb` or `#rrggbb` (the `#` optional), as lower-case `#rrggbb`. */
58
+ export function readHexColor(value, path) {
59
+ const text = typeof value === "string" ? value.trim() : "";
60
+ const short = HEX_SHORT.exec(text);
61
+ if (short) return `#${short[1].toLowerCase().replace(/./g, (digit) => digit + digit)}`;
62
+ const long = HEX_LONG.exec(text);
63
+ if (long) return `#${long[1].toLowerCase()}`;
64
+ throw new RecipeError(path, `colour must be #rgb or #rrggbb, got ${JSON.stringify(value)}`);
65
+ }
@@ -15,8 +15,12 @@
15
15
  * Contract: docs/sculpt-from-image.md.
16
16
  */
17
17
 
18
+ import { RecipeError, readHexColor, readVector, tidy } from "./sculpt-recipe-read.js";
19
+ import { SCULPT_SHAPES, SCULPT_SHAPE_KEYS, SCULPT_SHAPE_SPECS } from "./sculpt-shapes.js";
20
+ import { SCULPT_PART_FIELDS } from "./sculpt-part-fields.js";
21
+ import { checkSculptCutters } from "./sculpt-cutters.js";
22
+
18
23
  const SCULPT_RECIPE_VERSION = 1;
19
- const SCULPT_SHAPES = Object.freeze(["blob", "box", "cylinder", "torus", "frame"]);
20
24
  export const SCULPT_LIMITS = Object.freeze({
21
25
  /** after mirrors are expanded */
22
26
  maxParts: 48,
@@ -24,6 +28,8 @@ export const SCULPT_LIMITS = Object.freeze({
24
28
  maxExtent: 20,
25
29
  maxOffset: 20,
26
30
  maxDepth: 8,
31
+ /** scatter items across the whole recipe, after mirrors */
32
+ maxScatterItems: 12000,
27
33
  });
28
34
  const SCULPT_DEFAULT_COLOR = "#c2c6c8";
29
35
  /** The suffix a mirrored copy's id gets. `.` is outside the id alphabet, so a
@@ -32,61 +38,13 @@ export const SCULPT_MIRROR_SUFFIX = ".mirror";
32
38
  const MIN_HEIGHT = 0.05;
33
39
 
34
40
  const ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,39}$/;
35
- const HEX_SHORT = /^#?([0-9a-f]{3})$/i;
36
- const HEX_LONG = /^#?([0-9a-f]{6})$/i;
37
- const PART_KEYS = new Set(["id", "shape", "size", "position", "rotation", "color", "parent", "mirror", "roundness", "taper", "border"]);
38
- const SHAPE_KEYS = {
39
- blob: new Set(["roundness", "taper"]),
40
- box: new Set(["roundness"]),
41
- cylinder: new Set(["taper"]),
42
- torus: new Set(),
43
- frame: new Set(["border"]),
44
- };
45
- const DEFAULT_ROUNDNESS = { blob: 0.5, box: 0.1 };
46
-
47
- const tidy = (value) => {
48
- const rounded = Math.round(value * 1e4) / 1e4;
49
- return rounded === 0 ? 0 : rounded;
50
- };
51
- const isFiniteNumber = (value) => typeof value === "number" && Number.isFinite(value);
41
+ const PART_KEYS = new Set(["id", "shape", "size", "position", "rotation", "color", "parent", "mirror", ...SCULPT_SHAPE_KEYS, ...SCULPT_PART_FIELDS.map((field) => field.key)]);
52
42
  const wrapDegrees = (deg) => {
53
43
  const wrapped = ((((deg + 180) % 360) + 360) % 360) - 180;
54
44
  return wrapped === -180 ? 180 : wrapped;
55
45
  };
56
46
 
57
- class RecipeError extends Error {
58
- constructor(path, message) {
59
- super(`${path}: ${message}`);
60
- this.path = path;
61
- }
62
- }
63
-
64
- function readColor(value, path) {
65
- if (value === undefined) return SCULPT_DEFAULT_COLOR;
66
- const text = typeof value === "string" ? value.trim() : "";
67
- const short = HEX_SHORT.exec(text);
68
- if (short) return `#${short[1].toLowerCase().replace(/./g, (digit) => digit + digit)}`;
69
- const long = HEX_LONG.exec(text);
70
- if (long) return `#${long[1].toLowerCase()}`;
71
- throw new RecipeError(path, `colour must be #rgb or #rrggbb, got ${JSON.stringify(value)}`);
72
- }
73
-
74
- function readVector(value, path, fallback, check) {
75
- if (value === undefined && fallback) return [...fallback];
76
- if (!Array.isArray(value) || value.length !== 3 || !value.every(isFiniteNumber)) {
77
- throw new RecipeError(path, `must be three numbers [x, y, z], got ${JSON.stringify(value)}`);
78
- }
79
- value.forEach((component, axis) => check(component, `${path}[${axis}]`));
80
- return value.map(tidy);
81
- }
82
-
83
- function readUnit(value, path, fallback, min, max) {
84
- if (value === undefined) return fallback;
85
- if (!isFiniteNumber(value) || value < min || value > max) {
86
- throw new RecipeError(path, `must be a number from ${min} to ${max}, got ${JSON.stringify(value)}`);
87
- }
88
- return tidy(value);
89
- }
47
+ const readColor = (value, path) => (value === undefined ? SCULPT_DEFAULT_COLOR : readHexColor(value, path));
90
48
 
91
49
  function normalizePart(raw, index) {
92
50
  const at = `parts[${index}]`;
@@ -101,8 +59,9 @@ function normalizePart(raw, index) {
101
59
  if (!SCULPT_SHAPES.includes(raw.shape)) {
102
60
  throw new RecipeError(`${where}.shape`, `must be one of ${SCULPT_SHAPES.join(", ")}; got ${JSON.stringify(raw.shape)}`);
103
61
  }
104
- for (const key of ["roundness", "taper", "border"]) {
105
- if (raw[key] !== undefined && !SHAPE_KEYS[raw.shape].has(key)) {
62
+ const spec = SCULPT_SHAPE_SPECS[raw.shape];
63
+ for (const key of SCULPT_SHAPE_KEYS) {
64
+ if (raw[key] !== undefined && !spec.keys.includes(key)) {
106
65
  throw new RecipeError(`${where}.${key}`, `does not apply to a ${raw.shape}`);
107
66
  }
108
67
  }
@@ -129,20 +88,8 @@ function normalizePart(raw, index) {
129
88
  };
130
89
  if (raw.parent !== undefined) part.parent = raw.parent;
131
90
  if (raw.mirror === true) part.mirror = true;
132
- if (SHAPE_KEYS[raw.shape].has("roundness")) part.roundness = readUnit(raw.roundness, `${where}.roundness`, DEFAULT_ROUNDNESS[raw.shape], 0, 1);
133
- if (SHAPE_KEYS[raw.shape].has("taper")) part.taper = readUnit(raw.taper, `${where}.taper`, 0, -1, 1);
134
- if (raw.shape === "frame") {
135
- const limit = Math.min(size[0], size[1]) / 2;
136
- const fallback = tidy(Math.min(size[0], size[1]) * 0.15);
137
- const border = raw.border === undefined ? fallback : raw.border;
138
- if (!isFiniteNumber(border) || border <= 0 || border >= limit) {
139
- throw new RecipeError(`${where}.border`, `must be above 0 and below half the frame's smaller side (${tidy(limit)} m), got ${JSON.stringify(raw.border)}`);
140
- }
141
- part.border = tidy(border);
142
- }
143
- if (raw.shape === "torus" && size[2] >= Math.min(size[0], size[1])) {
144
- throw new RecipeError(`${where}.size`, "a torus's tube (size[2]) must be thinner than its outer diameter");
145
- }
91
+ spec.read(raw, part, where);
92
+ for (const field of SCULPT_PART_FIELDS) if (raw[field.key] !== undefined) field.read(raw, part, where);
146
93
  return part;
147
94
  }
148
95
 
@@ -196,10 +143,16 @@ export function normalizeSculptRecipe(input) {
196
143
  seen.add(part.id);
197
144
  }
198
145
  partDepths(parts);
146
+ checkSculptCutters(parts);
199
147
  const recipe = { version: SCULPT_RECIPE_VERSION, parts };
200
- const count = expandSculptParts(recipe).length;
201
- if (count > SCULPT_LIMITS.maxParts) {
202
- throw new RecipeError("recipe.parts", `expands to ${count} parts with mirrors; the limit is ${SCULPT_LIMITS.maxParts}`);
148
+ const drawn = expandSculptParts(recipe);
149
+ if (drawn.length > SCULPT_LIMITS.maxParts) {
150
+ throw new RecipeError("recipe.parts", `expands to ${drawn.length} parts with mirrors; the limit is ${SCULPT_LIMITS.maxParts}`);
151
+ }
152
+ // A scatter is one part but many drawn items; the frame rate pays per item.
153
+ const items = drawn.reduce((sum, part) => sum + (part.shape === "scatter" ? part.count : 0), 0);
154
+ if (items > SCULPT_LIMITS.maxScatterItems) {
155
+ throw new RecipeError("recipe.parts", `scatter parts draw ${items} items with mirrors; the limit is ${SCULPT_LIMITS.maxScatterItems}`);
203
156
  }
204
157
  return { ok: true, recipe };
205
158
  } catch (error) {
@@ -210,8 +163,9 @@ export function normalizeSculptRecipe(input) {
210
163
 
211
164
  /**
212
165
  * A recipe edited part by part instead of rewritten. `edits` are applied in
213
- * order: `{ set: id, ...fields }` merges fields into one part (`parent: null`
214
- * and `mirror: false` clear), `{ add: part }` appends, `{ remove: id }` drops a
166
+ * order: `{ set: id, ...fields }` merges fields into one part (`parent: null`,
167
+ * `mirror: false` and null for an optional field such as `detail` clear),
168
+ * `{ add: part }` appends, `{ remove: id }` drops a
215
169
  * part and every part below it. The result is normalized as a whole, so an edit
216
170
  * that leaves the recipe undrawable is refused with the usual path.
217
171
  * Returns `{ ok: true, recipe, changed: [ids] }` or `{ ok: false, error, path }`.
@@ -249,8 +203,9 @@ export function patchSculptRecipe(recipe, edits) {
249
203
  const next = { ...parts[found], ...fields };
250
204
  if (fields.parent === null) delete next.parent;
251
205
  if (fields.mirror === false) delete next.mirror;
206
+ for (const field of SCULPT_PART_FIELDS) if (fields[field.key] === null) delete next[field.key];
252
207
  // Shape-specific defaults belong to the old shape, not the new one.
253
- if ("shape" in fields) for (const key of ["roundness", "taper", "border"]) if (!(key in fields)) delete next[key];
208
+ if ("shape" in fields) for (const key of SCULPT_SHAPE_KEYS) if (!(key in fields)) delete next[key];
254
209
  parts[found] = next;
255
210
  changed.add(id);
256
211
  }
@@ -298,7 +253,8 @@ function applyMatrix(m, [x, y, z]) {
298
253
 
299
254
  /** A reflection across x = 0 of the parent frame keeps the part's shape and
300
255
  * negates the lateral axis only: position.x flips, and the yaw and roll turn
301
- * the other way. All five shapes are symmetric about their own x, so no
256
+ * the other way. Shapes symmetric about their own x need nothing more; the
257
+ * others reflect their own data (SCULPT_SHAPE_SPECS[shape].mirror), so no
302
258
  * negative scale (and no winding flip) is needed. */
303
259
  function mirroredPart(part, parentId) {
304
260
  const copy = {
@@ -308,6 +264,7 @@ function mirroredPart(part, parentId) {
308
264
  rotation: [part.rotation[0], tidy(-part.rotation[1]), tidy(-part.rotation[2])],
309
265
  };
310
266
  delete copy.mirror;
267
+ SCULPT_SHAPE_SPECS[copy.shape].mirror?.(copy);
311
268
  if (parentId !== undefined) copy.parent = parentId;
312
269
  return copy;
313
270
  }
@@ -352,10 +309,11 @@ function localCorners(part) {
352
309
  return corners;
353
310
  }
354
311
 
312
+ /** A cutter (op) is not drawn and only takes from its parent, so it never widens the box. */
355
313
  export function sculptBounds(recipe) {
356
314
  const min = [Infinity, Infinity, Infinity];
357
315
  const max = [-Infinity, -Infinity, -Infinity];
358
- for (const part of expandSculptParts(recipe)) {
316
+ for (const part of expandSculptParts(recipe).filter((drawn) => !drawn.op)) {
359
317
  for (const corner of localCorners(part)) {
360
318
  const point = applyMatrix(part.matrix, corner);
361
319
  for (let axis = 0; axis < 3; axis += 1) {
package/src/sculpt-run.js CHANGED
@@ -49,7 +49,7 @@ function dataUrlFromSheet(sheet) {
49
49
  const referenceArg = reference => (reference?.assetId ? { assetId: reference.assetId } : { dataUrl: reference?.dataUrl });
50
50
 
51
51
  // The recipe rules the agent needs to get object.sculpt right the first time (sculpt-recipe.js).
52
- const RECIPE_RULES = "Recipe: { \"version\": 1, \"parts\": [{ \"id\", \"shape\", \"size\": [x,y,z] metres, \"position\": [x,y,z] centre, \"rotation\": [x,y,z] degrees, \"color\": \"#rrggbb\", \"parent\"?, \"mirror\"?, \"roundness\"? 0-1, \"taper\"? }] }. id: lowercase a-z, 0-9 and -, starting with a letter or digit, at most 40 characters. shape is exactly one of blob, box, cylinder, torus, frame (no cone, sphere or other names). Up to 48 parts after mirrors. Floor is y=0 (feet touch it), front is +z, the subject's left is +x, mirror: true adds the reflection across x=0. roundness works backwards from its name: 0 is a smooth ellipsoid and 1 is a rounded box. roundness belongs only on blob and box, taper only on blob: leave both off cylinder, torus and frame. Keep roundness at 0.2 or below on every organic part (body, belly, head, face, limbs); go above 0.3 only for hard-edged objects. Use box only for hard-edged things.";
52
+ const RECIPE_RULES = "Recipe: { \"version\": 1, \"parts\": [{ \"id\", \"shape\", \"size\": [x,y,z] metres, \"position\": [x,y,z] centre, \"rotation\": [x,y,z] degrees, \"color\": \"#rrggbb\", \"parent\"?, \"mirror\"?, \"roundness\"? 0-1, \"taper\"? }] }. id: lowercase a-z, 0-9 and -, starting with a letter or digit, at most 40 characters. shape is exactly one of blob, box, cylinder, torus, frame, lathe, tube, extrude, loft, convex, scatter (no cone, sphere or other names): lathe { \"profile\": [[r 0-1, y 0-1], ...] } for turned forms (pot, bottle, bowl), tube { \"path\": [[x,y,z] each -0.5..0.5 of size], \"thickness\" m } for ropes, tails, handles and limbs that bend, extrude { \"outline\": [[x,y] -0.5..0.5] } for flat cut shapes (ears, fins, leaves, signs), loft { \"sections\": [{ \"y\", \"w\", \"d\" }, ...] } for bodies whose cross-section changes, convex { \"seed\" } for rocks. Any part may add \"detail\": { \"amount\": 0-1 } for a lumpy surface, \"gradient\": { \"color\" } for a two-tone blend, or \"op\": \"subtract\" to cut its parent (a mouth, a hollow). Up to 48 parts after mirrors. Floor is y=0 (feet touch it), front is +z, the subject's left is +x, mirror: true adds the reflection across x=0. roundness works backwards from its name: 0 is a smooth ellipsoid and 1 is a rounded box. roundness belongs only on blob, box and extrude, taper only on blob, cylinder and tube: leave both off the other shapes. Keep roundness at 0.2 or below on every organic part (body, belly, head, face, limbs); go above 0.3 only for hard-edged objects. Use box only for hard-edged things.";
53
53
 
54
54
  // A compact score for the next turn: what is missing where, and which parts stick out.
55
55
  function reportText(report) {