diff --git a/Cargo.lock b/Cargo.lock index f9cf3fbb..bf32c497 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -986,6 +986,14 @@ dependencies = [ "sha2", ] +[[package]] +name = "didbot-brand" +version = "0.1.0" +dependencies = [ + "didbot-avatar", + "didbot-site-anim", +] + [[package]] name = "didbot-claim" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 18e35097..369c4655 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,8 @@ publish = false didbot-config = { version = "0.1.0", path = "crates/didbot-config" } didbot-attest = { version = "0.1.0", path = "crates/didbot-attest" } didbot-avatar = { version = "0.1.0", path = "crates/didbot-avatar" } +didbot-brand = { version = "0.1.0", path = "crates/didbot-brand" } +didbot-site-anim = { version = "0.1.0", path = "crates/didbot-site-anim" } didbot-dns = { version = "0.1.0", path = "crates/didbot-dns" } didbot-fsm = { version = "0.1.0", path = "crates/didbot-fsm" } didbot-name = { version = "0.1.0", path = "crates/didbot-name" } diff --git a/crates/didbot-avatar/src/png.rs b/crates/didbot-avatar/src/png.rs index 1338fdfc..de5e969e 100644 --- a/crates/didbot-avatar/src/png.rs +++ b/crates/didbot-avatar/src/png.rs @@ -306,7 +306,27 @@ fn filter_row(row: &[u8], above: &[u8], stride: usize, out: &mut Vec) { /// If `pixels` is not exactly that long, which is a caller bug rather than a /// runtime condition. pub fn encode_rgb(width: u32, height: u32, pixels: &[u8]) -> Vec { - let stride = width as usize * 3; + encode(width, height, pixels, 3) +} + +/// Encodes eight-bit RGBA pixels as a PNG. +/// +/// `pixels` is `width * height * 4` bytes, row major, no padding. An +/// avatar is opaque and never needs this; a round icon cut out of a square +/// lattice is exactly the caller that does (`didbot-brand`). +/// +/// # Panics +/// +/// If `pixels` is not exactly that long, which is a caller bug rather than a +/// runtime condition. +pub fn encode_rgba(width: u32, height: u32, pixels: &[u8]) -> Vec { + encode(width, height, pixels, 4) +} + +/// The encoder both entry points are: everything about the two differs in +/// the bytes per pixel and the one colour-type byte in the header. +fn encode(width: u32, height: u32, pixels: &[u8], channels: usize) -> Vec { + let stride = width as usize * channels; assert_eq!( pixels.len(), stride * height as usize, @@ -317,7 +337,7 @@ pub fn encode_rgb(width: u32, height: u32, pixels: &[u8]) -> Vec { let mut above = vec![0u8; stride]; for y in 0..height as usize { let row = &pixels[y * stride..(y + 1) * stride]; - filter_row(row, &above, 3, &mut raw); + filter_row(row, &above, channels, &mut raw); above.copy_from_slice(row); } @@ -326,9 +346,11 @@ pub fn encode_rgb(width: u32, height: u32, pixels: &[u8]) -> Vec { let mut header = Vec::with_capacity(13); header.extend_from_slice(&width.to_be_bytes()); header.extend_from_slice(&height.to_be_bytes()); - // Eight bits a channel, truecolour, deflate, adaptive filtering, no - // interlace. The only combination this encoder emits. - header.extend_from_slice(&[8, 2, 0, 0, 0]); + // Eight bits a channel, truecolour with or without alpha, deflate, + // adaptive filtering, no interlace. The only combinations this encoder + // emits. + let colour_type = if channels == 4 { 6 } else { 2 }; + header.extend_from_slice(&[8, colour_type, 0, 0, 0]); chunk(&mut out, b"IHDR", &header); chunk(&mut out, b"IDAT", &zlib(&raw)); chunk(&mut out, b"IEND", &[]); @@ -505,17 +527,8 @@ mod tests { } } - #[test] - fn the_pixels_survive_the_encoding() { - // Encode, then undo the zlib wrapper and the row filters, and compare - // with what went in. This is the whole contract of the module. - let width = 17; - let height = 9; - let pixels: Vec = (0..width * height * 3) - .map(|i| (i * 13 % 251) as u8) - .collect(); - let png = encode_rgb(width as u32, height as u32, &pixels); - + /// Undoes the zlib wrapper and the row filters of an encoded PNG. + fn decode(png: &[u8], width: usize, height: usize, channels: usize) -> Vec { let start = png .windows(4) .position(|window| window == b"IDAT") @@ -529,13 +542,17 @@ mod tests { // Two bytes of zlib header, and four of adler32 at the end. let raw = inflate(&png[start + 4 + 2..start + 4 + length - 4]); - let stride = width * 3; - let mut out = vec![0u8; pixels.len()]; + let stride = width * channels; + let mut out = vec![0u8; stride * height]; for y in 0..height { let kind = raw[y * (stride + 1)]; let row = &raw[y * (stride + 1) + 1..(y + 1) * (stride + 1)]; for i in 0..stride { - let left = if i >= 3 { out[y * stride + i - 3] } else { 0 }; + let left = if i >= channels { + out[y * stride + i - channels] + } else { + 0 + }; let up = if y > 0 { out[(y - 1) * stride + i] } else { 0 }; out[y * stride + i] = match kind { 0 => row[i], @@ -544,6 +561,35 @@ mod tests { }; } } - assert_eq!(out, pixels); + out + } + + #[test] + fn the_pixels_survive_the_encoding() { + // Encode, then decode, and compare with what went in. This is the + // whole contract of the module. + let width = 17; + let height = 9; + let pixels: Vec = (0..width * height * 3) + .map(|i| (i * 13 % 251) as u8) + .collect(); + let png = encode_rgb(width as u32, height as u32, &pixels); + assert_eq!(png[25], 2, "truecolour"); + assert_eq!(decode(&png, width, height, 3), pixels); + } + + /// The same contract for the alpha channel, which is a different + /// colour type and a different filter stride — the two things an + /// encoder that only ever wrote three channels could get wrong. + #[test] + fn the_alpha_channel_survives_the_encoding_too() { + let width = 11; + let height = 7; + let pixels: Vec = (0..width * height * 4) + .map(|i| (i * 29 % 253) as u8) + .collect(); + let png = encode_rgba(width as u32, height as u32, &pixels); + assert_eq!(png[25], 6, "truecolour with alpha"); + assert_eq!(decode(&png, width, height, 4), pixels); } } diff --git a/crates/didbot-brand/Cargo.toml b/crates/didbot-brand/Cargo.toml new file mode 100644 index 00000000..4cf21359 --- /dev/null +++ b/crates/didbot-brand/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "didbot-brand" +description = "Rebuilds the did.bot brand images from the simulation that draws the landing page." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +publish.workspace = true + +[dependencies] +# The simulation itself: the Ising lattice, the shipped anneal schedule, and +# the pixel font the wordmark and every mark are set in. Depending on it is +# the whole point — a brand image that came from a re-implementation of the +# physics would not be a picture of this site. +didbot-site-anim.workspace = true +# For its PNG encoder, and only that. Writing a second one here to avoid the +# dependency would mean two encoders in one workspace, which is worse than a +# crate boundary drawn slightly wide. +didbot-avatar.workspace = true + +[lints] +workspace = true diff --git a/crates/didbot-brand/src/bin/didbot-brand.rs b/crates/didbot-brand/src/bin/didbot-brand.rs new file mode 100644 index 00000000..35194f72 --- /dev/null +++ b/crates/didbot-brand/src/bin/didbot-brand.rs @@ -0,0 +1,67 @@ +//! Writes the brand images, or checks the committed ones are current. +//! +//! ```text +//! didbot-brand # write +//! didbot-brand --check # write nothing, exit 1 if stale +//! ``` +//! +//! `scripts/build-brand.sh` is the wrapper that passes `site/public`. + +use std::path::PathBuf; +use std::process::ExitCode; + +fn main() -> ExitCode { + let mut check = false; + let mut destination = None; + for argument in std::env::args().skip(1) { + match argument.as_str() { + "--check" => check = true, + "-h" | "--help" => { + println!("usage: didbot-brand [--check] "); + return ExitCode::SUCCESS; + } + _ => destination = Some(PathBuf::from(argument)), + } + } + let Some(destination) = destination else { + eprintln!("usage: didbot-brand [--check] "); + return ExitCode::FAILURE; + }; + + let mut stale = Vec::new(); + for built in didbot_brand::build_all() { + let path = destination.join(built.path); + if check { + match std::fs::read(&path) { + Ok(existing) if existing == built.bytes => {} + Ok(_) => stale.push(format!("{} differs", path.display())), + Err(error) => stale.push(format!("{}: {error}", path.display())), + } + continue; + } + if let Some(parent) = path.parent() { + if let Err(error) = std::fs::create_dir_all(parent) { + eprintln!("{}: {error}", parent.display()); + return ExitCode::FAILURE; + } + } + if let Err(error) = std::fs::write(&path, &built.bytes) { + eprintln!("{}: {error}", path.display()); + return ExitCode::FAILURE; + } + println!("{} ({} bytes)", path.display(), built.bytes.len()); + } + + if !stale.is_empty() { + eprintln!("the committed brand images are not what the generator produces:"); + for line in &stale { + eprintln!(" {line}"); + } + eprintln!("run scripts/build-brand.sh and commit the result"); + return ExitCode::FAILURE; + } + if check { + println!("brand images are current"); + } + ExitCode::SUCCESS +} diff --git a/crates/didbot-brand/src/ico.rs b/crates/didbot-brand/src/ico.rs new file mode 100644 index 00000000..7a0590c8 --- /dev/null +++ b/crates/didbot-brand/src/ico.rs @@ -0,0 +1,71 @@ +//! The one container format a favicon still needs. +//! +//! `.ico` is a directory of images, and since Windows Vista an entry may +//! be a whole PNG rather than a bitmap — which is the only kind this +//! writes, because everything that reads a favicon today reads that and +//! nothing here has a reason to emit two encodings of the same tile. + +/// Packs `images` — PNG bytes, each `size` x `size` — into one `.ico`. +/// +/// # Panics +/// +/// If a size is not in `1..=256`, which is all the format's one-byte +/// dimension fields can say. +pub fn ico(images: &[(u32, Vec)]) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(&0u16.to_le_bytes()); // reserved + out.extend_from_slice(&1u16.to_le_bytes()); // 1 = icon, 2 = cursor + out.extend_from_slice(&(images.len() as u16).to_le_bytes()); + + // Every entry is 16 bytes and they all precede the first payload. + let mut offset = 6 + 16 * images.len() as u32; + for (size, png) in images { + assert!( + (1..=256).contains(size), + "an ico entry is at most 256 pixels, not {size}" + ); + // 256 is written as 0: the field is one byte and 256 does not fit. + let dimension = if *size == 256 { 0u8 } else { *size as u8 }; + out.push(dimension); + out.push(dimension); + out.push(0); // palette size, meaningless for a PNG entry + out.push(0); // reserved + out.extend_from_slice(&1u16.to_le_bytes()); // colour planes + out.extend_from_slice(&32u16.to_le_bytes()); // bits a pixel + out.extend_from_slice(&(png.len() as u32).to_le_bytes()); + out.extend_from_slice(&offset.to_le_bytes()); + offset += png.len() as u32; + } + for (_, png) in images { + out.extend_from_slice(png); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_entry_points_at_its_own_payload() { + let images = vec![(16u32, vec![1u8; 30]), (32, vec![2u8; 40])]; + let bytes = ico(&images); + assert_eq!(&bytes[0..6], &[0, 0, 1, 0, 2, 0]); + for (index, (size, png)) in images.iter().enumerate() { + let entry = 6 + 16 * index; + assert_eq!(u32::from(bytes[entry]), *size); + let length = + u32::from_le_bytes(bytes[entry + 8..entry + 12].try_into().unwrap()) as usize; + let offset = + u32::from_le_bytes(bytes[entry + 12..entry + 16].try_into().unwrap()) as usize; + assert_eq!(&bytes[offset..offset + length], png.as_slice()); + } + } + + #[test] + fn a_256_pixel_entry_writes_its_size_as_zero() { + let bytes = ico(&[(256, vec![0u8; 8])]); + assert_eq!(bytes[6], 0); + assert_eq!(bytes[7], 0); + } +} diff --git a/crates/didbot-brand/src/lib.rs b/crates/didbot-brand/src/lib.rs new file mode 100644 index 00000000..993f44f3 --- /dev/null +++ b/crates/didbot-brand/src/lib.rs @@ -0,0 +1,394 @@ +//! The did.bot brand images, rebuilt from the simulation that draws the +//! landing page. +//! +//! Every file this crate writes — the wordmark, the social card, the whole +//! icon set, the favicon — is a snap of the same 2-D Ising anneal +//! `crates/didbot-site-anim` runs in the browser, stopped at the end of +//! its schedule. Nothing is traced, redrawn, or exported from a design +//! tool, so there is no second copy of the mark to keep in step with the +//! page: change the schedule and rebuild, and the images change with it. +//! +//! # Rebuilding +//! +//! ```text +//! scripts/build-brand.sh # rewrite site/public +//! scripts/build-brand.sh --check # fail if anything is stale +//! ``` +//! +//! The `--check` form is what CI runs, and it is the reason the images are +//! committed at all: the tree is allowed to hold generated files as long +//! as something proves they are the ones the generator produces. +//! +//! # Icons, and what "the same noise" means +//! +//! An icon is not the wordmark shrunk. It is a *different mark* — "did" +//! over "bot", or a lone "d" at favicon sizes — set in the same +//! six-glyph pixel font, annealed on its own square lattice by the same +//! schedule, and painted with the same two colours at the same one-block-a-site +//! magnification. So the grain reads as the same material as the landing +//! page at every size, without ever resampling a picture of it. +//! +//! # Determinism +//! +//! An image is a pure function of its [`mark::Mark`] — no clock, no +//! entropy, no filesystem — so building twice on one host gives identical +//! bytes, which `--check` relies on. Bit-exactness across platforms is +//! not promised, for the reason `didbot-avatar` gives: the Metropolis +//! acceptance test goes through `exp`, and a `libm` is entitled to its own +//! last bit. That is why `--check` is a CI gate on one platform rather +//! than a claim about every machine. + +#![forbid(unsafe_code)] + +pub mod ico; +pub mod mark; +pub mod paint; + +use mark::{Lattice, Mark, Rect}; +use paint::Cut; +use std::collections::HashMap; + +/// Sites of margin left around the wordmark in the cropped logo. +pub const CROP_MARGIN: usize = 6; + +/// What an asset is cropped to before it is painted. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Crop { + /// The whole lattice — the landing page's own framing, noise and all. + Whole, + /// Tight to the mark, plus [`CROP_MARGIN`] sites: the logo to drop + /// into a README or a slide, where a screenful of noise is not wanted. + Mark, +} + +/// How an asset is encoded. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Format { + /// Blocks of pixels, `scale` a site. + Png { + /// Output pixels a lattice site. + scale: usize, + }, + /// One unit a site, scaled by whoever renders it. + Svg, + /// A `.ico` holding one PNG an entry, at these pixel sizes. The scale + /// is whatever makes each size out of the mark's own lattice, so every + /// size must be a multiple of it. + Ico { + /// The square pixel sizes the container holds, one PNG each. + sizes: &'static [usize], + }, +} + +/// One file, and everything needed to make it. +pub struct Asset { + /// Where it goes, relative to the destination root (`site/public`). + pub path: &'static str, + /// What is annealed to make it. + pub mark: Mark, + /// How much of that mark's lattice it shows. + pub crop: Crop, + /// Whether it is cut to a disc. + pub cut: Cut, + /// How it is encoded. + pub format: Format, +} + +/// Every brand image the site ships. +/// +/// **This list is the brand's inventory.** Adding a size, a shape or a +/// mark is an entry here and nothing else; the builder, the `--check` +/// gate and the tests all read it. +pub const ASSETS: &[Asset] = &[ + // The logo, in the landing page's own framing: the full lattice, so + // the wordmark sits in the field it crystallised out of. + Asset { + path: "brand/wordmark.png", + mark: mark::WORDMARK, + crop: Crop::Whole, + cut: Cut::Square, + format: Format::Png { scale: 4 }, + }, + Asset { + path: "brand/wordmark.svg", + mark: mark::WORDMARK, + crop: Crop::Whole, + cut: Cut::Square, + format: Format::Svg, + }, + // The same wordmark, cropped tight, for anywhere it has to sit beside + // other people's logos. + Asset { + path: "brand/wordmark-tight.png", + mark: mark::WORDMARK, + crop: Crop::Mark, + cut: Cut::Square, + format: Format::Png { scale: 8 }, + }, + Asset { + path: "brand/wordmark-tight.svg", + mark: mark::WORDMARK, + crop: Crop::Mark, + cut: Cut::Square, + format: Format::Svg, + }, + // 1200x630, the size every link unfurler crops towards. + Asset { + path: "brand/social-card.png", + mark: mark::BANNER, + crop: Crop::Whole, + cut: Cut::Square, + format: Format::Png { scale: 6 }, + }, + // Square icons, from the 64-site stack lattice. + Asset { + path: "brand/icon-512.png", + mark: mark::STACK, + crop: Crop::Whole, + cut: Cut::Square, + format: Format::Png { scale: 4 }, + }, + Asset { + path: "brand/icon-256.png", + mark: mark::STACK, + crop: Crop::Whole, + cut: Cut::Square, + format: Format::Png { scale: 2 }, + }, + Asset { + path: "brand/icon-128.png", + mark: mark::STACK, + crop: Crop::Whole, + cut: Cut::Square, + format: Format::Png { scale: 1 }, + }, + Asset { + path: "brand/icon.svg", + mark: mark::STACK, + crop: Crop::Whole, + cut: Cut::Square, + format: Format::Svg, + }, + // The one icon iOS looks for by name, at the size it is happy to + // scale down from. + Asset { + path: "apple-touch-icon.png", + mark: mark::STACK, + crop: Crop::Whole, + cut: Cut::Square, + format: Format::Png { scale: 2 }, + }, + // Round icons: the mark pulled inside the inscribed circle, the + // corners cut away. + Asset { + path: "brand/icon-round-512.png", + mark: mark::STACK_ROUND, + crop: Crop::Whole, + cut: Cut::Round, + format: Format::Png { scale: 8 }, + }, + Asset { + path: "brand/icon-round-256.png", + mark: mark::STACK_ROUND, + crop: Crop::Whole, + cut: Cut::Round, + format: Format::Png { scale: 4 }, + }, + Asset { + path: "brand/icon-round-128.png", + mark: mark::STACK_ROUND, + crop: Crop::Whole, + cut: Cut::Round, + format: Format::Png { scale: 2 }, + }, + Asset { + path: "brand/icon-round.svg", + mark: mark::STACK_ROUND, + crop: Crop::Whole, + cut: Cut::Round, + format: Format::Svg, + }, + // Favicon sizes, from the dot: no letterform survives a 16 pixel tile + // annealed (see `mark::Shape::Disc`). + Asset { + path: "brand/icon-48.png", + mark: mark::DOT, + crop: Crop::Whole, + cut: Cut::Square, + format: Format::Png { scale: 3 }, + }, + Asset { + path: "brand/icon-32.png", + mark: mark::DOT, + crop: Crop::Whole, + cut: Cut::Square, + format: Format::Png { scale: 2 }, + }, + Asset { + path: "brand/icon-16.png", + mark: mark::DOT, + crop: Crop::Whole, + cut: Cut::Square, + format: Format::Png { scale: 1 }, + }, + Asset { + path: "favicon.svg", + mark: mark::DOT, + crop: Crop::Whole, + cut: Cut::Square, + format: Format::Svg, + }, + Asset { + path: "favicon.ico", + mark: mark::DOT, + crop: Crop::Whole, + cut: Cut::Square, + format: Format::Ico { + sizes: &[16, 32, 48], + }, + }, +]; + +/// A built file: where it goes, and what goes in it. +pub struct Built { + /// Where it goes, relative to the destination root. + pub path: &'static str, + /// The file's contents. + pub bytes: Vec, +} + +/// Builds every asset in [`ASSETS`]. +/// +/// Each distinct mark is annealed once however many assets it feeds, +/// which is most of the runtime: the wordmark's lattice alone is 35,200 +/// sites through 1,200 sweeps. +pub fn build_all() -> Vec { + let mut lattices: HashMap<&'static str, Lattice> = HashMap::new(); + ASSETS + .iter() + .map(|asset| { + let lattice = lattices + .entry(asset.mark.name) + .or_insert_with(|| asset.mark.crystallise()); + Built { + path: asset.path, + bytes: build_one(asset, lattice), + } + }) + .collect() +} + +/// Builds one asset from an already-annealed lattice. +pub fn build_one(asset: &Asset, lattice: &Lattice) -> Vec { + let rect = match asset.crop { + Crop::Whole => Rect { + x: 0, + y: 0, + width: lattice.width, + height: lattice.height, + }, + Crop::Mark => Lattice::mask_bounds( + &asset.mark.mask(), + lattice.width, + lattice.height, + CROP_MARGIN, + ), + }; + match asset.format { + Format::Png { scale } => paint::png(lattice, rect, scale, asset.cut), + Format::Svg => paint::svg(lattice, rect, asset.cut).into_bytes(), + Format::Ico { sizes } => { + let images = sizes + .iter() + .map(|&size| { + assert_eq!( + size % rect.width, + 0, + "{size} is not a whole number of {}-site lattices", + rect.width + ); + ( + size as u32, + paint::png(lattice, rect, size / rect.width, asset.cut), + ) + }) + .collect::>(); + ico::ico(&images) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn no_two_assets_claim_the_same_path() { + let mut paths: Vec<&str> = ASSETS.iter().map(|asset| asset.path).collect(); + paths.sort_unstable(); + let count = paths.len(); + paths.dedup(); + assert_eq!(paths.len(), count, "two assets write the same file"); + } + + /// A PNG asset paints whole sites, so its pixel size is a whole + /// multiple of its lattice — this is what keeps an icon from ever + /// being a resampled picture of a lattice rather than the lattice. + #[test] + fn every_raster_size_is_a_whole_number_of_sites() { + for asset in ASSETS { + if let Format::Ico { sizes } = asset.format { + for &size in sizes { + assert_eq!( + size % asset.mark.lattice.0, + 0, + "{}: {size} is not a multiple of the lattice", + asset.path + ); + } + } + } + } + + #[test] + fn a_round_asset_uses_a_mark_that_clears_the_circle() { + for asset in ASSETS.iter().filter(|asset| asset.cut == Cut::Round) { + let mask = asset.mark.mask(); + let (width, height) = asset.mark.lattice; + let radius = width.min(height) as f32 / 2.0; + for (index, lit) in mask.iter().enumerate() { + if !lit { + continue; + } + let dx = (index % width) as f32 + 0.5 - width as f32 / 2.0; + let dy = (index / width) as f32 + 0.5 - height as f32 / 2.0; + assert!( + (dx * dx + dy * dy).sqrt() <= radius, + "{}: the mark leaves the disc it will be cut to", + asset.path + ); + } + } + } + + /// The other half of what `--check` rests on. The anneal's own + /// determinism is `mark`'s to test; this is that everything after it + /// — the crop, the paint, the encoders — is a pure function of the + /// lattice too, checked over every asset in the manifest from one + /// cheap lattice rather than by annealing the real ones twice. + #[test] + fn encoding_an_asset_twice_writes_the_same_bytes() { + let lattice = mark::DOT.crystallise(); + for asset in ASSETS { + let ico_fits = !matches!(asset.format, Format::Ico { sizes } + if sizes.iter().any(|size| size % lattice.width != 0)); + if !ico_fits { + continue; + } + let once = build_one(asset, &lattice); + let again = build_one(asset, &lattice); + assert_eq!(once, again, "{} is not deterministic", asset.path); + assert!(!once.is_empty(), "{} came out empty", asset.path); + } + } +} diff --git a/crates/didbot-brand/src/mark.rs b/crates/didbot-brand/src/mark.rs new file mode 100644 index 00000000..68ec870a --- /dev/null +++ b/crates/didbot-brand/src/mark.rs @@ -0,0 +1,284 @@ +//! What gets crystallised, and the snap of the simulation that does it. +//! +//! A mark is a shape for the external field to pull the lattice towards — +//! the "did.bot" wordmark for the logo, a short lockup set in the same +//! pixel font for the icons — plus the lattice size and RNG seed that +//! together make one particular run of the anneal. Given those, the +//! finished lattice is a pure function: the same mark always crystallises +//! into the same spins. + +use didbot_site_anim::anneal::{run_full_anneal_with_mask, AnnealSchedule, DEFAULT_TOTAL_SWEEPS}; +use didbot_site_anim::ising::{didbot_mask, text_mask}; + +/// What shape the field pulls the lattice towards. +/// +/// Three, and they are a size ladder rather than a set of alternatives: +/// the wordmark is the logo, the lockup is the icon, and the disc is what +/// is left when a tile is sixteen pixels across. +#[derive(Clone, Copy)] +pub enum Shape { + /// The "did.bot" wordmark, laid out the way the landing page lays it + /// out — hung off its own "." rather than centred — rather than as + /// text this crate composed. + Wordmark, + /// Lines of the same six-glyph font, centred as a lockup. + Text(&'static [&'static str]), + /// A filled disc. Letterforms do not survive this anneal below about + /// three lattice sites to the glyph pixel — surface tension closes a + /// two-site counter, and a "d" comes out a blob — so the favicon + /// sizes get the one shape a lattice this coarse can still hold, and + /// the one the dynamics actively help: a single ordered domain, which + /// is also the "." in did.bot. + Disc, +} + +/// One mark: a shape, the lattice it is annealed on, and the seed. +#[derive(Clone, Copy)] +pub struct Mark { + /// Appears in the manifest and in test failures; kebab-case. + pub name: &'static str, + /// What the field aims at. + pub shape: Shape, + /// How much of the lattice the mark fills, as a fraction of the + /// binding axis — its diameter, for a [`Shape::Disc`]. Lower for + /// round marks, which have to clear a circle. + pub fill: f32, + /// Lattice size in sites. This is the grain of the noise: an icon + /// painted a block a site reads as the same material as the landing + /// page, whatever the tile it ends up in, because the blocks are the + /// page's own — bigger on screen, never resampled. + pub lattice: (usize, usize), + /// The RNG seed: which run of the anneal this is. Always one of the + /// twelve `anneal.rs`'s crystallisation property test measures, so + /// the run behind a shipped image is one the test suite has actually + /// seen — and, for everything but the wordmark, the one of those + /// twelve that came out cleanest, since a seed is a free choice and + /// there is no reason to ship a worse frame than the schedule can + /// give. [`WORDMARK`] keeps the landing page's own seed instead + /// (`site/src/config/anim.ts`), because a logo that is not the frame + /// the page ends on would be a different picture wearing the same + /// name; [`BANNER`] cannot be that frame anyway — its lattice is a + /// different shape — so it is free to take the cleanest run too. + pub seed: u32, +} + +/// The landing page's own lattice: 220x160, seed 11 — the numbers in +/// `site/src/config/anim.ts`, which is why this image and the page agree. +pub const WORDMARK: Mark = Mark { + name: "wordmark", + shape: Shape::Wordmark, + fill: 0.0, // unused: the wordmark's own layout decides + lattice: (220, 160), + seed: 11, +}; + +/// The wordmark again on a wider, shorter lattice, for the social card: +/// 200x105 is what 1200x630 divides into at six pixels a site. +pub const BANNER: Mark = Mark { + name: "banner", + shape: Shape::Wordmark, + fill: 0.0, + lattice: (200, 105), + seed: 909, +}; + +/// The icon mark: "did" over "bot", square, which is the wordmark folded +/// in half so it survives being shrunk to a tile. Nothing new is invented +/// here — same glyphs, same font, same field. +pub const STACK: Mark = Mark { + name: "stack", + shape: Shape::Text(&["did", "bot"]), + fill: 0.72, + lattice: (128, 128), + seed: 59, +}; + +/// [`STACK`] pulled in far enough to sit inside the inscribed circle of +/// its own tile, for the icons that get masked round. +pub const STACK_ROUND: Mark = Mark { + name: "stack-round", + shape: Shape::Text(&["did", "bot"]), + fill: 0.56, + lattice: (128, 128), + seed: 202, +}; + +/// The small-size mark: the dot, on a lattice of its own. Two lines of +/// three glyphs do not survive a sixteen pixel tile, and neither does one +/// glyph — see [`Shape::Disc`] for what the lattice does to a letterform +/// this coarse, and why this is a disc instead. +pub const DOT: Mark = Mark { + name: "dot", + shape: Shape::Disc, + fill: 0.66, + lattice: (16, 16), + seed: 1, +}; + +/// Every mark, for the tests that hold all of them to the same bar. +pub const ALL: &[Mark] = &[WORDMARK, BANNER, STACK, STACK_ROUND, DOT]; + +impl Mark { + /// The shape the field pulls towards, as a bitmap on this mark's + /// lattice. + pub fn mask(&self) -> Vec { + let (width, height) = self.lattice; + match self.shape { + Shape::Wordmark => didbot_mask(width, height), + Shape::Text(lines) => text_mask(lines, width, height, self.fill), + Shape::Disc => { + let radius = width.min(height) as f32 * self.fill / 2.0; + let centre = (width as f32 / 2.0, height as f32 / 2.0); + (0..width * height) + .map(|index| { + let dx = (index % width) as f32 + 0.5 - centre.0; + let dy = (index / width) as f32 + 0.5 - centre.1; + (dx * dx + dy * dy).sqrt() <= radius + }) + .collect() + } + } + } + + /// Runs the shipped anneal to its end and returns the lattice it + /// froze into: `true` where the spin is up (lit). + /// + /// This is the snap. It is the same schedule + /// ([`AnnealSchedule::default`]), the same sweep count + /// ([`DEFAULT_TOTAL_SWEEPS`]) and the same dynamics the landing page + /// scrolls through, stopped at the end of the schedule — so a brand + /// image is the last frame of the page's own animation rather than a + /// drawing of one. + pub fn crystallise(&self) -> Lattice { + let (width, height) = self.lattice; + let spins = run_full_anneal_with_mask( + width, + height, + self.seed, + DEFAULT_TOTAL_SWEEPS, + &AnnealSchedule::default(), + &self.mask(), + ); + Lattice { + width, + height, + lit: spins.iter().map(|&spin| spin > 0).collect(), + } + } + + /// The fraction of sites that came out agreeing with the mask — the + /// same measure `anneal.rs`'s crystallisation property test applies to + /// the landing page, applied here to a mark that is not the wordmark + /// and a lattice that is not 220x160. + pub fn agreement(&self) -> f32 { + let mask = self.mask(); + let lattice = self.crystallise(); + let agree = lattice + .lit + .iter() + .zip(&mask) + .filter(|(lit, wanted)| lit == wanted) + .count(); + agree as f32 / mask.len() as f32 + } +} + +/// A finished lattice: one bit a site, row major. +pub struct Lattice { + /// Sites across. + pub width: usize, + /// Sites down. + pub height: usize, + /// `true` where the spin is up — the lit colour. + pub lit: Vec, +} + +impl Lattice { + /// Whether the site at `(x, y)` is lit; `false` outside the lattice. + pub fn lit_at(&self, x: usize, y: usize) -> bool { + x < self.width && y < self.height && self.lit[y * self.width + x] + } + + /// The bounding box of the lit sites inside `mask`'s shape, grown by + /// `margin` sites and clamped to the lattice: `(x, y, width, height)`. + /// What a cropped logo is cropped to. + pub fn mask_bounds(mask: &[bool], width: usize, height: usize, margin: usize) -> Rect { + let (mut min_x, mut min_y, mut max_x, mut max_y) = (width, height, 0usize, 0usize); + for y in 0..height { + for x in 0..width { + if mask[y * width + x] { + min_x = min_x.min(x); + min_y = min_y.min(y); + max_x = max_x.max(x); + max_y = max_y.max(y); + } + } + } + if min_x > max_x { + return Rect { + x: 0, + y: 0, + width, + height, + }; + } + let x = min_x.saturating_sub(margin); + let y = min_y.saturating_sub(margin); + Rect { + x, + y, + width: (max_x + margin + 1).min(width) - x, + height: (max_y + margin + 1).min(height) - y, + } + } +} + +/// A rectangle of lattice sites. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Rect { + /// Leftmost site, in lattice coordinates. + pub x: usize, + /// Topmost site, in lattice coordinates. + pub y: usize, + /// Sites across. + pub width: usize, + /// Sites down. + pub height: usize, +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Every mark has to actually come out looking like itself. The floor + /// is the landing page's own (`anneal.rs`'s `AGREEMENT_FLOOR`, 93%), + /// applied to lattices and shapes that test never saw: a schedule + /// tuned for one 220x160 wordmark is not owed good behaviour on a + /// 16x16 tile, so this is the test that says whether a mark is + /// shippable at all. + #[test] + fn every_mark_crystallises_into_its_own_shape() { + let mut failures = Vec::new(); + for mark in ALL { + let agreement = mark.agreement(); + if agreement < 0.93 { + failures.push((mark.name, agreement)); + } + } + assert!( + failures.is_empty(), + "marks that did not crystallise: {failures:?}" + ); + } + + /// The property the whole crate rests on: a mark is a pure function, + /// which is what lets the images be committed and checked. Run on the + /// smallest lattice, because the property is the anneal's and not this + /// mark's — the expensive ones are annealed once each above. + #[test] + fn the_same_mark_crystallises_into_the_same_lattice_every_time() { + let once = DOT.crystallise(); + let again = DOT.crystallise(); + assert_eq!(once.lit, again.lit); + } +} diff --git a/crates/didbot-brand/src/paint.rs b/crates/didbot-brand/src/paint.rs new file mode 100644 index 00000000..d69ef69a --- /dev/null +++ b/crates/didbot-brand/src/paint.rs @@ -0,0 +1,207 @@ +//! Turning a finished lattice into files: PNG for the raster sizes, SVG +//! for the ones that scale. +//! +//! Two rules hold every image here together. Sites are painted as whole +//! blocks at an integer scale, never resampled — a brand image is the +//! lattice, magnified, not a photograph of one — and the two colours are +//! the site's own (`site/src/styles/global.css`'s `--ground` and +//! `--accent`, the same pair the landing page's canvas fills with). + +use crate::mark::{Lattice, Rect}; + +/// `--ground`: the unlit site, and the background. +pub const GROUND: [u8; 3] = [0x05, 0x10, 0x0a]; +/// `--accent`: the lit site. +pub const ACCENT: [u8; 3] = [0x58, 0xe0, 0x7a]; + +/// How a square image is cut: left whole, or cut to the disc inscribed in +/// it (transparent outside, for an icon that will be shown round). +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Cut { + /// The whole tile, opaque to its corners. + Square, + /// The inscribed disc, transparent outside it. + Round, +} + +/// Paints `rect` of `lattice` at `scale` pixels a site, as PNG bytes. +/// +/// [`Cut::Square`] gives an opaque RGB image; [`Cut::Round`] an RGBA one +/// whose corners are transparent, with the edge of the disc antialiased +/// against the transparency (the one place in this crate that draws +/// something the simulation did not, because a hard-stepped circle at 64 +/// pixels looks broken in a way a lattice does not). +pub fn png(lattice: &Lattice, rect: Rect, scale: usize, cut: Cut) -> Vec { + let width = rect.width * scale; + let height = rect.height * scale; + match cut { + Cut::Square => { + let mut pixels = Vec::with_capacity(width * height * 3); + for_each_pixel(lattice, rect, scale, |lit, _| { + pixels.extend_from_slice(if lit { &ACCENT } else { &GROUND }); + }); + didbot_avatar::png::encode_rgb(width as u32, height as u32, &pixels) + } + Cut::Round => { + let mut pixels = Vec::with_capacity(width * height * 4); + for_each_pixel(lattice, rect, scale, |lit, coverage| { + let colour = if lit { ACCENT } else { GROUND }; + pixels.extend_from_slice(&colour); + pixels.push((coverage * 255.0).round() as u8); + }); + didbot_avatar::png::encode_rgba(width as u32, height as u32, &pixels) + } + } +} + +/// Walks the output pixels in row-major order, handing each one the site +/// it lands in and how much of it the disc covers (always `1.0` away from +/// a round cut's edge). +fn for_each_pixel(lattice: &Lattice, rect: Rect, scale: usize, mut pixel: impl FnMut(bool, f32)) { + let width = rect.width * scale; + let height = rect.height * scale; + let centre = (width as f32 / 2.0, height as f32 / 2.0); + let radius = (width.min(height) as f32) / 2.0; + for y in 0..height { + for x in 0..width { + let lit = lattice.lit_at(rect.x + x / scale, rect.y + y / scale); + // Distance of the pixel's centre from the disc's edge, in + // pixels, clamped into a one-pixel ramp: the cheapest + // antialiasing there is, and enough at these sizes. + let dx = x as f32 + 0.5 - centre.0; + let dy = y as f32 + 0.5 - centre.1; + let coverage = (radius - (dx * dx + dy * dy).sqrt() + 0.5).clamp(0.0, 1.0); + pixel(lit, coverage); + } + } +} + +/// The same picture as an SVG: one `` a run of lit sites, on a +/// ground-coloured field, at one unit a site. +/// +/// Runs rather than one rect a site for the same reason the landing page's +/// canvas draws runs — a 220x160 lattice is 35,200 sites and perhaps a +/// tenth as many runs. +pub fn svg(lattice: &Lattice, rect: Rect, cut: Cut) -> String { + let mut out = String::new(); + out.push_str(&format!( + "\n", + rect.width, rect.height + )); + let clip = match cut { + Cut::Square => { + out.push_str(&format!( + " \n", + rect.width, + rect.height, + hex(GROUND) + )); + String::new() + } + Cut::Round => { + let r = rect.width.min(rect.height) as f32 / 2.0; + out.push_str(&format!( + " \n", + rect.width as f32 / 2.0, + rect.height as f32 / 2.0, + hex(GROUND) + )); + out.push_str(&format!( + " \n", + rect.width as f32 / 2.0, + rect.height as f32 / 2.0, + )); + " clip-path=\"url(#disc)\"".to_string() + } + }; + out.push_str(&format!(" \n", hex(ACCENT))); + for y in 0..rect.height { + let mut run: Option = None; + for x in 0..=rect.width { + let lit = x < rect.width && lattice.lit_at(rect.x + x, rect.y + y); + match (lit, run) { + (true, None) => run = Some(x), + (false, Some(start)) => { + out.push_str(&format!( + " \n", + x - start + )); + run = None; + } + _ => {} + } + } + } + out.push_str(" \n\n"); + out +} + +fn hex(colour: [u8; 3]) -> String { + format!("{:02x}{:02x}{:02x}", colour[0], colour[1], colour[2]) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn checkerboard() -> Lattice { + Lattice { + width: 4, + height: 4, + lit: (0..16).map(|i| i % 3 == 0).collect(), + } + } + + fn whole(lattice: &Lattice) -> Rect { + Rect { + x: 0, + y: 0, + width: lattice.width, + height: lattice.height, + } + } + + #[test] + fn a_square_png_is_an_opaque_png_of_the_asked_for_size() { + let lattice = checkerboard(); + let bytes = png(&lattice, whole(&lattice), 8, Cut::Square); + assert_eq!(&bytes[1..4], b"PNG"); + // IHDR: width, height, then the colour-type byte. 2 is truecolour. + assert_eq!(&bytes[16..24], &[0, 0, 0, 32, 0, 0, 0, 32]); + assert_eq!(bytes[25], 2); + } + + #[test] + fn a_round_png_carries_alpha_and_is_transparent_in_its_corners() { + let lattice = checkerboard(); + let bytes = png(&lattice, whole(&lattice), 8, Cut::Round); + assert_eq!(bytes[25], 6, "colour type 6 is truecolour with alpha"); + } + + /// The corner of a round cut has to actually be cut, which the PNG + /// bytes above cannot show through the compression — so check the + /// coverage the painter computes instead. + #[test] + fn the_round_cut_covers_the_centre_and_not_the_corners() { + let lattice = checkerboard(); + let mut coverage = Vec::new(); + for_each_pixel(&lattice, whole(&lattice), 8, |_, alpha| { + coverage.push(alpha) + }); + assert_eq!(coverage[0], 0.0, "top-left corner"); + assert_eq!(coverage[32 * 16 + 16], 1.0, "the centre"); + } + + #[test] + fn the_svg_merges_a_row_into_one_rect_a_run() { + let lattice = Lattice { + width: 4, + height: 1, + lit: vec![true, true, true, false], + }; + let out = svg(&lattice, whole(&lattice), Cut::Square); + assert_eq!(out.matches(">, ) { let progress = sweep as f32 / total_sweeps as f32; model.set_temperature(schedule.temperature_at(progress)); let strength = schedule.field_strength_at(progress); if strength > 0.0 { - let base = field_cache.get_or_insert_with(|| didbot_field(width, height, 1.0)); + let base = field_cache.get_or_insert_with(|| field_from_mask(mask, 1.0)); let scaled: Vec = base.iter().map(|&v| v * strength).collect(); model.set_field(&scaled); } else if field_cache.is_some() { - model.set_field(&vec![0.0; width * height]); + model.set_field(&vec![0.0; model.width() * model.height()]); } model.step(1); } @@ -164,6 +165,28 @@ pub fn run_full_anneal( total_sweeps: u32, schedule: &AnnealSchedule, ) -> Vec { + let mask = didbot_mask(width, height); + run_full_anneal_with_mask(width, height, seed, total_sweeps, schedule, &mask) +} + +/// [`run_full_anneal`], aimed at an arbitrary mask instead of the +/// wordmark: same schedule, same dynamics, same seeded RNG, so a mark +/// annealed here carries the same noise distribution as the landing +/// page's lattice at the same point in its schedule. `mask.len()` must be +/// `width * height`. This is what `didbot-brand` renders every icon from. +pub fn run_full_anneal_with_mask( + width: usize, + height: usize, + seed: u32, + total_sweeps: u32, + schedule: &AnnealSchedule, + mask: &[bool], +) -> Vec { + assert_eq!( + mask.len(), + width * height, + "mask does not match {width}x{height}" + ); let mut model = IsingModel::new(width, height, seed); let mut field_cache = None; for sweep in 0..total_sweeps { @@ -172,8 +195,7 @@ pub fn run_full_anneal( schedule, total_sweeps, sweep, - width, - height, + mask, &mut field_cache, ); } @@ -190,6 +212,9 @@ pub struct CheckpointedAnneal { total_sweeps: u32, checkpoint_interval: u32, schedule: AnnealSchedule, + /// The shape the field pulls towards, rasterized once at + /// construction: the wordmark, for every caller this struct has. + mask: Vec, checkpoints: Vec>, /// Reused scratch model for both filling checkpoints and rendering — /// never left in a state a caller can observe between calls, so its @@ -231,6 +256,7 @@ impl CheckpointedAnneal { total_sweeps, checkpoint_interval, schedule, + mask: didbot_mask(width, height), checkpoints, scratch: initial, field_cache: None, @@ -263,8 +289,7 @@ impl CheckpointedAnneal { &self.schedule, self.total_sweeps, sweep, - self.width, - self.height, + &self.mask, &mut self.field_cache, ); } @@ -293,8 +318,7 @@ impl CheckpointedAnneal { &self.schedule, self.total_sweeps, sweep, - self.width, - self.height, + &self.mask, &mut self.field_cache, ); } diff --git a/crates/didbot-site-anim/src/ising.rs b/crates/didbot-site-anim/src/ising.rs index 07797b34..703e358f 100644 --- a/crates/didbot-site-anim/src/ising.rs +++ b/crates/didbot-site-anim/src/ising.rs @@ -409,11 +409,34 @@ mod glyphs { /// "did.bot"'s glyphs, in order, each 5 columns wide and 7 rows tall. pub const WORD: [[u8; 7]; 7] = [D, I, D, DOT, B, O, T]; + + /// The same six glyphs, addressed by character, for callers that + /// compose their own short strings out of this font rather than + /// rendering the wordmark ([`super::text_mask`]). `None` for anything + /// else: this is the alphabet "did.bot" happens to need and not a + /// letter more, so a caller asking for one it does not have is a bug + /// worth seeing rather than a blank to paper over. + pub fn for_char(ch: char) -> Option<[u8; 7]> { + match ch { + 'd' => Some(D), + 'i' => Some(I), + '.' => Some(DOT), + 'b' => Some(B), + 'o' => Some(O), + 't' => Some(T), + ' ' => Some([0; 7]), + _ => None, + } + } } const GLYPH_COLS: usize = 5; const GLYPH_ROWS: usize = 7; const GLYPH_GAP: usize = 1; +/// Blank source rows between two lines of a multi-line mark +/// ([`text_mask`]). One row less than a glyph is tall reads as a lockup +/// rather than as two separate words. +const LINE_GAP: usize = 2; /// Index into `glyphs::WORD` of the "i" in "did.bot" — the second glyph. const I_GLYPH_INDEX: usize = 1; /// Index into `glyphs::WORD` of the "." in "did.bot" — the fourth glyph. @@ -461,29 +484,145 @@ fn glyph_pixels(width: usize, height: usize) -> Vec<(usize, usize, usize)> { let origin_y = (height as f32 / 2.0 - dot_mid_row * scale).round().max(0.0) as usize; let origin_y = origin_y.min(height.saturating_sub(scaled_h)); - let mut pixels = Vec::new(); - for row in 0..scaled_h { - let src_row = ((row as f32 / scale) as usize).min(GLYPH_ROWS - 1); - for col in 0..scaled_w { - let src_col = (col as f32 / scale) as usize; + blit_scaled( + (word_cols, GLYPH_ROWS), + scale, + (origin_x, origin_y), + (width, height), + |src_col, src_row| { let glyph_index = src_col / (GLYPH_COLS + GLYPH_GAP); let col_in_glyph = src_col % (GLYPH_COLS + GLYPH_GAP); if glyph_index >= word.len() || col_in_glyph >= GLYPH_COLS { - continue; // inter-glyph gap column + return false; // inter-glyph gap column } let bit = GLYPH_COLS - 1 - col_in_glyph; - let on = (word[glyph_index][src_row] >> bit) & 1 == 1; - if on { - let (x, y) = (origin_x + col, origin_y + row); - if x < width && y < height { - pixels.push((x, y, glyph_index)); - } + (word[glyph_index][src_row] >> bit) & 1 == 1 + }, + ) + .into_iter() + .map(|(x, y, src_col, _)| (x, y, src_col / (GLYPH_COLS + GLYPH_GAP))) + .collect() +} + +/// Nearest-neighbour scales a `src` bitmap by `scale`, places it at +/// `origin` on a `lattice`-sized grid, and returns every lit site as +/// `(x, y, src_col, src_row)`. Shared by [`glyph_pixels`] and +/// [`text_mask`] so the wordmark and the marks built from the same font +/// are rasterized by one piece of code — the pixels of a mark and the +/// pixels of the wordmark land on their lattices the same way, which is +/// the whole reason a mark annealed with this field looks like it came +/// from the same simulation. +fn blit_scaled( + src: (usize, usize), + scale: f32, + origin: (usize, usize), + lattice: (usize, usize), + lit: impl Fn(usize, usize) -> bool, +) -> Vec<(usize, usize, usize, usize)> { + let (src_w, src_h) = src; + let (width, height) = lattice; + let scaled_w = (src_w as f32 * scale).round() as usize; + let scaled_h = (src_h as f32 * scale).round() as usize; + let mut pixels = Vec::new(); + for row in 0..scaled_h { + let src_row = ((row as f32 / scale) as usize).min(src_h - 1); + for col in 0..scaled_w { + let src_col = ((col as f32 / scale) as usize).min(src_w - 1); + if !lit(src_col, src_row) { + continue; + } + let (x, y) = (origin.0 + col, origin.1 + row); + if x < width && y < height { + pixels.push((x, y, src_col, src_row)); } } } pixels } +/// A bitmap of `lines` set in this module's pixel font, scaled to fill +/// `fill` of the lattice's width (or of its height, whichever binds +/// first) and centred on a `width` x `height` lattice: `true` where a +/// glyph pixel is on. This is how a *mark* — something short enough to +/// read inside an icon, like "did" over "bot" — gets a field to +/// crystallise into, and [`didbot_mask`] is the same idea for the +/// wordmark itself. +/// +/// # Panics +/// +/// If any character is not one this font has — the alphabet is the six +/// "did.bot" needs, plus a space — which is a caller bug. +pub fn text_mask(lines: &[&str], width: usize, height: usize, fill: f32) -> Vec { + let rows: Vec> = lines + .iter() + .map(|line| { + line.chars() + .map(|ch| { + glyphs::for_char(ch) + .unwrap_or_else(|| panic!("no glyph for {ch:?} in this font")) + }) + .collect() + }) + .collect(); + let src_w = rows + .iter() + .map(|line| line.len() * GLYPH_COLS + line.len().saturating_sub(1) * GLYPH_GAP) + .max() + .unwrap_or(0); + let line_pitch = GLYPH_ROWS + LINE_GAP; + let src_h = rows.len() * GLYPH_ROWS + rows.len().saturating_sub(1) * LINE_GAP; + let mut mask = vec![false; width * height]; + if src_w == 0 || src_h == 0 { + return mask; + } + + // One scale for both axes, so a mark's letterforms are the same shape + // as the wordmark's however oblong the lattice under it is. + let scale = ((width as f32 * fill) / src_w as f32) + .min((height as f32 * fill) / src_h as f32) + .max(1.0); + let scaled_w = (src_w as f32 * scale).round() as usize; + let scaled_h = (src_h as f32 * scale).round() as usize; + let origin = ( + width.saturating_sub(scaled_w) / 2, + height.saturating_sub(scaled_h) / 2, + ); + + for (x, y, _, _) in blit_scaled( + (src_w, src_h), + scale, + origin, + (width, height), + |col, row| { + let line_index = row / line_pitch; + let row_in_line = row % line_pitch; + let Some(line) = rows.get(line_index) else { + return false; + }; + if row_in_line >= GLYPH_ROWS { + return false; // the gap between two lines + } + // Each line is centred in the source bitmap rather than left-set, + // so a short line over a long one reads as a lockup. + let line_cols = line.len() * GLYPH_COLS + line.len().saturating_sub(1) * GLYPH_GAP; + let indent = src_w.saturating_sub(line_cols) / 2; + let Some(col) = col.checked_sub(indent) else { + return false; + }; + let glyph_index = col / (GLYPH_COLS + GLYPH_GAP); + let col_in_glyph = col % (GLYPH_COLS + GLYPH_GAP); + if glyph_index >= line.len() || col_in_glyph >= GLYPH_COLS { + return false; + } + let bit = GLYPH_COLS - 1 - col_in_glyph; + (line[glyph_index][row_in_line] >> bit) & 1 == 1 + }, + ) { + mask[y * width + x] = true; + } + mask +} + /// Builds an external-field bitmap spelling "did.bot", sized to a /// `width` x `height` lattice: `strength` where a glyph pixel is on, /// `-strength` everywhere else. Meant to be applied only in a final @@ -497,11 +636,30 @@ fn glyph_pixels(width: usize, height: usize) -> Vec<(usize, usize, usize)> { /// so the letters reliably stand out regardless of which of the two /// equivalent ordered states the lattice settled into first. pub fn didbot_field(width: usize, height: usize, strength: f32) -> Vec { - let mut field = vec![-strength; width * height]; + field_from_mask(&didbot_mask(width, height), strength) +} + +/// The "did.bot" wordmark as a bitmap on a `width` x `height` lattice: +/// `true` exactly where [`didbot_field`] biases positive. The shape the +/// anneal is aiming at, without the strength baked in, which is what a +/// caller comparing a finished lattice against the target wants (see +/// `anneal.rs`'s crystallisation test) and what +/// [`crate::anneal::run_full_anneal_with_mask`] takes. +pub fn didbot_mask(width: usize, height: usize) -> Vec { + let mut mask = vec![false; width * height]; for (x, y, _glyph_index) in glyph_pixels(width, height) { - field[y * width + x] = strength; + mask[y * width + x] = true; } - field + mask +} + +/// An external-field bitmap from any mask: `strength` where the mask is +/// on, `-strength` everywhere else. The negative background bias is not +/// optional — see [`didbot_field`] for why. +pub fn field_from_mask(mask: &[bool], strength: f32) -> Vec { + mask.iter() + .map(|&on| if on { strength } else { -strength }) + .collect() } /// The "did.bot" wordmark's bounding box on a `width` x `height` lattice, @@ -771,6 +929,84 @@ mod tests { assert!(field.iter().all(|&v| v == 3.0 || v == -3.0)); } + /// The mask and the field are two views of one shape, and the anneal + /// now takes the first while the page still asks for the second. + #[test] + fn the_didbot_mask_is_exactly_where_the_didbot_field_is_positive() { + let field = didbot_field(220, 160, 2.5); + let mask = didbot_mask(220, 160); + assert_eq!(field.len(), mask.len()); + for (index, (value, lit)) in field.iter().zip(&mask).enumerate() { + assert_eq!(*value > 0.0, *lit, "site {index}"); + } + } + + /// A mark set from the same font sits inside its lattice, centred, + /// and lights a sensible minority of it — the property an icon needs + /// and the wordmark's own layout cannot provide (it is hung off its + /// "." and sized for a wide lattice). + #[test] + fn a_two_line_mark_is_centred_and_fits_the_lattice() { + let (width, height) = (64, 64); + let mask = text_mask(&["did", "bot"], width, height, 0.72); + let lit: Vec<(usize, usize)> = mask + .iter() + .enumerate() + .filter(|(_, on)| **on) + .map(|(index, _)| (index % width, index / width)) + .collect(); + assert!(!lit.is_empty(), "the mark lit nothing"); + let min_x = lit.iter().map(|(x, _)| *x).min().unwrap(); + let max_x = lit.iter().map(|(x, _)| *x).max().unwrap(); + let min_y = lit.iter().map(|(_, y)| *y).min().unwrap(); + let max_y = lit.iter().map(|(_, y)| *y).max().unwrap(); + // Centred to within a site on both axes: the margins either side + // can differ by the one site an odd remainder leaves over. + assert!( + (min_x as i32 - (width - 1 - max_x) as i32).abs() <= 1, + "horizontal margins {min_x} and {}", + width - 1 - max_x + ); + assert!( + (min_y as i32 - (height - 1 - max_y) as i32).abs() <= 1, + "vertical margins {min_y} and {}", + height - 1 - max_y + ); + let fraction = lit.len() as f32 / mask.len() as f32; + assert!( + (0.05..0.5).contains(&fraction), + "a mark covering {fraction} of its lattice is not a mark" + ); + } + + /// Two lines are one lockup, not two words: the shorter line is + /// centred over the longer one rather than left-set. + #[test] + fn a_short_line_is_centred_over_a_long_one() { + let (width, height) = (64, 64); + let mask = text_mask(&["d", "bot"], width, height, 0.72); + let row_span = |wanted: usize| { + let lit: Vec = (0..width) + .filter(|&x| { + (0..height).any(|y| mask[y * width + x] && (y < height / 2) == (wanted == 0)) + }) + .collect(); + (*lit.first().unwrap(), *lit.last().unwrap()) + }; + let (top_min, top_max) = row_span(0); + let (bottom_min, bottom_max) = row_span(1); + let top_centre = top_min + top_max; + let bottom_centre = bottom_min + bottom_max; + // Compared as sums, so this is half of it in sites; the slack + // is there because a glyph's ink is not centred in its own cell + // ("d" has none in its leftmost column), which shifts a + // one-glyph line's ink off the centre the *cells* share. + assert!( + (top_centre as i32 - bottom_centre as i32).abs() <= 6, + "the lines are not stacked on one centre line: {top_centre} and {bottom_centre}" + ); + } + #[test] fn the_d_glyph_has_a_full_height_ascender_stem_not_a_rounded_bowl_top() { // Regression test for a real, shipped defect: an earlier bitmap's diff --git a/scripts/build-brand.sh b/scripts/build-brand.sh new file mode 100755 index 00000000..a865cafe --- /dev/null +++ b/scripts/build-brand.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Rebuilds the brand images from the simulation that draws the landing page, +# into site/public/, where Astro's passthrough copies them verbatim into +# dist/. +# +# Unlike the wasm build, this needs no toolchain the workspace does not +# already have: it is one native `cargo run`. And unlike the wasm output, its +# results are committed — a favicon that only exists after someone runs a +# script is a favicon that is missing from the first deploy of a fresh +# checkout. Committing generated files is only honest if something proves +# they are current, which is what `--check` is for: +# +# scripts/build-brand.sh # rewrite them +# scripts/build-brand.sh --check # fail if the tree is stale +# +# The check is a CI step (scripts/ci.sh), not a commit hook: a full anneal of +# every mark is tens of seconds, which is not a wait to put in front of every +# commit. +# +# What gets written, at what size, from which mark is crates/didbot-brand's +# ASSETS list and nowhere else. Adding one is an entry there, then this. +set -euo pipefail + +cd "$(dirname "$0")/.." + +# Release, not debug: the anneal is 1,200 sweeps over every site of every +# mark's lattice, which is a couple of seconds optimised and minutes not. +exec cargo run --release --quiet --package didbot-brand --bin didbot-brand -- \ + "$@" site/public