Skip to main content

async_geotiff/
tiler.rs

1//! Top-level orchestrator: HTTP request → tile bytes.
2
3use std::sync::Arc;
4use std::time::Duration;
5#[cfg(feature = "perf-tracing")]
6use std::time::Instant;
7
8use bytes::Bytes;
9
10use crate::blocking::{default_blocking_permits, CpuLimiter};
11use crate::bounds::Bbox;
12use crate::error::{AgError, AgResult};
13use crate::io::CogReader;
14#[cfg(feature = "perf-tracing")]
15use crate::perf::duration_ms;
16use crate::pixel::ImageData;
17use crate::request::{
18    RequestContext, RequestLimiter, RequestOptions, DEFAULT_MAX_IN_FLIGHT_REQUESTS,
19    DEFAULT_REQUEST_TIMEOUT,
20};
21use crate::style::params::compile_cached;
22use crate::style::TileStyle;
23use crate::tms::{TileCoord, Tms};
24
25const PASSTHROUGH_PIXEL_TOLERANCE: f64 = 1e-3;
26
27/// Reads a COG and renders requested tile grids with shared resource limits.
28#[derive(Clone)]
29pub struct Tiler {
30    reader: Arc<CogReader>,
31    cpu: CpuLimiter,
32    requests: RequestLimiter,
33    request_timeout: Duration,
34    work_budget: crate::resource::WorkBudget,
35}
36
37/// Runtime limits for a [`Tiler`].
38///
39/// `max_blocking_tasks` caps concurrent warp/render jobs. The permit wait is
40/// async, while the CPU-heavy work still runs on Tokio's blocking pool.
41#[derive(Clone, Copy, Debug)]
42pub struct TilerConfig {
43    /// Maximum simultaneous blocking warp/render jobs; zero is treated as one.
44    pub max_blocking_tasks: usize,
45    /// Maximum admitted public requests; excess requests fail immediately.
46    pub max_in_flight_requests: usize,
47    /// Includes IO and waits for CPU permits. Blocking work stops at stage boundaries.
48    pub request_timeout: Duration,
49}
50
51impl Default for TilerConfig {
52    fn default() -> Self {
53        Self {
54            max_blocking_tasks: default_blocking_permits(),
55            max_in_flight_requests: DEFAULT_MAX_IN_FLIGHT_REQUESTS,
56            request_timeout: DEFAULT_REQUEST_TIMEOUT,
57        }
58    }
59}
60
61impl TilerConfig {
62    fn blocking_task_limit(self) -> usize {
63        self.max_blocking_tasks.max(1)
64    }
65}
66
67/// Resolved read/warp plan for one output tile (see [`Tiler::plan_tile`]).
68struct ReadyPlan {
69    level: usize,
70    col: i64,
71    row: i64,
72    w: u64,
73    h: u64,
74    /// The window is an exact integer-aligned identity crop — skip the warp.
75    passthrough: bool,
76    dst_crs: crate::crs::Crs,
77    dst_bbox: Bbox,
78}
79
80impl Tiler {
81    /// Build a `Tiler` that shares the reader's [`CpuLimiter`], so decode,
82    /// stitch, warp and render all draw from one CPU budget. In a mosaic, where
83    /// every asset's reader shares one limiter (via `ReaderPool`), this bounds
84    /// the whole mosaic's CPU work — preventing oversubscription. Use
85    /// [`Self::with_config`] to give a single-source tiler its own bound.
86    pub fn new(reader: Arc<CogReader>) -> Self {
87        let cpu = reader.cpu_limiter();
88        let work_budget = reader.work_budget();
89        Self {
90            reader,
91            cpu,
92            requests: RequestLimiter::default(),
93            request_timeout: DEFAULT_REQUEST_TIMEOUT,
94            work_budget,
95        }
96    }
97
98    /// Build a `Tiler` with an independent CPU bound sized by
99    /// `config.max_blocking_tasks` (does not share the reader's limiter). Prefer
100    /// [`Self::new`] when you want one shared process/mosaic-wide budget.
101    pub fn with_config(reader: Arc<CogReader>, config: TilerConfig) -> Self {
102        let work_budget = reader.work_budget();
103        Self {
104            reader,
105            cpu: CpuLimiter::new(config.blocking_task_limit()),
106            requests: RequestLimiter::new(config.max_in_flight_requests),
107            request_timeout: config.request_timeout,
108            work_budget,
109        }
110    }
111
112    /// Share admission across multiple datasets or a serving application.
113    pub fn with_request_limiter(mut self, requests: RequestLimiter) -> Self {
114        self.requests = requests;
115        self
116    }
117
118    /// Share a byte budget for active source, warp, and render buffers.
119    pub fn with_work_budget(mut self, work_budget: crate::resource::WorkBudget) -> Self {
120        self.work_budget = work_budget;
121        self
122    }
123
124    /// Whether every source sample is an unsigned 8-bit integer.
125    pub fn is_u8(&self) -> AgResult<bool> {
126        let ifd = self.reader.data_ifd(0)?;
127        Ok(ifd.bits_per_sample().iter().all(|&bits| bits == 8)
128            && ifd
129                .sample_format()
130                .iter()
131                .all(|format| *format == async_tiff::tags::SampleFormat::Uint))
132    }
133
134    /// Number of renderable bands after an ExtraSamples alpha channel is removed.
135    pub fn band_count(&self) -> AgResult<usize> {
136        use async_tiff::tags::ExtraSamples;
137        let ifd = self.reader.data_ifd(0)?;
138        let samples = usize::from(ifd.samples_per_pixel());
139        let alpha = ifd.extra_samples().is_some_and(|extras| {
140            extras.len() <= samples
141                && extras.iter().any(|sample| {
142                    matches!(
143                        sample,
144                        ExtraSamples::AssociatedAlpha | ExtraSamples::UnassociatedAlpha
145                    )
146                })
147        });
148        Ok(samples - usize::from(alpha))
149    }
150
151    /// WGS84 `[west, south, east, north]` extent of the dataset.
152    pub fn wgs84_bounds(&self) -> AgResult<[f64; 4]> {
153        self.reader.wgs84_bounds()
154    }
155
156    /// Pure planning: destination grid → overview level + clamped pixel window.
157    /// Returns `None` when the tile is fully outside the COG or outside the
158    /// projection's valid domain. Shared by [`Self::warp_tile`] (mosaic
159    /// per-asset path) and the fused [`Self::tile`] path.
160    fn plan_tile(&self, coord: TileCoord, style: &TileStyle) -> AgResult<Option<ReadyPlan>> {
161        let tms = Tms::cached(coord.tms)?;
162        let dst_bbox = tms.tile_bbox(coord.z, coord.x, coord.y)?;
163        let dst_crs = tms.crs();
164
165        let bbox_in_src = if self.reader.src_crs() == &dst_crs {
166            dst_bbox
167        } else {
168            let mut read_bbox = dst_bbox;
169            if crate::warp::projection::is_epsg(&dst_crs, 4326)
170                && crate::warp::projection::is_epsg(self.reader.src_crs(), 3857)
171                && (dst_bbox.ymin <= -90.0 || dst_bbox.ymax >= 90.0)
172            {
173                // Clip the read plan to actual coverage before projecting singular poles.
174                let [_, south, _, north] = self.reader.wgs84_bounds()?;
175                let pole = 90.0_f64.next_down();
176                read_bbox.ymin = read_bbox.ymin.max(south.max(-pole));
177                read_bbox.ymax = read_bbox.ymax.min(north.min(pole));
178                if read_bbox.ymin >= read_bbox.ymax {
179                    return Ok(None);
180                }
181            }
182            match Self::reproject_bbox_sync(&read_bbox, &dst_crs, self.reader.src_crs()) {
183                Ok(b) => b,
184                Err(AgError::ProjectionDomain(_)) => return Ok(None),
185                Err(e) => return Err(e),
186            }
187        };
188
189        let target_gsd =
190            bbox_in_src.width().abs().max(bbox_in_src.height().abs()) / f64::from(style.tilesize);
191        let level = if style.raster.categorical {
192            // Source overviews do not carry a category-preservation proof.
193            0
194        } else {
195            self.reader.pick_overview(target_gsd)
196        };
197
198        let level_tr = self.reader.level_transform(level)?;
199        let (rc, rr, rw, rh) = level_tr
200            .window_for_bbox(&bbox_in_src)
201            .ok_or_else(|| AgError::Warp("source pixel window is non-finite, out of range, or its GeoTransform is not invertible".into()))?;
202        let same_crs = self.reader.src_crs() == &dst_crs;
203        let level_gsd = self.reader.level_gsd(level).unwrap_or(target_gsd);
204        let res_matches = level_gsd > 0.0 && ((target_gsd - level_gsd).abs() / level_gsd) < 0.01;
205        let aligned = same_crs && res_matches;
206        let ts = style.tilesize as usize;
207        let kernel = if same_crs {
208            style.resampling
209        } else {
210            style.reproject
211        };
212
213        // B-splines smooth even at exact pixel centers, so they still need
214        // convolution and the halo below on an otherwise identical grid.
215        if aligned && kernel != crate::warp::resampler::Resampling::CubicSpline {
216            if let Some((pc, pr)) = Self::passthrough_window(&level_tr, &dst_bbox, ts) {
217                if let Ok((iw, ih)) = self.reader.level_dimensions(level) {
218                    let in_bounds = pc >= 0
219                        && pr >= 0
220                        && (pc as u64 + ts as u64) <= iw as u64
221                        && (pr as u64 + ts as u64) <= ih as u64;
222                    if in_bounds {
223                        return Ok(Some(ReadyPlan {
224                            level,
225                            col: pc,
226                            row: pr,
227                            w: ts as u64,
228                            h: ts as u64,
229                            passthrough: true,
230                            dst_crs,
231                            dst_bbox,
232                        }));
233                    }
234                }
235            }
236        }
237
238        let halo = match kernel {
239            crate::warp::resampler::Resampling::Lanczos => 3,
240            crate::warp::resampler::Resampling::Cubic
241            | crate::warp::resampler::Resampling::CubicSpline => 2,
242            crate::warp::resampler::Resampling::Nearest => 0,
243            _ => 1,
244        };
245        let raw = (
246            rc.checked_sub(halo)
247                .ok_or_else(|| AgError::Warp("column halo overflow".into()))?,
248            rr.checked_sub(halo)
249                .ok_or_else(|| AgError::Warp("row halo overflow".into()))?,
250            rw.checked_add(2 * halo as u64)
251                .ok_or_else(|| AgError::Warp("width halo overflow".into()))?,
252            rh.checked_add(2 * halo as u64)
253                .ok_or_else(|| AgError::Warp("height halo overflow".into()))?,
254        );
255        let (col, row, w, h) = self.clamp_window(level, raw.0, raw.1, raw.2, raw.3)?;
256        if w == 0 || h == 0 {
257            return Ok(None);
258        }
259        Ok(Some(ReadyPlan {
260            level,
261            col,
262            row,
263            w,
264            h,
265            passthrough: false,
266            dst_crs,
267            dst_bbox,
268        }))
269    }
270
271    /// Read + warp the source COG onto the destination tile grid. Returns
272    /// `None` when the tile is fully outside the COG or outside the
273    /// projection's valid domain — [`Tiler::tile`] turns that into "no tile"
274    /// (HTTP 204). Returns sorted, unique source dependencies; with
275    /// raster:mosaic.stage=after_channels it returns expression outputs instead.
276    /// NoData is consumed into the mask. This is the per-asset mosaic primitive.
277    pub async fn warp_tile(
278        &self,
279        coord: TileCoord,
280        style: &TileStyle,
281    ) -> AgResult<Option<Arc<ImageData>>> {
282        self.warp_tile_with_options(coord, style, &RequestOptions::default())
283            .await
284    }
285
286    /// Warp with a per-call deadline override and explicit cancellation handle.
287    pub async fn warp_tile_with_options(
288        &self,
289        coord: TileCoord,
290        style: &TileStyle,
291        options: &RequestOptions,
292    ) -> AgResult<Option<Arc<ImageData>>> {
293        self.requests
294            .run(self.request_timeout, options, |context| async move {
295                self.work_budget
296                    .run_request(&context, |attempt| async move {
297                        self.warp_tile_in_request(coord, style, &attempt).await
298                    })
299                    .await
300            })
301            .await
302    }
303
304    pub(crate) async fn warp_tile_in_request(
305        &self,
306        coord: TileCoord,
307        style: &TileStyle,
308        context: &RequestContext,
309    ) -> AgResult<Option<Arc<ImageData>>> {
310        context.check()?;
311        compile_cached(style).await?;
312        self.check_raster_source()?;
313        let Some(plan) = self.plan_tile_async(coord, style, context).await? else {
314            return Ok(None);
315        };
316        let bands = u64::from(self.reader.data_ifd(plan.level)?.samples_per_pixel())
317            .max(style.raster.mosaic_band_count() as u64);
318        let bytes = tile_work_bytes(plan.w, plan.h, bands, u64::from(style.tilesize))?;
319        let context = &context.with_work(self.work_budget.try_reserve(bytes)?);
320        let mut src: Arc<ImageData> = self
321            .reader
322            .read_window(plan.level, plan.col, plan.row, plan.w, plan.h)
323            .await?;
324        context.check()?;
325        if let Some(nodata) = style.nodata {
326            Arc::make_mut(&mut src).nodata = Some(nodata);
327        }
328        let raster = style.raster.clone();
329        let size = style.tilesize as usize;
330        let kernel = if src.crs == plan.dst_crs {
331            style.resampling
332        } else {
333            style.reproject
334        };
335        let cancellation = context.clone();
336        self.run_tile_cpu(context, "raster mosaic channels", move || {
337            let warped =
338                match raster.warp_dependencies(&src, &plan.dst_crs, plan.dst_bbox, size, kernel) {
339                    Ok(image) => image,
340                    Err(AgError::ProjectionDomain(_)) => return Ok(None),
341                    Err(error) => return Err(error),
342                };
343            cancellation.check()?;
344            Ok(Some(Arc::new(raster.prepare_mosaic(warped))))
345        })
346        .await
347    }
348
349    /// Render one tile. Returns `Ok(None)` when the tile lies fully outside
350    /// the dataset (or the projection's valid domain) — callers should treat
351    /// that as "no tile" (e.g. HTTP 204) rather than synthesising a blank one,
352    /// so out-of-range areas stay empty for every output format (a black JPEG
353    /// would otherwise cover the map where PNG/WebP stay transparent).
354    /// In-range tiles that happen to be all-nodata still render normally
355    /// (transparent for PNG/WebP).
356    #[cfg_attr(feature = "perf-tracing", tracing::instrument(
357        skip(self, style),
358        fields(tms = coord.tms.name(), z = coord.z, x = coord.x, y = coord.y, format = ?style.format),
359        err
360    ))]
361    pub async fn tile(&self, coord: TileCoord, style: &TileStyle) -> AgResult<Option<Bytes>> {
362        self.tile_with_options(coord, style, &RequestOptions::default())
363            .await
364    }
365
366    /// Render with a per-call deadline override and explicit cancellation handle.
367    pub async fn tile_with_options(
368        &self,
369        coord: TileCoord,
370        style: &TileStyle,
371        options: &RequestOptions,
372    ) -> AgResult<Option<Bytes>> {
373        self.requests
374            .run(self.request_timeout, options, |context| async move {
375                self.work_budget
376                    .run_request(&context, |attempt| async move {
377                        self.tile_in_request(coord, style, &attempt).await
378                    })
379                    .await
380            })
381            .await
382    }
383
384    pub(crate) fn check_raster_source(&self) -> AgResult<()> {
385        {
386            use async_tiff::tags::ExtraSamples;
387            for level in 0..self.reader.level_count() {
388                if self
389                    .reader
390                    .data_ifd(level)?
391                    .extra_samples()
392                    .is_some_and(|extras| {
393                        extras.iter().any(|sample| {
394                            matches!(
395                                sample,
396                                ExtraSamples::AssociatedAlpha | ExtraSamples::UnassociatedAlpha
397                            )
398                        })
399                    })
400                {
401                    return Err(AgError::query("source_alpha", "Raster Style requires preserved fractional Alpha; this reader currently exposes a boolean mask"));
402                }
403            }
404        }
405        Ok(())
406    }
407
408    pub(crate) async fn tile_in_request(
409        &self,
410        coord: TileCoord,
411        style: &TileStyle,
412        context: &RequestContext,
413    ) -> AgResult<Option<Bytes>> {
414        context.check()?;
415        self.check_raster_source()?;
416        style.raster.statistics.check_scope(false)?;
417        #[cfg(feature = "perf-tracing")]
418        let started = Instant::now();
419        let compiled = compile_cached(style).await?;
420        let Some(plan) = self.plan_tile_async(coord, style, context).await? else {
421            crate::perf_trace! {
422                tracing::debug!(
423                    total_ms = duration_ms(started.elapsed()),
424                    "tile internals timing: no tile"
425                );
426            }
427            return Ok(None);
428        };
429        let raster = if style.raster.stretch.needs_statistics() {
430            crate::style::statistics::resolve(
431                style,
432                std::slice::from_ref(&self.reader),
433                false,
434                &self.cpu,
435                &self.work_budget,
436                context,
437            )
438            .await?
439        } else {
440            compiled.raster.clone()
441        };
442        let bands = u64::from(self.reader.data_ifd(plan.level)?.samples_per_pixel());
443        let bytes = tile_work_bytes(plan.w, plan.h, bands, u64::from(style.tilesize))?;
444        let context = &context.with_work(self.work_budget.try_reserve(bytes)?);
445        #[cfg(feature = "perf-tracing")]
446        let planned = started.elapsed();
447        let mut src: Arc<ImageData> = self
448            .reader
449            .read_window(plan.level, plan.col, plan.row, plan.w, plan.h)
450            .await?;
451        context.check()?;
452        if let Some(nodata) = style.nodata {
453            Arc::make_mut(&mut src).nodata = Some(nodata);
454        }
455        #[cfg(feature = "perf-tracing")]
456        let read_done = started.elapsed();
457
458        let ts = style.tilesize as usize;
459        let kernel = if src.crs == plan.dst_crs {
460            style.resampling
461        } else {
462            style.reproject
463        };
464        let cancellation = context.clone();
465        let bytes = self
466            .run_tile_cpu(context, "tile", move || {
467                let result = if plan.passthrough {
468                    raster.render(&src, src.nodata, compiled.format)
469                } else {
470                    raster.render_warped(
471                        &src,
472                        &plan.dst_crs,
473                        plan.dst_bbox,
474                        ts,
475                        kernel,
476                        compiled.format,
477                    )
478                };
479                cancellation.check()?;
480                match result {
481                    Ok(bytes) => Ok(Some(bytes)),
482                    Err(AgError::ProjectionDomain(_)) => Ok(None),
483                    Err(error) => Err(error),
484                }
485            })
486            .await?;
487        let Some(bytes) = bytes else {
488            return Ok(None);
489        };
490        #[cfg(feature = "perf-tracing")]
491        let done = started.elapsed();
492        crate::perf_trace! {
493            tracing::debug!(
494                plan_ms = duration_ms(planned),
495                read_window_ms = duration_ms(read_done - planned),
496                fused_cpu_ms = duration_ms(done - read_done),
497                total_ms = duration_ms(done),
498                bytes = bytes.len(),
499                "tile internals timing"
500            );
501        }
502        Ok(Some(bytes))
503    }
504
505    async fn plan_tile_async(
506        &self,
507        coord: TileCoord,
508        style: &TileStyle,
509        context: &RequestContext,
510    ) -> AgResult<Option<ReadyPlan>> {
511        let this = self.clone();
512        let style = style.clone();
513        self.run_tile_cpu(context, "plan", move || this.plan_tile(coord, &style))
514            .await
515    }
516
517    /// Run a pure-CPU closure `f` under one [`CpuLimiter`] permit.
518    ///
519    /// INVARIANT (deadlock-freedom): `f` must be **pure synchronous CPU** and
520    /// must NOT acquire another permit from the same limiter (directly, or by
521    /// calling back into a reader read). The limiter may be shared across decode,
522    /// stitch, warp and render; the safety of sharing rests on each job holding
523    /// at most ONE permit at a time. Reader reads (`read_window`) acquire+release
524    /// their decode permits in the async layer *before* `run_tile_cpu` is called,
525    /// so warp/render here never nest acquisition. Were `f` to await a second
526    /// permit, N permit-holding jobs could starve all remaining acquisitions.
527    async fn run_tile_cpu<T, F>(
528        &self,
529        context: &RequestContext,
530        label: &'static str,
531        f: F,
532    ) -> AgResult<T>
533    where
534        T: Send + 'static,
535        F: FnOnce() -> AgResult<T> + Send + 'static,
536    {
537        context.check()?;
538        let permit = self
539            .cpu
540            .acquire()
541            .await
542            .map_err(|_| AgError::Warp(format!("{label} cpu limiter closed")))?;
543        let context = context.clone();
544        tokio::task::spawn_blocking(move || {
545            let _permit = permit;
546            context.check()?;
547            let result = f();
548            context.check()?;
549            result
550        })
551        .await
552        .map_err(|e| AgError::Warp(format!("{label} join: {e}")))?
553    }
554
555    /// Returns the integer `(col, row)` of an exact `ts×ts` passthrough
556    /// window when the destination tile maps onto source pixels with
557    /// negligible sub-pixel offset and unit scale. `None` means the read
558    /// is not a clean identity crop and must go through the warp path.
559    ///
560    /// Tolerance: corners must be within 1e-3 px of integers and the span
561    /// must round to exactly `ts` — anything looser would misregister the
562    /// tile against the basemap.
563    fn passthrough_window(
564        level_tr: &crate::bounds::GeoTransform,
565        dst_bbox: &Bbox,
566        ts: usize,
567    ) -> Option<(i64, i64)> {
568        let (x0, y0) = level_tr.forward(0.0, 0.0);
569        let (x1, y1) = level_tr.forward(1.0, 0.0);
570        let (x2, y2) = level_tr.forward(0.0, 1.0);
571        if !(x1 > x0 && y2 < y0 && y1 == y0 && x2 == x0) {
572            return None;
573        }
574        let inv = level_tr.inverse()?;
575        // Top-left and bottom-right corners → source pixel coords.
576        let (c0, r0) = inv.forward(dst_bbox.xmin, dst_bbox.ymax);
577        let (c1, r1) = inv.forward(dst_bbox.xmax, dst_bbox.ymin);
578        let frac = |v: f64| (v - v.round()).abs();
579        if frac(c0) > PASSTHROUGH_PIXEL_TOLERANCE || frac(r0) > PASSTHROUGH_PIXEL_TOLERANCE {
580            return None;
581        }
582        let span_c = (c1 - c0).abs();
583        let span_r = (r1 - r0).abs();
584        if (span_c - ts as f64).abs() > PASSTHROUGH_PIXEL_TOLERANCE
585            || (span_r - ts as f64).abs() > PASSTHROUGH_PIXEL_TOLERANCE
586        {
587            return None;
588        }
589        Some((c0.round() as i64, r0.round() as i64))
590    }
591
592    /// Reproject a bbox boundary synchronously, sampling curved edges and
593    /// retaining valid portions of partially out-of-domain requests.
594    /// Used only on the cross-CRS path; same-CRS callers don't reach here.
595    fn reproject_bbox_sync(
596        bbox: &crate::bounds::Bbox,
597        from_crs: &crate::crs::Crs,
598        src_crs: &crate::crs::Crs,
599    ) -> AgResult<Bbox> {
600        crate::warp::projection::reproject_boundary(
601            [
602                (bbox.xmin, bbox.ymin),
603                (bbox.xmax, bbox.ymin),
604                (bbox.xmax, bbox.ymax),
605                (bbox.xmin, bbox.ymax),
606            ],
607            from_crs,
608            src_crs,
609        )
610    }
611
612    /// Clamp pixel window to the IFD extent; returns (0, 0, 0, 0)-equivalent
613    /// when the window is fully outside.
614    fn clamp_window(
615        &self,
616        level: usize,
617        col: i64,
618        row: i64,
619        w: u64,
620        h: u64,
621    ) -> AgResult<(i64, i64, u64, u64)> {
622        let ifd = self.reader.data_ifd(level)?;
623        let iw = ifd.image_width() as i64;
624        let ih = ifd.image_height() as i64;
625        let col_end = col
626            .checked_add_unsigned(w)
627            .ok_or_else(|| AgError::Warp("source column endpoint out of range".into()))?
628            .min(iw);
629        let row_end = row
630            .checked_add_unsigned(h)
631            .ok_or_else(|| AgError::Warp("source row endpoint out of range".into()))?
632            .min(ih);
633        let col = col.max(0);
634        let row = row.max(0);
635        let w = (col_end - col).max(0) as u64;
636        let h = (row_end - row).max(0) as u64;
637        Ok((col, row, w, h))
638    }
639}
640
641/// Conservative live working-set estimate: source/promotion and overlapping
642/// warp rows, output, render, encoding and coordinate/mask buffers.
643fn tile_work_bytes(width: u64, height: u64, bands: u64, tile_size: u64) -> AgResult<u64> {
644    width
645        .checked_mul(height)
646        .and_then(|n| n.checked_mul(bands))
647        .and_then(|n| n.checked_mul(16))
648        .and_then(|source| {
649            tile_size
650                .checked_mul(tile_size)
651                .and_then(|pixels| {
652                    bands
653                        .checked_mul(48)
654                        .and_then(|per_band| per_band.checked_add(48))
655                        .and_then(|per_pixel| pixels.checked_mul(per_pixel))
656                })
657                .and_then(|tile| source.checked_add(tile))
658        })
659        .ok_or_else(|| AgError::ResourceLimit("tile working-set estimate overflow".into()))
660}
661
662#[cfg(test)]
663mod tests {
664    #[test]
665    fn passthrough_rejects_flipped_and_rotated_grids() {
666        let bbox = crate::bounds::Bbox::new(0.0, 0.0, 256.0, 256.0);
667        for gt in [
668            [256.0, -1.0, 0.0, 256.0, 0.0, -1.0],
669            [0.0, 0.0, 1.0, 256.0, -1.0, 0.0],
670        ] {
671            assert!(super::Tiler::passthrough_window(
672                &crate::bounds::GeoTransform::from_gdal(&gt),
673                &bbox,
674                256
675            )
676            .is_none());
677        }
678    }
679    use super::{Tiler, TilerConfig};
680    use crate::bounds::{Bbox, GeoTransform};
681
682    #[test]
683    fn tiler_type_compiles() {
684        let _ = std::mem::size_of::<Tiler>();
685    }
686
687    /// Standard slippy-map lon/lat -> (x, y) at zoom `z` (WebMercator).
688    fn lonlat_to_tile(lon: f64, lat: f64, z: u8) -> (u32, u32) {
689        let n = 2f64.powi(z as i32);
690        let x = ((lon + 180.0) / 360.0 * n).floor().clamp(0.0, n - 1.0) as u32;
691        let lat_rad = lat.to_radians();
692        let y = ((1.0 - (lat_rad.tan() + 1.0 / lat_rad.cos()).ln() / std::f64::consts::PI) / 2.0
693            * n)
694            .floor()
695            .clamp(0.0, n - 1.0) as u32;
696        (x, y)
697    }
698
699    #[tokio::test]
700    async fn tile_returns_none_outside_data_and_some_inside() {
701        use crate::io::CogReader;
702        use crate::style::TileStyle;
703        use crate::tms::{TileCoord, TileMatrixSet};
704        use async_tiff::reader::ObjectReader;
705        use object_store::local::LocalFileSystem;
706        use object_store::path::Path as OsPath;
707        use std::sync::Arc;
708
709        let tif = std::env::current_dir()
710            .unwrap()
711            .join("tests/fixtures/rgb_nodata.tif");
712
713        let os_path = OsPath::from_filesystem_path(&tif).unwrap();
714        let reader = CogReader::builder()
715            .snapshot_policy(crate::io::SnapshotPolicy::AssumeImmutable)
716            .open(ObjectReader::new(Arc::new(LocalFileSystem::new()), os_path))
717            .await
718            .unwrap();
719        let tiler = Tiler::new(Arc::new(reader));
720        let style = TileStyle::from_raster_query("version=3.0&type=gray&bidx=1").unwrap();
721        let [w, s, e, n] = tiler.wgs84_bounds().unwrap();
722
723        // A tile over the dataset centre must render actual bytes.
724        let z = 12u8;
725        let (cx, cy) = lonlat_to_tile((w + e) / 2.0, (s + n) / 2.0, z);
726        let inside = tiler
727            .tile(
728                TileCoord {
729                    tms: TileMatrixSet::WebMercatorQuad,
730                    z,
731                    x: cx,
732                    y: cy,
733                },
734                &style,
735            )
736            .await
737            .unwrap();
738        assert!(inside.is_some(), "in-bounds tile must produce bytes");
739
740        // The antipode is guaranteed outside any single real dataset → no tile.
741        let anti_lon = {
742            let l = (w + e) / 2.0 + 180.0;
743            if l > 180.0 {
744                l - 360.0
745            } else {
746                l
747            }
748        };
749        let (ax, ay) = lonlat_to_tile(anti_lon, -(s + n) / 2.0, z);
750        let outside = tiler
751            .tile(
752                TileCoord {
753                    tms: TileMatrixSet::WebMercatorQuad,
754                    z,
755                    x: ax,
756                    y: ay,
757                },
758                &style,
759            )
760            .await
761            .unwrap();
762        assert!(
763            outside.is_none(),
764            "fully out-of-bounds tile must return None, got bytes"
765        );
766    }
767
768    #[tokio::test]
769    #[ignore = "requires optional 904 MiB local JL1 fixture"]
770    async fn local_jl1_cog_center_tile_is_not_opaque_black() {
771        use crate::io::CogReader;
772        use crate::style::TileStyle;
773        use crate::tms::{TileCoord, TileMatrixSet};
774        use async_tiff::reader::ObjectReader;
775        use object_store::local::LocalFileSystem;
776        use object_store::path::Path as OsPath;
777        use std::sync::Arc;
778
779        let tif = std::env::current_dir()
780            .unwrap()
781            .join("data/JL1GF04A_PMS_20230301124138_3857_cog.tif");
782
783        let os_path = OsPath::from_filesystem_path(&tif).unwrap();
784        let reader = CogReader::builder()
785            .snapshot_policy(crate::io::SnapshotPolicy::AssumeImmutable)
786            .open(ObjectReader::new(Arc::new(LocalFileSystem::new()), os_path))
787            .await
788            .unwrap();
789        let tiler = Tiler::new(Arc::new(reader));
790        let style = TileStyle::from_raster_query(
791            "version=3.0&type=gray&bidx=1&resampling=bilinear&reproject=bilinear&extensions=%5B%22https%3A%2F%2Fmapseekai.github.io%2Fraster-style%2Fextensions%2Fraster%2Ftile%2Fv1.0.0%2Fschema.json%22%5D&extension_fields=%7B%22raster%3Atile%22%3A%7B%22tilesize%22%3A256%7D%7D",
792        )
793        .unwrap();
794        let png = tiler
795            .tile(
796                TileCoord {
797                    tms: TileMatrixSet::WebMercatorQuad,
798                    z: 14,
799                    x: 12866,
800                    y: 7017,
801                },
802                &style,
803            )
804            .await
805            .unwrap()
806            .expect("JL1 center tile must intersect the COG");
807        let rgba = image::load_from_memory(&png).unwrap().to_rgba8();
808        assert!(
809            rgba.pixels()
810                .any(|pixel| pixel[3] > 0 && (pixel[0] != 0 || pixel[1] != 0 || pixel[2] != 0)),
811            "JL1 center tile must retain source imagery instead of becoming opaque black"
812        );
813    }
814
815    #[test]
816    fn tiler_config_clamps_blocking_limit_to_one() {
817        let cfg = TilerConfig {
818            max_blocking_tasks: 0,
819            ..Default::default()
820        };
821        assert_eq!(cfg.blocking_task_limit(), 1);
822    }
823
824    #[test]
825    fn passthrough_hits_on_exact_aligned_tile() {
826        // GT: origin (0, 256), 1 m/px, north-up. A 256×256 tile covering
827        // world [0,0]..[256,256] maps exactly to pixel window (0,0)+256.
828        let gt = GeoTransform::from_gdal(&[0.0, 1.0, 0.0, 256.0, 0.0, -1.0]);
829        let bbox = Bbox::new(0.0, 0.0, 256.0, 256.0);
830        assert_eq!(Tiler::passthrough_window(&gt, &bbox, 256), Some((0, 0)));
831
832        // Offset tile at world (512, 0)..(768, 256) → pixel col 512.
833        let bbox2 = Bbox::new(512.0, 0.0, 768.0, 256.0);
834        assert_eq!(Tiler::passthrough_window(&gt, &bbox2, 256), Some((512, 0)));
835    }
836
837    #[test]
838    fn passthrough_rejects_subpixel_offset() {
839        let gt = GeoTransform::from_gdal(&[0.0, 1.0, 0.0, 256.0, 0.0, -1.0]);
840        // Origin shifted half a pixel — must NOT passthrough (would
841        // misregister against the basemap).
842        let bbox = Bbox::new(0.5, 0.0, 256.5, 256.0);
843        assert_eq!(Tiler::passthrough_window(&gt, &bbox, 256), None);
844    }
845
846    #[test]
847    fn passthrough_rejects_scale_mismatch() {
848        let gt = GeoTransform::from_gdal(&[0.0, 1.0, 0.0, 256.0, 0.0, -1.0]);
849        // World span 512 but ts=256 → 2 m/px request, not unit scale.
850        let bbox = Bbox::new(0.0, -256.0, 512.0, 256.0);
851        assert_eq!(Tiler::passthrough_window(&gt, &bbox, 256), None);
852    }
853
854    #[tokio::test]
855    async fn cancelled_cpu_stage_keeps_all_permits_until_it_exits() {
856        use crate::request::{RequestLimiter, RequestOptions};
857        let reader = crate::io::CogReader::builder()
858            .snapshot_policy(crate::io::SnapshotPolicy::AssumeImmutable)
859            .open_object_store(
860                std::sync::Arc::new(
861                    object_store::local::LocalFileSystem::new_with_prefix(".").unwrap(),
862                ),
863                "tests/fixtures/rgb_nodata.tif",
864            )
865            .await
866            .unwrap();
867        let tiler = Tiler::with_config(
868            std::sync::Arc::new(reader),
869            TilerConfig {
870                max_blocking_tasks: 1,
871                ..Default::default()
872            },
873        );
874        let cpu = tiler.cpu.clone();
875        let admission = RequestLimiter::new(1);
876        let task_admission = admission.clone();
877        let budget = crate::resource::WorkBudget::new(100);
878        let task_budget = budget.clone();
879        let (entered_tx, entered_rx) = tokio::sync::oneshot::channel();
880        let (release_tx, release_rx) = std::sync::mpsc::channel();
881        let task = tokio::spawn(async move {
882            task_admission
883                .run(
884                    std::time::Duration::from_secs(30),
885                    &RequestOptions::default(),
886                    |context| async move {
887                        let context = context.with_work(task_budget.try_reserve(70).unwrap());
888                        tiler
889                            .run_tile_cpu(&context, "held test stage", move || {
890                                entered_tx.send(()).unwrap();
891                                release_rx.recv().unwrap();
892                                Ok(())
893                            })
894                            .await
895                    },
896                )
897                .await
898        });
899        entered_rx.await.unwrap();
900        task.abort();
901        let _ = task.await;
902        assert_eq!(cpu.available_permits(), 0);
903        assert_eq!(admission.available_permits(), 0);
904        assert_eq!(budget.used_bytes(), 70);
905        release_tx.send(()).unwrap();
906        tokio::time::timeout(std::time::Duration::from_secs(2), async {
907            while cpu.available_permits() != 1
908                || admission.available_permits() != 1
909                || budget.used_bytes() != 0
910            {
911                tokio::task::yield_now().await;
912            }
913        })
914        .await
915        .unwrap();
916    }
917
918    #[tokio::test]
919    async fn tile_rejects_work_budget_before_reading_pixels() {
920        let reader = crate::io::CogReader::builder()
921            .snapshot_policy(crate::io::SnapshotPolicy::AssumeImmutable)
922            .open_object_store(
923                std::sync::Arc::new(
924                    object_store::local::LocalFileSystem::new_with_prefix(".").unwrap(),
925                ),
926                "tests/fixtures/rgb_nodata.tif",
927            )
928            .await
929            .unwrap();
930        let budget = crate::resource::WorkBudget::new(1);
931        let tiler = Tiler::new(std::sync::Arc::new(reader)).with_work_budget(budget.clone());
932        let (x, y) = lonlat_to_tile(10.032, 20.032, 18);
933        let coord = crate::tms::TileCoord {
934            tms: crate::tms::TileMatrixSet::WebMercatorQuad,
935            z: 18,
936            x,
937            y,
938        };
939        assert!(matches!(
940            tiler.tile(coord, &crate::style::TileStyle::default()).await,
941            Err(crate::error::AgError::ResourceLimit(_))
942        ));
943        assert_eq!(budget.used_bytes(), 0);
944    }
945
946    #[tokio::test]
947    async fn lanczos_plan_reads_full_kernel_halo() {
948        let reader = crate::io::CogReader::builder()
949            .snapshot_policy(crate::io::SnapshotPolicy::AssumeImmutable)
950            .open_object_store(
951                std::sync::Arc::new(
952                    object_store::local::LocalFileSystem::new_with_prefix(".").unwrap(),
953                ),
954                "tests/fixtures/rgb_nodata.tif",
955            )
956            .await
957            .unwrap();
958        let tiler = Tiler::new(std::sync::Arc::new(reader));
959        let (x, y) = lonlat_to_tile(10.032, 20.032, 18);
960        let coord = crate::tms::TileCoord {
961            tms: crate::tms::TileMatrixSet::WebMercatorQuad,
962            z: 18,
963            x,
964            y,
965        };
966        let nearest = tiler
967            .plan_tile(coord, &crate::style::TileStyle::default())
968            .unwrap()
969            .unwrap();
970        let lanczos = tiler
971            .plan_tile(
972                coord,
973                &crate::style::TileStyle::from_raster_query(
974                    "version=3.0&type=gray&reproject=lanczos&bidx=1",
975                )
976                .unwrap(),
977            )
978            .unwrap()
979            .unwrap();
980        assert!(lanczos.col <= nearest.col - 3 && lanczos.row <= nearest.row - 3);
981        assert!(lanczos.w >= nearest.w + 6 && lanczos.h >= nearest.h + 6);
982    }
983
984    #[tokio::test]
985    async fn request_nodata_is_applied_before_interpolation() {
986        let store = std::sync::Arc::new(
987            object_store::local::LocalFileSystem::new_with_prefix(".").unwrap(),
988        );
989        let reader = crate::io::CogReader::builder()
990            .snapshot_policy(crate::io::SnapshotPolicy::AssumeImmutable)
991            .open_object_store(store, "tests/fixtures/rgb_nodata.tif")
992            .await
993            .unwrap();
994        let tiler = Tiler::new(std::sync::Arc::new(reader));
995        let [w, s, e, n] = tiler.wgs84_bounds().unwrap();
996        let (x, y) = lonlat_to_tile((w + e) / 2.0, (s + n) / 2.0, 12);
997        let coord = crate::tms::TileCoord {
998            tms: crate::tms::TileMatrixSet::WebMercatorQuad,
999            z: 12,
1000            x,
1001            y,
1002        };
1003        let style = crate::style::TileStyle::from_raster_query(
1004            "version=3.0&type=gray&nodata=100&reproject=bilinear&bidx=1",
1005        )
1006        .unwrap();
1007        let plan = tiler.plan_tile(coord, &style).unwrap().unwrap();
1008        let mut source = (*tiler
1009            .reader
1010            .read_window(plan.level, plan.col, plan.row, plan.w, plan.h)
1011            .await
1012            .unwrap())
1013        .clone();
1014        source.nodata = Some(100.0);
1015        // Raster Style warps only referenced bands, in floating point.
1016        source.pixels = crate::pixel::PixelData::F64(
1017            source.pixels.band_to_f64(0).insert_axis(ndarray::Axis(0)),
1018        );
1019        let expected = crate::warp::warp(
1020            &source,
1021            &plan.dst_crs,
1022            plan.dst_bbox,
1023            256,
1024            256,
1025            style.reproject,
1026        )
1027        .unwrap();
1028        let actual = tiler.warp_tile(coord, &style).await.unwrap().unwrap();
1029        assert_eq!(actual.mask, expected.mask);
1030        for (a, b) in actual
1031            .pixels
1032            .to_f64()
1033            .iter()
1034            .zip(expected.pixels.to_f64().iter())
1035        {
1036            assert!(a == b || (a.is_nan() && b.is_nan()));
1037        }
1038    }
1039}