From 6723dcb7b0e2e72eacdd0f49ce72663dc18b3eea Mon Sep 17 00:00:00 2001 From: CNCKitchen Date: Sat, 21 Mar 2026 09:42:08 +0100 Subject: [PATCH] Refactor surface masking and exclusion features - Renamed "Surface Mask" section to "Mask Angles" for clarity in index.html. - Updated translation keys and tooltips to reflect the new terminology in i18n.js. - Removed the erase toggle button from the exclusion panel and implemented Shift key functionality to toggle erase mode in main.js. - Adjusted brush radius handling to improve user experience and updated related UI elements in index.html. - Enhanced the subdivision process to track original face IDs for better masking accuracy in subdivision.js. - Added CSS styles for new UI elements and improved layout in style.css. --- index.html | 27 +++---- js/i18n.js | 54 +++++++------ js/main.js | 102 +++++++++++++++++------- js/subdivision.js | 194 ++++++++++++++++++++++++++++++++++++---------- style.css | 13 ++++ 5 files changed, 275 insertions(+), 115 deletions(-) diff --git a/index.html b/index.html index 9aa6830..16e8cb6 100644 --- a/index.html +++ b/index.html @@ -213,9 +213,9 @@ - +
-

Surface Mask ⓘ

+

Mask Angles ⓘ

@@ -230,7 +230,7 @@
-

Surface Exclusions ⓘ

+

Surface Masking ⓘ

