hephaestus 0.1.0

Backend-agnostic 2D scene renderer for data visualization.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
//! Panel chrome — the visuals **inside** the plotting area, shared
//! by every projection.
//!
//! Every projection's panel chrome has the same structure, drawn in
//! this order so geoms paint on top:
//!
//! 1. **Background** fill, bounded by the panel outline.
//! 2. **Minor grid lines**, one set per channel (x / y for Cartesian,
//!    theta / radius for Polar) at each `scale.minor_breaks()` position.
//! 3. **Major grid lines**, same shape at each `scale.breaks()` position.
//! 4. **Panel outline stroke**, the boundary of the plotting area.
//!
//! The projection contributes the geometry — what the outline path
//! looks like, what a "grid line" is for each channel — via the
//! `panel_outline_path` / `channel_grid_path` free functions
//! below. The drawing order, styling, and scale-break iteration are
//! shared across all projections.

use crate::brush::Brush;
use crate::geometry::Shape as _;
use crate::geometry::{Affine, Point, Rect};
use crate::path::{FillRule, Path};
use crate::pick::PickId;
use crate::plot::chrome::linear_axis::{stroke_from_line_element, stroke_from_rect_border};
use crate::plot::projection::{PolarEdgeStyle, PolarProjection, Projection};
use crate::plot::scale::Scale;
use crate::plot::theme::{LineElement, RectElement, Theme};
use crate::primitives::{
    annular_wedge, arc, circle, polygon, polyline, segment, wedge, PolygonOptions, PolylineOptions,
};
use crate::scales::breaks::DEFAULT_BREAK_COUNT;
use crate::scales::value::Value;
use crate::scene::SceneBuilder;

// ─── Entry point ────────────────────────────────────────────────────────────

/// Channels the projection consumes, in order. Channel 0 is x for
/// Cartesian, theta for Polar; channel 1 is y / radius. Passed in as
/// the scales bound to each channel — either may be `None`, in which
/// case the corresponding grid set is skipped.
pub(crate) struct PanelScales<'a> {
    pub channel_0: Option<&'a Scale>,
    pub channel_1: Option<&'a Scale>,
}

/// Draw the in-panel chrome: background fill, minor + major grid
/// lines for each channel, panel outline stroke. Drawn before the
/// geoms so they paint on top. Every visual element is sourced from
/// `theme` — `Element::Blank` skips that piece of chrome entirely.
pub(crate) fn draw_panel_chrome(
    scene: &mut dyn SceneBuilder,
    projection: &Projection,
    panel: Rect,
    scales: PanelScales<'_>,
    dpi: f64,
    theme: &Theme,
) {
    if panel.x1 <= panel.x0 || panel.y1 <= panel.y0 {
        return;
    }
    let corner_radius_px = panel_corner_radius_px(theme, dpi);
    let outline_path = panel_outline_path(
        projection,
        panel,
        corner_radius_px,
        scales.channel_0,
        scales.channel_1,
    );

    // Background fill — sourced from theme.panel_background.
    // Custom projections may emit a multi-ring path (holes); EvenOdd
    // gives the right unfilled-interior behaviour regardless of ring
    // winding direction, where NonZero depends on clipper2's output
    // convention being preserved through the y-flip and would
    // sometimes fill the hole. Cartesian / Polar paths are
    // single-ring (or annular with opposite-wound inner ring) and
    // render the same under either rule, so EvenOdd is safe across
    // the board.
    let bg_fill_rule = match projection {
        Projection::Custom(_) => FillRule::EvenOdd,
        _ => FillRule::NonZero,
    };
    if let Some(bg) = theme.panel_background.as_set() {
        fill_rect_element(scene, bg, &theme.palette, &outline_path, bg_fill_rule);
    }

    // Grid lines, per channel. Wrapped in a `push_layer` clip so
    // gridlines whose natural extent overshoots the panel edge
    // (Cartesian: a vertical line spans the full panel height; the
    // rounded corner trims its top + bottom — polar: radial spokes
    // cross the corner rounding) get cropped to the panel's actual
    // boundary. Minors drawn first so majors layer on top at
    // coincident fractions.
    scene.push_layer(
        crate::blend::BlendMode::default(),
        1.0,
        crate::geometry::Affine::IDENTITY,
        &outline_path,
    );
    let major_0 = theme.panel_grid_major.resolve(0);
    let minor_0 = theme.panel_grid_minor.resolve(0);
    let major_1 = theme.panel_grid_major.resolve(1);
    let minor_1 = theme.panel_grid_minor.resolve(1);

    if let Some(c) = projection.as_custom() {
        // Custom projections supply graticules directly — skip the
        // per-break loop. Each bucket is pre-clipped against the
        // trimmed outline via clipper2 before stroking, so the
        // boundary is exact even if the user's polylines extend past
        // the polygon. Minors drawn first so majors layer on top.
        draw_custom_graticules(
            scene,
            c,
            panel,
            scales.channel_0,
            scales.channel_1,
            major_0,
            minor_0,
            major_1,
            minor_1,
            &theme.palette,
            dpi,
        );
    } else {
        if let Some(scale) = scales.channel_0 {
            draw_grid_lines(
                scene,
                scale,
                |frac| channel_grid_path(projection, panel, 0, frac),
                major_0,
                minor_0,
                &theme.palette,
                dpi,
            );
        }
        if let Some(scale) = scales.channel_1 {
            draw_grid_lines(
                scene,
                scale,
                |frac| channel_grid_path(projection, panel, 1, frac),
                major_1,
                minor_1,
                &theme.palette,
                dpi,
            );
        }
    }
    scene.pop_layer();

    // Panel outline.
    if let Some(border) = theme.panel_border.as_set() {
        stroke_rect_element_border(scene, border, &theme.palette, &outline_path, dpi);
    }
}

