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