@@ -265,16 +265,6 @@ Fill -
@@ -282,15 +272,15 @@
- +
@@ -302,7 +292,8 @@
diff --git a/js/i18n.js b/js/i18n.js index dbf397b..b1d69fc 100644 --- a/js/i18n.js +++ b/js/i18n.js @@ -54,18 +54,18 @@ export const TRANSLATIONS = { 'labels.seamBlend': 'Seam Blend \u24d8', 'tooltips.seamBlend': 'Softens the hard seam where projection faces meet. Effective for Cubic and Cylindrical modes.', - // Surface mask section - 'sections.surfaceMask': 'Surface Mask \u24d8', - 'tooltips.surfaceMask': '0° = no masking. Surfaces within this angle of horizontal will not be textured.', + // Mask angles section + 'sections.maskAngles': 'Mask Angles \u24d8', + 'tooltips.maskAngles': '0° = no masking. Surfaces within this angle of horizontal will not be textured.', 'labels.bottomFaces': 'Bottom faces', 'tooltips.bottomFaces': 'Suppress texture on downward-facing surfaces within this angle of horizontal', 'labels.topFaces': 'Top faces', 'tooltips.topFaces': 'Suppress texture on upward-facing surfaces within this angle of horizontal', - // Surface exclusions section - 'sections.surfaceExclusions': 'Surface Exclusions \u24d8', + // Surface masking section + 'sections.surfaceMasking': 'Surface Masking \u24d8', 'sections.surfaceSelection': 'Surface Selection', - 'tooltips.surfaceExclusions': 'Excluded surfaces appear orange and will not receive displacement during export.', + 'tooltips.surfaceMasking': 'Mask surfaces to control which areas receive displacement.', 'tooltips.surfaceSelection': 'Selected surfaces appear green and will be the only ones to receive displacement during export.', 'excl.modeExclude': 'Exclude', 'excl.modeExcludeTitle': 'Exclude mode: painted surfaces will not receive texture displacement', @@ -75,21 +75,20 @@ export const TRANSLATIONS = { 'excl.toolBrushTitle': 'Brush: paint triangles to exclude', 'excl.toolFill': 'Fill', 'excl.toolFillTitle': 'Bucket fill: flood-fill surface up to a threshold angle', - 'excl.toolErase': 'Erase', - 'excl.toolEraseTitle': 'Toggle: mark or erase mode', + 'excl.shiftHint': 'Hold Shift to erase', 'labels.type': 'Type', 'brushType.single': 'Single', - 'brushType.radius': 'Radius', - 'labels.radius': 'Radius', + 'brushType.circle': 'Circle', + 'labels.size': 'Size', 'labels.maxAngle': 'Max angle', 'tooltips.maxAngle': 'Maximum dihedral angle between adjacent triangles for the fill to cross', 'ui.clearAll': 'Clear All', - 'excl.initExcluded': '0 faces excluded', - 'excl.faceExcluded': '{n} face excluded', - 'excl.facesExcluded': '{n} faces excluded', + 'excl.initExcluded': '0 faces masked', + 'excl.faceExcluded': '{n} face masked', + 'excl.facesExcluded': '{n} faces masked', 'excl.faceSelected': '{n} face selected', 'excl.facesSelected': '{n} faces selected', - 'excl.hintExclude': 'Excluded surfaces appear orange and will not receive displacement during export.', + 'excl.hintExclude': 'Masked surfaces appear orange and will not receive displacement during export.', 'excl.hintInclude': 'Selected surfaces appear green and will be the only ones to receive displacement during export.', // Symmetric displacement @@ -199,18 +198,18 @@ export const TRANSLATIONS = { 'labels.seamBlend': 'Nahtglättung \u24d8', 'tooltips.seamBlend': 'Glättet den scharfen Übergang zwischen Projektionsflächen. Wirksam für Kubische und Zylindrische Modi.', - // Surface mask section - 'sections.surfaceMask': 'Fl\u00e4chenmaskierung nach Winkel\u24d8', - 'tooltips.surfaceMask': '0° = keine Maskierung. Fl\u00e4chen innerhalb dieses Winkels zur Horizontalen werden nicht texturiert.', + // Winkelmaskierung + 'sections.maskAngles': 'Winkel maskieren \u24d8', + 'tooltips.maskAngles': '0° = keine Maskierung. Fl\u00e4chen innerhalb dieses Winkels zur Horizontalen werden nicht texturiert.', 'labels.bottomFaces': 'Unterseiten', 'tooltips.bottomFaces': 'Textur auf nach unten gerichteten Fl\u00e4chen innerhalb dieses Winkels zur Horizontalen unterdr\u00fccken', 'labels.topFaces': 'Oberseiten', 'tooltips.topFaces': 'Textur auf nach oben gerichteten Fl\u00e4chen innerhalb dieses Winkels zur Horizontalen unterdr\u00fccken', - // Surface exclusions section - 'sections.surfaceExclusions': 'Manuelle Fl\u00e4chenmaskierung \u24d8', + // Surface masking section + 'sections.surfaceMasking': 'Fl\u00e4chenmaskierung \u24d8', 'sections.surfaceSelection': 'Fl\u00e4chenauswahl', - 'tooltips.surfaceExclusions': 'Ausgeschlossene Fl\u00e4chen erscheinen orange und erhalten beim Export keine Verschiebung.', + 'tooltips.surfaceMasking': 'Fl\u00e4chen maskieren, um zu steuern, welche Bereiche Verschiebung erhalten.', 'tooltips.surfaceSelection': 'Ausgew\u00e4hlte Fl\u00e4chen erscheinen gr\u00fcn und sind die einzigen, die beim Export eine Verschiebung erhalten.', 'excl.modeExclude': 'Ausschlie\u00dfen', 'excl.modeExcludeTitle': 'Ausschlussmodus: bemalte Fl\u00e4chen erhalten keine Texturverschiebung', @@ -220,21 +219,20 @@ export const TRANSLATIONS = { 'excl.toolBrushTitle': 'Pinsel: Dreiecke zum Ausschlie\u00dfen einf\u00e4rben', 'excl.toolFill': 'F\u00fcllen', 'excl.toolFillTitle': 'F\u00fcllen: Fl\u00e4che bis zu einem Winkel fluten', - 'excl.toolErase': 'Radieren', - 'excl.toolEraseTitle': 'Umschalten: Markieren oder Radieren', + 'excl.shiftHint': 'Shift gedr\u00fcckt halten zum Radieren', 'labels.type': 'Typ', 'brushType.single': 'Einzeln', - 'brushType.radius': 'Radius', - 'labels.radius': 'Radius', + 'brushType.circle': 'Kreis', + 'labels.size': 'Gr\u00f6\u00dfe', 'labels.maxAngle': 'Max. Winkel', 'tooltips.maxAngle': 'Maximaler Di\u00e4dralwinkel zwischen angrenzenden Dreiecken f\u00fcr die F\u00fcllung', 'ui.clearAll': 'Alles l\u00f6schen', - 'excl.initExcluded': '0 Fl\u00e4chen ausgeschlossen', - 'excl.faceExcluded': '{n} Fl\u00e4che ausgeschlossen', - 'excl.facesExcluded': '{n} Fl\u00e4chen ausgeschlossen', + 'excl.initExcluded': '0 Fl\u00e4chen maskiert', + 'excl.faceExcluded': '{n} Fl\u00e4che maskiert', + 'excl.facesExcluded': '{n} Fl\u00e4chen maskiert', 'excl.faceSelected': '{n} Fl\u00e4che ausgew\u00e4hlt', 'excl.facesSelected': '{n} Fl\u00e4chen ausgew\u00e4hlt', - 'excl.hintExclude': 'Ausgeschlossene Fl\u00e4chen erscheinen orange und erhalten beim Export keine Verschiebung.', + 'excl.hintExclude': 'Maskierte Fl\u00e4chen erscheinen orange und erhalten beim Export keine Verschiebung.', 'excl.hintInclude': 'Ausgew\u00e4hlte Fl\u00e4chen erscheinen gr\u00fcn und sind die einzigen, die beim Export eine Verschiebung erhalten.', // Symmetric displacement diff --git a/js/main.js b/js/main.js index 552001b..0eb2cc2 100644 --- a/js/main.js +++ b/js/main.js @@ -115,7 +115,6 @@ const dispPreviewToggle = document.getElementById('displacement-preview'); // ── 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'); @@ -377,10 +376,12 @@ function wireEvents() { 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)); + // Shift key toggles erase mode + document.addEventListener('keydown', (e) => { + if (e.key === 'Shift' && exclusionTool) eraseMode = true; + }); + document.addEventListener('keyup', (e) => { + if (e.key === 'Shift') eraseMode = false; }); exclBrushSingleBtn.addEventListener('click', () => { @@ -401,13 +402,14 @@ function wireEvents() { }); exclBrushRadiusSlider.addEventListener('input', () => { - brushRadius = parseFloat(exclBrushRadiusSlider.value); - exclBrushRadiusVal.value = brushRadius; + brushRadius = parseFloat(exclBrushRadiusSlider.value) / 2; + exclBrushRadiusVal.value = parseFloat(exclBrushRadiusSlider.value); }); exclBrushRadiusVal.addEventListener('change', () => { - brushRadius = Math.max(0.1, Math.min(50, parseFloat(exclBrushRadiusVal.value) || 5)); - exclBrushRadiusSlider.value = brushRadius; - exclBrushRadiusVal.value = brushRadius; + let diam = Math.max(0.2, Math.min(100, parseFloat(exclBrushRadiusVal.value) || 10)); + brushRadius = diam / 2; + exclBrushRadiusSlider.value = diam; + exclBrushRadiusVal.value = diam; }); exclThresholdSlider.addEventListener('input', () => { @@ -433,11 +435,11 @@ function wireEvents() { // ── 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') { + e.preventDefault(); + _lastHoverTriIdx = -1; + setHoverPreview(null); const triIdx = pickTriangle(e); if (triIdx >= 0) { const filled = bucketFill(triIdx, triangleAdjacency, bucketThreshold); @@ -445,13 +447,18 @@ function wireEvents() { 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 { + // Brush mode: only start painting if we actually hit the mesh + const triIdx = pickTriangle(e); + if (triIdx < 0) return; // miss → let OrbitControls handle the drag + e.preventDefault(); + getControls().enabled = false; + isPainting = true; + _lastHoverTriIdx = -1; + setHoverPreview(null); paintAt(e); } }); @@ -464,6 +471,9 @@ function wireEvents() { paintAt(e); return; } + if (!isPainting && exclusionTool === 'brush' && currentGeometry) { + updateBrushHover(e); + } if (!isPainting && exclusionTool === 'bucket' && currentGeometry) { updateBucketHover(e); } @@ -498,7 +508,8 @@ function setSelectionMode(include) { exclModeIncludeBtn.classList.toggle('active', selectionMode); exclModeExcludeBtn.setAttribute('aria-pressed', String(!selectionMode)); exclModeIncludeBtn.setAttribute('aria-pressed', String(selectionMode)); - exclSectionHeading.textContent = selectionMode ? t('sections.surfaceSelection') : t('sections.surfaceExclusions'); + if (exclusionTool) setExclusionTool(null); + exclSectionHeading.textContent = selectionMode ? t('sections.surfaceSelection') : t('sections.surfaceMasking'); exclHint.textContent = selectionMode ? t('excl.hintInclude') : t('excl.hintExclude'); @@ -510,6 +521,13 @@ function setSelectionMode(include) { function setExclusionTool(tool) { // Clicking the active tool toggles it off; passing null always deactivates exclusionTool = (exclusionTool === tool) ? null : tool; + + // Exit 3D displacement preview when a masking tool is activated + if (exclusionTool && settings.useDisplacement) { + settings.useDisplacement = false; + dispPreviewToggle.checked = false; + toggleDisplacementPreview(false); + } exclBrushBtn.classList.toggle('active', exclusionTool === 'brush'); exclBucketBtn.classList.toggle('active', exclusionTool === 'bucket'); // Show brush-type row only while brush is active @@ -588,7 +606,7 @@ function paintAt(e) { } if (brushIsRadius) { - const hitPt = hits[0].point; + const hitPt = hit.point; const triCount = triangleCentroids.length / 3; const r2 = brushRadius * brushRadius; for (let t = 0; t < triCount; t++) { @@ -637,9 +655,10 @@ function updateBrushCursor(e) { 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 frontHit = getFrontFaceHit(hits, mesh); + if (!frontHit) { brushCursorEl.style.display = 'none'; return; } - const hitPt = hits[0].point; + const hitPt = frontHit.point; const cam = getCamera(); // Offset the hit point by brushRadius along the camera's right axis @@ -668,6 +687,39 @@ function updateBrushCursor(e) { brushCursorEl.style.height = `${diam}px`; } +function updateBrushHover(e) { + const mesh = getCurrentMesh(); + if (!mesh) { setHoverPreview(null); return; } + _raycaster.setFromCamera(_canvasNDC(e), getCamera()); + const hits = _raycaster.intersectObject(mesh); + const hit = getFrontFaceHit(hits, mesh); + if (!hit) { _lastHoverTriIdx = -1; setHoverPreview(null); return; } + + let triIdx = hit.faceIndex; + if (dispPreviewGeometry && mesh.geometry === dispPreviewGeometry && dispPreviewParentMap) { + triIdx = dispPreviewParentMap[triIdx]; + } + if (triIdx === _lastHoverTriIdx) return; + _lastHoverTriIdx = triIdx; + + if (brushIsRadius) { + const hitPt = hit.point; + const triCount = triangleCentroids.length / 3; + const r2 = brushRadius * brushRadius; + const hovered = new Set(); + 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) hovered.add(t); + } + setHoverPreview(buildExclusionOverlayGeo(currentGeometry, hovered)); + } else { + const hovered = new Set([triIdx]); + setHoverPreview(buildExclusionOverlayGeo(currentGeometry, hovered)); + } +} + function updateBucketHover(e) { const triIdx = pickTriangle(e); if (triIdx === _lastHoverTriIdx) return; // unchanged — skip expensive BFS @@ -749,7 +801,6 @@ function loadDefaultCube() { 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'); @@ -825,7 +876,6 @@ async function handleSTL(file) { 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'); @@ -1228,8 +1278,8 @@ async function toggleDisplacementPreview(enable) { await yieldFrame(); - const { geometry: subdivided } = await subdivide( - currentGeometry, previewEdge, null, null + const { geometry: subdivided, faceParentId } = await subdivide( + currentGeometry, previewEdge, null, null, { fast: true } ); addSmoothNormals(subdivided); @@ -1239,8 +1289,8 @@ async function toggleDisplacementPreview(enable) { if (dispPreviewGeometry) dispPreviewGeometry.dispose(); dispPreviewGeometry = subdivided; - // Build mapping from subdivided faces → original faces for exclusion masking - dispPreviewParentMap = buildParentFaceMap(subdivided); + // Use the face parent IDs tracked through subdivision (O(n) instead of spatial search) + dispPreviewParentMap = faceParentId; updateFaceMask(subdivided); // Force material recreation so it binds the new geometry with smoothNormal diff --git a/js/subdivision.js b/js/subdivision.js index 68b85bf..465996b 100644 --- a/js/subdivision.js +++ b/js/subdivision.js @@ -19,7 +19,7 @@ const SAFETY_CAP = 10_000_000; // absolute OOM guard // ── Public entry point ─────────────────────────────────────────────────────── -export async function subdivide(geometry, maxEdgeLength, onProgress, faceWeights = null) { +export async function subdivide(geometry, maxEdgeLength, onProgress, faceWeights = null, { fast = false } = {}) { // 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). @@ -33,13 +33,25 @@ export async function subdivide(geometry, maxEdgeLength, onProgress, faceWeights } } - const { positions, normals, weights, indices } = toIndexed(geometry, faceWeights); + // Fast mode (preview): simple position-merge, index-based edge keys. + // Accurate mode (export): cluster-based sharp-edge splitting + canonIdx. + const indexed = fast + ? toIndexedFast(geometry, faceWeights) + : toIndexed(geometry, faceWeights); + const { positions, normals, weights, indices } = indexed; + const canonIdx = indexed.canonIdx || null; + const posCanonMap = indexed.posCanonMap || null; const maxIterations = 12; let currentIndices = indices; let currentFaceExcluded = initialFaceExcluded; let safetyCapHit = false; + // Track which original face each subdivided face descends from. + const initialTriCount = indices.length / 3; + let currentFaceParentId = new Array(initialTriCount); + for (let i = 0; i < initialTriCount; i++) currentFaceParentId[i] = i; + for (let iter = 0; iter < maxIterations; iter++) { const triCount = currentIndices.length / 3; if (triCount >= SAFETY_CAP) { @@ -47,11 +59,13 @@ export async function subdivide(geometry, maxEdgeLength, onProgress, faceWeights break; } - const { newIndices, newFaceExcluded, changed } = subdividePass( - positions, normals, weights, currentIndices, maxEdgeLength, SAFETY_CAP, currentFaceExcluded + const { newIndices, newFaceExcluded, newFaceParentId, changed } = subdividePass( + positions, normals, weights, currentIndices, maxEdgeLength, SAFETY_CAP, currentFaceExcluded, + canonIdx, posCanonMap, currentFaceParentId ); currentIndices = newIndices; if (newFaceExcluded) currentFaceExcluded = newFaceExcluded; + if (newFaceParentId) currentFaceParentId = newFaceParentId; if (newIndices.length / 3 >= SAFETY_CAP) safetyCapHit = true; @@ -60,7 +74,11 @@ export async function subdivide(geometry, maxEdgeLength, onProgress, faceWeights if (!changed || safetyCapHit) break; } - return { geometry: toNonIndexed(positions, normals, weights, currentIndices, currentFaceExcluded), safetyCapHit }; + return { + geometry: toNonIndexed(positions, normals, weights, currentIndices, currentFaceExcluded), + safetyCapHit, + faceParentId: new Int32Array(currentFaceParentId), + }; } // ── One subdivision pass ────────────────────────────────────────────────────── @@ -84,20 +102,16 @@ export async function subdivide(geometry, maxEdgeLength, onProgress, faceWeights // 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, faceExcluded = null) { +function subdividePass(positions, normals, weights, indices, maxEdgeLength, safetyCap, faceExcluded = null, canonIdx = null, posCanonMap = null, faceParentId = null) { const maxSq = maxEdgeLength * maxEdgeLength; const midCache = new Map(); - // Position-based edge key for split detection. toIndexed() splits indexed - // vertices at sharp dihedral edges (>30°), so two faces sharing a geometric - // edge may reference different index pairs. Using quantised positions as - // the key guarantees both sides see the same split decision, preventing - // T-junctions at the boundary between textured and angle-masked faces. - const _posEdgeKey = (a, b) => { - const ka = `${Math.round(positions[a*3]*QUANTISE)}_${Math.round(positions[a*3+1]*QUANTISE)}_${Math.round(positions[a*3+2]*QUANTISE)}`; - const kb = `${Math.round(positions[b*3]*QUANTISE)}_${Math.round(positions[b*3+1]*QUANTISE)}_${Math.round(positions[b*3+2]*QUANTISE)}`; - return ka < kb ? `${ka}|${kb}` : `${kb}|${ka}`; - }; + // When canonIdx is available (accurate/export mode), use position-canonical + // edge keys so split-vertex faces on both sides of a sharp edge see the same + // split decision. Otherwise (fast/preview mode) use simple index-based keys. + const _edgeKey = canonIdx + ? (a, b) => { const ca = canonIdx[a], cb = canonIdx[b]; return ca < cb ? `${ca}:${cb}` : `${cb}:${ca}`; } + : (a, b) => a < b ? `${a}:${b}` : `${b}:${a}`; // ── Step 1: globally mark edges that need splitting ───────────────────── // Excluded triangles do NOT proactively mark their own edges – their @@ -108,16 +122,17 @@ function subdividePass(positions, normals, weights, indices, maxEdgeLength, safe 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(_posEdgeKey(a, b)); - if (edgeLenSq(positions, b, c) > maxSq) splitEdges.add(_posEdgeKey(b, c)); - if (edgeLenSq(positions, c, a) > maxSq) splitEdges.add(_posEdgeKey(c, a)); + 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, newFaceExcluded: faceExcluded, changed: false }; + if (splitEdges.size === 0) return { newIndices: indices, newFaceExcluded: faceExcluded, newFaceParentId: faceParentId, changed: false }; // ── Step 2: rebuild index list ─────────────────────────────────────────── const nextIndices = []; const nextFaceExcluded = faceExcluded ? [] : null; + const nextFaceParentId = faceParentId ? [] : null; for (let t = 0; t < indices.length; t += 3) { // Safety cap: stop splitting, carry remaining triangles as-is @@ -127,21 +142,26 @@ function subdividePass(positions, normals, weights, indices, maxEdgeLength, safe if (nextFaceExcluded && faceExcluded) { for (let r = t / 3; r < indices.length / 3; r++) nextFaceExcluded.push(faceExcluded[r]); } + if (nextFaceParentId && faceParentId) { + for (let r = t / 3; r < indices.length / 3; r++) nextFaceParentId.push(faceParentId[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(_posEdgeKey(a, b)); - const sBC = splitEdges.has(_posEdgeKey(b, c)); - const sCA = splitEdges.has(_posEdgeKey(c, a)); + const pid = faceParentId ? faceParentId[fIdx] : 0; + const sAB = splitEdges.has(_edgeKey(a, b)); + const sBC = splitEdges.has(_edgeKey(b, c)); + const sCA = splitEdges.has(_edgeKey(c, a)); const n = (sAB ? 1 : 0) + (sBC ? 1 : 0) + (sCA ? 1 : 0); if (n === 0) { // ── 0-split: keep triangle ───────────────────────────────────────── nextIndices.push(a, b, c); if (nextFaceExcluded) nextFaceExcluded.push(excl); + if (nextFaceParentId) nextFaceParentId.push(pid); } else if (n === 3) { // ── 3-split: 1→4 regular midpoint subdivision ────────────────────── @@ -152,9 +172,9 @@ function subdividePass(positions, normals, weights, indices, maxEdgeLength, safe // / \ / \ // c─mBC───b // - 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); + const mAB = getMidpoint(positions, normals, weights, midCache, a, b, canonIdx, posCanonMap); + const mBC = getMidpoint(positions, normals, weights, midCache, b, c, canonIdx, posCanonMap); + const mCA = getMidpoint(positions, normals, weights, midCache, c, a, canonIdx, posCanonMap); nextIndices.push( a, mAB, mCA, mAB, b, mBC, @@ -162,21 +182,25 @@ function subdividePass(positions, normals, weights, indices, maxEdgeLength, safe mAB, mBC, mCA, ); if (nextFaceExcluded) nextFaceExcluded.push(excl, excl, excl, excl); + if (nextFaceParentId) nextFaceParentId.push(pid, pid, pid, pid); } 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); + const m = getMidpoint(positions, normals, weights, midCache, a, b, canonIdx, posCanonMap); nextIndices.push(a, m, c, m, b, c); if (nextFaceExcluded) nextFaceExcluded.push(excl, excl); + if (nextFaceParentId) nextFaceParentId.push(pid, pid); } else if (sBC) { - const m = getMidpoint(positions, normals, weights, midCache, b, c); + const m = getMidpoint(positions, normals, weights, midCache, b, c, canonIdx, posCanonMap); nextIndices.push(a, b, m, a, m, c); if (nextFaceExcluded) nextFaceExcluded.push(excl, excl); + if (nextFaceParentId) nextFaceParentId.push(pid, pid); } else { // sCA - const m = getMidpoint(positions, normals, weights, midCache, c, a); + const m = getMidpoint(positions, normals, weights, midCache, c, a, canonIdx, posCanonMap); nextIndices.push(a, b, m, m, b, c); if (nextFaceExcluded) nextFaceExcluded.push(excl, excl); + if (nextFaceParentId) nextFaceParentId.push(pid, pid); } } else { @@ -188,37 +212,40 @@ function subdividePass(positions, normals, weights, indices, maxEdgeLength, safe // opposite vertices, preserving consistent CCW winding throughout. if (!sAB) { // sBC + sCA: fan from C - const mBC = getMidpoint(positions, normals, weights, midCache, b, c); - const mCA = getMidpoint(positions, normals, weights, midCache, c, a); + const mBC = getMidpoint(positions, normals, weights, midCache, b, c, canonIdx, posCanonMap); + const mCA = getMidpoint(positions, normals, weights, midCache, c, a, canonIdx, posCanonMap); nextIndices.push( a, b, mBC, a, mBC, mCA, c, mCA, mBC, ); if (nextFaceExcluded) nextFaceExcluded.push(excl, excl, excl); + if (nextFaceParentId) nextFaceParentId.push(pid, pid, pid); } 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); + const mAB = getMidpoint(positions, normals, weights, midCache, a, b, canonIdx, posCanonMap); + const mCA = getMidpoint(positions, normals, weights, midCache, c, a, canonIdx, posCanonMap); nextIndices.push( a, mAB, mCA, mAB, b, c, mAB, c, mCA, ); if (nextFaceExcluded) nextFaceExcluded.push(excl, excl, excl); + if (nextFaceParentId) nextFaceParentId.push(pid, pid, pid); } else { // sAB + sBC: fan from B - const mAB = getMidpoint(positions, normals, weights, midCache, a, b); - const mBC = getMidpoint(positions, normals, weights, midCache, b, c); + const mAB = getMidpoint(positions, normals, weights, midCache, a, b, canonIdx, posCanonMap); + const mBC = getMidpoint(positions, normals, weights, midCache, b, c, canonIdx, posCanonMap); nextIndices.push( b, mBC, mAB, a, mAB, mBC, a, mBC, c, ); if (nextFaceExcluded) nextFaceExcluded.push(excl, excl, excl); + if (nextFaceParentId) nextFaceParentId.push(pid, pid, pid); } } } - return { newIndices: nextIndices, newFaceExcluded: nextFaceExcluded, changed: true }; + return { newIndices: nextIndices, newFaceExcluded: nextFaceExcluded, newFaceParentId: nextFaceParentId, changed: true }; } // ── Helpers ────────────────────────────────────────────────────────────────── @@ -235,7 +262,7 @@ function edgeLenSq(pos, a, b) { return dx*dx + dy*dy + dz*dz; } -function getMidpoint(positions, normals, weights, cache, a, b) { +function getMidpoint(positions, normals, weights, cache, a, b, canonIdx, posCanonMap) { const key = a < b ? `${a}:${b}` : `${b}:${a}`; if (cache.has(key)) return cache.get(key); @@ -253,15 +280,80 @@ function getMidpoint(positions, normals, weights, 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); + + // Maintain canonIdx when in accurate (export) mode. + if (canonIdx) { + const pk = `${Math.round(mx * QUANTISE)}_${Math.round(my * QUANTISE)}_${Math.round(mz * QUANTISE)}`; + let cid = posCanonMap.get(pk); + if (cid === undefined) { + cid = idx; + posCanonMap.set(pk, cid); + } + canonIdx.push(cid); + } + cache.set(key, idx); return idx; } -// ── Non-indexed → indexed conversion ──────────────────────────────────────── +// ── Fast non-indexed → indexed (preview path) ────────────────────────────── +// Simple position-only merge — no cluster detection, no sharp-edge splitting. +// Much faster than toIndexed() on high-poly meshes like the 3DBenchy. + +function toIndexedFast(geometry, nonIndexedWeights = null) { + const posAttr = geometry.attributes.position; + const nrmAttr = geometry.attributes.normal; + const positions = []; + const normals = []; + const normalSums = []; + const weights = nonIndexedWeights ? [] : null; + const indices = []; + const vertMap = new Map(); + + const n = posAttr.count; + for (let i = 0; i < n; i++) { + const px = posAttr.getX(i); + const py = posAttr.getY(i); + const pz = posAttr.getZ(i); + const nx_ = nrmAttr ? nrmAttr.getX(i) : 0; + const ny_ = nrmAttr ? nrmAttr.getY(i) : 0; + const nz_ = nrmAttr ? nrmAttr.getZ(i) : 1; + + const key = `${Math.round(px * QUANTISE)}_${Math.round(py * QUANTISE)}_${Math.round(pz * QUANTISE)}`; + let idx = vertMap.get(key); + if (idx === undefined) { + idx = positions.length / 3; + positions.push(px, py, pz); + normals.push(nx_, ny_, nz_); + normalSums.push(nx_, ny_, nz_); + if (weights) weights.push(nonIndexedWeights[i]); + vertMap.set(key, idx); + } else { + normalSums[idx * 3] += nx_; + normalSums[idx * 3 + 1] += ny_; + normalSums[idx * 3 + 2] += nz_; + if (weights && nonIndexedWeights[i] > weights[idx]) { + weights[idx] = nonIndexedWeights[i]; + } + } + indices.push(idx); + } + + for (let i = 0; i < positions.length / 3; i++) { + const nx = normalSums[i * 3]; + const ny = normalSums[i * 3 + 1]; + const nz = normalSums[i * 3 + 2]; + const len = Math.sqrt(nx * nx + ny * ny + nz * nz) || 1; + normals[i * 3] = nx / len; + normals[i * 3 + 1] = ny / len; + normals[i * 3 + 2] = nz / len; + } + + return { positions, normals, weights, indices }; +} + +// ── Non-indexed → indexed conversion (export path) ────────────────────────── // nonIndexedWeights: optional Float32Array(vertexCount) where vertex i has // weight = 1.0 if its triangle (floor(i/3)) is user-excluded, else 0. @@ -308,7 +400,9 @@ function toIndexed(geometry, nonIndexedWeights = null) { const normalSums = []; const weights = nonIndexedWeights ? [] : null; const indices = []; - const vertMap = new Map(); // posKey → [{idx, fnU: [x,y,z]}] + const canonIdx = []; // vertex idx → canonical position ID + const posCanonMap = new Map(); // posKey → first vertex idx at that position + const vertMap = new Map(); // posKey → [{idx, fnU: [x,y,z]}] for (let i = 0; i < n; i++) { const px = posAttr.getX(i); @@ -318,6 +412,7 @@ function toIndexed(geometry, nonIndexedWeights = null) { const fnRx = faceNrmRaw[i*3], fnRy = faceNrmRaw[i*3+1], fnRz = faceNrmRaw[i*3+2]; const key = `${Math.round(px * QUANTISE)}_${Math.round(py * QUANTISE)}_${Math.round(pz * QUANTISE)}`; + let canonId = posCanonMap.get(key); const clusters = vertMap.get(key); if (clusters) { let matched = false; @@ -332,6 +427,15 @@ function toIndexed(geometry, nonIndexedWeights = null) { if (weights && nonIndexedWeights[i] > weights[idx]) { weights[idx] = nonIndexedWeights[i]; } + // Update the cluster representative to the running average direction + // so gradual curvature on smooth surfaces (benchy hull, cylinders) + // stays in one cluster instead of fragmenting when faces far from the + // seed happen to exceed 30° from the seed's fixed normal. + cl.fnU[0] += fnUx; + cl.fnU[1] += fnUy; + cl.fnU[2] += fnUz; + const rl = Math.sqrt(cl.fnU[0]*cl.fnU[0] + cl.fnU[1]*cl.fnU[1] + cl.fnU[2]*cl.fnU[2]) || 1; + cl.fnU[0] /= rl; cl.fnU[1] /= rl; cl.fnU[2] /= rl; indices.push(idx); matched = true; break; @@ -344,6 +448,7 @@ function toIndexed(geometry, nonIndexedWeights = null) { normals.push(fnRx, fnRy, fnRz); normalSums.push(fnRx, fnRy, fnRz); if (weights) weights.push(nonIndexedWeights[i]); + canonIdx.push(canonId); // same canonical position ID clusters.push({idx, fnU: [fnUx, fnUy, fnUz]}); indices.push(idx); } @@ -353,6 +458,9 @@ function toIndexed(geometry, nonIndexedWeights = null) { normals.push(fnRx, fnRy, fnRz); normalSums.push(fnRx, fnRy, fnRz); if (weights) weights.push(nonIndexedWeights[i]); + canonId = idx; // first vertex at this position is canonical + posCanonMap.set(key, canonId); + canonIdx.push(canonId); vertMap.set(key, [{idx, fnU: [fnUx, fnUy, fnUz]}]); indices.push(idx); } @@ -368,7 +476,7 @@ function toIndexed(geometry, nonIndexedWeights = null) { normals[i * 3 + 2] = nz / len; } - return { positions, normals, weights, indices }; + return { positions, normals, weights, indices, canonIdx, posCanonMap }; } // ── Indexed → non-indexed ──────────────────────────────────────────────────── diff --git a/style.css b/style.css index fb3bf3a..3fb948d 100644 --- a/style.css +++ b/style.css @@ -671,6 +671,13 @@ input[type="number"].val:focus { outline: none; border-color: var(--accent); } color: var(--accent); } +.excl-shift-hint { + font-size: 10px; + color: var(--text-muted); + margin: -4px 0 8px; + text-align: center; +} + /* Segmented button group (Single / Radius) */ .excl-seg { display: flex; @@ -720,9 +727,15 @@ input[type="number"].val:focus { outline: none; border-color: var(--accent); } display: flex; align-items: center; justify-content: space-between; + flex-wrap: wrap; + gap: 4px; margin-top: 10px; } +.excl-footer .excl-shift-hint { + margin: 0; +} + .excl-count { font-size: 11px; color: var(--text-muted);