fn fill_rect_element(
    scene: &mut dyn SceneBuilder,
    rect: &RectElement,
    palette: &crate::plot::theme::Palette,
    path: &Path,
    fill_rule: FillRule,
) {
    // `rect.fill` is `Option<ThemeColor>` — `None` after cascade
    // means an explicitly transparent interior (no fill drawn).
    let Some(fill) = rect.fill.clone() else {
        return;
    };
    let brush = Brush::Solid(fill.resolve(palette));
    scene.fill(
        fill_rule,
        Affine::IDENTITY,
        &brush,
        None,
        path,
        PickId::Skip,
    );
}

fn stroke_rect_element_border(
    scene: &mut dyn SceneBuilder,
    rect: &RectElement,
    palette: &crate::plot::theme::Palette,
    path: &Path,
    dpi: f64,
) {
    use crate::plot::theme::rect_concrete_defaults;
    let defaults = rect_concrete_defaults();
    let lw = rect
        .linewidth_pt
        .or(defaults.linewidth_pt)
        .expect("rect linewidth default");
    if lw.resolve(1.0) <= 0.0 {
        return;
    }
    let stroke = stroke_from_rect_border(rect, dpi);
    let color = rect
        .color
        .clone()
        .or(defaults.color)
        .expect("rect color default");
    let brush = Brush::Solid(color.resolve(palette));
    scene.stroke(&stroke, Affine::IDENTITY, &brush, None, path, PickId::Skip);
}

/// Iterate the scale's minor and major breaks, stroking each
/// returned path with the appropriate brush. Minors first so a
/// major painted at the same frac wins on top. `major` / `minor`
/// are optional theme elements — `None` (Blank or unresolved)
/// suppresses that level entirely.
#[allow(clippy::too_many_arguments)]
fn draw_grid_lines<F>(
    scene: &mut dyn SceneBuilder,
    scale: &Scale,
    mut path_at: F,
    major: Option<&LineElement>,
    minor: Option<&LineElement>,
    palette: &crate::plot::theme::Palette,
    dpi: f64,
) where
    F: FnMut(f64) -> Option<Path>,
{
    use crate::plot::theme::line_concrete_defaults;
    let line_defaults = line_concrete_defaults();
    let resolve_color = |el: &LineElement| {
        let c = el
            .color
            .clone()
            .or_else(|| line_defaults.color.clone())
            .expect("line color default");
        Brush::Solid(c.resolve(palette))
    };
    let minor_resolved = minor.map(|el| (stroke_from_line_element(el, dpi), resolve_color(el)));
    let major_resolved = major.map(|el| (stroke_from_line_element(el, dpi), resolve_color(el)));

    if let Some((stroke, brush)) = &minor_resolved {
        for v in scale.minor_breaks(DEFAULT_BREAK_COUNT) {
            if matches!(v, Value::Null) {
                continue;
            }
            let frac = match scale.map_break(&v).as_number() {
                Some(f) if f.is_finite() && (0.0..=1.0).contains(&f) => f,
                _ => continue,
            };
            if let Some(path) = path_at(frac) {
                scene.stroke(stroke, Affine::IDENTITY, brush, None, &path, PickId::Skip);
            }
        }
    }
    if let Some((stroke, brush)) = &major_resolved {
        for v in scale.breaks(DEFAULT_BREAK_COUNT) {
            if matches!(v, Value::Null) {
                continue;
            }
            let frac = match scale.map_break(&v).as_number() {
                Some(f) if f.is_finite() && (0.0..=1.0).contains(&f) => f,
                _ => continue,
            };
            if let Some(path) = path_at(frac) {
                scene.stroke(stroke, Affine::IDENTITY, brush, None, &path, PickId::Skip);
            }
        }
    }
}

