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.
- package/CHANGELOG.md +65 -0
- package/bin/agent/agent-routes.mjs +12 -11
- package/bin/agent/main-agent.mjs +21 -7
- package/bin/agent/main-prompt.mjs +7 -6
- package/bin/agent/plan-tool.mjs +114 -0
- package/bin/agent/script-tool.mjs +4 -1
- package/bin/agent/sculpt-sketch.mjs +190 -0
- package/bin/agent/session-store.mjs +2 -1
- package/bin/agent/sketch-code.mjs +151 -0
- package/bin/agent/sketch-sandbox.mjs +102 -0
- package/bin/agent/studio-prompt.mjs +1 -1
- package/bin/agent/studio-rebase.mjs +5 -4
- package/bin/agent/studio-task-routes.mjs +1 -1
- package/bin/agent/studio-tasks.mjs +116 -25
- package/bin/agent/studio-tools.mjs +3 -2
- package/bin/agent/turn-frames.mjs +4 -0
- package/bin/cozyclay.mjs +4 -2
- package/bin/live-endpoint.mjs +2 -0
- package/dist/app/index.html +6 -6
- package/dist/assets/{TaskBoard-DdDXMgZm.js → TaskBoard-GR8xZm5c.js} +1 -1
- package/dist/assets/{app-BlwOpest.css → app-Bhe0TpGz.css} +1 -1
- package/dist/assets/app-C4QTcDcb.js +5144 -0
- package/dist/assets/compile-worker-oy_P9HHh.js +6 -0
- package/dist/assets/motion3-compile-dgGhrvPK.js +1 -0
- package/dist/assets/scene-asset-sync-BEjjhNEo.css +1 -0
- package/dist/assets/scene-asset-sync-qoxcdrhj.js +13 -0
- package/dist/assets/task-board-BqReHzOi.js +15 -0
- package/dist/assets/workflow-C87p6E1E.js +179 -0
- package/dist/cozyclay-package.json +1 -1
- package/dist/index.html +1 -1
- package/dist/sitemap.xml +1 -1
- package/dist/workflow/index.html +4 -4
- package/mcp/README.md +1 -1
- package/mcp/live-hub.mjs +5 -3
- package/mcp/tool-handlers.mjs +7 -7
- package/package.json +3 -1
- package/src/App.jsx +44 -66
- package/src/bus-task-scopes.js +59 -11
- package/src/bus-task-targets.js +35 -9
- package/src/bus-tasks.js +23 -10
- package/src/command-bus.js +7 -4
- package/src/commands/ai.js +1 -1
- package/src/commands/elements.js +4 -3
- package/src/commands/export.js +1 -1
- package/src/commands/motion3.js +27 -7
- package/src/commands/objects.js +1 -1
- package/src/export-workspace.js +244 -0
- package/src/keyframe-pack.js +5 -1
- package/src/live-control.js +14 -7
- package/src/material-look.js +189 -0
- package/src/material-presets.js +78 -0
- package/src/material-textures.js +157 -0
- package/src/motion3/compile-job.js +15 -3
- package/src/motion3/compiler/channels.js +74 -24
- package/src/motion3/compiler/compile.js +2 -1
- package/src/motion3/compiler/idle.js +2 -0
- package/src/motion3/compiler/overlap.js +180 -0
- package/src/motion3/compiler/pose.js +6 -4
- package/src/motion3/compiler/validate-common.js +1 -0
- package/src/motion3/compiler/validate-parts.js +5 -1
- package/src/motion3/compiler/validate.js +30 -10
- package/src/motion3/correct.js +236 -0
- package/src/motion3/docs.js +8 -6
- package/src/motion3/face-lean.js +31 -0
- package/src/motion3/fix/index.js +5 -1
- package/src/motion3/fix/reach-body.js +172 -0
- package/src/motion3/hint-check.js +44 -0
- package/src/motion3/json-patch.js +120 -12
- package/src/motion3/lenient.js +46 -4
- package/src/motion3/object-keys.js +17 -5
- package/src/motion3/pipeline.js +46 -9
- package/src/motion3/qa/arm-checks.js +96 -0
- package/src/motion3/qa/asked.js +113 -0
- package/src/motion3/qa/goals.js +37 -8
- package/src/motion3/qa/hints-goal.js +6 -5
- package/src/motion3/qa/index.js +5 -2
- package/src/motion3/qa/key-checks.js +4 -3
- package/src/motion3/qa/motion-life.js +243 -0
- package/src/motion3/qa/spec-checks.js +15 -9
- package/src/motion3/qa/verdict.js +50 -0
- package/src/motion3/refs.js +38 -14
- package/src/motion3/repair-watch.js +132 -0
- package/src/motion3/repair.js +52 -19
- package/src/motion3/report.js +50 -9
- package/src/motion3/schema/topics.js +6 -4
- package/src/motion3/schema/types.js +1 -1
- package/src/motion3/skills/basic.js +15 -8
- package/src/motion3/skills/index.js +52 -18
- package/src/motion3/skills/info.js +1 -1
- package/src/motion3/skills/stance.js +6 -0
- package/src/motion3/skills/walk-fit.js +170 -0
- package/src/object-look.js +9 -5
- package/src/panels/PoseImagePanel.jsx +3 -2
- package/src/panels/SculptPanel.jsx +2 -2
- package/src/primitive-look.js +38 -0
- package/src/props.jsx +20 -28
- package/src/render-passes.js +7 -1
- package/src/result-modal.jsx +3 -3
- package/src/scene-objects.js +2 -2
- package/src/sculpt-colours.js +65 -0
- package/src/sculpt-compare.js +13 -50
- package/src/sculpt-csg.js +112 -0
- package/src/sculpt-curve.js +123 -0
- package/src/sculpt-cutters.js +43 -0
- package/src/sculpt-detail.js +207 -0
- package/src/sculpt-geometry-loft-convex.js +100 -0
- package/src/sculpt-geometry-shapes.js +250 -0
- package/src/sculpt-geometry.js +33 -15
- package/src/sculpt-loft-rings.js +225 -0
- package/src/sculpt-noise.js +65 -0
- package/src/sculpt-part-fields.js +27 -0
- package/src/sculpt-recipe-read.js +65 -0
- package/src/sculpt-recipe.js +33 -75
- package/src/sculpt-run.js +1 -1
- package/src/sculpt-scatter-items.js +183 -0
- package/src/sculpt-scatter-shape.js +57 -0
- package/src/sculpt-scatter.js +124 -0
- package/src/sculpt-shapes-curved.js +211 -0
- package/src/sculpt-shapes-loft-convex.js +218 -0
- package/src/sculpt-shapes.js +138 -0
- package/src/sculpt-sketch-state.js +76 -0
- package/src/sculpt-sketch.jsx +124 -0
- package/src/sculpt-surface-fields.js +65 -0
- package/src/shell/ExportFolderPreference.jsx +103 -0
- package/src/shell/PreferencesDialog.jsx +3 -166
- package/src/shell/StatusBar.jsx +1 -1
- package/src/shell/preference-controls.jsx +114 -0
- package/src/shell/preference-pages.jsx +72 -0
- package/src/shell/preferences.css +46 -0
- package/src/shot-prompt.js +29 -9
- package/src/studio-actions.js +2 -2
- package/src/studio-agent-motion.js +19 -9
- package/src/studio-agent-protocol.js +1 -1
- package/src/studio-app-binding.js +3 -2
- package/src/studio-elements.js +5 -1
- package/src/workflow/AgentPanel.jsx +6 -6
- package/src/workflow/AgentPlanCard.jsx +41 -0
- package/src/workflow/CozySceneNode.jsx +1 -1
- package/src/workflow/WorkflowBuilder.jsx +2 -2
- package/src/workflow/agent-client.js +14 -11
- package/src/workflow/agent-plan.css +93 -0
- package/src/workflow/agent-plan.js +59 -0
- package/src/workflow/keyframe-pack-request.js +3 -10
- package/src/workflow/task-board.css +1 -1
- package/src/workflow/task-board.js +7 -5
- package/tools/loc-baseline.json +1 -2
- package/tools/run-tests.mjs +8 -8
- package/dist/assets/app-B2BpfCl2.js +0 -5088
- package/dist/assets/compile-worker-D0pKioa0.js +0 -5
- package/dist/assets/motion3-compile-B887n77J.js +0 -1
- package/dist/assets/scene-asset-sync-COGOlF5T.css +0 -1
- package/dist/assets/scene-asset-sync-CQzEdsqu.js +0 -13
- package/dist/assets/task-board-BKaM7d-9.js +0 -14
- package/dist/assets/workflow-B8Dhzexj.js +0 -179
|
@@ -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
|
+
}
|
package/src/sculpt-recipe.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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
|
-
|
|
133
|
-
if (
|
|
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
|
|
201
|
-
if (
|
|
202
|
-
throw new RecipeError("recipe.parts", `expands to ${
|
|
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
|
-
*
|
|
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
|
|
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.
|
|
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
|
|
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
|
+
}
|