rustial-engine 0.0.1

Framework-agnostic 2.5D map engine for rustial
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
//! Tile source trait and data types.

use crate::geometry::FeatureCollection;
use rustial_math::TileId;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::SystemTime;
use thiserror::Error;

/// Number of bytes per pixel for RGBA8 imagery.
const RGBA8_BYTES_PER_PIXEL: usize = 4;

/// One generated mip level of an RGBA8 raster image.
#[derive(Debug, Clone)]
pub struct RasterMipLevel {
    /// Mip width in pixels.
    pub width: u32,
    /// Mip height in pixels.
    pub height: u32,
    /// RGBA8 pixel data for this mip level.
    pub data: Vec<u8>,
}

/// Full generated mip chain for an RGBA8 raster image.
#[derive(Debug, Clone)]
pub struct RasterMipChain {
    levels: Vec<RasterMipLevel>,
}

impl RasterMipChain {
    /// Borrow all mip levels from base level 0 down to 1x1.
    #[inline]
    pub fn levels(&self) -> &[RasterMipLevel] {
        &self.levels
    }

    /// Number of mip levels in the chain.
    #[inline]
    pub fn level_count(&self) -> u32 {
        self.levels.len() as u32
    }

    /// Total number of bytes across all mip levels.
    #[inline]
    pub fn byte_len(&self) -> usize {
        self.levels.iter().map(|level| level.data.len()).sum()
    }

    /// Flatten the full chain into one contiguous byte buffer.
    ///
    /// The layout is level-major for a single-layer texture:
    /// `Mip0, Mip1, Mip2, ...`.
    pub fn into_bytes(self) -> Vec<u8> {
        let mut bytes = Vec::with_capacity(self.byte_len());
        for level in self.levels {
            bytes.extend_from_slice(&level.data);
        }
        bytes
    }
}

/// Error type for tile fetching operations.
#[derive(Debug, Clone, Error)]
pub enum TileError {
    /// A network error occurred during tile fetching.
    #[error("network error: {0}")]
    Network(String),
    /// Failed to decode the tile data.
    #[error("decode error: {0}")]
    Decode(String),
    /// The requested tile was not found.
    #[error("not found: tile {0:?}")]
    NotFound(TileId),
    /// An unspecified error.
    #[error("{0}")]
    Other(String),
}

/// Decoded raster image data (RGBA8, row-major).
///
/// The pixel buffer is wrapped in `Arc<Vec<u8>>` so that cloning a
/// `DecodedImage` is a cheap reference-count bump (~256 KiB per tile
/// is never memcpy'd between engine frames).
#[derive(Debug, Clone)]
pub struct DecodedImage {
    /// Image width in pixels.
    pub width: u32,
    /// Image height in pixels.
    pub height: u32,
    /// Raw RGBA8 pixel data, row-major.
    ///
    /// Shared via `Arc` -- immutable after decode.
    pub data: Arc<Vec<u8>>,
}

impl DecodedImage {
    /// Return the expected RGBA8 byte length for this image.
    ///
    /// Returns `None` if the dimensions overflow `usize`.
    #[inline]
    pub fn expected_len(&self) -> Option<usize> {
        (self.width as usize)
            .checked_mul(self.height as usize)?
            .checked_mul(RGBA8_BYTES_PER_PIXEL)
    }

    /// Return the actual byte length of the shared pixel buffer.
    #[inline]
    pub fn byte_len(&self) -> usize {
        self.data.len()
    }

    /// Return `true` if the image has zero dimensions or zero bytes.
    #[inline]
    pub fn is_empty(&self) -> bool {
        self.width == 0 || self.height == 0 || self.data.is_empty()
    }

    /// Validate that the payload is well-formed RGBA8 data.
    pub fn validate_rgba8(&self) -> Result<(), TileError> {
        if self.width == 0 || self.height == 0 {
            return Err(TileError::Decode(format!(
                "invalid raster dimensions: {}x{}",
                self.width, self.height
            )));
        }

        let Some(expected_len) = self.expected_len() else {
            return Err(TileError::Decode(format!(
                "image dimensions overflow byte length computation: {}x{}",
                self.width, self.height
            )));
        };

        if self.data.len() != expected_len {
            return Err(TileError::Decode(format!(
                "invalid RGBA8 payload length: got {}, expected {} for {}x{}",
                self.data.len(),
                expected_len,
                self.width,
                self.height
            )));
        }

        Ok(())
    }

