Skip to main content

zenith_core/util/
pattern.rs

1//! Deterministic pattern instance-position computation.
2//!
3//! Computes the grid-cell or scatter-point offsets for a pattern's motif
4//! instances, expressed relative to the pattern's own bounds origin (i.e.
5//! `(0, 0)`-based within a `bounds_w × bounds_h` box). The caller adds the
6//! absolute bounds origin `(bx, by)` on top.
7//!
8//! Both layout kinds are fully deterministic: fixed loop order, pure integer
9//! bit-mixing via [`super::hash::hash_unit`]. Same inputs → same `Vec` on
10//! every machine.
11
12use super::hash::hash_unit;
13
14/// Parameters for a single pattern layout computation.
15///
16/// All fields carry their already-resolved values. The caller is responsible
17/// for applying defaults (`seed` → `0`, `jitter` → `0.0`) before constructing
18/// this struct.
19#[derive(Clone, Copy)]
20pub struct PatternLayout<'a> {
21    /// Layout kind: `"grid"` or `"scatter"`. Any other value yields an empty
22    /// result (validation has already flagged it).
23    pub kind: &'a str,
24    /// Width of the bounds box in px.
25    pub bounds_w: f64,
26    /// Height of the bounds box in px.
27    pub bounds_h: f64,
28    /// Cell spacing in px (`grid` only). Required to be `> 0`; ignored for
29    /// `scatter`.
30    pub spacing: Option<f64>,
31    /// Number of scatter instances (`scatter` only). Required to be `> 0`;
32    /// ignored for `grid`.
33    pub count: Option<i64>,
34    /// Seed passed to the bit-mixing hash for both jitter and scatter.
35    pub seed: i64,
36    /// Fraction of spacing applied as positional noise (`grid` only). `0.0`
37    /// disables jitter entirely.
38    pub jitter: f64,
39}
40
41/// Different seed mix for the vertical jitter axis so x and y jitter are
42/// uncorrelated for the same cell.
43const JITTER_Y_SEED_MIX: i64 = 0x5555;
44
45/// Compute deterministic instance offsets relative to the pattern bounds origin.
46///
47/// Returns a `Vec<(f64, f64)>` of `(ox, oy)` values, each in the range
48/// `[0, bounds_w) × [0, bounds_h)` for `scatter`, or spanning the lattice for
49/// `grid` (jitter may push a cell slightly outside the lattice but the clip
50/// in the caller handles that). The caller adds the absolute bounds origin
51/// `(bx, by)` to each offset before use.
52///
53/// Ordering:
54/// - `grid`: row-major — `(col=0,row=0)`, `(col=1,row=0)`, …, then next row.
55/// - `scatter`: ascending `i` from `0` to `count-1`.
56///
57/// Pure: no side effects, no randomness, no time, no allocations beyond the
58/// returned `Vec`.
59pub fn pattern_positions(p: PatternLayout<'_>) -> Vec<(f64, f64)> {
60    match p.kind {
61        "grid" => grid_positions(p),
62        "scatter" => scatter_positions(p),
63        _ => Vec::new(),
64    }
65}
66
67fn grid_positions(p: PatternLayout<'_>) -> Vec<(f64, f64)> {
68    let s = match p.spacing.filter(|&v| v > 0.0) {
69        Some(v) => v,
70        None => return Vec::new(),
71    };
72
73    let mut positions = Vec::new();
74    let mut row: i64 = 0;
75    while (row as f64) * s < p.bounds_h {
76        let mut col: i64 = 0;
77        while (col as f64) * s < p.bounds_w {
78            let base_x = (col as f64) * s;
79            let base_y = (row as f64) * s;
80            let (jx, jy) = if p.jitter > 0.0 {
81                let jx = (hash_unit(col, row, p.seed) * 2.0 - 1.0) * p.jitter * s;
82                let jy =
83                    (hash_unit(col, row, p.seed ^ JITTER_Y_SEED_MIX) * 2.0 - 1.0) * p.jitter * s;
84                (jx, jy)
85            } else {
86                (0.0, 0.0)
87            };
88            positions.push((base_x + jx, base_y + jy));
89            col += 1;
90        }
91        row += 1;
92    }
93    positions
94}
95
96fn scatter_positions(p: PatternLayout<'_>) -> Vec<(f64, f64)> {
97    let count = match p.count.filter(|&v| v > 0) {
98        Some(v) => v,
99        None => return Vec::new(),
100    };
101
102    let mut positions = Vec::with_capacity(count as usize);
103    for i in 0..count {
104        let ox = hash_unit(i, 0, p.seed) * p.bounds_w;
105        let oy = hash_unit(i, 1, p.seed) * p.bounds_h;
106        positions.push((ox, oy));
107    }
108    positions
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    fn layout_grid(
116        bw: f64,
117        bh: f64,
118        spacing: f64,
119        jitter: f64,
120        seed: i64,
121    ) -> PatternLayout<'static> {
122        PatternLayout {
123            kind: "grid",
124            bounds_w: bw,
125            bounds_h: bh,
126            spacing: Some(spacing),
127            count: None,
128            seed,
129            jitter,
130        }
131    }
132
133    fn layout_scatter(bw: f64, bh: f64, count: i64, seed: i64) -> PatternLayout<'static> {
134        PatternLayout {
135            kind: "scatter",
136            bounds_w: bw,
137            bounds_h: bh,
138            spacing: None,
139            count: Some(count),
140            seed,
141            jitter: 0.0,
142        }
143    }
144
145    #[test]
146    fn grid_exact_four_cells_no_jitter() {
147        // bounds 100×100, spacing 50, jitter 0
148        // rows: 0*50=0 < 100, 1*50=50 < 100, 2*50=100 not < 100 → rows 0,1
149        // cols: same → cols 0,1
150        // row-major: (0,0),(50,0),(0,50),(50,50)
151        let positions = pattern_positions(layout_grid(100.0, 100.0, 50.0, 0.0, 0));
152        assert_eq!(positions.len(), 4, "expected 4 cells; got {positions:?}");
153        assert_eq!(positions[0], (0.0, 0.0));
154        assert_eq!(positions[1], (50.0, 0.0));
155        assert_eq!(positions[2], (0.0, 50.0));
156        assert_eq!(positions[3], (50.0, 50.0));
157    }
158
159    #[test]
160    fn grid_missing_spacing_returns_empty() {
161        let p = PatternLayout {
162            kind: "grid",
163            bounds_w: 100.0,
164            bounds_h: 100.0,
165            spacing: None,
166            count: None,
167            seed: 0,
168            jitter: 0.0,
169        };
170        assert!(pattern_positions(p).is_empty());
171    }
172
173    #[test]
174    fn grid_zero_spacing_returns_empty() {
175        let positions = pattern_positions(layout_grid(100.0, 100.0, 0.0, 0.0, 0));
176        assert!(positions.is_empty());
177    }
178
179    #[test]
180    fn scatter_correct_count() {
181        let positions = pattern_positions(layout_scatter(200.0, 150.0, 5, 7));
182        assert_eq!(positions.len(), 5, "expected 5 scatter instances");
183    }
184
185    #[test]
186    fn scatter_within_bounds() {
187        let bw = 300.0;
188        let bh = 200.0;
189        let positions = pattern_positions(layout_scatter(bw, bh, 20, 42));
190        for (ox, oy) in &positions {
191            assert!(*ox >= 0.0 && *ox < bw, "scatter ox={ox} out of [0, {bw})");
192            assert!(*oy >= 0.0 && *oy < bh, "scatter oy={oy} out of [0, {bh})");
193        }
194    }
195
196    #[test]
197    fn scatter_missing_count_returns_empty() {
198        let p = PatternLayout {
199            kind: "scatter",
200            bounds_w: 100.0,
201            bounds_h: 100.0,
202            spacing: None,
203            count: None,
204            seed: 0,
205            jitter: 0.0,
206        };
207        assert!(pattern_positions(p).is_empty());
208    }
209
210    #[test]
211    fn scatter_zero_count_returns_empty() {
212        let positions = pattern_positions(layout_scatter(100.0, 100.0, 0, 0));
213        assert!(positions.is_empty());
214    }
215
216    #[test]
217    fn determinism_grid_same_input_same_output() {
218        let p = layout_grid(120.0, 80.0, 25.0, 0.4, 11);
219        assert_eq!(pattern_positions(p), pattern_positions(p));
220    }
221
222    #[test]
223    fn determinism_scatter_same_input_same_output() {
224        let p = layout_scatter(200.0, 200.0, 7, 99);
225        assert_eq!(pattern_positions(p), pattern_positions(p));
226    }
227
228    #[test]
229    fn unknown_kind_returns_empty() {
230        let p = PatternLayout {
231            kind: "hexagonal",
232            bounds_w: 100.0,
233            bounds_h: 100.0,
234            spacing: Some(20.0),
235            count: Some(10),
236            seed: 0,
237            jitter: 0.0,
238        };
239        assert!(pattern_positions(p).is_empty());
240    }
241}