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

nordisk/facett

bench/examples/nornir-bench.rs

//! nornir Bencher harness for facett — render throughput, historized in the
//! warehouse + gated on regression by `nornir release` / `nornir bench run`.
//!
//! Ported from the `#[ignore]`'d perf tests in `facett/tests/perf.rs` (B-bucket:
//! bench-type tests that had no bench). Those tests are now FAST downscaled
//! correctness checks (`assert!(r.drew())` at small N); the full-scale throughput
//! measurement lives HERE so it lands in `bench_history` and the README.
//!
//! Run: `nornir bench run facett`  (or `cargo run --release --example nornir-bench`).

use anyhow::{ensure, Result};
use nornir::bench::api::Bencher;
use nornir::bench::BenchResult;

use facett::graph::{hash_color, GraphView};
use facett::harness::headless_render;
use facett::map::MapView;
use facett::map3d::{scissor_report, Map3D, V3, ViewPreset};
use facett::table::Table;

use egui::{Color32, Pos2};

/// Headline scale — the largest size the perf tests swept.
const N: usize = 50_000;

/// Build the throughput `BenchResult`: `<unit>_per_sec` (the headline metric),
/// the vertex count actually drawn, and elapsed ms.
fn result(name: &str, unit: &str, units: usize, elapsed_s: f64, vertices: usize) -> BenchResult {
    let mut metrics = serde_json::Map::new();
    let per_sec = if elapsed_s > 0.0 { (units as f64 / elapsed_s).round() } else { 0.0 };
    metrics.insert(format!("{unit}_per_sec"), serde_json::json!(per_sec));
    metrics.insert("vertices".into(), serde_json::json!(vertices));
    metrics.insert("elapsed_ms".into(), serde_json::json!((elapsed_s * 1000.0).round()));
    metrics.insert("n".into(), serde_json::json!(units));
    BenchResult::measured(name, metrics)
}

// ── force-layout SCALING benchers (Barnes–Hut O(n log n) proof) ─────────────
// These time the *layout* stage (not the render) at n = 1k / 10k / 30k to show the
// sub-quadratic scaling of the shared Barnes–Hut kernel. A quadratic regression
// would blow the 30k time by ~100× vs 1k×√ growth — the number the Bencher
// historizes catches it. Host-stamped PLAIN ms per size; authoritative run on Loki.

/// One `(nodes, elapsed_ms)` layout timing across the three scales, packed as a
/// single historized `BenchResult` (`ms_1k`/`ms_10k`/`ms_30k`, plus the headline
/// `nodes_per_sec` at 30k and a `ratio_30k_over_10k` sub-quadratic witness).
fn scale_result(name: &str, ms: [f64; 3]) -> BenchResult {
    let mut metrics = serde_json::Map::new();
    metrics.insert("ms_1k".into(), serde_json::json!(ms[0].round()));
    metrics.insert("ms_10k".into(), serde_json::json!(ms[1].round()));
    metrics.insert("ms_30k".into(), serde_json::json!(ms[2].round()));
    let per_sec = if ms[2] > 0.0 { (30_000.0 / (ms[2] / 1000.0)).round() } else { 0.0 };
    metrics.insert("nodes_per_sec".into(), serde_json::json!(per_sec));
    // O(n log n): 30k/10k ≈ 3·(log-ratio) ≈ 3.1×; O(n²) would be ≈ 9×.
    let ratio = if ms[1] > 0.0 { (ms[2] / ms[1] * 100.0).round() / 100.0 } else { 0.0 };
    metrics.insert("ratio_30k_over_10k".into(), serde_json::json!(ratio));
    metrics.insert("n".into(), serde_json::json!(30_000));
    BenchResult::measured(name, metrics)
}

/// A ring + sparse-chord edge list over `n` nodes (O(n) edges).
fn ring_chords(n: usize) -> Vec<(usize, usize)> {
    let mut e: Vec<(usize, usize)> = (0..n).map(|i| (i, (i + 1) % n)).collect();
    for i in (0..n).step_by(11) {
        e.push((i, (i + 313) % n));
    }
    e
}

/// 2-D force layout (the shared facett-core graph skin) scaling.
struct Force2dScale;
impl Bencher for Force2dScale {
    fn id(&self) -> &'static str {
        "facett.force_layout_2d"
    }
    fn run(&self) -> Result<BenchResult> {
        let rect = egui::Rect::from_min_size(Pos2::ZERO, egui::vec2(1024.0, 1024.0));
        let mut ms = [0.0f64; 3];
        for (slot, &n) in [1_000usize, 10_000, 30_000].iter().enumerate() {
            let mut scene = facett_core::Scene::new();
            for _ in 0..n {
                scene.node("n", hash_color("n"));
            }
            for (a, b) in ring_chords(n) {
                scene.edge(a, b);
            }
            let t = std::time::Instant::now();
            let pos = facett_core::layout_positions(facett_core::Layout::Force, &scene, rect);
            ms[slot] = t.elapsed().as_secs_f64() * 1000.0;
            ensure!(pos.len() == n && pos.iter().all(|p| p.x.is_finite()), "2d layout produced a bad position set");
        }
        Ok(scale_result("force_layout_2d", ms))
    }
}

/// 3-D force layout (facett-graph3d octree) scaling.
struct Force3dScale;
impl Bencher for Force3dScale {
    fn id(&self) -> &'static str {
        "facett.force_layout_3d"
    }
    fn run(&self) -> Result<BenchResult> {
        use facett::graph3d::{Graph3D, Layout3D};
        let mut ms = [0.0f64; 3];
        for (slot, &n) in [1_000usize, 10_000, 30_000].iter().enumerate() {
            let nodes: Vec<(String, Color32)> = (0..n).map(|i| (format!("n{i}"), Color32::from_rgb(0, 200, 220))).collect();
            let edges = ring_chords(n);
            let t = std::time::Instant::now();
            let g = Graph3D::new(nodes, edges, Layout3D::Force);
            ms[slot] = t.elapsed().as_secs_f64() * 1000.0;
            ensure!(g.nodes.len() == n && g.nodes.iter().all(|nd| nd.pos.iter().all(|c| c.is_finite())), "3d layout produced a bad position set");
        }
        Ok(scale_result("force_layout_3d", ms))
    }
}

