Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

```
Expand Down
32 changes: 27 additions & 5 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -107,11 +107,17 @@ <h2 data-i18n="sections.displacementMap">Displacement Map</h2>
</div>

<!-- Custom upload -->
<label class="upload-btn" for="texture-file-input">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"><path d="M12 5v14M5 12l7-7 7 7" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
<span data-i18n="ui.uploadCustomMap">Upload custom map</span>
</label>
<input type="file" id="texture-file-input" accept="image/*" hidden />
<div class="texture-actions">
<label class="upload-btn" for="texture-file-input">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"><path d="M12 5v14M5 12l7-7 7 7" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
<span data-i18n="ui.uploadCustomMap">Upload custom map</span>
</label>
<button class="upload-btn" id="add-repo-btn" type="button">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 5h2v4h4v2h-4v4h-2v-4H7v-2h4V7z" fill="currentColor"/></svg>
<span data-i18n="ui.addTextureRepo">Add texture repo</span>
</button>
</div>
<input type="file" id="texture-file-input" accept="image/*" multiple hidden />
<div id="active-map-name" class="active-map-name" data-i18n="ui.noMapSelected">No map selected</div>
<div class="form-row slider-row">
<label for="texture-smoothing" data-i18n="labels.textureSmoothing" data-i18n-title="tooltips.textureSmoothing" title="Applies a Gaussian blur to the displacement map. Higher values produce softer, more gradual surface detail. 0 = off.">Texture Smoothing ⓘ</label>
Expand Down Expand Up @@ -418,6 +424,22 @@ <h2 id="sponsor-title" data-i18n="sponsor.title">Thanks for using BumpMesh by CN
</div>
</div>

<!-- Texture Repo Modal -->
<div id="repo-modal" class="modal-overlay">
<div class="modal-content">
<button id="repo-modal-close" class="modal-close" type="button">&times;</button>
<h3 data-i18n="repo.title">Add Texture Repository</h3>
<p class="modal-description" data-i18n="repo.description">Enter a GitHub repository that contains a <code>textures/</code> folder with displacement maps. Accepts <code>owner/repo</code> or a full GitHub URL.</p>
<p class="modal-warning" data-i18n="repo.warning">Warning: Loading textures from third-party repos is at your own risk. Only add repos you trust.</p>
<div id="repo-list" class="repo-list"></div>
<div class="repo-add-row">
<input type="text" id="repo-url-input" class="modal-input" placeholder="owner/repo" autocomplete="off" />
<button id="repo-modal-add-btn" class="repo-add-btn" type="button" data-i18n="repo.add">Add</button>
</div>
<div id="repo-modal-error" class="modal-error" style="display:none"></div>
</div>
</div>

<script type="module" src="js/main.js"></script>
</body>
</html>
27 changes: 27 additions & 0 deletions js/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
205 changes: 196 additions & 9 deletions js/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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() {
Expand Down Expand Up @@ -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 ──
Expand Down
Loading