Files
Avatarsia 689c192a89 fix: spatial index, decimation overflow, input validation, accessibility
Round 2 of performance and correctness improvements:

- Spatial grid index for brush painting: forEachTriInSphere now queries
  only nearby grid cells instead of scanning all triangles. ~5.7x faster
  for brush operations on 68k+ tri meshes.

- Decimation overflow fix: hasLinkViolation used a fixed 0x200000
  multiplier for vertex-pair keys, overflowing at >2M vertices.
  Now uses dynamic multiplier based on actual vertex count.

- Decimation determinant threshold: solveQ used absolute 1e-10 which
  fails for large coordinates. Now relative to matrix element magnitude.

- 3MF triangle index validation: bounds-check all parsed indices against
  vertex count, throw clear error on corrupt files instead of silent NaN.

- File size limit: reject files >500 MB before loading into memory,
  prevents browser tab crash on oversized files.

- Accessibility: preset swatches now keyboard-navigable (role=button,
  tabindex=0, Enter/Space to select). Modal dialogs trap focus and
  close on Escape.

- Ctrl+click straight line tool: click to set start point, Ctrl+click
  to paint a straight line between points. Ctrl+hover shows preview.

- Precision masking available for radius brush mode.

- Spatial grid rebuilt when entering/leaving precision mode.
2026-04-06 05:14:23 +02:00