/// Render a graph of N nodes + N edges and measure nodes/s.
struct GraphRender;
impl Bencher for GraphRender {
    fn id(&self) -> &'static str {
        "facett.graph_render"
    }
    fn run(&self) -> Result<BenchResult> {
        let mut scene = facett::Scene::new();
        for _ in 0..N {
            scene.node("node", hash_color("node"));
        }
        for i in 0..N {
            scene.edge(i, (i + 1) % N);
        }
        let mut gv = GraphView::new(scene);
        let t = std::time::Instant::now();
        let r = headless_render(&mut gv);
        let s = t.elapsed().as_secs_f64();
        ensure!(r.drew(), "graph render drew nothing");
        Ok(result("graph_render", "nodes", N, s, r.vertices))
    }
}

/// Render a map of N points and measure points/s.
struct MapRender;
impl Bencher for MapRender {
    fn id(&self) -> &'static str {
        "facett.map_render"
    }
    fn run(&self) -> Result<BenchResult> {
        let points: Vec<(f64, f64)> = (0..N)
            .map(|i| (9.4 + (i % 200) as f64 * 0.001, 47.0 + (i / 200) as f64 * 0.001))
            .collect();
        let mut mv = MapView::new("map", points);
        let t = std::time::Instant::now();
        let r = headless_render(&mut mv);
        let s = t.elapsed().as_secs_f64();
        ensure!(r.drew(), "map render drew nothing");
        Ok(result("map_render", "points", N, s, r.vertices))
    }
}

/// Render a 3-column table of N rows and measure rows/s.
struct TableRender;
impl Bencher for TableRender {
    fn id(&self) -> &'static str {
        "facett.table_render"
    }
    fn run(&self) -> Result<BenchResult> {
        let mut tbl = Table::new("t", vec!["id".into(), "name".into(), "val".into()]);
        for i in 0..N {
            tbl.push_row(vec![i.to_string(), format!("row{i}"), (i * 3).to_string()]);
        }
        let t = std::time::Instant::now();
        let r = headless_render(&mut tbl);
        let s = t.elapsed().as_secs_f64();
        Ok(result("table_render", "rows", N, s, r.vertices))
    }
}

// ── Panel-trait native frame-time bencher (wasm-ui design §5, Phase 0) ──────
// The `Panel` trait is the constellation-wide UI-pane seam; backend (1) is native
// in-process (the blanket `impl<T: Facet> Panel for T`). This measures the native
// frame time of a TRIVIAL pane driven through a `Box<dyn Panel>` trait object —
// the foundation's perf floor (dynamic dispatch + the four-method seam), not a hot
// path. Reports frames/sec + ms/frame. Deliberately trivial so it isolates the
// trait-object overhead, per the design's "native frame time of a trivial pane".

/// A trivial pane: a title + a counter it mutates via the Elm `update_json` seam.
struct TrivialPane {
    count: i64,
}
impl facett::Facet for TrivialPane {
    fn title(&self) -> &str {
        "trivial"
    }
    fn ui(&mut self, ui: &mut egui::Ui) {
        ui.label(format!("count = {}", self.count));
    }
    fn state_json(&self) -> serde_json::Value {
        serde_json::json!({ "count": self.count })
    }
    fn update_json(&mut self, msg_json: &str) {
        if let Ok(v) = serde_json::from_str::<serde_json::Value>(msg_json) {
            if let Some(n) = v.get("add").and_then(|n| n.as_i64()) {
                self.count += n;
            }
        }
    }
}

/// Native frame time of a trivial `Box<dyn Panel>` — paint + one `update_json` per
/// frame through the trait object, over `FRAMES` frames.
struct PanelFrame;
impl Bencher for PanelFrame {
    fn id(&self) -> &'static str {
        "facett.panel_frame"
    }
    fn run(&self) -> Result<BenchResult> {
        const FRAMES: usize = 20_000;
        let mut pane: Box<dyn facett::Panel> = Box::new(TrivialPane { count: 0 });
        let ctx = egui::Context::default();
        let input = egui::RawInput {
            screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, egui::vec2(800.0, 600.0))),
            ..Default::default()
        };
        let t = std::time::Instant::now();
        let mut last_verts = 0usize;
        for _ in 0..FRAMES {
            // Backend (1) paint through the trait object.
            let mut out = ctx.run_ui(input.clone(), |ui| {
                egui::CentralPanel::default().show(ui, |ui| pane.ui(ui));
            });
            out.textures_delta.clear();
            last_verts = ctx
                .tessellate(out.shapes, out.pixels_per_point)
                .iter()
                .map(|p| match &p.primitive {
                    egui::epaint::Primitive::Mesh(m) => m.vertices.len(),
                    _ => 0,
                })
                .sum();
            // Exercise the Elm mutation seam each frame too.
            pane.update_json(r#"{"add": 1}"#);
        }
        let s = t.elapsed().as_secs_f64();
        ensure!(last_verts > 0, "trivial pane drew nothing");
        ensure!(pane.state_json()["count"].as_i64() == Some(FRAMES as i64), "update_json ran every frame");
        let mut r = result("panel_frame", "frames", FRAMES, s, last_verts);
        let ms_per_frame = if FRAMES > 0 { s * 1000.0 / FRAMES as f64 } else { 0.0 };
        r.metrics.insert("ms_per_frame".into(), serde_json::json!((ms_per_frame * 1000.0).round() / 1000.0));
        Ok(r)
    }
}

// ── facett-map LAYER-TRAIT frame-prep bencher (consolidation §3.1) ──────────
// The selectable Map trait's per-frame CPU cost: for a heavy STACK of MapLayers
// under a ROTATED + TILTED car-nav camera, `MapFacet::enabled_features()` culls
// every layer to the viewport, projects each `Way` into a source-agnostic
// `DrawFeature`, and z-sorts the lot — the work the shared renderer paints each
// frame. Benched as the native frame time (ms/frame + features/sec). This is the
// perf gate for the "3D OSM facet, better than Garmin" native path.

