packed_spatial_index 0.18.0

Packed static spatial index (Hilbert R-tree) for 2D/3D AABBs — SIMD range, kNN, raycast, and spatial-join queries, with zero-copy and streaming serialization.
Documentation
//! Optional per-item payload: write, then read back via the zero-copy views and
//! confirm the index-only loaders tolerate payload files.

use packed_spatial_index::{
    Box2D, Box3D, Index2D, Index2DBuilder, Index2DView, Index3DBuilder, Index3DView,
};

fn build_2d(n: usize) -> Index2D {
    let mut builder = Index2DBuilder::new(n).node_size(16);
    for i in 0..n {
        let v = i as f64;
        builder.add(Box2D::new(v, v, v + 1.0, v + 1.0));
    }
    builder.finish().unwrap()
}

#[test]
fn view_2d_payload_round_trip_and_search() {
    let n = 500;
    let index = build_2d(n);
    let payloads: Vec<Vec<u8>> = (0..n).map(|i| format!("blob-{i}").into_bytes()).collect();
    let bytes = index.to_bytes_with_payloads(&payloads).unwrap();

    let view = Index2DView::from_bytes(&bytes).unwrap();
    assert!(view.has_payload());

    // Search results address payloads directly.
    let hits = view.search(Box2D::new(0.0, 0.0, 10.5, 10.5));
    assert_eq!(hits, index.search(Box2D::new(0.0, 0.0, 10.5, 10.5)));
    for id in hits {
        assert_eq!(view.payload(id), Some(payloads[id].as_slice()));
    }

    assert_eq!(view.payload(0), Some(b"blob-0".as_slice()));
    assert_eq!(view.payload(n - 1), Some(payloads[n - 1].as_slice()));
    assert_eq!(view.payload(n), None); // out of range

    // search_payloads pairs each hit with its blob (zero-copy).
    let pairs = view.search_payloads(Box2D::new(0.0, 0.0, 10.5, 10.5));
    let mut ids: Vec<usize> = pairs.iter().map(|(id, _)| *id).collect();
    ids.sort_unstable();
    assert_eq!(ids, index.search(Box2D::new(0.0, 0.0, 10.5, 10.5)));
    for (id, blob) in pairs {
        assert_eq!(blob, payloads[id].as_slice());
    }
}

#[test]
fn view_3d_payload_round_trip() {
    let n = 300;
    let mut builder = Index3DBuilder::new(n).node_size(16);
    for i in 0..n {
        let v = i as f64;
        builder.add(Box3D::new(v, v, v, v + 1.0, v + 1.0, v + 1.0));
    }
    let index = builder.finish().unwrap();
    let payloads: Vec<Vec<u8>> = (0..n).map(|i| vec![i as u8; (i % 7) + 1]).collect();
    let bytes = index.to_bytes_with_payloads(&payloads).unwrap();

    let view = Index3DView::from_bytes(&bytes).unwrap();
    assert!(view.has_payload());
    for (id, want) in payloads.iter().enumerate() {
        assert_eq!(view.payload(id), Some(want.as_slice()));
    }
}

#[test]
fn index_only_file_has_no_payload() {
    let bytes = build_2d(50).to_bytes();
    let view = Index2DView::from_bytes(&bytes).unwrap();
    assert!(!view.has_payload());
    assert_eq!(view.payload(0), None);
}

#[test]
fn corrupt_payload_bytes_view_never_panic() {
    // Flip a byte across the whole payload file; the view must reject it or read
    // it without panicking (offsets are validated, so blob slices stay in range).
    let index = build_2d(400);
    let payloads: Vec<Vec<u8>> = (0..400).map(|i| vec![i as u8; (i % 11) + 1]).collect();
    let base = index.to_bytes_with_payloads(&payloads).unwrap();
    let query = Box2D::new(-1.0, -1.0, 2000.0, 2000.0);
    for i in (0..base.len()).step_by(43) {
        let mut bytes = base.clone();
        bytes[i] ^= 0xFF;
        if let Ok(view) = Index2DView::from_bytes(&bytes) {
            let _ = view.search_payloads(query);
            for id in [0usize, 137, 399, 400] {
                let _ = view.payload(id);
            }
        }
    }
}

#[test]
fn owned_loader_ignores_payload() {
    // A payload file loads as an owned index (payload dropped), giving the same
    // query results as the index-only file.
    let index = build_2d(200);
    let payloads: Vec<Vec<u8>> = (0..200).map(|i| format!("x{i}").into_bytes()).collect();
    let with = index.to_bytes_with_payloads(&payloads).unwrap();

    let owned = Index2D::from_bytes(&with).unwrap();
    let query = Box2D::new(0.0, 0.0, 50.5, 50.5);
    assert_eq!(owned.search(query), index.search(query));
}