diff --git a/README.md b/README.md index b6fa70b..de1267e 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,8 @@ Load an STL, OBJ, or 3MF file, pick a texture, tune the parameters, and export a ### Textures - **24 built-in seamless textures** — basket, brick, bubble, carbon fiber, crystal, dots, grid, grip surface, hexagon, hexagons, isogrid, knitting, knurling, leather 2, noise, stripes (×2 variants), voronoi, weave (×3 variants), wood (×3 variants) -- **Custom textures** — upload your own image as a displacement map +- **Custom textures** — upload one or multiple images as displacement maps; they appear in the grid alongside the built-in presets for easy switching +- **Community texture repos** — add GitHub repositories as texture sources (see [Custom Texture Repos](#custom-texture-repos) below) - **Texture smoothing** — configurable blur to soften the displacement map before applying ### Projection Modes @@ -80,6 +81,39 @@ Load an STL, OBJ, or 3MF file, pick a texture, tune the parameters, and export a > **Note:** All processing runs entirely in the browser — no data is uploaded to any server. +## Custom Texture Repos + +You can load displacement maps from public GitHub repositories directly into BumpMesh. This lets the community share texture packs without bundling them into the app. + +### How it works + +1. Click **Add texture repo** in the Displacement Map section. +2. Enter a GitHub repository in `owner/repo` format (e.g. `myuser/my-textures`) or paste a full GitHub URL. +3. The textures are fetched and added to the grid. You can switch between them just like built-in presets. +4. To remove a repo, open the modal again and click the **x** next to the repo name. This removes all its textures from the grid. + +Added repos are session-only — they are not persisted and will be gone after a page reload. + +### Repository structure + +A texture repo must have a `textures/` folder at the root containing the image files: + +``` +my-textures/ + textures/ + pattern1.jpg + pattern2.png + ... +``` + +### Supported image formats + +jpg, jpeg, png, webp, bmp, gif, svg, tiff, avif, ico + +### Disclaimer + +Loading textures from third-party repositories is **at your own risk**. BumpMesh fetches images directly from GitHub — only add repos you trust. The authors of BumpMesh are not responsible for content provided by third-party repositories. + ## Project Structure ``` diff --git a/index.html b/index.html index 9db2716..e44eda1 100644 --- a/index.html +++ b/index.html @@ -107,11 +107,17 @@

Displacement Map

