mirror of
https://github.com/CNCKitchen/stlTexturizer.git
synced 2026-04-07 22:11:32 +00:00
393 lines
15 KiB
JavaScript
393 lines
15 KiB
JavaScript
import { initViewer, loadGeometry, setMeshMaterial, setWireframe } 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';
|
|
|
|
// ── 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;
|
|
|
|
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,
|
|
};
|
|
|
|
// ── DOM refs ──────────────────────────────────────────────────────────────────
|
|
|
|
const canvas = document.getElementById('viewport');
|
|
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');
|
|
|
|
// ── Init ──────────────────────────────────────────────────────────────────────
|
|
|
|
initViewer(canvas);
|
|
buildPresetGrid();
|
|
wireEvents();
|
|
|
|
// ── 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
|
|
scaleUSlider.addEventListener('input', () => {
|
|
const v = parseFloat(scaleUSlider.value);
|
|
settings.scaleU = v;
|
|
scaleUVal.textContent = v.toFixed(2);
|
|
if (settings.lockScale) {
|
|
settings.scaleV = v;
|
|
scaleVSlider.value = v;
|
|
scaleVVal.textContent = v.toFixed(2);
|
|
}
|
|
clearTimeout(previewDebounce);
|
|
previewDebounce = setTimeout(updatePreview, 80);
|
|
});
|
|
|
|
// Scale V — when lock is on, mirror to U
|
|
scaleVSlider.addEventListener('input', () => {
|
|
const v = parseFloat(scaleVSlider.value);
|
|
settings.scaleV = v;
|
|
scaleVVal.textContent = v.toFixed(2);
|
|
if (settings.lockScale) {
|
|
settings.scaleU = v;
|
|
scaleUSlider.value = v;
|
|
scaleUVal.textContent = v.toFixed(2);
|
|
}
|
|
clearTimeout(previewDebounce);
|
|
previewDebounce = setTimeout(updatePreview, 80);
|
|
});
|
|
|
|
// Lock toggle
|
|
lockScaleBtn.addEventListener('click', () => {
|
|
settings.lockScale = !settings.lockScale;
|
|
lockScaleBtn.classList.toggle('active', settings.lockScale);
|
|
lockScaleBtn.setAttribute('aria-pressed', String(settings.lockScale));
|
|
// When locking, snap V to current U
|
|
if (settings.lockScale) {
|
|
settings.scaleV = settings.scaleU;
|
|
scaleVSlider.value = settings.scaleU;
|
|
scaleVVal.textContent = settings.scaleU.toFixed(2);
|
|
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)} mm`; });
|
|
linkSlider(refineLenSlider, refineLenVal, v => { settings.refineLength = v; return `${v.toFixed(1)} mm`; }, false);
|
|
linkSlider(maxTriSlider, maxTriVal, v => { settings.maxTriangles = v; return formatM(v); }, false);
|
|
|
|
// ── Export ──
|
|
exportBtn.addEventListener('click', handleExport);
|
|
|
|
// ── Wireframe ──
|
|
wireframeToggle.addEventListener('change', () => setWireframe(wireframeToggle.checked));
|
|
}
|
|
|
|
// ── Slider helper ─────────────────────────────────────────────────────────────
|
|
|
|
let previewDebounce = null;
|
|
|
|
function linkSlider(slider, valEl, onChangeFn, livePreview = true) {
|
|
slider.addEventListener('input', () => {
|
|
const v = parseFloat(slider.value);
|
|
valEl.textContent = onChangeFn(v);
|
|
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 scale & offset sliders so scale=1 = one tile covers the full bounding box
|
|
const resetVal = (slider, valEl, value, fmt) => {
|
|
slider.value = value;
|
|
valEl.textContent = fmt(value);
|
|
};
|
|
settings.scaleU = 1; resetVal(scaleUSlider, scaleUVal, 1, v => v.toFixed(2));
|
|
settings.scaleV = 1; resetVal(scaleVSlider, scaleVVal, 1, v => v.toFixed(2));
|
|
settings.offsetU = 0; resetVal(offsetUSlider, offsetUVal, 0, v => v.toFixed(2));
|
|
settings.offsetV = 0; resetVal(offsetVSlider, offsetVVal, 0, v => v.toFixed(2));
|
|
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.textContent = `${defaultEdge.toFixed(2)} mm`;
|
|
|
|
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 ───────────────────────────────────────────────────────────
|
|
|
|
async function handleExport() {
|
|
if (!currentGeometry || !activeMapEntry || isExporting) return;
|
|
isExporting = true;
|
|
exportBtn.classList.add('busy');
|
|
exportProgress.classList.remove('hidden');
|
|
|
|
try {
|
|
setProgress(0.02, 'Subdividing mesh…');
|
|
|
|
const { geometry: subdivided, safetyCapHit } = await runAsync(() =>
|
|
subdivide(currentGeometry, settings.refineLength,
|
|
(p) => setProgress(0.02 + p * 0.35, 'Subdividing mesh…'))
|
|
);
|
|
|
|
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…`)
|
|
)
|
|
);
|
|
}
|
|
|
|
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));
|
|
}
|