diff --git a/basket.jpg b/basket.jpg new file mode 100644 index 0000000..1c5247d Binary files /dev/null and b/basket.jpg differ diff --git a/brick.jpg b/brick.jpg new file mode 100644 index 0000000..3b3b799 Binary files /dev/null and b/brick.jpg differ diff --git a/bubble.jpg b/bubble.jpg new file mode 100644 index 0000000..a85d5d7 Binary files /dev/null and b/bubble.jpg differ diff --git a/crystal.jpg b/crystal.jpg new file mode 100644 index 0000000..79abefd Binary files /dev/null and b/crystal.jpg differ diff --git a/index.html b/index.html index ab080ec..8ab0aba 100644 --- a/index.html +++ b/index.html @@ -88,13 +88,13 @@
@@ -134,6 +134,11 @@ +
+ + + +
@@ -141,8 +146,8 @@

Displacement

- - + +
@@ -253,7 +258,10 @@ Smaller edge length = finer displacement detail. Output is then decimated to the triangle limit.

diff --git a/js/decimation.js b/js/decimation.js index c7baca9..9736470 100644 --- a/js/decimation.js +++ b/js/decimation.js @@ -53,7 +53,7 @@ const _hlvLo = new Int32Array(128); // ── Public API ─────────────────────────────────────────────────────────────── -export function decimate(geometry, targetTriangles, onProgress) { +export async function decimate(geometry, targetTriangles, onProgress) { const { positions, faces, vertCount, faceCount } = buildIndexed(geometry); if (faceCount <= targetTriangles) return buildOutput(positions, faces, faceCount); @@ -172,7 +172,10 @@ export function decimate(geometry, targetTriangles, onProgress) { if (onProgress && (++collapses & 511) === 0) { const p = Math.min(1, (initFaces - activeFaces) / toRemove); - if (p - lastProg > 0.015) { onProgress(p); lastProg = p; } + if (p - lastProg > 0.015) { + onProgress(p); lastProg = p; + await new Promise(r => setTimeout(r, 0)); + } } } diff --git a/js/main.js b/js/main.js index 908c744..3c4aad5 100644 --- a/js/main.js +++ b/js/main.js @@ -3,7 +3,7 @@ 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 { loadPresets, loadCustomTexture } from './presetTextures.js'; import { createPreviewMaterial, updateMaterial } from './previewMaterial.js'; import { subdivide } from './subdivision.js'; import { applyDisplacement } from './displacement.js'; @@ -36,12 +36,13 @@ let _lastHoverTriIdx = -1; // last triangle index used for hover prev const _raycaster = new THREE.Raycaster(); const settings = { - mappingMode: 6, // Cubic default + mappingMode: 5, // Triplanar default scaleU: 1.0, scaleV: 1.0, amplitude: 0.5, offsetU: 0.0, offsetV: 0.0, + rotation: 0, refineLength: 1.0, maxTriangles: 1_000_000, lockScale: true, @@ -63,6 +64,7 @@ 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'); @@ -81,6 +83,8 @@ 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 refineLenVal = document.getElementById('refine-length-val'); const maxTriVal = document.getElementById('max-triangles-val'); @@ -120,13 +124,19 @@ const posToScale = p => parseFloat(Math.exp(_LOG_MIN + (p / 1000) * (_LOG_MAX - // ── Init ────────────────────────────────────────────────────────────────────── +let PRESETS = []; + 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)); +loadPresets().then(presets => { + PRESETS = presets; + buildPresetGrid(); +}).catch(err => console.error('Failed to load preset textures:', err)); + // ── Preset grid ─────────────────────────────────────────────────────────────── function buildPresetGrid() { @@ -241,6 +251,7 @@ function wireEvents() { linkSlider(offsetUSlider, offsetUVal, v => { settings.offsetU = v; return v.toFixed(2); }); linkSlider(offsetVSlider, offsetVVal, v => { settings.offsetV = v; return v.toFixed(2); }); + linkSlider(rotationSlider, rotationVal, v => { settings.rotation = v; return Math.round(v); }); 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); @@ -542,8 +553,14 @@ function linkSlider(slider, valInput, onChangeFn, livePreview = true) { 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; + // Clamp to the input's own min/max (may be wider than the slider range) + const inMin = parseFloat(valInput.min); + const inMax = parseFloat(valInput.max); + const clamped = (!isNaN(inMin) && !isNaN(inMax)) + ? Math.max(inMin, Math.min(inMax, raw)) + : raw; + // Move slider thumb to nearest valid position (saturates at slider edges) + slider.value = Math.max(parseFloat(slider.min), Math.min(parseFloat(slider.max), clamped)); valInput.value = onChangeFn(clamped); if (livePreview) { clearTimeout(previewDebounce); @@ -573,13 +590,12 @@ async function handleSTL(file) { 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; + // Auto-select first preset on first load + if (!activeMapEntry && PRESETS.length > 0) { + activeMapEntry = PRESETS[0]; + activeMapName.textContent = PRESETS[0].name; const swatches = document.querySelectorAll('.preset-swatch'); - swatches.forEach((s, i) => s.classList.toggle('active', i === brickIdx)); + if (swatches.length > 0) swatches[0].classList.add('active'); } mappingSelect.value = String(settings.mappingMode); @@ -622,7 +638,7 @@ async function handleSTL(file) { // 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))); + const defaultEdge = Math.max(0.1, Math.min(5.0, +(maxDim / 100).toFixed(2))); settings.refineLength = defaultEdge; refineLenSlider.value = defaultEdge; refineLenVal.value = defaultEdge; @@ -719,6 +735,7 @@ async function handleExport() { try { setProgress(0.02, 'Subdividing mesh…'); + await yieldFrame(); // 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 @@ -729,10 +746,10 @@ async function handleExport() { ? 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 { geometry: subdivided, safetyCapHit } = await subdivide( + currentGeometry, settings.refineLength, + (p) => setProgress(0.02 + p * 0.35, 'Subdividing mesh…'), + faceWeights ); const subTriCount = subdivided.attributes.position.count / 3; @@ -761,7 +778,13 @@ async function handleExport() { decimate( displaced, settings.maxTriangles, - (p) => setProgress(0.71 + p * 0.25, `Decimating mesh…`) + (p) => { + const cur = Math.round(dispTriCount - (dispTriCount - settings.maxTriangles) * p); + setProgress( + 0.71 + p * 0.25, + `Decimating: ${cur.toLocaleString()} → ${settings.maxTriangles.toLocaleString()} triangles` + ); + } ) ); } @@ -800,7 +823,9 @@ async function handleExport() { } function setProgress(fraction, label) { - exportProgBar.style.width = `${Math.round(fraction * 100)}%`; + const pct = Math.round(fraction * 100); + exportProgBar.style.width = `${pct}%`; + exportProgPct.textContent = `${pct}%`; exportProgLbl.textContent = label; } diff --git a/js/mapping.js b/js/mapping.js index 3586323..de5b193 100644 --- a/js/mapping.js +++ b/js/mapping.js @@ -27,26 +27,29 @@ const TWO_PI = Math.PI * 2; export function computeUV(pos, normal, mode, settings, bounds) { const { min, size, center } = bounds; const { scaleU, scaleV, offsetU, offsetV } = settings; + const rotRad = (settings.rotation ?? 0) * Math.PI / 180; + const maxDim = Math.max(size.x, size.y, size.z); + const md = Math.max(maxDim, 1e-6); 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); + u = (pos.x - min.x) / md; + v = (pos.y - min.y) / md; 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); + u = (pos.x - min.x) / md; + v = (pos.z - min.z) / md; 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); + u = (pos.y - min.y) / md; + v = (pos.z - min.z) / md; break; } @@ -93,23 +96,16 @@ export function computeUV(pos, normal, mode, settings, bounds) { const az = Math.abs(normal.z); let uRaw, vRaw; if (ax >= ay && ax >= az) { - // ±X dominant → project onto ZY (U=Z, V=Y keeps texture upright on side faces) - uRaw = (pos.z - min.z) / Math.max(size.z, 1e-6); - vRaw = (pos.y - min.y) / Math.max(size.y, 1e-6); + uRaw = (pos.z - min.z) / md; + vRaw = (pos.y - min.y) / md; } 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); + uRaw = (pos.x - min.x) / md; + vRaw = (pos.z - min.z) / md; } 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); + uRaw = (pos.x - min.x) / md; + vRaw = (pos.y - min.y) / md; } - return { - triplanar: false, - u: fract(uRaw / scaleU + offsetU), - v: fract(vRaw / scaleV + offsetV), - }; + return applyTransform(uRaw, vRaw, scaleU, scaleV, offsetU, offsetV, rotRad); } case MODE_TRIPLANAR: @@ -128,40 +124,47 @@ export function computeUV(pos, normal, mode, settings, bounds) { 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), + u: (pos.x - min.x) / md, + v: (pos.y - min.y) / md, 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), + u: (pos.x - min.x) / md, + v: (pos.z - min.z) / md, 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), + u: (pos.y - min.y) / md, + v: (pos.z - min.z) / md, 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. + // Apply scale+offset+rotation and tile each independently 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 }, + { ...applyTransform(uvXY.u, uvXY.v, scaleU, scaleV, offsetU, offsetV, rotRad), w: uvXY.w }, + { ...applyTransform(uvXZ.u, uvXZ.v, scaleU, scaleV, offsetU, offsetV, rotRad), w: uvXZ.w }, + { ...applyTransform(uvYZ.u, uvYZ.v, scaleU, scaleV, offsetU, offsetV, rotRad), w: uvYZ.w }, ], }; } } - return { - triplanar: false, - u: fract(u / scaleU + offsetU), - v: fract(v / scaleV + offsetV), - }; + return applyTransform(u, v, scaleU, scaleV, offsetU, offsetV, rotRad); +} + +function applyTransform(u, v, scaleU, scaleV, offsetU, offsetV, rotRad) { + let uu = u / scaleU + offsetU; + let vv = v / scaleV + offsetV; + if (rotRad !== 0) { + const c = Math.cos(rotRad), s = Math.sin(rotRad); + uu -= 0.5; vv -= 0.5; + const ru = c * uu - s * vv; + const rv = s * uu + c * vv; + uu = ru + 0.5; vv = rv + 0.5; + } + return { triplanar: false, u: fract(uu), v: fract(vv) }; } /** Fractional part, always positive (mirrors GLSL fract) */ diff --git a/js/presetTextures.js b/js/presetTextures.js index 216520e..16f77cf 100644 --- a/js/presetTextures.js +++ b/js/presetTextures.js @@ -1,6 +1,7 @@ import * as THREE from 'three'; -const SIZE = 512; // texture resolution for both preview and sampling +const SIZE = 512; // texture resolution for both preview and sampling +const THUMB = 80; // ── Helpers ────────────────────────────────────────────────────────────────── @@ -10,227 +11,47 @@ function makeCanvas(size = SIZE) { return c; } -function grayPixel(value255) { - return `rgb(${value255},${value255},${value255})`; -} +// ── Image-based presets ─────────────────────────────────────────────────────── -// 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 }, +const IMAGE_PRESETS = [ + { name: 'Basket', url: 'basket.jpg' }, + { name: 'Brick', url: 'brick.jpg' }, + { name: 'Bubble', url: 'bubble.jpg' }, + { name: 'Crystal', url: 'crystal.jpg' }, + { name: 'Knitting', url: 'knitting.jpg' }, + { name: 'Knurling', url: 'knurling.jpg' }, + { name: 'Leather', url: 'leather.jpg' }, + { name: 'Leather 2', url: 'leather2.jpg' }, + { name: 'Weave', url: 'weave.jpg' }, + { name: 'Wood', url: 'wood.jpg' }, ]; -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; +function loadImagePreset({ name, url }) { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => { + const full = makeCanvas(SIZE); + full.getContext('2d').drawImage(img, 0, 0, SIZE, SIZE); - // Extract ImageData for CPU sampling - const ctx = fullCanvas.getContext('2d'); - const imageData = ctx.getImageData(0, 0, SIZE, SIZE); + const thumb = makeCanvas(THUMB); + thumb.getContext('2d').drawImage(img, 0, 0, THUMB, THUMB); + + const imageData = full.getContext('2d').getImageData(0, 0, SIZE, SIZE); + const texture = new THREE.CanvasTexture(full); + texture.wrapS = texture.wrapT = THREE.RepeatWrapping; + texture.name = name; + + resolve({ name, thumbCanvas: thumb, fullCanvas: full, texture, imageData, width: SIZE, height: SIZE }); + }; + img.onerror = () => reject(new Error(`Failed to load preset image: ${url}`)); + img.src = url; + }); +} + +export function loadPresets() { + return Promise.all(IMAGE_PRESETS.map(loadImagePreset)); +} - return { name, thumbCanvas, fullCanvas, texture, imageData, width: SIZE, height: SIZE }; -}); /** * Build a THREE.CanvasTexture + ImageData from a user-uploaded image File. diff --git a/js/previewMaterial.js b/js/previewMaterial.js index 16642c5..660afdd 100644 --- a/js/previewMaterial.js +++ b/js/previewMaterial.js @@ -43,6 +43,7 @@ const fragmentShader = /* glsl */` uniform vec2 scaleUV; uniform float amplitude; uniform vec2 offsetUV; + uniform float rotation; uniform vec3 boundsMin; uniform vec3 boundsSize; uniform vec3 boundsCenter; @@ -59,7 +60,13 @@ const fragmentShader = /* glsl */` // Sample after applying scale + tiling float sampleMap(vec2 rawUV) { - return texture2D(displacementMap, fract(rawUV / scaleUV + offsetUV)).r; + vec2 uv = rawUV / scaleUV + offsetUV; + // rotate around tile centre + float c = cos(rotation); float s = sin(rotation); + uv -= 0.5; + uv = vec2(c * uv.x - s * uv.y, s * uv.x + c * uv.y); + uv += 0.5; + return texture2D(displacementMap, fract(uv)).r; } // Height at this fragment for all projection modes. @@ -68,15 +75,17 @@ const fragmentShader = /* glsl */` vec3 pos = vModelPos; vec3 MN = vModelNormal; // model-space normal vec3 rel = pos - boundsCenter; + float maxDim = max(boundsSize.x, max(boundsSize.y, boundsSize.z)); + float md = max(maxDim, 1e-4); if (mappingMode == 0) { - return sampleMap((pos.xy - boundsMin.xy) / max(boundsSize.xy, vec2(1e-4))); + return sampleMap(vec2((pos.x - boundsMin.x) / md, (pos.y - boundsMin.y) / md)); } else if (mappingMode == 1) { - return sampleMap((pos.xz - boundsMin.xz) / max(boundsSize.xz, vec2(1e-4))); + return sampleMap(vec2((pos.x - boundsMin.x) / md, (pos.z - boundsMin.z) / md)); } else if (mappingMode == 2) { - return sampleMap((pos.yz - boundsMin.yz) / max(boundsSize.yz, vec2(1e-4))); + return sampleMap(vec2((pos.y - boundsMin.y) / md, (pos.z - boundsMin.z) / md)); } else if (mappingMode == 3) { // Cylindrical around Z axis (Z is up) with automatic caps. @@ -114,9 +123,9 @@ const fragmentShader = /* glsl */` blend = pow(blend, vec3(4.0)); blend /= dot(blend, vec3(1.0)) + 1e-4; - float hXY = sampleMap((pos.xy - boundsMin.xy) / max(boundsSize.xy, vec2(1e-4))); - float hXZ = sampleMap((pos.xz - boundsMin.xz) / max(boundsSize.xz, vec2(1e-4))); - float hYZ = sampleMap((pos.yz - boundsMin.yz) / max(boundsSize.yz, vec2(1e-4))); + float hXY = sampleMap(vec2((pos.x - boundsMin.x) / md, (pos.y - boundsMin.y) / md)); + float hXZ = sampleMap(vec2((pos.x - boundsMin.x) / md, (pos.z - boundsMin.z) / md)); + float hYZ = sampleMap(vec2((pos.y - boundsMin.y) / md, (pos.z - boundsMin.z) / md)); return hXY * blend.z + hXZ * blend.y + hYZ * blend.x; @@ -125,14 +134,11 @@ const fragmentShader = /* glsl */` // Picks the single planar projection whose axis is most aligned with the face normal. vec3 absN = abs(MN); if (absN.x >= absN.y && absN.x >= absN.z) { - // ±X dominant → project onto ZY plane (U=Z, V=Y keeps texture upright on side faces) - return sampleMap((pos.zy - boundsMin.zy) / max(boundsSize.zy, vec2(1e-4))); + return sampleMap(vec2((pos.z - boundsMin.z) / md, (pos.y - boundsMin.y) / md)); } else if (absN.y >= absN.x && absN.y >= absN.z) { - // ±Y dominant → project onto XZ plane - return sampleMap((pos.xz - boundsMin.xz) / max(boundsSize.xz, vec2(1e-4))); + return sampleMap(vec2((pos.x - boundsMin.x) / md, (pos.z - boundsMin.z) / md)); } else { - // ±Z dominant → project onto XY plane - return sampleMap((pos.xy - boundsMin.xy) / max(boundsSize.xy, vec2(1e-4))); + return sampleMap(vec2((pos.x - boundsMin.x) / md, (pos.y - boundsMin.y) / md)); } } } @@ -229,6 +235,7 @@ export function updateMaterial(material, displacementTexture, settings) { u.scaleUV.value.set(settings.scaleU, settings.scaleV); u.amplitude.value = settings.amplitude; u.offsetUV.value.set(settings.offsetU, settings.offsetV); + u.rotation.value = (settings.rotation ?? 0) * Math.PI / 180; if (settings.bounds) { u.boundsMin.value.copy(settings.bounds.min); u.boundsSize.value.copy(settings.bounds.size); @@ -252,6 +259,7 @@ function buildUniforms(tex, settings) { scaleUV: { value: new THREE.Vector2(settings.scaleU ?? 1, settings.scaleV ?? 1) }, amplitude: { value: settings.amplitude ?? 1.0 }, offsetUV: { value: new THREE.Vector2(settings.offsetU ?? 0, settings.offsetV ?? 0) }, + rotation: { value: ((settings.rotation ?? 0) * Math.PI / 180) }, boundsMin: { value: b.min.clone() }, boundsSize: { value: b.size.clone() }, boundsCenter: { value: b.center.clone() }, diff --git a/js/subdivision.js b/js/subdivision.js index 364b6b8..954219f 100644 --- a/js/subdivision.js +++ b/js/subdivision.js @@ -19,7 +19,7 @@ const SAFETY_CAP = 5_000_000; // absolute OOM guard // ── Public entry point ─────────────────────────────────────────────────────── -export function subdivide(geometry, maxEdgeLength, onProgress, faceWeights = null) { +export async function subdivide(geometry, maxEdgeLength, onProgress, faceWeights = null) { // Derive per-face exclusion BEFORE toIndexed so we use the untouched // non-indexed weights (toIndexed uses MAX-merge which can push boundary // vertices to weight 1.0 even on included triangles). @@ -56,6 +56,7 @@ export function subdivide(geometry, maxEdgeLength, onProgress, faceWeights = nul if (newIndices.length / 3 >= SAFETY_CAP) safetyCapHit = true; if (onProgress) onProgress(Math.min(0.95, (iter + 1) / maxIterations)); + await new Promise(r => setTimeout(r, 0)); if (!changed || safetyCapHit) break; } diff --git a/knitting.jpg b/knitting.jpg new file mode 100644 index 0000000..1bed227 Binary files /dev/null and b/knitting.jpg differ diff --git a/knurling.jpg b/knurling.jpg new file mode 100644 index 0000000..99a2ad5 Binary files /dev/null and b/knurling.jpg differ diff --git a/leather.jpg b/leather.jpg new file mode 100644 index 0000000..7bf6aa5 Binary files /dev/null and b/leather.jpg differ diff --git a/leather2.jpg b/leather2.jpg new file mode 100644 index 0000000..bc1e0d6 Binary files /dev/null and b/leather2.jpg differ diff --git a/style.css b/style.css index a21f0cc..ed756bb 100644 --- a/style.css +++ b/style.css @@ -198,14 +198,14 @@ main { /* ── Preset grid ─────────────────────────────────────────────────────── */ .preset-grid { display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 6px; + grid-template-columns: repeat(5, 1fr); + gap: 4px; margin-bottom: 10px; } .preset-swatch { aspect-ratio: 1; - border-radius: 5px; + border-radius: 4px; overflow: hidden; cursor: pointer; border: 2px solid transparent; @@ -230,9 +230,9 @@ main { right: 0; background: rgba(0,0,0,0.6); color: #fff; - font-size: 9px; + font-size: 8px; text-align: center; - padding: 2px 0; + padding: 1px 0; opacity: 0; transition: opacity 0.15s; } @@ -377,13 +377,34 @@ input[type="number"].val:focus { outline: none; border-color: var(--accent); } .export-progress.hidden { display: none; } +.export-progress-track { + position: relative; + height: 14px; + background: var(--surface2); + border: 1px solid var(--border); + border-radius: 3px; + overflow: hidden; + margin-bottom: 6px; +} + .export-progress-bar { - height: 3px; + height: 100%; background: var(--accent); border-radius: 2px; width: 0%; - transition: width 0.1s; - margin-bottom: 6px; +} + +.export-progress-pct { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 10px; + font-weight: 600; + color: #fff; + mix-blend-mode: difference; + pointer-events: none; } /* ── Export button ───────────────────────────────────────────────────── */ diff --git a/weave.jpg b/weave.jpg new file mode 100644 index 0000000..07994c4 Binary files /dev/null and b/weave.jpg differ diff --git a/wood.jpg b/wood.jpg new file mode 100644 index 0000000..21a00ee Binary files /dev/null and b/wood.jpg differ