/// Draw the four user-supplied graticule buckets for a custom
/// projection. Each bucket is resolved through the bound scales,
/// clipped against the trimmed outline polygon via clipper2, and
/// stroked through the matching theme element. Minor first, major
/// after, so coincident lines layer correctly.
#[allow(clippy::too_many_arguments)]
fn draw_custom_graticules(
    scene: &mut dyn SceneBuilder,
    c: &crate::plot::projection::CustomProjection,
    panel: Rect,
    x_scale: Option<&Scale>,
    y_scale: Option<&Scale>,
    major_x: Option<&LineElement>,
    minor_x: Option<&LineElement>,
    major_y: Option<&LineElement>,
    minor_y: Option<&LineElement>,
    palette: &crate::plot::theme::Palette,
    dpi: f64,
) {
    // Resolve the trimmed outline rings once — used as the clip
    // polygon for every graticule bucket below.
    let outline_rings_frac = c.resolved_outline_fracs(x_scale, y_scale);
    if outline_rings_frac.is_empty() {
        return;
    }
    let panel_w = panel.x1 - panel.x0;
    let panel_h = panel.y1 - panel.y0;
    let to_px = |xf: f64, yf: f64| Point::new(panel.x0 + xf * panel_w, panel.y1 - yf * panel_h);
    let outline_rings_px: Vec<Vec<Point>> = outline_rings_frac
        .iter()
        .map(|ring| ring.iter().map(|(xf, yf)| to_px(*xf, *yf)).collect())
        .collect();
    let outline_refs: Vec<&[Point]> = outline_rings_px.iter().map(|r| r.as_slice()).collect();

    use crate::plot::theme::line_concrete_defaults;
    let line_defaults = line_concrete_defaults();
    let resolve_color = |el: &LineElement| -> Brush {
        let c = el
            .color
            .clone()
            .or_else(|| line_defaults.color.clone())
            .expect("line color default");
        Brush::Solid(c.resolve(palette))
    };
    // Render a bucket: resolve every polyline through scales, convert
    // to panel-pixel space, clip against the outline, stroke.
    let mut render = |lines: &[Vec<crate::scales::geometry::Coord>], el: Option<&LineElement>| {
        let Some(el) = el else { return };
        let stroke = stroke_from_line_element(el, dpi);
        let brush = resolve_color(el);
        if lines.is_empty() {
            return;
        }
        let resolved_px: Vec<Vec<Point>> = lines
            .iter()
            .map(|line| {
                c.resolve_graticule(line, x_scale, y_scale)
                    .into_iter()
                    .map(|(xf, yf)| to_px(xf, yf))
                    .collect::<Vec<Point>>()
            })
            .collect();
        let line_refs: Vec<&[Point]> = resolved_px.iter().map(|l| l.as_slice()).collect();
        let clipped = crate::primitives::clip_polylines_to_polygon(&line_refs, &outline_refs);
        for poly in &clipped {
            if poly.len() < 2 {
                continue;
            }
            let mut path = Path::new();
            path.move_to(poly[0]);
            for p in &poly[1..] {
                path.line_to(*p);
            }
            scene.stroke(&stroke, Affine::IDENTITY, &brush, None, &path, PickId::Skip);
        }
    };

    // Minors first so coincident majors layer on top — matches
    // `draw_grid_lines`'s ordering.
    render(&c.x_minor, minor_x);
    render(&c.y_minor, minor_y);
    render(&c.x_major, major_x);
    render(&c.y_major, major_y);
}

