1use 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#[derive(Debug)]
23pub struct GeneratedTile {
24 pub id: TileId,
26 pub time_start: i64,
28 pub time_end: i64,
30 pub cover_t_min: i64,
35 pub layers: Vec<ColumnarLayer>,
37}
38
39impl GeneratedTile {
40 pub fn feature_count(&self) -> u32 {
42 self.layers.iter().map(|l| l.feature_count() as u32).sum()
43 }
44}
45
46pub trait TileWriter {
48 fn write_tile(&mut self, tile: &GeneratedTile) -> Result<()>;
50
51 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#[derive(Debug, Default)]
66pub struct TileStats {
67 pub total_tiles: usize,
69 pub clipped_segments: usize,
71 pub original_features: usize,
73 pub dropped_invalid_coords: usize,
78 pub antimeridian_fallbacks: usize,
82}
83
84#[derive(Debug, Clone)]
86pub struct TileConfig {
87 pub min_zoom: u8,
89 pub max_zoom: u8,
91 pub layer_name: String,
93 pub temporal_bucket_ms: u64,
95 pub clip_trajectories: bool,
97 pub clip_non_trajectory: bool,
105 pub clip_min_vertices: usize,
107 pub simplify: bool,
109 pub simplify_max_zoom: u8,
111 pub pre_tessellate: bool,
115 pub temporal_lod: Vec<stt_core::metadata::TemporalLodLevel>,
122 pub min_features_per_tile: u32,
129 pub time_aware_simplify: bool,
134 pub adaptive_target_features: Option<u32>,
141 pub min_zoom_field: Option<String>,
147 pub max_zoom_field: Option<String>,
155 pub tile_budget: Option<TileBudget>,
164 pub attribute_filter: AttributeFilter,
168 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 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 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#[derive(Debug)]
234pub struct LodTaggedTile {
235 pub tile: GeneratedTile,
236 pub temporal_bucket_ms: Option<u64>,
237}
238
239#[derive(Debug, Clone)]
242enum TileFeature<'a> {
243 Original(&'a ParsedFeature),
244 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#[derive(Debug, Default)]
283struct PlacementCounters {
284 invalid_coords: AtomicUsize,
288 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
314pub 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
362fn generate_lod_level(
375 features: &[ParsedFeature],
376 level: &stt_core::metadata::TemporalLodLevel,
377 base_config: &TileConfig,
378) -> Result<Vec<GeneratedTile>> {
379 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(), ..base_config.clone()
388 };
389 if lod_config.max_zoom < lod_config.min_zoom {
390 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
415fn 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
449pub 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 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
491pub 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 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 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 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
569pub 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
584pub 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 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
643fn 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 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
678fn feature_min_zoom(feature: &ParsedFeature, field: &Option<String>) -> Option<u8> {
683 feature_zoom_bound(feature, field)
684}
685
686fn feature_max_zoom(feature: &ParsedFeature, field: &Option<String>) -> Option<u8> {
689 feature_zoom_bound(feature, field)
690}
691
692fn 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
704fn 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
721fn 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
738fn 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
791fn 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 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 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 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 lon: feature.lon,
907 lat: feature.lat,
908 }),
909 )
910 })
911 .collect()
912}
913
914fn 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 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 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 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 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 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
1162fn 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 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
1216fn 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 let placed: Vec<(u32, u32, TileFeature)> = features
1270 .par_iter()
1271 .flat_map(|feature| {
1272 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 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 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
1332fn 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
1346fn 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 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
1381fn build_tile(
1383 id: TileId,
1384 features: &[TileFeature],
1385 config: &TileConfig,
1386 time_start: i64,
1387 time_end: i64,
1388) -> Result<Option<GeneratedTile>> {
1389 let kept_indices = config
1395 .tile_budget
1396 .as_ref()
1397 .map(|budget| apply_tile_budget(budget, id, features));
1398 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 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 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
1462fn tile_feature_size(f: &TileFeature) -> usize {
1466 let (verts, props) = tile_feature_signals(f);
1467 verts * 16 + props * 16 + 32
1468}
1469
1470fn 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
1491fn 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
1508fn 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
1542fn 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
1558const ENCODE_CHUNK: usize = 1024;
1562
1563fn 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 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 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
1637pub 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
1652impl 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 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
1682pub trait LodTileWriter {
1684 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 #[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; 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 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 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 assert!(encode_single_tile(&feats, z, x + 9, y, bucket_start as i64, &config, &enc)
1789 .unwrap()
1790 .is_none());
1791
1792 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 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 #[test]
1837 fn matrix_corridor_builds_one_tile_spanning_range() {
1838 let num_buckets = 4usize;
1839 let bucket_ms = 900_000u64; let start = 1_420_070_400_000u64;
1841 let end = start + num_buckets as u64 * bucket_ms;
1842 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 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 assert_eq!(tile.time_start, start as i64);
1889 assert_eq!(tile.time_end, end as i64);
1890 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 #[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 #[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 let mut features = Vec::new();
1939 for i in 0..12u64 {
1940 let lon = -122.45 + i as f64 * 0.02; 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 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 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 #[test]
1982 fn end_to_end_points_archive_roundtrip() {
1983 let hour = 3_600_000u64;
1984 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 let archived: usize =
2019 reader.entries().iter().map(|e| e.feature_count as usize).sum();
2020 assert_eq!(archived, total_features);
2021
2022 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 #[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 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 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 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 #[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 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 use stt_core::metadata::TemporalLodLevel;
2152
2153 #[test]
2154 fn lod_aggregator_emits_base_plus_per_level_tiles() {
2155 let hour = 3_600_000u64;
2162 let day = 24 * hour;
2163 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 assert_eq!(base.len(), 72 * 2);
2193 assert_eq!(lod.len(), 3 * 2);
2195 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 assert!(!lod.is_empty());
2225 assert!(lod.iter().all(|&z| z <= 4));
2226 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, 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 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 assert!(buckets.contains(&Some(hour)));
2300 assert!(buckets.contains(&Some(day)));
2301 assert!(!buckets.contains(&None));
2302 }
2303
2304 #[test]
2309 fn base_path_temporally_clips_trajectory_into_buckets() {
2310 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 #[test]
2361 fn adaptive_temporal_chunking_sizes_windows_by_count() {
2362 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 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 #[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 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 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 .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 #[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 #[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 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 #[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 #[test]
2508 fn maximum_tile_bytes_drops_to_fit() {
2509 let (features, mut config) = dense_single_tile_features(30);
2510 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 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 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 #[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 #[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 #[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 #[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 assert_eq!((tiles[0].id.x, tiles[0].id.y), (511, 512));
2721 assert_eq!(tiles[0].feature_count(), 1);
2722 }
2723
2724 #[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 #[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 #[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 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 #[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); 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 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 #[test]
2937 fn antimeridian_polygon_falls_back_to_single_tile_and_is_counted() {
2938 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 assert_eq!(stats.antimeridian_fallbacks, num_zooms);
2961 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 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 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}