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