From 52338c255c294cf7bd2a8ddf764fdb3b60904931 Mon Sep 17 00:00:00 2001 From: Mitchell Hansen Date: Fri, 8 May 2026 21:37:20 -0700 Subject: [PATCH] =?UTF-8?q?demolish=20raster=E2=86=92stroke=20recovery=20p?= =?UTF-8?q?ipeline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The image and text pipelines are now separate. Text goes straight through Hershey strokes → gcode (no raster step); image input keeps its existing fill-strategy stack (hatch, zigzag, offset, spiral, outline, circles, voronoi, hilbert, waves, flow, gradient_*). Removed: - src/brush_paint.rs (the walker + everything around it) - src/brush_paint_opt.rs (coordinate-descent + meta-opt) - src/topo_strokes.rs (skeleton-graph predecessor) - src/bin/paint_opt_worker.rs - src/bin/paint_meta_opt_worker.rs - src/bin/paint_bench.rs - scripts/optimize_distributed.sh - scripts/meta_optimize_distributed.sh - src-frontend/.../PaintDebugView.jsx (interactive debugger) - src-frontend/.../lib/rasterRender.{js,test.js} Stripped from fill.rs: - chamfer_distance (ZS support) - skeleton_fill, centerline_fill, all skeleton/centerline tests - zhang_suen_thin, zs_mark, zs_neighbors, prune_skeleton_spurs, extract_skeleton_strokes, rasterize_text_to_hulls test helper Stripped from lib.rs: - mod brush_paint, brush_paint_opt, topo_strokes - "skeleton" / "centerline" / "topo" / "paint" fill-strategy arms - load_test_letter, get_paint_debug, optimize_paint_params commands - OptimizerProgress event struct Frontend: - 'paint' viewMode + tab gone - FILL_STRATEGIES drops skeleton, centerline, topo, paint - useTauri.js drops loadTestLetter, getPaintDebug, optimizePaintParams, DEFAULT_PAINT_PARAMS Cargo.toml: dropped spade dependency (no longer used) and the two optimizer-worker bin entries. Verified: cargo build, cargo test --lib (77 pass), npm run build, npm test (72 pass — 25 fewer than before, reflecting the deleted rasterRender tests). --- Cargo.toml | 9 - scripts/meta_optimize_distributed.sh | 141 - scripts/optimize_distributed.sh | 118 - src-frontend/src/App.jsx | 5 +- .../src/components/PaintDebugView.jsx | 1456 ------- src-frontend/src/hooks/useTauri.js | 41 - src-frontend/src/lib/rasterRender.js | 127 - src-frontend/src/lib/rasterRender.test.js | 356 -- src/bin/paint_bench.rs | 40 - src/bin/paint_meta_opt_worker.rs | 98 - src/bin/paint_opt_worker.rs | 94 - src/brush_paint.rs | 3525 ----------------- src/brush_paint_opt.rs | 580 --- src/fill.rs | 940 ----- src/lib.rs | 173 - src/topo_strokes.rs | 568 --- 16 files changed, 1 insertion(+), 8270 deletions(-) delete mode 100755 scripts/meta_optimize_distributed.sh delete mode 100755 scripts/optimize_distributed.sh delete mode 100644 src-frontend/src/components/PaintDebugView.jsx delete mode 100644 src-frontend/src/lib/rasterRender.js delete mode 100644 src-frontend/src/lib/rasterRender.test.js delete mode 100644 src/bin/paint_bench.rs delete mode 100644 src/bin/paint_meta_opt_worker.rs delete mode 100644 src/bin/paint_opt_worker.rs delete mode 100644 src/brush_paint.rs delete mode 100644 src/brush_paint_opt.rs delete mode 100644 src/topo_strokes.rs diff --git a/Cargo.toml b/Cargo.toml index 7c9c8355..8afef7bd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,6 @@ log = "0.4" env_logger = "0.11" reqwest = { version = "0.12", default-features = false, features = ["multipart", "rustls-tls", "blocking"] } tungstenite = { version = "0.24", default-features = false, features = ["handshake"] } -spade = "2" [profile.dev] opt-level = 2 @@ -44,11 +43,3 @@ path = "src/gen_test_assets.rs" [[bin]] name = "pipeline_bench" path = "src/pipeline_bench.rs" - -[[bin]] -name = "paint_opt_worker" -path = "src/bin/paint_opt_worker.rs" - -[[bin]] -name = "paint_meta_opt_worker" -path = "src/bin/paint_meta_opt_worker.rs" diff --git a/scripts/meta_optimize_distributed.sh b/scripts/meta_optimize_distributed.sh deleted file mode 100755 index d05afce2..00000000 --- a/scripts/meta_optimize_distributed.sh +++ /dev/null @@ -1,141 +0,0 @@ -#!/usr/bin/env bash -# Distributed meta-optimizer. -# -# Splits N outer ScoreWeights samples between THIS machine and a remote -# SSH host, runs each as `paint_meta_opt_worker N --inner I --passes P`, -# collects all `MetaResult`s, lexicographically sorts (matches the -# in-process `compare_reports`), and prints the best result + a top-5 -# table. -# -# Each outer sample = build a random ScoreWeights candidate at index N -# → run the FULL inner optimizer under those weights → score the result. -# Outer samples are independent → embarrassingly parallel across hosts. -# -# Usage: -# scripts/meta_optimize_distributed.sh [N] [I] [P] [REMOTE] -# N = total outer samples (default 16) -# I = inner starts per outer (default 16) -# P = inner passes (default 4) -# REMOTE = "user@host:/path/to/repo" (default $REMOTE_TRAC3R) -# -# Splits via LOCAL_FRAC env var (default 0.4 = 40 % local, 60 % remote). -# Tune for unequal cores; with 14-core local + 24-core remote, 0.37 is -# proportional. - -set -euo pipefail - -N="${1:-16}" -I="${2:-16}" -P="${3:-4}" -REMOTE="${4:-${REMOTE_TRAC3R:-}}" -LOCAL_FRAC="${LOCAL_FRAC:-0.37}" - -ROOT="$(cd "$(dirname "$0")/.." && pwd)" -TMPDIR="$(mktemp -d -t meta-distrib.XXXXXX)" -trap 'rm -rf "$TMPDIR"' EXIT - -echo "[orch] meta-opt: $N outer × $I inner × $P passes" >&2 -echo "[orch] root=$ROOT remote=$REMOTE" >&2 - -LOCAL_N=$(awk -v n="$N" -v f="$LOCAL_FRAC" 'BEGIN{ printf "%d", int(n*f + 0.5) }') -REMOTE_N=$((N - LOCAL_N)) -[[ -z "$REMOTE" ]] && { LOCAL_N="$N"; REMOTE_N=0; } -echo "[orch] split: $LOCAL_N local, $REMOTE_N remote" >&2 - -# Build local first. -echo "[orch] cargo build --release --bin paint_meta_opt_worker (local)…" >&2 -( cd "$ROOT" && cargo build --release --bin paint_meta_opt_worker ) >&2 - -# Build remote in parallel (login shell so cargo is on PATH). -REMOTE_BUILD_PID="" -if [[ -n "$REMOTE" ]]; then - HOST="${REMOTE%%:*}" - RPATH="${REMOTE#*:}" - echo "[orch] cargo build --release on remote ($HOST:$RPATH)…" >&2 - # Don't quote $RPATH inside the bash -lc script — leaving it bare - # lets tilde expansion work (cd ~/source/foo). With quotes, bash - # sees "~/source/foo" as a literal pathname and `cd` fails. We - # accept the no-spaces-in-path constraint that comes with this. - ( ssh "$HOST" "bash -lc 'cd $RPATH && cargo build --release --bin paint_meta_opt_worker'" >&2 ) & - REMOTE_BUILD_PID=$! -fi - -# Concurrency policy: each meta-worker already saturates rayon -# internally (par_iter inner starts × par_iter corpus). Running TWO -# in parallel on the same box doubles thread pressure with no gain -# and probably some loss. So: at most one local + one remote at a time. -# -# Use xargs -P1 to serialize on each host. The two HOSTS run in -# parallel because we kick them off as separate background pipelines. - -LOCAL_OUT="$TMPDIR/local.json" -: > "$LOCAL_OUT" -LOCAL_PID="" -if (( LOCAL_N > 0 )); then - echo "[orch] dispatching $LOCAL_N samples to local (serial)…" >&2 - ( - for i in $(seq 0 $((LOCAL_N - 1))); do - "$ROOT/target/release/paint_meta_opt_worker" "$i" --inner "$I" --passes "$P" - done - ) >> "$LOCAL_OUT" & - LOCAL_PID=$! -fi - -REMOTE_OUT="$TMPDIR/remote.json" -: > "$REMOTE_OUT" -REMOTE_PID="" -if [[ -n "$REMOTE" && "$REMOTE_N" -gt 0 ]]; then - [[ -n "$REMOTE_BUILD_PID" ]] && wait "$REMOTE_BUILD_PID" - HOST="${REMOTE%%:*}" - RPATH="${REMOTE#*:}" - echo "[orch] dispatching $REMOTE_N samples to $HOST (serial on remote)…" >&2 - ( - seq "$LOCAL_N" $((N - 1)) | \ - ssh "$HOST" "bash -lc 'cd $RPATH && while read -r i; do ./target/release/paint_meta_opt_worker \$i --inner $I --passes $P; done'" - ) >> "$REMOTE_OUT" & - REMOTE_PID=$! -fi - -[[ -n "$LOCAL_PID" ]] && wait "$LOCAL_PID" -[[ -n "$REMOTE_PID" ]] && wait "$REMOTE_PID" - -ALL="$TMPDIR/all.json" -cat "$LOCAL_OUT" "$REMOTE_OUT" > "$ALL" -LINES=$(wc -l < "$ALL" | tr -d ' ') -echo "[orch] collected $LINES results" >&2 -if [[ "$LINES" -ne "$N" ]]; then - echo "[orch] WARNING: expected $N, got $LINES" >&2 -fi - -# Lex-sort matching CorpusReport's compare_reports: -# tier-1: fail_coverage, fail_single_stroke, fail_two_stroke, fail_length_budget, fail_bg -# tier-2: total_bg, total_strokes, total_unpainted_density, total_repaint, total_length -echo "" >&2 -echo "[orch] top 5 by lex order:" >&2 -jq -s 'sort_by([ - .report.fail_coverage, - .report.fail_single_stroke, - .report.fail_two_stroke, - .report.fail_length_budget, - .report.fail_bg, - .report.total_bg, - .report.total_strokes, - .report.total_unpainted_density, - .report.total_repaint, - .report.total_length -]) | .[0:5] | .[] | "idx=\(.idx) T1[cov=\(.report.fail_coverage) bg=\(.report.fail_bg) 1stk=\(.report.fail_single_stroke) 2stk=\(.report.fail_two_stroke) len=\(.report.fail_length_budget)] T2[bg=\(.report.total_bg) stk=\(.report.total_strokes) dens=\(.report.total_unpainted_density|round) rep=\(.report.total_repaint) len=\(.report.total_length|round)]"' -r "$ALL" >&2 - -echo "" >&2 -echo "[orch] best (full JSON on stdout):" >&2 -jq -s 'sort_by([ - .report.fail_coverage, - .report.fail_single_stroke, - .report.fail_two_stroke, - .report.fail_length_budget, - .report.fail_bg, - .report.total_bg, - .report.total_strokes, - .report.total_unpainted_density, - .report.total_repaint, - .report.total_length -]) | .[0]' "$ALL" diff --git a/scripts/optimize_distributed.sh b/scripts/optimize_distributed.sh deleted file mode 100755 index 861f78fd..00000000 --- a/scripts/optimize_distributed.sh +++ /dev/null @@ -1,118 +0,0 @@ -#!/usr/bin/env bash -# Distributed brush-paint optimizer. -# -# Splits N optimizer starts between THIS machine and a remote SSH host, -# runs each as a `paint_opt_worker N` invocation, and prints the best -# result by score. Each "start" runs ONE refinement pass (golden-section -# coordinate descent from one randomized seed); they're embarrassingly -# parallel — no synchronisation, no shared state. -# -# Usage: -# scripts/optimize_distributed.sh [N] [REMOTE] -# N = total number of starts (default 32). 0..3 are seeded, -# 4..N-1 are random. -# REMOTE = "user@host:/path/to/Trac3r-rust" (default: env $REMOTE_TRAC3R). -# -# Examples: -# scripts/optimize_distributed.sh -# scripts/optimize_distributed.sh 64 me@beefy:~/source/Trac3r-rust -# -# Defaults: 32 starts, ~split half/half. Adjust LOCAL_FRAC for unequal -# machines. With your beefy=24t, mac=8t-ish, LOCAL_FRAC=0.25 puts 75% -# of work on the desktop. - -set -euo pipefail - -N="${1:-32}" -REMOTE="${2:-${REMOTE_TRAC3R:-}}" -LOCAL_FRAC="${LOCAL_FRAC:-0.25}" # 25% local, 75% remote (24t desktop) - -ROOT="$(cd "$(dirname "$0")/.." && pwd)" -TMPDIR="$(mktemp -d -t opt-distrib.XXXXXX)" -trap 'rm -rf "$TMPDIR"' EXIT - -echo "[orch] $N starts, root=$ROOT, tmp=$TMPDIR" >&2 - -# How many starts go local vs remote. -LOCAL_N=$(awk -v n="$N" -v f="$LOCAL_FRAC" 'BEGIN{ printf "%d", int(n*f + 0.5) }') -REMOTE_N=$((N - LOCAL_N)) - -echo "[orch] split: $LOCAL_N local, $REMOTE_N remote" >&2 - -# Build local binary first (release mode). -echo "[orch] cargo build --release --bin paint_opt_worker (local)…" >&2 -( cd "$ROOT" && cargo build --release --bin paint_opt_worker ) >&2 - -# Build remote binary in parallel if a remote is configured. We invoke -# remote commands via `bash -lc` so the login shell sources whatever -# rc files put cargo on PATH (~/.cargo/bin); without that, ssh's -# non-login shell may fail with "cargo: command not found". -REMOTE_BUILD_PID="" -if [[ -n "$REMOTE" ]]; then - HOST="${REMOTE%%:*}" - RPATH="${REMOTE#*:}" - echo "[orch] cargo build --release on remote ($HOST:$RPATH)…" >&2 - ( ssh "$HOST" "bash -lc 'cd $RPATH && cargo build --release --bin paint_opt_worker'" >&2 ) & - REMOTE_BUILD_PID=$! -fi - -# Wait for local build to be ready, then dispatch local work in -# background. xargs -P0 = max parallelism (each worker uses rayon for -# its own internal evaluations, but they're CPU-bound so we want the -# OS to schedule). -LOCAL_OUT="$TMPDIR/local.json" -: > "$LOCAL_OUT" -if (( LOCAL_N > 0 )); then - echo "[orch] launching $LOCAL_N local workers…" >&2 - seq 0 $((LOCAL_N - 1)) | \ - xargs -n1 -P"$LOCAL_N" -I{} \ - "$ROOT/target/release/paint_opt_worker" {} \ - >> "$LOCAL_OUT" & - LOCAL_PID=$! -else - LOCAL_PID="" -fi - -# Wait for remote build, then dispatch remote work. -REMOTE_OUT="$TMPDIR/remote.json" -: > "$REMOTE_OUT" -REMOTE_PID="" -if [[ -n "$REMOTE" && "$REMOTE_N" -gt 0 ]]; then - if [[ -n "$REMOTE_BUILD_PID" ]]; then - wait "$REMOTE_BUILD_PID" - fi - HOST="${REMOTE%%:*}" - RPATH="${REMOTE#*:}" - echo "[orch] launching $REMOTE_N remote workers on $HOST…" >&2 - # Generate the index list local-side and stream to xargs over ssh. - seq "$LOCAL_N" $((N - 1)) | \ - ssh "$HOST" "bash -lc 'cd $RPATH && xargs -n1 -P$REMOTE_N -I{} ./target/release/paint_opt_worker {}'" \ - >> "$REMOTE_OUT" & - REMOTE_PID=$! -fi - -# Wait for both pools. -[[ -n "$LOCAL_PID" ]] && wait "$LOCAL_PID" -[[ -n "$REMOTE_PID" ]] && wait "$REMOTE_PID" - -# Collect + pick the best by lowest .score field. One JSON per line. -ALL="$TMPDIR/all.json" -cat "$LOCAL_OUT" "$REMOTE_OUT" > "$ALL" - -LINES=$(wc -l < "$ALL" | tr -d ' ') -echo "[orch] collected $LINES results" >&2 -if [[ "$LINES" -ne "$N" ]]; then - echo "[orch] WARNING: expected $N, got $LINES" >&2 -fi - -# Sort by score (ascending), print top 5 to stderr, full best to stdout. -echo "" >&2 -echo "[orch] top 5 by score:" >&2 -sort -t : -k 1.1,1.1 "$ALL" \ - | jq -s 'sort_by(.score) | .[0:5] | .[] | "\(.start_idx)\t\(.score)"' -r >&2 \ - || awk -F'"score":' '{ split($2, a, ","); printf "%s\t%s\n", $0, a[1] }' "$ALL" \ - | sort -k2 -n | head -5 >&2 - -echo "" >&2 -echo "[orch] best result (full JSON on stdout):" >&2 -jq -s 'sort_by(.score) | .[0]' "$ALL" diff --git a/src-frontend/src/App.jsx b/src-frontend/src/App.jsx index a651775a..7339eb96 100644 --- a/src-frontend/src/App.jsx +++ b/src-frontend/src/App.jsx @@ -5,7 +5,6 @@ import TuningPanel from './components/TuningPanel.jsx' import CalibrationButtons from './components/CalibrationButtons.jsx' import CalibrationAxis from './components/CalibrationAxis.jsx' import TextEditOverlay from './components/TextEditOverlay.jsx' -import PaintDebugView from './components/PaintDebugView.jsx' import NodeGraph from './components/NodeGraph.jsx' import PassPanel from './components/PassPanel.jsx' import PerfPanel from './components/PerfPanel.jsx' @@ -15,7 +14,7 @@ import * as tauri from './hooks/useTauri.js' import { serialize, deserialize } from './project.js' import { useFps } from './hooks/useFps.js' -const VIEW_MODES = ['source', 'detection', 'contours', 'gcode', 'paint', 'printer', 'tuning'] +const VIEW_MODES = ['source', 'detection', 'contours', 'gcode', 'printer', 'tuning'] export default function App() { const [image, setImage] = useState(null) @@ -615,8 +614,6 @@ export default function App() { /> ) : viewMode === 'tuning' ? ( - ) : viewMode === 'paint' ? ( - ) : viewMode === 'source' && sourceMode === 'text' ? ( `hsl(${((i * 137.508) % 360).toFixed(1)}, 80%, 55%)` - -// HSL gradient red(0)→green(120) keyed on rank in [0,1]. -const rankHue = (rank01) => `hsl(${(rank01 * 120).toFixed(1)}, 90%, 55%)` - -const polylineLength = (pts) => { - let L = 0 - for (let i = 1; i < pts.length; i++) { - const dx = pts[i][0] - pts[i - 1][0] - const dy = pts[i][1] - pts[i - 1][1] - L += Math.hypot(dx, dy) - } - return L -} - -// Sum of |angle changes| across a polyline (in radians). -const polylineCurvature = (pts) => { - let c = 0 - for (let i = 1; i < pts.length - 1; i++) { - const ax = pts[i][0] - pts[i - 1][0], ay = pts[i][1] - pts[i - 1][1] - const bx = pts[i + 1][0] - pts[i][0], by = pts[i + 1][1] - pts[i][1] - const la = Math.hypot(ax, ay), lb = Math.hypot(bx, by) - if (la < 1e-9 || lb < 1e-9) continue - let cosT = (ax * bx + ay * by) / (la * lb) - if (cosT > 1) cosT = 1 - if (cosT < -1) cosT = -1 - c += Math.abs(Math.acos(cosT)) - } - return c -} - -export default function PaintDebugView({ passIdx = 0 }) { - const [hulls, setHulls] = useState([]) - const [hullIdx, setHullIdx] = useState(0) - const [params, setParams] = useState({ ...DEFAULT_PAINT_PARAMS }) - const setParam = (k, v) => setParams(p => ({ ...p, [k]: v })) - const [sourceOpacity, setSourceOpacity] = useState(0.4) - const [sdfOpacity, setSdfOpacity] = useState(0.5) - const [coverageOpacity, setCoverageOpacity] = useState(0.7) - const [debug, setDebug] = useState(null) - const [enabled, setEnabled] = useState( - Object.fromEntries(LAYERS.map(l => [l.key, l.on])), - ) - const [stepEnabled, setStepEnabled] = useState( - Object.fromEntries(STEP_LAYERS.map(l => [l.key, l.on])), - ) - const [view, setView] = useState({ zoom: 1, panX: 0, panY: 0 }) - const containerRef = useRef(null) - // Separate from `containerRef` (which is the outer panel container) — - // this one wraps just the SVG so we can size HTML overlays to its - // pixel dimensions. - const svgContainerRef = useRef(null) - const svgRef = useRef(null) - // Canvas for raster layers (source/sdf/coverage/snapshot). Bypasses - // both WebKit's SVG nearest-neighbor bug AND the - // foreignObject transform issue. ctx.imageSmoothingEnabled = false - // is honored everywhere. - const rasterCanvasRef = useRef(null) - const rasterCacheRef = useRef(new Map()) // src → loaded HTMLImageElement - const dragRef = useRef(null) - const [hover, setHover] = useState(null) - const [selBox, setSelBox] = useState(null) - const [toast, setToast] = useState(null) - - // Walker scrubber state - const [walkIdx, setWalkIdx] = useState(0) - const [stepIdx, setStepIdx] = useState(0) - const [playing, setPlaying] = useState(false) - const [playSpeedMs, setPlaySpeedMs] = useState(250) - const [candHover, setCandHover] = useState(null) - // last mouse pos in screen coords for tooltip placement - const cursorRef = useRef({ x: 0, y: 0 }) - // Bump on every successful test-letter load so the debug fetch effect - // re-fires even when (passIdx, hullIdx, params, hulls.length) all stay - // the same (common: any letter with 1 hull replaces another with 1 hull - // and hullIdx defaults to 0 both times). - const [reloadKey, setReloadKey] = useState(0) - - useEffect(() => { - let alive = true - tauri.listHulls(passIdx).then(list => { - if (!alive) return - const sorted = [...list].sort((a, b) => b.area - a.area) - setHulls(sorted) - if (sorted.length > 0) setHullIdx(sorted[0].index) - }).catch(() => {}) - return () => { alive = false } - }, [passIdx]) - - useEffect(() => { - if (hulls.length === 0) { setDebug(null); return } - let alive = true - tauri.getPaintDebug(passIdx, hullIdx, params).then(d => { - if (!alive) return - setDebug(d) - }).catch(() => {}) - return () => { alive = false } - }, [passIdx, hullIdx, params, hulls.length, reloadKey]) - - useEffect(() => { - setView({ zoom: 1, panX: 0, panY: 0 }) - }, [hullIdx]) - - // When new debug data arrives, clamp scrubber selection. - useEffect(() => { - if (!debug || !debug.walks || debug.walks.length === 0) return - setWalkIdx(w => Math.max(0, Math.min(w, debug.walks.length - 1))) - }, [debug]) - - const walks = debug?.walks ?? [] - const walk = walks[walkIdx] ?? null - const stepCount = walk?.steps?.length ?? 0 - - useEffect(() => { - setStepIdx(s => Math.max(0, Math.min(s, Math.max(0, stepCount - 1)))) - }, [walkIdx, stepCount]) - - const step = walk?.steps?.[stepIdx] ?? null - - // Play loop - useEffect(() => { - if (!playing || !walk) return - const id = setInterval(() => { - setStepIdx(s => { - if (s >= stepCount - 1) { - setPlaying(false) - return s - } - return s + 1 - }) - }, playSpeedMs) - return () => clearInterval(id) - }, [playing, playSpeedMs, walk, stepCount]) - - const viewBox = useMemo(() => { - if (!debug) return '0 0 100 100' - const [x0, y0, x1, y1] = debug.bounds - const pad = Math.max(2, (x1 - x0) * 0.04) - const w = (x1 - x0) + 2 * pad - const h = (y1 - y0) + 2 * pad - return `${x0 - pad - view.panX} ${y0 - pad - view.panY} ${w / view.zoom} ${h / view.zoom}` - }, [debug, view]) - - // Bump on resize so the layout-effect below re-runs and re-measures - // the SVG container synchronously. The actual measurement happens - // in the layout-effect itself via getBoundingClientRect — keeping - // the size in state has been unreliable (initial mount measures - // zero, async update doesn't always fire before the canvas - // renders). Synchronous measurement on every render that matters - // is more robust than chasing state. - const [resizeTick, setResizeTick] = useState(0) - useEffect(() => { - const el = svgContainerRef.current - if (!el) return - const ro = new ResizeObserver(() => setResizeTick(t => t + 1)) - ro.observe(el) - return () => ro.disconnect() - }, []) - - // Render raster layers (source / sdf / coverage / pre-snapshot) onto - // the canvas every time inputs change. useLayoutEffect runs - // synchronously after DOM commit but before browser paint — the - // canvas ref is guaranteed populated and we measure the container's - // actual bounding rect right then. This avoids the first-render - // "containerSize is still 0" trap. Body is just a wrapper around - // `renderRasterLayersToCanvas` (pure, tested). - useLayoutEffect(() => { - const canvas = rasterCanvasRef.current - const divEl = svgContainerRef.current - if (!divEl) return - const cache = rasterCacheRef.current - const loadImage = (src) => { - const cached = cache.get(src) - if (cached && cached.complete && cached.naturalWidth > 0) { - return Promise.resolve(cached) - } - return new Promise(resolve => { - const img = new Image() - img.onload = () => { cache.set(src, img); resolve(img) } - img.onerror = () => resolve(null) - img.src = src - }) - } - let cancelled = false - renderRasterLayersToCanvas({ - canvas, - divRect: divEl.getBoundingClientRect(), - debug, - viewBox, - enabled, - opacity: { source: sourceOpacity, sdf: sdfOpacity, coverage: coverageOpacity }, - walkIdx, - loadImage, - isCancelled: () => cancelled, - }).then(result => { - // Surface unexpected early-outs in the console so a regression - // is loud, not silent. 'ok', 'no-debug', 'no-layers', - // 'cancelled' are all normal; the others mean something is - // wrong with refs, layout, or the raster pipeline. - if (result && !['ok', 'no-debug', 'no-layers', 'cancelled'].includes(result.reason)) { - console.warn('[paint-debug] raster render skipped:', result.reason, result) - } - }) - return () => { cancelled = true } - }, [debug, viewBox, resizeTick, - enabled.source, enabled.sdf, enabled.preSnapshot, enabled.coverage, - walkIdx, sourceOpacity, sdfOpacity, coverageOpacity, enabled]) - - const onWheel = (e) => { - e.preventDefault() - if (!debug || !svgRef.current) return - const factor = Math.exp(-e.deltaY * ZOOM_SENSITIVITY) - const rect = svgRef.current.getBoundingClientRect() - const u = (e.clientX - rect.left) / rect.width - const vN = (e.clientY - rect.top) / rect.height - const [x0, y0, x1, y1] = debug.bounds - const pad = Math.max(2, (x1 - x0) * 0.04) - const wBase = (x1 - x0) + 2 * pad - const hBase = (y1 - y0) + 2 * pad - setView(v => { - const newZoom = Math.max(0.1, Math.min(200, v.zoom * factor)) - if (newZoom === v.zoom) return v - const dW = wBase * (1 / newZoom - 1 / v.zoom) - const dH = hBase * (1 / newZoom - 1 / v.zoom) - return { zoom: newZoom, panX: v.panX + u * dW, panY: v.panY + vN * dH } - }) - } - - const clientToImage = (clientX, clientY) => { - const svg = svgRef.current - if (!svg) return null - const pt = svg.createSVGPoint(); pt.x = clientX; pt.y = clientY - const ctm = svg.getScreenCTM(); if (!ctm) return null - return pt.matrixTransform(ctm.inverse()) - } - - const onMouseDown = (e) => { - if (e.button !== 0) return - if (e.shiftKey) { - const start = clientToImage(e.clientX, e.clientY) - if (!start) return - e.preventDefault() - setSelBox({ x0: start.x, y0: start.y, x1: start.x, y1: start.y }) - const onMove = (ev) => { - const cur = clientToImage(ev.clientX, ev.clientY); if (!cur) return - setSelBox(b => b && { ...b, x1: cur.x, y1: cur.y }) - } - const onUp = () => { - document.removeEventListener('mousemove', onMove) - document.removeEventListener('mouseup', onUp) - setSelBox(b => { if (!b) return null; finalizeSelection(b); return null }) - } - document.addEventListener('mousemove', onMove) - document.addEventListener('mouseup', onUp) - return - } - dragRef.current = { - startX: e.clientX, startY: e.clientY, - origPanX: view.panX, origPanY: view.panY, - } - const onMove = (ev) => { - const s = dragRef.current; if (!s) return - const rect = containerRef.current.getBoundingClientRect() - if (!debug) return - const [x0, y0, x1, y1] = debug.bounds - const w = (x1 - x0) * 1.08 / view.zoom - const dx = (ev.clientX - s.startX) / rect.width * w - const dy = (ev.clientY - s.startY) / rect.height * (y1 - y0) * 1.08 / view.zoom - setView(v => ({ ...v, panX: s.origPanX + dx, panY: s.origPanY + dy })) - } - const onUp = () => { - dragRef.current = null - document.removeEventListener('mousemove', onMove) - document.removeEventListener('mouseup', onUp) - } - document.addEventListener('mousemove', onMove) - document.addEventListener('mouseup', onUp) - } - - function finalizeSelection(box) { - if (!debug) return - const lo = { x: Math.min(box.x0, box.x1), y: Math.min(box.y0, box.y1) } - const hi = { x: Math.max(box.x0, box.x1), y: Math.max(box.y0, box.y1) } - if (hi.x - lo.x < 0.5 || hi.y - lo.y < 0.5) return - dumpDebug([round2(lo.x), round2(lo.y), round2(hi.x), round2(hi.y)]) - } - - const round2 = (n) => Math.round(n * 100) / 100 - const r2 = (p) => [round2(p[0]), round2(p[1])] - const r2list = (pts) => pts.map(r2) - - // Full-hull dump. Captures everything needed to reproduce this exact - // case: the source pixel mask (base64 PNG), bounds, the params that - // produced the strokes, and all algorithm output (start points, raw - // trajectories, smoothed strokes). The optional `selected_box` lets - // the user point at a specific area of interest within the hull. - function dumpDebug(selected_box = null) { - if (!debug) return - const out = { - hull_index: hullIdx, - hull_bounds: debug.bounds, - brush_radius: debug.brush_radius, - sdf_max: debug.sdf_max, - params, - source_b64: debug.source_b64, - selected_box, - start_points: r2list(debug.start_points), - trajectories: debug.trajectories.map(r2list), - strokes: debug.strokes.map(r2list), - } - navigator.clipboard.writeText(JSON.stringify(out, null, 2)).then(() => { - const note = selected_box - ? `Region dump: ${out.trajectories.length} traj · ${out.strokes.length} strokes (full hull data included)` - : `Hull dump: ${out.trajectories.length} traj · ${out.strokes.length} strokes` - setToast(note) - setTimeout(() => setToast(null), 4000) - }).catch(err => { - setToast(`Clipboard failed: ${err.message ?? err}`) - setTimeout(() => setToast(null), 4000) - }) - } - - const onMouseMoveSvg = (e) => { - cursorRef.current = { x: e.clientX, y: e.clientY } - const ip = clientToImage(e.clientX, e.clientY) - if (ip) setHover({ x: ip.x, y: ip.y }) - } - - const toggleLayer = (key) => setEnabled(en => ({ ...en, [key]: !en[key] })) - const toggleStepLayer = (key) => setStepEnabled(en => ({ ...en, [key]: !en[key] })) - - // ── Step viz: painted-so-far waypoints ─────────────────────────────────────── - // Memoised — rebuilds only when (walkIdx, stepIdx, brush_radius) change. - const paintedWaypoints = useMemo(() => { - if (!walk || !step) return [] - const path = walk.path ?? [] - // path is the full walker path; we want index 0..stepIdx INCLUSIVE. - // path is typically (steps.length + 1) long: start + one per step. - const k = Math.min(stepIdx + 1, path.length) - return path.slice(0, k) - }, [walkIdx, stepIdx, walk, step]) - - // ── Score breakdown for the whole hull ─────────────────────────────────────── - const scoreBreakdown = useMemo(() => { - if (!debug) return null - const totalLen = (debug.strokes ?? []).reduce((a, s) => a + polylineLength(s), 0) - const totalCurv = (debug.strokes ?? []).reduce((a, s) => a + polylineCurvature(s), 0) - const numStrokes = debug.strokes?.length ?? 0 - const skel = debug.skeleton_length ?? 0 - const budget = LENGTH_BUDGET_FACTOR * skel - const lengthExcess = Math.max(0, totalLen - budget) - const brushR = debug.brush_radius ?? 0 - - const rows = [ - { name: 'stroke', raw: numStrokes, unit: 'count', weight: SCORE_WEIGHTS.stroke }, - { name: 'length', raw: totalLen, unit: 'px', weight: SCORE_WEIGHTS.length }, - { name: 'bg', raw: debug.bg_painted ?? 0, unit: 'px', weight: SCORE_WEIGHTS.bg }, - { name: 'repaint', raw: debug.repaint ?? 0, unit: 'px', weight: SCORE_WEIGHTS.repaint }, - { name: 'unpainted', raw: debug.ink_unpainted ?? 0, unit: 'px', weight: SCORE_WEIGHTS.unpainted }, - { name: 'length_excess', raw: lengthExcess, unit: 'px', weight: SCORE_WEIGHTS.length_excess }, - { name: 'curvature', raw: totalCurv, unit: 'rad', weight: SCORE_WEIGHTS.curvature }, - { name: 'brush_size', raw: brushR, unit: 'px', weight: SCORE_WEIGHTS.brush_size }, - ] - const total = rows.reduce((a, r) => a + r.raw * r.weight, 0) - return { rows, total, totalLen, totalCurv, budget, lengthExcess } - }, [debug]) - - // ── Hull metrics ───────────────────────────────────────────────────────────── - const hullMetrics = useMemo(() => { - if (!debug) return null - const inkTotal = debug.ink_total ?? 0 - const inkUnpainted = debug.ink_unpainted ?? 0 - const totalSwept = debug.total_swept ?? 0 - const bgPainted = debug.bg_painted ?? 0 - const skel = debug.skeleton_length ?? 0 - const totalLen = (debug.strokes ?? []).reduce((a, s) => a + polylineLength(s), 0) - const coverage = inkTotal > 0 ? (inkTotal - inkUnpainted) / inkTotal * 100 : 0 - const offGlyph = totalSwept > 0 ? bgPainted / totalSwept * 100 : 0 - const lenRatio = skel > 0 ? totalLen / skel : 0 - return { - coverage, offGlyph, lenRatio, totalLen, - coverageOk: coverage >= 95, - offGlyphOk: offGlyph <= 5, - lenRatioOk: lenRatio <= 2.0, - } - }, [debug]) - - // ── Keyboard shortcuts (when SVG focused) ──────────────────────────────────── - const onSvgKeyDown = useCallback((e) => { - if (!walk) return - if (e.key === 'ArrowLeft') { setStepIdx(s => Math.max(0, s - 1)); e.preventDefault() } - else if (e.key === 'ArrowRight') { setStepIdx(s => Math.min(stepCount - 1, s + 1)); e.preventDefault() } - else if (e.key === ' ') { setPlaying(p => !p); e.preventDefault() } - else if (e.key === 'Home') { setStepIdx(0); e.preventDefault() } - else if (e.key === 'End') { setStepIdx(Math.max(0, stepCount - 1)); e.preventDefault() } - }, [walk, stepCount]) - - if (!debug) { - return ( -
-
- { - const sorted = [...list].sort((a, b) => b.area - a.area) - setHulls(sorted) - if (sorted.length > 0) setHullIdx(sorted[0].index) - setReloadKey(k => k + 1) - }} /> -
-

