From 1d3e756245014759857781021b5f1388f990377c Mon Sep 17 00:00:00 2001 From: CNCKitchen Date: Tue, 17 Mar 2026 14:35:45 +0100 Subject: [PATCH] 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. --- index.html | 63 ++++++++++- js/displacement.js | 17 ++- js/exclusion.js | 181 +++++++++++++++++++++++++++++++ js/main.js | 262 ++++++++++++++++++++++++++++++++++++++++++++- js/subdivision.js | 59 ++++++---- js/viewer.js | 70 +++++++++++- style.css | 110 ++++++++++++++++++- 7 files changed, 730 insertions(+), 32 deletions(-) create mode 100644 js/exclusion.js diff --git a/index.html b/index.html index 7358512..1aa183a 100644 --- a/index.html +++ b/index.html @@ -109,7 +109,7 @@
- +
@@ -153,6 +153,67 @@

0° = no masking. Surfaces within this angle of horizontal will not be textured.

+ +
+

Surface Exclusions

+ + +
+ + + +
+ + + + + + + + + + + + +

Excluded surfaces appear orange and will not receive displacement during export.

+
+

Export

diff --git a/js/displacement.js b/js/displacement.js index fc91993..4dc1b5a 100644 --- a/js/displacement.js +++ b/js/displacement.js @@ -63,6 +63,10 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett // vertices get a smooth displacement blend instead of a hard on/off cutoff. const maskedFracMap = new Map(); + // Optional per-vertex exclusion weights threaded through by subdivision.js. + // A face's user-exclusion flag = average of its 3 vertex weights > 0.5. + const ewAttr = geometry.attributes.excludeWeight || null; + for (let t = 0; t < count; t += 3) { vA.fromBufferAttribute(posAttr, t); vB.fromBufferAttribute(posAttr, t + 1); @@ -71,13 +75,22 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett edge2.subVectors(vC, vA); faceNrm.crossVectors(edge1, edge2); // length = 2× triangle area → natural area weighting - // Determine if this face is masked (used to build the per-vertex blend weight) + // Determine if this face is masked (used to build the per-vertex blend weight). + // Combines angle-based masking with optional user-painted exclusion. const faceArea = faceNrm.length(); // ∝ 2× triangle area const faceNzNorm = faceArea > 1e-12 ? faceNrm.z / faceArea : 0; // unit-normal Z component const faceAngle = Math.acos(Math.abs(faceNzNorm)) * (180 / Math.PI); - const faceMasked = faceNzNorm < 0 + const angleMasked = faceNzNorm < 0 ? (settings.bottomAngleLimit > 0 && faceAngle <= settings.bottomAngleLimit) : (settings.topAngleLimit > 0 && faceAngle <= settings.topAngleLimit); + // Threshold >0.99 (not 0.5) prevents shared-vertex MAX-propagation from + // accidentally marking adjacent faces as excluded on closed meshes (e.g. a + // cube): adjacent faces have 2/3 vertices at weight 1.0 → avg ≈ 0.67 which + // would wrongly trigger the old 0.5 threshold. + const userExcluded = ewAttr + ? (ewAttr.getX(t) + ewAttr.getX(t + 1) + ewAttr.getX(t + 2)) / 3 > 0.99 + : false; + const faceMasked = angleMasked || userExcluded; for (let v = 0; v < 3; v++) { tmpPos.fromBufferAttribute(posAttr, t + v); diff --git a/js/exclusion.js b/js/exclusion.js new file mode 100644 index 0000000..0ebda4b --- /dev/null +++ b/js/exclusion.js @@ -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>, + * 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>} adjacency + * @param {number} thresholdDeg + * @returns {Set} 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} 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} 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; +} diff --git a/js/main.js b/js/main.js index cbdfe4b..b2ffd97 100644 --- a/js/main.js +++ b/js/main.js @@ -1,4 +1,7 @@ -import { initViewer, loadGeometry, setMeshMaterial, setWireframe } from './viewer.js'; +import * as THREE from 'three'; +import { initViewer, loadGeometry, setMeshMaterial, setWireframe, + getControls, getCamera, getCurrentMesh, + setExclusionOverlay, setHoverPreview } from './viewer.js'; import { loadSTLFile, computeBounds, getTriangleCount } from './stlLoader.js'; import { PRESETS, loadCustomTexture } from './presetTextures.js'; import { createPreviewMaterial, updateMaterial } from './previewMaterial.js'; @@ -6,6 +9,8 @@ import { subdivide } from './subdivision.js'; import { applyDisplacement } from './displacement.js'; import { decimate } from './decimation.js'; import { exportSTL } from './exporter.js'; +import { buildAdjacency, bucketFill, + buildExclusionOverlayGeo, buildFaceWeights } from './exclusion.js'; // ── State ───────────────────────────────────────────────────────────────────── @@ -15,6 +20,19 @@ let activeMapEntry = null; // { name, texture, imageData, width, height } let previewMaterial = null; let isExporting = false; +// ── Exclusion state ─────────────────────────────────────────────────────────── +let excludedFaces = new Set(); // triangle indices in currentGeometry +let triangleAdjacency = null; // Map from buildAdjacency +let triangleCentroids = null; // Float32Array from buildAdjacency +let exclusionTool = null; // 'brush' | 'bucket' | null +let eraseMode = false; +let brushIsRadius = false; +let brushRadius = 5.0; +let bucketThreshold = 30; +let isPainting = false; +let _lastHoverTriIdx = -1; // last triangle index used for hover preview +const _raycaster = new THREE.Raycaster(); + const settings = { mappingMode: 6, // Cubic default scaleU: 1.0, @@ -69,6 +87,22 @@ const topAngleLimitSlider = document.getElementById('top-angle-limit'); const bottomAngleLimitVal = document.getElementById('bottom-angle-limit-val'); const topAngleLimitVal = document.getElementById('top-angle-limit-val'); +// ── Exclusion panel DOM refs ────────────────────────────────────────────────── +const exclBrushBtn = document.getElementById('excl-brush-btn'); +const exclBucketBtn = document.getElementById('excl-bucket-btn'); +const exclEraseToggle = document.getElementById('excl-erase-toggle'); +const exclBrushTypeRow = document.getElementById('excl-brush-type-row'); +const exclBrushSingleBtn = document.getElementById('excl-brush-single'); +const exclBrushRadiusBtn = document.getElementById('excl-brush-radius-btn'); +const exclRadiusRow = document.getElementById('excl-radius-row'); +const exclBrushRadiusSlider = document.getElementById('excl-brush-radius-slider'); +const exclBrushRadiusVal = document.getElementById('excl-brush-radius-val'); +const exclThresholdRow = document.getElementById('excl-threshold-row'); +const exclThresholdSlider = document.getElementById('excl-threshold-slider'); +const exclThresholdVal = document.getElementById('excl-threshold-val'); +const exclCount = document.getElementById('excl-count'); +const exclClearBtn = document.getElementById('excl-clear-btn'); + // ── Scale slider log helpers ────────────────────────────────────────────────── // Slider stores 0–1000; actual scale spans 0.1–10 on a log axis. // Middle position 500 → scale 1.0 (exact midpoint on log scale). @@ -211,12 +245,202 @@ function wireEvents() { // ── Wireframe ── wireframeToggle.addEventListener('change', () => setWireframe(wireframeToggle.checked)); + + // ── Exclusion tool wiring ───────────────────────────────────────────────── + + exclBrushBtn.addEventListener('click', () => setExclusionTool('brush')); + exclBucketBtn.addEventListener('click', () => setExclusionTool('bucket')); + + exclEraseToggle.addEventListener('click', () => { + eraseMode = !eraseMode; + exclEraseToggle.classList.toggle('active', eraseMode); + exclEraseToggle.setAttribute('aria-pressed', String(eraseMode)); + }); + + exclBrushSingleBtn.addEventListener('click', () => { + brushIsRadius = false; + exclBrushSingleBtn.classList.add('active'); + exclBrushRadiusBtn.classList.remove('active'); + exclRadiusRow.classList.add('hidden'); + }); + + exclBrushRadiusBtn.addEventListener('click', () => { + brushIsRadius = true; + exclBrushRadiusBtn.classList.add('active'); + exclBrushSingleBtn.classList.remove('active'); + if (exclusionTool === 'brush') exclRadiusRow.classList.remove('hidden'); + }); + + exclBrushRadiusSlider.addEventListener('input', () => { + brushRadius = parseFloat(exclBrushRadiusSlider.value); + exclBrushRadiusVal.value = brushRadius; + }); + exclBrushRadiusVal.addEventListener('change', () => { + brushRadius = Math.max(0.1, Math.min(50, parseFloat(exclBrushRadiusVal.value) || 5)); + exclBrushRadiusSlider.value = brushRadius; + exclBrushRadiusVal.value = brushRadius; + }); + + exclThresholdSlider.addEventListener('input', () => { + bucketThreshold = parseFloat(exclThresholdSlider.value); + exclThresholdVal.value = bucketThreshold; + _lastHoverTriIdx = -1; // invalidate hover so next mousemove re-computes + }); + exclThresholdVal.addEventListener('change', () => { + bucketThreshold = Math.max(0, Math.min(180, parseFloat(exclThresholdVal.value) || 30)); + exclThresholdSlider.value = bucketThreshold; + exclThresholdVal.value = bucketThreshold; + _lastHoverTriIdx = -1; + }); + + exclClearBtn.addEventListener('click', () => { + excludedFaces = new Set(); + refreshExclusionOverlay(); + }); + + // ── Canvas mouse events for exclusion painting ──────────────────────────── + canvas.addEventListener('mousedown', (e) => { + if (!currentGeometry || !exclusionTool || e.button !== 0) return; + e.preventDefault(); + getControls().enabled = false; + isPainting = true; + + if (exclusionTool === 'bucket') { + const triIdx = pickTriangle(e); + if (triIdx >= 0) { + const filled = bucketFill(triIdx, triangleAdjacency, bucketThreshold); + for (const t of filled) { + if (eraseMode) excludedFaces.delete(t); else excludedFaces.add(t); + } + refreshExclusionOverlay(); + // Clear hover immediately so the confirmed orange overlay is fully visible + _lastHoverTriIdx = -1; + setHoverPreview(null); + } + isPainting = false; + getControls().enabled = true; + } else { + paintAt(e); + } + }); + + canvas.addEventListener('mousemove', (e) => { + if (isPainting && exclusionTool === 'brush') { + paintAt(e); + return; + } + if (!isPainting && exclusionTool === 'bucket' && currentGeometry) { + updateBucketHover(e); + } + }); + + canvas.addEventListener('mouseleave', () => { + _lastHoverTriIdx = -1; + setHoverPreview(null); + }); + + document.addEventListener('mouseup', () => { + if (!isPainting) return; + isPainting = false; + getControls().enabled = true; + }); + + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && exclusionTool) { + setExclusionTool(null); + } + }); +} + +// ── Exclusion helpers ───────────────────────────────────────────────────────── + +function setExclusionTool(tool) { + // Clicking the active tool toggles it off; passing null always deactivates + exclusionTool = (exclusionTool === tool) ? null : tool; + exclBrushBtn.classList.toggle('active', exclusionTool === 'brush'); + exclBucketBtn.classList.toggle('active', exclusionTool === 'bucket'); + // Show brush-type row only while brush is active + exclBrushTypeRow.classList.toggle('hidden', exclusionTool !== 'brush'); + // Show radius row only while brush + radius mode is active + 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' : ''; + // Clear hover preview whenever the tool changes or is deactivated + _lastHoverTriIdx = -1; + setHoverPreview(null); + // Re-enable controls if tool was deactivated mid-paint + if (!exclusionTool) { + isPainting = false; + getControls().enabled = true; + } +} + +function _canvasNDC(e) { + const rect = canvas.getBoundingClientRect(); + return new THREE.Vector2( + ((e.clientX - rect.left) / rect.width) * 2 - 1, + ((e.clientY - rect.top) / rect.height) * -2 + 1, + ); +} + +function pickTriangle(e) { + const mesh = getCurrentMesh(); + if (!mesh) return -1; + _raycaster.setFromCamera(_canvasNDC(e), getCamera()); + const hits = _raycaster.intersectObject(mesh); + return hits.length > 0 ? hits[0].faceIndex : -1; +} + +function paintAt(e) { + const mesh = getCurrentMesh(); + if (!mesh) return; + _raycaster.setFromCamera(_canvasNDC(e), getCamera()); + const hits = _raycaster.intersectObject(mesh); + if (hits.length === 0) return; + + const triIdx = hits[0].faceIndex; + + if (brushIsRadius) { + const hitPt = hits[0].point; + const triCount = triangleCentroids.length / 3; + const r2 = brushRadius * brushRadius; + for (let t = 0; t < triCount; t++) { + const dx = triangleCentroids[t * 3] - hitPt.x; + const dy = triangleCentroids[t * 3 + 1] - hitPt.y; + const dz = triangleCentroids[t * 3 + 2] - hitPt.z; + if (dx * dx + dy * dy + dz * dz <= r2) { + if (eraseMode) excludedFaces.delete(t); else excludedFaces.add(t); + } + } + } else { + if (eraseMode) excludedFaces.delete(triIdx); else excludedFaces.add(triIdx); + } + + refreshExclusionOverlay(); +} + +function refreshExclusionOverlay() { + if (!currentGeometry) return; + setExclusionOverlay(buildExclusionOverlayGeo(currentGeometry, excludedFaces)); + const n = excludedFaces.size; + exclCount.textContent = `${n.toLocaleString()} face${n === 1 ? '' : 's'} excluded`; +} + +function updateBucketHover(e) { + const triIdx = pickTriangle(e); + if (triIdx === _lastHoverTriIdx) return; // unchanged — skip expensive BFS + _lastHoverTriIdx = triIdx; + if (triIdx < 0 || !triangleAdjacency) { + setHoverPreview(null); + return; + } + const hovered = bucketFill(triIdx, triangleAdjacency, bucketThreshold); + setHoverPreview(buildExclusionOverlayGeo(currentGeometry, hovered)); } // ── Slider helper ───────────────────────────────────────────────────────────── -let previewDebounce = null; - function linkSlider(slider, valInput, onChangeFn, livePreview = true) { const isSpan = valInput.tagName === 'SPAN'; slider.addEventListener('input', () => { @@ -277,6 +501,28 @@ async function handleSTL(file) { loadGeometry(geometry); dropHint.classList.add('hidden'); + // Reset exclusion state for the new mesh + excludedFaces = new Set(); + exclusionTool = null; + eraseMode = false; + isPainting = false; + exclBrushBtn.classList.remove('active'); + exclBucketBtn.classList.remove('active'); + exclEraseToggle.classList.remove('active'); + exclBrushTypeRow.classList.add('hidden'); + exclRadiusRow.classList.add('hidden'); + exclThresholdRow.classList.add('hidden'); + canvas.style.cursor = ''; + setExclusionOverlay(null); + setHoverPreview(null); + _lastHoverTriIdx = -1; + exclCount.textContent = '0 faces excluded'; + // Build adjacency data for brush/bucket tools (synchronous; fast enough for + // typical STL sizes processed by this tool) + const adjData = buildAdjacency(geometry); + triangleAdjacency = adjData.adjacency; + triangleCentroids = adjData.centroids; + // Reset scale & offset sliders so scale=1 = one tile covers the full bounding box const resetVal = (slider, valEl, value) => { slider.value = value; @@ -346,9 +592,17 @@ async function handleExport() { try { setProgress(0.02, 'Subdividing mesh…'); + // Build per-vertex exclusion weights if any faces are excluded. + // subdivision.js interpolates these through edge splits so the exclusion + // propagates correctly to all new vertices inside the excluded region. + const faceWeights = excludedFaces.size > 0 + ? buildFaceWeights(currentGeometry, excludedFaces) + : null; + const { geometry: subdivided, safetyCapHit } = await runAsync(() => subdivide(currentGeometry, settings.refineLength, - (p) => setProgress(0.02 + p * 0.35, 'Subdividing mesh…')) + (p) => setProgress(0.02 + p * 0.35, 'Subdividing mesh…'), + faceWeights) ); const subTriCount = subdivided.attributes.position.count / 3; diff --git a/js/subdivision.js b/js/subdivision.js index 6a6e1e1..c5dbacf 100644 --- a/js/subdivision.js +++ b/js/subdivision.js @@ -19,8 +19,8 @@ const SAFETY_CAP = 5_000_000; // absolute OOM guard // ── Public entry point ─────────────────────────────────────────────────────── -export function subdivide(geometry, maxEdgeLength, onProgress) { - const { positions, normals, indices } = toIndexed(geometry); +export function subdivide(geometry, maxEdgeLength, onProgress, faceWeights = null) { + const { positions, normals, weights, indices } = toIndexed(geometry, faceWeights); const maxIterations = 12; let currentIndices = indices; @@ -34,7 +34,7 @@ export function subdivide(geometry, maxEdgeLength, onProgress) { } const { newIndices, changed } = subdividePass( - positions, normals, currentIndices, maxEdgeLength, SAFETY_CAP + positions, normals, weights, currentIndices, maxEdgeLength, SAFETY_CAP ); currentIndices = newIndices; @@ -44,7 +44,7 @@ export function subdivide(geometry, maxEdgeLength, onProgress) { if (!changed || safetyCapHit) break; } - return { geometry: toNonIndexed(positions, normals, currentIndices), safetyCapHit }; + return { geometry: toNonIndexed(positions, normals, weights, currentIndices), safetyCapHit }; } // ── One subdivision pass ────────────────────────────────────────────────────── @@ -68,7 +68,7 @@ export function subdivide(geometry, maxEdgeLength, onProgress) { // long edge still produce chains of thin children (unavoidable without moving // vertices off the surface), but the mesh is now crack-free in all cases. -function subdividePass(positions, normals, indices, maxEdgeLength, safetyCap) { +function subdividePass(positions, normals, weights, indices, maxEdgeLength, safetyCap) { const maxSq = maxEdgeLength * maxEdgeLength; const midCache = new Map(); @@ -112,9 +112,9 @@ function subdividePass(positions, normals, indices, maxEdgeLength, safetyCap) { // / \ / \ // c─mBC───b // - const mAB = getMidpoint(positions, normals, midCache, a, b); - const mBC = getMidpoint(positions, normals, midCache, b, c); - const mCA = getMidpoint(positions, normals, midCache, c, a); + const mAB = getMidpoint(positions, normals, weights, midCache, a, b); + const mBC = getMidpoint(positions, normals, weights, midCache, b, c); + const mCA = getMidpoint(positions, normals, weights, midCache, c, a); nextIndices.push( a, mAB, mCA, mAB, b, mBC, @@ -125,13 +125,13 @@ function subdividePass(positions, normals, indices, maxEdgeLength, safetyCap) { } else if (n === 1) { // ── 1-split: bisect the one marked edge → 2 sub-triangles ────────── if (sAB) { - const m = getMidpoint(positions, normals, midCache, a, b); + const m = getMidpoint(positions, normals, weights, midCache, a, b); nextIndices.push(a, m, c, m, b, c); } else if (sBC) { - const m = getMidpoint(positions, normals, midCache, b, c); + const m = getMidpoint(positions, normals, weights, midCache, b, c); nextIndices.push(a, b, m, a, m, c); } else { // sCA - const m = getMidpoint(positions, normals, midCache, c, a); + const m = getMidpoint(positions, normals, weights, midCache, c, a); nextIndices.push(a, b, m, m, b, c); } @@ -144,24 +144,24 @@ function subdividePass(positions, normals, indices, maxEdgeLength, safetyCap) { // opposite vertices, preserving consistent CCW winding throughout. if (!sAB) { // sBC + sCA: fan from C - const mBC = getMidpoint(positions, normals, midCache, b, c); - const mCA = getMidpoint(positions, normals, midCache, c, a); + const mBC = getMidpoint(positions, normals, weights, midCache, b, c); + const mCA = getMidpoint(positions, normals, weights, midCache, c, a); nextIndices.push( a, b, mBC, a, mBC, mCA, c, mCA, mBC, ); } else if (!sBC) { // sAB + sCA: fan from A - const mAB = getMidpoint(positions, normals, midCache, a, b); - const mCA = getMidpoint(positions, normals, midCache, c, a); + const mAB = getMidpoint(positions, normals, weights, midCache, a, b); + const mCA = getMidpoint(positions, normals, weights, midCache, c, a); nextIndices.push( a, mAB, mCA, mAB, b, c, mAB, c, mCA, ); } else { // sAB + sBC: fan from B - const mAB = getMidpoint(positions, normals, midCache, a, b); - const mBC = getMidpoint(positions, normals, midCache, b, c); + const mAB = getMidpoint(positions, normals, weights, midCache, a, b); + const mBC = getMidpoint(positions, normals, weights, midCache, b, c); nextIndices.push( b, mBC, mAB, a, mAB, mBC, @@ -188,7 +188,7 @@ function edgeLenSq(pos, a, b) { return dx*dx + dy*dy + dz*dz; } -function getMidpoint(positions, normals, cache, a, b) { +function getMidpoint(positions, normals, weights, cache, a, b) { const key = a < b ? `${a}:${b}` : `${b}:${a}`; if (cache.has(key)) return cache.get(key); @@ -206,18 +206,27 @@ function getMidpoint(positions, normals, cache, a, b) { const idx = (positions.length / 3) | 0; positions.push(mx, my, mz); normals.push(nx / nl, ny / nl, nz / nl); + // Interpolate exclusion weight: 0 = included, 1 = excluded. + // A midpoint between two excluded vertices → 1.0; between mixed → 0.5 + // (displacement.js treats > 0.5 average as excluded for the face). + if (weights) weights.push((weights[a] + weights[b]) / 2); cache.set(key, idx); return idx; } // ── Non-indexed → indexed conversion ──────────────────────────────────────── -function toIndexed(geometry) { +// nonIndexedWeights: optional Float32Array(vertexCount) where vertex i has +// weight = 1.0 if its triangle (floor(i/3)) is user-excluded, else 0. +// When multiple original vertices map to the same indexed vertex, the MAX +// weight wins (conservative: any excluded face marks the shared vertex). +function toIndexed(geometry, nonIndexedWeights = null) { const posAttr = geometry.attributes.position; const nrmAttr = geometry.attributes.normal; const positions = []; const normals = []; + const weights = nonIndexedWeights ? [] : null; const indices = []; const vertMap = new Map(); @@ -236,20 +245,25 @@ function toIndexed(geometry) { idx = positions.length / 3; positions.push(px, py, pz); normals.push(nx_, ny_, nz_); + if (weights) weights.push(nonIndexedWeights[i]); vertMap.set(key, idx); + } else if (weights && nonIndexedWeights[i] > weights[idx]) { + // MAX: if any incident original face was excluded, the shared vertex is excluded + weights[idx] = nonIndexedWeights[i]; } indices.push(idx); } - return { positions, normals, indices }; + return { positions, normals, weights, indices }; } // ── Indexed → non-indexed ──────────────────────────────────────────────────── -function toNonIndexed(positions, normals, indices) { +function toNonIndexed(positions, normals, weights, indices) { const triCount = indices.length / 3; const posArray = new Float32Array(triCount * 9); const nrmArray = new Float32Array(triCount * 9); + const wgtArray = weights ? new Float32Array(triCount * 3) : null; for (let t = 0; t < triCount; t++) { for (let v = 0; v < 3; v++) { @@ -261,11 +275,14 @@ function toNonIndexed(positions, normals, indices) { nrmArray[t * 9 + v * 3] = normals[vidx * 3]; nrmArray[t * 9 + v * 3 + 1] = normals[vidx * 3 + 1]; nrmArray[t * 9 + v * 3 + 2] = normals[vidx * 3 + 2]; + + if (wgtArray) wgtArray[t * 3 + v] = weights[vidx]; } } const geo = new THREE.BufferGeometry(); geo.setAttribute('position', new THREE.BufferAttribute(posArray, 3)); geo.setAttribute('normal', new THREE.BufferAttribute(nrmArray, 3)); + if (wgtArray) geo.setAttribute('excludeWeight', new THREE.BufferAttribute(wgtArray, 1)); return geo; } diff --git a/js/viewer.js b/js/viewer.js index 1dfa0d0..9740652 100644 --- a/js/viewer.js +++ b/js/viewer.js @@ -9,6 +9,8 @@ let currentMesh = null; let axesGroup = null; let wireframeLines = null; // LineSegments overlay, or null when hidden let wireframeVisible = false; +let exclusionMesh = null; // flat orange overlay for user-excluded faces +let hoverMesh = null; // semi-transparent yellow bucket-fill preview // Build a labelled coordinate axes indicator scaled to `size`. // X = red, Y = green, Z = blue (up). @@ -245,11 +247,73 @@ function fitCamera(sphere) { controls.update(); } -export function getRenderer() { return renderer; } -export function getCamera() { return camera; } -export function getScene() { return scene; } +export function getRenderer() { return renderer; } +export function getCamera() { return camera; } +export function getScene() { return scene; } +export function getControls() { return controls; } export function getCurrentMesh() { return currentMesh; } +/** + * Replace (or clear) the flat orange exclusion overlay mesh. + * overlayGeo must be a non-indexed BufferGeometry with a 'position' attribute, + * or null / an empty geometry to clear the overlay. + * The mesh lives directly in the scene so loadGeometry() (which clears + * meshGroup) never accidentally removes it. + * + * @param {THREE.BufferGeometry|null} overlayGeo + */ +export function setExclusionOverlay(overlayGeo) { + if (exclusionMesh) { + scene.remove(exclusionMesh); + exclusionMesh.geometry.dispose(); + exclusionMesh.material.dispose(); + exclusionMesh = null; + } + if (!overlayGeo || overlayGeo.attributes.position.count === 0) return; + exclusionMesh = new THREE.Mesh( + overlayGeo, + new THREE.MeshBasicMaterial({ + color: 0xff6600, + side: THREE.DoubleSide, + polygonOffset: true, + polygonOffsetFactor: -1, + polygonOffsetUnits: -1, + }), + ); + exclusionMesh.renderOrder = 1; + scene.add(exclusionMesh); +} + +/** + * Replace (or clear) the yellow hover-preview overlay shown before a bucket-fill + * click is confirmed. Pass null or an empty geometry to clear it. + * + * @param {THREE.BufferGeometry|null} overlayGeo + */ +export function setHoverPreview(overlayGeo) { + if (hoverMesh) { + scene.remove(hoverMesh); + hoverMesh.geometry.dispose(); + hoverMesh.material.dispose(); + hoverMesh = null; + } + if (!overlayGeo || overlayGeo.attributes.position.count === 0) return; + hoverMesh = new THREE.Mesh( + overlayGeo, + new THREE.MeshBasicMaterial({ + color: 0xffee00, + side: THREE.DoubleSide, + transparent: true, + opacity: 0.45, + polygonOffset: true, + polygonOffsetFactor: -2, + polygonOffsetUnits: -2, + }), + ); + hoverMesh.renderOrder = 2; + scene.add(hoverMesh); +} + /** * Show or hide the triangle-edge wireframe overlay. * @param {boolean} enabled diff --git a/style.css b/style.css index fa0f4d3..4fa9ac2 100644 --- a/style.css +++ b/style.css @@ -443,4 +443,112 @@ input[type="number"].val:focus { outline: none; border-color: var(--accent); } margin-bottom: 8px; } -.tri-limit-warning.hidden { display: none; } \ No newline at end of file +.tri-limit-warning.hidden { display: none; } + +/* ── Surface Exclusions panel ────────────────────────────────────────────── */ + +/* Tool button row */ +.excl-tools { + display: flex; + gap: 6px; + margin-bottom: 10px; +} + +.excl-tool-btn { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 5px; + padding: 5px 8px; + background: var(--surface2); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text-muted); + font-size: 11px; + cursor: pointer; + transition: background 0.15s, border-color 0.15s, color 0.15s; + white-space: nowrap; +} + +.excl-tool-btn:hover { + border-color: var(--accent-hover); + color: var(--text); +} + +.excl-tool-btn.active { + background: color-mix(in srgb, var(--accent) 18%, var(--surface2)); + border-color: var(--accent); + color: var(--accent); +} + +/* Segmented button group (Single / Radius) */ +.excl-seg { + display: flex; + flex: 1; +} + +.excl-seg-btn { + flex: 1; + padding: 4px 8px; + background: var(--surface2); + border: 1px solid var(--border); + color: var(--text-muted); + font-size: 11px; + cursor: pointer; + transition: background 0.15s, color 0.15s; +} + +.excl-seg-btn:first-child { + border-radius: var(--radius) 0 0 var(--radius); +} + +.excl-seg-btn:last-child { + border-radius: 0 var(--radius) var(--radius) 0; + border-left: none; +} + +.excl-seg-btn.active { + background: color-mix(in srgb, var(--accent) 18%, var(--surface2)); + border-color: var(--accent); + color: var(--accent); +} + +.excl-seg-btn:hover:not(.active) { + background: var(--border); + color: var(--text); +} + +/* Footer row: count + clear */ +.excl-footer { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 10px; +} + +.excl-count { + font-size: 11px; + color: var(--text-muted); + font-variant-numeric: tabular-nums; +} + +.excl-clear-btn { + padding: 4px 10px; + background: var(--surface2); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text-muted); + font-size: 11px; + cursor: pointer; + transition: background 0.15s, border-color 0.15s, color 0.15s; +} + +.excl-clear-btn:hover { + border-color: var(--danger); + color: var(--danger); + background: color-mix(in srgb, var(--danger) 10%, var(--surface2)); +} + +/* Hide utility (used by JS to show/hide exclusion sub-rows) */ +.form-row.hidden { display: none; } \ No newline at end of file