brush-paint: per-endpoint init_dir from skeleton tangent

trace_stroke previously hard-coded init_dir = (0, 1) (downward) for
the forward walk's first step. That worked when pick_next_component
chose a top-of-glyph endpoint and the walker wrote downward, but
broke for endpoints at the bottom of the glyph (M/W/V/U feet) —
the walker would try to step downward off the foot, terminate
immediately, and the entire stroke would degenerate to a single
disk-stamp.

Compute init_dir per-endpoint at hull-cache time: each degree-1
skeleton pixel has exactly one in-skeleton neighbor by definition;
the unit vector from endpoint to neighbor is "into the letter."
Stored in HullData::skel_endpoints_init_dir, index-aligned with
skel_endpoints. pick_next_component now returns
((start, init_dir)) and trace_stroke takes init_dir as a parameter.
Closed-shape fallback (no endpoints) keeps the old downward bias.

Bit-shift on a few letters (W +1.5pt at 3mm/150, c/e/s/6 path
shifts), but M's coverage doesn't move since the apex problem
(walker can't navigate the corner) is independent of where it
starts. The fix is principled — picks the correct dead-end and
walks inward — but the structural M/W/A failure remains.
This commit is contained in:
Mitchell Hansen
2026-05-07 23:32:29 -07:00
parent f370b99d95
commit b9acb48ebe

View File

@@ -486,6 +486,14 @@ struct HullData {
/// recomputing chamfer per call.
sdf_values_sorted: Vec<f32>,
skel_endpoints: Vec<(i32, i32)>,
/// Per-endpoint initial direction: unit vector pointing from the
/// endpoint along the skeleton into the letter (toward the
/// endpoint's single skeleton-neighbor). Index-aligned with
/// `skel_endpoints`. Used as `init_dir` for the forward walk so a
/// stroke starting at e.g. M's bottom-left foot walks UP the leg
/// (instead of trying to go down off the end of the foot, which
/// is what the old hard-coded `(0, 1)` did).
skel_endpoints_init_dir: Vec<(f32, f32)>,
skeleton_length: u32,
ink_total: i32,
}
@@ -576,13 +584,26 @@ fn compute_hull_data(hull: &Hull) -> HullData {
let mut skel = zhang_suen_thin(&hull.pixels);
let spur_len = (sdf_max * 1.5).round() as usize;
prune_skeleton_spurs(&mut skel, spur_len.max(2));
let skel_endpoints: Vec<(i32, i32)> = skel.iter()
.filter(|&&p| zs_neighbors(p.0, p.1).iter().filter(|n| skel.contains(n)).count() == 1)
.map(|&(x, y)| (x as i32, y as i32))
.collect();
// Endpoints (degree-1) and their inward-along-the-skeleton init_dir.
// The single in-skel neighbor defines which way the skeleton
// continues from the endpoint; that vector, normalized, is the
// direction the walker should head when starting from this point.
let mut skel_endpoints: Vec<(i32, i32)> = Vec::new();
let mut skel_endpoints_init_dir: Vec<(f32, f32)> = Vec::new();
for &(x, y) in &skel {
let nbrs = zs_neighbors(x, y);
let in_skel: Vec<(u32, u32)> = nbrs.iter().filter(|n| skel.contains(n)).copied().collect();
if in_skel.len() != 1 { continue; }
let nbr = in_skel[0];
let dx = nbr.0 as f32 - x as f32;
let dy = nbr.1 as f32 - y as f32;
let mag = (dx * dx + dy * dy).sqrt().max(1e-6);
skel_endpoints.push((x as i32, y as i32));
skel_endpoints_init_dir.push((dx / mag, dy / mag));
}
let skeleton_length = skel.len() as u32;
HullData { bx, by, width, height, was_ink, sdf, sdf_values_sorted,
skel_endpoints, skeleton_length, ink_total: count }
skel_endpoints, skel_endpoints_init_dir, skeleton_length, ink_total: count }
}
// ── Coverage grid: per-call mutable state, sized to the hull's bbox ─────
@@ -839,7 +860,7 @@ impl Grid {
/// `paint_fill` exit cleanly instead of burning through max_strokes
/// on phantom 1-px gap attempts.
fn pick_next_component(&mut self, min_component_pixels: u32)
-> Option<(f32, f32)>
-> Option<((f32, f32), (f32, f32))>
{
let mut comp_id = vec![-1i32; self.unpainted.len()];
let mut components: Vec<(Vec<usize>, (i32, i32, i32, i32))> = Vec::new();
@@ -900,27 +921,32 @@ impl Grid {
// falls inside the chosen component's still-unpainted ink. These
// are the natural pen-down anchors — top of B's vertical, A's
// bottom-left, G's top-right, etc. Pick the topmost-leftmost.
// Fall back to the topmost-leftmost ink pixel if no endpoint is
// available (closed shapes like O, or after a partial fill where
// every endpoint sits in already-painted territory).
// Each endpoint also carries its skeleton-tangent init_dir
// (pointing inward into the letter), so the forward walker
// heads correctly into the glyph regardless of where on it
// the endpoint sits. Fall back to the topmost-leftmost ink
// pixel + downward init_dir if no endpoint is available
// (closed shapes like O).
let (pixels, _) = &components[chosen];
let comp_set: HashSet<usize> = pixels.iter().copied().collect();
let mut best_endpoint: Option<(i32, i32)> = None;
for &(ex, ey) in self.hull.skel_endpoints.iter() {
let mut best_endpoint: Option<((i32, i32), (f32, f32))> = None;
for (i, &(ex, ey)) in self.hull.skel_endpoints.iter().enumerate() {
let lx = ex - self.bx; let ly = ey - self.by;
if lx < 0 || ly < 0 || lx >= self.width || ly >= self.height { continue; }
let idx = (ly * self.width + lx) as usize;
if !comp_set.contains(&idx) { continue; }
let init_dir = self.hull.skel_endpoints_init_dir
.get(i).copied().unwrap_or((0.0, 1.0));
match best_endpoint {
None => best_endpoint = Some((ex, ey)),
Some((bx_e, by_e)) if ey < by_e || (ey == by_e && ex < bx_e) => {
best_endpoint = Some((ex, ey));
None => best_endpoint = Some(((ex, ey), init_dir)),
Some(((bx_e, by_e), _)) if ey < by_e || (ey == by_e && ex < bx_e) => {
best_endpoint = Some(((ex, ey), init_dir));
}
_ => {}
}
}
let raw = match best_endpoint {
Some((ex, ey)) => (ex as f32, ey as f32),
let (raw, init_dir) = match best_endpoint {
Some(((ex, ey), d)) => ((ex as f32, ey as f32), d),
None => {
let mut best_pixel: (i32, i32) = (i32::MAX, i32::MAX);
for &idx in pixels {
@@ -931,10 +957,10 @@ impl Grid {
best_pixel = abs;
}
}
(best_pixel.0 as f32, best_pixel.1 as f32)
((best_pixel.0 as f32, best_pixel.1 as f32), (0.0, 1.0))
}
};
Some(self.snap_to_ridge(raw, 16))
Some((self.snap_to_ridge(raw, 16), init_dir))
}
}
@@ -1125,8 +1151,8 @@ fn lookahead_score_breakdown(start: (f32, f32), dir: (f32, f32),
/// path slightly into a corner can paint pixels that the greedy walk
/// missed without losing pixels elsewhere. This folds "spurious cleanup
/// strokes" back into the main path.
fn trace_stroke(start: (f32, f32), grid: &mut Grid,
params: &PaintParams, brush_radius: f32,
fn trace_stroke(start: (f32, f32), init_dir: (f32, f32),
grid: &mut Grid, params: &PaintParams, brush_radius: f32,
walk_log: Option<&mut Vec<WalkTrace>>,
stroke_idx: u32) -> Vec<(f32, f32)>
{
@@ -1136,7 +1162,12 @@ fn trace_stroke(start: (f32, f32), grid: &mut Grid,
let mut walk_log = walk_log;
// ── Bidirectional walk ──────────────────────────────────────────────
let forward_init = Some((0.0_f32, 1.0_f32));
// init_dir comes from the seeding step — the skeleton-tangent at the
// starting endpoint, pointing into the letter. This replaces the old
// hard-coded downward bias which only worked when the start was at
// the top of the glyph. Now starting at e.g. M's bottom-left foot
// walks UP the leg as expected.
let forward_init = Some(init_dir);
let mut forward_trace = walk_log.as_ref().map(|_| WalkTrace {
kind: "forward".into(), stroke_idx, start,
init_dir: forward_init, brush_radius, step_size, min_score,
@@ -1198,10 +1229,10 @@ pub fn paint_fill_with(hull: &Hull, params: &PaintParams) -> FillResult {
for stroke_idx in 0..params.max_strokes {
if grid.ink_remaining <= 0 { break; }
let start = match grid.pick_next_component(min_component_pixels) {
let (start, init_dir) = match grid.pick_next_component(min_component_pixels) {
Some(s) => s, None => break,
};
let path = trace_stroke(start, &mut grid, params, brush_radius, None, stroke_idx);
let path = trace_stroke(start, init_dir, &mut grid, params, brush_radius, None, stroke_idx);
if path.len() >= 2 {
let simplified = if params.output_rdp_eps > 0.0 {
rdp_simplify_f32(&path, params.output_rdp_eps)
@@ -1559,11 +1590,11 @@ fn paint_fill_debug_inner(hull: &Hull, params: &PaintParams,
let mut walks: Vec<WalkTrace> = Vec::new();
for stroke_idx in 0..params.max_strokes {
if grid.ink_remaining <= 0 { break; }
let start = match grid.pick_next_component(min_component_pixels) {
let (start, init_dir) = match grid.pick_next_component(min_component_pixels) {
Some(s) => s, None => break,
};
let walk_log = if record_walks { Some(&mut walks) } else { None };
let path = trace_stroke(start, &mut grid, params, brush_radius,
let path = trace_stroke(start, init_dir, &mut grid, params, brush_radius,
walk_log, stroke_idx);
if path.len() >= 2 {
// Record path[0] as the "start" — that's where the gcode