diff --git a/src-frontend/src/components/PaintDebugView.jsx b/src-frontend/src/components/PaintDebugView.jsx
index 97558609..658ae4a9 100644
--- a/src-frontend/src/components/PaintDebugView.jsx
+++ b/src-frontend/src/components/PaintDebugView.jsx
@@ -645,16 +645,20 @@ export default function PaintDebugView({ passIdx = 0 }) {
preserveAspectRatio="none" />
)}
- {enabled.skeleton && debug.skeleton_b64 && (
-
- )}
+ {/* 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) => (
+ `${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) => (
+
+ ))}
{/* Per-endpoint init_dir arrows. Each arrow originates at the
endpoint and points along the skeleton tangent into the
diff --git a/src/brush_paint.rs b/src/brush_paint.rs
index d50b4f78..23374317 100644
--- a/src/brush_paint.rs
+++ b/src/brush_paint.rs
@@ -198,8 +198,16 @@ pub struct PaintDebug {
pub walks: Vec,
/// 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>,
+ /// 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>,
+ /// 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 {
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)>)
+{
+ 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::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,