- - +
+ + +
+
No map selected
@@ -418,6 +424,22 @@
+ + + diff --git a/js/i18n.js b/js/i18n.js index c5880bd..22f475a 100644 --- a/js/i18n.js +++ b/js/i18n.js @@ -24,7 +24,16 @@ export const TRANSLATIONS = { // Displacement map section 'sections.displacementMap': 'Displacement Map', 'ui.uploadCustomMap': 'Upload custom map', + 'ui.addTextureRepo': 'Add texture repo', 'ui.noMapSelected': 'No map selected', + 'repo.title': 'Add Texture Repository', + 'repo.description': 'Enter a GitHub repository that contains a textures/ folder with displacement maps. Accepts owner/repo or a full GitHub URL.', + 'repo.warning': 'Warning: Loading textures from third-party repos is at your own risk. Only add repos you trust.', + 'repo.add': 'Add', + 'repo.loading': 'Loading...', + 'repo.invalidUrl': 'Invalid format. Use owner/repo or a GitHub URL.', + 'repo.alreadyAdded': 'This repo has already been added.', + 'repo.remove': 'Remove repo', // Projection section 'sections.projection': 'Projection', @@ -210,7 +219,16 @@ export const TRANSLATIONS = { // Displacement map section 'sections.displacementMap': 'Textur', 'ui.uploadCustomMap': 'Eigene Textur hochladen', + 'ui.addTextureRepo': 'Textur-Repo hinzuf\u00fcgen', 'ui.noMapSelected': 'Keine Textur ausgew\u00e4hlt', + 'repo.title': 'Textur-Repository hinzuf\u00fcgen', + 'repo.description': 'Gib ein GitHub-Repository an, das einen textures/-Ordner mit Displacement Maps enth\u00e4lt. Akzeptiert owner/repo oder eine vollst\u00e4ndige GitHub-URL.', + 'repo.warning': 'Achtung: Das Laden von Texturen aus fremden Repos geschieht auf eigene Gefahr. F\u00fcge nur Repos hinzu, denen du vertraust.', + 'repo.add': 'Hinzuf\u00fcgen', + 'repo.loading': 'Wird geladen...', + 'repo.invalidUrl': 'Ung\u00fcltiges Format. Verwende owner/repo oder eine GitHub-URL.', + 'repo.alreadyAdded': 'Dieses Repo wurde bereits hinzugef\u00fcgt.', + 'repo.remove': 'Repo entfernen', // Projection section 'sections.projection': 'Projektion', @@ -396,7 +414,16 @@ export const TRANSLATIONS = { // Displacement map section 'sections.displacementMap': 'Mappa di Deformazione', 'ui.uploadCustomMap': 'Carica mappa personalizzata', + 'ui.addTextureRepo': 'Aggiungi repo texture', 'ui.noMapSelected': 'Nessuna mappa selezionata', + 'repo.title': 'Aggiungi repository texture', + 'repo.description': 'Inserisci un repository GitHub che contiene una cartella textures/ con mappe di displacement. Accetta owner/repo o un URL GitHub completo.', + 'repo.warning': 'Attenzione: Il caricamento di texture da repository di terze parti avviene a proprio rischio. Aggiungi solo repository di cui ti fidi.', + 'repo.add': 'Aggiungi', + 'repo.loading': 'Caricamento...', + 'repo.invalidUrl': 'Formato non valido. Usa owner/repo o un URL GitHub.', + 'repo.alreadyAdded': 'Questo repository \u00e8 gi\u00e0 stato aggiunto.', + 'repo.remove': 'Rimuovi repository', // Projection section 'sections.projection': 'Proiezione', diff --git a/js/main.js b/js/main.js index 018284e..11ec2e5 100644 --- a/js/main.js +++ b/js/main.js @@ -3,7 +3,7 @@ import { initViewer, loadGeometry, setMeshMaterial, setMeshGeometry, setWirefram getControls, getCamera, getCurrentMesh, setExclusionOverlay, setHoverPreview, setViewerTheme } from './viewer.js'; import { loadModelFile, computeBounds, getTriangleCount } from './stlLoader.js'; -import { loadPresets, loadCustomTexture } from './presetTextures.js'; +import { loadPresets, loadCustomTexture, loadTextureFromUrl } from './presetTextures.js'; import { createPreviewMaterial, updateMaterial } from './previewMaterial.js'; import { subdivide } from './subdivision.js'; import { applyDisplacement } from './displacement.js'; @@ -19,6 +19,9 @@ 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 customMaps = []; // user-uploaded textures (session only) +let repoMaps = []; // textures loaded from custom repos (session only) +let addedRepos = []; // list of added repo identifiers (session only) let previewMaterial = null; let isExporting = false; let previewDebounce = null; @@ -355,6 +358,50 @@ function buildPresetGrid() { }); } +function addCustomSwatch(entry) { + const swatch = document.createElement('div'); + swatch.className = 'preset-swatch custom-swatch'; + swatch.title = entry.name; + if (entry.repoId) swatch.dataset.repo = entry.repoId; + + swatch.appendChild(entry.thumbCanvas); + + const label = document.createElement('span'); + label.className = 'preset-label'; + label.textContent = entry.name; + swatch.appendChild(label); + + const removeBtn = document.createElement('button'); + removeBtn.className = 'custom-swatch-remove'; + removeBtn.textContent = '\u00d7'; + removeBtn.title = 'Remove'; + removeBtn.addEventListener('click', (e) => { + e.stopPropagation(); + customMaps = customMaps.filter(m => m !== entry); + repoMaps = repoMaps.filter(m => m !== entry); + swatch.remove(); + if (activeMapEntry === entry) { + activeMapEntry = null; + activeMapName.textContent = t('ui.noMapSelected'); + updatePreview(); + } + }); + swatch.appendChild(removeBtn); + + swatch.addEventListener('click', () => selectCustomMap(entry, swatch)); + presetGrid.appendChild(swatch); + return swatch; +} + +function selectCustomMap(entry, swatchEl) { + document.querySelectorAll('.preset-swatch').forEach(s => s.classList.remove('active')); + swatchEl.classList.add('active'); + activeMapEntry = entry; + activeMapName.textContent = entry.name; + resetTextureSmoothing(); + updatePreview(); +} + function resetTextureSmoothing() { settings.textureSmoothing = 0; textureSmoothingSlider.value = 0; @@ -371,6 +418,131 @@ function selectPreset(idx, swatchEl) { updatePreview(); } +// ── Texture repo loading ───────────────────────────────────────────────────── + +/** + * Parse a GitHub repo identifier from various input formats. + * Accepts: "owner/repo", "https://github.com/owner/repo", etc. + */ +function parseRepoId(input) { + const trimmed = input.trim().replace(/\/+$/, ''); + const ghMatch = trimmed.match(/github\.com\/([^/]+\/[^/]+)/); + if (ghMatch) return ghMatch[1]; + if (/^[^/]+\/[^/]+$/.test(trimmed)) return trimmed; + return null; +} + +async function loadRepoTextures(repoId) { + const apiUrl = `https://api.github.com/repos/${repoId}/contents/textures`; + const resp = await fetch(apiUrl); + if (!resp.ok) throw new Error(`Could not fetch repo: ${resp.status}`); + const files = await resp.json(); + const imageFiles = files.filter(f => f.type === 'file' && /\.(jpe?g|png|webp|bmp|gif|svg|tiff?|avif|ico)$/i.test(f.name)); + if (imageFiles.length === 0) throw new Error('No image files found in textures/ folder'); + + const results = []; + for (const file of imageFiles) { + try { + const entry = await loadTextureFromUrl(file.download_url, `${repoId.split('/')[1]}/${file.name}`); + entry.repoId = repoId; + repoMaps.push(entry); + const swatch = addCustomSwatch(entry); + results.push({ entry, swatch }); + } catch (err) { + console.warn(`Failed to load ${file.name} from ${repoId}:`, err); + } + } + return results; +} + +function renderRepoList() { + const list = document.getElementById('repo-list'); + list.innerHTML = ''; + if (addedRepos.length === 0) return; + addedRepos.forEach(repoId => { + const row = document.createElement('div'); + row.className = 'repo-list-item'; + const name = document.createElement('span'); + name.className = 'repo-list-name'; + name.textContent = repoId; + const removeBtn = document.createElement('button'); + removeBtn.className = 'repo-list-remove'; + removeBtn.textContent = '\u00d7'; + removeBtn.title = t('repo.remove'); + removeBtn.addEventListener('click', () => removeRepo(repoId)); + row.appendChild(name); + row.appendChild(removeBtn); + list.appendChild(row); + }); +} + +function removeRepo(repoId) { + addedRepos = addedRepos.filter(r => r !== repoId); + // Remove swatches and entries for this repo + const toRemove = repoMaps.filter(m => m.repoId === repoId); + toRemove.forEach(entry => { + const swatch = presetGrid.querySelector(`.custom-swatch[data-repo="${repoId}"][title="${entry.name}"]`); + if (swatch) swatch.remove(); + if (activeMapEntry === entry) { + activeMapEntry = null; + activeMapName.textContent = t('ui.noMapSelected'); + } + }); + repoMaps = repoMaps.filter(m => m.repoId !== repoId); + renderRepoList(); + updatePreview(); +} + +function openRepoModal() { + const input = document.getElementById('repo-url-input'); + const error = document.getElementById('repo-modal-error'); + input.value = ''; + error.textContent = ''; + error.style.display = 'none'; + renderRepoList(); + document.getElementById('repo-modal').classList.add('visible'); + input.focus(); +} + +function closeRepoModal() { + document.getElementById('repo-modal').classList.remove('visible'); +} + +async function submitRepo() { + const input = document.getElementById('repo-url-input'); + const error = document.getElementById('repo-modal-error'); + const addBtn = document.getElementById('repo-modal-add-btn'); + const repoId = parseRepoId(input.value); + if (!repoId) { + error.textContent = t('repo.invalidUrl'); + error.style.display = 'block'; + return; + } + if (addedRepos.includes(repoId)) { + error.textContent = t('repo.alreadyAdded'); + error.style.display = 'block'; + return; + } + error.style.display = 'none'; + addBtn.disabled = true; + addBtn.textContent = t('repo.loading'); + try { + const results = await loadRepoTextures(repoId); + if (results.length === 0) throw new Error('No textures loaded'); + addedRepos.push(repoId); + input.value = ''; + renderRepoList(); + // Select the first loaded texture + selectCustomMap(results[0].entry, results[0].swatch); + } catch (err) { + error.textContent = err.message; + error.style.display = 'block'; + } finally { + addBtn.disabled = false; + addBtn.textContent = t('repo.add'); + } +} + // ── Event wiring ────────────────────────────────────────────────────────────── function wireEvents() { @@ -399,18 +571,33 @@ function wireEvents() { // ── Custom texture upload ── textureInput.addEventListener('change', async (e) => { - const file = e.target.files[0]; - if (!file) return; + const files = [...e.target.files]; + if (!files.length) return; try { - activeMapEntry = await loadCustomTexture(file); - activeMapEntry.isCustom = true; - activeMapName.textContent = file.name; - document.querySelectorAll('.preset-swatch').forEach(s => s.classList.remove('active')); - resetTextureSmoothing(); - updatePreview(); + let lastSwatch = null; + for (const file of files) { + if (customMaps.some(m => m.name === file.name && m.size === file.size)) continue; + const entry = await loadCustomTexture(file); + entry.size = file.size; + customMaps.push(entry); + lastSwatch = addCustomSwatch(entry); + } + if (lastSwatch) selectCustomMap(customMaps[customMaps.length - 1], lastSwatch); } catch (err) { console.error('Failed to load texture:', err); } + textureInput.value = ''; + }); + + // ── Texture repo modal ── + document.getElementById('add-repo-btn').addEventListener('click', openRepoModal); + document.getElementById('repo-modal-close').addEventListener('click', closeRepoModal); + document.getElementById('repo-modal-add-btn').addEventListener('click', submitRepo); + document.getElementById('repo-url-input').addEventListener('keydown', (e) => { + if (e.key === 'Enter') submitRepo(); + }); + document.getElementById('repo-modal').addEventListener('click', (e) => { + if (e.target === e.currentTarget) closeRepoModal(); }); // ── Settings ── diff --git a/js/presetTextures.js b/js/presetTextures.js index 7ad1295..3cbc2d5 100644 --- a/js/presetTextures.js +++ b/js/presetTextures.js @@ -101,9 +101,39 @@ export function loadCustomTexture(file) { 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: w, height: h }); + + const thumb = makeCanvas(THUMB); + drawCover(thumb.getContext('2d'), img, THUMB); + + resolve({ name: file.name, thumbCanvas: thumb, fullCanvas: canvas, texture, imageData, width: w, height: h, isCustom: true }); }; img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('Failed to load image')); }; img.src = url; }); } + +/** + * Load a texture from a remote URL (e.g. raw GitHub content). + */ +export function loadTextureFromUrl(url, name) { + return new Promise((resolve, reject) => { + const img = new Image(); + img.crossOrigin = 'anonymous'; + img.onload = () => { + const { w, h } = fitDimensions(img.width, img.height); + const canvas = makeCanvas(w, h); + canvas.getContext('2d').drawImage(img, 0, 0, w, h); + const imageData = canvas.getContext('2d').getImageData(0, 0, w, h); + const texture = new THREE.CanvasTexture(canvas); + texture.wrapS = texture.wrapT = THREE.RepeatWrapping; + texture.name = name; + + const thumb = makeCanvas(THUMB); + drawCover(thumb.getContext('2d'), img, THUMB); + + resolve({ name, thumbCanvas: thumb, fullCanvas: canvas, texture, imageData, width: w, height: h, isCustom: true }); + }; + img.onerror = () => reject(new Error(`Failed to load texture: ${name}`)); + img.src = url; + }); +} diff --git a/style.css b/style.css index 9285f91..1ffe0f3 100644 --- a/style.css +++ b/style.css @@ -397,6 +397,14 @@ main { grid-template-columns: repeat(6, 1fr); gap: 3px; margin-bottom: 10px; + margin-right: -10px; + padding-right: 7px; + /* Cap at 5 rows then scroll */ + max-height: calc((var(--sidebar-w) - 2 * 16px - 5 * 3px) / 6 * 5 + 4 * 3px); + overflow-y: auto; + overflow-x: hidden; + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; } .preset-swatch { @@ -405,11 +413,11 @@ main { overflow: hidden; cursor: pointer; border: 2px solid transparent; - transition: border-color 0.15s, transform 0.1s; + transition: border-color 0.15s; position: relative; } -.preset-swatch:hover { border-color: var(--text-muted); transform: scale(1.04); } +.preset-swatch:hover { border-color: var(--text-muted); } .preset-swatch.active { border-color: var(--accent); } .preset-swatch canvas { @@ -435,6 +443,35 @@ main { .preset-swatch:hover .preset-label { opacity: 1; } +/* ── Custom swatch remove button ──────────────────────────────────────── */ +.custom-swatch-remove { + position: absolute; + top: 1px; + right: 1px; + width: 16px; + height: 16px; + border: none; + border-radius: 50%; + background: rgba(0,0,0,0.65); + color: #fff; + font-size: 12px; + line-height: 16px; + text-align: center; + padding: 0; + cursor: pointer; + opacity: 0; + transition: opacity 0.15s; +} +.custom-swatch:hover .custom-swatch-remove { opacity: 1; } +.custom-swatch-remove:hover { background: #e44; } + +/* ── Texture action buttons row ───────────────────────────────────────── */ +.texture-actions { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + /* ── Custom upload button ─────────────────────────────────────────────── */ .upload-btn { display: inline-flex; @@ -1139,4 +1176,152 @@ input[type="number"].val:focus { outline: none; border-color: var(--accent); } background: var(--border); border-color: var(--accent); color: var(--text); -} \ No newline at end of file +} + +/* ── Repo modal overlay ─────────────────────────────────────────────── */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + display: none; + align-items: center; + justify-content: center; + z-index: 9999; + backdrop-filter: blur(3px); +} +.modal-overlay.visible { display: flex; } + +.modal-content { + position: relative; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 24px; + max-width: 460px; + width: 90%; + box-shadow: 0 8px 32px rgba(0,0,0,0.3); +} + +.modal-close { + position: absolute; + top: 8px; + right: 12px; + background: none; + border: none; + color: var(--text-muted); + font-size: 22px; + cursor: pointer; + line-height: 1; +} +.modal-close:hover { color: var(--text); } + +.modal-content h3 { + margin: 0 0 12px; + font-size: 16px; + color: var(--text); +} + +.modal-description { + font-size: 12px; + color: var(--text-muted); + margin: 0 0 8px; + line-height: 1.5; +} +.modal-description code { + background: var(--surface2); + padding: 1px 4px; + border-radius: 3px; + font-size: 11px; +} + +.modal-warning { + font-size: 11px; + color: var(--danger); + margin: 0 0 14px; + line-height: 1.4; +} + +.modal-input { + width: 100%; + padding: 8px 10px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg); + color: var(--text); + font-size: 13px; + outline: none; + box-sizing: border-box; +} +.modal-input:focus { border-color: var(--accent); } + +.modal-error { + color: var(--danger); + font-size: 11px; + margin-top: 6px; +} + +/* ── Repo list in modal ──────────────────────────────────────────────── */ +.repo-list { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 10px; +} +.repo-list:empty { margin-bottom: 0; } + +.repo-list-item { + display: flex; + align-items: center; + justify-content: space-between; + background: var(--surface2); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 6px 10px; + font-size: 12px; + color: var(--text); +} + +.repo-list-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.repo-list-remove { + flex-shrink: 0; + background: none; + border: none; + color: var(--text-muted); + font-size: 18px; + line-height: 1; + cursor: pointer; + padding: 0 2px; + margin-left: 8px; +} +.repo-list-remove:hover { color: var(--danger); } + +/* ── Repo add row ────────────────────────────────────────────────────── */ +.repo-add-row { + display: flex; + gap: 6px; +} +.repo-add-row .modal-input { + flex: 1; + min-width: 0; +} + +.repo-add-btn { + flex-shrink: 0; + padding: 8px 14px; + background: var(--accent); + color: #fff; + border: none; + border-radius: var(--radius); + font-size: 13px; + cursor: pointer; + transition: opacity 0.15s; + white-space: nowrap; +} +.repo-add-btn:hover { opacity: 0.85; } +.repo-add-btn:disabled { opacity: 0.5; cursor: not-allowed; } \ No newline at end of file