diff --git a/src-frontend/src/components/PaintDebugView.jsx b/src-frontend/src/components/PaintDebugView.jsx index 46ae95a8..97558609 100644 --- a/src-frontend/src/components/PaintDebugView.jsx +++ b/src-frontend/src/components/PaintDebugView.jsx @@ -10,11 +10,15 @@ const ZOOM_SENSITIVITY = IS_DARWIN ? 0.0015 : 0.015 const LAYERS = [ { key: 'source', label: '0. Source pixels', on: true }, { key: 'sdf', label: '1. SDF heatmap', on: false }, - { key: 'coverage', label: '2. Missed-pixel mask', on: false }, - { key: 'starts', label: '3. Start points', on: true }, - { key: 'brushSweep', label: '4. Brush sweep (radius)', on: false }, - { key: 'trajectory', label: '5. Raw trajectories', on: true }, - { key: 'strokes', label: '6. Smoothed strokes', on: true }, + { key: 'skeleton', label: '2. Skeleton (deg-coded)', on: false }, + { key: 'endpoints', label: '3. Endpoint init_dirs', on: false }, + { key: 'components', label: '4. Pre-stroke components', on: false }, + { key: 'preSnapshot',label: '5. Pre-walk unpainted', on: false }, + { key: 'coverage', label: '6. Final missed-pixels', on: false }, + { key: 'starts', label: '7. Start points', on: true }, + { key: 'brushSweep', label: '8. Brush sweep (radius)', on: false }, + { key: 'trajectory', label: '9. Raw trajectories', on: true }, + { key: 'strokes', label: '10. Smoothed strokes', on: true }, ] // Step-viz layers — toggled independently from the "final result" layers above. @@ -25,7 +29,6 @@ const STEP_LAYERS = [ { key: 'brushHere', label: 'd. Brush footprint', on: true }, { key: 'momentum', label: 'e. Momentum arrow', on: true }, { key: 'candidates', label: 'f. Candidates', on: true }, - { key: 'skeleton', label: 'g. Skeleton (n/a)', on: false }, ] // Score weights — must match Rust's ScoreWeights::default(). @@ -453,14 +456,10 @@ export default function PaintDebugView({ passIdx = 0 }) { ))} - {stepEnabled.skeleton && ( -
skeleton not exposed yet
- )} @@ -646,6 +645,90 @@ export default function PaintDebugView({ passIdx = 0 }) { preserveAspectRatio="none" /> )} + {enabled.skeleton && debug.skeleton_b64 && ( + + )} + + {/* Per-endpoint init_dir arrows. Each arrow originates at the + endpoint and points along the skeleton tangent into the + letter — i.e., the direction the walker would head if it + seeded here. Arrow length scaled to ~1 brush radius. */} + {enabled.endpoints && (debug.endpoint_arrows ?? []).map((arr, i) => { + const [x, y, dx, dy] = arr + const L = Math.max(2, debug.brush_radius * 1.5) + const tx = x + dx * L + const ty = y + dy * L + return ( + + + + + + ) + })} + + {/* Pre-stroke component decomposition. For the currently- + selected walk's stroke, draws the bbox of every + connected unpainted component, color-coded: + green outline = substantial (got seeded or could) + grey outline = sub-threshold (sits in mask, no stroke) + yellow fill = the one this stroke chose. */} + {enabled.components && (() => { + const seedings = debug.stroke_seedings ?? [] + // Find the seeding for the currently-selected walk's stroke_idx. + const sIdx = walks[walkIdx]?.stroke_idx ?? 0 + const seeding = seedings.find(s => s.stroke_idx === sIdx) ?? seedings[0] + if (!seeding) return null + return (seeding.components ?? []).map((c, i) => { + const [xmin, ymin, xmax, ymax] = c.bbox + const fill = c.chosen ? '#facc1530' : 'none' + const stroke = c.substantial ? '#22c55e' : '#6b7280' + return ( + + ) + }) + })()} + + {/* Pre-walk unpainted snapshot for the currently-selected + walk's stroke. Shows what the walker SAW just before it + started — distinct from the final coverage layer which + shows what's left after ALL strokes. */} + {enabled.preSnapshot && (() => { + const snaps = debug.unpainted_snapshots ?? [] + const sIdx = walks[walkIdx]?.stroke_idx ?? 0 + const png = snaps[sIdx] + if (!png) return null + return ( + + ) + })()} + {enabled.coverage && debug.coverage_b64 && ( , + /// Spur-pruned thinned skeleton, color-coded by per-pixel degree + /// (endpoint / junction / path). Same coord system as + /// `source_b64` / `sdf_b64` / `coverage_b64`. + pub skeleton_b64: String, + /// 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 + /// skeleton (the direction the walker would head from this seed). + pub endpoint_arrows: Vec<(f32, f32, f32, f32)>, + /// Brush disk shape: integer (dx, dy) offsets that are inside the + /// brush mask at this radius. Lets the frontend render an inset + /// "this is the brush footprint" diagram. + pub disk_offsets: Vec<(i32, i32)>, + /// Per-stroke seeding info: one entry per `pick_next_component` + /// call that returned a seed. Each entry lists every connected + /// component in the unpainted mask at that moment, with bbox + + /// pixel count + flags for "would have been seeded" (substantial) + /// and "actually chosen as seed." + pub stroke_seedings: Vec, + /// Per-stroke pre-walk unpainted snapshot: the unpainted mask as + /// a PNG, captured just before each stroke's bidirectional walk + /// began. Same length as `trajectories`. Lets the scrubber show + /// "what the walker saw" at the start of stroke N. + pub unpainted_snapshots: Vec, +} + +/// Per-stroke seeding diagnostics: one of these is recorded for each +/// `pick_next_component` call that produced a seed. Lists every +/// connected unpainted-ink component visible at that moment, with +/// flags for whether it was eligible (≥ min_component_pixels) and +/// whether the picker picked it. +#[derive(Debug, Clone, serde::Serialize)] +pub struct StrokeSeeding { + pub stroke_idx: u32, + pub min_component_pixels: u32, + pub raw_start: (f32, f32), + pub snapped_start: (f32, f32), + pub init_dir: (f32, f32), + pub components: Vec, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct SeedComponent { + /// Bbox in hull-local (relative to grid bx/by) coords: + /// `[x_min, y_min, x_max, y_max]`. + pub bbox: [i32; 4], + pub pixel_count: u32, + pub substantial: bool, // ≥ min_component_pixels — eligible to seed + pub chosen: bool, // picked by this pick_next_component call } /// One full walk_brush invocation, recorded for stepping/visualization. @@ -400,6 +449,73 @@ fn encode_sdf_b64(hull: &Hull) -> (String, f32) { (format!("data:image/png;base64,{}", b64), max_d) } +/// Encode the spur-pruned skeleton as a per-pixel PNG, color-coded by +/// degree (count of in-skel 8-neighbors): +/// 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. +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; + 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 nbrs = zs_neighbors(abs_x, abs_y); + let mut deg = 0; + for (nx, ny) in nbrs { + let nlx = nx as i32 - hull_data.bx; + let nly = ny as i32 - hull_data.by; + if nlx < 0 || nly < 0 || nlx >= hull_data.width || nly >= hull_data.height { continue; } + if hull_data.skeleton.get((nly * hull_data.width + nlx) as usize) { + deg += 1; + } + } + 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 + }; + img.put_pixel(lx as u32, ly as u32, image::Rgba(rgba)); + } + } + let mut buf = std::io::Cursor::new(Vec::new()); + if img.write_to(&mut buf, image::ImageFormat::Png).is_err() { return String::new(); } + use base64::Engine as _; + let b64 = base64::engine::general_purpose::STANDARD.encode(buf.get_ref()); + format!("data:image/png;base64,{}", b64) +} + +/// 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. +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; + if grid.unpainted.get(idx) { + img.put_pixel(lx as u32, ly as u32, image::Rgba([244, 63, 94, 200])); + } + } + } + let mut buf = std::io::Cursor::new(Vec::new()); + if img.write_to(&mut buf, image::ImageFormat::Png).is_err() { return String::new(); } + use base64::Engine as _; + let b64 = base64::engine::general_purpose::STANDARD.encode(buf.get_ref()); + format!("data:image/png;base64,{}", b64) +} + fn encode_coverage_b64(grid: &Grid) -> String { let bw = grid.width.max(1) as u32; let bh = grid.height.max(1) as u32; @@ -494,6 +610,13 @@ struct HullData { /// (instead of trying to go down off the end of the foot, which /// is what the old hard-coded `(0, 1)` did). skel_endpoints_init_dir: Vec<(f32, f32)>, + /// Spur-pruned thinned skeleton, bit-packed in the same coord + /// system as `was_ink`. Kept around (small memory cost — ~1 bit + /// per ink pixel) so the debug viewer can render it overlaid on + /// the source. Per-pixel skeleton-degree (endpoint vs junction + /// vs path) is derived on demand by scanning 8-connected + /// neighbors of each skeleton pixel. + skeleton: BitMask, skeleton_length: u32, ink_total: i32, } @@ -601,9 +724,18 @@ fn compute_hull_data(hull: &Hull) -> HullData { skel_endpoints.push((x as i32, y as i32)); skel_endpoints_init_dir.push((dx / mag, dy / mag)); } + // Bit-pack the skeleton in the same coord system as was_ink so + // the debug renderer can paint it as a per-pixel overlay. + let mut skeleton = BitMask::new(cells); + for &(x, y) in &skel { + let lx = x as i32 - bx; let ly = y as i32 - by; + if lx < 0 || ly < 0 || lx >= width || ly >= height { continue; } + skeleton.set((ly * width + lx) as usize); + } 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_length, ink_total: count } + skel_endpoints, skel_endpoints_init_dir, skeleton, + skeleton_length, ink_total: count } } // ── Coverage grid: per-call mutable state, sized to the hull's bbox ───── @@ -859,8 +991,9 @@ impl Grid { /// Returns `None` once nothing remains worth painting, which lets /// `paint_fill` exit cleanly instead of burning through max_strokes /// on phantom 1-px gap attempts. - fn pick_next_component(&mut self, min_component_pixels: u32) - -> Option<((f32, f32), (f32, f32))> + fn pick_next_component(&mut self, min_component_pixels: u32, + debug_components: Option<&mut Vec>) + -> Option<((f32, f32), (f32, f32), (f32, f32))> // (snapped, init_dir, raw) { let mut comp_id = vec![-1i32; self.unpainted.len()]; let mut components: Vec<(Vec, (i32, i32, i32, i32))> = Vec::new(); @@ -915,7 +1048,34 @@ impl Grid { _ => {} } } - let chosen = match best { Some((i, _)) => i, None => return None }; + let chosen = match best { Some((i, _)) => i, None => { + // Even on None-return, fill the debug if requested so the + // viewer can see why nothing was seeded. + if let Some(out) = debug_components { + for (pixels, (top, left, bot, right)) in components.iter() { + out.push(SeedComponent { + bbox: [*left + self.bx, *top + self.by, + *right + self.bx, *bot + self.by], + pixel_count: pixels.len() as u32, + substantial: (pixels.len() as u32) >= min_component_pixels, + chosen: false, + }); + } + } + return None; + } }; + + if let Some(out) = debug_components { + for (i, (pixels, (top, left, bot, right))) in components.iter().enumerate() { + out.push(SeedComponent { + bbox: [*left + self.bx, *top + self.by, + *right + self.bx, *bot + self.by], + pixel_count: pixels.len() as u32, + substantial: (pixels.len() as u32) >= min_component_pixels, + chosen: i == chosen, + }); + } + } // Writing-order start: prefer a skeleton endpoint ("leg") that // falls inside the chosen component's still-unpainted ink. These @@ -966,7 +1126,7 @@ impl Grid { ((best_pixel.0 as f32, best_pixel.1 as f32), (0.0, 1.0)) } }; - Some((self.snap_to_ridge(raw, 16), init_dir)) + Some((self.snap_to_ridge(raw, 16), init_dir, raw)) } } @@ -1235,7 +1395,7 @@ pub fn paint_fill_with(hull: &Hull, params: &PaintParams) -> FillResult { for stroke_idx in 0..params.max_strokes { if grid.ink_remaining <= 0 { break; } - let (start, init_dir) = match grid.pick_next_component(min_component_pixels) { + let (start, init_dir, _raw) = match grid.pick_next_component(min_component_pixels, None) { Some(s) => s, None => break, }; let path = trace_stroke(start, init_dir, &mut grid, params, brush_radius, None, stroke_idx); @@ -1594,11 +1754,37 @@ fn paint_fill_debug_inner(hull: &Hull, params: &PaintParams, let min_component_pixels = (params.min_component_factor * brush_area).max(1.0) as u32; let mut walks: Vec = Vec::new(); + let mut stroke_seedings: Vec = Vec::new(); + let mut unpainted_snapshots: Vec = Vec::new(); for stroke_idx in 0..params.max_strokes { if grid.ink_remaining <= 0 { break; } - let (start, init_dir) = match grid.pick_next_component(min_component_pixels) { - Some(s) => s, None => break, - }; + // Capture the unpainted mask BEFORE this stroke walks, so the + // viewer can scrub through "what the walker saw at each step". + if render_pngs { + unpainted_snapshots.push(encode_grid_unpainted_b64(&grid)); + } + let mut comps_dbg: Vec = Vec::new(); + let comps_out: Option<&mut Vec> = if record_walks { + Some(&mut comps_dbg) + } else { None }; + let pnc = grid.pick_next_component(min_component_pixels, comps_out); + if record_walks { + // Record the seeding decision (even if it returned None — + // tells the viewer "no substantial component left"). + let (snapped, init_dir, raw) = match pnc { + Some(s) => s, + None => ((0.0, 0.0), (0.0, 0.0), (0.0, 0.0)), + }; + stroke_seedings.push(StrokeSeeding { + stroke_idx, + min_component_pixels, + raw_start: raw, + snapped_start: snapped, + init_dir, + components: std::mem::take(&mut comps_dbg), + }); + } + let (start, init_dir, _raw) = match pnc { Some(s) => s, None => break }; let walk_log = if record_walks { Some(&mut walks) } else { None }; let path = trace_stroke(start, init_dir, &mut grid, params, brush_radius, walk_log, stroke_idx); @@ -1623,11 +1809,19 @@ fn paint_fill_debug_inner(hull: &Hull, params: &PaintParams, let (bg_painted, total_swept, repaint) = measure_sweep_full(&strokes, &grid); let skeleton_length = grid.skeleton_length; let unpainted_clusters = grid.unpainted_cluster_sizes(); - let (source_b64, sdf_b64, coverage_b64) = if render_pngs { - (encode_hull_pixels_b64(hull), encode_sdf_b64(hull).0, encode_coverage_b64(&grid)) + let (source_b64, sdf_b64, coverage_b64, skeleton_b64) = if render_pngs { + (encode_hull_pixels_b64(hull), + encode_sdf_b64(hull).0, + encode_coverage_b64(&grid), + encode_skeleton_b64(&grid.hull)) } else { - (String::new(), String::new(), String::new()) + (String::new(), String::new(), String::new(), String::new()) }; + let endpoint_arrows: Vec<(f32, f32, f32, f32)> = grid.hull.skel_endpoints.iter() + .zip(grid.hull.skel_endpoints_init_dir.iter()) + .map(|(&(ex, ey), &(dx, dy))| (ex as f32, ey as f32, dx, dy)) + .collect(); + let disk_offsets = grid.disk_offsets.clone(); PaintDebug { bounds, source_b64, @@ -1646,6 +1840,11 @@ fn paint_fill_debug_inner(hull: &Hull, params: &PaintParams, strokes, start_points: starts, walks, + skeleton_b64, + endpoint_arrows, + disk_offsets, + stroke_seedings, + unpainted_snapshots, } }