    /// Generate a full RGBA8 mip chain from this image.
    ///
    /// RGB channels are downsampled in linear light and with
    /// premultiplied-alpha accumulation so oblique minification stays
    /// sharper and more stable than single-level sampling.
    pub fn build_mip_chain_rgba8(&self) -> Result<RasterMipChain, TileError> {
        self.validate_rgba8()?;

        let mut levels = Vec::new();
        levels.push(RasterMipLevel {
            width: self.width,
            height: self.height,
            data: self.data.to_vec(),
        });

        while let Some(prev) = levels.last() {
            if prev.width == 1 && prev.height == 1 {
                break;
            }

            levels.push(downsample_rgba8_level(prev)?);
        }

        Ok(RasterMipChain { levels })
    }
}

fn downsample_rgba8_level(prev: &RasterMipLevel) -> Result<RasterMipLevel, TileError> {
    let src_width = prev.width as usize;
    let src_height = prev.height as usize;
    let dst_width = (prev.width / 2).max(1);
    let dst_height = (prev.height / 2).max(1);

    let expected_len = src_width
        .checked_mul(src_height)
        .and_then(|px| px.checked_mul(RGBA8_BYTES_PER_PIXEL))
        .ok_or_else(|| {
            TileError::Decode(format!(
                "mip source dimensions overflow byte length computation: {}x{}",
                prev.width, prev.height
            ))
        })?;

    if prev.data.len() != expected_len {
        return Err(TileError::Decode(format!(
            "invalid mip source length: got {}, expected {} for {}x{}",
            prev.data.len(),
            expected_len,
            prev.width,
            prev.height
        )));
    }

    let mut out = vec![0u8; dst_width as usize * dst_height as usize * RGBA8_BYTES_PER_PIXEL];

    for y in 0..dst_height as usize {
        for x in 0..dst_width as usize {
            let sx0 = (x * 2).min(src_width - 1);
            let sy0 = (y * 2).min(src_height - 1);
            let sx1 = (sx0 + 1).min(src_width - 1);
            let sy1 = (sy0 + 1).min(src_height - 1);

            let taps = [(sx0, sy0), (sx1, sy0), (sx0, sy1), (sx1, sy1)];
            let mut premul_r = 0.0f32;
            let mut premul_g = 0.0f32;
            let mut premul_b = 0.0f32;
            let mut alpha = 0.0f32;

            for (sx, sy) in taps {
                let idx = (sy * src_width + sx) * RGBA8_BYTES_PER_PIXEL;
                let a = prev.data[idx + 3] as f32 / 255.0;
                premul_r += srgb8_to_linear(prev.data[idx]) * a;
                premul_g += srgb8_to_linear(prev.data[idx + 1]) * a;
                premul_b += srgb8_to_linear(prev.data[idx + 2]) * a;
                alpha += a;
            }

            let sample_count = taps.len() as f32;
            let out_idx = (y * dst_width as usize + x) * RGBA8_BYTES_PER_PIXEL;
            let avg_alpha = alpha / sample_count;

            if avg_alpha > 0.0 {
                let inv_alpha = 1.0 / alpha.max(1e-6);
                out[out_idx] = linear_to_srgb8((premul_r * inv_alpha).clamp(0.0, 1.0));
                out[out_idx + 1] = linear_to_srgb8((premul_g * inv_alpha).clamp(0.0, 1.0));
                out[out_idx + 2] = linear_to_srgb8((premul_b * inv_alpha).clamp(0.0, 1.0));
            } else {
                out[out_idx] = 0;
                out[out_idx + 1] = 0;
                out[out_idx + 2] = 0;
            }
            out[out_idx + 3] = ((avg_alpha.clamp(0.0, 1.0) * 255.0) + 0.5) as u8;
        }
    }

    Ok(RasterMipLevel {
        width: dst_width,
        height: dst_height,
        data: out,
    })
}

// ---------------------------------------------------------------------------
// sRGB <-> linear look-up tables
//
// The forward table (sRGB u8 -> linear f32) has 256 entries and is exact.
// The inverse table (linear f32 -> sRGB u8) uses 4096 entries covering
// [0, 1] and a fast index computation that replaces two `powf` calls
// per pixel with a single array lookup.
// ---------------------------------------------------------------------------