/// A heavy synthetic city: `n` E–W roads × `n` N–S roads (a dense street grid),
/// each a 3-vertex polyline, classified through the OSM styler → real `Way`s.
fn city_roads(n: usize) -> Vec<facett::map::Way> {
    let th = facett::Theme::default();
    let (lon0, lat0, step) = (18.00_f64, 59.30_f64, 0.0009_f64);
    let mut ways = Vec::with_capacity(n * 2);
    let mut id = 1i64;
    let class = |i: usize| if i % 8 == 0 { "primary" } else if i % 3 == 0 { "secondary" } else { "residential" };
    for r in 0..n {
        let lat = lat0 + r as f64 * step;
        let line: Vec<(f64, f64)> = (0..n).map(|c| (lon0 + c as f64 * step, lat)).collect();
        let tags = vec![("highway".to_string(), class(r).to_string())];
        if let Some(w) = facett::map::Way::from_lonlat(id, &line, &tags, &th) {
            ways.push(w);
            id += 1;
        }
    }
    for c in 0..n {
        let lon = lon0 + c as f64 * step;
        let line: Vec<(f64, f64)> = (0..n).map(|r| (lon, lat0 + r as f64 * step)).collect();
        let tags = vec![("highway".to_string(), class(c).to_string())];
        if let Some(w) = facett::map::Way::from_lonlat(id, &line, &tags, &th) {
            ways.push(w);
            id += 1;
        }
    }
    ways
}

/// Frame-prep over a rotated + tilted layer stack (OSM streets + a Trafikverket
/// overlay + a korp collector point layer) — the native car-nav frame cost.
struct MapLayerFrame;
impl Bencher for MapLayerFrame {
    fn id(&self) -> &'static str {
        "facett.map.layer_frame"
    }
    fn run(&self) -> Result<BenchResult> {
        use facett::map::layer::{LatLon, MapFacet, MapTheme, MapView, PointsLayer, WaysLayer};
        let n = std::env::var("MAP_LAYER_BENCH_N").ok().and_then(|v| v.parse().ok()).unwrap_or(140usize);
        let osm = city_roads(n);
        let tv = city_roads(n); // a second selectable road source (Trafikverket overlay)
        let ways = osm.len() + tv.len();
        let verts: usize = osm.iter().map(|w| w.lonlat.len()).sum::<usize>() * 2;
        // korp collector overlay points across the same extent.
        let pts: Vec<(f64, f64)> = (0..5000).map(|i| (18.00 + (i % 130) as f64 * 0.0009, 59.30 + (i / 130) as f64 * 0.0009)).collect();
        // The worst-case car-nav pose: heading 35°, pitched 55° back.
        let view = MapView::new(LatLon::new(59.36, 18.06), 14.0, (1280.0, 800.0)).with_bearing(35.0).with_tilt(55.0);
        let facet = MapFacet::new("bench", view, MapTheme::default())
            .with_layer(Box::new(WaysLayer::new("osm-streets", osm)))
            .with_layer(Box::new(WaysLayer::new("trafikverket-roads", tv)))
            .with_layer(Box::new(PointsLayer::new("korp-hits", pts, Color32::RED)));
        let iters = 60;
        let t = std::time::Instant::now();
        let mut features = 0usize;
        for _ in 0..iters {
            features = facet.enabled_features().len();
        }
        let s = t.elapsed().as_secs_f64();
        ensure!(features > 0, "frame prep produced no features");
        let mut metrics = serde_json::Map::new();
        let per_frame_ms = s * 1000.0 / iters as f64;
        metrics.insert("ms_per_frame".into(), serde_json::json!((per_frame_ms * 1000.0).round() / 1000.0));
        metrics.insert("frames_per_sec".into(), serde_json::json!((iters as f64 / s).round()));
        metrics.insert("features".into(), serde_json::json!(features));
        metrics.insert("ways".into(), serde_json::json!(ways));
        metrics.insert("vertices".into(), serde_json::json!(verts));
        metrics.insert("iters".into(), serde_json::json!(iters));
        Ok(BenchResult::measured("facett.map.layer_frame", metrics))
    }
}

// ── facett-map3d PART-STEP benchers (the clip pipeline) ─────────────────────
// Bencher LAW: bench the PARTS, not just "draw map". Each part-step of
// `shade_faces` gets its own `facett.map3d.<step>` entry, fed real injected
// input sized to isolate that step, with the end-to-end `facett.map_render` as
// the rollup. The dolled-in street-level pose is the one the clip pipeline must
// stay fast under (it splits the most triangles at the near plane).

/// A sized synthetic town of real `building=yes` rings — N×N footprints → prisms.
fn map3d_town(n: usize) -> facett::map3d::Map3D {
    let mut ways = Vec::with_capacity(n * n);
    let mut id = 1i64;
    for r in 0..n {
        for c in 0..n {
            let bx = 9.50 + c as f64 * 0.0015;
            let by = 47.10 + r as f64 * 0.0015;
            let ring = [(bx, by), (bx + 0.0008, by), (bx + 0.0008, by + 0.0008), (bx, by + 0.0008), (bx, by)];
            let tags = vec![("building".to_string(), "yes".to_string())];
            ways.push(facett::map::Way::from_lonlat(id, &ring, &tags, &facett::Theme::default()).unwrap());
            id += 1;
        }
    }
    let mut m = Map3D::from_ways("map3d-bench", &ways, 0.22);
    // The dolled-in street-level pose: the worst case for the near-plane clip.
    m.spin = false;
    m.cam.target = V3::new(0.0, 0.02, 0.0);
    m.cam.goal_target = m.cam.target;
    m.cam.distance = 0.42;
    m.cam.goal_distance = 0.42;
    m.view_preset(ViewPreset::Front);
    m.cam.elevation = 0.03;
    m.cam.goal_elevation = 0.03;
    m.cam.settle();
    m
}

/// Build a `BenchResult` of `tris/sec` for a part-step over `tris` source tris.
fn step_result(name: &str, tris: usize, elapsed_s: f64, iters: usize) -> BenchResult {
    let mut metrics = serde_json::Map::new();
    let per_sec = if elapsed_s > 0.0 { ((tris * iters) as f64 / elapsed_s).round() } else { 0.0 };
    metrics.insert("tris_per_sec".into(), serde_json::json!(per_sec));
    metrics.insert("tris".into(), serde_json::json!(tris));
    metrics.insert("iters".into(), serde_json::json!(iters));
    metrics.insert("ns_per_op".into(), serde_json::json!(if tris * iters > 0 { (elapsed_s * 1e9 / (tris * iters) as f64).round() } else { 0.0 }));
    metrics.insert("elapsed_ms".into(), serde_json::json!((elapsed_s * 1000.0).round()));
    BenchResult::measured(name, metrics)
}

