Skip to main content

inkling/
ordering.rs

1//! Orderings turn [`Art`] into a [`RankMap`].
2//!
3//! This is the single seam where "reveal the art in a way that *depends on the
4//! art*" lives. Implement [`Ordering`] and you control the choreography; the
5//! rest of the engine (rendering, easing, diffing) is oblivious to how ranks
6//! were chosen.
7
8use std::collections::VecDeque;
9
10use crate::{art::Art, rank::RankMap};
11
12/// Assigns every ink cell a reveal rank in `0..=1`.
13pub trait Ordering {
14    fn rank(&self, art: &Art) -> RankMap;
15}
16
17// ---------------------------------------------------------------------------
18// Scanline, the trivial geometric baseline.
19// ---------------------------------------------------------------------------
20
21/// Reveal in reading order: top-to-bottom, left-to-right.
22///
23/// The dullest possible ordering, included as a baseline and as a deterministic
24/// tie-breaker for richer strategies.
25#[derive(Clone, Copy, Debug, Default)]
26pub struct Scanline;
27
28impl Ordering for Scanline {
29    fn rank(&self, art: &Art) -> RankMap {
30        let mut map = RankMap::new(art.width(), art.height());
31        let cells: Vec<_> = art.ink_cells().collect();
32        let denom = cells.len().saturating_sub(1).max(1) as f32;
33        for (i, cell) in cells.iter().enumerate() {
34            map.set(cell.x, cell.y, i as f32 / denom);
35        }
36        map
37    }
38}
39
40// ---------------------------------------------------------------------------
41// Directional, a clean wipe along one axis.
42// ---------------------------------------------------------------------------
43
44/// The direction a [`Directional`] reveal sweeps.
45#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
46pub enum Direction {
47    /// Row by row from the top. Good for tall art. (default)
48    #[default]
49    TopToBottom,
50    /// Row by row from the bottom.
51    BottomToTop,
52    /// Column by column from the left.
53    LeftToRight,
54    /// Column by column from the right.
55    RightToLeft,
56    /// Top to bottom unless the art reads much wider than tall. The smart default.
57    Auto,
58}
59
60/// Reveal the art as a clean directional wipe, ranking each cell by its position
61/// along one axis. Predictable and intuitive: a tall dragon paints from the top, a
62/// wide serpent from the left, and nothing shows until the wipe reaches it. This is
63/// the [`Loader`](crate::Loader) default.
64#[derive(Clone, Copy, Debug)]
65pub struct Directional(pub Direction);
66
67impl Default for Directional {
68    /// `Auto`: top to bottom unless the art reads much wider than it is tall.
69    fn default() -> Self {
70        Directional(Direction::Auto)
71    }
72}
73
74impl Directional {
75    /// Left to right, or right to left under a right-to-left locale (read from
76    /// `LC_ALL` or `LANG`), so the wipe follows the reader's eye.
77    pub fn reading() -> Self {
78        let rtl = std::env::var("LC_ALL")
79            .or_else(|_| std::env::var("LANG"))
80            .map(|l| {
81                let l = l.to_ascii_lowercase();
82                ["ar", "he", "fa", "ur"].iter().any(|p| l.starts_with(p))
83            })
84            .unwrap_or(false);
85        Directional(if rtl {
86            Direction::RightToLeft
87        } else {
88            Direction::LeftToRight
89        })
90    }
91}
92
93impl Ordering for Directional {
94    fn rank(&self, art: &Art) -> RankMap {
95        let (w, h) = (art.width(), art.height());
96        // Terminal cells are about twice as tall as they are wide, so art with
97        // more columns than rows can still read as a tall image. Only wipe
98        // sideways when it is genuinely wide, more than twice as many columns as
99        // rows; otherwise paint top to bottom, which is the intuitive read.
100        let dir = match self.0 {
101            Direction::Auto if w as u32 > 2 * h as u32 => Direction::LeftToRight,
102            Direction::Auto => Direction::TopToBottom,
103            other => other,
104        };
105        let dx = w.saturating_sub(1).max(1) as f32;
106        let dy = h.saturating_sub(1).max(1) as f32;
107        let mut map = RankMap::new(w, h);
108        for cell in art.ink_cells() {
109            let rank = match dir {
110                Direction::BottomToTop => (h - 1 - cell.y) as f32 / dy,
111                Direction::LeftToRight => cell.x as f32 / dx,
112                Direction::RightToLeft => (w - 1 - cell.x) as f32 / dx,
113                _ => cell.y as f32 / dy, // TopToBottom
114            };
115            map.set(cell.x, cell.y, rank);
116        }
117        map
118    }
119}
120
121// ---------------------------------------------------------------------------
122// Geodesic, trace the spine and reveal along it.
123// ---------------------------------------------------------------------------
124
125/// Reveal along the "spine" of the art.
126///
127/// The ink forms a graph under 8-connectivity. We take its **largest connected
128/// component** (so a stray fleck can never hijack the reveal), find the two ends
129/// of that component's longest geodesic, a double breadth-first sweep, the
130/// standard graph-diameter trick, and rank each cell by geodesic distance from
131/// the chosen start, normalised to `0..=1`. A serpent therefore paints from one
132/// tip to the other along its body, around every coil, with no per-art tuning.
133///
134/// Detached ink (shading, flecks, a signature) does **not** dump at the end.
135/// Every cell inherits the rank of the nearest spine cell, a geodesic Voronoi
136/// computed by a multi-source flood, so detail reveals in step with the body it
137/// sits beside. Both behaviours fall out of one metric; neither is a special
138/// case, so imperfect hand-drawn art still reveals gracefully.
139#[derive(Clone, Copy, Debug, Default)]
140pub struct Geodesic {
141    /// Which tip of the spine the reveal begins from.
142    pub start: StartHint,
143}
144
145/// Which end of the spine the [`Geodesic`] reveal starts at.
146#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
147pub enum StartHint {
148    /// The tip nearest the top-left. Deterministic and reads like text. (default)
149    #[default]
150    TopLeft,
151    /// The tip nearest the bottom of the canvas.
152    Bottom,
153    /// Whichever diameter endpoint the sweep happens to find, purely topological.
154    Topological,
155}
156
157/// Diagnostics describing how well a piece of art suits geodesic reveal.
158///
159/// A low `connected_cells / ink_cells` ratio means the art is fragmented and
160/// will lean on the Voronoi inheritance rather than the spine itself.
161#[derive(Clone, Copy, Debug)]
162pub struct GeodesicReport {
163    pub ink_cells: usize,
164    /// Size of the largest connected component (the spine's component).
165    pub connected_cells: usize,
166    /// Length of the spine in cells (the component's graph diameter).
167    pub spine_length: u32,
168}
169
170impl Geodesic {
171    /// Inspect the art without building a full rank map.
172    pub fn diagnose(&self, art: &Art) -> GeodesicReport {
173        match Spine::solve(art, self.start) {
174            Some(spine) => GeodesicReport {
175                ink_cells: art.ink_count(),
176                connected_cells: spine.dist.iter().filter(|d| d.is_some()).count(),
177                spine_length: spine.diameter,
178            },
179            None => GeodesicReport {
180                ink_cells: 0,
181                connected_cells: 0,
182                spine_length: 0,
183            },
184        }
185    }
186}
187
188impl Ordering for Geodesic {
189    fn rank(&self, art: &Art) -> RankMap {
190        let w = art.width();
191        let h = art.height();
192        let mut map = RankMap::new(w, h);
193
194        let Some(spine) = Spine::solve(art, self.start) else {
195            return map; // no ink
196        };
197
198        let diameter = spine.diameter as f32;
199        let norm = |d: u32| {
200            if diameter > 0.0 {
201                d as f32 / diameter
202            } else {
203                0.0
204            }
205        };
206
207        // Multi-source flood over the *whole grid*, seeded with the spine's
208        // ranks. Every cell, including detached islands, inherits the rank of
209        // its nearest spine cell (a geodesic Voronoi). Detail thus reveals in
210        // step with the body part it sits beside, rather than all at the end.
211        let cells = w as usize * h as usize;
212        let mut inherited: Vec<Option<f32>> = vec![None; cells];
213        let mut queue = VecDeque::new();
214        for (i, dist) in spine.dist.iter().enumerate() {
215            if let Some(d) = dist {
216                inherited[i] = Some(norm(*d));
217                queue.push_back(i);
218            }
219        }
220        while let Some(cur) = queue.pop_front() {
221            let rank = inherited[cur];
222            for ni in neighbours(cur, w, h) {
223                if inherited[ni].is_none() {
224                    inherited[ni] = rank;
225                    queue.push_back(ni);
226                }
227            }
228        }
229
230        for cell in art.ink_cells() {
231            let rank = inherited[art.index(cell.x, cell.y)].unwrap_or(0.0);
232            map.set(cell.x, cell.y, rank);
233        }
234        map
235    }
236}
237
238/// The traced spine of the art's largest connected component.
239struct Spine {
240    /// Geodesic distance from the chosen start within the largest component;
241    /// `None` for every cell outside it.
242    dist: Vec<Option<u32>>,
243    /// The component's diameter (maximum geodesic distance).
244    diameter: u32,
245}
246
247impl Spine {
248    fn solve(art: &Art, hint: StartHint) -> Option<Self> {
249        let seed = largest_component_seed(art)?;
250
251        // Double sweep → the two ends (A, B) of the component's longest geodesic.
252        let (_, far_a) = bfs(art, seed);
253        let (dist_a, far_b) = bfs(art, far_a);
254        let (dist_b, _) = bfs(art, far_b);
255
256        // Pick which endpoint to start from.
257        let w = art.width() as usize;
258        let coord = |i: usize| ((i % w) as u16, (i / w) as u16);
259        let (ax, ay) = coord(far_a);
260        let (bx, by) = coord(far_b);
261        let start_is_a = match hint {
262            StartHint::Topological => true,
263            StartHint::TopLeft => (ay, ax) <= (by, bx),
264            StartHint::Bottom => ay >= by,
265        };
266
267        let dist = if start_is_a { dist_a } else { dist_b };
268        let diameter = dist.iter().flatten().copied().max().unwrap_or(0);
269        Some(Spine { dist, diameter })
270    }
271}
272
273// ---------------------------------------------------------------------------
274// Internal graph helpers (8-connectivity).
275// ---------------------------------------------------------------------------
276
277/// The in-bounds 8-neighbours of a flat grid index.
278fn neighbours(index: usize, w: u16, h: u16) -> impl Iterator<Item = usize> {
279    let (w, h) = (w as i32, h as i32);
280    let (cx, cy) = ((index as i32 % w), (index as i32 / w));
281    (-1..=1)
282        .flat_map(move |dy| (-1..=1).map(move |dx| (dx, dy)))
283        .filter_map(move |(dx, dy)| {
284            if dx == 0 && dy == 0 {
285                return None;
286            }
287            let (nx, ny) = (cx + dx, cy + dy);
288            (nx >= 0 && ny >= 0 && nx < w && ny < h).then_some((ny * w + nx) as usize)
289        })
290}
291
292/// A seed cell in the largest 8-connected ink component (`None` if no ink).
293fn largest_component_seed(art: &Art) -> Option<usize> {
294    let (w, h) = (art.width(), art.height());
295    let mut visited = vec![false; w as usize * h as usize];
296    let mut queue = VecDeque::new();
297    let mut best: Option<(usize, usize)> = None; // (size, seed)
298
299    for cell in art.ink_cells() {
300        let seed = art.index(cell.x, cell.y);
301        if visited[seed] {
302            continue;
303        }
304        let mut size = 0usize;
305        visited[seed] = true;
306        queue.push_back(seed);
307        while let Some(cur) = queue.pop_front() {
308            size += 1;
309            for ni in neighbours(cur, w, h) {
310                if !visited[ni] && is_ink_index(art, ni) {
311                    visited[ni] = true;
312                    queue.push_back(ni);
313                }
314            }
315        }
316        if best.map_or(true, |(best_size, _)| size > best_size) {
317            best = Some((size, seed));
318        }
319    }
320    best.map(|(_, seed)| seed)
321}
322
323/// BFS from `source` over ink cells (8-connectivity). Returns the distance to
324/// every cell (`None` where unreachable) and the farthest reachable cell.
325fn bfs(art: &Art, source: usize) -> (Vec<Option<u32>>, usize) {
326    let (w, h) = (art.width(), art.height());
327    let mut dist = vec![None; w as usize * h as usize];
328    let mut queue = VecDeque::new();
329
330    dist[source] = Some(0);
331    queue.push_back(source);
332    let (mut farthest, mut far_d) = (source, 0u32);
333
334    while let Some(cur) = queue.pop_front() {
335        let d = dist[cur].unwrap();
336        if d > far_d {
337            far_d = d;
338            farthest = cur;
339        }
340        for ni in neighbours(cur, w, h) {
341            if dist[ni].is_none() && is_ink_index(art, ni) {
342                dist[ni] = Some(d + 1);
343                queue.push_back(ni);
344            }
345        }
346    }
347    (dist, farthest)
348}
349
350#[inline]
351fn is_ink_index(art: &Art, index: usize) -> bool {
352    let w = art.width() as usize;
353    art.is_ink((index % w) as u16, (index / w) as u16)
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359
360    /// A straight horizontal stroke must reveal strictly along its length, i.e.
361    /// ranks increase monotonically (in one direction) and reach 1.0.
362    #[test]
363    fn straight_line_reveals_along_itself() {
364        let art = Art::parse("=========");
365        let ranks = Geodesic::default().rank(&art);
366        let row: Vec<f32> = (0..art.width())
367            .map(|x| ranks.rank_at(x, 0).unwrap())
368            .collect();
369        let increasing = row.windows(2).all(|w| w[0] <= w[1]);
370        let decreasing = row.windows(2).all(|w| w[0] >= w[1]);
371        assert!(
372            increasing || decreasing,
373            "spine reveal was not monotone: {row:?}"
374        );
375        assert!((row.iter().cloned().fold(0.0_f32, f32::max) - 1.0).abs() < 1e-6);
376    }
377
378    /// A lone fleck at the top-left must not become the spine; the long bar does.
379    #[test]
380    fn spine_traces_largest_component() {
381        let art = Art::parse(".\n\n   ========");
382        let report = Geodesic::default().diagnose(&art);
383        assert_eq!(report.ink_cells, 9);
384        assert_eq!(report.connected_cells, 8); // the bar, not the 1-cell fleck
385    }
386
387    /// Islands inherit the rank of the nearest spine tip: an island by the start
388    /// reveals early, one by the finish reveals late, not both dumped at the end.
389    #[test]
390    fn islands_inherit_nearest_spine_rank() {
391        let art = Art::parse(".  ======  .");
392        let ranks = Geodesic::default().rank(&art);
393        let left = ranks.rank_at(0, 0).unwrap();
394        let right = ranks.rank_at(11, 0).unwrap();
395        assert!(left < right, "left {left} should precede right {right}");
396        assert!(left < 0.25 && right > 0.75, "left={left} right={right}");
397    }
398
399    #[test]
400    fn diagnose_counts_connectivity() {
401        let report = Geodesic::default().diagnose(&Art::parse("==========    ."));
402        assert_eq!(report.ink_cells, 11);
403        assert_eq!(report.connected_cells, 10); // the bar; the '.' is an island
404    }
405
406    /// `Auto` weights for terminal cells being about twice as tall as wide: art
407    /// that is wider than tall in cells but reads tall still paints top to bottom;
408    /// only genuinely wide art wipes sideways.
409    #[test]
410    fn directional_auto_accounts_for_cell_aspect() {
411        // 5 wide by 4 tall: more columns than rows, yet reads tall -> top to bottom.
412        let tall = Art::parse("#####\n#####\n#####\n#####");
413        let r = Directional(Direction::Auto).rank(&tall);
414        assert!(
415            r.rank_at(0, 0).unwrap() < r.rank_at(0, 3).unwrap(),
416            "top first"
417        );
418        assert_eq!(
419            r.rank_at(0, 0),
420            r.rank_at(4, 0),
421            "same row reveals together"
422        );
423
424        // 10 wide by 2 tall: genuinely wide -> left to right.
425        let wide = Art::parse("##########\n##########");
426        let rw = Directional(Direction::Auto).rank(&wide);
427        assert!(
428            rw.rank_at(0, 0).unwrap() < rw.rank_at(9, 0).unwrap(),
429            "left first"
430        );
431        assert_eq!(
432            rw.rank_at(0, 0),
433            rw.rank_at(0, 1),
434            "same column reveals together"
435        );
436    }
437}