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>
317 lines
10 KiB
JavaScript
317 lines
10 KiB
JavaScript
// 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;
|
|
}
|
|
}
|