// ─── Per-projection geometry ────────────────────────────────────────────────

/// Closed path tracing the boundary of the plotting area. Used for
/// background fill, panel outline stroke, and the geom clip mask.
/// `corner_radius_px` rounds the Cartesian panel's four corners; for
/// polar projections it rounds the line-to-arc joins of partial
/// wedges (and the vertex-to-vertex joins of chord-style polygons) —
/// full disks and full annuli have no corners and the radius is a
/// no-op there.
///
/// `x_scale` / `y_scale` are the resolved scales for the projection's
/// two channels (in `consume_channels` order). They are consulted only
/// by [`Projection::Custom`](crate::plot::projection::Projection::Custom),
/// which resolves its data-space outline through them and trims against
/// the visible panel rect. Other projections ignore them; passing
/// `None` for both is the right call when no resolver is available
/// (Cartesian / Polar fall back to their fixed-shape outlines).
pub(crate) fn panel_outline_path(
    projection: &Projection,
    panel: Rect,
    corner_radius_px: f64,
    x_scale: Option<&Scale>,
    y_scale: Option<&Scale>,
) -> Path {
    match projection {
        Projection::Cartesian => {
            if corner_radius_px > 0.0 {
                crate::primitives::rounded_rect(panel, corner_radius_px)
            } else {
                panel.to_path(0.0)
            }
        }
        Projection::Polar(p) => {
            let path = polar_panel_outline(p, panel);
            if corner_radius_px > 0.0 {
                crate::primitives::round_path_corners(
                    &path,
                    crate::primitives::CornerRounding {
                        max_cut: corner_radius_px,
                        ..crate::primitives::CornerRounding::default()
                    },
                )
            } else {
                path
            }
        }
        Projection::Custom(c) => custom_panel_outline(c, panel, x_scale, y_scale),
    }
}

/// Build the panel-outline `Path` for a custom projection: resolve the
/// data-space outline through the bound scales, trim against the
/// visible panel rect, project each remaining ring to panel pixels,
/// emit one `MoveTo … ClosePath` subpath per ring. Returns an empty
/// path when the outline doesn't overlap the visible area (geoms get
/// clipped to nothing, which is the right visual outcome — there's no
/// drawing surface).
fn custom_panel_outline(
    c: &crate::plot::projection::CustomProjection,
    panel: Rect,
    x_scale: Option<&Scale>,
    y_scale: Option<&Scale>,
) -> Path {
    let rings = c.resolved_outline_fracs(x_scale, y_scale);
    let mut path = Path::new();
    let panel_w = panel.x1 - panel.x0;
    let panel_h = panel.y1 - panel.y0;
    for ring in &rings {
        if ring.len() < 3 {
            continue;
        }
        let mut first = true;
        for (xf, yf) in ring {
            let px = panel.x0 + xf * panel_w;
            let py = panel.y1 - yf * panel_h;
            if first {
                path.move_to(Point::new(px, py));
                first = false;
            } else {
                path.line_to(Point::new(px, py));
            }
        }
        path.close_path();
    }
    path
}

/// Resolve the panel's corner radius from `theme.panel_background`'s
/// `corner_radius` (falling through to the rect concrete defaults
/// when None). Returns 0 for `Element::Blank` or sharp corners.
pub(crate) fn panel_corner_radius_px(theme: &Theme, dpi: f64) -> f64 {
    use crate::plot::theme::rect_concrete_defaults;
    let Some(bg) = theme.panel_background.as_set() else {
        return 0.0;
    };
    let defaults = rect_concrete_defaults();
    let pt = bg
        .corner_radius
        .or(defaults.corner_radius)
        .map(|l| l.resolve(0.0))
        .unwrap_or(0.0);
    (pt * dpi / 72.0).max(0.0)
}

