use crate::camera_projection::CameraProjection;
use crate::expression::{ExprEvalContext, Expression};
use crate::geometry::{Feature, FeatureCollection, Geometry};
use crate::layer::{Layer, LayerId};
use crate::query::feature_id_for_feature;
use crate::symbols::{
SymbolAnchor, SymbolCandidate, SymbolIconTextFit, SymbolPlacement, SymbolTextJustify,
SymbolTextTransform, SymbolWritingMode,
};
use crate::terrain::TerrainManager;
use crate::tessellator;
use crate::visualization::ColorRamp;
use rustial_math::{GeoCoord, TileId, WorldCoord};
use std::any::Any;
use std::sync::Arc;
const METERS_PER_DEGREE: f64 = 111_319.49;
const DEGREES_PER_PIXEL_APPROX: f64 = 0.00001;
const METERS_PER_PIXEL_APPROX: f64 = METERS_PER_DEGREE * DEGREES_PER_PIXEL_APPROX;
const DEFAULT_CIRCLE_SEGMENTS: usize = 20;
#[derive(Debug, Clone)]
pub struct PatternImage {
pub width: u32,
pub height: u32,
pub data: Vec<u8>,
}
impl PatternImage {
pub fn new(width: u32, height: u32, data: Vec<u8>) -> Self {
assert_eq!(
data.len(),
(width * height * 4) as usize,
"RGBA8 data length must be width × height × 4"
);
Self {
width,
height,
data,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum VectorRenderMode {
#[default]
Generic,
Fill,
Line,
Circle,
Heatmap,
FillExtrusion,
Symbol,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum LineCap {
#[default]
Butt,
Round,
Square,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum LineJoin {
#[default]
Miter,
Bevel,
Round,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FeatureProvenance {
pub source_layer: Option<String>,
pub source_tile: Option<TileId>,
}
#[derive(Debug, Clone)]
pub struct VectorStyle {
pub render_mode: VectorRenderMode,
pub fill_color: [f32; 4],
pub stroke_color: [f32; 4],
pub stroke_width: f32,
pub point_radius: f32,
pub line_cap: LineCap,
pub line_join: LineJoin,
pub miter_limit: f32,
pub dash_array: Option<Vec<f32>>,
pub heatmap_radius: f32,
pub heatmap_intensity: f32,
pub extrusion_base: f32,
pub extrusion_height: f32,
pub symbol_size: f32,
pub symbol_halo_color: [f32; 4],
pub symbol_text_field: Option<String>,
pub symbol_icon_image: Option<String>,
pub symbol_font_stack: String,
pub symbol_padding: f32,
pub symbol_allow_overlap: bool,
pub symbol_text_allow_overlap: bool,
pub symbol_icon_allow_overlap: bool,
pub symbol_text_optional: bool,
pub symbol_icon_optional: bool,
pub symbol_text_ignore_placement: bool,
pub symbol_icon_ignore_placement: bool,
pub symbol_text_anchor: SymbolAnchor,
pub symbol_text_justify: SymbolTextJustify,
pub symbol_text_transform: SymbolTextTransform,
pub symbol_text_max_width: Option<f32>,
pub symbol_text_line_height: Option<f32>,
pub symbol_text_letter_spacing: Option<f32>,
pub symbol_icon_text_fit: SymbolIconTextFit,
pub symbol_icon_text_fit_padding: [f32; 4],
pub symbol_sort_key: Option<f32>,
pub symbol_anchors: Vec<SymbolAnchor>,
pub symbol_placement: SymbolPlacement,
pub symbol_spacing: f32,
pub symbol_max_angle: f32,
pub symbol_keep_upright: bool,
pub symbol_text_radial_offset: Option<f32>,
pub symbol_variable_anchor_offsets: Option<Vec<(SymbolAnchor, [f32; 2])>>,
pub symbol_writing_mode: SymbolWritingMode,
pub symbol_offset: [f32; 2],
pub fill_translate: [f32; 2],
pub fill_opacity: f32,
pub fill_antialias: bool,
pub fill_outline_color: Option<[f32; 4]>,
pub fill_pattern: Option<Arc<PatternImage>>,
pub line_pattern: Option<Arc<PatternImage>>,
pub width_expr: Option<Expression<f32>>,
pub stroke_color_expr: Option<Expression<[f32; 4]>>,
pub eval_zoom: f32,
pub line_gradient: Option<ColorRamp>,
}
impl Default for VectorStyle {
fn default() -> Self {
Self {
render_mode: VectorRenderMode::Generic,
fill_color: [0.2, 0.5, 0.8, 0.5],
stroke_color: [0.0, 0.0, 0.0, 1.0],
stroke_width: 2.0,
point_radius: 6.0,
line_cap: LineCap::Butt,
line_join: LineJoin::Miter,
miter_limit: 2.0,
dash_array: None,
heatmap_radius: 18.0,
heatmap_intensity: 1.0,
extrusion_base: 0.0,
extrusion_height: 30.0,
symbol_size: 10.0,
symbol_halo_color: [1.0, 1.0, 1.0, 0.85],
symbol_text_field: None,
symbol_icon_image: None,
symbol_font_stack: "Noto Sans Regular".into(),
symbol_padding: 2.0,
symbol_allow_overlap: false,
symbol_text_allow_overlap: false,
symbol_icon_allow_overlap: false,
symbol_text_optional: false,
symbol_icon_optional: false,
symbol_text_ignore_placement: false,
symbol_icon_ignore_placement: false,
symbol_text_anchor: SymbolAnchor::Center,
symbol_text_justify: SymbolTextJustify::Auto,
symbol_text_transform: SymbolTextTransform::None,
symbol_text_max_width: None,
symbol_text_line_height: None,
symbol_text_letter_spacing: None,
symbol_icon_text_fit: SymbolIconTextFit::None,
symbol_icon_text_fit_padding: [0.0, 0.0, 0.0, 0.0],
symbol_sort_key: None,
symbol_anchors: vec![SymbolAnchor::Center],
symbol_placement: SymbolPlacement::Point,
symbol_spacing: 250.0,
symbol_max_angle: 45.0,
symbol_keep_upright: true,
symbol_text_radial_offset: None,
symbol_variable_anchor_offsets: None,
symbol_writing_mode: SymbolWritingMode::Horizontal,
symbol_offset: [0.0, 0.0],
fill_translate: [0.0, 0.0],
fill_opacity: 1.0,
fill_antialias: true,
fill_outline_color: None,
fill_pattern: None,
line_pattern: None,
width_expr: None,
stroke_color_expr: None,
eval_zoom: 0.0,
line_gradient: None,
}
}
}
impl VectorStyle {
pub fn fill(fill_color: [f32; 4], outline_color: [f32; 4], outline_width: f32) -> Self {
Self {
render_mode: VectorRenderMode::Fill,
fill_color,
stroke_color: outline_color,
stroke_width: outline_width,
..Self::default()
}
}
pub fn fill_pattern(pattern: Arc<PatternImage>) -> Self {
Self {
render_mode: VectorRenderMode::Fill,
fill_pattern: Some(pattern),
..Self::default()
}
}
pub fn line_pattern(width: f32, pattern: Arc<PatternImage>) -> Self {
Self {
render_mode: VectorRenderMode::Line,
stroke_width: width,
line_pattern: Some(pattern),
..Self::default()
}
}
pub fn line(color: [f32; 4], width: f32) -> Self {
Self {
render_mode: VectorRenderMode::Line,
stroke_color: color,
stroke_width: width,
..Self::default()
}
}
pub fn line_styled(
color: [f32; 4],
width: f32,
cap: LineCap,
join: LineJoin,
miter_limit: f32,
dash_array: Option<Vec<f32>>,
) -> Self {
Self {
render_mode: VectorRenderMode::Line,
stroke_color: color,
stroke_width: width,
line_cap: cap,
line_join: join,
miter_limit,
dash_array,
..Self::default()
}
}
pub fn line_gradient(width: f32, ramp: ColorRamp) -> Self {
Self {
render_mode: VectorRenderMode::Line,
stroke_color: [1.0, 1.0, 1.0, 1.0],
stroke_width: width,
line_gradient: Some(ramp),
..Self::default()
}
}
pub fn circle(color: [f32; 4], radius: f32, stroke_color: [f32; 4], stroke_width: f32) -> Self {
Self {
render_mode: VectorRenderMode::Circle,
fill_color: color,
point_radius: radius,
stroke_color,
stroke_width,
..Self::default()
}
}
pub fn heatmap(color: [f32; 4], radius: f32, intensity: f32) -> Self {
Self {
render_mode: VectorRenderMode::Heatmap,
fill_color: color,
heatmap_radius: radius,
heatmap_intensity: intensity,
..Self::default()
}
}
pub fn fill_extrusion(color: [f32; 4], base: f32, height: f32) -> Self {
Self {
render_mode: VectorRenderMode::FillExtrusion,
fill_color: color,
extrusion_base: base,
extrusion_height: height,
..Self::default()
}
}
pub fn symbol(color: [f32; 4], halo_color: [f32; 4], size: f32) -> Self {
Self {
render_mode: VectorRenderMode::Symbol,
fill_color: color,
symbol_halo_color: halo_color,
symbol_size: size,
..Self::default()
}
}
pub fn tessellation_fingerprint(&self) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
std::mem::discriminant(&self.render_mode).hash(&mut h);
self.fill_color
.iter()
.for_each(|v| v.to_bits().hash(&mut h));
self.stroke_color
.iter()
.for_each(|v| v.to_bits().hash(&mut h));
self.stroke_width.to_bits().hash(&mut h);
self.point_radius.to_bits().hash(&mut h);
self.heatmap_radius.to_bits().hash(&mut h);
self.heatmap_intensity.to_bits().hash(&mut h);
self.extrusion_base.to_bits().hash(&mut h);
self.extrusion_height.to_bits().hash(&mut h);
self.symbol_size.to_bits().hash(&mut h);
self.symbol_halo_color
.iter()
.for_each(|v| v.to_bits().hash(&mut h));
self.fill_translate
.iter()
.for_each(|v| v.to_bits().hash(&mut h));
self.fill_opacity.to_bits().hash(&mut h);
self.fill_antialias.hash(&mut h);
if let Some(ref c) = self.fill_outline_color {
c.iter().for_each(|v| v.to_bits().hash(&mut h));
}
let has_dd_width = self.width_expr.as_ref().is_some_and(|e| e.is_data_driven());
let has_dd_color = self
.stroke_color_expr
.as_ref()
.is_some_and(|e| e.is_data_driven());
has_dd_width.hash(&mut h);
has_dd_color.hash(&mut h);
if has_dd_width || has_dd_color {
self.eval_zoom.to_bits().hash(&mut h);
}
h.finish()
}
#[inline]
pub fn is_width_data_driven(&self) -> bool {
self.width_expr.as_ref().is_some_and(|e| e.is_data_driven())
}
#[inline]
pub fn is_stroke_color_data_driven(&self) -> bool {
self.stroke_color_expr
.as_ref()
.is_some_and(|e| e.is_data_driven())
}
pub fn evaluate_width(&self, feature: &Feature) -> f32 {
match &self.width_expr {
Some(expr) if expr.is_data_driven() => {
let ctx = ExprEvalContext::with_feature(self.eval_zoom, &feature.properties);
expr.evaluate_with_properties(&ctx)
}
_ => self.stroke_width,
}
}
pub fn evaluate_stroke_color(&self, feature: &Feature) -> [f32; 4] {
match &self.stroke_color_expr {
Some(expr) if expr.is_data_driven() => {
let ctx = ExprEvalContext::with_feature(self.eval_zoom, &feature.properties);
expr.evaluate_with_properties(&ctx)
}
_ => self.stroke_color,
}
}
}
#[derive(Clone, Copy)]
struct LinePlacementAnchor {
coord: GeoCoord,
rotation_rad: f32,
distance: f64,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct CircleInstanceData {
pub center: [f64; 3],
pub radius: f32,
pub color: [f32; 4],
pub stroke_color: [f32; 4],
pub stroke_width: f32,
pub blur: f32,
}
#[derive(Debug, Clone)]
pub struct VectorMeshData {
pub positions: Vec<[f64; 3]>,
pub colors: Vec<[f32; 4]>,
pub normals: Vec<[f32; 3]>,
pub indices: Vec<u32>,
pub render_mode: VectorRenderMode,
pub line_distances: Vec<f32>,
pub line_normals: Vec<[f32; 2]>,
pub line_cap_joins: Vec<f32>,
pub line_params: [f32; 4],
pub circle_instances: Vec<CircleInstanceData>,
pub heatmap_points: Vec<[f64; 4]>,
pub heatmap_intensity: f32,
pub fill_translate: [f32; 2],
pub fill_opacity: f32,
pub fill_antialias: bool,
pub fill_outline_color: [f32; 4],
pub fill_pattern: Option<Arc<PatternImage>>,
pub fill_pattern_uvs: Vec<[f32; 2]>,
pub line_pattern: Option<Arc<PatternImage>>,
pub line_pattern_uvs: Vec<[f32; 2]>,
}
impl VectorMeshData {
#[inline]
pub fn is_empty(&self) -> bool {
self.indices.is_empty()
}
#[inline]
pub fn vertex_count(&self) -> usize {
self.positions.len()
}
#[inline]
pub fn index_count(&self) -> usize {
self.indices.len()
}
#[inline]
pub fn triangle_count(&self) -> usize {
self.indices.len() / 3
}
#[inline]
pub fn has_normals(&self) -> bool {
!self.normals.is_empty()
}
pub fn merge(&mut self, other: &VectorMeshData) {
let base = self.positions.len() as u32;
self.positions.extend_from_slice(&other.positions);
self.colors.extend_from_slice(&other.colors);
self.normals.extend_from_slice(&other.normals);
self.line_distances.extend_from_slice(&other.line_distances);
self.line_normals.extend_from_slice(&other.line_normals);
self.fill_pattern_uvs
.extend_from_slice(&other.fill_pattern_uvs);
self.line_pattern_uvs
.extend_from_slice(&other.line_pattern_uvs);
self.indices.extend(other.indices.iter().map(|i| base + i));
self.circle_instances
.extend_from_slice(&other.circle_instances);
self.heatmap_points.extend_from_slice(&other.heatmap_points);
}
pub fn clear(&mut self) {
self.positions.clear();
self.colors.clear();
self.normals.clear();
self.line_distances.clear();
self.line_normals.clear();
self.indices.clear();
self.circle_instances.clear();
self.heatmap_points.clear();
self.fill_translate = [0.0, 0.0];
self.fill_opacity = 1.0;
self.fill_antialias = true;
self.fill_outline_color = [0.0, 0.0, 0.0, 0.0];
self.fill_pattern = None;
self.fill_pattern_uvs.clear();
self.line_pattern = None;
self.line_pattern_uvs.clear();
}
}
impl Default for VectorMeshData {
fn default() -> Self {
Self {
positions: Vec::new(),
colors: Vec::new(),
normals: Vec::new(),
indices: Vec::new(),
render_mode: VectorRenderMode::Generic,
line_distances: Vec::new(),
line_normals: Vec::new(),
line_cap_joins: Vec::new(),
line_params: [0.0; 4],
circle_instances: Vec::new(),
heatmap_points: Vec::new(),
heatmap_intensity: 0.0,
fill_translate: [0.0, 0.0],
fill_opacity: 1.0,
fill_antialias: true,
fill_outline_color: [0.0, 0.0, 0.0, 0.0],
fill_pattern: None,
fill_pattern_uvs: Vec::new(),
line_pattern: None,
line_pattern_uvs: Vec::new(),
}
}
}
pub struct VectorLayer {
id: LayerId,
name: String,
visible: bool,
opacity: f32,
pub query_layer_id: Option<String>,
pub query_source_id: Option<String>,
pub query_source_layer: Option<String>,
pub features: FeatureCollection,
pub feature_provenance: Vec<Option<FeatureProvenance>>,
pub style: VectorStyle,
data_generation: u64,
}
impl std::fmt::Debug for VectorLayer {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("VectorLayer")
.field("id", &self.id)
.field("name", &self.name)
.field("visible", &self.visible)
.field("opacity", &self.opacity)
.field("feature_count", &self.features.len())
.field("style", &self.style)
.finish()
}
}
impl VectorLayer {
pub fn new(name: impl Into<String>, features: FeatureCollection, style: VectorStyle) -> Self {
Self {
id: LayerId::next(),
name: name.into(),
visible: true,
opacity: 1.0,
query_layer_id: None,
query_source_id: None,
query_source_layer: None,
feature_provenance: vec![None; features.len()],
features,
style,
data_generation: 0,
}
}
pub fn with_query_metadata(
mut self,
layer_id: impl Into<Option<String>>,
source_id: impl Into<Option<String>>,
) -> Self {
self.query_layer_id = layer_id.into();
self.query_source_id = source_id.into();
self
}
pub fn with_source_layer(mut self, source_layer: Option<String>) -> Self {
self.query_source_layer = source_layer;
self
}
pub fn set_features_with_provenance(
&mut self,
features: FeatureCollection,
mut provenance: Vec<Option<FeatureProvenance>>,
) {
if provenance.len() < features.len() {
provenance.resize(features.len(), None);
} else if provenance.len() > features.len() {
provenance.truncate(features.len());
}
self.features = features;
self.feature_provenance = provenance;
self.data_generation = self.data_generation.wrapping_add(1);
}
#[inline]
pub fn data_generation(&self) -> u64 {
self.data_generation
}
pub fn set_query_metadata(&mut self, layer_id: Option<String>, source_id: Option<String>) {
self.query_layer_id = layer_id;
self.query_source_id = source_id;
}
#[inline]
pub fn feature_count(&self) -> usize {
self.features.len()
}
#[inline]
pub fn total_coords(&self) -> usize {
self.features.total_coords()
}
pub fn drape_on_terrain(&mut self, terrain: &TerrainManager) {
if !terrain.enabled() {
return;
}
for feature in &mut self.features.features {
drape_geometry(&mut feature.geometry, terrain);
}
}
pub fn tessellate(&self, projection: CameraProjection) -> VectorMeshData {
let mut mesh = VectorMeshData {
render_mode: self.style.render_mode,
..VectorMeshData::default()
};
let dd_width = self.style.is_width_data_driven();
let dd_color = self.style.is_stroke_color_data_driven();
let default_half_width = self.style.stroke_width as f64 * DEGREES_PER_PIXEL_APPROX;
if dd_width || dd_color {
for feature in &self.features.features {
let half_width = if dd_width {
self.style.evaluate_width(feature) as f64 * DEGREES_PER_PIXEL_APPROX
} else {
default_half_width
};
if dd_color {
let color = self.style.evaluate_stroke_color(feature);
let mut feature_style = self.style.clone();
feature_style.stroke_color = color;
tessellate_geometry(
&feature.geometry,
&feature_style,
projection,
half_width,
&mut mesh,
);
} else {
tessellate_geometry(
&feature.geometry,
&self.style,
projection,
half_width,
&mut mesh,
);
}
}
} else {
for feature in &self.features.features {
tessellate_geometry(
&feature.geometry,
&self.style,
projection,
default_half_width,
&mut mesh,
);
}
}
if self.style.render_mode == VectorRenderMode::Fill {
mesh.fill_translate = self.style.fill_translate;
mesh.fill_opacity = self.style.fill_opacity;
mesh.fill_antialias = self.style.fill_antialias;
mesh.fill_outline_color = self
.style
.fill_outline_color
.unwrap_or(self.style.stroke_color);
}
if self.style.render_mode == VectorRenderMode::Line {
let (dash_len, gap_len) = match &self.style.dash_array {
Some(arr) if arr.len() >= 2 => (arr[0], arr[1]),
_ => (0.0, 0.0),
};
let cap_round = match self.style.line_cap {
LineCap::Round => 1.0,
_ => 0.0,
};
mesh.line_params = [dash_len, gap_len, cap_round, 0.0];
}
mesh
}
pub fn symbol_candidates(&self) -> Vec<SymbolCandidate> {
self.symbol_candidates_for_features(&self.features, &self.feature_provenance)
}
pub fn symbol_candidates_for_features(
&self,
features: &FeatureCollection,
feature_provenance: &[Option<FeatureProvenance>],
) -> Vec<SymbolCandidate> {
if self.style.render_mode != VectorRenderMode::Symbol {
return Vec::new();
}
let mut out = Vec::new();
for (feature_index, feature) in features.features.iter().enumerate() {
collect_symbol_candidates_from_geometry(
self.id,
self.query_layer_id.as_deref(),
self.query_source_id.as_deref(),
feature_provenance
.get(feature_index)
.and_then(|p| p.as_ref()),
feature_index,
0,
feature,
&feature.geometry,
&self.style,
&mut out,
);
}
out
}
}
#[allow(clippy::too_many_arguments)]
fn collect_symbol_candidates_from_geometry(
layer_id: LayerId,
query_layer_id: Option<&str>,
query_source_id: Option<&str>,
provenance: Option<&FeatureProvenance>,
feature_index: usize,
point_index: usize,
feature: &Feature,
geometry: &Geometry,
style: &VectorStyle,
out: &mut Vec<SymbolCandidate>,
) -> usize {
if style.symbol_placement == SymbolPlacement::Line {
return collect_line_symbol_candidates_from_geometry(
layer_id,
query_layer_id,
query_source_id,
provenance,
feature_index,
point_index,
feature,
geometry,
style,
out,
);
}
match geometry {
Geometry::Point(point) => {
let candidates = symbol_candidates_for_point(
layer_id,
query_layer_id,
query_source_id,
provenance,
feature_index,
point_index,
feature,
point.coord,
style,
);
out.extend(candidates);
point_index + 1
}
Geometry::MultiPoint(points) => {
let mut next = point_index;
for point in &points.points {
next = collect_symbol_candidates_from_geometry(
layer_id,
query_layer_id,
query_source_id,
provenance,
feature_index,
next,
feature,
&Geometry::Point(point.clone()),
style,
out,
);
}
next
}
Geometry::GeometryCollection(geometries) => {
let mut next = point_index;
for geometry in geometries {
next = collect_symbol_candidates_from_geometry(
layer_id,
query_layer_id,
query_source_id,
provenance,
feature_index,
next,
feature,
geometry,
style,
out,
);
}
next
}
_ => point_index,
}
}
#[allow(clippy::too_many_arguments)]
fn collect_line_symbol_candidates_from_geometry(
layer_id: LayerId,
query_layer_id: Option<&str>,
query_source_id: Option<&str>,
provenance: Option<&FeatureProvenance>,
feature_index: usize,
point_index: usize,
feature: &Feature,
geometry: &Geometry,
style: &VectorStyle,
out: &mut Vec<SymbolCandidate>,
) -> usize {
match geometry {
Geometry::LineString(line) => {
let mut next = point_index;
let label_length = estimated_line_label_length_meters(feature, style);
for (slot_index, anchor) in line_placement_anchors(line, style, label_length)
.into_iter()
.enumerate()
{
let candidates = symbol_candidates_at_anchor(
layer_id,
query_layer_id,
query_source_id,
provenance,
feature_index,
next,
feature,
anchor.coord,
style,
anchor.rotation_rad,
Some(slot_index),
);
next += candidates.len();
out.extend(candidates);
}
next
}
Geometry::MultiLineString(lines) => {
let mut next = point_index;
for line in &lines.lines {
next = collect_line_symbol_candidates_from_geometry(
layer_id,
query_layer_id,
query_source_id,
provenance,
feature_index,
next,
feature,
&Geometry::LineString(line.clone()),
style,
out,
);
}
next
}
Geometry::GeometryCollection(geometries) => {
let mut next = point_index;
for geometry in geometries {
next = collect_line_symbol_candidates_from_geometry(
layer_id,
query_layer_id,
query_source_id,
provenance,
feature_index,
next,
feature,
geometry,
style,
out,
);
}
next
}
_ => point_index,
}
}
#[allow(clippy::too_many_arguments)]
fn symbol_candidates_for_point(
layer_id: LayerId,
query_layer_id: Option<&str>,
query_source_id: Option<&str>,
provenance: Option<&FeatureProvenance>,
feature_index: usize,
point_index: usize,
feature: &Feature,
anchor: GeoCoord,
style: &VectorStyle,
) -> Vec<SymbolCandidate> {
symbol_candidates_at_anchor(
layer_id,
query_layer_id,
query_source_id,
provenance,
feature_index,
point_index,
feature,
anchor,
style,
0.0,
None,
)
}
#[allow(clippy::too_many_arguments)]
fn symbol_candidates_at_anchor(
layer_id: LayerId,
query_layer_id: Option<&str>,
query_source_id: Option<&str>,
provenance: Option<&FeatureProvenance>,
feature_index: usize,
point_index: usize,
feature: &Feature,
anchor: GeoCoord,
style: &VectorStyle,
rotation_rad: f32,
line_slot_index: Option<usize>,
) -> Vec<SymbolCandidate> {
let feature_id = feature_id_for_feature(feature, feature_index);
let text = style
.symbol_text_field
.as_deref()
.and_then(|field| feature.property(field))
.and_then(symbol_text_from_property)
.map(|text| transform_symbol_text(text, style.symbol_text_transform));
let icon_image = style.symbol_icon_image.clone();
if text.is_none() && icon_image.is_none() {
return Vec::new();
}
fn transform_symbol_text(text: String, transform: SymbolTextTransform) -> String {
match transform {
SymbolTextTransform::None => text,
SymbolTextTransform::Uppercase => text.to_uppercase(),
SymbolTextTransform::Lowercase => text.to_lowercase(),
}
}
let cross_tile_id = symbol_cross_tile_id(
query_layer_id,
query_source_id,
&feature_id,
text.as_deref(),
icon_image.as_deref(),
anchor,
style.symbol_placement,
line_slot_index,
style,
);
let base_id = format!("{}:{feature_index}:{point_index}", layer_id.as_u64());
let text_present = text.is_some();
let icon_present = icon_image.is_some();
let text_optional = style.symbol_text_optional;
let icon_optional = style.symbol_icon_optional;
let mut variants = Vec::new();
let base_candidate = make_symbol_candidate(
&base_id,
&base_id,
query_layer_id,
query_source_id,
provenance,
&feature_id,
feature_index,
style.symbol_placement,
anchor,
text.clone(),
icon_image.clone(),
style,
cross_tile_id.clone(),
rotation_rad,
);
variants.push(base_candidate);
if text_present && icon_present {
if text_optional {
variants.push(make_symbol_candidate(
&format!("{base_id}:icon-only"),
&base_id,
query_layer_id,
query_source_id,
provenance,
&feature_id,
feature_index,
style.symbol_placement,
anchor,
None,
icon_image.clone(),
style,
cross_tile_id.clone(),
rotation_rad,
));
}
if icon_optional {
variants.push(make_symbol_candidate(
&format!("{base_id}:text-only"),
&base_id,
query_layer_id,
query_source_id,
provenance,
&feature_id,
feature_index,
style.symbol_placement,
anchor,
text,
None,
style,
cross_tile_id,
rotation_rad,
));
}
}
variants
}
#[allow(clippy::too_many_arguments)]
fn make_symbol_candidate(
id: &str,
placement_group_id: &str,
query_layer_id: Option<&str>,
query_source_id: Option<&str>,
provenance: Option<&FeatureProvenance>,
feature_id: &str,
feature_index: usize,
placement: SymbolPlacement,
anchor: GeoCoord,
text: Option<String>,
icon_image: Option<String>,
style: &VectorStyle,
cross_tile_id: String,
rotation_rad: f32,
) -> SymbolCandidate {
let has_text = text.is_some();
let has_icon = icon_image.is_some();
SymbolCandidate {
id: id.to_owned(),
layer_id: query_layer_id.map(ToOwned::to_owned),
source_id: query_source_id.map(ToOwned::to_owned),
source_layer: provenance.and_then(|p| p.source_layer.clone()),
source_tile: provenance.and_then(|p| p.source_tile),
feature_id: feature_id.to_owned(),
feature_index,
placement_group_id: placement_group_id.to_owned(),
placement,
anchor,
text,
icon_image,
font_stack: style.symbol_font_stack.clone(),
cross_tile_id,
rotation_rad,
size_px: style.symbol_size,
padding_px: style.symbol_padding,
allow_overlap: effective_symbol_overlap(style, has_text, has_icon),
ignore_placement: effective_symbol_ignore_placement(style, has_text, has_icon),
sort_key: style.symbol_sort_key,
radial_offset: style.symbol_text_radial_offset,
variable_anchor_offsets: style.symbol_variable_anchor_offsets.clone(),
text_max_width: style.symbol_text_max_width,
text_line_height: style.symbol_text_line_height,
text_letter_spacing: style.symbol_text_letter_spacing,
icon_text_fit: style.symbol_icon_text_fit,
icon_text_fit_padding: style.symbol_icon_text_fit_padding,
anchors: if style.symbol_variable_anchor_offsets.is_some() {
style
.symbol_variable_anchor_offsets
.as_ref()
.map(|offsets| offsets.iter().map(|(anchor, _)| *anchor).collect())
.unwrap_or_default()
} else {
style.symbol_anchors.clone()
},
writing_mode: style.symbol_writing_mode,
offset_px: style.symbol_offset,
fill_color: style.fill_color,
halo_color: style.symbol_halo_color,
}
}
fn effective_symbol_ignore_placement(style: &VectorStyle, has_text: bool, has_icon: bool) -> bool {
match (has_text, has_icon) {
(true, true) => style.symbol_text_ignore_placement && style.symbol_icon_ignore_placement,
(true, false) => style.symbol_text_ignore_placement,
(false, true) => style.symbol_icon_ignore_placement,
(false, false) => false,
}
}
fn effective_symbol_overlap(style: &VectorStyle, has_text: bool, has_icon: bool) -> bool {
match (has_text, has_icon) {
(true, true) => style.symbol_text_allow_overlap && style.symbol_icon_allow_overlap,
(true, false) => style.symbol_text_allow_overlap,
(false, true) => style.symbol_icon_allow_overlap,
(false, false) => style.symbol_allow_overlap,
}
}
#[allow(clippy::too_many_arguments)]
fn symbol_cross_tile_id(
query_layer_id: Option<&str>,
query_source_id: Option<&str>,
feature_id: &str,
text: Option<&str>,
icon_image: Option<&str>,
anchor: GeoCoord,
placement: SymbolPlacement,
line_slot_index: Option<usize>,
style: &VectorStyle,
) -> String {
match placement {
SymbolPlacement::Point => format!(
"{}|{}|{:.6}|{:.6}",
text.unwrap_or(""),
icon_image.unwrap_or(""),
anchor.lat,
anchor.lon,
),
SymbolPlacement::Line => {
let slot = line_slot_index.unwrap_or(0);
let world = CameraProjection::WebMercator.project(&anchor);
let coarse_bucket = ((style.symbol_spacing.max(style.symbol_size).max(1.0) as f64)
* METERS_PER_PIXEL_APPROX
* 2.0)
.max(1.0);
let bucket_x = (world.position.x / coarse_bucket).round() as i64;
let bucket_y = (world.position.y / coarse_bucket).round() as i64;
format!(
"line|{}|{}|{}|{}|{}|{}|{}|{}",
query_source_id.unwrap_or(""),
query_layer_id.unwrap_or(""),
feature_id,
slot,
bucket_x,
bucket_y,
text.unwrap_or(""),
icon_image.unwrap_or(""),
)
}
}
}
fn line_placement_anchors(
line: &crate::geometry::LineString,
style: &VectorStyle,
label_length: f64,
) -> Vec<LinePlacementAnchor> {
if line.coords.len() < 2 {
return Vec::new();
}
let projected: Vec<_> = line
.coords
.iter()
.map(|coord| CameraProjection::WebMercator.project(coord))
.collect();
let total_length: f64 = projected
.windows(2)
.map(|segment| {
let a = segment[0].position;
let b = segment[1].position;
let dx = b.x - a.x;
let dy = b.y - a.y;
let dz = b.z - a.z;
(dx * dx + dy * dy + dz * dz).sqrt()
})
.sum();
if total_length <= f64::EPSILON {
return line
.coords
.first()
.copied()
.map(|coord| LinePlacementAnchor {
coord,
rotation_rad: 0.0,
distance: total_length * 0.5,
})
.into_iter()
.collect();
}
let spacing =
(style.symbol_spacing.max(style.symbol_size).max(1.0) as f64) * METERS_PER_PIXEL_APPROX;
if spacing <= f64::EPSILON || total_length <= spacing {
return interpolate_line_anchor_at_distance(
line,
&projected,
total_length * 0.5,
style.symbol_keep_upright,
)
.filter(|anchor| {
line_anchor_passes_max_angle(&projected, anchor.distance, label_length, style)
})
.into_iter()
.collect();
}
let mut anchors = Vec::new();
let mut target = spacing * 0.5;
while target < total_length {
if let Some(anchor) =
interpolate_line_anchor_at_distance(line, &projected, target, style.symbol_keep_upright)
{
if line_anchor_passes_max_angle(&projected, anchor.distance, label_length, style) {
anchors.push(anchor);
}
}
target += spacing;
}
if anchors.is_empty() {
interpolate_line_anchor_at_distance(
line,
&projected,
total_length * 0.5,
style.symbol_keep_upright,
)
.filter(|anchor| {
line_anchor_passes_max_angle(&projected, anchor.distance, label_length, style)
})
.into_iter()
.collect()
} else {
anchors
}
}
fn estimated_line_label_length_meters(feature: &Feature, style: &VectorStyle) -> f64 {
let text = style
.symbol_text_field
.as_deref()
.and_then(|field| feature.property(field))
.and_then(symbol_text_from_property);
let icon = style.symbol_icon_image.as_deref();
let size_px = style.symbol_size.max(1.0) as f64;
let text_width_px = text
.as_deref()
.map(|value| value.chars().count() as f64 * size_px * 0.6)
.unwrap_or(0.0);
let icon_width_px = if icon.is_some() { size_px * 1.2 } else { 0.0 };
(text_width_px.max(size_px) + icon_width_px + style.symbol_padding.max(0.0) as f64 * 2.0)
* METERS_PER_PIXEL_APPROX
}
fn line_anchor_passes_max_angle(
projected: &[WorldCoord],
anchor_distance: f64,
label_length: f64,
style: &VectorStyle,
) -> bool {
if projected.len() < 3 {
return true;
}
let half_label = label_length * 0.5;
if anchor_distance - half_label < 0.0
|| anchor_distance + half_label > line_total_length(projected)
{
return false;
}
let max_angle = style.symbol_max_angle.max(0.0) as f64 * std::f64::consts::PI / 180.0;
if max_angle >= std::f64::consts::PI {
return true;
}
let start = anchor_distance - half_label;
let end = anchor_distance + half_label;
let mut distance = 0.0;
let mut accumulated_turn = 0.0;
for index in 1..projected.len() - 1 {
let prev = projected[index - 1].position;
let current = projected[index].position;
let next = projected[index + 1].position;
let segment_dx = current.x - prev.x;
let segment_dy = current.y - prev.y;
let segment_dz = current.z - prev.z;
distance +=
(segment_dx * segment_dx + segment_dy * segment_dy + segment_dz * segment_dz).sqrt();
if distance < start || distance > end {
continue;
}
let prev_angle = (current.y - prev.y).atan2(current.x - prev.x);
let next_angle = (next.y - current.y).atan2(next.x - current.x);
accumulated_turn += normalize_angle_delta(next_angle - prev_angle).abs();
if accumulated_turn > max_angle {
return false;
}
}
true
}
fn normalize_angle_delta(angle: f64) -> f64 {
((angle + std::f64::consts::PI * 3.0) % (std::f64::consts::PI * 2.0)) - std::f64::consts::PI
}
fn line_total_length(projected: &[WorldCoord]) -> f64 {
projected
.windows(2)
.map(|segment| {
let a = segment[0].position;
let b = segment[1].position;
let dx = b.x - a.x;
let dy = b.y - a.y;
let dz = b.z - a.z;
(dx * dx + dy * dy + dz * dz).sqrt()
})
.sum()
}
fn interpolate_line_anchor_at_distance(
line: &crate::geometry::LineString,
projected: &[WorldCoord],
target: f64,
keep_upright: bool,
) -> Option<LinePlacementAnchor> {
let mut traversed = 0.0;
for (coords, segment) in line.coords.windows(2).zip(projected.windows(2)) {
let a = segment[0].position;
let b = segment[1].position;
let dx = b.x - a.x;
let dy = b.y - a.y;
let dz = b.z - a.z;
let segment_length = (dx * dx + dy * dy + dz * dz).sqrt();
if segment_length <= f64::EPSILON {
continue;
}
if traversed + segment_length >= target {
let t = ((target - traversed) / segment_length).clamp(0.0, 1.0);
let start = coords[0];
let end = coords[1];
let coord = GeoCoord::new(
start.lat + (end.lat - start.lat) * t,
start.lon + (end.lon - start.lon) * t,
start.alt + (end.alt - start.alt) * t,
);
let rotation_rad =
normalize_line_label_rotation((b.y - a.y).atan2(b.x - a.x) as f32, keep_upright);
return Some(LinePlacementAnchor {
coord,
rotation_rad,
distance: target,
});
}
traversed += segment_length;
}
line.coords
.last()
.copied()
.map(|coord| LinePlacementAnchor {
coord,
rotation_rad: 0.0,
distance: target,
})
}
fn normalize_line_label_rotation(rotation_rad: f32, keep_upright: bool) -> f32 {
if !keep_upright {
return rotation_rad;
}
let mut normalized = rotation_rad;
while normalized > std::f32::consts::PI {
normalized -= std::f32::consts::TAU;
}
while normalized < -std::f32::consts::PI {
normalized += std::f32::consts::TAU;
}
if normalized > std::f32::consts::FRAC_PI_2 {
normalized -= std::f32::consts::PI;
} else if normalized < -std::f32::consts::FRAC_PI_2 {
normalized += std::f32::consts::PI;
}
normalized
}
fn symbol_text_from_property(value: &crate::geometry::PropertyValue) -> Option<String> {
match value {
crate::geometry::PropertyValue::Null => None,
crate::geometry::PropertyValue::Bool(value) => Some(value.to_string()),
crate::geometry::PropertyValue::Number(value) => Some(value.to_string()),
crate::geometry::PropertyValue::String(value) => Some(value.clone()),
}
}
fn tessellate_geometry(
geometry: &Geometry,
style: &VectorStyle,
projection: CameraProjection,
half_width: f64,
mesh: &mut VectorMeshData,
) {
match style.render_mode {
VectorRenderMode::Generic => {
tessellate_generic_geometry(geometry, style, projection, half_width, mesh)
}
VectorRenderMode::Fill => tessellate_fill_geometry(geometry, style, projection, mesh),
VectorRenderMode::Line => {
tessellate_line_geometry(geometry, style, projection, half_width, mesh)
}
VectorRenderMode::Circle => tessellate_circle_geometry(geometry, style, projection, mesh),
VectorRenderMode::Heatmap => tessellate_heatmap_geometry(geometry, style, projection, mesh),
VectorRenderMode::FillExtrusion => {
tessellate_fill_extrusion_geometry(geometry, style, projection, mesh)
}
VectorRenderMode::Symbol => tessellate_symbol_geometry(geometry, style, projection, mesh),
}
}
fn tessellate_generic_geometry(
geometry: &Geometry,
style: &VectorStyle,
projection: CameraProjection,
half_width: f64,
mesh: &mut VectorMeshData,
) {
match geometry {
Geometry::Point(p) => append_square_marker(
mesh,
&p.coord,
projection,
half_width * METERS_PER_DEGREE,
style.fill_color,
),
Geometry::LineString(ls) => append_stroked_line(
mesh,
&ls.coords,
projection,
half_width,
style.stroke_color,
style,
),
Geometry::Polygon(poly) => {
append_polygon_fill(mesh, &poly.exterior, projection, style.fill_color, None)
}
Geometry::MultiPoint(mp) => {
for p in &mp.points {
tessellate_generic_geometry(
&Geometry::Point(p.clone()),
style,
projection,
half_width,
mesh,
);
}
}
Geometry::MultiLineString(mls) => {
for ls in &mls.lines {
tessellate_generic_geometry(
&Geometry::LineString(ls.clone()),
style,
projection,
half_width,
mesh,
);
}
}
Geometry::MultiPolygon(mpoly) => {
for poly in &mpoly.polygons {
tessellate_generic_geometry(
&Geometry::Polygon(poly.clone()),
style,
projection,
half_width,
mesh,
);
}
}
Geometry::GeometryCollection(geoms) => {
for g in geoms {
tessellate_generic_geometry(g, style, projection, half_width, mesh);
}
}
}
}
fn tessellate_fill_geometry(
geometry: &Geometry,
style: &VectorStyle,
projection: CameraProjection,
mesh: &mut VectorMeshData,
) {
if style.fill_pattern.is_some() && mesh.fill_pattern.is_none() {
mesh.fill_pattern = style.fill_pattern.clone();
}
match geometry {
Geometry::Polygon(poly) => {
append_polygon_fill(
mesh,
&poly.exterior,
projection,
style.fill_color,
style.fill_pattern.as_deref(),
);
if style.stroke_width > 0.0 {
append_stroked_line(
mesh,
&poly.exterior,
projection,
style.stroke_width as f64 * DEGREES_PER_PIXEL_APPROX,
style.stroke_color,
style,
);
for hole in &poly.interiors {
append_stroked_line(
mesh,
hole,
projection,
style.stroke_width as f64 * DEGREES_PER_PIXEL_APPROX,
style.stroke_color,
style,
);
}
}
}
Geometry::MultiPolygon(mpoly) => {
for poly in &mpoly.polygons {
tessellate_fill_geometry(&Geometry::Polygon(poly.clone()), style, projection, mesh);
}
}
Geometry::GeometryCollection(geoms) => {
for g in geoms {
tessellate_fill_geometry(g, style, projection, mesh);
}
}
_ => {}
}
}
fn tessellate_line_geometry(
geometry: &Geometry,
style: &VectorStyle,
projection: CameraProjection,
half_width: f64,
mesh: &mut VectorMeshData,
) {
if style.line_pattern.is_some() && mesh.line_pattern.is_none() {
mesh.line_pattern = style.line_pattern.clone();
}
match geometry {
Geometry::LineString(ls) => append_stroked_line(
mesh,
&ls.coords,
projection,
half_width,
style.stroke_color,
style,
),
Geometry::Polygon(poly) => {
append_stroked_line(
mesh,
&poly.exterior,
projection,
half_width,
style.stroke_color,
style,
);
for hole in &poly.interiors {
append_stroked_line(
mesh,
hole,
projection,
half_width,
style.stroke_color,
style,
);
}
}
Geometry::MultiLineString(mls) => {
for ls in &mls.lines {
tessellate_line_geometry(
&Geometry::LineString(ls.clone()),
style,
projection,
half_width,
mesh,
);
}
}
Geometry::MultiPolygon(mpoly) => {
for poly in &mpoly.polygons {
tessellate_line_geometry(
&Geometry::Polygon(poly.clone()),
style,
projection,
half_width,
mesh,
);
}
}
Geometry::GeometryCollection(geoms) => {
for g in geoms {
tessellate_line_geometry(g, style, projection, half_width, mesh);
}
}
_ => {}
}
}
fn tessellate_circle_geometry(
geometry: &Geometry,
style: &VectorStyle,
projection: CameraProjection,
mesh: &mut VectorMeshData,
) {
match geometry {
Geometry::Point(p) => {
let radius = style.point_radius as f64 * DEGREES_PER_PIXEL_APPROX * METERS_PER_DEGREE;
let stroke_w =
style.stroke_width.max(0.0) as f64 * DEGREES_PER_PIXEL_APPROX * METERS_PER_DEGREE;
append_circle(
mesh,
&p.coord,
projection,
radius,
style.fill_color,
Some((style.stroke_color, stroke_w)),
);
let w = projection.project(&p.coord);
mesh.circle_instances.push(CircleInstanceData {
center: [w.position.x, w.position.y, w.position.z],
radius: radius as f32,
color: style.fill_color,
stroke_color: style.stroke_color,
stroke_width: stroke_w as f32,
blur: 0.0,
});
}
Geometry::MultiPoint(mp) => {
for p in &mp.points {
tessellate_circle_geometry(&Geometry::Point(p.clone()), style, projection, mesh);
}
}
Geometry::GeometryCollection(geoms) => {
for g in geoms {
tessellate_circle_geometry(g, style, projection, mesh);
}
}
_ => {}
}
}
fn tessellate_heatmap_geometry(
geometry: &Geometry,
style: &VectorStyle,
projection: CameraProjection,
mesh: &mut VectorMeshData,
) {
match geometry {
Geometry::Point(p) => {
let radius =
style.heatmap_radius.max(0.0) as f64 * DEGREES_PER_PIXEL_APPROX * METERS_PER_DEGREE;
append_heat_blob(
mesh,
&p.coord,
projection,
radius,
style.fill_color,
style.heatmap_intensity.max(0.0),
);
let w = projection.project(&p.coord);
let weight = style.heatmap_intensity.max(0.0) as f64;
mesh.heatmap_points
.push([w.position.x, w.position.y, weight, radius]);
}
Geometry::MultiPoint(mp) => {
for p in &mp.points {
tessellate_heatmap_geometry(&Geometry::Point(p.clone()), style, projection, mesh);
}
}
Geometry::GeometryCollection(geoms) => {
for g in geoms {
tessellate_heatmap_geometry(g, style, projection, mesh);
}
}
_ => {}
}
}
fn tessellate_fill_extrusion_geometry(
geometry: &Geometry,
style: &VectorStyle,
projection: CameraProjection,
mesh: &mut VectorMeshData,
) {
match geometry {
Geometry::Polygon(poly) => append_extruded_polygon(mesh, &poly.exterior, projection, style),
Geometry::MultiPolygon(mpoly) => {
for poly in &mpoly.polygons {
append_extruded_polygon(mesh, &poly.exterior, projection, style);
}
}
Geometry::GeometryCollection(geoms) => {
for g in geoms {
tessellate_fill_extrusion_geometry(g, style, projection, mesh);
}
}
_ => {}
}
}
fn tessellate_symbol_geometry(
geometry: &Geometry,
style: &VectorStyle,
projection: CameraProjection,
mesh: &mut VectorMeshData,
) {
match geometry {
Geometry::Point(p) => {
let size =
style.symbol_size.max(0.0) as f64 * DEGREES_PER_PIXEL_APPROX * METERS_PER_DEGREE;
append_square_marker(
mesh,
&p.coord,
projection,
size * 1.35,
style.symbol_halo_color,
);
append_square_marker(mesh, &p.coord, projection, size, style.fill_color);
}
Geometry::MultiPoint(mp) => {
for p in &mp.points {
tessellate_symbol_geometry(&Geometry::Point(p.clone()), style, projection, mesh);
}
}
Geometry::GeometryCollection(geoms) => {
for g in geoms {
tessellate_symbol_geometry(g, style, projection, mesh);
}
}
_ => {}
}
}
fn append_polygon_fill(
mesh: &mut VectorMeshData,
coords: &[GeoCoord],
projection: CameraProjection,
color: [f32; 4],
pattern: Option<&PatternImage>,
) {
let ring = normalized_ring(coords);
if ring.len() < 3 {
return;
}
let indices = tessellator::triangulate_polygon(&ring);
let base = mesh.positions.len() as u32;
for coord in &ring {
let w = projection.project(coord);
mesh.positions
.push([w.position.x, w.position.y, w.position.z]);
mesh.colors.push(color);
if let Some(pat) = pattern {
let u = w.position.x as f32 / pat.width.max(1) as f32;
let v = w.position.y as f32 / pat.height.max(1) as f32;
mesh.fill_pattern_uvs.push([u, v]);
}
}
for idx in indices {
mesh.indices.push(base + idx);
}
}
fn append_stroked_line(
mesh: &mut VectorMeshData,
coords: &[GeoCoord],
projection: CameraProjection,
half_width: f64,
color: [f32; 4],
style: &VectorStyle,
) {
let result = tessellator::stroke_line_styled(
coords,
half_width,
style.line_cap,
style.line_join,
style.miter_limit,
);
if result.positions.is_empty() {
return;
}
let gradient_max_dist = if style.line_gradient.is_some() {
result
.distances
.iter()
.cloned()
.fold(0.0_f64, f64::max)
.max(f64::EPSILON)
} else {
1.0
};
let has_pattern = style.line_pattern.is_some();
let pat_width = style
.line_pattern
.as_ref()
.map_or(1.0_f32, |p| p.width.max(1) as f32);
let mut body_side = false;
let base = mesh.positions.len() as u32;
for (i, pos) in result.positions.iter().enumerate() {
let coord = GeoCoord::from_lat_lon(pos[1], pos[0]);
let w = projection.project(&coord);
mesh.positions
.push([w.position.x, w.position.y, w.position.z]);
let vertex_color = if let Some(ref ramp) = style.line_gradient {
let t = (result.distances[i] / gradient_max_dist) as f32;
ramp.evaluate(t)
} else {
color
};
mesh.colors.push(vertex_color);
mesh.line_normals
.push([result.normals[i][0] as f32, result.normals[i][1] as f32]);
let dist_meters = (result.distances[i] * METERS_PER_DEGREE) as f32;
mesh.line_distances.push(dist_meters);
mesh.line_cap_joins.push(result.cap_join[i]);
if has_pattern {
let u = dist_meters / pat_width;
let v = if result.cap_join[i] > 0.5 {
0.5
} else {
let v = if body_side { 1.0 } else { 0.0 };
body_side = !body_side;
v
};
mesh.line_pattern_uvs.push([u, v]);
}
}
for idx in &result.indices {
mesh.indices.push(base + idx);
}
}
fn append_square_marker(
mesh: &mut VectorMeshData,
coord: &GeoCoord,
projection: CameraProjection,
half_size: f64,
color: [f32; 4],
) {
let w = projection.project(coord);
let base = mesh.positions.len() as u32;
mesh.positions.push([
w.position.x - half_size,
w.position.y - half_size,
w.position.z,
]);
mesh.positions.push([
w.position.x + half_size,
w.position.y - half_size,
w.position.z,
]);
mesh.positions.push([
w.position.x + half_size,
w.position.y + half_size,
w.position.z,
]);
mesh.positions.push([
w.position.x - half_size,
w.position.y + half_size,
w.position.z,
]);
for _ in 0..4 {
mesh.colors.push(color);
}
mesh.indices
.extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]);
}
fn append_circle(
mesh: &mut VectorMeshData,
coord: &GeoCoord,
projection: CameraProjection,
radius: f64,
fill_color: [f32; 4],
outline: Option<([f32; 4], f64)>,
) {
append_radial_fan(mesh, coord, projection, radius, fill_color, fill_color);
if let Some((outline_color, outline_width)) = outline {
if outline_width > 0.0 {
let outer = radius + outline_width;
append_ring(mesh, coord, projection, radius, outer, outline_color);
}
}
}
fn append_heat_blob(
mesh: &mut VectorMeshData,
coord: &GeoCoord,
projection: CameraProjection,
radius: f64,
color: [f32; 4],
intensity: f32,
) {
let mut center_color = color;
center_color[3] = (center_color[3] * intensity).clamp(0.0, 1.0);
let mut edge_color = color;
edge_color[3] = 0.0;
append_radial_fan(mesh, coord, projection, radius, center_color, edge_color);
}
fn append_radial_fan(
mesh: &mut VectorMeshData,
coord: &GeoCoord,
projection: CameraProjection,
radius: f64,
center_color: [f32; 4],
edge_color: [f32; 4],
) {
let center = projection.project(coord);
let base = mesh.positions.len() as u32;
mesh.positions
.push([center.position.x, center.position.y, center.position.z]);
mesh.colors.push(center_color);
for i in 0..=DEFAULT_CIRCLE_SEGMENTS {
let t = i as f64 / DEFAULT_CIRCLE_SEGMENTS as f64;
let angle = std::f64::consts::TAU * t;
mesh.positions.push([
center.position.x + radius * angle.cos(),
center.position.y + radius * angle.sin(),
center.position.z,
]);
mesh.colors.push(edge_color);
}
for i in 1..=DEFAULT_CIRCLE_SEGMENTS as u32 {
mesh.indices
.extend_from_slice(&[base, base + i, base + i + 1]);
}
}
fn append_ring(
mesh: &mut VectorMeshData,
coord: &GeoCoord,
projection: CameraProjection,
inner_radius: f64,
outer_radius: f64,
color: [f32; 4],
) {
if outer_radius <= inner_radius {
return;
}
let center = projection.project(coord);
let base = mesh.positions.len() as u32;
for i in 0..=DEFAULT_CIRCLE_SEGMENTS {
let t = i as f64 / DEFAULT_CIRCLE_SEGMENTS as f64;
let angle = std::f64::consts::TAU * t;
let (sin, cos) = angle.sin_cos();
mesh.positions.push([
center.position.x + inner_radius * cos,
center.position.y + inner_radius * sin,
center.position.z,
]);
mesh.colors.push(color);
mesh.positions.push([
center.position.x + outer_radius * cos,
center.position.y + outer_radius * sin,
center.position.z,
]);
mesh.colors.push(color);
}
for i in 0..DEFAULT_CIRCLE_SEGMENTS as u32 {
let a = base + i * 2;
mesh.indices
.extend_from_slice(&[a, a + 1, a + 2, a + 1, a + 3, a + 2]);
}
}
fn append_extruded_polygon(
mesh: &mut VectorMeshData,
coords: &[GeoCoord],
projection: CameraProjection,
style: &VectorStyle,
) {
let ring = normalized_ring(coords);
if ring.len() < 3 {
return;
}
let top_base = mesh.positions.len() as u32;
for coord in &ring {
let w = projection.project(coord);
mesh.positions.push([
w.position.x,
w.position.y,
coord.alt + style.extrusion_base as f64 + style.extrusion_height as f64,
]);
mesh.colors.push(style.fill_color);
mesh.normals.push([0.0, 0.0, 1.0]); }
for idx in tessellator::triangulate_polygon(&ring) {
mesh.indices.push(top_base + idx);
}
let side_color = [
style.fill_color[0] * 0.75,
style.fill_color[1] * 0.75,
style.fill_color[2] * 0.75,
style.fill_color[3],
];
for i in 0..ring.len() {
let a = &ring[i];
let b = &ring[(i + 1) % ring.len()];
let wa = projection.project(a);
let wb = projection.project(b);
let base_z_a = a.alt + style.extrusion_base as f64;
let base_z_b = b.alt + style.extrusion_base as f64;
let top_z_a = base_z_a + style.extrusion_height as f64;
let top_z_b = base_z_b + style.extrusion_height as f64;
let dx = (wb.position.x - wa.position.x) as f32;
let dy = (wb.position.y - wa.position.y) as f32;
let len = (dx * dx + dy * dy).sqrt().max(1e-12);
let normal = [dy / len, -dx / len, 0.0];
let base = mesh.positions.len() as u32;
mesh.positions
.push([wa.position.x, wa.position.y, base_z_a]);
mesh.positions
.push([wb.position.x, wb.position.y, base_z_b]);
mesh.positions.push([wb.position.x, wb.position.y, top_z_b]);
mesh.positions.push([wa.position.x, wa.position.y, top_z_a]);
for _ in 0..4 {
mesh.colors.push(side_color);
mesh.normals.push(normal);
}
mesh.indices
.extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]);
}
}
fn normalized_ring(coords: &[GeoCoord]) -> Vec<GeoCoord> {
if coords.len() > 1 {
let first = coords.first().expect("ring first");
let last = coords.last().expect("ring last");
if (first.lat - last.lat).abs() < 1e-12
&& (first.lon - last.lon).abs() < 1e-12
&& (first.alt - last.alt).abs() < 1e-6
{
return coords[..coords.len() - 1].to_vec();
}
}
coords.to_vec()
}
fn drape_geometry(geometry: &mut Geometry, terrain: &TerrainManager) {
match geometry {
Geometry::Point(p) => {
if let Some(elev) = terrain.elevation_at(&p.coord) {
p.coord.alt = elev;
}
}
Geometry::LineString(ls) => {
drape_coords(&mut ls.coords, terrain);
}
Geometry::Polygon(poly) => {
drape_coords(&mut poly.exterior, terrain);
for hole in &mut poly.interiors {
drape_coords(hole, terrain);
}
}
Geometry::MultiPoint(mp) => {
for p in &mut mp.points {
if let Some(elev) = terrain.elevation_at(&p.coord) {
p.coord.alt = elev;
}
}
}
Geometry::MultiLineString(mls) => {
for ls in &mut mls.lines {
drape_coords(&mut ls.coords, terrain);
}
}
Geometry::MultiPolygon(mpoly) => {
for poly in &mut mpoly.polygons {
drape_coords(&mut poly.exterior, terrain);
for hole in &mut poly.interiors {
drape_coords(hole, terrain);
}
}
}
Geometry::GeometryCollection(geoms) => {
for g in geoms {
drape_geometry(g, terrain);
}
}
}
}
fn drape_coords(coords: &mut [GeoCoord], terrain: &TerrainManager) {
for coord in coords.iter_mut() {
if let Some(elev) = terrain.elevation_at(coord) {
coord.alt = elev;
}
}
}
impl Layer for VectorLayer {
fn id(&self) -> LayerId {
self.id
}
fn kind(&self) -> crate::layer::LayerKind {
crate::layer::LayerKind::Vector
}
fn name(&self) -> &str {
&self.name
}
fn visible(&self) -> bool {
self.visible
}
fn set_visible(&mut self, visible: bool) {
self.visible = visible;
}
fn opacity(&self) -> f32 {
self.opacity
}
fn set_opacity(&mut self, opacity: f32) {
self.opacity = opacity.clamp(0.0, 1.0);
}
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::camera_projection::CameraProjection;
use crate::geometry::{
Feature, LineString, MultiLineString, MultiPoint, MultiPolygon, Point, Polygon,
};
use crate::layer::Layer;
use crate::terrain::{FlatElevationSource, TerrainConfig, TerrainManager};
use rustial_math::{WebMercator, WorldBounds, WorldCoord};
use std::collections::HashMap;
fn make_layer(geometry: Geometry) -> VectorLayer {
let features = FeatureCollection {
features: vec![Feature {
geometry,
properties: HashMap::new(),
}],
};
VectorLayer::new("test", features, VectorStyle::default())
}
fn square_polygon() -> Polygon {
Polygon {
exterior: vec![
GeoCoord::from_lat_lon(0.0, 0.0),
GeoCoord::from_lat_lon(0.0, 1.0),
GeoCoord::from_lat_lon(1.0, 1.0),
GeoCoord::from_lat_lon(1.0, 0.0),
],
interiors: vec![],
}
}
fn two_point_line() -> LineString {
LineString {
coords: vec![
GeoCoord::from_lat_lon(0.0, 0.0),
GeoCoord::from_lat_lon(0.0, 1.0),
],
}
}
fn origin_point() -> Point {
Point {
coord: GeoCoord::from_lat_lon(0.0, 0.0),
}
}
fn flat_terrain_manager() -> TerrainManager {
let config = TerrainConfig {
enabled: true,
mesh_resolution: 4,
source: Box::new(FlatElevationSource::new(4, 4)),
..TerrainConfig::default()
};
let mut mgr = TerrainManager::new(config, 100);
let extent = WebMercator::max_extent();
let bounds = WorldBounds::new(
WorldCoord::new(-extent, -extent, 0.0),
WorldCoord::new(extent, extent, 0.0),
);
mgr.update(
&bounds,
0,
(0.0, 0.0),
CameraProjection::WebMercator,
10_000_000.0,
0.0,
);
mgr.update(
&bounds,
0,
(0.0, 0.0),
CameraProjection::WebMercator,
10_000_000.0,
0.0,
);
mgr
}
#[test]
fn new_layer_defaults() {
let layer = make_layer(Geometry::Point(origin_point()));
assert_eq!(layer.name(), "test");
assert!(layer.visible());
assert!((layer.opacity() - 1.0).abs() < f32::EPSILON);
assert_eq!(layer.feature_count(), 1);
assert_eq!(layer.total_coords(), 1);
}
#[test]
fn layer_trait_visibility() {
let mut layer = make_layer(Geometry::Point(origin_point()));
layer.set_visible(false);
assert!(!layer.visible());
layer.set_visible(true);
assert!(layer.visible());
}
#[test]
fn layer_trait_opacity_clamped() {
let mut layer = make_layer(Geometry::Point(origin_point()));
layer.set_opacity(1.5);
assert!((layer.opacity() - 1.0).abs() < f32::EPSILON);
layer.set_opacity(-0.5);
assert!((layer.opacity() - 0.0).abs() < f32::EPSILON);
}
#[test]
fn debug_impl() {
let layer = make_layer(Geometry::Point(origin_point()));
let dbg = format!("{layer:?}");
assert!(dbg.contains("VectorLayer"));
assert!(dbg.contains("test"));
}
#[test]
fn tessellate_polygon() {
let layer = make_layer(Geometry::Polygon(square_polygon()));
let mesh = layer.tessellate(CameraProjection::WebMercator);
assert_eq!(mesh.vertex_count(), 4);
assert_eq!(mesh.index_count(), 6); assert_eq!(mesh.triangle_count(), 2);
assert_eq!(mesh.colors.len(), 4);
assert!(!mesh.is_empty());
}
#[test]
fn tessellate_linestring() {
let layer = make_layer(Geometry::LineString(two_point_line()));
let mesh = layer.tessellate(CameraProjection::WebMercator);
assert_eq!(mesh.vertex_count(), 4); assert_eq!(mesh.index_count(), 6);
}
#[test]
fn tessellate_point() {
let layer = make_layer(Geometry::Point(origin_point()));
let mesh = layer.tessellate(CameraProjection::WebMercator);
assert_eq!(mesh.vertex_count(), 4); assert_eq!(mesh.index_count(), 6); assert_eq!(mesh.colors[0], VectorStyle::default().fill_color);
}
#[test]
fn tessellate_multi_point() {
let mp = Geometry::MultiPoint(MultiPoint {
points: vec![origin_point(), origin_point()],
});
let layer = make_layer(mp);
let mesh = layer.tessellate(CameraProjection::WebMercator);
assert_eq!(mesh.vertex_count(), 8);
assert_eq!(mesh.index_count(), 12);
}
#[test]
fn tessellate_multi_linestring() {
let mls = Geometry::MultiLineString(MultiLineString {
lines: vec![two_point_line(), two_point_line()],
});
let layer = make_layer(mls);
let mesh = layer.tessellate(CameraProjection::WebMercator);
assert_eq!(mesh.vertex_count(), 8); assert_eq!(mesh.index_count(), 12);
}
#[test]
fn tessellate_multi_polygon() {
let mpoly = Geometry::MultiPolygon(MultiPolygon {
polygons: vec![square_polygon(), square_polygon()],
});
let layer = make_layer(mpoly);
let mesh = layer.tessellate(CameraProjection::WebMercator);
assert_eq!(mesh.vertex_count(), 8); assert_eq!(mesh.index_count(), 12);
}
#[test]
fn tessellate_geometry_collection() {
let gc = Geometry::GeometryCollection(vec![
Geometry::Point(origin_point()),
Geometry::Polygon(square_polygon()),
]);
let layer = make_layer(gc);
let mesh = layer.tessellate(CameraProjection::WebMercator);
assert_eq!(mesh.vertex_count(), 8);
assert_eq!(mesh.index_count(), 12);
}
#[test]
fn tessellate_empty_collection() {
let features = FeatureCollection { features: vec![] };
let layer = VectorLayer::new("empty", features, VectorStyle::default());
let mesh = layer.tessellate(CameraProjection::WebMercator);
assert!(mesh.is_empty());
assert_eq!(mesh.vertex_count(), 0);
}
#[test]
fn mesh_data_merge() {
let mut a = VectorMeshData {
positions: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]],
colors: vec![[1.0, 0.0, 0.0, 1.0], [1.0, 0.0, 0.0, 1.0]],
indices: vec![0, 1, 0],
..Default::default()
};
let b = VectorMeshData {
positions: vec![[2.0, 0.0, 0.0], [3.0, 0.0, 0.0]],
colors: vec![[0.0, 1.0, 0.0, 1.0], [0.0, 1.0, 0.0, 1.0]],
indices: vec![0, 1, 0],
..Default::default()
};
a.merge(&b);
assert_eq!(a.vertex_count(), 4);
assert_eq!(a.index_count(), 6);
assert_eq!(a.indices, vec![0, 1, 0, 2, 3, 2]);
}
#[test]
fn mesh_data_clear() {
let mut mesh = VectorMeshData {
positions: vec![[0.0, 0.0, 0.0]],
colors: vec![[1.0, 0.0, 0.0, 1.0]],
indices: vec![0],
..Default::default()
};
mesh.clear();
assert!(mesh.is_empty());
assert_eq!(mesh.vertex_count(), 0);
}
#[test]
fn drape_sets_altitude() {
let mgr = flat_terrain_manager();
let features = FeatureCollection {
features: vec![Feature {
geometry: Geometry::Point(Point {
coord: GeoCoord::new(10.0, 20.0, 999.0),
}),
properties: HashMap::new(),
}],
};
let mut layer = VectorLayer::new("test", features, VectorStyle::default());
layer.drape_on_terrain(&mgr);
match &layer.features.features[0].geometry {
Geometry::Point(p) => assert!((p.coord.alt - 0.0).abs() < 1e-3),
_ => panic!("expected Point"),
}
}
#[test]
fn drape_skipped_when_terrain_disabled() {
let config = TerrainConfig::default(); let mgr = TerrainManager::new(config, 100);
let features = FeatureCollection {
features: vec![Feature {
geometry: Geometry::Point(Point {
coord: GeoCoord::new(10.0, 20.0, 999.0),
}),
properties: HashMap::new(),
}],
};
let mut layer = VectorLayer::new("test", features, VectorStyle::default());
layer.drape_on_terrain(&mgr);
match &layer.features.features[0].geometry {
Geometry::Point(p) => assert!((p.coord.alt - 999.0).abs() < 1e-3),
_ => panic!("expected Point"),
}
}
#[test]
fn drape_linestring_coords() {
let mgr = flat_terrain_manager();
let features = FeatureCollection {
features: vec![Feature {
geometry: Geometry::LineString(LineString {
coords: vec![
GeoCoord::new(10.0, 20.0, 500.0),
GeoCoord::new(11.0, 21.0, 600.0),
],
}),
properties: HashMap::new(),
}],
};
let mut layer = VectorLayer::new("test", features, VectorStyle::default());
layer.drape_on_terrain(&mgr);
match &layer.features.features[0].geometry {
Geometry::LineString(ls) => {
for coord in &ls.coords {
assert!(
coord.alt.abs() < 1e-3,
"expected flat terrain, got alt={}",
coord.alt
);
}
}
_ => panic!("expected LineString"),
}
}
#[test]
fn tessellate_circle_mode() {
let style = VectorStyle {
render_mode: VectorRenderMode::Circle,
..VectorStyle::default()
};
let layer = make_layer(Geometry::Point(origin_point()));
let layer = VectorLayer::new("circle", layer.features, style);
let mesh = layer.tessellate(CameraProjection::WebMercator);
assert!(mesh.vertex_count() > 8);
assert!(mesh.index_count() >= DEFAULT_CIRCLE_SEGMENTS * 3);
}
#[test]
fn tessellate_heatmap_mode_has_faded_edges() {
let mut style = VectorStyle::heatmap([1.0, 0.0, 0.0, 0.5], 24.0, 1.0);
style.render_mode = VectorRenderMode::Heatmap;
let layer = make_layer(Geometry::Point(origin_point()));
let layer = VectorLayer::new("heatmap", layer.features, style);
let mesh = layer.tessellate(CameraProjection::WebMercator);
assert_eq!(mesh.colors.first().map(|c| c[3]), Some(0.5));
assert_eq!(mesh.colors.last().map(|c| c[3]), Some(0.0));
}
#[test]
fn tessellate_fill_extrusion_mode_produces_vertical_geometry() {
let style = VectorStyle::fill_extrusion([0.5, 0.5, 0.8, 1.0], 0.0, 50.0);
let layer = make_layer(Geometry::Polygon(square_polygon()));
let layer = VectorLayer::new("extrusion", layer.features, style);
let mesh = layer.tessellate(CameraProjection::WebMercator);
let min_z = mesh
.positions
.iter()
.map(|p| p[2])
.fold(f64::INFINITY, f64::min);
let max_z = mesh
.positions
.iter()
.map(|p| p[2])
.fold(f64::NEG_INFINITY, f64::max);
assert!(max_z > min_z);
}
#[test]
fn fill_extrusion_tessellation_produces_normals() {
let style = VectorStyle::fill_extrusion([0.5, 0.5, 0.8, 1.0], 0.0, 50.0);
let layer = make_layer(Geometry::Polygon(square_polygon()));
let layer = VectorLayer::new("extrusion", layer.features, style);
let mesh = layer.tessellate(CameraProjection::WebMercator);
assert!(mesh.has_normals());
assert_eq!(mesh.normals.len(), mesh.positions.len());
assert_eq!(mesh.render_mode, VectorRenderMode::FillExtrusion);
}
#[test]
fn fill_extrusion_top_normals_point_up() {
let style = VectorStyle::fill_extrusion([1.0, 0.0, 0.0, 1.0], 0.0, 100.0);
let layer = make_layer(Geometry::Polygon(square_polygon()));
let layer = VectorLayer::new("extrusion", layer.features, style);
let mesh = layer.tessellate(CameraProjection::WebMercator);
let ring_len = 4; for i in 0..ring_len {
let n = mesh.normals[i];
assert!(
(n[0]).abs() < 1e-6,
"top normal x should be 0, got {}",
n[0]
);
assert!(
(n[1]).abs() < 1e-6,
"top normal y should be 0, got {}",
n[1]
);
assert!(
(n[2] - 1.0).abs() < 1e-6,
"top normal z should be 1, got {}",
n[2]
);
}
}
#[test]
fn fill_extrusion_side_normals_are_horizontal() {
let style = VectorStyle::fill_extrusion([1.0, 0.0, 0.0, 1.0], 0.0, 100.0);
let layer = make_layer(Geometry::Polygon(square_polygon()));
let layer = VectorLayer::new("extrusion", layer.features, style);
let mesh = layer.tessellate(CameraProjection::WebMercator);
let ring_len = 4;
for i in ring_len..mesh.normals.len() {
let n = mesh.normals[i];
assert!(
(n[2]).abs() < 1e-6,
"side normal z should be 0, got {} at vertex {}",
n[2],
i
);
let len = (n[0] * n[0] + n[1] * n[1]).sqrt();
assert!(
(len - 1.0).abs() < 0.01,
"side normal length should be ~1, got {} at vertex {}",
len,
i
);
}
}
#[test]
fn flat_fill_tessellation_has_no_normals() {
let style = VectorStyle {
render_mode: VectorRenderMode::Fill,
fill_color: [0.0, 1.0, 0.0, 1.0],
..VectorStyle::default()
};
let layer = make_layer(Geometry::Polygon(square_polygon()));
let layer = VectorLayer::new("fill", layer.features, style);
let mesh = layer.tessellate(CameraProjection::WebMercator);
assert!(!mesh.has_normals());
assert!(mesh.normals.is_empty());
assert_eq!(mesh.render_mode, VectorRenderMode::Fill);
}
#[test]
fn symbol_candidates_point_placement_skips_lines() {
let mut style = VectorStyle::symbol([0.1, 0.2, 0.9, 1.0], [1.0, 1.0, 1.0, 1.0], 8.0);
style.symbol_text_field = Some("name".to_owned());
let mut properties = HashMap::new();
properties.insert(
"name".to_owned(),
crate::geometry::PropertyValue::String("Road".to_owned()),
);
let layer = VectorLayer::new(
"symbol",
FeatureCollection {
features: vec![Feature {
geometry: Geometry::LineString(two_point_line()),
properties,
}],
},
style,
);
assert!(layer.symbol_candidates().is_empty());
}
#[test]
fn symbol_candidates_text_only_use_text_overlap_flag() {
let mut style = VectorStyle::symbol([0.1, 0.2, 0.9, 1.0], [1.0, 1.0, 1.0, 1.0], 8.0);
style.symbol_text_field = Some("name".to_owned());
style.symbol_text_allow_overlap = true;
style.symbol_icon_allow_overlap = false;
let mut properties = HashMap::new();
properties.insert(
"name".to_owned(),
crate::geometry::PropertyValue::String("Label".to_owned()),
);
let layer = VectorLayer::new(
"symbol",
FeatureCollection {
features: vec![Feature {
geometry: Geometry::Point(origin_point()),
properties,
}],
},
style,
);
let candidates = layer.symbol_candidates();
assert_eq!(candidates.len(), 1);
assert!(candidates[0].allow_overlap);
}
#[test]
fn symbol_candidates_use_fixed_text_anchor_when_variable_anchors_absent() {
let mut style = VectorStyle::symbol([0.1, 0.2, 0.9, 1.0], [1.0, 1.0, 1.0, 1.0], 8.0);
style.symbol_text_field = Some("name".to_owned());
style.symbol_text_anchor = SymbolAnchor::TopRight;
style.symbol_anchors = vec![SymbolAnchor::TopRight];
let mut properties = HashMap::new();
properties.insert(
"name".to_owned(),
crate::geometry::PropertyValue::String("Label".to_owned()),
);
let layer = VectorLayer::new(
"symbol",
FeatureCollection {
features: vec![Feature {
geometry: Geometry::Point(origin_point()),
properties,
}],
},
style,
);
let candidates = layer.symbol_candidates();
assert_eq!(candidates.len(), 1);
assert_eq!(candidates[0].anchors, vec![SymbolAnchor::TopRight]);
}
#[test]
fn symbol_candidates_propagate_text_radial_offset() {
let mut style = VectorStyle::symbol([0.1, 0.2, 0.9, 1.0], [1.0, 1.0, 1.0, 1.0], 8.0);
style.symbol_text_field = Some("name".to_owned());
style.symbol_text_anchor = SymbolAnchor::Top;
style.symbol_anchors = vec![SymbolAnchor::Top];
style.symbol_text_radial_offset = Some(2.0);
let mut properties = HashMap::new();
properties.insert(
"name".to_owned(),
crate::geometry::PropertyValue::String("Label".to_owned()),
);
let layer = VectorLayer::new(
"symbol",
FeatureCollection {
features: vec![Feature {
geometry: Geometry::Point(origin_point()),
properties,
}],
},
style,
);
let candidates = layer.symbol_candidates();
assert_eq!(candidates.len(), 1);
assert_eq!(candidates[0].radial_offset, Some(2.0));
}
#[test]
fn symbol_candidates_propagate_text_max_width() {
let mut style = VectorStyle::symbol([0.1, 0.2, 0.9, 1.0], [1.0, 1.0, 1.0, 1.0], 8.0);
style.symbol_text_field = Some("name".to_owned());
style.symbol_text_max_width = Some(6.0);
let mut properties = HashMap::new();
properties.insert(
"name".to_owned(),
crate::geometry::PropertyValue::String("Long label".to_owned()),
);
let layer = VectorLayer::new(
"symbol",
FeatureCollection {
features: vec![Feature {
geometry: Geometry::Point(origin_point()),
properties,
}],
},
style,
);
let candidates = layer.symbol_candidates();
assert_eq!(candidates.len(), 1);
assert_eq!(candidates[0].text_max_width, Some(6.0));
}
#[test]
fn symbol_candidates_propagate_text_line_height() {
let mut style = VectorStyle::symbol([0.1, 0.2, 0.9, 1.0], [1.0, 1.0, 1.0, 1.0], 8.0);
style.symbol_text_field = Some("name".to_owned());
style.symbol_text_line_height = Some(1.5);
let mut properties = HashMap::new();
properties.insert(
"name".to_owned(),
crate::geometry::PropertyValue::String("Long label".to_owned()),
);
let layer = VectorLayer::new(
"symbol",
FeatureCollection {
features: vec![Feature {
geometry: Geometry::Point(origin_point()),
properties,
}],
},
style,
);
let candidates = layer.symbol_candidates();
assert_eq!(candidates.len(), 1);
assert_eq!(candidates[0].text_line_height, Some(1.5));
}
#[test]
fn symbol_candidates_propagate_text_letter_spacing() {
let mut style = VectorStyle::symbol([0.1, 0.2, 0.9, 1.0], [1.0, 1.0, 1.0, 1.0], 8.0);
style.symbol_text_field = Some("name".to_owned());
style.symbol_text_letter_spacing = Some(0.25);
let mut properties = HashMap::new();
properties.insert(
"name".to_owned(),
crate::geometry::PropertyValue::String("Long label".to_owned()),
);
let layer = VectorLayer::new(
"symbol",
FeatureCollection {
features: vec![Feature {
geometry: Geometry::Point(origin_point()),
properties,
}],
},
style,
);
let candidates = layer.symbol_candidates();
assert_eq!(candidates.len(), 1);
assert_eq!(candidates[0].text_letter_spacing, Some(0.25));
}
#[test]
fn symbol_candidates_apply_uppercase_text_transform() {
let mut style = VectorStyle::symbol([0.1, 0.2, 0.9, 1.0], [1.0, 1.0, 1.0, 1.0], 8.0);
style.symbol_text_field = Some("name".to_owned());
style.symbol_text_transform = SymbolTextTransform::Uppercase;
let mut properties = HashMap::new();
properties.insert(
"name".to_owned(),
crate::geometry::PropertyValue::String("Main Street".to_owned()),
);
let layer = VectorLayer::new(
"symbol",
FeatureCollection {
features: vec![Feature {
geometry: Geometry::Point(origin_point()),
properties,
}],
},
style,
);
let candidates = layer.symbol_candidates();
assert_eq!(candidates.len(), 1);
assert_eq!(candidates[0].text.as_deref(), Some("MAIN STREET"));
}
#[test]
fn symbol_candidates_apply_lowercase_text_transform() {
let mut style = VectorStyle::symbol([0.1, 0.2, 0.9, 1.0], [1.0, 1.0, 1.0, 1.0], 8.0);
style.symbol_text_field = Some("name".to_owned());
style.symbol_text_transform = SymbolTextTransform::Lowercase;
let mut properties = HashMap::new();
properties.insert(
"name".to_owned(),
crate::geometry::PropertyValue::String("Main Street".to_owned()),
);
let layer = VectorLayer::new(
"symbol",
FeatureCollection {
features: vec![Feature {
geometry: Geometry::Point(origin_point()),
properties,
}],
},
style,
);
let candidates = layer.symbol_candidates();
assert_eq!(candidates.len(), 1);
assert_eq!(candidates[0].text.as_deref(), Some("main street"));
}
#[test]
fn symbol_candidates_keep_variable_anchor_priority_over_fixed_anchor() {
let mut style = VectorStyle::symbol([0.1, 0.2, 0.9, 1.0], [1.0, 1.0, 1.0, 1.0], 8.0);
style.symbol_text_field = Some("name".to_owned());
style.symbol_text_anchor = SymbolAnchor::BottomLeft;
style.symbol_anchors = vec![SymbolAnchor::Center, SymbolAnchor::Top];
let mut properties = HashMap::new();
properties.insert(
"name".to_owned(),
crate::geometry::PropertyValue::String("Label".to_owned()),
);
let layer = VectorLayer::new(
"symbol",
FeatureCollection {
features: vec![Feature {
geometry: Geometry::Point(origin_point()),
properties,
}],
},
style,
);
let candidates = layer.symbol_candidates();
assert_eq!(candidates.len(), 1);
assert_eq!(
candidates[0].anchors,
vec![SymbolAnchor::Center, SymbolAnchor::Top]
);
}
#[test]
fn symbol_candidates_use_variable_anchor_offset_order_when_present() {
let mut style = VectorStyle::symbol([0.1, 0.2, 0.9, 1.0], [1.0, 1.0, 1.0, 1.0], 8.0);
style.symbol_text_field = Some("name".to_owned());
style.symbol_text_anchor = SymbolAnchor::BottomLeft;
style.symbol_anchors = vec![SymbolAnchor::Center];
style.symbol_variable_anchor_offsets = Some(vec![
(SymbolAnchor::Top, [1.0, 2.0]),
(SymbolAnchor::Right, [3.0, 4.0]),
]);
let mut properties = HashMap::new();
properties.insert(
"name".to_owned(),
crate::geometry::PropertyValue::String("Label".to_owned()),
);
let layer = VectorLayer::new(
"symbol",
FeatureCollection {
features: vec![Feature {
geometry: Geometry::Point(origin_point()),
properties,
}],
},
style,
);
let candidates = layer.symbol_candidates();
assert_eq!(candidates.len(), 1);
assert_eq!(
candidates[0].anchors,
vec![SymbolAnchor::Top, SymbolAnchor::Right]
);
assert_eq!(
candidates[0].variable_anchor_offsets,
Some(vec![
(SymbolAnchor::Top, [1.0, 2.0]),
(SymbolAnchor::Right, [3.0, 4.0]),
])
);
}
#[test]
fn symbol_candidates_text_and_icon_require_both_overlap_flags() {
let mut style = VectorStyle::symbol([0.1, 0.2, 0.9, 1.0], [1.0, 1.0, 1.0, 1.0], 8.0);
style.symbol_text_field = Some("name".to_owned());
style.symbol_icon_image = Some("marker".to_owned());
style.symbol_text_allow_overlap = true;
style.symbol_icon_allow_overlap = false;
let mut properties = HashMap::new();
properties.insert(
"name".to_owned(),
crate::geometry::PropertyValue::String("Label".to_owned()),
);
let layer = VectorLayer::new(
"symbol",
FeatureCollection {
features: vec![Feature {
geometry: Geometry::Point(origin_point()),
properties,
}],
},
style,
);
let candidates = layer.symbol_candidates();
assert_eq!(candidates.len(), 1);
assert!(!candidates[0].allow_overlap);
}
#[test]
fn symbol_candidates_text_only_use_text_ignore_placement_flag() {
let mut style = VectorStyle::symbol([0.1, 0.2, 0.9, 1.0], [1.0, 1.0, 1.0, 1.0], 8.0);
style.symbol_text_field = Some("name".to_owned());
style.symbol_text_ignore_placement = true;
style.symbol_icon_ignore_placement = false;
let mut properties = HashMap::new();
properties.insert(
"name".to_owned(),
crate::geometry::PropertyValue::String("Label".to_owned()),
);
let layer = VectorLayer::new(
"symbol",
FeatureCollection {
features: vec![Feature {
geometry: Geometry::Point(origin_point()),
properties,
}],
},
style,
);
let candidates = layer.symbol_candidates();
assert_eq!(candidates.len(), 1);
assert!(candidates[0].ignore_placement);
}
#[test]
fn symbol_candidates_text_and_icon_require_both_ignore_flags() {
let mut style = VectorStyle::symbol([0.1, 0.2, 0.9, 1.0], [1.0, 1.0, 1.0, 1.0], 8.0);
style.symbol_text_field = Some("name".to_owned());
style.symbol_icon_image = Some("marker".to_owned());
style.symbol_text_ignore_placement = true;
style.symbol_icon_ignore_placement = false;
let mut properties = HashMap::new();
properties.insert(
"name".to_owned(),
crate::geometry::PropertyValue::String("Label".to_owned()),
);
let layer = VectorLayer::new(
"symbol",
FeatureCollection {
features: vec![Feature {
geometry: Geometry::Point(origin_point()),
properties,
}],
},
style,
);
let candidates = layer.symbol_candidates();
assert_eq!(candidates.len(), 1);
assert!(!candidates[0].ignore_placement);
}
#[test]
fn symbol_candidates_emit_icon_fallback_when_text_is_optional() {
let mut style = VectorStyle::symbol([0.1, 0.2, 0.9, 1.0], [1.0, 1.0, 1.0, 1.0], 8.0);
style.symbol_text_field = Some("name".to_owned());
style.symbol_icon_image = Some("marker".to_owned());
style.symbol_text_optional = true;
let mut properties = HashMap::new();
properties.insert(
"name".to_owned(),
crate::geometry::PropertyValue::String("Label".to_owned()),
);
let layer = VectorLayer::new(
"symbol",
FeatureCollection {
features: vec![Feature {
geometry: Geometry::Point(origin_point()),
properties,
}],
},
style,
);
let candidates = layer.symbol_candidates();
assert_eq!(candidates.len(), 2);
assert_eq!(candidates[0].text.as_deref(), Some("Label"));
assert_eq!(candidates[0].icon_image.as_deref(), Some("marker"));
assert!(candidates[1].text.is_none());
assert_eq!(candidates[1].icon_image.as_deref(), Some("marker"));
assert_eq!(candidates[0].cross_tile_id, candidates[1].cross_tile_id);
assert_eq!(
candidates[0].placement_group_id,
candidates[1].placement_group_id
);
}
#[test]
fn symbol_candidates_emit_text_fallback_when_icon_is_optional() {
let mut style = VectorStyle::symbol([0.1, 0.2, 0.9, 1.0], [1.0, 1.0, 1.0, 1.0], 8.0);
style.symbol_text_field = Some("name".to_owned());
style.symbol_icon_image = Some("marker".to_owned());
style.symbol_icon_optional = true;
let mut properties = HashMap::new();
properties.insert(
"name".to_owned(),
crate::geometry::PropertyValue::String("Label".to_owned()),
);
let layer = VectorLayer::new(
"symbol",
FeatureCollection {
features: vec![Feature {
geometry: Geometry::Point(origin_point()),
properties,
}],
},
style,
);
let candidates = layer.symbol_candidates();
assert_eq!(candidates.len(), 2);
assert_eq!(candidates[0].text.as_deref(), Some("Label"));
assert_eq!(candidates[0].icon_image.as_deref(), Some("marker"));
assert_eq!(candidates[1].text.as_deref(), Some("Label"));
assert!(candidates[1].icon_image.is_none());
assert_eq!(candidates[0].cross_tile_id, candidates[1].cross_tile_id);
assert_eq!(
candidates[0].placement_group_id,
candidates[1].placement_group_id
);
}
#[test]
fn symbol_candidates_line_placement_repeats_anchors_with_spacing() {
let mut style = VectorStyle::symbol([0.1, 0.2, 0.9, 1.0], [1.0, 1.0, 1.0, 1.0], 8.0);
style.symbol_text_field = Some("name".to_owned());
style.symbol_placement = SymbolPlacement::Line;
style.symbol_spacing = 1000.0;
let start = GeoCoord::from_lat_lon(0.0, 0.0);
let end = GeoCoord::from_lat_lon(0.0, 0.05);
let mut properties = HashMap::new();
properties.insert(
"name".to_owned(),
crate::geometry::PropertyValue::String("Road".to_owned()),
);
let layer = VectorLayer::new(
"symbol",
FeatureCollection {
features: vec![Feature {
geometry: Geometry::LineString(LineString {
coords: vec![start, end],
}),
properties,
}],
},
style,
);
let candidates = layer.symbol_candidates();
assert!(
candidates.len() > 1,
"longer lines should produce repeated anchors"
);
assert!(candidates
.iter()
.all(|candidate| (candidate.anchor.lat - 0.0).abs() < 1e-9));
assert!(candidates
.windows(2)
.all(|pair| pair[0].anchor.lon < pair[1].anchor.lon));
assert!(candidates
.iter()
.all(|candidate| candidate.rotation_rad.abs() < 1e-6));
assert_eq!(candidates[0].text.as_deref(), Some("Road"));
}
#[test]
fn symbol_candidates_line_placement_rotates_vertical_lines() {
let mut style = VectorStyle::symbol([0.1, 0.2, 0.9, 1.0], [1.0, 1.0, 1.0, 1.0], 8.0);
style.symbol_text_field = Some("name".to_owned());
style.symbol_placement = SymbolPlacement::Line;
let mut properties = HashMap::new();
properties.insert(
"name".to_owned(),
crate::geometry::PropertyValue::String("North".to_owned()),
);
let layer = VectorLayer::new(
"symbol",
FeatureCollection {
features: vec![Feature {
geometry: Geometry::LineString(LineString {
coords: vec![
GeoCoord::from_lat_lon(0.0, 0.0),
GeoCoord::from_lat_lon(1.0, 0.0),
],
}),
properties,
}],
},
style,
);
let candidates = layer.symbol_candidates();
assert!(!candidates.is_empty());
assert!(candidates.iter().all(|candidate| {
(candidate.rotation_rad.abs() - std::f32::consts::FRAC_PI_2).abs() < 0.05
}));
}
#[test]
fn symbol_candidates_line_placement_keeps_reversed_lines_upright() {
let mut style = VectorStyle::symbol([0.1, 0.2, 0.9, 1.0], [1.0, 1.0, 1.0, 1.0], 8.0);
style.symbol_text_field = Some("name".to_owned());
style.symbol_placement = SymbolPlacement::Line;
style.symbol_keep_upright = true;
let mut properties = HashMap::new();
properties.insert(
"name".to_owned(),
crate::geometry::PropertyValue::String("West".to_owned()),
);
let layer = VectorLayer::new(
"symbol",
FeatureCollection {
features: vec![Feature {
geometry: Geometry::LineString(LineString {
coords: vec![
GeoCoord::from_lat_lon(0.0, 1.0),
GeoCoord::from_lat_lon(0.0, 0.0),
],
}),
properties,
}],
},
style,
);
let candidates = layer.symbol_candidates();
assert!(!candidates.is_empty());
assert!(candidates
.iter()
.all(|candidate| candidate.rotation_rad.abs() < 0.05));
}
#[test]
fn symbol_candidates_line_placement_can_disable_keep_upright() {
let mut style = VectorStyle::symbol([0.1, 0.2, 0.9, 1.0], [1.0, 1.0, 1.0, 1.0], 8.0);
style.symbol_text_field = Some("name".to_owned());
style.symbol_placement = SymbolPlacement::Line;
style.symbol_keep_upright = false;
let mut properties = HashMap::new();
properties.insert(
"name".to_owned(),
crate::geometry::PropertyValue::String("West".to_owned()),
);
let layer = VectorLayer::new(
"symbol",
FeatureCollection {
features: vec![Feature {
geometry: Geometry::LineString(LineString {
coords: vec![
GeoCoord::from_lat_lon(0.0, 1.0),
GeoCoord::from_lat_lon(0.0, 0.0),
],
}),
properties,
}],
},
style,
);
let candidates = layer.symbol_candidates();
assert!(!candidates.is_empty());
assert!(candidates.iter().all(|candidate| {
(candidate.rotation_rad.abs() - std::f32::consts::PI).abs() < 0.05
}));
}
#[test]
fn symbol_candidates_line_placement_cross_tile_id_stays_stable_across_small_anchor_shifts() {
let mut style = VectorStyle::symbol([0.1, 0.2, 0.9, 1.0], [1.0, 1.0, 1.0, 1.0], 8.0);
style.symbol_text_field = Some("name".to_owned());
style.symbol_placement = SymbolPlacement::Line;
style.symbol_spacing = 1000.0;
let mut properties = HashMap::new();
properties.insert(
"id".to_owned(),
crate::geometry::PropertyValue::String("road-1".to_owned()),
);
properties.insert(
"name".to_owned(),
crate::geometry::PropertyValue::String("Main".to_owned()),
);
let base = VectorLayer::new(
"symbol",
FeatureCollection {
features: vec![Feature {
geometry: Geometry::LineString(LineString {
coords: vec![
GeoCoord::from_lat_lon(0.0, 0.0),
GeoCoord::from_lat_lon(0.0, 0.05),
],
}),
properties: properties.clone(),
}],
},
style.clone(),
);
let shifted = VectorLayer::new(
"symbol",
FeatureCollection {
features: vec![Feature {
geometry: Geometry::LineString(LineString {
coords: vec![
GeoCoord::from_lat_lon(0.0, 0.0),
GeoCoord::from_lat_lon(0.0002, 0.0502),
],
}),
properties,
}],
},
style,
);
let base_ids = base
.symbol_candidates()
.into_iter()
.map(|candidate| candidate.cross_tile_id)
.collect::<Vec<_>>();
let shifted_ids = shifted
.symbol_candidates()
.into_iter()
.map(|candidate| candidate.cross_tile_id)
.collect::<Vec<_>>();
assert_eq!(base_ids, shifted_ids);
}
#[test]
fn symbol_candidates_line_placement_cross_tile_id_changes_for_shifted_line_windows() {
let mut style = VectorStyle::symbol([0.1, 0.2, 0.9, 1.0], [1.0, 1.0, 1.0, 1.0], 8.0);
style.symbol_text_field = Some("name".to_owned());
style.symbol_placement = SymbolPlacement::Line;
style.symbol_spacing = 1000.0;
let mut properties = HashMap::new();
properties.insert(
"id".to_owned(),
crate::geometry::PropertyValue::String("road-2".to_owned()),
);
properties.insert(
"name".to_owned(),
crate::geometry::PropertyValue::String("Main".to_owned()),
);
let base = VectorLayer::new(
"symbol",
FeatureCollection {
features: vec![Feature {
geometry: Geometry::LineString(LineString {
coords: vec![
GeoCoord::from_lat_lon(0.0, 0.0),
GeoCoord::from_lat_lon(0.0, 0.05),
],
}),
properties: properties.clone(),
}],
},
style.clone(),
);
let shifted_window = VectorLayer::new(
"symbol",
FeatureCollection {
features: vec![Feature {
geometry: Geometry::LineString(LineString {
coords: vec![
GeoCoord::from_lat_lon(0.0, 0.01),
GeoCoord::from_lat_lon(0.0, 0.06),
],
}),
properties,
}],
},
style,
);
let base_ids = base
.symbol_candidates()
.into_iter()
.map(|candidate| candidate.cross_tile_id)
.collect::<Vec<_>>();
let shifted_ids = shifted_window
.symbol_candidates()
.into_iter()
.map(|candidate| candidate.cross_tile_id)
.collect::<Vec<_>>();
assert_ne!(base_ids.first(), shifted_ids.first());
assert!(base_ids.iter().any(|id| shifted_ids.contains(id)));
}
#[test]
fn symbol_candidates_line_placement_filters_sharp_turns_with_max_angle() {
let mut style = VectorStyle::symbol([0.1, 0.2, 0.9, 1.0], [1.0, 1.0, 1.0, 1.0], 8.0);
style.symbol_text_field = Some("name".to_owned());
style.symbol_placement = SymbolPlacement::Line;
style.symbol_spacing = 10_000.0;
style.symbol_max_angle = 10.0;
let mut properties = HashMap::new();
properties.insert(
"name".to_owned(),
crate::geometry::PropertyValue::String("Turn".to_owned()),
);
let layer = VectorLayer::new(
"symbol",
FeatureCollection {
features: vec![Feature {
geometry: Geometry::LineString(LineString {
coords: vec![
GeoCoord::from_lat_lon(0.0, 0.0),
GeoCoord::from_lat_lon(0.0, 0.03),
GeoCoord::from_lat_lon(0.03, 0.03),
],
}),
properties,
}],
},
style,
);
assert!(layer.symbol_candidates().is_empty());
}
#[test]
fn tessellate_symbol_mode_stacks_halo_and_fill() {
let style = VectorStyle::symbol([0.1, 0.2, 0.9, 1.0], [1.0, 1.0, 1.0, 1.0], 8.0);
let layer = make_layer(Geometry::Point(origin_point()));
let layer = VectorLayer::new("symbol", layer.features, style);
let mesh = layer.tessellate(CameraProjection::WebMercator);
assert_eq!(mesh.vertex_count(), 8);
assert_eq!(mesh.index_count(), 12);
}
#[test]
fn tessellate_equirectangular_changes_xy_positions() {
let layer = make_layer(Geometry::Polygon(square_polygon()));
let merc = layer.tessellate(CameraProjection::WebMercator);
let eq = layer.tessellate(CameraProjection::Equirectangular);
assert_eq!(merc.positions.len(), eq.positions.len());
assert!(merc
.positions
.iter()
.zip(eq.positions.iter())
.any(|(a, b)| (a[0] - b[0]).abs() > 1.0 || (a[1] - b[1]).abs() > 1.0));
}
#[test]
fn tessellate_line_mode_populates_normals_and_distances() {
let style = VectorStyle::line([1.0, 0.0, 0.0, 1.0], 4.0);
let layer = VectorLayer::new(
"line",
make_layer(Geometry::LineString(two_point_line())).features,
style,
);
let mesh = layer.tessellate(CameraProjection::WebMercator);
assert!(!mesh.is_empty());
assert_eq!(
mesh.line_normals.len(),
mesh.positions.len(),
"line_normals must have one entry per vertex"
);
assert_eq!(
mesh.line_distances.len(),
mesh.positions.len(),
"line_distances must have one entry per vertex"
);
assert!(
mesh.line_distances.iter().any(|&d| d > 0.0),
"at least one distance should be positive"
);
}
#[test]
fn tessellate_line_mode_propagates_dash_params() {
let style = VectorStyle::line_styled(
[1.0, 0.0, 0.0, 1.0],
4.0,
LineCap::Round,
LineJoin::Bevel,
2.0,
Some(vec![10.0, 5.0]),
);
let layer = VectorLayer::new(
"dashed",
make_layer(Geometry::LineString(two_point_line())).features,
style,
);
let mesh = layer.tessellate(CameraProjection::WebMercator);
assert_eq!(mesh.line_params[0], 10.0, "dash_length");
assert_eq!(mesh.line_params[1], 5.0, "gap_length");
assert_eq!(mesh.line_params[2], 1.0, "cap_round flag");
}
#[test]
fn tessellate_line_mode_round_join_adds_vertices() {
let line = crate::geometry::LineString {
coords: vec![
GeoCoord::from_lat_lon(0.0, 0.0),
GeoCoord::from_lat_lon(0.0, 1.0),
GeoCoord::from_lat_lon(1.0, 1.0),
],
};
let miter_style = VectorStyle::line_styled(
[1.0, 0.0, 0.0, 1.0],
4.0,
LineCap::Butt,
LineJoin::Miter,
10.0,
None,
);
let round_style = VectorStyle::line_styled(
[1.0, 0.0, 0.0, 1.0],
4.0,
LineCap::Butt,
LineJoin::Round,
2.0,
None,
);
let miter_layer = VectorLayer::new(
"m",
make_layer(Geometry::LineString(line.clone())).features,
miter_style,
);
let round_layer = VectorLayer::new(
"r",
make_layer(Geometry::LineString(line)).features,
round_style,
);
let miter_mesh = miter_layer.tessellate(CameraProjection::WebMercator);
let round_mesh = round_layer.tessellate(CameraProjection::WebMercator);
assert!(
round_mesh.vertex_count() > miter_mesh.vertex_count(),
"round join should produce more vertices than miter: {} vs {}",
round_mesh.vertex_count(),
miter_mesh.vertex_count(),
);
}
fn two_feature_dd_width_layer() -> VectorLayer {
use crate::geometry::PropertyValue;
let narrow_feature = Feature {
geometry: Geometry::LineString(two_point_line()),
properties: {
let mut p = HashMap::new();
p.insert("width".into(), PropertyValue::Number(2.0));
p
},
};
let wide_feature = Feature {
geometry: Geometry::LineString(two_point_line()),
properties: {
let mut p = HashMap::new();
p.insert("width".into(), PropertyValue::Number(10.0));
p
},
};
let features = FeatureCollection {
features: vec![narrow_feature, wide_feature],
};
let mut style = VectorStyle::line([1.0, 0.0, 0.0, 1.0], 2.0);
style.width_expr = Some(Expression::GetProperty {
key: "width".into(),
fallback: 2.0,
});
style.eval_zoom = 10.0;
VectorLayer::new("dd_width", features, style)
}
#[test]
fn data_driven_width_produces_different_ribbon_widths() {
let layer = two_feature_dd_width_layer();
let mesh = layer.tessellate(CameraProjection::WebMercator);
assert!(!mesh.is_empty());
let narrow_style = VectorStyle::line([1.0, 0.0, 0.0, 1.0], 2.0);
let wide_style = VectorStyle::line([1.0, 0.0, 0.0, 1.0], 10.0);
let narrow_layer = VectorLayer::new(
"narrow",
FeatureCollection {
features: vec![layer.features.features[0].clone()],
},
narrow_style,
);
let wide_layer = VectorLayer::new(
"wide",
FeatureCollection {
features: vec![layer.features.features[1].clone()],
},
wide_style,
);
let narrow_mesh = narrow_layer.tessellate(CameraProjection::WebMercator);
let wide_mesh = wide_layer.tessellate(CameraProjection::WebMercator);
let narrow_span = position_y_span(&narrow_mesh);
let wide_span = position_y_span(&wide_mesh);
assert!(
wide_span > narrow_span,
"wide ribbon span ({wide_span}) must exceed narrow ribbon span ({narrow_span})",
);
let combined_span = position_y_span(&mesh);
assert!(
combined_span >= wide_span * 0.99,
"combined mesh span ({combined_span}) should be >= wide span ({wide_span})",
);
}
#[test]
fn data_driven_color_assigns_per_feature_colors() {
use crate::geometry::PropertyValue;
let red_feature = Feature {
geometry: Geometry::LineString(two_point_line()),
properties: {
let mut p = HashMap::new();
p.insert("kind".into(), PropertyValue::String("highway".into()));
p
},
};
let blue_feature = Feature {
geometry: Geometry::LineString(two_point_line()),
properties: {
let mut p = HashMap::new();
p.insert("kind".into(), PropertyValue::String("local".into()));
p
},
};
let features = FeatureCollection {
features: vec![red_feature, blue_feature],
};
let mut style = VectorStyle::line([1.0, 0.0, 0.0, 1.0], 4.0);
style.stroke_color_expr = Some(Expression::Match {
input: Box::new(crate::expression::StringExpression::GetProperty {
key: "kind".into(),
fallback: String::new(),
}),
cases: vec![
("highway".into(), [1.0, 0.0, 0.0, 1.0]),
("local".into(), [0.0, 0.0, 1.0, 1.0]),
],
fallback: [0.5, 0.5, 0.5, 1.0],
});
style.eval_zoom = 10.0;
let layer = VectorLayer::new("dd_color", features, style);
let mesh = layer.tessellate(CameraProjection::WebMercator);
assert!(!mesh.is_empty());
let has_red = mesh
.colors
.iter()
.any(|c| c[0] > 0.9 && c[1] < 0.1 && c[2] < 0.1);
let has_blue = mesh
.colors
.iter()
.any(|c| c[0] < 0.1 && c[1] < 0.1 && c[2] > 0.9);
assert!(
has_red,
"mesh should contain red vertices from highway feature"
);
assert!(
has_blue,
"mesh should contain blue vertices from local feature"
);
}
#[test]
fn non_data_driven_width_expr_uses_uniform_value() {
let features = FeatureCollection {
features: vec![
Feature {
geometry: Geometry::LineString(two_point_line()),
properties: HashMap::new(),
},
Feature {
geometry: Geometry::LineString(two_point_line()),
properties: HashMap::new(),
},
],
};
let mut style = VectorStyle::line([1.0, 0.0, 0.0, 1.0], 4.0);
style.width_expr = Some(Expression::Constant(4.0));
style.eval_zoom = 10.0;
let layer = VectorLayer::new("uniform", features, style);
let mesh = layer.tessellate(CameraProjection::WebMercator);
assert!(!mesh.is_empty());
let first_color = mesh.colors[0];
assert!(mesh.colors.iter().all(|c| *c == first_color));
}
#[test]
fn data_driven_width_fingerprint_changes_with_zoom() {
let mut style = VectorStyle::line([1.0, 0.0, 0.0, 1.0], 4.0);
style.width_expr = Some(Expression::GetProperty {
key: "width".into(),
fallback: 4.0,
});
style.eval_zoom = 5.0;
let fp1 = style.tessellation_fingerprint();
style.eval_zoom = 10.0;
let fp2 = style.tessellation_fingerprint();
assert_ne!(fp1, fp2, "data-driven fingerprint should differ by zoom");
}
fn position_y_span(mesh: &VectorMeshData) -> f64 {
let ys: Vec<f64> = mesh.positions.iter().map(|p| p[1]).collect();
let min = ys.iter().cloned().fold(f64::INFINITY, f64::min);
let max = ys.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
max - min
}
fn blue_to_red_ramp() -> crate::visualization::ColorRamp {
use crate::visualization::{ColorRamp, ColorStop};
ColorRamp::new(vec![
ColorStop {
value: 0.0,
color: [0.0, 0.0, 1.0, 1.0],
},
ColorStop {
value: 1.0,
color: [1.0, 0.0, 0.0, 1.0],
},
])
}
#[test]
fn line_gradient_overrides_vertex_colors() {
let ramp = blue_to_red_ramp();
let style = VectorStyle::line_gradient(4.0, ramp);
let layer = VectorLayer::new(
"grad",
FeatureCollection {
features: vec![Feature {
geometry: Geometry::LineString(LineString {
coords: vec![
GeoCoord::from_lat_lon(0.0, 0.0),
GeoCoord::from_lat_lon(0.0, 1.0),
],
}),
properties: HashMap::new(),
}],
},
style,
);
let mesh = layer.tessellate(CameraProjection::WebMercator);
assert!(!mesh.colors.is_empty());
let has_blue = mesh.colors.iter().any(|c| c[2] > 0.8 && c[0] < 0.2);
let has_red = mesh.colors.iter().any(|c| c[0] > 0.8 && c[2] < 0.2);
assert!(has_blue, "expected some blue-ish vertices near start");
assert!(has_red, "expected some red-ish vertices near end");
}
#[test]
fn line_gradient_without_ramp_uses_solid_color() {
let solid = [0.5, 0.5, 0.5, 1.0];
let style = VectorStyle::line(solid, 4.0);
let layer = VectorLayer::new(
"solid",
FeatureCollection {
features: vec![Feature {
geometry: Geometry::LineString(LineString {
coords: vec![
GeoCoord::from_lat_lon(0.0, 0.0),
GeoCoord::from_lat_lon(0.0, 1.0),
],
}),
properties: HashMap::new(),
}],
},
style,
);
let mesh = layer.tessellate(CameraProjection::WebMercator);
assert!(!mesh.colors.is_empty());
for c in &mesh.colors {
assert_eq!(*c, solid, "all vertices should share the solid colour");
}
}
#[test]
fn line_gradient_midpoint_is_interpolated() {
use crate::visualization::{ColorRamp, ColorStop};
let ramp = ColorRamp::new(vec![
ColorStop {
value: 0.0,
color: [0.0, 0.0, 0.0, 1.0],
},
ColorStop {
value: 1.0,
color: [1.0, 1.0, 1.0, 1.0],
},
]);
let style = VectorStyle::line_gradient(2.0, ramp);
let layer = VectorLayer::new(
"mid",
FeatureCollection {
features: vec![Feature {
geometry: Geometry::LineString(LineString {
coords: vec![
GeoCoord::from_lat_lon(0.0, 0.0),
GeoCoord::from_lat_lon(0.0, 0.5),
GeoCoord::from_lat_lon(0.0, 1.0),
],
}),
properties: HashMap::new(),
}],
},
style,
);
let mesh = layer.tessellate(CameraProjection::WebMercator);
let has_mid = mesh.colors.iter().any(|c| c[0] > 0.3 && c[0] < 0.7);
assert!(
has_mid,
"expected midpoint vertices with interpolated colour"
);
}
#[test]
fn line_gradient_style_roundtrips_through_line_style_layer() {
use crate::style::{line_style_with_state, LineStyleLayer, StyleEvalContextFull};
let ramp = blue_to_red_ramp();
let mut layer = LineStyleLayer::new("grad", "src");
layer.line_gradient = Some(ramp.clone());
let state = HashMap::new();
let ctx = StyleEvalContextFull::new(5.0, &state);
let vs = line_style_with_state(&layer, &ctx);
assert!(vs.line_gradient.is_some());
}
fn checkerboard_2x2() -> Arc<PatternImage> {
#[rustfmt::skip]
let data = vec![
0, 0, 0, 255, 255, 255, 255, 255,
255, 255, 255, 255, 0, 0, 0, 255,
];
Arc::new(PatternImage::new(2, 2, data))
}
#[test]
fn pattern_image_validates_data_length() {
let img = PatternImage::new(2, 2, vec![0u8; 16]);
assert_eq!(img.width, 2);
assert_eq!(img.height, 2);
}
#[test]
#[should_panic(expected = "RGBA8 data length")]
fn pattern_image_rejects_wrong_data_length() {
let _img = PatternImage::new(2, 2, vec![0u8; 10]);
}
#[test]
fn fill_pattern_generates_uvs() {
let pattern = checkerboard_2x2();
let style = VectorStyle::fill_pattern(pattern);
let geom = Geometry::Polygon(square_polygon());
let features = FeatureCollection {
features: vec![Feature {
geometry: geom,
properties: HashMap::new(),
}],
};
let layer = VectorLayer::new("pat", features, style);
let mesh = layer.tessellate(CameraProjection::WebMercator);
assert!(mesh.fill_pattern.is_some());
assert!(
!mesh.fill_pattern_uvs.is_empty(),
"expected fill_pattern_uvs to be non-empty"
);
assert!(
mesh.fill_pattern_uvs.len() <= mesh.positions.len(),
"fill_pattern_uvs should not exceed positions count"
);
}
#[test]
fn solid_fill_has_no_pattern_uvs() {
let style = VectorStyle::fill([0.5, 0.5, 0.5, 1.0], [0.0, 0.0, 0.0, 1.0], 1.0);
let geom = Geometry::Polygon(square_polygon());
let features = FeatureCollection {
features: vec![Feature {
geometry: geom,
properties: HashMap::new(),
}],
};
let layer = VectorLayer::new("solid", features, style);
let mesh = layer.tessellate(CameraProjection::WebMercator);
assert!(mesh.fill_pattern.is_none());
assert!(
mesh.fill_pattern_uvs.is_empty(),
"solid fills should not generate pattern UVs"
);
}
#[test]
fn fill_pattern_style_roundtrips_through_fill_style_layer() {
use crate::style::{fill_style_with_state, FillStyleLayer, StyleEvalContextFull};
let pattern = checkerboard_2x2();
let mut layer = FillStyleLayer::new("fp", "src");
layer.fill_pattern = Some(pattern.clone());
let state = HashMap::new();
let ctx = StyleEvalContextFull::new(5.0, &state);
let vs = fill_style_with_state(&layer, &ctx);
assert!(vs.fill_pattern.is_some());
assert_eq!(vs.fill_pattern.as_ref().unwrap().width, 2);
}
fn line_feature() -> FeatureCollection {
FeatureCollection {
features: vec![Feature {
geometry: Geometry::LineString(LineString {
coords: vec![
GeoCoord::from_lat_lon(0.0, 0.0),
GeoCoord::from_lat_lon(0.0, 1.0),
],
}),
properties: HashMap::new(),
}],
}
}
#[test]
fn line_pattern_generates_uvs() {
let pattern = checkerboard_2x2();
let style = VectorStyle::line_pattern(4.0, pattern);
let layer = VectorLayer::new("lp", line_feature(), style);
let mesh = layer.tessellate(CameraProjection::WebMercator);
assert!(
mesh.line_pattern.is_some(),
"pattern image should be carried to mesh"
);
assert!(
!mesh.line_pattern_uvs.is_empty(),
"expected line_pattern_uvs to be non-empty"
);
assert_eq!(
mesh.line_pattern_uvs.len(),
mesh.positions.len(),
"each position should have a corresponding pattern UV"
);
}
#[test]
fn line_pattern_uvs_have_correct_v_range() {
let pattern = checkerboard_2x2();
let style = VectorStyle::line_pattern(4.0, pattern);
let layer = VectorLayer::new("lp", line_feature(), style);
let mesh = layer.tessellate(CameraProjection::WebMercator);
let has_left = mesh
.line_pattern_uvs
.iter()
.any(|uv| (uv[1] - 0.0).abs() < 0.01);
let has_right = mesh
.line_pattern_uvs
.iter()
.any(|uv| (uv[1] - 1.0).abs() < 0.01);
assert!(has_left, "expected some V=0.0 vertices (left edge)");
assert!(has_right, "expected some V=1.0 vertices (right edge)");
for uv in &mesh.line_pattern_uvs {
assert!(
uv[1] >= -0.01 && uv[1] <= 1.01,
"V coordinate {:.3} outside [0, 1]",
uv[1]
);
}
}
#[test]
fn solid_line_has_no_pattern_uvs() {
let style = VectorStyle::line([1.0, 0.0, 0.0, 1.0], 4.0);
let layer = VectorLayer::new("solid", line_feature(), style);
let mesh = layer.tessellate(CameraProjection::WebMercator);
assert!(mesh.line_pattern.is_none());
assert!(
mesh.line_pattern_uvs.is_empty(),
"solid lines should not generate pattern UVs"
);
}
#[test]
fn line_pattern_style_constructor() {
let pattern = checkerboard_2x2();
let style = VectorStyle::line_pattern(6.0, pattern.clone());
assert_eq!(style.render_mode, VectorRenderMode::Line);
assert_eq!(style.stroke_width, 6.0);
assert!(style.line_pattern.is_some());
assert_eq!(style.line_pattern.as_ref().unwrap().width, 2);
}
#[test]
fn line_pattern_style_roundtrips_through_line_style_layer() {
use crate::style::{line_style_with_state, LineStyleLayer, StyleEvalContextFull};
let pattern = checkerboard_2x2();
let mut layer = LineStyleLayer::new("lp", "src");
layer.line_pattern = Some(pattern.clone());
let state = HashMap::new();
let ctx = StyleEvalContextFull::new(5.0, &state);
let vs = line_style_with_state(&layer, &ctx);
assert!(vs.line_pattern.is_some());
assert_eq!(vs.line_pattern.as_ref().unwrap().width, 2);
}
#[test]
fn line_pattern_u_increases_along_line() {
let pattern = checkerboard_2x2();
let style = VectorStyle::line_pattern(4.0, pattern);
let features = FeatureCollection {
features: vec![Feature {
geometry: Geometry::LineString(LineString {
coords: vec![
GeoCoord::from_lat_lon(0.0, 0.0),
GeoCoord::from_lat_lon(0.0, 5.0),
],
}),
properties: HashMap::new(),
}],
};
let layer = VectorLayer::new("lp", features, style);
let mesh = layer.tessellate(CameraProjection::WebMercator);
let body_us: Vec<f32> = mesh
.line_pattern_uvs
.iter()
.filter(|uv| (uv[1] - 0.0).abs() < 0.01 || (uv[1] - 1.0).abs() < 0.01)
.map(|uv| uv[0])
.collect();
assert!(
!body_us.is_empty(),
"should have body vertices with pattern UVs"
);
let max_u = body_us.iter().cloned().fold(0.0_f32, f32::max);
assert!(max_u > 0.0, "max U along line should be > 0, got {max_u}");
}
#[test]
fn heatmap_tessellation_populates_heatmap_points() {
let gc = |lat: f64, lon: f64| rustial_math::GeoCoord::from_lat_lon(lat, lon);
let features = FeatureCollection {
features: vec![Feature {
geometry: Geometry::Point(Point {
coord: gc(0.0, 0.0),
}),
properties: HashMap::new(),
}],
};
let style = VectorStyle::heatmap([1.0, 0.0, 0.0, 1.0], 20.0, 1.0);
let layer = VectorLayer::new("hm", features, style);
let mesh = layer.tessellate(CameraProjection::WebMercator);
assert!(
!mesh.heatmap_points.is_empty(),
"heatmap tessellation should populate heatmap_points"
);
let pt = &mesh.heatmap_points[0];
assert!(pt[3] > 0.0, "heatmap radius should be positive: {}", pt[3]);
}
#[test]
fn circle_tessellation_populates_circle_instances() {
let gc = |lat: f64, lon: f64| rustial_math::GeoCoord::from_lat_lon(lat, lon);
let features = FeatureCollection {
features: vec![Feature {
geometry: Geometry::Point(Point {
coord: gc(0.0, 0.0),
}),
properties: HashMap::new(),
}],
};
let style = VectorStyle::circle([0.0, 1.0, 0.0, 1.0], 10.0, [0.0, 0.0, 0.0, 1.0], 2.0);
let layer = VectorLayer::new("cc", features, style);
let mesh = layer.tessellate(CameraProjection::WebMercator);
assert!(
!mesh.circle_instances.is_empty(),
"circle tessellation should populate circle_instances"
);
assert!(
mesh.circle_instances[0].radius > 0.0,
"circle radius should be positive"
);
}
}