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,37 @@
|
|
|
1
|
+
// Object kinds an old project can carry that this build no longer ships.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Kinds an earlier build shipped and this one does not (hand-built set
|
|
5
|
+
* pieces), with the standing size they had. They exist only so an old project
|
|
6
|
+
* still opens: each record becomes a plain cube with the same transform and
|
|
7
|
+
* the same size, so its blocking, collisions, route and children survive.
|
|
8
|
+
*/
|
|
9
|
+
const RETIRED_KINDS = Object.freeze({
|
|
10
|
+
chair: { width: 0.6, depth: 0.6, height: 1.15 },
|
|
11
|
+
car: { width: 1.8, depth: 4.5, height: 1.4 },
|
|
12
|
+
"small-plane": { width: 3.4, depth: 3.6, height: 1.4 },
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
/** A stored record of a retired kind as the cube that stands in for it; any
|
|
16
|
+
* other record is returned untouched. Scale absorbs the retired footprint, the
|
|
17
|
+
* same single-`scale` fallback normalizeSceneObject reads applies first. */
|
|
18
|
+
export function retireObjectKind(record) {
|
|
19
|
+
const size = record && typeof record === "object" ? RETIRED_KINDS[record.renderer] : undefined;
|
|
20
|
+
if (!size) return record;
|
|
21
|
+
const single = record.scaleX === undefined && record.scaleY === undefined && record.scaleZ === undefined ? Number(record.scale) : NaN;
|
|
22
|
+
const axis = (value) => {
|
|
23
|
+
const n = value === undefined ? (Number.isFinite(single) ? single : 1) : Number(value);
|
|
24
|
+
return Number.isFinite(n) ? n : 1;
|
|
25
|
+
};
|
|
26
|
+
const { scale: _scale, ...rest } = record;
|
|
27
|
+
return {
|
|
28
|
+
...rest,
|
|
29
|
+
renderer: "cube",
|
|
30
|
+
scaleX: axis(record.scaleX) * size.width,
|
|
31
|
+
scaleY: axis(record.scaleY) * size.height,
|
|
32
|
+
scaleZ: axis(record.scaleZ) * size.depth,
|
|
33
|
+
footprint: { width: 1, depth: 1 },
|
|
34
|
+
height: 1,
|
|
35
|
+
supportY: 1,
|
|
36
|
+
};
|
|
37
|
+
}
|
package/src/scene-objects.js
CHANGED
|
@@ -24,10 +24,11 @@ import { MESH_DEFAULT_HEIGHT, MESH_HEIGHT_MIN } from "./scene-mesh.js";
|
|
|
24
24
|
import { normalizeSculptRecipe, sculptStandingBox } from "./sculpt-recipe.js";
|
|
25
25
|
import { GREY_BOX } from "./object-colors.js";
|
|
26
26
|
import { isEffectivelyHidden } from "./object-hidden.js";
|
|
27
|
+
import { retireObjectKind } from "./retired-kinds.js";
|
|
27
28
|
import { objectSize } from "./object-size.js";
|
|
28
29
|
import { EFFECT_ENTRY, effectFields } from "./effects/effect-record.js";
|
|
29
30
|
import { TEXT_ENTRY, TEXT_KIND, textFields, textPatch } from "./text/text-record.js";
|
|
30
|
-
import { LIGHT_KIND, LIGHT_MENU_ENTRIES, lightLibraryEntry, objectLookFields, objectLookPatch, objectGraspFields, objectGraspPatch } from "./object-look.js";
|
|
31
|
+
import { LIGHT_KIND, LIGHT_MENU_ENTRIES, lightLibraryEntry, objectLookFields, objectLookPatch, objectGraspFields, objectGraspPatch, objectMaterialKeys } from "./object-look.js";
|
|
31
32
|
export { OBJECT_COLORS, normalizeObjectColor, rememberObjectColor, readStoredObjectColors, writeStoredObjectColors } from "./object-colors.js";
|
|
32
33
|
export { isEffectivelyHidden };
|
|
33
34
|
|
|
@@ -65,8 +66,7 @@ const snapTo = (value, step) => Number((step > 0 ? Math.round(value / step) * st
|
|
|
65
66
|
* object stands (used for the selection box and the gizmo's height).
|
|
66
67
|
*
|
|
67
68
|
* Primitives are grey-box grey on purpose: a blockout stands in for something
|
|
68
|
-
* else, and a tinted maquette reads as a finished prop.
|
|
69
|
-
* pieces keep their clay colours because they ARE the thing they depict.
|
|
69
|
+
* else, and a tinted maquette reads as a finished prop.
|
|
70
70
|
*/
|
|
71
71
|
export const OBJECT_LIBRARY = [
|
|
72
72
|
{ kind: "cube", label: "Cube", group: "Primitives", footprint: { width: 1, depth: 1 }, height: 1, color: GREY_BOX },
|
|
@@ -75,11 +75,6 @@ export const OBJECT_LIBRARY = [
|
|
|
75
75
|
{ kind: "cylinder", label: "Cylinder", group: "Primitives", footprint: { width: 1, depth: 1 }, height: 1, color: GREY_BOX },
|
|
76
76
|
{ kind: "cone", label: "Cone", group: "Primitives", footprint: { width: 1, depth: 1 }, height: 1, color: GREY_BOX },
|
|
77
77
|
{ kind: "plane", label: "Plane", group: "Primitives", footprint: { width: 2, depth: 2 }, height: 0, color: GREY_BOX },
|
|
78
|
-
// supportY is the walkable surface, which can differ from the object's
|
|
79
|
-
// bounding height (a chair's back rises above its seat).
|
|
80
|
-
{ kind: "chair", label: "Chair", group: "Set pieces", footprint: { width: 0.6, depth: 0.6 }, height: 1.15, supportY: 0.495, color: "#b9855d" },
|
|
81
|
-
{ kind: "car", label: "Car", group: "Set pieces", footprint: { width: 1.8, depth: 4.5 }, height: 1.4, color: "#d98770" },
|
|
82
|
-
{ kind: "small-plane", label: "Plane (aircraft)", group: "Set pieces", footprint: { width: 3.4, depth: 3.6 }, height: 1.4, color: "#7896a4" },
|
|
83
78
|
];
|
|
84
79
|
|
|
85
80
|
/**
|
|
@@ -552,7 +547,7 @@ const TRANSFORM_LIMITS = {
|
|
|
552
547
|
/** The patch keys updateSceneObject reads for this object; any other key is
|
|
553
548
|
* silently ignored there, so callers that must not be silent check against this. */
|
|
554
549
|
export function objectPatchFields(object) {
|
|
555
|
-
const fields = [...Object.keys(TRANSFORM_LIMITS), "name", "color", "hidden", "opacity", "path", "glow", "grasp"];
|
|
550
|
+
const fields = [...Object.keys(TRANSFORM_LIMITS), "name", "color", "hidden", "opacity", "path", "glow", "grasp", ...objectMaterialKeys(object?.renderer)];
|
|
556
551
|
if (object?.renderer === LIGHT_KIND) fields.push("light");
|
|
557
552
|
if (object?.renderer === CUTOUT_KIND) fields.push("assetId", "sourceAssetId", "matteAssetId", "matteScale", "height", "aspect", "width", "stretch");
|
|
558
553
|
if (object?.renderer === MESH_KIND) fields.push("clay", "assetId", "height");
|
|
@@ -994,6 +989,7 @@ function objectOpacity(value) {
|
|
|
994
989
|
|
|
995
990
|
export function normalizeSceneObject(record) {
|
|
996
991
|
if (!record || typeof record !== "object" || Array.isArray(record)) return null;
|
|
992
|
+
record = retireObjectKind(record);
|
|
997
993
|
const entry = objectLibraryEntry(record.renderer);
|
|
998
994
|
if (!entry) return null;
|
|
999
995
|
if (typeof record.id !== "string" || !record.id) return null;
|
package/src/scenes.js
CHANGED
|
@@ -6,7 +6,7 @@ import { DEFAULT_SENSOR_FORMAT, SENSOR_FORMATS } from "./shot.js";
|
|
|
6
6
|
import { normalizeStableItems } from "./stable-items.js";
|
|
7
7
|
|
|
8
8
|
import { elementByPath } from "./studio-elements.js";
|
|
9
|
-
import { wrapAngle } from "./scene-objects.js";
|
|
9
|
+
import { wrapAngle } from "./scene-objects.js"; import { retireObjectKind } from "./retired-kinds.js";
|
|
10
10
|
import { normalizeTakeSource } from "./domains/motion/take-source.js";
|
|
11
11
|
import { createKeyLight, normalizeLighting } from "./stage-lighting.js";
|
|
12
12
|
// The timeline clock lives in timeline-fps.js (three-free, so the agent
|
|
@@ -115,8 +115,6 @@ export function normalizeReferenceImage(value) {
|
|
|
115
115
|
|
|
116
116
|
/** Stature band for a cast member: the gizmo's scale handles are a deliberate
|
|
117
117
|
* artistic ask, and a previs giant or child is legitimate. */
|
|
118
|
-
export const CHARACTER_SCALE_MIN = CHARACTER_SCALE_LIMITS.min;
|
|
119
|
-
export const CHARACTER_SCALE_MAX = CHARACTER_SCALE_LIMITS.max;
|
|
120
118
|
const clampScale = (value) => (Number.isFinite(value) && value > 0
|
|
121
119
|
? Math.max(CHARACTER_SCALE_LIMITS.min, Math.min(CHARACTER_SCALE_LIMITS.max, value))
|
|
122
120
|
: 1);
|
|
@@ -373,7 +371,8 @@ function repairScene(record, existing, fallbackNumber, fallbackStage, legacyCloc
|
|
|
373
371
|
return {
|
|
374
372
|
id: record.id,
|
|
375
373
|
name: uniqueName(typeof record.name === "string" ? record.name : `SCENE ${String(fallbackNumber).padStart(2, "0")}`, existing),
|
|
376
|
-
|
|
374
|
+
// A kind this build no longer ships opens as the cube that stands in for it.
|
|
375
|
+
objects: record.objects.filter(plainObject).map((object) => retireObjectKind(cloneValue(object))),
|
|
377
376
|
// The shot document is a sealed envelope with its OWN version, and it
|
|
378
377
|
// migrates its own frames when its reader opens it.
|
|
379
378
|
shotDocument: cloneValue(record.shotDocument ?? null),
|
|
@@ -412,7 +411,7 @@ export function readSceneDocument(raw, legacyRaw = null) {
|
|
|
412
411
|
return { status: "corrupt", document: createSceneDocument(), dropped: 0, quarantineRaw: legacyRaw };
|
|
413
412
|
}
|
|
414
413
|
const document = createSceneDocument();
|
|
415
|
-
document.scenes[0].objects = legacy.objects.filter(plainObject).map((object) => cloneValue(object));
|
|
414
|
+
document.scenes[0].objects = legacy.objects.filter(plainObject).map((object) => retireObjectKind(cloneValue(object)));
|
|
416
415
|
return { status: "migrated", document, dropped: legacy.objects.length - document.scenes[0].objects.length };
|
|
417
416
|
}
|
|
418
417
|
return { status: "absent", document: createSceneDocument(), dropped: 0 };
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sculpt colours for the shaded look, applied once per part by
|
|
3
|
+
* buildSculptGroup after the part's material exists:
|
|
4
|
+
*
|
|
5
|
+
* - a `gradient` (sculpt-surface-fields.js) becomes a per-vertex `color`
|
|
6
|
+
* attribute blending the part's colour into the gradient's;
|
|
7
|
+
* - a scatter's items each get a small seeded hue and brightness jitter (and
|
|
8
|
+
* the gradient, by where the item sits), so a field is not one flat swatch.
|
|
9
|
+
*
|
|
10
|
+
* The colour then lives in the vertex or instance colours, so the material's
|
|
11
|
+
* own colour goes white and `vertexColors` turns on (a texture map still
|
|
12
|
+
* multiplies in). A flat display mode (auto-colour, through materialFor)
|
|
13
|
+
* skips this, so it ignores gradients and jitter.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import * as THREE from "three";
|
|
17
|
+
import { isMirrorCopy, partSeed, seededRandom } from "./sculpt-noise.js";
|
|
18
|
+
|
|
19
|
+
const AXES = { x: 0, y: 1, z: 2 };
|
|
20
|
+
/** Item-to-item spread: hue ± this (of the wheel), brightness × 1 ± this. */
|
|
21
|
+
const JITTER_HUE = 0.025;
|
|
22
|
+
const JITTER_SHADE = 0.16;
|
|
23
|
+
|
|
24
|
+
/** How far a local point is along the gradient: 0 at `from` (part colour), 1 at `to`. */
|
|
25
|
+
function blend(part, x, y, z) {
|
|
26
|
+
const { axis, from, to } = part.gradient;
|
|
27
|
+
const i = AXES[axis];
|
|
28
|
+
// A mirrored copy reads its x reversed, so an x gradient reflects with the part.
|
|
29
|
+
const along = [isMirrorCopy(part) ? -x : x, y, z][i] / part.size[i] + 0.5;
|
|
30
|
+
if (to === from) return along >= to ? 1 : 0;
|
|
31
|
+
return THREE.MathUtils.clamp((along - from) / (to - from), 0, 1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function applySculptColours(mesh, part) {
|
|
35
|
+
if (!part.gradient && !mesh.isInstancedMesh) return;
|
|
36
|
+
const base = new THREE.Color(part.color);
|
|
37
|
+
const end = part.gradient ? new THREE.Color(part.gradient.color) : null;
|
|
38
|
+
const colour = new THREE.Color();
|
|
39
|
+
if (mesh.isInstancedMesh) {
|
|
40
|
+
const random = seededRandom(partSeed(part, part.seed + 1));
|
|
41
|
+
const matrix = new THREE.Matrix4(), at = new THREE.Vector3();
|
|
42
|
+
for (let i = 0; i < mesh.count; i += 1) {
|
|
43
|
+
colour.copy(base);
|
|
44
|
+
if (end) {
|
|
45
|
+
at.setFromMatrixPosition(mesh.getMatrixAt(i, matrix));
|
|
46
|
+
colour.lerp(end, blend(part, at.x, at.y, at.z));
|
|
47
|
+
}
|
|
48
|
+
colour.offsetHSL((random() * 2 - 1) * JITTER_HUE, 0, 0).multiplyScalar(1 + (random() * 2 - 1) * JITTER_SHADE);
|
|
49
|
+
mesh.setColorAt(i, colour);
|
|
50
|
+
}
|
|
51
|
+
mesh.instanceColor.needsUpdate = true;
|
|
52
|
+
} else {
|
|
53
|
+
const position = mesh.geometry.getAttribute("position");
|
|
54
|
+
const colours = new Float32Array(position.count * 3);
|
|
55
|
+
for (let i = 0; i < position.count; i += 1) {
|
|
56
|
+
colour.copy(base).lerp(end, blend(part, position.getX(i), position.getY(i), position.getZ(i))).toArray(colours, 3 * i);
|
|
57
|
+
}
|
|
58
|
+
mesh.geometry.setAttribute("color", new THREE.BufferAttribute(colours, 3));
|
|
59
|
+
}
|
|
60
|
+
for (const material of [mesh.material].flat()) {
|
|
61
|
+
material.color?.set(0xffffff);
|
|
62
|
+
material.vertexColors = !!mesh.geometry.getAttribute("color");
|
|
63
|
+
material.needsUpdate = true;
|
|
64
|
+
}
|
|
65
|
+
}
|
package/src/sculpt-compare.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* Pure: image buffers in, numbers and RGBA out. The MCP tool owns the files.
|
|
17
17
|
*/
|
|
18
18
|
import { expandSculptParts, patchSculptRecipe } from "./sculpt-recipe.js";
|
|
19
|
+
import { SCULPT_SHAPE_SPECS } from "./sculpt-shapes.js";
|
|
19
20
|
|
|
20
21
|
const COMPARE_SIZE = 256;
|
|
21
22
|
|
|
@@ -117,37 +118,6 @@ function applyMatrix(m, [x, y, z]) {
|
|
|
117
118
|
return [m[0] * x + m[4] * y + m[8] * z + m[12], m[1] * x + m[5] * y + m[9] * z + m[13], m[2] * x + m[6] * y + m[10] * z + m[14]];
|
|
118
119
|
}
|
|
119
120
|
|
|
120
|
-
function partSurfaceSamples(part) {
|
|
121
|
-
const [sx, sy, sz] = part.size.map((v) => v / 2);
|
|
122
|
-
const points = [];
|
|
123
|
-
if (part.shape === "box") {
|
|
124
|
-
for (const x of [-sx, sx]) for (const y of [-sy, sy]) for (const z of [-sz, sz]) points.push([x, y, z]);
|
|
125
|
-
return points;
|
|
126
|
-
}
|
|
127
|
-
const p = 2 + 6 * (part.roundness ?? 0);
|
|
128
|
-
const taper = part.taper ?? 0;
|
|
129
|
-
for (let i = 0; i <= 16; i += 1) {
|
|
130
|
-
const v = -1 + (2 * i) / 16;
|
|
131
|
-
const k = taper > 0 ? 1 - taper * 0.6 * ((v + 1) / 2) : taper < 0 ? 1 + taper * 0.6 * ((1 - v) / 2) : 1;
|
|
132
|
-
for (let j = 0; j < 24; j += 1) {
|
|
133
|
-
const t = (j / 24) * 2 * Math.PI;
|
|
134
|
-
if (part.shape === "cylinder") {
|
|
135
|
-
const r = 1 - Math.max(0, part.taper) * ((v + 1) / 2) - Math.max(0, -part.taper) * ((1 - v) / 2);
|
|
136
|
-
points.push([Math.cos(t) * sx * r, v * sy, Math.sin(t) * sz * r]);
|
|
137
|
-
} else if (part.shape === "torus" || part.shape === "frame") {
|
|
138
|
-
points.push([Math.cos(t) * sx, Math.sin(t) * sy, v * sz]);
|
|
139
|
-
if (part.shape === "frame") points.push([Math.sign(Math.cos(t)) * sx, Math.sign(Math.sin(t)) * sy, v * sz]);
|
|
140
|
-
} else {
|
|
141
|
-
const ring = Math.max(0, 1 - Math.abs(v) ** p) ** (1 / p);
|
|
142
|
-
const c = Math.cos(t), s = Math.sin(t);
|
|
143
|
-
const norm = (Math.abs(c) ** p + Math.abs(s) ** p) ** (1 / p);
|
|
144
|
-
points.push([(c / norm) * sx * ring * k, v * sy, (s / norm) * sz * ring * k]);
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
return points;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
121
|
function convexHull(points) {
|
|
152
122
|
const pts = points.slice().sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
|
153
123
|
const cross = (o, a, b) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
|
|
@@ -157,26 +127,19 @@ function convexHull(points) {
|
|
|
157
127
|
return lower.slice(0, -1).concat(upper.slice(0, -1));
|
|
158
128
|
}
|
|
159
129
|
|
|
160
|
-
/** Projected convex outline of every drawn part, in view metres. Frames keep their hole. */
|
|
130
|
+
/** Projected convex outline of every drawn part, in view metres. Frames keep their hole. A cutter (op) adds no area and cuts no hole: the reference mask fills enclosed holes anyway, and an intersect's parent keeps its uncut outline. */
|
|
161
131
|
export function sculptViewPolygons(recipe, { yaw = 0, elevation = 0 } = {}) {
|
|
162
|
-
return expandSculptParts(recipe).map((part) => {
|
|
163
|
-
const
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
// face's opening alone is a slightly generous stand-in.
|
|
174
|
-
const opening = convexHull(projected.filter((_, i) => i % 2 === 0));
|
|
175
|
-
hole = opening.length >= 3 ? opening : null;
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
return { id: part.id, polygon, hole };
|
|
179
|
-
});
|
|
132
|
+
return expandSculptParts(recipe).filter((part) => !part.op).map((part) => {
|
|
133
|
+
const project = (point) => viewProject(applyMatrix(part.matrix, point), yaw, elevation);
|
|
134
|
+
const spec = SCULPT_SHAPE_SPECS[part.shape];
|
|
135
|
+
// See-through only where the front and back openings overlap; the front
|
|
136
|
+
// face's opening alone is a slightly generous stand-in.
|
|
137
|
+
const opening = spec.hole?.(part);
|
|
138
|
+
const holeHull = opening ? convexHull(opening.map(project)) : [];
|
|
139
|
+
const hole = holeHull.length >= 3 ? holeHull : null;
|
|
140
|
+
// One polygon per sample group, all carrying the part's id.
|
|
141
|
+
return spec.samples(part).map((group) => ({ id: part.id, polygon: convexHull(group.map(project)), hole }));
|
|
142
|
+
}).flat();
|
|
180
143
|
}
|
|
181
144
|
|
|
182
145
|
function insidePolygon(polygon, x, y) {
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sculpt booleans: the cutters a recipe declares (`op`, sculpt-cutters.js) run
|
|
3
|
+
* on their parent part's geometry with three-bvh-csg.
|
|
4
|
+
*
|
|
5
|
+
* The cut happens in the parent's own frame. The cutter's geometry is moved by
|
|
6
|
+
* inverse(parent) × cutter, both object-frame matrices from expandSculptParts,
|
|
7
|
+
* so the result drops straight back into the parent's mesh, and the mesh keeps
|
|
8
|
+
* its transform, name, shadows and material. A cut only removes (subtract) or
|
|
9
|
+
* keeps less (intersect), so the stored standing box stays an honest bound.
|
|
10
|
+
*
|
|
11
|
+
* A boolean that throws, yields non-finite points or leaves nothing is skipped
|
|
12
|
+
* with a warning. The part is then drawn as it was before that cut instead of
|
|
13
|
+
* vanishing or breaking the build, and the same recipe always skips the same
|
|
14
|
+
* cut.
|
|
15
|
+
*/
|
|
16
|
+
import * as THREE from "three";
|
|
17
|
+
import { Brush, Evaluator, HalfEdgeMap, INTERSECTION, SUBTRACTION } from "three-bvh-csg";
|
|
18
|
+
import { MeshBVH } from "three-mesh-bvh";
|
|
19
|
+
|
|
20
|
+
const OPERATIONS = { subtract: SUBTRACTION, intersect: INTERSECTION };
|
|
21
|
+
/** Carried through a cut when both sides have them at the same item size. The
|
|
22
|
+
* evaluator throws on an attribute one side lacks, so the rest are dropped. */
|
|
23
|
+
const CARRIED = ["position", "normal", "uv", "color"];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* three-bvh-csg 0.0.18 prepares a brush with the BVH option `maxLeafSize`, and
|
|
27
|
+
* three-mesh-bvh 0.9 warns about it on every build: two warnings per cut that
|
|
28
|
+
* would bury a real one. This is the same preparation with the current option.
|
|
29
|
+
* A sculpt brush is prepared once and evaluated synchronously, so it also
|
|
30
|
+
* skips the SharedArrayBuffer copy that only the worker path needs.
|
|
31
|
+
*/
|
|
32
|
+
class SculptBrush extends Brush {
|
|
33
|
+
prepareGeometry() {
|
|
34
|
+
const { geometry } = this;
|
|
35
|
+
geometry.boundsTree = new MeshBVH(geometry, { targetLeafSize: 3, indirect: true });
|
|
36
|
+
geometry.halfEdges = new HalfEdgeMap();
|
|
37
|
+
geometry.halfEdges.updateFrom(geometry);
|
|
38
|
+
// Every triangle in group 0: the evaluator runs with useGroups off.
|
|
39
|
+
geometry.groupIndices = new Uint16Array((geometry.index ?? geometry.getAttribute("position")).count / 3);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A parent with vertex colours (a gradient look) would lose them against a
|
|
44
|
+
* cutter without any, so the cutter borrows their mean. The parent keeps its
|
|
45
|
+
* colours, and the cut faces take its average tone. */
|
|
46
|
+
function lendColours(geometry, cutter) {
|
|
47
|
+
const colour = geometry.getAttribute("color");
|
|
48
|
+
if (!colour || cutter.hasAttribute("color")) return;
|
|
49
|
+
const { count, itemSize } = colour;
|
|
50
|
+
const mean = Array.from({ length: itemSize }, (_, k) => {
|
|
51
|
+
let sum = 0;
|
|
52
|
+
for (let i = 0; i < count; i += 1) sum += colour.getComponent(i, k);
|
|
53
|
+
return sum / count;
|
|
54
|
+
});
|
|
55
|
+
const lent = new THREE.BufferAttribute(new colour.array.constructor(cutter.getAttribute("position").count * itemSize), itemSize, colour.normalized);
|
|
56
|
+
for (let i = 0; i < lent.count; i += 1) for (let k = 0; k < itemSize; k += 1) lent.setComponent(i, k, mean[k]);
|
|
57
|
+
cutter.setAttribute("color", lent);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** `geometry` cut by `cutter` (already in the same frame), as a new geometry. Throws when the cut fails. */
|
|
61
|
+
function evaluate(geometry, cutter, operation) {
|
|
62
|
+
lendColours(geometry, cutter);
|
|
63
|
+
const evaluator = new Evaluator();
|
|
64
|
+
evaluator.useGroups = false;
|
|
65
|
+
// Constrained Delaunay clipping leaves far fewer T-junctions than the legacy
|
|
66
|
+
// splitter and handles coplanar faces, such as a cutter exactly as deep as its wall.
|
|
67
|
+
evaluator.useCDTClipping = true;
|
|
68
|
+
evaluator.attributes = CARRIED.filter((name) => geometry.hasAttribute(name) && geometry.getAttribute(name).itemSize === cutter.getAttribute(name)?.itemSize);
|
|
69
|
+
const a = new SculptBrush(geometry), b = new SculptBrush(cutter);
|
|
70
|
+
try {
|
|
71
|
+
a.updateMatrixWorld();
|
|
72
|
+
b.updateMatrixWorld();
|
|
73
|
+
const result = evaluator.evaluate(a, b, operation).geometry;
|
|
74
|
+
const points = result.getAttribute("position")?.array ?? [];
|
|
75
|
+
const reason = result.drawRange.count < 3 ? "it leaves nothing" : points.every(Number.isFinite) ? null : "it yields non-finite points";
|
|
76
|
+
if (reason) {
|
|
77
|
+
result.dispose();
|
|
78
|
+
throw new Error(reason);
|
|
79
|
+
}
|
|
80
|
+
if (!result.hasAttribute("normal")) result.computeVertexNormals();
|
|
81
|
+
return result;
|
|
82
|
+
} finally {
|
|
83
|
+
a.disposeCacheData();
|
|
84
|
+
b.disposeCacheData();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* `geometry` (built for `part`, in its own frame) cut by every cutter whose
|
|
90
|
+
* parent is `part`, in recipe order. `parts` is expandSculptParts' list and
|
|
91
|
+
* `build(cutter)` makes a cutter's geometry. Whatever the cuts replace is
|
|
92
|
+
* disposed here; the geometry returned belongs to the caller.
|
|
93
|
+
*/
|
|
94
|
+
export function cutSculptGeometry(geometry, part, parts, build) {
|
|
95
|
+
let cut = geometry;
|
|
96
|
+
let parentInverse = null;
|
|
97
|
+
for (const cutter of parts) {
|
|
98
|
+
if (!cutter.op || cutter.parent !== part.id) continue;
|
|
99
|
+
parentInverse ??= new THREE.Matrix4().fromArray(part.matrix).invert();
|
|
100
|
+
const tool = build(cutter).applyMatrix4(new THREE.Matrix4().fromArray(cutter.matrix).premultiply(parentInverse));
|
|
101
|
+
try {
|
|
102
|
+
const next = evaluate(cut, tool, OPERATIONS[cutter.op]);
|
|
103
|
+
cut.dispose();
|
|
104
|
+
cut = next;
|
|
105
|
+
} catch (error) {
|
|
106
|
+
console.warn(`sculpt: skipped ${cutter.op} "${cutter.id}" on "${part.id}" (${error.message}); the part is drawn without that cut`);
|
|
107
|
+
} finally {
|
|
108
|
+
tool.dispose();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return cut;
|
|
112
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Curve and outline math for the sculpt shapes that follow points (lathe,
|
|
3
|
+
* tube, extrude). Pure: no three.js. The recipe reader, the silhouette compare
|
|
4
|
+
* and the geometry builder all sample through here, so the drawn shape and the
|
|
5
|
+
* scored shape cannot drift apart.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const EPSILON = 1e-9;
|
|
9
|
+
|
|
10
|
+
/** Fritsch–Butland slope at a knot: zero where the axis turns, otherwise the
|
|
11
|
+
* harmonic mean of the two secants, which is never more than twice either. */
|
|
12
|
+
function knotSlope(before, after) {
|
|
13
|
+
if (before === null) return after;
|
|
14
|
+
if (after === null) return before;
|
|
15
|
+
return before * after <= 0 ? 0 : (2 * before * after) / (before + after);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A smooth curve through `points` (arrays of any length), `steps` samples per
|
|
20
|
+
* segment. Each axis is a monotone cubic: it passes through every point and,
|
|
21
|
+
* between two points, never leaves the box they span. So a profile with r <= 1
|
|
22
|
+
* stays <= 1 and a path inside its box stays inside, with no clamping. A
|
|
23
|
+
* Catmull-Rom spline overshoots instead. `closed` joins the last point back to
|
|
24
|
+
* the first and returns no repeated end sample.
|
|
25
|
+
*/
|
|
26
|
+
export function monotoneCurve(points, steps, closed = false) {
|
|
27
|
+
const n = points.length;
|
|
28
|
+
const at = (i) => points[closed ? (i + n) % n : i];
|
|
29
|
+
const tangents = points.map((point, k) => point.map((value, axis) => knotSlope(
|
|
30
|
+
closed || k > 0 ? value - at(k - 1)[axis] : null,
|
|
31
|
+
closed || k < n - 1 ? at(k + 1)[axis] - value : null,
|
|
32
|
+
)));
|
|
33
|
+
const out = [];
|
|
34
|
+
for (let segment = 0; segment < (closed ? n : n - 1); segment += 1) {
|
|
35
|
+
const a = at(segment), b = at(segment + 1), ta = tangents[segment], tb = tangents[(segment + 1) % n];
|
|
36
|
+
for (let i = 0; i < steps; i += 1) {
|
|
37
|
+
const t = i / steps, t2 = t * t, t3 = t2 * t;
|
|
38
|
+
const h00 = 2 * t3 - 3 * t2 + 1, h10 = t3 - 2 * t2 + t, h01 = 3 * t2 - 2 * t3, h11 = t3 - t2;
|
|
39
|
+
out.push(a.map((value, axis) => h00 * value + h10 * ta[axis] + h01 * b[axis] + h11 * tb[axis]));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (!closed) out.push([...points[n - 1]]);
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Signed area: positive when the loop runs counter-clockwise. */
|
|
47
|
+
export function signedArea(loop) {
|
|
48
|
+
let sum = 0;
|
|
49
|
+
for (let i = 0, j = loop.length - 1; i < loop.length; j = i, i += 1) sum += loop[j][0] * loop[i][1] - loop[i][0] * loop[j][1];
|
|
50
|
+
return sum / 2;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const cross = (o, a, b) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
|
|
54
|
+
const side = (o, a, b) => {
|
|
55
|
+
const value = cross(o, a, b);
|
|
56
|
+
return Math.abs(value) <= EPSILON ? 0 : Math.sign(value);
|
|
57
|
+
};
|
|
58
|
+
const within = (a, b, p) => Math.min(a[0], b[0]) - EPSILON <= p[0] && p[0] <= Math.max(a[0], b[0]) + EPSILON
|
|
59
|
+
&& Math.min(a[1], b[1]) - EPSILON <= p[1] && p[1] <= Math.max(a[1], b[1]) + EPSILON;
|
|
60
|
+
|
|
61
|
+
function segmentsTouch(a, b, c, d) {
|
|
62
|
+
const o1 = side(a, b, c), o2 = side(a, b, d), o3 = side(c, d, a), o4 = side(c, d, b);
|
|
63
|
+
if (o1 !== o2 && o3 !== o4) return true;
|
|
64
|
+
return (o1 === 0 && within(a, b, c)) || (o2 === 0 && within(a, b, d)) || (o3 === 0 && within(c, d, a)) || (o4 === 0 && within(c, d, b));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The first two edges that touch or cross, across every loop, as
|
|
69
|
+
* `{ a: { loop, edge }, b: { loop, edge } }`, or null when the loops are simple
|
|
70
|
+
* and apart. Neighbouring edges of one loop share a point by design; they only
|
|
71
|
+
* count when the second folds back along the first.
|
|
72
|
+
*/
|
|
73
|
+
export function loopCrossing(loops) {
|
|
74
|
+
const edges = loops.flatMap((loop, index) => loop.map((p, edge) => ({ loop: index, edge, n: loop.length, a: p, b: loop[(edge + 1) % loop.length] })));
|
|
75
|
+
for (let i = 0; i < edges.length; i += 1) {
|
|
76
|
+
for (let j = i + 1; j < edges.length; j += 1) {
|
|
77
|
+
const e = edges[i], f = edges[j];
|
|
78
|
+
const follows = e.loop === f.loop && (e.edge + 1) % e.n === f.edge;
|
|
79
|
+
const precedes = e.loop === f.loop && (f.edge + 1) % f.n === e.edge;
|
|
80
|
+
let touch;
|
|
81
|
+
if (follows || precedes) {
|
|
82
|
+
const [shared, p, q] = follows ? [e.b, e.a, f.b] : [e.a, e.b, f.a];
|
|
83
|
+
touch = side(shared, p, q) === 0 && (p[0] - shared[0]) * (q[0] - shared[0]) + (p[1] - shared[1]) * (q[1] - shared[1]) > 0;
|
|
84
|
+
} else touch = segmentsTouch(e.a, e.b, f.a, f.b);
|
|
85
|
+
if (touch) return { a: { loop: e.loop, edge: e.edge }, b: { loop: f.loop, edge: f.edge } };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Even-odd test of a point against a closed loop. */
|
|
92
|
+
export function insideLoop([x, y], loop) {
|
|
93
|
+
let inside = false;
|
|
94
|
+
for (let i = 0, j = loop.length - 1; i < loop.length; j = i, i += 1) {
|
|
95
|
+
const [xi, yi] = loop[i], [xj, yj] = loop[j];
|
|
96
|
+
if ((yi > y) !== (yj > y) && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside;
|
|
97
|
+
}
|
|
98
|
+
return inside;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Ear clipping: index triples covering a simple loop of either winding. n is
|
|
103
|
+
* at most 64, so the quadratic search is cheap. A loop left with only
|
|
104
|
+
* collinear corners is clipped anyway, so the loop always ends.
|
|
105
|
+
*/
|
|
106
|
+
export function earClip(loop) {
|
|
107
|
+
const ring = loop.map((_, index) => index);
|
|
108
|
+
if (signedArea(loop) < 0) ring.reverse();
|
|
109
|
+
const triangles = [];
|
|
110
|
+
while (ring.length > 3) {
|
|
111
|
+
const corner = (i) => [ring[(i + ring.length - 1) % ring.length], ring[i], ring[(i + 1) % ring.length]];
|
|
112
|
+
let cut = ring.findIndex((_, i) => {
|
|
113
|
+
const [a, b, c] = corner(i).map((k) => loop[k]);
|
|
114
|
+
if (cross(a, b, c) <= EPSILON) return false;
|
|
115
|
+
return !ring.some((k) => !corner(i).includes(k) && cross(a, b, loop[k]) >= 0 && cross(b, c, loop[k]) >= 0 && cross(c, a, loop[k]) >= 0);
|
|
116
|
+
});
|
|
117
|
+
if (cut < 0) cut = 0;
|
|
118
|
+
triangles.push(corner(cut));
|
|
119
|
+
ring.splice(cut, 1);
|
|
120
|
+
}
|
|
121
|
+
triangles.push(ring);
|
|
122
|
+
return triangles;
|
|
123
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sculpt cutters: a part with `op` is never drawn. It cuts its `parent` part,
|
|
3
|
+
* which is how a recipe gets windows, doors and arches in a wall, a hollow
|
|
4
|
+
* bowl or cup, or a notch in a plank (subtract), and a half-dome or a sliced
|
|
5
|
+
* fruit (intersect). This module is the pure half: it reads `op` and refuses
|
|
6
|
+
* the recipes a cutter cannot serve. sculpt-csg.js runs the boolean on the
|
|
7
|
+
* parent's geometry. Pure: no three.js.
|
|
8
|
+
*
|
|
9
|
+
* Several cutters on one parent apply in recipe order, and a mirrored cutter
|
|
10
|
+
* cuts both sides. A cutter's colour is never seen: the cut faces wear the
|
|
11
|
+
* parent's look. Each rule below is refused with its path, never bent into a
|
|
12
|
+
* different shape:
|
|
13
|
+
* - a cutter needs a parent, because a cutter at the object level would have
|
|
14
|
+
* nothing to cut;
|
|
15
|
+
* - a cutter carries no parts. A drawn child would ride a frame nobody sees,
|
|
16
|
+
* and a cutter that cuts another cutter is not supported, so two cutters
|
|
17
|
+
* that build one opening (an arched door) both cut the wall.
|
|
18
|
+
*/
|
|
19
|
+
import { RecipeError } from "./sculpt-recipe-read.js";
|
|
20
|
+
|
|
21
|
+
const SCULPT_OPS = ["subtract", "intersect"];
|
|
22
|
+
|
|
23
|
+
export const SCULPT_OP_FIELD = Object.freeze({
|
|
24
|
+
key: "op",
|
|
25
|
+
read(raw, part, where) {
|
|
26
|
+
if (!SCULPT_OPS.includes(raw.op)) throw new RecipeError(`${where}.op`, `must be "subtract" or "intersect", got ${JSON.stringify(raw.op)}`);
|
|
27
|
+
if (part.parent === undefined) throw new RecipeError(`${where}.op`, "a cutter needs a parent: the part it cuts");
|
|
28
|
+
part.op = raw.op;
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/** Refuses any part that names a cutter as its parent. Run it once parents resolve. */
|
|
33
|
+
export function checkSculptCutters(parts) {
|
|
34
|
+
const byId = new Map(parts.map((part) => [part.id, part]));
|
|
35
|
+
for (const part of parts) {
|
|
36
|
+
const parent = byId.get(part.parent);
|
|
37
|
+
if (part.op && parent?.shape === "scatter") throw new RecipeError(`parts[${part.id}].op`, `"${parent.id}" is a scatter, which is drawn as instances and cannot be cut`);
|
|
38
|
+
if (!parent?.op) continue;
|
|
39
|
+
throw new RecipeError(`parts[${part.id}].parent`, part.op
|
|
40
|
+
? `"${parent.id}" is a cutter, and a cutter only cuts drawn parts; give this one parent "${parent.parent}" so both cut it`
|
|
41
|
+
: `"${parent.id}" is a cutter (op), which is not drawn and cannot carry parts; use parent "${parent.parent}" instead`);
|
|
42
|
+
}
|
|
43
|
+
}
|