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.
|
/// recomputing chamfer per call.
|
||||||
sdf_values_sorted: Vec<f32>,
|
sdf_values_sorted: Vec<f32>,
|
||||||
skel_endpoints: Vec<(i32, i32)>,
|
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,
|
skeleton_length: u32,
|
||||||
ink_total: i32,
|
ink_total: i32,
|
||||||
}
|
}
|
||||||
@@ -576,13 +584,26 @@ fn compute_hull_data(hull: &Hull) -> HullData {
|
|||||||
let mut skel = zhang_suen_thin(&hull.pixels);
|
let mut skel = zhang_suen_thin(&hull.pixels);
|
||||||
let spur_len = (sdf_max * 1.5).round() as usize;
|
let spur_len = (sdf_max * 1.5).round() as usize;
|
||||||
prune_skeleton_spurs(&mut skel, spur_len.max(2));
|
prune_skeleton_spurs(&mut skel, spur_len.max(2));
|
||||||
let skel_endpoints: Vec<(i32, i32)> = skel.iter()
|
// Endpoints (degree-1) and their inward-along-the-skeleton init_dir.
|
||||||
.filter(|&&p| zs_neighbors(p.0, p.1).iter().filter(|n| skel.contains(n)).count() == 1)
|
// The single in-skel neighbor defines which way the skeleton
|
||||||
.map(|&(x, y)| (x as i32, y as i32))
|
// continues from the endpoint; that vector, normalized, is the
|
||||||
.collect();
|
// 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;
|
let skeleton_length = skel.len() as u32;
|
||||||
HullData { bx, by, width, height, was_ink, sdf, sdf_values_sorted,
|
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 ─────
|
// ── 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
|
/// `paint_fill` exit cleanly instead of burning through max_strokes
|
||||||
/// on phantom 1-px gap attempts.
|
/// on phantom 1-px gap attempts.
|
||||||
fn pick_next_component(&mut self, min_component_pixels: u32)
|
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 comp_id = vec![-1i32; self.unpainted.len()];
|
||||||
let mut components: Vec<(Vec<usize>, (i32, i32, i32, i32))> = Vec::new();
|
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
|
// falls inside the chosen component's still-unpainted ink. These
|
||||||
// are the natural pen-down anchors — top of B's vertical, A's
|
// are the natural pen-down anchors — top of B's vertical, A's
|
||||||
// bottom-left, G's top-right, etc. Pick the topmost-leftmost.
|
// bottom-left, G's top-right, etc. Pick the topmost-leftmost.
|
||||||
// Fall back to the topmost-leftmost ink pixel if no endpoint is
|
// Each endpoint also carries its skeleton-tangent init_dir
|
||||||
// available (closed shapes like O, or after a partial fill where
|
// (pointing inward into the letter), so the forward walker
|
||||||
// every endpoint sits in already-painted territory).
|
// 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 (pixels, _) = &components[chosen];
|
||||||
let comp_set: HashSet<usize> = pixels.iter().copied().collect();
|
let comp_set: HashSet<usize> = pixels.iter().copied().collect();
|
||||||
let mut best_endpoint: Option<(i32, i32)> = None;
|
let mut best_endpoint: Option<((i32, i32), (f32, f32))> = None;
|
||||||
for &(ex, ey) in self.hull.skel_endpoints.iter() {
|
for (i, &(ex, ey)) in self.hull.skel_endpoints.iter().enumerate() {
|
||||||
let lx = ex - self.bx; let ly = ey - self.by;
|
let lx = ex - self.bx; let ly = ey - self.by;
|
||||||
if lx < 0 || ly < 0 || lx >= self.width || ly >= self.height { continue; }
|
if lx < 0 || ly < 0 || lx >= self.width || ly >= self.height { continue; }
|
||||||
let idx = (ly * self.width + lx) as usize;
|
let idx = (ly * self.width + lx) as usize;
|
||||||
if !comp_set.contains(&idx) { continue; }
|
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 {
|
match best_endpoint {
|
||||||
None => 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) => {
|
Some(((bx_e, by_e), _)) if ey < by_e || (ey == by_e && ex < bx_e) => {
|
||||||
best_endpoint = Some((ex, ey));
|
best_endpoint = Some(((ex, ey), init_dir));
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let raw = match best_endpoint {
|
let (raw, init_dir) = match best_endpoint {
|
||||||
Some((ex, ey)) => (ex as f32, ey as f32),
|
Some(((ex, ey), d)) => ((ex as f32, ey as f32), d),
|
||||||
None => {
|
None => {
|
||||||
let mut best_pixel: (i32, i32) = (i32::MAX, i32::MAX);
|
let mut best_pixel: (i32, i32) = (i32::MAX, i32::MAX);
|
||||||
for &idx in pixels {
|
for &idx in pixels {
|
||||||
@@ -931,10 +957,10 @@ impl Grid {
|
|||||||
best_pixel = abs;
|
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
|
/// path slightly into a corner can paint pixels that the greedy walk
|
||||||
/// missed without losing pixels elsewhere. This folds "spurious cleanup
|
/// missed without losing pixels elsewhere. This folds "spurious cleanup
|
||||||
/// strokes" back into the main path.
|
/// strokes" back into the main path.
|
||||||
fn trace_stroke(start: (f32, f32), grid: &mut Grid,
|
fn trace_stroke(start: (f32, f32), init_dir: (f32, f32),
|
||||||
params: &PaintParams, brush_radius: f32,
|
grid: &mut Grid, params: &PaintParams, brush_radius: f32,
|
||||||
walk_log: Option<&mut Vec<WalkTrace>>,
|
walk_log: Option<&mut Vec<WalkTrace>>,
|
||||||
stroke_idx: u32) -> Vec<(f32, f32)>
|
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;
|
let mut walk_log = walk_log;
|
||||||
|
|
||||||
// ── Bidirectional walk ──────────────────────────────────────────────
|
// ── 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 {
|
let mut forward_trace = walk_log.as_ref().map(|_| WalkTrace {
|
||||||
kind: "forward".into(), stroke_idx, start,
|
kind: "forward".into(), stroke_idx, start,
|
||||||
init_dir: forward_init, brush_radius, step_size, min_score,
|
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 {
|
for stroke_idx in 0..params.max_strokes {
|
||||||
if grid.ink_remaining <= 0 { break; }
|
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,
|
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 {
|
if path.len() >= 2 {
|
||||||
let simplified = if params.output_rdp_eps > 0.0 {
|
let simplified = if params.output_rdp_eps > 0.0 {
|
||||||
rdp_simplify_f32(&path, params.output_rdp_eps)
|
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();
|
let mut walks: Vec<WalkTrace> = Vec::new();
|
||||||
for stroke_idx in 0..params.max_strokes {
|
for stroke_idx in 0..params.max_strokes {
|
||||||
if grid.ink_remaining <= 0 { break; }
|
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,
|
Some(s) => s, None => break,
|
||||||
};
|
};
|
||||||
let walk_log = if record_walks { Some(&mut walks) } else { None };
|
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);
|
walk_log, stroke_idx);
|
||||||
if path.len() >= 2 {
|
if path.len() >= 2 {
|
||||||
// Record path[0] as the "start" — that's where the gcode
|
// Record path[0] as the "start" — that's where the gcode
|
||||||
|
|||||||
Reference in New Issue
Block a user