Skip to main content

didbot_brand/
render.rs

1//! Building the brand images: what each asset is, and painting it.
2
3use crate::ico;
4use crate::mark::{self, Lattice, Mark, Rect};
5use crate::paint::{self, Cut};
6use std::collections::HashMap;
7
8/// Sites of margin left around the wordmark in the cropped logo.
9pub const CROP_MARGIN: usize = 6;
10
11/// What an asset is cropped to before it is painted.
12#[derive(Clone, Copy, PartialEq, Eq, Debug)]
13pub enum Crop {
14    /// The whole lattice — the landing page's own framing, noise and all.
15    Whole,
16    /// Tight to the mark, plus [`CROP_MARGIN`] sites: the logo to drop
17    /// into a README or a slide, where a screenful of noise is not wanted.
18    Mark,
19}
20
21/// How an asset is encoded.
22#[derive(Clone, Copy)]
23pub enum Format {
24    /// Blocks of pixels, `scale` a site.
25    Png {
26        /// Output pixels a lattice site.
27        scale: usize,
28    },
29    /// One unit a site, scaled by whoever renders it.
30    Svg,
31    /// A `.ico` holding one PNG an entry. Each entry names its own mark,
32    /// because the ladder changes mark between 16 and 48 pixels and a
33    /// favicon container spans exactly that range; the scale is whatever
34    /// makes that size out of that mark's lattice.
35    Ico {
36        /// The container's entries, smallest first: a mark and the pixel
37        /// size to paint it at.
38        entries: &'static [(Mark, usize)],
39    },
40}
41
42/// One file, and everything needed to make it.
43pub struct Asset {
44    /// Where it goes, relative to the destination root (`site/public`).
45    pub path: &'static str,
46    /// What is annealed to make it.
47    pub mark: Mark,
48    /// How much of that mark's lattice it shows.
49    pub crop: Crop,
50    /// Whether it is cut to a disc.
51    pub cut: Cut,
52    /// How it is encoded.
53    pub format: Format,
54}
55
56/// Every brand image the site ships.
57///
58/// **This list is the brand's inventory.** Adding a size, a shape or a
59/// mark is an entry here and nothing else; the builder, the `--check`
60/// gate and the tests all read it.
61pub const ASSETS: &[Asset] = &[
62    // The logo, in the landing page's own framing: the full lattice, so
63    // the wordmark sits in the field it crystallised out of.
64    Asset {
65        path: "brand/wordmark.png",
66        mark: mark::WORDMARK,
67        crop: Crop::Whole,
68        cut: Cut::Square,
69        format: Format::Png { scale: 4 },
70    },
71    Asset {
72        path: "brand/wordmark.svg",
73        mark: mark::WORDMARK,
74        crop: Crop::Whole,
75        cut: Cut::Square,
76        format: Format::Svg,
77    },
78    // The same wordmark, cropped tight, for anywhere it has to sit beside
79    // other people's logos.
80    Asset {
81        path: "brand/wordmark-tight.png",
82        mark: mark::WORDMARK,
83        crop: Crop::Mark,
84        cut: Cut::Square,
85        format: Format::Png { scale: 8 },
86    },
87    Asset {
88        path: "brand/wordmark-tight.svg",
89        mark: mark::WORDMARK,
90        crop: Crop::Mark,
91        cut: Cut::Square,
92        format: Format::Svg,
93    },
94    // 1200x630, the size every link unfurler crops towards.
95    Asset {
96        path: "brand/social-card.png",
97        mark: mark::BANNER,
98        crop: Crop::Whole,
99        cut: Cut::Square,
100        format: Format::Png { scale: 6 },
101    },
102    // The icon, at every size a platform asks for. One mark, magnified:
103    // 32 sites at one pixel a site through sixteen, so the large tiles are
104    // the same picture rather than a finer one that reads busier.
105    Asset {
106        path: "brand/icon-512.png",
107        mark: mark::BADGE,
108        crop: Crop::Whole,
109        cut: Cut::Square,
110        format: Format::Png { scale: 8 },
111    },
112    Asset {
113        path: "brand/icon-256.png",
114        mark: mark::BADGE,
115        crop: Crop::Whole,
116        cut: Cut::Square,
117        format: Format::Png { scale: 4 },
118    },
119    Asset {
120        path: "brand/icon-128.png",
121        mark: mark::BADGE,
122        crop: Crop::Whole,
123        cut: Cut::Square,
124        format: Format::Png { scale: 2 },
125    },
126    Asset {
127        path: "brand/icon-64.png",
128        mark: mark::BADGE,
129        crop: Crop::Whole,
130        cut: Cut::Square,
131        format: Format::Png { scale: 1 },
132    },
133    Asset {
134        path: "brand/icon-48.png",
135        mark: mark::BADGE_48,
136        crop: Crop::Whole,
137        cut: Cut::Square,
138        format: Format::Png { scale: 1 },
139    },
140    // The two smallest tiles: the dot, because nothing with a letter in
141    // it survives at sixteen sites.
142    Asset {
143        path: "brand/icon-32.png",
144        mark: mark::DOT,
145        crop: Crop::Whole,
146        cut: Cut::Square,
147        format: Format::Png { scale: 2 },
148    },
149    Asset {
150        path: "brand/icon-16.png",
151        mark: mark::DOT,
152        crop: Crop::Whole,
153        cut: Cut::Square,
154        format: Format::Png { scale: 1 },
155    },
156    Asset {
157        path: "brand/icon.svg",
158        mark: mark::BADGE,
159        crop: Crop::Whole,
160        cut: Cut::Square,
161        format: Format::Svg,
162    },
163    // The one icon iOS looks for by name, at the size it is happy to
164    // scale down from.
165    Asset {
166        path: "apple-touch-icon.png",
167        mark: mark::BADGE,
168        crop: Crop::Whole,
169        cut: Cut::Square,
170        format: Format::Png { scale: 4 },
171    },
172    // The round cut: the same mark, corners taken away. Nothing moves,
173    // because the square version already fits inside this circle — see
174    // `mark::SAFE_RADIUS`.
175    Asset {
176        path: "brand/icon-round-512.png",
177        mark: mark::BADGE,
178        crop: Crop::Whole,
179        cut: Cut::Round,
180        format: Format::Png { scale: 8 },
181    },
182    Asset {
183        path: "brand/icon-round-256.png",
184        mark: mark::BADGE,
185        crop: Crop::Whole,
186        cut: Cut::Round,
187        format: Format::Png { scale: 4 },
188    },
189    Asset {
190        path: "brand/icon-round-128.png",
191        mark: mark::BADGE,
192        crop: Crop::Whole,
193        cut: Cut::Round,
194        format: Format::Png { scale: 2 },
195    },
196    Asset {
197        path: "brand/icon-round.svg",
198        mark: mark::BADGE,
199        crop: Crop::Whole,
200        cut: Cut::Round,
201        format: Format::Svg,
202    },
203    // The tab icon. The badge rather than the dot, because a browser that
204    // reads this file scales it to whatever the tab is and a vector of the
205    // 48-site tile survives that better than any single raster — and
206    // because the badge is the icon. The .ico below still carries the dot
207    // for the 16 and 32 pixel entries, where blocks this small are what
208    // stays legible.
209    Asset {
210        path: "favicon.svg",
211        mark: mark::BADGE_48,
212        crop: Crop::Whole,
213        cut: Cut::Square,
214        format: Format::Svg,
215    },
216    // The container a browser picks a size out of: the ladder's own three
217    // small tiles, each from the mark that size is actually drawn in.
218    Asset {
219        path: "favicon.ico",
220        mark: mark::DOT,
221        crop: Crop::Whole,
222        cut: Cut::Square,
223        format: Format::Ico {
224            entries: &[(mark::DOT, 16), (mark::DOT, 32), (mark::BADGE_48, 48)],
225        },
226    },
227];
228
229/// A built file: where it goes, and what goes in it.
230pub struct Built {
231    /// Where it goes, relative to the destination root.
232    pub path: &'static str,
233    /// The file's contents.
234    pub bytes: Vec<u8>,
235}
236
237/// Builds every asset in [`ASSETS`].
238///
239/// Each distinct mark is annealed once however many assets it feeds,
240/// which is most of the runtime: the wordmark's lattice alone is 35,200
241/// sites through 1,200 sweeps.
242pub fn build_all() -> Vec<Built> {
243    let mut lattices: Lattices = HashMap::new();
244    ASSETS
245        .iter()
246        .map(|asset| Built {
247            path: asset.path,
248            bytes: build_one(asset, &mut lattices),
249        })
250        .collect()
251}
252
253/// Annealed lattices, kept by mark name so a mark that feeds several
254/// assets is annealed once. That is most of the runtime: the wordmark's
255/// lattice alone is 35,200 sites through 1,200 sweeps.
256pub type Lattices = HashMap<&'static str, Lattice>;
257
258/// Builds one asset, annealing whatever marks it needs that `lattices`
259/// does not already hold.
260pub fn build_one(asset: &Asset, lattices: &mut Lattices) -> Vec<u8> {
261    let lattice = crystallise(&asset.mark, lattices);
262    let rect = match asset.crop {
263        Crop::Whole => Rect {
264            x: 0,
265            y: 0,
266            width: lattice.width,
267            height: lattice.height,
268        },
269        Crop::Mark => Lattice::mask_bounds(
270            &asset.mark.mask(),
271            lattice.width,
272            lattice.height,
273            CROP_MARGIN,
274        ),
275    };
276    match asset.format {
277        Format::Png { scale } => paint::png(lattice, rect, scale, asset.cut),
278        Format::Svg => paint::svg(lattice, rect, asset.cut).into_bytes(),
279        Format::Ico { entries } => {
280            let images = entries
281                .iter()
282                .map(|(mark, size)| {
283                    let lattice = crystallise(mark, lattices);
284                    let rect = Rect {
285                        x: 0,
286                        y: 0,
287                        width: lattice.width,
288                        height: lattice.height,
289                    };
290                    assert_eq!(
291                        size % rect.width,
292                        0,
293                        "{size} is not a whole number of {}'s lattices",
294                        mark.name
295                    );
296                    (
297                        *size as u32,
298                        paint::png(lattice, rect, size / rect.width, asset.cut),
299                    )
300                })
301                .collect::<Vec<_>>();
302            ico::ico(&images)
303        }
304    }
305}
306
307/// `mark`'s finished lattice, annealing it if this is the first ask.
308fn crystallise<'a>(mark: &Mark, lattices: &'a mut Lattices) -> &'a Lattice {
309    lattices
310        .entry(mark.name)
311        .or_insert_with(|| mark.crystallise())
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317
318    #[test]
319    fn no_two_assets_claim_the_same_path() {
320        let mut paths: Vec<&str> = ASSETS.iter().map(|asset| asset.path).collect();
321        paths.sort_unstable();
322        let count = paths.len();
323        paths.dedup();
324        assert_eq!(paths.len(), count, "two assets write the same file");
325    }
326
327    /// A PNG asset paints whole sites, so its pixel size is a whole
328    /// multiple of its lattice — this is what keeps an icon from ever
329    /// being a resampled picture of a lattice rather than the lattice.
330    #[test]
331    fn every_raster_size_is_a_whole_number_of_sites() {
332        for asset in ASSETS {
333            if let Format::Ico { entries } = asset.format {
334                for (mark, size) in entries {
335                    assert_eq!(
336                        size % mark.lattice.0,
337                        0,
338                        "{}: {size} is not a multiple of {}'s lattice",
339                        asset.path,
340                        mark.name
341                    );
342                }
343            }
344        }
345    }
346
347    /// The rule the whole icon layout is built around: an uploader that
348    /// shows a square icon in a round frame crops to the tile's inscribed
349    /// circle, so anything outside it is a mark some places show and
350    /// others silently cut. Every icon mark — its badge *and* its specks —
351    /// has to be inside that circle, which is also what lets one mark
352    /// serve both cuts.
353    #[test]
354    fn every_icon_keeps_its_ink_inside_the_safe_circle() {
355        for mark in mark::ALL
356            .iter()
357            .filter(|mark| mark.specks != mark::Specks::None)
358        {
359            let (width, height) = mark.lattice;
360            let safe = width.min(height) as f32 * mark::SAFE_RADIUS;
361            let centre = (width as f32 / 2.0, height as f32 / 2.0);
362            for (index, lit) in mark.mask().iter().enumerate() {
363                if !lit {
364                    continue;
365                }
366                // The far corner of the site, not its centre: a site is a
367                // painted block, and the crop cuts pixels rather than
368                // sites.
369                let (x, y) = ((index % width) as f32, (index / width) as f32);
370                let dx = (x - centre.0).abs().max((x + 1.0 - centre.0).abs());
371                let dy = (y - centre.1).abs().max((y + 1.0 - centre.1).abs());
372                assert!(
373                    (dx * dx + dy * dy).sqrt() <= safe,
374                    "{}: a lit site at ({x}, {y}) is outside the safe circle",
375                    mark.name
376                );
377            }
378        }
379    }
380
381    /// The other half of what `--check` rests on. The anneal's own
382    /// determinism is `mark`'s to test; this is that everything after it
383    /// — the crop, the paint, the encoders — is a pure function of the
384    /// lattice too, checked over every asset in the manifest from one
385    /// cheap lattice rather than by annealing the real ones twice.
386    #[test]
387    fn encoding_an_asset_twice_writes_the_same_bytes() {
388        // One shared cache across both passes, so the anneal happens once
389        // per mark and what is compared is everything after it: the crop,
390        // the paint and the encoders.
391        let mut lattices = Lattices::new();
392        for asset in ASSETS {
393            let once = build_one(asset, &mut lattices);
394            let again = build_one(asset, &mut lattices);
395            assert_eq!(once, again, "{} is not deterministic", asset.path);
396            assert!(!once.is_empty(), "{} came out empty", asset.path);
397        }
398    }
399}