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,
}
}