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,306 @@
|
|
|
1
|
+
function lineEnds({ select = null, tolerance = 1, root: rootSlack = 2, mark = 7, least = 0.3, cover = 0.5, show = false } = {}) {
|
|
2
|
+
const plate = select == null ? document.querySelector(".iso-plate") || document.body : document.querySelector(select);
|
|
3
|
+
if (!plate) throw new Error(`Line target not found: ${select}`);
|
|
4
|
+
const box = plate.getBoundingClientRect();
|
|
5
|
+
const style = document.createElement("style");
|
|
6
|
+
style.textContent = `${select || ".iso-plate"} *{pointer-events:none!important}${select || ".iso-plate"} svg,${select || ".iso-plate"} svg *{pointer-events:visiblePainted!important}`;
|
|
7
|
+
document.head.append(style);
|
|
8
|
+
const skipClass = ["iso-edge", "iso-bevel", "iso-crease", "tb-edge"];
|
|
9
|
+
const svgs = [...(plate.matches("svg") ? [plate] : []), ...plate.querySelectorAll("svg")];
|
|
10
|
+
const shapes = svgs.flatMap((svg) => [...svg.querySelectorAll("path, line, polyline, polygon, circle, ellipse, rect")]).filter((el) => !el.closest("defs, clipPath, mask, pattern, marker, symbol"));
|
|
11
|
+
const opacityOf = (el) => {
|
|
12
|
+
let alpha = 1;
|
|
13
|
+
for (let node = el; node && node.nodeType === 1; node = node.parentElement) {
|
|
14
|
+
const cs = getComputedStyle(node);
|
|
15
|
+
if (cs.display === "none") return 0;
|
|
16
|
+
alpha *= Number(cs.opacity);
|
|
17
|
+
if (node === plate) break;
|
|
18
|
+
}
|
|
19
|
+
return getComputedStyle(el).visibility === "hidden" ? 0 : alpha;
|
|
20
|
+
};
|
|
21
|
+
const numbers = (text) => (text.match(/-?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?/gi) || []).map(Number);
|
|
22
|
+
const subpathsOf = (d) => {
|
|
23
|
+
const out = [];
|
|
24
|
+
let current = null;
|
|
25
|
+
let x = 0;
|
|
26
|
+
let y = 0;
|
|
27
|
+
let sx = 0;
|
|
28
|
+
let sy = 0;
|
|
29
|
+
let control = null;
|
|
30
|
+
const push = (px, py) => {
|
|
31
|
+
current.points.push([px, py]);
|
|
32
|
+
x = px;
|
|
33
|
+
y = py;
|
|
34
|
+
};
|
|
35
|
+
const start = (px, py) => {
|
|
36
|
+
current = { points: [[px, py]], closed: false };
|
|
37
|
+
out.push(current);
|
|
38
|
+
x = sx = px;
|
|
39
|
+
y = sy = py;
|
|
40
|
+
};
|
|
41
|
+
const cubic = (x1, y1, x2, y2, px, py) => {
|
|
42
|
+
const x0 = x;
|
|
43
|
+
const y0 = y;
|
|
44
|
+
for (let i = 1; i <= 8; i++) {
|
|
45
|
+
const t = i / 8;
|
|
46
|
+
const u = 1 - t;
|
|
47
|
+
push(u * u * u * x0 + 3 * u * u * t * x1 + 3 * u * t * t * x2 + t * t * t * px, u * u * u * y0 + 3 * u * u * t * y1 + 3 * u * t * t * y2 + t * t * t * py);
|
|
48
|
+
}
|
|
49
|
+
control = { kind: "cubic", point: [x2, y2] };
|
|
50
|
+
};
|
|
51
|
+
const quad = (x1, y1, px, py) => {
|
|
52
|
+
const x0 = x;
|
|
53
|
+
const y0 = y;
|
|
54
|
+
for (let i = 1; i <= 6; i++) {
|
|
55
|
+
const t = i / 6;
|
|
56
|
+
const u = 1 - t;
|
|
57
|
+
push(u * u * x0 + 2 * u * t * x1 + t * t * px, u * u * y0 + 2 * u * t * y1 + t * t * py);
|
|
58
|
+
}
|
|
59
|
+
control = { kind: "quadratic", point: [x1, y1] };
|
|
60
|
+
};
|
|
61
|
+
for (const [, command, args] of d.matchAll(/([MmLlHhVvCcSsQqTtAaZz])([^MmLlHhVvCcSsQqTtAaZz]*)/g)) {
|
|
62
|
+
const v = numbers(args);
|
|
63
|
+
const rel = command === command.toLowerCase();
|
|
64
|
+
const C = command.toUpperCase();
|
|
65
|
+
const ox = () => (rel ? x : 0);
|
|
66
|
+
const oy = () => (rel ? y : 0);
|
|
67
|
+
if (C === "Z") {
|
|
68
|
+
if (current) {
|
|
69
|
+
current.closed = true;
|
|
70
|
+
push(sx, sy);
|
|
71
|
+
}
|
|
72
|
+
current = null;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (C === "M") {
|
|
76
|
+
for (let i = 0; i + 1 < v.length; i += 2) {
|
|
77
|
+
if (i === 0) start(v[0] + ox(), v[1] + oy());
|
|
78
|
+
else push(v[i] + ox(), v[i + 1] + oy());
|
|
79
|
+
}
|
|
80
|
+
control = null;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (!current) start(x, y);
|
|
84
|
+
if (C === "L") for (let i = 0; i + 1 < v.length; i += 2) push(v[i] + ox(), v[i + 1] + oy());
|
|
85
|
+
else if (C === "H") for (const value of v) push(value + ox(), y);
|
|
86
|
+
else if (C === "V") for (const value of v) push(x, value + oy());
|
|
87
|
+
else if (C === "C") for (let i = 0; i + 5 < v.length; i += 6) cubic(v[i] + ox(), v[i + 1] + oy(), v[i + 2] + ox(), v[i + 3] + oy(), v[i + 4] + ox(), v[i + 5] + oy());
|
|
88
|
+
else if (C === "S")
|
|
89
|
+
for (let i = 0; i + 3 < v.length; i += 4) {
|
|
90
|
+
const reflected = control?.kind === "cubic" ? [2 * x - control.point[0], 2 * y - control.point[1]] : [x, y];
|
|
91
|
+
cubic(reflected[0], reflected[1], v[i] + ox(), v[i + 1] + oy(), v[i + 2] + ox(), v[i + 3] + oy());
|
|
92
|
+
}
|
|
93
|
+
else if (C === "Q") for (let i = 0; i + 3 < v.length; i += 4) quad(v[i] + ox(), v[i + 1] + oy(), v[i + 2] + ox(), v[i + 3] + oy());
|
|
94
|
+
else if (C === "T")
|
|
95
|
+
for (let i = 0; i + 1 < v.length; i += 2) {
|
|
96
|
+
const reflected = control?.kind === "quadratic" ? [2 * x - control.point[0], 2 * y - control.point[1]] : [x, y];
|
|
97
|
+
quad(reflected[0], reflected[1], v[i] + ox(), v[i + 1] + oy());
|
|
98
|
+
}
|
|
99
|
+
else if (C === "A") {
|
|
100
|
+
const arc = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
|
101
|
+
arc.setAttribute("d", `M${x} ${y}${command}${args}`);
|
|
102
|
+
const length = arc.getTotalLength();
|
|
103
|
+
const count = Math.max(8, Math.ceil(length / 2));
|
|
104
|
+
for (let index = 1; index <= count; index++) {
|
|
105
|
+
const point = arc.getPointAtLength(length * index / count);
|
|
106
|
+
push(point.x, point.y);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (C !== "C" && C !== "S" && C !== "Q" && C !== "T") control = null;
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
};
|
|
113
|
+
const geometryOf = (el) => {
|
|
114
|
+
const tag = el.tagName.toLowerCase();
|
|
115
|
+
const num = (name) => Number(el.getAttribute(name) || 0);
|
|
116
|
+
if (tag === "path") return subpathsOf(el.getAttribute("d") || "");
|
|
117
|
+
if (tag === "line") return [{ points: [[num("x1"), num("y1")], [num("x2"), num("y2")]], closed: false }];
|
|
118
|
+
if (tag === "polyline" || tag === "polygon") {
|
|
119
|
+
const v = numbers(el.getAttribute("points") || "");
|
|
120
|
+
const points = [];
|
|
121
|
+
for (let i = 0; i + 1 < v.length; i += 2) points.push([v[i], v[i + 1]]);
|
|
122
|
+
if (tag === "polygon" && points.length) points.push(points[0]);
|
|
123
|
+
return [{ points, closed: tag === "polygon" }];
|
|
124
|
+
}
|
|
125
|
+
if (tag === "rect") {
|
|
126
|
+
const [x, y, w, h] = [num("x"), num("y"), num("width"), num("height")];
|
|
127
|
+
const rx = Math.min(w / 2, Math.max(0, el.hasAttribute("rx") ? num("rx") : num("ry")));
|
|
128
|
+
const ry = Math.min(h / 2, Math.max(0, el.hasAttribute("ry") ? num("ry") : num("rx")));
|
|
129
|
+
if (!rx || !ry) return [{ points: [[x, y], [x + w, y], [x + w, y + h], [x, y + h], [x, y]], closed: true }];
|
|
130
|
+
const points = [];
|
|
131
|
+
for (const [cx, cy, start] of [[x + w - rx, y + ry, -Math.PI / 2], [x + w - rx, y + h - ry, 0], [x + rx, y + h - ry, Math.PI / 2], [x + rx, y + ry, Math.PI]]) {
|
|
132
|
+
for (let index = 0; index <= 32; index++) {
|
|
133
|
+
const angle = start + index * Math.PI / 64;
|
|
134
|
+
points.push([cx + rx * Math.cos(angle), cy + ry * Math.sin(angle)]);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
points.push(points[0]);
|
|
138
|
+
return [{ points, closed: true }];
|
|
139
|
+
}
|
|
140
|
+
return [];
|
|
141
|
+
};
|
|
142
|
+
const cell = 6;
|
|
143
|
+
const grid = new Map();
|
|
144
|
+
const add = (gx, gy, entry) => {
|
|
145
|
+
const id = `${gx},${gy}`;
|
|
146
|
+
if (!grid.has(id)) grid.set(id, []);
|
|
147
|
+
grid.get(id).push(entry);
|
|
148
|
+
};
|
|
149
|
+
const lines = [];
|
|
150
|
+
let scale = 1;
|
|
151
|
+
for (const el of shapes) {
|
|
152
|
+
const cs = getComputedStyle(el);
|
|
153
|
+
const alpha = opacityOf(el);
|
|
154
|
+
if (alpha < 0.1) continue;
|
|
155
|
+
const ctm = el.getScreenCTM();
|
|
156
|
+
if (!ctm) continue;
|
|
157
|
+
const map = ([px, py]) => [ctm.a * px + ctm.c * py + ctm.e, ctm.b * px + ctm.d * py + ctm.f];
|
|
158
|
+
const tag = el.tagName.toLowerCase();
|
|
159
|
+
const stroked = cs.stroke !== "none" && parseFloat(cs.strokeWidth) > 0 && Number(cs.strokeOpacity) > 0.05;
|
|
160
|
+
const filled = cs.fill !== "none" && Number(cs.fillOpacity) > 0.05;
|
|
161
|
+
if (tag === "circle" || tag === "ellipse") {
|
|
162
|
+
if (!stroked && !filled) continue;
|
|
163
|
+
const cx = Number(el.getAttribute("cx") || 0);
|
|
164
|
+
const cy = Number(el.getAttribute("cy") || 0);
|
|
165
|
+
const rx = Number(el.getAttribute(tag === "circle" ? "r" : "rx") || 0);
|
|
166
|
+
const ry = Number(el.getAttribute(tag === "circle" ? "r" : "ry") || 0);
|
|
167
|
+
if (rx <= 0 || ry <= 0) continue;
|
|
168
|
+
const points = Array.from({ length: 128 }, (_, index) => {
|
|
169
|
+
const angle = index * Math.PI * 2 / 128;
|
|
170
|
+
return map([cx + rx * Math.cos(angle), cy + ry * Math.sin(angle)]);
|
|
171
|
+
});
|
|
172
|
+
const entry = { el, kind: "round", points, cx, cy, rx, ry, filled };
|
|
173
|
+
const xs = points.map(([x]) => x);
|
|
174
|
+
const ys = points.map(([, y]) => y);
|
|
175
|
+
for (let gx = Math.floor(Math.min(...xs) / cell); gx <= Math.floor(Math.max(...xs) / cell); gx++) for (let gy = Math.floor(Math.min(...ys) / cell); gy <= Math.floor(Math.max(...ys) / cell); gy++) add(gx, gy, entry);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (!stroked) continue;
|
|
179
|
+
const owner = el.closest("svg");
|
|
180
|
+
const viewCtm = owner && owner.getScreenCTM();
|
|
181
|
+
if (viewCtm) scale = Math.hypot(viewCtm.a, viewCtm.b) || scale;
|
|
182
|
+
const subpaths = geometryOf(el).map((sub, index) => ({ ...sub, index, flat: sub.points.map(map) }));
|
|
183
|
+
subpaths.forEach((sub) => {
|
|
184
|
+
for (let i = 0; i < sub.flat.length - 1; i++) {
|
|
185
|
+
const a = sub.flat[i];
|
|
186
|
+
const b = sub.flat[i + 1];
|
|
187
|
+
const entry = { el, kind: "segment", a, b, sub: sub.index, i };
|
|
188
|
+
for (let gx = Math.floor(Math.min(a[0], b[0]) / cell); gx <= Math.floor(Math.max(a[0], b[0]) / cell); gx++) for (let gy = Math.floor(Math.min(a[1], b[1]) / cell); gy <= Math.floor(Math.max(a[1], b[1]) / cell); gy++) add(gx, gy, entry);
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
const checked = el.classList.contains("iso-line") && !skipClass.some((name) => el.classList.contains(name)) && alpha >= least;
|
|
192
|
+
if (checked) lines.push({ el, subpaths, alpha, free: el.getAttribute("data-free") });
|
|
193
|
+
}
|
|
194
|
+
const near = (p, reach) => {
|
|
195
|
+
const out = new Set();
|
|
196
|
+
for (let gx = Math.floor((p[0] - reach) / cell); gx <= Math.floor((p[0] + reach) / cell); gx++) for (let gy = Math.floor((p[1] - reach) / cell); gy <= Math.floor((p[1] + reach) / cell); gy++) for (const entry of grid.get(`${gx},${gy}`) || []) out.add(entry);
|
|
197
|
+
return out;
|
|
198
|
+
};
|
|
199
|
+
const segmentDistance = (p, a, b) => {
|
|
200
|
+
const dx = b[0] - a[0];
|
|
201
|
+
const dy = b[1] - a[1];
|
|
202
|
+
const span = dx * dx + dy * dy;
|
|
203
|
+
const t = span > 0 ? Math.max(0, Math.min(1, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / span)) : 0;
|
|
204
|
+
return Math.hypot(p[0] - a[0] - dx * t, p[1] - a[1] - dy * t);
|
|
205
|
+
};
|
|
206
|
+
const lengthOf = (flat) => flat.reduce((sum, p, i) => (i ? sum + Math.hypot(p[0] - flat[i - 1][0], p[1] - flat[i - 1][1]) : 0), 0);
|
|
207
|
+
const hidden = (el, p) => {
|
|
208
|
+
const stack = document.elementsFromPoint(p[0], p[1]);
|
|
209
|
+
const at = stack.indexOf(el);
|
|
210
|
+
const above = at < 0 ? stack : stack.slice(0, at);
|
|
211
|
+
return above.some((node) => {
|
|
212
|
+
if (!(node instanceof SVGGeometryElement) || node.contains(el)) return false;
|
|
213
|
+
const cs = getComputedStyle(node);
|
|
214
|
+
return cs.fill !== "none" && Number(cs.fillOpacity) * opacityOf(node) >= cover && node.isPointInFill(new DOMPoint(...inverse(node, p)));
|
|
215
|
+
});
|
|
216
|
+
};
|
|
217
|
+
const inverse = (node, p) => {
|
|
218
|
+
const m = node.getScreenCTM().inverse();
|
|
219
|
+
return [m.a * p[0] + m.c * p[1] + m.e, m.b * p[0] + m.d * p[1] + m.f];
|
|
220
|
+
};
|
|
221
|
+
const clearance = (line, sub, end, p, reach) => {
|
|
222
|
+
let best = Infinity;
|
|
223
|
+
const own = sub.flat;
|
|
224
|
+
const skipNear = 2 * reach;
|
|
225
|
+
const along = [0];
|
|
226
|
+
for (let i = 1; i < own.length; i++) along.push(along[i - 1] + Math.hypot(own[i][0] - own[i - 1][0], own[i][1] - own[i - 1][1]));
|
|
227
|
+
const total = along[along.length - 1];
|
|
228
|
+
for (const entry of near(p, reach * 4)) {
|
|
229
|
+
if (entry.kind === "round") {
|
|
230
|
+
const local = inverse(entry.el, p);
|
|
231
|
+
const inside = ((local[0] - entry.cx) / entry.rx) ** 2 + ((local[1] - entry.cy) / entry.ry) ** 2 <= 1;
|
|
232
|
+
if (entry.filled && inside) best = 0;
|
|
233
|
+
else for (let index = 0; index < entry.points.length; index++) best = Math.min(best, segmentDistance(p, entry.points[index], entry.points[(index + 1) % entry.points.length]));
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if (entry.el === line.el && entry.sub === sub.index) {
|
|
237
|
+
const from = along[entry.i];
|
|
238
|
+
const to = along[entry.i + 1];
|
|
239
|
+
const gapFrom = end === "start" ? from : total - to;
|
|
240
|
+
if (gapFrom < skipNear) continue;
|
|
241
|
+
}
|
|
242
|
+
best = Math.min(best, segmentDistance(p, entry.a, entry.b));
|
|
243
|
+
}
|
|
244
|
+
return best;
|
|
245
|
+
};
|
|
246
|
+
const describe = (el) => {
|
|
247
|
+
const item = el.closest("[data-part], [data-name], [data-item], .it");
|
|
248
|
+
const tag = item && item !== el ? (item.getAttribute("data-part") || item.getAttribute("data-name") || item.getAttribute("data-item") || `it k ${item.getAttribute("data-k") || ""}`) : "";
|
|
249
|
+
return `${el.getAttribute("class")}${el.getAttribute("data-tone") ? ` ${el.getAttribute("data-tone")}` : ""}${tag ? ` in ${tag}` : ""}`;
|
|
250
|
+
};
|
|
251
|
+
const problems = [];
|
|
252
|
+
let ends = 0;
|
|
253
|
+
const slack = tolerance * scale;
|
|
254
|
+
const rooted = rootSlack * scale;
|
|
255
|
+
for (const line of lines) {
|
|
256
|
+
for (const sub of line.subpaths) {
|
|
257
|
+
if (sub.closed || sub.flat.length < 2) continue;
|
|
258
|
+
const first = sub.flat[0];
|
|
259
|
+
const last = sub.flat[sub.flat.length - 1];
|
|
260
|
+
if (Math.hypot(first[0] - last[0], first[1] - last[1]) < 0.05 * scale) continue;
|
|
261
|
+
const length = lengthOf(sub.flat);
|
|
262
|
+
if (length < 0.2 * scale) continue;
|
|
263
|
+
const result = {};
|
|
264
|
+
for (const [end, p] of [["start", first], ["end", last]]) {
|
|
265
|
+
if (line.free === "" || line.free === "both" || line.free === end) {
|
|
266
|
+
result[end] = 0;
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
ends++;
|
|
270
|
+
result[end] = hidden(line.el, p) ? 0 : clearance(line, sub, end, p, Math.max(slack, rooted));
|
|
271
|
+
}
|
|
272
|
+
const straight = sub.flat.length === 2 || sub.flat.every((q) => segmentDistance(q, first, last) < 0.15 * scale);
|
|
273
|
+
const isMark = straight && length <= mark * scale;
|
|
274
|
+
const fail = (end) => result[end] !== undefined && result[end] > slack;
|
|
275
|
+
let bad = [];
|
|
276
|
+
if (isMark) {
|
|
277
|
+
const best = Math.min(result.start ?? Infinity, result.end ?? Infinity);
|
|
278
|
+
if (!(best <= rooted)) bad = ["start", "end"].filter((end) => result[end] !== undefined && result[end] > 0);
|
|
279
|
+
} else bad = ["start", "end"].filter(fail);
|
|
280
|
+
for (const end of bad) {
|
|
281
|
+
const p = end === "start" ? first : last;
|
|
282
|
+
problems.push({ what: describe(line.el), d: (line.el.getAttribute("d") || "").slice(0, 36), end, mark: isMark, gap: result[end] / scale, at: [Math.round((p[0] - box.left) * 10) / 10, Math.round((p[1] - box.top) * 10) / 10], client: p, opacity: Math.round(line.alpha * 100) / 100, length: Math.round((length / scale) * 10) / 10 });
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
style.remove();
|
|
287
|
+
problems.sort((a, b) => b.gap - a.gap);
|
|
288
|
+
if (show) {
|
|
289
|
+
const ns = "http://www.w3.org/2000/svg";
|
|
290
|
+
const overlay = document.createElementNS(ns, "svg");
|
|
291
|
+
overlay.setAttribute("data-line-ends", "");
|
|
292
|
+
overlay.setAttribute("style", `position:fixed;left:0;top:0;width:${innerWidth}px;height:${innerHeight}px;pointer-events:none;z-index:2147483647;overflow:visible`);
|
|
293
|
+
for (const problem of problems) {
|
|
294
|
+
const ring = document.createElementNS(ns, "circle");
|
|
295
|
+
ring.setAttribute("cx", problem.client[0]);
|
|
296
|
+
ring.setAttribute("cy", problem.client[1]);
|
|
297
|
+
ring.setAttribute("r", 5);
|
|
298
|
+
ring.setAttribute("style", "fill:none;stroke:#ff0050;stroke-width:1.5");
|
|
299
|
+
overlay.append(ring);
|
|
300
|
+
}
|
|
301
|
+
document.body.append(overlay);
|
|
302
|
+
}
|
|
303
|
+
return { ends, lines: lines.length, problems: problems.map(({ client, ...rest }) => rest) };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export const LINE_ENDS = lineEnds.toString();
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import test from "node:test";
|
|
9
|
+
|
|
10
|
+
const execute = promisify(execFile);
|
|
11
|
+
const lines = fileURLToPath(new URL("lines.mjs", import.meta.url));
|
|
12
|
+
|
|
13
|
+
async function check(arc, line) {
|
|
14
|
+
const directory = await mkdtemp(join(tmpdir(), "anatomy-lines-arcs-"));
|
|
15
|
+
try {
|
|
16
|
+
const page = join(directory, "figure.html");
|
|
17
|
+
await writeFile(page, `<!doctype html><div class="iso-plate"><svg width="200" height="100" viewBox="0 0 200 100"><path fill="none" stroke="black" d="${arc}"/><path class="iso-line" fill="none" stroke="black" d="${line}"/></svg></div>`);
|
|
18
|
+
try {
|
|
19
|
+
return { code: 0, ...await execute(process.execPath, [lines, page, "--wait", "0"], { timeout: 30000 }) };
|
|
20
|
+
} catch (error) {
|
|
21
|
+
return { code: error.code, stdout: error.stdout, stderr: error.stderr };
|
|
22
|
+
}
|
|
23
|
+
} finally {
|
|
24
|
+
await rm(directory, { recursive: true, force: true });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
test("the empty chord of an arc does not root a line", async () => {
|
|
29
|
+
const result = await check("M20 50A30 30 0 0 1 80 50", "M50 50L50 70");
|
|
30
|
+
assert.equal(result.code, 1);
|
|
31
|
+
assert.match(result.stdout, /line ends: 2 of 2/);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("the actual middle of an arc roots a line", async () => {
|
|
35
|
+
const result = await check("M20 50A30 30 0 0 1 80 50", "M50 20L50 0");
|
|
36
|
+
assert.equal(result.code, 1);
|
|
37
|
+
assert.match(result.stdout, /line ends: 1 of 2/);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("relative arcs retain their endpoint and curvature", async () => {
|
|
41
|
+
const result = await check("M20 50a30 30 0 0 1 60 0", "M50 20L50 0");
|
|
42
|
+
assert.equal(result.code, 1);
|
|
43
|
+
assert.match(result.stdout, /line ends: 1 of 2/);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("arc endpoints preserve fully rooted lines", async () => {
|
|
47
|
+
const result = await check("M20 50A30 30 0 0 1 80 50", "M20 50L80 50");
|
|
48
|
+
assert.equal(result.code, 0);
|
|
49
|
+
assert.match(result.stdout, /line ends: 0/);
|
|
50
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import test from "node:test";
|
|
8
|
+
|
|
9
|
+
const execute = promisify(execFile);
|
|
10
|
+
for (const [name, path, x, y] of [
|
|
11
|
+
["quadratic then smooth cubic", "M20 60 Q40 20 60 60 S80 100 100 60", 72.5, 75],
|
|
12
|
+
["cubic then smooth quadratic", "M20 60 C30 20 50 20 60 60 T100 60", 80, 60],
|
|
13
|
+
["matching cubic continuation", "M20 60 C30 20 50 20 60 60 S80 100 100 60", 76.25, 90],
|
|
14
|
+
["matching quadratic continuation", "M20 60 Q40 20 60 60 T100 60", 80, 80],
|
|
15
|
+
]) test(`native SVG ${name} roots a line on the actual curve`, async () => {
|
|
16
|
+
const directory = await mkdtemp(join(tmpdir(), "anatomy-bezier-control-"));
|
|
17
|
+
try {
|
|
18
|
+
const file = join(directory, "figure.html");
|
|
19
|
+
await writeFile(file, `<!doctype html><div class="iso-plate"><svg width="140" height="120" viewBox="0 0 140 120"><path id="curve" fill="none" stroke="black" d="${path}"/><path class="iso-line" data-free="start" fill="none" stroke="black" d="M${x} 10L${x} ${y}"/></svg></div>`);
|
|
20
|
+
const actions = JSON.stringify([["eval", `document.querySelector('#curve').isPointInStroke(new DOMPoint(${x},${y}))`], ["lines"]]);
|
|
21
|
+
let result;
|
|
22
|
+
try { result = { code: 0, ...await execute(process.execPath, ["scripts/drive.mjs", file, actions], { timeout: 30000 }) }; }
|
|
23
|
+
catch (error) { result = { code: error.code, stdout: error.stdout, stderr: error.stderr }; }
|
|
24
|
+
assert.match(result.stdout, /eval: true/);
|
|
25
|
+
assert.equal(result.code, 0);
|
|
26
|
+
assert.match(result.stdout, /line ends: 0 of 1/);
|
|
27
|
+
} finally { await rm(directory, { recursive: true, force: true }); }
|
|
28
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import test from "node:test";
|
|
9
|
+
|
|
10
|
+
const execute = promisify(execFile);
|
|
11
|
+
const lines = fileURLToPath(new URL("lines.mjs", import.meta.url));
|
|
12
|
+
|
|
13
|
+
async function check(shape, path) {
|
|
14
|
+
const directory = await mkdtemp(join(tmpdir(), "anatomy-lines-round-"));
|
|
15
|
+
try {
|
|
16
|
+
const page = join(directory, "figure.html");
|
|
17
|
+
await writeFile(page, `<!doctype html><div class="iso-plate"><svg width="200" height="100" viewBox="0 0 200 100">${shape}<path class="iso-line" fill="none" stroke="black" d="${path}"/></svg></div>`);
|
|
18
|
+
try {
|
|
19
|
+
return { code: 0, ...await execute(process.execPath, [lines, page, "--wait", "0"], { timeout: 30000 }) };
|
|
20
|
+
} catch (error) {
|
|
21
|
+
return { code: error.code, stdout: error.stdout, stderr: error.stderr };
|
|
22
|
+
}
|
|
23
|
+
} finally {
|
|
24
|
+
await rm(directory, { recursive: true, force: true });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
test("line ends above a flat ellipse are floating", async () => {
|
|
29
|
+
const result = await check('<ellipse cx="50" cy="50" rx="40" ry="5" fill="black"/>', "M30 30L50 30");
|
|
30
|
+
assert.equal(result.code, 1);
|
|
31
|
+
assert.match(result.stdout, /line ends: 2 of 2/);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("line ends inside an unfilled circle are floating", async () => {
|
|
35
|
+
const result = await check('<circle cx="50" cy="50" r="40" fill="none" stroke="black"/>', "M30 50L50 50");
|
|
36
|
+
assert.equal(result.code, 1);
|
|
37
|
+
assert.match(result.stdout, /line ends: 2 of 2/);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("line ends on ellipse rims remain rooted", async () => {
|
|
41
|
+
const result = await check('<ellipse cx="50" cy="50" rx="40" ry="5" fill="none" stroke="black"/>', "M10 50L90 50");
|
|
42
|
+
assert.equal(result.code, 0);
|
|
43
|
+
assert.match(result.stdout, /line ends: 0/);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("a nonuniformly scaled circle does not root nearby floating ends", async () => {
|
|
47
|
+
const result = await check('<circle cx="25" cy="25" r="20" transform="scale(2,.25)" fill="black"/>', "M30 20L50 20");
|
|
48
|
+
assert.equal(result.code, 1);
|
|
49
|
+
assert.match(result.stdout, /line ends: 2 of 2/);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("line ends inside a filled ellipse remain rooted", async () => {
|
|
53
|
+
const result = await check('<ellipse cx="50" cy="50" rx="40" ry="5" fill="black"/>', "M30 50L50 50");
|
|
54
|
+
assert.equal(result.code, 0);
|
|
55
|
+
assert.match(result.stdout, /line ends: 0/);
|
|
56
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import test from "node:test";
|
|
8
|
+
|
|
9
|
+
const execute = promisify(execFile);
|
|
10
|
+
for (const [name, attributes, x, y, rooted, transform = ""] of [
|
|
11
|
+
["rounded cutaway rejects square-corner attachment", 'width="100" height="80" rx="30"', 0, 0, false],
|
|
12
|
+
["rounded curve accepts its native attachment", 'width="100" height="80" rx="30"', 8.78679656440357, 8.786796564403573, true],
|
|
13
|
+
["elliptical corner accepts its native attachment", 'width="100" height="80" rx="30" ry="10"', 4.019237886466836, 5, true],
|
|
14
|
+
["missing horizontal radius follows vertical radius", 'width="100" height="80" ry="20"', 5.857864376269049, 5.857864376269051, true],
|
|
15
|
+
["oversized radii clamp independently", 'width="100" height="80" rx="200" ry="200"', 14.644660940672622, 11.715728752538102, true],
|
|
16
|
+
["affine corner preserves transformed attachment", 'width="100" height="80" rx="30"', 8.78679656440357, 8.786796564403573, true, "matrix(2 .2 .4 1 50 50)"],
|
|
17
|
+
["ordinary square retains its original attachment", 'width="100" height="80"', 0, 0, true],
|
|
18
|
+
["explicit zero radius remains square", 'width="100" height="80" rx="0" ry="30"', 0, 0, true],
|
|
19
|
+
]) test(name, async () => {
|
|
20
|
+
const directory = await mkdtemp(join(tmpdir(), "anatomy-rounded-rect-lines-"));
|
|
21
|
+
try {
|
|
22
|
+
const file = join(directory, "figure.html");
|
|
23
|
+
await writeFile(file, `<!doctype html><div class="iso-plate"><svg width="360" height="300" viewBox="0 0 360 300"><g transform="${transform}"><rect id="outline" x="0" y="0" ${attributes} fill="none" stroke="black"/><path class="iso-line" data-free="start" fill="none" stroke="black" d="M${x} -20 L${x} ${y}"/></g></svg></div>`);
|
|
24
|
+
const actions = JSON.stringify([["eval", `document.querySelector('#outline').isPointInStroke(new DOMPoint(${x},${y}))`], ["lines"]]);
|
|
25
|
+
let result;
|
|
26
|
+
try { result = { code: 0, ...await execute(process.execPath, ["scripts/drive.mjs", file, actions], { timeout: 30000 }) }; }
|
|
27
|
+
catch (error) { result = { code: error.code, stdout: error.stdout, stderr: error.stderr }; }
|
|
28
|
+
assert.match(result.stdout, new RegExp(`eval: ${rooted}`));
|
|
29
|
+
assert.equal(result.code, rooted ? 0 : 1, result.stderr);
|
|
30
|
+
assert.match(result.stdout, new RegExp(`line ends: ${rooted ? 0 : 1} of 1`));
|
|
31
|
+
} finally { await rm(directory, { recursive: true, force: true }); }
|
|
32
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
const argv = process.argv.slice(2);
|
|
6
|
+
if (!argv.length || argv.includes("--help") || argv.includes("-h")) {
|
|
7
|
+
console.log(`Check that every texture line in a figure ends on something.
|
|
8
|
+
|
|
9
|
+
node lines.mjs <file.html | url> [--out marks.png] [--tolerance 1] [--select css] [--wait 1500] [drive.mjs flags]
|
|
10
|
+
|
|
11
|
+
Every open subpath of every .iso-line (seams, grooves, ribs, rule lines, ticks, needles) must end on another stroke,
|
|
12
|
+
a dot or an outline within the tolerance (viewBox px), or under a fill painted after it. A short straight mark (a tick)
|
|
13
|
+
needs one rooted end. Outlines, creases, bevels and tube edges are the kit's own and are not checked. Lines drawn with
|
|
14
|
+
fadeLineSvg ramp out and pass; lineSvg(d, { free: true | "start" | "end" }) marks an end that is meant to stop (a needle
|
|
15
|
+
tip, rule lines standing for printed text). Exits 1 if any end floats; --out writes the card with each one ringed.`);
|
|
16
|
+
process.exit(argv.length ? 0 : 1);
|
|
17
|
+
}
|
|
18
|
+
const take = (flag, fallback) => {
|
|
19
|
+
const at = argv.indexOf(flag);
|
|
20
|
+
if (at < 0) return fallback;
|
|
21
|
+
const value = argv[at + 1];
|
|
22
|
+
argv.splice(at, 2);
|
|
23
|
+
return value;
|
|
24
|
+
};
|
|
25
|
+
const out = take("--out", null);
|
|
26
|
+
const tolerance = Number(take("--tolerance", 1));
|
|
27
|
+
const select = take("--select", null);
|
|
28
|
+
const wait = Number(take("--wait", 1500));
|
|
29
|
+
const actions = JSON.stringify([["wait", wait], ["lines", out, select, { tolerance }]]);
|
|
30
|
+
const child = spawn(process.execPath, [join(dirname(fileURLToPath(import.meta.url)), "drive.mjs"), argv[0], actions, ...argv.slice(1)], { stdio: "inherit" });
|
|
31
|
+
child.on("exit", (code) => process.exit(code ?? 1));
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import test from "node:test";
|
|
9
|
+
|
|
10
|
+
const execute = promisify(execFile);
|
|
11
|
+
const lines = fileURLToPath(new URL("lines.mjs", import.meta.url));
|
|
12
|
+
|
|
13
|
+
async function check(path) {
|
|
14
|
+
const directory = await mkdtemp(join(tmpdir(), "anatomy-lines-"));
|
|
15
|
+
try {
|
|
16
|
+
const page = join(directory, "figure.html");
|
|
17
|
+
await writeFile(page, `<!doctype html><svg id="figure" width="200" height="100" viewBox="0 0 200 100"><path class="iso-line" fill="none" stroke="black" d="${path}"/></svg>`);
|
|
18
|
+
try {
|
|
19
|
+
return { code: 0, ...await execute(process.execPath, [lines, page, "--select", "#figure", "--wait", "0"], { timeout: 30000 }) };
|
|
20
|
+
} catch (error) {
|
|
21
|
+
return { code: error.code, stdout: error.stdout, stderr: error.stderr };
|
|
22
|
+
}
|
|
23
|
+
} finally {
|
|
24
|
+
await rm(directory, { recursive: true, force: true });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
test("a selected SVG checks its own floating lines", async () => {
|
|
29
|
+
const result = await check("M20 20L180 20");
|
|
30
|
+
assert.equal(result.code, 1);
|
|
31
|
+
assert.match(result.stdout, /line ends: 2 of 2 ends on 1 lines float/);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("a selected SVG preserves closed-path exemption", async () => {
|
|
35
|
+
const result = await check("M20 20L180 20L180 80L20 80Z");
|
|
36
|
+
assert.equal(result.code, 0);
|
|
37
|
+
assert.match(result.stdout, /line ends: 0/);
|
|
38
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { createServer } from "node:net";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import test from "node:test";
|
|
10
|
+
|
|
11
|
+
const execute = promisify(execFile);
|
|
12
|
+
|
|
13
|
+
async function run(name, kind) {
|
|
14
|
+
const directory = await mkdtemp(join(tmpdir(), "anatomy-navigation-"));
|
|
15
|
+
try {
|
|
16
|
+
let target = join(directory, "figure.html");
|
|
17
|
+
if (kind === "valid") await writeFile(target, '<!doctype html><div class="iso-plate" style="width:100px;height:100px">Figure</div>');
|
|
18
|
+
if (kind === "refused") {
|
|
19
|
+
const server = createServer();
|
|
20
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
21
|
+
target = `http://127.0.0.1:${server.address().port}/`;
|
|
22
|
+
await new Promise((resolve) => server.close(resolve));
|
|
23
|
+
}
|
|
24
|
+
const args = [fileURLToPath(new URL(`${name}.mjs`, import.meta.url)), target, ...(name === "capture" ? ["--out", join(directory, "shot.png"), "--wait", "0"] : ["[]"])];
|
|
25
|
+
try {
|
|
26
|
+
return { code: 0, ...await execute(process.execPath, args, { timeout: 30000 }) };
|
|
27
|
+
} catch (error) {
|
|
28
|
+
return { code: error.code, stdout: error.stdout, stderr: error.stderr };
|
|
29
|
+
}
|
|
30
|
+
} finally {
|
|
31
|
+
await rm(directory, { recursive: true, force: true });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
for (const name of ["capture", "drive"]) {
|
|
36
|
+
test(`${name} fails a missing local page`, async () => {
|
|
37
|
+
const result = await run(name, "missing");
|
|
38
|
+
assert.equal(result.code, 1);
|
|
39
|
+
assert.match(result.stderr, /Navigation failed.*ERR_FILE_NOT_FOUND/);
|
|
40
|
+
assert.doesNotMatch(result.stdout, /console: clean/);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test(`${name} fails refused HTTP navigation`, async () => {
|
|
44
|
+
const result = await run(name, "refused");
|
|
45
|
+
assert.equal(result.code, 1);
|
|
46
|
+
assert.match(result.stderr, /Navigation failed.*ERR_CONNECTION_REFUSED/);
|
|
47
|
+
assert.doesNotMatch(result.stdout, /console: clean/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test(`${name} preserves successful local navigation`, async () => {
|
|
51
|
+
const result = await run(name, "valid");
|
|
52
|
+
assert.equal(result.code, 0);
|
|
53
|
+
assert.match(result.stdout, /console: clean/);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import test from "node:test";
|
|
8
|
+
|
|
9
|
+
const execute = promisify(execFile);
|
|
10
|
+
const page = "examples/turning-dial/turning-dial-verify-light.html";
|
|
11
|
+
async function run(args) {
|
|
12
|
+
try { return { code: 0, ...await execute(process.execPath, ["scripts/turn-check.mjs", page, ...args], { timeout: 60000 }) }; }
|
|
13
|
+
catch (error) { return { code: error.code, stdout: error.stdout, stderr: error.stderr }; }
|
|
14
|
+
}
|
|
15
|
+
for (const step of ["NaN", "Infinity", "-1", "721"])
|
|
16
|
+
test(`rejects orbit step ${step} instead of passing an empty sweep`, async () => {
|
|
17
|
+
const result = await run(["--order", "--step", step]);
|
|
18
|
+
assert.equal(result.code, 1);
|
|
19
|
+
assert.match(result.stderr, /--step must be a finite number/);
|
|
20
|
+
assert.doesNotMatch(result.stdout, /turn check passed/);
|
|
21
|
+
});
|
|
22
|
+
test("rejects an empty explicit pose list instead of auditing zero states", async () => {
|
|
23
|
+
const directory = await mkdtemp(join(tmpdir(), "anatomy-orbit-states-"));
|
|
24
|
+
try {
|
|
25
|
+
const file = join(directory, "states.json"); await writeFile(file, "[]");
|
|
26
|
+
const result = await run(["--order", "--states", file]);
|
|
27
|
+
assert.equal(result.code, 1); assert.match(result.stderr, /nonempty JSON list/);
|
|
28
|
+
assert.doesNotMatch(result.stdout, /turn check passed/);
|
|
29
|
+
} finally { await rm(directory, { recursive: true, force: true }); }
|
|
30
|
+
});
|
|
31
|
+
test("rejects a nonfinite fine step instead of silently skipping the second difference", async () => {
|
|
32
|
+
const result = await run(["--pops", "--step", "360", "--fine", "NaN"]);
|
|
33
|
+
assert.equal(result.code, 1); assert.match(result.stderr, /--fine must be a finite number/);
|
|
34
|
+
assert.doesNotMatch(result.stdout, /turn check passed/);
|
|
35
|
+
});
|
|
36
|
+
test("preserves a valid native orbit sweep and its planted control", async () => {
|
|
37
|
+
const result = await run(["--order", "--step", "360"]);
|
|
38
|
+
assert.equal(result.code, 0); assert.match(result.stdout, /order: 1 states/);
|
|
39
|
+
assert.match(result.stdout, /order control.*fails as it should/); assert.match(result.stdout, /turn check passed/);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("preserves a nonempty explicit pose list and its planted control", async () => {
|
|
43
|
+
const directory = await mkdtemp(join(tmpdir(), "anatomy-orbit-states-"));
|
|
44
|
+
try {
|
|
45
|
+
const file = join(directory, "states.json"); await writeFile(file, "[0,90]");
|
|
46
|
+
const result = await run(["--order", "--states", file]);
|
|
47
|
+
assert.equal(result.code, 0); assert.match(result.stdout, /order: 2 states/);
|
|
48
|
+
assert.match(result.stdout, /order control.*fails as it should/); assert.match(result.stdout, /turn check passed/);
|
|
49
|
+
} finally { await rm(directory, { recursive: true, force: true }); }
|
|
50
|
+
});
|