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,
}
}