brush-paint: remove Dijkstra repaint fallback (~110 lines)

Walker had a fallback path for "stuck" cases: SDF-weighted Dijkstra
through painted ink to reach the nearest unpainted pixel, gated by
`pen_lift_penalty`. Default was 0, which effectively disabled it
(any nonzero path cost broke the walker). The optimizer never
landed on a meaningfully-large value either, and last attempt to
push it (=50) produced visually-bad smear behaviour.

Removing:
- `nearest_unpainted_through_ink` function (~85 lines)
- `pen_lift_penalty`, `pen_lift_reach` from PaintParams + Default
- Both axes from brush_paint_opt and the duplicate-axes test
- `pen_lift_*` from frontend mirror and lib.rs's per-hull optimizer
- The Dijkstra branch in walk_brush — now `score < min_score ||
  would_be_stuck` just BREAKs cleanly.

Strokes now end where the walker decides they're done; there's no
"keep going through painted ink to find more unpainted." Multi-leg
letters (P, q, M's right downstroke) will need to be reached by
multiple strokes from `pick_next_component`.
This commit is contained in:
Mitchell Hansen
2026-05-06 23:22:01 -07:00
parent da653eb881
commit 7ce1fea64f
4 changed files with 15 additions and 154 deletions

View File

@@ -52,8 +52,6 @@ export const DEFAULT_PAINT_PARAMS = {
polish_search_factor: 0.5, polish_search_factor: 0.5,
bg_penalty: 2.0, bg_penalty: 2.0,
min_component_factor: 1.49, min_component_factor: 1.49,
pen_lift_penalty: 0.0,
pen_lift_reach: 3.0,
max_steps_per_stroke: 4000, max_steps_per_stroke: 4000,
max_strokes: 12, max_strokes: 12,
output_rdp_eps: 1.98, output_rdp_eps: 1.98,

View File

@@ -118,24 +118,10 @@ pub struct PaintParams {
/// the brush is wider than the stroke. /// the brush is wider than the stroke.
pub bg_penalty: f32, pub bg_penalty: f32,
/// Minimum unpainted-ink component size (as a multiplier of brush /// Minimum unpainted-ink component size (as a multiplier of brush
/// area = π·r²) to start a new stroke. Components smaller than this /// area = π·r²) to be eligible as a stroke seed. Sub-threshold
/// are leftovers from the previous stroke's brush sweep that the /// components stay in the unpainted mask and count against
/// relaxation didn't catch — we paint them with a single disk and /// coverage but don't get a stroke of their own.
/// move on instead of attempting a doomed walk. 1.0 = "must be at
/// least one full brush-disc worth of unpainted ink."
pub min_component_factor: f32, pub min_component_factor: f32,
/// "Pen lift" cost: how many ink-pixel-equivalents of overpaint+bg cost
/// the walker is willing to absorb to reach unpainted ink without
/// terminating the stroke. 0 = always terminate when local lookahead
/// dries up (= stroke per blob). Higher values let the walker double
/// back across already-painted ink to bridge to a new ink region —
/// e.g. M's bottom-V apex, where one stroke can naturally cover both
/// diagonals if it can pass through the painted apex.
pub pen_lift_penalty: f32,
/// How far (in brush radii) the bridge lookahead can reach when the
/// normal lookahead's best score is below `min_score`. Bridging only
/// kicks in when this is > step_size_factor × lookahead_steps.
pub pen_lift_reach: f32,
/// Cap. /// Cap.
pub max_steps_per_stroke: u32, pub max_steps_per_stroke: u32,
pub max_strokes: u32, pub max_strokes: u32,
@@ -167,8 +153,6 @@ impl Default for PaintParams {
polish_search_factor: 0.5, polish_search_factor: 0.5,
bg_penalty: 2.0, bg_penalty: 2.0,
min_component_factor: 1.49, min_component_factor: 1.49,
pen_lift_penalty: 0.0,
pen_lift_reach: 3.0,
max_steps_per_stroke: 4000, max_steps_per_stroke: 4000,
max_strokes: 12, max_strokes: 12,
output_rdp_eps: 1.98, output_rdp_eps: 1.98,
@@ -834,93 +818,6 @@ fn vec_dot(a: (f32, f32), b: (f32, f32)) -> f32 { a.0 * b.0 + a.1 * b.1 }
/// has a disk straddling both legs (lots of new ink), but the same disk /// has a disk straddling both legs (lots of new ink), but the same disk
/// pokes into bg on the outside of the bend. With walk_bg_penalty /// pokes into bg on the outside of the bend. With walk_bg_penalty
/// heavy, the cut loses to the inside-corner-following direction. /// heavy, the cut loses to the inside-corner-following direction.
/// Find the nearest unpainted ink pixel reachable from `start` by walking
/// only through ink (painted OR unpainted), using SDF-weighted Dijkstra
/// so the path hugs centerlines (high-SDF ridges are cheap, near-edge
/// pixels are expensive). Caller passes `max_pixel_radius` — the search
/// is cut off beyond that euclidean distance from start. Returns the
/// next-step direction toward the target, or None if no target is
/// reachable inside the budget. Cost is in "step pixels" so the caller
/// can compare it directly against `pen_lift_penalty`.
fn nearest_unpainted_through_ink(start: (f32, f32), grid: &Grid,
max_pixel_radius: f32)
-> Option<((f32, f32), f32)>
{
use std::collections::BinaryHeap;
use std::cmp::Reverse;
let sx = start.0.round() as i32;
let sy = start.1.round() as i32;
if !grid.is_ink(sx, sy) { return None; }
if grid.is_unpainted(sx, sy) {
// Nothing to do — caller would have a positive score in that case.
return Some(((0.0, 0.0), 0.0));
}
let r = max_pixel_radius.ceil() as i32;
let r2 = max_pixel_radius * max_pixel_radius;
let bx = sx - r;
let by = sy - r;
let span = 2 * r as usize + 1;
let cells = span * span;
let mut dist = vec![f32::INFINITY; cells];
let mut prev = vec![(-1i32, -1i32); cells];
let local = |x: i32, y: i32| -> Option<usize> {
let lx = x - bx; let ly = y - by;
if lx < 0 || ly < 0 || lx as usize >= span || ly as usize >= span { return None; }
Some(ly as usize * span + lx as usize)
};
let s_idx = local(sx, sy)?;
dist[s_idx] = 0.0;
let mut heap: BinaryHeap<(Reverse<u32>, i32, i32)> = BinaryHeap::new();
heap.push((Reverse(0), sx, sy));
while let Some((Reverse(d_int), x, y)) = heap.pop() {
let here = local(x, y)?;
let d = d_int as f32 / 1024.0;
if d > dist[here] + 1e-3 { continue; }
if (x, y) != (sx, sy) && grid.is_unpainted(x, y) {
// Reconstruct: walk prev[] back to start, take FIRST step.
let mut cx = x; let mut cy = y;
loop {
let idx = local(cx, cy).unwrap();
let (px, py) = prev[idx];
if (px, py) == (sx, sy) || (px, py) == (-1, -1) {
let dx = (cx - sx) as f32;
let dy = (cy - sy) as f32;
let mag = (dx * dx + dy * dy).sqrt().max(1e-6);
return Some(((dx / mag, dy / mag), d));
}
cx = px; cy = py;
}
}
for &(dx, dy) in &[(1,0i32),(-1,0),(0,1),(0,-1),(1,1),(1,-1),(-1,1),(-1,-1)] {
let nx = x + dx; let ny = y + dy;
if !grid.is_ink(nx, ny) { continue; }
// Stay inside the radius budget.
let rdx = (nx - sx) as f32; let rdy = (ny - sy) as f32;
if rdx * rdx + rdy * rdy > r2 { continue; }
// Step cost: euclidean length × ridge-aversion factor. High
// SDF (ridge interior) → cheap. Low SDF (near edge) → expensive.
// The +0.5 keeps the factor finite at boundary pixels.
let step_len = if dx != 0 && dy != 0 { 1.41421356 } else { 1.0 };
let ridge = grid.sdf_at(nx, ny);
let factor = 1.0 + 1.5 / (ridge + 0.5);
let nd = d + step_len * factor;
let nidx = match local(nx, ny) { Some(i) => i, None => continue };
if nd < dist[nidx] {
dist[nidx] = nd;
prev[nidx] = (x, y);
heap.push((Reverse((nd * 1024.0) as u32), nx, ny));
}
}
}
None
}
/// Walk the brush in one direction from `start` until it dead-ends. /// Walk the brush in one direction from `start` until it dead-ends.
/// `init_dir` seeds the momentum so the brush prefers a specific /// `init_dir` seeds the momentum so the brush prefers a specific
/// direction at the first step (used for the "walk backwards" pass). /// direction at the first step (used for the "walk backwards" pass).
@@ -1023,37 +920,18 @@ fn walk_brush(start: (f32, f32), init_dir: Option<(f32, f32)>,
nc == 0 && grid.evaluate_disk(p, brush_radius).0 == 0 nc == 0 && grid.evaluate_disk(p, brush_radius).0 == 0
}; };
let chosen_dir = if score >= min_score && !would_be_stuck { if score < min_score || would_be_stuck {
dir exit_reason = if score < min_score { "score_below_min".into() } else { "stuck".into() };
} else { if let Some(t) = trace.as_deref_mut() {
// Repaint Dijkstra fallback (disabled when pen_lift_penalty=0). t.steps.push(WalkStep {
if params.pen_lift_penalty <= 0.0 { idx: step_idx, p, prev_dir,
exit_reason = if score < min_score { "score_below_min".into() } else { "stuck".into() }; candidates: recorded,
if let Some(t) = trace.as_deref_mut() { chosen: None, new_p: None,
t.steps.push(WalkStep { });
idx: step_idx, p, prev_dir,
candidates: recorded,
chosen: None, new_p: None,
});
}
break;
} }
let max_radius = params.pen_lift_reach * brush_radius; break;
match nearest_unpainted_through_ink(p, grid, max_radius) { }
Some((rd, cost)) if cost <= params.pen_lift_penalty => rd, let chosen_dir = dir;
_ => {
exit_reason = "repaint_search_failed".into();
if let Some(t) = trace.as_deref_mut() {
t.steps.push(WalkStep {
idx: step_idx, p, prev_dir,
candidates: recorded,
chosen: None, new_p: None,
});
}
break;
}
}
};
let new_p = (p.0 + chosen_dir.0 * step_size, p.1 + chosen_dir.1 * step_size); let new_p = (p.0 + chosen_dir.0 * step_size, p.1 + chosen_dir.1 * step_size);
@@ -2138,7 +2016,6 @@ mod tests {
println!(" polish_iters = {}", best.params.polish_iters); println!(" polish_iters = {}", best.params.polish_iters);
println!(" polish_search_factor = {:.2}", best.params.polish_search_factor); println!(" polish_search_factor = {:.2}", best.params.polish_search_factor);
println!(" bg_penalty = {:.2}", best.params.bg_penalty); println!(" bg_penalty = {:.2}", best.params.bg_penalty);
println!(" pen_lift_penalty = {:.1}", best.params.pen_lift_penalty);
println!(" min_component_factor = {:.2}", best.params.min_component_factor); println!(" min_component_factor = {:.2}", best.params.min_component_factor);
} }
@@ -2211,10 +2088,6 @@ mod tests {
set: |p, v| p.min_score_factor = v, get: |p| p.min_score_factor }, set: |p, v| p.min_score_factor = v, get: |p| p.min_score_factor },
Axis { name: "min_component_factor", lo: 0.10, hi: 1.50, is_int: false, Axis { name: "min_component_factor", lo: 0.10, hi: 1.50, is_int: false,
set: |p, v| p.min_component_factor = v, get: |p| p.min_component_factor }, set: |p, v| p.min_component_factor = v, get: |p| p.min_component_factor },
Axis { name: "pen_lift_penalty", lo: 0.0, hi: 200.0, is_int: false,
set: |p, v| p.pen_lift_penalty = v, get: |p| p.pen_lift_penalty },
Axis { name: "pen_lift_reach", lo: 0.5, hi: 16.0, is_int: false,
set: |p, v| p.pen_lift_reach = v, get: |p| p.pen_lift_reach },
Axis { name: "output_rdp_eps", lo: 0.0, hi: 2.0, is_int: false, Axis { name: "output_rdp_eps", lo: 0.0, hi: 2.0, is_int: false,
set: |p, v| p.output_rdp_eps = v, get: |p| p.output_rdp_eps }, set: |p, v| p.output_rdp_eps = v, get: |p| p.output_rdp_eps },
]; ];
@@ -2375,8 +2248,6 @@ mod tests {
println!(" polish_search_factor = {:.2} (default {:.2})", current.polish_search_factor, base.polish_search_factor); println!(" polish_search_factor = {:.2} (default {:.2})", current.polish_search_factor, base.polish_search_factor);
println!(" bg_penalty = {:.2} (default {:.2})", current.bg_penalty, base.bg_penalty); println!(" bg_penalty = {:.2} (default {:.2})", current.bg_penalty, base.bg_penalty);
println!(" min_component_factor = {:.2} (default {:.2})", current.min_component_factor, base.min_component_factor); println!(" min_component_factor = {:.2} (default {:.2})", current.min_component_factor, base.min_component_factor);
println!(" pen_lift_penalty = {:.1} (default {:.1})", current.pen_lift_penalty, base.pen_lift_penalty);
println!(" pen_lift_reach = {:.1} (default {:.1})", current.pen_lift_reach, base.pen_lift_reach);
println!(" output_rdp_eps = {:.2} (default {:.2})", current.output_rdp_eps, base.output_rdp_eps); println!(" output_rdp_eps = {:.2} (default {:.2})", current.output_rdp_eps, base.output_rdp_eps);
// Per-letter breakdown at 5mm/425dpi for the constraint set. // Per-letter breakdown at 5mm/425dpi for the constraint set.

View File

@@ -59,7 +59,7 @@ pub fn default_axes() -> Vec<Axis> {
set: |p, v| p.overpaint_penalty = v, get: |p| p.overpaint_penalty }, set: |p, v| p.overpaint_penalty = v, get: |p| p.overpaint_penalty },
Axis { name: "step_size_factor", lo: 0.20, hi: 0.90, is_int: false, Axis { name: "step_size_factor", lo: 0.20, hi: 0.90, is_int: false,
set: |p, v| p.step_size_factor = v, get: |p| p.step_size_factor }, set: |p, v| p.step_size_factor = v, get: |p| p.step_size_factor },
Axis { name: "lookahead_steps", lo: 5.0, hi: 10.0, is_int: true, Axis { name: "lookahead_steps", lo: 3.0, hi: 8.0, is_int: true,
set: |p, v| p.lookahead_steps = v as usize, get: |p| p.lookahead_steps as f32 }, set: |p, v| p.lookahead_steps = v as usize, get: |p| p.lookahead_steps as f32 },
Axis { name: "n_directions", lo: 8.0, hi: 64.0, is_int: true, Axis { name: "n_directions", lo: 8.0, hi: 64.0, is_int: true,
set: |p, v| p.n_directions = v as usize, get: |p| p.n_directions as f32 }, set: |p, v| p.n_directions = v as usize, get: |p| p.n_directions as f32 },
@@ -71,10 +71,6 @@ pub fn default_axes() -> Vec<Axis> {
set: |p, v| p.back_dir_cutoff = v, get: |p| p.back_dir_cutoff }, set: |p, v| p.back_dir_cutoff = v, get: |p| p.back_dir_cutoff },
Axis { name: "min_component_factor", lo: 0.10, hi: 1.50, is_int: false, Axis { name: "min_component_factor", lo: 0.10, hi: 1.50, is_int: false,
set: |p, v| p.min_component_factor = v, get: |p| p.min_component_factor }, set: |p, v| p.min_component_factor = v, get: |p| p.min_component_factor },
Axis { name: "pen_lift_penalty", lo: 0.0, hi: 25.0, is_int: false,
set: |p, v| p.pen_lift_penalty = v, get: |p| p.pen_lift_penalty },
Axis { name: "pen_lift_reach", lo: 1.0, hi: 6.0, is_int: false,
set: |p, v| p.pen_lift_reach = v, get: |p| p.pen_lift_reach },
Axis { name: "output_rdp_eps", lo: 0.0, hi: 2.0, is_int: false, Axis { name: "output_rdp_eps", lo: 0.0, hi: 2.0, is_int: false,
set: |p, v| p.output_rdp_eps = v, get: |p| p.output_rdp_eps }, set: |p, v| p.output_rdp_eps = v, get: |p| p.output_rdp_eps },
] ]

View File

@@ -1107,10 +1107,6 @@ fn optimize_paint_params(
|p, v| p.min_score_factor = v), |p, v| p.min_score_factor = v),
("min_component_factor", vec![0.2, 0.4, 0.6, 0.8, 1.2], ("min_component_factor", vec![0.2, 0.4, 0.6, 0.8, 1.2],
|p, v| p.min_component_factor = v), |p, v| p.min_component_factor = v),
("pen_lift_penalty", vec![0.0, 10.0, 30.0, 60.0, 120.0],
|p, v| p.pen_lift_penalty = v),
("pen_lift_reach", vec![1.0, 3.0, 6.0, 10.0, 16.0],
|p, v| p.pen_lift_reach = v),
("output_rdp_eps", vec![0.0, 0.25, 0.5, 1.0], ("output_rdp_eps", vec![0.0, 0.25, 0.5, 1.0],
|p, v| p.output_rdp_eps = v), |p, v| p.output_rdp_eps = v),
]; ];