feat: add brush cursor for exclusion tool and enhance cursor behavior

This commit is contained in:
CNCKitchen
2026-03-17 20:57:27 +01:00
parent 598902021a
commit 2eb52a0bf2
4 changed files with 168 additions and 8 deletions
+1
View File
@@ -41,6 +41,7 @@
<input type="file" id="stl-file-input" accept=".stl" hidden />
</div>
<canvas id="viewport"></canvas>
<div id="brush-cursor"></div>
</div>
<div id="viewport-footer">
<span id="mesh-info" class="mesh-info"></span>
+103 -7
View File
@@ -18,6 +18,12 @@
* Recompute every affected face normal after the hypothetical collapse.
* dot(original, new) < 0.2 (~78°) → reject. Eliminates spikes / pits.
*
* Crease preservation (Garland & Heckbert §3.2):
* Edges where adjacent face normals diverge by more than CREASE_COS receive
* high-weight penalty planes added to both endpoint quadrics. This raises
* the QEM cost of any collapse that would move a vertex off a sharp feature,
* ensuring smooth regions are decimated first while creases are kept intact.
*
* @param {THREE.BufferGeometry} geometry non-indexed input
* @param {number} targetTriangles desired output face count
* @param {function} [onProgress] callback(01)
@@ -26,8 +32,10 @@
import * as THREE from 'three';
const QUANT = 1e4;
const FLIP_DOT = 0.2; // cos ~78° — reject collapse if new normal deviates more
const QUANT = 1e4;
const FLIP_DOT = 0.2; // cos ~78° — reject collapse if new normal deviates more
const CREASE_COS = 0.5; // cos 60° — edges sharper than this are treated as creases
const CREASE_WEIGHT = 1e4; // quadric penalty weight for crease edges
// ── Public API ───────────────────────────────────────────────────────────────
@@ -39,10 +47,17 @@ export function decimate(geometry, targetTriangles, onProgress) {
// Per-vertex error quadrics (10 doubles = upper triangle of symmetric 4×4)
const quadrics = new Float64Array(vertCount * 10);
initQuadrics(quadrics, positions, faces, faceCount);
addCreaseQuadrics(quadrics, positions, faces, faceCount);
// Vertex → set of incident face indices
const vertFaces = buildAdjacency(faces, faceCount, vertCount);
const active = new Uint8Array(vertCount).fill(1);
// Per-vertex version counter: incremented whenever a vertex's quadric or
// position changes. Heap entries carry the versions at push time; any
// entry whose versions no longer match is stale and is skipped. This
// prevents old low-cost entries (computed before a crease-quadric merge)
// from firing after the vertex has been updated to a higher-cost state.
const version = new Uint32Array(vertCount);
let activeFaces = faceCount;
// Seed min-heap with one entry per unique edge
@@ -54,7 +69,7 @@ export function decimate(geometry, targetTriangles, onProgress) {
const v1 = faces[f * 3 + e];
const v2 = faces[f * 3 + ((e + 1) % 3)];
const ek = v1 < v2 ? `${v1}:${v2}` : `${v2}:${v1}`;
if (!seedSeen.has(ek)) { seedSeen.add(ek); pushEdge(heap, quadrics, positions, v1, v2); }
if (!seedSeen.has(ek)) { seedSeen.add(ek); pushEdge(heap, quadrics, positions, version, v1, v2); }
}
}
seedSeen.clear();
@@ -63,10 +78,13 @@ export function decimate(geometry, targetTriangles, onProgress) {
let lastProg = 0;
while (activeFaces > targetTriangles && heap.size() > 0) {
const { v1, v2, px, py, pz } = heap.pop();
const { v1, v2, ver1, ver2, px, py, pz } = heap.pop();
// Stale-entry checks (lazy deletion)
if (!active[v1] || !active[v2]) continue;
// Version check: reject if either vertex's quadric/position has changed
// since this entry was pushed (catches outdated pre-merge low-cost entries)
if (version[v1] !== ver1 || version[v2] !== ver2) continue;
if (!shareActiveFace(faces, vertFaces, v1, v2)) continue;
// ── Three safety guards ───────────────────────────────────────────────────
@@ -81,6 +99,7 @@ export function decimate(geometry, targetTriangles, onProgress) {
positions[v1 * 3 + 1] = py;
positions[v1 * 3 + 2] = pz;
mergeQuadric(quadrics, v1, v2);
version[v1]++; // v1's quadric and position changed — invalidate old heap entries
for (const f of vertFaces[v2]) {
if (faces[f * 3] < 0) continue;
@@ -111,7 +130,7 @@ export function decimate(geometry, targetTriangles, onProgress) {
}
}
for (const nb of neighbors) {
if (active[nb]) pushEdge(heap, quadrics, positions, v1, nb);
if (active[nb]) pushEdge(heap, quadrics, positions, version, v1, nb);
}
if (onProgress) {
@@ -221,6 +240,82 @@ function faceNormal(ax, ay, az, bx, by, bz, cx, cy, cz) {
// ── Quadric helpers ──────────────────────────────────────────────────────────
// Symmetric 4×4 quadric stored as 10 upper-triangle values per vertex.
// ── Crease-edge quadric preservation (Garland & Heckbert §3.2) ─────────────
// For each interior edge whose two adjacent faces form a dihedral angle sharper
// than CREASE_COS, inject two penalty planes into both endpoint vertices.
// Each penalty plane is perpendicular to one adjacent face and passes through
// the crease edge, constraining the vertex to stay on the crease line.
// The high CREASE_WEIGHT ensures these edges have far higher QEM cost than
// smooth-surface edges and are therefore collapsed last (or not at all).
function addCreaseQuadrics(quadrics, positions, faces, faceCount) {
// Build edge → [face, face] map
const edgeToFaces = new Map();
for (let f = 0; f < faceCount; f++) {
if (faces[f * 3] < 0) continue;
for (let e = 0; e < 3; e++) {
const va = faces[f * 3 + e];
const vb = faces[f * 3 + ((e + 1) % 3)];
const key = va < vb ? `${va}:${vb}` : `${vb}:${va}`;
let arr = edgeToFaces.get(key);
if (!arr) { arr = []; edgeToFaces.set(key, arr); }
arr.push(f);
}
}
const sqrtW = Math.sqrt(CREASE_WEIGHT);
for (const [key, flist] of edgeToFaces) {
if (flist.length !== 2) continue; // open boundary or non-manifold — skip
const f0 = flist[0], f1 = flist[1];
const v0a = faces[f0*3], v0b = faces[f0*3+1], v0c = faces[f0*3+2];
const v1a = faces[f1*3], v1b = faces[f1*3+1], v1c = faces[f1*3+2];
const [n0x, n0y, n0z] = faceNormal(
positions[v0a*3], positions[v0a*3+1], positions[v0a*3+2],
positions[v0b*3], positions[v0b*3+1], positions[v0b*3+2],
positions[v0c*3], positions[v0c*3+1], positions[v0c*3+2]
);
const [n1x, n1y, n1z] = faceNormal(
positions[v1a*3], positions[v1a*3+1], positions[v1a*3+2],
positions[v1b*3], positions[v1b*3+1], positions[v1b*3+2],
positions[v1c*3], positions[v1c*3+1], positions[v1c*3+2]
);
if (n0x*n1x + n0y*n1y + n0z*n1z >= CREASE_COS) continue; // smooth — skip
// Resolve the two vertex indices from the key string
const colon = key.indexOf(':');
const va = parseInt(key.slice(0, colon));
const vb = parseInt(key.slice(colon + 1));
// Normalised edge direction
const ex = positions[vb*3] - positions[va*3];
const ey = positions[vb*3+1] - positions[va*3+1];
const ez = positions[vb*3+2] - positions[va*3+2];
const elen = Math.sqrt(ex*ex + ey*ey + ez*ez) || 1;
const edx = ex / elen, edy = ey / elen, edz = ez / elen;
// Add one penalty plane per adjacent face-normal
for (const [nx, ny, nz] of [[n0x, n0y, n0z], [n1x, n1y, n1z]]) {
// Penalty plane normal = normalize(face_normal × edge_dir)
// This plane contains the edge and is perpendicular to the face,
// so it constrains the vertex to lie on the crease line.
let px = ny*edz - nz*edy;
let py = nz*edx - nx*edz;
let pz = nx*edy - ny*edx;
const plen = Math.sqrt(px*px + py*py + pz*pz);
if (plen < 1e-10) continue; // edge parallel to face normal — degenerate
px /= plen; py /= plen; pz /= plen;
const d = -(px*positions[va*3] + py*positions[va*3+1] + pz*positions[va*3+2]);
// Scale by sqrtW: addPlaneQ accumulates (a²,ab,…) so scaling inputs by √w yields w×(a²,ab,…)
addPlaneQ(quadrics, va, px*sqrtW, py*sqrtW, pz*sqrtW, d*sqrtW);
addPlaneQ(quadrics, vb, px*sqrtW, py*sqrtW, pz*sqrtW, d*sqrtW);
}
}
}
function initQuadrics(quadrics, positions, faces, faceCount) {
for (let f = 0; f < faceCount; f++) {
if (faces[f * 3] < 0) continue;
@@ -286,7 +381,7 @@ function solveQ(q, v1, v2) {
return true;
}
function pushEdge(heap, quadrics, positions, v1, v2) {
function pushEdge(heap, quadrics, positions, version, v1, v2) {
let px, py, pz;
if (solveQ(quadrics, v1, v2)) {
@@ -304,7 +399,8 @@ function pushEdge(heap, quadrics, positions, v1, v2) {
}
const cost = evalQSum(quadrics, v1, v2, px, py, pz);
heap.push({ cost, v1, v2, px, py, pz });
// Snapshot both vertices' versions so the pop-side check can detect staleness
heap.push({ cost, v1, v2, ver1: version[v1], ver2: version[v2], px, py, pz });
}
// ── Indexed <-> Non-indexed conversion ──────────────────────────────────────
+53 -1
View File
@@ -52,6 +52,7 @@ const settings = {
// ── DOM refs ──────────────────────────────────────────────────────────────────
const canvas = document.getElementById('viewport');
const brushCursorEl = document.getElementById('brush-cursor');
const dropZone = document.getElementById('drop-zone');
const dropHint = document.getElementById('drop-hint');
const stlFileInput = document.getElementById('stl-file-input');
@@ -268,6 +269,8 @@ function wireEvents() {
exclBrushSingleBtn.classList.add('active');
exclBrushRadiusBtn.classList.remove('active');
exclRadiusRow.classList.add('hidden');
canvas.style.cursor = exclusionTool ? 'crosshair' : '';
brushCursorEl.style.display = 'none';
});
exclBrushRadiusBtn.addEventListener('click', () => {
@@ -275,6 +278,7 @@ function wireEvents() {
exclBrushRadiusBtn.classList.add('active');
exclBrushSingleBtn.classList.remove('active');
if (exclusionTool === 'brush') exclRadiusRow.classList.remove('hidden');
if (exclusionTool === 'brush') canvas.style.cursor = 'none';
});
exclBrushRadiusSlider.addEventListener('input', () => {
@@ -334,6 +338,9 @@ function wireEvents() {
});
canvas.addEventListener('mousemove', (e) => {
if (exclusionTool === 'brush' && brushIsRadius) {
updateBrushCursor(e);
}
if (isPainting && exclusionTool === 'brush') {
paintAt(e);
return;
@@ -346,6 +353,7 @@ function wireEvents() {
canvas.addEventListener('mouseleave', () => {
_lastHoverTriIdx = -1;
setHoverPreview(null);
brushCursorEl.style.display = 'none';
});
document.addEventListener('mouseup', () => {
@@ -390,10 +398,14 @@ function setExclusionTool(tool) {
exclRadiusRow.classList.toggle('hidden', !(exclusionTool === 'brush' && brushIsRadius));
// Show threshold row only while bucket is active
exclThresholdRow.classList.toggle('hidden', exclusionTool !== 'bucket');
canvas.style.cursor = exclusionTool ? 'crosshair' : '';
canvas.style.cursor = (exclusionTool === 'brush' && brushIsRadius) ? 'none' : exclusionTool ? 'crosshair' : '';
// Clear hover preview whenever the tool changes or is deactivated
_lastHoverTriIdx = -1;
setHoverPreview(null);
// Hide brush cursor if tool deactivated or switched away from radius brush
if (!(exclusionTool === 'brush' && brushIsRadius)) {
brushCursorEl.style.display = 'none';
}
// Re-enable controls if tool was deactivated mid-paint
if (!exclusionTool) {
isPainting = false;
@@ -461,6 +473,46 @@ function refreshExclusionOverlay() {
: `${n.toLocaleString()} face${n === 1 ? '' : 's'} excluded`;
}
function updateBrushCursor(e) {
if (!brushIsRadius || !currentGeometry) {
brushCursorEl.style.display = 'none';
return;
}
const mesh = getCurrentMesh();
if (!mesh) { brushCursorEl.style.display = 'none'; return; }
_raycaster.setFromCamera(_canvasNDC(e), getCamera());
const hits = _raycaster.intersectObject(mesh);
if (hits.length === 0) { brushCursorEl.style.display = 'none'; return; }
const hitPt = hits[0].point;
const cam = getCamera();
// Offset the hit point by brushRadius along the camera's right axis
// then project both to screen space to get pixel-accurate circle size
const camRight = new THREE.Vector3().setFromMatrixColumn(cam.matrixWorld, 0).normalize();
const edgePt = hitPt.clone().addScaledVector(camRight, brushRadius);
const rect = canvas.getBoundingClientRect();
const toScreen = (v) => {
const c = v.clone().project(cam);
return {
x: (c.x * 0.5 + 0.5) * rect.width,
y: (1 - (c.y * 0.5 + 0.5)) * rect.height,
};
};
const sc = toScreen(hitPt);
const se = toScreen(edgePt);
const screenRadius = Math.sqrt((se.x - sc.x) ** 2 + (se.y - sc.y) ** 2);
const diam = screenRadius * 2;
brushCursorEl.style.display = 'block';
brushCursorEl.style.left = `${rect.left + sc.x - screenRadius}px`;
brushCursorEl.style.top = `${rect.top + sc.y - screenRadius}px`;
brushCursorEl.style.width = `${diam}px`;
brushCursorEl.style.height = `${diam}px`;
}
function updateBucketHover(e) {
const triIdx = pickTriangle(e);
if (triIdx === _lastHoverTriIdx) return; // unchanged — skip expensive BFS
+11
View File
@@ -66,6 +66,17 @@ main {
}
/* ── 3-D Viewport section ────────────────────────────────────────────── */
#brush-cursor {
display: none;
position: fixed;
pointer-events: none;
border: 2px solid rgba(255, 255, 255, 0.85);
border-radius: 50%;
box-shadow: 0 0 0 1px rgba(0,0,0,0.5);
z-index: 1000;
transform: translate(0, 0);
}
#viewport-section {
flex: 1 1 0;
min-width: 0;