Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

facett-map/src/gpu/line.rs

//! **Pretty GPU line lane** — the triangle-strip quad upgrade over the raw 1px
//! `LineList` hairlines the original [`draw.wgsl`](super) pipeline emits.
//!
//! The detail-capture dive flagged the GPU line lane as "raw `PrimitiveTopology::
//! LineList` → 1px aliased hairlines, NO width/joins/AA; the CPU fall-through looks
//! BETTER". This module brings the GPU lane up: each polyline segment is expanded to
//! a **triangle-list quad** offset by ±half-width along a per-vertex **miter/bevel
//! join** bisector, projected with the same origin-local high-zoom-safe transform,
//! and shaded with **SDF anti-aliasing** and an optional dark **casing** pass
//! ([`line.wgsl`](super)). Width is **screen-space px** so a road keeps a constant
//! on-screen thickness at any zoom.
//!
//! ## What is NOT changed (the pins the directive set)
//! * The shared [`Vertex`](super::Vertex) (`size_of == 24`, consumed by
//!   facett-map3d) is **untouched** — this lane uses a DEDICATED [`LineVertex`]
//!   (`size_of == 48`).
//! * The existing `LineList` cull + `draw_indirect` pipeline is left intact as the
//!   fallback; this is an additive, self-contained [`LineRenderer`].
//!
//! The join/expansion geometry ([`build_line_geometry`]) is pure CPU and unit-tested
//! (vertex count, perpendicular normals, screen-space width, miter factor); the
//! shader + pipeline are validated by a headless-device render test (below) that
//! also writes the before/after PNGs — the GPU lane cannot run on the plain
//! `egui_kittest` harness (no `GpuMapRenderer` installed), so it is proven here on a
//! real (lavapipe) device instead.
//!
//! ## The inner-miter clamp (GFX_V2 §3.A)
//! A miter join's **convex** corner sticks out along the bisector, unbounded as the
//! turn approaches 180°; [`MITER_LIMIT`] bevels it. Its **concave** corner has the
//! mirror-image problem, and it is the one that was missing: the same offset moves it
//! `hw · miter · sin(τ/2)` **backwards along** its segment, so a segment shorter than
//! the retreat at both its ends has its two concave corners cross, its quad wind
//! backwards, and a bowtie paint over the road. That is the self-intersection GFX_V2
//! §2 credits deck.gl with and lists as our gap.
//!
//! The clamp bounds the retreat to **half the segment's own length** — see
//! [`Join::inner_limit_for`] for the derivation and for the two places it deliberately
//! tightens the §3.A sketch. The bound is baked per vertex into
//! [`LineVertex::inner_lim`] (the 44 → 48 B growth the item was gated on) in
//! **Mercator** units, and applied in the vertex shader, so one ceiling serves every
//! width, zoom and both the casing and fill passes.
//!
//! ### What the fix actually reaches — measured, not assumed
//! It lands in exactly one place, and that place serves **one** lane, not two. An
//! earlier revision of this doc (and the commit that introduced it) claimed the fix
//! reached the 2D and 3D skins at once through GFX_V2 item 6; that is **wrong** and is
//! retracted. Item 6 unified the *view*, not the line geometry. The four polyline
//! writers in and around facett are:
//!
//! | lane | joins | this clamp |
//! |---|---|---|
//! | **this module** — facett-map / facett-geomap GPU 2D basemap | CPU-baked miter/bevel, plus SDF round ENDS since GG #25 | **fixed here** |
//! | `facett-map3d/src/extrude.rs::ribbon_rounded` | ROUND — a raw perpendicular per segment plus an 8-segment fan disc at every vertex | not applicable: a round join cannot overrun a segment |
//! | `facett-core/src/render/gpu/line.wgsl` | none — per-segment capsule SDFs that overlap | not applicable |
//! | epaint's tessellator, via `egui::Shape::line` — the 2D CPU fallback | epaint's own miter | **out of reach**, it is upstream |
//!
//! So the 3D skin has no inner-miter defect to fix (it pays for that with a fan disc per
//! vertex), and the CPU fallback's miter belongs to epaint — `facett-core/src/render/
//! cpu/faces.rs` already records that it explodes on near-antiparallel corners. The
//! honest scope of this item is the GPU 2D vector basemap, which is the lane the
//! `map-wgpu` feature and an installed `GpuMapRenderer` select.
//!
//! Proven by [`applied_screen_pos`] over an angle × width × zoom sweep
//! (`facett-map/tests/inner_miter_clamp.rs`) and on real device pixels
//! (`facett-map/tests/line_gpu.rs`). Both suites re-render with `inner_lim` neutralised
//! to [`NO_INNER_LIMIT`] and require the invariants to FAIL, so neither can pass while
//! the clamp is dead.
//!
//! ## Round ends — caps, and the notch at an acute meet (lane GG #25)
//!
//! Everything above is about the middle of a polyline. Its two ENDS had nothing at all:
//! [`vertex_join`] gave index `0` and `n-1` a plain perpendicular with factor 1, so the
//! quad stopped dead on the perpendicular through the last point. Every cul-de-sac,
//! every stub into a T-junction and every way clipped at a tile edge ended in a **flat
//! bar**, and where two bars met at an angle the union left a wedge of paper.
//!
//! The corners had a second, subtler version of it. [`MITER_LIMIT`] is documented as a
//! bevel and is not one: past the limit the corner is pulled back to `4·hw` **along the
//! bisector**, and at the limit `cos(τ/2) < ¼`, so that point is *under* `hw` from either
//! segment's axis. Both quads therefore converge INWARD at the sharpest corner a way
//! has — the road necks to a waist. That is the audit's "notch at acute meets", and it
//! is a different shape from the missing cap but the same cure.
//!
//! One mechanism serves both: a **half-disc of radius `hw_ext` centred on the vertex**,
//! two triangles, with the fragment's radial position carried in the pair
//! (`side`, `cap_t`) so `fs_main` reads `hw_ext · |(side, cap_t)|` where it used to read
//! `hw_ext · |side|`. The circle is therefore **exact**, not a fan's polygon, and a quad
//! vertex (`cap_t = 0`) takes the identical path. [`Join::round_out`] decides which
//! vertices get one — the two endpoints, a 180° reversal, and a join whose miter was
//! clamped — and nothing else, so a gentle way pays 12 vertices for its two caps and a
//! straight run pays nothing per corner. The tag rides in the sign of `miter`, because
//! [`LineVertex`] is 48 B and a free bit in an existing word is the only room there is.
//!
//! Measured on oden / RTX 4090, 2026-09-05
//! (`line_gpu::a_dead_end_road_ends_in_a_semicircle_not_a_bar`): on a 16 px road the cap
//! profile matches `2·√(hw²−k²)` at 0, 2, 4, 6 and 7 px past the end, covering 88 px
//! there against **0** with the round-end vertices stripped; and on a 176° hairpin
//! (`an_acute_corner_has_no_notch_on_its_convex_side`) 7 of 7 convex probes at 0.75·hw
//! are ink, against 1 of 7 without.

use egui_wgpu::wgpu;

use super::{MapTransform, alloc_buffer, rebase_vertex_origin};
use crate::Way;

/// Miter length cap: past this the join is **beveled** (the miter factor is clamped)
/// so a near-reversal spike can't shoot the corner off to infinity.
///
/// This bounds the **convex** (outer) corner only. The **concave** (inner) corner is
/// bounded by [`NO_INNER_LIMIT`]/[`Join::inner_limit_for`] instead — see the
/// module docs on the inner-miter clamp.
const MITER_LIMIT: f32 = 4.0;

/// "This vertex has no inner-miter ceiling" — the value baked into
/// [`LineVertex::inner_lim`] on a convex corner, an endpoint, a straight join and a
/// 180° reversal.
///
/// A large finite number rather than `f32::INFINITY` so the shader's
/// `inner_lim * merc_to_px` cannot produce a NaN (`inf * 0.0`) at zoom 0; `min()`
/// against it is a no-op for any reachable half-width (the widest stroke facett
/// draws is [`crate::style::LINE_WIDTH_MAX_PX`] = 24 px × [`MITER_LIMIT`] = 96 px).
pub const NO_INNER_LIMIT: f32 = 1.0e30;

/// **The coverage below which a line fragment is DISCARDED instead of drawn** — lane
/// GG #2. Rust twin of `MIN_COVERAGE_ALPHA` in [`LINE_WGSL`], asserted equal by
/// `tests/paint_depth.rs`.
///
/// `0.5/255` — half a step of an 8-bit alpha channel. A fragment under it rounds to
/// nothing in the target, so the only effect it can still have is the **depth** it
/// writes, and that effect is a defect: the quad is rasterised one feather wider than the
/// road, so those invisible fragments formed a ~1 px halo that reserved its pixels and
/// depth-rejected any lower-ranked way drawn afterwards. Not blended away — discarded,
/// leaving the bare pane background: a paper-coloured slit alongside every road.
///
/// Deliberately at the very bottom of the ramp and not a "close enough to zero" value
/// like `0.01`. `coverage_inside` integrates the profile to exactly `2·hw`, which is what
/// makes the drawn width match the CPU stroke; a floor that ate real coverage would
/// narrow every road on the map to fix a one-pixel halo. `tests/line_gpu.rs` and
/// `tests/fill_outline_aa.rs` measure that width and are the guard on it.
pub const MIN_COVERAGE_ALPHA: f32 = 0.5 / 255.0;

/// The pretty-line shader BODY (screen-space width, CPU-baked miter joins, casing).
/// It is NOT a standalone module: the shared maths it calls lives in facett-core's
/// [`COMMON_WGSL`](facett_core::render::gpu::COMMON_WGSL) prelude, which
/// [`facett_core::render::gpu::wgsl`] prepends at pipeline build.
pub const LINE_WGSL: &str = include_str!("line.wgsl");

