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:
@@ -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 | 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`)
|
|
||||||
@@ -565,7 +565,7 @@ export default function App() {
|
|||||||
{/* Top bar — accent colors match the section dots in the left panel */}
|
{/* 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">
|
<div className="flex items-center gap-1 px-3 py-2 border-b border-neutral-800 bg-neutral-900/80">
|
||||||
{VIEW_MODES.map(m => {
|
{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)
|
const label = m === 'gcode' ? 'G-code' : m.charAt(0).toUpperCase() + m.slice(1)
|
||||||
return (
|
return (
|
||||||
<button key={m}
|
<button key={m}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
export const KERNELS = ['Luminance','Sobel','ColorGradient','Laplacian','Canny','Saturation','XDoG','ColorIsolate']
|
export const KERNELS = ['Luminance','Sobel','ColorGradient','Laplacian','Canny','Saturation','XDoG','ColorIsolate']
|
||||||
export const BLEND_MODES = ['Average','Min','Max','Multiply','Screen','Difference']
|
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.
|
// Per-strategy secondary parameter exposed as a slider.
|
||||||
// Strategies not listed here have no secondary parameter.
|
// Strategies not listed here have no secondary parameter.
|
||||||
|
|||||||
Reference in New Issue
Block a user