facett-core 0.1.16

facett — visual kernel: render a node/edge Scene into egui (wgpu fast path to come)
Documentation
//! **The host lane for GPU picking** (feature `wgpu`) — the one entry point a widget
//! calls to turn a click into a [`PickId`], and the install-once lifecycle that makes
//! it work.
//!
//! # Why this is NOT an `egui_wgpu::CallbackTrait`
//!
//! Every other GPU lane in the tree (`graphcloud::cloud`, `flowsim::gpu::host`,
//! `facett-map`, `facett-map3d`) is a paint callback: a persistent renderer in
//! `callback_resources`, an `install_*`, a `prepare` that encodes and a `paint` that
//! blits. This module keeps **three of those four** and deliberately drops the fourth,
//! because picking is a **query, not a paint**:
//!
//! * the id target is never composited — compositing it would put raw id numbers on
//!   screen as colours;
//! * the answer must come back on the *same* click, and a `CallbackTrait::prepare`
//!   records into an encoder egui submits *after* `prepare` returns, so a readback
//!   there is either a frame late or has to submit its own work anyway;
//! * a pick runs **once per click**, not once per frame, so hanging it off the paint
//!   loop would run an entire extra pass on every frame that nothing was clicked.
//!
//! What is kept, because it is the part that carries the value:
//!
//! * **the persistent renderer** — [`PickHost`] lives in the `RenderState`'s
//!   `callback_resources` via the shared
//!   [`install_renderer`](super::install_renderer), so the pipeline, the shader module
//!   and the id texture survive between clicks instead of being rebuilt per query;
//! * **an install fn + an installed latch** — [`install_pick_host`] /
//!   [`pick_host_installed`], the same gate `draw_flow` uses;
//! * **one fail-safe entry point** — [`pick_at`] returns `Option<PickId>`, and `None`
//!   means *"the GPU lane did not answer, use your CPU picker"*. It never invents a
//!   miss, because a fabricated `NOTHING` is indistinguishable from a real click on
//!   empty space and would silently deselect instead of falling back.
//!
//! # Cost, stated plainly
//!
//! [`pick_at`] submits an id pass and **blocks** on a one-texel readback
//! (`device.poll(wait_indefinitely)`). That is a GPU round-trip on the calling thread,
//! roughly a frame's worth of latency, once per click. It is the same trade deck.gl
//! makes, and it is why the pass is gated on a probe existing rather than run
//! speculatively every frame.

use super::picking::{PickBatch, PickPass, PickTarget, PICK_DEPTH_FORMAT};
use crate::engine::pick::PickId;
use egui::Pos2;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};

/// Has a host installed the pick lane in this process? Read this before letting a
/// widget rely on GPU picking — see [`pick_at`]'s gate 1.
static INSTALLED: AtomicBool = AtomicBool::new(false);

/// Id passes actually submitted to a device in this process.
static PASSES: AtomicU64 = AtomicU64::new(0);

/// `true` once a host has installed the pick lane. Monotonic (installing is a startup
/// act, never undone).
#[must_use]
pub fn pick_host_installed() -> bool {
    INSTALLED.load(Ordering::Relaxed)
}

/// **How many id passes have genuinely been submitted to a device**, process-wide.
///
/// Lane ATTRIBUTION, not a correctness oracle: it says *which* lane answered, which
/// the returned id alone cannot — a CPU fallback and a GPU hit can return the very
/// same `PickId`, and that is the whole point of them agreeing. Only meaningful PAIRED
/// with an id assertion; on its own it is the state round-trip LAW 2 forbids, since a
/// pass over an empty batch would still bump it.
///
/// Incremented only after a pass has been recorded and submitted against a real
/// device with a probe in range.
#[must_use]
pub fn pick_passes_run() -> u64 {
    PASSES.load(Ordering::Relaxed)
}

/// The persistent pick resources: the id target and the pipeline, kept across clicks.
///
/// Holds **both** a colour-only and a depth-capable pass, built lazily, because a
/// pipeline's depth state must match the target's attachments — and one host may serve
/// a 2-D pane (painter order) and a 3-D pane (camera occlusion) in the same frame.
pub struct PickHost {
    flat_target: PickTarget,
    flat_pass: Option<PickPass>,
    depth_target: PickTarget,
    depth_pass: Option<PickPass>,
}

impl PickHost {
    /// Build the host. Allocates nothing — the target and pipeline are created on the
    /// first [`pick_at`] that needs them, so installing on a host that never picks
    /// costs one empty struct.
    ///
    /// Signature matches [`install_renderer`](super::install_renderer)'s `make`
    /// closure (`&Device, TextureFormat`); the target format is unused because the id
    /// target's format is [`PICK_FORMAT`](super::picking::PICK_FORMAT) and nothing
    /// else, never the host surface's.
    pub fn new(_device: &wgpu::Device, _target_format: wgpu::TextureFormat) -> Self {
        Self {
            flat_target: PickTarget::new(),
            flat_pass: None,
            depth_target: PickTarget::with_depth(),
            depth_pass: None,
        }
    }

