Skill Tracer
Skills › Anatomy › anatomy › turn-audit.mjs

turn-audit.mjs

1101 lines · kit/turn-audit.mjs at 1482e84

1import { TURN } from "./turn.mjs";
2import * as A from "./audit.mjs";
3import * as G from "./lathe.mjs";
4import * as k from "./iso-kit.mjs";
5import { separate, sampleShape } from "./turn-build.mjs";
6
7const TAU = Math.PI * 2;
8const EYE = [1, 0, 0, 0, 1, 0, 0, 0, 1];
9const f2 = (v) => (Math.round(v * 100) / 100).toFixed(2);
10const f3 = (v) => (Math.round(v * 1000) / 1000).toFixed(3);
12const apply = (R, v) => [R[0] * v[0] + R[1] * v[1] + R[2] * v[2], R[3] * v[0] + R[4] * v[1] + R[5] * v[2], R[6] * v[0] + R[7] * v[1] + R[8] * v[2]];
13const back = (R, v) => [R[0] * v[0] + R[3] * v[1] + R[6] * v[2], R[1] * v[0] + R[4] * v[1] + R[7] * v[2], R[2] * v[0] + R[5] * v[1] + R[8] * v[2]];
15const place = (pose, p) => {
16 const q = apply(pose.R, [p[0] - pose.origin[0], p[1] - pose.origin[1], p[2] - pose.origin[2]]);
17 return [q[0] + pose.t[0], q[1] + pose.t[1], q[2] + pose.t[2]];
18};
20const unplace = (pose, p) => {
21 const q = back(pose.R, [p[0] - pose.t[0], p[1] - pose.t[1], p[2] - pose.t[2]]);
22 return [q[0] + pose.origin[0], q[1] + pose.origin[1], q[2] + pose.origin[2]];
23};
25export function posed(shape, pose) {
26 if (!pose) return shape;
27 const out = { ...shape, _b: undefined, _f: undefined };
28 if (shape.F) out.F = { o: place(pose, shape.F.o), a: apply(pose.R, shape.F.a), u: shape.F.u ? apply(pose.R, shape.F.u) : undefined, v: shape.F.v ? apply(pose.R, shape.F.v) : undefined };
29 if (shape.kind === "ball" || shape.kind === "box") out.o = place(pose, shape.o);
30 if (shape.flats) out.flats = shape.flats.map(([nx, ny, nz, d]) => [...apply(pose.R, [nx, ny, nz]), d]);
31 if (shape.axes) out.axes = shape.axes.map((axis) => apply(pose.R, axis));
32 if (shape.points) out.points = shape.points.map((p) => place(pose, p));
33 if (shape.tips) out.tips = shape.tips.map((tip) => tip && place(pose, tip));
34 if (shape.tipAxes) out.tipAxes = shape.tipAxes.map((axis) => axis && apply(pose.R, axis));
35 if (shape.cut) out.cut = Array.isArray(shape.cut) ? shape.cut.map((one) => posed(one, pose)) : posed(shape.cut, pose);
36 return out;
39const IDENTITY = { R: EYE, t: [0, 0, 0], origin: [0, 0, 0] };
40const poseFor = (cams, group) => (group && group !== "static" ? { R: cams[group.index].R, t: cams[group.index].t, origin: group.origin } : IDENTITY);
42export function eventsOf(T, layer) {
43 const data = T.data;
44 const name = typeof layer === "string" ? layer : layer.name;
45 const prepared = TURN.prepare(data, { unit: 1 }).layers.find((l) => l.name === name);
46 const plan = prepared ? prepared.planar : [];
47 const a = ((T.P.azimuth ?? 45) * Math.PI) / 180;
48 const e = ((T.P.elevation ?? 30) * Math.PI) / 180;
49 const out = new Set();
50 for (let i = 0; i < plan.length; i += 5) {
51 const part = T.parts[plan[i]];
52 const g = part.group;
53 if (!g || g.kind !== "turn" || g.parent) continue;
54 const n = [plan[i + 2], plan[i + 3], plan[i + 4]];
55 const flat = Math.hypot(n[0], n[1]);
56 if (flat < 1e-9) continue;
57 const c = (-n[2] * Math.sin(e)) / (flat * Math.cos(e));
58 if (c <= -1 || c >= 1) continue;
59 const phi = Math.atan2(n[1], n[0]);
60 for (const b of [phi + Math.acos(c), phi - Math.acos(c)]) {
61 const theta = ((((a - b) * 180) / Math.PI) % 360 + 360) % 360;
62 out.add(Math.round(theta * 1e6) / 1e6);
63 }
64 }
65 return [...out].sort((x, y) => x - y);
68function polygonArea(poly) {
69 let s = 0;
70 for (let i = 0; i < poly.length; i++) {
71 const a = poly[i];
72 const b = poly[(i + 1) % poly.length];
73 s += a[0] * b[1] - b[0] * a[1];
74 }
75 return s / 2;
78function clipArea(subject, clip) {
79 if (subject.length < 3 || clip.length < 3) return 0;
80 const sign = polygonArea(clip) >= 0 ? 1 : -1;
81 let out = subject;
82 for (let i = 0; i < clip.length && out.length; i++) {
83 const a = clip[i];
84 const b = clip[(i + 1) % clip.length];
85 const side = (p) => sign * ((b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0]));
86 const input = out;
87 out = [];
88 for (let j = 0; j < input.length; j++) {
89 const p = input[j];
90 const q = input[(j + 1) % input.length];
91 const sp = side(p);
92 const sq = side(q);
93 if (sp >= 0) out.push(p);
94 if ((sp >= 0) !== (sq >= 0)) {
95 const t = sp / (sp - sq);
96 out.push([p[0] + (q[0] - p[0]) * t, p[1] + (q[1] - p[1]) * t]);
97 }
98 }
99 }
100 return out.length >= 3 ? Math.abs(polygonArea(out)) : 0;
103const pathPolys = (d) =>
104 [...(d ?? "").matchAll(/M([^M]*)/g)].map(([, body]) => {
105 const nums = (body.match(/-?\d+(?:\.\d+)?(?:e-?\d+)?/g) ?? []).map(Number);
106 const pts = [];
107 for (let i = 0; i + 1 < nums.length; i += 2) pts.push([nums[i], nums[i + 1]]);
108 return pts;
109 });
111function convexHull(points) {
112 const sorted = points.slice().sort((a, b) => a[0] - b[0] || a[1] - b[1]);
113 if (sorted.length < 3) return sorted;
114 const cross = (o, a, b) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
115 const lower = [];
116 for (const p of sorted) {
117 while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0) lower.pop();
118 lower.push(p);
119 }
120 const upper = [];
121 for (let i = sorted.length - 1; i >= 0; i--) {
122 const p = sorted[i];
123 while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0) upper.pop();
124 upper.push(p);
125 }
126 return lower.slice(0, -1).concat(upper.slice(0, -1));
129function footprints(run, f) {
130 if (run.kind === "tube") return f.quadList.map(([buf, n]) => convexHull(Array.from({ length: n }, (_, i) => [buf[2 * i], buf[2 * i + 1]])));
131 if (run.kind === "fixed") return [Array.from({ length: f.hullN }, (_, i) => [f.hull[2 * i], f.hull[2 * i + 1]])];
132 const polys = pathPolys(f.d[run.role.fill]);
133 return polys.length ? [convexHull(polys.flat())] : [];
136function overlapArea(runA, fa, runB, fb) {
137 if (fa.box[2] < fb.box[0] || fb.box[2] < fa.box[0] || fa.box[3] < fb.box[1] || fb.box[3] < fa.box[1]) return 0;
138 let best = 0;
139 for (const a of footprints(runA, fa)) for (const b of footprints(runB, fb)) best = Math.max(best, clipArea(a, b));
140 return best;
143function hausdorffish(A, B) {
144 if (!A.length || !B.length) return 0;
145 const seg = (p, a, b) => {
146 const dx = b[0] - a[0];
147 const dy = b[1] - a[1];
148 const l = dx * dx + dy * dy;
149 const t = l ? Math.max(0, Math.min(1, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / l)) : 0;
150 return Math.hypot(p[0] - a[0] - dx * t, p[1] - a[1] - dy * t);
151 };
152 const one = (P, Q) => {
153 let worst = 0;
154 const m = Q.length;
155 const n = P.length;
156 for (let i = 0; i < n; i++) {
157 const centre = Math.round((i / n) * m);
158 let best = Infinity;
159 for (let w = -10; w <= 10; w++) {
160 const j = (((centre + w) % m) + m) % m;
161 best = Math.min(best, seg(P[i], Q[j], Q[(j + 1) % m]));
162 }
163 if (best > 0.02) for (let j = 0; j < m; j++) best = Math.min(best, seg(P[i], Q[j], Q[(j + 1) % m]));
164 worst = Math.max(worst, best);
165 }
166 return worst;
167 };
168 return Math.max(one(A, B), one(B, A));
171const areaOfPath = (d) => pathPolys(d).reduce((sum, poly) => sum + Math.abs(polygonArea(poly)), 0);
173function flatPolys(d) {
174 const out = [];
175 for (const [, body] of (d ?? "").matchAll(/M([^M]*)/g)) {
176 const nums = (body.match(/-?\d+(?:\.\d+)?(?:e-?\d+)?/g) ?? []).map(Number);
177 if (nums.length >= 6) out.push(nums);
178 }
179 return out;
182const jitter = (a, b) => {
183 const t = Math.sin(a * 12.9898 + b * 78.233) * 43758.5453;
184 return t - Math.floor(t);
185};
187function toneRaster(layers, box, scale) {
188 const x0 = box[0];
189 const y0 = box[1];
190 const w = Math.max(1, Math.ceil((box[2] - x0) * scale));
191 const h = Math.max(1, Math.ceil((box[3] - y0) * scale));
192 const out = new Float32Array(w * h).fill(-1);
193 const owner = new Int16Array(w * h).fill(-1);
194 for (const { polys, v, id } of layers) {
195 if (!polys.length) continue;
196 let ymin = Infinity;
197 let ymax = -Infinity;
198 for (const p of polys) for (let i = 1; i < p.length; i += 2) {
199 if (p[i] < ymin) ymin = p[i];
200 if (p[i] > ymax) ymax = p[i];
201 }
202 const r0 = Math.max(0, Math.floor((ymin - y0) * scale));
203 const r1 = Math.min(h - 1, Math.ceil((ymax - y0) * scale));
204 const hits = [];
205 for (let row = r0; row <= r1; row++)
206 for (let sub = 0; sub < 8; sub++) {
207 const y = y0 + (row + (sub + 0.5) / 8) / scale;
208 hits.length = 0;
209 for (const p of polys) {
210 const n = p.length;
211 for (let i = 0; i < n; i += 2) {
212 const ax = p[i];
213 const ay = p[i + 1];
214 const bx = p[(i + 2) % n];
215 const by = p[(i + 3) % n];
216 if ((ay <= y) === (by <= y)) continue;
217 hits.push([ax + ((y - ay) * (bx - ax)) / (by - ay), by > ay ? 1 : -1]);
218 }
219 }
220 if (hits.length < 2) continue;
221 hits.sort((a, b) => a[0] - b[0]);
222 let winding = 0;
223 for (let k = 0; k < hits.length - 1; k++) {
224 winding += hits[k][1];
225 if (!winding) continue;
226 const xa = hits[k][0];
227 const xb = hits[k + 1][0];
228 const c0 = Math.max(0, Math.floor((xa - x0) * scale));
229 const c1 = Math.min(w - 1, Math.floor((xb - x0) * scale));
230 for (let c = c0; c <= c1; c++) {
231 if (Math.floor(jitter(c, row) * 8) !== sub) continue;
232 if (c === c0 || c === c1) {
233 const x = x0 + (c + jitter(row, c)) / scale;
234 if (x < xa || x >= xb) continue;
235 }
236 out[row * w + c] = v;
237 owner[row * w + c] = id;
238 }
239 }
240 }
241 }
242 return { out, owner };
245function toneLayers(run, f) {
246 const layers = [];
247 run.roles.forEach((role, r) => {
248 const d = f.d[r];
249 if (!d) return;
250 if (role.type === "face") layers.push({ polys: flatPolys(d), v: f.q[r] / TURN.q, id: r });
251 else if (role.type === "path" && role.name === "fill") layers.push({ polys: flatPolys(d), v: 2, id: r });
252 else if (role.type === "path" && /^s[0-4]$/.test(role.name)) layers.push({ polys: flatPolys(d), v: Number(role.name.slice(1)), id: r });
253 else if (role.name === "body") layers.push({ polys: flatPolys(d), v: 2, id: r });
254 else if (role.name === "shine") layers.push({ polys: flatPolys(d), v: 4, id: r });
255 else if (role.name === "shade") layers.push({ polys: flatPolys(d), v: 0, id: r });
256 });
257 return layers;
260function visibleChange(run, frames, scale = 4) {
261 const box = [Infinity, Infinity, -Infinity, -Infinity];
262 for (const f of frames) {
263 box[0] = Math.min(box[0], f.box[0] - 1);
264 box[1] = Math.min(box[1], f.box[1] - 1);
265 box[2] = Math.max(box[2], f.box[2] + 1);
266 box[3] = Math.max(box[3], f.box[3] + 1);
267 }
268 const rasters = frames.map((f) => toneRaster(toneLayers(run, f), box, scale));
269 const steps = [];
270 for (let i = 1; i < rasters.length; i++) {
271 const a = rasters[i - 1];
272 const b = rasters[i];
273 let sum = 0;
274 for (let j = 0; j < a.out.length; j++) {
275 if (a.owner[j] === b.owner[j]) continue;
276 const va = a.out[j];
277 const vb = b.out[j];
278 if (va < 0 || vb < 0) sum += 2;
279 else {
280 const d = Math.abs(va - vb);
281 if (d > 1 / 16 + 1e-6) sum += d;
282 }
283 }
284 steps.push(sum / (scale * scale));
285 }
286 return steps;
289function surfacePoints(shape, spacing = 0.6) {
290 return sampleShape(shape, Math.max(16, Math.round(TAU / Math.max(0.05, spacing / Math.max(1, shape.r ?? 3)))));
293function sphereOf(points) {
294 const c = [0, 0, 0];
295 for (const p of points) for (let i = 0; i < 3; i++) c[i] += p[i] / Math.max(1, points.length);
296 let r = 0;
297 for (const p of points) r = Math.max(r, Math.hypot(p[0] - c[0], p[1] - c[1], p[2] - c[2]));
298 return { c, r };
301function familiesOf(T, { sweep, clips }) {
302 const groups = T.groups;
303 const out = [];
304 const roots = groups.filter((g) => g.kind === "turn" && !g.parent);
305 if (sweep) out.push({ name: "sweep", frame: 0.3, periodic: true, unit: "°", ...sweep });
306 else if (roots.length) out.push({ name: "sweep", at: (x) => Object.fromEntries(roots.map((g) => [g.name, x])), from: 0, to: 360, frame: 0.3, periodic: true, unit: "°" });
307 for (const clip of clips ?? []) out.push({ from: 0, frame: 1 / 60, unit: "s", ...clip });
308 return out;
311export function samplesOf(family, frame) {
312 const xs = [];
313 if (family.periodic) {
314 const count = Math.max(1, Math.round((family.to - family.from) / frame));
315 for (let i = 0; i < count; i++) xs.push(family.from + i * frame);
316 return xs;
317 }
318 const count = Math.max(0, Math.ceil((family.to - family.from) / frame - 1e-9));
319 for (let i = 0; i <= count; i++) xs.push(Math.min(family.to, family.from + i * frame));
320 return xs;
323const ORBIT_LINES = { separation: null, unseparated: "order", cycles: "order", forced: "order", swaps: "order", layers: "depth", rigid: "rigid", swept: "swept", posed: "posed", folds: "routes", self: "routes", crowding: "routes", endon: "routes", coverage: "coverage", depth: "depth", continuity: "continuity" };
324const ORBIT_CHECKS = ["order", "rigid", "swept", "posed", "routes", "coverage", "continuity", "depth"];
326export function orbit(T, R, P, options = {}) {
327 const started = Date.now();
328 const { sweep = null, clips = [], poses = null, random = 2000, step = 0.05, crowd = [], cover = [], fittings = [], depthAngles = 36, depthEvents = 36, quick = false, log = null, routeStates = 240, posedStep = null, only = null } = options;
329 const say = (text) => log && log(text);
330 const unknown = (only ?? []).filter((name) => !ORBIT_CHECKS.includes(name));
331 if (unknown.length) throw new Error(`orbit: unknown check ${unknown.join(", ")} in only (the checks are ${ORBIT_CHECKS.join(", ")})`);
332 const wants = (name) => !only || only.includes(name);
333 const data = T.data;
334 const scene = TURN.prepare(data, { unit: 1 });
335 const runs = scene.runs;
336 const groups = T.groups;
337 const E = ((P.elevation ?? 30) * Math.PI) / 180;
338 const result = { separation: [], unseparated: [], cycles: [], forced: [], swaps: [], layers: [], rigid: [], swept: [], posed: [], folds: [], self: [], crowding: [], endon: [], coverage: [], depth: [], continuity: [], notes: [], stats: {}, quick, skipped: Object.keys(ORBIT_LINES).filter((line) => ORBIT_LINES[line] && !wants(ORBIT_LINES[line])) };
339 const families = familiesOf(T, { sweep, clips });
340 const extra = typeof poses === "function" ? poses() : Array.isArray(poses) ? poses : [];
341 const frameOf = (family) => (quick ? family.frame * (family.periodic ? Math.max(1, 1 / family.frame) : 3) : family.frame);
342 const label = (family, x) => `${family.name} ${x.toFixed(3)}${family.unit ?? ""}`;
343 const emitAll = (values, frames = runs.map((run) => TURN.frame(run))) => {
344 const cams = TURN.cams(scene, values);
345 for (const run of runs) TURN.emit(run, run.group >= 0 ? cams[run.group] : cams.world, frames[run.index]);
346 return { cams, frames };
347 };
348 say("separation");
349 const allMargins = T.built?.report?.margins ?? [];
350 for (const m of allMargins) if (m.margin < -0.08) result.separation.push({ a: m.a, b: m.b, margin: m.margin });
351 result.stats.planes = allMargins.length;
352 result.stats.tightest = allMargins.length ? Math.min(...allMargins.map((m) => m.margin)) : 0;
353 const relationOf = (layer, i, j, cams) => {
354 const plan = layer.planar;
355 for (let p = 0; p < plan.length; p += 5)
356 if ((plan[p] === i && plan[p + 1] === j) || (plan[p] === j && plan[p + 1] === i)) {
357 const g = runs[plan[p]].group;
358 const V = g >= 0 ? cams[g].V : cams.world.V;
359 const s = plan[p + 2] * V[0] + plan[p + 3] * V[1] + plan[p + 4] * V[2];
360 return plan[p] === i ? s : -s;
361 }
362 const a = runs[i];
363 const b = runs[j];
364 const ca = a.group >= 0 ? cams[a.group] : cams.world;
365 const cb = b.group >= 0 ? cams[b.group] : cams.world;
366 const hit = TURN.gjk(a.cloud, ca, b.cloud, cb, null, 32, TURN.hintsOf(a, ca, b, cb, cams.world));
367 const V = cams.world.V;
368 return hit.n[0] * V[0] + hit.n[1] * V[1] + hit.n[2] * V[2];
369 };
370 const pairsOf = (layer) => {
371 if (layer._pairs) return layer._pairs;
372 const list = [];
373 const seen = new Set();
374 for (let p = 0; p < layer.planar.length; p += 5) list.push([layer.planar[p], layer.planar[p + 1]]);
375 for (let p = 0; p < layer.dynamic.length; p += 2) list.push([layer.dynamic[p], layer.dynamic[p + 1]]);
376 for (let p = 0; p < (layer.framed ? layer.framed.length : 0); p += 6) list.push([layer.framed[p], layer.framed[p + 1]]);
377 for (const gp of layer.groupPairs ?? []) for (const i of gp.a) for (const j of gp.b) list.push([i, j]);
378 for (const [i, j] of list) seen.add(i * 65536 + j);
379 for (const i of layer.free) for (const j of layer.parts) {
380 if (i === j || runs[i].group === runs[j].group) continue;
381 const key = Math.min(i, j) * 65536 + Math.max(i, j);
382 if (seen.has(key)) continue;
383 seen.add(key);
384 list.push([Math.min(i, j), Math.max(i, j)]);
385 }
386 layer._pairs = list;
387 return list;
388 };
389 const checkState = (values, tag, previous, record) => {
390 const { cams, frames } = emitAll(values);
391 const orders = [];
392 scene.layers.forEach((layer, index) => {
393 const stats = {};
394 const order = TURN.order(layer, frames, cams, previous ? previous[index] : null, stats, scene);
395 orders.push(order);
396 if (stats.forced) result.forced.push({ layer: layer.name, state: tag, forced: stats.forced });
397 const framed = layer.framed ?? [];
398 for (let p = 0; p < framed.length; p += 6) {
399 const i = framed[p];
400 const j = framed[p + 1];
401 if (TURN.disjoint(frames[i], frames[j], 0.1)) continue;
402 const g = framed[p + 2];
403 const R = g >= 0 ? cams[g].R : EYE;
404 const n = [R[0] * framed[p + 3] + R[1] * framed[p + 4] + R[2] * framed[p + 5], R[3] * framed[p + 3] + R[4] * framed[p + 4] + R[5] * framed[p + 5], R[6] * framed[p + 3] + R[7] * framed[p + 4] + R[8] * framed[p + 5]];
405 const extent = (run) => {
406 const c = run.group >= 0 ? cams[run.group] : cams.world;
407 let lo = Infinity;
408 let hi = -Infinity;
409 for (let q = 0; q < run.cloud.length; q += 3) {
410 const x = run.cloud[q];
411 const y = run.cloud[q + 1];
412 const z = run.cloud[q + 2];
413 const v = n[0] * (c.R[0] * x + c.R[1] * y + c.R[2] * z + c.t[0]) + n[1] * (c.R[3] * x + c.R[4] * y + c.R[5] * z + c.t[1]) + n[2] * (c.R[6] * x + c.R[7] * y + c.R[8] * z + c.t[2]);
414 if (v < lo) lo = v;
415 if (v > hi) hi = v;
416 }
417 return [lo, hi];
418 };
419 const margin = extent(runs[j])[0] - extent(runs[i])[1];
420 if (margin < -0.08) result.unseparated.push({ layer: layer.name, state: tag, a: runs[i].name, b: runs[j].name, margin, framed: true });
421 }
422 for (const gp of layer.groupPairs ?? []) {
423 const R = gp.frame >= 0 ? cams[gp.frame].R : EYE;
424 const nw = [R[0] * gp.n[0] + R[1] * gp.n[1] + R[2] * gp.n[2], R[3] * gp.n[0] + R[4] * gp.n[1] + R[5] * gp.n[2], R[6] * gp.n[0] + R[7] * gp.n[1] + R[8] * gp.n[2]];
425 const along = (list, high) => {
426 let best = high ? -Infinity : Infinity;
427 for (const index of list) {
428 const run = runs[index];
429 const c = run.group >= 0 ? cams[run.group] : cams.world;
430 for (let q = 0; q < run.cloud.length; q += 3) {
431 const x = run.cloud[q];
432 const y = run.cloud[q + 1];
433 const z = run.cloud[q + 2];
434 const v = nw[0] * (c.R[0] * x + c.R[1] * y + c.R[2] * z + c.t[0]) + nw[1] * (c.R[3] * x + c.R[4] * y + c.R[5] * z + c.t[1]) + nw[2] * (c.R[6] * x + c.R[7] * y + c.R[8] * z + c.t[2]);
435 best = high ? Math.max(best, v) : Math.min(best, v);
436 }
437 }
438 return best;
439 };
440 const margin = along(gp.b, false) - along(gp.a, true);
441 if (margin < -0.08) result.unseparated.push({ layer: layer.name, state: tag, a: runs[gp.a[0]].name, b: runs[gp.b[0]].name, margin, framed: true });
442 }
443 const n = layer.parts.length;
444 const local = layer.local;
445 const adj = Array.from({ length: n }, () => []);
446 const indeg = new Int32Array(n);
447 for (const [i, j] of pairsOf(layer)) {
448 if (TURN.disjoint(frames[i], frames[j], 0.1)) continue;
449 const ri = runs[i];
450 const rj = runs[j];
451 if (stats.unseparated && ri.group !== rj.group) {
452 const ca = ri.group >= 0 ? cams[ri.group] : cams.world;
453 const cb = rj.group >= 0 ? cams[rj.group] : cams.world;
454 const hit = TURN.gjk(ri.cloud, ca, rj.cloud, cb, null, 32, TURN.hintsOf(ri, ca, rj, cb, cams.world));
455 if (hit.margin < -0.08) result.unseparated.push({ layer: layer.name, state: tag, a: ri.name, b: rj.name, margin: hit.margin });
456 }
457 const s = relationOf(layer, i, j, cams);
458 if (s < 1e-7 && s > -1e-7) continue;
459 const from = s > 0 ? local.get(i) : local.get(j);
460 const to = s > 0 ? local.get(j) : local.get(i);
461 adj[from].push(to);
462 indeg[to]++;
463 }
464 const queue = [];
465 for (let v = 0; v < n; v++) if (!indeg[v]) queue.push(v);
466 let seen = 0;
467 while (queue.length) {
468 const v = queue.pop();
469 seen++;
470 for (const w of adj[v]) if (--indeg[w] === 0) queue.push(w);
471 }
472 if (seen < n) {
473 const stuck = [];
474 for (let v = 0; v < n; v++) if (indeg[v] > 0) stuck.push(runs[layer.parts[v]].name);
475 let split = null;
476 for (const m of T.built?.report?.margins ?? []) if (stuck.includes(m.a) && stuck.includes(m.b) && (!split || m.margin > split.margin)) split = m;
477 const plane = split && T.built.planesByName ? T.built.planesByName.get(`${split.a}|${split.b}`) : null;
478 result.cycles.push({ layer: layer.name, state: tag, parts: stuck.slice(0, 8), split: plane ? { part: split.b, a: split.a, b: split.b, n: plane.n, d: plane.d } : null });
479 }
480 });
481 if (record) record(cams, frames, orders);
482 return { cams, frames, orders };
483 };
484 const swapsOf = (a, b) => {
485 const list = [];
486 scene.layers.forEach((layer, index) => {
487 const pa = a.orders[index];
488 const pb = b.orders[index];
489 const rankA = new Map();
490 pa.forEach((g, at) => rankA.set(g, at));
491 const rankB = new Map();
492 pb.forEach((g, at) => rankB.set(g, at));
493 const moved = new Set();
494 for (let at = 0; at < pb.length; at++) if (pa[at] !== pb[at]) moved.add(pb[at]);
495 const moving = [...moved];
496 for (let x = 0; x < moving.length; x++)
497 for (const j of layer.parts) {
498 const i = moving[x];
499 if (i === j || (moved.has(j) && j < i)) continue;
500 if ((rankA.get(i) < rankA.get(j)) === (rankB.get(i) < rankB.get(j))) continue;
501 if (T.parts[i].route && T.parts[i].route === T.parts[j].route && Math.abs(T.parts[i].chunk - T.parts[j].chunk) <= 1) continue;
502 const area = Math.min(overlapArea(runs[i], a.frames[i], runs[j], a.frames[j]), overlapArea(runs[i], b.frames[i], runs[j], b.frames[j]));
503 list.push({ layer: layer.name, i, j, area });
504 }
505 });
506 return list;
507 };
508 const clone = (frames) => frames.map((f) => ({ ...f, d: f.d.slice(), box: Float64Array.from(f.box), hull: Float64Array.from(f.hull), quadList: f.quadList, fill: undefined }));
509 const flush = new Set();
510 for (const m of T.built?.report?.margins ?? []) if (Math.abs(m.margin) < 0.05) flush.add(`${m.a}|${m.b}`);
511 const flushOf = (a, b) => flush.has(`${a}|${b}`) || flush.has(`${b}|${a}`);
512 const changes = [];
513 let orderStates = 0;
514 for (const family of wants("order") ? families : []) {
515 const xs = samplesOf(family, frameOf(family));
516 say(`order scan ${family.name}: ${xs.length} states`);
517 let previous = null;
518 const local = [];
519 for (const x of xs) {
520 const values = family.at(x);
521 const tag = label(family, x);
522 const state = checkState(values, tag, previous ? previous.orders : null);
523 orderStates++;
524 const snapshot = { orders: state.orders.map((o) => Int32Array.from(o)), frames: clone(state.frames), x };
525 if (previous) {
526 const swaps = swapsOf(previous, snapshot);
527 for (const swap of swaps) {
528 if (swap.area <= 0.25) continue;
529 const layerIndex = scene.layers.findIndex((l) => l.name === swap.layer);
530 const layer = scene.layers[layerIndex];
531 const prior = previous.orders[layerIndex];
532 const probe = (value) => {
533 const { cams, frames } = emitAll(family.at(value));
534 const order = TURN.order(layer, frames, cams, prior, {}, scene);
535 const at = new Map();
536 order.forEach((g, index) => at.set(g, index));
537 return { first: at.get(swap.i) < at.get(swap.j), frames };
538 };
539 const start = probe(previous.x).first;
540 let lo = previous.x;
541 let hi = x;
542 let frames = null;
543 for (let it = 0; it < 16; it++) {
544 const mid = (lo + hi) / 2;
545 const next = probe(mid);
546 if (next.first === start) lo = mid;
547 else {
548 hi = mid;
549 frames = next.frames;
550 }
551 }
552 if (!frames) frames = probe(hi).frames;
553 const area = overlapArea(runs[swap.i], frames[swap.i], runs[swap.j], frames[swap.j]);
554 const built = flushOf(runs[swap.i].name, runs[swap.j].name);
555 if (area > (built ? 0.5 : 0.25)) result.swaps.push({ layer: swap.layer, a: runs[swap.i].name, b: runs[swap.j].name, area, at: `${family.name} ${f3(hi)}` });
556 }
557 if (swaps.length) local.push({ family, x, count: swaps.length, pairs: swaps });
558 }
559 previous = snapshot;
560 }
561 changes.push(...local);
562 const share = Math.round((quick ? Math.min(200, random) : random) / families.length);
563 let seed = 12345 + families.indexOf(family);
564 const rand = () => ((seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648);
565 const events = local.flatMap((c) => [c.x - (family.delta ?? (family.periodic ? 0.001 : family.frame / 50)), c.x + (family.delta ?? (family.periodic ? 0.001 : family.frame / 50))]);
566 say(`order scan ${family.name}: ${share} random states, ${events.length} event states`);
567 for (let i = 0; i < share; i++) {
568 const x = family.from + rand() * (family.to - family.from);
569 checkState(family.at(x), `random ${label(family, x)}`, null);
570 }
571 for (const x of events.slice(0, quick ? 100 : 4000)) checkState(family.at(x), `event ${label(family, x)}`, null);
572 orderStates += share + Math.min(events.length, quick ? 100 : 4000);
573 }
574 for (const values of wants("order") ? extra.slice(0, quick ? 100 : extra.length) : []) checkState(values, `pose ${JSON.stringify(values).slice(0, 60)}`, null);
575 result.stats.orderStates = orderStates + extra.length;
576 result.stats.changes = changes.length;
577 result.changes = changes.sort((a, b) => b.count - a.count).slice(0, 10).map((c) => ({ family: c.family.name, x: c.x, count: c.count, pairs: c.pairs.slice(0, 4).map((p) => `${runs[p.i].name} / ${runs[p.j].name}`), at: c.family.at }));
578 say("rigid");
579 const mounts = A.mountsOf(R.tubes, R.solids);
580 if (wants("rigid")) {
581 for (const c of A.clearances(R.tubes, R.solids, { mounts })) result.rigid.push({ kind: "clearance", text: `${c.kind} ${c.tube} × ${c.other} clearance ${f2(c.clearance)} < ${f2(c.need)}` });
582 for (const e of A.terminals(R.tubes, R.solids, { mounts })) result.rigid.push({ kind: "terminal", text: `${e.tube} ${e.end} ${Number.isFinite(e.d) ? f2(e.d) : "∞"} from ${e.nearest}` });
583 const held = A.supports(R.solids, R.tubes);
584 for (const name of held.loose) result.rigid.push({ kind: "support", text: `loose ${name}` });
585 for (const name of held.hanging ?? []) result.rigid.push({ kind: "support", text: `hanging ${name}` });
586 for (const d of held.sunk) result.rigid.push({ kind: "support", text: `sunk ${d.a} × ${d.b} ${f2(d.depth)} deep` });
587 for (const c of A.solidClearances(R.solids)) result.rigid.push({ kind: "solid", text: `${c.tube} × ${c.other} sunk ${f2(-c.clearance)}` });
588 }
589 say("swept");
590 const liveShapes = [];
591 for (const part of T.parts) for (const shape of part.shapes) liveShapes.push({ shape, part, group: part.group });
592 const liveSet = new Set(liveShapes.map((entry) => entry.shape));
593 const staticShapes = [];
594 for (const item of R.items) for (const shape of item.shapes ?? []) if (!liveSet.has(shape)) staticShapes.push({ shape, item });
595 const pointsCache = new Map();
596 const pointsOf = (shape) => {
597 if (!pointsCache.has(shape)) pointsCache.set(shape, surfacePoints(shape));
598 return pointsCache.get(shape);
599 };
600 for (const g of wants("swept") ? groups : []) {
601 if (g.kind !== "turn" || g.parent) continue;
602 const mine = liveShapes.filter((entry) => entry.group === g);
603 const slices = new Map();
604 for (const { shape } of mine)
605 for (const p of pointsOf(shape)) {
606 const band = Math.floor(p[2] * 2);
607 const r = Math.hypot(p[0] - g.origin[0], p[1] - g.origin[1]);
608 slices.set(band, Math.max(slices.get(band) ?? 0, r));
609 }
610 const flagged = [];
611 for (const { shape, item } of staticShapes) {
612 let worst = Infinity;
613 for (const p of pointsOf(shape)) {
614 const r = Math.hypot(p[0] - g.origin[0], p[1] - g.origin[1]);
615 for (const band of [Math.floor(p[2] * 2), Math.floor(p[2] * 2 - 0.2), Math.floor(p[2] * 2 + 0.2)]) {
616 const reach = slices.get(band);
617 if (reach !== undefined) worst = Math.min(worst, r - reach);
618 }
619 }
620 if (worst < -0.1) flagged.push({ shape, item });
621 }
622 for (const { shape, item } of flagged) {
623 let deepest = 0;
624 let where = 0;
625 const count = quick ? 90 : 720;
626 for (let i = 0; i < count; i++) {
627 const angle = (i * 360) / count;
628 const cams = TURN.cams(scene, { [g.name]: angle });
629 const pose = poseFor(cams, g);
630 const b = A.boundsOf(shape);
631 for (const { shape: other } of mine)
632 for (const p of pointsOf(other)) {
633 const q = place(pose, p);
634 if (q[0] < b.min[0] - 0.5 || q[0] > b.max[0] + 0.5 || q[1] < b.min[1] - 0.5 || q[1] > b.max[1] + 0.5 || q[2] < b.min[2] - 0.5 || q[2] > b.max[2] + 0.5) continue;
635 const d = A.sdf(shape, q);
636 if (d < deepest) {
637 deepest = d;
638 where = angle;
639 }
640 }
641 }
642 if (deepest < -0.1) result.swept.push({ text: `${item.name} × ${g.name} sweep: ${f2(-deepest)} deep at ${f2(where)}°` });
643 }
644 }
645 say("posed");
646 const posedStates = [];
647 for (const family of families) {
648 const frame = posedStep ?? (family.periodic ? (quick ? 4 : 0.5) : family.frame * (quick ? 4 : 1));
649 for (const x of samplesOf(family, frame)) posedStates.push({ values: family.at(x), tag: label(family, x) });
650 }
651 for (const values of extra.slice(0, quick ? 40 : extra.length)) posedStates.push({ values, tag: "pose" });
652 const entries = [
653 ...liveShapes.map((entry) => ({ ...entry, kind: "live", sphere: sphereOf(pointsOf(entry.shape)) })),
654 ...staticShapes.map((entry) => ({ shape: entry.shape, part: { name: entry.item.name }, group: "static", item: entry.item, kind: "static", sphere: sphereOf(pointsOf(entry.shape)) })),
655 ];
656 const posedSeen = new Set();
657 const movingGroups = groups.filter((g) => !(g.kind === "turn" && !g.parent));
658 const needsStatic = movingGroups.length > 0;
659 for (const { values, tag } of wants("posed") ? posedStates : []) {
660 const cams = TURN.cams(scene, values);
661 const placed = entries.map((entry) => {
662 if (entry.kind === "static") return { entry, c: entry.sphere.c, pose: null };
663 const pose = poseFor(cams, entry.group);
664 return { entry, c: entry.group ? place(pose, entry.sphere.c) : entry.sphere.c, pose: entry.group ? pose : null };
665 });
666 placed.sort((a, b) => a.c[0] - a.entry.sphere.r - (b.c[0] - b.entry.sphere.r));
667 for (let i = 0; i < placed.length; i++) {
668 const A0 = placed[i];
669 const reachA = A0.c[0] + A0.entry.sphere.r + 0.5;
670 for (let j = i + 1; j < placed.length; j++) {
671 const B0 = placed[j];
672 if (B0.c[0] - B0.entry.sphere.r > reachA) break;
673 const a = A0.entry;
674 const b = B0.entry;
675 if (a.group === b.group) continue;
676 if (a.kind === "static" && b.kind === "static") continue;
677 if ((a.kind === "static" || b.kind === "static") && !needsStatic) continue;
678 if (a.kind === "static" || b.kind === "static") {
679 const live = a.kind === "static" ? b : a;
680 if (!live.group || (live.group.kind === "turn" && !live.group.parent)) continue;
681 }
682 if (a.shape.kind === "tube" && b.shape.kind === "tube") continue;
683 if (Math.hypot(A0.c[0] - B0.c[0], A0.c[1] - B0.c[1], A0.c[2] - B0.c[2]) > a.sphere.r + b.sphere.r + 0.5) continue;
684 const key = `${a.part.name}|${b.part.name}`;
685 if (posedSeen.has(key)) continue;
686 const pa = A0.pose ?? IDENTITY;
687 const pb = B0.pose ?? IDENTITY;
688 let deepest = 0;
689 for (const p of pointsOf(a.shape)) {
690 const d = A.sdf(b.shape, unplace(pb, place(pa, p)));
691 if (d < deepest) deepest = d;
692 }
693 for (const p of pointsOf(b.shape)) {
694 const d = A.sdf(a.shape, unplace(pa, place(pb, p)));
695 if (d < deepest) deepest = d;
696 }
697 const limit = a.shape.kind === "tube" || b.shape.kind === "tube" ? -0.1 : -0.5;
698 if (deepest < limit) {
699 posedSeen.add(key);
700 result.posed.push({ text: `${a.part.name} × ${b.part.name} through ${f2(-deepest)} at ${tag}` });
701 }
702 }
703 }
704 }
705 result.stats.posedStates = posedStates.length;
706 say("folds, self, crowding, endon");
707 const routes = T.routes ?? [];
708 const turnGroups = groups.filter((g) => g.kind === "turn" && !g.parent);
709 const tubesAll = R.tubes;
710 const linkMap = A.linksOf(tubesAll, mounts);
711 const routeSample = [];
712 {
713 const all = posedStates.filter((s) => s.tag !== "pose");
714 const stride = Math.max(1, Math.floor(all.length / (quick ? 48 : routeStates)));
715 for (let i = 0; i < all.length; i += stride) routeSample.push(all[i]);
716 for (const values of extra.slice(0, 24)) routeSample.push({ values, tag: "pose" });
717 }
718 const foldSeen = new Set();
719 const crowdSeen = new Set();
720 const lo = (E * 180) / Math.PI;
721 for (const route of wants("routes") ? routes : []) {
722 const g = route.group;
723 const root = g && g.kind === "turn" && !g.parent;
724 const records = R.routes.filter((r) => r.name === route.name);
725 const groupParts = T.parts.filter((part) => part.group === g);
726 const itemsAt = (pose) => groupParts.map((part) => ({ name: part.name, route: part.route, shapes: pose ? part.shapes.map((shape) => posed(shape, pose)) : part.shapes }));
727 const views = [];
728 if (!g) views.push({ P, pose: null, tag: "rest" });
729 else if (root) {
730 const count = quick ? 72 : 720;
731 for (let i = 0; i < count; i++) {
732 const x = (i * 360) / count;
733 views.push({ P: { ...P, azimuth: (P.azimuth ?? 45) - x }, pose: null, tag: `${f2(x)}°` });
734 }
735 } else for (const { values, tag } of routeSample) views.push({ P, pose: poseFor(TURN.cams(scene, values), g), tag });
736 for (const view of views) {
737 const posedRecords = view.pose ? records.map((r) => ({ ...r, points: r.points.map((p) => place(view.pose, p)) })) : records;
738 const items = itemsAt(view.pose);
739 for (const fold of A.folds(posedRecords, view.P, { items })) {
740 const key = `${fold.name}|${fold.kind}`;
741 if (foldSeen.has(key)) continue;
742 foldSeen.add(key);
743 result.folds.push({ text: `${fold.name} ${fold.kind} at ${view.tag}: turns ${fold.flatTurn.toFixed(0)}° on screen${fold.kind === "pinch" ? ` round a ${f2(fold.radius)} px bend` : ` for ${fold.realTurn.toFixed(0)}° in 3D`}` });
744 }
745 for (const s of A.selfOverlaps(items.filter((item) => item.route === route.name), view.P)) {
746 const key = `self|${s.name}`;
747 if (foldSeen.has(key)) continue;
748 foldSeen.add(key);
749 result.self.push({ text: `${s.name} folds back at ${view.tag}: radius ${f2(s.radius)} px under its half-width ${f2(s.width)} px` });
750 }
751 }
752 const dense = route.world;
753 const total = G.pathLength(dense);
754 const stations = G.resample(dense, 0.25);
755 const rotations = !g || root || g.upright ? [null] : routeSample.map(({ values }) => poseFor(TURN.cams(scene, values), g).R);
756 const bad = [];
757 let walked = 0;
758 for (let i = 1; i < stations.length - 1; i++) {
759 walked += G.len3(G.sub3(stations[i], stations[i - 1]));
760 if (route.gaps.some(([a, b]) => walked >= a - 1e-6 && walked <= b + 1e-6)) continue;
761 if (fittings.some(([name, a, b]) => name === route.name && walked >= a && walked <= b)) continue;
762 const t0 = G.unit3(G.sub3(stations[i + 1], stations[i - 1]));
763 for (const Rm of rotations) {
764 const t = Rm ? apply(Rm, t0) : t0;
765 const phi = (Math.asin(Math.max(-1, Math.min(1, t[2]))) * 180) / Math.PI;
766 if ((phi > lo - 15 && phi < lo + 15) || (phi > -lo - 15 && phi < -lo + 15)) {
767 bad.push({ at: walked, phi });
768 break;
769 }
770 }
771 }
772 if (bad.length) result.endon.push({ text: `${route.name} points within 15° of the view along ${f2(bad[0].at)}–${f2(bad[bad.length - 1].at)} of ${f2(total)} (slope ${bad[0].phi.toFixed(1)}°): bend flat or inside a fitting` });
773 }
774 const crowdViews = [];
775 for (const g of turnGroups) {
776 const count = quick ? 24 : 72;
777 for (let i = 0; i < count; i++) crowdViews.push({ group: g, P: { ...P, azimuth: (P.azimuth ?? 45) - (i * 360) / count }, tag: `${f2((i * 360) / count)}°`, values: null });
778 }
779 const others = [...new Set(routes.map((route) => route.group).filter((g) => g && !(g.kind === "turn" && !g.parent)))];
780 for (const g of others) for (const { values, tag } of routeSample.slice(0, quick ? 24 : 72)) crowdViews.push({ group: g, P, tag, values });
781 for (const view of wants("routes") ? crowdViews : []) {
782 const g = view.group;
783 const pose = view.values ? poseFor(TURN.cams(scene, view.values), g) : null;
784 const mineRoutes = R.routes.filter((r) => routes.some((route) => route.name === r.name && route.group === g)).map((r) => (pose ? { ...r, points: r.points.map((p) => place(pose, p)) } : r));
785 if (!mineRoutes.length) continue;
786 const groupParts = T.parts.filter((part) => part.group === g);
787 const items = groupParts.map((part) => ({ name: part.name, route: part.route, shapes: pose ? part.shapes.map((shape) => posed(shape, pose)) : part.shapes }));
788 const rods = A.slenderOf(items.flatMap((item) => item.shapes).filter((shape) => shape.kind !== "tube"));
789 for (const c of A.crowding(mineRoutes, view.P, { links: linkMap, items, allow: crowd, rods })) {
790 const key = [c.a, c.b, c.where].join("|");
791 if (crowdSeen.has(key)) continue;
792 crowdSeen.add(key);
793 result.crowding.push({ text: `${c.a} ${c.where} × ${c.b} at ${view.tag}: outlines ${c.gap < 0 ? `overlap ${f2(-c.gap)}` : `${f2(c.gap)} apart`} px` });
794 }
795 }
796 say("coverage");
797 const rest = emitAll(Object.fromEntries(groups.map((g) => [g.name, g.kind === "free" ? null : 0])));
798 const coverItems = T.parts.map((part) => {
799 const run = runs[part.id];
800 const f = rest.frames[part.id];
801 const outline = run.kind === "fixed" ? part.svg : run.kind === "tube" ? f.d[run.role.body] : f.d[run.role.fill];
802 return { name: part.name, svg: `<path d="${outline}"/>`, shapes: part.shapes };
803 });
804 for (const c of wants("coverage") ? A.coverage(coverItems, P, { allow: cover }) : []) result.coverage.push({ text: `${c.name} its shapes cover ${Math.round(c.share * 100)}% of its drawing` });
805 say("continuity");
806 if (wants("continuity")) {
807 TURN.exact(true);
808 try {
809 continuity(T, scene, runs, groups, { step, quick, result, say, families, frameOf });
810 } finally {
811 TURN.exact(false);
812 }
813 }
814 say("depth oracle");
815 const depthStates = [];
816 const first = families[0];
817 if (first) for (let i = 0; i < (quick ? 6 : depthAngles); i++) {
818 const x = first.from + ((first.to - first.from) * i) / (quick ? 6 : depthAngles);
819 depthStates.push({ values: first.at(x), tag: label(first, x) });
820 }
821 for (const family of families.slice(1)) for (let i = 0; i <= (quick ? 3 : 12); i++) {
822 const x = family.from + ((family.to - family.from) * i) / (quick ? 3 : 12);
823 depthStates.push({ values: family.at(x), tag: label(family, x) });
824 }
825 for (const change of result.changes.slice(0, quick ? 4 : depthEvents)) depthStates.push({ values: change.at(change.x), tag: `${change.family} ${f3(change.x)}` });
826 let checked = 0;
827 for (const { values, tag } of wants("depth") ? depthStates : []) {
828 const state = checkState(values, `depth ${tag}`, null);
829 scene.layers.forEach((layer, index) => {
830 const items = [];
831 for (const global of layer.parts) {
832 const part = T.parts[global];
833 const pose = poseFor(state.cams, part.group);
834 items.push({ name: part.name, global, shapes: part.shapes.map((shape) => posed(shape, pose)), route: part.route, chunk: part.chunk });
835 }
836 const ranks = new Map((T.order.length ? T.order : T.layers.map((l) => l.name)).map((name, at) => [name, at]));
837 for (const item of R.items) if (item.layer && ranks.has(item.layer) && item.shapes && item.shapes.length) items.push({ name: item.name, static: ranks.get(item.layer) < ranks.get(layer.name) ? "below" : "above", shapes: item.shapes });
838 const overlaps = A.overlapsOf(items, P, { step: quick ? 0.5 : 0.25, skip: (a, b) => A.sameRun(a, b) || (a.static && b.static) });
839 for (const pair of overlaps.pairs) {
840 const a = items[pair.a];
841 const b = items[pair.b];
842 if (!a.static === !b.static) continue;
843 const sideIndex = a.static ? 0 : 1;
844 const fixed = a.static ? a : b;
845 const moving = a.static ? b : a;
846 const staticFront = pair.front[sideIndex].area >= overlaps.least;
847 const liveFront = pair.front[1 - sideIndex].area >= overlaps.least;
848 if (fixed.static === "below" && staticFront) result.layers.push({ text: `${fixed.name} (under ${layer.name}) is in front of ${moving.name} at ${tag}: ${f2(pair.front[sideIndex].area)} px²` });
849 if (fixed.static === "above" && liveFront) result.layers.push({ text: `${fixed.name} (over ${layer.name}) is behind ${moving.name} at ${tag}: ${f2(pair.front[1 - sideIndex].area)} px²` });
850 }
851 const rank = new Map();
852 state.orders[index].forEach((global, at) => rank.set(global, at));
853 for (const pair of overlaps.pairs) {
854 if (items[pair.a].static || items[pair.b].static) continue;
855 checked++;
856 const aFront = pair.front[0].area >= overlaps.least;
857 const bFront = pair.front[1].area >= overlaps.least;
858 if (aFront === bFront) continue;
859 const front = items[aFront ? pair.a : pair.b];
860 const backItem = items[aFront ? pair.b : pair.a];
861 if (rank.get(front.global) < rank.get(backItem.global)) result.depth.push({ text: `${layer.name} at ${tag}: ${front.name} is in front of ${backItem.name} but drawn first (${f2(pair.front[aFront ? 0 : 1].area)} px²)` });
862 }
863 });
864 }
865 result.stats.depthStates = depthStates.length;
866 result.stats.depthPairs = checked;
867 result.stats.seconds = (Date.now() - started) / 1000;
868 return result;
871function continuity(T, scene, runs, groups, { step, quick, result, say, families = [], frameOf = (f) => f.frame }) {
872 const stepSize = quick ? step * 10 : step;
873 const measures = [];
874 const worst = { q: 0, birth: 0, dot: 0, fade: 0, motion: 0, merge: 0 };
875 const failures = [];
876 const k = scene.P.scale * Math.sqrt(1.6);
877 const sweeps = [];
878 for (const g of groups) {
879 if (g.loose) continue;
880 if (g.kind === "turn") sweeps.push({ group: g, from: 0, to: 360, unit: "°" });
881 else if (g.kind === "hinge") sweeps.push({ group: g, from: g.range[0], to: g.range[1], unit: "°" });
882 else sweeps.push({ group: g, from: 0, to: g.travel, unit: "" });
883 }
884 for (const sweep of sweeps) {
885 const g = sweep.group;
886 const parts = runs.filter((run) => run.group === g.index);
887 if (!parts.length) continue;
888 const span = sweep.to - sweep.from;
889 const delta = g.kind === "slide" ? Math.max(0.01, span / 400) : stepSize;
890 const count = Math.max(1, Math.round(span / delta));
891 const reach = parts.map((run) => {
892 let r = 0;
893 const part = T.parts[run.index];
894 for (const p of part.samples) r = Math.max(r, g.kind === "slide" ? 0 : Math.hypot(p[0], p[1]));
895 return r;
896 });
897 const valuesAt = (x) => ({ [g.name]: x });
898 const emitPart = (run, x, f) => {
899 const cams = TURN.cams(scene, valuesAt(x));
900 TURN.emit(run, cams[g.index], f);
901 return f;
902 };
903 say(`continuity ${g.name}: ${count} steps of ${delta}${sweep.unit} over ${parts.length} parts`);
904 const snap = (run, f) => {
905 const roles = run.roles;
906 const out = { faces: [], sheets: [], dots: [], planes: [], fade: {}, outline: null, levels: f.levels ?? 0 };
907 roles.forEach((role, r) => {
908 if (role.type === "face") out.faces.push([f.d[r] ? areaOfPath(f.d[r]) : 0, f.q[r], Boolean(f.d[r])]);
909 else if (role.type === "path" && /^s[0-4]$/.test(role.name)) out.sheets.push([areaOfPath(f.d[r]), (f.d[r].match(/M/g) ?? []).length]);
910 else if (role.type === "dot") out.dots.push(f.o[r]);
911 else if (role.type === "plane") out.planes.push(f.o[r]);
912 });
913 for (const [prefix, set] of Object.entries(f.fade ?? {})) {
914 const map = new Map();
915 set.key.forEach((key, i) => map.set(key, Math.round(set.alpha[i] * TURN.levels) / TURN.levels));
916 out.fade[prefix] = map;
917 }
918 const edge = run.role.fill !== undefined ? f.d[run.role.fill] : run.role.body !== undefined ? f.d[run.role.body] : "";
919 out.outline = run.kind === "fixed" ? [Array.from({ length: f.hullN }, (_, i) => [f.hull[2 * i], f.hull[2 * i + 1]])] : pathPolys(edge);
920 out.slots = run.slots;
921 return out;
922 };
923 const compare = (run, a, b, dx, reachPx) => {
924 const m = { q: 0, birth: 0, dot: 0, fade: 0, motion: 0, merge: 0 };
925 for (let i = 0; i < Math.min(a.faces.length, b.faces.length); i++) {
926 const [areaA, qa, onA] = a.faces[i];
927 const [areaB, qb, onB] = b.faces[i];
928 if (onA && onB) m.q = Math.max(m.q, (Math.abs(qa - qb) * 16) / TURN.q);
929 if (onA !== onB) m.birth = Math.max(m.birth, onA ? areaA : areaB);
930 m.area = Math.max(m.area ?? 0, Math.abs(areaA - areaB));
931 }
932 for (let i = 0; i < Math.min(a.sheets.length, b.sheets.length); i++) {
933 const [areaA, countA] = a.sheets[i];
934 const [areaB, countB] = b.sheets[i];
935 if (!countA !== !countB) m.birth = Math.max(m.birth, Math.abs(areaA - areaB));
936 m.area = Math.max(m.area ?? 0, Math.abs(areaA - areaB));
937 }
938 for (let i = 0; i < Math.min(a.dots.length, b.dots.length); i++) m.dot = Math.max(m.dot, Math.abs(a.dots[i] - b.dots[i]));
939 for (let i = 0; i < Math.min(a.planes.length, b.planes.length); i++) m.dot = Math.max(m.dot, Math.abs(a.planes[i] - b.planes[i]));
940 for (const prefix of new Set([...Object.keys(a.fade), ...Object.keys(b.fade)])) {
941 const A0 = a.fade[prefix] ?? new Map();
942 const B0 = b.fade[prefix] ?? new Map();
943 for (const key of new Set([...A0.keys(), ...B0.keys()])) m.fade = Math.max(m.fade, Math.abs((A0.get(key) ?? 0) - (B0.get(key) ?? 0)));
944 }
945 const slots = Math.max(run.slots.bevel ?? 0, run.slots.ribs ?? 0);
946 if (slots && (a.levels > slots || b.levels > slots)) m.merge = Math.max(a.levels, b.levels) - slots;
947 if (a.outline.length && b.outline.length && a.outline[0].length && b.outline[0].length) m.motion = Math.max(0, hausdorffish(a.outline[0], b.outline[0]) - (1.5 * reachPx * dx + 0.05));
948 return m;
949 };
950 const frames = parts.map((run) => TURN.frame(run));
951 let previous = parts.map((run, i) => snap(run, emitPart(run, sweep.from, frames[i])));
952 for (let c = 1; c <= count; c++) {
953 const x = sweep.from + c * delta;
954 parts.forEach((run, i) => {
955 const current = snap(run, emitPart(run, x, frames[i]));
956 const reachPx = g.kind === "slide" ? k : (reach[i] * k * Math.PI) / 180;
957 const m = compare(run, previous[i], current, g.kind === "slide" ? delta : delta, reachPx);
958 for (const key of Object.keys(worst)) worst[key] = Math.max(worst[key], m[key] ?? 0);
959 const scale = delta / step;
960 if (m.q > Math.ceil(scale) || m.birth > 2 * scale || m.dot > 0.0105 * scale || m.fade > Math.ceil(scale) / 32 + 0.005 || m.merge > 0 || m.motion > 0) failures.push({ part: run.name, group: g.name, at: x, m });
961 measures.push({ run, x: x - delta, dx: delta, value: m.birth + m.motion * 4 + (m.area ?? 0), motion: m.motion, birth: m.birth, valuesAt, reachPx });
962 previous[i] = current;
963 });
964 }
965 }
966 measures.sort((a, b) => b.value - a.value);
967 let halvingFails = 0;
968 let halvingChecked = 0;
969 const halving = (run, at, x, dx) => {
970 const emitAt = (value) => {
971 const f = TURN.frame(run);
972 const cams = TURN.cams(scene, at(value));
973 TURN.emit(run, run.group >= 0 ? cams[run.group] : cams.world, f);
974 return f;
975 };
976 const frames = Array.from({ length: 9 }, (_, j) => emitAt(x + (dx * j) / 8));
977 const measure = (scale) => {
978 const eighth = Math.max(...visibleChange(run, frames, scale));
979 const quarter = Math.max(...visibleChange(run, [0, 2, 4, 6, 8].map((j) => frames[j]), scale));
980 const half = Math.max(...visibleChange(run, [frames[0], frames[4], frames[8]], scale));
981 const full = visibleChange(run, [frames[0], frames[8]], scale)[0];
982 return { full, half, quarter, eighth, fails: full >= 2 && half > 0.6 * full && quarter > 0.6 * half && eighth > 0.6 * quarter && eighth >= 2 };
983 };
984 const coarse = measure(4);
985 return coarse.fails ? measure(16) : coarse;
986 };
987 const seen = new Set();
988 for (const entry of measures.slice(0, quick ? 40 : 400)) {
989 if (!(entry.value > 1e-6)) break;
990 const key = `${entry.run.index}|${entry.x.toFixed(4)}`;
991 if (seen.has(key)) continue;
992 seen.add(key);
993 halvingChecked++;
994 const g = groups[entry.run.group];
995 const h = halving(entry.run, entry.valuesAt, entry.x, entry.dx);
996 if (!h.fails) continue;
997 halvingFails++;
998 failures.push({ part: entry.run.name, group: g.name, at: entry.x, m: { halving: `${f2(h.full)} tone·px² step stays ${f2(h.half)}, ${f2(h.quarter)} and ${f2(h.eighth)} at a half, a quarter and an eighth of the step` } });
999 }
1000 const areaRoles = (run) => run.roles.map((role, r) => (role.type === "face" || /^s[0-4]$/.test(role.name) || (run.kind === "tube" && role.name === "body") ? r : -1)).filter((r) => r >= 0);
1001 const clipSteps = [];
1002 for (const family of families) {
1003 if (family.periodic) continue;
1004 const xs = samplesOf(family, frameOf(family));
1005 say(`continuity ${family.name}: ${xs.length} frames over ${runs.length} parts`);
1006 const frames = runs.map((run) => TURN.frame(run));
1007 let previous = null;
1008 for (const x of xs) {
1009 const cams = TURN.cams(scene, family.at(x));
1010 const current = runs.map((run, index) => {
1011 TURN.emit(run, run.group >= 0 ? cams[run.group] : cams.world, frames[index]);
1012 return areaRoles(run).map((r) => areaOfPath(frames[index].d[r]));
1013 });
1014 if (previous)
1015 runs.forEach((run, index) => {
1016 let most = 0;
1017 let role = -1;
1018 current[index].forEach((v, k) => {
1019 const d = Math.abs(v - previous[index][k]);
1020 if (d > most) {
1021 most = d;
1022 role = k;
1024 });
1025 if (most >= 0.5) clipSteps.push({ family, run, x: x - frameOf(family), dx: frameOf(family), role, value: most });
1026 });
1027 previous = current;
1030 clipSteps.sort((a, b) => b.value - a.value);
1031 let clipChecked = 0;
1032 for (const entry of clipSteps.slice(0, quick ? 60 : 400)) {
1033 clipChecked++;
1034 const h = halving(entry.run, entry.family.at, entry.x, entry.dx);
1035 if (!h.fails) continue;
1036 halvingFails++;
1037 failures.push({ part: entry.run.name, group: entry.family.name, at: entry.x, m: { halving: `${f2(h.full)} tone·px² step stays ${f2(h.half)}, ${f2(h.quarter)} and ${f2(h.eighth)} at a half, a quarter and an eighth of the frame` } });
1039 result.continuity = failures;
1040 result.stats.continuity = { worst, halvingChecked: halvingChecked + clipChecked, halvingFails, measures: measures.length + clipSteps.length };
1043export function orbitReport(result) {
1044 const lines = [];
1045 const skipped = (name, items) => (result.skipped ?? []).includes(name) && !items.length;
1046 const list = (name, items, text) => {
1047 if (skipped(name, items)) return lines.push(`${name}: skipped`);
1048 lines.push(`${name}: ${items.length}`);
1049 for (const item of items.slice(0, 40)) lines.push(` ${text(item)}`);
1050 };
1051 list("separation", result.separation, (m) => `interlock ${m.a} × ${m.b} ${f2(m.margin)}: split, flats or a flush contact`);
1052 list("unseparated", result.unseparated, (m) => `${m.state} ${m.a} × ${m.b} margin ${f2(m.margin)} while their hulls overlap${m.framed ? " (framed plane: the build's poses() missed this motion; add it to poses())" : ""}`);
1053 const cycleGroups = new Map();
1054 for (const c of result.cycles) {
1055 const key = `${c.layer}|${c.parts.join(", ")}`;
1056 if (!cycleGroups.has(key)) cycleGroups.set(key, { ...c, first: c.state, last: c.state, count: 0 });
1057 const g = cycleGroups.get(key);
1058 g.last = c.state;
1059 g.count++;
1061 lines.push(skipped("cycles", result.cycles) ? "cycles: skipped" : `cycles: ${result.cycles.length}`);
1062 for (const g of [...cycleGroups.values()].slice(0, 20)) lines.push(` ${g.layer}: ${g.parts.join(", ")} in ${g.count} state${g.count === 1 ? "" : "s"} from ${g.first} to ${g.last}: ${g.split ? `split ${g.split.part} on n = (${g.split.n.map((v) => v.toFixed(3)).join(", ")}), d = ${g.split.d.toFixed(3)} (the plane of the cycle edge with the largest margin, ${g.split.a} × ${g.split.b})` : "split one of them (T.split) on the plane of the cycle edge with the largest margin"}`);
1063 list("forced", result.forced, (c) => `${c.state} ${c.layer}: ${c.forced} forced pick${c.forced === 1 ? "" : "s"}`);
1064 list("swaps", result.swaps, (s) => `${s.layer}: ${s.a} / ${s.b} swap at ${s.at} while overlapping ${f2(s.area)} px²`);
1065 list("layers", result.layers, (s) => s.text);
1066 list("rigid", result.rigid, (s) => `${s.kind} ${s.text}`);
1067 list("swept", result.swept, (s) => s.text);
1068 list("posed", result.posed, (s) => s.text);
1069 list("folds", result.folds, (s) => s.text);
1070 list("self", result.self, (s) => s.text);
1071 list("crowding", result.crowding, (s) => s.text);
1072 list("endon", result.endon, (s) => s.text);
1073 list("coverage", result.coverage, (s) => s.text);
1074 list("depth", result.depth, (s) => s.text);
1075 list("continuity", result.continuity, (c) => `${c.part} (${c.group}) at ${f3(c.at)}: ${Object.entries(c.m).filter(([, v]) => typeof v === "string" || v > 0).map(([key, v]) => `${key} ${typeof v === "string" ? v : f3(v)}`).join(", ")}`);
1076 const s = result.stats;
1077 const w = s.continuity?.worst ?? {};
1078 if (!skipped("continuity", result.continuity)) lines.push(`worst tone step ${w.q ?? 0} · worst birth ${f2(w.birth ?? 0)} px² · worst dot α step ${f3(w.dot ?? 0)} · worst faded α step ${f3(w.fade ?? 0)} · merges ${w.merge ?? 0} · outline motion over bound ${f3(w.motion ?? 0)} px · halving ${s.continuity?.halvingFails ?? 0} of ${s.continuity?.halvingChecked ?? 0}`);
1079 lines.push(`order changes: ${s.changes ?? 0} states with swaps · ${s.orderStates ?? 0} order states · ${s.posedStates ?? 0} posed states · ${s.planes ?? 0} planes (tightest margin ${f3(s.tightest ?? 0)}) · depth oracle ${s.depthStates ?? 0} states, ${s.depthPairs ?? 0} overlapping pairs`);
1080 for (const change of result.changes ?? []) lines.push(` ${change.family} at ${f3(change.x)}: ${change.count} pair${change.count === 1 ? "" : "s"} reorder (${change.pairs.join("; ")})`);
1081 lines.push(`turn audit · ${f2(s.seconds ?? 0)} s`);
1082 return lines.join("\n");
1085export const orbitFailures = (result) => ["separation", "unseparated", "cycles", "forced", "swaps", "layers", "rigid", "swept", "posed", "folds", "self", "crowding", "endon", "coverage", "depth", "continuity"].reduce((sum, key) => sum + result[key].length, 0);
1087export function orbitOrExit(T, R, P, { flag = "--audit", argv = process.argv, ...options } = {}) {
1088 if (!argv.includes(flag)) return null;
1089 const result = orbit(T, R, P, options);
1090 console.log(orbitReport(result));
1091 const count = orbitFailures(result);
1092 const skipped = result.skipped.length;
1093 const partial = skipped ? `${skipped} line${skipped === 1 ? "" : "s"} skipped by only` : "";
1094 const undersampled = result.quick ? "quick run, undersampled: the full run is the proof" : "";
1095 const notes = [partial, undersampled].filter(Boolean).join("; ");
1096 if (count) console.log(`turn audit failed: ${count} problem${count === 1 ? "" : "s"}${notes ? ` (${notes})` : ""}`);
1097 else if (notes) console.log(`turn audit ${skipped ? "partial" : "quick"}: no problems in what ran (${notes})`);
1098 else console.log("turn audit passed");
1099 if (count) process.exitCode = 1;
1100 return result;