axstack 0.36.0 → 0.37.0
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/README.md +10 -6
- package/bin/axstack.js +4 -18
- package/docs/getting-started.md +6 -6
- package/docs/host-operations.md +77 -5
- package/docs/installation.md +25 -43
- package/docs/workflows.md +12 -9
- package/package.json +1 -1
- package/profiles/presets/claude-only.json +3 -3
- package/profiles/presets/codex-only.json +3 -3
- package/profiles/presets/mixed.json +5 -4
- package/skills/axstack/references/role-roster.md +5 -3
- package/skills/axstack/references/ui-verification.md +6 -10
- package/skills/axstack/scripts/single-server.js +164 -0
- package/skills/axstack/systemd/axstack-single-server.service +8 -0
- package/skills/axstack/systemd/axstack-single-server.timer +10 -0
- package/skills/axstack-anatomy/ANATOMY.md +172 -0
- package/skills/axstack-anatomy/CHANGELOG.md +70 -0
- package/skills/axstack-anatomy/LICENSE +21 -0
- package/skills/axstack-anatomy/PROVENANCE.md +21 -0
- package/skills/axstack-anatomy/README.md +316 -0
- package/skills/axstack-anatomy/SKILL.md +74 -0
- package/skills/axstack-anatomy/SOURCE-PROOF.json +107 -0
- package/skills/axstack-anatomy/examples/arcade-cabinet/arcade-cabinet.css +44 -0
- package/skills/axstack-anatomy/examples/arcade-cabinet/build.mjs +714 -0
- package/skills/axstack-anatomy/examples/arcade-cabinet/live.js +597 -0
- package/skills/axstack-anatomy/examples/arcade-cabinet/parts.mjs +89 -0
- package/skills/axstack-anatomy/examples/arcade-cabinet/screen.mjs +114 -0
- package/skills/axstack-anatomy/examples/desk-computer/build.mjs +435 -0
- package/skills/axstack-anatomy/examples/dial-indicator/build.mjs +275 -0
- package/skills/axstack-anatomy/examples/raptor-engine/build.mjs +938 -0
- package/skills/axstack-anatomy/examples/raptor-engine/live.js +907 -0
- package/skills/axstack-anatomy/examples/raptor-engine/raptor.css +359 -0
- package/skills/axstack-anatomy/examples/ripple-tank/build.mjs +558 -0
- package/skills/axstack-anatomy/examples/ripple-tank/live.js +579 -0
- package/skills/axstack-anatomy/examples/ripple-tank/model.mjs +131 -0
- package/skills/axstack-anatomy/examples/ripple-tank/ripple.css +103 -0
- package/skills/axstack-anatomy/examples/spot-plate/art.tsx +110 -0
- package/skills/axstack-anatomy/examples/spot-plate/drops.ts +541 -0
- package/skills/axstack-anatomy/examples/spot-plate/fan.ts +84 -0
- package/skills/axstack-anatomy/examples/spot-plate/figure.tsx +25 -0
- package/skills/axstack-anatomy/examples/spot-plate/geometry.ts +280 -0
- package/skills/axstack-anatomy/examples/spot-plate/live.tsx +339 -0
- package/skills/axstack-anatomy/examples/spot-plate/rig.ts +230 -0
- package/skills/axstack-anatomy/examples/spot-plate/spot-plate.css +92 -0
- package/skills/axstack-anatomy/examples/test-rig/art.tsx +196 -0
- package/skills/axstack-anatomy/examples/test-rig/figure.tsx +23 -0
- package/skills/axstack-anatomy/examples/test-rig/geometry.ts +305 -0
- package/skills/axstack-anatomy/examples/test-rig/live.tsx +511 -0
- package/skills/axstack-anatomy/examples/test-rig/pose.ts +107 -0
- package/skills/axstack-anatomy/examples/test-rig/rig.ts +170 -0
- package/skills/axstack-anatomy/examples/test-rig/stretch.css +80 -0
- package/skills/axstack-anatomy/examples/test-rig/view.ts +99 -0
- package/skills/axstack-anatomy/examples/turning-dial/build.mjs +479 -0
- package/skills/axstack-anatomy/examples/turning-dial/dial.css +10 -0
- package/skills/axstack-anatomy/examples/turning-dial/live.js +342 -0
- package/skills/axstack-anatomy/examples/turning-dial/stress.mjs +269 -0
- package/skills/axstack-anatomy/kit/audit.mjs +1913 -0
- package/skills/axstack-anatomy/kit/canvas-painter.mjs +722 -0
- package/skills/axstack-anatomy/kit/gl-shared.mjs +184 -0
- package/skills/axstack-anatomy/kit/gl.mjs +184 -0
- package/skills/axstack-anatomy/kit/iso-kit.mjs +674 -0
- package/skills/axstack-anatomy/kit/iso-kit.ts +842 -0
- package/skills/axstack-anatomy/kit/iso.css +106 -0
- package/skills/axstack-anatomy/kit/lathe.mjs +1184 -0
- package/skills/axstack-anatomy/kit/react/draw.tsx +205 -0
- package/skills/axstack-anatomy/kit/react/live.tsx +140 -0
- package/skills/axstack-anatomy/kit/tube.mjs +306 -0
- package/skills/axstack-anatomy/kit/turn-audit.mjs +1096 -0
- package/skills/axstack-anatomy/kit/turn-build.mjs +1355 -0
- package/skills/axstack-anatomy/kit/turn-fixed.mjs +3800 -0
- package/skills/axstack-anatomy/kit/turn.mjs +3610 -0
- package/skills/axstack-anatomy/references/3d.md +352 -0
- package/skills/axstack-anatomy/references/craft.md +325 -0
- package/skills/axstack-anatomy/references/kit.md +419 -0
- package/skills/axstack-anatomy/references/motion.md +165 -0
- package/skills/axstack-anatomy/references/react.md +42 -0
- package/skills/axstack-anatomy/references/verify.md +351 -0
- package/skills/axstack-anatomy/references/walkthrough.md +75 -0
- package/skills/axstack-anatomy/references/webgl.md +473 -0
- package/skills/axstack-anatomy/scripts/audit-affine-bounds.test.mjs +54 -0
- package/skills/axstack-anatomy/scripts/audit-cache.test.mjs +37 -0
- package/skills/axstack-anatomy/scripts/audit-clearance-envelope.test.mjs +39 -0
- package/skills/axstack-anatomy/scripts/audit-empty-clipping.test.mjs +38 -0
- package/skills/axstack-anatomy/scripts/audit-order-convergence.test.mjs +55 -0
- package/skills/axstack-anatomy/scripts/audit-path-origin.test.mjs +26 -0
- package/skills/axstack-anatomy/scripts/audit-profile.test.mjs +41 -0
- package/skills/axstack-anatomy/scripts/audit-projection.test.mjs +36 -0
- package/skills/axstack-anatomy/scripts/audit-slide-clearance.test.mjs +58 -0
- package/skills/axstack-anatomy/scripts/audit-svg-attributes.test.mjs +46 -0
- package/skills/axstack-anatomy/scripts/audit-taper.test.mjs +29 -0
- package/skills/axstack-anatomy/scripts/capture-selector.test.mjs +131 -0
- package/skills/axstack-anatomy/scripts/capture.mjs +228 -0
- package/skills/axstack-anatomy/scripts/capture.test.mjs +42 -0
- package/skills/axstack-anatomy/scripts/chrome-port.mjs +36 -0
- package/skills/axstack-anatomy/scripts/chrome-startup.test.mjs +38 -0
- package/skills/axstack-anatomy/scripts/drive-actions.test.mjs +46 -0
- package/skills/axstack-anatomy/scripts/drive.mjs +302 -0
- package/skills/axstack-anatomy/scripts/drive.test.mjs +72 -0
- package/skills/axstack-anatomy/scripts/enter-key.test.mjs +38 -0
- package/skills/axstack-anatomy/scripts/fidelity-cut.test.mjs +54 -0
- package/skills/axstack-anatomy/scripts/fidelity-tube-coverage.test.mjs +40 -0
- package/skills/axstack-anatomy/scripts/inline-kit.mjs +26 -0
- package/skills/axstack-anatomy/scripts/line-ends.mjs +306 -0
- package/skills/axstack-anatomy/scripts/lines-arcs.test.mjs +50 -0
- package/skills/axstack-anatomy/scripts/lines-bezier.test.mjs +28 -0
- package/skills/axstack-anatomy/scripts/lines-round.test.mjs +56 -0
- package/skills/axstack-anatomy/scripts/lines-rounded-rect.test.mjs +32 -0
- package/skills/axstack-anatomy/scripts/lines.mjs +31 -0
- package/skills/axstack-anatomy/scripts/lines.test.mjs +38 -0
- package/skills/axstack-anatomy/scripts/navigation.test.mjs +55 -0
- package/skills/axstack-anatomy/scripts/orbit-sampling.test.mjs +50 -0
- package/skills/axstack-anatomy/scripts/react-dots-opacity.test.mjs +51 -0
- package/skills/axstack-anatomy/scripts/react-readout-first-write.test.mjs +37 -0
- package/skills/axstack-anatomy/scripts/react-solid-seamless.test.mjs +47 -0
- package/skills/axstack-anatomy/scripts/react-visibility-target.test.mjs +38 -0
- package/skills/axstack-anatomy/scripts/turn-bench.mjs +74 -0
- package/skills/axstack-anatomy/scripts/turn-check.mjs +641 -0
- package/skills/axstack-anatomy/scripts/turn-fidelity.mjs +413 -0
- package/skills/axstack-anatomy/scripts/turn-group-reference.test.mjs +40 -0
- package/skills/axstack-anatomy/scripts/turn-split-owner.test.mjs +62 -0
- package/skills/axstack-anatomy/theme.js +56 -0
- package/skills/axstack-explain/SKILL.md +28 -15
- package/skills/axstack-explain/references/delivery.md +22 -0
- package/skills/{axstack-diagram → axstack-explain}/references/fidelity.md +1 -7
- package/skills/axstack-explain/references/inline-pages.md +8 -8
- package/skills/axstack-explain/references/visual-qa.md +3 -5
- package/skills/axstack-explain/references/whiteboard-defense.md +59 -0
- package/src/archify-legacy.js +70 -0
- package/src/capabilities.js +1 -29
- package/src/installer.js +11 -31
- package/skills/axstack-diagram/SKILL.md +0 -28
- package/skills/axstack-diagram/references/archify.md +0 -76
- package/src/archify-pin.js +0 -5
- package/src/archify.js +0 -151
|
@@ -0,0 +1,1355 @@
|
|
|
1
|
+
import { TURN, TURN_CSS } from "./turn.mjs";
|
|
2
|
+
import * as k from "./iso-kit.mjs";
|
|
3
|
+
import * as G from "./lathe.mjs";
|
|
4
|
+
import * as A from "./audit.mjs";
|
|
5
|
+
|
|
6
|
+
const TAU = Math.PI * 2;
|
|
7
|
+
const r4 = (v) => Math.round(v * 1e4) / 1e4;
|
|
8
|
+
const r3 = (v) => Math.round(v * 1e3) / 1e3;
|
|
9
|
+
const pack3 = (value) => JSON.parse(JSON.stringify(value, (key, v) => (typeof v === "number" ? r3(v) : v instanceof Float64Array || v instanceof Float32Array ? Array.from(v, r3) : v)));
|
|
10
|
+
const pack = (value) => JSON.parse(JSON.stringify(value, (key, v) => (typeof v === "number" ? r4(v) : v instanceof Float64Array || v instanceof Float32Array ? Array.from(v, r4) : v)));
|
|
11
|
+
const esc = (text) => String(text).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
12
|
+
|
|
13
|
+
function octOf(n) {
|
|
14
|
+
const l = Math.abs(n[0]) + Math.abs(n[1]) + Math.abs(n[2]) || 1;
|
|
15
|
+
let x = n[0] / l;
|
|
16
|
+
let y = n[1] / l;
|
|
17
|
+
if (n[2] < 0) {
|
|
18
|
+
const ox = (1 - Math.abs(y)) * (x >= 0 ? 1 : -1);
|
|
19
|
+
const oy = (1 - Math.abs(x)) * (y >= 0 ? 1 : -1);
|
|
20
|
+
x = ox;
|
|
21
|
+
y = oy;
|
|
22
|
+
}
|
|
23
|
+
return [Math.round((x * 0.5 + 0.5) * 65535), Math.round((y * 0.5 + 0.5) * 65535)];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function packPairs(byLayer, stride, grouped) {
|
|
27
|
+
const out = {};
|
|
28
|
+
for (const [name, list] of Object.entries(byLayer)) {
|
|
29
|
+
const count = list.length / stride;
|
|
30
|
+
const bytes = new Uint8Array(count * (grouped ? 10 : 8));
|
|
31
|
+
const view = new DataView(bytes.buffer);
|
|
32
|
+
for (let e = 0; e < count; e++) {
|
|
33
|
+
const at = e * stride;
|
|
34
|
+
let o = e * (grouped ? 10 : 8);
|
|
35
|
+
view.setUint16(o, list[at]);
|
|
36
|
+
view.setUint16(o + 2, list[at + 1]);
|
|
37
|
+
o += 4;
|
|
38
|
+
if (grouped) {
|
|
39
|
+
view.setUint16(o, list[at + 2] + 1);
|
|
40
|
+
o += 2;
|
|
41
|
+
}
|
|
42
|
+
const [u, v] = octOf(list.slice(at + stride - 3, at + stride));
|
|
43
|
+
view.setUint16(o, u);
|
|
44
|
+
view.setUint16(o + 2, v);
|
|
45
|
+
}
|
|
46
|
+
out[name] = Buffer.from(bytes).toString("base64");
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function signedArea(points) {
|
|
52
|
+
let s = 0;
|
|
53
|
+
for (let i = 0; i < points.length; i++) {
|
|
54
|
+
const a = points[i];
|
|
55
|
+
const b = points[(i + 1) % points.length];
|
|
56
|
+
s += a[0] * b[1] - b[0] * a[1];
|
|
57
|
+
}
|
|
58
|
+
return s / 2;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function convexOrThrow(points, name) {
|
|
62
|
+
const n = points.length;
|
|
63
|
+
let sign = 0;
|
|
64
|
+
for (let i = 0; i < n; i++) {
|
|
65
|
+
const a = points[i];
|
|
66
|
+
const b = points[(i + 1) % n];
|
|
67
|
+
const c = points[(i + 2) % n];
|
|
68
|
+
const cross = (b[0] - a[0]) * (c[1] - b[1]) - (b[1] - a[1]) * (c[0] - b[0]);
|
|
69
|
+
if (Math.abs(cross) < 1e-9) continue;
|
|
70
|
+
if (!sign) sign = Math.sign(cross);
|
|
71
|
+
else if (Math.sign(cross) !== sign) throw new Error(`turning: part ${name} is not convex; split it into convex pieces`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function roundedPolygon(polygon, radius = 0, steps = 4) {
|
|
76
|
+
const poly = signedArea(polygon) < 0 ? polygon.slice().reverse() : polygon.slice();
|
|
77
|
+
if (!(radius > 0)) return poly.map(([x, y]) => [x, y]);
|
|
78
|
+
const n = poly.length;
|
|
79
|
+
const normals = poly.map((a, i) => {
|
|
80
|
+
const b = poly[(i + 1) % n];
|
|
81
|
+
const l = Math.hypot(b[0] - a[0], b[1] - a[1]) || 1;
|
|
82
|
+
return [(b[1] - a[1]) / l, -(b[0] - a[0]) / l];
|
|
83
|
+
});
|
|
84
|
+
const out = [];
|
|
85
|
+
for (let i = 0; i < n; i++) {
|
|
86
|
+
const na = normals[(i - 1 + n) % n];
|
|
87
|
+
const nb = normals[i];
|
|
88
|
+
const denom = 1 + na[0] * nb[0] + na[1] * nb[1];
|
|
89
|
+
const c = [poly[i][0] - (radius * (na[0] + nb[0])) / denom, poly[i][1] - (radius * (na[1] + nb[1])) / denom];
|
|
90
|
+
const a0 = Math.atan2(na[1], na[0]);
|
|
91
|
+
let a1 = Math.atan2(nb[1], nb[0]);
|
|
92
|
+
while (a1 < a0 - 1e-9) a1 += TAU;
|
|
93
|
+
for (let s = 0; s <= steps; s++) {
|
|
94
|
+
const t = a0 + ((a1 - a0) * s) / steps;
|
|
95
|
+
out.push([c[0] + radius * Math.cos(t), c[1] + radius * Math.sin(t)]);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const fine = (v) => {
|
|
102
|
+
const t = Math.round(v * 1000) / 1000;
|
|
103
|
+
return t === 0 ? "0" : String(t);
|
|
104
|
+
};
|
|
105
|
+
const finePath = (points) => points.map(([x, y], i) => `${i ? "L" : "M"}${fine(x)} ${fine(y)}`).join("") + "Z";
|
|
106
|
+
|
|
107
|
+
const dot3 = G.dot3;
|
|
108
|
+
const sub3 = G.sub3;
|
|
109
|
+
const add3 = G.add3;
|
|
110
|
+
const mul3 = G.mul3;
|
|
111
|
+
|
|
112
|
+
function marginOf(n, A, B) {
|
|
113
|
+
let hiA = -Infinity;
|
|
114
|
+
for (const a of A) {
|
|
115
|
+
const v = n[0] * a[0] + n[1] * a[1] + n[2] * a[2];
|
|
116
|
+
if (v > hiA) hiA = v;
|
|
117
|
+
}
|
|
118
|
+
let loB = Infinity;
|
|
119
|
+
for (const b of B) {
|
|
120
|
+
const v = n[0] * b[0] + n[1] * b[1] + n[2] * b[2];
|
|
121
|
+
if (v < loB) loB = v;
|
|
122
|
+
}
|
|
123
|
+
return { margin: loB - hiA, d: (loB + hiA) / 2 };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function closest(A, B, rounds = 200) {
|
|
127
|
+
let w = sub3(B[0], A[0]);
|
|
128
|
+
for (let it = 0; it < rounds; it++) {
|
|
129
|
+
const l = G.len3(w);
|
|
130
|
+
if (l < 1e-12) break;
|
|
131
|
+
let hi = -Infinity;
|
|
132
|
+
let a = A[0];
|
|
133
|
+
for (const p of A) {
|
|
134
|
+
const v = dot3(p, w);
|
|
135
|
+
if (v > hi) {
|
|
136
|
+
hi = v;
|
|
137
|
+
a = p;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
let lo = Infinity;
|
|
141
|
+
let b = B[0];
|
|
142
|
+
for (const p of B) {
|
|
143
|
+
const v = dot3(p, w);
|
|
144
|
+
if (v < lo) {
|
|
145
|
+
lo = v;
|
|
146
|
+
b = p;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const s = sub3(b, a);
|
|
150
|
+
const ds = sub3(w, s);
|
|
151
|
+
const dd = dot3(ds, ds);
|
|
152
|
+
if (dd < 1e-14) break;
|
|
153
|
+
const t = Math.max(0, Math.min(1, dot3(w, ds) / dd));
|
|
154
|
+
const next = sub3(w, mul3(ds, t));
|
|
155
|
+
if (G.len3(sub3(next, w)) < 1e-10) break;
|
|
156
|
+
w = next;
|
|
157
|
+
}
|
|
158
|
+
return w;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function gjk(A, B, warm = null) {
|
|
162
|
+
const w = warm && G.len3(warm) > 1e-9 ? warm : closest(A, B);
|
|
163
|
+
const n = G.unit3(G.len3(w) > 1e-12 ? w : [0, 0, 1]);
|
|
164
|
+
const { margin, d } = marginOf(n, A, B);
|
|
165
|
+
return { n, d, margin };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function quickSeparate(A, B, hints = []) {
|
|
169
|
+
const centre = (list) => mul3(list.reduce((sum, p) => add3(sum, p), [0, 0, 0]), 1 / list.length);
|
|
170
|
+
const candidates = [sub3(centre(B), centre(A)), ...hints.flatMap((h) => [h, mul3(h, -1)])].filter((n) => G.len3(n) > 1e-9).map(G.unit3);
|
|
171
|
+
let best = null;
|
|
172
|
+
for (const n of candidates) {
|
|
173
|
+
const { margin } = marginOf(n, A, B);
|
|
174
|
+
if (!best || margin > best.margin) best = { n, margin };
|
|
175
|
+
}
|
|
176
|
+
if (best.margin >= 0.1) return best;
|
|
177
|
+
let { n, margin } = best;
|
|
178
|
+
for (const step of [0.3, 0.1, 0.03]) {
|
|
179
|
+
for (let round = 0; round < 12; round++) {
|
|
180
|
+
const F = G.frameAlong([0, 0, 0], n);
|
|
181
|
+
let improved = false;
|
|
182
|
+
for (let j = 0; j < 8; j++) {
|
|
183
|
+
const t = (j / 8) * TAU;
|
|
184
|
+
const trial = G.unit3(add3(n, add3(mul3(F.u, step * Math.cos(t)), mul3(F.v, step * Math.sin(t)))));
|
|
185
|
+
const value = marginOf(trial, A, B).margin;
|
|
186
|
+
if (value > margin + 1e-9) {
|
|
187
|
+
margin = value;
|
|
188
|
+
n = trial;
|
|
189
|
+
improved = true;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (!improved) break;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return { n, margin };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function separate(A, B, hints = []) {
|
|
199
|
+
const centre = (list) => mul3(list.reduce((s, p) => add3(s, p), [0, 0, 0]), 1 / list.length);
|
|
200
|
+
const candidates = [sub3(centre(B), centre(A)), closest(A, B), ...hints.flatMap((h) => [h, mul3(h, -1)])].filter((n) => G.len3(n) > 1e-9).map(G.unit3);
|
|
201
|
+
let best = null;
|
|
202
|
+
for (const n of candidates) {
|
|
203
|
+
const { margin } = marginOf(n, A, B);
|
|
204
|
+
if (!best || margin > best.margin) best = { n, margin };
|
|
205
|
+
}
|
|
206
|
+
let { n, margin } = best;
|
|
207
|
+
for (const step of [0.3, 0.1, 0.03, 0.01, 0.003, 0.001]) {
|
|
208
|
+
for (let round = 0; round < 40; round++) {
|
|
209
|
+
const F = G.frameAlong([0, 0, 0], n);
|
|
210
|
+
let improved = false;
|
|
211
|
+
for (let j = 0; j < 8; j++) {
|
|
212
|
+
const t = (j / 8) * TAU;
|
|
213
|
+
const trial = G.unit3(add3(n, add3(mul3(F.u, step * Math.cos(t)), mul3(F.v, step * Math.sin(t)))));
|
|
214
|
+
const value = marginOf(trial, A, B).margin;
|
|
215
|
+
if (value > margin + 1e-9) {
|
|
216
|
+
margin = value;
|
|
217
|
+
n = trial;
|
|
218
|
+
improved = true;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (!improved) break;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return { n, d: marginOf(n, A, B).d, margin };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function frameFrom(F) {
|
|
228
|
+
const a = G.unit3(F.a);
|
|
229
|
+
let u = F.u ? G.unit3(sub3(F.u, mul3(a, dot3(F.u, a)))) : null;
|
|
230
|
+
if (!u || G.len3(u) < 1e-9) u = G.frameAlong([0, 0, 0], a).u;
|
|
231
|
+
const v = F.v && Math.abs(dot3(G.cross3(a, u), F.v)) > 0.999 ? G.unit3(F.v) : G.cross3(a, u);
|
|
232
|
+
return { o: F.o.slice(), a, u, v };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function sampleShape(shape, count = 48) {
|
|
236
|
+
const out = [];
|
|
237
|
+
if (shape.kind === "body") {
|
|
238
|
+
const F = frameFrom(shape.F);
|
|
239
|
+
for (const [s, r] of shape.poly) {
|
|
240
|
+
if (r < 1e-9) {
|
|
241
|
+
out.push(G.pointOf(F, s, 0, 0));
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
for (let j = 0; j < count; j++) out.push(G.pointOf(F, s, r, (j / count) * TAU));
|
|
245
|
+
}
|
|
246
|
+
} else if (shape.kind === "prism") {
|
|
247
|
+
const F = shape.F;
|
|
248
|
+
for (const s of [shape.s0, shape.s1]) for (const [x, y] of shape.poly) out.push(add3(add3(F.o, mul3(F.a, s)), add3(mul3(F.u, x), mul3(F.v, y))));
|
|
249
|
+
} else if (shape.kind === "ball") {
|
|
250
|
+
const golden = Math.PI * (3 - Math.sqrt(5));
|
|
251
|
+
const total = Math.max(count * 3, 96);
|
|
252
|
+
for (let i = 0; i < total; i++) {
|
|
253
|
+
const y = 1 - (2 * (i + 0.5)) / total;
|
|
254
|
+
const rad = Math.sqrt(1 - y * y);
|
|
255
|
+
const t = golden * i;
|
|
256
|
+
const n = [Math.cos(t) * rad, Math.sin(t) * rad, y];
|
|
257
|
+
if ((shape.flats ?? []).some(([nx, ny, nz, d]) => nx * n[0] * shape.r + ny * n[1] * shape.r + nz * n[2] * shape.r > d + 1e-9)) continue;
|
|
258
|
+
out.push(add3(shape.o, mul3(n, shape.r)));
|
|
259
|
+
}
|
|
260
|
+
for (const [nx, ny, nz, d] of shape.flats ?? []) {
|
|
261
|
+
const F = G.frameAlong(add3(shape.o, mul3([nx, ny, nz], d)), [nx, ny, nz]);
|
|
262
|
+
const rho = Math.sqrt(Math.max(0, shape.r * shape.r - d * d));
|
|
263
|
+
for (let j = 0; j < count; j++) {
|
|
264
|
+
const p = G.pointOf(F, 0, rho, (j / count) * TAU);
|
|
265
|
+
if ((shape.flats ?? []).every(([mx, my, mz, e]) => mx * (p[0] - shape.o[0]) + my * (p[1] - shape.o[1]) + mz * (p[2] - shape.o[2]) <= e + 1e-6)) out.push(p);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
} else if (shape.kind === "box") {
|
|
269
|
+
for (const sx of [-1, 1]) for (const sy of [-1, 1]) for (const sz of [-1, 1]) out.push(add3(shape.o, add3(add3(mul3(shape.axes[0], sx * shape.half[0]), mul3(shape.axes[1], sy * shape.half[1])), mul3(shape.axes[2], sz * shape.half[2]))));
|
|
270
|
+
} else if (shape.kind === "tube") {
|
|
271
|
+
const pts = shape.points;
|
|
272
|
+
for (let i = 0; i < pts.length; i++) {
|
|
273
|
+
const t = G.unit3(sub3(pts[Math.min(pts.length - 1, i + 1)], pts[Math.max(0, i - 1)]));
|
|
274
|
+
const F = G.frameAlong(pts[i], t);
|
|
275
|
+
const ring = Math.max(8, Math.round(count / 4));
|
|
276
|
+
for (let j = 0; j < ring; j++) out.push(G.pointOf(F, 0, shape.radii[i], (j / ring) * TAU));
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return out;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function hull2(points) {
|
|
283
|
+
return G.hullOf(points);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function thinCloud(points, most = 64) {
|
|
287
|
+
if (points.length <= most) return points;
|
|
288
|
+
const out = [];
|
|
289
|
+
const step = points.length / most;
|
|
290
|
+
for (let i = 0; i < most; i++) out.push(points[Math.floor(i * step)]);
|
|
291
|
+
return out;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export function turning(P, { recorder = null, scale = 1 } = {}) {
|
|
295
|
+
const T = { P, R: recorder, scale, groups: [], layers: [], order: [], parts: [], built: null };
|
|
296
|
+
const groupOf = (g) => {
|
|
297
|
+
if (g === undefined || g === null) return null;
|
|
298
|
+
if (typeof g !== "string") return g;
|
|
299
|
+
const group = T.groups.find((x) => x.name === g);
|
|
300
|
+
if (!group) throw new Error(`turning: unknown group ${g}`);
|
|
301
|
+
return group;
|
|
302
|
+
};
|
|
303
|
+
const layerOf = (l) => (typeof l === "string" ? T.layers.find((x) => x.name === l) : l);
|
|
304
|
+
const origin = (group) => (group ? group.origin : [0, 0, 0]);
|
|
305
|
+
const local = (group, p) => sub3(p, origin(group));
|
|
306
|
+
T.group = (name, options = {}) => {
|
|
307
|
+
if (T.groups.some((group) => group.name === name)) throw new Error(`turning: duplicate group ${name}`);
|
|
308
|
+
const parent = groupOf(options.parent);
|
|
309
|
+
const po = origin(parent);
|
|
310
|
+
let kind;
|
|
311
|
+
let spec;
|
|
312
|
+
if (options.turn) {
|
|
313
|
+
kind = "turn";
|
|
314
|
+
const pivot = options.turn.pivot ?? [0, 0];
|
|
315
|
+
spec = { origin: [pivot[0], pivot[1], 0], pivot: [pivot[0], pivot[1]], range: options.turn.range ?? null };
|
|
316
|
+
} else if (options.slide) {
|
|
317
|
+
kind = "slide";
|
|
318
|
+
spec = { origin: po.slice(), direction: G.unit3(options.slide.direction), travel: options.slide.travel ?? 0, range: [0, options.slide.travel ?? 0] };
|
|
319
|
+
} else if (options.hinge) {
|
|
320
|
+
kind = "hinge";
|
|
321
|
+
spec = { origin: options.hinge.point.slice(), point: options.hinge.point.slice(), axis: G.unit3(options.hinge.axis), range: options.hinge.range ?? [0, 0] };
|
|
322
|
+
} else if (options.free) {
|
|
323
|
+
kind = "free";
|
|
324
|
+
const at = options.free.origin ?? po;
|
|
325
|
+
spec = { origin: at.slice(), range: null };
|
|
326
|
+
} else throw new Error(`turning: group ${name} needs turn, slide, hinge or free`);
|
|
327
|
+
const upright = kind !== "free" && (parent ? parent.upright : true) && (kind !== "hinge" || Math.abs(spec.axis[2]) > 0.9999);
|
|
328
|
+
const loose = kind === "free" || Boolean(parent && parent.loose);
|
|
329
|
+
const group = { name, index: T.groups.length, parent, kind, upright, loose, ...spec };
|
|
330
|
+
T.groups.push(group);
|
|
331
|
+
return group;
|
|
332
|
+
};
|
|
333
|
+
T.layer = (name) => {
|
|
334
|
+
const layer = { name, index: T.layers.length, live: true };
|
|
335
|
+
T.layers.push(layer);
|
|
336
|
+
return layer;
|
|
337
|
+
};
|
|
338
|
+
T.stack = (names) => {
|
|
339
|
+
T.order = names.slice();
|
|
340
|
+
return T;
|
|
341
|
+
};
|
|
342
|
+
const common = (options, kind) => {
|
|
343
|
+
const group = groupOf(options.group);
|
|
344
|
+
const layer = layerOf(options.layer ?? T.layers[0]);
|
|
345
|
+
if (!layer) throw new Error(`turning: part ${options.name} has no live layer`);
|
|
346
|
+
const name = options.name ?? `${kind}#${T.parts.length}`;
|
|
347
|
+
const material = options.material ?? null;
|
|
348
|
+
if (material && !TURN.materials.includes(material)) throw new Error(`turning: part ${name} has material ${material}; use one of ${TURN.materials.join(", ")}`);
|
|
349
|
+
return { id: T.parts.length, name, kind, group, layer, owner: options.owner, tone: options.tone ?? "hi", crease: options.crease ?? "faint", lit: Boolean(options.lit), material, details: options.details ?? {}, shapes: [], planes: [], dots: [], samples: [], hints: [] };
|
|
350
|
+
};
|
|
351
|
+
const record = (part, shape) => {
|
|
352
|
+
shape.name = shape.name ?? part.name;
|
|
353
|
+
if (part.owner && !shape.owner) shape.owner = part.owner;
|
|
354
|
+
part.shapes.push(shape);
|
|
355
|
+
if (T.R) T.R.solid(shape);
|
|
356
|
+
return shape;
|
|
357
|
+
};
|
|
358
|
+
const toLocalDots = (part, F, list) => {
|
|
359
|
+
for (const dot of list ?? []) {
|
|
360
|
+
const [s, x, y] = dot.at;
|
|
361
|
+
const p = add3(add3(F.o, mul3(F.a, s)), add3(mul3(F.u, x), mul3(F.v, y)));
|
|
362
|
+
part.dots.push({ p: local(part.group, p), n: dot.normal ? G.unit3(dot.normal) : null, size: dot.size ?? 0.5, tone: dot.tone ?? "mid", fade: dot.fade ?? [0.04, 0.42] });
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
T.prism = (options) => {
|
|
366
|
+
const part = common(options, "prism");
|
|
367
|
+
let F;
|
|
368
|
+
let ring;
|
|
369
|
+
let s0;
|
|
370
|
+
let s1;
|
|
371
|
+
if (options.plan) {
|
|
372
|
+
F = { o: [0, 0, 0], a: [0, 0, 1], u: [1, 0, 0], v: [0, 1, 0] };
|
|
373
|
+
ring = k.roundedPlan(options.plan, options.steps ?? 4);
|
|
374
|
+
s0 = options.z ?? options.s0 ?? 0;
|
|
375
|
+
s1 = options.h !== undefined ? s0 + options.h : options.s1;
|
|
376
|
+
} else {
|
|
377
|
+
F = frameFrom(options.F);
|
|
378
|
+
ring = roundedPolygon(options.polygon, options.round ?? 0, options.steps ?? 4);
|
|
379
|
+
s0 = options.s0;
|
|
380
|
+
s1 = options.s1;
|
|
381
|
+
}
|
|
382
|
+
if (s1 < s0) [s0, s1] = [s1, s0];
|
|
383
|
+
if (signedArea(ring) < 0) ring = ring.slice().reverse();
|
|
384
|
+
convexOrThrow(ring, part.name);
|
|
385
|
+
const bevel = options.bevel ?? part.details.bevel ?? 0;
|
|
386
|
+
const upright = Math.abs(F.a[2]) > 0.9999 && (!part.group || part.group.upright);
|
|
387
|
+
record(part, A.prism(F, ring, s0, s1, { name: part.name }));
|
|
388
|
+
const corner = (s, [x, y]) => add3(add3(F.o, mul3(F.a, s)), add3(mul3(F.u, x), mul3(F.v, y)));
|
|
389
|
+
part.world = { F, ring, s0, s1 };
|
|
390
|
+
if (upright) {
|
|
391
|
+
const flip = F.a[2] < 0;
|
|
392
|
+
const pts = ring.map((p) => local(part.group, corner(flip ? s1 : s0, p)));
|
|
393
|
+
let xy = pts.map(([x, y]) => [x, y]);
|
|
394
|
+
if (signedArea(xy) < 0) xy = xy.reverse();
|
|
395
|
+
const z0 = pts[0][2];
|
|
396
|
+
const z1 = z0 + (s1 - s0);
|
|
397
|
+
part.spec = { up: 1, ring: xy.flat(), z0, z1, bevel };
|
|
398
|
+
part.planPath = finePath(xy);
|
|
399
|
+
part.localSeams = (part.details.seams ?? []).map((s) => (flip ? z1 - (s - s0) : z0 + (s - s0)));
|
|
400
|
+
} else {
|
|
401
|
+
const FL = { o: local(part.group, F.o), a: F.a, u: F.u, v: F.v };
|
|
402
|
+
part.spec = { up: 0, F: FL, ring: ring.flat(), s0, s1, bevel };
|
|
403
|
+
part.localSeams = (part.details.seams ?? []).slice();
|
|
404
|
+
}
|
|
405
|
+
toLocalDots(part, F, part.details.dots);
|
|
406
|
+
for (const s of [s0, s1]) for (const p of ring) part.samples.push(local(part.group, corner(s, p)));
|
|
407
|
+
part.hints.push(F.a);
|
|
408
|
+
const n = ring.length;
|
|
409
|
+
for (let i = 0; i < n; i++) {
|
|
410
|
+
const a = ring[i];
|
|
411
|
+
const b = ring[(i + 1) % n];
|
|
412
|
+
const l = Math.hypot(b[0] - a[0], b[1] - a[1]);
|
|
413
|
+
if (l > 1e-6) part.hints.push(G.unit3(add3(mul3(F.u, (b[1] - a[1]) / l), mul3(F.v, -(b[0] - a[0]) / l))));
|
|
414
|
+
}
|
|
415
|
+
T.parts.push(part);
|
|
416
|
+
return part;
|
|
417
|
+
};
|
|
418
|
+
const detailParts = (part, F, radiusAt, ends) => {
|
|
419
|
+
const details = part.details ?? {};
|
|
420
|
+
for (const bolt of details.bolts ?? []) {
|
|
421
|
+
const { s, r, count, phase = 0, size = 0.5, tone = "mid", fade = [0.04, 0.42] } = bolt;
|
|
422
|
+
const atEnd = ends ? (Math.abs(s - ends[0]) < 1e-6 ? -1 : Math.abs(s - ends[1]) < 1e-6 ? 1 : 0) : 0;
|
|
423
|
+
const onCap = atEnd !== 0 && (!radiusAt || r < radiusAt(s) - 1e-6);
|
|
424
|
+
const slope = radiusAt ? (radiusAt(s + 1e-3) - radiusAt(s - 1e-3)) / 2e-3 : 0;
|
|
425
|
+
for (let j = 0; j < count; j++) {
|
|
426
|
+
const t = phase + (j / count) * TAU;
|
|
427
|
+
const p = G.pointOf(F, s, r, t);
|
|
428
|
+
const radial = G.radialOf(F, t);
|
|
429
|
+
const n = onCap ? mul3(F.a, atEnd) : G.unit3(sub3(radial, mul3(F.a, slope)));
|
|
430
|
+
part.dots.push({ p: local(part.group, p), n, size, tone, fade });
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
toLocalDots(part, F, details.dots);
|
|
434
|
+
for (const ring of details.rings ?? []) {
|
|
435
|
+
const [s, x, y] = ring.at;
|
|
436
|
+
const o = add3(add3(F.o, mul3(F.a, s)), add3(mul3(F.u, x), mul3(F.v, y)));
|
|
437
|
+
const n = G.unit3(ring.normal ?? F.a);
|
|
438
|
+
const B = G.frameAlong([0, 0, 0], n);
|
|
439
|
+
const steps = Math.max(12, Math.ceil((TAU * ring.r * k.cameraOf(P).k) / 1.2));
|
|
440
|
+
const pts = Array.from({ length: steps }, (_, j) => [ring.r * Math.cos((j / steps) * TAU), ring.r * Math.sin((j / steps) * TAU)]);
|
|
441
|
+
T.plane({ part, o, u: B.u, v: B.v, normal: n, fade: ring.fade ?? [0.04, 0.3], svg: `<path class="iso-line" data-tone="${ring.tone ?? "lo"}" d="${finePath(pts)}"/>` });
|
|
442
|
+
}
|
|
443
|
+
for (const rule of details.rules ?? []) {
|
|
444
|
+
const pts = rule.points.map(([s, x, y]) => add3(add3(F.o, mul3(F.a, s)), add3(mul3(F.u, x), mul3(F.v, y))));
|
|
445
|
+
let n = rule.normal ? G.unit3(rule.normal) : null;
|
|
446
|
+
if (!n) {
|
|
447
|
+
for (let i = 2; i < pts.length && !n; i++) {
|
|
448
|
+
const c = G.cross3(sub3(pts[1], pts[0]), sub3(pts[i], pts[0]));
|
|
449
|
+
if (G.len3(c) > 1e-9) n = G.unit3(c);
|
|
450
|
+
}
|
|
451
|
+
n = n ?? F.a;
|
|
452
|
+
}
|
|
453
|
+
const B = G.frameAlong([0, 0, 0], n);
|
|
454
|
+
const o = pts[0];
|
|
455
|
+
const flat = pts.map((p) => [dot3(sub3(p, o), B.u), dot3(sub3(p, o), B.v)]);
|
|
456
|
+
const d = flat.map(([x, y], i) => `${i ? "L" : "M"}${fine(x)} ${fine(y)}`).join("");
|
|
457
|
+
const free = rule.free === true ? ' data-free=""' : rule.free ? ` data-free="${rule.free}"` : "";
|
|
458
|
+
T.plane({ part, o, u: B.u, v: B.v, normal: n, fade: rule.fade ?? [0.04, 0.3], svg: `<path class="iso-line" data-tone="${rule.tone ?? "lo"}"${free} d="${d}"/>` });
|
|
459
|
+
}
|
|
460
|
+
};
|
|
461
|
+
T.round = (options) => {
|
|
462
|
+
const F = frameFrom(options.F);
|
|
463
|
+
const r0 = Array.isArray(options.r) ? options.r[0] : options.r0 ?? options.r;
|
|
464
|
+
const r1 = Array.isArray(options.r) ? options.r[1] : options.r1 ?? options.r;
|
|
465
|
+
let s0 = options.s0;
|
|
466
|
+
let s1 = options.s1;
|
|
467
|
+
const ends = (options.ends ?? ["flat", "flat"]).slice();
|
|
468
|
+
if (s1 < s0) throw new Error(`turning: round ${options.name} needs s0 < s1`);
|
|
469
|
+
ends.forEach((end, j) => {
|
|
470
|
+
if (end === "dome" && Math.abs(r0 - r1) > 1e-9) throw new Error(`turning: round ${options.name} can only have a dome where the end radius equals the side's (a capsule)`);
|
|
471
|
+
});
|
|
472
|
+
const profile = [];
|
|
473
|
+
const arc = (sAt, sign, r) => Array.from({ length: 9 }, (_, j) => (j / 8) * (Math.PI / 2)).map((b) => [sAt + sign * r * Math.cos(b), r * Math.sin(b), 1]);
|
|
474
|
+
if (ends[0] === "dome") profile.push(...arc(s0, -1, r0).slice(0, -1).map((p, j) => (j === 0 ? [p[0], 0] : p)), [s0, r0, 1]);
|
|
475
|
+
else profile.push([s0, r0]);
|
|
476
|
+
if (ends[1] === "dome") profile.push([s1, r1, 1], ...arc(s1, 1, r1).reverse().slice(1).map((p, j, list) => (j === list.length - 1 ? [p[0], 0] : p)));
|
|
477
|
+
else profile.push([s1, r1]);
|
|
478
|
+
const vertical = Math.abs(F.a[2]) > 0.9999;
|
|
479
|
+
const groupOk = !options.group || groupOf(options.group).upright;
|
|
480
|
+
const radiusAt = (s) => r0 + ((r1 - r0) * (Math.max(s0, Math.min(s1, s)) - s0)) / (s1 - s0 || 1);
|
|
481
|
+
if (vertical && groupOk && options.fixed !== false) {
|
|
482
|
+
const meridian = [[profile[0][0], 0], ...profile.filter((p) => p[1] > 1e-9), [profile[profile.length - 1][0], 0]];
|
|
483
|
+
const seams = (options.details?.seams ?? []).map((sv) => G.arcOf(F, sv, radiusAt(sv), P, { slope: (r1 - r0) / (s1 - s0 || 1), least: 0 })).join("");
|
|
484
|
+
const paths = G.lathe(meridian, F, P, ends.includes("dome") ? { smooth: true } : {});
|
|
485
|
+
const svg = k.solidSvg(paths, { tone: options.tone ?? "hi", crease: options.crease ?? "faint", lit: options.lit, inner: seams ? k.lineSvg(seams, { tone: "faint" }) : "" });
|
|
486
|
+
const ribs = options.details?.ribs ? { ...options.details.ribs, r0: options.details.ribs.r0 ?? radiusAt(options.details.ribs.s0), r1: options.details.ribs.r1 ?? radiusAt(options.details.ribs.s1) } : undefined;
|
|
487
|
+
const part = T.fixed({ ...options, svg, anchor: G.pointOf(F, s0, 0, 0), F, details: { ...(options.details ?? {}), ribs, seams: undefined }, shapes: [A.solid(F, profile, { name: options.name })], skipDetails: true });
|
|
488
|
+
part.round = { F, s0, s1, r0, r1, ends };
|
|
489
|
+
detailParts(part, F, radiusAt, [s0, s1]);
|
|
490
|
+
return part;
|
|
491
|
+
}
|
|
492
|
+
const part = common(options, "round");
|
|
493
|
+
record(part, A.solid(F, profile, { name: part.name }));
|
|
494
|
+
part.world = { F, s0, s1, r0, r1, ends, profile };
|
|
495
|
+
part.spec = { F: { o: local(part.group, F.o), a: F.a, u: F.u, v: F.v }, s0, s1, r0, r1, ends };
|
|
496
|
+
detailParts(part, F, radiusAt, [s0, s1]);
|
|
497
|
+
const ring = (s, r, count = 48) => Array.from({ length: count }, (_, j) => G.pointOf(F, s, r, (j / count) * TAU));
|
|
498
|
+
const pts = [...ring(s0, r0), ...ring(s1, r1)];
|
|
499
|
+
for (const [end, sAt, r] of [[0, s0, r0], [1, s1, r1]]) {
|
|
500
|
+
if (ends[end] !== "dome") continue;
|
|
501
|
+
const sign = end ? 1 : -1;
|
|
502
|
+
for (let j = 1; j <= 6; j++) {
|
|
503
|
+
const b = (j / 6) * (Math.PI / 2);
|
|
504
|
+
if (j === 6) pts.push(add3(F.o, mul3(F.a, sAt + sign * r)));
|
|
505
|
+
else pts.push(...ring(sAt + sign * r * Math.sin(b), r * Math.cos(b), 32));
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
part.samples = pts.map((p) => local(part.group, p));
|
|
509
|
+
part.hints.push(F.a);
|
|
510
|
+
T.parts.push(part);
|
|
511
|
+
return part;
|
|
512
|
+
};
|
|
513
|
+
T.ball = (options) => {
|
|
514
|
+
const c = options.c;
|
|
515
|
+
const r = options.r;
|
|
516
|
+
const flats = (options.flats ?? []).map(({ n, d }) => ({ n: G.unit3(n), d }));
|
|
517
|
+
const shape = A.ball(c, r, { name: options.name, flats: flats.map(({ n, d }) => [n[0], n[1], n[2], d]) });
|
|
518
|
+
if (!flats.length && options.fixed !== false) {
|
|
519
|
+
const svg = k.solidSvg(G.sphereOf(c, r, P, { steps: 48 }), { tone: options.tone ?? "hi", crease: options.crease ?? "faint", lit: options.lit });
|
|
520
|
+
return T.fixed({ ...options, svg, anchor: c, shapes: [shape] });
|
|
521
|
+
}
|
|
522
|
+
const part = common(options, "ball");
|
|
523
|
+
record(part, shape);
|
|
524
|
+
part.world = { c, r, flats };
|
|
525
|
+
part.spec = { c: local(part.group, c), r, flats };
|
|
526
|
+
part.samples = sampleShape(shape, 48).map((p) => local(part.group, p));
|
|
527
|
+
for (const { n } of flats) part.hints.push(n);
|
|
528
|
+
detailParts(part, G.frameAlong(c, flats.length ? flats[0].n : [0, 0, 1]), null, null);
|
|
529
|
+
T.parts.push(part);
|
|
530
|
+
return part;
|
|
531
|
+
};
|
|
532
|
+
T.lathe = (options) => {
|
|
533
|
+
const F = frameFrom(options.F);
|
|
534
|
+
const profile = options.profile;
|
|
535
|
+
const curve = G.profileCurve(profile.map(([sv, rv]) => [sv, rv]), { slope: options.slope ?? null, radius: options.radius ?? null });
|
|
536
|
+
const kk = k.cameraOf(P).k;
|
|
537
|
+
const knots = [curve.s0];
|
|
538
|
+
const split = (a, b, level) => {
|
|
539
|
+
const mid = (a + b) / 2;
|
|
540
|
+
if (level < 7 && (Math.abs(curve.radius(mid) - (curve.radius(a) + curve.radius(b)) / 2) * kk > 0.1 || b - a > 4)) {
|
|
541
|
+
split(a, mid, level + 1);
|
|
542
|
+
knots.push(mid);
|
|
543
|
+
split(mid, b, level + 1);
|
|
544
|
+
}
|
|
545
|
+
};
|
|
546
|
+
const stops = [curve.s0, ...profile.map(([sv]) => sv).filter((sv) => sv > curve.s0 && sv < curve.s1), curve.s1];
|
|
547
|
+
for (let i = 0; i < stops.length - 1; i++) {
|
|
548
|
+
split(stops[i], stops[i + 1], 0);
|
|
549
|
+
knots.push(stops[i + 1]);
|
|
550
|
+
}
|
|
551
|
+
for (let i = 1; i < knots.length - 1; i++) {
|
|
552
|
+
const a = curve.radius(knots[i - 1]);
|
|
553
|
+
const b = curve.radius(knots[i]);
|
|
554
|
+
const c = curve.radius(knots[i + 1]);
|
|
555
|
+
const h0 = knots[i] - knots[i - 1];
|
|
556
|
+
const h1 = knots[i + 1] - knots[i];
|
|
557
|
+
if ((c - b) / h1 - (b - a) / h0 > 1e-6) throw new Error(`turning: lathe ${options.name} is not convex near s = ${knots[i].toFixed(2)} (r(s) must be concave); split it`);
|
|
558
|
+
}
|
|
559
|
+
const levels = [];
|
|
560
|
+
for (const sv of knots) {
|
|
561
|
+
const g = curve.slope(sv);
|
|
562
|
+
const norm = Math.hypot(1, g);
|
|
563
|
+
levels.push(sv, curve.radius(sv), 1 / norm, -g / norm);
|
|
564
|
+
}
|
|
565
|
+
const caps = [curve.radius(curve.s0) > 1e-6, curve.radius(curve.s1) > 1e-6];
|
|
566
|
+
const shape = A.solid(F, profile.map(([sv, rv]) => [sv, rv, 1]), { name: options.name, radius: curve.radius });
|
|
567
|
+
const vertical = Math.abs(F.a[2]) > 0.9999;
|
|
568
|
+
const groupOk = !options.group || groupOf(options.group).upright;
|
|
569
|
+
if (vertical && groupOk && options.fixed !== false) {
|
|
570
|
+
const meridian = [[curve.s0, 0], ...knots.map((sv) => [sv, curve.radius(sv), 1]), [curve.s1, 0]];
|
|
571
|
+
meridian[1] = [meridian[1][0], meridian[1][1]];
|
|
572
|
+
meridian[meridian.length - 2] = [meridian[meridian.length - 2][0], meridian[meridian.length - 2][1]];
|
|
573
|
+
const seams = (options.details?.seams ?? []).map((sv) => G.arcOf(F, sv, curve.radius(sv), P, { slope: curve.slope(sv), least: 0 })).join("");
|
|
574
|
+
const svg = k.solidSvg(G.lathe(meridian, F, P, { smooth: { slope: curve.slope, radius: curve.radius } }), { tone: options.tone ?? "hi", crease: options.crease ?? "faint", lit: options.lit, inner: seams ? k.lineSvg(seams, { tone: "faint" }) : "" });
|
|
575
|
+
const part = T.fixed({ ...options, svg, anchor: G.pointOf(F, curve.s0, 0, 0), F, details: { ...(options.details ?? {}), seams: undefined }, shapes: [shape], skipDetails: true });
|
|
576
|
+
detailParts(part, F, curve.radius, [curve.s0, curve.s1]);
|
|
577
|
+
return part;
|
|
578
|
+
}
|
|
579
|
+
const part = common(options, "lathe");
|
|
580
|
+
record(part, shape);
|
|
581
|
+
part.spec = { F: { o: local(part.group, F.o), a: F.a, u: F.u, v: F.v }, levels, caps };
|
|
582
|
+
part.world = { F, profile, curve, knots };
|
|
583
|
+
const pts = [];
|
|
584
|
+
for (const sv of knots) for (let j = 0; j < 32; j++) pts.push(G.pointOf(F, sv, curve.radius(sv), (j / 32) * TAU));
|
|
585
|
+
part.samples = pts.map((p) => local(part.group, p));
|
|
586
|
+
part.hints.push(F.a);
|
|
587
|
+
detailParts(part, F, curve.radius, [curve.s0, curve.s1]);
|
|
588
|
+
T.parts.push(part);
|
|
589
|
+
return part;
|
|
590
|
+
};
|
|
591
|
+
T.routes = [];
|
|
592
|
+
T.tube = (options) => {
|
|
593
|
+
const { route, r, chunk = 6, turn = 20, gaps = [], breaks = [], caps = true, rings = null, hue = null, spacing = 0.8, owner, touch, bundle, rims, free } = options;
|
|
594
|
+
const group = groupOf(options.group);
|
|
595
|
+
const name = options.name ?? `tube#${T.routes.length}`;
|
|
596
|
+
const dense = G.resample(route, spacing);
|
|
597
|
+
const total = G.pathLength(dense);
|
|
598
|
+
const turnBreaks = [];
|
|
599
|
+
let walked = 0;
|
|
600
|
+
let bent = 0;
|
|
601
|
+
for (let i = 1; i < dense.length - 1; i++) {
|
|
602
|
+
walked += G.len3(sub3(dense[i], dense[i - 1]));
|
|
603
|
+
const a = G.unit3(sub3(dense[i], dense[i - 1]));
|
|
604
|
+
const b = G.unit3(sub3(dense[i + 1], dense[i]));
|
|
605
|
+
bent += (Math.acos(Math.max(-1, Math.min(1, dot3(a, b)))) * 180) / Math.PI;
|
|
606
|
+
if (bent > turn) {
|
|
607
|
+
turnBreaks.push(walked);
|
|
608
|
+
bent = 0;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
const allBreaks = [...breaks, ...turnBreaks].sort((x, y) => x - y);
|
|
612
|
+
const pieces = spansOf(total, gaps, allBreaks, chunk);
|
|
613
|
+
const wide = r * k.cameraOf(P).k > 1.3;
|
|
614
|
+
const routeIndex = T.routes.length;
|
|
615
|
+
const capList = [caps].flat();
|
|
616
|
+
T.routes.push({ name, group, points: dense.map((p) => local(group, p)), r, spacing, wide, hue, rings, world: route, gaps, breaks: allBreaks, chunk, caps });
|
|
617
|
+
if (T.R) T.R.route(name, route, r, { gaps, limp: options.limp ?? true, owner: owner ?? name, touch, bundle, rims, free });
|
|
618
|
+
const chunks = pieces.map((piece, index) => {
|
|
619
|
+
const part = common({ ...options, name: `${name}:${index}` }, "tube");
|
|
620
|
+
part.route = name;
|
|
621
|
+
part.chunk = index;
|
|
622
|
+
part.hue = hue;
|
|
623
|
+
part.spec = { route: routeIndex, from: piece.from, to: piece.to, open0: piece.open0, open1: piece.open1, gap0: piece.gap0, gap1: piece.gap1, caps: [capList[0], capList[capList.length - 1]], wide, rings: rings ? { tone: rings.tone ?? "lo" } : null };
|
|
624
|
+
const core = G.cut(dense, piece.from, piece.to);
|
|
625
|
+
part.shapes.push(A.tube(core, r, { name: part.name, owner: owner ?? name }));
|
|
626
|
+
part.world = { route, r, index };
|
|
627
|
+
for (let i = 0; i < core.length; i++) {
|
|
628
|
+
const t = G.unit3(sub3(core[Math.min(core.length - 1, i + 1)], core[Math.max(0, i - 1)]));
|
|
629
|
+
const F = G.frameAlong(core[i], t);
|
|
630
|
+
for (let j = 0; j < 10; j++) part.samples.push(local(group, G.pointOf(F, 0, r, (j / 10) * TAU)));
|
|
631
|
+
}
|
|
632
|
+
part.hints.push(G.unit3(sub3(core[1], core[0])), G.unit3(sub3(core[core.length - 1], core[core.length - 2])));
|
|
633
|
+
T.parts.push(part);
|
|
634
|
+
return part;
|
|
635
|
+
});
|
|
636
|
+
return { name, chunks, total, dense };
|
|
637
|
+
};
|
|
638
|
+
T.fixed = (options) => {
|
|
639
|
+
const part = common(options, "fixed");
|
|
640
|
+
const anchor = options.anchor;
|
|
641
|
+
for (const shape of options.shapes ?? []) record(part, shape);
|
|
642
|
+
const world = part.shapes.flatMap((shape) => sampleShape(shape, 48));
|
|
643
|
+
const flat = world.map((p) => k.iso(p, P));
|
|
644
|
+
const hull = options.hull ?? hull2(flat);
|
|
645
|
+
const box = A.markupBox(options.svg) ?? [Math.min(...hull.map((p) => p[0])), Math.min(...hull.map((p) => p[1])), Math.max(...hull.map((p) => p[0])), Math.max(...hull.map((p) => p[1]))];
|
|
646
|
+
part.svg = options.svg;
|
|
647
|
+
const F = options.F ? frameFrom(options.F) : null;
|
|
648
|
+
part.spec = { anchor: local(part.group, anchor), home: k.iso(anchor, P), box: [box[0] - 0.5, box[1] - 0.5, box[2] + 0.5, box[3] + 0.5], hull: hull.flat(), F: F ? { o: local(part.group, F.o), a: F.a, u: F.u, v: F.v } : null };
|
|
649
|
+
if (F && !options.skipDetails) detailParts(part, F, null, null);
|
|
650
|
+
part.samples = world.map((p) => local(part.group, p));
|
|
651
|
+
if (F) part.hints.push(F.a);
|
|
652
|
+
T.parts.push(part);
|
|
653
|
+
return part;
|
|
654
|
+
};
|
|
655
|
+
T.item = (options) => {
|
|
656
|
+
const part = common(options, "item");
|
|
657
|
+
const cloud = options.cloud ?? [[0, 0, 0]];
|
|
658
|
+
part.spec = { item: 1 };
|
|
659
|
+
part.cloud = cloud.map((p) => local(part.group, p)).flat();
|
|
660
|
+
part.samples = cloud.map((p) => local(part.group, p));
|
|
661
|
+
T.parts.push(part);
|
|
662
|
+
return part;
|
|
663
|
+
};
|
|
664
|
+
T.plane = ({ part, o, u, v, svg, fade = [0.04, 0.3], normal = null }) => {
|
|
665
|
+
const U = G.unit3(u);
|
|
666
|
+
const W = G.unit3(v);
|
|
667
|
+
const n = normal ? G.unit3(normal) : G.unit3(G.cross3(U, W));
|
|
668
|
+
part.planes.push({ o: local(part.group, o), u: U, v: W, n, fade, svg });
|
|
669
|
+
return part;
|
|
670
|
+
};
|
|
671
|
+
T.billboard = ({ part, at, normal, svg, fade = [0.04, 0.3] }) => {
|
|
672
|
+
part.planes.push({ o: local(part.group, at), u: [1, 0, 0], v: [0, 1, 0], n: G.unit3(normal), fade, svg, billboard: true });
|
|
673
|
+
return part;
|
|
674
|
+
};
|
|
675
|
+
const drop = (part) => {
|
|
676
|
+
T.parts.splice(T.parts.indexOf(part), 1);
|
|
677
|
+
T.parts.forEach((p, index) => (p.id = index));
|
|
678
|
+
};
|
|
679
|
+
T.split = (part, { n, d }) => {
|
|
680
|
+
const N = G.unit3(n);
|
|
681
|
+
if (part.kind === "prism") {
|
|
682
|
+
const { F, ring, s0, s1 } = part.world;
|
|
683
|
+
const along = dot3(N, F.a);
|
|
684
|
+
const base = { name: part.name, owner: part.owner, group: part.group, layer: part.layer, tone: part.tone, crease: part.crease, lit: part.lit, material: part.material, details: { ...part.details }, bevel: part.spec.bevel };
|
|
685
|
+
let halves;
|
|
686
|
+
if (Math.abs(Math.abs(along) - 1) < 1e-6) {
|
|
687
|
+
const sCut = (d - dot3(N, F.o)) / along;
|
|
688
|
+
if (!(sCut > s0 + 1e-6 && sCut < s1 - 1e-6)) throw new Error(`turning: split plane misses ${part.name}`);
|
|
689
|
+
halves = [{ s0, s1: sCut }, { s0: sCut, s1 }].map((span) => ({ ...base, F, polygon: ring, s0: span.s0, s1: span.s1 }));
|
|
690
|
+
} else if (Math.abs(along) < 1e-6) {
|
|
691
|
+
const nu = dot3(N, F.u);
|
|
692
|
+
const nv = dot3(N, F.v);
|
|
693
|
+
const c = d - dot3(N, F.o);
|
|
694
|
+
const side = (p) => nu * p[0] + nv * p[1] - c;
|
|
695
|
+
const clip = (sign) => {
|
|
696
|
+
const out = [];
|
|
697
|
+
for (let i = 0; i < ring.length; i++) {
|
|
698
|
+
const a = ring[i];
|
|
699
|
+
const b = ring[(i + 1) % ring.length];
|
|
700
|
+
const sa = sign * side(a);
|
|
701
|
+
const sb = sign * side(b);
|
|
702
|
+
if (sa <= 0) out.push(a);
|
|
703
|
+
if ((sa < 0 && sb > 0) || (sa > 0 && sb < 0)) {
|
|
704
|
+
const t = sa / (sa - sb);
|
|
705
|
+
out.push([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
return out;
|
|
709
|
+
};
|
|
710
|
+
halves = [clip(1), clip(-1)].map((polygon) => ({ ...base, F, polygon, s0, s1 }));
|
|
711
|
+
if (halves.some((h) => h.polygon.length < 3)) throw new Error(`turning: split plane misses ${part.name}`);
|
|
712
|
+
} else throw new Error(`turning: a prism can only be split by a plane along or across its axis (${part.name})`);
|
|
713
|
+
part.dead = true;
|
|
714
|
+
if (T.R) T.R.solids.splice(T.R.solids.indexOf(part.shapes[0]), 1);
|
|
715
|
+
const made = halves.map((h, index) => T.prism({ ...h, name: `${part.name}.${index ? "b" : "a"}`, round: 0, details: index ? { ...h.details, dots: [] } : h.details }));
|
|
716
|
+
for (const half of made) {
|
|
717
|
+
half.spec.cuts = [...(part.spec.cuts ?? []), { n: N, d: d - dot3(N, half.group ? half.group.origin : [0, 0, 0]) }];
|
|
718
|
+
half.hints.unshift(N);
|
|
719
|
+
}
|
|
720
|
+
drop(part);
|
|
721
|
+
return made;
|
|
722
|
+
}
|
|
723
|
+
if (part.kind === "round" || (part.kind === "fixed" && part.round)) {
|
|
724
|
+
const { F, s0, s1, r0, r1, ends } = part.round ?? part.world;
|
|
725
|
+
const along = dot3(N, F.a);
|
|
726
|
+
if (Math.abs(Math.abs(along) - 1) > 1e-6) throw new Error(`turning: a round can only be split across its axis (${part.name})`);
|
|
727
|
+
const sCut = (d - dot3(N, F.o)) / along;
|
|
728
|
+
if (!(sCut > s0 + 1e-6 && sCut < s1 - 1e-6)) throw new Error(`turning: split plane misses ${part.name}`);
|
|
729
|
+
const rAt = r0 + ((r1 - r0) * (sCut - s0)) / (s1 - s0);
|
|
730
|
+
part.dead = true;
|
|
731
|
+
if (T.R) T.R.solids.splice(T.R.solids.indexOf(part.shapes[0]), 1);
|
|
732
|
+
const base = { owner: part.owner, group: part.group, layer: part.layer, tone: part.tone, crease: part.crease, lit: part.lit, material: part.material, F, fixed: false };
|
|
733
|
+
const made = [
|
|
734
|
+
T.round({ ...base, name: `${part.name}.a`, s0, s1: sCut, r: [r0, rAt], ends: [ends[0], "open"], details: part.details }),
|
|
735
|
+
T.round({ ...base, name: `${part.name}.b`, s0: sCut, s1, r: [rAt, r1], ends: ["open", ends[1]] }),
|
|
736
|
+
];
|
|
737
|
+
for (const half of made) {
|
|
738
|
+
half.spec.cuts = [...(part.spec.cuts ?? []), { n: N, d: d - dot3(N, half.group ? half.group.origin : [0, 0, 0]) }];
|
|
739
|
+
half.hints.unshift(N);
|
|
740
|
+
}
|
|
741
|
+
drop(part);
|
|
742
|
+
return made;
|
|
743
|
+
}
|
|
744
|
+
throw new Error(`turning: split supports prisms and rounds (${part.name})`);
|
|
745
|
+
};
|
|
746
|
+
T.build = (options = {}) => build(T, options);
|
|
747
|
+
return T;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function spansOf(total, gaps, breaks, maxLength) {
|
|
751
|
+
const cuts = gaps
|
|
752
|
+
.map(([a, b]) => [Math.max(0, Math.min(a, b)), Math.min(total, Math.max(a, b))])
|
|
753
|
+
.filter(([a, b]) => b - a > 1e-6)
|
|
754
|
+
.sort((x, y) => x[0] - y[0]);
|
|
755
|
+
const runs = [];
|
|
756
|
+
let at = 0;
|
|
757
|
+
for (const [a, b] of cuts) {
|
|
758
|
+
if (a > at + 1e-6) runs.push([at, a, at > 0, true]);
|
|
759
|
+
at = Math.max(at, b);
|
|
760
|
+
}
|
|
761
|
+
if (total > at + 1e-6) runs.push([at, total, at > 0, cuts.length > 0 && cuts[cuts.length - 1][1] >= total - 1e-6]);
|
|
762
|
+
const pieces = [];
|
|
763
|
+
for (const [from, to, gapStart, gapEnd] of runs) {
|
|
764
|
+
const stops = [from, ...breaks.filter((b) => b > from + 1e-6 && b < to - 1e-6).sort((x, y) => x - y), to];
|
|
765
|
+
for (let index = 0; index < stops.length - 1; index++) {
|
|
766
|
+
const a = stops[index];
|
|
767
|
+
const b = stops[index + 1];
|
|
768
|
+
const count = Math.max(1, Math.ceil((b - a) / maxLength - 1e-9));
|
|
769
|
+
for (let c = 0; c < count; c++) {
|
|
770
|
+
pieces.push({
|
|
771
|
+
from: a + ((b - a) * c) / count,
|
|
772
|
+
to: a + ((b - a) * (c + 1)) / count,
|
|
773
|
+
open0: !(index === 0 && c === 0),
|
|
774
|
+
open1: !(index === stops.length - 2 && c === count - 1),
|
|
775
|
+
gap0: index === 0 && c === 0 && gapStart,
|
|
776
|
+
gap1: index === stops.length - 2 && c === count - 1 && gapEnd,
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
return pieces;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function groupData(T) {
|
|
785
|
+
return T.groups.map((g) => ({
|
|
786
|
+
name: g.name,
|
|
787
|
+
parent: g.parent ? g.parent.index : -1,
|
|
788
|
+
kind: g.kind,
|
|
789
|
+
origin: g.origin,
|
|
790
|
+
pivot: g.pivot,
|
|
791
|
+
direction: g.direction,
|
|
792
|
+
travel: g.travel,
|
|
793
|
+
point: g.point,
|
|
794
|
+
axis: g.axis,
|
|
795
|
+
range: g.range,
|
|
796
|
+
upright: g.upright,
|
|
797
|
+
loose: g.loose || undefined,
|
|
798
|
+
}));
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function partData(part) {
|
|
802
|
+
const details = {};
|
|
803
|
+
const seams = part.kind === "prism" ? part.localSeams : part.kind === "fixed" ? null : part.details.seams;
|
|
804
|
+
if (seams && seams.length) details.seams = seams;
|
|
805
|
+
if (part.details.ribs) details.ribs = part.details.ribs;
|
|
806
|
+
const out = { name: part.name, kind: part.kind, group: part.group ? part.group.index : -1, layer: part.layer.index };
|
|
807
|
+
if (part.tone !== "hi") out.tone = part.tone;
|
|
808
|
+
if (part.crease !== "faint") out.crease = part.crease;
|
|
809
|
+
if (part.lit) out.lit = true;
|
|
810
|
+
if (part.material) out.mat = part.material;
|
|
811
|
+
out.spec = part.spec;
|
|
812
|
+
if (Object.keys(details).length) out.details = details;
|
|
813
|
+
if (part.planes.length) out.planes = part.planes.map(({ o, u, v, n, fade, billboard }) => ({ o, u, v, n, fade, billboard }));
|
|
814
|
+
if (part.dots.length) out.dots = part.dots.map(({ p, n, size, tone, fade }) => ({ p, n, size, tone, fade }));
|
|
815
|
+
if (part.kind === "item") out.cloud = part.cloud;
|
|
816
|
+
return out;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function statesOf(T, poses) {
|
|
820
|
+
if (typeof poses === "function") poses = poses();
|
|
821
|
+
if (Array.isArray(poses) && poses.length) return poses;
|
|
822
|
+
const out = [];
|
|
823
|
+
for (let deg = 0; deg < 360; deg += 2) out.push(Object.fromEntries(T.groups.map((g) => [g.name, g.kind === "turn" ? deg : g.range ? g.range[0] + ((g.range[1] - g.range[0]) * (deg % 90)) / 90 : 0])));
|
|
824
|
+
return out;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function tumbleStates(T, group, count = 160) {
|
|
828
|
+
let root = group;
|
|
829
|
+
while (root && root.kind !== "free") root = root.parent;
|
|
830
|
+
if (!root) return [];
|
|
831
|
+
let seed = 977;
|
|
832
|
+
const rand = () => (seed = (seed * 16807) % 2147483647) / 2147483647;
|
|
833
|
+
const out = [];
|
|
834
|
+
for (let i = 0; i < count; i++) {
|
|
835
|
+
const u = rand();
|
|
836
|
+
const a = rand() * TAU;
|
|
837
|
+
const b = rand() * TAU;
|
|
838
|
+
const q = [Math.sqrt(1 - u) * Math.sin(a), Math.sqrt(1 - u) * Math.cos(a), Math.sqrt(u) * Math.sin(b), Math.sqrt(u) * Math.cos(b)];
|
|
839
|
+
const values = { [root.name]: { q } };
|
|
840
|
+
for (const g of T.groups) if (g.kind === "hinge" && g.loose) values[g.name] = g.range[0] + (g.range[1] - g.range[0]) * rand();
|
|
841
|
+
out.push(values);
|
|
842
|
+
}
|
|
843
|
+
return out;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
function groupStates(T, group, states, { tumble = false } = {}) {
|
|
847
|
+
if (!group) return [{}];
|
|
848
|
+
let chain = [];
|
|
849
|
+
for (let g = group; g; g = g.parent) chain.push(g);
|
|
850
|
+
if (group.loose) return tumble ? [...states, ...tumbleStates(T, group)] : states.length ? states : [{}];
|
|
851
|
+
if (chain.length === 1 && group.kind === "turn") return Array.from({ length: 360 }, (_, deg) => ({ [group.name]: deg }));
|
|
852
|
+
if (chain.length === 1) {
|
|
853
|
+
const [lo, hi] = group.range ?? [0, 0];
|
|
854
|
+
return Array.from({ length: 33 }, (_, i) => ({ [group.name]: lo + ((hi - lo) * i) / 32 }));
|
|
855
|
+
}
|
|
856
|
+
return states;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function sweptGroups(T, layer, states, scene) {
|
|
860
|
+
const V = G.viewOf(T.P);
|
|
861
|
+
const cam = k.cameraOf(T.P);
|
|
862
|
+
const byGroup = new Map();
|
|
863
|
+
for (const part of T.parts) {
|
|
864
|
+
if (part.layer !== layer || part.dead) continue;
|
|
865
|
+
const key = part.group ? part.group.index : -1;
|
|
866
|
+
if (!byGroup.has(key)) byGroup.set(key, []);
|
|
867
|
+
byGroup.get(key).push(part);
|
|
868
|
+
}
|
|
869
|
+
const out = [];
|
|
870
|
+
for (const [key, parts] of byGroup) {
|
|
871
|
+
const group = key >= 0 ? T.groups[key] : null;
|
|
872
|
+
const local = parts.flatMap((part) => part.samples);
|
|
873
|
+
const root = group && group.kind === "turn" && !group.parent;
|
|
874
|
+
const list = root ? Array.from({ length: 72 }, (_, i) => ({ [group.name]: i * 5 })) : group ? groupStates(T, group, states) : [{}];
|
|
875
|
+
let zmin = Infinity;
|
|
876
|
+
let zmax = -Infinity;
|
|
877
|
+
const box = [Infinity, Infinity, -Infinity, -Infinity];
|
|
878
|
+
const cloud = [];
|
|
879
|
+
const stride = Math.max(1, Math.floor((local.length * list.length) / 4000));
|
|
880
|
+
let counter = 0;
|
|
881
|
+
for (const values of list) {
|
|
882
|
+
const cams = TURN.cams(scene, values);
|
|
883
|
+
const c = key >= 0 ? cams[key] : cams.world;
|
|
884
|
+
for (const q of local) {
|
|
885
|
+
const p = [c.R[0] * q[0] + c.R[1] * q[1] + c.R[2] * q[2] + c.t[0], c.R[3] * q[0] + c.R[4] * q[1] + c.R[5] * q[2] + c.t[1], c.R[6] * q[0] + c.R[7] * q[1] + c.R[8] * q[2] + c.t[2]];
|
|
886
|
+
if (p[2] < zmin) zmin = p[2];
|
|
887
|
+
if (p[2] > zmax) zmax = p[2];
|
|
888
|
+
const [x, y] = k.iso(p, T.P);
|
|
889
|
+
if (x < box[0]) box[0] = x;
|
|
890
|
+
if (y < box[1]) box[1] = y;
|
|
891
|
+
if (x > box[2]) box[2] = x;
|
|
892
|
+
if (y > box[3]) box[3] = y;
|
|
893
|
+
if (counter++ % stride === 0) cloud.push(p);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
let radius = 0;
|
|
897
|
+
let envelope = cloud;
|
|
898
|
+
if (root) {
|
|
899
|
+
const slices = new Map();
|
|
900
|
+
for (const q of local) {
|
|
901
|
+
const band = Math.floor(q[2]);
|
|
902
|
+
slices.set(band, Math.max(slices.get(band) ?? 0, Math.hypot(q[0], q[1])));
|
|
903
|
+
radius = Math.max(radius, Math.hypot(q[0], q[1]));
|
|
904
|
+
}
|
|
905
|
+
envelope = [];
|
|
906
|
+
for (const [band, r] of slices)
|
|
907
|
+
for (const z of [band, band + 1])
|
|
908
|
+
for (let j = 0; j < 48; j++) envelope.push([group.origin[0] + r * Math.cos((j / 48) * TAU), group.origin[1] + r * Math.sin((j / 48) * TAU), Math.max(zmin, Math.min(zmax, z))]);
|
|
909
|
+
const pad = radius * cam.k;
|
|
910
|
+
const c = k.iso([group.origin[0], group.origin[1], 0], T.P);
|
|
911
|
+
box[0] = Math.min(box[0], c[0] - pad);
|
|
912
|
+
box[2] = Math.max(box[2], c[0] + pad);
|
|
913
|
+
}
|
|
914
|
+
out.push({ key, group, parts, root, radius, zmin, zmax, box, envelope, V });
|
|
915
|
+
}
|
|
916
|
+
return out;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
function relation(points, box, swept, P) {
|
|
920
|
+
const V = G.viewOf(P);
|
|
921
|
+
let zmin = Infinity;
|
|
922
|
+
let zmax = -Infinity;
|
|
923
|
+
for (const p of points) {
|
|
924
|
+
zmin = Math.min(zmin, p[2]);
|
|
925
|
+
zmax = Math.max(zmax, p[2]);
|
|
926
|
+
}
|
|
927
|
+
if (zmax <= swept.zmin + 1e-6) return { side: "behind", rule: "horizontal plane" };
|
|
928
|
+
if (zmin >= swept.zmax - 1e-6) return { side: "front", rule: "horizontal plane" };
|
|
929
|
+
if (swept.root) {
|
|
930
|
+
const a = ((P.azimuth ?? 45) * Math.PI) / 180;
|
|
931
|
+
const along = points.map((p) => (p[0] - swept.group.origin[0]) * Math.cos(a) + (p[1] - swept.group.origin[1]) * Math.sin(a));
|
|
932
|
+
if (Math.max(...along) <= -swept.radius) return { side: "behind", rule: "cylinder" };
|
|
933
|
+
if (Math.min(...along) >= swept.radius) return { side: "front", rule: "cylinder" };
|
|
934
|
+
}
|
|
935
|
+
if (box && (box[2] < swept.box[0] - 1 || swept.box[2] < box[0] - 1 || box[3] < swept.box[1] - 1 || swept.box[3] < box[1] - 1)) return { side: "apart", rule: "never overlap" };
|
|
936
|
+
const hit = separate(points, swept.envelope, [[0, 0, 1], [Math.cos(((P.azimuth ?? 45) * Math.PI) / 180), Math.sin(((P.azimuth ?? 45) * Math.PI) / 180), 0]]);
|
|
937
|
+
if (hit.margin >= -0.08) return { side: dot3(hit.n, V) > 0 ? "behind" : "front", rule: "swept envelope", margin: hit.margin };
|
|
938
|
+
return null;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
function proveLayers(T, statics, states, scene) {
|
|
942
|
+
const order = T.order.length ? T.order : T.layers.map((layer) => layer.name);
|
|
943
|
+
const rank = new Map(order.map((name, index) => [name, index]));
|
|
944
|
+
const problems = [];
|
|
945
|
+
const proofs = [];
|
|
946
|
+
const swept = new Map(T.layers.map((layer) => [layer.name, sweptGroups(T, layer, states, scene)]));
|
|
947
|
+
for (const layer of T.layers) if (!rank.has(layer.name)) problems.push(`live layer ${layer.name} is missing from T.stack`);
|
|
948
|
+
for (const item of statics) {
|
|
949
|
+
if (!item.shapes || !item.shapes.length) continue;
|
|
950
|
+
const layerName = item.layer;
|
|
951
|
+
if (!rank.has(layerName)) {
|
|
952
|
+
problems.push(`static ${item.name} has layer ${layerName ?? "(none)"}, which is not in T.stack`);
|
|
953
|
+
continue;
|
|
954
|
+
}
|
|
955
|
+
const points = item.shapes.flatMap((shape) => sampleShape(shape, 24));
|
|
956
|
+
const flat = points.map((p) => k.iso(p, T.P));
|
|
957
|
+
const box = [Math.min(...flat.map((p) => p[0])), Math.min(...flat.map((p) => p[1])), Math.max(...flat.map((p) => p[0])), Math.max(...flat.map((p) => p[1]))];
|
|
958
|
+
for (const layer of T.layers) {
|
|
959
|
+
const want = rank.get(layerName) < rank.get(layer.name) ? "behind" : "front";
|
|
960
|
+
for (const group of swept.get(layer.name)) {
|
|
961
|
+
const proof = relation(points, box, group, T.P);
|
|
962
|
+
const label = `${item.name} × ${layer.name}${group.group ? `/${group.group.name}` : ""}`;
|
|
963
|
+
if (!proof) {
|
|
964
|
+
const zs = points.map((p) => p[2]);
|
|
965
|
+
const top = Math.max(...zs);
|
|
966
|
+
const bottom = Math.min(...zs);
|
|
967
|
+
const where = top > group.zmax + 1e-6 ? `at z = ${group.zmax.toFixed(2)}` : bottom < group.zmin - 1e-6 ? `at z = ${group.zmin.toFixed(2)}` : `into a piece behind and a piece in front of ${group.group ? `the ${group.group.name} axis` : "the layer"}`;
|
|
968
|
+
problems.push(`static ${item.name} interleaves live layer ${layer.name}: split ${item.name} ${where}, or move it`);
|
|
969
|
+
continue;
|
|
970
|
+
}
|
|
971
|
+
if (proof.side !== "apart" && proof.side !== want) {
|
|
972
|
+
problems.push(`static ${item.name} is ${proof.side === "behind" ? "behind" : "in front of"} live layer ${layer.name} (${proof.rule}) but its layer ${layerName} is ${want === "behind" ? "under" : "over"} it: move it to a layer ${proof.side === "behind" ? "under" : "over"} ${layer.name}`);
|
|
973
|
+
continue;
|
|
974
|
+
}
|
|
975
|
+
proofs.push({ static: item.name, layer: layer.name, group: group.group ? group.group.name : "world", rule: proof.rule, side: proof.side });
|
|
976
|
+
void label;
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
const live = T.layers.slice().sort((a, b) => rank.get(a.name) - rank.get(b.name));
|
|
981
|
+
for (let i = 0; i < live.length; i++)
|
|
982
|
+
for (let j = i + 1; j < live.length; j++)
|
|
983
|
+
for (const lower of swept.get(live[i].name))
|
|
984
|
+
for (const upper of swept.get(live[j].name)) {
|
|
985
|
+
let ok = lower.zmax <= upper.zmin + 1e-6 ? "horizontal plane" : null;
|
|
986
|
+
if (!ok && (lower.box[2] < upper.box[0] - 1 || upper.box[2] < lower.box[0] - 1 || lower.box[3] < upper.box[1] - 1 || upper.box[3] < lower.box[1] - 1)) ok = "never overlap";
|
|
987
|
+
if (!ok) {
|
|
988
|
+
const hit = separate(lower.envelope, upper.envelope, [[0, 0, 1]]);
|
|
989
|
+
if (hit.margin >= -0.08 && dot3(hit.n, G.viewOf(T.P)) > 0) ok = "swept envelope";
|
|
990
|
+
}
|
|
991
|
+
if (!ok) problems.push(`live layer ${live[i].name} interleaves live layer ${live[j].name} (${lower.group ? lower.group.name : "world"} × ${upper.group ? upper.group.name : "world"}): move parts between them or reorder T.stack`);
|
|
992
|
+
else proofs.push({ static: `layer ${live[i].name}`, layer: live[j].name, group: upper.group ? upper.group.name : "world", rule: ok, side: "behind" });
|
|
993
|
+
}
|
|
994
|
+
return { problems, proofs };
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
function build(T, { statics = [], verify = false, poses = null, strict = true, fullClouds = false } = {}) {
|
|
998
|
+
const P = T.P;
|
|
999
|
+
const started = Date.now();
|
|
1000
|
+
const states = statesOf(T, poses);
|
|
1001
|
+
const groups = groupData(T);
|
|
1002
|
+
const parts = T.parts.map(partData);
|
|
1003
|
+
const sweep = (layerParts, list) => {
|
|
1004
|
+
const data = { P, groups, layers: T.layers.map((layer) => ({ name: layer.name, parts: [], rest: [] })), parts };
|
|
1005
|
+
const scene = TURN.prepare(data, { unit: T.scale });
|
|
1006
|
+
const frames = scene.runs.map((run) => TURN.frame(run));
|
|
1007
|
+
return { scene, frames, data };
|
|
1008
|
+
};
|
|
1009
|
+
void sweep;
|
|
1010
|
+
const routes = T.routes.map((route) => ({ points: route.points.flat().map(r4), r: route.r, spacing: route.spacing, wide: route.wide, hue: route.hue, rings: route.rings }));
|
|
1011
|
+
const scratch = { P, groups, layers: T.layers.map((layer) => ({ name: layer.name, parts: [], rest: [] })), parts, routes };
|
|
1012
|
+
let scene = TURN.prepare(scratch, { unit: T.scale });
|
|
1013
|
+
const slotNeed = parts.map(() => ({ bevel: 0, ribs: 0 }));
|
|
1014
|
+
const sampleCams = new Map();
|
|
1015
|
+
const camsFor = (group) => {
|
|
1016
|
+
const key = group ? group.index : -1;
|
|
1017
|
+
if (!sampleCams.has(key)) {
|
|
1018
|
+
const list = groupStates(T, group, states, { tumble: true }).map((values) => TURN.cams(scene, values));
|
|
1019
|
+
sampleCams.set(key, list);
|
|
1020
|
+
}
|
|
1021
|
+
return sampleCams.get(key);
|
|
1022
|
+
};
|
|
1023
|
+
T.parts.forEach((part, index) => {
|
|
1024
|
+
const run = scene.runs[index];
|
|
1025
|
+
const needs = part.kind === "item" ? false : part.kind === "prism" ? Boolean(part.spec.bevel) : part.kind === "tube" ? Boolean(part.spec.rings) : Boolean(part.details.ribs);
|
|
1026
|
+
if (!needs) return;
|
|
1027
|
+
const g = part.group ? part.group.index : -1;
|
|
1028
|
+
const wide = { ...run, slots: { bevel: 64, ribs: 64 }, role: { ...run.role } };
|
|
1029
|
+
for (let j = 0; j < 64; j++) {
|
|
1030
|
+
wide.role[`v${j}`] = 1000 + j;
|
|
1031
|
+
wide.role[`r${j}`] = 2000 + j;
|
|
1032
|
+
}
|
|
1033
|
+
const f = TURN.frame(run);
|
|
1034
|
+
f.d = new Array(3000).fill("");
|
|
1035
|
+
f.o = new Float64Array(3000);
|
|
1036
|
+
f.q = new Int32Array(3000);
|
|
1037
|
+
f.w = new Float64Array(3000);
|
|
1038
|
+
f.t = new Array(3000).fill("");
|
|
1039
|
+
f.cx = new Float64Array(3000);
|
|
1040
|
+
f.cy = new Float64Array(3000);
|
|
1041
|
+
let most = 0;
|
|
1042
|
+
for (const cams of camsFor(part.group)) {
|
|
1043
|
+
TURN.emit(wide, g >= 0 ? cams[g] : cams.world, f);
|
|
1044
|
+
most = Math.max(most, f.levels ?? 0);
|
|
1045
|
+
}
|
|
1046
|
+
slotNeed[index][part.kind === "prism" ? "bevel" : "ribs"] = Math.max(1, most);
|
|
1047
|
+
});
|
|
1048
|
+
parts.forEach((data, index) => {
|
|
1049
|
+
data.spec = slotNeed[index].bevel || slotNeed[index].ribs ? { ...data.spec, slots: slotNeed[index] } : { ...data.spec };
|
|
1050
|
+
});
|
|
1051
|
+
scene = TURN.prepare({ ...scratch, parts }, { unit: T.scale });
|
|
1052
|
+
const centre = (list) => mul3(list.reduce((s, p) => add3(s, p), [0, 0, 0]), 1 / Math.max(1, list.length));
|
|
1053
|
+
const spheres = T.parts.map((part) => {
|
|
1054
|
+
const c = centre(part.samples);
|
|
1055
|
+
let r = 0;
|
|
1056
|
+
for (const p of part.samples) r = Math.max(r, G.len3(sub3(p, c)));
|
|
1057
|
+
return { c, r };
|
|
1058
|
+
});
|
|
1059
|
+
const planes = {};
|
|
1060
|
+
const dynamic = {};
|
|
1061
|
+
const free = {};
|
|
1062
|
+
let stateCamList = null;
|
|
1063
|
+
const stateCams = () => stateCamList ?? (stateCamList = states.map((values) => TURN.cams(scene, values)));
|
|
1064
|
+
const boxCache = new Map();
|
|
1065
|
+
const boxesOf = (part, list, key) => {
|
|
1066
|
+
const id = `${part.id}|${key}`;
|
|
1067
|
+
if (boxCache.has(id)) return boxCache.get(id);
|
|
1068
|
+
const local = thinCloud(part.samples, 40);
|
|
1069
|
+
const out = new Float64Array(4 * list.length + 1);
|
|
1070
|
+
const steps = [];
|
|
1071
|
+
list.forEach((cams, at) => {
|
|
1072
|
+
const c = part.group ? cams[part.group.index] : cams.world;
|
|
1073
|
+
let x0 = Infinity;
|
|
1074
|
+
let y0 = Infinity;
|
|
1075
|
+
let x1 = -Infinity;
|
|
1076
|
+
let y1 = -Infinity;
|
|
1077
|
+
for (const q of local) {
|
|
1078
|
+
const x = c.ox + c.m[0] * q[0] + c.m[1] * q[1] + c.m[2] * q[2];
|
|
1079
|
+
const y = c.oy + c.m[3] * q[0] + c.m[4] * q[1] + c.m[5] * q[2];
|
|
1080
|
+
if (x < x0) x0 = x;
|
|
1081
|
+
if (x > x1) x1 = x;
|
|
1082
|
+
if (y < y0) y0 = y;
|
|
1083
|
+
if (y > y1) y1 = y;
|
|
1084
|
+
}
|
|
1085
|
+
if (at) steps.push(Math.max(Math.abs(x0 - out[4 * at - 4]), Math.abs(y0 - out[4 * at - 3]), Math.abs(x1 - out[4 * at - 2]), Math.abs(y1 - out[4 * at - 1])));
|
|
1086
|
+
out.set([x0, y0, x1, y1], 4 * at);
|
|
1087
|
+
});
|
|
1088
|
+
steps.sort((x, y) => x - y);
|
|
1089
|
+
out[4 * list.length] = steps.length ? steps[Math.floor(steps.length * 0.95)] : 0;
|
|
1090
|
+
boxCache.set(id, out);
|
|
1091
|
+
return out;
|
|
1092
|
+
};
|
|
1093
|
+
const boxesMeet = (a, b, list, key) => {
|
|
1094
|
+
const A = boxesOf(a, list, key);
|
|
1095
|
+
const B = boxesOf(b, list, key);
|
|
1096
|
+
const pad = 1.5 + A[4 * list.length] + B[4 * list.length];
|
|
1097
|
+
for (let i = 0; i < list.length; i++) {
|
|
1098
|
+
const o = 4 * i;
|
|
1099
|
+
if (A[o] < B[o + 2] + pad && B[o] < A[o + 2] + pad && A[o + 1] < B[o + 3] + pad && B[o + 1] < A[o + 3] + pad) return true;
|
|
1100
|
+
}
|
|
1101
|
+
return false;
|
|
1102
|
+
};
|
|
1103
|
+
let framedCount = 0;
|
|
1104
|
+
const framedAll = {};
|
|
1105
|
+
const groupPairsAll = {};
|
|
1106
|
+
const ancestors = (g) => {
|
|
1107
|
+
const out = [];
|
|
1108
|
+
for (let x = g; x; x = x.parent) out.push(x);
|
|
1109
|
+
return out;
|
|
1110
|
+
};
|
|
1111
|
+
const commonOf = (g1, g2) => {
|
|
1112
|
+
const list = ancestors(g1);
|
|
1113
|
+
for (let x = g2; x; x = x.parent) if (list.includes(x)) return x;
|
|
1114
|
+
return null;
|
|
1115
|
+
};
|
|
1116
|
+
const sampleStates = () => {
|
|
1117
|
+
const all = stateCams();
|
|
1118
|
+
const count = Math.min(all.length, 24);
|
|
1119
|
+
return Array.from({ length: count }, (_, i) => all[Math.floor((i * all.length) / count)]);
|
|
1120
|
+
};
|
|
1121
|
+
const toFrame = (dir, from, frame) => {
|
|
1122
|
+
if (from === frame) return dir;
|
|
1123
|
+
const cams = sampleStates()[0] ?? TURN.cams(scene, {});
|
|
1124
|
+
const Rf = from ? cams[from.index].R : [1, 0, 0, 0, 1, 0, 0, 0, 1];
|
|
1125
|
+
const w = [Rf[0] * dir[0] + Rf[1] * dir[1] + Rf[2] * dir[2], Rf[3] * dir[0] + Rf[4] * dir[1] + Rf[5] * dir[2], Rf[6] * dir[0] + Rf[7] * dir[1] + Rf[8] * dir[2]];
|
|
1126
|
+
const Rc = frame ? cams[frame.index].R : [1, 0, 0, 0, 1, 0, 0, 0, 1];
|
|
1127
|
+
return [Rc[0] * w[0] + Rc[3] * w[1] + Rc[6] * w[2], Rc[1] * w[0] + Rc[4] * w[1] + Rc[7] * w[2], Rc[2] * w[0] + Rc[5] * w[1] + Rc[8] * w[2]];
|
|
1128
|
+
};
|
|
1129
|
+
const cloudCache = new Map();
|
|
1130
|
+
const framedCloud = (part, frame) => {
|
|
1131
|
+
const key = `${part.id}|${frame ? frame.index : -1}`;
|
|
1132
|
+
if (cloudCache.has(key)) return cloudCache.get(key);
|
|
1133
|
+
const local = thinCloud(part.samples, 32);
|
|
1134
|
+
const out = [];
|
|
1135
|
+
for (const cams of sampleStates()) {
|
|
1136
|
+
const c = part.group ? cams[part.group.index] : cams.world;
|
|
1137
|
+
const f = frame ? cams[frame.index] : cams.world;
|
|
1138
|
+
for (const q of local) {
|
|
1139
|
+
const p = [c.R[0] * q[0] + c.R[1] * q[1] + c.R[2] * q[2] + c.t[0] - f.t[0], c.R[3] * q[0] + c.R[4] * q[1] + c.R[5] * q[2] + c.t[1] - f.t[1], c.R[6] * q[0] + c.R[7] * q[1] + c.R[8] * q[2] + c.t[2] - f.t[2]];
|
|
1140
|
+
out.push([f.R[0] * p[0] + f.R[3] * p[1] + f.R[6] * p[2], f.R[1] * p[0] + f.R[4] * p[1] + f.R[7] * p[2], f.R[2] * p[0] + f.R[5] * p[1] + f.R[8] * p[2]]);
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
cloudCache.set(key, out);
|
|
1144
|
+
return out;
|
|
1145
|
+
};
|
|
1146
|
+
const skip = {};
|
|
1147
|
+
const problems = [];
|
|
1148
|
+
const margins = [];
|
|
1149
|
+
const planesByName = new Map();
|
|
1150
|
+
const layerData = [];
|
|
1151
|
+
for (const layer of T.layers) {
|
|
1152
|
+
const members = T.parts.filter((part) => part.layer === layer);
|
|
1153
|
+
const plan = [];
|
|
1154
|
+
const dyn = [];
|
|
1155
|
+
const framed = [];
|
|
1156
|
+
const skipped = [];
|
|
1157
|
+
const pairsOfGroups = [];
|
|
1158
|
+
const settled = new Set();
|
|
1159
|
+
const byGroup = new Map();
|
|
1160
|
+
for (const part of members) {
|
|
1161
|
+
if (part.group && part.group.loose) continue;
|
|
1162
|
+
const key = part.group ? part.group.index : -1;
|
|
1163
|
+
if (!byGroup.has(key)) byGroup.set(key, []);
|
|
1164
|
+
byGroup.get(key).push(part);
|
|
1165
|
+
}
|
|
1166
|
+
const keys = [...byGroup.keys()];
|
|
1167
|
+
if (states.length)
|
|
1168
|
+
for (let x = 0; x < keys.length; x++)
|
|
1169
|
+
for (let y = x + 1; y < keys.length; y++) {
|
|
1170
|
+
const A = byGroup.get(keys[x]);
|
|
1171
|
+
const B = byGroup.get(keys[y]);
|
|
1172
|
+
const ga = keys[x] >= 0 ? T.groups[keys[x]] : null;
|
|
1173
|
+
const gb = keys[y] >= 0 ? T.groups[keys[y]] : null;
|
|
1174
|
+
const common = commonOf(ga, gb);
|
|
1175
|
+
const ca = A.flatMap((part) => framedCloud(part, common).filter((_, i) => fullClouds || i % 2 === 0));
|
|
1176
|
+
const cb = B.flatMap((part) => framedCloud(part, common).filter((_, i) => fullClouds || i % 2 === 0));
|
|
1177
|
+
const hit = quickSeparate(ca, cb, [[0, 0, 1], ...A.flatMap((part) => part.hints).slice(0, 6).map((h) => toFrame(h, ga, common)), ...B.flatMap((part) => part.hints).slice(0, 6).map((h) => toFrame(h, gb, common))]);
|
|
1178
|
+
if (hit.margin < 0.1) continue;
|
|
1179
|
+
pairsOfGroups.push(keys[x], keys[y], common ? common.index : -1, hit.n[0], hit.n[1], hit.n[2]);
|
|
1180
|
+
settled.add(`${keys[x]}|${keys[y]}`);
|
|
1181
|
+
settled.add(`${keys[y]}|${keys[x]}`);
|
|
1182
|
+
}
|
|
1183
|
+
groupPairsAll[layer.name] = pairsOfGroups;
|
|
1184
|
+
for (let x = 0; x < members.length; x++)
|
|
1185
|
+
for (let y = x + 1; y < members.length; y++) {
|
|
1186
|
+
const a = members[x];
|
|
1187
|
+
const b = members[y];
|
|
1188
|
+
if (a.route && a.route === b.route && Math.abs(a.chunk - b.chunk) <= 1) {
|
|
1189
|
+
skipped.push(a.id, b.id);
|
|
1190
|
+
continue;
|
|
1191
|
+
}
|
|
1192
|
+
const sa = spheres[a.id];
|
|
1193
|
+
const sb = spheres[b.id];
|
|
1194
|
+
if (a.group === b.group) {
|
|
1195
|
+
const g = a.group ? a.group.index : -1;
|
|
1196
|
+
const reach = G.len3(sub3(sb.c, sa.c)) + sa.r + sb.r;
|
|
1197
|
+
let near = false;
|
|
1198
|
+
void reach;
|
|
1199
|
+
if (a.group && a.group.loose) near = G.len3(sub3(sb.c, sa.c)) < sa.r + sb.r + 1;
|
|
1200
|
+
else near = boxesMeet(a, b, camsFor(a.group), `g${g}`);
|
|
1201
|
+
if (!near) continue;
|
|
1202
|
+
const hit = separate(a.samples, b.samples, [[0, 0, 1], ...a.hints, ...b.hints]);
|
|
1203
|
+
margins.push({ a: a.name, b: b.name, margin: hit.margin });
|
|
1204
|
+
const o = a.group ? a.group.origin : [0, 0, 0];
|
|
1205
|
+
planesByName.set(`${a.name}|${b.name}`, { n: hit.n, d: hit.d + dot3(hit.n, o) });
|
|
1206
|
+
if (hit.margin < -0.08) {
|
|
1207
|
+
problems.push(`interlock ${a.name} × ${b.name} ${hit.margin.toFixed(2)}: split, flats or a flush contact`);
|
|
1208
|
+
if (strict) continue;
|
|
1209
|
+
}
|
|
1210
|
+
plan.push(a.id, b.id, hit.n[0], hit.n[1], hit.n[2]);
|
|
1211
|
+
} else if ((a.group && a.group.loose) || (b.group && b.group.loose)) {
|
|
1212
|
+
continue;
|
|
1213
|
+
} else if (settled.has(`${a.group ? a.group.index : -1}|${b.group ? b.group.index : -1}`)) {
|
|
1214
|
+
continue;
|
|
1215
|
+
} else {
|
|
1216
|
+
const near = !states.length || boxesMeet(a, b, stateCams(), "states");
|
|
1217
|
+
if (!near) continue;
|
|
1218
|
+
const common = commonOf(a.group, b.group);
|
|
1219
|
+
const ca = framedCloud(a, common);
|
|
1220
|
+
const cb = framedCloud(b, common);
|
|
1221
|
+
const hit = quickSeparate(ca, cb, [[0, 0, 1], ...a.hints, ...b.hints].map((h) => toFrame(h, a.group, common)).concat([[0, 0, 1], ...b.hints.map((h) => toFrame(h, b.group, common))]));
|
|
1222
|
+
if (hit.margin >= 0.1) {
|
|
1223
|
+
framed.push(a.id, b.id, common ? common.index : -1, hit.n[0], hit.n[1], hit.n[2]);
|
|
1224
|
+
framedCount++;
|
|
1225
|
+
} else dyn.push(a.id, b.id);
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
planes[layer.name] = plan;
|
|
1229
|
+
dynamic[layer.name] = dyn;
|
|
1230
|
+
framedAll[layer.name] = framed;
|
|
1231
|
+
free[layer.name] = members.filter((part) => part.group && part.group.loose).map((part) => part.id);
|
|
1232
|
+
skip[layer.name] = skipped;
|
|
1233
|
+
layerData.push({ name: layer.name, parts: members.map((part) => part.id), rest: [] });
|
|
1234
|
+
}
|
|
1235
|
+
if (problems.length && strict) throw new Error(`turning: ${problems.length} pair${problems.length === 1 ? "" : "s"} with no separating plane\n ${problems.join("\n ")}`);
|
|
1236
|
+
const layered = proveLayers(T, statics, states, scene);
|
|
1237
|
+
if (layered.problems.length) throw new Error(`turning: ${layered.problems.length} layer problem${layered.problems.length === 1 ? "" : "s"}\n ${layered.problems.join("\n ")}`);
|
|
1238
|
+
const values = Object.fromEntries(T.groups.map((g) => [g.name, g.kind === "free" ? null : 0]));
|
|
1239
|
+
const data = { P, groups, layers: layerData, parts, routes, planes: packPairs(planes, 5, false), framed: packPairs(framedAll, 6, true), groupPairs: pack(groupPairsAll), dynamic, free, skip, values };
|
|
1240
|
+
const clouds = T.parts.map((part) => thinCloud(part.samples, 64));
|
|
1241
|
+
data.parts.forEach((p, index) => {
|
|
1242
|
+
if (p.kind === "fixed") p.cloud = clouds[index].flat().map(r4);
|
|
1243
|
+
});
|
|
1244
|
+
const packed = { ...pack({ ...data, parts: [] }), parts: pack3(data.parts) };
|
|
1245
|
+
scene = TURN.prepare(packed, { unit: T.scale });
|
|
1246
|
+
const cams = TURN.cams(scene, values);
|
|
1247
|
+
const frames = scene.runs.map((run) => TURN.emit(run, run.group >= 0 ? cams[run.group] : cams.world, TURN.frame(run)));
|
|
1248
|
+
const V = G.viewOf(P);
|
|
1249
|
+
scene.layers.forEach((layer, index) => {
|
|
1250
|
+
const provisional = Int32Array.from(Array.from(layer.parts).sort((i, j) => dot3(spheres[i].c, V) - dot3(spheres[j].c, V)));
|
|
1251
|
+
const order = TURN.order(layer, frames, cams, provisional, {}, scene);
|
|
1252
|
+
packed.layers[index].rest = Array.from(order);
|
|
1253
|
+
layer.rest = order;
|
|
1254
|
+
});
|
|
1255
|
+
const markup = {};
|
|
1256
|
+
scene.layers.forEach((layer, index) => {
|
|
1257
|
+
markup[layer.name] = `<g class="iso-parts" data-layer="${esc(layer.name)}">${packed.layers[index].rest.map((i) => partMarkup(T.parts[i], scene.runs[i], frames[i])).join("")}</g>`;
|
|
1258
|
+
});
|
|
1259
|
+
const report = {
|
|
1260
|
+
parts: T.parts.length,
|
|
1261
|
+
groups: T.groups.length,
|
|
1262
|
+
layers: T.layers.map((layer) => ({ name: layer.name, parts: packed.layers[layer.index].parts.length, planar: planes[layer.name].length / 5, framed: framedAll[layer.name].length / 6, groupPairs: groupPairsAll[layer.name].length / 6, dynamic: dynamic[layer.name].length / 2, skip: skipped(skip[layer.name]) })),
|
|
1263
|
+
tight: margins.filter((m) => m.margin < 0).sort((a, b) => a.margin - b.margin),
|
|
1264
|
+
margins,
|
|
1265
|
+
proofs: layered.proofs,
|
|
1266
|
+
seconds: (Date.now() - started) / 1000,
|
|
1267
|
+
};
|
|
1268
|
+
const out = { svg: markup, data: packed, css: TURN_CSS, report, scene, frames, planesByName };
|
|
1269
|
+
out.layerSvg = (name, { width, height, label = "" }) => `<svg xmlns="http://www.w3.org/2000/svg" class="iso-svg iso-live iso-layer" data-live="${esc(name)}" viewBox="0 0 ${width} ${height}" aria-hidden="true"${label ? ` aria-label="${esc(label)}"` : ""}>${markup[name]}</svg>`;
|
|
1270
|
+
if (verify) out.verify = verifyData(T, statics);
|
|
1271
|
+
T.built = out;
|
|
1272
|
+
T.data = packed;
|
|
1273
|
+
return out;
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
const skipped = (list) => list.length / 2;
|
|
1277
|
+
|
|
1278
|
+
function attrs(pairs) {
|
|
1279
|
+
return Object.entries(pairs)
|
|
1280
|
+
.filter(([, v]) => v !== undefined && v !== null && v !== false)
|
|
1281
|
+
.map(([key, v]) => ` ${key}="${esc(v)}"`)
|
|
1282
|
+
.join("");
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
function roleMarkup(part, run, f, r) {
|
|
1286
|
+
const role = run.roles[r];
|
|
1287
|
+
if (role.type === "path") return `<path${attrs({ class: role.cls, "data-shade": role.shade === undefined ? undefined : String(role.shade), "data-tone": role.tone, "data-r": role.name, d: f.d[r] })}/>`;
|
|
1288
|
+
if (role.type === "face") {
|
|
1289
|
+
const q = f.q[r];
|
|
1290
|
+
const band = Math.min(3, Math.floor(q / TURN.q));
|
|
1291
|
+
const mix = (q - TURN.q * band) / TURN.q;
|
|
1292
|
+
const style = [mix ? `--f:${mix}` : "", f.w[r] >= 0 ? `stroke-width:${f.w[r]}` : ""].filter(Boolean).join(";");
|
|
1293
|
+
return `<path${attrs({ class: "iso-tn", "data-r": role.name, "data-b": band ? String(band) : undefined, style: style || undefined, d: f.d[r] })}/>`;
|
|
1294
|
+
}
|
|
1295
|
+
if (role.type === "slot") return `<path${attrs({ class: role.cls, "data-tone": role.tone, "data-r": role.name, opacity: String(Math.round(f.o[r] * 1000) / 1000), d: f.d[r] })}/>`;
|
|
1296
|
+
if (role.type === "xform") return `<path${attrs({ class: role.cls, "data-r": role.name, transform: f.t[r], d: part.planPath })}/>`;
|
|
1297
|
+
if (role.type === "plane") {
|
|
1298
|
+
const index = Number(role.name.slice(5));
|
|
1299
|
+
return `<g${attrs({ "data-r": role.name, transform: f.t[r], opacity: String(f.o[r]) })}>${part.planes[index].svg}</g>`;
|
|
1300
|
+
}
|
|
1301
|
+
return "";
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
function dotsMarkup(part, run, f) {
|
|
1305
|
+
let out = "";
|
|
1306
|
+
let open = null;
|
|
1307
|
+
run.roles.forEach((role, r) => {
|
|
1308
|
+
if (role.type !== "dot") return;
|
|
1309
|
+
if (open !== role.tone) {
|
|
1310
|
+
if (open !== null) out += "</g>";
|
|
1311
|
+
out += `<g class="iso-dots" data-tone="${role.tone}">`;
|
|
1312
|
+
open = role.tone;
|
|
1313
|
+
}
|
|
1314
|
+
out += `<circle data-r="${role.name}" cx="${Math.round(f.cx[r] * 100) / 100}" cy="${Math.round(f.cy[r] * 100) / 100}" r="${role.size}" fill-opacity="${f.o[r]}"/>`;
|
|
1315
|
+
});
|
|
1316
|
+
return open !== null ? out + "</g>" : out;
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
function partMarkup(part, run, f) {
|
|
1320
|
+
if (part.kind === "item") return `<g class="iso-item" data-p="${run.index}" data-name="${esc(part.name)}"></g>`;
|
|
1321
|
+
const head = (cls, extra = {}) => `<g${attrs({ class: cls, "data-p": String(run.index), "data-name": part.name, "data-mat": part.material ?? undefined, "data-lit": part.lit ? "" : undefined, ...extra })}>`;
|
|
1322
|
+
const inner = run.roles.map((role, r) => (role.type === "dot" || role.type === "self" ? "" : roleMarkup(part, run, f, r))).join("");
|
|
1323
|
+
if (part.kind === "tube") return head("tb", { "data-hue": part.hue ?? undefined }) + inner + "</g>";
|
|
1324
|
+
if (part.kind === "fixed") {
|
|
1325
|
+
const at = part.svg.lastIndexOf('<path class="iso-line iso-edge"');
|
|
1326
|
+
const body = at >= 0 ? part.svg.slice(0, at) + inner + part.svg.slice(at) : part.svg + inner;
|
|
1327
|
+
return head("iso-part", { transform: f.t[run.role.move] || undefined }) + body + dotsMarkup(part, run, f) + "</g>";
|
|
1328
|
+
}
|
|
1329
|
+
return head("iso-solid", { "data-seamless": part.kind === "round" || part.kind === "ball" || part.kind === "lathe" ? "" : undefined }) + inner + dotsMarkup(part, run, f) + "</g>";
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
function verifyData(T, statics = []) {
|
|
1333
|
+
const order = T.order.length ? T.order : T.layers.map((layer) => layer.name);
|
|
1334
|
+
const live = new Set(T.layers.map((layer) => layer.name));
|
|
1335
|
+
const staticLayers = order.filter((name) => !live.has(name));
|
|
1336
|
+
const liveShapes = new Set(T.parts.flatMap((part) => part.shapes));
|
|
1337
|
+
return {
|
|
1338
|
+
parts: T.parts.map((part) => ({ name: part.name, group: part.group ? part.group.index : -1, layer: part.layer.index, shapes: part.shapes.map((shape) => localShape(shape, part.group)) })),
|
|
1339
|
+
statics: statics.filter((item) => item.shapes && item.shapes.length && staticLayers.includes(item.layer)).map((item) => ({ name: item.name, rank: staticLayers.indexOf(item.layer), shapes: item.shapes.filter((shape) => !liveShapes.has(shape)).map((shape) => localShape(shape, null)) })),
|
|
1340
|
+
};
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
function localShape(shape, group) {
|
|
1344
|
+
const o = group ? group.origin : [0, 0, 0];
|
|
1345
|
+
const out = { kind: shape.kind };
|
|
1346
|
+
if (shape.F) out.F = { o: sub3(shape.F.o, o), a: shape.F.a, u: shape.F.u, v: shape.F.v };
|
|
1347
|
+
for (const key of ["poly", "s0", "s1", "r", "radii", "flats"]) if (shape[key] !== undefined) out[key] = shape[key];
|
|
1348
|
+
if (shape.o && shape.kind !== "body" && shape.kind !== "prism") out.o = sub3(shape.o, o);
|
|
1349
|
+
if (shape.points) out.points = shape.points.map((p) => sub3(p, o));
|
|
1350
|
+
if (shape.axes) out.axes = shape.axes;
|
|
1351
|
+
if (shape.half) out.half = shape.half;
|
|
1352
|
+
return pack(out);
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
export { pack, sampleShape };
|