diff --git a/index.html b/index.html index 0bac522..0cb4755 100644 --- a/index.html +++ b/index.html @@ -163,7 +163,17 @@
-

Surface Exclusions

+

Surface Exclusions

+ + +
+
+ + +
+
@@ -219,7 +229,7 @@ 0 faces excluded
-

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

+

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

diff --git a/js/exclusion.js b/js/exclusion.js index 0ebda4b..bcadcb1 100644 --- a/js/exclusion.js +++ b/js/exclusion.js @@ -136,23 +136,40 @@ export function bucketFill(seedTriIdx, adjacency, thresholdDeg) { // ── Overlay geometry ────────────────────────────────────────────────────────── /** - * Build a compact non-indexed BufferGeometry containing only the excluded - * triangles' positions. Used to drive the orange overlay mesh in the viewer. + * Build a compact non-indexed BufferGeometry for an overlay. * * @param {THREE.BufferGeometry} geometry – non-indexed source geometry - * @param {Set} excludedFaces + * @param {Set} faceSet + * @param {boolean} [invert=false] when true, include faces NOT in faceSet * @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 +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 count = invert ? total - faceSet.size : faceSet.size; + const outPos = new Float32Array(count * 9); + const outNrm = srcNrm ? new Float32Array(count * 9) : null; let dst = 0; - for (const t of excludedFaces) { - const src = t * 9; - for (let i = 0; i < 9; i++) outPos[dst++] = srcPos[src + i]; + if (invert) { + for (let t = 0; t < total; t++) { + if (faceSet.has(t)) continue; + const src = t * 9; + for (let i = 0; i < 9; i++) outPos[dst + i] = srcPos[src + i]; + if (outNrm) for (let i = 0; i < 9; i++) outNrm[dst + i] = srcNrm[src + i]; + dst += 9; + } + } else { + for (const t of faceSet) { + const src = t * 9; + for (let i = 0; i < 9; i++) outPos[dst + i] = srcPos[src + i]; + if (outNrm) for (let i = 0; i < 9; i++) outNrm[dst + i] = srcNrm[src + i]; + 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; } @@ -169,13 +186,23 @@ export function buildExclusionOverlayGeo(geometry, excludedFaces) { * @param {Set} excludedFaces * @returns {Float32Array} length = geometry.attributes.position.count */ -export function buildFaceWeights(geometry, excludedFaces) { +export function buildFaceWeights(geometry, excludedFaces, invert = false) { 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; + 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; } diff --git a/js/main.js b/js/main.js index f0ca368..0167ccb 100644 --- a/js/main.js +++ b/js/main.js @@ -31,6 +31,7 @@ let brushIsRadius = false; let brushRadius = 5.0; let bucketThreshold = 30; let isPainting = false; +let selectionMode = false; // false = exclude painted faces; true = include only painted faces let _lastHoverTriIdx = -1; // last triangle index used for hover preview const _raycaster = new THREE.Raycaster(); @@ -103,6 +104,10 @@ 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'); +const exclModeExcludeBtn = document.getElementById('excl-mode-exclude'); +const exclModeIncludeBtn = document.getElementById('excl-mode-include'); +const exclSectionHeading = document.getElementById('excl-section-heading'); +const exclHint = document.getElementById('excl-hint'); // ── Scale slider log helpers ────────────────────────────────────────────────── // Slider stores 0–1000; actual scale spans 0.1–10 on a log axis. @@ -299,6 +304,9 @@ function wireEvents() { refreshExclusionOverlay(); }); + exclModeExcludeBtn.addEventListener('click', () => setSelectionMode(false)); + exclModeIncludeBtn.addEventListener('click', () => setSelectionMode(true)); + // ── Canvas mouse events for exclusion painting ──────────────────────────── canvas.addEventListener('mousedown', (e) => { if (!currentGeometry || !exclusionTool || e.button !== 0) return; @@ -355,6 +363,22 @@ function wireEvents() { // ── Exclusion helpers ───────────────────────────────────────────────────────── +function setSelectionMode(include) { + if (selectionMode === include) return; + selectionMode = include; + exclModeExcludeBtn.classList.toggle('active', !selectionMode); + exclModeIncludeBtn.classList.toggle('active', selectionMode); + exclModeExcludeBtn.setAttribute('aria-pressed', String(!selectionMode)); + exclModeIncludeBtn.setAttribute('aria-pressed', String(selectionMode)); + exclSectionHeading.textContent = selectionMode ? 'Surface Selection' : 'Surface Exclusions'; + exclHint.textContent = selectionMode + ? 'Selected surfaces appear green and will be the only ones to receive displacement during export.' + : 'Excluded surfaces appear orange and will not receive displacement during export.'; + // Clear the painted set — faces had opposite semantics in the previous mode + excludedFaces = new Set(); + refreshExclusionOverlay(); +} + function setExclusionTool(tool) { // Clicking the active tool toggles it off; passing null always deactivates exclusionTool = (exclusionTool === tool) ? null : tool; @@ -423,9 +447,18 @@ function paintAt(e) { function refreshExclusionOverlay() { if (!currentGeometry) return; - setExclusionOverlay(buildExclusionOverlayGeo(currentGeometry, excludedFaces)); + if (selectionMode) { + // Include Only mode: grey out the complement (non-selected faces) so only the + // selected faces show the texture preview beneath. + const maskGeo = buildExclusionOverlayGeo(currentGeometry, excludedFaces, true); + setExclusionOverlay(maskGeo, 0x222222, 0.88); + } else { + setExclusionOverlay(buildExclusionOverlayGeo(currentGeometry, excludedFaces), 0xff6600); + } const n = excludedFaces.size; - exclCount.textContent = `${n.toLocaleString()} face${n === 1 ? '' : 's'} excluded`; + exclCount.textContent = selectionMode + ? `${n.toLocaleString()} face${n === 1 ? '' : 's'} selected` + : `${n.toLocaleString()} face${n === 1 ? '' : 's'} excluded`; } function updateBucketHover(e) { @@ -584,6 +617,48 @@ function updatePreview() { // ── Export pipeline ─────────────────────────────────────────────────────────── +/** + * Builds per-non-indexed-vertex weights (1.0 = excluded from subdivision/displacement) + * that combine the user-painted exclusion set AND the top/bottom angle mask. + */ +function buildCombinedFaceWeights(geometry, excludedFaces, invert, settings) { + const weights = buildFaceWeights(geometry, excludedFaces, invert); + + const hasAngleMask = settings.bottomAngleLimit > 0 || settings.topAngleLimit > 0; + if (!hasAngleMask) return weights; + + const posAttr = geometry.attributes.position; + const triCount = posAttr.count / 3; + const vA = new THREE.Vector3(); + const vB = new THREE.Vector3(); + const vC = new THREE.Vector3(); + const edge1 = new THREE.Vector3(); + const edge2 = new THREE.Vector3(); + const faceNrm = new THREE.Vector3(); + + for (let t = 0; t < triCount; t++) { + if (weights[t * 3] > 0.99) continue; // already excluded + vA.fromBufferAttribute(posAttr, t * 3); + vB.fromBufferAttribute(posAttr, t * 3 + 1); + vC.fromBufferAttribute(posAttr, t * 3 + 2); + edge1.subVectors(vB, vA); + edge2.subVectors(vC, vA); + faceNrm.crossVectors(edge1, edge2); + const faceArea = faceNrm.length(); + const faceNzNorm = faceArea > 1e-12 ? faceNrm.z / faceArea : 0; + const faceAngle = Math.acos(Math.abs(faceNzNorm)) * (180 / Math.PI); + const angleMasked = faceNzNorm < 0 + ? (settings.bottomAngleLimit > 0 && faceAngle <= settings.bottomAngleLimit) + : (settings.topAngleLimit > 0 && faceAngle <= settings.topAngleLimit); + if (angleMasked) { + weights[t * 3] = 1.0; + weights[t * 3 + 1] = 1.0; + weights[t * 3 + 2] = 1.0; + } + } + return weights; +} + async function handleExport() { if (!currentGeometry || !activeMapEntry || isExporting) return; isExporting = true; @@ -593,11 +668,13 @@ 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) + // Build per-vertex exclusion weights combining user-painted exclusion + angle masking. + // Faces masked by top/bottom angle limits are treated the same as user-excluded faces + // so subdivision skips their interior edges too, saving triangles where no + // displacement will be applied. + const hasAngleMask = settings.bottomAngleLimit > 0 || settings.topAngleLimit > 0; + const faceWeights = (excludedFaces.size > 0 || selectionMode || hasAngleMask) + ? buildCombinedFaceWeights(currentGeometry, excludedFaces, selectionMode, settings) : null; const { geometry: subdivided, safetyCapHit } = await runAsync(() => diff --git a/js/subdivision.js b/js/subdivision.js index c5dbacf..364b6b8 100644 --- a/js/subdivision.js +++ b/js/subdivision.js @@ -20,10 +20,24 @@ const SAFETY_CAP = 5_000_000; // absolute OOM guard // ── Public entry point ─────────────────────────────────────────────────────── export function subdivide(geometry, maxEdgeLength, onProgress, faceWeights = null) { + // Derive per-face exclusion BEFORE toIndexed so we use the untouched + // non-indexed weights (toIndexed uses MAX-merge which can push boundary + // vertices to weight 1.0 even on included triangles). + let initialFaceExcluded = null; + if (faceWeights) { + const triCount = faceWeights.length / 3; + initialFaceExcluded = new Uint8Array(triCount); + for (let i = 0; i < triCount; i++) { + // Non-indexed vertex i*3 belongs to face i; weight > 0.99 → excluded + if (faceWeights[i * 3] > 0.99) initialFaceExcluded[i] = 1; + } + } + const { positions, normals, weights, indices } = toIndexed(geometry, faceWeights); const maxIterations = 12; let currentIndices = indices; + let currentFaceExcluded = initialFaceExcluded; let safetyCapHit = false; for (let iter = 0; iter < maxIterations; iter++) { @@ -33,10 +47,11 @@ export function subdivide(geometry, maxEdgeLength, onProgress, faceWeights = nul break; } - const { newIndices, changed } = subdividePass( - positions, normals, weights, currentIndices, maxEdgeLength, SAFETY_CAP + const { newIndices, newFaceExcluded, changed } = subdividePass( + positions, normals, weights, currentIndices, maxEdgeLength, SAFETY_CAP, currentFaceExcluded ); currentIndices = newIndices; + if (newFaceExcluded) currentFaceExcluded = newFaceExcluded; if (newIndices.length / 3 >= SAFETY_CAP) safetyCapHit = true; @@ -68,32 +83,44 @@ export function subdivide(geometry, maxEdgeLength, onProgress, faceWeights = nul // 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, weights, indices, maxEdgeLength, safetyCap) { +function subdividePass(positions, normals, weights, indices, maxEdgeLength, safetyCap, faceExcluded = null) { const maxSq = maxEdgeLength * maxEdgeLength; const midCache = new Map(); // ── Step 1: globally mark edges that need splitting ───────────────────── + // Excluded triangles do NOT proactively mark their own edges – their + // interior edges will never be split, saving triangles on untextured + // regions. Boundary edges are still marked by the included neighbour, so + // excluded triangles respond to those splits and T-junctions are avoided. const splitEdges = new Set(); for (let t = 0; t < indices.length; t += 3) { + if (faceExcluded && faceExcluded[t / 3]) continue; // skip excluded faces const a = indices[t], b = indices[t + 1], c = indices[t + 2]; if (edgeLenSq(positions, a, b) > maxSq) splitEdges.add(edgeKey(a, b)); if (edgeLenSq(positions, b, c) > maxSq) splitEdges.add(edgeKey(b, c)); if (edgeLenSq(positions, c, a) > maxSq) splitEdges.add(edgeKey(c, a)); } - if (splitEdges.size === 0) return { newIndices: indices, changed: false }; + if (splitEdges.size === 0) return { newIndices: indices, newFaceExcluded: faceExcluded, changed: false }; // ── Step 2: rebuild index list ─────────────────────────────────────────── const nextIndices = []; + const nextFaceExcluded = faceExcluded ? [] : null; for (let t = 0; t < indices.length; t += 3) { // Safety cap: stop splitting, carry remaining triangles as-is if (nextIndices.length / 3 >= safetyCap) { for (let r = t; r < indices.length; r++) nextIndices.push(indices[r]); + // Carry remaining face-exclusion flags as-is + if (nextFaceExcluded && faceExcluded) { + for (let r = t / 3; r < indices.length / 3; r++) nextFaceExcluded.push(faceExcluded[r]); + } break; } const a = indices[t], b = indices[t + 1], c = indices[t + 2]; + const fIdx = t / 3; + const excl = faceExcluded ? faceExcluded[fIdx] : 0; const sAB = splitEdges.has(edgeKey(a, b)); const sBC = splitEdges.has(edgeKey(b, c)); const sCA = splitEdges.has(edgeKey(c, a)); @@ -102,6 +129,7 @@ function subdividePass(positions, normals, weights, indices, maxEdgeLength, safe if (n === 0) { // ── 0-split: keep triangle ───────────────────────────────────────── nextIndices.push(a, b, c); + if (nextFaceExcluded) nextFaceExcluded.push(excl); } else if (n === 3) { // ── 3-split: 1→4 regular midpoint subdivision ────────────────────── @@ -121,18 +149,22 @@ function subdividePass(positions, normals, weights, indices, maxEdgeLength, safe mCA, mBC, c, mAB, mBC, mCA, ); + if (nextFaceExcluded) nextFaceExcluded.push(excl, excl, excl, excl); } else if (n === 1) { // ── 1-split: bisect the one marked edge → 2 sub-triangles ────────── if (sAB) { const m = getMidpoint(positions, normals, weights, midCache, a, b); nextIndices.push(a, m, c, m, b, c); + if (nextFaceExcluded) nextFaceExcluded.push(excl, excl); } else if (sBC) { const m = getMidpoint(positions, normals, weights, midCache, b, c); nextIndices.push(a, b, m, a, m, c); + if (nextFaceExcluded) nextFaceExcluded.push(excl, excl); } else { // sCA const m = getMidpoint(positions, normals, weights, midCache, c, a); nextIndices.push(a, b, m, m, b, c); + if (nextFaceExcluded) nextFaceExcluded.push(excl, excl); } } else { @@ -151,6 +183,7 @@ function subdividePass(positions, normals, weights, indices, maxEdgeLength, safe a, mBC, mCA, c, mCA, mBC, ); + if (nextFaceExcluded) nextFaceExcluded.push(excl, excl, excl); } else if (!sBC) { // sAB + sCA: fan from A const mAB = getMidpoint(positions, normals, weights, midCache, a, b); const mCA = getMidpoint(positions, normals, weights, midCache, c, a); @@ -159,6 +192,7 @@ function subdividePass(positions, normals, weights, indices, maxEdgeLength, safe mAB, b, c, mAB, c, mCA, ); + if (nextFaceExcluded) nextFaceExcluded.push(excl, excl, excl); } else { // sAB + sBC: fan from B const mAB = getMidpoint(positions, normals, weights, midCache, a, b); const mBC = getMidpoint(positions, normals, weights, midCache, b, c); @@ -167,11 +201,12 @@ function subdividePass(positions, normals, weights, indices, maxEdgeLength, safe a, mAB, mBC, a, mBC, c, ); + if (nextFaceExcluded) nextFaceExcluded.push(excl, excl, excl); } } } - return { newIndices: nextIndices, changed: true }; + return { newIndices: nextIndices, newFaceExcluded: nextFaceExcluded, changed: true }; } // ── Helpers ────────────────────────────────────────────────────────────────── diff --git a/js/viewer.js b/js/viewer.js index 9740652..442d400 100644 --- a/js/viewer.js +++ b/js/viewer.js @@ -262,7 +262,7 @@ export function getCurrentMesh() { return currentMesh; } * * @param {THREE.BufferGeometry|null} overlayGeo */ -export function setExclusionOverlay(overlayGeo) { +export function setExclusionOverlay(overlayGeo, color = 0xff6600, opacity = 1.0) { if (exclusionMesh) { scene.remove(exclusionMesh); exclusionMesh.geometry.dispose(); @@ -272,9 +272,11 @@ export function setExclusionOverlay(overlayGeo) { if (!overlayGeo || overlayGeo.attributes.position.count === 0) return; exclusionMesh = new THREE.Mesh( overlayGeo, - new THREE.MeshBasicMaterial({ - color: 0xff6600, + new THREE.MeshLambertMaterial({ + color, side: THREE.DoubleSide, + transparent: opacity < 1.0, + opacity, polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1, diff --git a/style.css b/style.css index 4fa9ac2..2bde550 100644 --- a/style.css +++ b/style.css @@ -454,6 +454,13 @@ input[type="number"].val:focus { outline: none; border-color: var(--accent); } margin-bottom: 10px; } +.excl-mode-row { + margin-bottom: 10px; +} +.excl-mode-row .excl-seg { + width: 100%; +} + .excl-tool-btn { flex: 1; display: flex; @@ -514,6 +521,13 @@ input[type="number"].val:focus { outline: none; border-color: var(--accent); } color: var(--accent); } +/* Green active state for the Include Only mode button */ +#excl-mode-include.active { + background: color-mix(in srgb, #00cc66 18%, var(--surface2)); + border-color: #00cc66; + color: #00cc66; +} + .excl-seg-btn:hover:not(.active) { background: var(--border); color: var(--text);