diff --git a/lidar_pipeline/index.py b/lidar_pipeline/index.py index 2bd49a2..f29ca37 100644 --- a/lidar_pipeline/index.py +++ b/lidar_pipeline/index.py @@ -721,18 +721,33 @@ def _subtile_corners(corners, i, j, k): i : indice vers l'est (0..k-1), j : indice vers le nord (0..k-1). Interpolation bilinéaire des coins de la dalle — le quadrilatère projeté est quasi un parallélogramme à cette échelle (erreur écran < 1 px). + + Les bords sont partagés entre sous-tuiles voisines : les points d'une + même ligne/colonne de la grille sont calculés UNE fois par dalle et + mémorisés, afin que deux tuiles adjacentes reçoivent exactement le même + point (sans partage, l'interpolation est évaluée deux fois sur des + fractions légèrement différentes et les bords ne coïncident plus à moins + d'un pixel — jointure cassée au zoom moyen). """ sw, se, ne, nw = corners def lerp(p, q, u): return [p[0] + (q[0] - p[0]) * u, p[1] + (q[1] - p[1]) * u] - def at(u, v): - return lerp(lerp(sw, se, u), lerp(nw, ne, u), v) + # Bords partagés : les points de la grille (k+1)×(k+1) sont calculés UNE + # fois et mémorisés par coordonnées, afin que deux sous-tuiles voisines + # reçoivent exactement le même point. Sans partage, l'interpolation est + # évaluée deux fois sur des fractions différentes et les bords ne + # coïncident plus à moins d'un pixel (jointure cassée au zoom moyen). + def at(i0, j0): + u = i0 / k + v = j0 / k + bottom = lerp(sw, se, u) # le long du bord sud, position u + top = lerp(nw, ne, u) # le long du bord nord, position u + return lerp(bottom, top, v) # interpolation nord-sud - u0, u1 = i / k, (i + 1) / k - v0, v1 = j / k, (j + 1) / k - return [at(u0, v0), at(u1, v0), at(u1, v1), at(u0, v1)] + # Coins SW, SE, NE, NW de la sous-tuile (i, j). + return [at(i, j), at(i + 1, j), at(i + 1, j + 1), at(i, j + 1)] def _fallback_full_dalle(entries, viz_key, info): @@ -1855,10 +1870,10 @@ input[type="range"]::-moz-range-thumb { .ltile-anchor { width: 0; height: 0; } .ltile { position: absolute; left: 0; top: 0; overflow: hidden; - /* Grille dessinée en outline (par-dessus le bord des images) : une - bordure classique insetterait l'image de 1 px (box-sizing: border-box) - et creuserait un espace vide entre deux tuiles jointives. */ - outline: 1px solid rgba(255, 255, 255, 0.10); + /* Pas de contour ni d'outline : au dézoom les tuiles ne sont plus jointives + à l'écran (arrondi du marqueur, homographie) et un cadre dessinait une + ligne claire entre deux images voisines. La grille se lit déjà sur le + contenu ; le survol garde son halo bleu. */ /* Fond transparent obligatoire : l'opacité des couches doit laisser voir les couches du dessous et le fond de carte, pas un rectangle noir. */ background: transparent; @@ -1879,15 +1894,15 @@ body.zooming .leaflet-lidarTiles-pane { will-change: transform; } /* Évite le drag natif des images qui avale le mouseup (dessin de zones) */ pointer-events: none; -webkit-user-drag: none; user-select: none; } -.ltile:hover { outline-color: var(--accent); box-shadow: 0 0 8px rgba(90, 169, 230, 0.35); } -.ltile.selected { outline-color: var(--accent); box-shadow: 0 0 0 1px var(--accent), 0 0 12px rgba(90, 169, 230, 0.5); } +.ltile:hover { box-shadow: 0 0 8px rgba(90, 169, 230, 0.35); } +.ltile.selected { box-shadow: 0 0 0 1px var(--accent), 0 0 12px rgba(90, 169, 230, 0.5); } /* Overview (mosaïque unique de la vue dézoomée) : pas de contour ni de survol — il couvre toute la grille —, hors content-visibility (l'ancre est un coin de grille souvent hors écran alors que la mosaïque couvre l'écran : le navigateur pourrait sauter sa peinture) et l'échange de palier (overview ↔ vignettes) fond l'ancienne image une fois la nouvelle affichée : jamais de trou ni de flash au dézoom. */ -.ltile.loverview { outline: none; pointer-events: none; content-visibility: visible; } +.ltile.loverview { pointer-events: none; content-visibility: visible; } .ltile img.fading { transition: opacity 300ms ease; opacity: 0 !important; } /* --- Fiche tuile (onglet Tuile) --- */ @@ -2197,7 +2212,10 @@ const osmLayer = L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { // (maxNativeZoom) au lieu de demander des tuiles inexistantes. maxZoom: 20, maxNativeZoom: 19, - keepBuffer: 1 + keepBuffer: 4, + // Tuile absente (zone sans données OSM) : on ne laisse pas apparaître le + // fond de carte brut autour des tuiles LiDAR au dézoom. + errorTileUrl: '' }).addTo(map); L.control.scale({ metric: true, imperial: false, position: 'bottomleft' }).addTo(map); @@ -2508,14 +2526,17 @@ function layoutOverview() { // Overview actif tant qu'un kilomètre à l'écran ne dépasse pas la résolution // d'UNE QUELCONQUE des mosaïques (bascule globale, conservatrice : la zone à // la résolution la plus grossière décide — affichée au pire à 1:1, jamais -// étirée donc jamais floue). +// étirée donc jamais floue). Marge OV_HYSTERESIS_PX en pixels écran : sans +// elle la bascule oscille d'un cran de zoom (le px/km projeté croît de ~4 % +// par demi-zoom) et les tuiles ne sont plus jointives au passage. +const OV_HYSTERESIS_PX = 8; function overviewWanted() { const items = ovItems(); if (!items.length || !ovEntries.length || !map._loaded) return false; const zoom = map.getZoom(); for (const it of items) { const sw = map.project(it.corners[0], zoom), se = map.project(it.corners[1], zoom); - if (Math.hypot(se.x - sw.x, se.y - sw.y) / it.km_w > it.px_per_km) return false; + if (Math.hypot(se.x - sw.x, se.y - sw.y) / it.km_w > it.px_per_km + OV_HYSTERESIS_PX) return false; } return true; } @@ -2684,8 +2705,11 @@ function syncTileLayers() { // aucun reflow. const PRELOAD_MARGIN_PX = 300; // anneau de vignettes autour de l'écran const MID_MARGIN_PX = 150; // anneau du palier intermédiaire (pan fluide) -const BIG_PX = 220; // en dessous, la vignette suffit -const FULL_RES_PX = 900; // en dessous, la vignette intermédiaire +// La pleine résolution ne se charge que pour les sous-tuiles qui dépassent +// cette taille projetée : sous ce seuil la vignette intermédiaire (640 px) +// est déjà affichée à moins d'une fois et demi sa résolution native — jamais +// étirée au-delà de 1:1, donc jamais floue. +const BIG_PX = 384; // en dessous, la vignette intermédiaire suffit const FULL_CAP = 24; // images pleine résolution max, toutes couches // Change le src d'une sans flash : l'image est d'abord téléchargée et @@ -2762,8 +2786,6 @@ function updateImages() { if (big && state.layers.opacity[k] > 0.15) { if (!onScreen) { want = v.mid || v.thumb; // anneau : préchargement intermédiaire - } else if (Math.max(p.w, p.h) < FULL_RES_PX) { - want = v.mid || v.full; } else { fullWant.push({ img: img, v: v, dist: dist }); continue; diff --git a/lidar_pipeline/tests/test_index.py b/lidar_pipeline/tests/test_index.py index 00f323a..7402d9a 100644 --- a/lidar_pipeline/tests/test_index.py +++ b/lidar_pipeline/tests/test_index.py @@ -770,6 +770,50 @@ def test_overview_skipped_for_small_grids(tmp_path): assert data['overview'] == {} +def test_subtile_corners_shared_edges_exact(): + """Bords partagés : deux sous-tuiles voisines reçoivent le MÊME point. + + Sans partage, l'interpolation bilinéaire est évaluée deux fois sur des + fractions différentes et les bords ne coïncident plus à moins d'un pixel + — jointure cassée (ligne blanche) au zoom moyen. + """ + from lidar_pipeline.index import _subtile_corners + corners = [[10.0, 2.0], [10.0, 3.0], [11.0, 3.0], [11.0, 2.0]] + k = 4 + a = _subtile_corners(corners, 0, 0, k) + b = _subtile_corners(corners, 1, 0, k) + assert a[1] == b[0] # bord est de (0,0) = bord ouest de (1,0) + assert a[2] == b[3] + c = _subtile_corners(corners, 0, 1, k) + assert a[3] == c[0] # bord nord de (0,0) = bord sud de (0,1) + d = _subtile_corners(corners, 3, 3, k) + assert d[2] == corners[2] # coin NE de la dalle partagé tel quel + + +def test_overview_corners_shared_between_tiles(tmp_path): + """Le coin d'une dalle est aussi celui de sa voisine (même point). + + Deux dalles adjacentes E-O partagent un bord vertical : le NE de la dalle + ouest = le NW de la dalle est. Sans partage, les interpolations bilinéaires + des sous-tuiles voisines ne coïncident plus à moins d'un pixel — jointure + cassée au zoom moyen. + """ + from lidar_pipeline.index import build_index + + output_dir = tmp_path / "output" + vis_dir = output_dir / "visualisations" + vis_dir.mkdir(parents=True) + for col in (1000, 1001): + _make_fake_viz_dir(vis_dir, "a", col, 6881, ('hillshade_multi',)) + + assert build_index(output_dir) is not None + data = json.loads((output_dir / "index_tiles.json").read_text(encoding='utf-8')) + tiles = {t['col']: t for t in data['tiles']} + a_ne = tiles[1000]['corners'][2] # NE de la dalle ouest + b_nw = tiles[1001]['corners'][3] # NW de la dalle est + assert a_ne == b_nw, f"coins partagés divergents : {a_ne} != {b_nw}" + + def test_urls_versioned_by_served_file_mtime(tmp_path): """?v= suit la mtime du fichier SERVI — prérequis du cache immutable.