Skip to main content

navcore_route_find/
land_cache.rs

1//! A precomputed, ahead-of-time cache of a chart's land geometry, and a
2//! raster derived from it, so a route query does not have to fetch,
3//! reconcile and simplify `LNDARE` polygons from the chart before
4//! testing anything against them.
5//!
6//! Land is the one hazard class worth this: [`crate::visibility::dijkstra`]
7//! tests a candidate edge against it up to [`crate::visibility::MAX_VERTICES`]
8//! squared times, and those [`crate::hazards::Hazards::intersects_band`]
9//! calls -- each a [`geo::Intersects`] test against a simplified but
10//! still real coastline -- are the dominant cost of a hard case's
11//! wall-clock time. [`crate::hazards::Hazards::with_simplified_land`]'s
12//! per-lattice-node test pays the same real-geometry cost on every route
13//! this crate finds, not only the visibility-graph fallback's hard cases.
14//!
15//! A raster answers the same question -- does this query footprint touch
16//! land -- in time proportional to the footprint's size, not to how many
17//! points the charted coastline is sampled at. Restricted areas and
18//! depth areas are not cached here: both are comparatively few and
19//! cheap, and depth is already dropped from the hot screening path (see
20//! [`crate::hazards::Hazards::coarsened_for_screening`]'s own doc).
21//!
22//! Written once, ahead of time, by [`bake`] (driven by `enc-check bake`);
23//! read lazily and cached for the life of the process by [`load`]. A
24//! missing or stale cache is never a correctness risk, only a speed one:
25//! every caller in [`crate::hazards`] falls back to the live path this
26//! module exists to avoid paying for on every query.
27//!
28//! A cache built from over-simplified geometry would itself be a
29//! correctness risk: a small islet simplified down to nothing by
30//! [`crate::hazards::SCREENING_SIMPLIFY_DEG`]'s tolerance would never
31//! reach the raster, and a query small enough for [`LandCache::covers`]
32//! to trust the cache would read open water where `check_leg`, reading
33//! the chart directly, still finds land. [`bake`] rasterizes from the
34//! unsimplified parts instead, kept apart from the
35//! [`simplify_land`]-simplified ones [`LandCache::land_geometry`] still
36//! stores for the coarse lattice/visibility use that tolerance is for --
37//! see that function's own doc for the full reasoning.
38
39use std::fs;
40use std::path::{Path, PathBuf};
41
42use geo::{BoundingRect, Coord, Geometry, MultiPolygon, Polygon, Rect};
43use rusqlite::Connection;
44use wkt::{ToWkt, TryFromWkt};
45
46use enc_store::{ChartStore, Feature, StoreError};
47
48use crate::hazards::{fetch_preferring_detail, simplify_land};
49
50/// The S-57 class this module caches. Mirrors `hazards::ALWAYS_UNSAFE`'s
51/// one entry -- not imported from there, the same "answers a different
52/// question, stayed simple enough to duplicate" reasoning this crate
53/// already applies to its own small `polygon_parts` helpers.
54const LAND_CLASS: &str = "LNDARE";
55
56/// The S-57 class marking a chart's own real, surveyed coverage -- the
57/// authoritative source [`detect_coverage_area`] uses for "what area is
58/// this chart actually for", so nobody baking a cache has to name a
59/// region by hand and keep it in sync with whatever a deployment's own
60/// chart-ingest pipeline adds over time.
61const COVERAGE_CLASS: &str = "M_COVR";
62
63/// How far apart two [`COVERAGE_CLASS`] features' centres may be, in
64/// degrees, and still count as the same region in
65/// [`detect_coverage_area`]'s clustering: generous enough to bridge an
66/// ordinary gap between adjacent chart cells or between separately
67/// surveyed stretches of the same coastline, tight enough that a
68/// genuinely different part of the world stays its own cluster. Some
69/// charts carry `M_COVR` rows far outside their real coverage area,
70/// background rows a broader chart product carries alongside the region
71/// it was actually produced for.
72const CLUSTER_DISTANCE_DEG: f64 = 3.0;
73
74/// The raster's own cell size, in degrees -- finer than
75/// [`crate::hazards::SCREENING_SIMPLIFY_DEG`]'s 0.005 degrees, so no
76/// coastline detail the simplified geometry still carries is lost to a
77/// coarser grid on top of it. A starting point, not a settled constant --
78/// tune against a real chart's own bake time and cache size the same way
79/// [`crate::visibility::MAX_VERTICES`] was tuned, by measurement rather
80/// than guesswork, if either turns out to matter in practice.
81const CELL_DEG: f64 = 0.0025;
82
83/// How far beyond the fetched land geometry's own bounding box the raster
84/// extends, in degrees -- headroom for a query footprint that grazes the
85/// chart's own edge without every such query falling outside the raster
86/// and silently reading as clear water for the wrong reason.
87const RASTER_PADDING_DEG: f64 = 0.1;
88
89/// The cache format's own version, bumped whenever [`bake`]'s output or
90/// [`load`]'s expectations of it change shape -- a version mismatch is
91/// read the same as a missing file, not an error.
92const FORMAT_VERSION: i64 = 1;
93
94/// Everything that can go wrong baking a chart's land cache.
95#[derive(Debug)]
96pub enum LandCacheError {
97    /// Reading the source chart failed.
98    Store(StoreError),
99    /// Writing or reading the cache file itself failed.
100    Database(rusqlite::Error),
101    /// The source chart's own file metadata could not be read.
102    Io(std::io::Error),
103    /// The chart carries no `COVERAGE_CLASS` features at all, so
104    /// `detect_coverage_area` has nothing to detect a region from.
105    NoCoverage,
106}
107
108impl std::fmt::Display for LandCacheError {
109    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110        match self {
111            Self::Store(error) => write!(f, "chart: {error}"),
112            Self::Database(error) => write!(f, "land cache: {error}"),
113            Self::Io(error) => write!(f, "chart file: {error}"),
114            Self::NoCoverage => write!(f, "chart carries no M_COVR coverage features to detect a bake region from"),
115        }
116    }
117}
118
119impl std::error::Error for LandCacheError {}
120
121impl From<StoreError> for LandCacheError {
122    fn from(error: StoreError) -> Self {
123        Self::Store(error)
124    }
125}
126
127impl From<rusqlite::Error> for LandCacheError {
128    fn from(error: rusqlite::Error) -> Self {
129        Self::Database(error)
130    }
131}
132
133impl From<std::io::Error> for LandCacheError {
134    fn from(error: std::io::Error) -> Self {
135        Self::Io(error)
136    }
137}
138
139/// The sidecar path [`bake`] writes to and [`load`] reads from for a chart
140/// opened from `chart_path` -- appended to the chart's own name rather
141/// than replacing its extension, so it is unambiguous which chart file a
142/// cache belongs to even if a directory holds charts that differ only in
143/// extension.
144fn cache_path(chart_path: &Path) -> PathBuf {
145    let mut name = chart_path.as_os_str().to_owned();
146    name.push(".hazcache");
147    PathBuf::from(name)
148}
149
150/// A fingerprint of a chart file cheap enough to check on every
151/// [`load`] -- not a content hash, just enough to catch "this chart was
152/// replaced since the cache was baked" without reading the whole file
153/// again to confirm it.
154fn fingerprint(chart_path: &Path) -> Result<(u64, i64), std::io::Error> {
155    let metadata = fs::metadata(chart_path)?;
156    let modified = metadata
157        .modified()?
158        .duration_since(std::time::UNIX_EPOCH)
159        .map(|duration| duration.as_secs() as i64)
160        .unwrap_or(0);
161    Ok((metadata.len(), modified))
162}
163
164/// A precomputed land raster and the simplified geometry it was rasterized
165/// from, loaded once and kept for the life of the process -- see
166/// [`crate::hazards`]'s process-wide cache of this type.
167pub(crate) struct LandCache {
168    origin: Coord<f64>,
169    cell_deg: f64,
170    cols: usize,
171    rows: usize,
172    raster: Vec<bool>,
173    land_geometry: Geometry<f64>,
174}
175
176impl LandCache {
177    /// The simplified land geometry this cache was built from -- what
178    /// [`crate::hazards::Hazards::land`] hands back when a cache is
179    /// loaded, identical in shape to what
180    /// [`crate::hazards::Hazards::with_simplified_land`] computes live,
181    /// just precomputed. Always a [`Geometry::MultiPolygon`], to match
182    /// the type every other land entry in that iterator already is.
183    pub(crate) fn land_geometry(&self) -> &Geometry<f64> {
184        &self.land_geometry
185    }
186
187    /// Whether `area` falls entirely within this cache's own baked
188    /// extent.
189    ///
190    /// A chart is one file, but nothing says every query against it
191    /// stays inside whatever region it was baked for: a chart can carry
192    /// real, detailed coastline far from the area actually baked.
193    /// Trusting the cache there regardless would not just be slow:
194    /// [`Self::cell_of`] reads "outside the raster" as "no land here",
195    /// the same conclusion an empty live fetch would reach, so real
196    /// charted coastline outside the baked extent would go completely
197    /// unreported rather than merely uncached. [`Hazards::fetch`] checks
198    /// this before trusting a loaded cache for a given query; a query
199    /// that fails it falls back to the live fetch, the same fallback a
200    /// missing or stale cache takes.
201    pub(crate) fn covers(&self, area: Rect<f64>) -> bool {
202        #[allow(clippy::cast_precision_loss)]
203        let (max_x, max_y) = (self.origin.x + self.cell_deg * self.cols as f64, self.origin.y + self.cell_deg * self.rows as f64);
204        area.min().x >= self.origin.x
205            && area.min().y >= self.origin.y
206            && area.max().x <= max_x
207            && area.max().y <= max_y
208    }
209
210    /// The raster cell `coord` falls in, or `None` outside the raster's
211    /// own coverage -- which [`Self::blocked`] reads as "no land the
212    /// chart this cache was baked from ever reported here", the same
213    /// conclusion an empty fetch would already reach live.
214    fn cell_of(&self, coord: Coord<f64>) -> Option<(usize, usize)> {
215        let col = ((coord.x - self.origin.x) / self.cell_deg).floor();
216        let row = ((coord.y - self.origin.y) / self.cell_deg).floor();
217        if col < 0.0 || row < 0.0 {
218            return None;
219        }
220        let (col, row) = (col as usize, row as usize);
221        (col < self.cols && row < self.rows).then_some((row, col))
222    }
223
224    fn cell_blocked(&self, row: usize, col: usize) -> bool {
225        self.raster[row * self.cols + col]
226    }
227
228    /// Whether `polygon` -- a query footprint standing in for the water a
229    /// candidate step or leg needs clear, the same role
230    /// [`crate::hazards::Hazards::intersects_band`]'s own `band` plays --
231    /// touches a raster cell marked as land.
232    ///
233    /// Samples the polygon's exterior ring at [`CELL_DEG`]-spaced points
234    /// along every edge rather than rasterizing its full interior: exact
235    /// for a shape no wider than a handful of cells (a vessel's corridor
236    /// is rarely more than a couple of tenths of a nautical mile either
237    /// side of the line it is testing), and for a wider one this can in
238    /// principle miss a small obstacle entirely inside the footprint.
239    /// That gap is no wider than [`crate::hazards::SCREENING_SIMPLIFY_DEG`]'s
240    /// own simplification tolerance for the same coarse-screening
241    /// purpose, and this cache is never the last word regardless:
242    /// [`crate::smooth::straighten`]'s `check_leg` calls read the
243    /// unsimplified chart directly and catch whatever this test misses.
244    #[must_use]
245    pub(crate) fn blocked(&self, polygon: &Polygon<f64>) -> bool {
246        let exterior = polygon.exterior();
247        let vertices = exterior.0.as_slice();
248        if vertices.is_empty() {
249            return false;
250        }
251        if self.point_blocked(vertices[0]) {
252            return true;
253        }
254        vertices.windows(2).any(|edge| self.segment_blocked(edge[0], edge[1]))
255    }
256
257    fn point_blocked(&self, point: Coord<f64>) -> bool {
258        self.cell_of(point).is_some_and(|(row, col)| self.cell_blocked(row, col))
259    }
260
261    fn segment_blocked(&self, start: Coord<f64>, end: Coord<f64>) -> bool {
262        let length = ((end.x - start.x).powi(2) + (end.y - start.y).powi(2)).sqrt();
263        let steps = (length / self.cell_deg).ceil().max(1.0) as usize;
264        (1..=steps).any(|step| {
265            let t = f64::from(u32::try_from(step).unwrap_or(u32::MAX)) / f64::from(u32::try_from(steps).unwrap_or(1));
266            let point = Coord { x: start.x + (end.x - start.x) * t, y: start.y + (end.y - start.y) * t };
267            self.point_blocked(point)
268        })
269    }
270}
271
272/// The individual polygon parts inside `geometry` -- see
273/// `hazards::polygon_parts`'s own doc for why this crate duplicates
274/// rather than shares this helper across modules.
275fn polygon_parts(geometry: &Geometry<f64>) -> Vec<Polygon<f64>> {
276    match geometry {
277        Geometry::Polygon(polygon) => vec![polygon.clone()],
278        Geometry::MultiPolygon(multi) => multi.iter().cloned().collect(),
279        _ => Vec::new(),
280    }
281}
282
283/// Marks every raster cell `part` covers as land, in `bits` (row-major,
284/// `cols` wide).
285///
286/// A proper scanline fill, not "test every cell in the bounding box
287/// against the polygon": a single large, many-vertex coastline's own
288/// bounding box alone can hold hundreds of thousands of cells, and
289/// testing each one against the polygon directly would pay a full
290/// polygon-intersects test whose own cost scales with the ring's vertex
291/// count -- the same "cost scales with coastline complexity" problem
292/// this whole cache exists to get away from, just moved from query time
293/// to bake time. Walking one horizontal line per raster row
294/// instead -- intersecting it against every ring edge once, even-odd
295/// filling the spans between crossings -- costs rows times edges, not
296/// cells times edges; `cols` drops out of the cost entirely.
297fn rasterize_part(part: &Polygon<f64>, origin: Coord<f64>, cell_deg: f64, cols: usize, rows: usize, bits: &mut [bool]) {
298    let Some(bbox) = part.bounding_rect() else { return };
299    #[allow(clippy::cast_precision_loss)]
300    let (raster_max_x, raster_max_y) = (origin.x + cell_deg * cols as f64, origin.y + cell_deg * rows as f64);
301    // A part whose own bounding box does not touch the raster at all --
302    // possible even with an area-scoped fetch, since a feature is
303    // returned whenever its box merely overlaps the query area, not only
304    // when it sits fully inside it. Left unguarded, the clamped indices
305    // below would fold such a part onto the raster's own edge row/column
306    // instead of skipping it, marking cells as land that are nowhere
307    // near it.
308    if bbox.max().x < origin.x || bbox.min().x > raster_max_x || bbox.max().y < origin.y || bbox.min().y > raster_max_y {
309        return;
310    }
311    let min_row = (((bbox.min().y - origin.y) / cell_deg).floor().max(0.0) as usize).min(rows.saturating_sub(1));
312    let max_row = (((bbox.max().y - origin.y) / cell_deg).ceil() as usize).min(rows.saturating_sub(1));
313
314    let rings: Vec<&geo::LineString<f64>> = std::iter::once(part.exterior()).chain(part.interiors()).collect();
315
316    for row in min_row..=max_row {
317        #[allow(clippy::cast_precision_loss)]
318        let y = origin.y + (row as f64 + 0.5) * cell_deg;
319        let mut crossings: Vec<f64> = rings
320            .iter()
321            .flat_map(|ring| ring.lines())
322            .filter_map(|edge| {
323                let (y0, y1) = (edge.start.y, edge.end.y);
324                // The standard half-open scanline test: an edge crosses
325                // the line at `y` once, not zero or two times, even when
326                // `y` passes exactly through a shared vertex between two
327                // consecutive edges.
328                let crosses = (y0 <= y) != (y1 <= y);
329                crosses.then(|| edge.start.x + (y - y0) / (y1 - y0) * (edge.end.x - edge.start.x))
330            })
331            .collect();
332        crossings.sort_by(f64::total_cmp);
333
334        for span in crossings.chunks_exact(2) {
335            let min_col = (((span[0] - origin.x) / cell_deg).floor().max(0.0) as usize).min(cols.saturating_sub(1));
336            let max_col = (((span[1] - origin.x) / cell_deg).ceil() as usize).min(cols.saturating_sub(1));
337            for col in min_col..=max_col {
338                bits[row * cols + col] = true;
339            }
340        }
341    }
342}
343
344/// The real geographic extent `chart` provides coverage for, detected
345/// from its own [`COVERAGE_CLASS`] features rather than named by a
346/// caller.
347///
348/// Not simply the bounding box of every [`COVERAGE_CLASS`] feature in
349/// the file: as [`CLUSTER_DISTANCE_DEG`]'s own doc explains, a chart can
350/// carry a handful of such rows unrelated to the rest of the file, and a
351/// plain bounding box is not robust to even one such outlier. Clustering
352/// by proximity first, then keeping only the largest cluster, is robust:
353/// a chart's real coverage is one geographically contiguous region, and
354/// a background row elsewhere on the planet cannot join that cluster as
355/// long as it stays farther than [`CLUSTER_DISTANCE_DEG`] from it. This
356/// also makes baking automatic rather than something a caller names by
357/// hand: a deployment that adds another country's charts to its ingest
358/// pipeline grows this cluster along with it, nothing here to update.
359///
360/// # Errors
361///
362/// If the chart cannot be read, or carries no [`COVERAGE_CLASS`]
363/// features at all.
364fn detect_coverage_area(chart: &ChartStore) -> Result<Rect<f64>, LandCacheError> {
365    let world = Rect::new(Coord { x: -180.0, y: -90.0 }, Coord { x: 180.0, y: 90.0 });
366    let features = chart.features_in(COVERAGE_CLASS, world)?;
367    let boxes: Vec<Rect<f64>> = features.iter().filter_map(|feature| feature.geometry.bounding_rect()).collect();
368    largest_cluster_bbox(&boxes).ok_or(LandCacheError::NoCoverage)
369}
370
371/// The combined bounding box of the largest cluster among `boxes`,
372/// where two boxes join the same cluster whenever their own centres sit
373/// within [`CLUSTER_DISTANCE_DEG`] of each other -- transitively, so an
374/// elongated coastline's own boxes all end up in one cluster even
375/// though its own two ends may be much farther apart than the threshold
376/// itself. `None` only for an empty `boxes`.
377fn largest_cluster_bbox(boxes: &[Rect<f64>]) -> Option<Rect<f64>> {
378    if boxes.is_empty() {
379        return None;
380    }
381    let centres: Vec<Coord<f64>> = boxes.iter().map(|bbox| bbox.center()).collect();
382
383    let mut parent: Vec<usize> = (0..centres.len()).collect();
384    for i in 0..centres.len() {
385        for j in (i + 1)..centres.len() {
386            let (a, b) = (centres[i], centres[j]);
387            let close = (a.x - b.x).abs() <= CLUSTER_DISTANCE_DEG && (a.y - b.y).abs() <= CLUSTER_DISTANCE_DEG;
388            if close {
389                union(&mut parent, i, j);
390            }
391        }
392    }
393
394    let mut cluster_size: std::collections::HashMap<usize, usize> = std::collections::HashMap::new();
395    for i in 0..centres.len() {
396        *cluster_size.entry(find(&mut parent, i)).or_insert(0) += 1;
397    }
398    let largest_root = cluster_size
399        .into_iter()
400        .max_by_key(|&(_, count)| count)
401        .map(|(root, _)| root)
402        .expect("boxes is non-empty, so at least one cluster exists");
403
404    let mut area: Option<Rect<f64>> = None;
405    for (index, &bbox) in boxes.iter().enumerate() {
406        if find(&mut parent, index) != largest_root {
407            continue;
408        }
409        area = Some(area.map_or(bbox, |existing| union_rect(existing, bbox)));
410    }
411    area
412}
413
414/// The union-find root of `x`, with path compression.
415fn find(parent: &mut [usize], x: usize) -> usize {
416    if parent[x] != x {
417        parent[x] = find(parent, parent[x]);
418    }
419    parent[x]
420}
421
422/// Joins the clusters `a` and `b` belong to into one.
423fn union(parent: &mut [usize], a: usize, b: usize) {
424    let (root_a, root_b) = (find(parent, a), find(parent, b));
425    if root_a != root_b {
426        parent[root_a] = root_b;
427    }
428}
429
430/// The smallest rectangle containing both `a` and `b`.
431fn union_rect(a: Rect<f64>, b: Rect<f64>) -> Rect<f64> {
432    Rect::new(
433        Coord { x: a.min().x.min(b.min().x), y: a.min().y.min(b.min().y) },
434        Coord { x: a.max().x.max(b.max().x), y: a.max().y.max(b.max().y) },
435    )
436}
437
438/// How large each tile [`fetch_land_tiled`] reconciles `LNDARE`
439/// overview coverage against is, in degrees.
440///
441/// [`detect_coverage_area`] can find a region far larger than any real
442/// route's own local query, up to a whole sea basin.
443/// `fetch_preferring_detail` only counts an overview feature as covered
444/// by detail within the area it was asked about -- correct for a live
445/// query at a route's local scale (see that function's own doc for why
446/// dropping overview coverage outright is wrong), but not once that area
447/// spans a whole sea: detail on one side then does nothing to trim an
448/// overview feature's uncovered remainder on the other, and
449/// [`crate::visibility::MAX_OBSTACLE_SPAN_DEG`]'s own doc names what a
450/// hull that size does to a route search that finds it. Reconciling one
451/// small tile at a time, rather than the whole detected region in one
452/// call, keeps every reconciliation as local as a real route's query
453/// area already is.
454const BAKE_TILE_DEG: f64 = 2.0;
455
456/// Every `LNDARE` feature [`fetch_preferring_detail`] returns across
457/// `area`, tiled at [`BAKE_TILE_DEG`] rather than fetched in one call --
458/// see that constant's own doc for why. A feature straddling a tile
459/// boundary is fetched, reconciled and simplified once per tile it
460/// overlaps rather than once overall; harmless here, since every
461/// caller only ever folds the result into a simplified land shape or a
462/// raster, and rasterizing (or re-simplifying) the same small overlap
463/// twice changes nothing about either.
464///
465/// # Errors
466///
467/// If the chart cannot be read.
468fn fetch_land_tiled(chart: &ChartStore, area: Rect<f64>) -> Result<Vec<Feature>, LandCacheError> {
469    let mut features = Vec::new();
470    let mut min_y = area.min().y;
471    while min_y < area.max().y {
472        let max_y = (min_y + BAKE_TILE_DEG).min(area.max().y);
473        let mut min_x = area.min().x;
474        while min_x < area.max().x {
475            let max_x = (min_x + BAKE_TILE_DEG).min(area.max().x);
476            let tile = Rect::new(Coord { x: min_x, y: min_y }, Coord { x: max_x, y: max_y });
477            features.extend(fetch_preferring_detail(chart, tile, LAND_CLASS)?);
478            min_x += BAKE_TILE_DEG;
479        }
480        min_y += BAKE_TILE_DEG;
481    }
482    Ok(features)
483}
484
485/// Bakes a land cache for `chart`, covering whatever area
486/// `detect_coverage_area` finds plus `RASTER_PADDING_DEG`, and
487/// writes it to `<chart.path()>.hazcache` (or `out`, if given) -- run
488/// once, ahead of time, by `enc-check bake`, never at route-query time.
489///
490/// The region is detected from the chart itself, not named by a caller:
491/// see `detect_coverage_area`'s own doc for why a caller-supplied area
492/// would either have to be kept in sync by hand with whatever a
493/// deployment's own chart-ingest pipeline adds to the file over time, or
494/// risk baking a region that quietly stops matching what the chart
495/// actually covers.
496///
497/// # Errors
498///
499/// If the chart cannot be read, carries no coverage to detect a region
500/// from, its own file metadata cannot be read, or the cache file cannot
501/// be written.
502pub fn bake(chart: &ChartStore, out: Option<&Path>) -> Result<PathBuf, LandCacheError> {
503    let target = out.map_or_else(|| cache_path(chart.path()), Path::to_path_buf);
504    let (source_size, source_mtime) = fingerprint(chart.path())?;
505
506    let area = detect_coverage_area(chart)?;
507    let features = fetch_land_tiled(chart, area)?;
508
509    // The raster is rasterized from the *un*simplified parts, deliberately
510    // -- it stands in for `Hazards::blocked`'s own exact-position test
511    // (see that method's own doc: "the one place in the search that has
512    // to answer for the exact position rather than the lattice's
513    // approximation of it"), and `simplify_land`'s tolerance
514    // (`SCREENING_SIMPLIFY_DEG`, roughly 0.3 NM) is coarse enough to
515    // collapse a small real islet to nothing. Found, not assumed: a real
516    // one in the Marano-Grado lagoon vanishes from a simplified raster
517    // this way, and `find_gateway` accepts a point sitting on it as
518    // clear water -- confirmed against the chart with `check_leg`,
519    // which reads geometry this method never touches and catches the
520    // same islet without trouble. `land_geometry`
521    // stays simplified below -- it feeds `with_simplified_land`'s own
522    // coarse lattice-node test and `visibility`'s screening graph, both
523    // of which want exactly this tolerance, not the raster's.
524    let raw_parts: Vec<Polygon<f64>> = features.iter().flat_map(|feature| polygon_parts(&feature.geometry)).collect();
525
526    let simplified: Vec<Geometry<f64>> = features.iter().map(|feature| simplify_land(&feature.geometry)).collect();
527    let simplified_parts: Vec<Polygon<f64>> = simplified.iter().flat_map(polygon_parts).collect();
528    let land_geometry = MultiPolygon::new(simplified_parts);
529
530    let (origin, cols, rows) = raster_extent(area);
531    let mut bits = vec![false; cols * rows];
532    for part in &raw_parts {
533        rasterize_part(part, origin, CELL_DEG, cols, rows, &mut bits);
534    }
535
536    if let Some(parent) = target.parent() {
537        if !parent.as_os_str().is_empty() {
538            fs::create_dir_all(parent)?;
539        }
540    }
541    let _ = fs::remove_file(&target);
542    let connection = Connection::open(&target)?;
543    connection.execute_batch(
544        "CREATE TABLE meta (
545            format_version INTEGER NOT NULL,
546            source_size INTEGER NOT NULL,
547            source_mtime INTEGER NOT NULL,
548            origin_lon REAL NOT NULL,
549            origin_lat REAL NOT NULL,
550            cell_deg REAL NOT NULL,
551            cols INTEGER NOT NULL,
552            rows INTEGER NOT NULL
553        );
554        CREATE TABLE raster (bits BLOB NOT NULL);
555        CREATE TABLE land_geometry (wkt TEXT NOT NULL);",
556    )?;
557    #[allow(clippy::cast_possible_wrap)]
558    connection.execute(
559        "INSERT INTO meta (format_version, source_size, source_mtime, origin_lon, origin_lat, cell_deg, cols, rows) \
560         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
561        rusqlite::params![
562            FORMAT_VERSION,
563            source_size as i64,
564            source_mtime,
565            origin.x,
566            origin.y,
567            CELL_DEG,
568            cols as i64,
569            rows as i64
570        ],
571    )?;
572    connection.execute("INSERT INTO raster (bits) VALUES (?1)", rusqlite::params![pack_bits(&bits)])?;
573    connection.execute(
574        "INSERT INTO land_geometry (wkt) VALUES (?1)",
575        rusqlite::params![land_geometry.wkt_string()],
576    )?;
577
578    Ok(target)
579}
580
581/// The raster's own origin (south-west corner) and dimensions: `area`
582/// padded by [`RASTER_PADDING_DEG`] on every side, gridded at
583/// [`CELL_DEG`].
584fn raster_extent(area: Rect<f64>) -> (Coord<f64>, usize, usize) {
585    let origin = Coord { x: area.min().x - RASTER_PADDING_DEG, y: area.min().y - RASTER_PADDING_DEG };
586    let width = (area.max().x + RASTER_PADDING_DEG) - origin.x;
587    let height = (area.max().y + RASTER_PADDING_DEG) - origin.y;
588    let cols = ((width / CELL_DEG).ceil() as usize).max(1);
589    let rows = ((height / CELL_DEG).ceil() as usize).max(1);
590    (origin, cols, rows)
591}
592
593fn pack_bits(bits: &[bool]) -> Vec<u8> {
594    let mut bytes = vec![0u8; bits.len().div_ceil(8)];
595    for (index, &bit) in bits.iter().enumerate() {
596        if bit {
597            bytes[index / 8] |= 1 << (index % 8);
598        }
599    }
600    bytes
601}
602
603fn unpack_bits(bytes: &[u8], count: usize) -> Vec<bool> {
604    (0..count).map(|index| bytes[index / 8] & (1 << (index % 8)) != 0).collect()
605}
606
607/// Loads the land cache for a chart opened from `chart_path`, if one
608/// exists, is readable, and still matches that chart's own file
609/// metadata -- `None` for absent, stale, or corrupt alike, all read the
610/// same way by every caller in [`crate::hazards`]: fall back to the live
611/// path this cache exists to avoid.
612pub(crate) fn load(chart_path: &Path) -> Option<LandCache> {
613    try_load(chart_path).ok().flatten()
614}
615
616fn try_load(chart_path: &Path) -> Result<Option<LandCache>, LandCacheError> {
617    let path = cache_path(chart_path);
618    if !path.exists() {
619        return Ok(None);
620    }
621    let (source_size, source_mtime) = fingerprint(chart_path)?;
622
623    let connection = Connection::open_with_flags(&path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)?;
624    let meta = connection.query_row(
625        "SELECT format_version, source_size, source_mtime, origin_lon, origin_lat, cell_deg, cols, rows FROM meta",
626        [],
627        |row| {
628            Ok((
629                row.get::<_, i64>(0)?,
630                row.get::<_, i64>(1)?,
631                row.get::<_, i64>(2)?,
632                row.get::<_, f64>(3)?,
633                row.get::<_, f64>(4)?,
634                row.get::<_, f64>(5)?,
635                row.get::<_, i64>(6)?,
636                row.get::<_, i64>(7)?,
637            ))
638        },
639    );
640    let Ok((format_version, cached_size, cached_mtime, origin_lon, origin_lat, cell_deg, cols, rows)) = meta else {
641        return Ok(None);
642    };
643    #[allow(clippy::cast_possible_wrap)]
644    let size_matches = cached_size == source_size as i64;
645    if format_version != FORMAT_VERSION || !size_matches || cached_mtime != source_mtime {
646        return Ok(None);
647    }
648
649    let Ok(bits_blob) = connection.query_row("SELECT bits FROM raster", [], |row| row.get::<_, Vec<u8>>(0)) else {
650        return Ok(None);
651    };
652    let Ok(wkt_text) = connection.query_row("SELECT wkt FROM land_geometry", [], |row| row.get::<_, String>(0)) else {
653        return Ok(None);
654    };
655    let Ok(land_geometry) = MultiPolygon::<f64>::try_from_wkt_str(&wkt_text) else {
656        return Ok(None);
657    };
658
659    let (cols, rows) = (cols as usize, rows as usize);
660    let raster = unpack_bits(&bits_blob, cols * rows);
661
662    Ok(Some(LandCache {
663        origin: Coord { x: origin_lon, y: origin_lat },
664        cell_deg,
665        cols,
666        rows,
667        raster,
668        land_geometry: Geometry::MultiPolygon(land_geometry),
669    }))
670}
671
672#[cfg(test)]
673mod tests {
674    use super::*;
675    use geo::LineString;
676
677    fn square(min_lon: f64, min_lat: f64, max_lon: f64, max_lat: f64) -> Polygon<f64> {
678        Polygon::new(
679            LineString::new(vec![
680                Coord { x: min_lon, y: min_lat },
681                Coord { x: max_lon, y: min_lat },
682                Coord { x: max_lon, y: max_lat },
683                Coord { x: min_lon, y: max_lat },
684                Coord { x: min_lon, y: min_lat },
685            ]),
686            Vec::new(),
687        )
688    }
689
690    fn small_cache() -> LandCache {
691        let land = vec![square(13.0, 45.0, 13.1, 45.1)];
692        let area = Rect::new(Coord { x: 12.5, y: 44.5 }, Coord { x: 13.5, y: 45.5 });
693        let (origin, cols, rows) = raster_extent(area);
694        let mut bits = vec![false; cols * rows];
695        for part in &land {
696            rasterize_part(part, origin, CELL_DEG, cols, rows, &mut bits);
697        }
698        LandCache {
699            origin,
700            cell_deg: CELL_DEG,
701            cols,
702            rows,
703            raster: bits,
704            land_geometry: Geometry::MultiPolygon(MultiPolygon::new(land)),
705        }
706    }
707
708    fn rect(min_lon: f64, min_lat: f64, max_lon: f64, max_lat: f64) -> Rect<f64> {
709        Rect::new(Coord { x: min_lon, y: min_lat }, Coord { x: max_lon, y: max_lat })
710    }
711
712    #[test]
713    fn largest_cluster_bbox_ignores_a_far_away_outlier() {
714        // A dozen boxes scattered across a real coastline's own scale
715        // (a few degrees apart, chained so the whole run is one
716        // cluster), plus one far away on its own -- the Antarctica case
717        // this function exists for, in miniature.
718        let mainland: Vec<Rect<f64>> = (0..12).map(|i| rect(f64::from(i) * 2.0, 45.0, f64::from(i) * 2.0 + 0.5, 45.5)).collect();
719        let mut boxes = mainland.clone();
720        boxes.push(rect(160.0, -75.0, 160.5, -74.5));
721
722        let area = largest_cluster_bbox(&boxes).expect("a cluster exists");
723        assert!(area.max().x < 30.0, "the outlier must not have widened the detected area: {area:?}");
724        assert!(area.min().y > 0.0, "the outlier must not have widened the detected area: {area:?}");
725
726        let expected = mainland.into_iter().reduce(union_rect).unwrap();
727        assert_eq!(area, expected);
728    }
729
730    #[test]
731    fn largest_cluster_bbox_of_no_boxes_is_none() {
732        assert_eq!(largest_cluster_bbox(&[]), None);
733    }
734
735    #[test]
736    fn covers_accepts_only_an_area_fully_inside_the_baked_extent() {
737        let cache = small_cache();
738        assert!(cache.covers(Rect::new(Coord { x: 12.6, y: 44.6 }, Coord { x: 13.4, y: 45.4 })));
739        assert!(
740            !cache.covers(Rect::new(Coord { x: 13.0, y: 37.0 }, Coord { x: 15.0, y: 38.0 })),
741            "an area far outside the baked extent -- Sicily, say, against a cache baked for the \
742             Adriatic -- must not be reported as covered"
743        );
744        assert!(
745            !cache.covers(Rect::new(Coord { x: 13.0, y: 45.0 }, Coord { x: 20.0, y: 46.0 })),
746            "an area only partly inside the baked extent is not covered either"
747        );
748    }
749
750    #[test]
751    fn a_footprint_touching_land_reads_blocked() {
752        let cache = small_cache();
753        assert!(cache.blocked(&square(13.04, 45.04, 13.06, 45.06)));
754    }
755
756    #[test]
757    fn a_footprint_in_clear_water_reads_clear() {
758        let cache = small_cache();
759        assert!(!cache.blocked(&square(20.0, 50.0, 20.1, 50.1)));
760    }
761
762    #[test]
763    fn a_long_thin_corridor_crossing_land_is_caught_along_its_edge() {
764        // A corridor running the length of the raster, only grazing the
765        // land block's own southern edge -- the case a naive bounding-box
766        // test over-approximates massively, and a boundary-only test
767        // could in principle miss if it only sampled interior points.
768        let cache = small_cache();
769        let corridor = square(12.9, 45.0, 13.2, 45.005);
770        assert!(cache.blocked(&corridor));
771    }
772
773    #[test]
774    fn a_long_thin_corridor_missing_land_entirely_is_clear() {
775        let cache = small_cache();
776        let corridor = square(12.9, 45.5, 13.2, 45.505);
777        assert!(!cache.blocked(&corridor));
778    }
779
780    #[test]
781    fn bake_and_load_round_trip_agree_with_the_live_geometry() {
782        let dir = std::env::temp_dir().join(format!("land-cache-test-{}", std::process::id()));
783        std::fs::create_dir_all(&dir).unwrap();
784        let chart_path = dir.join("does-not-need-to-exist-for-this-fingerprint.gpkg");
785        std::fs::write(&chart_path, b"not a real chart, just a fingerprint source").unwrap();
786
787        let land = vec![square(13.0, 45.0, 13.1, 45.1)];
788        let area = Rect::new(Coord { x: 12.5, y: 44.5 }, Coord { x: 13.5, y: 45.5 });
789        let (origin, cols, rows) = raster_extent(area);
790        let mut bits = vec![false; cols * rows];
791        for part in &land {
792            rasterize_part(part, origin, CELL_DEG, cols, rows, &mut bits);
793        }
794        let (source_size, source_mtime) = fingerprint(&chart_path).unwrap();
795        let target = cache_path(&chart_path);
796        let connection = Connection::open(&target).unwrap();
797        connection
798            .execute_batch(
799                "CREATE TABLE meta (format_version INTEGER, source_size INTEGER, source_mtime INTEGER, \
800                 origin_lon REAL, origin_lat REAL, cell_deg REAL, cols INTEGER, rows INTEGER); \
801                 CREATE TABLE raster (bits BLOB); CREATE TABLE land_geometry (wkt TEXT);",
802            )
803            .unwrap();
804        connection
805            .execute(
806                "INSERT INTO meta VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
807                rusqlite::params![
808                    FORMAT_VERSION,
809                    source_size as i64,
810                    source_mtime,
811                    origin.x,
812                    origin.y,
813                    CELL_DEG,
814                    cols as i64,
815                    rows as i64
816                ],
817            )
818            .unwrap();
819        connection.execute("INSERT INTO raster VALUES (?1)", rusqlite::params![pack_bits(&bits)]).unwrap();
820        connection
821            .execute(
822                "INSERT INTO land_geometry VALUES (?1)",
823                rusqlite::params![MultiPolygon::new(land).wkt_string()],
824            )
825            .unwrap();
826        drop(connection);
827
828        let loaded = load(&chart_path).expect("a freshly baked cache should load");
829        assert!(loaded.blocked(&square(13.04, 45.04, 13.06, 45.06)));
830        assert!(!loaded.blocked(&square(20.0, 50.0, 20.1, 50.1)));
831
832        std::fs::write(&chart_path, b"a different chart entirely, same path").unwrap();
833        assert!(load(&chart_path).is_none(), "a changed chart must invalidate its own stale cache");
834
835        let _ = std::fs::remove_dir_all(&dir);
836    }
837}