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>
421 lines
13 KiB
JavaScript
421 lines
13 KiB
JavaScript
import { buildMesh, sphere } from "./mesh.js";
|
|
|
|
export const MAX_VERTICES = 65536;
|
|
export const MAX_TRIANGLES = 131072;
|
|
|
|
function checked(positions, indices, level = 0, masks) {
|
|
if (positions.length / 3 > MAX_VERTICES || indices.length / 3 > MAX_TRIANGLES)
|
|
throw new Error("Mesh exceeds topology limits.");
|
|
return buildMesh(positions, indices, level, masks);
|
|
}
|
|
|
|
function gridSurface(slices, stacks, point) {
|
|
const p = [],
|
|
i = [];
|
|
for (let y = 0; y < stacks; y++)
|
|
for (let x = 0; x < slices; x++) p.push(...point(x / slices, y / stacks));
|
|
const at = (x, y) => y * slices + ((x + slices) % slices);
|
|
for (let y = 0; y < stacks; y++)
|
|
for (let x = 0; x < slices; x++) {
|
|
const a = at(x, y),
|
|
b = at(x + 1, y),
|
|
c = at(x + 1, (y + 1) % stacks),
|
|
d = at(x, (y + 1) % stacks);
|
|
i.push(a, b, c, a, c, d);
|
|
}
|
|
return [p, i];
|
|
}
|
|
|
|
function cubeMesh(n) {
|
|
const p = [],
|
|
faces = [],
|
|
map = new Map();
|
|
const vertex = (v) => {
|
|
const key = v.join(",");
|
|
if (!map.has(key)) {
|
|
map.set(key, p.length / 3);
|
|
p.push(...v.map((x) => x / n));
|
|
}
|
|
return map.get(key);
|
|
};
|
|
const sides = [
|
|
[
|
|
[n, -n, -n],
|
|
[0, 2 * n, 0],
|
|
[0, 0, 2 * n],
|
|
],
|
|
[
|
|
[-n, -n, n],
|
|
[0, 2 * n, 0],
|
|
[0, 0, -2 * n],
|
|
],
|
|
[
|
|
[-n, n, -n],
|
|
[0, 0, 2 * n],
|
|
[2 * n, 0, 0],
|
|
],
|
|
[
|
|
[-n, -n, n],
|
|
[0, 0, -2 * n],
|
|
[2 * n, 0, 0],
|
|
],
|
|
[
|
|
[-n, -n, -n],
|
|
[0, 2 * n, 0],
|
|
[2 * n, 0, 0],
|
|
],
|
|
[
|
|
[n, -n, n],
|
|
[0, 2 * n, 0],
|
|
[-2 * n, 0, 0],
|
|
],
|
|
];
|
|
for (const [o, u, v] of sides)
|
|
for (let y = 0; y < n; y++)
|
|
for (let x = 0; x < n; x++) {
|
|
const q = (xx, yy) =>
|
|
vertex(o.map((z, k) => z + (u[k] * xx) / n + (v[k] * yy) / n));
|
|
const a = q(x, y),
|
|
b = q(x + 1, y),
|
|
c = q(x + 1, y + 1),
|
|
d = q(x, y + 1);
|
|
faces.push(a, b, c, a, c, d);
|
|
}
|
|
return [p, faces];
|
|
}
|
|
|
|
function radial(kind, slices, rings) {
|
|
const p = [],
|
|
idx = [];
|
|
const cone = kind === "cone";
|
|
for (let y = 0; y <= (cone ? rings - 1 : rings); y++) {
|
|
const t = y / rings,
|
|
r = cone ? 1 - t : 1,
|
|
z = -1 + 2 * t;
|
|
for (let x = 0; x < slices; x++) {
|
|
const a = (2 * Math.PI * x) / slices;
|
|
p.push(r * Math.cos(a), r * Math.sin(a), z);
|
|
}
|
|
}
|
|
for (let y = 0; y < rings - (cone ? 1 : 0); y++)
|
|
for (let x = 0; x < slices; x++) {
|
|
const a = y * slices + x,
|
|
b = y * slices + ((x + 1) % slices),
|
|
c = (y + 1) * slices + ((x + 1) % slices),
|
|
d = (y + 1) * slices + x;
|
|
idx.push(a, b, c, a, c, d);
|
|
}
|
|
if (cone) {
|
|
const apex = p.length / 3,
|
|
o = (rings - 1) * slices;
|
|
p.push(0, 0, 1);
|
|
for (let x = 0; x < slices; x++)
|
|
idx.push(o + x, o + ((x + 1) % slices), apex);
|
|
}
|
|
const bottom = p.length / 3;
|
|
p.push(0, 0, -1);
|
|
for (let x = 0; x < slices; x++) idx.push(bottom, (x + 1) % slices, x);
|
|
if (!cone) {
|
|
const top = p.length / 3;
|
|
p.push(0, 0, 1);
|
|
const o = rings * slices;
|
|
for (let x = 0; x < slices; x++)
|
|
idx.push(top, o + x, o + ((x + 1) % slices));
|
|
}
|
|
return [p, idx];
|
|
}
|
|
|
|
export function preset(name, level = 4) {
|
|
if (!Number.isInteger(level) || level < 1 || level > 6)
|
|
throw new Error("Choose a supported resolution.");
|
|
if (name === "sphere") return sphere(level);
|
|
let data;
|
|
const scale = 2 ** level;
|
|
if (name === "cube") data = cubeMesh(scale);
|
|
else if (name === "cylinder" || name === "cone")
|
|
data = radial(name, scale * 4, scale + 8);
|
|
else if (name === "torus")
|
|
data = gridSurface(scale * 4, scale * 2, (u, v) => {
|
|
const a = 2 * Math.PI * u,
|
|
b = 2 * Math.PI * v,
|
|
r = 0.32,
|
|
R = 0.68;
|
|
return [
|
|
(R + r * Math.cos(b)) * Math.cos(a),
|
|
(R + r * Math.cos(b)) * Math.sin(a),
|
|
r * Math.sin(b),
|
|
];
|
|
});
|
|
else throw new Error(`Unknown topology preset: ${name}`);
|
|
return checked(data[0], data[1], level);
|
|
}
|
|
|
|
export function subdivide(mesh) {
|
|
const tri = mesh.indices.length / 3,
|
|
vertices = mesh.positions.length / 3;
|
|
const edges = new Map();
|
|
for (let k = 0; k < mesh.indices.length; k += 3)
|
|
for (const [a, b] of [
|
|
[mesh.indices[k], mesh.indices[k + 1]],
|
|
[mesh.indices[k + 1], mesh.indices[k + 2]],
|
|
[mesh.indices[k + 2], mesh.indices[k]],
|
|
])
|
|
edges.set(a < b ? `${a},${b}` : `${b},${a}`, 0);
|
|
if (vertices + edges.size > MAX_VERTICES || tri * 4 > MAX_TRIANGLES)
|
|
throw new Error("Subdivision exceeds topology limits.");
|
|
const p = Array.from(mesh.positions),
|
|
masks = Array.from(mesh.masks || new Float32Array(vertices));
|
|
for (const key of edges.keys()) {
|
|
const [a, b] = key.split(",").map(Number),
|
|
v = p.length / 3;
|
|
edges.set(key, v);
|
|
for (let j = 0; j < 3; j++) p.push((p[a * 3 + j] + p[b * 3 + j]) / 2);
|
|
masks.push((masks[a] + masks[b]) / 2);
|
|
}
|
|
const mid = (a, b) => edges.get(a < b ? `${a},${b}` : `${b},${a}`),
|
|
out = [];
|
|
for (let k = 0; k < mesh.indices.length; k += 3) {
|
|
const a = mesh.indices[k],
|
|
b = mesh.indices[k + 1],
|
|
c = mesh.indices[k + 2],
|
|
ab = mid(a, b),
|
|
bc = mid(b, c),
|
|
ca = mid(c, a);
|
|
out.push(a, ab, ca, b, bc, ab, c, ca, bc, ab, bc, ca);
|
|
}
|
|
return checked(p, out, mesh.level + 1, masks);
|
|
}
|
|
|
|
function topology(indices, count) {
|
|
const neighbors = Array.from({ length: count }, () => new Set()),
|
|
incident = Array.from({ length: count }, () => []),
|
|
edgeFaces = new Map(),
|
|
faces = new Set();
|
|
let valid = true;
|
|
for (let k = 0; k < indices.length; k += 3) {
|
|
const face = [indices[k], indices[k + 1], indices[k + 2]],
|
|
faceKey = [...face].sort((a, b) => a - b).join(",");
|
|
if (new Set(face).size !== 3 || faces.has(faceKey)) valid = false;
|
|
faces.add(faceKey);
|
|
for (const v of face) incident[v].push(k / 3);
|
|
for (const [a, b] of [
|
|
[indices[k], indices[k + 1]],
|
|
[indices[k + 1], indices[k + 2]],
|
|
[indices[k + 2], indices[k]],
|
|
]) {
|
|
neighbors[a].add(b);
|
|
neighbors[b].add(a);
|
|
const key = a < b ? `${a},${b}` : `${b},${a}`;
|
|
const list = edgeFaces.get(key) || [];
|
|
list.push(k / 3);
|
|
edgeFaces.set(key, list);
|
|
if (list.length > 2) valid = false;
|
|
}
|
|
}
|
|
|
|
// A manifold vertex link is one cycle (interior) or one path (boundary).
|
|
// Edge incidence alone does not detect two components touching at a vertex.
|
|
for (let v = 0; valid && v < count; v++) {
|
|
if (!incident[v].length) continue;
|
|
const link = new Map();
|
|
for (const f of incident[v]) {
|
|
const other = indices.slice(f * 3, f * 3 + 3).filter((x) => x !== v);
|
|
if (other.length !== 2) {
|
|
valid = false;
|
|
break;
|
|
}
|
|
if (!link.has(other[0])) link.set(other[0], new Set());
|
|
if (!link.has(other[1])) link.set(other[1], new Set());
|
|
link.get(other[0]).add(other[1]);
|
|
link.get(other[1]).add(other[0]);
|
|
}
|
|
if (!valid) break;
|
|
const boundary = [...neighbors[v]].filter((n) => {
|
|
const key = v < n ? `${v},${n}` : `${n},${v}`;
|
|
return edgeFaces.get(key).length === 1;
|
|
});
|
|
const degrees = [...link.values()].map((x) => x.size);
|
|
if (
|
|
degrees.some((d) => d < 1 || d > 2) ||
|
|
(boundary.length === 0
|
|
? degrees.some((d) => d !== 2)
|
|
: boundary.length !== 2 || degrees.filter((d) => d === 1).length !== 2)
|
|
) {
|
|
valid = false;
|
|
break;
|
|
}
|
|
const reached = new Set(),
|
|
pending = [link.keys().next().value];
|
|
while (pending.length) {
|
|
const x = pending.pop();
|
|
if (reached.has(x)) continue;
|
|
reached.add(x);
|
|
for (const n of link.get(x)) pending.push(n);
|
|
}
|
|
if (reached.size !== link.size) valid = false;
|
|
}
|
|
return { neighbors, incident, edgeFaces, valid };
|
|
}
|
|
|
|
export function decimate(mesh, ratio = 0.5) {
|
|
if (!Number.isFinite(ratio) || ratio <= 0 || ratio > 1)
|
|
throw new Error("Ratio must be in (0, 1].");
|
|
let p = Array.from(mesh.positions),
|
|
idx = Array.from(mesh.indices),
|
|
masks = Array.from(mesh.masks || new Float32Array(p.length / 3));
|
|
const target = Math.max(4, Math.floor((idx.length / 3) * ratio)),
|
|
deadline = Date.now() + 2500;
|
|
let accepted = 0,
|
|
work = 0;
|
|
const MAX_PASSES = 64,
|
|
MAX_WORK = 1000000;
|
|
for (
|
|
let pass = 0;
|
|
pass < MAX_PASSES && idx.length / 3 > target && Date.now() < deadline;
|
|
pass++
|
|
) {
|
|
const { neighbors, incident, edgeFaces, valid } = topology(
|
|
idx,
|
|
p.length / 3,
|
|
),
|
|
protectedVertices = new Set();
|
|
if (!valid) break;
|
|
for (const [key, faces] of edgeFaces)
|
|
if (faces.length === 1)
|
|
for (const v of key.split(",").map(Number)) protectedVertices.add(v);
|
|
masks.forEach((m, v) => {
|
|
if (m !== 0) {
|
|
protectedVertices.add(v);
|
|
for (const n of neighbors[v]) protectedVertices.add(n);
|
|
}
|
|
});
|
|
const candidates = [];
|
|
for (const [key, edgeIncident] of edgeFaces) {
|
|
if (Date.now() >= deadline || work++ >= MAX_WORK) break;
|
|
if (edgeIncident.length === 2) {
|
|
const [a, b] = key.split(",").map(Number);
|
|
if (protectedVertices.has(a) || protectedVertices.has(b)) continue;
|
|
const opposite = edgeIncident.map((f) =>
|
|
idx.slice(f * 3, f * 3 + 3).find((v) => v !== a && v !== b),
|
|
),
|
|
common = [...neighbors[a]].filter((v) => neighbors[b].has(v));
|
|
if (
|
|
new Set(opposite).size !== 2 ||
|
|
common.length !== 2 ||
|
|
common.some((v) => !opposite.includes(v))
|
|
)
|
|
continue;
|
|
let d = 0;
|
|
for (let j = 0; j < 3; j++) d += (p[a * 3 + j] - p[b * 3 + j]) ** 2;
|
|
candidates.push([d, a, b]);
|
|
}
|
|
}
|
|
candidates.sort((a, b) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2]);
|
|
const replace = new Map(),
|
|
locked = new Set();
|
|
let removed = 0;
|
|
for (const [, a, b] of candidates) {
|
|
if (Date.now() >= deadline || work++ >= MAX_WORK) break;
|
|
if (idx.length / 3 - removed * 2 <= target) break;
|
|
if (locked.has(a) || locked.has(b)) continue;
|
|
const pos = [0, 1, 2].map((j) =>
|
|
Math.fround((p[a * 3 + j] + p[b * 3 + j]) / 2),
|
|
),
|
|
localFaces = [...new Set([...incident[a], ...incident[b]])],
|
|
edgeKey = a < b ? `${a},${b}` : `${b},${a}`,
|
|
omitted = new Set(edgeFaces.get(edgeKey)),
|
|
seen = new Set();
|
|
let candidateValid = true;
|
|
for (const f of localFaces) {
|
|
if (Date.now() >= deadline || work++ >= MAX_WORK) {
|
|
candidateValid = false;
|
|
break;
|
|
}
|
|
const k = f * 3;
|
|
if (omitted.has(f)) continue;
|
|
const vs = [idx[k], idx[k + 1], idx[k + 2]].map((v) =>
|
|
v === b ? a : v,
|
|
);
|
|
const triangleKey = [...vs].sort((x, y) => x - y).join(",");
|
|
if (new Set(vs).size < 3 || seen.has(triangleKey)) {
|
|
candidateValid = false;
|
|
break;
|
|
}
|
|
seen.add(triangleKey);
|
|
const q = vs.map((v) => (v === a ? pos : p.slice(v * 3, v * 3 + 3)));
|
|
const ux = q[1][0] - q[0][0],
|
|
uy = q[1][1] - q[0][1],
|
|
uz = q[1][2] - q[0][2],
|
|
vx = q[2][0] - q[0][0],
|
|
vy = q[2][1] - q[0][1],
|
|
vz = q[2][2] - q[0][2];
|
|
const nx = uy * vz - uz * vy,
|
|
ny = uz * vx - ux * vz,
|
|
nz = ux * vy - uy * vx;
|
|
const ox = meshLikeNormal(p, idx, k);
|
|
if (
|
|
nx * nx + ny * ny + nz * nz < 1e-16 ||
|
|
nx * ox[0] + ny * ox[1] + nz * ox[2] <= 0
|
|
) {
|
|
candidateValid = false;
|
|
break;
|
|
}
|
|
}
|
|
if (!candidateValid) continue;
|
|
replace.set(b, a);
|
|
p.splice(a * 3, 3, ...pos);
|
|
locked.add(a);
|
|
locked.add(b);
|
|
for (const n of neighbors[a]) locked.add(n);
|
|
for (const n of neighbors[b]) locked.add(n);
|
|
removed++;
|
|
accepted++;
|
|
}
|
|
if (!replace.size) break;
|
|
idx = idx.map((v) => replace.get(v) ?? v);
|
|
const seen = new Set();
|
|
idx = idx.reduce((o, v, k) => {
|
|
if (k % 3) return o;
|
|
const f = idx.slice(k, k + 3);
|
|
if (new Set(f).size < 3) return o;
|
|
const key = [...f].sort((a, b) => a - b).join(",");
|
|
// Candidate validation guarantees this. Never use duplicate removal to
|
|
// repair a collapse which violated the link condition.
|
|
if (seen.has(key))
|
|
throw new Error("Decimation produced duplicate faces.");
|
|
seen.add(key);
|
|
o.push(...f);
|
|
return o;
|
|
}, []);
|
|
}
|
|
if (!accepted)
|
|
return checked(mesh.positions, mesh.indices, mesh.level, mesh.masks);
|
|
const used = [...new Set(idx)].sort((a, b) => a - b),
|
|
remap = new Map(used.map((v, i) => [v, i])),
|
|
outp = [],
|
|
outm = [];
|
|
for (const v of used) {
|
|
outp.push(...p.slice(v * 3, v * 3 + 3));
|
|
outm.push(masks[v]);
|
|
}
|
|
return checked(
|
|
outp,
|
|
idx.map((v) => remap.get(v)),
|
|
mesh.level,
|
|
outm,
|
|
);
|
|
}
|
|
|
|
function meshLikeNormal(p, idx, k) {
|
|
const a = idx[k],
|
|
b = idx[k + 1],
|
|
c = idx[k + 2],
|
|
ux = p[b * 3] - p[a * 3],
|
|
uy = p[b * 3 + 1] - p[a * 3 + 1],
|
|
uz = p[b * 3 + 2] - p[a * 3 + 2],
|
|
vx = p[c * 3] - p[a * 3],
|
|
vy = p[c * 3 + 1] - p[a * 3 + 1],
|
|
vz = p[c * 3 + 2] - p[a * 3 + 2];
|
|
return [uy * vz - uz * vy, uz * vx - ux * vz, ux * vy - uy * vx];
|
|
}
|