import * as THREE from 'three'; import { initViewer, loadGeometry, setMeshMaterial, setMeshGeometry, setWireframe, getControls, getCamera, getCurrentMesh, setExclusionOverlay, setHoverPreview, setViewerTheme } from './viewer.js'; import { loadModelFile, computeBounds, getTriangleCount } from './stlLoader.js'; import { loadPresets, 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'; import { t, initLang, setLang, getLang, applyTranslations } from './i18n.js'; // ── State ───────────────────────────────────────────────────────────────────── let currentGeometry = null; // original loaded geometry let currentBounds = null; // bounds of the original geometry let currentStlName = 'model'; // base filename of the loaded STL (no extension) let activeMapEntry = null; // { name, texture, imageData, width, height, isCustom? } 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 = 20; 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 let placeOnFaceActive = false; // true while "Place on Face" mode is active const _raycaster = new THREE.Raycaster(); const settings = { mappingMode: 5, // Triplanar default scaleU: 0.5, scaleV: 0.5, amplitude: 0.5, offsetU: 0.0, offsetV: 0.0, rotation: 0, refineLength: 1.0, maxTriangles: 1_000_000, lockScale: true, bottomAngleLimit: 5, topAngleLimit: 0, mappingBlend: 1, seamBandWidth: 0.5, capAngle: 20, symmetricDisplacement: false, useDisplacement: false, }; // ── Displacement preview state ──────────────────────────────────────────────── let dispPreviewGeometry = null; // subdivided geometry with smoothNormal attribute let dispPreviewBusy = false; // true while async subdivision is running let dispPreviewParentMap = null; // Int32Array: subdivided face → original face index // ── 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 exportProgPct = document.getElementById('export-progress-pct'); const exportProgLbl = document.getElementById('export-progress-label'); const triLimitWarning = document.getElementById('tri-limit-warning'); const wireframeToggle = document.getElementById('wireframe-toggle'); const placeOnFaceBtn = document.getElementById('place-on-face-btn'); 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 rotationSlider = document.getElementById('rotation'); const rotationVal = document.getElementById('rotation-val'); const amplitudeVal = document.getElementById('amplitude-val'); const amplitudeWarning = document.getElementById('amplitude-warning'); 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'); const seamBlendSlider = document.getElementById('seam-blend'); const seamBlendVal = document.getElementById('seam-blend-val'); const seamBandWidthSlider = document.getElementById('seam-band-width'); const seamBandWidthVal = document.getElementById('seam-band-width-val'); const capAngleSlider = document.getElementById('cap-angle'); const capAngleVal = document.getElementById('cap-angle-val'); const capAngleRow = document.getElementById('cap-angle-row'); const symmetricDispToggle = document.getElementById('symmetric-displacement'); const dispPreviewToggle = document.getElementById('displacement-preview'); // ── Exclusion panel DOM refs ────────────────────────────────────────────────── const exclBrushBtn = document.getElementById('excl-brush-btn'); const exclBucketBtn = document.getElementById('excl-bucket-btn'); 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'); // ── License panel DOM refs ──────────────────────────────────────────────────── const licenseLink = document.getElementById('license-link'); const licenseOverlay = document.getElementById('license-overlay'); const licenseClose = document.getElementById('license-close'); // ── Scale slider log helpers ────────────────────────────────────────────────── // Slider stores 0–1000; actual scale spans 0.05–10 on a log axis. // Middle position 500 → scale ~0.71 (log midpoint between 0.05 and 10). const _LOG_MIN = Math.log(0.05); const _LOG_MAX = Math.log(10); const scaleToPos = v => Math.round((Math.log(Math.max(0.05, 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(2)); // ── Init ────────────────────────────────────────────────────────────────────── let PRESETS = []; initViewer(canvas); // Apply saved theme to 3D viewport on startup setViewerTheme(document.documentElement.getAttribute('data-theme') === 'light'); // Initialise language (reads localStorage / browser preference, applies translations) initLang(); // Sync lang buttons to current language (function() { const lang = getLang(); document.querySelectorAll('.lang-btn').forEach(btn => { btn.classList.toggle('active', btn.dataset.langCode === lang); }); })(); // Theme toggle document.getElementById('theme-toggle').addEventListener('click', () => { const isLight = document.documentElement.getAttribute('data-theme') !== 'light'; document.documentElement.setAttribute('data-theme', isLight ? 'light' : 'dark'); localStorage.setItem('stlt-theme', isLight ? 'light' : 'dark'); setViewerTheme(isLight); }); wireEvents(); // Sync scale number inputs with the slider's initial position scaleUVal.value = posToScale(parseFloat(scaleUSlider.value)); scaleVVal.value = posToScale(parseFloat(scaleVSlider.value)); loadPresets().then(presets => { PRESETS = presets; buildPresetGrid(); loadDefaultCube(); // Select Crystal as the default preset const noiseIdx = PRESETS.findIndex(p => p.name === 'Crystal'); const defaultIdx = noiseIdx !== -1 ? noiseIdx : 0; const swatches = presetGrid.querySelectorAll('.preset-swatch'); if (swatches[defaultIdx]) selectPreset(defaultIdx, swatches[defaultIdx]); }).catch(err => console.error('Failed to load preset textures:', err)); // ── 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() { // ── Language toggle ── document.querySelectorAll('.lang-btn').forEach(btn => { btn.addEventListener('click', () => { const lang = btn.dataset.langCode; setLang(lang); document.querySelectorAll('.lang-btn').forEach(b => b.classList.toggle('active', b.dataset.langCode === lang)); // Re-translate