    /// Run one id pass over `batch` and read the id at physical texel `(x, y)`.
    ///
    /// `depth_test` selects the lane: `false` = painter order (2-D), `true` = nearest
    /// wins (3-D, and the 2-D lane's painter-order-as-depth mapping — see
    /// [`PickBatch::push_quad_in_painter_order`]).
    ///
    /// `None` when the probe falls outside `w × h`, so a cursor off the pane is a
    /// *fallback*, not a fabricated miss.
    pub fn resolve(
        &mut self,
        device: &wgpu::Device,
        queue: &wgpu::Queue,
        batch: &PickBatch,
        w: u32,
        h: u32,
        x: u32,
        y: u32,
        depth_test: bool,
    ) -> Option<PickId> {
        if w == 0 || h == 0 || x >= w || y >= h {
            return None;
        }
        let (target, slot, depth_fmt) = if depth_test {
            (&mut self.depth_target, &mut self.depth_pass, Some(PICK_DEPTH_FORMAT))
        } else {
            (&mut self.flat_target, &mut self.flat_pass, None)
        };
        target.ensure(device, w, h);
        let pass = slot.get_or_insert_with(|| PickPass::new(device, depth_fmt));
        pass.set_viewport(queue, w, h);
        pass.upload(device, queue, batch);

        let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
            label: Some("l0_pick_host_pass"),
        });
        {
            let mut rp = target.begin_pass(&mut enc)?;
            pass.record(&mut rp);
        }
        queue.submit(Some(enc.finish()));
        PASSES.fetch_add(1, Ordering::Relaxed);
        // The readback polls the device itself; the id is decoded through the same
        // `PickId::from_rgba` the CPU lane uses.
        Some(target.read_id(device, queue, x, y))
    }

    /// The id target's current size in physical px (the colour-only lane).
    #[must_use]
    pub fn flat_size(&self) -> (u32, u32) {
        self.flat_target.size()
    }
}

/// Install a [`PickHost`] into an egui-wgpu `RenderState` — call once at host startup,
/// like `install_flow_renderer` / `facett-map`'s install. Idempotent. Also arms
/// [`pick_host_installed`], which is what unlocks [`pick_at`].
///
/// Returns `true` if a host was newly installed.
pub fn install_pick_host(render_state: &egui_wgpu::RenderState) -> bool {
    INSTALLED.store(true, Ordering::Relaxed);
    super::install_renderer(render_state, PickHost::new)
}

/// **THE one GPU-pick entry point a widget calls.** Resolve the click at `probe`
/// (logical points, relative to the widget's top-left) against `batch`.
///
/// `Some(id)` is the device's answer — possibly [`PickId::NOTHING`], which is a real
/// "you clicked empty space". `None` means **the GPU lane declined**, and the caller
/// must fall back to its CPU [`PickIndex`](crate::engine::pick::PickIndex). The
/// distinction is load-bearing: returning a fabricated `NOTHING` on failure would
/// silently *deselect* instead of falling back, and look exactly like a legitimate
/// click on the background.
///
/// The gates, each returning `None`:
/// 1. **no host installed** — the same trap `draw_flow` documents: opting in without
///    installing means nothing answers, and a caller that already skipped its CPU
///    picker would silently stop responding to clicks;
/// 2. **empty batch** — nothing is pickable, so there is nothing for the device to
///    say that the caller does not already know;
/// 3. **degenerate pane** (`< 1 px`);
/// 4. **probe outside the pane**, including negative logical coordinates.
/// `pane_px` is the widget's extent in **physical** pixels — the same space
/// [`PickVertex::pos_px`](super::picking::PickVertex::pos_px) is in. It is a parameter
/// rather than read off the surface for the reason `flowsim::gpu::host` records at
/// length: `egui_wgpu` works in the **widget rect**, and a lane built against the
/// screen size instead was measured **32.6 px** out of place while every pixel
/// assertion stayed green. For picking the same mistake is worse than a visual offset —
/// it is a click that silently resolves the wrong object.
pub fn pick_at(
    render_state: &egui_wgpu::RenderState,
    batch: &PickBatch,
    pane_px: (u32, u32),
    probe: Pos2,
    pixels_per_point: f32,
    depth_test: bool,
) -> Option<PickId> {
    if !pick_host_installed() {
        return None; // gate 1
    }
    if batch.is_empty() {
        return None; // gate 2
    }
    let (w, h) = pane_px;
    if w == 0 || h == 0 {
        return None; // gate 3
    }
    let (x, y) = super::picking::logical_to_texel(probe, pixels_per_point)?; // gate 4
    let mut guard = render_state.renderer.write();
    let host = guard.callback_resources.get_mut::<PickHost>()?;
    host.resolve(&render_state.device, &render_state.queue, batch, w, h, x, y, depth_test)
}