Skip to main content

wall/
lib.rs

1//! Panel topology: one logical image mapped onto a wall of panels, any size,
2//! rotation or mirroring, across any number of receiving cards. Rendering
3//! yields one screen-sized framebuffer: every card on the chain receives every
4//! row and keeps the pixels inside its own window (docs/receiver-identity.md),
5//! so a receiver's position here is the position it was provisioned with.
6//! The wire protocol is not involved here.
7
8use serde::{Deserialize, Serialize};
9
10/// An RGB8 image.
11#[derive(Clone, Debug, PartialEq, Eq)]
12pub struct Frame {
13    pub width: u32,
14    pub height: u32,
15    /// Always exactly `width * height * 3` bytes, row major.
16    data: Vec<u8>,
17}
18
19impl Frame {
20    #[must_use]
21    pub fn black(width: u32, height: u32) -> Self {
22        Self {
23            width,
24            height,
25            data: vec![0; (width as usize) * (height as usize) * 3],
26        }
27    }
28
29    /// Wrap existing RGB8 bytes.
30    ///
31    /// # Errors
32    /// Fails if `data` is not exactly `width * height * 3` bytes.
33    pub fn from_rgb(width: u32, height: u32, data: Vec<u8>) -> Result<Self, FrameError> {
34        let want = (width as usize) * (height as usize) * 3;
35        if data.len() == want {
36            Ok(Self {
37                width,
38                height,
39                data,
40            })
41        } else {
42            Err(FrameError::WrongSize {
43                got: data.len(),
44                want,
45            })
46        }
47    }
48
49    /// The raw RGB8 bytes, row major.
50    #[inline]
51    #[must_use]
52    pub fn as_bytes(&self) -> &[u8] {
53        &self.data
54    }
55
56    /// The raw RGB8 bytes, row major, for in-place fills.
57    #[inline]
58    pub fn as_bytes_mut(&mut self) -> &mut [u8] {
59        &mut self.data
60    }
61
62    #[inline]
63    #[must_use]
64    pub fn into_bytes(self) -> Vec<u8> {
65        self.data
66    }
67
68    /// One row of pixels. Panics if `y` is off the frame.
69    #[inline]
70    #[must_use]
71    pub fn row(&self, y: u32) -> &[[u8; 3]] {
72        let stride = (self.width as usize) * 3;
73        let start = (y as usize) * stride;
74        self.data[start..start + stride].as_chunks::<3>().0
75    }
76
77    /// One row of pixels, writable. Panics if `y` is off the frame.
78    #[inline]
79    pub fn row_mut(&mut self, y: u32) -> &mut [[u8; 3]] {
80        let stride = (self.width as usize) * 3;
81        let start = (y as usize) * stride;
82        self.data[start..start + stride].as_chunks_mut::<3>().0
83    }
84
85    /// Every row, top to bottom.
86    pub fn rows(&self) -> impl Iterator<Item = &[[u8; 3]]> + '_ {
87        (0..self.height).map(|y| self.row(y))
88    }
89
90    /// The pixel at `(x, y)`; black when off the frame.
91    #[inline]
92    #[must_use]
93    pub fn pixel(&self, x: u32, y: u32) -> [u8; 3] {
94        if x >= self.width || y >= self.height {
95            return [0; 3];
96        }
97        self.row(y)[x as usize]
98    }
99
100    /// Set the pixel at `(x, y)`; a no-op when off the frame.
101    #[inline]
102    pub fn set_pixel(&mut self, x: u32, y: u32, px: [u8; 3]) {
103        if x >= self.width || y >= self.height {
104            return;
105        }
106        self.row_mut(y)[x as usize] = px;
107    }
108}
109
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111pub enum FrameError {
112    WrongSize { got: usize, want: usize },
113}
114
115impl std::fmt::Display for FrameError {
116    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117        match self {
118            Self::WrongSize { got, want } => {
119                write!(f, "frame data is {got} bytes, expected {want}")
120            }
121        }
122    }
123}
124
125impl std::error::Error for FrameError {}
126
127/// How a panel is physically mounted relative to the image.
128#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
129#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
130#[serde(rename_all = "kebab-case")]
131pub enum Rotation {
132    #[default]
133    None,
134    /// Quarter turn clockwise.
135    Cw90,
136    /// Quarter turn counter-clockwise.
137    Ccw90,
138    Rot180,
139}
140
141/// One panel, positioned on the wall and assigned to a receiver.
142#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
143#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
144pub struct Panel {
145    /// Which receiving card drives this panel.
146    pub receiver: u16,
147    /// Where the panel's top-left sits in the receiver's own pixel space.
148    #[serde(default)]
149    pub receiver_x: u32,
150    #[serde(default)]
151    pub receiver_y: u32,
152    /// Where the panel's top-left sits on the logical canvas.
153    pub x: u32,
154    pub y: u32,
155    /// Panel size as it appears on the canvas, after rotation.
156    pub width: u32,
157    pub height: u32,
158    #[serde(default)]
159    pub rotation: Rotation,
160    #[serde(default)]
161    pub flip_x: bool,
162    #[serde(default)]
163    pub flip_y: bool,
164}
165
166/// Where a panel's canvas rectangle lands in receiver space, as an affine map:
167/// `origin + local_x * col_step + local_y * row_step`.
168#[derive(Clone, Copy, Debug, PartialEq, Eq)]
169struct Placement {
170    origin: (i64, i64),
171    col_step: (i64, i64),
172    row_step: (i64, i64),
173}
174
175impl Placement {
176    /// True when receiver rows are canvas rows, in the same direction, so a
177    /// panel row is one contiguous copy.
178    const fn is_row_copy(self) -> bool {
179        matches!(self.col_step, (1, 0)) && matches!(self.row_step, (0, 1))
180    }
181}
182
183impl Panel {
184    /// Map a point inside this panel's canvas rectangle to the pixel within the
185    /// receiver's framebuffer that lights it. Requires a non-empty panel.
186    fn receiver_coords(&self, local_x: u32, local_y: u32) -> (u32, u32) {
187        let (max_x, max_y) = (self.width - 1, self.height - 1);
188        // Mirror in canvas space, then undo the mounting rotation.
189        let lx = if self.flip_x { max_x - local_x } else { local_x };
190        let ly = if self.flip_y { max_y - local_y } else { local_y };
191        let (px, py) = match self.rotation {
192            Rotation::None => (lx, ly),
193            Rotation::Cw90 => (ly, max_x - lx),
194            Rotation::Ccw90 => (max_y - ly, lx),
195            Rotation::Rot180 => (max_x - lx, max_y - ly),
196        };
197        (self.receiver_x + px, self.receiver_y + py)
198    }
199
200    /// Map a point inside this panel's canvas rectangle to the screen pixel
201    /// that lights it, for a panel on the receiver at `(rx, ry)`.
202    fn screen_coords(&self, receiver: (u32, u32), local_x: u32, local_y: u32) -> (u32, u32) {
203        let (px, py) = self.receiver_coords(local_x, local_y);
204        (receiver.0 + px, receiver.1 + py)
205    }
206
207    /// The mapping as an affine map; exact because every rotation/flip
208    /// combination is a rigid motion (`every_mounting_matches_the_per_pixel_mapping`).
209    fn placement(&self, receiver: (u32, u32)) -> Placement {
210        let at = |x, y| {
211            let (sx, sy) = self.screen_coords(receiver, x, y);
212            (i64::from(sx), i64::from(sy))
213        };
214        let origin = at(0, 0);
215        let step = |p: (i64, i64)| (p.0 - origin.0, p.1 - origin.1);
216        // A one-pixel-wide or -high panel never takes the step; (0, 0) keeps
217        // the arithmetic in range.
218        let col_step = if self.width > 1 { step(at(1, 0)) } else { (0, 0) };
219        let row_step = if self.height > 1 { step(at(0, 1)) } else { (0, 0) };
220        Placement {
221            origin,
222            col_step,
223            row_step,
224        }
225    }
226
227    /// The panel's size in its own, unrotated pixel space.
228    const fn native_size(&self) -> (u32, u32) {
229        match self.rotation {
230            Rotation::None | Rotation::Rot180 => (self.width, self.height),
231            Rotation::Cw90 | Rotation::Ccw90 => (self.height, self.width),
232        }
233    }
234
235    /// Copy this panel's canvas rectangle from `src` onto the screen `dst`,
236    /// for a panel on the receiver at `receiver`. Off-frame source reads
237    /// black; off-screen destination writes are dropped.
238    fn blit(&self, receiver: (u32, u32), src: &Frame, dst: &mut Frame) {
239        if self.width == 0 || self.height == 0 {
240            return;
241        }
242        let place = self.placement(receiver);
243        if place.is_row_copy() {
244            self.blit_rows(src, dst, place.origin);
245            return;
246        }
247        let (ox, oy) = place.origin;
248        let (cx, cy) = place.col_step;
249        let (rx, ry) = place.row_step;
250        for ly in 0..self.height {
251            let sy = self.y + ly;
252            let (mut x, mut y) = (ox + i64::from(ly) * rx, oy + i64::from(ly) * ry);
253            for lx in 0..self.width {
254                let px = src.pixel(self.x + lx, sy);
255                dst.set_pixel(x as u32, y as u32, px);
256                x += cx;
257                y += cy;
258            }
259        }
260    }
261
262    fn blit_rows(&self, src: &Frame, dst: &mut Frame, origin: (i64, i64)) {
263        let (rx, ry) = (origin.0 as u32, origin.1 as u32);
264        // Clip to the destination; what the source does not cover is written black.
265        let dst_w = self.width.min(dst.width.saturating_sub(rx)) as usize;
266        let dst_rows = self.height.min(dst.height.saturating_sub(ry));
267        let src_w = dst_w.min(src.width.saturating_sub(self.x) as usize);
268        let src_rows = dst_rows.min(src.height.saturating_sub(self.y));
269        let (sx, dx) = (self.x as usize, rx as usize);
270        for ly in 0..dst_rows {
271            let row = &mut dst.row_mut(ry + ly)[dx..dx + dst_w];
272            if ly < src_rows {
273                row[..src_w].copy_from_slice(&src.row(self.y + ly)[sx..sx + src_w]);
274                row[src_w..].fill([0; 3]);
275            } else {
276                row.fill([0; 3]);
277            }
278        }
279    }
280}
281
282/// A receiving card: where its window sits on the screen and how big it is.
283#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
284#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
285pub struct Receiver {
286    pub index: u16,
287    /// The card's top-left on the screen: the same numbers given to
288    /// `rxp provision --position x,y`, which is what the card's EEPROM
289    /// control area keeps of the stream.
290    #[serde(default)]
291    pub x: u32,
292    #[serde(default)]
293    pub y: u32,
294    pub width: u32,
295    pub height: u32,
296}
297
298/// A complete wall.
299#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
300#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
301pub struct Canvas {
302    pub width: u32,
303    pub height: u32,
304    pub receivers: Vec<Receiver>,
305    pub panels: Vec<Panel>,
306}
307
308/// Why a [`Canvas`] cannot be driven, one line per problem.
309#[derive(Debug, Clone, PartialEq, Eq)]
310pub struct LayoutError(pub Vec<String>);
311
312impl std::fmt::Display for LayoutError {
313    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
314        write!(f, "canvas is not valid:\n  {}", self.0.join("\n  "))
315    }
316}
317
318impl std::error::Error for LayoutError {}
319
320impl Canvas {
321    /// The common case: a single panel on a single receiver.
322    #[must_use]
323    pub fn single(width: u32, height: u32) -> Self {
324        Self::grid(width, height, 1, 1)
325    }
326
327    /// A regular grid of identical panels across one receiver.
328    #[must_use]
329    pub fn grid(panel_w: u32, panel_h: u32, cols: u32, rows: u32) -> Self {
330        let (width, height) = (panel_w * cols, panel_h * rows);
331        let panels = (0..rows)
332            .flat_map(|row| {
333                (0..cols).map(move |col| Panel {
334                    receiver: 0,
335                    receiver_x: col * panel_w,
336                    receiver_y: row * panel_h,
337                    x: col * panel_w,
338                    y: row * panel_h,
339                    width: panel_w,
340                    height: panel_h,
341                    rotation: Rotation::None,
342                    flip_x: false,
343                    flip_y: false,
344                })
345            })
346            .collect();
347        Self {
348            width,
349            height,
350            receivers: vec![Receiver {
351                index: 0,
352                x: 0,
353                y: 0,
354                width,
355                height,
356            }],
357            panels,
358        }
359    }
360
361    /// A regular grid of identical panels, one receiving card per panel,
362    /// cards numbered row-major from 0.
363    #[must_use]
364    pub fn cards(panel_w: u32, panel_h: u32, cols: u32, rows: u32) -> Self {
365        let mut canvas = Self::grid(panel_w, panel_h, cols, rows);
366        canvas.receivers = canvas
367            .panels
368            .iter()
369            .enumerate()
370            .map(|(i, p)| Receiver {
371                index: i as u16,
372                x: p.x,
373                y: p.y,
374                width: panel_w,
375                height: panel_h,
376            })
377            .collect();
378        for (i, p) in canvas.panels.iter_mut().enumerate() {
379            p.receiver = i as u16;
380            p.receiver_x = 0;
381            p.receiver_y = 0;
382        }
383        canvas
384    }
385
386    /// Check the description is self-consistent before anything is driven.
387    ///
388    /// # Errors
389    /// Reports receivers that fall off the canvas and panels that fall off the
390    /// canvas, name an unknown receiver, or exceed their receiver's window.
391    pub fn validate(&self) -> Result<(), LayoutError> {
392        let mut problems = Vec::new();
393        for r in &self.receivers {
394            if r.x + r.width > self.width || r.y + r.height > self.height {
395                problems.push(format!(
396                    "receiver {} at ({}, {}) size {}x{} extends past the {}x{} canvas",
397                    r.index, r.x, r.y, r.width, r.height, self.width, self.height
398                ));
399            }
400        }
401        for (i, p) in self.panels.iter().enumerate() {
402            if p.x + p.width > self.width || p.y + p.height > self.height {
403                problems.push(format!(
404                    "panel {i} at ({}, {}) size {}x{} extends past the {}x{} canvas",
405                    p.x, p.y, p.width, p.height, self.width, self.height
406                ));
407            }
408            let Some(r) = self.receivers.iter().find(|r| r.index == p.receiver) else {
409                problems.push(format!(
410                    "panel {i} names receiver {}, which is not defined",
411                    p.receiver
412                ));
413                continue;
414            };
415            let (nw, nh) = p.native_size();
416            if p.receiver_x + nw > r.width || p.receiver_y + nh > r.height {
417                problems.push(format!(
418                    "panel {i} occupies ({}, {}) size {nw}x{nh} on receiver {}, which is only {}x{}",
419                    p.receiver_x, p.receiver_y, r.index, r.width, r.height
420                ));
421            }
422        }
423        if problems.is_empty() {
424            Ok(())
425        } else {
426            Err(LayoutError(problems))
427        }
428    }
429
430    /// A black framebuffer the size of the screen.
431    #[must_use]
432    pub fn screen_frame(&self) -> Frame {
433        Frame::black(self.width, self.height)
434    }
435
436    /// Map one canvas image onto the screen: each panel's pixels land where
437    /// its receiver's window shows them.
438    #[must_use]
439    pub fn render(&self, src: &Frame) -> Frame {
440        let mut out = self.screen_frame();
441        self.render_into(src, &mut out);
442        out
443    }
444
445    /// [`render`](Self::render) into a caller-owned screen frame, so a refresh
446    /// loop allocates nothing. `out` is cleared to black and reused when it is
447    /// the screen size, replaced otherwise.
448    pub fn render_into(&self, src: &Frame, out: &mut Frame) {
449        if (out.width, out.height) == (self.width, self.height) {
450            out.data.fill(0);
451        } else {
452            *out = self.screen_frame();
453        }
454        for panel in &self.panels {
455            let Some(r) = self.receivers.iter().find(|r| r.index == panel.receiver) else {
456                continue;
457            };
458            panel.blit((r.x, r.y), src, out);
459        }
460    }
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466
467    fn gradient(w: u32, h: u32) -> Frame {
468        let mut f = Frame::black(w, h);
469        for y in 0..h {
470            for x in 0..w {
471                f.set_pixel(x, y, [x as u8, y as u8, 0]);
472            }
473        }
474        f
475    }
476
477    /// Reference per-pixel mapping the row-copy and affine paths must match.
478    fn render_per_pixel(canvas: &Canvas, src: &Frame) -> Frame {
479        let mut out = canvas.screen_frame();
480        for panel in &canvas.panels {
481            let Some(r) = canvas.receivers.iter().find(|r| r.index == panel.receiver) else {
482                continue;
483            };
484            for ly in 0..panel.height {
485                for lx in 0..panel.width {
486                    let px = src.pixel(panel.x + lx, panel.y + ly);
487                    let (sx, sy) = panel.screen_coords((r.x, r.y), lx, ly);
488                    out.set_pixel(sx, sy, px);
489                }
490            }
491        }
492        out
493    }
494
495    #[test]
496    fn a_single_panel_passes_the_image_through_unchanged() {
497        let canvas = Canvas::single(8, 4);
498        let src = gradient(8, 4);
499        assert_eq!(canvas.render(&src), src);
500    }
501
502    #[test]
503    fn single_is_a_one_by_one_grid() {
504        assert_eq!(Canvas::single(8, 4), Canvas::grid(8, 4, 1, 1));
505    }
506
507    #[test]
508    fn a_grid_tiles_panels_across_the_canvas() {
509        let canvas = Canvas::grid(4, 2, 2, 2);
510        assert_eq!((canvas.width, canvas.height), (8, 4));
511        assert_eq!(canvas.panels.len(), 4);
512        canvas.validate().unwrap();
513        assert_eq!(canvas.render(&gradient(8, 4)), gradient(8, 4));
514    }
515
516    #[test]
517    fn cards_put_one_receiver_under_each_panel_at_its_screen_position() {
518        let canvas = Canvas::cards(4, 2, 3, 2);
519        canvas.validate().unwrap();
520        assert_eq!(canvas.receivers.len(), 6);
521        let r = canvas.receivers[4];
522        assert_eq!((r.index, r.x, r.y, r.width, r.height), (4, 4, 2, 4, 2));
523        let p = &canvas.panels[4];
524        assert_eq!((p.receiver, p.receiver_x, p.receiver_y, p.x, p.y), (4, 0, 0, 4, 2));
525        assert_eq!(canvas.render(&gradient(12, 4)), gradient(12, 4));
526    }
527
528    #[test]
529    fn a_receiver_position_defaults_to_the_origin_in_layout_files() {
530        let r: Receiver = serde_json::from_str(r#"{"index":3,"width":8,"height":4}"#).unwrap();
531        assert_eq!((r.x, r.y), (0, 0));
532    }
533
534    #[test]
535    fn rows_are_contiguous_pixel_slices() {
536        let f = gradient(4, 2);
537        assert_eq!(f.row(1), &[[0, 1, 0], [1, 1, 0], [2, 1, 0], [3, 1, 0]]);
538        assert_eq!(f.rows().count(), 2);
539        assert_eq!(f.as_bytes().len(), 4 * 2 * 3);
540        assert_eq!(f.clone().into_bytes(), f.as_bytes());
541    }
542
543    #[test]
544    fn pixels_off_the_frame_read_black_and_ignore_writes() {
545        let mut f = gradient(4, 2);
546        assert_eq!(f.pixel(4, 0), [0; 3]);
547        assert_eq!(f.pixel(0, 2), [0; 3]);
548        f.set_pixel(4, 0, [9; 3]);
549        f.set_pixel(0, 2, [9; 3]);
550        assert_eq!(f, gradient(4, 2));
551    }
552
553    #[test]
554    fn rotation_maps_corners_where_expected() {
555        // Mounted 90 degrees clockwise: canvas rectangle 2x4, panel itself 4x2.
556        let mut canvas = Canvas {
557            width: 2,
558            height: 4,
559            receivers: vec![Receiver {
560                index: 0,
561                x: 0,
562                y: 0,
563                width: 4,
564                height: 2,
565            }],
566            panels: vec![Panel {
567                receiver: 0,
568                receiver_x: 0,
569                receiver_y: 0,
570                x: 0,
571                y: 0,
572                width: 2,
573                height: 4,
574                rotation: Rotation::Cw90,
575                flip_x: false,
576                flip_y: false,
577            }],
578        };
579        // The receiver's window is wider than the canvas is: 4x2 on a 2x4 screen.
580        assert!(canvas.validate().is_err());
581        canvas.width = 4;
582
583        let mut src = Frame::black(2, 4);
584        src.set_pixel(0, 0, [255, 0, 0]); // canvas top-left
585        let out = canvas.render(&src);
586        // Canvas top-left lands at the panel's bottom-left.
587        assert_eq!(out.pixel(0, 1), [255, 0, 0]);
588    }
589
590    #[test]
591    fn flipping_mirrors_the_image() {
592        let mut canvas = Canvas::single(4, 1);
593        canvas.panels[0].flip_x = true;
594        let mut src = Frame::black(4, 1);
595        src.set_pixel(0, 0, [1, 2, 3]);
596        assert_eq!(canvas.render(&src).pixel(3, 0), [1, 2, 3]);
597    }
598
599    #[test]
600    fn every_mounting_matches_the_per_pixel_mapping() {
601        // Two receivers at different screen positions, offset panels, odd
602        // sizes, every rotation and flip, plus a panel hanging off both frames.
603        let src = gradient(23, 17);
604        let rotations = [
605            Rotation::None,
606            Rotation::Cw90,
607            Rotation::Ccw90,
608            Rotation::Rot180,
609        ];
610        for rotation in rotations {
611            for (flip_x, flip_y) in [(false, false), (true, false), (false, true), (true, true)] {
612                let (w, h) = (7, 5);
613                let (nw, nh) = match rotation {
614                    Rotation::None | Rotation::Rot180 => (w, h),
615                    Rotation::Cw90 | Rotation::Ccw90 => (h, w),
616                };
617                let panel = |receiver, receiver_x, receiver_y, x, y| Panel {
618                    receiver,
619                    receiver_x,
620                    receiver_y,
621                    x,
622                    y,
623                    width: w,
624                    height: h,
625                    rotation,
626                    flip_x,
627                    flip_y,
628                };
629                let canvas = Canvas {
630                    width: 23,
631                    height: 17,
632                    receivers: vec![
633                        Receiver {
634                            index: 0,
635                            x: 0,
636                            y: 0,
637                            width: nw + 3,
638                            height: nh + 2,
639                        },
640                        Receiver {
641                            index: 5,
642                            x: 11,
643                            y: 6,
644                            width: nw + 1,
645                            height: nh,
646                        },
647                    ],
648                    panels: vec![
649                        panel(0, 3, 2, 1, 4),
650                        panel(5, 1, 0, 9, 11),
651                        panel(5, 3, 2, 20, 15),
652                    ],
653                };
654                assert_eq!(
655                    canvas.render(&src),
656                    render_per_pixel(&canvas, &src),
657                    "{rotation:?} flip_x={flip_x} flip_y={flip_y}"
658                );
659            }
660        }
661    }
662
663    #[test]
664    fn render_into_reuses_a_screen_sized_frame() {
665        let canvas = Canvas::grid(4, 2, 2, 2);
666        let mut out = Frame::black(1, 1);
667        canvas.render_into(&gradient(8, 4), &mut out);
668        assert_eq!(out, canvas.render(&gradient(8, 4)));
669        let before = out.as_bytes().as_ptr();
670        canvas.render_into(&Frame::black(8, 4), &mut out);
671        assert_eq!(out.as_bytes().as_ptr(), before);
672        assert_eq!(out, Frame::black(8, 4));
673    }
674
675    #[test]
676    fn two_receivers_side_by_side_render_at_their_screen_positions() {
677        let canvas = Canvas {
678            width: 8,
679            height: 2,
680            receivers: vec![
681                Receiver {
682                    index: 0,
683                    x: 0,
684                    y: 0,
685                    width: 4,
686                    height: 2,
687                },
688                Receiver {
689                    index: 1,
690                    x: 4,
691                    y: 0,
692                    width: 4,
693                    height: 2,
694                },
695            ],
696            panels: vec![
697                Panel {
698                    receiver: 0,
699                    receiver_x: 0,
700                    receiver_y: 0,
701                    x: 0,
702                    y: 0,
703                    width: 4,
704                    height: 2,
705                    rotation: Rotation::None,
706                    flip_x: false,
707                    flip_y: false,
708                },
709                Panel {
710                    receiver: 1,
711                    receiver_x: 0,
712                    receiver_y: 0,
713                    x: 4,
714                    y: 0,
715                    width: 4,
716                    height: 2,
717                    rotation: Rotation::None,
718                    flip_x: false,
719                    flip_y: false,
720                },
721            ],
722        };
723        canvas.validate().unwrap();
724        let src = gradient(8, 2);
725        assert_eq!(canvas.render(&src), src);
726
727        // Swap the cards' screen positions: the image swaps halves.
728        let mut swapped = canvas;
729        swapped.receivers[0].x = 4;
730        swapped.receivers[1].x = 0;
731        let out = swapped.render(&src);
732        assert_eq!(out.pixel(0, 0), src.pixel(4, 0));
733        assert_eq!(out.pixel(4, 1), src.pixel(0, 1));
734    }
735
736    #[test]
737    fn validation_rejects_a_panel_that_hangs_off_the_canvas() {
738        let mut canvas = Canvas::single(8, 4);
739        canvas.panels[0].x = 4;
740        let err = canvas.validate().unwrap_err();
741        assert!(err.to_string().starts_with("canvas is not valid:\n  panel 0 at (4, 0)"));
742    }
743
744    #[test]
745    fn validation_rejects_a_receiver_that_hangs_off_the_canvas() {
746        let mut canvas = Canvas::cards(4, 2, 2, 1);
747        canvas.receivers[1].y = 1;
748        let err = canvas.validate().unwrap_err().to_string();
749        assert!(
750            err.contains("receiver 1 at (4, 1) size 4x2 extends past the 8x2 canvas"),
751            "{err}"
752        );
753    }
754
755    #[test]
756    fn validation_rejects_a_panel_that_hangs_off_its_receiver() {
757        let mut canvas = Canvas::cards(4, 2, 2, 1);
758        canvas.panels[1].receiver_x = 1;
759        let err = canvas.validate().unwrap_err().to_string();
760        assert!(
761            err.contains("panel 1 occupies (1, 0) size 4x2 on receiver 1, which is only 4x2"),
762            "{err}"
763        );
764    }
765
766    #[test]
767    fn validation_rejects_an_unknown_receiver() {
768        let mut canvas = Canvas::single(8, 4);
769        canvas.panels[0].receiver = 7;
770        assert!(canvas.validate().is_err());
771    }
772}