feat: add surface exclusions panel and functionality

- Introduced a new section in the UI for surface exclusions, allowing users to exclude triangles from displacement using brush and bucket fill tools.
- Implemented brush type switching (single and radius) and radius control for the brush tool.
- Added functionality for bucket fill with a threshold angle to control the fill area.
- Integrated exclusion weights into the displacement algorithm to ensure excluded faces are handled correctly during subdivision.
- Created adjacency and centroid calculations for triangles to support the bucket fill operation.
- Developed overlay geometries for visual feedback on excluded faces and hover previews.
- Enhanced the CSS for the new exclusion tools and their layout in the UI.
This commit is contained in:
CNCKitchen
2026-03-17 14:35:45 +01:00
parent f87b935b9a
commit 1d3e756245
7 changed files with 730 additions and 32 deletions
+61
View File
@@ -153,6 +153,67 @@
<p class="hint">0° = no masking. Surfaces within this angle of horizontal will not be textured.</p>
</section>
<!-- Surface Exclusions -->
<section class="panel-section">
<h2>Surface Exclusions</h2>
<!-- Tool buttons -->
<div class="excl-tools">
<button id="excl-brush-btn" class="excl-tool-btn" title="Brush: paint triangles to exclude" aria-pressed="false">
<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="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h2c1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V20c0 1 0 1 1 1z"/>
<path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h2c1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V20c0 1 0 1 1 1z"/>
</svg>
Brush
</button>
<button id="excl-bucket-btn" class="excl-tool-btn" title="Bucket fill: flood-fill surface up to a threshold angle" aria-pressed="false">
<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="M19 11V4a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2h9"/>
<path d="M13 17h8m-4-4 4 4-4 4"/>
</svg>
Fill
</button>
<button id="excl-erase-toggle" class="excl-tool-btn" title="Toggle: mark or erase mode" aria-pressed="false">
<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>
Erase
</button>
</div>
<!-- Brush type switcher (shown only when Brush is active) -->
<div id="excl-brush-type-row" class="form-row hidden">
<label>Type</label>
<div class="excl-seg">
<button id="excl-brush-single" class="excl-seg-btn active">Single</button>
<button id="excl-brush-radius-btn" class="excl-seg-btn">Radius</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">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" />
</div>
<!-- Bucket threshold (shown when Fill is active) -->
<div id="excl-threshold-row" class="form-row slider-row hidden">
<label for="excl-threshold-slider" title="Maximum dihedral angle between adjacent triangles for the fill to cross">Max angle</label>
<input type="range" id="excl-threshold-slider" min="0" max="180" step="1" value="30" />
<input type="number" class="val" id="excl-threshold-val" value="30" min="0" max="180" step="1" />
</div>
<!-- Footer: count + clear -->
<div class="excl-footer">
<span id="excl-count" class="excl-count">0 faces excluded</span>
<button id="excl-clear-btn" class="excl-clear-btn">Clear All</button>
</div>
<p class="hint">Excluded surfaces appear orange and will not receive displacement during export.</p>
</section>
<!-- Export -->
<section class="panel-section">
<h2>Export</h2>
+15 -2
View File
@@ -63,6 +63,10 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett
// vertices get a smooth displacement blend instead of a hard on/off cutoff.
const maskedFracMap = new Map();
// Optional per-vertex exclusion weights threaded through by subdivision.js.
// A face's user-exclusion flag = average of its 3 vertex weights > 0.5.
const ewAttr = geometry.attributes.excludeWeight || null;
for (let t = 0; t < count; t += 3) {
vA.fromBufferAttribute(posAttr, t);
vB.fromBufferAttribute(posAttr, t + 1);
@@ -71,13 +75,22 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett
edge2.subVectors(vC, vA);
faceNrm.crossVectors(edge1, edge2); // length = 2× triangle area → natural area weighting
// Determine if this face is masked (used to build the per-vertex blend weight)
// Determine if this face is masked (used to build the per-vertex blend weight).
// Combines angle-based masking with optional user-painted exclusion.
const faceArea = faceNrm.length(); // ∝ 2× triangle area
const faceNzNorm = faceArea > 1e-12 ? faceNrm.z / faceArea : 0; // unit-normal Z component
const faceAngle = Math.acos(Math.abs(faceNzNorm)) * (180 / Math.PI);
const faceMasked = faceNzNorm < 0
const angleMasked = faceNzNorm < 0
? (settings.bottomAngleLimit > 0 && faceAngle <= settings.bottomAngleLimit)
: (settings.topAngleLimit > 0 && faceAngle <= settings.topAngleLimit);
// Threshold >0.99 (not 0.5) prevents shared-vertex MAX-propagation from
// accidentally marking adjacent faces as excluded on closed meshes (e.g. a
// cube): adjacent faces have 2/3 vertices at weight 1.0 → avg ≈ 0.67 which
// would wrongly trigger the old 0.5 threshold.
const userExcluded = ewAttr
? (ewAttr.getX(t) + ewAttr.getX(t + 1) + ewAttr.getX(t + 2)) / 3 > 0.99
: false;
const faceMasked = angleMasked || userExcluded;
for (let v = 0; v < 3; v++) {
tmpPos.fromBufferAttribute(posAttr, t + v);
+181
View File
@@ -0,0 +1,181 @@
/**
* exclusion.js — per-face exclusion masking
*
* Provides three capabilities:
* 1. buildAdjacency builds an inter-triangle adjacency list with dihedral
* angles and precomputes per-triangle centroids.
* 2. bucketFill BFS flood fill that respects a max dihedral-angle
* threshold (stops at "sharp" edges).
* 3. buildExclusionOverlayGeo compact geometry for the orange preview overlay.
* 4. buildFaceWeights per-vertex exclusion weights for the subdivision pass.
*/
import * as THREE from 'three';
const QUANT = 1e4;
const quantKey = (x, y, z) =>
`${Math.round(x * QUANT)}_${Math.round(y * QUANT)}_${Math.round(z * QUANT)}`;
// ── Adjacency & centroids ─────────────────────────────────────────────────────
/**
* Build inter-triangle adjacency data for a non-indexed BufferGeometry.
*
* @param {THREE.BufferGeometry} geometry non-indexed
* @returns {{
* adjacency: Map<number, Array<{neighbor:number, angle:number}>>,
* centroids: Float32Array (triCount × 3, world-space centroid per triangle)
* }}
*/
export function buildAdjacency(geometry) {
const posAttr = geometry.attributes.position;
const triCount = posAttr.count / 3;
// Pre-allocate face normals and centroids
const faceNormals = new Float32Array(triCount * 3);
const centroids = new Float32Array(triCount * 3);
const vA = new THREE.Vector3();
const vB = new THREE.Vector3();
const vC = new THREE.Vector3();
const e1 = new THREE.Vector3();
const e2 = new THREE.Vector3();
const fn = new THREE.Vector3();
for (let t = 0; t < triCount; t++) {
const i = t * 3;
vA.fromBufferAttribute(posAttr, i);
vB.fromBufferAttribute(posAttr, i + 1);
vC.fromBufferAttribute(posAttr, i + 2);
e1.subVectors(vB, vA);
e2.subVectors(vC, vA);
fn.crossVectors(e1, e2).normalize();
faceNormals[i] = fn.x;
faceNormals[i + 1] = fn.y;
faceNormals[i + 2] = fn.z;
centroids[i] = (vA.x + vB.x + vC.x) / 3;
centroids[i + 1] = (vA.y + vB.y + vC.y) / 3;
centroids[i + 2] = (vA.z + vB.z + vC.z) / 3;
}
// Build edge → triangle list (two triangles share an edge iff they share two
// vertex positions after quantization-based deduplication).
const edgeMap = new Map();
const makeEdgeKey = (ax, ay, az, bx, by, bz) => {
const ka = quantKey(ax, ay, az);
const kb = quantKey(bx, by, bz);
return ka < kb ? `${ka}|${kb}` : `${kb}|${ka}`;
};
for (let t = 0; t < triCount; t++) {
const i = t * 3;
vA.fromBufferAttribute(posAttr, i);
vB.fromBufferAttribute(posAttr, i + 1);
vC.fromBufferAttribute(posAttr, i + 2);
const ekAB = makeEdgeKey(vA.x, vA.y, vA.z, vB.x, vB.y, vB.z);
const ekBC = makeEdgeKey(vB.x, vB.y, vB.z, vC.x, vC.y, vC.z);
const ekCA = makeEdgeKey(vC.x, vC.y, vC.z, vA.x, vA.y, vA.z);
for (const ek of [ekAB, ekBC, ekCA]) {
const entry = edgeMap.get(ek);
if (entry) entry.push(t);
else edgeMap.set(ek, [t]);
}
}
// Convert edge map to adjacency list with per-edge dihedral angle
const adjacency = new Map();
for (let t = 0; t < triCount; t++) adjacency.set(t, []);
for (const [, tris] of edgeMap) {
if (tris.length !== 2) continue;
const [a, b] = tris;
const nAx = faceNormals[a * 3], nAy = faceNormals[a * 3 + 1], nAz = faceNormals[a * 3 + 2];
const nBx = faceNormals[b * 3], nBy = faceNormals[b * 3 + 1], nBz = faceNormals[b * 3 + 2];
const dot = Math.max(-1, Math.min(1, nAx * nBx + nAy * nBy + nAz * nBz));
const angleDeg = Math.acos(dot) * (180 / Math.PI);
adjacency.get(a).push({ neighbor: b, angle: angleDeg });
adjacency.get(b).push({ neighbor: a, angle: angleDeg });
}
return { adjacency, centroids };
}
// ── Bucket fill ───────────────────────────────────────────────────────────────
/**
* BFS flood fill starting from seedTriIdx.
* Spreads across edges whose dihedral angle ≤ thresholdDeg.
*
* @param {number} seedTriIdx
* @param {Map<number, Array<{neighbor:number, angle:number}>>} adjacency
* @param {number} thresholdDeg
* @returns {Set<number>} set of triangle indices in the filled region
*/
export function bucketFill(seedTriIdx, adjacency, thresholdDeg) {
const visited = new Set([seedTriIdx]);
const queue = [seedTriIdx];
while (queue.length > 0) {
const cur = queue.shift();
const neighbors = adjacency.get(cur);
if (!neighbors) continue;
for (const { neighbor, angle } of neighbors) {
if (!visited.has(neighbor) && angle <= thresholdDeg) {
visited.add(neighbor);
queue.push(neighbor);
}
}
}
return visited;
}
// ── Overlay geometry ──────────────────────────────────────────────────────────
/**
* Build a compact non-indexed BufferGeometry containing only the excluded
* triangles' positions. Used to drive the orange overlay mesh in the viewer.
*
* @param {THREE.BufferGeometry} geometry non-indexed source geometry
* @param {Set<number>} excludedFaces
* @returns {THREE.BufferGeometry}
*/
export function buildExclusionOverlayGeo(geometry, excludedFaces) {
const srcPos = geometry.attributes.position.array;
const outPos = new Float32Array(excludedFaces.size * 9); // 3 verts × 3 floats
let dst = 0;
for (const t of excludedFaces) {
const src = t * 9;
for (let i = 0; i < 9; i++) outPos[dst++] = srcPos[src + i];
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(outPos, 3));
return geo;
}
// ── Face-weight array for subdivision ────────────────────────────────────────
/**
* Build a per-non-indexed-vertex exclusion weight array.
* Vertex i (in the non-indexed buffer) belongs to triangle floor(i/3).
* Excluded triangles get weight 1.0, all others 0.0.
* subdivision.js threads these through edge splits via linear interpolation,
* producing smooth 0→1 transitions at exclusion boundaries.
*
* @param {THREE.BufferGeometry} geometry
* @param {Set<number>} excludedFaces
* @returns {Float32Array} length = geometry.attributes.position.count
*/
export function buildFaceWeights(geometry, excludedFaces) {
const count = geometry.attributes.position.count;
const weights = new Float32Array(count); // default 0 (included)
for (const t of excludedFaces) {
weights[t * 3] = 1.0;
weights[t * 3 + 1] = 1.0;
weights[t * 3 + 2] = 1.0;
}
return weights;
}
+258 -4
View File
@@ -1,4 +1,7 @@
import { initViewer, loadGeometry, setMeshMaterial, setWireframe } from './viewer.js';
import * as THREE from 'three';
import { initViewer, loadGeometry, setMeshMaterial, setWireframe,
getControls, getCamera, getCurrentMesh,
setExclusionOverlay, setHoverPreview } from './viewer.js';
import { loadSTLFile, computeBounds, getTriangleCount } from './stlLoader.js';
import { PRESETS, loadCustomTexture } from './presetTextures.js';
import { createPreviewMaterial, updateMaterial } from './previewMaterial.js';
@@ -6,6 +9,8 @@ import { subdivide } from './subdivision.js';
import { applyDisplacement } from './displacement.js';
import { decimate } from './decimation.js';
import { exportSTL } from './exporter.js';
import { buildAdjacency, bucketFill,
buildExclusionOverlayGeo, buildFaceWeights } from './exclusion.js';
// ── State ─────────────────────────────────────────────────────────────────────
@@ -15,6 +20,19 @@ let activeMapEntry = null; // { name, texture, imageData, width, height }
let previewMaterial = null;
let isExporting = false;
// ── Exclusion state ───────────────────────────────────────────────────────────
let excludedFaces = new Set(); // triangle indices in currentGeometry
let triangleAdjacency = null; // Map from buildAdjacency
let triangleCentroids = null; // Float32Array from buildAdjacency
let exclusionTool = null; // 'brush' | 'bucket' | null
let eraseMode = false;
let brushIsRadius = false;
let brushRadius = 5.0;
let bucketThreshold = 30;
let isPainting = false;
let _lastHoverTriIdx = -1; // last triangle index used for hover preview
const _raycaster = new THREE.Raycaster();
const settings = {
mappingMode: 6, // Cubic default
scaleU: 1.0,
@@ -69,6 +87,22 @@ const topAngleLimitSlider = document.getElementById('top-angle-limit');
const bottomAngleLimitVal = document.getElementById('bottom-angle-limit-val');
const topAngleLimitVal = document.getElementById('top-angle-limit-val');
// ── 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');
const exclRadiusRow = document.getElementById('excl-radius-row');
const exclBrushRadiusSlider = document.getElementById('excl-brush-radius-slider');
const exclBrushRadiusVal = document.getElementById('excl-brush-radius-val');
const exclThresholdRow = document.getElementById('excl-threshold-row');
const exclThresholdSlider = document.getElementById('excl-threshold-slider');
const exclThresholdVal = document.getElementById('excl-threshold-val');
const exclCount = document.getElementById('excl-count');
const exclClearBtn = document.getElementById('excl-clear-btn');
// ── Scale slider log helpers ──────────────────────────────────────────────────
// Slider stores 01000; actual scale spans 0.110 on a log axis.
// Middle position 500 → scale 1.0 (exact midpoint on log scale).
@@ -211,12 +245,202 @@ function wireEvents() {
// ── Wireframe ──
wireframeToggle.addEventListener('change', () => setWireframe(wireframeToggle.checked));
// ── Exclusion tool wiring ─────────────────────────────────────────────────
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));
});
exclBrushSingleBtn.addEventListener('click', () => {
brushIsRadius = false;
exclBrushSingleBtn.classList.add('active');
exclBrushRadiusBtn.classList.remove('active');
exclRadiusRow.classList.add('hidden');
});
exclBrushRadiusBtn.addEventListener('click', () => {
brushIsRadius = true;
exclBrushRadiusBtn.classList.add('active');
exclBrushSingleBtn.classList.remove('active');
if (exclusionTool === 'brush') exclRadiusRow.classList.remove('hidden');
});
exclBrushRadiusSlider.addEventListener('input', () => {
brushRadius = parseFloat(exclBrushRadiusSlider.value);
exclBrushRadiusVal.value = brushRadius;
});
exclBrushRadiusVal.addEventListener('change', () => {
brushRadius = Math.max(0.1, Math.min(50, parseFloat(exclBrushRadiusVal.value) || 5));
exclBrushRadiusSlider.value = brushRadius;
exclBrushRadiusVal.value = brushRadius;
});
exclThresholdSlider.addEventListener('input', () => {
bucketThreshold = parseFloat(exclThresholdSlider.value);
exclThresholdVal.value = bucketThreshold;
_lastHoverTriIdx = -1; // invalidate hover so next mousemove re-computes
});
exclThresholdVal.addEventListener('change', () => {
bucketThreshold = Math.max(0, Math.min(180, parseFloat(exclThresholdVal.value) || 30));
exclThresholdSlider.value = bucketThreshold;
exclThresholdVal.value = bucketThreshold;
_lastHoverTriIdx = -1;
});
exclClearBtn.addEventListener('click', () => {
excludedFaces = new Set();
refreshExclusionOverlay();
});
// ── 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') {
const triIdx = pickTriangle(e);
if (triIdx >= 0) {
const filled = bucketFill(triIdx, triangleAdjacency, bucketThreshold);
for (const t of filled) {
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 {
paintAt(e);
}
});
canvas.addEventListener('mousemove', (e) => {
if (isPainting && exclusionTool === 'brush') {
paintAt(e);
return;
}
if (!isPainting && exclusionTool === 'bucket' && currentGeometry) {
updateBucketHover(e);
}
});
canvas.addEventListener('mouseleave', () => {
_lastHoverTriIdx = -1;
setHoverPreview(null);
});
document.addEventListener('mouseup', () => {
if (!isPainting) return;
isPainting = false;
getControls().enabled = true;
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && exclusionTool) {
setExclusionTool(null);
}
});
}
// ── Exclusion helpers ─────────────────────────────────────────────────────────
function setExclusionTool(tool) {
// Clicking the active tool toggles it off; passing null always deactivates
exclusionTool = (exclusionTool === tool) ? null : tool;
exclBrushBtn.classList.toggle('active', exclusionTool === 'brush');
exclBucketBtn.classList.toggle('active', exclusionTool === 'bucket');
// Show brush-type row only while brush is active
exclBrushTypeRow.classList.toggle('hidden', exclusionTool !== 'brush');
// Show radius row only while brush + radius mode is active
exclRadiusRow.classList.toggle('hidden', !(exclusionTool === 'brush' && brushIsRadius));
// Show threshold row only while bucket is active
exclThresholdRow.classList.toggle('hidden', exclusionTool !== 'bucket');
canvas.style.cursor = exclusionTool ? 'crosshair' : '';
// Clear hover preview whenever the tool changes or is deactivated
_lastHoverTriIdx = -1;
setHoverPreview(null);
// Re-enable controls if tool was deactivated mid-paint
if (!exclusionTool) {
isPainting = false;
getControls().enabled = true;
}
}
function _canvasNDC(e) {
const rect = canvas.getBoundingClientRect();
return new THREE.Vector2(
((e.clientX - rect.left) / rect.width) * 2 - 1,
((e.clientY - rect.top) / rect.height) * -2 + 1,
);
}
function pickTriangle(e) {
const mesh = getCurrentMesh();
if (!mesh) return -1;
_raycaster.setFromCamera(_canvasNDC(e), getCamera());
const hits = _raycaster.intersectObject(mesh);
return hits.length > 0 ? hits[0].faceIndex : -1;
}
function paintAt(e) {
const mesh = getCurrentMesh();
if (!mesh) return;
_raycaster.setFromCamera(_canvasNDC(e), getCamera());
const hits = _raycaster.intersectObject(mesh);
if (hits.length === 0) return;
const triIdx = hits[0].faceIndex;
if (brushIsRadius) {
const hitPt = hits[0].point;
const triCount = triangleCentroids.length / 3;
const r2 = brushRadius * brushRadius;
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) {
if (eraseMode) excludedFaces.delete(t); else excludedFaces.add(t);
}
}
} else {
if (eraseMode) excludedFaces.delete(triIdx); else excludedFaces.add(triIdx);
}
refreshExclusionOverlay();
}
function refreshExclusionOverlay() {
if (!currentGeometry) return;
setExclusionOverlay(buildExclusionOverlayGeo(currentGeometry, excludedFaces));
const n = excludedFaces.size;
exclCount.textContent = `${n.toLocaleString()} face${n === 1 ? '' : 's'} excluded`;
}
function updateBucketHover(e) {
const triIdx = pickTriangle(e);
if (triIdx === _lastHoverTriIdx) return; // unchanged — skip expensive BFS
_lastHoverTriIdx = triIdx;
if (triIdx < 0 || !triangleAdjacency) {
setHoverPreview(null);
return;
}
const hovered = bucketFill(triIdx, triangleAdjacency, bucketThreshold);
setHoverPreview(buildExclusionOverlayGeo(currentGeometry, hovered));
}
// ── Slider helper ─────────────────────────────────────────────────────────────
let previewDebounce = null;
function linkSlider(slider, valInput, onChangeFn, livePreview = true) {
const isSpan = valInput.tagName === 'SPAN';
slider.addEventListener('input', () => {
@@ -277,6 +501,28 @@ async function handleSTL(file) {
loadGeometry(geometry);
dropHint.classList.add('hidden');
// Reset exclusion state for the new mesh
excludedFaces = new Set();
exclusionTool = null;
eraseMode = false;
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');
canvas.style.cursor = '';
setExclusionOverlay(null);
setHoverPreview(null);
_lastHoverTriIdx = -1;
exclCount.textContent = '0 faces excluded';
// Build adjacency data for brush/bucket tools (synchronous; fast enough for
// typical STL sizes processed by this tool)
const adjData = buildAdjacency(geometry);
triangleAdjacency = adjData.adjacency;
triangleCentroids = adjData.centroids;
// Reset scale & offset sliders so scale=1 = one tile covers the full bounding box
const resetVal = (slider, valEl, value) => {
slider.value = value;
@@ -346,9 +592,17 @@ async function handleExport() {
try {
setProgress(0.02, 'Subdividing mesh…');
// Build per-vertex exclusion weights if any faces are excluded.
// subdivision.js interpolates these through edge splits so the exclusion
// propagates correctly to all new vertices inside the excluded region.
const faceWeights = excludedFaces.size > 0
? buildFaceWeights(currentGeometry, excludedFaces)
: null;
const { geometry: subdivided, safetyCapHit } = await runAsync(() =>
subdivide(currentGeometry, settings.refineLength,
(p) => setProgress(0.02 + p * 0.35, 'Subdividing mesh…'))
(p) => setProgress(0.02 + p * 0.35, 'Subdividing mesh…'),
faceWeights)
);
const subTriCount = subdivided.attributes.position.count / 3;
+38 -21
View File
@@ -19,8 +19,8 @@ const SAFETY_CAP = 5_000_000; // absolute OOM guard
// ── Public entry point ───────────────────────────────────────────────────────
export function subdivide(geometry, maxEdgeLength, onProgress) {
const { positions, normals, indices } = toIndexed(geometry);
export function subdivide(geometry, maxEdgeLength, onProgress, faceWeights = null) {
const { positions, normals, weights, indices } = toIndexed(geometry, faceWeights);
const maxIterations = 12;
let currentIndices = indices;
@@ -34,7 +34,7 @@ export function subdivide(geometry, maxEdgeLength, onProgress) {
}
const { newIndices, changed } = subdividePass(
positions, normals, currentIndices, maxEdgeLength, SAFETY_CAP
positions, normals, weights, currentIndices, maxEdgeLength, SAFETY_CAP
);
currentIndices = newIndices;
@@ -44,7 +44,7 @@ export function subdivide(geometry, maxEdgeLength, onProgress) {
if (!changed || safetyCapHit) break;
}
return { geometry: toNonIndexed(positions, normals, currentIndices), safetyCapHit };
return { geometry: toNonIndexed(positions, normals, weights, currentIndices), safetyCapHit };
}
// ── One subdivision pass ──────────────────────────────────────────────────────
@@ -68,7 +68,7 @@ export function subdivide(geometry, maxEdgeLength, onProgress) {
// 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, indices, maxEdgeLength, safetyCap) {
function subdividePass(positions, normals, weights, indices, maxEdgeLength, safetyCap) {
const maxSq = maxEdgeLength * maxEdgeLength;
const midCache = new Map();
@@ -112,9 +112,9 @@ function subdividePass(positions, normals, indices, maxEdgeLength, safetyCap) {
// / \ / \
// c─mBC───b
//
const mAB = getMidpoint(positions, normals, midCache, a, b);
const mBC = getMidpoint(positions, normals, midCache, b, c);
const mCA = getMidpoint(positions, normals, midCache, c, a);
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);
nextIndices.push(
a, mAB, mCA,
mAB, b, mBC,
@@ -125,13 +125,13 @@ function subdividePass(positions, normals, indices, maxEdgeLength, safetyCap) {
} else if (n === 1) {
// ── 1-split: bisect the one marked edge → 2 sub-triangles ──────────
if (sAB) {
const m = getMidpoint(positions, normals, midCache, a, b);
const m = getMidpoint(positions, normals, weights, midCache, a, b);
nextIndices.push(a, m, c, m, b, c);
} else if (sBC) {
const m = getMidpoint(positions, normals, midCache, b, c);
const m = getMidpoint(positions, normals, weights, midCache, b, c);
nextIndices.push(a, b, m, a, m, c);
} else { // sCA
const m = getMidpoint(positions, normals, midCache, c, a);
const m = getMidpoint(positions, normals, weights, midCache, c, a);
nextIndices.push(a, b, m, m, b, c);
}
@@ -144,24 +144,24 @@ function subdividePass(positions, normals, indices, maxEdgeLength, safetyCap) {
// opposite vertices, preserving consistent CCW winding throughout.
if (!sAB) { // sBC + sCA: fan from C
const mBC = getMidpoint(positions, normals, midCache, b, c);
const mCA = getMidpoint(positions, normals, midCache, c, a);
const mBC = getMidpoint(positions, normals, weights, midCache, b, c);
const mCA = getMidpoint(positions, normals, weights, midCache, c, a);
nextIndices.push(
a, b, mBC,
a, mBC, mCA,
c, mCA, mBC,
);
} else if (!sBC) { // sAB + sCA: fan from A
const mAB = getMidpoint(positions, normals, midCache, a, b);
const mCA = getMidpoint(positions, normals, midCache, c, a);
const mAB = getMidpoint(positions, normals, weights, midCache, a, b);
const mCA = getMidpoint(positions, normals, weights, midCache, c, a);
nextIndices.push(
a, mAB, mCA,
mAB, b, c,
mAB, c, mCA,
);
} else { // sAB + sBC: fan from B
const mAB = getMidpoint(positions, normals, midCache, a, b);
const mBC = getMidpoint(positions, normals, midCache, b, c);
const mAB = getMidpoint(positions, normals, weights, midCache, a, b);
const mBC = getMidpoint(positions, normals, weights, midCache, b, c);
nextIndices.push(
b, mBC, mAB,
a, mAB, mBC,
@@ -188,7 +188,7 @@ function edgeLenSq(pos, a, b) {
return dx*dx + dy*dy + dz*dz;
}
function getMidpoint(positions, normals, cache, a, b) {
function getMidpoint(positions, normals, weights, cache, a, b) {
const key = a < b ? `${a}:${b}` : `${b}:${a}`;
if (cache.has(key)) return cache.get(key);
@@ -206,18 +206,27 @@ function getMidpoint(positions, normals, 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);
cache.set(key, idx);
return idx;
}
// ── Non-indexed → indexed conversion ────────────────────────────────────────
function toIndexed(geometry) {
// nonIndexedWeights: optional Float32Array(vertexCount) where vertex i has
// weight = 1.0 if its triangle (floor(i/3)) is user-excluded, else 0.
// When multiple original vertices map to the same indexed vertex, the MAX
// weight wins (conservative: any excluded face marks the shared vertex).
function toIndexed(geometry, nonIndexedWeights = null) {
const posAttr = geometry.attributes.position;
const nrmAttr = geometry.attributes.normal;
const positions = [];
const normals = [];
const weights = nonIndexedWeights ? [] : null;
const indices = [];
const vertMap = new Map();
@@ -236,20 +245,25 @@ function toIndexed(geometry) {
idx = positions.length / 3;
positions.push(px, py, pz);
normals.push(nx_, ny_, nz_);
if (weights) weights.push(nonIndexedWeights[i]);
vertMap.set(key, idx);
} else if (weights && nonIndexedWeights[i] > weights[idx]) {
// MAX: if any incident original face was excluded, the shared vertex is excluded
weights[idx] = nonIndexedWeights[i];
}
indices.push(idx);
}
return { positions, normals, indices };
return { positions, normals, weights, indices };
}
// ── Indexed → non-indexed ────────────────────────────────────────────────────
function toNonIndexed(positions, normals, indices) {
function toNonIndexed(positions, normals, weights, indices) {
const triCount = indices.length / 3;
const posArray = new Float32Array(triCount * 9);
const nrmArray = new Float32Array(triCount * 9);
const wgtArray = weights ? new Float32Array(triCount * 3) : null;
for (let t = 0; t < triCount; t++) {
for (let v = 0; v < 3; v++) {
@@ -261,11 +275,14 @@ function toNonIndexed(positions, normals, indices) {
nrmArray[t * 9 + v * 3] = normals[vidx * 3];
nrmArray[t * 9 + v * 3 + 1] = normals[vidx * 3 + 1];
nrmArray[t * 9 + v * 3 + 2] = normals[vidx * 3 + 2];
if (wgtArray) wgtArray[t * 3 + v] = weights[vidx];
}
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(posArray, 3));
geo.setAttribute('normal', new THREE.BufferAttribute(nrmArray, 3));
if (wgtArray) geo.setAttribute('excludeWeight', new THREE.BufferAttribute(wgtArray, 1));
return geo;
}
+64
View File
@@ -9,6 +9,8 @@ let currentMesh = null;
let axesGroup = null;
let wireframeLines = null; // LineSegments overlay, or null when hidden
let wireframeVisible = false;
let exclusionMesh = null; // flat orange overlay for user-excluded faces
let hoverMesh = null; // semi-transparent yellow bucket-fill preview
// Build a labelled coordinate axes indicator scaled to `size`.
// X = red, Y = green, Z = blue (up).
@@ -248,8 +250,70 @@ function fitCamera(sphere) {
export function getRenderer() { return renderer; }
export function getCamera() { return camera; }
export function getScene() { return scene; }
export function getControls() { return controls; }
export function getCurrentMesh() { return currentMesh; }
/**
* Replace (or clear) the flat orange exclusion overlay mesh.
* overlayGeo must be a non-indexed BufferGeometry with a 'position' attribute,
* or null / an empty geometry to clear the overlay.
* The mesh lives directly in the scene so loadGeometry() (which clears
* meshGroup) never accidentally removes it.
*
* @param {THREE.BufferGeometry|null} overlayGeo
*/
export function setExclusionOverlay(overlayGeo) {
if (exclusionMesh) {
scene.remove(exclusionMesh);
exclusionMesh.geometry.dispose();
exclusionMesh.material.dispose();
exclusionMesh = null;
}
if (!overlayGeo || overlayGeo.attributes.position.count === 0) return;
exclusionMesh = new THREE.Mesh(
overlayGeo,
new THREE.MeshBasicMaterial({
color: 0xff6600,
side: THREE.DoubleSide,
polygonOffset: true,
polygonOffsetFactor: -1,
polygonOffsetUnits: -1,
}),
);
exclusionMesh.renderOrder = 1;
scene.add(exclusionMesh);
}
/**
* Replace (or clear) the yellow hover-preview overlay shown before a bucket-fill
* click is confirmed. Pass null or an empty geometry to clear it.
*
* @param {THREE.BufferGeometry|null} overlayGeo
*/
export function setHoverPreview(overlayGeo) {
if (hoverMesh) {
scene.remove(hoverMesh);
hoverMesh.geometry.dispose();
hoverMesh.material.dispose();
hoverMesh = null;
}
if (!overlayGeo || overlayGeo.attributes.position.count === 0) return;
hoverMesh = new THREE.Mesh(
overlayGeo,
new THREE.MeshBasicMaterial({
color: 0xffee00,
side: THREE.DoubleSide,
transparent: true,
opacity: 0.45,
polygonOffset: true,
polygonOffsetFactor: -2,
polygonOffsetUnits: -2,
}),
);
hoverMesh.renderOrder = 2;
scene.add(hoverMesh);
}
/**
* Show or hide the triangle-edge wireframe overlay.
* @param {boolean} enabled
+108
View File
@@ -444,3 +444,111 @@ input[type="number"].val:focus { outline: none; border-color: var(--accent); }
}
.tri-limit-warning.hidden { display: none; }
/* ── Surface Exclusions panel ────────────────────────────────────────────── */
/* Tool button row */
.excl-tools {
display: flex;
gap: 6px;
margin-bottom: 10px;
}
.excl-tool-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
padding: 5px 8px;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text-muted);
font-size: 11px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s;
white-space: nowrap;
}
.excl-tool-btn:hover {
border-color: var(--accent-hover);
color: var(--text);
}
.excl-tool-btn.active {
background: color-mix(in srgb, var(--accent) 18%, var(--surface2));
border-color: var(--accent);
color: var(--accent);
}
/* Segmented button group (Single / Radius) */
.excl-seg {
display: flex;
flex: 1;
}
.excl-seg-btn {
flex: 1;
padding: 4px 8px;
background: var(--surface2);
border: 1px solid var(--border);
color: var(--text-muted);
font-size: 11px;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.excl-seg-btn:first-child {
border-radius: var(--radius) 0 0 var(--radius);
}
.excl-seg-btn:last-child {
border-radius: 0 var(--radius) var(--radius) 0;
border-left: none;
}
.excl-seg-btn.active {
background: color-mix(in srgb, var(--accent) 18%, var(--surface2));
border-color: var(--accent);
color: var(--accent);
}
.excl-seg-btn:hover:not(.active) {
background: var(--border);
color: var(--text);
}
/* Footer row: count + clear */
.excl-footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 10px;
}
.excl-count {
font-size: 11px;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.excl-clear-btn {
padding: 4px 10px;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text-muted);
font-size: 11px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.excl-clear-btn:hover {
border-color: var(--danger);
color: var(--danger);
background: color-mix(in srgb, var(--danger) 10%, var(--surface2));
}
/* Hide utility (used by JS to show/hide exclusion sub-rows) */
.form-row.hidden { display: none; }