Skip to main content

stt_build/
tiler.rs

1//! Tile generation: clip trajectories, bucket features spatially and
2//! temporally, and emit Arrow [`ColumnarLayer`]s per tile.
3
4use crate::clip::{clip_trajectory, is_clippable_trajectory, ClipConfig, ClippedSegment};
5use crate::columnar::{
6    build_layer_from_segments, build_layers_from_features_with, AttributeFilter, ColumnarOptions,
7};
8use crate::input::ParsedFeature;
9use anyhow::Result;
10use rayon::prelude::*;
11use std::collections::{BTreeMap, HashMap};
12use std::sync::atomic::{AtomicUsize, Ordering};
13use std::sync::Arc;
14use stt_core::arrow_tile::ColumnarLayer;
15#[cfg(test)]
16use stt_core::arrow_tile::encode_tile;
17use stt_core::budget::TileBudget;
18use stt_core::projection;
19use stt_core::tile::TileId;
20
21/// A generated tile: its identity, temporal span, and Arrow layers.
22#[derive(Debug)]
23pub struct GeneratedTile {
24    /// Tile identity.
25    pub id: TileId,
26    /// Inclusive temporal start (Unix ms) — the addressable bucket boundary.
27    pub time_start: i64,
28    /// Inclusive temporal end (Unix ms) — the latest feature end in the tile.
29    pub time_end: i64,
30    /// Tight lower covering bound: the earliest feature *start* time actually
31    /// present in the tile (≤ `time_end`, and may be ≥ or < `time_start`).
32    /// Stored in the directory so a client can prune a tile whose data lies
33    /// entirely after a query window. See `TileEntry::cover_t_min`.
34    pub cover_t_min: i64,
35    /// One or more Arrow layers (grouped by geometry kind / clip status).
36    pub layers: Vec<ColumnarLayer>,
37}
38
39impl GeneratedTile {
40    /// Total feature count across the tile's layers.
41    pub fn feature_count(&self) -> u32 {
42        self.layers.iter().map(|l| l.feature_count() as u32).sum()
43    }
44}
45
46/// Sink for generated tiles (lets the tiler stream into an archive).
47pub trait TileWriter {
48    /// Persist one tile.
49    fn write_tile(&mut self, tile: &GeneratedTile) -> Result<()>;
50
51    /// Persist a batch of tiles. Implementations MAY encode payloads in
52    /// parallel but MUST hand tiles to storage in exactly the given order —
53    /// callers rely on a deterministic write sequence for byte-reproducible
54    /// output. The default forwards to [`write_tile`](Self::write_tile) one
55    /// by one.
56    fn write_tiles(&mut self, tiles: &[&GeneratedTile]) -> Result<()> {
57        for tile in tiles {
58            self.write_tile(tile)?;
59        }
60        Ok(())
61    }
62}
63
64/// Statistics from a tile-generation run.
65#[derive(Debug, Default)]
66pub struct TileStats {
67    /// Total tiles produced.
68    pub total_tiles: usize,
69    /// Clipped trajectory segments emitted.
70    pub clipped_segments: usize,
71    /// Un-clipped original features emitted.
72    pub original_features: usize,
73    /// Feature placements dropped because the position could not be projected
74    /// (lon ∉ [-180, 180], |lat| beyond the Web-Mercator clamp, or non-finite).
75    /// Counted per (feature, zoom) placement attempt and reported once at the
76    /// end of the build — never silently, never into tile (0, 0).
77    pub dropped_invalid_coords: usize,
78    /// Features placed whole into a single tile because their bbox spans more
79    /// than 180° of longitude (antimeridian-crossing; wrap-aware polygon
80    /// clipping is a documented limitation). Counted per (feature, zoom).
81    pub antimeridian_fallbacks: usize,
82}
83
84/// Configuration for tile generation.
85#[derive(Debug, Clone)]
86pub struct TileConfig {
87    /// Minimum zoom level.
88    pub min_zoom: u8,
89    /// Maximum zoom level.
90    pub max_zoom: u8,
91    /// Base layer name.
92    pub layer_name: String,
93    /// Temporal bucket size (ms) for chunking tiles into aligned intervals.
94    pub temporal_bucket_ms: u64,
95    /// Whether to clip LineString trajectories at tile boundaries.
96    pub clip_trajectories: bool,
97    /// Whether to clip NON-trajectory geometry (polygons, MultiPolygons,
98    /// timeless LineStrings/MultiLineStrings, MultiPoints) at tile boundaries
99    /// so a feature spanning several tiles is present — clipped — in each of
100    /// them. Default TRUE. `false` (`--whole-feature-placement`) restores the
101    /// legacy behaviour: the whole feature lands only in the single tile
102    /// containing its representative point, and neighbouring tiles render a
103    /// hole. Points are single-tile either way (they can't span tiles).
104    pub clip_non_trajectory: bool,
105    /// Minimum vertices required before a trajectory is clipped.
106    pub clip_min_vertices: usize,
107    /// Whether to simplify geometry at lower zoom levels.
108    pub simplify: bool,
109    /// Highest zoom that still receives simplification.
110    pub simplify_max_zoom: u8,
111    /// When true, polygon layers carry pre-baked earcut triangle indices in a
112    /// `triangles` sidecar column — letting the renderer skip its own CPU
113    /// tessellation at tile-arrival time.
114    pub pre_tessellate: bool,
115    /// Optional temporal LOD pyramid. When non-empty, the build emits an
116    /// extra aggregate tile per (zoom, spatial cell, lod-bucket) using the
117    /// LOD level's `bucket_ms` instead of the base `temporal_bucket_ms`.
118    /// Each level applies up to (and including) `max_zoom_level`. Levels
119    /// MUST be sorted by ascending bucket size and every bucket MUST be a
120    /// multiple of the base bucket.
121    pub temporal_lod: Vec<stt_core::metadata::TemporalLodLevel>,
122    /// Drop tiles whose feature_count is below this threshold. Default 1
123    /// (write every non-empty tile). For globally sparse point datasets, a
124    /// threshold like 2 skips the long tail of single-feature deep-zoom
125    /// tiles where per-tile Arrow IPC + zstd-frame overhead dominates the
126    /// payload. The renderer relies on the tileset's parent-fallback
127    /// strategy to surface those features at shallower zooms.
128    pub min_features_per_tile: u32,
129    /// Use time-aware TD-TR (Synchronized Euclidean Distance) simplification
130    /// instead of plain spatial Visvalingam. Preserves per-vertex timing —
131    /// important for temporal LOD so zoomed-out playback keeps moving objects
132    /// in the right place at the right time.
133    pub time_aware_simplify: bool,
134    /// When set, replaces fixed `temporal_bucket_ms` chunking with adaptive
135    /// windows of ~this many features each: dense periods get fine time windows,
136    /// sparse periods coarse ones (the tippecanoe `--maximum-tile-features`
137    /// idea applied to the time axis). Each window becomes one tile with its own
138    /// `[time_start, time_end]`. In-memory path only (the streaming path keeps
139    /// fixed buckets).
140    pub adaptive_target_features: Option<u32>,
141    /// When set, the named per-feature numeric property is a road-class-style
142    /// LOD floor: a feature is SKIPPED at any zoom below its value (vector-tile
143    /// "show major roads when zoomed out"). Whole-feature inclusion only — the
144    /// feature's geometry/attributes (incl. the value matrix) are untouched.
145    /// `None` = no filter (every feature at every zoom in range).
146    pub min_zoom_field: Option<String>,
147    /// When set, the named per-feature numeric property is a LOD *ceiling*: a
148    /// feature is SKIPPED at any zoom ABOVE its value. Paired with
149    /// [`Self::min_zoom_field`] it confines a feature to a zoom BAND
150    /// `[min_zoom, max_zoom]` — e.g. coarse-zoom clustered/aggregated features
151    /// that must NOT bleed into the full-resolution deep zooms. Whole-feature
152    /// inclusion only — geometry/attributes (incl. the value matrix) untouched.
153    /// `None` = no ceiling (a feature appears at every zoom ≥ its `min_zoom`).
154    pub max_zoom_field: Option<String>,
155    /// Opt-in per-tile size/feature budget (tippecanoe
156    /// `--maximum-tile-bytes`/`--maximum-tile-features`). `None` (the default)
157    /// means NO budget — every feature gathered for a tile is emitted, byte-for-
158    /// byte identical to a build without the flags. When `Some`, a tile whose
159    /// gathered features exceed the cap has its lowest-importance features
160    /// dropped to fit (importance-scored, never random), and the exact dropped
161    /// count is logged per affected tile. Honours the project's "no thinning by
162    /// default" principle: inert unless explicitly opted in.
163    pub tile_budget: Option<TileBudget>,
164    /// Opt-in user-property selection (`--exclude`/`--include`/`--exclude-all`).
165    /// Default [`AttributeFilter::KeepAll`] — every user property kept. System
166    /// columns always survive regardless.
167    pub attribute_filter: AttributeFilter,
168    /// Authoritative per-property kinds from the input source's schema (see
169    /// [`crate::columnar::ColumnarOptions::property_types`]). GeoParquet/DB
170    /// inputs populate this so a column that is all-null within one tile still
171    /// gets its column there — per-tile value sniffing otherwise drops it and
172    /// the layer schema drifts across tiles. Default empty (schema-less
173    /// producers keep sniffing).
174    pub property_types: Arc<crate::columnar::PropertyTypes>,
175}
176
177impl Default for TileConfig {
178    fn default() -> Self {
179        Self {
180            min_zoom: 0,
181            max_zoom: 14,
182            layer_name: "default".to_string(),
183            temporal_bucket_ms: 3600 * 1000,
184            clip_trajectories: true,
185            clip_non_trajectory: true,
186            clip_min_vertices: 2,
187            simplify: false,
188            simplify_max_zoom: 14,
189            pre_tessellate: false,
190            temporal_lod: Vec::new(),
191            min_features_per_tile: 1,
192            time_aware_simplify: false,
193            adaptive_target_features: None,
194            min_zoom_field: None,
195            max_zoom_field: None,
196            tile_budget: None,
197            attribute_filter: AttributeFilter::KeepAll,
198            property_types: Arc::default(),
199        }
200    }
201}
202
203impl TileConfig {
204    /// Project to the lower-level `ColumnarOptions` consumed by the columnar
205    /// builders. Keeps `tiler` from leaking columnar-level concerns.
206    fn columnar_options(&self) -> ColumnarOptions {
207        ColumnarOptions {
208            pre_tessellate: self.pre_tessellate,
209            attribute_filter: self.attribute_filter.clone(),
210            property_types: Arc::clone(&self.property_types),
211        }
212    }
213}
214
215impl TileConfig {
216    /// Return the LOD level that applies at `zoom`, if any. Mirrors
217    /// `stt_core::metadata::Metadata::temporal_lod_for_zoom` — the coarsest
218    /// applicable level wins.
219    pub fn lod_for_zoom(&self, zoom: u8) -> Option<&stt_core::metadata::TemporalLodLevel> {
220        self.temporal_lod
221            .iter()
222            .filter(|l| zoom <= l.max_zoom_level)
223            .max_by_key(|l| l.bucket_ms)
224    }
225}
226
227/// A generated tile tagged with the temporal-LOD bucket it represents.
228///
229/// `bucket_ms == None` means "base tile" (use the archive's
230/// `temporal_bucket_ms`). `Some(b)` means this is an aggregate tile produced
231/// for an LOD level; the writer records `b` in the directory so the reader
232/// can dispatch on bucket size at lookup time.
233#[derive(Debug)]
234pub struct LodTaggedTile {
235    pub tile: GeneratedTile,
236    pub temporal_bucket_ms: Option<u64>,
237}
238
239/// A feature assigned to a tile — an original feature, a per-tile piece of a
240/// clipped non-trajectory feature, or a clipped trajectory segment.
241#[derive(Debug, Clone)]
242enum TileFeature<'a> {
243    Original(&'a ParsedFeature),
244    /// Owned per-tile piece of a clipped NON-trajectory feature (polygon,
245    /// timeless line, MultiPoint member). Carries the parent's properties,
246    /// times, id and representative point (so id-less pieces hash to the SAME
247    /// synthetic feature id in every tile they span) with per-tile clipped
248    /// geometry. Routed through the same layer builders as `Original`.
249    ///
250    /// EXCEPTION — MultiPoint members carry their OWN coordinates as
251    /// `lon`/`lat`: for point layers that pair IS the emitted geometry
252    /// (`build_point_layer` reads `f.lon`/`f.lat`), so the parent point
253    /// would collapse every member onto one location. Identity is unharmed:
254    /// pieces inherit the parent's explicit id when it has one, and id-less
255    /// point ids are rewritten to per-tile row indices in
256    /// `build_point_layer` before the synthetic hash ever reaches bytes.
257    Derived(ParsedFeature),
258    Clipped(ClippedSegment),
259}
260
261impl<'a> TileFeature<'a> {
262    fn timestamp(&self) -> u64 {
263        match self {
264            TileFeature::Original(f) => f.timestamp,
265            TileFeature::Derived(f) => f.timestamp,
266            TileFeature::Clipped(s) => s.start_time,
267        }
268    }
269
270    fn end_timestamp(&self) -> u64 {
271        match self {
272            TileFeature::Original(f) => f.end_timestamp.unwrap_or(f.timestamp),
273            TileFeature::Derived(f) => f.end_timestamp.unwrap_or(f.timestamp),
274            TileFeature::Clipped(s) => s.end_time,
275        }
276    }
277}
278
279/// Per-build counters for placements that could not be performed normally.
280/// Surfaced ONCE at the end of a build via [`Self::report`] (never per-feature
281/// spam) — the "no silent drops" guarantee for the placement stage.
282#[derive(Debug, Default)]
283struct PlacementCounters {
284    /// Placements dropped because the position could not be projected
285    /// (previously `unwrap_or((0, 0))` filed these into the top-left world
286    /// tile as phantom features). Counted per (feature, zoom).
287    invalid_coords: AtomicUsize,
288    /// Features that fell back to legacy whole-feature single-tile placement
289    /// because their bbox spans more than 180° of longitude.
290    antimeridian_fallback: AtomicUsize,
291}
292
293impl PlacementCounters {
294    fn report(&self) {
295        let invalid = self.invalid_coords.load(Ordering::Relaxed);
296        if invalid > 0 {
297            tracing::warn!(
298                "dropped {invalid} feature placement(s) (per feature × zoom) with \
299                 coordinates outside the Web-Mercator domain (lon∉[-180,180], \
300                 |lat|>85.0511, or non-finite) — NOT written to any tile"
301            );
302        }
303        let anti = self.antimeridian_fallback.load(Ordering::Relaxed);
304        if anti > 0 {
305            tracing::warn!(
306                "{anti} feature placement(s) (per feature × zoom) used whole-feature \
307                 single-tile fallback because their bbox spans >180° of longitude \
308                 (antimeridian-crossing clipping is a documented limitation)"
309            );
310        }
311    }
312}
313
314/// Generate every tile into memory across all configured zoom levels,
315/// returning base tiles + LOD aggregate tiles tagged with their bucket size.
316///
317/// The base tiles are tagged `Some(config.temporal_bucket_ms)` so the
318/// writer can record the bucket size on every directory entry — that's
319/// what makes the reader's LOD dispatch possible without ambiguity at the
320/// `(z, x, y, t)` lookup level.
321///
322/// LOD tiles are emitted alongside base tiles at the same spatial cell;
323/// each LOD level produces one tile per (zoom, cell, bucket-of-that-level).
324pub fn generate_tiles_with_lod(
325    features: &[ParsedFeature],
326    config: &TileConfig,
327    workers: usize,
328) -> Result<Vec<LodTaggedTile>> {
329    validate_lod(config)?;
330    let base = generate_tiles(features, config, workers)?;
331    let mut out: Vec<LodTaggedTile> = base
332        .into_iter()
333        .map(|tile| LodTaggedTile {
334            tile,
335            temporal_bucket_ms: Some(config.temporal_bucket_ms),
336        })
337        .collect();
338    if !config.temporal_lod.is_empty() {
339        let pool = build_pool(workers)?;
340        pool.install(|| -> Result<()> {
341            for level in &config.temporal_lod {
342                let lod_tiles = generate_lod_level(features, level, config)?;
343                tracing::info!(
344                    "temporal LOD level bucket={}ms max_zoom={}: {} tiles",
345                    level.bucket_ms,
346                    level.max_zoom_level,
347                    lod_tiles.len()
348                );
349                for tile in lod_tiles {
350                    out.push(LodTaggedTile {
351                        tile,
352                        temporal_bucket_ms: Some(level.bucket_ms),
353                    });
354                }
355            }
356            Ok(())
357        })?;
358    }
359    Ok(out)
360}
361
362/// Emit aggregate tiles for one temporal LOD level.
363///
364/// The aggregator follows the spatial-summary pattern: features are placed
365/// onto the spatial tile grid, then *re-bucketed by the LOD's bucket size*
366/// rather than the base. Each (zoom, spatial cell, lod_bucket) becomes one
367/// aggregate tile. Within a tile the existing layer builder handles the
368/// per-cell aggregation (sum/mean/count fall out of the regrouping for
369/// numeric properties).
370///
371/// The scaffold *re-bucketes only* — feature-level simplification (collapse
372/// 1000 points per cell into 50 means) is left as a follow-up; the format
373/// already supports it because the per-tile aggregator is plugged in here.
374fn generate_lod_level(
375    features: &[ParsedFeature],
376    level: &stt_core::metadata::TemporalLodLevel,
377    base_config: &TileConfig,
378) -> Result<Vec<GeneratedTile>> {
379    // Reuse the base tile config but override the temporal bucket size for
380    // this level, and clamp the zoom range to the level's reach. Clipping +
381    // simplification stays on so trajectories that span the LOD bucket are
382    // still decomposed cell-by-cell.
383    let lod_config = TileConfig {
384        temporal_bucket_ms: level.bucket_ms,
385        max_zoom: base_config.max_zoom.min(level.max_zoom_level),
386        temporal_lod: Vec::new(), // do NOT recurse
387        ..base_config.clone()
388    };
389    if lod_config.max_zoom < lod_config.min_zoom {
390        // The LOD level's max_zoom_level falls below the archive's min_zoom;
391        // nothing to emit (no spatial zoom in range).
392        return Ok(Vec::new());
393    }
394    let clip_config = clip_config_from(&lod_config);
395    let total_clipped = AtomicUsize::new(0);
396    let total_original = AtomicUsize::new(0);
397    let counters = PlacementCounters::default();
398    let mut all = Vec::new();
399    for zoom in lod_config.min_zoom..=lod_config.max_zoom {
400        let tiles = process_zoom_level(
401            features,
402            zoom,
403            &lod_config,
404            &clip_config,
405            &total_clipped,
406            &total_original,
407            &counters,
408        )?;
409        all.extend(tiles);
410    }
411    counters.report();
412    Ok(all)
413}
414
415/// Validate every level against the archive's base bucket. Mirrors the
416/// invariants enforced by `Metadata::with_temporal_lod` so a TileConfig
417/// built independently can't slip a bad pyramid past the type checker.
418fn validate_lod(config: &TileConfig) -> Result<()> {
419    if config.temporal_lod.is_empty() {
420        return Ok(());
421    }
422    let base = config.temporal_bucket_ms;
423    anyhow::ensure!(base > 0, "temporal_bucket_ms must be > 0 when using LOD");
424    let mut prev: Option<u64> = None;
425    for (i, level) in config.temporal_lod.iter().enumerate() {
426        anyhow::ensure!(
427            level.bucket_ms > base,
428            "temporal_lod[{i}].bucket_ms ({}) must be > base bucket ({})",
429            level.bucket_ms,
430            base
431        );
432        anyhow::ensure!(
433            level.bucket_ms % base == 0,
434            "temporal_lod[{i}].bucket_ms ({}) must be a multiple of base ({})",
435            level.bucket_ms,
436            base
437        );
438        if let Some(p) = prev {
439            anyhow::ensure!(
440                level.bucket_ms > p,
441                "temporal_lod must be sorted by ascending bucket_ms"
442            );
443        }
444        prev = Some(level.bucket_ms);
445    }
446    Ok(())
447}
448
449/// Generate every tile into memory (one zoom level processed at a time).
450pub fn generate_tiles(
451    features: &[ParsedFeature],
452    config: &TileConfig,
453    workers: usize,
454) -> Result<Vec<GeneratedTile>> {
455    let pool = build_pool(workers)?;
456    let clip_config = clip_config_from(config);
457    let total_clipped = AtomicUsize::new(0);
458    let total_original = AtomicUsize::new(0);
459    let counters = PlacementCounters::default();
460    let mut all = Vec::new();
461
462    // Install the scoped pool for the duration of the build. Anything inside
463    // `pool.install(...)` that hits a rayon parallel-iterator runs there
464    // rather than the (possibly already-initialised) global pool.
465    pool.install(|| -> Result<()> {
466        for zoom in config.min_zoom..=config.max_zoom {
467            let start = std::time::Instant::now();
468            let tiles = process_zoom_level(
469                features,
470                zoom,
471                config,
472                &clip_config,
473                &total_clipped,
474                &total_original,
475                &counters,
476            )?;
477            tracing::info!(
478                "zoom {}: {} tiles in {:.1}s",
479                zoom,
480                tiles.len(),
481                start.elapsed().as_secs_f64()
482            );
483            all.extend(tiles);
484        }
485        Ok(())
486    })?;
487    counters.report();
488    Ok(all)
489}
490
491/// Encode exactly one tile `(z, x, y, t)` from a candidate feature set, without
492/// running the whole-dataset build (no rayon pool, no pack/directory writer, no
493/// cross-tile state). The returned bytes are an **uncompressed** STT layer-frame
494/// tile payload (Arrow IPC + GeoArrow geometry) — byte-identical to the frame
495/// the offline build feeds INTO the pack writer *before* per-blob zstd, so a
496/// dynamic server (`stt-serve`) can hand it out directly (it does its own
497/// transport compression). `Ok(None)` means the tile is empty.
498///
499/// This is the reusable core a dynamic per-request tile server (`stt-serve`)
500/// calls. `features` is the caller's candidate set — typically already narrowed
501/// by a PostGIS bbox + time-window query; this function performs the
502/// authoritative per-tile placement, clipping, temporal bucketing and encoding,
503/// so it stays byte-identical to the offline `process_zoom_level` path.
504///
505/// `t` selects the temporal bucket: the tile covers
506/// `[floor(t/bucket)*bucket, …)`, matching [`chunk_by_temporal_bucket`].
507///
508/// Also returns the number of features placed in the tile (after
509/// clipping/placement/budget), so a dynamic server can apply a
510/// `min_features_per_tile` gate identically to the offline build's writer loop.
511/// `Ok(None)` means the tile is empty.
512pub fn encode_single_tile_counted(
513    features: &[ParsedFeature],
514    z: u8,
515    x: u32,
516    y: u32,
517    t: i64,
518    config: &TileConfig,
519    encoder: &stt_core::arrow_tile::EncoderConfig,
520) -> Result<Option<(Vec<u8>, u32)>> {
521    let clip_config = clip_config_from(config);
522    let bucket_ms = config.temporal_bucket_ms.max(1);
523    let bucket_start = (t.max(0) as u64 / bucket_ms) * bucket_ms;
524
525    // Place each feature for this zoom, keeping only what lands in (x, y) — the
526    // same clip-or-coverage placement `process_zoom_level` performs.
527    let counters = PlacementCounters::default();
528    let mut chunk: Vec<TileFeature> = Vec::new();
529    for feature in features {
530        if feature_out_of_band(feature, z, config) {
531            continue;
532        }
533        for (fx, fy, tf) in place_feature(feature, z, config, &clip_config, &counters, Some((x, y)))
534        {
535            if fx == x && fy == y {
536                chunk.push(tf);
537            }
538        }
539    }
540    counters.report();
541
542    // Keep only the requested temporal bucket (matches chunk_by_temporal_bucket).
543    chunk.retain(|f| (f.timestamp() / bucket_ms) * bucket_ms == bucket_start);
544    if chunk.is_empty() {
545        return Ok(None);
546    }
547
548    let time_end = chunk
549        .iter()
550        .map(|f| f.end_timestamp())
551        .max()
552        .unwrap_or(bucket_start + bucket_ms);
553    let id = TileId::new(z, x, y, bucket_start);
554    match build_tile(id, &chunk, config, bucket_start as i64, time_end as i64)? {
555        Some(tile) => {
556            let feature_count = tile.feature_count();
557            // Encode with the caller's explicit encoder config (no globals), so a
558            // dynamic server can serve several datasets/requests with different
559            // settings concurrently without touching shared state.
560            Ok(Some((
561                stt_core::arrow_tile::encode_tile_with(&tile.layers, encoder)?,
562                feature_count,
563            )))
564        }
565        None => Ok(None),
566    }
567}
568
569/// Encode exactly one tile `(z, x, y, t)`, discarding the placed-feature count.
570/// The convenience form of [`encode_single_tile_counted`] for callers that don't
571/// apply a `min_features_per_tile` gate. `Ok(None)` means the tile is empty.
572pub fn encode_single_tile(
573    features: &[ParsedFeature],
574    z: u8,
575    x: u32,
576    y: u32,
577    t: i64,
578    config: &TileConfig,
579    encoder: &stt_core::arrow_tile::EncoderConfig,
580) -> Result<Option<Vec<u8>>> {
581    Ok(encode_single_tile_counted(features, z, x, y, t, config, encoder)?.map(|(bytes, _)| bytes))
582}
583
584/// Generate tiles and stream them straight into a [`TileWriter`], bounding
585/// memory to a single zoom level at a time.
586pub fn generate_tiles_streaming<W: TileWriter + Send>(
587    features: &[ParsedFeature],
588    config: &TileConfig,
589    writer: &mut W,
590    workers: usize,
591) -> Result<TileStats> {
592    let pool = build_pool(workers)?;
593    let clip_config = clip_config_from(config);
594    let total_clipped = AtomicUsize::new(0);
595    let total_original = AtomicUsize::new(0);
596    let counters = PlacementCounters::default();
597    let mut total_tiles = 0;
598
599    pool.install(|| -> Result<()> {
600        for zoom in config.min_zoom..=config.max_zoom {
601            let start = std::time::Instant::now();
602            let tiles = process_zoom_level(
603                features,
604                zoom,
605                config,
606                &clip_config,
607                &total_clipped,
608                &total_original,
609                &counters,
610            )?;
611            let min_features = config.min_features_per_tile.max(1);
612            let keep: Vec<&GeneratedTile> = tiles
613                .iter()
614                .filter(|t| t.feature_count() >= min_features)
615                .collect();
616            // Batch write: a PackWriter sink encodes the payloads in parallel
617            // (we're inside the --workers pool here) while handing tiles to
618            // storage in this exact deterministic order.
619            writer.write_tiles(&keep)?;
620            let written = keep.len();
621            total_tiles += written;
622            tracing::info!(
623                "zoom {}: {} tiles written (of {} generated) in {:.1}s",
624                zoom,
625                written,
626                tiles.len(),
627                start.elapsed().as_secs_f64()
628            );
629        }
630        Ok(())
631    })?;
632    counters.report();
633
634    Ok(TileStats {
635        total_tiles,
636        clipped_segments: total_clipped.load(Ordering::Relaxed),
637        original_features: total_original.load(Ordering::Relaxed),
638        dropped_invalid_coords: counters.invalid_coords.load(Ordering::Relaxed),
639        antimeridian_fallbacks: counters.antimeridian_fallback.load(Ordering::Relaxed),
640    })
641}
642
643/// Build a rayon thread pool scoped to a single build run.
644///
645/// The previous implementation called `build_global()` and silently swallowed
646/// the error if some other caller (or a previous build in the same process)
647/// had already initialised the global pool, so `--workers N` was effectively
648/// ignored after the first run. This builds a fresh local pool so the worker
649/// count is always honoured.
650fn build_pool(workers: usize) -> Result<rayon::ThreadPool> {
651    let threads = workers.max(1);
652    rayon::ThreadPoolBuilder::new()
653        .num_threads(threads)
654        .thread_name(|i| format!("stt-build-{i}"))
655        .build()
656        .map_err(|e| anyhow::anyhow!("failed to build rayon pool: {e}"))
657}
658
659fn clip_config_from(config: &TileConfig) -> ClipConfig {
660    ClipConfig {
661        min_vertices: config.clip_min_vertices,
662        buffer_degrees: 0.001,
663        polygon_buffer_degrees: 0.0,
664        // With adaptive temporal windows there's no fixed grid to slice
665        // trajectories against, so disable fixed-bucket temporal slicing in that
666        // mode; segments are assigned to a window by their start time instead.
667        temporal_granularity_ms: if config.adaptive_target_features.is_some() {
668            None
669        } else {
670            Some(config.temporal_bucket_ms)
671        },
672        simplify: config.simplify,
673        simplify_max_zoom: config.simplify_max_zoom,
674        time_aware_simplify: config.time_aware_simplify,
675    }
676}
677
678/// Process a single zoom level: clip in parallel, bucket spatially then
679/// temporally, and build each tile's layers.
680/// Read a feature's LOD floor from the configured `min_zoom_field` property:
681/// the shallowest zoom the feature appears at. `None` = always shown.
682fn feature_min_zoom(feature: &ParsedFeature, field: &Option<String>) -> Option<u8> {
683    feature_zoom_bound(feature, field)
684}
685
686/// Read a feature's LOD ceiling from the configured `max_zoom_field` property:
687/// the deepest zoom the feature appears at. `None` = no ceiling.
688fn feature_max_zoom(feature: &ParsedFeature, field: &Option<String>) -> Option<u8> {
689    feature_zoom_bound(feature, field)
690}
691
692/// Shared reader for the per-feature numeric zoom-bound properties
693/// (`min_zoom_field` / `max_zoom_field`).
694fn feature_zoom_bound(feature: &ParsedFeature, field: &Option<String>) -> Option<u8> {
695    let field = field.as_deref()?;
696    feature
697        .shared_properties
698        .as_ref()?
699        .get(field)
700        .and_then(|v| v.as_f64())
701        .map(|z| z.round() as u8)
702}
703
704/// `true` when `zoom` falls outside a feature's configured `[min_zoom,
705/// max_zoom]` band (either bound absent = open on that side). Whole-feature
706/// skip — callers return before any clip so the value matrix is never touched.
707fn feature_out_of_band(feature: &ParsedFeature, zoom: u8, config: &TileConfig) -> bool {
708    if let Some(mz) = feature_min_zoom(feature, &config.min_zoom_field) {
709        if zoom < mz {
710            return true;
711        }
712    }
713    if let Some(mx) = feature_max_zoom(feature, &config.max_zoom_field) {
714        if zoom > mx {
715            return true;
716        }
717    }
718    false
719}
720
721/// Legacy whole-feature placement: the single tile containing the feature's
722/// representative point. Projection failures are dropped + counted — never
723/// filed into tile (0, 0) as phantom features.
724fn place_whole_feature<'a>(
725    feature: &'a ParsedFeature,
726    zoom: u8,
727    counters: &PlacementCounters,
728) -> Vec<(u32, u32, TileFeature<'a>)> {
729    match projection::lonlat_to_tile(feature.lon, feature.lat, zoom) {
730        Ok((x, y)) => vec![(x, y, TileFeature::Original(feature))],
731        Err(_) => {
732            counters.invalid_coords.fetch_add(1, Ordering::Relaxed);
733            Vec::new()
734        }
735    }
736}
737
738/// Split a MultiPoint per containing tile: each member becomes its own
739/// per-tile Point piece (the legacy path placed — and rendered — only the
740/// whole feature's representative point). Members with unprojectable
741/// coordinates are dropped + counted individually.
742fn place_multipoint<'a>(
743    feature: &'a ParsedFeature,
744    points: &[Vec<f64>],
745    zoom: u8,
746    counters: &PlacementCounters,
747) -> Vec<(u32, u32, TileFeature<'a>)> {
748    if points.is_empty() {
749        return place_whole_feature(feature, zoom, counters);
750    }
751    let mut out = Vec::with_capacity(points.len());
752    for p in points {
753        if p.len() < 2 {
754            counters.invalid_coords.fetch_add(1, Ordering::Relaxed);
755            continue;
756        }
757        match projection::lonlat_to_tile(p[0], p[1], zoom) {
758            Ok((x, y)) => {
759                out.push((
760                    x,
761                    y,
762                    TileFeature::Derived(ParsedFeature {
763                        geojson: geojson::Feature {
764                            bbox: None,
765                            geometry: Some(geojson::Geometry::new(geojson::Value::Point(
766                                p.clone(),
767                            ))),
768                            id: feature.geojson.id.clone(),
769                            properties: None,
770                            foreign_members: None,
771                        },
772                        shared_properties: feature.shared_properties.clone(),
773                        timestamp: feature.timestamp,
774                        end_timestamp: feature.end_timestamp,
775                        vertex_timestamps: None,
776                        vertex_values: None,
777                        vertex_value_matrix: None,
778                        lon: p[0],
779                        lat: p[1],
780                    }),
781                ));
782            }
783            Err(_) => {
784                counters.invalid_coords.fetch_add(1, Ordering::Relaxed);
785            }
786        }
787    }
788    out
789}
790
791/// Coverage placement for (Multi)Polygons: clip the rings against every tile
792/// the geometry's bbox covers (Sutherland–Hodgman against the EXACT tile
793/// rect — `polygon_buffer_degrees`, 0 by default: adjacent tiles emit
794/// bit-identical seam vertices so fills rasterize watertight, where a
795/// buffered strip would double-blend under `opacity < 1`), emitting one
796/// per-tile piece wherever anything survives. Holes are preserved (rings
797/// clip independently). Fast path: a polygon fully inside its
798/// representative tile's rect takes the legacy single placement unchanged
799/// (byte-identical output). Antimeridian-crossing bboxes (>180° of
800/// longitude) fall back to legacy placement and are counted.
801/// `target` restricts the sweep to one tile (the stt-serve per-request
802/// path); the piece emitted for that tile is byte-identical to the full
803/// sweep's.
804fn place_polygon<'a>(
805    feature: &'a ParsedFeature,
806    polygons: &[crate::clip::PolygonRings],
807    multi: bool,
808    zoom: u8,
809    clip_config: &ClipConfig,
810    counters: &PlacementCounters,
811    target: Option<(u32, u32)>,
812) -> Vec<(u32, u32, TileFeature<'a>)> {
813    let mut min_lon = f64::MAX;
814    let mut min_lat = f64::MAX;
815    let mut max_lon = f64::MIN;
816    let mut max_lat = f64::MIN;
817    for c in polygons.iter().flatten().flatten() {
818        if c.len() >= 2 {
819            min_lon = min_lon.min(c[0]);
820            min_lat = min_lat.min(c[1]);
821            max_lon = max_lon.max(c[0]);
822            max_lat = max_lat.max(c[1]);
823        }
824    }
825    if !(min_lon.is_finite() && min_lat.is_finite() && max_lon.is_finite() && max_lat.is_finite())
826        || min_lon > max_lon
827    {
828        // Degenerate/garbage rings: let the representative point decide
829        // (an unprojectable point is dropped + counted there).
830        return place_whole_feature(feature, zoom, counters);
831    }
832    if max_lon - min_lon > 180.0 {
833        counters.antimeridian_fallback.fetch_add(1, Ordering::Relaxed);
834        return place_whole_feature(feature, zoom, counters);
835    }
836    let rep_tile = projection::lonlat_to_tile(feature.lon, feature.lat, zoom).ok();
837    if let Some((fx, fy)) = rep_tile {
838        // Same rect as the sweep below (polygon_buffer_degrees), so the two
839        // paths agree on containment.
840        let (bl, bb, br, bt) =
841            crate::clip::buffered_tile_bounds(fx, fy, zoom, clip_config.polygon_buffer_degrees);
842        if min_lon >= bl && max_lon <= br && min_lat >= bb && max_lat <= bt {
843            return vec![(fx, fy, TileFeature::Original(feature))];
844        }
845    }
846    let pieces = crate::clip::clip_polygons_to_tiles(
847        polygons,
848        zoom,
849        clip_config.polygon_buffer_degrees,
850        target,
851    );
852    if pieces.is_empty() {
853        // Nothing survived clipping (sliver thinner than the clipper keeps):
854        // keep the legacy placement rather than dropping the feature. Under a
855        // target restriction "empty" only means empty-at-target: the legacy
856        // fallback lands at the representative tile, so it applies only when
857        // the target IS that tile AND the UNRESTRICTED sweep is also empty
858        // (else the full build placed pieces elsewhere and nothing at all at
859        // the representative tile).
860        return match target {
861            None => place_whole_feature(feature, zoom, counters),
862            Some(t) if rep_tile == Some(t) => {
863                if crate::clip::clip_polygons_to_tiles(
864                    polygons,
865                    zoom,
866                    clip_config.polygon_buffer_degrees,
867                    None,
868                )
869                .is_empty()
870                {
871                    place_whole_feature(feature, zoom, counters)
872                } else {
873                    Vec::new()
874                }
875            }
876            Some(_) => Vec::new(),
877        };
878    }
879    pieces
880        .into_iter()
881        .map(|((x, y), mut polys)| {
882            let geometry = if !multi && polys.len() == 1 {
883                geojson::Value::Polygon(polys.pop().unwrap())
884            } else {
885                geojson::Value::MultiPolygon(polys)
886            };
887            (
888                x,
889                y,
890                TileFeature::Derived(ParsedFeature {
891                    geojson: geojson::Feature {
892                        bbox: None,
893                        geometry: Some(geojson::Geometry::new(geometry)),
894                        id: feature.geojson.id.clone(),
895                        properties: None,
896                        foreign_members: None,
897                    },
898                    shared_properties: feature.shared_properties.clone(),
899                    timestamp: feature.timestamp,
900                    end_timestamp: feature.end_timestamp,
901                    vertex_timestamps: None,
902                    vertex_values: None,
903                    vertex_value_matrix: None,
904                    // Parent's representative point, so id-less pieces hash
905                    // to the SAME synthetic feature id in every tile.
906                    lon: feature.lon,
907                    lat: feature.lat,
908                }),
909            )
910        })
911        .collect()
912}
913
914/// Clip a (Multi)LineString across the tiles it traverses.
915///
916/// Timeless lines are clipped spatially only — no temporal slicing, no
917/// simplification (the legacy timeless path never simplified) — and re-emitted
918/// as per-tile [`TileFeature::Derived`] pieces so the layer schema stays what
919/// the originals path produces for timeless lines (no `vertex_time` column
920/// unless the producer supplied per-vertex times). A MultiLineString WITH
921/// duration routes each part through the existing trajectory clipper as its
922/// own segment run (temporal slicing, matrix pinning and per-vertex-array
923/// interpolation apply exactly as for LineString trajectories); per-vertex
924/// arrays sized to the whole geometry are sliced per part.
925fn place_polyline<'a>(
926    feature: &'a ParsedFeature,
927    parts: &[Vec<Vec<f64>>],
928    zoom: u8,
929    clip_config: &ClipConfig,
930    counters: &PlacementCounters,
931) -> Vec<(u32, u32, TileFeature<'a>)> {
932    let total: usize = parts.iter().map(|p| p.len()).sum();
933    if total < 2 || parts.iter().flatten().any(|c| c.len() < 2) {
934        return place_whole_feature(feature, zoom, counters);
935    }
936
937    let mut min_lon = f64::MAX;
938    let mut min_lat = f64::MAX;
939    let mut max_lon = f64::MIN;
940    let mut max_lat = f64::MIN;
941    for c in parts.iter().flatten() {
942        min_lon = min_lon.min(c[0]);
943        min_lat = min_lat.min(c[1]);
944        max_lon = max_lon.max(c[0]);
945        max_lat = max_lat.max(c[1]);
946    }
947    if !(min_lon.is_finite() && min_lat.is_finite() && max_lon.is_finite() && max_lat.is_finite())
948    {
949        return place_whole_feature(feature, zoom, counters);
950    }
951    // Fast path: fully inside the representative tile's buffered rect —
952    // legacy single placement, byte-identical to the old behaviour. (No
953    // antimeridian bbox heuristic for lines: the trajectory clipper already
954    // splits runs at >180° longitude jumps.)
955    if let Ok((fx, fy)) = projection::lonlat_to_tile(feature.lon, feature.lat, zoom) {
956        let (bl, bb, br, bt) =
957            crate::clip::buffered_tile_bounds(fx, fy, zoom, clip_config.buffer_degrees);
958        if min_lon >= bl && max_lon <= br && min_lat >= bb && max_lat <= bt {
959            return vec![(fx, fy, TileFeature::Original(feature))];
960        }
961    }
962
963    // Per-vertex arrays are accepted only when sized to the WHOLE geometry
964    // (mirrors `build_line_layer`'s length contract) and sliced per part.
965    let supplied_times = match feature.vertex_timestamps.as_deref() {
966        Some(s) if s.len() == total => Some(s),
967        Some(s) => {
968            tracing::warn!(
969                "vertex_timestamps length {} != vertex count {} on a clipped \
970                 (multi)line; dropping the supplied times for this feature",
971                s.len(),
972                total
973            );
974            None
975        }
976        None => None,
977    };
978    let supplied_values = match feature.vertex_values.as_deref() {
979        Some(s) if s.len() == total => Some(s),
980        Some(s) => {
981            tracing::warn!(
982                "vertex_values length {} != vertex count {} on a clipped \
983                 (multi)line; dropping the supplied values for this feature",
984                s.len(),
985                total
986            );
987            None
988        }
989        None => None,
990    };
991    let matrix = match feature.vertex_value_matrix.as_deref() {
992        Some(m) if !m.is_empty() && m.len() % total == 0 => Some((m, m.len() / total)),
993        Some(m) => {
994            tracing::warn!(
995                "vertex_value_matrix length {} is not a multiple of vertex count {} \
996                 on a clipped (multi)line; dropping the matrix for this feature",
997                m.len(),
998                total
999            );
1000            None
1001        }
1002        None => None,
1003    };
1004
1005    if feature.end_timestamp.is_some() {
1006        // Duration MultiLineString: each part through the trajectory clipper
1007        // as its own segment run, sharing the parent's feature id. Flat
1008        // per-vertex times over the concatenated parts reproduce the legacy
1009        // flattened `build_line_layer` timing exactly.
1010        let end_time = feature.end_timestamp.unwrap_or(feature.timestamp);
1011        let flat_times: Vec<u64> = match supplied_times {
1012            Some(s) => s.to_vec(),
1013            None => {
1014                let flat: Vec<(f64, f64, f64)> = parts
1015                    .iter()
1016                    .flatten()
1017                    .map(|c| (c[0], c[1], if c.len() >= 3 { c[2] } else { 0.0 }))
1018                    .collect();
1019                crate::clip::compute_vertex_timestamps(&flat, feature.timestamp, end_time)
1020            }
1021        };
1022        let mut placements: Vec<(u32, u32, TileFeature<'a>)> = Vec::new();
1023        let mut offset = 0usize;
1024        for part in parts {
1025            let len = part.len();
1026            let part_times = &flat_times[offset..offset + len];
1027            let part_values = supplied_values.map(|v| &v[offset..offset + len]);
1028            let part_matrix = matrix.map(|(m, nb)| &m[offset * nb..(offset + len) * nb]);
1029            offset += len;
1030            if len < 2 {
1031                continue;
1032            }
1033            let synthetic = geojson::Feature {
1034                bbox: None,
1035                geometry: Some(geojson::Geometry::new(geojson::Value::LineString(
1036                    part.clone(),
1037                ))),
1038                id: feature.geojson.id.clone(),
1039                properties: None,
1040                foreign_members: None,
1041            };
1042            let segments = clip_trajectory(
1043                &synthetic,
1044                feature.shared_properties.clone(),
1045                feature.timestamp,
1046                end_time,
1047                zoom,
1048                clip_config,
1049                Some(part_times),
1050                part_values,
1051                part_matrix,
1052            );
1053            placements.extend(
1054                segments
1055                    .into_iter()
1056                    .map(|s| (s.tile_x, s.tile_y, TileFeature::Clipped(s))),
1057            );
1058        }
1059        if placements.is_empty() {
1060            return place_whole_feature(feature, zoom, counters);
1061        }
1062        return placements;
1063    }
1064
1065    // Timeless: spatial-only clip.
1066    let timeless_cfg = ClipConfig {
1067        temporal_granularity_ms: None,
1068        simplify: false,
1069        ..clip_config.clone()
1070    };
1071    let mut per_tile: BTreeMap<(u32, u32), Vec<ClippedSegment>> = BTreeMap::new();
1072    let mut offset = 0usize;
1073    for part in parts {
1074        let len = part.len();
1075        let part_times = supplied_times.map(|s| &s[offset..offset + len]);
1076        let part_values = supplied_values.map(|v| &v[offset..offset + len]);
1077        let part_matrix = matrix.map(|(m, nb)| &m[offset * nb..(offset + len) * nb]);
1078        offset += len;
1079        if len < 2 {
1080            continue;
1081        }
1082        let synthetic = geojson::Feature {
1083            bbox: None,
1084            geometry: Some(geojson::Geometry::new(geojson::Value::LineString(part.clone()))),
1085            id: feature.geojson.id.clone(),
1086            properties: None,
1087            foreign_members: None,
1088        };
1089        // Properties ride the Derived feature, not the throwaway segments.
1090        let segments = clip_trajectory(
1091            &synthetic,
1092            None,
1093            feature.timestamp,
1094            feature.timestamp,
1095            zoom,
1096            &timeless_cfg,
1097            part_times,
1098            part_values,
1099            part_matrix,
1100        );
1101        for s in segments {
1102            per_tile.entry((s.tile_x, s.tile_y)).or_default().push(s);
1103        }
1104    }
1105    if per_tile.is_empty() {
1106        return place_whole_feature(feature, zoom, counters);
1107    }
1108    per_tile
1109        .into_iter()
1110        .map(|((x, y), segs)| {
1111            let mut geom_parts: Vec<Vec<Vec<f64>>> = segs
1112                .iter()
1113                .map(|s| {
1114                    s.coordinates
1115                        .iter()
1116                        .map(|(lx, ly, la)| vec![*lx, *ly, *la])
1117                        .collect()
1118                })
1119                .collect();
1120            let geometry = if geom_parts.len() == 1 {
1121                geojson::Value::LineString(geom_parts.pop().unwrap())
1122            } else {
1123                geojson::Value::MultiLineString(geom_parts)
1124            };
1125            let vertex_timestamps = supplied_times
1126                .map(|_| segs.iter().flat_map(|s| s.timestamps.iter().copied()).collect());
1127            let vertex_values = supplied_values.map(|_| {
1128                segs.iter()
1129                    .flat_map(|s| s.vertex_values.iter().copied())
1130                    .collect()
1131            });
1132            let vertex_value_matrix = matrix.map(|_| {
1133                segs.iter()
1134                    .flat_map(|s| s.vertex_value_matrix.iter().flatten().copied())
1135                    .collect()
1136            });
1137            (
1138                x,
1139                y,
1140                TileFeature::Derived(ParsedFeature {
1141                    geojson: geojson::Feature {
1142                        bbox: None,
1143                        geometry: Some(geojson::Geometry::new(geometry)),
1144                        id: feature.geojson.id.clone(),
1145                        properties: None,
1146                        foreign_members: None,
1147                    },
1148                    shared_properties: feature.shared_properties.clone(),
1149                    timestamp: feature.timestamp,
1150                    end_timestamp: feature.end_timestamp,
1151                    vertex_timestamps,
1152                    vertex_values,
1153                    vertex_value_matrix,
1154                    lon: feature.lon,
1155                    lat: feature.lat,
1156                }),
1157            )
1158        })
1159        .collect()
1160}
1161
1162/// Place a non-trajectory feature: coverage placement + clipping for
1163/// polygons, timeless (multi)lines and multipoints when
1164/// `config.clip_non_trajectory` is on; legacy whole-feature single-tile
1165/// placement otherwise (and always for Points / GeometryCollections).
1166fn place_non_trajectory<'a>(
1167    feature: &'a ParsedFeature,
1168    zoom: u8,
1169    config: &TileConfig,
1170    clip_config: &ClipConfig,
1171    counters: &PlacementCounters,
1172    target: Option<(u32, u32)>,
1173) -> Vec<(u32, u32, TileFeature<'a>)> {
1174    use geojson::Value as G;
1175    if !config.clip_non_trajectory {
1176        return place_whole_feature(feature, zoom, counters);
1177    }
1178    let Some(geom) = feature.geojson.geometry.as_ref() else {
1179        return place_whole_feature(feature, zoom, counters);
1180    };
1181    match &geom.value {
1182        G::Point(_) | G::GeometryCollection(_) => place_whole_feature(feature, zoom, counters),
1183        G::MultiPoint(points) => place_multipoint(feature, points, zoom, counters),
1184        G::Polygon(rings) => place_polygon(
1185            feature,
1186            std::slice::from_ref(rings),
1187            false,
1188            zoom,
1189            clip_config,
1190            counters,
1191            target,
1192        ),
1193        G::MultiPolygon(polys) => {
1194            place_polygon(feature, polys, true, zoom, clip_config, counters, target)
1195        }
1196        G::LineString(coords) => {
1197            if feature.end_timestamp.is_some() {
1198                // A duration LineString only reaches here when trajectory
1199                // clipping is off (--no-clip) or the geometry is degenerate:
1200                // honour the user's explicit whole-trajectory placement.
1201                place_whole_feature(feature, zoom, counters)
1202            } else {
1203                place_polyline(feature, std::slice::from_ref(coords), zoom, clip_config, counters)
1204            }
1205        }
1206        G::MultiLineString(parts) => {
1207            if feature.end_timestamp.is_some() && !config.clip_trajectories {
1208                place_whole_feature(feature, zoom, counters)
1209            } else {
1210                place_polyline(feature, parts, zoom, clip_config, counters)
1211            }
1212        }
1213    }
1214}
1215
1216/// Place one feature at `zoom` — trajectory clip, non-trajectory coverage
1217/// clip, or whole-feature fallback. THE single placement authority: the
1218/// in-memory build, the streaming build and the dynamic single-tile encoder
1219/// (`stt-serve`) all route through here so their outputs stay identical.
1220/// `target` (the single-tile serve path) restricts the polygon coverage
1221/// sweep to one tile — the pieces emitted for that tile are byte-identical
1222/// to the full sweep's; other geometry kinds cost O(geometry), not
1223/// O(bbox tiles), and are simply filtered by the caller.
1224fn place_feature<'a>(
1225    feature: &'a ParsedFeature,
1226    zoom: u8,
1227    config: &TileConfig,
1228    clip_config: &ClipConfig,
1229    counters: &PlacementCounters,
1230    target: Option<(u32, u32)>,
1231) -> Vec<(u32, u32, TileFeature<'a>)> {
1232    let should_clip = config.clip_trajectories
1233        && is_clippable_trajectory(&feature.geojson, feature.end_timestamp);
1234    if should_clip {
1235        let segments = clip_trajectory(
1236            &feature.geojson,
1237            feature.shared_properties.clone(),
1238            feature.timestamp,
1239            feature.end_timestamp.unwrap_or(feature.timestamp),
1240            zoom,
1241            clip_config,
1242            feature.vertex_timestamps.as_deref(),
1243            feature.vertex_values.as_deref(),
1244            feature.vertex_value_matrix.as_deref(),
1245        );
1246        if segments.is_empty() {
1247            place_whole_feature(feature, zoom, counters)
1248        } else {
1249            segments
1250                .into_iter()
1251                .map(|s| (s.tile_x, s.tile_y, TileFeature::Clipped(s)))
1252                .collect()
1253        }
1254    } else {
1255        place_non_trajectory(feature, zoom, config, clip_config, counters, target)
1256    }
1257}
1258
1259fn process_zoom_level(
1260    features: &[ParsedFeature],
1261    zoom: u8,
1262    config: &TileConfig,
1263    clip_config: &ClipConfig,
1264    total_clipped: &AtomicUsize,
1265    total_original: &AtomicUsize,
1266    counters: &PlacementCounters,
1267) -> Result<Vec<GeneratedTile>> {
1268    // Parallel clip: each feature yields zero or more (tile_x, tile_y, feature).
1269    let placed: Vec<(u32, u32, TileFeature)> = features
1270        .par_iter()
1271        .flat_map(|feature| {
1272            // Road-class LOD: hide a feature outside its [min_zoom, max_zoom]
1273            // band. Whole-feature skip BEFORE clip — the value matrix is never
1274            // touched.
1275            if feature_out_of_band(feature, zoom, config) {
1276                return Vec::new();
1277            }
1278            let placements = place_feature(feature, zoom, config, clip_config, counters, None);
1279            let clipped = placements
1280                .iter()
1281                .filter(|(_, _, f)| matches!(f, TileFeature::Clipped(_)))
1282                .count();
1283            if clipped > 0 {
1284                total_clipped.fetch_add(clipped, Ordering::Relaxed);
1285            }
1286            if placements.len() > clipped {
1287                total_original.fetch_add(placements.len() - clipped, Ordering::Relaxed);
1288            }
1289            placements
1290        })
1291        .collect();
1292
1293    // Group by spatial tile.
1294    let mut spatial: HashMap<(u32, u32), Vec<TileFeature>> = HashMap::new();
1295    for (x, y, f) in placed {
1296        spatial.entry((x, y)).or_default().push(f);
1297    }
1298
1299    // Build tiles in parallel: each spatial cell is chunked into temporal
1300    // buckets, and every (cell, bucket) pair becomes one tile.
1301    let tiles: Vec<GeneratedTile> = spatial
1302        .into_par_iter()
1303        .flat_map(|((x, y), feats)| {
1304            let buckets = match config.adaptive_target_features {
1305                Some(target) => chunk_adaptive_by_count(feats, target),
1306                None => chunk_by_temporal_bucket(feats, config.temporal_bucket_ms),
1307            };
1308            let mut out = Vec::new();
1309            for (bucket_start, chunk) in buckets {
1310                if chunk.is_empty() {
1311                    continue;
1312                }
1313                let time_end = chunk
1314                    .iter()
1315                    .map(|f| f.end_timestamp())
1316                    .max()
1317                    .unwrap_or(bucket_start + config.temporal_bucket_ms);
1318                let id = TileId::new(zoom, x, y, bucket_start);
1319                match build_tile(id, &chunk, config, bucket_start as i64, time_end as i64) {
1320                    Ok(Some(tile)) => out.push(tile),
1321                    Ok(None) => {}
1322                    Err(e) => tracing::warn!("failed to build tile {id:?}: {e}"),
1323                }
1324            }
1325            out
1326        })
1327        .collect();
1328
1329    Ok(tiles)
1330}
1331
1332/// Chunk a spatial cell's features into fixed temporal buckets.
1333fn chunk_by_temporal_bucket(
1334    features: Vec<TileFeature>,
1335    bucket_ms: u64,
1336) -> Vec<(u64, Vec<TileFeature>)> {
1337    let bucket_ms = bucket_ms.max(1);
1338    let mut buckets: BTreeMap<u64, Vec<TileFeature>> = BTreeMap::new();
1339    for f in features {
1340        let bucket = (f.timestamp() / bucket_ms) * bucket_ms;
1341        buckets.entry(bucket).or_default().push(f);
1342    }
1343    buckets.into_iter().collect()
1344}
1345
1346/// Adaptive temporal chunking: partition a spatial cell's features into windows
1347/// of ~`target` features each, ordered by time. Dense periods produce many fine
1348/// windows, sparse periods few coarse ones. Each window's key is its first
1349/// feature's timestamp; a window is never closed in the middle of a run of
1350/// identical timestamps, so the per-window `(zoom, x, y, t)` keys stay distinct.
1351/// Features sharing one exact timestamp in a cell are inseparable (they map to
1352/// the same `(z, x, y, t)` key) and stay in a single window even past `target`.
1353fn chunk_adaptive_by_count(
1354    mut features: Vec<TileFeature>,
1355    target: u32,
1356) -> Vec<(u64, Vec<TileFeature>)> {
1357    let target = target.max(1) as usize;
1358    features.sort_by_key(|f| f.timestamp());
1359    let mut out: Vec<(u64, Vec<TileFeature>)> = Vec::new();
1360    let mut current: Vec<TileFeature> = Vec::new();
1361    let mut current_start = 0u64;
1362    for f in features {
1363        if current.is_empty() {
1364            current_start = f.timestamp();
1365        } else if current.len() >= target
1366            && f.timestamp() != current.last().unwrap().timestamp()
1367        {
1368            // Window is full and the next feature opens a new timestamp — close
1369            // here so two windows can't share a start time (TileId collision).
1370            out.push((current_start, std::mem::take(&mut current)));
1371            current_start = f.timestamp();
1372        }
1373        current.push(f);
1374    }
1375    if !current.is_empty() {
1376        out.push((current_start, current));
1377    }
1378    out
1379}
1380
1381/// Build one tile's layers from a chunk of features.
1382fn build_tile(
1383    id: TileId,
1384    features: &[TileFeature],
1385    config: &TileConfig,
1386    time_start: i64,
1387    time_end: i64,
1388) -> Result<Option<GeneratedTile>> {
1389    // Opt-in per-tile budget. Default (`tile_budget: None`) skips this entirely,
1390    // so a build without `--maximum-tile-bytes`/`--maximum-tile-features` is
1391    // byte-for-byte identical to before. When a budget is set and the tile
1392    // exceeds it, the lowest-importance features are dropped to fit and the
1393    // exact dropped count is logged for THIS tile (no silent truncation).
1394    let kept_indices = config
1395        .tile_budget
1396        .as_ref()
1397        .map(|budget| apply_tile_budget(budget, id, features));
1398    // Materialise the surviving feature list only when the budget actually
1399    // dropped something; otherwise reference the originals in place.
1400    let kept_features: Vec<&TileFeature> = match &kept_indices {
1401        Some(keep) if keep.len() < features.len() => {
1402            keep.iter().map(|&i| &features[i]).collect()
1403        }
1404        _ => features.iter().collect(),
1405    };
1406
1407    let mut originals: Vec<&ParsedFeature> = Vec::new();
1408    let mut segments: Vec<&ClippedSegment> = Vec::new();
1409    for f in &kept_features {
1410        match f {
1411            TileFeature::Original(o) => originals.push(o),
1412            TileFeature::Derived(d) => originals.push(d),
1413            TileFeature::Clipped(s) => segments.push(s),
1414        }
1415    }
1416
1417    let mut layers: Vec<ColumnarLayer> = Vec::new();
1418
1419    if !segments.is_empty() {
1420        layers.push(build_layer_from_segments(
1421            &segments,
1422            &config.layer_name,
1423            &config.columnar_options(),
1424        )?);
1425    }
1426    if !originals.is_empty() {
1427        // Suffix the originals layer name when clipped segments are also
1428        // present so layer names stay unique within the tile.
1429        let base = if segments.is_empty() {
1430            config.layer_name.clone()
1431        } else {
1432            format!("{}_originals", config.layer_name)
1433        };
1434        layers.extend(build_layers_from_features_with(
1435            &originals,
1436            &base,
1437            config.columnar_options(),
1438        )?);
1439    }
1440
1441    if layers.is_empty() {
1442        return Ok(None);
1443    }
1444    // Tight lower covering bound: earliest feature start actually in the tile
1445    // (vs `time_start`, the addressable bucket edge). Computed over the KEPT
1446    // features so a dropped early feature can't widen the bound. Falls back to
1447    // `time_start` for an (unexpected) empty feature set.
1448    let cover_t_min = kept_features
1449        .iter()
1450        .map(|f| f.timestamp() as i64)
1451        .min()
1452        .unwrap_or(time_start);
1453    Ok(Some(GeneratedTile {
1454        id,
1455        time_start,
1456        time_end,
1457        cover_t_min,
1458        layers,
1459    }))
1460}
1461
1462/// Estimated uncompressed payload bytes for one tile feature (geometry + props).
1463/// Mirrors `budget.rs`'s 16-bytes-per-coordinate-pair + per-property estimate so
1464/// the byte cap is comparable to `TileBudget::estimate_size`.
1465fn tile_feature_size(f: &TileFeature) -> usize {
1466    let (verts, props) = tile_feature_signals(f);
1467    verts * 16 + props * 16 + 32
1468}
1469
1470/// `(vertex_count, property_count)` signals the budget's importance scorer
1471/// needs, extracted without building an `stt_core::tile::Feature`.
1472fn tile_feature_signals(f: &TileFeature) -> (usize, usize) {
1473    match f {
1474        TileFeature::Original(o) => {
1475            let verts = geojson_vertex_count(&o.geojson);
1476            let props = o.shared_properties.as_ref().map(|p| p.len()).unwrap_or(0);
1477            (verts, props)
1478        }
1479        TileFeature::Derived(d) => {
1480            let verts = geojson_vertex_count(&d.geojson);
1481            let props = d.shared_properties.as_ref().map(|p| p.len()).unwrap_or(0);
1482            (verts, props)
1483        }
1484        TileFeature::Clipped(s) => {
1485            let props = s.properties.as_ref().map(|p| p.len()).unwrap_or(0);
1486            (s.coordinates.len(), props)
1487        }
1488    }
1489}
1490
1491/// Count the vertices in a GeoJSON feature's geometry (0 when absent).
1492fn geojson_vertex_count(f: &geojson::Feature) -> usize {
1493    use geojson::Value as G;
1494    let Some(geom) = f.geometry.as_ref() else {
1495        return 1;
1496    };
1497    match &geom.value {
1498        G::Point(_) => 1,
1499        G::MultiPoint(pts) => pts.len(),
1500        G::LineString(c) => c.len(),
1501        G::MultiLineString(lines) => lines.iter().map(|l| l.len()).sum(),
1502        G::Polygon(rings) => rings.iter().map(|r| r.len()).sum(),
1503        G::MultiPolygon(polys) => polys.iter().flatten().map(|r| r.len()).sum(),
1504        G::GeometryCollection(_) => 1,
1505    }
1506}
1507
1508/// Run a tile's gathered features through the budget, returning the indices to
1509/// KEEP (ascending). Logs the per-tile dropped count whenever anything is
1510/// dropped — the "no silent caps" guarantee.
1511fn apply_tile_budget(
1512    budget: &TileBudget,
1513    id: TileId,
1514    features: &[TileFeature],
1515) -> Vec<usize> {
1516    let keep = budget.enforce_indexed(
1517        features.len(),
1518        |i| {
1519            let (v, p) = tile_feature_signals(&features[i]);
1520            budget.score_signals(v, p)
1521        },
1522        |i| tile_feature_size(&features[i]),
1523    );
1524    let dropped = features.len() - keep.len();
1525    if dropped > 0 {
1526        tracing::warn!(
1527            "tile z{} x{} y{} t{}: dropped {} of {} features to fit budget \
1528             (max_features={}, max_bytes={})",
1529            id.z,
1530            id.x,
1531            id.y,
1532            id.t,
1533            dropped,
1534            features.len(),
1535            budget.max_feature_count,
1536            budget.max_uncompressed_size,
1537        );
1538    }
1539    keep
1540}
1541
1542/// The encoder config a [`stt_core::PackWriter`] sink encodes with: the frame
1543/// version + template sink come FROM THE WRITER (its `--format-version`
1544/// selection and schema-template collector), layered over the process-wide
1545/// encoder globals — the single coupling point that keeps a dataset's frames
1546/// and manifest declaration in lockstep (mixed versions are a reader hard
1547/// error, packed-v2 design §1 ★F6).
1548fn pack_encoder_config(writer: &stt_core::PackWriter) -> stt_core::arrow_tile::EncoderConfig {
1549    stt_core::arrow_tile::EncoderConfig {
1550        format_version: writer.format_version(),
1551        template_collector: (writer.format_version()
1552            == stt_core::pack::PACKED_FORMAT_VERSION_V2)
1553            .then(|| writer.template_collector()),
1554        ..stt_core::arrow_tile::EncoderConfig::from_globals()
1555    }
1556}
1557
1558/// Tiles per parallel-encode batch: enough to keep every worker busy, small
1559/// enough that the batch's encoded (uncompressed) payloads are a bounded,
1560/// transient allocation.
1561const ENCODE_CHUNK: usize = 1024;
1562
1563/// Encode tiles to Arrow IPC payloads IN PARALLEL (on the current rayon
1564/// pool), then hand them to the pack writer strictly in the given order.
1565///
1566/// This is the shared engine behind every `PackWriter` write loop (plain,
1567/// LOD-tagged, streaming). Parallelism cannot change output bytes:
1568/// `encode_tile_with` is deterministic per tile, the v2 schema-template
1569/// collector snapshot is sorted + deduped regardless of insertion order
1570/// (packed-v2 design ★F1), and `add_tile_full` is called in exactly the
1571/// sequential order of `tiles` (and the writer's finalize re-sorts by the
1572/// total space-time key anyway). `on_written` fires once per tile after its
1573/// ordered hand-off (progress reporting).
1574///
1575/// The writer's `--pack-memory-budget` covers the payloads it has BUFFERED,
1576/// but a full [`ENCODE_CHUNK`] would additionally hold up to 1024 encoded
1577/// payloads invisible to that budget. With a budget set, sub-batches are cut
1578/// on a running encoded-byte cap (~budget/4, growing one worker-wave of tiles
1579/// at a time so parallelism never collapses) and each sub-batch is flushed to
1580/// `add_tile_full` before the next is encoded. Hand-off order is unchanged,
1581/// so output bytes are identical either way (pinned by
1582/// `parallel_encode_writes_byte_identical_dataset`).
1583fn encode_and_add_tiles(
1584    writer: &mut stt_core::PackWriter,
1585    tiles: &[(&GeneratedTile, Option<u64>)],
1586    mut on_written: impl FnMut(),
1587) -> Result<()> {
1588    let encoder = pack_encoder_config(writer);
1589    let budget = writer.memory_budget();
1590    let byte_cap: u64 = if budget > 0 { (budget / 4).max(1) } else { u64::MAX };
1591    // Unlimited budget: one wave = the whole chunk (the legacy single
1592    // par_iter). Budgeted: waves of `workers` tiles, so at most one wave's
1593    // encoded bytes overshoot the cap.
1594    let wave = if budget > 0 {
1595        rayon::current_num_threads().max(1)
1596    } else {
1597        ENCODE_CHUNK
1598    };
1599    for chunk in tiles.chunks(ENCODE_CHUNK) {
1600        let mut start = 0usize;
1601        while start < chunk.len() {
1602            // Grow the sub-batch wave by wave until the running encoded-byte
1603            // total reaches the cap (always at least one wave).
1604            let mut payloads: Vec<Vec<u8>> = Vec::new();
1605            let mut bytes = 0u64;
1606            let mut end = start;
1607            while end < chunk.len() && (end == start || bytes < byte_cap) {
1608                let wave_end = (end + wave).min(chunk.len());
1609                let encoded: Vec<Vec<u8>> = chunk[end..wave_end]
1610                    .par_iter()
1611                    .map(|(tile, _)| {
1612                        stt_core::arrow_tile::encode_tile_with(&tile.layers, &encoder)
1613                    })
1614                    .collect::<std::result::Result<Vec<_>, stt_core::Error>>()?;
1615                bytes += encoded.iter().map(|p| p.len() as u64).sum::<u64>();
1616                payloads.extend(encoded);
1617                end = wave_end;
1618            }
1619            for ((tile, bucket), payload) in chunk[start..end].iter().zip(payloads) {
1620                writer.add_tile_full(
1621                    &tile.id,
1622                    tile.time_start,
1623                    tile.time_end,
1624                    Some(tile.cover_t_min),
1625                    tile.feature_count(),
1626                    *bucket,
1627                    &payload,
1628                )?;
1629                on_written();
1630            }
1631            start = end;
1632        }
1633    }
1634    Ok(())
1635}
1636
1637/// Parallel-encode `tiles` (bounded by a `workers`-sized rayon pool — the
1638/// `--workers` convention) and write them to `writer` in the given order.
1639/// The CLI's non-streaming write loops call this; `bucket` tags each tile's
1640/// directory entry with its temporal bucket (`None` = base tile). See
1641/// [`encode_and_add_tiles`] for why parallelism can't change output bytes.
1642pub fn write_tiles_parallel(
1643    writer: &mut stt_core::PackWriter,
1644    tiles: &[(&GeneratedTile, Option<u64>)],
1645    workers: usize,
1646    on_written: impl FnMut() + Send,
1647) -> Result<()> {
1648    let pool = build_pool(workers)?;
1649    pool.install(|| encode_and_add_tiles(writer, tiles, on_written))
1650}
1651
1652/// Stream generated tiles straight into a packed-format [`stt_core::PackWriter`].
1653///
1654/// Identical mapping to the `ArchiveWriter` impl above —
1655/// `PackWriter` shares the same `add_tile_full` contract; it just buffers the
1656/// tiles and cuts them into content-addressed packs at finalize. Payloads are
1657/// encoded with [`pack_encoder_config`] (frame version follows the writer).
1658impl TileWriter for stt_core::PackWriter {
1659    fn write_tile(&mut self, tile: &GeneratedTile) -> Result<()> {
1660        let payload = stt_core::arrow_tile::encode_tile_with(&tile.layers, &pack_encoder_config(self))?;
1661        self.add_tile_full(
1662            &tile.id,
1663            tile.time_start,
1664            tile.time_end,
1665            Some(tile.cover_t_min),
1666            tile.feature_count(),
1667            None,
1668            &payload,
1669        )?;
1670        Ok(())
1671    }
1672
1673    /// Batch write with parallel encode (on the caller's current rayon pool)
1674    /// and strictly ordered hand-off — see [`encode_and_add_tiles`].
1675    fn write_tiles(&mut self, tiles: &[&GeneratedTile]) -> Result<()> {
1676        let tagged: Vec<(&GeneratedTile, Option<u64>)> =
1677            tiles.iter().map(|t| (*t, None)).collect();
1678        encode_and_add_tiles(self, &tagged, || {})
1679    }
1680}
1681
1682/// Sink that also forwards the per-tile temporal bucket size.
1683pub trait LodTileWriter {
1684    /// Persist one tile, tagging the directory entry with `temporal_bucket_ms`.
1685    fn write_lod_tile(
1686        &mut self,
1687        tile: &GeneratedTile,
1688        temporal_bucket_ms: Option<u64>,
1689    ) -> Result<()>;
1690}
1691
1692impl LodTileWriter for stt_core::PackWriter {
1693    fn write_lod_tile(
1694        &mut self,
1695        tile: &GeneratedTile,
1696        temporal_bucket_ms: Option<u64>,
1697    ) -> Result<()> {
1698        let payload = stt_core::arrow_tile::encode_tile_with(&tile.layers, &pack_encoder_config(self))?;
1699        self.add_tile_full(
1700            &tile.id,
1701            tile.time_start,
1702            tile.time_end,
1703            Some(tile.cover_t_min),
1704            tile.feature_count(),
1705            temporal_bucket_ms,
1706            &payload,
1707        )?;
1708        Ok(())
1709    }
1710}
1711
1712#[cfg(test)]
1713mod tests {
1714    use super::*;
1715    use geojson::{Feature, Geometry, Value as GeomValue};
1716    use stt_core::{BlobOrdering, PackWriter, PackedReader};
1717    use stt_core::metadata::Metadata;
1718
1719    fn point(lon: f64, lat: f64, ts: u64) -> ParsedFeature {
1720        let props = serde_json::json!({ "v": ts as f64 })
1721            .as_object()
1722            .cloned()
1723            .map(std::sync::Arc::new);
1724        ParsedFeature {
1725            geojson: Feature {
1726                bbox: None,
1727                geometry: Some(Geometry::new(GeomValue::Point(vec![lon, lat]))),
1728                id: None,
1729                properties: None,
1730                foreign_members: None,
1731            },
1732            shared_properties: props,
1733            timestamp: ts,
1734            end_timestamp: None,
1735            vertex_timestamps: None,
1736            vertex_values: None,
1737            vertex_value_matrix: None,
1738            lon,
1739            lat,
1740        }
1741    }
1742
1743    /// `encode_single_tile` must produce a decodable STT blob containing exactly
1744    /// the features that fall in the requested `(z, x, y, bucket)` — the same
1745    /// selection the full build's `process_zoom_level` makes — and `None` for an
1746    /// empty tile. This is the core a dynamic per-request server (stt-serve)
1747    /// relies on, verified here with no database.
1748    #[test]
1749    fn encode_single_tile_selects_tile_and_bucket() {
1750        use stt_core::arrow_tile::decode_tile;
1751        let z = 12u8;
1752        let bucket_ms = 3_600_000u64; // 1h
1753        let lon = -122.42;
1754        let lat = 37.77;
1755        let (x, y) = projection::lonlat_to_tile(lon, lat, z).unwrap();
1756        let base = 1_700_000_000_000u64;
1757        let bucket_start = (base / bucket_ms) * bucket_ms;
1758
1759        let feats = vec![
1760            point(lon, lat, bucket_start + 10),
1761            point(lon + 0.0003, lat + 0.0003, bucket_start + 20),
1762            // A third point one bucket later (same tile, different time bucket).
1763            point(lon, lat, bucket_start + bucket_ms + 5),
1764        ];
1765        let config = TileConfig {
1766            min_zoom: z,
1767            max_zoom: z,
1768            layer_name: "obs".to_string(),
1769            temporal_bucket_ms: bucket_ms,
1770            clip_trajectories: false,
1771            ..TileConfig::default()
1772        };
1773
1774        let enc = stt_core::arrow_tile::EncoderConfig::default();
1775
1776        // The requested tile + bucket has exactly the two in-bucket points.
1777        let bytes = encode_single_tile(&feats, z, x, y, bucket_start as i64, &config, &enc)
1778            .unwrap()
1779            .expect("tile should be non-empty");
1780        let rows: usize = decode_tile(&bytes)
1781            .unwrap()
1782            .iter()
1783            .map(|l| l.batch.num_rows())
1784            .sum();
1785        assert_eq!(rows, 2, "only the two points in this (tile, bucket)");
1786
1787        // A different spatial cell is empty.
1788        assert!(encode_single_tile(&feats, z, x + 9, y, bucket_start as i64, &config, &enc)
1789            .unwrap()
1790            .is_none());
1791
1792        // The next bucket carries the single later point.
1793        let next =
1794            encode_single_tile(&feats, z, x, y, (bucket_start + bucket_ms) as i64, &config, &enc)
1795            .unwrap()
1796            .expect("next bucket tile");
1797        let n: usize = decode_tile(&next)
1798            .unwrap()
1799            .iter()
1800            .map(|l| l.batch.num_rows())
1801            .sum();
1802        assert_eq!(n, 1);
1803    }
1804
1805    fn trajectory(start: u64, end: u64) -> ParsedFeature {
1806        // A path crossing several tiles near San Francisco.
1807        let coords: Vec<Vec<f64>> = (0..20)
1808            .map(|i| vec![-122.5 + i as f64 * 0.02, 37.7 + i as f64 * 0.01])
1809            .collect();
1810        let first = coords[0].clone();
1811        ParsedFeature {
1812            geojson: Feature {
1813                bbox: None,
1814                geometry: Some(Geometry::new(GeomValue::LineString(coords))),
1815                id: None,
1816                properties: None,
1817                foreign_members: None,
1818            },
1819            shared_properties: None,
1820            timestamp: start,
1821            end_timestamp: Some(end),
1822            vertex_timestamps: None,
1823            vertex_values: None,
1824            vertex_value_matrix: None,
1825            lon: first[0],
1826            lat: first[1],
1827        }
1828    }
1829
1830    /// A static-geometry corridor carrying a per-vertex × per-bucket value
1831    /// matrix must build into ONE tile per spatial cell spanning the WHOLE
1832    /// range — never fragmented across temporal buckets by its interpolated
1833    /// vertex times — so the client loads its geometry once and animates the
1834    /// resident matrix. (The build bucket here is small enough that, without
1835    /// the matrix time-pin, the corridor would fragment into several tiles.)
1836    #[test]
1837    fn matrix_corridor_builds_one_tile_spanning_range() {
1838        let num_buckets = 4usize;
1839        let bucket_ms = 900_000u64; // 15 min
1840        let start = 1_420_070_400_000u64;
1841        let end = start + num_buckets as u64 * bucket_ms;
1842        // 3 vertices kept inside a single zoom-10 tile.
1843        let coords: Vec<Vec<f64>> = vec![
1844            vec![-73.980, 40.750],
1845            vec![-73.979, 40.751],
1846            vec![-73.978, 40.752],
1847        ];
1848        let nverts = coords.len();
1849        let first = coords[0].clone();
1850        // Flat vertex-major matrix: nverts * num_buckets.
1851        let matrix: Vec<f32> = (0..nverts * num_buckets).map(|i| i as f32).collect();
1852        let feature = ParsedFeature {
1853            geojson: Feature {
1854                bbox: None,
1855                geometry: Some(Geometry::new(GeomValue::LineString(coords))),
1856                id: None,
1857                properties: None,
1858                foreign_members: None,
1859            },
1860            shared_properties: None,
1861            timestamp: start,
1862            end_timestamp: Some(end),
1863            vertex_timestamps: None,
1864            vertex_values: None,
1865            vertex_value_matrix: Some(matrix),
1866            lon: first[0],
1867            lat: first[1],
1868        };
1869        let config = TileConfig {
1870            min_zoom: 10,
1871            max_zoom: 10,
1872            layer_name: "flows".to_string(),
1873            temporal_bucket_ms: bucket_ms,
1874            clip_trajectories: true,
1875            clip_min_vertices: 2,
1876            ..TileConfig::default()
1877        };
1878
1879        let tiles = generate_tiles(&[feature], &config, 1).unwrap();
1880        assert_eq!(
1881            tiles.len(),
1882            1,
1883            "matrix corridor must build exactly one tile, got {}",
1884            tiles.len()
1885        );
1886        let tile = &tiles[0];
1887        // Spans the whole range so its time window matches every playback frame.
1888        assert_eq!(tile.time_start, start as i64);
1889        assert_eq!(tile.time_end, end as i64);
1890        // The matrix survived clipping into the tile's columnar layer.
1891        let layer = &tile.layers[0];
1892        let vm = layer
1893            .vertex_value_matrix
1894            .as_ref()
1895            .expect("tile layer must carry the per-vertex value matrix");
1896        assert_eq!(vm.len(), 1);
1897        assert_eq!(vm[0].len(), nverts * num_buckets);
1898    }
1899
1900    /// A `max_zoom_field` ceiling (paired with `min_zoom_field`) confines a
1901    /// feature to a single-zoom band: present at zoom == its band, absent above
1902    /// AND below. This is what keeps coarse-zoom clustered corridors out of the
1903    /// full-resolution deep zooms.
1904    #[test]
1905    fn max_zoom_field_confines_feature_to_band() {
1906        let mut p = point(-73.98, 40.75, 1_600_000_000_000);
1907        {
1908            let props = std::sync::Arc::make_mut(p.shared_properties.as_mut().unwrap());
1909            props.insert("min_zoom".to_string(), serde_json::json!(11));
1910            props.insert("max_zoom".to_string(), serde_json::json!(11));
1911        }
1912        let config = TileConfig {
1913            min_zoom: 10,
1914            max_zoom: 12,
1915            layer_name: "flows".to_string(),
1916            min_zoom_field: Some("min_zoom".to_string()),
1917            max_zoom_field: Some("max_zoom".to_string()),
1918            ..TileConfig::default()
1919        };
1920        let tiles = generate_tiles(&[p], &config, 1).unwrap();
1921        let zooms: Vec<u8> = tiles.iter().map(|t| t.id.z).collect();
1922        assert_eq!(
1923            zooms,
1924            vec![11],
1925            "feature must appear only at its single-zoom band, got {zooms:?}"
1926        );
1927    }
1928
1929    /// The tight covering lower bound `cover_t_min` is the earliest feature
1930    /// START in a tile — strictly after the bucket-aligned `time_start` when the
1931    /// data sits late in the bucket — and survives build → write → read.
1932    #[test]
1933    fn cover_t_min_tracks_earliest_feature_through_build_and_read() {
1934        let hour = 3_600_000u64;
1935        let base = 1_600_000_000_000u64;
1936        // All points land in the SECOND half of their hour bucket, so the tight
1937        // lower bound is well after the bucket edge.
1938        let mut features = Vec::new();
1939        for i in 0..12u64 {
1940            let lon = -122.45 + i as f64 * 0.02; // spread across tiles
1941            let ts = base + hour / 2 + i * 1000;
1942            features.push(point(lon, 37.75, ts));
1943        }
1944
1945        let config = TileConfig {
1946            min_zoom: 8,
1947            max_zoom: 11,
1948            layer_name: "default".to_string(),
1949            temporal_bucket_ms: hour,
1950            clip_trajectories: false,
1951            ..TileConfig::default()
1952        };
1953        let tiles = generate_tiles(&features, &config, 2).unwrap();
1954        assert!(!tiles.is_empty());
1955        // Every tile's covering bound is ≥ its bucket edge, and at least one is
1956        // strictly tighter (the whole point of the lever).
1957        assert!(tiles.iter().all(|t| t.cover_t_min >= t.time_start));
1958        assert!(
1959            tiles.iter().any(|t| t.cover_t_min > t.time_start),
1960            "expected a tile whose earliest feature is after the bucket edge"
1961        );
1962
1963        let dir = tempfile::tempdir().unwrap();
1964        let mut writer = PackWriter::create(dir.path(), BlobOrdering::Auto, 64 * 1024 * 1024).unwrap();
1965        for tile in &tiles {
1966            writer.write_tile(tile).unwrap();
1967        }
1968        writer.finalize(&Metadata::new("cover")).unwrap();
1969
1970        let reader = PackedReader::open(dir.path().join("manifest.json")).unwrap();
1971        // The covering section round-trips: every entry carries a bound and at
1972        // least one is tighter than its bucket edge.
1973        assert!(reader.entries().iter().all(|e| e.cover_t_min.is_some()));
1974        assert!(reader
1975            .entries()
1976            .iter()
1977            .any(|e| e.cover_t_min.unwrap() > e.time_start));
1978    }
1979
1980    /// Full pipeline: features -> tiles -> archive -> read back.
1981    #[test]
1982    fn end_to_end_points_archive_roundtrip() {
1983        let hour = 3_600_000u64;
1984        // 40 points across two temporal buckets near SF.
1985        let mut features = Vec::new();
1986        for i in 0..40u64 {
1987            let lon = -122.45 + (i % 8) as f64 * 0.01;
1988            let lat = 37.75 + (i / 8) as f64 * 0.01;
1989            let ts = 1_600_000_000_000 + (i % 2) * hour + i * 1000;
1990            features.push(point(lon, lat, ts));
1991        }
1992
1993        let config = TileConfig {
1994            min_zoom: 8,
1995            max_zoom: 11,
1996            layer_name: "default".to_string(),
1997            temporal_bucket_ms: hour,
1998            clip_trajectories: false,
1999            ..TileConfig::default()
2000        };
2001
2002        let tiles = generate_tiles(&features, &config, 2).unwrap();
2003        assert!(!tiles.is_empty(), "expected tiles to be generated");
2004
2005        let dir = tempfile::tempdir().unwrap();
2006        let mut writer = PackWriter::create(dir.path(), BlobOrdering::Auto, 64 * 1024 * 1024).unwrap();
2007        for tile in &tiles {
2008            writer.write_tile(tile).unwrap();
2009        }
2010        let total_features: usize =
2011            tiles.iter().map(|t| t.feature_count() as usize).sum();
2012        writer.finalize(&Metadata::new("e2e-points")).unwrap();
2013
2014        let reader = PackedReader::open(dir.path().join("manifest.json")).unwrap();
2015        assert_eq!(reader.entries().len(), tiles.len());
2016
2017        // Every feature is represented somewhere (summed over all tiles).
2018        let archived: usize =
2019            reader.entries().iter().map(|e| e.feature_count as usize).sum();
2020        assert_eq!(archived, total_features);
2021
2022        // Decode one tile and confirm its Arrow layer is intact.
2023        let entry = reader.entries()[0].clone();
2024        let layers = reader.read_layers(&entry).unwrap();
2025        assert!(!layers.is_empty());
2026        assert!(layers[0].batch.num_rows() > 0);
2027        assert!(layers[0].batch.column_by_name("geometry").is_some());
2028        assert!(layers[0].batch.column_by_name("v").is_some());
2029    }
2030
2031    /// The parallel-encode batch path (`write_tiles_parallel`) must produce a
2032    /// byte-identical dataset to the sequential per-tile `write_tile` path —
2033    /// same directory hash, same pack hashes, same manifest. This is the
2034    /// unit-level pin for the E1 "parallel encode, deterministic write order"
2035    /// contract.
2036    #[test]
2037    fn parallel_encode_writes_byte_identical_dataset() {
2038        let hour = 3_600_000u64;
2039        let mut features = Vec::new();
2040        for i in 0..120u64 {
2041            let lon = -122.45 + (i % 12) as f64 * 0.01;
2042            let lat = 37.75 + (i / 12) as f64 * 0.01;
2043            let ts = 1_600_000_000_000 + (i % 3) * hour + i * 1000;
2044            features.push(point(lon, lat, ts));
2045        }
2046        let config = TileConfig {
2047            min_zoom: 8,
2048            max_zoom: 11,
2049            layer_name: "default".to_string(),
2050            temporal_bucket_ms: hour,
2051            clip_trajectories: false,
2052            ..TileConfig::default()
2053        };
2054        let tiles = generate_tiles(&features, &config, 2).unwrap();
2055        assert!(tiles.len() > 4, "want several tiles, got {}", tiles.len());
2056
2057        // Sequential reference.
2058        let dir_seq = tempfile::tempdir().unwrap();
2059        let mut w_seq =
2060            PackWriter::create(dir_seq.path(), BlobOrdering::Auto, 64 * 1024 * 1024).unwrap();
2061        for tile in &tiles {
2062            w_seq.write_tile(tile).unwrap();
2063        }
2064        let m_seq = w_seq.finalize(&Metadata::new("par-enc")).unwrap();
2065
2066        // Parallel encode, ordered hand-off (4 workers).
2067        let dir_par = tempfile::tempdir().unwrap();
2068        let mut w_par =
2069            PackWriter::create(dir_par.path(), BlobOrdering::Auto, 64 * 1024 * 1024).unwrap();
2070        let tagged: Vec<(&GeneratedTile, Option<u64>)> =
2071            tiles.iter().map(|t| (t, None)).collect();
2072        let mut written = 0usize;
2073        write_tiles_parallel(&mut w_par, &tagged, 4, || written += 1).unwrap();
2074        assert_eq!(written, tiles.len());
2075        let m_par = w_par.finalize(&Metadata::new("par-enc")).unwrap();
2076
2077        assert_eq!(m_seq.directory.key, m_par.directory.key, "directory hash differs");
2078        assert_eq!(
2079            m_seq.packs.iter().map(|p| &p.key).collect::<Vec<_>>(),
2080            m_par.packs.iter().map(|p| &p.key).collect::<Vec<_>>(),
2081            "pack hashes differ"
2082        );
2083        assert_eq!(m_seq.to_json_bytes().unwrap(), m_par.to_json_bytes().unwrap());
2084
2085        // Parallel encode under a TINY writer memory budget: the encode loop
2086        // now cuts sub-batches on the ~budget/4 encoded-byte cap (and the
2087        // writer itself spills), but the hand-off order — and therefore every
2088        // output byte — must not change.
2089        let dir_bud = tempfile::tempdir().unwrap();
2090        let mut w_bud =
2091            PackWriter::create(dir_bud.path(), BlobOrdering::Auto, 64 * 1024 * 1024)
2092                .unwrap()
2093                .with_memory_budget(1024);
2094        let mut written_bud = 0usize;
2095        write_tiles_parallel(&mut w_bud, &tagged, 4, || written_bud += 1).unwrap();
2096        assert_eq!(written_bud, tiles.len());
2097        let m_bud = w_bud.finalize(&Metadata::new("par-enc")).unwrap();
2098        assert_eq!(
2099            m_seq.directory.key, m_bud.directory.key,
2100            "directory hash must not depend on the encode-batch budget"
2101        );
2102        assert_eq!(
2103            m_seq.packs.iter().map(|p| &p.key).collect::<Vec<_>>(),
2104            m_bud.packs.iter().map(|p| &p.key).collect::<Vec<_>>(),
2105            "pack hashes must not depend on the encode-batch budget"
2106        );
2107        assert_eq!(m_seq.to_json_bytes().unwrap(), m_bud.to_json_bytes().unwrap());
2108    }
2109
2110    /// Trajectory clipping produces clipped linestring segments with
2111    /// per-vertex timestamps that survive the archive roundtrip.
2112    #[test]
2113    fn end_to_end_trajectory_clipping() {
2114        let features = vec![trajectory(1_000_000, 1_000_000 + 3_600_000)];
2115        let config = TileConfig {
2116            min_zoom: 9,
2117            max_zoom: 10,
2118            layer_name: "tracks".to_string(),
2119            temporal_bucket_ms: 3_600_000,
2120            clip_trajectories: true,
2121            clip_min_vertices: 2,
2122            ..TileConfig::default()
2123        };
2124
2125        let tiles = generate_tiles(&features, &config, 2).unwrap();
2126        assert!(
2127            tiles.len() > 1,
2128            "a multi-tile trajectory should clip into several tiles, got {}",
2129            tiles.len()
2130        );
2131
2132        let dir = tempfile::tempdir().unwrap();
2133        let mut writer = PackWriter::create(dir.path(), BlobOrdering::Auto, 64 * 1024 * 1024).unwrap();
2134        for tile in &tiles {
2135            writer.write_tile(tile).unwrap();
2136        }
2137        writer.finalize(&Metadata::new("e2e-tracks")).unwrap();
2138
2139        let reader = PackedReader::open(dir.path().join("manifest.json")).unwrap();
2140        let entry = reader.entries()[0].clone();
2141        let layers = reader.read_layers(&entry).unwrap();
2142        // Clipped segments are linestrings carrying a vertex_time column.
2143        assert!(layers[0].batch.column_by_name("vertex_time").is_some());
2144        assert!(layers[0].batch.column_by_name("geometry").is_some());
2145    }
2146
2147    // ------------------------------------------------------------------
2148    // Temporal LOD aggregator
2149    // ------------------------------------------------------------------
2150
2151    use stt_core::metadata::TemporalLodLevel;
2152
2153    #[test]
2154    fn lod_aggregator_emits_base_plus_per_level_tiles() {
2155        // 72 hourly points starting at a midnight boundary so they fall
2156        // into exactly 3 contiguous daily buckets. With base bucket = 1h
2157        // and an LOD level at 1d:
2158        //   - the base path produces 72 tiles per zoom (one per hour),
2159        //   - the LOD path collapses each day's hourly tiles into a single
2160        //     daily tile per zoom.
2161        let hour = 3_600_000u64;
2162        let day = 24 * hour;
2163        // 1700006400000 = 2023-11-14 00:00:00 UTC — exact day boundary.
2164        let day_aligned = 1_700_006_400_000u64;
2165        assert_eq!(day_aligned % day, 0);
2166        let mut features = Vec::new();
2167        for hour_idx in 0..72u64 {
2168            features.push(point(-122.45, 37.75, day_aligned + hour_idx * hour));
2169        }
2170        let config = TileConfig {
2171            min_zoom: 8,
2172            max_zoom: 9,
2173            layer_name: "default".to_string(),
2174            temporal_bucket_ms: hour,
2175            clip_trajectories: false,
2176            temporal_lod: vec![TemporalLodLevel {
2177                bucket_ms: day,
2178                max_zoom_level: 9,
2179            }],
2180            ..TileConfig::default()
2181        };
2182        let tagged = generate_tiles_with_lod(&features, &config, 1).unwrap();
2183        let base: Vec<&LodTaggedTile> = tagged
2184            .iter()
2185            .filter(|t| t.temporal_bucket_ms == Some(hour))
2186            .collect();
2187        let lod: Vec<&LodTaggedTile> = tagged
2188            .iter()
2189            .filter(|t| t.temporal_bucket_ms == Some(day))
2190            .collect();
2191        // 72 hourly buckets × 2 zooms.
2192        assert_eq!(base.len(), 72 * 2);
2193        // 3 daily buckets × 2 zooms.
2194        assert_eq!(lod.len(), 3 * 2);
2195        // Every emitted tile carries some bucket tag (None is never produced
2196        // by the LOD writer path).
2197        assert!(tagged.iter().all(|t| t.temporal_bucket_ms.is_some()));
2198    }
2199
2200    #[test]
2201    fn lod_aggregator_skips_zooms_above_max_zoom_level() {
2202        let hour = 3_600_000u64;
2203        let day = 24 * hour;
2204        let features = vec![point(0.0, 0.0, 1_000_000_000)];
2205        let config = TileConfig {
2206            min_zoom: 0,
2207            max_zoom: 10,
2208            layer_name: "default".to_string(),
2209            temporal_bucket_ms: hour,
2210            clip_trajectories: false,
2211            temporal_lod: vec![TemporalLodLevel {
2212                bucket_ms: day,
2213                max_zoom_level: 4,
2214            }],
2215            ..TileConfig::default()
2216        };
2217        let tagged = generate_tiles_with_lod(&features, &config, 1).unwrap();
2218        let lod: Vec<u8> = tagged
2219            .iter()
2220            .filter(|t| t.temporal_bucket_ms == Some(day))
2221            .map(|t| t.tile.id.z)
2222            .collect();
2223        // Every LOD tile sits at z<=4 (the level's max_zoom_level).
2224        assert!(!lod.is_empty());
2225        assert!(lod.iter().all(|&z| z <= 4));
2226        // Base tiles still cover the full 0..=10 zoom range.
2227        let base_zooms: std::collections::BTreeSet<u8> = tagged
2228            .iter()
2229            .filter(|t| t.temporal_bucket_ms == Some(hour))
2230            .map(|t| t.tile.id.z)
2231            .collect();
2232        assert_eq!(base_zooms, (0..=10).collect());
2233    }
2234
2235    #[test]
2236    fn lod_aggregator_rejects_non_multiple_bucket() {
2237        let config = TileConfig {
2238            temporal_bucket_ms: 3_600_000,
2239            temporal_lod: vec![TemporalLodLevel {
2240                bucket_ms: 3_600_000 + 1, // not a multiple
2241                max_zoom_level: 6,
2242            }],
2243            ..TileConfig::default()
2244        };
2245        let err = generate_tiles_with_lod(&[], &config, 1).unwrap_err();
2246        let msg = format!("{err:#}");
2247        assert!(msg.contains("multiple"), "got: {msg}");
2248    }
2249
2250    #[test]
2251    fn lod_aggregator_rejects_unsorted_levels() {
2252        let config = TileConfig {
2253            temporal_bucket_ms: 3_600_000,
2254            temporal_lod: vec![
2255                TemporalLodLevel { bucket_ms: 24 * 3_600_000, max_zoom_level: 6 },
2256                TemporalLodLevel { bucket_ms: 2 * 3_600_000, max_zoom_level: 6 },
2257            ],
2258            ..TileConfig::default()
2259        };
2260        assert!(generate_tiles_with_lod(&[], &config, 1).is_err());
2261    }
2262
2263    #[test]
2264    fn lod_tiles_carry_bucket_size_through_archive_round_trip() {
2265        // Full pipeline: build LOD-tagged tiles, write them with
2266        // LodTileWriter, read back, and confirm every directory entry's
2267        // temporal_bucket_ms matches the level that produced it.
2268        let hour = 3_600_000u64;
2269        let day = 24 * hour;
2270        let features: Vec<ParsedFeature> = (0..48u64)
2271            .map(|h| point(-122.45, 37.75, 1_700_000_000_000 + h * hour))
2272            .collect();
2273        let config = TileConfig {
2274            min_zoom: 8,
2275            max_zoom: 9,
2276            layer_name: "default".to_string(),
2277            temporal_bucket_ms: hour,
2278            clip_trajectories: false,
2279            temporal_lod: vec![TemporalLodLevel { bucket_ms: day, max_zoom_level: 9 }],
2280            ..TileConfig::default()
2281        };
2282        let tagged = generate_tiles_with_lod(&features, &config, 1).unwrap();
2283
2284        let dir = tempfile::tempdir().unwrap();
2285        let mut writer = PackWriter::create(dir.path(), BlobOrdering::Auto, 64 * 1024 * 1024).unwrap();
2286        for t in &tagged {
2287            writer.write_lod_tile(&t.tile, t.temporal_bucket_ms).unwrap();
2288        }
2289        let metadata = stt_core::metadata::Metadata::new("lod")
2290            .with_temporal_bucket_ms(hour)
2291            .with_temporal_lod(vec![TemporalLodLevel { bucket_ms: day, max_zoom_level: 9 }])
2292            .unwrap();
2293        writer.finalize(&metadata).unwrap();
2294
2295        let reader = PackedReader::open(dir.path().join("manifest.json")).unwrap();
2296        let buckets: std::collections::BTreeSet<Option<u64>> =
2297            reader.entries().iter().map(|e| e.temporal_bucket_ms).collect();
2298        // The on-disk index distinguishes base + LOD bucket sizes.
2299        assert!(buckets.contains(&Some(hour)));
2300        assert!(buckets.contains(&Some(day)));
2301        assert!(!buckets.contains(&None));
2302    }
2303
2304    // ------------------------------------------------------------------
2305    // Temporal clipping into the base path (WS-4)
2306    // ------------------------------------------------------------------
2307
2308    #[test]
2309    fn base_path_temporally_clips_trajectory_into_buckets() {
2310        // A trajectory whose timing spans two hourly buckets must split so each
2311        // (tile, bucket) is self-contained: tiles appear in >=2 distinct
2312        // temporal buckets. Without temporal clipping the whole trajectory would
2313        // land in its start bucket only.
2314        let hour = 3_600_000u64;
2315        let coords: Vec<Vec<f64>> = (0..=20)
2316            .map(|i| vec![-122.45 + i as f64 * 0.001, 37.75 + i as f64 * 0.0005])
2317            .collect();
2318        let first = coords[0].clone();
2319        let feat = ParsedFeature {
2320            geojson: Feature {
2321                bbox: None,
2322                geometry: Some(Geometry::new(GeomValue::LineString(coords))),
2323                id: None,
2324                properties: None,
2325                foreign_members: None,
2326            },
2327            shared_properties: None,
2328            timestamp: 0,
2329            end_timestamp: Some(2 * hour),
2330            vertex_timestamps: None,
2331            vertex_values: None,
2332            vertex_value_matrix: None,
2333            lon: first[0],
2334            lat: first[1],
2335        };
2336        let config = TileConfig {
2337            min_zoom: 12,
2338            max_zoom: 12,
2339            layer_name: "tracks".to_string(),
2340            temporal_bucket_ms: hour,
2341            clip_trajectories: true,
2342            ..TileConfig::default()
2343        };
2344        let tiles = generate_tiles(&[feat], &config, 1).unwrap();
2345        let h = hour as i64;
2346        let buckets: std::collections::BTreeSet<i64> = tiles
2347            .iter()
2348            .map(|t| t.time_start - t.time_start.rem_euclid(h))
2349            .collect();
2350        assert!(
2351            buckets.len() >= 2,
2352            "trajectory should temporally clip into >=2 buckets, got {buckets:?}"
2353        );
2354    }
2355
2356    // ------------------------------------------------------------------
2357    // Adaptive temporal chunking (WS-5)
2358    // ------------------------------------------------------------------
2359
2360    #[test]
2361    fn adaptive_temporal_chunking_sizes_windows_by_count() {
2362        // 100 distinct-time points in one spatial cell. With target=10 the
2363        // adaptive chunker yields ~10 windows (vs ~1 with a 1h bucket), each a
2364        // self-contained tile with a distinct (z, x, y, t) key.
2365        let features: Vec<ParsedFeature> = (0..100u64)
2366            .map(|i| point(-122.45, 37.75, 1_700_000_000_000 + i * 60_000))
2367            .collect();
2368        let config = TileConfig {
2369            min_zoom: 8,
2370            max_zoom: 8,
2371            layer_name: "default".to_string(),
2372            temporal_bucket_ms: 3_600_000,
2373            clip_trajectories: false,
2374            adaptive_target_features: Some(10),
2375            ..TileConfig::default()
2376        };
2377        let tiles = generate_tiles(&features, &config, 1).unwrap();
2378
2379        let total: usize = tiles.iter().map(|t| t.feature_count() as usize).sum();
2380        assert_eq!(total, 100, "every feature must appear exactly once");
2381        assert_eq!(tiles.len(), 10, "expected 10 windows of 10, got {}", tiles.len());
2382
2383        let keys: std::collections::BTreeSet<(u8, u32, u32, i64)> = tiles
2384            .iter()
2385            .map(|t| (t.id.z, t.id.x, t.id.y, t.time_start))
2386            .collect();
2387        assert_eq!(keys.len(), tiles.len(), "window keys must be distinct");
2388        for t in &tiles {
2389            assert!(t.feature_count() <= 11, "window over budget: {}", t.feature_count());
2390        }
2391    }
2392
2393    #[test]
2394    fn adaptive_chunking_keeps_identical_timestamps_together() {
2395        // Features sharing one exact timestamp in a cell map to the same
2396        // (z, x, y, t) key, so they cannot be split into separate tiles — they
2397        // stay in one window even past `target` (a documented constraint).
2398        let features: Vec<ParsedFeature> = (0..50)
2399            .map(|_| point(-122.45, 37.75, 1_700_000_000_000))
2400            .collect();
2401        let config = TileConfig {
2402            min_zoom: 8,
2403            max_zoom: 8,
2404            layer_name: "default".to_string(),
2405            temporal_bucket_ms: 3_600_000,
2406            clip_trajectories: false,
2407            adaptive_target_features: Some(10),
2408            ..TileConfig::default()
2409        };
2410        let tiles = generate_tiles(&features, &config, 1).unwrap();
2411        assert_eq!(tiles.len(), 1, "identical-timestamp features can't be split into tiles");
2412        assert_eq!(tiles[0].feature_count(), 50);
2413    }
2414
2415    // ------------------------------------------------------------------
2416    // Time-aware (SED) simplification (WS-8)
2417    // ------------------------------------------------------------------
2418
2419    #[test]
2420    fn time_aware_simplify_builds_tiles() {
2421        let feat = trajectory(0, 3_600_000);
2422        let config = TileConfig {
2423            min_zoom: 5,
2424            max_zoom: 5,
2425            layer_name: "tracks".to_string(),
2426            temporal_bucket_ms: 3_600_000,
2427            clip_trajectories: true,
2428            simplify: true,
2429            time_aware_simplify: true,
2430            simplify_max_zoom: 14,
2431            ..TileConfig::default()
2432        };
2433        let tiles = generate_tiles(&[feat], &config, 1).unwrap();
2434        assert!(!tiles.is_empty(), "time-aware simplify should still produce tiles");
2435        // Clipped trajectory layers carry per-vertex times (TD-TR preserved them).
2436        for t in &tiles {
2437            for l in &t.layers {
2438                assert!(l.vertex_times.is_some(), "trajectory layer should carry vertex_times");
2439            }
2440        }
2441    }
2442
2443    // ------------------------------------------------------------------
2444    // Opt-in per-tile budgets (Wave-1)
2445    // ------------------------------------------------------------------
2446
2447    /// Build 30 points that all land in one (zoom, x, y, bucket) tile so we can
2448    /// exercise the budget on a single dense tile.
2449    fn dense_single_tile_features(n: u64) -> (Vec<ParsedFeature>, TileConfig) {
2450        let base = 1_700_000_000_000u64;
2451        let features: Vec<ParsedFeature> = (0..n)
2452            // Tiny lon jitter keeps them in one zoom-6 tile while giving each a
2453            // distinct id; same timestamp -> one temporal bucket.
2454            .map(|i| point(-122.40 + i as f64 * 1e-6, 37.75, base))
2455            .collect();
2456        let config = TileConfig {
2457            min_zoom: 6,
2458            max_zoom: 6,
2459            layer_name: "default".to_string(),
2460            temporal_bucket_ms: 3_600_000,
2461            clip_trajectories: false,
2462            ..TileConfig::default()
2463        };
2464        (features, config)
2465    }
2466
2467    /// Default (no budget) leaves every feature in the tile — the inert path.
2468    #[test]
2469    fn budget_off_by_default_keeps_all_features() {
2470        let (features, config) = dense_single_tile_features(30);
2471        assert!(config.tile_budget.is_none());
2472        let tiles = generate_tiles(&features, &config, 1).unwrap();
2473        let total: u32 = tiles.iter().map(|t| t.feature_count()).sum();
2474        assert_eq!(total, 30, "no budget => no features dropped");
2475    }
2476
2477    /// `--maximum-tile-features` caps the per-tile count and drops the surplus.
2478    #[test]
2479    fn maximum_tile_features_caps_feature_count() {
2480        let (features, mut config) = dense_single_tile_features(30);
2481        config.tile_budget = Some(
2482            TileBudget::new(usize::MAX, usize::MAX, 10)
2483                .with_scorer(stt_core::budget::ImportanceScorer::Combined),
2484        );
2485        let tiles = generate_tiles(&features, &config, 1).unwrap();
2486        // All 30 collapsed into one dense tile, capped to 10.
2487        assert_eq!(tiles.len(), 1, "all points share one tile");
2488        assert_eq!(
2489            tiles[0].feature_count(),
2490            10,
2491            "feature cap of 10 must be enforced"
2492        );
2493    }
2494
2495    /// A tile already under the cap is left completely untouched.
2496    #[test]
2497    fn budget_under_cap_is_noop() {
2498        let (features, mut config) = dense_single_tile_features(5);
2499        config.tile_budget = Some(TileBudget::new(usize::MAX, usize::MAX, 10));
2500        let tiles = generate_tiles(&features, &config, 1).unwrap();
2501        let total: u32 = tiles.iter().map(|t| t.feature_count()).sum();
2502        assert_eq!(total, 5, "under-cap tile keeps every feature");
2503    }
2504
2505    /// The byte-cap axis also drops features (here a very small cap forces a
2506    /// drop even though the feature count is modest).
2507    #[test]
2508    fn maximum_tile_bytes_drops_to_fit() {
2509        let (features, mut config) = dense_single_tile_features(30);
2510        // ~48 bytes per point estimate; a 200-byte cap keeps only a few.
2511        config.tile_budget = Some(TileBudget::new(200, 200, usize::MAX));
2512        let tiles = generate_tiles(&features, &config, 1).unwrap();
2513        let total: u32 = tiles.iter().map(|t| t.feature_count()).sum();
2514        assert!(total < 30, "byte cap must drop some features, kept {total}");
2515        assert!(total >= 1, "byte cap should still keep at least one feature");
2516    }
2517
2518    // ------------------------------------------------------------------
2519    // Non-trajectory clipping (T1.1) + projection-failure drops (T1.2)
2520    // ------------------------------------------------------------------
2521
2522    fn square_ring(min_lon: f64, min_lat: f64, max_lon: f64, max_lat: f64) -> Vec<Vec<f64>> {
2523        vec![
2524            vec![min_lon, min_lat],
2525            vec![max_lon, min_lat],
2526            vec![max_lon, max_lat],
2527            vec![min_lon, max_lat],
2528            vec![min_lon, min_lat],
2529        ]
2530    }
2531
2532    fn polygon_feature(rings: Vec<Vec<Vec<f64>>>, ts: u64) -> ParsedFeature {
2533        let first = rings[0][0].clone();
2534        ParsedFeature {
2535            geojson: Feature {
2536                bbox: None,
2537                geometry: Some(Geometry::new(GeomValue::Polygon(rings))),
2538                id: None,
2539                properties: None,
2540                foreign_members: None,
2541            },
2542            shared_properties: None,
2543            timestamp: ts,
2544            end_timestamp: None,
2545            vertex_timestamps: None,
2546            vertex_values: None,
2547            vertex_value_matrix: None,
2548            lon: first[0],
2549            lat: first[1],
2550        }
2551    }
2552
2553    /// Every ring of every polygon feature in the tile must be closed, non-
2554    /// degenerate, and stay inside the tile's buffered rect.
2555    fn assert_valid_polygon_rings(tile: &GeneratedTile) {
2556        use stt_core::arrow_tile::GeometryColumn;
2557        let (min_lon, min_lat, max_lon, max_lat) =
2558            stt_core::projection::tile_geo_bounds(tile.id.z, tile.id.x, tile.id.y);
2559        let (bl, bb, br, bt) = (
2560            min_lon - 0.001 - 1e-9,
2561            min_lat - 0.001 - 1e-9,
2562            max_lon + 0.001 + 1e-9,
2563            max_lat + 0.001 + 1e-9,
2564        );
2565        for layer in &tile.layers {
2566            let GeometryColumn::Polygon(features) = &layer.geometry else {
2567                panic!("expected polygon geometry in {:?}", tile.id);
2568            };
2569            for rings in features {
2570                assert!(!rings.is_empty(), "feature with no rings in {:?}", tile.id);
2571                for ring in rings {
2572                    assert!(ring.len() >= 4, "degenerate ring in {:?}: {ring:?}", tile.id);
2573                    assert_eq!(ring.first(), ring.last(), "unclosed ring in {:?}", tile.id);
2574                    for [lon, lat] in ring {
2575                        assert!(
2576                            *lon >= bl && *lon <= br && *lat >= bb && *lat <= bt,
2577                            "vertex ({lon}, {lat}) escapes buffered bounds of {:?}",
2578                            tile.id
2579                        );
2580                    }
2581                }
2582            }
2583        }
2584    }
2585
2586    /// T1.1 headline case: a polygon straddling the 4-tile corner at (0°, 0°)
2587    /// must appear — clipped, with valid rings — in ALL 4 tiles, not just the
2588    /// one holding its representative point.
2589    #[test]
2590    fn polygon_spanning_four_tiles_is_clipped_into_each() {
2591        let feat = polygon_feature(
2592            vec![square_ring(-0.1, -0.1, 0.1, 0.1)],
2593            1_700_000_000_000,
2594        );
2595        let config = TileConfig {
2596            min_zoom: 10,
2597            max_zoom: 10,
2598            layer_name: "areas".to_string(),
2599            temporal_bucket_ms: 3_600_000,
2600            clip_trajectories: false,
2601            ..TileConfig::default()
2602        };
2603        let tiles = generate_tiles(&[feat], &config, 1).unwrap();
2604        let mut cells: Vec<(u32, u32, u32)> = tiles
2605            .iter()
2606            .map(|t| (t.id.x, t.id.y, t.feature_count()))
2607            .collect();
2608        cells.sort();
2609        assert_eq!(
2610            cells,
2611            vec![(511, 511, 1), (511, 512, 1), (512, 511, 1), (512, 512, 1)],
2612            "polygon around (0°,0°) must be present in all 4 corner tiles"
2613        );
2614        for tile in &tiles {
2615            assert_valid_polygon_rings(tile);
2616        }
2617    }
2618
2619    /// A polygon with a hole keeps the hole in every tile it spans (rings are
2620    /// clipped independently) and the multi-ring pieces auto-bake the
2621    /// hole-aware triangle sidecar.
2622    #[test]
2623    fn polygon_with_hole_keeps_hole_in_every_tile() {
2624        use stt_core::arrow_tile::GeometryColumn;
2625        let feat = polygon_feature(
2626            vec![
2627                square_ring(-0.1, -0.1, 0.1, 0.1),
2628                square_ring(-0.05, -0.05, 0.05, 0.05),
2629            ],
2630            1_700_000_000_000,
2631        );
2632        let config = TileConfig {
2633            min_zoom: 10,
2634            max_zoom: 10,
2635            layer_name: "areas".to_string(),
2636            temporal_bucket_ms: 3_600_000,
2637            clip_trajectories: false,
2638            ..TileConfig::default()
2639        };
2640        let tiles = generate_tiles(&[feat], &config, 1).unwrap();
2641        assert_eq!(tiles.len(), 4, "holed polygon still covers the 4 corner tiles");
2642        for tile in &tiles {
2643            assert_valid_polygon_rings(tile);
2644            let layer = &tile.layers[0];
2645            let GeometryColumn::Polygon(features) = &layer.geometry else {
2646                panic!("expected polygon geometry");
2647            };
2648            assert_eq!(features.len(), 1);
2649            assert_eq!(
2650                features[0].len(),
2651                2,
2652                "tile {:?} lost the hole ring: {} ring(s)",
2653                tile.id,
2654                features[0].len()
2655            );
2656            assert!(
2657                layer.triangles.is_some(),
2658                "multi-ring piece must auto-bake hole-aware triangles"
2659            );
2660        }
2661    }
2662
2663    /// FAST PATH: a polygon fully inside one buffered tile must take the
2664    /// legacy single-placement path — output byte-identical to a build with
2665    /// non-trajectory clipping disabled.
2666    #[test]
2667    fn fully_inside_polygon_is_byte_identical_to_legacy_path() {
2668        let ts = 1_700_000_000_000u64;
2669        let make =
2670            || polygon_feature(vec![square_ring(-122.5, 37.8, -122.45, 37.85)], ts);
2671        let config = TileConfig {
2672            min_zoom: 10,
2673            max_zoom: 10,
2674            layer_name: "areas".to_string(),
2675            temporal_bucket_ms: 3_600_000,
2676            clip_trajectories: false,
2677            ..TileConfig::default()
2678        };
2679        let legacy_config = TileConfig {
2680            clip_non_trajectory: false,
2681            ..config.clone()
2682        };
2683        let new_tiles = generate_tiles(&[make()], &config, 1).unwrap();
2684        let old_tiles = generate_tiles(&[make()], &legacy_config, 1).unwrap();
2685        assert_eq!(new_tiles.len(), 1);
2686        assert_eq!(old_tiles.len(), 1);
2687        assert_eq!(
2688            (new_tiles[0].id.z, new_tiles[0].id.x, new_tiles[0].id.y, new_tiles[0].id.t),
2689            (old_tiles[0].id.z, old_tiles[0].id.x, old_tiles[0].id.y, old_tiles[0].id.t),
2690        );
2691        let new_bytes = encode_tile(&new_tiles[0].layers).unwrap();
2692        let old_bytes = encode_tile(&old_tiles[0].layers).unwrap();
2693        assert_eq!(
2694            new_bytes, old_bytes,
2695            "fully-inside polygon must be byte-identical to the legacy path"
2696        );
2697    }
2698
2699    /// Kill switch: `clip_non_trajectory: false` (`--whole-feature-placement`)
2700    /// restores the legacy behaviour — the spanning polygon lands whole in the
2701    /// single tile containing its representative point.
2702    #[test]
2703    fn whole_feature_placement_restores_single_tile_placement() {
2704        let feat = polygon_feature(
2705            vec![square_ring(-0.1, -0.1, 0.1, 0.1)],
2706            1_700_000_000_000,
2707        );
2708        let config = TileConfig {
2709            min_zoom: 10,
2710            max_zoom: 10,
2711            layer_name: "areas".to_string(),
2712            temporal_bucket_ms: 3_600_000,
2713            clip_trajectories: false,
2714            clip_non_trajectory: false,
2715            ..TileConfig::default()
2716        };
2717        let tiles = generate_tiles(&[feat], &config, 1).unwrap();
2718        assert_eq!(tiles.len(), 1, "kill switch must restore single-tile placement");
2719        // Representative point = first exterior vertex (-0.1, -0.1) → (511, 512).
2720        assert_eq!((tiles[0].id.x, tiles[0].id.y), (511, 512));
2721        assert_eq!(tiles[0].feature_count(), 1);
2722    }
2723
2724    /// A timeless LineString (no duration) spanning several tiles must be
2725    /// present — spatially clipped, with NO vertex_time column (timeless
2726    /// semantics preserved) — in each of them.
2727    #[test]
2728    fn timeless_line_spanning_tiles_present_in_each() {
2729        use stt_core::arrow_tile::GeometryColumn;
2730        let coords = vec![vec![-0.1, 0.05], vec![0.1, 0.05]];
2731        let first = coords[0].clone();
2732        let feat = ParsedFeature {
2733            geojson: Feature {
2734                bbox: None,
2735                geometry: Some(Geometry::new(GeomValue::LineString(coords))),
2736                id: None,
2737                properties: None,
2738                foreign_members: None,
2739            },
2740            shared_properties: None,
2741            timestamp: 1_700_000_000_000,
2742            end_timestamp: None,
2743            vertex_timestamps: None,
2744            vertex_values: None,
2745            vertex_value_matrix: None,
2746            lon: first[0],
2747            lat: first[1],
2748        };
2749        let config = TileConfig {
2750            min_zoom: 10,
2751            max_zoom: 10,
2752            layer_name: "roads".to_string(),
2753            temporal_bucket_ms: 3_600_000,
2754            ..TileConfig::default()
2755        };
2756        let tiles = generate_tiles(&[feat], &config, 1).unwrap();
2757        let mut cells: Vec<(u32, u32)> = tiles.iter().map(|t| (t.id.x, t.id.y)).collect();
2758        cells.sort();
2759        assert_eq!(
2760            cells,
2761            vec![(511, 511), (512, 511)],
2762            "timeless line crossing lon 0 must be present in both tiles"
2763        );
2764        for tile in &tiles {
2765            assert_eq!(tile.feature_count(), 1);
2766            let layer = &tile.layers[0];
2767            assert!(
2768                layer.vertex_times.is_none(),
2769                "timeless line must not grow a vertex_time column"
2770            );
2771            let GeometryColumn::LineString(lines) = &layer.geometry else {
2772                panic!("expected linestring geometry");
2773            };
2774            assert!(lines[0].len() >= 2, "clipped line must keep >=2 vertices");
2775        }
2776    }
2777
2778    /// MultiPoint members are split per containing tile — every member is
2779    /// rendered at its own position (the legacy path placed, and rendered,
2780    /// only the whole feature's representative point).
2781    #[test]
2782    fn multipoint_members_split_per_containing_tile() {
2783        use stt_core::arrow_tile::GeometryColumn;
2784        let members = vec![vec![-0.1, 0.05], vec![0.1, 0.05]];
2785        let feat = ParsedFeature {
2786            geojson: Feature {
2787                bbox: None,
2788                geometry: Some(Geometry::new(GeomValue::MultiPoint(members.clone()))),
2789                id: None,
2790                properties: None,
2791                foreign_members: None,
2792            },
2793            shared_properties: None,
2794            timestamp: 1_700_000_000_000,
2795            end_timestamp: None,
2796            vertex_timestamps: None,
2797            vertex_values: None,
2798            vertex_value_matrix: None,
2799            lon: members[0][0],
2800            lat: members[0][1],
2801        };
2802        let config = TileConfig {
2803            min_zoom: 10,
2804            max_zoom: 10,
2805            layer_name: "stations".to_string(),
2806            temporal_bucket_ms: 3_600_000,
2807            ..TileConfig::default()
2808        };
2809        let tiles = generate_tiles(&[feat], &config, 1).unwrap();
2810        let mut cells: Vec<(u32, u32)> = tiles.iter().map(|t| (t.id.x, t.id.y)).collect();
2811        cells.sort();
2812        assert_eq!(cells, vec![(511, 511), (512, 511)], "one tile per member");
2813        let mut seen: Vec<[f64; 2]> = Vec::new();
2814        for tile in &tiles {
2815            assert_eq!(tile.feature_count(), 1);
2816            let GeometryColumn::Point(pts) = &tile.layers[0].geometry else {
2817                panic!("expected point geometry");
2818            };
2819            seen.push(pts[0]);
2820        }
2821        seen.sort_by(|a, b| a[0].partial_cmp(&b[0]).unwrap());
2822        assert_eq!(
2823            seen,
2824            vec![[-0.1, 0.05], [0.1, 0.05]],
2825            "each tile's point must sit at its member's own position"
2826        );
2827    }
2828
2829    /// A duration MultiLineString routes each part through the trajectory
2830    /// clipper: pieces land in every crossed tile, carry vertex times, and
2831    /// share the parent's stable feature id.
2832    #[test]
2833    fn duration_multilinestring_clips_parts_as_segment_runs() {
2834        let parts = vec![
2835            vec![vec![-0.1, 0.05], vec![0.1, 0.05]],
2836            vec![vec![-0.1, 0.02], vec![0.1, 0.02]],
2837        ];
2838        let feat = ParsedFeature {
2839            geojson: Feature {
2840                bbox: None,
2841                geometry: Some(Geometry::new(GeomValue::MultiLineString(parts))),
2842                id: Some(geojson::feature::Id::String("mls-1".to_string())),
2843                properties: None,
2844                foreign_members: None,
2845            },
2846            shared_properties: None,
2847            timestamp: 0,
2848            end_timestamp: Some(3_600_000),
2849            vertex_timestamps: None,
2850            vertex_values: None,
2851            vertex_value_matrix: None,
2852            lon: -0.1,
2853            lat: 0.05,
2854        };
2855        let config = TileConfig {
2856            min_zoom: 10,
2857            max_zoom: 10,
2858            layer_name: "tracks".to_string(),
2859            temporal_bucket_ms: 3_600_000,
2860            clip_trajectories: true,
2861            ..TileConfig::default()
2862        };
2863        let tiles = generate_tiles(&[feat], &config, 1).unwrap();
2864        let xs: std::collections::BTreeSet<u32> = tiles.iter().map(|t| t.id.x).collect();
2865        assert!(
2866            xs.len() >= 2,
2867            "duration MultiLineString must span multiple tile columns, got {xs:?}"
2868        );
2869        let mut ids: std::collections::BTreeSet<u64> = std::collections::BTreeSet::new();
2870        for tile in &tiles {
2871            for layer in &tile.layers {
2872                assert!(
2873                    layer.vertex_times.is_some(),
2874                    "trajectory pieces must carry vertex times"
2875                );
2876                ids.extend(layer.feature_ids.iter().copied());
2877            }
2878        }
2879        assert_eq!(
2880            ids.len(),
2881            1,
2882            "all pieces must share the parent's stable feature id, got {ids:?}"
2883        );
2884    }
2885
2886    /// Capture writer for streaming-stats assertions.
2887    struct CaptureWriter(Vec<(u8, u32, u32, u32)>);
2888    impl TileWriter for CaptureWriter {
2889        fn write_tile(&mut self, tile: &GeneratedTile) -> Result<()> {
2890            self.0
2891                .push((tile.id.z, tile.id.x, tile.id.y, tile.feature_count()));
2892            Ok(())
2893        }
2894    }
2895
2896    /// T1.2: a feature whose latitude lies beyond the Web-Mercator clamp is
2897    /// DROPPED and COUNTED — it never lands in tile (0, 0) as a phantom.
2898    #[test]
2899    fn invalid_coordinates_are_dropped_and_counted() {
2900        let good = point(-122.4194, 37.7749, 1_700_000_000_000);
2901        let bad = point(0.0, 89.9, 1_700_000_000_000); // beyond ±85.0511
2902        let config = TileConfig {
2903            min_zoom: 0,
2904            max_zoom: 5,
2905            layer_name: "default".to_string(),
2906            temporal_bucket_ms: 3_600_000,
2907            clip_trajectories: false,
2908            ..TileConfig::default()
2909        };
2910        let mut writer = CaptureWriter(Vec::new());
2911        let stats =
2912            generate_tiles_streaming(&[good, bad], &config, &mut writer, 1).unwrap();
2913        // Counted once per (feature, zoom): zooms 0..=5.
2914        assert_eq!(stats.dropped_invalid_coords, 6, "drop must be counted per zoom");
2915        assert_eq!(writer.0.len(), 6, "one tile per zoom for the valid point only");
2916        for (z, x, y, count) in &writer.0 {
2917            assert_eq!(*count, 1, "phantom feature leaked into z{z}/{x}/{y}");
2918            let (ex, ey) = projection::lonlat_to_tile(-122.4194, 37.7749, *z).unwrap();
2919            assert_eq!((*x, *y), (ex, ey), "tile must be the valid point's tile");
2920        }
2921    }
2922
2923    /// Antimeridian-crossing polygons (bbox wider than 180°) fall back to the
2924    /// legacy single-tile placement AT THE REPRESENTATIVE POINT and are counted
2925    /// once per (feature, zoom) — a documented limitation, NOT silent smearing
2926    /// across the whole world.
2927    ///
2928    /// This pins the CURRENT contract, which is a fallback, not correct
2929    /// wrap-aware SPLITTING: unlike the trajectory clipper (which splits a
2930    /// polyline at |Δlon|>180° — see `clip::test_clip_trajectory_splits_at_
2931    /// antimeridian`), `place_polygon` has NO wrap-aware split for polygon rings
2932    /// and `clip_polygons_to_tiles` explicitly refuses a >180° bbox. Correct
2933    /// splitting is a FEATURE gap, not a test gap; the guarantee here is only
2934    /// that the polygon is filed into ONE tile (the first-vertex tile), never
2935    /// smeared across the tile columns its clamped-lon bbox nominally spans.
2936    #[test]
2937    fn antimeridian_polygon_falls_back_to_single_tile_and_is_counted() {
2938        // `polygon_feature` sets the representative point to the exterior ring's
2939        // FIRST vertex — `(min_lon, min_lat)` for `square_ring`, i.e. the SW
2940        // corner (-179.9, 10.0). The fallback (`place_whole_feature`) files the
2941        // whole polygon into that point's tile.
2942        let feat = polygon_feature(
2943            vec![square_ring(-179.9, 10.0, 179.9, 20.0)],
2944            1_700_000_000_000,
2945        );
2946        let (min_zoom, max_zoom) = (2u8, 5u8);
2947        let config = TileConfig {
2948            min_zoom,
2949            max_zoom,
2950            layer_name: "areas".to_string(),
2951            temporal_bucket_ms: 3_600_000,
2952            clip_trajectories: false,
2953            ..TileConfig::default()
2954        };
2955        let mut writer = CaptureWriter(Vec::new());
2956        let stats = generate_tiles_streaming(&[feat], &config, &mut writer, 1).unwrap();
2957
2958        let num_zooms = (max_zoom - min_zoom + 1) as usize;
2959        // Counted once per (feature, zoom) — never silent.
2960        assert_eq!(stats.antimeridian_fallbacks, num_zooms);
2961        // EXACTLY one tile per zoom is the anti-smear guarantee: at zoom 5 a
2962        // straight Sutherland–Hodgman sweep in clamped-lon space would file the
2963        // polygon into many of the 32 columns instead of the single fallback.
2964        assert_eq!(
2965            writer.0.len(),
2966            num_zooms,
2967            "polygon smeared across multiple tiles instead of the single fallback tile"
2968        );
2969        for (z, x, y, count) in &writer.0 {
2970            assert_eq!(*count, 1, "phantom copy leaked into z{z}/{x}/{y}");
2971            // The one written tile is the representative point's (first-vertex) tile.
2972            let (ex, ey) = projection::lonlat_to_tile(-179.9, 10.0, *z).unwrap();
2973            assert_eq!(
2974                (*x, *y),
2975                (ex, ey),
2976                "fallback must land in the representative point's tile at z{z}"
2977            );
2978        }
2979        // One tile at each distinct zoom in range (none skipped, none doubled).
2980        let mut zooms: Vec<u8> = writer.0.iter().map(|t| t.0).collect();
2981        zooms.sort_unstable();
2982        assert_eq!(zooms, (min_zoom..=max_zoom).collect::<Vec<_>>());
2983    }
2984}