diff --git a/index.html b/index.html
index 9db2716..6dc2b11 100644
--- a/index.html
+++ b/index.html
@@ -111,7 +111,7 @@
Displacement Map
Upload custom map
-
+
No map selected
diff --git a/js/main.js b/js/main.js
index 018284e..6863c39 100644
--- a/js/main.js
+++ b/js/main.js
@@ -19,6 +19,7 @@ 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 previewMaterial = null;
let isExporting = false;
let previewDebounce = null;
@@ -355,6 +356,49 @@ function buildPresetGrid() {
});
}
+function addCustomSwatch(entry) {
+ const swatch = document.createElement('div');
+ swatch.className = 'preset-swatch custom-swatch';
+ swatch.title = entry.name;
+
+ 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();
+ const idx = customMaps.indexOf(entry);
+ if (idx !== -1) customMaps.splice(idx, 1);
+ 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;
@@ -399,18 +443,22 @@ 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 = '';
});
// ── Settings ──
diff --git a/js/presetTextures.js b/js/presetTextures.js
index 7ad1295..695747c 100644
--- a/js/presetTextures.js
+++ b/js/presetTextures.js
@@ -101,7 +101,11 @@ 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;
diff --git a/style.css b/style.css
index 9285f91..2678f7d 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,28 @@ 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; }
+
/* ── Custom upload button ─────────────────────────────────────────────── */
.upload-btn {
display: inline-flex;