Pick a test letter above to start, or load an image and run - the pipeline (Source → Kernel → Hull) to debug a real glyph.

-
-
-
- No hull loaded -
-
- ) - } - - // Compute candidate ranking (by score, ignoring rejected ones). - const candidateRanks = (() => { - if (!step) return null - const cands = step.candidates ?? [] - const live = cands - .map((c, i) => ({ c, i })) - .filter(({ c }) => !c.rejected_back && !c.rejected_off_ink) - const sorted = [...live].sort((a, b) => a.c.score - b.c.score) - const rank = new Map() // candidate index → rank01 - sorted.forEach(({ i }, k) => { - rank.set(i, sorted.length > 1 ? k / (sorted.length - 1) : 1) - }) - return rank - })() - - const brushR = debug.brush_radius ?? 1 - - return ( -
-
- { - const sorted = [...list].sort((a, b) => b.area - a.area) - setHulls(sorted) - if (sorted.length > 0) setHullIdx(sorted[0].index) - setReloadKey(k => k + 1) - }} /> - -
- - -
- -
-
Layers
-
- {LAYERS.map(l => ( - - ))} -
-
- -
-
Step viz
-
- {STEP_LAYERS.map(l => ( - - ))} -
-
- - - - - - - - - - - -
-
- Brush -
- - -
-
- 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." /> -
- -
-
- - setSourceOpacity(parseFloat(e.target.value))} - className="w-full" /> -
-
- - setSdfOpacity(parseFloat(e.target.value))} - className="w-full" /> -
-
- - setCoverageOpacity(parseFloat(e.target.value))} - className="w-full" /> -
-
- -
-

