didbot_brand/mark.rs
1//! What gets crystallised, and the snap of the simulation that does it.
2//!
3//! A mark is a shape for the external field to pull the lattice towards —
4//! the "did.bot" wordmark for the logo, a "d" cut out of a disc for the
5//! icons, a plain disc for the tiles too small to hold a letter — plus
6//! the lattice size and RNG seed that together make one particular run of
7//! the anneal. Given those, the finished lattice is a pure function: the
8//! same mark always crystallises into the same spins.
9
10use didbot_site_anim::anneal::{run_full_anneal_pinned, AnnealSchedule, DEFAULT_TOTAL_SWEEPS};
11use didbot_site_anim::ising::{didbot_mask, text_mask};
12
13/// What shape the field pulls the lattice towards.
14///
15/// A size ladder rather than a set of alternatives: the wordmark is the
16/// logo, the knockout is the icon, and the disc is what is left when a
17/// tile is sixteen pixels across. [`Shape::Text`] sets the font for
18/// whatever a knockout cuts.
19#[derive(Clone, Copy)]
20pub enum Shape {
21 /// The "did.bot" wordmark, laid out the way the landing page lays it
22 /// out — hung off its own "." rather than centred — rather than as
23 /// text this crate composed.
24 Wordmark,
25 /// Lines of the same six-glyph font, centred as a lockup. No mark
26 /// ships as one today; it is what [`Shape::Knockout`] cuts with, and
27 /// what a second line would be set in if the icons ever wanted one.
28 Text(&'static [&'static str]),
29 /// A filled disc. Letterforms do not survive this anneal below about
30 /// three lattice sites to the glyph pixel — surface tension closes a
31 /// two-site counter, and a "d" comes out a blob — so the smallest
32 /// tile gets the one shape a lattice this coarse can still hold, and
33 /// the one the dynamics actively help: a single ordered domain, which
34 /// is also the "." in did.bot.
35 Disc,
36 /// A disc with `lines` cut out of it: the letterform is the ground
37 /// showing through, not the ink.
38 ///
39 /// This is what every icon above the dot is. A letter
40 /// set *positively* at 32 sites is a stroke of ink two or three sites
41 /// wide with noise on both flanks; cut out of a disc instead, both
42 /// its flanks are one large ordered domain, which is far more stable
43 /// — the shape is carried by an edge rather than by a thin ribbon.
44 /// The counter still has to be a hole several sites across, which is
45 /// why `glyph` is well under [`Mark::fill`] rather than filling it.
46 Knockout {
47 /// The lines to cut, in this crate's own six-glyph font.
48 lines: &'static [&'static str],
49 /// How much of the tile the letterform spans, as a fraction.
50 glyph: f32,
51 },
52}
53
54/// Whether a mark carries the signature specks.
55///
56/// Three lit sites the field does not decide: two up by the top corners,
57/// one below. They are not decoration added to a finished picture — each
58/// is a *pinned* site ([`didbot_site_anim::ising::IsingModel::pin_spin`])
59/// with a small island of field around it, so the lattice grows whatever
60/// shape it likes on them. At 16 sites that is a single pixel, the way
61/// they first came out of the anneal; on a bigger tile the island is
62/// bigger and the anneal makes it irregular, which is the point — three
63/// identical squares would read as a stamp.
64#[derive(Clone, Copy, PartialEq, Eq, Debug)]
65pub enum Specks {
66 /// None: the wordmark and the social card are all field already.
67 None,
68 /// The three, in the ring between the mark and the safe circle.
69 Three,
70}
71
72/// The three specks' directions from the centre, taken from where the
73/// first anneal happened to leave them on the 16-site tile, as unit-ish
74/// vectors in screen coordinates (y down).
75const SPECK_DIRECTIONS: [(f32, f32); 3] = [(-0.81, -0.59), (0.76, -0.65), (0.07, 1.0)];
76/// How far out the specks sit, as a fraction of the tile's width.
77///
78/// The ring they live in is narrow and both its edges are hard: outside
79/// [`SAFE_RADIUS`] an uploader's round crop cuts them off, and inside the
80/// badge's own edge they stop being separate dots and merge with it. This
81/// is the middle of what is left, which is also why the badge is not
82/// larger than it is.
83const SPECK_ORBIT: f32 = 0.395;
84/// A speck's field island, as a fraction of the tile.
85const SPECK_RADIUS: f32 = 0.04;
86
87/// Everything an icon draws stays inside this radius, as a fraction of
88/// the tile's width — the tile's own inscribed circle, less a hair.
89///
90/// Not a stylistic margin: uploaders that show a square icon in a round
91/// frame crop to exactly this circle, so anything outside it is a mark
92/// that some places show and others silently cut. One layout satisfies
93/// both frames, which is why a round icon here is the same mark as the
94/// square one with a different [`crate::paint::Cut`] rather than a mark of
95/// its own.
96pub const SAFE_RADIUS: f32 = 0.49;
97
98/// One mark: a shape, the lattice it is annealed on, and the seed.
99#[derive(Clone, Copy)]
100pub struct Mark {
101 /// Appears in the manifest and in test failures; kebab-case.
102 pub name: &'static str,
103 /// What the field aims at.
104 pub shape: Shape,
105 /// The three pinned specks, or [`Specks::None`].
106 pub specks: Specks,
107 /// How much of the lattice the mark fills, as a fraction of the
108 /// binding axis — its diameter, for a [`Shape::Disc`]. Lower for
109 /// round marks, which have to clear a circle.
110 pub fill: f32,
111 /// Lattice size in sites. This is the grain of the noise: an icon
112 /// painted a block a site reads as the same material as the landing
113 /// page, whatever the tile it ends up in, because the blocks are the
114 /// page's own — bigger on screen, never resampled.
115 pub lattice: (usize, usize),
116 /// The RNG seed: which run of the anneal this is. Always one of the
117 /// twelve `anneal.rs`'s crystallisation property test measures, so
118 /// the run behind a shipped image is one the test suite has actually
119 /// seen — and, for everything but the wordmark, the one of those
120 /// twelve that came out cleanest, since a seed is a free choice and
121 /// there is no reason to ship a worse frame than the schedule can
122 /// give. [`WORDMARK`] keeps the landing page's own seed instead
123 /// (`site/src/config/anim.ts`), because a logo that is not the frame
124 /// the page ends on would be a different picture wearing the same
125 /// name; [`BANNER`] cannot be that frame anyway — its lattice is a
126 /// different shape — so it is free to take the cleanest run too.
127 pub seed: u32,
128}
129
130/// The landing page's own lattice: 220x160, seed 11 — the numbers in
131/// `site/src/config/anim.ts`, which is why this image and the page agree.
132pub const WORDMARK: Mark = Mark {
133 name: "wordmark",
134 shape: Shape::Wordmark,
135 specks: Specks::None,
136 fill: 0.0, // unused: the wordmark's own layout decides
137 lattice: (220, 160),
138 seed: 11,
139};
140
141/// The wordmark again on a wider, shorter lattice, for the social card:
142/// 200x105 is what 1200x630 divides into at six pixels a site.
143pub const BANNER: Mark = Mark {
144 name: "banner",
145 shape: Shape::Wordmark,
146 specks: Specks::None,
147 fill: 0.0,
148 lattice: (200, 105),
149 seed: 909,
150};
151
152/// The icon: a "d" cut out of a disc, with the three specks in the ring
153/// around it — one mark for every tile from 64 pixels up, painted square
154/// or round from the same lattice, since the square version already fits
155/// inside the circle a round frame crops to (see [`SAFE_RADIUS`]).
156///
157/// Sixty-four sites, and the big tiles are this magnified rather than a
158/// finer lattice of their own. A finer lattice is not the same icon: the
159/// stray lit sites the anneal leaves scale with the site count, so at 128
160/// or 256 sites the background fills with dust and the thing reads busy
161/// instead of as one shape.
162pub const BADGE: Mark = Mark {
163 name: "badge",
164 shape: Shape::Knockout {
165 lines: &["d"],
166 glyph: 0.50,
167 },
168 specks: Specks::Three,
169 fill: 0.68,
170 lattice: (64, 64),
171 seed: 202,
172};
173
174/// [`BADGE`] on the 48-site tile: the same proportions, for the one size
175/// that is not a whole multiple of 64.
176pub const BADGE_48: Mark = Mark {
177 name: "badge-48",
178 shape: Shape::Knockout {
179 lines: &["d"],
180 glyph: 0.50,
181 },
182 specks: Specks::Three,
183 fill: 0.68,
184 lattice: (48, 48),
185 seed: 2,
186};
187
188/// The bottom of the ladder: the dot, on a lattice of its own, painted at
189/// one and two pixels a site for the 16 and 32 pixel tiles.
190///
191/// Sixteen sites hold no letterform at all — not two lines of three
192/// glyphs, not one glyph, and not a knockout either, since a hole needs
193/// sites too and a "d" cut small enough to leave the specks their ring
194/// has a two-site stroke. See [`Shape::Disc`].
195pub const DOT: Mark = Mark {
196 name: "dot",
197 shape: Shape::Disc,
198 specks: Specks::Three,
199 fill: 0.66,
200 lattice: (16, 16),
201 seed: 2,
202};
203
204/// Every mark, for the tests that hold all of them to the same bar.
205pub const ALL: &[Mark] = &[WORDMARK, BANNER, BADGE, BADGE_48, DOT];
206
207impl Mark {
208 /// The shape the field pulls towards, as a bitmap on this mark's
209 /// lattice, specks included.
210 pub fn mask(&self) -> Vec<bool> {
211 let (width, height) = self.lattice;
212 let mut mask = match self.shape {
213 Shape::Wordmark => didbot_mask(width, height),
214 Shape::Text(lines) => text_mask(lines, width, height, self.fill),
215 Shape::Disc => self.disc(self.fill),
216 Shape::Knockout { lines, glyph } => {
217 let letter = text_mask(lines, width, height, glyph);
218 self.disc(self.fill)
219 .iter()
220 .zip(&letter)
221 .map(|(lit, cut)| *lit && !*cut)
222 .collect()
223 }
224 };
225 let radius = width.min(height) as f32 * SPECK_RADIUS;
226 for (x, y) in self.speck_sites() {
227 paint_disc(
228 &mut mask,
229 (width, height),
230 (x as f32 + 0.5, y as f32 + 0.5),
231 radius,
232 );
233 }
234 mask
235 }
236
237 /// A disc of `fill` of the tile, centred — the ground a [`Shape::Disc`]
238 /// is and a [`Shape::Knockout`] cuts into.
239 fn disc(&self, fill: f32) -> Vec<bool> {
240 let (width, height) = self.lattice;
241 let mut mask = vec![false; width * height];
242 paint_disc(
243 &mut mask,
244 (width, height),
245 (width as f32 / 2.0, height as f32 / 2.0),
246 width.min(height) as f32 * fill / 2.0,
247 );
248 mask
249 }
250
251 /// The sites this mark pins up-spin: its specks' centres, and nothing
252 /// else. Empty for [`Specks::None`].
253 pub fn speck_sites(&self) -> Vec<(usize, usize)> {
254 if self.specks == Specks::None {
255 return Vec::new();
256 }
257 let (width, height) = self.lattice;
258 let span = width.min(height) as f32;
259 let centre = (width as f32 / 2.0, height as f32 / 2.0);
260 SPECK_DIRECTIONS
261 .iter()
262 .map(|(dx, dy)| {
263 let x = centre.0 + dx * span * SPECK_ORBIT;
264 let y = centre.1 + dy * span * SPECK_ORBIT;
265 (
266 (x.max(0.0) as usize).min(width - 1),
267 (y.max(0.0) as usize).min(height - 1),
268 )
269 })
270 .collect()
271 }
272
273 /// Runs the shipped anneal to its end and returns the lattice it
274 /// froze into: `true` where the spin is up (lit).
275 ///
276 /// This is the snap. It is the same schedule
277 /// ([`AnnealSchedule::default`]), the same sweep count
278 /// ([`DEFAULT_TOTAL_SWEEPS`]) and the same dynamics the landing page
279 /// scrolls through, stopped at the end of the schedule — so a brand
280 /// image is the last frame of the page's own animation rather than a
281 /// drawing of one.
282 pub fn crystallise(&self) -> Lattice {
283 let (width, height) = self.lattice;
284 let spins = run_full_anneal_pinned(
285 width,
286 height,
287 self.seed,
288 DEFAULT_TOTAL_SWEEPS,
289 &AnnealSchedule::default(),
290 &self.mask(),
291 &self.speck_sites(),
292 );
293 Lattice {
294 width,
295 height,
296 lit: spins.iter().map(|&spin| spin > 0).collect(),
297 }
298 }
299
300 /// The fraction of sites that came out agreeing with the mask — the
301 /// same measure `anneal.rs`'s crystallisation property test applies to
302 /// the landing page, applied here to a mark that is not the wordmark
303 /// and a lattice that is not 220x160.
304 pub fn agreement(&self) -> f32 {
305 let mask = self.mask();
306 let lattice = self.crystallise();
307 let agree = lattice
308 .lit
309 .iter()
310 .zip(&mask)
311 .filter(|(lit, wanted)| lit == wanted)
312 .count();
313 agree as f32 / mask.len() as f32
314 }
315}
316
317/// Lights every site of `mask` within `radius` of `centre`.
318fn paint_disc(mask: &mut [bool], size: (usize, usize), centre: (f32, f32), radius: f32) {
319 let (width, height) = size;
320 for y in 0..height {
321 for x in 0..width {
322 let (dx, dy) = (x as f32 + 0.5 - centre.0, y as f32 + 0.5 - centre.1);
323 if (dx * dx + dy * dy).sqrt() <= radius {
324 mask[y * width + x] = true;
325 }
326 }
327 }
328}
329
330/// A finished lattice: one bit a site, row major.
331pub struct Lattice {
332 /// Sites across.
333 pub width: usize,
334 /// Sites down.
335 pub height: usize,
336 /// `true` where the spin is up — the lit colour.
337 pub lit: Vec<bool>,
338}
339
340impl Lattice {
341 /// Whether the site at `(x, y)` is lit; `false` outside the lattice.
342 pub fn lit_at(&self, x: usize, y: usize) -> bool {
343 x < self.width && y < self.height && self.lit[y * self.width + x]
344 }
345
346 /// The bounding box of the lit sites inside `mask`'s shape, grown by
347 /// `margin` sites and clamped to the lattice: `(x, y, width, height)`.
348 /// What a cropped logo is cropped to.
349 pub fn mask_bounds(mask: &[bool], width: usize, height: usize, margin: usize) -> Rect {
350 let (mut min_x, mut min_y, mut max_x, mut max_y) = (width, height, 0usize, 0usize);
351 for y in 0..height {
352 for x in 0..width {
353 if mask[y * width + x] {
354 min_x = min_x.min(x);
355 min_y = min_y.min(y);
356 max_x = max_x.max(x);
357 max_y = max_y.max(y);
358 }
359 }
360 }
361 if min_x > max_x {
362 return Rect {
363 x: 0,
364 y: 0,
365 width,
366 height,
367 };
368 }
369 let x = min_x.saturating_sub(margin);
370 let y = min_y.saturating_sub(margin);
371 Rect {
372 x,
373 y,
374 width: (max_x + margin + 1).min(width) - x,
375 height: (max_y + margin + 1).min(height) - y,
376 }
377 }
378}
379
380/// A rectangle of lattice sites.
381#[derive(Clone, Copy, Debug, PartialEq, Eq)]
382pub struct Rect {
383 /// Leftmost site, in lattice coordinates.
384 pub x: usize,
385 /// Topmost site, in lattice coordinates.
386 pub y: usize,
387 /// Sites across.
388 pub width: usize,
389 /// Sites down.
390 pub height: usize,
391}
392
393#[cfg(test)]
394mod tests {
395 use super::*;
396
397 /// Every mark has to actually come out looking like itself. The floor
398 /// is the landing page's own (`anneal.rs`'s `AGREEMENT_FLOOR`, 93%),
399 /// applied to lattices and shapes that test never saw: a schedule
400 /// tuned for one 220x160 wordmark is not owed good behaviour on a
401 /// 16x16 tile, so this is the test that says whether a mark is
402 /// shippable at all.
403 #[test]
404 fn every_mark_crystallises_into_its_own_shape() {
405 let mut failures = Vec::new();
406 for mark in ALL {
407 let agreement = mark.agreement();
408 if agreement < 0.93 {
409 failures.push((mark.name, agreement));
410 }
411 }
412 assert!(
413 failures.is_empty(),
414 "marks that did not crystallise: {failures:?}"
415 );
416 }
417
418 /// The specks' whole reason for being pinned. A lit site this small
419 /// is not something the field can hold — the island around it is a
420 /// site or two across at the tile sizes that carry specks — so
421 /// without the pin the anneal erases them, and the signature is
422 /// present in the mask and absent from the picture.
423 #[test]
424 fn every_speck_survives_the_anneal() {
425 for mark in ALL.iter().filter(|mark| mark.specks != Specks::None) {
426 let lattice = mark.crystallise();
427 for (x, y) in mark.speck_sites() {
428 assert!(
429 lattice.lit_at(x, y),
430 "{}: the speck at ({x}, {y}) did not survive",
431 mark.name
432 );
433 }
434 }
435 }
436
437 /// A speck has to be inside the tile it decorates, and the three of
438 /// them have to be three: a position that rounds onto another one, or
439 /// off the lattice, is a silently missing part of the mark.
440 #[test]
441 fn the_specks_are_three_distinct_sites_inside_the_tile() {
442 for mark in ALL.iter().filter(|mark| mark.specks != Specks::None) {
443 let mut sites = mark.speck_sites();
444 assert_eq!(sites.len(), 3, "{}", mark.name);
445 for &(x, y) in &sites {
446 assert!(x < mark.lattice.0 && y < mark.lattice.1, "{}", mark.name);
447 }
448 sites.sort_unstable();
449 sites.dedup();
450 assert_eq!(
451 sites.len(),
452 3,
453 "{}: two specks landed on one site",
454 mark.name
455 );
456 }
457 }
458
459 /// The property the whole crate rests on: a mark is a pure function,
460 /// which is what lets the images be committed and checked. Run on the
461 /// smallest lattice, because the property is the anneal's and not this
462 /// mark's — the expensive ones are annealed once each above.
463 #[test]
464 fn the_same_mark_crystallises_into_the_same_lattice_every_time() {
465 let once = DOT.crystallise();
466 let again = DOT.crystallise();
467 assert_eq!(once.lit, again.lit);
468 }
469}