brush-paint: prefer bottommost-leftmost endpoint as stroke seed

Swapped the endpoint-priority comparison in pick_next_component
from topmost-leftmost to bottommost-leftmost. For most Latin
letters the natural pen-down anchor sits at the bottom (M/W/V/U
feet, A's foot, vertical-stem letters' base). Top-of-glyph
endpoints are still candidates — they just lose to a bottom one
when both exist in the same component.

For M at 5mm/425 specifically, the first stroke now starts at
the bottom-left foot (108, 270) and walks up with init_dir=(0, -1),
where it previously started at the top-left peak (108, 146) and
walked down. Stroke count dropped 4 → 3 at that scale.

Corpus metrics are roughly flat (±0.5% coverage per scale, ±1-2
total strokes per scale). The change is principled — pen-down
anchors at the bottom for most letters — without regressing
overall coverage.

Adds a paint_diagnose_endpoints test (env-driven, #[ignore]) for
inspecting endpoint positions + init_dirs + first-stroke start
for any letter+scale.
This commit is contained in:
Mitchell Hansen
2026-05-07 23:45:31 -07:00
parent b9acb48ebe
commit dd2e3135d7

View File

@@ -939,7 +939,13 @@ impl Grid {
.get(i).copied().unwrap_or((0.0, 1.0)); .get(i).copied().unwrap_or((0.0, 1.0));
match best_endpoint { match best_endpoint {
None => best_endpoint = Some(((ex, ey), init_dir)), None => best_endpoint = Some(((ex, ey), init_dir)),
Some(((bx_e, by_e), _)) if ey < by_e || (ey == by_e && ex < bx_e) => { // Bottommost first, leftmost tiebreaker. For most Latin
// letters the natural pen-down anchor is at the bottom
// (M/W/V/U feet, A's foot, vertical-stem letters' base).
// Top-of-glyph endpoints are still candidates — they
// just lose to a bottom one when both exist in the
// same component.
Some(((bx_e, by_e), _)) if ey > by_e || (ey == by_e && ex < bx_e) => {
best_endpoint = Some(((ex, ey), init_dir)); best_endpoint = Some(((ex, ey), init_dir));
} }
_ => {} _ => {}
@@ -1690,6 +1696,44 @@ mod tests {
} }
} }
/// Print the skeleton endpoints + first-stroke start for one letter.
/// Run as: cargo test --release --lib paint_diagnose_endpoints -- --ignored --nocapture
/// Pick char + scale via env: PD_CHAR=M PD_MM=8 PD_DPI=425 PD_THICK=9
#[test]
#[ignore]
fn paint_diagnose_endpoints() {
let ch: char = std::env::var("PD_CHAR").ok().and_then(|s| s.chars().next()).unwrap_or('M');
let font_mm: f32 = std::env::var("PD_MM").ok().and_then(|s| s.parse().ok()).unwrap_or(8.0);
let dpi: u32 = std::env::var("PD_DPI").ok().and_then(|s| s.parse().ok()).unwrap_or(425);
let thick: u32 = std::env::var("PD_THICK").ok().and_then(|s| s.parse().ok()).unwrap_or(9);
let hulls = rasterize_letter_at(ch, font_mm, dpi, thick);
let main = match hulls.iter().max_by_key(|h| h.area) {
Some(h) => h, None => { println!("no hull"); return; }
};
let h = get_or_compute_hull_data(main);
println!("\n=== '{}' @ {}mm/{}dpi/{}px ===", ch, font_mm, dpi, thick);
println!("bbox: x [{}, {}], y [{}, {}] w={} h={}",
main.bounds.x_min, main.bounds.x_max,
main.bounds.y_min, main.bounds.y_max,
h.width, h.height);
println!("skeleton endpoints ({}):", h.skel_endpoints.len());
for (i, &(ex, ey)) in h.skel_endpoints.iter().enumerate() {
let d = h.skel_endpoints_init_dir.get(i).copied().unwrap_or((0.0, 0.0));
println!(" #{} pos=({}, {}) init_dir=({:+.2}, {:+.2})", i, ex, ey, d.0, d.1);
}
// Run paint_fill_debug and report the first stroke's start.
let dbg = paint_fill_debug(main, &PaintParams::default());
println!("brush_radius = {:.2} px", dbg.brush_radius);
println!("first stroke starts: {:?}", dbg.start_points.first());
println!("first walk init_dir: {:?}", dbg.walks.first().map(|w| w.init_dir));
println!("strokes: {}", dbg.strokes.len());
for (i, s) in dbg.strokes.iter().enumerate().take(6) {
if s.is_empty() { continue; }
println!(" stroke #{}: {} pts, start ({:.1}, {:.1}), end ({:.1}, {:.1})",
i, s.len(), s[0].0, s[0].1, s.last().unwrap().0, s.last().unwrap().1);
}
}
#[test] #[test]
fn paint_letter_I_is_one_stroke() { fn paint_letter_I_is_one_stroke() {
let hulls = rasterize_letter_at('I', 8.0, 200, 4); let hulls = rasterize_letter_at('I', 8.0, 200, 4);