brush-paint: expose every algorithm-stage abstraction to the debug viewer

Rust additions to PaintDebug:
  * skeleton_b64           — spur-pruned skeleton PNG, color-coded by
                             per-pixel degree (endpoint=red, junction=
                             green, path=grey).
  * endpoint_arrows        — Vec<(x, y, dx, dy)> for SVG init_dir
                             arrows at each skeleton endpoint.
  * disk_offsets           — raw Vec<(i32, i32)> for the brush's
                             precomputed pixel mask (frontend can
                             render an inset diagram).
  * stroke_seedings        — per-stroke list of all unpainted
                             components at that moment (bbox +
                             pixel_count + substantial flag + chosen
                             flag), plus the raw + post-snap start
                             positions and init_dir.
  * unpainted_snapshots    — pre-walk PNG of the unpainted mask
                             before each stroke begins.

HullData carries the bit-packed `skeleton` mask (alongside was_ink,
sdf, etc.). pick_next_component takes an optional Vec<SeedComponent>
out-param and returns the raw start in addition to snapped+init_dir.
Production path passes None — no overhead. metrics_for path
likewise (record_walks=false, render_pngs=false).

Frontend (PaintDebugView.jsx):
  * Replaced the disabled 'skeleton (n/a)' step-layer toggle with
    real layers in the main LAYERS list:
      2. Skeleton (deg-coded)
      3. Endpoint init_dirs
      4. Pre-stroke components
      5. Pre-walk unpainted (snapshot at stroke index)
      6. Final missed-pixels   (existing 'coverage', re-numbered)
  * SVG renderers for the new vector overlays:
      - endpoints as gold dots + tangent arrows scaled to ~1×brush
      - components as bbox outlines (green=substantial,
        grey=sub-threshold, yellow fill=chosen-by-this-stroke)
  * Pre-walk snapshot picks the right PNG by the currently-selected
    walk's stroke_idx, so the scrubber shows what the walker SAW
    just before its current stroke began.

Bit-exact behavior preserved: alphabet report unchanged.
This commit is contained in:
Mitchell Hansen
2026-05-08 00:19:12 -07:00
parent dd2e3135d7
commit f2691bbd2e
2 changed files with 306 additions and 24 deletions

View File

@@ -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 }) {
<label key={l.key} className="flex items-center gap-2 cursor-pointer">
<input type="checkbox"
checked={!!stepEnabled[l.key]}
onChange={() => toggleStepLayer(l.key)}
disabled={l.key === 'skeleton'} />
<span className={l.key === 'skeleton' ? 'text-neutral-600' : ''}>{l.label}</span>
onChange={() => toggleStepLayer(l.key)} />
<span>{l.label}</span>
</label>
))}
{stepEnabled.skeleton && (
<div className="text-[10px] text-neutral-500 pl-5">skeleton not exposed yet</div>
)}
</div>
</div>
@@ -646,6 +645,90 @@ 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" />
)}
{/* 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 (
<g key={`ep${i}`}>
<line x1={x} y1={y} x2={tx} y2={ty}
stroke="#fbbf24" strokeWidth={0.6}
vectorEffect="non-scaling-stroke" />
<circle cx={x} cy={y} r={0.8}
fill="#fbbf24" stroke="#000" strokeWidth={0.3}
vectorEffect="non-scaling-stroke" />
<circle cx={tx} cy={ty} r={0.4}
fill="#fbbf24" stroke="none"
vectorEffect="non-scaling-stroke" />
</g>
)
})}
{/* 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 (
<rect key={`cb${i}`}
x={xmin - 0.5} y={ymin - 0.5}
width={xmax - xmin + 1} height={ymax - ymin + 1}
fill={fill} stroke={stroke}
strokeWidth={0.4}
strokeOpacity={0.9}
vectorEffect="non-scaling-stroke" />
)
})
})()}
{/* 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 (
<image
href={png} xlinkHref={png}
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={coverageOpacity}
style={{ imageRendering: 'pixelated' }}
preserveAspectRatio="none" />
)
})()}
{enabled.coverage && debug.coverage_b64 && (
<image
href={debug.coverage_b64} xlinkHref={debug.coverage_b64}

View File

@@ -196,6 +196,55 @@ pub struct PaintDebug {
/// scrub through the algorithm step by step and inspect every
/// candidate direction the walker considered.
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`.
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<StrokeSeeding>,
/// 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<String>,
}
/// 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<SeedComponent>,
}
#[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<SeedComponent>>)
-> 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<usize>, (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<WalkTrace> = Vec::new();
let mut stroke_seedings: Vec<StrokeSeeding> = Vec::new();
let mut unpainted_snapshots: Vec<String> = 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<SeedComponent> = Vec::new();
let comps_out: Option<&mut Vec<SeedComponent>> = 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,
}
}