brush-paint: walker tracing, optimizer rewrite, distributed worker

Walker now records per-step traces (WalkStep / WalkCandidate) for the
debug viz to scrub through. The optimizer is reorganised around a
continuous-space, multi-start, golden-section line-search descent in a
new module (`brush_paint_opt`), with a worker binary
(`paint_opt_worker`) and an SSH-distribution orchestrator
(`scripts/optimize_distributed.sh`).

Frontend `PaintDebugView` rebuilt as a full step-by-step debugger:
walk picker, step scrubber, candidate-direction overlay with score
breakdown, live stats + score-component panel, optimizer-run button
streaming `optimizer-progress` events, and a hardcoded test-letter
picker so debugging works without an image loaded.

Defaults retuned by the optimizer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mitchell Hansen
2026-05-01 23:25:36 -07:00
parent 31bdb185d5
commit d461d2d20e
9 changed files with 3001 additions and 182 deletions

370
BRUSH_PAINT_ALGORITHM.md Normal file
View File

@@ -0,0 +1,370 @@
# 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

@@ -44,3 +44,7 @@ path = "src/gen_test_assets.rs"
[[bin]] [[bin]]
name = "pipeline_bench" name = "pipeline_bench"
path = "src/pipeline_bench.rs" path = "src/pipeline_bench.rs"
[[bin]]
name = "paint_opt_worker"
path = "src/bin/paint_opt_worker.rs"

115
scripts/optimize_distributed.sh Executable file
View File

@@ -0,0 +1,115 @@
#!/usr/bin/env bash
# Distributed brush-paint optimizer.
#
# Splits N optimizer starts between THIS machine and a remote SSH host,
# runs each as a `paint_opt_worker N` invocation, and prints the best
# result by score. Each "start" runs ONE refinement pass (golden-section
# coordinate descent from one randomized seed); they're embarrassingly
# parallel — no synchronisation, no shared state.
#
# Usage:
# scripts/optimize_distributed.sh [N] [REMOTE]
# N = total number of starts (default 32). 0..3 are seeded,
# 4..N-1 are random.
# REMOTE = "user@host:/path/to/Trac3r-rust" (default: env $REMOTE_TRAC3R).
#
# Examples:
# scripts/optimize_distributed.sh
# scripts/optimize_distributed.sh 64 me@beefy:~/source/Trac3r-rust
#
# Defaults: 32 starts, ~split half/half. Adjust LOCAL_FRAC for unequal
# machines. With your beefy=24t, mac=8t-ish, LOCAL_FRAC=0.25 puts 75%
# of work on the desktop.
set -euo pipefail
N="${1:-32}"
REMOTE="${2:-${REMOTE_TRAC3R:-}}"
LOCAL_FRAC="${LOCAL_FRAC:-0.25}" # 25% local, 75% remote (24t desktop)
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
TMPDIR="$(mktemp -d -t opt-distrib.XXXXXX)"
trap 'rm -rf "$TMPDIR"' EXIT
echo "[orch] $N starts, root=$ROOT, tmp=$TMPDIR" >&2
# How many starts go local vs remote.
LOCAL_N=$(awk -v n="$N" -v f="$LOCAL_FRAC" 'BEGIN{ printf "%d", int(n*f + 0.5) }')
REMOTE_N=$((N - LOCAL_N))
echo "[orch] split: $LOCAL_N local, $REMOTE_N remote" >&2
# Build local binary first (release mode).
echo "[orch] cargo build --release --bin paint_opt_worker (local)…" >&2
( cd "$ROOT" && cargo build --release --bin paint_opt_worker ) >&2
# Build remote binary in parallel if a remote is configured.
REMOTE_BUILD_PID=""
if [[ -n "$REMOTE" ]]; then
HOST="${REMOTE%%:*}"
RPATH="${REMOTE#*:}"
echo "[orch] cargo build --release on remote ($HOST:$RPATH)…" >&2
( ssh "$HOST" "cd '$RPATH' && cargo build --release --bin paint_opt_worker" >&2 ) &
REMOTE_BUILD_PID=$!
fi
# Wait for local build to be ready, then dispatch local work in
# background. xargs -P0 = max parallelism (each worker uses rayon for
# its own internal evaluations, but they're CPU-bound so we want the
# OS to schedule).
LOCAL_OUT="$TMPDIR/local.json"
: > "$LOCAL_OUT"
if (( LOCAL_N > 0 )); then
echo "[orch] launching $LOCAL_N local workers…" >&2
seq 0 $((LOCAL_N - 1)) | \
xargs -n1 -P"$LOCAL_N" -I{} \
"$ROOT/target/release/paint_opt_worker" {} \
>> "$LOCAL_OUT" &
LOCAL_PID=$!
else
LOCAL_PID=""
fi
# Wait for remote build, then dispatch remote work.
REMOTE_OUT="$TMPDIR/remote.json"
: > "$REMOTE_OUT"
REMOTE_PID=""
if [[ -n "$REMOTE" && "$REMOTE_N" -gt 0 ]]; then
if [[ -n "$REMOTE_BUILD_PID" ]]; then
wait "$REMOTE_BUILD_PID"
fi
HOST="${REMOTE%%:*}"
RPATH="${REMOTE#*:}"
echo "[orch] launching $REMOTE_N remote workers on $HOST" >&2
# Generate the index list local-side and stream to xargs over ssh.
seq "$LOCAL_N" $((N - 1)) | \
ssh "$HOST" "cd '$RPATH' && xargs -n1 -P$REMOTE_N -I{} ./target/release/paint_opt_worker {}" \
>> "$REMOTE_OUT" &
REMOTE_PID=$!
fi
# Wait for both pools.
[[ -n "$LOCAL_PID" ]] && wait "$LOCAL_PID"
[[ -n "$REMOTE_PID" ]] && wait "$REMOTE_PID"
# Collect + pick the best by lowest .score field. One JSON per line.
ALL="$TMPDIR/all.json"
cat "$LOCAL_OUT" "$REMOTE_OUT" > "$ALL"
LINES=$(wc -l < "$ALL" | tr -d ' ')
echo "[orch] collected $LINES results" >&2
if [[ "$LINES" -ne "$N" ]]; then
echo "[orch] WARNING: expected $N, got $LINES" >&2
fi
# Sort by score (ascending), print top 5 to stderr, full best to stdout.
echo "" >&2
echo "[orch] top 5 by score:" >&2
sort -t : -k 1.1,1.1 "$ALL" \
| jq -s 'sort_by(.score) | .[0:5] | .[] | "\(.start_idx)\t\(.score)"' -r >&2 \
|| awk -F'"score":' '{ split($2, a, ","); printf "%s\t%s\n", $0, a[1] }' "$ALL" \
| sort -k2 -n | head -5 >&2
echo "" >&2
echo "[orch] best result (full JSON on stdout):" >&2
jq -s 'sort_by(.score) | .[0]' "$ALL"

View File