/// The viewport the part-steps project + scissor against.
const BCENTER: Pos2 = Pos2::new(360.0, 360.0);
const BHALF_H: f32 = 360.0;
fn brect() -> egui::Rect {
    egui::Rect::from_center_size(BCENTER, egui::vec2(BHALF_H * 2.0, BHALF_H * 2.0))
}

/// Part-step 1 — view-space near-plane clip (the fix). Whole-pipeline timing with
/// the near clip ON (it is the costliest new stage at the straddling pose).
struct Map3dNearClip;
impl Bencher for Map3dNearClip {
    fn id(&self) -> &'static str {
        "facett.map3d.near_clip"
    }
    fn run(&self) -> Result<BenchResult> {
        let m = map3d_town(40); // 1600 prisms → a dense straddling town
        let tris = m.triangle_count();
        let iters = 50;
        let t = std::time::Instant::now();
        let mut acc = 0usize;
        for _ in 0..iters {
            acc += m.shade_faces_clip(BCENTER, BHALF_H, Color32::WHITE, true).len();
        }
        let s = t.elapsed().as_secs_f64();
        ensure!(acc > 0, "near clip produced no faces");
        Ok(step_result("facett.map3d.near_clip", tris, s, iters))
    }
}

/// Part-step 2 — project. Cost of `cam.project` over every mesh vertex.
struct Map3dProject;
impl Bencher for Map3dProject {
    fn id(&self) -> &'static str {
        "facett.map3d.project"
    }
    fn run(&self) -> Result<BenchResult> {
        let m = map3d_town(40);
        let cam = m.render_camera();
        let verts: Vec<V3> = m.mesh().positions.iter().map(|p| V3::new(p[0], p[1], p[2])).collect();
        let tris = m.triangle_count();
        let iters = 50;
        let t = std::time::Instant::now();
        let mut acc = 0usize;
        for _ in 0..iters {
            for v in &verts {
                if cam.project(*v, (BCENTER.x, BCENTER.y), BHALF_H).visible {
                    acc += 1;
                }
            }
        }
        let s = t.elapsed().as_secs_f64();
        ensure!(acc > 0, "project saw no visible verts");
        Ok(step_result("facett.map3d.project", tris, s, iters))
    }
}

/// Part-step 3/4 — frustum + back-face cull. Measured as the whole shade pass
/// (cull is folded inline); the rollup compares against `near_clip`.
struct Map3dCull;
impl Bencher for Map3dCull {
    fn id(&self) -> &'static str {
        "facett.map3d.cull"
    }
    fn run(&self) -> Result<BenchResult> {
        let m = map3d_town(40);
        let tris = m.triangle_count();
        let iters = 50;
        let t = std::time::Instant::now();
        let mut visible = 0usize;
        for _ in 0..iters {
            visible = m.shade_faces_clip(BCENTER, BHALF_H, Color32::WHITE, true).len();
        }
        let s = t.elapsed().as_secs_f64();
        ensure!(visible <= tris * 2, "cull is monotone (≤ 2× source tris)");
        Ok(step_result("facett.map3d.cull", tris, s, iters))
    }
}

/// Part-step 5 — shade. The lit + fog colouring over the visible faces.
struct Map3dShade;
impl Bencher for Map3dShade {
    fn id(&self) -> &'static str {
        "facett.map3d.shade"
    }
    fn run(&self) -> Result<BenchResult> {
        let m = map3d_town(40);
        let tris = m.triangle_count();
        let iters = 50;
        let t = std::time::Instant::now();
        let mut shades = 0usize;
        for _ in 0..iters {
            shades = m.shade_faces(BCENTER, BHALF_H, Color32::from_rgb(20, 24, 40)).len();
        }
        let s = t.elapsed().as_secs_f64();
        ensure!(shades > 0, "shade produced no coloured faces");
        Ok(step_result("facett.map3d.shade", tris, s, iters))
    }
}

/// Part-step 6 — depth-sort. The painter's-algorithm far→near sort.
struct Map3dDepthSort;
impl Bencher for Map3dDepthSort {
    fn id(&self) -> &'static str {
        "facett.map3d.depth_sort"
    }
    fn run(&self) -> Result<BenchResult> {
        let m = map3d_town(40);
        let mut faces = m.shade_faces(BCENTER, BHALF_H, Color32::WHITE);
        let tris = faces.len();
        let iters = 200;
        let t = std::time::Instant::now();
        for _ in 0..iters {
            // Shuffle-ish perturb then re-sort to time the sort in isolation.
            faces.rotate_left(1);
            faces.sort_by(|l, r| r.0.partial_cmp(&l.0).unwrap_or(std::cmp::Ordering::Equal));
        }
        let s = t.elapsed().as_secs_f64();
        ensure!(tris > 0, "no faces to sort");
        Ok(step_result("facett.map3d.depth_sort", tris, s, iters))
    }
}

/// Part-step 7 — 2D rect scissor. Clip every face to the widget rect (the confine).
struct Map3dScissor;
impl Bencher for Map3dScissor {
    fn id(&self) -> &'static str {
        "facett.map3d.scissor"
    }
    fn run(&self) -> Result<BenchResult> {
        let m = map3d_town(40);
        let faces = m.shade_faces(BCENTER, BHALF_H, Color32::WHITE);
        let rect = brect();
        let tris = faces.len();
        let iters = 100;
        let t = std::time::Instant::now();
        let mut escapes = 0usize;
        for _ in 0..iters {
            escapes = scissor_report(&faces, rect).1;
        }
        let s = t.elapsed().as_secs_f64();
        ensure!(escapes == 0, "the scissor leaves no ink outside the rect");
        Ok(step_result("facett.map3d.scissor", tris, s, iters))
    }
}

