diff --git a/index.html b/index.html
index 9aa6830..16e8cb6 100644
--- a/index.html
+++ b/index.html
@@ -213,9 +213,9 @@
-
+
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);