diff --git a/index.html b/index.html
index 9719dd3..77659b9 100644
--- a/index.html
+++ b/index.html
@@ -287,6 +287,22 @@
+
+
+
+
+ ⚠ Outdated
+
+
+
+
diff --git a/js/i18n.js b/js/i18n.js
index a57d00f..3f98640 100644
--- a/js/i18n.js
+++ b/js/i18n.js
@@ -97,6 +97,15 @@ export const TRANSLATIONS = {
'excl.hintExclude': 'Masked surfaces appear orange and will not receive displacement during export.',
'excl.hintInclude': 'Selected surfaces appear green and will be the only ones to receive displacement during export.',
+ // Precision masking
+ 'precision.label': 'Precision (Beta) \u24d8',
+ 'precision.labelTitle': 'Subdivide mesh in the background so the brush selects at finer granularity',
+ 'precision.outdated': '\u26a0 Outdated',
+ 'precision.refreshTitle': 'Re-subdivide mesh to match current brush size',
+ 'precision.triCount': '{n} \u25b3',
+ 'precision.refining': 'Refining\u2026',
+ 'precision.warningBody': 'Estimated ~{n} triangles. This may slow down your browser. Continue?',
+
// Symmetric displacement
'labels.symmetricDisplacement': 'Symmetric displacement \u24d8',
'tooltips.symmetricDisplacement':'When on, 50% grey = no displacement; white pushes out, black pushes in. Keeps part volume roughly constant.',
@@ -256,6 +265,15 @@ export const TRANSLATIONS = {
'excl.hintExclude': 'Maskierte Fl\u00e4chen erscheinen orange und erhalten beim Export keine Verschiebung.',
'excl.hintInclude': 'Ausgew\u00e4hlte Fl\u00e4chen erscheinen gr\u00fcn und sind die einzigen, die beim Export eine Verschiebung erhalten.',
+ // Precision masking
+ 'precision.label': 'Pr\u00e4zision (Beta) \u24d8',
+ 'precision.labelTitle': 'Netz im Hintergrund unterteilen, damit der Pinsel feiner ausw\u00e4hlen kann',
+ 'precision.outdated': '\u26a0 Veraltet',
+ 'precision.refreshTitle': 'Netz erneut unterteilen, um zur aktuellen Pinselgr\u00f6\u00dfe zu passen',
+ 'precision.triCount': '{n} \u25b3',
+ 'precision.refining': 'Wird verfeinert\u2026',
+ 'precision.warningBody': 'Gesch\u00e4tzt ~{n} Dreiecke. Dies kann den Browser verlangsamen. Fortfahren?',
+
// Symmetric displacement
'labels.symmetricDisplacement': 'Symmetrische Verschiebung \u24d8',
'tooltips.symmetricDisplacement':'Wenn aktiv: 50% Grau = keine Verschiebung; Weiß nach außen, Schwarz nach innen. H\u00e4lt das Volumen des Teils in etwa konstant.',
diff --git a/js/main.js b/js/main.js
index 8cf7409..6015b7e 100644
--- a/js/main.js
+++ b/js/main.js
@@ -131,6 +131,17 @@ function blurCanvas(canvas, sigma) {
}
}
+// ── Precision masking state ────────────────────────────────────────────────────
+let precisionMaskingEnabled = false;
+let precisionGeometry = null; // subdivided geometry for finer masking
+let precisionParentMap = null; // Int32Array: refined face → original face index
+let precisionEdgeLength = null; // edge length used for current refinement
+let precisionBusy = false; // true while async subdivision is running
+let precisionCentroids = null; // Float32Array from buildAdjacency on refined mesh
+let precisionBoundRadii = null; // Float32Array — max vertex-to-centroid per refined tri
+let precisionAdjacency = null; // Map from buildAdjacency on refined mesh
+let precisionExcludedFaces = new Set(); // precision face indices excluded while precision is active
+
// ── Displacement preview state ────────────────────────────────────────────────
let dispPreviewGeometry = null; // subdivided geometry with smoothNormal attribute
let dispPreviewBusy = false; // true while async subdivision is running
@@ -212,6 +223,14 @@ const exclModeIncludeBtn = document.getElementById('excl-mode-include');
const exclSectionHeading = document.getElementById('excl-section-heading');
const exclHint = document.getElementById('excl-hint');
+// ── Precision masking DOM refs ────────────────────────────────────────────────
+const precisionMaskingRow = document.getElementById('precision-masking-row');
+const precisionMaskingToggle = document.getElementById('precision-masking-toggle');
+const precisionStatus = document.getElementById('precision-status');
+const precisionOutdated = document.getElementById('precision-outdated');
+const precisionRefreshBtn = document.getElementById('precision-refresh-btn');
+const precisionWarning = document.getElementById('precision-warning');
+
// ── License panel DOM refs ────────────────────────────────────────────────────
const licenseLink = document.getElementById('license-link');
const licenseOverlay = document.getElementById('license-overlay');
@@ -496,6 +515,9 @@ function wireEvents() {
exclBrushSingleBtn.classList.add('active');
exclBrushRadiusBtn.classList.remove('active');
exclRadiusRow.classList.add('hidden');
+ precisionMaskingRow.classList.add('hidden');
+ // Deactivate precision when switching away from circle mode
+ if (precisionMaskingEnabled) deactivatePrecisionMasking();
canvas.style.cursor = exclusionTool ? 'crosshair' : '';
brushCursorEl.style.display = 'none';
});
@@ -505,23 +527,27 @@ function wireEvents() {
exclBrushRadiusBtn.classList.add('active');
exclBrushSingleBtn.classList.remove('active');
if (exclusionTool === 'brush') exclRadiusRow.classList.remove('hidden');
+ if (exclusionTool === 'brush') precisionMaskingRow.classList.remove('hidden');
if (exclusionTool === 'brush') canvas.style.cursor = 'none';
});
exclBrushRadiusSlider.addEventListener('input', () => {
brushRadius = parseFloat(exclBrushRadiusSlider.value) / 2;
exclBrushRadiusVal.value = parseFloat(exclBrushRadiusSlider.value);
+ checkPrecisionOutdated();
});
exclBrushRadiusSlider.addEventListener('dblclick', () => {
exclBrushRadiusSlider.value = exclBrushRadiusSlider.defaultValue;
brushRadius = parseFloat(exclBrushRadiusSlider.value) / 2;
exclBrushRadiusVal.value = parseFloat(exclBrushRadiusSlider.value);
+ checkPrecisionOutdated();
});
exclBrushRadiusVal.addEventListener('change', () => {
let diam = Math.max(0.2, Math.min(100, parseFloat(exclBrushRadiusVal.value) || 10));
brushRadius = diam / 2;
exclBrushRadiusSlider.value = diam;
exclBrushRadiusVal.value = diam;
+ checkPrecisionOutdated();
});
exclThresholdSlider.addEventListener('input', () => {
@@ -544,12 +570,21 @@ function wireEvents() {
exclClearBtn.addEventListener('click', () => {
excludedFaces = new Set();
+ precisionExcludedFaces = new Set();
refreshExclusionOverlay();
});
exclModeExcludeBtn.addEventListener('click', () => setSelectionMode(false));
exclModeIncludeBtn.addEventListener('click', () => setSelectionMode(true));
+ // ── Precision masking wiring ──────────────────────────────────────────────
+ precisionMaskingToggle.addEventListener('change', () => {
+ togglePrecisionMasking(precisionMaskingToggle.checked);
+ });
+ precisionRefreshBtn.addEventListener('click', () => {
+ refreshPrecisionMesh();
+ });
+
// ── Canvas mouse events for exclusion painting ────────────────────────────
canvas.addEventListener('mousedown', (e) => {
if (!currentGeometry || e.button !== 0) return;
@@ -563,6 +598,9 @@ function wireEvents() {
if (!exclusionTool) return;
+ // Block painting while precision mesh is being built
+ if (precisionBusy) return;
+
if (exclusionTool === 'bucket') {
e.preventDefault();
_lastHoverTriIdx = -1;
@@ -570,9 +608,19 @@ function wireEvents() {
const triIdx = pickTriangle(e);
if (triIdx >= 0) {
const filled = bucketFill(triIdx, triangleAdjacency, bucketThreshold);
+ // Bucket fill always uses original face indices
for (const t of filled) {
if (eraseMode) excludedFaces.delete(t); else excludedFaces.add(t);
}
+ // If precision is active, also sync to precisionExcludedFaces
+ if (precisionMaskingEnabled && precisionParentMap) {
+ const len = precisionParentMap.length;
+ for (let i = 0; i < len; i++) {
+ if (filled.has(precisionParentMap[i])) {
+ if (eraseMode) precisionExcludedFaces.delete(i); else precisionExcludedFaces.add(i);
+ }
+ }
+ }
refreshExclusionOverlay();
_lastHoverTriIdx = -1;
setHoverPreview(null);
@@ -647,6 +695,7 @@ function setSelectionMode(include) {
: t('excl.hintExclude');
// Clear the painted set — faces had opposite semantics in the previous mode
excludedFaces = new Set();
+ precisionExcludedFaces = new Set();
refreshExclusionOverlay();
}
@@ -669,6 +718,8 @@ function setExclusionTool(tool) {
exclBrushTypeRow.classList.toggle('hidden', exclusionTool !== 'brush');
// Show radius row only while brush + radius mode is active
exclRadiusRow.classList.toggle('hidden', !(exclusionTool === 'brush' && brushIsRadius));
+ // Show precision masking row only when brush + circle mode is active
+ precisionMaskingRow.classList.toggle('hidden', !(exclusionTool === 'brush' && brushIsRadius));
// Show threshold row only while bucket is active
exclThresholdRow.classList.toggle('hidden', exclusionTool !== 'bucket');
canvas.style.cursor = (exclusionTool === 'brush' && brushIsRadius) ? 'none' : exclusionTool ? 'crosshair' : '';
@@ -723,6 +774,10 @@ function pickTriangle(e) {
if (dispPreviewGeometry && mesh.geometry === dispPreviewGeometry && dispPreviewParentMap) {
fi = dispPreviewParentMap[fi];
}
+ // Same mapping for precision masking geometry
+ if (precisionGeometry && mesh.geometry === precisionGeometry && precisionParentMap) {
+ fi = precisionParentMap[fi];
+ }
return fi;
}
@@ -779,15 +834,19 @@ function distSqPointToTri(px, py, pz, ax, ay, az, bx, by, bz, cx, cy, cz) {
/** Test all triangles against a sphere and invoke cb(triIdx) for each hit. */
function forEachTriInSphere(hitPt, r2, cb) {
- const pos = currentGeometry.attributes.position;
- const triCount = triangleCentroids.length / 3;
+ const usePrecision = precisionMaskingEnabled && precisionGeometry;
+ const geo = usePrecision ? precisionGeometry : currentGeometry;
+ const centroids = usePrecision ? precisionCentroids : triangleCentroids;
+ const boundRadii = usePrecision ? precisionBoundRadii : triangleBoundRadii;
+ const pos = geo.attributes.position;
+ const triCount = centroids.length / 3;
const r = Math.sqrt(r2);
for (let t = 0; t < triCount; t++) {
// Quick reject: centroid distance > brush radius + triangle bounding radius
- const dx = triangleCentroids[t*3] - hitPt.x;
- const dy = triangleCentroids[t*3+1] - hitPt.y;
- const dz = triangleCentroids[t*3+2] - hitPt.z;
- const bound = r + triangleBoundRadii[t];
+ const dx = centroids[t*3] - hitPt.x;
+ const dy = centroids[t*3+1] - hitPt.y;
+ const dz = centroids[t*3+2] - hitPt.z;
+ const bound = r + boundRadii[t];
if (dx*dx + dy*dy + dz*dz > bound*bound) continue;
// Precise sphere-triangle test
const i = t * 3;
@@ -809,19 +868,33 @@ function paintAt(e) {
const hit = getFrontFaceHit(hits, mesh);
if (!hit) return;
- // Map subdivided → original face index when displacement preview is active
- let triIdx = hit.faceIndex;
- if (dispPreviewGeometry && mesh.geometry === dispPreviewGeometry && dispPreviewParentMap) {
- triIdx = dispPreviewParentMap[triIdx];
- }
+ const usePrecision = precisionMaskingEnabled && precisionGeometry && precisionParentMap;
- if (brushIsRadius) {
- const r2 = brushRadius * brushRadius;
- forEachTriInSphere(hit.point, r2, t => {
- if (eraseMode) excludedFaces.delete(t); else excludedFaces.add(t);
- });
+ if (usePrecision) {
+ // Precision mode: store precision face indices for fine-grained selection
+ if (brushIsRadius) {
+ const r2 = brushRadius * brushRadius;
+ forEachTriInSphere(hit.point, r2, t => {
+ if (eraseMode) precisionExcludedFaces.delete(t); else precisionExcludedFaces.add(t);
+ });
+ } else {
+ const precIdx = hit.faceIndex; // precision face index (mesh is precision geometry)
+ if (eraseMode) precisionExcludedFaces.delete(precIdx); else precisionExcludedFaces.add(precIdx);
+ }
} else {
- if (eraseMode) excludedFaces.delete(triIdx); else excludedFaces.add(triIdx);
+ // Normal mode: store original face indices
+ let triIdx = hit.faceIndex;
+ if (dispPreviewGeometry && mesh.geometry === dispPreviewGeometry && dispPreviewParentMap) {
+ triIdx = dispPreviewParentMap[triIdx];
+ }
+ if (brushIsRadius) {
+ const r2 = brushRadius * brushRadius;
+ forEachTriInSphere(hit.point, r2, t => {
+ if (eraseMode) excludedFaces.delete(t); else excludedFaces.add(t);
+ });
+ } else {
+ if (eraseMode) excludedFaces.delete(triIdx); else excludedFaces.add(triIdx);
+ }
}
refreshExclusionOverlay();
@@ -836,6 +909,8 @@ function togglePlaceOnFace(active) {
if (active) {
// Deactivate exclusion tool
if (exclusionTool) setExclusionTool(null);
+ // Deactivate precision masking (geometry will be rotated/replaced)
+ if (precisionMaskingEnabled) deactivatePrecisionMasking();
canvas.style.cursor = 'crosshair';
} else {
if (!exclusionTool) canvas.style.cursor = '';
@@ -918,6 +993,16 @@ function handlePlaceOnFaceClick(e) {
settings.useDisplacement = false;
dispPreviewToggle.checked = false;
+ // Reset precision masking (geometry was rotated)
+ if (precisionGeometry) { precisionGeometry.dispose(); precisionGeometry = null; }
+ precisionParentMap = null; precisionEdgeLength = null;
+ precisionCentroids = null; precisionBoundRadii = null; precisionAdjacency = null;
+ precisionMaskingEnabled = false; precisionMaskingToggle.checked = false;
+ precisionStatus.textContent = '';
+ precisionOutdated.classList.add('hidden'); precisionRefreshBtn.classList.add('hidden');
+ precisionWarning.classList.add('hidden'); precisionMaskingRow.classList.add('hidden');
+ precisionExcludedFaces = new Set();
+
// Deactivate tools but keep excludedFaces (face indices are stable after rotation)
exclusionTool = null;
eraseMode = false;
@@ -967,23 +1052,29 @@ function handlePlaceOnFaceClick(e) {
function refreshExclusionOverlay() {
if (!currentGeometry) return;
+
+ // Choose which geometry and face set to build the overlay from
+ const usePrecision = precisionMaskingEnabled && precisionGeometry;
+ const overlayGeo = usePrecision ? precisionGeometry : currentGeometry;
+ const overlayFaceSet = usePrecision ? precisionExcludedFaces : excludedFaces;
+
if (selectionMode) {
- // Include Only mode: tint the complement (non-selected faces) with a pastel blue
- // so the model stays visible against the dark background before any faces are painted.
- const maskGeo = buildExclusionOverlayGeo(currentGeometry, excludedFaces, true);
+ const maskGeo = buildExclusionOverlayGeo(overlayGeo, overlayFaceSet, true);
setExclusionOverlay(maskGeo, 0x8ab4d4, 0.96);
} else {
- setExclusionOverlay(buildExclusionOverlayGeo(currentGeometry, excludedFaces), 0xff6600);
+ setExclusionOverlay(buildExclusionOverlayGeo(overlayGeo, overlayFaceSet), 0xff6600);
}
- const n = excludedFaces.size;
+ const n = usePrecision ? precisionExcludedFaces.size : excludedFaces.size;
exclCount.textContent = selectionMode
? t(n === 1 ? 'excl.faceSelected' : 'excl.facesSelected', { n: n.toLocaleString() })
: t(n === 1 ? 'excl.faceExcluded' : 'excl.facesExcluded', { n: n.toLocaleString() });
// Update the faceMask attribute on the active preview geometry so the shader
// reflects user-painted exclusions in real time.
- const activeGeo = (settings.useDisplacement && dispPreviewGeometry)
- ? dispPreviewGeometry : currentGeometry;
+ const activeGeo = usePrecision
+ ? precisionGeometry
+ : (settings.useDisplacement && dispPreviewGeometry)
+ ? dispPreviewGeometry : currentGeometry;
updateFaceMask(activeGeo);
}
@@ -1036,22 +1127,34 @@ function updateBrushHover(e) {
const hit = getFrontFaceHit(hits, mesh);
if (!hit) { _lastHoverTriIdx = -1; setHoverPreview(null); return; }
+ // Use raw face index for cache when precision is active (small faces → frequent updates)
+ const usePrecision = precisionMaskingEnabled && precisionGeometry && precisionParentMap;
let triIdx = hit.faceIndex;
- if (dispPreviewGeometry && mesh.geometry === dispPreviewGeometry && dispPreviewParentMap) {
- triIdx = dispPreviewParentMap[triIdx];
+ if (!usePrecision) {
+ if (dispPreviewGeometry && mesh.geometry === dispPreviewGeometry && dispPreviewParentMap) {
+ triIdx = dispPreviewParentMap[triIdx];
+ }
}
if (triIdx === _lastHoverTriIdx) return;
_lastHoverTriIdx = triIdx;
+ const hoverGeo = usePrecision ? precisionGeometry : currentGeometry;
const hoverColor = eraseMode ? 0x999999 : 0xffee00;
if (brushIsRadius) {
const r2 = brushRadius * brushRadius;
const hovered = new Set();
forEachTriInSphere(hit.point, r2, t => hovered.add(t));
- setHoverPreview(buildExclusionOverlayGeo(currentGeometry, hovered), hoverColor);
+ setHoverPreview(buildExclusionOverlayGeo(hoverGeo, hovered), hoverColor);
} else {
- const hovered = new Set([triIdx]);
- setHoverPreview(buildExclusionOverlayGeo(currentGeometry, hovered), hoverColor);
+ // For single mode with precision, find the refined face index for the hover highlight
+ if (usePrecision) {
+ const rawIdx = hit.faceIndex;
+ const hovered = new Set([rawIdx]);
+ setHoverPreview(buildExclusionOverlayGeo(precisionGeometry, hovered), hoverColor);
+ } else {
+ const hovered = new Set([triIdx]);
+ setHoverPreview(buildExclusionOverlayGeo(currentGeometry, hovered), hoverColor);
+ }
}
}
@@ -1064,7 +1167,18 @@ function updateBucketHover(e) {
return;
}
const hovered = bucketFill(triIdx, triangleAdjacency, bucketThreshold);
- setHoverPreview(buildExclusionOverlayGeo(currentGeometry, hovered), eraseMode ? 0x999999 : 0xffee00);
+ const usePrecision = precisionMaskingEnabled && precisionGeometry && precisionParentMap;
+ if (usePrecision) {
+ // Map original face indices to precision face indices for overlay
+ const refinedHover = new Set();
+ const len = precisionParentMap.length;
+ for (let i = 0; i < len; i++) {
+ if (hovered.has(precisionParentMap[i])) refinedHover.add(i);
+ }
+ setHoverPreview(buildExclusionOverlayGeo(precisionGeometry, refinedHover), eraseMode ? 0x999999 : 0xffee00);
+ } else {
+ setHoverPreview(buildExclusionOverlayGeo(currentGeometry, hovered), eraseMode ? 0x999999 : 0xffee00);
+ }
}
// ── Slider helper ─────────────────────────────────────────────────────────────
@@ -1217,8 +1331,24 @@ async function handleModelFile(file) {
settings.useDisplacement = false;
dispPreviewToggle.checked = false;
+ // Reset precision masking for the new mesh
+ if (precisionGeometry) { precisionGeometry.dispose(); precisionGeometry = null; }
+ precisionParentMap = null;
+ precisionEdgeLength = null;
+ precisionCentroids = null;
+ precisionBoundRadii = null;
+ precisionAdjacency = null;
+ precisionMaskingEnabled = false;
+ precisionMaskingToggle.checked = false;
+ precisionStatus.textContent = '';
+ precisionOutdated.classList.add('hidden');
+ precisionRefreshBtn.classList.add('hidden');
+ precisionWarning.classList.add('hidden');
+ precisionMaskingRow.classList.add('hidden');
+
// Reset exclusion state for the new mesh
excludedFaces = new Set();
+ precisionExcludedFaces = new Set();
exclusionTool = null;
eraseMode = false;
isPainting = false;
@@ -1298,14 +1428,21 @@ function updateFaceMask(geometry) {
const triCount = posCount / 3;
const maskArr = new Float32Array(posCount);
+ // Determine which face set to check
+ const isPrecision = (geometry === precisionGeometry && precisionMaskingEnabled);
+ const faceSet = isPrecision ? precisionExcludedFaces : excludedFaces;
+
// Fast path: no user exclusion active
- if (excludedFaces.size === 0 && !selectionMode) {
+ if (faceSet.size === 0 && !selectionMode) {
maskArr.fill(1.0);
} else {
const isDisp = (geometry === dispPreviewGeometry && dispPreviewParentMap);
for (let t = 0; t < triCount; t++) {
- const origFace = isDisp ? dispPreviewParentMap[t] : t;
- const excluded = selectionMode ? !excludedFaces.has(origFace) : excludedFaces.has(origFace);
+ // For precision geometry, t is already a precision face index.
+ // For disp preview, map through dispPreviewParentMap to original.
+ // Otherwise t is already an original face index.
+ const faceIdx = isDisp ? dispPreviewParentMap[t] : t;
+ const excluded = selectionMode ? !faceSet.has(faceIdx) : faceSet.has(faceIdx);
const val = excluded ? 0.0 : 1.0;
maskArr[t * 3] = val;
maskArr[t * 3 + 1] = val;
@@ -1626,6 +1763,215 @@ function addSmoothNormals(geometry) {
geometry.setAttribute('smoothNormal', new THREE.Float32BufferAttribute(sn, 3));
}
+// ── Precision masking ─────────────────────────────────────────────────────────
+
+/** Compute the target max edge length from the brush diameter. */
+function computePrecisionEdgeLength(brushDiameter) {
+ // ~20 edge segments around the brush circumference, clamped to a sane floor
+ return Math.max(0.05, Math.PI * brushDiameter / 20);
+}
+
+/**
+ * Estimate how many triangles subdivision will produce for a given edge length.
+ * Uses a sample of existing edges to compute average edge length, then
+ * assumes area-proportional subdivision: triCount × (avgEdge / target)².
+ */
+function estimateSubdivisionTriCount(geometry, targetEdge) {
+ const pos = geometry.attributes.position;
+ const triCount = pos.count / 3;
+ // Sample up to 3000 edges (1000 triangles × 3 edges)
+ const sampleTris = Math.min(triCount, 1000);
+ let totalEdgeLen = 0;
+ let edgeCount = 0;
+ for (let t = 0; t < sampleTris; t++) {
+ const i = t * 3;
+ for (let e = 0; e < 3; e++) {
+ const a = i + e, b = i + (e + 1) % 3;
+ const dx = pos.getX(a) - pos.getX(b);
+ const dy = pos.getY(a) - pos.getY(b);
+ const dz = pos.getZ(a) - pos.getZ(b);
+ totalEdgeLen += Math.sqrt(dx * dx + dy * dy + dz * dz);
+ edgeCount++;
+ }
+ }
+ if (edgeCount === 0) return triCount;
+ const avgEdge = totalEdgeLen / edgeCount;
+ const ratio = avgEdge / targetEdge;
+ return Math.max(triCount, Math.round(triCount * ratio * ratio));
+}
+
+/** Deactivate precision masking and bake the refined mesh as the new base geometry. */
+function deactivatePrecisionMasking() {
+ if (precisionGeometry) {
+ // Bake: the precision geometry becomes the new currentGeometry
+ if (currentGeometry && currentGeometry !== precisionGeometry) {
+ currentGeometry.dispose();
+ }
+ currentGeometry = precisionGeometry;
+
+ // Promote precision adjacency data to the base adjacency
+ triangleAdjacency = precisionAdjacency;
+ triangleCentroids = precisionCentroids;
+ triangleBoundRadii = precisionBoundRadii;
+
+ // Promote precision excluded faces to the base set
+ excludedFaces = precisionExcludedFaces;
+
+ // Update mesh info display
+ const triCount = getTriangleCount(currentGeometry);
+ const mb = ((currentGeometry.attributes.position.array.byteLength) / 1024 / 1024).toFixed(2);
+ const sx = currentBounds.size.x.toFixed(2);
+ const sy = currentBounds.size.y.toFixed(2);
+ const sz = currentBounds.size.z.toFixed(2);
+ meshInfo.textContent = t('ui.meshInfo', { n: triCount.toLocaleString(), mb, sx, sy, sz });
+ } else if (precisionExcludedFaces.size > 0 && precisionParentMap) {
+ // No precision geometry but have selections — map back to original
+ excludedFaces = new Set();
+ for (const pf of precisionExcludedFaces) {
+ excludedFaces.add(precisionParentMap[pf]);
+ }
+ }
+
+ // Clear all precision state
+ precisionExcludedFaces = new Set();
+ precisionGeometry = null;
+ precisionParentMap = null;
+ precisionEdgeLength = null;
+ precisionCentroids = null;
+ precisionBoundRadii = null;
+ precisionAdjacency = null;
+ precisionMaskingEnabled = false;
+ precisionMaskingToggle.checked = false;
+ precisionStatus.textContent = '';
+ precisionOutdated.classList.add('hidden');
+ precisionRefreshBtn.classList.add('hidden');
+ precisionWarning.classList.add('hidden');
+ if (currentGeometry) {
+ setMeshGeometry(currentGeometry);
+ updateFaceMask(currentGeometry);
+ if (excludedFaces.size > 0) refreshExclusionOverlay();
+ else setExclusionOverlay(null);
+ }
+}
+
+/** Refresh (or initially build) the precision mesh from current brush size. */
+async function refreshPrecisionMesh() {
+ if (!currentGeometry || precisionBusy) return;
+
+ const brushDiameter = parseFloat(exclBrushRadiusSlider.value);
+ const targetEdge = computePrecisionEdgeLength(brushDiameter);
+
+ // Estimate triangle count and warn if > 5M
+ const estimated = estimateSubdivisionTriCount(currentGeometry, targetEdge);
+ if (estimated > 5_000_000) {
+ const estLabel = (estimated / 1_000_000).toFixed(1) + 'M';
+ const msg = t('precision.warningBody', { n: estLabel });
+ if (!confirm(msg)) return;
+ }
+
+ precisionBusy = true;
+ precisionStatus.textContent = t('precision.refining');
+ precisionOutdated.classList.add('hidden');
+ precisionRefreshBtn.classList.add('hidden');
+ precisionWarning.classList.add('hidden');
+
+ try {
+ await yieldFrame();
+
+ const { geometry: subdivided, safetyCapHit, faceParentId } = await subdivide(
+ currentGeometry, targetEdge, null, null, { fast: true }
+ );
+
+ // Dispose previous precision geometry if any
+ if (precisionGeometry) precisionGeometry.dispose();
+ precisionGeometry = subdivided;
+ precisionParentMap = faceParentId;
+ precisionEdgeLength = targetEdge;
+
+ // Build adjacency data for the refined mesh
+ const adjData = buildAdjacency(precisionGeometry);
+ precisionAdjacency = adjData.adjacency;
+ precisionCentroids = adjData.centroids;
+ precisionBoundRadii = adjData.boundRadii;
+
+ // Seed precisionExcludedFaces from existing excludedFaces
+ precisionExcludedFaces = new Set();
+ if (excludedFaces.size > 0) {
+ const len = precisionParentMap.length;
+ for (let i = 0; i < len; i++) {
+ if (excludedFaces.has(precisionParentMap[i])) precisionExcludedFaces.add(i);
+ }
+ }
+
+ // Swap display mesh to refined geometry
+ setMeshGeometry(precisionGeometry);
+ updateFaceMask(precisionGeometry);
+ if (precisionExcludedFaces.size > 0) refreshExclusionOverlay();
+ else setExclusionOverlay(null);
+
+ // Update status label
+ const triCount = precisionGeometry.attributes.position.count / 3;
+ const triLabel = triCount >= 1_000_000
+ ? (triCount / 1_000_000).toFixed(1) + 'M'
+ : triCount >= 1_000
+ ? (triCount / 1_000).toFixed(0) + 'k'
+ : String(triCount);
+ precisionStatus.textContent = t('precision.triCount', { n: triLabel });
+
+ // Update mesh info in the lower-left corner
+ const mb = ((precisionGeometry.attributes.position.array.byteLength) / 1024 / 1024).toFixed(2);
+ const sx = currentBounds.size.x.toFixed(2);
+ const sy = currentBounds.size.y.toFixed(2);
+ const sz = currentBounds.size.z.toFixed(2);
+ meshInfo.textContent = t('ui.meshInfo', { n: triCount.toLocaleString(), mb, sx, sy, sz });
+
+ if (safetyCapHit) {
+ triLimitWarning.classList.remove('hidden');
+ }
+ } catch (err) {
+ console.error('Precision masking subdivision failed:', err);
+ deactivatePrecisionMasking();
+ } finally {
+ precisionBusy = false;
+ }
+}
+
+/** Toggle precision masking on/off. */
+async function togglePrecisionMasking(enable) {
+ if (enable) {
+ // Mutually exclusive with displacement preview
+ if (settings.useDisplacement) {
+ settings.useDisplacement = false;
+ dispPreviewToggle.checked = false;
+ await toggleDisplacementPreview(false);
+ }
+ precisionMaskingEnabled = true;
+ await refreshPrecisionMesh();
+ // If refresh was cancelled (e.g. user declined warning), revert
+ if (!precisionGeometry) {
+ precisionMaskingEnabled = false;
+ precisionMaskingToggle.checked = false;
+ }
+ } else {
+ deactivatePrecisionMasking();
+ }
+}
+
+/** Show/hide the "outdated" badge when brush size changes while precision is active. */
+function checkPrecisionOutdated() {
+ if (!precisionMaskingEnabled || !precisionEdgeLength) return;
+ const neededEdge = computePrecisionEdgeLength(parseFloat(exclBrushRadiusSlider.value));
+ // Show outdated if the needed edge is significantly smaller than current
+ // (brush shrank → mesh too coarse for the new brush size)
+ if (neededEdge < precisionEdgeLength * 0.8) {
+ precisionOutdated.classList.remove('hidden');
+ precisionRefreshBtn.classList.remove('hidden');
+ } else {
+ precisionOutdated.classList.add('hidden');
+ precisionRefreshBtn.classList.add('hidden');
+ }
+}
+
/**
* Toggle displacement preview on/off.
* When enabled: subdivides the current geometry to a moderate resolution,
@@ -1641,6 +1987,11 @@ async function toggleDisplacementPreview(enable) {
setExclusionTool(null);
}
+ // Deactivate precision masking when displacement preview is activated
+ if (enable && precisionMaskingEnabled) {
+ deactivatePrecisionMasking();
+ }
+
if (!enable) {
// Revert to original geometry with bump-only shading.
if (currentGeometry && previewMaterial) {
@@ -1760,6 +2111,11 @@ async function handleExport() {
exportBtn.classList.add('busy');
exportProgress.classList.remove('hidden');
+ // If precision masking is active, bake the refined mesh before exporting
+ if (precisionMaskingEnabled) {
+ deactivatePrecisionMasking();
+ }
+
try {
setProgress(0.02, t('progress.subdividing'));
await yieldFrame();
diff --git a/style.css b/style.css
index ec7e983..dcb895a 100644
--- a/style.css
+++ b/style.css
@@ -819,6 +819,77 @@ input[type="number"].val:focus { outline: none; border-color: var(--accent); }
/* Hide utility (used by JS to show/hide exclusion sub-rows) */
.form-row.hidden { display: none; }
+/* ── Precision masking ───────────────────────────────────────────────── */
+.precision-masking-row {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin-bottom: 10px;
+}
+
+.precision-masking-row.hidden { display: none; }
+
+.precision-label {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 11px;
+ color: var(--text);
+ cursor: pointer;
+ white-space: nowrap;
+}
+
+.precision-label input[type="checkbox"] {
+ margin: 0;
+}
+
+.precision-status {
+ font-size: 10px;
+ color: var(--text-muted);
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+}
+
+.precision-outdated {
+ font-size: 10px;
+ color: #f59e0b;
+ white-space: nowrap;
+}
+
+.precision-outdated.hidden { display: none; }
+
+.precision-refresh-btn {
+ padding: 2px 6px;
+ background: var(--surface2);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ color: var(--text-muted);
+ font-size: 12px;
+ cursor: pointer;
+ transition: background 0.15s, border-color 0.15s, color 0.15s;
+ line-height: 1;
+}
+
+.precision-refresh-btn:hover {
+ border-color: var(--accent);
+ color: var(--accent);
+}
+
+.precision-refresh-btn.hidden { display: none; }
+
+.precision-warning {
+ background: color-mix(in srgb, #f59e0b 15%, var(--surface2));
+ border: 1px solid #f59e0b;
+ color: #f59e0b;
+ border-radius: var(--radius);
+ padding: 6px 10px;
+ font-size: 11px;
+ line-height: 1.4;
+ margin-bottom: 8px;
+}
+
+.precision-warning.hidden { display: none; }
+
/* ── Sponsor / popup overlay ─────────────────────────────────────────── */
.sponsor-overlay {
position: fixed;