mirror of
https://github.com/CNCKitchen/stlTexturizer.git
synced 2026-04-07 22:11:32 +00:00
feat: add brush cursor for exclusion tool and enhance cursor behavior
This commit is contained in:
@@ -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
@@ -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(0–1)
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user