facett-core 0.1.15

facett — visual kernel: render a node/edge Scene into egui (wgpu fast path to come)
Documentation
//! **THE one GPU→CPU readback** — map a buffer, or copy a texture region out and map
//! that, with the row-padding arithmetic done once.
//!
//! Seventeen hand-rolled `map_async` call sites existed across the workspace when this
//! was written and none of them shared a line (GFX_V2 item 8 named it as LAW #5 debt it
//! could not pay, because live agents held the files). Every one repeats the same four
//! steps and the same two traps:
//!
//! 1. `copy_texture_to_buffer` demands `bytes_per_row` aligned to
//!    [`wgpu::COPY_BYTES_PER_ROW_ALIGNMENT`] (256), so the staging buffer is *padded*
//!    and a naive `&data[..w*h*bpp]` reads the wrong texels on any width that is not a
//!    multiple of `256 / bytes_per_texel`. A 640-wide RGBA8 row is 2560 B — already
//!    aligned — which is exactly why this bug hides: it only appears at, say, 500 px.
//! 2. `map_async`'s callback never fires unless the device is polled, and the poll must
//!    happen *after* the submit. Getting that order wrong deadlocks rather than fails.
//!
//! So the padding strip and the poll ordering live here, once, and callers get plain
//! unpadded bytes back.

/// Map `buffer` (which must carry [`wgpu::BufferUsages::MAP_READ`]) and copy its whole
/// contents out as bytes. Unmaps before returning, so the buffer is reusable.
///
/// This is the raw four-step dance — submit-then-poll-then-recv-then-copy — and it is
/// the only place in the tree that spells it.
#[must_use]
pub fn map_read_all(device: &wgpu::Device, buffer: &wgpu::Buffer) -> Vec<u8> {
    let slice = buffer.slice(..);
    let (tx, rx) = std::sync::mpsc::channel();
    slice.map_async(wgpu::MapMode::Read, move |r| {
        let _ = tx.send(r);
    });
    // The callback only runs while the device is polled, and only after the work that
    // fills the buffer has been submitted. Caller's submit must already have happened.
    device.poll(wgpu::PollType::wait_indefinitely()).ok();
    rx.recv().ok();
    let data = slice.get_mapped_range();
    let out = data.to_vec();
    drop(data);
    buffer.unmap();
    out
}

/// Copy `len` bytes from `src` (needs [`wgpu::BufferUsages::COPY_SRC`]) at `offset`
/// into a fresh staging buffer and read them back.
///
/// Returns empty when the range is empty or runs past the end of `src`.
#[must_use]
pub fn read_buffer_range(
    device: &wgpu::Device,
    queue: &wgpu::Queue,
    src: &wgpu::Buffer,
    offset: u64,
    len: u64,
) -> Vec<u8> {
    if len == 0 || offset.saturating_add(len) > src.size() {
        return Vec::new();
    }
    // `copy_buffer_to_buffer` requires both size and offsets to be 4-aligned.
    let padded = len.div_ceil(wgpu::COPY_BUFFER_ALIGNMENT) * wgpu::COPY_BUFFER_ALIGNMENT;
    if offset.saturating_add(padded) > src.size() {
        return Vec::new();
    }
    let staging = device.create_buffer(&wgpu::BufferDescriptor {
        label: Some("l0_readback_buffer"),
        size: padded,
        usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
        mapped_at_creation: false,
    });
    let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
        label: Some("l0_readback_buffer_enc"),
    });
    enc.copy_buffer_to_buffer(src, offset, &staging, 0, padded);
    queue.submit(Some(enc.finish()));
    let mut out = map_read_all(device, &staging);
    out.truncate(len as usize);
    out
}

