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/// Evenly spaced ranks over `count` cells, so the first is `0.0` and the last
18/// `1.0` with no dead zone at either end.
19#[inline]
20fn even_step(count: usize) -> f32 {
21    count.saturating_sub(1).max(1) as f32
22}
23
24// ---------------------------------------------------------------------------
25// Scanline, the trivial geometric baseline.
26// ---------------------------------------------------------------------------
27
28/// Reveal in reading order: top-to-bottom, left-to-right.
29///
30/// The dullest possible ordering, included as a baseline and as a reference
31/// implementation of the [`Ordering`] trait.
32#[derive(Clone, Copy, Debug, Default)]
33pub struct Scanline;
34
35impl Ordering for Scanline {
36    fn rank(&self, art: &Art) -> RankMap {
37        let mut map = RankMap::new(art.width(), art.height());
38        let denom = even_step(art.ink_count());
39        for (i, cell) in art.ink_cells().enumerate() {
40            map.set(cell.x, cell.y, i as f32 / denom);
41        }
42        map
43    }
44}
45
46// ---------------------------------------------------------------------------
47// Directional, a clean wipe along one axis.
48// ---------------------------------------------------------------------------
49
50/// The direction a [`Directional`] reveal sweeps.
51#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
52pub enum Direction {
53    /// Row by row from the top. Good for tall art. (default)
54    #[default]
55    TopToBottom,
56    /// Row by row from the bottom.
57    BottomToTop,
58    /// Column by column from the left.
59    LeftToRight,
60    /// Column by column from the right.
61    RightToLeft,
62    /// Top to bottom unless the art reads much wider than tall. The smart default.
63    Auto,
64}
65
66/// Reveal the art as a clean directional wipe, ranking each cell by its position
67/// along one axis. Predictable and intuitive: a tall dragon paints from the top, a
68/// wide serpent from the left, and nothing shows until the wipe reaches it. This is
69/// the [`Loader`](crate::Loader) default.
70#[derive(Clone, Copy, Debug)]
71pub struct Directional(pub Direction);
72
73impl Default for Directional {
74    /// `Auto`: top to bottom unless the art reads much wider than it is tall.
75    fn default() -> Self {
76        Directional(Direction::Auto)
77    }
78}
79
80impl Directional {
81    /// Left to right: the wipe follows a left-to-right reader's eye.
82    pub fn ltr() -> Self {
83        Directional(Direction::LeftToRight)
84    }
85
86    /// Right to left, for Arabic, Hebrew, Persian, and Urdu layouts.
87    pub fn rtl() -> Self {
88        Directional(Direction::RightToLeft)
89    }
90
91    /// Wipe along the reading direction of the user's locale, so it follows the
92    /// reader's eye. Falls back to [`ltr`](Self::ltr) when the locale cannot be
93    /// determined.
94    ///
95    /// The locale comes from `LC_ALL` or `LANG` where those are set, and from the
96    /// user's default locale on Windows, where they usually are not. Call
97    /// [`ltr`](Self::ltr) or [`rtl`](Self::rtl) directly when your program already
98    /// knows its own text direction; that is always more reliable than sniffing.
99    pub fn reading() -> Self {
100        if locale_is_rtl() {
101            Self::rtl()
102        } else {
103            Self::ltr()
104        }
105    }
106}
107
108/// Language subtags written right to left.
109const RTL_LANGS: [&str; 4] = ["ar", "he", "fa", "ur"];
110
111fn locale_is_rtl() -> bool {
112    let tagged = |l: &str| {
113        let l = l.to_ascii_lowercase();
114        RTL_LANGS.iter().any(|p| l.starts_with(p))
115    };
116    if let Ok(l) = std::env::var("LC_ALL").or_else(|_| std::env::var("LANG")) {
117        return tagged(&l);
118    }
119    system_locale().map(|l| tagged(&l)).unwrap_or(false)
120}
121
122/// The user's default locale name, where the platform exposes one outside the
123/// environment. Windows does not set `LANG`, so without this every Windows user
124/// would be treated as left-to-right regardless of how their system is set up.
125#[cfg(windows)]
126fn system_locale() -> Option<String> {
127    // Declared directly rather than pulled from a crate: the core carries no
128    // dependencies, and this is one documented call into kernel32, which std
129    // already links.
130    #[link(name = "kernel32")]
131    extern "system" {
132        fn GetUserDefaultLocaleName(name: *mut u16, capacity: i32) -> i32;
133    }
134
135    // LOCALE_NAME_MAX_LENGTH is 85 wide chars.
136    let mut buf = [0u16; 85];
137    // SAFETY: the buffer outlives the call and its true capacity is passed.
138    let len = unsafe { GetUserDefaultLocaleName(buf.as_mut_ptr(), buf.len() as i32) };
139    if len <= 1 {
140        return None; // 0 on failure; 1 is just the trailing NUL
141    }
142    String::from_utf16(&buf[..len as usize - 1]).ok()
143}
144
145#[cfg(not(windows))]
146fn system_locale() -> Option<String> {
147    None
148}
149
150impl Ordering for Directional {
151    fn rank(&self, art: &Art) -> RankMap {
152        let (w, h) = (art.width(), art.height());
153        // Terminal cells are about twice as tall as they are wide, so art with
154        // more columns than rows can still read as a tall image. Only wipe
155        // sideways when it is genuinely wide, more than twice as many columns as
156        // rows; otherwise paint top to bottom, which is the intuitive read.
157        let dir = match self.0 {
158            Direction::Auto if is_wide(w, h) => Direction::LeftToRight,
159            Direction::Auto => Direction::TopToBottom,
160            other => other,
161        };
162        let dx = even_step(w as usize);
163        let dy = even_step(h as usize);
164        let mut map = RankMap::new(w, h);
165        for cell in art.ink_cells() {
166            let rank = match dir {
167                Direction::BottomToTop => (h - 1 - cell.y) as f32 / dy,
168                Direction::LeftToRight => cell.x as f32 / dx,
169                Direction::RightToLeft => (w - 1 - cell.x) as f32 / dx,
170                _ => cell.y as f32 / dy, // TopToBottom
171            };
172            map.set(cell.x, cell.y, rank);
173        }
174        map
175    }
176}
177
178/// True when the art reads as wide rather than tall, correcting for terminal
179/// cells being roughly twice as tall as they are wide.
180#[inline]
181fn is_wide(w: u16, h: u16) -> bool {
182    w as u32 > 2 * h as u32
183}
184
185// ---------------------------------------------------------------------------
186// Geodesic, trace the spine and reveal along it.
187// ---------------------------------------------------------------------------
188
189/// Reveal by tracing the art's skeleton.
190///
191/// The ink is first thinned to a one-cell-wide **skeleton** (Zhang-Suen), the
192/// centerline a pen would draw. Each connected piece of that skeleton is traced tip
193/// to tip by geodesic distance, a double breadth-first sweep finding the two ends of
194/// its longest path, and the pieces are ordered along the art's dominant axis. So a
195/// snake paints head to tail, a filled dragon paints down its spine, and a
196/// multi-letter logo paints letter by letter in reading order, with no per-art tuning.
197///
198/// Hand-drawn ASCII is usually many separate strokes, not one connected line, so the
199/// trace **bridges small gaps** to stitch a broken stroke into one piece; art that is
200/// already whole is traced strictly, with no shortcuts (see [`Geodesic::bridge`]).
201///
202/// The flesh around the skeleton inherits the value of its nearest centerline cell, a
203/// Voronoi flood, so detail reveals in step with the part of the spine it hangs from;
204/// where the skeleton is a mere dot, as in a solid blob, the fill radiates out from
205/// the middle. Finally the values are rank-transformed to evenly spaced ranks, so the
206/// reveal keeps its order yet tracks the progress bar with no dead zone at either end.
207#[derive(Clone, Copy, Debug)]
208pub struct Geodesic {
209    /// Which tip of the spine the reveal begins from.
210    pub start: StartHint,
211    /// The largest gap, in blank cells, the spine may step across. Bridging only
212    /// engages when the art is actually fragmented (see [`STRICT_CONNECTED_MIN`]),
213    /// so it stitches the separate strokes of hand-drawn ASCII into one body
214    /// without ever adding shortcuts to art that was already connected. `0`
215    /// disables it.
216    pub bridge: u16,
217}
218
219impl Default for Geodesic {
220    /// Start at the top-left tip and bridge single-cell gaps when the art is
221    /// fragmented, which is what most hand-drawn ASCII needs.
222    fn default() -> Self {
223        Geodesic {
224            start: StartHint::default(),
225            bridge: 1,
226        }
227    }
228}
229
230/// Which end of the spine the [`Geodesic`] reveal starts at.
231#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
232pub enum StartHint {
233    /// The tip nearest the top-left. Deterministic and reads like text. (default)
234    #[default]
235    TopLeft,
236    /// The tip nearest the bottom of the canvas.
237    Bottom,
238    /// Whichever diameter endpoint the sweep happens to find, purely topological.
239    Topological,
240}
241
242/// Diagnostics describing how well a piece of art suits geodesic reveal.
243///
244/// Every field describes the structure the reveal actually follows. A low
245/// `connected_cells / ink_cells` ratio means the ink is fragmented and the reveal
246/// leans on the Voronoi inheritance; a `pieces` count above 1 means the skeleton
247/// broke into strokes that are painted one after another.
248#[derive(Clone, Copy, Debug, PartialEq, Eq)]
249pub struct GeodesicReport {
250    /// Total ink cells in the art.
251    pub ink_cells: usize,
252    /// Size of the largest strictly 8-connected component of the *ink*.
253    pub connected_cells: usize,
254    /// Cells remaining after thinning, i.e. the length of the drawn centerline.
255    pub skeleton_cells: usize,
256    /// Separate pieces the skeleton breaks into once bridging has been applied.
257    /// Each is traced in turn, in reading order along the dominant axis.
258    pub pieces: usize,
259    /// Longest geodesic through the largest skeleton piece, in cells: the spine
260    /// the reveal actually traces.
261    pub spine_length: u32,
262}
263
264impl Geodesic {
265    /// Inspect the art without building a full rank map.
266    pub fn diagnose(&self, art: &Art) -> GeodesicReport {
267        let (w, h) = (art.width(), art.height());
268        let ink = ink_mask(art);
269        let ink_cells = ink.iter().filter(|&&m| m).count();
270        if ink_cells == 0 {
271            return GeodesicReport {
272                ink_cells: 0,
273                connected_cells: 0,
274                skeleton_cells: 0,
275                pieces: 0,
276                spine_length: 0,
277            };
278        }
279
280        let connected_cells = largest_component(&ink, w, h, 0).map_or(0, |(size, _)| size);
281        let skel = skeletonize(art);
282        let skeleton_cells = skel.iter().filter(|&&m| m).count();
283        let bridge = adaptive_bridge(&skel, w, h, self.bridge);
284
285        GeodesicReport {
286            ink_cells,
287            connected_cells,
288            skeleton_cells,
289            pieces: components(&skel, w, h, bridge).len(),
290            spine_length: spine(&skel, w, h, self.start, self.bridge)
291                .map_or(0, |trace| trace.diameter),
292        }
293    }
294}
295
296impl Ordering for Geodesic {
297    fn rank(&self, art: &Art) -> RankMap {
298        let (w, h) = (art.width(), art.height());
299        let mut map = RankMap::new(w, h);
300        if art.ink_count() == 0 {
301            return map;
302        }
303
304        // Thin the ink to its skeleton, then give every skeleton cell a reveal
305        // value: each piece traced tip to tip, the pieces in reading order.
306        let skel = skeletonize(art);
307        let value = skeleton_values(&skel, w, h, self.start, self.bridge);
308
309        // Voronoi flood: every cell takes the value of its nearest skeleton cell and
310        // remembers how far it sits from that centerline. The flesh thus reveals in
311        // step with the part of the spine it hangs from; and where the skeleton is a
312        // mere dot (a solid blob) the distance term spreads the fill out from the
313        // middle rather than all at once.
314        let mut val = value;
315        let mut depth = vec![0u32; val.len()];
316        let mut queue: VecDeque<usize> = (0..val.len()).filter(|&i| !val[i].is_nan()).collect();
317        while let Some(cur) = queue.pop_front() {
318            for ni in neighbours(cur, w, h) {
319                if val[ni].is_nan() {
320                    val[ni] = val[cur];
321                    depth[ni] = depth[cur] + 1;
322                    queue.push_back(ni);
323                }
324            }
325        }
326
327        // Rank-transform: order the ink by (centerline value, distance from it),
328        // then assign evenly spaced ranks so the reveal keeps that order but tracks
329        // the progress bar, with no dead zone at either end.
330        let mut order: Vec<(u16, u16, f32, u32)> = art
331            .ink_cells()
332            .map(|c| {
333                let i = art.index(c.x, c.y);
334                (c.x, c.y, val[i], depth[i])
335            })
336            .collect();
337        order.sort_by(|a, b| a.2.total_cmp(&b.2).then(a.3.cmp(&b.3)));
338        let denom = even_step(order.len());
339        for (i, &(x, y, _, _)) in order.iter().enumerate() {
340            map.set(x, y, i as f32 / denom);
341        }
342        map
343    }
344}
345
346/// If the largest strictly 8-connected component covers at least this fraction of
347/// the mask, it is treated as already whole and traced without bridging.
348pub const STRICT_CONNECTED_MIN: f32 = 0.6;
349
350// ---------------------------------------------------------------------------
351// Tracing. One implementation, shared by the whole-art spine and the per-piece
352// walk inside `skeleton_values`, so the two can never disagree about what a
353// "trace" means.
354// ---------------------------------------------------------------------------
355
356/// A traced piece: geodesic distance from the chosen start tip to every cell it
357/// reaches, and the piece's diameter.
358struct Trace {
359    /// Distance from the start; `None` for every cell outside the piece.
360    dist: Vec<Option<u32>>,
361    /// The piece's diameter (its maximum geodesic distance).
362    diameter: u32,
363}
364
365/// Trace the piece containing `seed` tip to tip: a double breadth-first sweep
366/// finds the two ends `(a, b)` of its longest geodesic, then `hint` picks which
367/// end the reveal starts from.
368fn trace(mask: &[bool], w: u16, h: u16, seed: usize, hint: StartHint, bridge: u16) -> Trace {
369    let (_, far_a) = bfs(mask, w, h, seed, bridge);
370    let (dist_a, far_b) = bfs(mask, w, h, far_a, bridge);
371    let (dist_b, _) = bfs(mask, w, h, far_b, bridge);
372
373    let coord = |i: usize| ((i % w as usize) as u16, (i / w as usize) as u16);
374    let (ax, ay) = coord(far_a);
375    let (bx, by) = coord(far_b);
376    let start_is_a = match hint {
377        StartHint::Topological => true,
378        StartHint::TopLeft => (ay, ax) <= (by, bx),
379        StartHint::Bottom => ay >= by,
380    };
381
382    let dist = if start_is_a { dist_a } else { dist_b };
383    let diameter = dist.iter().flatten().copied().max().unwrap_or(0);
384    Trace { dist, diameter }
385}
386
387/// Trace the largest piece of `mask` tip to tip, bridging only if it is genuinely
388/// fragmented. `None` when the mask is empty.
389fn spine(mask: &[bool], w: u16, h: u16, hint: StartHint, bridge: u16) -> Option<Trace> {
390    let bridge = adaptive_bridge(mask, w, h, bridge);
391    let (_, seed) = largest_component(mask, w, h, bridge)?;
392    Some(trace(mask, w, h, seed, hint, bridge))
393}
394
395/// Bridging engages only when the mask is actually fragmented. Stitching gaps in
396/// art that was already whole would add shortcuts straight across the body,
397/// shortening the spine and cutting corners on the trace.
398fn adaptive_bridge(mask: &[bool], w: u16, h: u16, bridge: u16) -> u16 {
399    if bridge == 0 {
400        return 0;
401    }
402    let count = mask.iter().filter(|&&m| m).count();
403    match largest_component(mask, w, h, 0) {
404        Some((strict, _)) if strict as f32 >= STRICT_CONNECTED_MIN * count.max(1) as f32 => 0,
405        _ => bridge,
406    }
407}
408
409// ---------------------------------------------------------------------------
410// Internal graph helpers (8-connectivity).
411// ---------------------------------------------------------------------------
412
413/// The in-bounds 8-neighbours of a flat grid index.
414fn neighbours(index: usize, w: u16, h: u16) -> impl Iterator<Item = usize> {
415    offsets(index, w, h, 0)
416}
417
418/// In-bounds neighbours within Chebyshev distance `bridge + 1` of `index`, so
419/// `bridge = 0` is plain 8-connectivity. Lazy: this sits in the inner loop of
420/// every sweep, and materialising a `Vec` per node expansion was the single
421/// hottest allocation in the crate.
422fn offsets(index: usize, w: u16, h: u16, bridge: u16) -> impl Iterator<Item = usize> {
423    let (wi, hi) = (w as i32, h as i32);
424    let r = bridge as i32 + 1;
425    let (cx, cy) = (index as i32 % wi.max(1), index as i32 / wi.max(1));
426    (-r..=r)
427        .flat_map(move |dy| (-r..=r).map(move |dx| (dx, dy)))
428        .filter_map(move |(dx, dy)| {
429            if dx == 0 && dy == 0 {
430                return None;
431            }
432            let (nx, ny) = (cx + dx, cy + dy);
433            (nx >= 0 && ny >= 0 && nx < wi && ny < hi).then_some((ny * wi + nx) as usize)
434        })
435}
436
437/// Member cells within Chebyshev distance `bridge + 1` of `index`.
438#[inline]
439fn bridged_neighbours(
440    mask: &[bool],
441    w: u16,
442    h: u16,
443    index: usize,
444    bridge: u16,
445) -> impl Iterator<Item = usize> + '_ {
446    offsets(index, w, h, bridge).filter(move |&ni| mask[ni])
447}
448
449/// A boolean grid: `true` where the art has ink.
450fn ink_mask(art: &Art) -> Vec<bool> {
451    let (w, h) = (art.width() as usize, art.height() as usize);
452    (0..w * h)
453        .map(|i| art.is_ink((i % w.max(1)) as u16, (i / w.max(1)) as u16))
454        .collect()
455}
456
457/// Every connected component of `mask`, each as its list of cells, in the order
458/// their first cell appears. With `bridge > 0` a component spans gaps of that many
459/// blank cells.
460fn components(mask: &[bool], w: u16, h: u16, bridge: u16) -> Vec<Vec<usize>> {
461    let mut seen = vec![false; mask.len()];
462    let mut queue = VecDeque::new();
463    let mut out = Vec::new();
464
465    for seed in 0..mask.len() {
466        if !mask[seed] || seen[seed] {
467            continue;
468        }
469        let mut cells = Vec::new();
470        seen[seed] = true;
471        queue.push_back(seed);
472        while let Some(cur) = queue.pop_front() {
473            cells.push(cur);
474            for ni in bridged_neighbours(mask, w, h, cur, bridge) {
475                if !seen[ni] {
476                    seen[ni] = true;
477                    queue.push_back(ni);
478                }
479            }
480        }
481        out.push(cells);
482    }
483    out
484}
485
486/// The size of, and a seed cell in, the largest component of `mask`.
487fn largest_component(mask: &[bool], w: u16, h: u16, bridge: u16) -> Option<(usize, usize)> {
488    components(mask, w, h, bridge)
489        .into_iter()
490        .map(|c| (c.len(), c[0]))
491        .max_by_key(|&(size, _)| size)
492}
493
494/// BFS from `source` over `mask`, stepping across gaps of up to `bridge` blank
495/// cells. Returns the distance to every cell (`None` where unreachable) and the
496/// farthest reachable cell.
497fn bfs(mask: &[bool], w: u16, h: u16, source: usize, bridge: u16) -> (Vec<Option<u32>>, usize) {
498    let mut dist = vec![None; mask.len()];
499    let mut queue = VecDeque::new();
500
501    dist[source] = Some(0);
502    queue.push_back(source);
503    let (mut farthest, mut far_d) = (source, 0u32);
504
505    while let Some(cur) = queue.pop_front() {
506        let d = dist[cur].unwrap();
507        if d > far_d {
508            far_d = d;
509            farthest = cur;
510        }
511        for ni in bridged_neighbours(mask, w, h, cur, bridge) {
512            if dist[ni].is_none() {
513                dist[ni] = Some(d + 1);
514                queue.push_back(ni);
515            }
516        }
517    }
518    (dist, farthest)
519}
520
521/// Zhang-Suen thinning: reduce the ink to a one-cell-wide skeleton, its medial
522/// axis. A solid shape collapses to the centerline a pen would trace; a shape that
523/// is already a line is left unchanged.
524fn skeletonize(art: &Art) -> Vec<bool> {
525    let (w, h) = (art.width() as i32, art.height() as i32);
526    let idx = |x: i32, y: i32| (y * w + x) as usize;
527    let mut g = ink_mask(art);
528    let val = |g: &[bool], x: i32, y: i32| -> u8 {
529        (x >= 0 && y >= 0 && x < w && y < h && g[idx(x, y)]) as u8
530    };
531    loop {
532        let mut removed = false;
533        for step in 0..2 {
534            let mut marks = Vec::new();
535            for y in 0..h {
536                for x in 0..w {
537                    if !g[idx(x, y)] {
538                        continue;
539                    }
540                    // p2..p9, clockwise from north.
541                    let p = [
542                        val(&g, x, y - 1),
543                        val(&g, x + 1, y - 1),
544                        val(&g, x + 1, y),
545                        val(&g, x + 1, y + 1),
546                        val(&g, x, y + 1),
547                        val(&g, x - 1, y + 1),
548                        val(&g, x - 1, y),
549                        val(&g, x - 1, y - 1),
550                    ];
551                    let b: u8 = p.iter().sum();
552                    if !(2..=6).contains(&b) {
553                        continue;
554                    }
555                    let a = (0..8).filter(|&i| p[i] == 0 && p[(i + 1) % 8] == 1).count();
556                    if a != 1 {
557                        continue;
558                    }
559                    let (c1, c2) = if step == 0 {
560                        (p[0] * p[2] * p[4], p[2] * p[4] * p[6])
561                    } else {
562                        (p[0] * p[2] * p[6], p[0] * p[4] * p[6])
563                    };
564                    if c1 == 0 && c2 == 0 {
565                        marks.push(idx(x, y));
566                    }
567                }
568            }
569            if !marks.is_empty() {
570                removed = true;
571                for i in marks {
572                    g[i] = false;
573                }
574            }
575        }
576        if !removed {
577            break;
578        }
579    }
580    g
581}
582
583/// A reveal value for every skeleton cell. Each connected piece of the skeleton is
584/// traced tip to tip, and the pieces are ordered along the art's dominant axis, so
585/// a multi-letter logo paints letter by letter in reading order while a single
586/// shape just traces its centerline. `NaN` off the skeleton.
587fn skeleton_values(skel: &[bool], w: u16, h: u16, hint: StartHint, bridge: u16) -> Vec<f32> {
588    let mut value = vec![f32::NAN; skel.len()];
589    if !skel.iter().any(|&m| m) {
590        return value;
591    }
592
593    let bridge = adaptive_bridge(skel, w, h, bridge);
594    let horizontal = is_wide(w, h);
595    let axis = |i: usize| -> u16 {
596        if horizontal {
597            (i % w as usize) as u16
598        } else {
599            (i / w as usize) as u16
600        }
601    };
602
603    // Trace each piece, and note its leading edge along the axis for ordering.
604    let mut pieces: Vec<(u16, Vec<usize>, Trace)> = components(skel, w, h, bridge)
605        .into_iter()
606        .map(|comp| {
607            let lead = comp.iter().map(|&c| axis(c)).min().unwrap_or(0);
608            let traced = trace(skel, w, h, comp[0], hint, bridge);
609            (lead, comp, traced)
610        })
611        .collect();
612
613    pieces.sort_by_key(|(lead, _, _)| *lead);
614    for (index, (_, comp, traced)) in pieces.iter().enumerate() {
615        let span = traced.diameter.max(1) as f32;
616        for &cell in comp {
617            let within = traced.dist[cell].map_or(0.0, |d| d as f32 / span);
618            value[cell] = index as f32 + within;
619        }
620    }
621    value
622}
623
624#[cfg(test)]
625mod tests {
626    use super::*;
627
628    /// A straight horizontal stroke must reveal strictly along its length, i.e.
629    /// ranks increase monotonically (in one direction) and reach 1.0.
630    #[test]
631    fn straight_line_reveals_along_itself() {
632        let art = Art::parse("=========");
633        let ranks = Geodesic::default().rank(&art);
634        let row: Vec<f32> = (0..art.width())
635            .map(|x| ranks.rank_at(x, 0).unwrap())
636            .collect();
637        let increasing = row.windows(2).all(|w| w[0] <= w[1]);
638        let decreasing = row.windows(2).all(|w| w[0] >= w[1]);
639        assert!(
640            increasing || decreasing,
641            "spine reveal was not monotone: {row:?}"
642        );
643        assert!((row.iter().cloned().fold(0.0_f32, f32::max) - 1.0).abs() < 1e-6);
644    }
645
646    /// A lone fleck at the top-left must not become the spine; the long bar does.
647    #[test]
648    fn spine_traces_largest_component() {
649        let art = Art::parse(".\n\n   ========");
650        let report = Geodesic::default().diagnose(&art);
651        assert_eq!(report.ink_cells, 9);
652        assert_eq!(report.connected_cells, 8); // the bar, not the 1-cell fleck
653    }
654
655    /// Islands inherit the rank of the nearest spine tip: an island by the start
656    /// reveals early, one by the finish reveals late, not both dumped at the end.
657    #[test]
658    fn islands_inherit_nearest_spine_rank() {
659        let art = Art::parse(".  ======  .");
660        let ranks = Geodesic::default().rank(&art);
661        let left = ranks.rank_at(0, 0).unwrap();
662        let right = ranks.rank_at(11, 0).unwrap();
663        assert!(left < right, "left {left} should precede right {right}");
664        assert!(left < 0.25 && right > 0.75, "left={left} right={right}");
665    }
666
667    #[test]
668    fn diagnose_counts_connectivity() {
669        let report = Geodesic::default().diagnose(&Art::parse("==========    ."));
670        assert_eq!(report.ink_cells, 11);
671        assert_eq!(report.connected_cells, 10); // the bar; the '.' is an island
672    }
673
674    /// `spine_length` must describe the skeleton the reveal actually traces, not
675    /// the raw ink: a thick bar thins to a centerline, and that centerline is what
676    /// the trace walks.
677    #[test]
678    fn diagnose_reports_the_traced_skeleton() {
679        let art = Art::parse(&"##########\n".repeat(3));
680        let report = Geodesic::default().diagnose(&art);
681        assert_eq!(report.ink_cells, 30);
682        assert_eq!(report.connected_cells, 30);
683        assert!(
684            report.skeleton_cells < report.ink_cells,
685            "thinning should shrink the ink: {report:?}"
686        );
687        assert_eq!(report.pieces, 1);
688        assert!(
689            (report.spine_length as usize) < report.ink_cells,
690            "spine must be the centerline, not the ink: {report:?}"
691        );
692    }
693
694    #[test]
695    fn diagnose_counts_pieces() {
696        let art = Art::parse("##        ##        ##");
697        let report = Geodesic::default().diagnose(&art);
698        assert_eq!(report.pieces, 3);
699    }
700
701    #[test]
702    fn diagnose_of_empty_art_is_all_zero() {
703        let report = Geodesic::default().diagnose(&Art::parse("   \n   "));
704        assert_eq!(report.ink_cells, 0);
705        assert_eq!(report.spine_length, 0);
706        assert_eq!(report.pieces, 0);
707    }
708
709    /// Fragmented art (two strokes one blank cell apart) reveals as one body: the
710    /// default bridges the gap, while `bridge: 0` keeps the strokes separate.
711    #[test]
712    fn bridges_small_gaps_when_fragmented() {
713        let art = Art::parse("== ==");
714        let strict = Geodesic {
715            start: StartHint::TopLeft,
716            bridge: 0,
717        };
718        assert_eq!(strict.diagnose(&art).pieces, 2);
719        assert_eq!(Geodesic::default().diagnose(&art).pieces, 1);
720    }
721
722    /// Already-connected art must not be bridged: shortcuts would cut across the
723    /// body and shrink the spine, so a clean stroke keeps its full-length trace.
724    #[test]
725    fn connected_art_is_not_bridged() {
726        // A zigzag whose passes sit two rows apart; bridging would short-circuit
727        // it, but since it is one strict component the spine stays long.
728        let art = Art::parse("####\n   #\n####\n#\n####");
729        let report = Geodesic::default().diagnose(&art);
730        assert_eq!(report.connected_cells, report.ink_cells);
731        assert_eq!(report.pieces, 1);
732        assert!(
733            report.spine_length >= 9,
734            "spine was {}",
735            report.spine_length
736        );
737    }
738
739    /// A solid block has no real structure, but the reveal must still use the whole
740    /// bar (no dead zone at either end) rather than dump everything at once.
741    #[test]
742    fn solid_block_reveals_across_the_whole_bar() {
743        let art = Art::parse(&"########\n".repeat(8));
744        let r = Geodesic::default().rank(&art);
745        let ranks: Vec<f32> = (0..8)
746            .flat_map(|y| (0..8u16).map(move |x| (x, y)))
747            .map(|(x, y)| r.rank_at(x, y).unwrap())
748            .collect();
749        let lo = ranks.iter().cloned().fold(f32::MAX, f32::min);
750        let hi = ranks.iter().cloned().fold(f32::MIN, f32::max);
751        assert!(
752            lo < 0.02 && hi > 0.98,
753            "block did not use the whole bar: {lo}..{hi}"
754        );
755    }
756
757    /// Separate pieces (the strokes of a logo) reveal one after another in reading
758    /// order, each traced, rather than all at once or out of order.
759    #[test]
760    fn separate_pieces_reveal_in_reading_order() {
761        let art = Art::parse("##        ##\n##        ##\n##        ##");
762        let r = Geodesic::default().rank(&art);
763        let left = r.rank_at(0, 1).unwrap();
764        let right = r.rank_at(11, 1).unwrap();
765        assert!(
766            left < right,
767            "left piece {left} should precede right {right}"
768        );
769        assert!(
770            left < 0.5 && right > 0.5,
771            "pieces out of order: {left} {right}"
772        );
773    }
774
775    /// A thin line keeps a pure spine trace: the directional blend stays out of the
776    /// way, so the two ends are the first and last cells revealed.
777    #[test]
778    fn thin_line_stays_a_trace() {
779        let art = Art::parse("==============");
780        let r = Geodesic::default().rank(&art);
781        let row: Vec<f32> = (0..art.width()).map(|x| r.rank_at(x, 0).unwrap()).collect();
782        let lo = row.iter().cloned().fold(f32::MAX, f32::min);
783        let hi = row.iter().cloned().fold(f32::MIN, f32::max);
784        assert!(
785            lo < 0.01 && hi > 0.99,
786            "line did not trace end to end: {row:?}"
787        );
788    }
789
790    /// `Auto` weights for terminal cells being about twice as tall as wide: art
791    /// that is wider than tall in cells but reads tall still paints top to bottom;
792    /// only genuinely wide art wipes sideways.
793    #[test]
794    fn directional_auto_accounts_for_cell_aspect() {
795        // 5 wide by 4 tall: more columns than rows, yet reads tall -> top to bottom.
796        let tall = Art::parse("#####\n#####\n#####\n#####");
797        let r = Directional(Direction::Auto).rank(&tall);
798        assert!(
799            r.rank_at(0, 0).unwrap() < r.rank_at(0, 3).unwrap(),
800            "top first"
801        );
802        assert_eq!(
803            r.rank_at(0, 0),
804            r.rank_at(4, 0),
805            "same row reveals together"
806        );
807
808        // 10 wide by 2 tall: genuinely wide -> left to right.
809        let wide = Art::parse("##########\n##########");
810        let rw = Directional(Direction::Auto).rank(&wide);
811        assert!(
812            rw.rank_at(0, 0).unwrap() < rw.rank_at(9, 0).unwrap(),
813            "left first"
814        );
815        assert_eq!(
816            rw.rank_at(0, 0),
817            rw.rank_at(0, 1),
818            "same column reveals together"
819        );
820    }
821
822    /// Padding must not steer the `Auto` heuristic. A one-column vertical bar is
823    /// tall art however much blank space surrounds it, so it wipes top to bottom
824    /// and the two cells never share a rank.
825    #[test]
826    fn padding_does_not_steer_auto() {
827        let padded = Directional(Direction::Auto).rank(&Art::parse("      #\n      #"));
828        let bare = Directional(Direction::Auto).rank(&Art::parse("#\n#"));
829        assert_eq!(padded.rank_at(0, 0), bare.rank_at(0, 0));
830        assert_eq!(padded.rank_at(0, 0), Some(0.0));
831        assert_eq!(padded.rank_at(0, 1), Some(1.0));
832    }
833
834    #[test]
835    fn explicit_direction_beats_locale_sniffing() {
836        let art = Art::parse("abcd");
837        let ltr = Directional::ltr().rank(&art);
838        let rtl = Directional::rtl().rank(&art);
839        assert_eq!(ltr.rank_at(0, 0), Some(0.0));
840        assert_eq!(rtl.rank_at(3, 0), Some(0.0));
841    }
842
843    #[test]
844    fn scanline_spans_the_whole_bar() {
845        let art = Art::parse("ab\ncd");
846        let r = Scanline.rank(&art);
847        assert_eq!(r.rank_at(0, 0), Some(0.0));
848        assert_eq!(r.rank_at(1, 1), Some(1.0));
849    }
850
851    /// Share of the ink visible at `progress`.
852    fn revealed_share(art: &Art, ranks: &RankMap, progress: f32) -> f32 {
853        let mut seen = 0usize;
854        for y in 0..art.height() {
855            for x in 0..art.width() {
856                if art.is_ink(x, y) && ranks.visible_at(x, y, progress) {
857                    seen += 1;
858                }
859            }
860        }
861        seen as f32 / art.ink_count().max(1) as f32
862    }
863
864    /// A reveal has to read as a progress bar: the share of ink on screen tracks
865    /// the reported fraction. Individually valid ranks can still add up to a
866    /// reveal that dumps half the picture at the start or stalls at the end, and
867    /// on real art (dense in some rows, sparse in others) that is exactly what a
868    /// naive rank assignment does. This pins the behaviour on the art that
869    /// actually ships, under every ordering.
870    #[test]
871    fn revealed_share_tracks_progress_on_the_bundled_art() {
872        let art = [
873            ("dragon", Art::parse(include_str!("../assets/dragon.txt"))),
874            ("serpent", Art::parse(include_str!("../assets/serpent.txt"))),
875            ("inkling", Art::parse(include_str!("../assets/inkling.txt"))),
876        ];
877        for (name, art) in &art {
878            // Scanline ranks cells one by one, and Geodesic ends in a rank
879            // transform, so both track the bar exactly. Directional is a
880            // geometric wipe: it ranks by position, knows nothing about where
881            // the ink is dense, and on the dragon (a sparse crest over a solid
882            // body) it is legitimately behind at the halfway mark. That is the
883            // ordering's character, not a defect, but it still must not lurch.
884            let maps: [(&str, RankMap, f32); 3] = [
885                ("directional", Directional::default().rank(art), 0.2),
886                ("geodesic", Geodesic::default().rank(art), 0.02),
887                ("scanline", Scanline.rank(art), 0.02),
888            ];
889            for (ordering, ranks, tolerance) in maps {
890                let at = |p| revealed_share(art, &ranks, p);
891                assert!(
892                    at(0.0) < 0.02,
893                    "{name}/{ordering}: {:.0}% of the ink is already showing at zero",
894                    at(0.0) * 100.0
895                );
896                for p in [0.25f32, 0.5, 0.75] {
897                    let share = at(p);
898                    assert!(
899                        (share - p).abs() < tolerance,
900                        "{name}/{ordering}: {:.0}% of the ink revealed at {:.0}% progress",
901                        share * 100.0,
902                        p * 100.0
903                    );
904                }
905                assert!(
906                    at(1.0) > 0.999,
907                    "{name}/{ordering}: the art never finishes filling"
908                );
909            }
910        }
911    }
912
913    #[test]
914    fn orderings_tolerate_empty_art() {
915        let art = Art::parse("");
916        for map in [
917            Scanline.rank(&art),
918            Directional::default().rank(&art),
919            Geodesic::default().rank(&art),
920        ] {
921            assert_eq!(map.ink_count(), 0);
922        }
923    }
924}