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}