Skip to main content

oxigdal_core/
simd_buffer.rs

1//! SIMD-aligned buffer management for high-performance raster operations
2//!
3//! This module provides buffers with guaranteed alignment for efficient SIMD operations.
4//! It ensures that data is properly aligned for AVX-512 (64 bytes), AVX2 (32 bytes),
5//! or SSE2/NEON (16 bytes) instructions.
6//!
7//! # Features
8//!
9//! - **Configurable Alignment**: Support for 16, 32, and 64-byte alignment
10//! - **Zero-Copy Views**: Create strided views without copying data
11//! - **Tiled Access**: Cache-friendly tiled iteration for large rasters
12//! - **Type-Safe**: Generic over element types with proper trait bounds
13
14// Unsafe code is necessary for aligned memory allocation and SIMD operations
15#![allow(unsafe_code)]
16
17//! # Example
18//!
19//! ```rust
20//! use oxigdal_core::simd_buffer::AlignedBuffer;
21//! use oxigdal_core::error::Result;
22//!
23//! # fn main() -> Result<()> {
24//! // Create a 64-byte aligned buffer for f32 data
25//! let mut buffer = AlignedBuffer::<f32>::new(1000, 64)?;
26//!
27//! // Fill with data
28//! for (i, val) in buffer.as_mut_slice().iter_mut().enumerate() {
29//!     *val = i as f32;
30//! }
31//!
32//! // Access as slice
33//! let sum: f32 = buffer.as_slice().iter().sum();
34//! assert_eq!(sum, 499500.0);
35//! # Ok(())
36//! # }
37//! ```
38//!
39//! # Cache-Friendly Tiling
40//!
41//! For large rasters, tiled iteration improves cache locality:
42//!
43//! ```rust
44//! use oxigdal_core::simd_buffer::TiledBuffer;
45//! use oxigdal_core::error::Result;
46//!
47//! # fn main() -> Result<()> {
48//! let buffer: TiledBuffer<f32> = TiledBuffer::new(1024, 1024, 64, 64)?;
49//!
50//! for tile in buffer.tiles() {
51//!     // Process each 64x64 tile independently
52//!     // Better cache locality and SIMD-friendly
53//! }
54//! # Ok(())
55//! # }
56//! ```
57
58use alloc::alloc::{Layout, alloc, dealloc};
59use core::ptr::NonNull;
60use core::slice;
61
62#[cfg(not(feature = "std"))]
63use crate::compat::*;
64use crate::error::{OxiGdalError, Result};
65
66/// A buffer with guaranteed SIMD-friendly alignment
67///
68/// This buffer ensures that data is aligned to the specified boundary,
69/// which is critical for efficient SIMD operations. It uses Rust's
70/// global allocator with custom alignment.
71pub struct AlignedBuffer<T> {
72    /// Pointer to the aligned data
73    ptr: NonNull<T>,
74    /// Number of elements
75    len: usize,
76    /// Alignment in bytes
77    align: usize,
78    /// Layout for deallocation
79    layout: Layout,
80}
81
82impl<T> AlignedBuffer<T> {
83    /// Create a new aligned buffer with the specified capacity and alignment
84    ///
85    /// # Arguments
86    ///
87    /// * `capacity` - Number of elements to allocate
88    /// * `align` - Alignment in bytes (must be a power of 2)
89    ///
90    /// # Errors
91    ///
92    /// Returns an error if:
93    /// - Alignment is not a power of 2
94    /// - Alignment is less than the natural alignment of T
95    /// - Memory allocation fails
96    pub fn new(capacity: usize, align: usize) -> Result<Self> {
97        if !align.is_power_of_two() {
98            return Err(OxiGdalError::InvalidParameter {
99                parameter: "align",
100                message: "Alignment must be a power of 2".to_string(),
101            });
102        }
103
104        if align < core::mem::align_of::<T>() {
105            return Err(OxiGdalError::InvalidParameter {
106                parameter: "align",
107                message: format!(
108                    "Alignment {} is less than natural alignment of {}",
109                    align,
110                    core::mem::align_of::<T>()
111                ),
112            });
113        }
114
115        if capacity == 0 {
116            return Err(OxiGdalError::InvalidParameter {
117                parameter: "capacity",
118                message: "Capacity must be greater than 0".to_string(),
119            });
120        }
121
122        let size = capacity
123            .checked_mul(core::mem::size_of::<T>())
124            .ok_or_else(|| OxiGdalError::InvalidParameter {
125                parameter: "capacity",
126                message: "Capacity overflow".to_string(),
127            })?;
128
129        let layout = Layout::from_size_align(size, align).map_err(|e| OxiGdalError::Internal {
130            message: format!("Invalid layout: {e}"),
131        })?;
132
133        // Safety: We've validated the layout above
134        let ptr = unsafe { alloc(layout) };
135
136        let ptr = NonNull::new(ptr)
137            .ok_or_else(|| OxiGdalError::Internal {
138                message: "Failed to allocate aligned buffer".to_string(),
139            })?
140            .cast::<T>();
141
142        Ok(Self {
143            ptr,
144            len: capacity,
145            align,
146            layout,
147        })
148    }
149
150    /// Create a new aligned buffer filled with zeros
151    ///
152    /// # Arguments
153    ///
154    /// * `capacity` - Number of elements to allocate
155    /// * `align` - Alignment in bytes (must be a power of 2)
156    ///
157    /// # Errors
158    ///
159    /// Returns an error if allocation fails
160    pub fn zeros(capacity: usize, align: usize) -> Result<Self>
161    where
162        T: Default + Copy,
163    {
164        let buffer = Self::new(capacity, align)?;
165
166        // Safety: The buffer is properly allocated and we have exclusive access
167        unsafe {
168            core::ptr::write_bytes(buffer.ptr.as_ptr(), 0, capacity);
169        }
170
171        Ok(buffer)
172    }
173
174    /// Get the number of elements in the buffer
175    #[must_use]
176    pub const fn len(&self) -> usize {
177        self.len
178    }
179
180    /// Check if the buffer is empty
181    #[must_use]
182    pub const fn is_empty(&self) -> bool {
183        self.len == 0
184    }
185
186    /// Get the alignment of the buffer
187    #[must_use]
188    pub const fn alignment(&self) -> usize {
189        self.align
190    }
191
192    /// Get a raw pointer to the buffer
193    #[must_use]
194    pub fn as_ptr(&self) -> *const T {
195        self.ptr.as_ptr()
196    }
197
198    /// Get a mutable raw pointer to the buffer
199    #[must_use]
200    pub fn as_mut_ptr(&mut self) -> *mut T {
201        self.ptr.as_ptr()
202    }
203
204    /// Get the buffer as a slice
205    #[must_use]
206    pub fn as_slice(&self) -> &[T] {
207        // Safety: The buffer is properly allocated with `len` elements
208        unsafe { slice::from_raw_parts(self.ptr.as_ptr(), self.len) }
209    }
210
211    /// Get the buffer as a mutable slice
212    #[must_use]
213    pub fn as_mut_slice(&mut self) -> &mut [T] {
214        // Safety: The buffer is properly allocated with `len` elements
215        unsafe { slice::from_raw_parts_mut(self.ptr.as_ptr(), self.len) }
216    }
217
218    /// Copy data from a slice into the buffer
219    ///
220    /// # Errors
221    ///
222    /// Returns an error if the slice length doesn't match the buffer capacity
223    pub fn copy_from_slice(&mut self, src: &[T]) -> Result<()>
224    where
225        T: Copy,
226    {
227        if src.len() != self.len {
228            return Err(OxiGdalError::InvalidParameter {
229                parameter: "src",
230                message: format!(
231                    "Source length {} doesn't match buffer capacity {}",
232                    src.len(),
233                    self.len
234                ),
235            });
236        }
237
238        self.as_mut_slice().copy_from_slice(src);
239        Ok(())
240    }
241
242    /// Create a strided view of the buffer
243    ///
244    /// This is useful for accessing every nth element without copying data.
245    ///
246    /// # Arguments
247    ///
248    /// * `stride` - Step size between elements
249    ///
250    /// # Errors
251    ///
252    /// Returns an error if stride is 0
253    pub fn strided_view(&self, stride: usize) -> Result<StridedView<'_, T>> {
254        if stride == 0 {
255            return Err(OxiGdalError::InvalidParameter {
256                parameter: "stride",
257                message: "Stride must be greater than 0".to_string(),
258            });
259        }
260
261        Ok(StridedView {
262            buffer: self.as_slice(),
263            stride,
264        })
265    }
266}
267
268impl<T> Drop for AlignedBuffer<T> {
269    fn drop(&mut self) {
270        // Safety: The pointer was allocated with this layout
271        unsafe {
272            dealloc(self.ptr.as_ptr().cast::<u8>(), self.layout);
273        }
274    }
275}
276
277// Safety: AlignedBuffer can be sent to another thread if T can be sent
278unsafe impl<T: Send> Send for AlignedBuffer<T> {}
279
280// Safety: AlignedBuffer can be shared between threads if T can be shared
281unsafe impl<T: Sync> Sync for AlignedBuffer<T> {}
282
283/// A strided view into a buffer for accessing every nth element
284pub struct StridedView<'a, T> {
285    buffer: &'a [T],
286    stride: usize,
287}
288
289impl<T> StridedView<'_, T> {
290    /// Get the number of elements in the strided view
291    #[must_use]
292    pub fn len(&self) -> usize {
293        self.buffer.len().div_ceil(self.stride)
294    }
295
296    /// Check if the view is empty
297    #[must_use]
298    pub fn is_empty(&self) -> bool {
299        self.buffer.is_empty()
300    }
301
302    /// Get an element at the specified index
303    #[must_use]
304    pub fn get(&self, index: usize) -> Option<&T> {
305        let offset = index * self.stride;
306        self.buffer.get(offset)
307    }
308
309    /// Create an iterator over the strided elements
310    #[must_use]
311    pub fn iter(&self) -> StridedIterator<'_, T> {
312        StridedIterator {
313            buffer: self.buffer,
314            stride: self.stride,
315            index: 0,
316        }
317    }
318}
319
320/// Iterator for strided buffer access
321pub struct StridedIterator<'a, T> {
322    buffer: &'a [T],
323    stride: usize,
324    index: usize,
325}
326
327impl<'a, T> Iterator for StridedIterator<'a, T> {
328    type Item = &'a T;
329
330    fn next(&mut self) -> Option<Self::Item> {
331        let offset = self.index * self.stride;
332        if offset < self.buffer.len() {
333            self.index += 1;
334            Some(&self.buffer[offset])
335        } else {
336            None
337        }
338    }
339}
340
341/// A tiled buffer for cache-friendly access patterns
342///
343/// Large rasters can be divided into tiles for better cache locality.
344/// This is especially important for SIMD operations on multi-megabyte datasets.
345pub struct TiledBuffer<T> {
346    buffer: AlignedBuffer<T>,
347    width: usize,
348    height: usize,
349    tile_width: usize,
350    tile_height: usize,
351}
352
353impl<T: Default + Copy> TiledBuffer<T> {
354    /// Create a new tiled buffer
355    ///
356    /// # Arguments
357    ///
358    /// * `width` - Total width in elements
359    /// * `height` - Total height in elements
360    /// * `tile_width` - Tile width
361    /// * `tile_height` - Tile height
362    ///
363    /// # Errors
364    ///
365    /// Returns an error if allocation fails or dimensions are invalid
366    pub fn new(width: usize, height: usize, tile_width: usize, tile_height: usize) -> Result<Self> {
367        if tile_width == 0 || tile_height == 0 {
368            return Err(OxiGdalError::InvalidParameter {
369                parameter: "tile_size",
370                message: "Tile dimensions must be greater than 0".to_string(),
371            });
372        }
373
374        let capacity = width
375            .checked_mul(height)
376            .ok_or_else(|| OxiGdalError::Internal {
377                message: "Buffer size overflow".to_string(),
378            })?;
379
380        let buffer = AlignedBuffer::zeros(capacity, 64)?;
381
382        Ok(Self {
383            buffer,
384            width,
385            height,
386            tile_width,
387            tile_height,
388        })
389    }
390
391    /// Get the total width
392    #[must_use]
393    pub const fn width(&self) -> usize {
394        self.width
395    }
396
397    /// Get the total height
398    #[must_use]
399    pub const fn height(&self) -> usize {
400        self.height
401    }
402
403    /// Get an iterator over tiles
404    #[must_use]
405    pub fn tiles(&self) -> TileIterator<'_, T> {
406        TileIterator {
407            buffer: &self.buffer,
408            width: self.width,
409            height: self.height,
410            tile_width: self.tile_width,
411            tile_height: self.tile_height,
412            current_x: 0,
413            current_y: 0,
414        }
415    }
416
417    /// Get the underlying buffer
418    #[must_use]
419    pub const fn buffer(&self) -> &AlignedBuffer<T> {
420        &self.buffer
421    }
422}
423
424/// Iterator over tiles in a tiled buffer
425pub struct TileIterator<'a, T> {
426    #[allow(dead_code)]
427    buffer: &'a AlignedBuffer<T>,
428    width: usize,
429    height: usize,
430    tile_width: usize,
431    tile_height: usize,
432    current_x: usize,
433    current_y: usize,
434}
435
436/// A tile from a tiled buffer
437pub struct Tile {
438    /// X offset in the parent buffer
439    pub x: usize,
440    /// Y offset in the parent buffer
441    pub y: usize,
442    /// Tile width
443    pub width: usize,
444    /// Tile height
445    pub height: usize,
446}
447
448impl<T> Iterator for TileIterator<'_, T> {
449    type Item = Tile;
450
451    fn next(&mut self) -> Option<Self::Item> {
452        if self.current_y >= self.height {
453            return None;
454        }
455
456        let tile = Tile {
457            x: self.current_x,
458            y: self.current_y,
459            width: self.tile_width.min(self.width - self.current_x),
460            height: self.tile_height.min(self.height - self.current_y),
461        };
462
463        // Move to next tile
464        self.current_x += self.tile_width;
465        if self.current_x >= self.width {
466            self.current_x = 0;
467            self.current_y += self.tile_height;
468        }
469
470        Some(tile)
471    }
472}
473
474// ─── Arena-backed tile iterator ───────────────────────────────────────────────
475
476/// A tile whose pixel data resides in an `Arena`.
477///
478/// The raw bytes live in the arena's memory block; dropping an `ArenaTile` does
479/// not free memory.  The arena reclaims all tiles when it is reset or dropped.
480///
481/// Requires the `std` feature because it is backed by [`crate::memory::arena`].
482#[cfg(feature = "std")]
483pub struct ArenaTile<'a> {
484    /// X offset in the parent buffer (pixels)
485    pub x: usize,
486    /// Y offset in the parent buffer (pixels)
487    pub y: usize,
488    /// Width of this tile (pixels)
489    pub width: usize,
490    /// Height of this tile (pixels)
491    pub height: usize,
492    /// Raw bytes copied from the parent buffer
493    pub data: &'a [u8],
494}
495
496/// An arena-backed variant of [`TileIterator`] that allocates each tile's data
497/// from a caller-supplied arena.
498///
499/// # Lifetime
500///
501/// `'a` binds both the source buffer slice and the arena.  All `ArenaTile`
502/// values yielded by this iterator live as long as `'a`.
503///
504/// Requires the `std` feature because it is backed by [`crate::memory::arena`].
505#[cfg(feature = "std")]
506pub struct TileIteratorArena<'a> {
507    /// Byte slice of the source buffer (row-major, interleaved or BSQ)
508    src: &'a [u8],
509    /// Row stride in bytes (width * bytes_per_pixel)
510    row_stride: usize,
511    /// Bytes per pixel
512    bytes_per_pixel: usize,
513    /// Full buffer width in pixels
514    width: usize,
515    /// Full buffer height in pixels
516    height: usize,
517    /// Tile width in pixels
518    tile_width: usize,
519    /// Tile height in pixels
520    tile_height: usize,
521    /// Current column (pixel offset)
522    current_x: usize,
523    /// Current row (pixel offset)
524    current_y: usize,
525    /// Arena used to allocate tile data
526    arena: &'a crate::memory::arena::Arena,
527}
528
529#[cfg(feature = "std")]
530impl<'a> TileIteratorArena<'a> {
531    /// Creates a new `TileIteratorArena`.
532    ///
533    /// # Arguments
534    ///
535    /// * `src`             – Byte slice of the source buffer (row-major).
536    /// * `width`           – Buffer width in pixels.
537    /// * `height`          – Buffer height in pixels.
538    /// * `bytes_per_pixel` – Number of bytes per pixel (e.g. 1 for `u8`, 4 for `f32`).
539    /// * `tile_width`      – Requested tile width in pixels.
540    /// * `tile_height`     – Requested tile height in pixels.
541    /// * `arena`           – Bump allocator that owns tile byte slices.
542    ///
543    /// # Panics
544    ///
545    /// Panics if `bytes_per_pixel == 0` or `tile_width == 0` or `tile_height == 0`.
546    #[must_use]
547    pub fn new(
548        src: &'a [u8],
549        width: usize,
550        height: usize,
551        bytes_per_pixel: usize,
552        tile_width: usize,
553        tile_height: usize,
554        arena: &'a crate::memory::arena::Arena,
555    ) -> Self {
556        assert!(bytes_per_pixel > 0, "bytes_per_pixel must be > 0");
557        assert!(tile_width > 0, "tile_width must be > 0");
558        assert!(tile_height > 0, "tile_height must be > 0");
559        Self {
560            src,
561            row_stride: width * bytes_per_pixel,
562            bytes_per_pixel,
563            width,
564            height,
565            tile_width,
566            tile_height,
567            current_x: 0,
568            current_y: 0,
569            arena,
570        }
571    }
572}
573
574#[cfg(feature = "std")]
575impl<'a> Iterator for TileIteratorArena<'a> {
576    type Item = crate::error::Result<ArenaTile<'a>>;
577
578    fn next(&mut self) -> Option<Self::Item> {
579        if self.current_y >= self.height {
580            return None;
581        }
582
583        let tile_x = self.current_x;
584        let tile_y = self.current_y;
585        let tw = self.tile_width.min(self.width - tile_x);
586        let th = self.tile_height.min(self.height - tile_y);
587        let tile_bytes = tw * th * self.bytes_per_pixel;
588
589        // Allocate space for this tile's data in the arena
590        let dest: &mut [u8] = match self.arena.allocate_slice(tile_bytes) {
591            Ok(s) => s,
592            Err(e) => return Some(Err(e)),
593        };
594
595        // Copy pixels row by row from the source buffer
596        for row in 0..th {
597            let src_row = tile_y + row;
598            let src_start = src_row * self.row_stride + tile_x * self.bytes_per_pixel;
599            let dst_start = row * tw * self.bytes_per_pixel;
600            let copy_len = tw * self.bytes_per_pixel;
601            dest[dst_start..dst_start + copy_len]
602                .copy_from_slice(&self.src[src_start..src_start + copy_len]);
603        }
604
605        // Advance tile cursor
606        self.current_x += self.tile_width;
607        if self.current_x >= self.width {
608            self.current_x = 0;
609            self.current_y += self.tile_height;
610        }
611
612        Some(Ok(ArenaTile {
613            x: tile_x,
614            y: tile_y,
615            width: tw,
616            height: th,
617            // SAFETY: `dest` was just allocated from the arena with lifetime `'a`.
618            // Extending to `'a` is safe because the arena lives for `'a`.
619            data: unsafe { core::slice::from_raw_parts(dest.as_ptr(), dest.len()) },
620        }))
621    }
622}
623
624// ─── end Arena-backed tile iterator ──────────────────────────────────────────
625
626#[cfg(test)]
627mod tests {
628    use super::*;
629
630    #[test]
631    fn test_aligned_buffer_creation() {
632        let buffer = AlignedBuffer::<f32>::new(100, 64)
633            .expect("Failed to create aligned buffer with valid parameters");
634        assert_eq!(buffer.len(), 100);
635        assert_eq!(buffer.alignment(), 64);
636        assert!(!buffer.is_empty());
637
638        // Check alignment
639        let ptr = buffer.as_ptr();
640        assert_eq!((ptr as usize) % 64, 0);
641    }
642
643    #[test]
644    fn test_aligned_buffer_zeros() {
645        let buffer = AlignedBuffer::<f32>::zeros(100, 64)
646            .expect("Failed to create zero-initialized aligned buffer");
647        for val in buffer.as_slice() {
648            assert_eq!(*val, 0.0);
649        }
650    }
651
652    #[test]
653    fn test_aligned_buffer_copy() {
654        let mut buffer =
655            AlignedBuffer::<f32>::new(10, 64).expect("Failed to create aligned buffer");
656        let data: Vec<f32> = (0..10).map(|i| i as f32).collect();
657
658        buffer
659            .copy_from_slice(&data)
660            .expect("Failed to copy data to aligned buffer");
661
662        for (i, val) in buffer.as_slice().iter().enumerate() {
663            assert_eq!(*val, i as f32);
664        }
665    }
666
667    #[test]
668    fn test_strided_view() {
669        let mut buffer =
670            AlignedBuffer::<f32>::new(10, 64).expect("Failed to create aligned buffer");
671        let data: Vec<f32> = (0..10).map(|i| i as f32).collect();
672        buffer
673            .copy_from_slice(&data)
674            .expect("Failed to copy data to buffer");
675
676        let view = buffer
677            .strided_view(2)
678            .expect("Failed to create strided view");
679        assert_eq!(view.len(), 5);
680
681        let values: Vec<f32> = view.iter().copied().collect();
682        assert_eq!(values, vec![0.0, 2.0, 4.0, 6.0, 8.0]);
683    }
684
685    #[test]
686    fn test_tiled_buffer() {
687        let buffer =
688            TiledBuffer::<f32>::new(100, 100, 32, 32).expect("Failed to create tiled buffer");
689        assert_eq!(buffer.width(), 100);
690        assert_eq!(buffer.height(), 100);
691
692        let tile_count = buffer.tiles().count();
693        // 100x100 with 32x32 tiles = 4x4 = 16 tiles
694        assert_eq!(tile_count, 16);
695    }
696
697    #[test]
698    fn test_tile_dimensions() {
699        let buffer =
700            TiledBuffer::<f32>::new(100, 100, 32, 32).expect("Failed to create tiled buffer");
701        let tiles: Vec<Tile> = buffer.tiles().collect();
702
703        // Check first tile
704        assert_eq!(tiles[0].x, 0);
705        assert_eq!(tiles[0].y, 0);
706        assert_eq!(tiles[0].width, 32);
707        assert_eq!(tiles[0].height, 32);
708
709        // Check last tile (partial)
710        let last = &tiles[15];
711        assert_eq!(last.x, 96);
712        assert_eq!(last.y, 96);
713        assert_eq!(last.width, 4); // 100 - 96 = 4
714        assert_eq!(last.height, 4);
715    }
716
717    #[test]
718    fn test_invalid_alignment() {
719        // Non-power-of-2
720        let result = AlignedBuffer::<f32>::new(100, 63);
721        assert!(result.is_err());
722
723        // Too small
724        let result = AlignedBuffer::<f32>::new(100, 1);
725        assert!(result.is_err());
726    }
727
728    #[test]
729    fn test_zero_capacity() {
730        let result = AlignedBuffer::<f32>::new(0, 64);
731        assert!(result.is_err());
732    }
733
734    #[test]
735    fn test_arena_tile_iterator_yields_arena_tiles() {
736        // 4×4 image, 1 byte per pixel, iterate with 2×2 tiles
737        let src: Vec<u8> = (0..16u8).collect();
738        let arena = crate::memory::arena::Arena::with_capacity(4096).expect("arena creation");
739        let mut it = TileIteratorArena::new(&src, 4, 4, 1, 2, 2, &arena);
740
741        let tile = it.next().expect("first tile").expect("no error");
742        assert_eq!(tile.x, 0);
743        assert_eq!(tile.y, 0);
744        assert_eq!(tile.width, 2);
745        assert_eq!(tile.height, 2);
746        // Row 0: bytes 0,1; row 1: bytes 4,5
747        assert_eq!(tile.data, &[0, 1, 4, 5]);
748
749        let tile2 = it.next().expect("second tile").expect("no error");
750        assert_eq!(tile2.x, 2);
751        assert_eq!(tile2.y, 0);
752        // Row 0: bytes 2,3; row 1: bytes 6,7
753        assert_eq!(tile2.data, &[2, 3, 6, 7]);
754
755        let total = TileIteratorArena::new(&src, 4, 4, 1, 2, 2, &arena).count();
756        assert_eq!(total, 4);
757    }
758
759    #[test]
760    fn test_arena_tile_iterator_drops_with_arena() {
761        // Verify that arena is released after iterator is consumed
762        let src: Vec<u8> = vec![0u8; 16];
763        let arena = crate::memory::arena::Arena::with_capacity(8192).expect("arena creation");
764        {
765            let it = TileIteratorArena::new(&src, 4, 4, 1, 2, 2, &arena);
766            let tiles: Vec<_> = it.collect();
767            assert_eq!(tiles.len(), 4);
768            // Tiles borrowed from arena — data still accessible within this scope
769            for t in &tiles {
770                let tile = t.as_ref().expect("no error");
771                assert_eq!(tile.data.len(), 4);
772            }
773        }
774        // Arena outlives the iterator; reset is a no-op but must not panic.
775        arena.reset();
776        assert_eq!(arena.usage(), 0);
777    }
778}