use std::sync::LazyLock;

/// sRGB u8 -> linear f32, 256-entry LUT (initialised on first access).
static SRGB_TO_LINEAR_LUT: LazyLock<[f32; 256]> = LazyLock::new(|| {
    let mut lut = [0.0f32; 256];
    for i in 0u32..256 {
        let s = i as f64 / 255.0;
        lut[i as usize] = if s <= 0.04045 {
            (s / 12.92) as f32
        } else {
            ((s + 0.055) / 1.055).powf(2.4) as f32
        };
    }
    lut
});

/// Linear f32 -> sRGB u8, 4096-entry LUT.
///
/// Index = `(linear_value * 4095.0 + 0.5) as usize`, clamped to [0, 4095].
static LINEAR_TO_SRGB_LUT: LazyLock<[u8; 4096]> = LazyLock::new(|| {
    let mut lut = [0u8; 4096];
    for i in 0u32..4096 {
        let lin = i as f64 / 4095.0;
        let s = if lin <= 0.0031308 {
            lin * 12.92
        } else {
            1.055 * lin.powf(1.0 / 2.4) - 0.055
        };
        lut[i as usize] = ((s.clamp(0.0, 1.0) * 255.0) + 0.5) as u8;
    }
    lut
});

#[inline]
fn srgb8_to_linear(v: u8) -> f32 {
    SRGB_TO_LINEAR_LUT[v as usize]
}

#[inline]
fn linear_to_srgb8(v: f32) -> u8 {
    let idx = ((v * 4095.0) + 0.5) as usize;
    LINEAR_TO_SRGB_LUT[if idx > 4095 { 4095 } else { idx }]
}

/// Freshness metadata attached to a fetched tile payload.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TileFreshness {
    /// Absolute time when this payload becomes stale.
    pub expires_at: Option<SystemTime>,
    /// Optional entity tag used for conditional revalidation.
    pub etag: Option<String>,
    /// Optional `Last-Modified` validator.
    pub last_modified: Option<String>,
}

impl TileFreshness {
    /// Return `true` when the payload should be treated as stale at `now`.
    #[inline]
    pub fn is_expired_at(&self, now: SystemTime) -> bool {
        self.expires_at.is_some_and(|expires_at| now >= expires_at)
    }

    /// Return `true` when the payload is currently stale.
    #[inline]
    pub fn is_expired(&self) -> bool {
        self.is_expired_at(SystemTime::now())
    }
}

/// Hints passed to [`TileSource::request_revalidate`] so that the
/// implementation can build conditional-request headers.
///
/// Both fields are `Option` because the original response may not have
/// included these validators.  When present, implementations should
/// send `If-None-Match` (for `etag`) and/or `If-Modified-Since` (for
/// `last_modified`) headers.  If the server responds `304 Not Modified`,
/// the source should return a [`TileResponse::not_modified`] result so
/// the cache refreshes its TTL without re-decoding the payload.
#[derive(Debug, Clone, Default)]
pub struct RevalidationHint {
    /// Value of the `ETag` header from the previous response.
    pub etag: Option<String>,
    /// Value of the `Last-Modified` header from the previous response.
    pub last_modified: Option<String>,
}

impl RevalidationHint {
    /// Whether this hint carries at least one usable validator.
    #[inline]
    pub fn has_validators(&self) -> bool {
        self.etag.is_some() || self.last_modified.is_some()
    }
}

/// A completed tile payload plus optional cache-freshness metadata.
#[derive(Debug, Clone)]
pub struct TileResponse {
    /// The decoded tile payload.
    pub data: TileData,
    /// Freshness metadata derived from the source response.
    pub freshness: TileFreshness,
    /// When `true`, this response represents a `304 Not Modified`
    /// confirmation � the `data` field is a zero-cost placeholder and
    /// should be ignored.  The cache should refresh the existing entry's
    /// TTL using `freshness` without replacing its payload.
    pub not_modified: bool,
}

impl TileResponse {
    /// Create a response wrapper with no freshness metadata.
    #[inline]
    pub fn from_data(data: TileData) -> Self {
        Self {
            data,
            freshness: TileFreshness::default(),
            not_modified: false,
        }
    }

