Skip to main content

stt_build/
columnar.rs

1//! Build Arrow [`ColumnarLayer`]s from parsed features and clipped segments.
2//!
3//! Geometry is stored as real WGS84 lon/lat (`f64`) — no quantization, no
4//! delta encoding. Arrow IPC + gzip handle compression, and the payload is
5//! consumable directly by GeoArrow-aware renderers.
6
7use crate::clip::ClippedSegment;
8use crate::input::ParsedFeature;
9use anyhow::Result;
10use std::collections::BTreeMap;
11use std::sync::Arc;
12use stt_core::arrow_tile::{
13    tessellate_polygon, ColumnarLayer, Coord, GeometryColumn, PropertyColumn,
14};
15use stt_core::types::GeometryType;
16
17/// Opt-in user-property selection (tippecanoe `--exclude`/`--include`/
18/// `--exclude-all` mental model). Applied at the point property columns are
19/// materialised, so it governs BOTH point/line/polygon layers and clipped
20/// trajectory-segment layers.
21///
22/// SYSTEM columns (feature id, start/end time, geometry, vertex_time /
23/// vertex_value / vertex_value_matrix, triangles) are NOT user properties and
24/// are therefore never reachable here — they always survive. This filter only
25/// ever touches the `properties` map.
26#[derive(Debug, Clone, Default)]
27pub enum AttributeFilter {
28    /// No filtering — every user property is kept (the default; byte-for-byte
29    /// identical to a build with none of the attribute flags set).
30    #[default]
31    KeepAll,
32    /// Drop exactly these property names (`--exclude`).
33    Exclude(std::collections::HashSet<String>),
34    /// Keep ONLY these property names (`--include`).
35    Include(std::collections::HashSet<String>),
36    /// Drop every user property — geometry + times only (`--exclude-all`).
37    ExcludeAll,
38}
39
40impl AttributeFilter {
41    /// Should the named user property be emitted?
42    pub fn keeps(&self, name: &str) -> bool {
43        match self {
44            AttributeFilter::KeepAll => true,
45            AttributeFilter::Exclude(set) => !set.contains(name),
46            AttributeFilter::Include(set) => set.contains(name),
47            AttributeFilter::ExcludeAll => false,
48        }
49    }
50
51    /// True when the filter is the inert default (no property is ever dropped).
52    pub fn is_keep_all(&self) -> bool {
53        matches!(self, AttributeFilter::KeepAll)
54    }
55}
56
57/// Authoritative kind of one user property, derived from the input source's
58/// schema (Arrow/GeoParquet column type, DB column type) rather than sniffed
59/// from values.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum PropertyKind {
62    /// Emitted as a Float64 (or quantized) tile column.
63    Numeric,
64    /// Emitted as a Dictionary<UInt16, Utf8> tile column.
65    Categorical,
66}
67
68/// Map property name → authoritative kind. Empty = no schema known
69/// (GeoJSON-shaped producers) — per-tile value sniffing applies.
70pub type PropertyTypes = BTreeMap<String, PropertyKind>;
71
72/// Per-tile build options that influence the columnar layout (independent of
73/// the tile-level partitioning logic the tiler owns).
74#[derive(Debug, Clone, Default)]
75pub struct ColumnarOptions {
76    /// When true, polygon layers will carry pre-baked earcut triangle indices
77    /// in a `triangles` sidecar column — letting the renderer skip its own
78    /// CPU-side tessellation on tile arrival (MLT-style).
79    pub pre_tessellate: bool,
80    /// Opt-in user-property selection. Default [`AttributeFilter::KeepAll`] —
81    /// inert unless `--exclude`/`--include`/`--exclude-all` is passed.
82    pub attribute_filter: AttributeFilter,
83    /// Authoritative per-property kinds from the input source's schema. A
84    /// listed key produces a column of the declared kind in EVERY tile — even
85    /// a tile where all its values happen to be null. Without this, per-tile
86    /// value sniffing silently DROPS an all-null-in-tile column (and can
87    /// reclassify a column tile-by-tile), drifting the layer schema across
88    /// tiles. Keys not listed keep the sniffing behaviour, which schema-less
89    /// producers rely on.
90    pub property_types: Arc<PropertyTypes>,
91}
92
93/// Build layers from a set of features sharing a tile. Features are grouped by
94/// geometry type — a single layer holds exactly one geometry kind, so a tile
95/// with mixed points and polygons yields one layer per kind.
96///
97/// Convenience wrapper for callers that don't care about extra build knobs.
98pub fn build_layers_from_features(
99    features: &[&ParsedFeature],
100    layer_name: &str,
101) -> Result<Vec<ColumnarLayer>> {
102    build_layers_from_features_with(features, layer_name, ColumnarOptions::default())
103}
104
105/// Build layers from features with explicit build options.
106pub fn build_layers_from_features_with(
107    features: &[&ParsedFeature],
108    layer_name: &str,
109    opts: ColumnarOptions,
110) -> Result<Vec<ColumnarLayer>> {
111    if features.is_empty() {
112        return Ok(vec![]);
113    }
114
115    // Partition by geometry type, preserving input order within each group.
116    let mut points: Vec<&ParsedFeature> = Vec::new();
117    let mut lines: Vec<&ParsedFeature> = Vec::new();
118    let mut polygons: Vec<&ParsedFeature> = Vec::new();
119    for f in features {
120        match determine_geometry_type(f) {
121            Ok(GeometryType::Point) => points.push(f),
122            Ok(GeometryType::LineString) => lines.push(f),
123            Ok(GeometryType::Polygon) => polygons.push(f),
124            Err(e) => tracing::warn!("skipping feature with no geometry: {e}"),
125        }
126    }
127
128    let mut layers = Vec::new();
129    // When a tile has multiple kinds, suffix the layer name so a reader can
130    // tell them apart; the dominant kind keeps the bare name.
131    let kinds_present =
132        [!points.is_empty(), !lines.is_empty(), !polygons.is_empty()]
133            .iter()
134            .filter(|p| **p)
135            .count();
136    let name_for = |kind: &str| -> String {
137        if kinds_present <= 1 {
138            layer_name.to_string()
139        } else {
140            format!("{layer_name}_{kind}")
141        }
142    };
143
144    if !points.is_empty() {
145        layers.push(build_point_layer(&points, name_for("points"), &opts)?);
146    }
147    if !lines.is_empty() {
148        layers.push(build_line_layer(&lines, name_for("lines"), &opts)?);
149    }
150    if !polygons.is_empty() {
151        layers.push(build_polygon_layer(&polygons, name_for("polygons"), &opts)?);
152    }
153    Ok(layers)
154}
155
156/// Build a single linestring layer from clipped trajectory segments. Segments
157/// carry real per-vertex timestamps produced by the clipper.
158pub fn build_layer_from_segments(
159    segments: &[&ClippedSegment],
160    layer_name: &str,
161    opts: &ColumnarOptions,
162) -> Result<ColumnarLayer> {
163    let n = segments.len();
164    let mut feature_ids = Vec::with_capacity(n);
165    let mut start_times = Vec::with_capacity(n);
166    let mut end_times = Vec::with_capacity(n);
167    let mut geometry: Vec<Vec<Coord>> = Vec::with_capacity(n);
168    let mut vertex_times: Vec<Vec<i64>> = Vec::with_capacity(n);
169    let mut vertex_values: Vec<Vec<f32>> = Vec::with_capacity(n);
170    let mut vertex_value_matrix: Vec<Vec<f32>> = Vec::with_capacity(n);
171    let mut any_values = false;
172    let mut any_matrix = false;
173
174    let mut props = PropertyAccumulator::new(
175        opts.attribute_filter.clone(),
176        Arc::clone(&opts.property_types),
177    );
178
179    for seg in segments {
180        feature_ids.push(segment_feature_id(seg));
181        start_times.push(seg.start_time as i64);
182        end_times.push(seg.end_time as i64);
183
184        let coords: Vec<Coord> = seg.coordinates.iter().map(|(x, y, _alt)| [*x, *y]).collect();
185
186        // Per-vertex timestamps: use the segment's real timestamps where
187        // present, padding with the start time if the clipper produced fewer.
188        let mut times: Vec<i64> = Vec::with_capacity(coords.len());
189        for i in 0..coords.len() {
190            let t = seg.timestamps.get(i).copied().unwrap_or(seg.start_time);
191            times.push(t as i64);
192        }
193
194        // Per-vertex scalar values (e.g. SST), aligned with coords. Missing
195        // entries become NaN so the column always has one value per vertex.
196        if !seg.vertex_values.is_empty() {
197            any_values = true;
198        }
199        let mut vals: Vec<f32> = Vec::with_capacity(coords.len());
200        for i in 0..coords.len() {
201            vals.push(seg.vertex_values.get(i).copied().unwrap_or(f32::NAN));
202        }
203
204        // Per-vertex × per-bucket matrix, flattened vertex-major. Each segment
205        // row is `[vertex][bucket]`, aligned 1:1 with `coordinates` by the
206        // clipper, so concatenating rows yields the tile's vertex-major layout.
207        if !seg.vertex_value_matrix.is_empty() {
208            any_matrix = true;
209            let nb = seg.vertex_value_matrix[0].len();
210            let mut flat = Vec::with_capacity(coords.len() * nb);
211            for row in &seg.vertex_value_matrix {
212                flat.extend_from_slice(row);
213            }
214            vertex_value_matrix.push(flat);
215        } else {
216            vertex_value_matrix.push(Vec::new());
217        }
218
219        geometry.push(coords);
220        vertex_times.push(times);
221        vertex_values.push(vals);
222
223        props.observe(seg.properties.as_deref());
224    }
225    // Second pass fills one value per feature for every discovered property.
226    for seg in segments {
227        props.push_row(seg.properties.as_deref());
228    }
229
230    Ok(ColumnarLayer {
231        name: layer_name.to_string(),
232        feature_ids,
233        start_times,
234        end_times,
235        geometry: GeometryColumn::LineString(geometry),
236        // Matrix corridors are timeless (animated by the matrix, not per-vertex
237        // times) — drop the dead per-vertex time column for them; keep it for
238        // ordinary trajectory segments that drive the trail animation.
239        vertex_times: (!any_matrix).then_some(vertex_times),
240        // Only attach per-vertex values if at least one segment carried them.
241        vertex_values: any_values.then_some(vertex_values),
242        triangles: None,
243        vertex_value_matrix: any_matrix.then_some(vertex_value_matrix),
244        properties: props.finish(),
245    })
246}
247
248// ----------------------------------------------------------------------------
249// Per-geometry-kind builders
250// ----------------------------------------------------------------------------
251
252fn build_point_layer(
253    features: &[&ParsedFeature],
254    name: String,
255    opts: &ColumnarOptions,
256) -> Result<ColumnarLayer> {
257    let (mut ids, start, end, props) = common_columns(features, opts);
258    // Points are never split across tile boundaries, so a point feature needs no
259    // globally stable id (unlike a clipped line/polygon, which must keep one id
260    // across the tiles it spans). When the source carries no explicit id, the
261    // fallback in `determine_feature_id` is a hash of (time, lon, lat): a
262    // high-entropy u64 that zstd cannot compress and that — measured on Waymo
263    // LiDAR — is the single largest column in the tile (~40% of a point's
264    // compressed bytes). Replace those synthetic ids with the per-tile row
265    // index: still unique within the tile (picking stays correct) but monotonic,
266    // so the column compresses to a few bits per point. Explicit source ids
267    // (e.g. earthquake/storm-cell ids a consumer may surface) are preserved.
268    for (i, f) in features.iter().enumerate() {
269        if f.geojson.id.is_none() {
270            ids[i] = i as u64;
271        }
272    }
273    let geometry: Vec<Coord> = features.iter().map(|f| [f.lon, f.lat]).collect();
274    Ok(ColumnarLayer {
275        name,
276        feature_ids: ids,
277        start_times: start,
278        end_times: end,
279        geometry: GeometryColumn::Point(geometry),
280        vertex_times: None,
281        vertex_values: None,
282        triangles: None,
283        vertex_value_matrix: None,
284        properties: props,
285    })
286}
287
288fn build_line_layer(
289    features: &[&ParsedFeature],
290    name: String,
291    opts: &ColumnarOptions,
292) -> Result<ColumnarLayer> {
293    let (ids, start, end, props) = common_columns(features, opts);
294
295    let mut geometry: Vec<Vec<Coord>> = Vec::with_capacity(features.len());
296    let mut vertex_times: Vec<Vec<i64>> = Vec::with_capacity(features.len());
297    let mut vertex_values: Vec<Vec<f32>> = Vec::with_capacity(features.len());
298    let mut vertex_value_matrix: Vec<Vec<f32>> = Vec::with_capacity(features.len());
299    let mut any_duration = false;
300    let mut any_values = false;
301    let mut any_matrix = false;
302    let mut length_mismatch_warned = false;
303
304    for f in features {
305        let coords = extract_line_coords(f)?;
306        // Priority for per-vertex times, in order:
307        //   1. Producer-supplied `vertex_timestamps` (e.g. OSRM annotations) —
308        //      real per-segment timing reflecting street class.
309        //   2. Distance-interpolated from start..end when a duration exists.
310        //   3. Flat: every vertex shares the feature start time.
311        // The supplied path is rejected if its length doesn't match the
312        // geometry's vertex count (logged once per build to surface bad
313        // producers rather than silently corrupting the timing).
314        let times = if let Some(supplied) = f.vertex_timestamps.as_ref() {
315            if supplied.len() == coords.len() {
316                any_duration = true;
317                supplied.iter().map(|&t| t as i64).collect()
318            } else {
319                if !length_mismatch_warned {
320                    tracing::warn!(
321                        "vertex_timestamps length {} != coord count {} for a line \
322                         feature; falling back to distance interpolation (further \
323                         mismatches in this build will be silent)",
324                        supplied.len(),
325                        coords.len()
326                    );
327                    length_mismatch_warned = true;
328                }
329                if let Some(end_ts) = f.end_timestamp {
330                    any_duration = true;
331                    interpolate_vertex_times(&coords, f.timestamp, end_ts)
332                } else {
333                    vec![f.timestamp as i64; coords.len()]
334                }
335            }
336        } else if let Some(end_ts) = f.end_timestamp {
337            any_duration = true;
338            interpolate_vertex_times(&coords, f.timestamp, end_ts)
339        } else {
340            vec![f.timestamp as i64; coords.len()]
341        };
342        // Per-vertex scalar values (e.g. SST). Accepted only when the supplied
343        // length matches the geometry; otherwise NaN-filled (gray at render).
344        let vals: Vec<f32> = match f.vertex_values.as_ref() {
345            Some(supplied) if supplied.len() == coords.len() => {
346                any_values = true;
347                supplied.clone()
348            }
349            _ => vec![f32::NAN; coords.len()],
350        };
351        // Per-vertex × per-bucket matrix (flat vertex-major). Accepted only when
352        // the length is a clean multiple of the vertex count.
353        let matrix: Vec<f32> = match f.vertex_value_matrix.as_ref() {
354            Some(m) if !m.is_empty() && m.len() % coords.len() == 0 => {
355                any_matrix = true;
356                m.clone()
357            }
358            _ => Vec::new(),
359        };
360
361        geometry.push(coords);
362        vertex_times.push(times);
363        vertex_values.push(vals);
364        vertex_value_matrix.push(matrix);
365    }
366
367    Ok(ColumnarLayer {
368        name,
369        feature_ids: ids,
370        start_times: start,
371        end_times: end,
372        geometry: GeometryColumn::LineString(geometry),
373        // Attach per-vertex times only when a feature has a real duration AND the
374        // layer carries no value matrix. A matrix corridor is TIMELESS — its
375        // geometry is static and the animation comes from the matrix, not from
376        // per-vertex times — so the interpolated times are dead weight no
377        // consumer reads (the decoder + trips layers synthesize them when absent).
378        // Dropping them keeps flow-corridor / baked-bundle tiles small.
379        vertex_times: (any_duration && !any_matrix).then_some(vertex_times),
380        // Likewise only attach per-vertex values if a feature supplied them.
381        vertex_values: any_values.then_some(vertex_values),
382        triangles: None,
383        vertex_value_matrix: any_matrix.then_some(vertex_value_matrix),
384        properties: props,
385    })
386}
387
388fn build_polygon_layer(
389    features: &[&ParsedFeature],
390    name: String,
391    opts: &ColumnarOptions,
392) -> Result<ColumnarLayer> {
393    let (ids, start, end, props) = common_columns(features, opts);
394    let mut geometry: Vec<Vec<Vec<Coord>>> = Vec::with_capacity(features.len());
395    for f in features {
396        geometry.push(extract_polygon_rings(f)?);
397    }
398    // Build the triangle index sidecar by running earcut over each feature's
399    // rings. The same coords feed both the geometry column and the tessellator —
400    // indices are local to the feature.
401    //
402    // We bake triangles when explicitly requested (`--pre-tessellate`) OR
403    // whenever ANY feature is multi-ring (a polygon with holes, or a perimeter
404    // carrying interior rings). Multi-ring polygons CANNOT render correctly
405    // through deck.gl's binary earcut path: with `_normalize:false` and no
406    // index buffer it triangulates the feature's concatenated ring run as a
407    // SINGLE boundary, bridging disjoint rings with spanning triangles — the
408    // storm-radar isoband streaks and wildfire-perimeter spikes. Supplying the
409    // baked, hole-aware `indices` buffer makes the renderer skip earcut, so the
410    // sidecar is MANDATORY for these layers, not just a perf win. Layers whose
411    // every feature is a single ring stay lean (no sidecar).
412    let needs_triangles =
413        opts.pre_tessellate || geometry.iter().any(|rings| rings.len() > 1);
414    let triangles = if needs_triangles {
415        let mut tris: Vec<Vec<u32>> = Vec::with_capacity(geometry.len());
416        for rings in &geometry {
417            tris.push(tessellate_polygon(rings));
418        }
419        Some(tris)
420    } else {
421        None
422    };
423    Ok(ColumnarLayer {
424        name,
425        feature_ids: ids,
426        start_times: start,
427        end_times: end,
428        geometry: GeometryColumn::Polygon(geometry),
429        vertex_times: None,
430        vertex_values: None,
431        triangles,
432        vertex_value_matrix: None,
433        properties: props,
434    })
435}
436
437/// Build the id / start / end / property columns shared by every layer kind.
438fn common_columns(
439    features: &[&ParsedFeature],
440    opts: &ColumnarOptions,
441) -> (Vec<u64>, Vec<i64>, Vec<i64>, Vec<(String, PropertyColumn)>) {
442    let mut ids = Vec::with_capacity(features.len());
443    let mut start = Vec::with_capacity(features.len());
444    let mut end = Vec::with_capacity(features.len());
445    let mut props = PropertyAccumulator::new(
446        opts.attribute_filter.clone(),
447        Arc::clone(&opts.property_types),
448    );
449
450    for f in features {
451        ids.push(determine_feature_id(f));
452        start.push(f.timestamp as i64);
453        end.push(f.end_timestamp.unwrap_or(f.timestamp) as i64);
454        props.observe(f.shared_properties.as_deref());
455    }
456    for f in features {
457        props.push_row(f.shared_properties.as_deref());
458    }
459    (ids, start, end, props.finish())
460}
461
462// ----------------------------------------------------------------------------
463// Property accumulation
464// ----------------------------------------------------------------------------
465
466/// Discovers the property schema across a group of features (the union of all
467/// keys, classifying each as numeric or categorical) and then materialises one
468/// value per feature, inserting `None` for missing entries.
469struct PropertyAccumulator {
470    /// Per-key type evidence gathered during the first (`observe`) pass.
471    seen: BTreeMap<String, KeyKind>,
472    /// Numeric columns, materialised at seal time, in stable (sorted) order.
473    numeric: BTreeMap<String, Vec<Option<f64>>>,
474    /// Categorical columns, materialised at seal time.
475    categorical: BTreeMap<String, Vec<Option<String>>>,
476    /// True once the schema is frozen (first `push_row`); `observe` is then a
477    /// no-op and the numeric/categorical split is fixed.
478    sealed: bool,
479    /// Opt-in user-property selection. A key the filter rejects is never
480    /// recorded in `seen`, so it produces no column at all. Default `KeepAll`
481    /// (every key kept) keeps output byte-for-byte identical to the no-flag
482    /// build.
483    filter: AttributeFilter,
484    /// Authoritative kinds from the input schema — see
485    /// [`ColumnarOptions::property_types`]. Declared keys bypass the sniffed
486    /// evidence entirely at seal time.
487    declared: Arc<PropertyTypes>,
488}
489
490/// Type evidence for one property key across a feature group.
491#[derive(Default)]
492struct KeyKind {
493    /// Saw a real JSON number.
494    has_number: bool,
495    /// Saw a string that parses cleanly as a finite f64 (e.g. "1000.0").
496    has_numeric_string: bool,
497    /// Saw a value that can't be numeric (non-numeric string, boolean, …).
498    has_other: bool,
499}
500
501/// Coerce a JSON value to f64, accepting both real numbers and strings that
502/// hold a number — so a producer that encoded e.g. `altitude` as the string
503/// "1000.0" (a known line/polygon writer bug) still yields a numeric column
504/// that can drive colour ramps and elevation.
505fn value_as_f64(v: &serde_json::Value) -> Option<f64> {
506    match v {
507        serde_json::Value::Number(_) => v.as_f64(),
508        serde_json::Value::String(s) => s.trim().parse::<f64>().ok().filter(|f| f.is_finite()),
509        _ => None,
510    }
511}
512
513impl PropertyAccumulator {
514    /// Construct with an opt-in user-property selection and the (possibly
515    /// empty) authoritative schema kinds. Pass [`AttributeFilter::KeepAll`] +
516    /// an empty map for the default "keep everything, sniff types" behaviour.
517    fn new(filter: AttributeFilter, declared: Arc<PropertyTypes>) -> Self {
518        Self {
519            seen: BTreeMap::new(),
520            numeric: BTreeMap::new(),
521            categorical: BTreeMap::new(),
522            sealed: false,
523            filter,
524            declared,
525        }
526    }
527
528    /// First pass: record type evidence for every key present on a feature.
529    fn observe(&mut self, props: Option<&serde_json::Map<String, serde_json::Value>>) {
530        if self.sealed {
531            return;
532        }
533        let Some(props) = props else { return };
534        for (key, value) in props {
535            if value.is_null() {
536                continue;
537            }
538            // Opt-in attribute control: a rejected key never enters `seen`, so
539            // it yields no column. System columns aren't user properties and
540            // never pass through here, so they always survive.
541            if !self.filter.keeps(key) {
542                continue;
543            }
544            let kind = self.seen.entry(key.clone()).or_default();
545            if value.is_number() {
546                kind.has_number = true;
547            } else if let Some(s) = value.as_str() {
548                if s.trim().parse::<f64>().map(|f| f.is_finite()).unwrap_or(false) {
549                    kind.has_numeric_string = true;
550                } else {
551                    kind.has_other = true;
552                }
553            } else {
554                // Booleans (and anything else non-numeric) → categorical. A flag
555                // a producer wants to *sum* should be emitted as numeric 0/1.
556                kind.has_other = true;
557            }
558        }
559    }
560
561    /// Freeze the schema. Declared keys (input-schema authority) come first:
562    /// each produces a column of its declared kind in EVERY tile, even one
563    /// where all its values are null — per-tile evidence would otherwise
564    /// silently drop such a column (or reclassify it), drifting the layer
565    /// schema across tiles. Undeclared keys keep the evidence rule: numeric
566    /// iff every observed value was a number (or a numeric-looking string)
567    /// and nothing forced it categorical.
568    fn seal(&mut self) {
569        if self.sealed {
570            return;
571        }
572        self.sealed = true;
573        let declared = Arc::clone(&self.declared);
574        for (key, kind) in declared.iter() {
575            if !self.filter.keeps(key) {
576                continue;
577            }
578            match kind {
579                PropertyKind::Numeric => {
580                    self.numeric.insert(key.clone(), Vec::new());
581                }
582                PropertyKind::Categorical => {
583                    self.categorical.insert(key.clone(), Vec::new());
584                }
585            }
586        }
587        for (key, kind) in &self.seen {
588            if self.numeric.contains_key(key) || self.categorical.contains_key(key) {
589                continue; // declared — schema wins over sniffed evidence
590            }
591            let is_numeric = (kind.has_number || kind.has_numeric_string) && !kind.has_other;
592            if is_numeric {
593                self.numeric.insert(key.clone(), Vec::new());
594            } else {
595                self.categorical.insert(key.clone(), Vec::new());
596            }
597        }
598    }
599
600    /// Second pass: append this feature's value for every discovered column.
601    fn push_row(&mut self, props: Option<&serde_json::Map<String, serde_json::Value>>) {
602        if !self.sealed {
603            self.seal();
604        }
605        for (key, col) in self.numeric.iter_mut() {
606            let v = props.and_then(|p| p.get(key)).and_then(value_as_f64);
607            col.push(v);
608        }
609        for (key, col) in self.categorical.iter_mut() {
610            let v = props.and_then(|p| p.get(key)).and_then(|v| match v {
611                serde_json::Value::String(s) => Some(s.clone()),
612                serde_json::Value::Bool(b) => Some(b.to_string()),
613                serde_json::Value::Number(n) => Some(n.to_string()),
614                _ => None,
615            });
616            col.push(v);
617        }
618    }
619
620    fn finish(self) -> Vec<(String, PropertyColumn)> {
621        let mut out = Vec::new();
622        for (name, values) in self.numeric {
623            out.push((name, PropertyColumn::Numeric(values)));
624        }
625        for (name, values) in self.categorical {
626            out.push((name, PropertyColumn::Categorical(values)));
627        }
628        out
629    }
630}
631
632// ----------------------------------------------------------------------------
633// Geometry extraction
634// ----------------------------------------------------------------------------
635
636/// Determine a feature's geometry type.
637pub fn determine_geometry_type(feature: &ParsedFeature) -> Result<GeometryType> {
638    use geojson::Value as GeomValue;
639    let geom = feature
640        .geojson
641        .geometry
642        .as_ref()
643        .ok_or_else(|| anyhow::anyhow!("feature has no geometry"))?;
644    Ok(match &geom.value {
645        GeomValue::Point(_) | GeomValue::MultiPoint(_) => GeometryType::Point,
646        GeomValue::LineString(_) | GeomValue::MultiLineString(_) => GeometryType::LineString,
647        GeomValue::Polygon(_) | GeomValue::MultiPolygon(_) => GeometryType::Polygon,
648        GeomValue::GeometryCollection(c) => match c.first().map(|g| &g.value) {
649            Some(GeomValue::Point(_)) | Some(GeomValue::MultiPoint(_)) => GeometryType::Point,
650            Some(GeomValue::LineString(_)) | Some(GeomValue::MultiLineString(_)) => {
651                GeometryType::LineString
652            }
653            _ => GeometryType::Polygon,
654        },
655    })
656}
657
658/// Extract a flat vertex list for a (multi)linestring feature.
659fn extract_line_coords(feature: &ParsedFeature) -> Result<Vec<Coord>> {
660    use geojson::Value as GeomValue;
661    let geom = feature
662        .geojson
663        .geometry
664        .as_ref()
665        .ok_or_else(|| anyhow::anyhow!("feature has no geometry"))?;
666    let coords: Vec<Coord> = match &geom.value {
667        GeomValue::LineString(pts) => pts.iter().filter(|c| c.len() >= 2).map(|c| [c[0], c[1]]).collect(),
668        GeomValue::MultiLineString(lines) => lines
669            .iter()
670            .flatten()
671            .filter(|c| c.len() >= 2)
672            .map(|c| [c[0], c[1]])
673            .collect(),
674        _ => vec![[feature.lon, feature.lat]],
675    };
676    if coords.is_empty() {
677        Ok(vec![[feature.lon, feature.lat]])
678    } else {
679        Ok(coords)
680    }
681}
682
683/// Extract polygon rings (ring 0 is the exterior). MultiPolygon rings are
684/// flattened — `ring_offsets` semantics in GeoArrow keep them separable.
685fn extract_polygon_rings(feature: &ParsedFeature) -> Result<Vec<Vec<Coord>>> {
686    use geojson::Value as GeomValue;
687    let geom = feature
688        .geojson
689        .geometry
690        .as_ref()
691        .ok_or_else(|| anyhow::anyhow!("feature has no geometry"))?;
692    let to_ring = |ring: &Vec<Vec<f64>>| -> Vec<Coord> {
693        ring.iter().filter(|c| c.len() >= 2).map(|c| [c[0], c[1]]).collect()
694    };
695    let rings: Vec<Vec<Coord>> = match &geom.value {
696        GeomValue::Polygon(rings) => rings
697            .iter()
698            .map(to_ring)
699            .filter(|r| r.len() >= 4)
700            .collect(),
701        GeomValue::MultiPolygon(polys) => polys
702            .iter()
703            .flat_map(|p| p.iter().map(to_ring))
704            .filter(|r| r.len() >= 4)
705            .collect(),
706        _ => vec![],
707    };
708    if rings.is_empty() {
709        // Degenerate fallback: a zero-area ring at the centroid.
710        Ok(vec![vec![[feature.lon, feature.lat]]])
711    } else {
712        Ok(rings)
713    }
714}
715
716/// Synthesise per-vertex timestamps by cumulative distance along a path.
717fn interpolate_vertex_times(coords: &[Coord], start: u64, end: u64) -> Vec<i64> {
718    let n = coords.len();
719    if n == 0 {
720        return vec![];
721    }
722    if n == 1 {
723        return vec![start as i64];
724    }
725    let mut cumulative = vec![0.0f64; n];
726    for i in 1..n {
727        let [lon1, lat1] = coords[i - 1];
728        let [lon2, lat2] = coords[i];
729        cumulative[i] = cumulative[i - 1] + haversine_distance(lat1, lon1, lat2, lon2);
730    }
731    let total = cumulative[n - 1];
732    let duration = end as f64 - start as f64;
733    if total <= 0.0 {
734        return vec![start as i64; n];
735    }
736    cumulative
737        .iter()
738        .map(|d| start as i64 + (d / total * duration) as i64)
739        .collect()
740}
741
742/// Haversine distance in metres.
743fn haversine_distance(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 {
744    const EARTH_RADIUS: f64 = 6_371_000.0;
745    let dlat = (lat2 - lat1).to_radians();
746    let dlon = (lon2 - lon1).to_radians();
747    let a = (dlat / 2.0).sin().powi(2)
748        + lat1.to_radians().cos() * lat2.to_radians().cos() * (dlon / 2.0).sin().powi(2);
749    EARTH_RADIUS * 2.0 * a.sqrt().asin()
750}
751
752// ----------------------------------------------------------------------------
753// Feature ids
754// ----------------------------------------------------------------------------
755
756/// 64-bit FNV-1a over a byte slice — the synthetic-feature-id hash.
757///
758/// Deliberately NOT `std::collections::hash_map::DefaultHasher`: its algorithm
759/// is explicitly unspecified across Rust releases, so a toolchain bump could
760/// change every synthetic id, every tile byte and every content address,
761/// silently breaking the incremental-deploy economics. FNV-1a is fixed by
762/// spec: offset basis `0xcbf29ce484222325`, prime `0x100000001b3`
763/// (http://www.isthe.com/chongo/tech/comp/fnv/). Multi-field inputs are fed
764/// as the concatenation of each field's little-endian bytes.
765fn fnv1a_64(bytes: &[u8]) -> u64 {
766    const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
767    const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
768    let mut hash = FNV_OFFSET_BASIS;
769    for &b in bytes {
770        hash ^= b as u64;
771        hash = hash.wrapping_mul(FNV_PRIME);
772    }
773    hash
774}
775
776/// FNV-1a over a sequence of u64 fields (each folded in as little-endian).
777fn fnv1a_64_fields(fields: &[u64]) -> u64 {
778    let mut bytes = Vec::with_capacity(fields.len() * 8);
779    for f in fields {
780        bytes.extend_from_slice(&f.to_le_bytes());
781    }
782    fnv1a_64(&bytes)
783}
784
785/// Resolve a stable u64 feature id (from the GeoJSON id, else a hash).
786fn determine_feature_id(feature: &ParsedFeature) -> u64 {
787    use geojson::feature::Id;
788
789    if let Some(id) = &feature.geojson.id {
790        match id {
791            Id::Number(num) => {
792                if let Some(v) = num.as_u64() {
793                    return v;
794                }
795                if let Some(v) = num.as_i64() {
796                    return v as u64;
797                }
798            }
799            Id::String(s) => {
800                return fnv1a_64(s.as_bytes());
801            }
802        }
803    }
804    fnv1a_64_fields(&[
805        feature.timestamp,
806        feature.lon.to_bits(),
807        feature.lat.to_bits(),
808    ])
809}
810
811/// Resolve a stable u64 id for a clipped segment.
812fn segment_feature_id(segment: &ClippedSegment) -> u64 {
813    use geojson::feature::Id;
814
815    if let Some(id) = &segment.feature_id {
816        match id {
817            Id::Number(num) => {
818                if let Some(v) = num.as_u64() {
819                    return v;
820                }
821                if let Some(v) = num.as_i64() {
822                    return v as u64;
823                }
824            }
825            Id::String(s) => {
826                return fnv1a_64(s.as_bytes());
827            }
828        }
829    }
830    match segment.coordinates.first() {
831        Some((lon, lat, _)) => {
832            fnv1a_64_fields(&[segment.start_time, lon.to_bits(), lat.to_bits()])
833        }
834        None => fnv1a_64_fields(&[segment.start_time]),
835    }
836}
837
838#[cfg(test)]
839mod tests {
840    use super::*;
841    use geojson::{Feature, Geometry, Value as GeomValue};
842    use serde_json::json;
843
844    fn point_feature(lon: f64, lat: f64, props: serde_json::Value) -> ParsedFeature {
845        ParsedFeature {
846            geojson: Feature {
847                bbox: None,
848                geometry: Some(Geometry::new(GeomValue::Point(vec![lon, lat]))),
849                id: None,
850                properties: None,
851                foreign_members: None,
852            },
853            // Properties live in shared_properties (see input.rs).
854            shared_properties: props
855                .as_object()
856                .filter(|m| !m.is_empty())
857                .map(|m| std::sync::Arc::new(m.clone())),
858            timestamp: 1000,
859            end_timestamp: None,
860            vertex_timestamps: None,
861            vertex_values: None,
862            vertex_value_matrix: None,
863            lon,
864            lat,
865        }
866    }
867
868    fn line_feature(coords: Vec<[f64; 2]>, start: u64, end: Option<u64>) -> ParsedFeature {
869        let pts: Vec<Vec<f64>> = coords.iter().map(|c| vec![c[0], c[1]]).collect();
870        ParsedFeature {
871            geojson: Feature {
872                bbox: None,
873                geometry: Some(Geometry::new(GeomValue::LineString(pts))),
874                id: None,
875                properties: None,
876                foreign_members: None,
877            },
878            shared_properties: None,
879            timestamp: start,
880            end_timestamp: end,
881            vertex_timestamps: None,
882            vertex_values: None,
883            vertex_value_matrix: None,
884            lon: coords[0][0],
885            lat: coords[0][1],
886        }
887    }
888
889    #[test]
890    fn point_features_become_one_layer() {
891        let f1 = point_feature(-122.4, 37.7, json!({ "speed": 10.0, "kind": "car" }));
892        let f2 = point_feature(-122.5, 37.8, json!({ "speed": 20.0 }));
893        let refs = vec![&f1, &f2];
894        let layers = build_layers_from_features(&refs, "default").unwrap();
895        assert_eq!(layers.len(), 1);
896        assert_eq!(layers[0].feature_count(), 2);
897        // "kind" present only on f1 must still be discovered, with None for f2.
898        let kind = layers[0]
899            .properties
900            .iter()
901            .find(|(n, _)| n == "kind")
902            .expect("kind column");
903        match &kind.1 {
904            PropertyColumn::Categorical(v) => {
905                assert_eq!(v[0].as_deref(), Some("car"));
906                assert_eq!(v[1], None);
907            }
908            _ => panic!("kind should be categorical"),
909        }
910    }
911
912    #[test]
913    fn numeric_string_and_boolean_properties_are_classified() {
914        // Guards the columnar inference contract the typed writers rely on:
915        // numbers -> Numeric, strings -> Categorical, and booleans carried as
916        // Categorical "true"/"false" rather than silently dropped (the pre-fix
917        // behaviour matched neither arm in `observe`).
918        let f1 = point_feature(
919            -122.4,
920            37.7,
921            json!({ "altitude": 1000.0, "label": "alpha", "active": true }),
922        );
923        let f2 = point_feature(
924            -122.5,
925            37.8,
926            json!({ "altitude": 2000.0, "label": "beta", "active": false }),
927        );
928        let refs = vec![&f1, &f2];
929        let layers = build_layers_from_features(&refs, "default").unwrap();
930        let col = |name: &str| {
931            layers[0]
932                .properties
933                .iter()
934                .find(|(n, _)| n == name)
935                .map(|(_, c)| c)
936        };
937
938        match col("altitude").expect("altitude column") {
939            PropertyColumn::Numeric(v) => {
940                assert_eq!(v[0], Some(1000.0));
941                assert_eq!(v[1], Some(2000.0));
942            }
943            _ => panic!("altitude should be numeric"),
944        }
945        match col("label").expect("label column") {
946            PropertyColumn::Categorical(v) => {
947                assert_eq!(v[0].as_deref(), Some("alpha"));
948                assert_eq!(v[1].as_deref(), Some("beta"));
949            }
950            _ => panic!("label should be categorical"),
951        }
952        // Regression guard: the boolean column must be present (not dropped)
953        // and carried as a "true"/"false" categorical.
954        match col("active").expect("boolean column must be present, not dropped") {
955            PropertyColumn::Categorical(v) => {
956                assert_eq!(v[0].as_deref(), Some("true"));
957                assert_eq!(v[1].as_deref(), Some("false"));
958            }
959            _ => panic!("boolean should be carried as categorical"),
960        }
961    }
962
963    /// The keystone producer-drift fix: a property a generator encoded as
964    /// numeric *strings* (the line/polygon writer bug that flattened flights'
965    /// altitude) must still be classified numeric so it can drive ramps and
966    /// elevation — while a genuinely non-numeric string column stays categorical.
967    #[test]
968    fn numeric_strings_are_promoted_to_numeric() {
969        let f1 = point_feature(
970            -122.4,
971            37.7,
972            json!({ "altitude": "1000.0", "code": "A12", "mixed": "5" }),
973        );
974        let f2 = point_feature(
975            -122.5,
976            37.8,
977            json!({ "altitude": "2000", "code": "B7", "mixed": "n/a" }),
978        );
979        let layers = build_layers_from_features(&[&f1, &f2], "default").unwrap();
980        let col = |name: &str| {
981            layers[0]
982                .properties
983                .iter()
984                .find(|(n, _)| n == name)
985                .map(|(_, c)| c)
986        };
987
988        // All-numeric strings → promoted to a Numeric column.
989        match col("altitude").expect("altitude column") {
990            PropertyColumn::Numeric(v) => {
991                assert_eq!(v[0], Some(1000.0));
992                assert_eq!(v[1], Some(2000.0));
993            }
994            _ => panic!("string-encoded numbers should promote to numeric"),
995        }
996        // Non-numeric strings → stays categorical.
997        match col("code").expect("code column") {
998            PropertyColumn::Categorical(v) => {
999                assert_eq!(v[0].as_deref(), Some("A12"));
1000                assert_eq!(v[1].as_deref(), Some("B7"));
1001            }
1002            _ => panic!("non-numeric strings should stay categorical"),
1003        }
1004        // A column with *any* non-numeric value stays categorical (no partial
1005        // promotion that would silently null-out the "n/a" row).
1006        match col("mixed").expect("mixed column") {
1007            PropertyColumn::Categorical(v) => {
1008                assert_eq!(v[0].as_deref(), Some("5"));
1009                assert_eq!(v[1].as_deref(), Some("n/a"));
1010            }
1011            _ => panic!("mixed numeric/non-numeric column should stay categorical"),
1012        }
1013    }
1014
1015    /// `--exclude` drops the named property while leaving every other user
1016    /// property AND all system columns (id/start/end/geometry) intact.
1017    #[test]
1018    fn exclude_drops_only_named_property() {
1019        let f1 = point_feature(-122.4, 37.7, json!({ "speed": 10.0, "kind": "car", "name": "a" }));
1020        let f2 = point_feature(-122.5, 37.8, json!({ "speed": 20.0, "kind": "bus", "name": "b" }));
1021        let opts = ColumnarOptions {
1022            attribute_filter: AttributeFilter::Exclude(
1023                ["kind".to_string()].into_iter().collect(),
1024            ),
1025            ..Default::default()
1026        };
1027        let layers =
1028            build_layers_from_features_with(&[&f1, &f2], "default", opts).unwrap();
1029        let names: Vec<&str> = layers[0].properties.iter().map(|(n, _)| n.as_str()).collect();
1030        assert!(!names.contains(&"kind"), "excluded property must be gone");
1031        assert!(names.contains(&"speed"));
1032        assert!(names.contains(&"name"));
1033        // System columns untouched.
1034        assert_eq!(layers[0].feature_count(), 2);
1035        assert_eq!(layers[0].start_times.len(), 2);
1036        assert_eq!(layers[0].geometry.len(), 2);
1037    }
1038
1039    /// `--include` keeps ONLY the named properties (plus system columns).
1040    #[test]
1041    fn include_keeps_only_named_properties() {
1042        let f1 = point_feature(-122.4, 37.7, json!({ "speed": 10.0, "kind": "car", "name": "a" }));
1043        let f2 = point_feature(-122.5, 37.8, json!({ "speed": 20.0, "kind": "bus", "name": "b" }));
1044        let opts = ColumnarOptions {
1045            attribute_filter: AttributeFilter::Include(
1046                ["speed".to_string()].into_iter().collect(),
1047            ),
1048            ..Default::default()
1049        };
1050        let layers =
1051            build_layers_from_features_with(&[&f1, &f2], "default", opts).unwrap();
1052        let names: Vec<&str> = layers[0].properties.iter().map(|(n, _)| n.as_str()).collect();
1053        assert_eq!(names, vec!["speed"], "only the included property survives");
1054        // System columns untouched.
1055        assert_eq!(layers[0].feature_count(), 2);
1056        assert!(!layers[0].start_times.is_empty());
1057        assert!(!layers[0].end_times.is_empty());
1058    }
1059
1060    /// `--exclude-all` drops every user property but keeps system columns.
1061    #[test]
1062    fn exclude_all_drops_every_user_property() {
1063        let f1 = point_feature(-122.4, 37.7, json!({ "speed": 10.0, "kind": "car" }));
1064        let opts = ColumnarOptions {
1065            attribute_filter: AttributeFilter::ExcludeAll,
1066            ..Default::default()
1067        };
1068        let layers =
1069            build_layers_from_features_with(&[&f1], "default", opts).unwrap();
1070        assert!(layers[0].properties.is_empty(), "no user property survives");
1071        // System columns remain.
1072        assert_eq!(layers[0].feature_count(), 1);
1073        assert_eq!(layers[0].geometry.len(), 1);
1074    }
1075
1076    /// Declared property kinds pin the tile schema: a column that is all-null
1077    /// within one tile still yields a (all-null) column of the declared kind
1078    /// there, instead of vanishing — the cross-tile schema-drift regression a
1079    /// GeoParquet input with a sparsely-populated column hits (e.g. AIS `sog`
1080    /// null for every row that lands in one tile).
1081    #[test]
1082    fn declared_property_kinds_pin_schema_for_all_null_tiles() {
1083        // Tile A's features: `sog` present. Tile B's: `sog` all-null (absent
1084        // from the JSON map — how the parquet reader materialises NULL).
1085        let with_val = point_feature(-122.4, 37.7, json!({ "sog": 3.5, "class": "cargo" }));
1086        let all_null = point_feature(10.0, 50.0, json!({ "class": "tanker" }));
1087
1088        let declared: PropertyTypes = [
1089            ("sog".to_string(), PropertyKind::Numeric),
1090            ("class".to_string(), PropertyKind::Categorical),
1091        ]
1092        .into_iter()
1093        .collect();
1094        let opts = ColumnarOptions {
1095            property_types: Arc::new(declared),
1096            ..Default::default()
1097        };
1098
1099        // Without the declared map, this tile would have NO `sog` column.
1100        let tile_b =
1101            build_layers_from_features_with(&[&all_null], "default", opts.clone()).unwrap();
1102        let names_b: Vec<&str> =
1103            tile_b[0].properties.iter().map(|(n, _)| n.as_str()).collect();
1104        // finish() emits numeric columns first, then categorical.
1105        assert_eq!(names_b, vec!["sog", "class"], "declared columns always present");
1106        match &tile_b[0].properties.iter().find(|(n, _)| n == "sog").unwrap().1 {
1107            PropertyColumn::Numeric(v) => assert_eq!(v, &vec![None]),
1108            other => panic!("declared-numeric sog must stay Numeric, got {other:?}"),
1109        }
1110
1111        // The populated tile has the identical property schema.
1112        let tile_a =
1113            build_layers_from_features_with(&[&with_val], "default", opts).unwrap();
1114        let names_a: Vec<&str> =
1115            tile_a[0].properties.iter().map(|(n, _)| n.as_str()).collect();
1116        assert_eq!(names_a, names_b, "schema identical across tiles");
1117        match &tile_a[0].properties.iter().find(|(n, _)| n == "sog").unwrap().1 {
1118            PropertyColumn::Numeric(v) => assert_eq!(v, &vec![Some(3.5)]),
1119            other => panic!("expected Numeric sog, got {other:?}"),
1120        }
1121
1122        // Declared kind beats sniffed evidence: a numeric-looking string in a
1123        // declared-Categorical column stays categorical (schema authority).
1124        let numeric_string = point_feature(0.0, 0.0, json!({ "class": "42" }));
1125        let opts2 = ColumnarOptions {
1126            property_types: Arc::new(
1127                [("class".to_string(), PropertyKind::Categorical)].into_iter().collect(),
1128            ),
1129            ..Default::default()
1130        };
1131        let tile_c =
1132            build_layers_from_features_with(&[&numeric_string], "default", opts2).unwrap();
1133        match &tile_c[0].properties.iter().find(|(n, _)| n == "class").unwrap().1 {
1134            PropertyColumn::Categorical(v) => assert_eq!(v, &vec![Some("42".to_string())]),
1135            other => panic!("declared-categorical must stay Categorical, got {other:?}"),
1136        }
1137    }
1138
1139    /// Clipped-segment layers honour the same attribute filter.
1140    #[test]
1141    fn segment_layer_honours_attribute_filter() {
1142        use crate::clip::ClippedSegment;
1143        let props = json!({ "road": "main", "lanes": 4 })
1144            .as_object()
1145            .cloned()
1146            .map(std::sync::Arc::new);
1147        let seg = ClippedSegment {
1148            tile_x: 0,
1149            tile_y: 0,
1150            zoom: 10,
1151            coordinates: vec![(0.0, 0.0, 0.0), (1.0, 1.0, 0.0)],
1152            timestamps: vec![1000, 2000],
1153            vertex_values: vec![],
1154            vertex_value_matrix: vec![],
1155            start_time: 1000,
1156            end_time: 2000,
1157            properties: props,
1158            feature_id: None,
1159        };
1160        let layer = build_layer_from_segments(
1161            &[&seg],
1162            "tracks",
1163            &ColumnarOptions {
1164                attribute_filter: AttributeFilter::Include(
1165                    ["road".to_string()].into_iter().collect(),
1166                ),
1167                ..Default::default()
1168            },
1169        )
1170        .unwrap();
1171        let names: Vec<&str> = layer.properties.iter().map(|(n, _)| n.as_str()).collect();
1172        assert_eq!(names, vec!["road"]);
1173        // vertex_time is a SYSTEM column and must survive the filter.
1174        assert!(layer.vertex_times.is_some());
1175    }
1176
1177    #[test]
1178    fn mixed_geometry_types_split_into_separate_layers() {
1179        let pt = point_feature(0.0, 0.0, json!({}));
1180        let line = line_feature(vec![[0.0, 0.0], [1.0, 1.0]], 1000, None);
1181        let refs = vec![&pt, &line];
1182        let layers = build_layers_from_features(&refs, "default").unwrap();
1183        assert_eq!(layers.len(), 2);
1184        // Distinct, kind-suffixed names so a reader can tell them apart.
1185        let names: Vec<&str> = layers.iter().map(|l| l.name.as_str()).collect();
1186        assert!(names.contains(&"default_points"));
1187        assert!(names.contains(&"default_lines"));
1188    }
1189
1190    #[test]
1191    fn line_with_duration_gets_interpolated_vertex_times() {
1192        let line = line_feature(
1193            vec![[0.0, 0.0], [0.0, 1.0], [0.0, 2.0]],
1194            1000,
1195            Some(3000),
1196        );
1197        let refs = vec![&line];
1198        let layers = build_layers_from_features(&refs, "default").unwrap();
1199        let vt = layers[0].vertex_times.as_ref().expect("vertex times present");
1200        assert_eq!(vt[0].len(), 3);
1201        // Evenly spaced vertices -> first 1000, last 3000, middle ~2000.
1202        assert_eq!(vt[0][0], 1000);
1203        assert_eq!(vt[0][2], 3000);
1204        assert!((vt[0][1] - 2000).abs() <= 1);
1205    }
1206
1207    #[test]
1208    fn line_without_duration_has_no_vertex_times() {
1209        let line = line_feature(vec![[0.0, 0.0], [1.0, 1.0]], 1000, None);
1210        let refs = vec![&line];
1211        let layers = build_layers_from_features(&refs, "default").unwrap();
1212        assert!(layers[0].vertex_times.is_none());
1213    }
1214
1215    #[test]
1216    fn matrix_corridor_drops_dead_vertex_times() {
1217        // A flow corridor carries a per-vertex×bucket matrix and spans the whole
1218        // range (start..end), so build_line_layer WOULD interpolate per-vertex
1219        // times — but a matrix corridor is timeless (animated by the matrix), so
1220        // those times are dead weight and must be suppressed.
1221        let mut line = line_feature(vec![[0.0, 0.0], [0.0, 1.0], [0.0, 2.0]], 1000, Some(3000));
1222        // 3 vertices × 2 buckets, vertex-major (matrix.len() % verts == 0).
1223        line.vertex_value_matrix = Some(vec![5.0, 7.0, 5.0, 7.0, 5.0, 7.0]);
1224        let refs = vec![&line];
1225        let layers = build_layers_from_features(&refs, "default").unwrap();
1226        assert!(
1227            layers[0].vertex_times.is_none(),
1228            "matrix corridor must not carry a per-vertex time column"
1229        );
1230        // The matrix itself is still attached (it's the time signal).
1231        assert!(layers[0].vertex_value_matrix.is_some());
1232    }
1233
1234    /// Build a square polygon feature for the pre-tessellation tests.
1235    fn polygon_feature(corner: [f64; 2], size: f64) -> ParsedFeature {
1236        let [x, y] = corner;
1237        let ring: Vec<Vec<f64>> = vec![
1238            vec![x, y],
1239            vec![x + size, y],
1240            vec![x + size, y + size],
1241            vec![x, y + size],
1242            vec![x, y], // closing vertex
1243        ];
1244        ParsedFeature {
1245            geojson: Feature {
1246                bbox: None,
1247                geometry: Some(Geometry::new(GeomValue::Polygon(vec![ring]))),
1248                id: None,
1249                properties: None,
1250                foreign_members: None,
1251            },
1252            shared_properties: None,
1253            timestamp: 1000,
1254            end_timestamp: None,
1255            vertex_timestamps: None,
1256            vertex_values: None,
1257            vertex_value_matrix: None,
1258            lon: x,
1259            lat: y,
1260        }
1261    }
1262
1263    /// A square polygon with a square hole (two rings) — exercises the
1264    /// multi-ring auto-tessellation path.
1265    fn polygon_feature_with_hole() -> ParsedFeature {
1266        let exterior: Vec<Vec<f64>> = vec![
1267            vec![0.0, 0.0],
1268            vec![4.0, 0.0],
1269            vec![4.0, 4.0],
1270            vec![0.0, 4.0],
1271            vec![0.0, 0.0],
1272        ];
1273        let hole: Vec<Vec<f64>> = vec![
1274            vec![1.0, 1.0],
1275            vec![2.0, 1.0],
1276            vec![2.0, 2.0],
1277            vec![1.0, 2.0],
1278            vec![1.0, 1.0],
1279        ];
1280        ParsedFeature {
1281            geojson: Feature {
1282                bbox: None,
1283                geometry: Some(Geometry::new(GeomValue::Polygon(vec![exterior, hole]))),
1284                id: None,
1285                properties: None,
1286                foreign_members: None,
1287            },
1288            shared_properties: None,
1289            timestamp: 1000,
1290            end_timestamp: None,
1291            vertex_timestamps: None,
1292            vertex_values: None,
1293            vertex_value_matrix: None,
1294            lon: 2.0,
1295            lat: 2.0,
1296        }
1297    }
1298
1299    #[test]
1300    fn polygon_layer_omits_triangles_by_default() {
1301        let p = polygon_feature([0.0, 0.0], 1.0);
1302        let refs = vec![&p];
1303        let layers = build_layers_from_features(&refs, "default").unwrap();
1304        assert_eq!(layers.len(), 1);
1305        assert!(layers[0].triangles.is_none());
1306    }
1307
1308    #[test]
1309    fn multi_ring_polygon_auto_bakes_triangles_without_flag() {
1310        // A hole-bearing polygon CANNOT render through deck.gl's binary earcut
1311        // path (it bridges the exterior and hole rings with spanning
1312        // triangles). The builder must bake the hole-aware index sidecar even
1313        // when --pre-tessellate is OFF. A single-ring feature sharing the layer
1314        // is tessellated too (consistent per-tile index buffer).
1315        let holed = polygon_feature_with_hole();
1316        let simple = polygon_feature([10.0, 10.0], 1.0);
1317        let refs = vec![&holed, &simple];
1318        let layers = build_layers_from_features(&refs, "default").unwrap();
1319        assert_eq!(layers.len(), 1);
1320        let tri = layers[0]
1321            .triangles
1322            .as_ref()
1323            .expect("multi-ring layer must auto-bake triangles even without the flag");
1324        assert_eq!(tri.len(), 2);
1325        // The holed square (8 distinct verts across two rings) tessellates into
1326        // a ring of quads = 8 triangles = 24 indices; every index stays within
1327        // the feature's 10 vertices (5 per closed ring), never bridging out.
1328        assert!(!tri[0].is_empty() && tri[0].len() % 3 == 0);
1329        for &i in &tri[0] {
1330            assert!((i as usize) < 10, "triangle index escapes the feature");
1331        }
1332        // The simple square still earcuts to two triangles.
1333        assert_eq!(tri[1].len(), 6);
1334    }
1335
1336    /// Synthetic ids MUST come from the documented FNV-1a 64 (never
1337    /// `DefaultHasher`, whose algorithm may change between Rust releases and
1338    /// would churn every content address). Pinned against the published FNV
1339    /// test vectors plus the exact composition rules for both id paths, so
1340    /// any accidental change to the hash breaks loudly here.
1341    #[test]
1342    fn synthetic_ids_use_stable_fnv1a() {
1343        // Published FNV-1a 64 vectors: "" → offset basis, "a", "foobar".
1344        assert_eq!(fnv1a_64(b""), 0xcbf2_9ce4_8422_2325);
1345        assert_eq!(fnv1a_64(b"a"), 0xaf63_dc4c_8601_ec8c);
1346        assert_eq!(fnv1a_64(b"foobar"), 0x85944171f73967e8);
1347
1348        // String-id path: FNV-1a over the raw UTF-8 bytes.
1349        let mut f = point_feature(-122.4, 37.7, json!({}));
1350        f.geojson.id = Some(geojson::feature::Id::String("quake-42".to_string()));
1351        assert_eq!(determine_feature_id(&f), fnv1a_64(b"quake-42"));
1352
1353        // Synthetic fallback: little-endian (timestamp, lon bits, lat bits).
1354        let f2 = point_feature(-122.4, 37.7, json!({}));
1355        assert_eq!(
1356            determine_feature_id(&f2),
1357            fnv1a_64_fields(&[1000, (-122.4f64).to_bits(), 37.7f64.to_bits()])
1358        );
1359
1360        // Segment fallback: little-endian (start_time, first lon bits, first lat bits).
1361        let seg = crate::clip::ClippedSegment {
1362            tile_x: 0,
1363            tile_y: 0,
1364            zoom: 10,
1365            coordinates: vec![(1.5, 2.5, 0.0)],
1366            timestamps: vec![7],
1367            vertex_values: vec![],
1368            vertex_value_matrix: vec![],
1369            start_time: 7,
1370            end_time: 7,
1371            properties: None,
1372            feature_id: None,
1373        };
1374        assert_eq!(
1375            segment_feature_id(&seg),
1376            fnv1a_64_fields(&[7, 1.5f64.to_bits(), 2.5f64.to_bits()])
1377        );
1378    }
1379
1380    #[test]
1381    fn pre_tessellate_option_bakes_triangle_indices_per_feature() {
1382        let p1 = polygon_feature([0.0, 0.0], 1.0);
1383        let p2 = polygon_feature([5.0, 5.0], 2.0);
1384        let refs = vec![&p1, &p2];
1385        let layers = build_layers_from_features_with(
1386            &refs,
1387            "default",
1388            ColumnarOptions {
1389                pre_tessellate: true,
1390                ..Default::default()
1391            },
1392        )
1393        .unwrap();
1394        assert_eq!(layers.len(), 1);
1395        let tri = layers[0]
1396            .triangles
1397            .as_ref()
1398            .expect("triangles populated when pre_tessellate is on");
1399        assert_eq!(tri.len(), 2);
1400        // Each square produces exactly two triangles → 6 indices.
1401        assert_eq!(tri[0].len(), 6);
1402        assert_eq!(tri[1].len(), 6);
1403        // Indices reference the 5 coords of that feature's exterior ring.
1404        for &i in &tri[0] {
1405            assert!(i < 5);
1406        }
1407    }
1408}