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