diff --git a/lidar_pipeline/index.py b/lidar_pipeline/index.py
index e462e36..f5acf9e 100644
--- a/lidar_pipeline/index.py
+++ b/lidar_pipeline/index.py
@@ -1235,6 +1235,13 @@ _HTML_TEMPLATE = """
+
+
+
+
+
@@ -1536,6 +1543,12 @@ select, input[type="checkbox"] { accent-color: var(--accent); }
}
.layer-sub-actions { display: inline-flex; align-items: center; gap: 4px; }
.layer-sub-actions .icon-btn { font-size: 10.5px; padding: 1px 6px; }
+.preset-row {
+ display: flex; align-items: center; gap: 4px;
+ padding: 4px 6px;
+}
+.preset-row .gen-select { flex: 1; min-width: 0; }
+.preset-row .icon-btn { font-size: 10.5px; padding: 1px 6px; }
/* Sliders d'opacité : piste fine dans une zone cliquable haute de 18 px —
le pouce reste saisissable au drag (l'élément lui-même fait toute la
@@ -2228,6 +2241,119 @@ document.getElementById('osmRow').querySelector('.layer-name')
document.getElementById('osmRow').querySelector('.layer-name').title =
'Cliquer : fond de carte clair / assombri';
+// ============================================================
+// Préconfigurations de couches : un preset définit les couches
+// visibles, leurs transparences et l'ordre de pile (bas → haut).
+// Les presets intégrés couvrent les couches du panneau ; les presets
+// personnels (couches + transparences + ordre) sont persistés en
+// localStorage et se suppriment depuis la liste.
+// ============================================================
+const PRESETS_LS = 'lidarPresets_v1';
+const BUILTIN_PRESETS = [
+ { label: 'Relief', on: ['aspect', 'positive_openness'],
+ opacity: { aspect: 1, positive_openness: 0.65 } },
+ { label: 'Pentes', on: ['aspect', 'slope'],
+ opacity: { aspect: 0.5, slope: 1 } },
+ { label: 'Cavités', on: ['slope', 'positive_openness'],
+ opacity: { slope: 0.5, positive_openness: 1 } },
+ { label: 'Rugosité', on: ['roughness'], opacity: {} },
+ { label: 'Tout', on: ['slope', 'aspect', 'positive_openness', 'roughness'],
+ opacity: { slope: 0.8, aspect: 0.8, positive_openness: 0.55, roughness: 0.8 } },
+];
+
+function loadCustomPresets() {
+ try {
+ const p = JSON.parse(localStorage.getItem(PRESETS_LS) || '[]');
+ return Array.isArray(p) ? p : [];
+ } catch (e) { return []; }
+}
+function saveCustomPresets(list) {
+ try { localStorage.setItem(PRESETS_LS, JSON.stringify(list)); } catch (e) { /* privé */ }
+}
+
+function applyPreset(p) {
+ const want = (p.on || []).filter(k => VIZ_META[k]);
+ const L = state.layers;
+ const off = L.order.filter(k => !want.includes(k));
+ L.order = want.slice(); // ordre du preset (bas → haut)
+ L.order.push(...off); // les autres gardent leur ordre relatif
+ for (const k of L.order) {
+ const on = want.includes(k);
+ L.on[k] = on;
+ if (on) L.opacity[k] = (p.opacity && k in p.opacity) ? Number(p.opacity[k]) : 1;
+ }
+ saveLayers();
+ renderLayerPanel();
+ syncTileLayers();
+}
+
+const presetSelectEl = document.getElementById('presetSelect');
+const presetSaveEl = document.getElementById('presetSave');
+const presetDeleteEl = document.getElementById('presetDelete');
+
+function renderPresetSelect(selectedValue) {
+ presetSelectEl.innerHTML = '';
+ const none = document.createElement('option');
+ none.value = '';
+ none.textContent = 'Préconfigurations…';
+ presetSelectEl.appendChild(none);
+ for (const p of BUILTIN_PRESETS) {
+ const o = document.createElement('option');
+ o.value = 'builtin:' + p.label;
+ o.textContent = p.label;
+ presetSelectEl.appendChild(o);
+ }
+ const customs = loadCustomPresets();
+ if (customs.length) {
+ const g = document.createElement('optgroup');
+ g.label = 'Mes presets';
+ for (const p of customs) {
+ const o = document.createElement('option');
+ o.value = 'custom:' + p.id;
+ o.textContent = p.label;
+ g.appendChild(o);
+ }
+ presetSelectEl.appendChild(g);
+ }
+ if (selectedValue) presetSelectEl.value = selectedValue;
+ presetDeleteEl.hidden = !presetSelectEl.value.startsWith('custom:');
+}
+
+presetSelectEl.addEventListener('change', () => {
+ const v = presetSelectEl.value;
+ if (!v) return;
+ let p = null;
+ if (v.startsWith('builtin:')) p = BUILTIN_PRESETS.find(x => x.label === v.slice(8));
+ else if (v.startsWith('custom:')) p = loadCustomPresets().find(x => x.id === v.slice(7));
+ if (p) applyPreset(p);
+});
+
+presetSaveEl.addEventListener('click', () => {
+ const name = prompt('Nom du preset :');
+ if (!name || !name.trim()) return;
+ const enabled = state.layers.order.filter(k => state.layers.on[k]);
+ const p = {
+ id: Date.now().toString(36),
+ label: name.trim(),
+ on: enabled,
+ opacity: Object.fromEntries(enabled.map(k => [k, state.layers.opacity[k]])),
+ };
+ const customs = loadCustomPresets();
+ customs.push(p);
+ saveCustomPresets(customs);
+ renderPresetSelect('custom:' + p.id);
+});
+
+presetDeleteEl.addEventListener('click', () => {
+ const v = presetSelectEl.value;
+ if (!v.startsWith('custom:')) return;
+ if (!confirm('Supprimer ce preset ?')) return;
+ saveCustomPresets(loadCustomPresets().filter(p => p.id !== v.slice(7)));
+ renderPresetSelect('');
+});
+
+renderPresetSelect();
+
// Barre latérale : repli + onglets (Couches / Génération)
const sidebar = document.getElementById('sidebar');
function setSidebarCollapsed(collapsed) {
diff --git a/lidar_pipeline/tests/test_index.py b/lidar_pipeline/tests/test_index.py
index 208ccd6..c4d4f48 100644
--- a/lidar_pipeline/tests/test_index.py
+++ b/lidar_pipeline/tests/test_index.py
@@ -274,6 +274,13 @@ def test_build_index_generates_html(tmp_path):
# le ↻ resynchronise puis reconstruit la carte (mode webapp distante).
assert 'id="layerRefresh"' in content
assert "/api/layers" in app_js and "/api/sync" in app_js
+ # Préconfigurations de couches (onglet Couches) : le sélecteur applique
+ # des presets intégrés (couches visibles, transparences, ordre de pile)
+ # et 💾/✕ gèrent les presets personnels persistés en localStorage.
+ assert 'id="presetSelect"' in content
+ assert 'id="presetSave"' in content
+ assert "BUILTIN_PRESETS" in app_js and "applyPreset" in app_js
+ assert "lidarPresets_v1" in app_js
# Partage de la vue : bouton « Partager » + lien contenant vue, couches
# et transparences (hash #zoom,lat,lng&L=...&O=...) — le destinataire
# reçoit l'état exact, indépendamment de son localStorage local.