purge dead UI references missed in the demolition

- store.js: drop skeleton/centerline/topo/paint from FILL_STRATEGIES
  (these still showed up in the fill node's strategy dropdown)
- App.jsx: drop 'paint' from the view-mode accent-color map
- delete BRUSH_PAINT_ALGORITHM.md (described src/brush_paint.rs which
  no longer exists)
This commit is contained in:
Mitchell Hansen
2026-05-08 21:42:02 -07:00
parent 52338c255c
commit b6e10ad4f6
3 changed files with 2 additions and 372 deletions

View File

@@ -1,370 +0,0 @@
# Brush-Paint Algorithm
A walkthrough of `src/brush_paint.rs` as it stands today. This file describes
*what the code does*, in the order it does it. No proposed changes — read
this first, then we can talk about what to keep, replace, or rip out.
---
## Mental model: reverse Microsoft Paint
Inputs are filled-pixel polygons (`Hull`s) extracted from a rasterized
glyph. The plotter has a fixed-radius pen. We need to produce a sequence
of pen strokes (`Vec<(f32, f32)>`) such that sweeping the pen along each
stroke covers the polygon's ink while minimizing pen lifts and bg paint.
Think of it as an *inverse* of MS Paint:
- MS Paint takes a stroke and produces a painted region (forward).
- We take a painted region and produce strokes (inverse).
A stroke can pass over already-painted pixels — that's repainting, and
it's a normal part of one continuous pen-down. A pen lift starts a new
stroke. The algorithm trades these against each other.
---
## Top-level flow (`paint_fill_with`)
```
input: hull (pixel set), PaintParams
output: FillResult { strokes: Vec<Vec<(f32, f32)>> }
1. compute SDF of the hull (chamfer 3-4)
2. brush_radius = brush_radius_factor * sdf_percentile(...) + brush_radius_offset_px
3. build Grid (per-pixel state: was_ink, unpainted, sdf, skel_endpoints)
4. for stroke_idx in 0..max_strokes:
if no unpainted ink remains → break
start = pick_next_component(...) // writing-order seed
path = trace_stroke(start, ...) // bidirectional walk + polish
simplified = rdp_simplify_f32(path, output_rdp_eps)
emit simplified
5. return strokes
```
Per stroke, the pipeline is:
```
pick_next_component → trace_stroke → rdp_simplify_f32
(start) (waypoints) (decimate)
```
`trace_stroke` itself decomposes into:
```
walk_brush(forward) → walk_brush(backward) → polish_path
↑ ↑ ↓
└─ score / step / repaint-search loop ─┘ relax + shorten
```
---
## Data structures
### `Grid` (per-glyph state)
- `bx, by, width, height` — bbox of the hull in source-image coords
- `was_ink: Vec<bool>` — original ink mask (immutable)
- `unpainted: Vec<bool>` — ink not yet covered by any disk this run
- `sdf: Vec<f32>` — chamfer-3-4 distance / 3 (≈ Euclidean px from polygon edge)
- `skel_endpoints: Vec<(i32, i32)>` — degree-1 nodes of the Zhang-Suen
skeleton after spur pruning. The "legs" — natural pen-down anchors.
- `ink_total`, `ink_remaining` — counters
### `PaintParams` (knobs)
Every parameter is described in the table at the bottom.
---
## Stage 1: brush sizing
```rust
effective_sdf = sdf_percentile(dist, brush_radius_percentile).max(0.5);
brush_radius = brush_radius_factor * effective_sdf + brush_radius_offset_px;
```
`sdf_percentile` sorts all per-pixel SDF values and returns the qth.
At q=0.99 (default) it ignores the top 1% of pixels — this clips the
spike at junctions where two strokes cross (medial-axis SDF goes well
past stroke half-width there). Without that clipping, the brush ends
up sized for the junction blob and is too fat for the rest of the
stroke.
Brush radius is a **single value per hull**, fixed for the whole run.
---
## Stage 2: pick_next_component (start picking)
```
1. Flood-fill 4-connected components of the unpainted ink.
2. For each component below min_component_factor * brush_area pixels:
paint each pixel once and forget about it (sub-threshold leftover).
3. Among the surviving components, pick the topmost-leftmost.
4. Within that component, prefer a skeleton endpoint that lies in
its still-unpainted ink (writing-order: topmost-leftmost endpoint).
Fall back to topmost-leftmost ink pixel if no endpoint qualifies
(closed shapes like O, or after partial fills).
5. Snap the chosen pixel to the local ridge (gradient-ascent on SDF
in a 5×5 window, up to 16 steps).
```
Output: `(f32, f32)` — the start point on the medial axis.
---
## Stage 3: trace_stroke (one stroke)
```
1. Snapshot grid state (so polish can re-evaluate against pre-stroke ink).
2. forward = walk_brush(start, init_dir=(0,1)) // bias downward at first step
3. If forward.len() < 2 → return forward.
4. back_init = -unit(forward[1] - forward[0])
5. backward = walk_brush(start, Some(back_init))
6. combined = reverse(backward) ++ forward[1..]
7. Restore unpainted mask to pre-stroke state.
8. polished = polish_path(combined) // relax + shorten
9. Re-paint disks along polished path into grid.
10. return polished
```
The bidirectional walk guarantees we cover both sides of the start, even
when the start landed mid-stroke. The init_dir on the forward walk
biases the very first step downward (writing-order); the backward walk
then handles whatever sticks up above the start.
---
## Stage 4: walk_brush (the stepping loop)
This is the core of the algorithm. Per iteration:
```
1. For each candidate direction:
a. skip if dot(dir, prev_dir) < -0.7 (no immediate flip-back)
b. probe = p + dir * step_size; skip if probe pixel isn't ink
c. compute lookahead_score; add momentum bonus
keep best (dir, score)
2. Compute new_p = p + dir * step_size.
3. would_be_stuck = (new_p disk has 0 unpainted) AND (p disk has 0 unpainted)
4. Decide:
IF score >= min_score AND NOT would_be_stuck:
chosen_dir = dir (normal step)
ELSE:
chosen_dir = nearest_unpainted_through_ink(...)
IF that returns nothing OR cost > pen_lift_penalty: BREAK
5. Step: p ← p + chosen_dir * step_size
6. Paint the disk at the new p.
```
Both the normal score path (step 1b) and the repaint Dijkstra reject
any candidate whose centre falls off-ink, so every committed waypoint
lies on an originally-ink pixel.
Two ways the stroke ends:
- no candidate direction passed the filters (back-direction, ink probe), or
- repaint search returns no reachable target within budget.
---
### Stage 4a: lookahead_score (direction scoring)
```rust
score = Σₖ₌₁..lookahead_steps (1/k) × [ new(k) overpaint*repaint(k) walk_bg*bg(k) ]
+ (has_momentum ? momentum_weight × max(0, dot(dir, prev_dir)) × brush_area : 0)
```
Where `new(k)`, `repaint(k)`, `bg(k)` are pixel counts under the disk
centered at `p + dir * step_size * k`:
- `new` — unpainted ink (ink we want, weighted +1)
- `repaint` — ink already painted this run (ignorable cost)
- `bg` — never-was-ink pixels (off-glyph paint)
Note that `bg` here means *under-disk pixels that are background*, not
*pixels we'd freshly paint* — if the prior stroke already swept some
of these bg pixels, they still count toward the penalty here.
The 1/k weighting means farther-away ink contributes less. With
`lookahead_steps=4` and `step_size=0.5*r`, the horizon is 2r ahead
and 90% of the score comes from the first 1-2 steps.
The `min_score` gate is `min_score_factor * brush_area` — i.e. "the
best direction must add at least 5% of a fresh disk worth of new
coverage in its lookahead horizon".
---
### Stage 4b: nearest_unpainted_through_ink (Dijkstra repaint)
When the walker is stuck or its score is too low, this is the fallback.
```
SDF-weighted Dijkstra on integer pixels:
- start: current walker position
- graph nodes: every ink pixel (painted OR unpainted) within
pen_lift_reach * brush_radius euclidean distance
- graph edges: 8-connected neighbors that are also ink
- edge cost: euclidean_length × (1 + 1.5 / (sdf + 0.5))
// ridges (high sdf) are cheap, near-edge pixels expensive
- target: first popped pixel that is_unpainted
Return: (unit-vector from start to that target, total cost).
Break the walker if cost > pen_lift_penalty.
```
This is what lets one stroke double back through M's painted apex to
reach the second diagonal: starting from the apex, the cheapest path
through painted ink is along the centerline, which leads up the
diagonal that's still unpainted.
The walker takes ONE step in that direction. Next iteration, the
search runs again from the new position. The walker keeps doubling
back until either the regular score becomes positive again (we
reached unpainted ink) or the Dijkstra fails (no unpainted ink in
budget).
---
## Stage 5: polish_path (post-walk relaxation)
Tick-tock for `polish_iters` rounds:
### relax_step
For each waypoint:
1. Find nearest uncovered ink pixel within `polish_search_factor * brush_radius`.
2. Compute proposed shift = 0.7 × distance to that pixel, capped at 0.6 × brush_radius.
3. Reject if the proposed center isn't on ink.
4. Score: `evaluate_perturbation` = `ink_gain - ink_loss - outside_penalty * bg_delta`
where the deltas are computed against the running coverage count
(how many waypoint disks currently cover each pixel).
5. Accept if score > 0.
`outside_penalty` (default 2.0) is heavy: 1 new bg pixel under brush
costs as much as 2 ink pixels gained. This is the strict version of
the walker's `walk_bg_penalty`.
### shorten_step
For each interior waypoint, drop it if every unpainted ink pixel
under its disk is also covered by some *other* waypoint's disk
(redundant). Reduces output gcode size without losing coverage.
---
## Stage 6: rdp_simplify_f32 (output decimation)
Drop waypoints whose perpendicular distance to the chord between their
neighbors is under `output_rdp_eps` (px). Reduces gcode size without
moving any retained waypoint.
---
## Where each penalty lives
| Stage | Function | What it computes | Penalties used |
|------|----------|------------------|----------------|
| Step direction | `lookahead_score` | per-direction goodness | `overpaint_penalty`, `momentum_weight` |
| Step gate | `min_score = min_score_factor × brush_area` | "is best dir good enough" | `min_score_factor` |
| Repaint Dijkstra | `nearest_unpainted_through_ink` | path cost through ink | `pen_lift_penalty`, `pen_lift_reach` |
| Polish perturbation | `evaluate_perturbation` | net-coverage change of a 1-waypoint move | `bg_penalty` |
The walker enforces ink containment as a hard constraint (probe step
must land on ink) and so doesn't need a soft bg term. Polish is the
only stage that weights bg pixels — it pulls waypoints onto the ridge.
---
## Parameters reference
### Brush sizing
| Param | Default | Effect |
|------|---------|--------|
| `brush_radius_factor` | 1.0 | × effective_sdf |
| `brush_radius_offset_px` | 0.5 | added after the multiplier |
| `brush_radius_percentile` | 0.99 | which SDF percentile defines "the typical stroke half-width" |
### Walker stepping
| Param | Default | Effect |
|------|---------|--------|
| `step_size_factor` | 0.5 | step size as × brush radius |
| `n_directions` | 24 | candidates evaluated per step |
| `lookahead_steps` | 4 | how many disks ahead to score |
| `momentum_weight` | 0.4 | bonus for keeping current heading |
### Walker scoring
| Param | Default | Effect |
|------|---------|--------|
| `overpaint_penalty` | 0.05 | per-pixel cost of repainting ink (in lookahead) |
| `min_score_factor` | 0.05 | stroke ends when best score < this × brush_area |
### Polish (post-walk relaxation)
| Param | Default | Effect |
|------|---------|--------|
| `polish_iters` | 4 | relaxshorten rounds |
| `polish_search_factor` | 0.5 | relax search radius (× brush radius) |
| `bg_penalty` | 2.0 | per-pixel cost of bg under disk during a perturbation |
### Components / strokes
| Param | Default | Effect |
|------|---------|--------|
| `min_component_factor` | 0.6 | smallest unpainted component worth a new stroke (× brush area) |
| `pen_lift_penalty` | 30.0 | Dijkstra path-cost budget for double-backs |
| `pen_lift_reach` | 6.0 | Dijkstra search radius (× brush radius) |
| `max_steps_per_stroke` | 4000 | safety cap |
| `max_strokes` | 12 | safety cap per hull |
### Output smoothing
| Param | Default | Effect |
|------|---------|--------|
| `output_rdp_eps` | 0.5 | RDP simplification tolerance (px); 0 disables |
---
## Optimizer
`paint_fill_with` is a deterministic transform `(hull, params) → strokes`.
The sweep wraps it: try a list of `PaintParams` variants, score each
result, return the best. The inner pipeline is unchanged.
```
PaintMetrics // strokes, total_length, bg_painted, ink_unpainted, brush_radius
metrics_for // run paint_fill_debug, build PaintMetrics
ScoreWeights // tunable weights: stroke, length, bg, unpainted
score_weighted // PaintMetrics → f32 (lower = better)
default_score // ScoreWeights::default() applied
paint_fill_sweep // generic: try N variants, pick best
paint_fill_sweep_radius // convenience: sweep absolute brush radii
```
Default scoring weights (`ScoreWeights::default`):
| Weight | Default | Meaning |
|------|---------|---------|
| `stroke` | 800.0 | one pen-lift = 800 score units |
| `length` | 1.0 | 1 px of stroke = 1 unit |
| `bg` | 5.0 | 1 bg pixel painted = 5 units |
| `unpainted` | 1000.0 | 1 ink pixel uncovered = 1000 (effectively a hard constraint) |
A new stroke is only worth 160 saved bg pixels. Coverage shortfall
dominates everything.
## Known properties
- All 8 unit tests in `brush_paint::tests` pass: full coverage, 4
strokes/char, every waypoint lies on ink, off-glyph % under 0.42 floor.
- Brush size is a single value per hull.
- Every emitted waypoint is guaranteed on-ink (walker rejects off-ink
candidates; repaint Dijkstra graph is restricted to ink).
- The Dijkstra repaint and the lookahead scoring are independent
systems they don't share a cost vocabulary.
- `pen_lift_penalty` is in Dijkstra step units (euclidean × ridge-aversion).
---
## Files
- `src/brush_paint.rs` everything described above
- `src/fill.rs` `chamfer_distance`, `zhang_suen_thin`, `prune_skeleton_spurs`,
`zs_neighbors`, `rdp_simplify_f32`
- `src-frontend/src/components/PaintDebugView.jsx` the live slider UI
- `src-frontend/src/hooks/useTauri.js` the JS-side `DEFAULT_PAINT_PARAMS`
(must match Rust `PaintParams::default()`)
- `target/paint_report/REPORT.md` + `*.png` the alphabet diagnostic
output (regenerate via `cargo test --release --lib paint_alphabet_report -- --ignored --nocapture`)

View File

@@ -565,7 +565,7 @@ export default function App() {
{/* Top bar — accent colors match the section dots in the left panel */}
<div className="flex items-center gap-1 px-3 py-2 border-b border-neutral-800 bg-neutral-900/80">
{VIEW_MODES.map(m => {
const accent = { detection: '#6366f1', contours: '#14b8a6', gcode: '#f59e0b', paint: '#22d3ee', printer: '#10b981', tuning: '#a855f7' }[m]
const accent = { detection: '#6366f1', contours: '#14b8a6', gcode: '#f59e0b', printer: '#10b981', tuning: '#a855f7' }[m]
const label = m === 'gcode' ? 'G-code' : m.charAt(0).toUpperCase() + m.slice(1)
return (
<button key={m}

View File

@@ -4,7 +4,7 @@
export const KERNELS = ['Luminance','Sobel','ColorGradient','Laplacian','Canny','Saturation','XDoG','ColorIsolate']
export const BLEND_MODES = ['Average','Min','Max','Multiply','Screen','Difference']
export const FILL_STRATEGIES = ['hatch','zigzag','offset','spiral','outline','circles','voronoi','hilbert','waves','flow','gradient_hatch','gradient_cross_hatch','skeleton','centerline','topo','paint']
export const FILL_STRATEGIES = ['hatch','zigzag','offset','spiral','outline','circles','voronoi','hilbert','waves','flow','gradient_hatch','gradient_cross_hatch']
// Per-strategy secondary parameter exposed as a slider.
// Strategies not listed here have no secondary parameter.