turn-check.mjs
664 lines · scripts/turn-check.mjs at 1482e84
1import { chromePort } from "./chrome-port.mjs";
2import { spawn } from "node:child_process";
3import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4import { homedir, tmpdir } from "node:os";
5import { dirname, join, resolve } from "node:path";
6import { pathToFileURL } from "node:url";
7import { LINE_ENDS } from "./line-ends.mjs";
9function usage() {
10 console.log(`Checks for 3D figures (turning groups).
12 node turn-check.mjs build.mjs --fidelity [--angles 7,37,…]
13 Node: every live prism against k.extrude / G.prismOf, every round and lathe against G.lathe(smooth) and every tube
14 against tubePieces, at P'(θ). Control: one prism drawn 2° off must fail.
15 node turn-check.mjs page.html [--order] [--pops] [--lines] [--perf] [--step 1] [--states states.json]
16 [--width 1440] [--height 1100] [--scale 2] [--throttle 1] [--no-webgl] [--out dir]
17 --order needs a --verify build: the recorded shapes in a WebGL z-buffer (part-ID colours, 2x) against the live
18 layers' fills in DOM order. Fails on any live pair with 4 or more interior pixels. Static × live mismatches
19 are listed (the layer proofs and the orbit audit own them). Control: each live layer's order reversed at
20 0, 90, 180 and 270 must give at least 1000 mismatched pixels.
21 --pops first difference θ → θ+0.02° at every step, and second difference at 0.1° over a full turn; control: one
22 part's fill hidden for one frame must fail. Run with --no-webgl and the tour held.
23 --lines LINE_ENDS at 24 angles (run it on the light and the dark page); control: a planted floating line must fail.
24 --perf 240 frames at 1.1° per frame driven through the controller inside requestAnimationFrame: script, task,
25 style and layout per frame, frame interval median and p95, live elements, writes and moves per frame.
26 --states a JSON list of value objects to visit instead of angles (gestures, a break-apart).
27 The angle is the figure's first turn group (window.__isoTurn.set(θ)).`);
28}
30function chromePath() {
31 const candidates = [
32 process.env.CHROME_PATH,
33 "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
34 "/Applications/Chromium.app/Contents/MacOS/Chromium",
35 "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
36 "/usr/bin/google-chrome",
37 "/usr/bin/google-chrome-stable",
38 "/usr/bin/chromium",
39 "/usr/bin/chromium-browser",
40 "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
41 "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
42 ...(process.env.LOCALAPPDATA ? [join(process.env.LOCALAPPDATA, "Google\\Chrome\\Application\\chrome.exe")] : []),
43 "C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe",
44 "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe",
45 ].filter(Boolean);
46 const playwright = join(homedir(), "Library/Caches/ms-playwright");
47 if (existsSync(playwright)) for (const dir of readdirSync(playwright).sort().reverse()) for (const sub of ["chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing", "chrome-mac/Chromium.app/Contents/MacOS/Chromium", "chrome-linux/chrome"]) candidates.push(join(playwright, dir, sub));
48 const linux = join(homedir(), ".cache/ms-playwright");
49 if (existsSync(linux)) for (const dir of readdirSync(linux).sort().reverse()) for (const sub of ["chrome-linux64/chrome", "chrome-linux/chrome"]) candidates.push(join(linux, dir, sub));
50 const win = join(homedir(), "AppData/Local/ms-playwright");
51 if (existsSync(win)) for (const dir of readdirSync(win).sort().reverse()) for (const sub of ["chrome-win/chrome.exe", "chromium-win64/chrome.exe"]) candidates.push(join(win, dir, sub));
52 return candidates.find((path) => existsSync(path));
53}
55async function browser(target, { width, height, scale, throttle, noWebgl }) {
56 const binary = chromePath();
57 if (!binary) {
58 console.error("No Chrome found. Set CHROME_PATH to a Chrome or Chromium binary.");
59 process.exit(1);
60 }
61 const profile = mkdtempSync(join(tmpdir(), "turn-check-"));
62 const chrome = spawn(binary, ["--headless=new", ...(process.platform === "linux" ? ["--no-sandbox"] : []), ...(process.platform === "win32" ? ["--do-not-de-elevate"] : []), "--remote-debugging-port=0", `--user-data-dir=${profile}`, "--no-first-run", "--no-default-browser-check", "--hide-scrollbars", "--allow-file-access-from-files", "--enable-unsafe-swiftshader", "--ignore-gpu-blocklist", "about:blank"]);
63 const port = await chromePort(chrome, profile);
64 const page = await (await fetch(`http://127.0.0.1:${port}/json/new?about:blank`, { method: "PUT" })).json();
65 const socket = new WebSocket(page.webSocketDebuggerUrl);
66 let id = 0;
67 const pending = new Map();
68 const problems = [];
69 socket.onmessage = (message) => {
70 const data = JSON.parse(message.data);
71 if (data.id && pending.has(data.id)) {
72 pending.get(data.id)(data);
73 pending.delete(data.id);
74 } else if (data.method === "Runtime.consoleAPICalled" && (data.params.type === "error" || data.params.type === "warning")) problems.push(`console.${data.params.type}: ${data.params.args.map((arg) => arg.value ?? arg.description ?? "").join(" ").slice(0, 400)}`);
75 else if (data.method === "Runtime.exceptionThrown") problems.push(`exception: ${data.params.exceptionDetails.exception?.description ?? data.params.exceptionDetails.text}`.slice(0, 400));
76 };
77 await new Promise((done) => (socket.onopen = done));
78 const send = (method, params = {}) =>
79 new Promise((done) => {
80 const call = ++id;
81 pending.set(call, done);
82 socket.send(JSON.stringify({ id: call, method, params }));
83 });
84 const evaluate = async (expression) => {
85 const result = await send("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true });
86 if (result.result?.exceptionDetails) throw new Error(JSON.stringify(result.result.exceptionDetails).slice(0, 800));
87 return result.result?.result?.value;
88 };
89 await send("Runtime.enable");
90 await send("Page.enable");
91 const NO_GL = `(()=>{const get=HTMLCanvasElement.prototype.getContext;HTMLCanvasElement.prototype.getContext=function(kind,...rest){if(/webgl/.test(String(kind))&&!this.dataset.check)return null;return get.call(this,kind,...rest)}})();`;
92 if (noWebgl) await send("Page.addScriptToEvaluateOnNewDocument", { source: NO_GL });
93 await send("Emulation.setDeviceMetricsOverride", { width, height, deviceScaleFactor: scale, mobile: width < 600 });
94 if (throttle > 1) await send("Emulation.setCPUThrottlingRate", { rate: throttle });
95 const url = /^https?:|^file:/.test(target) ? target : pathToFileURL(resolve(target)).href;
96 await send("Page.navigate", { url });
97 await new Promise((done) => setTimeout(done, 1500));
98 const close = () => {
99 socket.close();
100 chrome.kill("SIGKILL");
101 try {
102 rmSync(profile, { recursive: true, force: true });
103 } catch {}
104 };
105 return { send, evaluate, close, problems };
106}
108const HELPERS = String.raw`(() => {
109 const stage = document.querySelector(".iso-turn") || document.querySelector(".iso-stage");
110 const svgs = [...stage.querySelectorAll(":scope > svg")];
111 const hook = window.__isoTurn;
112 const C = hook.controller;
113 const vb = svgs[0].viewBox.baseVal;
114 const W = vb.width;
115 const H = vb.height;
116 const css = [...document.querySelectorAll("style")].map((s) => s.textContent).join("\n");
117 const theme = (stage.closest("[data-theme]") || document.body).getAttribute("data-theme") || "dark";
118 const setState = (state) => (typeof state === "number" ? C.set(state) : C.set(state));
119 const matrixOf = (el) => {
120 const m = el.ownerSVGElement.getScreenCTM().inverse().multiply(el.getScreenCTM());
121 return "matrix(" + m.a + " " + m.b + " " + m.c + " " + m.d + " " + m.e + " " + m.f + ")";
122 };
123 const decode = async (svg, w, h) => {
124 const url = URL.createObjectURL(new Blob([svg], { type: "image/svg+xml" }));
125 const image = new Image();
126 await new Promise((done, fail) => { image.onload = done; image.onerror = fail; image.src = url; });
127 const canvas = Object.assign(document.createElement("canvas"), { width: w, height: h });
128 const ctx = canvas.getContext("2d", { willReadFrequently: true });
129 ctx.drawImage(image, 0, 0, w, h);
130 URL.revokeObjectURL(url);
131 return ctx.getImageData(0, 0, w, h).data;
132 };
133 window.__tc = { stage, svgs, hook, C, W, H, css, theme, setState, matrixOf, decode };
134 return { W, H, layers: svgs.map((s) => s.dataset.live || null), theme };
135})()`;
137const ZBUFFER = String.raw`(() => {
138 const { svgs, C, W, H, setState, matrixOf, decode } = window.__tc;
139 const V = window.__isoVerify;
140 const SCALE = 2;
141 const w = W * SCALE;
142 const h = H * SCALE;
143 const STATIC = 30000;
144 const add3 = (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
145 const mul3 = (a, k) => [a[0] * k, a[1] * k, a[2] * k];
146 const sub3 = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
147 const dot3 = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
148 const cross3 = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
149 const unit3 = (a) => { const l = Math.hypot(a[0], a[1], a[2]) || 1; return [a[0] / l, a[1] / l, a[2] / l]; };
150 const frameOf = (F) => {
151 if (F.u && F.v) return F;
152 const a = unit3(F.a);
153 const helper = Math.abs(a[2]) > 0.9 ? [1, 0, 0] : [0, 0, 1];
154 const u = unit3(cross3(helper, a));
155 return { o: F.o, a, u, v: cross3(a, u) };
156 };
157 function meshOf(shape) {
158 const tris = [];
159 const quad = (a, b, c, d) => tris.push(a, b, c, a, c, d);
160 if (shape.kind === "prism") {
161 const F = frameOf(shape.F);
162 const at = (s, [x, y]) => add3(add3(F.o, mul3(F.a, s)), add3(mul3(F.u, x), mul3(F.v, y)));
163 const n = shape.poly.length;
164 const centre = shape.poly.reduce((s, p) => [s[0] + p[0] / n, s[1] + p[1] / n], [0, 0]);
165 for (const s of [shape.s0, shape.s1]) for (let i = 0; i < n; i++) tris.push(at(s, centre), at(s, shape.poly[i]), at(s, shape.poly[(i + 1) % n]));
166 for (let i = 0; i < n; i++) quad(at(shape.s0, shape.poly[i]), at(shape.s0, shape.poly[(i + 1) % n]), at(shape.s1, shape.poly[(i + 1) % n]), at(shape.s1, shape.poly[i]));
167 } else if (shape.kind === "body") {
168 const F = frameOf(shape.F);
169 const count = 72;
170 const ring = (s, r) => Array.from({ length: count }, (_, j) => { const t = (j / count) * Math.PI * 2; return add3(add3(F.o, mul3(F.a, s)), add3(mul3(F.u, r * Math.cos(t)), mul3(F.v, r * Math.sin(t)))); });
171 const poly = shape.poly;
172 for (let i = 0; i < poly.length; i++) {
173 const [s0, r0] = poly[i];
174 const [s1, r1] = poly[(i + 1) % poly.length];
175 if (r0 < 1e-9 && r1 < 1e-9) continue;
176 const a = ring(s0, r0);
177 const b = ring(s1, r1);
178 for (let j = 0; j < count; j++) quad(a[j], a[(j + 1) % count], b[(j + 1) % count], b[j]);
179 }
180 } else if (shape.kind === "ball") {
181 const rows = 32;
182 const cols = 64;
183 const flats = shape.flats || [];
184 const at = (i, j) => {
185 const phi = (i / rows) * Math.PI;
186 const t = (j / cols) * Math.PI * 2;
187 let n = [Math.sin(phi) * Math.cos(t), Math.sin(phi) * Math.sin(t), Math.cos(phi)];
188 let p = mul3(n, shape.r);
189 for (const [nx, ny, nz, d] of flats) {
190 const k = nx * p[0] + ny * p[1] + nz * p[2];
191 if (k > d) p = sub3(p, mul3([nx, ny, nz], k - d));
192 }
193 return add3(shape.o, p);
194 };
195 for (let i = 0; i < rows; i++) for (let j = 0; j < cols; j++) quad(at(i, j), at(i, j + 1), at(i + 1, j + 1), at(i + 1, j));
196 } else if (shape.kind === "box") {
197 const c = (sx, sy, sz) => add3(shape.o, add3(add3(mul3(shape.axes[0], sx * shape.half[0]), mul3(shape.axes[1], sy * shape.half[1])), mul3(shape.axes[2], sz * shape.half[2])));
198 const v = [c(-1, -1, -1), c(1, -1, -1), c(1, 1, -1), c(-1, 1, -1), c(-1, -1, 1), c(1, -1, 1), c(1, 1, 1), c(-1, 1, 1)];
199 for (const [a, b, cc, d] of [[0, 1, 2, 3], [4, 5, 6, 7], [0, 1, 5, 4], [1, 2, 6, 5], [2, 3, 7, 6], [3, 0, 4, 7]]) quad(v[a], v[b], v[cc], v[d]);
200 } else if (shape.kind === "tube") {
201 const pts = [];
202 const radii = [];
203 shape.points.forEach((p, i) => {
204 if (pts.length && Math.hypot(p[0] - pts[pts.length - 1][0], p[1] - pts[pts.length - 1][1], p[2] - pts[pts.length - 1][2]) < 1e-6) return;
205 pts.push(p);
206 radii.push(shape.radii[i]);
207 });
208 if (pts.length < 2) return tris;
209 const count = 20;
210 let u = null;
211 const rings = pts.map((p, i) => {
212 const t = unit3(sub3(pts[Math.min(pts.length - 1, i + 1)], pts[Math.max(0, i - 1)]));
213 if (!u) { const helper = Math.abs(t[2]) > 0.9 ? [1, 0, 0] : [0, 0, 1]; u = unit3(cross3(helper, t)); } else u = unit3(sub3(u, mul3(t, dot3(u, t))));
214 const v = cross3(t, u);
215 const r = radii[i];
216 return Array.from({ length: count }, (_, j) => { const a = (j / count) * Math.PI * 2; return add3(p, add3(mul3(u, r * Math.cos(a)), mul3(v, r * Math.sin(a)))); });
217 });
218 for (let i = 0; i < rings.length - 1; i++) for (let j = 0; j < count; j++) quad(rings[i][j], rings[i][(j + 1) % count], rings[i + 1][(j + 1) % count], rings[i + 1][j]);
219 for (const [index, p] of [[0, pts[0]], [rings.length - 1, pts[pts.length - 1]]]) for (let j = 0; j < count; j++) tris.push(p, rings[index][j], rings[index][(j + 1) % count]);
220 }
221 return tris;
222 }
223 const canvas = Object.assign(document.createElement("canvas"), { width: w, height: h });
224 canvas.dataset.check = "1";
225 const gl = canvas.getContext("webgl", { antialias: false, preserveDrawingBuffer: true, alpha: false, depth: true });
226 const vs = "attribute vec3 p;uniform vec3 uR0;uniform vec3 uR1;uniform vec3 uR2;uniform vec3 uT;uniform vec3 uM0;uniform vec3 uM1;uniform vec2 uO;uniform vec3 uV;uniform vec2 uView;uniform float uSpan;void main(){vec3 q=vec3(dot(uR0,p),dot(uR1,p),dot(uR2,p))+uT;vec2 s=uO+vec2(dot(uM0,q),dot(uM1,q));gl_Position=vec4(s.x/uView.x*2.-1.,1.-s.y/uView.y*2.,-dot(uV,q)/uSpan,1.);}";
227 const fs = "precision highp float;uniform vec3 uId;void main(){gl_FragColor=vec4(uId,1.);}";
228 const compile = (type, source) => { const sh = gl.createShader(type); gl.shaderSource(sh, source); gl.compileShader(sh); if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(sh)); return sh; };
229 const program = gl.createProgram();
230 gl.attachShader(program, compile(gl.VERTEX_SHADER, vs));
231 gl.attachShader(program, compile(gl.FRAGMENT_SHADER, fs));
232 gl.linkProgram(program);
233 gl.useProgram(program);
234 const loc = (name) => gl.getUniformLocation(program, name);
235 const where = { p: gl.getAttribLocation(program, "p"), R0: loc("uR0"), R1: loc("uR1"), R2: loc("uR2"), T: loc("uT"), M0: loc("uM0"), M1: loc("uM1"), O: loc("uO"), V: loc("uV"), view: loc("uView"), span: loc("uSpan"), id: loc("uId") };
236 const bufferOf = (shapes) => {
237 const pos = [];
238 for (const shape of shapes) for (const p of meshOf(shape)) pos.push(p[0], p[1], p[2]);
239 const b = gl.createBuffer();
240 gl.bindBuffer(gl.ARRAY_BUFFER, b);
241 gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(pos), gl.STATIC_DRAW);
242 return { b, count: pos.length / 3 };
243 };
244 const parts = V.parts.map((part, index) => ({ index, group: part.group, name: part.name, mesh: bufferOf(part.shapes) }));
245 const statics = V.statics.map((item) => ({ rank: item.rank, mesh: bufferOf(item.shapes) }));
246 const colour = (id) => [((id + 1) & 255) / 255, (((id + 1) >> 8) & 255) / 255, 0];
247 const zIds = () => {
248 const cams = C.cams();
249 const world = cams.world;
250 gl.viewport(0, 0, w, h);
251 gl.enable(gl.DEPTH_TEST);
252 gl.depthFunc(gl.LESS);
253 gl.clearColor(0, 0, 0, 1);
254 gl.clearDepth(1);
255 gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
256 gl.uniform3f(where.M0, world.m[0], world.m[1], world.m[2]);
257 gl.uniform3f(where.M1, world.m[3], world.m[4], world.m[5]);
258 gl.uniform2f(where.O, world.ox, world.oy);
259 gl.uniform3f(where.V, world.V[0], world.V[1], world.V[2]);
260 gl.uniform2f(where.view, W, H);
261 gl.uniform1f(where.span, 4000);
262 const draw = (mesh, R, t, id) => {
263 gl.uniform3f(where.R0, R[0], R[1], R[2]);
264 gl.uniform3f(where.R1, R[3], R[4], R[5]);
265 gl.uniform3f(where.R2, R[6], R[7], R[8]);
266 gl.uniform3f(where.T, t[0], t[1], t[2]);
267 gl.uniform3fv(where.id, colour(id));
268 gl.bindBuffer(gl.ARRAY_BUFFER, mesh.b);
269 gl.enableVertexAttribArray(where.p);
270 gl.vertexAttribPointer(where.p, 3, gl.FLOAT, false, 0, 0);
271 gl.drawArrays(gl.TRIANGLES, 0, mesh.count);
272 };
273 const eye = [1, 0, 0, 0, 1, 0, 0, 0, 1];
274 for (const item of statics) draw(item.mesh, eye, [0, 0, 0], STATIC + item.rank);
275 for (const part of parts) {
276 const cam = part.group >= 0 ? cams[part.group] : null;
277 draw(part.mesh, cam ? cam.R : eye, cam ? cam.t : [0, 0, 0], part.index);
278 }
279 const pixels = new Uint8Array(w * h * 4);
280 gl.readPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
281 const ids = new Int32Array(w * h);
282 for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) { const i = ((h - 1 - y) * w + x) * 4; ids[y * w + x] = pixels[i] + pixels[i + 1] * 256 - 1; }
283 return ids;
284 };
285 const toId = (id) => "rgb(" + ((id + 1) & 255) + "," + (((id + 1) >> 8) & 255) + ",0)";
286 const painterSvg = (reverse = false) => {
287 const out = [];
288 let rank = 0;
289 for (const svg of svgs) {
290 if (svg.dataset.live) {
291 const nodes = [...svg.querySelectorAll("[data-p]")];
292 if (reverse) nodes.reverse();
293 for (const node of nodes) for (const el of node.querySelectorAll(".iso-fill, .tb-body")) out.push('<path d="' + el.getAttribute("d") + '" transform="' + matrixOf(el) + '" fill-rule="' + (el.getAttribute("fill-rule") || "nonzero") + '" fill="' + toId(Number(node.dataset.p)) + '"/>');
294 } else {
295 for (const el of svg.querySelectorAll(".iso-fill, .tb-body")) out.push('<path d="' + el.getAttribute("d") + '" transform="' + matrixOf(el) + '" fill-rule="' + (el.getAttribute("fill-rule") || "nonzero") + '" fill="' + toId(STATIC + rank) + '"/>');
296 rank++;
297 }
298 }
299 return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + W + " " + H + '" width="' + w + '" height="' + h + '" shape-rendering="crispEdges"><rect width="100%" height="100%" fill="rgb(0,0,0)"/>' + out.join("") + "</svg>";
300 };
301 const pIds = async (reverse) => {
302 const data = await decode(painterSvg(reverse), w, h);
303 const ids = new Int32Array(w * h);
304 for (let i = 0; i < w * h; i++) ids[i] = data[i * 4] + data[i * 4 + 1] * 256 - 1;
305 return ids;
306 };
307 const nameOf = (id) => (id >= STATIC ? "static layer " + (id - STATIC) : id < 0 ? "empty" : V.parts[id].name);
308 const joint = (a, b) => {
309 const m = /^(.*):(\d+)$/;
310 const x = a.match(m);
311 const y = b.match(m);
312 return Boolean(x && y && x[1] === y[1] && Math.abs(Number(x[2]) - Number(y[2])) <= 1);
313 };
314 async function check(state, { reverse = false } = {}) {
315 setState(state);
316 const Z = zIds();
317 const P = await pIds(reverse);
318 const tally = new Map();
319 const interior = (ids, i, x, y) => {
320 const v = ids[i];
321 for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) { const xx = x + dx; const yy = y + dy; if (xx < 0 || yy < 0 || xx >= w || yy >= h || ids[yy * w + xx] !== v) return false; }
322 return true;
323 };
324 let total = 0;
325 for (let y = 1; y < h - 1; y++)
326 for (let x = 1; x < w - 1; x++) {
327 const i = y * w + x;
328 const p = P[i];
329 const z = Z[i];
330 if (p === z || p < 0 || z < 0) continue;
331 if (!interior(P, i, x, y) || !interior(Z, i, x, y)) continue;
332 const key = p + "|" + z;
333 tally.set(key, (tally.get(key) || 0) + 1);
334 total++;
335 }
336 const live = [];
337 const layers = [];
338 for (const [key, px] of tally) {
339 const [p, z] = key.split("|").map(Number);
340 const a = nameOf(p);
341 const b = nameOf(z);
342 if (p >= STATIC || z >= STATIC) layers.push({ painter: a, truth: b, px });
343 else if (!joint(a, b)) live.push({ painter: a, truth: b, px });
344 }
345 live.sort((x, y) => y.px - x.px);
346 layers.sort((x, y) => y.px - x.px);
347 return { total, live, layers, stats: C.stats() };
348 }
349 async function footprint(state, name) {
350 setState(state);
351 const index = V.parts.findIndex((part) => part.name === name);
352 const Z = zIds();
353 const P = await pIds(false);
354 const boxOf = (ids) => {
355 let n = 0, x0 = w, y0 = h, x1 = -1, y1 = -1;
356 for (let i = 0; i < ids.length; i++) if (ids[i] === index) { n++; const x = i % w, y = (i / w) | 0; x0 = Math.min(x0, x); y0 = Math.min(y0, y); x1 = Math.max(x1, x); y1 = Math.max(y1, y); }
357 return { px: n, box: n ? [x0 / SCALE, y0 / SCALE, x1 / SCALE, y1 / SCALE] : null };
358 };
359 return { painter: boxOf(P), truth: boxOf(Z) };
360 }
361 window.__tc.order = { check, footprint };
362 return { parts: parts.length, statics: statics.length, w, h };
363})()`;
365const POPS = String.raw`(() => {
366 const { stage, svgs, W, H, css, theme, setState, decode } = window.__tc;
367 const SCALE = 2;
368 const w = W * SCALE;
369 const h = H * SCALE;
370 let hidden = null;
371 const svgString = () => '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + W + " " + H + '" width="' + w + '" height="' + h + '" class="iso iso-svg" data-theme="' + theme + '" style="--anatomy-paper:var(--anatomy-card)"><style>' + css + '</style><rect width="100%" height="100%" style="fill:var(--anatomy-card)"/>' + svgs.map((s) => '<g class="' + (s.getAttribute("class") || "") + '">' + s.innerHTML + "</g>").join("") + "</svg>";
372 async function render(state, hide = null) {
373 setState(state);
374 if (hide !== null) {
375 hidden = stage.querySelector('[data-p="' + hide + '"]');
376 if (hidden) hidden.setAttribute("display", "none");
377 }
378 const data = await decode(svgString(), w, h);
379 if (hidden) {
380 hidden.removeAttribute("display");
381 hidden = null;
382 }
383 const lum = new Float32Array(w * h);
384 for (let i = 0; i < w * h; i++) lum[i] = 0.2126 * data[i * 4] + 0.7152 * data[i * 4 + 1] + 0.0722 * data[i * 4 + 2];
385 return lum;
386 }
387 const erode = (mask) => {
388 const out = new Uint8Array(w * h);
389 for (let y = 1; y < h - 1; y++)
390 for (let x = 1; x < w - 1; x++) {
391 const i = y * w + x;
392 if (!mask[i]) continue;
393 let all = 1;
394 for (let dy = -1; dy <= 1 && all; dy++) for (let dx = -1; dx <= 1; dx++) if (!mask[i + dy * w + dx]) { all = 0; break; }
395 out[i] = all;
396 }
397 return out;
398 };
399 const regionOf = (mask) => {
400 let n = 0;
401 let x0 = w, y0 = h, x1 = -1, y1 = -1;
402 for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) if (mask[y * w + x]) { n++; x0 = Math.min(x0, x); y0 = Math.min(y0, y); x1 = Math.max(x1, x); y1 = Math.max(y1, y); }
403 return { n, box: n ? [x0 / SCALE, y0 / SCALE, x1 / SCALE, y1 / SCALE].map((v) => Math.round(v * 10) / 10) : null };
404 };
405 async function first(a, b, hide = null) {
406 const A = await render(a);
407 const B = await render(b, hide);
408 let mask = new Uint8Array(w * h);
409 for (let i = 0; i < w * h; i++) if (Math.abs(A[i] - B[i]) >= 5) mask[i] = 1;
410 mask = erode(erode(mask));
411 return regionOf(mask);
412 }
413 let cache = null;
414 async function second(a, b, c) {
415 const A = cache && cache.x === a ? cache.lum : await render(a);
416 const B = cache && cache.y === b ? cache.mid : await render(b);
417 const Cl = await render(c);
418 cache = { x: b, lum: B, y: c, mid: Cl };
419 let mask = new Uint8Array(w * h);
420 for (let y = 1; y < h - 1; y++)
421 for (let x = 1; x < w - 1; x++) {
422 const i = y * w + x;
423 const e = Math.abs(2 * B[i] - A[i] - Cl[i]);
424 if (e <= 6) continue;
425 let flat = true;
426 for (let dy = -1; dy <= 1 && flat; dy++) for (let dx = -1; dx <= 1; dx++) if (Math.abs(B[i + dy * w + dx] - B[i]) > 4) { flat = false; break; }
427 if (e > 56 || flat) mask[i] = 1;
428 }
429 mask = erode(mask);
430 const region = regionOf(mask);
431 if (window.__tcDump && region.n >= 6 && !window.__tcDumped) {
432 window.__tcDumped = true;
433 const png = (lum, scaleBy = 1) => {
434 const canvas = Object.assign(document.createElement("canvas"), { width: w, height: h });
435 const ctx = canvas.getContext("2d");
436 const img = ctx.createImageData(w, h);
437 for (let i = 0; i < w * h; i++) {
438 const v = Math.max(0, Math.min(255, lum[i] * scaleBy));
439 img.data[i * 4] = img.data[i * 4 + 1] = img.data[i * 4 + 2] = v;
440 img.data[i * 4 + 3] = 255;
441 }
442 ctx.putImageData(img, 0, 0);
443 return canvas.toDataURL("image/png").split(",")[1];
444 };
445 const diff = new Float32Array(w * h);
446 for (let i = 0; i < w * h; i++) diff[i] = Math.abs(2 * B[i] - A[i] - Cl[i]);
447 window.__tcDumped = { a: png(A), b: png(B), c: png(Cl), e: png(diff, 4) };
448 }
449 return region;
450 }
451 window.__tc.pops = { first, second, render };
452 return { w, h };
453})()`;
455const PERF = String.raw`(async (frames, step, group) => {
456 const { C, stage } = window.__tc;
457 const name = group || C.scene().groups.find((g) => g.kind === "turn")?.name;
458 const base = C.values[name] || 0;
459 const ms = [];
460 const writes = [];
461 const moved = [];
462 const times = [];
463 await new Promise((done) => {
464 let i = 0;
465 const tick = (now) => {
466 times.push(now);
467 const s = C.set({ [name]: base + i * step });
468 ms.push(s.ms);
469 writes.push(s.writes);
470 moved.push(s.moved);
471 i++;
472 if (i < frames) requestAnimationFrame(tick);
473 else done();
474 };
475 requestAnimationFrame(tick);
476 });
477 const gaps = times.slice(1).map((t, i) => t - times[i]).sort((a, b) => a - b);
478 const sorted = (list) => list.slice().sort((a, b) => a - b);
479 const median = (list) => sorted(list)[Math.floor(list.length / 2)];
480 const p95 = (list) => sorted(list)[Math.floor(list.length * 0.95)];
481 const live = [...stage.querySelectorAll("svg[data-live] *")].length;
482 return { frames, setMedian: median(ms), setP95: p95(ms), writes: median(writes), moved: median(moved), gapMedian: median(gaps), gapP95: p95(gaps), live };
483})`;
485async function main() {
486 const argv = process.argv.slice(2);
487 if (!argv.length || argv.includes("--help") || argv.includes("-h")) {
488 usage();
489 process.exit(argv.length ? 0 : 1);
490 }
491 const value = (flag, fallback) => {
492 const at = argv.indexOf(flag);
493 return at >= 0 ? argv[at + 1] : fallback;
494 };
495 const target = argv[0];
496 let failed = 0;
497 if (target.endsWith(".mjs")) {
498 const F = await import("./turn-fidelity.mjs");
499 const { T, P } = await import(pathToFileURL(resolve(target)).href);
500 const angles = (value("--angles", "") || Array.from({ length: 12 }, (_, i) => 7 + i * 30).join(",")).split(",").map(Number);
501 const plant = T.parts.find((part) => part.kind === "prism" && part.group && part.group.kind === "turn");
502 if (plant) {
503 const control = F.identity(T, P, angles.slice(0, 2), { plant: plant.name });
504 console.log(`control (${plant.name} drawn 2° off): ${control.pass ? "did not fail: the check is broken" : "fails as it should"}`);
505 if (control.pass) failed++;
506 }
507 for (const [name, run] of [["prisms", F.identity], ["rounds", F.rounds], ["tubes", F.tubes]]) {
508 const result = run(T, P, angles);
509 console.log(result.text);
510 for (const line of result.worst) console.log(` ${line}`);
511 if (!result.pass) {
512 console.log(`${name} FAILED`);
513 failed++;
514 }
515 }
516 console.log(failed ? `fidelity failed: ${failed}` : "fidelity passed");
517 process.exit(failed ? 1 : 0);
518 }
519 const options = { width: Number(value("--width", 1440)), height: Number(value("--height", 1100)), scale: Number(value("--scale", argv.includes("--perf") ? (Number(value("--width", 1440)) < 600 ? 3 : 2) : 1)), throttle: Number(value("--throttle", 1)), noWebgl: argv.includes("--no-webgl") };
520 const step = Number(value("--step", 1));
521 if (!Number.isFinite(step) || step <= 0 || step > 360) throw new Error("--step must be a finite number greater than 0 and at most 360 degrees");
522 const fine = Number(value("--fine", 0.1));
523 if (argv.includes("--pops") && (!Number.isFinite(fine) || fine <= 0 || fine > 180)) throw new Error("--fine must be a finite number greater than 0 and at most 180 degrees");
524 const out = value("--out", null);
525 const statesFile = value("--states", null);
526 const states = statesFile ? JSON.parse(readFileSync(statesFile, "utf8")) : null;
527 if (statesFile && (!Array.isArray(states) || !states.length)) throw new Error("--states must contain a nonempty JSON list of poses");
528 const page = await browser(target, options);
529 const labelOf = (state, index) => (typeof state === "number" ? `${state.toFixed(3)}°` : `state ${index}`);
530 try {
531 const info = await page.evaluate(HELPERS);
532 console.log(`page ${info.W}×${info.H} · live layers ${info.layers.filter(Boolean).join(", ")} · theme ${info.theme}`);
533 await page.evaluate(`window.__isoTurn.set(0) && 0`);
534 const sweep = states ?? Array.from({ length: Math.round(360 / step) }, (_, i) => i * step);
535 if (argv.includes("--order")) {
536 const has = await page.evaluate("Boolean(window.__isoVerify)");
537 if (!has) {
538 console.log("order: this page has no window.__isoVerify; build it with --verify");
539 failed++;
540 } else {
541 const z = await page.evaluate(ZBUFFER);
542 console.log(`order: ${z.parts} live parts and ${z.statics} static items meshed, z-buffer ${z.w}×${z.h}`);
543 let control = Infinity;
544 for (const angle of [0, 90, 180, 270]) {
545 const r = await page.evaluate(`window.__tc.order.check(${angle}, { reverse: true }).then((r) => r.live.reduce((s, p) => s + p.px, 0))`);
546 control = Math.min(control, r);
547 }
548 console.log(`order control (each live layer reversed at 0, 90, 180, 270): least ${control} mismatched px ${control >= 1000 ? "· fails as it should" : "· did not fail: the check is broken"}`);
549 if (control < 1000) failed++;
550 const probe = value("--part", null);
551 if (probe) for (const state of sweep.slice(0, 12)) console.log(` ${probe} at ${JSON.stringify(state)}: ${JSON.stringify(await page.evaluate(`window.__tc.order.footprint(${JSON.stringify(state)}, ${JSON.stringify(probe)})`))}`);
552 let bad = 0;
553 const layerNotes = new Map();
554 let worst = 0;
555 for (let index = 0; index < sweep.length; index++) {
556 const state = sweep[index];
557 const r = await page.evaluate(`window.__tc.order.check(${JSON.stringify(state)})`);
558 const errors = r.live.filter((p) => p.px >= 4);
559 worst = Math.max(worst, ...r.live.map((p) => p.px), 0);
560 if (errors.length) {
561 bad++;
562 if (bad <= 30) console.log(` ${labelOf(state, index)}: ${errors.map((e) => `${e.painter} over ${e.truth} ${e.px} px`).join(" · ")}${r.stats.forced ? ` · forced ${r.stats.forced}` : ""}`);
563 }
564 for (const l of r.layers) if (l.px >= 16) layerNotes.set(`${l.painter} / ${l.truth}`, Math.max(layerNotes.get(`${l.painter} / ${l.truth}`) ?? 0, l.px));
565 }
566 console.log(`order: ${sweep.length} states · ${bad} with a live pair drawn out of order (4+ interior px at 2x) · worst live pair ${worst} px`);
567 if (layerNotes.size) console.log(` static × live (recorded static shapes are stand-ins; the layer proofs own these): ${[...layerNotes].slice(0, 8).map(([k, v]) => `${k} ${v}px`).join(" · ")}`);
568 if (bad) failed++;
569 }
570 }
571 if (argv.includes("--pops")) {
572 await page.evaluate(POPS);
573 if (out) await page.evaluate("window.__tcDump = true");
574 const plantPart = await page.evaluate(`(() => { const svgs = [...document.querySelectorAll("svg[data-live]")]; const parts = [...svgs[svgs.length - 1].querySelectorAll("[data-p]")]; let best = null; let area = 0; for (const el of parts) { const b = el.getBBox(); if (b.width * b.height > area) { area = b.width * b.height; best = el; } } return best ? Number(best.dataset.p) : null; })()`);
575 const plantAt = typeof sweep[0] === "number" ? 37 : sweep[0];
576 const control = await page.evaluate(`window.__tc.pops.first(${JSON.stringify(plantAt)}, ${JSON.stringify(typeof plantAt === "number" ? plantAt + 0.02 : plantAt)}, ${plantPart})`);
577 console.log(`pops control (part ${plantPart} hidden for one frame): ${control.n ? `${control.n} px flagged · fails as it should` : "nothing flagged: the check is broken"}`);
578 if (!control.n) failed++;
579 let firstBad = 0;
580 const firstStates = states ?? Array.from({ length: Math.round(360 / step) }, (_, i) => i * step);
581 for (let index = 0; index < firstStates.length; index++) {
582 const a = firstStates[index];
583 const b = typeof a === "number" ? a + 0.02 : firstStates[index + 1];
584 if (b === undefined) break;
585 const r = await page.evaluate(`window.__tc.pops.first(${JSON.stringify(a)}, ${JSON.stringify(b)})`);
586 if (r.n) {
587 firstBad++;
588 if (firstBad <= 20) console.log(` first difference ${labelOf(a, index)}: ${r.n} px survive two erosions in ${r.box.join(",")}`);
589 }
590 }
591 console.log(`pops, first difference: ${firstStates.length} steps · ${firstBad} with a surviving region`);
592 if (firstBad) failed++;
593 if (!states) {
594 let cores = 0;
595 const small = [];
596 for (let x = 0; x + 2 * fine <= 360 + 1e-9; x += fine) {
597 const r = await page.evaluate(`window.__tc.pops.second(${x.toFixed(4)}, ${(x + fine).toFixed(4)}, ${(x + 2 * fine).toFixed(4)})`);
598 if (r.n >= 6) {
599 cores++;
600 if (cores <= 20) console.log(` second difference at ${(x + fine).toFixed(2)}°: core of ${r.n} px in ${r.box.join(",")}`);
601 } else if (r.n) small.push(`${(x + fine).toFixed(2)}° ${r.n} px in ${r.box.join(",")}`);
602 }
603 console.log(`pops, second difference at ${fine}° over a full turn: ${cores} cores of 6+ px · ${small.length} small cores to read${small.length ? `: ${small.slice(0, 12).join(" · ")}` : ""}`);
604 const dumped = out ? await page.evaluate("window.__tcDumped && typeof window.__tcDumped === 'object' ? window.__tcDumped : null") : null;
605 if (dumped) {
606 mkdirSync(out, { recursive: true });
607 for (const [key, data] of Object.entries(dumped)) writeFileSync(join(out, `pops-core-${key}.png`), Buffer.from(data, "base64"));
608 console.log(` first core dumped to ${out}/pops-core-{a,b,c,e}.png`);
609 }
610 if (cores) failed++;
611 }
612 }
613 if (argv.includes("--lines")) {
614 const angles = Array.from({ length: 24 }, (_, i) => i * 15);
615 const planted = await page.evaluate(`(() => { window.__isoTurn.set(0); const part = document.querySelector("svg[data-live] [data-p]"); const el = document.createElementNS("http://www.w3.org/2000/svg", "path"); el.setAttribute("class", "iso-line"); el.setAttribute("data-tone", "lo"); el.setAttribute("d", "M5 5L25 9"); el.setAttribute("data-plant", ""); part.appendChild(el); const r = (${LINE_ENDS})({ select: null, show: false }); el.remove(); return r ? r.problems.length : -1; })()`);
616 console.log(`lines control (a planted floating line): ${planted > 0 ? "fails as it should" : "nothing flagged: the check is broken"}`);
617 if (!(planted > 0)) failed++;
618 let bad = 0;
619 for (const angle of angles) {
620 const r = await page.evaluate(`(() => { window.__isoTurn.set(${angle}); const r = (${LINE_ENDS})({ select: null, show: false }); return r ? { n: r.problems.length, list: r.problems.slice(0, 4).map((p) => p.what + " " + p.end + " " + p.gap.toFixed(2) + " px at " + p.at.join(",")) } : null; })()`);
621 if (r && r.n) {
622 bad += r.n;
623 console.log(` ${angle}°: ${r.n} floating ends: ${r.list.join(" · ")}`);
624 }
625 }
626 console.log(`lines: ${angles.length} angles · ${bad} floating ends`);
627 if (bad) failed++;
628 }
629 if (argv.includes("--perf")) {
630 await page.send("Performance.enable");
631 const metrics = async () => Object.fromEntries((await page.send("Performance.getMetrics")).result.metrics.map((m) => [m.name, m.value]));
632 const runs = [];
633 for (let r = 0; r < 3; r++) {
634 const before = await metrics();
635 const result = await page.evaluate(`(${PERF})(240, 1.1, null)`);
636 const after = await metrics();
637 const per = (key) => ((after[key] - before[key]) * 1000) / result.frames;
638 runs.push({ ...result, task: per("TaskDuration"), script: per("ScriptDuration"), style: per("RecalcStyleDuration"), layout: per("LayoutDuration") });
639 }
640 const med = (key) => runs.map((r) => r[key]).sort((a, b) => a - b)[1];
641 console.log(`perf at ${options.width} px, DPR ${options.scale}, ${options.throttle}× CPU, median of 3 runs of 240 frames at 1.1°: set() ${med("setMedian").toFixed(2)} ms (p95 ${med("setP95").toFixed(2)}) · task ${med("task").toFixed(2)} ms · script ${med("script").toFixed(2)} ms · style ${med("style").toFixed(2)} ms · layout ${med("layout").toFixed(2)} ms per frame · frame interval ${med("gapMedian").toFixed(1)} ms (p95 ${med("gapP95").toFixed(1)}) · ${runs[0].live} live elements · ${med("writes")} writes and ${med("moved")} moves per frame`);
642 if (out) {
643 mkdirSync(out, { recursive: true });
644 writeFileSync(join(out, `perf-${options.width}-${options.throttle}x.json`), JSON.stringify(runs, null, 1));
645 }
646 }
647 console.log(page.problems.length ? page.problems.join("\n") : "console: clean");
648 if (page.problems.length) failed++;
649 } catch (error) {
650 console.error(error);
651 failed++;
652 } finally {
653 page.close();
654 }
655 console.log(failed ? `turn check failed: ${failed}` : "turn check passed");
656 process.exit(failed ? 1 : 0);
657}
659if (typeof WebSocket === "undefined" && !process.execArgv.includes("--experimental-websocket")) {
660 const child = spawn(process.execPath, ["--experimental-websocket", ...process.argv.slice(1)], { stdio: "inherit" });
661 child.on("exit", (code) => process.exit(code ?? 1));
662} else {
663 await main();
664}