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.
This commit is contained in:
CNCKitchen
2026-03-21 09:42:08 +01:00
parent a5cb0e5671
commit 6723dcb7b0
5 changed files with 275 additions and 115 deletions
+9 -18
View File
@@ -213,9 +213,9 @@
</div>
</section>
<!-- Surface Mask -->
<!-- Mask Angles -->
<section class="panel-section">
<h2 data-i18n="sections.surfaceMask" data-i18n-title="tooltips.surfaceMask" title="0° = no masking. Surfaces within this angle of horizontal will not be textured.">Surface Mask</h2>
<h2 data-i18n="sections.maskAngles" data-i18n-title="tooltips.maskAngles" title="0° = no masking. Surfaces within this angle of horizontal will not be textured.">Mask Angles</h2>
<div class="form-row slider-row">
<label for="bottom-angle-limit" data-i18n="labels.bottomFaces" data-i18n-title="tooltips.bottomFaces" title="Suppress texture on downward-facing surfaces within this angle of horizontal">Bottom faces</label>
<input type="range" id="bottom-angle-limit" min="0" max="90" step="1" value="5" />
@@ -230,7 +230,7 @@
<!-- Surface Exclusions -->
<section class="panel-section">
<h2 id="excl-section-heading" data-i18n="sections.surfaceExclusions" data-i18n-title="tooltips.surfaceExclusions" title="Excluded surfaces appear orange and will not receive displacement during export.">Surface Exclusions</h2>
<h2 id="excl-section-heading" data-i18n="sections.surfaceMasking" data-i18n-title="tooltips.surfaceMasking" title="Mask surfaces to control which areas receive displacement.">Surface Masking</h2>
<p id="excl-hint" class="excl-hint" style="display:none"></p>
<!-- Mode toggle: Exclude / Include Only -->
@@ -265,16 +265,6 @@
</svg>
<span data-i18n="excl.toolFill">Fill</span>
</button>
<button id="excl-erase-toggle" class="excl-tool-btn" aria-pressed="false"
data-i18n-title="excl.toolEraseTitle"
title="Toggle: mark or erase mode">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m7 21-4.3-4.3c-1-1-1-2.5 0-3.4l9.6-9.6c1-1 2.5-1 3.4 0l5.6 5.6c1 1 1 2.5 0 3.4L13 21"/>
<path d="M22 21H7"/>
<path d="m5 11 9 9"/>
</svg>
<span data-i18n="excl.toolErase">Erase</span>
</button>
</div>
<!-- Brush type switcher (shown only when Brush is active) -->
@@ -282,15 +272,15 @@
<label data-i18n="labels.type">Type</label>
<div class="excl-seg">
<button id="excl-brush-single" class="excl-seg-btn active" data-i18n="brushType.single">Single</button>
<button id="excl-brush-radius-btn" class="excl-seg-btn" data-i18n="brushType.radius">Radius</button>
<button id="excl-brush-radius-btn" class="excl-seg-btn" data-i18n="brushType.circle">Circle</button>
</div>
</div>
<!-- Radius slider (shown when Brush + Radius) -->
<div id="excl-radius-row" class="form-row slider-row hidden">
<label for="excl-brush-radius-slider" data-i18n="labels.radius">Radius</label>
<input type="range" id="excl-brush-radius-slider" min="0.1" max="50" step="0.1" value="5" />
<input type="number" class="val" id="excl-brush-radius-val" value="5" min="0.1" max="50" step="0.1" />
<label for="excl-brush-radius-slider" data-i18n="labels.size">Size</label>
<input type="range" id="excl-brush-radius-slider" min="0.2" max="100" step="0.2" value="10" />
<input type="number" class="val" id="excl-brush-radius-val" value="10" min="0.2" max="100" step="0.2" />
</div>
<!-- Bucket threshold (shown when Fill is active) -->
@@ -302,7 +292,8 @@
<!-- Footer: count + clear -->
<div class="excl-footer">
<span id="excl-count" class="excl-count">0 faces excluded</span>
<span id="excl-count" class="excl-count">0 faces masked</span>
<span id="excl-shift-hint" class="excl-shift-hint" data-i18n="excl.shiftHint">Hold Shift to erase</span>
<button id="excl-clear-btn" class="excl-clear-btn" data-i18n="ui.clearAll">Clear All</button>
</div>
</section>
+26 -28
View File
@@ -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
+76 -26
View File
@@ -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
+151 -43
View File
@@ -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 ────────────────────────────────────────────────────
+13
View File
@@ -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);