use anyhow::{Context, Result};
use std::collections::{HashMap, HashSet};
use crate::columnar::AttributeFilter;
use stt_core::arrow_tile::{VectorElem, VectorGroup};
use stt_core::budget::{ImportanceScorer, TileBudget};
use stt_core::metadata::TemporalLodLevel;
pub fn parse_duration(s: &str) -> Result<u64> {
let s = s.trim().to_lowercase();
let mut num_str = String::new();
let mut unit = String::new();
for c in s.chars() {
if c.is_ascii_digit() || c == '.' {
num_str.push(c);
} else {
unit.push(c);
}
}
let value: f64 = num_str
.parse()
.with_context(|| format!("Invalid duration value: {s}"))?;
let multiplier: u64 = match unit.as_str() {
"ms" | "" => 1,
"s" | "sec" => 1000,
"m" | "min" => 60 * 1000,
"h" | "hr" | "hour" => 60 * 60 * 1000,
"d" | "day" => 24 * 60 * 60 * 1000,
_ => anyhow::bail!(
"Invalid duration unit '{unit}'. Use ms, s, m, h, or d (e.g., '1h', '30m', '6h')"
),
};
Ok((value * multiplier as f64) as u64)
}
pub fn parse_temporal_lod(s: &str, fallback_max_zoom: u8) -> Result<Vec<TemporalLodLevel>> {
let mut levels = Vec::new();
for piece in s.split(',') {
let piece = piece.trim();
if piece.is_empty() {
continue;
}
let (dur, zoom) = match piece.split_once('@') {
Some((d, z)) => {
let z: u8 = z
.trim()
.parse()
.with_context(|| format!("invalid zoom in temporal-lod entry '{piece}'"))?;
(d.trim(), z)
}
None => (piece, fallback_max_zoom),
};
let bucket_ms = parse_duration(dur)
.with_context(|| format!("invalid duration in temporal-lod entry '{piece}'"))?;
levels.push(TemporalLodLevel {
bucket_ms,
max_zoom_level: zoom,
});
}
Ok(levels)
}
pub fn parse_quantize_attrs(specs: &[String]) -> Result<HashMap<String, f64>> {
let mut attrs: HashMap<String, f64> = HashMap::new();
for spec in specs {
let (name, prec) = spec
.split_once('=')
.ok_or_else(|| anyhow::anyhow!("--quantize-attr expects NAME=PREC, got {spec:?}"))?;
let prec: f64 = prec
.trim()
.parse()
.map_err(|_| anyhow::anyhow!("--quantize-attr {spec:?}: PREC {prec:?} is not a number"))?;
if prec <= 0.0 {
anyhow::bail!("--quantize-attr {spec:?}: PREC must be > 0");
}
attrs.insert(name.trim().to_string(), prec);
}
Ok(attrs)
}
pub fn parse_vector_groups(specs: &[String]) -> Result<Vec<VectorGroup>> {
let mut groups: Vec<VectorGroup> = Vec::new();
for spec in specs {
let (name, rest) = spec.split_once('=').ok_or_else(|| {
anyhow::anyhow!("--vector-group expects NAME=COLS[:f32|u8], got {spec:?}")
})?;
let (cols_str, elem) = match rest.rsplit_once(':') {
Some((cols, "u8")) => (cols, VectorElem::U8),
Some((cols, "f32")) => (cols, VectorElem::F32),
Some((_, other)) => {
anyhow::bail!("--vector-group {spec:?}: leaf type {other:?} must be f32 or u8")
}
None => (rest, VectorElem::F32),
};
let components: Vec<String> = cols_str
.split(',')
.map(|c| c.trim().to_string())
.filter(|c| !c.is_empty())
.collect();
if components.is_empty() {
anyhow::bail!("--vector-group {spec:?}: no component columns");
}
groups.push(VectorGroup {
name: name.trim().to_string(),
components,
elem,
});
}
Ok(groups)
}
#[derive(Debug, Clone, Default)]
pub struct EncoderSettings {
pub vertex_time_precision: Option<u32>,
pub quantize_coords_m: f64,
pub quantize_attr: Vec<String>,
pub quantize_attrs_auto: bool,
pub vector_group: Vec<String>,
pub point_elevation_column: Option<String>,
}
impl EncoderSettings {
pub fn apply(&self) -> Result<Vec<String>> {
let mut enabled: Vec<String> = Vec::new();
if let Some(p) = self.vertex_time_precision {
stt_core::arrow_tile::set_vertex_time_max_step_ms(p);
if p != stt_core::arrow_tile::DEFAULT_VERTEX_TIME_MAX_STEP_MS {
enabled.push(format!("vertex-time-precision={p}ms"));
}
}
stt_core::arrow_tile::set_quantize_coords_m(self.quantize_coords_m);
if self.quantize_coords_m > 0.0 {
enabled.push(format!("quantize-coords={}m", self.quantize_coords_m));
}
if !self.quantize_attr.is_empty() {
let attrs = parse_quantize_attrs(&self.quantize_attr)?;
enabled.push(format!("quantize-attr={attrs:?}"));
stt_core::arrow_tile::set_quantize_attrs(attrs);
}
if self.quantize_attrs_auto {
stt_core::arrow_tile::set_quantize_attrs_auto(true);
enabled.push("quantize-attrs-auto".to_string());
}
if !self.vector_group.is_empty() {
let groups = parse_vector_groups(&self.vector_group)?;
enabled.push(format!("vector-groups={}", groups.len()));
stt_core::arrow_tile::set_vector_groups(groups);
}
if let Some(col) = self.point_elevation_column.as_deref() {
if !col.is_empty() {
stt_core::arrow_tile::set_point_elevation_column(col);
enabled.push(format!("point-elevation-column={col}"));
}
}
Ok(enabled)
}
pub fn resolve(&self) -> Result<stt_core::arrow_tile::EncoderConfig> {
Ok(stt_core::arrow_tile::EncoderConfig {
quantize_coords_m: (self.quantize_coords_m > 0.0).then_some(self.quantize_coords_m),
quantize_attrs: parse_quantize_attrs(&self.quantize_attr)?,
quantize_attrs_auto: self.quantize_attrs_auto,
vector_groups: parse_vector_groups(&self.vector_group)?,
point_elevation_column: self.point_elevation_column.clone().unwrap_or_default(),
vertex_time_max_step_ms: self
.vertex_time_precision
.unwrap_or(stt_core::arrow_tile::DEFAULT_VERTEX_TIME_MAX_STEP_MS),
})
}
}
pub fn build_tile_budget(
max_bytes: Option<usize>,
max_features: Option<usize>,
drop_densest: bool,
) -> Option<TileBudget> {
if max_bytes.is_none() && max_features.is_none() {
return None;
}
let max_bytes = max_bytes.unwrap_or(usize::MAX);
let max_features = max_features.unwrap_or(usize::MAX);
let scorer = if drop_densest {
ImportanceScorer::GeometrySize
} else {
ImportanceScorer::Combined
};
Some(TileBudget::new(max_bytes, max_bytes, max_features).with_scorer(scorer))
}
pub fn build_attribute_filter(
exclude: &[String],
include: &[String],
exclude_all: bool,
required: &[String],
) -> Result<AttributeFilter> {
let has_exclude = !exclude.is_empty();
let has_include = !include.is_empty();
let modes = [has_exclude, has_include, exclude_all]
.iter()
.filter(|b| **b)
.count();
if modes > 1 {
anyhow::bail!(
"--exclude, --include and --exclude-all are mutually exclusive; pass at most one"
);
}
let filter = if exclude_all {
AttributeFilter::ExcludeAll
} else if has_include {
AttributeFilter::Include(include.iter().cloned().collect())
} else if has_exclude {
AttributeFilter::Exclude(exclude.iter().cloned().collect())
} else {
AttributeFilter::KeepAll
};
let dropped_required: Vec<&String> =
required.iter().filter(|p| !filter.keeps(p)).collect();
if !dropped_required.is_empty() {
let uniq: HashSet<&String> = dropped_required.into_iter().collect();
let mut names: Vec<&str> = uniq.iter().map(|s| s.as_str()).collect();
names.sort_unstable();
anyhow::bail!(
"attribute filter would drop column(s) still needed by another build feature \
(--heatmap-weight/--heatmap-class/--summary-columns/--min-zoom-field/\
--max-zoom-field): {}. Add them to --include (or drop the conflicting flag).",
names.join(", ")
);
}
Ok(filter)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn durations() {
assert_eq!(parse_duration("1h").unwrap(), 3_600_000);
assert_eq!(parse_duration("30m").unwrap(), 1_800_000);
assert_eq!(parse_duration("1d").unwrap(), 86_400_000);
assert_eq!(parse_duration("500ms").unwrap(), 500);
assert_eq!(parse_duration("1.5h").unwrap(), 5_400_000);
assert!(parse_duration("5w").is_err());
}
#[test]
fn temporal_lod_parsing() {
let lod = parse_temporal_lod("1d,30d@4", 8).unwrap();
assert_eq!(lod.len(), 2);
assert_eq!(lod[0].bucket_ms, 86_400_000);
assert_eq!(lod[0].max_zoom_level, 8); assert_eq!(lod[1].max_zoom_level, 4); }
#[test]
fn quantize_attr_specs() {
let m = parse_quantize_attrs(&["z=0.05".into(), "speed=0.1".into()]).unwrap();
assert_eq!(m.get("z"), Some(&0.05));
assert_eq!(m.get("speed"), Some(&0.1));
assert!(parse_quantize_attrs(&["bad".into()]).is_err());
assert!(parse_quantize_attrs(&["z=0".into()]).is_err());
}
#[test]
fn vector_group_specs() {
let g = parse_vector_groups(&["rgba=r,g,b,a:u8".into(), "quat=qx,qy,qz,qw".into()]).unwrap();
assert_eq!(g.len(), 2);
assert_eq!(g[0].name, "rgba");
assert_eq!(g[0].components, vec!["r", "g", "b", "a"]);
assert!(matches!(g[0].elem, VectorElem::U8));
assert!(matches!(g[1].elem, VectorElem::F32));
assert!(parse_vector_groups(&["bad".into()]).is_err());
assert!(parse_vector_groups(&["x=a:f64".into()]).is_err());
}
#[test]
fn budget_off_by_default() {
assert!(build_tile_budget(None, None, false).is_none());
let b = build_tile_budget(Some(50_000), None, false).expect("budget");
assert_eq!(b.max_uncompressed_size, 50_000);
assert_eq!(b.max_feature_count, usize::MAX);
}
#[test]
fn attribute_filter_modes_and_guard() {
assert!(matches!(
build_attribute_filter(&[], &[], false, &[]).unwrap(),
AttributeFilter::KeepAll
));
assert!(build_attribute_filter(&["a".into()], &["b".into()], false, &[]).is_err());
let err = build_attribute_filter(&["mag".into()], &[], false, &["mag".into()])
.unwrap_err()
.to_string();
assert!(err.contains("mag"), "got: {err}");
}
}