/// A dedicated vertex for the pretty-line pipeline — **distinct** from the shared
/// [`Vertex`](super::Vertex) (24 B, pinned for facett-map3d). Carries the centreline
/// position plus the per-vertex join data the vertex shader needs to expand the
/// segment into a screen-space-width quad. `size_of == 48`.
///
/// It grew 44 → 48 B for the inner-miter clamp (GFX_V2 §3.A): the fourth word of the
/// second row is [`inner_lim`](Self::inner_lim), the per-vertex adjacent-segment
/// length the clamp needs and the reason the item was deferred.
#[repr(C)]
#[derive(Clone, Copy, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
pub struct LineVertex {
    /// Centreline position, **origin-local** Mercator (`mercator_f64(lonlat) − origin`).
    pub pos: [f32; 2],
    /// Join bisector, a **unit** vector in Mercator space; the shader rotates it into
    /// screen space and offsets by `side * half_px * miter`.
    pub bisector: [f32; 2],
    /// Which side of the centreline this vertex sits on: `+1.0` or `-1.0`.
    pub side: f32,
    /// Half the line width, in **screen px** (so width is zoom-independent) — and, in
    /// its SIGN, **the dash flag** (HO-T3). A width is strictly positive, so every
    /// negative value was unused; a dashed way carries `-half`. Read it through
    /// [`decode_dash`] (Rust) / the two lines at the top of `vs_main` (WGSL), never raw.
    pub half_px: f32,
    /// Miter length factor `1/cos(θ/2)`, clamped to [`MITER_LIMIT`] (bevel fallback).
    pub miter: f32,
    /// **The inner-miter ceiling** (GFX_V2 §3.A) — the largest bisector distance this
    /// vertex may be offset by, in origin-local **Mercator** units.
    ///
    /// [`NO_INNER_LIMIT`] on a convex corner, an endpoint, a straight join and a 180°
    /// reversal; `0.5·L / sin(τ/2)` on the **concave** corner, where `L` is the length
    /// of *this vertex's own segment* and `τ` the turn angle. The shader converts it to
    /// pixels per frame (the ceiling is a property of the polyline, so it is width- and
    /// zoom-independent) and takes `min(half_px·miter, inner_lim·merc_to_px)`.
    ///
    /// See [`Join::inner_limit_for`] for the derivation of the `0.5` and of why the
    /// length is the vertex's **own** segment rather than `min(L_A, L_B)`.
    pub inner_lim: f32,
    /// Premultiplied-friendly RGBA in `0..=1` — except that on a **dashed** vertex
    /// (`half_px < 0`) the fourth component is **the arc length** from the way's first
    /// point to this vertex, in origin-local Mercator units, and the material alpha is
    /// `1.0` by definition (HO-T3). See [`pack_dash_col`].
    pub col: [f32; 4],
}

const _: () = assert!(std::mem::size_of::<LineVertex>() == 48);

// ── THE DASH LANE: arc length in `col.a`, the flag in `half_px`'s sign (HO-T3) ──
//
// GG #28 measured the gap: "`LineVertex` is 48 B with no arc length, `line.wgsl` has no
// dash term". 48 B is load-bearing (compaction stride, `VPV = 12`, chunk partition, bake
// format), so the arc length has to ride in a word that is already there, the way MP-a
// put the depth in `side`'s magnitude and GG #25 put the cap in `miter`'s sign.
//
// Two words have room, and both are needed:
//
// * `half_px` is a HALF-WIDTH and therefore strictly positive — its sign is a free bit.
//   Negative = "this way's core is dashed". `resolved_width_px` and the shader read
//   `abs(half_px)`, so the width itself is untouched. (A `style.width` of exactly 0
//   cannot carry the flag — `-0.0 < 0.0` is false — and draws its 1 px floor solid; no
//   classified rail has width 0.)
// * `col.a` is the material alpha, and for the ways that dash it is a constant `1.0`:
//   a rail is an opaque line on both painters, and `paint_features` refuses a casing to
//   anything translucent, so a dashed translucent way would be two-tone with nothing to
//   show in the gaps anyway. On a dashed vertex the word carries the ARC LENGTH from the
//   way's first point, origin-local Mercator, and the shader restores `a = 1.0`.
//
// So a road's vertex is byte-identical to what it was, and a rail's differs in exactly
// the sign of one word and the value of another. The arc length interpolates across the
// segment quad like any varying; in the vertex stage it is `× merc_to_px_len(zoom)`
// (the same per-frame conversion `inner_lim` uses, so the dash is a property of the WAY
// and pan-stable for free) and in the fragment stage cut by its phase in
// `dash_period_px`. The gaps are DISCARDED in the fill pass — what shows through is the
// casing the previous pass already painted under the whole width, which is why no
// second colour is needed and the casing pass is untouched.
//
// The arc length is accumulated once, at bake, in `LineExpander::push_pts_at_depth_dashed`
// — one add per vertex into the expander's reused scratch, never per frame.

/// Encode a vertex's half-width and its dash flag into the [`LineVertex::half_px`]
/// slot. A dashed way carries `-half`; see the module note above.
#[inline]
#[must_use]
pub fn pack_dash_half_px(half: f32, dash: bool) -> f32 {
    if dash { -half } else { half }
}

/// Decode a [`LineVertex::half_px`] into `(is_dashed, half_px)` — the Rust twin of the
/// two lines at the top of `vs_main`. A road's vertex comes back as `(false, half_px)`.
#[inline]
#[must_use]
pub fn decode_dash(half_px: f32) -> (bool, f32) {
    (half_px < 0.0, half_px.abs())
}

/// The colour word a vertex carries: the material RGBA for an undashed way, and for a
/// dashed way the RGB plus the **arc length** `arc` (origin-local Mercator) in the
/// alpha slot. See the module note above.
#[inline]
#[must_use]
pub fn pack_dash_col(col: [f32; 4], dash: bool, arc: f32) -> [f32; 4] {
    if dash { [col[0], col[1], col[2], arc] } else { col }
}

/// The pretty-line uniform block — the origin-local transform (mirrors
/// [`MapTransform`]) plus the AA/casing controls. A DEDICATED block, so the pinned
/// full [`DrawUniforms`](super::DrawUniforms) (32 B, no free slot) is untouched.
#[repr(C)]
#[derive(Clone, Copy, Debug, bytemuck::Pod, bytemuck::Zeroable)]
pub struct MapLineUniforms {
    pub zoom_x: f32,
    pub zoom_y: f32,
    pub screen_center_x: f32,
    pub screen_center_y: f32,
    pub ref_x: f32,
    pub ref_y: f32,
    pub viewport_w: f32,
    pub viewport_h: f32,
    /// AA edge softness (px).
    pub feather_px: f32,
    /// **Ceiling** on the extra half-width (px) the casing pass adds. The casing
    /// itself is [`casing_ratio`](Self::casing_ratio) of the way's own half-width,
    /// floored at [`casing_min_px`](Self::casing_min_px) — this only stops a very
    /// wide arterial from growing an unbounded shadow.
    pub width_add: f32,
    /// `1.0` → paint `casing_col`; `0.0` → the per-vertex colour.
    pub use_casing: f32,
    /// deck.gl `widthScale` for THIS frame's zoom
    /// ([`line_width_scale`](crate::style::line_width_scale)).
    pub width_scale: f32,
    /// deck.gl `widthMinPixels` / `widthMaxPixels` — the band the resolved width is
    /// clamped into, in the vertex shader, every frame.
    pub width_min_px: f32,
    pub width_max_px: f32,
    /// **The casing's extra half-width as a FRACTION of the way's own half-width.**
    ///
    /// It was a flat `width_add` px for every way, which is a casing that grows
    /// relatively *thicker* the thinner the road: at `width_add = 2.0` a 1 px
    /// residential street drew as a 5 px band of near-black with a 1 px light core,
    /// while a 3 px motorway drew as 7 px with a 3 px core. So the minor road — the
    /// one there are tens of thousands of — read as solid dark ink, and central
    /// Stockholm at z12 was a field of navy hairlines. See `LINE_CASING_RATIO`.
    pub casing_ratio: f32,
    /// Floor (px) on the casing's extra half-width, so the thinnest way still gets a
    /// visible edge rather than none at all.
    pub casing_min_px: f32,
    pub casing_col: [f32; 4],
    /// The 2D ground rotation `[a, b, c, d]` — [`facett_core::render::GroundFrame2d::rot`]
    /// narrowed to `f32`. Identity `[1, 0, 0, 1]` is a north-up map.
    pub rot: [f32; 4],
    /// `cos(tilt)`, applied to the rotated `y` after the rotation. Identity `1.0`.
    pub foreshorten: f32,
    pub _pad2: f32,
    pub _pad3: f32,
    pub _pad4: f32,
}

// ── The uniform layout gate (stinky `seam-16`) ────────────────────────────────
//
// Every other repr(C)↔WGSL pair in this lane is nailed down: `DrawUniforms` by
// `assert!(size_of == 64)`, `LineVertex` by `assert!(size_of == 48)` above,
// `LineVertex`'s word count by `LINE_VERTEX_WORDS`. `MapLineUniforms` — **the only
// uniform block that actually reaches the screen** — had nothing. Its two `vec4`s sit
// at 16-byte-aligned offsets by luck of the field order, and a single `f32` inserted
// before `casing_col` on the Rust side only would push both `vec4`s off their WGSL
// offsets: the casing colour and the ground rotation would read neighbouring garbage,
// the map would draw with a wrong-coloured casing under a wrong rotation, and nothing
// in the tree would go red.
//
// Offsets, not just the total: a size assert alone passes when two fields are SWAPPED,
// which is the same silent garbage. These are the three that carry a `vec4` boundary.
const _: () = assert!(std::mem::size_of::<MapLineUniforms>() == 112);
const _: () = assert!(std::mem::offset_of!(MapLineUniforms, casing_col) == 64);
const _: () = assert!(std::mem::offset_of!(MapLineUniforms, rot) == 80);
const _: () = assert!(std::mem::offset_of!(MapLineUniforms, foreshorten) == 96);

impl MapLineUniforms {
    /// Build from a [`MapTransform`] + viewport size + the AA/casing controls, with the
    /// width band left at the shared [`crate::style`] defaults and a neutral (`1.0`)
    /// width scale — see [`with_width_scale`](Self::with_width_scale) to track zoom.
    pub fn new(t: &MapTransform, viewport_w: f32, viewport_h: f32, feather_px: f32, width_add: f32, casing: Option<[f32; 4]>) -> Self {
        Self {
            zoom_x: t.zoom_x,
            zoom_y: t.zoom_y,
            screen_center_x: t.screen_center_x,
            screen_center_y: t.screen_center_y,
            ref_x: t.ref_x,
            ref_y: t.ref_y,
            viewport_w,
            viewport_h,
            feather_px,
            width_add,
            use_casing: if casing.is_some() { 1.0 } else { 0.0 },
            width_scale: 1.0,
            width_min_px: crate::style::LINE_WIDTH_MIN_PX,
            width_max_px: crate::style::LINE_WIDTH_MAX_PX,
            // The shipped casing law, from ONE place — a test that builds its own
            // uniforms therefore renders the casing the product renders.
            casing_ratio: super::LINE_CASING_RATIO,
            casing_min_px: super::LINE_CASING_MIN_PX,
            casing_col: casing.unwrap_or([0.0, 0.0, 0.0, 0.0]),
            rot: t.rot_f32(),
            foreshorten: t.foreshorten_f32(),
            _pad2: 0.0,
            _pad3: 0.0,
            _pad4: 0.0,
        }
    }

