mirror of
https://github.com/CNCKitchen/stlTexturizer.git
synced 2026-04-07 22:11:32 +00:00
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:
+62
-1
@@ -109,7 +109,7 @@
|
||||
</button>
|
||||
<div class="lock-line"></div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="form-row slider-row">
|
||||
<label for="scale-v">Scale V</label>
|
||||
<input type="range" id="scale-v" min="0" max="1000" step="1" value="500" />
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 0–1000; actual scale spans 0.1–10 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
@@ -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;
|
||||
}
|
||||
|
||||
+67
-3
@@ -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).
|
||||
@@ -245,11 +247,73 @@ function fitCamera(sphere) {
|
||||
controls.update();
|
||||
}
|
||||
|
||||
export function getRenderer() { return renderer; }
|
||||
export function getCamera() { return camera; }
|
||||
export function getScene() { return scene; }
|
||||
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
|
||||
|
||||
@@ -443,4 +443,112 @@ input[type="number"].val:focus { outline: none; border-color: var(--accent); }
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.tri-limit-warning.hidden { display: none; }
|
||||
.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; }
|
||||
Reference in New Issue
Block a user