Skip to main content

device_envoy/led2d/
layout.rs

1//! Module containing [`LedLayout`], the struct for compile-time description of
2//! panel geometry and wiring.
3//!
4//! See [`LedLayout`] for details and examples.
5
6/// Compile-time description of panel geometry and wiring, including dimensions (with examples).
7///
8/// `LedLayout` defines how a rectangular `(x, y)` panel of LEDs maps to the linear
9/// wiring order of LEDs on a NeoPixel-style (WS2812) panel.
10///
11/// For examples of `LedLayout` in use, see the [`led2d`](mod@crate::led2d) module,
12/// [`Frame2d`](crate::led2d::Frame2d), and the example below.
13///
14/// **What `LedLayout` does:**
15/// - Lets you describe panel wiring once
16/// - Enables drawing text, graphics, and animations in `(x, y)` space
17/// - Hides LED strip order from rendering code
18///
19/// Coordinates use a screen-style convention:
20/// - `(0, 0)` is the top-left corner
21/// - `x` increases to the right
22/// - `y` increases downward
23///
24/// Most users should start with one of the constructors below and then apply
25/// transforms ([rotate_cw](`Self::rotate_cw`), [flip_h](`Self::flip_h`), [combine_v](`Self::combine_v`), etc.)
26/// as needed.
27///
28/// ## Constructing layouts
29///
30/// Prefer the built-in constructors when possible:
31/// - [`serpentine_row_major`](Self::serpentine_row_major)
32/// - [`serpentine_column_major`](Self::serpentine_column_major)
33/// - [`linear_h`](Self::linear_h) / [`linear_v`](Self::linear_v)
34///
35/// For unusual wiring, you can construct a layout directly with [`LedLayout::new`]
36/// by listing `(x, y)` for each LED in the order the strip is wired.
37///
38/// **The example below shows both construction methods.** Also, the documentation for every constructor
39/// and method includes illustrations of use.
40///
41/// ## Transforming layouts
42///
43/// You can adapt a layout without rewriting it:
44/// - rotate: [`rotate_cw`](Self::rotate_cw), [`rotate_ccw`](Self::rotate_ccw), [`rotate_180`](Self::rotate_180)
45/// - flip: [`flip_h`](Self::flip_h), [`flip_v`](Self::flip_v)
46/// - combine: [`combine_h`](Self::combine_h), [`combine_v`](Self::combine_v)  (join two layouts into a larger one)
47///
48/// ## Validation
49///
50/// Layouts are validated at **compile time**:
51/// - coordinates must be in-bounds
52/// - every `(x, y)` cell must appear exactly once
53///
54/// If you want the final mapping, use [`index_to_xy`](Self::index_to_xy).
55///
56/// # Example
57///
58/// Rotate a serpentine-wired 3×2 panel into a 2×3 layout and verify the result at compile time:
59///
60/// ```rust,no_run
61/// # #![no_std]
62/// # #![no_main]
63/// # #[panic_handler]
64/// # fn panic(_: &core::panic::PanicInfo) -> ! { loop {} }
65/// use device_envoy::led2d::layout::LedLayout;
66///
67/// const ROTATED: LedLayout<6, 2, 3> = LedLayout::serpentine_column_major().rotate_cw();
68/// const EXPECTED: LedLayout<6, 2, 3> =
69///     LedLayout::new([(1, 0), (0, 0), (0, 1), (1, 1), (1, 2), (0, 2)]);
70/// const _: () = assert!(ROTATED.equals(&EXPECTED)); // Compile-time assert
71/// ```
72///
73/// ```text
74/// Serpentine 3×2 rotated to 2×3:
75///
76///   Before:              After:
77///     LED0  LED3  LED4     LED1  LED0
78///     LED1  LED2  LED5     LED2  LED3
79///                          LED5  LED4
80/// ```
81#[derive(Clone, Copy, Debug, PartialEq, Eq)]
82pub struct LedLayout<const N: usize, const W: usize, const H: usize> {
83    map: [(u16, u16); N],
84}
85
86impl<const N: usize, const W: usize, const H: usize> LedLayout<N, W, H> {
87    /// Return the array mapping LED wiring order to `(x, y)` coordinates.
88    #[must_use]
89    pub const fn index_to_xy(&self) -> &[(u16, u16); N] {
90        &self.map
91    }
92
93    /// The width of the layout.
94    #[must_use]
95    pub const fn width(&self) -> usize {
96        W
97    }
98
99    /// The height of the layout.
100    #[must_use]
101    pub const fn height(&self) -> usize {
102        H
103    }
104
105    /// Total LEDs in this layout (width × height).
106    #[must_use]
107    pub const fn len(&self) -> usize {
108        N
109    }
110
111    #[must_use]
112    pub(crate) const fn xy_to_index(&self) -> [u16; N] {
113        assert!(
114            N <= u16::MAX as usize,
115            "total LEDs must fit in u16 for xy_to_index"
116        );
117
118        let mut mapping = [None; N];
119
120        let mut led_index = 0;
121        while led_index < N {
122            let (col, row) = self.map[led_index];
123            let col = col as usize;
124            let row = row as usize;
125            assert!(col < W, "column out of bounds in xy_to_index");
126            assert!(row < H, "row out of bounds in xy_to_index");
127            let target_index = row * W + col;
128
129            let slot = &mut mapping[target_index];
130            assert!(
131                slot.is_none(),
132                "duplicate (col,row) in xy_to_index inversion"
133            );
134            *slot = Some(led_index as u16);
135
136            led_index += 1;
137        }
138
139        let mut finalized = [0u16; N];
140        let mut i = 0;
141        while i < N {
142            finalized[i] = mapping[i].expect("xy_to_index requires every (col,row) to be covered");
143            i += 1;
144        }
145
146        finalized
147    }
148
149    /// Const equality helper for doctests/examples.
150    ///
151    /// ```rust,no_run
152    /// # #![no_std]
153    /// # #![no_main]
154    /// # #[panic_handler]
155    /// # fn panic(_: &core::panic::PanicInfo) -> ! { loop {} }
156    /// use device_envoy::led2d::layout::LedLayout;
157    ///
158    /// const LINEAR: LedLayout<4, 4, 1> = LedLayout::linear_h();
159    /// const ROTATED: LedLayout<4, 4, 1> = LedLayout::linear_v().rotate_cw();
160    ///
161    /// const _: () = assert!(LINEAR.equals(&LINEAR));   // assert equal
162    /// const _: () = assert!(!LINEAR.equals(&ROTATED)); // assert not equal
163    /// ```
164    ///
165    /// ```text
166    /// LINEAR:  LED0  LED1  LED2  LED3
167    /// ROTATED: LED3  LED2  LED1  LED0
168    /// ```
169    #[must_use]
170    pub const fn equals(&self, other: &Self) -> bool {
171        let mut i = 0;
172        while i < N {
173            if self.map[i].0 != other.map[i].0 || self.map[i].1 != other.map[i].1 {
174                return false;
175            }
176            i += 1;
177        }
178        true
179    }
180
181    /// Construct a `LedLayout` by explicitly specifying the wiring order.
182    ///
183    /// Use this constructor when your panel wiring does not match one of the
184    /// built-in patterns (linear, serpentine, etc.). You provide the `(x, y)`
185    /// coordinate for **each LED in strip order**, and `LedLayout` derives the
186    /// panel geometry from that mapping.
187    ///
188    /// This constructor is `const` and is intended to be used in a `const`
189    /// definition, so layout errors are caught at **compile time**, not at runtime.
190    /// ```rust,no_run
191    /// # #![no_std]
192    /// # #![no_main]
193    /// # #[panic_handler]
194    /// # fn panic(_: &core::panic::PanicInfo) -> ! { loop {} }
195    /// use device_envoy::led2d::layout::LedLayout;
196    ///
197    /// // 3×2 panel (landscape, W×H)
198    /// const MAP: LedLayout<6, 3, 2> =
199    ///     LedLayout::new([(0, 0), (1, 0), (2, 0), (2, 1), (1, 1), (0, 1)]);
200    ///
201    /// // Rotate to portrait (CW)
202    /// const ROTATED: LedLayout<6, 2, 3> = MAP.rotate_cw();
203    ///
204    /// // Expected: 2×3 panel (W×H)
205    /// const EXPECTED: LedLayout<6, 2, 3> =
206    ///     LedLayout::new([(1, 0), (1, 1), (1, 2), (0, 2), (0, 1), (0, 0)]);
207    ///
208    /// const _: () = assert!(ROTATED.equals(&EXPECTED));
209    /// ```
210    ///
211    /// ```text
212    /// 3×2 input (col,row by LED index):
213    ///   LED0  LED1  LED2
214    ///   LED5  LED4  LED3
215    ///
216    /// After rotate to 2×3:
217    ///   LED1  LED0
218    ///   LED2  LED3
219    ///   LED5  LED4
220    /// ```
221    #[must_use]
222    pub const fn new(map: [(u16, u16); N]) -> Self {
223        assert!(W > 0 && H > 0, "W and H must be positive");
224        assert!(W * H == N, "W*H must equal N");
225
226        let mut seen = [false; N];
227
228        let mut i = 0;
229        while i < N {
230            let (c, r) = map[i];
231            let c = c as usize;
232            let r = r as usize;
233
234            assert!(c < W, "column out of bounds");
235            assert!(r < H, "row out of bounds");
236
237            let cell = r * W + c;
238            assert!(!seen[cell], "duplicate (col,row) in mapping");
239            seen[cell] = true;
240
241            i += 1;
242        }
243
244        let mut k = 0;
245        while k < N {
246            assert!(seen[k], "mapping does not cover every cell");
247            k += 1;
248        }
249
250        Self { map }
251    }
252
253    /// Linear row-major mapping for a single-row strip (cols increase left-to-right).
254    ///
255    /// ```rust,no_run
256    /// # #![no_std]
257    /// # #![no_main]
258    /// # #[panic_handler]
259    /// # fn panic(_: &core::panic::PanicInfo) -> ! { loop {} }
260    /// use device_envoy::led2d::layout::LedLayout;
261    ///
262    /// const LINEAR: LedLayout<6, 6, 1> = LedLayout::linear_h();
263    /// const EXPECTED: LedLayout<6, 6, 1> =
264    ///     LedLayout::new([(0, 0), (1, 0), (2, 0), (3, 0), (4, 0), (5, 0)]);
265    /// const _: () = assert!(LINEAR.equals(&EXPECTED));
266    /// ```
267    ///
268    /// ```text
269    /// 6×1 strip maps to single row:
270    ///   LED0  LED1  LED2  LED3  LED4  LED5
271    /// ```
272    #[must_use]
273    pub const fn linear_h() -> Self {
274        assert!(H == 1, "linear_h requires H == 1");
275        assert!(W == N, "linear_h requires W == N");
276
277        let mut mapping = [(0_u16, 0_u16); N];
278        let mut x_index = 0;
279        while x_index < W {
280            mapping[x_index] = (x_index as u16, 0);
281            x_index += 1;
282        }
283        Self::new(mapping)
284    }
285
286    /// Linear column-major mapping for a single-column strip (rows increase top-to-bottom).
287    ///
288    /// ```rust,no_run
289    /// # #![no_std]
290    /// # #![no_main]
291    /// # #[panic_handler]
292    /// # fn panic(_: &core::panic::PanicInfo) -> ! { loop {} }
293    /// use device_envoy::led2d::layout::LedLayout;
294    ///
295    /// const LINEAR: LedLayout<6, 1, 6> = LedLayout::linear_v();
296    /// const EXPECTED: LedLayout<6, 1, 6> =
297    ///     LedLayout::new([(0, 0), (0, 1), (0, 2), (0, 3), (0, 4), (0, 5)]);
298    /// const _: () = assert!(LINEAR.equals(&EXPECTED));
299    /// ```
300    ///
301    /// ```text
302    /// 1×6 strip maps to single column:
303    ///   LED0
304    ///   LED1
305    ///   LED2
306    ///   LED3
307    ///   LED4
308    ///   LED5
309    /// ```
310    #[must_use]
311    pub const fn linear_v() -> Self {
312        assert!(W == 1, "linear_v requires W == 1");
313        assert!(H == N, "linear_v requires H == N");
314
315        let mut mapping = [(0_u16, 0_u16); N];
316        let mut y_index = 0;
317        while y_index < H {
318            mapping[y_index] = (0, y_index as u16);
319            y_index += 1;
320        }
321        Self::new(mapping)
322    }
323
324    /// Serpentine column-major mapping returned as a checked `LedLayout`.
325    ///
326    /// ```rust,no_run
327    /// # #![no_std]
328    /// # #![no_main]
329    /// # #[panic_handler]
330    /// # fn panic(_: &core::panic::PanicInfo) -> ! { loop {} }
331    /// use device_envoy::led2d::layout::LedLayout;
332    ///
333    /// const MAP: LedLayout<6, 3, 2> = LedLayout::serpentine_column_major();
334    /// const EXPECTED: LedLayout<6, 3, 2> =
335    ///     LedLayout::new([(0, 0), (0, 1), (1, 1), (1, 0), (2, 0), (2, 1)]);
336    /// const _: () = assert!(MAP.equals(&EXPECTED));
337    /// ```
338    ///
339    /// ```text
340    /// Strip snakes down columns (3×2 example):
341    ///   LED0  LED3  LED4
342    ///   LED1  LED2  LED5
343    /// ```
344    #[must_use]
345    pub const fn serpentine_column_major() -> Self {
346        assert!(W > 0 && H > 0, "W and H must be positive");
347        assert!(W * H == N, "W*H must equal N");
348
349        let mut mapping = [(0_u16, 0_u16); N];
350        let mut y_index = 0;
351        while y_index < H {
352            let mut x_index = 0;
353            while x_index < W {
354                let led_index = if x_index % 2 == 0 {
355                    // Even column: top-to-bottom
356                    x_index * H + y_index
357                } else {
358                    // Odd column: bottom-to-top
359                    x_index * H + (H - 1 - y_index)
360                };
361                mapping[led_index] = (x_index as u16, y_index as u16);
362                x_index += 1;
363            }
364            y_index += 1;
365        }
366        Self::new(mapping)
367    }
368
369    /// Serpentine row-major mapping (alternating left-to-right and right-to-left across rows).
370    ///
371    /// ```rust,no_run
372    /// # #![no_std]
373    /// # #![no_main]
374    /// # #[panic_handler]
375    /// # fn panic(_: &core::panic::PanicInfo) -> ! { loop {} }
376    /// use device_envoy::led2d::layout::LedLayout;
377    ///
378    /// const MAP: LedLayout<6, 3, 2> = LedLayout::serpentine_row_major();
379    /// const EXPECTED: LedLayout<6, 3, 2> =
380    ///     LedLayout::new([(0, 0), (1, 0), (2, 0), (2, 1), (1, 1), (0, 1)]);
381    /// const _: () = assert!(MAP.equals(&EXPECTED));
382    /// ```
383    ///
384    /// ```text
385    /// Strip snakes across rows (3×2 example):
386    ///   LED0  LED1  LED2
387    ///   LED5  LED4  LED3
388    /// ```
389    #[must_use]
390    pub const fn serpentine_row_major() -> Self {
391        assert!(W > 0 && H > 0, "W and H must be positive");
392        assert!(W * H == N, "W*H must equal N");
393
394        let mut mapping = [(0_u16, 0_u16); N];
395        let mut y_index = 0;
396        while y_index < H {
397            let mut x_index = 0;
398            while x_index < W {
399                let led_index = if y_index % 2 == 0 {
400                    y_index * W + x_index
401                } else {
402                    y_index * W + (W - 1 - x_index)
403                };
404                mapping[led_index] = (x_index as u16, y_index as u16);
405                x_index += 1;
406            }
407            y_index += 1;
408        }
409        Self::new(mapping)
410    }
411
412    /// Rotate 90° clockwise (dims swap).
413    ///
414    /// ```rust,no_run
415    /// # #![no_std]
416    /// # #![no_main]
417    /// # #[panic_handler]
418    /// # fn panic(_: &core::panic::PanicInfo) -> ! { loop {} }
419    /// use device_envoy::led2d::layout::LedLayout;
420    ///
421    /// const ROTATED: LedLayout<6, 2, 3> = LedLayout::serpentine_column_major().rotate_cw();
422    /// const EXPECTED: LedLayout<6, 2, 3> =
423    ///     LedLayout::new([(1, 0), (0, 0), (0, 1), (1, 1), (1, 2), (0, 2)]);
424    /// const _: () = assert!(ROTATED.equals(&EXPECTED));
425    /// ```
426    ///
427    /// ```text
428    /// Before (3×2 serpentine): After (2×3):
429    ///   LED0  LED3  LED4        LED1  LED0
430    ///   LED1  LED2  LED5        LED2  LED3
431    ///                           LED5  LED4
432    /// ```
433    #[must_use]
434    pub const fn rotate_cw(self) -> LedLayout<N, H, W> {
435        let mut out = [(0u16, 0u16); N];
436        let mut i = 0;
437        while i < N {
438            let (c, r) = self.map[i];
439            let c = c as usize;
440            let r = r as usize;
441            out[i] = ((H - 1 - r) as u16, c as u16);
442            i += 1;
443        }
444        LedLayout::<N, H, W>::new(out)
445    }
446
447    /// Flip horizontally (mirror columns).
448    ///
449    /// ```rust,no_run
450    /// # #![no_std]
451    /// # #![no_main]
452    /// # #[panic_handler]
453    /// # fn panic(_: &core::panic::PanicInfo) -> ! { loop {} }
454    /// use device_envoy::led2d::layout::LedLayout;
455    ///
456    /// const FLIPPED: LedLayout<6, 3, 2> = LedLayout::serpentine_column_major().flip_h();
457    /// const EXPECTED: LedLayout<6, 3, 2> =
458    ///     LedLayout::new([(2, 0), (2, 1), (1, 1), (1, 0), (0, 0), (0, 1)]);
459    /// const _: () = assert!(FLIPPED.equals(&EXPECTED));
460    /// ```
461    ///
462    /// ```text
463    /// Before (serpentine): After:
464    ///   LED0  LED3  LED4      LED4  LED3  LED0
465    ///   LED1  LED2  LED5      LED5  LED2  LED1
466    /// ```
467    #[must_use]
468    pub const fn flip_h(self) -> Self {
469        let mut out = [(0u16, 0u16); N];
470        let mut i = 0;
471        while i < N {
472            let (c, r) = self.map[i];
473            let c = c as usize;
474            out[i] = ((W - 1 - c) as u16, r);
475            i += 1;
476        }
477        Self::new(out)
478    }
479
480    /// Rotate 180° derived from rotate_cw.
481    ///
482    /// ```rust,no_run
483    /// # #![no_std]
484    /// # #![no_main]
485    /// # #[panic_handler]
486    /// # fn panic(_: &core::panic::PanicInfo) -> ! { loop {} }
487    /// use device_envoy::led2d::layout::LedLayout;
488    ///
489    /// const ROTATED: LedLayout<6, 3, 2> = LedLayout::serpentine_column_major().rotate_180();
490    /// const EXPECTED: LedLayout<6, 3, 2> =
491    ///     LedLayout::new([(2, 1), (2, 0), (1, 0), (1, 1), (0, 1), (0, 0)]);
492    /// const _: () = assert!(ROTATED.equals(&EXPECTED));
493    /// ```
494    ///
495    /// ```text
496    /// Before (3×2 serpentine): After 180°:
497    ///   LED0  LED3  LED4        LED5  LED2  LED1
498    ///   LED1  LED2  LED5        LED4  LED3  LED0
499    /// ```
500    #[must_use]
501    pub const fn rotate_180(self) -> Self {
502        self.rotate_cw().rotate_cw()
503    }
504
505    /// Rotate 90° counter-clockwise derived from rotate_cw.
506    ///
507    /// ```rust,no_run
508    /// # #![no_std]
509    /// # #![no_main]
510    /// # #[panic_handler]
511    /// # fn panic(_: &core::panic::PanicInfo) -> ! { loop {} }
512    /// use device_envoy::led2d::layout::LedLayout;
513    ///
514    /// const ROTATED: LedLayout<6, 2, 3> = LedLayout::serpentine_column_major().rotate_ccw();
515    /// const EXPECTED: LedLayout<6, 2, 3> =
516    ///     LedLayout::new([(0, 2), (1, 2), (1, 1), (0, 1), (0, 0), (1, 0)]);
517    /// const _: () = assert!(ROTATED.equals(&EXPECTED));
518    /// ```
519    ///
520    /// ```text
521    /// Before (3×2 serpentine): After (2×3):
522    ///   LED0  LED3  LED4        LED4  LED5
523    ///   LED1  LED2  LED5        LED3  LED2
524    ///                           LED0  LED1
525    /// ```
526    #[must_use]
527    pub const fn rotate_ccw(self) -> LedLayout<N, H, W> {
528        self.rotate_cw().rotate_cw().rotate_cw()
529    }
530
531    /// Flip vertically derived from rotation + horizontal flip.
532    ///
533    /// ```rust,no_run
534    /// # #![no_std]
535    /// # #![no_main]
536    /// # #[panic_handler]
537    /// # fn panic(_: &core::panic::PanicInfo) -> ! { loop {} }
538    /// use device_envoy::led2d::layout::LedLayout;
539    ///
540    /// const FLIPPED: LedLayout<6, 3, 2> = LedLayout::serpentine_column_major().flip_v();
541    /// const EXPECTED: LedLayout<6, 3, 2> =
542    ///     LedLayout::new([(0, 1), (0, 0), (1, 0), (1, 1), (2, 1), (2, 0)]);
543    /// const _: () = assert!(FLIPPED.equals(&EXPECTED));
544    /// ```
545    ///
546    /// ```text
547    /// Before (serpentine): After:
548    ///   LED0  LED3  LED4      LED1  LED2  LED5
549    ///   LED1  LED2  LED5      LED0  LED3  LED4
550    /// ```
551    #[must_use]
552    pub const fn flip_v(self) -> Self {
553        self.rotate_cw().flip_h().rotate_ccw()
554    }
555
556    /// Concatenate horizontally with another mapping sharing the same rows.
557    ///
558    /// ```rust,no_run
559    /// # #![no_std]
560    /// # #![no_main]
561    /// # #[panic_handler]
562    /// # fn panic(_: &core::panic::PanicInfo) -> ! { loop {} }
563    /// use device_envoy::led2d::layout::LedLayout;
564    ///
565    /// const LED_LAYOUT: LedLayout<6, 3, 2> = LedLayout::serpentine_column_major();
566    /// const COMBINED: LedLayout<12, 6, 2> = LED_LAYOUT.combine_h::<6, 12, 3, 6>(LED_LAYOUT);
567    /// const EXPECTED: LedLayout<12, 6, 2> = LedLayout::new([
568    ///     (0, 0), (0, 1), (1, 1), (1, 0), (2, 0), (2, 1), (3, 0), (3, 1), (4, 1),
569    ///     (4, 0), (5, 0), (5, 1),
570    /// ]);
571    /// const _: () = assert!(COMBINED.equals(&EXPECTED));
572    /// ```
573    ///
574    /// ```text
575    /// Left serpentine (3×2):    Right serpentine (3×2):
576    ///   0  3  4                   6  9 10
577    ///   1  2  5                   7  8 11
578    ///
579    /// Combined (6×2):
580    ///   0  3  4  6  9 10
581    ///   1  2  5  7  8 11
582    /// ```
583    #[must_use]
584    pub const fn combine_h<
585        const N2: usize,
586        const OUT_N: usize,
587        const W2: usize,
588        const OUT_W: usize,
589    >(
590        self,
591        right: LedLayout<N2, W2, H>,
592    ) -> LedLayout<OUT_N, OUT_W, H> {
593        assert!(OUT_N == N + N2, "OUT_N must equal LEFT + RIGHT");
594        assert!(OUT_W == W + W2, "OUT_W must equal W + W2");
595
596        let mut out = [(0u16, 0u16); OUT_N];
597
598        let mut i = 0;
599        while i < N {
600            out[i] = self.map[i];
601            i += 1;
602        }
603
604        let mut j = 0;
605        while j < N2 {
606            let (c, r) = right.map[j];
607            out[N + j] = ((c as usize + W) as u16, r);
608            j += 1;
609        }
610
611        LedLayout::<OUT_N, OUT_W, H>::new(out)
612    }
613
614    /// Concatenate vertically with another mapping sharing the same columns.
615    ///
616    /// ```rust,no_run
617    /// # #![no_std]
618    /// # #![no_main]
619    /// # #[panic_handler]
620    /// # fn panic(_: &core::panic::PanicInfo) -> ! { loop {} }
621    /// use device_envoy::led2d::layout::LedLayout;
622    ///
623    /// const LED_LAYOUT: LedLayout<6, 3, 2> = LedLayout::serpentine_column_major();
624    /// const COMBINED: LedLayout<12, 3, 4> = LED_LAYOUT.combine_v::<6, 12, 2, 4>(LED_LAYOUT);
625    /// const EXPECTED: LedLayout<12, 3, 4> = LedLayout::new([
626    ///     (0, 0), (0, 1), (1, 1), (1, 0), (2, 0), (2, 1), (0, 2), (0, 3), (1, 3),
627    ///     (1, 2), (2, 2), (2, 3),
628    /// ]);
629    /// const _: () = assert!(COMBINED.equals(&EXPECTED));
630    /// ```
631    ///
632    /// ```text
633    /// Top serpentine (3×2):    Bottom serpentine (3×2):
634    ///   0  3  4                   6  9 10
635    ///   1  2  5                   7  8 11
636    ///
637    /// Combined (3×4):
638    ///   0  3  4
639    ///   1  2  5
640    ///   6  9 10
641    ///   7  8 11
642    /// ```
643    #[must_use]
644    pub const fn combine_v<
645        const N2: usize,
646        const OUT_N: usize,
647        const H2: usize,
648        const OUT_H: usize,
649    >(
650        self,
651        bottom: LedLayout<N2, W, H2>,
652    ) -> LedLayout<OUT_N, W, OUT_H> {
653        assert!(OUT_N == N + N2, "OUT_N must equal TOP + BOTTOM");
654        assert!(OUT_H == H + H2, "OUT_H must equal H + H2");
655
656        // Derive vertical concat via transpose + horizontal concat + transpose back.
657        // Transpose is implemented as rotate_cw + flip_h.
658        let top_t = self.rotate_cw().flip_h(); // H width, W height
659        let bot_t = bottom.rotate_cw().flip_h(); // H2 width, W height
660
661        let combined_t: LedLayout<OUT_N, OUT_H, W> = top_t.combine_h::<N2, OUT_N, H2, OUT_H>(bot_t);
662
663        combined_t.rotate_cw().flip_h() // transpose back to W x OUT_H
664    }
665}