    /// Set the per-frame deck.gl `widthScale` (from
    /// [`line_width_scale`](crate::style::line_width_scale)) so the drawn width tracks
    /// zoom without touching the baked geometry.
    #[must_use]
    pub fn with_width_scale(mut self, scale: f32) -> Self {
        self.width_scale = scale;
        self
    }

    /// Narrow the `widthMinPixels`/`widthMaxPixels` band this uniform clamps into.
    ///
    /// The road band's floor ([`LINE_WIDTH_MIN_PX`](crate::style::LINE_WIDTH_MIN_PX) =
    /// 1 px) exists so a distant motorway stays legible. A filled area's ring stroke
    /// has no such duty — it is 0.5 px on the CPU painter
    /// ([`fill_outline_width_px`](crate::style::fill_outline_width_px)) — and clamping
    /// it up to 1 px would thicken every building footprint in a city viewport. This is
    /// the ONE knob the fill-outline pass turns; everything else about it is the road
    /// lane's own uniform (stinky `mapgpu-1`).
    #[must_use]
    pub fn with_width_band(mut self, min_px: f32, max_px: f32) -> Self {
        self.width_min_px = min_px;
        self.width_max_px = max_px;
        self
    }
}

/// One polyline to expand: an origin-local Mercator centreline, a **screen-px** line
/// width, and an RGBA colour. (`build_line_geometry` input, decoupled from [`Way`] so
/// the join math is testable in isolation.)
#[derive(Clone, Debug)]
pub struct LinePolyline {
    pub pts: Vec<[f32; 2]>,
    pub width_px: f32,
    pub col: [f32; 4],
    /// **The way's paint depth**, `0..=1`, from `paint_order::depth_of` on its rank
    /// (lane MP-a). It lives HERE and not only at the call site because two paths expand
    /// these — the per-way one the GPU lane bakes through and the whole-tier one
    /// `build_line_geometry` uses — and `tests/cull_indirect.rs` asserts they are
    /// **byte-identical**. Passing the depth at one call site and not the other broke
    /// exactly that invariant, and the test caught it.
    pub depth: f32,
    /// **Is this way's core dashed along its arc length?** (HO-T3) — from
    /// [`crate::style::WayStyle::dashed`]; `true` for a [`crate::WayKind::Rail`], `false`
    /// for every road. Rides into the vertex as the sign of `half_px`, and puts the arc
    /// length into `col.a`. See the dash-lane note above [`pack_dash_half_px`].
    pub dash: bool,
}

#[inline]
fn sub(a: [f32; 2], b: [f32; 2]) -> [f32; 2] {
    [a[0] - b[0], a[1] - b[1]]
}

#[inline]
fn normalize(d: [f32; 2]) -> [f32; 2] {
    let l = (d[0] * d[0] + d[1] * d[1]).sqrt();
    if l < 1e-20 { [0.0, 0.0] } else { [d[0] / l, d[1] / l] }
}

/// Left-hand perpendicular of a direction.
#[inline]
fn perp(d: [f32; 2]) -> [f32; 2] {
    [-d[1], d[0]]
}

// ── ROUND ENDS: the cap / joint half-disc (lane GG #25) ───────────────────────
//
// The audit's row: *"line.rs:373-412 miter+bevel only, NO caps (butt ends, notches at
// acute meets)"*. Both halves are one shape — a **half-disc of radius `hw_ext` centred
// on a polyline vertex** — and both are drawn by the SAME six vertices, the same
// pipeline and the same coverage ramp as a segment quad. Nothing here is a second
// expander (LAW 5); it is four more corners out of the one that exists.
//
// # Why a two-triangle SDF and not a fan
//
// `facett-map3d`'s `ribbon_rounded` draws an 8-segment fan disc per vertex — 24
// vertices, and a polygon, so its silhouette is inscribed in the circle it is meant to
// be. Here the fragment stage already owns an exact distance ramp (`coverage_inside`
// over `edge_px`), so a round end only has to hand it the right distance. Two triangles
// covering the half-disc's bounding box, with the 2D offset carried in the two
// interpolants `side` and `cap_t`, give an **exact** circle at 6 vertices:
//
// ```text
//     off = hw_ext · (side · spn + cap_t · sbn)      (screen frame, spn ⟂ sbn)
//     radial distance = hw_ext · |(side, cap_t)|
// ```
//
// and `fs_main` reads `hw_ext · length(vec2(side, cap_t))` where it used to read
// `hw_ext · abs(side)`. A quad vertex carries `cap_t = 0`, so `length` degenerates to
// `abs` and the road's own body is byte-unchanged — ONE formula, not a branch.
//
// # Why the tag rides in `miter`
//
// [`LineVertex`] is 48 B and that is load-bearing (compaction stride, `VPV = 12`, chunk
// partition, bake format), so a round end may not add a field. `miter` is the one word
// with room: [`vertex_join`] never emits below `1.0` and [`MITER_LIMIT`] caps it at
// `4.0`, so **every negative value is unused**. A round-end vertex carries
// `-(1 + cap_t)`. That is the same trick MP-a played on `side`, in the same vertex, for
// the same reason — see [`crate::paint_order::pack_side_depth`].

/// Encode a round-end vertex's `cap_t` (`0` on the disc's flat edge, `1` at its rim)
/// into the [`LineVertex::miter`] slot. See the module note above.
#[inline]
#[must_use]
pub fn round_end_miter(cap_t: f32) -> f32 {
    -(1.0 + cap_t)
}

/// Decode a [`LineVertex::miter`] into `(is_round_end, cap_t, effective_miter)` — the
/// Rust twin of the three lines at the top of `vs_main`. A quad vertex comes back as
/// `(false, 0.0, miter)`, which is exactly what the pre-GG-#25 shader did.
#[inline]
#[must_use]
pub fn decode_round_end(miter: f32) -> (bool, f32, f32) {
    if miter < 0.0 { (true, -miter - 1.0, 1.0) } else { (false, 0.0, miter) }
}

/// Per-vertex join geometry: the (unit, Mercator-space) bisector to offset along, the
/// miter length factor, and which side of the centreline the join's **concave** corner
/// falls on (the one the inner-miter clamp bounds).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Join {
    /// Unit bisector in Mercator space. Always points to the **left** of travel
    /// (it is the mean of the two left-hand segment normals), so which side of the
    /// centreline is concave depends on the turn direction — see [`Self::inner_side`].
    pub bisector: [f32; 2],
    /// Miter length factor `1/cos(τ/2)`, clamped to [`MITER_LIMIT`] (bevel fallback).
    pub miter: f32,
    /// The `side` value (`+1.0` / `-1.0`) of the **concave** corner, or `0.0` when the
    /// join has no concave corner to bound: an endpoint, a straight join, or a 180°
    /// reversal (all of which offset purely perpendicular, so nothing retreats).
    pub inner_side: f32,
    /// `sin(τ/2)` for the turn angle `τ` — the factor that converts a bisector
    /// distance into how far the corner retreats **along** the segment.
    pub sin_half: f32,
    /// **The outward direction of this vertex's ROUND END**, unit and in Mercator
    /// space, or `[0.0, 0.0]` when the vertex needs none — lane GG #25.
    ///
    /// Exactly three joins need one, and each for the same reason: the quads alone
    /// leave the vertex's own neighbourhood uncovered.
    ///
    /// * **an endpoint** — `-d` at the head, `+d` at the tail. The quad stops dead on
    ///   the perpendicular through the last point, which is a butt end. A dead-end road,
    ///   a stub into a junction and every T-meet in the map end in a flat bar.
    /// * **a 180° reversal** — `+d`, the travel direction. `vertex_join` gives a
    ///   reversal the incoming normal and factor 1, so both segments lie on the same
    ///   strip and the hairpin's tip is simply missing.
    /// * **a join whose miter was CLAMPED** (`τ` past [`MITER_LIMIT`]'s bevel point) —
    ///   the **convex** bisector, `bisector · -inner_side`. This is the "notch at acute
    ///   meets": at the clamp the corner sits at `4·hw` along the bisector but only
    ///   `4·hw·cos(τ/2) < hw` from either segment's axis, so both quads pinch INWARD
    ///   and the road necks down to a waist at the sharpest corner it has.
    ///
    /// An ordinary miter join needs nothing: the two quads share the corner point
    /// exactly, which is what a miter *is*.
    pub round_out: [f32; 2],
}

impl Join {
    /// **The inner-miter clamp** (GFX_V2 §3.A) — the ceiling for the vertex on `side`
    /// of this join, given the length of the segment that vertex belongs to.
    ///
    /// # Why a ceiling is needed at all
    /// The concave corner of a miter join does not stick *out*, it retreats *back*: the
    /// bisector offset `D = half_px · miter` moves it `a = D · sin(τ/2)` **backwards
    /// along** the segment (and the convex corner the same distance forwards — that is
    /// the spike [`MITER_LIMIT`] already bevels). A segment's two ends each eat `a`
    /// out of it, so once `a_start + a_end > L` the quad's concave edge runs
    /// **backwards**: the two triangles invert, wind the other way, and paint a bowtie.
    /// `a` grows without bound as `τ → 180°`, so any short segment between two
    /// same-handed sharp turns self-intersects. That is the deck.gl gap GFX_V2 §2 names.
    ///
    /// # The ceiling
    /// Require `a ≤ L/2` at **both** ends, so `a_start + a_end ≤ L` and the concave edge
    /// can at worst collapse to zero length — never reverse. In bisector distance:
    ///
    /// ```text
    ///     D ≤ 0.5 · L / sin(τ/2)
    /// ```
    ///
    /// # Two deliberate deviations from the GFX_V2 §3.A sketch
    /// 1. **The `0.5`.** The sketch's `max_inner = min(L_A,L_B)/cos(θ/2)` permits
    ///    `a = L`, which lets the two ends of one segment each retreat its full length
    ///    and cross — it bounds one join in isolation but not a *pair* sharing a
    ///    segment, which is exactly the staircase case that bowties.
    /// 2. **`L` is this vertex's OWN segment, not `min(L_A, L_B)`.** The expansion emits
    ///    an independent triangle list, so each segment owns its copy of the shared
    ///    corner and they may carry different ceilings. Taking `min(L_A, L_B)` would
    ///    import a short neighbour's ceiling into a long segment, and because clamping
    ///    pulls the corner off the offset line it would **taper the long segment's whole
    ///    width** — a 200 px road thinning to nothing because one 2 px stub joins it.
    ///    Per-segment keeps the deformation inside the segment that is actually too
    ///    short, bounded by `L/2`, and still gives the no-reversal guarantee above
    ///    (both ends of segment `L` are bounded by `L/2`). Measured: with `min(L_A,L_B)`
    ///    the long arm of the staircase draws 1.6 px where the width formula says 6.0.
    #[must_use]
    pub fn inner_limit_for(&self, side: f32, seg_len: f32) -> f32 {
        if self.inner_side == 0.0 || side != self.inner_side {
            return NO_INNER_LIMIT;
        }
        // `sin_half` is only ~0 for a straight join, which sets `inner_side = 0.0` and
        // returns above; the guard keeps the division finite regardless.
        (0.5 * seg_len / self.sin_half.max(1.0e-6)).min(NO_INNER_LIMIT)
    }
}

