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,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Surface detail: a part's `detail` (sculpt-surface-fields.js) turns a smooth
|
|
3
|
+
* primitive into a lumpy cloud, bush, rock or loaf by pushing its surface out
|
|
4
|
+
* along the normals with seeded, billowed fBm noise (|fBm|: rounded lumps with
|
|
5
|
+
* creases between them, which reads as puffs or stone where plain fBm reads as
|
|
6
|
+
* a dented ball). It is a post-process on any part's
|
|
7
|
+
* BufferGeometry, so a shape added later gets it without a line of its own.
|
|
8
|
+
*
|
|
9
|
+
* - Bounds: the base is shrunk by the bump height first and no vertex then
|
|
10
|
+
* moves further than that, so the lumps end inside the declared size and
|
|
11
|
+
* the record's measured box (which trusts size) still holds.
|
|
12
|
+
* - Seams: vertices are welded by position before anything moves, so both
|
|
13
|
+
* sides of a sphere's seam (or a box's edge) move together and never split.
|
|
14
|
+
* - Tessellation: a box or a one-segment cylinder has too few vertices to
|
|
15
|
+
* show a bump, so long edges are halved until they are a fraction of a bump.
|
|
16
|
+
* The split test reads only the edge, so both triangles on an edge agree
|
|
17
|
+
* and no T-junction cracks open once the surface moves.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import * as THREE from "three";
|
|
21
|
+
import { DETAIL_RELIEF } from "./sculpt-surface-fields.js";
|
|
22
|
+
import { fbm3, isMirrorCopy, partSeed, seededRandom } from "./sculpt-noise.js";
|
|
23
|
+
|
|
24
|
+
/** A detailed part stops splitting edges past this many triangles. */
|
|
25
|
+
const MAX_TRIANGLES = 40000;
|
|
26
|
+
/** Edges are split until a bump spans about this many of them. */
|
|
27
|
+
const EDGES_PER_BUMP = 5;
|
|
28
|
+
/** Faces meeting more sharply than this stay creased (a box edge); lumps shade smooth. */
|
|
29
|
+
const CREASE_COS = Math.cos((65 * Math.PI) / 180);
|
|
30
|
+
|
|
31
|
+
/** Vertex → id shared by every vertex at the same position (within `eps`), via
|
|
32
|
+
* an open-addressed table on the quantized position. */
|
|
33
|
+
function weld(position, eps) {
|
|
34
|
+
const count = position.length / 3;
|
|
35
|
+
const ids = new Int32Array(count);
|
|
36
|
+
let size = 1;
|
|
37
|
+
while (size < count * 2) size <<= 1;
|
|
38
|
+
const table = new Int32Array(size).fill(-1);
|
|
39
|
+
const keys = new Int32Array(count * 3);
|
|
40
|
+
let unique = 0;
|
|
41
|
+
for (let i = 0; i < count; i += 1) {
|
|
42
|
+
const qx = Math.round(position[3 * i] / eps), qy = Math.round(position[3 * i + 1] / eps), qz = Math.round(position[3 * i + 2] / eps);
|
|
43
|
+
let slot = (Math.imul(qx, 73856093) ^ Math.imul(qy, 19349663) ^ Math.imul(qz, 83492791)) & (size - 1);
|
|
44
|
+
for (;;) {
|
|
45
|
+
const id = table[slot];
|
|
46
|
+
if (id < 0) {
|
|
47
|
+
table[slot] = unique;
|
|
48
|
+
keys[3 * unique] = qx; keys[3 * unique + 1] = qy; keys[3 * unique + 2] = qz;
|
|
49
|
+
ids[i] = unique++;
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
if (keys[3 * id] === qx && keys[3 * id + 1] === qy && keys[3 * id + 2] === qz) {
|
|
53
|
+
ids[i] = id;
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
slot = (slot + 1) & (size - 1);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return { ids, count: unique };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Local corners per split mask (bit 0 = edge ab, 1 = bc, 2 = ca); 3, 4, 5 are
|
|
63
|
+
* the midpoints of ab, bc, ca. Every pattern keeps the source winding. */
|
|
64
|
+
const PATTERNS = [
|
|
65
|
+
[0, 1, 2],
|
|
66
|
+
[0, 3, 2, 3, 1, 2],
|
|
67
|
+
[1, 4, 0, 4, 2, 0],
|
|
68
|
+
[3, 1, 4, 0, 3, 4, 0, 4, 2],
|
|
69
|
+
[2, 5, 1, 5, 0, 1],
|
|
70
|
+
[5, 0, 3, 2, 5, 3, 2, 3, 1],
|
|
71
|
+
[4, 2, 5, 1, 4, 5, 1, 5, 0],
|
|
72
|
+
[0, 3, 5, 3, 1, 4, 5, 4, 2, 3, 4, 5],
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
/** Non-indexed attributes ([position, ...others]) with every edge longer
|
|
76
|
+
* than `maxEdge` halved, pass by pass, within MAX_TRIANGLES. */
|
|
77
|
+
function subdivide(attributes, maxEdge) {
|
|
78
|
+
const limit = maxEdge * maxEdge;
|
|
79
|
+
for (let pass = 0; pass < 10; pass += 1) {
|
|
80
|
+
const position = attributes[0].array;
|
|
81
|
+
const triangles = position.length / 9;
|
|
82
|
+
const masks = new Uint8Array(triangles);
|
|
83
|
+
let next = 0;
|
|
84
|
+
for (let t = 0; t < triangles; t += 1) {
|
|
85
|
+
for (let e = 0; e < 3; e += 1) {
|
|
86
|
+
const a = 9 * t + 3 * e, b = 9 * t + 3 * ((e + 1) % 3);
|
|
87
|
+
const dx = position[a] - position[b], dy = position[a + 1] - position[b + 1], dz = position[a + 2] - position[b + 2];
|
|
88
|
+
if (dx * dx + dy * dy + dz * dz > limit) masks[t] |= 1 << e;
|
|
89
|
+
}
|
|
90
|
+
next += PATTERNS[masks[t]].length / 3;
|
|
91
|
+
}
|
|
92
|
+
if (next === triangles || next > MAX_TRIANGLES) break;
|
|
93
|
+
attributes = attributes.map(({ name, size, array }) => {
|
|
94
|
+
const out = new Float64Array(next * 3 * size);
|
|
95
|
+
let o = 0;
|
|
96
|
+
for (let t = 0; t < triangles; t += 1) {
|
|
97
|
+
const base = 3 * t * size;
|
|
98
|
+
for (const corner of PATTERNS[masks[t]]) {
|
|
99
|
+
// (a + b) / 2 is the same bits from either side, so neighbours share the midpoint exactly.
|
|
100
|
+
const a = corner < 3 ? corner : corner - 3, b = corner < 3 ? corner : (corner - 2) % 3;
|
|
101
|
+
for (let k = 0; k < size; k += 1) out[o++] = (array[base + a * size + k] + array[base + b * size + k]) / 2;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return { name, size, array: out };
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
return attributes;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Area-weighted (unnormalized) normal of every triangle. */
|
|
111
|
+
function faceNormals(position) {
|
|
112
|
+
const normals = new Float64Array(position.length / 3);
|
|
113
|
+
for (let f = 0; f < position.length; f += 9) {
|
|
114
|
+
const ax = position[f + 3] - position[f], ay = position[f + 4] - position[f + 1], az = position[f + 5] - position[f + 2];
|
|
115
|
+
const bx = position[f + 6] - position[f], by = position[f + 7] - position[f + 1], bz = position[f + 8] - position[f + 2];
|
|
116
|
+
normals[f / 3] = ay * bz - az * by;
|
|
117
|
+
normals[f / 3 + 1] = az * bx - ax * bz;
|
|
118
|
+
normals[f / 3 + 2] = ax * by - ay * bx;
|
|
119
|
+
}
|
|
120
|
+
return normals;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Per-vertex normals: the faces around the welded vertex that meet the
|
|
124
|
+
* vertex's own face within the crease angle, area-weighted. */
|
|
125
|
+
function creasedNormals(position, ids, count) {
|
|
126
|
+
const faces = faceNormals(position);
|
|
127
|
+
const unit = faces.map((value, i) => value / (Math.hypot(faces[i - (i % 3)], faces[i - (i % 3) + 1], faces[i - (i % 3) + 2]) || 1));
|
|
128
|
+
const start = new Int32Array(count + 1);
|
|
129
|
+
for (const id of ids) start[id + 1] += 1;
|
|
130
|
+
for (let id = 0; id < count; id += 1) start[id + 1] += start[id];
|
|
131
|
+
const fill = start.slice(0, count), around = new Int32Array(ids.length);
|
|
132
|
+
ids.forEach((id, v) => { around[fill[id]++] = Math.floor(v / 3); });
|
|
133
|
+
const normals = new Float32Array(position.length);
|
|
134
|
+
for (let v = 0; v < ids.length; v += 1) {
|
|
135
|
+
const own = 3 * Math.floor(v / 3);
|
|
136
|
+
const flat = unit[own] === 0 && unit[own + 1] === 0 && unit[own + 2] === 0;
|
|
137
|
+
let x = 0, y = 0, z = 0;
|
|
138
|
+
for (let k = start[ids[v]]; k < start[ids[v] + 1]; k += 1) {
|
|
139
|
+
const g = 3 * around[k];
|
|
140
|
+
if (!flat && unit[g] * unit[own] + unit[g + 1] * unit[own + 1] + unit[g + 2] * unit[own + 2] < CREASE_COS) continue;
|
|
141
|
+
x += faces[g]; y += faces[g + 1]; z += faces[g + 2];
|
|
142
|
+
}
|
|
143
|
+
const length = Math.hypot(x, y, z);
|
|
144
|
+
if (length > 0) normals.set([x / length, y / length, z / length], 3 * v);
|
|
145
|
+
else normals[3 * v + 1] = 1;
|
|
146
|
+
}
|
|
147
|
+
return normals;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The part's geometry with its `detail` applied, or the geometry untouched
|
|
152
|
+
* when the part has none. The result is non-indexed and keeps every other
|
|
153
|
+
* attribute (uv, colour), interpolated where edges were split.
|
|
154
|
+
*/
|
|
155
|
+
export function applySculptDetail(geometry, part) {
|
|
156
|
+
const amount = part.detail?.amount ?? 0;
|
|
157
|
+
if (!(amount > 0)) return geometry;
|
|
158
|
+
const half = part.size.map((value) => value / 2);
|
|
159
|
+
const longest = Math.max(...part.size);
|
|
160
|
+
const relief = amount * DETAIL_RELIEF * Math.min(...half);
|
|
161
|
+
geometry.scale(...half.map((h) => (h - relief) / h));
|
|
162
|
+
const source = geometry.index ? geometry.toNonIndexed() : geometry;
|
|
163
|
+
const names = ["position", ...Object.keys(source.attributes).filter((name) => name !== "position" && name !== "normal")];
|
|
164
|
+
let attributes = names.map((name) => ({ name, size: source.attributes[name].itemSize, array: Float64Array.from(source.attributes[name].array) }));
|
|
165
|
+
geometry.dispose();
|
|
166
|
+
source.dispose();
|
|
167
|
+
// Snap every vertex onto its welded twin so split decisions and midpoints match bit for bit.
|
|
168
|
+
const eps = longest * 1e-5;
|
|
169
|
+
let { ids, count } = weld(attributes[0].array, eps);
|
|
170
|
+
const first = new Int32Array(count).fill(-1);
|
|
171
|
+
ids.forEach((id, v) => {
|
|
172
|
+
if (first[id] < 0) first[id] = v;
|
|
173
|
+
else for (let k = 0; k < 3; k += 1) attributes[0].array[3 * v + k] = attributes[0].array[3 * first[id] + k];
|
|
174
|
+
});
|
|
175
|
+
attributes = subdivide(attributes, longest / part.detail.scale / EDGES_PER_BUMP);
|
|
176
|
+
const position = attributes[0].array;
|
|
177
|
+
({ ids, count } = weld(position, eps));
|
|
178
|
+
const faces = faceNormals(position);
|
|
179
|
+
const normal = new Float64Array(count * 3);
|
|
180
|
+
ids.forEach((id, v) => {
|
|
181
|
+
const f = 3 * Math.floor(v / 3);
|
|
182
|
+
for (let k = 0; k < 3; k += 1) normal[3 * id + k] += faces[f + k];
|
|
183
|
+
});
|
|
184
|
+
// Noise per welded vertex. A mirrored copy reads it at −x, so it lumps as its source's reflection.
|
|
185
|
+
const seed = partSeed(part, part.detail.seed);
|
|
186
|
+
const random = seededRandom(seed);
|
|
187
|
+
const offset = [random() * 64, random() * 64, random() * 64];
|
|
188
|
+
const frequency = part.detail.scale / longest, flip = isMirrorCopy(part) ? -1 : 1;
|
|
189
|
+
const lift = new Float64Array(count).fill(NaN);
|
|
190
|
+
ids.forEach((id, v) => {
|
|
191
|
+
if (Number.isNaN(lift[id])) lift[id] = Math.abs(fbm3(flip * position[3 * v] * frequency + offset[0], position[3 * v + 1] * frequency + offset[1], position[3 * v + 2] * frequency + offset[2], seed));
|
|
192
|
+
});
|
|
193
|
+
let low = Infinity, high = -Infinity;
|
|
194
|
+
for (const value of lift) { low = Math.min(low, value); high = Math.max(high, value); }
|
|
195
|
+
// Rescaled to 0..1, so the highest lump reaches the declared size and the deepest stays on the shrunk base.
|
|
196
|
+
const span = high - low || 1;
|
|
197
|
+
ids.forEach((id, v) => {
|
|
198
|
+
const n = 3 * id, length = Math.hypot(normal[n], normal[n + 1], normal[n + 2]);
|
|
199
|
+
if (!(length > 0)) return;
|
|
200
|
+
const push = (relief * (lift[id] - low)) / span / length;
|
|
201
|
+
for (let k = 0; k < 3; k += 1) position[3 * v + k] += normal[n + k] * push;
|
|
202
|
+
});
|
|
203
|
+
const detailed = new THREE.BufferGeometry();
|
|
204
|
+
for (const { name, size, array } of attributes) detailed.setAttribute(name, new THREE.BufferAttribute(Float32Array.from(array), size));
|
|
205
|
+
detailed.setAttribute("normal", new THREE.BufferAttribute(creasedNormals(position, ids, count), 3));
|
|
206
|
+
return detailed;
|
|
207
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Geometry for the `loft` and `convex` sculpt shapes. The points come from the
|
|
3
|
+
* pure modules (sculpt-loft-rings.js, sculpt-shapes-loft-convex.js), so the
|
|
4
|
+
* silhouette compare hulls exactly what is drawn; three.js only skins them.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as THREE from "three";
|
|
8
|
+
import { ConvexGeometry } from "three/examples/jsm/geometries/ConvexGeometry.js";
|
|
9
|
+
import { LoftGeometry } from "three/examples/jsm/geometries/LoftGeometry.js";
|
|
10
|
+
import { loftRings } from "./sculpt-loft-rings.js";
|
|
11
|
+
import { convexPoints } from "./sculpt-shapes-loft-convex.js";
|
|
12
|
+
|
|
13
|
+
/** An unsmoothed loft keeps edges sharper than this crisp; a rounded corner's
|
|
14
|
+
* small steps still shade as one curve. */
|
|
15
|
+
const LOFT_CREASE = (40 * Math.PI) / 180;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Normals for a non-indexed mesh: each corner averages the area-weighted
|
|
19
|
+
* normals of the faces that share its exact position and turn less than
|
|
20
|
+
* `crease` from its own face. (BufferGeometryUtils.toCreasedNormals hashes
|
|
21
|
+
* positions to 1 cm, which merges the corners of a small gem.)
|
|
22
|
+
*/
|
|
23
|
+
function creaseNormals(geometry, crease) {
|
|
24
|
+
const position = geometry.getAttribute("position");
|
|
25
|
+
const key = (i) => `${position.getX(i)},${position.getY(i)},${position.getZ(i)}`;
|
|
26
|
+
const a = new THREE.Vector3(), b = new THREE.Vector3(), c = new THREE.Vector3();
|
|
27
|
+
const faceNormals = [];
|
|
28
|
+
const facesAt = new Map();
|
|
29
|
+
for (let f = 0; f < position.count / 3; f += 1) {
|
|
30
|
+
a.fromBufferAttribute(position, 3 * f);
|
|
31
|
+
b.fromBufferAttribute(position, 3 * f + 1);
|
|
32
|
+
c.fromBufferAttribute(position, 3 * f + 2);
|
|
33
|
+
faceNormals.push(new THREE.Vector3().subVectors(c, b).cross(a.sub(b)));
|
|
34
|
+
for (let k = 0; k < 3; k += 1) {
|
|
35
|
+
const at = key(3 * f + k);
|
|
36
|
+
if (!facesAt.has(at)) facesAt.set(at, []);
|
|
37
|
+
facesAt.get(at).push(f);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const limit = Math.cos(crease);
|
|
41
|
+
const previous = geometry.getAttribute("normal");
|
|
42
|
+
const normals = new Float32Array(position.count * 3);
|
|
43
|
+
const own = new THREE.Vector3(), other = new THREE.Vector3(), sum = new THREE.Vector3();
|
|
44
|
+
for (let i = 0; i < position.count; i += 1) {
|
|
45
|
+
own.copy(faceNormals[Math.floor(i / 3)]).normalize();
|
|
46
|
+
sum.set(0, 0, 0);
|
|
47
|
+
for (const f of facesAt.get(key(i))) if (other.copy(faceNormals[f]).normalize().dot(own) >= limit) sum.add(faceNormals[f]);
|
|
48
|
+
// A zero-area face has no direction of its own: it keeps the one it had.
|
|
49
|
+
if (sum.lengthSq() === 0) sum.fromBufferAttribute(previous, i);
|
|
50
|
+
sum.normalize().toArray(normals, 3 * i);
|
|
51
|
+
}
|
|
52
|
+
geometry.setAttribute("normal", new THREE.BufferAttribute(normals, 3));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Box-projected uvs per triangle: each facet maps the two axes it faces
|
|
56
|
+
* most squarely, scaled by size, so a facet's texture is never smeared. */
|
|
57
|
+
function boxUvs(geometry, size) {
|
|
58
|
+
const position = geometry.getAttribute("position");
|
|
59
|
+
const uvs = new Float32Array(position.count * 2);
|
|
60
|
+
const a = new THREE.Vector3(), b = new THREE.Vector3(), c = new THREE.Vector3();
|
|
61
|
+
for (let f = 0; f < position.count / 3; f += 1) {
|
|
62
|
+
a.fromBufferAttribute(position, 3 * f);
|
|
63
|
+
b.fromBufferAttribute(position, 3 * f + 1);
|
|
64
|
+
c.fromBufferAttribute(position, 3 * f + 2);
|
|
65
|
+
const n = c.sub(b).cross(a.sub(b));
|
|
66
|
+
const facing = [Math.abs(n.x), Math.abs(n.y), Math.abs(n.z)];
|
|
67
|
+
const axis = facing.indexOf(Math.max(...facing));
|
|
68
|
+
const [u, v] = [[2, 1], [0, 2], [0, 1]][axis];
|
|
69
|
+
for (let k = 0; k < 3; k += 1) {
|
|
70
|
+
uvs[(3 * f + k) * 2] = position.getComponent(3 * f + k, u) / size[u] + 0.5;
|
|
71
|
+
uvs[(3 * f + k) * 2 + 1] = position.getComponent(3 * f + k, v) / size[v] + 0.5;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
geometry.setAttribute("uv", new THREE.BufferAttribute(uvs, 2));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function loftGeometry(part) {
|
|
78
|
+
const sections = loftRings(part).rings.map((ring) => ring.map(([x, y, z]) => new THREE.Vector3(x, y, z)));
|
|
79
|
+
const geometry = new LoftGeometry(sections, { capStart: true, capEnd: true });
|
|
80
|
+
// The caps come last, one vertex per ring point each, and are level (every
|
|
81
|
+
// section is horizontal). Earcut leaves zero-area slivers between collinear
|
|
82
|
+
// samples, whose computed normals can be zero, so the caps are set outright.
|
|
83
|
+
const normal = geometry.getAttribute("normal"), first = normal.count - 2 * sections[0].length;
|
|
84
|
+
for (let i = first; i < normal.count; i += 1) normal.setXYZ(i, 0, i < first + sections[0].length ? -1 : 1, 0);
|
|
85
|
+
if (part.smooth) return geometry;
|
|
86
|
+
const faceted = geometry.toNonIndexed();
|
|
87
|
+
geometry.dispose();
|
|
88
|
+
creaseNormals(faceted, LOFT_CREASE);
|
|
89
|
+
return faceted;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** ConvexGeometry gives flat facet normals; `facets: false` smooths them all. */
|
|
93
|
+
function convexGeometry(part) {
|
|
94
|
+
const geometry = new ConvexGeometry(convexPoints(part).map(([x, y, z]) => new THREE.Vector3(x, y, z)));
|
|
95
|
+
boxUvs(geometry, part.size);
|
|
96
|
+
if (!part.facets) creaseNormals(geometry, Math.PI);
|
|
97
|
+
return geometry;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export const LOFT_CONVEX_BUILDERS = Object.freeze({ loft: loftGeometry, convex: convexGeometry });
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Geometry for the sculpt shapes that follow points (sculpt-shapes-curved.js):
|
|
3
|
+
* lathe, tube and extrude, each one indexed mesh with a uv in metres, so a
|
|
4
|
+
* texture keeps one scale across parts of different sizes.
|
|
5
|
+
*
|
|
6
|
+
* Normals are welded per piece: inside a piece, every vertex at one position
|
|
7
|
+
* shares one normal (a lathe's seam, a tube's round end); between pieces they
|
|
8
|
+
* stay apart, so a pot's bottom edge and a sign's face edge stay crisp. Like
|
|
9
|
+
* every sculpt shape, nothing is drawn outside the part's declared size.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import * as THREE from "three";
|
|
13
|
+
import { signedArea } from "./sculpt-curve.js";
|
|
14
|
+
import { latheRows, tubeCentre, tubeRadii } from "./sculpt-shapes-curved.js";
|
|
15
|
+
|
|
16
|
+
const AROUND = 48;
|
|
17
|
+
const TUBE_SIDES = 20;
|
|
18
|
+
const CAP_ROWS = 6;
|
|
19
|
+
const BEVEL_ROWS = 4;
|
|
20
|
+
/** An outline corner that turns more than this keeps a hard edge on the side wall. */
|
|
21
|
+
const CREASE = (35 * Math.PI) / 180;
|
|
22
|
+
|
|
23
|
+
const sub = (a, b) => a.map((value, axis) => value - b[axis]);
|
|
24
|
+
const dot = (a, b) => a.reduce((sum, value, axis) => sum + value * b[axis], 0);
|
|
25
|
+
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]];
|
|
26
|
+
const unit = (v) => {
|
|
27
|
+
const length = Math.hypot(...v);
|
|
28
|
+
return length > 1e-12 ? v.map((value) => value / length) : null;
|
|
29
|
+
};
|
|
30
|
+
const clamp = (value, limit) => Math.min(limit, Math.max(-limit, value));
|
|
31
|
+
|
|
32
|
+
class Pieces {
|
|
33
|
+
constructor() {
|
|
34
|
+
this.position = [];
|
|
35
|
+
this.uv = [];
|
|
36
|
+
this.index = [];
|
|
37
|
+
this.weld = [];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A rows × cols piece from `at(row, col)` → [x, y, z, u, v]. The column step
|
|
41
|
+
* crossed with the row step must point out of the solid. */
|
|
42
|
+
grid(rows, cols, at) {
|
|
43
|
+
const keys = new Map();
|
|
44
|
+
const ids = [];
|
|
45
|
+
for (let i = 0; i < rows; i += 1) for (let j = 0; j < cols; j += 1) ids.push(this.vertex(keys, at(i, j)));
|
|
46
|
+
for (let i = 0; i + 1 < rows; i += 1) {
|
|
47
|
+
for (let j = 0; j + 1 < cols; j += 1) {
|
|
48
|
+
const a = ids[i * cols + j], b = ids[i * cols + j + 1], c = ids[(i + 1) * cols + j], d = ids[(i + 1) * cols + j + 1];
|
|
49
|
+
this.index.push(a, b, c, b, d, c);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A flat piece: points [x, y, z, u, v] and triangles over them, wound outward. */
|
|
55
|
+
fan(points, triangles) {
|
|
56
|
+
const keys = new Map();
|
|
57
|
+
const ids = points.map((point) => this.vertex(keys, point));
|
|
58
|
+
for (const triangle of triangles) this.index.push(...triangle.map((k) => ids[k]));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
vertex(keys, [x, y, z, u, v]) {
|
|
62
|
+
const id = this.position.length / 3;
|
|
63
|
+
const key = `${x},${y},${z}`;
|
|
64
|
+
if (!keys.has(key)) keys.set(key, id);
|
|
65
|
+
this.weld.push(keys.get(key));
|
|
66
|
+
this.position.push(x, y, z);
|
|
67
|
+
this.uv.push(u, v);
|
|
68
|
+
return id;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Area-weighted face normals, summed on each welded position. */
|
|
72
|
+
build() {
|
|
73
|
+
const p = this.position, sum = new Float64Array(p.length);
|
|
74
|
+
for (let t = 0; t < this.index.length; t += 3) {
|
|
75
|
+
const [a, b, c] = [0, 1, 2].map((k) => this.index[t + k]);
|
|
76
|
+
const corner = (k) => [p[k * 3], p[k * 3 + 1], p[k * 3 + 2]];
|
|
77
|
+
const normal = cross(sub(corner(b), corner(a)), sub(corner(c), corner(a)));
|
|
78
|
+
for (const k of [a, b, c]) for (let axis = 0; axis < 3; axis += 1) sum[this.weld[k] * 3 + axis] += normal[axis];
|
|
79
|
+
}
|
|
80
|
+
const normals = new Float32Array(p.length);
|
|
81
|
+
this.weld.forEach((w, i) => {
|
|
82
|
+
const n = unit([sum[w * 3], sum[w * 3 + 1], sum[w * 3 + 2]]) ?? [0, 1, 0];
|
|
83
|
+
normals.set(n, i * 3);
|
|
84
|
+
});
|
|
85
|
+
const geometry = new THREE.BufferGeometry();
|
|
86
|
+
geometry.setAttribute("position", new THREE.Float32BufferAttribute(p, 3));
|
|
87
|
+
geometry.setAttribute("normal", new THREE.BufferAttribute(normals, 3));
|
|
88
|
+
geometry.setAttribute("uv", new THREE.Float32BufferAttribute(this.uv, 2));
|
|
89
|
+
geometry.setIndex(this.index);
|
|
90
|
+
return geometry;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The profile turned round y with an elliptical section; caps close the ends
|
|
95
|
+
* that stand off the axis, so a pot has a bottom. */
|
|
96
|
+
export function latheGeometry(part) {
|
|
97
|
+
const [hx, , hz] = part.size.map((value) => value / 2);
|
|
98
|
+
const rows = latheRows(part, Math.min(12, Math.ceil(120 / (part.profile.length - 1))));
|
|
99
|
+
const arc = [0];
|
|
100
|
+
for (let k = 1; k < rows.length; k += 1) arc.push(arc[k - 1] + Math.hypot(((rows[k][0] - rows[k - 1][0]) * (hx + hz)) / 2, rows[k][1] - rows[k - 1][1]));
|
|
101
|
+
// `j % AROUND` makes the seam column the very same numbers as the first, so it welds.
|
|
102
|
+
const ring = ([r, y], j) => {
|
|
103
|
+
const angle = ((j % AROUND) / AROUND) * 2 * Math.PI;
|
|
104
|
+
return [r * hx * Math.sin(angle), y, r * hz * Math.cos(angle)];
|
|
105
|
+
};
|
|
106
|
+
const mesh = new Pieces();
|
|
107
|
+
const wall = (from, to) => mesh.grid(to - from + 1, AROUND + 1, (i, j) => [...ring(rows[from + i], j), (j / AROUND) * Math.PI * (hx + hz), arc[from + i]]);
|
|
108
|
+
if (part.smooth) wall(0, rows.length - 1);
|
|
109
|
+
else for (let k = 0; k + 1 < rows.length; k += 1) wall(k, k + 1);
|
|
110
|
+
const cap = (rim, top) => {
|
|
111
|
+
const order = top ? [rim, [0, rim[1]]] : [[0, rim[1]], rim];
|
|
112
|
+
mesh.grid(2, AROUND + 1, (i, j) => {
|
|
113
|
+
const point = ring(order[i], j);
|
|
114
|
+
return [...point, point[0], point[2]];
|
|
115
|
+
});
|
|
116
|
+
};
|
|
117
|
+
if (rows[0][0] > 0) cap(rows[0], false);
|
|
118
|
+
if (rows.at(-1)[0] > 0) cap(rows.at(-1), true);
|
|
119
|
+
return mesh.build();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Parallel-transport frames: the ring does not twist where the path bends. On
|
|
123
|
+
* a loop the leftover twist is spread along it, so the seam meets itself. */
|
|
124
|
+
function tubeFrames(tangents, closed) {
|
|
125
|
+
const across = (t) => unit(cross(t, Math.abs(t[0]) < 0.9 ? [1, 0, 0] : [0, 1, 0]));
|
|
126
|
+
let normal = across(tangents[0]);
|
|
127
|
+
const frames = tangents.map((t) => {
|
|
128
|
+
normal = unit(sub(normal, t.map((value) => value * dot(normal, t)))) ?? across(t);
|
|
129
|
+
return [normal, cross(t, normal)];
|
|
130
|
+
});
|
|
131
|
+
if (!closed) return frames;
|
|
132
|
+
const [n0, b0] = frames[0], t0 = tangents[0], last = frames.at(-1)[0];
|
|
133
|
+
const end = unit(sub(last, t0.map((value) => value * dot(last, t0)))) ?? n0;
|
|
134
|
+
const twist = Math.atan2(dot(end, b0), dot(end, n0));
|
|
135
|
+
return frames.map(([n, b], k) => {
|
|
136
|
+
const angle = (-twist * k) / frames.length;
|
|
137
|
+
const turned = n.map((value, axis) => Math.cos(angle) * value + Math.sin(angle) * b[axis]);
|
|
138
|
+
return [turned, cross(tangents[k], turned)];
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** A round tube along the path; an open one ends in half-balls. */
|
|
143
|
+
export function tubeGeometry(part) {
|
|
144
|
+
const segments = part.closed ? part.path.length : part.path.length - 1;
|
|
145
|
+
const centre = tubeCentre(part, Math.min(24, Math.max(4, Math.ceil(128 / segments))));
|
|
146
|
+
const radii = tubeRadii(part, centre);
|
|
147
|
+
const n = centre.length;
|
|
148
|
+
const near = (k) => centre[part.closed ? (k + n) % n : Math.min(n - 1, Math.max(0, k))];
|
|
149
|
+
let previous = [0, 1, 0];
|
|
150
|
+
const tangents = centre.map((_, k) => (previous = unit(sub(near(k + 1), near(k - 1))) ?? previous));
|
|
151
|
+
const frames = tubeFrames(tangents, part.closed);
|
|
152
|
+
const run = [0];
|
|
153
|
+
for (let k = 1; k < n; k += 1) run.push(run[k - 1] + Math.hypot(...sub(centre[k], centre[k - 1])));
|
|
154
|
+
const rows = centre.map((c, k) => ({ c, r: radii[k], frame: frames[k], u: run[k] }));
|
|
155
|
+
if (part.closed) rows.push({ ...rows[0], u: run[n - 1] + Math.hypot(...sub(centre[0], centre[n - 1])) });
|
|
156
|
+
else {
|
|
157
|
+
// Rows of a half-ball from the pole to just short of the end ring.
|
|
158
|
+
const cap = (k, sign) => Array.from({ length: CAP_ROWS }, (_, i) => {
|
|
159
|
+
const angle = (((CAP_ROWS - i) / CAP_ROWS) * Math.PI) / 2;
|
|
160
|
+
const reach = radii[k] * Math.sin(angle);
|
|
161
|
+
return { c: centre[k].map((value, axis) => value + sign * tangents[k][axis] * reach), r: i ? radii[k] * Math.cos(angle) : 0, frame: frames[k], u: run[k] + sign * reach };
|
|
162
|
+
});
|
|
163
|
+
rows.unshift(...cap(0, -1));
|
|
164
|
+
rows.push(...cap(n - 1, 1).reverse());
|
|
165
|
+
}
|
|
166
|
+
const mesh = new Pieces();
|
|
167
|
+
mesh.grid(rows.length, TUBE_SIDES + 1, (i, j) => {
|
|
168
|
+
const { c, r, frame: [normal, binormal], u } = rows[i];
|
|
169
|
+
const angle = ((j % TUBE_SIDES) / TUBE_SIDES) * 2 * Math.PI;
|
|
170
|
+
const cos = Math.cos(angle), sin = Math.sin(angle);
|
|
171
|
+
return [...c.map((value, axis) => value + r * (cos * normal[axis] + sin * binormal[axis])), u, (j / TUBE_SIDES) * Math.PI * part.thickness];
|
|
172
|
+
});
|
|
173
|
+
return mesh.build();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Each corner's inward offset per metre of inset (a mitre, capped at 2× so a
|
|
177
|
+
* spike cannot shoot far); the solid is on every loop's left. */
|
|
178
|
+
function mitres(loop) {
|
|
179
|
+
const left = (a, b) => unit([a[1] - b[1], b[0] - a[0]]);
|
|
180
|
+
return loop.map((p, i) => {
|
|
181
|
+
const n1 = left(loop[(i + loop.length - 1) % loop.length], p), n2 = left(p, loop[(i + 1) % loop.length]);
|
|
182
|
+
const m = unit([n1[0] + n2[0], n1[1] + n2[1]]) ?? n1;
|
|
183
|
+
const scale = 1 / Math.max(0.5, dot(m, n1));
|
|
184
|
+
return [m[0] * scale, m[1] * scale];
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The deepest inset before an edge of the loop turns round, which is where a
|
|
189
|
+
* thin spike would fold through itself. */
|
|
190
|
+
function insetLimit(loop, offsets) {
|
|
191
|
+
return loop.reduce((limit, p, i) => {
|
|
192
|
+
const k = (i + 1) % loop.length, edge = sub(loop[k], p), shrink = dot(sub(offsets[k], offsets[i]), edge);
|
|
193
|
+
return shrink < 0 ? Math.min(limit, -dot(edge, edge) / shrink) : limit;
|
|
194
|
+
}, Infinity);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The outline pushed through size z, with an optional rounded bevel and holes. */
|
|
198
|
+
export function extrudeGeometry(part) {
|
|
199
|
+
const [hx, hy, hz] = part.size.map((value) => value / 2);
|
|
200
|
+
const metres = (loop, ccw) => {
|
|
201
|
+
const points = loop.map(([x, y]) => [x * part.size[0], y * part.size[1]]);
|
|
202
|
+
return signedArea(points) > 0 === ccw ? points : points.reverse();
|
|
203
|
+
};
|
|
204
|
+
const loops = [metres(part.outline, true), ...(part.holes ?? []).map((hole) => metres(hole, false))];
|
|
205
|
+
const span = (axis) => Math.max(...loops[0].map((p) => p[axis])) - Math.min(...loops[0].map((p) => p[axis]));
|
|
206
|
+
const bevel = (part.roundness * Math.min(part.size[2], span(0), span(1))) / 2;
|
|
207
|
+
const offsets = loops.map(mitres);
|
|
208
|
+
// Star points and thin arms take a shallower inset than the bevel's depth, so the face never folds.
|
|
209
|
+
const inset = Math.min(bevel, 0.5 * Math.min(...loops.map((loop, l) => insetLimit(loop, offsets[l]))));
|
|
210
|
+
// The side's section [inset, z] from the back face round to the front face.
|
|
211
|
+
const quarter = Array.from({ length: bevel > 0 ? BEVEL_ROWS + 1 : 1 }, (_, i) => {
|
|
212
|
+
const angle = (i / BEVEL_ROWS) * (Math.PI / 2);
|
|
213
|
+
return [inset * (1 - Math.sin(angle)), hz - bevel * (1 - Math.cos(angle))];
|
|
214
|
+
});
|
|
215
|
+
const back = quarter.map(([offset, z]) => [offset, -z]), front = [...quarter].reverse();
|
|
216
|
+
const section = back.at(-1)[1] === front[0][1] ? [...back, ...front.slice(1)] : [...back, ...front];
|
|
217
|
+
// A deep bevel on a thin spike can push a mitre past the outline; the clamp keeps the box honest.
|
|
218
|
+
const at = (l, i, depth) => [clamp(loops[l][i][0] + offsets[l][i][0] * depth, hx), clamp(loops[l][i][1] + offsets[l][i][1] * depth, hy)];
|
|
219
|
+
const mesh = new Pieces();
|
|
220
|
+
loops.forEach((loop, l) => {
|
|
221
|
+
const n = loop.length;
|
|
222
|
+
const along = [0];
|
|
223
|
+
for (let i = 1; i <= n; i += 1) along.push(along[i - 1] + Math.hypot(...sub(loop[i % n], loop[i - 1])));
|
|
224
|
+
const corners = loop.flatMap((p, i) => {
|
|
225
|
+
const turn = Math.acos(Math.max(-1, Math.min(1, dot(unit(sub(p, loop[(i + n - 1) % n])), unit(sub(loop[(i + 1) % n], p))))));
|
|
226
|
+
return turn > CREASE ? [i] : [];
|
|
227
|
+
});
|
|
228
|
+
const runs = corners.length ? corners.map((from, k) => [from, k + 1 < corners.length ? corners[k + 1] : corners[0] + n]) : [[0, n]];
|
|
229
|
+
for (const [from, to] of runs) {
|
|
230
|
+
mesh.grid(section.length, to - from + 1, (i, j) => {
|
|
231
|
+
const k = from + j;
|
|
232
|
+
return [...at(l, k % n, section[i][0]), section[i][1], along[k % n] + (k >= n ? along[n] : 0), section[i][1]];
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
const flat = loops.flatMap((loop, l) => loop.map((_, i) => [l, i]));
|
|
237
|
+
const vectors = loops.map((loop) => loop.map(([x, y]) => new THREE.Vector2(x, y)));
|
|
238
|
+
const faces = THREE.ShapeUtils.triangulateShape(vectors[0], vectors.slice(1)).map((face) => {
|
|
239
|
+
const [a, b, c] = face.map((k) => loops[flat[k][0]][flat[k][1]]);
|
|
240
|
+
return signedArea([a, b, c]) >= 0 ? face : [face[0], face[2], face[1]];
|
|
241
|
+
});
|
|
242
|
+
for (const sign of [1, -1]) {
|
|
243
|
+
const points = flat.map(([l, i]) => {
|
|
244
|
+
const [x, y] = at(l, i, inset);
|
|
245
|
+
return [x, y, sign * hz, x, y];
|
|
246
|
+
});
|
|
247
|
+
mesh.fan(points, sign > 0 ? faces : faces.map(([a, b, c]) => [a, c, b]));
|
|
248
|
+
}
|
|
249
|
+
return mesh.build();
|
|
250
|
+
}
|
package/src/sculpt-geometry.js
CHANGED
|
@@ -12,11 +12,14 @@
|
|
|
12
12
|
import * as THREE from "three";
|
|
13
13
|
import { RoundedBoxGeometry } from "three/examples/jsm/geometries/RoundedBoxGeometry.js";
|
|
14
14
|
import { expandSculptParts } from "./sculpt-recipe.js";
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
15
|
+
import { BLOB_TAPER } from "./sculpt-shapes.js";
|
|
16
|
+
import { cutSculptGeometry } from "./sculpt-csg.js";
|
|
17
|
+
import { LOFT_CONVEX_BUILDERS } from "./sculpt-geometry-loft-convex.js";
|
|
18
|
+
import { extrudeGeometry, latheGeometry, tubeGeometry } from "./sculpt-geometry-shapes.js";
|
|
19
|
+
import { applySculptDetail } from "./sculpt-detail.js";
|
|
20
|
+
import { applySculptColours } from "./sculpt-colours.js";
|
|
21
|
+
import { scatterMesh } from "./sculpt-scatter.js";
|
|
22
|
+
import { lookMaterial } from "./material-look.js";
|
|
20
23
|
|
|
21
24
|
/** roundness 0..1 → superellipse exponent 2..8 */
|
|
22
25
|
const blobExponent = (roundness) => 2 + 6 * roundness;
|
|
@@ -97,32 +100,44 @@ function frameGeometry(part) {
|
|
|
97
100
|
return geometry;
|
|
98
101
|
}
|
|
99
102
|
|
|
100
|
-
const BUILDERS = { blob: blobGeometry, box: boxGeometry, cylinder: cylinderGeometry, torus: torusGeometry, frame: frameGeometry };
|
|
103
|
+
const BUILDERS = { blob: blobGeometry, box: boxGeometry, cylinder: cylinderGeometry, torus: torusGeometry, frame: frameGeometry, lathe: latheGeometry, tube: tubeGeometry, extrude: extrudeGeometry, ...LOFT_CONVEX_BUILDERS };
|
|
104
|
+
/** Shapes that draw their own mesh (many instances) instead of one geometry. */
|
|
105
|
+
const MESH_BUILDERS = { scatter: scatterMesh };
|
|
101
106
|
|
|
107
|
+
/** The part's geometry, with its surface `detail` applied. */
|
|
102
108
|
export function sculptPartGeometry(part) {
|
|
103
109
|
const build = BUILDERS[part.shape];
|
|
104
110
|
if (!build) throw new Error(`unknown sculpt shape "${part.shape}"`);
|
|
105
|
-
return build(part);
|
|
111
|
+
return applySculptDetail(build(part), part);
|
|
106
112
|
}
|
|
107
113
|
|
|
108
114
|
/**
|
|
109
115
|
* The drawable object for a normalized recipe. `materialFor(part)` lets the
|
|
110
|
-
* caller decide the look (
|
|
111
|
-
*
|
|
112
|
-
*
|
|
116
|
+
* caller decide the look (auto-colour, depth rank); the default is the part's
|
|
117
|
+
* own preset (material-look.js), clay when it names none, with the part's
|
|
118
|
+
* gradient and a scatter's per-item tint. Meshes (a scatter's InstancedMesh
|
|
119
|
+
* too) carry their part id as the name and as `userData.sculptPart`, so a
|
|
120
|
+
* capture or a raycast can say which part it hit. Glass casts no shadow: light
|
|
121
|
+
* passes through it.
|
|
113
122
|
*/
|
|
114
123
|
export function buildSculptGroup(recipe, { materialFor } = {}) {
|
|
115
124
|
const group = new THREE.Group();
|
|
116
125
|
group.userData.sculpt = true;
|
|
117
126
|
const matrix = new THREE.Matrix4();
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
127
|
+
const parts = expandSculptParts(recipe);
|
|
128
|
+
for (const part of parts) {
|
|
129
|
+
// A cutter (op) is never drawn: it cuts its parent's geometry instead.
|
|
130
|
+
if (part.op) continue;
|
|
131
|
+
const material = materialFor ? materialFor(part) : lookMaterial(part);
|
|
132
|
+
// A scatter draws its own InstancedMesh and is never cut; every other part is cut by its cutters.
|
|
133
|
+
const mesh = MESH_BUILDERS[part.shape]?.(part, material) ?? new THREE.Mesh(cutSculptGeometry(sculptPartGeometry(part), part, parts, sculptPartGeometry), material);
|
|
134
|
+
// Gradients and scatter tints belong to the shaded look; a flat display mode (materialFor) ignores them.
|
|
135
|
+
if (!materialFor) applySculptColours(mesh, part);
|
|
121
136
|
mesh.name = part.id;
|
|
122
137
|
mesh.userData.sculptPart = part.id;
|
|
123
138
|
matrix.fromArray(part.matrix);
|
|
124
139
|
matrix.decompose(mesh.position, mesh.quaternion, mesh.scale);
|
|
125
|
-
mesh.castShadow =
|
|
140
|
+
mesh.castShadow = !(material.transmission > 0);
|
|
126
141
|
mesh.receiveShadow = true;
|
|
127
142
|
group.add(mesh);
|
|
128
143
|
}
|
|
@@ -130,11 +145,14 @@ export function buildSculptGroup(recipe, { materialFor } = {}) {
|
|
|
130
145
|
}
|
|
131
146
|
|
|
132
147
|
/** Frees what buildSculptGroup allocated. Materials are disposed too, because
|
|
133
|
-
* buildSculptGroup (or the caller's materialFor) minted one per part
|
|
148
|
+
* buildSculptGroup (or the caller's materialFor) minted one per part; a
|
|
149
|
+
* scatter's instance buffers go with its InstancedMesh. The preset pattern
|
|
150
|
+
* textures they sample are shared and stay. */
|
|
134
151
|
export function disposeSculptGroup(group) {
|
|
135
152
|
group?.traverse((node) => {
|
|
136
153
|
if (!node.isMesh) return;
|
|
137
154
|
node.geometry.dispose();
|
|
155
|
+
if (node.isInstancedMesh) node.dispose();
|
|
138
156
|
const materials = Array.isArray(node.material) ? node.material : [node.material];
|
|
139
157
|
for (const material of materials) material.dispose();
|
|
140
158
|
});
|