Skip to main content

shuyi_cli/contract/bake/
sheet.rs

1//! The contact sheet of a one-bit bake (`bake --sheet <file>`): every
2//! picture the bake dithered, a row each, its source on the left and its
3//! one-bit picture from the pack on the right, so an artist sees at a
4//! glance which ones the dither spoiled and are worth drawing by hand in
5//! the target's layer. Each pixel is [`SCALE`] × [`SCALE`], so the dots
6//! show, on a grey ground that see-through pixels show too.
7
8use shuyi_assets::bitmap::Bitmap;
9use shuyi_assets::bitmap::convert::png_pixels;
10use shuyi_assets::bundle::Bundle;
11
12use crate::codec::encode_png;
13
14/// How many sheet pixels a picture's pixel is, each way.
15pub const SCALE: u32 = 2;
16
17/// The ground between and around the pictures, in sheet pixels.
18pub const GAP: u32 = 8;
19
20/// The ground's colour: a middle grey, neither the one-bit black nor its
21/// white.
22pub const GROUND: [u8; 4] = [128, 128, 128, 255];
23
24/// One row of the sheet: the source's pixels and the pack's one-bit
25/// picture.
26struct Row<'a> {
27    width: u32,
28    height: u32,
29    rgba: Vec<u8>,
30    bitmap: Bitmap<'a>,
31}
32
33impl Row<'_> {
34    /// The row's height in sheet pixels, the taller picture's.
35    fn height(&self) -> u32 {
36        self.height.max(u32::from(self.bitmap.height())) * SCALE
37    }
38
39    /// Its width in sheet pixels, both pictures and the gap between.
40    fn width(&self) -> u32 {
41        (self.width + u32::from(self.bitmap.width())) * SCALE + GAP
42    }
43}
44
45/// The contact sheet, a PNG file, of the pictures at `paths`: each read
46/// from `source` (a PNG) and from `pack` (its one-bit picture). An error
47/// names a picture that is no PNG in the sources or no one-bit picture
48/// in the pack.
49pub fn draw(source: &Bundle, pack: &Bundle, paths: &[String]) -> Result<Vec<u8>, String> {
50    let mut rows = Vec::with_capacity(paths.len());
51    for path in paths {
52        let colour = source.bytes(path).ok_or_else(|| format!("no {path}"))?;
53        let (width, height, rgba) =
54            png_pixels(colour).map_err(|e| format!("{}: {e}", source.display_path(path)))?;
55        let one_bit = pack.bytes(path).unwrap_or_default();
56        let bitmap = Bitmap::parse(one_bit).map_err(|e| format!("{path} in the pack: {e}"))?;
57        rows.push(Row {
58            width,
59            height,
60            rgba,
61            bitmap,
62        });
63    }
64    let width = rows.iter().map(Row::width).max().unwrap_or(0) + 2 * GAP;
65    let height = rows.iter().map(|row| row.height() + GAP).sum::<u32>() + GAP;
66    let mut sheet = Sheet {
67        width,
68        pixels: GROUND.repeat(usize::try_from(width * height).unwrap_or(0)),
69    };
70    let mut top = GAP;
71    for row in &rows {
72        for y in 0..row.height {
73            for x in 0..row.width {
74                let at = usize::try_from((y * row.width + x) * 4).unwrap_or(0);
75                let mut pixel = [0; 4];
76                pixel.copy_from_slice(&row.rgba[at..at + 4]);
77                sheet.dot(GAP + x * SCALE, top + y * SCALE, over_ground(pixel));
78            }
79        }
80        let left = GAP + row.width * SCALE + GAP;
81        for y in 0..row.bitmap.height() {
82            for x in 0..row.bitmap.width() {
83                let dot = match row.bitmap.pixel(0, x, y) {
84                    Some(true) => [255, 255, 255, 255],
85                    Some(false) => [0, 0, 0, 255],
86                    None => GROUND,
87                };
88                let (x, y) = (u32::from(x), u32::from(y));
89                sheet.dot(left + x * SCALE, top + y * SCALE, dot);
90            }
91        }
92        top += row.height() + GAP;
93    }
94    encode_png(width, height, &sheet.pixels)
95}
96
97/// The sheet being drawn: RGBA pixels, row by row.
98struct Sheet {
99    width: u32,
100    pixels: Vec<u8>,
101}
102
103impl Sheet {
104    /// Paints the [`SCALE`] × [`SCALE`] dot whose top-left is `(x, y)`.
105    fn dot(&mut self, x: u32, y: u32, rgba: [u8; 4]) {
106        for dy in 0..SCALE {
107            for dx in 0..SCALE {
108                let at = usize::try_from(((y + dy) * self.width + x + dx) * 4).unwrap_or(0);
109                if let Some(pixel) = self.pixels.get_mut(at..at + 4) {
110                    pixel.copy_from_slice(&rgba);
111                }
112            }
113        }
114    }
115}
116
117/// `pixel` laid over the [`GROUND`] by its alpha, opaque.
118fn over_ground(pixel: [u8; 4]) -> [u8; 4] {
119    let alpha = u32::from(pixel[3]);
120    let mix = |channel: usize| {
121        let (colour, ground) = (u32::from(pixel[channel]), u32::from(GROUND[channel]));
122        u8::try_from((colour * alpha + ground * (255 - alpha) + 127) / 255).unwrap_or(u8::MAX)
123    };
124    [mix(0), mix(1), mix(2), 255]
125}
126
127#[cfg(test)]
128mod tests {
129    use shuyi_assets::bitmap::encode;
130    use shuyi_assets::bundle::Layer;
131
132    use super::*;
133
134    /// A bundle of the file `bytes` at `a.png`.
135    fn bundle(name: &'static str, bytes: Vec<u8>) -> Bundle {
136        let file: &'static [u8] = bytes.leak();
137        let files: &'static [(&str, &[u8])] = vec![("a.png", file)].leak();
138        Bundle::new(vec![Layer::table(name, files)])
139    }
140
141    #[test]
142    fn each_picture_is_drawn_beside_its_source_twice_its_size() {
143        // A 2 × 1 source: red, then half see-through white.
144        let png = encode_png(2, 1, &[255, 0, 0, 255, 255, 255, 255, 128]).unwrap();
145        // Its one-bit picture: black, then see-through.
146        let one_bit = encode((2, 1), (2, 1), &[&[0]], Some(&[0x80])).unwrap();
147        let source = bundle("assets", png);
148        let pack = bundle("pack", one_bit);
149        let sheet = draw(&source, &pack, &["a.png".to_owned()]).unwrap();
150        let (width, height, rgba) = png_pixels(&sheet).unwrap();
151        // Ground, source (4), ground, picture (4), ground.
152        assert_eq!((width, height), (GAP + 4 + GAP + 4 + GAP, GAP + 2 + GAP));
153        let at = |x: u32, y: u32| {
154            let i = usize::try_from((y * width + x) * 4).unwrap();
155            [rgba[i], rgba[i + 1], rgba[i + 2], rgba[i + 3]]
156        };
157        assert_eq!(at(0, 0), GROUND);
158        assert_eq!(at(GAP, GAP), [255, 0, 0, 255]);
159        assert_eq!(at(GAP + 1, GAP + 1), [255, 0, 0, 255]);
160        assert_eq!(at(GAP + 2, GAP), [192, 192, 192, 255]);
161        let left = GAP + 4 + GAP;
162        assert_eq!(at(left, GAP), [0, 0, 0, 255]);
163        assert_eq!(at(left + 3, GAP + 1), GROUND);
164        // A picture missing from the pack is named.
165        let png = bundle("assets", b"not a png".to_vec());
166        let error = draw(&png, &pack, &["a.png".to_owned()]).unwrap_err();
167        assert!(error.starts_with("assets/a.png: "), "{error}");
168        let error = draw(&source, &source, &["a.png".to_owned()]).unwrap_err();
169        assert!(error.starts_with("a.png in the pack: "), "{error}");
170        // No picture: the ground alone.
171        let empty = png_pixels(&draw(&source, &pack, &[]).unwrap()).unwrap();
172        assert_eq!((empty.0, empty.1), (2 * GAP, GAP));
173    }
174}