commit 92e7f487ce8593e03f524ae766e6a1e88deb0a75 Author: CNCKitchen Date: Mon Mar 16 20:37:32 2026 +0100 initial commit diff --git a/index.html b/index.html new file mode 100644 index 0000000..cb9ad57 --- /dev/null +++ b/index.html @@ -0,0 +1,166 @@ + + + + + + STL Texturizer + + + + +
+ +
Units assumed to be mm
+
+ +
+ +
+
+
+ + + + + +

Drop an .stl file here
or

+ +
+ +
+ +
+ + + +
+ + + + diff --git a/js/displacement.js b/js/displacement.js new file mode 100644 index 0000000..3c2affe --- /dev/null +++ b/js/displacement.js @@ -0,0 +1,102 @@ +import * as THREE from 'three'; +import { computeUV } from './mapping.js'; + +/** + * Apply displacement to every vertex of a non-indexed BufferGeometry. + * + * For each vertex: + * 1. Compute UV with the same math used in the GLSL preview shader (mapping.js). + * 2. Bilinear-sample the greyscale ImageData at that UV. + * 3. Move the vertex along its normal by: grey * amplitude + * + * @param {THREE.BufferGeometry} geometry – non-indexed (from subdivide()) + * @param {ImageData} imageData – raw pixel data from Canvas2D + * @param {number} imgWidth + * @param {number} imgHeight + * @param {object} settings – { mappingMode, scaleU, scaleV, amplitude, offsetU, offsetV } + * @param {object} bounds – { min, max, center, size } (THREE.Vector3) + * @param {function} [onProgress] + * @returns {THREE.BufferGeometry} new non-indexed geometry with displaced positions + */ +export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, settings, bounds, onProgress) { + const posAttr = geometry.attributes.position; + const nrmAttr = geometry.attributes.normal; + const count = posAttr.count; + + const newPos = new Float32Array(count * 3); + const newNrm = new Float32Array(count * 3); + + const tmpPos = new THREE.Vector3(); + const tmpNrm = new THREE.Vector3(); + + const REPORT_EVERY = 5000; + + for (let i = 0; i < count; i++) { + tmpPos.fromBufferAttribute(posAttr, i); + tmpNrm.fromBufferAttribute(nrmAttr, i); + + const uvResult = computeUV(tmpPos, tmpNrm, settings.mappingMode, settings, bounds); + + let grey; + if (uvResult.triplanar) { + // Weighted blend of three samples + grey = 0; + for (const s of uvResult.samples) { + grey += sampleBilinear(imageData.data, imgWidth, imgHeight, s.u, s.v) * s.w; + } + } else { + grey = sampleBilinear(imageData.data, imgWidth, imgHeight, uvResult.u, uvResult.v); + } + + const disp = grey * settings.amplitude; + + newPos[i*3] = tmpPos.x + tmpNrm.x * disp; + newPos[i*3+1] = tmpPos.y + tmpNrm.y * disp; + newPos[i*3+2] = tmpPos.z + tmpNrm.z * disp; + + newNrm[i*3] = tmpNrm.x; + newNrm[i*3+1] = tmpNrm.y; + newNrm[i*3+2] = tmpNrm.z; + + if (onProgress && i % REPORT_EVERY === 0) onProgress(i / count); + } + + const out = new THREE.BufferGeometry(); + out.setAttribute('position', new THREE.BufferAttribute(newPos, 3)); + out.setAttribute('normal', new THREE.BufferAttribute(newNrm, 3)); + // Recompute face normals for correct lighting in exported STL + out.computeVertexNormals(); + return out; +} + +// ── Bilinear sampler ───────────────────────────────────────────────────────── + +/** + * Sample a greyscale value (0–1) from raw RGBA ImageData using + * bilinear interpolation. UV is tiled via mod 1. + */ +function sampleBilinear(data, w, h, u, v) { + // Ensure [0,1) — guard against floating-point edge cases + u = ((u % 1) + 1) % 1; + v = ((v % 1) + 1) % 1; + + const fx = u * (w - 1); + const fy = v * (h - 1); + const x0 = Math.floor(fx); + const y0 = Math.floor(fy); + const x1 = Math.min(x0 + 1, w - 1); + const y1 = Math.min(y0 + 1, h - 1); + const tx = fx - x0; + const ty = fy - y0; + + // Red channel — image is greyscale so R == G == B + const v00 = data[(y0 * w + x0) * 4] / 255; + const v10 = data[(y0 * w + x1) * 4] / 255; + const v01 = data[(y1 * w + x0) * 4] / 255; + const v11 = data[(y1 * w + x1) * 4] / 255; + + return v00 * (1-tx) * (1-ty) + + v10 * tx * (1-ty) + + v01 * (1-tx) * ty + + v11 * tx * ty; +} diff --git a/js/exporter.js b/js/exporter.js new file mode 100644 index 0000000..53b5dc1 --- /dev/null +++ b/js/exporter.js @@ -0,0 +1,37 @@ +import * as THREE from 'three'; +import { STLExporter } from 'three/addons/exporters/STLExporter.js'; + +const exporter = new STLExporter(); + +/** + * Export a BufferGeometry as a binary STL file download. + * + * @param {THREE.BufferGeometry} geometry + * @param {string} [filename] + */ +export function exportSTL(geometry, filename = 'textured.stl') { + // The geometry was rotated -90° around X on load to convert Z-up → Y-up for the viewer. + // Undo that rotation before export so the STL lands back in the original Z-up orientation + // that 3D-print slicers expect. + const exportGeom = geometry.clone(); + exportGeom.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2)); + + const mesh = new THREE.Mesh(exportGeom, new THREE.MeshBasicMaterial()); + const result = exporter.parse(mesh, { binary: true }); + exportGeom.dispose(); + + // result is an ArrayBuffer in binary mode + const blob = new Blob([result], { type: 'application/octet-stream' }); + const url = URL.createObjectURL(blob); + + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.style.display = 'none'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + + // Revoke after a short delay so the download has time to start + setTimeout(() => URL.revokeObjectURL(url), 10000); +} diff --git a/js/main.js b/js/main.js new file mode 100644 index 0000000..90420b1 --- /dev/null +++ b/js/main.js @@ -0,0 +1,366 @@ +import { initViewer, loadGeometry, setMeshMaterial } 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 { 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: 5, // Triplanar default — covers all faces of any shape + 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 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); +} + +// ── 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; + } + + // 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 / 100).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…'); + + // Run subdivision synchronously (may take a few seconds on large meshes) + // Wrap in a small yielding loop to allow the browser to repaint the progress bar. + const { geometry: subdivided, limitReached } = await runAsync(() => + subdivide(currentGeometry, settings.refineLength, settings.maxTriangles, + (p) => setProgress(p * 0.6, 'Subdividing mesh…')) + ); + + triLimitWarning.classList.toggle('hidden', !limitReached); + + const subTriCount = subdivided.attributes.position.count / 3; + setProgress(0.62, `Applying displacement to ${subTriCount.toLocaleString()} triangles…`); + + const displaced = await runAsync(() => + applyDisplacement( + subdivided, + activeMapEntry.imageData, + activeMapEntry.width, + activeMapEntry.height, + settings, + currentBounds, + (p) => setProgress(0.62 + p * 0.35, `Displacing vertices…`) + ) + ); + + setProgress(0.98, 'Writing STL…'); + await yieldFrame(); + + const baseName = 'textured'; + exportSTL(displaced, `${baseName}.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)); +} diff --git a/js/mapping.js b/js/mapping.js new file mode 100644 index 0000000..03ba126 --- /dev/null +++ b/js/mapping.js @@ -0,0 +1,153 @@ +/** + * CPU-side UV mapping — exact JavaScript mirror of the GLSL in previewMaterial.js. + * All functions take Three.js Vector3 objects for position/normal and + * a bounds object { min, max, center, size } (all THREE.Vector3). + */ + +export const MODE_PLANAR_XY = 0; +export const MODE_PLANAR_XZ = 1; +export const MODE_PLANAR_YZ = 2; +export const MODE_CYLINDRICAL = 3; +export const MODE_SPHERICAL = 4; +export const MODE_TRIPLANAR = 5; +export const MODE_CUBIC = 6; + +const TWO_PI = Math.PI * 2; + +/** + * Compute normalised UV coordinates [0, 1) (tiling) for a vertex. + * + * @param {{ x:number, y:number, z:number }} pos vertex position + * @param {{ x:number, y:number, z:number }} normal vertex normal (unit) + * @param {number} mode one of the MODE_* constants + * @param {{ scaleU:number, scaleV:number, offsetU:number, offsetV:number }} settings + * @param {{ min, max, center, size }} bounds THREE.Vector3 fields + * @returns {{ u:number, v:number }} tiled UV after scale+offset + */ +export function computeUV(pos, normal, mode, settings, bounds) { + const { min, size, center } = bounds; + const { scaleU, scaleV, offsetU, offsetV } = settings; + + let u = 0, v = 0; + + switch (mode) { + + case MODE_PLANAR_XY: { + u = (pos.x - min.x) / Math.max(size.x, 1e-6); + v = (pos.y - min.y) / Math.max(size.y, 1e-6); + break; + } + + case MODE_PLANAR_XZ: { + u = (pos.x - min.x) / Math.max(size.x, 1e-6); + v = (pos.z - min.z) / Math.max(size.z, 1e-6); + break; + } + + case MODE_PLANAR_YZ: { + u = (pos.y - min.y) / Math.max(size.y, 1e-6); + v = (pos.z - min.z) / Math.max(size.z, 1e-6); + break; + } + + case MODE_CYLINDRICAL: { + // Wrap around Y axis (vertical axis after Z-up → Y-up rotation) + const rx = pos.x - center.x; + const rz = pos.z - center.z; + const theta = Math.atan2(rz, rx); // [-PI, PI] + u = (theta / TWO_PI) + 0.5; // [0, 1] + v = (pos.y - min.y) / Math.max(size.y, 1e-6); + break; + } + + case MODE_SPHERICAL: { + const rx = pos.x - center.x; + const ry = pos.y - center.y; + const rz = pos.z - center.z; + const r = Math.sqrt(rx*rx + ry*ry + rz*rz); + const phi = Math.acos(Math.max(-1, Math.min(1, ry / Math.max(r, 1e-6)))); // [0, PI], Y is up + const theta = Math.atan2(rz, rx); // [-PI, PI] + u = (theta / TWO_PI) + 0.5; + v = phi / Math.PI; + break; + } + + case MODE_CUBIC: { + const ax = Math.abs(normal.x); + const ay = Math.abs(normal.y); + const az = Math.abs(normal.z); + let uRaw, vRaw; + if (ax >= ay && ax >= az) { + // ±X dominant → project onto YZ + uRaw = (pos.y - min.y) / Math.max(size.y, 1e-6); + vRaw = (pos.z - min.z) / Math.max(size.z, 1e-6); + } else if (ay >= ax && ay >= az) { + // ±Y dominant → project onto XZ + uRaw = (pos.x - min.x) / Math.max(size.x, 1e-6); + vRaw = (pos.z - min.z) / Math.max(size.z, 1e-6); + } else { + // ±Z dominant → project onto XY + uRaw = (pos.x - min.x) / Math.max(size.x, 1e-6); + vRaw = (pos.y - min.y) / Math.max(size.y, 1e-6); + } + return { + triplanar: false, + u: fract(uRaw * scaleU + offsetU), + v: fract(vRaw * scaleV + offsetV), + }; + } + + case MODE_TRIPLANAR: + default: { + // World-space normal blending + const ax = Math.abs(normal.x); + const ay = Math.abs(normal.y); + const az = Math.abs(normal.z); + const pw = 4.0; + const bx = Math.pow(ax, pw); + const by = Math.pow(ay, pw); + const bz = Math.pow(az, pw); + const sum = bx + by + bz + 1e-6; + const wx = bx / sum; + const wy = by / sum; + const wz = bz / sum; + + const uvXY = { + u: (pos.x - min.x) / Math.max(size.x, 1e-6), + v: (pos.y - min.y) / Math.max(size.y, 1e-6), + w: wz, + }; + const uvXZ = { + u: (pos.x - min.x) / Math.max(size.x, 1e-6), + v: (pos.z - min.z) / Math.max(size.z, 1e-6), + w: wy, + }; + const uvYZ = { + u: (pos.y - min.y) / Math.max(size.y, 1e-6), + v: (pos.z - min.z) / Math.max(size.z, 1e-6), + w: wx, + }; + + // Apply scale+offset and tile each independently + // We return a special { triplanar: true, samples } object. + // The caller (displacement.js) handles the 3-sample blend itself. + return { + triplanar: true, + samples: [ + { u: fract(uvXY.u * scaleU + offsetU), v: fract(uvXY.v * scaleV + offsetV), w: uvXY.w }, + { u: fract(uvXZ.u * scaleU + offsetU), v: fract(uvXZ.v * scaleV + offsetV), w: uvXZ.w }, + { u: fract(uvYZ.u * scaleU + offsetU), v: fract(uvYZ.v * scaleV + offsetV), w: uvYZ.w }, + ], + }; + } + } + + return { + triplanar: false, + u: fract(u * scaleU + offsetU), + v: fract(v * scaleV + offsetV), + }; +} + +/** Fractional part, always positive (mirrors GLSL fract) */ +function fract(x) { return x - Math.floor(x); } diff --git a/js/presetTextures.js b/js/presetTextures.js new file mode 100644 index 0000000..216520e --- /dev/null +++ b/js/presetTextures.js @@ -0,0 +1,256 @@ +import * as THREE from 'three'; + +const SIZE = 512; // texture resolution for both preview and sampling + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function makeCanvas(size = SIZE) { + const c = document.createElement('canvas'); + c.width = c.height = size; + return c; +} + +function grayPixel(value255) { + return `rgb(${value255},${value255},${value255})`; +} + +// Simple seeded LCG pseudo-random number generator (deterministic) +function lcg(seed) { + let s = seed >>> 0; + return () => { + s = (Math.imul(1664525, s) + 1013904223) >>> 0; + return s / 0xFFFFFFFF; + }; +} + +// ── Generators ─────────────────────────────────────────────────────────────── + +/** Horizontal sine waves */ +function generateWaves(size = SIZE) { + const canvas = makeCanvas(size); + const ctx = canvas.getContext('2d'); + const id = ctx.createImageData(size, size); + const d = id.data; + for (let y = 0; y < size; y++) { + const v = Math.sin((y / size) * Math.PI * 10) * 0.5 + 0.5; + const g = Math.round(v * 255); + for (let x = 0; x < size; x++) { + const i = (y * size + x) * 4; + d[i] = d[i+1] = d[i+2] = g; + d[i+3] = 255; + } + } + ctx.putImageData(id, 0, 0); + return canvas; +} + +/** Fish-scale / overlapping circles */ +function generateScales(size = SIZE) { + const canvas = makeCanvas(size); + const ctx = canvas.getContext('2d'); + ctx.fillStyle = '#000'; + ctx.fillRect(0, 0, size, size); + + const r = size / 8; + const rStroke = r * 0.08; + ctx.strokeStyle = '#fff'; + ctx.lineWidth = rStroke; + ctx.fillStyle = '#333'; + + const rows = Math.ceil(size / r) + 2; + const cols = Math.ceil(size / r) + 2; + for (let row = -1; row < rows; row++) { + for (let col = -1; col < cols; col++) { + const ox = col * r * 1.0 + (row % 2 === 0 ? 0 : r * 0.5); + const oy = row * r * 0.75; + ctx.beginPath(); + ctx.arc(ox, oy, r * 0.92, Math.PI, 0); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + } + } + return canvas; +} + +/** Hexagonal grid */ +function generateHex(size = SIZE) { + const canvas = makeCanvas(size); + const ctx = canvas.getContext('2d'); + ctx.fillStyle = '#222'; + ctx.fillRect(0, 0, size, size); + + const r = size / 8; + const w = Math.sqrt(3) * r; + const h = 2 * r; + ctx.strokeStyle = '#fff'; + ctx.lineWidth = r * 0.12; + + function hexPath(cx, cy) { + ctx.beginPath(); + for (let i = 0; i < 6; i++) { + const angle = (Math.PI / 3) * i - Math.PI / 6; + const px = cx + r * 0.88 * Math.cos(angle); + const py = cy + r * 0.88 * Math.sin(angle); + if (i === 0) ctx.moveTo(px, py); + else ctx.lineTo(px, py); + } + ctx.closePath(); + } + + const cols = Math.ceil(size / w) + 2; + const rows = Math.ceil(size / (h * 0.75)) + 2; + for (let row = -1; row < rows; row++) { + for (let col = -1; col < cols; col++) { + const cx = col * w + (row % 2 === 0 ? 0 : w / 2); + const cy = row * h * 0.75; + hexPath(cx, cy); + ctx.fillStyle = `hsl(0,0%,${20 + Math.random() * 10}%)`; + ctx.fill(); + ctx.stroke(); + } + } + return canvas; +} + +/** Diamond / crosshatch */ +function generateDiamonds(size = SIZE) { + const canvas = makeCanvas(size); + const ctx = canvas.getContext('2d'); + const id = ctx.createImageData(size, size); + const d = id.data; + const freq = 8; + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + const u = x / size; + const v = y / size; + const val = (Math.abs(Math.sin(u * Math.PI * freq)) + + Math.abs(Math.sin(v * Math.PI * freq))) / 2; + const g = Math.round(val * 255); + const i = (y * size + x) * 4; + d[i] = d[i+1] = d[i+2] = g; + d[i+3] = 255; + } + } + ctx.putImageData(id, 0, 0); + return canvas; +} + +/** Smooth noise (value noise via bilinear interpolation of random grid) */ +function generateNoise(size = SIZE) { + const canvas = makeCanvas(size); + const ctx = canvas.getContext('2d'); + const id = ctx.createImageData(size, size); + const d = id.data; + const rand = lcg(0xdeadbeef); + + // Generate random value grid at coarser resolution + const GRID = 16; + const grid = new Float32Array((GRID + 1) * (GRID + 1)); + for (let i = 0; i < grid.length; i++) grid[i] = rand(); + + function bilerp(gx, gy) { + const x0 = Math.floor(gx) % GRID; + const y0 = Math.floor(gy) % GRID; + const x1 = (x0 + 1) % GRID; + const y1 = (y0 + 1) % GRID; + const fx = gx - Math.floor(gx); + const fy = gy - Math.floor(gy); + // Smoothstep + const sx = fx * fx * (3 - 2 * fx); + const sy = fy * fy * (3 - 2 * fy); + const v00 = grid[y0 * (GRID+1) + x0]; + const v10 = grid[y0 * (GRID+1) + x1]; + const v01 = grid[y1 * (GRID+1) + x0]; + const v11 = grid[y1 * (GRID+1) + x1]; + return v00 + sx * (v10 - v00) + sy * (v01 - v00) + sx * sy * (v00 - v10 - v01 + v11); + } + + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + const gx = (x / size) * GRID; + const gy = (y / size) * GRID; + // Octave 1 + octave 2 + let v = bilerp(gx, gy) * 0.65 + bilerp(gx * 2, gy * 2) * 0.25 + bilerp(gx * 4, gy * 4) * 0.10; + const g = Math.round(Math.max(0, Math.min(1, v)) * 255); + const i4 = (y * size + x) * 4; + d[i4] = d[i4+1] = d[i4+2] = g; + d[i4+3] = 255; + } + } + ctx.putImageData(id, 0, 0); + return canvas; +} + +/** Brick pattern */ +function generateBrick(size = SIZE) { + const canvas = makeCanvas(size); + const ctx = canvas.getContext('2d'); + ctx.fillStyle = '#555'; + ctx.fillRect(0, 0, size, size); + + const bw = size / 5; // brick width + const bh = size / 10; // brick height + const mortar = bw * 0.07; + + ctx.fillStyle = '#ddd'; + const rows = Math.ceil(size / bh) + 1; + const cols = Math.ceil(size / bw) + 2; + for (let row = 0; row < rows; row++) { + const offset = (row % 2 === 0 ? 0 : bw * 0.5); + for (let col = -1; col < cols; col++) { + const x = col * bw + offset + mortar / 2; + const y = row * bh + mortar / 2; + ctx.fillRect(x, y, bw - mortar, bh - mortar); + } + } + return canvas; +} + +// ── Build PRESETS array ─────────────────────────────────────────────────────── + +const GENERATORS = [ + { name: 'Waves', gen: generateWaves }, + { name: 'Scales', gen: generateScales }, + { name: 'Hexagonal', gen: generateHex }, + { name: 'Diamonds', gen: generateDiamonds }, + { name: 'Noise', gen: generateNoise }, + { name: 'Brick', gen: generateBrick }, +]; + +export const PRESETS = GENERATORS.map(({ name, gen }) => { + const fullCanvas = gen(SIZE); + const thumbCanvas = gen(80); // small canvas for swatch UI + const texture = new THREE.CanvasTexture(fullCanvas); + texture.wrapS = texture.wrapT = THREE.RepeatWrapping; + texture.name = name; + + // Extract ImageData for CPU sampling + const ctx = fullCanvas.getContext('2d'); + const imageData = ctx.getImageData(0, 0, SIZE, SIZE); + + return { name, thumbCanvas, fullCanvas, texture, imageData, width: SIZE, height: SIZE }; +}); + +/** + * Build a THREE.CanvasTexture + ImageData from a user-uploaded image File. + */ +export function loadCustomTexture(file) { + return new Promise((resolve, reject) => { + const img = new Image(); + const url = URL.createObjectURL(file); + img.onload = () => { + URL.revokeObjectURL(url); + const canvas = makeCanvas(SIZE); + const ctx = canvas.getContext('2d'); + ctx.drawImage(img, 0, 0, SIZE, SIZE); + const imageData = ctx.getImageData(0, 0, SIZE, SIZE); + const texture = new THREE.CanvasTexture(canvas); + texture.wrapS = texture.wrapT = THREE.RepeatWrapping; + texture.name = file.name; + resolve({ name: file.name, fullCanvas: canvas, texture, imageData, width: SIZE, height: SIZE }); + }; + img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('Failed to load image')); }; + img.src = url; + }); +} diff --git a/js/previewMaterial.js b/js/previewMaterial.js new file mode 100644 index 0000000..d99370c --- /dev/null +++ b/js/previewMaterial.js @@ -0,0 +1,239 @@ +import * as THREE from 'three'; + +// Mapping mode constants (must match index.html