mirror of
https://github.com/CNCKitchen/stlTexturizer.git
synced 2026-04-07 22:11:32 +00:00
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:
+8
-5
@@ -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
@@ -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
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
Reference in New Issue
Block a user