Skip to main content

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}