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}