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 by tracing the art's skeleton.
126///
127/// The ink is first thinned to a one-cell-wide **skeleton** (Zhang-Suen), the
128/// centerline a pen would draw. Each connected piece of that skeleton is traced tip
129/// to tip by geodesic distance, a double breadth-first sweep finding the two ends of
130/// its longest path, and the pieces are ordered along the art's dominant axis. So a
131/// snake paints head to tail, a filled dragon paints down its spine, and a
132/// multi-letter logo paints letter by letter in reading order, with no per-art tuning.
133///
134/// Hand-drawn ASCII is usually many separate strokes, not one connected line, so the
135/// trace **bridges small gaps** to stitch a broken stroke into one piece; art that is
136/// already whole is traced strictly, with no shortcuts (see [`Geodesic::bridge`]).
137///
138/// The flesh around the skeleton inherits the value of its nearest centerline cell, a
139/// Voronoi flood, so detail reveals in step with the part of the spine it hangs from;
140/// where the skeleton is a mere dot, as in a solid blob, the fill radiates out from
141/// the middle. Finally the values are rank-transformed to evenly spaced ranks, so the
142/// reveal keeps its order yet tracks the progress bar with no dead zone at either end.
143#[derive(Clone, Copy, Debug)]
144pub struct Geodesic {
145    /// Which tip of the spine the reveal begins from.
146    pub start: StartHint,
147    /// The largest gap, in blank cells, the spine may step across. Bridging only
148    /// engages when the art is actually fragmented (see [`Spine::solve`]), so it
149    /// stitches the separate strokes of hand-drawn ASCII into one body without ever
150    /// adding shortcuts to art that was already connected. `0` disables it.
151    pub bridge: u16,
152}
153
154impl Default for Geodesic {
155    /// Start at the top-left tip and bridge single-cell gaps when the art is
156    /// fragmented, which is what most hand-drawn ASCII needs.
157    fn default() -> Self {
158        Geodesic {
159            start: StartHint::default(),
160            bridge: 1,
161        }
162    }
163}
164
165/// Which end of the spine the [`Geodesic`] reveal starts at.
166#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
167pub enum StartHint {
168    /// The tip nearest the top-left. Deterministic and reads like text. (default)
169    #[default]
170    TopLeft,
171    /// The tip nearest the bottom of the canvas.
172    Bottom,
173    /// Whichever diameter endpoint the sweep happens to find, purely topological.
174    Topological,
175}
176
177/// Diagnostics describing how well a piece of art suits geodesic reveal.
178///
179/// A low `connected_cells / ink_cells` ratio means the art is fragmented and
180/// will lean on the Voronoi inheritance rather than the spine itself.
181#[derive(Clone, Copy, Debug)]
182pub struct GeodesicReport {
183    pub ink_cells: usize,
184    /// Size of the largest connected component (the spine's component).
185    pub connected_cells: usize,
186    /// Length of the spine in cells (the component's graph diameter).
187    pub spine_length: u32,
188}
189
190impl Geodesic {
191    /// Inspect the art without building a full rank map.
192    pub fn diagnose(&self, art: &Art) -> GeodesicReport {
193        let mask = ink_mask(art);
194        match Spine::solve(&mask, art.width(), art.height(), self.start, self.bridge) {
195            Some(spine) => GeodesicReport {
196                ink_cells: art.ink_count(),
197                connected_cells: spine.dist.iter().filter(|d| d.is_some()).count(),
198                spine_length: spine.diameter,
199            },
200            None => GeodesicReport {
201                ink_cells: 0,
202                connected_cells: 0,
203                spine_length: 0,
204            },
205        }
206    }
207}
208
209impl Ordering for Geodesic {
210    fn rank(&self, art: &Art) -> RankMap {
211        let (w, h) = (art.width(), art.height());
212        let mut map = RankMap::new(w, h);
213        if art.ink_count() == 0 {
214            return map;
215        }
216
217        // Thin the ink to its skeleton, then give every skeleton cell a reveal
218        // value: each piece traced tip to tip, the pieces in reading order.
219        let skel = skeletonize(art);
220        let value = skeleton_values(&skel, w, h, self.start, self.bridge);
221
222        // Voronoi flood: every cell takes the value of its nearest skeleton cell and
223        // remembers how far it sits from that centerline. The flesh thus reveals in
224        // step with the part of the spine it hangs from; and where the skeleton is a
225        // mere dot (a solid blob) the distance term spreads the fill out from the
226        // middle rather than all at once.
227        let mut val = value.clone();
228        let mut depth = vec![0u32; val.len()];
229        let mut queue: VecDeque<usize> = (0..val.len()).filter(|&i| !val[i].is_nan()).collect();
230        while let Some(cur) = queue.pop_front() {
231            for ni in neighbours(cur, w, h) {
232                if val[ni].is_nan() {
233                    val[ni] = val[cur];
234                    depth[ni] = depth[cur] + 1;
235                    queue.push_back(ni);
236                }
237            }
238        }
239
240        // Rank-transform: order the ink by (centerline value, distance from it),
241        // then assign evenly spaced ranks so the reveal keeps that order but tracks
242        // the progress bar, with no dead zone at either end.
243        let mut order: Vec<(u16, u16, f32, u32)> = art
244            .ink_cells()
245            .map(|c| {
246                let i = art.index(c.x, c.y);
247                (c.x, c.y, val[i], depth[i])
248            })
249            .collect();
250        order.sort_by(|a, b| a.2.total_cmp(&b.2).then(a.3.cmp(&b.3)));
251        let denom = order.len().saturating_sub(1).max(1) as f32;
252        for (i, &(x, y, _, _)) in order.iter().enumerate() {
253            map.set(x, y, i as f32 / denom);
254        }
255        map
256    }
257}
258
259/// If the largest strictly 8-connected component covers at least this fraction of
260/// the ink, the art is treated as already whole and traced without bridging.
261const STRICT_CONNECTED_MIN: f32 = 0.6;
262
263/// The traced spine of the art's largest connected component.
264struct Spine {
265    /// Geodesic distance from the chosen start within the largest component;
266    /// `None` for every cell outside it.
267    dist: Vec<Option<u32>>,
268    /// The component's diameter (maximum geodesic distance).
269    diameter: u32,
270}
271
272impl Spine {
273    fn solve(mask: &[bool], w: u16, h: u16, hint: StartHint, bridge: u16) -> Option<Self> {
274        // If the mask is already mostly one 8-connected piece, trace it strictly;
275        // only stitch gaps when it is genuinely fragmented. Bridging then fixes
276        // hand-drawn art split into strokes without adding shortcuts across art
277        // that was already whole (which would shorten the spine and cut corners).
278        let count = mask.iter().filter(|&&m| m).count();
279        let strict_seed = largest_component_seed(mask, w, h, 0)?;
280        let strict_size = bfs(mask, w, h, 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 * count.max(1) as f32 {
286            bridge
287        } else {
288            0
289        };
290
291        // Double sweep → the two ends (A, B) of the component's longest geodesic.
292        let seed = if bridge == 0 {
293            strict_seed
294        } else {
295            largest_component_seed(mask, w, h, bridge)?
296        };
297        let (_, far_a) = bfs(mask, w, h, seed, bridge);
298        let (dist_a, far_b) = bfs(mask, w, h, far_a, bridge);
299        let (dist_b, _) = bfs(mask, w, h, far_b, bridge);
300
301        // Pick which endpoint to start from.
302        let coord = |i: usize| ((i % w as usize) as u16, (i / w as usize) as u16);
303        let (ax, ay) = coord(far_a);
304        let (bx, by) = coord(far_b);
305        let start_is_a = match hint {
306            StartHint::Topological => true,
307            StartHint::TopLeft => (ay, ax) <= (by, bx),
308            StartHint::Bottom => ay >= by,
309        };
310
311        let dist = if start_is_a { dist_a } else { dist_b };
312        let diameter = dist.iter().flatten().copied().max().unwrap_or(0);
313        Some(Spine { dist, diameter })
314    }
315}
316
317// ---------------------------------------------------------------------------
318// Internal graph helpers (8-connectivity).
319// ---------------------------------------------------------------------------
320
321/// The in-bounds 8-neighbours of a flat grid index.
322fn neighbours(index: usize, w: u16, h: u16) -> impl Iterator<Item = usize> {
323    let (w, h) = (w as i32, h as i32);
324    let (cx, cy) = ((index as i32 % w), (index as i32 / w));
325    (-1..=1)
326        .flat_map(move |dy| (-1..=1).map(move |dx| (dx, dy)))
327        .filter_map(move |(dx, dy)| {
328            if dx == 0 && dy == 0 {
329                return None;
330            }
331            let (nx, ny) = (cx + dx, cy + dy);
332            (nx >= 0 && ny >= 0 && nx < w && ny < h).then_some((ny * w + nx) as usize)
333        })
334}
335
336/// A boolean grid: `true` where the art has ink.
337fn ink_mask(art: &Art) -> Vec<bool> {
338    let (w, h) = (art.width() as usize, art.height() as usize);
339    (0..w * h)
340        .map(|i| art.is_ink((i % w) as u16, (i / w) as u16))
341        .collect()
342}
343
344/// A seed cell in the largest component of `mask` (`None` if empty). With
345/// `bridge > 0` a component spans gaps of that many blank cells.
346fn largest_component_seed(mask: &[bool], w: u16, h: u16, bridge: u16) -> Option<usize> {
347    let mut visited = vec![false; mask.len()];
348    let mut queue = VecDeque::new();
349    let mut best: Option<(usize, usize)> = None; // (size, seed)
350
351    for seed in 0..mask.len() {
352        if !mask[seed] || visited[seed] {
353            continue;
354        }
355        let mut size = 0usize;
356        visited[seed] = true;
357        queue.push_back(seed);
358        while let Some(cur) = queue.pop_front() {
359            size += 1;
360            for ni in bridged_neighbours(mask, w, h, cur, bridge) {
361                if !visited[ni] {
362                    visited[ni] = true;
363                    queue.push_back(ni);
364                }
365            }
366        }
367        if best.map_or(true, |(best_size, _)| size > best_size) {
368            best = Some((size, seed));
369        }
370    }
371    best.map(|(_, seed)| seed)
372}
373
374/// BFS from `source` over `mask`, stepping across gaps of up to `bridge` blank
375/// cells. Returns the distance to every cell (`None` where unreachable) and the
376/// farthest reachable cell.
377fn bfs(mask: &[bool], w: u16, h: u16, source: usize, bridge: u16) -> (Vec<Option<u32>>, usize) {
378    let mut dist = vec![None; mask.len()];
379    let mut queue = VecDeque::new();
380
381    dist[source] = Some(0);
382    queue.push_back(source);
383    let (mut farthest, mut far_d) = (source, 0u32);
384
385    while let Some(cur) = queue.pop_front() {
386        let d = dist[cur].unwrap();
387        if d > far_d {
388            far_d = d;
389            farthest = cur;
390        }
391        for ni in bridged_neighbours(mask, w, h, cur, bridge) {
392            if dist[ni].is_none() {
393                dist[ni] = Some(d + 1);
394                queue.push_back(ni);
395            }
396        }
397    }
398    (dist, farthest)
399}
400
401/// Member cells within Chebyshev distance `bridge + 1` of `index`, so `bridge = 0`
402/// is plain 8-connectivity and larger values let a trace step across small gaps.
403fn bridged_neighbours(mask: &[bool], w: u16, h: u16, index: usize, bridge: u16) -> Vec<usize> {
404    let (wi, hi) = (w as i32, h as i32);
405    let r = bridge as i32 + 1;
406    let (cx, cy) = (index as i32 % wi, index as i32 / wi);
407    let mut out = Vec::new();
408    for dy in -r..=r {
409        for dx in -r..=r {
410            if dx == 0 && dy == 0 {
411                continue;
412            }
413            let (nx, ny) = (cx + dx, cy + dy);
414            if nx >= 0 && ny >= 0 && nx < wi && ny < hi {
415                let ni = (ny * wi + nx) as usize;
416                if mask[ni] {
417                    out.push(ni);
418                }
419            }
420        }
421    }
422    out
423}
424
425/// Zhang-Suen thinning: reduce the ink to a one-cell-wide skeleton, its medial
426/// axis. A solid shape collapses to the centerline a pen would trace; a shape that
427/// is already a line is left unchanged.
428fn skeletonize(art: &Art) -> Vec<bool> {
429    let (w, h) = (art.width() as i32, art.height() as i32);
430    let idx = |x: i32, y: i32| (y * w + x) as usize;
431    let mut g = ink_mask(art);
432    let val = |g: &[bool], x: i32, y: i32| -> u8 {
433        (x >= 0 && y >= 0 && x < w && y < h && g[idx(x, y)]) as u8
434    };
435    loop {
436        let mut removed = false;
437        for step in 0..2 {
438            let mut marks = Vec::new();
439            for y in 0..h {
440                for x in 0..w {
441                    if !g[idx(x, y)] {
442                        continue;
443                    }
444                    // p2..p9, clockwise from north.
445                    let p = [
446                        val(&g, x, y - 1),
447                        val(&g, x + 1, y - 1),
448                        val(&g, x + 1, y),
449                        val(&g, x + 1, y + 1),
450                        val(&g, x, y + 1),
451                        val(&g, x - 1, y + 1),
452                        val(&g, x - 1, y),
453                        val(&g, x - 1, y - 1),
454                    ];
455                    let b: u8 = p.iter().sum();
456                    if !(2..=6).contains(&b) {
457                        continue;
458                    }
459                    let a = (0..8).filter(|&i| p[i] == 0 && p[(i + 1) % 8] == 1).count();
460                    if a != 1 {
461                        continue;
462                    }
463                    let (c1, c2) = if step == 0 {
464                        (p[0] * p[2] * p[4], p[2] * p[4] * p[6])
465                    } else {
466                        (p[0] * p[2] * p[6], p[0] * p[4] * p[6])
467                    };
468                    if c1 == 0 && c2 == 0 {
469                        marks.push(idx(x, y));
470                    }
471                }
472            }
473            if !marks.is_empty() {
474                removed = true;
475                for i in marks {
476                    g[i] = false;
477                }
478            }
479        }
480        if !removed {
481            break;
482        }
483    }
484    g
485}
486
487/// A reveal value for every skeleton cell. Each connected piece of the skeleton is
488/// traced tip to tip (geodesic distance), and the pieces are ordered along the
489/// art's dominant axis, so a multi-letter logo paints letter by letter in reading
490/// order while a single shape just traces its centerline. `NaN` off the skeleton.
491fn skeleton_values(skel: &[bool], w: u16, h: u16, hint: StartHint, bridge: u16) -> Vec<f32> {
492    let mut value = vec![f32::NAN; skel.len()];
493    let count = skel.iter().filter(|&&m| m).count();
494    if count == 0 {
495        return value;
496    }
497
498    // Adaptive bridge: stitch a fragmented skeleton, but never add shortcuts to one
499    // that is already whole (which would cut corners on the trace).
500    let bridge = match largest_component_seed(skel, w, h, 0) {
501        Some(seed)
502            if bfs(skel, w, h, seed, 0)
503                .0
504                .iter()
505                .filter(|d| d.is_some())
506                .count() as f32
507                >= STRICT_CONNECTED_MIN * count as f32 =>
508        {
509            0
510        }
511        _ => bridge,
512    };
513
514    // Label connected components.
515    let mut comp_id = vec![usize::MAX; skel.len()];
516    let mut comps: Vec<Vec<usize>> = Vec::new();
517    for i in 0..skel.len() {
518        if !skel[i] || comp_id[i] != usize::MAX {
519            continue;
520        }
521        let id = comps.len();
522        let mut cells = Vec::new();
523        let mut queue = VecDeque::new();
524        comp_id[i] = id;
525        queue.push_back(i);
526        while let Some(cur) = queue.pop_front() {
527            cells.push(cur);
528            for ni in bridged_neighbours(skel, w, h, cur, bridge) {
529                if comp_id[ni] == usize::MAX {
530                    comp_id[ni] = id;
531                    queue.push_back(ni);
532                }
533            }
534        }
535        comps.push(cells);
536    }
537
538    let horizontal = w as u32 > 2 * h as u32;
539    let axis = |i: usize| -> u16 {
540        if horizontal {
541            (i % w as usize) as u16
542        } else {
543            (i / w as usize) as u16
544        }
545    };
546    let coord = |i: usize| ((i % w as usize) as u16, (i / w as usize) as u16);
547
548    // Trace each piece, and note its leading edge along the axis for ordering.
549    let mut pieces: Vec<(u16, Vec<(usize, f32)>)> = comps
550        .iter()
551        .map(|comp| {
552            let (_, far_a) = bfs(skel, w, h, comp[0], bridge);
553            let (dist_a, far_b) = bfs(skel, w, h, far_a, bridge);
554            let (dist_b, _) = bfs(skel, w, h, far_b, bridge);
555            let (ax, ay) = coord(far_a);
556            let (bx, by) = coord(far_b);
557            let start_is_a = match hint {
558                StartHint::Topological => true,
559                StartHint::TopLeft => (ay, ax) <= (by, bx),
560                StartHint::Bottom => ay >= by,
561            };
562            let dist = if start_is_a { dist_a } else { dist_b };
563            let diameter = dist.iter().flatten().copied().max().unwrap_or(0).max(1) as f32;
564            let lead = comp.iter().map(|&c| axis(c)).min().unwrap_or(0);
565            let within = comp
566                .iter()
567                .map(|&c| (c, dist[c].map_or(0.0, |d| d as f32 / diameter)))
568                .collect();
569            (lead, within)
570        })
571        .collect();
572
573    pieces.sort_by_key(|(lead, _)| *lead);
574    for (piece, (_, within)) in pieces.iter().enumerate() {
575        for &(cell, w) in within {
576            value[cell] = piece as f32 + w;
577        }
578    }
579    value
580}
581
582#[cfg(test)]
583mod tests {
584    use super::*;
585
586    /// A straight horizontal stroke must reveal strictly along its length, i.e.
587    /// ranks increase monotonically (in one direction) and reach 1.0.
588    #[test]
589    fn straight_line_reveals_along_itself() {
590        let art = Art::parse("=========");
591        let ranks = Geodesic::default().rank(&art);
592        let row: Vec<f32> = (0..art.width())
593            .map(|x| ranks.rank_at(x, 0).unwrap())
594            .collect();
595        let increasing = row.windows(2).all(|w| w[0] <= w[1]);
596        let decreasing = row.windows(2).all(|w| w[0] >= w[1]);
597        assert!(
598            increasing || decreasing,
599            "spine reveal was not monotone: {row:?}"
600        );
601        assert!((row.iter().cloned().fold(0.0_f32, f32::max) - 1.0).abs() < 1e-6);
602    }
603
604    /// A lone fleck at the top-left must not become the spine; the long bar does.
605    #[test]
606    fn spine_traces_largest_component() {
607        let art = Art::parse(".\n\n   ========");
608        let report = Geodesic::default().diagnose(&art);
609        assert_eq!(report.ink_cells, 9);
610        assert_eq!(report.connected_cells, 8); // the bar, not the 1-cell fleck
611    }
612
613    /// Islands inherit the rank of the nearest spine tip: an island by the start
614    /// reveals early, one by the finish reveals late, not both dumped at the end.
615    #[test]
616    fn islands_inherit_nearest_spine_rank() {
617        let art = Art::parse(".  ======  .");
618        let ranks = Geodesic::default().rank(&art);
619        let left = ranks.rank_at(0, 0).unwrap();
620        let right = ranks.rank_at(11, 0).unwrap();
621        assert!(left < right, "left {left} should precede right {right}");
622        assert!(left < 0.25 && right > 0.75, "left={left} right={right}");
623    }
624
625    #[test]
626    fn diagnose_counts_connectivity() {
627        let report = Geodesic::default().diagnose(&Art::parse("==========    ."));
628        assert_eq!(report.ink_cells, 11);
629        assert_eq!(report.connected_cells, 10); // the bar; the '.' is an island
630    }
631
632    /// Fragmented art (two strokes one blank cell apart) reveals as one body: the
633    /// default bridges the gap, while `bridge: 0` keeps the strokes separate.
634    #[test]
635    fn bridges_small_gaps_when_fragmented() {
636        let art = Art::parse("== ==");
637        let strict = Geodesic {
638            start: StartHint::TopLeft,
639            bridge: 0,
640        };
641        assert_eq!(strict.diagnose(&art).connected_cells, 2);
642        assert_eq!(Geodesic::default().diagnose(&art).connected_cells, 4);
643    }
644
645    /// Already-connected art must not be bridged: shortcuts would cut across the
646    /// body and shrink the spine, so a clean stroke keeps its full-length trace.
647    #[test]
648    fn connected_art_is_not_bridged() {
649        // A zigzag whose passes sit two rows apart; bridging would short-circuit
650        // it, but since it is one strict component the spine stays long.
651        let art = Art::parse("####\n   #\n####\n#\n####");
652        let report = Geodesic::default().diagnose(&art);
653        assert_eq!(report.connected_cells, report.ink_cells);
654        assert!(
655            report.spine_length >= 9,
656            "spine was {}",
657            report.spine_length
658        );
659    }
660
661    /// A solid block has no real structure, but the reveal must still use the whole
662    /// bar (no dead zone at either end) rather than dump everything at once.
663    #[test]
664    fn solid_block_reveals_across_the_whole_bar() {
665        let art = Art::parse(&"########\n".repeat(8));
666        let r = Geodesic::default().rank(&art);
667        let ranks: Vec<f32> = (0..8)
668            .flat_map(|y| (0..8u16).map(move |x| (x, y)))
669            .map(|(x, y)| r.rank_at(x, y).unwrap())
670            .collect();
671        let lo = ranks.iter().cloned().fold(f32::MAX, f32::min);
672        let hi = ranks.iter().cloned().fold(f32::MIN, f32::max);
673        assert!(
674            lo < 0.02 && hi > 0.98,
675            "block did not use the whole bar: {lo}..{hi}"
676        );
677    }
678
679    /// Separate pieces (the strokes of a logo) reveal one after another in reading
680    /// order, each traced, rather than all at once or out of order.
681    #[test]
682    fn separate_pieces_reveal_in_reading_order() {
683        let art = Art::parse("##        ##\n##        ##\n##        ##");
684        let r = Geodesic::default().rank(&art);
685        let left = r.rank_at(0, 1).unwrap();
686        let right = r.rank_at(11, 1).unwrap();
687        assert!(
688            left < right,
689            "left piece {left} should precede right {right}"
690        );
691        assert!(
692            left < 0.5 && right > 0.5,
693            "pieces out of order: {left} {right}"
694        );
695    }
696
697    /// A thin line keeps a pure spine trace: the directional blend stays out of the
698    /// way, so the two ends are the first and last cells revealed.
699    #[test]
700    fn thin_line_stays_a_trace() {
701        let art = Art::parse("==============");
702        let r = Geodesic::default().rank(&art);
703        let row: Vec<f32> = (0..art.width()).map(|x| r.rank_at(x, 0).unwrap()).collect();
704        let lo = row.iter().cloned().fold(f32::MAX, f32::min);
705        let hi = row.iter().cloned().fold(f32::MIN, f32::max);
706        assert!(
707            lo < 0.01 && hi > 0.99,
708            "line did not trace end to end: {row:?}"
709        );
710    }
711
712    /// `Auto` weights for terminal cells being about twice as tall as wide: art
713    /// that is wider than tall in cells but reads tall still paints top to bottom;
714    /// only genuinely wide art wipes sideways.
715    #[test]
716    fn directional_auto_accounts_for_cell_aspect() {
717        // 5 wide by 4 tall: more columns than rows, yet reads tall -> top to bottom.
718        let tall = Art::parse("#####\n#####\n#####\n#####");
719        let r = Directional(Direction::Auto).rank(&tall);
720        assert!(
721            r.rank_at(0, 0).unwrap() < r.rank_at(0, 3).unwrap(),
722            "top first"
723        );
724        assert_eq!(
725            r.rank_at(0, 0),
726            r.rank_at(4, 0),
727            "same row reveals together"
728        );
729
730        // 10 wide by 2 tall: genuinely wide -> left to right.
731        let wide = Art::parse("##########\n##########");
732        let rw = Directional(Direction::Auto).rank(&wide);
733        assert!(
734            rw.rank_at(0, 0).unwrap() < rw.rank_at(9, 0).unwrap(),
735            "left first"
736        );
737        assert_eq!(
738            rw.rank_at(0, 0),
739            rw.rank_at(0, 1),
740            "same column reveals together"
741        );
742    }
743}