diff --git a/index.html b/index.html
index 53a8428..f3e2e47 100644
--- a/index.html
+++ b/index.html
@@ -9,7 +9,10 @@
// Apply saved theme before first paint to avoid flash
(function() {
const t = localStorage.getItem('stlt-theme');
- if (t !== 'dark') document.documentElement.setAttribute('data-theme', 'light');
+ // If user never picked a theme, respect their OS preference
+ const prefersDark = t ? t === 'dark'
+ : window.matchMedia('(prefers-color-scheme: dark)').matches;
+ if (!prefersDark) document.documentElement.setAttribute('data-theme', 'light');
})();
// Apply saved language before first paint to avoid flash
(function() {
diff --git a/js/decimation.js b/js/decimation.js
index 6b5c23a..eda0b12 100644
--- a/js/decimation.js
+++ b/js/decimation.js
@@ -48,8 +48,8 @@ const CREASE_WEIGHT = 1e4; // quadric penalty weight for crease edges
// Module-level scratch arrays for hasLinkViolation — avoids new Map() per call.
// Size 128 exceeds the maximum practical vertex valence in any STL mesh.
-const _hlvHi = new Float64Array(128);
-const _hlvLo = new Int32Array(128);
+const _hlvHi = new Float64Array(512);
+const _hlvLo = new Int32Array(512);
// ── Public API ───────────────────────────────────────────────────────────────
@@ -97,12 +97,23 @@ export async function decimate(geometry, targetTriangles, onProgress) {
const initFaces = activeFaces;
const toRemove = initFaces - targetTriangles;
let lastProg = 0;
- let collapses = 0;
+ let iterations = 0;
while (activeFaces > targetTriangles && heap.size() > 0) {
const idx = heap.pop();
if (idx < 0) break;
+ // Yield periodically based on total iterations (including rejections)
+ // to keep the UI responsive. Critical for flat / low-displacement
+ // surfaces where most collapses are rejected by the safety guards.
+ if ((++iterations & 4095) === 0) {
+ await new Promise(r => setTimeout(r, 0));
+ if (onProgress) {
+ const p = Math.min(1, (initFaces - activeFaces) / toRemove);
+ if (p - lastProg > 0.005) { onProgress(p); lastProg = p; }
+ }
+ }
+
const v1 = heap.getV1(idx), v2 = heap.getV2(idx);
const ver1 = heap.getVer1(idx), ver2 = heap.getVer2(idx);
const px = heap.getPx(idx), py = heap.getPy(idx), pz = heap.getPz(idx);
@@ -170,13 +181,7 @@ export async function decimate(geometry, targetTriangles, onProgress) {
}
}
- if (onProgress && (++collapses & 511) === 0) {
- const p = Math.min(1, (initFaces - activeFaces) / toRemove);
- if (p - lastProg > 0.015) {
- onProgress(p); lastProg = p;
- await new Promise(r => setTimeout(r, 0));
- }
- }
+
}
if (onProgress) onProgress(1);
@@ -498,14 +503,25 @@ function pushEdge(heap, quadrics, positions, version, v1, v2) {
const e1 = evalQSum(quadrics, v1, v2, positions[v1*3], positions[v1*3+1], positions[v1*3+2]);
const e2 = evalQSum(quadrics, v1, v2, positions[v2*3], positions[v2*3+1], positions[v2*3+2]);
const em = evalQSum(quadrics, v1, v2, mx, my, mz);
- if (e1 <= e2 && e1 <= em) { px = positions[v1*3]; py = positions[v1*3+1]; pz = positions[v1*3+2]; }
- else if (e2 <= em) { px = positions[v2*3]; py = positions[v2*3+1]; pz = positions[v2*3+2]; }
- else { px = mx; py = my; pz = mz; }
+ // Prefer midpoint when costs are near-equal (degenerate / flat surfaces).
+ // Midpoint minimises displacement of adjacent triangles, reducing normal
+ // flips and preventing the collapse loop from stalling on coplanar geometry.
+ const eMin = Math.min(e1, e2, em);
+ const eTol = eMin * 1e-2 + 1e-12;
+ if (em <= eMin + eTol) { px = mx; py = my; pz = mz; }
+ else if (e1 <= e2) { px = positions[v1*3]; py = positions[v1*3+1]; pz = positions[v1*3+2]; }
+ else { px = positions[v2*3]; py = positions[v2*3+1]; pz = positions[v2*3+2]; }
}
const cost = evalQSum(quadrics, v1, v2, px, py, pz);
- // Snapshot both vertices' versions so the pop-side check can detect staleness
- heap.push(cost, v1, v2, version[v1], version[v2], px, py, pz);
+ // Tiny edge-length tiebreaker: on degenerate (flat) surfaces where QEM
+ // costs are ~0, prefer collapsing shorter edges first for better triangle
+ // quality and fewer guard rejections.
+ const dx = positions[v2*3] - positions[v1*3];
+ const dy = positions[v2*3+1] - positions[v1*3+1];
+ const dz = positions[v2*3+2] - positions[v1*3+2];
+ heap.push(cost + (dx*dx + dy*dy + dz*dz) * 1e-8,
+ v1, v2, version[v1], version[v2], px, py, pz);
}
// ── Indexed <-> Non-indexed conversion ──────────────────────────────────────
diff --git a/js/presetTextures.js b/js/presetTextures.js
index af7ad4b..7f6509a 100644
--- a/js/presetTextures.js
+++ b/js/presetTextures.js
@@ -34,6 +34,7 @@ const IMAGE_PRESETS = [
{ name: 'Bubble', url: 'textures/bubble.jpg' },
{ name: 'Carbon Fiber', url: 'textures/carbonFiber.jpg' },
{ name: 'Crystal', url: 'textures/crystal.jpg' },
+ { name: 'Dots', url: 'textures/dots.jpg' },
{ name: 'Grip Surface', url: 'textures/gripSurface.jpg' },
{ name: 'Hexagons', url: 'textures/hexagons.jpg' },
{ name: 'Knitting', url: 'textures/knitting.jpg' },
diff --git a/textures/dots.jpg b/textures/dots.jpg
new file mode 100644
index 0000000..91c0e59
Binary files /dev/null and b/textures/dots.jpg differ
diff --git a/textures/hexagons.jpg b/textures/hexagons.jpg
index 003da8c..3213be6 100644
Binary files a/textures/hexagons.jpg and b/textures/hexagons.jpg differ