Skip to main content

rustial_engine/
tile_source.rs

1//! Tile source trait and data types.
2
3use crate::geometry::FeatureCollection;
4use rustial_math::TileId;
5use std::collections::HashMap;
6use std::sync::Arc;
7use std::time::SystemTime;
8use thiserror::Error;
9
10/// Number of bytes per pixel for RGBA8 imagery.
11const RGBA8_BYTES_PER_PIXEL: usize = 4;
12
13/// One generated mip level of an RGBA8 raster image.
14#[derive(Debug, Clone)]
15pub struct RasterMipLevel {
16    /// Mip width in pixels.
17    pub width: u32,
18    /// Mip height in pixels.
19    pub height: u32,
20    /// RGBA8 pixel data for this mip level.
21    pub data: Vec<u8>,
22}
23
24/// Full generated mip chain for an RGBA8 raster image.
25#[derive(Debug, Clone)]
26pub struct RasterMipChain {
27    levels: Vec<RasterMipLevel>,
28}
29
30impl RasterMipChain {
31    /// Borrow all mip levels from base level 0 down to 1x1.
32    #[inline]
33    pub fn levels(&self) -> &[RasterMipLevel] {
34        &self.levels
35    }
36
37    /// Number of mip levels in the chain.
38    #[inline]
39    pub fn level_count(&self) -> u32 {
40        self.levels.len() as u32
41    }
42
43    /// Total number of bytes across all mip levels.
44    #[inline]
45    pub fn byte_len(&self) -> usize {
46        self.levels.iter().map(|level| level.data.len()).sum()
47    }
48
49    /// Flatten the full chain into one contiguous byte buffer.
50    ///
51    /// The layout is level-major for a single-layer texture:
52    /// `Mip0, Mip1, Mip2, ...`.
53    pub fn into_bytes(self) -> Vec<u8> {
54        let mut bytes = Vec::with_capacity(self.byte_len());
55        for level in self.levels {
56            bytes.extend_from_slice(&level.data);
57        }
58        bytes
59    }
60}
61
62/// Error type for tile fetching operations.
63#[derive(Debug, Clone, Error)]
64pub enum TileError {
65    /// A network error occurred during tile fetching.
66    #[error("network error: {0}")]
67    Network(String),
68    /// Failed to decode the tile data.
69    #[error("decode error: {0}")]
70    Decode(String),
71    /// The requested tile was not found.
72    #[error("not found: tile {0:?}")]
73    NotFound(TileId),
74    /// An unspecified error.
75    #[error("{0}")]
76    Other(String),
77}
78
79/// Decoded raster image data (RGBA8, row-major).
80///
81/// The pixel buffer is wrapped in `Arc<Vec<u8>>` so that cloning a
82/// `DecodedImage` is a cheap reference-count bump (~256 KiB per tile
83/// is never memcpy'd between engine frames).
84#[derive(Debug, Clone)]
85pub struct DecodedImage {
86    /// Image width in pixels.
87    pub width: u32,
88    /// Image height in pixels.
89    pub height: u32,
90    /// Raw RGBA8 pixel data, row-major.
91    ///
92    /// Shared via `Arc` -- immutable after decode.
93    pub data: Arc<Vec<u8>>,
94}
95
96impl DecodedImage {
97    /// Return the expected RGBA8 byte length for this image.
98    ///
99    /// Returns `None` if the dimensions overflow `usize`.
100    #[inline]
101    pub fn expected_len(&self) -> Option<usize> {
102        (self.width as usize)
103            .checked_mul(self.height as usize)?
104            .checked_mul(RGBA8_BYTES_PER_PIXEL)
105    }
106
107    /// Return the actual byte length of the shared pixel buffer.
108    #[inline]
109    pub fn byte_len(&self) -> usize {
110        self.data.len()
111    }
112
113    /// Return `true` if the image has zero dimensions or zero bytes.
114    #[inline]
115    pub fn is_empty(&self) -> bool {
116        self.width == 0 || self.height == 0 || self.data.is_empty()
117    }
118
119    /// Validate that the payload is well-formed RGBA8 data.
120    pub fn validate_rgba8(&self) -> Result<(), TileError> {
121        if self.width == 0 || self.height == 0 {
122            return Err(TileError::Decode(format!(
123                "invalid raster dimensions: {}x{}",
124                self.width, self.height
125            )));
126        }
127
128        let Some(expected_len) = self.expected_len() else {
129            return Err(TileError::Decode(format!(
130                "image dimensions overflow byte length computation: {}x{}",
131                self.width, self.height
132            )));
133        };
134
135        if self.data.len() != expected_len {
136            return Err(TileError::Decode(format!(
137                "invalid RGBA8 payload length: got {}, expected {} for {}x{}",
138                self.data.len(),
139                expected_len,
140                self.width,
141                self.height
142            )));
143        }
144
145        Ok(())
146    }
147
148    /// Generate a full RGBA8 mip chain from this image.
149    ///
150    /// RGB channels are downsampled in linear light and with
151    /// premultiplied-alpha accumulation so oblique minification stays
152    /// sharper and more stable than single-level sampling.
153    pub fn build_mip_chain_rgba8(&self) -> Result<RasterMipChain, TileError> {
154        self.validate_rgba8()?;
155
156        let mut levels = Vec::new();
157        levels.push(RasterMipLevel {
158            width: self.width,
159            height: self.height,
160            data: self.data.to_vec(),
161        });
162
163        while let Some(prev) = levels.last() {
164            if prev.width == 1 && prev.height == 1 {
165                break;
166            }
167
168            levels.push(downsample_rgba8_level(prev)?);
169        }
170
171        Ok(RasterMipChain { levels })
172    }
173}
174
175fn downsample_rgba8_level(prev: &RasterMipLevel) -> Result<RasterMipLevel, TileError> {
176    let src_width = prev.width as usize;
177    let src_height = prev.height as usize;
178    let dst_width = (prev.width / 2).max(1);
179    let dst_height = (prev.height / 2).max(1);
180
181    let expected_len = src_width
182        .checked_mul(src_height)
183        .and_then(|px| px.checked_mul(RGBA8_BYTES_PER_PIXEL))
184        .ok_or_else(|| TileError::Decode(format!(
185            "mip source dimensions overflow byte length computation: {}x{}",
186            prev.width, prev.height
187        )))?;
188
189    if prev.data.len() != expected_len {
190        return Err(TileError::Decode(format!(
191            "invalid mip source length: got {}, expected {} for {}x{}",
192            prev.data.len(),
193            expected_len,
194            prev.width,
195            prev.height
196        )));
197    }
198
199    let mut out = vec![0u8; dst_width as usize * dst_height as usize * RGBA8_BYTES_PER_PIXEL];
200
201    for y in 0..dst_height as usize {
202        for x in 0..dst_width as usize {
203            let sx0 = (x * 2).min(src_width - 1);
204            let sy0 = (y * 2).min(src_height - 1);
205            let sx1 = (sx0 + 1).min(src_width - 1);
206            let sy1 = (sy0 + 1).min(src_height - 1);
207
208            let taps = [(sx0, sy0), (sx1, sy0), (sx0, sy1), (sx1, sy1)];
209            let mut premul_r = 0.0f32;
210            let mut premul_g = 0.0f32;
211            let mut premul_b = 0.0f32;
212            let mut alpha = 0.0f32;
213
214            for (sx, sy) in taps {
215                let idx = (sy * src_width + sx) * RGBA8_BYTES_PER_PIXEL;
216                let a = prev.data[idx + 3] as f32 / 255.0;
217                premul_r += srgb8_to_linear(prev.data[idx]) * a;
218                premul_g += srgb8_to_linear(prev.data[idx + 1]) * a;
219                premul_b += srgb8_to_linear(prev.data[idx + 2]) * a;
220                alpha += a;
221            }
222
223            let sample_count = taps.len() as f32;
224            let out_idx = (y * dst_width as usize + x) * RGBA8_BYTES_PER_PIXEL;
225            let avg_alpha = alpha / sample_count;
226
227            if avg_alpha > 0.0 {
228                let inv_alpha = 1.0 / alpha.max(1e-6);
229                out[out_idx] = linear_to_srgb8((premul_r * inv_alpha).clamp(0.0, 1.0));
230                out[out_idx + 1] = linear_to_srgb8((premul_g * inv_alpha).clamp(0.0, 1.0));
231                out[out_idx + 2] = linear_to_srgb8((premul_b * inv_alpha).clamp(0.0, 1.0));
232            } else {
233                out[out_idx] = 0;
234                out[out_idx + 1] = 0;
235                out[out_idx + 2] = 0;
236            }
237            out[out_idx + 3] = ((avg_alpha.clamp(0.0, 1.0) * 255.0) + 0.5) as u8;
238        }
239    }
240
241    Ok(RasterMipLevel {
242        width: dst_width,
243        height: dst_height,
244        data: out,
245    })
246}
247
248// ---------------------------------------------------------------------------
249// sRGB <-> linear look-up tables
250//
251// The forward table (sRGB u8 -> linear f32) has 256 entries and is exact.
252// The inverse table (linear f32 -> sRGB u8) uses 4096 entries covering
253// [0, 1] and a fast index computation that replaces two `powf` calls
254// per pixel with a single array lookup.
255// ---------------------------------------------------------------------------
256
257use std::sync::LazyLock;
258
259/// sRGB u8 -> linear f32, 256-entry LUT (initialised on first access).
260static SRGB_TO_LINEAR_LUT: LazyLock<[f32; 256]> = LazyLock::new(|| {
261    let mut lut = [0.0f32; 256];
262    for i in 0u32..256 {
263        let s = i as f64 / 255.0;
264        lut[i as usize] = if s <= 0.04045 {
265            (s / 12.92) as f32
266        } else {
267            ((s + 0.055) / 1.055).powf(2.4) as f32
268        };
269    }
270    lut
271});
272
273/// Linear f32 -> sRGB u8, 4096-entry LUT.
274///
275/// Index = `(linear_value * 4095.0 + 0.5) as usize`, clamped to [0, 4095].
276static LINEAR_TO_SRGB_LUT: LazyLock<[u8; 4096]> = LazyLock::new(|| {
277    let mut lut = [0u8; 4096];
278    for i in 0u32..4096 {
279        let lin = i as f64 / 4095.0;
280        let s = if lin <= 0.0031308 {
281            lin * 12.92
282        } else {
283            1.055 * lin.powf(1.0 / 2.4) - 0.055
284        };
285        lut[i as usize] = ((s.clamp(0.0, 1.0) * 255.0) + 0.5) as u8;
286    }
287    lut
288});
289
290#[inline]
291fn srgb8_to_linear(v: u8) -> f32 {
292    SRGB_TO_LINEAR_LUT[v as usize]
293}
294
295#[inline]
296fn linear_to_srgb8(v: f32) -> u8 {
297    let idx = ((v * 4095.0) + 0.5) as usize;
298    LINEAR_TO_SRGB_LUT[if idx > 4095 { 4095 } else { idx }]
299}
300
301/// Freshness metadata attached to a fetched tile payload.
302#[derive(Debug, Clone, Default, PartialEq, Eq)]
303pub struct TileFreshness {
304    /// Absolute time when this payload becomes stale.
305    pub expires_at: Option<SystemTime>,
306    /// Optional entity tag used for conditional revalidation.
307    pub etag: Option<String>,
308    /// Optional `Last-Modified` validator.
309    pub last_modified: Option<String>,
310}
311
312impl TileFreshness {
313    /// Return `true` when the payload should be treated as stale at `now`.
314    #[inline]
315    pub fn is_expired_at(&self, now: SystemTime) -> bool {
316        self.expires_at.is_some_and(|expires_at| now >= expires_at)
317    }
318
319    /// Return `true` when the payload is currently stale.
320    #[inline]
321    pub fn is_expired(&self) -> bool {
322        self.is_expired_at(SystemTime::now())
323    }
324}
325
326/// Hints passed to [`TileSource::request_revalidate`] so that the
327/// implementation can build conditional-request headers.
328///
329/// Both fields are `Option` because the original response may not have
330/// included these validators.  When present, implementations should
331/// send `If-None-Match` (for `etag`) and/or `If-Modified-Since` (for
332/// `last_modified`) headers.  If the server responds `304 Not Modified`,
333/// the source should return a [`TileResponse::not_modified`] result so
334/// the cache refreshes its TTL without re-decoding the payload.
335#[derive(Debug, Clone, Default)]
336pub struct RevalidationHint {
337    /// Value of the `ETag` header from the previous response.
338    pub etag: Option<String>,
339    /// Value of the `Last-Modified` header from the previous response.
340    pub last_modified: Option<String>,
341}
342
343impl RevalidationHint {
344    /// Whether this hint carries at least one usable validator.
345    #[inline]
346    pub fn has_validators(&self) -> bool {
347        self.etag.is_some() || self.last_modified.is_some()
348    }
349}
350
351/// A completed tile payload plus optional cache-freshness metadata.
352#[derive(Debug, Clone)]
353pub struct TileResponse {
354    /// The decoded tile payload.
355    pub data: TileData,
356    /// Freshness metadata derived from the source response.
357    pub freshness: TileFreshness,
358    /// When `true`, this response represents a `304 Not Modified`
359    /// confirmation � the `data` field is a zero-cost placeholder and
360    /// should be ignored.  The cache should refresh the existing entry's
361    /// TTL using `freshness` without replacing its payload.
362    pub not_modified: bool,
363}
364
365impl TileResponse {
366    /// Create a response wrapper with no freshness metadata.
367    #[inline]
368    pub fn from_data(data: TileData) -> Self {
369        Self {
370            data,
371            freshness: TileFreshness::default(),
372            not_modified: false,
373        }
374    }
375
376    /// Attach freshness metadata to this payload.
377    #[inline]
378    pub fn with_freshness(mut self, freshness: TileFreshness) -> Self {
379        self.freshness = freshness;
380        self
381    }
382
383    /// Create a `304 Not Modified` response carrying only updated
384    /// freshness metadata.
385    ///
386    /// The `data` field is an empty raster placeholder and should be
387    /// ignored � the cache retains the existing payload and only
388    /// refreshes its TTL.
389    #[inline]
390    pub fn not_modified(freshness: TileFreshness) -> Self {
391        Self {
392            data: TileData::Raster(DecodedImage {
393                width: 0,
394                height: 0,
395                data: std::sync::Arc::new(Vec::new()),
396            }),
397            freshness,
398            not_modified: true,
399        }
400    }
401}
402
403impl From<TileData> for TileResponse {
404    #[inline]
405    fn from(value: TileData) -> Self {
406        Self::from_data(value)
407    }
408}
409
410/// Decoded vector tile payload: per-source-layer feature collections.
411///
412/// This is the output of the MVT/PBF decoder.  Each key in `layers`
413/// corresponds to a source layer name inside the vector tile, and
414/// the value is the decoded feature collection for that layer.
415#[derive(Debug, Clone)]
416pub struct VectorTileData {
417    /// Per-source-layer feature collections.
418    pub layers: HashMap<String, FeatureCollection>,
419}
420
421impl VectorTileData {
422    /// Return the total number of features across all layers.
423    pub fn feature_count(&self) -> usize {
424        self.layers.values().map(|fc| fc.len()).sum()
425    }
426
427    /// Return the number of source layers.
428    #[inline]
429    pub fn layer_count(&self) -> usize {
430        self.layers.len()
431    }
432
433    /// Return `true` if all layers are empty.
434    pub fn is_empty(&self) -> bool {
435        self.layers.values().all(|fc| fc.is_empty())
436    }
437
438    /// Look up a source layer by name.
439    pub fn layer(&self, name: &str) -> Option<&FeatureCollection> {
440        self.layers.get(name)
441    }
442
443    /// Return the names of all source layers.
444    pub fn layer_names(&self) -> Vec<&str> {
445        self.layers.keys().map(String::as_str).collect()
446    }
447
448    /// Approximate byte size for cache accounting.
449    ///
450    /// This counts coordinate data only (16 bytes per `GeoCoord`).
451    pub fn approx_byte_len(&self) -> usize {
452        self.layers
453            .values()
454            .map(|fc| fc.total_coords() * 16)
455            .sum()
456    }
457}
458
459/// Raw (undecoded) vector tile payload.
460///
461/// Carries the wire-format PBF bytes, the originating tile ID, and the
462/// decode options so that decoding can be deferred to a background thread
463/// via [`DataTaskPool::spawn_decode`](crate::async_data::DataTaskPool::spawn_decode).
464#[derive(Debug, Clone)]
465pub struct RawVectorPayload {
466    /// The originating tile ID needed by the MVT decoder.
467    pub tile_id: TileId,
468    /// Raw PBF/MVT bytes as received from the network.
469    pub bytes: Arc<Vec<u8>>,
470    /// Decode options to apply (layer filter, etc.).
471    pub decode_options: crate::mvt::MvtDecodeOptions,
472}
473
474/// The payload of a fetched tile.
475#[derive(Debug, Clone)]
476pub enum TileData {
477    /// A decoded raster image (RGBA8).
478    Raster(DecodedImage),
479    /// Decoded vector tile with per-source-layer feature collections.
480    Vector(VectorTileData),
481    /// Raw (undecoded) vector tile bytes awaiting background decode.
482    ///
483    /// This variant is produced by [`HttpVectorTileSource`] when
484    /// deferred decoding is enabled and is promoted to [`Vector`](Self::Vector)
485    /// once the background decode task completes.
486    RawVector(RawVectorPayload),
487}
488
489impl TileData {
490    /// Return the raster image if this tile contains raster data.
491    #[inline]
492    pub fn as_raster(&self) -> Option<&DecodedImage> {
493        match self {
494            Self::Raster(image) => Some(image),
495            Self::Vector(_) | Self::RawVector(_) => None,
496        }
497    }
498
499    /// Return the vector tile data if this tile contains vector data.
500    #[inline]
501    pub fn as_vector(&self) -> Option<&VectorTileData> {
502        match self {
503            Self::Vector(data) => Some(data),
504            Self::Raster(_) | Self::RawVector(_) => None,
505        }
506    }
507
508    /// Return `true` if this is a raster tile.
509    #[inline]
510    pub fn is_raster(&self) -> bool {
511        matches!(self, Self::Raster(_))
512    }
513
514    /// Return `true` if this is a decoded vector tile.
515    #[inline]
516    pub fn is_vector(&self) -> bool {
517        matches!(self, Self::Vector(_))
518    }
519
520    /// Return `true` if this is a raw (undecoded) vector tile.
521    #[inline]
522    pub fn is_raw_vector(&self) -> bool {
523        matches!(self, Self::RawVector(_))
524    }
525
526    /// Return the raw vector payload if this is an undecoded vector tile.
527    #[inline]
528    pub fn as_raw_vector(&self) -> Option<&RawVectorPayload> {
529        match self {
530            Self::RawVector(raw) => Some(raw),
531            _ => None,
532        }
533    }
534
535    /// Return the raster dimensions in pixels.
536    ///
537    /// Returns `(0, 0)` for vector tiles.
538    #[inline]
539    pub fn dimensions(&self) -> (u32, u32) {
540        match self {
541            Self::Raster(image) => (image.width, image.height),
542            Self::Vector(_) | Self::RawVector(_) => (0, 0),
543        }
544    }
545
546    /// Return the payload size in bytes.
547    #[inline]
548    pub fn byte_len(&self) -> usize {
549        match self {
550            Self::Raster(image) => image.byte_len(),
551            Self::Vector(data) => data.approx_byte_len(),
552            Self::RawVector(raw) => raw.bytes.len(),
553        }
554    }
555
556    /// Return `true` if the payload is empty or has zero dimensions.
557    #[inline]
558    pub fn is_empty(&self) -> bool {
559        match self {
560            Self::Raster(image) => image.is_empty(),
561            Self::Vector(data) => data.is_empty(),
562            Self::RawVector(raw) => raw.bytes.is_empty(),
563        }
564    }
565
566    /// Validate the tile payload.
567    #[inline]
568    pub fn validate(&self) -> Result<(), TileError> {
569        match self {
570            Self::Raster(image) => image.validate_rgba8(),
571            Self::Vector(_) | Self::RawVector(_) => Ok(()),
572        }
573    }
574}
575
576/// Decodes raw HTTP response bytes into a [`DecodedImage`].
577///
578/// Implementations bridge the engine to an image decoding library
579/// (e.g. the `image` crate, `stb_image`, browser canvas, etc.)
580/// without adding a hard dependency to `rustial-engine`.
581pub trait TileDecoder: Send + Sync {
582    /// Decode raw bytes (PNG, JPEG, WebP, etc.) into RGBA8 pixel data.
583    fn decode(&self, bytes: &[u8]) -> Result<DecodedImage, TileError>;
584}
585
586/// Optional runtime diagnostics exposed by a tile source.
587#[derive(Debug, Clone, Default, PartialEq, Eq)]
588pub struct TileSourceFailureDiagnostics {
589    /// Number of transport-level failures reported by the HTTP client.
590    pub transport_failures: u64,
591    /// Number of non-404 HTTP status failures.
592    pub http_status_failures: u64,
593    /// Number of `404 Not Found` tile responses.
594    pub not_found_failures: u64,
595    /// Number of decode failures returned by the tile decoder.
596    pub decode_failures: u64,
597    /// Number of transport failures classified as timeouts.
598    pub timeout_failures: u64,
599    /// Number of requests force-cancelled by the engine/source path.
600    pub forced_cancellations: u64,
601    /// Number of completed responses ignored because their request mapping was removed.
602    pub ignored_completed_responses: u64,
603}
604
605/// Optional runtime diagnostics exposed by a tile source.
606#[derive(Debug, Clone, Default, PartialEq, Eq)]
607pub struct TileSourceDiagnostics {
608    /// Number of queued requests not yet sent to the transport.
609    pub queued_requests: usize,
610    /// Number of requests currently in-flight.
611    pub in_flight_requests: usize,
612    /// Number of URLs tracked by the source transport dedup set.
613    pub known_requests: usize,
614    /// Number of URLs marked as force-cancelled while already in-flight.
615    pub cancelled_in_flight_requests: usize,
616    /// Maximum number of concurrent in-flight requests allowed by the source.
617    pub max_concurrent_requests: usize,
618    /// Number of MVT decode tasks currently in flight on background threads.
619    pub pending_decode_tasks: usize,
620    /// Categorized source-side failure and cancellation counts.
621    pub failure_diagnostics: TileSourceFailureDiagnostics,
622}
623
624/// A tile source that can fetch tiles by ID.
625///
626/// The engine does not own an async runtime. The host provides
627/// completed results via polling or callbacks.
628pub trait TileSource: Send + Sync {
629    /// Start fetching a tile. Returns immediately.
630    ///
631    /// The implementation should arrange for the result to be
632    /// retrievable via [`TileSource::poll`].
633    fn request(&self, id: TileId);
634
635    /// Start fetching multiple tiles.
636    ///
637    /// The default implementation forwards to [`request`](Self::request)
638    /// in-order, but implementations may override this to batch or
639    /// reprioritize work internally.
640    fn request_many(&self, ids: &[TileId]) {
641        for &id in ids {
642            self.request(id);
643        }
644    }
645
646    /// Start a conditional revalidation fetch for a stale tile.
647    ///
648    /// Implementations that support conditional revalidation should
649    /// attach `If-None-Match` / `If-Modified-Since` headers derived
650    /// from `hint` so the server can respond with `304 Not Modified`
651    /// when the tile has not changed.
652    ///
653    /// The default implementation ignores the hint and falls back to a
654    /// normal [`request`](Self::request).
655    fn request_revalidate(&self, id: TileId, _hint: RevalidationHint) {
656        self.request(id);
657    }
658
659    /// Start conditional revalidation for multiple tiles.
660    ///
661    /// The default implementation forwards to
662    /// [`request_revalidate`](Self::request_revalidate) in-order.
663    fn request_revalidate_many(&self, ids: &[(TileId, RevalidationHint)]) {
664        for (id, hint) in ids {
665            self.request_revalidate(*id, hint.clone());
666        }
667    }
668
669    /// Poll for completed tile fetches.
670    ///
671    /// Returns a vector of `(TileId, Result)` pairs for all tiles
672    /// that have completed since the last poll.
673    fn poll(&self) -> Vec<(TileId, Result<TileResponse, TileError>)>;
674
675    /// Cancel a previously requested tile fetch.
676    ///
677    /// Implementations may ignore this if cancellation is not supported.
678    /// The default implementation does nothing.
679    fn cancel(&self, _id: TileId) {}
680
681    /// Cancel multiple previously requested tile fetches.
682    ///
683    /// The default implementation forwards to [`cancel`](Self::cancel)
684    /// in-order.
685    fn cancel_many(&self, ids: &[TileId]) {
686        for &id in ids {
687            self.cancel(id);
688        }
689    }
690
691    /// Optional runtime diagnostics about the source fetch pipeline.
692    fn diagnostics(&self) -> Option<TileSourceDiagnostics> {
693        None
694    }
695}
696
697#[cfg(test)]
698mod tests {
699    use super::*;
700    use std::sync::Mutex;
701
702    #[derive(Default)]
703    struct RecordingSource {
704        requested: Mutex<Vec<TileId>>,
705        cancelled: Mutex<Vec<TileId>>,
706    }
707
708    impl TileSource for RecordingSource {
709        fn request(&self, id: TileId) {
710            self.requested.lock().unwrap().push(id);
711        }
712
713        fn poll(&self) -> Vec<(TileId, Result<TileResponse, TileError>)> {
714            Vec::new()
715        }
716
717        fn cancel(&self, id: TileId) {
718            self.cancelled.lock().unwrap().push(id);
719        }
720    }
721
722    #[test]
723    fn decoded_image_validation_accepts_valid_rgba8() {
724        let image = DecodedImage {
725            width: 2,
726            height: 2,
727            data: vec![255u8; 16].into(),
728        };
729
730        assert_eq!(image.expected_len(), Some(16));
731        assert_eq!(image.byte_len(), 16);
732        assert!(!image.is_empty());
733        assert!(image.validate_rgba8().is_ok());
734    }
735
736    #[test]
737    fn decoded_image_validation_rejects_invalid_length() {
738        let image = DecodedImage {
739            width: 2,
740            height: 2,
741            data: vec![255u8; 15].into(),
742        };
743
744        let err = image.validate_rgba8().expect_err("image should be invalid");
745        assert!(matches!(err, TileError::Decode(_)));
746    }
747
748    #[test]
749    fn tile_data_helpers_delegate_to_raster_payload() {
750        let tile = TileData::Raster(DecodedImage {
751            width: 1,
752            height: 2,
753            data: vec![1u8; 8].into(),
754        });
755
756        assert_eq!(tile.dimensions(), (1, 2));
757        assert_eq!(tile.byte_len(), 8);
758        assert!(tile.as_raster().is_some());
759        assert!(tile.as_vector().is_none());
760        assert!(tile.is_raster());
761        assert!(!tile.is_vector());
762        assert!(!tile.is_empty());
763        assert!(tile.validate().is_ok());
764    }
765
766    #[test]
767    fn tile_data_vector_variant() {
768        use crate::geometry::FeatureCollection;
769        let mut layers = HashMap::new();
770        layers.insert("water".to_string(), FeatureCollection::default());
771        let tile = TileData::Vector(VectorTileData { layers });
772
773        assert!(tile.as_vector().is_some());
774        assert!(tile.as_raster().is_none());
775        assert!(tile.is_vector());
776        assert!(!tile.is_raster());
777        assert_eq!(tile.dimensions(), (0, 0));
778        assert!(tile.is_empty());
779        assert!(tile.validate().is_ok());
780    }
781
782    #[test]
783    fn vector_tile_data_helpers() {
784        use crate::geometry::{Feature, FeatureCollection, Geometry, Point};
785        use rustial_math::GeoCoord;
786
787        let mut layers = HashMap::new();
788        layers.insert(
789            "places".to_string(),
790            FeatureCollection {
791                features: vec![Feature {
792                    geometry: Geometry::Point(Point {
793                        coord: GeoCoord::from_lat_lon(0.0, 0.0),
794                    }),
795                    properties: HashMap::new(),
796                }],
797            },
798        );
799
800        let vt = VectorTileData { layers };
801        assert_eq!(vt.feature_count(), 1);
802        assert_eq!(vt.layer_count(), 1);
803        assert!(!vt.is_empty());
804        assert!(vt.layer("places").is_some());
805        assert!(vt.layer("missing").is_none());
806        assert_eq!(vt.layer_names(), vec!["places"]);
807        assert!(vt.approx_byte_len() > 0);
808    }
809
810    #[test]
811    fn tile_source_batch_defaults_forward_in_order() {
812        let source = RecordingSource::default();
813        let ids = [TileId::new(1, 0, 0), TileId::new(1, 1, 0), TileId::new(1, 0, 1)];
814
815        source.request_many(&ids);
816        source.cancel_many(&ids[1..]);
817
818        assert_eq!(*source.requested.lock().unwrap(), ids);
819        assert_eq!(*source.cancelled.lock().unwrap(), ids[1..]);
820    }
821
822    #[test]
823    fn decoded_image_builds_full_mip_chain() {
824        let image = DecodedImage {
825            width: 4,
826            height: 2,
827            data: vec![255u8; 4 * 2 * 4].into(),
828        };
829
830        let mip_chain = image.build_mip_chain_rgba8().expect("valid image should mipmap");
831        let dims: Vec<(u32, u32)> = mip_chain
832            .levels()
833            .iter()
834            .map(|level| (level.width, level.height))
835            .collect();
836
837        assert_eq!(dims, vec![(4, 2), (2, 1), (1, 1)]);
838        assert_eq!(mip_chain.level_count(), 3);
839        assert_eq!(mip_chain.byte_len(), 32 + 8 + 4);
840    }
841
842    #[test]
843    fn decoded_image_mip_chain_preserves_constant_opaque_color() {
844        let mut data = vec![0u8; 4 * 4 * 4];
845        for pixel in data.chunks_exact_mut(4) {
846            pixel.copy_from_slice(&[32, 96, 224, 255]);
847        }
848
849        let image = DecodedImage {
850            width: 4,
851            height: 4,
852            data: data.into(),
853        };
854
855        let mip_chain = image.build_mip_chain_rgba8().expect("valid image should mipmap");
856        for level in mip_chain.levels() {
857            for pixel in level.data.chunks_exact(4) {
858                assert_eq!(pixel, [32, 96, 224, 255]);
859            }
860        }
861    }
862
863    #[test]
864    fn srgb_lut_roundtrip_is_within_one_lsb() {
865        // Verify the compile-time LUT matches the reference powf
866        // implementation for every u8 input value.
867        for i in 0u16..=255 {
868            let lut_val = SRGB_TO_LINEAR_LUT[i as usize];
869            let s = i as f32 / 255.0;
870            let ref_val = if s <= 0.04045 {
871                s / 12.92
872            } else {
873                ((s + 0.055) / 1.055).powf(2.4)
874            };
875            let err = (lut_val - ref_val).abs();
876            assert!(
877                err < 1e-6,
878                "srgb_to_linear LUT[{i}]: lut={lut_val}, ref={ref_val}, err={err}"
879            );
880        }
881
882        // Verify the inverse LUT roundtrips correctly (within +/-1 LSB).
883        for i in 0u16..=255 {
884            let linear = SRGB_TO_LINEAR_LUT[i as usize];
885            let back = linear_to_srgb8(linear);
886            let diff = (back as i16 - i as i16).unsigned_abs();
887            assert!(
888                diff <= 1,
889                "roundtrip failed for {i}: got {back}, diff={diff}"
890            );
891        }
892    }
893}