Files
archived-stlTexturizer/js/main.js
T

820 lines
34 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as THREE from 'three';
import { initViewer, loadGeometry, setMeshMaterial, setWireframe,
getControls, getCamera, getCurrentMesh,
setExclusionOverlay, setHoverPreview } from './viewer.js';
import { loadSTLFile, computeBounds, getTriangleCount } from './stlLoader.js';
import { PRESETS, loadCustomTexture } from './presetTextures.js';
import { createPreviewMaterial, updateMaterial } from './previewMaterial.js';
import { subdivide } from './subdivision.js';
import { applyDisplacement } from './displacement.js';
import { decimate } from './decimation.js';
import { exportSTL } from './exporter.js';
import { buildAdjacency, bucketFill,
buildExclusionOverlayGeo, buildFaceWeights } from './exclusion.js';
// ── State ─────────────────────────────────────────────────────────────────────
let currentGeometry = null; // original loaded geometry
let currentBounds = null; // bounds of the original geometry
let activeMapEntry = null; // { name, texture, imageData, width, height }
let previewMaterial = null;
let isExporting = false;
let previewDebounce = null;
// ── Exclusion state ───────────────────────────────────────────────────────────
let excludedFaces = new Set(); // triangle indices in currentGeometry
let triangleAdjacency = null; // Map from buildAdjacency
let triangleCentroids = null; // Float32Array from buildAdjacency
let exclusionTool = null; // 'brush' | 'bucket' | null
let eraseMode = false;
let brushIsRadius = false;
let brushRadius = 5.0;
let bucketThreshold = 30;
let isPainting = false;
let selectionMode = false; // false = exclude painted faces; true = include only painted faces
let _lastHoverTriIdx = -1; // last triangle index used for hover preview
const _raycaster = new THREE.Raycaster();
const settings = {
mappingMode: 6, // Cubic default
scaleU: 1.0,
scaleV: 1.0,
amplitude: 0.5,
offsetU: 0.0,
offsetV: 0.0,
refineLength: 1.0,
maxTriangles: 1_000_000,
lockScale: true,
bottomAngleLimit: 5,
topAngleLimit: 0,
};
// ── DOM refs ──────────────────────────────────────────────────────────────────
const canvas = document.getElementById('viewport');
const brushCursorEl = document.getElementById('brush-cursor');
const dropZone = document.getElementById('drop-zone');
const dropHint = document.getElementById('drop-hint');
const stlFileInput = document.getElementById('stl-file-input');
const textureInput = document.getElementById('texture-file-input');
const presetGrid = document.getElementById('preset-grid');
const activeMapName = document.getElementById('active-map-name');
const meshInfo = document.getElementById('mesh-info');
const exportBtn = document.getElementById('export-btn');
const exportProgress = document.getElementById('export-progress');
const exportProgBar = document.getElementById('export-progress-bar');
const exportProgLbl = document.getElementById('export-progress-label');
const triLimitWarning = document.getElementById('tri-limit-warning');
const wireframeToggle = document.getElementById('wireframe-toggle');
const mappingSelect = document.getElementById('mapping-mode');
const scaleUSlider = document.getElementById('scale-u');
const scaleVSlider = document.getElementById('scale-v');
const lockScaleBtn = document.getElementById('lock-scale');
const offsetUSlider = document.getElementById('offset-u');
const offsetVSlider = document.getElementById('offset-v');
const amplitudeSlider = document.getElementById('amplitude');
const refineLenSlider = document.getElementById('refine-length');
const maxTriSlider = document.getElementById('max-triangles');
const scaleUVal = document.getElementById('scale-u-val');
const scaleVVal = document.getElementById('scale-v-val');
const offsetUVal = document.getElementById('offset-u-val');
const offsetVVal = document.getElementById('offset-v-val');
const amplitudeVal = document.getElementById('amplitude-val');
const refineLenVal = document.getElementById('refine-length-val');
const maxTriVal = document.getElementById('max-triangles-val');
const bottomAngleLimitSlider = document.getElementById('bottom-angle-limit');
const topAngleLimitSlider = document.getElementById('top-angle-limit');
const bottomAngleLimitVal = document.getElementById('bottom-angle-limit-val');
const topAngleLimitVal = document.getElementById('top-angle-limit-val');
// ── Exclusion panel DOM refs ──────────────────────────────────────────────────
const exclBrushBtn = document.getElementById('excl-brush-btn');
const exclBucketBtn = document.getElementById('excl-bucket-btn');
const exclEraseToggle = document.getElementById('excl-erase-toggle');
const exclBrushTypeRow = document.getElementById('excl-brush-type-row');
const exclBrushSingleBtn = document.getElementById('excl-brush-single');
const exclBrushRadiusBtn = document.getElementById('excl-brush-radius-btn');
const exclRadiusRow = document.getElementById('excl-radius-row');
const exclBrushRadiusSlider = document.getElementById('excl-brush-radius-slider');
const exclBrushRadiusVal = document.getElementById('excl-brush-radius-val');
const exclThresholdRow = document.getElementById('excl-threshold-row');
const exclThresholdSlider = document.getElementById('excl-threshold-slider');
const exclThresholdVal = document.getElementById('excl-threshold-val');
const exclCount = document.getElementById('excl-count');
const exclClearBtn = document.getElementById('excl-clear-btn');
const exclModeExcludeBtn = document.getElementById('excl-mode-exclude');
const exclModeIncludeBtn = document.getElementById('excl-mode-include');
const exclSectionHeading = document.getElementById('excl-section-heading');
const exclHint = document.getElementById('excl-hint');
// ── Scale slider log helpers ──────────────────────────────────────────────────
// Slider stores 01000; actual scale spans 0.110 on a log axis.
// Middle position 500 → scale 1.0 (exact midpoint on log scale).
const _LOG_MIN = Math.log(0.1);
const _LOG_MAX = Math.log(10);
const scaleToPos = v => Math.round((Math.log(Math.max(0.1, Math.min(10, v))) - _LOG_MIN) / (_LOG_MAX - _LOG_MIN) * 1000);
const posToScale = p => parseFloat(Math.exp(_LOG_MIN + (p / 1000) * (_LOG_MAX - _LOG_MIN)).toFixed(1));
// ── Init ──────────────────────────────────────────────────────────────────────
initViewer(canvas);
buildPresetGrid();
wireEvents();
// Sync scale number inputs with the slider's initial position
scaleUVal.value = posToScale(parseFloat(scaleUSlider.value));
scaleVVal.value = posToScale(parseFloat(scaleVSlider.value));
// ── Preset grid ───────────────────────────────────────────────────────────────
function buildPresetGrid() {
PRESETS.forEach((preset, idx) => {
const swatch = document.createElement('div');
swatch.className = 'preset-swatch';
swatch.title = preset.name;
// Use the small thumbnail canvas
swatch.appendChild(preset.thumbCanvas);
const label = document.createElement('span');
label.className = 'preset-label';
label.textContent = preset.name;
swatch.appendChild(label);
swatch.addEventListener('click', () => selectPreset(idx, swatch));
presetGrid.appendChild(swatch);
});
}
function selectPreset(idx, swatchEl) {
document.querySelectorAll('.preset-swatch').forEach(s => s.classList.remove('active'));
swatchEl.classList.add('active');
activeMapEntry = PRESETS[idx];
activeMapName.textContent = PRESETS[idx].name;
updatePreview();
}
// ── Event wiring ──────────────────────────────────────────────────────────────
function wireEvents() {
// ── STL loading ──
stlFileInput.addEventListener('change', (e) => {
if (e.target.files[0]) handleSTL(e.target.files[0]);
});
// Drag & drop on the viewport section
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('drag-over');
});
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('drag-over');
const file = [...e.dataTransfer.files].find(f => f.name.toLowerCase().endsWith('.stl'));
if (file) handleSTL(file);
});
// Allow clicking the drop zone to open the file picker (except on canvas)
dropZone.addEventListener('click', (e) => {
if (e.target === dropZone) stlFileInput.click();
});
// ── Custom texture upload ──
textureInput.addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
try {
activeMapEntry = await loadCustomTexture(file);
activeMapName.textContent = file.name;
document.querySelectorAll('.preset-swatch').forEach(s => s.classList.remove('active'));
updatePreview();
} catch (err) {
console.error('Failed to load texture:', err);
}
});
// ── Settings ──
mappingSelect.addEventListener('change', () => {
settings.mappingMode = parseInt(mappingSelect.value, 10);
updatePreview();
});
// Scale U — when lock is on, mirror to V
const applyScaleU = (v) => {
v = Math.max(0.1, Math.min(10, v));
settings.scaleU = v;
scaleUSlider.value = scaleToPos(v);
scaleUVal.value = v;
if (settings.lockScale) { settings.scaleV = v; scaleVSlider.value = scaleToPos(v); scaleVVal.value = v; }
clearTimeout(previewDebounce); previewDebounce = setTimeout(updatePreview, 80);
};
scaleUSlider.addEventListener('input', () => applyScaleU(posToScale(parseFloat(scaleUSlider.value))));
scaleUVal.addEventListener('change', () => applyScaleU(parseFloat(scaleUVal.value)));
// Scale V — when lock is on, mirror to U
const applyScaleV = (v) => {
v = Math.max(0.1, Math.min(10, v));
settings.scaleV = v;
scaleVSlider.value = scaleToPos(v);
scaleVVal.value = v;
if (settings.lockScale) { settings.scaleU = v; scaleUSlider.value = scaleToPos(v); scaleUVal.value = v; }
clearTimeout(previewDebounce); previewDebounce = setTimeout(updatePreview, 80);
};
scaleVSlider.addEventListener('input', () => applyScaleV(posToScale(parseFloat(scaleVSlider.value))));
scaleVVal.addEventListener('change', () => applyScaleV(parseFloat(scaleVVal.value)));
// Lock toggle
lockScaleBtn.addEventListener('click', () => {
settings.lockScale = !settings.lockScale;
lockScaleBtn.classList.toggle('active', settings.lockScale);
lockScaleBtn.setAttribute('aria-pressed', String(settings.lockScale));
if (settings.lockScale) {
settings.scaleV = settings.scaleU;
scaleVSlider.value = scaleToPos(settings.scaleU);
scaleVVal.value = settings.scaleU;
updatePreview();
}
});
linkSlider(offsetUSlider, offsetUVal, v => { settings.offsetU = v; return v.toFixed(2); });
linkSlider(offsetVSlider, offsetVVal, v => { settings.offsetV = v; return v.toFixed(2); });
linkSlider(amplitudeSlider, amplitudeVal, v => { settings.amplitude = v; return v.toFixed(2); });
linkSlider(refineLenSlider, refineLenVal, v => { settings.refineLength = v; return v.toFixed(1); }, false);
linkSlider(maxTriSlider, maxTriVal, v => { settings.maxTriangles = v; return formatM(v); }, false);
linkSlider(bottomAngleLimitSlider, bottomAngleLimitVal, v => { settings.bottomAngleLimit = v; return v; });
linkSlider(topAngleLimitSlider, topAngleLimitVal, v => { settings.topAngleLimit = v; return v; });
// ── Export ──
exportBtn.addEventListener('click', handleExport);
// ── Wireframe ──
wireframeToggle.addEventListener('change', () => setWireframe(wireframeToggle.checked));
// ── Exclusion tool wiring ─────────────────────────────────────────────────
exclBrushBtn.addEventListener('click', () => setExclusionTool('brush'));
exclBucketBtn.addEventListener('click', () => setExclusionTool('bucket'));
exclEraseToggle.addEventListener('click', () => {
eraseMode = !eraseMode;
exclEraseToggle.classList.toggle('active', eraseMode);
exclEraseToggle.setAttribute('aria-pressed', String(eraseMode));
});
exclBrushSingleBtn.addEventListener('click', () => {
brushIsRadius = false;
exclBrushSingleBtn.classList.add('active');
exclBrushRadiusBtn.classList.remove('active');
exclRadiusRow.classList.add('hidden');
canvas.style.cursor = exclusionTool ? 'crosshair' : '';
brushCursorEl.style.display = 'none';
});
exclBrushRadiusBtn.addEventListener('click', () => {
brushIsRadius = true;
exclBrushRadiusBtn.classList.add('active');
exclBrushSingleBtn.classList.remove('active');
if (exclusionTool === 'brush') exclRadiusRow.classList.remove('hidden');
if (exclusionTool === 'brush') canvas.style.cursor = 'none';
});
exclBrushRadiusSlider.addEventListener('input', () => {
brushRadius = parseFloat(exclBrushRadiusSlider.value);
exclBrushRadiusVal.value = brushRadius;
});
exclBrushRadiusVal.addEventListener('change', () => {
brushRadius = Math.max(0.1, Math.min(50, parseFloat(exclBrushRadiusVal.value) || 5));
exclBrushRadiusSlider.value = brushRadius;
exclBrushRadiusVal.value = brushRadius;
});
exclThresholdSlider.addEventListener('input', () => {
bucketThreshold = parseFloat(exclThresholdSlider.value);
exclThresholdVal.value = bucketThreshold;
_lastHoverTriIdx = -1; // invalidate hover so next mousemove re-computes
});
exclThresholdVal.addEventListener('change', () => {
bucketThreshold = Math.max(0, Math.min(180, parseFloat(exclThresholdVal.value) || 30));
exclThresholdSlider.value = bucketThreshold;
exclThresholdVal.value = bucketThreshold;
_lastHoverTriIdx = -1;
});
exclClearBtn.addEventListener('click', () => {
excludedFaces = new Set();
refreshExclusionOverlay();
});
exclModeExcludeBtn.addEventListener('click', () => setSelectionMode(false));
exclModeIncludeBtn.addEventListener('click', () => setSelectionMode(true));
// ── Canvas mouse events for exclusion painting ────────────────────────────
canvas.addEventListener('mousedown', (e) => {
if (!currentGeometry || !exclusionTool || e.button !== 0) return;
e.preventDefault();
getControls().enabled = false;
isPainting = true;
if (exclusionTool === 'bucket') {
const triIdx = pickTriangle(e);
if (triIdx >= 0) {
const filled = bucketFill(triIdx, triangleAdjacency, bucketThreshold);
for (const t of filled) {
if (eraseMode) excludedFaces.delete(t); else excludedFaces.add(t);
}
refreshExclusionOverlay();
// Clear hover immediately so the confirmed orange overlay is fully visible
_lastHoverTriIdx = -1;
setHoverPreview(null);
}
isPainting = false;
getControls().enabled = true;
} else {
paintAt(e);
}
});
canvas.addEventListener('mousemove', (e) => {
if (exclusionTool === 'brush' && brushIsRadius) {
updateBrushCursor(e);
}
if (isPainting && exclusionTool === 'brush') {
paintAt(e);
return;
}
if (!isPainting && exclusionTool === 'bucket' && currentGeometry) {
updateBucketHover(e);
}
});
canvas.addEventListener('mouseleave', () => {
_lastHoverTriIdx = -1;
setHoverPreview(null);
brushCursorEl.style.display = 'none';
});
document.addEventListener('mouseup', () => {
if (!isPainting) return;
isPainting = false;
getControls().enabled = true;
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && exclusionTool) {
setExclusionTool(null);
}
});
}
// ── Exclusion helpers ─────────────────────────────────────────────────────────
function setSelectionMode(include) {
if (selectionMode === include) return;
selectionMode = include;
exclModeExcludeBtn.classList.toggle('active', !selectionMode);
exclModeIncludeBtn.classList.toggle('active', selectionMode);
exclModeExcludeBtn.setAttribute('aria-pressed', String(!selectionMode));
exclModeIncludeBtn.setAttribute('aria-pressed', String(selectionMode));
exclSectionHeading.textContent = selectionMode ? 'Surface Selection' : 'Surface Exclusions';
exclHint.textContent = selectionMode
? 'Selected surfaces appear green and will be the only ones to receive displacement during export.'
: 'Excluded surfaces appear orange and will not receive displacement during export.';
// Clear the painted set — faces had opposite semantics in the previous mode
excludedFaces = new Set();
refreshExclusionOverlay();
}
function setExclusionTool(tool) {
// Clicking the active tool toggles it off; passing null always deactivates
exclusionTool = (exclusionTool === tool) ? null : tool;
exclBrushBtn.classList.toggle('active', exclusionTool === 'brush');
exclBucketBtn.classList.toggle('active', exclusionTool === 'bucket');
// Show brush-type row only while brush is active
exclBrushTypeRow.classList.toggle('hidden', exclusionTool !== 'brush');
// Show radius row only while brush + radius mode is active
exclRadiusRow.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' : '';
// Clear hover preview whenever the tool changes or is deactivated
_lastHoverTriIdx = -1;
setHoverPreview(null);
// Hide brush cursor if tool deactivated or switched away from radius brush
if (!(exclusionTool === 'brush' && brushIsRadius)) {
brushCursorEl.style.display = 'none';
}
// Re-enable controls if tool was deactivated mid-paint
if (!exclusionTool) {
isPainting = false;
getControls().enabled = true;
}
}
function _canvasNDC(e) {
const rect = canvas.getBoundingClientRect();
return new THREE.Vector2(
((e.clientX - rect.left) / rect.width) * 2 - 1,
((e.clientY - rect.top) / rect.height) * -2 + 1,
);
}
function pickTriangle(e) {
const mesh = getCurrentMesh();
if (!mesh) return -1;
_raycaster.setFromCamera(_canvasNDC(e), getCamera());
const hits = _raycaster.intersectObject(mesh);
return hits.length > 0 ? hits[0].faceIndex : -1;
}
function paintAt(e) {
const mesh = getCurrentMesh();
if (!mesh) return;
_raycaster.setFromCamera(_canvasNDC(e), getCamera());
const hits = _raycaster.intersectObject(mesh);
if (hits.length === 0) return;
const triIdx = hits[0].faceIndex;
if (brushIsRadius) {
const hitPt = hits[0].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);
}
}
} else {
if (eraseMode) excludedFaces.delete(triIdx); else excludedFaces.add(triIdx);
}
refreshExclusionOverlay();
}
function refreshExclusionOverlay() {
if (!currentGeometry) return;
if (selectionMode) {
// Include Only mode: grey out the complement (non-selected faces) so only the
// selected faces show the texture preview beneath.
const maskGeo = buildExclusionOverlayGeo(currentGeometry, excludedFaces, true);
setExclusionOverlay(maskGeo, 0x222222, 0.88);
} else {
setExclusionOverlay(buildExclusionOverlayGeo(currentGeometry, excludedFaces), 0xff6600);
}
const n = excludedFaces.size;
exclCount.textContent = selectionMode
? `${n.toLocaleString()} face${n === 1 ? '' : 's'} selected`
: `${n.toLocaleString()} face${n === 1 ? '' : 's'} excluded`;
}
function updateBrushCursor(e) {
if (!brushIsRadius || !currentGeometry) {
brushCursorEl.style.display = 'none';
return;
}
const mesh = getCurrentMesh();
if (!mesh) { brushCursorEl.style.display = 'none'; return; }
_raycaster.setFromCamera(_canvasNDC(e), getCamera());
const hits = _raycaster.intersectObject(mesh);
if (hits.length === 0) { brushCursorEl.style.display = 'none'; return; }
const hitPt = hits[0].point;
const cam = getCamera();
// Offset the hit point by brushRadius along the camera's right axis
// then project both to screen space to get pixel-accurate circle size
const camRight = new THREE.Vector3().setFromMatrixColumn(cam.matrixWorld, 0).normalize();
const edgePt = hitPt.clone().addScaledVector(camRight, brushRadius);
const rect = canvas.getBoundingClientRect();
const toScreen = (v) => {
const c = v.clone().project(cam);
return {
x: (c.x * 0.5 + 0.5) * rect.width,
y: (1 - (c.y * 0.5 + 0.5)) * rect.height,
};
};
const sc = toScreen(hitPt);
const se = toScreen(edgePt);
const screenRadius = Math.sqrt((se.x - sc.x) ** 2 + (se.y - sc.y) ** 2);
const diam = screenRadius * 2;
brushCursorEl.style.display = 'block';
brushCursorEl.style.left = `${rect.left + sc.x - screenRadius}px`;
brushCursorEl.style.top = `${rect.top + sc.y - screenRadius}px`;
brushCursorEl.style.width = `${diam}px`;
brushCursorEl.style.height = `${diam}px`;
}
function updateBucketHover(e) {
const triIdx = pickTriangle(e);
if (triIdx === _lastHoverTriIdx) return; // unchanged — skip expensive BFS
_lastHoverTriIdx = triIdx;
if (triIdx < 0 || !triangleAdjacency) {
setHoverPreview(null);
return;
}
const hovered = bucketFill(triIdx, triangleAdjacency, bucketThreshold);
setHoverPreview(buildExclusionOverlayGeo(currentGeometry, hovered));
}
// ── Slider helper ─────────────────────────────────────────────────────────────
function linkSlider(slider, valInput, onChangeFn, livePreview = true) {
const isSpan = valInput.tagName === 'SPAN';
slider.addEventListener('input', () => {
const v = parseFloat(slider.value);
const display = onChangeFn(v);
if (isSpan) valInput.textContent = display; else valInput.value = display;
if (livePreview) {
clearTimeout(previewDebounce);
previewDebounce = setTimeout(updatePreview, 80);
}
});
if (!isSpan) {
valInput.addEventListener('change', () => {
const raw = parseFloat(valInput.value);
if (isNaN(raw)) { valInput.value = slider.value; return; }
const clamped = Math.max(parseFloat(slider.min), Math.min(parseFloat(slider.max), raw));
slider.value = clamped;
valInput.value = onChangeFn(clamped);
if (livePreview) {
clearTimeout(previewDebounce);
previewDebounce = setTimeout(updatePreview, 80);
}
});
}
}
function formatM(n) {
return n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)} M`
: n >= 1_000 ? `${(n / 1_000).toFixed(0)} k`
: String(n);
}
// ── STL loading ───────────────────────────────────────────────────────────────
async function handleSTL(file) {
try {
const { geometry, bounds } = await loadSTLFile(file);
currentGeometry = geometry;
currentBounds = bounds;
// Dispose old preview material and reset state for the new mesh
if (previewMaterial) {
previewMaterial.dispose();
previewMaterial = null;
}
// Auto-select Brick preset (index 5) on first load
const brickIdx = PRESETS.findIndex(p => p.name === 'Brick');
if (brickIdx >= 0 && !activeMapEntry) {
activeMapEntry = PRESETS[brickIdx];
activeMapName.textContent = PRESETS[brickIdx].name;
const swatches = document.querySelectorAll('.preset-swatch');
swatches.forEach((s, i) => s.classList.toggle('active', i === brickIdx));
}
mappingSelect.value = String(settings.mappingMode);
// Show mesh with a default material until a map is selected
loadGeometry(geometry);
dropHint.classList.add('hidden');
// Reset exclusion state for the new mesh
excludedFaces = new Set();
exclusionTool = null;
eraseMode = false;
isPainting = false;
exclBrushBtn.classList.remove('active');
exclBucketBtn.classList.remove('active');
exclEraseToggle.classList.remove('active');
exclBrushTypeRow.classList.add('hidden');
exclRadiusRow.classList.add('hidden');
exclThresholdRow.classList.add('hidden');
canvas.style.cursor = '';
setExclusionOverlay(null);
setHoverPreview(null);
_lastHoverTriIdx = -1;
exclCount.textContent = '0 faces excluded';
// Build adjacency data for brush/bucket tools (synchronous; fast enough for
// typical STL sizes processed by this tool)
const adjData = buildAdjacency(geometry);
triangleAdjacency = adjData.adjacency;
triangleCentroids = adjData.centroids;
// Reset scale & offset sliders so scale=1 = one tile covers the full bounding box
const resetVal = (slider, valEl, value) => {
slider.value = value;
valEl.value = value;
};
settings.scaleU = 1; scaleUSlider.value = scaleToPos(1); scaleUVal.value = 1;
settings.scaleV = 1; scaleVSlider.value = scaleToPos(1); scaleVVal.value = 1;
settings.offsetU = 0; resetVal(offsetUSlider, offsetUVal, 0);
settings.offsetV = 0; resetVal(offsetVSlider, offsetVVal, 0);
triLimitWarning.classList.add('hidden');
// Default edge length = 1/100 of the largest bounding box dimension
const maxDim = Math.max(bounds.size.x, bounds.size.y, bounds.size.z);
const defaultEdge = Math.max(0.1, Math.min(5.0, +(maxDim / 200).toFixed(2)));
settings.refineLength = defaultEdge;
refineLenSlider.value = defaultEdge;
refineLenVal.value = defaultEdge;
const triCount = getTriangleCount(geometry);
const mb = ((geometry.attributes.position.array.byteLength) / 1024 / 1024).toFixed(2);
meshInfo.textContent = `${triCount.toLocaleString()} triangles · ${mb} MB`;
exportBtn.disabled = (activeMapEntry === null);
updatePreview();
} catch (err) {
console.error('Failed to load STL:', err);
alert(`Could not load STL: ${err.message}`);
}
}
// ── Live preview ──────────────────────────────────────────────────────────────
function updatePreview() {
if (!currentGeometry || !currentBounds) return;
const fullSettings = { ...settings, bounds: currentBounds };
if (!activeMapEntry) {
// No map yet — plain material
if (previewMaterial) {
setMeshMaterial(null);
previewMaterial.dispose();
previewMaterial = null;
}
exportBtn.disabled = true;
return;
}
if (!previewMaterial) {
previewMaterial = createPreviewMaterial(activeMapEntry.texture, fullSettings);
loadGeometry(currentGeometry, previewMaterial);
} else {
updateMaterial(previewMaterial, activeMapEntry.texture, fullSettings);
}
exportBtn.disabled = false;
}
// ── Export pipeline ───────────────────────────────────────────────────────────
/**
* Builds per-non-indexed-vertex weights (1.0 = excluded from subdivision/displacement)
* that combine the user-painted exclusion set AND the top/bottom angle mask.
*/
function buildCombinedFaceWeights(geometry, excludedFaces, invert, settings) {
const weights = buildFaceWeights(geometry, excludedFaces, invert);
const hasAngleMask = settings.bottomAngleLimit > 0 || settings.topAngleLimit > 0;
if (!hasAngleMask) return weights;
const posAttr = geometry.attributes.position;
const triCount = posAttr.count / 3;
const vA = new THREE.Vector3();
const vB = new THREE.Vector3();
const vC = new THREE.Vector3();
const edge1 = new THREE.Vector3();
const edge2 = new THREE.Vector3();
const faceNrm = new THREE.Vector3();
for (let t = 0; t < triCount; t++) {
if (weights[t * 3] > 0.99) continue; // already excluded
vA.fromBufferAttribute(posAttr, t * 3);
vB.fromBufferAttribute(posAttr, t * 3 + 1);
vC.fromBufferAttribute(posAttr, t * 3 + 2);
edge1.subVectors(vB, vA);
edge2.subVectors(vC, vA);
faceNrm.crossVectors(edge1, edge2);
const faceArea = faceNrm.length();
const faceNzNorm = faceArea > 1e-12 ? faceNrm.z / faceArea : 0;
const faceAngle = Math.acos(Math.abs(faceNzNorm)) * (180 / Math.PI);
const angleMasked = faceNzNorm < 0
? (settings.bottomAngleLimit > 0 && faceAngle <= settings.bottomAngleLimit)
: (settings.topAngleLimit > 0 && faceAngle <= settings.topAngleLimit);
if (angleMasked) {
weights[t * 3] = 1.0;
weights[t * 3 + 1] = 1.0;
weights[t * 3 + 2] = 1.0;
}
}
return weights;
}
async function handleExport() {
if (!currentGeometry || !activeMapEntry || isExporting) return;
isExporting = true;
exportBtn.classList.add('busy');
exportProgress.classList.remove('hidden');
try {
setProgress(0.02, 'Subdividing mesh…');
// Build per-vertex exclusion weights combining user-painted exclusion + angle masking.
// Faces masked by top/bottom angle limits are treated the same as user-excluded faces
// so subdivision skips their interior edges too, saving triangles where no
// displacement will be applied.
const hasAngleMask = settings.bottomAngleLimit > 0 || settings.topAngleLimit > 0;
const faceWeights = (excludedFaces.size > 0 || selectionMode || hasAngleMask)
? buildCombinedFaceWeights(currentGeometry, excludedFaces, selectionMode, settings)
: null;
const { geometry: subdivided, safetyCapHit } = await runAsync(() =>
subdivide(currentGeometry, settings.refineLength,
(p) => setProgress(0.02 + p * 0.35, 'Subdividing mesh…'),
faceWeights)
);
const subTriCount = subdivided.attributes.position.count / 3;
setProgress(0.38, `Applying displacement to ${subTriCount.toLocaleString()} triangles…`);
const displaced = await runAsync(() =>
applyDisplacement(
subdivided,
activeMapEntry.imageData,
activeMapEntry.width,
activeMapEntry.height,
settings,
currentBounds,
(p) => setProgress(0.38 + p * 0.32, `Displacing vertices…`)
)
);
const dispTriCount = displaced.attributes.position.count / 3;
const needsDecimation = dispTriCount > settings.maxTriangles;
triLimitWarning.classList.toggle('hidden', !safetyCapHit);
let finalGeometry = displaced;
if (needsDecimation) {
setProgress(0.71, `Decimating ${dispTriCount.toLocaleString()}${settings.maxTriangles.toLocaleString()} triangles…`);
finalGeometry = await runAsync(() =>
decimate(
displaced,
settings.maxTriangles,
(p) => setProgress(0.71 + p * 0.25, `Decimating mesh…`)
)
);
}
// Flat-bottom clamp: when bottom faces are masked (bottomAngleLimit > 0),
// any vertex that ended up below the original model's bottom layer gets
// snapped back up to that Z. Only the Z-value is changed.
if (settings.bottomAngleLimit > 0) {
const bottomZ = currentBounds.min.z;
const posArr = finalGeometry.attributes.position.array;
for (let i = 2; i < posArr.length; i += 3) {
if (posArr[i] < bottomZ) posArr[i] = bottomZ;
}
finalGeometry.attributes.position.needsUpdate = true;
finalGeometry.computeVertexNormals();
}
setProgress(0.97, 'Writing STL…');
await yieldFrame();
exportSTL(finalGeometry, 'textured.stl');
setProgress(1.0, 'Done!');
setTimeout(() => {
exportProgress.classList.add('hidden');
setProgress(0, '');
}, 1500);
} catch (err) {
console.error('Export failed:', err);
alert(`Export failed: ${err.message}`);
exportProgress.classList.add('hidden');
} finally {
isExporting = false;
exportBtn.classList.remove('busy');
}
}
function setProgress(fraction, label) {
exportProgBar.style.width = `${Math.round(fraction * 100)}%`;
exportProgLbl.textContent = label;
}
/** Yield to the browser event loop for one frame, then run fn. */
function runAsync(fn) {
return new Promise((resolve, reject) => {
requestAnimationFrame(() => {
try { resolve(fn()); }
catch (e) { reject(e); }
});
});
}
function yieldFrame() {
return new Promise(r => requestAnimationFrame(r));
}