facett-core 0.1.7

facett — visual kernel: render a node/edge Scene into egui (wgpu fast path to come)
Documentation
//! **Upload-once + chunking** (feature `wgpu`) — the country-scale fix #12,
//! extracted **verbatim** from `facett-map/src/gpu/mod.rs`.
//!
//! A whole country (Sweden ≈ 314 MB of [`Vertex`](super::types::Vertex)) exceeds
//! wgpu's default `max_buffer_size` (256 MiB), so a single `geom` buffer would fail
//! `Device::create_buffer` with a Validation Error and panic. The upload **splits
//! the geometry into chunks**, each `geom`/`meta`/`compact` buffer kept
//! `≤ max_buffer_size`, with a `cull` dispatch + a `draw_indirect` per chunk. Ways
//! are **never split across a chunk boundary** (each way's vertices live wholly in
//! one chunk; `vert_start` is chunk-relative), so the proven per-way cull shader is
//! unchanged.
//!
//! Phase A extracts the **partition algorithm** (the load-bearing fix-#12 logic)
//! as a pure, device-free function so it is testable on synthetic meta without a
//! GPU, plus the chunk-relative meta rebase. The wgpu buffer creation that consumes
//! a [`ChunkSpan`] stays in the skin kernels until Phase C re-points them here —
//! nothing here is consumed yet. The shader semantics (`cull.wgsl`/`draw.wgsl`) are
//! kept byte-identical (do not change them).

use super::types::{GpuWayMeta, Vertex};

/// One chunk's span over the source geometry: the half-open way range
/// `[way_lo, way_hi)` and the vertex range `[vert_lo, vert_hi)` it covers. Each
/// chunk's vertex bytes are `≤ budget` (except a single way larger than the whole
/// budget, which still gets its own chunk — graceful, never split).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ChunkSpan {
    pub way_lo: usize,
    pub way_hi: usize,
    pub vert_lo: u32,
    pub vert_hi: u32,
}

impl ChunkSpan {
    /// Vertex count in this chunk.
    pub fn vert_count(&self) -> u32 {
        self.vert_hi - self.vert_lo
    }
}

/// The byte budget a chunk's vertex buffer must stay within, derived from the
/// device's `max_buffer_size`. Extracted verbatim from facett-map's `upload`:
/// reserve headroom for the indirect counter slack + alignment, never let a chunk's
/// vertex bytes reach the device cap, but always allow ≥1 way's worth.
pub fn chunk_budget(max_buffer_size: u64) -> u64 {
    let vbytes = std::mem::size_of::<Vertex>() as u64;
    max_buffer_size.saturating_sub(256).max(vbytes)
}

/// Partition the way list into contiguous runs whose total vertex bytes fit the
/// `budget`. **Extracted verbatim** from facett-map's `upload` (fix #12): a way
/// never straddles a boundary, so the per-way cull shader runs unchanged with
/// chunk-relative `vert_start`. Returns one [`ChunkSpan`] per chunk (≥1 when
/// `way_meta` is non-empty).
pub fn partition_chunks(way_meta: &[GpuWayMeta], budget: u64) -> Vec<ChunkSpan> {
    if way_meta.is_empty() {
        return Vec::new();
    }
    let vbytes = std::mem::size_of::<Vertex>() as u64;
    let mut chunks: Vec<ChunkSpan> = Vec::new();
    let mut way_lo = 0usize;
    let mut cur_vert_lo = way_meta[0].vert_start;
    for (wi, m) in way_meta.iter().enumerate() {
        let chunk_verts = (m.vert_start + m.vert_count) as u64 - cur_vert_lo as u64;
        if chunk_verts * vbytes > budget && wi > way_lo {
            // This way would overflow the current chunk → close it before `wi`.
            let prev = &way_meta[wi - 1];
            chunks.push(ChunkSpan {
                way_lo,
                way_hi: wi,
                vert_lo: cur_vert_lo,
                vert_hi: prev.vert_start + prev.vert_count,
            });
            way_lo = wi;
            cur_vert_lo = m.vert_start;
        }
    }
    let last = way_meta.last().unwrap();
    chunks.push(ChunkSpan {
        way_lo,
        way_hi: way_meta.len(),
        vert_lo: cur_vert_lo,
        vert_hi: last.vert_start + last.vert_count,
    });
    chunks
}

