Skip to main content

agent/avatar/
shape.rs

1//! The silhouette, as parameters rather than a roster: one radial profile
2//! `r(θ)` that every seed lands somewhere inside.
3
4use std::{
5    f32::consts::{FRAC_PI_2, FRAC_PI_4, PI, TAU},
6    sync::atomic::{AtomicU64, Ordering},
7};
8use web_time::{SystemTime, UNIX_EPOCH};
9
10/// Points the outline is sampled at. Corners land between samples, so the
11/// spline through them is rounded by the sampling itself.
12pub const SAMPLES: usize = 64;
13
14/// How close to the centre the outline may dip before the spline through it
15/// starts crossing itself.
16const FLOOR: f32 = 0.45;
17
18/// One harmonic of the outline: `k` bumps around the circle.
19#[derive(Clone, Copy, Debug, Default, PartialEq)]
20pub struct Lobe {
21    pub k: u8,
22    pub amp: f32,
23    pub phase: f32,
24}
25
26const NONE: Lobe = Lobe {
27    k: 0,
28    amp: 0.0,
29    phase: 0.0,
30};
31
32const fn lobe(k: u8, amp: f32, phase: f32) -> Lobe {
33    Lobe { k, amp, phase }
34}
35
36#[derive(Clone, Copy, Debug, PartialEq)]
37pub struct Shape {
38    pub lobes: [Lobe; 3],
39    /// Sides of the polygon the outline leans toward; under 3 there is none.
40    pub sides: u8,
41    /// How far it leans — 0 is a circle, 1 the polygon itself.
42    pub corner: f32,
43    /// Width against height, area held constant.
44    pub stretch: f32,
45    /// A pull toward a single point at the top, which is what makes a droplet.
46    pub taper: f32,
47    pub rot: f32,
48}
49
50impl Default for Shape {
51    fn default() -> Self {
52        Self::ROUND
53    }
54}
55
56impl Shape {
57    pub const ROUND: Self = Self {
58        lobes: [NONE; 3],
59        sides: 0,
60        corner: 0.0,
61        stretch: 1.0,
62        taper: 0.0,
63        rot: 0.0,
64    };
65    pub const EGG: Self = Self {
66        stretch: 0.93,
67        taper: 0.2,
68        ..Self::ROUND
69    };
70    pub const BEAN: Self = Self {
71        lobes: [lobe(2, 0.13, 0.9), NONE, NONE],
72        stretch: 1.1,
73        rot: 0.5,
74        ..Self::ROUND
75    };
76    pub const DROP: Self = Self {
77        stretch: 0.92,
78        taper: 0.44,
79        ..Self::ROUND
80    };
81    pub const BLOB: Self = Self {
82        lobes: [lobe(3, 0.09, 0.6), lobe(5, 0.035, 2.1), NONE],
83        rot: 0.4,
84        ..Self::ROUND
85    };
86    pub const CLOUD: Self = Self {
87        lobes: [lobe(5, 0.12, 1.2), lobe(2, 0.05, 0.3), NONE],
88        stretch: 1.1,
89        ..Self::ROUND
90    };
91    /// A quarter of the segment past the vertex the polygon term puts at the
92    /// top, which is what turns a diamond into a square.
93    pub const TILE: Self = Self {
94        sides: 4,
95        corner: 0.82,
96        rot: FRAC_PI_4,
97        ..Self::ROUND
98    };
99    pub const GEM: Self = Self {
100        sides: 6,
101        corner: 0.72,
102        rot: 0.26,
103        ..Self::ROUND
104    };
105    pub const SHARD: Self = Self {
106        sides: 3,
107        corner: 0.62,
108        stretch: 1.04,
109        ..Self::ROUND
110    };
111    pub const SUN: Self = Self {
112        lobes: [lobe(8, 0.11, 0.0), NONE, NONE],
113        ..Self::ROUND
114    };
115
116    /// The presets, for a picker that wants to name them.
117    pub const PRESETS: [(&'static str, Self); 10] = [
118        ("round", Self::ROUND),
119        ("egg", Self::EGG),
120        ("bean", Self::BEAN),
121        ("blob", Self::BLOB),
122        ("cloud", Self::CLOUD),
123        ("drop", Self::DROP),
124        ("tile", Self::TILE),
125        ("gem", Self::GEM),
126        ("shard", Self::SHARD),
127        ("sun", Self::SUN),
128    ];
129
130    /// A fresh silhouette, unrelated to the last one.
131    pub fn random() -> Self {
132        static COUNT: AtomicU64 = AtomicU64::new(0);
133        let nanos = SystemTime::now()
134            .duration_since(UNIX_EPOCH)
135            .map(|d| d.as_nanos() as u64)
136            .unwrap_or(0);
137        Self::from(nanos ^ COUNT.fetch_add(0x9e37_79b9_7f4a_7c15, Ordering::Relaxed))
138    }
139
140    /// The outline's distance from the centre at `theta`, before stretch.
141    pub fn radius(&self, theta: f32) -> f32 {
142        let a = theta + self.rot;
143        let mut r = 1.0;
144        if self.sides >= 3 {
145            // Shifted a quarter turn so a vertex sits at the top, which is
146            // where every polygon anyone draws by hand puts one.
147            let seg = TAU / self.sides as f32;
148            let phi = ((a + FRAC_PI_2) % seg + seg) % seg - seg / 2.0;
149            r += ((seg / 2.0).cos() / phi.cos() - 1.0) * self.corner;
150        }
151        for l in &self.lobes {
152            if l.k >= 2 {
153                r += l.amp * (l.k as f32 * a + l.phase).cos();
154            }
155        }
156        // Read off `theta` rather than the rotated angle: a point belongs at the
157        // top of the head, and one swung round to the side reads as an arrow.
158        // Falls away steeply, so a tapered body keeps its own curve at the sides.
159        let up = (1.0 - theta.sin()) * 0.5;
160        r += self.taper * up.powi(6);
161        r.max(FLOOR)
162    }
163
164    /// The outline in unit space, centred on the origin.
165    pub fn outline(&self) -> [(f32, f32); SAMPLES] {
166        std::array::from_fn(|i| {
167            let a = TAU * i as f32 / SAMPLES as f32;
168            let r = self.radius(a);
169            (r * a.cos() * self.stretch, r * a.sin() / self.stretch)
170        })
171    }
172
173    /// How far the outline sits in the direction of `(x, y)` — what anything
174    /// placed on the body has to measure itself against.
175    pub fn reach(&self, x: f32, y: f32) -> f32 {
176        let a = y.atan2(x);
177        let r = self.radius(a);
178        (r * a.cos() * self.stretch).hypot(r * a.sin() / self.stretch)
179    }
180}
181
182/// SplitMix64 — one multiply-xor round per value, and every seed is a legal
183/// starting point, which is what makes `random()` and a name the same thing.
184struct Rng(u64);
185
186impl Rng {
187    fn next(&mut self) -> u64 {
188        self.0 = self.0.wrapping_add(0x9e37_79b9_7f4a_7c15);
189        let mut z = self.0;
190        z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
191        z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
192        z ^ (z >> 31)
193    }
194
195    fn unit(&mut self) -> f32 {
196        (self.next() >> 40) as f32 / 16_777_216.0
197    }
198
199    fn range(&mut self, lo: f32, hi: f32) -> f32 {
200        lo + self.unit() * (hi - lo)
201    }
202
203    fn int(&mut self, lo: u8, hi: u8) -> u8 {
204        lo + (self.unit() * (hi - lo + 1) as f32) as u8
205    }
206}
207
208impl From<u64> for Shape {
209    fn from(seed: u64) -> Self {
210        let mut rng = Rng(seed);
211        let sides = if rng.unit() < 0.34 { rng.int(3, 8) } else { 0 };
212        let corner = if sides >= 3 { rng.range(0.2, 0.8) } else { 0.0 };
213
214        // The polygon term only ever pulls the outline inward, so what it takes
215        // is what the harmonics may not spend.
216        let waist = if sides >= 3 {
217            1.0 + corner * ((PI / sides as f32).cos() - 1.0)
218        } else {
219            1.0
220        };
221        let mut budget = (waist - FLOOR).clamp(0.0, 0.26);
222
223        let count = rng.int(1, 3);
224        let lobes = std::array::from_fn(|i| {
225            if i as u8 >= count || budget <= 0.01 {
226                return Lobe::default();
227            }
228            // Squared, so most bodies are gently lobed and a spiky one is a
229            // find — a uniform draw makes a starburst of nearly every seed.
230            let k = 2 + (rng.unit().powi(2) * 7.0) as u8;
231            // The same amplitude at k=8 that reads as a curve at k=3 reads as
232            // teeth, so what a lobe may spend falls as it gets finer.
233            let amp = budget * rng.range(0.35, 0.9) * (3.0 / k as f32).min(1.0);
234            budget -= amp;
235            lobe(k, amp, rng.range(0.0, TAU))
236        });
237
238        Self {
239            lobes,
240            sides,
241            corner,
242            stretch: rng.range(0.88, 1.14),
243            taper: if rng.unit() < 0.28 {
244                rng.range(0.08, 0.3)
245            } else {
246                0.0
247            },
248            rot: rng.range(0.0, TAU),
249        }
250    }
251}
252
253impl From<&str> for Shape {
254    fn from(name: &str) -> Self {
255        Self::from(seed(name))
256    }
257}
258
259/// FNV-1a over a canonical form of the name — lowercased, outer whitespace
260/// dropped and inner runs collapsed to one space — so the same person keeps the
261/// same face across whatever the keyboard did.
262///
263/// Composed and decomposed spellings still part ways: `café` written with `é`
264/// and with `e` + a combining acute are one name to a reader and two here.
265/// Normalizing them together needs a Unicode table, which is a dependency this
266/// crate does not carry.
267pub fn seed(name: &str) -> u64 {
268    let feed = |h: u64, b: u8| (h ^ b as u64).wrapping_mul(0x0000_0100_0000_01b3);
269    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
270    let (mut started, mut gap) = (false, false);
271    let mut buf = [0u8; 4];
272    for c in name.chars().flat_map(char::to_lowercase) {
273        if c.is_whitespace() {
274            // Only between characters, so leading and trailing runs never land.
275            gap = started;
276            continue;
277        }
278        if gap {
279            h = feed(h, b' ');
280            gap = false;
281        }
282        for b in c.encode_utf8(&mut buf).as_bytes() {
283            h = feed(h, *b);
284        }
285        started = true;
286    }
287    h
288}