diff --git a/src-frontend/src/components/PaintDebugView.jsx b/src-frontend/src/components/PaintDebugView.jsx index cb2cc7e4..05d97511 100644 --- a/src-frontend/src/components/PaintDebugView.jsx +++ b/src-frontend/src/components/PaintDebugView.jsx @@ -12,6 +12,8 @@ const LAYERS = [ { key: 'source', label: '0. Source pixels', on: true }, { key: 'sdf', label: '1. SDF heatmap', on: false }, { key: 'skeleton', label: '2. Skeleton (deg-coded)', on: false }, + { key: 'voronoi', label: '2a. Voronoi medial axis', on: false }, + { key: 'afmm', label: '2b. AFMM medial pixels', on: false }, { key: 'endpoints', label: '3. Endpoint init_dirs', on: false }, { key: 'components', label: '4. Pre-stroke components', on: false }, { key: 'preSnapshot',label: '5. Pre-walk unpainted', on: false }, @@ -745,6 +747,28 @@ export default function PaintDebugView({ passIdx = 0 }) { vectorEffect="non-scaling-stroke" /> ))} + {/* Voronoi medial-axis edges — magenta segments. Each edge + connects two adjacent triangle circumcenters of the + boundary-sample Delaunay; only edges entirely inside the + shape are kept. Junctions are real Voronoi vertices, so + W/M apex behavior should differ visibly from ZS. */} + {enabled.voronoi && (debug.voronoi_segments ?? []).map((seg, i) => ( + + ))} + + {/* AFMM medial-axis points — cyan dots. Each pixel labelled + with the arc-length of its closest contour pixel; pixels + whose 8-nbrs disagree by > perim/5 are medial. Look for + clean junction handling that ZS clusters mishandle. */} + {enabled.afmm && (debug.afmm_points ?? []).map((p, i) => ( + + ))} + {/* Per-endpoint init_dir arrows. Each arrow originates at the endpoint and points along the skeleton tangent into the letter — i.e., the direction the walker would head if it diff --git a/src/brush_paint.rs b/src/brush_paint.rs index 23374317..a4076c3b 100644 --- a/src/brush_paint.rs +++ b/src/brush_paint.rs @@ -228,6 +228,15 @@ pub struct PaintDebug { /// began. Same length as `trajectories`. Lets the scrubber show /// "what the walker saw" at the start of stroke N. pub unpainted_snapshots: Vec, + /// Voronoi-based medial axis: line segments between adjacent + /// triangle circumcenters whose both endpoints are inside the + /// hull. Each entry is `((x0,y0),(x1,y1))` in hull-image coords. + /// Debug viz only — not consumed by the painter. + pub voronoi_segments: Vec<((f32, f32), (f32, f32))>, + /// Telea AFMM medial-axis points: every interior pixel whose 8-nbr + /// arc-length labels disagree by more than perim/5. Rendered as + /// a dot cloud on the frontend. Debug viz only. + pub afmm_points: Vec<(f32, f32)>, } /// Per-stroke seeding diagnostics: one of these is recorded for each @@ -548,6 +557,113 @@ fn encode_coverage_b64(grid: &Grid) -> String { format!("data:image/png;base64,{}", b64) } +// ── Alternative medial-axis algorithms (debug viz only) ────────────────── + +/// Voronoi-based medial axis. Insert every contour pixel as a Voronoi +/// site, take all undirected Voronoi edges, keep only those whose both +/// endpoints lie inside the shape — those are exactly the medial-axis +/// segments. No raster-domain thinning, no junction-cluster pixels: +/// each Voronoi vertex IS a junction or apex by construction. +/// +/// Returns line segments in absolute hull-image coords (matches the +/// frame used by `source_b64` / skeleton overlays). +fn voronoi_medial_segments(hull: &Hull) -> Vec<((f32, f32), (f32, f32))> { + use spade::{DelaunayTriangulation, Point2, Triangulation as _}; + + if hull.contour.len() < 4 { return Vec::new(); } + let pixel_set: HashSet<(u32, u32)> = hull.pixels.iter().copied().collect(); + + let mut tri: DelaunayTriangulation> = DelaunayTriangulation::new(); + for &(x, y) in &hull.contour { + // Slight jitter to avoid degenerate-collinear inserts. Boundary + // pixels are integer coords; offsetting by tiny fractions keeps + // their layout but breaks ties for the triangulator. + let _ = tri.insert(Point2::new(x as f64, y as f64)); + } + + let mut segs = Vec::new(); + for ve in tri.undirected_voronoi_edges() { + let [a, b] = ve.vertices(); + let pa = match a.position() { Some(p) => p, None => continue }; + let pb = match b.position() { Some(p) => p, None => continue }; + // Keep edges whose endpoints AND midpoint are inside the ink set + // (filters edges that exit the shape — exterior Voronoi cells). + let inside = |x: f64, y: f64| -> bool { + let px = x.round() as i64; let py = y.round() as i64; + if px < 0 || py < 0 { return false; } + pixel_set.contains(&(px as u32, py as u32)) + }; + if !inside(pa.x, pa.y) || !inside(pb.x, pb.y) { continue; } + let mx = (pa.x + pb.x) * 0.5; + let my = (pa.y + pb.y) * 0.5; + if !inside(mx, my) { continue; } + segs.push(((pa.x as f32, pa.y as f32), (pb.x as f32, pb.y as f32))); + } + segs +} + +/// Augmented Fast Marching (Telea-style, simplified): label each interior +/// pixel with the *arc-length position* of the boundary pixel it's +/// closest to (via 8-conn BFS from the contour). The medial axis is +/// then the set of pixels that have a neighbor whose arc-length differs +/// by more than `threshold` (modular, since the contour is a loop) — +/// i.e., the boundary "arrives at" this pixel from two far-apart +/// places along the outline, which is the definition of the medial +/// axis. +/// +/// Threshold is set proportionally to perimeter (perim/5 by default), +/// matching Telea's "significance" parameter — controls how much +/// boundary travel counts as a real medial branch vs noise. +/// +/// Returns medial pixels as a point cloud (one (x,y) per pixel). The +/// frontend renders each as a small dot. +fn afmm_medial_points(hull: &Hull) -> Vec<(f32, f32)> { + use std::collections::VecDeque; + if hull.contour.len() < 4 || hull.pixels.is_empty() { return Vec::new(); } + let pixel_set: HashSet<(u32, u32)> = hull.pixels.iter().copied().collect(); + let perim = hull.contour.len() as u32; + + // BFS from the contour, propagating each contour pixel's arc-length. + let mut arc: HashMap<(u32, u32), u32> = HashMap::with_capacity(hull.pixels.len()); + let mut queue: VecDeque<(u32, u32)> = VecDeque::new(); + for (i, &p) in hull.contour.iter().enumerate() { + arc.entry(p).or_insert(i as u32); + queue.push_back(p); + } + while let Some(p) = queue.pop_front() { + let a = arc[&p]; + for n in zs_neighbors(p.0, p.1) { + if !pixel_set.contains(&n) { continue; } + if arc.contains_key(&n) { continue; } + arc.insert(n, a); + queue.push_back(n); + } + } + + // Modular distance on the contour loop. + let mod_dist = |a: u32, b: u32| -> u32 { + let d = a.abs_diff(b); + d.min(perim - d) + }; + let threshold = (perim / 5).max(4); + + let mut medial: Vec<(f32, f32)> = Vec::new(); + for &p in &hull.pixels { + let a = match arc.get(&p) { Some(&v) => v, None => continue }; + let mut max_jump = 0u32; + for n in zs_neighbors(p.0, p.1) { + if let Some(&b) = arc.get(&n) { + let j = mod_dist(a, b); + if j > max_jump { max_jump = j; } + } + } + if max_jump > threshold { + medial.push((p.0 as f32, p.1 as f32)); + } + } + medial +} + // ── Bit-packed mask: 1 bit per pixel ──────────────────────────────────── /// Compact boolean mask backed by `Vec`. Used for `was_ink` and @@ -1939,6 +2055,14 @@ fn paint_fill_debug_inner(hull: &Hull, params: &PaintParams, let skeleton_segments = grid.hull.skeleton_segments.clone(); let skeleton_junctions = grid.hull.skeleton_junctions.clone(); let disk_offsets = grid.disk_offsets.clone(); + // Alternative medial-axis algorithms — viz only, computed only when + // we're rendering PNGs (i.e., the interactive debugger), since the + // optimizer's per-call hot path doesn't need them. + let (voronoi_segments, afmm_points) = if render_pngs { + (voronoi_medial_segments(hull), afmm_medial_points(hull)) + } else { + (Vec::new(), Vec::new()) + }; PaintDebug { bounds, source_b64, @@ -1964,6 +2088,8 @@ fn paint_fill_debug_inner(hull: &Hull, params: &PaintParams, disk_offsets, stroke_seedings, unpainted_snapshots, + voronoi_segments, + afmm_points, } }