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.
- package/CHANGELOG.md +93 -0
- package/INSTALL_FOR_AGENTS.md +146 -0
- package/README.md +74 -192
- package/THIRD_PARTY_NOTICES.md +0 -21
- package/bin/agent/agent-routes.mjs +29 -62
- package/bin/agent/agent-runner.mjs +5 -3
- package/bin/agent/main-agent.mjs +70 -0
- package/bin/agent/main-prompt.mjs +17 -0
- package/bin/agent/plan-tool.mjs +114 -0
- package/bin/agent/script-tool.mjs +2 -0
- package/bin/agent/sculpt-sketch.mjs +190 -0
- package/bin/agent/session-store.mjs +4 -3
- 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-task-routes.mjs +185 -0
- package/bin/agent/studio-tasks.mjs +156 -0
- package/bin/agent/studio-tools.mjs +3 -2
- package/bin/agent/studio-turn-transaction.mjs +76 -0
- package/bin/agent/turn-frames.mjs +4 -0
- package/bin/cozyclay.mjs +5 -7
- package/dist/app/index.html +7 -6
- package/dist/assets/TaskBoard-ngdqiu7u.js +1 -0
- package/dist/assets/app-BlwOpest.css +1 -0
- package/dist/assets/app-DmR7jBkz.js +5144 -0
- package/dist/assets/compile-worker-D0pKioa0.js +5 -0
- package/dist/assets/landing-y3EqDgSZ.js +1 -0
- package/dist/assets/modulepreload-polyfill-P2Xu9kJm.js +1 -0
- package/dist/assets/motion3-compile-BB3_1-EB.js +1 -0
- package/dist/assets/scene-asset-sync-CBx7XRti.css +1 -0
- package/dist/assets/scene-asset-sync-Cgdtr1Yw.js +13 -0
- package/dist/assets/task-board-ErQvfoaz.js +14 -0
- package/dist/assets/workflow-zQM-EKW1.js +179 -0
- package/dist/assets/x-bot-tpose-body-BusyR3eg.json +1 -0
- package/dist/assets/y-bot-tpose-body-Dy1VKChZ.json +1 -0
- package/dist/cozyclay-package.json +1 -1
- package/dist/greybox-to-video/index.html +2 -3
- package/dist/index.html +7 -57
- package/dist/llms.txt +2 -1
- package/dist/previs-software/index.html +2 -2
- package/dist/privacy/index.html +3 -7
- package/dist/seedance-camera-control/index.html +2 -2
- package/dist/sitemap.xml +5 -5
- package/dist/sw.js +5 -1
- package/dist/thumbs/blank-stage-dark.webp +0 -0
- package/dist/thumbs/blank-stage-light.webp +0 -0
- package/dist/workflow/index.html +5 -4
- package/mcp/mesh-file.mjs +3 -1
- package/mcp/server.mjs +3 -0
- package/mcp/tool-handlers.mjs +2 -2
- package/package.json +5 -3
- package/src/App.jsx +51 -282
- package/src/analytics.js +3 -148
- package/src/app-stage.jsx +1 -1
- package/src/asset-pane.jsx +5 -10
- package/src/auto-flat.js +10 -0
- package/src/bus-tasks.js +11 -5
- package/src/command-bus.js +1 -1
- package/src/commands/elements.js +4 -3
- package/src/commands/motion3-compile.js +51 -27
- package/src/commands/motion3.js +3 -3
- package/src/commands/objects.js +1 -1
- package/src/commands/project.js +1 -1
- package/src/commands/shot.js +16 -4
- package/src/controls.jsx +0 -11
- package/src/cuts.js +11 -0
- package/src/domains/cast.js +3 -2
- package/src/domains/motion.js +3 -10
- package/src/domains/playback.js +32 -0
- package/src/domains/scenes.js +3 -22
- package/src/domains/shots.js +5 -5
- package/src/dualview.jsx +11 -15
- package/src/hierarchy-panel.css +18 -4
- package/src/hierarchy-panel.jsx +12 -11
- package/src/kits/held.js +3 -2
- package/src/light-blockers.js +2 -2
- package/src/main.jsx +5 -0
- package/src/material-look.js +189 -0
- package/src/material-presets.js +78 -0
- package/src/material-textures.js +157 -0
- package/src/motion/ground.js +5 -14
- package/src/motion/playback-frame-store.js +79 -0
- package/src/motion/playback-probe.js +16 -0
- package/src/motion/playback-subject.js +7 -0
- package/src/motion/timeline.css +43 -5
- package/src/motion/timeline.jsx +4 -3
- package/src/motion3/body.js +37 -5
- package/src/motion3/carried.js +11 -3
- package/src/motion3/compile-job.js +1 -1
- package/src/motion3/compiler/compile.js +7 -1
- package/src/motion3/compiler/finger-close.js +137 -0
- package/src/motion3/compiler/limbs.js +18 -3
- package/src/motion3/compiler/pose.js +6 -0
- package/src/motion3/compiler/validate-objects.js +17 -2
- package/src/motion3/compiler/validate-parts.js +2 -1
- package/src/motion3/compiler/vocab.js +4 -4
- package/src/motion3/describe.js +2 -2
- package/src/motion3/fix/index.js +5 -3
- package/src/motion3/flight.js +38 -0
- package/src/motion3/free-motion.js +10 -7
- package/src/motion3/load.js +125 -0
- package/src/motion3/mass.js +23 -0
- package/src/motion3/object-keys.js +11 -6
- package/src/motion3/object-pose.js +11 -3
- package/src/motion3/pipeline.js +3 -0
- package/src/motion3/qa/explain.js +3 -1
- package/src/motion3/qa/grasp-checks.js +123 -0
- package/src/motion3/qa/hints-grasp.js +71 -0
- package/src/motion3/qa/index.js +3 -1
- package/src/motion3/qa/object-checks.js +7 -2
- package/src/motion3/qa/patch.js +2 -1
- package/src/motion3/qa/spec-checks.js +10 -1
- package/src/motion3/schema/fields.js +3 -3
- package/src/motion3/schema/topics.js +9 -8
- package/src/motion3/schema/types.js +4 -1
- package/src/motion3/seat.js +84 -0
- package/src/motion3/settle.js +11 -6
- package/src/motion3/shapes.js +70 -0
- package/src/motion3/skills/catch.js +71 -0
- package/src/motion3/skills/index.js +10 -3
- package/src/motion3/skills/info.js +13 -0
- package/src/motion3/skills/throw.js +72 -0
- package/src/object-catalog.jsx +0 -4
- package/src/object-gizmo.jsx +2 -1
- package/src/object-look.js +9 -5
- package/src/object-path.js +1 -1
- package/src/panels/ProjectPanel.jsx +42 -21
- package/src/panels/details.css +15 -4
- package/src/popover.js +37 -0
- package/src/posestudio.jsx +15 -10
- package/src/primitive-look.js +38 -0
- package/src/project-browser-art.jsx +19 -0
- package/src/project-browser.css +74 -121
- package/src/project-browser.jsx +36 -60
- package/src/props.jsx +24 -35
- package/src/render-passes.js +7 -1
- package/src/retired-kinds.js +37 -0
- package/src/scene-objects.js +5 -9
- package/src/scenes.js +4 -5
- 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/settings-menu.jsx +1 -20
- package/src/shell/BottomDock.jsx +9 -9
- package/src/shell/MenuBar.jsx +1 -9
- package/src/shell/PreferencesDialog.jsx +3 -4
- package/src/shell/ViewportToolbar.jsx +3 -3
- package/src/shell/add-camera-shot.js +11 -0
- package/src/shell/glass-regions.css +3 -2
- package/src/shell/glass.css +0 -5
- package/src/shell/lens-scrub-math.js +51 -0
- package/src/shell/lens-scrub.css +73 -0
- package/src/shell/lens-scrub.jsx +98 -0
- package/src/shell/mode.css +6 -5
- package/src/shell/preferences.css +1 -32
- package/src/shell/shell.css +18 -1
- package/src/shell/studio-shell-context.js +5 -0
- package/src/shot-frame.js +26 -0
- package/src/stage-canvas-rigs.jsx +7 -8
- package/src/stage-characters.jsx +26 -0
- package/src/stage-gizmo.js +43 -0
- package/src/stage-labels-ko.js +0 -6
- package/src/stage-startup-defaults.js +3 -9
- package/src/starter-scenes.js +5 -11
- package/src/studio-agent-commands.js +2 -2
- package/src/studio-agent-protocol.js +2 -7
- package/src/studio-app-binding.js +1 -1
- package/src/studio-elements.js +6 -2
- package/src/styles/themes.css +13 -0
- package/src/styles.css +6 -674
- package/src/text/text-object.jsx +1 -1
- package/src/ui.jsx +8 -16
- package/src/use-embed-bridge.js +3 -6
- package/src/use-render-activity.js +3 -1
- package/src/view-ghosts.jsx +5 -3
- package/src/viewport-visible-rect.js +37 -0
- package/src/workflow/AgentPanel.jsx +79 -46
- package/src/workflow/AgentPlanCard.jsx +41 -0
- package/src/workflow/AgentProgressCard.jsx +1 -1
- package/src/workflow/TaskBoard.jsx +102 -0
- package/src/workflow/TaskInline.jsx +114 -0
- package/src/workflow/agent-client.js +25 -36
- package/src/workflow/agent-panel.css +14 -1
- package/src/workflow/agent-plan.css +93 -0
- package/src/workflow/agent-plan.js +59 -0
- package/src/workflow/attachment-image.js +10 -3
- package/src/workflow/attachment-video.js +91 -0
- package/src/workflow/task-board.css +98 -0
- package/src/workflow/task-board.js +169 -0
- package/tools/check-eslint.mjs +2 -1
- package/tools/check-knip.mjs +2 -1
- package/tools/dev/pages/greybox-to-video.html +2 -3
- package/tools/dev/pages/previs-software.html +2 -2
- package/tools/dev/pages/privacy.html +3 -7
- package/tools/dev/pages/seedance-camera-control.html +2 -2
- package/tools/dev-full.mjs +11 -2
- package/tools/eslint-baseline.json +6 -6
- package/tools/knip-baseline.json +0 -11
- package/tools/loc-baseline.json +30 -31
- package/tools/metrics-snapshot.mjs +43 -5
- package/tools/render-start-thumbs.mjs +175 -0
- package/tools/run-tests.mjs +10 -17
- package/tools/sitemap.mjs +1 -1
- package/dist/assets/analytics-BibyE5ar.js +0 -6
- package/dist/assets/app-DhRJ-PBz.js +0 -5088
- package/dist/assets/app-f-fkV5uP.css +0 -1
- package/dist/assets/compile-worker-DqiA4AaN.js +0 -5
- package/dist/assets/first-shot-handoff-Qx7SKl38.js +0 -1
- package/dist/assets/landing-DYg87MDY.js +0 -1
- package/dist/assets/scene-asset-sync-DBYQBR0T.js +0 -20
- package/dist/assets/scene-asset-sync-_S8NQl5B.css +0 -1
- package/dist/assets/workflow-BSA22Ofr.js +0 -179
- package/dist/scenes/city-block.cclayproject +0 -1
- package/src/burn-in.js +0 -91
- package/src/camera-tutorial.jsx +0 -520
- package/src/facing-marks.js +0 -88
- package/src/first-shot-handoff.js +0 -44
- package/src/panels/motion.css +0 -197
- package/src/set-pieces.jsx +0 -183
- package/src/tutorial-analytics.js +0 -86
- package/src/tutorial-walk.js +0 -17
- package/src/use-camera-tutorial.js +0 -50
- package/src/use-semantic-state.js +0 -12
- package/tools/metrics/archive-traffic.mjs +0 -138
- package/tools/perf/ik-camera-drag.mjs +0 -138
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recipe rules for the `loft` and `convex` sculpt shapes (the contract is the
|
|
3
|
+
* header of sculpt-shapes.js). Pure: no three.js.
|
|
4
|
+
*
|
|
5
|
+
* loft a skin through 2-12 horizontal sections stacked up the part's own
|
|
6
|
+
* y, for things whose section changes along their length: a boat
|
|
7
|
+
* hull, a car body, a squared bottle, a loaf. Lay it on its side with
|
|
8
|
+
* rotation for a hull or a car. Rings: sculpt-loft-rings.js.
|
|
9
|
+
* convex the convex hull of 4-48 points, flat facets by default, for rocks,
|
|
10
|
+
* gems and crystals; or `seed` alone for a random rock that fills its
|
|
11
|
+
* size, so "a boulder" is one line.
|
|
12
|
+
*
|
|
13
|
+
* Every coordinate is a fraction of size (-0.5..0.5, loft y 0..1), so a part
|
|
14
|
+
* never draws outside its declared box and resizing never rewrites points.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { RecipeError, isFiniteNumber, readUnit, tidy } from "./sculpt-recipe-read.js";
|
|
18
|
+
import { loftRings, signedArea } from "./sculpt-loft-rings.js";
|
|
19
|
+
|
|
20
|
+
const LOFT_SECTIONS = [2, 12];
|
|
21
|
+
const OUTLINE_POINTS = [3, 24];
|
|
22
|
+
const CONVEX_POINTS = [4, 48];
|
|
23
|
+
const FACET_COUNTS = [6, 40];
|
|
24
|
+
const DEFAULT_FACETS = 16;
|
|
25
|
+
const SECTION_ROUNDNESS = 0.25;
|
|
26
|
+
/** A hull thinner than this (as a fraction of size) in any direction is flat. */
|
|
27
|
+
const MIN_DEPTH = 0.02;
|
|
28
|
+
const MAX_SEED = 2147483647;
|
|
29
|
+
|
|
30
|
+
function readNumber(value, path, min, max) {
|
|
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
|
+
function readBoolean(value, path, fallback) {
|
|
38
|
+
if (value === undefined) return fallback;
|
|
39
|
+
if (typeof value !== "boolean") throw new RecipeError(path, "must be true or false");
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function readList(value, path, [min, max], what) {
|
|
44
|
+
if (!Array.isArray(value) || value.length < min || value.length > max) {
|
|
45
|
+
throw new RecipeError(path, `must be ${min}-${max} ${what}, got ${Array.isArray(value) ? value.length : JSON.stringify(value)}`);
|
|
46
|
+
}
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function readPoint(value, path, axes) {
|
|
51
|
+
if (!Array.isArray(value) || value.length !== axes.length || !value.every(isFiniteNumber)) {
|
|
52
|
+
throw new RecipeError(path, `must be [${axes.join(", ")}] fractions of size, got ${JSON.stringify(value)}`);
|
|
53
|
+
}
|
|
54
|
+
return value.map((component, k) => readNumber(component, `${path}[${k}]`, -0.5, 0.5));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const cross2 = (o, a, b) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
|
|
58
|
+
|
|
59
|
+
/** An outline that crosses itself has no inside to skin. */
|
|
60
|
+
function crossingEdges(points) {
|
|
61
|
+
const n = points.length;
|
|
62
|
+
for (let i = 0; i < n; i += 1) {
|
|
63
|
+
for (let j = i + 2; j < n; j += 1) {
|
|
64
|
+
if (i === 0 && j === n - 1) continue;
|
|
65
|
+
const [a, b, c, d] = [points[i], points[(i + 1) % n], points[j], points[(j + 1) % n]];
|
|
66
|
+
if (cross2(a, b, c) * cross2(a, b, d) < 0 && cross2(c, d, a) * cross2(c, d, b) < 0) return [i, j];
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function readSection(raw, path) {
|
|
73
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new RecipeError(path, "must be { y, points } or { y, w, d }");
|
|
74
|
+
const outline = raw.points !== undefined;
|
|
75
|
+
const allowed = outline ? ["y", "points"] : ["y", "w", "d", "x", "z", "roundness"];
|
|
76
|
+
for (const key of Object.keys(raw)) {
|
|
77
|
+
if (!allowed.includes(key)) throw new RecipeError(`${path}.${key}`, `unknown key (a section is { y, points } or { y, w, d, x, z, roundness })`);
|
|
78
|
+
}
|
|
79
|
+
const y = readNumber(raw.y, `${path}.y`, 0, 1);
|
|
80
|
+
if (outline) {
|
|
81
|
+
const points = readList(raw.points, `${path}.points`, OUTLINE_POINTS, "[x, z] points").map((point, i) => readPoint(point, `${path}.points[${i}]`, ["x", "z"]));
|
|
82
|
+
const crossing = crossingEdges(points);
|
|
83
|
+
if (crossing) throw new RecipeError(`${path}.points`, `edges ${crossing[0]} and ${crossing[1]} cross; list the outline in order around the section`);
|
|
84
|
+
if (Math.abs(signedArea(points)) < 1e-6) throw new RecipeError(`${path}.points`, "the outline encloses no area");
|
|
85
|
+
return { y, points };
|
|
86
|
+
}
|
|
87
|
+
const section = { y, w: readNumber(raw.w, `${path}.w`, 0.01, 1), d: readNumber(raw.d, `${path}.d`, 0.01, 1) };
|
|
88
|
+
for (const [key, span] of [["x", "w"], ["z", "d"]]) {
|
|
89
|
+
section[key] = readUnit(raw[key], `${path}.${key}`, 0, -0.5, 0.5);
|
|
90
|
+
if (Math.abs(section[key]) + section[span] / 2 > 0.5 + 1e-9) {
|
|
91
|
+
throw new RecipeError(`${path}.${key}`, `the section reaches past the part's side: |${key}| + ${span}/2 must be at most 0.5`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
section.roundness = readUnit(raw.roundness, `${path}.roundness`, SECTION_ROUNDNESS, 0, 1);
|
|
95
|
+
return section;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function readSections(value, where) {
|
|
99
|
+
const path = `${where}.sections`;
|
|
100
|
+
const sections = readList(value, path, LOFT_SECTIONS, "sections").map((section, k) => readSection(section, `${path}[${k}]`));
|
|
101
|
+
sections.forEach((section, k) => {
|
|
102
|
+
if (k > 0 && section.y <= sections[k - 1].y) throw new RecipeError(`${path}[${k}].y`, "must be above the section before it (sections run bottom to top)");
|
|
103
|
+
});
|
|
104
|
+
if (sections[0].y !== 0 || sections.at(-1).y !== 1) {
|
|
105
|
+
throw new RecipeError(path, "the first section must sit at y 0 and the last at y 1 (size[1] is the loft's full height)");
|
|
106
|
+
}
|
|
107
|
+
return sections;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Points [x, y, z] that span no volume would hull into a flat, broken mesh. */
|
|
111
|
+
function hullDepth(points) {
|
|
112
|
+
const sub = (a, b) => a.map((value, k) => value - b[k]);
|
|
113
|
+
const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
|
|
114
|
+
const length = (a) => Math.hypot(...a);
|
|
115
|
+
const spread = (k) => Math.max(...points.map((p) => p[k])) - Math.min(...points.map((p) => p[k]));
|
|
116
|
+
const axis = [1, 2].reduce((best, k) => (spread(k) > spread(best) ? k : best), 0);
|
|
117
|
+
const a = points.reduce((best, p) => (p[axis] < best[axis] ? p : best));
|
|
118
|
+
const b = points.reduce((best, p) => (p[axis] > best[axis] ? p : best));
|
|
119
|
+
const line = sub(b, a);
|
|
120
|
+
const off = (p) => length(cross(line, sub(p, a))) / (length(line) || 1);
|
|
121
|
+
const c = points.reduce((best, p) => (off(p) > off(best) ? p : best));
|
|
122
|
+
const normal = cross(line, sub(c, a));
|
|
123
|
+
const plane = (p) => Math.abs(sub(p, a).reduce((sum, value, k) => sum + value * normal[k], 0)) / (length(normal) || 1);
|
|
124
|
+
return Math.min(length(line), off(c), Math.max(...points.map(plane)));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** mulberry32: a small seeded PRNG, so a seed is the same rock everywhere. */
|
|
128
|
+
function seededRandom(seed) {
|
|
129
|
+
let state = seed >>> 0;
|
|
130
|
+
return () => {
|
|
131
|
+
state = (state + 0x6d2b79f5) >>> 0;
|
|
132
|
+
let t = Math.imul(state ^ (state >>> 15), state | 1);
|
|
133
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
134
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* A rock of about `facetCount` triangles: a jittered Fibonacci sphere of
|
|
140
|
+
* facetCount / 2 + 2 points (a hull of n points on a sphere has 2n - 4
|
|
141
|
+
* triangles), tipped to a random pole, pulled in by up to 12%, then stretched
|
|
142
|
+
* per axis so it touches all six sides of its box: size is the rock's extent.
|
|
143
|
+
*/
|
|
144
|
+
function rockPoints(seed, facetCount) {
|
|
145
|
+
const random = seededRandom(seed);
|
|
146
|
+
const count = Math.floor(facetCount / 2) + 2;
|
|
147
|
+
const tilt = Math.acos(1 - 2 * random()), spin = 2 * Math.PI * random();
|
|
148
|
+
const golden = Math.PI * (3 - Math.sqrt(5));
|
|
149
|
+
const raw = [];
|
|
150
|
+
for (let i = 0; i < count; i += 1) {
|
|
151
|
+
const y = Math.max(-1, Math.min(1, 1 - (2 * (i + 0.5)) / count + (random() - 0.5) / count));
|
|
152
|
+
const angle = i * golden + (random() - 0.5) * golden * 0.5;
|
|
153
|
+
const ring = Math.sqrt(1 - y * y), radius = 0.88 + 0.12 * random();
|
|
154
|
+
const [x0, y0, z0] = [Math.cos(angle) * ring, y, Math.sin(angle) * ring];
|
|
155
|
+
const [y1, z1] = [y0 * Math.cos(tilt) - z0 * Math.sin(tilt), y0 * Math.sin(tilt) + z0 * Math.cos(tilt)];
|
|
156
|
+
raw.push([(x0 * Math.cos(spin) + z1 * Math.sin(spin)) * radius, y1 * radius, (z1 * Math.cos(spin) - x0 * Math.sin(spin)) * radius]);
|
|
157
|
+
}
|
|
158
|
+
const ranges = [0, 1, 2].map((k) => [Math.min(...raw.map((p) => p[k])), Math.max(...raw.map((p) => p[k]))]);
|
|
159
|
+
return raw.map((point) => point.map((value, k) => tidy((value - (ranges[k][0] + ranges[k][1]) / 2) / (ranges[k][1] - ranges[k][0]))));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const convexFractions = (part) => part.points ?? rockPoints(part.seed, part.facetCount);
|
|
163
|
+
|
|
164
|
+
/** The hull's points in metres, in the part's frame. */
|
|
165
|
+
export function convexPoints(part) {
|
|
166
|
+
return convexFractions(part).map((point) => point.map((value, k) => value * part.size[k]));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export const LOFT_CONVEX_SPECS = Object.freeze({
|
|
170
|
+
loft: {
|
|
171
|
+
keys: ["sections", "smooth"],
|
|
172
|
+
read(raw, part, where) {
|
|
173
|
+
part.sections = readSections(raw.sections, where);
|
|
174
|
+
part.smooth = readBoolean(raw.smooth, `${where}.smooth`, true);
|
|
175
|
+
},
|
|
176
|
+
// x flips, and an outline is listed backwards so it keeps its winding.
|
|
177
|
+
mirror(copy) {
|
|
178
|
+
copy.sections = copy.sections.map((section) => (section.points
|
|
179
|
+
? { y: section.y, points: section.points.map(([x, z]) => [tidy(-x), z]).reverse() }
|
|
180
|
+
: { ...section, x: tidy(-section.x) }));
|
|
181
|
+
},
|
|
182
|
+
// One hull per span, so a waist between two wide sections stays a waist.
|
|
183
|
+
samples(part) {
|
|
184
|
+
const { rings, at } = loftRings(part);
|
|
185
|
+
return at.slice(1).map((end, k) => rings.slice(at[k], end + 1).flat());
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
convex: {
|
|
189
|
+
keys: ["points", "seed", "facetCount", "facets"],
|
|
190
|
+
read(raw, part, where) {
|
|
191
|
+
// null reads as absent, so an edit can swap a seeded rock for points;
|
|
192
|
+
// seed: null also clears the facetCount that belonged to it.
|
|
193
|
+
const has = (key) => raw[key] !== undefined && raw[key] !== null;
|
|
194
|
+
if (has("points") === has("seed")) throw new RecipeError(`${where}.points`, "give either points: [[x, y, z]...] or seed (a random rock), not both");
|
|
195
|
+
if (has("points")) {
|
|
196
|
+
if (has("facetCount") && raw.seed !== null) throw new RecipeError(`${where}.facetCount`, "applies to a seeded rock; points already set the facets");
|
|
197
|
+
part.points = readList(raw.points, `${where}.points`, CONVEX_POINTS, "[x, y, z] points").map((point, i) => readPoint(point, `${where}.points[${i}]`, ["x", "y", "z"]));
|
|
198
|
+
if (hullDepth(part.points) < MIN_DEPTH) throw new RecipeError(`${where}.points`, "the points lie (nearly) on one line or plane; a hull needs depth in all three directions");
|
|
199
|
+
} else {
|
|
200
|
+
if (!Number.isInteger(raw.seed) || raw.seed < 0 || raw.seed > MAX_SEED) throw new RecipeError(`${where}.seed`, `must be a whole number from 0 to ${MAX_SEED}, got ${JSON.stringify(raw.seed)}`);
|
|
201
|
+
part.seed = raw.seed;
|
|
202
|
+
const facetCount = has("facetCount") ? raw.facetCount : DEFAULT_FACETS;
|
|
203
|
+
if (!Number.isInteger(facetCount) || facetCount < FACET_COUNTS[0] || facetCount > FACET_COUNTS[1]) {
|
|
204
|
+
throw new RecipeError(`${where}.facetCount`, `must be a whole number from ${FACET_COUNTS[0]} to ${FACET_COUNTS[1]}, got ${JSON.stringify(raw.facetCount)}`);
|
|
205
|
+
}
|
|
206
|
+
part.facetCount = facetCount;
|
|
207
|
+
}
|
|
208
|
+
part.facets = readBoolean(raw.facets, `${where}.facets`, true);
|
|
209
|
+
},
|
|
210
|
+
// A seeded rock is not symmetric either: its copy carries the reflected points.
|
|
211
|
+
mirror(copy) {
|
|
212
|
+
copy.points = convexFractions(copy).map(([x, y, z]) => [tidy(-x), y, z]);
|
|
213
|
+
delete copy.seed;
|
|
214
|
+
delete copy.facetCount;
|
|
215
|
+
},
|
|
216
|
+
samples: (part) => [convexPoints(part)],
|
|
217
|
+
},
|
|
218
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-shape rules for sculpt recipes: the keys a shape takes and how they are
|
|
3
|
+
* read, how a mirrored copy reflects the shape's own data, and the local
|
|
4
|
+
* points the silhouette compare hulls. Pure: no three.js.
|
|
5
|
+
*
|
|
6
|
+
* A new shape is one entry here plus a builder in sculpt-geometry.js (a
|
|
7
|
+
* geometry builder, or a mesh builder for an instanced shape like scatter).
|
|
8
|
+
* The shapes that follow points (lathe, tube, extrude) live in sculpt-shapes-curved.js.
|
|
9
|
+
*
|
|
10
|
+
* keys shape-only part keys; any other shape refuses them
|
|
11
|
+
* read (raw, part, where) fills the normalized part's shape fields
|
|
12
|
+
* mirror (copy) optional: reflect shape data across the part's own x = 0,
|
|
13
|
+
* for shapes that are not symmetric about their own x
|
|
14
|
+
* samples (part) local sample points, as groups; each group is hulled
|
|
15
|
+
* separately, so a bent shape can stay concave in silhouette
|
|
16
|
+
* hole (part) optional: the local front-face outline of a see-through hole
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { RecipeError, isFiniteNumber, readUnit, tidy } from "./sculpt-recipe-read.js";
|
|
20
|
+
import { LOFT_CONVEX_SPECS } from "./sculpt-shapes-loft-convex.js";
|
|
21
|
+
import { CURVED_SHAPE_SPECS } from "./sculpt-shapes-curved.js";
|
|
22
|
+
import { SCATTER_SHAPE } from "./sculpt-scatter-shape.js";
|
|
23
|
+
|
|
24
|
+
/** How far a full taper narrows a blob's end; sculpt-geometry.js draws the same. */
|
|
25
|
+
export const BLOB_TAPER = 0.6;
|
|
26
|
+
|
|
27
|
+
const half = (part) => part.size.map((value) => value / 2);
|
|
28
|
+
|
|
29
|
+
/** Sample rings from bottom (v = −1) to top (v = 1), `point(v, t)` per ring. */
|
|
30
|
+
function rings(point) {
|
|
31
|
+
const points = [];
|
|
32
|
+
for (let i = 0; i <= 16; i += 1) {
|
|
33
|
+
const v = -1 + (2 * i) / 16;
|
|
34
|
+
for (let j = 0; j < 24; j += 1) points.push(...point(v, (j / 24) * 2 * Math.PI));
|
|
35
|
+
}
|
|
36
|
+
return points;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function blobSamples(part) {
|
|
40
|
+
const [sx, sy, sz] = half(part);
|
|
41
|
+
const p = 2 + 6 * part.roundness;
|
|
42
|
+
const taper = part.taper;
|
|
43
|
+
return [rings((v, t) => {
|
|
44
|
+
const k = taper > 0 ? 1 - taper * BLOB_TAPER * ((v + 1) / 2) : taper < 0 ? 1 + taper * BLOB_TAPER * ((1 - v) / 2) : 1;
|
|
45
|
+
const ring = Math.max(0, 1 - Math.abs(v) ** p) ** (1 / p);
|
|
46
|
+
const c = Math.cos(t), s = Math.sin(t);
|
|
47
|
+
const norm = (Math.abs(c) ** p + Math.abs(s) ** p) ** (1 / p);
|
|
48
|
+
return [[(c / norm) * sx * ring * k, v * sy, (s / norm) * sz * ring * k]];
|
|
49
|
+
})];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function boxSamples(part) {
|
|
53
|
+
const [sx, sy, sz] = half(part);
|
|
54
|
+
const points = [];
|
|
55
|
+
for (const x of [-sx, sx]) for (const y of [-sy, sy]) for (const z of [-sz, sz]) points.push([x, y, z]);
|
|
56
|
+
return [points];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function cylinderSamples(part) {
|
|
60
|
+
const [sx, sy, sz] = half(part);
|
|
61
|
+
return [rings((v, t) => {
|
|
62
|
+
const r = 1 - Math.max(0, part.taper) * ((v + 1) / 2) - Math.max(0, -part.taper) * ((1 - v) / 2);
|
|
63
|
+
return [[Math.cos(t) * sx * r, v * sy, Math.sin(t) * sz * r]];
|
|
64
|
+
})];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function ringSamples(part) {
|
|
68
|
+
const [sx, sy, sz] = half(part);
|
|
69
|
+
return [rings((v, t) => {
|
|
70
|
+
const points = [[Math.cos(t) * sx, Math.sin(t) * sy, v * sz]];
|
|
71
|
+
if (part.shape === "frame") points.push([Math.sign(Math.cos(t)) * sx, Math.sign(Math.sin(t)) * sy, v * sz]);
|
|
72
|
+
return points;
|
|
73
|
+
})];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The opening of a torus or frame, as the four corners of its front face. */
|
|
77
|
+
function ringHole(part) {
|
|
78
|
+
const inset = part.shape === "frame" ? part.border : part.size[2];
|
|
79
|
+
const ix = part.size[0] / 2 - inset, iy = part.size[1] / 2 - inset;
|
|
80
|
+
if (ix <= 0 || iy <= 0) return null;
|
|
81
|
+
const z = -part.size[2] / 2;
|
|
82
|
+
return [[-ix, -iy, z], [ix, -iy, z], [ix, iy, z], [-ix, iy, z]];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const SCULPT_SHAPE_SPECS = Object.freeze({
|
|
86
|
+
blob: {
|
|
87
|
+
keys: ["roundness", "taper"],
|
|
88
|
+
read(raw, part, where) {
|
|
89
|
+
part.roundness = readUnit(raw.roundness, `${where}.roundness`, 0.5, 0, 1);
|
|
90
|
+
part.taper = readUnit(raw.taper, `${where}.taper`, 0, -1, 1);
|
|
91
|
+
},
|
|
92
|
+
samples: blobSamples,
|
|
93
|
+
},
|
|
94
|
+
box: {
|
|
95
|
+
keys: ["roundness"],
|
|
96
|
+
read(raw, part, where) {
|
|
97
|
+
part.roundness = readUnit(raw.roundness, `${where}.roundness`, 0.1, 0, 1);
|
|
98
|
+
},
|
|
99
|
+
samples: boxSamples,
|
|
100
|
+
},
|
|
101
|
+
cylinder: {
|
|
102
|
+
keys: ["taper"],
|
|
103
|
+
read(raw, part, where) {
|
|
104
|
+
part.taper = readUnit(raw.taper, `${where}.taper`, 0, -1, 1);
|
|
105
|
+
},
|
|
106
|
+
samples: cylinderSamples,
|
|
107
|
+
},
|
|
108
|
+
torus: {
|
|
109
|
+
keys: [],
|
|
110
|
+
read(raw, part, where) {
|
|
111
|
+
if (part.size[2] >= Math.min(part.size[0], part.size[1])) {
|
|
112
|
+
throw new RecipeError(`${where}.size`, "a torus's tube (size[2]) must be thinner than its outer diameter");
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
samples: ringSamples,
|
|
116
|
+
hole: ringHole,
|
|
117
|
+
},
|
|
118
|
+
frame: {
|
|
119
|
+
keys: ["border"],
|
|
120
|
+
read(raw, part, where) {
|
|
121
|
+
const limit = Math.min(part.size[0], part.size[1]) / 2;
|
|
122
|
+
const border = raw.border === undefined ? tidy(Math.min(part.size[0], part.size[1]) * 0.15) : raw.border;
|
|
123
|
+
if (!isFiniteNumber(border) || border <= 0 || border >= limit) {
|
|
124
|
+
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)}`);
|
|
125
|
+
}
|
|
126
|
+
part.border = tidy(border);
|
|
127
|
+
},
|
|
128
|
+
samples: ringSamples,
|
|
129
|
+
hole: ringHole,
|
|
130
|
+
},
|
|
131
|
+
...CURVED_SHAPE_SPECS,
|
|
132
|
+
...LOFT_CONVEX_SPECS,
|
|
133
|
+
scatter: SCATTER_SHAPE,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
export const SCULPT_SHAPES = Object.freeze(Object.keys(SCULPT_SHAPE_SPECS));
|
|
137
|
+
/** Every shape-only key, in first-seen order. */
|
|
138
|
+
export const SCULPT_SHAPE_KEYS = Object.freeze([...new Set(Object.values(SCULPT_SHAPE_SPECS).flatMap((spec) => spec.keys))]);
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// The live sketch of the running agent turn: the sculpt parts a tool call has written so far, as the sidecar's
|
|
2
|
+
// sculpt.sketch frames carry them (bin/agent/sculpt-sketch.mjs), for sculpt-sketch.jsx to draw as ghost clay.
|
|
3
|
+
// In-page only and per turn: nothing here is saved, undone, exported or sent to the agent. A call's sketch goes
|
|
4
|
+
// when its tool.done arrives (the real objects are in the scene by then, or were refused), and everything goes
|
|
5
|
+
// when the turn ends, fails or is stopped (agent-client.js clears it), so no ghost outlives its turn.
|
|
6
|
+
import { expandSculptParts, normalizeSculptRecipe } from "./sculpt-recipe.js";
|
|
7
|
+
|
|
8
|
+
const MAX_PARTS = 300;
|
|
9
|
+
const EMPTY = Object.freeze([]);
|
|
10
|
+
const sketches = new Map(), finished = new Set(), listeners = new Set();
|
|
11
|
+
let snapshot = EMPTY;
|
|
12
|
+
|
|
13
|
+
function emit() {
|
|
14
|
+
snapshot = Object.freeze([...sketches].flatMap(([call, groups]) => groups.map((group) => ({ ...group, key: `${call}/${group.key}` }))));
|
|
15
|
+
for (const listener of [...listeners]) {
|
|
16
|
+
try { listener(); } catch { /* a broken view never stops the turn */ }
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* sculptSketchFrame(event) takes every frame of the running turn: sculpt.sketch sets (or, with no groups, drops)
|
|
22
|
+
* one call's sketch; tool.done drops that call's sketch and ignores any sketch of it that arrives later (the final
|
|
23
|
+
* one can trail a quick tool); done and error end the turn's sketches.
|
|
24
|
+
*/
|
|
25
|
+
export function sculptSketchFrame(event) {
|
|
26
|
+
if (event?.type === "sculpt.sketch") {
|
|
27
|
+
const call = typeof event.callId === "string" && event.callId ? event.callId : `#${event.index}`;
|
|
28
|
+
if (finished.has(call)) return;
|
|
29
|
+
// The same call sketched before its id was known (bin/agent/sculpt-sketch.mjs).
|
|
30
|
+
const unnamed = call !== `#${event.index}` && sketches.delete(`#${event.index}`);
|
|
31
|
+
const groups = (Array.isArray(event.groups) ? event.groups : [])
|
|
32
|
+
.filter((group) => group && typeof group.key === "string" && Array.isArray(group.parts) && group.parts.length);
|
|
33
|
+
if (groups.length) sketches.set(call, groups);
|
|
34
|
+
else if (!sketches.delete(call) && !unnamed) return;
|
|
35
|
+
emit();
|
|
36
|
+
} else if (event?.type === "tool.done" && typeof event.callId === "string") {
|
|
37
|
+
finished.add(event.callId);
|
|
38
|
+
if (sketches.delete(event.callId)) emit();
|
|
39
|
+
} else if (event?.type === "done" || event?.type === "error") clearSculptSketch();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Forget every sketch of the turn (it ended, failed or was stopped). */
|
|
43
|
+
export function clearSculptSketch() {
|
|
44
|
+
finished.clear();
|
|
45
|
+
if (!sketches.size) return;
|
|
46
|
+
sketches.clear();
|
|
47
|
+
emit();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** subscribeSculptSketch(listener) -> unsubscribe; with sculptSketchSnapshot it fits React's useSyncExternalStore. */
|
|
51
|
+
export function subscribeSculptSketch(listener) {
|
|
52
|
+
listeners.add(listener);
|
|
53
|
+
return () => { listeners.delete(listener); };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Every group being sketched, [{ key, name?, target?, placement?, parts }]; the same array until a frame changes it. */
|
|
57
|
+
export const sculptSketchSnapshot = () => snapshot;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* sketchDrawnParts(parts) -> the parts a ghost draws, as expandSculptParts gives them (object-frame matrix, mirrors
|
|
61
|
+
* expanded). Lenient where the real recipe is strict: each part is checked on its own and a bad one is dropped, not
|
|
62
|
+
* the whole recipe, and a part whose parent is not written yet waits, because a sketch is a recipe half written.
|
|
63
|
+
*/
|
|
64
|
+
export function sketchDrawnParts(parts) {
|
|
65
|
+
const drawable = [], ids = new Set();
|
|
66
|
+
for (const raw of Array.isArray(parts) ? parts.slice(0, MAX_PARTS) : []) {
|
|
67
|
+
if (!raw || typeof raw !== "object" || ids.has(raw.id)) continue;
|
|
68
|
+
const { parent, mirror, ...own } = raw;
|
|
69
|
+
const checked = normalizeSculptRecipe({ version: 1, parts: [own] });
|
|
70
|
+
if (!checked.ok || checked.recipe.parts[0].op) continue;
|
|
71
|
+
ids.add(raw.id);
|
|
72
|
+
drawable.push({ ...checked.recipe.parts[0], ...(typeof parent === "string" && parent ? { parent } : {}), ...(mirror === true ? { mirror: true } : {}) });
|
|
73
|
+
}
|
|
74
|
+
// expandSculptParts walks down from the roots, so an orphan (or a parent cycle) is never reached.
|
|
75
|
+
return expandSculptParts({ version: 1, parts: drawable });
|
|
76
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// The live sketch in the working views: while the agent is still writing a call that builds sculpts, the parts it
|
|
2
|
+
// has written so far (sculpt-sketch-state.js) stand on the stage as translucent clay in their own colours, so the
|
|
3
|
+
// scene takes shape while the model types instead of after minutes of an empty stage. Each part grows in briefly,
|
|
4
|
+
// so new ones read as new. Everything sits on GIZMO_LAYER and takes no ray: the shot preview card, PlayView,
|
|
5
|
+
// captures the agent reads (verify_result), contact sheets and exports never draw it, and no pick can select it.
|
|
6
|
+
// A part that did not change keeps its mesh; a removed one is disposed with its geometry and material.
|
|
7
|
+
import { useEffect, useMemo, useSyncExternalStore } from "react";
|
|
8
|
+
import * as THREE from "three";
|
|
9
|
+
import { useFrame, useThree } from "@react-three/fiber";
|
|
10
|
+
import { GIZMO_LAYER } from "./dualview.jsx";
|
|
11
|
+
import { sculptPartGeometry } from "./sculpt-geometry.js";
|
|
12
|
+
import { clearSculptSketch, sculptSketchFrame, sculptSketchSnapshot, sketchDrawnParts, subscribeSculptSketch } from "./sculpt-sketch-state.js";
|
|
13
|
+
|
|
14
|
+
const OPACITY = 0.42;
|
|
15
|
+
/** A scatter is drawn as the soft box it fills: its items are the real sculpt's job. */
|
|
16
|
+
const SCATTER_OPACITY = 0.2;
|
|
17
|
+
const GROW_MS = 420;
|
|
18
|
+
const DEG = Math.PI / 180;
|
|
19
|
+
// After the other see-through things (ghost-set.js draws onion skins at 40).
|
|
20
|
+
const RENDER_ORDER = 44;
|
|
21
|
+
const noRay = () => {};
|
|
22
|
+
const grow = (t) => 1 - (1 - t) ** 3;
|
|
23
|
+
|
|
24
|
+
/** Where a group stands: its object.sculpt placement, else the sculpt it rebuilds, else the origin (where a new sculpt lands). */
|
|
25
|
+
function placementMatrix(group, objects) {
|
|
26
|
+
const target = group.target ? objects?.find((object) => object.id === group.target) : null;
|
|
27
|
+
const at = { x: target?.x ?? 0, y: target?.y ?? 0, z: target?.z ?? 0, rot: target?.rot ?? 0, ...group.placement };
|
|
28
|
+
return new THREE.Matrix4().compose(new THREE.Vector3(at.x, at.y, at.z), new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), at.rot * DEG), new THREE.Vector3(1, 1, 1));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Every part to draw: { identity, part, matrix (world) }. The identity leaves out ids and placement, so a part
|
|
32
|
+
* the model renamed, or a group that just learned where it stands, keeps its mesh. */
|
|
33
|
+
function sketchItems(groups, objects) {
|
|
34
|
+
const items = [];
|
|
35
|
+
for (const group of groups) {
|
|
36
|
+
const place = placementMatrix(group, objects);
|
|
37
|
+
for (const part of sketchDrawnParts(group.parts)) {
|
|
38
|
+
const { id, parent, matrix, ...shape } = part;
|
|
39
|
+
items.push({ identity: JSON.stringify([shape, matrix.map((value) => Math.round(value * 1e4))]), part, matrix: place.clone().multiply(new THREE.Matrix4().fromArray(matrix)) });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return items;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The meshes of the sketch under one root, keyed by identity. */
|
|
46
|
+
class SketchLayer {
|
|
47
|
+
root = new THREE.Group();
|
|
48
|
+
meshes = new Map();
|
|
49
|
+
constructor() {
|
|
50
|
+
this.root.name = "sculpt-sketch";
|
|
51
|
+
this.root.userData.sculptSketch = true;
|
|
52
|
+
}
|
|
53
|
+
make(part, now) {
|
|
54
|
+
const geometry = sculptPartGeometry(part.shape === "scatter" ? { ...part, shape: "blob", roundness: 0.5, taper: 0 } : part);
|
|
55
|
+
const material = new THREE.MeshStandardMaterial({ color: part.color, emissive: part.color, emissiveIntensity: 0.18, roughness: 0.85, metalness: 0, transparent: true, opacity: 0, depthWrite: false });
|
|
56
|
+
const mesh = new THREE.Mesh(geometry, material);
|
|
57
|
+
Object.assign(mesh, { raycast: noRay, castShadow: false, receiveShadow: false, matrixAutoUpdate: false, renderOrder: RENDER_ORDER, frustumCulled: false });
|
|
58
|
+
mesh.layers.set(GIZMO_LAYER);
|
|
59
|
+
mesh.userData.sculptSketch = true;
|
|
60
|
+
this.root.add(mesh);
|
|
61
|
+
return { mesh, base: new THREE.Matrix4(), born: now, opacity: part.shape === "scatter" ? SCATTER_OPACITY : OPACITY };
|
|
62
|
+
}
|
|
63
|
+
drop({ mesh }) {
|
|
64
|
+
this.root.remove(mesh);
|
|
65
|
+
mesh.geometry.dispose();
|
|
66
|
+
mesh.material.dispose();
|
|
67
|
+
}
|
|
68
|
+
/** Keep the meshes whose parts are still there, make the new ones, dispose the rest. */
|
|
69
|
+
update(items, now) {
|
|
70
|
+
const next = new Map(), seen = new Map();
|
|
71
|
+
for (const item of items) {
|
|
72
|
+
const n = (seen.get(item.identity) ?? 0) + 1;
|
|
73
|
+
seen.set(item.identity, n);
|
|
74
|
+
const key = `${item.identity}#${n}`;
|
|
75
|
+
let entry = this.meshes.get(key);
|
|
76
|
+
if (entry) this.meshes.delete(key);
|
|
77
|
+
else {
|
|
78
|
+
try { entry = this.make(item.part, now); } catch { continue; }
|
|
79
|
+
}
|
|
80
|
+
entry.base.copy(item.matrix);
|
|
81
|
+
next.set(key, entry);
|
|
82
|
+
}
|
|
83
|
+
for (const entry of this.meshes.values()) this.drop(entry);
|
|
84
|
+
this.meshes = next;
|
|
85
|
+
this.frame(now);
|
|
86
|
+
}
|
|
87
|
+
/** Grow the new parts in; true while one still grows. */
|
|
88
|
+
frame(now) {
|
|
89
|
+
let growing = false;
|
|
90
|
+
const scale = new THREE.Matrix4();
|
|
91
|
+
for (const entry of this.meshes.values()) {
|
|
92
|
+
const t = Math.min(1, Math.max(0, (now - entry.born) / GROW_MS)), k = grow(t);
|
|
93
|
+
growing ||= t < 1;
|
|
94
|
+
const s = 0.55 + 0.45 * k;
|
|
95
|
+
entry.mesh.matrix.copy(entry.base).multiply(scale.makeScale(s, s, s));
|
|
96
|
+
entry.mesh.material.opacity = entry.opacity * k;
|
|
97
|
+
}
|
|
98
|
+
return growing;
|
|
99
|
+
}
|
|
100
|
+
dispose() {
|
|
101
|
+
for (const entry of this.meshes.values()) this.drop(entry);
|
|
102
|
+
this.meshes.clear();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** objects: the authored records, for the placement of a sculpt the agent rebuilds (object.sculpt { id }). */
|
|
107
|
+
export function SculptSketch({ objects }) {
|
|
108
|
+
const groups = useSyncExternalStore(subscribeSculptSketch, sculptSketchSnapshot, sculptSketchSnapshot);
|
|
109
|
+
const scene = useThree((state) => state.scene), invalidate = useThree((state) => state.invalidate);
|
|
110
|
+
const layer = useMemo(() => new SketchLayer(), []);
|
|
111
|
+
const items = useMemo(() => sketchItems(groups, objects), [groups, objects]);
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
scene.add(layer.root);
|
|
114
|
+
// QA: the browser suite feeds frames and counts what the stage draws.
|
|
115
|
+
if (typeof window !== "undefined") window.__cozyclaySculptSketch = { frame: sculptSketchFrame, clear: clearSculptSketch, meshes: () => layer.meshes.size, root: layer.root };
|
|
116
|
+
return () => { scene.remove(layer.root); layer.dispose(); };
|
|
117
|
+
}, [scene, layer]);
|
|
118
|
+
useEffect(() => {
|
|
119
|
+
layer.update(items, performance.now());
|
|
120
|
+
invalidate();
|
|
121
|
+
}, [layer, items, invalidate]);
|
|
122
|
+
useFrame(() => { if (layer.frame(performance.now())) invalidate(); });
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Surface fields any part takes (registered in sculpt-part-fields.js):
|
|
3
|
+
*
|
|
4
|
+
* detail { amount: 0..1, scale?: 1..12, seed? } lumps a smooth part into
|
|
5
|
+
* a cloud, bush, rock, hay bale or loaf. `amount` is the bump
|
|
6
|
+
* height (1 = DETAIL_RELIEF of the part's smallest half-extent),
|
|
7
|
+
* `scale` about how many bumps fit across its longest side.
|
|
8
|
+
* gradient { color, axis?: y|x|z, from?: 0..1, to?: 0..1 } blends the
|
|
9
|
+
* part's colour (at `from`) into `color` (at `to`) along a local
|
|
10
|
+
* axis, 0 at its low end and 1 at its high end: a sky-lit cloud,
|
|
11
|
+
* a ripening fruit, a sunset wall. On a scatter it colours whole
|
|
12
|
+
* items by where they sit.
|
|
13
|
+
*
|
|
14
|
+
* The geometry halves are sculpt-detail.js and sculpt-colours.js. Pure: no three.js.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { RecipeError, readChoice, readHexColor, readSeed, readUnit } from "./sculpt-recipe-read.js";
|
|
18
|
+
|
|
19
|
+
/** Bump height at amount 1, as a fraction of the part's smallest half-extent:
|
|
20
|
+
* past this a rock reads as a crumpled ball. sculpt-detail.js draws the same. */
|
|
21
|
+
export const DETAIL_RELIEF = 0.35;
|
|
22
|
+
const DETAIL_SCALE = { fallback: 4, min: 1, max: 12 };
|
|
23
|
+
const GRADIENT_AXES = ["y", "x", "z"];
|
|
24
|
+
|
|
25
|
+
function readFields(value, path, keys) {
|
|
26
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
27
|
+
throw new RecipeError(path, `must be an object { ${keys.join(", ")} }, got ${JSON.stringify(value)}`);
|
|
28
|
+
}
|
|
29
|
+
for (const key of Object.keys(value)) {
|
|
30
|
+
if (!keys.includes(key)) throw new RecipeError(`${path}.${key}`, `unknown key (allowed: ${keys.join(", ")})`);
|
|
31
|
+
}
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const DETAIL_FIELD = {
|
|
36
|
+
key: "detail",
|
|
37
|
+
read(raw, part, where) {
|
|
38
|
+
const at = `${where}.detail`;
|
|
39
|
+
if (part.shape === "scatter") throw new RecipeError(at, "does not apply to a scatter: its items are already small; lump the part under it instead");
|
|
40
|
+
const detail = readFields(raw.detail, at, ["amount", "scale", "seed"]);
|
|
41
|
+
if (detail.amount === undefined) throw new RecipeError(`${at}.amount`, "is required: the bump height from 0 (smooth) to 1 (very lumpy)");
|
|
42
|
+
part.detail = {
|
|
43
|
+
amount: readUnit(detail.amount, `${at}.amount`, 0, 0, 1),
|
|
44
|
+
scale: readUnit(detail.scale, `${at}.scale`, DETAIL_SCALE.fallback, DETAIL_SCALE.min, DETAIL_SCALE.max),
|
|
45
|
+
seed: readSeed(detail.seed, `${at}.seed`),
|
|
46
|
+
};
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const GRADIENT_FIELD = {
|
|
51
|
+
key: "gradient",
|
|
52
|
+
read(raw, part, where) {
|
|
53
|
+
const at = `${where}.gradient`;
|
|
54
|
+
const gradient = readFields(raw.gradient, at, ["color", "axis", "from", "to"]);
|
|
55
|
+
if (gradient.color === undefined) throw new RecipeError(`${at}.color`, "is required: the #rrggbb colour the part blends into");
|
|
56
|
+
part.gradient = {
|
|
57
|
+
color: readHexColor(gradient.color, `${at}.color`),
|
|
58
|
+
axis: readChoice(gradient.axis, `${at}.axis`, "y", GRADIENT_AXES),
|
|
59
|
+
from: readUnit(gradient.from, `${at}.from`, 0, 0, 1),
|
|
60
|
+
to: readUnit(gradient.to, `${at}.to`, 1, 0, 1),
|
|
61
|
+
};
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export const SURFACE_FIELDS = [DETAIL_FIELD, GRADIENT_FIELD];
|
package/src/settings-menu.jsx
CHANGED
|
@@ -7,8 +7,7 @@ import PreferencesDialog from "./shell/PreferencesDialog.jsx";
|
|
|
7
7
|
// not belong on the Save/Export row as bare toggles.
|
|
8
8
|
//
|
|
9
9
|
// The trigger now opens the v2 Preferences dialog. The old language strings stay
|
|
10
|
-
// here because the locale contract
|
|
11
|
-
// treat this file as the Settings entry point.
|
|
10
|
+
// here because the locale contract treats this file as the Settings entry point.
|
|
12
11
|
const LANGUAGES = [
|
|
13
12
|
{ id: "en", label: "English", action: "Switch to English" },
|
|
14
13
|
{ id: "ko", label: "한국어", action: "한국어로 전환" },
|
|
@@ -21,23 +20,6 @@ export default function SettingsMenu() {
|
|
|
21
20
|
setOpen(false);
|
|
22
21
|
triggerRef.current?.focus();
|
|
23
22
|
}, []);
|
|
24
|
-
const openCameraTutorial = useCallback(() => {
|
|
25
|
-
window.dispatchEvent(new CustomEvent("cozyclay:camera-tutorial", { detail: { open: true } }));
|
|
26
|
-
setOpen(false);
|
|
27
|
-
}, []);
|
|
28
|
-
const cameraTutorial = (
|
|
29
|
-
<>
|
|
30
|
-
<h4>{ko("Help", "도움말")}</h4>
|
|
31
|
-
<button
|
|
32
|
-
type="button"
|
|
33
|
-
data-testid="settings-camera-tutorial"
|
|
34
|
-
title={ko("Learn the camera in seven steps", "일곱 단계로 카메라 익히기")}
|
|
35
|
-
onClick={openCameraTutorial}
|
|
36
|
-
>
|
|
37
|
-
{ko("Camera tutorial", "카메라 튜토리얼")}
|
|
38
|
-
</button>
|
|
39
|
-
</>
|
|
40
|
-
);
|
|
41
23
|
|
|
42
24
|
// First-run cue: the studio starts in English even on a Korean browser, so
|
|
43
25
|
// the only visible hint that Korean exists used to be the old locale button.
|
|
@@ -64,7 +46,6 @@ export default function SettingsMenu() {
|
|
|
64
46
|
open={open}
|
|
65
47
|
onClose={close}
|
|
66
48
|
initialSection="Appearance"
|
|
67
|
-
cameraTutorial={cameraTutorial}
|
|
68
49
|
/>
|
|
69
50
|
</div>
|
|
70
51
|
);
|