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}