242 lines
9.0 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* exclusion.js — per-face exclusion masking
*
* Provides three capabilities:
* 1. buildAdjacency builds an inter-triangle adjacency list with dihedral
* angles and precomputes per-triangle centroids.
* 2. bucketFill BFS flood fill that respects a max dihedral-angle
* threshold (stops at "sharp" edges).
* 3. buildExclusionOverlayGeo compact geometry for the orange preview overlay.
* 4. buildFaceWeights per-vertex exclusion weights for the subdivision pass.
*/
import * as THREE from 'three';
const QUANT = 1e4;
const quantKey = (x, y, z) =>
`${Math.round(x * QUANT)}_${Math.round(y * QUANT)}_${Math.round(z * QUANT)}`;
// ── Adjacency & centroids ─────────────────────────────────────────────────────
/**
* Build inter-triangle adjacency data for a non-indexed BufferGeometry.
*
* @param {THREE.BufferGeometry} geometry non-indexed
* @returns {{
* adjacency: Array<Array<{neighbor:number, angle:number}>>,
* centroids: Float32Array (triCount × 3, world-space centroid per triangle)
* }}
*/
export function buildAdjacency(geometry) {
const posAttr = geometry.attributes.position;
const triCount = posAttr.count / 3;
// Pre-allocate face normals, centroids, and per-triangle bounding radii
const faceNormals = new Float32Array(triCount * 3);
const centroids = new Float32Array(triCount * 3);
const boundRadii = new Float32Array(triCount); // max vertex-to-centroid distance
const vA = new THREE.Vector3();
const vB = new THREE.Vector3();
const vC = new THREE.Vector3();
const e1 = new THREE.Vector3();
const e2 = new THREE.Vector3();
const fn = new THREE.Vector3();
for (let t = 0; t < triCount; t++) {
const i = t * 3;
vA.fromBufferAttribute(posAttr, i);
vB.fromBufferAttribute(posAttr, i + 1);
vC.fromBufferAttribute(posAttr, i + 2);
e1.subVectors(vB, vA);
e2.subVectors(vC, vA);
fn.crossVectors(e1, e2).normalize();
faceNormals[i] = fn.x;
faceNormals[i + 1] = fn.y;
faceNormals[i + 2] = fn.z;
const cx = (vA.x + vB.x + vC.x) / 3;
const cy = (vA.y + vB.y + vC.y) / 3;
const cz = (vA.z + vB.z + vC.z) / 3;
centroids[i] = cx;
centroids[i + 1] = cy;
centroids[i + 2] = cz;
const dA = (vA.x-cx)**2 + (vA.y-cy)**2 + (vA.z-cz)**2;
const dB = (vB.x-cx)**2 + (vB.y-cy)**2 + (vB.z-cz)**2;
const dC = (vC.x-cx)**2 + (vC.y-cy)**2 + (vC.z-cz)**2;
boundRadii[t] = Math.sqrt(Math.max(dA, dB, dC));
}
// Build edge → triangle list (two triangles share an edge iff they share two
// vertex positions after quantization-based deduplication).
// Vertex-dedup pass: assign a numeric ID to each unique quantised position.
const posToId = new Map();
let nextId = 0;
const vertId = new Uint32Array(triCount * 3);
for (let i = 0; i < triCount * 3; i++) {
const x = posAttr.getX(i), y = posAttr.getY(i), z = posAttr.getZ(i);
const key = `${Math.round(x*QUANT)}_${Math.round(y*QUANT)}_${Math.round(z*QUANT)}`;
let id = posToId.get(key);
if (id === undefined) { id = nextId++; posToId.set(key, id); }
vertId[i] = id;
}
// nextId^2 < MAX_SAFE_INTEGER → safe up to ~94M unique vertices
const numEdgeKey = (a, b) => a < b ? a * nextId + b : b * nextId + a;
const edgeMap = new Map();
const edgePairs = [0, 1, 0, 2, 1, 2]; // vertex-index pairs within triangle
for (let t = 0; t < triCount; t++) {
const base = t * 3;
for (let e = 0; e < 6; e += 2) {
const ek = numEdgeKey(vertId[base + edgePairs[e]], vertId[base + edgePairs[e + 1]]);
const entry = edgeMap.get(ek);
if (entry) entry.push(t);
else edgeMap.set(ek, [t]);
}
}
// Convert edge map to adjacency list with per-edge dihedral angle
// Array from buildAdjacency
const adjacency = new Array(triCount);
for (let t = 0; t < triCount; t++) adjacency[t] = [];
for (const [, tris] of edgeMap) {
if (tris.length !== 2) continue;
const [a, b] = tris;
const nAx = faceNormals[a * 3], nAy = faceNormals[a * 3 + 1], nAz = faceNormals[a * 3 + 2];
const nBx = faceNormals[b * 3], nBy = faceNormals[b * 3 + 1], nBz = faceNormals[b * 3 + 2];
const dot = Math.max(-1, Math.min(1, nAx * nBx + nAy * nBy + nAz * nBz));
const angleDeg = Math.acos(dot) * (180 / Math.PI);
adjacency[a].push({ neighbor: b, angle: angleDeg });
adjacency[b].push({ neighbor: a, angle: angleDeg });
}
return { adjacency, centroids, boundRadii };
}
// ── Bucket fill ───────────────────────────────────────────────────────────────
/**
* BFS flood fill starting from seedTriIdx.
* Spreads across edges whose dihedral angle ≤ thresholdDeg.
*
* @param {number} seedTriIdx
* @param {Array<Array<{neighbor:number, angle:number}>>} adjacency
* @param {number} thresholdDeg
* @returns {Set<number>} set of triangle indices in the filled region
*/
export function bucketFill(seedTriIdx, adjacency, thresholdDeg) {
const visited = new Set([seedTriIdx]);
const queue = [seedTriIdx];
let head = 0;
while (head < queue.length) {
const cur = queue[head++];
const neighbors = adjacency[cur];
if (!neighbors) continue;
for (const { neighbor, angle } of neighbors) {
if (!visited.has(neighbor) && angle <= thresholdDeg) {
visited.add(neighbor);
queue.push(neighbor);
}
}
}
return visited;
}
// ── Overlay geometry ──────────────────────────────────────────────────────────
/**
* Build a compact non-indexed BufferGeometry for an overlay.
*
* @param {THREE.BufferGeometry} geometry non-indexed source geometry
* @param {Set<number>} faceSet
* @param {boolean} [invert=false] when true, include faces NOT in faceSet
* @returns {THREE.BufferGeometry}
*/
export function buildExclusionOverlayGeo(geometry, faceSet, invert = false) {
const srcPos = geometry.attributes.position.array;
const srcNrm = geometry.attributes.normal ? geometry.attributes.normal.array : null;
const total = srcPos.length / 9; // total triangle count
const isArr = faceSet instanceof Uint8Array;
// Count included faces
let setSize;
if (isArr) {
setSize = 0;
for (let i = 0; i < faceSet.length; i++) if (faceSet[i]) setSize++;
} else {
setSize = faceSet.size;
}
const count = invert ? total - setSize : setSize;
const outPos = new Float32Array(count * 9);
const outNrm = srcNrm ? new Float32Array(count * 9) : null;
let dst = 0;
if (invert) {
for (let t = 0; t < total; t++) {
if (isArr ? faceSet[t] : faceSet.has(t)) continue;
const src = t * 9;
outPos.set(srcPos.subarray(src, src + 9), dst);
if (outNrm) outNrm.set(srcNrm.subarray(src, src + 9), dst);
dst += 9;
}
} else {
if (isArr) {
for (let t = 0; t < faceSet.length; t++) {
if (!faceSet[t]) continue;
const src = t * 9;
outPos.set(srcPos.subarray(src, src + 9), dst);
if (outNrm) outNrm.set(srcNrm.subarray(src, src + 9), dst);
dst += 9;
}
} else {
for (const t of faceSet) {
const src = t * 9;
outPos.set(srcPos.subarray(src, src + 9), dst);
if (outNrm) outNrm.set(srcNrm.subarray(src, src + 9), dst);
dst += 9;
}
}
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(outPos, 3));
if (outNrm) geo.setAttribute('normal', new THREE.BufferAttribute(outNrm, 3));
return geo;
}
// ── Face-weight array for subdivision ────────────────────────────────────────
/**
* Build a per-non-indexed-vertex exclusion weight array.
* Vertex i (in the non-indexed buffer) belongs to triangle floor(i/3).
* Excluded triangles get weight 1.0, all others 0.0.
* subdivision.js threads these through edge splits via linear interpolation,
* producing smooth 0→1 transitions at exclusion boundaries.
*
* @param {THREE.BufferGeometry} geometry
* @param {Set<number>} excludedFaces
* @returns {Float32Array} length = geometry.attributes.position.count
*/
export function buildFaceWeights(geometry, excludedFaces, invert = false) {
const count = geometry.attributes.position.count;
const weights = new Float32Array(count); // default 0.0 (included)
if (invert) {
// Include-only mode: all faces start excluded (1.0); painted faces are included (0.0)
weights.fill(1.0);
for (const t of excludedFaces) {
weights[t * 3] = 0.0;
weights[t * 3 + 1] = 0.0;
weights[t * 3 + 2] = 0.0;
}
} else {
for (const t of excludedFaces) {
weights[t * 3] = 1.0;
weights[t * 3 + 1] = 1.0;
weights[t * 3 + 2] = 1.0;
}
}
return weights;
}