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>
270 lines
8.6 KiB
JavaScript
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");
|
|
}
|