    /// Attach freshness metadata to this payload.
    #[inline]
    pub fn with_freshness(mut self, freshness: TileFreshness) -> Self {
        self.freshness = freshness;
        self
    }

    /// Create a `304 Not Modified` response carrying only updated
    /// freshness metadata.
    ///
    /// The `data` field is an empty raster placeholder and should be
    /// ignored � the cache retains the existing payload and only
    /// refreshes its TTL.
    #[inline]
    pub fn not_modified(freshness: TileFreshness) -> Self {
        Self {
            data: TileData::Raster(DecodedImage {
                width: 0,
                height: 0,
                data: std::sync::Arc::new(Vec::new()),
            }),
            freshness,
            not_modified: true,
        }
    }
}

impl From<TileData> for TileResponse {
    #[inline]
    fn from(value: TileData) -> Self {
        Self::from_data(value)
    }
}

/// Decoded vector tile payload: per-source-layer feature collections.
///
/// This is the output of the MVT/PBF decoder.  Each key in `layers`
/// corresponds to a source layer name inside the vector tile, and
/// the value is the decoded feature collection for that layer.
#[derive(Debug, Clone)]
pub struct VectorTileData {
    /// Per-source-layer feature collections.
    pub layers: HashMap<String, FeatureCollection>,
}

impl VectorTileData {
    /// Return the total number of features across all layers.
    pub fn feature_count(&self) -> usize {
        self.layers.values().map(|fc| fc.len()).sum()
    }

    /// Return the number of source layers.
    #[inline]
    pub fn layer_count(&self) -> usize {
        self.layers.len()
    }

    /// Return `true` if all layers are empty.
    pub fn is_empty(&self) -> bool {
        self.layers.values().all(|fc| fc.is_empty())
    }

    /// Look up a source layer by name.
    pub fn layer(&self, name: &str) -> Option<&FeatureCollection> {
        self.layers.get(name)
    }

    /// Return the names of all source layers.
    pub fn layer_names(&self) -> Vec<&str> {
        self.layers.keys().map(String::as_str).collect()
    }

    /// Approximate byte size for cache accounting.
    ///
    /// This counts coordinate data only (16 bytes per `GeoCoord`).
    pub fn approx_byte_len(&self) -> usize {
        self.layers.values().map(|fc| fc.total_coords() * 16).sum()
    }
}

/// Raw (undecoded) vector tile payload.
///
/// Carries the wire-format PBF bytes, the originating tile ID, and the
/// decode options so that decoding can be deferred to a background thread
/// via [`DataTaskPool::spawn_decode`](crate::async_data::DataTaskPool::spawn_decode).
#[derive(Debug, Clone)]
pub struct RawVectorPayload {
    /// The originating tile ID needed by the MVT decoder.
    pub tile_id: TileId,
    /// Raw PBF/MVT bytes as received from the network.
    pub bytes: Arc<Vec<u8>>,
    /// Decode options to apply (layer filter, etc.).
    pub decode_options: crate::mvt::MvtDecodeOptions,
}

/// The payload of a fetched tile.
#[derive(Debug, Clone)]
pub enum TileData {
    /// A decoded raster image (RGBA8).
    Raster(DecodedImage),
    /// Decoded vector tile with per-source-layer feature collections.
    Vector(VectorTileData),
    /// Raw (undecoded) vector tile bytes awaiting background decode.
    ///
    /// This variant is produced by [`HttpVectorTileSource`] when
    /// deferred decoding is enabled and is promoted to [`Vector`](Self::Vector)
    /// once the background decode task completes.
    RawVector(RawVectorPayload),
}

impl TileData {
    /// Return the raster image if this tile contains raster data.
    #[inline]
    pub fn as_raster(&self) -> Option<&DecodedImage> {
        match self {
            Self::Raster(image) => Some(image),
            Self::Vector(_) | Self::RawVector(_) => None,
        }
    }

    /// Return the vector tile data if this tile contains vector data.
    #[inline]
    pub fn as_vector(&self) -> Option<&VectorTileData> {
        match self {
            Self::Vector(data) => Some(data),
            Self::Raster(_) | Self::RawVector(_) => None,
        }
    }

