brush-paint: fix scaling of bbox-overlay PNGs + render skeleton as SVG vector

Two related fixes to the debug viewer's overlays:

(1) The grid mask (was_ink padded by HULL_GRID_PAD=32) doesn't match
the hull bbox the SVG places overlays at. encode_skeleton_b64,
encode_grid_unpainted_b64 and encode_coverage_b64 were rendering
grid-sized PNGs (W+64 × H+64) but the JSX placed them at hull-bbox
coordinates (W × H), squashing them. Crop all three to the hull
bbox by sampling from grid coords offset by HULL_GRID_PAD. Now
they line up pixel-for-pixel with source_b64 / sdf_b64.

Also promoted PAD to a module-level const HULL_GRID_PAD so the
encoders and the grid-builder share one source of truth.

(2) The skeleton was rendered as a rasterized PNG, which got blurry
when zooming. Now traced as vector polylines: trace_skeleton_segments
walks the skeleton graph from each special node (degree-1 endpoint
or degree-≥3 junction) along its degree-2 chain neighbors, producing
one polyline per skeleton edge. Closed loops (O / 0 cores) get
their own segment with first==last point.

PaintDebug now exposes:
  * skeleton_segments     — Vec<polyline> for SVG rendering
  * skeleton_junctions    — Vec<(x, y)> for green dots
The rasterized skeleton_b64 is still produced for debugging the
encoder; frontend uses the vector data instead. Stays sharp under
any zoom.

Bit-exact behavior preserved: alphabet report unchanged.
This commit is contained in:
Mitchell Hansen
2026-05-08 00:27:15 -07:00
parent f2691bbd2e
commit 5dd2e8aba5
2 changed files with 183 additions and 60 deletions

View File

