mirror of
https://github.com/CNCKitchen/stlTexturizer.git
synced 2026-04-07 22:11:32 +00:00
feat: implement surface exclusion mode toggle and enhance exclusion overlay logic
This commit is contained in:
+12
-2
@@ -163,7 +163,17 @@
|
|||||||
|
|
||||||
<!-- Surface Exclusions -->
|
<!-- Surface Exclusions -->
|
||||||
<section class="panel-section">
|
<section class="panel-section">
|
||||||
<h2>Surface Exclusions</h2>
|
<h2 id="excl-section-heading">Surface Exclusions</h2>
|
||||||
|
|
||||||
|
<!-- Mode toggle: Exclude / Include Only -->
|
||||||
|
<div class="excl-mode-row">
|
||||||
|
<div class="excl-seg">
|
||||||
|
<button id="excl-mode-exclude" class="excl-seg-btn active" aria-pressed="true"
|
||||||
|
title="Exclude mode: painted surfaces will not receive texture displacement">Exclude</button>
|
||||||
|
<button id="excl-mode-include" class="excl-seg-btn" aria-pressed="false"
|
||||||
|
title="Include Only mode: only painted surfaces will receive texture displacement">Include Only</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Tool buttons -->
|
<!-- Tool buttons -->
|
||||||
<div class="excl-tools">
|
<div class="excl-tools">
|
||||||
@@ -219,7 +229,7 @@
|
|||||||
<span id="excl-count" class="excl-count">0 faces excluded</span>
|
<span id="excl-count" class="excl-count">0 faces excluded</span>
|
||||||
<button id="excl-clear-btn" class="excl-clear-btn">Clear All</button>
|
<button id="excl-clear-btn" class="excl-clear-btn">Clear All</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="hint">Excluded surfaces appear orange and will not receive displacement during export.</p>
|
<p id="excl-hint" class="hint">Excluded surfaces appear orange and will not receive displacement during export.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Export -->
|
<!-- Export -->
|
||||||
|
|||||||
+42
-15
@@ -136,23 +136,40 @@ export function bucketFill(seedTriIdx, adjacency, thresholdDeg) {
|
|||||||
// ── Overlay geometry ──────────────────────────────────────────────────────────
|
// ── Overlay geometry ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a compact non-indexed BufferGeometry containing only the excluded
|
* Build a compact non-indexed BufferGeometry for an overlay.
|
||||||
* triangles' positions. Used to drive the orange overlay mesh in the viewer.
|
|
||||||
*
|
*
|
||||||
* @param {THREE.BufferGeometry} geometry – non-indexed source geometry
|
* @param {THREE.BufferGeometry} geometry – non-indexed source geometry
|
||||||
* @param {Set<number>} excludedFaces
|
* @param {Set<number>} faceSet
|
||||||
|
* @param {boolean} [invert=false] when true, include faces NOT in faceSet
|
||||||
* @returns {THREE.BufferGeometry}
|
* @returns {THREE.BufferGeometry}
|
||||||
*/
|
*/
|
||||||
export function buildExclusionOverlayGeo(geometry, excludedFaces) {
|
export function buildExclusionOverlayGeo(geometry, faceSet, invert = false) {
|
||||||
const srcPos = geometry.attributes.position.array;
|
const srcPos = geometry.attributes.position.array;
|
||||||
const outPos = new Float32Array(excludedFaces.size * 9); // 3 verts × 3 floats
|
const srcNrm = geometry.attributes.normal ? geometry.attributes.normal.array : null;
|
||||||
|
const total = srcPos.length / 9; // total triangle count
|
||||||
|
const count = invert ? total - faceSet.size : faceSet.size;
|
||||||
|
const outPos = new Float32Array(count * 9);
|
||||||
|
const outNrm = srcNrm ? new Float32Array(count * 9) : null;
|
||||||
let dst = 0;
|
let dst = 0;
|
||||||
for (const t of excludedFaces) {
|
if (invert) {
|
||||||
const src = t * 9;
|
for (let t = 0; t < total; t++) {
|
||||||
for (let i = 0; i < 9; i++) outPos[dst++] = srcPos[src + i];
|
if (faceSet.has(t)) continue;
|
||||||
|
const src = t * 9;
|
||||||
|
for (let i = 0; i < 9; i++) outPos[dst + i] = srcPos[src + i];
|
||||||
|
if (outNrm) for (let i = 0; i < 9; i++) outNrm[dst + i] = srcNrm[src + i];
|
||||||
|
dst += 9;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const t of faceSet) {
|
||||||
|
const src = t * 9;
|
||||||
|
for (let i = 0; i < 9; i++) outPos[dst + i] = srcPos[src + i];
|
||||||
|
if (outNrm) for (let i = 0; i < 9; i++) outNrm[dst + i] = srcNrm[src + i];
|
||||||
|
dst += 9;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const geo = new THREE.BufferGeometry();
|
const geo = new THREE.BufferGeometry();
|
||||||
geo.setAttribute('position', new THREE.BufferAttribute(outPos, 3));
|
geo.setAttribute('position', new THREE.BufferAttribute(outPos, 3));
|
||||||
|
if (outNrm) geo.setAttribute('normal', new THREE.BufferAttribute(outNrm, 3));
|
||||||
return geo;
|
return geo;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,13 +186,23 @@ export function buildExclusionOverlayGeo(geometry, excludedFaces) {
|
|||||||
* @param {Set<number>} excludedFaces
|
* @param {Set<number>} excludedFaces
|
||||||
* @returns {Float32Array} length = geometry.attributes.position.count
|
* @returns {Float32Array} length = geometry.attributes.position.count
|
||||||
*/
|
*/
|
||||||
export function buildFaceWeights(geometry, excludedFaces) {
|
export function buildFaceWeights(geometry, excludedFaces, invert = false) {
|
||||||
const count = geometry.attributes.position.count;
|
const count = geometry.attributes.position.count;
|
||||||
const weights = new Float32Array(count); // default 0 (included)
|
const weights = new Float32Array(count); // default 0.0 (included)
|
||||||
for (const t of excludedFaces) {
|
if (invert) {
|
||||||
weights[t * 3] = 1.0;
|
// Include-only mode: all faces start excluded (1.0); painted faces are included (0.0)
|
||||||
weights[t * 3 + 1] = 1.0;
|
weights.fill(1.0);
|
||||||
weights[t * 3 + 2] = 1.0;
|
for (const t of excludedFaces) {
|
||||||
|
weights[t * 3] = 0.0;
|
||||||
|
weights[t * 3 + 1] = 0.0;
|
||||||
|
weights[t * 3 + 2] = 0.0;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const t of excludedFaces) {
|
||||||
|
weights[t * 3] = 1.0;
|
||||||
|
weights[t * 3 + 1] = 1.0;
|
||||||
|
weights[t * 3 + 2] = 1.0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return weights;
|
return weights;
|
||||||
}
|
}
|
||||||
|
|||||||
+84
-7
@@ -31,6 +31,7 @@ let brushIsRadius = false;
|
|||||||
let brushRadius = 5.0;
|
let brushRadius = 5.0;
|
||||||
let bucketThreshold = 30;
|
let bucketThreshold = 30;
|
||||||
let isPainting = false;
|
let isPainting = false;
|
||||||
|
let selectionMode = false; // false = exclude painted faces; true = include only painted faces
|
||||||
let _lastHoverTriIdx = -1; // last triangle index used for hover preview
|
let _lastHoverTriIdx = -1; // last triangle index used for hover preview
|
||||||
const _raycaster = new THREE.Raycaster();
|
const _raycaster = new THREE.Raycaster();
|
||||||
|
|
||||||
@@ -103,6 +104,10 @@ const exclThresholdSlider = document.getElementById('excl-threshold-slider');
|
|||||||
const exclThresholdVal = document.getElementById('excl-threshold-val');
|
const exclThresholdVal = document.getElementById('excl-threshold-val');
|
||||||
const exclCount = document.getElementById('excl-count');
|
const exclCount = document.getElementById('excl-count');
|
||||||
const exclClearBtn = document.getElementById('excl-clear-btn');
|
const exclClearBtn = document.getElementById('excl-clear-btn');
|
||||||
|
const exclModeExcludeBtn = document.getElementById('excl-mode-exclude');
|
||||||
|
const exclModeIncludeBtn = document.getElementById('excl-mode-include');
|
||||||
|
const exclSectionHeading = document.getElementById('excl-section-heading');
|
||||||
|
const exclHint = document.getElementById('excl-hint');
|
||||||
|
|
||||||
// ── Scale slider log helpers ──────────────────────────────────────────────────
|
// ── Scale slider log helpers ──────────────────────────────────────────────────
|
||||||
// Slider stores 0–1000; actual scale spans 0.1–10 on a log axis.
|
// Slider stores 0–1000; actual scale spans 0.1–10 on a log axis.
|
||||||
@@ -299,6 +304,9 @@ function wireEvents() {
|
|||||||
refreshExclusionOverlay();
|
refreshExclusionOverlay();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
exclModeExcludeBtn.addEventListener('click', () => setSelectionMode(false));
|
||||||
|
exclModeIncludeBtn.addEventListener('click', () => setSelectionMode(true));
|
||||||
|
|
||||||
// ── Canvas mouse events for exclusion painting ────────────────────────────
|
// ── Canvas mouse events for exclusion painting ────────────────────────────
|
||||||
canvas.addEventListener('mousedown', (e) => {
|
canvas.addEventListener('mousedown', (e) => {
|
||||||
if (!currentGeometry || !exclusionTool || e.button !== 0) return;
|
if (!currentGeometry || !exclusionTool || e.button !== 0) return;
|
||||||
@@ -355,6 +363,22 @@ function wireEvents() {
|
|||||||
|
|
||||||
// ── Exclusion helpers ─────────────────────────────────────────────────────────
|
// ── Exclusion helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function setSelectionMode(include) {
|
||||||
|
if (selectionMode === include) return;
|
||||||
|
selectionMode = include;
|
||||||
|
exclModeExcludeBtn.classList.toggle('active', !selectionMode);
|
||||||
|
exclModeIncludeBtn.classList.toggle('active', selectionMode);
|
||||||
|
exclModeExcludeBtn.setAttribute('aria-pressed', String(!selectionMode));
|
||||||
|
exclModeIncludeBtn.setAttribute('aria-pressed', String(selectionMode));
|
||||||
|
exclSectionHeading.textContent = selectionMode ? 'Surface Selection' : 'Surface Exclusions';
|
||||||
|
exclHint.textContent = selectionMode
|
||||||
|
? 'Selected surfaces appear green and will be the only ones to receive displacement during export.'
|
||||||
|
: 'Excluded surfaces appear orange and will not receive displacement during export.';
|
||||||
|
// Clear the painted set — faces had opposite semantics in the previous mode
|
||||||
|
excludedFaces = new Set();
|
||||||
|
refreshExclusionOverlay();
|
||||||
|
}
|
||||||
|
|
||||||
function setExclusionTool(tool) {
|
function setExclusionTool(tool) {
|
||||||
// Clicking the active tool toggles it off; passing null always deactivates
|
// Clicking the active tool toggles it off; passing null always deactivates
|
||||||
exclusionTool = (exclusionTool === tool) ? null : tool;
|
exclusionTool = (exclusionTool === tool) ? null : tool;
|
||||||
@@ -423,9 +447,18 @@ function paintAt(e) {
|
|||||||
|
|
||||||
function refreshExclusionOverlay() {
|
function refreshExclusionOverlay() {
|
||||||
if (!currentGeometry) return;
|
if (!currentGeometry) return;
|
||||||
setExclusionOverlay(buildExclusionOverlayGeo(currentGeometry, excludedFaces));
|
if (selectionMode) {
|
||||||
|
// Include Only mode: grey out the complement (non-selected faces) so only the
|
||||||
|
// selected faces show the texture preview beneath.
|
||||||
|
const maskGeo = buildExclusionOverlayGeo(currentGeometry, excludedFaces, true);
|
||||||
|
setExclusionOverlay(maskGeo, 0x222222, 0.88);
|
||||||
|
} else {
|
||||||
|
setExclusionOverlay(buildExclusionOverlayGeo(currentGeometry, excludedFaces), 0xff6600);
|
||||||
|
}
|
||||||
const n = excludedFaces.size;
|
const n = excludedFaces.size;
|
||||||
exclCount.textContent = `${n.toLocaleString()} face${n === 1 ? '' : 's'} excluded`;
|
exclCount.textContent = selectionMode
|
||||||
|
? `${n.toLocaleString()} face${n === 1 ? '' : 's'} selected`
|
||||||
|
: `${n.toLocaleString()} face${n === 1 ? '' : 's'} excluded`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateBucketHover(e) {
|
function updateBucketHover(e) {
|
||||||
@@ -584,6 +617,48 @@ function updatePreview() {
|
|||||||
|
|
||||||
// ── Export pipeline ───────────────────────────────────────────────────────────
|
// ── Export pipeline ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds per-non-indexed-vertex weights (1.0 = excluded from subdivision/displacement)
|
||||||
|
* that combine the user-painted exclusion set AND the top/bottom angle mask.
|
||||||
|
*/
|
||||||
|
function buildCombinedFaceWeights(geometry, excludedFaces, invert, settings) {
|
||||||
|
const weights = buildFaceWeights(geometry, excludedFaces, invert);
|
||||||
|
|
||||||
|
const hasAngleMask = settings.bottomAngleLimit > 0 || settings.topAngleLimit > 0;
|
||||||
|
if (!hasAngleMask) return weights;
|
||||||
|
|
||||||
|
const posAttr = geometry.attributes.position;
|
||||||
|
const triCount = posAttr.count / 3;
|
||||||
|
const vA = new THREE.Vector3();
|
||||||
|
const vB = new THREE.Vector3();
|
||||||
|
const vC = new THREE.Vector3();
|
||||||
|
const edge1 = new THREE.Vector3();
|
||||||
|
const edge2 = new THREE.Vector3();
|
||||||
|
const faceNrm = new THREE.Vector3();
|
||||||
|
|
||||||
|
for (let t = 0; t < triCount; t++) {
|
||||||
|
if (weights[t * 3] > 0.99) continue; // already excluded
|
||||||
|
vA.fromBufferAttribute(posAttr, t * 3);
|
||||||
|
vB.fromBufferAttribute(posAttr, t * 3 + 1);
|
||||||
|
vC.fromBufferAttribute(posAttr, t * 3 + 2);
|
||||||
|
edge1.subVectors(vB, vA);
|
||||||
|
edge2.subVectors(vC, vA);
|
||||||
|
faceNrm.crossVectors(edge1, edge2);
|
||||||
|
const faceArea = faceNrm.length();
|
||||||
|
const faceNzNorm = faceArea > 1e-12 ? faceNrm.z / faceArea : 0;
|
||||||
|
const faceAngle = Math.acos(Math.abs(faceNzNorm)) * (180 / Math.PI);
|
||||||
|
const angleMasked = faceNzNorm < 0
|
||||||
|
? (settings.bottomAngleLimit > 0 && faceAngle <= settings.bottomAngleLimit)
|
||||||
|
: (settings.topAngleLimit > 0 && faceAngle <= settings.topAngleLimit);
|
||||||
|
if (angleMasked) {
|
||||||
|
weights[t * 3] = 1.0;
|
||||||
|
weights[t * 3 + 1] = 1.0;
|
||||||
|
weights[t * 3 + 2] = 1.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return weights;
|
||||||
|
}
|
||||||
|
|
||||||
async function handleExport() {
|
async function handleExport() {
|
||||||
if (!currentGeometry || !activeMapEntry || isExporting) return;
|
if (!currentGeometry || !activeMapEntry || isExporting) return;
|
||||||
isExporting = true;
|
isExporting = true;
|
||||||
@@ -593,11 +668,13 @@ async function handleExport() {
|
|||||||
try {
|
try {
|
||||||
setProgress(0.02, 'Subdividing mesh…');
|
setProgress(0.02, 'Subdividing mesh…');
|
||||||
|
|
||||||
// Build per-vertex exclusion weights if any faces are excluded.
|
// Build per-vertex exclusion weights combining user-painted exclusion + angle masking.
|
||||||
// subdivision.js interpolates these through edge splits so the exclusion
|
// Faces masked by top/bottom angle limits are treated the same as user-excluded faces
|
||||||
// propagates correctly to all new vertices inside the excluded region.
|
// so subdivision skips their interior edges too, saving triangles where no
|
||||||
const faceWeights = excludedFaces.size > 0
|
// displacement will be applied.
|
||||||
? buildFaceWeights(currentGeometry, excludedFaces)
|
const hasAngleMask = settings.bottomAngleLimit > 0 || settings.topAngleLimit > 0;
|
||||||
|
const faceWeights = (excludedFaces.size > 0 || selectionMode || hasAngleMask)
|
||||||
|
? buildCombinedFaceWeights(currentGeometry, excludedFaces, selectionMode, settings)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const { geometry: subdivided, safetyCapHit } = await runAsync(() =>
|
const { geometry: subdivided, safetyCapHit } = await runAsync(() =>
|
||||||
|
|||||||
+40
-5
@@ -20,10 +20,24 @@ const SAFETY_CAP = 5_000_000; // absolute OOM guard
|
|||||||
// ── Public entry point ───────────────────────────────────────────────────────
|
// ── Public entry point ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function subdivide(geometry, maxEdgeLength, onProgress, faceWeights = null) {
|
export function subdivide(geometry, maxEdgeLength, onProgress, faceWeights = null) {
|
||||||
|
// 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).
|
||||||
|
let initialFaceExcluded = null;
|
||||||
|
if (faceWeights) {
|
||||||
|
const triCount = faceWeights.length / 3;
|
||||||
|
initialFaceExcluded = new Uint8Array(triCount);
|
||||||
|
for (let i = 0; i < triCount; i++) {
|
||||||
|
// Non-indexed vertex i*3 belongs to face i; weight > 0.99 → excluded
|
||||||
|
if (faceWeights[i * 3] > 0.99) initialFaceExcluded[i] = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const { positions, normals, weights, indices } = toIndexed(geometry, faceWeights);
|
const { positions, normals, weights, indices } = toIndexed(geometry, faceWeights);
|
||||||
|
|
||||||
const maxIterations = 12;
|
const maxIterations = 12;
|
||||||
let currentIndices = indices;
|
let currentIndices = indices;
|
||||||
|
let currentFaceExcluded = initialFaceExcluded;
|
||||||
let safetyCapHit = false;
|
let safetyCapHit = false;
|
||||||
|
|
||||||
for (let iter = 0; iter < maxIterations; iter++) {
|
for (let iter = 0; iter < maxIterations; iter++) {
|
||||||
@@ -33,10 +47,11 @@ export function subdivide(geometry, maxEdgeLength, onProgress, faceWeights = nul
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { newIndices, changed } = subdividePass(
|
const { newIndices, newFaceExcluded, changed } = subdividePass(
|
||||||
positions, normals, weights, currentIndices, maxEdgeLength, SAFETY_CAP
|
positions, normals, weights, currentIndices, maxEdgeLength, SAFETY_CAP, currentFaceExcluded
|
||||||
);
|
);
|
||||||
currentIndices = newIndices;
|
currentIndices = newIndices;
|
||||||
|
if (newFaceExcluded) currentFaceExcluded = newFaceExcluded;
|
||||||
|
|
||||||
if (newIndices.length / 3 >= SAFETY_CAP) safetyCapHit = true;
|
if (newIndices.length / 3 >= SAFETY_CAP) safetyCapHit = true;
|
||||||
|
|
||||||
@@ -68,32 +83,44 @@ export function subdivide(geometry, maxEdgeLength, onProgress, faceWeights = nul
|
|||||||
// long edge still produce chains of thin children (unavoidable without moving
|
// 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.
|
// vertices off the surface), but the mesh is now crack-free in all cases.
|
||||||
|
|
||||||
function subdividePass(positions, normals, weights, indices, maxEdgeLength, safetyCap) {
|
function subdividePass(positions, normals, weights, indices, maxEdgeLength, safetyCap, faceExcluded = null) {
|
||||||
const maxSq = maxEdgeLength * maxEdgeLength;
|
const maxSq = maxEdgeLength * maxEdgeLength;
|
||||||
const midCache = new Map();
|
const midCache = new Map();
|
||||||
|
|
||||||
// ── Step 1: globally mark edges that need splitting ─────────────────────
|
// ── Step 1: globally mark edges that need splitting ─────────────────────
|
||||||
|
// Excluded triangles do NOT proactively mark their own edges – their
|
||||||
|
// interior edges will never be split, saving triangles on untextured
|
||||||
|
// regions. Boundary edges are still marked by the included neighbour, so
|
||||||
|
// excluded triangles respond to those splits and T-junctions are avoided.
|
||||||
const splitEdges = new Set();
|
const splitEdges = new Set();
|
||||||
for (let t = 0; t < indices.length; t += 3) {
|
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];
|
const a = indices[t], b = indices[t + 1], c = indices[t + 2];
|
||||||
if (edgeLenSq(positions, a, b) > maxSq) splitEdges.add(edgeKey(a, b));
|
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, b, c) > maxSq) splitEdges.add(edgeKey(b, c));
|
||||||
if (edgeLenSq(positions, c, a) > maxSq) splitEdges.add(edgeKey(c, a));
|
if (edgeLenSq(positions, c, a) > maxSq) splitEdges.add(edgeKey(c, a));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (splitEdges.size === 0) return { newIndices: indices, changed: false };
|
if (splitEdges.size === 0) return { newIndices: indices, newFaceExcluded: faceExcluded, changed: false };
|
||||||
|
|
||||||
// ── Step 2: rebuild index list ───────────────────────────────────────────
|
// ── Step 2: rebuild index list ───────────────────────────────────────────
|
||||||
const nextIndices = [];
|
const nextIndices = [];
|
||||||
|
const nextFaceExcluded = faceExcluded ? [] : null;
|
||||||
|
|
||||||
for (let t = 0; t < indices.length; t += 3) {
|
for (let t = 0; t < indices.length; t += 3) {
|
||||||
// Safety cap: stop splitting, carry remaining triangles as-is
|
// Safety cap: stop splitting, carry remaining triangles as-is
|
||||||
if (nextIndices.length / 3 >= safetyCap) {
|
if (nextIndices.length / 3 >= safetyCap) {
|
||||||
for (let r = t; r < indices.length; r++) nextIndices.push(indices[r]);
|
for (let r = t; r < indices.length; r++) nextIndices.push(indices[r]);
|
||||||
|
// Carry remaining face-exclusion flags as-is
|
||||||
|
if (nextFaceExcluded && faceExcluded) {
|
||||||
|
for (let r = t / 3; r < indices.length / 3; r++) nextFaceExcluded.push(faceExcluded[r]);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const a = indices[t], b = indices[t + 1], c = indices[t + 2];
|
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(edgeKey(a, b));
|
const sAB = splitEdges.has(edgeKey(a, b));
|
||||||
const sBC = splitEdges.has(edgeKey(b, c));
|
const sBC = splitEdges.has(edgeKey(b, c));
|
||||||
const sCA = splitEdges.has(edgeKey(c, a));
|
const sCA = splitEdges.has(edgeKey(c, a));
|
||||||
@@ -102,6 +129,7 @@ function subdividePass(positions, normals, weights, indices, maxEdgeLength, safe
|
|||||||
if (n === 0) {
|
if (n === 0) {
|
||||||
// ── 0-split: keep triangle ─────────────────────────────────────────
|
// ── 0-split: keep triangle ─────────────────────────────────────────
|
||||||
nextIndices.push(a, b, c);
|
nextIndices.push(a, b, c);
|
||||||
|
if (nextFaceExcluded) nextFaceExcluded.push(excl);
|
||||||
|
|
||||||
} else if (n === 3) {
|
} else if (n === 3) {
|
||||||
// ── 3-split: 1→4 regular midpoint subdivision ──────────────────────
|
// ── 3-split: 1→4 regular midpoint subdivision ──────────────────────
|
||||||
@@ -121,18 +149,22 @@ function subdividePass(positions, normals, weights, indices, maxEdgeLength, safe
|
|||||||
mCA, mBC, c,
|
mCA, mBC, c,
|
||||||
mAB, mBC, mCA,
|
mAB, mBC, mCA,
|
||||||
);
|
);
|
||||||
|
if (nextFaceExcluded) nextFaceExcluded.push(excl, excl, excl, excl);
|
||||||
|
|
||||||
} else if (n === 1) {
|
} else if (n === 1) {
|
||||||
// ── 1-split: bisect the one marked edge → 2 sub-triangles ──────────
|
// ── 1-split: bisect the one marked edge → 2 sub-triangles ──────────
|
||||||
if (sAB) {
|
if (sAB) {
|
||||||
const m = getMidpoint(positions, normals, weights, midCache, a, b);
|
const m = getMidpoint(positions, normals, weights, midCache, a, b);
|
||||||
nextIndices.push(a, m, c, m, b, c);
|
nextIndices.push(a, m, c, m, b, c);
|
||||||
|
if (nextFaceExcluded) nextFaceExcluded.push(excl, excl);
|
||||||
} else if (sBC) {
|
} else if (sBC) {
|
||||||
const m = getMidpoint(positions, normals, weights, midCache, b, c);
|
const m = getMidpoint(positions, normals, weights, midCache, b, c);
|
||||||
nextIndices.push(a, b, m, a, m, c);
|
nextIndices.push(a, b, m, a, m, c);
|
||||||
|
if (nextFaceExcluded) nextFaceExcluded.push(excl, excl);
|
||||||
} else { // sCA
|
} else { // sCA
|
||||||
const m = getMidpoint(positions, normals, weights, midCache, c, a);
|
const m = getMidpoint(positions, normals, weights, midCache, c, a);
|
||||||
nextIndices.push(a, b, m, m, b, c);
|
nextIndices.push(a, b, m, m, b, c);
|
||||||
|
if (nextFaceExcluded) nextFaceExcluded.push(excl, excl);
|
||||||
}
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
@@ -151,6 +183,7 @@ function subdividePass(positions, normals, weights, indices, maxEdgeLength, safe
|
|||||||
a, mBC, mCA,
|
a, mBC, mCA,
|
||||||
c, mCA, mBC,
|
c, mCA, mBC,
|
||||||
);
|
);
|
||||||
|
if (nextFaceExcluded) nextFaceExcluded.push(excl, excl, excl);
|
||||||
} else if (!sBC) { // sAB + sCA: fan from A
|
} else if (!sBC) { // sAB + sCA: fan from A
|
||||||
const mAB = getMidpoint(positions, normals, weights, midCache, a, b);
|
const mAB = getMidpoint(positions, normals, weights, midCache, a, b);
|
||||||
const mCA = getMidpoint(positions, normals, weights, midCache, c, a);
|
const mCA = getMidpoint(positions, normals, weights, midCache, c, a);
|
||||||
@@ -159,6 +192,7 @@ function subdividePass(positions, normals, weights, indices, maxEdgeLength, safe
|
|||||||
mAB, b, c,
|
mAB, b, c,
|
||||||
mAB, c, mCA,
|
mAB, c, mCA,
|
||||||
);
|
);
|
||||||
|
if (nextFaceExcluded) nextFaceExcluded.push(excl, excl, excl);
|
||||||
} else { // sAB + sBC: fan from B
|
} else { // sAB + sBC: fan from B
|
||||||
const mAB = getMidpoint(positions, normals, weights, midCache, a, b);
|
const mAB = getMidpoint(positions, normals, weights, midCache, a, b);
|
||||||
const mBC = getMidpoint(positions, normals, weights, midCache, b, c);
|
const mBC = getMidpoint(positions, normals, weights, midCache, b, c);
|
||||||
@@ -167,11 +201,12 @@ function subdividePass(positions, normals, weights, indices, maxEdgeLength, safe
|
|||||||
a, mAB, mBC,
|
a, mAB, mBC,
|
||||||
a, mBC, c,
|
a, mBC, c,
|
||||||
);
|
);
|
||||||
|
if (nextFaceExcluded) nextFaceExcluded.push(excl, excl, excl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { newIndices: nextIndices, changed: true };
|
return { newIndices: nextIndices, newFaceExcluded: nextFaceExcluded, changed: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
+5
-3
@@ -262,7 +262,7 @@ export function getCurrentMesh() { return currentMesh; }
|
|||||||
*
|
*
|
||||||
* @param {THREE.BufferGeometry|null} overlayGeo
|
* @param {THREE.BufferGeometry|null} overlayGeo
|
||||||
*/
|
*/
|
||||||
export function setExclusionOverlay(overlayGeo) {
|
export function setExclusionOverlay(overlayGeo, color = 0xff6600, opacity = 1.0) {
|
||||||
if (exclusionMesh) {
|
if (exclusionMesh) {
|
||||||
scene.remove(exclusionMesh);
|
scene.remove(exclusionMesh);
|
||||||
exclusionMesh.geometry.dispose();
|
exclusionMesh.geometry.dispose();
|
||||||
@@ -272,9 +272,11 @@ export function setExclusionOverlay(overlayGeo) {
|
|||||||
if (!overlayGeo || overlayGeo.attributes.position.count === 0) return;
|
if (!overlayGeo || overlayGeo.attributes.position.count === 0) return;
|
||||||
exclusionMesh = new THREE.Mesh(
|
exclusionMesh = new THREE.Mesh(
|
||||||
overlayGeo,
|
overlayGeo,
|
||||||
new THREE.MeshBasicMaterial({
|
new THREE.MeshLambertMaterial({
|
||||||
color: 0xff6600,
|
color,
|
||||||
side: THREE.DoubleSide,
|
side: THREE.DoubleSide,
|
||||||
|
transparent: opacity < 1.0,
|
||||||
|
opacity,
|
||||||
polygonOffset: true,
|
polygonOffset: true,
|
||||||
polygonOffsetFactor: -1,
|
polygonOffsetFactor: -1,
|
||||||
polygonOffsetUnits: -1,
|
polygonOffsetUnits: -1,
|
||||||
|
|||||||
@@ -454,6 +454,13 @@ input[type="number"].val:focus { outline: none; border-color: var(--accent); }
|
|||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.excl-mode-row {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.excl-mode-row .excl-seg {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.excl-tool-btn {
|
.excl-tool-btn {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -514,6 +521,13 @@ input[type="number"].val:focus { outline: none; border-color: var(--accent); }
|
|||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Green active state for the Include Only mode button */
|
||||||
|
#excl-mode-include.active {
|
||||||
|
background: color-mix(in srgb, #00cc66 18%, var(--surface2));
|
||||||
|
border-color: #00cc66;
|
||||||
|
color: #00cc66;
|
||||||
|
}
|
||||||
|
|
||||||
.excl-seg-btn:hover:not(.active) {
|
.excl-seg-btn:hover:not(.active) {
|
||||||
background: var(--border);
|
background: var(--border);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
|
|||||||
Reference in New Issue
Block a user