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,