/// Rebase a chunk's per-way meta to its own `geom` buffer: `vert_start - vert_lo`,
/// so each chunk's cull shader indexes its chunk-relative geometry. Verbatim from
/// facett-map's `upload`.
pub fn rebase_chunk_meta(way_meta: &[GpuWayMeta], span: ChunkSpan) -> Vec<GpuWayMeta> {
    way_meta[span.way_lo..span.way_hi]
        .iter()
        .map(|m| GpuWayMeta { vert_start: m.vert_start - span.vert_lo, ..*m })
        .collect()
}

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

    fn meta(vert_start: u32, vert_count: u32) -> GpuWayMeta {
        GpuWayMeta {
            bbox_min: [0.0, 0.0],
            bbox_max: [1.0, 1.0],
            vert_start,
            vert_count,
            lod: 0,
            _pad: 0,
        }
    }

    /// INJECT-ASSERT (fix #12): a fixture that fits in one buffer is ONE chunk; with
    /// a tiny budget it splits into several, each within budget, ways never split,
    /// and the chunks tile the full vertex range with no gap/overlap.
    #[test]
    fn partition_splits_within_budget_and_never_splits_a_way() {
        // 10 ways × 100 verts = 1000 verts contiguous.
        let metas: Vec<GpuWayMeta> = (0..10).map(|i| meta(i * 100, 100)).collect();
        let vbytes = std::mem::size_of::<Vertex>() as u64;

        // Generous budget → a single chunk covering everything.
        let one = partition_chunks(&metas, 1024 * 1024);
        assert_eq!(one.len(), 1);
        assert_eq!(one[0], ChunkSpan { way_lo: 0, way_hi: 10, vert_lo: 0, vert_hi: 1000 });

        // Tiny budget (≈3 ways' worth) → several chunks, each ≤ budget, contiguous.
        let budget = 300 * vbytes; // 3 ways of 100 verts each
        let chunks = partition_chunks(&metas, budget);
        assert!(chunks.len() > 1, "tiny budget forces a split, got {}", chunks.len());
        // Tile check: chunks cover [0,1000) back-to-back, no way split.
        let mut expect_lo = 0u32;
        for c in &chunks {
            assert_eq!(c.vert_lo, expect_lo, "chunks are contiguous");
            assert!((c.vert_count() as u64) * vbytes <= budget, "chunk within budget");
            // boundaries land on a way edge (multiple of 100 verts here).
            assert_eq!(c.vert_lo % 100, 0, "boundary on a way edge (no way split)");
            assert_eq!(c.vert_hi % 100, 0, "boundary on a way edge (no way split)");
            expect_lo = c.vert_hi;
        }
        assert_eq!(expect_lo, 1000, "chunks tile the whole vertex range");
    }

    /// INJECT-ASSERT: a single way larger than the entire budget still gets ONE
    /// chunk (graceful — never split, never zero chunks). Matches the verbatim
    /// `.max(vbytes)` / `wi > way_lo` guard.
    #[test]
    fn a_single_oversized_way_gets_its_own_chunk() {
        let metas = vec![meta(0, 1_000_000)];
        let chunks = partition_chunks(&metas, 16); // absurdly small budget
        assert_eq!(chunks.len(), 1, "the lone way still gets a chunk");
        assert_eq!(chunks[0].vert_count(), 1_000_000);
    }

    /// INJECT-ASSERT: chunk meta is rebased so each chunk's `vert_start` is
    /// chunk-relative (first way of a chunk starts at 0).
    #[test]
    fn rebase_makes_chunk_meta_relative() {
        let metas: Vec<GpuWayMeta> = (0..6).map(|i| meta(i * 100, 100)).collect();
        let budget = 300 * std::mem::size_of::<Vertex>() as u64;
        let chunks = partition_chunks(&metas, budget);
        let second = chunks[1];
        let rebased = rebase_chunk_meta(&metas, second);
        assert_eq!(rebased[0].vert_start, 0, "first way of the chunk is at 0");
        // Relative offsets preserved (each subsequent way +100).
        for (i, m) in rebased.iter().enumerate() {
            assert_eq!(m.vert_start, i as u32 * 100);
        }
    }

    /// INJECT-ASSERT: the budget reserves the 256-byte headroom (the verbatim slack).
    #[test]
    fn budget_reserves_headroom() {
        assert_eq!(chunk_budget(256 * 1024 * 1024), 256 * 1024 * 1024 - 256);
        // Never below one vertex even for an absurd cap.
        assert_eq!(chunk_budget(0), std::mem::size_of::<Vertex>() as u64);
    }

    #[test]
    fn empty_meta_yields_no_chunks() {
        assert!(partition_chunks(&[], 1024).is_empty());
    }
}