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}