Add core-powered web sculpting studio with tablet controls
Add pressure-aware worker sculpting, bounded shared input, project files and history, and compact icon navigation with an XYZ orbit gizmo. Amp-Thread-ID: https://ampcode.com/threads/T-01a0b4b4-202c-73f1-984d-9a55c88f9c83 Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
This commit is contained in:
@@ -0,0 +1,492 @@
|
||||
// Run explicitly in a fresh /view/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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user