Files
yawn/server/web/assets/sculpt/browser-check.js
T
Ampandheaust d279ec4123 Add clay pieces, masking, and topology editing to sculpt
Add pastel presets, piece selection and colors, mask brushes, subdivision and conservative decimation with bounded worker operations and mixed undo history.

Amp-Thread-ID: https://ampcode.com/threads/T-01a0b4b4-202c-73f1-984d-9a55c88f9c83
Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
2026-10-01 07:55:48 +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 === 6,
"Brush rail must include five sculpt brushes and Mask",
);
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;
}
}