Skip to main content

oxiproj_transformations/transformations/
gridshift.rs

1#![forbid(unsafe_code)]
2//! Generic grid shift (`gridshift`) — port of PROJ 9.8.0 generic gridshift.
3//! Detects the grid format from raw bytes (NTv2, GTX, GeoTIFF) and applies
4//! the appropriate horizontal, vertical, or 3-D shift. Acts as a dispatcher.
5
6use crate::{TransBuild, TransParams};
7use oxiproj_core::{Coord, IoUnits, Operation, ProjError, ProjResult};
8use oxiproj_grids::{
9    read_geotiff, read_geotiff_gdal_metadata, read_geotiff_hierarchy, read_gtx, read_ntv2,
10    sample_grid, BandPositive, BandRole, BandUnit, GridBand, GridSet,
11};
12use std::f64::consts::PI;
13
14const ARCSEC_TO_RAD: f64 = PI / 648_000.0;
15const DEG_TO_RAD: f64 = PI / 180.0;
16const MAX_ITER: usize = 10;
17const ITER_TOL: f64 = 1e-12;
18/// Relative tolerance for snapping a query that lands a hair outside the grid
19/// back onto the edge cell, matching PROJ's `REL_TOLERANCE_HGRIDSHIFT`.
20const REL_TOLERANCE_HGRIDSHIFT: f64 = 1e-5;
21
22/// Interpolation kernel selected via `+interpolation=` on the generic
23/// `gridshift`, mirroring PROJ `gridshift.cpp` (`bilinear` | `biquadratic`).
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub(crate) enum Interpolation {
26    /// Standard 2×2 bilinear interpolation (PROJ default).
27    Bilinear,
28    /// 3×3-window quadratic interpolation (NOAA `qterp`), used e.g. for the
29    /// NADCON5 GeoTIFF grids to reproduce the NCAT reference transformer.
30    Biquadratic,
31}
32
33/// Return `true` if `data` begins with a TIFF magic signature (little-endian
34/// `II\x2a\x00` or big-endian `MM\x00\x2a`), i.e. a GeoTIFF grid rather than an
35/// NTv2 `.gsb` / GTX binary. Shared by `vgridshift`/`hgridshift`.
36pub(crate) fn is_tiff(data: &[u8]) -> bool {
37    data.starts_with(b"II\x2a\x00") || data.starts_with(b"MM\x00\x2a")
38}
39
40/// Split a `+grids=` value on commas and resolve each entry's raw bytes,
41/// honoring PROJ's per-entry `@` "optional" prefix (`src/grids.cpp`
42/// `pj_hgrid_init`/`pj_vgrid_init`/`pj_generic_grid_init`).
43///
44/// Each comma-separated entry may carry a leading `@`, meaning "may fail, skip
45/// silently"; a non-`@` entry that cannot be resolved aborts the whole list with
46/// its resolution error (PROJ's "could not find required grid(s)"). The returned
47/// list preserves input order, giving the same first-containing-grid-wins
48/// fallback semantics PROJ applies when sampling. An all-optional list whose
49/// entries all fail resolves to an empty list (a pass-through transform), exactly
50/// as PROJ leaves `Q->grids` empty in that case.
51pub(crate) fn resolve_grid_list(
52    registry: Option<&dyn crate::GridRegistry>,
53    csv: &str,
54) -> ProjResult<Vec<(String, Vec<u8>)>> {
55    let mut out = Vec::new();
56    for raw in csv.split(',') {
57        let entry = raw.trim();
58        if entry.is_empty() {
59            continue;
60        }
61        let (name, can_fail) = match entry.strip_prefix('@') {
62            Some(rest) => (rest.trim(), true),
63            None => (entry, false),
64        };
65        if name.is_empty() {
66            continue;
67        }
68        match resolve_grid_bytes(registry, name) {
69            Ok(bytes) => out.push((name.to_string(), bytes)),
70            Err(e) => {
71                if !can_fail {
72                    return Err(e);
73                }
74            }
75        }
76    }
77    Ok(out)
78}
79
80/// Parse the optional `+t_epoch` / `+t_final` time-restriction parameters shared
81/// by `hgridshift` and `vgridshift` (PROJ `hgridshift.cpp` / `vgridshift.cpp`).
82///
83/// `+t_final=now` is resolved to the current decimal year exactly as PROJ does
84/// (`1900 + tm_year + tm_yday/365`). Absent parameters yield `0.0`, which
85/// disables gating (the shift is always applied).
86pub(crate) fn parse_time_gate(p: &TransParams) -> (f64, f64) {
87    let t_epoch = p.params.get_f64("t_epoch").unwrap_or(0.0);
88    let t_final = match p.params.get_f64("t_final") {
89        Some(v) if v != 0.0 => v,
90        _ => {
91            // A number wasn't passed (or it was 0); honor the "now" keyword.
92            if p.params.get_str("t_final") == Some("now") {
93                decimal_year_now()
94            } else {
95                0.0
96            }
97        }
98    };
99    (t_epoch, t_final)
100}
101
102/// Return `true` when a grid shift should be applied to a point observed at
103/// decimal year `t`, per PROJ's `pj_*gridshift_forward_4d` gating: apply
104/// unconditionally when either bound is 0, otherwise only when
105/// `t < t_epoch && t_final > t_epoch`.
106pub(crate) fn time_gate_applies(t_epoch: f64, t_final: f64, t: f64) -> bool {
107    if t_final == 0.0 || t_epoch == 0.0 {
108        return true;
109    }
110    t < t_epoch && t_final > t_epoch
111}
112
113/// Current time as a decimal year, mirroring PROJ's
114/// `1900.0 + date->tm_year + date->tm_yday / 365.0` (UTC here; the sub-day
115/// difference from local time is negligible for a year fraction). Used only for
116/// `+t_final=now`.
117fn decimal_year_now() -> f64 {
118    let secs = std::time::SystemTime::now()
119        .duration_since(std::time::UNIX_EPOCH)
120        .map(|d| d.as_secs() as i64)
121        .unwrap_or(0);
122    let days = secs.div_euclid(86_400);
123    let (year, month, day) = civil_from_days(days);
124    let doy = day_of_year(year, month, day);
125    year as f64 + doy as f64 / 365.0
126}
127
128/// Convert a count of days since 1970-01-01 to a `(year, month, day)` civil date
129/// (Howard Hinnant's `civil_from_days`, valid across the full proleptic
130/// Gregorian range).
131fn civil_from_days(days: i64) -> (i64, u32, u32) {
132    let z = days + 719_468;
133    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
134    let doe = z - era * 146_097;
135    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
136    let y = yoe + era * 400;
137    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
138    let mp = (5 * doy + 2) / 153;
139    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
140    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
141    let year = if m <= 2 { y + 1 } else { y };
142    (year, m, d)
143}
144
145/// Zero-based day of year (matching C's `tm_yday`) for a civil date.
146fn day_of_year(year: i64, month: u32, day: u32) -> u32 {
147    const CUM: [u32; 12] = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
148    let leap = (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0);
149    let mut doy = CUM[(month.clamp(1, 12) - 1) as usize] + day.saturating_sub(1);
150    if leap && month > 2 {
151        doy += 1;
152    }
153    doy
154}
155
156/// Pick the index of the vertical-correction band in a GeoTIFF vertical grid,
157/// mirroring PROJ's `GTiffVGridShiftSet::open` band selection: prefer a band
158/// whose GDAL_METADATA `DESCRIPTION` marks it as a geoid/vertical offset,
159/// otherwise fall back to band 0.
160pub(crate) fn resolve_vertical_band(gs: &GridSet) -> Option<usize> {
161    if gs.bands.is_empty() {
162        return None;
163    }
164    for (i, band) in gs.bands.iter().enumerate() {
165        if matches!(
166            band.semantics.role,
167            BandRole::GeoidUndulation | BandRole::VerticalOffset
168        ) {
169            return Some(i);
170        }
171    }
172    // No described vertical channel: default to the first band (single-band
173    // geoid grids such as us_nga_egm96_15.tif carry no per-band DESCRIPTION).
174    Some(0)
175}
176
177/// Reduce a multi-band GeoTIFF vertical grid to a single-band [`GridSet`]
178/// holding only the resolved geoid/vertical-offset band, so the shared
179/// `ShiftKind::Vertical` sampler (which reads band 0) applies the correct
180/// channel in metres.
181pub(crate) fn vertical_gridset(mut gs: GridSet) -> ProjResult<GridSet> {
182    let idx = resolve_vertical_band(&gs).ok_or(ProjError::FileNotFound)?;
183    if idx != 0 {
184        gs.bands.swap(0, idx);
185    }
186    gs.bands.truncate(1);
187    Ok(gs)
188}
189
190/// Resolved recipe for applying a 2-band GeoTIFF horizontal-offset grid,
191/// mirroring PROJ's `GTiffHGridShiftSet::open` (`src/grids.cpp`): which band is
192/// the latitude / longitude offset, the arc-second/degree/radian → radian
193/// conversion factor, and whether positive longitude values point east.
194#[derive(Debug, Clone, Copy)]
195pub(crate) struct HShiftPlan {
196    idx_lat: usize,
197    idx_long: usize,
198    conv_factor_to_rad: f64,
199    positive_east: bool,
200}
201
202/// Convert a declared [`BandUnit`] to its radian conversion factor, or `None`
203/// for a non-angular unit (which cannot be a horizontal offset).
204fn unit_conv_factor(unit: BandUnit) -> Option<f64> {
205    match unit {
206        // PROJ's default when UNITTYPE is absent is arc-second (see the
207        // `convFactorToRadian = ARC_SECOND_TO_RADIAN` initializer).
208        BandUnit::ArcSecond | BandUnit::Unknown => Some(ARCSEC_TO_RAD),
209        BandUnit::Degree => Some(DEG_TO_RAD),
210        BandUnit::Radian => Some(1.0),
211        BandUnit::Metre => None,
212    }
213}
214
215/// Resolve the [`HShiftPlan`] for a GeoTIFF grid set, replicating PROJ's band
216/// identification and its NTv2-inspired defaults (band 0 = latitude offset,
217/// band 1 = longitude offset, arc-second, positive east) when the
218/// GDAL_METADATA descriptions are absent.
219///
220/// Returns `None` when the grid cannot be interpreted as a horizontal-offset
221/// grid (fewer than 2 bands; only one of lat/long described; a described but
222/// non-horizontal grid; or a non-angular unit).
223pub(crate) fn resolve_hshift_plan(gs: &GridSet) -> Option<HShiftPlan> {
224    let nbands = gs.bands.len();
225    if nbands < 2 {
226        return None;
227    }
228
229    // PROJ defaults, inspired from NTv2.
230    let mut idx_lat = 0usize;
231    let mut idx_long = 1usize;
232    let mut found_lat = false;
233    let mut found_long = false;
234    let mut found_any_desc = false;
235
236    for (i, band) in gs.bands.iter().enumerate() {
237        match band.semantics.role {
238            BandRole::LatitudeOffset => {
239                idx_lat = i;
240                found_lat = true;
241                found_any_desc = true;
242            }
243            BandRole::LongitudeOffset => {
244                idx_long = i;
245                found_long = true;
246                found_any_desc = true;
247            }
248            BandRole::Unknown => {}
249            // A described but non-horizontal role (geoid/vertical) still counts
250            // as "descriptions present".
251            BandRole::GeoidUndulation | BandRole::VerticalOffset => {
252                found_any_desc = true;
253            }
254        }
255    }
256
257    // Descriptions present but neither offset channel found: not horizontal.
258    if found_any_desc && !found_lat && !found_long {
259        return None;
260    }
261    // Exactly one of the two channels described: PROJ treats this as an error.
262    if found_lat != found_long {
263        return None;
264    }
265    if idx_lat >= nbands || idx_long >= nbands {
266        return None;
267    }
268
269    // Unit (PROJ requires lat and long to share it); default arc-second.
270    let conv_factor_to_rad = unit_conv_factor(gs.bands[idx_lat].semantics.unit)?;
271    // positive_value on the longitude band; default east.
272    let positive_east = gs.bands[idx_long].semantics.positive != BandPositive::West;
273
274    Some(HShiftPlan {
275        idx_lat,
276        idx_long,
277        conv_factor_to_rad,
278        positive_east,
279    })
280}
281
282/// Apply an [`HShiftPlan`] to interpolated band samples, returning the
283/// longitude and latitude offsets in radians (`(dlon, dlat)`), matching PROJ's
284/// `GTiffHGrid::valueAt` (arc-second → radian, then negate longitude when
285/// positive values point west).
286pub(crate) fn geotiff_horizontal_offset(plan: &HShiftPlan, shifts: &[f64]) -> (f64, f64) {
287    let raw_lat = shifts.get(plan.idx_lat).copied().unwrap_or(0.0);
288    let raw_long = shifts.get(plan.idx_long).copied().unwrap_or(0.0);
289    let dlat = raw_lat * plan.conv_factor_to_rad;
290    let mut dlon = raw_long * plan.conv_factor_to_rad;
291    if !plan.positive_east {
292        dlon = -dlon;
293    }
294    (dlon, dlat)
295}
296
297/// Build a semantics-aware horizontal GeoTIFF shift operation from an already
298/// decoded [`GridSet`]. Used by the `gridshift` unit tests to exercise the
299/// horizontal-offset application path directly from an in-memory [`GridSet`].
300/// Returns an error if the grid cannot be interpreted as a horizontal-offset
301/// grid.
302#[cfg(test)]
303pub(crate) fn build_horizontal_geotiff(gs: GridSet) -> ProjResult<TransBuild> {
304    let plan = resolve_hshift_plan(&gs).ok_or(ProjError::UnsupportedOperation)?;
305    Ok(TransBuild::new(
306        Box::new(GridShift {
307            grids: vec![gs],
308            kind: ShiftKind::HorizontalGeoTiff,
309            hplan: Some(plan),
310            idx_z: None,
311            interpolation: Interpolation::Bilinear,
312            no_z_transform: false,
313        }),
314        IoUnits::Radians,
315        IoUnits::Radians,
316    ))
317}
318
319/// Port of the NOAA `qterp` Fortran routine (a parabola through three equally
320/// spaced samples). `x` is the fractional position in `[0, 2]`; `f0`, `f1`, `f2`
321/// are the samples at 0, 1, 2. Matches PROJ `gridshift.cpp`'s
322/// `quadraticInterpol` lambda exactly.
323fn quadratic_interpol(x: f64, f0: f64, f1: f64, f2: f64) -> f64 {
324    let df0 = f1 - f0;
325    let df1 = f2 - f1;
326    let d2f0 = df1 - df0;
327    f0 + x * df0 + 0.5 * x * (x - 1.0) * d2f0
328}
329
330/// Biquadratic (3×3-window quadratic) interpolation of every band of `gs` at the
331/// geographic position `(lat_deg, lon_deg)`, returning one value per band in the
332/// bands' stored units (arc-second / metre — the same raw values `sample_grid`
333/// returns), or `None` when the point is outside the grid or hits nodata.
334///
335/// This is a faithful port of PROJ `gridshift.cpp`'s `grid_interpolate`
336/// biquadratic branch (its `qterp` window-shift + `quadraticInterpol`), operating
337/// in this crate's north-first raster convention. Grids narrower/shorter than
338/// 3 cells fall back to bilinear, exactly as PROJ does
339/// (`grid->width() < 3 || grid->height() < 3`).
340fn sample_grid_biquadratic(gs: &GridSet, lat_deg: f64, lon_deg: f64) -> Option<Vec<f64>> {
341    if gs.bands.is_empty() {
342        return None;
343    }
344    let mut results = Vec::with_capacity(gs.bands.len());
345    for band in &gs.bands {
346        results.push(band_biquadratic(band, lat_deg, lon_deg)?);
347    }
348    Some(results)
349}
350
351/// Biquadratic interpolation of a single [`GridBand`]; see
352/// [`sample_grid_biquadratic`].
353fn band_biquadratic(band: &GridBand, lat_deg: f64, lon_deg: f64) -> Option<f64> {
354    let ext = &band.extent;
355    let width = ext.cols() as i64;
356    let height = ext.rows() as i64;
357    // PROJ falls back to bilinear for grids too small for a 3×3 window.
358    if width < 3 || height < 3 {
359        return band_bilinear_fallback(band, lat_deg, lon_deg);
360    }
361
362    // Longitude normalization (single ±360° adjustment, as PROJ's normalizeX).
363    let mut lon = lon_deg;
364    let eps = (ext.lon_inc + ext.lat_inc) * REL_TOLERANCE_HGRIDSHIFT;
365    if lon < ext.ll_lon - eps {
366        lon += 360.0;
367    } else if lon > ext.ur_lon + eps {
368        lon -= 360.0;
369    }
370
371    // West-based column index and south-based row index (PROJ's grid frame).
372    let x = (lon - ext.ll_lon) / ext.lon_inc;
373    let y = (lat_deg - ext.ll_lat) / ext.lat_inc;
374    let mut ix = x.floor() as i64;
375    let mut iy = y.floor() as i64;
376    let mut fx = x - ix as f64;
377    let mut fy = y - iy as f64;
378
379    // Boundary snap (PROJ `grid_interpolate`).
380    if ix < 0 {
381        if ix == -1 && fx > 1.0 - 10.0 * REL_TOLERANCE_HGRIDSHIFT {
382            ix += 1;
383            fx = 0.0;
384        } else {
385            return None;
386        }
387    } else if ix + 1 >= width {
388        if ix + 1 == width && fx < 10.0 * REL_TOLERANCE_HGRIDSHIFT {
389            ix -= 1;
390            fx = 1.0;
391        } else {
392            return None;
393        }
394    }
395    if iy < 0 {
396        if iy == -1 && fy > 1.0 - 10.0 * REL_TOLERANCE_HGRIDSHIFT {
397            iy += 1;
398            fy = 0.0;
399        } else {
400            return None;
401        }
402    } else if iy + 1 >= height {
403        if iy + 1 == height && fy < 10.0 * REL_TOLERANCE_HGRIDSHIFT {
404            iy -= 1;
405            fy = 1.0;
406        } else {
407            return None;
408        }
409    }
410
411    // Shift the 3×3 window depending on which half-pixel we are in.
412    if (fx <= 0.5 && ix > 0) || (ix + 2 == width) {
413        ix -= 1;
414        fx += 1.0;
415    }
416    if (fy <= 0.5 && iy > 0) || (iy + 2 == height) {
417        iy -= 1;
418        fy += 1.0;
419    }
420    if ix < 0 || iy < 0 || ix + 2 >= width || iy + 2 >= height {
421        return None;
422    }
423
424    // Read the 3×3 window and qterp along x per south-row, then along y.
425    // Grid rows are stored north-first, so south-based row j maps to
426    // north_row = (height - 1) - (iy + j).
427    let mut row_vals = [0.0f64; 3];
428    for (j, slot) in row_vals.iter_mut().enumerate() {
429        let north_row = (height - 1 - (iy + j as i64)) as usize;
430        let mut cols = [0.0f64; 3];
431        for (i, c) in cols.iter_mut().enumerate() {
432            let v = band.get(north_row, (ix + i as i64) as usize)? as f64;
433            if !v.is_finite() {
434                return None;
435            }
436            *c = v;
437        }
438        *slot = quadratic_interpol(fx, cols[0], cols[1], cols[2]);
439    }
440    Some(quadratic_interpol(
441        fy,
442        row_vals[0],
443        row_vals[1],
444        row_vals[2],
445    ))
446}
447
448/// Single-band bilinear fallback used by [`band_biquadratic`] for grids too
449/// small for a 3×3 window. Reuses the shared [`sample_grid`] by wrapping the one
450/// band in a temporary [`GridSet`] is avoided; instead we sample directly.
451fn band_bilinear_fallback(band: &GridBand, lat_deg: f64, lon_deg: f64) -> Option<f64> {
452    let tmp = GridSet {
453        bands: vec![band.clone()],
454        source: oxiproj_grids::GridSource::Memory,
455    };
456    sample_grid(&tmp, lat_deg, lon_deg).and_then(|v| v.first().copied())
457}
458
459/// Sample `gs` with the selected [`Interpolation`] kernel, returning one raw
460/// value per band (arc-second / degree / metre, as stored).
461fn sample_with(
462    interp: Interpolation,
463    gs: &GridSet,
464    lat_deg: f64,
465    lon_deg: f64,
466) -> Option<Vec<f64>> {
467    match interp {
468        Interpolation::Bilinear => sample_grid(gs, lat_deg, lon_deg),
469        Interpolation::Biquadratic => sample_grid_biquadratic(gs, lat_deg, lon_deg),
470    }
471}
472
473/// Resolve the raw bytes of a grid file for a grid-based transformation.
474///
475/// This is the single resolution path shared by every grid-consuming transform
476/// (`gridshift`, `hgridshift`, `vgridshift`, `xyzgridshift`, and — via the
477/// engine's `+nadgrids=`/`+geoidgrids=` synthesis — the classic datum/geoid
478/// shifts). It mirrors PROJ's `pj_open_lib` resource-resolution order:
479///
480/// 1. **In-memory registry** — bytes registered on the engine `Context` (via
481///    `register_grid`) or any other [`GridRegistry`](crate::GridRegistry) the
482///    caller passes. Registered bytes always win, keeping the in-memory
483///    registration API authoritative (existing callers and tests rely on it).
484/// 2. **Local resource directories** — the `PROJ_DATA` (modern) and `PROJ_LIB`
485///    (legacy) directory lists, each of which may hold several
486///    platform-path-separator-delimited directories, followed by the unified
487///    downloadable-resource cache directory shared across the workspace
488///    ([`oxiproj_grids::GridDiskCache::default_dir`]).
489/// 3. **PROJ CDN** — only when this crate's off-by-default `network` feature is
490///    enabled, the grid is fetched (and cached on disk for next time) via
491///    [`oxiproj_grids::GridResolver`].
492///
493/// Returns [`ProjError::FileNotFound`] when the grid cannot be located in any
494/// layer, preserving the previous "grid missing" error for callers.
495pub(crate) fn resolve_grid_bytes(
496    registry: Option<&dyn crate::GridRegistry>,
497    name: &str,
498) -> ProjResult<Vec<u8>> {
499    // 1. In-memory registry (explicit registered bytes).
500    if let Some(reg) = registry {
501        if let Some(bytes) = reg.get_grid(name) {
502            return Ok(bytes.to_vec());
503        }
504    }
505    // 2. Local PROJ_DATA / PROJ_LIB directories and the unified disk cache.
506    if let Some(bytes) = read_from_resource_dirs(name) {
507        return Ok(bytes);
508    }
509    // 3. Network CDN, only with the `network` feature enabled.
510    #[cfg(feature = "network")]
511    {
512        if let Some(bytes) = fetch_from_cdn(name) {
513            return Ok(bytes);
514        }
515    }
516    Err(ProjError::FileNotFound)
517}
518
519/// Read a grid named `name` from the first local resource directory that
520/// contains it, or `None` if no directory holds it (or it cannot be read).
521///
522/// The searched directories — the `PROJ_DATA` and `PROJ_LIB` path lists, then
523/// the unified disk-cache directory — come from the single shared source of
524/// truth [`oxiproj_grids::resource_dirs`], the same list the factory's
525/// no-network availability check consults, so "found locally" means the same
526/// thing on both sides. No absolute path is ever hard-coded.
527fn read_from_resource_dirs(name: &str) -> Option<Vec<u8>> {
528    for dir in oxiproj_grids::resource_dirs() {
529        let path = dir.join(name);
530        if path.is_file() {
531            if let Ok(bytes) = std::fs::read(&path) {
532                return Some(bytes);
533            }
534        }
535    }
536    None
537}
538
539/// Fetch a grid from the PROJ CDN via [`oxiproj_grids::GridResolver`], caching
540/// it in the unified disk cache for subsequent lookups. Compiled only with the
541/// `network` feature; a clean miss or any error yields `None` so the caller
542/// falls through to [`ProjError::FileNotFound`].
543///
544/// Building a grid op is eager: it resolves the grid bytes up front. To keep
545/// operation selection offline-safe and deterministic by default — mirroring
546/// PROJ's network-off default — the CDN is consulted ONLY when the process-wide
547/// build-time network policy is explicitly enabled
548/// ([`oxiproj_grids::network_build_allowed`], turned on by the factory solely
549/// under `GridAvailability::AllowNetwork`). When it is off (the default) a
550/// missing grid resolves to a clean [`ProjError::FileNotFound`] instead of
551/// blocking on a network fetch.
552#[cfg(feature = "network")]
553fn fetch_from_cdn(name: &str) -> Option<Vec<u8>> {
554    if !oxiproj_grids::network_build_allowed() {
555        return None;
556    }
557    let dir = oxiproj_grids::GridDiskCache::default_dir().unwrap_or_else(std::env::temp_dir);
558    let mut resolver = oxiproj_grids::GridResolver::new(dir).ok()?;
559    if resolver.ensure(name).ok()? {
560        return resolver.get_loaded(name).map(<[u8]>::to_vec);
561    }
562    None
563}
564
565/// Determines how the interpolated band values are interpreted as shifts.
566#[derive(Debug)]
567enum ShiftKind {
568    /// 2-band NTv2: band0=lat-shift (arcsec), band1=lon-shift (arcsec).
569    HorizontalArcSec,
570    /// 1-band GTX / GeoTIFF vertical: band0=vertical shift (meters).
571    Vertical,
572    /// 2-band GeoTIFF horizontal offset applied via a semantics-aware
573    /// [`HShiftPlan`] (role/unit/positive from GDAL_METADATA), matching PROJ.
574    HorizontalGeoTiff,
575    /// GeoTIFF `TYPE=GEOGRAPHIC_3D_OFFSET`: a semantics-aware horizontal offset
576    /// ([`HShiftPlan`]) plus a vertical (`geoid_undulation`/`vertical_offset`/…)
577    /// band in metres, applied together in a single pass (PROJ `gridshift.cpp`).
578    Geographic3DOffset,
579    /// 3-band: band0=lon-shift (deg), band1=lat-shift (deg), band2=dz (m).
580    Xyz,
581}
582
583#[derive(Debug)]
584struct GridShift {
585    grids: Vec<GridSet>,
586    kind: ShiftKind,
587    /// Resolved horizontal-offset recipe; `Some` for
588    /// [`ShiftKind::HorizontalGeoTiff`] and [`ShiftKind::Geographic3DOffset`].
589    hplan: Option<HShiftPlan>,
590    /// Index of the vertical (metre) band; `Some` only for
591    /// [`ShiftKind::Geographic3DOffset`].
592    idx_z: Option<usize>,
593    /// Interpolation kernel (`+interpolation=bilinear|biquadratic`).
594    interpolation: Interpolation,
595    /// When set (`+no_z_transform`), the vertical component is left untouched.
596    no_z_transform: bool,
597}
598
599impl Operation for GridShift {
600    fn forward_4d(&self, c: Coord) -> ProjResult<Coord> {
601        let v = c.v();
602        // Empty grid list (all-optional grids absent): pass through unchanged,
603        // matching PROJ's "only try the gridshift if at least one grid is
604        // loaded" behavior.
605        if self.grids.is_empty() {
606            return Ok(c);
607        }
608        let lon_deg = v[0].to_degrees();
609        let lat_deg = v[1].to_degrees();
610        for gs in &self.grids {
611            if let Some(shifts) = sample_with(self.interpolation, gs, lat_deg, lon_deg) {
612                return match self.kind {
613                    ShiftKind::HorizontalArcSec => Ok(Coord::new(
614                        v[0] + shifts[1] * ARCSEC_TO_RAD,
615                        v[1] + shifts[0] * ARCSEC_TO_RAD,
616                        v[2],
617                        v[3],
618                    )),
619                    // The generic `gridshift` operator ADDS the vertical shift in
620                    // the forward direction (PROJ `gridshift.cpp`
621                    // `grid_apply_internal`: `out.z += shift.z`). This is the
622                    // OPPOSITE of the dedicated `vgridshift` operator (which
623                    // defaults `forward_multiplier = -1`); the two operators must
624                    // not be conflated.
625                    ShiftKind::Vertical => {
626                        let z = if self.no_z_transform {
627                            v[2]
628                        } else {
629                            v[2] + shifts[0]
630                        };
631                        Ok(Coord::new(v[0], v[1], z, v[3]))
632                    }
633                    ShiftKind::HorizontalGeoTiff => {
634                        let plan = self.hplan.as_ref().ok_or(ProjError::UnsupportedOperation)?;
635                        let (dlon, dlat) = geotiff_horizontal_offset(plan, &shifts);
636                        Ok(Coord::new(v[0] + dlon, v[1] + dlat, v[2], v[3]))
637                    }
638                    ShiftKind::Geographic3DOffset => {
639                        let plan = self.hplan.as_ref().ok_or(ProjError::UnsupportedOperation)?;
640                        let (dlon, dlat) = geotiff_horizontal_offset(plan, &shifts);
641                        let dz = self.geog3d_dz(&shifts);
642                        Ok(Coord::new(v[0] + dlon, v[1] + dlat, v[2] + dz, v[3]))
643                    }
644                    ShiftKind::Xyz => {
645                        let dz = if self.no_z_transform || shifts.len() < 3 {
646                            0.0
647                        } else {
648                            shifts[2]
649                        };
650                        Ok(Coord::new(
651                            v[0] + shifts[0].to_radians(),
652                            v[1] + shifts[1].to_radians(),
653                            v[2] + dz,
654                            v[3],
655                        ))
656                    }
657                };
658            }
659        }
660        Err(ProjError::OutsideGrid)
661    }
662
663    fn inverse_4d(&self, c: Coord) -> ProjResult<Coord> {
664        let v = c.v();
665        if self.grids.is_empty() {
666            return Ok(c);
667        }
668        match self.kind {
669            ShiftKind::Vertical => {
670                // No iteration needed for pure vertical shift. Inverse SUBTRACTS
671                // the grid value (mirror of the forward addition; PROJ
672                // `gridshift.cpp` `grid_apply_internal` with `isVerticalOnly`:
673                // `out.z -= shift.z`).
674                for gs in &self.grids {
675                    if let Some(shifts) =
676                        sample_with(self.interpolation, gs, v[1].to_degrees(), v[0].to_degrees())
677                    {
678                        let z = if self.no_z_transform {
679                            v[2]
680                        } else {
681                            v[2] - shifts[0]
682                        };
683                        return Ok(Coord::new(v[0], v[1], z, v[3]));
684                    }
685                }
686                Err(ProjError::OutsideGrid)
687            }
688            _ => {
689                // Iterative inversion for horizontal components.
690                let mut lon_r = v[0];
691                let mut lat_r = v[1];
692                'outer: for gs in &self.grids {
693                    if sample_with(
694                        self.interpolation,
695                        gs,
696                        lat_r.to_degrees(),
697                        lon_r.to_degrees(),
698                    )
699                    .is_none()
700                    {
701                        continue;
702                    }
703                    for _ in 0..MAX_ITER {
704                        let shifts = match sample_with(
705                            self.interpolation,
706                            gs,
707                            lat_r.to_degrees(),
708                            lon_r.to_degrees(),
709                        ) {
710                            Some(s) => s,
711                            None => break 'outer,
712                        };
713                        let (new_lon, new_lat) = match self.kind {
714                            ShiftKind::HorizontalArcSec => (
715                                v[0] - shifts[1] * ARCSEC_TO_RAD,
716                                v[1] - shifts[0] * ARCSEC_TO_RAD,
717                            ),
718                            ShiftKind::HorizontalGeoTiff | ShiftKind::Geographic3DOffset => {
719                                let plan =
720                                    self.hplan.as_ref().ok_or(ProjError::UnsupportedOperation)?;
721                                let (dlon, dlat) = geotiff_horizontal_offset(plan, &shifts);
722                                (v[0] - dlon, v[1] - dlat)
723                            }
724                            ShiftKind::Xyz => {
725                                (v[0] - shifts[0].to_radians(), v[1] - shifts[1].to_radians())
726                            }
727                            // Vertical is handled by the outer match arm above;
728                            // this arm is logically unreachable, but we return
729                            // the input coordinates unchanged rather than panicking.
730                            ShiftKind::Vertical => (v[0], v[1]),
731                        };
732                        let dlon = (new_lon - lon_r).abs();
733                        let dlat = (new_lat - lat_r).abs();
734                        lon_r = new_lon;
735                        lat_r = new_lat;
736                        if dlon < ITER_TOL && dlat < ITER_TOL {
737                            break;
738                        }
739                    }
740                    // Compute the dz correction for the kinds that carry one.
741                    if let Some(shifts) = sample_with(
742                        self.interpolation,
743                        gs,
744                        lat_r.to_degrees(),
745                        lon_r.to_degrees(),
746                    ) {
747                        let dz = match self.kind {
748                            ShiftKind::Xyz => {
749                                if self.no_z_transform || shifts.len() < 3 {
750                                    0.0
751                                } else {
752                                    shifts[2]
753                                }
754                            }
755                            ShiftKind::Geographic3DOffset => self.geog3d_dz(&shifts),
756                            _ => 0.0,
757                        };
758                        return Ok(Coord::new(lon_r, lat_r, v[2] - dz, v[3]));
759                    }
760                    return Ok(Coord::new(lon_r, lat_r, v[2], v[3]));
761                }
762                Err(ProjError::OutsideGrid)
763            }
764        }
765    }
766
767    fn has_inverse(&self) -> bool {
768        true
769    }
770}
771
772impl GridShift {
773    /// Vertical (metre) shift for a [`ShiftKind::Geographic3DOffset`] grid: the
774    /// value of the resolved `idx_z` band, honoring `+no_z_transform`.
775    fn geog3d_dz(&self, shifts: &[f64]) -> f64 {
776        if self.no_z_transform {
777            return 0.0;
778        }
779        match self.idx_z {
780            Some(i) => shifts.get(i).copied().unwrap_or(0.0),
781            None => 0.0,
782        }
783    }
784}
785
786/// Parse `+interpolation=` into an [`Interpolation`], returning `None` when the
787/// parameter is absent (so the grid's own `interpolation_method` metadata, then
788/// the bilinear default, can apply) and erroring on unsupported values exactly
789/// as PROJ `gridshift.cpp` does.
790fn parse_interpolation(p: &TransParams) -> ProjResult<Option<Interpolation>> {
791    match p.params.get_str("interpolation") {
792        None => Ok(None),
793        Some("bilinear") => Ok(Some(Interpolation::Bilinear)),
794        Some("biquadratic") => Ok(Some(Interpolation::Biquadratic)),
795        Some(_) => Err(ProjError::IllegalArgValue),
796    }
797}
798
799/// Interpret a grid-level `interpolation_method` GDAL_METADATA string into an
800/// [`Interpolation`], mirroring PROJ's fallback (`bilinear` when empty; error on
801/// any other value).
802fn interpolation_from_metadata(method: Option<&str>) -> ProjResult<Interpolation> {
803    match method {
804        None | Some("") | Some("bilinear") => Ok(Interpolation::Bilinear),
805        Some("biquadratic") => Ok(Interpolation::Biquadratic),
806        Some(_) => Err(ProjError::IllegalArgValue),
807    }
808}
809
810/// Build a [`TransBuild`] wrapping a radians-in/radians-out [`GridShift`].
811fn build(shift: GridShift) -> TransBuild {
812    TransBuild::new(Box::new(shift), IoUnits::Radians, IoUnits::Radians)
813}
814
815/// Construct a `gridshift` transform from parsed parameters.
816///
817/// Splits `+grids=` on commas (honoring the per-entry `@` optional prefix),
818/// resolves each grid, and determines the shift kind. For GeoTIFF grids the
819/// grid-level `TYPE` GDAL_METADATA item drives dispatch
820/// (`HORIZONTAL_OFFSET` / `GEOGRAPHIC_3D_OFFSET` /
821/// `VERTICAL_OFFSET_GEOGRAPHIC_TO_VERTICAL` / `VERTICAL_OFFSET_VERTICAL_TO_VERTICAL`
822/// / `ELLIPSOIDAL_HEIGHT_OFFSET`), matching PROJ `gridshift.cpp`'s
823/// `checkGridTypes`; when `TYPE` is absent the band-count heuristic (NTv2 → 2-band
824/// horizontal, 1-band → vertical, 3+ → xyz) is used. `+interpolation=` and
825/// `+no_z_transform` are honored.
826pub fn new(p: &TransParams) -> ProjResult<TransBuild> {
827    let grid_name = p.params.get_str("grids").ok_or(ProjError::MissingArg)?;
828    let interp_param = parse_interpolation(p)?;
829    let no_z_transform = p.params.get_bool("no_z_transform");
830
831    let resolved = resolve_grid_list(p.registry, grid_name)?;
832    // All-optional list whose entries all failed: pass-through transform.
833    if resolved.is_empty() {
834        return Ok(build(GridShift {
835            grids: Vec::new(),
836            kind: ShiftKind::Vertical,
837            hplan: None,
838            idx_z: None,
839            interpolation: interp_param.unwrap_or(Interpolation::Bilinear),
840            no_z_transform,
841        }));
842    }
843
844    // Determine the format from the first resolved grid; every entry in a
845    // fallback list is parsed the same way (PROJ requires a homogeneous list).
846    let (first_name, first_bytes) = &resolved[0];
847
848    if is_tiff(first_bytes) {
849        return build_geotiff_dispatch(&resolved, interp_param, no_z_transform);
850    }
851
852    // Non-GeoTIFF grids carry no interpolation_method metadata: honor the
853    // explicit +interpolation, else default bilinear.
854    let interpolation = interp_param.unwrap_or(Interpolation::Bilinear);
855
856    // NTv2 (horizontal). Flatten every sub-grid of every entry into the
857    // fallback list.
858    if let Ok(grids) = read_ntv2(first_bytes, first_name) {
859        if !grids.is_empty() && grids[0].bands.len() >= 2 {
860            let mut all = grids;
861            for (name, bytes) in &resolved[1..] {
862                let more = read_ntv2(bytes, name)?;
863                all.extend(more);
864            }
865            return Ok(build(GridShift {
866                grids: all,
867                kind: ShiftKind::HorizontalArcSec,
868                hplan: None,
869                idx_z: None,
870                interpolation,
871                no_z_transform,
872            }));
873        }
874    }
875
876    // GTX (vertical).
877    if let Ok(gs) = read_gtx(first_bytes, first_name) {
878        if gs.bands.len() == 1 {
879            let mut all = vec![gs];
880            for (name, bytes) in &resolved[1..] {
881                all.push(read_gtx(bytes, name)?);
882            }
883            return Ok(build(GridShift {
884                grids: all,
885                kind: ShiftKind::Vertical,
886                hplan: None,
887                idx_z: None,
888                interpolation,
889                no_z_transform,
890            }));
891        }
892    }
893
894    Err(ProjError::FileNotFound)
895}
896
897/// Dispatch a GeoTIFF `+grids=` list: read the grid-level `TYPE` metadata of the
898/// first grid and build the corresponding [`ShiftKind`], parsing every entry the
899/// same way. When `TYPE` is absent, fall back to the band-count heuristic.
900///
901/// The interpolation kernel is the explicit `+interpolation=` when given,
902/// otherwise the grid's own `interpolation_method` GDAL_METADATA item, otherwise
903/// bilinear — matching PROJ `gridshift.cpp`'s `grid_interpolate`.
904fn build_geotiff_dispatch(
905    resolved: &[(String, Vec<u8>)],
906    interp_param: Option<Interpolation>,
907    no_z_transform: bool,
908) -> ProjResult<TransBuild> {
909    let (first_name, first_bytes) = &resolved[0];
910    let first_gs = read_geotiff(first_bytes, first_name)?;
911    let md = read_geotiff_gdal_metadata(first_bytes)?;
912    let type_meta = md.as_ref().and_then(|m| m.get("TYPE").map(str::to_string));
913    let interpolation = match interp_param {
914        Some(i) => i,
915        None => {
916            interpolation_from_metadata(md.as_ref().and_then(|m| m.get("interpolation_method")))?
917        }
918    };
919
920    // Helper: expand every `+grids=` entry into its full parent/child sub-grid
921    // hierarchy (`read_geotiff_hierarchy` — every georeferenced IFD, ordered
922    // child-first) and apply `map` to each resulting grid. A multi-IFD GeoTIFF
923    // (e.g. de_geosn_NTv2_SN.tif: coarse RD83 parent + densified SN_* children)
924    // therefore contributes every sub-grid, finest-first, so the
925    // first-containing-grid-wins scan selects the finest sub-grid covering a
926    // point — matching PROJ's `HorizontalShiftGrid::gridAt` recursion.
927    let expand = |map: &dyn Fn(GridSet) -> ProjResult<GridSet>| -> ProjResult<Vec<GridSet>> {
928        let mut all = Vec::new();
929        for (name, bytes) in resolved {
930            for gs in read_geotiff_hierarchy(bytes, name)? {
931                all.push(map(gs)?);
932            }
933        }
934        Ok(all)
935    };
936
937    match type_meta.as_deref() {
938        Some("HORIZONTAL_OFFSET") => {
939            let plan = resolve_hshift_plan(&first_gs).ok_or(ProjError::UnsupportedOperation)?;
940            let grids = expand(&|gs| Ok(gs))?;
941            Ok(build(GridShift {
942                grids,
943                kind: ShiftKind::HorizontalGeoTiff,
944                hplan: Some(plan),
945                idx_z: None,
946                interpolation,
947                no_z_transform,
948            }))
949        }
950        Some("GEOGRAPHIC_3D_OFFSET") => {
951            let plan = resolve_hshift_plan(&first_gs).ok_or(ProjError::UnsupportedOperation)?;
952            let idx_z = resolve_vertical_band(&first_gs).ok_or(ProjError::FileNotFound)?;
953            let grids = expand(&|gs| Ok(gs))?;
954            Ok(build(GridShift {
955                grids,
956                kind: ShiftKind::Geographic3DOffset,
957                hplan: Some(plan),
958                idx_z: Some(idx_z),
959                interpolation,
960                no_z_transform,
961            }))
962        }
963        Some("VERTICAL_OFFSET_GEOGRAPHIC_TO_VERTICAL")
964        | Some("VERTICAL_OFFSET_VERTICAL_TO_VERTICAL")
965        | Some("ELLIPSOIDAL_HEIGHT_OFFSET") => {
966            let grids = expand(&|gs| vertical_gridset(gs))?;
967            Ok(build(GridShift {
968                grids,
969                kind: ShiftKind::Vertical,
970                hplan: None,
971                idx_z: None,
972                interpolation,
973                no_z_transform,
974            }))
975        }
976        // An explicit but unhandled TYPE (e.g. VELOCITY, which PROJ routes to
977        // the deformation model, not generic gridshift) is an error, matching
978        // PROJ's "Unhandled value for TYPE metadata item".
979        Some(_) => Err(ProjError::UnsupportedOperation),
980        // No TYPE metadata: fall back to the band-count heuristic of IFD0.
981        None => match first_gs.bands.len() {
982            2 => {
983                let plan = resolve_hshift_plan(&first_gs).ok_or(ProjError::UnsupportedOperation)?;
984                let grids = expand(&|gs| Ok(gs))?;
985                Ok(build(GridShift {
986                    grids,
987                    kind: ShiftKind::HorizontalGeoTiff,
988                    hplan: Some(plan),
989                    idx_z: None,
990                    interpolation,
991                    no_z_transform,
992                }))
993            }
994            1 => {
995                let grids = expand(&|gs| Ok(gs))?;
996                Ok(build(GridShift {
997                    grids,
998                    kind: ShiftKind::Vertical,
999                    hplan: None,
1000                    idx_z: None,
1001                    interpolation,
1002                    no_z_transform,
1003                }))
1004            }
1005            _ => {
1006                let grids = expand(&|gs| Ok(gs))?;
1007                Ok(build(GridShift {
1008                    grids,
1009                    kind: ShiftKind::Xyz,
1010                    hplan: None,
1011                    idx_z: None,
1012                    interpolation,
1013                    no_z_transform,
1014                }))
1015            }
1016        },
1017    }
1018}
1019
1020#[cfg(test)]
1021mod tests {
1022    use super::*;
1023    use oxiproj_core::DEG_TO_RAD;
1024
1025    fn build_ntv2(lat_shift: f32, lon_shift: f32) -> Vec<u8> {
1026        // Minimal 2×2 NTv2 covering lat 0-1°, lon -1-0°
1027        let mut buf = Vec::new();
1028        buf.extend_from_slice(b"NUM_OREC");
1029        buf.extend_from_slice(&11i32.to_le_bytes());
1030        buf.extend_from_slice(&[0u8; 4]);
1031        buf.extend_from_slice(b"NUM_SREC");
1032        buf.extend_from_slice(&11i32.to_le_bytes());
1033        buf.extend_from_slice(&[0u8; 4]);
1034        buf.extend_from_slice(b"NUM_FILE");
1035        buf.extend_from_slice(&1u32.to_le_bytes());
1036        buf.extend_from_slice(&[0u8; 4]);
1037        buf.extend_from_slice(b"GS_TYPE ");
1038        buf.extend_from_slice(b"SECONDS ");
1039        buf.extend_from_slice(&[0u8; 112]);
1040        buf.extend_from_slice(b"SUB_NAME");
1041        buf.extend_from_slice(b"TESTGRID");
1042        buf.extend_from_slice(b"PARENT  ");
1043        buf.extend_from_slice(b"NONE    ");
1044        buf.extend_from_slice(b"CREATED ");
1045        buf.extend_from_slice(b"20240101");
1046        buf.extend_from_slice(b"UPDATED ");
1047        buf.extend_from_slice(b"20240101");
1048        buf.extend_from_slice(b"S_LAT   ");
1049        buf.extend_from_slice(&0.0f64.to_le_bytes());
1050        buf.extend_from_slice(b"N_LAT   ");
1051        buf.extend_from_slice(&3600.0f64.to_le_bytes());
1052        buf.extend_from_slice(b"E_LONG  ");
1053        buf.extend_from_slice(&0.0f64.to_le_bytes());
1054        buf.extend_from_slice(b"W_LONG  ");
1055        buf.extend_from_slice(&3600.0f64.to_le_bytes());
1056        buf.extend_from_slice(b"LAT_INC ");
1057        buf.extend_from_slice(&3600.0f64.to_le_bytes());
1058        buf.extend_from_slice(b"LONG_INC");
1059        buf.extend_from_slice(&3600.0f64.to_le_bytes());
1060        buf.extend_from_slice(b"GS_COUNT");
1061        buf.extend_from_slice(&4i32.to_le_bytes());
1062        buf.extend_from_slice(&[0u8; 4]);
1063        for _ in 0..4 {
1064            buf.extend_from_slice(&lat_shift.to_le_bytes());
1065            buf.extend_from_slice(&lon_shift.to_le_bytes());
1066            buf.extend_from_slice(&0.0f32.to_le_bytes());
1067            buf.extend_from_slice(&0.0f32.to_le_bytes());
1068        }
1069        buf
1070    }
1071
1072    fn build_gtx(shift: f32) -> Vec<u8> {
1073        let mut buf = Vec::new();
1074        buf.extend_from_slice(&0.0f64.to_be_bytes()); // south_lat=0
1075        buf.extend_from_slice(&0.0f64.to_be_bytes()); // west_lon=0
1076        buf.extend_from_slice(&1.0f64.to_be_bytes()); // lat_inc=1
1077        buf.extend_from_slice(&1.0f64.to_be_bytes()); // lon_inc=1
1078        buf.extend_from_slice(&2i32.to_be_bytes()); // rows=2
1079        buf.extend_from_slice(&2i32.to_be_bytes()); // cols=2
1080        for _ in 0..4 {
1081            buf.extend_from_slice(&shift.to_be_bytes());
1082        }
1083        buf
1084    }
1085
1086    struct InMemReg(std::collections::HashMap<String, Vec<u8>>);
1087    impl crate::GridRegistry for InMemReg {
1088        fn get_grid(&self, name: &str) -> Option<&[u8]> {
1089            self.0.get(name).map(|v| v.as_slice())
1090        }
1091    }
1092
1093    struct TestParams {
1094        grids: String,
1095    }
1096    impl crate::TransParamLookup for TestParams {
1097        fn get_str(&self, key: &str) -> Option<&str> {
1098            if key == "grids" {
1099                Some(&self.grids)
1100            } else {
1101                None
1102            }
1103        }
1104        fn get_f64(&self, _: &str) -> Option<f64> {
1105            None
1106        }
1107        fn get_dms(&self, _: &str) -> Option<f64> {
1108            None
1109        }
1110        fn get_int(&self, _: &str) -> Option<i64> {
1111            None
1112        }
1113        fn get_bool(&self, _: &str) -> bool {
1114            false
1115        }
1116        fn exists(&self, key: &str) -> bool {
1117            key == "grids"
1118        }
1119    }
1120
1121    #[test]
1122    fn test_gridshift_detects_ntv2() {
1123        // NTv2 grid covers lat 0-1°, lon -1-0° (NTv2 positive-west convention)
1124        let data = build_ntv2(1800.0, 900.0);
1125        let mut map = std::collections::HashMap::new();
1126        map.insert("grid.gsb".to_string(), data);
1127        let reg = InMemReg(map);
1128        let tp = TestParams {
1129            grids: "grid.gsb".to_string(),
1130        };
1131        let ell = oxiproj_core::Ellipsoid::named("WGS84").unwrap();
1132        let p = crate::TransParams {
1133            ellipsoid: &ell,
1134            params: &tp,
1135            registry: Some(&reg),
1136        };
1137        let tb = new(&p).unwrap();
1138        // Point inside the grid: lat=0.5°, lon=-0.5°
1139        let input = Coord::new(-0.5 * DEG_TO_RAD, 0.5 * DEG_TO_RAD, 0.0, 0.0);
1140        let out = tb.operation.forward_4d(input).unwrap();
1141        // NTv2 lat_shift=1800 arcsec → +0.5 deg (positive-north).
1142        // Raw lon_shift=900 arcsec is positive-WEST; read_ntv2 negates it to
1143        // east-positive (-0.25 deg), so lon moves WEST by 0.25 deg.
1144        let expected_lat = (0.5 + 0.5) * DEG_TO_RAD;
1145        let expected_lon = (-0.5 - 0.25) * DEG_TO_RAD;
1146        assert!((out.v()[1] - expected_lat).abs() < 1e-9, "lat");
1147        assert!((out.v()[0] - expected_lon).abs() < 1e-9, "lon");
1148    }
1149
1150    #[test]
1151    fn test_gridshift_detects_gtx() {
1152        // GTX grid covers lat 0-1°, lon 0-1°
1153        let data = build_gtx(5.0);
1154        let mut map = std::collections::HashMap::new();
1155        map.insert("grid.gtx".to_string(), data);
1156        let reg = InMemReg(map);
1157        let tp = TestParams {
1158            grids: "grid.gtx".to_string(),
1159        };
1160        let ell = oxiproj_core::Ellipsoid::named("WGS84").unwrap();
1161        let p = crate::TransParams {
1162            ellipsoid: &ell,
1163            params: &tp,
1164            registry: Some(&reg),
1165        };
1166        let tb = new(&p).unwrap();
1167        let input = Coord::new(0.5 * DEG_TO_RAD, 0.5 * DEG_TO_RAD, 0.0, 0.0);
1168        let out = tb.operation.forward_4d(input).unwrap();
1169        // The generic `gridshift` operator ADDS the vertical grid value in the
1170        // forward direction (PROJ `gridshift.cpp` `out.z += shift.z`), which is
1171        // the OPPOSITE of the dedicated `vgridshift` operator.
1172        assert!(
1173            (out.v()[2] - 5.0).abs() < 1e-6,
1174            "z should be +5.0 (generic gridshift forward adds), got {}",
1175            out.v()[2]
1176        );
1177    }
1178
1179    #[test]
1180    fn resolve_grid_bytes_registry_first_then_file_not_found() {
1181        // E4: registered in-memory bytes win (registry-first), and a grid
1182        // present nowhere maps to FileNotFound. The name is unique enough that
1183        // no local resource directory can hold it.
1184        let mut map = std::collections::HashMap::new();
1185        map.insert("reg.gsb".to_string(), vec![1u8, 2, 3]);
1186        let reg = InMemReg(map);
1187        assert_eq!(
1188            resolve_grid_bytes(Some(&reg), "reg.gsb").unwrap(),
1189            vec![1u8, 2, 3]
1190        );
1191        assert_eq!(
1192            resolve_grid_bytes(Some(&reg), "e4_absent_grid_9d3f7a.gsb").err(),
1193            Some(ProjError::FileNotFound)
1194        );
1195        assert_eq!(
1196            resolve_grid_bytes(None, "e4_absent_grid_9d3f7a.gsb").err(),
1197            Some(ProjError::FileNotFound)
1198        );
1199    }
1200
1201    #[test]
1202    fn test_gridshift_round_trip_ntv2() {
1203        // NTv2 grid covers lat 0-1°, lon -1-0°
1204        let data = build_ntv2(1800.0, 900.0);
1205        let mut map = std::collections::HashMap::new();
1206        map.insert("grid.gsb".to_string(), data);
1207        let reg = InMemReg(map);
1208        let tp = TestParams {
1209            grids: "grid.gsb".to_string(),
1210        };
1211        let ell = oxiproj_core::Ellipsoid::named("WGS84").unwrap();
1212        let p = crate::TransParams {
1213            ellipsoid: &ell,
1214            params: &tp,
1215            registry: Some(&reg),
1216        };
1217        let tb = new(&p).unwrap();
1218        // Point inside the grid: lat=0.4°, lon=-0.7°
1219        let input = Coord::new(-0.7 * DEG_TO_RAD, 0.4 * DEG_TO_RAD, 0.0, 0.0);
1220        let fwd = tb.operation.forward_4d(input).unwrap();
1221        let inv = tb.operation.inverse_4d(fwd).unwrap();
1222        let vi = inv.v();
1223        let vi0 = input.v();
1224        assert!((vi[0] - vi0[0]).abs() < 1e-9, "lon round-trip");
1225        assert!((vi[1] - vi0[1]).abs() < 1e-9, "lat round-trip");
1226    }
1227
1228    // ------------------------------------------------------------------
1229    // Semantics-aware GeoTIFF horizontal-offset application (offline; no
1230    // PROJ_DATA / real grid required).
1231    // ------------------------------------------------------------------
1232
1233    use oxiproj_grids::{BandSemantics, GridBand, GridExtent, GridSource};
1234
1235    /// Build a uniform 2-band GeoTIFF-style horizontal-offset grid covering
1236    /// lat [39,41], lon [-81,-79], with the given band semantics/values.
1237    fn geotiff_hshift_gridset(
1238        lat_role: BandRole,
1239        lat_unit: BandUnit,
1240        lat_val: f32,
1241        lon_role: BandRole,
1242        lon_unit: BandUnit,
1243        lon_positive: BandPositive,
1244        lon_val: f32,
1245    ) -> GridSet {
1246        let extent = GridExtent {
1247            ll_lat: 39.0,
1248            ll_lon: -81.0,
1249            ur_lat: 41.0,
1250            ur_lon: -79.0,
1251            lat_inc: 2.0,
1252            lon_inc: 2.0,
1253        };
1254        let band0 = GridBand {
1255            extent: extent.clone(),
1256            values: vec![lat_val; 4],
1257            semantics: BandSemantics {
1258                role: lat_role,
1259                unit: lat_unit,
1260                positive: BandPositive::Unknown,
1261            },
1262        };
1263        let band1 = GridBand {
1264            extent,
1265            values: vec![lon_val; 4],
1266            semantics: BandSemantics {
1267                role: lon_role,
1268                unit: lon_unit,
1269                positive: lon_positive,
1270            },
1271        };
1272        GridSet {
1273            bands: vec![band0, band1],
1274            source: GridSource::GeoTiff {
1275                path: "mem.tif".into(),
1276            },
1277        }
1278    }
1279
1280    #[test]
1281    fn geotiff_hshift_no_band_swap_arcsec_positive_east() {
1282        // band0=latitude_offset (+3600 arcsec = +1°), band1=longitude_offset
1283        // (+3600 arcsec = +1°, positive east). At (-80,40): lat→41, lon→-79.
1284        let gs = geotiff_hshift_gridset(
1285            BandRole::LatitudeOffset,
1286            BandUnit::ArcSecond,
1287            3600.0,
1288            BandRole::LongitudeOffset,
1289            BandUnit::ArcSecond,
1290            BandPositive::East,
1291            3600.0,
1292        );
1293        let tb = build_horizontal_geotiff(gs).unwrap();
1294        let out = tb
1295            .operation
1296            .forward_4d(Coord::new(-80.0 * DEG_TO_RAD, 40.0 * DEG_TO_RAD, 0.0, 0.0))
1297            .unwrap();
1298        let v = out.v();
1299        assert!(
1300            (v[1].to_degrees() - 41.0).abs() < 1e-9,
1301            "lat += band0 (no swap): {}",
1302            v[1].to_degrees()
1303        );
1304        assert!(
1305            (v[0].to_degrees() - (-79.0)).abs() < 1e-9,
1306            "lon += band1 east: {}",
1307            v[0].to_degrees()
1308        );
1309    }
1310
1311    #[test]
1312    fn geotiff_hshift_positive_west_negates_longitude() {
1313        // Same magnitudes, but longitude positive=west must subtract: lon→-81.
1314        let gs = geotiff_hshift_gridset(
1315            BandRole::LatitudeOffset,
1316            BandUnit::ArcSecond,
1317            0.0,
1318            BandRole::LongitudeOffset,
1319            BandUnit::ArcSecond,
1320            BandPositive::West,
1321            3600.0,
1322        );
1323        let tb = build_horizontal_geotiff(gs).unwrap();
1324        let out = tb
1325            .operation
1326            .forward_4d(Coord::new(-80.0 * DEG_TO_RAD, 40.0 * DEG_TO_RAD, 0.0, 0.0))
1327            .unwrap();
1328        let v = out.v();
1329        assert!(
1330            (v[0].to_degrees() - (-81.0)).abs() < 1e-9,
1331            "lon -= band1 (positive west): {}",
1332            v[0].to_degrees()
1333        );
1334    }
1335
1336    #[test]
1337    fn geotiff_hshift_degree_unit_and_default_band_order() {
1338        // No DESCRIPTION roles (Unknown) => PROJ NTv2-inspired default:
1339        // band0=lat, band1=lon. Degree unit: +0.5° each. At (-80,40):
1340        // lat→40.5, lon→-79.5.
1341        let gs = geotiff_hshift_gridset(
1342            BandRole::Unknown,
1343            BandUnit::Degree,
1344            0.5,
1345            BandRole::Unknown,
1346            BandUnit::Degree,
1347            BandPositive::Unknown,
1348            0.5,
1349        );
1350        let tb = build_horizontal_geotiff(gs).unwrap();
1351        let out = tb
1352            .operation
1353            .forward_4d(Coord::new(-80.0 * DEG_TO_RAD, 40.0 * DEG_TO_RAD, 0.0, 0.0))
1354            .unwrap();
1355        let v = out.v();
1356        assert!((v[1].to_degrees() - 40.5).abs() < 1e-9, "lat default band0");
1357        assert!(
1358            (v[0].to_degrees() - (-79.5)).abs() < 1e-9,
1359            "lon default band1"
1360        );
1361    }
1362
1363    #[test]
1364    fn geotiff_hshift_round_trips() {
1365        let gs = geotiff_hshift_gridset(
1366            BandRole::LatitudeOffset,
1367            BandUnit::ArcSecond,
1368            120.0,
1369            BandRole::LongitudeOffset,
1370            BandUnit::ArcSecond,
1371            BandPositive::East,
1372            -240.0,
1373        );
1374        let tb = build_horizontal_geotiff(gs).unwrap();
1375        let input = Coord::new(-80.0 * DEG_TO_RAD, 40.0 * DEG_TO_RAD, 7.0, 0.0);
1376        let fwd = tb.operation.forward_4d(input).unwrap();
1377        let inv = tb.operation.inverse_4d(fwd).unwrap();
1378        assert!((inv.v()[0] - input.v()[0]).abs() < 1e-11, "lon round-trip");
1379        assert!((inv.v()[1] - input.v()[1]).abs() < 1e-11, "lat round-trip");
1380    }
1381
1382    #[test]
1383    fn resolve_hshift_plan_rejects_single_offset_channel() {
1384        // Only latitude_offset described (no longitude_offset) => None.
1385        let gs = geotiff_hshift_gridset(
1386            BandRole::LatitudeOffset,
1387            BandUnit::ArcSecond,
1388            1.0,
1389            BandRole::Unknown,
1390            BandUnit::ArcSecond,
1391            BandPositive::Unknown,
1392            1.0,
1393        );
1394        assert!(resolve_hshift_plan(&gs).is_none());
1395    }
1396
1397    // ------------------------------------------------------------------
1398    // +interpolation / biquadratic sampler
1399    // ------------------------------------------------------------------
1400
1401    struct InterpParams {
1402        interpolation: Option<String>,
1403        no_z: bool,
1404    }
1405    impl crate::TransParamLookup for InterpParams {
1406        fn get_str(&self, key: &str) -> Option<&str> {
1407            match key {
1408                "grids" => Some("grid.gtx"),
1409                "interpolation" => self.interpolation.as_deref(),
1410                _ => None,
1411            }
1412        }
1413        fn get_f64(&self, _: &str) -> Option<f64> {
1414            None
1415        }
1416        fn get_dms(&self, _: &str) -> Option<f64> {
1417            None
1418        }
1419        fn get_int(&self, _: &str) -> Option<i64> {
1420            None
1421        }
1422        fn get_bool(&self, key: &str) -> bool {
1423            key == "no_z_transform" && self.no_z
1424        }
1425        fn exists(&self, key: &str) -> bool {
1426            key == "grids"
1427                || (key == "interpolation" && self.interpolation.is_some())
1428                || (key == "no_z_transform" && self.no_z)
1429        }
1430    }
1431
1432    fn interp_of(s: Option<&str>) -> ProjResult<Option<Interpolation>> {
1433        let ell = oxiproj_core::Ellipsoid::named("WGS84").unwrap();
1434        let tp = InterpParams {
1435            interpolation: s.map(str::to_string),
1436            no_z: false,
1437        };
1438        let p = TransParams {
1439            ellipsoid: &ell,
1440            params: &tp,
1441            registry: None,
1442        };
1443        parse_interpolation(&p)
1444    }
1445
1446    #[test]
1447    fn parse_interpolation_maps_and_rejects() {
1448        // Absent => None (so grid metadata / bilinear default can apply).
1449        assert_eq!(interp_of(None).unwrap(), None);
1450        assert_eq!(
1451            interp_of(Some("bilinear")).unwrap(),
1452            Some(Interpolation::Bilinear)
1453        );
1454        assert_eq!(
1455            interp_of(Some("biquadratic")).unwrap(),
1456            Some(Interpolation::Biquadratic)
1457        );
1458        assert_eq!(
1459            interp_of(Some("bicubic")).err(),
1460            Some(ProjError::IllegalArgValue)
1461        );
1462    }
1463
1464    #[test]
1465    fn interpolation_from_metadata_maps_and_rejects() {
1466        assert_eq!(
1467            interpolation_from_metadata(None).unwrap(),
1468            Interpolation::Bilinear
1469        );
1470        assert_eq!(
1471            interpolation_from_metadata(Some("bilinear")).unwrap(),
1472            Interpolation::Bilinear
1473        );
1474        assert_eq!(
1475            interpolation_from_metadata(Some("biquadratic")).unwrap(),
1476            Interpolation::Biquadratic
1477        );
1478        assert_eq!(
1479            interpolation_from_metadata(Some("nearest")).err(),
1480            Some(ProjError::IllegalArgValue)
1481        );
1482    }
1483
1484    /// Build a single-band 5×5 grid over lon[0,4], lat[0,4] (1° spacing) whose
1485    /// value follows a separable quadratic `g(col, south_row)`; biquadratic
1486    /// interpolation must then reproduce `g` exactly at fractional positions.
1487    fn quadratic_gridset() -> GridSet {
1488        use oxiproj_grids::{BandSemantics, GridBand, GridExtent, GridSource};
1489        let n = 5usize;
1490        let g = |col: f64, srow: f64| {
1491            1.0 + 2.0 * col - 3.0 * srow + 0.5 * col * col + 0.25 * srow * srow
1492        };
1493        let mut values = Vec::with_capacity(n * n);
1494        for north_row in 0..n {
1495            let srow = (n - 1 - north_row) as f64;
1496            for col in 0..n {
1497                values.push(g(col as f64, srow) as f32);
1498            }
1499        }
1500        let extent = GridExtent {
1501            ll_lat: 0.0,
1502            ll_lon: 0.0,
1503            ur_lat: (n - 1) as f64,
1504            ur_lon: (n - 1) as f64,
1505            lat_inc: 1.0,
1506            lon_inc: 1.0,
1507        };
1508        GridSet {
1509            bands: vec![GridBand {
1510                extent,
1511                values,
1512                semantics: BandSemantics::default(),
1513            }],
1514            source: GridSource::Memory,
1515        }
1516    }
1517
1518    #[test]
1519    fn biquadratic_reproduces_quadratic_exactly() {
1520        let gs = quadratic_gridset();
1521        let g = |col: f64, srow: f64| {
1522            1.0 + 2.0 * col - 3.0 * srow + 0.5 * col * col + 0.25 * srow * srow
1523        };
1524        // Query at (lon=2.3, lat=1.7) => col=2.3, south_row=1.7.
1525        let got = sample_grid_biquadratic(&gs, 1.7, 2.3).unwrap()[0];
1526        let expected = g(2.3, 1.7);
1527        assert!(
1528            (got - expected).abs() < 1e-6,
1529            "biquadratic exact for quadratic: got {got}, expected {expected}"
1530        );
1531        // Bilinear does NOT reproduce a curved quadratic — sanity that the two
1532        // kernels actually differ here.
1533        let bilinear = sample_grid(&gs, 1.7, 2.3).unwrap()[0];
1534        assert!(
1535            (bilinear - expected).abs() > 1e-3,
1536            "bilinear should differ from the quadratic truth"
1537        );
1538    }
1539
1540    #[test]
1541    fn biquadratic_falls_back_to_bilinear_for_small_grids() {
1542        use oxiproj_grids::{BandSemantics, GridBand, GridExtent, GridSource};
1543        // 2×2 grid: too small for a 3×3 window, must match bilinear.
1544        let extent = GridExtent {
1545            ll_lat: 0.0,
1546            ll_lon: 0.0,
1547            ur_lat: 1.0,
1548            ur_lon: 1.0,
1549            lat_inc: 1.0,
1550            lon_inc: 1.0,
1551        };
1552        let gs = GridSet {
1553            bands: vec![GridBand {
1554                extent,
1555                values: vec![1.0, 2.0, 3.0, 4.0],
1556                semantics: BandSemantics::default(),
1557            }],
1558            source: GridSource::Memory,
1559        };
1560        let bq = sample_grid_biquadratic(&gs, 0.5, 0.5).unwrap()[0];
1561        let bl = sample_grid(&gs, 0.5, 0.5).unwrap()[0];
1562        assert!(
1563            (bq - bl).abs() < 1e-12,
1564            "2×2 biquadratic falls back to bilinear"
1565        );
1566    }
1567
1568    // ------------------------------------------------------------------
1569    // resolve_grid_list (@-optional comma fallback)
1570    // ------------------------------------------------------------------
1571
1572    #[test]
1573    fn resolve_grid_list_honors_optional_prefix_and_order() {
1574        let mut map = std::collections::HashMap::new();
1575        map.insert("a.gsb".to_string(), vec![1u8]);
1576        map.insert("b.gsb".to_string(), vec![2u8]);
1577        let reg = InMemReg(map);
1578
1579        // Optional-missing then present: skips the first, keeps the second.
1580        let list = resolve_grid_list(Some(&reg), "@missing.gsb,b.gsb").unwrap();
1581        assert_eq!(list.len(), 1);
1582        assert_eq!(list[0].0, "b.gsb");
1583
1584        // Order preserved for two present grids.
1585        let list = resolve_grid_list(Some(&reg), "a.gsb,b.gsb").unwrap();
1586        assert_eq!(
1587            list.iter().map(|(n, _)| n.as_str()).collect::<Vec<_>>(),
1588            ["a.gsb", "b.gsb"]
1589        );
1590
1591        // Required missing aborts.
1592        assert_eq!(
1593            resolve_grid_list(Some(&reg), "a.gsb,missing.gsb").err(),
1594            Some(ProjError::FileNotFound)
1595        );
1596
1597        // All-optional-absent => empty list (pass-through).
1598        assert!(resolve_grid_list(Some(&reg), "@x.gsb,@y.gsb")
1599            .unwrap()
1600            .is_empty());
1601    }
1602
1603    // ------------------------------------------------------------------
1604    // Vertical band selection
1605    // ------------------------------------------------------------------
1606
1607    #[test]
1608    fn resolve_vertical_band_prefers_geoid_role() {
1609        use oxiproj_grids::{BandSemantics, GridBand, GridExtent, GridSource};
1610        let extent = GridExtent {
1611            ll_lat: 0.0,
1612            ll_lon: 0.0,
1613            ur_lat: 1.0,
1614            ur_lon: 1.0,
1615            lat_inc: 1.0,
1616            lon_inc: 1.0,
1617        };
1618        let band = |role: BandRole| GridBand {
1619            extent: extent.clone(),
1620            values: vec![0.0; 4],
1621            semantics: BandSemantics {
1622                role,
1623                unit: BandUnit::Metre,
1624                positive: BandPositive::Unknown,
1625            },
1626        };
1627        // Band 1 is the geoid channel => selected and swapped to index 0.
1628        let gs = GridSet {
1629            bands: vec![band(BandRole::Unknown), band(BandRole::GeoidUndulation)],
1630            source: GridSource::Memory,
1631        };
1632        assert_eq!(resolve_vertical_band(&gs), Some(1));
1633        let reduced = vertical_gridset(gs).unwrap();
1634        assert_eq!(reduced.bands.len(), 1);
1635        assert_eq!(reduced.bands[0].semantics.role, BandRole::GeoidUndulation);
1636
1637        // No described vertical channel => default to band 0.
1638        let gs2 = GridSet {
1639            bands: vec![band(BandRole::Unknown)],
1640            source: GridSource::Memory,
1641        };
1642        assert_eq!(resolve_vertical_band(&gs2), Some(0));
1643    }
1644
1645    // ------------------------------------------------------------------
1646    // Time-gate helpers
1647    // ------------------------------------------------------------------
1648
1649    #[test]
1650    fn time_gate_matches_proj_condition() {
1651        // Either bound zero => always apply.
1652        assert!(time_gate_applies(0.0, 0.0, 1234.0));
1653        assert!(time_gate_applies(2000.0, 0.0, 3000.0));
1654        assert!(time_gate_applies(0.0, 2010.0, 3000.0));
1655        // Both set: apply only when t < t_epoch and t_final > t_epoch.
1656        assert!(time_gate_applies(2000.0, 2010.0, 1995.0));
1657        assert!(!time_gate_applies(2000.0, 2010.0, 2005.0));
1658        assert!(!time_gate_applies(2000.0, 2010.0, 2000.0));
1659        // Degenerate bracket (t_final <= t_epoch) never applies for finite t.
1660        assert!(!time_gate_applies(2010.0, 2000.0, 1995.0));
1661    }
1662
1663    #[test]
1664    fn civil_date_round_trips_known_days() {
1665        // 2020-02-29 is day 18321 since 1970-01-01 (a leap day).
1666        assert_eq!(civil_from_days(18_321), (2020, 2, 29));
1667        // tm_yday is 0-based: Jan 1 => 0, Feb 29 (leap) => 59.
1668        assert_eq!(day_of_year(2020, 1, 1), 0);
1669        assert_eq!(day_of_year(2020, 2, 29), 59);
1670        assert_eq!(day_of_year(2021, 3, 1), 59); // non-leap
1671    }
1672}