mirror of
https://github.com/CNCKitchen/stlTexturizer.git
synced 2026-04-07 22:11:32 +00:00
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:
+9
-18
@@ -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
@@ -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
@@ -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
@@ -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 ────────────────────────────────────────────────────
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user