Files
yawn/server/web/assets/sculpt/app.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

787 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
Sculpt,
defaults,
pressureValue,
saveProject,
loadProject,
exportOBJ,
} from "./engine.js";
import { sphere } from "./mesh.js";
import { Renderer } from "./renderer.js";
import { Input } from "./input.js";
import { StrokeClient } from "./stroke-client.js";
import { clamp, clampZoom, cameraBasis } from "./math.js";
import { Menus } from "./menus.js";
import { Navigation } from "./navigation.js";
import { snapshot } from "./document.js";
const $ = (id) => document.getElementById(id),
viewport = $("viewport");
const settings = { ...defaults },
camera = {
yaw: 0,
pitch: 0,
zoom: 1.5,
target: [0, 0, 0],
color: [0.5, 0.3, 0.2],
};
let engine = new Sculpt(),
renderer = null,
initializing = false,
pendingResize = false,
frameRequest = 0,
publishing = false;
let viewRevision = 0,
published = -1,
generation = 0,
lastSize = "",
modified = false,
saved = engine.state,
fileBusy = false;
let operation = null,
piecesSignature = "";
const brushes = ["draw", "inflate", "smooth", "flatten", "grab", "mask"];
let viewNavigation, cameraAspect, messageTimer;
const input = new Input(viewport, {
engine: () => engine,
processor: () => renderer.processor,
camera,
settings,
ready: () =>
!!renderer &&
!initializing &&
!fileBusy &&
!operation &&
!viewNavigation?.busy &&
sizeKey() === lastSize,
blocked: () => !!viewNavigation?.busy,
navigation: () => "sculpt",
palmRejection: () => $("palm-rejection").checked,
schedule,
invalidate,
changed: updateUI,
committed: checkModified,
message,
cursor,
pressure,
});
const menus = new Menus(
() => !interactionBusy(),
(e) => e.target === viewport.querySelector("canvas") && input.rejectsTouch(e),
);
viewNavigation = new Navigation($("view-navigation"), viewport, {
camera,
ready: () =>
!!renderer &&
!initializing &&
!fileBusy &&
!input.busy &&
!operation &&
sizeKey() === lastSize &&
!document.querySelector("dialog[open]"),
start: () => {
menus.close(false);
cursor(null);
},
changed: updateUI,
invalidate,
});
function interactionBusy() {
return input.busy || !!viewNavigation?.busy || !!operation;
}
const colorHex = (color) =>
"#" +
color
.map((x) =>
Math.round(x ** (1 / 2.2) * 255)
.toString(16)
.padStart(2, "0"),
)
.join("");
function updatePieces(busy) {
const pieces = engine.mesh.pieces;
const signature = JSON.stringify([pieces, engine.activeId, busy]);
if (signature !== piecesSignature) {
piecesSignature = signature;
$("piece-list").replaceChildren(
...pieces.map((piece) => {
const button = document.createElement("button");
button.className = "btn piece-row";
button.setAttribute(
"aria-pressed",
String(piece.id === engine.activeId),
);
button.dataset.piece = piece.id;
button.disabled = busy;
const swatch = document.createElement("i");
swatch.style.background = colorHex(piece.color);
button.append(swatch, document.createTextNode(piece.name));
button.onclick = () => {
if (interactionBusy() || initializing || fileBusy) return;
engine.activeId = piece.id;
updateUI();
};
return button;
}),
);
}
const active = pieces.find((p) => p.id === engine.activeId);
for (const id of [
"delete-piece",
"piece-color",
"subdivide",
"decimate",
"clear-mask",
"invert-mask",
])
$(id).disabled = busy || !active;
if (active) $("piece-color").value = colorHex(active.color);
document
.querySelectorAll("[data-preset]")
.forEach((b) => (b.disabled = busy || pieces.length >= 16));
$("empty-clay").hidden = !!pieces.length || busy;
}
function settleOperation(job) {
if (!job || operation !== job) return false;
clearTimeout(job.timer);
job.worker.terminate();
operation = null;
$("clay-progress").hidden = true;
return true;
}
function cancelOperation() {
if (settleOperation(operation)) updateUI();
}
function clayOperation(action, options = {}) {
if (interactionBusy() || initializing || fileBusy) return;
const worker = new Worker(new URL("./operations.js", import.meta.url), {
type: "module",
name: "clay-topology",
});
const job = (operation = {
worker,
engine,
state: engine.state,
generation,
size: sizeKey(),
});
const finish = () => {
if (!settleOperation(job)) return false;
updateUI();
return true;
};
job.timer = setTimeout(() => {
if (finish())
message("That operation took too long. Your clay is unchanged.");
}, 20000);
worker.onerror = worker.onmessageerror = () => {
if (finish()) message("Could not edit this piece. Your clay is unchanged.");
};
worker.onmessage = ({ data }) => {
if (operation !== job) return;
if (
engine !== job.engine ||
engine.state !== job.state ||
generation !== job.generation ||
sizeKey() !== job.size
) {
finish();
return;
}
if (data.error) {
finish();
message(data.error);
return;
}
try {
const changed = engine.install(data.document, data.activeId);
finish();
checkModified();
if (changed) {
if (action === "add") frameModel();
recreate();
}
message(data.note || (changed ? "Clay updated" : "No change needed"));
} catch (error) {
finish();
message(error.message);
}
};
$("clay-progress").hidden = false;
$("operation-label").textContent =
action === "decimate"
? "Simplifying clay…"
: action === "subdivide"
? "Adding detail…"
: "Updating clay…";
updateUI();
try {
const document = snapshot(engine.mesh);
worker.postMessage(
{ document, action, activeId: engine.activeId, options },
[
document.positions.buffer,
document.indices.buffer,
document.masks.buffer,
],
);
} catch (error) {
if (finish()) message(error.message);
}
}
$("cancel-operation").onclick = cancelOperation;
window.addEventListener(
"keydown",
(e) => {
if (e.key === "Escape" && !document.querySelector("dialog[open]"))
cancelOperation();
},
true,
);
$("add-empty").onclick = () => $("pieces-toggle").click();
$("delete-piece").onclick = () => clayOperation("delete");
$("subdivide").onclick = () => clayOperation("subdivide");
$("decimate").onclick = () => clayOperation("decimate");
document.querySelectorAll("[data-preset]").forEach(
(b) =>
(b.onclick = () => {
// Store the randomly chosen material once; undo/redo never rerolls it.
const hue = Math.random() * 6;
const rgb = [0, 4, 2].map((offset) => {
const k = (hue + offset) % 6;
return (0.88 - 0.24 * Math.max(0, Math.min(k, 4 - k, 1))) ** 2.2;
});
clayOperation("add", {
preset: b.dataset.preset,
id: crypto.randomUUID(),
color: rgb,
});
}),
);
$("piece-color").onchange = () => {
if (interactionBusy() || initializing || !engine.activeId) return;
const hex = $("piece-color").value;
engine.setColor(
[1, 3, 5].map((i) => (parseInt(hex.slice(i, i + 2), 16) / 255) ** 2.2),
);
checkModified();
invalidate();
};
for (const id of ["clear-mask", "invert-mask"])
$(id).onclick = () => {
if (interactionBusy() || initializing) return;
engine.editMask(id === "invert-mask");
checkModified();
invalidate();
};
$("palm-rejection").onchange = () => {
if ($("palm-rejection").checked && input.active?.type === "touch")
input.cancel();
};
function message(text) {
$("status").textContent = text;
$("status").classList.add("visible");
clearTimeout(messageTimer);
messageTimer = setTimeout(
() => $("status").classList.remove("visible"),
3500,
);
}
function checkModified() {
modified = engine.state !== saved;
updateUI();
}
function updateUI() {
const busy = interactionBusy() || initializing || fileBusy;
$("undo").disabled = busy || !engine.undoStack.length;
$("redo").disabled = busy || !engine.redoStack.length;
for (const id of ["new", "open", "quality"]) $(id).disabled = busy;
for (const id of ["save", "export"])
$(id).disabled = interactionBusy() || fileBusy;
$("modified").hidden = !modified;
updatePieces(busy);
if (pendingResize && !resizeTimer && !interactionBusy() && !initializing) {
const token = generation;
queueMicrotask(() => {
if (
token === generation &&
pendingResize &&
!resizeTimer &&
!interactionBusy() &&
!initializing
)
recreate();
});
}
}
function cursor(sample) {
const el = $("cursor");
el.hidden = !sample || !renderer || initializing || !!viewNavigation?.busy;
if (!sample) return;
const s = engine.stroke?.settings ?? settings;
const p = pressureValue(sample.pressure, s.gain, s.gamma),
radius =
(s.radius *
(s.pressureRadius ? 0.25 + 0.75 * p : 1) *
viewport.clientHeight) /
(2 * camera.zoom);
el.style.width = el.style.height = `${radius * 2}px`;
el.style.left = `${sample.x}px`;
el.style.top = `${sample.y}px`;
}
function pressure(value) {
$("pen-state").textContent =
value === null ? "Mouse / touch" : `Pen · ${Math.round(value * 100)}%`;
$("pressure-fill").style.width =
`${value === null ? 0 : pressureValue(value, settings.gain, settings.gamma) * 100}%`;
}
function invalidate() {
viewRevision++;
schedule();
}
function schedule() {
if (!frameRequest) frameRequest = requestAnimationFrame(tick);
}
function fail(error) {
clearTimeout(resizeTimer);
resizeTimer = 0;
pendingResize = false;
settleOperation(operation);
viewNavigation?.cancel();
generation++;
renderer?.dispose();
renderer = null;
engine.markAll();
input.cancel();
$("loading").hidden = true;
$("error").hidden = false;
console.error(error);
$("error-detail").textContent =
"The canvas could not start. Try again, or open this page in an up-to-date browser. You can still download your project in Settings.";
message("Canvas paused. Your completed work is still here.");
updateUI();
}
function tick() {
frameRequest = 0;
viewNavigation.checkViewport();
viewNavigation.render();
input.checkViewport();
if (
renderer &&
!initializing &&
!publishing &&
(published !== viewRevision || engine.dirty.size)
) {
const active = renderer,
token = generation,
revision = viewRevision;
const snapshot = {
...camera,
target: camera.target.slice(),
color: camera.color.slice(),
};
publishing = true;
active
.publish(engine, snapshot)
.then(() => {
if (token === generation) published = revision;
})
.catch((error) => {
if (token === generation) fail(error);
})
.finally(() => {
publishing = false;
if (
renderer &&
!initializing &&
(published !== viewRevision || engine.dirty.size)
)
schedule();
});
}
}
function sizeKey() {
const rect = viewport.getBoundingClientRect();
return `${rect.width}:${rect.height}:${devicePixelRatio}:${$("quality").value}`;
}
async function recreate() {
if (interactionBusy() || initializing) {
pendingResize = true;
return;
}
if (!viewport.clientWidth || !viewport.clientHeight) return;
const aspect = viewport.clientWidth / viewport.clientHeight;
if (cameraAspect)
camera.zoom *= Math.min(1, cameraAspect) / Math.min(1, aspect);
cameraAspect = aspect;
clearTimeout(resizeTimer);
resizeTimer = 0;
pendingResize = false;
initializing = true;
const token = ++generation;
lastSize = sizeKey();
renderer?.dispose();
renderer = null;
$("loading").hidden = false;
$("error").hidden = true;
cursor(null);
updateUI();
try {
if (!crossOriginIsolated || typeof SharedArrayBuffer === "undefined")
throw new Error("Cross-origin isolation is required");
if (!navigator.gpu) throw new Error("WebGPU is not available");
const canvas = document.createElement("canvas");
canvas.setAttribute("aria-label", "3D sculpt");
viewport.querySelector("canvas").replaceWith(canvas);
const next = await Renderer.create(
canvas,
engine.mesh,
camera,
$("quality").value,
);
if (token !== generation) {
next.dispose();
return;
}
renderer = next;
next.processor = new StrokeClient(next.sample, engine.mesh, (error) => {
if (token === generation) fail(error);
});
await next.processor.ready;
if (token !== generation) {
next.dispose();
return;
}
engine.markAll();
published = -1;
$("loading").hidden = true;
if (sizeKey() !== lastSize) resized();
else message("Ready to sculpt");
} catch (error) {
if (token === generation) fail(error);
} finally {
initializing = false;
updateUI();
if (renderer) invalidate();
}
}
function frameModel() {
cameraAspect = viewport.clientWidth / viewport.clientHeight;
if (!engine.mesh.indices.length) {
camera.target = [0, 0, 0];
camera.zoom = 1.5;
invalidate();
return;
}
const bounds = engine.bvh.root;
camera.target = bounds.min.map((v, k) => (v + bounds.max[k]) / 2);
const { right, up } = cameraBasis(camera.yaw, camera.pitch);
const half = bounds.max.map((v, k) => (v - bounds.min[k]) / 2);
const width = half.reduce((sum, v, k) => sum + v * Math.abs(right[k]), 0);
const height = half.reduce((sum, v, k) => sum + v * Math.abs(up[k]), 0);
camera.zoom = Math.max(
0.2,
1.45 *
Math.max(height, (width * viewport.clientHeight) / viewport.clientWidth),
);
invalidate();
}
function replace(mesh, name) {
engine = new Sculpt(mesh);
saved = engine.state;
modified = false;
$("document-name").textContent = name;
camera.yaw = 0;
camera.pitch = 0;
frameModel();
recreate();
updateUI();
}
function download(text, name, type) {
const url = URL.createObjectURL(new Blob([text], { type }));
const a = document.createElement("a");
a.href = url;
a.download = name;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
$("save").onclick = () => {
if (interactionBusy() || fileBusy) return;
download(saveProject(engine.mesh), "sculpt.yawn-sculpt", "application/json");
saved = engine.state;
modified = false;
updateUI();
message("Project downloaded");
};
$("export").onclick = () => {
if (interactionBusy() || fileBusy) return;
download(exportOBJ(engine.mesh), "sculpt.obj", "text/plain");
message("Model downloaded");
};
$("open").onclick = () => {
if (!interactionBusy() && !initializing) $("file").click();
};
$("file").onchange = async () => {
const file = $("file").files[0];
$("file").value = "";
if (!file || interactionBusy() || initializing) return;
fileBusy = true;
updateUI();
try {
if (file.size > 8 * 1048576)
throw new Error("Project exceeds the 8 MiB limit");
const mesh = loadProject(await file.text());
if (
modified &&
!confirm(
"Replace your current sculpt? Download it first if you want to keep it.",
)
)
return;
replace(mesh, file.name.replace(/\.yawn-sculpt$/, ""));
} catch (error) {
console.error(error);
message(
"Could not open this project. Choose a project downloaded from Sculpt.",
);
} finally {
fileBusy = false;
updateUI();
}
};
$("new").onclick = () => {
if (interactionBusy() || initializing) return;
$("new-warning").textContent = modified
? "This replaces your unsaved sculpt. Save it first to keep it."
: "Choose how much detail you want.";
$("new-dialog").returnValue = "cancel";
$("new-dialog").showModal();
};
$("new-dialog").addEventListener("close", () => {
if ($("new-dialog").returnValue === "create")
replace(sphere(Number($("density").value)), "Untitled sphere");
});
$("help").onclick = () => $("help-dialog").showModal();
$("undo").onclick = () => {
const mesh = engine.mesh;
if (!interactionBusy() && !initializing && engine.restore()) {
checkModified();
if (engine.mesh !== mesh) recreate();
invalidate();
message("Undo");
}
};
$("redo").onclick = () => {
const mesh = engine.mesh;
if (!interactionBusy() && !initializing && engine.restore(true)) {
checkModified();
if (engine.mesh !== mesh) recreate();
invalidate();
message("Redo");
}
};
$("frame").onclick = () => {
if (!interactionBusy()) frameModel();
};
function chooseBrush(name) {
if (interactionBusy()) return;
settings.brush = name;
document
.querySelectorAll("[data-brush]")
.forEach((b) =>
b.setAttribute("aria-pressed", String(b.dataset.brush === name)),
);
$("brush-title").textContent = name[0].toUpperCase() + name.slice(1);
$("invert").disabled = !["draw", "inflate", "mask"].includes(name);
$("invert-label").textContent = name === "mask" ? "Erase mask" : "Subtract";
}
document
.querySelectorAll("[data-brush]")
.forEach((b) => (b.onclick = () => chooseBrush(b.dataset.brush)));
function settingsUI() {
for (const id of ["radius", "strength", "gain", "gamma"]) {
settings[id] = Number($(id).value);
$(id).style.setProperty(
"--slider-value",
`${(100 * (settings[id] - Number($(id).min))) / (Number($(id).max) - Number($(id).min))}%`,
);
$(id + "-value").textContent =
id === "radius"
? `${Math.round((settings[id] / 0.6) * 100)}%`
: id === "strength"
? `${Math.round(settings[id] * 100)}%`
: settings[id].toFixed(2) + (id === "gain" ? "×" : "");
}
for (const id of ["symmetry", "invert", "pressureRadius"])
settings[id] = $(id).checked;
$("curve-line").setAttribute(
"d",
Array.from(
{ length: 41 },
(_, i) =>
`${i ? "L" : "M"}${i * 5} ${53 - 52 * pressureValue(i / 40, settings.gain, settings.gamma)}`,
).join(" "),
);
}
for (const id of [
"radius",
"strength",
"gain",
"gamma",
"symmetry",
"invert",
"pressureRadius",
])
$(id).addEventListener("input", settingsUI);
$("reset-pressure").onclick = () => {
$("gain").value = "1";
$("gamma").value = "1";
$("pressureRadius").checked = false;
settingsUI();
};
document.querySelectorAll("[data-color]").forEach(
(b) =>
(b.onclick = () => {
if (interactionBusy() || initializing || !engine.activeId) return;
engine.setColor(b.dataset.color.split(",").map(Number));
checkModified();
document
.querySelectorAll("[data-color]")
.forEach((x) => x.setAttribute("aria-pressed", String(x === b)));
invalidate();
}),
);
$("fullscreen").hidden = !document.fullscreenEnabled;
$("fullscreen").onclick = async () => {
try {
if (document.fullscreenElement) await document.exitFullscreen();
else await document.documentElement.requestFullscreen();
} catch {
message("Full screen isn't available in this browser.");
}
};
document.addEventListener("fullscreenchange", () => {
$("fullscreen").textContent = document.fullscreenElement
? "Exit full screen"
: "Full screen";
});
$("quality").onchange = () => {
pendingResize = true;
recreate();
};
$("retry").onclick = recreate;
let resizeTimer;
function resized() {
viewNavigation.checkViewport();
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
resizeTimer = 0;
if (sizeKey() !== lastSize) {
pendingResize = true;
recreate();
}
}, 200);
if (operation && operation.size !== sizeKey()) cancelOperation();
const unchanged = input.checkViewport();
if (sizeKey() !== lastSize) {
cursor(null);
if (unchanged) message("Updating viewport…");
}
}
new ResizeObserver(resized).observe(viewport);
window.addEventListener("resize", resized);
function watchDpr() {
const query = matchMedia(`(resolution: ${devicePixelRatio}dppx)`);
query.addEventListener(
"change",
() => {
resized();
watchDpr();
},
{ once: true },
);
}
watchDpr();
window.addEventListener("beforeunload", (e) => {
if (modified || engine.stroke) {
e.preventDefault();
e.returnValue = "";
}
});
document.addEventListener("visibilitychange", () => {
if (document.hidden) input.cancel();
const active = renderer,
token = generation;
if (!active) return;
(document.hidden ? active.core.pause() : active.core.play())
.then(() => {
if (token === generation && !document.hidden) invalidate();
})
.catch((error) => {
if (token === generation) fail(error);
});
});
window.addEventListener("keydown", (e) => {
if (e.key === "Escape" && !document.querySelector("dialog[open]")) {
viewNavigation.cancel();
input.cancel();
return;
}
if (
document.querySelector("dialog[open]") ||
e.target.closest?.(".panel,.tool-rail,#view-navigation") ||
/INPUT|SELECT|TEXTAREA/.test(e.target.tagName) ||
e.target.isContentEditable
)
return;
if (interactionBusy()) return;
const key = e.key.toLowerCase(),
mod = e.ctrlKey || e.metaKey;
if (mod && ["z", "s", "o", "y"].includes(key)) {
e.preventDefault();
$(
key === "s"
? "save"
: key === "o"
? "open"
: key === "y" || e.shiftKey
? "redo"
: "undo",
).click();
return;
}
if (mod || e.altKey) return;
if (/^[1-6]$/.test(key)) chooseBrush(brushes[Number(key) - 1]);
else if (key === "f") $("frame").click();
else if (key === "?") $("help").click();
else if (key === "[" || key === "]") {
$("radius").value = String(
clamp(settings.radius + (key === "]" ? 0.01 : -0.01), 0.04, 0.6),
);
settingsUI();
} else if (
["arrowleft", "arrowright", "arrowup", "arrowdown", "+", "=", "-"].includes(
key,
)
) {
e.preventDefault();
if (key === "arrowleft") camera.yaw -= 0.1;
else if (key === "arrowright") camera.yaw += 0.1;
else if (key === "arrowup")
camera.pitch = clamp(camera.pitch + 0.1, -Math.PI / 2, Math.PI / 2);
else if (key === "arrowdown")
camera.pitch = clamp(camera.pitch - 0.1, -Math.PI / 2, Math.PI / 2);
else
camera.zoom = clampZoom(
camera.zoom * (key === "-" ? 1.1 : 0.9),
viewport.getBoundingClientRect(),
);
invalidate();
}
});
settingsUI();
frameModel();
updateUI();
recreate();