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