Files
yawn/server/web/assets/sculpt/browser-check.js
T
Ampandheaust d930d8172f Serve sculpt studio at /sculpt
Keep existing viewer URLs compatible and test embedded pages, assets, and isolation headers.

Amp-Thread-ID: https://ampcode.com/threads/T-01a0b4b4-202c-73f1-984d-9a55c88f9c83
Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
2026-09-28 10:25:02 +00:00

493 lines
18 KiB
JavaScript

// Run explicitly in a fresh /sculpt page:
// await (await import('/assets/sculpt/browser-check.js')).check()
// Synthetic input checks logic, not physical pen feel. Hooks are always restored.
import { Renderer } from "./renderer.js";
import { Navigation } from "./navigation.js";
export async function check() {
const $ = (id) => document.getElementById(id),
results = [];
const assert = (value, message) => {
if (!value) throw new Error(message);
};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const wait = async (fn) => {
const start = performance.now();
while (!fn()) {
if (performance.now() - start > 15000)
throw new Error("Timed out waiting for UI");
await sleep(25);
}
};
const viewport = $("viewport"),
capture = viewport.setPointerCapture,
createURL = URL.createObjectURL,
publish = Renderer.prototype.publish;
const navRoot = $("view-navigation"),
navCapture = navRoot.setPointerCapture,
navRender = Navigation.prototype.render;
let nav;
navRoot.setPointerCapture = () => {};
Navigation.prototype.render = function () {
nav = this;
return navRender.call(this);
};
let blob,
engine,
camera,
processor,
publications = 0,
failNext = false;
viewport.setPointerCapture = () => {};
URL.createObjectURL = function (value) {
blob = value;
return createURL.call(this, value);
};
Renderer.prototype.publish = function (...args) {
engine = args[0];
camera = args[1];
processor = this.processor;
publications++;
if (failNext) {
failNext = false;
return Promise.reject(new Error("Synthetic upload failure"));
}
return publish.apply(this, args);
};
const saved = async () => {
await wait(() => !$("save").disabled);
$("save").click();
return JSON.parse(await blob.text());
};
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const pointer = (type, x, y, pressure = 0.7, pointerType = "pen", id = 7) => {
const r = viewport.getBoundingClientRect();
viewport.querySelector("canvas").dispatchEvent(
new PointerEvent(type, {
bubbles: true,
pointerId: id,
pointerType,
clientX: r.left + r.width * x,
clientY: r.top + r.height * y,
pressure,
button: 0,
buttons: type === "pointerup" ? 0 : 1,
}),
);
};
const stroke = async (p) => {
pointer("pointerdown", 0.55, 0.44, p);
pointer("pointermove", 0.59, 0.51, p);
pointer("pointerup", 0.59, 0.51, 0);
await wait(() => !$("save").disabled);
await sleep(100);
};
try {
await wait(() => $("loading").hidden && $("error").hidden);
const initialCanvas = viewport.querySelector("canvas");
const bounds = JSON.stringify(viewport.getBoundingClientRect());
assert(
$("tools-panel").hidden && $("inspector").hidden,
"Panels must start closed",
);
const hover = (id, type = "pointerenter") =>
$(id).dispatchEvent(
new PointerEvent(type, { pointerType: "pen", buttons: 0 }),
);
hover("tools-toggle");
assert(!$("tools-panel").hidden, "Pen hover did not reveal brushes");
const palette = $("tools-panel").getBoundingClientRect();
assert(
palette.left >= 50 && palette.width <= 216,
"Brush popover overlaps rail or has oversized width",
);
assert(
document.querySelectorAll(".tool-rail [data-brush] svg").length === 5,
"Brush rail must have five icons",
);
assert(
$("radius").style.getPropertyValue("--slider-value").startsWith("32.14"),
"Basecoat size fill is not synchronized",
);
pointer("pointerdown", 0.8, 0.8, 1, "touch", 91);
pointer("pointermove", 0.82, 0.81, 1, "touch", 91);
pointer("pointerup", 0.82, 0.81, 0, "touch", 91);
assert(!$("tools-panel").hidden, "Rejected palm dismissed hover tools");
hover("tools-toggle", "pointerleave");
await sleep(100);
hover("tools-panel");
await sleep(300);
assert(!$("tools-panel").hidden, "Crossing panel gap closed tools");
hover("tools-panel", "pointerleave");
await sleep(300);
assert($("tools-panel").hidden, "Hover panel did not dismiss");
$("settings-toggle").click();
hover("settings-toggle", "pointerleave");
await sleep(300);
assert(!$("inspector").hidden, "Tapped panel did not stay open");
pointer("pointerdown", 0.2, 0.8, 1, "touch", 91);
pointer("pointerup", 0.2, 0.8, 0, "touch", 91);
assert(!$("inspector").hidden, "Rejected palm dismissed pinned settings");
$("pen-settings").open = true;
assert(
$("reset-pressure").getBoundingClientRect().bottom <=
$("pen-settings").nextElementSibling.getBoundingClientRect().top,
"Expanded pen controls overlap display disclosure",
);
$("gain").dispatchEvent(
new PointerEvent("pointerdown", { pointerType: "pen", bubbles: true }),
);
pointer("pointerdown", 0.2, 0.8, 1, "touch", 91);
assert(
!$("inspector").hidden,
"Rejected palm interrupted slider interaction",
);
pointer("pointerup", 0.2, 0.8, 0, "touch", 91);
$("gain").focus();
$("gain").dispatchEvent(
new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
);
assert(
$("inspector").hidden && document.activeElement === $("settings-toggle"),
"Escape did not restore launcher focus",
);
$("pen-settings").open = false;
assert(
initialCanvas === viewport.querySelector("canvas") &&
bounds === JSON.stringify(viewport.getBoundingClientRect()),
"Menus resized/replaced canvas",
);
results.push(
"pen hover / gap grace / auto-hide / tap pin / palm-safe panels and slider / Escape focus / unchanged canvas",
);
$("new").click();
$("density").value = "4";
$("new-dialog").close("create");
await wait(
() =>
viewport.querySelector("canvas") !== initialCanvas &&
$("loading").hidden &&
!$("new").disabled,
);
await sleep(100);
const original = await saved();
await stroke(0);
assert(same(original, await saved()), "Zero pen pressure altered geometry");
results.push("zero pen pressure");
await stroke(0.8);
const edited = await saved();
assert(!same(original, edited), "Pen stroke did not deform mesh");
assert(!$("undo").disabled, "Undo unavailable");
$("undo").click();
assert(same(original, await saved()), "Undo not exact");
$("redo").click();
assert(same(edited, await saved()), "Redo not exact");
results.push("pen sculpt / exact undo / redo");
pointer("pointerdown", 0.48, 0.5, 0.9);
await sleep(100);
pointer("pointermove", 0.52, 0.55, 0.9);
viewport.dispatchEvent(new PointerEvent("pointercancel", { pointerId: 7 }));
assert(same(edited, await saved()), "Cancel did not restore mesh");
results.push("pointer cancellation");
for (const settling of [false, true]) {
const before = engine.mesh.positions.slice();
const history = [...engine.undoStack];
const redoHistory = [...engine.redoStack];
pointer("pointerdown", 0.48, 0.5, 0.9);
pointer("pointermove", 0.56, 0.55, 0.9);
await wait(() => engine.mesh.positions.some((v, i) => v !== before[i]));
$("frame").focus();
if (settling) pointer("pointerup", 0.56, 0.55, 0);
$("frame").dispatchEvent(
new KeyboardEvent("keydown", {
key: "Escape",
bubbles: true,
cancelable: true,
}),
);
assert(
engine.mesh.positions.every((v, i) => v === before[i]),
"Focused navigation swallowed sculpt cancellation",
);
if (!settling) pointer("pointerup", 0.56, 0.55, 0);
await wait(() => !$("save").disabled);
assert(
engine.mesh.positions.every((v, i) => v === before[i]) &&
engine.undoStack.length === history.length &&
engine.undoStack.every((v, i) => v === history[i]) &&
engine.redoStack.length === redoHistory.length &&
engine.redoStack.every((v, i) => v === redoHistory[i]),
"Focused navigation cancellation changed geometry/history after settlement",
);
}
results.push(
"focused navigation Escape rolls back active and settling sculpt",
);
assert($("palm-rejection").checked, "Palm rejection must default on");
await sleep(100);
const beforePalm = structuredClone(camera);
pointer("pointerdown", 0.48, 0.5, 1, "touch", 1);
pointer("pointermove", 0.58, 0.54, 1, "touch", 1);
pointer("pointerup", 0.58, 0.54, 0, "touch", 1);
await sleep(100);
assert(
same(beforePalm, camera) && same(edited, await saved()),
"Palm changed canvas",
);
results.push("default palm rejection");
$("palm-rejection").checked = false;
$("palm-rejection").dispatchEvent(new Event("change"));
pointer("pointerdown", 0.48, 0.5, 1, "touch", 1);
await sleep(100);
pointer("pointerdown", 0.56, 0.52, 1, "touch", 2);
pointer("pointermove", 0.58, 0.54, 1, "touch", 2);
pointer("pointerup", 0.48, 0.5, 0, "touch", 1);
pointer("pointerup", 0.58, 0.54, 0, "touch", 2);
assert(same(edited, await saved()), "Pinch left sculpt marks");
await wait(() => camera.zoom !== beforePalm.zoom);
$("palm-rejection").checked = true;
$("palm-rejection").dispatchEvent(new Event("change"));
results.push("two-touch rollback/navigation");
$("quality").value = "economy";
$("quality").dispatchEvent(new Event("change"));
await wait(() => $("loading").hidden && !$("quality").disabled);
assert(same(edited, await saved()), "Quality changed geometry");
assert(!$("undo").disabled, "Quality lost history");
results.push("quality preserves mesh/history");
const oldCanvas = viewport.querySelector("canvas");
viewport.style.width = "85%";
await wait(
() =>
viewport.querySelector("canvas") !== oldCanvas && $("loading").hidden,
);
assert(same(edited, await saved()), "Resize changed geometry");
assert(!$("undo").disabled, "Resize lost history");
const smallCanvas = viewport.querySelector("canvas");
viewport.style.width = "";
await wait(
() =>
viewport.querySelector("canvas") !== smallCanvas && $("loading").hidden,
);
results.push("resize preserves mesh/history");
const positions = engine.mesh.positions.slice(),
undo = [...engine.undoStack],
redo = [...engine.redoStack],
bytes = engine.historyBytes;
pointer("pointerdown", 0.48, 0.5, 0.9);
pointer("pointermove", 0.56, 0.55, 0.9);
await wait(() => engine.mesh.positions.some((v, i) => v !== positions[i]));
const beforeResize = viewport.querySelector("canvas");
viewport.style.width = "90%";
// Release in this task, before ResizeObserver or the 200 ms debounce runs.
pointer("pointerup", 0.56, 0.55, 0);
const exact = () =>
engine.mesh.positions.every((v, i) => v === positions[i]) &&
engine.undoStack.length === undo.length &&
engine.undoStack.every((v, i) => v === undo[i]) &&
engine.redoStack.length === redo.length &&
engine.redoStack.every((v, i) => v === redo[i]) &&
engine.historyBytes === bytes;
assert(
exact(),
"Resize/release committed or retained provisional geometry",
);
pointer("pointerdown", 0.5, 0.5, 0.9);
assert(!engine.stroke, "Stroke started against a stale renderer size");
pointer("pointerup", 0.5, 0.5, 0);
await wait(
() =>
viewport.querySelector("canvas") !== beforeResize &&
$("loading").hidden,
);
assert(exact(), "Resize recreation changed history or geometry");
await stroke(0.8);
assert(
!same(edited, await saved()),
"New stroke failed after resize recovery",
);
$("undo").click();
assert(same(edited, await saved()), "Recovery stroke undo failed");
const resizedCanvas = viewport.querySelector("canvas");
viewport.style.width = "";
await wait(
() =>
viewport.querySelector("canvas") !== resizedCanvas &&
$("loading").hidden,
);
results.push(
"resize during stroke: immediate exact rollback / stale-start gate / recovery",
);
const dt = new DataTransfer();
dt.items.add(new File(['{"version":42}'], "invalid.yawn-sculpt"));
$("file").files = dt.files;
$("file").dispatchEvent(new Event("change"));
await wait(() => !$("open").disabled);
assert(same(edited, await saved()), "Invalid project replaced geometry");
results.push("invalid project preservation");
const native = JSON.stringify(edited),
valid = new DataTransfer();
valid.items.add(new File([native], "roundtrip.yawn-sculpt"));
$("file").files = valid.files;
$("file").dispatchEvent(new Event("change"));
await wait(() => !$("open").disabled && $("loading").hidden);
assert(same(edited, await saved()), "Project roundtrip changed geometry");
results.push("native project roundtrip");
$("export").click();
const obj = await blob.text();
assert(
obj.split("\n").filter((l) => l.startsWith("v ")).length === 2562,
"OBJ vertex count",
);
assert(
obj.split("\n").filter((l) => l.startsWith("f ")).length === 5120,
"OBJ face count",
);
results.push("OBJ export");
const navPointer = (mode, type, x = 100, y = 100) =>
document.querySelector(`[data-nav="${mode}"]`).dispatchEvent(
new PointerEvent(type, {
bubbles: true,
pointerType: "pen",
pointerId: 90,
button: 0,
clientX: x,
clientY: y,
}),
);
await wait(() => nav);
for (const mode of ["orbit", "pan", "zoom"]) {
const before = structuredClone(nav.host.camera);
navPointer(mode, "pointerdown");
assert(nav.busy, "Navigation did not capture gesture");
pointer("pointerdown", 0.5, 0.5);
assert(!engine.stroke, "Canvas sculpt started during navigation");
viewport.dispatchEvent(
new WheelEvent("wheel", {
deltaY: 200,
bubbles: true,
cancelable: true,
}),
);
assert(same(nav.host.camera, before), "Wheel competed with navigation");
navPointer(mode, "pointermove", 126, 119);
assert(!same(nav.host.camera, before), "Navigation did not move camera");
navRoot.dispatchEvent(
new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
);
assert(
!nav.busy && same(nav.host.camera, before),
"Escape did not restore camera",
);
navPointer(mode, "pointerdown");
navPointer(mode, "pointerup", 126, 119);
assert(
!nav.busy && !same(nav.host.camera, before),
"Navigation release did not finish",
);
await sleep(80);
}
const navBefore = structuredClone(nav.host.camera);
pointer("pointerdown", 0.5, 0.5);
navPointer("orbit", "pointerdown");
assert(!nav.busy, "Navigation started during sculpt");
pointer("pointerup", 0.5, 0.5, 0);
navPointer("orbit", "pointerdown");
assert(!nav.busy, "Navigation started during worker settlement");
await wait(() => !$("save").disabled);
$("undo").click();
assert(
same(nav.host.camera, navBefore),
"Rejected navigation changed camera",
);
const viewBefore = structuredClone(camera),
aspectBefore = viewport.clientWidth / viewport.clientHeight;
const shortZoom = viewBefore.zoom * Math.min(1, aspectBefore);
const resizeTo = async (width, height) => {
const previous = viewport.querySelector("canvas");
viewport.style.width = width;
viewport.style.height = height;
await wait(
() =>
viewport.querySelector("canvas") !== previous && $("loading").hidden,
);
await sleep(100);
};
await resizeTo("600px", "1000px");
assert(
Math.abs(camera.zoom * 0.6 - shortZoom) < 1e-9,
"Portrait changed apparent zoom",
);
assert(
same(camera.target, viewBefore.target) &&
camera.yaw === viewBefore.yaw &&
camera.pitch === viewBefore.pitch,
"Orientation reset camera pose",
);
await resizeTo("", "");
assert(
Math.abs(camera.zoom - viewBefore.zoom) < 1e-9,
"Orientation roundtrip changed zoom",
);
assert(same(edited, await saved()), "Navigation changed geometry");
document.querySelector('[data-brush="draw"]').click();
results.push(
"direct navigation / mutual exclusion / Escape rollback / orientation / geometry intact",
);
await sleep(300);
const idle = publications;
await sleep(500);
assert(publications === idle, "Idle repeated publications");
results.push("idle: zero publications over 500 ms");
failNext = true;
$("frame").click();
await wait(() => !$("error").hidden);
assert(!$("save").disabled, "Error disabled save");
assert(same(edited, await saved()), "Upload failure lost geometry");
$("retry").click();
await wait(() => $("loading").hidden && $("error").hidden);
assert(same(edited, await saved()), "Retry lost geometry");
results.push("upload failure / save / retry");
const recovered = engine.mesh.positions.slice();
pointer("pointerdown", 0.5, 0.5, 0.9);
await wait(() => engine.mesh.positions.some((v, i) => v !== recovered[i]));
// Publication captures the renderer's client after recreation; geometry can
// arrive first. Never inject into the previous, already-disposed worker.
await wait(() => processor && !processor.disposed && processor.job);
const oldProcessor = processor;
oldProcessor.worker.dispatchEvent(new Event("error"));
await wait(() => !$("error").hidden && !$("save").disabled);
assert(
same(edited, await saved()),
"Worker failure retained provisional geometry",
);
assert(oldProcessor.disposed, "Failed worker was not terminated");
$("retry").click();
await wait(
() =>
$("loading").hidden && $("error").hidden && processor !== oldProcessor,
);
await stroke(0.8);
assert(
!same(edited, await saved()),
"New worker failed to sculpt after retry",
);
$("undo").click();
assert(same(edited, await saved()), "Recovered worker undo failed");
results.push(
"sculpt worker failure / exact rollback / new worker / sculpt / undo",
);
return {
passed: results.length,
checks: results,
publications,
physicalPenVerified: false,
};
} finally {
viewport.setPointerCapture = capture;
URL.createObjectURL = createURL;
Renderer.prototype.publish = publish;
navRoot.setPointerCapture = navCapture;
Navigation.prototype.render = navRender;
}
}