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