feat: add surface exclusions panel and functionality

- Introduced a new section in the UI for surface exclusions, allowing users to exclude triangles from displacement using brush and bucket fill tools.
- Implemented brush type switching (single and radius) and radius control for the brush tool.
- Added functionality for bucket fill with a threshold angle to control the fill area.
- Integrated exclusion weights into the displacement algorithm to ensure excluded faces are handled correctly during subdivision.
- Created adjacency and centroid calculations for triangles to support the bucket fill operation.
- Developed overlay geometries for visual feedback on excluded faces and hover previews.
- Enhanced the CSS for the new exclusion tools and their layout in the UI.
This commit is contained in:
CNCKitchen
2026-03-17 14:35:45 +01:00
parent f87b935b9a
commit 1d3e756245
7 changed files with 730 additions and 32 deletions
+181
View File
@@ -0,0 +1,181 @@
/**
* 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: Map<number, 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 and centroids
const faceNormals = new Float32Array(triCount * 3);
const centroids = new Float32Array(triCount * 3);
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;
centroids[i] = (vA.x + vB.x + vC.x) / 3;
centroids[i + 1] = (vA.y + vB.y + vC.y) / 3;
centroids[i + 2] = (vA.z + vB.z + vC.z) / 3;
}
// Build edge → triangle list (two triangles share an edge iff they share two
// vertex positions after quantization-based deduplication).
const edgeMap = new Map();
const makeEdgeKey = (ax, ay, az, bx, by, bz) => {
const ka = quantKey(ax, ay, az);
const kb = quantKey(bx, by, bz);
return ka < kb ? `${ka}|${kb}` : `${kb}|${ka}`;
};
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);
const ekAB = makeEdgeKey(vA.x, vA.y, vA.z, vB.x, vB.y, vB.z);
const ekBC = makeEdgeKey(vB.x, vB.y, vB.z, vC.x, vC.y, vC.z);
const ekCA = makeEdgeKey(vC.x, vC.y, vC.z, vA.x, vA.y, vA.z);
for (const ek of [ekAB, ekBC, ekCA]) {
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
const adjacency = new Map();
for (let t = 0; t < triCount; t++) adjacency.set(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.get(a).push({ neighbor: b, angle: angleDeg });
adjacency.get(b).push({ neighbor: a, angle: angleDeg });
}
return { adjacency, centroids };
}
// ── Bucket fill ───────────────────────────────────────────────────────────────
/**
* BFS flood fill starting from seedTriIdx.
* Spreads across edges whose dihedral angle ≤ thresholdDeg.
*
* @param {number} seedTriIdx
* @param {Map<number, 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];
while (queue.length > 0) {
const cur = queue.shift();
const neighbors = adjacency.get(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 containing only the excluded
* triangles' positions. Used to drive the orange overlay mesh in the viewer.
*
* @param {THREE.BufferGeometry} geometry non-indexed source geometry
* @param {Set<number>} excludedFaces
* @returns {THREE.BufferGeometry}
*/
export function buildExclusionOverlayGeo(geometry, excludedFaces) {
const srcPos = geometry.attributes.position.array;
const outPos = new Float32Array(excludedFaces.size * 9); // 3 verts × 3 floats
let dst = 0;
for (const t of excludedFaces) {
const src = t * 9;
for (let i = 0; i < 9; i++) outPos[dst++] = srcPos[src + i];
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(outPos, 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) {
const count = geometry.attributes.position.count;
const weights = new Float32Array(count); // default 0 (included)
for (const t of excludedFaces) {
weights[t * 3] = 1.0;
weights[t * 3 + 1] = 1.0;
weights[t * 3 + 2] = 1.0;
}
return weights;
}