/// Part-step 8 — composite. The full CPU shade → clip pass (the painter's whole
/// per-frame geometry cost; the pixel raster lives in the host renderer).
struct Map3dComposite;
impl Bencher for Map3dComposite {
    fn id(&self) -> &'static str {
        "facett.map3d.composite"
    }
    fn run(&self) -> Result<BenchResult> {
        let m = map3d_town(40);
        let rect = brect();
        let tris = m.triangle_count();
        let iters = 30;
        let t = std::time::Instant::now();
        let mut painted = 0usize;
        for _ in 0..iters {
            let faces = m.shade_faces(BCENTER, BHALF_H, Color32::from_rgb(20, 24, 40));
            for (_d, pts, _c) in &faces {
                if facett::map3d::clip_poly_to_rect(pts, rect).len() >= 3 {
                    painted += 1;
                }
            }
        }
        let s = t.elapsed().as_secs_f64();
        ensure!(painted > 0, "composite painted no polygons");
        Ok(step_result("facett.map3d.composite", tris, s, iters))
    }
}

/// **`facett.demo_frame`** — the WHOLE-frame CPU cost of the demo's heaviest 3D pane
/// (the OSM-3D cinematic-tour tab): a dense town of prisms shaded end-to-end at the
/// dolled-in street-level pose the tour holds. The rollup the part-steps above
/// decompose — the frame budget the interactive `facett-demo` must keep under
/// **16.6 ms** for a smooth 60 fps. The headline `ms_per_frame` / `fps` are the number
/// the lag fix (removing the per-frame full-deck `state_json` dump from the hot loop)
/// has to beat; the Loki bencher re-runs it on every push to guard against a
/// regression back into per-frame stutter.
struct DemoFrame;
impl Bencher for DemoFrame {
    fn id(&self) -> &'static str {
        "facett.demo_frame"
    }
    fn run(&self) -> Result<BenchResult> {
        let m = map3d_town(40); // 1600 prisms → a dense demo town at street level
        let tris = m.triangle_count();
        let iters = 60; // ~1 s of frames
        let t = std::time::Instant::now();
        let mut acc = 0usize;
        for _ in 0..iters {
            // The full CPU paint pipeline for one frame (near-clip → project → cull →
            // shade → depth-sort), the residual per-frame cost that decides smoothness.
            acc += m.shade_faces(BCENTER, BHALF_H, Color32::from_rgb(20, 24, 40)).len();
        }
        let s = t.elapsed().as_secs_f64();
        ensure!(acc > 0, "demo frame drew no faces");
        let frame_ms = s * 1000.0 / iters as f64;
        let mut metrics = serde_json::Map::new();
        metrics.insert("ms_per_frame".into(), serde_json::json!((frame_ms * 1000.0).round() / 1000.0));
        metrics.insert("fps".into(), serde_json::json!(if frame_ms > 0.0 { (1000.0 / frame_ms).round() } else { 0.0 }));
        metrics.insert("smooth_60fps".into(), serde_json::json!(frame_ms <= 16.6));
        metrics.insert("triangles".into(), serde_json::json!(tris));
        metrics.insert("iters".into(), serde_json::json!(iters));
        Ok(BenchResult::measured("facett.demo_frame", metrics))
    }
}

// ── facett-core L0 SDF PART-STEP benchers (CONS-CORE Phase B) ────────────────
// Bencher LAW: bench the PARTS, not just "draw". Each L0 SDF primitive + the CPU
// raster + the GPU upload gets its own `facett.core.<step>` entry, fed injected
// sized input, sweeping instance counts (the galileo bar = ≥3.6M points; the rollup
// sweep reaches 10M). The coverage math is the same the GPU runs, so these time the
// CPU floor's per-instance cost — the number that matters on the no-GPU row.
//
// NOTE: this bench crate currently fails to COMPILE because `nornir`'s `skade`
// dependency pulls a conflicting arrow 57/58 (the same pre-existing conflict that
// blocks the map3d part-benchers from running). The benchers are registered in the
// correct pattern so they run as soon as that conflict is resolved.

use facett::core::render::cpu::sdf;
use facett::core::render::prim::{shape, CircleInstance, LineInstance, MarkerInstance, QuadInstance, RingInstance};
use facett::core::render::{Camera, Canvas, CpuCanvas};

/// The instance-count sweep each SDF part-step is timed over (1k → 10M).
const SDF_SWEEP: [usize; 5] = [1_000, 10_000, 100_000, 1_000_000, 10_000_000];

/// A `BenchResult` of `instances/sec` for an SDF part-step at the headline (largest)
/// sweep size, carrying the full sweep grid in the metrics.
fn sdf_result(name: &str, per_size_ns: &[(usize, f64)]) -> BenchResult {
    let mut metrics = serde_json::Map::new();
    // Headline: throughput at the largest size.
    if let Some((n, ns)) = per_size_ns.last() {
        let per_sec = if *ns > 0.0 { (*n as f64 / (*ns / 1e9)).round() } else { 0.0 };
        metrics.insert("instances_per_sec".into(), serde_json::json!(per_sec));
        metrics.insert("n".into(), serde_json::json!(n));
        metrics.insert("ns_per_op".into(), serde_json::json!((ns / *n as f64).round()));
    }
    // The full sweep grid (size → ns/op) — the part-bench parameter sweep.
    let grid: serde_json::Map<String, serde_json::Value> = per_size_ns
        .iter()
        .map(|(n, ns)| (n.to_string(), serde_json::json!((ns / *n as f64))))
        .collect();
    metrics.insert("sweep_ns_per_op".into(), serde_json::Value::Object(grid));
    BenchResult::measured(name, metrics)
}

/// Time a per-instance coverage closure over the count sweep — the inner SDF kernel
/// (the work the fragment shader does per covered pixel), evaluated at a fixed
/// sample point so it isolates the math, not the raster loop.
fn sweep_coverage(name: &str, make: impl Fn(usize) -> QuadInstance) -> Result<BenchResult> {
    let mut grid = Vec::new();
    for &n in &SDF_SWEEP {
        let inst = make(n);
        let t = std::time::Instant::now();
        let mut acc = 0.0f32;
        for i in 0..n {
            // Vary the sample so the optimiser can't hoist the call.
            let f = (i & 63) as f32 * 0.25;
            acc += sdf::quad_coverage(&inst, f, f * 0.5);
        }
        let ns = t.elapsed().as_nanos() as f64;
        ensure!(acc.is_finite(), "{name} produced non-finite coverage");
        grid.push((n, ns));
    }
    Ok(sdf_result(name, &grid))
}

