diff --git a/src-frontend/src/hooks/useTauri.js b/src-frontend/src/hooks/useTauri.js index a2ba6719..6c317987 100644 --- a/src-frontend/src/hooks/useTauri.js +++ b/src-frontend/src/hooks/useTauri.js @@ -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, diff --git a/src/brush_paint.rs b/src/brush_paint.rs index 2ce8a9bb..633e58c2 100644 --- a/src/brush_paint.rs +++ b/src/brush_paint.rs @@ -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>, 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 = 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, - 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, - 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(); diff --git a/src/brush_paint_opt.rs b/src/brush_paint_opt.rs index 0ae99ad5..cc7f25a5 100644 --- a/src/brush_paint_opt.rs +++ b/src/brush_paint_opt.rs @@ -47,12 +47,6 @@ pub fn default_axes() -> Vec { 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 } _ => { diff --git a/src/lib.rs b/src/lib.rs index 0635073e..0843b2f1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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],