Borner le comblement du MNT à l'enveloppe des points, ajouter la couche précision et un affichage relief/précision, encoder les sous-tuiles une seule fois en q75

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Antoine Jacquin
2026-09-27 15:07:04 +02:00
parent c5097e21a0
commit 90dbe61f3b
17 changed files with 994 additions and 593 deletions

View File

@ -210,7 +210,9 @@ def test_map_meta(tmp_path, monkeypatch):
assert meta["tile_size"] == 512 and meta["zoom_offset"] == -1
assert meta["max_native_zoom"] == 19
assert meta["bounds"] and meta["stamp"] > 0
assert meta["default_layers"] == ["relief_oriente"]
assert meta["default_main"] == "relief_oriente"
assert meta["precision_layer"] is None # densité absente de la fixture
assert meta["default_mode"] == "relief"
def test_only_panel_layers_are_served(tmp_path, monkeypatch):
@ -256,31 +258,31 @@ def test_healthz_and_assets(tmp_path, monkeypatch):
mapserve.assets("introuvable.js")
def test_ui_offers_reorder_and_blend_modes():
"""Pile de couches : réordonnancement au doigt ET modes de fusion.
def test_ui_main_layer_and_precision_modes():
"""Une couche d'affichage principal et la précision : relief seul,
précision seule, ou précision en « produit » sur le relief.
La superposition ne se réduit pas à de la transparence : chaque couche
porte un mix-blend-mode, cantonné à la pile LiDAR par un conteneur isolé
(sinon « Produit » assombrirait aussi le fond de carte).
Plus de pile réordonnable ni de fusion par couche. Le produit est
cantonné à la pile LiDAR par un conteneur isolé (sinon il assombrirait
aussi le fond de carte). Bascule rapide au clavier (touche P).
"""
from lidar_pipeline.mapui import _MAP_CSS, _MAP_HTML, _MAP_JS
# Modes proposés
for mode in ("multiply", "screen", "overlay", "soft-light", "difference",
"luminosity"):
assert f"'{mode}'" in _MAP_JS, mode
# Réordonnancement : glisser-déposer ET boutons (seuls utilisables au doigt)
assert "function moveLayer(" in _MAP_JS
assert "moveLayer(key, 1)" in _MAP_JS and "moveLayer(key, -1)" in _MAP_JS
assert "dragstart" in _MAP_JS and "layer-move" in _MAP_CSS
# Pile isolée + fusion par couche et fusion de la pile sur le fond
assert "createPane('lidarStack')" in _MAP_JS
assert "isolation = 'isolate'" in _MAP_JS
assert "map.createPane(paneName, stack)" in _MAP_JS
assert "pane.style.mixBlendMode" in _MAP_JS
assert "STATE.stackBlend" in _MAP_JS and 'id="stackBlend"' in _MAP_HTML
# Le lien partagé transporte la fusion (4e champ, rétrocompatible)
assert "(STATE.blend[k] || 'normal')" in _MAP_JS
assert "const b = validBlend(f[3]);" in _MAP_JS
for mode in ('data-mode="relief"', 'data-mode="precision"', 'data-mode="both"'):
assert mode in _MAP_HTML
assert 'id="precOpacity"' in _MAP_HTML and 'id="precLegend"' in _MAP_HTML
assert 'id="mainSel"' in _MAP_HTML
assert "blend = 'multiply'" in _MAP_JS
assert "createPane('lidarStack')" in _MAP_JS and "isolation = 'isolate'" in _MAP_JS
assert "e.key === 'p'" in _MAP_JS
# Légende en 16 paliers exacts (échelle log de la densité)
assert "const PREC_LEVELS = 16;" in _MAP_JS and "repeat(16, 1fr)" in _MAP_CSS
# Pile et fusions supprimées
for gone in ("function moveLayer(", "reorderRelative", "stackBlend", "dragstart", "blendOptions"):
assert gone not in _MAP_JS, gone
assert "Haut de pile" not in _MAP_HTML
# Lien partagé : principale, mode:opacité, fond
assert "'&M=' + STATE.main" in _MAP_JS
assert "'&P=' + STATE.mode" in _MAP_JS
def test_map_ui_tile_load_indicator_and_selection_pane():
@ -301,85 +303,73 @@ def test_map_ui_tile_load_indicator_and_selection_pane():
def test_defaults_roundtrip(tmp_path, monkeypatch):
"""L'état réglé depuis la carte devient la configuration servie à tous."""
"""L'affichage réglé depuis la carte devient la configuration servie à tous."""
import lidar_pipeline.mapserve as mapserve
_setup(tmp_path, monkeypatch)
_setup(tmp_path, monkeypatch, layers=("relief_oriente", "aspect", "densite_sol"))
monkeypatch.setattr(mapserve, "DEFAULTS_FILE", tmp_path / ".map-defaults.json")
# Sans enregistrement : réglages du registre (index.py)
meta = mapserve.map_meta()
assert meta["defaults_saved"] is False
assert meta["default_order"] is None
assert meta["default_stack_blend"] == "normal"
assert meta["default_main"] == "relief_oriente"
assert meta["precision_layer"] == "densite_sol"
assert meta["default_mode"] == "relief"
assert meta["default_precision_opacity"] == 0.6
req = mapserve.DefaultsRequest(
order=["slope", "aspect"], on=["aspect"],
opacity={"aspect": 0.6, "slope": 1.0},
blend={"aspect": "multiply", "slope": "normal"},
base={"on": True, "opacity": 0.4, "dark": False},
stack_blend="soft-light")
req = mapserve.DefaultsRequest(main="aspect", mode="both", precision_opacity=0.4,
base={"on": True, "opacity": 0.4, "dark": False})
assert mapserve.set_defaults(req)["enregistré"] is True
meta = mapserve.map_meta()
assert meta["defaults_saved"] is True
assert meta["default_order"] == ["slope", "aspect"]
assert meta["default_layers"] == ["aspect"]
assert meta["default_opacity"]["aspect"] == 0.6
assert meta["default_blend"]["aspect"] == "multiply"
assert meta["default_main"] == "aspect"
assert meta["default_mode"] == "both"
assert meta["default_precision_opacity"] == 0.4
assert meta["default_base"] == {"on": True, "opacity": 0.4, "dark": False}
assert meta["default_stack_blend"] == "soft-light"
# Persisté sur disque : survit au redémarrage du conteneur
assert (tmp_path / ".map-defaults.json").is_file()
assert mapserve.get_defaults()["defaults"]["on"] == ["aspect"]
assert mapserve.get_defaults()["defaults"]["mode"] == "both"
# Retrait : retour aux réglages du registre
assert mapserve.clear_defaults()["supprimé"] is True
assert mapserve.map_meta()["defaults_saved"] is False
def test_defaults_obsolete_layers_fall_back(tmp_path, monkeypatch):
"""Défauts figés sur des couches qui ne sont plus servies : registre.
Sinon un nouveau navigateur arrive sur une carte sans aucune couche
LiDAR allumée (cas de la prod après le passage au seul relief orienté).
"""
def test_defaults_legacy_stack_file_falls_back(tmp_path, monkeypatch):
"""Fichier de l'ancienne pile (order/on/blend) ou couche disparue :
couche principale du registre, mode relief ; le fond est conservé."""
import json
import lidar_pipeline.mapserve as mapserve
_setup(tmp_path, monkeypatch, layers=("relief_oriente",))
defaults = tmp_path / ".map-defaults.json"
monkeypatch.setattr(mapserve, "DEFAULTS_FILE", defaults)
defaults.write_text(json.dumps({
"order": ["aspect", "positive_openness"],
"on": ["aspect", "positive_openness"], "opacity": {}, "blend": {},
"base": {"on": True, "opacity": 0.85, "dark": True},
"stack_blend": "normal"}), encoding="utf-8")
"order": ["aspect"], "on": ["aspect"], "opacity": {}, "blend": {},
"base": {"on": True, "opacity": 0.5, "dark": True}, "stack_blend": "normal"}),
encoding="utf-8")
meta = mapserve.map_meta()
assert meta["default_layers"] == ["relief_oriente"]
# Tout éteint volontairement (couches connues) : respecté
defaults.write_text(json.dumps({
"order": ["relief_oriente"], "on": [], "opacity": {}, "blend": {},
"base": {}, "stack_blend": "normal"}), encoding="utf-8")
assert mapserve.map_meta()["default_layers"] == []
assert meta["default_main"] == "relief_oriente"
assert meta["default_mode"] == "relief"
assert meta["default_base"]["opacity"] == 0.5
defaults.write_text(json.dumps({"main": "aspect", "mode": "vaudou"}), encoding="utf-8")
meta = mapserve.map_meta()
assert meta["default_main"] == "relief_oriente" and meta["default_mode"] == "relief"
def test_defaults_sanitised(tmp_path, monkeypatch):
"""Couches inconnues écartées, opacités bornées, fusions validées."""
"""Couche inconnue ou précision refusée comme principale, mode validé, opacités bornées."""
import lidar_pipeline.mapserve as mapserve
_setup(tmp_path, monkeypatch)
_setup(tmp_path, monkeypatch, layers=("relief_oriente", "densite_sol"))
monkeypatch.setattr(mapserve, "DEFAULTS_FILE", tmp_path / ".map-defaults.json")
data = mapserve.set_defaults(mapserve.DefaultsRequest(
order=["aspect", "inexistante"], on=["aspect", "inexistante"],
opacity={"aspect": 5, "slope": "x", "inexistante": 0.5},
blend={"aspect": "vaudou", "slope": "screen"},
base={"opacity": -3}, stack_blend="vaudou"))["defaults"]
assert "inexistante" not in data["order"] and "inexistante" not in data["on"]
# Les couches présentes mais non citées complètent l'ordre
assert set(data["order"]) == {"aspect", "slope"}
assert data["opacity"]["aspect"] == 1.0 # borné à 1
assert data["opacity"]["slope"] == 1.0 # valeur illisible → opaque
assert data["blend"] == {"slope": "screen"} # mode inconnu écarté
main="inexistante", mode="vaudou", precision_opacity=5,
base={"opacity": -3}))["defaults"]
assert data["main"] is None
assert data["mode"] == "relief"
assert data["precision_opacity"] == 1.0 # borné à 1
assert data["base"]["opacity"] == 0.0 # borné à 0
assert data["stack_blend"] == "normal" # repli
data = mapserve.set_defaults(mapserve.DefaultsRequest(main="densite_sol"))["defaults"]
assert data["main"] is None # la précision n'est pas une principale
def test_ui_applies_server_defaults():
@ -387,38 +377,13 @@ def test_ui_applies_server_defaults():
from lidar_pipeline.mapui import _MAP_HTML, _MAP_JS
assert 'id="btnDefault"' in _MAP_HTML and 'id="btnReset"' in _MAP_HTML
assert "api/map/defaults" in _MAP_JS
assert "meta.default_order" in _MAP_JS
assert "meta.default_main" in _MAP_JS and "meta.default_mode" in _MAP_JS
assert "meta.default_base" in _MAP_JS
assert "validBlend(meta.default_stack_blend)" in _MAP_JS
assert "meta.default_precision_opacity" in _MAP_JS
# Réinitialiser oublie l'état local avant de reprendre celui du serveur
assert "localStorage.removeItem(LS_KEY)" in _MAP_JS
def test_ui_reorder_shows_destination():
"""Réorganiser doit être lisible : repère de chute, fantôme, arrivée signalée.
Sans repère, on lâche une couche sans savoir où elle atterrit — la pile
n'est pas un détail, elle décide de ce qu'on voit.
"""
from lidar_pipeline.mapui import _MAP_CSS, _MAP_HTML, _MAP_JS
# Barre d'insertion selon la moitié survolée, effacée entre deux survols
assert ".layer-row.drop-before" in _MAP_CSS and ".layer-row.drop-after" in _MAP_CSS
assert "clearDropMarks" in _MAP_JS
assert "box.height / 2" in _MAP_JS
assert "row.classList.add(before ? 'drop-before' : 'drop-after')" in _MAP_JS
# Ligne déplacée estompée pendant le glisser
assert ".layer-row.dragging" in _MAP_CSS and "classList.add('dragging')" in _MAP_JS
# Insertion relative (avant/après la ligne visée), pas un simple échange
assert "function reorderRelative(" in _MAP_JS
# Arrivée mise en évidence puis ramenée dans le champ de vision
assert "just-moved" in _MAP_CSS and "@keyframes moved" in _MAP_CSS
assert "scrollIntoView" in _MAP_JS
assert "renderPanel(key)" in _MAP_JS and "renderPanel(src)" in _MAP_JS
# Extrémités de la pile nommées
assert 'id="edgeTop"' in _MAP_HTML and 'id="edgeBottom"' in _MAP_HTML
assert "Haut de pile" in _MAP_HTML and "Bas de pile" in _MAP_HTML
def test_ui_uses_standard_tilelayer():
"""L'interface s'appuie sur le LOD natif de Leaflet, pas sur un palier maison."""
from lidar_pipeline.mapui import _MAP_JS, render_html, ui_version