struct SdfCircle;
impl Bencher for SdfCircle {
    fn id(&self) -> &'static str { "facett.core.sdf_circle" }
    fn run(&self) -> Result<BenchResult> {
        sweep_coverage("facett.core.sdf_circle", |_| {
            CircleInstance { center: [0.0, 0.0], radius: 16.0, color: [1.0; 4], aa: 1.5 }.lower()
        })
    }
}

struct SdfRing;
impl Bencher for SdfRing {
    fn id(&self) -> &'static str { "facett.core.sdf_ring" }
    fn run(&self) -> Result<BenchResult> {
        sweep_coverage("facett.core.sdf_ring", |_| {
            RingInstance { center: [0.0; 2], radius: 16.0, inner: 9.0, color: [1.0; 4], aa: 1.5 }.lower()
        })
    }
}

struct SdfMarker;
impl Bencher for SdfMarker {
    fn id(&self) -> &'static str { "facett.core.sdf_marker" }
    fn run(&self) -> Result<BenchResult> {
        sweep_coverage("facett.core.sdf_marker", |_| {
            MarkerInstance { center: [0.0; 2], radius: 14.0, corner: 2.0, color: [1.0; 4], aa: 1.0, shape: shape::DIAMOND }.lower()
        })
    }
}

struct ThickLine;
impl Bencher for ThickLine {
    fn id(&self) -> &'static str { "facett.core.thick_line" }
    fn run(&self) -> Result<BenchResult> {
        let mut grid = Vec::new();
        for &n in &SDF_SWEEP {
            let l = LineInstance::round([10.0, 50.0], [200.0, 80.0], 4.0, 1.5, [1.0; 4]);
            let t = std::time::Instant::now();
            let mut acc = 0.0f32;
            for i in 0..n {
                let f = (i & 127) as f32 * 0.5;
                acc += sdf::line_coverage(&l, [f, 50.0 + (i & 31) as f32]);
            }
            let ns = t.elapsed().as_nanos() as f64;
            ensure!(acc.is_finite(), "thick_line non-finite");
            grid.push((n, ns));
        }
        Ok(sdf_result("facett.core.thick_line", &grid))
    }
}

/// CPU raster: the whole `CpuCanvas` → vello_cpu pixmap path.
///
/// **Scaling-honest bench (rewritten):** the old version timed `new` (a 1M-px serial
/// clear) + `frame` (a single-threaded un-premultiply `collect`) + `lit_px` inside
/// the window, swamping the one parallel call with serial tail and capping the
/// measured speedup at ~6.3× / 15.78 cores-busy. Now:
/// - The canvas is built **once** and the framebuffer is **reused** across passes
///   (no per-pass alloc; the per-frame bg clear runs inside the parallel raster).
/// - Only `rasterize()` is timed; the correctness `lit_px()` check is outside the
///   timer (a separate untimed pass).
/// - A large 4096×4096 frame + many discs makes the parallel region dominate, and
///   the **same** raster is timed at `workers = N` (all cores) vs `workers = 1`
///   (forced sequential) so cores-busy is a measured ratio, not a guess.
/// - Reported **per-n**: `cores_busy_<n>` and `speedup_<n>` for the big case, not a
///   sweep average that drowns the heavy case in serial-light small ones.
struct CpuRaster;
impl CpuRaster {
    /// Median wall-time (ns) of `passes` rasters of `quads` onto a reused
    /// `dim×dim` canvas, all at `workers` gatling workers. Reusing the canvas drops
    /// per-pass alloc; the parallel raster repaints the bg each pass.
    fn time_raster(dim: u32, quads: &[QuadInstance], workers: usize, passes: usize) -> f64 {
        let mut samples = Vec::with_capacity(passes);
        let mut canvas = CpuCanvas::new(dim, dim, Camera::default(), [8, 10, 14, 255]);
        for _ in 0..passes {
            canvas.push_quads(quads);
            let t = std::time::Instant::now();
            let _frame = canvas.rasterize_with_workers(workers);
            samples.push(t.elapsed().as_nanos() as f64);
        }
        samples.sort_by(|a, b| a.partial_cmp(b).unwrap());
        samples[samples.len() / 2]
    }