Wheel: zoom · Drag: pan · Shift+drag: copy region

-

SVG keys: ←/→ step · Space play · Home/End

- -
-
· 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
-
- - -
- -
-
- Step - - {stepCount === 0 ? '—' : `${stepIdx + 1} / ${stepCount}`} - -
- setStepIdx(parseInt(e.target.value, 10))} - className="w-full" /> -
- -
- - - - - - -
- - {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 }) => ( - {children} - ) - const Row = ({ k, v }) => ( -
- {k} - {v} -
- ) - return ( -
-
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 }) => ( -
- {k} - {v} -
- ) - if (!step) { - return ( -
-
Selected step
-
no step (walk has 0 iters · exit: {walk.exit_reason})
-
- ) - } - const cands = step.candidates ?? [] - const rejBack = cands.filter(c => c.rejected_back).length - const rejOff = cands.filter(c => c.rejected_off_ink).length - const accepted = cands.length - rejBack - rejOff - const mom = step.prev_dir ? Math.hypot(step.prev_dir[0], step.prev_dir[1]) : 0 - const chosen = step.chosen != null ? cands[step.chosen] : null - const fmt = (n) => Number.isFinite(n) ? n.toFixed(3) : String(n) - return ( -
-
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…} -
-
- - -
-
- {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} - - )} -
- -
- {TEST_CHARS.map(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, "![{}mm/{}dpi]({}mm_{}dpi.png)\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); - } - } -}