/// Per-vertex join geometry. At an endpoint this is just the segment normal with
/// factor 1 and no concave corner.
fn vertex_join(pts: &[[f32; 2]], i: usize) -> Join {
    let n = pts.len();
    let flat = |b: [f32; 2], round_out: [f32; 2]| Join {
        bisector: b,
        miter: 1.0,
        inner_side: 0.0,
        sin_half: 0.0,
        round_out,
    };
    if i == 0 {
        // The head cap points BACKWARDS out of the first segment.
        let d = normalize(sub(pts[1], pts[0]));
        return flat(perp(d), [-d[0], -d[1]]);
    }
    if i == n - 1 {
        // The tail cap points FORWARDS out of the last segment.
        let d = normalize(sub(pts[n - 1], pts[n - 2]));
        return flat(perp(d), d);
    }
    let d0 = normalize(sub(pts[i], pts[i - 1]));
    let d1 = normalize(sub(pts[i + 1], pts[i]));
    let n0 = perp(d0);
    let n1 = perp(d1);
    let sum = [n0[0] + n1[0], n0[1] + n1[1]];
    let b = normalize(sum);
    if b == [0.0, 0.0] {
        // ~180° reversal — no meaningful bisector; use the incoming normal. Both
        // segments then lie on ONE strip, so the hairpin's tip is uncovered: the round
        // end faces along the travel direction and closes it.
        return flat(n1, d0);
    }
    // b·n1 = cos(τ/2); the miter length is its reciprocal, clamped for a bevel.
    let cos_half = (b[0] * n1[0] + b[1] * n1[1]).abs();
    let miter = if cos_half < 1.0 / MITER_LIMIT { MITER_LIMIT } else { 1.0 / cos_half };
    // The bisector is the mean of two LEFT normals, so `side = +1` is the left of
    // travel. A LEFT turn (`d0 × d1 > 0`) curves toward the left, making the left side
    // the concave one; a right turn makes it the right side. (GFX_V2 §3.A spells this
    // with `dir_a` pointing AWAY from the joint, which flips the cross product's sign —
    // same rule, opposite convention.)
    let cross = d0[0] * d1[1] - d0[1] * d1[0];
    // sin(τ/2) from cos(τ/2), both non-negative on [0, π].
    let sin_half = (1.0 - cos_half * cos_half).max(0.0).sqrt();
    // A straight join (or a duplicated point, which degenerates to one) retreats by
    // `D · sin(τ/2) ≈ 0` — nothing to bound, and no well-defined handedness either.
    let inner_side = if sin_half <= 1.0e-6 {
        0.0
    } else if cross > 0.0 {
        1.0
    } else {
        -1.0
    };
    // **The notch** (lane GG #25). Below the limit the two quads share the miter point
    // and the join is seamless; AT the limit the corner is pulled back to `4·hw` along
    // the bisector, which is under `hw` away from either segment's axis, so the road
    // necks. The round end fills that waist, on the CONVEX side — the corner on `side`
    // sits at `bisector · side · dist`, so the convex one is `-inner_side`.
    let round_out = if miter >= MITER_LIMIT && inner_side != 0.0 {
        [b[0] * -inner_side, b[1] * -inner_side]
    } else {
        [0.0, 0.0]
    };
    Join { bisector: b, miter, inner_side, sin_half, round_out }
}

/// **The quad expander**, and the only place a polyline becomes [`LineVertex`]es.
///
/// It owns the per-vertex [`Join`] scratch so that buffer is allocated ONCE and reused
/// across every way of a bake instead of once per way. That is the whole reason the
/// type exists — see [`LineExpander::push`] and stinky `mapdraw-5`.
#[derive(Debug, Default)]
pub struct LineExpander {
    joins: Vec<Join>,
    /// **Arc length at each polyline vertex** — origin-local Mercator, `0` at the first
    /// point (HO-T3). Filled beside `joins` for a dashed way only, from the same reused
    /// scratch, so a rail costs no allocation a road does not.
    arcs: Vec<f32>,
}

impl LineExpander {
    /// A fresh expander with no scratch yet.
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    /// Expand ONE polyline's quads onto the end of `out`. A polyline with < 2 points
    /// contributes nothing.
    ///
    /// **This is the one body.** [`build_line_geometry`] is a loop over it, and
    /// `build_gpu_geometry` calls it per way straight into the shared buffer — so the
    /// per-way bake and the whole-slice bake cannot drift (LAW 5), which
    /// `per_way_quad_expansion_concatenates_to_the_whole_tier_expansion` pins
    /// byte-for-byte.
    ///
    /// Nothing here allocates once the scratch and `out` have been sized: `clear`
    /// keeps the join capacity, and `out` is the caller's shared buffer.
    pub fn push(&mut self, out: &mut Vec<LineVertex>, line: &LinePolyline) {
        self.push_at_depth(out, line, line.depth);
    }

    /// [`Self::push`] carrying a **paint depth** (lane MP-a) — see [`Self::push_pts_at_depth`].
    pub fn push_at_depth(&mut self, out: &mut Vec<LineVertex>, line: &LinePolyline, depth: f32) {
        self.push_pts_at_depth_dashed(out, &line.pts, line.width_px, line.col, depth, line.dash);
    }

    /// [`Self::push`] over a BORROWED point run — the actual body.
    ///
    /// The fill lane's ring outlines (stinky `mapgpu-1`) come out of one shared
    /// `Vec<[f32; 2]>` scratch that is refilled per ring, so they have no
    /// [`LinePolyline`] to hand over and must not be forced to mint one: a
    /// `LinePolyline` OWNS its points, and building one per ring would put back
    /// exactly the per-way allocation `mapdraw-5` took out of the road lane.
    /// The depth default is **`1.0`, the FARTHEST plane** — lane GG #1. It was `0.0`,
    /// which reads like a neutral zero and is the exact opposite: against a buffer
    /// cleared to `1.0` under `Less`, `0.0` is the closest plane in the clip volume, so
    /// a caller with no rank to give silently outranked every ranked thing drawn after
    /// it. A default that is wrong can only be wrong in one direction; this one puts an
    /// unranked run BEHIND everything, where the failure is a line you cannot see rather
    /// than a line that erases the map.
    pub fn push_pts(&mut self, out: &mut Vec<LineVertex>, pts: &[[f32; 2]], width_px: f32, col: [f32; 4]) {
        self.push_pts_at_depth(out, pts, width_px, col, 1.0);
    }

    /// [`Self::push_pts`] carrying a **paint depth** in `0..=1` (lane MP-a).
    ///
    /// The depth is packed into each vertex's `side` through
    /// [`crate::paint_order::pack_side_depth`], because `LineVertex` is exactly 48 B and
    /// that size is load-bearing, while `side` is `±1.0` — one bit in a whole `f32`.
    /// `0.0` is the historical value and leaves the geometry byte-identical for every
    /// caller that has no rank to give.
    ///
    /// Note the RAW side is what reaches `inner_limit_for`: the packing is a transport
    /// detail of the vertex, never an input to the miter maths.
    pub fn push_pts_at_depth(
        &mut self,
        out: &mut Vec<LineVertex>,
        pts: &[[f32; 2]],
        width_px: f32,
        col: [f32; 4],
        depth: f32,
    ) {
        self.push_pts_at_depth_dashed(out, pts, width_px, col, depth, false);
    }