    /// Median wall-time (ns) of the **compositing raster only** — `raster_only`
    /// (bg clear + y-bucketed SDF blend, the region the gatling kernel governs),
    /// excluding the memory-bandwidth-bound un-premultiply (`frame`). This isolates
    /// the part that *should* scale with cores from the part that is capped by
    /// RAM bandwidth, so the report is honest about which is which.
    fn time_composite(dim: u32, quads: &[QuadInstance], workers: usize, passes: usize) -> f64 {
        let mut samples = Vec::with_capacity(passes);
        let mut canvas = CpuCanvas::new(dim, dim, Camera::default(), [8, 10, 14, 255]);
        for _ in 0..passes {
            canvas.push_quads(quads);
            let t = std::time::Instant::now();
            canvas.raster_only(workers);
            samples.push(t.elapsed().as_nanos() as f64);
        }
        samples.sort_by(|a, b| a.partial_cmp(b).unwrap());
        samples[samples.len() / 2]
    }
}
impl Bencher for CpuRaster {
    fn id(&self) -> &'static str { "facett.core.cpu_raster" }
    fn run(&self) -> Result<BenchResult> {
        const DIM: u32 = 4096;
        let cores = std::thread::available_parallelism().map(|c| c.get()).unwrap_or(1);

        let mut grid = Vec::new();
        let mut metrics = serde_json::Map::new();
        for &n in &[10_000usize, 50_000, 200_000] {
            // Spread discs across the whole 4096² frame so every scanline carries
            // real work (the gatling fan-out has nothing to starve on).
            let quads: Vec<QuadInstance> = (0..n)
                .map(|i| {
                    let x = (i * 131 % DIM as usize) as f32;
                    let y = (i * 197 % DIM as usize) as f32;
                    CircleInstance { center: [x, y], radius: 9.0, color: [0.2, 0.7, 1.0, 0.8], aa: 1.5 }.lower()
                })
                .collect();

            // Correctness OUTSIDE the timer: prove it actually drew.
            {
                let mut c = CpuCanvas::new(DIM, DIM, Camera::default(), [8, 10, 14, 255]);
                c.push_quads(&quads);
                ensure!(c.rasterize().lit_px() > 0, "cpu_raster drew nothing");
            }

            // More passes on the heavy case so the parallel window dominates.
            let passes = if n >= 200_000 { 16 } else { 24 };
            // Optional serial-tail breakdown (env-gated, to stderr) — proves what
            // does/doesn't scale, so the ceiling report is honest, not guessed.
            if std::env::var_os("FACETT_RASTER_PROBE").is_some() {
                use facett::core::render::cpu::CpuCanvas as C;
                let c = C::new(DIM, DIM, Camera::default(), [8, 10, 14, 255]);
                let probe = |label: &str, f: &mut dyn FnMut()| {
                    let t = std::time::Instant::now();
                    for _ in 0..passes { f(); }
                    eprintln!("PROBE n={n} {label} = {:.3} ms", t.elapsed().as_secs_f64() * 1000.0 / passes as f64);
                };
                probe("new(4096)", &mut || { let _ = C::new(DIM, DIM, Camera::default(), [8,10,14,255]); });
                probe("frame_par", &mut || { let _ = c.frame_with_workers(0); });
                probe("frame_seq", &mut || { let _ = c.frame_with_workers(1); });
                {
                    let mut cc = C::new(DIM, DIM, Camera::default(), [8,10,14,255]);
                    probe("composite_par", &mut || { cc.push_quads(&quads); cc.raster_only(0); });
                    probe("composite_seq", &mut || { cc.push_quads(&quads); cc.raster_only(1); });
                }
            }
            // Whole rasterize() (composite + un-premultiply), all cores vs 1.
            let par_ns = Self::time_raster(DIM, &quads, 0, passes); // all cores
            let seq_ns = Self::time_raster(DIM, &quads, 1, passes); // forced 1 worker
            let speedup = if par_ns > 0.0 { seq_ns / par_ns } else { 0.0 };
            // Compositing raster ONLY (the gatling-governed region), all cores vs 1.
            let cpar = Self::time_composite(DIM, &quads, 0, passes);
            let cseq = Self::time_composite(DIM, &quads, 1, passes);
            let cspeedup = if cpar > 0.0 { cseq / cpar } else { 0.0 };

            let r2 = |x: f64| (x * 100.0).round() / 100.0;
            metrics.insert(format!("par_ns_{n}"), serde_json::json!(par_ns.round()));
            metrics.insert(format!("seq_ns_{n}"), serde_json::json!(seq_ns.round()));
            metrics.insert(format!("speedup_{n}"), serde_json::json!(r2(speedup)));
            metrics.insert(format!("cores_busy_{n}"), serde_json::json!(r2(speedup.min(cores as f64))));
            metrics.insert(format!("composite_speedup_{n}"), serde_json::json!(r2(cspeedup)));
            metrics.insert(format!("composite_cores_busy_{n}"), serde_json::json!(r2(cspeedup.min(cores as f64))));
            grid.push((n, par_ns));
        }
        metrics.insert("cores".into(), serde_json::json!(cores));

        // Roll the per-n cores-busy/speedup into the standard sdf_result envelope so
        // it still historizes the headline throughput, then merge the per-n metrics.
        let mut res = sdf_result("facett.core.cpu_raster", &grid);
        for (k, v) in metrics {
            res.metrics.insert(k, v);
        }
        Ok(res)
    }
}

/// GPU upload + offscreen draw: builds the instance buffer + runs the L0 SDF
/// pipeline headlessly, timed by total instances uploaded. Requires the `wgpu`
/// feature AND a usable adapter; otherwise reports a zero result with `skipped=1`
/// (honest — no GPU lane on this box).
struct GpuUpload;
impl Bencher for GpuUpload {
    fn id(&self) -> &'static str { "facett.core.gpu_upload" }
    fn run(&self) -> Result<BenchResult> {
        #[cfg(feature = "wgpu")]
        {
            use facett::core::render::gpu::offscreen_render;
            let mut grid = Vec::new();
            for &n in &[10_000usize, 100_000, 1_000_000] {
                let quads: Vec<QuadInstance> = (0..n)
                    .map(|i| {
                        let x = (i % 512) as f32;
                        let y = ((i / 512) % 512) as f32;
                        CircleInstance { center: [x, y], radius: 2.0, color: [1.0, 0.5, 0.2, 1.0], aa: 1.0 }.lower()
                    })
                    .collect();
                let t = std::time::Instant::now();
                let frame = offscreen_render(512, 512, [0.03, 0.04, 0.05, 1.0], &quads, &[]);
                let ns = t.elapsed().as_nanos() as f64;
                match frame {
                    Some(_) => grid.push((n, ns)),
                    None => {
                        let mut metrics = serde_json::Map::new();
                        metrics.insert("skipped".into(), serde_json::json!(1));
                        metrics.insert("reason".into(), serde_json::json!("no wgpu adapter"));
                        return Ok(BenchResult::measured("facett.core.gpu_upload", metrics));
                    }
                }
            }
            Ok(sdf_result("facett.core.gpu_upload", &grid))
        }
        #[cfg(not(feature = "wgpu"))]
        {
            let mut metrics = serde_json::Map::new();
            metrics.insert("skipped".into(), serde_json::json!(1));
            metrics.insert("reason".into(), serde_json::json!("wgpu feature off"));
            Ok(BenchResult::measured("facett.core.gpu_upload", metrics))
        }
    }
}

// ── scene WINDOW-CHROME frame cost (L2 non-regression for a default-ON feature) ──
// Every pane in a `facett_app::scene` now draws a title bar with fold / minimise /
// maximise. That is ON BY DEFAULT, so it must be proven not to cost a frame: this arm
// renders the SAME deep pane tree twice — once with the default chrome, once with every
// pane opted out — and reports both frame times plus the overhead between them. A
// regression shows up as `chrome_overhead_pct` climbing, not as a vague "feels slower".

