diff --git a/src/brush_paint.rs b/src/brush_paint.rs index d7566a90..de89fed8 100644 --- a/src/brush_paint.rs +++ b/src/brush_paint.rs @@ -486,6 +486,14 @@ struct HullData { /// recomputing chamfer per call. sdf_values_sorted: Vec, 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, (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 = 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>, 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 = 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