Skip to main content

zenpixels_convert/
orient.rs

1//! Physical orientation baking — rotate / flip a whole pixel buffer.
2//!
3//! [`apply_orientation`] takes a (possibly strided) [`PixelSlice`] and an
4//! [`Orientation`] and returns a fresh, tightly-allocated [`PixelBuffer`] with
5//! the pixels physically rearranged. It is the "bake" half of the zen
6//! orientation model: codecs that decode to a raster buffer and are asked to
7//! resolve orientation (`OrientationHint::bakes()` is true) call this; the
8//! cheap coordinate math (`Orientation::forward_map` / `output_dimensions`)
9//! lives in `zenpixels`, and this is the buffer operation that consumes it.
10//!
11//! # Algorithm
12//!
13//! The eight orientations split into two classes:
14//!
15//! * **Non-transposing** (`Identity`, `FlipH`, `FlipV`, `Rotate180`) — the
16//!   output has the same dimensions, so each output row maps to exactly one
17//!   input row. `Identity`/`FlipV` are pure row copies (memcpy, reordered for
18//!   `FlipV`); `FlipH`/`Rotate180` additionally reverse the `bpp`-sized
19//!   elements within each row. These are memory-bandwidth bound — a scalar
20//!   `copy_from_slice` per row already runs at copy speed.
21//!
22//! * **Transposing** (`Transpose`, `Rotate90`, `Rotate270`, `Transverse`) —
23//!   width and height swap, and the access pattern is a matrix transpose, which
24//!   is the cache-hostile case: a naïve element loop strides one of the two
25//!   buffers by a full row per step and thrashes the cache once the image
26//!   exceeds L1/L2. We use the standard fix — **loop tiling (cache blocking)**:
27//!   process the image in `TILE`×`TILE` blocks so each block's source and
28//!   destination footprints (`TILE*TILE*bpp` bytes each) stay resident while we
29//!   transpose them. The orientation's reflection (the `h-1-sy` / `w-1-sx`
30//!   terms that turn a bare transpose into a 90°/270° rotation or anti-diagonal
31//!   flip) is folded into the per-element destination address via
32//!   `forward_map`, so the whole thing is a single pass with no intermediate
33//!   buffer.
34//!
35//! For **4-byte pixels** the per-tile transpose is SIMD on every supported
36//! arch: full 4×4 tiles go through magetypes' `f32x4::transpose_4x4` (the
37//! classic `_MM_TRANSPOSE4_PS`-shaped shuffle cascade — SSE on x86, NEON on
38//! aarch64, SIMD128 on wasm), generated once via `#[magetypes(v3, neon,
39//! wasm128, scalar)]` and dispatched by `incant!` at runtime (scalar tier when
40//! no SIMD is available). Each pixel rides as one f32 lane — the kernel only
41//! shuffles whole 32-bit lanes (no float math), so reinterpreting the bytes as
42//! f32 is bit-exact for any 4-byte format, NaN bit patterns included. The
43//! non-multiple-of-4 edge strips and every other element width use the
44//! cache-blocked scalar path, which is also the parity oracle
45//! (`simd_transpose_matches_scalar_reference_rgba8`). (1- and 2-byte SIMD
46//! transpose — the 16×16 `punpck` cascade — is a possible follow-up; gray /
47//! 16-bit currently go scalar.)
48
49use core::cmp::min;
50
51use zenpixels::{InPlacePixels, Orientation, PixelBuffer, PixelSlice, PixelSliceMut};
52
53use crate::error::ConvertError;
54
55// Cross-arch SIMD: the `#[magetypes(...)]` codegen attribute + `incant!`
56// runtime dispatch from the archmage prelude, and the token-parameterized
57// generic `f32x4` from magetypes — whose `transpose_4x4` lowers to SSE
58// `_MM_TRANSPOSE4_PS` on x86, the NEON `zip`/`trn` cascade on aarch64, and the
59// `i32x4.shuffle` cascade on wasm128.
60use archmage::prelude::*;
61use magetypes::simd::generic::f32x4 as GenericF32x4;
62
63/// Side length of the cache-blocking tile for transposing orientations, in
64/// pixels. At `bpp = 4` a 32×32 tile touches 4 KiB of source and 4 KiB of
65/// destination — comfortably inside L1 — while staying large enough to amortise
66/// the per-tile loop overhead.
67const TILE: u32 = 32;
68
69/// Apply `orientation` to `src`, returning a freshly-allocated buffer with the
70/// pixels physically rearranged.
71///
72/// The returned buffer's dimensions are
73/// [`orientation.output_dimensions(src.width(), src.rows())`](Orientation::output_dimensions)
74/// — width and height swap for the four axis-swapping orientations. The pixel
75/// descriptor is preserved exactly (this moves whole `bpp`-sized pixels; it
76/// never touches their contents), so it is format-, channel-, and bit-depth
77/// agnostic. Strided input is handled.
78///
79/// This allocates the output every call. Callers that reuse or pool a target
80/// buffer (e.g. a codec `decode_into`, or an image proxy processing same-size
81/// images) should use [`apply_orientation_into`] to avoid the allocation.
82/// `Orientation::Identity` still allocates and copies (callers that want to skip
83/// the copy entirely should check `orientation.is_identity()` themselves).
84#[must_use]
85pub fn apply_orientation(src: PixelSlice<'_>, orientation: Orientation) -> PixelBuffer {
86    let (ow, oh) = orientation.output_dimensions(src.width(), src.rows());
87    let desc = src.descriptor();
88    let mut out = PixelBuffer::new(ow, oh, desc);
89    // The buffer is constructed to the exact output geometry + descriptor, so
90    // the size/format check inside `apply_orientation_into` cannot fail.
91    apply_orientation_into(src, orientation, out.as_slice_mut())
92        .expect("apply_orientation: freshly allocated buffer matches output geometry");
93    out
94}
95
96/// Apply `orientation` to `src`, writing into a caller-provided `dst` — **no
97/// allocation**, so callers can reuse / pool the target across many calls.
98///
99/// `dst` must already have the oriented geometry
100/// ([`orientation.output_dimensions(src.width(), src.rows())`](Orientation::output_dimensions))
101/// and the same bytes-per-pixel as `src`; otherwise [`ConvertError::BufferSize`]
102/// is returned and `dst` is left untouched. The allocating [`apply_orientation`]
103/// is a thin wrapper over this.
104pub fn apply_orientation_into(
105    src: PixelSlice<'_>,
106    orientation: Orientation,
107    mut dst: PixelSliceMut<'_>,
108) -> Result<(), ConvertError> {
109    let w = src.width();
110    let h = src.rows();
111    let bpp = src.descriptor().bytes_per_pixel();
112    let (ow, oh) = orientation.output_dimensions(w, h);
113
114    let dst_bpp = dst.descriptor().bytes_per_pixel();
115    if dst.width() != ow || dst.rows() != oh || dst_bpp != bpp {
116        return Err(ConvertError::BufferSize {
117            expected: ow as usize * oh as usize * bpp,
118            actual: dst.width() as usize * dst.rows() as usize * dst_bpp,
119        });
120    }
121    if w == 0 || h == 0 || bpp == 0 {
122        return Ok(());
123    }
124
125    {
126        match orientation {
127            Orientation::Identity => {
128                for y in 0..h {
129                    dst.row_mut(y).copy_from_slice(src.row(y));
130                }
131            }
132            Orientation::FlipV => {
133                for y in 0..h {
134                    dst.row_mut(y).copy_from_slice(src.row(h - 1 - y));
135                }
136            }
137            Orientation::FlipH => {
138                for y in 0..h {
139                    reverse_row(src.row(y), dst.row_mut(y), w as usize, bpp);
140                }
141            }
142            Orientation::Rotate180 => {
143                for y in 0..h {
144                    reverse_row(src.row(h - 1 - y), dst.row_mut(y), w as usize, bpp);
145                }
146            }
147            // Axis-swapping: cache-blocked transpose with the orientation's
148            // reflection folded into the destination address. The `_` arm makes
149            // this the correct (if unoptimised) fallback for any orientation
150            // added to the `#[non_exhaustive]` enum in future — it scatters by
151            // `forward_map`, which is defined for every variant.
152            Orientation::Transpose
153            | Orientation::Rotate90
154            | Orientation::Rotate270
155            | Orientation::Transverse
156            | _ => {
157                do_transpose(&src, &mut dst, orientation, w, h, bpp);
158            }
159        }
160    }
161    Ok(())
162}
163
164/// Largest bytes-per-pixel the in-place path's per-element temp supports — covers
165/// every current format up to RGBA f32 (16 bytes).
166const MAX_INPLACE_BPP: usize = 16;
167
168/// Bake `orientation` into `dst` **in place**, reusing its allocation — no second
169/// pixel buffer (the transposing orientations would otherwise need a 2× transient).
170///
171/// Consumes the mutable view, permutes the bytes within the backing allocation,
172/// and returns a re-described **tight-stride** `PixelSliceMut` over the same
173/// memory — dimensions swapped for the four transposing orientations. Like the
174/// no-alloc reduction APIs, the returned view carries the new geometry; the
175/// source `PixelBuffer`'s own `width()`/`height()` go stale, so use the returned
176/// view. Square images transpose via an in-place diagonal swap; non-square via
177/// cycle-following (an `n`-element visited scratch — not a 2× pixel buffer).
178///
179/// Returns [`ConvertError::BufferSize`] if `bpp` exceeds 16 (the per-element temp
180/// limit) or if re-describing the output fails.
181pub fn apply_orientation_in_place(
182    dst: &mut PixelBuffer,
183    orientation: Orientation,
184) -> Result<(), ConvertError> {
185    let bpp = dst.descriptor().bytes_per_pixel();
186    if bpp == 0 || bpp > MAX_INPLACE_BPP {
187        return Err(ConvertError::BufferSize {
188            expected: MAX_INPLACE_BPP,
189            actual: bpp,
190        });
191    }
192    // The eight known orientations all have an in-place mapping; a future
193    // `#[non_exhaustive]` variant falls back to the allocating
194    // `apply_orientation` at the caller.
195    if !matches!(
196        orientation,
197        Orientation::Identity
198            | Orientation::FlipH
199            | Orientation::FlipV
200            | Orientation::Rotate180
201            | Orientation::Transpose
202            | Orientation::Rotate90
203            | Orientation::Rotate270
204            | Orientation::Transverse
205    ) {
206        return Err(ConvertError::BufferSize {
207            expected: MAX_INPLACE_BPP,
208            actual: 0,
209        });
210    }
211    dst.transform_in_place(|px| orient_in_place_impl(px, orientation));
212    Ok(())
213}
214
215/// The transform body behind [`apply_orientation_in_place`]: permute the
216/// bytes and return the re-described tight-stride view for
217/// [`PixelBuffer::transform_in_place`] to adopt.
218fn orient_in_place_impl(px: InPlacePixels<'_>, orientation: Orientation) -> PixelSliceMut<'_> {
219    let InPlacePixels {
220        bytes,
221        width: w,
222        rows: h,
223        stride: in_stride,
224        descriptor: desc,
225        color,
226        ..
227    } = px;
228    let bpp = desc.bytes_per_pixel();
229    let (ow, oh) = orientation.output_dimensions(w, h);
230    let tight = w as usize * bpp;
231    let out_stride = ow as usize * bpp;
232    let out_len = out_stride * oh as usize;
233
234    fn rewrap<'b>(
235        bytes: &'b mut [u8],
236        ow: u32,
237        oh: u32,
238        out_stride: usize,
239        desc: zenpixels::PixelDescriptor,
240        color: Option<alloc::sync::Arc<zenpixels::ColorContext>>,
241    ) -> PixelSliceMut<'b> {
242        let out = PixelSliceMut::new(bytes, ow, oh, out_stride, desc)
243            .expect("oriented in-place geometry is always valid");
244        match color {
245            Some(c) => out.with_color_context(c),
246            None => out,
247        }
248    }
249
250    if w == 0 || h == 0 {
251        return rewrap(&mut bytes[..out_len], ow, oh, out_stride, desc, color);
252    }
253
254    // 1. Compact to tight (drop any row padding) so the transpose is a clean
255    //    permutation of a contiguous element array.
256    if in_stride != tight {
257        for y in 1..h as usize {
258            bytes.copy_within(y * in_stride..y * in_stride + tight, y * tight);
259        }
260    }
261    let content = &mut bytes[..tight * h as usize];
262
263    // 2/3. Permute in place. Transposing orientations transpose the tight w×h
264    //      grid (→ h×w = ow×oh) then add the orientation's reflection.
265    match orientation {
266        Orientation::Identity => {}
267        Orientation::FlipH => inplace_flip_h(content, w, h, bpp),
268        Orientation::FlipV => inplace_flip_v(content, w, h, bpp),
269        Orientation::Rotate180 => inplace_reverse_elements(content, bpp),
270        Orientation::Transpose => inplace_transpose(content, w, h, bpp),
271        Orientation::Rotate90 => {
272            inplace_transpose(content, w, h, bpp);
273            inplace_flip_h(content, ow, oh, bpp); // transpose ∘ FlipH
274        }
275        Orientation::Rotate270 => {
276            inplace_transpose(content, w, h, bpp);
277            inplace_flip_v(content, ow, oh, bpp); // transpose ∘ FlipV
278        }
279        Orientation::Transverse => {
280            inplace_transpose(content, w, h, bpp);
281            inplace_reverse_elements(content, bpp); // transpose ∘ Rotate180
282        }
283        // Pre-checked in `apply_orientation_in_place`; unreachable here.
284        _ => {}
285    }
286
287    rewrap(&mut bytes[..out_len], ow, oh, out_stride, desc, color)
288}
289
290/// Reverse the `bpp`-sized elements within each row, in place (`FlipH`).
291fn inplace_flip_h(a: &mut [u8], w: u32, h: u32, bpp: usize) {
292    let w = w as usize;
293    let row_len = w * bpp;
294    for y in 0..h as usize {
295        let row = &mut a[y * row_len..y * row_len + row_len];
296        let (mut lo, mut hi) = (0usize, w - 1);
297        while lo < hi {
298            let (al, ah) = (lo * bpp, hi * bpp);
299            for k in 0..bpp {
300                row.swap(al + k, ah + k);
301            }
302            lo += 1;
303            hi -= 1;
304        }
305    }
306}
307
308/// Swap row `y` with row `h-1-y`, in place (`FlipV`). No temp row — the two rows
309/// are disjoint, so `split_at_mut` + `swap_with_slice` exchanges them directly.
310fn inplace_flip_v(a: &mut [u8], w: u32, h: u32, bpp: usize) {
311    let row_len = w as usize * bpp;
312    let h = h as usize;
313    let (mut top, mut bot) = (0usize, h - 1);
314    while top < bot {
315        let split = bot * row_len;
316        let (head, tail) = a.split_at_mut(split);
317        head[top * row_len..top * row_len + row_len].swap_with_slice(&mut tail[..row_len]);
318        top += 1;
319        bot -= 1;
320    }
321}
322
323/// Reverse the order of all `bpp`-sized elements in the buffer (`Rotate180` =
324/// `FlipH ∘ FlipV`).
325fn inplace_reverse_elements(a: &mut [u8], bpp: usize) {
326    let n = a.len() / bpp;
327    if n < 2 {
328        return;
329    }
330    let (mut lo, mut hi) = (0usize, n - 1);
331    while lo < hi {
332        let (al, ah) = (lo * bpp, hi * bpp);
333        for k in 0..bpp {
334            a.swap(al + k, ah + k);
335        }
336        lo += 1;
337        hi -= 1;
338    }
339}
340
341/// In-place transpose of a tight `w`×`h` (row-major) grid of `bpp`-byte elements
342/// into `h`×`w`, within the same buffer.
343///
344/// Square is the diagonal swap. Non-square follows the transpose permutation's
345/// cycles (`Wikipedia: in-place matrix transposition`): element index `k = r*w+c`
346/// maps to `c*h+r ≡ (k*h) mod (n-1)`; to fill position `cur` we gather from
347/// `(cur*w) mod (n-1)` (the inverse, since `w*h ≡ 1`), walking each cycle once
348/// with a one-element temp and an `n`-bit visited set. `0` and `n-1` are fixed.
349fn inplace_transpose(a: &mut [u8], w: u32, h: u32, bpp: usize) {
350    if w == h {
351        let n = w as usize;
352        for i in 0..n {
353            for j in (i + 1)..n {
354                let (p, q) = ((i * n + j) * bpp, (j * n + i) * bpp);
355                for k in 0..bpp {
356                    a.swap(p + k, q + k);
357                }
358            }
359        }
360        return;
361    }
362
363    let (w, h) = (w as usize, h as usize);
364    let n = w * h;
365    if n <= 1 {
366        return;
367    }
368    let mn1 = n - 1;
369    let mut moved = alloc::vec![false; n];
370    moved[0] = true;
371    moved[mn1] = true;
372    let mut tmp = [0u8; MAX_INPLACE_BPP];
373    let mut start = 1;
374    while start < mn1 {
375        if moved[start] {
376            start += 1;
377            continue;
378        }
379        tmp[..bpp].copy_from_slice(&a[start * bpp..start * bpp + bpp]);
380        let mut cur = start;
381        loop {
382            moved[cur] = true;
383            let prev = (cur * w) % mn1; // element that belongs at `cur`
384            if prev == start {
385                break;
386            }
387            a.copy_within(prev * bpp..prev * bpp + bpp, cur * bpp);
388            cur = prev;
389        }
390        a[cur * bpp..cur * bpp + bpp].copy_from_slice(&tmp[..bpp]);
391        start += 1;
392    }
393}
394
395/// Bench-only A/B handle: bake `orientation` via the cache-blocked **scalar**
396/// transpose, bypassing the SIMD kernel, so `bench_orient` can compare the two
397/// paths on identical input. Only meaningful for the transposing orientations.
398#[cfg(feature = "__bench_orient")]
399#[doc(hidden)]
400#[must_use]
401pub fn __bench_apply_orientation_scalar(
402    src: PixelSlice<'_>,
403    orientation: Orientation,
404) -> PixelBuffer {
405    let w = src.width();
406    let h = src.rows();
407    let desc = src.descriptor();
408    let bpp = desc.bytes_per_pixel();
409    let (ow, oh) = orientation.output_dimensions(w, h);
410    let mut out = PixelBuffer::new(ow, oh, desc);
411    if w == 0 || h == 0 || bpp == 0 {
412        return out;
413    }
414    {
415        let mut dst = out.as_slice_mut();
416        transpose_blocked(&src, &mut dst, orientation, w, h, bpp);
417    }
418    out
419}
420
421/// Copy one row, reversing the order of `bpp`-sized pixels (`FlipH` per row).
422#[inline]
423fn reverse_row(s: &[u8], d: &mut [u8], width: usize, bpp: usize) {
424    for x in 0..width {
425        let si = (width - 1 - x) * bpp;
426        let di = x * bpp;
427        d[di..di + bpp].copy_from_slice(&s[si..si + bpp]);
428    }
429}
430
431/// Scatter one source pixel `(sx, sy)` to its oriented destination.
432#[inline]
433#[allow(clippy::too_many_arguments)] // per-pixel helper; an args struct would add overhead/noise
434fn scatter_pixel(
435    s: &[u8],
436    dst: &mut PixelSliceMut<'_>,
437    orientation: Orientation,
438    sx: u32,
439    sy: u32,
440    w: u32,
441    h: u32,
442    bpp: usize,
443) {
444    let (dx, dy) = orientation.forward_map(sx, sy, w, h);
445    let si = sx as usize * bpp;
446    let di = dx as usize * bpp;
447    dst.row_mut(dy)[di..di + bpp].copy_from_slice(&s[si..si + bpp]);
448}
449
450/// Dispatch the axis-swapping orientations: the SIMD 4×4 register transpose for
451/// 4-byte pixels (the common decoder output), the cache-blocked scalar path
452/// otherwise. `incant!` picks the best tier per target (AVX2 / NEON / WASM
453/// SIMD128 / scalar); the scalar tier is the same algorithm as `transpose_blocked`.
454fn do_transpose(
455    src: &PixelSlice<'_>,
456    dst: &mut PixelSliceMut<'_>,
457    orientation: Orientation,
458    w: u32,
459    h: u32,
460    bpp: usize,
461) {
462    // Only the four known transposing orientations have a `tile_dest` mapping;
463    // a future `#[non_exhaustive]` variant falls through to the scalar scatter.
464    if bpp == 4
465        && matches!(
466            orientation,
467            Orientation::Transpose
468                | Orientation::Rotate90
469                | Orientation::Rotate270
470                | Orientation::Transverse
471        )
472    {
473        // Explicit tier list matching the `#[magetypes(v3, neon, wasm128,
474        // scalar)]` attribute on `transpose4_simd`: a bare `incant!` expands
475        // the full cascade and references a `_v4` variant that was never
476        // generated, breaking `--features avx512` builds (caught by the
477        // feature-powerset CI job). Same convention as the `scan` kernels.
478        incant!(
479            transpose4_simd(src, dst, orientation, w, h),
480            [v3, neon, wasm128, scalar]
481        );
482        return;
483    }
484    transpose_blocked(src, dst, orientation, w, h, bpp);
485}
486
487/// Cache-blocked scalar transpose for the four axis-swapping orientations. The
488/// per-element destination is `orientation.forward_map(sx, sy, w, h)`, which
489/// encodes transpose + whatever reflection the orientation adds; tiling keeps
490/// each block's scattered destination writes inside the cache. This is the
491/// portable path and the parity oracle for the SIMD kernel.
492fn transpose_blocked(
493    src: &PixelSlice<'_>,
494    dst: &mut PixelSliceMut<'_>,
495    orientation: Orientation,
496    w: u32,
497    h: u32,
498    bpp: usize,
499) {
500    let mut tile_y = 0;
501    while tile_y < h {
502        let y_end = min(tile_y + TILE, h);
503        let mut tile_x = 0;
504        while tile_x < w {
505            let x_end = min(tile_x + TILE, w);
506            for sy in tile_y..y_end {
507                let s = src.row(sy);
508                for sx in tile_x..x_end {
509                    scatter_pixel(s, dst, orientation, sx, sy, w, h, bpp);
510                }
511            }
512            tile_x += TILE;
513        }
514        tile_y += TILE;
515    }
516}
517
518/// Scalar scatter for the edge strips a 4×4-tiled SIMD pass leaves uncovered:
519/// the right strip (`cols [full_w, w)`) and the bottom strip (`rows [full_h,
520/// h)`, which also covers the bottom-right corner). No overlap between strips.
521#[allow(clippy::too_many_arguments)] // edge-strip helper; mirrors the scatter-loop signature
522fn transpose_edges(
523    src: &PixelSlice<'_>,
524    dst: &mut PixelSliceMut<'_>,
525    orientation: Orientation,
526    w: u32,
527    h: u32,
528    bpp: usize,
529    full_w: u32,
530    full_h: u32,
531) {
532    for sy in 0..full_h {
533        let s = src.row(sy);
534        for sx in full_w..w {
535            scatter_pixel(s, dst, orientation, sx, sy, w, h, bpp);
536        }
537    }
538    for sy in full_h..h {
539        let s = src.row(sy);
540        for sx in 0..w {
541            scatter_pixel(s, dst, orientation, sx, sy, w, h, bpp);
542        }
543    }
544}
545
546// ── SIMD 4×4 register transpose (cross-arch, 4-byte pixels) ──────────────────
547
548/// Destination of transposed-tile row `r` (transposed row index 0..4) for a
549/// source 4×4 tile at `(bx, by)`, as `(dst_row, dst_col_start, reverse_lanes)`.
550/// Derived from `Orientation::forward_map`: a bare `Transpose` writes row `r` to
551/// `dst[bx+r][by..]`; the rotations/anti-diagonal add a row/col reflection.
552/// `by`/`bx` are multiples of 4 with `by+4 ≤ h`, `bx+4 ≤ w`, so the subtractions
553/// never underflow.
554#[inline]
555fn tile_dest(
556    orientation: Orientation,
557    bx: u32,
558    by: u32,
559    r: u32,
560    w: u32,
561    h: u32,
562) -> (u32, u32, bool) {
563    match orientation {
564        Orientation::Transpose => (bx + r, by, false),
565        Orientation::Rotate90 => (bx + r, h - 4 - by, true),
566        Orientation::Rotate270 => (w - 1 - bx - r, by, false),
567        Orientation::Transverse => (w - 1 - bx - r, h - 4 - by, true),
568        _ => unreachable!("tile_dest only handles the four transposing orientations"),
569    }
570}
571
572/// SIMD path: transpose full 4×4 tiles via `f32x4::transpose_4x4` (the classic
573/// `_MM_TRANSPOSE4_PS`-shaped shuffle cascade), scalar for the edges. The
574/// `#[magetypes]` attribute generates one variant per SIMD tier from this single
575/// body; `incant!` in [`do_transpose`] picks the best at runtime.
576///
577/// Each 4-byte pixel rides as one f32 lane. The transpose only *shuffles whole
578/// 32-bit lanes* (no float arithmetic), so the reinterpret is bit-exact for any
579/// 4-byte pixel format, including bit patterns that happen to be NaN.
580#[magetypes(v3, neon, wasm128, scalar)]
581fn transpose4_simd(
582    token: Token,
583    src: &PixelSlice<'_>,
584    dst: &mut PixelSliceMut<'_>,
585    orientation: Orientation,
586    w: u32,
587    h: u32,
588) {
589    #[allow(non_camel_case_types)]
590    type f32x4 = GenericF32x4<Token>;
591
592    let full_w = w & !3; // largest multiple of 4 ≤ w
593    let full_h = h & !3;
594
595    let mut by = 0;
596    while by < full_h {
597        let mut bx = 0;
598        while bx < full_w {
599            let xb = bx as usize * 4;
600            let f0: [f32; 4] =
601                bytemuck::cast::<[u8; 16], _>(src.row(by)[xb..xb + 16].try_into().unwrap());
602            let f1: [f32; 4] =
603                bytemuck::cast::<[u8; 16], _>(src.row(by + 1)[xb..xb + 16].try_into().unwrap());
604            let f2: [f32; 4] =
605                bytemuck::cast::<[u8; 16], _>(src.row(by + 2)[xb..xb + 16].try_into().unwrap());
606            let f3: [f32; 4] =
607                bytemuck::cast::<[u8; 16], _>(src.row(by + 3)[xb..xb + 16].try_into().unwrap());
608            let mut rows = [
609                f32x4::load(token, &f0),
610                f32x4::load(token, &f1),
611                f32x4::load(token, &f2),
612                f32x4::load(token, &f3),
613            ];
614            f32x4::transpose_4x4(&mut rows);
615
616            for r in 0..4u32 {
617                let mut lanes = [0f32; 4];
618                rows[r as usize].store(&mut lanes);
619                let (drow, dcol, rev) = tile_dest(orientation, bx, by, r, w, h);
620                if rev {
621                    lanes.reverse();
622                }
623                let bytes: [u8; 16] = bytemuck::cast(lanes);
624                let db = dcol as usize * 4;
625                dst.row_mut(drow)[db..db + 16].copy_from_slice(&bytes);
626            }
627            bx += 4;
628        }
629        by += 4;
630    }
631
632    transpose_edges(src, dst, orientation, w, h, 4, full_w, full_h);
633}
634
635#[cfg(test)]
636mod tests {
637    use super::*;
638    use zenpixels::PixelDescriptor;
639
640    /// Build a tightly-packed source slice from raw bytes.
641    fn slice<'a>(data: &'a [u8], w: u32, h: u32, desc: PixelDescriptor) -> PixelSlice<'a> {
642        PixelSlice::new(data, w, h, w as usize * desc.bytes_per_pixel(), desc).unwrap()
643    }
644
645    /// A 3×2 gray8 image with per-pixel values 0..6:
646    ///   row0: 0 1 2
647    ///   row1: 3 4 5
648    const SRC_3X2: [u8; 6] = [0, 1, 2, 3, 4, 5];
649
650    /// Expected output of each orientation on `SRC_3X2`, hand-derived from the
651    /// rotation geometry (NOT from `forward_map` — this is the independent
652    /// oracle). `(out_w, out_h, bytes)`.
653    fn expected_3x2(o: Orientation) -> (u32, u32, Vec<u8>) {
654        match o {
655            Orientation::Identity => (3, 2, vec![0, 1, 2, 3, 4, 5]),
656            Orientation::FlipH => (3, 2, vec![2, 1, 0, 5, 4, 3]),
657            Orientation::FlipV => (3, 2, vec![3, 4, 5, 0, 1, 2]),
658            Orientation::Rotate180 => (3, 2, vec![5, 4, 3, 2, 1, 0]),
659            // transposing → dims swap to 2×3
660            Orientation::Transpose => (2, 3, vec![0, 3, 1, 4, 2, 5]),
661            Orientation::Rotate90 => (2, 3, vec![3, 0, 4, 1, 5, 2]),
662            Orientation::Rotate270 => (2, 3, vec![2, 5, 1, 4, 0, 3]),
663            Orientation::Transverse => (2, 3, vec![5, 2, 4, 1, 3, 0]),
664            _ => unreachable!("non-exhaustive Orientation in test oracle"),
665        }
666    }
667
668    #[test]
669    fn all_orientations_match_hand_derived_oracle_gray8() {
670        let desc = PixelDescriptor::GRAY8;
671        for &o in &Orientation::ALL {
672            let out = apply_orientation(slice(&SRC_3X2, 3, 2, desc), o);
673            let (ew, eh, ebytes) = expected_3x2(o);
674            assert_eq!((out.width(), out.height()), (ew, eh), "{o:?} dims");
675            // Compare row-by-row (output stride may be SIMD-aligned, not tight).
676            let s = out.as_slice();
677            for y in 0..eh {
678                let got = s.row(y);
679                let exp = &ebytes[y as usize * ew as usize..][..ew as usize];
680                assert_eq!(got, exp, "{o:?} row {y}");
681            }
682        }
683    }
684
685    #[test]
686    fn all_orientations_match_oracle_rgba8() {
687        // Same geometry, but each pixel carries 4 distinct channel bytes so a
688        // within-pixel byte-order bug would show. pixel v -> [v, v+64, v+128, 255].
689        let desc = PixelDescriptor::RGBA8;
690        let mut src = Vec::new();
691        for v in 0u8..6 {
692            src.extend_from_slice(&[v, v + 64, v + 128, 255]);
693        }
694        for &o in &Orientation::ALL {
695            let out = apply_orientation(slice(&src, 3, 2, desc), o);
696            let (ew, eh, gray) = expected_3x2(o);
697            assert_eq!((out.width(), out.height()), (ew, eh), "{o:?} dims");
698            let s = out.as_slice();
699            for y in 0..eh {
700                let got = s.row(y);
701                for x in 0..ew {
702                    let v = gray[(y * ew + x) as usize];
703                    let exp = [v, v + 64, v + 128, 255];
704                    assert_eq!(&got[x as usize * 4..][..4], &exp, "{o:?} px ({x},{y})");
705                }
706            }
707        }
708    }
709
710    /// Deterministic pseudo-random byte (no Math.random/Date in tests anyway).
711    fn fill(n: usize) -> Vec<u8> {
712        let mut v = Vec::with_capacity(n);
713        let mut s = 0x9e3779b9u32;
714        for _ in 0..n {
715            s = s.wrapping_mul(1664525).wrapping_add(1013904223);
716            v.push((s >> 24) as u8);
717        }
718        v
719    }
720
721    #[test]
722    fn roundtrip_orientation_then_inverse_is_identity() {
723        // apply(apply(img, o), o.inverse()) == img, for every orientation and a
724        // spread of element sizes and odd dimensions.
725        for &desc in &[
726            PixelDescriptor::GRAY8,   // 1
727            PixelDescriptor::GRAYA8,  // 2
728            PixelDescriptor::RGB8,    // 3
729            PixelDescriptor::RGBA8,   // 4
730            PixelDescriptor::RGBAF32, // 16
731        ] {
732            let bpp = desc.bytes_per_pixel();
733            for &(w, h) in &[(1u32, 1u32), (17, 13), (33, 31), (64, 48)] {
734                let data = fill(w as usize * h as usize * bpp);
735                for &o in &Orientation::ALL {
736                    let once = apply_orientation(slice(&data, w, h, desc), o);
737                    let back = apply_orientation(once.as_slice(), o.inverse());
738                    assert_eq!(
739                        (back.width(), back.height()),
740                        (w, h),
741                        "{o:?} {desc:?} {w}x{h}"
742                    );
743                    for y in 0..h {
744                        let exp = &data[y as usize * w as usize * bpp..][..w as usize * bpp];
745                        assert_eq!(
746                            back.as_slice().row(y),
747                            exp,
748                            "{o:?} {desc:?} {w}x{h} row {y}"
749                        );
750                    }
751                }
752            }
753        }
754    }
755
756    #[test]
757    fn compose_matches_sequential_application() {
758        // apply(img, a.then(b)) == apply(apply(img, a), b) — ties the baker to
759        // the D4 group algebra in zenpixels.
760        let desc = PixelDescriptor::RGBA8;
761        let (w, h) = (11u32, 7u32);
762        let data = fill(w as usize * h as usize * 4);
763        for &a in &Orientation::ALL {
764            for &b in &Orientation::ALL {
765                let seq =
766                    apply_orientation(apply_orientation(slice(&data, w, h, desc), a).as_slice(), b);
767                let fused = apply_orientation(slice(&data, w, h, desc), a.then(b));
768                assert_eq!(
769                    (seq.width(), seq.height()),
770                    (fused.width(), fused.height()),
771                    "{a:?}.then({b:?}) dims"
772                );
773                for y in 0..seq.height() {
774                    assert_eq!(
775                        seq.as_slice().row(y),
776                        fused.as_slice().row(y),
777                        "{a:?}.then({b:?}) row {y}"
778                    );
779                }
780            }
781        }
782    }
783
784    #[test]
785    fn handles_strided_source() {
786        // A source whose stride exceeds width*bpp must produce the same result
787        // as a tight one (padding bytes must be ignored).
788        let desc = PixelDescriptor::RGBA8;
789        let (w, h) = (5u32, 4u32);
790        let tight_stride = w as usize * 4;
791        let padded_stride = tight_stride + 12;
792        let tight = fill(tight_stride * h as usize);
793        let mut padded = vec![0xABu8; padded_stride * h as usize];
794        for y in 0..h as usize {
795            padded[y * padded_stride..y * padded_stride + tight_stride]
796                .copy_from_slice(&tight[y * tight_stride..][..tight_stride]);
797        }
798        for &o in &Orientation::ALL {
799            // PixelSlice is a non-Copy view; build a fresh one per iteration.
800            let tight_slice = PixelSlice::new(&tight, w, h, tight_stride, desc).unwrap();
801            let padded_slice = PixelSlice::new(&padded, w, h, padded_stride, desc).unwrap();
802            let a = apply_orientation(tight_slice, o);
803            let b = apply_orientation(padded_slice, o);
804            for y in 0..a.height() {
805                assert_eq!(a.as_slice().row(y), b.as_slice().row(y), "{o:?} row {y}");
806            }
807        }
808    }
809
810    /// Gold-standard parity gate: the (SIMD on x86_64) `apply_orientation` must
811    /// match the explicit scalar `transpose_blocked` for 4-byte pixels across
812    /// the four transposing orientations and a spread of dimensions — full 4×4
813    /// tiles (8×8, 16×16, 64×48), edge-only (3×3, 1×1), and mixed full+edge
814    /// (17×13, 9×7, 12×4, 4×12, 5×5). This is what proves the SIMD kernel +
815    /// edge handling are correct against the portable oracle.
816    #[test]
817    fn simd_transpose_matches_scalar_reference_rgba8() {
818        let desc = PixelDescriptor::RGBA8;
819        let dims = [
820            (8u32, 8u32),
821            (16, 16),
822            (64, 48),
823            (17, 13),
824            (9, 7),
825            (12, 4),
826            (4, 12),
827            (3, 3),
828            (1, 1),
829            (5, 5),
830        ];
831        for &(w, h) in &dims {
832            let data = fill(w as usize * h as usize * 4);
833            for &o in &[
834                Orientation::Transpose,
835                Orientation::Rotate90,
836                Orientation::Rotate270,
837                Orientation::Transverse,
838            ] {
839                // Path under test (SIMD on x86_64, scalar elsewhere).
840                let got = apply_orientation(slice(&data, w, h, desc), o);
841                // Explicit scalar reference via the cache-blocked oracle.
842                let (ow, oh) = o.output_dimensions(w, h);
843                let mut reference = PixelBuffer::new(ow, oh, desc);
844                {
845                    let src = slice(&data, w, h, desc);
846                    let mut d = reference.as_slice_mut();
847                    transpose_blocked(&src, &mut d, o, w, h, 4);
848                }
849                for y in 0..oh {
850                    assert_eq!(
851                        got.as_slice().row(y),
852                        reference.as_slice().row(y),
853                        "{o:?} {w}x{h} row {y}"
854                    );
855                }
856            }
857        }
858    }
859
860    #[test]
861    fn into_writes_caller_buffer_and_is_reusable() {
862        // One target buffer, reused across four transposing orientations (all
863        // share the swapped 13×17 geometry for a 17×13 input) — proves the
864        // no-alloc reuse path and that it matches the allocating version.
865        let desc = PixelDescriptor::RGBA8;
866        let (w, h) = (17u32, 13u32);
867        let data = fill(w as usize * h as usize * 4);
868        let (ow, oh) = Orientation::Rotate90.output_dimensions(w, h);
869        let mut target = PixelBuffer::new(ow, oh, desc);
870        for &o in &[
871            Orientation::Rotate90,
872            Orientation::Rotate270,
873            Orientation::Transverse,
874            Orientation::Transpose,
875        ] {
876            apply_orientation_into(slice(&data, w, h, desc), o, target.as_slice_mut())
877                .expect("into should accept a correctly-sized buffer");
878            let want = apply_orientation(slice(&data, w, h, desc), o);
879            for y in 0..oh {
880                assert_eq!(
881                    target.as_slice().row(y),
882                    want.as_slice().row(y),
883                    "{o:?} row {y}"
884                );
885            }
886        }
887    }
888
889    #[test]
890    fn into_rejects_wrong_sized_dst() {
891        // Rotate90 of 8×6 needs a 6×8 target; an 8×6 buffer (same byte count,
892        // wrong dims) must be rejected with BufferSize, leaving dst untouched.
893        let desc = PixelDescriptor::RGBA8;
894        let (w, h) = (8u32, 6u32);
895        let data = fill(w as usize * h as usize * 4);
896        let mut wrong = PixelBuffer::new(w, h, desc); // 8×6, but Rotate90 → 6×8
897        let result = apply_orientation_into(
898            slice(&data, w, h, desc),
899            Orientation::Rotate90,
900            wrong.as_slice_mut(),
901        );
902        assert!(
903            matches!(result, Err(ConvertError::BufferSize { .. })),
904            "expected BufferSize, got {result:?}"
905        );
906    }
907
908    /// In-place must produce byte-identical output to the proven out-of-place
909    /// `apply_orientation`, across square + non-square, tight + (via
910    /// `PixelBuffer::new`'s aligned stride) padded buffers, every orientation
911    /// and a spread of element sizes. This is the correctness gate for the
912    /// diagonal-swap (square), cycle-following (non-square), and in-place flips.
913    #[test]
914    fn in_place_matches_out_of_place() {
915        let descs = [
916            PixelDescriptor::GRAY8,
917            PixelDescriptor::GRAYA8,
918            PixelDescriptor::RGB8,
919            PixelDescriptor::RGBA8,
920            PixelDescriptor::RGBAF32,
921        ];
922        let dims = [
923            (1u32, 1u32),
924            (2, 2),
925            (4, 4),
926            (8, 8),
927            (32, 32),
928            (3, 5),
929            (5, 3),
930            (17, 13),
931            (13, 17),
932            (16, 9),
933            (9, 16),
934            (7, 1),
935            (1, 7),
936        ];
937        for &desc in &descs {
938            let bpp = desc.bytes_per_pixel();
939            for &(w, h) in &dims {
940                let data = fill(w as usize * h as usize * bpp);
941                for &o in &Orientation::ALL {
942                    let want = apply_orientation(slice(&data, w, h, desc), o);
943                    // Load `data` into a fresh buffer (its stride may be padded),
944                    // then bake in place.
945                    let mut buf = PixelBuffer::new(w, h, desc);
946                    {
947                        let mut s = buf.as_slice_mut();
948                        for y in 0..h {
949                            s.row_mut(y).copy_from_slice(
950                                &data[y as usize * w as usize * bpp..][..w as usize * bpp],
951                            );
952                        }
953                    }
954                    apply_orientation_in_place(&mut buf, o)
955                        .expect("in_place should accept bpp ≤ 16");
956                    assert_eq!(
957                        (buf.width(), buf.height()),
958                        (want.width(), want.height()),
959                        "{o:?} {desc:?} {w}x{h} dims"
960                    );
961                    for y in 0..buf.height() {
962                        assert_eq!(
963                            buf.as_slice().row(y),
964                            want.as_slice().row(y),
965                            "{o:?} {desc:?} {w}x{h} row {y}"
966                        );
967                    }
968                }
969            }
970        }
971    }
972}