facett-demo/src/gfx_v2/mod.rs
//! **GFX v2** — the renderer track, on show and self-evidencing.
//!
//! One tab that exercises three of the GFX_V2 items together over [`Scene`], a
//! single scene of real Swedish geography:
//!
//! | item | what this tab does with it |
//! |------|----------------------------|
//! | **6 — 2D is a constrained 3D** | The two panes are **not** two views. The 3D view is the only one stored; the 2D view is produced from it by [`View::constrain_2d`]. Toggle *Constrain* and the right pane collapses onto the left one — and the tab reports the measured worst-case per-place disagreement in pixels. |
//! | **8 — GPU picking** | Clicking either pane runs an `R32Uint` id pass on the device and reads one texel back. The picked [`PickId`] resolves to a real place name. Item 8 shipped with no consumer at all; this is it. |
//! | **4 — GPU label collision** | The place names are laid out into the epaint font atlas and handed to the device, where three compute passes claim grid cells by `atomicMax` priority and append the winners to a `DrawIndexedIndirect`. See [`labels`]. |
//!
//! ## The trap this tab is built to avoid
//!
//! Five closed GFX_V2 items turned out to be "the feature was already there and
//! nothing was watching it": flowsim's GPU lane was proven and never called, item
//! 2's `cull.wgsl` was orphaned so deleting the whole pass failed no test, and a
//! pixel assertion passed at 486 px with **zero** device frames because the CPU
//! painter drew it.
//!
//! So no claim in this tab's [`state_json`](GfxV2Tab::state_json) is "a frame was
//! drawn". Every GPU claim is device-side and separately falsifiable:
//!
//! * `pick.passes_run` — `facett_core`'s process-wide count of id passes actually
//! submitted, **paired** with `pick.picked_id`, because a pass over an empty
//! batch would bump the counter while proving nothing.
//! * `pick.declined` — how many clicks the GPU lane refused (no host, empty batch,
//! degenerate pane, probe outside). A lane that silently declines every click
//! looks exactly like a lane that works, so the refusals are counted too.
//! * `labels.*` — read back out of the **indirect draw buffer** the device wrote:
//! the instance count the draw actually uses, plus how many labels won the
//! collision and how many were suppressed.
//! * `gpu.adapter` — the adapter name. A GPU test that skips gracefully goes green
//! having run no shader; if this field is absent, nothing ran.
//!
//! ## Arming
//!
//! The device lanes need an [`egui_wgpu::RenderState`], which a `Facet` cannot
//! reach from a `&mut Ui`. The host hands it over once via [`arm`] — `main.rs` at
//! startup, or a test with `egui_kittest`'s `create_render_state`. Un-armed, every
//! lane reports itself off and the CPU painter draws; nothing pretends.
pub mod scene;
#[cfg(all(feature = "gfx-v2-gpu", not(target_arch = "wasm32")))]
pub mod labels;
use egui::{Pos2, Rect, Sense, Stroke, StrokeKind, Ui, Vec2};
use facett_core::engine::pick::PickId;
use facett_core::render::View;
use serde_json::json;
pub use scene::{Place, Projected, Scene, LAYER_CITY, LAYER_PLACE};
/// The stable tab title — the deck / discovery-contract / LEGEND key.
pub const TITLE: &str = "GFX v2";
/// The field of view the 3D lane uses, in degrees.
const FOV_DEG: f32 = 45.0;
// ─────────────────────────────────────────────────────────────────────────────
// Arming — the host's one-time hand-over of the device.
// ─────────────────────────────────────────────────────────────────────────────
#[cfg(all(feature = "gfx-v2-gpu", not(target_arch = "wasm32")))]
mod armed {
use std::sync::RwLock;
static RENDER_STATE: RwLock<Option<egui_wgpu::RenderState>> = RwLock::new(None);
/// Hand the device to the GFX v2 lanes and install the pick host.
///
/// Returns whether this call was the FIRST to arm (a later call replaces the
/// armed device and returns `false`). A process-wide slot
/// rather than a field on the tab, for the same reason
/// `facett_core::render::gpu::install_pick_host` is one: the deck builds its
/// facets before `eframe` has a `RenderState` to give, and threading an
/// `Option<RenderState>` through every constructor buys nothing — the device is
/// process-wide in fact, so modelling it as anything else is a lie that costs
/// plumbing.
///
/// **A `RwLock<Option<_>>` and not a `OnceLock`, and the difference is load
/// bearing.** A `OnceLock` can be armed exactly once per process, and an
/// `egui_kittest` test binary legitimately builds SEVERAL devices — the
/// harness's `WgpuTestRenderer::from_render_state` refuses a `RenderState` that
/// has already been used, so every harness needs a fresh one. With a `OnceLock`
/// the second harness would paint through a device the lanes were not armed
/// with, the collider would be installed in the wrong `CallbackResources`, and
/// the compute pass would silently never run while the host-side frame counter
/// happily counted frames. That is not hypothetical: it is the first thing this
/// tab's device test caught. Last arm wins.
pub fn arm(render_state: &egui_wgpu::RenderState) -> bool {
let mut slot = RENDER_STATE.write().expect("the arm slot is never poisoned");
let first = slot.is_none();
*slot = Some(render_state.clone());
// Install unconditionally: `install_pick_host` is itself idempotent per
// device, and a second host would be a second set of GPU resources for one
// device.
facett_core::render::gpu::install_pick_host(render_state);
first
}
/// The armed device, if the host has handed one over. A clone — `RenderState` is
/// a bundle of `Arc`s, so this is cheap and it is what lets the slot be
/// re-armable without handing out a borrow of the lock.
pub fn render_state() -> Option<egui_wgpu::RenderState> {
RENDER_STATE.read().ok()?.clone()
}
/// The adapter's reported name — the honest answer to "did anything actually
/// run on a GPU". A graceful skip leaves this `None`.
pub fn adapter_name() -> Option<String> {
Some(RENDER_STATE.read().ok()?.as_ref()?.adapter.get_info().name)
}
}
#[cfg(all(feature = "gfx-v2-gpu", not(target_arch = "wasm32")))]
pub use armed::{adapter_name, arm, render_state};
/// Un-armed stand-ins for builds without the GPU lane (wasm, or the feature off).
/// They exist so the tab body needs no `cfg` at every call site — LAW: one writer.
#[cfg(not(all(feature = "gfx-v2-gpu", not(target_arch = "wasm32"))))]
mod armed_off {
pub fn adapter_name() -> Option<String> {
None
}
}
#[cfg(not(all(feature = "gfx-v2-gpu", not(target_arch = "wasm32"))))]
pub use armed_off::adapter_name;
// ─────────────────────────────────────────────────────────────────────────────
// The tab.
// ─────────────────────────────────────────────────────────────────────────────
/// Which pane a click landed in.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Pane {
/// The left pane — the 3D view collapsed by [`View::constrain_2d`].
TwoD,
/// The right pane — the stored 3D view (or the constrained one, when the
/// *Constrain* toggle is on).
ThreeD,
}
impl Pane {
pub fn as_str(self) -> &'static str {
match self {
Pane::TwoD => "2d",
Pane::ThreeD => "3d",
}
}
}
/// One recorded pick outcome, kept so `state_json` can report the *whole* answer
/// rather than only the happy path.
#[derive(Clone, Debug, Default)]
struct PickRecord {
/// `Some(id)` for a resolved click — including [`PickId::NOTHING`], which is a
/// real click on empty space and NOT the same thing as a declined lane.
id: Option<PickId>,
pane: Option<Pane>,
/// The place the id resolved to, if any.
name: Option<&'static str>,
/// Clicks the GPU lane answered.
resolved: u64,
/// Clicks the GPU lane declined (`pick_at` returned `None`) — no host, empty
/// batch, degenerate pane, or a probe outside the pane.
declined: u64,
/// Clicks that fell through to the CPU lane because the GPU declined.
cpu_fallbacks: u64,
/// The last id the CPU fallback produced, so the two lanes can be compared.
cpu_id: Option<PickId>,
}
/// **GFX v2** — the item 4 / 6 / 8 showcase over one real scene.
pub struct GfxV2Tab {
title: String,
scene: Scene,
/// **The only view.** The 2D lane is derived from this by
/// [`View::constrain_2d`] every frame — see [`Self::view_2d`]. Storing a second
/// `View` for the 2D pane is exactly the twinning item 6 removed.
view_3d: View,
/// When set, the 3D pane renders the *constrained* view too, so the two panes
/// must agree pixel-for-pixel.
constrain: bool,
/// Draw the collision-grid cells the label lane claims into.
show_grid: bool,
/// Drive the GPU label lane (item 4). Off ⇒ the CPU painter draws the names.
gpu_labels: bool,
pick: PickRecord,
/// Worst per-place |Δ| in points between the two panes' projections while
/// *Constrain* is on. `None` until a constrained frame has been measured.
parity_max_pt: Option<f32>,
#[cfg(all(feature = "gfx-v2-gpu", not(target_arch = "wasm32")))]
labels: labels::LabelLane,
}
impl Default for GfxV2Tab {
fn default() -> Self {
Self::new(TITLE)
}
}
impl GfxV2Tab {
/// Build the tab over the Sweden scene, framed so the whole country is visible.
pub fn new(title: impl Into<String>) -> Self {
let scene = Scene::sweden();
let c = scene.centre();
let (_, h) = scene.extent();
// Sit back far enough that the scene's height fills the frustum at FOV_DEG.
// Derived from the scene rather than a tuned constant, so the framing cannot
// silently stop matching the data.
let distance = (h * 0.5) / (FOV_DEG.to_radians() * 0.5).tan() * 1.25;
// `from_orbit` rather than `perspective_3d`, so this crate needs no `glam`
// edge of its own: `V3` is facett-core's own plain vector and `from_orbit`
// maps elevation → tilt inside the view core (the ONE writer).
let view_3d = View::from_orbit(
facett_core::render::camera::V3::new(c[0], 0.0, c[1]),
0.0,
std::f32::consts::FRAC_PI_2 - 0.60,
distance,
FOV_DEG.to_radians(),
);
Self {
title: title.into(),
scene,
view_3d,
constrain: false,
show_grid: false,
gpu_labels: true,
pick: PickRecord::default(),
parity_max_pt: None,
#[cfg(all(feature = "gfx-v2-gpu", not(target_arch = "wasm32")))]
labels: labels::LabelLane::default(),
}
}
/// **Item 6, as a derivation rather than a duplicate.** The 2D view IS the 3D
/// view collapsed onto the constraint surface: tilt and azimuth to zero,
/// projection to ortho, terrain flat, post-FX off, and the perspective scale at
/// the target converted into the equivalent `px_per_unit`.
///
/// It is a method, not a field, on purpose. A stored 2D view is a second copy of
/// the camera that can drift from the first — which is the bug class item 6
/// existed to remove, so the demo must not reintroduce it.
pub fn view_2d(&self, half_h: f32) -> View {
let mut v = self.view_3d;
v.constrain_2d(half_h);
v
}
/// The view a given pane renders with.
fn view_for(&self, pane: Pane, half_h: f32) -> View {
match pane {
Pane::TwoD => self.view_2d(half_h),
Pane::ThreeD if self.constrain => self.view_2d(half_h),
Pane::ThreeD => self.view_3d,
}
}
/// The scene, for hosts and tests.
pub fn scene(&self) -> &Scene {
&self.scene
}
/// The last picked id, if a click has been resolved.
pub fn picked(&self) -> Option<PickId> {
self.pick.id
}
/// The name the last pick resolved to.
pub fn picked_name(&self) -> Option<&'static str> {
self.pick.name
}
/// Turn the GPU label lane on or off (the host's / a robot's switch).
pub fn set_gpu_labels(&mut self, on: bool) {
self.gpu_labels = on;
}
/// Turn the item-6 constraint on or off.
pub fn set_constrain(&mut self, on: bool) {
self.constrain = on;
}
/// **Read the label lane's indirect draw buffer back off the device.**
///
/// This is the tab's device-side proof hook for item 4: the `instance_count` the
/// indirect draw actually uses, plus how many labels won the collision and how
/// many were suppressed. Nothing on the CPU chose any of those numbers.
///
/// It **blocks** on a `device.poll` — roughly a frame of latency — so it is a
/// button and a test hook, never part of the paint loop. `None` when no device is
/// armed or the collider has not been installed by a first draw yet.
#[cfg(all(feature = "gfx-v2-gpu", not(target_arch = "wasm32")))]
pub fn read_label_device_stats(&mut self) -> Option<labels::DeviceStats> {
self.labels.read_device_stats()
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Painting.
// ─────────────────────────────────────────────────────────────────────────────
/// A borrowed (rect, projected) pair — the only two things the pick batch needs, so
/// a caller that has projected a pane can build a batch without owning a `PaneFrame`.
#[cfg(all(feature = "gfx-v2-gpu", not(target_arch = "wasm32")))]
struct PaneFrameRef<'a> {
rect: Rect,
projected: &'a [Projected],
}
/// The projected scene for one pane, plus the geometry the pick lane needs.
struct PaneFrame {
view: View,
rect: Rect,
projected: Vec<Projected>,
}
impl GfxV2Tab {
/// Project the scene for one pane. The ONE place a pane's geometry is
/// computed — the painter, the label anchors and the pick quads all read this,
/// so a label can never end up describing a different projection than the dot
/// it sits on.
fn frame(&self, pane: Pane, rect: Rect) -> PaneFrame {
let half_h = rect.height() * 0.5;
let view = self.view_for(pane, half_h);
let centre = (rect.center().x, rect.center().y);
let projected = self.scene.project(&view, centre, half_h);
PaneFrame { view, rect, projected }
}
/// Radius in points for a place — cities read larger, by real population weight.
fn radius(p: &Place) -> f32 {
if p.is_city() {
2.5 + (p.weight * 0.20).min(5.0)
} else {
1.6
}
}
/// Paint the dots. Names are drawn by the label lane (GPU) or, when that is
/// off, by [`Self::paint_cpu_labels`].
fn paint_dots(&self, ui: &Ui, f: &PaneFrame, theme: &facett_core::Theme) {
let painter = ui.painter_at(f.rect);
for (p, pr) in self.scene.places.iter().zip(&f.projected) {
if !pr.visible {
continue;
}
let pos = Pos2::new(pr.x, pr.y);
if !f.rect.contains(pos) {
continue;
}
let picked = self.pick.id == Some(p.id);
let col = if picked {
theme.accent
} else if p.is_city() {
theme.text
} else {
theme.text_dim
};
let r = Self::radius(p);
painter.circle_filled(pos, r, col);
if picked {
// The pick answer, made visible: a ring on the thing that was hit.
painter.circle_stroke(pos, r + 4.0, Stroke::new(1.5, theme.accent));
}
}
}
/// The CPU label fallback — deliberately **naive**: it draws every name with no
/// collision resolution at all.
///
/// That is the point. Flip *GPU labels* off and the pane turns into the
/// unreadable pile-up that the device-side collision pass exists to prevent, so
/// the difference between the two is visible rather than asserted. A CPU
/// declutter here would hide exactly the thing this tab is showing.
fn paint_cpu_labels(&self, ui: &Ui, f: &PaneFrame, theme: &facett_core::Theme) {
let painter = ui.painter_at(f.rect);
for (p, pr) in self.scene.places.iter().zip(&f.projected) {
if !pr.visible || !f.rect.contains(Pos2::new(pr.x, pr.y)) {
continue;
}
let size = if p.is_city() { 12.0 } else { 9.0 };
painter.text(
Pos2::new(pr.x + Self::radius(p) + 3.0, pr.y),
egui::Align2::LEFT_CENTER,
p.name,
egui::FontId::proportional(size),
if p.is_city() { theme.text } else { theme.text_dim },
);
}
}
/// Draw the collision grid the label lane claims cells in, so the mechanism is
/// visible and not merely described.
fn paint_grid(&self, ui: &Ui, f: &PaneFrame, theme: &facett_core::Theme) {
let painter = ui.painter_at(f.rect);
let cols = facett_core::label_grid::GRID_W;
let rows = facett_core::label_grid::GRID_H;
let cw = f.rect.width() / cols as f32;
let ch = f.rect.height() / rows as f32;
let col = theme.text_dim.linear_multiply(0.18);
// Every 8th line only — 128x96 lines at once is noise, not information.
for i in (0..=cols).step_by(8) {
let x = f.rect.left() + i as f32 * cw;
painter.line_segment([Pos2::new(x, f.rect.top()), Pos2::new(x, f.rect.bottom())], Stroke::new(1.0, col));
}
for j in (0..=rows).step_by(8) {
let y = f.rect.top() + j as f32 * ch;
painter.line_segment([Pos2::new(f.rect.left(), y), Pos2::new(f.rect.right(), y)], Stroke::new(1.0, col));
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Picking (item 8) — the CPU lane, and the device lane.
// ─────────────────────────────────────────────────────────────────────────────
impl GfxV2Tab {
/// The CPU pick: nearest place whose disc contains the probe, painter order.
///
/// This is the FALLBACK, and it is also the oracle the device lane is checked
/// against — `pick_at` returning `None` means the GPU declined, and a caller
/// that treated that as "nothing there" would silently deselect. So the CPU
/// answer is always computed and always reported.
fn cpu_pick(&self, f: &PaneFrame, probe: Pos2) -> PickId {
let mut best = PickId::NOTHING;
for i in self.pick_order() {
let p = &self.scene.places[i];
let pr = f.projected[i];
if !pr.visible {
continue;
}
let d = Vec2::new(pr.x - probe.x, pr.y - probe.y).length();
if d <= Self::radius(p) + 3.0 {
// Later in `pick_order` wins, matching the painter-order depth the
// GPU batch is built with, so the two lanes answer the same question.
best = p.id;
}
}
best
}
/// **The ONE pick order**, shared by the CPU lane and the device batch:
/// addresses first, cities last, so a city is on top.
///
/// This exists because of a real behaviour found by the test below. The scene's
/// addresses cluster *on* the city centres by construction (that is what
/// `address_for` does), so with plain scene order a click on "Stockholm" returned
/// one of the anonymous addresses sitting on top of it — technically the
/// painter-order answer, and the wrong object to hand a user.
///
/// Both lanes read this list, rather than each imposing its own order, because
/// their DEFAULT tie-breaks are opposite: a colour pass with no depth answers
/// "whichever quad was submitted last", while a CPU containment scan naturally
/// answers "the first one found". For overlapping features those are different
/// objects — a wrong click, not a cosmetic difference — so the order has to be
/// one writer or the GPU and CPU lanes silently disagree exactly where the scene
/// is densest.
fn pick_order(&self) -> Vec<usize> {
let mut idx: Vec<usize> = (0..self.scene.places.len()).collect();
idx.sort_by_key(|&i| u8::from(self.scene.places[i].is_city()));
idx
}
/// Handle a click in a pane: run the device pick if armed, else the CPU one.
fn on_click(&mut self, pane: Pane, f: &PaneFrame, probe_screen: Pos2, _ppp: f32) {
let local = Pos2::new(probe_screen.x - f.rect.left(), probe_screen.y - f.rect.top());
let cpu = self.cpu_pick(f, probe_screen);
#[cfg(all(feature = "gfx-v2-gpu", not(target_arch = "wasm32")))]
let gpu = self.device_pick(f, local, _ppp);
#[cfg(not(all(feature = "gfx-v2-gpu", not(target_arch = "wasm32"))))]
let gpu: Option<PickId> = None;
let _ = local;
match gpu {
Some(id) => {
self.pick.resolved += 1;
self.pick.id = Some(id);
}
None => {
// The GPU lane DECLINED. Count it, fall back, and keep both answers
// so `state_json` cannot report a GPU pick that never happened.
self.pick.declined += 1;
self.pick.cpu_fallbacks += 1;
self.pick.id = Some(cpu);
}
}
self.pick.cpu_id = Some(cpu);
self.pick.pane = Some(pane);
self.pick.name = self.pick.id.and_then(|id| self.scene.by_id(id)).map(|p| p.name);
}
/// Build the device pick batch for a pane and resolve one probe through it.
///
/// `pane_px` is the pane's extent in **physical** pixels and the vertices are in
/// pane-local physical pixels — the space `pick_at` documents. Building the
/// batch in screen space instead is the measured 32.6 px misplacement that
/// `flowsim`'s host records, and every pixel assertion stayed green through it.
#[cfg(all(feature = "gfx-v2-gpu", not(target_arch = "wasm32")))]
fn device_pick(&self, f: &PaneFrame, local: Pos2, ppp: f32) -> Option<PickId> {
use facett_core::render::gpu::pick_at;
let rs = render_state()?;
let batch = self.pick_batch(&f.projected, f.rect, ppp);
let pane_px = ((f.rect.width() * ppp) as u32, (f.rect.height() * ppp) as u32);
pick_at(&rs, &batch, pane_px, local, ppp, true)
}
/// **The ONE pick-batch writer.** Build the device batch for a projected pane, in
/// pane-local PHYSICAL pixels with the depth that makes the device agree with
/// [`Self::cpu_pick`].
///
/// Public because the device test drives it. A test that re-implemented this
/// would be asserting against its own copy of the geometry — which is how a
/// batch built in screen space instead of pane space passes every test and is
/// still measurably 32.6 px out of place in the app.
#[cfg(all(feature = "gfx-v2-gpu", not(target_arch = "wasm32")))]
pub fn pick_batch(
&self,
projected: &[Projected],
rect: Rect,
ppp: f32,
) -> facett_core::render::gpu::PickBatch {
use facett_core::render::gpu::PickBatch;
let f = PaneFrameRef { rect, projected };
let mut batch = PickBatch::new();
// The SAME order the CPU lane scans in — see `pick_order`.
let visible: Vec<usize> =
self.pick_order().into_iter().filter(|&i| f.projected[i].visible).collect();
let n = visible.len();
for (order, &i) in visible.iter().enumerate() {
let p = &self.scene.places[i];
let pr = &f.projected[i];
let r = Self::radius(p);
// Pane-local, in physical pixels.
let cx = (pr.x - f.rect.left()) * ppp;
let cy = (pr.y - f.rect.top()) * ppp;
let rr = (r + 3.0) * ppp;
let rect = Rect::from_min_max(Pos2::new(cx - rr, cy - rr), Pos2::new(cx + rr, cy + rr));
// Depth, so the device tie-break matches `cpu_pick`'s "later in
// `pick_order` wins".
//
// The index is REVERSED, and that is not a stylistic choice — it was
// measured. `painter_depth(i, n)` returns `(i + 0.5) / n`, so a later
// index is a LARGER depth, and the id pipeline's depth compare is
// `CompareFunction::Less`: the smallest depth wins. Passing the paint
// index straight through therefore makes the FIRST-submitted quad win,
// which is the bottom of the paint order rather than the top.
//
// Found by the device test in `tests/gfx_v2_device.rs`: with the plain
// index, clicking Stockholm returned `0x02000001` — an anonymous address
// sitting under it — while the CPU lane correctly returned the city. Two
// lanes, two different objects, from one click.
//
// Reversing here fixes THIS consumer without touching item 8's shipped
// helper, whose name (`..._in_painter_order`) and the tie-break it
// actually produces with `depth_test: true` do not agree. That belongs to
// whoever owns the pick lane; recorded here so it is not lost.
batch.push_quad_in_painter_order(rect, p.id, n - 1 - order, n);
}
batch
}
}
// ─────────────────────────────────────────────────────────────────────────────
// The Facet impl.
// ─────────────────────────────────────────────────────────────────────────────
impl facett_core::Facet for GfxV2Tab {
fn title(&self) -> &str {
&self.title
}
fn ui(&mut self, ui: &mut Ui) {
facett::core::navgraph::declare_own_draw_order(ui); // atoms follow the draw order (navgraph §9)
let theme = facett_core::theme(ui);
self.controls(ui, &theme);
ui.separator();
// Two panes, side by side, over the SAME scene.
let avail = ui.available_size();
let pane_w = ((avail.x - 8.0) * 0.5).max(64.0);
let pane_h = avail.y.max(64.0);
let mut frames: Vec<(Pane, PaneFrame)> = Vec::with_capacity(2);
ui.horizontal(|ui| {
for pane in [Pane::TwoD, Pane::ThreeD] {
let (id, rect) = ui.allocate_space(Vec2::new(pane_w, pane_h));
let f = self.frame(pane, rect);
// A real interactive surface, and an AccessKit node with a STABLE
// domain key (the pane's name, never a loop index) so the robot can
// find it by role/label rather than by pixel.
let resp = facett_core::a11y::node(
ui,
id,
("gfx_v2_pane", pane.as_str()),
Sense::click(),
rect,
facett_core::a11y::Semantics::button(match pane {
Pane::TwoD => "gfx-v2 2d pane",
Pane::ThreeD => "gfx-v2 3d pane",
}),
);
let painter = ui.painter_at(rect);
painter.rect_filled(rect, 4.0, theme.panel_bg);
painter.rect_stroke(rect, 4.0, Stroke::new(1.0, theme.text_dim.linear_multiply(0.5)), StrokeKind::Inside);
if self.show_grid {
self.paint_grid(ui, &f, &theme);
}
self.paint_dots(ui, &f, &theme);
let mut labels_on_device = false;
#[cfg(all(feature = "gfx-v2-gpu", not(target_arch = "wasm32")))]
if self.gpu_labels {
labels_on_device = self.labels.draw(ui, &f.rect, &self.scene, &f.projected, &theme);
}
if !labels_on_device {
// The GPU lane did not take the frame — draw the names on the
// CPU. `draw` returning false is the ONLY licence to skip the
// CPU painter; opting in without that check is how flowsim's
// overlay went silently missing.
self.paint_cpu_labels(ui, &f, &theme);
}
self.pane_caption(ui, &f, pane, &theme, labels_on_device);
if let Some(pos) = resp.interact_pointer_pos() && resp.clicked() {
let ppp = ui.ctx().pixels_per_point();
self.on_click(pane, &f, pos, ppp);
}
frames.push((pane, f));
ui.add_space(8.0);
}
});
// Item 6's measurement: with the constraint on, both panes ran the SAME
// derived view, so every projected place must land in the same place. This
// is the applied output — projected pixels — not a state round-trip.
if self.constrain && frames.len() == 2 {
let (_, a) = &frames[0];
let (_, b) = &frames[1];
let mut worst = 0.0f32;
for (pa, pb) in a.projected.iter().zip(&b.projected) {
// Compare pane-LOCAL positions: the two panes sit at different
// screen offsets, so raw screen coordinates would differ by the
// pane pitch and the check would be measuring the layout.
let ax = pa.x - a.rect.left();
let ay = pa.y - a.rect.top();
let bx = pb.x - b.rect.left();
let by = pb.y - b.rect.top();
worst = worst.max((ax - bx).abs()).max((ay - by).abs());
}
self.parity_max_pt = Some(worst);
}
}
fn state_json(&self) -> serde_json::Value {
let half_h = 300.0; // the reported view is measured at a fixed reference height
let v2 = self.view_2d(half_h);
let v3 = self.view_3d;
json!({
// ── the scene ────────────────────────────────────────────────────
"scene": {
"source": "knut_popsim::geo (real WGS-84 Swedish geography)",
"places": self.scene.places.len(),
"cities": knut_popsim::geo::CITIES.len(),
"distinct_names": self.scene.distinct_names(),
"bounds": self.scene.bounds,
},
// ── item 6: 2D is a constrained 3D ───────────────────────────────
"view": {
"constrain": self.constrain,
"two_d": {
"is_constrained_2d": v2.is_constrained_2d(),
"tilt_deg": v2.tilt_deg(),
"azimuth_deg": v2.azimuth_deg(),
"ortho": v2.projection.is_ortho(),
"any_post": v2.post.any(),
},
"three_d": {
"is_constrained_2d": v3.is_constrained_2d(),
"tilt_deg": v3.tilt_deg(),
"azimuth_deg": v3.azimuth_deg(),
"ortho": v3.projection.is_ortho(),
"any_post": v3.post.any(),
"distance": v3.distance,
},
// The measured worst-case per-place disagreement, in points, between
// the two panes while the constraint is on. 0.0 is the claim; any
// other number means the derivation is not exact.
"parity_max_pt": self.parity_max_pt,
},
// ── item 8: GPU picking ──────────────────────────────────────────
"pick": {
"picked_id": self.pick.id.map(|i| i.0),
"picked_layer": self.pick.id.map(|i| i.layer()),
"picked_feature": self.pick.id.map(|i| i.feature()),
"picked_name": self.pick.name,
"pane": self.pick.pane.map(|p| p.as_str()),
"resolved": self.pick.resolved,
"declined": self.pick.declined,
"cpu_fallbacks": self.pick.cpu_fallbacks,
"cpu_id": self.pick.cpu_id.map(|i| i.0),
// Device-side: id passes actually submitted, process-wide. Only
// meaningful paired with `picked_id` — a pass over an empty batch
// would bump this while proving nothing.
"passes_run": pick_passes_run(),
"host_installed": pick_host_installed(),
},
// ── item 4: GPU label collision ──────────────────────────────────
"labels": self.labels_json(),
// ── the honest renderer answer ───────────────────────────────────
"gpu": {
"adapter": adapter_name(),
"armed": adapter_name().is_some(),
},
})
}
fn update_json(&mut self, msg_json: &str) {
let Ok(v) = serde_json::from_str::<serde_json::Value>(msg_json) else { return };
if let Some(b) = v["constrain"].as_bool() {
self.constrain = b;
}
if let Some(b) = v["gpu_labels"].as_bool() {
self.gpu_labels = b;
}
if let Some(b) = v["show_grid"].as_bool() {
self.show_grid = b;
}
}
}
// ── the process-wide device counters, with un-armed stand-ins ────────────────
#[cfg(all(feature = "gfx-v2-gpu", not(target_arch = "wasm32")))]
fn pick_passes_run() -> u64 {
facett_core::render::gpu::pick_passes_run()
}
#[cfg(all(feature = "gfx-v2-gpu", not(target_arch = "wasm32")))]
fn pick_host_installed() -> bool {
facett_core::render::gpu::pick_host_installed()
}
#[cfg(not(all(feature = "gfx-v2-gpu", not(target_arch = "wasm32"))))]
fn pick_passes_run() -> u64 {
0
}
#[cfg(not(all(feature = "gfx-v2-gpu", not(target_arch = "wasm32"))))]
fn pick_host_installed() -> bool {
false
}
impl GfxV2Tab {
#[cfg(all(feature = "gfx-v2-gpu", not(target_arch = "wasm32")))]
fn labels_json(&self) -> serde_json::Value {
self.labels.state_json(self.gpu_labels)
}
#[cfg(not(all(feature = "gfx-v2-gpu", not(target_arch = "wasm32"))))]
fn labels_json(&self) -> serde_json::Value {
json!({ "lane": "cpu", "requested": self.gpu_labels, "reason": "gfx-v2-gpu feature off" })
}
/// The control strip. Every control is a real, robot-clickable widget whose
/// effect is visible in `state_json`.
fn controls(&mut self, ui: &mut Ui, theme: &facett_core::Theme) {
ui.horizontal_wrapped(|ui| {
ui.checkbox(&mut self.constrain, "Constrain 3D → 2D (item 6)");
ui.checkbox(&mut self.gpu_labels, "GPU labels (item 4)");
ui.checkbox(&mut self.show_grid, "Collision grid");
ui.separator();
match adapter_name() {
Some(a) => {
ui.label(egui::RichText::new(format!("device: {a}")).color(theme.accent).small());
}
None => {
// Say so, loudly. A tab that looks GPU-driven while the CPU
// painter does the work is this fleet's signature failure.
ui.label(
egui::RichText::new("device: NOT ARMED — CPU painter")
.color(theme.point)
.small(),
);
}
}
if let Some(n) = self.pick.name {
ui.separator();
ui.label(egui::RichText::new(format!("picked: {n}")).color(theme.accent).small());
} else if self.pick.id == Some(PickId::NOTHING) {
ui.separator();
ui.label(egui::RichText::new("picked: (empty space)").color(theme.text_dim).small());
}
});
}
/// The per-pane caption: which view, and what the lanes did.
fn pane_caption(&self, ui: &Ui, f: &PaneFrame, pane: Pane, theme: &facett_core::Theme, on_device: bool) {
let painter = ui.painter_at(f.rect);
let what = match pane {
Pane::TwoD => "2D — View::constrain_2d(3D)",
Pane::ThreeD if self.constrain => "3D — constrained (must match 2D)",
Pane::ThreeD => "3D — perspective",
};
let lane = if on_device { "labels: GPU collision" } else { "labels: CPU (no declutter)" };
let flag = if f.view.is_constrained_2d() { "is_constrained_2d = true" } else { "is_constrained_2d = false" };
for (i, line) in [what, flag, lane].iter().enumerate() {
painter.text(
f.rect.left_top() + Vec2::new(8.0, 8.0 + i as f32 * 13.0),
egui::Align2::LEFT_TOP,
*line,
egui::FontId::monospace(10.0),
if i == 0 { theme.text } else { theme.text_dim },
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use facett_core::Facet;
/// **Item 6, as this tab makes it structural.** The 2D view is not stored — it
/// is derived — and the derivation lands exactly on the constraint surface.
///
/// Red-when-broken: give the tab a second stored `View` for the 2D pane, or drop
/// the `constrain_2d` call, and this fails.
#[test]
fn the_two_d_view_is_the_three_d_view_constrained() {
let tab = GfxV2Tab::default();
assert!(!tab.view_3d.is_constrained_2d(), "the stored view is a real perspective 3D view");
let v2 = tab.view_2d(300.0);
assert!(v2.is_constrained_2d(), "the derived view satisfies every 2D constraint");
assert_eq!(v2.tilt_deg(), 0.0, "tilt collapses to plan");
assert_eq!(v2.azimuth_deg(), 0.0, "azimuth collapses to north-up");
assert!(v2.projection.is_ortho(), "projection collapses to ortho");
assert!(!v2.post.any(), "the post-FX bypass is derived FROM the view");
// The target is preserved — the collapse changes the camera, not the subject.
assert_eq!(v2.target.x, tab.view_3d.target.x);
assert_eq!(v2.target.z, tab.view_3d.target.z);
}
/// The derivation is a pure function of the stored view, so two panes running it
/// project **bit-identically** — which is what makes the on-screen parity
/// measurement meaningful rather than a tolerance nobody checks.
#[test]
fn the_derived_view_projects_bit_identically_twice() {
let tab = GfxV2Tab::default();
let rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(600.0, 600.0));
let a = tab.frame(Pane::TwoD, rect);
let mut t2 = GfxV2Tab::default();
t2.set_constrain(true);
let b = t2.frame(Pane::ThreeD, rect);
assert_eq!(a.projected.len(), b.projected.len());
for (i, (pa, pb)) in a.projected.iter().zip(&b.projected).enumerate() {
assert_eq!(pa.x.to_bits(), pb.x.to_bits(), "place {i} x is bit-equal");
assert_eq!(pa.y.to_bits(), pb.y.to_bits(), "place {i} y is bit-equal");
}
}
/// And the constraint is not vacuous: WITHOUT it the two panes disagree by a
/// large, visible amount. A parity check whose two sides are identical by
/// construction proves nothing — this is the red half of the pair.
#[test]
fn without_the_constraint_the_two_panes_genuinely_disagree() {
let tab = GfxV2Tab::default();
let rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(600.0, 600.0));
let two = tab.frame(Pane::TwoD, rect);
let three = tab.frame(Pane::ThreeD, rect);
let worst = two
.projected
.iter()
.zip(&three.projected)
.map(|(a, b)| (a.x - b.x).abs().max((a.y - b.y).abs()))
.fold(0.0f32, f32::max);
assert!(worst > 20.0, "a perspective view must differ from its plan by more than {worst} pt");
}
/// `state_json` reports the whole pick answer, including the parts that would
/// hide a dead lane: how many clicks were DECLINED and whether a device is
/// armed at all.
#[test]
fn state_json_cannot_claim_a_gpu_that_is_not_there() {
let tab = GfxV2Tab::default();
let s = tab.state_json();
assert_eq!(s["scene"]["places"], 14 + scene::PLACE_COUNT);
assert!(s["pick"].get("declined").is_some(), "declined clicks are reported");
assert!(s["pick"].get("host_installed").is_some(), "the host latch is reported");
assert!(s["gpu"].get("adapter").is_some(), "the adapter field always exists");
// Un-armed in a plain unit test: `armed` must say so rather than defaulting
// to something reassuring.
if s["gpu"]["adapter"].is_null() {
assert_eq!(s["gpu"]["armed"], false, "no adapter ⇒ not armed");
}
}
/// The CPU pick lane answers with a real place, and with `NOTHING` off-target —
/// the oracle the device lane is compared against.
///
/// The city assertion is the one that found a real bug: the scene's addresses
/// cluster ON the city centres, so in plain scene order a click on Stockholm
/// returned an anonymous address drawn over it. `pick_order` is the fix, and
/// this is the guard that keeps it.
#[test]
fn clicking_a_city_picks_the_city_not_an_address_stacked_on_it() {
let tab = GfxV2Tab::default();
let rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(800.0, 800.0));
let f = tab.frame(Pane::TwoD, rect);
// Every visible city must be pickable AS a city, not as whatever landed on
// top of it. Checking one city would pass on a lucky sparse one.
let mut checked = 0;
for (i, p) in tab.scene.places.iter().enumerate() {
if !p.is_city() {
continue;
}
let pr = f.projected[i];
if !pr.visible || !rect.contains(Pos2::new(pr.x, pr.y)) {
continue;
}
let hit = tab.cpu_pick(&f, Pos2::new(pr.x, pr.y));
assert_eq!(hit.layer(), LAYER_CITY, "clicking {} must pick a city, got layer {}", p.name, hit.layer());
assert_eq!(hit, p.id, "and specifically {}", p.name);
checked += 1;
}
assert!(checked >= 10, "at least 10 cities were on screen to check, got {checked}");
// Far outside every dot.
let miss = tab.cpu_pick(&f, Pos2::new(rect.right() - 1.0, rect.top() + 1.0));
assert!(miss.is_nothing(), "empty space picks NOTHING");
}
/// **The two lanes must scan the SAME order**, or they disagree exactly where the
/// scene is densest. The device batch is built by walking `pick_order` and
/// stamping painter-order depth, so this asserts the order itself puts every
/// city after every address.
///
/// Red-when-broken: revert `pick_order` to plain scene order and this fails,
/// which is the drift that would otherwise only show up as a wrong click.
#[test]
fn both_pick_lanes_share_one_order_with_cities_on_top() {
let tab = GfxV2Tab::default();
let order = tab.pick_order();
assert_eq!(order.len(), tab.scene.places.len(), "the order covers every place exactly once");
let mut seen = order.clone();
seen.sort_unstable();
seen.dedup();
assert_eq!(seen.len(), order.len(), "no place appears twice");
let first_city = order.iter().position(|&i| tab.scene.places[i].is_city()).expect("a city");
// Everything before the first city is an address; everything after is a city.
assert!(
order[..first_city].iter().all(|&i| !tab.scene.places[i].is_city()),
"addresses come first",
);
assert!(
order[first_city..].iter().all(|&i| tab.scene.places[i].is_city()),
"and cities are the contiguous tail — so painter-order depth puts them on top",
);
}
/// `update_json` is the robot's write surface for the two toggles that change
/// what the tab proves.
#[test]
fn update_json_drives_the_toggles() {
let mut tab = GfxV2Tab::default();
assert_eq!(tab.state_json()["view"]["constrain"], false);
tab.update_json(r#"{"constrain": true}"#);
assert_eq!(tab.state_json()["view"]["constrain"], true);
tab.update_json(r#"{"gpu_labels": false}"#);
assert_eq!(tab.state_json()["labels"]["requested"], false);
}
}