brush-paint: remove polish_path post-walk relaxation (~270 lines)

trace_stroke was: bidirectional walk → snapshot/restore grid →
polish (relax + shorten over polish_iters rounds) → re-paint disks.
Polish was nudging waypoints toward unpainted ink and pruning
redundant ones — extra plumbing on top of the walker's own decisions.

Removing all of it:
- polish_path, relax_step, shorten_step, waypoint_is_redundant,
  stamp_count, nearest_uncovered_ink, evaluate_perturbation
- polish_iters, polish_search_factor, bg_penalty PaintParams
  (walk_bg_penalty stays — it's the WALKER's bg term, separate)
- Three optimizer axes (in brush_paint_opt + the duplicate-axes
  test + lib.rs's per-hull optimizer)
- The grid snapshot/restore in trace_stroke (no longer needed
  with no post-walk relaxation step)
- Frontend mirror entries

trace_stroke is now: forward walk → backward walk from same start
→ concat. Whatever the walker produces is the final path.
This commit is contained in:
Mitchell Hansen
2026-05-06 23:34:12 -07:00
parent 7ce1fea64f
commit 84d8af67f3
4 changed files with 23 additions and 320 deletions

View File

@@ -48,9 +48,6 @@ export const DEFAULT_PAINT_PARAMS = {
walk_bg_penalty: 0.69,
min_score_factor: 0.20,
back_dir_cutoff: -0.7,
polish_iters: 2,
polish_search_factor: 0.5,
bg_penalty: 2.0,
min_component_factor: 1.49,
max_steps_per_stroke: 4000,
max_strokes: 12,

View File

@@ -102,21 +102,6 @@ pub struct PaintParams {
/// admits sharper turns (down to direct reversals); closer to 0
/// rejects mild backward components.
pub back_dir_cutoff: f32,
/// Number of relax↔shorten tick-tock rounds after the bidirectional
/// walk. Each round runs (a) waypoint relaxation toward unpainted ink,
/// then (b) waypoint pruning where it doesn't lose coverage. 0 disables.
pub polish_iters: u32,
/// How far (in brush radii) to search for unpainted ink near each
/// waypoint during relaxation.
pub polish_search_factor: f32,
/// Per-pixel penalty when the brush hangs over background (off-glyph
/// disk overlap). 1.0 = "1 bg pixel under brush is worth 1 ink pixel
/// of coverage." Used by the polish/relax pass to bias waypoints
/// onto the centerline. The walker proper enforces ink containment
/// as a hard constraint (waypoint center must be on ink) and does
/// not include this term — bg under the disk is unavoidable when
/// the brush is wider than the stroke.
pub bg_penalty: f32,
/// Minimum unpainted-ink component size (as a multiplier of brush
/// area = π·r²) to be eligible as a stroke seed. Sub-threshold
/// components stay in the unpainted mask and count against
@@ -149,9 +134,6 @@ impl Default for PaintParams {
walk_bg_penalty: 0.69,
min_score_factor: 0.20,
back_dir_cutoff: -0.7,
polish_iters: 2,
polish_search_factor: 0.5,
bg_penalty: 2.0,
min_component_factor: 1.49,
max_steps_per_stroke: 4000,
max_strokes: 12,
@@ -997,12 +979,6 @@ fn trace_stroke(start: (f32, f32), grid: &mut Grid,
walk_log: Option<&mut Vec<WalkTrace>>,
stroke_idx: u32) -> Vec<(f32, f32)>
{
// Snapshot pre-stroke ink state so we can relax against the original
// unpainted mask (without our own path's contributions confusing the
// "is this pixel uncovered?" question).
let pre_unpainted = grid.unpainted.clone();
let pre_ink_remaining = grid.ink_remaining;
let step_size = params.step_size_factor * brush_radius;
let brush_area = std::f32::consts::PI * brush_radius * brush_radius;
let min_score = params.min_score_factor * brush_area;
@@ -1022,271 +998,30 @@ fn trace_stroke(start: (f32, f32), grid: &mut Grid,
}
if forward.len() < 2 { return forward; }
let combined = {
let dx = forward[1].0 - forward[0].0;
let dy = forward[1].1 - forward[0].1;
let mag = (dx * dx + dy * dy).sqrt();
if mag < 1e-6 {
forward
} else {
let back_init = (-dx / mag, -dy / mag);
let mut backward_trace = walk_log.as_ref().map(|_| WalkTrace {
kind: "backward".into(), stroke_idx, start,
init_dir: Some(back_init), brush_radius, step_size, min_score,
steps: Vec::new(), exit_reason: String::new(), path: Vec::new(),
});
let backward = walk_brush(start, Some(back_init), grid, params, brush_radius,
backward_trace.as_mut());
if let (Some(log), Some(t)) = (walk_log.as_deref_mut(), backward_trace.take()) {
log.push(t);
}
if backward.len() < 2 {
forward
} else {
let mut c: Vec<(f32, f32)> = Vec::with_capacity(forward.len() + backward.len());
for &p in backward.iter().rev() { c.push(p); }
for &p in forward.iter().skip(1) { c.push(p); }
c
}
}
};
// ── Relaxation ──────────────────────────────────────────────────────
if params.polish_iters == 0 || combined.len() < 3 {
return combined;
let dx = forward[1].0 - forward[0].0;
let dy = forward[1].1 - forward[0].1;
let mag = (dx * dx + dy * dy).sqrt();
if mag < 1e-6 {
return forward;
}
// Restore the pre-stroke unpainted mask so the relaxation sees the
// FULL set of pixels this stroke could potentially cover, not what's
// left over after the walk's painting.
grid.unpainted = pre_unpainted;
grid.ink_remaining = pre_ink_remaining;
let polished = polish_path(combined, grid, brush_radius, params);
// Now paint the final polished path back into the grid.
for &p in &polished { grid.paint_disk(p, brush_radius); }
polished
}
/// Tick-tock relax + shorten. Each round:
/// 1. Relax: nudge each interior waypoint toward unpainted ink (subject
/// to the "stay-on-shape" constraint and outside-penalty).
/// 2. Shorten: drop waypoints whose removal causes zero coverage loss.
fn polish_path(mut path: Vec<(f32, f32)>, grid: &Grid,
brush_radius: f32, params: &PaintParams) -> Vec<(f32, f32)>
{
if path.len() < 3 { return path; }
// Coverage count: how many waypoints' brushes cover each pixel. We
// maintain this incrementally across both relax and shorten passes.
let mut count: Vec<u16> = vec![0; grid.unpainted.len()];
for &p in &path { stamp_count(&mut count, grid, p, brush_radius, 1); }
for _ in 0..params.polish_iters {
let any_relaxed = relax_step(&mut path, &mut count, grid, brush_radius, params);
let any_shortened = shorten_step(&mut path, &mut count, grid, brush_radius);
if !any_relaxed && !any_shortened { break; }
let back_init = (-dx / mag, -dy / mag);
let mut backward_trace = walk_log.as_ref().map(|_| WalkTrace {
kind: "backward".into(), stroke_idx, start,
init_dir: Some(back_init), brush_radius, step_size, min_score,
steps: Vec::new(), exit_reason: String::new(), path: Vec::new(),
});
let backward = walk_brush(start, Some(back_init), grid, params, brush_radius,
backward_trace.as_mut());
if let (Some(log), Some(t)) = (walk_log.as_deref_mut(), backward_trace.take()) {
log.push(t);
}
path
}
/// One sweep of waypoint relaxation. Returns true if any waypoint moved.
/// Only accepts perturbations that:
/// - Land the waypoint INSIDE the original glyph (no off-shape drift)
/// - Improve net score (ink-gain - ink-loss - bg_penalty * background-gain)
fn relax_step(path: &mut Vec<(f32, f32)>, count: &mut Vec<u16>,
grid: &Grid, brush_radius: f32, params: &PaintParams) -> bool
{
let n = path.len();
if n < 3 { return false; }
let max_perturb = brush_radius * 0.6;
let search_r = brush_radius * params.polish_search_factor;
let mut moved = false;
// Iterate ALL waypoints including endpoints. A true dead-end has no
// unpainted ink nearby, so `nearest_uncovered_ink` returns None and
// the loop body bails — endpoints stay put. A misplaced edge-hugger
// gets pulled toward the centerline like any interior waypoint.
for i in 0..n {
let p_old = path[i];
let target = match nearest_uncovered_ink(p_old, search_r, grid, count) {
Some(t) => t, None => continue,
};
let dx = target.0 - p_old.0;
let dy = target.1 - p_old.1;
let dist = (dx * dx + dy * dy).sqrt();
if dist < 0.3 { continue; }
let shift = (dist * 0.7).min(max_perturb);
let p_new = (p_old.0 + dx / dist * shift,
p_old.1 + dy / dist * shift);
// Hard constraint: waypoint center must be inside the original
// glyph. Otherwise the gcode's pen would draw a line outside the
// letter — visible, ugly, fatal.
if !grid.is_ink(p_new.0.round() as i32, p_new.1.round() as i32) { continue; }
let score = evaluate_perturbation(p_old, p_new, brush_radius, grid, count, params);
if score > 0.0 {
stamp_count(count, grid, p_old, brush_radius, -1);
stamp_count(count, grid, p_new, brush_radius, 1);
path[i] = p_new;
moved = true;
}
if backward.len() < 2 {
return forward;
}
moved
}
/// One sweep of waypoint pruning. Removes any interior waypoint whose
/// brush is FULLY redundant (every ink pixel under it is covered by some
/// other waypoint too). Returns true if any waypoint was removed.
fn shorten_step(path: &mut Vec<(f32, f32)>, count: &mut Vec<u16>,
grid: &Grid, brush_radius: f32) -> bool
{
if path.len() < 3 { return false; }
let mut removed_any = false;
let mut i = 1usize;
while i + 1 < path.len() {
let p = path[i];
if waypoint_is_redundant(p, brush_radius, grid, count) {
stamp_count(count, grid, p, brush_radius, -1);
path.remove(i);
removed_any = true;
// Don't increment i — the next waypoint shifted into i.
} else {
i += 1;
}
}
removed_any
}
/// True iff every pre-stroke-unpainted ink pixel under disk(p, r) is
/// covered by at least one OTHER waypoint (i.e., count > 1 there). When
/// true, removing waypoint at p doesn't drop coverage anywhere.
fn waypoint_is_redundant(p: (f32, f32), brush_radius: f32,
grid: &Grid, count: &[u16]) -> bool
{
let cx_i = p.0.round() as i32;
let cy_i = p.1.round() as i32;
let r = (brush_radius + 1.0).ceil() as i32;
let r2 = brush_radius * brush_radius;
for dy in -r..=r {
for dx in -r..=r {
let ddx = (cx_i + dx) as f32 - p.0;
let ddy = (cy_i + dy) as f32 - p.1;
if ddx * ddx + ddy * ddy > r2 { continue; }
let lx = cx_i + dx - grid.bx;
let ly = cy_i + dy - grid.by;
if lx < 0 || ly < 0 || lx >= grid.width || ly >= grid.height { continue; }
let idx = (ly * grid.width + lx) as usize;
if !grid.unpainted[idx] { continue; } // ineligible pixel
if count[idx] <= 1 { return false; } // we'd lose this one
}
}
true
}
/// Add `delta` to coverage count over the disk(p, radius). Used to
/// install or remove a waypoint's brush contribution.
fn stamp_count(count: &mut [u16], grid: &Grid, p: (f32, f32), radius: f32, delta: i16) {
let cx_i = p.0.round() as i32;
let cy_i = p.1.round() as i32;
let r = (radius + 1.0).ceil() as i32;
let r2 = radius * radius;
for dy in -r..=r {
for dx in -r..=r {
let ddx = (cx_i + dx) as f32 - p.0;
let ddy = (cy_i + dy) as f32 - p.1;
if ddx * ddx + ddy * ddy > r2 { continue; }
let lx = cx_i + dx - grid.bx;
let ly = cy_i + dy - grid.by;
if lx < 0 || ly < 0 || lx >= grid.width || ly >= grid.height { continue; }
let idx = (ly * grid.width + lx) as usize;
count[idx] = (count[idx] as i32 + delta as i32).max(0) as u16;
}
}
}
/// Find the nearest pre-stroke-unpainted ink pixel to `from`, within
/// `search_radius`, that isn't already covered by some other waypoint's
/// brush (count == 0).
fn nearest_uncovered_ink(from: (f32, f32), search_radius: f32,
grid: &Grid, count: &[u16]) -> Option<(f32, f32)>
{
let r = search_radius.ceil() as i32;
let r2 = search_radius * search_radius;
let mut best: Option<((f32, f32), f32)> = None;
for dy in -r..=r {
for dx in -r..=r {
let d2 = (dx * dx + dy * dy) as f32;
if d2 > r2 { continue; }
let px = from.0 as i32 + dx;
let py = from.1 as i32 + dy;
let lx = px - grid.bx;
let ly = py - grid.by;
if lx < 0 || ly < 0 || lx >= grid.width || ly >= grid.height { continue; }
let idx = (ly * grid.width + lx) as usize;
if grid.unpainted[idx] && count[idx] == 0 {
match best {
None => best = Some(((px as f32, py as f32), d2)),
Some((_, bd2)) if d2 < bd2 => best = Some(((px as f32, py as f32), d2)),
_ => {}
}
}
}
}
best.map(|(p, _)| p)
}
/// Score for moving waypoint p_old → p_new. Three terms:
/// + gain — pre-stroke-unpainted ink that newly becomes covered
/// - loss — uniquely-covered ink that would become uncovered
/// - background — extra background-pixel coverage by the new brush
/// position (waste; weighted by `bg_penalty`)
/// Net > 0 → keep the move.
fn evaluate_perturbation(p_old: (f32, f32), p_new: (f32, f32), brush_radius: f32,
grid: &Grid, count: &[u16], params: &PaintParams) -> f32
{
let r2 = brush_radius * brush_radius;
let mut gain = 0i32;
let mut loss = 0i32;
let mut bg_delta = 0i32; // bg_in_new - bg_in_old (positive = more wasted brush outside)
let cx = (p_old.0 + p_new.0) * 0.5;
let cy = (p_old.1 + p_new.1) * 0.5;
let dx = p_new.0 - p_old.0;
let dy = p_new.1 - p_old.1;
let half_dist = ((dx * dx + dy * dy).sqrt()) * 0.5;
let search_r = (brush_radius + half_dist).ceil() as i32;
for ddy in -search_r..=search_r {
for ddx in -search_r..=search_r {
let px = cx as i32 + ddx;
let py = cy as i32 + ddy;
let lx = px - grid.bx;
let ly = py - grid.by;
if lx < 0 || ly < 0 || lx >= grid.width || ly >= grid.height { continue; }
let idx = (ly * grid.width + lx) as usize;
let dx_old = px as f32 - p_old.0; let dy_old = py as f32 - p_old.1;
let in_old = dx_old * dx_old + dy_old * dy_old <= r2;
let dx_new = px as f32 - p_new.0; let dy_new = py as f32 - p_new.1;
let in_new = dx_new * dx_new + dy_new * dy_new <= r2;
if !in_old && !in_new { continue; }
if grid.was_ink[idx] {
if !grid.unpainted[idx] { continue; } // covered by prior stroke
let c_old = count[idx];
let c_new = c_old as i32 - in_old as i32 + in_new as i32;
if c_old == 0 && c_new > 0 { gain += 1; }
if c_old > 0 && c_new == 0 { loss += 1; }
} else {
// Background pixel under brush.
if in_new && !in_old { bg_delta += 1; }
if !in_new && in_old { bg_delta -= 1; }
}
}
}
gain as f32 - loss as f32 - params.bg_penalty * bg_delta as f32
let mut combined: Vec<(f32, f32)> = Vec::with_capacity(forward.len() + backward.len());
for &p in backward.iter().rev() { combined.push(p); }
for &p in forward.iter().skip(1) { combined.push(p); }
combined
}
// ── Top-level compute ───────────────────────────────────────────────────
@@ -2013,9 +1748,6 @@ mod tests {
println!(" brush_radius_percentile = {:.2}", best.params.brush_radius_percentile);
println!(" step_size_factor = {:.2}", best.params.step_size_factor);
println!(" walk_bg_penalty = {:.2}", best.params.walk_bg_penalty);
println!(" polish_iters = {}", best.params.polish_iters);
println!(" polish_search_factor = {:.2}", best.params.polish_search_factor);
println!(" bg_penalty = {:.2}", best.params.bg_penalty);
println!(" min_component_factor = {:.2}", best.params.min_component_factor);
}
@@ -2066,12 +1798,6 @@ mod tests {
set: |p, v| p.brush_radius_percentile = v, get: |p| p.brush_radius_percentile },
Axis { name: "brush_radius_offset_px", lo: 0.0, hi: 1.0, is_int: false,
set: |p, v| p.brush_radius_offset_px = v, get: |p| p.brush_radius_offset_px },
Axis { name: "polish_iters", lo: 0.0, hi: 12.0, is_int: true,
set: |p, v| p.polish_iters = v as u32, get: |p| p.polish_iters as f32 },
Axis { name: "polish_search_factor", lo: 0.10, hi: 4.0, is_int: false,
set: |p, v| p.polish_search_factor = v, get: |p| p.polish_search_factor },
Axis { name: "bg_penalty", lo: 0.0, hi: 20.0, is_int: false,
set: |p, v| p.bg_penalty = v, get: |p| p.bg_penalty },
Axis { name: "walk_bg_penalty", lo: 0.0, hi: 20.0, is_int: false,
set: |p, v| p.walk_bg_penalty = v, get: |p| p.walk_bg_penalty },
Axis { name: "overpaint_penalty", lo: 0.0, hi: 0.5, is_int: false,
@@ -2180,17 +1906,15 @@ mod tests {
// Same hand-picked diverse-brush seeds as before.
let mut s = base.clone();
s.brush_radius_factor = 0.55; s.brush_radius_percentile = 0.85;
s.min_component_factor = 1.20; s.polish_iters = 4;
s.min_component_factor = 1.20;
starts.push(s);
let mut s = base.clone();
s.brush_radius_factor = 1.00; s.brush_radius_offset_px = 0.5;
s.brush_radius_percentile = 0.99; s.min_component_factor = 0.20;
s.polish_iters = 2;
starts.push(s);
let mut s = base.clone();
s.brush_radius_factor = 1.15; s.brush_radius_offset_px = 0.5;
s.brush_radius_percentile = 0.99; s.min_component_factor = 0.20;
s.polish_iters = 1;
starts.push(s);
for i in 0..N_RANDOM_STARTS {
let mut state = (i as u64).wrapping_mul(0x9E3779B97F4A7C15).wrapping_add(0xDEADBEEF);
@@ -2244,9 +1968,6 @@ mod tests {
println!(" overpaint_penalty = {:.3} (default {:.3})", current.overpaint_penalty, base.overpaint_penalty);
println!(" walk_bg_penalty = {:.2} (default {:.2})", current.walk_bg_penalty, base.walk_bg_penalty);
println!(" min_score_factor = {:.3} (default {:.3})", current.min_score_factor, base.min_score_factor);
println!(" polish_iters = {} (default {})", current.polish_iters, base.polish_iters);
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!(" min_component_factor = {:.2} (default {:.2})", current.min_component_factor, base.min_component_factor);
println!(" output_rdp_eps = {:.2} (default {:.2})", current.output_rdp_eps, base.output_rdp_eps);
@@ -2720,7 +2441,7 @@ mod tests {
let mut summary = String::new();
writeln!(summary, "# Brush-Paint Alphabet Report\n").unwrap();
writeln!(summary, "Defaults: percentile-sized brush, bg_penalty=2.0 (polish only)\n").unwrap();
writeln!(summary, "Defaults: percentile-sized brush, walker-only (no polish, no Dijkstra repaint)\n").unwrap();
for &(font_mm, dpi, thick) in scales {
writeln!(summary, "\n## font={}mm dpi={} thickness={}px\n", font_mm, dpi, thick).unwrap();

View File

@@ -47,12 +47,6 @@ pub fn default_axes() -> Vec<Axis> {
set: |p, v| p.brush_radius_percentile = v, get: |p| p.brush_radius_percentile },
Axis { name: "brush_radius_offset_px", lo: 0.0, hi: 1.0, is_int: false,
set: |p, v| p.brush_radius_offset_px = v, get: |p| p.brush_radius_offset_px },
Axis { name: "polish_iters", lo: 0.0, hi: 4.0, is_int: true,
set: |p, v| p.polish_iters = v as u32, get: |p| p.polish_iters as f32 },
Axis { name: "polish_search_factor", lo: 0.30, hi: 1.20, is_int: false,
set: |p, v| p.polish_search_factor = v, get: |p| p.polish_search_factor },
Axis { name: "bg_penalty", lo: 0.0, hi: 20.0, is_int: false,
set: |p, v| p.bg_penalty = v, get: |p| p.bg_penalty },
Axis { name: "walk_bg_penalty", lo: 0.0, hi: 20.0, is_int: false,
set: |p, v| p.walk_bg_penalty = v, get: |p| p.walk_bg_penalty },
Axis { name: "overpaint_penalty", lo: 0.0, hi: 0.5, is_int: false,
@@ -125,7 +119,6 @@ pub fn build_start_params(idx: usize, base: &PaintParams, axes: &[Axis]) -> Pain
s.brush_radius_offset_px = 0.25;
s.brush_radius_percentile = 0.85;
s.min_component_factor = 1.20;
s.polish_iters = 4;
s
}
2 => {
@@ -134,7 +127,6 @@ pub fn build_start_params(idx: usize, base: &PaintParams, axes: &[Axis]) -> Pain
s.brush_radius_offset_px = 0.5;
s.brush_radius_percentile = 0.99;
s.min_component_factor = 0.20;
s.polish_iters = 2;
s
}
3 => {
@@ -143,7 +135,6 @@ pub fn build_start_params(idx: usize, base: &PaintParams, axes: &[Axis]) -> Pain
s.brush_radius_offset_px = 0.5;
s.brush_radius_percentile = 0.99;
s.min_component_factor = 0.20;
s.polish_iters = 1;
s
}
_ => {

View File

@@ -1085,12 +1085,6 @@ fn optimize_paint_params(
|p, v| p.brush_radius_percentile = v),
("brush_radius_offset_px", vec![0.0, 0.25, 0.5],
|p, v| p.brush_radius_offset_px = v),
("polish_iters", vec![0.0, 1.0, 2.0, 4.0, 6.0, 10.0],
|p, v| p.polish_iters = v as u32),
("polish_search_factor", vec![0.2, 0.4, 0.7, 1.0, 1.5, 2.5],
|p, v| p.polish_search_factor = v),
("bg_penalty", vec![0.0, 1.0, 2.0, 4.0, 8.0, 15.0],
|p, v| p.bg_penalty = v),
("walk_bg_penalty", vec![0.0, 1.0, 2.0, 4.0, 8.0, 15.0],
|p, v| p.walk_bg_penalty = v),
("overpaint_penalty", vec![0.0, 0.02, 0.05, 0.1, 0.2, 0.4],