    /// Return `true` if this is a raster tile.
    #[inline]
    pub fn is_raster(&self) -> bool {
        matches!(self, Self::Raster(_))
    }

    /// Return `true` if this is a decoded vector tile.
    #[inline]
    pub fn is_vector(&self) -> bool {
        matches!(self, Self::Vector(_))
    }

    /// Return `true` if this is a raw (undecoded) vector tile.
    #[inline]
    pub fn is_raw_vector(&self) -> bool {
        matches!(self, Self::RawVector(_))
    }

    /// Return the raw vector payload if this is an undecoded vector tile.
    #[inline]
    pub fn as_raw_vector(&self) -> Option<&RawVectorPayload> {
        match self {
            Self::RawVector(raw) => Some(raw),
            _ => None,
        }
    }

    /// Return the raster dimensions in pixels.
    ///
    /// Returns `(0, 0)` for vector tiles.
    #[inline]
    pub fn dimensions(&self) -> (u32, u32) {
        match self {
            Self::Raster(image) => (image.width, image.height),
            Self::Vector(_) | Self::RawVector(_) => (0, 0),
        }
    }

    /// Return the payload size in bytes.
    #[inline]
    pub fn byte_len(&self) -> usize {
        match self {
            Self::Raster(image) => image.byte_len(),
            Self::Vector(data) => data.approx_byte_len(),
            Self::RawVector(raw) => raw.bytes.len(),
        }
    }

    /// Return `true` if the payload is empty or has zero dimensions.
    #[inline]
    pub fn is_empty(&self) -> bool {
        match self {
            Self::Raster(image) => image.is_empty(),
            Self::Vector(data) => data.is_empty(),
            Self::RawVector(raw) => raw.bytes.is_empty(),
        }
    }

    /// Validate the tile payload.
    #[inline]
    pub fn validate(&self) -> Result<(), TileError> {
        match self {
            Self::Raster(image) => image.validate_rgba8(),
            Self::Vector(_) | Self::RawVector(_) => Ok(()),
        }
    }
}

/// Decodes raw HTTP response bytes into a [`DecodedImage`].
///
/// Implementations bridge the engine to an image decoding library
/// (e.g. the `image` crate, `stb_image`, browser canvas, etc.)
/// without adding a hard dependency to `rustial-engine`.
pub trait TileDecoder: Send + Sync {
    /// Decode raw bytes (PNG, JPEG, WebP, etc.) into RGBA8 pixel data.
    fn decode(&self, bytes: &[u8]) -> Result<DecodedImage, TileError>;
}

/// Optional runtime diagnostics exposed by a tile source.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TileSourceFailureDiagnostics {
    /// Number of transport-level failures reported by the HTTP client.
    pub transport_failures: u64,
    /// Number of non-404 HTTP status failures.
    pub http_status_failures: u64,
    /// Number of `404 Not Found` tile responses.
    pub not_found_failures: u64,
    /// Number of decode failures returned by the tile decoder.
    pub decode_failures: u64,
    /// Number of transport failures classified as timeouts.
    pub timeout_failures: u64,
    /// Number of requests force-cancelled by the engine/source path.
    pub forced_cancellations: u64,
    /// Number of completed responses ignored because their request mapping was removed.
    pub ignored_completed_responses: u64,
}

/// Optional runtime diagnostics exposed by a tile source.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TileSourceDiagnostics {
    /// Number of queued requests not yet sent to the transport.
    pub queued_requests: usize,
    /// Number of requests currently in-flight.
    pub in_flight_requests: usize,
    /// Number of URLs tracked by the source transport dedup set.
    pub known_requests: usize,
    /// Number of URLs marked as force-cancelled while already in-flight.
    pub cancelled_in_flight_requests: usize,
    /// Maximum number of concurrent in-flight requests allowed by the source.
    pub max_concurrent_requests: usize,
    /// Number of MVT decode tasks currently in flight on background threads.
    pub pending_decode_tasks: usize,
    /// Categorized source-side failure and cancellation counts.
    pub failure_diagnostics: TileSourceFailureDiagnostics,
}

/// A tile source that can fetch tiles by ID.
///
/// The engine does not own an async runtime. The host provides
/// completed results via polling or callbacks.
pub trait TileSource: Send + Sync {
    /// Start fetching a tile. Returns immediately.
    ///
    /// The implementation should arrange for the result to be
    /// retrievable via [`TileSource::poll`].
    fn request(&self, id: TileId);

