// Explicit browser integration check on a fresh /sculpt page; no permanent hooks. import { Renderer } from "./renderer.js"; import { snapshot, loadDocument, saveDocument } from "./document.js"; import { cameraBasis, dot, sub } from "./math.js"; export async function check() { const $ = (id) => document.getElementById(id); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const assert = (ok, message) => { if (!ok) throw new Error(message); }; const wait = async (fn) => { for (let i = 0; i < 600; i++) { if (fn()) return; await sleep(50); } throw new Error("Timed out: " + fn); }; const ready = () => $("loading").hidden && $("clay-progress").hidden && !$("new").disabled && $("error").hidden; const same = (a, b) => JSON.stringify(a) === JSON.stringify(b); const publish = Renderer.prototype.publish, capture = $("viewport").setPointerCapture; let engine, camera, processor; Renderer.prototype.publish = function (e, c) { engine = e; camera = c; processor = this.processor; return publish.call(this, e, c); }; $("viewport").setPointerCapture = () => {}; const command = async (id) => { $(id).click(); await wait(ready); await sleep(120); }; const stroke = async (brush) => { document.querySelector(`[data-brush="${brush}"]`).click(); const p = engine.mesh.pieces.find((p) => p.id === engine.activeId); const center = [0, 0, 0]; for (let v = p.vertexStart; v < p.vertexStart + p.vertexCount; v++) for (let k = 0; k < 3; k++) center[k] += engine.mesh.positions[v * 3 + k] / p.vertexCount; center[0] += 0.15; const rect = $("viewport").getBoundingClientRect(), basis = cameraBasis(camera.yaw, camera.pitch), d = sub(center, camera.target); const x = rect.left + rect.width / 2 + (dot(d, basis.right) * rect.height) / (2 * camera.zoom); const y = rect.top + rect.height / 2 - (dot(d, basis.up) * rect.height) / (2 * camera.zoom); const pointer = (type, dx = 0) => $("viewport") .querySelector("canvas") .dispatchEvent( new PointerEvent(type, { bubbles: true, pointerId: 9, pointerType: "pen", button: 0, buttons: type === "pointerup" ? 0 : 1, pressure: 0.8, clientX: x + dx, clientY: y, }), ); pointer("pointerdown"); pointer("pointermove", 14); await sleep(150); pointer("pointerup", 14); await wait(ready); await sleep(100); }; try { await wait(ready); $("frame").click(); await wait(() => !!engine); const initial = snapshot(engine.mesh); await command("delete-piece"); assert( engine.mesh.pieces.length === 0 && !$("empty-clay").hidden, "Delete last piece failed", ); assert( loadDocument(saveDocument(engine.mesh)).pieces.length === 0, "Empty roundtrip failed", ); await command("undo"); assert(same(snapshot(engine.mesh), initial), "Delete undo lost clay"); await stroke("mask"); assert( engine.mesh.masks.some((x) => x > 0), "Worker mask brush did not paint", ); assert( same(engine.mesh.positions, initial.positions), "Mask moved geometry", ); await command("undo"); assert( engine.mesh.masks.every((x) => x === 0), "Mask undo not exact", ); for (const name of ["sphere", "cube", "cylinder", "cone", "torus"]) { const count = engine.mesh.pieces.length; document.querySelector(`[data-preset="${name}"]`).click(); await wait(ready); await sleep(120); assert(engine.mesh.pieces.length === count + 1, `Add ${name} failed`); assert( engine.activeId === engine.mesh.pieces.at(-1).id, "Addition was not selected", ); } assert( new Set(engine.mesh.pieces.slice(1).map((p) => JSON.stringify(p.color))) .size === 5, "Pastels were not individually chosen", ); const cube = engine.mesh.pieces.find((p) => p.name === "Cube"); document.querySelector(`[data-piece="${cube.id}"]`).click(); $("piece-color").value = "#99bbee"; $("piece-color").dispatchEvent(new Event("change")); const colored = snapshot(engine.mesh); await command("undo"); assert( !same( engine.mesh.pieces.find((p) => p.id === cube.id).color, colored.pieces.find((p) => p.id === cube.id).color, ), "Color undo failed", ); await command("redo"); assert(same(snapshot(engine.mesh), colored), "Color redo failed"); const count = cube.indexCount; await command("subdivide"); assert( engine.mesh.pieces.find((p) => p.id === cube.id).indexCount === count * 4, "Subdivision did not split triangles", ); const subdivided = snapshot(engine.mesh); await stroke("draw"); assert( !same(engine.mesh.positions, subdivided.positions), "Cannot sculpt replacement topology", ); await command("undo"); assert( same(snapshot(engine.mesh), subdivided), "Post-topology stroke undo failed", ); await command("invert-mask"); const masked = snapshot(engine.mesh), state = engine.state; await stroke("draw"); assert(same(snapshot(engine.mesh), masked), "Fully masked piece moved"); await command("decimate"); assert( engine.state === state && same(snapshot(engine.mesh), masked), "Masked decimation was not a no-op", ); await command("clear-mask"); await command("decimate"); assert( engine.mesh.pieces.find((p) => p.id === cube.id).indexCount < count * 4, "Decimation did not reduce triangles", ); const beforeCancel = snapshot(engine.mesh), beforeState = engine.state; $("subdivide").click(); $("cancel-operation").click(); await sleep(300); assert( ready() && engine.state === beforeState && same(snapshot(engine.mesh), beforeCancel), "Canceled topology changed clay", ); const loaded = loadDocument(saveDocument(engine.mesh)); assert( same(snapshot(loaded), snapshot(engine.mesh)), "Mixed scene project roundtrip failed", ); // Hold real worker results to exercise races without production hooks. const NativeWorker = window.Worker, create = Renderer.create; const jobs = []; window.Worker = class { constructor(url, options) { if (!String(url).endsWith("/operations.js")) return new NativeWorker(url, options); this.inner = new NativeWorker(url, options); this.inner.onmessage = (event) => { this.packet = event; }; this.inner.onerror = (event) => this.onerror?.(event); jobs.push(this); } postMessage(...args) { this.inner.postMessage(...args); } terminate() { this.terminated = true; this.inner.terminate(); } deliver() { this.onmessage(this.packet); } }; const pending = async () => { $("delete-piece").click(); const job = jobs.at(-1); await wait(() => !!job.packet); return job; }; const baseline = saveDocument(engine.mesh), baselineState = engine.state; const unchanged = () => saveDocument(engine.mesh) === baseline && engine.state === baselineState; try { const a = await pending(); $("cancel-operation").click(); const b = await pending(); const status = $("status").textContent; a.deliver(); a.onerror(); assert( !$("clay-progress").hidden && unchanged() && $("status").textContent === status, "Old operation affected new job", ); $("cancel-operation").click(); const c = await pending(); window.dispatchEvent(new Event("resize")); assert(!c.terminated, "Same-size resize canceled operation"); $("viewport").style.width = "90%"; c.deliver(); // Deliver before ResizeObserver: receipt must detect stale size. assert(c.terminated && unchanged(), "Stale viewport result installed"); await wait(ready); $("viewport").style.width = ""; await sleep(350); await wait(ready); const d = await pending(); d.onmessageerror(); assert( d.terminated && unchanged() && $("clay-progress").hidden, "Message error stranded operation", ); $("pieces-toggle").click(); const e = await pending(); $("delete-piece").dispatchEvent( new KeyboardEvent("keydown", { key: "Escape", bubbles: true }), ); assert( e.terminated && $("pieces-panel").hidden && unchanged(), "Panel Escape did not cancel operation", ); const f = await pending(); processor.fail(new Error("Synthetic failure during topology")); f.deliver(); await sleep(350); assert( f.terminated && !$("error").hidden && !$("save").disabled && unchanged(), "Canvas failure allowed pending topology commit/restart", ); await command("retry"); const g = await pending(); Renderer.create = async () => { throw new Error("Synthetic post-commit startup failure"); }; g.deliver(); await wait(() => !$("error").hidden && !$("new").disabled); assert( engine.state !== baselineState && !$("save").disabled, "Post-commit failure lost document or Save", ); const committed = saveDocument(engine.mesh); assert( loadDocument(committed).pieces.length === loadDocument(baseline).pieces.length - 1, "Saved document missed committed deletion", ); Renderer.create = create; await command("undo"); assert( unchanged(), "Undo after renderer failure did not restore document", ); } finally { window.Worker = NativeWorker; Renderer.create = create; for (const job of jobs) job.terminate(); $("viewport").style.width = ""; } return { passed: 21, checks: "preset add/selection/pastels, delete last/undo, mask worker/undo, color history, subdivision, replacement-topology sculpt/undo, full protection, masked decimation no-op, clear, decimation, cancellation, mixed project roundtrip; stale jobs, same-size resize, changed-size receipt, message error, panel Escape, canvas failure, post-commit failure/save/undo", physicalTabletVerified: false, }; } finally { Renderer.prototype.publish = publish; $("viewport").setPointerCapture = capture; } }