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:
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user