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