    /// Start fetching multiple tiles.
    ///
    /// The default implementation forwards to [`request`](Self::request)
    /// in-order, but implementations may override this to batch or
    /// reprioritize work internally.
    fn request_many(&self, ids: &[TileId]) {
        for &id in ids {
            self.request(id);
        }
    }

    /// Start a conditional revalidation fetch for a stale tile.
    ///
    /// Implementations that support conditional revalidation should
    /// attach `If-None-Match` / `If-Modified-Since` headers derived
    /// from `hint` so the server can respond with `304 Not Modified`
    /// when the tile has not changed.
    ///
    /// The default implementation ignores the hint and falls back to a
    /// normal [`request`](Self::request).
    fn request_revalidate(&self, id: TileId, _hint: RevalidationHint) {
        self.request(id);
    }

    /// Start conditional revalidation for multiple tiles.
    ///
    /// The default implementation forwards to
    /// [`request_revalidate`](Self::request_revalidate) in-order.
    fn request_revalidate_many(&self, ids: &[(TileId, RevalidationHint)]) {
        for (id, hint) in ids {
            self.request_revalidate(*id, hint.clone());
        }
    }

    /// Poll for completed tile fetches.
    ///
    /// Returns a vector of `(TileId, Result)` pairs for all tiles
    /// that have completed since the last poll.
    fn poll(&self) -> Vec<(TileId, Result<TileResponse, TileError>)>;

    /// Cancel a previously requested tile fetch.
    ///
    /// Implementations may ignore this if cancellation is not supported.
    /// The default implementation does nothing.
    fn cancel(&self, _id: TileId) {}

    /// Cancel multiple previously requested tile fetches.
    ///
    /// The default implementation forwards to [`cancel`](Self::cancel)
    /// in-order.
    fn cancel_many(&self, ids: &[TileId]) {
        for &id in ids {
            self.cancel(id);
        }
    }