/// Grid line for `channel` (0 or 1) at fraction `frac` ∈ [0, 1].
/// Returns `None` when the grid line should be omitted (e.g., the
/// full-circle duplicate at `theta_frac == 1`, or a degenerate
/// zero-radius ring).
pub(crate) fn channel_grid_path(
    projection: &Projection,
    panel: Rect,
    channel: usize,
    frac: f64,
) -> Option<Path> {
    if !frac.is_finite() || !(0.0..=1.0).contains(&frac) {
        return None;
    }
    match projection {
        Projection::Cartesian => Some(cartesian_grid(panel, channel, frac)),
        Projection::Polar(p) => polar_grid(p, panel, channel, frac),
        // Custom projections supply their graticules directly; the
        // per-break grid path is unused.
        Projection::Custom(_) => None,
    }
}

// ─── Cartesian ──────────────────────────────────────────────────────────────

fn cartesian_grid(panel: Rect, channel: usize, frac: f64) -> Path {
    let w = panel.x1 - panel.x0;
    let h = panel.y1 - panel.y0;
    if channel == 0 {
        let x = panel.x0 + frac * w;
        segment(Point::new(x, panel.y0), Point::new(x, panel.y1))
    } else {
        let y = panel.y1 - frac * h;
        segment(Point::new(panel.x0, y), Point::new(panel.x1, y))
    }
}

// ─── Polar ──────────────────────────────────────────────────────────────────

fn polar_grid(p: &PolarProjection, panel: Rect, channel: usize, frac: f64) -> Option<Path> {
    let g = p.geometry(panel);
    let span = p.theta_end() - p.theta_start();
    let is_full_circle = (span.abs() - std::f64::consts::TAU).abs() < 1e-6;
    let is_chord = matches!(p.edge_style(), PolarEdgeStyle::Chord);

    if channel == 0 {
        // Spoke at theta_for_frac(frac), from r_inner to r_outer. Skip
        // the full-circle duplicate at frac=1 (same physical spoke as 0).
        if is_full_circle && frac >= 1.0 - 1e-9 {
            return None;
        }
        let theta = p.theta_for_frac(frac);
        let centre = Point::new(g.cx, g.cy);
        let p_in = PolarProjection::polar_point(centre, g.r_inner, theta);
        let p_out = PolarProjection::polar_point(centre, g.r_outer, theta);
        Some(segment(p_in, p_out))
    } else {
        // Ring at radius r_inner + frac * (r_outer - r_inner).
        let r_px = g.r_inner + frac * (g.r_outer - g.r_inner);
        if r_px <= 0.0 {
            return None;
        }
        let centre = Point::new(g.cx, g.cy);
        Some(if is_chord && !p.theta_break_fracs().is_empty() {
            polar_polygon_ring(p, centre, r_px, is_full_circle)
        } else if is_full_circle {
            circle(centre, r_px)
        } else {
            // Negate so the math-convention CCW arc renders as the
            // visual sweep (consistent with how the projection maps
            // angles into screen y-down space).
            arc(centre, r_px, -p.theta_start(), -span)
        })
    }
}

/// Path for the chord-style "ring" at a given pixel radius — polygon
/// vertices at each `theta_break_frac` (extended to `theta_start` /
/// `theta_end` for partial arcs). Closed for full-circle radars,
/// open polyline for partial-arc radars.
fn polar_polygon_ring(
    p: &PolarProjection,
    centre: Point,
    radius: f64,
    is_full_circle: bool,
) -> Path {
    let mut thetas: Vec<f64> = Vec::with_capacity(p.theta_break_fracs().len() + 2);
    if !is_full_circle {
        thetas.push(p.theta_start());
    }
    for &frac in p.theta_break_fracs() {
        thetas.push(p.theta_for_frac(frac));
    }
    if !is_full_circle {
        thetas.push(p.theta_end());
    }
    let mut pts: Vec<Point> = thetas
        .iter()
        .map(|&t| Point::new(centre.x + radius * t.cos(), centre.y - radius * t.sin()))
        .collect();
    if is_full_circle && pts.len() >= 2 {
        let first = pts[0];
        pts.push(first);
    }
    polyline(&pts, PolylineOptions::default())
}

