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:
@@ -1,6 +1,13 @@
|
||||
import { sphere, updateNormals } from "./mesh.js";
|
||||
import { BVH } from "./bvh.js";
|
||||
import { point, sub, dot, clamp } from "./math.js";
|
||||
import {
|
||||
ensureDocument,
|
||||
snapshot,
|
||||
hydrate,
|
||||
saveDocument,
|
||||
loadDocument,
|
||||
} from "./document.js";
|
||||
|
||||
export const pressureValue = (pressure, gain = 1, gamma = 1) =>
|
||||
clamp(gain * clamp(pressure, 0, 1) ** gamma, 0, 1);
|
||||
@@ -15,14 +22,53 @@ export const defaults = {
|
||||
pressureRadius: false,
|
||||
};
|
||||
|
||||
const persistedJson = (value) =>
|
||||
JSON.stringify(value, (_key, child) => {
|
||||
if (!child || Array.isArray(child) || typeof child !== "object")
|
||||
return child;
|
||||
return Object.fromEntries(
|
||||
Object.keys(child)
|
||||
.sort()
|
||||
.filter((key) => child[key] !== undefined)
|
||||
.map((key) => [key, child[key]]),
|
||||
);
|
||||
});
|
||||
const canonicalEqual = (a, b) => {
|
||||
if (
|
||||
a.level !== b.level ||
|
||||
a.positions.length !== b.positions.length ||
|
||||
a.indices.length !== b.indices.length ||
|
||||
a.masks.length !== b.masks.length ||
|
||||
a.pieces.length !== b.pieces.length
|
||||
)
|
||||
return false;
|
||||
for (const name of ["positions", "indices", "masks"])
|
||||
for (let i = 0; i < a[name].length; i++)
|
||||
if (a[name][i] !== b[name][i]) return false;
|
||||
return persistedJson(a.pieces) === persistedJson(b.pieces);
|
||||
};
|
||||
const snapshotBytes = (value) =>
|
||||
value.positions.byteLength +
|
||||
value.indices.byteLength +
|
||||
value.masks.byteLength +
|
||||
8 +
|
||||
new TextEncoder().encode(JSON.stringify(value.pieces)).byteLength;
|
||||
const allDirty = (mesh) =>
|
||||
new Set(
|
||||
Array.from({ length: mesh.positions.length / 3 }, (_, vertex) => vertex),
|
||||
);
|
||||
|
||||
export class Sculpt {
|
||||
constructor(mesh = sphere(), historyCap = 16 * 1024 * 1024) {
|
||||
this.mesh = mesh;
|
||||
this.mesh = ensureDocument(mesh);
|
||||
this.bvh = new BVH(mesh);
|
||||
this.undoStack = [];
|
||||
this.redoStack = [];
|
||||
this.historyCap = historyCap;
|
||||
this.revision = 0;
|
||||
this.state = 0;
|
||||
this.nextState = 1;
|
||||
this.activeId = this.mesh.pieces[0]?.id ?? null;
|
||||
this.dirty = new Set();
|
||||
this.markAll();
|
||||
this.stroke = null;
|
||||
@@ -33,16 +79,30 @@ export class Sculpt {
|
||||
}
|
||||
get historyBytes() {
|
||||
return [...this.undoStack, ...this.redoStack].reduce(
|
||||
(sum, e) =>
|
||||
sum + e.ids.byteLength + e.before.byteLength + e.after.byteLength,
|
||||
(sum, e) => sum + (e.bytes ?? 0),
|
||||
0,
|
||||
);
|
||||
}
|
||||
commit(entry) {
|
||||
entry.beforeState = this.state;
|
||||
entry.afterState = this.nextState++;
|
||||
this.state = entry.afterState;
|
||||
this.redoStack = [];
|
||||
this.undoStack.push(entry);
|
||||
while (this.undoStack.length > 50 || this.historyBytes > this.historyCap)
|
||||
this.undoStack.shift();
|
||||
}
|
||||
begin(settings) {
|
||||
if (this.stroke) throw new Error("A stroke is already active.");
|
||||
const requested = settings?.activeId;
|
||||
if (requested && this.mesh.pieces.some((p) => p.id === requested))
|
||||
this.activeId = requested;
|
||||
const piece = this.mesh.pieces.find((p) => p.id === this.activeId);
|
||||
this.stroke = {
|
||||
settings: { ...defaults, ...settings },
|
||||
piece,
|
||||
before: this.mesh.positions.slice(),
|
||||
beforeMasks: this.mesh.masks.slice(),
|
||||
touched: new Set(),
|
||||
grab: null,
|
||||
};
|
||||
@@ -56,7 +116,14 @@ export class Sculpt {
|
||||
}
|
||||
dab(hit, rawPressure = 1, drag = [0, 0, 0]) {
|
||||
if (!this.stroke || !hit) return;
|
||||
const { settings: s } = this.stroke;
|
||||
const { settings: s, piece } = this.stroke;
|
||||
if (
|
||||
!piece ||
|
||||
(hit.face != null &&
|
||||
(hit.face * 3 < piece.indexStart ||
|
||||
hit.face * 3 >= piece.indexStart + piece.indexCount))
|
||||
)
|
||||
return;
|
||||
const pressure = pressureValue(rawPressure, s.gain, s.gamma);
|
||||
if (pressure === 0) return;
|
||||
const radius = s.radius * (s.pressureRadius ? 0.25 + 0.75 * pressure : 1);
|
||||
@@ -68,12 +135,17 @@ export class Sculpt {
|
||||
affected = new Map();
|
||||
for (const mirror of s.symmetry ? [1, -1] : [1]) {
|
||||
const center = [
|
||||
hit.position[0] * mirror,
|
||||
piece.symmetryX + (hit.position[0] - piece.symmetryX) * mirror,
|
||||
hit.position[1],
|
||||
hit.position[2],
|
||||
];
|
||||
const normal = [hit.normal[0] * mirror, hit.normal[1], hit.normal[2]];
|
||||
for (const v of this.bvh.within(center, radius)) {
|
||||
if (
|
||||
v < piece.vertexStart ||
|
||||
v >= piece.vertexStart + piece.vertexCount
|
||||
)
|
||||
continue;
|
||||
if (dot(point(n, v), normal) < 0.05) continue;
|
||||
const delta = sub(point(p, v), center),
|
||||
weight = (1 - dot(delta, delta) / (radius * radius)) ** 2;
|
||||
@@ -112,12 +184,34 @@ export class Sculpt {
|
||||
}
|
||||
for (let k = 0; k < 3; k++) displacement[k] += delta[k] * weight;
|
||||
}
|
||||
if (s.brush === "mask") {
|
||||
const old = this.mesh.masks[v];
|
||||
const direction = s.invert ? -1 : 1;
|
||||
const next = clamp(
|
||||
old + direction * s.strength * pressure * envelope,
|
||||
0,
|
||||
1,
|
||||
);
|
||||
if (Math.fround(next) !== old) {
|
||||
this.mesh.masks[v] = next;
|
||||
this.stroke.touched.add(v);
|
||||
this.dirty.add(v);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const base = s.brush === "grab" ? point(this.stroke.before, v) : position;
|
||||
let next = base.map(
|
||||
(x, k) =>
|
||||
x + (displacement[k] * s.strength * pressure * envelope) / total,
|
||||
x +
|
||||
(displacement[k] *
|
||||
s.strength *
|
||||
pressure *
|
||||
envelope *
|
||||
(1 - this.mesh.masks[v])) /
|
||||
total,
|
||||
);
|
||||
if (s.symmetry && this.stroke.before[v * 3] === 0) next[0] = 0;
|
||||
if (s.symmetry && this.stroke.before[v * 3] === piece.symmetryX)
|
||||
next[0] = piece.symmetryX;
|
||||
// Bounded document coordinates keep camera and project validation meaningful.
|
||||
next = next.map((x) => clamp(x, -8, 8));
|
||||
if (next.some((x, k) => Math.fround(x) !== position[k])) {
|
||||
@@ -129,52 +223,231 @@ export class Sculpt {
|
||||
// Jacobi update: smoothing reads only the pre-dab mesh, independent of iteration order.
|
||||
for (let i = 0; i < moved.length; i++)
|
||||
p.set(updates.slice(i * 3, i * 3 + 3), moved[i] * 3);
|
||||
this.changed(moved);
|
||||
if (s.brush === "mask") {
|
||||
if (this.stroke.touched.size) this.revision++;
|
||||
} else this.changed(moved);
|
||||
}
|
||||
end(cancel = false) {
|
||||
if (!this.stroke) return;
|
||||
const { before, touched } = this.stroke;
|
||||
const { before, beforeMasks, touched, settings } = this.stroke;
|
||||
this.stroke = null;
|
||||
const maskStroke = settings.brush === "mask";
|
||||
const ids = Uint32Array.from(
|
||||
[...touched].filter((v) =>
|
||||
[0, 1, 2].some(
|
||||
(k) => before[v * 3 + k] !== this.mesh.positions[v * 3 + k],
|
||||
),
|
||||
maskStroke
|
||||
? beforeMasks[v] !== this.mesh.masks[v]
|
||||
: [0, 1, 2].some(
|
||||
(k) => before[v * 3 + k] !== this.mesh.positions[v * 3 + k],
|
||||
),
|
||||
),
|
||||
);
|
||||
if (cancel) {
|
||||
for (const v of ids)
|
||||
this.mesh.positions.set(before.subarray(v * 3, v * 3 + 3), v * 3);
|
||||
this.changed(Array.from(ids));
|
||||
if (maskStroke) {
|
||||
for (const v of ids) {
|
||||
this.mesh.masks[v] = beforeMasks[v];
|
||||
this.dirty.add(v);
|
||||
}
|
||||
if (ids.length) this.revision++;
|
||||
} else {
|
||||
for (const v of ids)
|
||||
this.mesh.positions.set(before.subarray(v * 3, v * 3 + 3), v * 3);
|
||||
this.changed(Array.from(ids));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!ids.length) return;
|
||||
const width = maskStroke ? 1 : 3;
|
||||
const entry = {
|
||||
kind: maskStroke ? "mask" : "positions",
|
||||
ids,
|
||||
before: new Float32Array(ids.length * 3),
|
||||
after: new Float32Array(ids.length * 3),
|
||||
before: new Float32Array(ids.length * width),
|
||||
after: new Float32Array(ids.length * width),
|
||||
};
|
||||
ids.forEach((v, i) => {
|
||||
entry.before.set(before.subarray(v * 3, v * 3 + 3), i * 3);
|
||||
entry.after.set(this.mesh.positions.subarray(v * 3, v * 3 + 3), i * 3);
|
||||
if (maskStroke) {
|
||||
entry.before[i] = beforeMasks[v];
|
||||
entry.after[i] = this.mesh.masks[v];
|
||||
} else {
|
||||
entry.before.set(before.subarray(v * 3, v * 3 + 3), i * 3);
|
||||
entry.after.set(this.mesh.positions.subarray(v * 3, v * 3 + 3), i * 3);
|
||||
}
|
||||
});
|
||||
this.redoStack = [];
|
||||
this.undoStack.push(entry);
|
||||
while (this.undoStack.length > 50 || this.historyBytes > this.historyCap)
|
||||
this.undoStack.shift();
|
||||
entry.bytes =
|
||||
entry.ids.byteLength + entry.before.byteLength + entry.after.byteLength;
|
||||
this.commit(entry);
|
||||
}
|
||||
restore(redo = false) {
|
||||
if (this.stroke) return false;
|
||||
const source = redo ? this.redoStack : this.undoStack,
|
||||
target = redo ? this.undoStack : this.redoStack,
|
||||
entry = source.pop();
|
||||
entry = source[source.length - 1];
|
||||
if (!entry) return false;
|
||||
const values = redo ? entry.after : entry.before;
|
||||
entry.ids.forEach((v, i) =>
|
||||
this.mesh.positions.set(values.subarray(i * 3, i * 3 + 3), v * 3),
|
||||
);
|
||||
const expectedState = redo ? entry.beforeState : entry.afterState;
|
||||
const nextState = redo ? entry.afterState : entry.beforeState;
|
||||
if (
|
||||
!Number.isInteger(expectedState) ||
|
||||
!Number.isInteger(nextState) ||
|
||||
this.state !== expectedState
|
||||
)
|
||||
throw new Error("Invalid history entry.");
|
||||
let prepared = null;
|
||||
if (entry.kind === "structural") {
|
||||
const value = redo ? entry.after : entry.before;
|
||||
const mesh = hydrate(value?.mesh);
|
||||
if (
|
||||
value.activeId !== null &&
|
||||
!mesh.pieces.some((piece) => piece.id === value.activeId)
|
||||
)
|
||||
throw new Error("Invalid history active piece.");
|
||||
prepared = {
|
||||
mesh,
|
||||
activeId: value.activeId,
|
||||
bvh: new BVH(mesh),
|
||||
dirty: allDirty(mesh),
|
||||
};
|
||||
} else if (entry.kind === "color") {
|
||||
const piece = this.mesh.pieces.find((p) => p.id === entry.id);
|
||||
const color = redo ? entry.after : entry.before;
|
||||
if (
|
||||
!piece ||
|
||||
!Array.isArray(color) ||
|
||||
color.length !== 3 ||
|
||||
color.some((x) => !Number.isFinite(x) || x < 0 || x > 1)
|
||||
)
|
||||
throw new Error("Invalid history entry.");
|
||||
piece.color = [...color];
|
||||
for (
|
||||
let v = piece.vertexStart;
|
||||
v < piece.vertexStart + piece.vertexCount;
|
||||
v++
|
||||
)
|
||||
this.dirty.add(v);
|
||||
this.revision++;
|
||||
} else {
|
||||
const values = redo ? entry.after : entry.before;
|
||||
const width =
|
||||
entry.kind === "mask" ? 1 : entry.kind === "positions" ? 3 : 0;
|
||||
if (
|
||||
!(entry.ids instanceof Uint32Array) ||
|
||||
!(values instanceof Float32Array) ||
|
||||
!width ||
|
||||
values.length !== entry.ids.length * width ||
|
||||
[...entry.ids].some((v) => v >= this.mesh.positions.length / 3) ||
|
||||
[...values].some((x) => !Number.isFinite(x)) ||
|
||||
(entry.kind === "mask" && [...values].some((x) => x < 0 || x > 1))
|
||||
)
|
||||
throw new Error("Invalid history entry.");
|
||||
if (entry.kind === "mask") {
|
||||
entry.ids.forEach((v, i) => {
|
||||
this.mesh.masks[v] = values[i];
|
||||
this.dirty.add(v);
|
||||
});
|
||||
this.revision++;
|
||||
} else {
|
||||
entry.ids.forEach((v, i) =>
|
||||
this.mesh.positions.set(values.subarray(i * 3, i * 3 + 3), v * 3),
|
||||
);
|
||||
this.changed(Array.from(entry.ids));
|
||||
}
|
||||
}
|
||||
if (prepared) {
|
||||
this.mesh = prepared.mesh;
|
||||
this.activeId = prepared.activeId;
|
||||
this.bvh = prepared.bvh;
|
||||
this.dirty = prepared.dirty;
|
||||
this.revision++;
|
||||
}
|
||||
source.pop();
|
||||
target.push(entry);
|
||||
this.changed(Array.from(entry.ids));
|
||||
this.state = nextState;
|
||||
return true;
|
||||
}
|
||||
install(candidateMesh, selectedId = this.activeId) {
|
||||
if (this.stroke)
|
||||
throw new Error("Cannot install topology during a stroke.");
|
||||
const next = snapshot(candidateMesh),
|
||||
current = snapshot(this.mesh);
|
||||
if (canonicalEqual(current, next)) return false;
|
||||
const before = { mesh: current, activeId: this.activeId };
|
||||
const activeId = next.pieces.some((p) => p.id === selectedId)
|
||||
? selectedId
|
||||
: (next.pieces[0]?.id ?? null);
|
||||
const mesh = hydrate(next),
|
||||
bvh = new BVH(mesh),
|
||||
dirty = allDirty(mesh);
|
||||
const entry = {
|
||||
kind: "structural",
|
||||
before,
|
||||
after: { mesh: next, activeId },
|
||||
bytes: snapshotBytes(before.mesh) + snapshotBytes(next),
|
||||
};
|
||||
this.mesh = mesh;
|
||||
this.activeId = activeId;
|
||||
this.bvh = bvh;
|
||||
this.dirty = dirty;
|
||||
this.revision++;
|
||||
this.commit(entry);
|
||||
return true;
|
||||
}
|
||||
setColor(color) {
|
||||
const piece = this.mesh.pieces.find((p) => p.id === this.activeId);
|
||||
if (
|
||||
!piece ||
|
||||
!Array.isArray(color) ||
|
||||
color.length !== 3 ||
|
||||
color.some((x) => !Number.isFinite(x) || x < 0 || x > 1)
|
||||
)
|
||||
throw new Error("Invalid piece color.");
|
||||
if (color.every((x, i) => x === piece.color[i])) return false;
|
||||
const before = [...piece.color];
|
||||
piece.color = [...color];
|
||||
for (
|
||||
let v = piece.vertexStart;
|
||||
v < piece.vertexStart + piece.vertexCount;
|
||||
v++
|
||||
)
|
||||
this.dirty.add(v);
|
||||
this.revision++;
|
||||
this.commit({
|
||||
kind: "color",
|
||||
id: piece.id,
|
||||
before,
|
||||
after: [...color],
|
||||
bytes: 24,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
editMask(invert = false) {
|
||||
const piece = this.mesh.pieces.find((p) => p.id === this.activeId);
|
||||
if (!piece) return false;
|
||||
const ids = [],
|
||||
before = [],
|
||||
after = [];
|
||||
for (
|
||||
let v = piece.vertexStart;
|
||||
v < piece.vertexStart + piece.vertexCount;
|
||||
v++
|
||||
) {
|
||||
const n = invert ? 1 - this.mesh.masks[v] : 0;
|
||||
if (n !== this.mesh.masks[v]) {
|
||||
ids.push(v);
|
||||
before.push(this.mesh.masks[v]);
|
||||
after.push(n);
|
||||
this.mesh.masks[v] = n;
|
||||
this.dirty.add(v);
|
||||
}
|
||||
}
|
||||
if (!ids.length) return false;
|
||||
const entry = {
|
||||
kind: "mask",
|
||||
ids: Uint32Array.from(ids),
|
||||
before: Float32Array.from(before),
|
||||
after: Float32Array.from(after),
|
||||
};
|
||||
entry.bytes =
|
||||
entry.ids.byteLength + entry.before.byteLength + entry.after.byteLength;
|
||||
this.revision++;
|
||||
this.commit(entry);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -227,43 +500,25 @@ export class StrokePath {
|
||||
}
|
||||
}
|
||||
|
||||
export function saveProject(mesh) {
|
||||
return JSON.stringify({
|
||||
version: 1,
|
||||
level: mesh.level,
|
||||
positions: Array.from(mesh.positions),
|
||||
});
|
||||
}
|
||||
export function loadProject(text) {
|
||||
if (text.length > 8 * 1024 * 1024)
|
||||
throw new Error("Project exceeds the 8 MiB limit.");
|
||||
const data = JSON.parse(text);
|
||||
if (
|
||||
data?.version !== 1 ||
|
||||
!Number.isInteger(data.level) ||
|
||||
data.level < 4 ||
|
||||
data.level > 6 ||
|
||||
!Array.isArray(data.positions) ||
|
||||
data.positions.length !== (10 * 4 ** data.level + 2) * 3 ||
|
||||
!data.positions.every(
|
||||
(v) => typeof v === "number" && Number.isFinite(v) && Math.abs(v) <= 8,
|
||||
)
|
||||
)
|
||||
throw new Error("Invalid sculpt project.");
|
||||
const mesh = sphere(data.level);
|
||||
mesh.positions.set(data.positions);
|
||||
updateNormals(mesh);
|
||||
return mesh;
|
||||
}
|
||||
export const saveProject = saveDocument;
|
||||
export const loadProject = loadDocument;
|
||||
export function exportOBJ(mesh) {
|
||||
const lines = ["# Yawn Sculpt — local mesh export"];
|
||||
for (let i = 0; i < mesh.positions.length; i += 3)
|
||||
lines.push(
|
||||
`v ${mesh.positions[i]} ${mesh.positions[i + 1]} ${mesh.positions[i + 2]}`,
|
||||
);
|
||||
for (let i = 0; i < mesh.indices.length; i += 3)
|
||||
lines.push(
|
||||
`f ${mesh.indices[i] + 1} ${mesh.indices[i + 1] + 1} ${mesh.indices[i + 2] + 1}`,
|
||||
);
|
||||
const pieces = ensureDocument(mesh).pieces;
|
||||
for (const piece of pieces) {
|
||||
lines.push(`g ${piece.name.replace(/\s+/g, "_")}`);
|
||||
for (
|
||||
let i = piece.indexStart;
|
||||
i < piece.indexStart + piece.indexCount;
|
||||
i += 3
|
||||
)
|
||||
lines.push(
|
||||
`f ${mesh.indices[i] + 1} ${mesh.indices[i + 1] + 1} ${mesh.indices[i + 2] + 1}`,
|
||||
);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user