    /// [`Self::push_pts_at_depth`] with the **dash flag** (HO-T3) — the one body.
    ///
    /// `dash = true` bakes the arc length from the run's first point into every vertex's
    /// `col.a` and negates `half_px` (see [`pack_dash_half_px`] / [`pack_dash_col`]);
    /// `false` leaves the vertex byte-identical to what every caller got before the dash
    /// lane existed, which `per_way_quad_expansion_concatenates_to_the_whole_tier_expansion`
    /// and `tests/cull_indirect.rs` pin.
    pub fn push_pts_at_depth_dashed(
        &mut self,
        out: &mut Vec<LineVertex>,
        pts: &[[f32; 2]],
        width_px: f32,
        col: [f32; 4],
        depth: f32,
        dash: bool,
    ) {
        if pts.len() < 2 {
            return;
        }
        let half = pack_dash_half_px(width_px * 0.5, dash);
        self.joins.clear();
        self.joins.extend((0..pts.len()).map(|i| vertex_join(pts, i)));
        // The arc length, accumulated ONCE here at bake and never per frame (HO-T3): a
        // running sum of segment lengths into the reused scratch. Only a dashed run pays
        // for it; an undashed run's `arcs` stays empty and is never read.
        self.arcs.clear();
        if dash {
            self.arcs.push(0.0);
            let mut arc = 0.0f32;
            for i in 1..pts.len() {
                let d = sub(pts[i], pts[i - 1]);
                arc += (d[0] * d[0] + d[1] * d[1]).sqrt();
                self.arcs.push(arc);
            }
        }
        let joins = &self.joins;
        let arcs = &self.arcs;
        let arc_at = |k: usize| if dash { arcs[k] } else { 0.0 };
        // Emit two triangles per segment from the four corners. The two vertices a
        // corner contributes are NOT shared between the segments that meet there — this
        // is a triangle list, not a strip — which is what lets each segment carry its
        // OWN inner-miter ceiling (see `Join::inner_limit_for`).
        // Quads plus the round ends this run actually needs — counted, not guessed, so
        // the reserve stays exact and `tests/bake_alloc_budget.rs` keeps seeing one
        // growth per way rather than two.
        let rounds = joins.iter().filter(|j| j.round_out != [0.0, 0.0]).count();
        out.reserve((pts.len() - 1) * 6 + rounds * 6);
        for i in 0..pts.len() - 1 {
            let d = sub(pts[i + 1], pts[i]);
            let seg_len = (d[0] * d[0] + d[1] * d[1]).sqrt();
            let corner = |k: usize| -> [LineVertex; 2] {
                let j = joins[k];
                let vert = |side: f32| LineVertex {
                    pos: pts[k],
                    bisector: j.bisector,
                    // The RAW side feeds the miter maths; the packed one rides the wire.
                    side: crate::paint_order::pack_side_depth(side, depth),
                    half_px: half,
                    miter: j.miter,
                    inner_lim: j.inner_limit_for(side, seg_len),
                    col: pack_dash_col(col, dash, arc_at(k)),
                };
                [vert(1.0), vert(-1.0)]
            };
            let [a_pos, a_neg] = corner(i);
            let [b_pos, b_neg] = corner(i + 1);
            out.extend_from_slice(&[a_pos, a_neg, b_pos]); // tri 1
            out.extend_from_slice(&[a_neg, b_neg, b_pos]); // tri 2
        }
        // ── THE ROUND ENDS, after the quads (lane GG #25) ─────────────────────
        // AFTER, deliberately: the quads' vertex order is what
        // `per_way_quad_expansion_concatenates_to_the_whole_tier_expansion` and
        // `cull_indirect`'s byte-equality pin, and appending leaves every existing
        // index where it was. A polyline with no sharp corner adds exactly two of
        // these (its two caps); a closed ring adds two at its seam, which is a full
        // disc and the reason a ring no longer shows a butt joint where it closes.
        for k in 0..pts.len() {
            let j = joins[k];
            if j.round_out == [0.0, 0.0] {
                continue;
            }
            // A round end sits AT its vertex, so it carries that vertex's arc length: a
            // cap in a lit phase is lit, a cap in a gap shows the casing's own cap.
            Self::push_round_end(out, pts[k], j.round_out, half, pack_dash_col(col, dash, arc_at(k)), depth);
        }
    }

    /// **One round end** — a half-disc of radius `hw_ext` centred on `c`, facing `out`
    /// (unit, Mercator), as two triangles over its bounding box (lane GG #25).
    ///
    /// The disc's shape is not in these four corners at all; it is in the two
    /// interpolants they carry. `side` (±1) runs across `out`'s screen-space
    /// perpendicular and `cap_t` (0 → 1, in the [`round_end_miter`] slot) runs along
    /// `out`, so the fragment stage reads the radial distance straight off
    /// `hw_ext · |(side, cap_t)|` and gets an **exact** circle, not a fan's polygon.
    ///
    /// `cap_t = 0` reproduces a plain quad corner exactly, so the disc's flat edge and
    /// the adjoining segment's end edge are the same two points and there is no seam.
    fn push_round_end(
        out: &mut Vec<LineVertex>,
        c: [f32; 2],
        dir: [f32; 2],
        half: f32,
        col: [f32; 4],
        depth: f32,
    ) {
        let vert = |side: f32, cap_t: f32| LineVertex {
            pos: c,
            bisector: dir,
            side: crate::paint_order::pack_side_depth(side, depth),
            half_px: half,
            miter: round_end_miter(cap_t),
            // A round end has no concave corner to bound — the clamp is a property of
            // a segment's LENGTH and this vertex belongs to no segment.
            inner_lim: NO_INNER_LIMIT,
            col,
        };
        let n_pos = vert(1.0, 0.0);
        let n_neg = vert(-1.0, 0.0);
        let f_pos = vert(1.0, 1.0);
        let f_neg = vert(-1.0, 1.0);
        out.extend_from_slice(&[n_pos, n_neg, f_pos]);
        out.extend_from_slice(&[n_neg, f_neg, f_pos]);
    }
}

/// Expand polylines into a **triangle-list** of [`LineVertex`] (6 vertices per
/// segment) with per-vertex miter/bevel joins. A polyline with < 2 points is skipped.
/// The result is uploaded once and drawn (twice, for casing + fill) by
/// [`LineRenderer`].
///
/// A loop over [`LineExpander::push`] — the one body. A caller expanding MANY
/// polylines into one buffer (the GPU bake) should drive the expander directly, so the
/// result buffer and the join scratch are shared instead of re-allocated per way.
#[must_use]
pub fn build_line_geometry(lines: &[LinePolyline]) -> Vec<LineVertex> {
    let mut out = Vec::new();
    let mut ex = LineExpander::new();
    for line in lines {
        ex.push(&mut out, line);
    }
    out
}

// ── The vertex shader's geometry, on the CPU ──────────────────────────────────

/// Mercator → pixel scale for a **length**, as the inner-miter clamp must read it.
///
/// `min(z)² / max(z)` is exactly `zoom` for the uniform camera every live caller
/// builds ([`MapTransform::new`] is always handed `zoom, zoom`; only
/// [`crate::MapView`]'s non-uniform fit differs). Under a non-uniform fit the
/// Mercator-space bisector and turn angle are no longer the screen-space ones, and the
/// error in `sin(τ/2)` is bounded by the anisotropy ratio `max/min` — dividing the
/// allowance by that ratio absorbs it, so the ceiling stays **conservative** (it can
/// only clamp harder than strictly needed, never looser) instead of letting a
/// stretched view slip a self-intersection through.
#[must_use]
pub fn merc_to_px_scale(zoom_x: f32, zoom_y: f32) -> f32 {
    let lo = zoom_x.min(zoom_y);
    let hi = zoom_x.max(zoom_y).max(1.0e-20);
    lo * lo / hi
}

/// The bisector distance, **in px**, the vertex shader offsets a vertex by: the raw
/// miter, bounded by the inner-miter ceiling converted from Mercator units.
///
/// This is the CPU twin of the two lines that carry `dist` in
/// [`line.wgsl`](LINE_WGSL), and the same arrangement as
/// [`crate::style::line_width_px_scaled`] ↔ the prelude's `resolve_line_width_px`:
/// WGSL cannot call Rust, so the formula is written twice and a real-pixel guard
/// proves they agree — `facett-map/tests/line_gpu.rs::
/// gpu_draws_exactly_the_clamped_join_geometry_the_cpu_model_predicts`.
#[must_use]
pub fn resolve_join_distance_px(hw_px: f32, miter: f32, inner_lim: f32, merc_to_px: f32) -> f32 {
    (hw_px * miter).min(inner_lim * merc_to_px)
}

/// **The GPU lane's on-screen line width, in px** — the ONE line of the vertex path that
/// turns a baked `half_px` into a drawn width: the shader prelude's
/// `resolve_line_width_px(half_px * 2, width_scale, width_min_px, width_max_px)`, which
/// is [`crate::style::line_width_px_scaled`]`(style.width, width_scale)` by
/// construction (`half_px = style.width / 2`). Exposed so a test can hold the GPU lane's
/// width next to the CPU painter's for the same way and the same camera — lane GG #27
/// measured them apart by the whole zoom ramp.
#[doc(hidden)]
#[must_use]
pub fn resolved_width_px(v: &LineVertex, u: &MapLineUniforms) -> f32 {
    // Through `decode_dash`: the SIGN of `half_px` is the dash flag (HO-T3), not a width.
    (decode_dash(v.half_px).1 * 2.0 * u.width_scale).clamp(u.width_min_px, u.width_max_px)
}

/// Where [`line.wgsl`](LINE_WGSL)'s `vs_main` places `v` in **logical screen pixels**.
///
/// The centreline projection delegates to [`MapTransform::project`], i.e. to the
/// unified [`facett_core::render::View`] core (GFX_V2 item 6) — there is no second
/// projection writer here. Only the join offset itself is spelled out, and it is
/// proven against real GPU pixels by the guard named on
/// [`resolve_join_distance_px`].
///
/// Tests use this to assert on the geometry the GPU actually rasterises — triangle
/// winding, corner retreat, drawn width — without reading a framebuffer back for every
/// case in a sweep.
#[must_use]
pub fn applied_screen_pos(v: &LineVertex, t: &MapTransform, u: &MapLineUniforms) -> [f32; 2] {
    let c = t.project(v.pos);
    // Bisector into screen space (per-axis zoom), then normalised so the offset is a
    // pure px distance regardless of map zoom.
    let sb = [v.bisector[0] * t.zoom_x, v.bisector[1] * t.zoom_y];
    let l = (sb[0] * sb[0] + sb[1] * sb[1]).sqrt();
    let sbn = if l > 1.0e-12 { [sb[0] / l, sb[1] / l] } else { [0.0, 0.0] };
    let w_px = resolved_width_px(v, u);
    let hw = w_px * 0.5 + u.width_add;
    // The rasterised quad is expanded by one feather so the AA ramp has fragments to
    // land on (`hw_ext` in the shader); that is the extent the clamp bounds.
    let hw_ext = hw + u.feather_px.max(0.0);
    let (is_round, cap_t, miter) = decode_round_end(v.miter);
    let dist = resolve_join_distance_px(hw_ext, miter, v.inner_lim, merc_to_px_scale(t.zoom_x, t.zoom_y));
    // **The RAW side, not the packed one.** `side` carries `sign · (1 + depth)` since
    // MP-a, so multiplying by it directly stretched every offset by `1 + depth` — a
    // silent 0..100 % widening of the CPU twin that the sweeps could not see because
    // every one of them bakes at `depth = 0`, where the packed value IS ±1. The shader
    // has always unpacked (`select(1.0, -1.0, side_packed < 0.0)`); this line had not.
    let side = crate::paint_order::unpack_side(v.side);
    if is_round {
        // A round end's offset is 2D: `side` across the screen-space perpendicular of
        // the outward direction, `cap_t` along it. See `LineExpander::push_round_end`.
        let spn = [-sbn[1], sbn[0]];
        return [
            c[0] + (spn[0] * side + sbn[0] * cap_t) * dist,
            c[1] + (spn[1] * side + sbn[1] * cap_t) * dist,
        ];
    }
    [c[0] + sbn[0] * side * dist, c[1] + sbn[1] * side * dist]
}

