Skip to main content

stt_core/
arrow_tile.rs

1//! Arrow-based tile payload format.
2//!
3//! A tile's payload is one or more *layers*. Each layer is a single Arrow
4//! [`RecordBatch`] serialised as an Arrow IPC **stream**. Geometry is encoded
5//! using the GeoArrow interleaved-coordinate convention so the payload can be
6//! consumed directly by GeoArrow-aware renderers (e.g. `@geoarrow/deck.gl`).
7//!
8//! ## Per-layer schema
9//!
10//! | column        | type                                    | notes                         |
11//! |---------------|-----------------------------------------|-------------------------------|
12//! | `id`          | `UInt64`                                | feature id                    |
13//! | `start_time`  | `Int64`                                 | Unix ms, absolute             |
14//! | `end_time`    | `Int64`                                 | Unix ms, absolute             |
15//! | `geometry`    | GeoArrow point / linestring / polygon   | interleaved f64 lon/lat       |
16//! | `vertex_time` | `List<UInt16>` deltas or `List<Int64>` (nullable) | per-vertex Unix ms (optional; see [`build_vertex_time_array`]) |
17//! | `vertex_value`| `List<Float32>` (nullable)              | per-vertex scalar, e.g. SST (optional) |
18//! | `triangles`   | `List<UInt16>` or `List<UInt32>`        | pre-baked earcut indices, feature-local (optional; polygon only) |
19//! | `<property>`  | `Float64` or `Dictionary<UInt16,Utf8>`   | one column per property       |
20//!
21//! All layers in one tile are concatenated with a tiny frame so a tile can
22//! carry, say, a linestring layer and a point layer side by side:
23//!
24//! ```text
25//! [u16 layer_count | ALIGNED_FRAME_FLAG]
26//!   repeated: [u16 name_len][name utf8][u32 ipc_len][pad to 8][ipc stream bytes]
27//! ```
28//!
29//! The leading u16's top bit ([`ALIGNED_FRAME_FLAG`]) marks the *aligned*
30//! frame: zero padding after each `ipc_len` places every Arrow IPC stream at
31//! an 8-byte boundary relative to the payload start, so readers can hand the
32//! stream to an Arrow implementation zero-copy (Arrow buffers are 8-byte
33//! aligned *within* a stream; the stream itself must start aligned for that
34//! to survive). The pad length is not stored — readers derive it as
35//! `(8 - pos % 8) % 8` from the position after `ipc_len`. Frames without the
36//! flag (every archive written before the flag existed) carry no padding and
37//! decode exactly as before.
38
39use crate::error::{Error, Result};
40use crate::types::GeometryType;
41use arrow::array::{
42    Array, ArrayRef, DictionaryArray, FixedSizeListArray, Float32Array, Float32Builder,
43    Float64Array, Int32Array, Int32Builder, Int64Array, Int64Builder, ListArray, ListBuilder,
44    RecordBatch, StringArray, UInt16Array, UInt16Builder, UInt32Builder, UInt64Array, UInt8Array,
45};
46use arrow::buffer::OffsetBuffer;
47use arrow::datatypes::{DataType, Field, Schema, UInt16Type};
48use arrow::ipc::reader::StreamReader;
49use arrow::ipc::writer::StreamWriter;
50use std::collections::{BTreeMap, HashMap, HashSet};
51use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
52use std::sync::{Arc, OnceLock, RwLock};
53
54/// Top bit of the layer frame's leading u16: marks an *aligned* frame whose
55/// IPC streams are padded to 8-byte boundaries (see the module docs). The
56/// remaining 15 bits carry the layer count, so a tile may have at most
57/// `0x7fff` layers. Old frames never set this bit (layer counts are tiny).
58pub const ALIGNED_FRAME_FLAG: u16 = 0x8000;
59
60/// Alignment (bytes) of each layer's Arrow IPC stream within an aligned frame.
61const FRAME_ALIGN: usize = 8;
62
63/// GeoArrow extension-name metadata key.
64const GEOARROW_EXT_KEY: &str = "ARROW:extension:name";
65
66/// GeoArrow extension-*metadata* key — the sibling of [`GEOARROW_EXT_KEY`] that
67/// carries the per-geometry-type JSON metadata (CRS, edge interpretation, ...).
68const GEOARROW_EXT_META_KEY: &str = "ARROW:extension:metadata";
69
70/// CRS advertised on every geometry field.
71///
72/// STT stores interleaved `[lon, lat]` in WGS84, i.e. **OGC:CRS84** (the GeoJSON
73/// longitude-first axis order) — *not* `EPSG:4326`, which strict readers treat as
74/// lat/lon. Emitting it as a GeoArrow `authority_code` makes every tile
75/// self-describing to GDAL / GeoPandas / lonboard / QGIS; without it those
76/// readers fall back to "unknown CRS" even though the geometry is plain lon/lat.
77const GEOARROW_CRS_METADATA: &str = r#"{"crs":"OGC:CRS84","crs_type":"authority_code"}"#;
78
79/// A single coordinate pair (lon, lat) in WGS84 degrees.
80pub type Coord = [f64; 2];
81
82/// Geometry for one layer, grouped by kind. Every feature in a layer shares
83/// one kind — the tiler emits a separate layer per geometry type.
84#[derive(Debug, Clone)]
85pub enum GeometryColumn {
86    /// One coordinate per feature.
87    Point(Vec<Coord>),
88    /// A vertex list per feature.
89    LineString(Vec<Vec<Coord>>),
90    /// A list of rings per feature (ring 0 is the exterior).
91    Polygon(Vec<Vec<Vec<Coord>>>),
92}
93
94impl GeometryColumn {
95    /// Number of features represented.
96    pub fn len(&self) -> usize {
97        match self {
98            GeometryColumn::Point(v) => v.len(),
99            GeometryColumn::LineString(v) => v.len(),
100            GeometryColumn::Polygon(v) => v.len(),
101        }
102    }
103
104    /// Whether the column is empty.
105    pub fn is_empty(&self) -> bool {
106        self.len() == 0
107    }
108
109    /// The matching [`GeometryType`].
110    pub fn kind(&self) -> GeometryType {
111        match self {
112            GeometryColumn::Point(_) => GeometryType::Point,
113            GeometryColumn::LineString(_) => GeometryType::LineString,
114            GeometryColumn::Polygon(_) => GeometryType::Polygon,
115        }
116    }
117
118    /// The GeoArrow extension name for this geometry kind.
119    fn geoarrow_name(&self) -> &'static str {
120        match self {
121            GeometryColumn::Point(_) => "geoarrow.point",
122            GeometryColumn::LineString(_) => "geoarrow.linestring",
123            GeometryColumn::Polygon(_) => "geoarrow.polygon",
124        }
125    }
126}
127
128/// Leaf element type of a [`PropertyColumn::Vector`] — the GPU upload type the
129/// renderer binds the decoded child buffer as.
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131pub enum VectorElem {
132    /// `Float32` leaf — quaternion / scale / generic vec attributes.
133    F32,
134    /// `UInt8` leaf — packed RGBA colour (0–255 per channel).
135    U8,
136}
137
138/// A property column. Values are per-feature and may be missing.
139#[derive(Debug, Clone)]
140pub enum PropertyColumn {
141    /// Numeric values (f64).
142    Numeric(Vec<Option<f64>>),
143    /// Categorical / string values.
144    Categorical(Vec<Option<String>>),
145    /// A fixed-width interleaved vector per feature — a GPU-ready instance
146    /// attribute baked at build time (e.g. a `[qx,qy,qz,qw]` surfel quaternion,
147    /// a `[s_major,s_minor]` scale, an `[r,g,b,a]` colour). Encoded as
148    /// `FixedSizeList<Float32|UInt8, width>` so the TS decoder hands the
149    /// contiguous child buffer straight to deck.gl with **zero per-point work**
150    /// (no main-thread re-interleave). `values` is row-major and flattened:
151    /// feature `i` occupies `[i*width, (i+1)*width)`. No per-element nulls (a
152    /// missing feature is encoded as a zero/identity vector by the producer).
153    Vector {
154        /// Components per feature (the FixedSizeList list size).
155        width: usize,
156        /// Leaf upload type (`F32` or `U8`).
157        elem: VectorElem,
158        /// Flattened row-major values; length must be `width * feature_count`.
159        /// `U8` values are rounded+clamped to `[0,255]` at encode.
160        values: Vec<f32>,
161    },
162}
163
164/// One decoded/encodable tile layer.
165#[derive(Debug, Clone)]
166pub struct ColumnarLayer {
167    /// Layer name (e.g. `"default"`, `"default_originals"`).
168    pub name: String,
169    /// Per-feature id.
170    pub feature_ids: Vec<u64>,
171    /// Per-feature start time (Unix ms, absolute).
172    pub start_times: Vec<i64>,
173    /// Per-feature end time (Unix ms, absolute).
174    pub end_times: Vec<i64>,
175    /// Geometry, one entry per feature.
176    pub geometry: GeometryColumn,
177    /// Optional per-vertex timestamps (Unix ms). When present, length equals
178    /// the feature count and each inner vec matches that feature's vertex count.
179    pub vertex_times: Option<Vec<Vec<i64>>>,
180    /// Optional per-vertex scalar values (producer-defined; e.g. sea-surface
181    /// temperature for the ocean-drifter dataset). When present, length equals
182    /// the feature count and each inner vec matches that feature's vertex count.
183    /// `NaN` marks a vertex with no value; renderers map it to a fallback color.
184    pub vertex_values: Option<Vec<Vec<f32>>>,
185    /// Optional per-vertex × per-time-bucket value matrix, flattened
186    /// **vertex-major** per feature: `matrix[v * num_buckets + b]`. When
187    /// present, length equals the feature count and each inner vec is
188    /// `feature_vertex_count * num_buckets` long. Lets a static-geometry
189    /// overview carry a per-vertex time series (e.g. flow-corridor counts per
190    /// bin) so the renderer animates resident data instead of re-fetching
191    /// geometry per bucket. Encoded as the tile's `vertex_value_matrix` column;
192    /// `num_buckets` is recorded in schema metadata under
193    /// `stt:vertex_value_buckets`.
194    pub vertex_value_matrix: Option<Vec<Vec<f32>>>,
195    /// Optional pre-baked triangle indices for polygon features (MLT-style).
196    ///
197    /// When present, `triangles.len() == feature_count` and each inner vec is
198    /// the flat triangle-index list (groups of 3 vertex indices) produced by
199    /// earcut at build time. Indices are LOCAL to the feature: they reference
200    /// positions within that feature's own ring coordinates, so the renderer
201    /// only needs to add the feature's `startIndex` to each one.
202    ///
203    /// Only meaningful for `GeometryColumn::Polygon`. Encoders that set this
204    /// on a non-polygon layer will have it dropped at encode time.
205    pub triangles: Option<Vec<Vec<u32>>>,
206    /// Property columns, keyed by name. Each column has one value per feature.
207    pub properties: Vec<(String, PropertyColumn)>,
208}
209
210impl ColumnarLayer {
211    /// Feature count for this layer.
212    pub fn feature_count(&self) -> usize {
213        self.feature_ids.len()
214    }
215
216    /// Validate that every column has a consistent length.
217    fn validate(&self) -> Result<()> {
218        let n = self.feature_ids.len();
219        let check = |label: &str, len: usize| -> Result<()> {
220            if len != n {
221                return Err(Error::Other(format!(
222                    "tile layer '{}': {} has {} entries, expected {}",
223                    self.name, label, len, n
224                )));
225            }
226            Ok(())
227        };
228        check("start_times", self.start_times.len())?;
229        check("end_times", self.end_times.len())?;
230        check("geometry", self.geometry.len())?;
231        if let Some(vt) = &self.vertex_times {
232            check("vertex_times", vt.len())?;
233        }
234        if let Some(vv) = &self.vertex_values {
235            check("vertex_values", vv.len())?;
236        }
237        if let Some(vm) = &self.vertex_value_matrix {
238            check("vertex_value_matrix", vm.len())?;
239        }
240        if let Some(tri) = &self.triangles {
241            check("triangles", tri.len())?;
242        }
243        for (name, col) in &self.properties {
244            match col {
245                PropertyColumn::Numeric(v) => check(&format!("property '{}'", name), v.len())?,
246                PropertyColumn::Categorical(v) => check(&format!("property '{}'", name), v.len())?,
247                PropertyColumn::Vector { width, values, .. } => {
248                    if *width == 0 {
249                        return Err(Error::Other(format!(
250                            "tile layer '{}': vector property '{}' has width 0",
251                            self.name, name
252                        )));
253                    }
254                    if values.len() != width * n {
255                        return Err(Error::Other(format!(
256                            "tile layer '{}': vector property '{}' has {} values, expected {} ({} × {})",
257                            self.name,
258                            name,
259                            values.len(),
260                            width * n,
261                            width,
262                            n
263                        )));
264                    }
265                }
266            }
267        }
268        Ok(())
269    }
270}
271
272/// Schema-metadata key set on layers that carry pre-baked triangle indices.
273pub const TRIANGLES_METADATA_KEY: &str = "stt:has_triangles";
274
275/// Tessellate one polygon feature (a list of rings) using earcut. Returns the
276/// flat triangle index list — each triple of indices is one triangle, indices
277/// are LOCAL (relative to the start of the feature's coordinate run, where
278/// the exterior ring sits first followed by every hole).
279///
280/// Returns an empty vec for degenerate inputs (no exterior ring, <3 vertices).
281pub fn tessellate_polygon(rings: &[Vec<Coord>]) -> Vec<u32> {
282    if rings.is_empty() {
283        return Vec::new();
284    }
285    // Flatten coords into the [x0, y0, x1, y1, ...] format earcutr expects.
286    let mut flat: Vec<f64> = Vec::with_capacity(rings.iter().map(|r| r.len()).sum::<usize>() * 2);
287    // Hole offsets are vertex indices (not coord-pair indices) where each
288    // hole begins. The first hole starts after the exterior ring.
289    let mut hole_indices: Vec<usize> = Vec::with_capacity(rings.len().saturating_sub(1));
290    let mut running = 0usize;
291    for (i, ring) in rings.iter().enumerate() {
292        if i > 0 {
293            hole_indices.push(running);
294        }
295        for [x, y] in ring {
296            flat.push(*x);
297            flat.push(*y);
298        }
299        running += ring.len();
300    }
301    if running < 3 {
302        return Vec::new();
303    }
304    match earcutr::earcut(&flat, &hole_indices, 2) {
305        Ok(tris) => tris.into_iter().map(|i| i as u32).collect(),
306        Err(_) => Vec::new(),
307    }
308}
309
310// ----------------------------------------------------------------------------
311// Geometry array construction (GeoArrow interleaved)
312// ----------------------------------------------------------------------------
313
314/// Build an `i32` offset buffer from per-element counts.
315fn offsets_from_counts(counts: impl Iterator<Item = usize>) -> OffsetBuffer<i32> {
316    let mut acc = 0i32;
317    let mut offsets = vec![0i32];
318    for c in counts {
319        acc += c as i32;
320        offsets.push(acc);
321    }
322    OffsetBuffer::new(offsets.into())
323}
324
325/// Meters per degree of latitude (WGS84 mean) — the constant the coordinate
326/// quantizer sizes its grid from. Longitude scales by `cos(lat)`.
327const M_PER_DEG_LAT: f64 = 111_320.0;
328
329/// Schema-metadata key (on the `geometry` field) that flags a tile whose
330/// coordinates are fixed-point `i32` grid indices rather than GeoArrow Float64
331/// lon/lat. Its value is the [`QuantAffine`] JSON; absent ⇒ standard Float64.
332pub const STT_QUANT_META_KEY: &str = "stt:quant";
333
334/// Per-layer coordinate-quantization affine. Coordinates ship as `i32` grid
335/// indices; the decoder reconstructs `lon = x0 + qx*sx`, `lat = y0 + qy*sy`.
336/// `sx`/`sy` (degrees per quantum) are sized from a target ground precision in
337/// meters at the layer's mid-latitude, so the worst-case error is ≤ half a
338/// quantum (~`meters/2`). This trades GeoArrow self-describing Float64 for size
339/// (coords are the dominant, near-incompressible column) and is opt-in.
340#[derive(Debug, Clone, Copy, PartialEq)]
341pub struct QuantAffine {
342    pub x0: f64,
343    pub y0: f64,
344    pub sx: f64,
345    pub sy: f64,
346    /// Z-axis origin/step (metres), present ONLY for 3D point geometry
347    /// (`FixedSizeList<i32,3>`). `None` ⇒ plain 2D coords, byte-identical to the
348    /// historical affine (the `z0`/`sz` keys are simply omitted from the JSON).
349    pub z0: Option<f64>,
350    pub sz: Option<f64>,
351}
352
353impl QuantAffine {
354    fn to_json(&self) -> String {
355        // Full f64 round-trip precision (17 sig digits) so decode is exact. The
356        // z keys are emitted only for 3D affines, so a 2D affine is byte-identical.
357        match (self.z0, self.sz) {
358            (Some(z0), Some(sz)) => format!(
359                r#"{{"x0":{:.17e},"y0":{:.17e},"sx":{:.17e},"sy":{:.17e},"z0":{:.17e},"sz":{:.17e}}}"#,
360                self.x0, self.y0, self.sx, self.sy, z0, sz
361            ),
362            _ => format!(
363                r#"{{"x0":{:.17e},"y0":{:.17e},"sx":{:.17e},"sy":{:.17e}}}"#,
364                self.x0, self.y0, self.sx, self.sy
365            ),
366        }
367    }
368
369    /// Parse the affine from its [`STT_QUANT_META_KEY`] JSON value. The TS
370    /// reader applies the identical reconstruction (`tile.ts`).
371    pub fn from_json(s: &str) -> Option<QuantAffine> {
372        let v: serde_json::Value = serde_json::from_str(s).ok()?;
373        let f = |k: &str| v.get(k).and_then(|x| x.as_f64());
374        Some(QuantAffine {
375            x0: f("x0")?,
376            y0: f("y0")?,
377            sx: f("sx")?,
378            sy: f("sy")?,
379            z0: f("z0"),
380            sz: f("sz"),
381        })
382    }
383
384    /// Reconstruct longitude from a quantized x grid index.
385    #[inline]
386    pub fn lon(&self, qx: i32) -> f64 {
387        self.x0 + qx as f64 * self.sx
388    }
389    /// Reconstruct latitude from a quantized y grid index.
390    #[inline]
391    pub fn lat(&self, qy: i32) -> f64 {
392        self.y0 + qy as f64 * self.sy
393    }
394
395    #[inline]
396    fn qx(&self, lon: f64) -> i32 {
397        (((lon - self.x0) / self.sx).round() as i64).clamp(i32::MIN as i64, i32::MAX as i64) as i32
398    }
399    #[inline]
400    fn qy(&self, lat: f64) -> i32 {
401        (((lat - self.y0) / self.sy).round() as i64).clamp(i32::MIN as i64, i32::MAX as i64) as i32
402    }
403    /// Quantize a metre altitude to a grid index (3D affines only; `z0`/`sz` set).
404    #[inline]
405    fn qz(&self, z: f64) -> i32 {
406        let z0 = self.z0.unwrap_or(0.0);
407        let sz = self.sz.unwrap_or(1.0);
408        (((z - z0) / sz).round() as i64).clamp(i32::MIN as i64, i32::MAX as i64) as i32
409    }
410}
411
412/// The fixed, dataset-independent quantization grid for a target ground
413/// precision in meters, or `None` when off (`meters <= 0`).
414///
415/// The origin is the world corner `(-180, -90)` and the step is uniform in
416/// degrees (`meters / M_PER_DEG_LAT`) — deliberately **not** a per-tile
417/// bbox-relative grid. A per-tile grid gives the same coordinate different
418/// indices in different tiles, which destroys the packed format's
419/// content-addressed blob dedup (measured: +61% on a dedup-heavy dataset). A
420/// single world grid keeps identical geometry byte-identical across tiles.
421///
422/// Latitude precision is exactly `meters`; longitude is `meters * cos(lat)` —
423/// i.e. always ≤ `meters` (finer toward the poles), never coarser than asked.
424/// At 1 m the largest index is `360 * M_PER_DEG_LAT ≈ 4.0e7`, well within i32.
425fn world_grid_affine(meters: f64) -> Option<QuantAffine> {
426    if !(meters > 0.0) {
427        return None;
428    }
429    let step = meters / M_PER_DEG_LAT;
430    Some(QuantAffine {
431        x0: -180.0,
432        y0: -90.0,
433        sx: step,
434        sy: step,
435        z0: None,
436        sz: None,
437    })
438}
439
440/// The 3D variant of [`world_grid_affine`]: same world-grid xy plus a Z axis
441/// quantized to the SAME ground precision in metres, origin pinned to a fixed
442/// global datum (`z0 = 0`) so identical surfels stay byte-identical across tiles
443/// (dedup-preserving, like the xy world grid). For point clouds whose altitude
444/// rides the geometry's 3rd coordinate (`--point-elevation-column`).
445fn world_grid_affine_3d(meters: f64) -> Option<QuantAffine> {
446    let a = world_grid_affine(meters)?;
447    Some(QuantAffine {
448        z0: Some(0.0),
449        sz: Some(meters),
450        ..a
451    })
452}
453
454/// Optionally quantizing coordinates to an `i32` grid
455/// (when `quant` is `Some`). The List/FixedSizeList nesting and offset buffers
456/// are identical either way — only the leaf changes from `Float64` to `Int32`,
457/// so `quant = None` is byte-identical to the historical encoder.
458///
459/// When `point_elev` is `Some` (POINT geometry only), each point's altitude is
460/// folded in as a 3rd coordinate: the leaf becomes `FixedSizeList<_,3>`
461/// (`[x,y,z]`), quantized with the affine's `z0`/`sz` when quantizing. The
462/// renderer then binds 3D positions zero-copy (no pad). `None` ⇒ 2D, unchanged.
463fn build_geometry_array_q(
464    geom: &GeometryColumn,
465    quant: Option<&QuantAffine>,
466    point_elev: Option<&[f64]>,
467) -> ArrayRef {
468    // 3D POINT path: interleave [x,y,z] into a FixedSizeList<_,3> leaf.
469    if let (GeometryColumn::Point(points), Some(elev)) = (geom, point_elev) {
470        let list_size = 3;
471        let dt = if quant.is_some() { DataType::Int32 } else { DataType::Float64 };
472        let field = Arc::new(Field::new("xyz", dt, false));
473        let child: ArrayRef = match quant {
474            Some(q) => {
475                let mut iv = Vec::with_capacity(points.len() * 3);
476                for (i, [x, y]) in points.iter().enumerate() {
477                    iv.push(q.qx(*x));
478                    iv.push(q.qy(*y));
479                    iv.push(q.qz(elev.get(i).copied().unwrap_or(0.0)));
480                }
481                Arc::new(Int32Array::from(iv))
482            }
483            None => {
484                let mut flat = Vec::with_capacity(points.len() * 3);
485                for (i, [x, y]) in points.iter().enumerate() {
486                    flat.push(*x);
487                    flat.push(*y);
488                    flat.push(elev.get(i).copied().unwrap_or(0.0));
489                }
490                Arc::new(Float64Array::from(flat))
491            }
492        };
493        return Arc::new(FixedSizeListArray::new(field, list_size, child, None));
494    }
495
496    let coord_field = || {
497        let dt = if quant.is_some() {
498            DataType::Int32
499        } else {
500            DataType::Float64
501        };
502        Arc::new(Field::new("xy", dt, false))
503    };
504    // Turn a flat `[x,y,x,y,...]` f64 run into the FixedSizeList<_,2> leaf,
505    // quantizing to i32 grid indices when an affine is supplied.
506    let make_leaf = |flat: Vec<f64>| -> ArrayRef {
507        match quant {
508            Some(q) => {
509                let mut iv = Vec::with_capacity(flat.len());
510                let mut i = 0;
511                while i + 1 < flat.len() {
512                    iv.push(q.qx(flat[i]));
513                    iv.push(q.qy(flat[i + 1]));
514                    i += 2;
515                }
516                Arc::new(FixedSizeListArray::new(
517                    coord_field(),
518                    2,
519                    Arc::new(Int32Array::from(iv)),
520                    None,
521                ))
522            }
523            None => Arc::new(FixedSizeListArray::new(
524                coord_field(),
525                2,
526                Arc::new(Float64Array::from(flat)),
527                None,
528            )),
529        }
530    };
531
532    match geom {
533        GeometryColumn::Point(points) => {
534            let mut flat = Vec::with_capacity(points.len() * 2);
535            for [x, y] in points {
536                flat.push(*x);
537                flat.push(*y);
538            }
539            make_leaf(flat)
540        }
541        GeometryColumn::LineString(lines) => {
542            let mut flat: Vec<f64> = Vec::new();
543            for line in lines {
544                for [x, y] in line {
545                    flat.push(*x);
546                    flat.push(*y);
547                }
548            }
549            let coords = make_leaf(flat);
550            let offsets = offsets_from_counts(lines.iter().map(|l| l.len()));
551            let vertex_field = Arc::new(Field::new("vertices", coords.data_type().clone(), false));
552            Arc::new(ListArray::new(vertex_field, offsets, coords, None))
553        }
554        GeometryColumn::Polygon(polys) => {
555            // Flatten all coordinates, ring sizes, and ring counts per feature.
556            let mut flat: Vec<f64> = Vec::new();
557            let mut ring_sizes: Vec<usize> = Vec::new();
558            let mut rings_per_feature: Vec<usize> = Vec::new();
559            for feature in polys {
560                rings_per_feature.push(feature.len());
561                for ring in feature {
562                    ring_sizes.push(ring.len());
563                    for [x, y] in ring {
564                        flat.push(*x);
565                        flat.push(*y);
566                    }
567                }
568            }
569            let coords = make_leaf(flat);
570            // Ring level: List<FixedSizeList>.
571            let ring_offsets = offsets_from_counts(ring_sizes.into_iter());
572            let vertex_field = Arc::new(Field::new("vertices", coords.data_type().clone(), false));
573            let rings: ArrayRef = Arc::new(ListArray::new(
574                vertex_field,
575                ring_offsets,
576                coords,
577                None,
578            ));
579            // Feature level: List<List<FixedSizeList>>.
580            let feature_offsets = offsets_from_counts(rings_per_feature.into_iter());
581            let ring_field = Arc::new(Field::new("rings", rings.data_type().clone(), false));
582            Arc::new(ListArray::new(ring_field, feature_offsets, rings, None))
583        }
584    }
585}
586
587/// Schema metadata keys for the v3 per-vertex time encoding.
588const VERTEX_TIME_ORIGIN_KEY: &str = "stt:vertex_time_origin_ms";
589const VERTEX_TIME_STEP_KEY: &str = "stt:vertex_time_step_ms";
590/// Number of time buckets packed into each row of the `vertex_value_matrix`
591/// column. The renderer reshapes the flat vertex-major list back into a
592/// `[vertex][bucket]` grid using this count.
593const VERTEX_VALUE_BUCKETS_KEY: &str = "stt:vertex_value_buckets";
594/// Baked minimum feature start-time (integer Unix ms) for the layer. The TS
595/// decoder relativizes every start/end time against this value so the times
596/// fit an f32; baking it here lets the decoder skip its client-side min-scan
597/// over the whole start-time column. Mirrors exactly what the decoder computes
598/// (the min of the `start_time` column) — see `packages/core/src/tile.ts`.
599const TIME_OFFSET_MS_KEY: &str = "stt:time_offset_ms";
600
601/// Default ceiling (ms) on the u16-delta `vertex_time` quantization step.
602///
603/// The u16 encoding trades precision for a 4x payload shrink, but the step is
604/// derived from the layer's temporal span — without a ceiling, a wide layer
605/// (e.g. a 30-day temporal-LOD bucket) silently quantizes vertex times to
606/// tens of seconds. A 1 s ceiling keeps the worst-case error below anything
607/// playback can show; layers needing a coarser step fall back to the exact
608/// `List<Int64>` shape instead (no thinning).
609pub const DEFAULT_VERTEX_TIME_MAX_STEP_MS: u32 = 1000;
610
611/// Process-wide step ceiling, settable once at startup (e.g. from
612/// `stt-build --vertex-time-precision`). Reads/writes are monotonic-free
613/// config, so `Relaxed` is sufficient.
614static VERTEX_TIME_MAX_STEP_MS: AtomicU32 = AtomicU32::new(DEFAULT_VERTEX_TIME_MAX_STEP_MS);
615
616/// Latch so the "u16-delta ceiling exceeded" warning fires at most once per
617/// process instead of once per tile.
618static VERTEX_TIME_FALLBACK_WARNED: AtomicBool = AtomicBool::new(false);
619
620/// Override the u16-delta `vertex_time` step ceiling for every subsequent
621/// [`encode_layer`] call. Values below 1 ms clamp to 1 (every layer with a
622/// span beyond u16 milliseconds then takes the exact `List<Int64>` path).
623pub fn set_vertex_time_max_step_ms(ms: u32) {
624    VERTEX_TIME_MAX_STEP_MS.store(ms.max(1), Ordering::Relaxed);
625}
626
627/// The currently configured u16-delta `vertex_time` step ceiling (ms).
628pub fn vertex_time_max_step_ms() -> u32 {
629    VERTEX_TIME_MAX_STEP_MS.load(Ordering::Relaxed)
630}
631
632/// Build-global coordinate quantization precision, in **micrometers** (lets the
633/// `AtomicU32` carry sub-mm..km without a float). `0` = off (Float64 coords).
634/// Set once per build (e.g. `stt-build --quantize-coords`); read by the default
635/// [`encode_tile`] / [`encode_layer`] path so it covers both the streaming and
636/// in-memory builders without threading through every tile.
637static QUANTIZE_COORDS_UM: AtomicU32 = AtomicU32::new(0);
638
639/// Set the build-global coordinate quantization precision in meters for every
640/// subsequent default [`encode_tile`] call. `<= 0` (the default) turns it off —
641/// coordinates stay Float64 GeoArrow. See [`encode_layer_quantized`] for the
642/// size/precision trade-off.
643pub fn set_quantize_coords_m(meters: f64) {
644    let um = if meters > 0.0 {
645        (meters * 1.0e6).round().clamp(1.0, u32::MAX as f64) as u32
646    } else {
647        0
648    };
649    QUANTIZE_COORDS_UM.store(um, Ordering::Relaxed);
650}
651
652/// The build-global quantization precision in meters, or `None` when off.
653pub fn quantize_coords_m() -> Option<f64> {
654    let um = QUANTIZE_COORDS_UM.load(Ordering::Relaxed);
655    (um > 0).then(|| um as f64 / 1.0e6)
656}
657
658/// Field-metadata key flagging a *numeric property* column that ships as
659/// fixed-point integer indices (smallest of `UInt16`/`Int32`) instead of
660/// `Float64`. Its value is the [`AttrQuant`] JSON (`value = o + q*s`). Sibling
661/// of [`STT_QUANT_META_KEY`] for geometry; lives on the property field, so a
662/// reader reconstructs Float64 the same way it does coordinates.
663pub const STT_QUANT_ATTR_META_KEY: &str = "stt:qa";
664
665/// Per-numeric-property quantization affine: `value = o + q * s`, where `o` is
666/// the column minimum (the dequantization offset) and `s` the requested ground
667/// precision (the step). Reconstruction is lossy to ≤ `s/2`; the reader applies
668/// the identical math (`tile.ts`).
669#[derive(Debug, Clone, Copy, PartialEq)]
670pub struct AttrQuant {
671    pub o: f64,
672    pub s: f64,
673}
674
675impl AttrQuant {
676    fn to_json(&self) -> String {
677        // Full f64 round-trip precision so the offset/step decode exactly.
678        format!(r#"{{"o":{:.17e},"s":{:.17e}}}"#, self.o, self.s)
679    }
680
681    /// Parse the affine from its [`STT_QUANT_ATTR_META_KEY`] JSON value.
682    pub fn from_json(s: &str) -> Option<AttrQuant> {
683        let v: serde_json::Value = serde_json::from_str(s).ok()?;
684        Some(AttrQuant {
685            o: v.get("o")?.as_f64()?,
686            s: v.get("s")?.as_f64()?,
687        })
688    }
689
690    /// Reconstruct the original value from a quantized index.
691    #[inline]
692    pub fn value(&self, q: i64) -> f64 {
693        self.o + q as f64 * self.s
694    }
695}
696
697/// Build-global map of `property-name → ground precision (units)`. A numeric
698/// property named here is stored quantized (see [`build_quantized_numeric`]);
699/// every other numeric property stays `Float64`. Set once per build from
700/// `stt-build --quantize-attr name=prec`. Empty (the default) ⇒ all numeric
701/// properties stay `Float64`, byte-identical to the historical encoder.
702fn quant_attrs_cell() -> &'static RwLock<HashMap<String, f64>> {
703    static A: OnceLock<RwLock<HashMap<String, f64>>> = OnceLock::new();
704    A.get_or_init(|| RwLock::new(HashMap::new()))
705}
706
707/// Replace the build-global numeric-property quantization map.
708pub fn set_quantize_attrs(map: HashMap<String, f64>) {
709    *quant_attrs_cell().write().unwrap() = map;
710}
711
712/// The current numeric-property quantization map (a clone).
713pub fn quantize_attrs() -> HashMap<String, f64> {
714    quant_attrs_cell().read().unwrap().clone()
715}
716
717
718/// When set, EVERY `Float64` numeric property not given an explicit precision in
719/// the [`set_quantize_attrs`] map is quantized automatically: its step is sized
720/// so the column's full `[min, max]` range spans the 16-bit index space
721/// (`step = (max-min)/65535`), i.e. a range-adaptive `UInt16` with ~65k levels.
722/// This is the "born-optimized" generation default — no per-column precision to
723/// pick, and >=16 bits of dynamic range is visually lossless for the scalar
724/// fields STT carries (magnitude, depth, altitude, speed, SST, dBZ, ...). The
725/// default-off keeps `stt-build` byte-identical unless a caller opts in.
726static QUANTIZE_ATTRS_AUTO: AtomicBool = AtomicBool::new(false);
727
728/// Enable/disable automatic range-adaptive quantization of every otherwise-raw
729/// `Float64` numeric property (see [`QUANTIZE_ATTRS_AUTO`]). Explicit precisions
730/// in [`set_quantize_attrs`] always win.
731pub fn set_quantize_attrs_auto(on: bool) {
732    QUANTIZE_ATTRS_AUTO.store(on, Ordering::Relaxed);
733}
734
735/// Whether automatic numeric-property quantization is enabled.
736pub fn quantize_attrs_auto() -> bool {
737    QUANTIZE_ATTRS_AUTO.load(Ordering::Relaxed)
738}
739
740/// A build-time directive to fuse several scalar numeric properties into one
741/// GPU-ready interleaved [`PropertyColumn::Vector`]. The component order is the
742/// vector's component order (e.g. `["qx","qy","qz","qw"]` → `instanceQuaternions`).
743/// Applied at encode time (like the quantization maps), so the producer keeps
744/// emitting plain scalar columns and the renderer still binds the result
745/// zero-copy. See [`set_vector_groups`].
746#[derive(Debug, Clone)]
747pub struct VectorGroup {
748    /// Output column name (the FixedSizeList field name the decoder keys on).
749    pub name: String,
750    /// Source scalar-property names, in component order.
751    pub components: Vec<String>,
752    /// Leaf upload type (`F32` for quat/scale, `U8` for 0–255 RGBA).
753    pub elem: VectorElem,
754}
755
756/// Build-global list of vector groups (see [`VectorGroup`]). Set once per build
757/// from `stt-build --vector-group`. Empty (the default) ⇒ every property stays a
758/// scalar column, byte-identical to the historical encoder.
759fn vector_groups_cell() -> &'static RwLock<Vec<VectorGroup>> {
760    static A: OnceLock<RwLock<Vec<VectorGroup>>> = OnceLock::new();
761    A.get_or_init(|| RwLock::new(Vec::new()))
762}
763
764/// Replace the build-global vector-group list.
765pub fn set_vector_groups(groups: Vec<VectorGroup>) {
766    *vector_groups_cell().write().unwrap() = groups;
767}
768
769/// The current vector-group list (a clone).
770pub fn vector_groups() -> Vec<VectorGroup> {
771    vector_groups_cell().read().unwrap().clone()
772}
773
774/// Build-global name of the numeric property to fold into POINT geometry as the
775/// 3rd (altitude) coordinate, so the tile ships true 3D points
776/// (`FixedSizeList<_,3>`) the renderer binds zero-copy — no per-point pad to 3D
777/// on the main thread. The named column is REMOVED from the property set (it
778/// lives in the geometry instead). Empty (default) ⇒ plain 2D points.
779fn point_elevation_column_cell() -> &'static RwLock<String> {
780    static A: OnceLock<RwLock<String>> = OnceLock::new();
781    A.get_or_init(|| RwLock::new(String::new()))
782}
783
784/// Set the build-global point-elevation column name (see above). Empty disables.
785pub fn set_point_elevation_column(name: &str) {
786    *point_elevation_column_cell().write().unwrap() = name.to_string();
787}
788
789/// The current point-elevation column name (empty ⇒ disabled).
790pub fn point_elevation_column() -> String {
791    point_elevation_column_cell().read().unwrap().clone()
792}
793
794/// Resolved, explicit encoder settings — the values the tile encoder reads at
795/// encode time (coordinate + attribute quantization, vector grouping, the
796/// point-elevation fold, vertex-time precision).
797///
798/// Historically these lived only in process-wide mutable statics set via the
799/// `set_*` functions above; that made a new encode caller silently inherit
800/// whatever the last `set_*` left behind (the origin of the `stt-serve` parity
801/// bug) and made it impossible to encode two different configurations in one
802/// process (blocking multi-dataset serve + parallel per-config tests). Passing an
803/// `EncoderConfig` explicitly to [`encode_tile_with`] / [`encode_layer_with`]
804/// removes both problems. The globals + the no-arg [`encode_tile`] /
805/// [`encode_layer`] wrappers remain for the one-shot CLI and existing callers;
806/// [`EncoderConfig::from_globals`] snapshots them.
807#[derive(Debug, Clone)]
808pub struct EncoderConfig {
809    /// Fixed-point coordinate quantization ground precision in meters
810    /// (`None` = Float64 GeoArrow coordinates, the default).
811    pub quantize_coords_m: Option<f64>,
812    /// Per-property explicit fixed-point precisions (`name → precision`).
813    pub quantize_attrs: HashMap<String, f64>,
814    /// Range-adaptive `UInt16` quantization for every un-listed Float64 property.
815    pub quantize_attrs_auto: bool,
816    /// Scalar columns to fuse into interleaved `FixedSizeList` vector columns.
817    pub vector_groups: Vec<VectorGroup>,
818    /// Property folded into POINT geometry as the z coordinate (empty = none).
819    pub point_elevation_column: String,
820    /// Ceiling (ms) on the per-vertex time u16-delta quantization step.
821    pub vertex_time_max_step_ms: u32,
822}
823
824impl Default for EncoderConfig {
825    fn default() -> Self {
826        Self {
827            quantize_coords_m: None,
828            quantize_attrs: HashMap::new(),
829            quantize_attrs_auto: false,
830            vector_groups: Vec::new(),
831            point_elevation_column: String::new(),
832            vertex_time_max_step_ms: DEFAULT_VERTEX_TIME_MAX_STEP_MS,
833        }
834    }
835}
836
837impl EncoderConfig {
838    /// Snapshot the current process-wide encoder globals into an explicit config.
839    /// Used by the no-arg [`encode_tile`] / [`encode_layer`] back-compat wrappers.
840    pub fn from_globals() -> Self {
841        Self {
842            quantize_coords_m: quantize_coords_m(),
843            quantize_attrs: quantize_attrs(),
844            quantize_attrs_auto: quantize_attrs_auto(),
845            vector_groups: vector_groups(),
846            point_elevation_column: point_elevation_column(),
847            vertex_time_max_step_ms: vertex_time_max_step_ms(),
848        }
849    }
850}
851
852/// Fuse the scalar columns named by each configured [`VectorGroup`] into a single
853/// [`PropertyColumn::Vector`], leaving every other column untouched and in order.
854///
855/// A group whose components are not ALL present as `Numeric` columns is skipped
856/// (its scalars stay as-is) — so a tile that happens not to carry one of the
857/// inputs degrades to scalars rather than dropping data. Missing per-feature
858/// values (`None`) encode as `0.0`. Returns `None` when no group applied, letting
859/// the caller iterate `layer.properties` directly with no clone.
860fn group_vector_properties(
861    props: &[(String, PropertyColumn)],
862    n: usize,
863    groups: &[VectorGroup],
864) -> Option<Vec<(String, PropertyColumn)>> {
865    if groups.is_empty() {
866        return None;
867    }
868    // name → numeric values slice, for component lookup.
869    let numeric: HashMap<&str, &[Option<f64>]> = props
870        .iter()
871        .filter_map(|(k, c)| match c {
872            PropertyColumn::Numeric(v) => Some((k.as_str(), v.as_slice())),
873            _ => None,
874        })
875        .collect();
876
877    let mut out: Vec<(String, PropertyColumn)> = Vec::new();
878    let mut consumed: HashSet<String> = HashSet::new();
879    let mut any = false;
880    for g in groups {
881        if g.components.is_empty()
882            || !g.components.iter().all(|c| numeric.contains_key(c.as_str()))
883        {
884            continue;
885        }
886        let width = g.components.len();
887        let mut values = vec![0f32; width * n];
888        for (ci, cname) in g.components.iter().enumerate() {
889            let col = numeric[cname.as_str()];
890            for i in 0..n {
891                values[i * width + ci] = col[i].map(|x| x as f32).unwrap_or(0.0);
892            }
893        }
894        for c in &g.components {
895            consumed.insert(c.clone());
896        }
897        out.push((
898            g.name.clone(),
899            PropertyColumn::Vector {
900                width,
901                elem: g.elem,
902                values,
903            },
904        ));
905        any = true;
906    }
907    if !any {
908        return None;
909    }
910    // Keep every column not pulled into a group, in original order.
911    for (k, c) in props {
912        if !consumed.contains(k) {
913            out.push((k.clone(), c.clone()));
914        }
915    }
916    Some(out)
917}
918
919/// Range-adaptive auto quantization: size the step from the column's own span so
920/// `[min, max]` maps onto `[0, 65535]`. ALWAYS returns a `UInt16` column (never
921/// `None`, never `Int32`) so a column is quantized to the *same type in every
922/// tile* — a per-tile range-adaptive choice would otherwise leave constant /
923/// all-null tiles as `Float64` and drift the layer schema across tiles. A
924/// constant column quantizes to all-zeros and an all-null column to all-nulls;
925/// both compress to nothing, so the uniform `UInt16` costs nothing and keeps the
926/// schema consistent.
927fn build_quantized_numeric_auto(values: &[Option<f64>]) -> Option<(ArrayRef, String)> {
928    let mut min = f64::INFINITY;
929    let mut max = f64::NEG_INFINITY;
930    for v in values.iter().flatten() {
931        if v.is_finite() {
932            min = min.min(*v);
933            max = max.max(*v);
934        }
935    }
936    // o = offset (column min, or 0 when no finite value); s = step. A zero span
937    // (constant / no-data) uses step 1 → every present value maps to index 0,
938    // which reconstructs to `o` exactly.
939    let (o, s) = if min.is_finite() {
940        if max > min {
941            (min, (max - min) / u16::MAX as f64)
942        } else {
943            (min, 1.0)
944        }
945    } else {
946        (0.0, 1.0)
947    };
948    let affine = AttrQuant { o, s };
949    let mut b = UInt16Builder::with_capacity(values.len());
950    for v in values {
951        match v {
952            Some(x) if x.is_finite() => {
953                let q = (((*x - o) / s).round()).clamp(0.0, u16::MAX as f64) as u16;
954                b.append_value(q);
955            }
956            _ => b.append_null(),
957        }
958    }
959    Some((Arc::new(b.finish()), affine.to_json()))
960}
961
962/// Quantize a numeric property column to the smallest integer leaf at `prec`
963/// units, with the offset pinned to the column minimum. Returns
964/// `(array, affine_json)` — a `UInt16` leaf when the quantized range fits 16
965/// bits, else `Int32` — or `None` when no finite value exists (caller keeps the
966/// `Float64` column). Nulls and non-finite values become Arrow nulls. The
967/// offset is the per-column minimum: identical columns quantize identically, so
968/// the packed format's content-addressed dedup is preserved.
969fn build_quantized_numeric(values: &[Option<f64>], prec: f64) -> Option<(ArrayRef, String)> {
970    if !(prec > 0.0) {
971        return None;
972    }
973    let mut min = f64::INFINITY;
974    for v in values.iter().flatten() {
975        if v.is_finite() && *v < min {
976            min = *v;
977        }
978    }
979    if !min.is_finite() {
980        return None; // no finite values — keep Float64
981    }
982    let affine = AttrQuant { o: min, s: prec };
983    let mut q: Vec<Option<i64>> = Vec::with_capacity(values.len());
984    let mut max_q: i64 = 0;
985    for v in values {
986        match v {
987            Some(x) if x.is_finite() => {
988                let qi = (((*x - affine.o) / affine.s).round() as i64).max(0);
989                if qi > max_q {
990                    max_q = qi;
991                }
992                q.push(Some(qi));
993            }
994            _ => q.push(None),
995        }
996    }
997    let array: ArrayRef = if max_q <= u16::MAX as i64 {
998        let mut b = UInt16Builder::with_capacity(q.len());
999        for qi in &q {
1000            match qi {
1001                Some(v) => b.append_value(*v as u16),
1002                None => b.append_null(),
1003            }
1004        }
1005        Arc::new(b.finish())
1006    } else {
1007        let mut b = Int32Builder::with_capacity(q.len());
1008        for qi in &q {
1009            match qi {
1010                Some(v) => b.append_value((*v).clamp(0, i32::MAX as i64) as i32),
1011                None => b.append_null(),
1012            }
1013        }
1014        Arc::new(b.finish())
1015    };
1016    Some((array, affine.to_json()))
1017}
1018
1019/// Build a (key_array, value_array) pair for a Dictionary<UInt16, Utf8>
1020/// column. Null inputs become null keys (the corresponding string is not
1021/// inserted into the dictionary); strings are deduplicated in first-seen
1022/// order so the on-disk Arrow dictionary is stable across runs.
1023///
1024/// Errors if a column has more than `u16::MAX` distinct values, which the
1025/// `UInt16` key space cannot address. In practice STT categorical columns top
1026/// out in the low hundreds, so this only fires on pathological input (e.g. a
1027/// per-feature unique id mistaken for a category). Erroring is deliberate: the
1028/// previous behaviour silently collapsed every overflowing value to a single
1029/// index, mislabeling features without a trace. A producer that genuinely needs
1030/// >65k distinct strings should split the column or widen the key type.
1031fn build_dictionary_indices(
1032    values: &[Option<String>],
1033) -> Result<(Vec<Option<u16>>, Vec<String>)> {
1034    let mut categories: Vec<String> = Vec::new();
1035    let mut lookup: HashMap<String, u16> = HashMap::new();
1036    let mut indices: Vec<Option<u16>> = Vec::with_capacity(values.len());
1037    for v in values {
1038        match v {
1039            Some(s) => {
1040                if let Some(&idx) = lookup.get(s) {
1041                    indices.push(Some(idx));
1042                } else if categories.len() < u16::MAX as usize {
1043                    let idx = categories.len() as u16;
1044                    categories.push(s.clone());
1045                    lookup.insert(s.clone(), idx);
1046                    indices.push(Some(idx));
1047                } else {
1048                    return Err(Error::Other(format!(
1049                        "categorical column has more than {} distinct values, which a \
1050                         Dictionary<UInt16, Utf8> key cannot address; split the column \
1051                         into multiple categorical fields or widen the key type",
1052                        u16::MAX
1053                    )));
1054                }
1055            }
1056            None => indices.push(None),
1057        }
1058    }
1059    Ok((indices, categories))
1060}
1061
1062/// Built per-vertex time column, alongside the per-layer schema metadata
1063/// that lets the reader reconstruct absolute timestamps.
1064struct VertexTimeColumn {
1065    array: ArrayRef,
1066    /// `(origin_ms, step_ms)` when the column is u16-delta-encoded. `None`
1067    /// when the column kept its absolute `List<Int64>` shape (the exact
1068    /// fallback path, used for layers whose temporal span would need a step
1069    /// beyond the configured ceiling — see [`DEFAULT_VERTEX_TIME_MAX_STEP_MS`]).
1070    encoding: Option<(i64, u32)>,
1071}
1072
1073/// Build the optional per-vertex time column.
1074///
1075/// v3 attempts to encode timestamps as `List<UInt16>` deltas relative to
1076/// a per-layer origin and step (`absolute = origin + delta * step`), with
1077/// the step bounded by `max_step_ms` (see
1078/// [`DEFAULT_VERTEX_TIME_MAX_STEP_MS`]). When the layer's temporal span
1079/// would need a coarser step than that, the column keeps the exact v2
1080/// `List<Int64>` shape instead — bounded quantization or no quantization,
1081/// never a silent precision cliff.
1082fn build_vertex_time_array(
1083    vertex_times: &Option<Vec<Vec<i64>>>,
1084    feature_count: usize,
1085    max_step_ms: u32,
1086) -> Option<VertexTimeColumn> {
1087    let vt = vertex_times.as_ref()?;
1088
1089    // Discover the layer's temporal span across every (feature, vertex) pair.
1090    // Empty / null lists are skipped — a list-of-nulls is fine, it just won't
1091    // shrink the span.
1092    let mut min = i64::MAX;
1093    let mut max = i64::MIN;
1094    let mut any = false;
1095    for times in vt.iter().take(feature_count) {
1096        for &t in times {
1097            if t < min {
1098                min = t;
1099            }
1100            if t > max {
1101                max = t;
1102            }
1103            any = true;
1104        }
1105    }
1106
1107    if any && max >= min {
1108        // Pick the smallest step (in ms) that keeps every (t - min) inside
1109        // u16::MAX. step=1 means "exact ms granularity"; larger steps trade
1110        // precision (bounded by step ms) for a 4x payload shrink vs i64.
1111        let span = (max - min) as u64;
1112        // Computed in u64 and compared BEFORE narrowing: a pathological span
1113        // whose step overflows u32 must hit the i64 path, not wrap small.
1114        let step = if span <= u16::MAX as u64 {
1115            1u64
1116        } else {
1117            ((span + u16::MAX as u64 - 1) / u16::MAX as u64).max(1)
1118        };
1119        // Step ceiling: beyond it the quantization error would exceed the
1120        // configured precision, so take the exact i64 path below instead.
1121        if step <= max_step_ms as u64 {
1122            let step = step as u32;
1123            let mut builder = ListBuilder::new(UInt16Builder::new());
1124            for i in 0..feature_count {
1125                match vt.get(i) {
1126                    Some(times) if !times.is_empty() => {
1127                        for &t in times {
1128                            // Saturate at u16::MAX — for a sensibly chosen
1129                            // step this branch can only fire on inputs that
1130                            // disagree with the (min,max) scan above (e.g.
1131                            // a `vertex_times` longer than feature_count).
1132                            let delta = ((t - min) as u64 / step as u64).min(u16::MAX as u64) as u16;
1133                            builder.values().append_value(delta);
1134                        }
1135                        builder.append(true);
1136                    }
1137                    _ => builder.append(false),
1138                }
1139            }
1140            return Some(VertexTimeColumn {
1141                array: Arc::new(builder.finish()),
1142                encoding: Some((min, step)),
1143            });
1144        }
1145        // Reached the exact-Int64 fallback because the span needs a coarser
1146        // step than the ceiling. Warn once per process (not per tile).
1147        if !VERTEX_TIME_FALLBACK_WARNED.swap(true, Ordering::Relaxed) {
1148            tracing::warn!(
1149                "vertex-time span {}ms exceeds u16-delta ceiling (step {}ms > max {}ms); \
1150                 falling back to exact Int64 — payload keeps full precision but is ~4x larger",
1151                span,
1152                step,
1153                max_step_ms
1154            );
1155        }
1156    }
1157
1158    // Fallback: legacy absolute List<Int64> for empty-ish columns or
1159    // pathological steps. Identical wire shape to v2.
1160    let mut builder = ListBuilder::new(Int64Builder::new());
1161    for i in 0..feature_count {
1162        match vt.get(i) {
1163            Some(times) if !times.is_empty() => {
1164                for &t in times {
1165                    builder.values().append_value(t);
1166                }
1167                builder.append(true);
1168            }
1169            _ => builder.append(false),
1170        }
1171    }
1172    Some(VertexTimeColumn {
1173        array: Arc::new(builder.finish()),
1174        encoding: None,
1175    })
1176}
1177
1178/// Build the optional per-vertex scalar column as a nullable `List<Float32>`.
1179///
1180/// Unlike `vertex_time`, there's no delta/origin/step encoding — the values are
1181/// producer-defined scalars (e.g. sea-surface temperature) with a small range
1182/// where f32 precision is ample. A feature with no per-vertex values appends a
1183/// null list; `NaN` entries within a list mark individual vertices with no value.
1184/// Returns `None` when no feature carries any value, so the column is omitted.
1185fn build_vertex_value_array(
1186    vertex_values: &Option<Vec<Vec<f32>>>,
1187    feature_count: usize,
1188) -> Option<ArrayRef> {
1189    let vv = vertex_values.as_ref()?;
1190    let any = vv.iter().take(feature_count).any(|v| !v.is_empty());
1191    if !any {
1192        return None;
1193    }
1194    let mut builder = ListBuilder::new(Float32Builder::new());
1195    for i in 0..feature_count {
1196        match vv.get(i) {
1197            Some(values) if !values.is_empty() => {
1198                for &v in values {
1199                    builder.values().append_value(v);
1200                }
1201                builder.append(true);
1202            }
1203            _ => builder.append(false),
1204        }
1205    }
1206    Some(Arc::new(builder.finish()))
1207}
1208
1209/// Recover the per-vertex value-matrix bucket count: for the first LineString
1210/// feature carrying both vertices and matrix data, `num_buckets = matrix_len /
1211/// vertex_count` (the matrix is vertex-major, `num_vertices * num_buckets`
1212/// long). Returns `None` for non-line geometry or when no feature carries a
1213/// clean multiple — the matrix column is then present but non-animatable.
1214fn infer_vertex_value_buckets(matrix: &[Vec<f32>], geometry: &GeometryColumn) -> Option<u32> {
1215    let lines = match geometry {
1216        GeometryColumn::LineString(lines) => lines,
1217        _ => return None,
1218    };
1219    for (i, m) in matrix.iter().enumerate() {
1220        let nv = lines.get(i)?.len();
1221        if !m.is_empty() && nv > 0 && m.len() % nv == 0 {
1222            return Some((m.len() / nv) as u32);
1223        }
1224    }
1225    None
1226}
1227
1228// ----------------------------------------------------------------------------
1229// Encoding
1230// ----------------------------------------------------------------------------
1231
1232/// Encode a single layer to an Arrow IPC stream.
1233pub fn encode_layer(layer: &ColumnarLayer) -> Result<Vec<u8>> {
1234    encode_layer_cfg(layer, &EncoderConfig::from_globals())
1235}
1236
1237/// [`encode_layer`] with optional fixed-point coordinate quantization.
1238///
1239/// `quantize_m = Some(meters)` stores coordinates as `i32` grid indices at that
1240/// ground precision (default-off `None` is byte-identical to [`encode_layer`]).
1241/// Coordinates are the dominant, near-incompressible tile column, so quantizing
1242/// them is the single largest size lever — at the cost of GeoArrow Float64
1243/// self-description, hence opt-in. The per-layer affine rides in the geometry
1244/// field metadata under [`STT_QUANT_META_KEY`]; the reader reconstructs Float64.
1245///
1246/// The non-coordinate settings (attribute quantization, vector grouping,
1247/// point-elevation fold, vertex-time precision) come from the process-wide
1248/// globals; use [`encode_layer_with`] to pass every setting explicitly.
1249pub fn encode_layer_quantized(layer: &ColumnarLayer, quantize_m: Option<f64>) -> Result<Vec<u8>> {
1250    encode_layer_cfg(
1251        layer,
1252        &EncoderConfig {
1253            quantize_coords_m: quantize_m,
1254            ..EncoderConfig::from_globals()
1255        },
1256    )
1257}
1258
1259/// [`encode_layer`] with a fully-explicit [`EncoderConfig`] — no process-wide
1260/// globals are read. This is the concurrency- and multi-config-safe entry point
1261/// (e.g. a dynamic server fronting several datasets with different settings).
1262pub fn encode_layer_with(layer: &ColumnarLayer, cfg: &EncoderConfig) -> Result<Vec<u8>> {
1263    encode_layer_cfg(layer, cfg)
1264}
1265
1266/// The single encode implementation, driven entirely by an explicit
1267/// [`EncoderConfig`]. Every public `encode_layer*` entry point funnels here.
1268fn encode_layer_cfg(layer: &ColumnarLayer, cfg: &EncoderConfig) -> Result<Vec<u8>> {
1269    layer.validate()?;
1270    let n = layer.feature_count();
1271
1272    let mut fields: Vec<Arc<Field>> = Vec::new();
1273    let mut columns: Vec<ArrayRef> = Vec::new();
1274
1275    fields.push(Arc::new(Field::new("id", DataType::UInt64, false)));
1276    columns.push(Arc::new(UInt64Array::from(layer.feature_ids.clone())));
1277
1278    fields.push(Arc::new(Field::new("start_time", DataType::Int64, false)));
1279    columns.push(Arc::new(Int64Array::from(layer.start_times.clone())));
1280
1281    fields.push(Arc::new(Field::new("end_time", DataType::Int64, false)));
1282    columns.push(Arc::new(Int64Array::from(layer.end_times.clone())));
1283
1284    // 3D POINT geometry: fold the configured numeric column into the geometry's
1285    // 3rd coordinate, so the tile ships true 3D points the renderer binds
1286    // zero-copy (no per-point pad). The column is then dropped from properties.
1287    let elev_col = cfg.point_elevation_column.clone();
1288    let point_elev: Option<Vec<f64>> = if !elev_col.is_empty()
1289        && matches!(layer.geometry, GeometryColumn::Point(_))
1290    {
1291        layer.properties.iter().find_map(|(name, col)| match col {
1292            PropertyColumn::Numeric(v) if name == &elev_col => {
1293                let n = layer.feature_count();
1294                let mut out = vec![0.0f64; n];
1295                for (i, x) in v.iter().enumerate() {
1296                    if let Some(val) = x {
1297                        out[i] = *val;
1298                    }
1299                }
1300                Some(out)
1301            }
1302            _ => None,
1303        })
1304    } else {
1305        None
1306    };
1307    let elev_consumed = point_elev.is_some();
1308
1309    // Geometry column carries the GeoArrow extension name in field metadata.
1310    let quant = cfg.quantize_coords_m.and_then(|m| {
1311        if point_elev.is_some() {
1312            world_grid_affine_3d(m)
1313        } else {
1314            world_grid_affine(m)
1315        }
1316    });
1317    let geom_array =
1318        build_geometry_array_q(&layer.geometry, quant.as_ref(), point_elev.as_deref());
1319    // Assemble field metadata in a BTreeMap so the key set is emitted in a
1320    // deterministic (lexicographic) order regardless of insertion order. Arrow
1321    // stores field metadata in a HashMap and serializes it in HashMap-iteration
1322    // order (see the quantization note below), so building from a sorted source
1323    // removes this encoder's own contribution to non-reproducible pack bytes;
1324    // the residual arrow-ipc-v54 byte-order gap is tracked in
1325    // docs/spec/stt-packed-format.md §7-D6.
1326    let mut geom_meta = BTreeMap::new();
1327    geom_meta.insert(
1328        GEOARROW_EXT_KEY.to_string(),
1329        layer.geometry.geoarrow_name().to_string(),
1330    );
1331    match &quant {
1332        // A quantized tile's `xy` leaf is i32 grid indices, not Float64 lon/lat,
1333        // so the GeoArrow CRS doesn't apply — swap it for the reconstruction
1334        // affine (whose presence is the reader's quantization signal). Field
1335        // metadata is assembled in a BTreeMap (deterministic key order); Arrow
1336        // (v54) still serializes it in per-process HashMap-iteration order
1337        // (arrow-ipc `convert::metadata_to_fb` iterates the HashMap without
1338        // sorting), so the raw metadata-region bytes remain non-reproducible
1339        // across runs — the tracked gap in docs/spec/stt-packed-format.md §7-D6
1340        // and docs/spec/conformance.md §3.
1341        Some(q) => {
1342            geom_meta.insert(STT_QUANT_META_KEY.to_string(), q.to_json());
1343        }
1344        // Advertise the CRS so the tile is self-describing to GeoArrow consumers.
1345        None => {
1346            geom_meta.insert(
1347                GEOARROW_EXT_META_KEY.to_string(),
1348                GEOARROW_CRS_METADATA.to_string(),
1349            );
1350        }
1351    }
1352    fields.push(Arc::new(
1353        Field::new("geometry", geom_array.data_type().clone(), false)
1354            .with_metadata(geom_meta.into_iter().collect()),
1355    ));
1356    columns.push(geom_array);
1357
1358    // Track per-layer vertex-time encoding so the schema metadata (set
1359    // below) records the origin/step needed for the u16-delta reader path.
1360    let mut vertex_time_encoding: Option<(i64, u32)> = None;
1361    if let Some(vt_col) = build_vertex_time_array(&layer.vertex_times, n, cfg.vertex_time_max_step_ms) {
1362        fields.push(Arc::new(Field::new(
1363            "vertex_time",
1364            vt_col.array.data_type().clone(),
1365            true,
1366        )));
1367        columns.push(vt_col.array);
1368        vertex_time_encoding = vt_col.encoding;
1369    }
1370
1371    // Optional per-vertex scalar column (e.g. sea-surface temperature),
1372    // aligned 1:1 with the geometry vertices like `vertex_time`.
1373    if let Some(vv_array) = build_vertex_value_array(&layer.vertex_values, n) {
1374        fields.push(Arc::new(Field::new(
1375            "vertex_value",
1376            vv_array.data_type().clone(),
1377            true,
1378        )));
1379        columns.push(vv_array);
1380    }
1381
1382    // Optional per-vertex × per-bucket value matrix (static-geometry overview
1383    // animation). Reuses the `vertex_value` List<Float32> encoding — each row
1384    // is just longer (vertex_count * num_buckets, vertex-major). num_buckets is
1385    // recovered from the per-feature vertex count and recorded in schema meta.
1386    let mut vertex_value_buckets: Option<u32> = None;
1387    if let Some(vm_array) = build_vertex_value_array(&layer.vertex_value_matrix, n) {
1388        fields.push(Arc::new(Field::new(
1389            "vertex_value_matrix",
1390            vm_array.data_type().clone(),
1391            true,
1392        )));
1393        columns.push(vm_array);
1394        vertex_value_buckets = layer
1395            .vertex_value_matrix
1396            .as_ref()
1397            .and_then(|vm| infer_vertex_value_buckets(vm, &layer.geometry));
1398    }
1399
1400    // Pre-baked triangle indices (MLT-style). Only emitted for polygon
1401    // layers; for any other geometry kind the column is silently dropped so
1402    // an over-eager builder can't poison a point/line layer with stale data.
1403    let has_triangles = matches!(layer.geometry, GeometryColumn::Polygon(_))
1404        && layer
1405            .triangles
1406            .as_ref()
1407            .map(|t| t.iter().any(|f| !f.is_empty()))
1408            .unwrap_or(false);
1409    if has_triangles {
1410        let tri = layer.triangles.as_ref().unwrap();
1411        // Indices are feature-LOCAL (see the field doc above), so they're
1412        // almost always well under 65,536 even for large layers. Mirrors
1413        // build_vertex_time_array's width-selection: scan once, use the
1414        // narrower UInt16 (half the bytes) when every index fits, UInt32
1415        // otherwise. The Arrow field type is derived from the array below,
1416        // so this is fully self-describing — the TS decoder branches on the
1417        // runtime child-array type exactly like it already does for
1418        // vertex_time's UInt16-delta vs Int64-absolute split.
1419        let max_index = tri.iter().flatten().copied().max().unwrap_or(0);
1420        let array: ArrayRef = if max_index <= u16::MAX as u32 {
1421            let mut builder = ListBuilder::new(UInt16Builder::new());
1422            for feature in tri {
1423                for &idx in feature {
1424                    builder.values().append_value(idx as u16);
1425                }
1426                // Always append a (possibly empty) list — readers expect one
1427                // entry per feature.
1428                builder.append(true);
1429            }
1430            Arc::new(builder.finish())
1431        } else {
1432            let mut builder = ListBuilder::new(UInt32Builder::new());
1433            for feature in tri {
1434                for &idx in feature {
1435                    builder.values().append_value(idx);
1436                }
1437                builder.append(true);
1438            }
1439            Arc::new(builder.finish())
1440        };
1441        fields.push(Arc::new(Field::new(
1442            "triangles",
1443            array.data_type().clone(),
1444            false,
1445        )));
1446        columns.push(array);
1447    }
1448
1449    // Fuse configured scalar columns into GPU-ready interleaved Vector columns
1450    // (e.g. qx/qy/qz/qw → one FixedSizeList<f32,4>). Runs BEFORE the quantize
1451    // loop so grouped components are written as the raw vector, not individually
1452    // quantized. No groups configured ⇒ iterate `layer.properties` with no clone.
1453    let grouped =
1454        group_vector_properties(&layer.properties, layer.feature_count(), &cfg.vector_groups);
1455    let props_iter: &[(String, PropertyColumn)] =
1456        grouped.as_deref().unwrap_or(&layer.properties);
1457    for (name, col) in props_iter {
1458        // The point-elevation column now lives in the geometry's 3rd coordinate;
1459        // don't also emit it as a scalar property.
1460        if elev_consumed && name == &elev_col {
1461            continue;
1462        }
1463        match col {
1464            PropertyColumn::Numeric(values) => {
1465                // Opt-in: a numeric property named in the build-global
1466                // quantization map ships as fixed-point ints + a per-column
1467                // affine in field metadata (the reader reconstructs Float64).
1468                // For a LiDAR `z` column this is the single largest size lever
1469                // after `id` — a raw Float64 elevation barely compresses, while
1470                // the i16 grid is both smaller and far more compressible.
1471                let quantized = cfg
1472                    .quantize_attrs
1473                    .get(name)
1474                    .copied()
1475                    .filter(|p| *p > 0.0)
1476                    .and_then(|p| build_quantized_numeric(values, p))
1477                    .or_else(|| {
1478                        // No explicit precision — fall back to the configured
1479                        // automatic range-adaptive quantization when enabled.
1480                        cfg.quantize_attrs_auto
1481                            .then(|| build_quantized_numeric_auto(values))
1482                            .flatten()
1483                    });
1484                match quantized {
1485                    Some((array, affine_json)) => {
1486                        let mut m = HashMap::new();
1487                        m.insert(STT_QUANT_ATTR_META_KEY.to_string(), affine_json);
1488                        fields.push(Arc::new(
1489                            Field::new(name, array.data_type().clone(), true).with_metadata(m),
1490                        ));
1491                        columns.push(array);
1492                    }
1493                    None => {
1494                        fields.push(Arc::new(Field::new(name, DataType::Float64, true)));
1495                        columns.push(Arc::new(Float64Array::from(values.clone())));
1496                    }
1497                }
1498            }
1499            PropertyColumn::Categorical(values) => {
1500                // Build a Dictionary<UInt16, Utf8>: deduplicate strings once
1501                // here so the TS reader can lift the dictionary table out of
1502                // the Arrow batch directly instead of rebuilding it per tile.
1503                let (indices, categories) = build_dictionary_indices(values)?;
1504                let key_type = DataType::UInt16;
1505                let value_type = DataType::Utf8;
1506                let dict_type = DataType::Dictionary(Box::new(key_type), Box::new(value_type));
1507                fields.push(Arc::new(Field::new(name, dict_type, true)));
1508
1509                let value_array: ArrayRef = Arc::new(StringArray::from(
1510                    categories.iter().map(|s| Some(s.as_str())).collect::<Vec<_>>(),
1511                ));
1512                let key_array = UInt16Array::from(indices);
1513                let dict = DictionaryArray::<UInt16Type>::try_new(key_array, value_array)
1514                    .map_err(|e| Error::Other(format!("dictionary build failed: {e}")))?;
1515                columns.push(Arc::new(dict));
1516            }
1517            PropertyColumn::Vector { width, elem, values } => {
1518                // Interleaved GPU-ready vector → FixedSizeList<leaf, width>. The
1519                // child buffer is the flattened row-major run, so the TS decoder
1520                // hands `child.values.subarray(...)` straight to deck.gl with no
1521                // per-point re-interleave. Non-null leaf (producer encodes a
1522                // missing feature as a zero/identity vector).
1523                let (child, child_dt): (ArrayRef, DataType) = match elem {
1524                    VectorElem::F32 => (
1525                        Arc::new(Float32Array::from(values.clone())),
1526                        DataType::Float32,
1527                    ),
1528                    VectorElem::U8 => {
1529                        let bytes: Vec<u8> = values
1530                            .iter()
1531                            .map(|v| v.round().clamp(0.0, 255.0) as u8)
1532                            .collect();
1533                        (Arc::new(UInt8Array::from(bytes)), DataType::UInt8)
1534                    }
1535                };
1536                let item_field = Arc::new(Field::new("item", child_dt, false));
1537                let fsl = FixedSizeListArray::new(item_field, *width as i32, child, None);
1538                fields.push(Arc::new(Field::new(
1539                    name,
1540                    fsl.data_type().clone(),
1541                    true,
1542                )));
1543                columns.push(Arc::new(fsl));
1544            }
1545        }
1546    }
1547
1548    // Schema-level metadata records the layer name and geometry kind so a
1549    // reader does not have to inspect the geometry column. When the
1550    // vertex_time column is u16-delta encoded we add `origin_ms` and
1551    // `step_ms` so the reader can reconstruct absolute timestamps as
1552    // `origin + delta * step`.
1553    //
1554    // Built in a BTreeMap so the key set is assembled in deterministic
1555    // (lexicographic) order — this encoder contributes no ordering
1556    // non-determinism, and the tile is byte-reproducible on any Arrow whose
1557    // metadata serialization preserves order. NOTE: Arrow (v54) still stores
1558    // this in a HashMap and serializes it in per-process HashMap-iteration
1559    // order, so the *raw* metadata-region bytes of two identical tiles can
1560    // differ across runs; that residual gap (which caps content-addressed pack
1561    // dedup) is tracked in docs/spec/stt-packed-format.md §7-D6.
1562    let mut schema_meta: BTreeMap<String, String> = BTreeMap::new();
1563    schema_meta.insert("stt:layer".to_string(), layer.name.clone());
1564    schema_meta.insert(
1565        "stt:geometry".to_string(),
1566        layer.geometry.geoarrow_name().to_string(),
1567    );
1568    // Bake the layer's minimum feature start-time (integer Unix ms) so the TS
1569    // decoder can skip its client-side min-scan over the whole start-time column
1570    // and relativize times against this value directly. Mirrors exactly what the
1571    // decoder computes (the min of the `start_time` column); only emitted when a
1572    // start-time column is present. See packages/core/src/tile.ts.
1573    if let Some(min_start) = layer.start_times.iter().copied().min() {
1574        schema_meta.insert(TIME_OFFSET_MS_KEY.to_string(), min_start.to_string());
1575    }
1576    if let Some((origin, step)) = vertex_time_encoding {
1577        schema_meta.insert(VERTEX_TIME_ORIGIN_KEY.to_string(), origin.to_string());
1578        schema_meta.insert(VERTEX_TIME_STEP_KEY.to_string(), step.to_string());
1579    }
1580    if let Some(buckets) = vertex_value_buckets {
1581        schema_meta.insert(VERTEX_VALUE_BUCKETS_KEY.to_string(), buckets.to_string());
1582    }
1583    if has_triangles {
1584        schema_meta.insert(TRIANGLES_METADATA_KEY.to_string(), "true".to_string());
1585    }
1586    let schema = Arc::new(Schema::new(fields).with_metadata(schema_meta.into_iter().collect()));
1587
1588    let batch = RecordBatch::try_new(schema.clone(), columns)
1589        .map_err(|e| Error::Other(format!("failed to build tile RecordBatch: {e}")))?;
1590
1591    let mut buf = Vec::new();
1592    {
1593        let mut writer = StreamWriter::try_new(&mut buf, &schema)
1594            .map_err(|e| Error::Other(format!("Arrow IPC writer init failed: {e}")))?;
1595        writer
1596            .write(&batch)
1597            .map_err(|e| Error::Other(format!("Arrow IPC write failed: {e}")))?;
1598        writer
1599            .finish()
1600            .map_err(|e| Error::Other(format!("Arrow IPC finish failed: {e}")))?;
1601    }
1602    Ok(buf)
1603}
1604
1605/// Encode a full tile payload (one or more layers) with the layer frame.
1606///
1607/// Always emits the *aligned* frame ([`ALIGNED_FRAME_FLAG`] set): each
1608/// layer's IPC stream is preceded by zero padding to an 8-byte boundary
1609/// relative to the payload start, so readers can wrap the stream zero-copy.
1610/// `ipc_len` records the exact IPC byte length (padding excluded); readers
1611/// derive the pad from alignment math alone.
1612pub fn encode_tile(layers: &[ColumnarLayer]) -> Result<Vec<u8>> {
1613    encode_tile_cfg(layers, &EncoderConfig::from_globals())
1614}
1615
1616/// [`encode_tile`] with optional fixed-point coordinate quantization applied to
1617/// every layer (see [`encode_layer_quantized`]). `quantize_m = None` is
1618/// byte-identical to [`encode_tile`]; the other encoder settings come from the
1619/// process-wide globals.
1620pub fn encode_tile_quantized(layers: &[ColumnarLayer], quantize_m: Option<f64>) -> Result<Vec<u8>> {
1621    encode_tile_cfg(
1622        layers,
1623        &EncoderConfig {
1624            quantize_coords_m: quantize_m,
1625            ..EncoderConfig::from_globals()
1626        },
1627    )
1628}
1629
1630/// [`encode_tile`] with a fully-explicit [`EncoderConfig`] — no process-wide
1631/// globals are read. The concurrency- and multi-config-safe entry point a
1632/// dynamic per-request tile server uses so each dataset/request encodes with its
1633/// own settings without touching shared state.
1634pub fn encode_tile_with(layers: &[ColumnarLayer], cfg: &EncoderConfig) -> Result<Vec<u8>> {
1635    encode_tile_cfg(layers, cfg)
1636}
1637
1638/// The single tile-encode implementation, driven entirely by an explicit
1639/// [`EncoderConfig`]. Every public `encode_tile*` entry point funnels here.
1640fn encode_tile_cfg(layers: &[ColumnarLayer], cfg: &EncoderConfig) -> Result<Vec<u8>> {
1641    if layers.len() >= ALIGNED_FRAME_FLAG as usize {
1642        return Err(Error::Other(format!(
1643            "tile has {} layers, exceeds the {} frame limit",
1644            layers.len(),
1645            ALIGNED_FRAME_FLAG - 1
1646        )));
1647    }
1648    let mut out = Vec::new();
1649    out.extend_from_slice(&(layers.len() as u16 | ALIGNED_FRAME_FLAG).to_le_bytes());
1650    for layer in layers {
1651        let name = layer.name.as_bytes();
1652        if name.len() > u16::MAX as usize {
1653            return Err(Error::Other("layer name too long".into()));
1654        }
1655        let ipc = encode_layer_cfg(layer, cfg)?;
1656        out.extend_from_slice(&(name.len() as u16).to_le_bytes());
1657        out.extend_from_slice(name);
1658        out.extend_from_slice(&(ipc.len() as u32).to_le_bytes());
1659        let pad = (FRAME_ALIGN - out.len() % FRAME_ALIGN) % FRAME_ALIGN;
1660        out.extend_from_slice(&[0u8; FRAME_ALIGN][..pad]);
1661        out.extend_from_slice(&ipc);
1662    }
1663    Ok(out)
1664}
1665
1666// ----------------------------------------------------------------------------
1667// Decoding
1668// ----------------------------------------------------------------------------
1669
1670/// A decoded tile layer: its name and the raw Arrow [`RecordBatch`].
1671#[derive(Debug, Clone)]
1672pub struct DecodedLayer {
1673    /// Layer name from the layer frame.
1674    pub name: String,
1675    /// The decoded Arrow record batch.
1676    pub batch: RecordBatch,
1677}
1678
1679/// Decode a single-layer Arrow IPC stream into a [`RecordBatch`].
1680pub fn decode_layer(ipc: &[u8]) -> Result<RecordBatch> {
1681    let reader = StreamReader::try_new(ipc, None)
1682        .map_err(|e| Error::Other(format!("Arrow IPC reader init failed: {e}")))?;
1683    let mut batches: Vec<RecordBatch> = Vec::new();
1684    for batch in reader {
1685        batches.push(batch.map_err(|e| Error::Other(format!("Arrow IPC read failed: {e}")))?);
1686    }
1687    match batches.len() {
1688        0 => Err(Error::Other("tile layer IPC contained no record batch".into())),
1689        1 => Ok(batches.into_iter().next().unwrap()),
1690        // A layer is written as exactly one batch; concatenating is the safe
1691        // fallback if a producer ever splits it.
1692        _ => arrow::compute::concat_batches(&batches[0].schema(), &batches)
1693            .map_err(|e| Error::Other(format!("failed to concat tile batches: {e}"))),
1694    }
1695}
1696
1697/// Decode a full tile payload (the layer frame) into its layers.
1698///
1699/// Accepts both frame shapes: the aligned frame ([`ALIGNED_FRAME_FLAG`] set,
1700/// with derived padding before each IPC stream) and the legacy unpadded
1701/// frame written by every archive that predates the flag.
1702pub fn decode_tile(payload: &[u8]) -> Result<Vec<DecodedLayer>> {
1703    if payload.len() < 2 {
1704        return Err(Error::Other("tile payload too short for layer frame".into()));
1705    }
1706    let raw_count = u16::from_le_bytes([payload[0], payload[1]]);
1707    let aligned = raw_count & ALIGNED_FRAME_FLAG != 0;
1708    let count = (raw_count & !ALIGNED_FRAME_FLAG) as usize;
1709    let mut pos = 2usize;
1710    let mut layers = Vec::with_capacity(count);
1711    for _ in 0..count {
1712        let name_len = read_u16(payload, &mut pos)? as usize;
1713        let name = read_slice(payload, &mut pos, name_len)?;
1714        let name = String::from_utf8(name.to_vec())
1715            .map_err(|e| Error::Other(format!("layer name not utf8: {e}")))?;
1716        let ipc_len = read_u32(payload, &mut pos)? as usize;
1717        if aligned {
1718            let pad = (FRAME_ALIGN - pos % FRAME_ALIGN) % FRAME_ALIGN;
1719            read_slice(payload, &mut pos, pad)?;
1720        }
1721        let ipc = read_slice(payload, &mut pos, ipc_len)?;
1722        let batch = decode_layer(ipc)?;
1723        layers.push(DecodedLayer { name, batch });
1724    }
1725    Ok(layers)
1726}
1727
1728fn read_u16(buf: &[u8], pos: &mut usize) -> Result<u16> {
1729    let s = read_slice(buf, pos, 2)?;
1730    Ok(u16::from_le_bytes([s[0], s[1]]))
1731}
1732
1733fn read_u32(buf: &[u8], pos: &mut usize) -> Result<u32> {
1734    let s = read_slice(buf, pos, 4)?;
1735    Ok(u32::from_le_bytes([s[0], s[1], s[2], s[3]]))
1736}
1737
1738fn read_slice<'a>(buf: &'a [u8], pos: &mut usize, len: usize) -> Result<&'a [u8]> {
1739    let end = pos
1740        .checked_add(len)
1741        .ok_or_else(|| Error::Other("tile frame length overflow".into()))?;
1742    if end > buf.len() {
1743        return Err(Error::Other("tile frame truncated".into()));
1744    }
1745    let s = &buf[*pos..end];
1746    *pos = end;
1747    Ok(s)
1748}
1749
1750#[cfg(test)]
1751mod tests {
1752    use super::*;
1753
1754    fn sample_point_layer() -> ColumnarLayer {
1755        ColumnarLayer {
1756            name: "points".to_string(),
1757            feature_ids: vec![1, 2, 3],
1758            start_times: vec![1000, 2000, 3000],
1759            end_times: vec![1500, 2500, 3500],
1760            geometry: GeometryColumn::Point(vec![
1761                [-122.4, 37.7],
1762                [-122.5, 37.8],
1763                [-122.6, 37.9],
1764            ]),
1765            vertex_times: None,
1766            vertex_values: None,
1767            triangles: None,
1768            vertex_value_matrix: None,
1769            properties: vec![
1770                (
1771                    "speed".to_string(),
1772                    PropertyColumn::Numeric(vec![Some(10.0), None, Some(30.0)]),
1773                ),
1774                (
1775                    "kind".to_string(),
1776                    PropertyColumn::Categorical(vec![
1777                        Some("car".to_string()),
1778                        Some("bus".to_string()),
1779                        None,
1780                    ]),
1781                ),
1782            ],
1783        }
1784    }
1785
1786    /// Two DIFFERENT [`EncoderConfig`]s encode the SAME layer to DIFFERENT tiles
1787    /// in ONE process, and the output is driven purely by the passed config — not
1788    /// by the process-wide globals. This is the property that unblocks a dynamic
1789    /// server hosting several datasets/configs concurrently: if `encode_tile_with`
1790    /// read the (unset) globals instead of the config, the quantized and plain
1791    /// encodes would be identical and this would fail.
1792    #[test]
1793    fn encode_tile_with_is_config_driven_not_global() {
1794        let layer = sample_point_layer();
1795        let layers = std::slice::from_ref(&layer);
1796
1797        let plain_cfg = EncoderConfig::default();
1798        let quant_cfg = EncoderConfig {
1799            quantize_coords_m: Some(1.0),
1800            ..EncoderConfig::default()
1801        };
1802        let attr_cfg = EncoderConfig {
1803            quantize_attrs_auto: true,
1804            ..EncoderConfig::default()
1805        };
1806
1807        let plain = encode_tile_with(layers, &plain_cfg).unwrap();
1808        let quant = encode_tile_with(layers, &quant_cfg).unwrap();
1809        let attr = encode_tile_with(layers, &attr_cfg).unwrap();
1810
1811        // Each explicit config yields a distinct tile — the config, not shared
1812        // state, decides the encoding. (If `encode_tile_with` read the unset
1813        // globals instead of the config, all three would be identical.) These
1814        // differences are config-driven at the WIRE COLUMN level — coord
1815        // quantization changes the geometry column (i32 grid vs Float64) and
1816        // attribute quantization changes the `speed` column (u16 vs Float64) — so
1817        // the inequality is not attributable to the encoder's (separately
1818        // tracked) non-deterministic Arrow-metadata ordering, which we therefore
1819        // deliberately do NOT byte-assert here.
1820        assert_ne!(plain, quant, "coord quantization must change the tile");
1821        assert_ne!(plain, attr, "attribute quantization must change the tile");
1822        assert_ne!(quant, attr, "the two quantizations differ from each other");
1823
1824        // All three still decode to the SAME feature set — encoding differs, data
1825        // does not.
1826        for tile in [&plain, &quant, &attr] {
1827            let rows: usize = decode_tile(tile).unwrap().iter().map(|l| l.batch.num_rows()).sum();
1828            assert_eq!(rows, 3);
1829        }
1830    }
1831
1832    fn sample_line_layer() -> ColumnarLayer {
1833        ColumnarLayer {
1834            name: "tracks".to_string(),
1835            feature_ids: vec![10, 11],
1836            start_times: vec![0, 100],
1837            end_times: vec![50, 200],
1838            geometry: GeometryColumn::LineString(vec![
1839                vec![[0.0, 0.0], [1.0, 1.0], [2.0, 2.0]],
1840                vec![[5.0, 5.0], [6.0, 6.0]],
1841            ]),
1842            vertex_times: Some(vec![vec![0, 25, 50], vec![100, 200]]),
1843            vertex_values: None,
1844            triangles: None,
1845            vertex_value_matrix: None,
1846            properties: vec![],
1847        }
1848    }
1849
1850    fn sample_polygon_layer() -> ColumnarLayer {
1851        ColumnarLayer {
1852            name: "zones".to_string(),
1853            feature_ids: vec![42],
1854            start_times: vec![0],
1855            end_times: vec![1000],
1856            geometry: GeometryColumn::Polygon(vec![vec![
1857                // exterior ring
1858                vec![[0.0, 0.0], [4.0, 0.0], [4.0, 4.0], [0.0, 4.0], [0.0, 0.0]],
1859                // hole
1860                vec![[1.0, 1.0], [2.0, 1.0], [2.0, 2.0], [1.0, 2.0], [1.0, 1.0]],
1861            ]]),
1862            vertex_times: None,
1863            vertex_values: None,
1864            triangles: None,
1865            vertex_value_matrix: None,
1866            properties: vec![],
1867        }
1868    }
1869
1870    #[test]
1871    fn categorical_columns_use_dictionary_encoding() {
1872        let layer = ColumnarLayer {
1873            name: "cars".into(),
1874            feature_ids: vec![1, 2, 3, 4, 5],
1875            start_times: vec![0; 5],
1876            end_times: vec![1; 5],
1877            geometry: GeometryColumn::Point(vec![[0.0, 0.0]; 5]),
1878            vertex_times: None,
1879            vertex_values: None,
1880            triangles: None,
1881            vertex_value_matrix: None,
1882            properties: vec![(
1883                "kind".into(),
1884                PropertyColumn::Categorical(vec![
1885                    Some("car".into()),
1886                    Some("bus".into()),
1887                    Some("car".into()),
1888                    None,
1889                    Some("car".into()),
1890                ]),
1891            )],
1892        };
1893        let ipc = encode_layer(&layer).unwrap();
1894        let batch = decode_layer(&ipc).unwrap();
1895        let field = batch.schema().field_with_name("kind").unwrap().clone();
1896        match field.data_type() {
1897            DataType::Dictionary(k, v) => {
1898                assert_eq!(k.as_ref(), &DataType::UInt16);
1899                assert_eq!(v.as_ref(), &DataType::Utf8);
1900            }
1901            other => panic!("expected Dictionary<UInt16, Utf8>, got {other:?}"),
1902        }
1903
1904        let col = batch
1905            .column_by_name("kind")
1906            .unwrap()
1907            .as_any()
1908            .downcast_ref::<DictionaryArray<UInt16Type>>()
1909            .unwrap();
1910        let values = col
1911            .values()
1912            .as_any()
1913            .downcast_ref::<StringArray>()
1914            .unwrap();
1915        // First-seen order: "car" then "bus".
1916        let mut categories: Vec<&str> = (0..values.len()).map(|i| values.value(i)).collect();
1917        categories.sort();
1918        assert_eq!(categories, vec!["bus", "car"]);
1919
1920        // The 4th row is null; others reference one of the two slots.
1921        assert!(col.is_null(3));
1922        let keys = col.keys();
1923        for i in [0usize, 1, 2, 4] {
1924            assert!(keys.value(i) < values.len() as u16);
1925        }
1926    }
1927
1928    #[test]
1929    fn categorical_overflow_errors_instead_of_corrupting() {
1930        // A column whose distinct-value count exceeds the UInt16 dictionary
1931        // key space must be rejected, not silently collapsed onto one index.
1932        let n = u16::MAX as usize + 1; // 65_536 distinct strings
1933        let kinds: Vec<Option<String>> = (0..n).map(|i| Some(format!("c{i}"))).collect();
1934        let layer = ColumnarLayer {
1935            name: "huge".into(),
1936            feature_ids: (0..n as u64).collect(),
1937            start_times: vec![0; n],
1938            end_times: vec![1; n],
1939            geometry: GeometryColumn::Point(vec![[0.0, 0.0]; n]),
1940            vertex_times: None,
1941            vertex_values: None,
1942            triangles: None,
1943            vertex_value_matrix: None,
1944            properties: vec![("kind".into(), PropertyColumn::Categorical(kinds))],
1945        };
1946        let err = encode_layer(&layer).expect_err("overflowing dictionary must error");
1947        assert!(
1948            err.to_string().contains("distinct values"),
1949            "unexpected error: {err}"
1950        );
1951    }
1952
1953    #[test]
1954    fn geometry_field_advertises_crs_metadata() {
1955        // Every geometry field carries the GeoArrow extension *name* and the
1956        // CRS in extension *metadata*, so external GeoArrow readers see WGS84
1957        // lon/lat (OGC:CRS84) rather than an unknown CRS.
1958        for layer in [sample_point_layer(), sample_line_layer(), sample_polygon_layer()] {
1959            let ipc = encode_layer(&layer).unwrap();
1960            let batch = decode_layer(&ipc).unwrap();
1961            let field = batch.schema().field_with_name("geometry").unwrap().clone();
1962            let meta = field.metadata();
1963            assert_eq!(
1964                meta.get(GEOARROW_EXT_KEY).map(String::as_str),
1965                Some(layer.geometry.geoarrow_name())
1966            );
1967            let crs = meta
1968                .get(GEOARROW_EXT_META_KEY)
1969                .expect("geometry field must carry ARROW:extension:metadata");
1970            assert!(crs.contains("OGC:CRS84"), "crs metadata was: {crs}");
1971            assert!(crs.contains("crs_type"), "crs metadata was: {crs}");
1972        }
1973    }
1974
1975    #[test]
1976    fn point_layer_roundtrips() {
1977        let layer = sample_point_layer();
1978        let ipc = encode_layer(&layer).unwrap();
1979        let batch = decode_layer(&ipc).unwrap();
1980
1981        assert_eq!(batch.num_rows(), 3);
1982        // id / start / end / geometry / speed / kind
1983        assert_eq!(batch.num_columns(), 6);
1984
1985        let ids = batch
1986            .column_by_name("id")
1987            .unwrap()
1988            .as_any()
1989            .downcast_ref::<UInt64Array>()
1990            .unwrap();
1991        assert_eq!(ids.values(), &[1, 2, 3]);
1992
1993        let geom = batch
1994            .column_by_name("geometry")
1995            .unwrap()
1996            .as_any()
1997            .downcast_ref::<FixedSizeListArray>()
1998            .unwrap();
1999        assert_eq!(geom.len(), 3);
2000        assert_eq!(geom.value_length(), 2);
2001
2002        // Geometry field carries the GeoArrow extension name.
2003        let geom_field = batch.schema().field_with_name("geometry").unwrap().clone();
2004        assert_eq!(
2005            geom_field.metadata().get(GEOARROW_EXT_KEY).map(String::as_str),
2006            Some("geoarrow.point")
2007        );
2008
2009        // Nullable numeric property preserves the null.
2010        let speed = batch
2011            .column_by_name("speed")
2012            .unwrap()
2013            .as_any()
2014            .downcast_ref::<Float64Array>()
2015            .unwrap();
2016        assert!(speed.is_null(1));
2017        assert_eq!(speed.value(0), 10.0);
2018    }
2019
2020    #[test]
2021    fn vector_property_roundtrips_as_fixed_size_list() {
2022        // A Vector property encodes as FixedSizeList<leaf, width>: the f32 quat
2023        // as <Float32,4>, the u8 colour as <UInt8,4>, with the child buffer the
2024        // flattened row-major run the TS decoder hands to the GPU zero-copy.
2025        use arrow::array::{Float32Array, UInt8Array};
2026        let layer = ColumnarLayer {
2027            name: "surfels".to_string(),
2028            feature_ids: vec![1, 2],
2029            start_times: vec![0, 10],
2030            end_times: vec![0, 10],
2031            geometry: GeometryColumn::Point(vec![[-122.4, 37.7], [-122.5, 37.8]]),
2032            vertex_times: None,
2033            vertex_values: None,
2034            triangles: None,
2035            vertex_value_matrix: None,
2036            properties: vec![
2037                (
2038                    "surfel_quat".to_string(),
2039                    PropertyColumn::Vector {
2040                        width: 4,
2041                        elem: VectorElem::F32,
2042                        values: vec![0.0, 0.0, 0.0, 1.0, 0.5, 0.5, 0.5, 0.5],
2043                    },
2044                ),
2045                (
2046                    "surfel_rgba".to_string(),
2047                    PropertyColumn::Vector {
2048                        width: 4,
2049                        elem: VectorElem::U8,
2050                        values: vec![255.0, 0.0, 0.0, 128.0, 0.0, 255.0, 0.0, 255.0],
2051                    },
2052                ),
2053            ],
2054        };
2055        let ipc = encode_layer(&layer).unwrap();
2056        let batch = decode_layer(&ipc).unwrap();
2057
2058        let quat = batch
2059            .column_by_name("surfel_quat")
2060            .unwrap()
2061            .as_any()
2062            .downcast_ref::<FixedSizeListArray>()
2063            .unwrap();
2064        assert_eq!(quat.len(), 2);
2065        assert_eq!(quat.value_length(), 4);
2066        let qchild = quat
2067            .values()
2068            .as_any()
2069            .downcast_ref::<Float32Array>()
2070            .unwrap();
2071        assert_eq!(
2072            qchild.values(),
2073            &[0.0, 0.0, 0.0, 1.0, 0.5, 0.5, 0.5, 0.5]
2074        );
2075
2076        let rgba = batch
2077            .column_by_name("surfel_rgba")
2078            .unwrap()
2079            .as_any()
2080            .downcast_ref::<FixedSizeListArray>()
2081            .unwrap();
2082        assert_eq!(rgba.value_length(), 4);
2083        let cchild = rgba
2084            .values()
2085            .as_any()
2086            .downcast_ref::<UInt8Array>()
2087            .unwrap();
2088        assert_eq!(cchild.values(), &[255, 0, 0, 128, 0, 255, 0, 255]);
2089    }
2090
2091    #[test]
2092    fn vector_groups_fuse_scalar_columns_at_encode() {
2093        // `--vector-group` fuses named scalar columns into one interleaved
2094        // FixedSizeList and drops the scalars; ungrouped columns are untouched.
2095        use arrow::array::Float32Array;
2096        set_vector_groups(vec![VectorGroup {
2097            name: "surfel_quat".to_string(),
2098            components: vec!["qx".into(), "qy".into(), "qz".into(), "qw".into()],
2099            elem: VectorElem::F32,
2100        }]);
2101        let layer = ColumnarLayer {
2102            name: "surfels".to_string(),
2103            feature_ids: vec![1, 2],
2104            start_times: vec![0, 10],
2105            end_times: vec![0, 10],
2106            geometry: GeometryColumn::Point(vec![[-122.4, 37.7], [-122.5, 37.8]]),
2107            vertex_times: None,
2108            vertex_values: None,
2109            triangles: None,
2110            vertex_value_matrix: None,
2111            properties: vec![
2112                ("qx".into(), PropertyColumn::Numeric(vec![Some(0.0), Some(0.5)])),
2113                ("qy".into(), PropertyColumn::Numeric(vec![Some(0.0), Some(0.5)])),
2114                ("qz".into(), PropertyColumn::Numeric(vec![Some(0.0), Some(0.5)])),
2115                ("qw".into(), PropertyColumn::Numeric(vec![Some(1.0), Some(0.5)])),
2116                ("z".into(), PropertyColumn::Numeric(vec![Some(3.0), Some(4.0)])),
2117            ],
2118        };
2119        let ipc = encode_layer(&layer).unwrap();
2120        set_vector_groups(Vec::new()); // reset the build-global before asserting
2121        let batch = decode_layer(&ipc).unwrap();
2122
2123        // Scalars fused away; the grouped vector + the ungrouped `z` remain.
2124        assert!(batch.column_by_name("qx").is_none());
2125        assert!(batch.column_by_name("z").is_some());
2126        let quat = batch
2127            .column_by_name("surfel_quat")
2128            .unwrap()
2129            .as_any()
2130            .downcast_ref::<FixedSizeListArray>()
2131            .unwrap();
2132        assert_eq!(quat.value_length(), 4);
2133        let qchild = quat
2134            .values()
2135            .as_any()
2136            .downcast_ref::<Float32Array>()
2137            .unwrap();
2138        assert_eq!(qchild.values(), &[0.0, 0.0, 0.0, 1.0, 0.5, 0.5, 0.5, 0.5]);
2139    }
2140
2141    #[test]
2142    fn point_elevation_folds_into_3d_geometry_unquantized() {
2143        use arrow::array::Float64Array;
2144        let layer = ColumnarLayer {
2145            name: "cloud".into(),
2146            feature_ids: vec![1, 2],
2147            start_times: vec![0, 0],
2148            end_times: vec![0, 0],
2149            geometry: GeometryColumn::Point(vec![[-122.4, 37.7], [-122.5, 37.8]]),
2150            vertex_times: None,
2151            vertex_values: None,
2152            triangles: None,
2153            vertex_value_matrix: None,
2154            properties: vec![
2155                ("z".into(), PropertyColumn::Numeric(vec![Some(3.5), Some(9.0)])),
2156                ("speed".into(), PropertyColumn::Numeric(vec![Some(1.0), Some(2.0)])),
2157            ],
2158        };
2159        set_point_elevation_column("z");
2160        let ipc = encode_layer(&layer).unwrap();
2161        set_point_elevation_column("");
2162        let batch = decode_layer(&ipc).unwrap();
2163
2164        // Geometry is now a 3-wide list with z folded in; `z` is gone as a property.
2165        let geom = batch
2166            .column_by_name("geometry")
2167            .unwrap()
2168            .as_any()
2169            .downcast_ref::<FixedSizeListArray>()
2170            .unwrap();
2171        assert_eq!(geom.value_length(), 3);
2172        let coords = geom.values().as_any().downcast_ref::<Float64Array>().unwrap();
2173        assert_eq!(coords.value(2), 3.5); // feature 0 z
2174        assert_eq!(coords.value(5), 9.0); // feature 1 z
2175        assert!(batch.column_by_name("z").is_none(), "z folded into geometry");
2176        assert!(batch.column_by_name("speed").is_some(), "other props untouched");
2177    }
2178
2179    #[test]
2180    fn point_elevation_3d_geometry_quantizes_with_z_affine() {
2181        use arrow::array::Int32Array;
2182        let layer = ColumnarLayer {
2183            name: "cloud".into(),
2184            feature_ids: vec![1],
2185            start_times: vec![0],
2186            end_times: vec![0],
2187            geometry: GeometryColumn::Point(vec![[-122.4, 37.7]]),
2188            vertex_times: None,
2189            vertex_values: None,
2190            triangles: None,
2191            vertex_value_matrix: None,
2192            properties: vec![("z".into(), PropertyColumn::Numeric(vec![Some(5.0)]))],
2193        };
2194        set_point_elevation_column("z");
2195        let ipc = encode_layer_quantized(&layer, Some(0.05)).unwrap();
2196        set_point_elevation_column("");
2197        let batch = decode_layer(&ipc).unwrap();
2198
2199        let field = batch.schema().field_with_name("geometry").unwrap().clone();
2200        let affine = QuantAffine::from_json(field.metadata().get(STT_QUANT_META_KEY).unwrap()).unwrap();
2201        assert_eq!(affine.z0, Some(0.0));
2202        assert_eq!(affine.sz, Some(0.05));
2203        let geom = batch
2204            .column_by_name("geometry")
2205            .unwrap()
2206            .as_any()
2207            .downcast_ref::<FixedSizeListArray>()
2208            .unwrap();
2209        assert_eq!(geom.value_length(), 3);
2210        let coords = geom.values().as_any().downcast_ref::<Int32Array>().unwrap();
2211        // z = 5.0 / 0.05 = 100; reconstructs to z0 + 100*sz = 5.0.
2212        assert_eq!(coords.value(2), 100);
2213        assert_eq!(affine.z0.unwrap() + coords.value(2) as f64 * affine.sz.unwrap(), 5.0);
2214    }
2215
2216    #[test]
2217    fn quantized_point_layer_roundtrips_within_precision() {
2218        let layer = sample_point_layer();
2219        let ipc = encode_layer_quantized(&layer, Some(1.0)).unwrap();
2220        let batch = decode_layer(&ipc).unwrap();
2221
2222        // Geometry leaf is now i32 grid indices, and the affine rides in metadata.
2223        let geom_field = batch.schema().field_with_name("geometry").unwrap().clone();
2224        let affine = QuantAffine::from_json(
2225            geom_field
2226                .metadata()
2227                .get(STT_QUANT_META_KEY)
2228                .expect("quantized tile must carry the affine"),
2229        )
2230        .unwrap();
2231
2232        let geom = batch
2233            .column_by_name("geometry")
2234            .unwrap()
2235            .as_any()
2236            .downcast_ref::<FixedSizeListArray>()
2237            .unwrap();
2238        assert_eq!(geom.value_type(), DataType::Int32);
2239        let coords = geom
2240            .values()
2241            .as_any()
2242            .downcast_ref::<Int32Array>()
2243            .unwrap();
2244
2245        let original = [[-122.4, 37.7], [-122.5, 37.8], [-122.6, 37.9]];
2246        for (i, [lon, lat]) in original.iter().enumerate() {
2247            let rlon = affine.lon(coords.value(i * 2));
2248            let rlat = affine.lat(coords.value(i * 2 + 1));
2249            // Worst-case reconstruction error ≤ ~half a quantum (~0.5 m).
2250            let dlon_m = (rlon - lon).abs() * M_PER_DEG_LAT * lat.to_radians().cos();
2251            let dlat_m = (rlat - lat).abs() * M_PER_DEG_LAT;
2252            assert!(dlon_m < 1.0, "lon err {dlon_m} m at point {i}");
2253            assert!(dlat_m < 1.0, "lat err {dlat_m} m at point {i}");
2254        }
2255    }
2256
2257    #[test]
2258    fn quantized_numeric_attr_roundtrips_within_precision_and_is_opt_in() {
2259        // A LiDAR-style `z` elevation column: high-entropy Float64 by default,
2260        // but fixed-point UInt16 when the build opts the column in. The reader
2261        // reconstructs `value = o + q*s`, lossy to <= s/2.
2262        let zvals: Vec<Option<f64>> =
2263            vec![Some(1.07), Some(-2.4), Some(15.9), None, Some(40.02)];
2264        let make = || ColumnarLayer {
2265            name: "lidar".into(),
2266            feature_ids: vec![1, 2, 3, 4, 5],
2267            start_times: vec![0; 5],
2268            end_times: vec![1; 5],
2269            geometry: GeometryColumn::Point(vec![[-122.4, 37.7]; 5]),
2270            vertex_times: None,
2271            vertex_values: None,
2272            triangles: None,
2273            vertex_value_matrix: None,
2274            properties: vec![("z".into(), PropertyColumn::Numeric(zvals.clone()))],
2275        };
2276
2277        // Default (no attr-quant configured): `z` stays Float64, byte-identical
2278        // to the historical encoder.
2279        set_quantize_attrs(HashMap::new());
2280        let plain = decode_layer(&encode_layer(&make()).unwrap()).unwrap();
2281        let zf = plain.schema().field_with_name("z").unwrap().clone();
2282        assert_eq!(zf.data_type(), &DataType::Float64);
2283        assert!(zf.metadata().get(STT_QUANT_ATTR_META_KEY).is_none());
2284
2285        // Opt the `z` column in at 0.05-unit precision.
2286        set_quantize_attrs(HashMap::from([("z".to_string(), 0.05f64)]));
2287        let q = encode_layer(&make()).unwrap();
2288        set_quantize_attrs(HashMap::new()); // reset so other tests are unaffected
2289
2290        let batch = decode_layer(&q).unwrap();
2291        let field = batch.schema().field_with_name("z").unwrap().clone();
2292        // Range (-2.4..40.02)/0.05 ~= 848 fits 16 bits → UInt16 leaf.
2293        assert_eq!(field.data_type(), &DataType::UInt16);
2294        let affine = AttrQuant::from_json(
2295            field
2296                .metadata()
2297                .get(STT_QUANT_ATTR_META_KEY)
2298                .expect("quantized attr must carry the affine"),
2299        )
2300        .unwrap();
2301
2302        let col = batch
2303            .column_by_name("z")
2304            .unwrap()
2305            .as_any()
2306            .downcast_ref::<UInt16Array>()
2307            .unwrap();
2308        for (i, want) in zvals.iter().enumerate() {
2309            match want {
2310                Some(v) => {
2311                    assert!(!col.is_null(i), "row {i} should be present");
2312                    let got = affine.value(col.value(i) as i64);
2313                    assert!((got - v).abs() <= 0.05 / 2.0 + 1e-9, "z[{i}] {got} vs {v}");
2314                }
2315                None => assert!(col.is_null(i), "row {i} should be null"),
2316            }
2317        }
2318    }
2319
2320    #[test]
2321    fn auto_numeric_quantization_is_range_adaptive_and_opt_in() {
2322        // With auto-quant enabled, a raw Float64 property is quantized to a
2323        // UInt16 sized from its own [min,max] span (no precision configured),
2324        // and reconstructs to <= span/65535. Default-off keeps it Float64.
2325        let depth: Vec<Option<f64>> = vec![Some(0.0), Some(10.0), Some(123.4), Some(700.0)];
2326        let make = || ColumnarLayer {
2327            name: "q".into(),
2328            feature_ids: vec![1, 2, 3, 4],
2329            start_times: vec![0; 4],
2330            end_times: vec![1; 4],
2331            geometry: GeometryColumn::Point(vec![[0.0, 0.0]; 4]),
2332            vertex_times: None,
2333            vertex_values: None,
2334            triangles: None,
2335            vertex_value_matrix: None,
2336            properties: vec![("depth".into(), PropertyColumn::Numeric(depth.clone()))],
2337        };
2338
2339        // Default: auto off → Float64.
2340        set_quantize_attrs_auto(false);
2341        let plain = decode_layer(&encode_layer(&make()).unwrap()).unwrap();
2342        assert_eq!(
2343            plain.schema().field_with_name("depth").unwrap().data_type(),
2344            &DataType::Float64
2345        );
2346
2347        // Auto on → range-adaptive UInt16 + affine.
2348        set_quantize_attrs_auto(true);
2349        let batch = decode_layer(&encode_layer(&make()).unwrap()).unwrap();
2350        set_quantize_attrs_auto(false); // reset for other tests
2351
2352        let field = batch.schema().field_with_name("depth").unwrap().clone();
2353        assert_eq!(field.data_type(), &DataType::UInt16);
2354        let aff = AttrQuant::from_json(field.metadata().get(STT_QUANT_ATTR_META_KEY).unwrap()).unwrap();
2355        let col = batch.column_by_name("depth").unwrap().as_any().downcast_ref::<UInt16Array>().unwrap();
2356        let tol = (700.0 - 0.0) / u16::MAX as f64 / 2.0 + 1e-9;
2357        for (i, want) in depth.iter().enumerate() {
2358            let got = aff.value(col.value(i) as i64);
2359            assert!((got - want.unwrap()).abs() <= tol, "depth[{i}] {got} vs {want:?}");
2360        }
2361        // Min and max land on the index endpoints (full 16-bit span used).
2362        assert_eq!(col.value(0), 0);
2363        assert_eq!(col.value(3), u16::MAX);
2364    }
2365
2366    #[test]
2367    fn quantization_shrinks_geometry_and_is_opt_in() {
2368        // A many-vertex line is coordinate-dominated; quantization should shrink
2369        // the IPC, and the default (None) path must stay byte-identical.
2370        let line: Vec<[f64; 2]> = (0..400)
2371            .map(|k| [-73.95 + k as f64 * 1e-4, 40.75 + k as f64 * 7e-5])
2372            .collect();
2373        let layer = ColumnarLayer {
2374            name: "q".into(),
2375            feature_ids: vec![1],
2376            start_times: vec![0],
2377            end_times: vec![1],
2378            geometry: GeometryColumn::LineString(vec![line]),
2379            vertex_times: None,
2380            vertex_values: None,
2381            triangles: None,
2382            vertex_value_matrix: None,
2383            properties: vec![],
2384        };
2385        let plain = encode_layer_quantized(&layer, None).unwrap();
2386        let quant = encode_layer_quantized(&layer, Some(1.0)).unwrap();
2387
2388        // None keeps the Float64 GeoArrow leaf and carries no affine.
2389        let pb = decode_layer(&plain).unwrap();
2390        let pf = pb.schema().field_with_name("geometry").unwrap().clone();
2391        assert!(pf.metadata().get(STT_QUANT_META_KEY).is_none());
2392
2393        // Some(_) switches the leaf to Int32 and emits the affine.
2394        let qb = decode_layer(&quant).unwrap();
2395        let qf = qb.schema().field_with_name("geometry").unwrap().clone();
2396        assert!(qf.metadata().get(STT_QUANT_META_KEY).is_some());
2397
2398        // i32 coords (4 B) replace f64 (8 B) for a coordinate-dominated layer.
2399        assert!(
2400            quant.len() < plain.len(),
2401            "quantized {} should be smaller than f64 {}",
2402            quant.len(),
2403            plain.len()
2404        );
2405    }
2406
2407    #[test]
2408    fn line_layer_roundtrips_with_vertex_times() {
2409        let layer = sample_line_layer();
2410        let ipc = encode_layer(&layer).unwrap();
2411        let batch = decode_layer(&ipc).unwrap();
2412
2413        assert_eq!(batch.num_rows(), 2);
2414        let geom = batch
2415            .column_by_name("geometry")
2416            .unwrap()
2417            .as_any()
2418            .downcast_ref::<ListArray>()
2419            .unwrap();
2420        // Feature 0 has 3 vertices, feature 1 has 2.
2421        assert_eq!(geom.value(0).len(), 3);
2422        assert_eq!(geom.value(1).len(), 2);
2423
2424        // v3 layers with a tight temporal span carry u16-delta vertex times
2425        // and the origin/step metadata needed to reconstruct absolutes.
2426        let meta = batch.schema().metadata().clone();
2427        let origin: i64 = meta
2428            .get("stt:vertex_time_origin_ms")
2429            .expect("u16 vertex-time layers carry an origin")
2430            .parse()
2431            .unwrap();
2432        let step: u32 = meta
2433            .get("stt:vertex_time_step_ms")
2434            .expect("u16 vertex-time layers carry a step")
2435            .parse()
2436            .unwrap();
2437        assert_eq!(origin, 0);
2438        assert_eq!(step, 1);
2439
2440        let vt = batch
2441            .column_by_name("vertex_time")
2442            .unwrap()
2443            .as_any()
2444            .downcast_ref::<ListArray>()
2445            .unwrap();
2446        assert_eq!(vt.len(), 2);
2447        let first = vt.value(0);
2448        let deltas = first.as_any().downcast_ref::<arrow::array::UInt16Array>().unwrap();
2449        let absolutes: Vec<i64> = deltas
2450            .values()
2451            .iter()
2452            .map(|d| origin + (*d as i64) * step as i64)
2453            .collect();
2454        assert_eq!(absolutes, vec![0, 25, 50]);
2455    }
2456
2457    #[test]
2458    fn line_layer_roundtrips_with_vertex_values() {
2459        // Per-vertex scalars (e.g. SST) ride a nullable List<Float32> aligned
2460        // with the geometry vertices. A NaN entry marks a vertex with no value.
2461        let layer = ColumnarLayer {
2462            name: "drift".into(),
2463            feature_ids: vec![1, 2],
2464            start_times: vec![0, 0],
2465            end_times: vec![100, 100],
2466            geometry: GeometryColumn::LineString(vec![
2467                vec![[0.0, 0.0], [1.0, 1.0], [2.0, 2.0]],
2468                vec![[3.0, 3.0], [4.0, 4.0]],
2469            ]),
2470            vertex_times: None,
2471            vertex_values: Some(vec![vec![5.0, f32::NAN, 27.5], vec![12.0, 13.0]]),
2472            triangles: None,
2473            vertex_value_matrix: None,
2474            properties: vec![],
2475        };
2476        let ipc = encode_layer(&layer).unwrap();
2477        let batch = decode_layer(&ipc).unwrap();
2478
2479        let vv = batch
2480            .column_by_name("vertex_value")
2481            .expect("layers with per-vertex values carry a vertex_value column")
2482            .as_any()
2483            .downcast_ref::<ListArray>()
2484            .unwrap();
2485        assert_eq!(vv.len(), 2);
2486        let first = vv.value(0);
2487        let vals = first.as_any().downcast_ref::<arrow::array::Float32Array>().unwrap();
2488        assert_eq!(vals.value(0), 5.0);
2489        assert!(vals.value(1).is_nan());
2490        assert_eq!(vals.value(2), 27.5);
2491        let second = vv.value(1);
2492        let vals2 = second.as_any().downcast_ref::<arrow::array::Float32Array>().unwrap();
2493        assert_eq!(vals2.values(), &[12.0, 13.0]);
2494    }
2495
2496    #[test]
2497    fn line_layer_roundtrips_with_vertex_value_matrix() {
2498        // Static-geometry overview: per-vertex × per-bucket value matrix rides a
2499        // nullable List<Float32>, flattened vertex-major (vertex 0's buckets,
2500        // then vertex 1's, ...). num_buckets is recorded in schema metadata.
2501        // Feature 0: 3 vertices × 2 buckets; feature 1: 2 vertices × 2 buckets.
2502        let layer = ColumnarLayer {
2503            name: "flows".into(),
2504            feature_ids: vec![1, 2],
2505            start_times: vec![0, 0],
2506            end_times: vec![1800, 1800],
2507            geometry: GeometryColumn::LineString(vec![
2508                vec![[0.0, 0.0], [1.0, 1.0], [2.0, 2.0]],
2509                vec![[3.0, 3.0], [4.0, 4.0]],
2510            ]),
2511            vertex_times: None,
2512            vertex_values: None,
2513            triangles: None,
2514            // vertex-major: [v0b0, v0b1, v1b0, v1b1, v2b0, v2b1]
2515            vertex_value_matrix: Some(vec![
2516                vec![10.0, 11.0, 20.0, 21.0, 30.0, 31.0],
2517                vec![40.0, 41.0, 50.0, 51.0],
2518            ]),
2519            properties: vec![],
2520        };
2521        let ipc = encode_layer(&layer).unwrap();
2522        let batch = decode_layer(&ipc).unwrap();
2523
2524        let vm = batch
2525            .column_by_name("vertex_value_matrix")
2526            .expect("matrix layers carry a vertex_value_matrix column")
2527            .as_any()
2528            .downcast_ref::<ListArray>()
2529            .unwrap();
2530        assert_eq!(vm.len(), 2);
2531        let f0 = vm.value(0);
2532        let f0v = f0.as_any().downcast_ref::<arrow::array::Float32Array>().unwrap();
2533        assert_eq!(f0v.values(), &[10.0, 11.0, 20.0, 21.0, 30.0, 31.0]);
2534        let f1 = vm.value(1);
2535        let f1v = f1.as_any().downcast_ref::<arrow::array::Float32Array>().unwrap();
2536        assert_eq!(f1v.values(), &[40.0, 41.0, 50.0, 51.0]);
2537
2538        // num_buckets = matrix_len / vertex_count = 6 / 3 = 2, in schema meta.
2539        assert_eq!(
2540            batch.schema().metadata().get("stt:vertex_value_buckets"),
2541            Some(&"2".to_string())
2542        );
2543    }
2544
2545    #[test]
2546    fn vertex_time_falls_back_to_int64_for_wide_spans() {
2547        // span = 100 billion ms; the u16 encoding would need step ≈ 1.5e6 ms,
2548        // far beyond the DEFAULT_VERTEX_TIME_MAX_STEP_MS ceiling — so the
2549        // encoder must take the exact List<Int64> path, byte-for-byte
2550        // absolute timestamps, with no origin/step metadata.
2551        let layer = ColumnarLayer {
2552            name: "edge".into(),
2553            feature_ids: vec![1],
2554            start_times: vec![0],
2555            end_times: vec![100],
2556            geometry: GeometryColumn::LineString(vec![vec![[0.0, 0.0], [1.0, 1.0]]]),
2557            vertex_times: Some(vec![vec![0, 100_000_000_000]]),
2558            vertex_values: None,
2559            triangles: None,
2560            vertex_value_matrix: None,
2561            properties: vec![],
2562        };
2563        let ipc = encode_layer(&layer).unwrap();
2564        let batch = decode_layer(&ipc).unwrap();
2565        let schema = batch.schema();
2566        let meta = schema.metadata();
2567        assert!(meta.get("stt:vertex_time_origin_ms").is_none());
2568        assert!(meta.get("stt:vertex_time_step_ms").is_none());
2569        let vt = batch
2570            .column_by_name("vertex_time")
2571            .unwrap()
2572            .as_any()
2573            .downcast_ref::<ListArray>()
2574            .unwrap();
2575        let first = vt.value(0);
2576        let absolutes = first
2577            .as_any()
2578            .downcast_ref::<Int64Array>()
2579            .expect("wide spans must keep the exact Int64 shape");
2580        assert_eq!(absolutes.values(), &[0, 100_000_000_000]);
2581    }
2582
2583    #[test]
2584    fn vertex_time_step_ceiling_is_the_u16_vs_int64_threshold() {
2585        // span = 65_535_000 ms quantizes at exactly the 1000 ms default
2586        // ceiling → u16 deltas; one ms more pushes the step to 1001 → i64.
2587        let make = |span: i64| ColumnarLayer {
2588            name: "edge".into(),
2589            feature_ids: vec![1],
2590            start_times: vec![0],
2591            end_times: vec![100],
2592            geometry: GeometryColumn::LineString(vec![vec![[0.0, 0.0], [1.0, 1.0]]]),
2593            vertex_times: Some(vec![vec![0, span]]),
2594            vertex_values: None,
2595            triangles: None,
2596            vertex_value_matrix: None,
2597            properties: vec![],
2598        };
2599
2600        let at_ceiling = decode_layer(&encode_layer(&make(65_535_000)).unwrap()).unwrap();
2601        let schema = at_ceiling.schema();
2602        let step: u32 = schema
2603            .metadata()
2604            .get("stt:vertex_time_step_ms")
2605            .expect("span at the ceiling stays u16-delta encoded")
2606            .parse()
2607            .unwrap();
2608        assert_eq!(step, DEFAULT_VERTEX_TIME_MAX_STEP_MS);
2609
2610        let past_ceiling = decode_layer(&encode_layer(&make(65_536_000)).unwrap()).unwrap();
2611        assert!(past_ceiling
2612            .schema()
2613            .metadata()
2614            .get("stt:vertex_time_step_ms")
2615            .is_none());
2616        let vt = past_ceiling
2617            .column_by_name("vertex_time")
2618            .unwrap()
2619            .as_any()
2620            .downcast_ref::<ListArray>()
2621            .unwrap();
2622        let first = vt.value(0);
2623        let absolutes = first.as_any().downcast_ref::<Int64Array>().unwrap();
2624        assert_eq!(absolutes.values(), &[0, 65_536_000]);
2625    }
2626
2627    #[test]
2628    fn polygon_layer_roundtrips_with_rings() {
2629        let layer = sample_polygon_layer();
2630        let ipc = encode_layer(&layer).unwrap();
2631        let batch = decode_layer(&ipc).unwrap();
2632
2633        let geom = batch
2634            .column_by_name("geometry")
2635            .unwrap()
2636            .as_any()
2637            .downcast_ref::<ListArray>()
2638            .unwrap();
2639        assert_eq!(geom.len(), 1);
2640        // One feature with two rings (exterior + hole).
2641        let rings = geom.value(0);
2642        let rings = rings.as_any().downcast_ref::<ListArray>().unwrap();
2643        assert_eq!(rings.len(), 2);
2644        assert_eq!(rings.value(0).len(), 5); // exterior ring vertices
2645        assert_eq!(rings.value(1).len(), 5); // hole vertices
2646    }
2647
2648    #[test]
2649    fn multi_layer_tile_frame_roundtrips() {
2650        let layers = vec![sample_line_layer(), sample_point_layer()];
2651        let payload = encode_tile(&layers).unwrap();
2652        let decoded = decode_tile(&payload).unwrap();
2653
2654        assert_eq!(decoded.len(), 2);
2655        assert_eq!(decoded[0].name, "tracks");
2656        assert_eq!(decoded[1].name, "points");
2657        assert_eq!(decoded[0].batch.num_rows(), 2);
2658        assert_eq!(decoded[1].batch.num_rows(), 3);
2659        // Schema metadata records the layer name on the batch too.
2660        assert_eq!(
2661            decoded[1]
2662                .batch
2663                .schema()
2664                .metadata()
2665                .get("stt:layer")
2666                .map(String::as_str),
2667            Some("points")
2668        );
2669    }
2670
2671    #[test]
2672    fn tessellate_polygon_emits_two_triangles_for_a_square() {
2673        // A simple closed square (5 verts, last duplicates first) earcuts into
2674        // exactly 2 triangles, 6 indices in [0, 3].
2675        let ring: Vec<Coord> = vec![
2676            [0.0, 0.0],
2677            [1.0, 0.0],
2678            [1.0, 1.0],
2679            [0.0, 1.0],
2680            [0.0, 0.0],
2681        ];
2682        let tris = tessellate_polygon(&[ring]);
2683        assert_eq!(tris.len(), 6);
2684        for &i in &tris {
2685            assert!(i < 5);
2686        }
2687    }
2688
2689    #[test]
2690    fn tessellate_polygon_handles_a_hole() {
2691        // 4x4 square with a 1x1 hole — earcut should still produce a valid
2692        // tessellation. Index count is implementation-dependent but must be a
2693        // multiple of 3 and reference valid vertex indices.
2694        let exterior: Vec<Coord> =
2695            vec![[0.0, 0.0], [4.0, 0.0], [4.0, 4.0], [0.0, 4.0], [0.0, 0.0]];
2696        let hole: Vec<Coord> =
2697            vec![[1.0, 1.0], [2.0, 1.0], [2.0, 2.0], [1.0, 2.0], [1.0, 1.0]];
2698        let tris = tessellate_polygon(&[exterior, hole]);
2699        assert!(tris.len() >= 6);
2700        assert_eq!(tris.len() % 3, 0);
2701        for &i in &tris {
2702            assert!(i < 10);
2703        }
2704    }
2705
2706    #[test]
2707    fn tessellate_polygon_handles_degenerate_input() {
2708        // No rings → empty result, not a panic.
2709        assert!(tessellate_polygon(&[]).is_empty());
2710        // Single 2-vert ring is below the 3-vertex minimum.
2711        let degenerate: Vec<Coord> = vec![[0.0, 0.0], [1.0, 1.0]];
2712        assert!(tessellate_polygon(&[degenerate]).is_empty());
2713    }
2714
2715    #[test]
2716    fn polygon_layer_with_triangles_roundtrips() {
2717        let exterior: Vec<Coord> =
2718            vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0], [0.0, 0.0]];
2719        let tris = tessellate_polygon(&[exterior.clone()]);
2720        assert_eq!(tris.len(), 6);
2721        let layer = ColumnarLayer {
2722            name: "zones".into(),
2723            feature_ids: vec![42],
2724            start_times: vec![0],
2725            end_times: vec![1000],
2726            geometry: GeometryColumn::Polygon(vec![vec![exterior]]),
2727            vertex_times: None,
2728            vertex_values: None,
2729            triangles: Some(vec![tris.clone()]),
2730            vertex_value_matrix: None,
2731            properties: vec![],
2732        };
2733        let ipc = encode_layer(&layer).unwrap();
2734        let batch = decode_layer(&ipc).unwrap();
2735
2736        // Schema metadata advertises the sidecar.
2737        assert_eq!(
2738            batch
2739                .schema()
2740                .metadata()
2741                .get(TRIANGLES_METADATA_KEY)
2742                .map(String::as_str),
2743            Some("true")
2744        );
2745        // Column exists with the expected shape. Indices here are tiny
2746        // (well under u16::MAX), so the narrower UInt16 encoding applies.
2747        let col = batch
2748            .column_by_name("triangles")
2749            .expect("triangles column present")
2750            .as_any()
2751            .downcast_ref::<ListArray>()
2752            .expect("triangles is a List");
2753        assert_eq!(col.len(), 1);
2754        let first = col.value(0);
2755        let values: &arrow::array::UInt16Array = first
2756            .as_any()
2757            .downcast_ref::<arrow::array::UInt16Array>()
2758            .expect("triangle values are UInt16 for small feature-local indices");
2759        assert_eq!(
2760            values.values().iter().map(|&v| v as u32).collect::<Vec<_>>(),
2761            tris
2762        );
2763    }
2764
2765    #[test]
2766    fn polygon_layer_with_oversized_triangle_index_falls_back_to_uint32() {
2767        // A feature-local triangle index beyond u16::MAX (pathological, but
2768        // possible for a single giant polygon) must fall back to UInt32
2769        // rather than silently truncating.
2770        let exterior: Vec<Coord> =
2771            vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0], [0.0, 0.0]];
2772        let big_tris = vec![0u32, 1, 70_000];
2773        let layer = ColumnarLayer {
2774            name: "zones".into(),
2775            feature_ids: vec![42],
2776            start_times: vec![0],
2777            end_times: vec![1000],
2778            geometry: GeometryColumn::Polygon(vec![vec![exterior]]),
2779            vertex_times: None,
2780            vertex_values: None,
2781            triangles: Some(vec![big_tris.clone()]),
2782            vertex_value_matrix: None,
2783            properties: vec![],
2784        };
2785        let ipc = encode_layer(&layer).unwrap();
2786        let batch = decode_layer(&ipc).unwrap();
2787
2788        let col = batch
2789            .column_by_name("triangles")
2790            .expect("triangles column present")
2791            .as_any()
2792            .downcast_ref::<ListArray>()
2793            .expect("triangles is a List");
2794        let first = col.value(0);
2795        let values: &arrow::array::UInt32Array = first
2796            .as_any()
2797            .downcast_ref::<arrow::array::UInt32Array>()
2798            .expect("triangle values fall back to UInt32 when an index exceeds u16::MAX");
2799        assert_eq!(values.values().to_vec(), big_tris);
2800    }
2801
2802    #[test]
2803    fn polygon_layer_without_triangles_skips_the_metadata_key() {
2804        // Backwards-compat guarantee: a v3 polygon layer that was NOT built
2805        // with pre-tessellation must not carry the metadata flag — otherwise
2806        // a reader would expect a column that isn't there.
2807        let layer = sample_polygon_layer();
2808        let ipc = encode_layer(&layer).unwrap();
2809        let batch = decode_layer(&ipc).unwrap();
2810        assert!(!batch.schema().metadata().contains_key(TRIANGLES_METADATA_KEY));
2811        assert!(batch.column_by_name("triangles").is_none());
2812    }
2813
2814    #[test]
2815    fn non_polygon_layer_drops_stray_triangles() {
2816        // A producer that mistakenly attaches `triangles` to a point or line
2817        // layer must not poison the wire format. The encoder silently drops
2818        // the column so the metadata key never appears.
2819        let mut layer = sample_point_layer();
2820        // Add a bogus per-feature triangle list. The encoder must ignore it.
2821        layer.triangles = Some(vec![vec![0, 1, 2]; layer.feature_ids.len()]);
2822        let ipc = encode_layer(&layer).unwrap();
2823        let batch = decode_layer(&ipc).unwrap();
2824        assert!(!batch.schema().metadata().contains_key(TRIANGLES_METADATA_KEY));
2825        assert!(batch.column_by_name("triangles").is_none());
2826    }
2827
2828    /// Walk a frame and return each layer's IPC start offset + length,
2829    /// honouring the aligned-frame padding rule.
2830    fn ipc_offsets(payload: &[u8]) -> Vec<(usize, usize)> {
2831        let raw = u16::from_le_bytes([payload[0], payload[1]]);
2832        let aligned = raw & ALIGNED_FRAME_FLAG != 0;
2833        let count = (raw & !ALIGNED_FRAME_FLAG) as usize;
2834        let mut pos = 2usize;
2835        let mut out = Vec::new();
2836        for _ in 0..count {
2837            let name_len =
2838                u16::from_le_bytes([payload[pos], payload[pos + 1]]) as usize;
2839            pos += 2 + name_len;
2840            let ipc_len = u32::from_le_bytes([
2841                payload[pos],
2842                payload[pos + 1],
2843                payload[pos + 2],
2844                payload[pos + 3],
2845            ]) as usize;
2846            pos += 4;
2847            if aligned {
2848                pos += (FRAME_ALIGN - pos % FRAME_ALIGN) % FRAME_ALIGN;
2849            }
2850            out.push((pos, ipc_len));
2851            pos += ipc_len;
2852        }
2853        out
2854    }
2855
2856    #[test]
2857    fn encoded_frames_align_every_ipc_stream_to_8_bytes() {
2858        // Layer names of varying lengths so the unpadded offsets would land
2859        // all over the place; the aligned frame must place every IPC stream
2860        // at an 8-byte boundary regardless.
2861        let mut a = sample_line_layer();
2862        a.name = "x".into();
2863        let mut b = sample_point_layer();
2864        b.name = "a-longer-layer-name".into();
2865        let payload = encode_tile(&[a, b]).unwrap();
2866
2867        let raw = u16::from_le_bytes([payload[0], payload[1]]);
2868        assert_ne!(raw & ALIGNED_FRAME_FLAG, 0, "writer must set the aligned flag");
2869
2870        let offsets = ipc_offsets(&payload);
2871        assert_eq!(offsets.len(), 2);
2872        for (off, _) in &offsets {
2873            assert_eq!(off % 8, 0, "IPC stream at offset {off} is misaligned");
2874        }
2875
2876        // And the padded frame still round-trips.
2877        let decoded = decode_tile(&payload).unwrap();
2878        assert_eq!(decoded[0].name, "x");
2879        assert_eq!(decoded[1].name, "a-longer-layer-name");
2880        assert_eq!(decoded[0].batch.num_rows(), 2);
2881        assert_eq!(decoded[1].batch.num_rows(), 3);
2882    }
2883
2884    #[test]
2885    fn legacy_unpadded_frames_still_decode() {
2886        // Rebuild an old-style frame (no flag, no padding) from the layers'
2887        // IPC bytes — the shape every pre-alignment archive carries — and
2888        // assert the decoder reproduces the aligned frame's batches.
2889        let layers = vec![sample_line_layer(), sample_point_layer()];
2890        let aligned_payload = encode_tile(&layers).unwrap();
2891        let aligned = decode_tile(&aligned_payload).unwrap();
2892
2893        let mut legacy: Vec<u8> = Vec::new();
2894        legacy.extend_from_slice(&(layers.len() as u16).to_le_bytes());
2895        for ((off, len), layer) in ipc_offsets(&aligned_payload).iter().zip(&layers) {
2896            let name = layer.name.as_bytes();
2897            legacy.extend_from_slice(&(name.len() as u16).to_le_bytes());
2898            legacy.extend_from_slice(name);
2899            legacy.extend_from_slice(&(*len as u32).to_le_bytes());
2900            legacy.extend_from_slice(&aligned_payload[*off..*off + *len]);
2901        }
2902
2903        let decoded = decode_tile(&legacy).unwrap();
2904        assert_eq!(decoded.len(), aligned.len());
2905        for (l, a) in decoded.iter().zip(&aligned) {
2906            assert_eq!(l.name, a.name);
2907            assert_eq!(l.batch, a.batch);
2908        }
2909    }
2910
2911    #[test]
2912    fn truncated_tile_frame_errors_cleanly() {
2913        let payload = encode_tile(&[sample_point_layer()]).unwrap();
2914        // Chop the payload mid-stream; decode must error, not panic.
2915        let truncated = &payload[..payload.len() / 2];
2916        assert!(decode_tile(truncated).is_err());
2917    }
2918
2919    #[test]
2920    fn length_mismatch_is_rejected() {
2921        let mut layer = sample_point_layer();
2922        layer.start_times.pop(); // now 2 entries vs 3 features
2923        assert!(encode_layer(&layer).is_err());
2924    }
2925}