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