    /// Optional runtime diagnostics about the source fetch pipeline.
    fn diagnostics(&self) -> Option<TileSourceDiagnostics> {
        None
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::Mutex;

    #[derive(Default)]
    struct RecordingSource {
        requested: Mutex<Vec<TileId>>,
        cancelled: Mutex<Vec<TileId>>,
    }

    impl TileSource for RecordingSource {
        fn request(&self, id: TileId) {
            self.requested.lock().unwrap().push(id);
        }

        fn poll(&self) -> Vec<(TileId, Result<TileResponse, TileError>)> {
            Vec::new()
        }

        fn cancel(&self, id: TileId) {
            self.cancelled.lock().unwrap().push(id);
        }
    }

    #[test]
    fn decoded_image_validation_accepts_valid_rgba8() {
        let image = DecodedImage {
            width: 2,
            height: 2,
            data: vec![255u8; 16].into(),
        };

        assert_eq!(image.expected_len(), Some(16));
        assert_eq!(image.byte_len(), 16);
        assert!(!image.is_empty());
        assert!(image.validate_rgba8().is_ok());
    }

    #[test]
    fn decoded_image_validation_rejects_invalid_length() {
        let image = DecodedImage {
            width: 2,
            height: 2,
            data: vec![255u8; 15].into(),
        };

        let err = image.validate_rgba8().expect_err("image should be invalid");
        assert!(matches!(err, TileError::Decode(_)));
    }

    #[test]
    fn tile_data_helpers_delegate_to_raster_payload() {
        let tile = TileData::Raster(DecodedImage {
            width: 1,
            height: 2,
            data: vec![1u8; 8].into(),
        });

        assert_eq!(tile.dimensions(), (1, 2));
        assert_eq!(tile.byte_len(), 8);
        assert!(tile.as_raster().is_some());
        assert!(tile.as_vector().is_none());
        assert!(tile.is_raster());
        assert!(!tile.is_vector());
        assert!(!tile.is_empty());
        assert!(tile.validate().is_ok());
    }

    #[test]
    fn tile_data_vector_variant() {
        use crate::geometry::FeatureCollection;
        let mut layers = HashMap::new();
        layers.insert("water".to_string(), FeatureCollection::default());
        let tile = TileData::Vector(VectorTileData { layers });

        assert!(tile.as_vector().is_some());
        assert!(tile.as_raster().is_none());
        assert!(tile.is_vector());
        assert!(!tile.is_raster());
        assert_eq!(tile.dimensions(), (0, 0));
        assert!(tile.is_empty());
        assert!(tile.validate().is_ok());
    }

    #[test]
    fn vector_tile_data_helpers() {
        use crate::geometry::{Feature, FeatureCollection, Geometry, Point};
        use rustial_math::GeoCoord;

        let mut layers = HashMap::new();
        layers.insert(
            "places".to_string(),
            FeatureCollection {
                features: vec![Feature {
                    geometry: Geometry::Point(Point {
                        coord: GeoCoord::from_lat_lon(0.0, 0.0),
                    }),
                    properties: HashMap::new(),
                }],
            },
        );

        let vt = VectorTileData { layers };
        assert_eq!(vt.feature_count(), 1);
        assert_eq!(vt.layer_count(), 1);
        assert!(!vt.is_empty());
        assert!(vt.layer("places").is_some());
        assert!(vt.layer("missing").is_none());
        assert_eq!(vt.layer_names(), vec!["places"]);
        assert!(vt.approx_byte_len() > 0);
    }

    #[test]
    fn tile_source_batch_defaults_forward_in_order() {
        let source = RecordingSource::default();
        let ids = [
            TileId::new(1, 0, 0),
            TileId::new(1, 1, 0),
            TileId::new(1, 0, 1),
        ];

        source.request_many(&ids);
        source.cancel_many(&ids[1..]);

        assert_eq!(*source.requested.lock().unwrap(), ids);
        assert_eq!(*source.cancelled.lock().unwrap(), ids[1..]);
    }

    #[test]
    fn decoded_image_builds_full_mip_chain() {
        let image = DecodedImage {
            width: 4,
            height: 2,
            data: vec![255u8; 4 * 2 * 4].into(),
        };

        let mip_chain = image
            .build_mip_chain_rgba8()
            .expect("valid image should mipmap");
        let dims: Vec<(u32, u32)> = mip_chain
            .levels()
            .iter()
            .map(|level| (level.width, level.height))
            .collect();

        assert_eq!(dims, vec![(4, 2), (2, 1), (1, 1)]);
        assert_eq!(mip_chain.level_count(), 3);
        assert_eq!(mip_chain.byte_len(), 32 + 8 + 4);
    }

    #[test]
    fn decoded_image_mip_chain_preserves_constant_opaque_color() {
        let mut data = vec![0u8; 4 * 4 * 4];
        for pixel in data.chunks_exact_mut(4) {
            pixel.copy_from_slice(&[32, 96, 224, 255]);
        }

        let image = DecodedImage {
            width: 4,
            height: 4,
            data: data.into(),
        };

        let mip_chain = image
            .build_mip_chain_rgba8()
            .expect("valid image should mipmap");
        for level in mip_chain.levels() {
            for pixel in level.data.chunks_exact(4) {
                assert_eq!(pixel, [32, 96, 224, 255]);
            }
        }
    }

    #[test]
    fn srgb_lut_roundtrip_is_within_one_lsb() {
        // Verify the compile-time LUT matches the reference powf
        // implementation for every u8 input value.
        for i in 0u16..=255 {
            let lut_val = SRGB_TO_LINEAR_LUT[i as usize];
            let s = i as f32 / 255.0;
            let ref_val = if s <= 0.04045 {
                s / 12.92
            } else {
                ((s + 0.055) / 1.055).powf(2.4)
            };
            let err = (lut_val - ref_val).abs();
            assert!(
                err < 1e-6,
                "srgb_to_linear LUT[{i}]: lut={lut_val}, ref={ref_val}, err={err}"
            );
        }

        // Verify the inverse LUT roundtrips correctly (within +/-1 LSB).
        for i in 0u16..=255 {
            let linear = SRGB_TO_LINEAR_LUT[i as usize];
            let back = linear_to_srgb8(linear);
            let diff = (back as i16 - i as i16).unsigned_abs();
            assert!(
                diff <= 1,
                "roundtrip failed for {i}: got {back}, diff={diff}"
            );
        }
    }
}