Skip to main content

yog_book/
pattern.rs

1//! Patterns on a hexagonal grid (Hex Casting's), drawn as its book draws
2//! them: a start direction and a signature of turns — `w` straight on,
3//! `e`/`d` 60°/120° right, `q`/`a` 60°/120° left, `s` back — as a "zappy"
4//! line (each stroke broken into little noisy hops, the same every time),
5//! dark inside a light outline, a blue dot where it starts, faint dots on
6//! the points it passes.
7
8use serde::{Deserialize, Serialize};
9
10/// One pattern of a [`crate::BookPage::Pattern`] page.
11#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
12pub struct HexPattern {
13    /// The first stroke's direction: `EAST`, `SOUTH_EAST`, … (or `E`, `SE`, …).
14    pub startdir: String,
15    /// The turns after it.
16    #[serde(default)]
17    pub signature: String,
18}
19
20type V = (f32, f32);
21
22/// Directions clockwise from north-east, as axial `(q, r)` steps.
23const DIRS: [(i32, i32); 6] = [(1, -1), (1, 0), (0, 1), (-1, 1), (-1, 0), (0, -1)];
24
25fn dir_index(d: &str) -> Option<usize> {
26    Some(match d.trim().to_ascii_uppercase().as_str() {
27        "NE" | "NORTH_EAST" => 0,
28        "E" | "EAST" => 1,
29        "SE" | "SOUTH_EAST" => 2,
30        "SW" | "SOUTH_WEST" => 3,
31        "W" | "WEST" => 4,
32        "NW" | "NORTH_WEST" => 5,
33        _ => return None,
34    })
35}
36
37impl HexPattern {
38    /// The grid points it passes through, in order (axial `q`, `r`).
39    pub fn positions(&self) -> Vec<(i32, i32)> {
40        let Some(mut dir) = dir_index(&self.startdir) else { return Vec::new() };
41        let mut at = (0, 0);
42        let mut out = vec![at];
43        for c in self.signature.chars() {
44            let turn = match c {
45                'w' => 0,
46                'e' => 1,
47                'd' => 2,
48                's' => 3,
49                'a' => 4,
50                'q' => 5,
51                _ => continue,
52            };
53            at = (at.0 + DIRS[dir].0, at.1 + DIRS[dir].1);
54            out.push(at);
55            dir = (dir + turn) % 6;
56        }
57        out.push((at.0 + DIRS[dir].0, at.1 + DIRS[dir].1));
58        out
59    }
60}
61
62/// A grid point on screen (unit hexes; y down).
63fn to_px((q, r): (i32, i32)) -> V {
64    let s3 = 3f32.sqrt();
65    (s3 * q as f32 + s3 / 2.0 * r as f32, 1.5 * r as f32)
66}
67
68/// Indices of the points visited more than once.
69fn dup_indices(pos: &[(i32, i32)]) -> Vec<bool> {
70    let mut first = std::collections::HashMap::new();
71    let mut dup = vec![false; pos.len()];
72    for (i, p) in pos.iter().enumerate() {
73        match first.get(p) {
74            Some(&j) => {
75                dup[i] = true;
76                dup[j] = true;
77            }
78            None => {
79                first.insert(*p, i);
80            }
81        }
82    }
83    dup
84}
85
86// ── Minecraft's noise and trigonometry, as Hex Casting draws with them ──
87
88/// `SingleThreadedRandomSource`: Java's LCG.
89struct Lcg(i64);
90
91impl Lcg {
92    fn new(seed: i64) -> Lcg {
93        Lcg((seed ^ 0x5DEECE66D) & ((1 << 48) - 1))
94    }
95    fn next(&mut self, bits: u32) -> i32 {
96        self.0 = (self.0.wrapping_mul(0x5DEECE66D).wrapping_add(11)) & ((1 << 48) - 1);
97        (self.0 >> (48 - bits)) as i32
98    }
99    fn next_int(&mut self, bound: i32) -> i32 {
100        if bound & (bound - 1) == 0 {
101            return ((bound as i64 * self.next(31) as i64) >> 31) as i32;
102        }
103        loop {
104            let i = self.next(31);
105            let j = i % bound;
106            if i.wrapping_sub(j).wrapping_add(bound - 1) >= 0 {
107                return j;
108            }
109        }
110    }
111    fn next_double(&mut self) {
112        self.next(26);
113        self.next(27);
114    }
115}
116
117/// `SimplexNoise` (3D).
118struct Simplex([i32; 256]);
119
120const GRADIENT: [[i32; 3]; 16] = [
121    [1, 1, 0], [-1, 1, 0], [1, -1, 0], [-1, -1, 0], [1, 0, 1], [-1, 0, 1], [1, 0, -1], [-1, 0, -1],
122    [0, 1, 1], [0, -1, 1], [0, 1, -1], [0, -1, -1], [1, 1, 0], [0, -1, 1], [-1, 1, 0], [0, -1, -1],
123];
124
125impl Simplex {
126    fn new(rng: &mut Lcg) -> Simplex {
127        // (Its offsets, unused in 3D, still take their turn of the random.)
128        for _ in 0..3 {
129            rng.next_double();
130        }
131        let mut p = [0i32; 256];
132        for (i, v) in p.iter_mut().enumerate() {
133            *v = i as i32;
134        }
135        for l in 0..256 {
136            let j = rng.next_int(256 - l as i32) as usize;
137            p.swap(l, j + l);
138        }
139        Simplex(p)
140    }
141    fn p(&self, i: i32) -> i32 {
142        self.0[(i & 255) as usize]
143    }
144    fn corner(g: i32, x: f64, y: f64, z: f64) -> f64 {
145        let d = 0.6 - x * x - y * y - z * z;
146        if d < 0.0 {
147            return 0.0;
148        }
149        let d = d * d;
150        let g = GRADIENT[g as usize];
151        d * d * (g[0] as f64 * x + g[1] as f64 * y + g[2] as f64 * z)
152    }
153    fn value(&self, x: f64, y: f64, z: f64) -> f64 {
154        let s = (x + y + z) * 0.333_333_333_333_333_3;
155        let (i, j, k) = ((x + s).floor() as i32, (y + s).floor() as i32, (z + s).floor() as i32);
156        let t = (i + j + k) as f64 * 0.166_666_666_666_666_66;
157        let (x0, y0, z0) = (x - (i as f64 - t), y - (j as f64 - t), z - (k as f64 - t));
158        let (i1, j1, k1, i2, j2, k2) = if x0 >= y0 {
159            if y0 >= z0 {
160                (1, 0, 0, 1, 1, 0)
161            } else if x0 >= z0 {
162                (1, 0, 0, 1, 0, 1)
163            } else {
164                (0, 0, 1, 1, 0, 1)
165            }
166        } else if y0 < z0 {
167            (0, 0, 1, 0, 1, 1)
168        } else if x0 < z0 {
169            (0, 1, 0, 0, 1, 1)
170        } else {
171            (0, 1, 0, 1, 1, 0)
172        };
173        let g6 = 0.166_666_666_666_666_66;
174        let g3 = 0.333_333_333_333_333_3;
175        let (x1, y1, z1) = (x0 - i1 as f64 + g6, y0 - j1 as f64 + g6, z0 - k1 as f64 + g6);
176        let (x2, y2, z2) = (x0 - i2 as f64 + g3, y0 - j2 as f64 + g3, z0 - k2 as f64 + g3);
177        let (x3, y3, z3) = (x0 - 0.5, y0 - 0.5, z0 - 0.5);
178        let (ii, jj, kk) = (i & 255, j & 255, k & 255);
179        let g0 = self.p(ii + self.p(jj + self.p(kk))) % 12;
180        let g1 = self.p(ii + i1 + self.p(jj + j1 + self.p(kk + k1))) % 12;
181        let g2 = self.p(ii + i2 + self.p(jj + j2 + self.p(kk + k2))) % 12;
182        let g3i = self.p(ii + 1 + self.p(jj + 1 + self.p(kk + 1))) % 12;
183        32.0 * (Self::corner(g0, x0, y0, z0)
184            + Self::corner(g1, x1, y1, z1)
185            + Self::corner(g2, x2, y2, z2)
186            + Self::corner(g3i, x3, y3, z3))
187    }
188}
189
190fn noise() -> &'static Simplex {
191    static N: std::sync::OnceLock<Simplex> = std::sync::OnceLock::new();
192    N.get_or_init(|| Simplex::new(&mut Lcg::new(9001)))
193}
194
195/// Hex Casting's `getNoise`.
196fn get_noise(x: f64, y: f64, z: f64) -> f64 {
197    noise().value(x * 0.6, y * 0.6, z * 0.6) / 2.0
198}
199
200/// `Mth.sin`/`Mth.cos`: a 65536-step table.
201fn mth_sin(f: f32) -> f32 {
202    let i = ((f * 10430.378) as i32) & 0xFFFF;
203    (i as f64 * std::f64::consts::PI * 2.0 / 65536.0).sin() as f32
204}
205fn mth_cos(f: f32) -> f32 {
206    let i = ((f * 10430.378 + 16384.0) as i32) & 0xFFFF;
207    (i as f64 * std::f64::consts::PI * 2.0 / 65536.0).sin() as f32
208}
209
210/// Hex Casting's `makeZappy` (at rest: its book's patterns don't move).
211fn make_zappy(points: &[V], dups: &[bool], readability: f32, last_segment: f32, seed: f64) -> Vec<V> {
212    const HOPS: usize = 10;
213    const VARIANCE: f32 = 0.5;
214    const FLOW_IRREGULAR: f64 = 0.2;
215    let zappify = |pts: &[V], truncate_last: bool| -> Vec<V> {
216        let scale_variance = |t: f64| 1f64.min(8.0 * (0.5 - (0.5 - t).abs()));
217        let mut out = vec![pts[0]];
218        for i in 0..pts.len().saturating_sub(1) {
219            let (src, target) = (pts[i], pts[i + 1]);
220            let delta = (target.0 - src.0, target.1 - src.1);
221            let hop_dist = ((target.0 - src.0).powi(2) + (target.1 - src.1).powi(2)).sqrt() / HOPS as f32;
222            let max_variance = hop_dist * VARIANCE;
223            let max_j = if truncate_last && i == pts.len() - 2 {
224                (last_segment * HOPS as f32).round() as usize
225            } else {
226                HOPS
227            };
228            for j in 1..=max_j {
229                let progress = j as f64 / (HOPS + 1) as f64;
230                let pos = (src.0 + delta.0 * progress as f32, src.1 + delta.1 * progress as f32);
231                let minor = get_noise(i as f64, j as f64, 0.0) * FLOW_IRREGULAR;
232                let theta = (3.0 * get_noise(i as f64 + progress + minor, 1337.0, seed) * std::f64::consts::TAU) as f32;
233                let r = (get_noise(i as f64 + progress, 69420.0, seed) * max_variance as f64 * scale_variance(progress)) as f32;
234                out.push((pos.0 + r * mth_cos(theta), pos.1 + r * mth_sin(theta)));
235                if j == HOPS {
236                    out.push(target);
237                }
238            }
239        }
240        out
241    };
242    let mut out = Vec::new();
243    let mut chain: Vec<V> = Vec::new();
244    for i in 0..points.len().saturating_sub(1) {
245        let (head, tail) = (points[i], points[i + 1]);
246        let tangent = ((tail.0 - head.0) * readability, (tail.1 - head.1) * readability);
247        chain.push(if i != 0 && dups[i] { (head.0 + tangent.0, head.1 + tangent.1) } else { head });
248        if i == points.len() - 2 {
249            chain.push(tail);
250            out.extend(zappify(&chain, true));
251        } else if dups[i + 1] {
252            chain.push((tail.0 - tangent.0, tail.1 - tangent.1));
253            out.extend(zappify(&chain, false));
254            chain.clear();
255        }
256    }
257    out
258}
259
260/// Hex Casting's book colors (`READABLE_GRID_SCROLL_COLORS`) and strokes (`fromStroke(4)`).
261const OUTER: &str = "#d2c8c8";
262const INNER: &str = "#554d54";
263const START_DOT: &str = "#5b7bd7";
264const STROKE: f64 = 4.0;
265
266/// `patterns` as an SVG `w`×`h` (in book pixels), as Hex Casting's book
267/// lays them out: a grid of cells, one pattern centred in each.
268pub fn svg(patterns: &[HexPattern], w: f32, h: f32) -> String {
269    let (w, h) = (w as f64, h as f64);
270    let mut s = format!(r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {w} {h}">"#);
271    let n = patterns.len().max(1);
272    let cols = (n as f64).sqrt().ceil() as usize;
273    let rows = n.div_ceil(cols);
274    let (cell_w, cell_h) = (w / cols as f64, h / rows as f64);
275    for (k, pat) in patterns.iter().enumerate() {
276        let pos = pat.positions();
277        if pos.len() < 2 {
278            continue;
279        }
280        let dots: Vec<V> = pos.iter().map(|&p| to_px(p)).collect();
281        let zappy = make_zappy(&dots, &dup_indices(&pos), 0.2, 0.8, 0.0);
282        // HexPatternPoints: fit into the cell (padding 2, base scale 16).
283        let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
284        for &(x, y) in &zappy {
285            x0 = x0.min(x as f64);
286            x1 = x1.max(x as f64);
287            y0 = y0.min(y as f64);
288            y1 = y1.max(y as f64);
289        }
290        let base = 16.0 / 1.5;
291        let (bw, bh) = ((x1 - x0) * base, (y1 - y0) * base);
292        let mut scale = 1f64;
293        scale = scale.min((cell_h - 4.0 - STROKE) / bh);
294        scale = scale.min((cell_w - 4.0 - STROKE) / bw);
295        let fin = base * scale;
296        let (iw, ih) = (bw * scale + 4.0 + STROKE, bh * scale + 4.0 + STROKE);
297        let off_x = (iw - bw * scale) / 2.0 + (cell_w - iw).max(0.0) / 2.0;
298        let off_y = (ih - bh * scale) / 2.0 + (cell_h - ih).max(0.0) / 2.0;
299        let (cx, cy) = (cell_w * (k % cols) as f64, cell_h * (k / cols) as f64);
300        let at = |&(x, y): &V| (cx + (x as f64 - x0) * fin + off_x, cy + (y as f64 - y0) * fin + off_y);
301        let line: Vec<String> = zappy.iter().map(|p| {
302            let (x, y) = at(p);
303            format!("{x:.2},{y:.2}")
304        }).collect();
305        let line = line.join(" ");
306        for (color, width) in [(OUTER, STROKE), (INNER, STROKE * 2.0 / 5.0)] {
307            s += &format!(
308                r#"<polyline points="{line}" fill="none" stroke="{color}" stroke-width="{width}" stroke-linecap="round" stroke-linejoin="round"/>"#
309            );
310        }
311        // Dots: little hexagons.
312        let spot = |(x, y): (f64, f64), r: f64, fill: &str, opacity: f64| {
313            let pts: Vec<String> = (0..6)
314                .map(|i| {
315                    let t = i as f64 / 6.0 * std::f64::consts::TAU;
316                    format!("{:.2},{:.2}", x + t.cos() * r, y + t.sin() * r)
317                })
318                .collect();
319            format!(r#"<polygon points="{}" fill="{fill}" fill-opacity="{opacity}"/>"#, pts.join(" "))
320        };
321        s += &spot(at(&dots[0]), 0.8 * STROKE * 2.0 / 5.0, START_DOT, 1.0);
322        for d in &dots[1..] {
323            s += &spot(at(d), 0.4 * STROKE * 2.0 / 5.0, OUTER, 0.5);
324        }
325    }
326    s + "</svg>"
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    fn p(dir: &str, sig: &str) -> HexPattern {
334        HexPattern { startdir: dir.into(), signature: sig.into() }
335    }
336
337    #[test]
338    fn positions_follow_the_signature() {
339        // East, then straight on: three points in a row.
340        assert_eq!(p("EAST", "w").positions(), [(0, 0), (1, 0), (2, 0)]);
341        // `s` goes back where it came from.
342        assert_eq!(p("E", "s").positions(), [(0, 0), (1, 0), (0, 0)]);
343        // `qaq` (Mind's Reflection): five points.
344        assert_eq!(p("NORTH_EAST", "qaq").positions().len(), 5);
345        assert!(p("UP", "w").positions().is_empty(), "an unknown direction draws nothing");
346    }
347
348    #[test]
349    fn random_and_noise_match_minecraft() {
350        // The same LCG as java.util.Random: `new Random(0).nextInt()`.
351        assert_eq!(Lcg::new(0).next(32), -1155484576);
352        // The noise is smooth and in range.
353        let a = get_noise(0.5, 1337.0, 0.0);
354        assert!(a.abs() <= 0.5 && (a - get_noise(0.5001, 1337.0, 0.0)).abs() < 1e-3);
355        assert_eq!(mth_sin(0.0), 0.0);
356        assert!((mth_cos(0.0) - 1.0).abs() < 1e-6);
357    }
358
359    #[test]
360    fn zappy_lines_stop_short_and_stay_close() {
361        let pos = p("E", "w").positions();
362        let dots: Vec<V> = pos.iter().map(|&q| to_px(q)).collect();
363        let z = make_zappy(&dots, &dup_indices(&pos), 0.2, 0.8, 0.0);
364        // 1 + 10 hops + the target, then 8 hops of the last stroke.
365        assert_eq!(z.len(), 1 + 10 + 1 + 8);
366        assert!(z.iter().all(|&(x, y)| x >= -0.5 && x <= 3.7 && y.abs() < 0.5));
367    }
368
369    #[test]
370    fn svg_has_a_line_per_pattern() {
371        let s = svg(&[p("NE", "qaq"), p("E", "qaq")], 116.0, 64.0);
372        assert_eq!(s.matches("<polyline").count(), 4, "outline and line each");
373        assert!(s.contains(START_DOT));
374        assert!(s.starts_with("<svg") && s.ends_with("</svg>"));
375    }
376}