diff --git a/index.html b/index.html
index 990a9e1..9719dd3 100644
--- a/index.html
+++ b/index.html
@@ -328,8 +328,8 @@
-
- 1.0 M
+
+ 0.75 M
⚠ 20M-triangle safety cap hit during subdivision — result may still be coarser than requested edge length.
diff --git a/js/exclusion.js b/js/exclusion.js
index bcadcb1..b379d30 100644
--- a/js/exclusion.js
+++ b/js/exclusion.js
@@ -31,9 +31,10 @@ export function buildAdjacency(geometry) {
const posAttr = geometry.attributes.position;
const triCount = posAttr.count / 3;
- // Pre-allocate face normals and centroids
+ // Pre-allocate face normals, centroids, and per-triangle bounding radii
const faceNormals = new Float32Array(triCount * 3);
const centroids = new Float32Array(triCount * 3);
+ const boundRadii = new Float32Array(triCount); // max vertex-to-centroid distance
const vA = new THREE.Vector3();
const vB = new THREE.Vector3();
@@ -56,9 +57,16 @@ export function buildAdjacency(geometry) {
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;
+ const cx = (vA.x + vB.x + vC.x) / 3;
+ const cy = (vA.y + vB.y + vC.y) / 3;
+ const cz = (vA.z + vB.z + vC.z) / 3;
+ centroids[i] = cx;
+ centroids[i + 1] = cy;
+ centroids[i + 2] = cz;
+ const dA = (vA.x-cx)**2 + (vA.y-cy)**2 + (vA.z-cz)**2;
+ const dB = (vB.x-cx)**2 + (vB.y-cy)**2 + (vB.z-cz)**2;
+ const dC = (vC.x-cx)**2 + (vC.y-cy)**2 + (vC.z-cz)**2;
+ boundRadii[t] = Math.sqrt(Math.max(dA, dB, dC));
}
// Build edge → triangle list (two triangles share an edge iff they share two
@@ -102,7 +110,7 @@ export function buildAdjacency(geometry) {
adjacency.get(b).push({ neighbor: a, angle: angleDeg });
}
- return { adjacency, centroids };
+ return { adjacency, centroids, boundRadii };
}
// ── Bucket fill ───────────────────────────────────────────────────────────────
diff --git a/js/main.js b/js/main.js
index 45c05e0..8cf7409 100644
--- a/js/main.js
+++ b/js/main.js
@@ -27,6 +27,7 @@ let previewDebounce = null;
let excludedFaces = new Set(); // triangle indices in currentGeometry
let triangleAdjacency = null; // Map from buildAdjacency
let triangleCentroids = null; // Float32Array from buildAdjacency
+let triangleBoundRadii = null; // Float32Array — max vertex-to-centroid dist per tri
let exclusionTool = null; // 'brush' | 'bucket' | null
let eraseMode = false;
let brushIsRadius = false;
@@ -47,7 +48,7 @@ const settings = {
offsetV: 0.0,
rotation: 0,
refineLength: 1.0,
- maxTriangles: 1_000_000,
+ maxTriangles: 750_000,
lockScale: true,
bottomAngleLimit: 5,
topAngleLimit: 0,
@@ -725,6 +726,81 @@ function pickTriangle(e) {
return fi;
}
+/**
+ * Squared distance from point P to the closest point on triangle ABC.
+ * Uses the Voronoi-region method (no allocations, pure arithmetic).
+ */
+function distSqPointToTri(px, py, pz, ax, ay, az, bx, by, bz, cx, cy, cz) {
+ const abx = bx-ax, aby = by-ay, abz = bz-az;
+ const acx = cx-ax, acy = cy-ay, acz = cz-az;
+ const apx = px-ax, apy = py-ay, apz = pz-az;
+
+ const d1 = abx*apx + aby*apy + abz*apz;
+ const d2 = acx*apx + acy*apy + acz*apz;
+ if (d1 <= 0 && d2 <= 0) return apx*apx + apy*apy + apz*apz; // vertex A
+
+ const bpx = px-bx, bpy = py-by, bpz = pz-bz;
+ const d3 = abx*bpx + aby*bpy + abz*bpz;
+ const d4 = acx*bpx + acy*bpy + acz*bpz;
+ if (d3 >= 0 && d4 <= d3) return bpx*bpx + bpy*bpy + bpz*bpz; // vertex B
+
+ const cpx = px-cx, cpy = py-cy, cpz = pz-cz;
+ const d5 = abx*cpx + aby*cpy + abz*cpz;
+ const d6 = acx*cpx + acy*cpy + acz*cpz;
+ if (d6 >= 0 && d5 <= d6) return cpx*cpx + cpy*cpy + cpz*cpz; // vertex C
+
+ const vc = d1*d4 - d3*d2;
+ if (vc <= 0 && d1 >= 0 && d3 <= 0) { // edge AB
+ const v = d1 / (d1 - d3);
+ const qx = ax+v*abx-px, qy = ay+v*aby-py, qz = az+v*abz-pz;
+ return qx*qx + qy*qy + qz*qz;
+ }
+
+ const vb = d5*d2 - d1*d6;
+ if (vb <= 0 && d2 >= 0 && d6 <= 0) { // edge AC
+ const w = d2 / (d2 - d6);
+ const qx = ax+w*acx-px, qy = ay+w*acy-py, qz = az+w*acz-pz;
+ return qx*qx + qy*qy + qz*qz;
+ }
+
+ const va = d3*d6 - d5*d4;
+ if (va <= 0 && (d4-d3) >= 0 && (d5-d6) >= 0) { // edge BC
+ const w = (d4-d3) / ((d4-d3) + (d5-d6));
+ const qx = bx+w*(cx-bx)-px, qy = by+w*(cy-by)-py, qz = bz+w*(cz-bz)-pz;
+ return qx*qx + qy*qy + qz*qz;
+ }
+
+ // Inside triangle
+ const den = 1 / (va + vb + vc);
+ const v = vb*den, w = vc*den;
+ const qx = ax+abx*v+acx*w-px, qy = ay+aby*v+acy*w-py, qz = az+abz*v+acz*w-pz;
+ return qx*qx + qy*qy + qz*qz;
+}
+
+/** 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 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];
+ if (dx*dx + dy*dy + dz*dz > bound*bound) continue;
+ // Precise sphere-triangle test
+ const i = t * 3;
+ const d2 = distSqPointToTri(
+ hitPt.x, hitPt.y, hitPt.z,
+ pos.getX(i), pos.getY(i), pos.getZ(i),
+ pos.getX(i+1), pos.getY(i+1), pos.getZ(i+1),
+ pos.getX(i+2), pos.getY(i+2), pos.getZ(i+2),
+ );
+ if (d2 <= r2) cb(t);
+ }
+}
+
function paintAt(e) {
const mesh = getCurrentMesh();
if (!mesh) return;
@@ -740,17 +816,10 @@ function paintAt(e) {
}
if (brushIsRadius) {
- const hitPt = hit.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);
- }
- }
+ forEachTriInSphere(hit.point, r2, t => {
+ if (eraseMode) excludedFaces.delete(t); else excludedFaces.add(t);
+ });
} else {
if (eraseMode) excludedFaces.delete(triIdx); else excludedFaces.add(triIdx);
}
@@ -865,7 +934,7 @@ function handlePlaceOnFaceClick(e) {
// Rebuild adjacency
const adjData = buildAdjacency(currentGeometry);
triangleAdjacency = adjData.adjacency;
- triangleCentroids = adjData.centroids;
+ triangleCentroids = adjData.centroids; triangleBoundRadii = adjData.boundRadii;
// Update edge length for new bounds
const maxDim = Math.max(currentBounds.size.x, currentBounds.size.y, currentBounds.size.z);
@@ -974,21 +1043,15 @@ function updateBrushHover(e) {
if (triIdx === _lastHoverTriIdx) return;
_lastHoverTriIdx = triIdx;
+ const hoverColor = eraseMode ? 0x999999 : 0xffee00;
if (brushIsRadius) {
- const hitPt = hit.point;
- const triCount = triangleCentroids.length / 3;
const r2 = brushRadius * brushRadius;
const hovered = new Set();
- for (let t = 0; t < triCount; t++) {
- const dx = triangleCentroids[t * 3] - hitPt.x;
- const dy = triangleCentroids[t * 3 + 1] - hitPt.y;
- const dz = triangleCentroids[t * 3 + 2] - hitPt.z;
- if (dx * dx + dy * dy + dz * dz <= r2) hovered.add(t);
- }
- setHoverPreview(buildExclusionOverlayGeo(currentGeometry, hovered));
+ forEachTriInSphere(hit.point, r2, t => hovered.add(t));
+ setHoverPreview(buildExclusionOverlayGeo(currentGeometry, hovered), hoverColor);
} else {
const hovered = new Set([triIdx]);
- setHoverPreview(buildExclusionOverlayGeo(currentGeometry, hovered));
+ setHoverPreview(buildExclusionOverlayGeo(currentGeometry, hovered), hoverColor);
}
}
@@ -1001,7 +1064,7 @@ function updateBucketHover(e) {
return;
}
const hovered = bucketFill(triIdx, triangleAdjacency, bucketThreshold);
- setHoverPreview(buildExclusionOverlayGeo(currentGeometry, hovered));
+ setHoverPreview(buildExclusionOverlayGeo(currentGeometry, hovered), eraseMode ? 0x999999 : 0xffee00);
}
// ── Slider helper ─────────────────────────────────────────────────────────────
@@ -1096,7 +1159,7 @@ function loadDefaultCube() {
const adjData = buildAdjacency(geo);
triangleAdjacency = adjData.adjacency;
- triangleCentroids = adjData.centroids;
+ triangleCentroids = adjData.centroids; triangleBoundRadii = adjData.boundRadii;
settings.scaleU = 0.5; scaleUSlider.value = scaleToPos(0.5); scaleUVal.value = 0.5;
settings.scaleV = 0.5; scaleVSlider.value = scaleToPos(0.5); scaleVVal.value = 0.5;
@@ -1174,7 +1237,7 @@ async function handleModelFile(file) {
// typical STL sizes processed by this tool)
const adjData = buildAdjacency(geometry);
triangleAdjacency = adjData.adjacency;
- triangleCentroids = adjData.centroids;
+ triangleCentroids = adjData.centroids; triangleBoundRadii = adjData.boundRadii;
// Reset scale & offset sliders so scale=1 = one tile covers the full bounding box
const resetVal = (slider, valEl, value) => {
diff --git a/js/viewer.js b/js/viewer.js
index eeac1aa..646b66a 100644
--- a/js/viewer.js
+++ b/js/viewer.js
@@ -582,7 +582,7 @@ export function setExclusionOverlay(overlayGeo, color = 0xff6600, opacity = 1.0)
*
* @param {THREE.BufferGeometry|null} overlayGeo
*/
-export function setHoverPreview(overlayGeo) {
+export function setHoverPreview(overlayGeo, color = 0xffee00) {
if (hoverMesh) {
scene.remove(hoverMesh);
hoverMesh.geometry.dispose();
@@ -593,7 +593,7 @@ export function setHoverPreview(overlayGeo) {
hoverMesh = new THREE.Mesh(
overlayGeo,
new THREE.MeshBasicMaterial({
- color: 0xffee00,
+ color,
side: THREE.DoubleSide,
transparent: true,
opacity: 0.45,
diff --git a/textures/stripes.png b/textures/stripes.png
index 79a59f8..d10fb8c 100644
Binary files a/textures/stripes.png and b/textures/stripes.png differ