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>
574 lines
16 KiB
JavaScript
574 lines
16 KiB
JavaScript
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();
|