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    // ≥59 serializes IPC schema metadata in sorted order, so building from a
1322    // sorted source makes the raw metadata-region bytes byte-reproducible across
1323    // runs (guarded by `same_tile_encodes_byte_identically` in
1324    // reproducible_build.rs) — closing the old arrow-54 HashMap-iteration gap.
1325    let mut geom_meta = BTreeMap::new();
1326    geom_meta.insert(
1327        GEOARROW_EXT_KEY.to_string(),
1328        layer.geometry.geoarrow_name().to_string(),
1329    );
1330    match &quant {
1331        // A quantized tile's `xy` leaf is i32 grid indices, not Float64 lon/lat,
1332        // so the GeoArrow CRS doesn't apply — swap it for the reconstruction
1333        // affine (whose presence is the reader's quantization signal). Field
1334        // metadata is assembled in a BTreeMap (deterministic key order); Arrow
1335        // ≥59 serializes IPC schema metadata in sorted order, so the raw
1336        // metadata-region bytes are byte-reproducible across runs (guarded by
1337        // the `same_tile_encodes_byte_identically` test in reproducible_build.rs).
1338        Some(q) => {
1339            geom_meta.insert(STT_QUANT_META_KEY.to_string(), q.to_json());
1340        }
1341        // Advertise the CRS so the tile is self-describing to GeoArrow consumers.
1342        None => {
1343            geom_meta.insert(
1344                GEOARROW_EXT_META_KEY.to_string(),
1345                GEOARROW_CRS_METADATA.to_string(),
1346            );
1347        }
1348    }
1349    fields.push(Arc::new(
1350        Field::new("geometry", geom_array.data_type().clone(), false)
1351            .with_metadata(geom_meta.into_iter().collect()),
1352    ));
1353    columns.push(geom_array);
1354
1355    // Track per-layer vertex-time encoding so the schema metadata (set
1356    // below) records the origin/step needed for the u16-delta reader path.
1357    let mut vertex_time_encoding: Option<(i64, u32)> = None;
1358    if let Some(vt_col) = build_vertex_time_array(&layer.vertex_times, n, cfg.vertex_time_max_step_ms) {
1359        fields.push(Arc::new(Field::new(
1360            "vertex_time",
1361            vt_col.array.data_type().clone(),
1362            true,
1363        )));
1364        columns.push(vt_col.array);
1365        vertex_time_encoding = vt_col.encoding;
1366    }
1367
1368    // Optional per-vertex scalar column (e.g. sea-surface temperature),
1369    // aligned 1:1 with the geometry vertices like `vertex_time`.
1370    if let Some(vv_array) = build_vertex_value_array(&layer.vertex_values, n) {
1371        fields.push(Arc::new(Field::new(
1372            "vertex_value",
1373            vv_array.data_type().clone(),
1374            true,
1375        )));
1376        columns.push(vv_array);
1377    }
1378
1379    // Optional per-vertex × per-bucket value matrix (static-geometry overview
1380    // animation). Reuses the `vertex_value` List<Float32> encoding — each row
1381    // is just longer (vertex_count * num_buckets, vertex-major). num_buckets is
1382    // recovered from the per-feature vertex count and recorded in schema meta.
1383    let mut vertex_value_buckets: Option<u32> = None;
1384    if let Some(vm_array) = build_vertex_value_array(&layer.vertex_value_matrix, n) {
1385        fields.push(Arc::new(Field::new(
1386            "vertex_value_matrix",
1387            vm_array.data_type().clone(),
1388            true,
1389        )));
1390        columns.push(vm_array);
1391        vertex_value_buckets = layer
1392            .vertex_value_matrix
1393            .as_ref()
1394            .and_then(|vm| infer_vertex_value_buckets(vm, &layer.geometry));
1395    }
1396
1397    // Pre-baked triangle indices (MLT-style). Only emitted for polygon
1398    // layers; for any other geometry kind the column is silently dropped so
1399    // an over-eager builder can't poison a point/line layer with stale data.
1400    let has_triangles = matches!(layer.geometry, GeometryColumn::Polygon(_))
1401        && layer
1402            .triangles
1403            .as_ref()
1404            .map(|t| t.iter().any(|f| !f.is_empty()))
1405            .unwrap_or(false);
1406    if has_triangles {
1407        let tri = layer.triangles.as_ref().unwrap();
1408        // Indices are feature-LOCAL (see the field doc above), so they're
1409        // almost always well under 65,536 even for large layers. Mirrors
1410        // build_vertex_time_array's width-selection: scan once, use the
1411        // narrower UInt16 (half the bytes) when every index fits, UInt32
1412        // otherwise. The Arrow field type is derived from the array below,
1413        // so this is fully self-describing — the TS decoder branches on the
1414        // runtime child-array type exactly like it already does for
1415        // vertex_time's UInt16-delta vs Int64-absolute split.
1416        let max_index = tri.iter().flatten().copied().max().unwrap_or(0);
1417        let array: ArrayRef = if max_index <= u16::MAX as u32 {
1418            let mut builder = ListBuilder::new(UInt16Builder::new());
1419            for feature in tri {
1420                for &idx in feature {
1421                    builder.values().append_value(idx as u16);
1422                }
1423                // Always append a (possibly empty) list — readers expect one
1424                // entry per feature.
1425                builder.append(true);
1426            }
1427            Arc::new(builder.finish())
1428        } else {
1429            let mut builder = ListBuilder::new(UInt32Builder::new());
1430            for feature in tri {
1431                for &idx in feature {
1432                    builder.values().append_value(idx);
1433                }
1434                builder.append(true);
1435            }
1436            Arc::new(builder.finish())
1437        };
1438        fields.push(Arc::new(Field::new(
1439            "triangles",
1440            array.data_type().clone(),
1441            false,
1442        )));
1443        columns.push(array);
1444    }
1445
1446    // Fuse configured scalar columns into GPU-ready interleaved Vector columns
1447    // (e.g. qx/qy/qz/qw → one FixedSizeList<f32,4>). Runs BEFORE the quantize
1448    // loop so grouped components are written as the raw vector, not individually
1449    // quantized. No groups configured ⇒ iterate `layer.properties` with no clone.
1450    let grouped =
1451        group_vector_properties(&layer.properties, layer.feature_count(), &cfg.vector_groups);
1452    let props_iter: &[(String, PropertyColumn)] =
1453        grouped.as_deref().unwrap_or(&layer.properties);
1454    for (name, col) in props_iter {
1455        // The point-elevation column now lives in the geometry's 3rd coordinate;
1456        // don't also emit it as a scalar property.
1457        if elev_consumed && name == &elev_col {
1458            continue;
1459        }
1460        match col {
1461            PropertyColumn::Numeric(values) => {
1462                // Opt-in: a numeric property named in the build-global
1463                // quantization map ships as fixed-point ints + a per-column
1464                // affine in field metadata (the reader reconstructs Float64).
1465                // For a LiDAR `z` column this is the single largest size lever
1466                // after `id` — a raw Float64 elevation barely compresses, while
1467                // the i16 grid is both smaller and far more compressible.
1468                let quantized = cfg
1469                    .quantize_attrs
1470                    .get(name)
1471                    .copied()
1472                    .filter(|p| *p > 0.0)
1473                    .and_then(|p| build_quantized_numeric(values, p))
1474                    .or_else(|| {
1475                        // No explicit precision — fall back to the configured
1476                        // automatic range-adaptive quantization when enabled.
1477                        cfg.quantize_attrs_auto
1478                            .then(|| build_quantized_numeric_auto(values))
1479                            .flatten()
1480                    });
1481                match quantized {
1482                    Some((array, affine_json)) => {
1483                        let mut m = HashMap::new();
1484                        m.insert(STT_QUANT_ATTR_META_KEY.to_string(), affine_json);
1485                        fields.push(Arc::new(
1486                            Field::new(name, array.data_type().clone(), true).with_metadata(m),
1487                        ));
1488                        columns.push(array);
1489                    }
1490                    None => {
1491                        fields.push(Arc::new(Field::new(name, DataType::Float64, true)));
1492                        columns.push(Arc::new(Float64Array::from(values.clone())));
1493                    }
1494                }
1495            }
1496            PropertyColumn::Categorical(values) => {
1497                // Build a Dictionary<UInt16, Utf8>: deduplicate strings once
1498                // here so the TS reader can lift the dictionary table out of
1499                // the Arrow batch directly instead of rebuilding it per tile.
1500                let (indices, categories) = build_dictionary_indices(values)?;
1501                let key_type = DataType::UInt16;
1502                let value_type = DataType::Utf8;
1503                let dict_type = DataType::Dictionary(Box::new(key_type), Box::new(value_type));
1504                fields.push(Arc::new(Field::new(name, dict_type, true)));
1505
1506                let value_array: ArrayRef = Arc::new(StringArray::from(
1507                    categories.iter().map(|s| Some(s.as_str())).collect::<Vec<_>>(),
1508                ));
1509                let key_array = UInt16Array::from(indices);
1510                let dict = DictionaryArray::<UInt16Type>::try_new(key_array, value_array)
1511                    .map_err(|e| Error::Other(format!("dictionary build failed: {e}")))?;
1512                columns.push(Arc::new(dict));
1513            }
1514            PropertyColumn::Vector { width, elem, values } => {
1515                // Interleaved GPU-ready vector → FixedSizeList<leaf, width>. The
1516                // child buffer is the flattened row-major run, so the TS decoder
1517                // hands `child.values.subarray(...)` straight to deck.gl with no
1518                // per-point re-interleave. Non-null leaf (producer encodes a
1519                // missing feature as a zero/identity vector).
1520                let (child, child_dt): (ArrayRef, DataType) = match elem {
1521                    VectorElem::F32 => (
1522                        Arc::new(Float32Array::from(values.clone())),
1523                        DataType::Float32,
1524                    ),
1525                    VectorElem::U8 => {
1526                        let bytes: Vec<u8> = values
1527                            .iter()
1528                            .map(|v| v.round().clamp(0.0, 255.0) as u8)
1529                            .collect();
1530                        (Arc::new(UInt8Array::from(bytes)), DataType::UInt8)
1531                    }
1532                };
1533                let item_field = Arc::new(Field::new("item", child_dt, false));
1534                let fsl = FixedSizeListArray::new(item_field, *width as i32, child, None);
1535                fields.push(Arc::new(Field::new(
1536                    name,
1537                    fsl.data_type().clone(),
1538                    true,
1539                )));
1540                columns.push(Arc::new(fsl));
1541            }
1542        }
1543    }
1544
1545    // Schema-level metadata records the layer name and geometry kind so a
1546    // reader does not have to inspect the geometry column. When the
1547    // vertex_time column is u16-delta encoded we add `origin_ms` and
1548    // `step_ms` so the reader can reconstruct absolute timestamps as
1549    // `origin + delta * step`.
1550    //
1551    // Built in a BTreeMap so the key set is assembled in deterministic
1552    // (lexicographic) order — this encoder contributes no ordering
1553    // non-determinism. Arrow ≥59 serializes IPC schema metadata in sorted order,
1554    // so the raw metadata-region bytes of two identical tiles are now identical
1555    // across runs (unblocking content-addressed pack dedup) — closing the old
1556    // arrow-54 HashMap-iteration gap.
1557    let mut schema_meta: BTreeMap<String, String> = BTreeMap::new();
1558    schema_meta.insert("stt:layer".to_string(), layer.name.clone());
1559    schema_meta.insert(
1560        "stt:geometry".to_string(),
1561        layer.geometry.geoarrow_name().to_string(),
1562    );
1563    // Bake the layer's minimum feature start-time (integer Unix ms) so the TS
1564    // decoder can skip its client-side min-scan over the whole start-time column
1565    // and relativize times against this value directly. Mirrors exactly what the
1566    // decoder computes (the min of the `start_time` column); only emitted when a
1567    // start-time column is present. See packages/core/src/tile.ts.
1568    if let Some(min_start) = layer.start_times.iter().copied().min() {
1569        schema_meta.insert(TIME_OFFSET_MS_KEY.to_string(), min_start.to_string());
1570    }
1571    if let Some((origin, step)) = vertex_time_encoding {
1572        schema_meta.insert(VERTEX_TIME_ORIGIN_KEY.to_string(), origin.to_string());
1573        schema_meta.insert(VERTEX_TIME_STEP_KEY.to_string(), step.to_string());
1574    }
1575    if let Some(buckets) = vertex_value_buckets {
1576        schema_meta.insert(VERTEX_VALUE_BUCKETS_KEY.to_string(), buckets.to_string());
1577    }
1578    if has_triangles {
1579        schema_meta.insert(TRIANGLES_METADATA_KEY.to_string(), "true".to_string());
1580    }
1581    let schema = Arc::new(Schema::new(fields).with_metadata(schema_meta.into_iter().collect()));
1582
1583    let batch = RecordBatch::try_new(schema.clone(), columns)
1584        .map_err(|e| Error::Other(format!("failed to build tile RecordBatch: {e}")))?;
1585
1586    let mut buf = Vec::new();
1587    {
1588        let mut writer = StreamWriter::try_new(&mut buf, &schema)
1589            .map_err(|e| Error::Other(format!("Arrow IPC writer init failed: {e}")))?;
1590        writer
1591            .write(&batch)
1592            .map_err(|e| Error::Other(format!("Arrow IPC write failed: {e}")))?;
1593        writer
1594            .finish()
1595            .map_err(|e| Error::Other(format!("Arrow IPC finish failed: {e}")))?;
1596    }
1597    Ok(buf)
1598}
1599
1600/// Encode a full tile payload (one or more layers) with the layer frame.
1601///
1602/// Always emits the *aligned* frame ([`ALIGNED_FRAME_FLAG`] set): each
1603/// layer's IPC stream is preceded by zero padding to an 8-byte boundary
1604/// relative to the payload start, so readers can wrap the stream zero-copy.
1605/// `ipc_len` records the exact IPC byte length (padding excluded); readers
1606/// derive the pad from alignment math alone.
1607pub fn encode_tile(layers: &[ColumnarLayer]) -> Result<Vec<u8>> {
1608    encode_tile_cfg(layers, &EncoderConfig::from_globals())
1609}
1610
1611/// [`encode_tile`] with optional fixed-point coordinate quantization applied to
1612/// every layer (see [`encode_layer_quantized`]). `quantize_m = None` is
1613/// byte-identical to [`encode_tile`]; the other encoder settings come from the
1614/// process-wide globals.
1615pub fn encode_tile_quantized(layers: &[ColumnarLayer], quantize_m: Option<f64>) -> Result<Vec<u8>> {
1616    encode_tile_cfg(
1617        layers,
1618        &EncoderConfig {
1619            quantize_coords_m: quantize_m,
1620            ..EncoderConfig::from_globals()
1621        },
1622    )
1623}
1624
1625/// [`encode_tile`] with a fully-explicit [`EncoderConfig`] — no process-wide
1626/// globals are read. The concurrency- and multi-config-safe entry point a
1627/// dynamic per-request tile server uses so each dataset/request encodes with its
1628/// own settings without touching shared state.
1629pub fn encode_tile_with(layers: &[ColumnarLayer], cfg: &EncoderConfig) -> Result<Vec<u8>> {
1630    encode_tile_cfg(layers, cfg)
1631}
1632
1633/// The single tile-encode implementation, driven entirely by an explicit
1634/// [`EncoderConfig`]. Every public `encode_tile*` entry point funnels here.
1635fn encode_tile_cfg(layers: &[ColumnarLayer], cfg: &EncoderConfig) -> Result<Vec<u8>> {
1636    if layers.len() >= ALIGNED_FRAME_FLAG as usize {
1637        return Err(Error::Other(format!(
1638            "tile has {} layers, exceeds the {} frame limit",
1639            layers.len(),
1640            ALIGNED_FRAME_FLAG - 1
1641        )));
1642    }
1643    let mut out = Vec::new();
1644    out.extend_from_slice(&(layers.len() as u16 | ALIGNED_FRAME_FLAG).to_le_bytes());
1645    for layer in layers {
1646        let name = layer.name.as_bytes();
1647        if name.len() > u16::MAX as usize {
1648            return Err(Error::Other("layer name too long".into()));
1649        }
1650        let ipc = encode_layer_cfg(layer, cfg)?;
1651        out.extend_from_slice(&(name.len() as u16).to_le_bytes());
1652        out.extend_from_slice(name);
1653        out.extend_from_slice(&(ipc.len() as u32).to_le_bytes());
1654        let pad = (FRAME_ALIGN - out.len() % FRAME_ALIGN) % FRAME_ALIGN;
1655        out.extend_from_slice(&[0u8; FRAME_ALIGN][..pad]);
1656        out.extend_from_slice(&ipc);
1657    }
1658    Ok(out)
1659}
1660
1661// ----------------------------------------------------------------------------
1662// Decoding
1663// ----------------------------------------------------------------------------
1664
1665/// A decoded tile layer: its name and the raw Arrow [`RecordBatch`].
1666#[derive(Debug, Clone)]
1667pub struct DecodedLayer {
1668    /// Layer name from the layer frame.
1669    pub name: String,
1670    /// The decoded Arrow record batch.
1671    pub batch: RecordBatch,
1672}
1673
1674/// Decode a single-layer Arrow IPC stream into a [`RecordBatch`].
1675pub fn decode_layer(ipc: &[u8]) -> Result<RecordBatch> {
1676    let reader = StreamReader::try_new(ipc, None)
1677        .map_err(|e| Error::Other(format!("Arrow IPC reader init failed: {e}")))?;
1678    let mut batches: Vec<RecordBatch> = Vec::new();
1679    for batch in reader {
1680        batches.push(batch.map_err(|e| Error::Other(format!("Arrow IPC read failed: {e}")))?);
1681    }
1682    match batches.len() {
1683        0 => Err(Error::Other("tile layer IPC contained no record batch".into())),
1684        1 => Ok(batches.into_iter().next().unwrap()),
1685        // A layer is written as exactly one batch; concatenating is the safe
1686        // fallback if a producer ever splits it.
1687        _ => arrow::compute::concat_batches(&batches[0].schema(), &batches)
1688            .map_err(|e| Error::Other(format!("failed to concat tile batches: {e}"))),
1689    }
1690}
1691
1692/// Decode a full tile payload (the layer frame) into its layers.
1693///
1694/// Accepts both frame shapes: the aligned frame ([`ALIGNED_FRAME_FLAG`] set,
1695/// with derived padding before each IPC stream) and the legacy unpadded
1696/// frame written by every archive that predates the flag.
1697pub fn decode_tile(payload: &[u8]) -> Result<Vec<DecodedLayer>> {
1698    if payload.len() < 2 {
1699        return Err(Error::Other("tile payload too short for layer frame".into()));
1700    }
1701    let raw_count = u16::from_le_bytes([payload[0], payload[1]]);
1702    let aligned = raw_count & ALIGNED_FRAME_FLAG != 0;
1703    let count = (raw_count & !ALIGNED_FRAME_FLAG) as usize;
1704    let mut pos = 2usize;
1705    let mut layers = Vec::with_capacity(count);
1706    for _ in 0..count {
1707        let name_len = read_u16(payload, &mut pos)? as usize;
1708        let name = read_slice(payload, &mut pos, name_len)?;
1709        let name = String::from_utf8(name.to_vec())
1710            .map_err(|e| Error::Other(format!("layer name not utf8: {e}")))?;
1711        let ipc_len = read_u32(payload, &mut pos)? as usize;
1712        if aligned {
1713            let pad = (FRAME_ALIGN - pos % FRAME_ALIGN) % FRAME_ALIGN;
1714            read_slice(payload, &mut pos, pad)?;
1715        }
1716        let ipc = read_slice(payload, &mut pos, ipc_len)?;
1717        let batch = decode_layer(ipc)?;
1718        layers.push(DecodedLayer { name, batch });
1719    }
1720    Ok(layers)
1721}
1722
1723fn read_u16(buf: &[u8], pos: &mut usize) -> Result<u16> {
1724    let s = read_slice(buf, pos, 2)?;
1725    Ok(u16::from_le_bytes([s[0], s[1]]))
1726}
1727
1728fn read_u32(buf: &[u8], pos: &mut usize) -> Result<u32> {
1729    let s = read_slice(buf, pos, 4)?;
1730    Ok(u32::from_le_bytes([s[0], s[1], s[2], s[3]]))
1731}
1732
1733fn read_slice<'a>(buf: &'a [u8], pos: &mut usize, len: usize) -> Result<&'a [u8]> {
1734    let end = pos
1735        .checked_add(len)
1736        .ok_or_else(|| Error::Other("tile frame length overflow".into()))?;
1737    if end > buf.len() {
1738        return Err(Error::Other("tile frame truncated".into()));
1739    }
1740    let s = &buf[*pos..end];
1741    *pos = end;
1742    Ok(s)
1743}
1744
1745#[cfg(test)]
1746mod tests {
1747    use super::*;
1748
1749    fn sample_point_layer() -> ColumnarLayer {
1750        ColumnarLayer {
1751            name: "points".to_string(),
1752            feature_ids: vec![1, 2, 3],
1753            start_times: vec![1000, 2000, 3000],
1754            end_times: vec![1500, 2500, 3500],
1755            geometry: GeometryColumn::Point(vec![
1756                [-122.4, 37.7],
1757                [-122.5, 37.8],
1758                [-122.6, 37.9],
1759            ]),
1760            vertex_times: None,
1761            vertex_values: None,
1762            triangles: None,
1763            vertex_value_matrix: None,
1764            properties: vec![
1765                (
1766                    "speed".to_string(),
1767                    PropertyColumn::Numeric(vec![Some(10.0), None, Some(30.0)]),
1768                ),
1769                (
1770                    "kind".to_string(),
1771                    PropertyColumn::Categorical(vec![
1772                        Some("car".to_string()),
1773                        Some("bus".to_string()),
1774                        None,
1775                    ]),
1776                ),
1777            ],
1778        }
1779    }
1780
1781    /// Two DIFFERENT [`EncoderConfig`]s encode the SAME layer to DIFFERENT tiles
1782    /// in ONE process, and the output is driven purely by the passed config — not
1783    /// by the process-wide globals. This is the property that unblocks a dynamic
1784    /// server hosting several datasets/configs concurrently: if `encode_tile_with`
1785    /// read the (unset) globals instead of the config, the quantized and plain
1786    /// encodes would be identical and this would fail.
1787    #[test]
1788    fn encode_tile_with_is_config_driven_not_global() {
1789        let layer = sample_point_layer();
1790        let layers = std::slice::from_ref(&layer);
1791
1792        let plain_cfg = EncoderConfig::default();
1793        let quant_cfg = EncoderConfig {
1794            quantize_coords_m: Some(1.0),
1795            ..EncoderConfig::default()
1796        };
1797        let attr_cfg = EncoderConfig {
1798            quantize_attrs_auto: true,
1799            ..EncoderConfig::default()
1800        };
1801
1802        let plain = encode_tile_with(layers, &plain_cfg).unwrap();
1803        let quant = encode_tile_with(layers, &quant_cfg).unwrap();
1804        let attr = encode_tile_with(layers, &attr_cfg).unwrap();
1805
1806        // Each explicit config yields a distinct tile — the config, not shared
1807        // state, decides the encoding. (If `encode_tile_with` read the unset
1808        // globals instead of the config, all three would be identical.) These
1809        // differences are config-driven at the WIRE COLUMN level — coord
1810        // quantization changes the geometry column (i32 grid vs Float64) and
1811        // attribute quantization changes the `speed` column (u16 vs Float64) — so
1812        // the inequality is not attributable to the encoder's (separately
1813        // tracked) non-deterministic Arrow-metadata ordering, which we therefore
1814        // deliberately do NOT byte-assert here.
1815        assert_ne!(plain, quant, "coord quantization must change the tile");
1816        assert_ne!(plain, attr, "attribute quantization must change the tile");
1817        assert_ne!(quant, attr, "the two quantizations differ from each other");
1818
1819        // All three still decode to the SAME feature set — encoding differs, data
1820        // does not.
1821        for tile in [&plain, &quant, &attr] {
1822            let rows: usize = decode_tile(tile).unwrap().iter().map(|l| l.batch.num_rows()).sum();
1823            assert_eq!(rows, 3);
1824        }
1825    }
1826
1827    fn sample_line_layer() -> ColumnarLayer {
1828        ColumnarLayer {
1829            name: "tracks".to_string(),
1830            feature_ids: vec![10, 11],
1831            start_times: vec![0, 100],
1832            end_times: vec![50, 200],
1833            geometry: GeometryColumn::LineString(vec![
1834                vec![[0.0, 0.0], [1.0, 1.0], [2.0, 2.0]],
1835                vec![[5.0, 5.0], [6.0, 6.0]],
1836            ]),
1837            vertex_times: Some(vec![vec![0, 25, 50], vec![100, 200]]),
1838            vertex_values: None,
1839            triangles: None,
1840            vertex_value_matrix: None,
1841            properties: vec![],
1842        }
1843    }
1844
1845    fn sample_polygon_layer() -> ColumnarLayer {
1846        ColumnarLayer {
1847            name: "zones".to_string(),
1848            feature_ids: vec![42],
1849            start_times: vec![0],
1850            end_times: vec![1000],
1851            geometry: GeometryColumn::Polygon(vec![vec![
1852                // exterior ring
1853                vec![[0.0, 0.0], [4.0, 0.0], [4.0, 4.0], [0.0, 4.0], [0.0, 0.0]],
1854                // hole
1855                vec![[1.0, 1.0], [2.0, 1.0], [2.0, 2.0], [1.0, 2.0], [1.0, 1.0]],
1856            ]]),
1857            vertex_times: None,
1858            vertex_values: None,
1859            triangles: None,
1860            vertex_value_matrix: None,
1861            properties: vec![],
1862        }
1863    }
1864
1865    #[test]
1866    fn categorical_columns_use_dictionary_encoding() {
1867        let layer = ColumnarLayer {
1868            name: "cars".into(),
1869            feature_ids: vec![1, 2, 3, 4, 5],
1870            start_times: vec![0; 5],
1871            end_times: vec![1; 5],
1872            geometry: GeometryColumn::Point(vec![[0.0, 0.0]; 5]),
1873            vertex_times: None,
1874            vertex_values: None,
1875            triangles: None,
1876            vertex_value_matrix: None,
1877            properties: vec![(
1878                "kind".into(),
1879                PropertyColumn::Categorical(vec![
1880                    Some("car".into()),
1881                    Some("bus".into()),
1882                    Some("car".into()),
1883                    None,
1884                    Some("car".into()),
1885                ]),
1886            )],
1887        };
1888        let ipc = encode_layer(&layer).unwrap();
1889        let batch = decode_layer(&ipc).unwrap();
1890        let field = batch.schema().field_with_name("kind").unwrap().clone();
1891        match field.data_type() {
1892            DataType::Dictionary(k, v) => {
1893                assert_eq!(k.as_ref(), &DataType::UInt16);
1894                assert_eq!(v.as_ref(), &DataType::Utf8);
1895            }
1896            other => panic!("expected Dictionary<UInt16, Utf8>, got {other:?}"),
1897        }
1898
1899        let col = batch
1900            .column_by_name("kind")
1901            .unwrap()
1902            .as_any()
1903            .downcast_ref::<DictionaryArray<UInt16Type>>()
1904            .unwrap();
1905        let values = col
1906            .values()
1907            .as_any()
1908            .downcast_ref::<StringArray>()
1909            .unwrap();
1910        // First-seen order: "car" then "bus".
1911        let mut categories: Vec<&str> = (0..values.len()).map(|i| values.value(i)).collect();
1912        categories.sort();
1913        assert_eq!(categories, vec!["bus", "car"]);
1914
1915        // The 4th row is null; others reference one of the two slots.
1916        assert!(col.is_null(3));
1917        let keys = col.keys();
1918        for i in [0usize, 1, 2, 4] {
1919            assert!(keys.value(i) < values.len() as u16);
1920        }
1921    }
1922
1923    #[test]
1924    fn categorical_overflow_errors_instead_of_corrupting() {
1925        // A column whose distinct-value count exceeds the UInt16 dictionary
1926        // key space must be rejected, not silently collapsed onto one index.
1927        let n = u16::MAX as usize + 1; // 65_536 distinct strings
1928        let kinds: Vec<Option<String>> = (0..n).map(|i| Some(format!("c{i}"))).collect();
1929        let layer = ColumnarLayer {
1930            name: "huge".into(),
1931            feature_ids: (0..n as u64).collect(),
1932            start_times: vec![0; n],
1933            end_times: vec![1; n],
1934            geometry: GeometryColumn::Point(vec![[0.0, 0.0]; n]),
1935            vertex_times: None,
1936            vertex_values: None,
1937            triangles: None,
1938            vertex_value_matrix: None,
1939            properties: vec![("kind".into(), PropertyColumn::Categorical(kinds))],
1940        };
1941        let err = encode_layer(&layer).expect_err("overflowing dictionary must error");
1942        assert!(
1943            err.to_string().contains("distinct values"),
1944            "unexpected error: {err}"
1945        );
1946    }
1947
1948    #[test]
1949    fn geometry_field_advertises_crs_metadata() {
1950        // Every geometry field carries the GeoArrow extension *name* and the
1951        // CRS in extension *metadata*, so external GeoArrow readers see WGS84
1952        // lon/lat (OGC:CRS84) rather than an unknown CRS.
1953        for layer in [sample_point_layer(), sample_line_layer(), sample_polygon_layer()] {
1954            let ipc = encode_layer(&layer).unwrap();
1955            let batch = decode_layer(&ipc).unwrap();
1956            let field = batch.schema().field_with_name("geometry").unwrap().clone();
1957            let meta = field.metadata();
1958            assert_eq!(
1959                meta.get(GEOARROW_EXT_KEY).map(String::as_str),
1960                Some(layer.geometry.geoarrow_name())
1961            );
1962            let crs = meta
1963                .get(GEOARROW_EXT_META_KEY)
1964                .expect("geometry field must carry ARROW:extension:metadata");
1965            assert!(crs.contains("OGC:CRS84"), "crs metadata was: {crs}");
1966            assert!(crs.contains("crs_type"), "crs metadata was: {crs}");
1967        }
1968    }
1969
1970    #[test]
1971    fn point_layer_roundtrips() {
1972        let layer = sample_point_layer();
1973        let ipc = encode_layer(&layer).unwrap();
1974        let batch = decode_layer(&ipc).unwrap();
1975
1976        assert_eq!(batch.num_rows(), 3);
1977        // id / start / end / geometry / speed / kind
1978        assert_eq!(batch.num_columns(), 6);
1979
1980        let ids = batch
1981            .column_by_name("id")
1982            .unwrap()
1983            .as_any()
1984            .downcast_ref::<UInt64Array>()
1985            .unwrap();
1986        assert_eq!(ids.values(), &[1, 2, 3]);
1987
1988        let geom = batch
1989            .column_by_name("geometry")
1990            .unwrap()
1991            .as_any()
1992            .downcast_ref::<FixedSizeListArray>()
1993            .unwrap();
1994        assert_eq!(geom.len(), 3);
1995        assert_eq!(geom.value_length(), 2);
1996
1997        // Geometry field carries the GeoArrow extension name.
1998        let geom_field = batch.schema().field_with_name("geometry").unwrap().clone();
1999        assert_eq!(
2000            geom_field.metadata().get(GEOARROW_EXT_KEY).map(String::as_str),
2001            Some("geoarrow.point")
2002        );
2003
2004        // Nullable numeric property preserves the null.
2005        let speed = batch
2006            .column_by_name("speed")
2007            .unwrap()
2008            .as_any()
2009            .downcast_ref::<Float64Array>()
2010            .unwrap();
2011        assert!(speed.is_null(1));
2012        assert_eq!(speed.value(0), 10.0);
2013    }
2014
2015    #[test]
2016    fn vector_property_roundtrips_as_fixed_size_list() {
2017        // A Vector property encodes as FixedSizeList<leaf, width>: the f32 quat
2018        // as <Float32,4>, the u8 colour as <UInt8,4>, with the child buffer the
2019        // flattened row-major run the TS decoder hands to the GPU zero-copy.
2020        use arrow::array::{Float32Array, UInt8Array};
2021        let layer = ColumnarLayer {
2022            name: "surfels".to_string(),
2023            feature_ids: vec![1, 2],
2024            start_times: vec![0, 10],
2025            end_times: vec![0, 10],
2026            geometry: GeometryColumn::Point(vec![[-122.4, 37.7], [-122.5, 37.8]]),
2027            vertex_times: None,
2028            vertex_values: None,
2029            triangles: None,
2030            vertex_value_matrix: None,
2031            properties: vec![
2032                (
2033                    "surfel_quat".to_string(),
2034                    PropertyColumn::Vector {
2035                        width: 4,
2036                        elem: VectorElem::F32,
2037                        values: vec![0.0, 0.0, 0.0, 1.0, 0.5, 0.5, 0.5, 0.5],
2038                    },
2039                ),
2040                (
2041                    "surfel_rgba".to_string(),
2042                    PropertyColumn::Vector {
2043                        width: 4,
2044                        elem: VectorElem::U8,
2045                        values: vec![255.0, 0.0, 0.0, 128.0, 0.0, 255.0, 0.0, 255.0],
2046                    },
2047                ),
2048            ],
2049        };
2050        let ipc = encode_layer(&layer).unwrap();
2051        let batch = decode_layer(&ipc).unwrap();
2052
2053        let quat = batch
2054            .column_by_name("surfel_quat")
2055            .unwrap()
2056            .as_any()
2057            .downcast_ref::<FixedSizeListArray>()
2058            .unwrap();
2059        assert_eq!(quat.len(), 2);
2060        assert_eq!(quat.value_length(), 4);
2061        let qchild = quat
2062            .values()
2063            .as_any()
2064            .downcast_ref::<Float32Array>()
2065            .unwrap();
2066        assert_eq!(
2067            qchild.values(),
2068            &[0.0, 0.0, 0.0, 1.0, 0.5, 0.5, 0.5, 0.5]
2069        );
2070
2071        let rgba = batch
2072            .column_by_name("surfel_rgba")
2073            .unwrap()
2074            .as_any()
2075            .downcast_ref::<FixedSizeListArray>()
2076            .unwrap();
2077        assert_eq!(rgba.value_length(), 4);
2078        let cchild = rgba
2079            .values()
2080            .as_any()
2081            .downcast_ref::<UInt8Array>()
2082            .unwrap();
2083        assert_eq!(cchild.values(), &[255, 0, 0, 128, 0, 255, 0, 255]);
2084    }
2085
2086    #[test]
2087    fn vector_groups_fuse_scalar_columns_at_encode() {
2088        // `--vector-group` fuses named scalar columns into one interleaved
2089        // FixedSizeList and drops the scalars; ungrouped columns are untouched.
2090        use arrow::array::Float32Array;
2091        let layer = ColumnarLayer {
2092            name: "surfels".to_string(),
2093            feature_ids: vec![1, 2],
2094            start_times: vec![0, 10],
2095            end_times: vec![0, 10],
2096            geometry: GeometryColumn::Point(vec![[-122.4, 37.7], [-122.5, 37.8]]),
2097            vertex_times: None,
2098            vertex_values: None,
2099            triangles: None,
2100            vertex_value_matrix: None,
2101            properties: vec![
2102                ("qx".into(), PropertyColumn::Numeric(vec![Some(0.0), Some(0.5)])),
2103                ("qy".into(), PropertyColumn::Numeric(vec![Some(0.0), Some(0.5)])),
2104                ("qz".into(), PropertyColumn::Numeric(vec![Some(0.0), Some(0.5)])),
2105                ("qw".into(), PropertyColumn::Numeric(vec![Some(1.0), Some(0.5)])),
2106                ("z".into(), PropertyColumn::Numeric(vec![Some(3.0), Some(4.0)])),
2107            ],
2108        };
2109        // Explicit config (not the process-global setter) so this test can't
2110        // leak a non-default vector-group into a concurrently-running test that
2111        // reads the encoder globals via bare `encode_layer`.
2112        let cfg = EncoderConfig {
2113            vector_groups: vec![VectorGroup {
2114                name: "surfel_quat".to_string(),
2115                components: vec!["qx".into(), "qy".into(), "qz".into(), "qw".into()],
2116                elem: VectorElem::F32,
2117            }],
2118            ..EncoderConfig::default()
2119        };
2120        let ipc = encode_layer_with(&layer, &cfg).unwrap();
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        let cfg = EncoderConfig {
2160            point_elevation_column: "z".to_string(),
2161            ..EncoderConfig::default()
2162        };
2163        let ipc = encode_layer_with(&layer, &cfg).unwrap();
2164        let batch = decode_layer(&ipc).unwrap();
2165
2166        // Geometry is now a 3-wide list with z folded in; `z` is gone as a property.
2167        let geom = batch
2168            .column_by_name("geometry")
2169            .unwrap()
2170            .as_any()
2171            .downcast_ref::<FixedSizeListArray>()
2172            .unwrap();
2173        assert_eq!(geom.value_length(), 3);
2174        let coords = geom.values().as_any().downcast_ref::<Float64Array>().unwrap();
2175        assert_eq!(coords.value(2), 3.5); // feature 0 z
2176        assert_eq!(coords.value(5), 9.0); // feature 1 z
2177        assert!(batch.column_by_name("z").is_none(), "z folded into geometry");
2178        assert!(batch.column_by_name("speed").is_some(), "other props untouched");
2179    }
2180
2181    #[test]
2182    fn point_elevation_3d_geometry_quantizes_with_z_affine() {
2183        use arrow::array::Int32Array;
2184        let layer = ColumnarLayer {
2185            name: "cloud".into(),
2186            feature_ids: vec![1],
2187            start_times: vec![0],
2188            end_times: vec![0],
2189            geometry: GeometryColumn::Point(vec![[-122.4, 37.7]]),
2190            vertex_times: None,
2191            vertex_values: None,
2192            triangles: None,
2193            vertex_value_matrix: None,
2194            properties: vec![("z".into(), PropertyColumn::Numeric(vec![Some(5.0)]))],
2195        };
2196        let cfg = EncoderConfig {
2197            quantize_coords_m: Some(0.05),
2198            point_elevation_column: "z".to_string(),
2199            ..EncoderConfig::default()
2200        };
2201        let ipc = encode_layer_with(&layer, &cfg).unwrap();
2202        let batch = decode_layer(&ipc).unwrap();
2203
2204        let field = batch.schema().field_with_name("geometry").unwrap().clone();
2205        let affine = QuantAffine::from_json(field.metadata().get(STT_QUANT_META_KEY).unwrap()).unwrap();
2206        assert_eq!(affine.z0, Some(0.0));
2207        assert_eq!(affine.sz, Some(0.05));
2208        let geom = batch
2209            .column_by_name("geometry")
2210            .unwrap()
2211            .as_any()
2212            .downcast_ref::<FixedSizeListArray>()
2213            .unwrap();
2214        assert_eq!(geom.value_length(), 3);
2215        let coords = geom.values().as_any().downcast_ref::<Int32Array>().unwrap();
2216        // z = 5.0 / 0.05 = 100; reconstructs to z0 + 100*sz = 5.0.
2217        assert_eq!(coords.value(2), 100);
2218        assert_eq!(affine.z0.unwrap() + coords.value(2) as f64 * affine.sz.unwrap(), 5.0);
2219    }
2220
2221    #[test]
2222    fn quantized_point_layer_roundtrips_within_precision() {
2223        let layer = sample_point_layer();
2224        let ipc = encode_layer_quantized(&layer, Some(1.0)).unwrap();
2225        let batch = decode_layer(&ipc).unwrap();
2226
2227        // Geometry leaf is now i32 grid indices, and the affine rides in metadata.
2228        let geom_field = batch.schema().field_with_name("geometry").unwrap().clone();
2229        let affine = QuantAffine::from_json(
2230            geom_field
2231                .metadata()
2232                .get(STT_QUANT_META_KEY)
2233                .expect("quantized tile must carry the affine"),
2234        )
2235        .unwrap();
2236
2237        let geom = batch
2238            .column_by_name("geometry")
2239            .unwrap()
2240            .as_any()
2241            .downcast_ref::<FixedSizeListArray>()
2242            .unwrap();
2243        assert_eq!(geom.value_type(), DataType::Int32);
2244        let coords = geom
2245            .values()
2246            .as_any()
2247            .downcast_ref::<Int32Array>()
2248            .unwrap();
2249
2250        let original = [[-122.4, 37.7], [-122.5, 37.8], [-122.6, 37.9]];
2251        for (i, [lon, lat]) in original.iter().enumerate() {
2252            let rlon = affine.lon(coords.value(i * 2));
2253            let rlat = affine.lat(coords.value(i * 2 + 1));
2254            // Worst-case reconstruction error ≤ ~half a quantum (~0.5 m).
2255            let dlon_m = (rlon - lon).abs() * M_PER_DEG_LAT * lat.to_radians().cos();
2256            let dlat_m = (rlat - lat).abs() * M_PER_DEG_LAT;
2257            assert!(dlon_m < 1.0, "lon err {dlon_m} m at point {i}");
2258            assert!(dlat_m < 1.0, "lat err {dlat_m} m at point {i}");
2259        }
2260    }
2261
2262    #[test]
2263    fn quantized_numeric_attr_roundtrips_within_precision_and_is_opt_in() {
2264        // A LiDAR-style `z` elevation column: high-entropy Float64 by default,
2265        // but fixed-point UInt16 when the build opts the column in. The reader
2266        // reconstructs `value = o + q*s`, lossy to <= s/2.
2267        let zvals: Vec<Option<f64>> =
2268            vec![Some(1.07), Some(-2.4), Some(15.9), None, Some(40.02)];
2269        let make = || ColumnarLayer {
2270            name: "lidar".into(),
2271            feature_ids: vec![1, 2, 3, 4, 5],
2272            start_times: vec![0; 5],
2273            end_times: vec![1; 5],
2274            geometry: GeometryColumn::Point(vec![[-122.4, 37.7]; 5]),
2275            vertex_times: None,
2276            vertex_values: None,
2277            triangles: None,
2278            vertex_value_matrix: None,
2279            properties: vec![("z".into(), PropertyColumn::Numeric(zvals.clone()))],
2280        };
2281
2282        // Default (no attr-quant configured): `z` stays Float64, byte-identical
2283        // to the historical encoder. Explicit config (not the global setter) so
2284        // this test stays hermetic under the parallel test runner.
2285        let plain =
2286            decode_layer(&encode_layer_with(&make(), &EncoderConfig::default()).unwrap()).unwrap();
2287        let zf = plain.schema().field_with_name("z").unwrap().clone();
2288        assert_eq!(zf.data_type(), &DataType::Float64);
2289        assert!(zf.metadata().get(STT_QUANT_ATTR_META_KEY).is_none());
2290
2291        // Opt the `z` column in at 0.05-unit precision.
2292        let q = encode_layer_with(
2293            &make(),
2294            &EncoderConfig {
2295                quantize_attrs: HashMap::from([("z".to_string(), 0.05f64)]),
2296                ..EncoderConfig::default()
2297            },
2298        )
2299        .unwrap();
2300
2301        let batch = decode_layer(&q).unwrap();
2302        let field = batch.schema().field_with_name("z").unwrap().clone();
2303        // Range (-2.4..40.02)/0.05 ~= 848 fits 16 bits → UInt16 leaf.
2304        assert_eq!(field.data_type(), &DataType::UInt16);
2305        let affine = AttrQuant::from_json(
2306            field
2307                .metadata()
2308                .get(STT_QUANT_ATTR_META_KEY)
2309                .expect("quantized attr must carry the affine"),
2310        )
2311        .unwrap();
2312
2313        let col = batch
2314            .column_by_name("z")
2315            .unwrap()
2316            .as_any()
2317            .downcast_ref::<UInt16Array>()
2318            .unwrap();
2319        for (i, want) in zvals.iter().enumerate() {
2320            match want {
2321                Some(v) => {
2322                    assert!(!col.is_null(i), "row {i} should be present");
2323                    let got = affine.value(col.value(i) as i64);
2324                    assert!((got - v).abs() <= 0.05 / 2.0 + 1e-9, "z[{i}] {got} vs {v}");
2325                }
2326                None => assert!(col.is_null(i), "row {i} should be null"),
2327            }
2328        }
2329    }
2330
2331    #[test]
2332    fn auto_numeric_quantization_is_range_adaptive_and_opt_in() {
2333        // With auto-quant enabled, a raw Float64 property is quantized to a
2334        // UInt16 sized from its own [min,max] span (no precision configured),
2335        // and reconstructs to <= span/65535. Default-off keeps it Float64.
2336        let depth: Vec<Option<f64>> = vec![Some(0.0), Some(10.0), Some(123.4), Some(700.0)];
2337        let make = || ColumnarLayer {
2338            name: "q".into(),
2339            feature_ids: vec![1, 2, 3, 4],
2340            start_times: vec![0; 4],
2341            end_times: vec![1; 4],
2342            geometry: GeometryColumn::Point(vec![[0.0, 0.0]; 4]),
2343            vertex_times: None,
2344            vertex_values: None,
2345            triangles: None,
2346            vertex_value_matrix: None,
2347            properties: vec![("depth".into(), PropertyColumn::Numeric(depth.clone()))],
2348        };
2349
2350        // Default: auto off → Float64. Explicit config (not the global setter)
2351        // so this test can't flip `quantize_attrs_auto` under a concurrently
2352        // running test that reads the encoder globals via bare `encode_layer`.
2353        let plain =
2354            decode_layer(&encode_layer_with(&make(), &EncoderConfig::default()).unwrap()).unwrap();
2355        assert_eq!(
2356            plain.schema().field_with_name("depth").unwrap().data_type(),
2357            &DataType::Float64
2358        );
2359
2360        // Auto on → range-adaptive UInt16 + affine.
2361        let batch = decode_layer(
2362            &encode_layer_with(
2363                &make(),
2364                &EncoderConfig {
2365                    quantize_attrs_auto: true,
2366                    ..EncoderConfig::default()
2367                },
2368            )
2369            .unwrap(),
2370        )
2371        .unwrap();
2372
2373        let field = batch.schema().field_with_name("depth").unwrap().clone();
2374        assert_eq!(field.data_type(), &DataType::UInt16);
2375        let aff = AttrQuant::from_json(field.metadata().get(STT_QUANT_ATTR_META_KEY).unwrap()).unwrap();
2376        let col = batch.column_by_name("depth").unwrap().as_any().downcast_ref::<UInt16Array>().unwrap();
2377        let tol = (700.0 - 0.0) / u16::MAX as f64 / 2.0 + 1e-9;
2378        for (i, want) in depth.iter().enumerate() {
2379            let got = aff.value(col.value(i) as i64);
2380            assert!((got - want.unwrap()).abs() <= tol, "depth[{i}] {got} vs {want:?}");
2381        }
2382        // Min and max land on the index endpoints (full 16-bit span used).
2383        assert_eq!(col.value(0), 0);
2384        assert_eq!(col.value(3), u16::MAX);
2385    }
2386
2387    #[test]
2388    fn quantization_shrinks_geometry_and_is_opt_in() {
2389        // A many-vertex line is coordinate-dominated; quantization should shrink
2390        // the IPC, and the default (None) path must stay byte-identical.
2391        let line: Vec<[f64; 2]> = (0..400)
2392            .map(|k| [-73.95 + k as f64 * 1e-4, 40.75 + k as f64 * 7e-5])
2393            .collect();
2394        let layer = ColumnarLayer {
2395            name: "q".into(),
2396            feature_ids: vec![1],
2397            start_times: vec![0],
2398            end_times: vec![1],
2399            geometry: GeometryColumn::LineString(vec![line]),
2400            vertex_times: None,
2401            vertex_values: None,
2402            triangles: None,
2403            vertex_value_matrix: None,
2404            properties: vec![],
2405        };
2406        let plain = encode_layer_quantized(&layer, None).unwrap();
2407        let quant = encode_layer_quantized(&layer, Some(1.0)).unwrap();
2408
2409        // None keeps the Float64 GeoArrow leaf and carries no affine.
2410        let pb = decode_layer(&plain).unwrap();
2411        let pf = pb.schema().field_with_name("geometry").unwrap().clone();
2412        assert!(pf.metadata().get(STT_QUANT_META_KEY).is_none());
2413
2414        // Some(_) switches the leaf to Int32 and emits the affine.
2415        let qb = decode_layer(&quant).unwrap();
2416        let qf = qb.schema().field_with_name("geometry").unwrap().clone();
2417        assert!(qf.metadata().get(STT_QUANT_META_KEY).is_some());
2418
2419        // i32 coords (4 B) replace f64 (8 B) for a coordinate-dominated layer.
2420        assert!(
2421            quant.len() < plain.len(),
2422            "quantized {} should be smaller than f64 {}",
2423            quant.len(),
2424            plain.len()
2425        );
2426    }
2427
2428    #[test]
2429    fn line_layer_roundtrips_with_vertex_times() {
2430        let layer = sample_line_layer();
2431        let ipc = encode_layer(&layer).unwrap();
2432        let batch = decode_layer(&ipc).unwrap();
2433
2434        assert_eq!(batch.num_rows(), 2);
2435        let geom = batch
2436            .column_by_name("geometry")
2437            .unwrap()
2438            .as_any()
2439            .downcast_ref::<ListArray>()
2440            .unwrap();
2441        // Feature 0 has 3 vertices, feature 1 has 2.
2442        assert_eq!(geom.value(0).len(), 3);
2443        assert_eq!(geom.value(1).len(), 2);
2444
2445        // v3 layers with a tight temporal span carry u16-delta vertex times
2446        // and the origin/step metadata needed to reconstruct absolutes.
2447        let meta = batch.schema().metadata().clone();
2448        let origin: i64 = meta
2449            .get("stt:vertex_time_origin_ms")
2450            .expect("u16 vertex-time layers carry an origin")
2451            .parse()
2452            .unwrap();
2453        let step: u32 = meta
2454            .get("stt:vertex_time_step_ms")
2455            .expect("u16 vertex-time layers carry a step")
2456            .parse()
2457            .unwrap();
2458        assert_eq!(origin, 0);
2459        assert_eq!(step, 1);
2460
2461        let vt = batch
2462            .column_by_name("vertex_time")
2463            .unwrap()
2464            .as_any()
2465            .downcast_ref::<ListArray>()
2466            .unwrap();
2467        assert_eq!(vt.len(), 2);
2468        let first = vt.value(0);
2469        let deltas = first.as_any().downcast_ref::<arrow::array::UInt16Array>().unwrap();
2470        let absolutes: Vec<i64> = deltas
2471            .values()
2472            .iter()
2473            .map(|d| origin + (*d as i64) * step as i64)
2474            .collect();
2475        assert_eq!(absolutes, vec![0, 25, 50]);
2476    }
2477
2478    #[test]
2479    fn line_layer_roundtrips_with_vertex_values() {
2480        // Per-vertex scalars (e.g. SST) ride a nullable List<Float32> aligned
2481        // with the geometry vertices. A NaN entry marks a vertex with no value.
2482        let layer = ColumnarLayer {
2483            name: "drift".into(),
2484            feature_ids: vec![1, 2],
2485            start_times: vec![0, 0],
2486            end_times: vec![100, 100],
2487            geometry: GeometryColumn::LineString(vec![
2488                vec![[0.0, 0.0], [1.0, 1.0], [2.0, 2.0]],
2489                vec![[3.0, 3.0], [4.0, 4.0]],
2490            ]),
2491            vertex_times: None,
2492            vertex_values: Some(vec![vec![5.0, f32::NAN, 27.5], vec![12.0, 13.0]]),
2493            triangles: None,
2494            vertex_value_matrix: None,
2495            properties: vec![],
2496        };
2497        let ipc = encode_layer(&layer).unwrap();
2498        let batch = decode_layer(&ipc).unwrap();
2499
2500        let vv = batch
2501            .column_by_name("vertex_value")
2502            .expect("layers with per-vertex values carry a vertex_value column")
2503            .as_any()
2504            .downcast_ref::<ListArray>()
2505            .unwrap();
2506        assert_eq!(vv.len(), 2);
2507        let first = vv.value(0);
2508        let vals = first.as_any().downcast_ref::<arrow::array::Float32Array>().unwrap();
2509        assert_eq!(vals.value(0), 5.0);
2510        assert!(vals.value(1).is_nan());
2511        assert_eq!(vals.value(2), 27.5);
2512        let second = vv.value(1);
2513        let vals2 = second.as_any().downcast_ref::<arrow::array::Float32Array>().unwrap();
2514        assert_eq!(vals2.values(), &[12.0, 13.0]);
2515    }
2516
2517    #[test]
2518    fn line_layer_roundtrips_with_vertex_value_matrix() {
2519        // Static-geometry overview: per-vertex × per-bucket value matrix rides a
2520        // nullable List<Float32>, flattened vertex-major (vertex 0's buckets,
2521        // then vertex 1's, ...). num_buckets is recorded in schema metadata.
2522        // Feature 0: 3 vertices × 2 buckets; feature 1: 2 vertices × 2 buckets.
2523        let layer = ColumnarLayer {
2524            name: "flows".into(),
2525            feature_ids: vec![1, 2],
2526            start_times: vec![0, 0],
2527            end_times: vec![1800, 1800],
2528            geometry: GeometryColumn::LineString(vec![
2529                vec![[0.0, 0.0], [1.0, 1.0], [2.0, 2.0]],
2530                vec![[3.0, 3.0], [4.0, 4.0]],
2531            ]),
2532            vertex_times: None,
2533            vertex_values: None,
2534            triangles: None,
2535            // vertex-major: [v0b0, v0b1, v1b0, v1b1, v2b0, v2b1]
2536            vertex_value_matrix: Some(vec![
2537                vec![10.0, 11.0, 20.0, 21.0, 30.0, 31.0],
2538                vec![40.0, 41.0, 50.0, 51.0],
2539            ]),
2540            properties: vec![],
2541        };
2542        let ipc = encode_layer(&layer).unwrap();
2543        let batch = decode_layer(&ipc).unwrap();
2544
2545        let vm = batch
2546            .column_by_name("vertex_value_matrix")
2547            .expect("matrix layers carry a vertex_value_matrix column")
2548            .as_any()
2549            .downcast_ref::<ListArray>()
2550            .unwrap();
2551        assert_eq!(vm.len(), 2);
2552        let f0 = vm.value(0);
2553        let f0v = f0.as_any().downcast_ref::<arrow::array::Float32Array>().unwrap();
2554        assert_eq!(f0v.values(), &[10.0, 11.0, 20.0, 21.0, 30.0, 31.0]);
2555        let f1 = vm.value(1);
2556        let f1v = f1.as_any().downcast_ref::<arrow::array::Float32Array>().unwrap();
2557        assert_eq!(f1v.values(), &[40.0, 41.0, 50.0, 51.0]);
2558
2559        // num_buckets = matrix_len / vertex_count = 6 / 3 = 2, in schema meta.
2560        assert_eq!(
2561            batch.schema().metadata().get("stt:vertex_value_buckets"),
2562            Some(&"2".to_string())
2563        );
2564    }
2565
2566    #[test]
2567    fn vertex_time_falls_back_to_int64_for_wide_spans() {
2568        // span = 100 billion ms; the u16 encoding would need step ≈ 1.5e6 ms,
2569        // far beyond the DEFAULT_VERTEX_TIME_MAX_STEP_MS ceiling — so the
2570        // encoder must take the exact List<Int64> path, byte-for-byte
2571        // absolute timestamps, with no origin/step metadata.
2572        let layer = ColumnarLayer {
2573            name: "edge".into(),
2574            feature_ids: vec![1],
2575            start_times: vec![0],
2576            end_times: vec![100],
2577            geometry: GeometryColumn::LineString(vec![vec![[0.0, 0.0], [1.0, 1.0]]]),
2578            vertex_times: Some(vec![vec![0, 100_000_000_000]]),
2579            vertex_values: None,
2580            triangles: None,
2581            vertex_value_matrix: None,
2582            properties: vec![],
2583        };
2584        let ipc = encode_layer(&layer).unwrap();
2585        let batch = decode_layer(&ipc).unwrap();
2586        let schema = batch.schema();
2587        let meta = schema.metadata();
2588        assert!(meta.get("stt:vertex_time_origin_ms").is_none());
2589        assert!(meta.get("stt:vertex_time_step_ms").is_none());
2590        let vt = batch
2591            .column_by_name("vertex_time")
2592            .unwrap()
2593            .as_any()
2594            .downcast_ref::<ListArray>()
2595            .unwrap();
2596        let first = vt.value(0);
2597        let absolutes = first
2598            .as_any()
2599            .downcast_ref::<Int64Array>()
2600            .expect("wide spans must keep the exact Int64 shape");
2601        assert_eq!(absolutes.values(), &[0, 100_000_000_000]);
2602    }
2603
2604    #[test]
2605    fn vertex_time_step_ceiling_is_the_u16_vs_int64_threshold() {
2606        // span = 65_535_000 ms quantizes at exactly the 1000 ms default
2607        // ceiling → u16 deltas; one ms more pushes the step to 1001 → i64.
2608        let make = |span: i64| ColumnarLayer {
2609            name: "edge".into(),
2610            feature_ids: vec![1],
2611            start_times: vec![0],
2612            end_times: vec![100],
2613            geometry: GeometryColumn::LineString(vec![vec![[0.0, 0.0], [1.0, 1.0]]]),
2614            vertex_times: Some(vec![vec![0, span]]),
2615            vertex_values: None,
2616            triangles: None,
2617            vertex_value_matrix: None,
2618            properties: vec![],
2619        };
2620
2621        let at_ceiling = decode_layer(&encode_layer(&make(65_535_000)).unwrap()).unwrap();
2622        let schema = at_ceiling.schema();
2623        let step: u32 = schema
2624            .metadata()
2625            .get("stt:vertex_time_step_ms")
2626            .expect("span at the ceiling stays u16-delta encoded")
2627            .parse()
2628            .unwrap();
2629        assert_eq!(step, DEFAULT_VERTEX_TIME_MAX_STEP_MS);
2630
2631        let past_ceiling = decode_layer(&encode_layer(&make(65_536_000)).unwrap()).unwrap();
2632        assert!(past_ceiling
2633            .schema()
2634            .metadata()
2635            .get("stt:vertex_time_step_ms")
2636            .is_none());
2637        let vt = past_ceiling
2638            .column_by_name("vertex_time")
2639            .unwrap()
2640            .as_any()
2641            .downcast_ref::<ListArray>()
2642            .unwrap();
2643        let first = vt.value(0);
2644        let absolutes = first.as_any().downcast_ref::<Int64Array>().unwrap();
2645        assert_eq!(absolutes.values(), &[0, 65_536_000]);
2646    }
2647
2648    #[test]
2649    fn polygon_layer_roundtrips_with_rings() {
2650        let layer = sample_polygon_layer();
2651        let ipc = encode_layer(&layer).unwrap();
2652        let batch = decode_layer(&ipc).unwrap();
2653
2654        let geom = batch
2655            .column_by_name("geometry")
2656            .unwrap()
2657            .as_any()
2658            .downcast_ref::<ListArray>()
2659            .unwrap();
2660        assert_eq!(geom.len(), 1);
2661        // One feature with two rings (exterior + hole).
2662        let rings = geom.value(0);
2663        let rings = rings.as_any().downcast_ref::<ListArray>().unwrap();
2664        assert_eq!(rings.len(), 2);
2665        assert_eq!(rings.value(0).len(), 5); // exterior ring vertices
2666        assert_eq!(rings.value(1).len(), 5); // hole vertices
2667    }
2668
2669    #[test]
2670    fn multi_layer_tile_frame_roundtrips() {
2671        let layers = vec![sample_line_layer(), sample_point_layer()];
2672        let payload = encode_tile(&layers).unwrap();
2673        let decoded = decode_tile(&payload).unwrap();
2674
2675        assert_eq!(decoded.len(), 2);
2676        assert_eq!(decoded[0].name, "tracks");
2677        assert_eq!(decoded[1].name, "points");
2678        assert_eq!(decoded[0].batch.num_rows(), 2);
2679        assert_eq!(decoded[1].batch.num_rows(), 3);
2680        // Schema metadata records the layer name on the batch too.
2681        assert_eq!(
2682            decoded[1]
2683                .batch
2684                .schema()
2685                .metadata()
2686                .get("stt:layer")
2687                .map(String::as_str),
2688            Some("points")
2689        );
2690    }
2691
2692    #[test]
2693    fn tessellate_polygon_emits_two_triangles_for_a_square() {
2694        // A simple closed square (5 verts, last duplicates first) earcuts into
2695        // exactly 2 triangles, 6 indices in [0, 3].
2696        let ring: Vec<Coord> = vec![
2697            [0.0, 0.0],
2698            [1.0, 0.0],
2699            [1.0, 1.0],
2700            [0.0, 1.0],
2701            [0.0, 0.0],
2702        ];
2703        let tris = tessellate_polygon(&[ring]);
2704        assert_eq!(tris.len(), 6);
2705        for &i in &tris {
2706            assert!(i < 5);
2707        }
2708    }
2709
2710    #[test]
2711    fn tessellate_polygon_handles_a_hole() {
2712        // 4x4 square with a 1x1 hole — earcut should still produce a valid
2713        // tessellation. Index count is implementation-dependent but must be a
2714        // multiple of 3 and reference valid vertex indices.
2715        let exterior: Vec<Coord> =
2716            vec![[0.0, 0.0], [4.0, 0.0], [4.0, 4.0], [0.0, 4.0], [0.0, 0.0]];
2717        let hole: Vec<Coord> =
2718            vec![[1.0, 1.0], [2.0, 1.0], [2.0, 2.0], [1.0, 2.0], [1.0, 1.0]];
2719        let tris = tessellate_polygon(&[exterior, hole]);
2720        assert!(tris.len() >= 6);
2721        assert_eq!(tris.len() % 3, 0);
2722        for &i in &tris {
2723            assert!(i < 10);
2724        }
2725    }
2726
2727    #[test]
2728    fn tessellate_polygon_handles_degenerate_input() {
2729        // No rings → empty result, not a panic.
2730        assert!(tessellate_polygon(&[]).is_empty());
2731        // Single 2-vert ring is below the 3-vertex minimum.
2732        let degenerate: Vec<Coord> = vec![[0.0, 0.0], [1.0, 1.0]];
2733        assert!(tessellate_polygon(&[degenerate]).is_empty());
2734    }
2735
2736    #[test]
2737    fn polygon_layer_with_triangles_roundtrips() {
2738        let exterior: Vec<Coord> =
2739            vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0], [0.0, 0.0]];
2740        let tris = tessellate_polygon(&[exterior.clone()]);
2741        assert_eq!(tris.len(), 6);
2742        let layer = ColumnarLayer {
2743            name: "zones".into(),
2744            feature_ids: vec![42],
2745            start_times: vec![0],
2746            end_times: vec![1000],
2747            geometry: GeometryColumn::Polygon(vec![vec![exterior]]),
2748            vertex_times: None,
2749            vertex_values: None,
2750            triangles: Some(vec![tris.clone()]),
2751            vertex_value_matrix: None,
2752            properties: vec![],
2753        };
2754        let ipc = encode_layer(&layer).unwrap();
2755        let batch = decode_layer(&ipc).unwrap();
2756
2757        // Schema metadata advertises the sidecar.
2758        assert_eq!(
2759            batch
2760                .schema()
2761                .metadata()
2762                .get(TRIANGLES_METADATA_KEY)
2763                .map(String::as_str),
2764            Some("true")
2765        );
2766        // Column exists with the expected shape. Indices here are tiny
2767        // (well under u16::MAX), so the narrower UInt16 encoding applies.
2768        let col = batch
2769            .column_by_name("triangles")
2770            .expect("triangles column present")
2771            .as_any()
2772            .downcast_ref::<ListArray>()
2773            .expect("triangles is a List");
2774        assert_eq!(col.len(), 1);
2775        let first = col.value(0);
2776        let values: &arrow::array::UInt16Array = first
2777            .as_any()
2778            .downcast_ref::<arrow::array::UInt16Array>()
2779            .expect("triangle values are UInt16 for small feature-local indices");
2780        assert_eq!(
2781            values.values().iter().map(|&v| v as u32).collect::<Vec<_>>(),
2782            tris
2783        );
2784    }
2785
2786    #[test]
2787    fn polygon_layer_with_oversized_triangle_index_falls_back_to_uint32() {
2788        // A feature-local triangle index beyond u16::MAX (pathological, but
2789        // possible for a single giant polygon) must fall back to UInt32
2790        // rather than silently truncating.
2791        let exterior: Vec<Coord> =
2792            vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0], [0.0, 0.0]];
2793        let big_tris = vec![0u32, 1, 70_000];
2794        let layer = ColumnarLayer {
2795            name: "zones".into(),
2796            feature_ids: vec![42],
2797            start_times: vec![0],
2798            end_times: vec![1000],
2799            geometry: GeometryColumn::Polygon(vec![vec![exterior]]),
2800            vertex_times: None,
2801            vertex_values: None,
2802            triangles: Some(vec![big_tris.clone()]),
2803            vertex_value_matrix: None,
2804            properties: vec![],
2805        };
2806        let ipc = encode_layer(&layer).unwrap();
2807        let batch = decode_layer(&ipc).unwrap();
2808
2809        let col = batch
2810            .column_by_name("triangles")
2811            .expect("triangles column present")
2812            .as_any()
2813            .downcast_ref::<ListArray>()
2814            .expect("triangles is a List");
2815        let first = col.value(0);
2816        let values: &arrow::array::UInt32Array = first
2817            .as_any()
2818            .downcast_ref::<arrow::array::UInt32Array>()
2819            .expect("triangle values fall back to UInt32 when an index exceeds u16::MAX");
2820        assert_eq!(values.values().to_vec(), big_tris);
2821    }
2822
2823    #[test]
2824    fn polygon_layer_without_triangles_skips_the_metadata_key() {
2825        // Backwards-compat guarantee: a v3 polygon layer that was NOT built
2826        // with pre-tessellation must not carry the metadata flag — otherwise
2827        // a reader would expect a column that isn't there.
2828        let layer = sample_polygon_layer();
2829        let ipc = encode_layer(&layer).unwrap();
2830        let batch = decode_layer(&ipc).unwrap();
2831        assert!(!batch.schema().metadata().contains_key(TRIANGLES_METADATA_KEY));
2832        assert!(batch.column_by_name("triangles").is_none());
2833    }
2834
2835    #[test]
2836    fn non_polygon_layer_drops_stray_triangles() {
2837        // A producer that mistakenly attaches `triangles` to a point or line
2838        // layer must not poison the wire format. The encoder silently drops
2839        // the column so the metadata key never appears.
2840        let mut layer = sample_point_layer();
2841        // Add a bogus per-feature triangle list. The encoder must ignore it.
2842        layer.triangles = Some(vec![vec![0, 1, 2]; layer.feature_ids.len()]);
2843        let ipc = encode_layer(&layer).unwrap();
2844        let batch = decode_layer(&ipc).unwrap();
2845        assert!(!batch.schema().metadata().contains_key(TRIANGLES_METADATA_KEY));
2846        assert!(batch.column_by_name("triangles").is_none());
2847    }
2848
2849    /// Walk a frame and return each layer's IPC start offset + length,
2850    /// honouring the aligned-frame padding rule.
2851    fn ipc_offsets(payload: &[u8]) -> Vec<(usize, usize)> {
2852        let raw = u16::from_le_bytes([payload[0], payload[1]]);
2853        let aligned = raw & ALIGNED_FRAME_FLAG != 0;
2854        let count = (raw & !ALIGNED_FRAME_FLAG) as usize;
2855        let mut pos = 2usize;
2856        let mut out = Vec::new();
2857        for _ in 0..count {
2858            let name_len =
2859                u16::from_le_bytes([payload[pos], payload[pos + 1]]) as usize;
2860            pos += 2 + name_len;
2861            let ipc_len = u32::from_le_bytes([
2862                payload[pos],
2863                payload[pos + 1],
2864                payload[pos + 2],
2865                payload[pos + 3],
2866            ]) as usize;
2867            pos += 4;
2868            if aligned {
2869                pos += (FRAME_ALIGN - pos % FRAME_ALIGN) % FRAME_ALIGN;
2870            }
2871            out.push((pos, ipc_len));
2872            pos += ipc_len;
2873        }
2874        out
2875    }
2876
2877    #[test]
2878    fn encoded_frames_align_every_ipc_stream_to_8_bytes() {
2879        // Layer names of varying lengths so the unpadded offsets would land
2880        // all over the place; the aligned frame must place every IPC stream
2881        // at an 8-byte boundary regardless.
2882        let mut a = sample_line_layer();
2883        a.name = "x".into();
2884        let mut b = sample_point_layer();
2885        b.name = "a-longer-layer-name".into();
2886        let payload = encode_tile(&[a, b]).unwrap();
2887
2888        let raw = u16::from_le_bytes([payload[0], payload[1]]);
2889        assert_ne!(raw & ALIGNED_FRAME_FLAG, 0, "writer must set the aligned flag");
2890
2891        let offsets = ipc_offsets(&payload);
2892        assert_eq!(offsets.len(), 2);
2893        for (off, _) in &offsets {
2894            assert_eq!(off % 8, 0, "IPC stream at offset {off} is misaligned");
2895        }
2896
2897        // And the padded frame still round-trips.
2898        let decoded = decode_tile(&payload).unwrap();
2899        assert_eq!(decoded[0].name, "x");
2900        assert_eq!(decoded[1].name, "a-longer-layer-name");
2901        assert_eq!(decoded[0].batch.num_rows(), 2);
2902        assert_eq!(decoded[1].batch.num_rows(), 3);
2903    }
2904
2905    #[test]
2906    fn legacy_unpadded_frames_still_decode() {
2907        // Rebuild an old-style frame (no flag, no padding) from the layers'
2908        // IPC bytes — the shape every pre-alignment archive carries — and
2909        // assert the decoder reproduces the aligned frame's batches.
2910        let layers = vec![sample_line_layer(), sample_point_layer()];
2911        let aligned_payload = encode_tile(&layers).unwrap();
2912        let aligned = decode_tile(&aligned_payload).unwrap();
2913
2914        let mut legacy: Vec<u8> = Vec::new();
2915        legacy.extend_from_slice(&(layers.len() as u16).to_le_bytes());
2916        for ((off, len), layer) in ipc_offsets(&aligned_payload).iter().zip(&layers) {
2917            let name = layer.name.as_bytes();
2918            legacy.extend_from_slice(&(name.len() as u16).to_le_bytes());
2919            legacy.extend_from_slice(name);
2920            legacy.extend_from_slice(&(*len as u32).to_le_bytes());
2921            legacy.extend_from_slice(&aligned_payload[*off..*off + *len]);
2922        }
2923
2924        let decoded = decode_tile(&legacy).unwrap();
2925        assert_eq!(decoded.len(), aligned.len());
2926        for (l, a) in decoded.iter().zip(&aligned) {
2927            assert_eq!(l.name, a.name);
2928            assert_eq!(l.batch, a.batch);
2929        }
2930    }
2931
2932    #[test]
2933    fn truncated_tile_frame_errors_cleanly() {
2934        let payload = encode_tile(&[sample_point_layer()]).unwrap();
2935        // Chop the payload mid-stream; decode must error, not panic.
2936        let truncated = &payload[..payload.len() / 2];
2937        assert!(decode_tile(truncated).is_err());
2938    }
2939
2940    #[test]
2941    fn length_mismatch_is_rejected() {
2942        let mut layer = sample_point_layer();
2943        layer.start_times.pop(); // now 2 entries vs 3 features
2944        assert!(encode_layer(&layer).is_err());
2945    }
2946}