Skip to main content

rustial_engine/layers/
vector_layer.rs

1//! Vector data layer -- geographic feature tessellation and terrain draping.
2//!
3//! [`VectorLayer`] holds a [`FeatureCollection`] and a [`VectorStyle`],
4//! and provides two core operations consumed by the per-frame engine
5//! update loop in [`MapState::update_layers`](crate::MapState):
6//!
7//! 1. **Terrain draping** ([`drape_on_terrain`](VectorLayer::drape_on_terrain))
8//!    -- walk every vertex in the feature collection and set its altitude
9//!    to the terrain elevation at that coordinate.  No-op when terrain is
10//!    disabled or elevation data is unavailable.
11//!
12//! 2. **Tessellation** ([`tessellate`](VectorLayer::tessellate)) -- convert
13//!    the geographic features into GPU-ready triangle meshes stored in
14//!    [`VectorMeshData`].
15//!
16//! Rustial's style/runtime layer families such as `fill`, `line`, `circle`,
17//! `heatmap`, `symbol`, and `fill-extrusion` are all currently lowered onto
18//! this shared engine primitive via [`VectorRenderMode`].
19
20use crate::camera_projection::CameraProjection;
21use crate::expression::{ExprEvalContext, Expression};
22use crate::geometry::{Feature, FeatureCollection, Geometry};
23use crate::layer::{Layer, LayerId};
24use crate::query::feature_id_for_feature;
25use crate::symbols::{
26    SymbolAnchor, SymbolCandidate, SymbolIconTextFit, SymbolPlacement, SymbolTextJustify,
27    SymbolTextTransform, SymbolWritingMode,
28};
29use crate::terrain::TerrainManager;
30use crate::tessellator;
31use crate::visualization::ColorRamp;
32use rustial_math::{GeoCoord, TileId, WorldCoord};
33use std::any::Any;
34use std::sync::Arc;
35
36// ---------------------------------------------------------------------------
37// Constants
38// ---------------------------------------------------------------------------
39
40/// Approximate meters per degree of latitude at the equator.
41///
42/// Used to convert pixel-space stroke widths into a geographic offset
43/// for the stroke tessellator, and to size point-marker quads.  This
44/// is a rough approximation; proper screen-to-world conversion
45/// requires the camera's meters-per-pixel, which is not available at
46/// the layer level.  A future improvement would pass `meters_per_pixel`
47/// into `tessellate()`.
48const METERS_PER_DEGREE: f64 = 111_319.49;
49const DEGREES_PER_PIXEL_APPROX: f64 = 0.00001;
50const METERS_PER_PIXEL_APPROX: f64 = METERS_PER_DEGREE * DEGREES_PER_PIXEL_APPROX;
51const DEFAULT_CIRCLE_SEGMENTS: usize = 20;
52
53// ---------------------------------------------------------------------------
54// Pattern image
55// ---------------------------------------------------------------------------
56
57/// An RGBA8 pattern image used for fill-pattern and line-pattern rendering.
58///
59/// The image is stored as a flat `Vec<u8>` in row-major RGBA order
60/// (`width × height × 4` bytes).  Typically shared via `Arc<PatternImage>`
61/// so the same image can be referenced by the style, the engine mesh
62/// data, and the renderer without cloning pixel data.
63#[derive(Debug, Clone)]
64pub struct PatternImage {
65    /// Image width in pixels.
66    pub width: u32,
67    /// Image height in pixels.
68    pub height: u32,
69    /// Pixel data in RGBA8 format (`width * height * 4` bytes).
70    pub data: Vec<u8>,
71}
72
73impl PatternImage {
74    /// Create a new pattern image from raw RGBA8 pixel data.
75    ///
76    /// # Panics
77    ///
78    /// Panics if `data.len() != width * height * 4`.
79    pub fn new(width: u32, height: u32, data: Vec<u8>) -> Self {
80        assert_eq!(
81            data.len(),
82            (width * height * 4) as usize,
83            "RGBA8 data length must be width × height × 4"
84        );
85        Self {
86            width,
87            height,
88            data,
89        }
90    }
91}
92
93/// High-level vector rendering family.
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
95pub enum VectorRenderMode {
96    /// Legacy generic tessellation that renders polygons, lines, and points.
97    #[default]
98    Generic,
99    /// Polygon fills.
100    Fill,
101    /// Line-only rendering.
102    Line,
103    /// Point circles.
104    Circle,
105    /// Point heatmap blobs.
106    Heatmap,
107    /// Polygon extrusion into simple 3D prisms.
108    FillExtrusion,
109    /// Basic point symbols rendered as simple haloed markers.
110    Symbol,
111}
112
113/// Line cap style — how the endpoints of a line are drawn.
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
115pub enum LineCap {
116    /// Flat cut at the endpoint (default).
117    #[default]
118    Butt,
119    /// Semicircular cap extending half-width beyond the endpoint.
120    Round,
121    /// Square cap extending half-width beyond the endpoint.
122    Square,
123}
124
125/// Line join style — how adjacent segments of a line are connected.
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
127pub enum LineJoin {
128    /// Sharp corner extended along the bisector, falling back to bevel
129    /// when the miter ratio exceeds [`VectorStyle::miter_limit`].
130    #[default]
131    Miter,
132    /// Flat diagonal cut at the outer bend.
133    Bevel,
134    /// Rounded arc at the outer bend.
135    Round,
136}
137
138/// Tile-owned provenance for a vector feature.
139#[derive(Debug, Clone, PartialEq, Eq)]
140pub struct FeatureProvenance {
141    /// Source-layer name, when known.
142    pub source_layer: Option<String>,
143    /// Actual tile that supplied this feature.
144    pub source_tile: Option<TileId>,
145}
146
147/// Style parameters for rendering a vector layer.
148#[derive(Debug, Clone)]
149pub struct VectorStyle {
150    /// Active render family.
151    pub render_mode: VectorRenderMode,
152    /// Fill colour (RGBA, 0--1 range).
153    pub fill_color: [f32; 4],
154    /// Stroke (outline) colour (RGBA, 0--1 range).
155    pub stroke_color: [f32; 4],
156    /// Stroke width in pixels.
157    pub stroke_width: f32,
158    /// Point/circle radius in pixels.
159    pub point_radius: f32,
160    /// Line cap style.
161    pub line_cap: LineCap,
162    /// Line join style.
163    pub line_join: LineJoin,
164    /// Miter limit ratio — when the miter length exceeds `miter_limit *
165    /// stroke_width`, a miter join falls back to bevel.  Default 2.0
166    /// (matching MapLibre / Mapbox).
167    pub miter_limit: f32,
168    /// Optional dash pattern `[dash_length, gap_length, …]` in pixels.
169    pub dash_array: Option<Vec<f32>>,
170    /// Heatmap radius in pixels.
171    pub heatmap_radius: f32,
172    /// Heatmap intensity multiplier.
173    pub heatmap_intensity: f32,
174    /// Extrusion base height in meters.
175    pub extrusion_base: f32,
176    /// Extrusion height in meters.
177    pub extrusion_height: f32,
178    /// Symbol size in pixels.
179    pub symbol_size: f32,
180    /// Symbol halo colour.
181    pub symbol_halo_color: [f32; 4],
182    /// Feature property used for text labels.
183    pub symbol_text_field: Option<String>,
184    /// Optional icon image id for point symbols.
185    pub symbol_icon_image: Option<String>,
186    /// Font stack used for glyph dependency requests.
187    pub symbol_font_stack: String,
188    /// Collision padding in pixels.
189    pub symbol_padding: f32,
190    /// Shared overlap fallback for simplified callers.
191    ///
192    /// Mapbox and MapLibre distinguish text and icon overlap flags. The engine
193    /// keeps this field as a shared default for existing call sites, while the
194    /// more specific text/icon flags below let symbol candidates apply closer
195    /// spec behavior.
196    pub symbol_allow_overlap: bool,
197    /// Whether text is allowed to overlap other placed symbols.
198    pub symbol_text_allow_overlap: bool,
199    /// Whether icons are allowed to overlap other placed symbols.
200    pub symbol_icon_allow_overlap: bool,
201    /// Whether text may be dropped while keeping the icon.
202    pub symbol_text_optional: bool,
203    /// Whether the icon may be dropped while keeping the text.
204    pub symbol_icon_optional: bool,
205    /// Whether text may be placed without blocking later symbols.
206    pub symbol_text_ignore_placement: bool,
207    /// Whether icons may be placed without blocking later symbols.
208    pub symbol_icon_ignore_placement: bool,
209    /// Default anchor when variable anchors are not in use.
210    pub symbol_text_anchor: SymbolAnchor,
211    /// Effective horizontal justification for the current label.
212    pub symbol_text_justify: SymbolTextJustify,
213    /// Text transformation applied before measurement and placement.
214    pub symbol_text_transform: SymbolTextTransform,
215    /// Maximum point-label width in symbol-size units before wrapping.
216    ///
217    /// This is the engine's simplified `text-max-width` representation. It is
218    /// currently used to estimate wrapped placeholder text boxes for point
219    /// labels, which improves collision and sizing behavior before full glyph
220    /// shaping is in place.
221    pub symbol_text_max_width: Option<f32>,
222    /// Preferred line height for wrapped text in text-size units.
223    ///
224    /// The engine uses this in its simplified wrapped text-box estimate so
225    /// placeholder label height follows the style more closely even before full
226    /// glyph shaping is implemented.
227    pub symbol_text_line_height: Option<f32>,
228    /// Extra spacing between adjacent glyphs in text-size units.
229    ///
230    /// The engine applies this to its placeholder text width estimate so label
231    /// collisions and wrapping react to `text-letter-spacing` before full glyph
232    /// shaping exists.
233    pub symbol_text_letter_spacing: Option<f32>,
234    /// Icon sizing mode relative to label text.
235    pub symbol_icon_text_fit: SymbolIconTextFit,
236    /// Padding applied when fitting the icon around text.
237    pub symbol_icon_text_fit_padding: [f32; 4],
238    /// Placement priority for symbol ordering.
239    ///
240    /// Lower sort keys are placed first, matching Mapbox and MapLibre's
241    /// `symbol-sort-key` semantics. `None` preserves source order when the
242    /// style does not request explicit symbol ordering.
243    pub symbol_sort_key: Option<f32>,
244    /// Preferred variable anchors in priority order.
245    pub symbol_anchors: Vec<SymbolAnchor>,
246    /// Whether symbols are anchored on points or along lines.
247    pub symbol_placement: SymbolPlacement,
248    /// Preferred spacing between repeated line-placed symbols in pixels.
249    ///
250    /// Mapbox and MapLibre interpret this during tile-space symbol layout.
251    /// The engine currently uses it as an approximate world-space spacing for
252    /// repeated line anchors until full line-shaped symbol layout is in place.
253    pub symbol_spacing: f32,
254    /// Maximum cumulative turn angle tolerated for a line-placed label.
255    ///
256    /// The current implementation applies this as a simplified readability
257    /// check over the retained line window around each anchor. It is not yet a
258    /// full reproduction of Mapbox or MapLibre's sliding-window angle logic,
259    /// but it prevents labels from being emitted on the sharpest bends.
260    pub symbol_max_angle: f32,
261    /// Whether line-placed text should be flipped to remain upright.
262    ///
263    /// Mapbox and MapLibre use this to avoid upside-down line labels. The
264    /// engine currently applies it by normalizing line-anchor rotation into an
265    /// upright range before placement.
266    pub symbol_keep_upright: bool,
267    /// Radial text offset measured in symbol-size units.
268    ///
269    /// The full style spec defines this in EM-like text units. The engine uses
270    /// the current symbol size as the scale factor so anchored labels can move
271    /// outward in the requested direction without waiting for full glyph
272    /// shaping metrics.
273    pub symbol_text_radial_offset: Option<f32>,
274    /// Explicit per-anchor offsets for variable anchor placement.
275    ///
276    /// This is the engine's simplified representation of
277    /// `text-variable-anchor-offset`. Values are stored in symbol-size units and
278    /// resolved for the selected anchor during placement.
279    pub symbol_variable_anchor_offsets: Option<Vec<(SymbolAnchor, [f32; 2])>>,
280    /// Writing mode for symbol text.
281    pub symbol_writing_mode: SymbolWritingMode,
282    /// Pixel-space symbol offset.
283    pub symbol_offset: [f32; 2],
284    /// Fill-layer pixel translate offset `[tx, ty]`.
285    pub fill_translate: [f32; 2],
286    /// Fill-layer opacity (separate from vertex alpha).
287    pub fill_opacity: f32,
288    /// Whether fill edges should be antialiased.
289    pub fill_antialias: bool,
290    /// Optional distinct outline color. When `None`, outline uses `stroke_color`.
291    pub fill_outline_color: Option<[f32; 4]>,
292
293    /// Optional fill pattern image.
294    ///
295    /// When set, the fill is rendered with a repeating pattern texture
296    /// instead of a solid colour.  The pattern repeats in world space
297    /// with one pattern pixel mapping to approximately one meter,
298    /// matching MapLibre / Mapbox `fill-pattern` semantics.
299    pub fill_pattern: Option<Arc<PatternImage>>,
300
301    /// Optional line pattern image.
302    ///
303    /// When set, the line is rendered with a repeating pattern texture
304    /// instead of a solid colour.  The pattern maps along the line
305    /// centreline (U axis, distance / pattern width) and across the line
306    /// width (V axis, 0 at left edge → 1 at right edge), matching
307    /// MapLibre / Mapbox `line-pattern` semantics.
308    pub line_pattern: Option<Arc<PatternImage>>,
309
310    // -- Data-driven expression overrides ---------------------------------
311    /// Optional data-driven width expression.
312    ///
313    /// When present and [`Expression::is_data_driven`] returns `true`,
314    /// [`VectorLayer::tessellate`] evaluates the expression per feature
315    /// instead of using the single `stroke_width` value for every feature.
316    pub width_expr: Option<Expression<f32>>,
317    /// Optional data-driven stroke-color expression.
318    ///
319    /// Same semantics as `width_expr`: when data-driven, colour is resolved
320    /// per feature during tessellation.
321    pub stroke_color_expr: Option<Expression<[f32; 4]>>,
322    /// Zoom level captured at style-evaluation time.
323    ///
324    /// Needed to build a per-feature [`ExprEvalContext`] during tessellation
325    /// so that zoom-dependent stops still resolve correctly.
326    pub eval_zoom: f32,
327
328    // -- Line gradient -------------------------------------------------------
329    /// Optional colour ramp evaluated along the polyline centreline.
330    ///
331    /// When set, the tessellator overrides per-vertex colours with the
332    /// gradient evaluated at each vertex's normalized distance `[0, 1]`
333    /// along the feature.  This replicates MapLibre / Mapbox's
334    /// `line-gradient` property.
335    pub line_gradient: Option<ColorRamp>,
336}
337
338impl Default for VectorStyle {
339    fn default() -> Self {
340        Self {
341            render_mode: VectorRenderMode::Generic,
342            fill_color: [0.2, 0.5, 0.8, 0.5],
343            stroke_color: [0.0, 0.0, 0.0, 1.0],
344            stroke_width: 2.0,
345            point_radius: 6.0,
346            line_cap: LineCap::Butt,
347            line_join: LineJoin::Miter,
348            miter_limit: 2.0,
349            dash_array: None,
350            heatmap_radius: 18.0,
351            heatmap_intensity: 1.0,
352            extrusion_base: 0.0,
353            extrusion_height: 30.0,
354            symbol_size: 10.0,
355            symbol_halo_color: [1.0, 1.0, 1.0, 0.85],
356            symbol_text_field: None,
357            symbol_icon_image: None,
358            symbol_font_stack: "Noto Sans Regular".into(),
359            symbol_padding: 2.0,
360            symbol_allow_overlap: false,
361            symbol_text_allow_overlap: false,
362            symbol_icon_allow_overlap: false,
363            symbol_text_optional: false,
364            symbol_icon_optional: false,
365            symbol_text_ignore_placement: false,
366            symbol_icon_ignore_placement: false,
367            symbol_text_anchor: SymbolAnchor::Center,
368            symbol_text_justify: SymbolTextJustify::Auto,
369            symbol_text_transform: SymbolTextTransform::None,
370            symbol_text_max_width: None,
371            symbol_text_line_height: None,
372            symbol_text_letter_spacing: None,
373            symbol_icon_text_fit: SymbolIconTextFit::None,
374            symbol_icon_text_fit_padding: [0.0, 0.0, 0.0, 0.0],
375            symbol_sort_key: None,
376            symbol_anchors: vec![SymbolAnchor::Center],
377            symbol_placement: SymbolPlacement::Point,
378            symbol_spacing: 250.0,
379            symbol_max_angle: 45.0,
380            symbol_keep_upright: true,
381            symbol_text_radial_offset: None,
382            symbol_variable_anchor_offsets: None,
383            symbol_writing_mode: SymbolWritingMode::Horizontal,
384            symbol_offset: [0.0, 0.0],
385            fill_translate: [0.0, 0.0],
386            fill_opacity: 1.0,
387            fill_antialias: true,
388            fill_outline_color: None,
389            fill_pattern: None,
390            line_pattern: None,
391            width_expr: None,
392            stroke_color_expr: None,
393            eval_zoom: 0.0,
394            line_gradient: None,
395        }
396    }
397}
398
399impl VectorStyle {
400    /// Convenience constructor for fill rendering.
401    pub fn fill(fill_color: [f32; 4], outline_color: [f32; 4], outline_width: f32) -> Self {
402        Self {
403            render_mode: VectorRenderMode::Fill,
404            fill_color,
405            stroke_color: outline_color,
406            stroke_width: outline_width,
407            ..Self::default()
408        }
409    }
410
411    /// Fill rendering with a repeating pattern texture.
412    ///
413    /// The pattern overrides `fill_color` — the fragment shader samples
414    /// the pattern image instead of using a solid colour.
415    pub fn fill_pattern(pattern: Arc<PatternImage>) -> Self {
416        Self {
417            render_mode: VectorRenderMode::Fill,
418            fill_pattern: Some(pattern),
419            ..Self::default()
420        }
421    }
422
423    /// Line rendering with a repeating pattern texture.
424    ///
425    /// The pattern maps along the line centreline and across its width.
426    /// The fragment shader samples the pattern image instead of using a
427    /// solid colour, matching MapLibre / Mapbox `line-pattern` semantics.
428    pub fn line_pattern(width: f32, pattern: Arc<PatternImage>) -> Self {
429        Self {
430            render_mode: VectorRenderMode::Line,
431            stroke_width: width,
432            line_pattern: Some(pattern),
433            ..Self::default()
434        }
435    }
436
437    /// Convenience constructor for line rendering.
438    pub fn line(color: [f32; 4], width: f32) -> Self {
439        Self {
440            render_mode: VectorRenderMode::Line,
441            stroke_color: color,
442            stroke_width: width,
443            ..Self::default()
444        }
445    }
446
447    /// Line rendering with explicit cap, join, and miter limit.
448    pub fn line_styled(
449        color: [f32; 4],
450        width: f32,
451        cap: LineCap,
452        join: LineJoin,
453        miter_limit: f32,
454        dash_array: Option<Vec<f32>>,
455    ) -> Self {
456        Self {
457            render_mode: VectorRenderMode::Line,
458            stroke_color: color,
459            stroke_width: width,
460            line_cap: cap,
461            line_join: join,
462            miter_limit,
463            dash_array,
464            ..Self::default()
465        }
466    }
467
468    /// Line rendering with a colour gradient along the polyline.
469    ///
470    /// The gradient overrides `color` — per-vertex colours are computed
471    /// from the `ColorRamp` at each vertex's normalised distance along
472    /// the line.
473    pub fn line_gradient(width: f32, ramp: ColorRamp) -> Self {
474        Self {
475            render_mode: VectorRenderMode::Line,
476            stroke_color: [1.0, 1.0, 1.0, 1.0],
477            stroke_width: width,
478            line_gradient: Some(ramp),
479            ..Self::default()
480        }
481    }
482
483    /// Convenience constructor for point circles.
484    pub fn circle(color: [f32; 4], radius: f32, stroke_color: [f32; 4], stroke_width: f32) -> Self {
485        Self {
486            render_mode: VectorRenderMode::Circle,
487            fill_color: color,
488            point_radius: radius,
489            stroke_color,
490            stroke_width,
491            ..Self::default()
492        }
493    }
494
495    /// Convenience constructor for heatmap blobs.
496    pub fn heatmap(color: [f32; 4], radius: f32, intensity: f32) -> Self {
497        Self {
498            render_mode: VectorRenderMode::Heatmap,
499            fill_color: color,
500            heatmap_radius: radius,
501            heatmap_intensity: intensity,
502            ..Self::default()
503        }
504    }
505
506    /// Convenience constructor for polygon extrusion.
507    pub fn fill_extrusion(color: [f32; 4], base: f32, height: f32) -> Self {
508        Self {
509            render_mode: VectorRenderMode::FillExtrusion,
510            fill_color: color,
511            extrusion_base: base,
512            extrusion_height: height,
513            ..Self::default()
514        }
515    }
516
517    /// Convenience constructor for basic symbol markers.
518    pub fn symbol(color: [f32; 4], halo_color: [f32; 4], size: f32) -> Self {
519        Self {
520            render_mode: VectorRenderMode::Symbol,
521            fill_color: color,
522            symbol_halo_color: halo_color,
523            symbol_size: size,
524            ..Self::default()
525        }
526    }
527
528    /// Compute a lightweight `u64` fingerprint of the fields that affect
529    /// [`VectorLayer::tessellate`] output.
530    ///
531    /// Two styles that produce identical tessellation results will have the
532    /// same fingerprint. The hash is cheap (a few dozen field reads through
533    /// the default hasher) and is used by the sync-path vector bucket cache
534    /// to detect style changes without requiring `PartialEq` on
535    /// `VectorStyle`.
536    pub fn tessellation_fingerprint(&self) -> u64 {
537        use std::hash::{Hash, Hasher};
538        let mut h = std::collections::hash_map::DefaultHasher::new();
539        // render_mode
540        std::mem::discriminant(&self.render_mode).hash(&mut h);
541        // colors and widths that feed into vertex output
542        self.fill_color
543            .iter()
544            .for_each(|v| v.to_bits().hash(&mut h));
545        self.stroke_color
546            .iter()
547            .for_each(|v| v.to_bits().hash(&mut h));
548        self.stroke_width.to_bits().hash(&mut h);
549        self.point_radius.to_bits().hash(&mut h);
550        self.heatmap_radius.to_bits().hash(&mut h);
551        self.heatmap_intensity.to_bits().hash(&mut h);
552        self.extrusion_base.to_bits().hash(&mut h);
553        self.extrusion_height.to_bits().hash(&mut h);
554        self.symbol_size.to_bits().hash(&mut h);
555        self.symbol_halo_color
556            .iter()
557            .for_each(|v| v.to_bits().hash(&mut h));
558        self.fill_translate
559            .iter()
560            .for_each(|v| v.to_bits().hash(&mut h));
561        self.fill_opacity.to_bits().hash(&mut h);
562        self.fill_antialias.hash(&mut h);
563        if let Some(ref c) = self.fill_outline_color {
564            c.iter().for_each(|v| v.to_bits().hash(&mut h));
565        }
566        // Data-driven state: when an expression is data-driven, the
567        // tessellation output depends on per-feature properties, making
568        // the fingerprint also depend on the zoom level (which affects
569        // interpolated stops).
570        let has_dd_width = self.width_expr.as_ref().is_some_and(|e| e.is_data_driven());
571        let has_dd_color = self
572            .stroke_color_expr
573            .as_ref()
574            .is_some_and(|e| e.is_data_driven());
575        has_dd_width.hash(&mut h);
576        has_dd_color.hash(&mut h);
577        if has_dd_width || has_dd_color {
578            self.eval_zoom.to_bits().hash(&mut h);
579        }
580        h.finish()
581    }
582
583    /// Whether line width is data-driven (varies per feature).
584    #[inline]
585    pub fn is_width_data_driven(&self) -> bool {
586        self.width_expr.as_ref().is_some_and(|e| e.is_data_driven())
587    }
588
589    /// Whether stroke color is data-driven (varies per feature).
590    #[inline]
591    pub fn is_stroke_color_data_driven(&self) -> bool {
592        self.stroke_color_expr
593            .as_ref()
594            .is_some_and(|e| e.is_data_driven())
595    }
596
597    /// Evaluate stroke width for a specific feature's properties.
598    ///
599    /// Returns `self.stroke_width` when no data-driven expression is set.
600    pub fn evaluate_width(&self, feature: &Feature) -> f32 {
601        match &self.width_expr {
602            Some(expr) if expr.is_data_driven() => {
603                let ctx = ExprEvalContext::with_feature(self.eval_zoom, &feature.properties);
604                expr.evaluate_with_properties(&ctx)
605            }
606            _ => self.stroke_width,
607        }
608    }
609
610    /// Evaluate stroke color for a specific feature's properties.
611    ///
612    /// Returns `self.stroke_color` when no data-driven expression is set.
613    pub fn evaluate_stroke_color(&self, feature: &Feature) -> [f32; 4] {
614        match &self.stroke_color_expr {
615            Some(expr) if expr.is_data_driven() => {
616                let ctx = ExprEvalContext::with_feature(self.eval_zoom, &feature.properties);
617                expr.evaluate_with_properties(&ctx)
618            }
619            _ => self.stroke_color,
620        }
621    }
622}
623
624#[derive(Clone, Copy)]
625struct LinePlacementAnchor {
626    coord: GeoCoord,
627    rotation_rad: f32,
628    distance: f64,
629}
630
631// ---------------------------------------------------------------------------
632// VectorMeshData
633// ---------------------------------------------------------------------------
634
635/// Per-instance data for SDF circle rendering.
636///
637/// Each circle is rendered as a screen-aligned quad by the GPU.  The
638/// fragment shader evaluates an SDF to produce anti-aliased circles with
639/// optional stroke.
640#[derive(Debug, Clone, Copy, Default)]
641pub struct CircleInstanceData {
642    /// Circle centre in world space `[x, y, z]` (f64 for precision).
643    pub center: [f64; 3],
644    /// Circle radius in world-space meters.
645    pub radius: f32,
646    /// Fill colour `[r, g, b, a]`.
647    pub color: [f32; 4],
648    /// Stroke colour `[r, g, b, a]`.
649    pub stroke_color: [f32; 4],
650    /// Stroke width in world-space meters.
651    pub stroke_width: f32,
652    /// Blur amount (0 = hard edge, 1 = fully soft).
653    pub blur: f32,
654}
655
656/// CPU-side mesh data for vector rendering, ready for GPU upload.
657///
658/// Produced by [`VectorLayer::tessellate`] and consumed by renderers.
659/// One instance per visible vector layer per frame.
660///
661/// All positions are in **Web Mercator world space** (f64 for precision).
662/// The renderer converts them to camera-relative f32 before uploading.
663#[derive(Debug, Clone)]
664pub struct VectorMeshData {
665    /// Vertex positions in world space `[x, y, z]` (f64 for precision).
666    pub positions: Vec<[f64; 3]>,
667    /// Per-vertex colour `[r, g, b, a]` (0--1 range, linear).
668    pub colors: Vec<[f32; 4]>,
669    /// Per-vertex surface normals `[nx, ny, nz]`.
670    ///
671    /// Empty for flat geometry (fills, lines, circles).  Populated for
672    /// 3-D geometry such as fill-extrusions where per-face normals are
673    /// needed for proper lighting.  When non-empty, the length **must**
674    /// equal `positions.len()`.
675    pub normals: Vec<[f32; 3]>,
676    /// Triangle indices into `positions` / `colors` / `normals`.
677    pub indices: Vec<u32>,
678    /// High-level render family that produced this mesh.
679    ///
680    /// Renderers use this to select the appropriate GPU pipeline
681    /// (e.g. lit fill-extrusion vs. flat fill).
682    pub render_mode: VectorRenderMode,
683
684    // -- Line-specific data -----------------------------------------------
685    /// Per-vertex distance along the polyline centreline (meters in world
686    /// space).  Non-empty only for `VectorRenderMode::Line`.  The GPU line
687    /// shader uses this for dash-pattern evaluation.
688    pub line_distances: Vec<f32>,
689    /// Per-vertex extrusion normal `[nx, ny]` (unit-length direction
690    /// perpendicular to the line centreline).  Non-empty only for
691    /// `VectorRenderMode::Line`.
692    pub line_normals: Vec<[f32; 2]>,
693    /// Per-vertex cap/join flag.  `1.0` for round cap/join fan vertices,
694    /// `0.0` for ribbon body and non-round cap/join vertices.  The GPU
695    /// line shader uses this to switch between linear edge AA and
696    /// SDF circle-based AA.  Non-empty only for `VectorRenderMode::Line`.
697    pub line_cap_joins: Vec<f32>,
698    /// Line style parameters `[dash_length, gap_length, half_width, cap_round]`.
699    /// Uniform across all vertices in a single mesh.  `cap_round` is 1.0
700    /// for round caps and 0.0 for butt caps.
701    pub line_params: [f32; 4],
702
703    // -- Circle-specific data ---------------------------------------------
704    /// Per-instance circle data: `[center_x, center_y, center_z, radius]`
705    /// in world space (f64 centre, f32 radius).
706    pub circle_instances: Vec<CircleInstanceData>,
707
708    // -- Heatmap-specific data --------------------------------------------
709    /// Per-point heatmap data: `[x, y, weight, radius]` in world space.
710    pub heatmap_points: Vec<[f64; 4]>,
711    /// Heatmap intensity multiplier.
712    pub heatmap_intensity: f32,
713
714    // -- Fill-specific data -----------------------------------------------
715    /// Fill-layer pixel translate offset `[tx, ty]`.
716    pub fill_translate: [f32; 2],
717    /// Fill-layer opacity (separate from vertex alpha).
718    pub fill_opacity: f32,
719    /// Whether fill edges should be antialiased.
720    pub fill_antialias: bool,
721    /// Outline colour override for fill outlines.
722    /// When `[0,0,0,0]`, the outline uses the fill colour.
723    pub fill_outline_color: [f32; 4],
724    /// Optional pattern image for fill-pattern rendering.
725    ///
726    /// When present, the renderer creates a repeating texture and
727    /// uses `fill_pattern_uvs` to sample it.  Non-empty only for
728    /// `VectorRenderMode::Fill` with a pattern set.
729    pub fill_pattern: Option<Arc<PatternImage>>,
730    /// Per-vertex pattern UV coordinates `[u, v]`.
731    ///
732    /// Populated only when `fill_pattern` is `Some`.  The coordinates
733    /// are in pattern-tile units: `(0,0)` is the pattern origin,
734    /// `(1,1)` is one full pattern repetition.  Values outside `[0,1]`
735    /// repeat via the GPU's `Repeat` address mode.
736    pub fill_pattern_uvs: Vec<[f32; 2]>,
737
738    // -- Line-pattern data ------------------------------------------------
739    /// Optional pattern image for line-pattern rendering.
740    ///
741    /// When present, the renderer creates a repeating texture and
742    /// uses `line_pattern_uvs` to sample it.  Non-empty only for
743    /// `VectorRenderMode::Line` with a pattern set.
744    pub line_pattern: Option<Arc<PatternImage>>,
745    /// Per-vertex pattern UV coordinates `[u, v]` for line-pattern rendering.
746    ///
747    /// U maps along the line centreline (`distance / pattern_width`),
748    /// V maps across the line width (`0.0` = left edge, `1.0` = right edge).
749    /// Values outside `[0,1]` on the U axis repeat via the GPU's `Repeat`
750    /// address mode.
751    pub line_pattern_uvs: Vec<[f32; 2]>,
752}
753
754impl VectorMeshData {
755    /// Whether the mesh contains no geometry.
756    #[inline]
757    pub fn is_empty(&self) -> bool {
758        self.indices.is_empty()
759    }
760
761    /// Number of vertices.
762    #[inline]
763    pub fn vertex_count(&self) -> usize {
764        self.positions.len()
765    }
766
767    /// Number of triangle indices (always a multiple of 3 in valid meshes).
768    #[inline]
769    pub fn index_count(&self) -> usize {
770        self.indices.len()
771    }
772
773    /// Number of triangles.
774    #[inline]
775    pub fn triangle_count(&self) -> usize {
776        self.indices.len() / 3
777    }
778
779    /// Whether this mesh carries per-vertex normals for lighting.
780    #[inline]
781    pub fn has_normals(&self) -> bool {
782        !self.normals.is_empty()
783    }
784
785    /// Append another mesh into this one, adjusting indices.
786    pub fn merge(&mut self, other: &VectorMeshData) {
787        let base = self.positions.len() as u32;
788        self.positions.extend_from_slice(&other.positions);
789        self.colors.extend_from_slice(&other.colors);
790        self.normals.extend_from_slice(&other.normals);
791        self.line_distances.extend_from_slice(&other.line_distances);
792        self.line_normals.extend_from_slice(&other.line_normals);
793        self.fill_pattern_uvs
794            .extend_from_slice(&other.fill_pattern_uvs);
795        self.line_pattern_uvs
796            .extend_from_slice(&other.line_pattern_uvs);
797        self.indices.extend(other.indices.iter().map(|i| base + i));
798        self.circle_instances
799            .extend_from_slice(&other.circle_instances);
800        self.heatmap_points.extend_from_slice(&other.heatmap_points);
801    }
802
803    /// Remove all vertices and indices.
804    pub fn clear(&mut self) {
805        self.positions.clear();
806        self.colors.clear();
807        self.normals.clear();
808        self.line_distances.clear();
809        self.line_normals.clear();
810        self.indices.clear();
811        self.circle_instances.clear();
812        self.heatmap_points.clear();
813        self.fill_translate = [0.0, 0.0];
814        self.fill_opacity = 1.0;
815        self.fill_antialias = true;
816        self.fill_outline_color = [0.0, 0.0, 0.0, 0.0];
817        self.fill_pattern = None;
818        self.fill_pattern_uvs.clear();
819        self.line_pattern = None;
820        self.line_pattern_uvs.clear();
821    }
822}
823
824impl Default for VectorMeshData {
825    fn default() -> Self {
826        Self {
827            positions: Vec::new(),
828            colors: Vec::new(),
829            normals: Vec::new(),
830            indices: Vec::new(),
831            render_mode: VectorRenderMode::Generic,
832            line_distances: Vec::new(),
833            line_normals: Vec::new(),
834            line_cap_joins: Vec::new(),
835            line_params: [0.0; 4],
836            circle_instances: Vec::new(),
837            heatmap_points: Vec::new(),
838            heatmap_intensity: 0.0,
839            fill_translate: [0.0, 0.0],
840            fill_opacity: 1.0,
841            fill_antialias: true,
842            fill_outline_color: [0.0, 0.0, 0.0, 0.0],
843            fill_pattern: None,
844            fill_pattern_uvs: Vec::new(),
845            line_pattern: None,
846            line_pattern_uvs: Vec::new(),
847        }
848    }
849}
850
851// ---------------------------------------------------------------------------
852// VectorLayer
853// ---------------------------------------------------------------------------
854
855/// A vector layer holding parsed geographic features with a render style.
856///
857/// See the [module-level documentation](self) for the tessellation
858/// pipeline and coordinate flow.
859pub struct VectorLayer {
860    id: LayerId,
861    name: String,
862    visible: bool,
863    opacity: f32,
864    /// Optional originating style layer id for query APIs.
865    pub query_layer_id: Option<String>,
866    /// Optional originating style source id for query APIs.
867    pub query_source_id: Option<String>,
868    /// Optional originating style source-layer id for streamed vector sources.
869    pub query_source_layer: Option<String>,
870    /// The feature data.
871    pub features: FeatureCollection,
872    /// Per-feature tile/source-layer provenance.
873    pub feature_provenance: Vec<Option<FeatureProvenance>>,
874    /// Render style.
875    pub style: VectorStyle,
876    /// Monotonically increasing counter bumped whenever feature data changes
877    /// via [`set_features_with_provenance`](Self::set_features_with_provenance).
878    /// Used by the sync-path tessellation cache to detect data staleness.
879    data_generation: u64,
880}
881
882impl std::fmt::Debug for VectorLayer {
883    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
884        f.debug_struct("VectorLayer")
885            .field("id", &self.id)
886            .field("name", &self.name)
887            .field("visible", &self.visible)
888            .field("opacity", &self.opacity)
889            .field("feature_count", &self.features.len())
890            .field("style", &self.style)
891            .finish()
892    }
893}
894
895impl VectorLayer {
896    /// Create a new vector layer.
897    pub fn new(name: impl Into<String>, features: FeatureCollection, style: VectorStyle) -> Self {
898        Self {
899            id: LayerId::next(),
900            name: name.into(),
901            visible: true,
902            opacity: 1.0,
903            query_layer_id: None,
904            query_source_id: None,
905            query_source_layer: None,
906            feature_provenance: vec![None; features.len()],
907            features,
908            style,
909            data_generation: 0,
910        }
911    }
912
913    /// Attach style/runtime query metadata to this layer.
914    pub fn with_query_metadata(
915        mut self,
916        layer_id: impl Into<Option<String>>,
917        source_id: impl Into<Option<String>>,
918    ) -> Self {
919        self.query_layer_id = layer_id.into();
920        self.query_source_id = source_id.into();
921        self
922    }
923
924    /// Attach the originating source-layer id used by style/runtime evaluation.
925    pub fn with_source_layer(mut self, source_layer: Option<String>) -> Self {
926        self.query_source_layer = source_layer;
927        self
928    }
929
930    /// Replace the feature set together with per-feature provenance.
931    pub fn set_features_with_provenance(
932        &mut self,
933        features: FeatureCollection,
934        mut provenance: Vec<Option<FeatureProvenance>>,
935    ) {
936        if provenance.len() < features.len() {
937            provenance.resize(features.len(), None);
938        } else if provenance.len() > features.len() {
939            provenance.truncate(features.len());
940        }
941        self.features = features;
942        self.feature_provenance = provenance;
943        self.data_generation = self.data_generation.wrapping_add(1);
944    }
945
946    /// Monotonically increasing generation counter for feature data changes.
947    ///
948    /// Bumped by [`set_features_with_provenance`](Self::set_features_with_provenance).
949    /// Used by the sync-path tessellation cache to detect data staleness.
950    #[inline]
951    pub fn data_generation(&self) -> u64 {
952        self.data_generation
953    }
954
955    /// Attach style/runtime query metadata to this layer in place.
956    pub fn set_query_metadata(&mut self, layer_id: Option<String>, source_id: Option<String>) {
957        self.query_layer_id = layer_id;
958        self.query_source_id = source_id;
959    }
960
961    // -- Queries ----------------------------------------------------------
962
963    /// Number of features in this layer.
964    #[inline]
965    pub fn feature_count(&self) -> usize {
966        self.features.len()
967    }
968
969    /// Total number of coordinate vertices across all features.
970    #[inline]
971    pub fn total_coords(&self) -> usize {
972        self.features.total_coords()
973    }
974
975    // -- Terrain draping --------------------------------------------------
976
977    /// Drape all vector features onto terrain by querying elevation
978    /// for each vertex and setting its altitude.
979    ///
980    /// Does nothing if terrain is disabled or elevation data is
981    /// unavailable for a given coordinate (the vertex retains its
982    /// original altitude).
983    pub fn drape_on_terrain(&mut self, terrain: &TerrainManager) {
984        if !terrain.enabled() {
985            return;
986        }
987        for feature in &mut self.features.features {
988            drape_geometry(&mut feature.geometry, terrain);
989        }
990    }
991
992    // -- Tessellation -----------------------------------------------------
993
994    /// Tessellate all features into GPU-ready mesh data.
995    ///
996    /// - **Polygons** are fan-triangulated (exterior ring only; holes
997    ///   are not yet subtracted).
998    /// - **Line strings** are stroke-expanded into ribbon quads with
999    ///   the configured [`stroke_width`](VectorStyle::stroke_width).
1000    /// - **Points** are rendered as small axis-aligned quads.
1001    /// - **Multi\*** and **GeometryCollection** types recurse into
1002    ///   their children.
1003    ///
1004    /// When the style carries data-driven expressions for width or colour,
1005    /// those are evaluated per feature against each feature's properties.
1006    ///
1007    /// All output positions are in the active planar world-space meters.
1008    pub fn tessellate(&self, projection: CameraProjection) -> VectorMeshData {
1009        let mut mesh = VectorMeshData {
1010            render_mode: self.style.render_mode,
1011            ..VectorMeshData::default()
1012        };
1013
1014        let dd_width = self.style.is_width_data_driven();
1015        let dd_color = self.style.is_stroke_color_data_driven();
1016        let default_half_width = self.style.stroke_width as f64 * DEGREES_PER_PIXEL_APPROX;
1017
1018        if dd_width || dd_color {
1019            // Data-driven path: evaluate width/color per feature.
1020            for feature in &self.features.features {
1021                let half_width = if dd_width {
1022                    self.style.evaluate_width(feature) as f64 * DEGREES_PER_PIXEL_APPROX
1023                } else {
1024                    default_half_width
1025                };
1026                if dd_color {
1027                    let color = self.style.evaluate_stroke_color(feature);
1028                    let mut feature_style = self.style.clone();
1029                    feature_style.stroke_color = color;
1030                    tessellate_geometry(
1031                        &feature.geometry,
1032                        &feature_style,
1033                        projection,
1034                        half_width,
1035                        &mut mesh,
1036                    );
1037                } else {
1038                    tessellate_geometry(
1039                        &feature.geometry,
1040                        &self.style,
1041                        projection,
1042                        half_width,
1043                        &mut mesh,
1044                    );
1045                }
1046            }
1047        } else {
1048            // Fast path: uniform width and color for all features.
1049            for feature in &self.features.features {
1050                tessellate_geometry(
1051                    &feature.geometry,
1052                    &self.style,
1053                    projection,
1054                    default_half_width,
1055                    &mut mesh,
1056                );
1057            }
1058        }
1059
1060        // Propagate fill-specific style parameters to the mesh data so the
1061        // renderer has access without needing the full VectorStyle.
1062        if self.style.render_mode == VectorRenderMode::Fill {
1063            mesh.fill_translate = self.style.fill_translate;
1064            mesh.fill_opacity = self.style.fill_opacity;
1065            mesh.fill_antialias = self.style.fill_antialias;
1066            mesh.fill_outline_color = self
1067                .style
1068                .fill_outline_color
1069                .unwrap_or(self.style.stroke_color);
1070        }
1071
1072        // Propagate line-specific style parameters so the GPU line shader
1073        // can evaluate dash patterns and cap styles without the full style.
1074        if self.style.render_mode == VectorRenderMode::Line {
1075            let (dash_len, gap_len) = match &self.style.dash_array {
1076                Some(arr) if arr.len() >= 2 => (arr[0], arr[1]),
1077                _ => (0.0, 0.0),
1078            };
1079            let cap_round = match self.style.line_cap {
1080                LineCap::Round => 1.0,
1081                _ => 0.0,
1082            };
1083            mesh.line_params = [dash_len, gap_len, cap_round, 0.0];
1084        }
1085
1086        mesh
1087    }
1088
1089    /// Build symbol-placement candidates for this layer.
1090    pub fn symbol_candidates(&self) -> Vec<SymbolCandidate> {
1091        self.symbol_candidates_for_features(&self.features, &self.feature_provenance)
1092    }
1093
1094    /// Build symbol-placement candidates from an explicit feature/provenance set.
1095    pub fn symbol_candidates_for_features(
1096        &self,
1097        features: &FeatureCollection,
1098        feature_provenance: &[Option<FeatureProvenance>],
1099    ) -> Vec<SymbolCandidate> {
1100        if self.style.render_mode != VectorRenderMode::Symbol {
1101            return Vec::new();
1102        }
1103
1104        let mut out = Vec::new();
1105        for (feature_index, feature) in features.features.iter().enumerate() {
1106            collect_symbol_candidates_from_geometry(
1107                self.id,
1108                self.query_layer_id.as_deref(),
1109                self.query_source_id.as_deref(),
1110                feature_provenance
1111                    .get(feature_index)
1112                    .and_then(|p| p.as_ref()),
1113                feature_index,
1114                0,
1115                feature,
1116                &feature.geometry,
1117                &self.style,
1118                &mut out,
1119            );
1120        }
1121        out
1122    }
1123}
1124
1125#[allow(clippy::too_many_arguments)]
1126fn collect_symbol_candidates_from_geometry(
1127    layer_id: LayerId,
1128    query_layer_id: Option<&str>,
1129    query_source_id: Option<&str>,
1130    provenance: Option<&FeatureProvenance>,
1131    feature_index: usize,
1132    point_index: usize,
1133    feature: &Feature,
1134    geometry: &Geometry,
1135    style: &VectorStyle,
1136    out: &mut Vec<SymbolCandidate>,
1137) -> usize {
1138    if style.symbol_placement == SymbolPlacement::Line {
1139        return collect_line_symbol_candidates_from_geometry(
1140            layer_id,
1141            query_layer_id,
1142            query_source_id,
1143            provenance,
1144            feature_index,
1145            point_index,
1146            feature,
1147            geometry,
1148            style,
1149            out,
1150        );
1151    }
1152
1153    match geometry {
1154        Geometry::Point(point) => {
1155            let candidates = symbol_candidates_for_point(
1156                layer_id,
1157                query_layer_id,
1158                query_source_id,
1159                provenance,
1160                feature_index,
1161                point_index,
1162                feature,
1163                point.coord,
1164                style,
1165            );
1166            out.extend(candidates);
1167            point_index + 1
1168        }
1169        Geometry::MultiPoint(points) => {
1170            let mut next = point_index;
1171            for point in &points.points {
1172                next = collect_symbol_candidates_from_geometry(
1173                    layer_id,
1174                    query_layer_id,
1175                    query_source_id,
1176                    provenance,
1177                    feature_index,
1178                    next,
1179                    feature,
1180                    &Geometry::Point(point.clone()),
1181                    style,
1182                    out,
1183                );
1184            }
1185            next
1186        }
1187        Geometry::GeometryCollection(geometries) => {
1188            let mut next = point_index;
1189            for geometry in geometries {
1190                next = collect_symbol_candidates_from_geometry(
1191                    layer_id,
1192                    query_layer_id,
1193                    query_source_id,
1194                    provenance,
1195                    feature_index,
1196                    next,
1197                    feature,
1198                    geometry,
1199                    style,
1200                    out,
1201                );
1202            }
1203            next
1204        }
1205        _ => point_index,
1206    }
1207}
1208
1209#[allow(clippy::too_many_arguments)]
1210fn collect_line_symbol_candidates_from_geometry(
1211    layer_id: LayerId,
1212    query_layer_id: Option<&str>,
1213    query_source_id: Option<&str>,
1214    provenance: Option<&FeatureProvenance>,
1215    feature_index: usize,
1216    point_index: usize,
1217    feature: &Feature,
1218    geometry: &Geometry,
1219    style: &VectorStyle,
1220    out: &mut Vec<SymbolCandidate>,
1221) -> usize {
1222    match geometry {
1223        Geometry::LineString(line) => {
1224            let mut next = point_index;
1225            let label_length = estimated_line_label_length_meters(feature, style);
1226            for (slot_index, anchor) in line_placement_anchors(line, style, label_length)
1227                .into_iter()
1228                .enumerate()
1229            {
1230                let candidates = symbol_candidates_at_anchor(
1231                    layer_id,
1232                    query_layer_id,
1233                    query_source_id,
1234                    provenance,
1235                    feature_index,
1236                    next,
1237                    feature,
1238                    anchor.coord,
1239                    style,
1240                    anchor.rotation_rad,
1241                    Some(slot_index),
1242                );
1243                next += candidates.len();
1244                out.extend(candidates);
1245            }
1246            next
1247        }
1248        Geometry::MultiLineString(lines) => {
1249            let mut next = point_index;
1250            for line in &lines.lines {
1251                next = collect_line_symbol_candidates_from_geometry(
1252                    layer_id,
1253                    query_layer_id,
1254                    query_source_id,
1255                    provenance,
1256                    feature_index,
1257                    next,
1258                    feature,
1259                    &Geometry::LineString(line.clone()),
1260                    style,
1261                    out,
1262                );
1263            }
1264            next
1265        }
1266        Geometry::GeometryCollection(geometries) => {
1267            let mut next = point_index;
1268            for geometry in geometries {
1269                next = collect_line_symbol_candidates_from_geometry(
1270                    layer_id,
1271                    query_layer_id,
1272                    query_source_id,
1273                    provenance,
1274                    feature_index,
1275                    next,
1276                    feature,
1277                    geometry,
1278                    style,
1279                    out,
1280                );
1281            }
1282            next
1283        }
1284        _ => point_index,
1285    }
1286}
1287
1288#[allow(clippy::too_many_arguments)]
1289fn symbol_candidates_for_point(
1290    layer_id: LayerId,
1291    query_layer_id: Option<&str>,
1292    query_source_id: Option<&str>,
1293    provenance: Option<&FeatureProvenance>,
1294    feature_index: usize,
1295    point_index: usize,
1296    feature: &Feature,
1297    anchor: GeoCoord,
1298    style: &VectorStyle,
1299) -> Vec<SymbolCandidate> {
1300    symbol_candidates_at_anchor(
1301        layer_id,
1302        query_layer_id,
1303        query_source_id,
1304        provenance,
1305        feature_index,
1306        point_index,
1307        feature,
1308        anchor,
1309        style,
1310        0.0,
1311        None,
1312    )
1313}
1314
1315#[allow(clippy::too_many_arguments)]
1316fn symbol_candidates_at_anchor(
1317    layer_id: LayerId,
1318    query_layer_id: Option<&str>,
1319    query_source_id: Option<&str>,
1320    provenance: Option<&FeatureProvenance>,
1321    feature_index: usize,
1322    point_index: usize,
1323    feature: &Feature,
1324    anchor: GeoCoord,
1325    style: &VectorStyle,
1326    rotation_rad: f32,
1327    line_slot_index: Option<usize>,
1328) -> Vec<SymbolCandidate> {
1329    let feature_id = feature_id_for_feature(feature, feature_index);
1330    let text = style
1331        .symbol_text_field
1332        .as_deref()
1333        .and_then(|field| feature.property(field))
1334        .and_then(symbol_text_from_property)
1335        .map(|text| transform_symbol_text(text, style.symbol_text_transform));
1336    let icon_image = style.symbol_icon_image.clone();
1337
1338    if text.is_none() && icon_image.is_none() {
1339        return Vec::new();
1340    }
1341
1342    /// Apply style-driven text transformation before measurement and placement.
1343    ///
1344    /// Mapbox and MapLibre transform text before shaping, which means the rendered
1345    /// label content and its measured footprint both use the transformed string.
1346    /// The engine mirrors that ordering here so uppercase and lowercase styles
1347    /// affect candidate text, wrapping, and collision estimates consistently.
1348    fn transform_symbol_text(text: String, transform: SymbolTextTransform) -> String {
1349        match transform {
1350            SymbolTextTransform::None => text,
1351            SymbolTextTransform::Uppercase => text.to_uppercase(),
1352            SymbolTextTransform::Lowercase => text.to_lowercase(),
1353        }
1354    }
1355
1356    let cross_tile_id = symbol_cross_tile_id(
1357        query_layer_id,
1358        query_source_id,
1359        &feature_id,
1360        text.as_deref(),
1361        icon_image.as_deref(),
1362        anchor,
1363        style.symbol_placement,
1364        line_slot_index,
1365        style,
1366    );
1367
1368    let base_id = format!("{}:{feature_index}:{point_index}", layer_id.as_u64());
1369    let text_present = text.is_some();
1370    let icon_present = icon_image.is_some();
1371    let text_optional = style.symbol_text_optional;
1372    let icon_optional = style.symbol_icon_optional;
1373
1374    let mut variants = Vec::new();
1375    let base_candidate = make_symbol_candidate(
1376        &base_id,
1377        &base_id,
1378        query_layer_id,
1379        query_source_id,
1380        provenance,
1381        &feature_id,
1382        feature_index,
1383        style.symbol_placement,
1384        anchor,
1385        text.clone(),
1386        icon_image.clone(),
1387        style,
1388        cross_tile_id.clone(),
1389        rotation_rad,
1390    );
1391    variants.push(base_candidate);
1392
1393    // The engine still renders text and icon together by default, but optional
1394    // flags need a way to keep one part visible when the full pair does not fit.
1395    // Emit fallback candidates with the same cross-tile identity so placement
1396    // can try the full pair first and then fall back to the optional subset.
1397    if text_present && icon_present {
1398        if text_optional {
1399            variants.push(make_symbol_candidate(
1400                &format!("{base_id}:icon-only"),
1401                &base_id,
1402                query_layer_id,
1403                query_source_id,
1404                provenance,
1405                &feature_id,
1406                feature_index,
1407                style.symbol_placement,
1408                anchor,
1409                None,
1410                icon_image.clone(),
1411                style,
1412                cross_tile_id.clone(),
1413                rotation_rad,
1414            ));
1415        }
1416        if icon_optional {
1417            variants.push(make_symbol_candidate(
1418                &format!("{base_id}:text-only"),
1419                &base_id,
1420                query_layer_id,
1421                query_source_id,
1422                provenance,
1423                &feature_id,
1424                feature_index,
1425                style.symbol_placement,
1426                anchor,
1427                text,
1428                None,
1429                style,
1430                cross_tile_id,
1431                rotation_rad,
1432            ));
1433        }
1434    }
1435
1436    variants
1437}
1438
1439#[allow(clippy::too_many_arguments)]
1440fn make_symbol_candidate(
1441    id: &str,
1442    placement_group_id: &str,
1443    query_layer_id: Option<&str>,
1444    query_source_id: Option<&str>,
1445    provenance: Option<&FeatureProvenance>,
1446    feature_id: &str,
1447    feature_index: usize,
1448    placement: SymbolPlacement,
1449    anchor: GeoCoord,
1450    text: Option<String>,
1451    icon_image: Option<String>,
1452    style: &VectorStyle,
1453    cross_tile_id: String,
1454    rotation_rad: f32,
1455) -> SymbolCandidate {
1456    let has_text = text.is_some();
1457    let has_icon = icon_image.is_some();
1458
1459    SymbolCandidate {
1460        id: id.to_owned(),
1461        layer_id: query_layer_id.map(ToOwned::to_owned),
1462        source_id: query_source_id.map(ToOwned::to_owned),
1463        source_layer: provenance.and_then(|p| p.source_layer.clone()),
1464        source_tile: provenance.and_then(|p| p.source_tile),
1465        feature_id: feature_id.to_owned(),
1466        feature_index,
1467        placement_group_id: placement_group_id.to_owned(),
1468        placement,
1469        anchor,
1470        text,
1471        icon_image,
1472        font_stack: style.symbol_font_stack.clone(),
1473        cross_tile_id,
1474        rotation_rad,
1475        size_px: style.symbol_size,
1476        padding_px: style.symbol_padding,
1477        allow_overlap: effective_symbol_overlap(style, has_text, has_icon),
1478        ignore_placement: effective_symbol_ignore_placement(style, has_text, has_icon),
1479        sort_key: style.symbol_sort_key,
1480        radial_offset: style.symbol_text_radial_offset,
1481        variable_anchor_offsets: style.symbol_variable_anchor_offsets.clone(),
1482        text_max_width: style.symbol_text_max_width,
1483        text_line_height: style.symbol_text_line_height,
1484        text_letter_spacing: style.symbol_text_letter_spacing,
1485        icon_text_fit: style.symbol_icon_text_fit,
1486        icon_text_fit_padding: style.symbol_icon_text_fit_padding,
1487        anchors: if style.symbol_variable_anchor_offsets.is_some() {
1488            style
1489                .symbol_variable_anchor_offsets
1490                .as_ref()
1491                .map(|offsets| offsets.iter().map(|(anchor, _)| *anchor).collect())
1492                .unwrap_or_default()
1493        } else {
1494            style.symbol_anchors.clone()
1495        },
1496        writing_mode: style.symbol_writing_mode,
1497        offset_px: style.symbol_offset,
1498        fill_color: style.fill_color,
1499        halo_color: style.symbol_halo_color,
1500    }
1501}
1502
1503/// Resolve the simplified engine ignore-placement decision for a combined symbol.
1504///
1505/// Mapbox and MapLibre apply ignore-placement separately to text and icon
1506/// collision geometry. The engine still places a combined symbol candidate, so
1507/// it uses the same presence-based reduction as overlap handling: text-only and
1508/// icon-only fallbacks use their own flags, while a paired text+icon symbol
1509/// only skips blocking later symbols when both parts are configured to ignore
1510/// placement.
1511fn effective_symbol_ignore_placement(style: &VectorStyle, has_text: bool, has_icon: bool) -> bool {
1512    match (has_text, has_icon) {
1513        (true, true) => style.symbol_text_ignore_placement && style.symbol_icon_ignore_placement,
1514        (true, false) => style.symbol_text_ignore_placement,
1515        (false, true) => style.symbol_icon_ignore_placement,
1516        (false, false) => false,
1517    }
1518}
1519
1520/// Resolve the simplified engine overlap decision for a combined text/icon symbol.
1521///
1522/// Mapbox and MapLibre place text and icon collision data separately. The
1523/// engine still places them as one combined symbol candidate, so it uses the
1524/// narrowest overlap rule that is consistent with the parts that are actually
1525/// present: text-only symbols follow the text flag, icon-only symbols follow
1526/// the icon flag, and paired text+icon symbols may overlap only when both
1527/// parts are configured to allow overlap.
1528fn effective_symbol_overlap(style: &VectorStyle, has_text: bool, has_icon: bool) -> bool {
1529    match (has_text, has_icon) {
1530        (true, true) => style.symbol_text_allow_overlap && style.symbol_icon_allow_overlap,
1531        (true, false) => style.symbol_text_allow_overlap,
1532        (false, true) => style.symbol_icon_allow_overlap,
1533        (false, false) => style.symbol_allow_overlap,
1534    }
1535}
1536
1537/// Build a placement identity that survives the kinds of updates expected for
1538/// the current symbol mode.
1539///
1540/// Point labels still use coordinate-based identities because their visual
1541/// anchor is the point itself. Line labels are different: repeated anchors can
1542/// move slightly when geometry or clipping changes, and neighboring tiles may
1543/// expose overlapping slices of the same underlying feature. To reduce visible
1544/// churn, line labels use a quantized world-anchor bucket keyed by feature id
1545/// and source/layer identity instead of a raw local slot index. That keeps
1546/// nearby anchors stable across small shifts while still distinguishing labels
1547/// that land in meaningfully different positions along the line.
1548#[allow(clippy::too_many_arguments)]
1549fn symbol_cross_tile_id(
1550    query_layer_id: Option<&str>,
1551    query_source_id: Option<&str>,
1552    feature_id: &str,
1553    text: Option<&str>,
1554    icon_image: Option<&str>,
1555    anchor: GeoCoord,
1556    placement: SymbolPlacement,
1557    line_slot_index: Option<usize>,
1558    style: &VectorStyle,
1559) -> String {
1560    match placement {
1561        SymbolPlacement::Point => format!(
1562            "{}|{}|{:.6}|{:.6}",
1563            text.unwrap_or(""),
1564            icon_image.unwrap_or(""),
1565            anchor.lat,
1566            anchor.lon,
1567        ),
1568        SymbolPlacement::Line => {
1569            let slot = line_slot_index.unwrap_or(0);
1570            let world = CameraProjection::WebMercator.project(&anchor);
1571            // Use a coarse positional bucket alongside the slot index so that
1572            // genuinely different line windows produce different IDs while
1573            // small geometry shifts stay in the same bucket.  The bucket is
1574            // twice the spacing to absorb interpolation jitter from small
1575            // anchor shifts (~22m) while still distinguishing window shifts
1576            // on the order of a full spacing interval (~1113m).
1577            let coarse_bucket = ((style.symbol_spacing.max(style.symbol_size).max(1.0) as f64)
1578                * METERS_PER_PIXEL_APPROX
1579                * 2.0)
1580                .max(1.0);
1581            let bucket_x = (world.position.x / coarse_bucket).round() as i64;
1582            let bucket_y = (world.position.y / coarse_bucket).round() as i64;
1583            format!(
1584                "line|{}|{}|{}|{}|{}|{}|{}|{}",
1585                query_source_id.unwrap_or(""),
1586                query_layer_id.unwrap_or(""),
1587                feature_id,
1588                slot,
1589                bucket_x,
1590                bucket_y,
1591                text.unwrap_or(""),
1592                icon_image.unwrap_or(""),
1593            )
1594        }
1595    }
1596}
1597
1598/// Choose repeated anchors for a line-placed label.
1599///
1600/// This is still a simplified version of Mapbox and MapLibre line placement:
1601/// we generate stable anchors along the path at approximately the requested
1602/// spacing and filter out anchors on sharp bends using a simplified
1603/// readability check. The retained anchor list is enough to move from one
1604/// midpoint label toward repeated line labels with stable ordering while full
1605/// path-shaped glyph layout remains future work.
1606fn line_placement_anchors(
1607    line: &crate::geometry::LineString,
1608    style: &VectorStyle,
1609    label_length: f64,
1610) -> Vec<LinePlacementAnchor> {
1611    if line.coords.len() < 2 {
1612        return Vec::new();
1613    }
1614
1615    let projected: Vec<_> = line
1616        .coords
1617        .iter()
1618        .map(|coord| CameraProjection::WebMercator.project(coord))
1619        .collect();
1620    let total_length: f64 = projected
1621        .windows(2)
1622        .map(|segment| {
1623            let a = segment[0].position;
1624            let b = segment[1].position;
1625            let dx = b.x - a.x;
1626            let dy = b.y - a.y;
1627            let dz = b.z - a.z;
1628            (dx * dx + dy * dy + dz * dz).sqrt()
1629        })
1630        .sum();
1631    if total_length <= f64::EPSILON {
1632        return line
1633            .coords
1634            .first()
1635            .copied()
1636            .map(|coord| LinePlacementAnchor {
1637                coord,
1638                rotation_rad: 0.0,
1639                distance: total_length * 0.5,
1640            })
1641            .into_iter()
1642            .collect();
1643    }
1644
1645    // Full style-spec parity would derive spacing in tile units after symbol
1646    // shaping. Until that layout stage exists in the engine, use the same
1647    // approximate pixel-to-world conversion already used elsewhere in the
1648    // vector layer so repeated line anchors stay stable and configurable.
1649    let spacing =
1650        (style.symbol_spacing.max(style.symbol_size).max(1.0) as f64) * METERS_PER_PIXEL_APPROX;
1651    if spacing <= f64::EPSILON || total_length <= spacing {
1652        return interpolate_line_anchor_at_distance(
1653            line,
1654            &projected,
1655            total_length * 0.5,
1656            style.symbol_keep_upright,
1657        )
1658        .filter(|anchor| {
1659            line_anchor_passes_max_angle(&projected, anchor.distance, label_length, style)
1660        })
1661        .into_iter()
1662        .collect();
1663    }
1664
1665    let mut anchors = Vec::new();
1666    let mut target = spacing * 0.5;
1667    while target < total_length {
1668        if let Some(anchor) =
1669            interpolate_line_anchor_at_distance(line, &projected, target, style.symbol_keep_upright)
1670        {
1671            if line_anchor_passes_max_angle(&projected, anchor.distance, label_length, style) {
1672                anchors.push(anchor);
1673            }
1674        }
1675        target += spacing;
1676    }
1677
1678    if anchors.is_empty() {
1679        interpolate_line_anchor_at_distance(
1680            line,
1681            &projected,
1682            total_length * 0.5,
1683            style.symbol_keep_upright,
1684        )
1685        .filter(|anchor| {
1686            line_anchor_passes_max_angle(&projected, anchor.distance, label_length, style)
1687        })
1688        .into_iter()
1689        .collect()
1690    } else {
1691        anchors
1692    }
1693}
1694
1695/// Estimate the on-line footprint of a label in world meters.
1696///
1697/// The engine does not yet run full text shaping during candidate generation,
1698/// so this uses the same lightweight text/icon size heuristic as the placeholder
1699/// symbol mesh. That keeps line-anchor spacing and angle filtering consistent
1700/// with the current symbol collision approximation.
1701fn estimated_line_label_length_meters(feature: &Feature, style: &VectorStyle) -> f64 {
1702    let text = style
1703        .symbol_text_field
1704        .as_deref()
1705        .and_then(|field| feature.property(field))
1706        .and_then(symbol_text_from_property);
1707    let icon = style.symbol_icon_image.as_deref();
1708    let size_px = style.symbol_size.max(1.0) as f64;
1709    let text_width_px = text
1710        .as_deref()
1711        .map(|value| value.chars().count() as f64 * size_px * 0.6)
1712        .unwrap_or(0.0);
1713    let icon_width_px = if icon.is_some() { size_px * 1.2 } else { 0.0 };
1714    (text_width_px.max(size_px) + icon_width_px + style.symbol_padding.max(0.0) as f64 * 2.0)
1715        * METERS_PER_PIXEL_APPROX
1716}
1717
1718/// Reject line anchors whose surrounding path bends too sharply.
1719///
1720/// Mapbox and MapLibre use a sliding angle window tied to shaped label length.
1721/// The engine keeps this simpler for now by summing turn angles across the
1722/// retained label span around each anchor. That still removes the least
1723/// readable anchors while keeping the implementation small and predictable.
1724fn line_anchor_passes_max_angle(
1725    projected: &[WorldCoord],
1726    anchor_distance: f64,
1727    label_length: f64,
1728    style: &VectorStyle,
1729) -> bool {
1730    if projected.len() < 3 {
1731        return true;
1732    }
1733    let half_label = label_length * 0.5;
1734    if anchor_distance - half_label < 0.0
1735        || anchor_distance + half_label > line_total_length(projected)
1736    {
1737        return false;
1738    }
1739
1740    let max_angle = style.symbol_max_angle.max(0.0) as f64 * std::f64::consts::PI / 180.0;
1741    if max_angle >= std::f64::consts::PI {
1742        return true;
1743    }
1744
1745    let start = anchor_distance - half_label;
1746    let end = anchor_distance + half_label;
1747    let mut distance = 0.0;
1748    let mut accumulated_turn = 0.0;
1749
1750    for index in 1..projected.len() - 1 {
1751        let prev = projected[index - 1].position;
1752        let current = projected[index].position;
1753        let next = projected[index + 1].position;
1754        let segment_dx = current.x - prev.x;
1755        let segment_dy = current.y - prev.y;
1756        let segment_dz = current.z - prev.z;
1757        distance +=
1758            (segment_dx * segment_dx + segment_dy * segment_dy + segment_dz * segment_dz).sqrt();
1759        if distance < start || distance > end {
1760            continue;
1761        }
1762
1763        let prev_angle = (current.y - prev.y).atan2(current.x - prev.x);
1764        let next_angle = (next.y - current.y).atan2(next.x - current.x);
1765        accumulated_turn += normalize_angle_delta(next_angle - prev_angle).abs();
1766        if accumulated_turn > max_angle {
1767            return false;
1768        }
1769    }
1770
1771    true
1772}
1773
1774fn normalize_angle_delta(angle: f64) -> f64 {
1775    ((angle + std::f64::consts::PI * 3.0) % (std::f64::consts::PI * 2.0)) - std::f64::consts::PI
1776}
1777
1778fn line_total_length(projected: &[WorldCoord]) -> f64 {
1779    projected
1780        .windows(2)
1781        .map(|segment| {
1782            let a = segment[0].position;
1783            let b = segment[1].position;
1784            let dx = b.x - a.x;
1785            let dy = b.y - a.y;
1786            let dz = b.z - a.z;
1787            (dx * dx + dy * dy + dz * dz).sqrt()
1788        })
1789        .sum()
1790}
1791
1792fn interpolate_line_anchor_at_distance(
1793    line: &crate::geometry::LineString,
1794    projected: &[WorldCoord],
1795    target: f64,
1796    keep_upright: bool,
1797) -> Option<LinePlacementAnchor> {
1798    let mut traversed = 0.0;
1799    for (coords, segment) in line.coords.windows(2).zip(projected.windows(2)) {
1800        let a = segment[0].position;
1801        let b = segment[1].position;
1802        let dx = b.x - a.x;
1803        let dy = b.y - a.y;
1804        let dz = b.z - a.z;
1805        let segment_length = (dx * dx + dy * dy + dz * dz).sqrt();
1806        if segment_length <= f64::EPSILON {
1807            continue;
1808        }
1809        if traversed + segment_length >= target {
1810            let t = ((target - traversed) / segment_length).clamp(0.0, 1.0);
1811            let start = coords[0];
1812            let end = coords[1];
1813            let coord = GeoCoord::new(
1814                start.lat + (end.lat - start.lat) * t,
1815                start.lon + (end.lon - start.lon) * t,
1816                start.alt + (end.alt - start.alt) * t,
1817            );
1818            // Use the projected segment direction so placeholder symbol quads
1819            // rotate with the rendered path in the active working projection.
1820            let rotation_rad =
1821                normalize_line_label_rotation((b.y - a.y).atan2(b.x - a.x) as f32, keep_upright);
1822            return Some(LinePlacementAnchor {
1823                coord,
1824                rotation_rad,
1825                distance: target,
1826            });
1827        }
1828        traversed += segment_length;
1829    }
1830
1831    line.coords
1832        .last()
1833        .copied()
1834        .map(|coord| LinePlacementAnchor {
1835            coord,
1836            rotation_rad: 0.0,
1837            distance: target,
1838        })
1839}
1840
1841/// Normalize a line-label rotation into an upright range.
1842///
1843/// With `text-keep-upright`, Mapbox and MapLibre avoid rendering line labels
1844/// upside down by flipping them 180 degrees when the local line direction
1845/// would otherwise exceed the readable range. The engine mirrors that intent
1846/// with a small normalization step over the already-computed line angle.
1847fn normalize_line_label_rotation(rotation_rad: f32, keep_upright: bool) -> f32 {
1848    if !keep_upright {
1849        return rotation_rad;
1850    }
1851
1852    let mut normalized = rotation_rad;
1853    while normalized > std::f32::consts::PI {
1854        normalized -= std::f32::consts::TAU;
1855    }
1856    while normalized < -std::f32::consts::PI {
1857        normalized += std::f32::consts::TAU;
1858    }
1859    if normalized > std::f32::consts::FRAC_PI_2 {
1860        normalized -= std::f32::consts::PI;
1861    } else if normalized < -std::f32::consts::FRAC_PI_2 {
1862        normalized += std::f32::consts::PI;
1863    }
1864    normalized
1865}
1866
1867fn symbol_text_from_property(value: &crate::geometry::PropertyValue) -> Option<String> {
1868    match value {
1869        crate::geometry::PropertyValue::Null => None,
1870        crate::geometry::PropertyValue::Bool(value) => Some(value.to_string()),
1871        crate::geometry::PropertyValue::Number(value) => Some(value.to_string()),
1872        crate::geometry::PropertyValue::String(value) => Some(value.clone()),
1873    }
1874}
1875
1876// ---------------------------------------------------------------------------
1877// Tessellation (module-private)
1878// ---------------------------------------------------------------------------
1879
1880/// Recursively tessellate a geometry into `mesh`.
1881///
1882/// Multi\* and GeometryCollection variants delegate to their children
1883/// **by reference** to avoid cloning geometry data.
1884fn tessellate_geometry(
1885    geometry: &Geometry,
1886    style: &VectorStyle,
1887    projection: CameraProjection,
1888    half_width: f64,
1889    mesh: &mut VectorMeshData,
1890) {
1891    match style.render_mode {
1892        VectorRenderMode::Generic => {
1893            tessellate_generic_geometry(geometry, style, projection, half_width, mesh)
1894        }
1895        VectorRenderMode::Fill => tessellate_fill_geometry(geometry, style, projection, mesh),
1896        VectorRenderMode::Line => {
1897            tessellate_line_geometry(geometry, style, projection, half_width, mesh)
1898        }
1899        VectorRenderMode::Circle => tessellate_circle_geometry(geometry, style, projection, mesh),
1900        VectorRenderMode::Heatmap => tessellate_heatmap_geometry(geometry, style, projection, mesh),
1901        VectorRenderMode::FillExtrusion => {
1902            tessellate_fill_extrusion_geometry(geometry, style, projection, mesh)
1903        }
1904        VectorRenderMode::Symbol => tessellate_symbol_geometry(geometry, style, projection, mesh),
1905    }
1906}
1907
1908fn tessellate_generic_geometry(
1909    geometry: &Geometry,
1910    style: &VectorStyle,
1911    projection: CameraProjection,
1912    half_width: f64,
1913    mesh: &mut VectorMeshData,
1914) {
1915    match geometry {
1916        Geometry::Point(p) => append_square_marker(
1917            mesh,
1918            &p.coord,
1919            projection,
1920            half_width * METERS_PER_DEGREE,
1921            style.fill_color,
1922        ),
1923        Geometry::LineString(ls) => append_stroked_line(
1924            mesh,
1925            &ls.coords,
1926            projection,
1927            half_width,
1928            style.stroke_color,
1929            style,
1930        ),
1931        Geometry::Polygon(poly) => {
1932            append_polygon_fill(mesh, &poly.exterior, projection, style.fill_color, None)
1933        }
1934        Geometry::MultiPoint(mp) => {
1935            for p in &mp.points {
1936                tessellate_generic_geometry(
1937                    &Geometry::Point(p.clone()),
1938                    style,
1939                    projection,
1940                    half_width,
1941                    mesh,
1942                );
1943            }
1944        }
1945        Geometry::MultiLineString(mls) => {
1946            for ls in &mls.lines {
1947                tessellate_generic_geometry(
1948                    &Geometry::LineString(ls.clone()),
1949                    style,
1950                    projection,
1951                    half_width,
1952                    mesh,
1953                );
1954            }
1955        }
1956        Geometry::MultiPolygon(mpoly) => {
1957            for poly in &mpoly.polygons {
1958                tessellate_generic_geometry(
1959                    &Geometry::Polygon(poly.clone()),
1960                    style,
1961                    projection,
1962                    half_width,
1963                    mesh,
1964                );
1965            }
1966        }
1967        Geometry::GeometryCollection(geoms) => {
1968            for g in geoms {
1969                tessellate_generic_geometry(g, style, projection, half_width, mesh);
1970            }
1971        }
1972    }
1973}
1974
1975fn tessellate_fill_geometry(
1976    geometry: &Geometry,
1977    style: &VectorStyle,
1978    projection: CameraProjection,
1979    mesh: &mut VectorMeshData,
1980) {
1981    // Carry the pattern image to the mesh so the renderer can access it.
1982    if style.fill_pattern.is_some() && mesh.fill_pattern.is_none() {
1983        mesh.fill_pattern = style.fill_pattern.clone();
1984    }
1985
1986    match geometry {
1987        Geometry::Polygon(poly) => {
1988            append_polygon_fill(
1989                mesh,
1990                &poly.exterior,
1991                projection,
1992                style.fill_color,
1993                style.fill_pattern.as_deref(),
1994            );
1995            if style.stroke_width > 0.0 {
1996                append_stroked_line(
1997                    mesh,
1998                    &poly.exterior,
1999                    projection,
2000                    style.stroke_width as f64 * DEGREES_PER_PIXEL_APPROX,
2001                    style.stroke_color,
2002                    style,
2003                );
2004                for hole in &poly.interiors {
2005                    append_stroked_line(
2006                        mesh,
2007                        hole,
2008                        projection,
2009                        style.stroke_width as f64 * DEGREES_PER_PIXEL_APPROX,
2010                        style.stroke_color,
2011                        style,
2012                    );
2013                }
2014            }
2015        }
2016        Geometry::MultiPolygon(mpoly) => {
2017            for poly in &mpoly.polygons {
2018                tessellate_fill_geometry(&Geometry::Polygon(poly.clone()), style, projection, mesh);
2019            }
2020        }
2021        Geometry::GeometryCollection(geoms) => {
2022            for g in geoms {
2023                tessellate_fill_geometry(g, style, projection, mesh);
2024            }
2025        }
2026        _ => {}
2027    }
2028}
2029
2030fn tessellate_line_geometry(
2031    geometry: &Geometry,
2032    style: &VectorStyle,
2033    projection: CameraProjection,
2034    half_width: f64,
2035    mesh: &mut VectorMeshData,
2036) {
2037    // Carry the pattern image to the mesh so the renderer can access it.
2038    if style.line_pattern.is_some() && mesh.line_pattern.is_none() {
2039        mesh.line_pattern = style.line_pattern.clone();
2040    }
2041
2042    match geometry {
2043        Geometry::LineString(ls) => append_stroked_line(
2044            mesh,
2045            &ls.coords,
2046            projection,
2047            half_width,
2048            style.stroke_color,
2049            style,
2050        ),
2051        Geometry::Polygon(poly) => {
2052            append_stroked_line(
2053                mesh,
2054                &poly.exterior,
2055                projection,
2056                half_width,
2057                style.stroke_color,
2058                style,
2059            );
2060            for hole in &poly.interiors {
2061                append_stroked_line(
2062                    mesh,
2063                    hole,
2064                    projection,
2065                    half_width,
2066                    style.stroke_color,
2067                    style,
2068                );
2069            }
2070        }
2071        Geometry::MultiLineString(mls) => {
2072            for ls in &mls.lines {
2073                tessellate_line_geometry(
2074                    &Geometry::LineString(ls.clone()),
2075                    style,
2076                    projection,
2077                    half_width,
2078                    mesh,
2079                );
2080            }
2081        }
2082        Geometry::MultiPolygon(mpoly) => {
2083            for poly in &mpoly.polygons {
2084                tessellate_line_geometry(
2085                    &Geometry::Polygon(poly.clone()),
2086                    style,
2087                    projection,
2088                    half_width,
2089                    mesh,
2090                );
2091            }
2092        }
2093        Geometry::GeometryCollection(geoms) => {
2094            for g in geoms {
2095                tessellate_line_geometry(g, style, projection, half_width, mesh);
2096            }
2097        }
2098        _ => {}
2099    }
2100}
2101
2102fn tessellate_circle_geometry(
2103    geometry: &Geometry,
2104    style: &VectorStyle,
2105    projection: CameraProjection,
2106    mesh: &mut VectorMeshData,
2107) {
2108    match geometry {
2109        Geometry::Point(p) => {
2110            let radius = style.point_radius as f64 * DEGREES_PER_PIXEL_APPROX * METERS_PER_DEGREE;
2111            let stroke_w =
2112                style.stroke_width.max(0.0) as f64 * DEGREES_PER_PIXEL_APPROX * METERS_PER_DEGREE;
2113            append_circle(
2114                mesh,
2115                &p.coord,
2116                projection,
2117                radius,
2118                style.fill_color,
2119                Some((style.stroke_color, stroke_w)),
2120            );
2121
2122            // Also populate the instanced circle data used by the WGPU
2123            // SDF circle pipeline (screen-aligned quads evaluated by the
2124            // fragment shader).
2125            let w = projection.project(&p.coord);
2126            mesh.circle_instances.push(CircleInstanceData {
2127                center: [w.position.x, w.position.y, w.position.z],
2128                radius: radius as f32,
2129                color: style.fill_color,
2130                stroke_color: style.stroke_color,
2131                stroke_width: stroke_w as f32,
2132                blur: 0.0,
2133            });
2134        }
2135        Geometry::MultiPoint(mp) => {
2136            for p in &mp.points {
2137                tessellate_circle_geometry(&Geometry::Point(p.clone()), style, projection, mesh);
2138            }
2139        }
2140        Geometry::GeometryCollection(geoms) => {
2141            for g in geoms {
2142                tessellate_circle_geometry(g, style, projection, mesh);
2143            }
2144        }
2145        _ => {}
2146    }
2147}
2148
2149fn tessellate_heatmap_geometry(
2150    geometry: &Geometry,
2151    style: &VectorStyle,
2152    projection: CameraProjection,
2153    mesh: &mut VectorMeshData,
2154) {
2155    match geometry {
2156        Geometry::Point(p) => {
2157            let radius =
2158                style.heatmap_radius.max(0.0) as f64 * DEGREES_PER_PIXEL_APPROX * METERS_PER_DEGREE;
2159            append_heat_blob(
2160                mesh,
2161                &p.coord,
2162                projection,
2163                radius,
2164                style.fill_color,
2165                style.heatmap_intensity.max(0.0),
2166            );
2167
2168            // Also populate the instanced heatmap data used by the WGPU
2169            // two-pass heatmap pipeline (accumulation + colormap).
2170            let w = projection.project(&p.coord);
2171            let weight = style.heatmap_intensity.max(0.0) as f64;
2172            mesh.heatmap_points
2173                .push([w.position.x, w.position.y, weight, radius]);
2174        }
2175        Geometry::MultiPoint(mp) => {
2176            for p in &mp.points {
2177                tessellate_heatmap_geometry(&Geometry::Point(p.clone()), style, projection, mesh);
2178            }
2179        }
2180        Geometry::GeometryCollection(geoms) => {
2181            for g in geoms {
2182                tessellate_heatmap_geometry(g, style, projection, mesh);
2183            }
2184        }
2185        _ => {}
2186    }
2187}
2188
2189fn tessellate_fill_extrusion_geometry(
2190    geometry: &Geometry,
2191    style: &VectorStyle,
2192    projection: CameraProjection,
2193    mesh: &mut VectorMeshData,
2194) {
2195    match geometry {
2196        Geometry::Polygon(poly) => append_extruded_polygon(mesh, &poly.exterior, projection, style),
2197        Geometry::MultiPolygon(mpoly) => {
2198            for poly in &mpoly.polygons {
2199                append_extruded_polygon(mesh, &poly.exterior, projection, style);
2200            }
2201        }
2202        Geometry::GeometryCollection(geoms) => {
2203            for g in geoms {
2204                tessellate_fill_extrusion_geometry(g, style, projection, mesh);
2205            }
2206        }
2207        _ => {}
2208    }
2209}
2210
2211fn tessellate_symbol_geometry(
2212    geometry: &Geometry,
2213    style: &VectorStyle,
2214    projection: CameraProjection,
2215    mesh: &mut VectorMeshData,
2216) {
2217    match geometry {
2218        Geometry::Point(p) => {
2219            let size =
2220                style.symbol_size.max(0.0) as f64 * DEGREES_PER_PIXEL_APPROX * METERS_PER_DEGREE;
2221            append_square_marker(
2222                mesh,
2223                &p.coord,
2224                projection,
2225                size * 1.35,
2226                style.symbol_halo_color,
2227            );
2228            append_square_marker(mesh, &p.coord, projection, size, style.fill_color);
2229        }
2230        Geometry::MultiPoint(mp) => {
2231            for p in &mp.points {
2232                tessellate_symbol_geometry(&Geometry::Point(p.clone()), style, projection, mesh);
2233            }
2234        }
2235        Geometry::GeometryCollection(geoms) => {
2236            for g in geoms {
2237                tessellate_symbol_geometry(g, style, projection, mesh);
2238            }
2239        }
2240        _ => {}
2241    }
2242}
2243
2244fn append_polygon_fill(
2245    mesh: &mut VectorMeshData,
2246    coords: &[GeoCoord],
2247    projection: CameraProjection,
2248    color: [f32; 4],
2249    pattern: Option<&PatternImage>,
2250) {
2251    let ring = normalized_ring(coords);
2252    if ring.len() < 3 {
2253        return;
2254    }
2255    let indices = tessellator::triangulate_polygon(&ring);
2256    let base = mesh.positions.len() as u32;
2257    for coord in &ring {
2258        let w = projection.project(coord);
2259        mesh.positions
2260            .push([w.position.x, w.position.y, w.position.z]);
2261        mesh.colors.push(color);
2262
2263        // Generate pattern UVs when a fill-pattern is present.
2264        // One pattern repetition = pattern dimensions in meters.
2265        // World-space position is already in meters, so UV = pos / size.
2266        if let Some(pat) = pattern {
2267            let u = w.position.x as f32 / pat.width.max(1) as f32;
2268            let v = w.position.y as f32 / pat.height.max(1) as f32;
2269            mesh.fill_pattern_uvs.push([u, v]);
2270        }
2271    }
2272    for idx in indices {
2273        mesh.indices.push(base + idx);
2274    }
2275}
2276
2277fn append_stroked_line(
2278    mesh: &mut VectorMeshData,
2279    coords: &[GeoCoord],
2280    projection: CameraProjection,
2281    half_width: f64,
2282    color: [f32; 4],
2283    style: &VectorStyle,
2284) {
2285    let result = tessellator::stroke_line_styled(
2286        coords,
2287        half_width,
2288        style.line_cap,
2289        style.line_join,
2290        style.miter_limit,
2291    );
2292    if result.positions.is_empty() {
2293        return;
2294    }
2295
2296    // Pre-compute normalized distances for gradient evaluation.
2297    let gradient_max_dist = if style.line_gradient.is_some() {
2298        result
2299            .distances
2300            .iter()
2301            .cloned()
2302            .fold(0.0_f64, f64::max)
2303            .max(f64::EPSILON)
2304    } else {
2305        1.0
2306    };
2307
2308    // Track left/right side alternation for line-pattern V coordinate.
2309    // Body vertices (cap_join = 0) are always pushed in left-right pairs
2310    // by the tessellator: left = 0.0, right = 1.0.
2311    // Cap/join fan vertices (cap_join = 1) get V = 0.5.
2312    let has_pattern = style.line_pattern.is_some();
2313    let pat_width = style
2314        .line_pattern
2315        .as_ref()
2316        .map_or(1.0_f32, |p| p.width.max(1) as f32);
2317    let mut body_side = false; // false = left (V=0), true = right (V=1)
2318
2319    let base = mesh.positions.len() as u32;
2320    for (i, pos) in result.positions.iter().enumerate() {
2321        let coord = GeoCoord::from_lat_lon(pos[1], pos[0]);
2322        let w = projection.project(&coord);
2323        mesh.positions
2324            .push([w.position.x, w.position.y, w.position.z]);
2325
2326        // When a gradient is present, override the vertex colour with
2327        // the ramp evaluated at the normalised distance along the line.
2328        let vertex_color = if let Some(ref ramp) = style.line_gradient {
2329            let t = (result.distances[i] / gradient_max_dist) as f32;
2330            ramp.evaluate(t)
2331        } else {
2332            color
2333        };
2334        mesh.colors.push(vertex_color);
2335
2336        mesh.line_normals
2337            .push([result.normals[i][0] as f32, result.normals[i][1] as f32]);
2338        // Convert distance from degrees to approximate meters for the shader.
2339        let dist_meters = (result.distances[i] * METERS_PER_DEGREE) as f32;
2340        mesh.line_distances.push(dist_meters);
2341        mesh.line_cap_joins.push(result.cap_join[i]);
2342
2343        // Generate line-pattern UVs when a pattern is present.
2344        // U = distance_meters / pattern_width (repeats via GPU sampler).
2345        // V = perpendicular position across line width [0 = left, 1 = right].
2346        if has_pattern {
2347            let u = dist_meters / pat_width;
2348            let v = if result.cap_join[i] > 0.5 {
2349                // Cap/join fan vertex — map to centre.
2350                0.5
2351            } else {
2352                // Body vertex — alternate left (0) / right (1).
2353                let v = if body_side { 1.0 } else { 0.0 };
2354                body_side = !body_side;
2355                v
2356            };
2357            mesh.line_pattern_uvs.push([u, v]);
2358        }
2359    }
2360    for idx in &result.indices {
2361        mesh.indices.push(base + idx);
2362    }
2363}
2364
2365fn append_square_marker(
2366    mesh: &mut VectorMeshData,
2367    coord: &GeoCoord,
2368    projection: CameraProjection,
2369    half_size: f64,
2370    color: [f32; 4],
2371) {
2372    let w = projection.project(coord);
2373    let base = mesh.positions.len() as u32;
2374    mesh.positions.push([
2375        w.position.x - half_size,
2376        w.position.y - half_size,
2377        w.position.z,
2378    ]);
2379    mesh.positions.push([
2380        w.position.x + half_size,
2381        w.position.y - half_size,
2382        w.position.z,
2383    ]);
2384    mesh.positions.push([
2385        w.position.x + half_size,
2386        w.position.y + half_size,
2387        w.position.z,
2388    ]);
2389    mesh.positions.push([
2390        w.position.x - half_size,
2391        w.position.y + half_size,
2392        w.position.z,
2393    ]);
2394    for _ in 0..4 {
2395        mesh.colors.push(color);
2396    }
2397    mesh.indices
2398        .extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]);
2399}
2400
2401fn append_circle(
2402    mesh: &mut VectorMeshData,
2403    coord: &GeoCoord,
2404    projection: CameraProjection,
2405    radius: f64,
2406    fill_color: [f32; 4],
2407    outline: Option<([f32; 4], f64)>,
2408) {
2409    append_radial_fan(mesh, coord, projection, radius, fill_color, fill_color);
2410    if let Some((outline_color, outline_width)) = outline {
2411        if outline_width > 0.0 {
2412            let outer = radius + outline_width;
2413            append_ring(mesh, coord, projection, radius, outer, outline_color);
2414        }
2415    }
2416}
2417
2418fn append_heat_blob(
2419    mesh: &mut VectorMeshData,
2420    coord: &GeoCoord,
2421    projection: CameraProjection,
2422    radius: f64,
2423    color: [f32; 4],
2424    intensity: f32,
2425) {
2426    let mut center_color = color;
2427    center_color[3] = (center_color[3] * intensity).clamp(0.0, 1.0);
2428    let mut edge_color = color;
2429    edge_color[3] = 0.0;
2430    append_radial_fan(mesh, coord, projection, radius, center_color, edge_color);
2431}
2432
2433fn append_radial_fan(
2434    mesh: &mut VectorMeshData,
2435    coord: &GeoCoord,
2436    projection: CameraProjection,
2437    radius: f64,
2438    center_color: [f32; 4],
2439    edge_color: [f32; 4],
2440) {
2441    let center = projection.project(coord);
2442    let base = mesh.positions.len() as u32;
2443    mesh.positions
2444        .push([center.position.x, center.position.y, center.position.z]);
2445    mesh.colors.push(center_color);
2446    for i in 0..=DEFAULT_CIRCLE_SEGMENTS {
2447        let t = i as f64 / DEFAULT_CIRCLE_SEGMENTS as f64;
2448        let angle = std::f64::consts::TAU * t;
2449        mesh.positions.push([
2450            center.position.x + radius * angle.cos(),
2451            center.position.y + radius * angle.sin(),
2452            center.position.z,
2453        ]);
2454        mesh.colors.push(edge_color);
2455    }
2456    for i in 1..=DEFAULT_CIRCLE_SEGMENTS as u32 {
2457        mesh.indices
2458            .extend_from_slice(&[base, base + i, base + i + 1]);
2459    }
2460}
2461
2462fn append_ring(
2463    mesh: &mut VectorMeshData,
2464    coord: &GeoCoord,
2465    projection: CameraProjection,
2466    inner_radius: f64,
2467    outer_radius: f64,
2468    color: [f32; 4],
2469) {
2470    if outer_radius <= inner_radius {
2471        return;
2472    }
2473    let center = projection.project(coord);
2474    let base = mesh.positions.len() as u32;
2475    for i in 0..=DEFAULT_CIRCLE_SEGMENTS {
2476        let t = i as f64 / DEFAULT_CIRCLE_SEGMENTS as f64;
2477        let angle = std::f64::consts::TAU * t;
2478        let (sin, cos) = angle.sin_cos();
2479        mesh.positions.push([
2480            center.position.x + inner_radius * cos,
2481            center.position.y + inner_radius * sin,
2482            center.position.z,
2483        ]);
2484        mesh.colors.push(color);
2485        mesh.positions.push([
2486            center.position.x + outer_radius * cos,
2487            center.position.y + outer_radius * sin,
2488            center.position.z,
2489        ]);
2490        mesh.colors.push(color);
2491    }
2492    for i in 0..DEFAULT_CIRCLE_SEGMENTS as u32 {
2493        let a = base + i * 2;
2494        mesh.indices
2495            .extend_from_slice(&[a, a + 1, a + 2, a + 1, a + 3, a + 2]);
2496    }
2497}
2498
2499fn append_extruded_polygon(
2500    mesh: &mut VectorMeshData,
2501    coords: &[GeoCoord],
2502    projection: CameraProjection,
2503    style: &VectorStyle,
2504) {
2505    let ring = normalized_ring(coords);
2506    if ring.len() < 3 {
2507        return;
2508    }
2509
2510    // --- Top surface ---
2511    let top_base = mesh.positions.len() as u32;
2512    for coord in &ring {
2513        let w = projection.project(coord);
2514        mesh.positions.push([
2515            w.position.x,
2516            w.position.y,
2517            coord.alt + style.extrusion_base as f64 + style.extrusion_height as f64,
2518        ]);
2519        mesh.colors.push(style.fill_color);
2520        mesh.normals.push([0.0, 0.0, 1.0]); // top face points up
2521    }
2522    for idx in tessellator::triangulate_polygon(&ring) {
2523        mesh.indices.push(top_base + idx);
2524    }
2525
2526    // --- Side faces ---
2527    let side_color = [
2528        style.fill_color[0] * 0.75,
2529        style.fill_color[1] * 0.75,
2530        style.fill_color[2] * 0.75,
2531        style.fill_color[3],
2532    ];
2533
2534    for i in 0..ring.len() {
2535        let a = &ring[i];
2536        let b = &ring[(i + 1) % ring.len()];
2537        let wa = projection.project(a);
2538        let wb = projection.project(b);
2539        let base_z_a = a.alt + style.extrusion_base as f64;
2540        let base_z_b = b.alt + style.extrusion_base as f64;
2541        let top_z_a = base_z_a + style.extrusion_height as f64;
2542        let top_z_b = base_z_b + style.extrusion_height as f64;
2543
2544        // Outward-facing normal for this wall quad.
2545        // Edge direction: a→b in the XY plane.  Normal = perpendicular in XY, no Z.
2546        let dx = (wb.position.x - wa.position.x) as f32;
2547        let dy = (wb.position.y - wa.position.y) as f32;
2548        let len = (dx * dx + dy * dy).sqrt().max(1e-12);
2549        let normal = [dy / len, -dx / len, 0.0];
2550
2551        let base = mesh.positions.len() as u32;
2552        mesh.positions
2553            .push([wa.position.x, wa.position.y, base_z_a]);
2554        mesh.positions
2555            .push([wb.position.x, wb.position.y, base_z_b]);
2556        mesh.positions.push([wb.position.x, wb.position.y, top_z_b]);
2557        mesh.positions.push([wa.position.x, wa.position.y, top_z_a]);
2558        for _ in 0..4 {
2559            mesh.colors.push(side_color);
2560            mesh.normals.push(normal);
2561        }
2562        mesh.indices
2563            .extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]);
2564    }
2565}
2566
2567fn normalized_ring(coords: &[GeoCoord]) -> Vec<GeoCoord> {
2568    if coords.len() > 1 {
2569        let first = coords.first().expect("ring first");
2570        let last = coords.last().expect("ring last");
2571        if (first.lat - last.lat).abs() < 1e-12
2572            && (first.lon - last.lon).abs() < 1e-12
2573            && (first.alt - last.alt).abs() < 1e-6
2574        {
2575            return coords[..coords.len() - 1].to_vec();
2576        }
2577    }
2578    coords.to_vec()
2579}
2580
2581// ---------------------------------------------------------------------------
2582// Terrain draping (module-private)
2583// ---------------------------------------------------------------------------
2584
2585/// Recursively drape all coordinates in a geometry onto terrain.
2586fn drape_geometry(geometry: &mut Geometry, terrain: &TerrainManager) {
2587    match geometry {
2588        Geometry::Point(p) => {
2589            if let Some(elev) = terrain.elevation_at(&p.coord) {
2590                p.coord.alt = elev;
2591            }
2592        }
2593        Geometry::LineString(ls) => {
2594            drape_coords(&mut ls.coords, terrain);
2595        }
2596        Geometry::Polygon(poly) => {
2597            drape_coords(&mut poly.exterior, terrain);
2598            for hole in &mut poly.interiors {
2599                drape_coords(hole, terrain);
2600            }
2601        }
2602        Geometry::MultiPoint(mp) => {
2603            for p in &mut mp.points {
2604                if let Some(elev) = terrain.elevation_at(&p.coord) {
2605                    p.coord.alt = elev;
2606                }
2607            }
2608        }
2609        Geometry::MultiLineString(mls) => {
2610            for ls in &mut mls.lines {
2611                drape_coords(&mut ls.coords, terrain);
2612            }
2613        }
2614        Geometry::MultiPolygon(mpoly) => {
2615            for poly in &mut mpoly.polygons {
2616                drape_coords(&mut poly.exterior, terrain);
2617                for hole in &mut poly.interiors {
2618                    drape_coords(hole, terrain);
2619                }
2620            }
2621        }
2622        Geometry::GeometryCollection(geoms) => {
2623            for g in geoms {
2624                drape_geometry(g, terrain);
2625            }
2626        }
2627    }
2628}
2629
2630/// Drape a coordinate slice onto terrain.
2631fn drape_coords(coords: &mut [GeoCoord], terrain: &TerrainManager) {
2632    for coord in coords.iter_mut() {
2633        if let Some(elev) = terrain.elevation_at(coord) {
2634            coord.alt = elev;
2635        }
2636    }
2637}
2638
2639// ---------------------------------------------------------------------------
2640// Layer trait implementation
2641// ---------------------------------------------------------------------------
2642
2643impl Layer for VectorLayer {
2644    fn id(&self) -> LayerId {
2645        self.id
2646    }
2647
2648    fn kind(&self) -> crate::layer::LayerKind {
2649        crate::layer::LayerKind::Vector
2650    }
2651
2652    fn name(&self) -> &str {
2653        &self.name
2654    }
2655
2656    fn visible(&self) -> bool {
2657        self.visible
2658    }
2659
2660    fn set_visible(&mut self, visible: bool) {
2661        self.visible = visible;
2662    }
2663
2664    fn opacity(&self) -> f32 {
2665        self.opacity
2666    }
2667
2668    fn set_opacity(&mut self, opacity: f32) {
2669        self.opacity = opacity.clamp(0.0, 1.0);
2670    }
2671
2672    fn as_any(&self) -> &dyn Any {
2673        self
2674    }
2675
2676    fn as_any_mut(&mut self) -> &mut dyn Any {
2677        self
2678    }
2679}
2680
2681// ---------------------------------------------------------------------------
2682// Tests
2683// ---------------------------------------------------------------------------
2684
2685#[cfg(test)]
2686mod tests {
2687    use super::*;
2688    use crate::camera_projection::CameraProjection;
2689    use crate::geometry::{
2690        Feature, LineString, MultiLineString, MultiPoint, MultiPolygon, Point, Polygon,
2691    };
2692    use crate::layer::Layer;
2693    use crate::terrain::{FlatElevationSource, TerrainConfig, TerrainManager};
2694    use rustial_math::{WebMercator, WorldBounds, WorldCoord};
2695    use std::collections::HashMap;
2696
2697    // =====================================================================
2698    // Helpers
2699    // =====================================================================
2700
2701    fn make_layer(geometry: Geometry) -> VectorLayer {
2702        let features = FeatureCollection {
2703            features: vec![Feature {
2704                geometry,
2705                properties: HashMap::new(),
2706            }],
2707        };
2708        VectorLayer::new("test", features, VectorStyle::default())
2709    }
2710
2711    fn square_polygon() -> Polygon {
2712        Polygon {
2713            exterior: vec![
2714                GeoCoord::from_lat_lon(0.0, 0.0),
2715                GeoCoord::from_lat_lon(0.0, 1.0),
2716                GeoCoord::from_lat_lon(1.0, 1.0),
2717                GeoCoord::from_lat_lon(1.0, 0.0),
2718            ],
2719            interiors: vec![],
2720        }
2721    }
2722
2723    fn two_point_line() -> LineString {
2724        LineString {
2725            coords: vec![
2726                GeoCoord::from_lat_lon(0.0, 0.0),
2727                GeoCoord::from_lat_lon(0.0, 1.0),
2728            ],
2729        }
2730    }
2731
2732    fn origin_point() -> Point {
2733        Point {
2734            coord: GeoCoord::from_lat_lon(0.0, 0.0),
2735        }
2736    }
2737
2738    fn flat_terrain_manager() -> TerrainManager {
2739        let config = TerrainConfig {
2740            enabled: true,
2741            mesh_resolution: 4,
2742            source: Box::new(FlatElevationSource::new(4, 4)),
2743            ..TerrainConfig::default()
2744        };
2745        let mut mgr = TerrainManager::new(config, 100);
2746        let extent = WebMercator::max_extent();
2747        let bounds = WorldBounds::new(
2748            WorldCoord::new(-extent, -extent, 0.0),
2749            WorldCoord::new(extent, extent, 0.0),
2750        );
2751        // Two updates: first requests, second loads.
2752        mgr.update(
2753            &bounds,
2754            0,
2755            (0.0, 0.0),
2756            CameraProjection::WebMercator,
2757            10_000_000.0,
2758            0.0,
2759        );
2760        mgr.update(
2761            &bounds,
2762            0,
2763            (0.0, 0.0),
2764            CameraProjection::WebMercator,
2765            10_000_000.0,
2766            0.0,
2767        );
2768        mgr
2769    }
2770
2771    // =====================================================================
2772    // Construction and queries
2773    // =====================================================================
2774
2775    #[test]
2776    fn new_layer_defaults() {
2777        let layer = make_layer(Geometry::Point(origin_point()));
2778        assert_eq!(layer.name(), "test");
2779        assert!(layer.visible());
2780        assert!((layer.opacity() - 1.0).abs() < f32::EPSILON);
2781        assert_eq!(layer.feature_count(), 1);
2782        assert_eq!(layer.total_coords(), 1);
2783    }
2784
2785    #[test]
2786    fn layer_trait_visibility() {
2787        let mut layer = make_layer(Geometry::Point(origin_point()));
2788        layer.set_visible(false);
2789        assert!(!layer.visible());
2790        layer.set_visible(true);
2791        assert!(layer.visible());
2792    }
2793
2794    #[test]
2795    fn layer_trait_opacity_clamped() {
2796        let mut layer = make_layer(Geometry::Point(origin_point()));
2797        layer.set_opacity(1.5);
2798        assert!((layer.opacity() - 1.0).abs() < f32::EPSILON);
2799        layer.set_opacity(-0.5);
2800        assert!((layer.opacity() - 0.0).abs() < f32::EPSILON);
2801    }
2802
2803    #[test]
2804    fn debug_impl() {
2805        let layer = make_layer(Geometry::Point(origin_point()));
2806        let dbg = format!("{layer:?}");
2807        assert!(dbg.contains("VectorLayer"));
2808        assert!(dbg.contains("test"));
2809    }
2810
2811    // =====================================================================
2812    // Tessellation: Polygon
2813    // =====================================================================
2814
2815    #[test]
2816    fn tessellate_polygon() {
2817        let layer = make_layer(Geometry::Polygon(square_polygon()));
2818        let mesh = layer.tessellate(CameraProjection::WebMercator);
2819        assert_eq!(mesh.vertex_count(), 4);
2820        assert_eq!(mesh.index_count(), 6); // 2 triangles
2821        assert_eq!(mesh.triangle_count(), 2);
2822        assert_eq!(mesh.colors.len(), 4);
2823        assert!(!mesh.is_empty());
2824    }
2825
2826    // =====================================================================
2827    // Tessellation: LineString
2828    // =====================================================================
2829
2830    #[test]
2831    fn tessellate_linestring() {
2832        let layer = make_layer(Geometry::LineString(two_point_line()));
2833        let mesh = layer.tessellate(CameraProjection::WebMercator);
2834        assert_eq!(mesh.vertex_count(), 4); // Ribbon quad
2835        assert_eq!(mesh.index_count(), 6);
2836    }
2837
2838    // =====================================================================
2839    // Tessellation: Point
2840    // =====================================================================
2841
2842    #[test]
2843    fn tessellate_point() {
2844        let layer = make_layer(Geometry::Point(origin_point()));
2845        let mesh = layer.tessellate(CameraProjection::WebMercator);
2846        assert_eq!(mesh.vertex_count(), 4); // Quad
2847        assert_eq!(mesh.index_count(), 6); // 2 triangles
2848                                           // Fill colour should be used for points.
2849        assert_eq!(mesh.colors[0], VectorStyle::default().fill_color);
2850    }
2851
2852    // =====================================================================
2853    // Tessellation: Multi* types
2854    // =====================================================================
2855
2856    #[test]
2857    fn tessellate_multi_point() {
2858        let mp = Geometry::MultiPoint(MultiPoint {
2859            points: vec![origin_point(), origin_point()],
2860        });
2861        let layer = make_layer(mp);
2862        let mesh = layer.tessellate(CameraProjection::WebMercator);
2863        // Two points = 2 quads = 8 vertices, 12 indices.
2864        assert_eq!(mesh.vertex_count(), 8);
2865        assert_eq!(mesh.index_count(), 12);
2866    }
2867
2868    #[test]
2869    fn tessellate_multi_linestring() {
2870        let mls = Geometry::MultiLineString(MultiLineString {
2871            lines: vec![two_point_line(), two_point_line()],
2872        });
2873        let layer = make_layer(mls);
2874        let mesh = layer.tessellate(CameraProjection::WebMercator);
2875        assert_eq!(mesh.vertex_count(), 8); // 2 ribbons x 4 verts
2876        assert_eq!(mesh.index_count(), 12);
2877    }
2878
2879    #[test]
2880    fn tessellate_multi_polygon() {
2881        let mpoly = Geometry::MultiPolygon(MultiPolygon {
2882            polygons: vec![square_polygon(), square_polygon()],
2883        });
2884        let layer = make_layer(mpoly);
2885        let mesh = layer.tessellate(CameraProjection::WebMercator);
2886        assert_eq!(mesh.vertex_count(), 8); // 2 quads x 4 verts
2887        assert_eq!(mesh.index_count(), 12);
2888    }
2889
2890    #[test]
2891    fn tessellate_geometry_collection() {
2892        let gc = Geometry::GeometryCollection(vec![
2893            Geometry::Point(origin_point()),
2894            Geometry::Polygon(square_polygon()),
2895        ]);
2896        let layer = make_layer(gc);
2897        let mesh = layer.tessellate(CameraProjection::WebMercator);
2898        // Point: 4 verts + 6 indices, Polygon: 4 verts + 6 indices.
2899        assert_eq!(mesh.vertex_count(), 8);
2900        assert_eq!(mesh.index_count(), 12);
2901    }
2902
2903    // =====================================================================
2904    // Tessellation: empty / degenerate
2905    // =====================================================================
2906
2907    #[test]
2908    fn tessellate_empty_collection() {
2909        let features = FeatureCollection { features: vec![] };
2910        let layer = VectorLayer::new("empty", features, VectorStyle::default());
2911        let mesh = layer.tessellate(CameraProjection::WebMercator);
2912        assert!(mesh.is_empty());
2913        assert_eq!(mesh.vertex_count(), 0);
2914    }
2915
2916    // =====================================================================
2917    // VectorMeshData helpers
2918    // =====================================================================
2919
2920    #[test]
2921    fn mesh_data_merge() {
2922        let mut a = VectorMeshData {
2923            positions: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]],
2924            colors: vec![[1.0, 0.0, 0.0, 1.0], [1.0, 0.0, 0.0, 1.0]],
2925            indices: vec![0, 1, 0],
2926            ..Default::default()
2927        };
2928        let b = VectorMeshData {
2929            positions: vec![[2.0, 0.0, 0.0], [3.0, 0.0, 0.0]],
2930            colors: vec![[0.0, 1.0, 0.0, 1.0], [0.0, 1.0, 0.0, 1.0]],
2931            indices: vec![0, 1, 0],
2932            ..Default::default()
2933        };
2934        a.merge(&b);
2935        assert_eq!(a.vertex_count(), 4);
2936        assert_eq!(a.index_count(), 6);
2937        // Merged indices should be offset by base=2.
2938        assert_eq!(a.indices, vec![0, 1, 0, 2, 3, 2]);
2939    }
2940
2941    #[test]
2942    fn mesh_data_clear() {
2943        let mut mesh = VectorMeshData {
2944            positions: vec![[0.0, 0.0, 0.0]],
2945            colors: vec![[1.0, 0.0, 0.0, 1.0]],
2946            indices: vec![0],
2947            ..Default::default()
2948        };
2949        mesh.clear();
2950        assert!(mesh.is_empty());
2951        assert_eq!(mesh.vertex_count(), 0);
2952    }
2953
2954    // =====================================================================
2955    // Terrain draping
2956    // =====================================================================
2957
2958    #[test]
2959    fn drape_sets_altitude() {
2960        let mgr = flat_terrain_manager();
2961
2962        let features = FeatureCollection {
2963            features: vec![Feature {
2964                geometry: Geometry::Point(Point {
2965                    coord: GeoCoord::new(10.0, 20.0, 999.0),
2966                }),
2967                properties: HashMap::new(),
2968            }],
2969        };
2970
2971        let mut layer = VectorLayer::new("test", features, VectorStyle::default());
2972        layer.drape_on_terrain(&mgr);
2973
2974        // Flat terrain -> altitude should be 0.
2975        match &layer.features.features[0].geometry {
2976            Geometry::Point(p) => assert!((p.coord.alt - 0.0).abs() < 1e-3),
2977            _ => panic!("expected Point"),
2978        }
2979    }
2980
2981    #[test]
2982    fn drape_skipped_when_terrain_disabled() {
2983        let config = TerrainConfig::default(); // enabled = false
2984        let mgr = TerrainManager::new(config, 100);
2985
2986        let features = FeatureCollection {
2987            features: vec![Feature {
2988                geometry: Geometry::Point(Point {
2989                    coord: GeoCoord::new(10.0, 20.0, 999.0),
2990                }),
2991                properties: HashMap::new(),
2992            }],
2993        };
2994
2995        let mut layer = VectorLayer::new("test", features, VectorStyle::default());
2996        layer.drape_on_terrain(&mgr);
2997
2998        // Altitude should remain unchanged.
2999        match &layer.features.features[0].geometry {
3000            Geometry::Point(p) => assert!((p.coord.alt - 999.0).abs() < 1e-3),
3001            _ => panic!("expected Point"),
3002        }
3003    }
3004
3005    #[test]
3006    fn drape_linestring_coords() {
3007        let mgr = flat_terrain_manager();
3008
3009        let features = FeatureCollection {
3010            features: vec![Feature {
3011                geometry: Geometry::LineString(LineString {
3012                    coords: vec![
3013                        GeoCoord::new(10.0, 20.0, 500.0),
3014                        GeoCoord::new(11.0, 21.0, 600.0),
3015                    ],
3016                }),
3017                properties: HashMap::new(),
3018            }],
3019        };
3020
3021        let mut layer = VectorLayer::new("test", features, VectorStyle::default());
3022        layer.drape_on_terrain(&mgr);
3023
3024        match &layer.features.features[0].geometry {
3025            Geometry::LineString(ls) => {
3026                for coord in &ls.coords {
3027                    assert!(
3028                        coord.alt.abs() < 1e-3,
3029                        "expected flat terrain, got alt={}",
3030                        coord.alt
3031                    );
3032                }
3033            }
3034            _ => panic!("expected LineString"),
3035        }
3036    }
3037
3038    #[test]
3039    fn tessellate_circle_mode() {
3040        let style = VectorStyle {
3041            render_mode: VectorRenderMode::Circle,
3042            ..VectorStyle::default()
3043        };
3044        let layer = make_layer(Geometry::Point(origin_point()));
3045        let layer = VectorLayer::new("circle", layer.features, style);
3046        let mesh = layer.tessellate(CameraProjection::WebMercator);
3047        assert!(mesh.vertex_count() > 8);
3048        assert!(mesh.index_count() >= DEFAULT_CIRCLE_SEGMENTS * 3);
3049    }
3050
3051    #[test]
3052    fn tessellate_heatmap_mode_has_faded_edges() {
3053        let mut style = VectorStyle::heatmap([1.0, 0.0, 0.0, 0.5], 24.0, 1.0);
3054        style.render_mode = VectorRenderMode::Heatmap;
3055        let layer = make_layer(Geometry::Point(origin_point()));
3056        let layer = VectorLayer::new("heatmap", layer.features, style);
3057        let mesh = layer.tessellate(CameraProjection::WebMercator);
3058        assert_eq!(mesh.colors.first().map(|c| c[3]), Some(0.5));
3059        assert_eq!(mesh.colors.last().map(|c| c[3]), Some(0.0));
3060    }
3061
3062    #[test]
3063    fn tessellate_fill_extrusion_mode_produces_vertical_geometry() {
3064        let style = VectorStyle::fill_extrusion([0.5, 0.5, 0.8, 1.0], 0.0, 50.0);
3065        let layer = make_layer(Geometry::Polygon(square_polygon()));
3066        let layer = VectorLayer::new("extrusion", layer.features, style);
3067        let mesh = layer.tessellate(CameraProjection::WebMercator);
3068        let min_z = mesh
3069            .positions
3070            .iter()
3071            .map(|p| p[2])
3072            .fold(f64::INFINITY, f64::min);
3073        let max_z = mesh
3074            .positions
3075            .iter()
3076            .map(|p| p[2])
3077            .fold(f64::NEG_INFINITY, f64::max);
3078        assert!(max_z > min_z);
3079    }
3080
3081    #[test]
3082    fn fill_extrusion_tessellation_produces_normals() {
3083        let style = VectorStyle::fill_extrusion([0.5, 0.5, 0.8, 1.0], 0.0, 50.0);
3084        let layer = make_layer(Geometry::Polygon(square_polygon()));
3085        let layer = VectorLayer::new("extrusion", layer.features, style);
3086        let mesh = layer.tessellate(CameraProjection::WebMercator);
3087
3088        assert!(mesh.has_normals());
3089        assert_eq!(mesh.normals.len(), mesh.positions.len());
3090        assert_eq!(mesh.render_mode, VectorRenderMode::FillExtrusion);
3091    }
3092
3093    #[test]
3094    fn fill_extrusion_top_normals_point_up() {
3095        let style = VectorStyle::fill_extrusion([1.0, 0.0, 0.0, 1.0], 0.0, 100.0);
3096        let layer = make_layer(Geometry::Polygon(square_polygon()));
3097        let layer = VectorLayer::new("extrusion", layer.features, style);
3098        let mesh = layer.tessellate(CameraProjection::WebMercator);
3099
3100        // The top surface vertices are the first `ring.len()` vertices.
3101        // Their normals should all point straight up: [0, 0, 1].
3102        let ring_len = 4; // square has 4 unique vertices
3103        for i in 0..ring_len {
3104            let n = mesh.normals[i];
3105            assert!(
3106                (n[0]).abs() < 1e-6,
3107                "top normal x should be 0, got {}",
3108                n[0]
3109            );
3110            assert!(
3111                (n[1]).abs() < 1e-6,
3112                "top normal y should be 0, got {}",
3113                n[1]
3114            );
3115            assert!(
3116                (n[2] - 1.0).abs() < 1e-6,
3117                "top normal z should be 1, got {}",
3118                n[2]
3119            );
3120        }
3121    }
3122
3123    #[test]
3124    fn fill_extrusion_side_normals_are_horizontal() {
3125        let style = VectorStyle::fill_extrusion([1.0, 0.0, 0.0, 1.0], 0.0, 100.0);
3126        let layer = make_layer(Geometry::Polygon(square_polygon()));
3127        let layer = VectorLayer::new("extrusion", layer.features, style);
3128        let mesh = layer.tessellate(CameraProjection::WebMercator);
3129
3130        // Side faces start after the top ring vertices.
3131        let ring_len = 4;
3132        for i in ring_len..mesh.normals.len() {
3133            let n = mesh.normals[i];
3134            // Side normals have Z = 0 (horizontal).
3135            assert!(
3136                (n[2]).abs() < 1e-6,
3137                "side normal z should be 0, got {} at vertex {}",
3138                n[2],
3139                i
3140            );
3141            // And should be unit length in XY.
3142            let len = (n[0] * n[0] + n[1] * n[1]).sqrt();
3143            assert!(
3144                (len - 1.0).abs() < 0.01,
3145                "side normal length should be ~1, got {} at vertex {}",
3146                len,
3147                i
3148            );
3149        }
3150    }
3151
3152    #[test]
3153    fn flat_fill_tessellation_has_no_normals() {
3154        let style = VectorStyle {
3155            render_mode: VectorRenderMode::Fill,
3156            fill_color: [0.0, 1.0, 0.0, 1.0],
3157            ..VectorStyle::default()
3158        };
3159        let layer = make_layer(Geometry::Polygon(square_polygon()));
3160        let layer = VectorLayer::new("fill", layer.features, style);
3161        let mesh = layer.tessellate(CameraProjection::WebMercator);
3162
3163        assert!(!mesh.has_normals());
3164        assert!(mesh.normals.is_empty());
3165        assert_eq!(mesh.render_mode, VectorRenderMode::Fill);
3166    }
3167
3168    #[test]
3169    fn symbol_candidates_point_placement_skips_lines() {
3170        let mut style = VectorStyle::symbol([0.1, 0.2, 0.9, 1.0], [1.0, 1.0, 1.0, 1.0], 8.0);
3171        style.symbol_text_field = Some("name".to_owned());
3172        let mut properties = HashMap::new();
3173        properties.insert(
3174            "name".to_owned(),
3175            crate::geometry::PropertyValue::String("Road".to_owned()),
3176        );
3177        let layer = VectorLayer::new(
3178            "symbol",
3179            FeatureCollection {
3180                features: vec![Feature {
3181                    geometry: Geometry::LineString(two_point_line()),
3182                    properties,
3183                }],
3184            },
3185            style,
3186        );
3187
3188        assert!(layer.symbol_candidates().is_empty());
3189    }
3190
3191    #[test]
3192    fn symbol_candidates_text_only_use_text_overlap_flag() {
3193        let mut style = VectorStyle::symbol([0.1, 0.2, 0.9, 1.0], [1.0, 1.0, 1.0, 1.0], 8.0);
3194        style.symbol_text_field = Some("name".to_owned());
3195        style.symbol_text_allow_overlap = true;
3196        style.symbol_icon_allow_overlap = false;
3197
3198        let mut properties = HashMap::new();
3199        properties.insert(
3200            "name".to_owned(),
3201            crate::geometry::PropertyValue::String("Label".to_owned()),
3202        );
3203        let layer = VectorLayer::new(
3204            "symbol",
3205            FeatureCollection {
3206                features: vec![Feature {
3207                    geometry: Geometry::Point(origin_point()),
3208                    properties,
3209                }],
3210            },
3211            style,
3212        );
3213
3214        let candidates = layer.symbol_candidates();
3215        assert_eq!(candidates.len(), 1);
3216        assert!(candidates[0].allow_overlap);
3217    }
3218
3219    #[test]
3220    fn symbol_candidates_use_fixed_text_anchor_when_variable_anchors_absent() {
3221        let mut style = VectorStyle::symbol([0.1, 0.2, 0.9, 1.0], [1.0, 1.0, 1.0, 1.0], 8.0);
3222        style.symbol_text_field = Some("name".to_owned());
3223        style.symbol_text_anchor = SymbolAnchor::TopRight;
3224        style.symbol_anchors = vec![SymbolAnchor::TopRight];
3225
3226        let mut properties = HashMap::new();
3227        properties.insert(
3228            "name".to_owned(),
3229            crate::geometry::PropertyValue::String("Label".to_owned()),
3230        );
3231        let layer = VectorLayer::new(
3232            "symbol",
3233            FeatureCollection {
3234                features: vec![Feature {
3235                    geometry: Geometry::Point(origin_point()),
3236                    properties,
3237                }],
3238            },
3239            style,
3240        );
3241
3242        let candidates = layer.symbol_candidates();
3243        assert_eq!(candidates.len(), 1);
3244        assert_eq!(candidates[0].anchors, vec![SymbolAnchor::TopRight]);
3245    }
3246
3247    #[test]
3248    fn symbol_candidates_propagate_text_radial_offset() {
3249        let mut style = VectorStyle::symbol([0.1, 0.2, 0.9, 1.0], [1.0, 1.0, 1.0, 1.0], 8.0);
3250        style.symbol_text_field = Some("name".to_owned());
3251        style.symbol_text_anchor = SymbolAnchor::Top;
3252        style.symbol_anchors = vec![SymbolAnchor::Top];
3253        style.symbol_text_radial_offset = Some(2.0);
3254
3255        let mut properties = HashMap::new();
3256        properties.insert(
3257            "name".to_owned(),
3258            crate::geometry::PropertyValue::String("Label".to_owned()),
3259        );
3260        let layer = VectorLayer::new(
3261            "symbol",
3262            FeatureCollection {
3263                features: vec![Feature {
3264                    geometry: Geometry::Point(origin_point()),
3265                    properties,
3266                }],
3267            },
3268            style,
3269        );
3270
3271        let candidates = layer.symbol_candidates();
3272        assert_eq!(candidates.len(), 1);
3273        assert_eq!(candidates[0].radial_offset, Some(2.0));
3274    }
3275
3276    #[test]
3277    fn symbol_candidates_propagate_text_max_width() {
3278        let mut style = VectorStyle::symbol([0.1, 0.2, 0.9, 1.0], [1.0, 1.0, 1.0, 1.0], 8.0);
3279        style.symbol_text_field = Some("name".to_owned());
3280        style.symbol_text_max_width = Some(6.0);
3281
3282        let mut properties = HashMap::new();
3283        properties.insert(
3284            "name".to_owned(),
3285            crate::geometry::PropertyValue::String("Long label".to_owned()),
3286        );
3287        let layer = VectorLayer::new(
3288            "symbol",
3289            FeatureCollection {
3290                features: vec![Feature {
3291                    geometry: Geometry::Point(origin_point()),
3292                    properties,
3293                }],
3294            },
3295            style,
3296        );
3297
3298        let candidates = layer.symbol_candidates();
3299        assert_eq!(candidates.len(), 1);
3300        assert_eq!(candidates[0].text_max_width, Some(6.0));
3301    }
3302
3303    #[test]
3304    fn symbol_candidates_propagate_text_line_height() {
3305        let mut style = VectorStyle::symbol([0.1, 0.2, 0.9, 1.0], [1.0, 1.0, 1.0, 1.0], 8.0);
3306        style.symbol_text_field = Some("name".to_owned());
3307        style.symbol_text_line_height = Some(1.5);
3308
3309        let mut properties = HashMap::new();
3310        properties.insert(
3311            "name".to_owned(),
3312            crate::geometry::PropertyValue::String("Long label".to_owned()),
3313        );
3314        let layer = VectorLayer::new(
3315            "symbol",
3316            FeatureCollection {
3317                features: vec![Feature {
3318                    geometry: Geometry::Point(origin_point()),
3319                    properties,
3320                }],
3321            },
3322            style,
3323        );
3324
3325        let candidates = layer.symbol_candidates();
3326        assert_eq!(candidates.len(), 1);
3327        assert_eq!(candidates[0].text_line_height, Some(1.5));
3328    }
3329
3330    #[test]
3331    fn symbol_candidates_propagate_text_letter_spacing() {
3332        let mut style = VectorStyle::symbol([0.1, 0.2, 0.9, 1.0], [1.0, 1.0, 1.0, 1.0], 8.0);
3333        style.symbol_text_field = Some("name".to_owned());
3334        style.symbol_text_letter_spacing = Some(0.25);
3335
3336        let mut properties = HashMap::new();
3337        properties.insert(
3338            "name".to_owned(),
3339            crate::geometry::PropertyValue::String("Long label".to_owned()),
3340        );
3341        let layer = VectorLayer::new(
3342            "symbol",
3343            FeatureCollection {
3344                features: vec![Feature {
3345                    geometry: Geometry::Point(origin_point()),
3346                    properties,
3347                }],
3348            },
3349            style,
3350        );
3351
3352        let candidates = layer.symbol_candidates();
3353        assert_eq!(candidates.len(), 1);
3354        assert_eq!(candidates[0].text_letter_spacing, Some(0.25));
3355    }
3356
3357    #[test]
3358    fn symbol_candidates_apply_uppercase_text_transform() {
3359        let mut style = VectorStyle::symbol([0.1, 0.2, 0.9, 1.0], [1.0, 1.0, 1.0, 1.0], 8.0);
3360        style.symbol_text_field = Some("name".to_owned());
3361        style.symbol_text_transform = SymbolTextTransform::Uppercase;
3362
3363        let mut properties = HashMap::new();
3364        properties.insert(
3365            "name".to_owned(),
3366            crate::geometry::PropertyValue::String("Main Street".to_owned()),
3367        );
3368        let layer = VectorLayer::new(
3369            "symbol",
3370            FeatureCollection {
3371                features: vec![Feature {
3372                    geometry: Geometry::Point(origin_point()),
3373                    properties,
3374                }],
3375            },
3376            style,
3377        );
3378
3379        let candidates = layer.symbol_candidates();
3380        assert_eq!(candidates.len(), 1);
3381        assert_eq!(candidates[0].text.as_deref(), Some("MAIN STREET"));
3382    }
3383
3384    #[test]
3385    fn symbol_candidates_apply_lowercase_text_transform() {
3386        let mut style = VectorStyle::symbol([0.1, 0.2, 0.9, 1.0], [1.0, 1.0, 1.0, 1.0], 8.0);
3387        style.symbol_text_field = Some("name".to_owned());
3388        style.symbol_text_transform = SymbolTextTransform::Lowercase;
3389
3390        let mut properties = HashMap::new();
3391        properties.insert(
3392            "name".to_owned(),
3393            crate::geometry::PropertyValue::String("Main Street".to_owned()),
3394        );
3395        let layer = VectorLayer::new(
3396            "symbol",
3397            FeatureCollection {
3398                features: vec![Feature {
3399                    geometry: Geometry::Point(origin_point()),
3400                    properties,
3401                }],
3402            },
3403            style,
3404        );
3405
3406        let candidates = layer.symbol_candidates();
3407        assert_eq!(candidates.len(), 1);
3408        assert_eq!(candidates[0].text.as_deref(), Some("main street"));
3409    }
3410
3411    #[test]
3412    fn symbol_candidates_keep_variable_anchor_priority_over_fixed_anchor() {
3413        let mut style = VectorStyle::symbol([0.1, 0.2, 0.9, 1.0], [1.0, 1.0, 1.0, 1.0], 8.0);
3414        style.symbol_text_field = Some("name".to_owned());
3415        style.symbol_text_anchor = SymbolAnchor::BottomLeft;
3416        style.symbol_anchors = vec![SymbolAnchor::Center, SymbolAnchor::Top];
3417
3418        let mut properties = HashMap::new();
3419        properties.insert(
3420            "name".to_owned(),
3421            crate::geometry::PropertyValue::String("Label".to_owned()),
3422        );
3423        let layer = VectorLayer::new(
3424            "symbol",
3425            FeatureCollection {
3426                features: vec![Feature {
3427                    geometry: Geometry::Point(origin_point()),
3428                    properties,
3429                }],
3430            },
3431            style,
3432        );
3433
3434        let candidates = layer.symbol_candidates();
3435        assert_eq!(candidates.len(), 1);
3436        assert_eq!(
3437            candidates[0].anchors,
3438            vec![SymbolAnchor::Center, SymbolAnchor::Top]
3439        );
3440    }
3441
3442    #[test]
3443    fn symbol_candidates_use_variable_anchor_offset_order_when_present() {
3444        let mut style = VectorStyle::symbol([0.1, 0.2, 0.9, 1.0], [1.0, 1.0, 1.0, 1.0], 8.0);
3445        style.symbol_text_field = Some("name".to_owned());
3446        style.symbol_text_anchor = SymbolAnchor::BottomLeft;
3447        style.symbol_anchors = vec![SymbolAnchor::Center];
3448        style.symbol_variable_anchor_offsets = Some(vec![
3449            (SymbolAnchor::Top, [1.0, 2.0]),
3450            (SymbolAnchor::Right, [3.0, 4.0]),
3451        ]);
3452
3453        let mut properties = HashMap::new();
3454        properties.insert(
3455            "name".to_owned(),
3456            crate::geometry::PropertyValue::String("Label".to_owned()),
3457        );
3458        let layer = VectorLayer::new(
3459            "symbol",
3460            FeatureCollection {
3461                features: vec![Feature {
3462                    geometry: Geometry::Point(origin_point()),
3463                    properties,
3464                }],
3465            },
3466            style,
3467        );
3468
3469        let candidates = layer.symbol_candidates();
3470        assert_eq!(candidates.len(), 1);
3471        assert_eq!(
3472            candidates[0].anchors,
3473            vec![SymbolAnchor::Top, SymbolAnchor::Right]
3474        );
3475        assert_eq!(
3476            candidates[0].variable_anchor_offsets,
3477            Some(vec![
3478                (SymbolAnchor::Top, [1.0, 2.0]),
3479                (SymbolAnchor::Right, [3.0, 4.0]),
3480            ])
3481        );
3482    }
3483
3484    #[test]
3485    fn symbol_candidates_text_and_icon_require_both_overlap_flags() {
3486        let mut style = VectorStyle::symbol([0.1, 0.2, 0.9, 1.0], [1.0, 1.0, 1.0, 1.0], 8.0);
3487        style.symbol_text_field = Some("name".to_owned());
3488        style.symbol_icon_image = Some("marker".to_owned());
3489        style.symbol_text_allow_overlap = true;
3490        style.symbol_icon_allow_overlap = false;
3491
3492        let mut properties = HashMap::new();
3493        properties.insert(
3494            "name".to_owned(),
3495            crate::geometry::PropertyValue::String("Label".to_owned()),
3496        );
3497        let layer = VectorLayer::new(
3498            "symbol",
3499            FeatureCollection {
3500                features: vec![Feature {
3501                    geometry: Geometry::Point(origin_point()),
3502                    properties,
3503                }],
3504            },
3505            style,
3506        );
3507
3508        let candidates = layer.symbol_candidates();
3509        assert_eq!(candidates.len(), 1);
3510        assert!(!candidates[0].allow_overlap);
3511    }
3512
3513    #[test]
3514    fn symbol_candidates_text_only_use_text_ignore_placement_flag() {
3515        let mut style = VectorStyle::symbol([0.1, 0.2, 0.9, 1.0], [1.0, 1.0, 1.0, 1.0], 8.0);
3516        style.symbol_text_field = Some("name".to_owned());
3517        style.symbol_text_ignore_placement = true;
3518        style.symbol_icon_ignore_placement = false;
3519
3520        let mut properties = HashMap::new();
3521        properties.insert(
3522            "name".to_owned(),
3523            crate::geometry::PropertyValue::String("Label".to_owned()),
3524        );
3525        let layer = VectorLayer::new(
3526            "symbol",
3527            FeatureCollection {
3528                features: vec![Feature {
3529                    geometry: Geometry::Point(origin_point()),
3530                    properties,
3531                }],
3532            },
3533            style,
3534        );
3535
3536        let candidates = layer.symbol_candidates();
3537        assert_eq!(candidates.len(), 1);
3538        assert!(candidates[0].ignore_placement);
3539    }
3540
3541    #[test]
3542    fn symbol_candidates_text_and_icon_require_both_ignore_flags() {
3543        let mut style = VectorStyle::symbol([0.1, 0.2, 0.9, 1.0], [1.0, 1.0, 1.0, 1.0], 8.0);
3544        style.symbol_text_field = Some("name".to_owned());
3545        style.symbol_icon_image = Some("marker".to_owned());
3546        style.symbol_text_ignore_placement = true;
3547        style.symbol_icon_ignore_placement = false;
3548
3549        let mut properties = HashMap::new();
3550        properties.insert(
3551            "name".to_owned(),
3552            crate::geometry::PropertyValue::String("Label".to_owned()),
3553        );
3554        let layer = VectorLayer::new(
3555            "symbol",
3556            FeatureCollection {
3557                features: vec![Feature {
3558                    geometry: Geometry::Point(origin_point()),
3559                    properties,
3560                }],
3561            },
3562            style,
3563        );
3564
3565        let candidates = layer.symbol_candidates();
3566        assert_eq!(candidates.len(), 1);
3567        assert!(!candidates[0].ignore_placement);
3568    }
3569
3570    #[test]
3571    fn symbol_candidates_emit_icon_fallback_when_text_is_optional() {
3572        let mut style = VectorStyle::symbol([0.1, 0.2, 0.9, 1.0], [1.0, 1.0, 1.0, 1.0], 8.0);
3573        style.symbol_text_field = Some("name".to_owned());
3574        style.symbol_icon_image = Some("marker".to_owned());
3575        style.symbol_text_optional = true;
3576
3577        let mut properties = HashMap::new();
3578        properties.insert(
3579            "name".to_owned(),
3580            crate::geometry::PropertyValue::String("Label".to_owned()),
3581        );
3582        let layer = VectorLayer::new(
3583            "symbol",
3584            FeatureCollection {
3585                features: vec![Feature {
3586                    geometry: Geometry::Point(origin_point()),
3587                    properties,
3588                }],
3589            },
3590            style,
3591        );
3592
3593        let candidates = layer.symbol_candidates();
3594        assert_eq!(candidates.len(), 2);
3595        assert_eq!(candidates[0].text.as_deref(), Some("Label"));
3596        assert_eq!(candidates[0].icon_image.as_deref(), Some("marker"));
3597        assert!(candidates[1].text.is_none());
3598        assert_eq!(candidates[1].icon_image.as_deref(), Some("marker"));
3599        assert_eq!(candidates[0].cross_tile_id, candidates[1].cross_tile_id);
3600        assert_eq!(
3601            candidates[0].placement_group_id,
3602            candidates[1].placement_group_id
3603        );
3604    }
3605
3606    #[test]
3607    fn symbol_candidates_emit_text_fallback_when_icon_is_optional() {
3608        let mut style = VectorStyle::symbol([0.1, 0.2, 0.9, 1.0], [1.0, 1.0, 1.0, 1.0], 8.0);
3609        style.symbol_text_field = Some("name".to_owned());
3610        style.symbol_icon_image = Some("marker".to_owned());
3611        style.symbol_icon_optional = true;
3612
3613        let mut properties = HashMap::new();
3614        properties.insert(
3615            "name".to_owned(),
3616            crate::geometry::PropertyValue::String("Label".to_owned()),
3617        );
3618        let layer = VectorLayer::new(
3619            "symbol",
3620            FeatureCollection {
3621                features: vec![Feature {
3622                    geometry: Geometry::Point(origin_point()),
3623                    properties,
3624                }],
3625            },
3626            style,
3627        );
3628
3629        let candidates = layer.symbol_candidates();
3630        assert_eq!(candidates.len(), 2);
3631        assert_eq!(candidates[0].text.as_deref(), Some("Label"));
3632        assert_eq!(candidates[0].icon_image.as_deref(), Some("marker"));
3633        assert_eq!(candidates[1].text.as_deref(), Some("Label"));
3634        assert!(candidates[1].icon_image.is_none());
3635        assert_eq!(candidates[0].cross_tile_id, candidates[1].cross_tile_id);
3636        assert_eq!(
3637            candidates[0].placement_group_id,
3638            candidates[1].placement_group_id
3639        );
3640    }
3641
3642    #[test]
3643    fn symbol_candidates_line_placement_repeats_anchors_with_spacing() {
3644        let mut style = VectorStyle::symbol([0.1, 0.2, 0.9, 1.0], [1.0, 1.0, 1.0, 1.0], 8.0);
3645        style.symbol_text_field = Some("name".to_owned());
3646        style.symbol_placement = SymbolPlacement::Line;
3647        style.symbol_spacing = 1000.0;
3648
3649        let start = GeoCoord::from_lat_lon(0.0, 0.0);
3650        let end = GeoCoord::from_lat_lon(0.0, 0.05);
3651        let mut properties = HashMap::new();
3652        properties.insert(
3653            "name".to_owned(),
3654            crate::geometry::PropertyValue::String("Road".to_owned()),
3655        );
3656        let layer = VectorLayer::new(
3657            "symbol",
3658            FeatureCollection {
3659                features: vec![Feature {
3660                    geometry: Geometry::LineString(LineString {
3661                        coords: vec![start, end],
3662                    }),
3663                    properties,
3664                }],
3665            },
3666            style,
3667        );
3668
3669        let candidates = layer.symbol_candidates();
3670        assert!(
3671            candidates.len() > 1,
3672            "longer lines should produce repeated anchors"
3673        );
3674        assert!(candidates
3675            .iter()
3676            .all(|candidate| (candidate.anchor.lat - 0.0).abs() < 1e-9));
3677        assert!(candidates
3678            .windows(2)
3679            .all(|pair| pair[0].anchor.lon < pair[1].anchor.lon));
3680        assert!(candidates
3681            .iter()
3682            .all(|candidate| candidate.rotation_rad.abs() < 1e-6));
3683        assert_eq!(candidates[0].text.as_deref(), Some("Road"));
3684    }
3685
3686    #[test]
3687    fn symbol_candidates_line_placement_rotates_vertical_lines() {
3688        let mut style = VectorStyle::symbol([0.1, 0.2, 0.9, 1.0], [1.0, 1.0, 1.0, 1.0], 8.0);
3689        style.symbol_text_field = Some("name".to_owned());
3690        style.symbol_placement = SymbolPlacement::Line;
3691
3692        let mut properties = HashMap::new();
3693        properties.insert(
3694            "name".to_owned(),
3695            crate::geometry::PropertyValue::String("North".to_owned()),
3696        );
3697        let layer = VectorLayer::new(
3698            "symbol",
3699            FeatureCollection {
3700                features: vec![Feature {
3701                    geometry: Geometry::LineString(LineString {
3702                        coords: vec![
3703                            GeoCoord::from_lat_lon(0.0, 0.0),
3704                            GeoCoord::from_lat_lon(1.0, 0.0),
3705                        ],
3706                    }),
3707                    properties,
3708                }],
3709            },
3710            style,
3711        );
3712
3713        let candidates = layer.symbol_candidates();
3714        assert!(!candidates.is_empty());
3715        assert!(candidates.iter().all(|candidate| {
3716            (candidate.rotation_rad.abs() - std::f32::consts::FRAC_PI_2).abs() < 0.05
3717        }));
3718    }
3719
3720    #[test]
3721    fn symbol_candidates_line_placement_keeps_reversed_lines_upright() {
3722        let mut style = VectorStyle::symbol([0.1, 0.2, 0.9, 1.0], [1.0, 1.0, 1.0, 1.0], 8.0);
3723        style.symbol_text_field = Some("name".to_owned());
3724        style.symbol_placement = SymbolPlacement::Line;
3725        style.symbol_keep_upright = true;
3726
3727        let mut properties = HashMap::new();
3728        properties.insert(
3729            "name".to_owned(),
3730            crate::geometry::PropertyValue::String("West".to_owned()),
3731        );
3732        let layer = VectorLayer::new(
3733            "symbol",
3734            FeatureCollection {
3735                features: vec![Feature {
3736                    geometry: Geometry::LineString(LineString {
3737                        coords: vec![
3738                            GeoCoord::from_lat_lon(0.0, 1.0),
3739                            GeoCoord::from_lat_lon(0.0, 0.0),
3740                        ],
3741                    }),
3742                    properties,
3743                }],
3744            },
3745            style,
3746        );
3747
3748        let candidates = layer.symbol_candidates();
3749        assert!(!candidates.is_empty());
3750        assert!(candidates
3751            .iter()
3752            .all(|candidate| candidate.rotation_rad.abs() < 0.05));
3753    }
3754
3755    #[test]
3756    fn symbol_candidates_line_placement_can_disable_keep_upright() {
3757        let mut style = VectorStyle::symbol([0.1, 0.2, 0.9, 1.0], [1.0, 1.0, 1.0, 1.0], 8.0);
3758        style.symbol_text_field = Some("name".to_owned());
3759        style.symbol_placement = SymbolPlacement::Line;
3760        style.symbol_keep_upright = false;
3761
3762        let mut properties = HashMap::new();
3763        properties.insert(
3764            "name".to_owned(),
3765            crate::geometry::PropertyValue::String("West".to_owned()),
3766        );
3767        let layer = VectorLayer::new(
3768            "symbol",
3769            FeatureCollection {
3770                features: vec![Feature {
3771                    geometry: Geometry::LineString(LineString {
3772                        coords: vec![
3773                            GeoCoord::from_lat_lon(0.0, 1.0),
3774                            GeoCoord::from_lat_lon(0.0, 0.0),
3775                        ],
3776                    }),
3777                    properties,
3778                }],
3779            },
3780            style,
3781        );
3782
3783        let candidates = layer.symbol_candidates();
3784        assert!(!candidates.is_empty());
3785        assert!(candidates.iter().all(|candidate| {
3786            (candidate.rotation_rad.abs() - std::f32::consts::PI).abs() < 0.05
3787        }));
3788    }
3789
3790    #[test]
3791    fn symbol_candidates_line_placement_cross_tile_id_stays_stable_across_small_anchor_shifts() {
3792        let mut style = VectorStyle::symbol([0.1, 0.2, 0.9, 1.0], [1.0, 1.0, 1.0, 1.0], 8.0);
3793        style.symbol_text_field = Some("name".to_owned());
3794        style.symbol_placement = SymbolPlacement::Line;
3795        style.symbol_spacing = 1000.0;
3796
3797        let mut properties = HashMap::new();
3798        properties.insert(
3799            "id".to_owned(),
3800            crate::geometry::PropertyValue::String("road-1".to_owned()),
3801        );
3802        properties.insert(
3803            "name".to_owned(),
3804            crate::geometry::PropertyValue::String("Main".to_owned()),
3805        );
3806
3807        let base = VectorLayer::new(
3808            "symbol",
3809            FeatureCollection {
3810                features: vec![Feature {
3811                    geometry: Geometry::LineString(LineString {
3812                        coords: vec![
3813                            GeoCoord::from_lat_lon(0.0, 0.0),
3814                            GeoCoord::from_lat_lon(0.0, 0.05),
3815                        ],
3816                    }),
3817                    properties: properties.clone(),
3818                }],
3819            },
3820            style.clone(),
3821        );
3822        let shifted = VectorLayer::new(
3823            "symbol",
3824            FeatureCollection {
3825                features: vec![Feature {
3826                    geometry: Geometry::LineString(LineString {
3827                        coords: vec![
3828                            GeoCoord::from_lat_lon(0.0, 0.0),
3829                            GeoCoord::from_lat_lon(0.0002, 0.0502),
3830                        ],
3831                    }),
3832                    properties,
3833                }],
3834            },
3835            style,
3836        );
3837
3838        let base_ids = base
3839            .symbol_candidates()
3840            .into_iter()
3841            .map(|candidate| candidate.cross_tile_id)
3842            .collect::<Vec<_>>();
3843        let shifted_ids = shifted
3844            .symbol_candidates()
3845            .into_iter()
3846            .map(|candidate| candidate.cross_tile_id)
3847            .collect::<Vec<_>>();
3848
3849        assert_eq!(base_ids, shifted_ids);
3850    }
3851
3852    #[test]
3853    fn symbol_candidates_line_placement_cross_tile_id_changes_for_shifted_line_windows() {
3854        let mut style = VectorStyle::symbol([0.1, 0.2, 0.9, 1.0], [1.0, 1.0, 1.0, 1.0], 8.0);
3855        style.symbol_text_field = Some("name".to_owned());
3856        style.symbol_placement = SymbolPlacement::Line;
3857        style.symbol_spacing = 1000.0;
3858
3859        let mut properties = HashMap::new();
3860        properties.insert(
3861            "id".to_owned(),
3862            crate::geometry::PropertyValue::String("road-2".to_owned()),
3863        );
3864        properties.insert(
3865            "name".to_owned(),
3866            crate::geometry::PropertyValue::String("Main".to_owned()),
3867        );
3868
3869        let base = VectorLayer::new(
3870            "symbol",
3871            FeatureCollection {
3872                features: vec![Feature {
3873                    geometry: Geometry::LineString(LineString {
3874                        coords: vec![
3875                            GeoCoord::from_lat_lon(0.0, 0.0),
3876                            GeoCoord::from_lat_lon(0.0, 0.05),
3877                        ],
3878                    }),
3879                    properties: properties.clone(),
3880                }],
3881            },
3882            style.clone(),
3883        );
3884        let shifted_window = VectorLayer::new(
3885            "symbol",
3886            FeatureCollection {
3887                features: vec![Feature {
3888                    geometry: Geometry::LineString(LineString {
3889                        coords: vec![
3890                            GeoCoord::from_lat_lon(0.0, 0.01),
3891                            GeoCoord::from_lat_lon(0.0, 0.06),
3892                        ],
3893                    }),
3894                    properties,
3895                }],
3896            },
3897            style,
3898        );
3899
3900        let base_ids = base
3901            .symbol_candidates()
3902            .into_iter()
3903            .map(|candidate| candidate.cross_tile_id)
3904            .collect::<Vec<_>>();
3905        let shifted_ids = shifted_window
3906            .symbol_candidates()
3907            .into_iter()
3908            .map(|candidate| candidate.cross_tile_id)
3909            .collect::<Vec<_>>();
3910
3911        assert_ne!(base_ids.first(), shifted_ids.first());
3912        assert!(base_ids.iter().any(|id| shifted_ids.contains(id)));
3913    }
3914
3915    #[test]
3916    fn symbol_candidates_line_placement_filters_sharp_turns_with_max_angle() {
3917        let mut style = VectorStyle::symbol([0.1, 0.2, 0.9, 1.0], [1.0, 1.0, 1.0, 1.0], 8.0);
3918        style.symbol_text_field = Some("name".to_owned());
3919        style.symbol_placement = SymbolPlacement::Line;
3920        style.symbol_spacing = 10_000.0;
3921        style.symbol_max_angle = 10.0;
3922
3923        let mut properties = HashMap::new();
3924        properties.insert(
3925            "name".to_owned(),
3926            crate::geometry::PropertyValue::String("Turn".to_owned()),
3927        );
3928        let layer = VectorLayer::new(
3929            "symbol",
3930            FeatureCollection {
3931                features: vec![Feature {
3932                    geometry: Geometry::LineString(LineString {
3933                        coords: vec![
3934                            GeoCoord::from_lat_lon(0.0, 0.0),
3935                            GeoCoord::from_lat_lon(0.0, 0.03),
3936                            GeoCoord::from_lat_lon(0.03, 0.03),
3937                        ],
3938                    }),
3939                    properties,
3940                }],
3941            },
3942            style,
3943        );
3944
3945        assert!(layer.symbol_candidates().is_empty());
3946    }
3947
3948    #[test]
3949    fn tessellate_symbol_mode_stacks_halo_and_fill() {
3950        let style = VectorStyle::symbol([0.1, 0.2, 0.9, 1.0], [1.0, 1.0, 1.0, 1.0], 8.0);
3951        let layer = make_layer(Geometry::Point(origin_point()));
3952        let layer = VectorLayer::new("symbol", layer.features, style);
3953        let mesh = layer.tessellate(CameraProjection::WebMercator);
3954        assert_eq!(mesh.vertex_count(), 8);
3955        assert_eq!(mesh.index_count(), 12);
3956    }
3957
3958    #[test]
3959    fn tessellate_equirectangular_changes_xy_positions() {
3960        let layer = make_layer(Geometry::Polygon(square_polygon()));
3961        let merc = layer.tessellate(CameraProjection::WebMercator);
3962        let eq = layer.tessellate(CameraProjection::Equirectangular);
3963
3964        assert_eq!(merc.positions.len(), eq.positions.len());
3965        assert!(merc
3966            .positions
3967            .iter()
3968            .zip(eq.positions.iter())
3969            .any(|(a, b)| (a[0] - b[0]).abs() > 1.0 || (a[1] - b[1]).abs() > 1.0));
3970    }
3971
3972    // =====================================================================
3973    // Line-mode tessellation: cap/join/dash pipeline
3974    // =====================================================================
3975
3976    #[test]
3977    fn tessellate_line_mode_populates_normals_and_distances() {
3978        let style = VectorStyle::line([1.0, 0.0, 0.0, 1.0], 4.0);
3979        let layer = VectorLayer::new(
3980            "line",
3981            make_layer(Geometry::LineString(two_point_line())).features,
3982            style,
3983        );
3984        let mesh = layer.tessellate(CameraProjection::WebMercator);
3985        assert!(!mesh.is_empty());
3986        assert_eq!(
3987            mesh.line_normals.len(),
3988            mesh.positions.len(),
3989            "line_normals must have one entry per vertex"
3990        );
3991        assert_eq!(
3992            mesh.line_distances.len(),
3993            mesh.positions.len(),
3994            "line_distances must have one entry per vertex"
3995        );
3996        // At least one distance should be > 0 (the endpoint).
3997        assert!(
3998            mesh.line_distances.iter().any(|&d| d > 0.0),
3999            "at least one distance should be positive"
4000        );
4001    }
4002
4003    #[test]
4004    fn tessellate_line_mode_propagates_dash_params() {
4005        let style = VectorStyle::line_styled(
4006            [1.0, 0.0, 0.0, 1.0],
4007            4.0,
4008            LineCap::Round,
4009            LineJoin::Bevel,
4010            2.0,
4011            Some(vec![10.0, 5.0]),
4012        );
4013        let layer = VectorLayer::new(
4014            "dashed",
4015            make_layer(Geometry::LineString(two_point_line())).features,
4016            style,
4017        );
4018        let mesh = layer.tessellate(CameraProjection::WebMercator);
4019        assert_eq!(mesh.line_params[0], 10.0, "dash_length");
4020        assert_eq!(mesh.line_params[1], 5.0, "gap_length");
4021        assert_eq!(mesh.line_params[2], 1.0, "cap_round flag");
4022    }
4023
4024    #[test]
4025    fn tessellate_line_mode_round_join_adds_vertices() {
4026        let line = crate::geometry::LineString {
4027            coords: vec![
4028                GeoCoord::from_lat_lon(0.0, 0.0),
4029                GeoCoord::from_lat_lon(0.0, 1.0),
4030                GeoCoord::from_lat_lon(1.0, 1.0),
4031            ],
4032        };
4033        let miter_style = VectorStyle::line_styled(
4034            [1.0, 0.0, 0.0, 1.0],
4035            4.0,
4036            LineCap::Butt,
4037            LineJoin::Miter,
4038            10.0,
4039            None,
4040        );
4041        let round_style = VectorStyle::line_styled(
4042            [1.0, 0.0, 0.0, 1.0],
4043            4.0,
4044            LineCap::Butt,
4045            LineJoin::Round,
4046            2.0,
4047            None,
4048        );
4049        let miter_layer = VectorLayer::new(
4050            "m",
4051            make_layer(Geometry::LineString(line.clone())).features,
4052            miter_style,
4053        );
4054        let round_layer = VectorLayer::new(
4055            "r",
4056            make_layer(Geometry::LineString(line)).features,
4057            round_style,
4058        );
4059        let miter_mesh = miter_layer.tessellate(CameraProjection::WebMercator);
4060        let round_mesh = round_layer.tessellate(CameraProjection::WebMercator);
4061        assert!(
4062            round_mesh.vertex_count() > miter_mesh.vertex_count(),
4063            "round join should produce more vertices than miter: {} vs {}",
4064            round_mesh.vertex_count(),
4065            miter_mesh.vertex_count(),
4066        );
4067    }
4068
4069    // =====================================================================
4070    // Data-driven per-feature line width and colour
4071    // =====================================================================
4072
4073    /// Build a two-feature line layer where each feature has a `"width"`
4074    /// property (narrow = 2.0, wide = 10.0) sharing a single VectorStyle
4075    /// with a data-driven width expression.
4076    fn two_feature_dd_width_layer() -> VectorLayer {
4077        use crate::geometry::PropertyValue;
4078
4079        let narrow_feature = Feature {
4080            geometry: Geometry::LineString(two_point_line()),
4081            properties: {
4082                let mut p = HashMap::new();
4083                p.insert("width".into(), PropertyValue::Number(2.0));
4084                p
4085            },
4086        };
4087        let wide_feature = Feature {
4088            geometry: Geometry::LineString(two_point_line()),
4089            properties: {
4090                let mut p = HashMap::new();
4091                p.insert("width".into(), PropertyValue::Number(10.0));
4092                p
4093            },
4094        };
4095        let features = FeatureCollection {
4096            features: vec![narrow_feature, wide_feature],
4097        };
4098
4099        let mut style = VectorStyle::line([1.0, 0.0, 0.0, 1.0], 2.0);
4100        style.width_expr = Some(Expression::GetProperty {
4101            key: "width".into(),
4102            fallback: 2.0,
4103        });
4104        style.eval_zoom = 10.0;
4105
4106        VectorLayer::new("dd_width", features, style)
4107    }
4108
4109    #[test]
4110    fn data_driven_width_produces_different_ribbon_widths() {
4111        let layer = two_feature_dd_width_layer();
4112        let mesh = layer.tessellate(CameraProjection::WebMercator);
4113
4114        // Both features should be tessellated (non-empty).
4115        assert!(!mesh.is_empty());
4116
4117        // Now tessellate each feature individually to compare widths.
4118        let narrow_style = VectorStyle::line([1.0, 0.0, 0.0, 1.0], 2.0);
4119        let wide_style = VectorStyle::line([1.0, 0.0, 0.0, 1.0], 10.0);
4120
4121        let narrow_layer = VectorLayer::new(
4122            "narrow",
4123            FeatureCollection {
4124                features: vec![layer.features.features[0].clone()],
4125            },
4126            narrow_style,
4127        );
4128        let wide_layer = VectorLayer::new(
4129            "wide",
4130            FeatureCollection {
4131                features: vec![layer.features.features[1].clone()],
4132            },
4133            wide_style,
4134        );
4135
4136        let narrow_mesh = narrow_layer.tessellate(CameraProjection::WebMercator);
4137        let wide_mesh = wide_layer.tessellate(CameraProjection::WebMercator);
4138
4139        // Narrow ribbon should have smaller span than wide ribbon.
4140        let narrow_span = position_y_span(&narrow_mesh);
4141        let wide_span = position_y_span(&wide_mesh);
4142        assert!(
4143            wide_span > narrow_span,
4144            "wide ribbon span ({wide_span}) must exceed narrow ribbon span ({narrow_span})",
4145        );
4146
4147        // The combined data-driven mesh should have vertices spanning at
4148        // least as wide as the widest individual ribbon.
4149        let combined_span = position_y_span(&mesh);
4150        assert!(
4151            combined_span >= wide_span * 0.99,
4152            "combined mesh span ({combined_span}) should be >= wide span ({wide_span})",
4153        );
4154    }
4155
4156    #[test]
4157    fn data_driven_color_assigns_per_feature_colors() {
4158        use crate::geometry::PropertyValue;
4159
4160        let red_feature = Feature {
4161            geometry: Geometry::LineString(two_point_line()),
4162            properties: {
4163                let mut p = HashMap::new();
4164                p.insert("kind".into(), PropertyValue::String("highway".into()));
4165                p
4166            },
4167        };
4168        let blue_feature = Feature {
4169            geometry: Geometry::LineString(two_point_line()),
4170            properties: {
4171                let mut p = HashMap::new();
4172                p.insert("kind".into(), PropertyValue::String("local".into()));
4173                p
4174            },
4175        };
4176        let features = FeatureCollection {
4177            features: vec![red_feature, blue_feature],
4178        };
4179
4180        let mut style = VectorStyle::line([1.0, 0.0, 0.0, 1.0], 4.0);
4181        style.stroke_color_expr = Some(Expression::Match {
4182            input: Box::new(crate::expression::StringExpression::GetProperty {
4183                key: "kind".into(),
4184                fallback: String::new(),
4185            }),
4186            cases: vec![
4187                ("highway".into(), [1.0, 0.0, 0.0, 1.0]),
4188                ("local".into(), [0.0, 0.0, 1.0, 1.0]),
4189            ],
4190            fallback: [0.5, 0.5, 0.5, 1.0],
4191        });
4192        style.eval_zoom = 10.0;
4193
4194        let layer = VectorLayer::new("dd_color", features, style);
4195        let mesh = layer.tessellate(CameraProjection::WebMercator);
4196
4197        assert!(!mesh.is_empty());
4198
4199        // Vertices should contain both red and blue colors.
4200        let has_red = mesh
4201            .colors
4202            .iter()
4203            .any(|c| c[0] > 0.9 && c[1] < 0.1 && c[2] < 0.1);
4204        let has_blue = mesh
4205            .colors
4206            .iter()
4207            .any(|c| c[0] < 0.1 && c[1] < 0.1 && c[2] > 0.9);
4208        assert!(
4209            has_red,
4210            "mesh should contain red vertices from highway feature"
4211        );
4212        assert!(
4213            has_blue,
4214            "mesh should contain blue vertices from local feature"
4215        );
4216    }
4217
4218    #[test]
4219    fn non_data_driven_width_expr_uses_uniform_value() {
4220        // A constant expression is NOT data-driven; the fast path should
4221        // apply the uniform stroke_width to all features.
4222        let features = FeatureCollection {
4223            features: vec![
4224                Feature {
4225                    geometry: Geometry::LineString(two_point_line()),
4226                    properties: HashMap::new(),
4227                },
4228                Feature {
4229                    geometry: Geometry::LineString(two_point_line()),
4230                    properties: HashMap::new(),
4231                },
4232            ],
4233        };
4234        let mut style = VectorStyle::line([1.0, 0.0, 0.0, 1.0], 4.0);
4235        style.width_expr = Some(Expression::Constant(4.0));
4236        style.eval_zoom = 10.0;
4237
4238        let layer = VectorLayer::new("uniform", features, style);
4239        let mesh = layer.tessellate(CameraProjection::WebMercator);
4240        assert!(!mesh.is_empty());
4241
4242        // All vertices should have the same color (uniform path).
4243        let first_color = mesh.colors[0];
4244        assert!(mesh.colors.iter().all(|c| *c == first_color));
4245    }
4246
4247    #[test]
4248    fn data_driven_width_fingerprint_changes_with_zoom() {
4249        let mut style = VectorStyle::line([1.0, 0.0, 0.0, 1.0], 4.0);
4250        style.width_expr = Some(Expression::GetProperty {
4251            key: "width".into(),
4252            fallback: 4.0,
4253        });
4254
4255        style.eval_zoom = 5.0;
4256        let fp1 = style.tessellation_fingerprint();
4257
4258        style.eval_zoom = 10.0;
4259        let fp2 = style.tessellation_fingerprint();
4260
4261        assert_ne!(fp1, fp2, "data-driven fingerprint should differ by zoom");
4262    }
4263
4264    /// Compute the Y-axis extent of all positions in a mesh.
4265    fn position_y_span(mesh: &VectorMeshData) -> f64 {
4266        let ys: Vec<f64> = mesh.positions.iter().map(|p| p[1]).collect();
4267        let min = ys.iter().cloned().fold(f64::INFINITY, f64::min);
4268        let max = ys.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
4269        max - min
4270    }
4271
4272    // =====================================================================
4273    // Line gradient tests
4274    // =====================================================================
4275
4276    fn blue_to_red_ramp() -> crate::visualization::ColorRamp {
4277        use crate::visualization::{ColorRamp, ColorStop};
4278        ColorRamp::new(vec![
4279            ColorStop {
4280                value: 0.0,
4281                color: [0.0, 0.0, 1.0, 1.0],
4282            },
4283            ColorStop {
4284                value: 1.0,
4285                color: [1.0, 0.0, 0.0, 1.0],
4286            },
4287        ])
4288    }
4289
4290    #[test]
4291    fn line_gradient_overrides_vertex_colors() {
4292        let ramp = blue_to_red_ramp();
4293        let style = VectorStyle::line_gradient(4.0, ramp);
4294        let layer = VectorLayer::new(
4295            "grad",
4296            FeatureCollection {
4297                features: vec![Feature {
4298                    geometry: Geometry::LineString(LineString {
4299                        coords: vec![
4300                            GeoCoord::from_lat_lon(0.0, 0.0),
4301                            GeoCoord::from_lat_lon(0.0, 1.0),
4302                        ],
4303                    }),
4304                    properties: HashMap::new(),
4305                }],
4306            },
4307            style,
4308        );
4309        let mesh = layer.tessellate(CameraProjection::WebMercator);
4310        assert!(!mesh.colors.is_empty());
4311        // Start vertices should be blueish (distance ~0 → t ≈ 0).
4312        // End vertices should be reddish (distance ~max → t ≈ 1).
4313        let has_blue = mesh.colors.iter().any(|c| c[2] > 0.8 && c[0] < 0.2);
4314        let has_red = mesh.colors.iter().any(|c| c[0] > 0.8 && c[2] < 0.2);
4315        assert!(has_blue, "expected some blue-ish vertices near start");
4316        assert!(has_red, "expected some red-ish vertices near end");
4317    }
4318
4319    #[test]
4320    fn line_gradient_without_ramp_uses_solid_color() {
4321        let solid = [0.5, 0.5, 0.5, 1.0];
4322        let style = VectorStyle::line(solid, 4.0);
4323        let layer = VectorLayer::new(
4324            "solid",
4325            FeatureCollection {
4326                features: vec![Feature {
4327                    geometry: Geometry::LineString(LineString {
4328                        coords: vec![
4329                            GeoCoord::from_lat_lon(0.0, 0.0),
4330                            GeoCoord::from_lat_lon(0.0, 1.0),
4331                        ],
4332                    }),
4333                    properties: HashMap::new(),
4334                }],
4335            },
4336            style,
4337        );
4338        let mesh = layer.tessellate(CameraProjection::WebMercator);
4339        assert!(!mesh.colors.is_empty());
4340        for c in &mesh.colors {
4341            assert_eq!(*c, solid, "all vertices should share the solid colour");
4342        }
4343    }
4344
4345    #[test]
4346    fn line_gradient_midpoint_is_interpolated() {
4347        use crate::visualization::{ColorRamp, ColorStop};
4348        let ramp = ColorRamp::new(vec![
4349            ColorStop {
4350                value: 0.0,
4351                color: [0.0, 0.0, 0.0, 1.0],
4352            },
4353            ColorStop {
4354                value: 1.0,
4355                color: [1.0, 1.0, 1.0, 1.0],
4356            },
4357        ]);
4358        let style = VectorStyle::line_gradient(2.0, ramp);
4359        let layer = VectorLayer::new(
4360            "mid",
4361            FeatureCollection {
4362                features: vec![Feature {
4363                    geometry: Geometry::LineString(LineString {
4364                        coords: vec![
4365                            GeoCoord::from_lat_lon(0.0, 0.0),
4366                            GeoCoord::from_lat_lon(0.0, 0.5),
4367                            GeoCoord::from_lat_lon(0.0, 1.0),
4368                        ],
4369                    }),
4370                    properties: HashMap::new(),
4371                }],
4372            },
4373            style,
4374        );
4375        let mesh = layer.tessellate(CameraProjection::WebMercator);
4376        // With three input coords, the midpoint vertices should have
4377        // colours around 0.5.  Find any vertex with R/G/B in (0.3, 0.7).
4378        let has_mid = mesh.colors.iter().any(|c| c[0] > 0.3 && c[0] < 0.7);
4379        assert!(
4380            has_mid,
4381            "expected midpoint vertices with interpolated colour"
4382        );
4383    }
4384
4385    #[test]
4386    fn line_gradient_style_roundtrips_through_line_style_layer() {
4387        use crate::style::{line_style_with_state, LineStyleLayer, StyleEvalContextFull};
4388        let ramp = blue_to_red_ramp();
4389        let mut layer = LineStyleLayer::new("grad", "src");
4390        layer.line_gradient = Some(ramp.clone());
4391        let state = HashMap::new();
4392        let ctx = StyleEvalContextFull::new(5.0, &state);
4393        let vs = line_style_with_state(&layer, &ctx);
4394        assert!(vs.line_gradient.is_some());
4395    }
4396
4397    // -- Fill-pattern tests -----------------------------------------------
4398
4399    /// Helper: create a tiny 2×2 checkerboard pattern image.
4400    fn checkerboard_2x2() -> Arc<PatternImage> {
4401        // RGBA: black, white, white, black
4402        #[rustfmt::skip]
4403        let data = vec![
4404            0, 0, 0, 255,   255, 255, 255, 255,
4405            255, 255, 255, 255,   0, 0, 0, 255,
4406        ];
4407        Arc::new(PatternImage::new(2, 2, data))
4408    }
4409
4410    #[test]
4411    fn pattern_image_validates_data_length() {
4412        // 2×2 RGBA needs exactly 16 bytes.
4413        let img = PatternImage::new(2, 2, vec![0u8; 16]);
4414        assert_eq!(img.width, 2);
4415        assert_eq!(img.height, 2);
4416    }
4417
4418    #[test]
4419    #[should_panic(expected = "RGBA8 data length")]
4420    fn pattern_image_rejects_wrong_data_length() {
4421        let _img = PatternImage::new(2, 2, vec![0u8; 10]);
4422    }
4423
4424    #[test]
4425    fn fill_pattern_generates_uvs() {
4426        let pattern = checkerboard_2x2();
4427        let style = VectorStyle::fill_pattern(pattern);
4428
4429        let geom = Geometry::Polygon(square_polygon());
4430        let features = FeatureCollection {
4431            features: vec![Feature {
4432                geometry: geom,
4433                properties: HashMap::new(),
4434            }],
4435        };
4436        let layer = VectorLayer::new("pat", features, style);
4437        let mesh = layer.tessellate(CameraProjection::WebMercator);
4438
4439        // Pattern is present → UVs must be generated.
4440        assert!(mesh.fill_pattern.is_some());
4441        assert!(
4442            !mesh.fill_pattern_uvs.is_empty(),
4443            "expected fill_pattern_uvs to be non-empty"
4444        );
4445        // Fill UVs are generated for fill polygon vertices only (not outline
4446        // stroke vertices), so the count may be less than positions.len()
4447        // when the style includes an outline.
4448        assert!(
4449            mesh.fill_pattern_uvs.len() <= mesh.positions.len(),
4450            "fill_pattern_uvs should not exceed positions count"
4451        );
4452    }
4453
4454    #[test]
4455    fn solid_fill_has_no_pattern_uvs() {
4456        let style = VectorStyle::fill([0.5, 0.5, 0.5, 1.0], [0.0, 0.0, 0.0, 1.0], 1.0);
4457
4458        let geom = Geometry::Polygon(square_polygon());
4459        let features = FeatureCollection {
4460            features: vec![Feature {
4461                geometry: geom,
4462                properties: HashMap::new(),
4463            }],
4464        };
4465        let layer = VectorLayer::new("solid", features, style);
4466        let mesh = layer.tessellate(CameraProjection::WebMercator);
4467
4468        assert!(mesh.fill_pattern.is_none());
4469        assert!(
4470            mesh.fill_pattern_uvs.is_empty(),
4471            "solid fills should not generate pattern UVs"
4472        );
4473    }
4474
4475    #[test]
4476    fn fill_pattern_style_roundtrips_through_fill_style_layer() {
4477        use crate::style::{fill_style_with_state, FillStyleLayer, StyleEvalContextFull};
4478        let pattern = checkerboard_2x2();
4479        let mut layer = FillStyleLayer::new("fp", "src");
4480        layer.fill_pattern = Some(pattern.clone());
4481        let state = HashMap::new();
4482        let ctx = StyleEvalContextFull::new(5.0, &state);
4483        let vs = fill_style_with_state(&layer, &ctx);
4484        assert!(vs.fill_pattern.is_some());
4485        assert_eq!(vs.fill_pattern.as_ref().unwrap().width, 2);
4486    }
4487
4488    // -----------------------------------------------------------------------
4489    // Line-pattern tests
4490    // -----------------------------------------------------------------------
4491
4492    fn line_feature() -> FeatureCollection {
4493        FeatureCollection {
4494            features: vec![Feature {
4495                geometry: Geometry::LineString(LineString {
4496                    coords: vec![
4497                        GeoCoord::from_lat_lon(0.0, 0.0),
4498                        GeoCoord::from_lat_lon(0.0, 1.0),
4499                    ],
4500                }),
4501                properties: HashMap::new(),
4502            }],
4503        }
4504    }
4505
4506    #[test]
4507    fn line_pattern_generates_uvs() {
4508        let pattern = checkerboard_2x2();
4509        let style = VectorStyle::line_pattern(4.0, pattern);
4510        let layer = VectorLayer::new("lp", line_feature(), style);
4511        let mesh = layer.tessellate(CameraProjection::WebMercator);
4512
4513        assert!(
4514            mesh.line_pattern.is_some(),
4515            "pattern image should be carried to mesh"
4516        );
4517        assert!(
4518            !mesh.line_pattern_uvs.is_empty(),
4519            "expected line_pattern_uvs to be non-empty"
4520        );
4521        assert_eq!(
4522            mesh.line_pattern_uvs.len(),
4523            mesh.positions.len(),
4524            "each position should have a corresponding pattern UV"
4525        );
4526    }
4527
4528    #[test]
4529    fn line_pattern_uvs_have_correct_v_range() {
4530        let pattern = checkerboard_2x2();
4531        let style = VectorStyle::line_pattern(4.0, pattern);
4532        let layer = VectorLayer::new("lp", line_feature(), style);
4533        let mesh = layer.tessellate(CameraProjection::WebMercator);
4534
4535        // Body vertices should have V = 0.0 or V = 1.0 (left/right edges).
4536        // Cap/join vertices should have V = 0.5.
4537        let has_left = mesh
4538            .line_pattern_uvs
4539            .iter()
4540            .any(|uv| (uv[1] - 0.0).abs() < 0.01);
4541        let has_right = mesh
4542            .line_pattern_uvs
4543            .iter()
4544            .any(|uv| (uv[1] - 1.0).abs() < 0.01);
4545        assert!(has_left, "expected some V=0.0 vertices (left edge)");
4546        assert!(has_right, "expected some V=1.0 vertices (right edge)");
4547
4548        // All V values should be in [0, 1].
4549        for uv in &mesh.line_pattern_uvs {
4550            assert!(
4551                uv[1] >= -0.01 && uv[1] <= 1.01,
4552                "V coordinate {:.3} outside [0, 1]",
4553                uv[1]
4554            );
4555        }
4556    }
4557
4558    #[test]
4559    fn solid_line_has_no_pattern_uvs() {
4560        let style = VectorStyle::line([1.0, 0.0, 0.0, 1.0], 4.0);
4561        let layer = VectorLayer::new("solid", line_feature(), style);
4562        let mesh = layer.tessellate(CameraProjection::WebMercator);
4563
4564        assert!(mesh.line_pattern.is_none());
4565        assert!(
4566            mesh.line_pattern_uvs.is_empty(),
4567            "solid lines should not generate pattern UVs"
4568        );
4569    }
4570
4571    #[test]
4572    fn line_pattern_style_constructor() {
4573        let pattern = checkerboard_2x2();
4574        let style = VectorStyle::line_pattern(6.0, pattern.clone());
4575        assert_eq!(style.render_mode, VectorRenderMode::Line);
4576        assert_eq!(style.stroke_width, 6.0);
4577        assert!(style.line_pattern.is_some());
4578        assert_eq!(style.line_pattern.as_ref().unwrap().width, 2);
4579    }
4580
4581    #[test]
4582    fn line_pattern_style_roundtrips_through_line_style_layer() {
4583        use crate::style::{line_style_with_state, LineStyleLayer, StyleEvalContextFull};
4584        let pattern = checkerboard_2x2();
4585        let mut layer = LineStyleLayer::new("lp", "src");
4586        layer.line_pattern = Some(pattern.clone());
4587        let state = HashMap::new();
4588        let ctx = StyleEvalContextFull::new(5.0, &state);
4589        let vs = line_style_with_state(&layer, &ctx);
4590        assert!(vs.line_pattern.is_some());
4591        assert_eq!(vs.line_pattern.as_ref().unwrap().width, 2);
4592    }
4593
4594    #[test]
4595    fn line_pattern_u_increases_along_line() {
4596        let pattern = checkerboard_2x2();
4597        let style = VectorStyle::line_pattern(4.0, pattern);
4598
4599        // Longer line to get more meaningful distance variation.
4600        let features = FeatureCollection {
4601            features: vec![Feature {
4602                geometry: Geometry::LineString(LineString {
4603                    coords: vec![
4604                        GeoCoord::from_lat_lon(0.0, 0.0),
4605                        GeoCoord::from_lat_lon(0.0, 5.0),
4606                    ],
4607                }),
4608                properties: HashMap::new(),
4609            }],
4610        };
4611        let layer = VectorLayer::new("lp", features, style);
4612        let mesh = layer.tessellate(CameraProjection::WebMercator);
4613
4614        // Body vertices (V=0 or V=1) should have increasing U along the line.
4615        let body_us: Vec<f32> = mesh
4616            .line_pattern_uvs
4617            .iter()
4618            .filter(|uv| (uv[1] - 0.0).abs() < 0.01 || (uv[1] - 1.0).abs() < 0.01)
4619            .map(|uv| uv[0])
4620            .collect();
4621        assert!(
4622            !body_us.is_empty(),
4623            "should have body vertices with pattern UVs"
4624        );
4625        // The max U should be greater than 0 (line has non-zero length).
4626        let max_u = body_us.iter().cloned().fold(0.0_f32, f32::max);
4627        assert!(max_u > 0.0, "max U along line should be > 0, got {max_u}");
4628    }
4629
4630    #[test]
4631    fn heatmap_tessellation_populates_heatmap_points() {
4632        let gc = |lat: f64, lon: f64| rustial_math::GeoCoord::from_lat_lon(lat, lon);
4633        let features = FeatureCollection {
4634            features: vec![Feature {
4635                geometry: Geometry::Point(Point {
4636                    coord: gc(0.0, 0.0),
4637                }),
4638                properties: HashMap::new(),
4639            }],
4640        };
4641        let style = VectorStyle::heatmap([1.0, 0.0, 0.0, 1.0], 20.0, 1.0);
4642        let layer = VectorLayer::new("hm", features, style);
4643        let mesh = layer.tessellate(CameraProjection::WebMercator);
4644        assert!(
4645            !mesh.heatmap_points.is_empty(),
4646            "heatmap tessellation should populate heatmap_points"
4647        );
4648        let pt = &mesh.heatmap_points[0];
4649        assert!(pt[3] > 0.0, "heatmap radius should be positive: {}", pt[3]);
4650    }
4651
4652    #[test]
4653    fn circle_tessellation_populates_circle_instances() {
4654        let gc = |lat: f64, lon: f64| rustial_math::GeoCoord::from_lat_lon(lat, lon);
4655        let features = FeatureCollection {
4656            features: vec![Feature {
4657                geometry: Geometry::Point(Point {
4658                    coord: gc(0.0, 0.0),
4659                }),
4660                properties: HashMap::new(),
4661            }],
4662        };
4663        let style = VectorStyle::circle([0.0, 1.0, 0.0, 1.0], 10.0, [0.0, 0.0, 0.0, 1.0], 2.0);
4664        let layer = VectorLayer::new("cc", features, style);
4665        let mesh = layer.tessellate(CameraProjection::WebMercator);
4666        assert!(
4667            !mesh.circle_instances.is_empty(),
4668            "circle tessellation should populate circle_instances"
4669        );
4670        assert!(
4671            mesh.circle_instances[0].radius > 0.0,
4672            "circle radius should be positive"
4673        );
4674    }
4675}