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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user