Skip to main content

proj_core/
grid.rs

1use crate::operation::{AreaOfUse, GridId, GridInterpolation, GridShiftDirection};
2use smallvec::SmallVec;
3use std::collections::HashMap;
4use std::f64::consts::PI;
5use std::path::PathBuf;
6use std::sync::{Arc, Mutex};
7use thiserror::Error;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum GridFormat {
11    Ntv2,
12    Unsupported,
13}
14
15#[derive(Debug, Clone, PartialEq)]
16pub struct GridDefinition {
17    pub id: GridId,
18    pub name: String,
19    pub format: GridFormat,
20    pub interpolation: GridInterpolation,
21    pub area_of_use: Option<AreaOfUse>,
22    pub resource_names: SmallVec<[String; 2]>,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq)]
26pub struct GridSample {
27    pub lon_shift_radians: f64,
28    pub lat_shift_radians: f64,
29}
30
31#[derive(Debug, Error, Clone)]
32pub enum GridError {
33    #[error("grid not found: {0}")]
34    NotFound(String),
35    #[error("grid resource unavailable: {0}")]
36    Unavailable(String),
37    #[error("grid parse error: {0}")]
38    Parse(String),
39    #[error("grid point outside coverage: {0}")]
40    OutsideCoverage(String),
41    #[error("unsupported grid format: {0}")]
42    UnsupportedFormat(String),
43}
44
45pub trait GridProvider: Send + Sync {
46    fn definition(
47        &self,
48        grid: &GridDefinition,
49    ) -> std::result::Result<Option<GridDefinition>, GridError>;
50    fn load(&self, grid: &GridDefinition) -> std::result::Result<Option<GridHandle>, GridError>;
51}
52
53#[derive(Clone)]
54pub struct GridHandle {
55    definition: GridDefinition,
56    data: Arc<GridData>,
57}
58
59impl GridHandle {
60    pub fn definition(&self) -> &GridDefinition {
61        &self.definition
62    }
63
64    pub fn sample(
65        &self,
66        lon_radians: f64,
67        lat_radians: f64,
68    ) -> std::result::Result<GridSample, GridError> {
69        match self.data.as_ref() {
70            GridData::Ntv2(set) => set.sample(lon_radians, lat_radians),
71        }
72    }
73
74    pub fn apply(
75        &self,
76        lon_radians: f64,
77        lat_radians: f64,
78        direction: GridShiftDirection,
79    ) -> std::result::Result<(f64, f64), GridError> {
80        match self.data.as_ref() {
81            GridData::Ntv2(set) => set.apply(lon_radians, lat_radians, direction),
82        }
83    }
84}
85
86pub(crate) struct GridRuntime {
87    providers: Vec<Arc<dyn GridProvider>>,
88    definition_cache: Mutex<HashMap<GridId, GridDefinition>>,
89    handle_cache: Mutex<HashMap<GridId, GridHandle>>,
90}
91
92impl GridRuntime {
93    pub(crate) fn new(app_provider: Option<Arc<dyn GridProvider>>) -> Self {
94        let mut providers: Vec<Arc<dyn GridProvider>> = Vec::with_capacity(2);
95        if let Some(provider) = app_provider {
96            providers.push(provider);
97        }
98        providers.push(Arc::new(EmbeddedGridProvider));
99        Self {
100            providers,
101            definition_cache: Mutex::new(HashMap::new()),
102            handle_cache: Mutex::new(HashMap::new()),
103        }
104    }
105
106    pub(crate) fn resolve_definition(
107        &self,
108        grid: &GridDefinition,
109    ) -> std::result::Result<GridDefinition, GridError> {
110        if let Some(cached) = self
111            .definition_cache
112            .lock()
113            .expect("grid definition cache poisoned")
114            .get(&grid.id)
115            .cloned()
116        {
117            return Ok(cached);
118        }
119
120        for provider in &self.providers {
121            if let Some(definition) = provider.definition(grid)? {
122                self.definition_cache
123                    .lock()
124                    .expect("grid definition cache poisoned")
125                    .insert(grid.id, definition.clone());
126                return Ok(definition);
127            }
128        }
129
130        Err(GridError::Unavailable(grid.name.clone()))
131    }
132
133    pub(crate) fn resolve_handle(
134        &self,
135        grid: &GridDefinition,
136    ) -> std::result::Result<GridHandle, GridError> {
137        if let Some(cached) = self
138            .handle_cache
139            .lock()
140            .expect("grid handle cache poisoned")
141            .get(&grid.id)
142            .cloned()
143        {
144            return Ok(cached);
145        }
146
147        let definition = self.resolve_definition(grid)?;
148        for provider in &self.providers {
149            if let Some(handle) = provider.load(&definition)? {
150                self.handle_cache
151                    .lock()
152                    .expect("grid handle cache poisoned")
153                    .insert(grid.id, handle.clone());
154                return Ok(handle);
155            }
156        }
157
158        Err(GridError::Unavailable(definition.name))
159    }
160}
161
162#[derive(Default)]
163pub struct EmbeddedGridProvider;
164
165impl GridProvider for EmbeddedGridProvider {
166    fn definition(
167        &self,
168        grid: &GridDefinition,
169    ) -> std::result::Result<Option<GridDefinition>, GridError> {
170        if embedded_grid_bytes(&grid.resource_names).is_some() {
171            return Ok(Some(grid.clone()));
172        }
173        Ok(None)
174    }
175
176    fn load(&self, grid: &GridDefinition) -> std::result::Result<Option<GridHandle>, GridError> {
177        let Some(bytes) = embedded_grid_bytes(&grid.resource_names) else {
178            return Ok(None);
179        };
180
181        let data = match grid.format {
182            GridFormat::Ntv2 => GridData::Ntv2(Ntv2GridSet::parse(bytes)?),
183            GridFormat::Unsupported => {
184                return Err(GridError::UnsupportedFormat(grid.name.clone()));
185            }
186        };
187
188        Ok(Some(GridHandle {
189            definition: grid.clone(),
190            data: Arc::new(data),
191        }))
192    }
193}
194
195pub struct FilesystemGridProvider {
196    roots: Vec<PathBuf>,
197}
198
199impl FilesystemGridProvider {
200    pub fn new<I>(roots: I) -> Self
201    where
202        I: IntoIterator<Item = PathBuf>,
203    {
204        Self {
205            roots: roots.into_iter().collect(),
206        }
207    }
208
209    fn locate(&self, grid: &GridDefinition) -> Option<PathBuf> {
210        for root in &self.roots {
211            for name in &grid.resource_names {
212                let candidate = root.join(name);
213                if candidate.exists() {
214                    return Some(candidate);
215                }
216            }
217        }
218        None
219    }
220}
221
222impl GridProvider for FilesystemGridProvider {
223    fn definition(
224        &self,
225        grid: &GridDefinition,
226    ) -> std::result::Result<Option<GridDefinition>, GridError> {
227        if self.locate(grid).is_some() {
228            return Ok(Some(grid.clone()));
229        }
230        Ok(None)
231    }
232
233    fn load(&self, grid: &GridDefinition) -> std::result::Result<Option<GridHandle>, GridError> {
234        let Some(path) = self.locate(grid) else {
235            return Ok(None);
236        };
237
238        let bytes = std::fs::read(&path)
239            .map_err(|err| GridError::Unavailable(format!("{}: {err}", path.display())))?;
240        let data = match grid.format {
241            GridFormat::Ntv2 => GridData::Ntv2(Ntv2GridSet::parse(&bytes)?),
242            GridFormat::Unsupported => {
243                return Err(GridError::UnsupportedFormat(grid.name.clone()));
244            }
245        };
246
247        Ok(Some(GridHandle {
248            definition: grid.clone(),
249            data: Arc::new(data),
250        }))
251    }
252}
253
254enum GridData {
255    Ntv2(Ntv2GridSet),
256}
257
258fn embedded_grid_bytes(names: &[String]) -> Option<&'static [u8]> {
259    for name in names {
260        if name.eq_ignore_ascii_case("ntv2_0.gsb") {
261            return Some(include_bytes!("../data/grids/ntv2_0.gsb"));
262        }
263    }
264    None
265}
266
267#[derive(Clone)]
268struct Ntv2GridSet {
269    grids: Vec<Ntv2Grid>,
270    roots: Vec<usize>,
271}
272
273impl Ntv2GridSet {
274    fn parse(bytes: &[u8]) -> std::result::Result<Self, GridError> {
275        const HEADER_LEN: usize = 11 * 16;
276
277        if bytes.len() < HEADER_LEN {
278            return Err(GridError::Parse("NTv2 file too small".into()));
279        }
280
281        let endian = if u32::from_le_bytes(bytes[8..12].try_into().expect("slice length checked"))
282            == 11
283        {
284            Endian::Little
285        } else if u32::from_be_bytes(bytes[8..12].try_into().expect("slice length checked")) == 11 {
286            Endian::Big
287        } else {
288            return Err(GridError::Parse(
289                "invalid NTv2 header endianness marker".into(),
290            ));
291        };
292
293        if &bytes[56..63] != b"SECONDS" {
294            return Err(GridError::Parse(
295                "only NTv2 GS_TYPE=SECONDS is supported".into(),
296            ));
297        }
298
299        let num_subfiles = read_u32(bytes, 40, endian)? as usize;
300        let mut offset = HEADER_LEN;
301        let mut grids = Vec::with_capacity(num_subfiles);
302        let mut name_to_index = HashMap::new();
303        let mut parent_links: Vec<Option<String>> = Vec::with_capacity(num_subfiles);
304
305        for _ in 0..num_subfiles {
306            let header = bytes
307                .get(offset..offset + HEADER_LEN)
308                .ok_or_else(|| GridError::Parse("truncated NTv2 subfile header".into()))?;
309            if &header[0..8] != b"SUB_NAME" {
310                return Err(GridError::Parse("invalid NTv2 subfile header tag".into()));
311            }
312
313            let name = parse_label(&header[8..16]);
314            let parent = parse_label(&header[24..32]);
315            let south = read_f64(header, 72, endian)? * PI / 180.0 / 3600.0;
316            let north = read_f64(header, 88, endian)? * PI / 180.0 / 3600.0;
317            let east = -read_f64(header, 104, endian)? * PI / 180.0 / 3600.0;
318            let west = -read_f64(header, 120, endian)? * PI / 180.0 / 3600.0;
319            let res_y = read_f64(header, 136, endian)? * PI / 180.0 / 3600.0;
320            let res_x = read_f64(header, 152, endian)? * PI / 180.0 / 3600.0;
321            let gs_count = read_u32(header, 168, endian)? as usize;
322
323            if !(west < east && south < north && res_x > 0.0 && res_y > 0.0) {
324                return Err(GridError::Parse(format!(
325                    "invalid NTv2 georeferencing for subgrid {name}"
326                )));
327            }
328
329            let width = (((east - west) / res_x).abs() + 0.5).floor() as usize + 1;
330            let height = (((north - south) / res_y).abs() + 0.5).floor() as usize + 1;
331            if width * height != gs_count {
332                return Err(GridError::Parse(format!(
333                    "NTv2 subgrid {name} cell count mismatch: expected {} got {gs_count}",
334                    width * height
335                )));
336            }
337
338            let data_len = gs_count
339                .checked_mul(4)
340                .and_then(|count| count.checked_mul(4))
341                .ok_or_else(|| GridError::Parse("NTv2 data size overflow".into()))?;
342            let data = bytes
343                .get(offset + HEADER_LEN..offset + HEADER_LEN + data_len)
344                .ok_or_else(|| {
345                    GridError::Parse(format!("truncated NTv2 data for subgrid {name}"))
346                })?;
347
348            let mut lat_shift = vec![0.0f64; gs_count];
349            let mut lon_shift = vec![0.0f64; gs_count];
350            for y in 0..height {
351                for x in 0..width {
352                    let source_x = width - 1 - x;
353                    let record_offset = (y * width + source_x) * 16;
354                    let lat = read_f32(data, record_offset, endian)? as f64 * PI / 180.0 / 3600.0;
355                    let lon =
356                        -(read_f32(data, record_offset + 4, endian)? as f64) * PI / 180.0 / 3600.0;
357                    let dest = y * width + x;
358                    lat_shift[dest] = lat;
359                    lon_shift[dest] = lon;
360                }
361            }
362
363            let index = grids.len();
364            name_to_index.insert(name.clone(), index);
365            parent_links.push(
366                if parent.eq_ignore_ascii_case("none") || parent.is_empty() {
367                    None
368                } else {
369                    Some(parent)
370                },
371            );
372            grids.push(Ntv2Grid {
373                name,
374                extent: GridExtent {
375                    west,
376                    south,
377                    east,
378                    north,
379                    res_x,
380                    res_y,
381                },
382                width,
383                height,
384                lat_shift,
385                lon_shift,
386                children: Vec::new(),
387            });
388            offset += HEADER_LEN + data_len;
389        }
390
391        let mut roots = Vec::new();
392        for (idx, parent) in parent_links.into_iter().enumerate() {
393            if let Some(parent_name) = parent {
394                let Some(parent_idx) = name_to_index.get(&parent_name).copied() else {
395                    return Err(GridError::Parse(format!(
396                        "missing NTv2 parent subgrid {parent_name} for {}",
397                        grids[idx].name
398                    )));
399                };
400                grids[parent_idx].children.push(idx);
401            } else {
402                roots.push(idx);
403            }
404        }
405
406        Ok(Self { grids, roots })
407    }
408
409    fn sample(
410        &self,
411        lon_radians: f64,
412        lat_radians: f64,
413    ) -> std::result::Result<GridSample, GridError> {
414        let (grid_idx, local_lon, local_lat) = self.grid_at(lon_radians, lat_radians)?;
415        let (lon_shift, lat_shift) = interpolate(&self.grids[grid_idx], local_lon, local_lat)?;
416        Ok(GridSample {
417            lon_shift_radians: lon_shift,
418            lat_shift_radians: lat_shift,
419        })
420    }
421
422    fn apply(
423        &self,
424        lon_radians: f64,
425        lat_radians: f64,
426        direction: GridShiftDirection,
427    ) -> std::result::Result<(f64, f64), GridError> {
428        match direction {
429            GridShiftDirection::Forward => {
430                let shift = self.sample(lon_radians, lat_radians)?;
431                Ok((
432                    lon_radians + shift.lon_shift_radians,
433                    lat_radians + shift.lat_shift_radians,
434                ))
435            }
436            GridShiftDirection::Reverse => self.apply_inverse(lon_radians, lat_radians),
437        }
438    }
439
440    fn apply_inverse(
441        &self,
442        lon_radians: f64,
443        lat_radians: f64,
444    ) -> std::result::Result<(f64, f64), GridError> {
445        const MAX_ITERATIONS: usize = 10;
446        const TOLERANCE: f64 = 1e-12;
447
448        let mut estimate_lon = lon_radians;
449        let mut estimate_lat = lat_radians;
450
451        for _ in 0..MAX_ITERATIONS {
452            let shift = self.sample(estimate_lon, estimate_lat)?;
453            let next_lon = lon_radians - shift.lon_shift_radians;
454            let next_lat = lat_radians - shift.lat_shift_radians;
455            let diff_lon = next_lon - estimate_lon;
456            let diff_lat = next_lat - estimate_lat;
457            estimate_lon = next_lon;
458            estimate_lat = next_lat;
459            if diff_lon * diff_lon + diff_lat * diff_lat <= TOLERANCE * TOLERANCE {
460                return Ok((estimate_lon, estimate_lat));
461            }
462        }
463
464        Ok((estimate_lon, estimate_lat))
465    }
466
467    fn grid_at(
468        &self,
469        lon_radians: f64,
470        lat_radians: f64,
471    ) -> std::result::Result<(usize, f64, f64), GridError> {
472        for &root in &self.roots {
473            if self.grids[root].extent.contains(lon_radians, lat_radians) {
474                let idx = self.deepest_child(root, lon_radians, lat_radians);
475                let extent = &self.grids[idx].extent;
476                return Ok((idx, lon_radians - extent.west, lat_radians - extent.south));
477            }
478        }
479        Err(GridError::OutsideCoverage(format!(
480            "longitude {:.8} latitude {:.8}",
481            lon_radians.to_degrees(),
482            lat_radians.to_degrees()
483        )))
484    }
485
486    fn deepest_child(&self, index: usize, lon_radians: f64, lat_radians: f64) -> usize {
487        for &child in &self.grids[index].children {
488            if self.grids[child].extent.contains(lon_radians, lat_radians) {
489                return self.deepest_child(child, lon_radians, lat_radians);
490            }
491        }
492        index
493    }
494}
495
496#[derive(Clone)]
497struct Ntv2Grid {
498    name: String,
499    extent: GridExtent,
500    width: usize,
501    height: usize,
502    lat_shift: Vec<f64>,
503    lon_shift: Vec<f64>,
504    children: Vec<usize>,
505}
506
507#[derive(Clone, Copy)]
508struct GridExtent {
509    west: f64,
510    south: f64,
511    east: f64,
512    north: f64,
513    res_x: f64,
514    res_y: f64,
515}
516
517impl GridExtent {
518    fn contains(&self, lon_radians: f64, lat_radians: f64) -> bool {
519        let epsilon = (self.res_x + self.res_y) * 1e-10;
520        lon_radians >= self.west - epsilon
521            && lon_radians <= self.east + epsilon
522            && lat_radians >= self.south - epsilon
523            && lat_radians <= self.north + epsilon
524    }
525}
526
527fn interpolate(
528    grid: &Ntv2Grid,
529    local_lon: f64,
530    local_lat: f64,
531) -> std::result::Result<(f64, f64), GridError> {
532    let lam = local_lon / grid.extent.res_x;
533    let phi = local_lat / grid.extent.res_y;
534    let mut x = lam.floor() as isize;
535    let mut y = phi.floor() as isize;
536    let mut fx = lam - x as f64;
537    let mut fy = phi - y as f64;
538
539    if x < 0 {
540        if x == -1 && fx > 1.0 - 1e-9 {
541            x = 0;
542            fx = 0.0;
543        } else {
544            return Err(GridError::OutsideCoverage(grid.name.clone()));
545        }
546    }
547    if y < 0 {
548        if y == -1 && fy > 1.0 - 1e-9 {
549            y = 0;
550            fy = 0.0;
551        } else {
552            return Err(GridError::OutsideCoverage(grid.name.clone()));
553        }
554    }
555    if x as usize + 1 >= grid.width {
556        if x as usize + 1 == grid.width && fx < 1e-9 {
557            x -= 1;
558            fx = 1.0;
559        } else {
560            return Err(GridError::OutsideCoverage(grid.name.clone()));
561        }
562    }
563    if y as usize + 1 >= grid.height {
564        if y as usize + 1 == grid.height && fy < 1e-9 {
565            y -= 1;
566            fy = 1.0;
567        } else {
568            return Err(GridError::OutsideCoverage(grid.name.clone()));
569        }
570    }
571
572    let idx = |xx: usize, yy: usize| yy * grid.width + xx;
573    let x0 = x as usize;
574    let y0 = y as usize;
575    let x1 = x0 + 1;
576    let y1 = y0 + 1;
577
578    let m00 = (1.0 - fx) * (1.0 - fy);
579    let m10 = fx * (1.0 - fy);
580    let m01 = (1.0 - fx) * fy;
581    let m11 = fx * fy;
582
583    let lon = m00 * grid.lon_shift[idx(x0, y0)]
584        + m10 * grid.lon_shift[idx(x1, y0)]
585        + m01 * grid.lon_shift[idx(x0, y1)]
586        + m11 * grid.lon_shift[idx(x1, y1)];
587    let lat = m00 * grid.lat_shift[idx(x0, y0)]
588        + m10 * grid.lat_shift[idx(x1, y0)]
589        + m01 * grid.lat_shift[idx(x0, y1)]
590        + m11 * grid.lat_shift[idx(x1, y1)];
591
592    Ok((lon, lat))
593}
594
595#[derive(Clone, Copy)]
596enum Endian {
597    Little,
598    Big,
599}
600
601fn parse_label(bytes: &[u8]) -> String {
602    let end = bytes
603        .iter()
604        .position(|byte| *byte == 0)
605        .unwrap_or(bytes.len());
606    String::from_utf8_lossy(&bytes[..end]).trim().to_string()
607}
608
609fn read_u32(bytes: &[u8], offset: usize, endian: Endian) -> std::result::Result<u32, GridError> {
610    let slice = bytes
611        .get(offset..offset + 4)
612        .ok_or_else(|| GridError::Parse("truncated integer".into()))?;
613    Ok(match endian {
614        Endian::Little => u32::from_le_bytes(slice.try_into().expect("slice length checked")),
615        Endian::Big => u32::from_be_bytes(slice.try_into().expect("slice length checked")),
616    })
617}
618
619fn read_f64(bytes: &[u8], offset: usize, endian: Endian) -> std::result::Result<f64, GridError> {
620    let slice = bytes
621        .get(offset..offset + 8)
622        .ok_or_else(|| GridError::Parse("truncated float64".into()))?;
623    Ok(match endian {
624        Endian::Little => f64::from_le_bytes(slice.try_into().expect("slice length checked")),
625        Endian::Big => f64::from_be_bytes(slice.try_into().expect("slice length checked")),
626    })
627}
628
629fn read_f32(bytes: &[u8], offset: usize, endian: Endian) -> std::result::Result<f32, GridError> {
630    let slice = bytes
631        .get(offset..offset + 4)
632        .ok_or_else(|| GridError::Parse("truncated float32".into()))?;
633    Ok(match endian {
634        Endian::Little => f32::from_le_bytes(slice.try_into().expect("slice length checked")),
635        Endian::Big => f32::from_be_bytes(slice.try_into().expect("slice length checked")),
636    })
637}
638
639#[cfg(test)]
640mod tests {
641    use super::*;
642    use std::sync::atomic::{AtomicUsize, Ordering};
643
644    #[test]
645    fn embedded_ntv2_grid_samples_known_point() {
646        let provider = EmbeddedGridProvider;
647        let definition = GridDefinition {
648            id: GridId(1),
649            name: "ntv2_0.gsb".into(),
650            format: GridFormat::Ntv2,
651            interpolation: GridInterpolation::Bilinear,
652            area_of_use: None,
653            resource_names: SmallVec::from_vec(vec!["ntv2_0.gsb".into()]),
654        };
655        let handle = provider.load(&definition).unwrap().expect("embedded grid");
656        let (lon, lat) = handle
657            .apply(
658                (-80.5041667f64).to_radians(),
659                44.5458333f64.to_radians(),
660                GridShiftDirection::Forward,
661            )
662            .unwrap();
663        assert!(
664            (lon.to_degrees() - (-80.50401615833)).abs() < 1e-6,
665            "lon={lon}"
666        );
667        assert!((lat.to_degrees() - 44.5458827236).abs() < 3e-6, "lat={lat}");
668    }
669
670    struct TrackingGridProvider {
671        override_definition: bool,
672        definition_calls: Arc<AtomicUsize>,
673        load_calls: Arc<AtomicUsize>,
674    }
675
676    impl GridProvider for TrackingGridProvider {
677        fn definition(
678            &self,
679            grid: &GridDefinition,
680        ) -> std::result::Result<Option<GridDefinition>, GridError> {
681            self.definition_calls.fetch_add(1, Ordering::SeqCst);
682            if self.override_definition {
683                let mut overridden = grid.clone();
684                overridden.name = "custom override".into();
685                Ok(Some(overridden))
686            } else {
687                Ok(None)
688            }
689        }
690
691        fn load(
692            &self,
693            grid: &GridDefinition,
694        ) -> std::result::Result<Option<GridHandle>, GridError> {
695            self.load_calls.fetch_add(1, Ordering::SeqCst);
696            EmbeddedGridProvider.load(grid)
697        }
698    }
699
700    fn test_grid_definition() -> GridDefinition {
701        GridDefinition {
702            id: GridId(1),
703            name: "ntv2_0.gsb".into(),
704            format: GridFormat::Ntv2,
705            interpolation: GridInterpolation::Bilinear,
706            area_of_use: None,
707            resource_names: SmallVec::from_vec(vec!["ntv2_0.gsb".into()]),
708        }
709    }
710
711    #[test]
712    fn app_grid_provider_can_override_embedded_grid() {
713        let definition_calls = Arc::new(AtomicUsize::new(0));
714        let load_calls = Arc::new(AtomicUsize::new(0));
715        let provider = TrackingGridProvider {
716            override_definition: true,
717            definition_calls: Arc::clone(&definition_calls),
718            load_calls: Arc::clone(&load_calls),
719        };
720        let runtime = GridRuntime::new(Some(Arc::new(provider)));
721
722        let handle = runtime
723            .resolve_handle(&test_grid_definition())
724            .expect("grid should resolve");
725
726        assert_eq!(handle.definition().name, "custom override");
727        assert_eq!(definition_calls.load(Ordering::SeqCst), 1);
728        assert_eq!(load_calls.load(Ordering::SeqCst), 1);
729    }
730
731    #[test]
732    fn app_grid_provider_falls_back_to_embedded_grid() {
733        let definition_calls = Arc::new(AtomicUsize::new(0));
734        let load_calls = Arc::new(AtomicUsize::new(0));
735        let provider = TrackingGridProvider {
736            override_definition: false,
737            definition_calls: Arc::clone(&definition_calls),
738            load_calls: Arc::clone(&load_calls),
739        };
740        let runtime = GridRuntime::new(Some(Arc::new(provider)));
741
742        let handle = runtime
743            .resolve_handle(&test_grid_definition())
744            .expect("embedded grid should remain available");
745
746        assert_eq!(handle.definition().name, "ntv2_0.gsb");
747        assert_eq!(definition_calls.load(Ordering::SeqCst), 1);
748        assert_eq!(load_calls.load(Ordering::SeqCst), 1);
749    }
750}