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>
525 lines
16 KiB
JavaScript
525 lines
16 KiB
JavaScript
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);
|
|
export const defaults = {
|
|
brush: "draw",
|
|
radius: 0.22,
|
|
strength: 0.45,
|
|
gain: 1,
|
|
gamma: 1,
|
|
symmetry: true,
|
|
invert: false,
|
|
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 = 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;
|
|
}
|
|
markAll() {
|
|
for (let v = 0; v < this.mesh.positions.length / 3; v++) this.dirty.add(v);
|
|
this.revision++;
|
|
}
|
|
get historyBytes() {
|
|
return [...this.undoStack, ...this.redoStack].reduce(
|
|
(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,
|
|
};
|
|
}
|
|
changed(ids) {
|
|
if (!ids.length) return;
|
|
const { faces, vertices } = updateNormals(this.mesh, ids);
|
|
this.bvh.refit(faces);
|
|
for (const v of vertices) this.dirty.add(v);
|
|
this.revision++;
|
|
}
|
|
dab(hit, rawPressure = 1, drag = [0, 0, 0]) {
|
|
if (!this.stroke || !hit) return;
|
|
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);
|
|
const p = this.mesh.positions,
|
|
n = this.mesh.normals;
|
|
// Merge reflected influence instead of applying two sequential dabs at the seam.
|
|
let affected = this.stroke.grab;
|
|
if (!affected) {
|
|
affected = new Map();
|
|
for (const mirror of s.symmetry ? [1, -1] : [1]) {
|
|
const center = [
|
|
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;
|
|
if (!affected.has(v)) affected.set(v, []);
|
|
affected.get(v).push({ weight, center, normal, mirror });
|
|
}
|
|
}
|
|
if (s.brush === "grab") this.stroke.grab = affected;
|
|
}
|
|
const moved = [],
|
|
updates = [];
|
|
for (const [v, contributions] of affected) {
|
|
const position = point(p, v),
|
|
displacement = [0, 0, 0];
|
|
let total = 0,
|
|
envelope = 0;
|
|
for (const { weight, center, normal, mirror } of contributions) {
|
|
total += weight;
|
|
envelope = Math.max(envelope, weight);
|
|
let delta;
|
|
if (s.brush === "smooth") {
|
|
const neighbors = this.mesh.neighbors[v];
|
|
delta = [0, 1, 2].map((k) => {
|
|
let sum = 0;
|
|
for (const neighbor of neighbors) sum += p[neighbor * 3 + k];
|
|
return sum / neighbors.length - position[k];
|
|
});
|
|
} else if (s.brush === "flatten") {
|
|
const distance = dot(sub(position, center), normal);
|
|
delta = normal.map((x) => -x * distance);
|
|
} else if (s.brush === "grab") {
|
|
delta = drag.map((x, k) => x * (k === 0 ? mirror : 1));
|
|
} else {
|
|
const direction = s.brush === "inflate" ? point(n, v) : normal;
|
|
delta = direction.map((x) => x * radius * 0.12 * (s.invert ? -1 : 1));
|
|
}
|
|
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 *
|
|
(1 - this.mesh.masks[v])) /
|
|
total,
|
|
);
|
|
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])) {
|
|
moved.push(v);
|
|
updates.push(...next);
|
|
this.stroke.touched.add(v);
|
|
}
|
|
}
|
|
// 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);
|
|
if (s.brush === "mask") {
|
|
if (this.stroke.touched.size) this.revision++;
|
|
} else this.changed(moved);
|
|
}
|
|
end(cancel = false) {
|
|
if (!this.stroke) return;
|
|
const { before, beforeMasks, touched, settings } = this.stroke;
|
|
this.stroke = null;
|
|
const maskStroke = settings.brush === "mask";
|
|
const ids = Uint32Array.from(
|
|
[...touched].filter((v) =>
|
|
maskStroke
|
|
? beforeMasks[v] !== this.mesh.masks[v]
|
|
: [0, 1, 2].some(
|
|
(k) => before[v * 3 + k] !== this.mesh.positions[v * 3 + k],
|
|
),
|
|
),
|
|
);
|
|
if (cancel) {
|
|
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 * width),
|
|
after: new Float32Array(ids.length * width),
|
|
};
|
|
ids.forEach((v, i) => {
|
|
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);
|
|
}
|
|
});
|
|
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[source.length - 1];
|
|
if (!entry) return false;
|
|
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.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;
|
|
}
|
|
}
|
|
|
|
// Screen-space arc-length sampler. Call break() when the ray misses the surface.
|
|
export class StrokePath {
|
|
constructor(spacing, emit) {
|
|
this.spacing = spacing;
|
|
this.emit = emit;
|
|
this.break();
|
|
}
|
|
break() {
|
|
this.generation = (this.generation ?? 0) + 1;
|
|
this.last = null;
|
|
this.remaining = 0;
|
|
}
|
|
push(sample, budget = Infinity) {
|
|
const generation = this.generation;
|
|
if (!this.last) {
|
|
this.last = sample;
|
|
this.remaining = this.spacing;
|
|
this.emit(sample);
|
|
return true;
|
|
}
|
|
const a = this.last,
|
|
dx = sample.x - a.x,
|
|
dy = sample.y - a.y,
|
|
length = Math.hypot(dx, dy);
|
|
let distance = this.remaining;
|
|
let emitted = 0;
|
|
while (distance <= length && length > 0) {
|
|
const t = distance / length;
|
|
const current = {
|
|
x: a.x + dx * t,
|
|
y: a.y + dy * t,
|
|
pressure: a.pressure + (sample.pressure - a.pressure) * t,
|
|
};
|
|
this.emit(current);
|
|
if (this.generation !== generation) return true;
|
|
distance += this.spacing;
|
|
if (++emitted >= budget && distance <= length) {
|
|
this.last = current;
|
|
this.remaining = this.spacing;
|
|
return false;
|
|
}
|
|
}
|
|
this.remaining = distance - length;
|
|
this.last = sample;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
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]}`,
|
|
);
|
|
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");
|
|
}
|