/// Copy the `w × h` texel region at `(x, y)` of `tex` out and return it as
/// **unpadded** bytes, `w * bytes_per_texel` per row, `h` rows.
///
/// `tex` needs [`wgpu::TextureUsages::COPY_SRC`]. The region is clamped to the texture;
/// a region entirely outside it returns empty. `bytes_per_texel` is the caller's, not
/// derived from the format, because a depth/stencil aspect has no single answer — pass
/// the size of the aspect you are copying.
#[must_use]
pub fn read_texture_region(
    device: &wgpu::Device,
    queue: &wgpu::Queue,
    tex: &wgpu::Texture,
    bytes_per_texel: u32,
    x: u32,
    y: u32,
    w: u32,
    h: u32,
) -> Vec<u8> {
    let (tw, th) = (tex.width(), tex.height());
    if x >= tw || y >= th || w == 0 || h == 0 || bytes_per_texel == 0 {
        return Vec::new();
    }
    let w = w.min(tw - x);
    let h = h.min(th - y);

    let unpadded = w * bytes_per_texel;
    let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
    let padded = unpadded.div_ceil(align) * align;
    let staging = device.create_buffer(&wgpu::BufferDescriptor {
        label: Some("l0_readback_texture"),
        size: u64::from(padded) * u64::from(h),
        usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
        mapped_at_creation: false,
    });
    let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
        label: Some("l0_readback_texture_enc"),
    });
    enc.copy_texture_to_buffer(
        wgpu::TexelCopyTextureInfo {
            texture: tex,
            mip_level: 0,
            origin: wgpu::Origin3d { x, y, z: 0 },
            aspect: wgpu::TextureAspect::All,
        },
        wgpu::TexelCopyBufferInfo {
            buffer: &staging,
            layout: wgpu::TexelCopyBufferLayout {
                offset: 0,
                bytes_per_row: Some(padded),
                rows_per_image: Some(h),
            },
        },
        wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
    );
    queue.submit(Some(enc.finish()));

    let data = map_read_all(device, &staging);
    strip_row_padding(&data, unpadded as usize, padded as usize, h as usize)
}

/// Drop the per-row alignment padding a `copy_texture_to_buffer` inserts: keep the
/// first `unpadded` bytes of each `padded`-byte row, for `rows` rows.
///
/// Pure, so the arithmetic that silently corrupts a non-256-aligned readback is
/// testable without a GPU. Short input is truncated rather than panicking.
#[must_use]
pub fn strip_row_padding(data: &[u8], unpadded: usize, padded: usize, rows: usize) -> Vec<u8> {
    if unpadded == 0 || padded < unpadded {
        return Vec::new();
    }
    let mut out = Vec::with_capacity(unpadded * rows);
    for row in 0..rows {
        let base = row * padded;
        let end = base + unpadded;
        if end > data.len() {
            break;
        }
        out.extend_from_slice(&data[base..end]);
    }
    out
}

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

    /// The trap this helper exists for: a width whose row is NOT 256-aligned. 5 RGBA8
    /// texels = 20 B, padded to 256 — so a reader that ignores the padding takes 20 B
    /// of row 0 and then 236 B of *padding* as rows 1-2.
    #[test]
    fn row_padding_is_stripped_and_the_naive_read_would_be_wrong() {
        let (w, bpp, rows) = (5usize, 4usize, 3usize);
        let unpadded = w * bpp; // 20
        let padded = 256usize;
        // Row r is filled with the byte value (r+1)*10; padding is 0xEE.
        let mut raw = vec![0xEEu8; padded * rows];
        for r in 0..rows {
            for b in 0..unpadded {
                raw[r * padded + b] = ((r + 1) * 10) as u8;
            }
        }
        let out = strip_row_padding(&raw, unpadded, padded, rows);
        assert_eq!(out.len(), unpadded * rows, "one unpadded row per row");
        assert!(out.iter().all(|&b| b != 0xEE), "no padding byte survives the strip");
        for r in 0..rows {
            let row = &out[r * unpadded..(r + 1) * unpadded];
            assert!(row.iter().all(|&b| b == ((r + 1) * 10) as u8), "row {r} is its own bytes");
        }
        // And the naive contiguous read is genuinely wrong — this is what the helper buys.
        let naive = &raw[..unpadded * rows];
        assert_ne!(naive, &out[..], "the naive read differs, i.e. the padding is load-bearing");
    }

    /// A row that IS aligned must pass through byte-identical, so the helper is not
    /// quietly reshaping the common case.
    #[test]
    fn an_already_aligned_row_passes_through_unchanged() {
        let (unpadded, rows) = (256usize, 4usize);
        let raw: Vec<u8> = (0..unpadded * rows).map(|i| (i % 251) as u8).collect();
        assert_eq!(strip_row_padding(&raw, unpadded, unpadded, rows), raw);
    }

    #[test]
    fn degenerate_shapes_return_empty_rather_than_panicking() {
        assert!(strip_row_padding(&[1, 2, 3], 0, 256, 1).is_empty(), "zero-width");
        assert!(strip_row_padding(&[1, 2, 3], 8, 4, 1).is_empty(), "padded < unpadded is nonsense");
        assert!(strip_row_padding(&[1, 2], 8, 8, 1).is_empty(), "short input truncates");
    }
}