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:
Amp
2026-09-28 08:53:50 +00:00
co-authored by heaust
parent eb8aa0e9dc
commit 347b676cf1
25 changed files with 5468 additions and 1 deletions
+573
View File
@@ -0,0 +1,573 @@
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";
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.mesh.positions.slice(),
fileBusy = false;
const brushes = ["draw", "inflate", "smooth", "flatten", "grab"];
let viewNavigation, cameraAspect, messageTimer;
const input = new Input(viewport, {
engine: () => engine,
processor: () => renderer.processor,
camera,
settings,
ready: () =>
!!renderer &&
!initializing &&
!fileBusy &&
!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 &&
sizeKey() === lastSize &&
!document.querySelector("dialog[open]"),
start: () => {
menus.close(false);
cursor(null);
},
changed: updateUI,
invalidate,
});
function interactionBusy() {
return input.busy || !!viewNavigation?.busy;
}
$("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.mesh.positions.every((v, i) => v === saved[i]);
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;
if (pendingResize && !resizeTimer && !interactionBusy() && !initializing) {
queueMicrotask(() => {
if (pendingResize && !resizeTimer) 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) {
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.level,
(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;
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, 2 ** (mesh.level - 4) * 8 * 1048576);
saved = mesh.positions.slice();
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.mesh.positions.slice();
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 = () => {
if (!interactionBusy() && engine.restore()) {
checkModified();
invalidate();
message("Undo");
}
};
$("redo").onclick = () => {
if (!interactionBusy() && engine.restore(true)) {
checkModified();
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"].includes(name);
}
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 = () => {
camera.color = b.dataset.color.split(",").map(Number);
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);
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-5]$/.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();