Skip to main content

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