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:
+233
-20
@@ -13,6 +13,7 @@ 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");
|
||||
@@ -35,9 +36,11 @@ let viewRevision = 0,
|
||||
generation = 0,
|
||||
lastSize = "",
|
||||
modified = false,
|
||||
saved = engine.mesh.positions.slice(),
|
||||
saved = engine.state,
|
||||
fileBusy = false;
|
||||
const brushes = ["draw", "inflate", "smooth", "flatten", "grab"];
|
||||
let operation = null,
|
||||
piecesSignature = "";
|
||||
const brushes = ["draw", "inflate", "smooth", "flatten", "grab", "mask"];
|
||||
let viewNavigation, cameraAspect, messageTimer;
|
||||
const input = new Input(viewport, {
|
||||
engine: () => engine,
|
||||
@@ -48,6 +51,7 @@ const input = new Input(viewport, {
|
||||
!!renderer &&
|
||||
!initializing &&
|
||||
!fileBusy &&
|
||||
!operation &&
|
||||
!viewNavigation?.busy &&
|
||||
sizeKey() === lastSize,
|
||||
blocked: () => !!viewNavigation?.busy,
|
||||
@@ -72,6 +76,7 @@ viewNavigation = new Navigation($("view-navigation"), viewport, {
|
||||
!initializing &&
|
||||
!fileBusy &&
|
||||
!input.busy &&
|
||||
!operation &&
|
||||
sizeKey() === lastSize &&
|
||||
!document.querySelector("dialog[open]"),
|
||||
start: () => {
|
||||
@@ -82,8 +87,193 @@ viewNavigation = new Navigation($("view-navigation"), viewport, {
|
||||
invalidate,
|
||||
});
|
||||
function interactionBusy() {
|
||||
return input.busy || !!viewNavigation?.busy;
|
||||
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();
|
||||
@@ -99,7 +289,7 @@ function message(text) {
|
||||
);
|
||||
}
|
||||
function checkModified() {
|
||||
modified = !engine.mesh.positions.every((v, i) => v === saved[i]);
|
||||
modified = engine.state !== saved;
|
||||
updateUI();
|
||||
}
|
||||
function updateUI() {
|
||||
@@ -110,9 +300,18 @@ function updateUI() {
|
||||
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 (pendingResize && !resizeTimer) recreate();
|
||||
if (
|
||||
token === generation &&
|
||||
pendingResize &&
|
||||
!resizeTimer &&
|
||||
!interactionBusy() &&
|
||||
!initializing
|
||||
)
|
||||
recreate();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -145,6 +344,10 @@ function schedule() {
|
||||
if (!frameRequest) frameRequest = requestAnimationFrame(tick);
|
||||
}
|
||||
function fail(error) {
|
||||
clearTimeout(resizeTimer);
|
||||
resizeTimer = 0;
|
||||
pendingResize = false;
|
||||
settleOperation(operation);
|
||||
viewNavigation?.cancel();
|
||||
generation++;
|
||||
renderer?.dispose();
|
||||
@@ -242,13 +445,9 @@ async function recreate() {
|
||||
return;
|
||||
}
|
||||
renderer = next;
|
||||
next.processor = new StrokeClient(
|
||||
next.sample,
|
||||
engine.mesh.level,
|
||||
(error) => {
|
||||
if (token === generation) fail(error);
|
||||
},
|
||||
);
|
||||
next.processor = new StrokeClient(next.sample, engine.mesh, (error) => {
|
||||
if (token === generation) fail(error);
|
||||
});
|
||||
await next.processor.ready;
|
||||
if (token !== generation) {
|
||||
next.dispose();
|
||||
@@ -269,6 +468,12 @@ async function recreate() {
|
||||
}
|
||||
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);
|
||||
@@ -283,8 +488,8 @@ function frameModel() {
|
||||
invalidate();
|
||||
}
|
||||
function replace(mesh, name) {
|
||||
engine = new Sculpt(mesh, 2 ** (mesh.level - 4) * 8 * 1048576);
|
||||
saved = mesh.positions.slice();
|
||||
engine = new Sculpt(mesh);
|
||||
saved = engine.state;
|
||||
modified = false;
|
||||
$("document-name").textContent = name;
|
||||
camera.yaw = 0;
|
||||
@@ -304,7 +509,7 @@ function download(text, name, type) {
|
||||
$("save").onclick = () => {
|
||||
if (interactionBusy() || fileBusy) return;
|
||||
download(saveProject(engine.mesh), "sculpt.yawn-sculpt", "application/json");
|
||||
saved = engine.mesh.positions.slice();
|
||||
saved = engine.state;
|
||||
modified = false;
|
||||
updateUI();
|
||||
message("Project downloaded");
|
||||
@@ -359,15 +564,19 @@ $("new-dialog").addEventListener("close", () => {
|
||||
});
|
||||
$("help").onclick = () => $("help-dialog").showModal();
|
||||
$("undo").onclick = () => {
|
||||
if (!interactionBusy() && engine.restore()) {
|
||||
const mesh = engine.mesh;
|
||||
if (!interactionBusy() && !initializing && engine.restore()) {
|
||||
checkModified();
|
||||
if (engine.mesh !== mesh) recreate();
|
||||
invalidate();
|
||||
message("Undo");
|
||||
}
|
||||
};
|
||||
$("redo").onclick = () => {
|
||||
if (!interactionBusy() && engine.restore(true)) {
|
||||
const mesh = engine.mesh;
|
||||
if (!interactionBusy() && !initializing && engine.restore(true)) {
|
||||
checkModified();
|
||||
if (engine.mesh !== mesh) recreate();
|
||||
invalidate();
|
||||
message("Redo");
|
||||
}
|
||||
@@ -384,7 +593,8 @@ function chooseBrush(name) {
|
||||
b.setAttribute("aria-pressed", String(b.dataset.brush === name)),
|
||||
);
|
||||
$("brush-title").textContent = name[0].toUpperCase() + name.slice(1);
|
||||
$("invert").disabled = !["draw", "inflate"].includes(name);
|
||||
$("invert").disabled = !["draw", "inflate", "mask"].includes(name);
|
||||
$("invert-label").textContent = name === "mask" ? "Erase mask" : "Subtract";
|
||||
}
|
||||
document
|
||||
.querySelectorAll("[data-brush]")
|
||||
@@ -433,7 +643,9 @@ $("reset-pressure").onclick = () => {
|
||||
document.querySelectorAll("[data-color]").forEach(
|
||||
(b) =>
|
||||
(b.onclick = () => {
|
||||
camera.color = b.dataset.color.split(",").map(Number);
|
||||
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)));
|
||||
@@ -470,6 +682,7 @@ function resized() {
|
||||
recreate();
|
||||
}
|
||||
}, 200);
|
||||
if (operation && operation.size !== sizeKey()) cancelOperation();
|
||||
const unchanged = input.checkViewport();
|
||||
if (sizeKey() !== lastSize) {
|
||||
cursor(null);
|
||||
@@ -539,7 +752,7 @@ window.addEventListener("keydown", (e) => {
|
||||
return;
|
||||
}
|
||||
if (mod || e.altKey) return;
|
||||
if (/^[1-5]$/.test(key)) chooseBrush(brushes[Number(key) - 1]);
|
||||
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 === "]") {
|
||||
|
||||
Reference in New Issue
Block a user