axstack 0.36.1 → 0.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -4
- package/bin/axstack.js +4 -18
- package/docs/getting-started.md +6 -6
- package/docs/host-operations.md +9 -5
- package/docs/installation.md +20 -41
- package/docs/workflows.md +12 -9
- package/package.json +1 -1
- package/profiles/presets/claude-only.json +3 -3
- package/profiles/presets/codex-only.json +3 -3
- package/profiles/presets/mixed.json +5 -4
- package/skills/axstack/references/automations.md +7 -2
- package/skills/axstack/references/lifecycle.md +2 -1
- package/skills/axstack/references/review-manager-prompt.md +44 -3
- package/skills/axstack/references/role-roster.md +13 -6
- package/skills/axstack/references/routing.md +2 -1
- package/skills/axstack/references/run-record.md +2 -0
- package/skills/axstack/references/ui-verification.md +6 -10
- package/skills/axstack-anatomy/ANATOMY.md +172 -0
- package/skills/axstack-anatomy/CHANGELOG.md +70 -0
- package/skills/axstack-anatomy/LICENSE +21 -0
- package/skills/axstack-anatomy/PROVENANCE.md +21 -0
- package/skills/axstack-anatomy/README.md +316 -0
- package/skills/axstack-anatomy/SKILL.md +74 -0
- package/skills/axstack-anatomy/SOURCE-PROOF.json +107 -0
- package/skills/axstack-anatomy/examples/arcade-cabinet/arcade-cabinet.css +44 -0
- package/skills/axstack-anatomy/examples/arcade-cabinet/build.mjs +714 -0
- package/skills/axstack-anatomy/examples/arcade-cabinet/live.js +597 -0
- package/skills/axstack-anatomy/examples/arcade-cabinet/parts.mjs +89 -0
- package/skills/axstack-anatomy/examples/arcade-cabinet/screen.mjs +114 -0
- package/skills/axstack-anatomy/examples/desk-computer/build.mjs +435 -0
- package/skills/axstack-anatomy/examples/dial-indicator/build.mjs +275 -0
- package/skills/axstack-anatomy/examples/raptor-engine/build.mjs +938 -0
- package/skills/axstack-anatomy/examples/raptor-engine/live.js +907 -0
- package/skills/axstack-anatomy/examples/raptor-engine/raptor.css +359 -0
- package/skills/axstack-anatomy/examples/ripple-tank/build.mjs +558 -0
- package/skills/axstack-anatomy/examples/ripple-tank/live.js +579 -0
- package/skills/axstack-anatomy/examples/ripple-tank/model.mjs +131 -0
- package/skills/axstack-anatomy/examples/ripple-tank/ripple.css +103 -0
- package/skills/axstack-anatomy/examples/spot-plate/art.tsx +110 -0
- package/skills/axstack-anatomy/examples/spot-plate/drops.ts +541 -0
- package/skills/axstack-anatomy/examples/spot-plate/fan.ts +84 -0
- package/skills/axstack-anatomy/examples/spot-plate/figure.tsx +25 -0
- package/skills/axstack-anatomy/examples/spot-plate/geometry.ts +280 -0
- package/skills/axstack-anatomy/examples/spot-plate/live.tsx +339 -0
- package/skills/axstack-anatomy/examples/spot-plate/rig.ts +230 -0
- package/skills/axstack-anatomy/examples/spot-plate/spot-plate.css +92 -0
- package/skills/axstack-anatomy/examples/test-rig/art.tsx +196 -0
- package/skills/axstack-anatomy/examples/test-rig/figure.tsx +23 -0
- package/skills/axstack-anatomy/examples/test-rig/geometry.ts +305 -0
- package/skills/axstack-anatomy/examples/test-rig/live.tsx +511 -0
- package/skills/axstack-anatomy/examples/test-rig/pose.ts +107 -0
- package/skills/axstack-anatomy/examples/test-rig/rig.ts +170 -0
- package/skills/axstack-anatomy/examples/test-rig/stretch.css +80 -0
- package/skills/axstack-anatomy/examples/test-rig/view.ts +99 -0
- package/skills/axstack-anatomy/examples/turning-dial/build.mjs +479 -0
- package/skills/axstack-anatomy/examples/turning-dial/dial.css +10 -0
- package/skills/axstack-anatomy/examples/turning-dial/live.js +342 -0
- package/skills/axstack-anatomy/examples/turning-dial/stress.mjs +269 -0
- package/skills/axstack-anatomy/kit/audit.mjs +1913 -0
- package/skills/axstack-anatomy/kit/canvas-painter.mjs +722 -0
- package/skills/axstack-anatomy/kit/gl-shared.mjs +184 -0
- package/skills/axstack-anatomy/kit/gl.mjs +184 -0
- package/skills/axstack-anatomy/kit/iso-kit.mjs +674 -0
- package/skills/axstack-anatomy/kit/iso-kit.ts +842 -0
- package/skills/axstack-anatomy/kit/iso.css +106 -0
- package/skills/axstack-anatomy/kit/lathe.mjs +1184 -0
- package/skills/axstack-anatomy/kit/react/draw.tsx +205 -0
- package/skills/axstack-anatomy/kit/react/live.tsx +140 -0
- package/skills/axstack-anatomy/kit/tube.mjs +306 -0
- package/skills/axstack-anatomy/kit/turn-audit.mjs +1096 -0
- package/skills/axstack-anatomy/kit/turn-build.mjs +1355 -0
- package/skills/axstack-anatomy/kit/turn-fixed.mjs +3800 -0
- package/skills/axstack-anatomy/kit/turn.mjs +3610 -0
- package/skills/axstack-anatomy/references/3d.md +352 -0
- package/skills/axstack-anatomy/references/craft.md +325 -0
- package/skills/axstack-anatomy/references/kit.md +419 -0
- package/skills/axstack-anatomy/references/motion.md +165 -0
- package/skills/axstack-anatomy/references/react.md +42 -0
- package/skills/axstack-anatomy/references/verify.md +351 -0
- package/skills/axstack-anatomy/references/walkthrough.md +75 -0
- package/skills/axstack-anatomy/references/webgl.md +473 -0
- package/skills/axstack-anatomy/scripts/audit-affine-bounds.test.mjs +54 -0
- package/skills/axstack-anatomy/scripts/audit-cache.test.mjs +37 -0
- package/skills/axstack-anatomy/scripts/audit-clearance-envelope.test.mjs +39 -0
- package/skills/axstack-anatomy/scripts/audit-empty-clipping.test.mjs +38 -0
- package/skills/axstack-anatomy/scripts/audit-order-convergence.test.mjs +55 -0
- package/skills/axstack-anatomy/scripts/audit-path-origin.test.mjs +26 -0
- package/skills/axstack-anatomy/scripts/audit-profile.test.mjs +41 -0
- package/skills/axstack-anatomy/scripts/audit-projection.test.mjs +36 -0
- package/skills/axstack-anatomy/scripts/audit-slide-clearance.test.mjs +58 -0
- package/skills/axstack-anatomy/scripts/audit-svg-attributes.test.mjs +46 -0
- package/skills/axstack-anatomy/scripts/audit-taper.test.mjs +29 -0
- package/skills/axstack-anatomy/scripts/capture-selector.test.mjs +131 -0
- package/skills/axstack-anatomy/scripts/capture.mjs +228 -0
- package/skills/axstack-anatomy/scripts/capture.test.mjs +42 -0
- package/skills/axstack-anatomy/scripts/chrome-port.mjs +36 -0
- package/skills/axstack-anatomy/scripts/chrome-startup.test.mjs +38 -0
- package/skills/axstack-anatomy/scripts/drive-actions.test.mjs +46 -0
- package/skills/axstack-anatomy/scripts/drive.mjs +302 -0
- package/skills/axstack-anatomy/scripts/drive.test.mjs +72 -0
- package/skills/axstack-anatomy/scripts/enter-key.test.mjs +38 -0
- package/skills/axstack-anatomy/scripts/fidelity-cut.test.mjs +54 -0
- package/skills/axstack-anatomy/scripts/fidelity-tube-coverage.test.mjs +40 -0
- package/skills/axstack-anatomy/scripts/inline-kit.mjs +26 -0
- package/skills/axstack-anatomy/scripts/line-ends.mjs +306 -0
- package/skills/axstack-anatomy/scripts/lines-arcs.test.mjs +50 -0
- package/skills/axstack-anatomy/scripts/lines-bezier.test.mjs +28 -0
- package/skills/axstack-anatomy/scripts/lines-round.test.mjs +56 -0
- package/skills/axstack-anatomy/scripts/lines-rounded-rect.test.mjs +32 -0
- package/skills/axstack-anatomy/scripts/lines.mjs +31 -0
- package/skills/axstack-anatomy/scripts/lines.test.mjs +38 -0
- package/skills/axstack-anatomy/scripts/navigation.test.mjs +55 -0
- package/skills/axstack-anatomy/scripts/orbit-sampling.test.mjs +50 -0
- package/skills/axstack-anatomy/scripts/react-dots-opacity.test.mjs +51 -0
- package/skills/axstack-anatomy/scripts/react-readout-first-write.test.mjs +37 -0
- package/skills/axstack-anatomy/scripts/react-solid-seamless.test.mjs +47 -0
- package/skills/axstack-anatomy/scripts/react-visibility-target.test.mjs +38 -0
- package/skills/axstack-anatomy/scripts/turn-bench.mjs +74 -0
- package/skills/axstack-anatomy/scripts/turn-check.mjs +641 -0
- package/skills/axstack-anatomy/scripts/turn-fidelity.mjs +413 -0
- package/skills/axstack-anatomy/scripts/turn-group-reference.test.mjs +40 -0
- package/skills/axstack-anatomy/scripts/turn-split-owner.test.mjs +62 -0
- package/skills/axstack-anatomy/theme.js +56 -0
- package/skills/axstack-explain/SKILL.md +28 -15
- package/skills/axstack-explain/references/delivery.md +22 -0
- package/skills/{axstack-diagram → axstack-explain}/references/fidelity.md +1 -7
- package/skills/axstack-explain/references/inline-pages.md +8 -8
- package/skills/axstack-explain/references/visual-qa.md +3 -5
- package/skills/axstack-explain/references/whiteboard-defense.md +59 -0
- package/skills/axstack-research/SKILL.md +18 -6
- package/src/archify-legacy.js +70 -0
- package/src/capabilities.js +1 -29
- package/src/installer.js +11 -31
- package/skills/axstack-diagram/SKILL.md +0 -28
- package/skills/axstack-diagram/references/archify.md +0 -76
- package/src/archify-pin.js +0 -5
- package/src/archify.js +0 -151
|
@@ -0,0 +1,1096 @@
|
|
|
1
|
+
import { TURN } from "./turn.mjs";
|
|
2
|
+
import * as A from "./audit.mjs";
|
|
3
|
+
import * as G from "./lathe.mjs";
|
|
4
|
+
import * as k from "./iso-kit.mjs";
|
|
5
|
+
import { separate, sampleShape } from "./turn-build.mjs";
|
|
6
|
+
|
|
7
|
+
const TAU = Math.PI * 2;
|
|
8
|
+
const EYE = [1, 0, 0, 0, 1, 0, 0, 0, 1];
|
|
9
|
+
const f2 = (v) => (Math.round(v * 100) / 100).toFixed(2);
|
|
10
|
+
const f3 = (v) => (Math.round(v * 1000) / 1000).toFixed(3);
|
|
11
|
+
|
|
12
|
+
const 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]];
|
|
13
|
+
const 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]];
|
|
14
|
+
|
|
15
|
+
const 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
|
+
};
|
|
19
|
+
|
|
20
|
+
const 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
|
+
};
|
|
24
|
+
|
|
25
|
+
export 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;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const IDENTITY = { R: EYE, t: [0, 0, 0], origin: [0, 0, 0] };
|
|
40
|
+
const poseFor = (cams, group) => (group && group !== "static" ? { R: cams[group.index].R, t: cams[group.index].t, origin: group.origin } : IDENTITY);
|
|
41
|
+
|
|
42
|
+
export 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);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function 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;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function 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;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const 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
|
+
});
|
|
110
|
+
|
|
111
|
+
function 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));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function 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())] : [];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function 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;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function 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));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const areaOfPath = (d) => pathPolys(d).reduce((sum, poly) => sum + Math.abs(polygonArea(poly)), 0);
|
|
172
|
+
|
|
173
|
+
function 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;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const jitter = (a, b) => {
|
|
183
|
+
const t = Math.sin(a * 12.9898 + b * 78.233) * 43758.5453;
|
|
184
|
+
return t - Math.floor(t);
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
function 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 };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function 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;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function 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;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function 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)))));
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function 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 };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function 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;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function samplesOf(family, frame) {
|
|
312
|
+
const xs = [];
|
|
313
|
+
const count = Math.max(1, Math.round((family.to - family.from) / frame));
|
|
314
|
+
for (let i = 0; i < count + (family.periodic ? 0 : 1); i++) xs.push(family.from + i * frame);
|
|
315
|
+
return xs;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const 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" };
|
|
319
|
+
const ORBIT_CHECKS = ["order", "rigid", "swept", "posed", "routes", "coverage", "continuity", "depth"];
|
|
320
|
+
|
|
321
|
+
export function orbit(T, R, P, options = {}) {
|
|
322
|
+
const started = Date.now();
|
|
323
|
+
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;
|
|
324
|
+
const say = (text) => log && log(text);
|
|
325
|
+
const unknown = (only ?? []).filter((name) => !ORBIT_CHECKS.includes(name));
|
|
326
|
+
if (unknown.length) throw new Error(`orbit: unknown check ${unknown.join(", ")} in only (the checks are ${ORBIT_CHECKS.join(", ")})`);
|
|
327
|
+
const wants = (name) => !only || only.includes(name);
|
|
328
|
+
const data = T.data;
|
|
329
|
+
const scene = TURN.prepare(data, { unit: 1 });
|
|
330
|
+
const runs = scene.runs;
|
|
331
|
+
const groups = T.groups;
|
|
332
|
+
const E = ((P.elevation ?? 30) * Math.PI) / 180;
|
|
333
|
+
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])) };
|
|
334
|
+
const families = familiesOf(T, { sweep, clips });
|
|
335
|
+
const extra = typeof poses === "function" ? poses() : Array.isArray(poses) ? poses : [];
|
|
336
|
+
const frameOf = (family) => (quick ? family.frame * (family.periodic ? Math.max(1, 1 / family.frame) : 3) : family.frame);
|
|
337
|
+
const label = (family, x) => `${family.name} ${x.toFixed(3)}${family.unit ?? ""}`;
|
|
338
|
+
const emitAll = (values, frames = runs.map((run) => TURN.frame(run))) => {
|
|
339
|
+
const cams = TURN.cams(scene, values);
|
|
340
|
+
for (const run of runs) TURN.emit(run, run.group >= 0 ? cams[run.group] : cams.world, frames[run.index]);
|
|
341
|
+
return { cams, frames };
|
|
342
|
+
};
|
|
343
|
+
say("separation");
|
|
344
|
+
const allMargins = T.built?.report?.margins ?? [];
|
|
345
|
+
for (const m of allMargins) if (m.margin < -0.08) result.separation.push({ a: m.a, b: m.b, margin: m.margin });
|
|
346
|
+
result.stats.planes = allMargins.length;
|
|
347
|
+
result.stats.tightest = allMargins.length ? Math.min(...allMargins.map((m) => m.margin)) : 0;
|
|
348
|
+
const relationOf = (layer, i, j, cams) => {
|
|
349
|
+
const plan = layer.planar;
|
|
350
|
+
for (let p = 0; p < plan.length; p += 5)
|
|
351
|
+
if ((plan[p] === i && plan[p + 1] === j) || (plan[p] === j && plan[p + 1] === i)) {
|
|
352
|
+
const g = runs[plan[p]].group;
|
|
353
|
+
const V = g >= 0 ? cams[g].V : cams.world.V;
|
|
354
|
+
const s = plan[p + 2] * V[0] + plan[p + 3] * V[1] + plan[p + 4] * V[2];
|
|
355
|
+
return plan[p] === i ? s : -s;
|
|
356
|
+
}
|
|
357
|
+
const a = runs[i];
|
|
358
|
+
const b = runs[j];
|
|
359
|
+
const ca = a.group >= 0 ? cams[a.group] : cams.world;
|
|
360
|
+
const cb = b.group >= 0 ? cams[b.group] : cams.world;
|
|
361
|
+
const hit = TURN.gjk(a.cloud, ca, b.cloud, cb, null, 32, TURN.hintsOf(a, ca, b, cb, cams.world));
|
|
362
|
+
const V = cams.world.V;
|
|
363
|
+
return hit.n[0] * V[0] + hit.n[1] * V[1] + hit.n[2] * V[2];
|
|
364
|
+
};
|
|
365
|
+
const pairsOf = (layer) => {
|
|
366
|
+
if (layer._pairs) return layer._pairs;
|
|
367
|
+
const list = [];
|
|
368
|
+
const seen = new Set();
|
|
369
|
+
for (let p = 0; p < layer.planar.length; p += 5) list.push([layer.planar[p], layer.planar[p + 1]]);
|
|
370
|
+
for (let p = 0; p < layer.dynamic.length; p += 2) list.push([layer.dynamic[p], layer.dynamic[p + 1]]);
|
|
371
|
+
for (let p = 0; p < (layer.framed ? layer.framed.length : 0); p += 6) list.push([layer.framed[p], layer.framed[p + 1]]);
|
|
372
|
+
for (const gp of layer.groupPairs ?? []) for (const i of gp.a) for (const j of gp.b) list.push([i, j]);
|
|
373
|
+
for (const [i, j] of list) seen.add(i * 65536 + j);
|
|
374
|
+
for (const i of layer.free) for (const j of layer.parts) {
|
|
375
|
+
if (i === j || runs[i].group === runs[j].group) continue;
|
|
376
|
+
const key = Math.min(i, j) * 65536 + Math.max(i, j);
|
|
377
|
+
if (seen.has(key)) continue;
|
|
378
|
+
seen.add(key);
|
|
379
|
+
list.push([Math.min(i, j), Math.max(i, j)]);
|
|
380
|
+
}
|
|
381
|
+
layer._pairs = list;
|
|
382
|
+
return list;
|
|
383
|
+
};
|
|
384
|
+
const checkState = (values, tag, previous, record) => {
|
|
385
|
+
const { cams, frames } = emitAll(values);
|
|
386
|
+
const orders = [];
|
|
387
|
+
scene.layers.forEach((layer, index) => {
|
|
388
|
+
const stats = {};
|
|
389
|
+
const order = TURN.order(layer, frames, cams, previous ? previous[index] : null, stats, scene);
|
|
390
|
+
orders.push(order);
|
|
391
|
+
if (stats.forced) result.forced.push({ layer: layer.name, state: tag, forced: stats.forced });
|
|
392
|
+
const framed = layer.framed ?? [];
|
|
393
|
+
for (let p = 0; p < framed.length; p += 6) {
|
|
394
|
+
const i = framed[p];
|
|
395
|
+
const j = framed[p + 1];
|
|
396
|
+
if (TURN.disjoint(frames[i], frames[j], 0.1)) continue;
|
|
397
|
+
const g = framed[p + 2];
|
|
398
|
+
const R = g >= 0 ? cams[g].R : EYE;
|
|
399
|
+
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]];
|
|
400
|
+
const extent = (run) => {
|
|
401
|
+
const c = run.group >= 0 ? cams[run.group] : cams.world;
|
|
402
|
+
let lo = Infinity;
|
|
403
|
+
let hi = -Infinity;
|
|
404
|
+
for (let q = 0; q < run.cloud.length; q += 3) {
|
|
405
|
+
const x = run.cloud[q];
|
|
406
|
+
const y = run.cloud[q + 1];
|
|
407
|
+
const z = run.cloud[q + 2];
|
|
408
|
+
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]);
|
|
409
|
+
if (v < lo) lo = v;
|
|
410
|
+
if (v > hi) hi = v;
|
|
411
|
+
}
|
|
412
|
+
return [lo, hi];
|
|
413
|
+
};
|
|
414
|
+
const margin = extent(runs[j])[0] - extent(runs[i])[1];
|
|
415
|
+
if (margin < -0.08) result.unseparated.push({ layer: layer.name, state: tag, a: runs[i].name, b: runs[j].name, margin, framed: true });
|
|
416
|
+
}
|
|
417
|
+
for (const gp of layer.groupPairs ?? []) {
|
|
418
|
+
const R = gp.frame >= 0 ? cams[gp.frame].R : EYE;
|
|
419
|
+
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]];
|
|
420
|
+
const along = (list, high) => {
|
|
421
|
+
let best = high ? -Infinity : Infinity;
|
|
422
|
+
for (const index of list) {
|
|
423
|
+
const run = runs[index];
|
|
424
|
+
const c = run.group >= 0 ? cams[run.group] : cams.world;
|
|
425
|
+
for (let q = 0; q < run.cloud.length; q += 3) {
|
|
426
|
+
const x = run.cloud[q];
|
|
427
|
+
const y = run.cloud[q + 1];
|
|
428
|
+
const z = run.cloud[q + 2];
|
|
429
|
+
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]);
|
|
430
|
+
best = high ? Math.max(best, v) : Math.min(best, v);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
return best;
|
|
434
|
+
};
|
|
435
|
+
const margin = along(gp.b, false) - along(gp.a, true);
|
|
436
|
+
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 });
|
|
437
|
+
}
|
|
438
|
+
const n = layer.parts.length;
|
|
439
|
+
const local = layer.local;
|
|
440
|
+
const adj = Array.from({ length: n }, () => []);
|
|
441
|
+
const indeg = new Int32Array(n);
|
|
442
|
+
for (const [i, j] of pairsOf(layer)) {
|
|
443
|
+
if (TURN.disjoint(frames[i], frames[j], 0.1)) continue;
|
|
444
|
+
const ri = runs[i];
|
|
445
|
+
const rj = runs[j];
|
|
446
|
+
if (stats.unseparated && ri.group !== rj.group) {
|
|
447
|
+
const ca = ri.group >= 0 ? cams[ri.group] : cams.world;
|
|
448
|
+
const cb = rj.group >= 0 ? cams[rj.group] : cams.world;
|
|
449
|
+
const hit = TURN.gjk(ri.cloud, ca, rj.cloud, cb, null, 32, TURN.hintsOf(ri, ca, rj, cb, cams.world));
|
|
450
|
+
if (hit.margin < -0.08) result.unseparated.push({ layer: layer.name, state: tag, a: ri.name, b: rj.name, margin: hit.margin });
|
|
451
|
+
}
|
|
452
|
+
const s = relationOf(layer, i, j, cams);
|
|
453
|
+
if (s < 1e-7 && s > -1e-7) continue;
|
|
454
|
+
const from = s > 0 ? local.get(i) : local.get(j);
|
|
455
|
+
const to = s > 0 ? local.get(j) : local.get(i);
|
|
456
|
+
adj[from].push(to);
|
|
457
|
+
indeg[to]++;
|
|
458
|
+
}
|
|
459
|
+
const queue = [];
|
|
460
|
+
for (let v = 0; v < n; v++) if (!indeg[v]) queue.push(v);
|
|
461
|
+
let seen = 0;
|
|
462
|
+
while (queue.length) {
|
|
463
|
+
const v = queue.pop();
|
|
464
|
+
seen++;
|
|
465
|
+
for (const w of adj[v]) if (--indeg[w] === 0) queue.push(w);
|
|
466
|
+
}
|
|
467
|
+
if (seen < n) {
|
|
468
|
+
const stuck = [];
|
|
469
|
+
for (let v = 0; v < n; v++) if (indeg[v] > 0) stuck.push(runs[layer.parts[v]].name);
|
|
470
|
+
let split = null;
|
|
471
|
+
for (const m of T.built?.report?.margins ?? []) if (stuck.includes(m.a) && stuck.includes(m.b) && (!split || m.margin > split.margin)) split = m;
|
|
472
|
+
const plane = split && T.built.planesByName ? T.built.planesByName.get(`${split.a}|${split.b}`) : null;
|
|
473
|
+
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 });
|
|
474
|
+
}
|
|
475
|
+
});
|
|
476
|
+
if (record) record(cams, frames, orders);
|
|
477
|
+
return { cams, frames, orders };
|
|
478
|
+
};
|
|
479
|
+
const swapsOf = (a, b) => {
|
|
480
|
+
const list = [];
|
|
481
|
+
scene.layers.forEach((layer, index) => {
|
|
482
|
+
const pa = a.orders[index];
|
|
483
|
+
const pb = b.orders[index];
|
|
484
|
+
const rankA = new Map();
|
|
485
|
+
pa.forEach((g, at) => rankA.set(g, at));
|
|
486
|
+
const rankB = new Map();
|
|
487
|
+
pb.forEach((g, at) => rankB.set(g, at));
|
|
488
|
+
const moved = new Set();
|
|
489
|
+
for (let at = 0; at < pb.length; at++) if (pa[at] !== pb[at]) moved.add(pb[at]);
|
|
490
|
+
const moving = [...moved];
|
|
491
|
+
for (let x = 0; x < moving.length; x++)
|
|
492
|
+
for (const j of layer.parts) {
|
|
493
|
+
const i = moving[x];
|
|
494
|
+
if (i === j || (moved.has(j) && j < i)) continue;
|
|
495
|
+
if ((rankA.get(i) < rankA.get(j)) === (rankB.get(i) < rankB.get(j))) continue;
|
|
496
|
+
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;
|
|
497
|
+
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]));
|
|
498
|
+
list.push({ layer: layer.name, i, j, area });
|
|
499
|
+
}
|
|
500
|
+
});
|
|
501
|
+
return list;
|
|
502
|
+
};
|
|
503
|
+
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 }));
|
|
504
|
+
const flush = new Set();
|
|
505
|
+
for (const m of T.built?.report?.margins ?? []) if (Math.abs(m.margin) < 0.05) flush.add(`${m.a}|${m.b}`);
|
|
506
|
+
const flushOf = (a, b) => flush.has(`${a}|${b}`) || flush.has(`${b}|${a}`);
|
|
507
|
+
const changes = [];
|
|
508
|
+
let orderStates = 0;
|
|
509
|
+
for (const family of wants("order") ? families : []) {
|
|
510
|
+
const xs = samplesOf(family, frameOf(family));
|
|
511
|
+
say(`order scan ${family.name}: ${xs.length} states`);
|
|
512
|
+
let previous = null;
|
|
513
|
+
const local = [];
|
|
514
|
+
for (const x of xs) {
|
|
515
|
+
const values = family.at(x);
|
|
516
|
+
const tag = label(family, x);
|
|
517
|
+
const state = checkState(values, tag, previous ? previous.orders : null);
|
|
518
|
+
orderStates++;
|
|
519
|
+
const snapshot = { orders: state.orders.map((o) => Int32Array.from(o)), frames: clone(state.frames), x };
|
|
520
|
+
if (previous) {
|
|
521
|
+
const swaps = swapsOf(previous, snapshot);
|
|
522
|
+
for (const swap of swaps) {
|
|
523
|
+
if (swap.area <= 0.25) continue;
|
|
524
|
+
const layerIndex = scene.layers.findIndex((l) => l.name === swap.layer);
|
|
525
|
+
const layer = scene.layers[layerIndex];
|
|
526
|
+
const prior = previous.orders[layerIndex];
|
|
527
|
+
const probe = (value) => {
|
|
528
|
+
const { cams, frames } = emitAll(family.at(value));
|
|
529
|
+
const order = TURN.order(layer, frames, cams, prior, {}, scene);
|
|
530
|
+
const at = new Map();
|
|
531
|
+
order.forEach((g, index) => at.set(g, index));
|
|
532
|
+
return { first: at.get(swap.i) < at.get(swap.j), frames };
|
|
533
|
+
};
|
|
534
|
+
const start = probe(previous.x).first;
|
|
535
|
+
let lo = previous.x;
|
|
536
|
+
let hi = x;
|
|
537
|
+
let frames = null;
|
|
538
|
+
for (let it = 0; it < 16; it++) {
|
|
539
|
+
const mid = (lo + hi) / 2;
|
|
540
|
+
const next = probe(mid);
|
|
541
|
+
if (next.first === start) lo = mid;
|
|
542
|
+
else {
|
|
543
|
+
hi = mid;
|
|
544
|
+
frames = next.frames;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
if (!frames) frames = probe(hi).frames;
|
|
548
|
+
const area = overlapArea(runs[swap.i], frames[swap.i], runs[swap.j], frames[swap.j]);
|
|
549
|
+
const built = flushOf(runs[swap.i].name, runs[swap.j].name);
|
|
550
|
+
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)}` });
|
|
551
|
+
}
|
|
552
|
+
if (swaps.length) local.push({ family, x, count: swaps.length, pairs: swaps });
|
|
553
|
+
}
|
|
554
|
+
previous = snapshot;
|
|
555
|
+
}
|
|
556
|
+
changes.push(...local);
|
|
557
|
+
const share = Math.round((quick ? Math.min(200, random) : random) / families.length);
|
|
558
|
+
let seed = 12345 + families.indexOf(family);
|
|
559
|
+
const rand = () => ((seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648);
|
|
560
|
+
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))]);
|
|
561
|
+
say(`order scan ${family.name}: ${share} random states, ${events.length} event states`);
|
|
562
|
+
for (let i = 0; i < share; i++) {
|
|
563
|
+
const x = family.from + rand() * (family.to - family.from);
|
|
564
|
+
checkState(family.at(x), `random ${label(family, x)}`, null);
|
|
565
|
+
}
|
|
566
|
+
for (const x of events.slice(0, quick ? 100 : 4000)) checkState(family.at(x), `event ${label(family, x)}`, null);
|
|
567
|
+
orderStates += share + Math.min(events.length, quick ? 100 : 4000);
|
|
568
|
+
}
|
|
569
|
+
for (const values of wants("order") ? extra.slice(0, quick ? 100 : extra.length) : []) checkState(values, `pose ${JSON.stringify(values).slice(0, 60)}`, null);
|
|
570
|
+
result.stats.orderStates = orderStates + extra.length;
|
|
571
|
+
result.stats.changes = changes.length;
|
|
572
|
+
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 }));
|
|
573
|
+
say("rigid");
|
|
574
|
+
const mounts = A.mountsOf(R.tubes, R.solids);
|
|
575
|
+
if (wants("rigid")) {
|
|
576
|
+
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)}` });
|
|
577
|
+
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}` });
|
|
578
|
+
const held = A.supports(R.solids, R.tubes);
|
|
579
|
+
for (const name of held.loose) result.rigid.push({ kind: "support", text: `loose ${name}` });
|
|
580
|
+
for (const name of held.hanging ?? []) result.rigid.push({ kind: "support", text: `hanging ${name}` });
|
|
581
|
+
for (const d of held.sunk) result.rigid.push({ kind: "support", text: `sunk ${d.a} × ${d.b} ${f2(d.depth)} deep` });
|
|
582
|
+
for (const c of A.solidClearances(R.solids)) result.rigid.push({ kind: "solid", text: `${c.tube} × ${c.other} sunk ${f2(-c.clearance)}` });
|
|
583
|
+
}
|
|
584
|
+
say("swept");
|
|
585
|
+
const liveShapes = [];
|
|
586
|
+
for (const part of T.parts) for (const shape of part.shapes) liveShapes.push({ shape, part, group: part.group });
|
|
587
|
+
const liveSet = new Set(liveShapes.map((entry) => entry.shape));
|
|
588
|
+
const staticShapes = [];
|
|
589
|
+
for (const item of R.items) for (const shape of item.shapes ?? []) if (!liveSet.has(shape)) staticShapes.push({ shape, item });
|
|
590
|
+
const pointsCache = new Map();
|
|
591
|
+
const pointsOf = (shape) => {
|
|
592
|
+
if (!pointsCache.has(shape)) pointsCache.set(shape, surfacePoints(shape));
|
|
593
|
+
return pointsCache.get(shape);
|
|
594
|
+
};
|
|
595
|
+
for (const g of wants("swept") ? groups : []) {
|
|
596
|
+
if (g.kind !== "turn" || g.parent) continue;
|
|
597
|
+
const mine = liveShapes.filter((entry) => entry.group === g);
|
|
598
|
+
const slices = new Map();
|
|
599
|
+
for (const { shape } of mine)
|
|
600
|
+
for (const p of pointsOf(shape)) {
|
|
601
|
+
const band = Math.floor(p[2] * 2);
|
|
602
|
+
const r = Math.hypot(p[0] - g.origin[0], p[1] - g.origin[1]);
|
|
603
|
+
slices.set(band, Math.max(slices.get(band) ?? 0, r));
|
|
604
|
+
}
|
|
605
|
+
const flagged = [];
|
|
606
|
+
for (const { shape, item } of staticShapes) {
|
|
607
|
+
let worst = Infinity;
|
|
608
|
+
for (const p of pointsOf(shape)) {
|
|
609
|
+
const r = Math.hypot(p[0] - g.origin[0], p[1] - g.origin[1]);
|
|
610
|
+
for (const band of [Math.floor(p[2] * 2), Math.floor(p[2] * 2 - 0.2), Math.floor(p[2] * 2 + 0.2)]) {
|
|
611
|
+
const reach = slices.get(band);
|
|
612
|
+
if (reach !== undefined) worst = Math.min(worst, r - reach);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
if (worst < -0.1) flagged.push({ shape, item });
|
|
616
|
+
}
|
|
617
|
+
for (const { shape, item } of flagged) {
|
|
618
|
+
let deepest = 0;
|
|
619
|
+
let where = 0;
|
|
620
|
+
const count = quick ? 90 : 720;
|
|
621
|
+
for (let i = 0; i < count; i++) {
|
|
622
|
+
const angle = (i * 360) / count;
|
|
623
|
+
const cams = TURN.cams(scene, { [g.name]: angle });
|
|
624
|
+
const pose = poseFor(cams, g);
|
|
625
|
+
const b = A.boundsOf(shape);
|
|
626
|
+
for (const { shape: other } of mine)
|
|
627
|
+
for (const p of pointsOf(other)) {
|
|
628
|
+
const q = place(pose, p);
|
|
629
|
+
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;
|
|
630
|
+
const d = A.sdf(shape, q);
|
|
631
|
+
if (d < deepest) {
|
|
632
|
+
deepest = d;
|
|
633
|
+
where = angle;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
if (deepest < -0.1) result.swept.push({ text: `${item.name} × ${g.name} sweep: ${f2(-deepest)} deep at ${f2(where)}°` });
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
say("posed");
|
|
641
|
+
const posedStates = [];
|
|
642
|
+
for (const family of families) {
|
|
643
|
+
const frame = posedStep ?? (family.periodic ? (quick ? 4 : 0.5) : family.frame * (quick ? 4 : 1));
|
|
644
|
+
for (const x of samplesOf(family, frame)) posedStates.push({ values: family.at(x), tag: label(family, x) });
|
|
645
|
+
}
|
|
646
|
+
for (const values of extra.slice(0, quick ? 40 : extra.length)) posedStates.push({ values, tag: "pose" });
|
|
647
|
+
const entries = [
|
|
648
|
+
...liveShapes.map((entry) => ({ ...entry, kind: "live", sphere: sphereOf(pointsOf(entry.shape)) })),
|
|
649
|
+
...staticShapes.map((entry) => ({ shape: entry.shape, part: { name: entry.item.name }, group: "static", item: entry.item, kind: "static", sphere: sphereOf(pointsOf(entry.shape)) })),
|
|
650
|
+
];
|
|
651
|
+
const posedSeen = new Set();
|
|
652
|
+
const movingGroups = groups.filter((g) => !(g.kind === "turn" && !g.parent));
|
|
653
|
+
const needsStatic = movingGroups.length > 0;
|
|
654
|
+
for (const { values, tag } of wants("posed") ? posedStates : []) {
|
|
655
|
+
const cams = TURN.cams(scene, values);
|
|
656
|
+
const placed = entries.map((entry) => {
|
|
657
|
+
if (entry.kind === "static") return { entry, c: entry.sphere.c, pose: null };
|
|
658
|
+
const pose = poseFor(cams, entry.group);
|
|
659
|
+
return { entry, c: entry.group ? place(pose, entry.sphere.c) : entry.sphere.c, pose: entry.group ? pose : null };
|
|
660
|
+
});
|
|
661
|
+
placed.sort((a, b) => a.c[0] - a.entry.sphere.r - (b.c[0] - b.entry.sphere.r));
|
|
662
|
+
for (let i = 0; i < placed.length; i++) {
|
|
663
|
+
const A0 = placed[i];
|
|
664
|
+
const reachA = A0.c[0] + A0.entry.sphere.r + 0.5;
|
|
665
|
+
for (let j = i + 1; j < placed.length; j++) {
|
|
666
|
+
const B0 = placed[j];
|
|
667
|
+
if (B0.c[0] - B0.entry.sphere.r > reachA) break;
|
|
668
|
+
const a = A0.entry;
|
|
669
|
+
const b = B0.entry;
|
|
670
|
+
if (a.group === b.group) continue;
|
|
671
|
+
if (a.kind === "static" && b.kind === "static") continue;
|
|
672
|
+
if ((a.kind === "static" || b.kind === "static") && !needsStatic) continue;
|
|
673
|
+
if (a.kind === "static" || b.kind === "static") {
|
|
674
|
+
const live = a.kind === "static" ? b : a;
|
|
675
|
+
if (!live.group || (live.group.kind === "turn" && !live.group.parent)) continue;
|
|
676
|
+
}
|
|
677
|
+
if (a.shape.kind === "tube" && b.shape.kind === "tube") continue;
|
|
678
|
+
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;
|
|
679
|
+
const key = `${a.part.name}|${b.part.name}`;
|
|
680
|
+
if (posedSeen.has(key)) continue;
|
|
681
|
+
const pa = A0.pose ?? IDENTITY;
|
|
682
|
+
const pb = B0.pose ?? IDENTITY;
|
|
683
|
+
let deepest = 0;
|
|
684
|
+
for (const p of pointsOf(a.shape)) {
|
|
685
|
+
const d = A.sdf(b.shape, unplace(pb, place(pa, p)));
|
|
686
|
+
if (d < deepest) deepest = d;
|
|
687
|
+
}
|
|
688
|
+
for (const p of pointsOf(b.shape)) {
|
|
689
|
+
const d = A.sdf(a.shape, unplace(pa, place(pb, p)));
|
|
690
|
+
if (d < deepest) deepest = d;
|
|
691
|
+
}
|
|
692
|
+
const limit = a.shape.kind === "tube" || b.shape.kind === "tube" ? -0.1 : -0.5;
|
|
693
|
+
if (deepest < limit) {
|
|
694
|
+
posedSeen.add(key);
|
|
695
|
+
result.posed.push({ text: `${a.part.name} × ${b.part.name} through ${f2(-deepest)} at ${tag}` });
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
result.stats.posedStates = posedStates.length;
|
|
701
|
+
say("folds, self, crowding, endon");
|
|
702
|
+
const routes = T.routes ?? [];
|
|
703
|
+
const turnGroups = groups.filter((g) => g.kind === "turn" && !g.parent);
|
|
704
|
+
const tubesAll = R.tubes;
|
|
705
|
+
const linkMap = A.linksOf(tubesAll, mounts);
|
|
706
|
+
const routeSample = [];
|
|
707
|
+
{
|
|
708
|
+
const all = posedStates.filter((s) => s.tag !== "pose");
|
|
709
|
+
const stride = Math.max(1, Math.floor(all.length / (quick ? 48 : routeStates)));
|
|
710
|
+
for (let i = 0; i < all.length; i += stride) routeSample.push(all[i]);
|
|
711
|
+
for (const values of extra.slice(0, 24)) routeSample.push({ values, tag: "pose" });
|
|
712
|
+
}
|
|
713
|
+
const foldSeen = new Set();
|
|
714
|
+
const crowdSeen = new Set();
|
|
715
|
+
const lo = (E * 180) / Math.PI;
|
|
716
|
+
for (const route of wants("routes") ? routes : []) {
|
|
717
|
+
const g = route.group;
|
|
718
|
+
const root = g && g.kind === "turn" && !g.parent;
|
|
719
|
+
const records = R.routes.filter((r) => r.name === route.name);
|
|
720
|
+
const groupParts = T.parts.filter((part) => part.group === g);
|
|
721
|
+
const itemsAt = (pose) => groupParts.map((part) => ({ name: part.name, route: part.route, shapes: pose ? part.shapes.map((shape) => posed(shape, pose)) : part.shapes }));
|
|
722
|
+
const views = [];
|
|
723
|
+
if (!g) views.push({ P, pose: null, tag: "rest" });
|
|
724
|
+
else if (root) {
|
|
725
|
+
const count = quick ? 72 : 720;
|
|
726
|
+
for (let i = 0; i < count; i++) {
|
|
727
|
+
const x = (i * 360) / count;
|
|
728
|
+
views.push({ P: { ...P, azimuth: (P.azimuth ?? 45) - x }, pose: null, tag: `${f2(x)}°` });
|
|
729
|
+
}
|
|
730
|
+
} else for (const { values, tag } of routeSample) views.push({ P, pose: poseFor(TURN.cams(scene, values), g), tag });
|
|
731
|
+
for (const view of views) {
|
|
732
|
+
const posedRecords = view.pose ? records.map((r) => ({ ...r, points: r.points.map((p) => place(view.pose, p)) })) : records;
|
|
733
|
+
const items = itemsAt(view.pose);
|
|
734
|
+
for (const fold of A.folds(posedRecords, view.P, { items })) {
|
|
735
|
+
const key = `${fold.name}|${fold.kind}`;
|
|
736
|
+
if (foldSeen.has(key)) continue;
|
|
737
|
+
foldSeen.add(key);
|
|
738
|
+
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`}` });
|
|
739
|
+
}
|
|
740
|
+
for (const s of A.selfOverlaps(items.filter((item) => item.route === route.name), view.P)) {
|
|
741
|
+
const key = `self|${s.name}`;
|
|
742
|
+
if (foldSeen.has(key)) continue;
|
|
743
|
+
foldSeen.add(key);
|
|
744
|
+
result.self.push({ text: `${s.name} folds back at ${view.tag}: radius ${f2(s.radius)} px under its half-width ${f2(s.width)} px` });
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
const dense = route.world;
|
|
748
|
+
const total = G.pathLength(dense);
|
|
749
|
+
const stations = G.resample(dense, 0.25);
|
|
750
|
+
const rotations = !g || root || g.upright ? [null] : routeSample.map(({ values }) => poseFor(TURN.cams(scene, values), g).R);
|
|
751
|
+
const bad = [];
|
|
752
|
+
let walked = 0;
|
|
753
|
+
for (let i = 1; i < stations.length - 1; i++) {
|
|
754
|
+
walked += G.len3(G.sub3(stations[i], stations[i - 1]));
|
|
755
|
+
if (route.gaps.some(([a, b]) => walked >= a - 1e-6 && walked <= b + 1e-6)) continue;
|
|
756
|
+
if (fittings.some(([name, a, b]) => name === route.name && walked >= a && walked <= b)) continue;
|
|
757
|
+
const t0 = G.unit3(G.sub3(stations[i + 1], stations[i - 1]));
|
|
758
|
+
for (const Rm of rotations) {
|
|
759
|
+
const t = Rm ? apply(Rm, t0) : t0;
|
|
760
|
+
const phi = (Math.asin(Math.max(-1, Math.min(1, t[2]))) * 180) / Math.PI;
|
|
761
|
+
if ((phi > lo - 15 && phi < lo + 15) || (phi > -lo - 15 && phi < -lo + 15)) {
|
|
762
|
+
bad.push({ at: walked, phi });
|
|
763
|
+
break;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
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` });
|
|
768
|
+
}
|
|
769
|
+
const crowdViews = [];
|
|
770
|
+
for (const g of turnGroups) {
|
|
771
|
+
const count = quick ? 24 : 72;
|
|
772
|
+
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 });
|
|
773
|
+
}
|
|
774
|
+
const others = [...new Set(routes.map((route) => route.group).filter((g) => g && !(g.kind === "turn" && !g.parent)))];
|
|
775
|
+
for (const g of others) for (const { values, tag } of routeSample.slice(0, quick ? 24 : 72)) crowdViews.push({ group: g, P, tag, values });
|
|
776
|
+
for (const view of wants("routes") ? crowdViews : []) {
|
|
777
|
+
const g = view.group;
|
|
778
|
+
const pose = view.values ? poseFor(TURN.cams(scene, view.values), g) : null;
|
|
779
|
+
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));
|
|
780
|
+
if (!mineRoutes.length) continue;
|
|
781
|
+
const groupParts = T.parts.filter((part) => part.group === g);
|
|
782
|
+
const items = groupParts.map((part) => ({ name: part.name, route: part.route, shapes: pose ? part.shapes.map((shape) => posed(shape, pose)) : part.shapes }));
|
|
783
|
+
const rods = A.slenderOf(items.flatMap((item) => item.shapes).filter((shape) => shape.kind !== "tube"));
|
|
784
|
+
for (const c of A.crowding(mineRoutes, view.P, { links: linkMap, items, allow: crowd, rods })) {
|
|
785
|
+
const key = [c.a, c.b, c.where].join("|");
|
|
786
|
+
if (crowdSeen.has(key)) continue;
|
|
787
|
+
crowdSeen.add(key);
|
|
788
|
+
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` });
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
say("coverage");
|
|
792
|
+
const rest = emitAll(Object.fromEntries(groups.map((g) => [g.name, g.kind === "free" ? null : 0])));
|
|
793
|
+
const coverItems = T.parts.map((part) => {
|
|
794
|
+
const run = runs[part.id];
|
|
795
|
+
const f = rest.frames[part.id];
|
|
796
|
+
const outline = run.kind === "fixed" ? part.svg : run.kind === "tube" ? f.d[run.role.body] : f.d[run.role.fill];
|
|
797
|
+
return { name: part.name, svg: `<path d="${outline}"/>`, shapes: part.shapes };
|
|
798
|
+
});
|
|
799
|
+
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` });
|
|
800
|
+
say("continuity");
|
|
801
|
+
if (wants("continuity")) {
|
|
802
|
+
TURN.exact(true);
|
|
803
|
+
try {
|
|
804
|
+
continuity(T, scene, runs, groups, { step, quick, result, say, families, frameOf });
|
|
805
|
+
} finally {
|
|
806
|
+
TURN.exact(false);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
say("depth oracle");
|
|
810
|
+
const depthStates = [];
|
|
811
|
+
const first = families[0];
|
|
812
|
+
if (first) for (let i = 0; i < (quick ? 6 : depthAngles); i++) {
|
|
813
|
+
const x = first.from + ((first.to - first.from) * i) / (quick ? 6 : depthAngles);
|
|
814
|
+
depthStates.push({ values: first.at(x), tag: label(first, x) });
|
|
815
|
+
}
|
|
816
|
+
for (const family of families.slice(1)) for (let i = 0; i <= (quick ? 3 : 12); i++) {
|
|
817
|
+
const x = family.from + ((family.to - family.from) * i) / (quick ? 3 : 12);
|
|
818
|
+
depthStates.push({ values: family.at(x), tag: label(family, x) });
|
|
819
|
+
}
|
|
820
|
+
for (const change of result.changes.slice(0, quick ? 4 : depthEvents)) depthStates.push({ values: change.at(change.x), tag: `${change.family} ${f3(change.x)}` });
|
|
821
|
+
let checked = 0;
|
|
822
|
+
for (const { values, tag } of wants("depth") ? depthStates : []) {
|
|
823
|
+
const state = checkState(values, `depth ${tag}`, null);
|
|
824
|
+
scene.layers.forEach((layer, index) => {
|
|
825
|
+
const items = [];
|
|
826
|
+
for (const global of layer.parts) {
|
|
827
|
+
const part = T.parts[global];
|
|
828
|
+
const pose = poseFor(state.cams, part.group);
|
|
829
|
+
items.push({ name: part.name, global, shapes: part.shapes.map((shape) => posed(shape, pose)), route: part.route, chunk: part.chunk });
|
|
830
|
+
}
|
|
831
|
+
const ranks = new Map((T.order.length ? T.order : T.layers.map((l) => l.name)).map((name, at) => [name, at]));
|
|
832
|
+
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 });
|
|
833
|
+
const overlaps = A.overlapsOf(items, P, { step: quick ? 0.5 : 0.25, skip: (a, b) => A.sameRun(a, b) || (a.static && b.static) });
|
|
834
|
+
for (const pair of overlaps.pairs) {
|
|
835
|
+
const a = items[pair.a];
|
|
836
|
+
const b = items[pair.b];
|
|
837
|
+
if (!a.static === !b.static) continue;
|
|
838
|
+
const sideIndex = a.static ? 0 : 1;
|
|
839
|
+
const fixed = a.static ? a : b;
|
|
840
|
+
const moving = a.static ? b : a;
|
|
841
|
+
const staticFront = pair.front[sideIndex].area >= overlaps.least;
|
|
842
|
+
const liveFront = pair.front[1 - sideIndex].area >= overlaps.least;
|
|
843
|
+
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²` });
|
|
844
|
+
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²` });
|
|
845
|
+
}
|
|
846
|
+
const rank = new Map();
|
|
847
|
+
state.orders[index].forEach((global, at) => rank.set(global, at));
|
|
848
|
+
for (const pair of overlaps.pairs) {
|
|
849
|
+
if (items[pair.a].static || items[pair.b].static) continue;
|
|
850
|
+
checked++;
|
|
851
|
+
const aFront = pair.front[0].area >= overlaps.least;
|
|
852
|
+
const bFront = pair.front[1].area >= overlaps.least;
|
|
853
|
+
if (aFront === bFront) continue;
|
|
854
|
+
const front = items[aFront ? pair.a : pair.b];
|
|
855
|
+
const backItem = items[aFront ? pair.b : pair.a];
|
|
856
|
+
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²)` });
|
|
857
|
+
}
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
result.stats.depthStates = depthStates.length;
|
|
861
|
+
result.stats.depthPairs = checked;
|
|
862
|
+
result.stats.seconds = (Date.now() - started) / 1000;
|
|
863
|
+
return result;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
function continuity(T, scene, runs, groups, { step, quick, result, say, families = [], frameOf = (f) => f.frame }) {
|
|
867
|
+
const stepSize = quick ? step * 10 : step;
|
|
868
|
+
const measures = [];
|
|
869
|
+
const worst = { q: 0, birth: 0, dot: 0, fade: 0, motion: 0, merge: 0 };
|
|
870
|
+
const failures = [];
|
|
871
|
+
const k = scene.P.scale * Math.sqrt(1.6);
|
|
872
|
+
const sweeps = [];
|
|
873
|
+
for (const g of groups) {
|
|
874
|
+
if (g.loose) continue;
|
|
875
|
+
if (g.kind === "turn") sweeps.push({ group: g, from: 0, to: 360, unit: "°" });
|
|
876
|
+
else if (g.kind === "hinge") sweeps.push({ group: g, from: g.range[0], to: g.range[1], unit: "°" });
|
|
877
|
+
else sweeps.push({ group: g, from: 0, to: g.travel, unit: "" });
|
|
878
|
+
}
|
|
879
|
+
for (const sweep of sweeps) {
|
|
880
|
+
const g = sweep.group;
|
|
881
|
+
const parts = runs.filter((run) => run.group === g.index);
|
|
882
|
+
if (!parts.length) continue;
|
|
883
|
+
const span = sweep.to - sweep.from;
|
|
884
|
+
const delta = g.kind === "slide" ? Math.max(0.01, span / 400) : stepSize;
|
|
885
|
+
const count = Math.max(1, Math.round(span / delta));
|
|
886
|
+
const reach = parts.map((run) => {
|
|
887
|
+
let r = 0;
|
|
888
|
+
const part = T.parts[run.index];
|
|
889
|
+
for (const p of part.samples) r = Math.max(r, g.kind === "slide" ? 0 : Math.hypot(p[0], p[1]));
|
|
890
|
+
return r;
|
|
891
|
+
});
|
|
892
|
+
const valuesAt = (x) => ({ [g.name]: x });
|
|
893
|
+
const emitPart = (run, x, f) => {
|
|
894
|
+
const cams = TURN.cams(scene, valuesAt(x));
|
|
895
|
+
TURN.emit(run, cams[g.index], f);
|
|
896
|
+
return f;
|
|
897
|
+
};
|
|
898
|
+
say(`continuity ${g.name}: ${count} steps of ${delta}${sweep.unit} over ${parts.length} parts`);
|
|
899
|
+
const snap = (run, f) => {
|
|
900
|
+
const roles = run.roles;
|
|
901
|
+
const out = { faces: [], sheets: [], dots: [], planes: [], fade: {}, outline: null, levels: f.levels ?? 0 };
|
|
902
|
+
roles.forEach((role, r) => {
|
|
903
|
+
if (role.type === "face") out.faces.push([f.d[r] ? areaOfPath(f.d[r]) : 0, f.q[r], Boolean(f.d[r])]);
|
|
904
|
+
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]);
|
|
905
|
+
else if (role.type === "dot") out.dots.push(f.o[r]);
|
|
906
|
+
else if (role.type === "plane") out.planes.push(f.o[r]);
|
|
907
|
+
});
|
|
908
|
+
for (const [prefix, set] of Object.entries(f.fade ?? {})) {
|
|
909
|
+
const map = new Map();
|
|
910
|
+
set.key.forEach((key, i) => map.set(key, Math.round(set.alpha[i] * TURN.levels) / TURN.levels));
|
|
911
|
+
out.fade[prefix] = map;
|
|
912
|
+
}
|
|
913
|
+
const edge = run.role.fill !== undefined ? f.d[run.role.fill] : run.role.body !== undefined ? f.d[run.role.body] : "";
|
|
914
|
+
out.outline = run.kind === "fixed" ? [Array.from({ length: f.hullN }, (_, i) => [f.hull[2 * i], f.hull[2 * i + 1]])] : pathPolys(edge);
|
|
915
|
+
out.slots = run.slots;
|
|
916
|
+
return out;
|
|
917
|
+
};
|
|
918
|
+
const compare = (run, a, b, dx, reachPx) => {
|
|
919
|
+
const m = { q: 0, birth: 0, dot: 0, fade: 0, motion: 0, merge: 0 };
|
|
920
|
+
for (let i = 0; i < Math.min(a.faces.length, b.faces.length); i++) {
|
|
921
|
+
const [areaA, qa, onA] = a.faces[i];
|
|
922
|
+
const [areaB, qb, onB] = b.faces[i];
|
|
923
|
+
if (onA && onB) m.q = Math.max(m.q, (Math.abs(qa - qb) * 16) / TURN.q);
|
|
924
|
+
if (onA !== onB) m.birth = Math.max(m.birth, onA ? areaA : areaB);
|
|
925
|
+
m.area = Math.max(m.area ?? 0, Math.abs(areaA - areaB));
|
|
926
|
+
}
|
|
927
|
+
for (let i = 0; i < Math.min(a.sheets.length, b.sheets.length); i++) {
|
|
928
|
+
const [areaA, countA] = a.sheets[i];
|
|
929
|
+
const [areaB, countB] = b.sheets[i];
|
|
930
|
+
if (!countA !== !countB) m.birth = Math.max(m.birth, Math.abs(areaA - areaB));
|
|
931
|
+
m.area = Math.max(m.area ?? 0, Math.abs(areaA - areaB));
|
|
932
|
+
}
|
|
933
|
+
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]));
|
|
934
|
+
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]));
|
|
935
|
+
for (const prefix of new Set([...Object.keys(a.fade), ...Object.keys(b.fade)])) {
|
|
936
|
+
const A0 = a.fade[prefix] ?? new Map();
|
|
937
|
+
const B0 = b.fade[prefix] ?? new Map();
|
|
938
|
+
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)));
|
|
939
|
+
}
|
|
940
|
+
const slots = Math.max(run.slots.bevel ?? 0, run.slots.ribs ?? 0);
|
|
941
|
+
if (slots && (a.levels > slots || b.levels > slots)) m.merge = Math.max(a.levels, b.levels) - slots;
|
|
942
|
+
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));
|
|
943
|
+
return m;
|
|
944
|
+
};
|
|
945
|
+
const frames = parts.map((run) => TURN.frame(run));
|
|
946
|
+
let previous = parts.map((run, i) => snap(run, emitPart(run, sweep.from, frames[i])));
|
|
947
|
+
for (let c = 1; c <= count; c++) {
|
|
948
|
+
const x = sweep.from + c * delta;
|
|
949
|
+
parts.forEach((run, i) => {
|
|
950
|
+
const current = snap(run, emitPart(run, x, frames[i]));
|
|
951
|
+
const reachPx = g.kind === "slide" ? k : (reach[i] * k * Math.PI) / 180;
|
|
952
|
+
const m = compare(run, previous[i], current, g.kind === "slide" ? delta : delta, reachPx);
|
|
953
|
+
for (const key of Object.keys(worst)) worst[key] = Math.max(worst[key], m[key] ?? 0);
|
|
954
|
+
const scale = delta / step;
|
|
955
|
+
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 });
|
|
956
|
+
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 });
|
|
957
|
+
previous[i] = current;
|
|
958
|
+
});
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
measures.sort((a, b) => b.value - a.value);
|
|
962
|
+
let halvingFails = 0;
|
|
963
|
+
let halvingChecked = 0;
|
|
964
|
+
const halving = (run, at, x, dx) => {
|
|
965
|
+
const emitAt = (value) => {
|
|
966
|
+
const f = TURN.frame(run);
|
|
967
|
+
const cams = TURN.cams(scene, at(value));
|
|
968
|
+
TURN.emit(run, run.group >= 0 ? cams[run.group] : cams.world, f);
|
|
969
|
+
return f;
|
|
970
|
+
};
|
|
971
|
+
const frames = Array.from({ length: 9 }, (_, j) => emitAt(x + (dx * j) / 8));
|
|
972
|
+
const measure = (scale) => {
|
|
973
|
+
const eighth = Math.max(...visibleChange(run, frames, scale));
|
|
974
|
+
const quarter = Math.max(...visibleChange(run, [0, 2, 4, 6, 8].map((j) => frames[j]), scale));
|
|
975
|
+
const half = Math.max(...visibleChange(run, [frames[0], frames[4], frames[8]], scale));
|
|
976
|
+
const full = visibleChange(run, [frames[0], frames[8]], scale)[0];
|
|
977
|
+
return { full, half, quarter, eighth, fails: full >= 2 && half > 0.6 * full && quarter > 0.6 * half && eighth > 0.6 * quarter && eighth >= 2 };
|
|
978
|
+
};
|
|
979
|
+
const coarse = measure(4);
|
|
980
|
+
return coarse.fails ? measure(16) : coarse;
|
|
981
|
+
};
|
|
982
|
+
const seen = new Set();
|
|
983
|
+
for (const entry of measures.slice(0, quick ? 40 : 400)) {
|
|
984
|
+
if (!(entry.value > 1e-6)) break;
|
|
985
|
+
const key = `${entry.run.index}|${entry.x.toFixed(4)}`;
|
|
986
|
+
if (seen.has(key)) continue;
|
|
987
|
+
seen.add(key);
|
|
988
|
+
halvingChecked++;
|
|
989
|
+
const g = groups[entry.run.group];
|
|
990
|
+
const h = halving(entry.run, entry.valuesAt, entry.x, entry.dx);
|
|
991
|
+
if (!h.fails) continue;
|
|
992
|
+
halvingFails++;
|
|
993
|
+
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` } });
|
|
994
|
+
}
|
|
995
|
+
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);
|
|
996
|
+
const clipSteps = [];
|
|
997
|
+
for (const family of families) {
|
|
998
|
+
if (family.periodic) continue;
|
|
999
|
+
const xs = samplesOf(family, frameOf(family));
|
|
1000
|
+
say(`continuity ${family.name}: ${xs.length} frames over ${runs.length} parts`);
|
|
1001
|
+
const frames = runs.map((run) => TURN.frame(run));
|
|
1002
|
+
let previous = null;
|
|
1003
|
+
for (const x of xs) {
|
|
1004
|
+
const cams = TURN.cams(scene, family.at(x));
|
|
1005
|
+
const current = runs.map((run, index) => {
|
|
1006
|
+
TURN.emit(run, run.group >= 0 ? cams[run.group] : cams.world, frames[index]);
|
|
1007
|
+
return areaRoles(run).map((r) => areaOfPath(frames[index].d[r]));
|
|
1008
|
+
});
|
|
1009
|
+
if (previous)
|
|
1010
|
+
runs.forEach((run, index) => {
|
|
1011
|
+
let most = 0;
|
|
1012
|
+
let role = -1;
|
|
1013
|
+
current[index].forEach((v, k) => {
|
|
1014
|
+
const d = Math.abs(v - previous[index][k]);
|
|
1015
|
+
if (d > most) {
|
|
1016
|
+
most = d;
|
|
1017
|
+
role = k;
|
|
1018
|
+
}
|
|
1019
|
+
});
|
|
1020
|
+
if (most >= 0.5) clipSteps.push({ family, run, x: x - frameOf(family), dx: frameOf(family), role, value: most });
|
|
1021
|
+
});
|
|
1022
|
+
previous = current;
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
clipSteps.sort((a, b) => b.value - a.value);
|
|
1026
|
+
let clipChecked = 0;
|
|
1027
|
+
for (const entry of clipSteps.slice(0, quick ? 60 : 400)) {
|
|
1028
|
+
clipChecked++;
|
|
1029
|
+
const h = halving(entry.run, entry.family.at, entry.x, entry.dx);
|
|
1030
|
+
if (!h.fails) continue;
|
|
1031
|
+
halvingFails++;
|
|
1032
|
+
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` } });
|
|
1033
|
+
}
|
|
1034
|
+
result.continuity = failures;
|
|
1035
|
+
result.stats.continuity = { worst, halvingChecked: halvingChecked + clipChecked, halvingFails, measures: measures.length + clipSteps.length };
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
export function orbitReport(result) {
|
|
1039
|
+
const lines = [];
|
|
1040
|
+
const skipped = (name, items) => (result.skipped ?? []).includes(name) && !items.length;
|
|
1041
|
+
const list = (name, items, text) => {
|
|
1042
|
+
if (skipped(name, items)) return lines.push(`${name}: skipped`);
|
|
1043
|
+
lines.push(`${name}: ${items.length}`);
|
|
1044
|
+
for (const item of items.slice(0, 40)) lines.push(` ${text(item)}`);
|
|
1045
|
+
};
|
|
1046
|
+
list("separation", result.separation, (m) => `interlock ${m.a} × ${m.b} ${f2(m.margin)}: split, flats or a flush contact`);
|
|
1047
|
+
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())" : ""}`);
|
|
1048
|
+
const cycleGroups = new Map();
|
|
1049
|
+
for (const c of result.cycles) {
|
|
1050
|
+
const key = `${c.layer}|${c.parts.join(", ")}`;
|
|
1051
|
+
if (!cycleGroups.has(key)) cycleGroups.set(key, { ...c, first: c.state, last: c.state, count: 0 });
|
|
1052
|
+
const g = cycleGroups.get(key);
|
|
1053
|
+
g.last = c.state;
|
|
1054
|
+
g.count++;
|
|
1055
|
+
}
|
|
1056
|
+
lines.push(skipped("cycles", result.cycles) ? "cycles: skipped" : `cycles: ${result.cycles.length}`);
|
|
1057
|
+
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"}`);
|
|
1058
|
+
list("forced", result.forced, (c) => `${c.state} ${c.layer}: ${c.forced} forced pick${c.forced === 1 ? "" : "s"}`);
|
|
1059
|
+
list("swaps", result.swaps, (s) => `${s.layer}: ${s.a} / ${s.b} swap at ${s.at} while overlapping ${f2(s.area)} px²`);
|
|
1060
|
+
list("layers", result.layers, (s) => s.text);
|
|
1061
|
+
list("rigid", result.rigid, (s) => `${s.kind} ${s.text}`);
|
|
1062
|
+
list("swept", result.swept, (s) => s.text);
|
|
1063
|
+
list("posed", result.posed, (s) => s.text);
|
|
1064
|
+
list("folds", result.folds, (s) => s.text);
|
|
1065
|
+
list("self", result.self, (s) => s.text);
|
|
1066
|
+
list("crowding", result.crowding, (s) => s.text);
|
|
1067
|
+
list("endon", result.endon, (s) => s.text);
|
|
1068
|
+
list("coverage", result.coverage, (s) => s.text);
|
|
1069
|
+
list("depth", result.depth, (s) => s.text);
|
|
1070
|
+
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(", ")}`);
|
|
1071
|
+
const s = result.stats;
|
|
1072
|
+
const w = s.continuity?.worst ?? {};
|
|
1073
|
+
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}`);
|
|
1074
|
+
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`);
|
|
1075
|
+
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("; ")})`);
|
|
1076
|
+
lines.push(`turn audit · ${f2(s.seconds ?? 0)} s`);
|
|
1077
|
+
return lines.join("\n");
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
export 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);
|
|
1081
|
+
|
|
1082
|
+
export function orbitOrExit(T, R, P, { flag = "--audit", argv = process.argv, ...options } = {}) {
|
|
1083
|
+
if (!argv.includes(flag)) return null;
|
|
1084
|
+
const result = orbit(T, R, P, options);
|
|
1085
|
+
console.log(orbitReport(result));
|
|
1086
|
+
const count = orbitFailures(result);
|
|
1087
|
+
const skipped = result.skipped.length;
|
|
1088
|
+
const partial = skipped ? `${skipped} line${skipped === 1 ? "" : "s"} skipped by only` : "";
|
|
1089
|
+
const undersampled = result.quick ? "quick run, undersampled: the full run is the proof" : "";
|
|
1090
|
+
const notes = [partial, undersampled].filter(Boolean).join("; ");
|
|
1091
|
+
if (count) console.log(`turn audit failed: ${count} problem${count === 1 ? "" : "s"}${notes ? ` (${notes})` : ""}`);
|
|
1092
|
+
else if (notes) console.log(`turn audit ${skipped ? "partial" : "quick"}: no problems in what ran (${notes})`);
|
|
1093
|
+
else console.log("turn audit passed");
|
|
1094
|
+
if (count) process.exitCode = 1;
|
|
1095
|
+
return result;
|
|
1096
|
+
}
|