diff --git a/BRUSH_PAINT_ALGORITHM.md b/BRUSH_PAINT_ALGORITHM.md deleted file mode 100644 index 3ea8fca5..00000000 --- a/BRUSH_PAINT_ALGORITHM.md +++ /dev/null @@ -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> } - -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` — original ink mask (immutable) -- `unpainted: Vec` — ink not yet covered by any disk this run -- `sdf: Vec` — 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 | relax↔shorten 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`) diff --git a/src-frontend/src/App.jsx b/src-frontend/src/App.jsx index 7339eb96..c8e89249 100644 --- a/src-frontend/src/App.jsx +++ b/src-frontend/src/App.jsx @@ -565,7 +565,7 @@ export default function App() { {/* Top bar — accent colors match the section dots in the left panel */}
{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 (