Files
yawn/server/web/assets/sculpt/engine.js
T
Ampandheaust 347b676cf1 Add core-powered web sculpting studio with tablet controls
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>
2026-09-28 08:53:50 +00:00

270 lines
8.6 KiB
JavaScript

import { sphere, updateNormals } from "./mesh.js";
import { BVH } from "./bvh.js";
import { point, sub, dot, clamp } from "./math.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,
};
export class Sculpt {
constructor(mesh = sphere(), historyCap = 16 * 1024 * 1024) {
this.mesh = mesh;
this.bvh = new BVH(mesh);
this.undoStack = [];
this.redoStack = [];
this.historyCap = historyCap;
this.revision = 0;
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.ids.byteLength + e.before.byteLength + e.after.byteLength,
0,
);
}
begin(settings) {
if (this.stroke) throw new Error("A stroke is already active.");
this.stroke = {
settings: { ...defaults, ...settings },
before: this.mesh.positions.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 } = this.stroke;
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 = [
hit.position[0] * 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 (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;
}
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,
);
if (s.symmetry && this.stroke.before[v * 3] === 0) next[0] = 0;
// 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);
this.changed(moved);
}
end(cancel = false) {
if (!this.stroke) return;
const { before, touched } = this.stroke;
this.stroke = null;
const ids = Uint32Array.from(
[...touched].filter((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));
return;
}
if (!ids.length) return;
const entry = {
ids,
before: new Float32Array(ids.length * 3),
after: new Float32Array(ids.length * 3),
};
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);
});
this.redoStack = [];
this.undoStack.push(entry);
while (this.undoStack.length > 50 || this.historyBytes > this.historyCap)
this.undoStack.shift();
}
restore(redo = false) {
if (this.stroke) return false;
const source = redo ? this.redoStack : this.undoStack,
target = redo ? this.undoStack : this.redoStack,
entry = source.pop();
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),
);
target.push(entry);
this.changed(Array.from(entry.ids));
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 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 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}`,
);
return lines.join("\n");
}