navcore_route_find/gateway.rs
1//! Finding a harbour's gateway: the nearest point outside a mole, a
2//! breakwater or a marina basin from which [`crate::find_route`]'s
3//! open-water search can start.
4//!
5//! # Why this exists
6//!
7//! `find_route`'s lattice and visibility search are tuned for open
8//! water: a cell size in tenths of a nautical mile, a corridor half a
9//! cable wide, margins that escalate from a fraction of the direct
10//! distance. A marina basin, a dredged channel or a narrow breakwater
11//! gap needs metre-scale precision this crate does not claim, on chart
12//! data that this crate's own overview-vs-detail reconciliation
13//! (`hazards::fetch_preferring_detail`) is not built to trust blindly
14//! either.
15//!
16//! No chartplotter auto-routes a vessel all the way to its own berth:
17//! Garmin's Auto Guidance documentation, Raymarine's LightHouse manual
18//! and OpenCPN's routing all stop at "clear of the marina" and hand
19//! pilotage back to the mariner. This module automates that same manual
20//! search: stand off the harbour a little further, look around, and
21//! find the nearest spot the open-water search can use.
22//!
23//! # The mechanism
24//!
25//! [`find_gateway`] takes a harbour's nominal position and, if it is
26//! not already usable, searches outward in expanding rings. Each
27//! candidate is tested with the same corridor-width check
28//! [`crate::find_route`]'s lattice search uses for its `from`/`to`
29//! endpoints (`Hazards::blocked`), not the zero-length `check_leg` probe
30//! that answers "clear" for any position, including one nowhere near
31//! water (see `enc-check`'s own `bench` module doc). A candidate is
32//! accepted only once one ring further out from it is clear too, so a
33//! single-point gap between two hazards cannot pass for a real opening.
34//!
35//! Ring spacing is constant in arc length, not bearing count: a fixed
36//! compass rose can miss a narrow marina entrance far out, because the
37//! angular gap between two adjacent rays grows past the gap in the
38//! breakwater. [`bearings_for_ring`] grows the bearing count with the
39//! ring's radius instead, keeping physical spacing between adjacent
40//! rays close to [`GATEWAY_ARC_SPACING_NM`] regardless of distance.
41//!
42//! # What this does not attempt
43//!
44//! The gateway found is open water a few tenths of a mile off the
45//! coast, not the pier; reaching the berth from there is left to the
46//! mariner, the same way every route this crate produces ends at a
47//! position, not a cleat. A future version could route the last stretch
48//! too, using a chart's own fairway data (`NAVLNE`) where charted.
49//!
50//! # A second approach: [`find_gateway_route`]
51//!
52//! [`find_gateway`] answers with a bare point; a caller still has to
53//! draw its own line from `harbor` to it, which is unreliable since a
54//! `harbor` pin is routinely charted land at the precision `check_leg`
55//! insists on. [`find_gateway_route`] answers with the path instead: a
56//! real, `check_leg`-approved waypoint list, the same quality
57//! [`crate::find_route`] produces for any other passage.
58//!
59//! The search runs in two phases at two corridors. Phase one,
60//! [`search::nearest_safe`], threads `harbor`'s immediate obstruction at
61//! [`GATEWAY_ESCAPE_RADIUS_NM`] -- tighter than the caller's own
62//! corridor, since real harbour water is routinely narrower than that
63//! margin calls safe -- expanding through nodes that pass the tighter
64//! test and stopping at the first one that also clears the caller's
65//! open-water corridor. Phase two hands off to [`crate::find_route`] at
66//! the caller's real corridor. Both halves are straightened and
67//! `check_leg`-approved by [`smooth::straighten`] before being joined.
68//!
69//! # The full passage: [`find_dock_to_dock_route`]
70//!
71//! [`find_gateway`] answers one harbour's question. A passage between
72//! two harbours needs that answered twice, with [`crate::find_route`]'s
73//! open-water search filling the middle. [`find_dock_to_dock_route`]
74//! does the stitching: `from_harbor`'s gateway, the open-water route
75//! between the two gateways, `to_harbor`'s gateway, concatenated into
76//! one waypoint list. Built on [`find_gateway`], not
77//! [`find_gateway_route`]: the destination harbour only needs a usable
78//! point to route to, not its own escape path.
79
80use nav_math::{Position, rhumb};
81
82use enc_store::{ChartStore, Leg, StoreError, Vessel};
83
84use crate::grid::Grid;
85use crate::hazards::Hazards;
86use crate::search;
87use crate::smooth;
88use crate::{FindError, FindOptions};
89
90/// How far apart, in nautical miles, [`find_gateway`] tries to keep
91/// adjacent test rays at any given ring radius -- see [`bearings_for_ring`].
92/// The same scale [`crate::find_route`]'s own default corridor half-width
93/// already uses elsewhere in this crate, chosen for the same reason: it
94/// is roughly the narrowest real gap (a breakwater entrance, a channel
95/// between two shoals) worth resolving at all, not merely a round number.
96const GATEWAY_ARC_SPACING_NM: f64 = 0.05;
97
98/// How far outward [`find_gateway`] widens its search rings before
99/// giving up, in nautical miles. Three miles is generous against a
100/// working gateway, typically under half a mile from a hand-picked pier
101/// position, while still bounded: a harbour this module cannot help
102/// with (a marina reachable only through a dredged channel narrower
103/// than any real chart resolves) would otherwise search forever rather
104/// than answering `None`.
105const GATEWAY_MAX_RADIUS_NM: f64 = 3.0;
106
107/// The ring-to-ring step, in nautical miles: how far each expanding
108/// ring moves out, and, doubled, the "one ring further" confirmation
109/// distance a candidate must stay clear past. A tenth of a mile matches
110/// the granularity a real harbour approach needs.
111const GATEWAY_RING_STEP_NM: f64 = 0.1;
112
113/// The fewest bearings any ring is tested at, however small its radius.
114/// Without this floor, [`bearings_for_ring`]'s arc-length scaling would
115/// let the tightest ring (a tenth of a mile out) get by with as few as
116/// seven or eight bearings, coarse enough to plausibly miss a gap even
117/// that close in.
118const GATEWAY_MIN_BEARINGS: usize = 16;
119
120/// The most bearings any one ring is tested at, however large its
121/// radius. A defensive ceiling, not a working limit: it sits well past
122/// the roughly 380 rays [`GATEWAY_ARC_SPACING_NM`]'s target spacing
123/// needs even at [`GATEWAY_MAX_RADIUS_NM`], the widest ring this module
124/// searches, so ordinary use never reaches it. It bounds the per-ring
125/// cost if either constant above changes.
126const GATEWAY_MAX_BEARINGS: usize = 512;
127
128/// Lattice spacing [`find_gateway_route`]'s escape-phase grid uses, in
129/// nautical miles: roughly 18 m, fine enough to resolve a marina
130/// channel or breakwater gap, near-shore precision
131/// [`crate::find_route`]'s open-water lattice (auto-scaled from the
132/// direct distance, routinely far coarser) is not built for.
133const GATEWAY_ESCAPE_CELL_NM: f64 = 0.01;
134
135/// The escape phase's corridor half-width, in nautical miles: much
136/// tighter than a caller's real `port_xtd_nm`/`starboard_xtd_nm` (the
137/// open-water passage margin), since a marina channel or dredged gap is
138/// routinely narrower than that margin calls safe. Roughly 5.5 m either
139/// side (an 11 m minimum channel width), an engineering default for
140/// slow-speed harbour manoeuvring rather than a specific vessel's beam
141/// -- this crate has no beam measurement to draw on (see
142/// [`FindOptions`]). A wider figure (18 m) would call a genuinely open
143/// berth exit blocked, sweeping sideways into the land either side of a
144/// gap this narrow. A future version could take a vessel's beam as an
145/// explicit setting and use it here directly.
146const GATEWAY_ESCAPE_RADIUS_NM: f64 = 0.003;
147
148/// How far out [`find_gateway_route`]'s escape phase fetches hazards
149/// and lays its grid, in nautical miles: much smaller than
150/// [`GATEWAY_MAX_RADIUS_NM`], since this phase only needs to reach
151/// clear, open-water-corridor water close to `harbor`, not the far
152/// gateway itself.
153const GATEWAY_ESCAPE_SEARCH_RADIUS_NM: f64 = 1.5;
154
155/// How many lattice nodes [`search::nearest_safe`] explores in
156/// [`find_gateway_route`]'s escape phase before giving up on `harbor`
157/// ever reaching water clear at the wide, open-water corridor.
158///
159/// [`search::nearest_safe`] has no heuristic to steer by -- a safe node
160/// could be in any direction from `harbor` -- so it explores by
161/// accumulated distance in a growing disc. At [`GATEWAY_ESCAPE_CELL_NM`],
162/// forty thousand nodes cover a disc a little over a nautical mile in
163/// radius (`sqrt(n / pi) * cell_nm`), past the widest escape this
164/// module handles in practice, so this is a defensive ceiling against a
165/// `harbor` with no nearby safe water at all.
166const GATEWAY_ESCAPE_MAX_NODES: usize = 40_000;
167
168/// A `check_leg`-approved route between two harbours: `from_harbor`'s
169/// gateway, [`crate::find_route`]'s open-water search between the two
170/// gateways, then `to_harbor`'s gateway, combining two [`find_gateway`]
171/// calls with the open-water search between them so a caller does not
172/// stitch them by hand.
173///
174/// `from_harbor` and `to_harbor` are included as the first and last
175/// positions, unless a harbour was already its own gateway, but neither
176/// is validated by a `check_leg` call the way every position between
177/// them is: a harbour pin is routinely charted land at the precision
178/// `check_leg` insists on (see [`find_gateway_route`]'s own doc). Both
179/// bookend the route as a position, not a cleat, left for the mariner
180/// to close.
181///
182/// `Ok(None)` when either harbour has no gateway within
183/// `GATEWAY_MAX_RADIUS_NM`, or when [`crate::find_route`] itself finds
184/// no safe passage between the two gateways once found.
185///
186/// # Errors
187///
188/// If the chart cannot be read.
189pub fn find_dock_to_dock_route(
190 chart: &ChartStore,
191 from_harbor: Position,
192 to_harbor: Position,
193 options: &FindOptions,
194) -> Result<Option<Vec<Position>>, FindError> {
195 let Some(from_gateway) = find_gateway(chart, from_harbor, options)? else {
196 return Ok(None);
197 };
198 let Some(to_gateway) = find_gateway(chart, to_harbor, options)? else {
199 return Ok(None);
200 };
201
202 let Some(open_water) = crate::find_route(chart, from_gateway, to_gateway, *options)? else {
203 return Ok(None);
204 };
205
206 let mut route = Vec::with_capacity(open_water.len() + 2);
207 if from_harbor != from_gateway {
208 route.push(from_harbor);
209 }
210 route.extend(open_water.into_iter().map(|waypoint| waypoint.position));
211 if to_harbor != to_gateway {
212 route.push(to_harbor);
213 }
214 Ok(Some(route))
215}
216
217/// How many rays to test around a ring of radius `radius_nm`, keeping
218/// the physical distance between adjacent rays close to
219/// [`GATEWAY_ARC_SPACING_NM`] regardless of the ring's radius.
220#[must_use]
221fn bearings_for_ring(radius_nm: f64) -> usize {
222 let circumference_nm = 2.0 * std::f64::consts::PI * radius_nm;
223 let scaled = (circumference_nm / GATEWAY_ARC_SPACING_NM).ceil() as usize;
224 scaled.clamp(GATEWAY_MIN_BEARINGS, GATEWAY_MAX_BEARINGS)
225}
226
227/// The nearest point to `harbor` that [`crate::find_route`]'s open-water
228/// search can use as a `from` or `to`: `harbor` itself if that is
229/// already clear, otherwise the first point found searching outward in
230/// expanding rings. See this module's own doc for the search itself and
231/// what it does not attempt.
232///
233/// `Ok(None)` when nothing was found within `GATEWAY_MAX_RADIUS_NM`:
234/// either `harbor` is nowhere near navigable water, or its gateway lies
235/// down a channel narrower than this search's rays cross paths with.
236///
237/// # Errors
238///
239/// If the chart cannot be read.
240pub fn find_gateway(chart: &ChartStore, harbor: Position, options: &FindOptions) -> Result<Option<Position>, FindError> {
241 let vessel = Vessel { safety_contour_m: options.safety_contour_m };
242 let radius_nm = options.port_xtd_nm.max(options.starboard_xtd_nm);
243
244 let area = crate::search_area(harbor, harbor, GATEWAY_MAX_RADIUS_NM + GATEWAY_RING_STEP_NM);
245 let hazards = Hazards::fetch(chart, area, options.avoid_restricted_areas)?;
246
247 if !hazards.blocked(harbor, radius_nm, vessel) {
248 return Ok(Some(harbor));
249 }
250
251 let mut ring_nm = GATEWAY_RING_STEP_NM;
252 while ring_nm <= GATEWAY_MAX_RADIUS_NM {
253 let bearings = bearings_for_ring(ring_nm);
254 for step in 0..bearings {
255 let bearing_deg = 360.0 * step as f64 / bearings as f64;
256 let candidate = rhumb::destination(harbor, bearing_deg, ring_nm);
257 if hazards.blocked(candidate, radius_nm, vessel) {
258 continue;
259 }
260
261 // One ring further out, same bearing: a candidate only
262 // narrowly clear -- a rounding artefact of where this ring
263 // happened to fall, not a real opening -- fails this and is
264 // left for a later, wider ring to find honestly instead.
265 let confirmed = rhumb::destination(harbor, bearing_deg, ring_nm + GATEWAY_RING_STEP_NM);
266 if !hazards.blocked(confirmed, radius_nm, vessel) {
267 return Ok(Some(candidate));
268 }
269 }
270 ring_nm += GATEWAY_RING_STEP_NM;
271 }
272
273 Ok(None)
274}
275
276/// A `check_leg`-approved route from `harbor` out to [`find_gateway`]'s
277/// confirmed destination: the actual path there, not just the point,
278/// validated end to end from `harbor`'s exact position onward. See this
279/// module's own doc, "A second approach", for how this compares to
280/// [`find_gateway`].
281///
282/// Two phases, at two corridors, joined at whichever node the first
283/// phase reaches: `search::nearest_safe` threads `harbor`'s immediate
284/// obstruction -- a marina basin, a breakwater gap -- at
285/// `GATEWAY_ESCAPE_RADIUS_NM`, tighter than a caller's open-water
286/// `port_xtd_nm`/`starboard_xtd_nm`, since harbour water is routinely
287/// narrower than that margin calls safe; then [`crate::find_route`]
288/// takes over at the caller's real corridor for the rest of the
289/// passage. Both halves are straightened and `check_leg`-approved by
290/// `smooth::straighten` before being joined.
291///
292/// `Ok(None)` when [`find_gateway`] finds no destination, when `harbor`
293/// has no `check_leg`-safe way out of its immediate obstruction within
294/// `GATEWAY_ESCAPE_MAX_NODES` lattice steps, or when
295/// [`crate::find_route`] finds no safe passage from there to the
296/// gateway.
297///
298/// Depth is not tested during the escape phase -- only land, a no-entry
299/// restricted area, or the tighter corridor width can block it -- on
300/// the mariner's own instruction: a marina berth's charted depth is
301/// routinely less trustworthy than the mariner's own knowledge of it
302/// (confirmed against a position the chart placed to only 6 m accuracy
303/// at its compilation scale). [`find_gateway`]'s far target and the
304/// open-water passage [`crate::find_route`] finds beyond `launch` are
305/// unaffected: depth still applies exactly as `options` asks everywhere
306/// past the harbour's immediate obstruction.
307///
308/// # Errors
309///
310/// If the chart cannot be read.
311pub fn find_gateway_route(chart: &ChartStore, harbor: Position, options: &FindOptions) -> Result<Option<Vec<Position>>, FindError> {
312 let vessel = Vessel { safety_contour_m: options.safety_contour_m };
313 let radius_nm = options.port_xtd_nm.max(options.starboard_xtd_nm);
314
315 // The confirmed-far target: the same point [`find_gateway`]'s own
316 // ring search already finds and validates. Reused rather than
317 // re-derived, so the two functions can never quietly disagree about
318 // what "clear of the harbour" means.
319 let Some(gateway) = find_gateway(chart, harbor, options)? else {
320 return Ok(None);
321 };
322 if gateway == harbor {
323 // Nothing to escape from: harbor was already clear.
324 return Ok(Some(vec![harbor]));
325 }
326
327 // Phase one: a real, corridor-respecting path out of whatever tight
328 // space `harbor` sits in, at the escape phase's own tighter
329 // corridor -- not the caller's open-water one, which is exactly what
330 // `harbor` just failed above. Depth is deliberately not tested here,
331 // on the mariner's own explicit instruction, scoped to this phase
332 // only -- `Hazards::ignoring_depth` drops every depth entry from the
333 // index outright, which is the only way to actually ignore one: a
334 // vessel with an impossibly permissive contour still cannot bypass
335 // `Kind::Depth { drval1_m: None }`'s unconditional "unsurveyed means
336 // drying" rule, confirmed live against a real marina position where
337 // that rule fired even at a 1.0 m safety contour. Land and no-entry
338 // restricted areas are untouched by `ignoring_depth` and still
339 // block, exactly as `crosses_no_entry`/`intersects_band` always did.
340 //
341 // `passable` judges the real step, not `Hazards::blocked`'s
342 // symmetric point-square -- confirmed live that a real marina berth
343 // is routinely land on most compass directions and open on one:
344 // land showed up on five of eight bearings within 40 m of a real
345 // position this was tested against, open water on the other three.
346 // A point-square test would have called every node in that berth
347 // blocked regardless of which side it was ever actually approached
348 // from; a directional leg band judges the one side a step actually
349 // crosses.
350 // `fetch_without_land_cache`, not `Hazards::fetch`: the baked land
351 // cache is a raster built for the open-water lattice and visibility
352 // searches, gridded far too coarse (roughly 200-300 m at this
353 // crate's working latitudes) for this phase's own metre-scale
354 // precision -- confirmed live, a genuine 18 m gap read as blocked
355 // through a freshly rebaked cache and correctly as open water once
356 // bypassed. `safe` below stays on `hazards`, the normal, possibly
357 // cached fetch: it works at the caller's own wide, open-water
358 // corridor, the scale that raster was built for.
359 let area = crate::search_area(harbor, harbor, GATEWAY_ESCAPE_SEARCH_RADIUS_NM);
360 let hazards = Hazards::fetch(chart, area, options.avoid_restricted_areas)?;
361 let live = Hazards::fetch_without_land_cache(chart, area, options.avoid_restricted_areas)?;
362 let depth_free = live.ignoring_depth();
363 let grid = Grid::covering(area, GATEWAY_ESCAPE_CELL_NM);
364 let start = grid.nearest(harbor);
365 let passable = |from: (usize, usize), to: (usize, usize)| {
366 let leg = Leg::symmetric(
367 grid.position(from.0, from.1),
368 grid.position(to.0, to.1),
369 GATEWAY_ESCAPE_RADIUS_NM,
370 );
371 !depth_free.crosses_no_entry(leg)
372 && !leg.band().is_some_and(|band| depth_free.intersects_band(&band, vessel))
373 };
374 let safe = |node: (usize, usize)| !hazards.blocked(grid.position(node.0, node.1), radius_nm, vessel);
375
376 let Some(raw_escape) = search::nearest_safe(&grid, start, GATEWAY_ESCAPE_MAX_NODES, passable, safe)
377 else {
378 return Ok(None);
379 };
380
381 let mut escape_positions: Vec<Position> =
382 raw_escape.iter().map(|&(row, col)| grid.position(row, col)).collect();
383 // The promised route starts at `harbor` itself, not the lattice node
384 // nearest it -- `CoarseOnly` below re-tests every leg it touches
385 // anyway, so this costs nothing and the first leg it checks is the
386 // one that will actually be sailed.
387 if let Some(first) = escape_positions.first_mut() {
388 *first = harbor;
389 }
390
391 // Not `smooth::straighten`/`smooth::Safety`: those call the real
392 // `check_leg` oracle directly against `chart`, bypassing this
393 // function's own `Hazards` entirely -- which means `ignoring_depth`
394 // above could not stop it from finding, and rejecting a leg for,
395 // the exact same "no charted depth, treated as drying" verdict this
396 // phase exists to set aside. `CoarseOnly` re-tests each candidate
397 // leg only against `depth_free`'s own coarse band test instead --
398 // still real land, still a real no-entry area, never a real
399 // `check_leg` depth finding.
400 let escape_safety = CoarseOnly { hazards: &depth_free };
401 let Some(escape_route) = smooth::straighten(
402 &escape_positions,
403 GATEWAY_ESCAPE_RADIUS_NM,
404 GATEWAY_ESCAPE_RADIUS_NM,
405 vessel,
406 &escape_safety,
407 )?
408 else {
409 return Ok(None);
410 };
411 let Some(&launch) = escape_route.last() else {
412 return Ok(None);
413 };
414
415 // Phase two: `crate::find_route`'s own job from here, at the
416 // caller's real, wider corridor.
417 let Some(onward) = crate::find_route(chart, launch, gateway, *options)? else {
418 return Ok(None);
419 };
420
421 let mut route = escape_route;
422 route.extend(onward.into_iter().map(|waypoint| waypoint.position).skip(1));
423 Ok(Some(route))
424}
425
426/// [`smooth::EdgeClear`] for [`find_gateway_route`]'s escape phase:
427/// `hazards`'s coarse band test, land and no-entry areas included, and
428/// nothing else. Not the `check_leg` oracle [`smooth::Safety`] calls,
429/// which reads `chart` directly and so cannot be steered clear of a
430/// depth finding by anything done to `hazards` alone -- see
431/// [`find_gateway_route`]'s own doc for why that distinction matters
432/// here.
433struct CoarseOnly<'a> {
434 hazards: &'a Hazards,
435}
436
437impl smooth::EdgeClear for CoarseOnly<'_> {
438 fn is_clear(&self, leg: Leg, vessel: Vessel) -> Result<bool, StoreError> {
439 if self.hazards.crosses_no_entry(leg) {
440 return Ok(false);
441 }
442 Ok(!leg.band().is_some_and(|band| self.hazards.intersects_band(&band, vessel)))
443 }
444}
445
446#[cfg(test)]
447mod tests {
448 use super::*;
449
450 #[test]
451 fn a_ring_close_in_still_gets_the_minimum_bearing_count() {
452 assert_eq!(bearings_for_ring(0.01), GATEWAY_MIN_BEARINGS);
453 }
454
455 #[test]
456 fn bearing_count_grows_with_ring_radius() {
457 assert!(bearings_for_ring(1.0) > bearings_for_ring(0.1));
458 }
459
460 #[test]
461 fn bearing_count_is_capped_for_a_wide_ring() {
462 assert_eq!(bearings_for_ring(100.0), GATEWAY_MAX_BEARINGS);
463 }
464
465 #[test]
466 fn ray_spacing_stays_close_to_target_across_every_ring_this_module_searches() {
467 // The whole reason bearings_for_ring exists: a real gap the
468 // width of GATEWAY_ARC_SPACING_NM should never fall between two
469 // adjacent rays, at any radius find_gateway's own
470 // GATEWAY_MAX_RADIUS_NM ever actually reaches -- GATEWAY_MAX_BEARINGS
471 // is chosen wide enough that it never binds within that range
472 // (see the next test for the far-out regime where it does).
473 for radius_nm in [0.1, 0.5, 1.0, 2.0, GATEWAY_MAX_RADIUS_NM] {
474 let bearings = bearings_for_ring(radius_nm);
475 let circumference_nm = 2.0 * std::f64::consts::PI * radius_nm;
476 let spacing_nm = circumference_nm / bearings as f64;
477 assert!(
478 spacing_nm <= GATEWAY_ARC_SPACING_NM * 1.01,
479 "ring {radius_nm} NM: spacing {spacing_nm} NM exceeds target"
480 );
481 }
482 }
483
484}
485