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>
This commit is contained in:
@@ -0,0 +1,316 @@
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user