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/// Hand-drawn ASCII is usually many separate strokes, not one connected line.
135/// When the ink is fragmented the spine **bridges small gaps** so the whole body
136/// still traces as one path, head to tail; when it is already mostly connected it
137/// is traced strictly, with no shortcuts. The switch is automatic (see
138/// [`Spine::solve`] and [`Geodesic::bridge`]).
139///
140/// Detached ink (shading, flecks, a signature) does **not** dump at the end.
141/// Every cell inherits the rank of the nearest spine cell, a geodesic Voronoi
142/// computed by a multi-source flood, so detail reveals in step with the body it
143/// sits beside. Both behaviours fall out of one metric; neither is a special
144/// case, so imperfect hand-drawn art still reveals gracefully.
145#[derive(Clone, Copy, Debug)]
146pub struct Geodesic {
147    /// Which tip of the spine the reveal begins from.
148    pub start: StartHint,
149    /// The largest gap, in blank cells, the spine may step across. Bridging only
150    /// engages when the art is actually fragmented (see [`Spine::solve`]), so it
151    /// stitches the separate strokes of hand-drawn ASCII into one body without ever
152    /// adding shortcuts to art that was already connected. `0` disables it.
153    pub bridge: u16,
154}
155
156impl Default for Geodesic {
157    /// Start at the top-left tip and bridge single-cell gaps when the art is
158    /// fragmented, which is what most hand-drawn ASCII needs.
159    fn default() -> Self {
160        Geodesic {
161            start: StartHint::default(),
162            bridge: 1,
163        }
164    }
165}
166
167/// Which end of the spine the [`Geodesic`] reveal starts at.
168#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
169pub enum StartHint {
170    /// The tip nearest the top-left. Deterministic and reads like text. (default)
171    #[default]
172    TopLeft,
173    /// The tip nearest the bottom of the canvas.
174    Bottom,
175    /// Whichever diameter endpoint the sweep happens to find, purely topological.
176    Topological,
177}
178
179/// Diagnostics describing how well a piece of art suits geodesic reveal.
180///
181/// A low `connected_cells / ink_cells` ratio means the art is fragmented and
182/// will lean on the Voronoi inheritance rather than the spine itself.
183#[derive(Clone, Copy, Debug)]
184pub struct GeodesicReport {
185    pub ink_cells: usize,
186    /// Size of the largest connected component (the spine's component).
187    pub connected_cells: usize,
188    /// Length of the spine in cells (the component's graph diameter).
189    pub spine_length: u32,
190}
191
192impl Geodesic {
193    /// Inspect the art without building a full rank map.
194    pub fn diagnose(&self, art: &Art) -> GeodesicReport {
195        match Spine::solve(art, self.start, self.bridge) {
196            Some(spine) => GeodesicReport {
197                ink_cells: art.ink_count(),
198                connected_cells: spine.dist.iter().filter(|d| d.is_some()).count(),
199                spine_length: spine.diameter,
200            },
201            None => GeodesicReport {
202                ink_cells: 0,
203                connected_cells: 0,
204                spine_length: 0,
205            },
206        }
207    }
208}
209
210impl Ordering for Geodesic {
211    fn rank(&self, art: &Art) -> RankMap {
212        let w = art.width();
213        let h = art.height();
214        let mut map = RankMap::new(w, h);
215
216        let Some(spine) = Spine::solve(art, self.start, self.bridge) else {
217            return map; // no ink
218        };
219
220        let diameter = spine.diameter as f32;
221        let norm = |d: u32| {
222            if diameter > 0.0 {
223                d as f32 / diameter
224            } else {
225                0.0
226            }
227        };
228
229        // Multi-source flood over the *whole grid*, seeded with the spine's
230        // ranks. Every cell, including detached islands, inherits the rank of
231        // its nearest spine cell (a geodesic Voronoi). Detail thus reveals in
232        // step with the body part it sits beside, rather than all at the end.
233        let cells = w as usize * h as usize;
234        let mut inherited: Vec<Option<f32>> = vec![None; cells];
235        let mut queue = VecDeque::new();
236        for (i, dist) in spine.dist.iter().enumerate() {
237            if let Some(d) = dist {
238                inherited[i] = Some(norm(*d));
239                queue.push_back(i);
240            }
241        }
242        while let Some(cur) = queue.pop_front() {
243            let rank = inherited[cur];
244            for ni in neighbours(cur, w, h) {
245                if inherited[ni].is_none() {
246                    inherited[ni] = rank;
247                    queue.push_back(ni);
248                }
249            }
250        }
251
252        for cell in art.ink_cells() {
253            let rank = inherited[art.index(cell.x, cell.y)].unwrap_or(0.0);
254            map.set(cell.x, cell.y, rank);
255        }
256        map
257    }
258}
259
260/// If the largest strictly 8-connected component covers at least this fraction of
261/// the ink, the art is treated as already whole and traced without bridging.
262const STRICT_CONNECTED_MIN: f32 = 0.6;
263
264/// The traced spine of the art's largest connected component.
265struct Spine {
266    /// Geodesic distance from the chosen start within the largest component;
267    /// `None` for every cell outside it.
268    dist: Vec<Option<u32>>,
269    /// The component's diameter (maximum geodesic distance).
270    diameter: u32,
271}
272
273impl Spine {
274    fn solve(art: &Art, hint: StartHint, bridge: u16) -> Option<Self> {
275        // If the art is already mostly one 8-connected piece, trace it strictly;
276        // only stitch gaps when it is genuinely fragmented. Bridging then fixes
277        // hand-drawn art split into strokes without adding shortcuts across art
278        // that was already whole (which would shorten the spine and cut corners).
279        let strict_seed = largest_component_seed(art, 0)?;
280        let strict_size = bfs(art, strict_seed, 0)
281            .0
282            .iter()
283            .filter(|d| d.is_some())
284            .count();
285        let bridge = if (strict_size as f32) < STRICT_CONNECTED_MIN * art.ink_count().max(1) as f32
286        {
287            bridge
288        } else {
289            0
290        };
291
292        // Double sweep → the two ends (A, B) of the component's longest geodesic.
293        let seed = if bridge == 0 {
294            strict_seed
295        } else {
296            largest_component_seed(art, bridge)?
297        };
298        let (_, far_a) = bfs(art, seed, bridge);
299        let (dist_a, far_b) = bfs(art, far_a, bridge);
300        let (dist_b, _) = bfs(art, far_b, bridge);
301
302        // Pick which endpoint to start from.
303        let w = art.width() as usize;
304        let coord = |i: usize| ((i % w) as u16, (i / w) as u16);
305        let (ax, ay) = coord(far_a);
306        let (bx, by) = coord(far_b);
307        let start_is_a = match hint {
308            StartHint::Topological => true,
309            StartHint::TopLeft => (ay, ax) <= (by, bx),
310            StartHint::Bottom => ay >= by,
311        };
312
313        let dist = if start_is_a { dist_a } else { dist_b };
314        let diameter = dist.iter().flatten().copied().max().unwrap_or(0);
315        Some(Spine { dist, diameter })
316    }
317}
318
319// ---------------------------------------------------------------------------
320// Internal graph helpers (8-connectivity).
321// ---------------------------------------------------------------------------
322
323/// The in-bounds 8-neighbours of a flat grid index.
324fn neighbours(index: usize, w: u16, h: u16) -> impl Iterator<Item = usize> {
325    let (w, h) = (w as i32, h as i32);
326    let (cx, cy) = ((index as i32 % w), (index as i32 / w));
327    (-1..=1)
328        .flat_map(move |dy| (-1..=1).map(move |dx| (dx, dy)))
329        .filter_map(move |(dx, dy)| {
330            if dx == 0 && dy == 0 {
331                return None;
332            }
333            let (nx, ny) = (cx + dx, cy + dy);
334            (nx >= 0 && ny >= 0 && nx < w && ny < h).then_some((ny * w + nx) as usize)
335        })
336}
337
338/// A seed cell in the largest ink component (`None` if no ink). With `bridge > 0`
339/// the component spans gaps of that many blank cells.
340fn largest_component_seed(art: &Art, bridge: u16) -> Option<usize> {
341    let (w, h) = (art.width(), art.height());
342    let mut visited = vec![false; w as usize * h as usize];
343    let mut queue = VecDeque::new();
344    let mut best: Option<(usize, usize)> = None; // (size, seed)
345
346    for cell in art.ink_cells() {
347        let seed = art.index(cell.x, cell.y);
348        if visited[seed] {
349            continue;
350        }
351        let mut size = 0usize;
352        visited[seed] = true;
353        queue.push_back(seed);
354        while let Some(cur) = queue.pop_front() {
355            size += 1;
356            for ni in bridged_neighbours(art, cur, bridge) {
357                if !visited[ni] {
358                    visited[ni] = true;
359                    queue.push_back(ni);
360                }
361            }
362        }
363        if best.map_or(true, |(best_size, _)| size > best_size) {
364            best = Some((size, seed));
365        }
366    }
367    best.map(|(_, seed)| seed)
368}
369
370/// BFS from `source` over ink cells, stepping across gaps of up to `bridge` blank
371/// cells. Returns the distance to every cell (`None` where unreachable) and the
372/// farthest reachable cell.
373fn bfs(art: &Art, source: usize, bridge: u16) -> (Vec<Option<u32>>, usize) {
374    let (w, h) = (art.width(), art.height());
375    let mut dist = vec![None; w as usize * h as usize];
376    let mut queue = VecDeque::new();
377
378    dist[source] = Some(0);
379    queue.push_back(source);
380    let (mut farthest, mut far_d) = (source, 0u32);
381
382    while let Some(cur) = queue.pop_front() {
383        let d = dist[cur].unwrap();
384        if d > far_d {
385            far_d = d;
386            farthest = cur;
387        }
388        for ni in bridged_neighbours(art, cur, bridge) {
389            if dist[ni].is_none() {
390                dist[ni] = Some(d + 1);
391                queue.push_back(ni);
392            }
393        }
394    }
395    (dist, farthest)
396}
397
398/// Ink cells within Chebyshev distance `bridge + 1` of `index`, so `bridge = 0`
399/// is plain 8-connectivity and larger values let the spine step across small gaps
400/// between the separate strokes of hand-drawn art.
401fn bridged_neighbours(art: &Art, index: usize, bridge: u16) -> Vec<usize> {
402    let (w, h) = (art.width() as i32, art.height() as i32);
403    let r = bridge as i32 + 1;
404    let (cx, cy) = (index as i32 % w, index as i32 / w);
405    let mut out = Vec::new();
406    for dy in -r..=r {
407        for dx in -r..=r {
408            if dx == 0 && dy == 0 {
409                continue;
410            }
411            let (nx, ny) = (cx + dx, cy + dy);
412            if nx >= 0 && ny >= 0 && nx < w && ny < h {
413                let ni = (ny * w + nx) as usize;
414                if is_ink_index(art, ni) {
415                    out.push(ni);
416                }
417            }
418        }
419    }
420    out
421}
422
423#[inline]
424fn is_ink_index(art: &Art, index: usize) -> bool {
425    let w = art.width() as usize;
426    art.is_ink((index % w) as u16, (index / w) as u16)
427}
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432
433    /// A straight horizontal stroke must reveal strictly along its length, i.e.
434    /// ranks increase monotonically (in one direction) and reach 1.0.
435    #[test]
436    fn straight_line_reveals_along_itself() {
437        let art = Art::parse("=========");
438        let ranks = Geodesic::default().rank(&art);
439        let row: Vec<f32> = (0..art.width())
440            .map(|x| ranks.rank_at(x, 0).unwrap())
441            .collect();
442        let increasing = row.windows(2).all(|w| w[0] <= w[1]);
443        let decreasing = row.windows(2).all(|w| w[0] >= w[1]);
444        assert!(
445            increasing || decreasing,
446            "spine reveal was not monotone: {row:?}"
447        );
448        assert!((row.iter().cloned().fold(0.0_f32, f32::max) - 1.0).abs() < 1e-6);
449    }
450
451    /// A lone fleck at the top-left must not become the spine; the long bar does.
452    #[test]
453    fn spine_traces_largest_component() {
454        let art = Art::parse(".\n\n   ========");
455        let report = Geodesic::default().diagnose(&art);
456        assert_eq!(report.ink_cells, 9);
457        assert_eq!(report.connected_cells, 8); // the bar, not the 1-cell fleck
458    }
459
460    /// Islands inherit the rank of the nearest spine tip: an island by the start
461    /// reveals early, one by the finish reveals late, not both dumped at the end.
462    #[test]
463    fn islands_inherit_nearest_spine_rank() {
464        let art = Art::parse(".  ======  .");
465        let ranks = Geodesic::default().rank(&art);
466        let left = ranks.rank_at(0, 0).unwrap();
467        let right = ranks.rank_at(11, 0).unwrap();
468        assert!(left < right, "left {left} should precede right {right}");
469        assert!(left < 0.25 && right > 0.75, "left={left} right={right}");
470    }
471
472    #[test]
473    fn diagnose_counts_connectivity() {
474        let report = Geodesic::default().diagnose(&Art::parse("==========    ."));
475        assert_eq!(report.ink_cells, 11);
476        assert_eq!(report.connected_cells, 10); // the bar; the '.' is an island
477    }
478
479    /// Fragmented art (two strokes one blank cell apart) reveals as one body: the
480    /// default bridges the gap, while `bridge: 0` keeps the strokes separate.
481    #[test]
482    fn bridges_small_gaps_when_fragmented() {
483        let art = Art::parse("== ==");
484        let strict = Geodesic {
485            start: StartHint::TopLeft,
486            bridge: 0,
487        };
488        assert_eq!(strict.diagnose(&art).connected_cells, 2);
489        assert_eq!(Geodesic::default().diagnose(&art).connected_cells, 4);
490    }
491
492    /// Already-connected art must not be bridged: shortcuts would cut across the
493    /// body and shrink the spine, so a clean stroke keeps its full-length trace.
494    #[test]
495    fn connected_art_is_not_bridged() {
496        // A zigzag whose passes sit two rows apart; bridging would short-circuit
497        // it, but since it is one strict component the spine stays long.
498        let art = Art::parse("####\n   #\n####\n#\n####");
499        let report = Geodesic::default().diagnose(&art);
500        assert_eq!(report.connected_cells, report.ink_cells);
501        assert!(
502            report.spine_length >= 9,
503            "spine was {}",
504            report.spine_length
505        );
506    }
507
508    /// `Auto` weights for terminal cells being about twice as tall as wide: art
509    /// that is wider than tall in cells but reads tall still paints top to bottom;
510    /// only genuinely wide art wipes sideways.
511    #[test]
512    fn directional_auto_accounts_for_cell_aspect() {
513        // 5 wide by 4 tall: more columns than rows, yet reads tall -> top to bottom.
514        let tall = Art::parse("#####\n#####\n#####\n#####");
515        let r = Directional(Direction::Auto).rank(&tall);
516        assert!(
517            r.rank_at(0, 0).unwrap() < r.rank_at(0, 3).unwrap(),
518            "top first"
519        );
520        assert_eq!(
521            r.rank_at(0, 0),
522            r.rank_at(4, 0),
523            "same row reveals together"
524        );
525
526        // 10 wide by 2 tall: genuinely wide -> left to right.
527        let wide = Art::parse("##########\n##########");
528        let rw = Directional(Direction::Auto).rank(&wide);
529        assert!(
530            rw.rank_at(0, 0).unwrap() < rw.rank_at(9, 0).unwrap(),
531            "left first"
532        );
533        assert_eq!(
534            rw.rank_at(0, 0),
535            rw.rank_at(0, 1),
536            "same column reveals together"
537        );
538    }
539}