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