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}