import { chromePort } from "./chrome-port.mjs"; import { spawn } from "node:child_process"; import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { LINE_ENDS } from "./line-ends.mjs"; function usage() { console.log(`Checks for 3D figures (turning groups). node turn-check.mjs build.mjs --fidelity [--angles 7,37,…] Node: every live prism against k.extrude / G.prismOf, every round and lathe against G.lathe(smooth) and every tube against tubePieces, at P'(θ). Control: one prism drawn 2° off must fail. node turn-check.mjs page.html [--order] [--pops] [--lines] [--perf] [--step 1] [--states states.json] [--width 1440] [--height 1100] [--scale 2] [--throttle 1] [--no-webgl] [--out dir] --order needs a --verify build: the recorded shapes in a WebGL z-buffer (part-ID colours, 2x) against the live layers' fills in DOM order. Fails on any live pair with 4 or more interior pixels. Static × live mismatches are listed (the layer proofs and the orbit audit own them). Control: each live layer's order reversed at 0, 90, 180 and 270 must give at least 1000 mismatched pixels. --pops first difference θ → θ+0.02° at every step, and second difference at 0.1° over a full turn; control: one part's fill hidden for one frame must fail. Run with --no-webgl and the tour held. --lines LINE_ENDS at 24 angles (run it on the light and the dark page); control: a planted floating line must fail. --perf 240 frames at 1.1° per frame driven through the controller inside requestAnimationFrame: script, task, style and layout per frame, frame interval median and p95, live elements, writes and moves per frame. --states a JSON list of value objects to visit instead of angles (gestures, a break-apart). The angle is the figure's first turn group (window.__isoTurn.set(θ)).`); } function chromePath() { const candidates = [process.env.CHROME_PATH, "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Chromium.app/Contents/MacOS/Chromium", "/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium", "/usr/bin/chromium-browser"].filter(Boolean); const playwright = join(homedir(), "Library/Caches/ms-playwright"); 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)); return candidates.find((path) => existsSync(path)); } async function browser(target, { width, height, scale, throttle, noWebgl }) { const profile = mkdtempSync(join(tmpdir(), "turn-check-")); const chrome = spawn(chromePath(), ["--headless=new", "--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"]); const port = await chromePort(chrome, profile); const page = await (await fetch(`http://127.0.0.1:${port}/json/new?about:blank`, { method: "PUT" })).json(); const socket = new WebSocket(page.webSocketDebuggerUrl); let id = 0; const pending = new Map(); const problems = []; socket.onmessage = (message) => { const data = JSON.parse(message.data); if (data.id && pending.has(data.id)) { pending.get(data.id)(data); pending.delete(data.id); } 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)}`); else if (data.method === "Runtime.exceptionThrown") problems.push(`exception: ${data.params.exceptionDetails.exception?.description ?? data.params.exceptionDetails.text}`.slice(0, 400)); }; await new Promise((done) => (socket.onopen = done)); const send = (method, params = {}) => new Promise((done) => { const call = ++id; pending.set(call, done); socket.send(JSON.stringify({ id: call, method, params })); }); const evaluate = async (expression) => { const result = await send("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true }); if (result.result?.exceptionDetails) throw new Error(JSON.stringify(result.result.exceptionDetails).slice(0, 800)); return result.result?.result?.value; }; await send("Runtime.enable"); await send("Page.enable"); 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)}})();`; if (noWebgl) await send("Page.addScriptToEvaluateOnNewDocument", { source: NO_GL }); await send("Emulation.setDeviceMetricsOverride", { width, height, deviceScaleFactor: scale, mobile: width < 600 }); if (throttle > 1) await send("Emulation.setCPUThrottlingRate", { rate: throttle }); const url = /^https?:|^file:/.test(target) ? target : pathToFileURL(resolve(target)).href; await send("Page.navigate", { url }); await new Promise((done) => setTimeout(done, 1500)); const close = () => { socket.close(); chrome.kill("SIGKILL"); try { rmSync(profile, { recursive: true, force: true }); } catch {} }; return { send, evaluate, close, problems }; } const HELPERS = String.raw`(() => { const stage = document.querySelector(".iso-turn") || document.querySelector(".iso-stage"); const svgs = [...stage.querySelectorAll(":scope > svg")]; const hook = window.__isoTurn; const C = hook.controller; const vb = svgs[0].viewBox.baseVal; const W = vb.width; const H = vb.height; const css = [...document.querySelectorAll("style")].map((s) => s.textContent).join("\n"); const theme = (stage.closest("[data-theme]") || document.body).getAttribute("data-theme") || "dark"; const setState = (state) => (typeof state === "number" ? C.set(state) : C.set(state)); const matrixOf = (el) => { const m = el.ownerSVGElement.getScreenCTM().inverse().multiply(el.getScreenCTM()); return "matrix(" + m.a + " " + m.b + " " + m.c + " " + m.d + " " + m.e + " " + m.f + ")"; }; const decode = async (svg, w, h) => { const url = URL.createObjectURL(new Blob([svg], { type: "image/svg+xml" })); const image = new Image(); await new Promise((done, fail) => { image.onload = done; image.onerror = fail; image.src = url; }); const canvas = Object.assign(document.createElement("canvas"), { width: w, height: h }); const ctx = canvas.getContext("2d", { willReadFrequently: true }); ctx.drawImage(image, 0, 0, w, h); URL.revokeObjectURL(url); return ctx.getImageData(0, 0, w, h).data; }; window.__tc = { stage, svgs, hook, C, W, H, css, theme, setState, matrixOf, decode }; return { W, H, layers: svgs.map((s) => s.dataset.live || null), theme }; })()`; const ZBUFFER = String.raw`(() => { const { svgs, C, W, H, setState, matrixOf, decode } = window.__tc; const V = window.__isoVerify; const SCALE = 2; const w = W * SCALE; const h = H * SCALE; const STATIC = 30000; const add3 = (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]]; const mul3 = (a, k) => [a[0] * k, a[1] * k, a[2] * k]; const sub3 = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; const dot3 = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; 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]]; const unit3 = (a) => { const l = Math.hypot(a[0], a[1], a[2]) || 1; return [a[0] / l, a[1] / l, a[2] / l]; }; const frameOf = (F) => { if (F.u && F.v) return F; const a = unit3(F.a); const helper = Math.abs(a[2]) > 0.9 ? [1, 0, 0] : [0, 0, 1]; const u = unit3(cross3(helper, a)); return { o: F.o, a, u, v: cross3(a, u) }; }; function meshOf(shape) { const tris = []; const quad = (a, b, c, d) => tris.push(a, b, c, a, c, d); if (shape.kind === "prism") { const F = frameOf(shape.F); const at = (s, [x, y]) => add3(add3(F.o, mul3(F.a, s)), add3(mul3(F.u, x), mul3(F.v, y))); const n = shape.poly.length; const centre = shape.poly.reduce((s, p) => [s[0] + p[0] / n, s[1] + p[1] / n], [0, 0]); 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])); 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])); } else if (shape.kind === "body") { const F = frameOf(shape.F); const count = 72; 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)))); }); const poly = shape.poly; for (let i = 0; i < poly.length; i++) { const [s0, r0] = poly[i]; const [s1, r1] = poly[(i + 1) % poly.length]; if (r0 < 1e-9 && r1 < 1e-9) continue; const a = ring(s0, r0); const b = ring(s1, r1); for (let j = 0; j < count; j++) quad(a[j], a[(j + 1) % count], b[(j + 1) % count], b[j]); } } else if (shape.kind === "ball") { const rows = 32; const cols = 64; const flats = shape.flats || []; const at = (i, j) => { const phi = (i / rows) * Math.PI; const t = (j / cols) * Math.PI * 2; let n = [Math.sin(phi) * Math.cos(t), Math.sin(phi) * Math.sin(t), Math.cos(phi)]; let p = mul3(n, shape.r); for (const [nx, ny, nz, d] of flats) { const k = nx * p[0] + ny * p[1] + nz * p[2]; if (k > d) p = sub3(p, mul3([nx, ny, nz], k - d)); } return add3(shape.o, p); }; 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)); } else if (shape.kind === "box") { 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]))); 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)]; 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]); } else if (shape.kind === "tube") { const pts = []; const radii = []; shape.points.forEach((p, i) => { 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; pts.push(p); radii.push(shape.radii[i]); }); if (pts.length < 2) return tris; const count = 20; let u = null; const rings = pts.map((p, i) => { const t = unit3(sub3(pts[Math.min(pts.length - 1, i + 1)], pts[Math.max(0, i - 1)])); 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)))); const v = cross3(t, u); const r = radii[i]; 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)))); }); }); 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]); 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]); } return tris; } const canvas = Object.assign(document.createElement("canvas"), { width: w, height: h }); canvas.dataset.check = "1"; const gl = canvas.getContext("webgl", { antialias: false, preserveDrawingBuffer: true, alpha: false, depth: true }); 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.);}"; const fs = "precision highp float;uniform vec3 uId;void main(){gl_FragColor=vec4(uId,1.);}"; 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; }; const program = gl.createProgram(); gl.attachShader(program, compile(gl.VERTEX_SHADER, vs)); gl.attachShader(program, compile(gl.FRAGMENT_SHADER, fs)); gl.linkProgram(program); gl.useProgram(program); const loc = (name) => gl.getUniformLocation(program, name); 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") }; const bufferOf = (shapes) => { const pos = []; for (const shape of shapes) for (const p of meshOf(shape)) pos.push(p[0], p[1], p[2]); const b = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, b); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(pos), gl.STATIC_DRAW); return { b, count: pos.length / 3 }; }; const parts = V.parts.map((part, index) => ({ index, group: part.group, name: part.name, mesh: bufferOf(part.shapes) })); const statics = V.statics.map((item) => ({ rank: item.rank, mesh: bufferOf(item.shapes) })); const colour = (id) => [((id + 1) & 255) / 255, (((id + 1) >> 8) & 255) / 255, 0]; const zIds = () => { const cams = C.cams(); const world = cams.world; gl.viewport(0, 0, w, h); gl.enable(gl.DEPTH_TEST); gl.depthFunc(gl.LESS); gl.clearColor(0, 0, 0, 1); gl.clearDepth(1); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); gl.uniform3f(where.M0, world.m[0], world.m[1], world.m[2]); gl.uniform3f(where.M1, world.m[3], world.m[4], world.m[5]); gl.uniform2f(where.O, world.ox, world.oy); gl.uniform3f(where.V, world.V[0], world.V[1], world.V[2]); gl.uniform2f(where.view, W, H); gl.uniform1f(where.span, 4000); const draw = (mesh, R, t, id) => { gl.uniform3f(where.R0, R[0], R[1], R[2]); gl.uniform3f(where.R1, R[3], R[4], R[5]); gl.uniform3f(where.R2, R[6], R[7], R[8]); gl.uniform3f(where.T, t[0], t[1], t[2]); gl.uniform3fv(where.id, colour(id)); gl.bindBuffer(gl.ARRAY_BUFFER, mesh.b); gl.enableVertexAttribArray(where.p); gl.vertexAttribPointer(where.p, 3, gl.FLOAT, false, 0, 0); gl.drawArrays(gl.TRIANGLES, 0, mesh.count); }; const eye = [1, 0, 0, 0, 1, 0, 0, 0, 1]; for (const item of statics) draw(item.mesh, eye, [0, 0, 0], STATIC + item.rank); for (const part of parts) { const cam = part.group >= 0 ? cams[part.group] : null; draw(part.mesh, cam ? cam.R : eye, cam ? cam.t : [0, 0, 0], part.index); } const pixels = new Uint8Array(w * h * 4); gl.readPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, pixels); const ids = new Int32Array(w * h); 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; } return ids; }; const toId = (id) => "rgb(" + ((id + 1) & 255) + "," + (((id + 1) >> 8) & 255) + ",0)"; const painterSvg = (reverse = false) => { const out = []; let rank = 0; for (const svg of svgs) { if (svg.dataset.live) { const nodes = [...svg.querySelectorAll("[data-p]")]; if (reverse) nodes.reverse(); for (const node of nodes) for (const el of node.querySelectorAll(".iso-fill, .tb-body")) out.push(''); } else { for (const el of svg.querySelectorAll(".iso-fill, .tb-body")) out.push(''); rank++; } } return '' + out.join("") + ""; }; const pIds = async (reverse) => { const data = await decode(painterSvg(reverse), w, h); const ids = new Int32Array(w * h); for (let i = 0; i < w * h; i++) ids[i] = data[i * 4] + data[i * 4 + 1] * 256 - 1; return ids; }; const nameOf = (id) => (id >= STATIC ? "static layer " + (id - STATIC) : id < 0 ? "empty" : V.parts[id].name); const joint = (a, b) => { const m = /^(.*):(\d+)$/; const x = a.match(m); const y = b.match(m); return Boolean(x && y && x[1] === y[1] && Math.abs(Number(x[2]) - Number(y[2])) <= 1); }; async function check(state, { reverse = false } = {}) { setState(state); const Z = zIds(); const P = await pIds(reverse); const tally = new Map(); const interior = (ids, i, x, y) => { const v = ids[i]; 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; } return true; }; let total = 0; for (let y = 1; y < h - 1; y++) for (let x = 1; x < w - 1; x++) { const i = y * w + x; const p = P[i]; const z = Z[i]; if (p === z || p < 0 || z < 0) continue; if (!interior(P, i, x, y) || !interior(Z, i, x, y)) continue; const key = p + "|" + z; tally.set(key, (tally.get(key) || 0) + 1); total++; } const live = []; const layers = []; for (const [key, px] of tally) { const [p, z] = key.split("|").map(Number); const a = nameOf(p); const b = nameOf(z); if (p >= STATIC || z >= STATIC) layers.push({ painter: a, truth: b, px }); else if (!joint(a, b)) live.push({ painter: a, truth: b, px }); } live.sort((x, y) => y.px - x.px); layers.sort((x, y) => y.px - x.px); return { total, live, layers, stats: C.stats() }; } async function footprint(state, name) { setState(state); const index = V.parts.findIndex((part) => part.name === name); const Z = zIds(); const P = await pIds(false); const boxOf = (ids) => { let n = 0, x0 = w, y0 = h, x1 = -1, y1 = -1; 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); } return { px: n, box: n ? [x0 / SCALE, y0 / SCALE, x1 / SCALE, y1 / SCALE] : null }; }; return { painter: boxOf(P), truth: boxOf(Z) }; } window.__tc.order = { check, footprint }; return { parts: parts.length, statics: statics.length, w, h }; })()`; const POPS = String.raw`(() => { const { stage, svgs, W, H, css, theme, setState, decode } = window.__tc; const SCALE = 2; const w = W * SCALE; const h = H * SCALE; let hidden = null; const svgString = () => '' + svgs.map((s) => '' + s.innerHTML + "").join("") + ""; async function render(state, hide = null) { setState(state); if (hide !== null) { hidden = stage.querySelector('[data-p="' + hide + '"]'); if (hidden) hidden.setAttribute("display", "none"); } const data = await decode(svgString(), w, h); if (hidden) { hidden.removeAttribute("display"); hidden = null; } const lum = new Float32Array(w * h); 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]; return lum; } const erode = (mask) => { const out = new Uint8Array(w * h); for (let y = 1; y < h - 1; y++) for (let x = 1; x < w - 1; x++) { const i = y * w + x; if (!mask[i]) continue; let all = 1; for (let dy = -1; dy <= 1 && all; dy++) for (let dx = -1; dx <= 1; dx++) if (!mask[i + dy * w + dx]) { all = 0; break; } out[i] = all; } return out; }; const regionOf = (mask) => { let n = 0; let x0 = w, y0 = h, x1 = -1, y1 = -1; 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); } return { n, box: n ? [x0 / SCALE, y0 / SCALE, x1 / SCALE, y1 / SCALE].map((v) => Math.round(v * 10) / 10) : null }; }; async function first(a, b, hide = null) { const A = await render(a); const B = await render(b, hide); let mask = new Uint8Array(w * h); for (let i = 0; i < w * h; i++) if (Math.abs(A[i] - B[i]) >= 5) mask[i] = 1; mask = erode(erode(mask)); return regionOf(mask); } let cache = null; async function second(a, b, c) { const A = cache && cache.x === a ? cache.lum : await render(a); const B = cache && cache.y === b ? cache.mid : await render(b); const Cl = await render(c); cache = { x: b, lum: B, y: c, mid: Cl }; let mask = new Uint8Array(w * h); for (let y = 1; y < h - 1; y++) for (let x = 1; x < w - 1; x++) { const i = y * w + x; const e = Math.abs(2 * B[i] - A[i] - Cl[i]); if (e <= 6) continue; let flat = true; 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; } if (e > 56 || flat) mask[i] = 1; } mask = erode(mask); const region = regionOf(mask); if (window.__tcDump && region.n >= 6 && !window.__tcDumped) { window.__tcDumped = true; const png = (lum, scaleBy = 1) => { const canvas = Object.assign(document.createElement("canvas"), { width: w, height: h }); const ctx = canvas.getContext("2d"); const img = ctx.createImageData(w, h); for (let i = 0; i < w * h; i++) { const v = Math.max(0, Math.min(255, lum[i] * scaleBy)); img.data[i * 4] = img.data[i * 4 + 1] = img.data[i * 4 + 2] = v; img.data[i * 4 + 3] = 255; } ctx.putImageData(img, 0, 0); return canvas.toDataURL("image/png").split(",")[1]; }; const diff = new Float32Array(w * h); for (let i = 0; i < w * h; i++) diff[i] = Math.abs(2 * B[i] - A[i] - Cl[i]); window.__tcDumped = { a: png(A), b: png(B), c: png(Cl), e: png(diff, 4) }; } return region; } window.__tc.pops = { first, second, render }; return { w, h }; })()`; const PERF = String.raw`(async (frames, step, group) => { const { C, stage } = window.__tc; const name = group || C.scene().groups.find((g) => g.kind === "turn")?.name; const base = C.values[name] || 0; const ms = []; const writes = []; const moved = []; const times = []; await new Promise((done) => { let i = 0; const tick = (now) => { times.push(now); const s = C.set({ [name]: base + i * step }); ms.push(s.ms); writes.push(s.writes); moved.push(s.moved); i++; if (i < frames) requestAnimationFrame(tick); else done(); }; requestAnimationFrame(tick); }); const gaps = times.slice(1).map((t, i) => t - times[i]).sort((a, b) => a - b); const sorted = (list) => list.slice().sort((a, b) => a - b); const median = (list) => sorted(list)[Math.floor(list.length / 2)]; const p95 = (list) => sorted(list)[Math.floor(list.length * 0.95)]; const live = [...stage.querySelectorAll("svg[data-live] *")].length; return { frames, setMedian: median(ms), setP95: p95(ms), writes: median(writes), moved: median(moved), gapMedian: median(gaps), gapP95: p95(gaps), live }; })`; async function main() { const argv = process.argv.slice(2); if (!argv.length || argv.includes("--help") || argv.includes("-h")) { usage(); process.exit(argv.length ? 0 : 1); } const value = (flag, fallback) => { const at = argv.indexOf(flag); return at >= 0 ? argv[at + 1] : fallback; }; const target = argv[0]; let failed = 0; if (target.endsWith(".mjs")) { const F = await import("./turn-fidelity.mjs"); const { T, P } = await import(pathToFileURL(resolve(target)).href); const angles = (value("--angles", "") || Array.from({ length: 12 }, (_, i) => 7 + i * 30).join(",")).split(",").map(Number); const plant = T.parts.find((part) => part.kind === "prism" && part.group && part.group.kind === "turn"); if (plant) { const control = F.identity(T, P, angles.slice(0, 2), { plant: plant.name }); console.log(`control (${plant.name} drawn 2° off): ${control.pass ? "did not fail: the check is broken" : "fails as it should"}`); if (control.pass) failed++; } for (const [name, run] of [["prisms", F.identity], ["rounds", F.rounds], ["tubes", F.tubes]]) { const result = run(T, P, angles); console.log(result.text); for (const line of result.worst) console.log(` ${line}`); if (!result.pass) { console.log(`${name} FAILED`); failed++; } } console.log(failed ? `fidelity failed: ${failed}` : "fidelity passed"); process.exit(failed ? 1 : 0); } 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") }; const step = Number(value("--step", 1)); 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"); const fine = Number(value("--fine", 0.1)); 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"); const out = value("--out", null); const statesFile = value("--states", null); const states = statesFile ? JSON.parse(readFileSync(statesFile, "utf8")) : null; if (statesFile && (!Array.isArray(states) || !states.length)) throw new Error("--states must contain a nonempty JSON list of poses"); const page = await browser(target, options); const labelOf = (state, index) => (typeof state === "number" ? `${state.toFixed(3)}°` : `state ${index}`); try { const info = await page.evaluate(HELPERS); console.log(`page ${info.W}×${info.H} · live layers ${info.layers.filter(Boolean).join(", ")} · theme ${info.theme}`); await page.evaluate(`window.__isoTurn.set(0) && 0`); const sweep = states ?? Array.from({ length: Math.round(360 / step) }, (_, i) => i * step); if (argv.includes("--order")) { const has = await page.evaluate("Boolean(window.__isoVerify)"); if (!has) { console.log("order: this page has no window.__isoVerify; build it with --verify"); failed++; } else { const z = await page.evaluate(ZBUFFER); console.log(`order: ${z.parts} live parts and ${z.statics} static items meshed, z-buffer ${z.w}×${z.h}`); let control = Infinity; for (const angle of [0, 90, 180, 270]) { const r = await page.evaluate(`window.__tc.order.check(${angle}, { reverse: true }).then((r) => r.live.reduce((s, p) => s + p.px, 0))`); control = Math.min(control, r); } 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"}`); if (control < 1000) failed++; const probe = value("--part", null); 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)})`))}`); let bad = 0; const layerNotes = new Map(); let worst = 0; for (let index = 0; index < sweep.length; index++) { const state = sweep[index]; const r = await page.evaluate(`window.__tc.order.check(${JSON.stringify(state)})`); const errors = r.live.filter((p) => p.px >= 4); worst = Math.max(worst, ...r.live.map((p) => p.px), 0); if (errors.length) { bad++; 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}` : ""}`); } 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)); } 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`); 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(" · ")}`); if (bad) failed++; } } if (argv.includes("--pops")) { await page.evaluate(POPS); if (out) await page.evaluate("window.__tcDump = true"); 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; })()`); const plantAt = typeof sweep[0] === "number" ? 37 : sweep[0]; const control = await page.evaluate(`window.__tc.pops.first(${JSON.stringify(plantAt)}, ${JSON.stringify(typeof plantAt === "number" ? plantAt + 0.02 : plantAt)}, ${plantPart})`); 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"}`); if (!control.n) failed++; let firstBad = 0; const firstStates = states ?? Array.from({ length: Math.round(360 / step) }, (_, i) => i * step); for (let index = 0; index < firstStates.length; index++) { const a = firstStates[index]; const b = typeof a === "number" ? a + 0.02 : firstStates[index + 1]; if (b === undefined) break; const r = await page.evaluate(`window.__tc.pops.first(${JSON.stringify(a)}, ${JSON.stringify(b)})`); if (r.n) { firstBad++; if (firstBad <= 20) console.log(` first difference ${labelOf(a, index)}: ${r.n} px survive two erosions in ${r.box.join(",")}`); } } console.log(`pops, first difference: ${firstStates.length} steps · ${firstBad} with a surviving region`); if (firstBad) failed++; if (!states) { let cores = 0; const small = []; for (let x = 0; x + 2 * fine <= 360 + 1e-9; x += fine) { const r = await page.evaluate(`window.__tc.pops.second(${x.toFixed(4)}, ${(x + fine).toFixed(4)}, ${(x + 2 * fine).toFixed(4)})`); if (r.n >= 6) { cores++; if (cores <= 20) console.log(` second difference at ${(x + fine).toFixed(2)}°: core of ${r.n} px in ${r.box.join(",")}`); } else if (r.n) small.push(`${(x + fine).toFixed(2)}° ${r.n} px in ${r.box.join(",")}`); } 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(" · ")}` : ""}`); const dumped = out ? await page.evaluate("window.__tcDumped && typeof window.__tcDumped === 'object' ? window.__tcDumped : null") : null; if (dumped) { mkdirSync(out, { recursive: true }); for (const [key, data] of Object.entries(dumped)) writeFileSync(join(out, `pops-core-${key}.png`), Buffer.from(data, "base64")); console.log(` first core dumped to ${out}/pops-core-{a,b,c,e}.png`); } if (cores) failed++; } } if (argv.includes("--lines")) { const angles = Array.from({ length: 24 }, (_, i) => i * 15); 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; })()`); console.log(`lines control (a planted floating line): ${planted > 0 ? "fails as it should" : "nothing flagged: the check is broken"}`); if (!(planted > 0)) failed++; let bad = 0; for (const angle of angles) { 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; })()`); if (r && r.n) { bad += r.n; console.log(` ${angle}°: ${r.n} floating ends: ${r.list.join(" · ")}`); } } console.log(`lines: ${angles.length} angles · ${bad} floating ends`); if (bad) failed++; } if (argv.includes("--perf")) { await page.send("Performance.enable"); const metrics = async () => Object.fromEntries((await page.send("Performance.getMetrics")).result.metrics.map((m) => [m.name, m.value])); const runs = []; for (let r = 0; r < 3; r++) { const before = await metrics(); const result = await page.evaluate(`(${PERF})(240, 1.1, null)`); const after = await metrics(); const per = (key) => ((after[key] - before[key]) * 1000) / result.frames; runs.push({ ...result, task: per("TaskDuration"), script: per("ScriptDuration"), style: per("RecalcStyleDuration"), layout: per("LayoutDuration") }); } const med = (key) => runs.map((r) => r[key]).sort((a, b) => a - b)[1]; 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`); if (out) { mkdirSync(out, { recursive: true }); writeFileSync(join(out, `perf-${options.width}-${options.throttle}x.json`), JSON.stringify(runs, null, 1)); } } console.log(page.problems.length ? page.problems.join("\n") : "console: clean"); if (page.problems.length) failed++; } catch (error) { console.error(error); failed++; } finally { page.close(); } console.log(failed ? `turn check failed: ${failed}` : "turn check passed"); process.exit(failed ? 1 : 0); } if (typeof WebSocket === "undefined" && !process.execArgv.includes("--experimental-websocket")) { const child = spawn(process.execPath, ["--experimental-websocket", ...process.argv.slice(1)], { stdio: "inherit" }); child.on("exit", (code) => process.exit(code ?? 1)); } else { await main(); }