Files
yawn/server/web/assets/sculpt/engine.js
T
Ampandheaust d279ec4123 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>
2026-10-01 07:55:48 +00:00

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");
}