import * as THREE from 'three'; import { initViewer, loadGeometry, setMeshMaterial, setMeshGeometry, setWireframe, getControls, getCamera, getCurrentMesh, setExclusionOverlay, setHoverPreview, setViewerTheme, setProjection, requestRender } 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, TRANSLATIONS } 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; // Boundary edge data texture for per-fragment falloff in bump-only preview let _boundaryEdgeTex = null; let _boundaryEdgeCount = 0; let _falloffDirty = true; // recompute falloff on next updateFaceMask let _falloffGeometry = null; // geometry the falloff was last computed for // ── Exclusion state ─────────────────────────────────────────────────────────── let excludedFaces = new Set(); // triangle indices in currentGeometry let triangleAdjacency = null; // Array from buildAdjacency let triangleCentroids = null; // Float32Array from buildAdjacency let triangleBoundRadii = null; // Float32Array — max vertex-to-centroid dist per tri 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(); 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 _effectiveMapCache = null; let _effectiveMapCacheKey = null; 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: 750_000, lockScale: true, bottomAngleLimit: 5, topAngleLimit: 0, mappingBlend: 1, seamBandWidth: 0.5, textureSmoothing: 0, capAngle: 20, boundaryFalloff: 0, symmetricDisplacement: false, useDisplacement: false, }; // ── Canvas filter support (Safari / iOS WebView don't support ctx.filter) ──── const CANVAS_FILTER_SUPPORTED = 'filter' in CanvasRenderingContext2D.prototype; /** * Box-blur one row of RGBA pixels (horizontal pass). * Operates in-place reading from `src` and writing to `dst`. */ function _boxBlurH(src, dst, w, h, r) { const iarr = 1 / (2 * r + 1); for (let y = 0; y < h; y++) { const row = y * w; for (let ch = 0; ch < 4; ch++) { let val = 0; // Seed with left-edge pixel repeated r+1 times plus the first r pixels for (let x = -r; x <= r; x++) val += src[(row + Math.max(0, Math.min(x, w - 1))) * 4 + ch]; for (let x = 0; x < w; x++) { val += src[(row + Math.min(x + r, w - 1)) * 4 + ch] - src[(row + Math.max(x - r - 1, 0)) * 4 + ch]; dst[(row + x) * 4 + ch] = Math.round(val * iarr); } } } } /** Box-blur one column of RGBA pixels (vertical pass). */ function _boxBlurV(src, dst, w, h, r) { const iarr = 1 / (2 * r + 1); for (let x = 0; x < w; x++) { for (let ch = 0; ch < 4; ch++) { let val = 0; for (let y = -r; y <= r; y++) val += src[(Math.max(0, Math.min(y, h - 1)) * w + x) * 4 + ch]; for (let y = 0; y < h; y++) { val += src[(Math.min(y + r, h - 1) * w + x) * 4 + ch] - src[(Math.max(y - r - 1, 0) * w + x) * 4 + ch]; dst[(y * w + x) * 4 + ch] = Math.round(val * iarr); } } } } /** * Apply an approximate Gaussian blur (sigma px) to `canvas` in-place. * Uses the native CSS filter on Chrome/Firefox; falls back to a 3-pass * separable box blur for Safari / iOS WebKit. */ function blurCanvas(canvas, sigma) { if (sigma <= 0) return; if (CANVAS_FILTER_SUPPORTED) { const tmp = document.createElement('canvas'); tmp.width = canvas.width; tmp.height = canvas.height; const tc = tmp.getContext('2d'); tc.filter = `blur(${sigma}px)`; tc.drawImage(canvas, 0, 0); canvas.getContext('2d').clearRect(0, 0, canvas.width, canvas.height); canvas.getContext('2d').drawImage(tmp, 0, 0); } else { // 3 passes of box blur ≈ Gaussian; radius r where r(r+1) ≈ sigma² const r = Math.max(1, Math.round((Math.sqrt(4 * sigma * sigma + 1) - 1) / 2)); const ctx = canvas.getContext('2d'); const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height); const a = imgData.data; const b = new Uint8ClampedArray(a.length); const w = canvas.width, h = canvas.height; for (let pass = 0; pass < 3; pass++) { _boxBlurH(a, b, w, h, r); _boxBlurV(b, a, w, h, r); } ctx.putImageData(imgData, 0, 0); } } // ── Precision masking state ──────────────────────────────────────────────────── let precisionMaskingEnabled = false; let precisionGeometry = null; // subdivided geometry for finer masking let precisionParentMap = null; // Int32Array: refined face → original face index let precisionEdgeLength = null; // edge length used for current refinement let precisionBusy = false; // true while async subdivision is running let precisionCentroids = null; // Float32Array from buildAdjacency on refined mesh let precisionBoundRadii = null; // Float32Array — max vertex-to-centroid per refined tri let precisionAdjacency = null; // Array from buildAdjacency on refined mesh let precisionExcludedFaces = new Set(); // precision face indices excluded while precision is active // ── 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 projectionToggle = document.getElementById('projection-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 textureSmoothingSlider = document.getElementById('texture-smoothing'); const textureSmoothingVal = document.getElementById('texture-smoothing-val'); const capAngleSlider = document.getElementById('cap-angle'); const capAngleVal = document.getElementById('cap-angle-val'); const capAngleRow = document.getElementById('cap-angle-row'); const boundaryFalloffSlider = document.getElementById('boundary-falloff'); const boundaryFalloffVal = document.getElementById('boundary-falloff-val'); 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'); // ── Precision masking DOM refs ──────────────────────────────────────────────── const precisionMaskingRow = document.getElementById('precision-masking-row'); const precisionMaskingToggle = document.getElementById('precision-masking-toggle'); const precisionStatus = document.getElementById('precision-status'); const precisionOutdated = document.getElementById('precision-outdated'); const precisionRefreshBtn = document.getElementById('precision-refresh-btn'); const precisionWarning = document.getElementById('precision-warning'); // ── License panel DOM refs ──────────────────────────────────────────────────── const licenseLink = document.getElementById('license-link'); const licenseOverlay = document.getElementById('license-overlay'); const licenseClose = document.getElementById('license-close'); const imprintLink = document.getElementById('imprint-link'); const imprintOverlay = document.getElementById('imprint-overlay'); const imprintClose = document.getElementById('imprint-close'); // ── Language selector DOM refs ──────────────────────────────────────────────────── const languageSelector = document.querySelector('.lang-seg'); // ── 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)); function _applyScaleU(v) { v = Math.max(0.05, 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); } // ── Init ────────────────────────────────────────────────────────────────────── let PRESETS = []; initViewer(canvas); // Apply saved theme to 3D viewport on startup setViewerTheme(document.documentElement.getAttribute('data-theme') === 'light'); // Populate the language selector function populateLanguageSelector() { if (!languageSelector) return; languageSelector.innerHTML = ''; const select = document.createElement('select'); select.className = 'lang-dropdown'; for (const langKey in TRANSLATIONS) { const opt = document.createElement('option'); opt.value = langKey; opt.className = 'lang-option'; opt.textContent = TRANSLATIONS[langKey]['lang.name'] || langKey.toUpperCase(); select.appendChild(opt); } select.addEventListener('change', async (e) => { await setLang(e.target.value); // Re-translate