/// Convenience: expand a set of **line ways** (fill-less) into pretty-line geometry,
/// baking each vertex origin-local (`mercator_f64(lonlat) − origin`, the same bake as
/// [`build_gpu_geometry`](super::build_gpu_geometry)) and taking the width from the
/// way's themed `style.width` scaled to px by `px_per_width`.
pub fn build_line_geometry_from_ways<'a>(
    ways: impl Iterator<Item = &'a Way>,
    origin: [f64; 2],
    px_per_width: f32,
) -> Vec<LineVertex> {
    let lines: Vec<LinePolyline> = ways
        .filter(|w| w.fill.is_none() && w.lonlat.len() >= 2)
        .map(|w| {
            let pts: Vec<[f32; 2]> = w
                .lonlat
                .iter()
                .map(|&(lon, lat)| {
                    let (mx, my) = crate::mercator(lon, lat);
                    rebase_vertex_origin(mx, my, origin)
                })
                .collect();
            // RAW themed width (zoom-independent). The deck.gl-style
            // `widthScale`/`widthMinPixels`/`widthMaxPixels` resolution happens in the
            // vertex shader every frame, so this bake must NOT pre-clamp to pixels —
            // otherwise the width freezes at whatever zoom first painted.
            LinePolyline {
                pts,
                width_px: w.style.width * px_per_width,
                col: super::color32_to_f32(w.style.color),
                // The SAME rank the per-way bake uses, so the two expansions stay
                // byte-identical (`tests/cull_indirect.rs`).
                depth: crate::paint_order::depth_of(crate::paint_order::way_rank(w), false),
                dash: w.style.dashed(),
            }
        })
        .collect();
    build_line_geometry(&lines)
}

// ── The GPU pipeline ──────────────────────────────────────────────────────────

/// The pretty-line render pipeline: one triangle-list pass over the expanded
/// [`LineVertex`] buffer, drawn **twice** — a wide dark **casing** first, then the
/// bright fill — with SDF anti-aliasing in the fragment shader. Self-contained
/// (its own pipeline + two uniform buffers), additive to the existing
/// [`GpuMapRenderer`](super::GpuMapRenderer) line lane.
pub struct LineRenderer {
    pipeline: wgpu::RenderPipeline,
    /// **The ROAD draw's uniform PAIR** — `[casing, fill]`, two [`MapLineUniforms`]
    /// back to back in one buffer, which the shader declares as `array<Uniforms, 2>`
    /// and indexes by `instance_index` (GG audit row 21). The casing and the fill used
    /// to be two buffers, two bind groups and two `draw_indirect`s per chunk; they are
    /// now instance 0 and instance 1 of ONE draw. The order guarantee is the same one:
    /// a draw's instances are rasterised in instance order, so every casing in the
    /// chunk still lands before any fill in it.
    u_road: wgpu::Buffer,
    /// **The fill-OUTLINE pass's uniform** (stinky `mapgpu-1`). A filled area's rings
    /// go through this same quad pipeline so they get the SDF coverage ramp the raw
    /// `LineList` could never have, but they must not get the road lane's dark casing
    /// and must not track zoom — an area's edge is a boundary mark, not a road. So it
    /// is a second uniform buffer rather than a second pipeline: `use_casing = 0`,
    /// `width_scale = 1`, and a width band that starts at
    /// [`FILL_OUTLINE_MIN_PX`](crate::style::FILL_OUTLINE_MIN_PX). The same 2-slot
    /// shape as `u_road` (the layout is one), with the outline in BOTH slots; the
    /// outline draw is one instance and reads slot 0.
    u_outline: wgpu::Buffer,
    bind_road: wgpu::BindGroup,
    bind_outline: wgpu::BindGroup,
    /// The uniform bind-group layout, kept so the TILE lane can mint per-tile
    /// uniform pairs ([`Self::tile_uniforms`]) against the same pipeline.
    bgl: wgpu::BindGroupLayout,
}

/// Bytes of one uniform buffer: the `[slot 0, slot 1]` pair the shader declares. Rust
/// twin of `line.wgsl`'s `array<Uniforms, 2>`; a bind group over a smaller buffer is a
/// wgpu validation error at draw time, not a wrong picture.
const UNIFORM_PAIR_BYTES: u64 = 2 * std::mem::size_of::<MapLineUniforms>() as u64;
/// Byte offset of slot 1 (the fill) inside the pair.
const UNIFORM_SLOT1_OFFSET: u64 = std::mem::size_of::<MapLineUniforms>() as u64;

/// **One tile's pretty-line uniforms** — the per-tile twin of the renderer's own
/// `u_road`/`u_outline`, because a tile lane bakes each tile against its OWN origin
/// and the shader transform must therefore be per tile. Owned by the tile-cache
/// entry; written per frame through [`LineRenderer::write_uniforms_to`].
pub struct LineTileUniforms {
    /// The tile's `[casing, fill]` pair — see [`LineRenderer::u_road`].
    pub u_road: wgpu::Buffer,
    /// The tile's fill-outline uniform — see [`LineRenderer::u_outline`].
    pub u_outline: wgpu::Buffer,
    pub bind_road: wgpu::BindGroup,
    pub bind_outline: wgpu::BindGroup,
}

impl LineTileUniforms {
    /// Every byte these uniforms hold on the card — the tile cache's census asks.
    #[must_use]
    pub fn bytes(&self) -> u64 {
        self.u_road.size() + self.u_outline.size()
    }
}

