1use 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
50const LAND_CLASS: &str = "LNDARE";
55
56const COVERAGE_CLASS: &str = "M_COVR";
62
63const CLUSTER_DISTANCE_DEG: f64 = 3.0;
73
74const CELL_DEG: f64 = 0.0025;
82
83const RASTER_PADDING_DEG: f64 = 0.1;
88
89const FORMAT_VERSION: i64 = 1;
93
94#[derive(Debug)]
96pub enum LandCacheError {
97 Store(StoreError),
99 Database(rusqlite::Error),
101 Io(std::io::Error),
103 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
139fn 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
150fn 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
164pub(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 pub(crate) fn land_geometry(&self) -> &Geometry<f64> {
184 &self.land_geometry
185 }
186
187 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 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 #[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
272fn 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
283fn 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 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 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
344fn 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
371fn 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
414fn 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
422fn 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
430fn 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
438const BAKE_TILE_DEG: f64 = 2.0;
455
456fn 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
485pub 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 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
581fn 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
607pub(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 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 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}