/// Closed boundary path for the polar plotting area: outer ring
/// (arc or polygon) + side caps + inner ring (if the projection has
/// a non-zero `inner_radius_frac`). Filled for the background and
/// stroked for the outline.
fn polar_panel_outline(p: &PolarProjection, panel: Rect) -> Path {
    let g = p.geometry(panel);
    let span = p.theta_end() - p.theta_start();
    let is_full_circle = (span.abs() - std::f64::consts::TAU).abs() < 1e-6;
    let is_chord = matches!(p.edge_style(), PolarEdgeStyle::Chord);
    let centre = Point::new(g.cx, g.cy);
    let has_inner = g.r_inner > 0.0;

    match (is_chord, is_full_circle, has_inner) {
        // Geodesic full disk.
        (false, true, false) => circle(centre, g.r_outer),
        // Geodesic full annulus — two subpaths, callers fill with
        // NonZero (the inner subpath's reversed winding cancels the
        // outer fill, leaving a true ring).
        (false, true, true) => {
            let mut path = circle(centre, g.r_outer);
            // Inner circle traced CW (opposite winding) to cancel the
            // outer's fill. `circle` returns CCW by default; we
            // reverse-construct manually here.
            let inner = reverse_circle(centre, g.r_inner);
            for el in inner.elements() {
                path.push(*el);
            }
            path
        }
        // Geodesic partial pie / annular wedge.
        (false, false, false) => wedge(centre, g.r_outer, -p.theta_start(), -span),
        (false, false, true) => {
            annular_wedge(centre, g.r_inner, g.r_outer, -p.theta_start(), -span)
        }
        // Chord-style full polygon / annular polygon.
        (true, true, false) => polygon_ring(p, centre, g.r_outer, false),
        (true, true, true) => {
            let mut path = polygon_ring(p, centre, g.r_outer, false);
            // Reverse-wound inner polygon so the annulus fills correctly.
            for el in polygon_ring(p, centre, g.r_inner, true).elements() {
                path.push(*el);
            }
            path
        }
        // Chord-style partial: trace outer polygon (theta_start →
        // breaks → theta_end), then inner polygon back (if any).
        (true, false, false) => chord_partial_filled(p, centre, g.r_outer, 0.0),
        (true, false, true) => chord_partial_filled(p, centre, g.r_outer, g.r_inner),
    }
}

/// The chord-style ring at `radius` — one vertex per theta break.
/// `reversed` flips the winding, which is how an annulus punches its
/// inner ring out of the outer one under the nonzero fill rule.
fn polygon_ring(p: &PolarProjection, centre: Point, radius: f64, reversed: bool) -> Path {
    let mut pts: Vec<Point> = p
        .theta_break_fracs()
        .iter()
        .map(|&frac| PolarProjection::polar_point(centre, radius, p.theta_for_frac(frac)))
        .collect();
    if reversed {
        pts.reverse();
    }
    polygon(&[&pts], PolygonOptions::default())
}

fn reverse_circle(centre: Point, radius: f64) -> Path {
    // kurbo's Arc with a negative sweep traces clockwise — reverse of
    // the default `circle` (CCW).
    arc(centre, radius, 0.0, -std::f64::consts::TAU)
}

/// Closed boundary for a chord-style partial arc: outer polygon from
/// `theta_start` through each break to `theta_end`, then either back
/// to centre (no inner) or back along the inner polygon (with inner).
fn chord_partial_filled(p: &PolarProjection, centre: Point, r_outer: f64, r_inner: f64) -> Path {
    let thetas: Vec<f64> = std::iter::once(p.theta_start())
        .chain(p.theta_break_fracs().iter().map(|&f| p.theta_for_frac(f)))
        .chain(std::iter::once(p.theta_end()))
        .collect();
    let outer: Vec<Point> = thetas
        .iter()
        .map(|&t| Point::new(centre.x + r_outer * t.cos(), centre.y - r_outer * t.sin()))
        .collect();
    let inner: Vec<Point> = if r_inner > 0.0 {
        thetas
            .iter()
            .rev()
            .map(|&t| Point::new(centre.x + r_inner * t.cos(), centre.y - r_inner * t.sin()))
            .collect()
    } else {
        // Pie slice: close back through the centre.
        vec![centre]
    };
    let mut pts = outer;
    pts.extend(inner);
    polygon(&[&pts], PolygonOptions::default())
}