@@ -645,16 +645,20 @@ export default function PaintDebugView({ passIdx = 0 }) {
preserveAspectRatio="none" />
)}
{enabled.skeleton && debug.skeleton_b64 && (
<image
href={debug.skeleton_b64} xlinkHref={debug.skeleton_b64}
x={debug.bounds[0]} y={debug.bounds[1]}
width={debug.bounds[2] - debug.bounds[0] + 1}
height={debug.bounds[3] - debug.bounds[1] + 1}
opacity={0.95}
style={{ imageRendering: 'pixelated' }}
preserveAspectRatio="none" />
)}
{/* Vector skeleton — polylines per segment between special
nodes. Stays sharp at any zoom. Junction dots in green. */}
{enabled.skeleton && (debug.skeleton_segments ?? []).map((seg, i) => (
<polyline key={`sk${i}`}
points={seg.map(p => `${p[0]},${p[1]}`).join(' ')}
fill="none" stroke="#a3a3a3" strokeWidth={0.6}
strokeLinecap="round" strokeLinejoin="round"
vectorEffect="non-scaling-stroke" />
))}
{enabled.skeleton && (debug.skeleton_junctions ?? []).map((p, i) => (
<circle key={`sj${i}`} cx={p[0]} cy={p[1]} r={0.7}
fill="#22c55e" stroke="#000" strokeWidth={0.3}
vectorEffect="non-scaling-stroke" />
))}
{/* Per-endpoint init_dir arrows. Each arrow originates at the
endpoint and points along the skeleton tangent into the

View File

@@ -198,8 +198,16 @@ pub struct PaintDebug {
pub walks: Vec<WalkTrace>,
/// Spur-pruned thinned skeleton, color-coded by per-pixel degree
/// (endpoint / junction / path). Same coord system as
/// `source_b64` / `sdf_b64` / `coverage_b64`.
/// `source_b64` / `sdf_b64` / `coverage_b64`. (Vector polylines
/// in `skeleton_segments` are usually preferable for display —
/// stay sharp under zoom.)
pub skeleton_b64: String,
/// Skeleton as vector polylines, one per segment between special
/// nodes (endpoint or junction). Coordinates in hull-image space.
pub skeleton_segments: Vec<Vec<(f32, f32)>>,
/// Skeleton junction positions (degree ≥ 3). Endpoints are in
/// `endpoint_arrows`.
pub skeleton_junctions: Vec<(f32, f32)>,
/// Skeleton endpoints as render-ready arrows: each tuple is
/// `(x, y, dx, dy)` where `(x, y)` is the endpoint position in
/// hull coords and `(dx, dy)` is the unit init_dir along the
@@ -454,19 +462,21 @@ fn encode_sdf_b64(hull: &Hull) -> (String, f32) {
/// degree 1 → endpoint (red)
/// degree 2 → path (mid grey)
/// degree ≥ 3 → junction (green)
/// Empty pixels stay transparent. Sized to the hull's grid bbox so it
/// overlays directly on the source/sdf/coverage layers.
/// Empty pixels stay transparent. Cropped to the hull's bbox (not the
/// padded grid bbox) so the image lines up with `source_b64` /
/// `sdf_b64` / `coverage_b64` when overlaid in the viewer.
fn encode_skeleton_b64(hull_data: &HullData) -> String {
let bw = hull_data.width.max(1) as u32;
let bh = hull_data.height.max(1) as u32;
let mut img: image::RgbaImage = image::ImageBuffer::new(bw, bh);
for ly in 0..hull_data.height {
for lx in 0..hull_data.width {
let idx = (ly * hull_data.width + lx) as usize;
let bw = (hull_data.width - 2 * HULL_GRID_PAD).max(1);
let bh = (hull_data.height - 2 * HULL_GRID_PAD).max(1);
let mut img: image::RgbaImage = image::ImageBuffer::new(bw as u32, bh as u32);
for ly in 0..bh {
for lx in 0..bw {
let glx = lx + HULL_GRID_PAD; // grid-local
let gly = ly + HULL_GRID_PAD;
let idx = (gly * hull_data.width + glx) as usize;
if !hull_data.skeleton.get(idx) { continue; }
// Count 8-connected in-skel neighbors.
let abs_x = (lx + hull_data.bx) as u32;
let abs_y = (ly + hull_data.by) as u32;
let abs_x = (glx + hull_data.bx) as u32;
let abs_y = (gly + hull_data.by) as u32;
let nbrs = zs_neighbors(abs_x, abs_y);
let mut deg = 0;
for (nx, ny) in nbrs {
@@ -478,9 +488,9 @@ fn encode_skeleton_b64(hull_data: &HullData) -> String {
}
}
let rgba = match deg {
0 | 1 => [244, 63, 94, 230], // endpoint — red
2 => [120, 120, 120, 200], // path — grey
_ => [ 34, 197, 94, 230], // junction — green
0 | 1 => [244, 63, 94, 230],
2 => [120, 120, 120, 200],
_ => [ 34, 197, 94, 230],
};
img.put_pixel(lx as u32, ly as u32, image::Rgba(rgba));
}
@@ -493,17 +503,17 @@ fn encode_skeleton_b64(hull_data: &HullData) -> String {
}
/// Snapshot the current `unpainted` BitMask as a transparent PNG —
/// red pixels where ink is not yet painted, transparent elsewhere.
/// Same coord system as the other layers. Used by the per-stroke
/// scrubber so the viewer can see what was unpainted just before
/// stroke N began.
/// red pixels where ink is not yet painted. Cropped to hull bbox so
/// it lines up with the other overlays.
fn encode_grid_unpainted_b64(grid: &Grid) -> String {
let bw = grid.width.max(1) as u32;
let bh = grid.height.max(1) as u32;
let mut img: image::RgbaImage = image::ImageBuffer::new(bw, bh);
for ly in 0..grid.height {
for lx in 0..grid.width {
let idx = (ly * grid.width + lx) as usize;
let bw = (grid.width - 2 * HULL_GRID_PAD).max(1);
let bh = (grid.height - 2 * HULL_GRID_PAD).max(1);
let mut img: image::RgbaImage = image::ImageBuffer::new(bw as u32, bh as u32);
for ly in 0..bh {
for lx in 0..bw {
let glx = lx + HULL_GRID_PAD;
let gly = ly + HULL_GRID_PAD;
let idx = (gly * grid.width + glx) as usize;
if grid.unpainted.get(idx) {
img.put_pixel(lx as u32, ly as u32, image::Rgba([244, 63, 94, 200]));
}
@@ -516,22 +526,16 @@ fn encode_grid_unpainted_b64(grid: &Grid) -> String {
format!("data:image/png;base64,{}", b64)
}
/// Final unpainted ink, color-coded red. Cropped to hull bbox.
fn encode_coverage_b64(grid: &Grid) -> String {
let bw = grid.width.max(1) as u32;
let bh = grid.height.max(1) as u32;
let mut img: image::RgbaImage = image::ImageBuffer::new(bw, bh);
for ly in 0..grid.height {
for lx in 0..grid.width {
let idx = (ly * grid.width + lx) as usize;
// Was-ink test: we don't have a separate "was-ink" mask, but
// we can reconstruct from the grid's initial state (after
// construction, unpainted == ink). After tracing, unpainted=true
// means "was ink and STILL not painted" — the gaps. Anything
// else is either background or already-painted-ink. We can't
// distinguish those without a second mask.
//
// For the coverage view, paint UNPAINTED ink red (= missed).
// Painted/background stays transparent.
let bw = (grid.width - 2 * HULL_GRID_PAD).max(1);
let bh = (grid.height - 2 * HULL_GRID_PAD).max(1);
let mut img: image::RgbaImage = image::ImageBuffer::new(bw as u32, bh as u32);
for ly in 0..bh {
for lx in 0..bw {
let glx = lx + HULL_GRID_PAD;
let gly = ly + HULL_GRID_PAD;
let idx = (gly * grid.width + glx) as usize;
if grid.unpainted.get(idx) {
img.put_pixel(lx as u32, ly as u32, image::Rgba([244, 63, 94, 200]));
}
@@ -617,6 +621,15 @@ struct HullData {
/// vs path) is derived on demand by scanning 8-connected
/// neighbors of each skeleton pixel.
skeleton: BitMask,
/// Skeleton traced as polylines, one per segment between
/// "special" nodes (endpoints with degree 1 + junctions with
/// degree ≥ 3). Closed loops (O / 0 cores) are stored as
/// segments whose first and last point coincide. Coordinates
/// are absolute (hull-image space, same as `skel_endpoints`).
skeleton_segments: Vec<Vec<(f32, f32)>>,
/// Skeleton junction positions (degree ≥ 3 in the skeleton
/// graph). Endpoints are already in `skel_endpoints`.
skeleton_junctions: Vec<(f32, f32)>,
skeleton_length: u32,
ink_total: i32,
}
@@ -672,17 +685,20 @@ fn get_or_compute_hull_data(hull: &Hull) -> Arc<HullData> {
cache.entry(key).or_insert_with(|| computed.clone()).clone()
}
/// Pad the grid past the hull's AABB so that bg pixels swept by a brush
/// that overhangs the polygon (e.g. at the top of an `I`, or the
/// corners of a square letter) are counted instead of silently dropped
/// by the bounds check. Must exceed any brush_radius the optimizer
/// might try. The encoders crop back to hull bbox using this constant
/// so the rendered overlays line up with `source_b64` / `sdf_b64`
/// (which are hull-bbox-sized).
const HULL_GRID_PAD: i32 = 32;
fn compute_hull_data(hull: &Hull) -> HullData {
// Pad the grid past the hull's AABB so that bg pixels swept by a
// brush that overhangs the polygon (e.g. at the top of an `I`,
// or the corners of a square letter) are counted instead of
// silently dropped by the bounds check. PAD must exceed any
// brush_radius the optimizer might try.
const PAD: i32 = 32;
let bx = hull.bounds.x_min as i32 - PAD;
let by = hull.bounds.y_min as i32 - PAD;
let width = (hull.bounds.x_max as i32 - hull.bounds.x_min as i32 + 1 + 2 * PAD).max(1);
let height = (hull.bounds.y_max as i32 - hull.bounds.y_min as i32 + 1 + 2 * PAD).max(1);
let bx = hull.bounds.x_min as i32 - HULL_GRID_PAD;
let by = hull.bounds.y_min as i32 - HULL_GRID_PAD;
let width = (hull.bounds.x_max as i32 - hull.bounds.x_min as i32 + 1 + 2 * HULL_GRID_PAD).max(1);
let height = (hull.bounds.y_max as i32 - hull.bounds.y_min as i32 + 1 + 2 * HULL_GRID_PAD).max(1);
let cells = (width * height) as usize;
let mut was_ink = BitMask::new(cells);
let mut sdf = vec![0.0_f32; cells];
@@ -732,12 +748,111 @@ fn compute_hull_data(hull: &Hull) -> HullData {
if lx < 0 || ly < 0 || lx >= width || ly >= height { continue; }
skeleton.set((ly * width + lx) as usize);
}
let (skeleton_segments, skeleton_junctions) = trace_skeleton_segments(&skel);
let skeleton_length = skel.len() as u32;
HullData { bx, by, width, height, was_ink, sdf, sdf_values_sorted,
skel_endpoints, skel_endpoints_init_dir, skeleton,
skeleton_segments, skeleton_junctions,
skeleton_length, ink_total: count }
}
/// Decompose the skeleton into polyline segments connecting "special"
/// nodes (degree-1 endpoints and degree-≥3 junctions), plus closed
/// loops for components that have no special nodes (O / 0 / D-style
/// closed shapes). Walking from each special node along its degree-2
/// chain neighbors produces one segment per skeleton edge in the
/// implicit graph; the visited-edge set prevents duplicates.
fn trace_skeleton_segments(skel: &HashSet<(u32, u32)>)
-> (Vec<Vec<(f32, f32)>>, Vec<(f32, f32)>)
{
let neighbors_of = |p: (u32, u32)| -> Vec<(u32, u32)> {
zs_neighbors(p.0, p.1).into_iter()
.filter(|n| skel.contains(n))
.collect()
};
let normalize = |a: (u32, u32), b: (u32, u32)| -> ((u32, u32), (u32, u32)) {
if a <= b { (a, b) } else { (b, a) }
};
let mut special: HashSet<(u32, u32)> = HashSet::new();
let mut junctions: Vec<(f32, f32)> = Vec::new();
for &p in skel {
let deg = neighbors_of(p).len();
if deg == 1 || deg >= 3 { special.insert(p); }
if deg >= 3 { junctions.push((p.0 as f32, p.1 as f32)); }
}
let mut segments: Vec<Vec<(f32, f32)>> = Vec::new();
let mut visited_edges: HashSet<((u32, u32), (u32, u32))> = HashSet::new();
// Walk one segment per (special-node, neighbor) edge.
for &s in &special {
for n in neighbors_of(s) {
let edge = normalize(s, n);
if visited_edges.contains(&edge) { continue; }
visited_edges.insert(edge);
let mut seg: Vec<(f32, f32)> = vec![
(s.0 as f32, s.1 as f32),
(n.0 as f32, n.1 as f32),
];
let mut prev = s;
let mut cur = n;
while !special.contains(&cur) {
let nbrs = neighbors_of(cur);
let next_opt = nbrs.iter().copied().find(|&p| p != prev);
let Some(next) = next_opt else { break };
let next_edge = normalize(cur, next);
if visited_edges.contains(&next_edge) { break; }
visited_edges.insert(next_edge);
seg.push((next.0 as f32, next.1 as f32));
prev = cur;
cur = next;
}
segments.push(seg);
}
}
// Isolated cycles: connected components with NO special nodes
// (e.g. O's skeleton). Pick any unvisited pixel, walk until we
// either return to start or run out of unvisited neighbors.
let mut visited_pixels: HashSet<(u32, u32)> = HashSet::new();
for seg in &segments {
for &(x, y) in seg {
visited_pixels.insert((x as u32, y as u32));
}
}
for &start in skel {
if visited_pixels.contains(&start) || special.contains(&start) { continue; }
let mut seg: Vec<(f32, f32)> = vec![(start.0 as f32, start.1 as f32)];
visited_pixels.insert(start);
let mut prev: Option<(u32, u32)> = None;
let mut cur = start;
loop {
let nbrs = neighbors_of(cur);
let next = nbrs.iter().copied()
.find(|&p| Some(p) != prev && !visited_pixels.contains(&p));
match next {
Some(n) => {
visited_pixels.insert(n);
seg.push((n.0 as f32, n.1 as f32));
prev = Some(cur);
cur = n;
}
None => {
// Close the loop if we're adjacent to start.
if nbrs.iter().any(|&p| p == start) {
seg.push((start.0 as f32, start.1 as f32));
}
break;
}
}
}
if seg.len() >= 2 { segments.push(seg); }
}
(segments, junctions)
}
// ── Coverage grid: per-call mutable state, sized to the hull's bbox ─────
struct Grid {
@@ -1821,6 +1936,8 @@ fn paint_fill_debug_inner(hull: &Hull, params: &PaintParams,
.zip(grid.hull.skel_endpoints_init_dir.iter())
.map(|(&(ex, ey), &(dx, dy))| (ex as f32, ey as f32, dx, dy))
.collect();
let skeleton_segments = grid.hull.skeleton_segments.clone();
let skeleton_junctions = grid.hull.skeleton_junctions.clone();
let disk_offsets = grid.disk_offsets.clone();
PaintDebug {
bounds,
@@ -1841,6 +1958,8 @@ fn paint_fill_debug_inner(hull: &Hull, params: &PaintParams,
start_points: starts,
walks,
skeleton_b64,
skeleton_segments,
skeleton_junctions,
endpoint_arrows,
disk_offsets,
stroke_seedings,