-
-
{
- const sorted = [...list].sort((a, b) => b.area - a.area)
- setHulls(sorted)
- if (sorted.length > 0) setHullIdx(sorted[0].index)
- setReloadKey(k => k + 1)
- }} />
-
-
- Hull (largest first)
- setHullIdx(parseInt(e.target.value, 10))}
- className="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-xs">
- {hulls.map((h, i) => (
-
- #{h.index} · {h.area}px · {h.bounds[2] - h.bounds[0]}×{h.bounds[3] - h.bounds[1]}
- {i === 0 ? ' (largest)' : ''}
-
- ))}
-
-
-
-
-
Layers
-
- {LAYERS.map(l => (
-
- toggleLayer(l.key)} />
- {l.label}
-
- ))}
-
-
-
-
-
Step viz
-
- {STEP_LAYERS.map(l => (
-
- toggleStepLayer(l.key)} />
- {l.label}
-
- ))}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Brush
-
- dumpDebug(null)}
- 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"
- title="Copy full hull state (mask + params + output) to clipboard">
- Dump
-
- setParams({ ...DEFAULT_PAINT_PARAMS })}
- className="text-[10px] px-2 py-0.5 rounded bg-neutral-800 hover:bg-neutral-700 text-neutral-400">
- Reset
-
-
-
-
setParam('brush_radius_factor', v)}
- hint="× sdf_max. 1.0 ≈ matches stroke width given the offset below." />
- setParam('brush_radius_offset_px', v)}
- hint="Added to the radius after the multiplier. Compensates chamfer underestimate." />
- setParam('step_size_factor', v)}
- hint="× brush radius. 0.5 = 50% disk overlap each step." />
-
-
-
-
Direction scoring
-
setParam('n_directions', v)}
- hint="Number of candidate directions sampled per step." />
- setParam('lookahead_steps', v)}
- hint="How many steps ahead to evaluate when scoring a direction." />
- setParam('momentum_weight', v)}
- hint="Bonus for directions aligned with previous velocity." />
- setParam('overpaint_penalty', v)}
- hint="Per-pixel cost for painting over already-painted pixels." />
- 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)." />
- setParam('min_score_factor', v)}
- hint="Stroke ends when best direction's score < this × brush area." />
-
-
-
-
Path relaxation
-
setParam('polish_iters', v)}
- hint="Relax↔shorten tick-tock rounds. 0 = no relaxation." />
- setParam('polish_search_factor', v)}
- hint="How far (in brush radii) to search for unpainted ink near each waypoint." />
- setParam('bg_penalty', v)}
- hint="Per-bg-pixel cost in the polish/relax pass. Higher = stricter centerline pull." />
- 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." />
- setParam('pen_lift_penalty', v)}
- hint="Path-cost budget (SDF-weighted pixel steps) the walker absorbs to double back through painted ink to reach unpainted ink instead of lifting the pen. 0 = always lift; higher = more doubling-back. Trades against overpaint penalty." />
- setParam('pen_lift_reach', v)}
- hint="Max search radius (in brush radii) for the SDF-guided Dijkstra that finds the next unpainted ink pixel through painted territory. Bigger = walker doubles back further before lifting." />
-
-
-
-
Caps
-
setParam('max_steps_per_stroke', v)} hint="Safety cap." />
- setParam('max_strokes', v)} hint="Safety cap on strokes per hull." />
- setParam('output_rdp_eps', v)} hint="Final stroke RDP simplification epsilon (px). 0 disables." />
-
-
-
-
-
-
Wheel: zoom · Drag: pan · Shift+drag: copy region
-
SVG keys: ←/→ step · Space play · Home/End
-
setView({ zoom: 1, panX: 0, panY: 0 })}
- className="mt-1 text-xs px-2 py-0.5 bg-neutral-800 rounded">Fit
-
-
· brush r: {debug.brush_radius?.toFixed(2) ?? '—'} px (sdf max: {debug.sdf_max?.toFixed(2) ?? '—'})
-
· {debug.start_points.length} start points
-
· {debug.trajectories.length} raw trajectories
-
· {debug.strokes.length} smoothed strokes
-
· {walks.length} walks recorded
-
- {hover && (
-
- ({hover.x.toFixed(2)}, {hover.y.toFixed(2)})
-
- )}
-
-
-
-
-
- {/* Raster layers (source / sdf / coverage / snapshot) all
- composite onto a single absolutely-positioned canvas
- below the SVG. Canvas2D's `imageSmoothingEnabled = false`
- is honored everywhere, so they stay sharp at any zoom. */}
-
-
-
setCandHover(null)}
- onKeyDown={onSvgKeyDown}
- style={{
- cursor: 'grab', outline: 'none',
- // SVG stays in flex flow so the container has a height;
- // HTML overlays are absolutely positioned over the
- // SVG's rendered rect via `overlayStyle`.
- position: 'relative', zIndex: 1,
- }}>
-
- {/* Source / SDF / coverage / pre-snapshot rasters render as
- HTML overlays outside the SVG (above) — see
- `overlayStyle`. WebKit's SVG rasterizer can't be
- forced to nearest-neighbor, but HTML with CSS
- `image-rendering: pixelated` is honored. */}
-
- {/* Vector skeleton — polylines per segment between special
- nodes. Stays sharp at any zoom. Junction dots in green. */}
- {enabled.skeleton && (debug.skeleton_segments ?? []).map((seg, i) => (
- `${p[0]},${p[1]}`).join(' ')}
- fill="none" stroke="#a3a3a3" strokeWidth={0.6}
- strokeLinecap="round" strokeLinejoin="round"
- vectorEffect="non-scaling-stroke" />
- ))}
- {enabled.skeleton && (debug.skeleton_junctions ?? []).map((p, i) => (
-
- ))}
-
- {/* Voronoi medial-axis edges — magenta segments. Each edge
- connects two adjacent triangle circumcenters of the
- boundary-sample Delaunay; only edges entirely inside the
- shape are kept. Junctions are real Voronoi vertices, so
- W/M apex behavior should differ visibly from ZS. */}
- {enabled.voronoi && (debug.voronoi_segments ?? []).map((seg, i) => (
-
- ))}
-
- {/* AFMM medial-axis points — cyan dots. Each pixel labelled
- with the arc-length of its closest contour pixel; pixels
- whose 8-nbrs disagree by > perim/5 are medial. Look for
- clean junction handling that ZS clusters mishandle. */}
- {enabled.afmm && (debug.afmm_points ?? []).map((p, i) => (
-
- ))}
-
- {/* Per-endpoint init_dir arrows. Each arrow originates at the
- endpoint and points along the skeleton tangent into the
- letter — i.e., the direction the walker would head if it
- seeded here. Arrow length scaled to ~1 brush radius. */}
- {enabled.endpoints && (debug.endpoint_arrows ?? []).map((arr, i) => {
- const [x, y, dx, dy] = arr
- const L = Math.max(2, debug.brush_radius * 1.5)
- const tx = x + dx * L
- const ty = y + dy * L
- return (
-
-
-
-
-
- )
- })}
-
- {/* Pre-stroke component decomposition. For the currently-
- selected walk's stroke, draws the bbox of every
- connected unpainted component, color-coded:
- green outline = substantial (got seeded or could)
- grey outline = sub-threshold (sits in mask, no stroke)
- yellow fill = the one this stroke chose. */}
- {enabled.components && (() => {
- const seedings = debug.stroke_seedings ?? []
- // Find the seeding for the currently-selected walk's stroke_idx.
- const sIdx = walks[walkIdx]?.stroke_idx ?? 0
- const seeding = seedings.find(s => s.stroke_idx === sIdx) ?? seedings[0]
- if (!seeding) return null
- return (seeding.components ?? []).map((c, i) => {
- const [xmin, ymin, xmax, ymax] = c.bbox
- const fill = c.chosen ? '#facc1530' : 'none'
- const stroke = c.substantial ? '#22c55e' : '#6b7280'
- return (
-
- )
- })
- })()}
-
- {/* preSnapshot + coverage are HTML overlays outside
- the SVG (see top of container). */}
-
- {/* Brush sweep: each trajectory rendered as a fat translucent
- line of width = 2 × brush_radius. Shows what the brush
- actually painted along the path. */}
- {enabled.brushSweep && debug.trajectories.map((t, i) => (
- `${p[0]},${p[1]}`).join(' ')}
- fill="none"
- stroke={strokeHue(i)}
- strokeOpacity={0.18}
- strokeWidth={2 * debug.brush_radius}
- strokeLinecap="round" strokeLinejoin="round" />
- ))}
-
- {enabled.trajectory && debug.trajectories.map((t, i) => (
- `${p[0]},${p[1]}`).join(' ')}
- fill="none" stroke={strokeHue(i)} strokeWidth={1.0}
- strokeOpacity={0.85}
- vectorEffect="non-scaling-stroke" />
- ))}
-
- {enabled.strokes && debug.strokes.map((s, i) => (
- `${p[0]},${p[1]}`).join(' ')}
- fill="none" stroke={strokeHue(i)} strokeWidth={2}
- strokeLinecap="round" strokeLinejoin="round"
- vectorEffect="non-scaling-stroke" />
- ))}
-
- {enabled.starts && debug.start_points.map((p, i) => (
-
-
-
- {i + 1}
-
-
- ))}
-
- {/* ── Step viz ──────────────────────────────────────────────────── */}
-
- {/* Painted-so-far disks. Stamped at each waypoint up to current step. */}
- {stepEnabled.paintedSoFar && walk && paintedWaypoints.map((p, i) => (
-
- ))}
-
- {/* Path-up-to-step polyline */}
- {stepEnabled.pathSoFar && walk && paintedWaypoints.length > 1 && (
- `${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 (
- `${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 && (
-
- )}
-
- {/* 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 (
-
-
-
-
- )
- })()}
-
- {/* 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 (
- setCandHover({ idx: i, c })}
- onMouseLeave={() => setCandHover(h => h?.idx === i ? null : h)}
- style={{ cursor: 'crosshair' }}>
-
-
-
- )
- }
- const rank01 = candidateRanks?.get(i) ?? 0.5
- const fill = rankHue(rank01)
- return (
- setCandHover({ idx: i, c })}
- onMouseLeave={() => setCandHover(h => h?.idx === i ? null : h)}
- style={{ cursor: 'crosshair' }}>
-
- {isChosen && (
-
- )}
-
- )
- })}
-
- {selBox && (
-
- )}
-
-
-
- Shift+drag to copy region data to clipboard · Click SVG, then ←/→/Space/Home/End
-
-
- {toast && (
-
- {toast}
-
- )}
-
- {/* 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 (
-
-
cand #{candHover.idx} · θ={fmt(c.theta)}
-
new_ink: {fmt(c.new_ink)}
-
repaint: {fmt(c.repaint)}
-
bg: {fmt(c.bg)}
-
momentum_bonus: {fmt(c.momentum_bonus)}
-
score: {fmt(c.score)}
-
- rejected_back: {String(c.rejected_back)}
-
-
- rejected_off_ink: {String(c.rejected_off_ink)}
-
-
- )
- })()}
-
-
- )
-}
-
-// ── Sub-components ──────────────────────────────────────────────────────────────
-
-function ScrubberPanel({
- walks, walkIdx, setWalkIdx, stepIdx, setStepIdx, stepCount,
- playing, setPlaying, playSpeedMs, setPlaySpeedMs,
-}) {
- const walk = walks[walkIdx]
- return (
-
-
Walker scrubber
-
- Walk
- 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 && (no walks) }
- {walks.map((w, i) => (
-
- stroke {w.stroke_idx} {w.kind} · {w.steps.length}st · {w.exit_reason}
-
- ))}
-
-
-
-
-
- Step
-
- {stepCount === 0 ? '—' : `${stepIdx + 1} / ${stepCount}`}
-
-
-
setStepIdx(parseInt(e.target.value, 10))}
- className="w-full" />
-
-
-
- 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">⏮
- 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 (←)">◀
- 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 ? '❚❚' : '▶'}
- 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 (→)">▶
- 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">⏭
- 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 => (
- {s.label}
- ))}
-
-
-
- {walk && (
-
- 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)}
-
- init_dir: {walk.init_dir
- ? `(${walk.init_dir[0].toFixed(2)}, ${walk.init_dir[1].toFixed(2)})`
- : 'none'}
-
- )}
-
- )
-}
-
-function HullMetricsPanel({ m, debug }) {
- if (!m || !debug) return null
- const Badge = ({ ok, children }) => (
-
-
Hull metrics
-
|
-
|
-
|
-
|
-
|
-
|
-
|
-
- coverage
-
- {m.coverage.toFixed(1)}%
- {m.coverageOk ? 'ok' : '<95%'}
-
-
-
- off-glyph
-
- {m.offGlyph.toFixed(1)}%
- {m.offGlyphOk ? 'ok' : '>5%'}
-
-
-
- len/skel
-
- {m.lenRatio.toFixed(2)}×
- {m.lenRatioOk ? 'ok' : '>2.0'}
-
-
-
- )
-}
-
-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 (
-
-
Score breakdown
-
-
- term
- raw
- w
- contrib
-
- {sb.rows.map(r => {
- const contrib = r.raw * r.weight
- return (
-
- {r.name}
- {fmt(r.raw)}
- ×{r.weight}
-
- {sign(contrib)}{fmt(Math.abs(contrib))}
-
-
- )
- })}
-
- total
- {fmt(sb.total)}
-
-
-
- budget = 1.5 × skel = {sb.budget.toFixed(1)} px ·
- excess = {sb.lengthExcess.toFixed(1)} ·
- Σ|Δθ| = {sb.totalCurv.toFixed(2)} rad
-
-
- )
-}
-
-function SelectedStepPanel({ walk, step, stepIdx }) {
- if (!walk) return null
- const Row = ({ k, v }) => (
-
-
Selected step
-
|
-
|
-
|
-
|
-
|
- {chosen ? (
-
-
chosen #{step.chosen}
-
θ {fmt(chosen.theta)}
-
new_ink {fmt(chosen.new_ink)}
-
repaint {fmt(chosen.repaint)}
-
bg {fmt(chosen.bg)}
-
momentum_bonus {fmt(chosen.momentum_bonus)}
-
score {fmt(chosen.score)}
-
- ) : (
-
- exited: {walk.exit_reason}
-
- )}
-
- )
-}
-
-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: '', 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 (
-
-
- Optimizer
- {running && running… }
-
-
-
- {running ? 'Running…' : 'Run'}
-
-
- Apply best
-
-
-
- {log.length === 0
- ?
(idle)
- : log.map((e, i) => (
-
- {e.axis === '' && (
- starting…
- )}
- {e.axis === 'ERROR' && (
- error: {e.err}
- )}
- {e.axis !== '' && e.axis !== 'ERROR' && (
-
- {String(e.step).padStart(2, ' ')} {' '}
- {e.axis.padEnd(22, ' ')} = {Number(e.value).toFixed(2)}
- {' → '}
- {Math.round(e.score)}
- {' '}
- (Δ {Math.round(e.delta)})
-
- )}
-
- ))}
-
- {best && !running && (
-
- best score:
- {Math.round(log[log.length - 1]?.score ?? 0)}
-
-
- )}
-
- )
-}
-
-// 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 (
-
-
- Test letter
- {loadedCh && (
-
- loaded: {loadedCh}
-
- )}
-
-
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) => (
- {s.label}
- ))}
-
-
- {TEST_CHARS.map(ch => (
- 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}
-
- ))}
-
-
- )
-}
-
-function ParamSlider({ label, value, min, max, step, onChange, hint }) {
- if (typeof value !== 'number' || !Number.isFinite(value)) {
- return (
-
- {label} —
-
- )
- }
- const display = Number.isInteger(step) ? value.toString() : value.toFixed(2)
- return (
-
-
- {label}
- {display}
-
-
onChange(parseFloat(e.target.value))}
- className="w-full" />
-
- )
-}
diff --git a/src-frontend/src/hooks/useTauri.js b/src-frontend/src/hooks/useTauri.js
index 6c317987..09cb39f5 100644
--- a/src-frontend/src/hooks/useTauri.js
+++ b/src-frontend/src/hooks/useTauri.js
@@ -26,47 +26,6 @@ export async function listHulls(passIdx = 0) {
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 PaintParams must match Rust's `impl Default for PaintParams`.
-export const DEFAULT_PAINT_PARAMS = {
- brush_radius_factor: 0.88,
- brush_radius_offset_px: 0.53,
- brush_radius_percentile: 0.93,
- step_size_factor: 0.40,
- n_directions: 48,
- lookahead_steps: 3,
- momentum_weight: 0.20,
- overpaint_penalty: 0.10,
- walk_bg_penalty: 0.69,
- min_score_factor: 0.20,
- back_dir_cutoff: -0.7,
- min_component_factor: 1.49,
- max_steps_per_stroke: 4000,
- max_strokes: 12,
- output_rdp_eps: 1.98,
-}
-
-export async function getPaintDebug(passIdx, hullIdx, params = DEFAULT_PAINT_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() {
return tracedInvoke('get_all_strokes', {})
}
diff --git a/src-frontend/src/lib/rasterRender.js b/src-frontend/src/lib/rasterRender.js
deleted file mode 100644
index aa97c538..00000000
--- a/src-frontend/src/lib/rasterRender.js
+++ /dev/null
@@ -1,127 +0,0 @@
-// Pure helpers for compositing raster debug-layers onto a canvas in
-// the same coordinate frame the SVG uses for its vector overlays.
-// Extracted from PaintDebugView so they can be unit-tested without
-// React, and so the layout calculation has one source of truth.
-
-/// Mirror SVG `preserveAspectRatio="xMidYMid meet"`: uniform scale to
-/// fit the viewBox inside the container, with letterbox-style centering
-/// on the larger axis. Returns null if any input is missing or zero.
-export function computeViewboxTransform(viewBoxStr, container) {
- if (!viewBoxStr || !container) return null
- const parts = String(viewBoxStr).trim().split(/\s+/).map(Number)
- if (parts.length !== 4 || parts.some(v => !Number.isFinite(v))) return null
- const [vbX, vbY, vbW, vbH] = parts
- if (vbW <= 0 || vbH <= 0) return null
- if (!container || container.w <= 0 || container.h <= 0) return null
- const scale = Math.min(container.w / vbW, container.h / vbH)
- const contentX = (container.w - vbW * scale) / 2
- const contentY = (container.h - vbH * scale) / 2
- return { vbX, vbY, scale, contentX, contentY }
-}
-
-/// Convert hull bounds in SVG user-space to canvas-pixel draw rect.
-export function computeDrawRect(bounds, transform) {
- if (!bounds || !transform || bounds.length !== 4) return null
- const [x0, y0, x1, y1] = bounds
- const sw = x1 - x0 + 1
- const sh = y1 - y0 + 1
- return {
- dx: (x0 - transform.vbX) * transform.scale + transform.contentX,
- dy: (y0 - transform.vbY) * transform.scale + transform.contentY,
- dw: sw * transform.scale,
- dh: sh * transform.scale,
- }
-}
-
-/// Composite layers onto `ctx`. `layers` is an array of
-/// `{ image: HTMLImageElement|null, opacity: number }`. Skips layers
-/// whose image is null. Sets imageSmoothingEnabled=false so each
-/// layer draws with nearest-neighbor sampling — sharp at any zoom.
-export function drawRasterLayers(ctx, drawRect, layers) {
- if (!ctx || !drawRect) return
- ctx.imageSmoothingEnabled = false
- for (const layer of layers) {
- if (!layer || !layer.image) continue
- ctx.globalAlpha = Math.max(0, Math.min(1, layer.opacity ?? 1))
- ctx.drawImage(layer.image, drawRect.dx, drawRect.dy, drawRect.dw, drawRect.dh)
- }
- ctx.globalAlpha = 1
-}
-
-/// Build the array of `{ src, opacity }` layer specs for a given debug
-/// payload + UI state. Pulled out so the test can assert which layers
-/// the React component would push to the canvas without instantiating
-/// React. Pure — same inputs always produce same output.
-export function buildLayerSpecs(debug, enabled, opacity, walkIdx) {
- if (!debug) return []
- const specs = []
- if (enabled.source && debug.source_b64) {
- specs.push({ src: debug.source_b64, opacity: opacity.source })
- }
- if (enabled.sdf && debug.sdf_b64) {
- specs.push({ src: debug.sdf_b64, opacity: opacity.sdf })
- }
- if (enabled.preSnapshot) {
- const sIdx = (debug.walks ?? [])[walkIdx]?.stroke_idx ?? 0
- const png = (debug.unpainted_snapshots ?? [])[sIdx]
- if (png) specs.push({ src: png, opacity: opacity.coverage })
- }
- if (enabled.coverage && debug.coverage_b64) {
- specs.push({ src: debug.coverage_b64, opacity: opacity.coverage })
- }
- return specs
-}
-
-/// End-to-end render: measure → compute transform → resize canvas →
-/// async-load each layer → composite. The React `useLayoutEffect`
-/// is a thin shim around this; isolating the body here lets tests
-/// run it against a mock canvas + mock loadImage with zero React.
-///
-/// Returns a status object so the caller (and tests) can verify
-/// what happened: how many layers actually drew, and the rect they
-/// targeted. Returns `drew: 0, reason: ...` on any early-out.
-export async function renderRasterLayersToCanvas(args) {
- const {
- canvas,
- divRect,
- debug,
- viewBox,
- enabled,
- opacity,
- walkIdx,
- loadImage,
- isCancelled = () => false,
- } = args
-
- if (!canvas) return { drew: 0, reason: 'no-canvas' }
- if (!divRect) return { drew: 0, reason: 'no-divrect' }
- if (!debug) return { drew: 0, reason: 'no-debug' }
- if (divRect.width <= 0 || divRect.height <= 0) return { drew: 0, reason: 'zero-size' }
-
- const transform = computeViewboxTransform(viewBox, { w: divRect.width, h: divRect.height })
- const drawRect = computeDrawRect(debug.bounds, transform)
- if (!transform || !drawRect) return { drew: 0, reason: 'bad-transform' }
-
- // Resize the canvas raster to match its CSS rect (1:1 device pixels).
- const W = Math.round(divRect.width)
- const H = Math.round(divRect.height)
- if (canvas.width !== W || canvas.height !== H) {
- canvas.width = W
- canvas.height = H
- }
- const ctx = canvas.getContext('2d')
- if (!ctx) return { drew: 0, reason: 'no-ctx' }
- ctx.clearRect(0, 0, W, H)
-
- const specs = buildLayerSpecs(debug, enabled, opacity, walkIdx)
- if (specs.length === 0) return { drew: 0, reason: 'no-layers', drawRect }
-
- const imgs = await Promise.all(specs.map(s => loadImage(s.src)))
- if (isCancelled()) return { drew: 0, reason: 'cancelled', drawRect }
-
- ctx.clearRect(0, 0, canvas.width, canvas.height)
- const layers = specs.map((s, i) => ({ image: imgs[i], opacity: s.opacity }))
- drawRasterLayers(ctx, drawRect, layers)
- const drewCount = imgs.filter(i => !!i).length
- return { drew: drewCount, reason: 'ok', drawRect }
-}
diff --git a/src-frontend/src/lib/rasterRender.test.js b/src-frontend/src/lib/rasterRender.test.js
deleted file mode 100644
index 684fb9d9..00000000
--- a/src-frontend/src/lib/rasterRender.test.js
+++ /dev/null
@@ -1,356 +0,0 @@
-import { describe, test, expect, vi } from 'vitest'
-import {
- computeViewboxTransform,
- computeDrawRect,
- drawRasterLayers,
- buildLayerSpecs,
- renderRasterLayersToCanvas,
-} from './rasterRender.js'
-
-describe('computeViewboxTransform', () => {
- test('returns null on empty/zero inputs', () => {
- expect(computeViewboxTransform('', { w: 100, h: 100 })).toBeNull()
- expect(computeViewboxTransform('0 0 100 100', null)).toBeNull()
- expect(computeViewboxTransform('0 0 100 100', { w: 0, h: 0 })).toBeNull()
- expect(computeViewboxTransform('0 0 0 100', { w: 100, h: 100 })).toBeNull()
- expect(computeViewboxTransform('not a viewbox', { w: 100, h: 100 })).toBeNull()
- })
-
- test('square container, square viewBox -> 1:1 scale, no offset', () => {
- const t = computeViewboxTransform('0 0 100 100', { w: 100, h: 100 })
- expect(t).toEqual({ vbX: 0, vbY: 0, scale: 1, contentX: 0, contentY: 0 })
- })
-
- test('container 800x600, viewBox 100x100 -> meet scales 6, centers x', () => {
- // meet = scale to fit, smaller axis dominates
- const t = computeViewboxTransform('0 0 100 100', { w: 800, h: 600 })
- expect(t.scale).toBe(6)
- expect(t.contentX).toBe((800 - 600) / 2) // 100
- expect(t.contentY).toBe(0)
- })
-
- test('viewBox offset is preserved', () => {
- const t = computeViewboxTransform('50 30 100 100', { w: 100, h: 100 })
- expect(t.vbX).toBe(50)
- expect(t.vbY).toBe(30)
- expect(t.scale).toBe(1)
- })
-})
-
-describe('computeDrawRect', () => {
- test('returns null when transform is null', () => {
- expect(computeDrawRect([0, 0, 10, 10], null)).toBeNull()
- })
-
- test('identity transform passes bounds through (with +1 inclusivity)', () => {
- const t = { vbX: 0, vbY: 0, scale: 1, contentX: 0, contentY: 0 }
- const r = computeDrawRect([10, 20, 39, 49], t)
- expect(r).toEqual({ dx: 10, dy: 20, dw: 30, dh: 30 })
- })
-
- test('viewBox offset shifts dx/dy correctly', () => {
- const t = { vbX: 5, vbY: 10, scale: 2, contentX: 100, contentY: 50 }
- const r = computeDrawRect([10, 20, 19, 29], t)
- // dx = (10 - 5) * 2 + 100 = 110
- // dy = (20 - 10) * 2 + 50 = 70
- // dw = 10 * 2 = 20, dh = 10 * 2 = 20
- expect(r).toEqual({ dx: 110, dy: 70, dw: 20, dh: 20 })
- })
-})
-
-describe('drawRasterLayers', () => {
- // Build a fake canvas-2d-context that records calls.
- function makeCtx() {
- const calls = []
- return {
- calls,
- imageSmoothingEnabled: true, // start true so we verify we set it false
- globalAlpha: 1,
- drawImage(img, dx, dy, dw, dh) {
- calls.push({ kind: 'drawImage', img, dx, dy, dw, dh,
- alpha: this.globalAlpha,
- smoothing: this.imageSmoothingEnabled })
- },
- clearRect(x, y, w, h) {
- calls.push({ kind: 'clearRect', x, y, w, h })
- },
- }
- }
-
- test('no-op on null drawRect or null ctx', () => {
- const ctx = makeCtx()
- drawRasterLayers(ctx, null, [{ image: 'fake-img', opacity: 1 }])
- drawRasterLayers(null, { dx: 0, dy: 0, dw: 10, dh: 10 }, [])
- expect(ctx.calls.length).toBe(0)
- })
-
- test('disables smoothing and draws each image at the rect', () => {
- const ctx = makeCtx()
- const drawRect = { dx: 5, dy: 7, dw: 11, dh: 13 }
- const img1 = { id: 'img1' }
- const img2 = { id: 'img2' }
- drawRasterLayers(ctx, drawRect, [
- { image: img1, opacity: 0.5 },
- { image: img2, opacity: 0.8 },
- ])
- expect(ctx.imageSmoothingEnabled).toBe(false)
- const draws = ctx.calls.filter(c => c.kind === 'drawImage')
- expect(draws.length).toBe(2)
- expect(draws[0]).toMatchObject({ img: img1, dx: 5, dy: 7, dw: 11, dh: 13,
- alpha: 0.5, smoothing: false })
- expect(draws[1]).toMatchObject({ img: img2, dx: 5, dy: 7, dw: 11, dh: 13,
- alpha: 0.8, smoothing: false })
- })
-
- test('skips layers whose image is null', () => {
- const ctx = makeCtx()
- drawRasterLayers(ctx, { dx: 0, dy: 0, dw: 10, dh: 10 }, [
- { image: null, opacity: 1 },
- { image: { id: 'real' }, opacity: 1 },
- null,
- ])
- const draws = ctx.calls.filter(c => c.kind === 'drawImage')
- expect(draws.length).toBe(1)
- expect(draws[0].img).toEqual({ id: 'real' })
- })
-
- test('clamps opacity to [0, 1]', () => {
- const ctx = makeCtx()
- drawRasterLayers(ctx, { dx: 0, dy: 0, dw: 10, dh: 10 }, [
- { image: { id: 'a' }, opacity: -0.5 },
- { image: { id: 'b' }, opacity: 2.0 },
- ])
- const draws = ctx.calls.filter(c => c.kind === 'drawImage')
- expect(draws[0].alpha).toBe(0)
- expect(draws[1].alpha).toBe(1)
- })
-
- test('resets globalAlpha to 1 after drawing', () => {
- const ctx = makeCtx()
- drawRasterLayers(ctx, { dx: 0, dy: 0, dw: 10, dh: 10 }, [
- { image: { id: 'a' }, opacity: 0.3 },
- ])
- expect(ctx.globalAlpha).toBe(1)
- })
-})
-
-describe('buildLayerSpecs', () => {
- const dbg = {
- source_b64: 'data:source',
- sdf_b64: 'data:sdf',
- coverage_b64: 'data:coverage',
- walks: [{ stroke_idx: 2 }, { stroke_idx: 3 }],
- unpainted_snapshots: ['snap0', 'snap1', 'snap2', 'snap3'],
- }
- const allOn = { source: true, sdf: true, preSnapshot: true, coverage: true }
- const op = { source: 0.4, sdf: 0.5, coverage: 0.7 }
-
- test('null debug -> empty', () => {
- expect(buildLayerSpecs(null, allOn, op, 0)).toEqual([])
- })
- test('all enabled -> all four specs', () => {
- const specs = buildLayerSpecs(dbg, allOn, op, 0)
- expect(specs.length).toBe(4)
- expect(specs[0]).toEqual({ src: 'data:source', opacity: 0.4 })
- expect(specs[1]).toEqual({ src: 'data:sdf', opacity: 0.5 })
- expect(specs[2]).toEqual({ src: 'snap2', opacity: 0.7 }) // walks[0].stroke_idx
- expect(specs[3]).toEqual({ src: 'data:coverage', opacity: 0.7 })
- })
- test('walkIdx selects which snapshot', () => {
- const specs = buildLayerSpecs(dbg, allOn, op, 1)
- // walks[1].stroke_idx = 3 -> snapshots[3]
- expect(specs[2].src).toBe('snap3')
- })
- test('partial layers respect their toggles', () => {
- const specs = buildLayerSpecs(dbg,
- { source: true, sdf: false, preSnapshot: false, coverage: false },
- op, 0)
- expect(specs.length).toBe(1)
- expect(specs[0].src).toBe('data:source')
- })
- test('missing PNG fields skipped', () => {
- const specs = buildLayerSpecs(
- { ...dbg, sdf_b64: '' }, allOn, op, 0)
- expect(specs.find(s => s.src === 'data:sdf')).toBeUndefined()
- })
-})
-
-// Mock canvas/context — drop-in replacement for HTMLCanvasElement that
-// lets us assert which drawImage calls happened. The real React effect
-// calls these via `canvas.getContext('2d')`. jsdom doesn't ship a
-// canvas implementation, so this stub stands in for tests.
-function makeMockCanvas() {
- const calls = []
- const ctx = {
- calls,
- imageSmoothingEnabled: true,
- globalAlpha: 1,
- clearRect(x, y, w, h) { calls.push({ kind: 'clearRect', x, y, w, h }) },
- drawImage(img, dx, dy, dw, dh) {
- calls.push({ kind: 'drawImage', img, dx, dy, dw, dh,
- alpha: this.globalAlpha,
- smoothing: this.imageSmoothingEnabled })
- },
- }
- return {
- width: 0, height: 0,
- getContext: () => ctx,
- _ctx: ctx,
- }
-}
-
-describe('renderRasterLayersToCanvas — integration', () => {
- // Common fixture: an M-like glyph at 5mm/425dpi.
- const debug = {
- bounds: [108, 142, 207, 275],
- source_b64: 'data:image/png;base64,SOURCEMOCK',
- sdf_b64: 'data:image/png;base64,SDFMOCK',
- coverage_b64: 'data:image/png;base64,COVMOCK',
- walks: [{ stroke_idx: 0 }],
- unpainted_snapshots: ['data:snap0'],
- }
- const viewBox = '104 138 107.92 141' // bounds + ~4 px pad
- const enabled = { source: true, sdf: true, preSnapshot: false, coverage: false }
- const opacity = { source: 0.4, sdf: 0.5, coverage: 0.7 }
- // Mock loadImage returns a fake "image" object that drawImage
- // accepts (it doesn't care what the type is, just that it's
- // not null).
- const fakeImage = (src) => ({ _mockSrc: src })
- const loadImage = (src) => Promise.resolve(fakeImage(src))
-
- test('happy path: drawImage called with non-zero rect for every enabled layer', async () => {
- const canvas = makeMockCanvas()
- const divRect = { width: 800, height: 600 }
- const result = await renderRasterLayersToCanvas({
- canvas, divRect, debug, viewBox, enabled, opacity, walkIdx: 0, loadImage,
- })
- expect(result.reason).toBe('ok')
- expect(result.drew).toBe(2)
- expect(canvas.width).toBe(800)
- expect(canvas.height).toBe(600)
-
- const draws = canvas._ctx.calls.filter(c => c.kind === 'drawImage')
- expect(draws.length).toBe(2)
- // Both draws must have non-zero rect.
- for (const d of draws) {
- expect(d.dw).toBeGreaterThan(0)
- expect(d.dh).toBeGreaterThan(0)
- expect(d.smoothing).toBe(false) // pixelated guarantee
- }
- // Verify the two draws went to the same rect (same overlay).
- expect(draws[0].dw).toBeCloseTo(draws[1].dw)
- expect(draws[0].dh).toBeCloseTo(draws[1].dh)
- // Verify the rect occupies a meaningful portion of the canvas
- // (the original bug had the imgs render at "tiny" 1:1 px size).
- expect(Math.max(draws[0].dw / 800, draws[0].dh / 600)).toBeGreaterThan(0.3)
- })
-
- test('regression: zero-size container -> no draw, sensible reason', async () => {
- const canvas = makeMockCanvas()
- const result = await renderRasterLayersToCanvas({
- canvas, divRect: { width: 0, height: 0 },
- debug, viewBox, enabled, opacity, walkIdx: 0, loadImage,
- })
- expect(result.drew).toBe(0)
- expect(result.reason).toBe('zero-size')
- })
-
- test('regression: null debug -> no draw', async () => {
- const canvas = makeMockCanvas()
- const result = await renderRasterLayersToCanvas({
- canvas, divRect: { width: 800, height: 600 },
- debug: null, viewBox, enabled, opacity, walkIdx: 0, loadImage,
- })
- expect(result.drew).toBe(0)
- expect(result.reason).toBe('no-debug')
- })
-
- test('regression: all layer toggles off -> no-layers reason, no drawImage', async () => {
- const canvas = makeMockCanvas()
- const result = await renderRasterLayersToCanvas({
- canvas, divRect: { width: 800, height: 600 },
- debug, viewBox,
- enabled: { source: false, sdf: false, preSnapshot: false, coverage: false },
- opacity, walkIdx: 0, loadImage,
- })
- expect(result.drew).toBe(0)
- expect(result.reason).toBe('no-layers')
- const draws = canvas._ctx.calls.filter(c => c.kind === 'drawImage')
- expect(draws.length).toBe(0)
- })
-
- test('regression: cancelled mid-load -> no draw', async () => {
- const canvas = makeMockCanvas()
- let cancelledFlag = false
- // Make loadImage actually async so we can flip cancel mid-flight.
- const slowLoad = (src) => new Promise(res => setTimeout(() => res(fakeImage(src)), 0))
- const promise = renderRasterLayersToCanvas({
- canvas, divRect: { width: 800, height: 600 },
- debug, viewBox, enabled, opacity, walkIdx: 0,
- loadImage: slowLoad,
- isCancelled: () => cancelledFlag,
- })
- cancelledFlag = true
- const result = await promise
- expect(result.drew).toBe(0)
- expect(result.reason).toBe('cancelled')
- })
-
- test('regression: tiny container (3mm/150dpi case) -> still produces correct rect', async () => {
- // 3mm at 150dpi is ~17 px tall; bounds proportionally small.
- const tinyDebug = {
- ...debug,
- bounds: [10, 10, 26, 26], // 17×17 hull
- }
- const tinyVB = '8 8 21 21' // bounds + 2px pad
- const canvas = makeMockCanvas()
- const result = await renderRasterLayersToCanvas({
- canvas, divRect: { width: 800, height: 600 },
- debug: tinyDebug, viewBox: tinyVB, enabled, opacity, walkIdx: 0, loadImage,
- })
- expect(result.reason).toBe('ok')
- const draws = canvas._ctx.calls.filter(c => c.kind === 'drawImage')
- // Even tiny letters must fill a meaningful chunk of the canvas
- // because the SVG's `meet` aspect-fit upscales them.
- expect(draws[0].dw).toBeGreaterThan(100)
- expect(draws[0].dh).toBeGreaterThan(100)
- })
-})
-
-// Integration-like test: end-to-end pipeline from viewBox + container
-// + bounds → final draw call. This is the path the React component
-// follows; if any of these three pure functions misbehaves the canvas
-// shows nothing.
-describe('end-to-end pipeline', () => {
- test('typical M at 5mm/425dpi: image lands inside container with correct size', () => {
- // M at 5mm/425dpi has bounds approximately:
- // x: 108..207, y: 142..275 (per earlier diagnostic test)
- // pad = max(2, (x1-x0)*0.04) = max(2, 4) = 4
- // viewBox = `${x0-pad} ${y0-pad} ${(x1-x0)+2*pad} ${(y1-y0)+2*pad}`
- const bounds = [108, 142, 207, 275]
- const pad = Math.max(2, (bounds[2] - bounds[0]) * 0.04)
- const vb = `${bounds[0]-pad} ${bounds[1]-pad} ${(bounds[2]-bounds[0])+2*pad} ${(bounds[3]-bounds[1])+2*pad}`
- const container = { w: 800, h: 600 }
- const t = computeViewboxTransform(vb, container)
- const r = computeDrawRect(bounds, t)
- expect(t).not.toBeNull()
- expect(r).not.toBeNull()
- // The image should land WITHIN the container (not off-screen).
- expect(r.dx).toBeGreaterThanOrEqual(0)
- expect(r.dy).toBeGreaterThanOrEqual(0)
- expect(r.dx + r.dw).toBeLessThanOrEqual(container.w + 1)
- expect(r.dy + r.dh).toBeLessThanOrEqual(container.h + 1)
- // The image should occupy a sensible fraction of the container —
- // at least 1/3 of either dimension (otherwise the user would see
- // it as "tiny", which is the original bug).
- const fillFrac = Math.max(r.dw / container.w, r.dh / container.h)
- expect(fillFrac).toBeGreaterThan(0.3)
- })
-
- test('regression: empty/zero container yields a null draw rect (skip render)', () => {
- const t = computeViewboxTransform('0 0 100 100', { w: 0, h: 0 })
- expect(t).toBeNull()
- const r = computeDrawRect([0, 0, 10, 10], t)
- expect(r).toBeNull()
- })
-})
diff --git a/src/bin/paint_bench.rs b/src/bin/paint_bench.rs
deleted file mode 100644
index 3eac42b7..00000000
--- a/src/bin/paint_bench.rs
+++ /dev/null
@@ -1,40 +0,0 @@
-//! Hot-path bench: build the optimizer corpus once, then loop calling
-//! `evaluate(corpus, &default_params)` for a fixed wall-clock duration.
-//! Prints iter count + ms/iter so you have a baseline number, and
-//! holds the process up long enough that an external profiler
-//! (samply, sample, Instruments) can capture a representative trace.
-//!
-//! Usage: paint_bench [seconds] (default 60)
-
-use std::time::{Duration, Instant};
-use trac3r_lib::brush_paint::PaintParams;
-use trac3r_lib::brush_paint_opt::{build_corpus, evaluate};
-
-fn main() {
- let secs: u64 = std::env::args().nth(1)
- .and_then(|s| s.parse().ok())
- .unwrap_or(60);
- eprintln!("[bench] building corpus...");
- let corpus = build_corpus();
- eprintln!("[bench] corpus: {} hulls", corpus.len());
- let params = PaintParams::default();
- eprintln!("[bench] pid={} running for {}s", std::process::id(), secs);
-
- // Warm up the hull cache + jit any lazy code paths.
- let _ = evaluate(&corpus, ¶ms);
-
- let deadline = Instant::now() + Duration::from_secs(secs);
- let mut iters = 0u32;
- let start = Instant::now();
- while Instant::now() < deadline {
- let _ = evaluate(&corpus, ¶ms);
- iters += 1;
- if iters.is_multiple_of(10) {
- let elapsed = start.elapsed().as_secs_f64();
- eprintln!("[bench] {iters} iters, {:.0} ms/iter", 1000.0 * elapsed / iters as f64);
- }
- }
- let elapsed = start.elapsed().as_secs_f64();
- eprintln!("[bench] DONE: {iters} iters in {:.1}s = {:.0} ms/iter",
- elapsed, 1000.0 * elapsed / iters as f64);
-}
diff --git a/src/bin/paint_meta_opt_worker.rs b/src/bin/paint_meta_opt_worker.rs
deleted file mode 100644
index b3314f62..00000000
--- a/src/bin/paint_meta_opt_worker.rs
+++ /dev/null
@@ -1,98 +0,0 @@
-//! Meta-optimizer worker — runs ONE outer sample of the meta search.
-//! Builds the ScoreWeights for index N, runs the full inner optimizer
-//! under those weights, evaluates the result against the corpus, and
-//! prints a JSON `MetaResult` to stdout. Lets the meta-search be
-//! sharded across SSH-reachable machines: each box runs
-//! `paint_meta_opt_worker N` for its assigned indices in parallel,
-//! orchestrator collects + lex-sorts.
-//!
-//! Usage:
-//! paint_meta_opt_worker [--inner N] [--passes K]
-//!
-//! Output (stdout): `{ "idx", "weights", "params", "report" }` (JSON).
-//! Stderr: 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::{
- build_meta_weights, build_corpus, default_axes,
- evaluate_score_weights, MetaResult,
-};
-
-fn parse_args() -> Result<(usize, usize, u32), String> {
- let argv: Vec = env::args().collect();
- if argv.len() < 2 {
- return Err(format!(
- "usage: {} [--inner N] [--passes K]\n\
- outer_idx is the meta-optimizer's outer index (0..K-1).\n\
- inner defaults to 16, passes to 4.",
- argv.first().cloned().unwrap_or_else(|| "paint_meta_opt_worker".to_string())
- ));
- }
- let outer_idx: usize = argv[1].parse()
- .map_err(|e| format!("outer_idx must be a non-negative integer: {e}"))?;
- let mut inner: usize = 16;
- let mut passes: u32 = 4;
- let mut i = 2;
- while i < argv.len() {
- match argv[i].as_str() {
- "--inner" => {
- i += 1;
- inner = argv.get(i).ok_or("--inner requires a value")?
- .parse().map_err(|e| format!("--inner value invalid: {e}"))?;
- }
- "--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((outer_idx, inner, passes))
-}
-
-fn main() -> ExitCode {
- let (outer_idx, n_inner, n_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!("[meta-worker {host}/{cores}t] outer_idx={outer_idx} inner={n_inner} passes={n_passes}");
-
- let t0 = std::time::Instant::now();
- let weights = build_meta_weights(outer_idx);
- let corpus = build_corpus();
- let axes = default_axes();
- let base = PaintParams::default();
- let (params, report) = evaluate_score_weights(
- &weights, &corpus, &axes, &base, n_inner, n_passes
- );
- let elapsed = t0.elapsed();
- eprintln!(
- "[meta-worker {host}] done idx={} elapsed={:.1}s {}",
- outer_idx, elapsed.as_secs_f64(), report.summary()
- );
-
- let result = MetaResult { idx: outer_idx, weights, params, report };
- match serde_json::to_string(&result) {
- Ok(json) => { println!("{json}"); ExitCode::SUCCESS }
- Err(e) => { eprintln!("[meta-worker {host}] JSON serialize 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())
- })
-}
diff --git a/src/bin/paint_opt_worker.rs b/src/bin/paint_opt_worker.rs
deleted file mode 100644
index fbf832ee..00000000
--- a/src/bin/paint_opt_worker.rs
+++ /dev/null
@@ -1,94 +0,0 @@
-//! 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 [--passes N]
-//!
-//! Output (stdout):
-//! { "start_idx": , "score": , "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 = env::args().collect();
- if argv.len() < 2 {
- return Err(format!(
- "usage: {} [--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())
- })
-}
diff --git a/src/brush_paint.rs b/src/brush_paint.rs
deleted file mode 100644
index a4076c3b..00000000
--- a/src/brush_paint.rs
+++ /dev/null
@@ -1,3525 +0,0 @@
-// Brush-coverage pen-stroke planner.
-//
-// Models the pen-plotter as a circle-brush moving over the glyph.
-// Brush radius ≈ stroke half-width (= SDF max). At each step the brush
-// picks the direction that adds the most new coverage (un-painted ink
-// pixels under the disk), with a momentum bias and a small overpaint
-// penalty. Continues as long as some direction adds enough new coverage;
-// pen-ups only when all directions are exhausted.
-//
-// This subsumes the figure-8 / N / O cases that the medial-axis approach
-// fragments: the brush naturally traverses junctions because the cross-
-// over direction has unpainted ink ahead, while alternate directions
-// don't.
-
-use std::collections::{HashMap, HashSet};
-use std::sync::{Arc, Mutex, OnceLock};
-use rayon::prelude::*;
-use crate::fill::{FillResult, rdp_simplify_f32, chamfer_distance,
- zhang_suen_thin, prune_skeleton_spurs, zs_neighbors};
-use crate::hulls::Hull;
-
-/// Rasterize a single character into a fresh canvas and extract hulls.
-/// Used by the debug viz so the user can load any test letter on demand
-/// without having to push an image through the full pipeline. The canvas
-/// is sized to give the brush sweep room around the glyph's outermost
-/// strokes — same recipe used in the test corpus.
-pub fn rasterize_test_letter(ch: char, font_size_mm: f32, dpi: u32, thickness_px: u32)
- -> Vec
-{
- use crate::text::{TextBlockSpec, rasterize_blocks};
- use crate::hulls::{extract_hulls, HullParams, Connectivity};
-
- let pad_mm = font_size_mm.max(2.0);
- let canvas_mm = pad_mm * 2.0 + font_size_mm * 3.0;
- let block = TextBlockSpec {
- text: ch.to_string(), font_size_mm,
- line_spacing_mm: None, x_mm: pad_mm, y_mm: pad_mm,
- };
- let rgb = rasterize_blocks(&[block], canvas_mm, canvas_mm, dpi, thickness_px);
- let (w, h) = rgb.dimensions();
- let luma: Vec = rgb.pixels()
- .map(|p| ((p[0] as u32 + p[1] as u32 + p[2] as u32) / 3) as u8)
- .collect();
- let params = HullParams {
- threshold: 253, min_area: 4, rdp_epsilon: 1.5,
- connectivity: Connectivity::Four,
- ..HullParams::default()
- };
- extract_hulls(&luma, &rgb, w, h, ¶ms)
-}
-
-#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
-#[serde(default)]
-pub struct PaintParams {
- /// Brush radius as a multiplier of `effective_sdf` (the percentile-
- /// based SDF; see `brush_radius_percentile`). 1.0 = brush matches the
- /// typical stroke half-width.
- pub brush_radius_factor: f32,
- /// Add this many pixels to the brush radius after the multiplier.
- /// Compensates for the chamfer underestimate at any thickness.
- pub brush_radius_offset_px: f32,
- /// SDF percentile (0.0-1.0) used to size the brush. The straight
- /// `max` is biased upward by junctions/T-intersections — at the
- /// crossing of two strokes the medial-axis SDF spikes well past the
- /// stroke half-width, and using that maximum as the brush size makes
- /// the brush too fat for the rest of the glyph (visible as a thick
- /// red halo of off-glyph paint on letters like W, M, 4, A). 0.95
- /// (default) ignores the top 5% of SDF values, which clips the
- /// junction spike while still covering the typical stroke ridge.
- pub brush_radius_percentile: f32,
- /// Step size as a multiplier of brush radius. 0.5 = each step
- /// advances half a brush diameter, giving 50% disk overlap and a
- /// continuous painted track.
- pub step_size_factor: f32,
- /// How many candidate directions to evaluate per step.
- pub n_directions: usize,
- /// Look-ahead distance, in steps. The brush evaluates "what would I
- /// cover if I walked k steps in this direction?" instead of just one.
- /// 1 = greedy 1-step. Higher values steer through junctions correctly.
- pub lookahead_steps: usize,
- /// Bonus weight on direction alignment with current velocity.
- /// 0 = no momentum, 1 = momentum equally weighted with new coverage.
- pub momentum_weight: f32,
- /// Per-overpainted-pixel penalty in scoring (relative to new coverage
- /// which is +1 per pixel). Applied to ink we already painted (mild —
- /// just discourages backtracking).
- pub overpaint_penalty: f32,
- /// Per-bg-pixel penalty applied to disk overhang in the walker's
- /// lookahead score. Critical for preventing corner-cutting: at a
- /// bend, the highest-new-ink direction is the diagonal cut (disk
- /// straddles ink on both sides of the corner), but the disk also
- /// pokes into bg on the OUTSIDE of the bend. A heavy penalty here
- /// rejects the cut upfront, before the relax pass has to fight it.
- /// Higher = stricter centerline-following at corners.
- pub walk_bg_penalty: f32,
- /// Stop the stroke when the best direction's score falls below this
- /// fraction of the brush area (e.g. 0.05 = "stop when no direction
- /// adds even 5% of a fresh disk worth of new coverage").
- pub min_score_factor: f32,
- /// Reject candidate directions whose `dot(dir, prev_dir)` is below
- /// this value. cos(135°) ≈ -0.71 — at -0.7 the walker just barely
- /// rejects the 135° turns that M/W/A apexes need. Closer to -1
- /// admits sharper turns (down to direct reversals); closer to 0
- /// rejects mild backward components.
- pub back_dir_cutoff: f32,
- /// Minimum unpainted-ink component size (as a multiplier of brush
- /// area = π·r²) to be eligible as a stroke seed. Sub-threshold
- /// components stay in the unpainted mask and count against
- /// coverage but don't get a stroke of their own.
- pub min_component_factor: f32,
- /// Cap.
- pub max_steps_per_stroke: u32,
- pub max_strokes: u32,
- /// Final stroke RDP simplification epsilon (px).
- pub output_rdp_eps: f32,
-}
-
-impl Default for PaintParams {
- fn default() -> Self {
- Self {
- // META-OPTIMIZER WINNING CONFIG (idx 20 of 24-sample lex-
- // ranked search). Tier-1 result on the corpus: cov_fail=8,
- // bg_fail=0, len_fail=0; ~12 px bg per letter on average.
- // Stroke-count constraints (12 single-stroke + 11 two-stroke
- // letters miscounted) are NOT respected — known limitation
- // of the soft inner-score the meta search uses.
- brush_radius_factor: 0.88,
- brush_radius_offset_px: 0.53,
- brush_radius_percentile: 0.93,
- step_size_factor: 0.40,
- n_directions: 48,
- lookahead_steps: 3,
- momentum_weight: 0.20,
- overpaint_penalty: 0.10,
- walk_bg_penalty: 0.69,
- min_score_factor: 0.20,
- back_dir_cutoff: -0.7,
- min_component_factor: 1.49,
- max_steps_per_stroke: 4000,
- max_strokes: 12,
- output_rdp_eps: 1.98,
- }
- }
-}
-
-#[derive(Debug, Clone, serde::Serialize)]
-pub struct PaintDebug {
- pub bounds: [f32; 4],
- pub source_b64: String,
- pub sdf_b64: String,
- pub sdf_max: f32,
- pub brush_radius: f32,
- /// Coverage mask: pixels that the brush sweep painted. Shows what got
- /// covered vs missed.
- pub coverage_b64: String,
- /// Total ink pixels in the source glyph.
- pub ink_total: u32,
- /// Ink pixels still unpainted after all strokes (the brush couldn't
- /// reach them under the current params — visible as red in the
- /// coverage layer).
- pub ink_unpainted: u32,
- /// Background pixels covered by the final brush sweep — the brush
- /// hanging outside the glyph. Pen ink ends up here on the actual
- /// plot, so this should be small. Compared to the swept area gives
- /// a "% off-glyph" metric.
- pub bg_painted: u32,
- /// Total pixels covered by the brush sweep (ink_total - ink_unpainted
- /// + bg_painted, near enough). Useful as a denominator for
- /// off-glyph ratio.
- pub total_swept: u32,
- /// Repaint count: number of *extra* disk-stamps on ink pixels beyond
- /// the first. Counts the redundant brush coverage from disk-overlap
- /// (natural baseline) PLUS any path doubling-back. Higher = more
- /// path-on-path overlap.
- pub repaint: u32,
- /// Approximate medial-axis length of the source hull, in pixels.
- /// Used as the "ideal" path length: a single-pass trace should be
- /// near this; well under 1.5× this means efficient, well over means
- /// the path is snaking.
- pub skeleton_length: u32,
- /// Sizes of every connected component in the *unpainted* ink mask
- /// at the end of painting. Lets the score function tell scattered
- /// edge slop ("ten 1-px clusters") apart from feature-sized misses
- /// ("one 30-px crossbar tip cluster").
- pub unpainted_clusters: Vec,
- /// Raw trajectories (one per stroke), pre-smoothing.
- pub trajectories: Vec>,
- /// Final smoothed strokes (what would go to gcode).
- pub strokes: Vec>,
- /// Starting points of each stroke, in order. These are the actual
- /// pen-down positions (path[0] of each trajectory).
- pub start_points: Vec<(f32, f32)>,
- /// Per-walk traces: one outer Vec per call to `walk_brush` (forward
- /// + backward = 2 entries per stroke). Each inner Vec is the
- /// sequence of `WalkStep`s the walker took. Lets the frontend
- /// scrub through the algorithm step by step and inspect every
- /// candidate direction the walker considered.
- pub walks: Vec,
- /// Spur-pruned thinned skeleton, color-coded by per-pixel degree
- /// (endpoint / junction / path). Same coord system as
- /// `source_b64` / `sdf_b64` / `coverage_b64`. (Vector polylines
- /// in `skeleton_segments` are usually preferable for display —
- /// stay sharp under zoom.)
- pub skeleton_b64: String,
- /// Skeleton as vector polylines, one per segment between special
- /// nodes (endpoint or junction). Coordinates in hull-image space.
- pub skeleton_segments: Vec>,
- /// Skeleton junction positions (degree ≥ 3). Endpoints are in
- /// `endpoint_arrows`.
- pub skeleton_junctions: Vec<(f32, f32)>,
- /// Skeleton endpoints as render-ready arrows: each tuple is
- /// `(x, y, dx, dy)` where `(x, y)` is the endpoint position in
- /// hull coords and `(dx, dy)` is the unit init_dir along the
- /// skeleton (the direction the walker would head from this seed).
- pub endpoint_arrows: Vec<(f32, f32, f32, f32)>,
- /// Brush disk shape: integer (dx, dy) offsets that are inside the
- /// brush mask at this radius. Lets the frontend render an inset
- /// "this is the brush footprint" diagram.
- pub disk_offsets: Vec<(i32, i32)>,
- /// Per-stroke seeding info: one entry per `pick_next_component`
- /// call that returned a seed. Each entry lists every connected
- /// component in the unpainted mask at that moment, with bbox +
- /// pixel count + flags for "would have been seeded" (substantial)
- /// and "actually chosen as seed."
- pub stroke_seedings: Vec,
- /// Per-stroke pre-walk unpainted snapshot: the unpainted mask as
- /// a PNG, captured just before each stroke's bidirectional walk
- /// began. Same length as `trajectories`. Lets the scrubber show
- /// "what the walker saw" at the start of stroke N.
- pub unpainted_snapshots: Vec,
- /// Voronoi-based medial axis: line segments between adjacent
- /// triangle circumcenters whose both endpoints are inside the
- /// hull. Each entry is `((x0,y0),(x1,y1))` in hull-image coords.
- /// Debug viz only — not consumed by the painter.
- pub voronoi_segments: Vec<((f32, f32), (f32, f32))>,
- /// Telea AFMM medial-axis points: every interior pixel whose 8-nbr
- /// arc-length labels disagree by more than perim/5. Rendered as
- /// a dot cloud on the frontend. Debug viz only.
- pub afmm_points: Vec<(f32, f32)>,
-}
-
-/// Per-stroke seeding diagnostics: one of these is recorded for each
-/// `pick_next_component` call that produced a seed. Lists every
-/// connected unpainted-ink component visible at that moment, with
-/// flags for whether it was eligible (≥ min_component_pixels) and
-/// whether the picker picked it.
-#[derive(Debug, Clone, serde::Serialize)]
-pub struct StrokeSeeding {
- pub stroke_idx: u32,
- pub min_component_pixels: u32,
- pub raw_start: (f32, f32),
- pub snapped_start: (f32, f32),
- pub init_dir: (f32, f32),
- pub components: Vec,
-}
-
-#[derive(Debug, Clone, serde::Serialize)]
-pub struct SeedComponent {
- /// Bbox in hull-local (relative to grid bx/by) coords:
- /// `[x_min, y_min, x_max, y_max]`.
- pub bbox: [i32; 4],
- pub pixel_count: u32,
- pub substantial: bool, // ≥ min_component_pixels — eligible to seed
- pub chosen: bool, // picked by this pick_next_component call
-}
-
-/// One full walk_brush invocation, recorded for stepping/visualization.
-#[derive(Debug, Clone, serde::Serialize)]
-pub struct WalkTrace {
- /// "forward" or "backward".
- pub kind: String,
- /// Index of the parent stroke (0-based).
- pub stroke_idx: u32,
- /// Position passed in as `start`.
- pub start: (f32, f32),
- /// Initial momentum direction (None = no init bias).
- pub init_dir: Option<(f32, f32)>,
- /// Brush radius for this walk.
- pub brush_radius: f32,
- /// Step size used by this walk (= step_size_factor × brush_radius).
- pub step_size: f32,
- /// `min_score_factor × brush_area` — the score gate.
- pub min_score: f32,
- /// One entry per loop iteration the walker performed.
- pub steps: Vec,
- /// Why the walker exited (loop max, no-candidates, score,
-}
-
-/// One iteration of the walker loop.
-#[derive(Debug, Clone, serde::Serialize)]
-pub struct WalkStep {
- /// Iteration count, 0 = start position.
- pub idx: u32,
- /// Walker position at the START of this iteration.
- pub p: (f32, f32),
- /// Momentum at the start (= dir of the previous step's chosen move).
- pub prev_dir: Option<(f32, f32)>,
- /// Every candidate direction sampled (n_directions of them).
- pub candidates: Vec,
- /// Index into `candidates` of the chosen direction (None = stroke ended).
- pub chosen: Option,
- /// New position after taking the chosen step (None if walker exited
- /// here).
- pub new_p: Option<(f32, f32)>,
-}
-
-/// One candidate direction's evaluation.
-#[derive(Debug, Clone, serde::Serialize)]
-pub struct WalkCandidate {
- /// Angle (radians) of the candidate direction.
- pub theta: f32,
- /// Unit vector.
- pub dir: (f32, f32),
- /// Probe pixel = p + dir * step_size.
- pub probe: (f32, f32),
- /// True if rejected upfront because dot(dir, prev_dir) < -0.7.
- pub rejected_back: bool,
- /// True if rejected because the probe pixel isn't on ink.
- pub rejected_off_ink: bool,
- /// Sum of `new_ink_under_disk × 1/k` over k=1..lookahead_steps.
- pub new_ink: f32,
- /// Sum of `repaint_ink_under_disk × 1/k`.
- pub repaint: f32,
- /// Sum of `bg_under_disk × 1/k`.
- pub bg: f32,
- /// `momentum_weight × max(0, dot(dir, prev_dir)) × brush_area` (0 if
- /// no prev_dir).
- pub momentum_bonus: f32,
- /// Final score = new_ink − overpaint·repaint − walk_bg·bg + momentum_bonus.
- pub score: f32,
-}
-
-/// Percentile of the SDF distribution over all ink pixels. `q` ∈ [0, 1].
-/// At q=1.0 returns the max; at q=0.95 returns the 95th-percentile value.
-/// We use this to pick a brush radius that ignores junction spikes (where
-/// the medial axis's distance to boundary balloons past the typical
-/// stroke half-width).
-fn sdf_percentile(dist: &std::collections::HashMap<(u32, u32), f32>, q: f32) -> f32 {
- if dist.is_empty() { return 0.0; }
- let q = q.clamp(0.0, 1.0);
- let mut vals: Vec = dist.values().copied().collect();
- vals.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
- let idx = ((vals.len() as f32 - 1.0) * q).round() as usize;
- vals[idx.min(vals.len() - 1)]
-}
-
-/// Re-simulate the brush sweep over the final strokes and count
-/// (bg_painted, total_swept, repaint) — bg+ink pixels under the disk,
-/// total covered, and extra hits beyond the first per pixel. The
-/// baseline single-pass sweep has some repaint from adjacent-sample
-/// disk overlap (~4× per pixel at step_size_factor=0.5); higher
-/// values mean the path is doubling back over itself.
-fn measure_sweep_full(strokes: &[Vec<(f32, f32)>], grid: &Grid)
- -> (u32, u32, u32)
-{
- if strokes.is_empty() { return (0, 0, 0); }
- let mut count = vec![0u32; grid.hull.was_ink.len()];
- let r2 = grid.brush_radius_sq;
- for stroke in strokes {
- for win in stroke.windows(2) {
- let (a, b) = (win[0], win[1]);
- let dx = b.0 - a.0; let dy = b.1 - a.1;
- let len = (dx * dx + dy * dy).sqrt();
- let n = (len * 2.0).ceil().max(1.0) as i32;
- for i in 0..=n {
- let t = i as f32 / n as f32;
- let cx = a.0 + dx * t;
- let cy = a.1 + dy * t;
- let cx_i = cx.round() as i32;
- let cy_i = cy.round() as i32;
- for &(ddx, ddy) in &grid.disk_offsets {
- let dxr = (cx_i + ddx) as f32 - cx;
- let dyr = (cy_i + ddy) as f32 - cy;
- if dxr * dxr + dyr * dyr > r2 { continue; }
- let lx = cx_i + ddx - grid.bx;
- let ly = cy_i + ddy - grid.by;
- if lx < 0 || ly < 0 || lx >= grid.width || ly >= grid.height { continue; }
- count[(ly * grid.width + lx) as usize] += 1;
- }
- }
- }
- }
- let mut bg = 0u32;
- let mut total = 0u32;
- let mut repaint = 0u32;
- for (i, &c) in count.iter().enumerate() {
- if c == 0 { continue; }
- total += 1;
- if !grid.hull.was_ink.get(i) { bg += 1; }
- else { repaint += c - 1; }
- }
- (bg, total, repaint)
-}
-
-fn colormap_viridis(t: f32) -> (u8, u8, u8) {
- let stops: [(u8, u8, u8); 5] = [
- ( 68, 1, 84),
- ( 59, 82, 139),
- ( 33, 144, 141),
- ( 93, 201, 99),
- (253, 231, 37),
- ];
- let t = t.clamp(0.0, 1.0);
- let n = stops.len() - 1;
- let pos = t * n as f32;
- let i = (pos as usize).min(n - 1);
- let f = pos - i as f32;
- let lerp = |a: u8, b: u8| (a as f32 + (b as f32 - a as f32) * f).round() as u8;
- (lerp(stops[i].0, stops[i + 1].0),
- lerp(stops[i].1, stops[i + 1].1),
- lerp(stops[i].2, stops[i + 1].2))
-}
-
-fn encode_hull_pixels_b64(hull: &Hull) -> String {
- let bx = hull.bounds.x_min;
- let by = hull.bounds.y_min;
- let bw = hull.bounds.x_max.saturating_sub(bx) + 1;
- let bh = hull.bounds.y_max.saturating_sub(by) + 1;
- let mut img: image::RgbaImage = image::ImageBuffer::new(bw, bh);
- for &(x, y) in &hull.pixels {
- if x < bx || y < by { continue; }
- let lx = x - bx;
- let ly = y - by;
- if lx < bw && ly < bh {
- img.put_pixel(lx, ly, image::Rgba([255, 255, 255, 255]));
- }
- }
- let mut buf = std::io::Cursor::new(Vec::new());
- if img.write_to(&mut buf, image::ImageFormat::Png).is_err() {
- return String::new();
- }
- use base64::Engine as _;
- let b64 = base64::engine::general_purpose::STANDARD.encode(buf.get_ref());
- format!("data:image/png;base64,{}", b64)
-}
-
-fn encode_sdf_b64(hull: &Hull) -> (String, f32) {
- let bx = hull.bounds.x_min;
- let by = hull.bounds.y_min;
- let bw = hull.bounds.x_max.saturating_sub(bx) + 1;
- let bh = hull.bounds.y_max.saturating_sub(by) + 1;
- if hull.pixels.is_empty() || bw == 0 || bh == 0 { return (String::new(), 0.0); }
- let pixel_set: HashSet<(u32, u32)> = hull.pixels.iter().copied().collect();
- let dist = chamfer_distance(hull, &pixel_set);
- let max_d = dist.values().cloned().fold(0.0_f32, f32::max);
- if max_d <= 0.0 { return (String::new(), 0.0); }
- let mut img: image::RgbaImage = image::ImageBuffer::new(bw, bh);
- for (&(x, y), &d) in dist.iter() {
- if x < bx || y < by { continue; }
- let lx = x - bx;
- let ly = y - by;
- if lx >= bw || ly >= bh { continue; }
- let t = d / max_d;
- let (r, g, b) = colormap_viridis(t);
- img.put_pixel(lx, ly, image::Rgba([r, g, b, 230]));
- }
- let mut buf = std::io::Cursor::new(Vec::new());
- if img.write_to(&mut buf, image::ImageFormat::Png).is_err() {
- return (String::new(), 0.0);
- }
- use base64::Engine as _;
- let b64 = base64::engine::general_purpose::STANDARD.encode(buf.get_ref());
- (format!("data:image/png;base64,{}", b64), max_d)
-}
-
-/// Encode the spur-pruned skeleton as a per-pixel PNG, color-coded by
-/// degree (count of in-skel 8-neighbors):
-/// degree 1 → endpoint (red)
-/// degree 2 → path (mid grey)
-/// degree ≥ 3 → junction (green)
-/// Empty pixels stay transparent. Cropped to the hull's bbox (not the
-/// padded grid bbox) so the image lines up with `source_b64` /
-/// `sdf_b64` / `coverage_b64` when overlaid in the viewer.
-fn encode_skeleton_b64(hull_data: &HullData) -> String {
- let bw = (hull_data.width - 2 * HULL_GRID_PAD).max(1);
- let bh = (hull_data.height - 2 * HULL_GRID_PAD).max(1);
- let mut img: image::RgbaImage = image::ImageBuffer::new(bw as u32, bh as u32);
- for ly in 0..bh {
- for lx in 0..bw {
- let glx = lx + HULL_GRID_PAD; // grid-local
- let gly = ly + HULL_GRID_PAD;
- let idx = (gly * hull_data.width + glx) as usize;
- if !hull_data.skeleton.get(idx) { continue; }
- let abs_x = (glx + hull_data.bx) as u32;
- let abs_y = (gly + hull_data.by) as u32;
- let nbrs = zs_neighbors(abs_x, abs_y);
- let mut deg = 0;
- for (nx, ny) in nbrs {
- let nlx = nx as i32 - hull_data.bx;
- let nly = ny as i32 - hull_data.by;
- if nlx < 0 || nly < 0 || nlx >= hull_data.width || nly >= hull_data.height { continue; }
- if hull_data.skeleton.get((nly * hull_data.width + nlx) as usize) {
- deg += 1;
- }
- }
- let rgba = match deg {
- 0 | 1 => [244, 63, 94, 230],
- 2 => [120, 120, 120, 200],
- _ => [ 34, 197, 94, 230],
- };
- img.put_pixel(lx as u32, ly as u32, image::Rgba(rgba));
- }
- }
- let mut buf = std::io::Cursor::new(Vec::new());
- if img.write_to(&mut buf, image::ImageFormat::Png).is_err() { return String::new(); }
- use base64::Engine as _;
- let b64 = base64::engine::general_purpose::STANDARD.encode(buf.get_ref());
- format!("data:image/png;base64,{}", b64)
-}
-
-/// Snapshot the current `unpainted` BitMask as a transparent PNG —
-/// red pixels where ink is not yet painted. Cropped to hull bbox so
-/// it lines up with the other overlays.
-fn encode_grid_unpainted_b64(grid: &Grid) -> String {
- let bw = (grid.width - 2 * HULL_GRID_PAD).max(1);
- let bh = (grid.height - 2 * HULL_GRID_PAD).max(1);
- let mut img: image::RgbaImage = image::ImageBuffer::new(bw as u32, bh as u32);
- for ly in 0..bh {
- for lx in 0..bw {
- let glx = lx + HULL_GRID_PAD;
- let gly = ly + HULL_GRID_PAD;
- let idx = (gly * grid.width + glx) as usize;
- if grid.unpainted.get(idx) {
- img.put_pixel(lx as u32, ly as u32, image::Rgba([244, 63, 94, 200]));
- }
- }
- }
- let mut buf = std::io::Cursor::new(Vec::new());
- if img.write_to(&mut buf, image::ImageFormat::Png).is_err() { return String::new(); }
- use base64::Engine as _;
- let b64 = base64::engine::general_purpose::STANDARD.encode(buf.get_ref());
- format!("data:image/png;base64,{}", b64)
-}
-
-/// Final unpainted ink, color-coded red. Cropped to hull bbox.
-fn encode_coverage_b64(grid: &Grid) -> String {
- let bw = (grid.width - 2 * HULL_GRID_PAD).max(1);
- let bh = (grid.height - 2 * HULL_GRID_PAD).max(1);
- let mut img: image::RgbaImage = image::ImageBuffer::new(bw as u32, bh as u32);
- for ly in 0..bh {
- for lx in 0..bw {
- let glx = lx + HULL_GRID_PAD;
- let gly = ly + HULL_GRID_PAD;
- let idx = (gly * grid.width + glx) as usize;
- if grid.unpainted.get(idx) {
- img.put_pixel(lx as u32, ly as u32, image::Rgba([244, 63, 94, 200]));
- }
- }
- }
- let mut buf = std::io::Cursor::new(Vec::new());
- if img.write_to(&mut buf, image::ImageFormat::Png).is_err() { return String::new(); }
- use base64::Engine as _;
- let b64 = base64::engine::general_purpose::STANDARD.encode(buf.get_ref());
- format!("data:image/png;base64,{}", b64)
-}
-
-// ── Alternative medial-axis algorithms (debug viz only) ──────────────────
-
-/// Voronoi-based medial axis. Insert every contour pixel as a Voronoi
-/// site, take all undirected Voronoi edges, keep only those whose both
-/// endpoints lie inside the shape — those are exactly the medial-axis
-/// segments. No raster-domain thinning, no junction-cluster pixels:
-/// each Voronoi vertex IS a junction or apex by construction.
-///
-/// Returns line segments in absolute hull-image coords (matches the
-/// frame used by `source_b64` / skeleton overlays).
-fn voronoi_medial_segments(hull: &Hull) -> Vec<((f32, f32), (f32, f32))> {
- use spade::{DelaunayTriangulation, Point2, Triangulation as _};
-
- if hull.contour.len() < 4 { return Vec::new(); }
- let pixel_set: HashSet<(u32, u32)> = hull.pixels.iter().copied().collect();
-
- let mut tri: DelaunayTriangulation> = DelaunayTriangulation::new();
- for &(x, y) in &hull.contour {
- // Slight jitter to avoid degenerate-collinear inserts. Boundary
- // pixels are integer coords; offsetting by tiny fractions keeps
- // their layout but breaks ties for the triangulator.
- let _ = tri.insert(Point2::new(x as f64, y as f64));
- }
-
- let mut segs = Vec::new();
- for ve in tri.undirected_voronoi_edges() {
- let [a, b] = ve.vertices();
- let pa = match a.position() { Some(p) => p, None => continue };
- let pb = match b.position() { Some(p) => p, None => continue };
- // Keep edges whose endpoints AND midpoint are inside the ink set
- // (filters edges that exit the shape — exterior Voronoi cells).
- let inside = |x: f64, y: f64| -> bool {
- let px = x.round() as i64; let py = y.round() as i64;
- if px < 0 || py < 0 { return false; }
- pixel_set.contains(&(px as u32, py as u32))
- };
- if !inside(pa.x, pa.y) || !inside(pb.x, pb.y) { continue; }
- let mx = (pa.x + pb.x) * 0.5;
- let my = (pa.y + pb.y) * 0.5;
- if !inside(mx, my) { continue; }
- segs.push(((pa.x as f32, pa.y as f32), (pb.x as f32, pb.y as f32)));
- }
- segs
-}
-
-/// Augmented Fast Marching (Telea-style, simplified): label each interior
-/// pixel with the *arc-length position* of the boundary pixel it's
-/// closest to (via 8-conn BFS from the contour). The medial axis is
-/// then the set of pixels that have a neighbor whose arc-length differs
-/// by more than `threshold` (modular, since the contour is a loop) —
-/// i.e., the boundary "arrives at" this pixel from two far-apart
-/// places along the outline, which is the definition of the medial
-/// axis.
-///
-/// Threshold is set proportionally to perimeter (perim/5 by default),
-/// matching Telea's "significance" parameter — controls how much
-/// boundary travel counts as a real medial branch vs noise.
-///
-/// Returns medial pixels as a point cloud (one (x,y) per pixel). The
-/// frontend renders each as a small dot.
-fn afmm_medial_points(hull: &Hull) -> Vec<(f32, f32)> {
- use std::collections::VecDeque;
- if hull.contour.len() < 4 || hull.pixels.is_empty() { return Vec::new(); }
- let pixel_set: HashSet<(u32, u32)> = hull.pixels.iter().copied().collect();
- let perim = hull.contour.len() as u32;
-
- // BFS from the contour, propagating each contour pixel's arc-length.
- let mut arc: HashMap<(u32, u32), u32> = HashMap::with_capacity(hull.pixels.len());
- let mut queue: VecDeque<(u32, u32)> = VecDeque::new();
- for (i, &p) in hull.contour.iter().enumerate() {
- arc.entry(p).or_insert(i as u32);
- queue.push_back(p);
- }
- while let Some(p) = queue.pop_front() {
- let a = arc[&p];
- for n in zs_neighbors(p.0, p.1) {
- if !pixel_set.contains(&n) { continue; }
- if arc.contains_key(&n) { continue; }
- arc.insert(n, a);
- queue.push_back(n);
- }
- }
-
- // Modular distance on the contour loop.
- let mod_dist = |a: u32, b: u32| -> u32 {
- let d = a.abs_diff(b);
- d.min(perim - d)
- };
- let threshold = (perim / 5).max(4);
-
- let mut medial: Vec<(f32, f32)> = Vec::new();
- for &p in &hull.pixels {
- let a = match arc.get(&p) { Some(&v) => v, None => continue };
- let mut max_jump = 0u32;
- for n in zs_neighbors(p.0, p.1) {
- if let Some(&b) = arc.get(&n) {
- let j = mod_dist(a, b);
- if j > max_jump { max_jump = j; }
- }
- }
- if max_jump > threshold {
- medial.push((p.0 as f32, p.1 as f32));
- }
- }
- medial
-}
-
-// ── Bit-packed mask: 1 bit per pixel ────────────────────────────────────
-
-/// Compact boolean mask backed by `Vec`. Used for `was_ink` and
-/// `unpainted` so a 200×200 letter mask is ~5 KB instead of ~40 KB —
-/// fits L1 nicely, and word-at-a-time popcount is available when
-/// scanning whole grids. All ops are `#[inline]` since they're called
-/// from the disk-iteration hot path.
-#[derive(Clone)]
-struct BitMask {
- bits: Vec,
- len: usize,
-}
-
-impl BitMask {
- fn new(n_bits: usize) -> Self {
- let words = (n_bits + 63) / 64;
- Self { bits: vec![0u64; words], len: n_bits }
- }
- #[inline] fn len(&self) -> usize { self.len }
- #[inline] fn get(&self, i: usize) -> bool {
- // Safety: caller guarantees i < self.len; we still bounds-check
- // via the indexed Vec access (Rust will panic on OOB anyway).
- (self.bits[i >> 6] >> (i & 63)) & 1 == 1
- }
- #[inline] fn set(&mut self, i: usize) {
- self.bits[i >> 6] |= 1u64 << (i & 63);
- }
- #[inline] fn clear(&mut self, i: usize) {
- self.bits[i >> 6] &= !(1u64 << (i & 63));
- }
- fn count_ones(&self) -> u32 {
- self.bits.iter().map(|w| w.count_ones()).sum()
- }
-}
-
-// ── Hull-derived data: cached per hull.id ───────────────────────────────
-
-/// Pure-function-of-the-hull state: the bbox/grid dimensions, ink mask,
-/// chamfer SDF, and skeleton-endpoint set. Computing chamfer +
-/// Zhang-Suen thin + spur-prune is the dominant cost of one
-/// `paint_fill_with` call (~50% wall time at small radii). The
-/// optimizer calls `paint_fill_with` thousands of times per hull while
-/// only varying brush/walker params, so the result is identical every
-/// time. A small `(hull.id) → Arc` cache eliminates the
-/// recomputation across calls.
-struct HullData {
- bx: i32, by: i32,
- width: i32, height: i32,
- was_ink: BitMask,
- sdf: Vec,
- /// Sorted chamfer-distance values for the ink pixels (the same set
- /// `chamfer_distance` returns). Lets `sdf_percentile_q(q)` answer
- /// in O(1) instead of recomputing chamfer + sort. Critical for
- /// the optimizer hot path: `paint_fill_with` needs an SDF
- /// percentile to derive `brush_radius` and was redundantly
- /// recomputing chamfer per call.
- sdf_values_sorted: Vec,
- skel_endpoints: Vec<(i32, i32)>,
- /// Per-endpoint initial direction: unit vector pointing from the
- /// endpoint along the skeleton into the letter (toward the
- /// endpoint's single skeleton-neighbor). Index-aligned with
- /// `skel_endpoints`. Used as `init_dir` for the forward walk so a
- /// stroke starting at e.g. M's bottom-left foot walks UP the leg
- /// (instead of trying to go down off the end of the foot, which
- /// is what the old hard-coded `(0, 1)` did).
- skel_endpoints_init_dir: Vec<(f32, f32)>,
- /// Spur-pruned thinned skeleton, bit-packed in the same coord
- /// system as `was_ink`. Kept around (small memory cost — ~1 bit
- /// per ink pixel) so the debug viewer can render it overlaid on
- /// the source. Per-pixel skeleton-degree (endpoint vs junction
- /// vs path) is derived on demand by scanning 8-connected
- /// neighbors of each skeleton pixel.
- skeleton: BitMask,
- /// Skeleton traced as polylines, one per segment between
- /// "special" nodes (endpoints with degree 1 + junctions with
- /// degree ≥ 3). Closed loops (O / 0 cores) are stored as
- /// segments whose first and last point coincide. Coordinates
- /// are absolute (hull-image space, same as `skel_endpoints`).
- skeleton_segments: Vec>,
- /// Skeleton junction positions (degree ≥ 3 in the skeleton
- /// graph). Endpoints are already in `skel_endpoints`.
- skeleton_junctions: Vec<(f32, f32)>,
- skeleton_length: u32,
- ink_total: i32,
-}
-
-impl HullData {
- fn sdf_percentile_q(&self, q: f32) -> f32 {
- let v = &self.sdf_values_sorted;
- if v.is_empty() { return 0.0; }
- let q = q.clamp(0.0, 1.0);
- let idx = ((v.len() as f32 - 1.0) * q).round() as usize;
- v[idx.min(v.len() - 1)]
- }
-}
-
-/// Cache key. `hull.id` alone isn't enough — extract_hulls assigns
-/// IDs from a per-call counter, so distinct hulls from different
-/// rasterizations collide on id. Mirror-image letters (p/q at the
-/// same scale) can also share area + bounds. We use a full FNV-1a
-/// hash over the pixel coordinate stream as the key — O(N) once
-/// per cache miss, but conclusive against collisions.
-type HullKey = u64;
-
-fn hull_key(hull: &Hull) -> HullKey {
- let mut h = 0xcbf29ce484222325u64;
- for &(x, y) in &hull.pixels {
- h ^= x as u64;
- h = h.wrapping_mul(0x100000001b3);
- h ^= y as u64;
- h = h.wrapping_mul(0x100000001b3);
- }
- h
-}
-
-fn hull_cache() -> &'static Mutex>> {
- static CACHE: OnceLock>>> = OnceLock::new();
- CACHE.get_or_init(|| Mutex::new(HashMap::new()))
-}
-
-fn get_or_compute_hull_data(hull: &Hull) -> Arc {
- let key = hull_key(hull);
- {
- let cache = hull_cache().lock().unwrap();
- if let Some(c) = cache.get(&key) {
- return c.clone();
- }
- }
- // Compute outside the lock so concurrent callers for different
- // hulls don't serialize. A small race is possible (two threads
- // miss for the same hull and both compute) — both produce
- // identical data, so the loser's copy is just dropped.
- let computed = Arc::new(compute_hull_data(hull));
- let mut cache = hull_cache().lock().unwrap();
- cache.entry(key).or_insert_with(|| computed.clone()).clone()
-}
-
-/// Pad the grid past the hull's AABB so that bg pixels swept by a brush
-/// that overhangs the polygon (e.g. at the top of an `I`, or the
-/// corners of a square letter) are counted instead of silently dropped
-/// by the bounds check. Must exceed any brush_radius the optimizer
-/// might try. The encoders crop back to hull bbox using this constant
-/// so the rendered overlays line up with `source_b64` / `sdf_b64`
-/// (which are hull-bbox-sized).
-const HULL_GRID_PAD: i32 = 32;
-
-fn compute_hull_data(hull: &Hull) -> HullData {
- let bx = hull.bounds.x_min as i32 - HULL_GRID_PAD;
- let by = hull.bounds.y_min as i32 - HULL_GRID_PAD;
- let width = (hull.bounds.x_max as i32 - hull.bounds.x_min as i32 + 1 + 2 * HULL_GRID_PAD).max(1);
- let height = (hull.bounds.y_max as i32 - hull.bounds.y_min as i32 + 1 + 2 * HULL_GRID_PAD).max(1);
- let cells = (width * height) as usize;
- let mut was_ink = BitMask::new(cells);
- let mut sdf = vec![0.0_f32; cells];
- let mut count = 0;
- for &(x, y) in &hull.pixels {
- let lx = x as i32 - bx; let ly = y as i32 - by;
- if lx < 0 || ly < 0 || lx >= width || ly >= height { continue; }
- let idx = (ly * width + lx) as usize;
- was_ink.set(idx);
- count += 1;
- }
- let pixel_set: HashSet<(u32, u32)> = hull.pixels.iter().copied().collect();
- let dist = chamfer_distance(hull, &pixel_set);
- for (&(x, y), &d) in dist.iter() {
- let lx = x as i32 - bx; let ly = y as i32 - by;
- if lx < 0 || ly < 0 || lx >= width || ly >= height { continue; }
- sdf[(ly * width + lx) as usize] = d;
- }
- let sdf_max = dist.values().copied().fold(0.0_f32, f32::max).max(0.5);
- let mut sdf_values_sorted: Vec = dist.values().copied().collect();
- sdf_values_sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
- let mut skel = zhang_suen_thin(&hull.pixels);
- let spur_len = (sdf_max * 1.5).round() as usize;
- prune_skeleton_spurs(&mut skel, spur_len.max(2));
- // Endpoints (degree-1) and their inward-along-the-skeleton init_dir.
- // The single in-skel neighbor defines which way the skeleton
- // continues from the endpoint; that vector, normalized, is the
- // direction the walker should head when starting from this point.
- let mut skel_endpoints: Vec<(i32, i32)> = Vec::new();
- let mut skel_endpoints_init_dir: Vec<(f32, f32)> = Vec::new();
- for &(x, y) in &skel {
- let nbrs = zs_neighbors(x, y);
- let in_skel: Vec<(u32, u32)> = nbrs.iter().filter(|n| skel.contains(n)).copied().collect();
- if in_skel.len() != 1 { continue; }
- let nbr = in_skel[0];
- let dx = nbr.0 as f32 - x as f32;
- let dy = nbr.1 as f32 - y as f32;
- let mag = (dx * dx + dy * dy).sqrt().max(1e-6);
- skel_endpoints.push((x as i32, y as i32));
- skel_endpoints_init_dir.push((dx / mag, dy / mag));
- }
- // Bit-pack the skeleton in the same coord system as was_ink so
- // the debug renderer can paint it as a per-pixel overlay.
- let mut skeleton = BitMask::new(cells);
- for &(x, y) in &skel {
- let lx = x as i32 - bx; let ly = y as i32 - by;
- if lx < 0 || ly < 0 || lx >= width || ly >= height { continue; }
- skeleton.set((ly * width + lx) as usize);
- }
- let (skeleton_segments, skeleton_junctions) = trace_skeleton_segments(&skel);
- let skeleton_length = skel.len() as u32;
- HullData { bx, by, width, height, was_ink, sdf, sdf_values_sorted,
- skel_endpoints, skel_endpoints_init_dir, skeleton,
- skeleton_segments, skeleton_junctions,
- skeleton_length, ink_total: count }
-}
-
-/// Decompose the skeleton into polyline segments connecting "special"
-/// nodes (degree-1 endpoints and degree-≥3 junctions), plus closed
-/// loops for components that have no special nodes (O / 0 / D-style
-/// closed shapes). Walking from each special node along its degree-2
-/// chain neighbors produces one segment per skeleton edge in the
-/// implicit graph; the visited-edge set prevents duplicates.
-fn trace_skeleton_segments(skel: &HashSet<(u32, u32)>)
- -> (Vec>, Vec<(f32, f32)>)
-{
- let neighbors_of = |p: (u32, u32)| -> Vec<(u32, u32)> {
- zs_neighbors(p.0, p.1).into_iter()
- .filter(|n| skel.contains(n))
- .collect()
- };
- let normalize = |a: (u32, u32), b: (u32, u32)| -> ((u32, u32), (u32, u32)) {
- if a <= b { (a, b) } else { (b, a) }
- };
-
- let mut special: HashSet<(u32, u32)> = HashSet::new();
- let mut junctions: Vec<(f32, f32)> = Vec::new();
- for &p in skel {
- let deg = neighbors_of(p).len();
- if deg == 1 || deg >= 3 { special.insert(p); }
- if deg >= 3 { junctions.push((p.0 as f32, p.1 as f32)); }
- }
-
- let mut segments: Vec> = Vec::new();
- let mut visited_edges: HashSet<((u32, u32), (u32, u32))> = HashSet::new();
-
- // Walk one segment per (special-node, neighbor) edge.
- for &s in &special {
- for n in neighbors_of(s) {
- let edge = normalize(s, n);
- if visited_edges.contains(&edge) { continue; }
- visited_edges.insert(edge);
- let mut seg: Vec<(f32, f32)> = vec![
- (s.0 as f32, s.1 as f32),
- (n.0 as f32, n.1 as f32),
- ];
- let mut prev = s;
- let mut cur = n;
- while !special.contains(&cur) {
- let nbrs = neighbors_of(cur);
- let next_opt = nbrs.iter().copied().find(|&p| p != prev);
- let Some(next) = next_opt else { break };
- let next_edge = normalize(cur, next);
- if visited_edges.contains(&next_edge) { break; }
- visited_edges.insert(next_edge);
- seg.push((next.0 as f32, next.1 as f32));
- prev = cur;
- cur = next;
- }
- segments.push(seg);
- }
- }
-
- // Isolated cycles: connected components with NO special nodes
- // (e.g. O's skeleton). Pick any unvisited pixel, walk until we
- // either return to start or run out of unvisited neighbors.
- let mut visited_pixels: HashSet<(u32, u32)> = HashSet::new();
- for seg in &segments {
- for &(x, y) in seg {
- visited_pixels.insert((x as u32, y as u32));
- }
- }
- for &start in skel {
- if visited_pixels.contains(&start) || special.contains(&start) { continue; }
- let mut seg: Vec<(f32, f32)> = vec![(start.0 as f32, start.1 as f32)];
- visited_pixels.insert(start);
- let mut prev: Option<(u32, u32)> = None;
- let mut cur = start;
- loop {
- let nbrs = neighbors_of(cur);
- let next = nbrs.iter().copied()
- .find(|&p| Some(p) != prev && !visited_pixels.contains(&p));
- match next {
- Some(n) => {
- visited_pixels.insert(n);
- seg.push((n.0 as f32, n.1 as f32));
- prev = Some(cur);
- cur = n;
- }
- None => {
- // Close the loop if we're adjacent to start.
- if nbrs.iter().any(|&p| p == start) {
- seg.push((start.0 as f32, start.1 as f32));
- }
- break;
- }
- }
- }
- if seg.len() >= 2 { segments.push(seg); }
- }
-
- (segments, junctions)
-}
-
-// ── Coverage grid: per-call mutable state, sized to the hull's bbox ─────
-
-struct Grid {
- // Bbox is duplicated from `hull` so the disk-iteration hot path
- // doesn't pay an Arc deref on every step.
- bx: i32, by: i32,
- width: i32, height: i32,
- /// Cached hull-derived state: was_ink mask, SDF, skeleton
- /// endpoints. Shared across all `paint_fill_with` calls on the
- /// same hull via Arc — avoids recomputing chamfer + skeleton
- /// per call. Read-only from this struct's perspective.
- hull: Arc,
- /// `true` = ink pixel that hasn't been painted yet. Owned, mutable.
- /// Initialized as a clone of `hull.was_ink`.
- unpainted: BitMask,
- /// Approximate medial-axis length, in pixels. Counted as skeleton
- /// pixel count (each connected skeleton pixel contributes ~1 px to
- /// the centerline length). Used as the "ideal" path length — a
- /// single-stroke trace of the letter should be ≈ this length, with
- /// a budget of ~1.5× before flagging as snake.
- skeleton_length: u32,
- /// Total ink pixel count (for stop-when-fully-covered).
- ink_total: i32,
- /// Currently unpainted ink pixel count.
- ink_remaining: i32,
- /// Brush radius used for disk operations. Set via `set_brush`
- /// before any disk evaluation/painting; populates `disk_offsets`.
- brush_radius: f32,
- /// brush_radius² — precomputed for the inner disk-membership check.
- brush_radius_sq: f32,
- /// Pixel offsets (dx, dy) from a rounded disk center such that the
- /// pixel might land within `brush_radius` of any sub-pixel point
- /// that rounds to that center. The list is a small superset of any
- /// actual disk; the per-pixel distance check inside the disk loops
- /// then prunes to the exact fractional disk for waypoint p. Saves
- /// ~50% of inner-loop iterations vs the bare bounding-box scan.
- disk_offsets: Vec<(i32, i32)>,
-}
-
-impl Grid {
- fn from_hull(hull: &Hull) -> Self {
- Self::from_hull_data(get_or_compute_hull_data(hull))
- }
-
- /// Construct a Grid from an already-fetched HullData. Lets the
- /// caller use the same Arc for cheap SDF-percentile
- /// lookup AND for the Grid, avoiding two cache lookups per call.
- fn from_hull_data(h: Arc) -> Self {
- let unpainted = h.was_ink.clone();
- let ink_total = h.ink_total;
- let bx = h.bx; let by = h.by;
- let width = h.width; let height = h.height;
- let skeleton_length = h.skeleton_length;
- Self {
- bx, by, width, height,
- hull: h,
- unpainted,
- skeleton_length,
- ink_total,
- ink_remaining: ink_total,
- brush_radius: 0.0,
- brush_radius_sq: 0.0,
- disk_offsets: Vec::new(),
- }
- }
-
- /// Configure the disk shape used for evaluate_disk / paint_disk /
- /// measure_sweep_full. Must be called before any of those run.
- /// Computed offsets are a superset of any actual fractional-disk
- /// at this radius: a pixel (dx, dy) is included iff the closest
- /// point of the [-0.5, 0.5)² square around it to the origin is
- /// within `brush_radius`. The inner-loop fractional check then
- /// prunes to the exact disk for waypoint p, so the result is
- /// bit-exact w.r.t. iterating the full bounding box.
- fn set_brush(&mut self, brush_radius: f32) {
- self.brush_radius = brush_radius;
- self.brush_radius_sq = brush_radius * brush_radius;
- let r2 = brush_radius * brush_radius;
- let mask_r = (brush_radius + 0.5).ceil() as i32;
- let mut offsets: Vec<(i32, i32)> = Vec::with_capacity(((2 * mask_r + 1) * (2 * mask_r + 1)) as usize);
- for dy in -mask_r..=mask_r {
- for dx in -mask_r..=mask_r {
- let nx = ((dx.abs() as f32) - 0.5).max(0.0);
- let ny = ((dy.abs() as f32) - 0.5).max(0.0);
- if nx * nx + ny * ny <= r2 {
- offsets.push((dx, dy));
- }
- }
- }
- self.disk_offsets = offsets;
- }
-
- /// Look up SDF at an integer pixel.
- fn sdf_at(&self, x: i32, y: i32) -> f32 {
- let lx = x - self.bx; let ly = y - self.by;
- if lx < 0 || ly < 0 || lx >= self.width || ly >= self.height { return 0.0; }
- self.hull.sdf[(ly * self.width + lx) as usize]
- }
-
- /// Snap a raw pixel position onto the medial-axis ridge by greedy
- /// gradient ascent over a 5×5 window. Window-based (rather than
- /// 4-neighbor) so it can escape a polygon's outer corner where every
- /// 4-neighbor is also boundary (SDF=0) — the top-left of an `I`
- /// being the canonical case: only the diagonal step (1,1) climbs.
- fn snap_to_ridge(&self, p: (f32, f32), max_steps: u32) -> (f32, f32) {
- let mut cur = (p.0.round() as i32, p.1.round() as i32);
- for _ in 0..max_steps {
- let here = self.sdf_at(cur.0, cur.1);
- let mut best = (cur, here);
- // 5x5 window: enough to escape any polygon corner where the
- // immediate 8-neighbors might also be boundary (e.g. a 1-px
- // protrusion at the corner of a thick stroke).
- for dy in -2..=2_i32 {
- for dx in -2..=2_i32 {
- if dx == 0 && dy == 0 { continue; }
- let nx = cur.0 + dx;
- let ny = cur.1 + dy;
- if !self.is_ink(nx, ny) { continue; }
- let v = self.sdf_at(nx, ny);
- if v > best.1 { best = ((nx, ny), v); }
- }
- }
- if best.0 == cur { break; } // local max in this window
- cur = best.0;
- }
- (cur.0 as f32, cur.1 as f32)
- }
-
- /// True iff `(x, y)` is an originally-ink pixel.
- /// 4-connected connected-component analysis on the *currently
- /// unpainted* ink mask. Returns one size per CC, in pixels.
- /// Used for density-aware coverage scoring: a single 30-px cluster
- /// (a missed crossbar tip) is recognisable as missing whereas the
- /// same 30 pixels split into 30 single-pixel scattered slop is just
- /// brush-edge noise.
- fn unpainted_cluster_sizes(&self) -> Vec {
- let n = self.unpainted.len();
- let mut comp_id = vec![-1i32; n];
- let mut sizes: Vec = Vec::new();
- for sy in 0..self.height {
- for sx in 0..self.width {
- let s_idx = (sy * self.width + sx) as usize;
- if !self.unpainted.get(s_idx) || comp_id[s_idx] >= 0 { continue; }
- let id = sizes.len() as i32;
- let mut size = 0u32;
- let mut stack: Vec<(i32, i32)> = vec![(sx, sy)];
- while let Some((cx, cy)) = stack.pop() {
- let cidx = (cy * self.width + cx) as usize;
- if comp_id[cidx] >= 0 { continue; }
- if !self.unpainted.get(cidx) { continue; }
- comp_id[cidx] = id;
- size += 1;
- for (dx, dy) in [(1, 0i32), (-1, 0), (0, 1), (0, -1)] {
- let nx = cx + dx; let ny = cy + dy;
- if nx < 0 || ny < 0 || nx >= self.width || ny >= self.height { continue; }
- let nidx = (ny * self.width + nx) as usize;
- if self.unpainted.get(nidx) && comp_id[nidx] < 0 {
- stack.push((nx, ny));
- }
- }
- }
- sizes.push(size);
- }
- }
- sizes
- }
-
- fn is_ink(&self, x: i32, y: i32) -> bool {
- let lx = x - self.bx; let ly = y - self.by;
- if lx < 0 || ly < 0 || lx >= self.width || ly >= self.height { return false; }
- self.hull.was_ink.get((ly * self.width + lx) as usize)
- }
-
- /// Returns (new_ink, repaint_ink, bg) — pixel counts under disk(p, r):
- /// new_ink: unpainted ink pixels (the score we want to grow)
- /// repaint_ink: ink pixels we already painted (mild penalty)
- /// bg: pixels that were never ink (heavy penalty — these
- /// become visible off-glyph paint on the actual plot)
- /// Does NOT mutate the grid.
- fn evaluate_disk(&self, p: (f32, f32)) -> (i32, i32, i32) {
- let cx_i = p.0.round() as i32;
- let cy_i = p.1.round() as i32;
- let r2 = self.brush_radius_sq;
- let mut new_ink = 0;
- let mut repaint_ink = 0;
- let mut bg = 0;
- for &(dx, dy) in &self.disk_offsets {
- // True distance from float waypoint to integer pixel
- // center — keeps the brush's footprint shifting smoothly
- // with sub-pixel waypoint motion (without this, small
- // brushes paint the same pixels for any sub-pixel step).
- let ddx = (cx_i + dx) as f32 - p.0;
- let ddy = (cy_i + dy) as f32 - p.1;
- if ddx * ddx + ddy * ddy > r2 { continue; }
- let lx = cx_i + dx - self.bx;
- let ly = cy_i + dy - self.by;
- if lx < 0 || ly < 0 || lx >= self.width || ly >= self.height { continue; }
- let idx = (ly * self.width + lx) as usize;
- if self.unpainted.get(idx) {
- new_ink += 1;
- } else if self.hull.was_ink.get(idx) {
- repaint_ink += 1;
- } else {
- bg += 1;
- }
- }
- (new_ink, repaint_ink, bg)
- }
-
- /// Paint a disk: marks ink pixels under it as painted. Returns the
- /// number of ink pixels newly painted.
- fn paint_disk(&mut self, p: (f32, f32)) -> i32 {
- let cx_i = p.0.round() as i32;
- let cy_i = p.1.round() as i32;
- let r2 = self.brush_radius_sq;
- let mut newly = 0;
- for &(dx, dy) in &self.disk_offsets {
- let ddx = (cx_i + dx) as f32 - p.0;
- let ddy = (cy_i + dy) as f32 - p.1;
- if ddx * ddx + ddy * ddy > r2 { continue; }
- let lx = cx_i + dx - self.bx;
- let ly = cy_i + dy - self.by;
- if lx < 0 || ly < 0 || lx >= self.width || ly >= self.height { continue; }
- let idx = (ly * self.width + lx) as usize;
- if self.unpainted.get(idx) {
- self.unpainted.clear(idx);
- newly += 1;
- }
- }
- self.ink_remaining -= newly;
- newly
- }
-
- /// True if (x, y) is an unpainted ink pixel.
- fn is_unpainted(&self, x: i32, y: i32) -> bool {
- let lx = x - self.bx; let ly = y - self.by;
- if lx < 0 || ly < 0 || lx >= self.width || ly >= self.height { return false; }
- self.unpainted.get((ly * self.width + lx) as usize)
- }
-
- /// Pick the next stroke's start by analysing the connected components
- /// of remaining unpainted ink. Components smaller than
- /// `min_component_pixels` are skipped as seed candidates — they're
- /// too small for a separate stroke to walk effectively. They stay in
- /// the unpainted mask so the metric reports them truthfully and the
- /// optimizer sees gaps. The largest substantial component
- /// (writing-order tie-broken: topmost first, then leftmost) yields
- /// the seed; we use its highest-SDF interior pixel and then
- /// ridge-snap so the brush starts on the centerline.
- ///
- /// Returns `None` once nothing remains worth painting, which lets
- /// `paint_fill` exit cleanly instead of burning through max_strokes
- /// on phantom 1-px gap attempts.
- fn pick_next_component(&mut self, min_component_pixels: u32,
- debug_components: Option<&mut Vec>)
- -> Option<((f32, f32), (f32, f32), (f32, f32))> // (snapped, init_dir, raw)
- {
- let mut comp_id = vec![-1i32; self.unpainted.len()];
- let mut components: Vec<(Vec, (i32, i32, i32, i32))> = Vec::new();
- // (pixel indices, top, left, bottom, right) per component
-
- for sy in 0..self.height {
- for sx in 0..self.width {
- let s_idx = (sy * self.width + sx) as usize;
- if !self.unpainted.get(s_idx) || comp_id[s_idx] >= 0 { continue; }
- let id = components.len() as i32;
- let mut pixels: Vec = Vec::new();
- let (mut top, mut left, mut bot, mut right) = (sy, sx, sy, sx);
- let mut stack = vec![(sx, sy)];
- while let Some((cx, cy)) = stack.pop() {
- let cidx = (cy * self.width + cx) as usize;
- if comp_id[cidx] >= 0 { continue; }
- if !self.unpainted.get(cidx) { continue; }
- comp_id[cidx] = id;
- pixels.push(cidx);
- if cy < top { top = cy; }
- if cy > bot { bot = cy; }
- if cx < left { left = cx; }
- if cx > right { right = cx; }
- for (dx, dy) in [(1, 0), (-1, 0), (0, 1), (0, -1)] {
- let nx = cx + dx; let ny = cy + dy;
- if nx < 0 || ny < 0 || nx >= self.width || ny >= self.height { continue; }
- let nidx = (ny * self.width + nx) as usize;
- if self.unpainted.get(nidx) && comp_id[nidx] < 0 {
- stack.push((nx, ny));
- }
- }
- }
- components.push((pixels, (top, left, bot, right)));
- }
- }
- if components.is_empty() { return None; }
-
- // Skip sub-threshold components as seed candidates — they're
- // too small for a separate stroke to walk effectively. Leave
- // them in the unpainted mask so they count against coverage and
- // the optimizer sees them. Above-threshold components compete
- // for the seed via writing-order tie-break.
- let mut best: Option<(usize, (i32, i32))> = None; // (component_idx, (top, left))
- for (i, (pixels, (top, left, _, _))) in components.iter().enumerate() {
- if (pixels.len() as u32) < min_component_pixels { continue; }
- // Writing-order priority: topmost; then leftmost.
- match best {
- None => best = Some((i, (*top, *left))),
- Some((_, (bt, bl))) if *top < bt - 3 || (((top - bt).abs() <= 3) && *left < bl) => {
- best = Some((i, (*top, *left)));
- }
- _ => {}
- }
- }
- let chosen = match best { Some((i, _)) => i, None => {
- // Even on None-return, fill the debug if requested so the
- // viewer can see why nothing was seeded.
- if let Some(out) = debug_components {
- for (pixels, (top, left, bot, right)) in components.iter() {
- out.push(SeedComponent {
- bbox: [*left + self.bx, *top + self.by,
- *right + self.bx, *bot + self.by],
- pixel_count: pixels.len() as u32,
- substantial: (pixels.len() as u32) >= min_component_pixels,
- chosen: false,
- });
- }
- }
- return None;
- } };
-
- if let Some(out) = debug_components {
- for (i, (pixels, (top, left, bot, right))) in components.iter().enumerate() {
- out.push(SeedComponent {
- bbox: [*left + self.bx, *top + self.by,
- *right + self.bx, *bot + self.by],
- pixel_count: pixels.len() as u32,
- substantial: (pixels.len() as u32) >= min_component_pixels,
- chosen: i == chosen,
- });
- }
- }
-
- // Writing-order start: prefer a skeleton endpoint ("leg") that
- // falls inside the chosen component's still-unpainted ink. These
- // are the natural pen-down anchors — top of B's vertical, A's
- // bottom-left, G's top-right, etc. Pick the topmost-leftmost.
- // Each endpoint also carries its skeleton-tangent init_dir
- // (pointing inward into the letter), so the forward walker
- // heads correctly into the glyph regardless of where on it
- // the endpoint sits. Fall back to the topmost-leftmost ink
- // pixel + downward init_dir if no endpoint is available
- // (closed shapes like O).
- let (pixels, _) = &components[chosen];
- let comp_set: HashSet = pixels.iter().copied().collect();
- let mut best_endpoint: Option<((i32, i32), (f32, f32))> = None;
- for (i, &(ex, ey)) in self.hull.skel_endpoints.iter().enumerate() {
- let lx = ex - self.bx; let ly = ey - self.by;
- if lx < 0 || ly < 0 || lx >= self.width || ly >= self.height { continue; }
- let idx = (ly * self.width + lx) as usize;
- if !comp_set.contains(&idx) { continue; }
- let init_dir = self.hull.skel_endpoints_init_dir
- .get(i).copied().unwrap_or((0.0, 1.0));
- match best_endpoint {
- None => best_endpoint = Some(((ex, ey), init_dir)),
- // Bottommost first, leftmost tiebreaker. For most Latin
- // letters the natural pen-down anchor is at the bottom
- // (M/W/V/U feet, A's foot, vertical-stem letters' base).
- // Top-of-glyph endpoints are still candidates — they
- // just lose to a bottom one when both exist in the
- // same component.
- Some(((bx_e, by_e), _)) if ey > by_e || (ey == by_e && ex < bx_e) => {
- best_endpoint = Some(((ex, ey), init_dir));
- }
- _ => {}
- }
- }
- let (raw, init_dir) = match best_endpoint {
- Some(((ex, ey), d)) => ((ex as f32, ey as f32), d),
- None => {
- let mut best_pixel: (i32, i32) = (i32::MAX, i32::MAX);
- for &idx in pixels {
- let lx = (idx as i32) % self.width;
- let ly = (idx as i32) / self.width;
- let abs = (lx + self.bx, ly + self.by);
- if abs.1 < best_pixel.1 || (abs.1 == best_pixel.1 && abs.0 < best_pixel.0) {
- best_pixel = abs;
- }
- }
- ((best_pixel.0 as f32, best_pixel.1 as f32), (0.0, 1.0))
- }
- };
- Some((self.snap_to_ridge(raw, 16), init_dir, raw))
- }
-}
-
-// ── Vector helpers ──────────────────────────────────────────────────────
-
-fn vec_norm(v: (f32, f32)) -> f32 { (v.0 * v.0 + v.1 * v.1).sqrt() }
-fn vec_unit(v: (f32, f32)) -> (f32, f32) {
- let n = vec_norm(v); if n < 1e-9 { (0.0, 0.0) } else { (v.0 / n, v.1 / n) }
-}
-fn vec_dot(a: (f32, f32), b: (f32, f32)) -> f32 { a.0 * b.0 + a.1 * b.1 }
-
-// ── Trace a single stroke ───────────────────────────────────────────────
-
-/// Score one candidate direction by simulating `lookahead_steps` walks
-/// of the brush along it on a virtual copy of the grid. The bg term is
-/// what prevents corner-cutting: at a bend, the cut-diagonal direction
-/// has a disk straddling both legs (lots of new ink), but the same disk
-/// pokes into bg on the outside of the bend. With walk_bg_penalty
-/// heavy, the cut loses to the inside-corner-following direction.
-/// Walk the brush in one direction from `start` until it dead-ends.
-/// `init_dir` seeds the momentum so the brush prefers a specific
-/// direction at the first step (used for the "walk backwards" pass).
-/// If `trace` is `Some`, every iteration is recorded for visualization.
-fn walk_brush(start: (f32, f32), init_dir: Option<(f32, f32)>,
- grid: &mut Grid, params: &PaintParams, brush_radius: f32,
- trace: Option<&mut WalkTrace>)
- -> Vec<(f32, f32)>
-{
- let step_size = params.step_size_factor * brush_radius;
- let brush_area = std::f32::consts::PI * brush_radius * brush_radius;
- let min_score = params.min_score_factor * brush_area;
-
- let mut p = start;
- let mut path = vec![p];
- grid.paint_disk(p);
-
- let mut prev_dir: Option<(f32, f32)> = init_dir.map(vec_unit);
-
- if let Some(t) = trace.as_deref().map(|t| t as *const _) {
- // SAFETY: just used to verify trace is Some without consuming the
- // mut ref; we use `trace` directly below.
- let _ = t;
- }
-
- let mut step_idx: u32 = 0;
- let mut exit_reason = String::from("max_steps");
- // We need a small dance to keep `trace` as Option<&mut WalkTrace>
- // across the loop without re-borrowing.
- let mut trace = trace;
-
- for _ in 0..params.max_steps_per_stroke {
- let prev_dir_unit = prev_dir.unwrap_or((0.0, 0.0));
- let has_momentum = prev_dir.is_some();
-
- // Sample candidate directions, score each via lookahead.
- // Also collect everything needed for the trace.
- let recording = trace.is_some();
- let mut best: Option<((f32, f32), f32, usize)> = None; // (dir, score, candidate_idx)
- let mut recorded: Vec = if recording {
- Vec::with_capacity(params.n_directions)
- } else { Vec::new() };
-
- for i in 0..params.n_directions {
- let theta = 2.0 * std::f32::consts::PI * i as f32 / params.n_directions as f32;
- let dir = (theta.cos(), theta.sin());
- let probe = (p.0 + dir.0 * step_size, p.1 + dir.1 * step_size);
- let rejected_back = has_momentum && vec_dot(dir, prev_dir_unit) < params.back_dir_cutoff;
- let rejected_off_ink = !grid.is_ink(probe.0.round() as i32, probe.1.round() as i32);
-
- // Compute breakdown either way (cheap-ish; lets the viz show
- // even rejected directions' would-be score).
- // Skip the disk evaluation entirely for rejected candidates
- // when not tracing. The breakdown is only needed to record
- // would-be scores in the debug viewer; the walker never
- // picks a rejected candidate as `best`.
- if !recording && (rejected_back || rejected_off_ink) { continue; }
-
- let (new_ink, repaint, bg) = lookahead_score_breakdown(
- p, dir, grid, params, brush_radius, step_size);
- let momentum_bonus = if has_momentum {
- params.momentum_weight * vec_dot(dir, prev_dir_unit).max(0.0) * brush_area
- } else { 0.0 };
- let score = new_ink
- - params.overpaint_penalty * repaint
- - params.walk_bg_penalty * bg
- + momentum_bonus;
-
- if recording {
- recorded.push(WalkCandidate {
- theta, dir, probe,
- rejected_back, rejected_off_ink,
- new_ink, repaint, bg, momentum_bonus, score,
- });
- }
-
- if rejected_back || rejected_off_ink { continue; }
-
- match best {
- None => best = Some((dir, score, i)),
- Some((_, bs, _)) if score > bs => best = Some((dir, score, i)),
- _ => {}
- }
- }
-
- let (dir, score, best_idx) = match best {
- Some(b) => b,
- None => {
- exit_reason = "no_candidate_passed_filters".into();
- if let Some(t) = trace.as_deref_mut() {
- t.steps.push(WalkStep {
- idx: step_idx, p, prev_dir,
- candidates: recorded,
- chosen: None, new_p: None,
- });
- }
- break;
- }
- };
-
- if score < min_score {
- exit_reason = "score_below_min".into();
- if let Some(t) = trace.as_deref_mut() {
- t.steps.push(WalkStep {
- idx: step_idx, p, prev_dir,
- candidates: recorded,
- chosen: None, new_p: None,
- });
- }
- break;
- }
-
- let new_p = (p.0 + dir.0 * step_size, p.1 + dir.1 * step_size);
-
- if let Some(t) = trace.as_deref_mut() {
- t.steps.push(WalkStep {
- idx: step_idx, p, prev_dir,
- candidates: recorded,
- chosen: Some(best_idx as u32),
- new_p: Some(new_p),
- });
- }
-
- p = new_p;
- path.push(p);
- prev_dir = Some(dir);
- grid.paint_disk(p);
- step_idx += 1;
- }
-
- if let Some(t) = trace.as_deref_mut() {
- t.exit_reason = exit_reason;
- t.path = path.clone();
- }
- path
-}
-
-/// Same scoring math as `lookahead_score`, but returns the per-component
-/// breakdown so the viz can show "this direction wins on new ink, but
-/// loses on bg" etc. without recomputing.
-fn lookahead_score_breakdown(start: (f32, f32), dir: (f32, f32),
- grid: &Grid, params: &PaintParams,
- brush_radius: f32, step_size: f32)
- -> (f32, f32, f32)
-{
- let mut total_new: f32 = 0.0;
- let mut total_repaint: f32 = 0.0;
- let mut total_bg: f32 = 0.0;
- for k in 1..=params.lookahead_steps {
- let p = (start.0 + dir.0 * step_size * k as f32,
- start.1 + dir.1 * step_size * k as f32);
- let (new, repaint, bg) = grid.evaluate_disk(p);
- let weight = 1.0 / (k as f32);
- total_new += new as f32 * weight;
- total_repaint += repaint as f32 * weight;
- total_bg += bg as f32 * weight;
- }
- (total_new, total_repaint, total_bg)
-}
-
-/// Trace one stroke: walk forward from `start`, then walk backward from
-/// `start` (in the opposite of the first step's direction), and stitch
-/// them. Guarantees that even when `pick_start` lands in the middle of a
-/// stroke we still cover BOTH ends — no half-strokes.
-///
-/// After both walks, optionally run a *relaxation* pass that perturbs each
-/// interior waypoint toward nearby unpainted ink. The perturbation is
-/// kept only when net coverage improves (overpaint-aware): pulling the
-/// path slightly into a corner can paint pixels that the greedy walk
-/// missed without losing pixels elsewhere. This folds "spurious cleanup
-/// strokes" back into the main path.
-fn trace_stroke(start: (f32, f32), init_dir: (f32, f32),
- grid: &mut Grid, params: &PaintParams, brush_radius: f32,
- walk_log: Option<&mut Vec>,
- stroke_idx: u32) -> Vec<(f32, f32)>
-{
- let step_size = params.step_size_factor * brush_radius;
- let brush_area = std::f32::consts::PI * brush_radius * brush_radius;
- let min_score = params.min_score_factor * brush_area;
- let mut walk_log = walk_log;
-
- // ── Bidirectional walk ──────────────────────────────────────────────
- // init_dir comes from the seeding step — the skeleton-tangent at the
- // starting endpoint, pointing into the letter. This replaces the old
- // hard-coded downward bias which only worked when the start was at
- // the top of the glyph. Now starting at e.g. M's bottom-left foot
- // walks UP the leg as expected.
- let forward_init = Some(init_dir);
- let mut forward_trace = walk_log.as_ref().map(|_| WalkTrace {
- kind: "forward".into(), stroke_idx, start,
- init_dir: forward_init, brush_radius, step_size, min_score,
- steps: Vec::new(), exit_reason: String::new(), path: Vec::new(),
- });
- let forward = walk_brush(start, forward_init, grid, params, brush_radius,
- forward_trace.as_mut());
- if let (Some(log), Some(t)) = (walk_log.as_deref_mut(), forward_trace.take()) {
- log.push(t);
- }
- if forward.len() < 2 { return forward; }
-
- let dx = forward[1].0 - forward[0].0;
- let dy = forward[1].1 - forward[0].1;
- let mag = (dx * dx + dy * dy).sqrt();
- if mag < 1e-6 {
- return forward;
- }
- let back_init = (-dx / mag, -dy / mag);
- let mut backward_trace = walk_log.as_ref().map(|_| WalkTrace {
- kind: "backward".into(), stroke_idx, start,
- init_dir: Some(back_init), brush_radius, step_size, min_score,
- steps: Vec::new(), exit_reason: String::new(), path: Vec::new(),
- });
- let backward = walk_brush(start, Some(back_init), grid, params, brush_radius,
- backward_trace.as_mut());
- if let (Some(log), Some(t)) = (walk_log.as_deref_mut(), backward_trace.take()) {
- log.push(t);
- }
- if backward.len() < 2 {
- return forward;
- }
- let mut combined: Vec<(f32, f32)> = Vec::with_capacity(forward.len() + backward.len());
- for &p in backward.iter().rev() { combined.push(p); }
- for &p in forward.iter().skip(1) { combined.push(p); }
- combined
-}
-
-// ── Top-level compute ───────────────────────────────────────────────────
-
-pub fn paint_fill(hull: &Hull, _intensity: f32) -> FillResult {
- paint_fill_with(hull, &PaintParams::default())
-}
-
-pub fn paint_fill_with(hull: &Hull, params: &PaintParams) -> FillResult {
- if hull.pixels.is_empty() {
- return FillResult { hull_id: hull.id, strokes: vec![] };
- }
- let h = get_or_compute_hull_data(hull);
- let effective_sdf = h.sdf_percentile_q(params.brush_radius_percentile).max(0.5);
- let brush_radius = params.brush_radius_factor * effective_sdf + params.brush_radius_offset_px;
-
- let mut grid = Grid::from_hull_data(h);
- grid.set_brush(brush_radius);
- let mut strokes: Vec> = Vec::new();
-
- let brush_area = std::f32::consts::PI * brush_radius * brush_radius;
- let min_component_pixels = (params.min_component_factor * brush_area).max(1.0) as u32;
-
- for stroke_idx in 0..params.max_strokes {
- if grid.ink_remaining <= 0 { break; }
- let (start, init_dir, _raw) = match grid.pick_next_component(min_component_pixels, None) {
- Some(s) => s, None => break,
- };
- let path = trace_stroke(start, init_dir, &mut grid, params, brush_radius, None, stroke_idx);
- if path.len() >= 2 {
- let simplified = if params.output_rdp_eps > 0.0 {
- rdp_simplify_f32(&path, params.output_rdp_eps)
- } else { path };
- strokes.push(simplified);
- } else {
- grid.paint_disk(start);
- }
- }
-
- FillResult {
- hull_id: hull.id,
- strokes: strokes.into_iter().filter(|s| s.len() >= 2).collect(),
- }
-}
-
-// ── Optimizer: outer-loop sweep over PaintParams ────────────────────────
-//
-// `paint_fill_with` is a deterministic transform (params → strokes). The
-// sweep wraps it: try a list of param overrides, score each result, keep
-// the best. Pure outer loop — no inner-pipeline changes.
-
-/// Quantitative summary of one fill result. Computed cheaply from a
-/// `FillResult` plus the source hull (the hull is needed to count
-/// background paint, since FillResult only has stroke geometry).
-#[derive(Debug, Clone)]
-pub struct PaintMetrics {
- /// Number of strokes (= pen lifts + 1, or 0 if no strokes).
- pub strokes: u32,
- /// Sum of stroke polyline lengths in pixels.
- pub total_length: f32,
- /// Pixels swept by the brush that were never ink (off-glyph paint).
- pub bg_painted: u32,
- /// Total pixels swept by the brush (ink + bg). Used as denominator
- /// for the bg-rate hard constraint.
- pub total_swept: u32,
- /// Repaint count: extra disk-stamps on ink pixels beyond the first.
- /// Baseline ~3-4× per ink pixel from natural disk-overlap; higher
- /// values mean the path is snaking through already-painted ink.
- pub repaint: u32,
- /// Total ink pixels in the source hull. Used to compute coverage
- /// fraction for hard constraints.
- pub ink_total: u32,
- /// Original ink pixels still uncovered after all strokes.
- pub ink_unpainted: u32,
- /// Approximate medial-axis length of the hull, in pixels. The
- /// "ideal" path length budget — `total_length` should sit close to
- /// this for efficient single-pass tracing.
- pub skeleton_length: u32,
- /// Sizes of unpainted-ink connected components after the algorithm
- /// finishes. Density signal: one 30-px cluster (a real missing
- /// feature) reads worse than thirty 1-px scattered slop pixels even
- /// though both have the same total unpainted count.
- pub unpainted_clusters: Vec,
- /// Sum of absolute angle changes between consecutive segments along
- /// every stroke, in radians. Smooth handwriting has small total
- /// curvature; jagged zigzag accumulates lots.
- pub curvature: f32,
- /// Brush radius the result was generated with (px).
- pub brush_radius: f32,
-}
-
-/// Compute metrics by running the painter. Skips walk-trace
-/// recording and PNG rendering — both are debug-viewer-only and
-/// add ~25% overhead to the optimizer's hot loop.
-pub fn metrics_for(hull: &Hull, params: &PaintParams) -> (FillResult, PaintMetrics) {
- let dbg = paint_fill_debug_inner(hull, params, false, false);
- let strokes = dbg.strokes.iter().filter(|s| s.len() >= 2).cloned().collect::>();
- let total_length: f32 = strokes.iter().map(|s| {
- s.windows(2).map(|w| {
- let dx = w[1].0 - w[0].0;
- let dy = w[1].1 - w[0].1;
- (dx * dx + dy * dy).sqrt()
- }).sum::()
- }).sum();
- // Total absolute angle change across all stroke interiors. Sums
- // |arccos(dot(v_in, v_out)/|v_in||v_out|)| over each interior
- // waypoint. Smooth = low; zigzag = high.
- let curvature: f32 = strokes.iter().map(|s| {
- let mut c = 0.0_f32;
- for i in 1..s.len().saturating_sub(1) {
- let v1 = (s[i].0 - s[i-1].0, s[i].1 - s[i-1].1);
- let v2 = (s[i+1].0 - s[i].0, s[i+1].1 - s[i].1);
- let n1 = (v1.0*v1.0 + v1.1*v1.1).sqrt();
- let n2 = (v2.0*v2.0 + v2.1*v2.1).sqrt();
- if n1 > 1e-6 && n2 > 1e-6 {
- let cos = ((v1.0*v2.0 + v1.1*v2.1) / (n1*n2)).clamp(-1.0, 1.0);
- c += cos.acos();
- }
- }
- c
- }).sum();
- let m = PaintMetrics {
- strokes: strokes.len() as u32,
- total_length,
- bg_painted: dbg.bg_painted,
- total_swept: dbg.total_swept,
- repaint: dbg.repaint,
- ink_total: dbg.ink_total,
- ink_unpainted: dbg.ink_unpainted,
- skeleton_length: dbg.skeleton_length,
- unpainted_clusters: dbg.unpainted_clusters.clone(),
- curvature,
- brush_radius: dbg.brush_radius,
- };
- (FillResult { hull_id: hull.id, strokes }, m)
-}
-
-/// Letters that MUST be drawable in a single stroke. The optimizer uses
-/// this as a hard constraint: any param set producing >1 stroke for any
-/// of these letters takes a heavy score penalty. List is conservative —
-/// each one has a known single-stroke topology (possibly with double-back).
-pub const SINGLE_STROKE_LETTERS: &str = "CGIJLMNOSUVWZcejilosvwz";
-
-/// Letters whose natural human topology is *exactly two* pen strokes.
-/// Crosses (T/t/X/x/+) and Y-junctions where one continuous stroke
-/// would require an unnatural double-back across the cross. Constraint
-/// penalty applies when stroke count ≠ 2.
-pub const TWO_STROKE_LETTERS: &str = "TtXxKkYyFfHh";
-
-/// Letters made entirely of straight strokes — the curvature penalty
-/// fires only on these. Curvy letters (O/S/G/c/e/...) need to bend, so
-/// applying a uniform curvature cost there penalised the natural form.
-/// Excludes ambiguous cases (lowercase k/t/f) where fonts may curve.
-pub const STRAIGHT_STROKE_LETTERS: &str = "AEFHIKLMNTVWXYZilvwxz";
-
-pub fn is_single_stroke_letter(ch: char) -> bool {
- SINGLE_STROKE_LETTERS.contains(ch)
-}
-
-pub fn is_two_stroke_letter(ch: char) -> bool {
- TWO_STROKE_LETTERS.contains(ch)
-}
-
-pub fn is_straight_letter(ch: char) -> bool {
- STRAIGHT_STROKE_LETTERS.contains(ch)
-}
-
-/// Exponential rate for the unpainted-cluster penalty. Per-cluster
-/// cost is `(exp(α × size / brush_area) − 1) × brush_area`. The shape
-/// is scale-invariant in the brush. At α=2.0:
-/// - 1-px cluster ≈ 2 units
-/// - cluster = brush_area → ~6.4 × brush_area
-/// - cluster = 2 × brush_area → ~53 × brush_area
-/// - cluster = 3 × brush_area → ~400 × brush_area
-/// So one missing tail-leg (a multi-brush blob) outweighs hundreds
-/// of single-pixel slop edges, which matches the eye's response.
-pub const UNPAINTED_CLUSTER_ALPHA: f32 = 2.0;
-
-/// Density penalty across one letter's cluster sizes. Same shape used
-/// in `score_weighted` and in `CorpusReport`'s tier-2 aggregate, so the
-/// inner optimiser and the outer lex comparator agree on what "bad
-/// unpainted distribution" means.
-pub fn unpainted_density_score(clusters: &[u32], brush_radius: f32) -> f32 {
- let brush_area = std::f32::consts::PI * brush_radius * brush_radius;
- if brush_area <= 0.0 { return 0.0; }
- clusters.iter().map(|&n| {
- // Clamp the exponent so a runaway cluster doesn't overflow f32
- // (exp(20) ≈ 4.85e8; multiplied by brush_area still finite).
- let exponent = (UNPAINTED_CLUSTER_ALPHA * n as f32 / brush_area).min(20.0);
- (exponent.exp() - 1.0) * brush_area
- }).sum()
-}
-
-/// Letter-aware score: applies the default score plus hard constraint
-/// failures. A config that trips ANY hard ceiling returns f32::MAX so
-/// the optimizer rejects it outright. Soft knobs (brush_size bonus,
-/// length, repaint, curvature) decide between configs that pass all
-/// three hard ceilings.
-///
-/// Hard ceilings (auto-fail):
-/// - bg / total_swept > 5 % (off-glyph paint cap)
-/// - ink_unpainted / ink_total > 5 % (fill-rate floor 95 %)
-/// - total_length > 2 × skeleton_length (path budget cap)
-///
-/// Plus stroke-count penalties (soft but heavy):
-/// - 0 strokes → +200k (refuse "paint nothing")
-/// - SINGLE_STROKE_LETTERS with strokes ≠ 1 → +50k per extra stroke
-pub fn score_for_letter(ch: char, m: &PaintMetrics) -> f32 {
- // Curvature penalty fires only on straight-stroke letters. For
- // curvy letters (O/S/G/c/e/...) bending IS the natural form, so
- // penalising it pushes the optimizer toward zigzag approximations.
- // The length-excess term still keeps wandering paths in check.
- let mut w = ScoreWeights::default();
- if !is_straight_letter(ch) { w.curvature = 0.0; }
- let mut s = score_weighted(m, w);
-
- // Hard-ceiling barriers. They're "soft" in the sense that they're
- // finite (not f32::MAX) so the optimizer can still gradient-descend
- // when every config in the local neighborhood is infeasible — but
- // the coefficient (100M / rate-unit) is large enough that even a
- // 1% violation costs ~1M per letter, dominating any savings the
- // optimizer might find on the soft terms (bg, repaint, length).
- //
- // Calibration: at the 5% unpainted ceiling, jumping to 10% costs
- // 5,000,000 per letter × 64 letters = 320M. The whole corpus's
- // soft-score budget is ~30M. So a sub-ceiling solution is always
- // preferred unless literally no feasible config exists.
- if m.total_swept > 0 {
- let bg_rate = m.bg_painted as f32 / m.total_swept as f32;
- if bg_rate > 0.05 {
- s += 100_000_000.0 * (bg_rate - 0.05);
- }
- }
- // Density-aware unpainted barrier. A cluster bigger than half the
- // brush footprint = a recognisable feature is missing (a crossbar
- // tip, half a loop, etc.). Scattered single-pixel slop never trips
- // this; one 30-px cluster does. Threshold scales with brush.
- let cluster_threshold = 0.5 * std::f32::consts::PI * m.brush_radius * m.brush_radius;
- let max_cluster = m.unpainted_clusters.iter().copied().max().unwrap_or(0) as f32;
- if max_cluster > cluster_threshold {
- s += 1_000_000.0 * (max_cluster - cluster_threshold);
- }
- if m.skeleton_length > 0 && m.total_length > 2.0 * m.skeleton_length as f32 {
- // Length budget: 100k/px above 2× skel. For a 300-px-skeleton
- // letter, exceeding by 100 px costs 10M.
- s += 100_000.0 * (m.total_length - 2.0 * m.skeleton_length as f32);
- }
-
- if m.strokes == 0 {
- s += 200_000.0;
- }
- if is_single_stroke_letter(ch) && m.strokes != 1 {
- let delta = (m.strokes as i64 - 1).abs() as f32;
- s += 50_000.0 * delta;
- }
- if is_two_stroke_letter(ch) && m.strokes != 2 {
- let delta = (m.strokes as i64 - 2).abs() as f32;
- s += 50_000.0 * delta;
- }
- s
-}
-
-/// Default scoring function aligned with the project's stated goals:
-/// - ~zero background drawing (heavy: 10 px-of-stroke per bg pixel)
-/// - fewest strokes possible / fewest pen lifts (heavy: 200 px per stroke)
-/// - shortest path possible (light: 1 per px)
-/// - full coverage is a hard constraint (1000 per unpainted ink pixel)
-///
-/// Lower is better. Tunable via `score_weighted` if you want different
-/// emphasis.
-pub fn default_score(m: &PaintMetrics) -> f32 {
- score_weighted(m, ScoreWeights::default())
-}
-
-#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
-pub struct ScoreWeights {
- pub stroke: f32,
- pub length: f32,
- pub bg: f32,
- pub repaint: f32,
- /// Linear unpainted-pixel cost. Cheap by design — the heavy lifting
- /// is done by `unpainted_density` which super-linearly weights
- /// large clusters. Linear stays for tie-breaking among configs that
- /// have similar density patterns.
- pub unpainted: f32,
- /// Density-aware unpainted cost. Penalty per letter is
- /// `weight × Σ over clusters of size^1.5`. A 30-px cluster (a
- /// recognisable missing feature) costs ~5× a 30-px scattered slop,
- /// matching how visible each is on the printed page.
- pub unpainted_density: f32,
- /// Per-pixel cost of stroke length above 1.5× the skeleton length
- /// (the "ideal" trace). 0 inside budget; ramps up sharply outside.
- pub length_excess: f32,
- /// Per-radian cost of cumulative path curvature. Penalises jagged
- /// zigzag paths.
- pub curvature: f32,
- /// Per-pixel REWARD for brush radius (subtracted from score). Pushes
- /// the optimizer toward bigger brushes — a small brush is penalised
- /// here so it has to *earn* its place by saving more bg/repaint than
- /// the bonus a bigger brush would have collected.
- pub brush_size: f32,
-}
-
-impl Default for ScoreWeights {
- fn default() -> Self {
- // Calibration matches the project's stated preference order:
- // bg paint > pen lift > unpainted ink > path length
- //
- // 1 bg pixel = 50 score units (HEAVY: bg is the worst outcome)
- // 1 pen lift = 500 (one stroke worth 10 bg pixels saved)
- // 1 unpainted = 10 (gaps in nooks/crannies are OK)
- // 1 px length = 1 (length is mostly a tiebreaker)
- //
- // So the sweep prefers a smaller-radius solution that leaves a
- // few unpainted pixels over a larger-radius solution that paints
- // 50× as many bg pixels.
- // Meta-optimizer winning weights (idx 20). Note: meta-opt
- // didn't fix stroke-count constraint failures — those need a
- // larger per-letter penalty in the inner score before they
- // bite the gradient. Soft costs are well-tuned for the
- // tier-1/tier-2 lex objective.
- Self {
- stroke: 844.0,
- length: 8.6,
- bg: 98.0,
- repaint: 8.8,
- unpainted: 70.0,
- unpainted_density: 22.8,
- length_excess: 423.0,
- curvature: 515.0,
- brush_size: 214.0, // (was 2000 — meta dropped pressure
- // letter, +1 px brush = +2000 bonus;
- // vs bg=50/px that's "worth" up to
- // ~40 extra bg pixels per letter. So
- // bg still dominates outright (a
- // config with 100+ extra bg loses),
- // but among configs with comparably
- // low bg the bigger brush wins.
- }
- }
-}
-
-pub fn score_weighted(m: &PaintMetrics, w: ScoreWeights) -> f32 {
- let budget = 1.5 * m.skeleton_length as f32;
- let excess = (m.total_length - budget).max(0.0);
- let density = unpainted_density_score(&m.unpainted_clusters, m.brush_radius);
- w.stroke * m.strokes as f32
- + w.length * m.total_length
- + w.bg * m.bg_painted as f32
- + w.repaint * m.repaint as f32
- + w.unpainted * m.ink_unpainted as f32
- + w.unpainted_density * density
- + w.length_excess * excess
- + w.curvature * m.curvature
- - w.brush_size * m.brush_radius
-}
-
-/// Internal: do the painting and produce a fully-populated PaintDebug.
-/// `record_walks` enables the WalkTrace step recording (heavy — also
-/// triggers per-candidate breakdown work in walk_brush). `render_pngs`
-/// enables base64 PNG encoding for the frontend overlays. Both
-/// default-off paths are taken by `metrics_for`, the optimizer's
-/// per-call entry, where neither output is read — that path runs
-/// noticeably faster as a result.
-fn paint_fill_debug_inner(hull: &Hull, params: &PaintParams,
- record_walks: bool, render_pngs: bool) -> PaintDebug {
- let bounds = [
- hull.bounds.x_min as f32, hull.bounds.y_min as f32,
- hull.bounds.x_max as f32, hull.bounds.y_max as f32,
- ];
- let h = get_or_compute_hull_data(hull);
- let sdf_max = h.sdf_values_sorted.last().copied().unwrap_or(0.0).max(0.5);
- let effective_sdf = h.sdf_percentile_q(params.brush_radius_percentile).max(0.5);
- let brush_radius = params.brush_radius_factor * effective_sdf + params.brush_radius_offset_px;
-
- let mut grid = Grid::from_hull_data(h);
- grid.set_brush(brush_radius);
- let mut trajectories: Vec> = Vec::new();
- let mut starts: Vec<(f32, f32)> = Vec::new();
-
- let brush_area = std::f32::consts::PI * brush_radius * brush_radius;
- let min_component_pixels = (params.min_component_factor * brush_area).max(1.0) as u32;
-
- let mut walks: Vec = Vec::new();
- let mut stroke_seedings: Vec = Vec::new();
- let mut unpainted_snapshots: Vec = Vec::new();
- for stroke_idx in 0..params.max_strokes {
- if grid.ink_remaining <= 0 { break; }
- // Capture the unpainted mask BEFORE this stroke walks, so the
- // viewer can scrub through "what the walker saw at each step".
- if render_pngs {
- unpainted_snapshots.push(encode_grid_unpainted_b64(&grid));
- }
- let mut comps_dbg: Vec = Vec::new();
- let comps_out: Option<&mut Vec> = if record_walks {
- Some(&mut comps_dbg)
- } else { None };
- let pnc = grid.pick_next_component(min_component_pixels, comps_out);
- if record_walks {
- // Record the seeding decision (even if it returned None —
- // tells the viewer "no substantial component left").
- let (snapped, init_dir, raw) = match pnc {
- Some(s) => s,
- None => ((0.0, 0.0), (0.0, 0.0), (0.0, 0.0)),
- };
- stroke_seedings.push(StrokeSeeding {
- stroke_idx,
- min_component_pixels,
- raw_start: raw,
- snapped_start: snapped,
- init_dir,
- components: std::mem::take(&mut comps_dbg),
- });
- }
- let (start, init_dir, _raw) = match pnc { Some(s) => s, None => break };
- let walk_log = if record_walks { Some(&mut walks) } else { None };
- let path = trace_stroke(start, init_dir, &mut grid, params, brush_radius,
- walk_log, stroke_idx);
- if path.len() >= 2 {
- // Record path[0] as the "start" — that's where the gcode
- // pen actually comes down.
- starts.push(path[0]);
- trajectories.push(path);
- } else {
- grid.paint_disk(start);
- }
- }
-
- let strokes: Vec> = trajectories.iter()
- .map(|t| if params.output_rdp_eps > 0.0 {
- rdp_simplify_f32(t, params.output_rdp_eps)
- } else { t.clone() })
- .filter(|s| s.len() >= 2)
- .collect();
-
- let ink_unpainted = grid.ink_remaining.max(0) as u32;
- let (bg_painted, total_swept, repaint) = measure_sweep_full(&strokes, &grid);
- let skeleton_length = grid.skeleton_length;
- let unpainted_clusters = grid.unpainted_cluster_sizes();
- let (source_b64, sdf_b64, coverage_b64, skeleton_b64) = if render_pngs {
- (encode_hull_pixels_b64(hull),
- encode_sdf_b64(hull).0,
- encode_coverage_b64(&grid),
- encode_skeleton_b64(&grid.hull))
- } else {
- (String::new(), String::new(), String::new(), String::new())
- };
- let endpoint_arrows: Vec<(f32, f32, f32, f32)> = grid.hull.skel_endpoints.iter()
- .zip(grid.hull.skel_endpoints_init_dir.iter())
- .map(|(&(ex, ey), &(dx, dy))| (ex as f32, ey as f32, dx, dy))
- .collect();
- let skeleton_segments = grid.hull.skeleton_segments.clone();
- let skeleton_junctions = grid.hull.skeleton_junctions.clone();
- let disk_offsets = grid.disk_offsets.clone();
- // Alternative medial-axis algorithms — viz only, computed only when
- // we're rendering PNGs (i.e., the interactive debugger), since the
- // optimizer's per-call hot path doesn't need them.
- let (voronoi_segments, afmm_points) = if render_pngs {
- (voronoi_medial_segments(hull), afmm_medial_points(hull))
- } else {
- (Vec::new(), Vec::new())
- };
- PaintDebug {
- bounds,
- source_b64,
- sdf_b64,
- sdf_max,
- brush_radius,
- coverage_b64,
- ink_total: grid.ink_total.max(0) as u32,
- ink_unpainted,
- bg_painted,
- total_swept,
- repaint,
- skeleton_length,
- unpainted_clusters,
- trajectories,
- strokes,
- start_points: starts,
- walks,
- skeleton_b64,
- skeleton_segments,
- skeleton_junctions,
- endpoint_arrows,
- disk_offsets,
- stroke_seedings,
- unpainted_snapshots,
- voronoi_segments,
- afmm_points,
- }
-}
-
-pub fn paint_fill_debug(hull: &Hull, params: &PaintParams) -> PaintDebug {
- paint_fill_debug_inner(hull, params, true, true)
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use crate::text::{TextBlockSpec, rasterize_blocks};
- use crate::hulls::{extract_hulls, HullParams, Connectivity};
-
- fn rasterize_letter_at(c: char, font_size_mm: f32, dpi: u32, thickness_px: u32)
- -> Vec
- {
- // Canvas sized from the font with generous margins. Hershey's
- // tallest descender chars (`j`) span ~1.7× the nominal font size
- // top-to-bottom; widest caps span ~1.2×. Use 3× the font size
- // each way with a fixed mm pad so we don't crowd the strokes
- // (which can change SDF behaviour at the boundary).
- let pad_mm = font_size_mm.max(2.0);
- let canvas_mm = pad_mm * 2.0 + font_size_mm * 3.0;
- let block = TextBlockSpec {
- text: c.to_string(), font_size_mm,
- line_spacing_mm: None, x_mm: pad_mm, y_mm: pad_mm,
- };
- let rgb = rasterize_blocks(&[block], canvas_mm, canvas_mm, dpi, thickness_px);
- let (w, h) = rgb.dimensions();
- let luma: Vec = rgb.pixels()
- .map(|p| ((p[0] as u32 + p[1] as u32 + p[2] as u32) / 3) as u8)
- .collect();
- let params = HullParams {
- threshold: 253, min_area: 4, rdp_epsilon: 1.5,
- connectivity: Connectivity::Four,
- ..HullParams::default()
- };
- extract_hulls(&luma, &rgb, w, h, ¶ms)
- }
-
- #[test]
- fn paint_no_panic_for_any_printable_ascii() {
- for b in 0x20u8..=0x7E {
- let ch = b as char;
- for h in rasterize_letter_at(ch, 8.0, 200, 4) {
- let _ = paint_fill(&h, 0.0);
- }
- }
- }
-
- /// Print the skeleton endpoints + first-stroke start for one letter.
- /// Run as: cargo test --release --lib paint_diagnose_endpoints -- --ignored --nocapture
- /// Pick char + scale via env: PD_CHAR=M PD_MM=8 PD_DPI=425 PD_THICK=9
- #[test]
- #[ignore]
- fn paint_diagnose_endpoints() {
- let ch: char = std::env::var("PD_CHAR").ok().and_then(|s| s.chars().next()).unwrap_or('M');
- let font_mm: f32 = std::env::var("PD_MM").ok().and_then(|s| s.parse().ok()).unwrap_or(8.0);
- let dpi: u32 = std::env::var("PD_DPI").ok().and_then(|s| s.parse().ok()).unwrap_or(425);
- let thick: u32 = std::env::var("PD_THICK").ok().and_then(|s| s.parse().ok()).unwrap_or(9);
- let hulls = rasterize_letter_at(ch, font_mm, dpi, thick);
- let main = match hulls.iter().max_by_key(|h| h.area) {
- Some(h) => h, None => { println!("no hull"); return; }
- };
- let h = get_or_compute_hull_data(main);
- println!("\n=== '{}' @ {}mm/{}dpi/{}px ===", ch, font_mm, dpi, thick);
- println!("bbox: x [{}, {}], y [{}, {}] w={} h={}",
- main.bounds.x_min, main.bounds.x_max,
- main.bounds.y_min, main.bounds.y_max,
- h.width, h.height);
- println!("skeleton endpoints ({}):", h.skel_endpoints.len());
- for (i, &(ex, ey)) in h.skel_endpoints.iter().enumerate() {
- let d = h.skel_endpoints_init_dir.get(i).copied().unwrap_or((0.0, 0.0));
- println!(" #{} pos=({}, {}) init_dir=({:+.2}, {:+.2})", i, ex, ey, d.0, d.1);
- }
- // Run paint_fill_debug and report the first stroke's start.
- let dbg = paint_fill_debug(main, &PaintParams::default());
- println!("brush_radius = {:.2} px", dbg.brush_radius);
- println!("first stroke starts: {:?}", dbg.start_points.first());
- println!("first walk init_dir: {:?}", dbg.walks.first().map(|w| w.init_dir));
- println!("strokes: {}", dbg.strokes.len());
- for (i, s) in dbg.strokes.iter().enumerate().take(6) {
- if s.is_empty() { continue; }
- println!(" stroke #{}: {} pts, start ({:.1}, {:.1}), end ({:.1}, {:.1})",
- i, s.len(), s[0].0, s[0].1, s.last().unwrap().0, s.last().unwrap().1);
- }
- }
-
- #[test]
- fn paint_letter_I_is_one_stroke() {
- let hulls = rasterize_letter_at('I', 8.0, 200, 4);
- let main = hulls.iter().max_by_key(|h| h.area).unwrap();
- let r = paint_fill(main, 0.0);
- assert_eq!(r.strokes.len(), 1, "expected 1 stroke for 'I', got {}", r.strokes.len());
- }
-
- #[test]
- fn paint_letter_O_is_at_most_two_strokes() {
- // The brush usually stops a few pixels shy of closing, leaving a
- // tiny gap-filler as a second stroke. ≤2 is acceptable; closing
- // the loop exactly is a separate optimization.
- let hulls = rasterize_letter_at('O', 8.0, 200, 4);
- let main = hulls.iter().max_by_key(|h| h.area).unwrap();
- let r = paint_fill(main, 0.0);
- assert!(r.strokes.len() <= 2, "expected ≤2 strokes for 'O', got {}", r.strokes.len());
- }
-
- #[test]
- fn paint_no_phantom_starts() {
- // Every recorded start point must correspond to an output stroke.
- // Phantom starts (where the walk produced a 0-step path) used to
- // pad the debug visualisation with up to 12 spurious pen-downs
- // per glyph. The component-based picker should eliminate them.
- let chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
- let p = PaintParams::default();
- for &(font_mm, dpi, thick) in &[(3.0_f32, 150_u32, 3_u32), (5.0, 200, 4), (8.0, 200, 4)] {
- for ch in chars.chars() {
- let hulls = rasterize_letter_at(ch, font_mm, dpi, thick);
- for h in &hulls {
- let dbg = paint_fill_debug(h, &p);
- assert_eq!(dbg.start_points.len(), dbg.trajectories.len(),
- "'{}' @ {}mm/{}dpi/{}px: {} starts but {} trajectories — phantom start",
- ch, font_mm, dpi, thick,
- dbg.start_points.len(), dbg.trajectories.len());
- }
- }
- }
- }
-
- #[test]
- #[ignore] // bare-walker rebuild in progress; old polish/Dijkstra tests are stale
- fn paint_alphabet_full_coverage() {
- // After all strokes, at least 95% of ink pixels must be painted
- // for every alphanumeric at every test scale. Catches glyphs
- // that fragment correctly but leave whole portions unpainted —
- // 4 was the canonical reported failure case.
- //
- // Includes the user's actual production scale (425 dpi, 9-px
- // thickness, 3mm + 5mm fonts) so failures there get caught here
- // instead of after the fact.
- let chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
- let p = PaintParams::default();
- let mut bad: Vec<(char, f32, u32, u32, u32, f32)> = Vec::new();
- for &(font_mm, dpi, thick) in &[
- (3.0_f32, 150_u32, 3_u32),
- (5.0, 200, 4),
- (8.0, 200, 4),
- (3.0, 425, 9), // user's production setup
- (5.0, 425, 9), // user's production setup
- ] {
- for ch in chars.chars() {
- let hulls = rasterize_letter_at(ch, font_mm, dpi, thick);
- for h in &hulls {
- let dbg = paint_fill_debug(h, &p);
- if dbg.ink_total == 0 { continue; }
- let cov = 1.0 - (dbg.ink_unpainted as f32 / dbg.ink_total as f32);
- if cov < 0.95 {
- bad.push((ch, font_mm, dpi, dbg.ink_total, dbg.ink_unpainted, cov));
- }
- }
- }
- }
- if !bad.is_empty() {
- let report: Vec = bad.iter().map(|&(ch, mm, dpi, total, un, cov)|
- format!("'{}' @ {}mm/{}dpi: {}/{} unpainted ({:.1}% coverage)",
- ch, mm, dpi, un, total, cov * 100.0)
- ).collect();
- panic!("Insufficient coverage:\n {}", report.join("\n "));
- }
- }
-
- #[test]
- fn paint_alphabet_all_waypoints_inside_ink() {
- // Every waypoint of every stroke for every alphanumeric, at every
- // test scale, must lie on an originally-ink pixel. Otherwise the
- // pen plotter literally draws a line outside the glyph.
- let chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
- let p = PaintParams::default();
- let mut bad: Vec<(char, f32, u32, (f32, f32))> = Vec::new();
- for &(font_mm, dpi, thick) in &[
- (3.0_f32, 150_u32, 3_u32),
- (5.0, 200, 4),
- (8.0, 200, 4),
- (3.0, 425, 9),
- (5.0, 425, 9),
- ] {
- for ch in chars.chars() {
- let hulls = rasterize_letter_at(ch, font_mm, dpi, thick);
- for h in &hulls {
- let pixel_set: HashSet<(u32, u32)> =
- h.pixels.iter().copied().collect();
- let r = paint_fill_with(h, &p);
- for stroke in &r.strokes {
- for &(x, y) in stroke {
- // Round to pixel; check it's an ink pixel.
- let px = x.round() as i32;
- let py = y.round() as i32;
- if px < 0 || py < 0 { continue; }
- if !pixel_set.contains(&(px as u32, py as u32)) {
- bad.push((ch, font_mm, dpi, (x, y)));
- break;
- }
- }
- }
- }
- }
- }
- if !bad.is_empty() {
- let report: Vec = bad.iter().take(20).map(|&(ch, mm, dpi, (x, y))|
- format!("'{}' @ {}mm/{}dpi: waypoint ({:.1},{:.1}) outside ink",
- ch, mm, dpi, x, y)
- ).collect();
- panic!("Waypoints outside polygon ({} total):\n {}",
- bad.len(), report.join("\n "));
- }
- }
-
- #[test]
- fn paint_alphabet_off_glyph_under_threshold() {
- // Bg pixels swept ÷ total pixels swept. Substantial hulls (≥150
- // px ink area) must stay under the bar — small components like
- // the i/j dots are excluded from the test, since for those
- // brush_radius >> component_radius and the bg ratio is dominated
- // by unavoidable disk overhang.
- let chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
- let p = PaintParams::default();
- let mut bad: Vec<(char, f32, u32, u32, u32, f32)> = Vec::new();
- for &(font_mm, dpi, thick) in &[
- (3.0_f32, 150_u32, 3_u32),
- (5.0, 200, 4),
- (8.0, 200, 4),
- (3.0, 425, 9),
- (5.0, 425, 9),
- ] {
- for ch in chars.chars() {
- let hulls = rasterize_letter_at(ch, font_mm, dpi, thick);
- for h in &hulls {
- if h.area < 150 { continue; }
- let dbg = paint_fill_debug(h, &p);
- if dbg.total_swept == 0 { continue; }
- let bg_ratio = dbg.bg_painted as f32 / dbg.total_swept as f32;
- if bg_ratio > 0.55 {
- bad.push((ch, font_mm, dpi, dbg.bg_painted,
- dbg.total_swept, bg_ratio));
- }
- }
- }
- }
- if !bad.is_empty() {
- let report: Vec = bad.iter().map(|&(ch, mm, dpi, bg, swept, r)|
- format!("'{}' @ {}mm/{}dpi: {}/{} off-glyph ({:.1}%)",
- ch, mm, dpi, bg, swept, r * 100.0)
- ).collect();
- panic!("Off-glyph brush coverage too high:\n {}", report.join("\n "));
- }
- }
-
- #[test]
- #[ignore] // bare-walker rebuild in progress; reinstate when polish/Dijkstra are added back with tests
- fn paint_alphabet_max_4_strokes() {
- // The user's bound: every alphanumeric should decompose to ≤4
- // strokes at typical font sizes. This is the strict test that
- // pinned the algorithm's correctness.
- let chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
- let p = PaintParams::default();
- let mut bad: Vec<(char, usize, f32, u32)> = Vec::new();
- for &(font_mm, dpi, thick) in &[(3.0_f32, 150_u32, 3_u32), (5.0, 200, 4), (8.0, 200, 4)] {
- for ch in chars.chars() {
- let hulls = rasterize_letter_at(ch, font_mm, dpi, thick);
- let main = match hulls.iter().max_by_key(|h| h.area) {
- Some(h) => h, None => continue
- };
- let r = paint_fill_with(main, &p);
- if r.strokes.len() > 5 { // give 1 over the user's bound for the gap-filler
- bad.push((ch, r.strokes.len(), font_mm, dpi));
- }
- }
- }
- if !bad.is_empty() {
- panic!("Glyphs over 5-stroke bound: {:?}", bad);
- }
- }
-
- /// Coordinate-descent optimization over (almost) the whole PaintParams
- /// surface, parallel-evaluated against a corpus of letters at multiple
- /// scales. Each axis has a list of candidate values; the optimizer
- /// repeatedly sweeps each axis (holding the others at the current
- /// best) and converges when a full pass fails to improve.
- ///
- /// This explores far more parameter combinations than a Cartesian
- /// grid could — for N axes with K candidates each, coordinate
- /// descent visits N × K candidates per pass (vs Kᴺ for the grid),
- /// and the parallel inner loop evaluates each candidate against
- /// the whole corpus in one go.
- /// Outer (meta) optimizer: searches ScoreWeights space and ranks
- /// each candidate by the lexicographic comparator (`compare_reports`)
- /// instead of a hand-tuned weighted sum. The ordering is hard:
- /// fewer letters with feature-sized unpainted clusters > fewer with
- /// >5% bg > fewer single-stroke-letter constraint violations >
- /// fewer two-stroke-letter constraint violations > fewer length
- /// over-budget > then aggregate totals as tiebreakers.
- #[test]
- #[ignore]
- fn paint_meta_optimize() {
- use crate::brush_paint_opt::{run_meta_opt, compare_reports};
-
- let base = PaintParams::default();
- // Smoke-test sizes — change for real runs. With these defaults
- // each meta-iteration is ~25-40s on an 8-core laptop; the full
- // 12×8×3 run takes ~40 min serial (use the SSH orchestrator if
- // you want it faster).
- let n_outer = std::env::var("META_N_OUTER").ok().and_then(|s| s.parse().ok()).unwrap_or(4);
- let n_inner_starts = std::env::var("META_N_INNER").ok().and_then(|s| s.parse().ok()).unwrap_or(4);
- let inner_passes = std::env::var("META_PASSES").ok().and_then(|s| s.parse().ok()).unwrap_or(2);
-
- println!("\n[meta] {} outer × {} inner starts × {} passes",
- n_outer, n_inner_starts, inner_passes);
- let t0 = std::time::Instant::now();
- let results = run_meta_opt(n_outer, n_inner_starts, inner_passes, &base);
- let elapsed = t0.elapsed();
-
- println!("\n[meta] {} results in {:.1}s, lex-sorted best-first:",
- results.len(), elapsed.as_secs_f64());
- for (rank, r) in results.iter().enumerate() {
- println!(" #{:2} idx={:2} {}", rank+1, r.idx, r.report.summary());
- }
- let best = &results[0];
- let _ = compare_reports; // silence unused warn
- println!("\n[meta] BEST WEIGHTS (idx={}):", best.idx);
- println!(" stroke = {:.0}", best.weights.stroke);
- println!(" length = {:.2}", best.weights.length);
- println!(" bg = {:.1}", best.weights.bg);
- println!(" repaint = {:.1}", best.weights.repaint);
- println!(" unpainted = {:.1}", best.weights.unpainted);
- println!(" unpainted_density= {:.2}", best.weights.unpainted_density);
- println!(" length_excess = {:.0}", best.weights.length_excess);
- println!(" curvature = {:.0}", best.weights.curvature);
- println!(" brush_size = {:.0}", best.weights.brush_size);
- println!("\n[meta] BEST PAINT PARAMS:");
- println!(" brush_radius_factor = {:.2}", best.params.brush_radius_factor);
- println!(" brush_radius_offset_px = {:.2}", best.params.brush_radius_offset_px);
- println!(" brush_radius_percentile = {:.2}", best.params.brush_radius_percentile);
- println!(" step_size_factor = {:.2}", best.params.step_size_factor);
- println!(" walk_bg_penalty = {:.2}", best.params.walk_bg_penalty);
- println!(" min_component_factor = {:.2}", best.params.min_component_factor);
- }
-
- #[test]
- #[ignore]
- fn paint_optimize_global_defaults() {
- let cases: &[(f32, u32, u32)] = &[
- (5.0, 200, 4),
- (5.0, 425, 9),
- ];
- let alphabet = "ACGIJLMNOSUVWXZabcdefijlmosuvwxz";
- let base = PaintParams::default();
-
- // Pre-rasterise hulls once.
- let corpus: Vec<(char, Hull)> = cases.iter().flat_map(|&(mm, dpi, t)| {
- alphabet.chars().filter_map(move |ch| {
- let hulls = rasterize_letter_at(ch, mm, dpi, t);
- hulls.into_iter().max_by_key(|h| h.area).map(|h| (ch, h))
- })
- }).collect();
- println!("\n[opt] corpus: {} hulls", corpus.len());
-
- // Score one candidate config against the whole corpus. Parallel
- // over hulls (rayon).
- let eval = |p: &PaintParams| -> f32 {
- corpus.par_iter().map(|(ch, hull)| {
- let (_, m) = metrics_for(hull, p);
- score_for_letter(*ch, &m)
- }).sum()
- };
-
- // Continuous parameter ranges. Each axis has [lo, hi] bounds and
- // an `is_int` flag; ints get rounded after line search. The
- // optimizer samples random starting points uniformly across the
- // joint product of these ranges, then golden-section line-searches
- // each axis to local minimum.
- type Setter = fn(&mut PaintParams, f32);
- type Getter = fn(&PaintParams) -> f32;
- struct Axis {
- name: &'static str,
- lo: f32, hi: f32, is_int: bool,
- set: Setter, get: Getter,
- }
- let axes: Vec = 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: "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: "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 },
- ];
-
- // Cheap deterministic per-thread RNG. Each random start gets a
- // unique seed so they explore different basins.
- 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)
- }
-
- // Apply one axis value to a clone of `params`, evaluating the
- // result. Rounded for int axes (so two nearby reals collapse to
- // the same int eval — fine, golden-section just stops descending).
- 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);
- eval(&p)
- };
-
- // Golden-section line search along one axis. Returns (best_v,
- // best_score). Tries `iters` evaluations; for int axes converges
- // when the search interval collapses to ≤1 unit.
- 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)
- }
- };
-
- // Local refinement from a single starting point. Best-improvement
- // coordinate descent with golden-section line search per axis.
- // Stops when no axis can find a meaningful improvement.
- let refine = |start: &PaintParams| -> (PaintParams, f32, Vec) {
- let mut current = start.clone();
- let mut current_score = eval(¤t);
- let mut log: Vec = vec![format!("start → {:.0}", current_score)];
- let max_passes = 4;
- for _pass in 0..max_passes {
- // Each pass: line-search every axis, take the SINGLE
- // axis whose line minimum drops the score the most.
- let per_axis: Vec<(usize, f32, f32)> = axes.par_iter().enumerate().map(|(ai, axis)| {
- let (v, s) = golden_section(¤t, 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)(¤t), best_v, best_s, current_score - best_s));
- (axis.set)(&mut current, best_v);
- current_score = best_s;
- }
- (current, current_score, log)
- };
-
- let initial_score = eval(&base);
- println!("[opt] initial (base) score = {:.0}", initial_score);
-
- // Build random starting points + the bare default + a few
- // hand-picked seeds (so we explore the space we know about plus
- // novel basins).
- const N_RANDOM_STARTS: usize = 24;
- let mut starts: Vec = Vec::with_capacity(N_RANDOM_STARTS + 4);
- starts.push(base.clone());
- // Same hand-picked diverse-brush seeds as before.
- let mut s = base.clone();
- s.brush_radius_factor = 0.55; s.brush_radius_percentile = 0.85;
- s.min_component_factor = 1.20;
- starts.push(s);
- 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;
- starts.push(s);
- 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;
- starts.push(s);
- for i in 0..N_RANDOM_STARTS {
- let mut state = (i 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);
- }
- starts.push(p);
- }
- println!("[opt] running {} starts ({} random + {} seeded) with golden-section line search",
- starts.len(), N_RANDOM_STARTS, starts.len() - N_RANDOM_STARTS);
-
- // Run all starts in parallel.
- let results: Vec<(PaintParams, f32, Vec)> = starts.par_iter()
- .map(|s| refine(s))
- .collect();
-
- // Pick best.
- let (best_idx, _) = results.iter().enumerate()
- .min_by(|(_, a), (_, b)| a.1.partial_cmp(&b.1).unwrap()).unwrap();
- let (current, current_score, _) = &results[best_idx];
- let current = current.clone();
- let current_score = *current_score;
-
- // Show top 5 starting points with their final scores.
- let mut ranked: Vec<(usize, f32)> = results.iter().enumerate()
- .map(|(i, (_, s, _))| (i, *s)).collect();
- ranked.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap());
- println!("\n=== top 5 results (of {}) ===", starts.len());
- for (rank, (idx, score)) in ranked.iter().take(5).enumerate() {
- let kind = if *idx == 0 { "base" }
- else if *idx < 4 { "seeded" }
- else { "random" };
- println!(" #{} ({:>6}, idx {:>2}): {:.0}", rank + 1, kind, idx, score);
- }
- println!("\n=== best refinement log ===");
- for line in &results[best_idx].2 { println!(" {}", line); }
-
- println!("\n=== best global config (score {:.0} → {:.0}, Δ {:.0}) ===",
- initial_score, current_score, initial_score - current_score);
- println!(" brush_radius_factor = {:.2} (default {:.2})", current.brush_radius_factor, base.brush_radius_factor);
- println!(" brush_radius_offset_px = {:.2} (default {:.2})", current.brush_radius_offset_px, base.brush_radius_offset_px);
- println!(" brush_radius_percentile= {:.3} (default {:.3})", current.brush_radius_percentile, base.brush_radius_percentile);
- println!(" step_size_factor = {:.2} (default {:.2})", current.step_size_factor, base.step_size_factor);
- println!(" n_directions = {} (default {})", current.n_directions, base.n_directions);
- println!(" lookahead_steps = {} (default {})", current.lookahead_steps, base.lookahead_steps);
- println!(" momentum_weight = {:.2} (default {:.2})", current.momentum_weight, base.momentum_weight);
- println!(" overpaint_penalty = {:.3} (default {:.3})", current.overpaint_penalty, base.overpaint_penalty);
- println!(" walk_bg_penalty = {:.2} (default {:.2})", current.walk_bg_penalty, base.walk_bg_penalty);
- println!(" min_score_factor = {:.3} (default {:.3})", current.min_score_factor, base.min_score_factor);
- println!(" min_component_factor = {:.2} (default {:.2})", current.min_component_factor, base.min_component_factor);
- println!(" output_rdp_eps = {:.2} (default {:.2})", current.output_rdp_eps, base.output_rdp_eps);
-
- // Per-letter breakdown at 5mm/425dpi for the constraint set.
- println!("\n=== constraint letters @ 5mm/425dpi ===");
- println!(" letter | strokes | bg | repaint | unp | r");
- for (ch, hull) in &corpus {
- // pick out only 5mm/425dpi entries — chars are just dedup
- // markers per case; we'll only include constraints
- if !is_single_stroke_letter(*ch) { continue; }
- // need to know which scale this hull came from. Hack: use
- // hull.area magnitude as a proxy. Better: re-rasterise.
- let h2 = rasterize_letter_at(*ch, 5.0, 425, 9);
- let main = match h2.into_iter().max_by_key(|h| h.area) {
- Some(h) => h, None => continue
- };
- if main.area != hull.area { continue; } // only the 5mm/425 entry
- let (_, m) = metrics_for(&main, ¤t);
- let flag = if m.strokes > 1 { " ⚠" } else { "" };
- println!(" {} | {:2} | {:4} | {:6} | {:3} | {:.2}{}",
- ch, m.strokes, m.bg_painted, m.repaint, m.ink_unpainted, m.brush_radius, flag);
- }
- }
-
- #[test]
- #[ignore]
- fn paint_sdf_calibration() {
- // Print sdf_max vs nominal stroke width at every test scale, for
- // a single vertical bar 'I'. Tells us the empirical relationship
- // between chamfer-3-4 sdf_max and the actual polygon half-width
- // so we can pick a brush_radius formula that matches.
- for &(font_mm, dpi, thick) in &[
- (3.0_f32, 150_u32, 3_u32),
- (5.0, 200, 4),
- (8.0, 200, 4),
- (3.0, 425, 9),
- (5.0, 425, 9),
- (8.0, 425, 9),
- ] {
- let hulls = rasterize_letter_at('I', font_mm, dpi, thick);
- let main = match hulls.iter().max_by_key(|h| h.area) {
- Some(h) => h, None => continue
- };
- let bw = main.bounds.x_max - main.bounds.x_min;
- let bh = main.bounds.y_max - main.bounds.y_min;
- let pixel_set: HashSet<(u32, u32)> = main.pixels.iter().copied().collect();
- let dist = chamfer_distance(main, &pixel_set);
- let sdf_max = dist.values().cloned().fold(0.0_f32, f32::max);
- // True half-width estimate: median chamfer-distance / 3 of all
- // pixels — gives a sense of how thick the polygon actually is.
- let mut all: Vec = dist.values().cloned().collect();
- all.sort_by(|a, b| a.partial_cmp(b).unwrap());
- let median = if all.is_empty() { 0.0 } else { all[all.len() / 2] };
- // Approximate true half-width = bw/2 (for a vertical bar I,
- // bbox width = stroke thickness exactly).
- let approx_half_width = bw as f32 / 2.0;
- println!("'I' @ {}mm/{}dpi/thick={}: bbox {}x{}, sdf_max={:.2}, median={:.2}, half-width-approx={:.2}, ratio={:.2}",
- font_mm, dpi, thick, bw, bh, sdf_max, median, approx_half_width,
- approx_half_width / sdf_max.max(0.01));
- }
- }
-
- #[test]
- #[ignore]
- fn paint_inspect_4_user_scale() {
- // Inspect '4' at the user's exact production scale (425 dpi, 9-px
- // thickness, 5mm font) — the case they reported "not generating
- // correctly."
- for font_mm in [3.0_f32, 5.0] {
- let hulls = rasterize_letter_at('4', font_mm, 425, 9);
- let main = match hulls.iter().max_by_key(|h| h.area) {
- Some(h) => h, None => { println!("'4' @ {}mm: no hull", font_mm); continue; }
- };
- let bw = main.bounds.x_max - main.bounds.x_min;
- let bh = main.bounds.y_max - main.bounds.y_min;
- println!("\n'4' @ {}mm/425dpi/9px: bbox {}x{}, area {}",
- font_mm, bw, bh, main.area);
- let dbg = paint_fill_debug(main, &PaintParams::default());
- let cov = 1.0 - dbg.ink_unpainted as f32 / dbg.ink_total.max(1) as f32;
- println!(" brush_radius: {:.2}, sdf_max: {:.2}",
- dbg.brush_radius, dbg.sdf_max);
- println!(" starts: {}, trajectories: {}, strokes: {}",
- dbg.start_points.len(), dbg.trajectories.len(), dbg.strokes.len());
- println!(" coverage: {}/{} painted ({:.1}%)",
- dbg.ink_total - dbg.ink_unpainted, dbg.ink_total, cov * 100.0);
- for (i, t) in dbg.trajectories.iter().enumerate() {
- let len: f32 = t.windows(2).map(|w| {
- let dx = w[1].0 - w[0].0; let dy = w[1].1 - w[0].1;
- (dx*dx + dy*dy).sqrt()
- }).sum();
- let s = dbg.start_points.get(i).copied().unwrap_or((0.0, 0.0));
- println!(" [{}] start ({:.1},{:.1}) → {} pts, {:.1}px",
- i, s.0, s.1, t.len(), len);
- }
- }
- }
-
- #[test]
- #[ignore]
- fn paint_inspect_texttest() {
- use crate::text::{TextBlockSpec, rasterize_blocks};
- use crate::hulls::{extract_hulls, HullParams, Connectivity};
- let blocks = vec![
- TextBlockSpec {
- text: "Your Name\n123 Your St\nYour City, ST 12345".into(),
- font_size_mm: 3.0, line_spacing_mm: Some(7.0),
- x_mm: 6.83, y_mm: 6.36,
- },
- TextBlockSpec {
- text: "Recipient Name\n456 Their St\nTheir City, ST 67890".into(),
- font_size_mm: 5.0, line_spacing_mm: Some(10.0),
- x_mm: 74.67, y_mm: 48.05,
- },
- ];
- let dpi = 425;
- let stroke_thickness = ((dpi as f32 / 50.0).round() as u32).max(2);
- let rgb = rasterize_blocks(&blocks, 241.3, 104.775, dpi, stroke_thickness);
- let (w, h) = rgb.dimensions();
- let luma: Vec = rgb.pixels()
- .map(|p| ((p[0] as u32 + p[1] as u32 + p[2] as u32) / 3) as u8)
- .collect();
- // Match the user's saved project (threshold=128, min_area=4,
- // rdp=1.5 from texttest.trac3r), not my prior synthetic defaults.
- let hp = HullParams {
- threshold: 128, min_area: 4, rdp_epsilon: 1.5,
- connectivity: Connectivity::Four,
- ..HullParams::default()
- };
- let hulls = extract_hulls(&luma, &rgb, w, h, &hp);
- let params = PaintParams::default();
-
- // Per-hull breakdown sorted worst-first.
- let mut per_hull: Vec<(usize, usize, u32, Vec)> = Vec::new();
- let mut total = 0;
- let mut total_short = 0;
- let mut total_short_strokes = 0;
- for (i, h) in hulls.iter().enumerate() {
- let r = paint_fill_with(h, ¶ms);
- total += r.strokes.len();
- let lengths: Vec = r.strokes.iter().map(|s| {
- s.windows(2).map(|w| {
- let dx = w[1].0 - w[0].0; let dy = w[1].1 - w[0].1;
- (dx*dx + dy*dy).sqrt()
- }).sum()
- }).collect();
- // Count strokes shorter than 5 px (presumed gap-fillers).
- let short = lengths.iter().filter(|&&l| l < 5.0).count();
- total_short_strokes += short;
- if short > 0 { total_short += 1; }
- per_hull.push((i, r.strokes.len(), h.area, lengths));
- }
- per_hull.sort_by(|a, b| b.1.cmp(&a.1));
-
- println!("\ntexttest @ dpi={}, thickness={}: {} hulls, {} total strokes (avg {:.2})",
- dpi, stroke_thickness, hulls.len(), total, total as f32 / hulls.len() as f32);
- println!("strokes <5px (gap-fillers): {} across {} hulls", total_short_strokes, total_short);
- println!("\nWorst 12 hulls:");
- for &(i, n, area, ref lengths) in per_hull.iter().take(12) {
- let bw = hulls[i].bounds.x_max - hulls[i].bounds.x_min;
- let bh = hulls[i].bounds.y_max - hulls[i].bounds.y_min;
- let lens_str: Vec = lengths.iter().map(|l| format!("{:.0}", l)).collect();
- println!(" hull #{}: {} strokes · area {} bbox {}x{} · lens [{}]",
- i, n, area, bw, bh, lens_str.join(","));
- }
- }
-
- /// Focused diagnostic: M (and a few comparison letters) at 5mm/425dpi.
- /// Dumps SDF distribution stats per hull (max, p99, p95, p90, p80, p50,
- /// mean, mode) and saves a high-resolution PNG with the painted path
- /// overlaid on the ink, so we can scrutinize where the brush picks up
- /// junction-spike clearance and how the walker behaves near corners.
- /// Output: target/paint_report/diag_M.png
- #[test]
- #[ignore]
- fn paint_diag_M_5mm_425dpi() {
- let chars = ['M', 'W', 'V', 'N', 'X'];
- let font_mm = 5.0_f32;
- let dpi = 425;
- let thick = 9;
- let p = PaintParams::default();
-
- let out_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
- .join("target").join("paint_report");
- std::fs::create_dir_all(&out_root).expect("create report dir");
-
- let mut renders: Vec = Vec::new();
- for ch in chars {
- let hulls = rasterize_letter_at(ch, font_mm, dpi, thick);
- if hulls.is_empty() { continue; }
- let main = hulls.iter().max_by_key(|h| h.area).unwrap();
-
- let pixel_set: HashSet<(u32, u32)> = main.pixels.iter().copied().collect();
- let dist = chamfer_distance(main, &pixel_set);
- let mut vals: Vec = dist.values().copied().collect();
- vals.sort_by(|a, b| a.partial_cmp(b).unwrap());
- let n = vals.len();
- let pct = |q: f32| -> f32 {
- if n == 0 { return 0.0; }
- let i = (((n - 1) as f32) * q).round() as usize;
- vals[i.min(n - 1)]
- };
- let mean: f32 = vals.iter().sum::() / n.max(1) as f32;
- let median = pct(0.5);
- // Mode: bin into 0.5-pixel buckets (skip the 0-bucket which is
- // boundary).
- let mut hist: std::collections::HashMap = std::collections::HashMap::new();
- for &v in &vals {
- let bin = (v / 0.5).round() as i32;
- if bin == 0 { continue; }
- *hist.entry(bin).or_insert(0) += 1;
- }
- let mode_bin = hist.iter().max_by_key(|(_, &c)| c).map(|(&b, _)| b).unwrap_or(0);
- let mode = mode_bin as f32 * 0.5;
-
- let dbg = paint_fill_debug(main, &p);
-
- println!("\n'{}' @ {}mm/{}dpi/thick={} ({} hulls, main area={})",
- ch, font_mm, dpi, thick, hulls.len(), main.area);
- println!(" SDF: max={:.2} p99={:.2} p95={:.2} p90={:.2} p80={:.2} median={:.2} mean={:.2} mode={:.2}",
- pct(1.0), pct(0.99), pct(0.95), pct(0.90), pct(0.80), median, mean, mode);
- println!(" brush_r={:.2} (used: p{:.0}={:.2} + offset 0.5)",
- dbg.brush_radius, p.brush_radius_percentile * 100.0,
- pct(p.brush_radius_percentile));
- println!(" strokes={} bg={} swept={} off={:.1}%",
- dbg.trajectories.len(), dbg.bg_painted, dbg.total_swept,
- 100.0 * dbg.bg_painted as f32 / dbg.total_swept.max(1) as f32);
- for (i, t) in dbg.trajectories.iter().enumerate() {
- let len: f32 = t.windows(2).map(|w| {
- let dx = w[1].0 - w[0].0; let dy = w[1].1 - w[0].1;
- (dx*dx + dy*dy).sqrt()
- }).sum();
- println!(" [{}] {} pts, {:.1}px", i, t.len(), len);
- }
-
- // Build GlyphRender for the high-res PNG (re-using the same
- // sweep-replay logic from paint_alphabet_report).
- let bx = main.bounds.x_min as i32;
- let by = main.bounds.y_min as i32;
- let w = (main.bounds.x_max - main.bounds.x_min + 1) as i32;
- let h = (main.bounds.y_max - main.bounds.y_min + 1) as i32;
- let cells = (w * h) as usize;
- let mut was_ink = vec![false; cells];
- let mut painted_ink = vec![false; cells];
- let mut swept_bg = vec![false; cells];
- for &(x, y) in &main.pixels {
- let lx = x as i32 - bx; let ly = y as i32 - by;
- if lx < 0 || ly < 0 || lx >= w || ly >= h { continue; }
- was_ink[(ly * w + lx) as usize] = true;
- }
- let r = (dbg.brush_radius + 1.0).ceil() as i32;
- let r2 = dbg.brush_radius * dbg.brush_radius;
- for stroke in &dbg.strokes {
- for win in stroke.windows(2) {
- let (a, b) = (win[0], win[1]);
- let dx = b.0 - a.0; let dy = b.1 - a.1;
- let len = (dx * dx + dy * dy).sqrt();
- let nseg = (len * 2.0).ceil().max(1.0) as i32;
- for i in 0..=nseg {
- let t = i as f32 / nseg as f32;
- let cx = a.0 + dx * t;
- let cy = a.1 + dy * t;
- let cxi = cx.round() as i32;
- let cyi = cy.round() as i32;
- for ddy in -r..=r {
- for ddx in -r..=r {
- let dxr = (cxi + ddx) as f32 - cx;
- let dyr = (cyi + ddy) as f32 - cy;
- if dxr * dxr + dyr * dyr > r2 { continue; }
- let px = cxi + ddx;
- let py = cyi + ddy;
- let lx = px - bx; let ly = py - by;
- if lx < 0 || ly < 0 || lx >= w || ly >= h { continue; }
- let idx = (ly * w + lx) as usize;
- if was_ink[idx] { painted_ink[idx] = true; }
- else { swept_bg[idx] = true; }
- }
- }
- }
- }
- }
- renders.push(GlyphRender {
- ch, bx, by, w, h, was_ink, painted_ink, swept_bg,
- strokes: dbg.strokes.clone(),
- starts: dbg.start_points.clone(),
- bg: dbg.bg_painted,
- total_swept: dbg.total_swept,
- stroke_count: dbg.trajectories.len() as u32,
- brush_radius: dbg.brush_radius,
- });
- }
-
- // Render BIG: scale=12 so individual brush stamps are clearly visible.
- let composite_path = out_root.join("diag_M.png");
- render_diag_grid(&renders, &composite_path, 12, 5);
- println!("\n📷 Saved: {}", composite_path.display());
- }
-
- /// Like render_alphabet_grid but per-glyph (NO global bbox), 1 row,
- /// configurable scale. Used for big zoomed-in diagnostic dumps.
- fn render_diag_grid(renders: &[GlyphRender], path: &std::path::Path,
- scale: u32, cols: usize) {
- if renders.is_empty() { return; }
- let pad: u32 = 8;
- let label_h: u32 = 22;
- let rows = ((renders.len() + cols - 1) / cols) as u32;
- let cell_w = renders.iter().map(|r| r.w as u32 * scale + pad * 2).max().unwrap();
- let cell_h = renders.iter().map(|r| r.h as u32 * scale + pad * 2 + label_h).max().unwrap();
- let bw = cell_w * cols as u32;
- let bh = cell_h * rows;
- let mut img: image::RgbaImage = image::ImageBuffer::from_pixel(
- bw, bh, image::Rgba([250, 250, 250, 255]));
-
- for (i, r) in renders.iter().enumerate() {
- let col = (i % cols) as u32;
- let row = (i / cols) as u32;
- let cell_x0 = col * cell_w;
- let cell_y0 = row * cell_h;
- let off_x = cell_x0 + pad;
- let off_y = cell_y0 + pad + label_h;
-
- for ly in 0..r.h {
- for lx in 0..r.w {
- let idx = (ly * r.w + lx) as usize;
- let was = r.was_ink[idx];
- let bg_swept = r.swept_bg[idx];
- let ink_done = r.painted_ink[idx];
- let color = if was && ink_done {
- image::Rgba([200, 200, 200, 255])
- } else if was && !ink_done {
- image::Rgba([220, 40, 200, 255])
- } else if !was && bg_swept {
- image::Rgba([240, 60, 60, 255])
- } else {
- continue;
- };
- let px0 = off_x + lx as u32 * scale;
- let py0 = off_y + ly as u32 * scale;
- for dy in 0..scale {
- for dx in 0..scale {
- if px0 + dx < bw && py0 + dy < bh {
- img.put_pixel(px0 + dx, py0 + dy, color);
- }
- }
- }
- }
- }
-
- // Ink edge.
- for ly in 0..r.h {
- for lx in 0..r.w {
- let idx = (ly * r.w + lx) as usize;
- if !r.was_ink[idx] { continue; }
- let neighbors = [(-1_i32, 0_i32), (1, 0), (0, -1), (0, 1)];
- let on_edge = neighbors.iter().any(|&(ndx, ndy)| {
- let nx = lx + ndx; let ny = ly + ndy;
- if nx < 0 || ny < 0 || nx >= r.w || ny >= r.h { return true; }
- !r.was_ink[(ny * r.w + nx) as usize]
- });
- if !on_edge { continue; }
- let px0 = off_x + lx as u32 * scale;
- let py0 = off_y + ly as u32 * scale;
- for dy in 0..scale {
- for dx in 0..scale {
- if px0 + dx < bw && py0 + dy < bh {
- img.put_pixel(px0 + dx, py0 + dy, image::Rgba([60, 60, 60, 255]));
- }
- }
- }
- }
- }
-
- // Brush footprint markers at every waypoint (yellow ring).
- for stroke in &r.strokes {
- for &(wx, wy) in stroke {
- let cx = off_x as f32 + (wx - r.bx as f32) * scale as f32;
- let cy = off_y as f32 + (wy - r.by as f32) * scale as f32;
- let radius_px = r.brush_radius * scale as f32;
- let steps = ((2.0 * std::f32::consts::PI * radius_px) as i32).max(16);
- for k in 0..steps {
- let theta = 2.0 * std::f32::consts::PI * k as f32 / steps as f32;
- let x = cx + radius_px * theta.cos();
- let y = cy + radius_px * theta.sin();
- if x < 0.0 || y < 0.0 || x >= bw as f32 || y >= bh as f32 { continue; }
- img.put_pixel(x as u32, y as u32, image::Rgba([255, 200, 0, 255]));
- }
- }
- }
-
- // Stroke polylines.
- for stroke in &r.strokes {
- for win in stroke.windows(2) {
- let ax = off_x as f32 + (win[0].0 - r.bx as f32) * scale as f32;
- let ay = off_y as f32 + (win[0].1 - r.by as f32) * scale as f32;
- let bx2 = off_x as f32 + (win[1].0 - r.bx as f32) * scale as f32;
- let by2 = off_y as f32 + (win[1].1 - r.by as f32) * scale as f32;
- draw_line(&mut img, ax, ay, bx2, by2, image::Rgba([0, 0, 0, 255]));
- }
- }
-
- // Waypoint dots (small, in stroke order).
- for stroke in &r.strokes {
- for &(wx, wy) in stroke {
- let cx = (off_x as f32 + (wx - r.bx as f32) * scale as f32) as i32;
- let cy = (off_y as f32 + (wy - r.by as f32) * scale as f32) as i32;
- for dy in -1..=1i32 {
- for dx in -1..=1i32 {
- let px = cx + dx; let py = cy + dy;
- if px < 0 || py < 0 || px >= bw as i32 || py >= bh as i32 { continue; }
- img.put_pixel(px as u32, py as u32, image::Rgba([0, 0, 0, 255]));
- }
- }
- }
- }
-
- // Start dots.
- for &(sx, sy) in &r.starts {
- let cx = off_x as f32 + (sx - r.bx as f32) * scale as f32;
- let cy = off_y as f32 + (sy - r.by as f32) * scale as f32;
- let dot = scale as i32;
- for dy in -dot..=dot {
- for dx in -dot..=dot {
- if dx * dx + dy * dy > dot * dot { continue; }
- let px = cx as i32 + dx; let py = cy as i32 + dy;
- if px < 0 || py < 0 || px >= bw as i32 || py >= bh as i32 { continue; }
- img.put_pixel(px as u32, py as u32, image::Rgba([20, 80, 240, 255]));
- }
- }
- }
-
- let off_pct = if r.total_swept > 0 {
- 100.0 * r.bg as f32 / r.total_swept as f32
- } else { 0.0 };
- let label = format!("{} r:{:.2} off:{:.0}% s:{}",
- r.ch, r.brush_radius, off_pct, r.stroke_count);
- draw_text_5x7(&mut img, &label, cell_x0 + pad, cell_y0 + 3,
- image::Rgba([60, 60, 60, 255]));
- }
- img.save(path).ok();
- }
-
- /// Comprehensive report: per-letter stroke count, coverage, off-glyph%,
- /// plus one composite alphabet-grid PNG per scale. Output:
- /// target/paint_report/REPORT.md (per-scale stats tables)
- /// target/paint_report/.png (one composite per scale)
- ///
- /// Image layout (per glyph cell):
- /// • dark gray = original ink polygon outline
- /// • light gray = brush-swept area inside ink (good)
- /// • red = brush-swept area outside ink (off-glyph; bad)
- /// • magenta = unpainted ink (missed coverage)
- /// • black line = final stroke polylines
- /// • blue dot = stroke start (pen-down)
- /// • white text on bg = char + bg% + stroke count
- #[test]
- #[ignore]
- fn paint_alphabet_report() {
- use std::fmt::Write as _;
- let chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
- let p = PaintParams::default();
- let scales: &[(f32, u32, u32)] = &[
- (3.0, 150, 3),
- (5.0, 200, 4),
- (8.0, 200, 4),
- (3.0, 425, 9),
- (5.0, 425, 9),
- (8.0, 425, 9),
- ];
- let out_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
- .join("target").join("paint_report");
- std::fs::create_dir_all(&out_root).expect("create report dir");
-
- let mut summary = String::new();
- writeln!(summary, "# Brush-Paint Alphabet Report\n").unwrap();
- writeln!(summary, "Defaults: percentile-sized brush, walker-only (no polish, no Dijkstra repaint)\n").unwrap();
-
- for &(font_mm, dpi, thick) in scales {
- writeln!(summary, "\n## font={}mm dpi={} thickness={}px\n", font_mm, dpi, thick).unwrap();
- writeln!(summary, "\n", font_mm, dpi, font_mm as u32, dpi).unwrap();
- writeln!(summary, "| char | strokes | ink | painted | cov% | bg | swept | off% | repaint | rep/ink | length | skel | len/skel | curv | brush_r |").unwrap();
- writeln!(summary, "|------|---------|-----|---------|------|----|----|------|---------|---------|--------|------|----------|------|---------|").unwrap();
-
- let mut totals = (0u32, 0u32, 0u32, 0u32, 0u32, 0u32); // strokes, ink, painted, bg, swept, repaint
- let mut over4: Vec<(char, usize)> = Vec::new();
-
- let mut renders: Vec = Vec::new();
-
- for ch in chars.chars() {
- let hulls = rasterize_letter_at(ch, font_mm, dpi, thick);
- if hulls.is_empty() { continue; }
-
- // Character-level bbox = union of all hull bboxes.
- let bx = hulls.iter().map(|h| h.bounds.x_min as i32).min().unwrap();
- let by = hulls.iter().map(|h| h.bounds.y_min as i32).min().unwrap();
- let x_max = hulls.iter().map(|h| h.bounds.x_max as i32).max().unwrap();
- let y_max = hulls.iter().map(|h| h.bounds.y_max as i32).max().unwrap();
- let w = (x_max - bx + 1).max(1);
- let h = (y_max - by + 1).max(1);
- let cells = (w * h) as usize;
- let mut was_ink = vec![false; cells];
- let mut painted_ink = vec![false; cells];
- let mut swept_bg = vec![false; cells];
- let mut strokes_all: Vec> = Vec::new();
- let mut starts_all: Vec<(f32, f32)> = Vec::new();
- let mut bg_total = 0u32;
- let mut swept_total = 0u32;
- let mut repaint_total = 0u32;
- let mut stroke_count = 0u32;
- let mut max_brush_r: f32 = 0.0;
- let mut ink_total = 0u32;
- let mut ink_painted_total = 0u32;
- let mut skel_total = 0u32;
- let mut length_total: f32 = 0.0;
- let mut curvature_total: f32 = 0.0;
-
- for hull in &hulls {
- let dbg = paint_fill_debug(hull, &p);
- let (_, lm) = metrics_for(hull, &p);
- stroke_count += dbg.trajectories.len() as u32;
- bg_total += dbg.bg_painted;
- swept_total += dbg.total_swept;
- repaint_total += dbg.repaint;
- ink_total += dbg.ink_total;
- ink_painted_total += dbg.ink_total - dbg.ink_unpainted;
- skel_total += dbg.skeleton_length;
- length_total += lm.total_length;
- curvature_total += lm.curvature;
- if dbg.brush_radius > max_brush_r { max_brush_r = dbg.brush_radius; }
- for &(x, y) in &hull.pixels {
- let lx = x as i32 - bx; let ly = y as i32 - by;
- if lx < 0 || ly < 0 || lx >= w || ly >= h { continue; }
- was_ink[(ly * w + lx) as usize] = true;
- }
- // Re-sim sweep into char-bbox.
- let r = (dbg.brush_radius + 1.0).ceil() as i32;
- let r2 = dbg.brush_radius * dbg.brush_radius;
- for stroke in &dbg.strokes {
- for win in stroke.windows(2) {
- let (a, b) = (win[0], win[1]);
- let dx = b.0 - a.0; let dy = b.1 - a.1;
- let len = (dx * dx + dy * dy).sqrt();
- let n = (len * 2.0).ceil().max(1.0) as i32;
- for i in 0..=n {
- let t = i as f32 / n as f32;
- let cx = a.0 + dx * t;
- let cy = a.1 + dy * t;
- let cxi = cx.round() as i32;
- let cyi = cy.round() as i32;
- for ddy in -r..=r {
- for ddx in -r..=r {
- let dxr = (cxi + ddx) as f32 - cx;
- let dyr = (cyi + ddy) as f32 - cy;
- if dxr * dxr + dyr * dyr > r2 { continue; }
- let px = cxi + ddx;
- let py = cyi + ddy;
- let lx = px - bx; let ly = py - by;
- if lx < 0 || ly < 0 || lx >= w || ly >= h { continue; }
- let idx = (ly * w + lx) as usize;
- if was_ink[idx] { painted_ink[idx] = true; }
- else { swept_bg[idx] = true; }
- }
- }
- }
- }
- strokes_all.push(stroke.clone());
- }
- for &s in &dbg.start_points { starts_all.push(s); }
- }
-
- let cov_pct = if ink_total > 0 { 100.0 * ink_painted_total as f32 / ink_total as f32 } else { 0.0 };
- let off_pct = if swept_total > 0 { 100.0 * bg_total as f32 / swept_total as f32 } else { 0.0 };
- let rep_per_ink = if ink_painted_total > 0 {
- repaint_total as f32 / ink_painted_total as f32
- } else { 0.0 };
- if stroke_count > 4 { over4.push((ch, stroke_count as usize)); }
- totals.0 += stroke_count;
- totals.1 += ink_total;
- totals.2 += ink_painted_total;
- totals.3 += bg_total;
- totals.4 += swept_total;
- totals.5 += repaint_total;
-
- let len_skel = if skel_total > 0 { length_total / skel_total as f32 } else { 0.0 };
- writeln!(summary,
- "| `{}` | {} | {} | {} | {:.1} | {} | {} | {:.1} | {} | {:.2} | {:.0} | {} | {:.2} | {:.1} | {:.2} |",
- ch, stroke_count, ink_total, ink_painted_total, cov_pct,
- bg_total, swept_total, off_pct, repaint_total, rep_per_ink,
- length_total, skel_total, len_skel, curvature_total, max_brush_r).unwrap();
-
- if font_mm == 8.0 && dpi == 425 {
- println!("[debug8] '{}' bbox=({},{})..({},{}) w={} h={}",
- ch, bx, by, bx+w-1, by+h-1, w, h);
- }
- renders.push(GlyphRender {
- ch, bx, by, w, h, was_ink, painted_ink, swept_bg,
- strokes: strokes_all, starts: starts_all,
- bg: bg_total, total_swept: swept_total, stroke_count,
- brush_radius: max_brush_r,
- });
- }
-
- let avg_strokes = totals.0 as f32 / chars.len() as f32;
- let avg_cov = if totals.1 > 0 { 100.0 * totals.2 as f32 / totals.1 as f32 } else { 0.0 };
- let avg_off = if totals.4 > 0 { 100.0 * totals.3 as f32 / totals.4 as f32 } else { 0.0 };
- let avg_rep_per_ink = if totals.2 > 0 { totals.5 as f32 / totals.2 as f32 } else { 0.0 };
- writeln!(summary, "\n**Totals:** {} strokes (avg {:.2}/char), coverage {:.1}%, off-glyph {:.1}%, repaint/ink {:.2}, len/skel avg ?",
- totals.0, avg_strokes, avg_cov, avg_off, avg_rep_per_ink).unwrap();
- if !over4.is_empty() {
- writeln!(summary, "**>4 strokes:** {:?}", over4).unwrap();
- }
-
- // Composite all 62 glyphs onto one image.
- let composite_path = out_root.join(format!("{}mm_{}dpi.png", font_mm as u32, dpi));
- render_alphabet_grid(&renders, &composite_path);
- }
-
- let report_path = out_root.join("REPORT.md");
- std::fs::write(&report_path, &summary).expect("write report");
- println!("\n📋 Report: {}", report_path.display());
- println!("📷 Composite per scale: {}/.png", out_root.display());
- println!("\n{}", summary);
- }
-
- /// Compose the alphabet into one big PNG. Each glyph gets a cell
- /// sized to the largest glyph at this scale; smaller glyphs are
- /// centered inside their cell. 8 columns × ceil(N/8) rows.
- fn render_alphabet_grid(renders: &[GlyphRender], path: &std::path::Path) {
- if renders.is_empty() { return; }
- let scale: u32 = 4;
- let pad: u32 = 4;
- let cols = 8;
- let rows = ((renders.len() + cols - 1) / cols) as u32;
- let label_h: u32 = 18; // 7 rows × 2 scale + a few pad pixels
-
- // Use a single GLOBAL bbox spanning all characters' canvas coordinates.
- // This aligns every glyph to the same baseline/x-origin within its cell —
- // descenders show below, dots show above, and no glyph gets clipped.
- let g_bx = renders.iter().map(|r| r.bx).min().unwrap();
- let g_by = renders.iter().map(|r| r.by).min().unwrap();
- let g_xmax = renders.iter().map(|r| r.bx + r.w - 1).max().unwrap();
- let g_ymax = renders.iter().map(|r| r.by + r.h - 1).max().unwrap();
- let g_w = (g_xmax - g_bx + 1) as u32;
- let g_h = (g_ymax - g_by + 1) as u32;
-
- let cell_w = g_w * scale + pad * 2;
- let cell_h = g_h * scale + pad * 2 + label_h;
-
- let bw = cell_w * cols as u32;
- let bh = cell_h * rows;
- let mut img: image::RgbaImage = image::ImageBuffer::from_pixel(
- bw, bh, image::Rgba([250, 250, 250, 255]));
-
- for (i, r) in renders.iter().enumerate() {
- let col = (i % cols) as u32;
- let row = (i / cols) as u32;
- let cell_x0 = col * cell_w;
- let cell_y0 = row * cell_h;
- // Light separator border.
- for x in cell_x0..(cell_x0 + cell_w).min(bw) {
- for y in [cell_y0, (cell_y0 + cell_h - 1).min(bh - 1)] {
- img.put_pixel(x, y, image::Rgba([220, 220, 220, 255]));
- }
- }
- // Origin of the global bbox inside this cell.
- let off_x = cell_x0 + pad;
- let off_y = cell_y0 + pad + label_h;
-
- // Per-character → global-bbox offset (in CHAR-pixel units).
- let dx_global = (r.bx - g_bx) as u32;
- let dy_global = (r.by - g_by) as u32;
-
- // Fill pixel cells (use global-bbox-relative position).
- for ly in 0..r.h {
- for lx in 0..r.w {
- let idx = (ly * r.w + lx) as usize;
- let was_ink = r.was_ink[idx];
- let bg_swept = r.swept_bg[idx];
- let ink_done = r.painted_ink[idx];
- let color = if was_ink && ink_done {
- image::Rgba([200, 200, 200, 255])
- } else if was_ink && !ink_done {
- image::Rgba([220, 40, 200, 255])
- } else if !was_ink && bg_swept {
- image::Rgba([240, 60, 60, 255])
- } else {
- continue;
- };
- let px0 = off_x + (dx_global + lx as u32) * scale;
- let py0 = off_y + (dy_global + ly as u32) * scale;
- for dy in 0..scale {
- for dx in 0..scale {
- if px0 + dx < bw && py0 + dy < bh {
- img.put_pixel(px0 + dx, py0 + dy, color);
- }
- }
- }
- }
- }
-
- // Ink edge outline.
- for ly in 0..r.h {
- for lx in 0..r.w {
- let idx = (ly * r.w + lx) as usize;
- if !r.was_ink[idx] { continue; }
- let neighbors = [(-1_i32, 0_i32), (1, 0), (0, -1), (0, 1)];
- let on_edge = neighbors.iter().any(|&(ndx, ndy)| {
- let nx = lx + ndx; let ny = ly + ndy;
- if nx < 0 || ny < 0 || nx >= r.w || ny >= r.h { return true; }
- !r.was_ink[(ny * r.w + nx) as usize]
- });
- if !on_edge { continue; }
- let px0 = off_x + (dx_global + lx as u32) * scale;
- let py0 = off_y + (dy_global + ly as u32) * scale;
- for dy in 0..scale {
- for dx in 0..scale {
- if px0 + dx < bw && py0 + dy < bh {
- img.put_pixel(px0 + dx, py0 + dy, image::Rgba([80, 80, 80, 255]));
- }
- }
- }
- }
- }
-
- // Stroke polylines (in absolute canvas coords → global bbox).
- for stroke in &r.strokes {
- for win in stroke.windows(2) {
- let ax = off_x as f32 + (win[0].0 - g_bx as f32) * scale as f32;
- let ay = off_y as f32 + (win[0].1 - g_by as f32) * scale as f32;
- let bx = off_x as f32 + (win[1].0 - g_bx as f32) * scale as f32;
- let by = off_y as f32 + (win[1].1 - g_by as f32) * scale as f32;
- draw_line(&mut img, ax, ay, bx, by, image::Rgba([0, 0, 0, 255]));
- }
- }
-
- // Start dots.
- for &(sx, sy) in &r.starts {
- let cx = off_x as f32 + (sx - g_bx as f32) * scale as f32;
- let cy = off_y as f32 + (sy - g_by as f32) * scale as f32;
- let dot = (scale as i32) / 2;
- for dy in -dot..=dot {
- for dx in -dot..=dot {
- if dx * dx + dy * dy > dot * dot { continue; }
- let px = cx as i32 + dx;
- let py = cy as i32 + dy;
- if px < 0 || py < 0 || px >= bw as i32 || py >= bh as i32 { continue; }
- img.put_pixel(px as u32, py as u32, image::Rgba([20, 80, 240, 255]));
- }
- }
- }
-
- // Label: char + off% + strokes — printed as a tiny bitmap top-left.
- let off_pct = if r.total_swept > 0 {
- 100.0 * r.bg as f32 / r.total_swept as f32
- } else { 0.0 };
- let label = format!("{} off:{:.0}% s:{}", r.ch, off_pct, r.stroke_count);
- // Color the label red if the off-glyph % is alarming.
- let label_color = if off_pct > 25.0 { image::Rgba([200, 0, 0, 255]) }
- else { image::Rgba([60, 60, 60, 255]) };
- draw_text_5x7(&mut img, &label, cell_x0 + pad, cell_y0 + 2, label_color);
- }
-
- img.save(path).ok();
- }
-
- /// Bresenham-ish line into an image buffer.
- fn draw_line(img: &mut image::RgbaImage, x0: f32, y0: f32, x1: f32, y1: f32,
- color: image::Rgba) {
- let dx = x1 - x0; let dy = y1 - y0;
- let len = (dx * dx + dy * dy).sqrt().max(1.0);
- let n = len.ceil() as i32;
- for i in 0..=n {
- let t = i as f32 / n as f32;
- let x = (x0 + dx * t) as i32;
- let y = (y0 + dy * t) as i32;
- if x < 0 || y < 0 || x >= img.width() as i32 || y >= img.height() as i32 { continue; }
- img.put_pixel(x as u32, y as u32, color);
- }
- }
-
- /// Tiny 5×7 ASCII bitmap font for cell labels. Only covers the
- /// characters we need (alphanumeric + space + ':' + '%').
- fn draw_text_5x7(img: &mut image::RgbaImage, text: &str, x: u32, y: u32, color: image::Rgba) {
- let s: u32 = 2; // upscale each pixel of the bitmap font
- let mut cx = x;
- for ch in text.chars() {
- let glyph = font_5x7(ch);
- for (row, bits) in glyph.iter().enumerate() {
- for col in 0..5 {
- if bits & (1 << (4 - col)) != 0 {
- let px0 = cx + col * s;
- let py0 = y + row as u32 * s;
- for ddy in 0..s {
- for ddx in 0..s {
- let px = px0 + ddx;
- let py = py0 + ddy;
- if px < img.width() && py < img.height() {
- img.put_pixel(px, py, color);
- }
- }
- }
- }
- }
- }
- cx += 6 * s;
- }
- }
-
- /// Returns 7 rows × 5 bits per row for the requested char (LSB-aligned).
- /// Unknown chars render as a small box.
- fn font_5x7(c: char) -> [u8; 7] {
- match c.to_ascii_uppercase() {
- 'A' => [0b01110, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001],
- 'B' => [0b11110, 0b10001, 0b10001, 0b11110, 0b10001, 0b10001, 0b11110],
- 'C' => [0b01110, 0b10001, 0b10000, 0b10000, 0b10000, 0b10001, 0b01110],
- 'D' => [0b11110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b11110],
- 'E' => [0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b11111],
- 'F' => [0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b10000],
- 'G' => [0b01110, 0b10001, 0b10000, 0b10111, 0b10001, 0b10001, 0b01110],
- 'H' => [0b10001, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001],
- 'I' => [0b01110, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110],
- 'J' => [0b00111, 0b00010, 0b00010, 0b00010, 0b00010, 0b10010, 0b01100],
- 'K' => [0b10001, 0b10010, 0b10100, 0b11000, 0b10100, 0b10010, 0b10001],
- 'L' => [0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b11111],
- 'M' => [0b10001, 0b11011, 0b10101, 0b10101, 0b10001, 0b10001, 0b10001],
- 'N' => [0b10001, 0b11001, 0b10101, 0b10011, 0b10001, 0b10001, 0b10001],
- 'O' => [0b01110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110],
- 'P' => [0b11110, 0b10001, 0b10001, 0b11110, 0b10000, 0b10000, 0b10000],
- 'Q' => [0b01110, 0b10001, 0b10001, 0b10001, 0b10101, 0b10010, 0b01101],
- 'R' => [0b11110, 0b10001, 0b10001, 0b11110, 0b10100, 0b10010, 0b10001],
- 'S' => [0b01111, 0b10000, 0b10000, 0b01110, 0b00001, 0b00001, 0b11110],
- 'T' => [0b11111, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100],
- 'U' => [0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110],
- 'V' => [0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01010, 0b00100],
- 'W' => [0b10001, 0b10001, 0b10001, 0b10101, 0b10101, 0b11011, 0b10001],
- 'X' => [0b10001, 0b10001, 0b01010, 0b00100, 0b01010, 0b10001, 0b10001],
- 'Y' => [0b10001, 0b10001, 0b01010, 0b00100, 0b00100, 0b00100, 0b00100],
- 'Z' => [0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b10000, 0b11111],
- '0' => [0b01110, 0b10001, 0b10011, 0b10101, 0b11001, 0b10001, 0b01110],
- '1' => [0b00100, 0b01100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110],
- '2' => [0b01110, 0b10001, 0b00001, 0b00010, 0b00100, 0b01000, 0b11111],
- '3' => [0b11111, 0b00010, 0b00100, 0b00010, 0b00001, 0b10001, 0b01110],
- '4' => [0b00010, 0b00110, 0b01010, 0b10010, 0b11111, 0b00010, 0b00010],
- '5' => [0b11111, 0b10000, 0b11110, 0b00001, 0b00001, 0b10001, 0b01110],
- '6' => [0b00110, 0b01000, 0b10000, 0b11110, 0b10001, 0b10001, 0b01110],
- '7' => [0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b01000, 0b01000],
- '8' => [0b01110, 0b10001, 0b10001, 0b01110, 0b10001, 0b10001, 0b01110],
- '9' => [0b01110, 0b10001, 0b10001, 0b01111, 0b00001, 0b00010, 0b01100],
- ':' => [0b00000, 0b00100, 0b00100, 0b00000, 0b00100, 0b00100, 0b00000],
- '%' => [0b11000, 0b11001, 0b00010, 0b00100, 0b01000, 0b10011, 0b00011],
- ' ' => [0; 7],
- _ => [0b11111, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b11111],
- }
- }
-
- /// Per-character render data passed from the report into the grid composer.
- struct GlyphRender {
- ch: char,
- bx: i32, by: i32,
- w: i32, h: i32,
- was_ink: Vec,
- painted_ink: Vec,
- swept_bg: Vec,
- strokes: Vec>,
- starts: Vec<(f32, f32)>,
- bg: u32,
- total_swept: u32,
- stroke_count: u32,
- #[allow(dead_code)] brush_radius: f32,
- }
-}
diff --git a/src/brush_paint_opt.rs b/src/brush_paint_opt.rs
deleted file mode 100644
index cc7f25a5..00000000
--- a/src/brush_paint_opt.rs
+++ /dev/null
@@ -1,580 +0,0 @@
-//! 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 std::cmp::Ordering;
-use rayon::prelude::*;
-use serde::{Serialize, Deserialize};
-use crate::brush_paint::{
- PaintParams, PaintMetrics, ScoreWeights,
- score_for_letter, metrics_for, rasterize_test_letter,
- is_single_stroke_letter, is_two_stroke_letter, is_straight_letter,
- unpainted_density_score,
-};
-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 {
- // Bounds tightened to keep the search inside a sane neighbourhood
- // around the original brush-sizing guess. Wider ranges let the
- // optimizer find a "smear" basin (tiny brush + huge pen-lift +
- // low score gate) that covers the corpus by repainting every
- // pixel 7-8× — visually awful even though all metrics pass.
- vec![
- Axis { name: "brush_radius_factor", lo: 0.70, hi: 1.20, is_int: false,
- set: |p, v| p.brush_radius_factor = v, get: |p| p.brush_radius_factor },
- Axis { name: "brush_radius_percentile", lo: 0.85, 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: "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: 3.0, hi: 8.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.05, hi: 0.30, is_int: false,
- set: |p, v| p.min_score_factor = v, get: |p| p.min_score_factor },
- Axis { name: "back_dir_cutoff", lo: -0.95, hi: -0.3, is_int: false,
- set: |p, v| p.back_dir_cutoff = v, get: |p| p.back_dir_cutoff },
- 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: "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),
-];
-// Includes all SINGLE_STROKE_LETTERS, all TWO_STROKE_LETTERS, plus the
-// remaining alphanumerics for breadth. ~52 letters × N scales is the
-// per-inner-eval cost.
-pub const CORPUS_ALPHABET: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
-
-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
- }
- 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
- }
- 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
- }
- _ => {
- 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,
-}
-
-/// 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) {
- 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, ¤t);
- let mut log: Vec = 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(¤t, 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)(¤t), 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 }
-}
-
-// ─── Lexicographic outer-ranking ────────────────────────────────────────
-//
-// The outer optimizer (`run_meta_opt`) needs a way to rank "the result of
-// running the inner optimizer with these ScoreWeights" without using
-// another weighted sum. CorpusReport summarises one full corpus run and
-// `compare_reports` is the lexicographic comparator that orders them.
-//
-// Tier 1 — count of letters violating each hard criterion:
-// 1. max unpainted cluster > 0.5 × brush_area (a feature is missing)
-// 2. SINGLE_STROKE_LETTERS with strokes ≠ 1 (wrong topology)
-// 3. TWO_STROKE_LETTERS with strokes ≠ 2 (wrong topology)
-// 4. total_length > 2 × skeleton_length (wandering path)
-// 5. bg_painted / total_swept > 5 % (off-glyph paint)
-//
-// Order rationale: structural correctness (cluster coverage, stroke
-// topology, path budget) ranks above aesthetic cleanliness (off-glyph
-// paint). A previous run found the comparator was preferring
-// tiny-brush configs that minimised bg paint at the cost of doubling
-// stroke counts and 5×ing path length — bg sat at the wrong tier.
-//
-// Tier 2 — corpus aggregates (smaller is better, in this order):
-// 6. total bg pixels
-// 7. total stroke count
-// 8. total density-weighted unpainted (exponential per-cluster)
-// 9. total repaint
-// 10. total length
-
-#[derive(Debug, Clone, Default, Serialize, Deserialize)]
-pub struct CorpusReport {
- // Tier 1 — counts of failing letters.
- pub fail_coverage: u32, // max cluster > 0.5 × brush_area
- pub fail_bg: u32, // bg/swept > 5%
- pub fail_single_stroke: u32,
- pub fail_two_stroke: u32,
- pub fail_length_budget: u32, // total_length > 2 × skeleton_length
- // Tier 2 — corpus-wide totals (smaller is better).
- pub total_bg: u64,
- pub total_strokes: u64,
- pub total_unpainted_density: f64,
- pub total_repaint: u64,
- pub total_length: f64,
- // Bookkeeping (not used in comparator).
- pub n_letters: u32,
-}
-
-impl CorpusReport {
- pub fn build(letter_metrics: &[(char, PaintMetrics)]) -> Self {
- let mut r = CorpusReport { n_letters: letter_metrics.len() as u32, ..Default::default() };
- for (ch, m) in letter_metrics {
- // Tier 1.
- let cluster_threshold = 0.5 * std::f32::consts::PI * m.brush_radius * m.brush_radius;
- let max_cluster = m.unpainted_clusters.iter().copied().max().unwrap_or(0);
- if (max_cluster as f32) > cluster_threshold { r.fail_coverage += 1; }
-
- if m.total_swept > 0 {
- let bg_rate = m.bg_painted as f32 / m.total_swept as f32;
- if bg_rate > 0.05 { r.fail_bg += 1; }
- }
- if is_single_stroke_letter(*ch) && m.strokes != 1 { r.fail_single_stroke += 1; }
- if is_two_stroke_letter(*ch) && m.strokes != 2 { r.fail_two_stroke += 1; }
- if m.skeleton_length > 0 && m.total_length > 2.0 * m.skeleton_length as f32 {
- r.fail_length_budget += 1;
- }
-
- // Tier 2.
- r.total_bg += m.bg_painted as u64;
- r.total_strokes += m.strokes as u64;
- r.total_repaint += m.repaint as u64;
- r.total_length += m.total_length as f64;
- // Same exponential shape used in `score_weighted` so the
- // inner soft signal and the outer lex ranking agree on what
- // "bad unpainted distribution" means.
- r.total_unpainted_density += unpainted_density_score(
- &m.unpainted_clusters, m.brush_radius
- ) as f64;
- }
- r
- }
-
- /// Sum of all Tier 1 fail counts. Useful as a "feasibility score".
- pub fn tier1_total(&self) -> u32 {
- self.fail_coverage + self.fail_bg + self.fail_single_stroke
- + self.fail_two_stroke + self.fail_length_budget
- }
-
- pub fn summary(&self) -> String {
- format!(
- "T1[cov={} bg={} 1stk={} 2stk={} len={}] T2[bg={} stk={} dens={:.0} rep={} len={:.0}]",
- self.fail_coverage, self.fail_bg, self.fail_single_stroke,
- self.fail_two_stroke, self.fail_length_budget,
- self.total_bg, self.total_strokes, self.total_unpainted_density,
- self.total_repaint, self.total_length,
- )
- }
-}
-
-/// Lexicographic compare. Returns `Less` if `a` is BETTER than `b`
-/// (sorts in ascending order = best first).
-pub fn compare_reports(a: &CorpusReport, b: &CorpusReport) -> Ordering {
- macro_rules! cmp_field { ($f:ident) => {
- match a.$f.cmp(&b.$f) {
- Ordering::Equal => {},
- non_eq => return non_eq,
- }
- }; }
- macro_rules! cmp_float { ($f:ident) => {
- match a.$f.partial_cmp(&b.$f).unwrap_or(Ordering::Equal) {
- Ordering::Equal => {},
- non_eq => return non_eq,
- }
- }; }
- // Tier 1: count of letters failing each hard criterion.
- // Structural fails first; bg-rate last (a soft aesthetic).
- cmp_field!(fail_coverage);
- cmp_field!(fail_single_stroke);
- cmp_field!(fail_two_stroke);
- cmp_field!(fail_length_budget);
- cmp_field!(fail_bg);
- // Tier 2: aggregates.
- cmp_field!(total_bg);
- cmp_field!(total_strokes);
- cmp_float!(total_unpainted_density);
- cmp_field!(total_repaint);
- cmp_float!(total_length);
- Ordering::Equal
-}
-
-/// Run the inner optimizer (multi-start refinement) under given
-/// ScoreWeights, evaluate the resulting params on the corpus, and
-/// return both the best params and a CorpusReport. Used by the
-/// meta-optimizer's outer evaluation.
-pub fn evaluate_score_weights(
- weights: &ScoreWeights,
- corpus: &[(char, Hull)],
- axes: &[Axis],
- base: &PaintParams,
- n_starts: usize,
- max_passes: u32,
-) -> (PaintParams, CorpusReport) {
- // Inner score function uses the supplied weights.
- let inner_score = |p: &PaintParams| -> f32 {
- corpus.par_iter().map(|(ch, hull)| {
- let (_, m) = metrics_for(hull, p);
- let mut s = score_for_letter_with_weights(*ch, &m, weights);
- // score_for_letter already includes the constraint barriers.
- s += 0.0; // placeholder
- s
- }).sum()
- };
-
- 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);
- inner_score(&p)
- };
- let golden = |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 { (if axis.is_int { x1.round() } else { x1 }, f1) }
- else { (if axis.is_int { x2.round() } else { x2 }, f2) }
- };
- let refine = |start: &PaintParams| -> (PaintParams, f32) {
- let mut current = start.clone();
- let mut current_score = inner_score(¤t);
- for _ in 0..max_passes {
- let per_axis: Vec<(usize, f32, f32)> = axes.par_iter().enumerate().map(|(ai, ax)| {
- let (v, s) = golden(¤t, ax, 8); // fewer iters than full inner — meta is outer of outer
- (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; }
- (axes[best_ai].set)(&mut current, best_v);
- current_score = best_s;
- }
- (current, current_score)
- };
-
- // Run all starts in parallel. Print a progress dot as each start
- // completes (relative to a Mutex counter) so the user can
- // see something happening during long meta-optimization runs.
- let starts: Vec = (0..n_starts)
- .map(|i| build_start_params(i, base, axes))
- .collect();
- let counter = std::sync::atomic::AtomicUsize::new(0);
- let results: Vec<(PaintParams, f32)> = starts.par_iter().map(|s| {
- let r = refine(s);
- let n = counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
- eprint!("\r[inner] refined {n}/{n_starts}");
- if n == n_starts { eprint!("\n"); }
- r
- }).collect();
- let (best_params, _) = results.into_iter()
- .min_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap()).unwrap();
-
- // Build the CorpusReport on the BEST params.
- let letter_metrics: Vec<(char, PaintMetrics)> = corpus.iter()
- .map(|(ch, hull)| {
- let (_, m) = metrics_for(hull, &best_params);
- (*ch, m)
- }).collect();
- let report = CorpusReport::build(&letter_metrics);
- (best_params, report)
-}
-
-/// Inner score function used during META-OPTIMIZATION. Unlike
-/// `score_for_letter` (the production score), this version DOES NOT
-/// add 100M-magnitude barriers for bg / coverage / length-budget
-/// violations.
-///
-/// Why: the barriers are so large they swamp every soft-weight
-/// difference. With barriers in place, two different ScoreWeights
-/// candidates produce inner-descent results dominated by "minimise
-/// barriers" rather than "minimise the weighted soft score" — so
-/// the inner optimizer converges to identical PaintParams under
-/// most weight choices and the meta search has nothing to compare.
-///
-/// Without barriers, the inner descent is purely guided by the
-/// candidate's ScoreWeights → different weights produce genuinely
-/// different optima → the OUTER lex comparator ranks them by the
-/// hard criteria (tier-1 fail counts) at the end.
-///
-/// Stroke-count penalties stay (they're per-letter natural-form
-/// requirements, not score-vs-feasibility tradeoffs) and the
-/// "refuse zero strokes" pin stays (without it the inner descent
-/// can degenerate to "paint nothing" under low coverage weight).
-fn score_for_letter_with_weights(ch: char, m: &PaintMetrics, w: &ScoreWeights) -> f32 {
- use crate::brush_paint::score_weighted;
- // Curvature is letter-conditional: only straight-stroke glyphs
- // (AEFHIKLMNTVWXYZilvwxz) pay it. Mirror of `score_for_letter`.
- let mut w_local = *w;
- if !is_straight_letter(ch) { w_local.curvature = 0.0; }
- let mut s = score_weighted(m, w_local);
- if m.strokes == 0 { s += 200_000.0; }
- if is_single_stroke_letter(ch) && m.strokes != 1 {
- s += 50_000.0 * ((m.strokes as i64 - 1).abs() as f32);
- }
- if is_two_stroke_letter(ch) && m.strokes != 2 {
- s += 50_000.0 * ((m.strokes as i64 - 2).abs() as f32);
- }
- s
-}
-
-// ─── Meta-optimizer (outer search over ScoreWeights) ────────────────────
-
-/// One outer-sample's outcome: the candidate weights, the best inner
-/// params they produced, and the lexicographic report.
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct MetaResult {
- pub idx: usize,
- pub weights: ScoreWeights,
- pub params: PaintParams,
- pub report: CorpusReport,
-}
-
-/// Sample one ScoreWeights from a deterministic per-index PRNG. The
-/// ranges are picked to roughly bracket the existing defaults at ½×–4×.
-pub fn build_meta_weights(idx: usize) -> ScoreWeights {
- if idx == 0 { return ScoreWeights::default(); }
- let mut state = (idx as u64)
- .wrapping_mul(0xDA942042E4DD58B5)
- .wrapping_add(0xCAFEBABE);
- let 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)
- };
- let unif = |state: &mut u64, lo: f32, hi: f32| lo + next(state) * (hi - lo);
-
- ScoreWeights {
- stroke: unif(&mut state, 100.0, 2000.0),
- length: unif(&mut state, 0.5, 20.0),
- bg: unif(&mut state, 10.0, 200.0),
- repaint: unif(&mut state, 5.0, 100.0),
- unpainted: unif(&mut state, 5.0, 300.0),
- unpainted_density: unif(&mut state, 1.0, 50.0),
- length_excess: unif(&mut state, 50.0, 1500.0),
- curvature: unif(&mut state, 50.0, 2000.0),
- brush_size: unif(&mut state, 0.0, 8000.0),
- }
-}
-
-/// Run the meta-optimizer: try `n_outer` random ScoreWeights, run the
-/// inner optimizer for each, rank lexicographically, return ALL results
-/// sorted best-first.
-///
-/// Progress: prints one line to stderr per outer sample as it
-/// completes — sample idx, elapsed, this-result's tier-1/tier-2
-/// summary, and whether it's the best yet (★ = improved).
-pub fn run_meta_opt(
- n_outer: usize,
- n_inner_starts: usize,
- inner_passes: u32,
- base: &PaintParams,
-) -> Vec {
- let axes = default_axes();
- let corpus = build_corpus();
-
- let t_start = std::time::Instant::now();
- eprintln!("[meta] starting {} outer × {} inner × {} passes",
- n_outer, n_inner_starts, inner_passes);
-
- let mut results: Vec = Vec::with_capacity(n_outer);
- let mut best_so_far_report: Option = None;
- for idx in 0..n_outer {
- let t0 = std::time::Instant::now();
- let weights = build_meta_weights(idx);
- let (params, report) = evaluate_score_weights(
- &weights, &corpus, &axes, base, n_inner_starts, inner_passes
- );
- let dt = t0.elapsed().as_secs_f64();
- let total = t_start.elapsed().as_secs_f64();
- let est_remaining = (total / (idx as f64 + 1.0)) * (n_outer as f64 - idx as f64 - 1.0);
-
- let is_new_best = match &best_so_far_report {
- None => true,
- Some(b) => compare_reports(&report, b) == std::cmp::Ordering::Less,
- };
- let marker = if is_new_best { "★" } else { " " };
- eprintln!(
- "[meta] {}{:3}/{} {:6.1}s {} (total {:.0}s, eta {:.0}s)",
- marker, idx + 1, n_outer, dt, report.summary(), total, est_remaining,
- );
-
- if is_new_best {
- best_so_far_report = Some(report.clone());
- }
- results.push(MetaResult { idx, weights, params, report });
- }
- eprintln!("[meta] done, lex-sorting {} results", results.len());
- results.sort_by(|a, b| compare_reports(&a.report, &b.report));
- results
-}
diff --git a/src/fill.rs b/src/fill.rs
index 12661adb..9efa263f 100644
--- a/src/fill.rs
+++ b/src/fill.rs
@@ -533,74 +533,6 @@ pub fn spiral(hull: &Hull, spacing_px: f32) -> FillResult {
// ── Circle packing ─────────────────────────────────────────────────────────────
-/// Chamfer 3-4 distance transform: cheaper than full Euclidean, but the
-/// 3:4 weights closely approximate (1:√2), so contours are near-circular
-/// instead of L-shaped. Returns scaled distances (units of 1/3 pixel).
-pub(crate) fn chamfer_distance(hull: &Hull, pixel_set: &HashSet<(u32, u32)>) -> HashMap<(u32, u32), f32> {
- if hull.pixels.is_empty() { return HashMap::new(); }
- let inf = i32::MAX / 4;
- let mut bx = u32::MAX;
- let mut by = u32::MAX;
- let mut bx_max = 0u32;
- let mut by_max = 0u32;
- for &(x, y) in &hull.pixels {
- bx = bx.min(x); by = by.min(y);
- bx_max = bx_max.max(x); by_max = by_max.max(y);
- }
- let w = (bx_max - bx + 1) as usize;
- let h = (by_max - by + 1) as usize;
- let mut grid: Vec = vec![inf; w * h];
- let idx = |x: u32, y: u32| -> usize { ((y - by) as usize) * w + (x - bx) as usize };
-
- // Boundary pixels: any pixel whose 4-neighbor is outside the hull.
- for &(x, y) in &hull.pixels {
- let on_boundary = !pixel_set.contains(&(x.wrapping_sub(1), y))
- || !pixel_set.contains(&(x + 1, y))
- || !pixel_set.contains(&(x, y.wrapping_sub(1)))
- || !pixel_set.contains(&(x, y + 1));
- if on_boundary { grid[idx(x, y)] = 0; }
- }
-
- // Forward pass: top-left → bottom-right, examines NW/N/NE/W neighbors.
- for j in 0..h {
- for i in 0..w {
- let here = j * w + i;
- if grid[here] == 0 { continue; }
- let mut best = grid[here];
- if j > 0 {
- if i > 0 { best = best.min(grid[(j-1)*w + i-1].saturating_add(4)); }
- best = best.min(grid[(j-1)*w + i ].saturating_add(3));
- if i+1 < w { best = best.min(grid[(j-1)*w + i+1].saturating_add(4)); }
- }
- if i > 0 { best = best.min(grid[j*w + i-1].saturating_add(3)); }
- grid[here] = best;
- }
- }
- // Backward pass: bottom-right → top-left, examines SE/S/SW/E neighbors.
- for j in (0..h).rev() {
- for i in (0..w).rev() {
- let here = j * w + i;
- if grid[here] == 0 { continue; }
- let mut best = grid[here];
- if j+1 < h {
- if i+1 < w { best = best.min(grid[(j+1)*w + i+1].saturating_add(4)); }
- best = best.min(grid[(j+1)*w + i ].saturating_add(3));
- if i > 0 { best = best.min(grid[(j+1)*w + i-1].saturating_add(4)); }
- }
- if i+1 < w { best = best.min(grid[j*w + i+1].saturating_add(3)); }
- grid[here] = best;
- }
- }
-
- let mut dist: HashMap<(u32, u32), f32> = HashMap::with_capacity(hull.pixels.len());
- for &(x, y) in &hull.pixels {
- let v = grid[idx(x, y)];
- // Convert chamfer units (3 per orthogonal step) to ~Euclidean pixels.
- dist.insert((x, y), if v >= inf { 0.0 } else { v as f32 / 3.0 });
- }
- dist
-}
-
/// BFS distance transform from the hull boundary (Manhattan approximation).
fn boundary_distance(hull: &Hull, pixel_set: &HashSet<(u32, u32)>) -> HashMap<(u32, u32), f32> {
let mut dist: HashMap<(u32, u32), f32> = HashMap::with_capacity(hull.pixels.len());
@@ -832,478 +764,6 @@ pub fn hilbert_fill(hull: &Hull, spacing_px: f32) -> FillResult {
FillResult { hull_id: hull.id, strokes }
}
-// ── Skeleton (medial axis) fill ─────────────────────────────────────────────────
-//
-// Designed for text-shaped hulls: extract each glyph's centerline as one or
-// more polylines so the pen draws each stroke once instead of hatching/
-// outlining it. Two-stage pipeline:
-// 1. Zhang-Suen iterative thinning erodes the hull boundary symmetrically
-// until only a 1-pixel-wide skeleton remains. Topology is preserved
-// (no holes are punched, no strokes are severed).
-// 2. Graph traversal — endpoints (1 neighbor) and junctions (≥3
-// neighbors) split the skeleton into "branches"; we walk each branch
-// end-to-end. Paired junction continuations could be a follow-up to
-// reduce pen lifts at crossings; this version stops at every junction.
-//
-// The output is jaggy at 1-px resolution; the caller's RDP+Chaikin smoothing
-// (`smooth_fill_result`) cleans it up. `_spacing_px` is unused but matches
-// the dispatch signature shared by all fill strategies.
-
-/// Distance-transform medial axis fill — robust raster→centerline conversion.
-///
-/// Unlike `skeleton_fill` (which uses Zhang-Suen morphological thinning),
-/// this computes the medial axis from the boundary-distance field. A pixel
-/// is part of the medial axis if its distance to the boundary is a local
-/// maximum along at least one axis — exactly the points equidistant from
-/// at least two boundary points.
-///
-/// The key advantage for text: thin tails (e.g. the descender on 'a' or
-/// '9') survive because their centerlines are local distance maxima, even
-/// when very short. ZS thinning, by contrast, erodes such features into
-/// "Y" spurs that subsequently get pruned away.
-///
-/// Pipeline:
-/// 1. Distance transform via `boundary_distance`.
-/// 2. Local-maximum extraction along the four axes (E-W, N-S, NE-SW, NW-SE).
-/// 3. Light ZS thinning to collapse plateaus to 1-px width. We don't
-/// apply spur pruning here — every ridge branch is meaningful.
-/// 4. Same junction-cluster pairing + walk machinery as `skeleton_fill`.
-pub fn centerline_fill(hull: &Hull, _spacing_px: f32) -> FillResult {
- if hull.pixels.is_empty() {
- return FillResult { hull_id: hull.id, strokes: vec![] };
- }
- let pixel_set: HashSet<(u32, u32)> = hull.pixels.iter().copied().collect();
- let dist = chamfer_distance(hull, &pixel_set);
-
- // Extract ridge pixels: local max along at least one of 4 axes.
- // Pixels touching the boundary (distance ~0) are excluded.
- let mut ridge: HashSet<(u32, u32)> = HashSet::new();
- let eps = 1e-3;
- for &p in &pixel_set {
- let dp = match dist.get(&p) { Some(&d) => d, None => continue };
- if dp < 1.0 { continue; } // skip boundary-adjacent pixels
-
- let axes: [((u32, u32), (u32, u32)); 4] = [
- ((p.0 + 1, p.1), (p.0.wrapping_sub(1), p.1)),
- ((p.0, p.1 + 1), (p.0, p.1.wrapping_sub(1))),
- ((p.0 + 1, p.1 + 1), (p.0.wrapping_sub(1), p.1.wrapping_sub(1))),
- ((p.0 + 1, p.1.wrapping_sub(1)), (p.0.wrapping_sub(1), p.1 + 1)),
- ];
- for (n1, n2) in axes {
- let d1 = dist.get(&n1).copied().unwrap_or(0.0);
- let d2 = dist.get(&n2).copied().unwrap_or(0.0);
- if dp + eps >= d1 && dp + eps >= d2 {
- ridge.insert(p);
- break;
- }
- }
- }
-
- // Plateaus can produce thicker-than-1-pixel ridges. Thin to ensure a
- // clean 1-px graph for the walk.
- let ridge_vec: Vec<(u32, u32)> = ridge.into_iter().collect();
- let mut thinned = zhang_suen_thin(&ridge_vec);
- // Junction artifacts are short (1-3 px); real tails (a, 9, etc.) are
- // much longer. Prune the artifacts so X-style intersections collapse
- // to clean crossings instead of fragmenting into many short stubs.
- prune_skeleton_spurs(&mut thinned, /* max_spur_len */ 4);
-
- let pixel_strokes = extract_skeleton_strokes(&thinned);
- // Medial-axis paths are sequences of integer pixel coords that
- // stair-step along diagonals — inherent to the algorithm, not the
- // user's problem. Pre-smooth here so the output is a clean curve
- // before the fill node's user-controlled RDP/Chaikin runs on top.
- let strokes: Vec> = pixel_strokes.into_iter()
- .filter(|s| s.len() >= 3)
- .map(|s| {
- let f: Vec<(f32, f32)> = s.into_iter().map(|(x, y)| (x as f32, y as f32)).collect();
- smooth_stroke(&f, /* rdp_eps */ 1.0, /* chaikin_iters */ 3)
- })
- .filter(|s| s.len() >= 2)
- .collect();
- FillResult { hull_id: hull.id, strokes }
-}
-
-pub fn skeleton_fill(hull: &Hull, _spacing_px: f32) -> FillResult {
- if hull.pixels.is_empty() {
- return FillResult { hull_id: hull.id, strokes: vec![] };
- }
- let mut skeleton = zhang_suen_thin(&hull.pixels);
- // ZS leaves 1-2 pixel "Y" artifacts at every corner of a thick stroke.
- // Pruning them removes the false junctions so the walk only stops at
- // real glyph crossings.
- prune_skeleton_spurs(&mut skeleton, /* max_spur_len */ 3);
- let pixel_strokes = extract_skeleton_strokes(&skeleton);
- // Filter sub-pixel junction artifacts. A 2-point stroke is a single
- // pixel step (~0.17mm at 150 dpi); never a real glyph stroke and
- // always either a cluster-exit fragment or a tiny dangling spur.
- let strokes: Vec> = pixel_strokes.into_iter()
- .filter(|s| s.len() >= 3)
- .map(|s| s.into_iter().map(|(x, y)| (x as f32, y as f32)).collect())
- .collect();
- FillResult { hull_id: hull.id, strokes }
-}
-
-/// Iteratively remove dead-end branches up to `max_spur_len` pixels long.
-/// Pruning a spur can turn its parent junction into an endpoint, exposing
-/// further removable spurs — so we loop until no further removals.
-pub(crate) fn prune_skeleton_spurs(skeleton: &mut HashSet<(u32, u32)>, max_spur_len: usize) {
- fn nbrs_in(p: (u32, u32), skel: &HashSet<(u32, u32)>) -> Vec<(u32, u32)> {
- zs_neighbors(p.0, p.1).into_iter().filter(|n| skel.contains(n)).collect()
- }
- loop {
- let endpoints: Vec<(u32, u32)> = skeleton.iter().copied()
- .filter(|p| nbrs_in(*p, skeleton).len() == 1)
- .collect();
- let mut to_remove: HashSet<(u32, u32)> = HashSet::new();
- for ep in endpoints {
- let mut path = vec![ep];
- let mut prev: Option<(u32, u32)> = None;
- let mut cur = ep;
- for _ in 0..=max_spur_len {
- let nbrs = nbrs_in(cur, skeleton);
- if nbrs.len() >= 3 {
- // Hit a junction within the spur budget — drop the path
- // (excluding the junction pixel).
- if path.len() <= max_spur_len {
- for &p in &path { to_remove.insert(p); }
- }
- break;
- }
- if nbrs.is_empty() { break; }
- let next = nbrs.into_iter().find(|n| Some(*n) != prev);
- match next {
- Some(n) => { prev = Some(cur); cur = n; path.push(cur); }
- None => break,
- }
- }
- }
- if to_remove.is_empty() { break; }
- for p in to_remove { skeleton.remove(&p); }
- }
-}
-
-/// Zhang-Suen 8-neighbor positions in clockwise order starting from north:
-/// index 0..7 == P2, P3, P4, P5, P6, P7, P8, P9.
-/// Underflow on the edges is fine — those positions just won't be in the set.
-pub(crate) fn zs_neighbors(x: u32, y: u32) -> [(u32, u32); 8] {
- [
- (x, y.wrapping_sub(1)),
- (x + 1, y.wrapping_sub(1)),
- (x + 1, y),
- (x + 1, y + 1),
- (x, y + 1),
- (x.wrapping_sub(1), y + 1),
- (x.wrapping_sub(1), y),
- (x.wrapping_sub(1), y.wrapping_sub(1)),
- ]
-}
-
-/// Run Zhang-Suen thinning until idempotent. Two sub-iterations per round
-/// with mirrored conditions keep erosion symmetric.
-pub(crate) fn zhang_suen_thin(pixels: &[(u32, u32)]) -> HashSet<(u32, u32)> {
- let mut current: HashSet<(u32, u32)> = pixels.iter().copied().collect();
- loop {
- let to_remove1 = zs_mark(¤t, true);
- for p in &to_remove1 { current.remove(p); }
- let to_remove2 = zs_mark(¤t, false);
- for p in &to_remove2 { current.remove(p); }
- if to_remove1.is_empty() && to_remove2.is_empty() { break; }
- }
- current
-}
-
-fn zs_mark(set: &HashSet<(u32, u32)>, first_pass: bool) -> Vec<(u32, u32)> {
- let mut to_remove = Vec::new();
- for &(x, y) in set {
- let nbrs = zs_neighbors(x, y);
- let n: [bool; 8] = std::array::from_fn(|i| set.contains(&nbrs[i]));
- let b = n.iter().filter(|&&v| v).count();
- if !(2..=6).contains(&b) { continue; }
- // A(P): number of 0→1 transitions around the 8-ring (P2…P9, wrapping).
- let mut a = 0;
- for i in 0..8 {
- if !n[i] && n[(i + 1) % 8] { a += 1; }
- }
- if a != 1 { continue; }
- // n indices: P2=0 P3=1 P4=2 P5=3 P6=4 P7=5 P8=6 P9=7
- if first_pass {
- if n[0] && n[2] && n[4] { continue; } // P2*P4*P6 ≠ 0
- if n[2] && n[4] && n[6] { continue; } // P4*P6*P8 ≠ 0
- } else {
- if n[0] && n[2] && n[6] { continue; } // P2*P4*P8 ≠ 0
- if n[0] && n[4] && n[6] { continue; } // P2*P6*P8 ≠ 0
- }
- to_remove.push((x, y));
- }
- to_remove
-}
-
-/// Given a 1-px-wide skeleton, walk its connectivity graph and emit one
-/// polyline per logical glyph stroke.
-///
-/// The non-trivial bit is junction handling: ZS thinning of a thick stroke
-/// where two branches meet doesn't produce a single junction pixel — it
-/// produces a small CLUSTER of adjacent junction pixels (T's intersection
-/// is typically 4 pixels wide, X's centre is similar). To get clean
-/// "draw straight through" behaviour we have to pair the cluster's
-/// EXTERNAL exits, not the individual neighbours of one junction pixel.
-///
-/// Process:
-/// 1. Find junction pixels (≥3 8-connected neighbours).
-/// 2. Group them into clusters (8-connected components of junction pixels).
-/// 3. For each cluster, list its exits (external skeleton pixels adjacent
-/// to any cluster pixel). Pair them greedily by direction-from-centroid:
-/// each exit pairs with the one most-opposite, which is the natural
-/// "continues through" partner.
-/// 4. Walk: when a step crosses from external into a cluster, look up the
-/// paired exit, BFS-traverse the cluster to it, emit the through path
-/// as one continuous stroke, mark all touched edges used.
-fn extract_skeleton_strokes(skeleton: &HashSet<(u32, u32)>) -> Vec> {
- if skeleton.is_empty() { return vec![]; }
-
- fn nbrs_in(p: (u32, u32), skel: &HashSet<(u32, u32)>) -> Vec<(u32, u32)> {
- zs_neighbors(p.0, p.1).into_iter().filter(|n| skel.contains(n)).collect()
- }
- fn edge(a: (u32, u32), b: (u32, u32)) -> ((u32, u32), (u32, u32)) {
- if a <= b { (a, b) } else { (b, a) }
- }
-
- let junctions: HashSet<(u32, u32)> = skeleton.iter()
- .copied()
- .filter(|p| nbrs_in(*p, skeleton).len() >= 3)
- .collect();
-
- // ── Group junctions into 8-connected clusters ─────────────────────────
- let mut clusters: Vec> = Vec::new();
- let mut pixel_to_cluster: HashMap<(u32, u32), usize> = HashMap::new();
- {
- let mut assigned: HashSet<(u32, u32)> = HashSet::new();
- let mut juncs_sorted: Vec<_> = junctions.iter().copied().collect();
- juncs_sorted.sort();
- for j in juncs_sorted {
- if assigned.contains(&j) { continue; }
- let mut cluster: HashSet<(u32, u32)> = HashSet::new();
- let mut queue = vec![j];
- while let Some(p) = queue.pop() {
- if !cluster.insert(p) { continue; }
- assigned.insert(p);
- for n in zs_neighbors(p.0, p.1) {
- if junctions.contains(&n) && !cluster.contains(&n) { queue.push(n); }
- }
- }
- let idx = clusters.len();
- for &p in &cluster { pixel_to_cluster.insert(p, idx); }
- clusters.push(cluster);
- }
- }
-
- // For each cluster, build (exit_external → paired_exit_external) map.
- // Exits are computed once: each (internal_cluster_pixel, external_skeleton_pixel)
- // edge that leaves the cluster. Pair by direction: each external's vector
- // from cluster centroid; pairs maximise the angle between vectors
- // (= most-opposite = continues through).
- let cluster_pairings: Vec> = clusters.iter().map(|cluster| {
- let mut exits: Vec<((u32, u32), (u32, u32))> = Vec::new();
- for &c in cluster {
- for n in zs_neighbors(c.0, c.1) {
- if skeleton.contains(&n) && !cluster.contains(&n) {
- exits.push((c, n));
- }
- }
- }
- // Centroid of the cluster.
- let n = cluster.len() as f32;
- let cx = cluster.iter().map(|p| p.0 as f32).sum::() / n;
- let cy = cluster.iter().map(|p| p.1 as f32).sum::() / n;
-
- let mut paired: HashMap<(u32, u32), (u32, u32)> = HashMap::new();
- let mut available: Vec<(u32, u32)> = {
- let mut e: Vec<(u32, u32)> = exits.iter().map(|&(_, ext)| ext).collect();
- e.sort();
- e.dedup();
- e
- };
- while available.len() >= 2 {
- let e1 = available.remove(0);
- let d1x = e1.0 as f32 - cx;
- let d1y = e1.1 as f32 - cy;
- // Pick the external whose direction is most-opposite (most negative dot).
- let best = (0..available.len()).max_by(|&i, &j| {
- let ei = available[i]; let ej = available[j];
- let dix = ei.0 as f32 - cx; let diy = ei.1 as f32 - cy;
- let djx = ej.0 as f32 - cx; let djy = ej.1 as f32 - cy;
- let si = -(d1x * dix + d1y * diy);
- let sj = -(d1x * djx + d1y * djy);
- si.partial_cmp(&sj).unwrap_or(std::cmp::Ordering::Equal)
- });
- if let Some(idx) = best {
- let e2 = available.remove(idx);
- paired.insert(e1, e2);
- paired.insert(e2, e1);
- }
- }
- paired
- }).collect();
-
- let mut endpoints: Vec<(u32, u32)> = skeleton.iter()
- .copied()
- .filter(|p| nbrs_in(*p, skeleton).len() == 1)
- .collect();
- endpoints.sort();
-
- let mut used: HashSet<((u32, u32), (u32, u32))> = HashSet::new();
- let mut out = Vec::new();
-
- // Pre-mark every intra-cluster edge as used. The BFS through a cluster
- // picks ONE path from entry to exit; the rest of the cluster's internal
- // edges would otherwise show up as 2-pixel "junction artifact" strokes
- // in pass 2/3. By marking them all up front, only genuine external
- // edges (cluster → non-cluster skeleton pixel) remain walkable.
- for cluster in &clusters {
- for &c in cluster {
- for n in zs_neighbors(c.0, c.1) {
- if cluster.contains(&n) {
- used.insert(edge(c, n));
- }
- }
- }
- }
-
- // BFS inside `cluster` from `from` to any pixel adjacent to
- // `target_external`. Returns the path of cluster pixels (inclusive).
- let bfs_cluster = |from: (u32, u32), target_external: (u32, u32),
- cluster: &HashSet<(u32, u32)>| -> Vec<(u32, u32)> {
- let mut prev_map: HashMap<(u32, u32), Option<(u32, u32)>> = HashMap::new();
- prev_map.insert(from, None);
- let mut queue: VecDeque<(u32, u32)> = VecDeque::new();
- queue.push_back(from);
- let mut tail = None;
- while let Some(p) = queue.pop_front() {
- // Found if p is adjacent to target_external.
- if zs_neighbors(p.0, p.1).iter().any(|n| *n == target_external) {
- tail = Some(p);
- break;
- }
- for n in zs_neighbors(p.0, p.1) {
- if cluster.contains(&n) && !prev_map.contains_key(&n) {
- prev_map.insert(n, Some(p));
- queue.push_back(n);
- }
- }
- }
- // Reconstruct path
- let mut path = Vec::new();
- let mut cur = tail;
- while let Some(p) = cur {
- path.push(p);
- cur = *prev_map.get(&p).unwrap_or(&None);
- }
- path.reverse();
- path
- };
-
- // Walk from `start` along the edge to `next`. When stepping into a
- // junction cluster, look up the paired exit and traverse the cluster
- // through to it as part of the same stroke.
- let walk = |start: (u32, u32), next: (u32, u32),
- used: &mut HashSet<((u32, u32), (u32, u32))>| -> Vec<(u32, u32)> {
- if used.contains(&edge(start, next)) { return vec![]; }
- let mut stroke = vec![start];
- let mut prev = start;
- let mut cur = next;
- loop {
- used.insert(edge(prev, cur));
- stroke.push(cur);
-
- // Are we entering a junction cluster?
- if let Some(&cidx) = pixel_to_cluster.get(&cur) {
- let cluster = &clusters[cidx];
- let pairings = &cluster_pairings[cidx];
- // We came from `prev` (external pixel of the cluster).
- let Some(ext) = pairings.get(&prev).copied() else { break };
- // Walk through the cluster from `cur` to some internal pixel
- // adjacent to `ext`. BFS ignores `used` (intra-cluster edges
- // are all pre-marked) — we just need any cluster path.
- let path = bfs_cluster(cur, ext, cluster);
- if path.is_empty() { break; }
- // Verify the exit edge hasn't been claimed by an earlier walk.
- let last_internal = *path.last().unwrap();
- if used.contains(&edge(last_internal, ext)) { break; }
- // path[0] == cur (already pushed). Add the rest.
- let mut last = cur;
- for &p in &path[1..] {
- used.insert(edge(last, p));
- stroke.push(p);
- last = p;
- }
- used.insert(edge(last, ext));
- stroke.push(ext);
- prev = last;
- cur = ext;
- continue;
- }
-
- // Regular path step.
- let nxt = nbrs_in(cur, skeleton).into_iter()
- .find(|n| *n != prev && !used.contains(&edge(cur, *n)));
- match nxt {
- Some(n) => { prev = cur; cur = n; }
- None => break,
- }
- if cur == start && stroke.len() >= 3 {
- used.insert(edge(prev, cur));
- stroke.push(cur);
- break;
- }
- }
- stroke
- };
-
- // Pass 1: walk from every endpoint.
- for ep in &endpoints {
- for nbr in nbrs_in(*ep, skeleton) {
- if used.contains(&edge(*ep, nbr)) { continue; }
- let s = walk(*ep, nbr, &mut used);
- if s.len() >= 2 { out.push(s); }
- }
- }
-
- // Pass 2: cluster-rooted walks for branches not reached by endpoints.
- // We start from a cluster pixel, step out to one of its unused exits,
- // and let the regular walk handle the rest.
- let mut cluster_starts: Vec = (0..clusters.len()).collect();
- cluster_starts.sort();
- for cidx in cluster_starts {
- let cluster = &clusters[cidx];
- for &c in cluster {
- for ext in zs_neighbors(c.0, c.1) {
- if !skeleton.contains(&ext) || cluster.contains(&ext) { continue; }
- if used.contains(&edge(c, ext)) { continue; }
- // Start from ext (treating it like a one-step seed).
- let s = walk(ext, c, &mut used); // walk back into the cluster
- if s.len() >= 2 { out.push(s); }
- }
- }
- }
-
- // Pass 3: pure cycles (no endpoints, no junctions — rings, "O" glyphs).
- let mut leftovers: Vec<_> = skeleton.iter()
- .copied()
- .filter(|p| nbrs_in(*p, skeleton).iter().any(|n| !used.contains(&edge(*p, *n))))
- .collect();
- leftovers.sort();
- for p in leftovers {
- for nbr in nbrs_in(p, skeleton) {
- if used.contains(&edge(p, nbr)) { continue; }
- let s = walk(p, nbr, &mut used);
- if s.len() >= 2 { out.push(s); }
- }
- }
-
- out
-}
-
// ── Wave interference ──────────────────────────────────────────────────────────
/// Concentric ring systems from `num_sources` hull interior points.
@@ -1960,406 +1420,6 @@ mod tests {
}
}
- // ── skeleton_fill tests ───────────────────────────────────────────────────
-
- /// Build a horizontal bar `width` × `thickness` pixels at (x0, y0).
- fn make_bar_hull(x0: u32, y0: u32, width: u32, thickness: u32) -> Hull {
- let mut pixels = Vec::with_capacity((width * thickness) as usize);
- for y in 0..thickness { for x in 0..width {
- pixels.push((x0 + x, y0 + y));
- }}
- Hull {
- id: 0,
- pixels,
- contour: vec![], simplified: vec![],
- area: width * thickness, avg_luminance: 0.0, avg_color: [0, 0, 0],
- bounds: crate::hulls::Bounds {
- x_min: x0, y_min: y0,
- x_max: x0 + width - 1, y_max: y0 + thickness - 1,
- },
- }
- }
-
- /// Build an L-shape: a vertical bar with a horizontal bar at its bottom.
- fn make_l_hull(x0: u32, y0: u32, height: u32, width: u32, thick: u32) -> Hull {
- let mut pixels = Vec::new();
- // Vertical part
- for y in 0..height { for x in 0..thick {
- pixels.push((x0 + x, y0 + y));
- }}
- // Horizontal part (bottom), skipping the overlap with the vertical
- for y in 0..thick { for x in thick..width {
- pixels.push((x0 + x, y0 + height - thick + y));
- }}
- let area = pixels.len() as u32;
- Hull {
- id: 0,
- pixels,
- contour: vec![], simplified: vec![],
- area, avg_luminance: 0.0, avg_color: [0, 0, 0],
- bounds: crate::hulls::Bounds {
- x_min: x0, y_min: y0,
- x_max: x0 + width - 1, y_max: y0 + height - 1,
- },
- }
- }
-
- #[test]
- fn skeleton_empty_hull_returns_no_strokes() {
- let hull = Hull {
- id: 0, pixels: vec![], contour: vec![], simplified: vec![],
- area: 0, avg_luminance: 0.0, avg_color: [0, 0, 0],
- bounds: crate::hulls::Bounds { x_min: 0, y_min: 0, x_max: 0, y_max: 0 },
- };
- let r = skeleton_fill(&hull, 1.0);
- assert!(r.strokes.is_empty());
- }
-
- #[test]
- fn skeleton_of_thick_bar_is_a_horizontal_polyline() {
- // A 60-px-wide × 9-px-thick horizontal bar should skeletonise to one
- // mostly-horizontal stroke roughly through its center line.
- let hull = make_bar_hull(0, 0, 60, 9);
- let r = skeleton_fill(&hull, 1.0);
- assert_eq!(r.strokes.len(), 1, "expected exactly 1 stroke, got {}", r.strokes.len());
- let s = &r.strokes[0];
-
- // Span should cover most of the bar's length (allow some boundary erosion).
- let xs: Vec = s.iter().map(|&(x, _)| x).collect();
- let span = xs.iter().cloned().fold(f32::MIN, f32::max)
- - xs.iter().cloned().fold(f32::MAX, f32::min);
- assert!(span >= 50.0, "skeleton span {span} too short for 60-px bar");
-
- // Skeleton should sit near the bar's middle row (y ≈ 4 ± a bit).
- for &(_, y) in s {
- assert!(y >= 2.0 && y <= 6.0, "skeleton y={y} outside expected band 2..=6");
- }
- }
-
- #[test]
- fn skeleton_of_thick_l_has_a_junction_or_two_branches() {
- // Thick L → skeleton has a corner. Stop-at-junction policy means we
- // expect either one stroke that bends through the corner (when no
- // junction forms — possible for sharp L's) or two strokes meeting.
- let hull = make_l_hull(0, 0, /*h*/ 40, /*w*/ 40, /*t*/ 7);
- let r = skeleton_fill(&hull, 1.0);
- assert!(!r.strokes.is_empty(), "L-shape produced no skeleton strokes");
- // Any skeleton output points must lie inside the original hull.
- let pixel_set: HashSet<(u32, u32)> = hull.pixels.iter().copied().collect();
- for stroke in &r.strokes {
- for &(sx, sy) in stroke {
- let inside = (-1i32..=1).any(|dy| (-1i32..=1).any(|dx| {
- let px = (sx.round() as i32 + dx).max(0) as u32;
- let py = (sy.round() as i32 + dy).max(0) as u32;
- pixel_set.contains(&(px, py))
- }));
- assert!(inside, "skeleton point ({sx},{sy}) outside hull");
- }
- }
- }
-
- #[test]
- fn skeleton_of_solid_square_terminates() {
- // A solid blob skeletonises to a small connected fragment near the
- // centre. Important: this must not loop forever or produce empty.
- let hull = make_square_hull(0, 0, 30);
- let r = skeleton_fill(&hull, 1.0);
- // Either some strokes, or a single point (which is dropped). Either
- // is acceptable — the contract is "doesn't hang".
- for stroke in &r.strokes {
- assert!(stroke.len() >= 2);
- }
- }
-
- // ── skeleton_fill on real letter shapes ───────────────────────────────────
- //
- // Renders Hershey text → rasterises with a thick stroke → extracts hulls
- // → runs the full skeleton pipeline. Pins expected stroke counts for
- // simple glyphs the user would type into an envelope. Stroke counts here
- // are the contract: a healthy skeleton fill draws "O" in ONE pen-down,
- // "I" in ONE, "T" in two or three (depending on junction handling), etc.
- //
- // If the user's gcode is fragmenting every glyph into many 2-point
- // segments, these tests will fail loudly with the actual number observed.
-
- /// Rasterise `text` at `font_size_mm`/`dpi`/`thickness` and run hull
- /// detection with the same params the front-end's default text mode uses.
- fn rasterize_text_to_hulls(text: &str, font_size_mm: f32, dpi: u32, thickness_px: u32)
- -> Vec
- {
- use crate::text::{TextBlockSpec, rasterize_blocks};
- use crate::hulls::{extract_hulls, HullParams, Connectivity};
- let block = TextBlockSpec {
- text: text.to_string(),
- font_size_mm, line_spacing_mm: None,
- x_mm: 5.0, y_mm: 5.0,
- };
- let rgb = rasterize_blocks(&[block], 60.0, 30.0, dpi, thickness_px);
- let (w, h) = rgb.dimensions();
- let luma: Vec = rgb.pixels()
- .map(|p| ((p[0] as u32 + p[1] as u32 + p[2] as u32) / 3) as u8)
- .collect();
- let params = HullParams {
- threshold: 253, min_area: 4, rdp_epsilon: 0.1,
- connectivity: Connectivity::Four,
- ..HullParams::default()
- };
- extract_hulls(&luma, &rgb, w, h, ¶ms)
- }
-
- #[test]
- fn skeleton_letter_I_is_one_stroke() {
- // Hershey "I" is a single vertical line; thinned and walked, it's 1 stroke.
- let hulls = rasterize_text_to_hulls("I", 5.0, 150, 3);
- assert_eq!(hulls.len(), 1, "expected 1 hull for 'I', got {}", hulls.len());
- let r = skeleton_fill(&hulls[0], 1.0);
- assert_eq!(r.strokes.len(), 1,
- "expected 1 stroke for 'I', got {} (avg points/stroke = {:.1})",
- r.strokes.len(),
- r.strokes.iter().map(|s| s.len()).sum::() as f32 / r.strokes.len().max(1) as f32);
- }
-
- #[test]
- fn skeleton_letter_O_is_one_stroke() {
- // "O" is a closed loop: skeleton is a 1-px ring, walked as one closed stroke.
- let hulls = rasterize_text_to_hulls("O", 6.0, 150, 3);
- assert_eq!(hulls.len(), 1, "expected 1 hull for 'O', got {}", hulls.len());
- let r = skeleton_fill(&hulls[0], 1.0);
- assert_eq!(r.strokes.len(), 1,
- "expected 1 stroke for 'O', got {} (avg points/stroke = {:.1})",
- r.strokes.len(),
- r.strokes.iter().map(|s| s.len()).sum::() as f32 / r.strokes.len().max(1) as f32);
- }
-
- /// Renders a letter, runs the full skeleton pipeline, and prints an
- /// ASCII-art picture of the post-prune skeleton for diagnostics.
- /// Marked `#[ignore]` so it doesn't run by default; invoke with
- /// `cargo test --lib skeleton_dump_letter -- --ignored --nocapture`.
- #[test]
- #[ignore]
- fn skeleton_dump_letter() {
- use crate::hulls::Bounds;
- let letter = std::env::var("DUMP_LETTER").unwrap_or_else(|_| "T".into());
- let hulls = rasterize_text_to_hulls(&letter, 6.0, 150, 3);
- for (i, h) in hulls.iter().enumerate() {
- let mut sk = zhang_suen_thin(&h.pixels);
- prune_skeleton_spurs(&mut sk, 3);
- let strokes = extract_skeleton_strokes(&sk);
- let Bounds { x_min, y_min, x_max, y_max } = h.bounds;
- println!("=== hull[{i}] '{letter}' bounds ({x_min},{y_min})-({x_max},{y_max}) ===");
- // Render skeleton
- // Mark endpoints (1 nbr) as '*', junctions (≥3) as 'X', path (2) as '#'.
- for y in y_min..=y_max {
- let mut row = String::new();
- for x in x_min..=x_max {
- if !sk.contains(&(x, y)) { row.push('.'); continue; }
- let n = zs_neighbors(x, y).iter().filter(|p| sk.contains(p)).count();
- row.push(match n {
- 0 => 'o', 1 => '*', 2 => '#', 3 => 'Y', 4 => 'X', _ => '@',
- });
- }
- println!("{}", row);
- }
- println!("strokes: {}", strokes.len());
- for (j, s) in strokes.iter().enumerate() {
- println!(" stroke {j}: {} pts, first={:?} last={:?}", s.len(), s.first(), s.last());
- }
- }
- }
-
- #[test]
- fn skeleton_letter_T_is_two_strokes() {
- // "T" has one real junction. With direction pairing, the horizontal
- // bar walks straight through it as one stroke; the vertical post
- // is a separate stroke. Total: 2.
- let hulls = rasterize_text_to_hulls("T", 5.0, 150, 3);
- assert_eq!(hulls.len(), 1, "expected 1 hull for 'T', got {}", hulls.len());
- let r = skeleton_fill(&hulls[0], 1.0);
- assert_eq!(r.strokes.len(), 2,
- "expected 2 strokes for 'T' (horizontal-through, vertical-post), got {} (avg points/stroke = {:.1})",
- r.strokes.len(),
- r.strokes.iter().map(|s| s.len()).sum::() as f32 / r.strokes.len().max(1) as f32);
- }
-
- #[test]
- fn skeleton_all_alphanumerics_have_few_strokes() {
- // Sweep across A-Z, a-z, 0-9. The user's bug ("hundreds of strokes
- // per letter") would manifest as totals well into the dozens for
- // any glyph; this regression test catches that. Two checks:
- // (a) NO single letter exceeds 6 strokes (B clocks the worst at 5
- // in the current implementation; 6 leaves a tiny buffer).
- // (b) The AVERAGE stroke count across the full alphabet stays
- // under 2.5 — anything higher means junctions are
- // fragmenting most letters.
- const MAX_STROKES_PER_LETTER: usize = 6;
- const MAX_AVG_STROKES: f32 = 2.5;
- let chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ\
- abcdefghijklmnopqrstuvwxyz\
- 0123456789";
- let mut report = String::new();
- let mut bad: Vec<(char, usize)> = Vec::new();
- let mut total_strokes = 0usize;
- let mut count = 0usize;
- for ch in chars.chars() {
- let hulls = rasterize_text_to_hulls(&ch.to_string(), 6.0, 150, 3);
- let total: usize = hulls.iter()
- .map(|h| skeleton_fill(h, 1.0).strokes.len())
- .sum();
- report.push_str(&format!("'{}': {} hull(s), {} stroke(s)\n",
- ch, hulls.len(), total));
- if total > MAX_STROKES_PER_LETTER { bad.push((ch, total)); }
- total_strokes += total;
- count += 1;
- }
- let avg = total_strokes as f32 / count as f32;
- if !bad.is_empty() {
- panic!("Letters with > {} strokes: {:?}\n\
- avg = {:.2} (limit {:.2})\nFull report:\n{}",
- MAX_STROKES_PER_LETTER, bad, avg, MAX_AVG_STROKES, report);
- }
- assert!(avg <= MAX_AVG_STROKES,
- "avg strokes per glyph = {:.2} > limit {:.2}\n{}",
- avg, MAX_AVG_STROKES, report);
- }
-
- // ── centerline_fill tests (distance-transform medial axis) ────────────────
-
- /// Helper: bounds-of-strokes test. `centerline_fill` of a glyph should
- /// produce strokes that span the GLYPH'S full bounding box, both x and y.
- /// If the tail of 'a' or '9' is missing, the strokes' Y range will be
- /// significantly smaller than the hull's Y range.
- fn stroke_y_range(strokes: &[Vec<(f32, f32)>]) -> (f32, f32) {
- let mut lo = f32::MAX;
- let mut hi = f32::MIN;
- for s in strokes {
- for &(_, y) in s {
- if y < lo { lo = y; }
- if y > hi { hi = y; }
- }
- }
- (lo, hi)
- }
-
- #[test]
- fn centerline_letter_a_has_tail() {
- // Lowercase 'a' has a descending tail on its right side. The strokes
- // must span at least 90% of the glyph's height — if the tail is
- // dropped, the stroke set would only span the bowl (~70% of height).
- let hulls = rasterize_text_to_hulls("a", 8.0, 200, 4);
- assert_eq!(hulls.len(), 1, "expected 1 hull for 'a'");
- let r = centerline_fill(&hulls[0], 1.0);
- assert!(!r.strokes.is_empty(), "centerline produced no strokes for 'a'");
- let (lo, hi) = stroke_y_range(&r.strokes);
- let span = hi - lo;
- let glyph_h = (hulls[0].bounds.y_max - hulls[0].bounds.y_min) as f32;
- assert!(span >= 0.85 * glyph_h,
- "'a' stroke Y-span {:.1} only covers {:.0}% of glyph height {:.1} \
- — likely missing tail. {} strokes total.",
- span, span / glyph_h * 100.0, glyph_h, r.strokes.len());
- }
-
- #[test]
- fn centerline_digit_9_has_descender() {
- // '9' has an upper bowl and a tail going down. If the tail is
- // disconnected from the bowl, the y-range may still cover the
- // whole glyph but the strokes won't form a single connected
- // graph from top to bottom. Check both span AND connectivity.
- let hulls = rasterize_text_to_hulls("9", 10.0, 200, 4);
- assert_eq!(hulls.len(), 1, "expected 1 hull for '9'");
- let r = centerline_fill(&hulls[0], 1.0);
- let (lo, hi) = stroke_y_range(&r.strokes);
- let span = hi - lo;
- let glyph_h = (hulls[0].bounds.y_max - hulls[0].bounds.y_min) as f32;
- assert!(span >= 0.85 * glyph_h,
- "'9' stroke Y-span {:.1} only covers {:.0}% of glyph height {:.1} \
- — likely missing descender. {} strokes total.",
- span, span / glyph_h * 100.0, glyph_h, r.strokes.len());
- }
-
- #[test]
- fn centerline_letter_O_is_one_closed_stroke() {
- // Sanity: the algorithm must still handle simple shapes well.
- let hulls = rasterize_text_to_hulls("O", 8.0, 150, 3);
- let r = centerline_fill(&hulls[0], 1.0);
- assert_eq!(r.strokes.len(), 1, "expected 1 stroke for 'O', got {}", r.strokes.len());
- }
-
- #[test]
- fn centerline_letter_T_is_two_strokes() {
- let hulls = rasterize_text_to_hulls("T", 8.0, 150, 3);
- let r = centerline_fill(&hulls[0], 1.0);
- assert_eq!(r.strokes.len(), 2, "expected 2 strokes for 'T', got {}", r.strokes.len());
- }
-
- #[test]
- fn centerline_all_alphanumerics_have_few_strokes() {
- // Same regression sweep as for skeleton_fill, but tighter: every
- // glyph should produce strokes whose Y-span ≥ 85% of glyph height
- // (i.e. nothing is silently truncated).
- let chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
- let mut report = String::new();
- let mut bad: Vec<(char, String)> = Vec::new();
- let mut total_strokes = 0usize;
- let mut count = 0usize;
- for ch in chars.chars() {
- let hulls = rasterize_text_to_hulls(&ch.to_string(), 8.0, 200, 4);
- let mut total = 0usize;
- let mut min_cov = 1.0f32;
- for h in &hulls {
- let r = centerline_fill(h, 1.0);
- total += r.strokes.len();
- if !r.strokes.is_empty() {
- let (lo, hi) = stroke_y_range(&r.strokes);
- let glyph_h = (h.bounds.y_max - h.bounds.y_min) as f32;
- let cov = if glyph_h > 0.0 { (hi - lo) / glyph_h } else { 1.0 };
- if cov < min_cov { min_cov = cov; }
- }
- }
- report.push_str(&format!("'{}': {} hull(s), {} stroke(s), min_y_cov {:.2}\n",
- ch, hulls.len(), total, min_cov));
- if total > 8 {
- bad.push((ch, format!("{} strokes", total)));
- }
- if min_cov < 0.70 {
- bad.push((ch, format!("only {:.0}% Y coverage", min_cov * 100.0)));
- }
- total_strokes += total;
- count += 1;
- }
- let avg = total_strokes as f32 / count as f32;
- if !bad.is_empty() {
- panic!("Letters with issues: {:?}\navg = {:.2}\nFull report:\n{}",
- bad, avg, report);
- }
- }
-
- #[test]
- fn skeleton_letter_X_is_two_strokes() {
- // "X" has one 4-way junction. With pairing, two diagonal strokes
- // continue straight through, giving exactly 2 strokes total.
- let hulls = rasterize_text_to_hulls("X", 6.0, 150, 3);
- assert_eq!(hulls.len(), 1, "expected 1 hull for 'X', got {}", hulls.len());
- let r = skeleton_fill(&hulls[0], 1.0);
- assert_eq!(r.strokes.len(), 2,
- "expected 2 strokes for 'X' (two diagonals), got {} (avg points/stroke = {:.1})",
- r.strokes.len(),
- r.strokes.iter().map(|s| s.len()).sum::() as f32 / r.strokes.len().max(1) as f32);
- }
-
- #[test]
- fn skeleton_thin_3px_bar_one_long_stroke() {
- // 3-px-thick bar: minimum thickness the rasteriser produces by
- // default (dpi/50 ≈ 3 at 150dpi). Existing 9-px test masks issues
- // unique to thin strokes.
- let hull = make_bar_hull(0, 0, 50, 3);
- let r = skeleton_fill(&hull, 1.0);
- assert_eq!(r.strokes.len(), 1,
- "expected 1 stroke for 3×50 bar, got {}", r.strokes.len());
- assert!(r.strokes[0].len() >= 30,
- "expected ≥30 points (~bar length) but got {} (looks fragmented)",
- r.strokes[0].len());
- }
-
// ── hilbert_fill tests ────────────────────────────────────────────────────
#[test]
diff --git a/src/lib.rs b/src/lib.rs
index 0843b2f1..b5e02c45 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -3,9 +3,6 @@ pub mod hulls;
pub mod fill;
pub mod gcode;
pub mod text;
-pub mod topo_strokes;
-pub mod brush_paint;
-pub mod brush_paint_opt;
use std::time::Instant;
@@ -822,10 +819,6 @@ fn process_pass_work(
"circles" => fill::circle_pack(hull, spacing, param.max(0.1)),
"voronoi" => fill::voronoi_fill(hull, spacing),
"hilbert" => fill::hilbert_fill(hull, spacing),
- "skeleton" => fill::skeleton_fill(hull, spacing),
- "centerline" => fill::centerline_fill(hull, spacing),
- "topo" => topo_strokes::topo_fill(hull, param.max(0.0)),
- "paint" => brush_paint::paint_fill(hull, param.max(0.0)),
"waves" => fill::wave_interference(hull, spacing, param.round().max(1.0) as usize),
"flow" => fill::flow_field(hull, spacing, angle, param.max(0.0)),
"gradient_hatch" => fill::gradient_hatch(hull, &response_arc, img_w, spacing, angle, param.clamp(0.05, 1.0)),
@@ -854,10 +847,6 @@ fn process_pass_work(
"circles" => fill::circle_pack(hull, spacing, param.max(0.1)),
"voronoi" => fill::voronoi_fill(hull, spacing),
"hilbert" => fill::hilbert_fill(hull, spacing),
- "skeleton" => fill::skeleton_fill(hull, spacing),
- "centerline" => fill::centerline_fill(hull, spacing),
- "topo" => topo_strokes::topo_fill(hull, param.max(0.0)),
- "paint" => brush_paint::paint_fill(hull, param.max(0.0)),
"waves" => fill::wave_interference(hull, spacing, param.round().max(1.0) as usize),
"flow" => fill::flow_field(hull, spacing, angle, param.max(0.0)),
"gradient_hatch" => fill::gradient_hatch(hull, &response_arc, img_w, spacing, angle, param.clamp(0.05, 1.0)),
@@ -998,165 +987,6 @@ fn list_hulls(pass_idx: usize, state: State>) -> Result>,
-) -> Result, 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]
-fn get_paint_debug(
- pass_idx: usize, hull_idx: usize, params: brush_paint::PaintParams,
- state: State>,
-) -> Result {
- let st = state.lock().unwrap();
- let ps = st.passes.get(pass_idx)
- .ok_or_else(|| format!("pass {pass_idx} out of range"))?;
- let h = ps.hulls.get(hull_idx)
- .ok_or_else(|| format!("hull {hull_idx} out of range (pass has {})", ps.hulls.len()))?;
- Ok(brush_paint::paint_fill_debug(h, ¶ms))
-}
-
-#[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>,
-) -> Result {
- // 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, 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),
- ("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),
- ("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(¤t);
- let _ = app.emit("optimizer-progress", OptimizerProgress {
- step: 0, axis: "".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]
fn set_pass_count(count: usize, state: State>) {
let mut st = state.lock().unwrap();
@@ -2976,9 +2806,6 @@ pub fn run() {
get_images_dir,
set_pass_count,
list_hulls,
- load_test_letter,
- get_paint_debug,
- optimize_paint_params,
process_pass,
get_all_strokes,
get_gcode_viz,
diff --git a/src/topo_strokes.rs b/src/topo_strokes.rs
deleted file mode 100644
index db9d1983..00000000
--- a/src/topo_strokes.rs
+++ /dev/null
@@ -1,568 +0,0 @@
-// Topology-aware pen-stroke decomposition.
-//
-// raster glyph
-// ↓ Zhang-Suen thinning
-// 1-px skeleton
-// ↓ salience-based spur prune
-// cleaned skeleton
-// ↓ identify junctions (degree ≥ 3) + endpoints (degree 1)
-// medial-axis graph (nodes + edges with pixel paths)
-// ↓ Chinese postman (pair odd-degree vertices, find Eulerian trails)
-// minimum-pen-up stroke decomposition
-// ↓ smooth each stroke (RDP + Chaikin)
-// final pen strokes
-//
-// The Chinese-postman step is the key. For a graph with 2k odd-degree
-// vertices, the minimum number of pen-strokes is k (Eulerian trail count
-// after pairing). The trick is which pairing minimises total walk length —
-// for k ≤ 4 we brute-force all (2k-1)!! pairings (≤ 105 for k=4).
-//
-// Concrete glyph counts under this model:
-// I/L/J/U: 1 stroke (graph is a single edge or path)
-// O/D/0: 1 stroke (Eulerian circuit on a cycle)
-// T/X: 2 strokes
-// N/M/A: 3 strokes
-// 8: 1 stroke (figure-8: degree-4 junction + 2 self-loops)
-// B: 2-3 strokes
-// E/F: 3 strokes
-
-use std::collections::{HashMap, HashSet};
-use crate::fill::{FillResult, smooth_stroke, chamfer_distance,
- zhang_suen_thin, prune_skeleton_spurs, zs_neighbors};
-use crate::hulls::Hull;
-
-#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
-#[serde(default)]
-pub struct TopoParams {
- /// Spur prune length as a multiplier of stroke half-width (= sdf_max).
- /// 0 = no pruning, 2.5 ≈ "drop branches up to 2.5× stroke half-width."
- /// Scale-invariant: same value works at 3mm and 8mm. Tradeoff: too
- /// high removes real letter tails (`a`, `g`, `9`); too low keeps
- /// reflex-corner artifacts that explode the stroke count.
- pub spur_prune_factor: f32,
- /// Final stroke RDP epsilon (px).
- pub output_rdp_eps: f32,
- /// Final stroke Chaikin smoothing passes.
- pub output_chaikin: u32,
-}
-
-impl Default for TopoParams {
- fn default() -> Self {
- Self { spur_prune_factor: 6.0, output_rdp_eps: 0.5, output_chaikin: 2 }
- }
-}
-
-// ── Graph data structures ──────────────────────────────────────────────
-
-#[derive(Debug, Clone)]
-pub struct GraphEdge {
- pub a: usize, // node index of one endpoint
- pub b: usize, // node index of the other
- pub path: Vec<(f32, f32)>, // pixel-coord polyline a→b inclusive
- pub length: f32, // Euclidean length
-}
-
-#[derive(Debug, Clone)]
-pub struct MedialGraph {
- pub nodes: Vec<(f32, f32)>,
- pub edges: Vec,
- /// adj[node_idx] = vec of edge indices incident to that node.
- pub adj: Vec>,
-}
-
-impl MedialGraph {
- fn degree(&self, node: usize) -> usize { self.adj[node].len() }
-}
-
-// ── Build graph from a hull ────────────────────────────────────────────
-
-pub fn build_graph(hull: &Hull, params: &TopoParams) -> MedialGraph {
- if hull.pixels.is_empty() {
- return MedialGraph { nodes: vec![], edges: vec![], adj: vec![] };
- }
-
- // Compute SDF max once so spur-prune length scales with stroke
- // thickness — same params then work at all font sizes.
- let pixel_set: HashSet<(u32, u32)> = hull.pixels.iter().copied().collect();
- let dist = chamfer_distance(hull, &pixel_set);
- let sdf_max = dist.values().cloned().fold(0.0_f32, f32::max).max(0.5);
-
- let mut skel = zhang_suen_thin(&hull.pixels);
- let spur_len = (params.spur_prune_factor * sdf_max).round() as usize;
- prune_skeleton_spurs(&mut skel, spur_len.max(2));
-
- fn nbrs_in(p: (u32, u32), skel: &HashSet<(u32, u32)>) -> Vec<(u32, u32)> {
- zs_neighbors(p.0, p.1).into_iter().filter(|n| skel.contains(n)).collect()
- }
-
- // Identify endpoints (degree 1) and junctions (degree ≥ 3).
- let junctions: HashSet<(u32, u32)> = skel.iter().copied()
- .filter(|p| nbrs_in(*p, &skel).len() >= 3).collect();
- let endpoints: HashSet<(u32, u32)> = skel.iter().copied()
- .filter(|p| nbrs_in(*p, &skel).len() == 1).collect();
-
- // Cluster adjacent junction pixels (8-connected) into super-junctions.
- // ZS thinning leaves a small blob of degree-3+ pixels at every real
- // junction, which would otherwise show up as multiple distinct nodes
- // connected by 1-2 px sub-edges.
- let mut pixel_to_node: HashMap<(u32, u32), usize> = HashMap::new();
- let mut nodes: Vec<(f32, f32)> = Vec::new();
- {
- let mut visited: HashSet<(u32, u32)> = HashSet::new();
- for &p in &junctions {
- if visited.contains(&p) { continue; }
- // BFS over the junction-pixel cluster.
- let mut cluster: Vec<(u32, u32)> = Vec::new();
- let mut q: Vec<(u32, u32)> = vec![p];
- while let Some(q_p) = q.pop() {
- if !visited.insert(q_p) { continue; }
- cluster.push(q_p);
- for n in zs_neighbors(q_p.0, q_p.1) {
- if junctions.contains(&n) && !visited.contains(&n) {
- q.push(n);
- }
- }
- }
- // Cluster centroid is the super-junction's position.
- let n = cluster.len() as f32;
- let cx = cluster.iter().map(|p| p.0 as f32).sum::() / n;
- let cy = cluster.iter().map(|p| p.1 as f32).sum::() / n;
- let nidx = nodes.len();
- nodes.push((cx, cy));
- for &cp in &cluster { pixel_to_node.insert(cp, nidx); }
- }
- // Each endpoint is its own node.
- for &p in &endpoints {
- let nidx = nodes.len();
- nodes.push((p.0 as f32, p.1 as f32));
- pixel_to_node.insert(p, nidx);
- }
- }
- let node_pixels: HashSet<(u32, u32)> = pixel_to_node.keys().copied().collect();
- let node_idx = pixel_to_node;
-
- // Walk every edge starting from each node along each unused incident
- // skeleton-pixel direction. Edges are uniqued by their (a, b) endpoints
- // and a hash of their pixel sequence.
- let mut edges: Vec = Vec::new();
- let mut used_edge_pixels: HashSet<((u32, u32), (u32, u32))> = HashSet::new();
- let edge_key = |a: (u32, u32), b: (u32, u32)| -> ((u32, u32), (u32, u32)) {
- if a <= b { (a, b) } else { (b, a) }
- };
-
- for &start in &node_pixels {
- let start_ni = node_idx[&start];
- for next in nbrs_in(start, &skel) {
- if used_edge_pixels.contains(&edge_key(start, next)) { continue; }
- // Skip intra-cluster steps — those don't form graph edges
- // (the cluster collapses to one super-node). Without this we'd
- // emit fake 1-2 px self-loops between every pair of junction
- // pixels in the same blob.
- if node_idx.get(&next) == Some(&start_ni) { continue; }
- // Walk: start → next → ... until we hit another node pixel.
- let mut path_u: Vec<(u32, u32)> = vec![start, next];
- used_edge_pixels.insert(edge_key(start, next));
- let mut prev = start;
- let mut cur = next;
- let mut end_ni: Option = None;
- loop {
- if let Some(&ni) = node_idx.get(&cur) {
- end_ni = Some(ni);
- break;
- }
- let mut step = None;
- for n in nbrs_in(cur, &skel) {
- if n == prev { continue; }
- if used_edge_pixels.contains(&edge_key(cur, n)) { continue; }
- step = Some(n); break;
- }
- let next_step = match step { Some(s) => s, None => break };
- used_edge_pixels.insert(edge_key(cur, next_step));
- path_u.push(next_step);
- prev = cur;
- cur = next_step;
- if cur == start {
- end_ni = Some(start_ni);
- break;
- }
- }
- // If the walk ran out without hitting a node (shouldn't happen
- // for well-formed skeletons but guard anyway), drop this edge.
- let end_ni = match end_ni { Some(ni) => ni, None => continue };
- let path: Vec<(f32, f32)> = path_u.into_iter()
- .map(|(x, y)| (x as f32, y as f32)).collect();
- let length: f32 = path.windows(2).map(|w| {
- let dx = w[1].0 - w[0].0; let dy = w[1].1 - w[0].1;
- (dx * dx + dy * dy).sqrt()
- }).sum();
- edges.push(GraphEdge { a: start_ni, b: end_ni, path, length });
- }
- }
-
- // Detect pure-cycle components (no node pixels at all — every pixel is
- // degree 2). These need a synthetic node so postman has something to
- // walk. Pick the topmost-leftmost cycle pixel as the "anchor."
- let mut visited_cycle: HashSet<(u32, u32)> = used_edge_pixels.iter()
- .flat_map(|(a, b)| [*a, *b])
- .collect();
- for &p in &skel {
- if visited_cycle.contains(&p) || node_pixels.contains(&p) { continue; }
- // Trace a cycle from p.
- let anchor_ni = nodes.len();
- nodes.push((p.0 as f32, p.1 as f32));
-
- let mut path_u: Vec<(u32, u32)> = vec![p];
- visited_cycle.insert(p);
- let mut prev: Option<(u32, u32)> = None;
- let mut cur = p;
- loop {
- let mut step = None;
- for n in nbrs_in(cur, &skel) {
- if Some(n) == prev { continue; }
- if visited_cycle.contains(&n) && n != p { continue; }
- step = Some(n); break;
- }
- let next_step = match step { Some(s) => s, None => break };
- path_u.push(next_step);
- if next_step == p { break; } // closed
- visited_cycle.insert(next_step);
- prev = Some(cur);
- cur = next_step;
- }
- let path: Vec<(f32, f32)> = path_u.into_iter()
- .map(|(x, y)| (x as f32, y as f32)).collect();
- let length: f32 = path.windows(2).map(|w| {
- let dx = w[1].0 - w[0].0; let dy = w[1].1 - w[0].1;
- (dx * dx + dy * dy).sqrt()
- }).sum();
- edges.push(GraphEdge { a: anchor_ni, b: anchor_ni, path, length });
- }
-
- let mut adj: Vec> = vec![vec![]; nodes.len()];
- for (i, e) in edges.iter().enumerate() {
- adj[e.a].push(i);
- // Self-loops contribute 2 to degree.
- adj[e.b].push(i);
- }
-
- MedialGraph { nodes, edges, adj }
-}
-
-// ── Chinese postman ────────────────────────────────────────────────────
-
-/// Chinese postman: produce minimum-pen-stroke decomposition.
-///
-/// Algorithm:
-/// 1. For each connected component, find odd-degree vertices.
-/// 2. Pair them up (sequential pairing is fine for the small graphs we
-/// get from glyphs). Each pair gets a "virtual" edge connecting them.
-/// 3. The augmented graph is Eulerian (every vertex now even-degree).
-/// 4. Run Hierholzer to get one Eulerian circuit covering all real +
-/// virtual edges.
-/// 5. Split the circuit at each virtual-edge crossing — each split is a
-/// pen-up. Result is k pen-strokes for k virtual edges (= k pairs of
-/// odd vertices).
-///
-/// The number of pen-strokes equals (odd_count / 2) per component.
-pub fn chinese_postman(graph: &MedialGraph) -> Vec> {
- if graph.edges.is_empty() { return vec![]; }
-
- // Build a per-component view, then process each independently.
- let components = connected_components(graph);
- let mut trails: Vec> = Vec::new();
-
- for component in components {
- // Local mutable adjacency (so we can consume edges without
- // touching other components).
- let mut adj: Vec> = vec![Vec::new(); graph.nodes.len()];
- for &n in &component {
- adj[n] = graph.adj[n].clone();
- }
- if adj.iter().all(|v| v.is_empty()) { continue; }
-
- // Odd-degree vertices in this component.
- let odd: Vec = component.iter().copied()
- .filter(|&n| graph.adj[n].len() % 2 == 1).collect();
-
- // Pair odd vertices and inject virtual edges. Virtual edges have
- // index ≥ graph.edges.len() — we'll split the final trail there.
- let n_real = graph.edges.len();
- let mut virtual_endpoints: Vec<(usize, usize)> = Vec::new();
- for chunk in odd.chunks(2) {
- if chunk.len() < 2 { continue; }
- let (u, v) = (chunk[0], chunk[1]);
- let vidx = n_real + virtual_endpoints.len();
- virtual_endpoints.push((u, v));
- adj[u].push(vidx);
- adj[v].push(vidx);
- }
-
- // Pick a start node: any odd vertex (so we end at an odd vertex
- // too, which is where a pen-up makes sense), else any with edges.
- let start = odd.first().copied()
- .or_else(|| component.iter().copied().find(|&n| !adj[n].is_empty()));
- let start = match start { Some(s) => s, None => continue };
-
- // Hierholzer over the augmented (Eulerian) graph.
- let circuit = hierholzer(graph, n_real, &virtual_endpoints,
- start, &mut adj);
-
- // Split at virtual edges. Each split = pen-up.
- let mut current: Vec = Vec::new();
- for eidx in circuit {
- if eidx >= n_real {
- if !current.is_empty() { trails.push(std::mem::take(&mut current)); }
- } else {
- current.push(eidx);
- }
- }
- if !current.is_empty() { trails.push(current); }
- }
- trails
-}
-
-fn connected_components(graph: &MedialGraph) -> Vec> {
- let mut seen = vec![false; graph.nodes.len()];
- let mut components: Vec> = Vec::new();
- for start in 0..graph.nodes.len() {
- if seen[start] { continue; }
- if graph.adj[start].is_empty() { seen[start] = true; continue; }
- let mut comp: Vec = Vec::new();
- let mut q: Vec = vec![start];
- while let Some(n) = q.pop() {
- if seen[n] { continue; }
- seen[n] = true;
- comp.push(n);
- for &eidx in &graph.adj[n] {
- let e = &graph.edges[eidx];
- let other = if e.a == n { e.b } else { e.a };
- if !seen[other] { q.push(other); }
- }
- }
- if !comp.is_empty() { components.push(comp); }
- }
- components
-}
-
-/// Hierholzer over a graph augmented with virtual edges. `n_real` is the
-/// real-edge index threshold (real edges are 0..n_real, virtual are
-/// n_real..). `virtual_endpoints[i]` gives endpoints for virtual edge
-/// `n_real + i`. Returns one Eulerian circuit/trail covering ALL edges
-/// (real + virtual) — guaranteed because the augmented graph is Eulerian.
-fn hierholzer(graph: &MedialGraph,
- n_real: usize, virtual_endpoints: &[(usize, usize)],
- start: usize, adj: &mut Vec>) -> Vec
-{
- let endpoints = |eidx: usize| -> (usize, usize) {
- if eidx < n_real {
- let e = &graph.edges[eidx];
- (e.a, e.b)
- } else {
- virtual_endpoints[eidx - n_real]
- }
- };
-
- // Standard Hierholzer node-stack, but we record the EDGE used for each
- // forward step and emit it when the source node is popped.
- let mut node_stack: Vec = vec![start];
- // Edge that brought us to each node (parallel to node_stack, with first
- // entry being a sentinel).
- let mut arrival_edge: Vec> = vec![None];
- let mut trail: Vec = Vec::new();
-
- while let Some(&top) = node_stack.last() {
- if let Some(&edge) = adj[top].first() {
- // Consume edge.
- let pos = adj[top].iter().position(|&e| e == edge).unwrap();
- adj[top].swap_remove(pos);
- let (a, b) = endpoints(edge);
- let other = if a == top { b } else { a };
- if a == b {
- // Self-loop: remove duplicate at top.
- if let Some(p) = adj[top].iter().position(|&e| e == edge) {
- adj[top].swap_remove(p);
- }
- } else {
- if let Some(p) = adj[other].iter().position(|&e| e == edge) {
- adj[other].swap_remove(p);
- }
- }
- node_stack.push(other);
- arrival_edge.push(Some(edge));
- } else {
- node_stack.pop();
- if let Some(Some(e)) = arrival_edge.pop() {
- trail.push(e);
- }
- }
- }
- trail.reverse();
- trail
-}
-
-// ── Public entry point ─────────────────────────────────────────────────
-
-pub fn topo_fill(hull: &Hull, _intensity: f32) -> FillResult {
- topo_fill_with(hull, &TopoParams::default())
-}
-
-pub fn topo_fill_with(hull: &Hull, params: &TopoParams) -> FillResult {
- let graph = build_graph(hull, params);
- if graph.edges.is_empty() {
- return FillResult { hull_id: hull.id, strokes: vec![] };
- }
- let stroke_edges = chinese_postman(&graph);
-
- let strokes: Vec> = stroke_edges.into_iter()
- .map(|edge_seq| stitch_path(&edge_seq, &graph))
- .map(|p| smooth_stroke(&p, params.output_rdp_eps, params.output_chaikin))
- .filter(|p| p.len() >= 2)
- .collect();
-
- FillResult { hull_id: hull.id, strokes }
-}
-
-/// Concatenate the pixel paths of consecutive edges, flipping each edge's
-/// path to match orientation. The first edge sets the orientation by
-/// matching its `b` to the next edge's shared node.
-fn stitch_path(edge_seq: &[usize], graph: &MedialGraph) -> Vec<(f32, f32)> {
- if edge_seq.is_empty() { return vec![]; }
- let mut out: Vec<(f32, f32)> = Vec::new();
- // Establish first edge orientation by looking at the next one (if any).
- let first = &graph.edges[edge_seq[0]];
- let mut current_end = if edge_seq.len() == 1 {
- // Single-edge stroke: orientation arbitrary. Use a→b as-is.
- out.extend(&first.path);
- return out;
- } else {
- let next = &graph.edges[edge_seq[1]];
- let shared = if first.b == next.a || first.b == next.b { first.b }
- else if first.a == next.a || first.a == next.b { first.a }
- else { first.b }; // shouldn't happen on a valid trail
- if shared == first.b {
- out.extend(&first.path);
- first.b
- } else {
- out.extend(first.path.iter().rev());
- first.a
- }
- };
-
- for &eidx in &edge_seq[1..] {
- let e = &graph.edges[eidx];
- let (path_iter, end): (Box>, usize) =
- if e.a == current_end {
- (Box::new(e.path.iter().copied().skip(1)), e.b)
- } else {
- // Either e.b == current_end, or self-loop.
- (Box::new(e.path.iter().rev().copied().skip(1)), e.a)
- };
- out.extend(path_iter);
- current_end = end;
- }
- out
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use crate::text::{TextBlockSpec, rasterize_blocks};
- use crate::hulls::{extract_hulls, HullParams, Connectivity};
-
- fn rasterize_letter_at(c: char, font_size_mm: f32, dpi: u32, thickness_px: u32)
- -> Vec
- {
- let block = TextBlockSpec {
- text: c.to_string(), font_size_mm,
- line_spacing_mm: None, x_mm: 5.0, y_mm: 5.0,
- };
- let rgb = rasterize_blocks(&[block], 30.0, 20.0, dpi, thickness_px);
- let (w, h) = rgb.dimensions();
- let luma: Vec = rgb.pixels()
- .map(|p| ((p[0] as u32 + p[1] as u32 + p[2] as u32) / 3) as u8)
- .collect();
- let params = HullParams {
- threshold: 253, min_area: 4, rdp_epsilon: 1.5,
- connectivity: Connectivity::Four,
- ..HullParams::default()
- };
- extract_hulls(&luma, &rgb, w, h, ¶ms)
- }
-
- #[test]
- #[ignore]
- fn topo_alphabet_report() {
- let chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
- let p = TopoParams::default();
- for &(font_mm, dpi, thick) in &[(3.0_f32, 150_u32, 3_u32), (5.0, 200, 4), (8.0, 200, 4)] {
- println!("\n══ font={}mm, dpi={}, thickness={}px ══", font_mm, dpi, thick);
- let mut total = 0;
- let mut bad: Vec<(char, usize)> = Vec::new();
- for ch in chars.chars() {
- let hulls = rasterize_letter_at(ch, font_mm, dpi, thick);
- let main = match hulls.iter().max_by_key(|h| h.area) {
- Some(h) => h, None => continue
- };
- let r = topo_fill_with(main, &p);
- let n = r.strokes.len();
- total += n;
- if n > 4 { bad.push((ch, n)); }
- println!("'{}': {} strokes", ch, n);
- }
- println!("Total: {} / 62 chars (avg {:.2})", total, total as f32 / 62.0);
- println!("Over-4-strokes: {:?}", bad);
- }
- }
-
- #[test]
- fn topo_letter_I_is_one_stroke() {
- let hulls = rasterize_letter_at('I', 8.0, 200, 4);
- let main = hulls.iter().max_by_key(|h| h.area).unwrap();
- let r = topo_fill(main, 0.0);
- assert_eq!(r.strokes.len(), 1, "expected 1 stroke for 'I', got {}", r.strokes.len());
- }
-
- #[test]
- fn topo_letter_O_is_one_stroke() {
- let hulls = rasterize_letter_at('O', 8.0, 200, 4);
- let main = hulls.iter().max_by_key(|h| h.area).unwrap();
- let r = topo_fill(main, 0.0);
- assert_eq!(r.strokes.len(), 1, "expected 1 stroke for 'O' (closed loop), got {}",
- r.strokes.len());
- }
-
- #[test]
- fn topo_no_panic_for_any_printable_ascii() {
- for b in 0x20u8..=0x7E {
- let ch = b as char;
- for h in rasterize_letter_at(ch, 8.0, 200, 4) {
- let _ = topo_fill(&h, 0.0);
- }
- }
- }
-
- #[test]
- fn topo_alphabet_max_5_strokes() {
- // Strict bound: every alphanumeric should decompose to ≤5 strokes
- // at typical font sizes. If something exceeds this, the user will
- // see a fragmented glyph.
- let chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
- let p = TopoParams::default();
- let mut bad: Vec<(char, usize, f32, u32)> = Vec::new();
- for &(font_mm, dpi, thick) in &[(3.0_f32, 150_u32, 3_u32), (5.0, 200, 4), (8.0, 200, 4)] {
- for ch in chars.chars() {
- let hulls = rasterize_letter_at(ch, font_mm, dpi, thick);
- let main = match hulls.iter().max_by_key(|h| h.area) {
- Some(h) => h, None => continue
- };
- let r = topo_fill_with(main, &p);
- if r.strokes.len() > 5 {
- bad.push((ch, r.strokes.len(), font_mm, dpi));
- }
- }
- }
- if !bad.is_empty() {
- panic!("Glyphs over the 5-stroke bound: {:?}", bad);
- }
- }
-}