/// A balanced binary split tree `depth` levels deep ⇒ `2^depth` leaves,
/// `2^(depth+1) - 1` panes. `chrome` false ⇒ every pane opts out (the pre-chrome look).
fn chrome_tree(depth: u32, chrome: bool) -> facett_app::scene::Scene {
    use facett_app::scene::{Axis, Pane, Scene};
    fn build(prefix: &str, depth: u32, chrome: bool) -> Pane {
        if depth == 0 {
            let p = Pane::slot(format!("leaf{prefix}"), "cell").title("Leaf");
            return if chrome { p } else { p.no_chrome() };
        }
        let axis = if depth % 2 == 0 { Axis::Horizontal } else { Axis::Vertical };
        let kids = vec![build(&format!("{prefix}0"), depth - 1, chrome), build(&format!("{prefix}1"), depth - 1, chrome)];
        let p = Pane::split(format!("n{prefix}"), axis, 0.5, kids);
        if chrome { p } else { p.no_chrome() }
    }
    Scene::new("chrome-bench", build("", depth, chrome))
}

/// A slot host that draws one cheap label, so the frame cost is the SCENE's, not a
/// component's.
struct CellHost;
impl facett_app::scene::SceneHost for CellHost {
    fn slot(&mut self, _id: &str, ui: &mut egui::Ui) -> serde_json::Value {
        ui.label("·");
        serde_json::Value::Null
    }
}

/// Median ns per rendered frame for one scene, over `iters` frames after a warmup.
fn scene_frame_ns(scene: &facett_app::scene::Scene, iters: usize) -> (f64, usize) {
    let ctx = egui::Context::default();
    let rect = egui::Rect::from_min_size(Pos2::ZERO, egui::vec2(1920.0, 1080.0));
    let mut nav = facett_app::scene::NavState::new(scene);
    let raw = || egui::RawInput { screen_rect: Some(rect), ..Default::default() };
    let mut drawn = 0usize;
    // Warm the font atlas / layout caches so the measured frames are steady-state.
    for _ in 0..3 {
        // egui 0.35: `Context::run` is gone and `Panel::show` now takes a `&mut Ui`
        // rather than a `&Context` (that is the same rename the `show_inside`
        // deprecation warnings point at). `run_ui` is the one-for-one replacement —
        // it builds the root viewport `Ui` and runs a full pass around it, so the
        // CentralPanel this bench exists to time is still in the frame.
        ctx.run_ui(raw(), |ui| {
            egui::CentralPanel::default().show(ui, |ui| {
                let st = facett_app::scene::render(scene, &mut nav, ui, &mut CellHost);
                drawn = st["panes"].as_object().map(|o| o.len()).unwrap_or(0);
            });
        }).textures_delta.clear();
    }
    let mut samples: Vec<f64> = Vec::with_capacity(iters);
    for _ in 0..iters {
        let t = std::time::Instant::now();
        ctx.run_ui(raw(), |ui| {
            egui::CentralPanel::default().show(ui, |ui| {
                facett_app::scene::render(scene, &mut nav, ui, &mut CellHost);
            });
        }).textures_delta.clear();
        samples.push(t.elapsed().as_secs_f64() * 1e9);
    }
    samples.sort_by(|a, b| a.partial_cmp(b).unwrap());
    (samples[samples.len() / 2], drawn)
}

/// The chrome frame-cost arm: `scene::render` with the default chrome vs. every pane
/// opted out, at a REALISTIC shell size (31 panes) and at a pathological one (1023).
/// The realistic number is the one that decides whether default-ON was affordable; the
/// pathological one is the regression tripwire.
struct SceneChromeFrame;
impl Bencher for SceneChromeFrame {
    fn id(&self) -> &'static str {
        "facett.scene_chrome_frame"
    }
    fn run(&self) -> Result<BenchResult> {
        const ITERS: usize = 40;
        let mut metrics = serde_json::Map::new();
        for (depth, tag) in [(4u32, "shell"), (9u32, "deep")] {
            let (on_ns, panes) = scene_frame_ns(&chrome_tree(depth, true), ITERS);
            let (off_ns, off_panes) = scene_frame_ns(&chrome_tree(depth, false), ITERS);
            ensure!(panes == off_panes && panes > 1, "both trees must lay out the same {panes} panes (off: {off_panes})");
            metrics.insert(format!("{tag}_panes"), serde_json::json!(panes));
            metrics.insert(format!("{tag}_chrome_on_ns"), serde_json::json!(on_ns.round()));
            metrics.insert(format!("{tag}_chrome_off_ns"), serde_json::json!(off_ns.round()));
            metrics.insert(format!("{tag}_ns_per_pane"), serde_json::json!((on_ns / panes as f64).round()));
            let pct = if off_ns > 0.0 { ((on_ns - off_ns) / off_ns * 1000.0).round() / 10.0 } else { 0.0 };
            metrics.insert(format!("{tag}_chrome_overhead_pct"), serde_json::json!(pct));
            let fps = if on_ns > 0.0 { (1e9 / on_ns).round() } else { 0.0 };
            metrics.insert(format!("{tag}_frames_per_sec"), serde_json::json!(fps));
            // The number that actually decides affordability: the share of a 60fps frame
            // budget (16666667 ns) the scene walk costs with the chrome on.
            let budget = (on_ns / 16_666_667.0 * 1000.0).round() / 10.0;
            metrics.insert(format!("{tag}_pct_of_60fps_budget"), serde_json::json!(budget));
            if tag == "shell" {
                metrics.insert("n".into(), serde_json::json!(panes));
            }
        }
        Ok(BenchResult::measured("facett.scene_chrome_frame", metrics))
    }
}

nornir::register_bench!(Force2dScale);
nornir::register_bench!(Force3dScale);
nornir::register_bench!(GraphRender);
nornir::register_bench!(MapRender);
nornir::register_bench!(PanelFrame);
nornir::register_bench!(MapLayerFrame);
nornir::register_bench!(TableRender);
nornir::register_bench!(Map3dNearClip);
nornir::register_bench!(Map3dProject);
nornir::register_bench!(Map3dCull);
nornir::register_bench!(Map3dShade);
nornir::register_bench!(Map3dDepthSort);
nornir::register_bench!(Map3dScissor);
nornir::register_bench!(Map3dComposite);
nornir::register_bench!(DemoFrame);
nornir::register_bench!(SdfCircle);
nornir::register_bench!(SdfRing);
nornir::register_bench!(SdfMarker);
nornir::register_bench!(ThickLine);
nornir::register_bench!(CpuRaster);
nornir::register_bench!(GpuUpload);
nornir::register_bench!(SceneChromeFrame);

fn main() -> Result<()> {
    nornir::bench::api::run_main_json()
}