audit.mjs
1913 lines · kit/audit.mjs at 1482e84
1import { cameraOf, roundedPlan } from "./iso-kit.mjs";
2import { add3, sub3, mul3, dot3, len3, unit3, pathLength, resample, curveRuns, cut as cutAlong, tangentAlong } from "./lathe.mjs";
3import { runsOf } from "./tube.mjs";
5const BIG = 1e9;
7export function body(F, poly, meta = {}) {
8 return { kind: "body", F, poly: poly.map(([s, r]) => [s, Math.max(0, r)]), ...meta };
9}
11export function traced(meridian, { radius = null, error = 0.02, depth = 6 } = {}) {
12 const segments = radius ? null : curveRuns(meridian);
13 const out = [[meridian[0][0], meridian[0][1]]];
14 for (let index = 0; index < meridian.length - 1; index++) {
15 const [s0] = meridian[index];
16 const [s1, r1] = meridian[index + 1];
17 const along = Math.abs(s1 - s0) > 1e-9;
18 const at = along && radius ? radius : along && segments[index] ? segments[index].radius : null;
19 if (at) {
20 const split = (a, b, level) => {
21 const mid = (a + b) / 2;
22 if (level >= depth) return;
23 const ra = at(a);
24 const rb = at(b);
25 const deviation = Math.max(...[0.25, 0.5, 0.75].map((t) => Math.abs(at(a + (b - a) * t) - (ra + (rb - ra) * t))));
26 if (deviation <= error) return;
27 split(a, mid, level + 1);
28 out.push([mid, at(mid)]);
29 split(mid, b, level + 1);
30 };
31 split(s0, s1, 0);
32 }
33 out.push([s1, r1]);
34 }
35 return out;
36}
38export function lathe(F, meridian, { radius = null, ...meta } = {}) {
39 return body(F, traced(meridian, { radius }), meta);
40}
42export function solid(F, profile, { radius = null, ...meta } = {}) {
43 const first = profile[0];
44 const last = profile[profile.length - 1];
45 return body(F, traced([[first[0], 0], ...profile, [last[0], 0]], { radius: radius && ((s) => radius(Math.max(first[0], Math.min(last[0], s)))) }), meta);
46}
48export function prism(F, polygon, s0, s1, meta = {}) {
49 return { kind: "prism", F, poly: polygon.map(([x, y]) => [x, y]), s0: Math.min(s0, s1), s1: Math.max(s0, s1), ...meta };
50}
52const FLOOR = { o: [0, 0, 0], a: [0, 0, 1], u: [1, 0, 0], v: [0, 1, 0] };
54export const extruded = (ring, z, height, meta = {}) => prism(FLOOR, ring, z, z + height, meta);
56export const slab = (plan, z, height, meta = {}, steps = 8) => extruded(roundedPlan(plan, steps), z, height, meta);
58export const cylinder = (cx, cy, r, z, height, meta = {}) => disc({ o: [cx, cy, 0], a: [0, 0, 1], u: [1, 0, 0], v: [0, 1, 0] }, z, z + height, r, meta);
60export const disc = (F, s0, s1, r, meta = {}) => body(F, [[s0, 0], [s0, r], [s1, r], [s1, 0]], meta);
62export const ring = (F, s0, s1, rIn, rOut, meta = {}) => body(F, [[s0, rIn], [s0, rOut], [s1, rOut], [s1, rIn]], meta);
64export function box(min, max, meta = {}) {
65 const centre = mul3(add3(min, max), 0.5);
66 return { kind: "box", o: centre, axes: [[1, 0, 0], [0, 1, 0], [0, 0, 1]], half: mul3(sub3(max, min), 0.5), ...meta };
67}
69export const orientedBox = (centre, axes, half, meta = {}) => ({ kind: "box", o: centre, axes: axes.map(unit3), half, ...meta });
71export const ball = (at, r, meta = {}) => ({ kind: "ball", o: at, r, ...meta });
73export function tube(points, radius, meta = {}) {
74 const total = pathLength(points) || 1;
75 let walked = 0;
76 const radii = Array.isArray(radius)
77 ? radius
78 : points.map((point, index) => {
79 if (index > 0) walked += len3(sub3(point, points[index - 1]));
80 return typeof radius === "function" ? radius(walked / total) : radius;
81 });
82 return { kind: "tube", points, radii, round: meta.round ?? [false, false], ...meta };
83}
85export const slide = (F, from, to, r, meta = {}) => ({ ...disc(F, from, to, r, meta), slide: { from, to, r } });
87export function moved(shape, offset) {
88 if (!offset || (!offset[0] && !offset[1] && !offset[2])) return shape;
89 if (shape.slide) {
90 const { from, to, r } = shape.slide;
91 const reach = Math.max(from + 1e-3, to + dot3(offset, shape.F.a));
92 return { ...shape, poly: [[from, 0], [from, r], [reach, r], [reach, 0]], slide: { from, to: reach, r }, _b: undefined, _f: undefined };
93 }
94 const cut = shape.cut ? (Array.isArray(shape.cut) ? shape.cut.map((one) => moved(one, offset)) : moved(shape.cut, offset)) : undefined;
95 if (shape.kind === "body" || shape.kind === "prism") return { ...shape, cut, F: { ...shape.F, o: add3(shape.F.o, offset) }, _b: undefined, _f: undefined };
96 if (shape.kind === "tube") return { ...shape, cut, points: shape.points.map((p) => add3(p, offset)), tips: shape.tips && shape.tips.map((tip) => tip && add3(tip, offset)), _b: undefined };
97 return { ...shape, cut, o: add3(shape.o, offset), _b: undefined };
98}
100function polyDistance(poly, s, r) {
101 let best = BIG;
102 let inside = false;
103 for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
104 const [s0, r0] = poly[j];
105 const [s1, r1] = poly[i];
106 const ds = s1 - s0;
107 const dr = r1 - r0;
108 const span = ds * ds + dr * dr;
109 const t = span > 0 ? Math.max(0, Math.min(1, ((s - s0) * ds + (r - r0) * dr) / span)) : 0;
110 const es = s - s0 - ds * t;
111 const er = r - r0 - dr * t;
112 const d = es * es + er * er;
113 if (d < best && (r0 > 1e-9 || r1 > 1e-9)) best = d;
114 if (r0 > r !== r1 > r && s < s0 + ((r - r0) * ds) / (dr || 1e-12)) inside = !inside;
115 }
116 const d = Math.sqrt(best);
117 return inside ? -d : d;
118}
120function polygonDistance(poly, x, y) {
121 let best = BIG;
122 let inside = false;
123 for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
124 const [x0, y0] = poly[j];
125 const [x1, y1] = poly[i];
126 const dx = x1 - x0;
127 const dy = y1 - y0;
128 const span = dx * dx + dy * dy;
129 const t = span > 0 ? Math.max(0, Math.min(1, ((x - x0) * dx + (y - y0) * dy) / span)) : 0;
130 const ex = x - x0 - dx * t;
131 const ey = y - y0 - dy * t;
132 const d = ex * ex + ey * ey;
133 if (d < best) best = d;
134 if (y0 > y !== y1 > y && x < x0 + ((y - y0) * dx) / (dy || 1e-12)) inside = !inside;
135 }
136 const d = Math.sqrt(best);
137 return inside ? -d : d;
138}
140function cappedSegment(p, a, b, r, endRadius = r) {
141 const bx = b[0] - a[0];
142 const by = b[1] - a[1];
143 const bz = b[2] - a[2];
144 const px = p[0] - a[0];
145 const py = p[1] - a[1];
146 const pz = p[2] - a[2];
147 const baba = bx * bx + by * by + bz * bz || 1e-12;
148 const paba = px * bx + py * by + pz * bz;
149 if (endRadius !== r) {
150 const length = Math.sqrt(baba);
151 const along = paba / length;
152 const radial = Math.hypot(px - bx * paba / baba, py - by * paba / baba, pz - bz * paba / baba);
153 return polyDistance([[0, 0], [0, r], [length, endRadius], [length, 0]], along, radial);
154 }
155 const x = Math.hypot(px * baba - bx * paba, py * baba - by * paba, pz * baba - bz * paba) - r * baba;
156 const y = Math.abs(paba - baba * 0.5) - baba * 0.5;
157 const x2 = x * x;
158 const y2 = y * y * baba;
159 const d = Math.max(x, y) < 0 ? -Math.min(x2, y2) : (x > 0 ? x2 : 0) + (y > 0 ? y2 : 0);
160 return (Math.sign(d) * Math.sqrt(Math.abs(d))) / baba;
161}
163function segmentPoint(p, a, b) {
164 const ba = sub3(b, a);
165 const pa = sub3(p, a);
166 const t = Math.max(0, Math.min(1, dot3(pa, ba) / (dot3(ba, ba) || 1e-12)));
167 return { d: len3(sub3(pa, mul3(ba, t))), t };
168}
170export function sdf(shape, p) {
171 const d = baseSdf(shape, p);
172 return shape.cut ? Math.max(d, -cutDistance(shape, p)) : d;
173}
175function cutDistance(shape, p) {
176 if (!Array.isArray(shape.cut)) return sdf(shape.cut, p);
177 let best = BIG;
178 for (const cut of shape.cut) best = Math.min(best, sdf(cut, p));
179 return best;
180}
182function baseSdf(shape, p) {
183 if (shape.kind === "body") {
184 const { F } = shape;
185 const q = sub3(p, F.o);
186 const s = dot3(q, F.a);
187 const x = dot3(q, F.u);
188 const y = dot3(q, F.v);
189 return polyDistance(shape.poly, s, Math.hypot(x, y));
190 }
191 if (shape.kind === "ball") {
192 let d = len3(sub3(p, shape.o)) - shape.r;
193 if (shape.flats) for (const [nx, ny, nz, cut] of shape.flats) d = Math.max(d, nx * (p[0] - shape.o[0]) + ny * (p[1] - shape.o[1]) + nz * (p[2] - shape.o[2]) - cut);
194 return d;
195 }
196 if (shape.kind === "prism") {
197 const { F } = shape;
198 const q = sub3(p, F.o);
199 const s = dot3(q, F.a);
200 const flat = polygonDistance(shape.poly, dot3(q, F.u), dot3(q, F.v));
201 const along = Math.max(shape.s0 - s, s - shape.s1);
202 return Math.hypot(Math.max(flat, 0), Math.max(along, 0)) + Math.min(Math.max(flat, along), 0);
203 }
204 if (shape.kind === "box") {
205 const q = sub3(p, shape.o);
206 const d = shape.axes.map((axis, index) => Math.abs(dot3(q, axis)) - shape.half[index]);
207 const outside = Math.hypot(Math.max(d[0], 0), Math.max(d[1], 0), Math.max(d[2], 0));
208 return outside + Math.min(Math.max(d[0], d[1], d[2]), 0);
209 }
210 const { points, radii, round } = shape;
211 let best = BIG;
212 const last = points.length - 1;
213 for (let index = 0; index < last; index++) {
214 const d = cappedSegment(p, points[index], points[index + 1], radii[index], radii[index + 1]);
215 if (d < best) best = d;
216 }
217 if (round[0]) best = Math.min(best, len3(sub3(p, points[0])) - radii[0]);
218 if (round[1]) best = Math.min(best, len3(sub3(p, points[last])) - radii[last]);
219 return best;
220}
222export function boundsOf(shape) {
223 if (shape._b) return shape._b;
224 let min;
225 let max;
226 if (shape.kind === "body") {
227 const { F, poly } = shape;
228 const s0 = Math.min(...poly.map(([s]) => s));
229 const s1 = Math.max(...poly.map(([s]) => s));
230 const rm = Math.max(...poly.map(([, r]) => r));
231 const a = add3(F.o, mul3(F.a, s0));
232 const b = add3(F.o, mul3(F.a, s1));
233 const spread = [0, 1, 2].map((i) => rm * Math.sqrt(Math.max(0, 1 - F.a[i] * F.a[i])));
234 min = [0, 1, 2].map((i) => Math.min(a[i], b[i]) - spread[i]);
235 max = [0, 1, 2].map((i) => Math.max(a[i], b[i]) + spread[i]);
236 } else if (shape.kind === "ball") {
237 min = shape.o.map((v) => v - shape.r);
238 max = shape.o.map((v) => v + shape.r);
239 } else if (shape.kind === "prism") {
240 const { F } = shape;
241 min = [BIG, BIG, BIG];
242 max = [-BIG, -BIG, -BIG];
243 for (const s of [shape.s0, shape.s1])
244 for (const [x, y] of shape.poly) {
245 const p = add3(add3(F.o, mul3(F.a, s)), add3(mul3(F.u, x), mul3(F.v, y)));
246 for (let i = 0; i < 3; i++) (min[i] = Math.min(min[i], p[i])), (max[i] = Math.max(max[i], p[i]));
247 }
248 } else if (shape.kind === "box") {
249 const extent = [0, 1, 2].map((i) => shape.axes.reduce((sum, axis, j) => sum + Math.abs(axis[i]) * shape.half[j], 0));
250 min = shape.o.map((v, i) => v - extent[i]);
251 max = shape.o.map((v, i) => v + extent[i]);
252 } else {
253 min = [BIG, BIG, BIG];
254 max = [-BIG, -BIG, -BIG];
255 shape.points.forEach((p, index) => {
256 for (let i = 0; i < 3; i++) {
257 min[i] = Math.min(min[i], p[i] - shape.radii[index]);
258 max[i] = Math.max(max[i], p[i] + shape.radii[index]);
259 }
260 });
261 }
262 shape._b = { min, max };
263 return shape._b;
264}
266export function cameraBasis(P) {
267 const { sinA, cosA, sinE, cosE, k } = cameraOf(P);
268 return { R: [sinA, -cosA, 0], D: [cosA * sinE, sinA * sinE, -cosE], V: [cosA * cosE, sinA * cosE, sinE], k, origin: P.origin };
269}
271function screenBox(shape, cam, offset) {
272 const { min, max } = boundsOf(shape);
273 let x0 = BIG;
274 let y0 = BIG;
275 let x1 = -BIG;
276 let y1 = -BIG;
277 const visit = (p) => {
278 const x = cam.origin[0] + dot3(p, cam.R) * cam.k;
279 const y = cam.origin[1] + dot3(p, cam.D) * cam.k;
280 x0 = Math.min(x0, x);
281 y0 = Math.min(y0, y);
282 x1 = Math.max(x1, x);
283 y1 = Math.max(y1, y);
284 };
285 if (shape.kind === "tube") {
286 shape.points.forEach((p, index) => {
287 const r = shape.radii[index];
288 const q = add3(p, offset);
289 visit(add3(q, mul3(cam.R, r)));
290 visit(add3(q, mul3(cam.R, -r)));
291 visit(add3(q, mul3(cam.D, r)));
292 visit(add3(q, mul3(cam.D, -r)));
293 });
294 } else {
295 for (const x of [min[0], max[0]]) for (const y of [min[1], max[1]]) for (const z of [min[2], max[2]]) visit(add3([x, y, z], offset));
296 }
297 return [x0, y0, x1, y1];
298}
300function spanOf(c, V, min, max) {
301 let t0 = -BIG;
302 let t1 = BIG;
303 for (let i = 0; i < 3; i++) {
304 if (Math.abs(V[i]) < 1e-12) {
305 if (c[i] < min[i] || c[i] > max[i]) return null;
306 continue;
307 }
308 let a = (min[i] - c[i]) / V[i];
309 let b = (max[i] - c[i]) / V[i];
310 if (a > b) [a, b] = [b, a];
311 t0 = Math.max(t0, a);
312 t1 = Math.min(t1, b);
313 }
314 return t0 <= t1 ? [t0, t1] : null;
315}
317function tubeDistance(shape, p, segments) {
318 const { points, radii, round } = shape;
319 const last = points.length - 1;
320 let best = BIG;
321 for (const index of segments) {
322 const d = cappedSegment(p, points[index], points[index + 1], radii[index], radii[index + 1]);
323 if (d < best) best = d;
324 for (const [joint, flag] of [[0, round[0]], [last, round[1]]]) {
325 if (!flag || (joint !== index && joint !== index + 1)) continue;
326 const q = points[joint];
327 const e = Math.hypot(p[0] - q[0], p[1] - q[1], p[2] - q[2]) - radii[joint];
328 if (e < best) best = e;
329 }
330 }
331 return best;
332}
334function bodyDepth(shape, c, V, eps) {
335 const { F, poly } = shape;
336 if (!shape._f || shape._f.view.some((value, index) => value !== V[index])) {
337 const s = poly.map(([value]) => value);
338 shape._f = { view: [...V], aV: dot3(F.a, V), uV: dot3(F.u, V), vV: dot3(F.v, V), s0: Math.min(...s), s1: Math.max(...s), rm: Math.max(...poly.map(([, r]) => r)) };
339 }
340 const { aV, uV, vV, s0, s1, rm } = shape._f;
341 const q = [c[0] - F.o[0], c[1] - F.o[1], c[2] - F.o[2]];
342 const sc = dot3(q, F.a);
343 const xc = dot3(q, F.u);
344 const yc = dot3(q, F.v);
345 let lo = -BIG;
346 let hi = BIG;
347 if (Math.abs(aV) > 1e-9) {
348 const a = (s0 - sc) / aV;
349 const b = (s1 - sc) / aV;
350 lo = Math.min(a, b);
351 hi = Math.max(a, b);
352 } else if (sc < s0 || sc > s1) return null;
353 const qa = uV * uV + vV * vV;
354 if (qa > 1e-12) {
355 const qb = 2 * (xc * uV + yc * vV);
356 const qc = xc * xc + yc * yc - rm * rm;
357 const disc = qb * qb - 4 * qa * qc;
358 if (disc < 0) return null;
359 const root = Math.sqrt(disc);
360 lo = Math.max(lo, (-qb - root) / (2 * qa));
361 hi = Math.min(hi, (-qb + root) / (2 * qa));
362 } else if (xc * xc + yc * yc > rm * rm) return null;
363 if (lo > hi) return null;
364 let t = hi + eps;
365 for (let step = 0; step < 160 && t >= lo - eps; step++) {
366 let d = polyDistance(poly, sc + t * aV, Math.hypot(xc + t * uV, yc + t * vV));
367 if (shape.cut) d = Math.max(d, -cutDistance(shape, [c[0] + V[0] * t, c[1] + V[1] * t, c[2] + V[2] * t]));
368 if (d < eps) return t;
369 t -= Math.max(d, eps * 0.5);
370 }
371 return null;
372}
374function rayDepth(shape, c, V, eps, segments) {
375 if (shape.kind === "body") return bodyDepth(shape, c, V, eps);
376 const span = spanOf(c, V, boundsOf(shape).min, boundsOf(shape).max);
377 if (!span) return null;
378 let t = span[1] + eps;
379 for (let step = 0; step < 160 && t >= span[0] - eps; step++) {
380 const p = [c[0] + V[0] * t, c[1] + V[1] * t, c[2] + V[2] * t];
381 const d = segments ? tubeDistance(shape, p, segments) : sdf(shape, p);
382 if (d < eps) return t;
383 t -= Math.max(d, eps * 0.5);
384 }
385 return null;
386}
388function flatDistance(x, y, a, b) {
389 const dx = b[0] - a[0];
390 const dy = b[1] - a[1];
391 const span = dx * dx + dy * dy;
392 const t = span > 0 ? Math.max(0, Math.min(1, ((x - a[0]) * dx + (y - a[1]) * dy) / span)) : 0;
393 return Math.hypot(x - a[0] - dx * t, y - a[1] - dy * t);
394}
396export const shareOf = (item, state) => (typeof state === "number" ? state : state.shares[item.ride ?? item.piece] ?? 0);
398export const offsetOf = (item, state) => mul3(item.move ?? [0, 0, 0], shareOf(item, state));
400export const labelOf = (state) => (typeof state === "number" ? `share ${Math.round(state * 1000) / 1000}` : state.label);
402export function springStates(order, { stagger = 0.035, stiffness = 64, damping = 14.2, substeps = 4, fps = 60, settle = 1e-3, stride = 1, reversals = [] } = {}) {
403 const out = [];
404 const scripts = [[true], [false], ...reversals.flatMap((at) => [[true, at], [false, at]])];
405 for (const [on, back] of scripts) {
406 const springs = Object.fromEntries(order.map((id) => [id, { x: on ? 0 : 1, v: 0, target: on ? 0 : 1, pending: null }]));
407 const toggle = (value, clock) => {
408 const goal = value ? 1 : 0;
409 let index = 0;
410 for (const id of value ? order : [...order].reverse()) {
411 if (springs[id].target === goal) springs[id].pending = null;
412 else springs[id].pending = { value: goal, at: clock + index++ * stagger };
413 }
414 };
415 const name = back === undefined ? (on ? "apart" : "together") : `${on ? "apart" : "together"}, back at ${back.toFixed(2)} s,`;
416 const dt = 1 / fps;
417 let clock = 0;
418 let goal = on ? 1 : 0;
419 toggle(on, 0);
420 for (let frame = 0; frame < 100000; frame++) {
421 if (back !== undefined && clock < back && clock + dt >= back) toggle(!on, back), (goal = on ? 0 : 1);
422 clock += dt;
423 for (const spring of Object.values(springs)) {
424 if (spring.pending && clock >= spring.pending.at) (spring.target = spring.pending.value), (spring.pending = null);
425 for (let index = 0; index < substeps; index++) {
426 const h = dt / substeps;
427 spring.v += ((spring.target - spring.x) * stiffness - spring.v * damping) * h;
428 spring.x += spring.v * h;
429 }
430 }
431 const settled = (back === undefined || clock >= back) && Object.values(springs).every((spring) => !spring.pending && Math.abs(spring.x - goal) < settle && Math.abs(spring.v) < settle);
432 if (settled) break;
433 if ((frame + 1) % stride === 0) out.push({ label: `${name} ${clock.toFixed(3)} s`, shares: Object.fromEntries(Object.entries(springs).map(([id, spring]) => [id, spring.x])) });
434 }
435 }
436 return out;
437}
439export const uniformStates = (count = 10) => Array.from({ length: count + 1 }, (_, index) => index / count);
441export const reversalsOf = (order, stagger = 0.035, margin = 0.008) => order.flatMap((_, index) => [index * stagger - margin, index * stagger + margin]).filter((at) => at > 0);
443export function prefixStates(order, { shares = [0.25, 0.5, 0.75, 1] } = {}) {
444 const out = [];
445 for (let count = 1; count < order.length; count++)
446 for (const s of shares)
447 out.push({ label: `first ${count - 1} out, ${order[count - 1]} at ${s}`, shares: Object.fromEntries(order.map((id, index) => [id, index < count - 1 ? 1 : index === count - 1 ? s : 0])) });
448 return out;
449}
451function rasterOf(item, cam, step, offset) {
452 const shapes = item.shapes.map((shape) => moved(shape, offset));
453 const flat = (p) => [cam.origin[0] + dot3(p, cam.R) * cam.k, cam.origin[1] + dot3(p, cam.D) * cam.k];
454 const boxes = shapes.map((shape) => screenBox(shape, cam, [0, 0, 0]));
455 const guides = shapes.map((shape) => {
456 if (shape.kind === "tube") {
457 const points = shape.points.map(flat);
458 const widths = shape.radii.map((r) => r * cam.k);
459 const boxes = new Float64Array((points.length - 1) * 4);
460 for (let i = 0; i < points.length - 1; i++) {
461 const pad = Math.max(widths[i], widths[i + 1]) + step * 0.5;
462 boxes[i * 4] = Math.min(points[i][0], points[i + 1][0]) - pad;
463 boxes[i * 4 + 1] = Math.max(points[i][0], points[i + 1][0]) + pad;
464 boxes[i * 4 + 2] = Math.min(points[i][1], points[i + 1][1]) - pad;
465 boxes[i * 4 + 3] = Math.max(points[i][1], points[i + 1][1]) + pad;
466 }
467 return { points, widths, boxes };
468 }
469 if (shape.kind === "body") {
470 const s = shape.poly.map(([value]) => value);
471 return { axis: [flat(add3(shape.F.o, mul3(shape.F.a, Math.min(...s)))), flat(add3(shape.F.o, mul3(shape.F.a, Math.max(...s))))], reach: Math.max(...shape.poly.map(([, r]) => r)) * cam.k };
472 }
473 return null;
474 });
475 const x0 = Math.floor(Math.min(...boxes.map((b) => b[0])) / step);
476 const y0 = Math.floor(Math.min(...boxes.map((b) => b[1])) / step);
477 const x1 = Math.ceil(Math.max(...boxes.map((b) => b[2])) / step);
478 const y1 = Math.ceil(Math.max(...boxes.map((b) => b[3])) / step);
479 const w = x1 - x0 + 1;
480 const h = y1 - y0 + 1;
481 return { x0, y0, x1, y1, w, h, boxes, shapes, guides, depth: null };
482}
484function depthAt(raster, cam, step, cx, cy) {
485 if (!raster.depth) {
486 raster.depth = new Float32Array(raster.w * raster.h);
487 raster.done = new Uint8Array(raster.w * raster.h);
488 }
489 const cell = (cy - raster.y0) * raster.w + (cx - raster.x0);
490 if (raster.done[cell]) return raster.depth[cell];
491 const x = cx * step;
492 const y = cy * step;
493 const c = add3(mul3(cam.R, (x - cam.origin[0]) / cam.k), mul3(cam.D, (y - cam.origin[1]) / cam.k));
494 let best = NaN;
495 for (let index = 0; index < raster.shapes.length; index++) {
496 const shape = raster.shapes[index];
497 const b = raster.boxes[index];
498 if (x < b[0] || x > b[2] || y < b[1] || y > b[3]) continue;
499 const guide = raster.guides[index];
500 let segments = null;
501 if (shape.kind === "tube") {
502 segments = [];
503 const boxes = guide.boxes;
504 for (let i = 0; i < guide.points.length - 1; i++) {
505 if (x < boxes[i * 4] || x > boxes[i * 4 + 1] || y < boxes[i * 4 + 2] || y > boxes[i * 4 + 3]) continue;
506 if (flatDistance(x, y, guide.points[i], guide.points[i + 1]) <= Math.max(guide.widths[i], guide.widths[i + 1]) + step * 0.5) segments.push(i);
507 }
508 if (!segments.length) continue;
509 } else if (shape.kind === "body" && flatDistance(x, y, guide.axis[0], guide.axis[1]) > guide.reach + step) continue;
510 const t = rayDepth(shape, c, cam.V, 0.02, segments);
511 if (t !== null && !(t <= best)) best = t;
512 }
513 raster.depth[cell] = best;
514 raster.done[cell] = 1;
515 return best;
516}
518function regionOf(flags, gw, gh, value, step) {
519 const n = gw * gh;
520 const on = new Uint8Array(n);
521 let all = 0;
522 let inner = 0;
523 for (let cell = 0; cell < n; cell++) if (flags[cell] === value) (on[cell] = 1), all++;
524 if (!all) return { all, inner, area: 0 };
525 const grow = new Uint8Array(n);
526 for (let y = 0; y < gh; y++)
527 for (let x = 0; x < gw; x++) {
528 const cell = y * gw + x;
529 if (on[cell] && x > 0 && x < gw - 1 && y > 0 && y < gh - 1 && on[cell - 1] && on[cell + 1] && on[cell - gw] && on[cell + gw]) inner++;
530 if (on[cell] || (x > 0 && on[cell - 1]) || (x < gw - 1 && on[cell + 1]) || (y > 0 && on[cell - gw]) || (y < gh - 1 && on[cell + gw])) grow[cell] = 1;
531 }
532 const shut = new Uint8Array(n);
533 for (let y = 0; y < gh; y++)
534 for (let x = 0; x < gw; x++) {
535 const cell = y * gw + x;
536 shut[cell] = on[cell] || (grow[cell] && (x === 0 || grow[cell - 1]) && (x === gw - 1 || grow[cell + 1]) && (y === 0 || grow[cell - gw]) && (y === gh - 1 || grow[cell + gw])) ? 1 : 0;
537 }
538 const core = new Uint8Array(n);
539 for (let y = 1; y < gh - 1; y++)
540 for (let x = 1; x < gw - 1; x++) {
541 const cell = y * gw + x;
542 core[cell] = shut[cell] && shut[cell - 1] && shut[cell + 1] && shut[cell - gw] && shut[cell + gw] ? 1 : 0;
543 }
544 const seen = new Uint8Array(n);
545 const stack = new Int32Array(n);
546 let largest = 0;
547 for (let start = 0; start < n; start++) {
548 if (!core[start] || seen[start]) continue;
549 let top = 0;
550 let size = 0;
551 stack[top++] = start;
552 seen[start] = 1;
553 while (top) {
554 const cell = stack[--top];
555 size++;
556 const x = cell % gw;
557 const y = (cell - x) / gw;
558 const visit = (next) => {
559 if (core[next] && !seen[next]) (seen[next] = 1), (stack[top++] = next);
560 };
561 if (x > 0) visit(cell - 1);
562 if (x < gw - 1) visit(cell + 1);
563 if (y > 0) visit(cell - gw);
564 if (y < gh - 1) visit(cell + gw);
565 }
566 if (size > largest) largest = size;
567 }
568 return { all, inner, area: largest * step * step };
569}
571function measure(ra, rb, dx, dy, dz, cam, step, tolerance, least) {
572 if (ra.x1 < rb.x0 + dx || rb.x1 + dx < ra.x0 || ra.y1 < rb.y0 + dy || rb.y1 + dy < ra.y0) return null;
573 const ix0 = Math.max(ra.x0, rb.x0 + dx);
574 const ix1 = Math.min(ra.x1, rb.x1 + dx);
575 const iy0 = Math.max(ra.y0, rb.y0 + dy);
576 const iy1 = Math.min(ra.y1, rb.y1 + dy);
577 const gw = ix1 - ix0 + 1;
578 const gh = iy1 - iy0 + 1;
579 const flags = new Int8Array(gw * gh);
580 const worst = [0, 0];
581 const at = [null, null];
582 let any = false;
583 for (let y = iy0; y <= iy1; y++) {
584 for (let x = ix0; x <= ix1; x++) {
585 const ta = depthAt(ra, cam, step, x, y);
586 if (Number.isNaN(ta)) continue;
587 const tb = depthAt(rb, cam, step, x - dx, y - dy) + dz;
588 if (Number.isNaN(tb)) continue;
589 const cell = (y - iy0) * gw + (x - ix0);
590 if (ta > tb + tolerance) {
591 flags[cell] = 1;
592 any = true;
593 if (ta - tb > worst[0]) (worst[0] = ta - tb), (at[0] = [x * step, y * step]);
594 } else if (tb > ta + tolerance) {
595 flags[cell] = -1;
596 any = true;
597 if (tb - ta > worst[1]) (worst[1] = tb - ta), (at[1] = [x * step, y * step]);
598 }
599 }
600 }
601 if (!any) return null;
602 const front = [regionOf(flags, gw, gh, 1, step), regionOf(flags, gw, gh, -1, step)];
603 if (front[0].area < least && front[1].area < least) return null;
604 return front.map((region, side) => ({ ...region, depth: worst[side], at: at[side] }));
605}
607export function overlapsOf(items, P, { step = 0.25, tolerance = 0.3, least = 0.25, states = [0], skip = () => false } = {}) {
608 const cam = cameraBasis(P);
609 const live = items.map((item, index) => ({ item, index })).filter(({ item }) => item.shapes && item.shapes.length);
610 const homes = live.map(() => null);
611 let uid = 0;
612 const stamp = (raster) => Object.assign(raster, { uid: uid++ });
613 const home = (index) => homes[index] ?? (homes[index] = stamp(rasterOf(live[index].item, cam, step, [0, 0, 0])));
614 const sliding = live.map(({ item }) => item.shapes.some((shape) => shape.slide));
615 const slid = new Map();
616 const rasterAt = (index, offset) => {
617 if (!sliding[index]) return home(index);
618 const id = `${index}|${offset.map((v) => v.toFixed(2)).join(",")}`;
619 if (!slid.has(id)) slid.set(id, stamp(rasterOf(live[index].item, cam, step, offset)));
620 return slid.get(id);
621 };
622 const shiftOf = (offset) => ({ gx: Math.round((dot3(offset, cam.R) * cam.k) / step), gy: Math.round((dot3(offset, cam.D) * cam.k) / step), dz: dot3(offset, cam.V) });
623 const memo = new Map();
624 const pairs = [];
625 for (const state of states) {
626 const offsets = live.map(({ item }) => offsetOf(item, state));
627 const shifts = live.map((_, index) => (sliding[index] ? { gx: 0, gy: 0, dz: 0 } : shiftOf(offsets[index])));
628 for (let a = 0; a < live.length; a++) {
629 const ra = rasterAt(a, offsets[a]);
630 const sa = shifts[a];
631 for (let b = a + 1; b < live.length; b++) {
632 const rb = rasterAt(b, offsets[b]);
633 const sb = shifts[b];
634 if (ra.x1 + sa.gx < rb.x0 + sb.gx || rb.x1 + sb.gx < ra.x0 + sa.gx || ra.y1 + sa.gy < rb.y0 + sb.gy || rb.y1 + sb.gy < ra.y0 + sa.gy) continue;
635 if (skip(live[a].item, live[b].item)) continue;
636 const dx = sb.gx - sa.gx;
637 const dy = sb.gy - sa.gy;
638 const dz = sb.dz - sa.dz;
639 const id = `${ra.uid}|${rb.uid}|${dx},${dy},${dz.toFixed(3)}`;
640 let hit = memo.get(id);
641 if (hit === undefined) {
642 hit = measure(ra, rb, dx, dy, dz, cam, step, tolerance, least);
643 memo.set(id, hit);
644 }
645 if (!hit) continue;
646 pairs.push({ state, a: live[a].index, b: live[b].index, front: hit.map((side) => ({ ...side, at: side.at && [side.at[0] + sa.gx * step, side.at[1] + sa.gy * step] })) });
647 }
648 }
649 }
650 return { pairs, least, states, step, V: cam.V };
651}
653const liftOf = (item, V) => dot3(item.move ?? [0, 0, 0], V);
655export const keyAt = (item, state, V) => item.key + liftOf(item, V) * shareOf(item, state);
657export function orderKeys(items, overlaps, { gap = 0.02, rounds = 0 } = {}) {
658 const { pairs, least, V } = overlaps;
659 const start = items.map((item) => item.key);
660 const strongest = new Map();
661 const cycles = [];
662 for (const pair of pairs) {
663 const aFront = pair.front[0].area >= least;
664 const bFront = pair.front[1].area >= least;
665 if (aFront && bFront) {
666 cycles.push(pair);
667 continue;
668 }
669 const [front, back] = aFront ? [pair.a, pair.b] : [pair.b, pair.a];
670 const weight = pair.front[aFront ? 0 : 1].area;
671 const need = gap + shareOf(items[back], pair.state) * liftOf(items[back], V) - shareOf(items[front], pair.state) * liftOf(items[front], V);
672 const id = `${front}|${back}`;
673 const prior = strongest.get(id);
674 if (!prior) strongest.set(id, { front, back, weight, pair, need });
675 else {
676 prior.weight = Math.max(prior.weight, weight);
677 if (need > prior.need) (prior.need = need), (prior.pair = pair);
678 }
679 }
680 const rules = [...strongest.values()];
681 const limit = rounds || items.length + 1;
682 const dropped = [];
683 let keys = start.slice();
684 let active = rules.slice();
685 for (let attempt = 0; attempt < 400; attempt++) {
686 keys = start.slice();
687 const by = new Array(items.length).fill(null);
688 let changed = true;
689 let round = 0;
690 let last = -1;
691 for (; changed && round < limit; round++) {
692 changed = false;
693 for (const rule of active) {
694 if (keys[rule.front] - keys[rule.back] >= rule.need - 1e-9) continue;
695 keys[rule.front] = keys[rule.back] + rule.need;
696 by[rule.front] = rule;
697 last = rule.front;
698 changed = true;
699 }
700 }
701 if (!changed) break;
702 const seen = new Map();
703 let node = last;
704 let step = 0;
705 while (node !== -1 && by[node] && !seen.has(node) && step < items.length * 2) {
706 seen.set(node, step++);
707 node = by[node].back;
708 }
709 const loop = [];
710 if (node !== -1 && seen.has(node)) {
711 let walk = node;
712 do {
713 loop.push(by[walk]);
714 walk = by[walk].back;
715 } while (walk !== node && loop.length <= items.length);
716 }
717 const candidates = loop.length ? loop : active.filter((rule) => keys[rule.front] - keys[rule.back] < rule.need - 1e-9);
718 if (!candidates.length) break;
719 const victim = candidates.reduce((low, rule) => (rule.weight < low.weight ? rule : low));
720 dropped.push(victim);
721 active = active.filter((rule) => rule !== victim);
722 }
723 return { keys, rules: rules.length, cycles, dropped };
724}
726export function depthOrder(items, P, options = {}) {
727 const overlaps = options.overlaps ?? overlapsOf(items, P, options);
728 const { pairs, least, states, V } = overlaps;
729 const allow = options.allow ?? [];
730 const dropped = new Set((options.solved?.dropped ?? []).map((rule) => `${rule.front}|${rule.back}`));
731 const missing = items.filter((item) => !item.shapes || !item.shapes.length).map((item) => item.name);
732 const ranks = new Map();
733 for (const state of states) {
734 const order = items.map((item, index) => ({ index, key: keyAt(item, state, V) })).sort((x, y) => x.key - y.key || x.index - y.index);
735 const rank = new Int32Array(items.length);
736 order.forEach(({ index }, position) => (rank[index] = position));
737 ranks.set(state, rank);
738 }
739 const rest = options.rest ?? states.filter((state) => state === 0 || state === 1);
740 const problems = [];
741 const passing = [];
742 for (const pair of pairs) {
743 const rank = ranks.get(pair.state);
744 const aFirst = rank[pair.a] < rank[pair.b];
745 const wrong = pair.front[aFirst ? 0 : 1];
746 const right = pair.front[aFirst ? 1 : 0];
747 if (wrong.area < least) continue;
748 const front = items[aFirst ? pair.a : pair.b];
749 const back = items[aFirst ? pair.b : pair.a];
750 const excused = allow.find(([x, y]) => (matches([x], front.name) && matches([y], back.name)) || (matches([y], front.name) && matches([x], back.name)));
751 const entry = {
752 state: pair.state,
753 label: labelOf(pair.state),
754 front: front.name,
755 back: back.name,
756 frontKey: keyAt(front, pair.state, V),
757 backKey: keyAt(back, pair.state, V),
758 cells: wrong.all,
759 area: wrong.area,
760 against: right.area,
761 cycle: right.area >= least,
762 depth: wrong.depth,
763 at: wrong.at,
764 };
765 const front_ = aFirst ? pair.a : pair.b;
766 const back_ = aFirst ? pair.b : pair.a;
767 entry.unorderable = !rest.includes(pair.state) && dropped.has(`${front_}|${back_}`);
768 const replayed = typeof pair.state !== "number";
769 (!excused && (rest.includes(pair.state) || replayed || (!entry.cycle && !entry.unorderable)) ? problems : passing).push(entry);
770 }
771 return { problems, passing, missing };
772}
774function samplesOf(shape, spacing) {
775 const dense = resample(shape.points, spacing);
776 const total = pathLength(shape.points) || 1;
777 const lengths = [0];
778 for (let index = 1; index < shape.points.length; index++) lengths.push(lengths[index - 1] + len3(sub3(shape.points[index], shape.points[index - 1])));
779 const radiusAt = (at) => {
780 let index = 1;
781 while (index < lengths.length - 1 && lengths[index] < at) index++;
782 const span = lengths[index] - lengths[index - 1] || 1;
783 const t = Math.max(0, Math.min(1, (at - lengths[index - 1]) / span));
784 return shape.radii[index - 1] + (shape.radii[index] - shape.radii[index - 1]) * t;
785 };
786 const step = total / Math.max(1, dense.length - 1);
787 return dense.map((p, index) => {
788 const t = unit3(sub3(dense[Math.min(dense.length - 1, index + 1)], dense[Math.max(0, index - 1)]));
789 return { p, t, along: index * step, edge: Math.min(index * step, total - index * step), r: radiusAt(index * step) };
790 });
791}
793const overlaps = (a, b, pad) => a.min.every((v, i) => v - pad <= b.max[i] && b.min[i] - pad <= a.max[i]);
795function centreline(shape, p) {
796 let best = { d: BIG, r: 0 };
797 for (let index = 0; index < shape.points.length - 1; index++) {
798 const { d, t } = segmentPoint(p, shape.points[index], shape.points[index + 1]);
799 if (d < best.d) best = { d, r: shape.radii[index] + (shape.radii[index + 1] - shape.radii[index]) * t };
800 }
801 return best;
802}
804const matches = (list, name) =>
805 (list ?? []).some((entry) => {
806 if (typeof entry === "function") return entry(name);
807 if (entry.endsWith("*")) return name.startsWith(entry.slice(0, -1));
808 return name === entry || name.startsWith(entry + ".") || name.startsWith(entry + ":") || name.startsWith(entry + "#");
809 });
811const sameOffset = (a, b) => Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) + Math.abs(a[2] - b[2]) < 1e-6;
813const placed = (shape, state) => (state === undefined || !shape.move ? shape : moved(shape, offsetOf(shape, state)));
815const offsetAt = (shape, state) => (state === undefined || !shape.move ? [0, 0, 0] : offsetOf(shape, state));
817function tipDistance(other, p) {
818 if (other.kind === "tube") {
819 const { d, r } = centreline(other, p);
820 return d - r;
821 }
822 return sdf(other, p);
823}
825function rimOf(tube, index) {
826 const tip = tube.tips[index];
827 const axis = tube.tipAxes ? tube.tipAxes[index] : unit3(index ? sub3(tube.points[tube.points.length - 1], tube.points[tube.points.length - 2]) : sub3(tube.points[1], tube.points[0]));
828 const radius = tube.tipRims ? tube.tipRims[index] : tube.radii[index ? tube.radii.length - 1 : 0];
829 const helper = Math.abs(axis[2]) > 0.9 ? [1, 0, 0] : [0, 0, 1];
830 const u = unit3([helper[1] * axis[2] - helper[2] * axis[1], helper[2] * axis[0] - helper[0] * axis[2], helper[0] * axis[1] - helper[1] * axis[0]]);
831 const v = [axis[1] * u[2] - axis[2] * u[1], axis[2] * u[0] - axis[0] * u[2], axis[0] * u[1] - axis[1] * u[0]];
832 return Array.from({ length: 12 }, (_, k) => add3(tip, add3(mul3(u, radius * Math.cos((k * Math.PI) / 6)), mul3(v, radius * Math.sin((k * Math.PI) / 6)))));
833}
835export function mountsOf(tubes, solids, { tolerance = 0.15 } = {}) {
836 const byName = new Map(tubes.map((tube) => [tube.name, tube]));
837 const out = new Map();
838 for (const tube of tubes) {
839 const found = [null, null];
840 (tube.tips ?? []).forEach((tip, index) => {
841 if (!tip) return;
842 const rim = rimOf(tube, index);
843 let best = null;
844 const consider = (other) => {
845 if (other === tube || (other.owner && other.owner === tube.owner) || other.name === tube.owner || other.anchor === false) return;
846 const { min, max } = boundsOf(other);
847 if (!overlaps({ min: tip, max: tip }, { min, max }, 3 + (tube.tipRims ? tube.tipRims[index] : 0))) return;
848 const ds = rim.map((p) => tipDistance(other, p));
849 const score = (Math.abs(tipDistance(other, tip)) + ds.reduce((sum, d) => sum + Math.abs(d), 0) / ds.length) / 2;
850 if (!best || score < best.score) best = { shape: other, score, d: tipDistance(other, tip), high: Math.max(...ds), low: Math.min(...ds) };
851 };
852 solids.forEach(consider);
853 tubes.forEach(consider);
854 if (best && byName.get(best.shape.owner) && best.shape.kind !== "tube") best.host = byName.get(best.shape.owner);
855 found[index] = best && { ...best, tip, landed: best.high <= tolerance && best.low >= -tolerance };
856 });
857 out.set(tube, found);
858 }
859 return out;
860}
862function ringAround(sample, r) {
863 const axis = sample.t;
864 const helper = Math.abs(axis[2]) > 0.9 ? [1, 0, 0] : [0, 0, 1];
865 const u = unit3([helper[1] * axis[2] - helper[2] * axis[1], helper[2] * axis[0] - helper[0] * axis[2], helper[0] * axis[1] - helper[1] * axis[0]]);
866 const v = [axis[1] * u[2] - axis[2] * u[1], axis[2] * u[0] - axis[0] * u[2], axis[0] * u[1] - axis[1] * u[0]];
867 return Array.from({ length: 12 }, (_, k) => add3(sample.p, add3(mul3(u, r * Math.cos((k * Math.PI) / 6)), mul3(v, r * Math.sin((k * Math.PI) / 6)))));
868}
870export function clearances(tubes, solids, { ratio = 0.5, least = 0.2, spacing = 0.5, touching = -0.05, through = -0.1, state, only, mounts = mountsOf(tubes, solids) } = {}) {
871 const gapOf = (r) => Math.max(least, ratio * r);
872 const problems = new Map();
873 const note = (tube, other, kind, clearance, need, sample) => {
874 if (only && kind !== only) return;
875 const id = `${tube.name}|${other.name}`;
876 const prior = problems.get(id);
877 if (prior && prior.clearance <= clearance) {
878 prior.from = Math.min(prior.from, sample.along);
879 prior.to = Math.max(prior.to, sample.along);
880 return;
881 }
882 problems.set(id, { tube: tube.name, other: other.name, kind, clearance, need, at: sample.p, from: prior ? Math.min(prior.from, sample.along) : sample.along, to: prior ? Math.max(prior.to, sample.along) : sample.along, length: pathLength(tube.points), state: state === undefined ? "" : labelOf(state) });
883 };
884 const poseOf = new Map([...tubes, ...solids].map((shape) => [shape, placed(shape, state)]));
885 const offsets = new Map([...tubes, ...solids].map((shape) => [shape, offsetAt(shape, state)]));
886 const widest = new Map(tubes.map((tube) => [tube, poseOf.get(tube).radii.reduce((r, next) => Math.max(r, next), 0)]));
887 const together = (a, b) => len3(sub3(offsets.get(a), offsets.get(b))) < 0.05;
888 const runsBy = new Map();
889 for (const tube of tubes) runsBy.set(tube.owner, [...(runsBy.get(tube.owner) ?? []), tube]);
890 const tipsOf = (owner) => (runsBy.get(owner) ?? []).flatMap((run) => (mounts.get(run) ?? []).filter(Boolean).map((mount) => ({ run, mount })));
891 const seatedOn = (shape, owner) => shape && shape.cut && [shape.cut].flat().some((cut) => cut.owner === owner);
892 const zone = (r) => r + gapOf(r) + 1;
893 const ownerOf = (other) => other.owner ?? other.name;
894 const related = (tube, other, sample) => {
895 if (seatedOn(other, tube.owner)) return true;
896 const tips = tipsOf(tube.owner);
897 const near = (entry) => len3(sub3(sample.p, add3(entry.mount.tip, offsets.get(entry.run)))) <= zone(sample.r) + (entry.run.tipRims ? Math.max(...entry.run.tipRims) : 0);
898 for (const entry of tips) {
899 if (!near(entry) || !together(tube, entry.mount.shape)) continue;
900 const mount = entry.mount.shape;
901 if (mount === other || (entry.mount.host && (entry.mount.host === other || other.owner === entry.mount.host.owner))) return true;
902 if (tipsOf(ownerOf(other)).some((theirs) => theirs.mount.shape === mount)) return true;
903 }
904 for (const entry of tipsOf(ownerOf(other))) {
905 const mount = entry.mount.shape;
906 const lands = mount.owner === tube.owner || seatedOn(mount, tube.owner);
907 if (!lands || !together(entry.run, tube)) continue;
908 if (len3(sub3(sample.p, add3(entry.mount.tip, offsets.get(entry.run)))) <= zone(sample.r) + (entry.run.tipRims ? Math.max(...entry.run.tipRims) : 0) + sample.r) return true;
909 }
910 return false;
911 };
912 const nearTip = (tube, sample) => {
913 const at = (tube.offset ?? 0) + sample.along;
914 const total = tube.total ?? pathLength(tube.points);
915 const tips = mounts.get(tube) ?? [null, null];
916 return (tips[0] && at <= zone(sample.r)) || (tips[1] && total - at <= zone(sample.r));
917 };
918 const clearanceTo = (other, sample) => {
919 if (sample.edge >= sample.r) return sdf(other, sample.p) - sample.r;
920 return Math.min(...ringAround(sample, sample.r).map((q) => sdf(other, q)));
921 };
922 for (const tube of tubes) {
923 const posed = poseOf.get(tube);
924 const samples = samplesOf(posed, spacing);
925 const tb = boundsOf(posed);
926 for (const shape of solids) {
927 if (shape.owner && shape.owner === tube.owner) continue;
928 if (matches(tube.touch, shape.name) || matches(shape.touch, tube.name)) continue;
929 const other = poseOf.get(shape);
930 const ob = boundsOf(other);
931 if (!overlaps(tb, ob, Math.max(4, gapOf(widest.get(tube))))) continue;
932 for (const sample of samples) {
933 if (!overlaps({ min: sample.p, max: sample.p }, ob, sample.r + Math.max(3, gapOf(sample.r)))) continue;
934 const gap = gapOf(sample.r);
935 const clearance = clearanceTo(other, sample);
936 if (clearance >= gap) continue;
937 if (related(tube, shape, sample)) continue;
938 if (nearTip(tube, sample) && clearance >= through) continue;
939 note(tube, shape, clearance < 0 ? "through" : "close", clearance, gap, sample);
940 }
941 }
942 }
943 for (let a = 0; a < tubes.length; a++) {
944 const ta = poseOf.get(tubes[a]);
945 const samples = samplesOf(ta, spacing);
946 for (let b = 0; b < tubes.length; b++) {
947 if (a === b) continue;
948 const tb = poseOf.get(tubes[b]);
949 if (tubes[a].owner && tubes[a].owner === tubes[b].owner && tubes[a].joined) continue;
950 if (matches(tubes[a].touch, tubes[b].name) || matches(tubes[b].touch, tubes[a].name)) continue;
951 const bundled = tubes[a].bundle && tubes[a].bundle === tubes[b].bundle;
952 const widestB = widest.get(tubes[b]);
953 const envelope = bundled ? 3 : Math.max(3, gapOf(Math.min(widest.get(tubes[a]), widestB)));
954 if (!overlaps(boundsOf(ta), boundsOf(tb), envelope)) continue;
955 for (const sample of samples) {
956 const envelope = bundled ? 3 : Math.max(3, gapOf(Math.min(sample.r, widestB)));
957 if (!overlaps({ min: sample.p, max: sample.p }, boundsOf(tb), sample.r + envelope)) continue;
958 const clearance = sample.edge >= sample.r ? sdf(tb, sample.p) - sample.r : Math.min(...ringAround(sample, sample.r).map((q) => sdf(tb, q)));
959 const r = centreline(tb, sample.p).r;
960 const gap = bundled ? touching : gapOf(Math.min(sample.r, r));
961 if (clearance >= gap) continue;
962 if (related(tubes[a], tubes[b], sample)) continue;
963 if (nearTip(tubes[a], sample) && clearance >= through) continue;
964 if (a > b && problems.has(`${tubes[b].name}|${tubes[a].name}`)) continue;
965 note(tubes[a], tubes[b], clearance < 0 ? "through" : "close", clearance, gap, sample);
966 }
967 }
968 }
969 const seen = new Set();
970 return [...problems.values()]
971 .sort((x, y) => x.clearance - y.clearance)
972 .filter((c) => {
973 const id = [c.tube, c.other].sort().join("|");
974 if (seen.has(id)) return false;
975 seen.add(id);
976 return true;
977 });
978}
980export function terminals(tubes, solids, { tolerance = 0.15, mounts = mountsOf(tubes, solids, { tolerance }) } = {}) {
981 const out = [];
982 for (const tube of tubes) {
983 (mounts.get(tube) ?? []).forEach((mount, index) => {
984 if (!tube.tips || !tube.tips[index] || (mount && mount.landed)) return;
985 out.push({ tube: tube.name, end: index ? "end" : "start", at: tube.tips[index], d: mount ? mount.d : Infinity, high: mount ? mount.high : Infinity, low: mount ? mount.low : -Infinity, nearest: mount ? mount.shape.name : "nothing" });
986 });
987 }
988 return out;
989}
991export function looseEnds(tubes, solids, { state = 1, tolerance = 0.15 } = {}) {
992 const out = [];
993 for (const tube of tubes) {
994 const offset = offsetAt(tube, state);
995 (tube.tips ?? []).forEach((tip, index) => {
996 if (!tip) return;
997 const hit = solids.some((shape) => {
998 if ((shape.owner && shape.owner === tube.owner) || shape.name === tube.owner) return false;
999 if (!sameOffset(offsetAt(shape, state), offset)) return false;
1000 const { min, max } = boundsOf(shape);
1001 return overlaps({ min: tip, max: tip }, { min, max }, 1) && sdf(shape, tip) <= tolerance;
1002 });
1003 if (!hit) out.push({ tube: tube.name, end: index ? "end" : "start", at: add3(tip, offset), state: labelOf(state), free: Boolean(tube.free && tube.free[index]) });
1004 });
1005 }
1006 return out;
1007}
1009function surfaceOf(shape, spacing = 0.6) {
1010 const points = [];
1011 if (shape.kind === "body") {
1012 const { F, poly } = shape;
1013 const u = F.u ?? unit3(Math.abs(F.a[2]) > 0.9 ? [1, 0, 0] : [-F.a[1], F.a[0], 0]);
1014 const v = F.v ?? [F.a[1] * u[2] - F.a[2] * u[1], F.a[2] * u[0] - F.a[0] * u[2], F.a[0] * u[1] - F.a[1] * u[0]];
1015 for (let i = 0; i < poly.length - 1; i++) {
1016 const [s0, r0] = poly[i];
1017 const [s1, r1] = poly[i + 1];
1018 const steps = Math.max(1, Math.ceil(Math.hypot(s1 - s0, r1 - r0) / spacing));
1019 for (let j = 0; j <= steps; j++) {
1020 const s = s0 + ((s1 - s0) * j) / steps;
1021 const r = r0 + ((r1 - r0) * j) / steps;
1022 const around = Math.max(1, Math.min(160, Math.ceil((2 * Math.PI * r) / spacing)));
1023 for (let k = 0; k < around; k++) {
1024 const t = (k / around) * 2 * Math.PI;
1025 points.push(add3(add3(F.o, mul3(F.a, s)), add3(mul3(u, r * Math.cos(t)), mul3(v, r * Math.sin(t)))));
1026 }
1027 }
1028 }
1029 } else if (shape.kind === "box") {
1030 for (let axis = 0; axis < 3; axis++)
1031 for (const sign of [-1, 1]) {
1032 const o1 = (axis + 1) % 3;
1033 const o2 = (axis + 2) % 3;
1034 const n1 = Math.max(1, Math.ceil((2 * shape.half[o1]) / spacing));
1035 const n2 = Math.max(1, Math.ceil((2 * shape.half[o2]) / spacing));
1036 for (let i = 0; i <= n1; i++)
1037 for (let j = 0; j <= n2; j++) {
1038 const c = [0, 0, 0];
1039 c[axis] = sign * shape.half[axis];
1040 c[o1] = -shape.half[o1] + (2 * shape.half[o1] * i) / n1;
1041 c[o2] = -shape.half[o2] + (2 * shape.half[o2] * j) / n2;
1042 points.push(add3(shape.o, add3(add3(mul3(shape.axes[0], c[0]), mul3(shape.axes[1], c[1])), mul3(shape.axes[2], c[2]))));
1043 }
1044 }
1045 } else if (shape.kind === "prism") {
1046 const { F, poly, s0, s1 } = shape;
1047 const at = (s, x, y) => add3(add3(F.o, mul3(F.a, s)), add3(mul3(F.u, x), mul3(F.v, y)));
1048 const rows = Math.max(1, Math.ceil((s1 - s0) / spacing));
1049 for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
1050 const [x0, y0] = poly[j];
1051 const [x1, y1] = poly[i];
1052 const steps = Math.max(1, Math.ceil(Math.hypot(x1 - x0, y1 - y0) / spacing));
1053 for (let k = 0; k < steps; k++)
1054 for (let row = 0; row <= rows; row++) points.push(at(s0 + ((s1 - s0) * row) / rows, x0 + ((x1 - x0) * k) / steps, y0 + ((y1 - y0) * k) / steps));
1055 }
1056 const xs = poly.map(([x]) => x);
1057 const ys = poly.map(([, y]) => y);
1058 for (let x = Math.min(...xs); x <= Math.max(...xs); x += spacing)
1059 for (let y = Math.min(...ys); y <= Math.max(...ys); y += spacing) if (polygonDistance(poly, x, y) < 0) points.push(at(s0, x, y), at(s1, x, y));
1060 } else if (shape.kind === "ball") {
1061 const count = Math.max(24, Math.min(600, Math.ceil((4 * Math.PI * shape.r * shape.r) / (spacing * spacing))));
1062 for (let i = 0; i < count; i++) {
1063 const z = 1 - (2 * (i + 0.5)) / count;
1064 const ring = Math.sqrt(1 - z * z);
1065 const t = i * 2.399963;
1066 points.push(add3(shape.o, mul3([ring * Math.cos(t), ring * Math.sin(t), z], shape.r)));
1067 }
1068 if (shape.flats) {
1069 const kept = points.filter((p) => shape.flats.every(([nx, ny, nz, cut]) => nx * (p[0] - shape.o[0]) + ny * (p[1] - shape.o[1]) + nz * (p[2] - shape.o[2]) <= cut + 1e-9));
1070 points.length = 0;
1071 points.push(...kept);
1072 for (const [nx, ny, nz, cut] of shape.flats) {
1073 const n = [nx, ny, nz];
1074 const helper = Math.abs(nz) > 0.9 ? [1, 0, 0] : [0, 0, 1];
1075 const u = unit3([helper[1] * nz - helper[2] * ny, helper[2] * nx - helper[0] * nz, helper[0] * ny - helper[1] * nx]);
1076 const v = [ny * u[2] - nz * u[1], nz * u[0] - nx * u[2], nx * u[1] - ny * u[0]];
1077 const rho = Math.sqrt(Math.max(0, shape.r * shape.r - cut * cut));
1078 const centre = add3(shape.o, mul3(n, cut));
1079 for (let x = -rho; x <= rho; x += spacing)
1080 for (let y = -rho; y <= rho; y += spacing) {
1081 if (x * x + y * y > rho * rho) continue;
1082 const p = add3(centre, add3(mul3(u, x), mul3(v, y)));
1083 if (shape.flats.every(([mx, my, mz, other]) => mx * (p[0] - shape.o[0]) + my * (p[1] - shape.o[1]) + mz * (p[2] - shape.o[2]) <= other + 1e-6)) points.push(p);
1084 }
1085 }
1086 }
1087 } else {
1088 for (const sample of samplesOf(shape, spacing)) points.push(...ringAround(sample, sample.r));
1089 }
1090 return shape.cut ? points.filter((p) => cutDistance(shape, p) >= -1e-3) : points;
1091}
1093const seatedOn = (shape, host) => Boolean(shape.seated && shape.cut && [shape.cut].flat().some((cut) => cut === host || (cut.name && cut.name === host.name)));
1095export const grounded = (shape) => Boolean(shape.ground) || (shape.name ?? "").startsWith("stand.");
1097export function supports(solids, tubes, { ground = grounded, touch = 0.15, sunk = 0.5 } = {}) {
1098 const nodes = [...solids, ...tubes.filter((tube) => !tube.limp)];
1099 const surfaces = nodes.map((shape) => surfaceOf(shape));
1100 const links = nodes.map(() => []);
1101 const deep = [];
1102 for (let a = 0; a < nodes.length; a++) {
1103 const ba = boundsOf(nodes[a]);
1104 for (let b = a + 1; b < nodes.length; b++) {
1105 const bb = boundsOf(nodes[b]);
1106 if (!overlaps(ba, bb, touch + 0.05)) continue;
1107 const same = (nodes[a].owner && (nodes[a].owner === nodes[b].owner || nodes[a].owner === nodes[b].name)) || (nodes[b].owner && nodes[b].owner === nodes[a].name);
1108 let gap = BIG;
1109 for (const p of surfaces[a]) gap = Math.min(gap, sdf(nodes[b], p));
1110 for (const p of surfaces[b]) gap = Math.min(gap, sdf(nodes[a], p));
1111 if (gap <= touch) links[a].push(b), links[b].push(a);
1112 const solidPair = nodes[a].kind !== "tube" && nodes[b].kind !== "tube";
1113 if (solidPair && !same && gap < -sunk && !seatedOn(nodes[a], nodes[b]) && !seatedOn(nodes[b], nodes[a])) deep.push({ a: nodes[a].name, b: nodes[b].name, depth: -gap });
1114 }
1115 }
1116 const held = new Uint8Array(nodes.length);
1117 const queue = [];
1118 nodes.forEach((shape, index) => ground(shape) && ((held[index] = 1), queue.push(index)));
1119 while (queue.length) {
1120 const index = queue.pop();
1121 for (const next of links[index]) if (!held[next]) (held[next] = 1), queue.push(next);
1122 }
1123 const loose = nodes.filter((_, index) => !held[index] && nodes[index].kind !== "tube").map((shape) => shape.name);
1124 const hanging = nodes.filter((_, index) => !held[index] && nodes[index].kind === "tube").map((shape) => shape.name);
1125 return { loose, hanging, sunk: deep.sort((x, y) => y.depth - x.depth) };
1126}
1128export function folds(routes, P, { fold = 140, turn = 110, pinch = 2, items = null } = {}) {
1129 const cam = cameraBasis(P);
1130 const entries = items ? entriesOf(items, cam) : null;
1131 const shown = (name, p) => !entries || frontAt(entries, cam, cam.origin[0] + dot3(p, cam.R) * cam.k, cam.origin[1] + dot3(p, cam.D) * cam.k) === name;
1132 const flat = (p) => [dot3(p, cam.R) * cam.k, dot3(p, cam.D) * cam.k];
1133 const angle = (a, b) => (Math.acos(Math.max(-1, Math.min(1, (a[0] * b[0] + a[1] * b[1]) / (Math.hypot(a[0], a[1]) * Math.hypot(b[0], b[1]) || 1)))) * 180) / Math.PI;
1134 const out = [];
1135 for (const { name, points, r } of routes) {
1136 const step = Math.max(0.25, r * 0.5);
1137 const dense = resample(points, step);
1138 const screen = dense.map(flat);
1139 const reach = Math.max(2, Math.round((4 * r) / step));
1140 const width = r * cam.k;
1141 let worst = null;
1142 for (const span of [1.5, 3, 6]) {
1143 const near = Math.max(1, Math.round((span * r) / step));
1144 for (let i = near; i < dense.length - near; i++) {
1145 const p0 = screen[i - near];
1146 const p1 = screen[i];
1147 const p2 = screen[i + near];
1148 const a = [p1[0] - p0[0], p1[1] - p0[1]];
1149 const b = [p2[0] - p1[0], p2[1] - p1[1]];
1150 const c = [p2[0] - p0[0], p2[1] - p0[1]];
1151 const la = Math.hypot(a[0], a[1]);
1152 const lb = Math.hypot(b[0], b[1]);
1153 if (la < width * 0.3 || lb < width * 0.3) continue;
1154 const bend = angle(a, b);
1155 const area = Math.abs(a[0] * b[1] - a[1] * b[0]) / 2;
1156 const radius = area > 1e-9 ? (la * lb * Math.hypot(c[0], c[1])) / (4 * area) : Infinity;
1157 if (bend > 90 && radius < pinch * width && (!worst || radius < worst.radius) && shown(name, dense[i])) worst = { name, kind: "pinch", flatTurn: bend, radius, width, at: dense[i], index: i };
1158 }
1159 }
1160 if (worst) {
1161 let toStart = 0;
1162 for (let i = 1; i <= worst.index; i++) toStart += Math.hypot(screen[i][0] - screen[i - 1][0], screen[i][1] - screen[i - 1][1]);
1163 let toEnd = 0;
1164 for (let i = worst.index + 1; i < screen.length; i++) toEnd += Math.hypot(screen[i][0] - screen[i - 1][0], screen[i][1] - screen[i - 1][1]);
1165 if (Math.min(toStart, toEnd) > 3 * width) out.push(worst);
1166 }
1167 for (let i = reach; i < dense.length - reach; i++) {
1168 const a = [screen[i][0] - screen[i - reach][0], screen[i][1] - screen[i - reach][1]];
1169 const b = [screen[i + reach][0] - screen[i][0], screen[i + reach][1] - screen[i][1]];
1170 if (Math.hypot(a[0], a[1]) < 1e-6 || Math.hypot(b[0], b[1]) < 1e-6) continue;
1171 const flatTurn = angle(a, b);
1172 const A3 = sub3(dense[i], dense[i - reach]);
1173 const B3 = sub3(dense[i + reach], dense[i]);
1174 const realTurn = (Math.acos(Math.max(-1, Math.min(1, dot3(A3, B3) / (len3(A3) * len3(B3) || 1)))) * 180) / Math.PI;
1175 if (flatTurn > fold && realTurn < turn && shown(name, dense[i])) {
1176 out.push({ name, kind: "fold", flatTurn, realTurn, at: dense[i] });
1177 i += 2 * reach;
1178 }
1179 }
1180 }
1181 return out;
1182}
1184export function linksOf(tubes, mounts) {
1185 const links = new Map();
1186 const link = (a, b) => {
1187 if (!a || !b || a === b) return;
1188 if (!links.has(a)) links.set(a, new Set());
1189 if (!links.has(b)) links.set(b, new Set());
1190 links.get(a).add(b);
1191 links.get(b).add(a);
1192 };
1193 const ownerOf = (shape) => shape.owner ?? shape.name;
1194 const seats = new Map();
1195 for (const tube of tubes)
1196 for (const mount of mounts.get(tube) ?? []) {
1197 if (!mount) continue;
1198 const host = ownerOf(mount.shape);
1199 link(tube.owner, host);
1200 if (!seats.has(host)) seats.set(host, []);
1201 seats.get(host).push(tube.owner);
1202 if (mount.shape.cut) for (const cut of [mount.shape.cut].flat()) link(tube.owner, ownerOf(cut));
1203 }
1204 for (const owners of seats.values()) for (const a of owners) for (const b of owners) link(a, b);
1205 return links;
1206}
1208const entriesOf = (items, cam) => items.flatMap((item) => (item.shapes ?? []).map((shape) => ({ shape, box: screenBox(shape, cam, [0, 0, 0]).map((v, i) => v + (i < 2 ? -1 : 1)), owner: item.route ?? shape.owner ?? item.name, bundle: shape.bundle ?? null, item: item.name })));
1210function frontEntry(entries, cam, x, y) {
1211 const c = add3(mul3(cam.R, (x - cam.origin[0]) / cam.k), mul3(cam.D, (y - cam.origin[1]) / cam.k));
1212 let best = -BIG;
1213 let who = null;
1214 for (const entry of entries) {
1215 const { shape, box } = entry;
1216 if (x < box[0] || x > box[2] || y < box[1] || y > box[3]) continue;
1217 const t = rayDepth(shape, c, cam.V, 0.02, null);
1218 if (t !== null && t > best) (best = t), (who = entry);
1219 }
1220 return who;
1221}
1223function frontAt(entries, cam, x, y) {
1224 const entry = frontEntry(entries, cam, x, y);
1225 return entry ? entry.owner : null;
1226}
1228const belongs = (entry, name, bundle) => Boolean(entry) && (entry.owner === name || entry.item === name || (bundle && entry.bundle === bundle));
1230export function slenderOf(solids, { ratio = 4, least = 0.3 } = {}) {
1231 const out = [];
1232 const axisLine = (centre, axis, half, r, shape) => {
1233 out.push({ name: shape.owner ?? shape.name, solid: shape.name, owners: new Set([shape.name, shape.owner].filter(Boolean)), points: [add3(centre, mul3(axis, -half)), add3(centre, mul3(axis, half))], r, move: shape.move, ride: shape.ride, piece: shape.piece, rod: true });
1234 };
1235 for (const shape of solids) {
1236 if (!shape || shape.kind === "tube" || shape.kind === "ball" || shape.fitting) continue;
1237 if (shape.kind === "body") {
1238 const s = shape.poly.map(([value]) => value);
1239 const s0 = Math.min(...s);
1240 const s1 = Math.max(...s);
1241 const r = Math.max(...shape.poly.map(([, value]) => value));
1242 if (r >= least && s1 - s0 >= ratio * r) axisLine(add3(shape.F.o, mul3(shape.F.a, (s0 + s1) / 2)), shape.F.a, (s1 - s0) / 2, r, shape);
1243 continue;
1244 }
1245 let axes;
1246 let half;
1247 let centre;
1248 if (shape.kind === "prism") {
1249 const xs = shape.poly.map(([x]) => x);
1250 const ys = shape.poly.map(([, y]) => y);
1251 const [x0, x1, y0, y1] = [Math.min(...xs), Math.max(...xs), Math.min(...ys), Math.max(...ys)];
1252 axes = [shape.F.u, shape.F.v, shape.F.a];
1253 half = [(x1 - x0) / 2, (y1 - y0) / 2, (shape.s1 - shape.s0) / 2];
1254 centre = add3(shape.F.o, add3(add3(mul3(shape.F.u, (x0 + x1) / 2), mul3(shape.F.v, (y0 + y1) / 2)), mul3(shape.F.a, (shape.s0 + shape.s1) / 2)));
1255 } else if (shape.kind === "box") {
1256 axes = shape.axes;
1257 half = shape.half;
1258 centre = shape.o;
1259 } else continue;
1260 const order = [0, 1, 2].sort((a, b) => half[b] - half[a]);
1261 const r = half[order[1]];
1262 if (r >= least && half[order[0]] >= ratio * r) axisLine(centre, axes[order[0]], half[order[0]], r, shape);
1263 }
1264 return out;
1265}
1267export function crowding(routes, P, { gap = 1, bend = 30, links = new Map(), items = null, allow = [], state, clear = 2, rods = [] } = {}) {
1268 const cam = cameraBasis(P);
1269 const shift = (thing) => (state === undefined ? [0, 0, 0] : offsetOf(thing, state));
1270 const entries = items ? entriesOf(items.map((item) => ({ ...item, shapes: (item.shapes ?? []).map((shape) => moved(shape, shift(item))) })), cam) : null;
1271 const bundles = new Map(routes.map((route) => [route.name, route.bundle]));
1272 const shown = (owner, x, y, rod) => {
1273 if (!entries) return true;
1274 const entry = frontEntry(entries, cam, x, y);
1275 if (rod) return Boolean(entry) && (entry.shape.name === rod.solid || (!entry.shape.name && entry.item === rod.solid));
1276 return belongs(entry, owner, bundles.get(owner));
1277 };
1278 const flat = (p) => [cam.origin[0] + dot3(p, cam.R) * cam.k, cam.origin[1] + dot3(p, cam.D) * cam.k];
1279 const lines = [...routes, ...rods].map((route) => {
1280 const step = Math.max(0.25, route.r * 0.5);
1281 const offset = shift(route);
1282 const dense = resample(route.points.map((p) => add3(p, offset)), step);
1283 return { ...route, step, dense, screen: dense.map(flat), width: route.r * cam.k };
1284 });
1285 const marksOf = (line) => {
1286 const out = [];
1287 const reach = Math.max(2, Math.round((2 * line.r) / line.step));
1288 const last = line.dense.length - 1;
1289 for (let i = 0; i <= last; i++) {
1290 if (i <= reach || i >= last - reach) {
1291 out.push({ i, where: i <= reach ? "start" : "end" });
1292 continue;
1293 }
1294 const A = sub3(line.dense[i], line.dense[i - reach]);
1295 const B = sub3(line.dense[i + reach], line.dense[i]);
1296 const turn = (Math.acos(Math.max(-1, Math.min(1, dot3(A, B) / (len3(A) * len3(B) || 1)))) * 180) / Math.PI;
1297 if (turn > bend) out.push({ i, where: "bend" });
1298 }
1299 return out;
1300 };
1301 const found = new Map();
1302 for (const a of lines) {
1303 if (a.rod) continue;
1304 const marks = marksOf(a);
1305 for (const b of lines) {
1306 if (a === b || (a.bundle && a.bundle === b.bundle) || links.get(a.name)?.has(b.name)) continue;
1307 if (b.rod && [...b.owners].some((name) => name === a.name || links.get(a.name)?.has(name))) continue;
1308 if (allow.some(([x, y]) => [b.name, b.solid].filter(Boolean).some((name) => (matches([x], a.name) && matches([y], name)) || (matches([y], a.name) && matches([x], name))))) continue;
1309 for (const { i, where } of marks) {
1310 const [x, y] = a.screen[i];
1311 let best = { d: BIG, j: -1 };
1312 for (let j = 0; j < b.screen.length; j++) {
1313 const d = Math.hypot(b.screen[j][0] - x, b.screen[j][1] - y);
1314 if (d < best.d) best = { d, j };
1315 }
1316 const touch = best.d - a.width - b.width;
1317 if (touch >= gap) continue;
1318 if (where !== "bend" && touch <= -2 * Math.min(a.width, b.width)) {
1319 const tip = a.screen[where === "start" ? 0 : a.screen.length - 1];
1320 let clearTip = BIG;
1321 for (const q of b.screen) clearTip = Math.min(clearTip, Math.hypot(q[0] - tip[0], q[1] - tip[1]));
1322 const reachB = Math.max(2, Math.round((2 * b.r) / b.step));
1323 const j0 = Math.max(0, best.j - reachB);
1324 const j1 = Math.min(b.dense.length - 1, best.j + reachB);
1325 const straight = j1 - j0 >= 2 && dot3(unit3(sub3(b.dense[best.j], b.dense[j0])), unit3(sub3(b.dense[j1], b.dense[best.j]))) > Math.cos((bend * Math.PI) / 180);
1326 if (straight && clearTip > b.width + a.width + 1) continue;
1327 }
1328 const [bx, by] = b.screen[best.j];
1329 const u = [(bx - x) / (best.d || 1), (by - y) / (best.d || 1)];
1330 const inA = Math.max(0.2, a.width - 0.35);
1331 const inB = Math.max(0.2, b.width - 0.35);
1332 if (!shown(a.name, x + u[0] * inA, y + u[1] * inA)) continue;
1333 if (b.rod) {
1334 if (!shown(b.name, bx - u[0] * inB, by - u[1] * inB, b) && !shown(b.name, bx + u[0] * inB, by + u[1] * inB, b)) continue;
1335 } else if (!shown(b.name, bx - u[0] * inB, by - u[1] * inB)) continue;
1336 const depth = Math.abs(dot3(sub3(a.dense[i], b.dense[best.j]), cam.V));
1337 const need = 2 * (a.r + b.r);
1338 const id = [a.name, b.name].sort().join("|");
1339 const prior = found.get(id);
1340 if (!prior || Math.abs(touch) < Math.abs(prior.gap)) found.set(id, { a: a.name, b: b.rod ? `${b.solid} (rod)` : b.name, where, gap: touch, depth, need, screen: [x, y], at: a.dense[i], state: state === undefined ? "" : labelOf(state) });
1341 }
1342 }
1343 }
1344 if (entries)
1345 for (const fitting of entries.filter((entry) => entry.shape.fitting && entry.shape.kind === "body")) {
1346 const { shape } = fitting;
1347 const reach = Math.max(...shape.poly.map(([, r]) => r));
1348 const [x, y] = flat(shape.F.o);
1349 for (const b of lines) {
1350 if (b.rod) continue;
1351 if ((fitting.bundle && b.bundle === fitting.bundle) || b.name === fitting.owner || links.get(fitting.owner)?.has(b.name)) continue;
1352 if (allow.some(([p, q]) => (matches([p], fitting.item) && matches([q], b.name)) || (matches([q], fitting.item) && matches([p], b.name)))) continue;
1353 let best = { d: BIG, j: -1 };
1354 for (let j = 0; j < b.screen.length; j++) {
1355 const d = Math.hypot(b.screen[j][0] - x, b.screen[j][1] - y);
1356 if (d < best.d) best = { d, j };
1357 }
1358 const touch = best.d - reach * cam.k - b.width;
1359 if (touch >= clear) continue;
1360 const front = frontEntry(entries, cam, x, y);
1361 if (!(front === fitting || belongs(front, fitting.owner, fitting.bundle))) continue;
1362 const id = [fitting.item, b.name].sort().join("|");
1363 const prior = found.get(id);
1364 if (!prior || Math.abs(touch) < Math.abs(prior.gap)) found.set(id, { a: fitting.item, b: b.name, where: "fitting", gap: touch, depth: Math.abs(dot3(sub3(shape.F.o, b.dense[best.j]), cam.V)), need: 0, screen: [x, y], at: shape.F.o, state: state === undefined ? "" : labelOf(state) });
1365 }
1366 }
1367 return [...found.values()].sort((x, y) => Math.abs(x.gap) - Math.abs(y.gap));
1368}
1370const multiply = (m, n) => [m[0] * n[0] + m[2] * n[1], m[1] * n[0] + m[3] * n[1], m[0] * n[2] + m[2] * n[3], m[1] * n[2] + m[3] * n[3], m[0] * n[4] + m[2] * n[5] + m[4], m[1] * n[4] + m[3] * n[5] + m[5]];
1372function matrixOf(text) {
1373 let m = [1, 0, 0, 1, 0, 0];
1374 if (!text) return m;
1375 for (const [, kind, args] of text.matchAll(/(matrix|translate|scale|rotate|skewX|skewY)\s*\(([^)]*)\)/g)) {
1376 const v = (args.match(/-?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?/gi) ?? []).map(Number);
1377 let n = [1, 0, 0, 1, 0, 0];
1378 if (kind === "matrix" && v.length === 6) n = v;
1379 else if (kind === "translate") n = [1, 0, 0, 1, v[0] ?? 0, v[1] ?? 0];
1380 else if (kind === "scale") n = [v[0] ?? 1, 0, 0, v[1] ?? v[0] ?? 1, 0, 0];
1381 else if (kind === "rotate") {
1382 const a = ((v[0] ?? 0) * Math.PI) / 180;
1383 const [cx, cy] = [v[1] ?? 0, v[2] ?? 0];
1384 n = multiply(multiply([1, 0, 0, 1, cx, cy], [Math.cos(a), Math.sin(a), -Math.sin(a), Math.cos(a), 0, 0]), [1, 0, 0, 1, -cx, -cy]);
1385 }
1386 else if (kind === "skewX") n = [1, 0, Math.tan((v[0] ?? 0) * Math.PI / 180), 1, 0, 0];
1387 else if (kind === "skewY") n = [1, Math.tan((v[0] ?? 0) * Math.PI / 180), 0, 1, 0, 0];
1388 m = multiply(m, n);
1389 }
1390 return m;
1391}
1393const NUMBER = /-?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?/gi;
1395const attributeOf = (attrs, name) => {
1396 const hit = [...attrs.matchAll(/(?:^|\s)([^\s=/>]+)\s*=\s*(["'])([\s\S]*?)\2/g)].find((match) => match[1] === name);
1397 if (!hit) return null;
1398 const named = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" };
1399 return hit[3].replace(/&(#x[0-9a-fA-F]+|#\d+|amp|lt|gt|quot|apos);/g, (all, code) => {
1400 if (code[0] !== "#") return named[code];
1401 const value = code[1] === "x" ? parseInt(code.slice(2), 16) : Number(code.slice(1));
1402 return value > 0 && value <= 0x10ffff ? String.fromCodePoint(value) : all;
1403 });
1404};
1406function pointsOfShape(tag, attrs) {
1407 const get = (name) => attributeOf(attrs, name);
1408 const num = (name) => Number(get(name) ?? 0);
1409 if (tag === "path") {
1410 const d = get("d") ?? "";
1411 const out = [];
1412 let x = 0;
1413 let y = 0;
1414 let sx = 0;
1415 let sy = 0;
1416 for (const [, command, args] of d.matchAll(/([MmLlHhVvCcSsQqTtAaZz])([^MmLlHhVvCcSsQqTtAaZz]*)/g)) {
1417 const v = (args.match(NUMBER) ?? []).map(Number);
1418 const rel = command === command.toLowerCase();
1419 const C = command.toUpperCase();
1420 const pair = (i) => [v[i] + (rel ? x : 0), v[i + 1] + (rel ? y : 0)];
1421 const take = (p) => {
1422 out.push(p);
1423 return p;
1424 };
1425 if (C === "H") for (const value of v) [x] = take([value + (rel ? x : 0), y]);
1426 else if (C === "V") for (const value of v) [, y] = take([x, value + (rel ? y : 0)]);
1427 else if (C === "A") for (let i = 0; i + 6 < v.length; i += 7) [x, y] = take([v[i + 5] + (rel ? x : 0), v[i + 6] + (rel ? y : 0)]);
1428 else if (C === "Z") {
1429 x = sx;
1430 y = sy;
1431 } else {
1432 const width = C === "C" ? 6 : C === "S" || C === "Q" ? 4 : 2;
1433 for (let i = 0; i + 1 < v.length; i += width) {
1434 for (let j = 0; j < width; j += 2) out.push(pair(i + j));
1435 [x, y] = pair(i + width - 2);
1436 if (C === "M" && i === 0) {
1437 sx = x;
1438 sy = y;
1439 }
1440 }
1441 }
1442 }
1443 return out;
1444 }
1445 if (tag === "circle" || tag === "ellipse") {
1446 const r = Number(get("r") ?? get("rx") ?? 0);
1447 const ry = Number(get("r") ?? get("ry") ?? 0);
1448 return [[num("cx") - r, num("cy") - ry], [num("cx") + r, num("cy") + ry]];
1449 }
1450 if (tag === "line") return [[num("x1"), num("y1")], [num("x2"), num("y2")]];
1451 if (tag === "rect") return cornersOfBox([num("x"), num("y"), num("x") + num("width"), num("y") + num("height")]);
1452 if (tag === "polyline" || tag === "polygon") {
1453 const v = (get("points") ?? "").match(NUMBER)?.map(Number) ?? [];
1454 const out = [];
1455 for (let i = 0; i + 1 < v.length; i += 2) out.push([v[i], v[i + 1]]);
1456 return out;
1457 }
1458 return [];
1459}
1461const boxOfPoints = (points, m) => {
1462 let box = null;
1463 for (const [x, y] of points) {
1464 const px = m[0] * x + m[2] * y + m[4];
1465 const py = m[1] * x + m[3] * y + m[5];
1466 box = box ? [Math.min(box[0], px), Math.min(box[1], py), Math.max(box[2], px), Math.max(box[3], py)] : [px, py, px, py];
1467 }
1468 return box;
1469};
1470const cornersOfBox = ([x0, y0, x1, y1]) => [[x0, y0], [x1, y0], [x1, y1], [x0, y1]];
1471const boxOfShape = (tag, attrs, m) => {
1472 if (tag !== "circle" && tag !== "ellipse") return boxOfPoints(pointsOfShape(tag, attrs), m);
1473 const num = (name) => Number(attributeOf(attrs, name) ?? 0);
1474 const rx = tag === "circle" ? num("r") : num("rx");
1475 const ry = tag === "circle" ? rx : num("ry");
1476 const x = m[0] * num("cx") + m[2] * num("cy") + m[4];
1477 const y = m[1] * num("cx") + m[3] * num("cy") + m[5];
1478 const dx = Math.hypot(m[0] * rx, m[2] * ry);
1479 const dy = Math.hypot(m[1] * rx, m[3] * ry);
1480 return [x - dx, y - dy, x + dx, y + dy];
1481};
1482const unionBox = (a, b) => (!a ? b : !b ? a : [Math.min(a[0], b[0]), Math.min(a[1], b[1]), Math.max(a[2], b[2]), Math.max(a[3], b[3])]);
1483const meetBox = (a, b) => {
1484 if (!a || !b) return a ?? null;
1485 const box = [Math.max(a[0], b[0]), Math.max(a[1], b[1]), Math.min(a[2], b[2]), Math.min(a[3], b[3])];
1486 return box[0] <= box[2] && box[1] <= box[3] ? box : null;
1487};
1488const areaOf = (box) => (box ? Math.max(0, box[2] - box[0]) * Math.max(0, box[3] - box[1]) : 0);
1490export function markupBox(svg) {
1491 const tags = [...svg.matchAll(/<(\/?)([a-zA-Z]+)((?:"[^"]*"|'[^']*'|[^'">])*?)(\/?)>/g)];
1492 const clips = new Map();
1493 let clipId = null;
1494 for (const [, close, tag, attrs, self] of tags) {
1495 if (tag === "clipPath" && !close) {
1496 const id = attributeOf(attrs, "id");
1497 if (id) clips.set(id, null);
1498 clipId = self ? null : id;
1499 continue;
1500 }
1501 if (clipId) {
1502 if (tag === "clipPath" && close) {
1503 clipId = null;
1504 continue;
1505 }
1506 clips.set(clipId, unionBox(clips.get(clipId), boxOfShape(tag, attrs, matrixOf(attributeOf(attrs, "transform")))));
1507 }
1508 }
1509 const stack = [{ m: [1, 0, 0, 1, 0, 0], clip: null, skip: false }];
1510 let box = null;
1511 for (const [, close, tag, attrs, self] of tags) {
1512 const top = stack[stack.length - 1];
1513 if (close) {
1514 if (stack.length > 1) stack.pop();
1515 continue;
1516 }
1517 const m = multiply(top.m, matrixOf(attributeOf(attrs, "transform")));
1518 const ref = ((attributeOf(attrs, "clip-path") ?? "").match(/url\(#([^)]*)\)/) ?? [])[1];
1519 const own = ref && clips.get(ref) ? boxOfPoints(cornersOfBox(clips.get(ref)), m) : null;
1520 const clip = own ? meetBox(top.clip ?? own, own) : top.clip;
1521 const empty = ref && clips.has(ref) && (!own || areaOf(own) === 0 || (top.clip && (!clip || areaOf(clip) === 0)));
1522 const frame = { m, clip, skip: Boolean(empty) || top.skip || ["defs", "clipPath", "mask", "linearGradient", "radialGradient", "pattern", "text", "title"].includes(tag) || (attributeOf(attrs, "class") ?? "").includes("iso-halo") };
1523 if (!frame.skip) {
1524 const shape = boxOfShape(tag, attrs, m);
1525 if (shape) box = unionBox(box, frame.clip ? meetBox(shape, frame.clip) : shape);
1526 }
1527 if (!self) stack.push(frame);
1528 }
1529 return box;
1530}
1532export function footprintOf(shape, cam) {
1533 const flat = (p) => [cam.origin[0] + dot3(p, cam.R) * cam.k, cam.origin[1] + dot3(p, cam.D) * cam.k];
1534 if (shape.kind === "body") {
1535 const { F } = shape;
1536 const points = [];
1537 for (const [s, r] of shape.poly) {
1538 if (r <= 0) {
1539 points.push(flat(add3(F.o, mul3(F.a, s))));
1540 continue;
1541 }
1542 for (let i = 0; i < 16; i++) {
1543 const a = (i / 16) * Math.PI * 2;
1544 points.push(flat(add3(add3(F.o, mul3(F.a, s)), add3(mul3(F.u, r * Math.cos(a)), mul3(F.v, r * Math.sin(a))))));
1545 }
1546 }
1547 return boxOfPoints(points, [1, 0, 0, 1, 0, 0]);
1548 }
1549 if (shape.kind === "prism") {
1550 const { F } = shape;
1551 const points = [];
1552 for (const s of [shape.s0, shape.s1]) for (const [x, y] of shape.poly) points.push(flat(add3(add3(F.o, mul3(F.a, s)), add3(mul3(F.u, x), mul3(F.v, y)))));
1553 return boxOfPoints(points, [1, 0, 0, 1, 0, 0]);
1554 }
1555 const [x0, y0, x1, y1] = screenBox(shape, cam, [0, 0, 0]);
1556 return [x0, y0, x1, y1];
1557}
1559export function coverage(items, P, { least = 0.6, pad = 1, allow = [] } = {}) {
1560 const cam = cameraBasis(P);
1561 const out = [];
1562 for (const item of items) {
1563 if (typeof item.svg !== "string" || !item.shapes || !item.shapes.length || matches(allow, item.name)) continue;
1564 const drawn = markupBox(item.svg);
1565 if (!drawn) continue;
1566 const grow = (box) => [box[0] - pad, box[1] - pad, box[2] + pad, box[3] + pad];
1567 const shapes = item.shapes.map((shape) => footprintOf(shape, cam)).reduce(unionBox, null);
1568 const share = areaOf(meetBox(grow(drawn), grow(shapes))) / (areaOf(grow(drawn)) || 1);
1569 if (share < least) out.push({ name: item.name, share, drawn, shapes });
1570 }
1571 return out.sort((a, b) => a.share - b.share);
1572}
1574export function selfOverlaps(items, P, { entries: given = null, fold = 0.25, least = 0.5 } = {}) {
1575 const cam = cameraBasis(P);
1576 const entries = given ?? entriesOf(items, cam);
1577 const flat = (p) => [cam.origin[0] + dot3(p, cam.R) * cam.k, cam.origin[1] + dot3(p, cam.D) * cam.k];
1578 const out = [];
1579 for (const item of items) {
1580 if (!item.route || !item.shapes || !item.shapes.length || item.shapes[0].kind !== "tube") continue;
1581 const shape = item.shapes[0];
1582 const r = shape.radii.reduce((sum, value) => sum + value, 0) / shape.radii.length;
1583 const dense = resample(shape.points, Math.max(0.1, r * 0.2));
1584 const screen = dense.map(flat);
1585 const n = screen.length;
1586 if (n < 4) continue;
1587 const width = r * cam.k;
1588 const span = 3;
1589 const normals = screen.map((_, i) => {
1590 const a = screen[Math.max(0, i - span)];
1591 const b = screen[Math.min(n - 1, i + span)];
1592 const length = Math.hypot(b[0] - a[0], b[1] - a[1]) || 1;
1593 return [-(b[1] - a[1]) / length, (b[0] - a[0]) / length];
1594 });
1595 let worst = null;
1596 for (const side of [1, -1]) {
1597 const edge = screen.map((p, i) => [p[0] + side * normals[i][0] * width, p[1] + side * normals[i][1] * width]);
1598 for (let i = span; i < n - span - 1; i++) {
1599 const along = [screen[i + 1][0] - screen[i][0], screen[i + 1][1] - screen[i][1]];
1600 const step = [edge[i + 1][0] - edge[i][0], edge[i + 1][1] - edge[i][1]];
1601 const length = Math.hypot(along[0], along[1]);
1602 if (length < 1e-6) continue;
1603 const forward = (step[0] * along[0] + step[1] * along[1]) / length;
1604 if (forward > -fold * length) continue;
1605 const x = (screen[i][0] + screen[i + 1][0]) / 2;
1606 const y = (screen[i][1] + screen[i + 1][1]) / 2;
1607 const front = frontEntry(entries, cam, x, y);
1608 if (front && front.owner !== item.route && front.item !== item.name) continue;
1609 const radius = Math.abs(forward / length - 1) > 1e-6 ? width / Math.abs(1 - forward / length) : Infinity;
1610 if (width - radius < least) continue;
1611 if (!worst || radius < worst.radius) worst = { name: item.name, route: item.route, radius, width, at: dense[i], screen: [x, y] };
1612 }
1613 }
1614 if (worst) out.push(worst);
1615 }
1616 return out;
1617}
1619export function solidClearances(solids, { state, sunk = 0.5, spacing = 0.6, cell = 4 } = {}) {
1620 const stretched = new Set();
1621 const list = solids.filter((shape) => shape.kind !== "tube").map((shape) => {
1622 if (!(shape.slide && state !== undefined && shape.move)) return shape;
1623 const next = { ...moved(shape, offsetOf(shape, state)), move: undefined };
1624 if (next.slide.to !== shape.slide.to) stretched.add(next);
1625 return next;
1626 });
1627 if (!solidClearances.cache) solidClearances.cache = new WeakMap();
1628 const cache = solidClearances.cache;
1629 const gridOf = (shape) => {
1630 let samples = cache.get(shape);
1631 if (!samples) cache.set(shape, samples = new Map());
1632 const key = `${spacing}:${cell}`;
1633 let grid = samples.get(key);
1634 if (grid) return grid;
1635 grid = new Map();
1636 for (const p of surfaceOf(shape, spacing)) {
1637 const key = p.map((v) => Math.floor(v / cell)).join(",");
1638 if (!grid.has(key)) grid.set(key, []);
1639 grid.get(key).push(p);
1640 }
1641 samples.set(key, grid);
1642 return grid;
1643 };
1644 const pointsIn = (shape, box) => {
1645 const grid = gridOf(shape);
1646 const lo = box.min.map((v) => Math.floor(v / cell));
1647 const hi = box.max.map((v) => Math.floor(v / cell));
1648 const out = [];
1649 if ((hi[0] - lo[0] + 1) * (hi[1] - lo[1] + 1) * (hi[2] - lo[2] + 1) > grid.size) {
1650 for (const points of grid.values()) for (const p of points) if (p.every((v, i) => v >= box.min[i] && v <= box.max[i])) out.push(p);
1651 return out;
1652 }
1653 for (let x = lo[0]; x <= hi[0]; x++)
1654 for (let y = lo[1]; y <= hi[1]; y++)
1655 for (let z = lo[2]; z <= hi[2]; z++) {
1656 const points = grid.get(`${x},${y},${z}`);
1657 if (points) for (const p of points) if (p.every((v, i) => v >= box.min[i] && v <= box.max[i])) out.push(p);
1658 }
1659 return out;
1660 };
1661 const offsets = list.map((shape) => offsetAt(shape, state));
1662 const bounds = list.map((shape) => boundsOf(shape));
1663 const found = [];
1664 for (let a = 0; a < list.length; a++)
1665 for (let b = a + 1; b < list.length; b++) {
1666 const rel = sub3(offsets[a], offsets[b]);
1667 if (Math.abs(rel[0]) + Math.abs(rel[1]) + Math.abs(rel[2]) < 1e-6 && !stretched.has(list[a]) && !stretched.has(list[b])) continue;
1668 const box = { min: [0, 1, 2].map((i) => Math.max(bounds[a].min[i] + rel[i], bounds[b].min[i])), max: [0, 1, 2].map((i) => Math.min(bounds[a].max[i] + rel[i], bounds[b].max[i])) };
1669 if (box.min.some((v, i) => v > box.max[i] - sunk)) continue;
1670 if (seatedOn(list[a], list[b]) || seatedOn(list[b], list[a])) continue;
1671 let worst = -sunk;
1672 let at = null;
1673 const local = { min: sub3(box.min, rel), max: sub3(box.max, rel) };
1674 for (const p of pointsIn(list[a], local)) {
1675 const d = sdf(list[b], add3(p, rel));
1676 if (d < worst) (worst = d), (at = add3(p, offsets[a]));
1677 }
1678 for (const p of pointsIn(list[b], box)) {
1679 const d = sdf(list[a], sub3(p, rel));
1680 if (d < worst) (worst = d), (at = add3(p, offsets[b]));
1681 }
1682 if (at) found.push({ tube: list[a].name, other: list[b].name, kind: "through", solid: true, clearance: worst, need: -sunk, at, from: 0, to: 0, length: 0, state: state === undefined ? "" : labelOf(state) });
1683 }
1684 return found.sort((x, y) => x.clearance - y.clearance);
1685}
1687export function wobble(solids, { samples = 20 } = {}) {
1688 const out = [];
1689 for (const shape of solids) {
1690 const profile = shape.curve;
1691 if (!profile) continue;
1692 const segments = curveRuns(profile);
1693 let run = [];
1694 const flush = () => {
1695 if (run.length) {
1696 const values = [];
1697 for (const index of run) {
1698 const [s0] = profile[index];
1699 const [s1] = profile[index + 1];
1700 for (let j = values.length ? 1 : 0; j <= samples; j++) {
1701 const s = s0 + ((s1 - s0) * j) / samples;
1702 values.push([s, segments[index].slope(s)]);
1703 }
1704 }
1705 const scale = Math.max(1, ...values.map(([, g]) => Math.abs(g))) * 1e-6;
1706 const turns = [];
1707 let last = 0;
1708 for (let i = 1; i < values.length; i++) {
1709 const d = values[i][1] - values[i - 1][1];
1710 if (Math.abs(d) <= scale) continue;
1711 const sign = Math.sign(d);
1712 if (last && sign !== last) turns.push(Math.round(values[i - 1][0] * 100) / 100);
1713 last = sign;
1714 }
1715 if (turns.length) out.push({ name: shape.name, at: turns, from: profile[run[0]][0], to: profile[run[run.length - 1] + 1][0] });
1716 }
1717 run = [];
1718 };
1719 segments.forEach((segment, index) => (segment ? run.push(index) : flush()));
1720 flush();
1721 }
1722 return out;
1723}
1725export function seats(solids) {
1726 const out = [];
1727 for (const shape of solids) {
1728 if (!shape.seated || !shape.cut) continue;
1729 for (const host of [shape.cut].flat()) {
1730 const mine = mul3(shape.move ?? [0, 0, 0], 1);
1731 const theirs = mul3(host.move ?? [0, 0, 0], 1);
1732 if ((shape.ride ?? shape.piece) === (host.ride ?? host.piece) && sameOffset(mine, theirs)) continue;
1733 out.push({ name: shape.name, host: host.name, piece: shape.ride ?? shape.piece, hostPiece: host.ride ?? host.piece });
1734 }
1735 }
1736 return out;
1737}
1739export function report({ clearance = [], depth = { problems: [], missing: [] }, terminals: ends, folds: bends, supports: held, loose, apart, flight, crowding: crowded, self, wobble: wobbly, seats: seated, cover: covered } = {}, { limit = 4000 } = {}) {
1740 const lines = [];
1741 const f = (v) => (Math.round(v * 100) / 100).toFixed(2);
1742 const p3 = (p) => `[${p.map((v) => v.toFixed(1)).join(", ")}]`;
1743 const clash = (c) => (c.solid ? ` solid ${c.state ? c.state + " " : ""}${c.tube} × ${c.other} sunk ${f(-c.clearance)} deep at ${p3(c.at)}` : ` ${c.kind.padEnd(7)} ${c.state ? c.state + " " : ""}${c.tube} × ${c.other} clearance ${f(c.clearance)} < ${f(c.need)} along ${f(c.from)}–${f(c.to)} of ${f(c.length)} at ${p3(c.at)}`);
1744 lines.push(`clearance: ${clearance.length} problem${clearance.length === 1 ? "" : "s"}`);
1745 for (const c of clearance.slice(0, limit)) lines.push(clash(c));
1746 if (apart) {
1747 lines.push(`clearance apart: ${apart.length} problem${apart.length === 1 ? "" : "s"}`);
1748 for (const c of apart.slice(0, limit)) lines.push(clash(c));
1749 }
1750 if (ends) {
1751 lines.push(`terminals: ${ends.length} end${ends.length === 1 ? "" : "s"} not on a mount`);
1752 for (const e of ends.slice(0, limit)) lines.push(` ${e.tube} ${e.end} ${Number.isFinite(e.d) ? f(e.d) : "∞"} from ${e.nearest} (rim ${Number.isFinite(e.high) ? `${f(e.low)}…${f(e.high)}` : "-"}) at ${p3(e.at)}`);
1753 }
1754 if (loose) {
1755 const hanging = loose.filter((e) => !e.free);
1756 lines.push(`loose ends: ${hanging.length}`);
1757 for (const e of hanging.slice(0, limit)) lines.push(` ${e.state} ${e.tube} ${e.end} touches nothing that moves with it at ${p3(e.at)}`);
1758 }
1759 if (bends) {
1760 lines.push(`screen folds: ${bends.length}`);
1761 for (const b of bends.slice(0, limit))
1762 lines.push(b.kind === "pinch" ? ` ${b.name} turns ${b.flatTurn.toFixed(0)}° on screen round a ${f(b.radius)} px bend, under ${f(b.width * 2)} px for a ${f(b.width)} px tube at ${p3(b.at)}` : ` ${b.name} turns ${b.flatTurn.toFixed(0)}° on screen for ${b.realTurn.toFixed(0)}° in 3D at ${p3(b.at)}`);
1763 }
1764 if (held) {
1765 const hanging = held.hanging ?? [];
1766 lines.push(`support: ${held.loose.length} unsupported solid${held.loose.length === 1 ? "" : "s"} · ${hanging.length} hanging tube${hanging.length === 1 ? "" : "s"} · ${held.sunk.length} sunk pair${held.sunk.length === 1 ? "" : "s"}`);
1767 for (const name of held.loose.slice(0, limit)) lines.push(` loose ${name}`);
1768 for (const name of hanging.slice(0, limit)) lines.push(` hanging ${name}`);
1769 for (const d of held.sunk.slice(0, limit)) lines.push(` sunk ${d.a} × ${d.b} ${f(d.depth)} deep`);
1770 }
1771 lines.push(`depth order: ${depth.problems.length} problem${depth.problems.length === 1 ? "" : "s"}`);
1772 for (const d of depth.problems.slice(0, limit))
1773 lines.push(` ${d.cycle ? "cycle " : "order "} ${d.label} ${d.front} (k ${f(d.frontKey)}) is in front of ${d.back} (k ${f(d.backKey)}) but drawn first ${f(d.area)} px²${d.against ? ` (${f(d.against)} px² the other way)` : ""}, up to ${f(d.depth)} nearer, at ${d.at.map((v) => v.toFixed(1)).join(",")}`);
1774 if (flight) {
1775 lines.push(`through in mid-flight: ${flight.length} pair${flight.length === 1 ? "" : "s"}`);
1776 for (const c of flight.slice(0, limit)) lines.push(clash(c));
1777 }
1778 if (self) {
1779 lines.push(`self-overlap: ${self.length} chunk${self.length === 1 ? "" : "s"} whose outline crosses itself on screen`);
1780 for (const c of self.slice(0, limit)) lines.push(` ${c.name} its outline folds back on screen: the line turns on a ${f(c.radius)} px radius, under its ${f(c.width)} px half-width at ${p3(c.at)}, screen ${c.screen.map((v) => v.toFixed(1)).join(",")}`);
1781 }
1782 if (wobbly) {
1783 lines.push(`wobble: ${wobbly.length} smoothed profile${wobbly.length === 1 ? "" : "s"} whose slope turns between its ends`);
1784 for (const w of wobbly.slice(0, limit)) lines.push(` ${w.name} dr/ds turns at s = ${w.at.join(", ")} on the run ${f(w.from)}–${f(w.to)}; give it an analytic profile and smooth.slope`);
1785 }
1786 if (seated) {
1787 lines.push(`saddles off their host apart: ${seated.length}`);
1788 for (const d of seated.slice(0, limit)) lines.push(` ${d.name} (${d.piece}) is cut to ${d.host} (${d.hostPiece}); apart, its cut base is on show`);
1789 }
1790 if (covered) {
1791 lines.push(`stand-in shapes: ${covered.length} item${covered.length === 1 ? "" : "s"} whose recorded shape covers too little of what it draws`);
1792 for (const c of covered.slice(0, limit)) lines.push(` ${c.name} its shapes cover ${Math.round(c.share * 100)}% of its drawing (drawn ${c.drawn.map((v) => v.toFixed(0)).join(",")}, recorded ${c.shapes.map((v) => v.toFixed(0)).join(",")}); record the true shape of what you draw`);
1793 }
1794 if (crowded) {
1795 lines.push(`crowding: ${crowded.length} pair${crowded.length === 1 ? "" : "s"} where a bend or end touches another line on screen`);
1796 for (const c of crowded.slice(0, limit)) lines.push(` ${c.state ? c.state + " " : ""}${c.a} ${c.where} × ${c.b} outlines ${c.gap < 0 ? `overlap ${f(-c.gap)}` : `${f(c.gap)} apart`} px, ${f(c.depth)} apart in depth at screen ${c.screen.map((v) => v.toFixed(1)).join(",")}`);
1797 }
1798 if (depth.passing && depth.passing.length) {
1799 const pairs = (list) => new Set(list.map((d) => [d.front, d.back].sort().join("|"))).size;
1800 lines.push(`in passing (uniform explode states only, not failing): ${pairs(depth.passing.filter((d) => d.cycle))} interlocked pairs, ${pairs(depth.passing.filter((d) => !d.cycle))} pairs no linear key can order`);
1801 for (const d of depth.passing.slice(0, limit)) lines.push(` ${d.cycle ? "cycle " : "unorderable"} ${d.label} ${d.front} / ${d.back} ${f(d.area)} px²`);
1802 }
1803 if (depth.missing.length) lines.push(`items with no shape: ${depth.missing.length} ${depth.missing.slice(0, 20).join(", ")}`);
1804 if (loose && loose.some((e) => e.free)) {
1805 const free = loose.filter((e) => e.free);
1806 lines.push(`free ends apart (marked free, not failing): ${free.length}`);
1807 for (const e of free.slice(0, limit)) lines.push(` ${e.tube} ${e.end} at ${p3(e.at)}`);
1808 }
1809 return lines.join("\n");
1810}
1812export const failures = ({ clearance = [], depth = { problems: [], missing: [] }, terminals: ends = [], folds: bends = [], supports: held = { loose: [], sunk: [] }, loose = [], apart = [], flight = [], crowding: crowded = [], self = [], wobble: wobbly = [], seats: seated = [], cover: covered = [] }) =>
1813 covered.length + clearance.length + apart.length + flight.length + crowded.length + ends.length + loose.filter((e) => !e.free).length + bends.length + held.loose.length + (held.hanging ?? []).length + held.sunk.length + depth.problems.length + depth.missing.length + self.length + wobbly.length + seated.length;
1815export const sameRun = (a, b) => Boolean(a.route) && a.route === b.route && Math.abs(a.chunk - b.chunk) <= 1;
1817export function recorder(P, { order = [], spring = {}, ground = grounded } = {}) {
1818 const V = cameraBasis(P).V;
1819 const counts = new Map();
1820 const R = { P, V, order, spring, ground, items: [], solids: [], tubes: [], routes: [], settled: null };
1821 R.nameFor = (base) => {
1822 const count = (counts.get(base) ?? 0) + 1;
1823 counts.set(base, count);
1824 return count === 1 ? base : `${base}#${count}`;
1825 };
1826 R.solid = (shape) => {
1827 R.solids.push(shape);
1828 return shape;
1829 };
1830 R.put = ({ at = null, bias = 0, key, name, piece = "", move = [0, 0, 0], ride, shapes = [], ...rest } = {}) => {
1831 const item = { ...rest, piece, ride: ride ?? piece, key: key ?? (at ? dot3(at, V) : 0) + bias, move, shapes, name: name ?? R.nameFor(`${piece || "item"}.item`) };
1832 for (const shape of shapes) (shape.move = item.move), (shape.ride = item.ride), (shape.piece = piece);
1833 R.items.push(item);
1834 return item;
1835 };
1836 R.route = (name, points, radius, { owner = name, touch, bundle, limp = false, gaps = [], closed = false, ends = [true, true], rims, free = [false, false], piece = "", move = [0, 0, 0], ride = piece } = {}) => {
1837 const total = pathLength(points);
1838 const radiusOf = typeof radius === "function" ? radius : () => radius;
1839 const runs = runsOf(total, gaps);
1840 const recorded = runs.map(([from, to], index) => ({
1841 ...tube(cutAlong(points, from, to), (t) => radiusOf((from + (to - from) * t) / total), { name, owner, touch, bundle, limp, joined: true }),
1842 tips: closed ? [null, null] : [index === 0 && ends[0] ? points[0] : null, index === runs.length - 1 && ends[1] ? points[points.length - 1] : null],
1843 tipAxes: [tangentAlong(points, 0), tangentAlong(points, total)],
1844 tipRims: rims ?? [radiusOf(0), radiusOf(1)],
1845 free: [index === 0 && free[0], index === runs.length - 1 && free[1]],
1846 offset: from,
1847 total,
1848 move,
1849 ride,
1850 piece,
1851 }));
1852 R.tubes.push(...recorded);
1853 R.routes.push({ name, points, r: radiusOf(0.5), bundle, move, ride, piece });
1854 return recorded;
1855 };
1856 return R;
1857}
1859export function explodeStates(order = [], spring = {}, { uniform = 10, stride = 4 } = {}) {
1860 if (!order.length) return { depth: [0], frames: [], apart: false };
1861 const reversals = reversalsOf(order, spring.stagger ?? 0.035);
1862 const prefix = prefixStates(order);
1863 return {
1864 depth: [...uniformStates(uniform), ...springStates(order, { ...spring, stride, reversals }), ...prefix],
1865 frames: [...springStates(order, { ...spring, reversals }), ...prefix],
1866 apart: true,
1867 };
1868}
1870export function settle(R, P = R.P, { states = explodeStates(R.order, R.spring).depth, skip = sameRun, ...options } = {}) {
1871 const items = R.items.slice();
1872 const overlaps = overlapsOf(items, P, { states, skip, ...options });
1873 const solved = orderKeys(items, overlaps);
1874 items.forEach((item, index) => (item.key = solved.keys[index]));
1875 R.settled = { items, overlaps, solved, states };
1876 return R.settled;
1877}
1879const worstOf = (lists) => [...lists.flat().reduce((all, c) => (all.has(`${c.tube}|${c.other}`) && all.get(`${c.tube}|${c.other}`).clearance <= c.clearance ? all : all.set(`${c.tube}|${c.other}`, c)), new Map()).values()].sort((x, y) => x.clearance - y.clearance);
1881export function audit(R, P = R.P, { allow = [], crowd = [], cover = [], ground = R.ground, frames = explodeStates(R.order, R.spring).frames, apart = R.order.length > 0 } = {}) {
1882 const { items, overlaps, solved } = R.settled ?? settle(R, P);
1883 const mounts = mountsOf(R.tubes, R.solids);
1884 const links = linksOf(R.tubes, mounts);
1885 return {
1886 clearance: clearances(R.tubes, R.solids, { mounts }),
1887 apart: apart ? [...clearances(R.tubes, R.solids, { state: 1, mounts, only: "through" }), ...solidClearances(R.solids, { state: 1 })] : [],
1888 flight: worstOf(frames.map((state) => [...clearances(R.tubes, R.solids, { state, mounts, only: "through" }), ...solidClearances(R.solids, { state })])),
1889 crowding: (apart ? [0, 1] : [0]).flatMap((state) => crowding(R.routes, P, { links, items, allow: crowd, state, rods: slenderOf(R.solids) })),
1890 cover: coverage(items, P, { allow: cover }),
1891 terminals: terminals(R.tubes, R.solids, { mounts }),
1892 loose: looseEnds(R.tubes, R.solids, { state: 1 }),
1893 folds: folds(R.routes, P, { items }),
1894 self: selfOverlaps(items, P),
1895 wobble: wobble(R.solids),
1896 seats: seats(R.solids),
1897 supports: supports(R.solids, R.tubes, { ground }),
1898 depth: depthOrder(items, P, { overlaps, solved, allow }),
1899 };
1900}
1902export function auditOrExit(R, P = R.P, { flag = "--audit", argv = process.argv, ...options } = {}) {
1903 if (!argv.includes(flag)) return null;
1904 const started = Date.now();
1905 const result = audit(R, P, options);
1906 const { overlaps, solved, states } = R.settled;
1907 console.log(report(result));
1908 console.log(`audit · ${R.tubes.length} tubes · ${R.solids.length} solids · ${R.items.length} items · ${states.length} depth states · ${overlaps.pairs.length} overlaps · ${solved.rules} order rules · ${((Date.now() - started) / 1000).toFixed(1)} s`);
1909 const count = failures(result);
1910 console.log(count ? `audit failed: ${count} problem${count === 1 ? "" : "s"}` : "audit passed");
1911 if (count) process.exitCode = 1;
1912 return result;
1913}