brush-paint: add Voronoi + AFMM medial-axis debug layers

Both produced by paint_fill_debug for the interactive viewer. Two
new layer toggles (2a/2b) overlay them in the same coord frame as
the Zhang-Suen skeleton.

- Voronoi: insert every contour pixel into a spade Delaunay; keep
  undirected Voronoi edges whose endpoints + midpoint are inside
  the ink set.
- AFMM (Telea-style, simplified): 8-conn BFS from contour pixels
  propagates each pixel's arc-length index inward; medial pixels
  are those whose 8-nbr arc-length disagrees by more than perim/5.

Checkpoint commit only — both confirm what skeleton already showed:
all three centerline algorithms cut convex corners, because the
medial axis is geometrically defined to converge to inscribed-circle
radius 0 a few px short of the apex. Pre-demolition checkpoint.
This commit is contained in:
Mitchell Hansen
2026-05-08 21:28:32 -07:00
parent 55a4edcd19
commit a96e14e8c8
2 changed files with 150 additions and 0 deletions

View File

@@ -12,6 +12,8 @@ const LAYERS = [
{ key: 'source', label: '0. Source pixels', on: true }, { key: 'source', label: '0. Source pixels', on: true },
{ key: 'sdf', label: '1. SDF heatmap', on: false }, { key: 'sdf', label: '1. SDF heatmap', on: false },
{ key: 'skeleton', label: '2. Skeleton (deg-coded)', 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: 'endpoints', label: '3. Endpoint init_dirs', on: false },
{ key: 'components', label: '4. Pre-stroke components', on: false }, { key: 'components', label: '4. Pre-stroke components', on: false },
{ key: 'preSnapshot',label: '5. Pre-walk unpainted', 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" /> 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) => (
<line key={`vor${i}`}
x1={seg[0][0]} y1={seg[0][1]} x2={seg[1][0]} y2={seg[1][1]}
stroke="#e879f9" strokeWidth={0.5}
vectorEffect="non-scaling-stroke" />
))}
{/* 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) => (
<circle key={`af${i}`} cx={p[0]} cy={p[1]} r={0.4}
fill="#22d3ee" stroke="none"
vectorEffect="non-scaling-stroke" />
))}
{/* Per-endpoint init_dir arrows. Each arrow originates at the {/* Per-endpoint init_dir arrows. Each arrow originates at the
endpoint and points along the skeleton tangent into the endpoint and points along the skeleton tangent into the
letter — i.e., the direction the walker would head if it letter — i.e., the direction the walker would head if it

View File

@@ -228,6 +228,15 @@ pub struct PaintDebug {
/// began. Same length as `trajectories`. Lets the scrubber show /// began. Same length as `trajectories`. Lets the scrubber show
/// "what the walker saw" at the start of stroke N. /// "what the walker saw" at the start of stroke N.
pub unpainted_snapshots: Vec<String>, pub unpainted_snapshots: Vec<String>,
/// 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 /// 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) 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<Point2<f64>> = 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 ──────────────────────────────────── // ── Bit-packed mask: 1 bit per pixel ────────────────────────────────────
/// Compact boolean mask backed by `Vec<u64>`. Used for `was_ink` and /// Compact boolean mask backed by `Vec<u64>`. 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_segments = grid.hull.skeleton_segments.clone();
let skeleton_junctions = grid.hull.skeleton_junctions.clone(); let skeleton_junctions = grid.hull.skeleton_junctions.clone();
let disk_offsets = grid.disk_offsets.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 { PaintDebug {
bounds, bounds,
source_b64, source_b64,
@@ -1964,6 +2088,8 @@ fn paint_fill_debug_inner(hull: &Hull, params: &PaintParams,
disk_offsets, disk_offsets,
stroke_seedings, stroke_seedings,
unpainted_snapshots, unpainted_snapshots,
voronoi_segments,
afmm_points,
} }
} }