impl LineRenderer {
    pub fn new(device: &wgpu::Device, target_format: wgpu::TextureFormat) -> Self {
        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
            label: Some("facett_map_line"),
            // The map-specific body + facett-core's shared prelude (`px_to_ndc`,
            // `coverage_inside`, `resolve_line_width_px`) — the ONE composer, so this
            // lane's geometry maths is the same text every other facett line shader
            // compiles. See `facett-core/src/render/gpu/common.wgsl`.
            source: wgpu::ShaderSource::Wgsl(
                facett_core::render::gpu::wgsl(LINE_WGSL).into(),
            ),
        });
        let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
            label: Some("line_bgl"),
            entries: &[wgpu::BindGroupLayoutEntry {
                binding: 0,
                visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
                ty: wgpu::BindingType::Buffer {
                    ty: wgpu::BufferBindingType::Uniform,
                    has_dynamic_offset: false,
                    min_binding_size: None,
                },
                count: None,
            }],
        });
        let mk_uniform = |label| {
            alloc_buffer(device, &wgpu::BufferDescriptor {
                label: Some(label),
                size: UNIFORM_PAIR_BYTES,
                usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
                mapped_at_creation: false,
            })
        };
        let u_road = mk_uniform("line_u_road");
        let u_outline = mk_uniform("line_u_outline");
        let mk_bind = |buf: &wgpu::Buffer, label| {
            device.create_bind_group(&wgpu::BindGroupDescriptor {
                label: Some(label),
                layout: &bgl,
                entries: &[wgpu::BindGroupEntry { binding: 0, resource: buf.as_entire_binding() }],
            })
        };
        let bind_road = mk_bind(&u_road, "line_bind_road");
        let bind_outline = mk_bind(&u_outline, "line_bind_outline");

        let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
            label: Some("line_pipeline"),
            layout: Some(&device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
                label: Some("line_pll"),
                bind_group_layouts: &[Some(&bgl)],
                immediate_size: 0,
            })),
            vertex: wgpu::VertexState {
                module: &shader,
                entry_point: Some("vs_main"),
                compilation_options: wgpu::PipelineCompilationOptions::default(),
                buffers: &[Some(wgpu::VertexBufferLayout {
                    array_stride: std::mem::size_of::<LineVertex>() as u64,
                    step_mode: wgpu::VertexStepMode::Vertex,
                    attributes: &[
                        wgpu::VertexAttribute { format: wgpu::VertexFormat::Float32x2, offset: 0, shader_location: 0 },
                        wgpu::VertexAttribute { format: wgpu::VertexFormat::Float32x2, offset: 8, shader_location: 1 },
                        wgpu::VertexAttribute { format: wgpu::VertexFormat::Float32, offset: 16, shader_location: 2 },
                        wgpu::VertexAttribute { format: wgpu::VertexFormat::Float32, offset: 20, shader_location: 3 },
                        wgpu::VertexAttribute { format: wgpu::VertexFormat::Float32, offset: 24, shader_location: 4 },
                        wgpu::VertexAttribute { format: wgpu::VertexFormat::Float32, offset: 28, shader_location: 6 },
                        wgpu::VertexAttribute { format: wgpu::VertexFormat::Float32x4, offset: 32, shader_location: 5 },
                    ],
                })],
            },
            fragment: Some(wgpu::FragmentState {
                module: &shader,
                entry_point: Some("fs_main"),
                compilation_options: wgpu::PipelineCompilationOptions::default(),
                targets: &[Some(wgpu::ColorTargetState {
                    format: target_format,
                    blend: Some(wgpu::BlendState::ALPHA_BLENDING),
                    write_mask: wgpu::ColorWrites::ALL,
                })],
            }),
            primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, ..Default::default() },
            // Lane MP-a: ordering rides in Z so it reaches across submissions.
            depth_stencil: Some(crate::gpu::map_depth_state()),
            // Lane GG #2: 4× with alpha-to-coverage, because THIS is the shader whose
            // partial coverage lives in the fragment alpha. See `crate::gpu::map_msaa_state`.
            multisample: crate::gpu::map_msaa_state(),
            multiview_mask: None,
            cache: None,
        });

        Self { pipeline, u_road, u_outline, bind_road, bind_outline, bgl }
    }

    /// Mint one tile's uniform buffers + binds against this renderer's layout — the
    /// per-tile twin of the buffers `new` builds for the whole-payload path.
    pub fn tile_uniforms(&self, device: &wgpu::Device) -> LineTileUniforms {
        let mk = |label: &str| {
            alloc_buffer(device, &wgpu::BufferDescriptor {
                label: Some(label),
                size: UNIFORM_PAIR_BYTES,
                usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
                mapped_at_creation: false,
            })
        };
        let u_road = mk("line_tile_u_road");
        let u_outline = mk("line_tile_u_outline");
        let bind = |buf: &wgpu::Buffer, label: &str| {
            device.create_bind_group(&wgpu::BindGroupDescriptor {
                label: Some(label),
                layout: &self.bgl,
                entries: &[wgpu::BindGroupEntry { binding: 0, resource: buf.as_entire_binding() }],
            })
        };
        let bind_road = bind(&u_road, "line_tile_bind_road");
        let bind_outline = bind(&u_outline, "line_tile_bind_outline");
        LineTileUniforms { u_road, u_outline, bind_road, bind_outline }
    }

    /// [`Self::write_uniforms`] into EXPLICIT buffers — the one body both the
    /// whole-payload path and the per-tile path write through (LAW #5). `u_road` takes
    /// the `[casing, fill]` pair, `u_outline` the outline in both slots.
    #[allow(clippy::too_many_arguments, reason = "mirrors write_uniforms; one body, two callers")]
    pub fn write_uniforms_to(
        &self,
        queue: &wgpu::Queue,
        u_road: &wgpu::Buffer,
        u_outline: &wgpu::Buffer,
        transform: &MapTransform,
        viewport_w: f32,
        viewport_h: f32,
        casing_px: f32,
        casing_col: [f32; 4],
        feather_px: f32,
        width_scale: f32,
    ) {
        let casing = MapLineUniforms::new(transform, viewport_w, viewport_h, feather_px, casing_px, Some(casing_col))
            .with_width_scale(width_scale);
        let fill =
            MapLineUniforms::new(transform, viewport_w, viewport_h, feather_px, 0.0, None).with_width_scale(width_scale);
        // The fill-OUTLINE pass (stinky `mapgpu-1`): same transform, same feather, same
        // pipeline — but no casing, no zoom scale, and a band that starts where the CPU
        // painter's `fill_outline_width_px` starts. Written HERE and not at a second
        // call site so a tile's outline uniform cannot drift from the whole-payload one.
        let outline =
            MapLineUniforms::new(transform, viewport_w, viewport_h, crate::style::FILL_OUTLINE_FEATHER_PX, 0.0, None)
                .with_width_scale(1.0)
            .with_width_band(crate::style::FILL_OUTLINE_MIN_PX, crate::style::LINE_WIDTH_MAX_PX);
        // Slot 0 is what instance 0 reads, slot 1 what instance 1 reads. The road draw
        // has two instances (casing, fill); the outline draw has one and reads slot 0 —
        // slot 1 is written the same so the buffer never holds a stale second pass.
        queue.write_buffer(u_road, 0, bytemuck::bytes_of(&casing));
        queue.write_buffer(u_road, UNIFORM_SLOT1_OFFSET, bytemuck::bytes_of(&fill));
        queue.write_buffer(u_outline, 0, bytemuck::bytes_of(&outline));
        queue.write_buffer(u_outline, UNIFORM_SLOT1_OFFSET, bytemuck::bytes_of(&outline));
    }

    /// [`Self::draw_indirect`] with an EXPLICIT bind group — the tile lane's draw,
    /// same two instances, same count, per-tile transform.
    pub fn draw_indirect_bound(
        &self,
        pass: &mut wgpu::RenderPass<'_>,
        vbuf: &wgpu::Buffer,
        ibuf: &wgpu::Buffer,
        indirect_buf: &wgpu::Buffer,
        binds: &LineTileUniforms,
    ) -> u64 {
        pass.set_pipeline(&self.pipeline);
        pass.set_bind_group(0, &binds.bind_road, &[]);
        super::draw_chunk(pass, vbuf, ibuf, indirect_buf)
    }

    /// **The fill-outline draw** — ONE pass, not two (stinky `mapgpu-1`).
    ///
    /// A filled area's rings ride the same quad pipeline the roads do, so they get the
    /// same SDF coverage ramp; what they must NOT get is the road lane's casing, which
    /// would put a dark band around every building footprint. So the casing pass is
    /// simply not submitted — the chunk's args carry `instance_count = 1`
    /// (`OUTLINE_INSTANCES`), so the draw is the same command as the road's with its
    /// second instance absent — and it is why this is a method rather than a
    /// `draw_indirect` call with a different bind group.
    ///
    /// `binds` selects which uniform the pass reads: the renderer's own
    /// ([`Self::outline_bind`]) for the whole-payload path, a tile's
    /// [`LineTileUniforms::bind_outline`] for the tile lane.
    pub fn draw_indirect_outline(
        &self,
        pass: &mut wgpu::RenderPass<'_>,
        vbuf: &wgpu::Buffer,
        ibuf: &wgpu::Buffer,
        indirect_buf: &wgpu::Buffer,
        binds: &wgpu::BindGroup,
    ) -> u64 {
        pass.set_pipeline(&self.pipeline);
        pass.set_bind_group(0, binds, &[]);
        super::draw_chunk(pass, vbuf, ibuf, indirect_buf)
    }

    /// This renderer's own fill-outline bind group — the whole-payload path's argument
    /// to [`Self::draw_indirect_outline`].
    pub fn outline_bind(&self) -> &wgpu::BindGroup {
        &self.bind_outline
    }

    /// Update both uniform buffers for a frame: the casing pass (wide, dark) and the
    /// fill pass (the per-vertex colour). `casing_px` is the extra half-width of the
    /// casing; `casing_col` its colour; `feather_px` the AA softness.
    #[allow(clippy::too_many_arguments)] // public signature hosts call: kept as is
    pub fn write_uniforms(
        &self,
        queue: &wgpu::Queue,
        transform: &MapTransform,
        viewport_w: f32,
        viewport_h: f32,
        casing_px: f32,
        casing_col: [f32; 4],
        feather_px: f32,
        width_scale: f32,
    ) {
        self.write_uniforms_to(
            queue,
            &self.u_road,
            &self.u_outline,
            transform,
            viewport_w,
            viewport_h,
            casing_px,
            casing_col,
            feather_px,
            width_scale,
        );
    }

    /// Two-INSTANCE draw into an active render pass: instance 0 is the dark casing (so
    /// it sits UNDER as a wider outline), instance 1 the bright fill on top. `vbuf` is
    /// the [`build_line_geometry`] output; `vcount` its length. The direct (non-culled,
    /// non-indexed) twin of [`Self::draw_indirect`], for the device oracles in
    /// `tests/line_gpu.rs`.
    pub fn draw(&self, pass: &mut wgpu::RenderPass<'_>, vbuf: &wgpu::Buffer, vcount: u32) {
        pass.set_pipeline(&self.pipeline);
        pass.set_vertex_buffer(0, vbuf.slice(..));
        pass.set_bind_group(0, &self.bind_road, &[]);
        pass.draw(0..vcount, 0..2);
    }

    /// [`Self::draw`], but the vertex count is read **by the GPU** out of
    /// `indirect_buf` (GFX_V2 item 2).
    ///
    /// `vbuf` is the chunk's geometry, `ibuf` the cull's index list — the survivors of
    /// this frame's frustum + LOD test, compacted in bake order — and `indirect_buf`
    /// holds the `DrawIndexedIndirectArgs` whose `index_count` the cull's scan pass
    /// stored. The CPU never learns how many roads survived, so there is no readback
    /// and no stall: the count is produced and consumed entirely on the device.
    ///
    /// Both instances read the SAME count from the SAME args, so the casing can never
    /// outline a different set of roads than the fill paints.
    ///
    /// # This is not a second draw PER WAY, and that is lane GG #26
    ///
    /// The audit reads the two `draw_indirect` calls below as *"casing = second draw of
    /// the same geometry per way … casings of one class do not merge at intersections
    /// (Garmin does)"* and asks for a whole-class casing pass with a stencil. **Measured
    /// on oden / RTX 4090, 2026-09-05, and the premise does not hold.**
    ///
    /// The unit here is the CHUNK, not the way: pass 1 lays down every casing in the
    /// chunk before pass 2 lays down any fill in it, so inside a chunk the casings of
    /// everything drawn together already merge. Across chunks and tiles the merge is
    /// carried by Z rather than by order — a casing rides at
    /// `depth_of(rank) + CASING_DEPTH_BIAS` and its fill at `depth_of(rank)`, the compare
    /// is `LessEqual` (GG #3), and two ways of ONE class have ONE rank. So a later way's
    /// casing is depth-rejected exactly where any earlier way's fill of that class has
    /// already written, and a higher class's casing still cuts a lower one. That is the
    /// Garmin rule, both halves of it.
    ///
    /// `tests/gpu_lane_paint_order.rs::casings_of_one_class_merge_at_a_t_junction_and_a_
    /// higher_class_still_cuts` is the pixel evidence: on a T-junction of two
    /// residentials the stub lays **0** dark columns across the through road's fill —
    /// in one tile AND in two, where the stub's casing is submitted after the through
    /// road's fill and only the equal rank can reject it — while a motorway stub on the
    /// same row lays **4**.
    ///
    /// The stencil pass was therefore NOT taken, and not only because it is unnecessary:
    /// it would put the class's casings in their own pass, and `coverage_mask`'s
    /// low-bits-first nesting (GG #2) rests on a road's casing and its own fill being
    /// drawn back to back at one bias apart so the fill's samples are a subset of the
    /// casing's. Splitting them is exactly the "pattern that varied per pass" that
    /// comment warns sparkles along every road edge.
    ///
    /// # One command, two instances (GG audit row 21)
    ///
    /// The two passes above are now the two INSTANCES of one `draw_indexed_indirect`:
    /// the chunk's args carry `instance_count = 2` (`ROAD_INSTANCES`), `line.wgsl` reads
    /// `u2[instance_index]` — slot 0 the casing uniform, slot 1 the fill's — and the
    /// device rasterises every primitive of instance 0 before any of instance 1, which
    /// is the order the two draws had. Nothing GG #26 measured moves: the casing/fill
    /// split is still per chunk, the depth bias still sits in the casing uniform, and
    /// GG #2's back-to-back nesting is now enforced by the hardware's instance order
    /// rather than by two adjacent commands. `tests/draw_count.rs` holds the command
    /// count at one per chunk per stream; the paint-order pixel oracles hold the picture.
    pub fn draw_indirect(
        &self,
        pass: &mut wgpu::RenderPass<'_>,
        vbuf: &wgpu::Buffer,
        ibuf: &wgpu::Buffer,
        indirect_buf: &wgpu::Buffer,
    ) -> u64 {
        pass.set_pipeline(&self.pipeline);
        pass.set_bind_group(0, &self.bind_road, &[]);
        super::draw_chunk(pass, vbuf, ibuf, indirect_buf)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn line_vertex_is_a_dedicated_type_not_the_pinned_24b_vertex() {
        // The shared Vertex is pinned at 24B for facett-map3d; the pretty-line lane
        // uses its own 48B vertex (44 B before the inner-miter clamp added
        // `inner_lim`). This locks that they are DISTINCT.
        assert_eq!(std::mem::size_of::<super::super::Vertex>(), 24, "shared Vertex stays 24B");
        assert_eq!(std::mem::size_of::<LineVertex>(), 48, "LineVertex is the dedicated type");
    }

    #[test]
    fn quad_expansion_emits_six_vertices_per_segment() {
        // A 4-point polyline → 3 segments → 3 quads → 18 triangle-list vertices.
        let line = LinePolyline {
            pts: vec![[0.0, 0.0], [1.0, 0.0], [2.0, 0.0], [3.0, 0.0]],
            width_px: 4.0,
            col: [1.0, 1.0, 1.0, 1.0],
            depth: 0.0,
            dash: false,
        };
        let verts = build_line_geometry(&[line]);
        // Plus the two ROUND ENDS (lane GG #25): 6 vertices each, one per open END, and
        // NOT one per corner — this run is straight, so no join needs a disc.
        let rounds = verts.iter().filter(|v| decode_round_end(v.miter).0).count();
        assert_eq!(verts.len() - rounds, 6 * 3, "6 quad vertices per segment × 3 segments");
        assert_eq!(rounds, 6 * 2, "6 round-end vertices per cap × 2 caps, and none at a straight join");
        // A single-point (degenerate) polyline is skipped.
        assert!(build_line_geometry(&[LinePolyline { pts: vec![[0.0, 0.0]], width_px: 2.0, col: [0.0; 4], depth: 0.0, dash: false }]).is_empty());
    }

    /// **A round end is a HALF-DISC in the right place, and only where one is needed** —
    /// lane GG #25 on the data, beside the device oracles in `tests/line_gpu.rs`.
    #[test]
    fn round_ends_land_at_the_two_caps_and_at_a_clamped_join_only() {
        let round_dirs = |pts: Vec<[f32; 2]>| -> Vec<[f32; 2]> {
            let v = build_line_geometry(&[LinePolyline { pts, width_px: 4.0, col: [1.0; 4], depth: 0.0, dash: false }]);
            // One direction per DISC, not per vertex: the six share it.
            v.as_chunks::<6>().0.iter()
                .filter(|c| decode_round_end(c[0].miter).0)
                .map(|c| c[0].bisector)
                .collect()
        };
        // Straight: two caps, pointing OUT of the run at each end.
        let straight = round_dirs(vec![[0.0, 0.0], [10.0, 0.0], [20.0, 0.0]]);
        assert_eq!(straight, vec![[-1.0, 0.0], [1.0, 0.0]], "a straight run gets exactly its two caps");
        // A gentle 90° bend is a clean miter — the two quads share the corner point, so
        // the corner needs nothing and only the caps appear.
        assert_eq!(round_dirs(vec![[0.0, 0.0], [10.0, 0.0], [10.0, 10.0]]).len(), 2, "a 90° miter needs no disc");
        // A hairpin past MITER_LIMIT gets a third, on the CONVEX side.
        // Discs come out in vertex order, so [0] is the head cap, [1] the joint at the
        // corner and [2] the tail cap.
        let hairpin = round_dirs(vec![[-10.0, 0.0], [0.0, 0.0], [-10.0, 0.4]]);
        assert_eq!(hairpin.len(), 3, "a clamped join adds a round joint to the two caps: {hairpin:?}");
        assert!(hairpin[1][0] > 0.9, "the joint faces the CONVEX side (+x for this left hairpin): {:?}", hairpin[1]);
        // The disc's six vertices are the four corners of its bounding box, twice over:
        // two at `cap_t = 0` (the flat edge, which coincides with the quad's own end
        // edge) and two at `cap_t = 1` (the rim).
        let v = build_line_geometry(&[LinePolyline { pts: vec![[0.0, 0.0], [10.0, 0.0]], width_px: 4.0, col: [1.0; 4], depth: 0.0, dash: false }]);
        let disc = &v[6..12];
        let ts: Vec<f32> = disc.iter().map(|x| decode_round_end(x.miter).1).collect();
        assert_eq!(ts, vec![0.0, 0.0, 1.0, 0.0, 1.0, 1.0], "two triangles over the half-disc's box");
        assert!(disc.iter().all(|x| x.inner_lim == NO_INNER_LIMIT), "a disc belongs to no segment, so it has no ceiling");
        assert!(disc.iter().all(|x| x.pos == [0.0, 0.0]), "every vertex of the head cap sits on the head vertex");
    }

    #[test]
    fn straight_segment_offsets_perpendicular_at_full_width() {
        // A horizontal segment: the bisector must be the vertical normal (±y), the
        // two sides opposite, the miter 1, and the width carried as half of width_px.
        let line = LinePolyline { pts: vec![[0.0, 0.0], [10.0, 0.0]], width_px: 6.0, col: [0.2, 0.4, 0.8, 1.0], depth: 0.0, dash: false };
        let v = build_line_geometry(&[line]);
        // First two vertices are the two sides at the start vertex.
        assert_eq!(v[0].bisector, [0.0, 1.0], "bisector perpendicular to a horizontal segment");
        assert_eq!(v[0].side, 1.0);
        assert_eq!(v[1].side, -1.0, "the paired vertex sits on the opposite side");
        assert_eq!(v[0].half_px, 3.0, "half of width_px");
        assert_eq!(v[0].miter, 1.0, "an endpoint has no miter lengthening");
    }

    #[test]
    fn a_right_angle_join_lengthens_the_miter() {
        // An L-bend (90° turn): the interior vertex's miter factor = 1/cos(45°) ≈
        // 1.414 (so the outer corner reaches the true miter point, not a gap).
        let line = LinePolyline { pts: vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0]], width_px: 2.0, col: [1.0; 4], depth: 0.0, dash: false };
        let v = build_line_geometry(&[line]);
        // The interior vertex is the second centreline vertex → strip index 2..4,
        // i.e. the 3rd emitted quad-corner references it. Find a vertex at (1,0)
        // that is NOT an endpoint by its non-trivial miter.
        let interior_miter = v.iter().find(|x| x.pos == [1.0, 0.0]).map(|x| x.miter).unwrap();
        assert!((interior_miter - std::f32::consts::SQRT_2).abs() < 1e-3, "90° miter ≈ √2, got {interior_miter}");
    }

    #[test]
    fn an_endpoint_and_the_convex_corner_carry_no_inner_ceiling() {
        // Only the CONCAVE corner is bounded. A left turn curves toward the left, so
        // `side = +1` is concave and `side = -1` (the outer miter, bounded by
        // MITER_LIMIT) must be left alone — otherwise the clamp would eat the very
        // miter point it exists to protect.
        let line = LinePolyline { pts: vec![[0.0, 0.0], [10.0, 0.0], [10.0, 10.0]], width_px: 2.0, col: [1.0; 4], depth: 0.0, dash: false };
        let v = build_line_geometry(&[line]);
        // Endpoints: both sides unbounded.
        for e in v.iter().filter(|x| x.pos == [0.0, 0.0] || x.pos == [10.0, 10.0]) {
            assert_eq!(e.inner_lim, NO_INNER_LIMIT, "an endpoint has no join to bound");
        }
        let corner: Vec<&LineVertex> = v.iter().filter(|x| x.pos == [10.0, 0.0]).collect();
        assert!(!corner.is_empty(), "the interior corner is emitted");
        for c in corner {
            if c.side > 0.0 {
                assert!(c.inner_lim < NO_INNER_LIMIT, "the concave (left-turn, +1) corner IS bounded");
            } else {
                assert_eq!(c.inner_lim, NO_INNER_LIMIT, "the convex corner keeps its full miter");
            }
        }
    }

    #[test]
    fn the_concave_side_follows_the_turn_direction() {
        // Mirror the polyline: a RIGHT turn must bound `side = -1` instead. A clamp
        // that hard-coded one side would pass the left-turn test above and silently
        // eat the outer miter of every right turn.
        let right = LinePolyline { pts: vec![[0.0, 0.0], [10.0, 0.0], [10.0, -10.0]], width_px: 2.0, col: [1.0; 4], depth: 0.0, dash: false };
        let v = build_line_geometry(&[right]);
        for c in v.iter().filter(|x| x.pos == [10.0, 0.0]) {
            if c.side < 0.0 {
                assert!(c.inner_lim < NO_INNER_LIMIT, "the concave (right-turn, −1) corner IS bounded");
            } else {
                assert_eq!(c.inner_lim, NO_INNER_LIMIT, "the convex corner keeps its full miter");
            }
        }
    }

    #[test]
    fn each_segment_carries_its_own_ceiling_not_the_shorter_neighbours() {
        // A long arm meeting a SHORT stub at a sharp corner. The corner contributes two
        // vertices to each segment, and the long arm's copy must be bounded by the LONG
        // length — `min(L_A, L_B)` would import the stub's ceiling and taper the whole
        // arm (see `Join::inner_limit_for`).
        let line = LinePolyline { pts: vec![[0.0, 0.0], [200.0, 0.0], [196.0, 3.0]], width_px: 6.0, col: [1.0; 4], depth: 0.0, dash: false };
        let v = build_line_geometry(&[line]);
        // The first 6 vertices are the long segment's two triangles; the next 6 the stub.
        let long_corner = v[..6].iter().find(|x| x.pos == [200.0, 0.0] && x.inner_lim < NO_INNER_LIMIT);
        let stub_corner = v[6..].iter().find(|x| x.pos == [200.0, 0.0] && x.inner_lim < NO_INNER_LIMIT);
        let long_lim = long_corner.expect("the long arm's concave corner is bounded").inner_lim;
        let stub_lim = stub_corner.expect("the stub's concave corner is bounded").inner_lim;
        // 200 vs 5 in length → the ceilings differ by the same ratio (same τ).
        let ratio = long_lim / stub_lim;
        assert!(
            (ratio - 40.0).abs() < 0.5,
            "the long arm's ceiling is its OWN length's, not the stub's — got ratio {ratio:.3}, want 200/5 = 40"
        );
    }

    #[test]
    fn a_hairpin_join_is_beveled_not_infinite() {
        // A near-reversal would send an un-clamped miter toward infinity; it must be
        // capped at MITER_LIMIT (the bevel fallback).
        let line = LinePolyline { pts: vec![[0.0, 0.0], [1.0, 0.0], [0.01, 0.02]], width_px: 2.0, col: [1.0; 4], depth: 0.0, dash: false };
        let v = build_line_geometry(&[line]);
        let m = v.iter().find(|x| x.pos == [1.0, 0.0]).map(|x| x.miter).unwrap();
        assert!(m <= MITER_LIMIT + 1e-4, "the sharp join is clamped to a bevel, got {m}");
    }
}