@@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { listen } from '@tauri-apps/api/event'
import * as tauri from '../hooks/useTauri.js' import * as tauri from '../hooks/useTauri.js'
import { DEFAULT_PAINT_PARAMS } from '../hooks/useTauri.js' import { DEFAULT_PAINT_PARAMS } from '../hooks/useTauri.js'
@@ -16,8 +17,67 @@ const LAYERS = [
{ key: 'strokes', label: '6. Smoothed strokes', on: true }, { key: 'strokes', label: '6. Smoothed strokes', on: true },
] ]
// Step-viz layers — toggled independently from the "final result" layers above.
const STEP_LAYERS = [
{ key: 'paintedSoFar', label: 'a. Painted-so-far', on: true },
{ key: 'pathSoFar', label: 'b. Path up to step', on: true },
{ key: 'futurePath', label: 'c. Future path (ghost)', on: true },
{ 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().
const SCORE_WEIGHTS = {
stroke: 500,
length: 5,
bg: 50,
repaint: 30,
unpainted: 200,
length_excess: 300,
curvature: 500,
brush_size: 2000,
}
const LENGTH_BUDGET_FACTOR = 1.5
const PLAY_SPEEDS = [
{ label: 'slow', ms: 500 },
{ label: 'normal', ms: 250 },
{ label: 'fast', ms: 100 },
]
const strokeHue = (i) => `hsl(${((i * 137.508) % 360).toFixed(1)}, 80%, 55%)` const strokeHue = (i) => `hsl(${((i * 137.508) % 360).toFixed(1)}, 80%, 55%)`
// HSL gradient red(0)→green(120) keyed on rank in [0,1].
const rankHue = (rank01) => `hsl(${(rank01 * 120).toFixed(1)}, 90%, 55%)`
const polylineLength = (pts) => {
let L = 0
for (let i = 1; i < pts.length; i++) {
const dx = pts[i][0] - pts[i - 1][0]
const dy = pts[i][1] - pts[i - 1][1]
L += Math.hypot(dx, dy)
}
return L
}
// Sum of |angle changes| across a polyline (in radians).
const polylineCurvature = (pts) => {
let c = 0
for (let i = 1; i < pts.length - 1; i++) {
const ax = pts[i][0] - pts[i - 1][0], ay = pts[i][1] - pts[i - 1][1]
const bx = pts[i + 1][0] - pts[i][0], by = pts[i + 1][1] - pts[i][1]
const la = Math.hypot(ax, ay), lb = Math.hypot(bx, by)
if (la < 1e-9 || lb < 1e-9) continue
let cosT = (ax * bx + ay * by) / (la * lb)
if (cosT > 1) cosT = 1
if (cosT < -1) cosT = -1
c += Math.abs(Math.acos(cosT))
}
return c
}
export default function PaintDebugView({ passIdx = 0 }) { export default function PaintDebugView({ passIdx = 0 }) {
const [hulls, setHulls] = useState([]) const [hulls, setHulls] = useState([])
const [hullIdx, setHullIdx] = useState(0) const [hullIdx, setHullIdx] = useState(0)
@@ -30,6 +90,9 @@ export default function PaintDebugView({ passIdx = 0 }) {
const [enabled, setEnabled] = useState( const [enabled, setEnabled] = useState(
Object.fromEntries(LAYERS.map(l => [l.key, l.on])), Object.fromEntries(LAYERS.map(l => [l.key, l.on])),
) )
const [stepEnabled, setStepEnabled] = useState(
Object.fromEntries(STEP_LAYERS.map(l => [l.key, l.on])),
)
const [view, setView] = useState({ zoom: 1, panX: 0, panY: 0 }) const [view, setView] = useState({ zoom: 1, panX: 0, panY: 0 })
const containerRef = useRef(null) const containerRef = useRef(null)
const svgRef = useRef(null) const svgRef = useRef(null)
@@ -38,6 +101,20 @@ export default function PaintDebugView({ passIdx = 0 }) {
const [selBox, setSelBox] = useState(null) const [selBox, setSelBox] = useState(null)
const [toast, setToast] = useState(null) const [toast, setToast] = useState(null)
// Walker scrubber state
const [walkIdx, setWalkIdx] = useState(0)
const [stepIdx, setStepIdx] = useState(0)
const [playing, setPlaying] = useState(false)
const [playSpeedMs, setPlaySpeedMs] = useState(250)
const [candHover, setCandHover] = useState(null)
// last mouse pos in screen coords for tooltip placement
const cursorRef = useRef({ x: 0, y: 0 })
// Bump on every successful test-letter load so the debug fetch effect
// re-fires even when (passIdx, hullIdx, params, hulls.length) all stay
// the same (common: any letter with 1 hull replaces another with 1 hull
// and hullIdx defaults to 0 both times).
const [reloadKey, setReloadKey] = useState(0)
useEffect(() => { useEffect(() => {
let alive = true let alive = true
tauri.listHulls(passIdx).then(list => { tauri.listHulls(passIdx).then(list => {
@@ -50,19 +127,50 @@ export default function PaintDebugView({ passIdx = 0 }) {
}, [passIdx]) }, [passIdx])
useEffect(() => { useEffect(() => {
if (hulls.length === 0) return if (hulls.length === 0) { setDebug(null); return }
let alive = true let alive = true
tauri.getPaintDebug(passIdx, hullIdx, params).then(d => { tauri.getPaintDebug(passIdx, hullIdx, params).then(d => {
if (!alive) return if (!alive) return
setDebug(d) setDebug(d)
}).catch(() => {}) }).catch(() => {})
return () => { alive = false } return () => { alive = false }
}, [passIdx, hullIdx, params, hulls.length]) }, [passIdx, hullIdx, params, hulls.length, reloadKey])
useEffect(() => { useEffect(() => {
setView({ zoom: 1, panX: 0, panY: 0 }) setView({ zoom: 1, panX: 0, panY: 0 })
}, [hullIdx]) }, [hullIdx])
// When new debug data arrives, clamp scrubber selection.
useEffect(() => {
if (!debug || !debug.walks || debug.walks.length === 0) return
setWalkIdx(w => Math.max(0, Math.min(w, debug.walks.length - 1)))
}, [debug])
const walks = debug?.walks ?? []
const walk = walks[walkIdx] ?? null
const stepCount = walk?.steps?.length ?? 0
useEffect(() => {
setStepIdx(s => Math.max(0, Math.min(s, Math.max(0, stepCount - 1))))
}, [walkIdx, stepCount])
const step = walk?.steps?.[stepIdx] ?? null
// Play loop
useEffect(() => {
if (!playing || !walk) return
const id = setInterval(() => {
setStepIdx(s => {
if (s >= stepCount - 1) {
setPlaying(false)
return s
}
return s + 1
})
}, playSpeedMs)
return () => clearInterval(id)
}, [playing, playSpeedMs, walk, stepCount])
const viewBox = useMemo(() => { const viewBox = useMemo(() => {
if (!debug) return '0 0 100 100' if (!debug) return '0 0 100 100'
const [x0, y0, x1, y1] = debug.bounds const [x0, y0, x1, y1] = debug.bounds
@@ -187,26 +295,129 @@ export default function PaintDebugView({ passIdx = 0 }) {
} }
const onMouseMoveSvg = (e) => { const onMouseMoveSvg = (e) => {
cursorRef.current = { x: e.clientX, y: e.clientY }
const ip = clientToImage(e.clientX, e.clientY) const ip = clientToImage(e.clientX, e.clientY)
if (ip) setHover({ x: ip.x, y: ip.y }) if (ip) setHover({ x: ip.x, y: ip.y })
} }
const toggleLayer = (key) => setEnabled(en => ({ ...en, [key]: !en[key] })) const toggleLayer = (key) => setEnabled(en => ({ ...en, [key]: !en[key] }))
const toggleStepLayer = (key) => setStepEnabled(en => ({ ...en, [key]: !en[key] }))
// ── Step viz: painted-so-far waypoints ───────────────────────────────────────
// Memoised — rebuilds only when (walkIdx, stepIdx, brush_radius) change.
const paintedWaypoints = useMemo(() => {
if (!walk || !step) return []
const path = walk.path ?? []
// path is the full walker path; we want index 0..stepIdx INCLUSIVE.
// path is typically (steps.length + 1) long: start + one per step.
const k = Math.min(stepIdx + 1, path.length)
return path.slice(0, k)
}, [walkIdx, stepIdx, walk, step])
// ── Score breakdown for the whole hull ───────────────────────────────────────
const scoreBreakdown = useMemo(() => {
if (!debug) return null
const totalLen = (debug.strokes ?? []).reduce((a, s) => a + polylineLength(s), 0)
const totalCurv = (debug.strokes ?? []).reduce((a, s) => a + polylineCurvature(s), 0)
const numStrokes = debug.strokes?.length ?? 0
const skel = debug.skeleton_length ?? 0
const budget = LENGTH_BUDGET_FACTOR * skel
const lengthExcess = Math.max(0, totalLen - budget)
const brushR = debug.brush_radius ?? 0
const rows = [
{ name: 'stroke', raw: numStrokes, unit: 'count', weight: SCORE_WEIGHTS.stroke },
{ name: 'length', raw: totalLen, unit: 'px', weight: SCORE_WEIGHTS.length },
{ name: 'bg', raw: debug.bg_painted ?? 0, unit: 'px', weight: SCORE_WEIGHTS.bg },
{ name: 'repaint', raw: debug.repaint ?? 0, unit: 'px', weight: SCORE_WEIGHTS.repaint },
{ name: 'unpainted', raw: debug.ink_unpainted ?? 0, unit: 'px', weight: SCORE_WEIGHTS.unpainted },
{ name: 'length_excess', raw: lengthExcess, unit: 'px', weight: SCORE_WEIGHTS.length_excess },
{ name: 'curvature', raw: totalCurv, unit: 'rad', weight: SCORE_WEIGHTS.curvature },
{ name: 'brush_size', raw: brushR, unit: 'px', weight: SCORE_WEIGHTS.brush_size },
]
const total = rows.reduce((a, r) => a + r.raw * r.weight, 0)
return { rows, total, totalLen, totalCurv, budget, lengthExcess }
}, [debug])
// ── Hull metrics ─────────────────────────────────────────────────────────────
const hullMetrics = useMemo(() => {
if (!debug) return null
const inkTotal = debug.ink_total ?? 0
const inkUnpainted = debug.ink_unpainted ?? 0
const totalSwept = debug.total_swept ?? 0
const bgPainted = debug.bg_painted ?? 0
const skel = debug.skeleton_length ?? 0
const totalLen = (debug.strokes ?? []).reduce((a, s) => a + polylineLength(s), 0)
const coverage = inkTotal > 0 ? (inkTotal - inkUnpainted) / inkTotal * 100 : 0
const offGlyph = totalSwept > 0 ? bgPainted / totalSwept * 100 : 0
const lenRatio = skel > 0 ? totalLen / skel : 0
return {
coverage, offGlyph, lenRatio, totalLen,
coverageOk: coverage >= 95,
offGlyphOk: offGlyph <= 5,
lenRatioOk: lenRatio <= 2.0,
}
}, [debug])
// ── Keyboard shortcuts (when SVG focused) ────────────────────────────────────
const onSvgKeyDown = useCallback((e) => {
if (!walk) return
if (e.key === 'ArrowLeft') { setStepIdx(s => Math.max(0, s - 1)); e.preventDefault() }
else if (e.key === 'ArrowRight') { setStepIdx(s => Math.min(stepCount - 1, s + 1)); e.preventDefault() }
else if (e.key === ' ') { setPlaying(p => !p); e.preventDefault() }
else if (e.key === 'Home') { setStepIdx(0); e.preventDefault() }
else if (e.key === 'End') { setStepIdx(Math.max(0, stepCount - 1)); e.preventDefault() }
}, [walk, stepCount])
if (!debug) { if (!debug) {
return ( return (
<div className="absolute inset-0 flex items-center justify-center bg-neutral-900 text-neutral-500"> <div className="absolute inset-0 bg-neutral-900 flex">
<div className="text-center space-y-2"> <div className="w-72 shrink-0 border-r border-neutral-800 p-3 overflow-y-auto text-xs text-neutral-300 space-y-3">
<p>Paint debug</p> <TestLetterPicker passIdx={passIdx} onLoaded={(list) => {
<p className="text-xs">No hulls available run the pipeline first (Source Kernel Hull).</p> const sorted = [...list].sort((a, b) => b.area - a.area)
setHulls(sorted)
if (sorted.length > 0) setHullIdx(sorted[0].index)
setReloadKey(k => k + 1)
}} />
<div className="text-neutral-500 leading-relaxed pt-2">
<p>Pick a test letter above to start, or load an image and run
the pipeline (Source Kernel Hull) to debug a real glyph.</p>
</div>
</div>
<div className="flex-1 flex items-center justify-center text-neutral-600 text-sm">
No hull loaded
</div> </div>
</div> </div>
) )
} }
// Compute candidate ranking (by score, ignoring rejected ones).
const candidateRanks = (() => {
if (!step) return null
const cands = step.candidates ?? []
const live = cands
.map((c, i) => ({ c, i }))
.filter(({ c }) => !c.rejected_back && !c.rejected_off_ink)
const sorted = [...live].sort((a, b) => a.c.score - b.c.score)
const rank = new Map() // candidate index → rank01
sorted.forEach(({ i }, k) => {
rank.set(i, sorted.length > 1 ? k / (sorted.length - 1) : 1)
})
return rank
})()
const brushR = debug.brush_radius ?? 1
return ( return (
<div ref={containerRef} className="absolute inset-0 bg-neutral-900 flex"> <div ref={containerRef} className="absolute inset-0 bg-neutral-900 flex">
<div className="w-64 shrink-0 border-r border-neutral-800 p-3 overflow-y-auto text-xs text-neutral-300 space-y-3"> <div className="w-72 shrink-0 border-r border-neutral-800 p-3 overflow-y-auto text-xs text-neutral-300 space-y-3">
<TestLetterPicker passIdx={passIdx} onLoaded={(list) => {
const sorted = [...list].sort((a, b) => b.area - a.area)
setHulls(sorted)
if (sorted.length > 0) setHullIdx(sorted[0].index)
setReloadKey(k => k + 1)
}} />
<div> <div>
<label className="block text-neutral-500 mb-1">Hull (largest first)</label> <label className="block text-neutral-500 mb-1">Hull (largest first)</label>
<select value={hullIdx} <select value={hullIdx}
@@ -235,6 +446,45 @@ export default function PaintDebugView({ passIdx = 0 }) {
</div> </div>
</div> </div>
<div className="pt-1 border-t border-neutral-800">
<div className="text-neutral-500 mb-1">Step viz</div>
<div className="space-y-1">
{STEP_LAYERS.map(l => (
<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>
</label>
))}
{stepEnabled.skeleton && (
<div className="text-[10px] text-neutral-500 pl-5">skeleton not exposed yet</div>
)}
</div>
</div>
<ScrubberPanel
walks={walks}
walkIdx={walkIdx}
setWalkIdx={setWalkIdx}
stepIdx={stepIdx}
setStepIdx={setStepIdx}
stepCount={stepCount}
playing={playing}
setPlaying={setPlaying}
playSpeedMs={playSpeedMs}
setPlaySpeedMs={setPlaySpeedMs}
/>
<HullMetricsPanel m={hullMetrics} debug={debug} />
<ScoreBreakdownPanel sb={scoreBreakdown} />
<SelectedStepPanel walk={walk} step={step} stepIdx={stepIdx} />
<OptimizerPanel passIdx={passIdx} hullIdx={hullIdx} params={params} setParams={setParams} />
<div className="space-y-2 pt-1 border-t border-neutral-800"> <div className="space-y-2 pt-1 border-t border-neutral-800">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-neutral-500">Brush</span> <span className="text-neutral-500">Brush</span>
@@ -275,6 +525,9 @@ export default function PaintDebugView({ passIdx = 0 }) {
<ParamSlider label="Overpaint penalty" value={params.overpaint_penalty} min={0} max={0.5} step={0.01} <ParamSlider label="Overpaint penalty" value={params.overpaint_penalty} min={0} max={0.5} step={0.01}
onChange={v => setParam('overpaint_penalty', v)} onChange={v => setParam('overpaint_penalty', v)}
hint="Per-pixel cost for painting over already-painted pixels." /> hint="Per-pixel cost for painting over already-painted pixels." />
<ParamSlider label="Walk BG penalty" value={params.walk_bg_penalty} min={0} max={20} step={0.5}
onChange={v => setParam('walk_bg_penalty', v)}
hint="Per-bg-pixel cost in the walker's lookahead. Higher = stricter centerline-following at corners (rejects corner-cut shortcuts)." />
<ParamSlider label="Min score factor" value={params.min_score_factor} min={0} max={0.5} step={0.01} <ParamSlider label="Min score factor" value={params.min_score_factor} min={0} max={0.5} step={0.01}
onChange={v => setParam('min_score_factor', v)} onChange={v => setParam('min_score_factor', v)}
hint="Stroke ends when best direction's score < this × brush area." /> hint="Stroke ends when best direction's score < this × brush area." />
@@ -288,9 +541,9 @@ export default function PaintDebugView({ passIdx = 0 }) {
<ParamSlider label="Polish search ×r" value={params.polish_search_factor} min={0.5} max={6} step={0.25} <ParamSlider label="Polish search ×r" value={params.polish_search_factor} min={0.5} max={6} step={0.25}
onChange={v => setParam('polish_search_factor', v)} onChange={v => setParam('polish_search_factor', v)}
hint="How far (in brush radii) to search for unpainted ink near each waypoint." /> hint="How far (in brush radii) to search for unpainted ink near each waypoint." />
<ParamSlider label="Outside penalty" value={params.outside_penalty} min={0} max={10} step={0.25} <ParamSlider label="BG penalty" value={params.bg_penalty} min={0} max={10} step={0.25}
onChange={v => setParam('outside_penalty', v)} onChange={v => setParam('bg_penalty', v)}
hint="Cost per background-pixel under brush. Reject moves that drift the path off the glyph." /> hint="Per-bg-pixel cost in the polish/relax pass. Higher = stricter centerline pull." />
<ParamSlider label="Min component" value={params.min_component_factor} min={0} max={2} step={0.1} <ParamSlider label="Min component" value={params.min_component_factor} min={0} max={2} step={0.1}
onChange={v => setParam('min_component_factor', v)} onChange={v => setParam('min_component_factor', v)}
hint="Smallest unpainted-ink connected component that warrants a new stroke, as a multiple of brush area. Smaller components get a single disk stamp instead." /> hint="Smallest unpainted-ink connected component that warrants a new stroke, as a multiple of brush area. Smaller components get a single disk stamp instead." />
@@ -309,9 +562,7 @@ export default function PaintDebugView({ passIdx = 0 }) {
<ParamSlider label="Max strokes" value={params.max_strokes} min={1} max={30} step={1} <ParamSlider label="Max strokes" value={params.max_strokes} min={1} max={30} step={1}
onChange={v => setParam('max_strokes', v)} hint="Safety cap on strokes per hull." /> onChange={v => setParam('max_strokes', v)} hint="Safety cap on strokes per hull." />
<ParamSlider label="Output RDP" value={params.output_rdp_eps} min={0} max={2} step={0.1} <ParamSlider label="Output RDP" value={params.output_rdp_eps} min={0} max={2} step={0.1}
onChange={v => setParam('output_rdp_eps', v)} hint="Final stroke RDP epsilon." /> onChange={v => setParam('output_rdp_eps', v)} hint="Final stroke RDP simplification epsilon (px). 0 disables." />
<ParamSlider label="Output Chaikin" value={params.output_chaikin} min={0} max={6} step={1}
onChange={v => setParam('output_chaikin', v)} hint="Final stroke Chaikin smoothing passes." />
</div> </div>
<div className="pt-2 border-t border-neutral-800 space-y-2"> <div className="pt-2 border-t border-neutral-800 space-y-2">
@@ -343,6 +594,7 @@ export default function PaintDebugView({ passIdx = 0 }) {
<div className="pt-2 border-t border-neutral-800 text-neutral-500 leading-relaxed"> <div className="pt-2 border-t border-neutral-800 text-neutral-500 leading-relaxed">
<p>Wheel: zoom · Drag: pan · Shift+drag: copy region</p> <p>Wheel: zoom · Drag: pan · Shift+drag: copy region</p>
<p className="mt-1">SVG keys: / step · Space play · Home/End</p>
<button onClick={() => setView({ zoom: 1, panX: 0, panY: 0 })} <button onClick={() => setView({ zoom: 1, panX: 0, panY: 0 })}
className="mt-1 text-xs px-2 py-0.5 bg-neutral-800 rounded">Fit</button> className="mt-1 text-xs px-2 py-0.5 bg-neutral-800 rounded">Fit</button>
<div className="mt-2 space-y-0.5"> <div className="mt-2 space-y-0.5">
@@ -350,6 +602,7 @@ export default function PaintDebugView({ passIdx = 0 }) {
<div>· {debug.start_points.length} start points</div> <div>· {debug.start_points.length} start points</div>
<div>· {debug.trajectories.length} raw trajectories</div> <div>· {debug.trajectories.length} raw trajectories</div>
<div>· {debug.strokes.length} smoothed strokes</div> <div>· {debug.strokes.length} smoothed strokes</div>
<div>· {walks.length} walks recorded</div>
</div> </div>
{hover && ( {hover && (
<div className="mt-2 font-mono"> <div className="mt-2 font-mono">
@@ -362,11 +615,14 @@ export default function PaintDebugView({ passIdx = 0 }) {
<div className="flex-1 relative overflow-hidden" onWheel={onWheel} onMouseDown={onMouseDown}> <div className="flex-1 relative overflow-hidden" onWheel={onWheel} onMouseDown={onMouseDown}>
<svg <svg
ref={svgRef} ref={svgRef}
tabIndex={0}
width="100%" height="100%" width="100%" height="100%"
viewBox={viewBox} viewBox={viewBox}
preserveAspectRatio="xMidYMid meet" preserveAspectRatio="xMidYMid meet"
onMouseMove={onMouseMoveSvg} onMouseMove={onMouseMoveSvg}
style={{ cursor: 'grab', background: '#0f0f10' }}> onMouseLeave={() => setCandHover(null)}
onKeyDown={onSvgKeyDown}
style={{ cursor: 'grab', background: '#0f0f10', outline: 'none' }}>
{enabled.source && debug.source_b64 && ( {enabled.source && debug.source_b64 && (
<image <image
@@ -443,6 +699,111 @@ export default function PaintDebugView({ passIdx = 0 }) {
</g> </g>
))} ))}
{/* ── Step viz ──────────────────────────────────────────────────── */}
{/* Painted-so-far disks. Stamped at each waypoint up to current step. */}
{stepEnabled.paintedSoFar && walk && paintedWaypoints.map((p, i) => (
<circle key={`pa${i}`}
cx={p[0]} cy={p[1]} r={brushR}
fill="lightgray" fillOpacity={0.25} stroke="none" />
))}
{/* Path-up-to-step polyline */}
{stepEnabled.pathSoFar && walk && paintedWaypoints.length > 1 && (
<polyline
points={paintedWaypoints.map(p => `${p[0]},${p[1]}`).join(' ')}
fill="none" stroke="#fb923c" strokeWidth={1.5}
strokeLinecap="round" strokeLinejoin="round"
vectorEffect="non-scaling-stroke" />
)}
{/* Future path (ghost) */}
{stepEnabled.futurePath && walk && step && (() => {
const path = walk.path ?? []
const k = Math.min(stepIdx + 1, path.length)
const future = path.slice(Math.max(0, k - 1))
if (future.length < 2) return null
return (
<polyline
points={future.map(p => `${p[0]},${p[1]}`).join(' ')}
fill="none" stroke="#94a3b8" strokeWidth={1}
strokeOpacity={0.55} strokeDasharray="2 1.5"
strokeLinecap="round" strokeLinejoin="round"
vectorEffect="non-scaling-stroke" />
)
})()}
{/* Brush footprint at current step */}
{stepEnabled.brushHere && walk && step && (
<circle cx={step.p[0]} cy={step.p[1]} r={brushR}
fill="#fde047" fillOpacity={0.4}
stroke="#fde047" strokeOpacity={0.9} strokeWidth={0.6}
vectorEffect="non-scaling-stroke" />
)}
{/* Momentum arrow */}
{stepEnabled.momentum && walk && step && step.prev_dir && (() => {
const [dx, dy] = step.prev_dir
const x2 = step.p[0] + dx * brushR
const y2 = step.p[1] + dy * brushR
return (
<g>
<line x1={step.p[0]} y1={step.p[1]} x2={x2} y2={y2}
stroke="#22d3ee" strokeWidth={1.2}
strokeLinecap="round"
vectorEffect="non-scaling-stroke" />
<circle cx={x2} cy={y2} r={brushR * 0.12}
fill="#22d3ee" stroke="none" />
</g>
)
})()}
{/* Candidates */}
{stepEnabled.candidates && step && step.candidates && step.candidates.map((c, i) => {
const rejected = c.rejected_back || c.rejected_off_ink
const isChosen = step.chosen === i
const r = brushR * 0.5
if (rejected) {
const s = brushR * 0.35
return (
<g key={`cd${i}`}
onMouseEnter={() => setCandHover({ idx: i, c })}
onMouseLeave={() => setCandHover(h => h?.idx === i ? null : h)}
style={{ cursor: 'crosshair' }}>
<line x1={c.probe[0] - s} y1={c.probe[1] - s}
x2={c.probe[0] + s} y2={c.probe[1] + s}
stroke="#525252" strokeOpacity={0.5} strokeWidth={0.6}
vectorEffect="non-scaling-stroke" />
<line x1={c.probe[0] - s} y1={c.probe[1] + s}
x2={c.probe[0] + s} y2={c.probe[1] - s}
stroke="#525252" strokeOpacity={0.5} strokeWidth={0.6}
vectorEffect="non-scaling-stroke" />
</g>
)
}
const rank01 = candidateRanks?.get(i) ?? 0.5
const fill = rankHue(rank01)
return (
<g key={`cd${i}`}
onMouseEnter={() => setCandHover({ idx: i, c })}
onMouseLeave={() => setCandHover(h => h?.idx === i ? null : h)}
style={{ cursor: 'crosshair' }}>
<circle cx={c.probe[0]} cy={c.probe[1]} r={r}
fill={fill} fillOpacity={0.55}
stroke={isChosen ? '#ffffff' : fill}
strokeOpacity={isChosen ? 1 : 0.9}
strokeWidth={isChosen ? 1.4 : 0.4}
vectorEffect="non-scaling-stroke" />
{isChosen && (
<line x1={step.p[0]} y1={step.p[1]} x2={c.probe[0]} y2={c.probe[1]}
stroke="#ffffff" strokeWidth={1.2}
strokeLinecap="round"
vectorEffect="non-scaling-stroke" />
)}
</g>
)
})}
{selBox && ( {selBox && (
<rect <rect
x={Math.min(selBox.x0, selBox.x1)} x={Math.min(selBox.x0, selBox.x1)}
@@ -457,7 +818,7 @@ export default function PaintDebugView({ passIdx = 0 }) {
</svg> </svg>
<div className="absolute bottom-2 left-3 text-[10px] text-neutral-500 pointer-events-none"> <div className="absolute bottom-2 left-3 text-[10px] text-neutral-500 pointer-events-none">
Shift+drag to copy region data to clipboard Shift+drag to copy region data to clipboard · Click SVG, then //Space/Home/End
</div> </div>
{toast && ( {toast && (
@@ -467,6 +828,438 @@ export default function PaintDebugView({ passIdx = 0 }) {
{toast} {toast}
</div> </div>
)} )}
{/* Candidate tooltip */}
{candHover && (() => {
const cur = cursorRef.current
const pad = 12
const rect = containerRef.current?.getBoundingClientRect()
const left = (cur.x - (rect?.left ?? 0)) + pad
const top = (cur.y - (rect?.top ?? 0)) + pad
const c = candHover.c
const fmt = (n) => Number.isFinite(n) ? n.toFixed(3) : String(n)
return (
<div className="absolute pointer-events-none z-10 px-2 py-1.5 rounded
bg-neutral-950/95 border border-neutral-700 text-[10px] text-neutral-200
font-mono shadow-lg whitespace-nowrap"
style={{ left, top }}>
<div className="text-neutral-400">cand #{candHover.idx} · θ={fmt(c.theta)}</div>
<div>new_ink: {fmt(c.new_ink)}</div>
<div>repaint: {fmt(c.repaint)}</div>
<div>bg: {fmt(c.bg)}</div>
<div>momentum_bonus: {fmt(c.momentum_bonus)}</div>
<div className="text-amber-300">score: {fmt(c.score)}</div>
<div className={c.rejected_back ? 'text-rose-400' : 'text-neutral-500'}>
rejected_back: {String(c.rejected_back)}
</div>
<div className={c.rejected_off_ink ? 'text-rose-400' : 'text-neutral-500'}>
rejected_off_ink: {String(c.rejected_off_ink)}
</div>
</div>
)
})()}
</div>
</div>
)
}
// ── Sub-components ──────────────────────────────────────────────────────────────
function ScrubberPanel({
walks, walkIdx, setWalkIdx, stepIdx, setStepIdx, stepCount,
playing, setPlaying, playSpeedMs, setPlaySpeedMs,
}) {
const walk = walks[walkIdx]
return (
<div className="space-y-2 pt-1 border-t border-neutral-800">
<div className="text-neutral-500">Walker scrubber</div>
<div>
<label className="block text-[10px] text-neutral-500 mb-0.5">Walk</label>
<select value={walkIdx}
onChange={e => setWalkIdx(parseInt(e.target.value, 10))}
disabled={walks.length === 0}
className="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-xs">
{walks.length === 0 && <option>(no walks)</option>}
{walks.map((w, i) => (
<option key={i} value={i}>
stroke {w.stroke_idx} {w.kind} · {w.steps.length}st · {w.exit_reason}
</option>
))}
</select>
</div>
<div>
<div className="flex items-center justify-between text-[10px]">
<span className="text-neutral-400">Step</span>
<span className="text-neutral-300 font-mono">
{stepCount === 0 ? '—' : `${stepIdx + 1} / ${stepCount}`}
</span>
</div>
<input type="range"
min={0}
max={Math.max(0, stepCount - 1)}
step={1}
value={stepIdx}
disabled={stepCount === 0}
onChange={e => setStepIdx(parseInt(e.target.value, 10))}
className="w-full" />
</div>
<div className="flex flex-wrap items-center gap-1">
<button onClick={() => setStepIdx(0)}
disabled={stepCount === 0}
className="text-[10px] px-2 py-0.5 rounded bg-neutral-800 hover:bg-neutral-700 disabled:opacity-40"
title="Restart"></button>
<button onClick={() => setStepIdx(s => Math.max(0, s - 1))}
disabled={stepCount === 0}
className="text-[10px] px-2 py-0.5 rounded bg-neutral-800 hover:bg-neutral-700 disabled:opacity-40"
title="Prev (←)"></button>
<button onClick={() => setPlaying(p => !p)}
disabled={stepCount === 0}
className="text-[10px] px-2 py-0.5 rounded bg-indigo-600/30 border border-indigo-500/60 hover:bg-indigo-600/50 text-indigo-200 disabled:opacity-40"
title="Play/Pause (Space)">{playing ? '❚❚' : '▶'}</button>
<button onClick={() => setStepIdx(s => Math.min(stepCount - 1, s + 1))}
disabled={stepCount === 0}
className="text-[10px] px-2 py-0.5 rounded bg-neutral-800 hover:bg-neutral-700 disabled:opacity-40"
title="Next (→)"></button>
<button onClick={() => setStepIdx(Math.max(0, stepCount - 1))}
disabled={stepCount === 0}
className="text-[10px] px-2 py-0.5 rounded bg-neutral-800 hover:bg-neutral-700 disabled:opacity-40"
title="End"></button>
<select
value={playSpeedMs}
onChange={e => setPlaySpeedMs(parseInt(e.target.value, 10))}
className="ml-auto bg-neutral-800 border border-neutral-700 rounded px-1 py-0.5 text-[10px]">
{PLAY_SPEEDS.map(s => (
<option key={s.label} value={s.ms}>{s.label}</option>
))}
</select>
</div>
{walk && (
<div className="text-[10px] text-neutral-500 leading-snug">
start: ({walk.start[0].toFixed(1)}, {walk.start[1].toFixed(1)}) ·
step={walk.step_size?.toFixed(2)} ·
r={walk.brush_radius?.toFixed(2)} ·
min_score={walk.min_score?.toFixed(2)}
<br />
init_dir: {walk.init_dir
? `(${walk.init_dir[0].toFixed(2)}, ${walk.init_dir[1].toFixed(2)})`
: 'none'}
</div>
)}
</div>
)
}
function HullMetricsPanel({ m, debug }) {
if (!m || !debug) return null
const Badge = ({ ok, children }) => (
<span className={`ml-2 px-1 py-0.5 rounded text-[9px] font-mono ${
ok ? 'bg-emerald-500/20 text-emerald-300 border border-emerald-500/40'
: 'bg-rose-500/20 text-rose-300 border border-rose-500/40'
}`}>{children}</span>
)
const Row = ({ k, v }) => (
<div className="flex justify-between text-[10px]">
<span className="text-neutral-500">{k}</span>
<span className="font-mono">{v}</span>
</div>
)
return (
<div className="space-y-1 pt-1 border-t border-neutral-800">
<div className="text-neutral-500">Hull metrics</div>
<Row k="brush_radius" v={(debug.brush_radius ?? 0).toFixed(2)} />
<Row k="ink_total" v={debug.ink_total ?? 0} />
<Row k="ink_unpainted" v={debug.ink_unpainted ?? 0} />
<Row k="bg_painted" v={debug.bg_painted ?? 0} />
<Row k="total_swept" v={debug.total_swept ?? 0} />
<Row k="repaint" v={debug.repaint ?? 0} />
<Row k="skeleton_length" v={(debug.skeleton_length ?? 0).toFixed(2)} />
<div className="flex justify-between text-[10px]">
<span className="text-neutral-500">coverage</span>
<span>
<span className="font-mono">{m.coverage.toFixed(1)}%</span>
<Badge ok={m.coverageOk}>{m.coverageOk ? 'ok' : '<95%'}</Badge>
</span>
</div>
<div className="flex justify-between text-[10px]">
<span className="text-neutral-500">off-glyph</span>
<span>
<span className="font-mono">{m.offGlyph.toFixed(1)}%</span>
<Badge ok={m.offGlyphOk}>{m.offGlyphOk ? 'ok' : '>5%'}</Badge>
</span>
</div>
<div className="flex justify-between text-[10px]">
<span className="text-neutral-500">len/skel</span>
<span>
<span className="font-mono">{m.lenRatio.toFixed(2)}×</span>
<Badge ok={m.lenRatioOk}>{m.lenRatioOk ? 'ok' : '>2.0'}</Badge>
</span>
</div>
</div>
)
}
function ScoreBreakdownPanel({ sb }) {
if (!sb) return null
const fmt = (n) => {
const a = Math.abs(n)
if (a >= 1000) return n.toFixed(0)
if (a >= 1) return n.toFixed(2)
return n.toFixed(3)
}
const sign = (n) => (n >= 0 ? '+' : '')
return (
<div className="space-y-1 pt-1 border-t border-neutral-800">
<div className="text-neutral-500">Score breakdown</div>
<div className="text-[10px] font-mono space-y-0.5">
<div className="grid grid-cols-[1fr_auto_auto_auto] gap-x-2 text-neutral-500">
<span>term</span>
<span className="text-right">raw</span>
<span className="text-right">w</span>
<span className="text-right">contrib</span>
</div>
{sb.rows.map(r => {
const contrib = r.raw * r.weight
return (
<div key={r.name} className="grid grid-cols-[1fr_auto_auto_auto] gap-x-2">
<span className="text-neutral-300">{r.name}</span>
<span className="text-right">{fmt(r.raw)}</span>
<span className="text-right text-neutral-500">×{r.weight}</span>
<span className="text-right text-amber-300">
{sign(contrib)}{fmt(Math.abs(contrib))}
</span>
</div>
)
})}
<div className="grid grid-cols-[1fr_auto] gap-x-2 pt-1 border-t border-neutral-800">
<span className="text-neutral-300">total</span>
<span className="text-right text-amber-300 font-bold">{fmt(sb.total)}</span>
</div>
</div>
<div className="text-[10px] text-neutral-500 leading-snug">
budget = 1.5 × skel = {sb.budget.toFixed(1)} px ·
excess = {sb.lengthExcess.toFixed(1)} ·
Σ|Δθ| = {sb.totalCurv.toFixed(2)} rad
</div>
</div>
)
}
function SelectedStepPanel({ walk, step, stepIdx }) {
if (!walk) return null
const Row = ({ k, v }) => (
<div className="flex justify-between text-[10px]">
<span className="text-neutral-500">{k}</span>
<span className="font-mono">{v}</span>
</div>
)
if (!step) {
return (
<div className="space-y-1 pt-1 border-t border-neutral-800">
<div className="text-neutral-500">Selected step</div>
<div className="text-[10px] text-neutral-500">no step (walk has 0 iters · exit: {walk.exit_reason})</div>
</div>
)
}
const cands = step.candidates ?? []
const rejBack = cands.filter(c => c.rejected_back).length
const rejOff = cands.filter(c => c.rejected_off_ink).length
const accepted = cands.length - rejBack - rejOff
const mom = step.prev_dir ? Math.hypot(step.prev_dir[0], step.prev_dir[1]) : 0
const chosen = step.chosen != null ? cands[step.chosen] : null
const fmt = (n) => Number.isFinite(n) ? n.toFixed(3) : String(n)
return (
<div className="space-y-1 pt-1 border-t border-neutral-800">
<div className="text-neutral-500">Selected step</div>
<Row k="idx" v={step.idx} />
<Row k="p" v={`(${step.p[0].toFixed(2)}, ${step.p[1].toFixed(2)})`} />
<Row k="prev_dir" v={step.prev_dir
? `(${step.prev_dir[0].toFixed(2)}, ${step.prev_dir[1].toFixed(2)})`
: 'none'} />
<Row k="|momentum|" v={mom.toFixed(3)} />
<Row k="cands" v={`${accepted} ok · ${rejBack} back · ${rejOff} off`} />
{chosen ? (
<div className="mt-1 text-[10px] font-mono bg-neutral-950/60 border border-neutral-800 rounded p-1.5 space-y-0.5">
<div className="text-emerald-300">chosen #{step.chosen}</div>
<div>θ {fmt(chosen.theta)}</div>
<div>new_ink {fmt(chosen.new_ink)}</div>
<div>repaint {fmt(chosen.repaint)}</div>
<div>bg {fmt(chosen.bg)}</div>
<div>momentum_bonus {fmt(chosen.momentum_bonus)}</div>
<div className="text-amber-300">score {fmt(chosen.score)}</div>
</div>
) : (
<div className="mt-1 text-[10px] font-mono bg-rose-950/40 border border-rose-800/60 rounded p-1.5 text-rose-200">
exited: {walk.exit_reason}
</div>
)}
</div>
)
}
function OptimizerPanel({ passIdx, hullIdx, params, setParams }) {
const [running, setRunning] = useState(false)
const [log, setLog] = useState([]) // Vec<{step, axis, value, score, delta}>
const [best, setBest] = useState(null) // PaintParams from final result
const logEndRef = useRef(null)
// Auto-scroll to bottom of log on new entries.
useEffect(() => {
if (logEndRef.current) logEndRef.current.scrollTop = logEndRef.current.scrollHeight
}, [log])
const run = async () => {
setRunning(true)
setLog([{ axis: '<initial>', value: 0, score: NaN, delta: 0 }])
setBest(null)
let unlisten = null
try {
unlisten = await listen('optimizer-progress', (event) => {
const p = event.payload
setLog(L => [...L, {
step: p.step, axis: p.axis, value: p.value,
score: p.score, delta: p.delta,
}])
})
const result = await tauri.optimizePaintParams(passIdx, hullIdx, params)
setBest(result)
} catch (err) {
setLog(L => [...L, { axis: 'ERROR', value: 0, score: NaN, delta: 0, err: String(err) }])
} finally {
if (unlisten) unlisten()
setRunning(false)
}
}
const applyBest = () => {
if (best) setParams({ ...best })
}
return (
<div className="space-y-1 pt-1 border-t border-neutral-800">
<div className="flex items-center justify-between">
<span className="text-neutral-500">Optimizer</span>
{running && <span className="text-[9px] text-amber-400 animate-pulse">running</span>}
</div>
<div className="flex gap-1">
<button
onClick={run}
disabled={running}
className="text-[10px] px-2 py-0.5 rounded bg-emerald-700/40 border border-emerald-600/60 hover:bg-emerald-700/60 text-emerald-100 disabled:opacity-50 disabled:cursor-not-allowed">
{running ? 'Running…' : 'Run'}
</button>
<button
onClick={applyBest}
disabled={!best || running}
title={best ? 'Copy the optimizer result into current params' : 'Run first'}
className="text-[10px] px-2 py-0.5 rounded bg-indigo-700/40 border border-indigo-600/60 hover:bg-indigo-700/60 text-indigo-100 disabled:opacity-50 disabled:cursor-not-allowed">
Apply best
</button>
</div>
<div ref={logEndRef}
className="h-32 rounded bg-neutral-950/60 border border-neutral-800 text-[10px] text-neutral-300 px-2 py-1 font-mono overflow-y-auto">
{log.length === 0
? <span className="text-neutral-600">(idle)</span>
: log.map((e, i) => (
<div key={i} className={e.axis === 'ERROR' ? 'text-red-400' : ''}>
{e.axis === '<initial>' && (
<span className="text-neutral-500">starting</span>
)}
{e.axis === 'ERROR' && (
<span>error: {e.err}</span>
)}
{e.axis !== '<initial>' && e.axis !== 'ERROR' && (
<span>
<span className="text-neutral-500">{String(e.step).padStart(2, ' ')}</span>{' '}
{e.axis.padEnd(22, ' ')} = {Number(e.value).toFixed(2)}
{' → '}
<span className="text-amber-300">{Math.round(e.score)}</span>
{' '}
<span className="text-emerald-400">(Δ {Math.round(e.delta)})</span>
</span>
)}
</div>
))}
</div>
{best && !running && (
<div className="text-[10px] text-neutral-500">
best score: <span className="text-emerald-400 font-mono">
{Math.round(log[log.length - 1]?.score ?? 0)}
</span>
</div>
)}
</div>
)
}
// Hardcoded test characters + scales. Click any to rasterize that letter
// at that scale into pass `passIdx`, replacing the current hulls. Lets the
// user jump straight into debugging a specific glyph without running the
// full image-load pipeline.
const TEST_CHARS = 'ACGIJLMNOSUVWXZBDEFHKPQRTYabcdefghijklmnopqrstuvwxyz0123456789'.split('')
const TEST_SCALES = [
{ label: '3mm/150dpi/3px', font_mm: 3.0, dpi: 150, thick: 3 },
{ label: '5mm/200dpi/4px', font_mm: 5.0, dpi: 200, thick: 4 },
{ label: '8mm/200dpi/4px', font_mm: 8.0, dpi: 200, thick: 4 },
{ label: '5mm/425dpi/9px', font_mm: 5.0, dpi: 425, thick: 9 },
{ label: '8mm/425dpi/9px', font_mm: 8.0, dpi: 425, thick: 9 },
]
function TestLetterPicker({ passIdx, onLoaded }) {
const [scaleIdx, setScaleIdx] = useState(3) // default 5mm/425dpi
const [loadedCh, setLoadedCh] = useState(null)
const [busy, setBusy] = useState(false)
const load = async (ch) => {
setBusy(true)
try {
const list = await tauri.loadTestLetter(
passIdx, ch,
TEST_SCALES[scaleIdx].font_mm,
TEST_SCALES[scaleIdx].dpi,
TEST_SCALES[scaleIdx].thick,
)
setLoadedCh(ch)
onLoaded(list)
} catch (err) {
console.error('loadTestLetter failed', err)
} finally {
setBusy(false)
}
}
return (
<div className="space-y-1.5 pb-2 border-b border-neutral-800">
<div className="flex items-center justify-between">
<span className="text-neutral-500">Test letter</span>
{loadedCh && (
<span className="text-[10px] text-neutral-400 font-mono">
loaded: <span className="text-emerald-400">{loadedCh}</span>
</span>
)}
</div>
<select value={scaleIdx}
onChange={e => setScaleIdx(parseInt(e.target.value, 10))}
className="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-[10px]">
{TEST_SCALES.map((s, i) => (
<option key={i} value={i}>{s.label}</option>
))}
</select>
<div className="grid grid-cols-10 gap-0.5">
{TEST_CHARS.map(ch => (
<button key={ch}
onClick={() => load(ch)}
disabled={busy}
className={`text-[11px] py-0.5 rounded font-mono
${loadedCh === ch
? 'bg-emerald-700/40 border border-emerald-500/60 text-emerald-100'
: 'bg-neutral-800 hover:bg-neutral-700 text-neutral-300 border border-neutral-700'}
disabled:opacity-50 disabled:cursor-wait`}>
{ch}
</button>
))}
</div> </div>
</div> </div>
) )

View File

@@ -26,6 +26,15 @@ export async function listHulls(passIdx = 0) {
return tracedInvoke('list_hulls', { passIdx }) return tracedInvoke('list_hulls', { passIdx })
} }
// Replace the hulls of a pass with a freshly-rasterized test letter. Used
// by the paint debug viewer's "Test letters" picker so any character/scale
// is one click away — no full pipeline run required.
export async function loadTestLetter(passIdx, ch, fontMm, dpi, thicknessPx) {
return tracedInvoke('load_test_letter', {
passIdx, ch, fontMm, dpi, thicknessPx,
})
}
// Default StreamlineParams must match Rust's `impl Default for StreamlineParams`. // Default StreamlineParams must match Rust's `impl Default for StreamlineParams`.
// Values from streamline_optimize coordinate descent over 62-glyph alphabet. // Values from streamline_optimize coordinate descent over 62-glyph alphabet.
export const DEFAULT_STREAMLINE_PARAMS = { export const DEFAULT_STREAMLINE_PARAMS = {
@@ -54,32 +63,40 @@ export async function getStreamlineDebug(passIdx, hullIdx, params = DEFAULT_STRE
// Default PaintParams must match Rust's `impl Default for PaintParams`. // Default PaintParams must match Rust's `impl Default for PaintParams`.
export const DEFAULT_PAINT_PARAMS = { export const DEFAULT_PAINT_PARAMS = {
brush_radius_factor: 1.0, brush_radius_factor: 1.15,
brush_radius_offset_px: 0.5, brush_radius_offset_px: 0.25,
brush_radius_percentile: 0.99, brush_radius_percentile: 0.85,
step_size_factor: 0.5, step_size_factor: 0.40,
n_directions: 24, n_directions: 48,
lookahead_steps: 4, lookahead_steps: 3,
momentum_weight: 0.4, momentum_weight: 0.20,
overpaint_penalty: 0.05, overpaint_penalty: 0.10,
walk_bg_penalty: 0.3, walk_bg_penalty: 4.0,
min_score_factor: 0.05, min_score_factor: 0.20,
polish_iters: 4, polish_iters: 1,
polish_search_factor: 0.5, polish_search_factor: 0.5,
outside_penalty: 2.0, bg_penalty: 2.0,
min_component_factor: 0.6, min_component_factor: 1.20,
pen_lift_penalty: 30.0, pen_lift_penalty: 0.0,
pen_lift_reach: 6.0, pen_lift_reach: 3.0,
max_steps_per_stroke: 4000, max_steps_per_stroke: 4000,
max_strokes: 12, max_strokes: 12,
output_rdp_eps: 0.5, output_rdp_eps: 1.0,
output_chaikin: 2,
} }
export async function getPaintDebug(passIdx, hullIdx, params = DEFAULT_PAINT_PARAMS) { export async function getPaintDebug(passIdx, hullIdx, params = DEFAULT_PAINT_PARAMS) {
return tracedInvoke('get_paint_debug', { passIdx, hullIdx, params }) return tracedInvoke('get_paint_debug', { passIdx, hullIdx, params })
} }
// Run coordinate-descent optimization on the current hull's paint params.
// While it runs the backend emits `optimizer-progress` events; subscribe
// via `import { listen } from '@tauri-apps/api/event'` then
// `listen('optimizer-progress', e => …)`. Resolves with the final best
// PaintParams.
export async function optimizePaintParams(passIdx, hullIdx, base = DEFAULT_PAINT_PARAMS) {
return tracedInvoke('optimize_paint_params', { passIdx, hullIdx, base })
}
export async function getAllStrokes() { export async function getAllStrokes() {
return tracedInvoke('get_all_strokes', {}) return tracedInvoke('get_all_strokes', {})
} }

View File

@@ -0,0 +1,94 @@
//! Distributed optimizer worker. Runs ONE refinement starting from the
//! N-th start in `brush_paint_opt::build_start_params`, prints a JSON
//! `RefineResult` to stdout. Lets the main optimizer be sharded across
//! SSH-reachable machines: each machine runs `paint_opt_worker N` for
//! its assigned indices in parallel, the orchestrator collects all
//! the JSON outputs and picks the best.
//!
//! Usage:
//! paint_opt_worker <start_idx> [--passes N]
//!
//! Output (stdout):
//! { "start_idx": <int>, "score": <f32>, "params": {…}, "log": [...] }
//!
//! Stderr is for human-readable progress; never parse it.
use std::env;
use std::process::ExitCode;
use trac3r_lib::brush_paint::PaintParams;
use trac3r_lib::brush_paint_opt::run_one_start;
fn parse_args() -> Result<(usize, u32), String> {
let argv: Vec<String> = env::args().collect();
if argv.len() < 2 {
return Err(format!(
"usage: {} <start_idx> [--passes N]\n\
start_idx is the optimizer's start index (0..K-1).\n\
passes defaults to 4.",
argv.first().cloned().unwrap_or_else(|| "paint_opt_worker".to_string())
));
}
let start_idx: usize = argv[1].parse()
.map_err(|e| format!("start_idx must be a non-negative integer: {e}"))?;
let mut passes: u32 = 4;
let mut i = 2;
while i < argv.len() {
match argv[i].as_str() {
"--passes" => {
i += 1;
passes = argv.get(i)
.ok_or("--passes requires a value")?
.parse()
.map_err(|e| format!("--passes value invalid: {e}"))?;
}
other => return Err(format!("unknown arg: {other}")),
}
i += 1;
}
Ok((start_idx, passes))
}
fn main() -> ExitCode {
let (start_idx, passes) = match parse_args() {
Ok(t) => t,
Err(e) => {
eprintln!("{e}");
return ExitCode::from(2);
}
};
let host = hostname();
let cores = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(0);
eprintln!("[worker {host}/{cores}t] start_idx={start_idx} passes={passes}");
let t0 = std::time::Instant::now();
let result = run_one_start(start_idx, &PaintParams::default(), passes);
let elapsed = t0.elapsed();
eprintln!(
"[worker {host}] done idx={} score={:.0} elapsed={:.1}s",
result.start_idx, result.score, elapsed.as_secs_f64()
);
match serde_json::to_string(&result) {
Ok(json) => {
println!("{json}");
ExitCode::SUCCESS
}
Err(e) => {
eprintln!("[worker {host}] JSON serialise failed: {e}");
ExitCode::from(3)
}
}
}
fn hostname() -> String {
std::env::var("HOSTNAME")
.or_else(|_| std::env::var("HOST"))
.unwrap_or_else(|_| {
std::process::Command::new("hostname")
.output().ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.unwrap_or_else(|| "?".to_string())
})
}

File diff suppressed because it is too large Load Diff

238
src/brush_paint_opt.rs Normal file
View File

@@ -0,0 +1,238 @@
//! Multi-start, continuous-space, gradient-free optimizer for the
//! brush-paint algorithm's `PaintParams`. Each starting point is
//! independently refined via best-improvement coordinate descent with
//! golden-section line search per axis.
//!
//! Two consumers:
//! - `paint_optimize_global_defaults` test (in `brush_paint::tests`):
//! runs all starts in the test process via rayon.
//! - `paint_opt_worker` binary: runs ONE start, prints JSON. Lets us
//! distribute starts across SSH workers.
//!
//! Both share `default_axes` / `build_corpus` / `build_start_params` /
//! `refine_one` so they search identical landscapes.
use rayon::prelude::*;
use serde::{Serialize, Deserialize};
use crate::brush_paint::{
PaintParams, score_for_letter, metrics_for, rasterize_test_letter,
};
use crate::hulls::Hull;
/// One tunable parameter axis. Continuous range; ints rounded after line
/// search.
pub struct Axis {
pub name: &'static str,
pub lo: f32,
pub hi: f32,
pub is_int: bool,
pub set: fn(&mut PaintParams, f32),
pub get: fn(&PaintParams) -> f32,
}
pub fn default_axes() -> Vec<Axis> {
vec![
Axis { name: "brush_radius_factor", lo: 0.40, hi: 1.50, is_int: false,
set: |p, v| p.brush_radius_factor = v, get: |p| p.brush_radius_factor },
Axis { name: "brush_radius_percentile", lo: 0.70, hi: 1.00, is_int: false,
set: |p, v| p.brush_radius_percentile = v, get: |p| p.brush_radius_percentile },
Axis { name: "brush_radius_offset_px", lo: 0.0, hi: 1.0, is_int: false,
set: |p, v| p.brush_radius_offset_px = v, get: |p| p.brush_radius_offset_px },
Axis { name: "polish_iters", lo: 0.0, hi: 12.0, is_int: true,
set: |p, v| p.polish_iters = v as u32, get: |p| p.polish_iters as f32 },
Axis { name: "polish_search_factor", lo: 0.10, hi: 4.0, is_int: false,
set: |p, v| p.polish_search_factor = v, get: |p| p.polish_search_factor },
Axis { name: "bg_penalty", lo: 0.0, hi: 20.0, is_int: false,
set: |p, v| p.bg_penalty = v, get: |p| p.bg_penalty },
Axis { name: "walk_bg_penalty", lo: 0.0, hi: 20.0, is_int: false,
set: |p, v| p.walk_bg_penalty = v, get: |p| p.walk_bg_penalty },
Axis { name: "overpaint_penalty", lo: 0.0, hi: 0.5, is_int: false,
set: |p, v| p.overpaint_penalty = v, get: |p| p.overpaint_penalty },
Axis { name: "step_size_factor", lo: 0.20, hi: 0.90, is_int: false,
set: |p, v| p.step_size_factor = v, get: |p| p.step_size_factor },
Axis { name: "lookahead_steps", lo: 1.0, hi: 12.0, is_int: true,
set: |p, v| p.lookahead_steps = v as usize, get: |p| p.lookahead_steps as f32 },
Axis { name: "n_directions", lo: 8.0, hi: 64.0, is_int: true,
set: |p, v| p.n_directions = v as usize, get: |p| p.n_directions as f32 },
Axis { name: "momentum_weight", lo: 0.0, hi: 2.0, is_int: false,
set: |p, v| p.momentum_weight = v, get: |p| p.momentum_weight },
Axis { name: "min_score_factor", lo: 0.0, hi: 0.30, is_int: false,
set: |p, v| p.min_score_factor = v, get: |p| p.min_score_factor },
Axis { name: "min_component_factor", lo: 0.10, hi: 1.50, is_int: false,
set: |p, v| p.min_component_factor = v, get: |p| p.min_component_factor },
Axis { name: "pen_lift_penalty", lo: 0.0, hi: 200.0, is_int: false,
set: |p, v| p.pen_lift_penalty = v, get: |p| p.pen_lift_penalty },
Axis { name: "pen_lift_reach", lo: 0.5, hi: 16.0, is_int: false,
set: |p, v| p.pen_lift_reach = v, get: |p| p.pen_lift_reach },
Axis { name: "output_rdp_eps", lo: 0.0, hi: 2.0, is_int: false,
set: |p, v| p.output_rdp_eps = v, get: |p| p.output_rdp_eps },
]
}
/// Test corpus: every letter in `CORPUS_ALPHABET` rasterised at every
/// scale in `CORPUS_CASES`, taking the largest hull from each. Both ends
/// of the workload (test + worker) build the same corpus → identical
/// score landscape → safe to distribute.
pub const CORPUS_CASES: &[(f32, u32, u32)] = &[
(5.0, 200, 4),
(5.0, 425, 9),
];
pub const CORPUS_ALPHABET: &str = "ACGIJLMNOSUVWXZabcdefijlmosuvwxz";
pub fn build_corpus() -> Vec<(char, Hull)> {
CORPUS_CASES.iter().flat_map(|&(mm, dpi, t)| {
CORPUS_ALPHABET.chars().filter_map(move |ch| {
let hulls = rasterize_test_letter(ch, mm, dpi, t);
hulls.into_iter().max_by_key(|h| h.area).map(|h| (ch, h))
})
}).collect()
}
/// Score one config against the whole corpus, parallel over hulls.
pub fn evaluate(corpus: &[(char, Hull)], p: &PaintParams) -> f32 {
corpus.par_iter().map(|(ch, hull)| {
let (_, m) = metrics_for(hull, p);
score_for_letter(*ch, &m)
}).sum()
}
/// Cheap deterministic PRNG so each start index produces the same point
/// on every machine.
fn rng_next(state: &mut u64) -> f32 {
*state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
((state.wrapping_shr(33)) as u32 as f32) / (u32::MAX as f32)
}
/// Build the starting params for a given index. Indices 0-3 are
/// hand-picked seeds (base, small-brush, fit-brush, big-brush). 4+ are
/// random samples in the joint param space, deterministic per-index.
pub fn build_start_params(idx: usize, base: &PaintParams, axes: &[Axis]) -> PaintParams {
match idx {
0 => base.clone(),
1 => {
let mut s = base.clone();
s.brush_radius_factor = 0.55;
s.brush_radius_offset_px = 0.25;
s.brush_radius_percentile = 0.85;
s.min_component_factor = 1.20;
s.polish_iters = 4;
s
}
2 => {
let mut s = base.clone();
s.brush_radius_factor = 1.00;
s.brush_radius_offset_px = 0.5;
s.brush_radius_percentile = 0.99;
s.min_component_factor = 0.20;
s.polish_iters = 2;
s
}
3 => {
let mut s = base.clone();
s.brush_radius_factor = 1.15;
s.brush_radius_offset_px = 0.5;
s.brush_radius_percentile = 0.99;
s.min_component_factor = 0.20;
s.polish_iters = 1;
s
}
_ => {
let mut state = (idx as u64).wrapping_mul(0x9E3779B97F4A7C15).wrapping_add(0xDEADBEEF);
let mut p = base.clone();
for axis in axes {
let r = rng_next(&mut state);
let v = axis.lo + r * (axis.hi - axis.lo);
let v = if axis.is_int { v.round() } else { v };
(axis.set)(&mut p, v);
}
p
}
}
}
/// Output of one refinement run.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RefineResult {
pub start_idx: usize,
pub score: f32,
pub params: PaintParams,
pub log: Vec<String>,
}
/// Best-improvement coordinate descent with golden-section line search
/// on each axis. Stops when no axis can drop the score by more than 1.
pub fn refine_one(
corpus: &[(char, Hull)],
axes: &[Axis],
start: &PaintParams,
max_passes: u32,
) -> (PaintParams, f32, Vec<String>) {
let try_axis = |params: &PaintParams, axis: &Axis, v: f32| -> f32 {
let mut p = params.clone();
let v = if axis.is_int { v.round().clamp(axis.lo, axis.hi) }
else { v.clamp(axis.lo, axis.hi) };
(axis.set)(&mut p, v);
evaluate(corpus, &p)
};
let golden_section = |params: &PaintParams, axis: &Axis, iters: u32| -> (f32, f32) {
const PHI: f32 = 0.6180339887;
let (mut a, mut b) = (axis.lo, axis.hi);
let mut x1 = b - PHI * (b - a);
let mut x2 = a + PHI * (b - a);
let mut f1 = try_axis(params, axis, x1);
let mut f2 = try_axis(params, axis, x2);
for _ in 0..iters {
if f1 < f2 {
b = x2; x2 = x1; f2 = f1;
x1 = b - PHI * (b - a);
f1 = try_axis(params, axis, x1);
} else {
a = x1; x1 = x2; f1 = f2;
x2 = a + PHI * (b - a);
f2 = try_axis(params, axis, x2);
}
if axis.is_int && (b - a) < 1.0 { break; }
}
if f1 < f2 {
let v = if axis.is_int { x1.round() } else { x1 };
(v, f1)
} else {
let v = if axis.is_int { x2.round() } else { x2 };
(v, f2)
}
};
let mut current = start.clone();
let mut current_score = evaluate(corpus, &current);
let mut log: Vec<String> = vec![format!("start → {:.0}", current_score)];
for _ in 0..max_passes {
let per_axis: Vec<(usize, f32, f32)> = axes.par_iter().enumerate().map(|(ai, axis)| {
let (v, s) = golden_section(&current, axis, 12);
(ai, v, s)
}).collect();
let (best_ai, best_v, best_s) = per_axis.iter()
.min_by(|a, b| a.2.partial_cmp(&b.2).unwrap()).cloned().unwrap();
if best_s + 1.0 >= current_score { break; }
let axis = &axes[best_ai];
log.push(format!(
" {:25} {:>6.2}{:>6.2}{:.0}{:.0})",
axis.name, (axis.get)(&current), best_v, best_s, current_score - best_s
));
(axis.set)(&mut current, best_v);
current_score = best_s;
}
(current, current_score, log)
}
/// One-call entry point used by the worker binary: build corpus, build
/// the indexed start, refine, return.
pub fn run_one_start(start_idx: usize, base: &PaintParams, max_passes: u32) -> RefineResult {
let axes = default_axes();
let corpus = build_corpus();
let start = build_start_params(start_idx, base, &axes);
let (params, score, log) = refine_one(&corpus, &axes, &start, max_passes);
RefineResult { start_idx, score, params, log }
}

View File

@@ -6,6 +6,7 @@ pub mod text;
pub mod streamline; pub mod streamline;
pub mod topo_strokes; pub mod topo_strokes;
pub mod brush_paint; pub mod brush_paint;
pub mod brush_paint_opt;
use std::time::Instant; use std::time::Instant;
@@ -1000,6 +1001,34 @@ fn list_hulls(pass_idx: usize, state: State<Mutex<AppState>>) -> Result<Vec<Hull
}).collect()) }).collect())
} }
/// Replace the hulls in `pass_idx` with a freshly-rasterized test letter.
/// Lets the debug viewer load any character at any scale on demand without
/// having to push an image through the full pipeline. Returns the updated
/// hull list.
#[tauri::command]
fn load_test_letter(
pass_idx: usize,
ch: String,
font_mm: f32,
dpi: u32,
thickness_px: u32,
state: State<Mutex<AppState>>,
) -> Result<Vec<HullSummary>, String> {
let c = ch.chars().next().ok_or("empty character")?;
let hulls = brush_paint::rasterize_test_letter(c, font_mm, dpi, thickness_px);
let mut st = state.lock().unwrap();
if pass_idx >= st.passes.len() {
st.passes.resize_with(pass_idx + 1, PassState::default);
}
let ps = &mut st.passes[pass_idx];
ps.hulls = hulls;
Ok(ps.hulls.iter().enumerate().map(|(i, h)| HullSummary {
index: i,
area: h.area,
bounds: [h.bounds.x_min, h.bounds.y_min, h.bounds.x_max, h.bounds.y_max],
}).collect())
}
#[tauri::command] #[tauri::command]
fn get_streamline_debug( fn get_streamline_debug(
pass_idx: usize, hull_idx: usize, params: streamline::StreamlineParams, pass_idx: usize, hull_idx: usize, params: streamline::StreamlineParams,
@@ -1026,6 +1055,134 @@ fn get_paint_debug(
Ok(brush_paint::paint_fill_debug(h, &params)) Ok(brush_paint::paint_fill_debug(h, &params))
} }
#[derive(Clone, serde::Serialize)]
struct OptimizerProgress {
step: u32,
axis: String,
value: f32,
score: f32,
delta: f32,
params: brush_paint::PaintParams,
}
/// Run best-improvement coordinate descent on the brush-paint params,
/// optimizing against the single hull at `(pass_idx, hull_idx)` using
/// `default_score`. Emits an `optimizer-progress` event after every axis
/// improvement; the final best params come back as the return value.
///
/// This is the in-app version of the `paint_optimize_global_defaults`
/// test — single-hull, no constraint corpus, so it's fast (< 5s typical)
/// and lets the user dial in params for whatever letter they're staring at.
///
/// Synchronous: Tauri runs sync commands on a thread pool, so the UI
/// stays responsive while this runs.
#[tauri::command]
fn optimize_paint_params(
pass_idx: usize, hull_idx: usize, base: brush_paint::PaintParams,
app: AppHandle,
state: State<Mutex<AppState>>,
) -> Result<brush_paint::PaintParams, String> {
// Clone the hull so we don't hold the mutex during the descent.
let hull = {
let st = state.lock().unwrap();
let ps = st.passes.get(pass_idx)
.ok_or_else(|| format!("pass {pass_idx} out of range"))?;
ps.hulls.get(hull_idx)
.ok_or_else(|| format!("hull {hull_idx} out of range"))?
.clone()
};
let result = (|| -> brush_paint::PaintParams {
type Setter = fn(&mut brush_paint::PaintParams, f32);
let axes: Vec<(&str, Vec<f32>, Setter)> = vec![
("brush_radius_factor", vec![0.55, 0.65, 0.75, 0.85, 0.95, 1.05, 1.15],
|p, v| p.brush_radius_factor = v),
("brush_radius_percentile", vec![0.85, 0.90, 0.95, 0.99, 1.00],
|p, v| p.brush_radius_percentile = v),
("brush_radius_offset_px", vec![0.0, 0.25, 0.5],
|p, v| p.brush_radius_offset_px = v),
("polish_iters", vec![0.0, 1.0, 2.0, 4.0, 6.0, 10.0],
|p, v| p.polish_iters = v as u32),
("polish_search_factor", vec![0.2, 0.4, 0.7, 1.0, 1.5, 2.5],
|p, v| p.polish_search_factor = v),
("bg_penalty", vec![0.0, 1.0, 2.0, 4.0, 8.0, 15.0],
|p, v| p.bg_penalty = v),
("walk_bg_penalty", vec![0.0, 1.0, 2.0, 4.0, 8.0, 15.0],
|p, v| p.walk_bg_penalty = v),
("overpaint_penalty", vec![0.0, 0.02, 0.05, 0.1, 0.2, 0.4],
|p, v| p.overpaint_penalty = v),
("step_size_factor", vec![0.25, 0.4, 0.5, 0.65, 0.8],
|p, v| p.step_size_factor = v),
("lookahead_steps", vec![1.0, 2.0, 3.0, 4.0, 6.0, 8.0],
|p, v| p.lookahead_steps = v as usize),
("n_directions", vec![8.0, 16.0, 24.0, 32.0, 48.0],
|p, v| p.n_directions = v as usize),
("momentum_weight", vec![0.0, 0.2, 0.4, 0.6, 0.9, 1.5],
|p, v| p.momentum_weight = v),
("min_score_factor", vec![0.0, 0.02, 0.05, 0.1, 0.2],
|p, v| p.min_score_factor = v),
("min_component_factor", vec![0.2, 0.4, 0.6, 0.8, 1.2],
|p, v| p.min_component_factor = v),
("pen_lift_penalty", vec![0.0, 10.0, 30.0, 60.0, 120.0],
|p, v| p.pen_lift_penalty = v),
("pen_lift_reach", vec![1.0, 3.0, 6.0, 10.0, 16.0],
|p, v| p.pen_lift_reach = v),
("output_rdp_eps", vec![0.0, 0.25, 0.5, 1.0],
|p, v| p.output_rdp_eps = v),
];
let eval = |p: &brush_paint::PaintParams| -> f32 {
let (_, m) = brush_paint::metrics_for(&hull, p);
brush_paint::default_score(&m)
};
let mut current = base.clone();
let mut current_score = eval(&current);
let _ = app.emit("optimizer-progress", OptimizerProgress {
step: 0, axis: "<initial>".into(), value: 0.0,
score: current_score, delta: 0.0,
params: current.clone(),
});
let max_steps = axes.len() * 6;
for step_no in 1..=max_steps {
// Best-improvement: try every axis's best candidate, take the
// single move with the biggest score drop.
let mut best_axis_idx: usize = usize::MAX;
let mut best_axis_v: f32 = f32::NAN;
let mut best_axis_score: f32 = current_score;
for (ai, (_name, candidates, setter)) in axes.iter().enumerate() {
for &v in candidates {
let mut p = current.clone();
setter(&mut p, v);
let s = eval(&p);
if s + 1e-3 < best_axis_score {
best_axis_score = s;
best_axis_v = v;
best_axis_idx = ai;
}
}
}
if best_axis_idx == usize::MAX { break; } // converged
let (name, _, setter) = &axes[best_axis_idx];
let prev = current_score;
setter(&mut current, best_axis_v);
current_score = best_axis_score;
let _ = app.emit("optimizer-progress", OptimizerProgress {
step: step_no as u32,
axis: name.to_string(),
value: best_axis_v,
score: current_score,
delta: prev - current_score,
params: current.clone(),
});
}
current
})();
Ok(result)
}
#[tauri::command] #[tauri::command]
fn set_pass_count(count: usize, state: State<Mutex<AppState>>) { fn set_pass_count(count: usize, state: State<Mutex<AppState>>) {
let mut st = state.lock().unwrap(); let mut st = state.lock().unwrap();
@@ -2845,8 +3002,10 @@ pub fn run() {
get_images_dir, get_images_dir,
set_pass_count, set_pass_count,
list_hulls, list_hulls,
load_test_letter,
get_streamline_debug, get_streamline_debug,
get_paint_debug, get_paint_debug,
optimize_paint_params,
process_pass, process_pass,
get_all_strokes, get_all_strokes,
get_gcode_viz, get_gcode_viz,