fix: spatial index, decimation overflow, input validation, accessibility

Round 2 of performance and correctness improvements:

- Spatial grid index for brush painting: forEachTriInSphere now queries
  only nearby grid cells instead of scanning all triangles. ~5.7x faster
  for brush operations on 68k+ tri meshes.

- Decimation overflow fix: hasLinkViolation used a fixed 0x200000
  multiplier for vertex-pair keys, overflowing at >2M vertices.
  Now uses dynamic multiplier based on actual vertex count.

- Decimation determinant threshold: solveQ used absolute 1e-10 which
  fails for large coordinates. Now relative to matrix element magnitude.

- 3MF triangle index validation: bounds-check all parsed indices against
  vertex count, throw clear error on corrupt files instead of silent NaN.

- File size limit: reject files >500 MB before loading into memory,
  prevents browser tab crash on oversized files.

- Accessibility: preset swatches now keyboard-navigable (role=button,
  tabindex=0, Enter/Space to select). Modal dialogs trap focus and
  close on Escape.

- Ctrl+click straight line tool: click to set start point, Ctrl+click
  to paint a straight line between points. Ctrl+hover shows preview.

- Precision masking available for radius brush mode.

- Spatial grid rebuilt when entering/leaving precision mode.
This commit is contained in:
Avatarsia
2026-04-06 05:14:23 +02:00
parent 4811b55d5c
commit 689c192a89
5 changed files with 270 additions and 46 deletions
+8 -5
View File
@@ -134,7 +134,7 @@ export async function decimate(geometry, targetTriangles, onProgress) {
if (nsh < 2) continue; if (nsh < 2) continue;
// ── Three safety guards ─────────────────────────────────────────────────── // ── Three safety guards ───────────────────────────────────────────────────
if (hasLinkViolation(faces, vfHead, slotFace, slotNext, v1, v2)) continue; // Guard 2 if (hasLinkViolation(faces, vfHead, slotFace, slotNext, v1, v2, vertCount)) continue; // Guard 2
if (checkFlipped(positions, vfHead, slotFace, slotNext, faces, v1, v2, px, py, pz)) continue; // Guard 3a if (checkFlipped(positions, vfHead, slotFace, slotNext, faces, v1, v2, px, py, pz)) continue; // Guard 3a
if (checkFlipped(positions, vfHead, slotFace, slotNext, faces, v2, v1, px, py, pz)) continue; // Guard 3b if (checkFlipped(positions, vfHead, slotFace, slotNext, faces, v2, v1, px, py, pz)) continue; // Guard 3b
@@ -267,7 +267,8 @@ function sharedFaceCount(faces, vfHead, slotFace, slotNext, v1, v2) {
// Uses module-level scratch arrays (_hlvHi, _hlvLo) — zero allocation per call. // Uses module-level scratch arrays (_hlvHi, _hlvLo) — zero allocation per call.
// Linear scan is faster than Set for typical STL vertex valence (5-8). // Linear scan is faster than Set for typical STL vertex valence (5-8).
function hasLinkViolation(faces, vfHead, slotFace, slotNext, v1, v2) { function hasLinkViolation(faces, vfHead, slotFace, slotNext, v1, v2, vc) {
const MUL = vc < 0x200000 ? 0x200000 : vc + 1;
let n = 0; let n = 0;
for (let s = vfHead[v1]; s >= 0; s = slotNext[s]) { for (let s = vfHead[v1]; s >= 0; s = slotNext[s]) {
const f = slotFace[s]; const f = slotFace[s];
@@ -278,7 +279,7 @@ function hasLinkViolation(faces, vfHead, slotFace, slotNext, v1, v2) {
if (fa > fb) { t = fa; fa = fb; fb = t; } if (fa > fb) { t = fa; fa = fb; fb = t; }
if (fb > fc) { t = fb; fb = fc; fc = t; } if (fb > fc) { t = fb; fb = fc; fc = t; }
if (fa > fb) { t = fa; fa = fb; fb = t; } if (fa > fb) { t = fa; fa = fb; fb = t; }
_hlvHi[n] = fa * 0x200000 + fb; _hlvHi[n] = fa * MUL + fb;
_hlvLo[n] = fc; _hlvLo[n] = fc;
n++; n++;
} }
@@ -292,7 +293,7 @@ function hasLinkViolation(faces, vfHead, slotFace, slotNext, v1, v2) {
if (fa > fb) { t = fa; fa = fb; fb = t; } if (fa > fb) { t = fa; fa = fb; fb = t; }
if (fb > fc) { t = fb; fb = fc; fc = t; } if (fb > fc) { t = fb; fb = fc; fc = t; }
if (fa > fb) { t = fa; fa = fb; fb = t; } if (fa > fb) { t = fa; fa = fb; fb = t; }
const hi = fa * 0x200000 + fb; const hi = fa * MUL + fb;
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
if (_hlvHi[i] === hi && _hlvLo[i] === fc) return true; if (_hlvHi[i] === hi && _hlvLo[i] === fc) return true;
} }
@@ -495,7 +496,9 @@ function solveQ(q, v1, v2) {
const b2 = -(q[o1+8] + q[o2+8]); const b2 = -(q[o1+8] + q[o2+8]);
const det = a00*(a11*a22 - a12*a12) - a01*(a01*a22 - a12*a02) + a02*(a01*a12 - a11*a02); const det = a00*(a11*a22 - a12*a12) - a01*(a01*a22 - a12*a02) + a02*(a01*a12 - a11*a02);
if (Math.abs(det) < 1e-10) return false; const maxEl = Math.max(Math.abs(a00), Math.abs(a01), Math.abs(a02), Math.abs(a11), Math.abs(a12), Math.abs(a22));
const threshold = maxEl * maxEl * maxEl * 1e-10;
if (Math.abs(det) < Math.max(threshold, 1e-30)) return false;
const inv = 1 / det; const inv = 1 / det;
_s[0] = inv * (b0*(a11*a22 - a12*a12) - a01*(b1*a22 - a12*b2) + a02*(b1*a12 - a11*b2)); _s[0] = inv * (b0*(a11*a22 - a12*a12) - a01*(b1*a22 - a12*b2) + a02*(b1*a12 - a11*b2));
+27 -7
View File
@@ -160,24 +160,44 @@ export function buildExclusionOverlayGeo(geometry, faceSet, invert = false) {
const srcPos = geometry.attributes.position.array; const srcPos = geometry.attributes.position.array;
const srcNrm = geometry.attributes.normal ? geometry.attributes.normal.array : null; const srcNrm = geometry.attributes.normal ? geometry.attributes.normal.array : null;
const total = srcPos.length / 9; // total triangle count const total = srcPos.length / 9; // total triangle count
const count = invert ? total - faceSet.size : faceSet.size; const isArr = faceSet instanceof Uint8Array;
// Count included faces
let setSize;
if (isArr) {
setSize = 0;
for (let i = 0; i < faceSet.length; i++) if (faceSet[i]) setSize++;
} else {
setSize = faceSet.size;
}
const count = invert ? total - setSize : setSize;
const outPos = new Float32Array(count * 9); const outPos = new Float32Array(count * 9);
const outNrm = srcNrm ? new Float32Array(count * 9) : null; const outNrm = srcNrm ? new Float32Array(count * 9) : null;
let dst = 0; let dst = 0;
if (invert) { if (invert) {
for (let t = 0; t < total; t++) { for (let t = 0; t < total; t++) {
if (faceSet.has(t)) continue; if (isArr ? faceSet[t] : faceSet.has(t)) continue;
const src = t * 9; const src = t * 9;
outPos.set(srcPos.subarray(src, src + 9), dst); outPos.set(srcPos.subarray(src, src + 9), dst);
if (outNrm) outNrm.set(srcNrm.subarray(src, src + 9), dst); if (outNrm) outNrm.set(srcNrm.subarray(src, src + 9), dst);
dst += 9; dst += 9;
} }
} else { } else {
for (const t of faceSet) { if (isArr) {
const src = t * 9; for (let t = 0; t < faceSet.length; t++) {
outPos.set(srcPos.subarray(src, src + 9), dst); if (!faceSet[t]) continue;
if (outNrm) outNrm.set(srcNrm.subarray(src, src + 9), dst); const src = t * 9;
dst += 9; outPos.set(srcPos.subarray(src, src + 9), dst);
if (outNrm) outNrm.set(srcNrm.subarray(src, src + 9), dst);
dst += 9;
}
} else {
for (const t of faceSet) {
const src = t * 9;
outPos.set(srcPos.subarray(src, src + 9), dst);
if (outNrm) outNrm.set(srcNrm.subarray(src, src + 9), dst);
dst += 9;
}
} }
} }
const geo = new THREE.BufferGeometry(); const geo = new THREE.BufferGeometry();
+4 -2
View File
@@ -81,7 +81,7 @@ export const TRANSLATIONS = {
'excl.toolBrushTitle': 'Brush: paint triangles to exclude', 'excl.toolBrushTitle': 'Brush: paint triangles to exclude',
'excl.toolFill': 'Fill', 'excl.toolFill': 'Fill',
'excl.toolFillTitle': 'Bucket fill: flood-fill surface up to a threshold angle', 'excl.toolFillTitle': 'Bucket fill: flood-fill surface up to a threshold angle',
'excl.shiftHint': 'Hold Shift to erase', 'excl.shiftHint': 'Shift: erase \u00b7 Ctrl+click: straight line',
'labels.type': 'Type', 'labels.type': 'Type',
'brushType.single': 'Single', 'brushType.single': 'Single',
'brushType.circle': 'Circle', 'brushType.circle': 'Circle',
@@ -185,6 +185,7 @@ export const TRANSLATIONS = {
// Alerts // Alerts
'alerts.loadFailed': 'Could not load model: {msg}', 'alerts.loadFailed': 'Could not load model: {msg}',
'alerts.exportFailed': 'Export failed: {msg}', 'alerts.exportFailed': 'Export failed: {msg}',
'alerts.fileTooLarge': 'File too large ({size} MB). Maximum: {max} MB.',
}, },
de: { de: {
@@ -266,7 +267,7 @@ export const TRANSLATIONS = {
'excl.toolBrushTitle': 'Pinsel: Dreiecke zum Ausschlie\u00dfen einf\u00e4rben', 'excl.toolBrushTitle': 'Pinsel: Dreiecke zum Ausschlie\u00dfen einf\u00e4rben',
'excl.toolFill': 'F\u00fcllen', 'excl.toolFill': 'F\u00fcllen',
'excl.toolFillTitle': 'F\u00fcllen: Fl\u00e4che bis zu einem Winkel fluten', 'excl.toolFillTitle': 'F\u00fcllen: Fl\u00e4che bis zu einem Winkel fluten',
'excl.shiftHint': 'Shift gedr\u00fcckt halten zum Radieren', 'excl.shiftHint': 'Shift: Radieren \u00b7 Strg+Klick: Gerade Linie',
'labels.type': 'Typ', 'labels.type': 'Typ',
'brushType.single': 'Einzeln', 'brushType.single': 'Einzeln',
'brushType.circle': 'Kreis', 'brushType.circle': 'Kreis',
@@ -370,6 +371,7 @@ export const TRANSLATIONS = {
// Alerts // Alerts
'alerts.loadFailed': 'Modell konnte nicht geladen werden: {msg}', 'alerts.loadFailed': 'Modell konnte nicht geladen werden: {msg}',
'alerts.exportFailed': 'Export fehlgeschlagen: {msg}', 'alerts.exportFailed': 'Export fehlgeschlagen: {msg}',
'alerts.fileTooLarge': 'Datei zu gross ({size} MB). Maximum: {max} MB.',
}, },
}; };
+207 -32
View File
@@ -39,6 +39,8 @@ let selectionMode = false; // false = exclude painted faces; true = i
let _lastHoverTriIdx = -1; // last triangle index used for hover preview let _lastHoverTriIdx = -1; // last triangle index used for hover preview
let placeOnFaceActive = false; // true while "Place on Face" mode is active let placeOnFaceActive = false; // true while "Place on Face" mode is active
const _raycaster = new THREE.Raycaster(); const _raycaster = new THREE.Raycaster();
let _lastPaintHitPoint = null; // THREE.Vector3 — last brush paint position for shift-line
let _shiftLineMesh = null; // THREE.Line — preview line from last paint to cursor
let _lastEffectiveTexture = null; let _lastEffectiveTexture = null;
let _effectiveMapCache = null; let _effectiveMapCache = null;
let _effectiveMapCacheKey = null; let _effectiveMapCacheKey = null;
@@ -310,6 +312,8 @@ function buildPresetGrid() {
PRESETS.forEach((preset, idx) => { PRESETS.forEach((preset, idx) => {
const swatch = document.createElement('div'); const swatch = document.createElement('div');
swatch.className = 'preset-swatch'; swatch.className = 'preset-swatch';
swatch.setAttribute('role', 'button');
swatch.setAttribute('tabindex', '0');
swatch.title = preset.name; swatch.title = preset.name;
// Use the small thumbnail canvas // Use the small thumbnail canvas
@@ -321,6 +325,12 @@ function buildPresetGrid() {
swatch.appendChild(label); swatch.appendChild(label);
swatch.addEventListener('click', () => selectPreset(idx, swatch)); swatch.addEventListener('click', () => selectPreset(idx, swatch));
swatch.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
selectPreset(idx, swatch);
}
});
presetGrid.appendChild(swatch); presetGrid.appendChild(swatch);
}); });
} }
@@ -341,6 +351,32 @@ function selectPreset(idx, swatchEl) {
updatePreview(); updatePreview();
} }
// ── Accessibility: Modal focus trap ───────────────────────────────────────────
function trapFocus(overlay) {
const focusable = overlay.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
first.focus();
function handler(e) {
if (e.key === 'Escape') {
overlay.classList.add('hidden');
overlay.removeEventListener('keydown', handler);
return;
}
if (e.key !== 'Tab') return;
if (e.shiftKey) {
if (document.activeElement === first) { e.preventDefault(); last.focus(); }
} else {
if (document.activeElement === last) { e.preventDefault(); first.focus(); }
}
}
overlay.addEventListener('keydown', handler);
}
// ── Event wiring ────────────────────────────────────────────────────────────── // ── Event wiring ──────────────────────────────────────────────────────────────
function wireEvents() { function wireEvents() {
@@ -466,14 +502,14 @@ function wireEvents() {
}); });
// ── License ── // ── License ──
licenseLink.addEventListener('click', () => licenseOverlay.classList.remove('hidden')); licenseLink.addEventListener('click', () => { licenseOverlay.classList.remove('hidden'); trapFocus(licenseOverlay); });
licenseClose.addEventListener('click', () => licenseOverlay.classList.add('hidden')); licenseClose.addEventListener('click', () => licenseOverlay.classList.add('hidden'));
licenseOverlay.addEventListener('click', (e) => { licenseOverlay.addEventListener('click', (e) => {
if (e.target === licenseOverlay) licenseOverlay.classList.add('hidden'); if (e.target === licenseOverlay) licenseOverlay.classList.add('hidden');
}); });
// ── Imprint & Privacy ── // ── Imprint & Privacy ──
imprintLink.addEventListener('click', () => imprintOverlay.classList.remove('hidden')); imprintLink.addEventListener('click', () => { imprintOverlay.classList.remove('hidden'); trapFocus(imprintOverlay); });
imprintClose.addEventListener('click', () => imprintOverlay.classList.add('hidden')); imprintClose.addEventListener('click', () => imprintOverlay.classList.add('hidden'));
imprintOverlay.addEventListener('click', (e) => { imprintOverlay.addEventListener('click', (e) => {
if (e.target === imprintOverlay) imprintOverlay.classList.add('hidden'); if (e.target === imprintOverlay) imprintOverlay.classList.add('hidden');
@@ -494,6 +530,7 @@ function wireEvents() {
const closeBtn = document.getElementById('sponsor-close'); const closeBtn = document.getElementById('sponsor-close');
const storeLink = overlay.querySelector('.sponsor-link'); const storeLink = overlay.querySelector('.sponsor-link');
overlay.classList.remove('hidden'); overlay.classList.remove('hidden');
trapFocus(overlay);
const dismiss = () => { const dismiss = () => {
if (document.getElementById('sponsor-dont-show').checked) { if (document.getElementById('sponsor-dont-show').checked) {
@@ -530,7 +567,6 @@ function wireEvents() {
exclBrushRadiusBtn.classList.remove('active'); exclBrushRadiusBtn.classList.remove('active');
exclRadiusRow.classList.add('hidden'); exclRadiusRow.classList.add('hidden');
precisionMaskingRow.classList.add('hidden'); precisionMaskingRow.classList.add('hidden');
// Deactivate precision when switching away from circle mode
if (precisionMaskingEnabled) deactivatePrecisionMasking(); if (precisionMaskingEnabled) deactivatePrecisionMasking();
canvas.style.cursor = exclusionTool ? 'crosshair' : ''; canvas.style.cursor = exclusionTool ? 'crosshair' : '';
brushCursorEl.style.display = 'none'; brushCursorEl.style.display = 'none';
@@ -683,6 +719,7 @@ function wireEvents() {
if (exclusionTool === 'brush') { if (exclusionTool === 'brush') {
updateBrushCursor(ev); updateBrushCursor(ev);
if (brushIsRadius && !isPainting && currentGeometry) updateBrushHover(ev); if (brushIsRadius && !isPainting && currentGeometry) updateBrushHover(ev);
_updateShiftLinePreview(ev);
} else if (exclusionTool === 'bucket' && !isPainting && currentGeometry) { } else if (exclusionTool === 'bucket' && !isPainting && currentGeometry) {
updateBucketHover(ev); updateBucketHover(ev);
} }
@@ -708,8 +745,13 @@ function wireEvents() {
if (exclusionTool) setExclusionTool(null); if (exclusionTool) setExclusionTool(null);
licenseOverlay.classList.add('hidden'); licenseOverlay.classList.add('hidden');
imprintOverlay.classList.add('hidden'); imprintOverlay.classList.add('hidden');
_clearShiftLinePreview();
} }
}); });
document.addEventListener('keyup', (e) => {
if (e.key === 'Control') _clearShiftLinePreview();
});
} }
// ── Exclusion helpers ───────────────────────────────────────────────────────── // ── Exclusion helpers ─────────────────────────────────────────────────────────
@@ -867,6 +909,31 @@ function distSqPointToTri(px, py, pz, ax, ay, az, bx, by, bz, cx, cy, cz) {
return qx*qx + qy*qy + qz*qz; return qx*qx + qy*qy + qz*qz;
} }
// ── Spatial grid for fast sphere queries ──────────────────────────────────
let _spatialGrid = null;
let _spatialCellSize = 0;
let _spatialMinX = 0, _spatialMinY = 0, _spatialMinZ = 0;
function buildSpatialGrid(centroids, triCount, bounds) {
const vol = bounds.size.x * bounds.size.y * bounds.size.z;
const cellSize = Math.max(Math.cbrt(vol / Math.max(triCount, 1)) * 2, 1e-6);
_spatialCellSize = cellSize;
_spatialMinX = bounds.min.x;
_spatialMinY = bounds.min.y;
_spatialMinZ = bounds.min.z;
const grid = new Map();
for (let t = 0; t < triCount; t++) {
const gx = Math.floor((centroids[t*3] - _spatialMinX) / cellSize);
const gy = Math.floor((centroids[t*3+1] - _spatialMinY) / cellSize);
const gz = Math.floor((centroids[t*3+2] - _spatialMinZ) / cellSize);
const key = (gx * 73856093) ^ (gy * 19349663) ^ (gz * 83492791);
let list = grid.get(key);
if (!list) { list = []; grid.set(key, list); }
list.push(t);
}
_spatialGrid = grid;
}
/** Test all triangles against a sphere and invoke cb(triIdx) for each hit. */ /** Test all triangles against a sphere and invoke cb(triIdx) for each hit. */
function forEachTriInSphere(hitPt, r2, cb) { function forEachTriInSphere(hitPt, r2, cb) {
const usePrecision = precisionMaskingEnabled && precisionGeometry; const usePrecision = precisionMaskingEnabled && precisionGeometry;
@@ -874,50 +941,69 @@ function forEachTriInSphere(hitPt, r2, cb) {
const centroids = usePrecision ? precisionCentroids : triangleCentroids; const centroids = usePrecision ? precisionCentroids : triangleCentroids;
const boundRadii = usePrecision ? precisionBoundRadii : triangleBoundRadii; const boundRadii = usePrecision ? precisionBoundRadii : triangleBoundRadii;
const pos = geo.attributes.position; const pos = geo.attributes.position;
const triCount = centroids.length / 3;
const r = Math.sqrt(r2); const r = Math.sqrt(r2);
for (let t = 0; t < triCount; t++) {
// Quick reject: centroid distance > brush radius + triangle bounding radius if (!_spatialGrid) {
const dx = centroids[t*3] - hitPt.x; // Fallback: linear scan (grid not built yet)
const dy = centroids[t*3+1] - hitPt.y; const triCount = centroids.length / 3;
const dz = centroids[t*3+2] - hitPt.z; for (let t = 0; t < triCount; t++) {
const bound = r + boundRadii[t]; const dx = centroids[t*3] - hitPt.x, dy = centroids[t*3+1] - hitPt.y, dz = centroids[t*3+2] - hitPt.z;
if (dx*dx + dy*dy + dz*dz > bound*bound) continue; const bound = r + boundRadii[t];
// Precise sphere-triangle test if (dx*dx + dy*dy + dz*dz > bound*bound) continue;
const i = t * 3; const i = t * 3;
const d2 = distSqPointToTri( const d2 = distSqPointToTri(hitPt.x, hitPt.y, hitPt.z,
hitPt.x, hitPt.y, hitPt.z, pos.getX(i), pos.getY(i), pos.getZ(i),
pos.getX(i), pos.getY(i), pos.getZ(i), pos.getX(i+1), pos.getY(i+1), pos.getZ(i+1),
pos.getX(i+1), pos.getY(i+1), pos.getZ(i+1), pos.getX(i+2), pos.getY(i+2), pos.getZ(i+2));
pos.getX(i+2), pos.getY(i+2), pos.getZ(i+2), if (d2 <= r2) cb(t);
); }
if (d2 <= r2) cb(t); return;
}
const cs = _spatialCellSize;
const xMin = Math.floor((hitPt.x - r - _spatialMinX) / cs);
const xMax = Math.floor((hitPt.x + r - _spatialMinX) / cs);
const yMin = Math.floor((hitPt.y - r - _spatialMinY) / cs);
const yMax = Math.floor((hitPt.y + r - _spatialMinY) / cs);
const zMin = Math.floor((hitPt.z - r - _spatialMinZ) / cs);
const zMax = Math.floor((hitPt.z + r - _spatialMinZ) / cs);
for (let gx = xMin; gx <= xMax; gx++) {
for (let gy = yMin; gy <= yMax; gy++) {
for (let gz = zMin; gz <= zMax; gz++) {
const key = (gx * 73856093) ^ (gy * 19349663) ^ (gz * 83492791);
const list = _spatialGrid.get(key);
if (!list) continue;
for (let li = 0; li < list.length; li++) {
const t = list[li];
const dx = centroids[t*3] - hitPt.x, dy = centroids[t*3+1] - hitPt.y, dz = centroids[t*3+2] - hitPt.z;
const bound = r + boundRadii[t];
if (dx*dx + dy*dy + dz*dz > bound*bound) continue;
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) { function _paintSingleHit(hit, mesh) {
const mesh = getCurrentMesh();
if (!mesh) return;
_raycaster.setFromCamera(_canvasNDC(e), getCamera());
const hits = _raycaster.intersectObject(mesh);
const hit = getFrontFaceHit(hits, mesh);
if (!hit) return;
const usePrecision = precisionMaskingEnabled && precisionGeometry && precisionParentMap; const usePrecision = precisionMaskingEnabled && precisionGeometry && precisionParentMap;
if (usePrecision) { if (usePrecision) {
// Precision mode: store precision face indices for fine-grained selection
if (brushIsRadius) { if (brushIsRadius) {
const r2 = brushRadius * brushRadius; const r2 = brushRadius * brushRadius;
forEachTriInSphere(hit.point, r2, t => { forEachTriInSphere(hit.point, r2, t => {
if (eraseMode) precisionExcludedFaces.delete(t); else precisionExcludedFaces.add(t); if (eraseMode) precisionExcludedFaces.delete(t); else precisionExcludedFaces.add(t);
}); });
} else { } else {
const precIdx = hit.faceIndex; // precision face index (mesh is precision geometry) const precIdx = hit.faceIndex;
if (eraseMode) precisionExcludedFaces.delete(precIdx); else precisionExcludedFaces.add(precIdx); if (eraseMode) precisionExcludedFaces.delete(precIdx); else precisionExcludedFaces.add(precIdx);
} }
} else { } else {
// Normal mode: store original face indices
let triIdx = hit.faceIndex; let triIdx = hit.faceIndex;
if (dispPreviewGeometry && mesh.geometry === dispPreviewGeometry && dispPreviewParentMap) { if (dispPreviewGeometry && mesh.geometry === dispPreviewGeometry && dispPreviewParentMap) {
triIdx = dispPreviewParentMap[triIdx]; triIdx = dispPreviewParentMap[triIdx];
@@ -931,12 +1017,90 @@ function paintAt(e) {
if (eraseMode) excludedFaces.delete(triIdx); else excludedFaces.add(triIdx); if (eraseMode) excludedFaces.delete(triIdx); else excludedFaces.add(triIdx);
} }
} }
}
function _paintLineBetween(from, to, mesh) {
// Sample points along the line and paint at each
const dist = from.distanceTo(to);
const step = brushIsRadius ? Math.max(brushRadius * 0.5, 0.1) : 0.5;
const steps = Math.max(Math.ceil(dist / step), 1);
const dir = new THREE.Vector3().subVectors(to, from);
const cam = getCamera();
for (let i = 0; i <= steps; i++) {
const t = i / steps;
const pt = new THREE.Vector3().lerpVectors(from, to, t);
// Project 3D point to screen, then raycast back to find mesh hit
const ndc = pt.clone().project(cam);
_raycaster.setFromCamera(new THREE.Vector2(ndc.x, ndc.y), cam);
const hits = _raycaster.intersectObject(mesh);
const hit = getFrontFaceHit(hits, mesh);
if (hit) _paintSingleHit(hit, mesh);
}
}
function paintAt(e) {
const mesh = getCurrentMesh();
if (!mesh) return;
_raycaster.setFromCamera(_canvasNDC(e), getCamera());
const hits = _raycaster.intersectObject(mesh);
const hit = getFrontFaceHit(hits, mesh);
if (!hit) return;
// Shift+click: draw line from last paint point to current
if (e.ctrlKey && _lastPaintHitPoint) {
_paintLineBetween(_lastPaintHitPoint, hit.point, mesh);
_clearShiftLinePreview();
} else {
_paintSingleHit(hit, mesh);
}
_lastPaintHitPoint = hit.point.clone();
refreshExclusionOverlay(); refreshExclusionOverlay();
} }
// ── Place on Face ───────────────────────────────────────────────────────────── // ── Place on Face ─────────────────────────────────────────────────────────────
// ── Shift-line preview for brush painting ─────────────────────────────────
function _updateShiftLinePreview(e) {
if (!e.ctrlKey || !_lastPaintHitPoint || !exclusionTool || exclusionTool !== 'brush') {
_clearShiftLinePreview();
return;
}
const mesh = getCurrentMesh();
if (!mesh) return;
_raycaster.setFromCamera(_canvasNDC(e), getCamera());
const hits = _raycaster.intersectObject(mesh);
const hit = getFrontFaceHit(hits, mesh);
if (!hit) { _clearShiftLinePreview(); return; }
const points = [_lastPaintHitPoint, hit.point];
if (_shiftLineMesh) {
_shiftLineMesh.geometry.setFromPoints(points);
_shiftLineMesh.geometry.attributes.position.needsUpdate = true;
} else {
const geo = new THREE.BufferGeometry().setFromPoints(points);
const mat = new THREE.LineBasicMaterial({ color: 0x00ffaa, linewidth: 2, depthTest: false });
_shiftLineMesh = new THREE.Line(geo, mat);
_shiftLineMesh.renderOrder = 999;
const scene = mesh.parent.parent; // meshGroup → scene
if (scene) scene.add(_shiftLineMesh);
}
requestRender();
}
function _clearShiftLinePreview() {
if (_shiftLineMesh) {
if (_shiftLineMesh.parent) _shiftLineMesh.parent.remove(_shiftLineMesh);
_shiftLineMesh.geometry.dispose();
_shiftLineMesh.material.dispose();
_shiftLineMesh = null;
requestRender();
}
}
// ── Place on Face ─────────────────────────────────────────────────────────────
function togglePlaceOnFace(active) { function togglePlaceOnFace(active) {
placeOnFaceActive = active; placeOnFaceActive = active;
placeOnFaceBtn.classList.toggle('active', active); placeOnFaceBtn.classList.toggle('active', active);
@@ -1055,6 +1219,7 @@ function handlePlaceOnFaceClick(e) {
const adjData = buildAdjacency(currentGeometry); const adjData = buildAdjacency(currentGeometry);
triangleAdjacency = adjData.adjacency; triangleAdjacency = adjData.adjacency;
triangleCentroids = adjData.centroids; triangleBoundRadii = adjData.boundRadii; triangleCentroids = adjData.centroids; triangleBoundRadii = adjData.boundRadii;
buildSpatialGrid(triangleCentroids, currentGeometry.attributes.position.count / 3, currentBounds);
// Update edge length for new bounds // Update edge length for new bounds
const maxDim = Math.max(currentBounds.size.x, currentBounds.size.y, currentBounds.size.z); const maxDim = Math.max(currentBounds.size.x, currentBounds.size.y, currentBounds.size.z);
@@ -1309,6 +1474,7 @@ function loadDefaultCube() {
const adjData = buildAdjacency(geo); const adjData = buildAdjacency(geo);
triangleAdjacency = adjData.adjacency; triangleAdjacency = adjData.adjacency;
triangleCentroids = adjData.centroids; triangleBoundRadii = adjData.boundRadii; triangleCentroids = adjData.centroids; triangleBoundRadii = adjData.boundRadii;
buildSpatialGrid(triangleCentroids, geo.attributes.position.count / 3, currentBounds);
settings.scaleU = 0.5; scaleUSlider.value = scaleToPos(0.5); scaleUVal.value = 0.5; 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; settings.scaleV = 0.5; scaleVSlider.value = scaleToPos(0.5); scaleVVal.value = 0.5;
@@ -1403,6 +1569,7 @@ async function handleModelFile(file) {
const adjData = buildAdjacency(geometry); const adjData = buildAdjacency(geometry);
triangleAdjacency = adjData.adjacency; triangleAdjacency = adjData.adjacency;
triangleCentroids = adjData.centroids; triangleBoundRadii = adjData.boundRadii; triangleCentroids = adjData.centroids; triangleBoundRadii = adjData.boundRadii;
buildSpatialGrid(triangleCentroids, geometry.attributes.position.count / 3, bounds);
// Reset scale & offset sliders so scale=1 = one tile covers the full bounding box // Reset scale & offset sliders so scale=1 = one tile covers the full bounding box
const resetVal = (slider, valEl, value) => { const resetVal = (slider, valEl, value) => {
@@ -1871,6 +2038,10 @@ function deactivatePrecisionMasking() {
triangleCentroids = precisionCentroids; triangleCentroids = precisionCentroids;
triangleBoundRadii = precisionBoundRadii; triangleBoundRadii = precisionBoundRadii;
// Rebuild spatial grid for the promoted base mesh
const triCount = currentGeometry.attributes.position.count / 3;
buildSpatialGrid(triangleCentroids, triCount, currentBounds);
// Promote precision excluded faces to the base set // Promote precision excluded faces to the base set
excludedFaces = precisionExcludedFaces; excludedFaces = precisionExcludedFaces;
@@ -1951,6 +2122,10 @@ async function refreshPrecisionMesh() {
precisionCentroids = adjData.centroids; precisionCentroids = adjData.centroids;
precisionBoundRadii = adjData.boundRadii; precisionBoundRadii = adjData.boundRadii;
// Rebuild spatial grid for the precision mesh so brush queries are fast
const precTriCount = precisionGeometry.attributes.position.count / 3;
buildSpatialGrid(precisionCentroids, precTriCount, currentBounds);
// Seed precisionExcludedFaces from existing excludedFaces // Seed precisionExcludedFaces from existing excludedFaces
precisionExcludedFaces = new Set(); precisionExcludedFaces = new Set();
if (excludedFaces.size > 0) { if (excludedFaces.size > 0) {
+24
View File
@@ -3,6 +3,8 @@ import { OBJLoader } from 'three/addons/loaders/OBJLoader.js';
import { unzipSync } from 'fflate'; import { unzipSync } from 'fflate';
import * as THREE from 'three'; import * as THREE from 'three';
const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500 MB
const stlLoader = new STLLoader(); const stlLoader = new STLLoader();
const objLoader = new OBJLoader(); const objLoader = new OBJLoader();
@@ -12,6 +14,11 @@ const objLoader = new OBJLoader();
* The geometry is translated so its bounding-box centre is at the world origin. * The geometry is translated so its bounding-box centre is at the world origin.
*/ */
export function loadSTLFile(file) { export function loadSTLFile(file) {
if (file.size > MAX_FILE_SIZE) {
return Promise.reject(new Error(
'File too large (' + Math.round(file.size / 1024 / 1024) + ' MB). Maximum supported: ' + (MAX_FILE_SIZE / 1024 / 1024) + ' MB.'
));
}
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (e) => { reader.onload = (e) => {
@@ -73,6 +80,11 @@ export function getTriangleCount(geometry) {
* Returns { geometry, bounds }. * Returns { geometry, bounds }.
*/ */
export function loadOBJFile(file) { export function loadOBJFile(file) {
if (file.size > MAX_FILE_SIZE) {
return Promise.reject(new Error(
'File too large (' + Math.round(file.size / 1024 / 1024) + ' MB). Maximum supported: ' + (MAX_FILE_SIZE / 1024 / 1024) + ' MB.'
));
}
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (e) => { reader.onload = (e) => {
@@ -99,6 +111,11 @@ export function loadOBJFile(file) {
* Returns { geometry, bounds }. * Returns { geometry, bounds }.
*/ */
export function load3MFFile(file) { export function load3MFFile(file) {
if (file.size > MAX_FILE_SIZE) {
return Promise.reject(new Error(
'File too large (' + Math.round(file.size / 1024 / 1024) + ' MB). Maximum supported: ' + (MAX_FILE_SIZE / 1024 / 1024) + ' MB.'
));
}
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (e) => { reader.onload = (e) => {
@@ -170,6 +187,13 @@ function parse3MF(data) {
triangles[i * 3 + 2] = parseInt(triEls[i].getAttribute('v3'), 10); triangles[i * 3 + 2] = parseInt(triEls[i].getAttribute('v3'), 10);
} }
const vertCount = vertEls.length;
for (let i = 0; i < triangles.length; i++) {
if (triangles[i] < 0 || triangles[i] >= vertCount || isNaN(triangles[i])) {
throw new Error('Invalid triangle index in 3MF file');
}
}
// Normalise path for lookup (strip leading slash, use forward slashes) // Normalise path for lookup (strip leading slash, use forward slashes)
const normPath = path.replace(/^\//, '').replace(/\\/g, '/'); const normPath = path.replace(/^\//, '').replace(/\\/g, '/');
objectMap.set(normPath + '#' + id, { vertices, triangles }); objectMap.set(normPath + '#' + id, { vertices, triangles });