diff --git a/src-frontend/src/App.jsx b/src-frontend/src/App.jsx index 15ef234f..922f7f16 100644 --- a/src-frontend/src/App.jsx +++ b/src-frontend/src/App.jsx @@ -5,7 +5,8 @@ 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 ChordalDebugView from './components/ChordalDebugView.jsx' +import StreamlineDebugView from './components/StreamlineDebugView.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 +16,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', 'chordal', 'printer', 'tuning'] +const VIEW_MODES = ['source', 'detection', 'contours', 'gcode', 'streamline', 'paint', 'printer', 'tuning'] export default function App() { const [image, setImage] = useState(null) @@ -566,7 +567,7 @@ export default function App() { {/* Top bar — accent colors match the section dots in the left panel */}
{VIEW_MODES.map(m => { - const accent = { detection: '#6366f1', contours: '#14b8a6', gcode: '#f59e0b', chordal: '#ec4899', printer: '#10b981', tuning: '#a855f7' }[m] + const accent = { detection: '#6366f1', contours: '#14b8a6', gcode: '#f59e0b', streamline: '#ec4899', paint: '#22d3ee', printer: '#10b981', tuning: '#a855f7' }[m] const label = m === 'gcode' ? 'G-code' : m.charAt(0).toUpperCase() + m.slice(1) return ( -

-
-
· {debug.outer.length} outer verts
-
· {debug.holes.length} holes ({debug.hole_pixels.reduce((s, h) => s + h.length, 0)} px)
-
· {debug.triangles.length} triangles - ({debug.triangles.filter(t => t.kind !== 'outside').length} interior)
-
· {debug.segments.length} CAT segments
-
· {debug.polylines.length} polylines - ({debug.polylines.filter(p => p.branch).length} branches, - {' '}{debug.polylines.filter(p => !p.kept).length} pruned)
-
· {debug.strokes.length} final strokes
-
· source_b64: {debug.source_b64 - ? `${(debug.source_b64.length / 1024).toFixed(1)} KB` - : 'MISSING (rebuild Rust)'}
-
- {hover && ( -
- ({hover.x.toFixed(2)}, {hover.y.toFixed(2)}) -
- )} -
- - - {/* Canvas */} -
- - - {/* Source pixels — bottom-most, so all algorithm layers render on top. - imageRendering pixelated keeps it crisp under zoom. xlinkHref - alongside href for max webview compatibility. */} - {enabled.source && debug.source_b64 && ( - - )} - - {/* Hole pixel cells (raster underlay) */} - {enabled.holePixels && debug.hole_pixels.map((hp, hi) => ( - - {hp.map(([x, y], i) => ( - - ))} - - ))} - - {/* Triangulation: fill by classification, stroke for edges. - strokeWidth is in screen pixels (vectorEffect:non-scaling-stroke), - so 0.6 = ~half a CSS pixel, visible at every zoom level. */} - {enabled.triangulation && debug.triangles.map((t, i) => { - const fill = enabled.classification ? (KIND_FILL[t.kind] ?? 'transparent') : 'transparent' - return ( - - `${p[0]},${p[1]}`).join(' ')} - fill={fill} - stroke="rgba(160, 160, 165, 0.85)" - strokeWidth={0.6} - vectorEffect="non-scaling-stroke" - /> - {/* Constraint edges drawn thicker + bright so they read as - "this is the polygon boundary, the rest are diagonals." */} - {t.edge_constraint.map((c, ei) => { - if (!c) return null - const a = t.points[ei] - const b = t.points[(ei + 1) % 3] - return ( - - ) - })} - - ) - })} - - {/* Outer polygon (highlighted on top) */} - {enabled.outer && ( - `${p[0]},${p[1]}`).join(' ')} - fill="none" stroke="#34d399" strokeWidth={1.2} - vectorEffect="non-scaling-stroke" /> - )} - - {/* Hole polygons */} - {enabled.holes && debug.holes.map((h, i) => ( - `${p[0]},${p[1]}`).join(' ')} - fill="none" stroke="#fbbf24" strokeWidth={1.0} - vectorEffect="non-scaling-stroke" /> - ))} - - {/* Polygon vertices */} - {enabled.vertices && ( - - {debug.outer.map((p, i) => ( - - ))} - {debug.holes.flatMap((h, hi) => h.map((p, i) => ( - - )))} - - )} - - {/* Raw CAT segments (before walking) */} - {enabled.segments && debug.segments.map(([a, b], i) => ( - - ))} - - {/* Raw polylines (before smoothing) */} - {enabled.polylines && debug.polylines.map((pl, i) => ( - `${p[0]},${p[1]}`).join(' ')} - fill="none" - stroke={pl.branch ? '#a78bfa' : '#22d3ee'} - strokeWidth={0.8} - vectorEffect="non-scaling-stroke" /> - ))} - - {/* Pruned (dropped by salience) — dashed red so it's visible against - the kept set. Only meaningful when salience > 0. */} - {enabled.pruned && debug.polylines.filter(p => !p.kept).map((pl, i) => ( - `${p[0]},${p[1]}`).join(' ')} - fill="none" - stroke="#ef4444" - strokeWidth={0.8} - strokeDasharray="2 1" - vectorEffect="non-scaling-stroke" /> - ))} - - {/* Final smoothed strokes (the gcode output) */} - {enabled.strokes && debug.strokes.map((s, i) => ( - `${p[0]},${p[1]}`).join(' ')} - fill="none" stroke="#f8fafc" strokeWidth={0.8} - strokeLinecap="round" strokeLinejoin="round" - vectorEffect="non-scaling-stroke" /> - ))} - - {/* Selection box (live during shift-drag) */} - {selBox && ( - - )} - - - {/* Shift+drag hint, bottom-left */} -
- Shift+drag to copy region data to clipboard -
- - {/* Toast notification */} - {toast && ( -
- {toast} -
- )} -
- - ) -} diff --git a/src-frontend/src/components/PaintDebugView.jsx b/src-frontend/src/components/PaintDebugView.jsx new file mode 100644 index 00000000..b6669018 --- /dev/null +++ b/src-frontend/src/components/PaintDebugView.jsx @@ -0,0 +1,496 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import * as tauri from '../hooks/useTauri.js' +import { DEFAULT_PAINT_PARAMS } from '../hooks/useTauri.js' + +const IS_DARWIN = typeof navigator !== 'undefined' && + /Mac|iPhone|iPad|iPod/i.test(navigator.platform || navigator.userAgent || '') +const ZOOM_SENSITIVITY = IS_DARWIN ? 0.0015 : 0.015 + +const LAYERS = [ + { key: 'source', label: '0. Source pixels', on: true }, + { key: 'sdf', label: '1. SDF heatmap', on: false }, + { key: 'coverage', label: '2. Missed-pixel mask', on: false }, + { key: 'starts', label: '3. Start points', on: true }, + { key: 'brushSweep', label: '4. Brush sweep (radius)', on: false }, + { key: 'trajectory', label: '5. Raw trajectories', on: true }, + { key: 'strokes', label: '6. Smoothed strokes', on: true }, +] + +const strokeHue = (i) => `hsl(${((i * 137.508) % 360).toFixed(1)}, 80%, 55%)` + +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 [view, setView] = useState({ zoom: 1, panX: 0, panY: 0 }) + const containerRef = useRef(null) + const svgRef = useRef(null) + const dragRef = useRef(null) + const [hover, setHover] = useState(null) + const [selBox, setSelBox] = useState(null) + const [toast, setToast] = useState(null) + + 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) 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]) + + useEffect(() => { + setView({ zoom: 1, panX: 0, panY: 0 }) + }, [hullIdx]) + + 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]) + + 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) => { + 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] })) + + if (!debug) { + return ( +
+
+

Paint debug

+

No hulls available — run the pipeline first (Source → Kernel → Hull).

+
+
+ ) + } + + return ( +
+
+
+ + +
+ +
+
Layers
+
+ {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('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('outside_penalty', v)} + hint="Cost per background-pixel under brush. Reject moves that drift the path off the glyph." /> + 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 epsilon." /> + setParam('output_chaikin', v)} hint="Final stroke Chaikin smoothing passes." /> +
+ +
+
+ + 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

+ +
+
· 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
+
+ {hover && ( +
+ ({hover.x.toFixed(2)}, {hover.y.toFixed(2)}) +
+ )} +
+
+ +
+ + + {enabled.source && debug.source_b64 && ( + + )} + + {enabled.sdf && debug.sdf_b64 && ( + + )} + + {enabled.coverage && debug.coverage_b64 && ( + + )} + + {/* 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} + + + ))} + + {selBox && ( + + )} + + +
+ Shift+drag to copy region data to clipboard +
+ + {toast && ( +
+ {toast} +
+ )} +
+
+ ) +} + +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/components/StreamlineDebugView.jsx b/src-frontend/src/components/StreamlineDebugView.jsx new file mode 100644 index 00000000..21a63ca8 --- /dev/null +++ b/src-frontend/src/components/StreamlineDebugView.jsx @@ -0,0 +1,471 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import * as tauri from '../hooks/useTauri.js' +import { DEFAULT_STREAMLINE_PARAMS } from '../hooks/useTauri.js' + +// macOS trackpads emit lots of small wheel events per gesture. Apply a +// gentler per-event factor on Darwin. +const IS_DARWIN = typeof navigator !== 'undefined' && + /Mac|iPhone|iPad|iPod/i.test(navigator.platform || navigator.userAgent || '') +const ZOOM_SENSITIVITY = IS_DARWIN ? 0.0015 : 0.015 + +const LAYERS = [ + { key: 'source', label: '0. Source pixels', on: true }, + { key: 'sdf', label: '1. SDF heatmap', on: false }, + { key: 'visited', label: '2. Visited mask', on: false }, + { key: 'starts', label: '3. Start points', on: true }, + { key: 'trajectory',label: '4. Raw trajectories', on: true }, + { key: 'strokes', label: '5. Smoothed strokes', on: true }, +] + +// Per-stroke colour cycle for clarity (golden-ratio hue rotation). +const strokeHue = (i) => `hsl(${((i * 137.508) % 360).toFixed(1)}, 80%, 55%)` + +export default function StreamlineDebugView({ passIdx = 0 }) { + const [hulls, setHulls] = useState([]) + const [hullIdx, setHullIdx] = useState(0) + const [params, setParams] = useState({ ...DEFAULT_STREAMLINE_PARAMS }) + const setParam = (k, v) => setParams(p => ({ ...p, [k]: v })) + const [sourceOpacity, setSourceOpacity] = useState(0.4) + const [sdfOpacity, setSdfOpacity] = useState(0.7) + const [debug, setDebug] = useState(null) + const [enabled, setEnabled] = useState( + Object.fromEntries(LAYERS.map(l => [l.key, l.on])), + ) + const [view, setView] = useState({ zoom: 1, panX: 0, panY: 0 }) + const containerRef = useRef(null) + const svgRef = useRef(null) + const dragRef = useRef(null) + const [hover, setHover] = useState(null) + const [selBox, setSelBox] = useState(null) + const [toast, setToast] = useState(null) + + 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) return + let alive = true + tauri.getStreamlineDebug(passIdx, hullIdx, params).then(d => { + if (!alive) return + setDebug(d) + }).catch(() => {}) + return () => { alive = false } + }, [passIdx, hullIdx, params, hulls.length]) + + useEffect(() => { + setView({ zoom: 1, panX: 0, panY: 0 }) + }, [hullIdx]) + + 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]) + + 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 + const ip = pt.matrixTransform(ctm.inverse()) + return { x: ip.x, y: ip.y } + } + + 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 + + const ptIn = (p) => p[0] >= lo.x && p[0] <= hi.x && p[1] >= lo.y && p[1] <= hi.y + const anyIn = (pts) => pts.some(ptIn) + const round2 = (n) => Math.round(n * 100) / 100 + const r2 = (p) => [round2(p[0]), round2(p[1])] + const r2list = (pts) => pts.map(r2) + + const out = { + hull_index: hullIdx, + box: [round2(lo.x), round2(lo.y), round2(hi.x), round2(hi.y)], + start_points_in_box: r2list(debug.start_points.filter(ptIn)), + trajectories: debug.trajectories.filter(anyIn).map(r2list), + strokes: debug.strokes.filter(anyIn).map(r2list), + } + const json = JSON.stringify(out, null, 2) + navigator.clipboard.writeText(json).then(() => { + setToast(`Copied — ${out.trajectories.length} traj · ${out.strokes.length} strokes`) + setTimeout(() => setToast(null), 3000) + }).catch(err => { + setToast(`Clipboard write failed: ${err.message ?? err}`) + setTimeout(() => setToast(null), 4000) + }) + } + + const onMouseMoveSvg = (e) => { + if (!debug) return + const svg = e.currentTarget + const pt = svg.createSVGPoint(); pt.x = e.clientX; pt.y = e.clientY + const ctm = svg.getScreenCTM(); if (!ctm) return + const ip = pt.matrixTransform(ctm.inverse()) + setHover({ x: ip.x, y: ip.y }) + } + + const toggleLayer = (key) => setEnabled(en => ({ ...en, [key]: !en[key] })) + + if (!debug) { + return ( +
+
+

Streamline debug

+

No hulls available — run the pipeline first (Source → Kernel → Hull).

+
+
+ ) + } + + return ( +
+ {/* Sidebar */} +
+
+ + +
+ +
+
Layers
+
+ {LAYERS.map(l => ( + + ))} +
+
+ +
+
+ Dynamics + +
+ setParam('speed', v)} + hint="Constant pen speed (px/step). Direction can rotate, magnitude is renormalised." /> + setParam('dt', v)} + hint="Time step. Step distance per iteration = speed × dt." /> + setParam('ridge_lerp', v)} + hint="Direction-lerp rate toward local ridge tangent. Lower = stickier momentum." /> + setParam('center_strength', v)} + hint="Per-step lateral nudge toward higher SDF (counters drift on curves)." /> + setParam('min_clearance', v)} + hint="Stop when SDF at the particle drops below this — drifted off ridge." /> +
+ +
+ Pivot detection + setParam('pivot_threshold', v)} + hint="−∇D·v̂ value above which look-ahead fires (gradient opposing velocity)." /> + setParam('lookahead_radius', v)} + hint="Radius (px) for pivot direction sampling." /> + setParam('pivot_steer_rate', v)} + hint="How fast velocity snaps to chosen pivot direction." /> + setParam('min_pivot_score', v)} + hint="Minimum mean-SDF along a pivot direction to count as viable continuation." /> +
+ +
+ Mask · loop · caps + setParam('visited_radius', v)} + hint="Radius (px) of the visited-mask stamp at each step." /> + setParam('loop_close_radius', v)} + hint="Stop when the particle returns within this many px of stroke start." /> + setParam('min_loop_distance', v)} + hint="Don't let loop-close fire until particle has travelled at least this far." /> + setParam('min_stroke_length', v)} + hint="Drop strokes shorter than this — fringe artifacts from pick_start." /> + setParam('max_steps_per_stroke', v)} + hint="Safety cap." /> + setParam('max_strokes', v)} + hint="Safety cap on strokes per hull." /> +
+ +
+ Output smoothing + setParam('output_rdp_eps', v)} + hint="Final stroke RDP epsilon." /> + setParam('output_chaikin', v)} + hint="Final stroke Chaikin smoothing passes." /> +
+ +
+
+ + setSourceOpacity(parseFloat(e.target.value))} + className="w-full" /> +
+
+ + setSdfOpacity(parseFloat(e.target.value))} + className="w-full" /> +
+
+ +
+

Zoom: wheel · Pan: drag · Shift+drag: copy region

+ +
+
· {debug.start_points.length} start points
+
· {debug.trajectories.length} raw trajectories
+
· {debug.strokes.length} smoothed strokes
+
· sdf max: {debug.sdf_max?.toFixed(2) ?? '—'} px
+
+ {hover && ( +
+ ({hover.x.toFixed(2)}, {hover.y.toFixed(2)}) +
+ )} +
+
+ + {/* Canvas */} +
+ + + {enabled.source && debug.source_b64 && ( + + )} + + {enabled.sdf && debug.sdf_b64 && ( + + )} + + {enabled.visited && debug.visited_b64 && ( + + )} + + {enabled.trajectory && debug.trajectories.map((t, i) => ( + `${p[0]},${p[1]}`).join(' ')} + fill="none" stroke={strokeHue(i)} strokeWidth={1} + 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} + + + ))} + + {selBox && ( + + )} + + +
+ Shift+drag to copy region data to clipboard +
+ + {toast && ( +
+ {toast} +
+ )} +
+
+ ) +} + +function ParamSlider({ label, value, min, max, step, onChange, hint }) { + // Guard: if a default-params entry is missing (e.g. backend renamed a + // field), don't blow up the whole UI. Show "—" and let the user notice. + 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 c5a8ee2d..4277762f 100644 --- a/src-frontend/src/hooks/useTauri.js +++ b/src-frontend/src/hooks/useTauri.js @@ -26,8 +26,58 @@ export async function listHulls(passIdx = 0) { return tracedInvoke('list_hulls', { passIdx }) } -export async function getChordalDebug(passIdx, hullIdx, salience = 0) { - return tracedInvoke('get_chordal_debug', { passIdx, hullIdx, salience }) +// Default StreamlineParams must match Rust's `impl Default for StreamlineParams`. +// Values from streamline_optimize coordinate descent over 62-glyph alphabet. +export const DEFAULT_STREAMLINE_PARAMS = { + speed: 1.5, + dt: 0.5, + ridge_lerp: 0.3, + center_strength: 0.5, + min_clearance: 0.2, + pivot_threshold: 0.2, + lookahead_radius: 5.0, + pivot_steer_rate: 1.0, + min_pivot_score: 0.2, + visited_radius: 1.2, + loop_close_radius: 5.0, + min_loop_distance: 50.0, + min_stroke_length: 2.0, + max_steps_per_stroke: 4000, + max_strokes: 12, + output_rdp_eps: 0.5, + output_chaikin: 2, +} + +export async function getStreamlineDebug(passIdx, hullIdx, params = DEFAULT_STREAMLINE_PARAMS) { + return tracedInvoke('get_streamline_debug', { passIdx, hullIdx, params }) +} + +// Default PaintParams must match Rust's `impl Default for PaintParams`. +export const DEFAULT_PAINT_PARAMS = { + brush_radius_factor: 1.0, + brush_radius_offset_px: 0.5, + brush_radius_percentile: 0.99, + step_size_factor: 0.5, + n_directions: 24, + lookahead_steps: 4, + momentum_weight: 0.4, + overpaint_penalty: 0.05, + walk_bg_penalty: 0.3, + min_score_factor: 0.05, + polish_iters: 4, + polish_search_factor: 0.5, + outside_penalty: 2.0, + min_component_factor: 0.6, + pen_lift_penalty: 30.0, + pen_lift_reach: 6.0, + max_steps_per_stroke: 4000, + max_strokes: 12, + output_rdp_eps: 0.5, + output_chaikin: 2, +} + +export async function getPaintDebug(passIdx, hullIdx, params = DEFAULT_PAINT_PARAMS) { + return tracedInvoke('get_paint_debug', { passIdx, hullIdx, params }) } export async function getAllStrokes() { diff --git a/src-frontend/src/store.js b/src-frontend/src/store.js index 29ba8475..599e6e00 100644 --- a/src-frontend/src/store.js +++ b/src-frontend/src/store.js @@ -4,7 +4,7 @@ export const KERNELS = ['Luminance','Sobel','ColorGradient','Laplacian','Canny','Saturation','XDoG','ColorIsolate'] export const BLEND_MODES = ['Average','Min','Max','Multiply','Screen','Difference'] -export const FILL_STRATEGIES = ['hatch','zigzag','offset','spiral','outline','circles','voronoi','hilbert','waves','flow','gradient_hatch','gradient_cross_hatch','skeleton','centerline','chordal'] +export const FILL_STRATEGIES = ['hatch','zigzag','offset','spiral','outline','circles','voronoi','hilbert','waves','flow','gradient_hatch','gradient_cross_hatch','skeleton','centerline','streamline','topo','paint'] // Per-strategy secondary parameter exposed as a slider. // Strategies not listed here have no secondary parameter. @@ -19,8 +19,6 @@ export const FILL_STRATEGY_PARAMS = { hint: '1.0 = uniform · 0.05 = 20× denser at darkest ink' }, gradient_cross_hatch: { label: 'Min Scale', min: 0.05, max: 1.0, step: 0.05, default: 0.25, hint: '1.0 = uniform · 0.05 = 20× denser at darkest ink' }, - chordal: { label: 'Prune', min: 0, max: 4, step: 0.1, default: 0, - hint: 'Drop centerline tails shorter than N× local stroke width. Scale-invariant. 1.5–2.5 removes junction artifacts; 0 keeps everything.' }, } // Strategies that use the angle slider diff --git a/src/brush_paint.rs b/src/brush_paint.rs new file mode 100644 index 00000000..832f7b0b --- /dev/null +++ b/src/brush_paint.rs @@ -0,0 +1,2264 @@ +// 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::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 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 in the walk's lookahead score. Treats painting + /// outside the glyph as worse than overpainting our own ink, but small + /// enough that we don't refuse to navigate past minor bg overlap when + /// it's the only way forward. Sums over `lookahead_steps` with 1/k + /// weighting — keep modest. See `outside_penalty` for the polish-time + /// equivalent (which can be much heavier since it's per-perturbation, + /// not accumulated). + 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, + /// Number of relax↔shorten tick-tock rounds after the bidirectional + /// walk. Each round runs (a) waypoint relaxation toward unpainted ink, + /// then (b) waypoint pruning where it doesn't lose coverage. 0 disables. + pub polish_iters: u32, + /// How far (in brush radii) to search for unpainted ink near each + /// waypoint during relaxation. + pub polish_search_factor: f32, + /// Per-pixel penalty when the brush hangs outside the original ink + /// (i.e., the brush disk covers background). Strongly discourages + /// perturbations that drift the path off the glyph. Measured in + /// "ink pixels"; 1.0 = "1 background pixel under brush is worth + /// not painting 1 ink pixel." + pub outside_penalty: f32, + /// Minimum unpainted-ink component size (as a multiplier of brush + /// area = π·r²) to start a new stroke. Components smaller than this + /// are leftovers from the previous stroke's brush sweep that the + /// relaxation didn't catch — we paint them with a single disk and + /// move on instead of attempting a doomed walk. 1.0 = "must be at + /// least one full brush-disc worth of unpainted ink." + pub min_component_factor: f32, + /// "Pen lift" cost: how many ink-pixel-equivalents of overpaint+bg cost + /// the walker is willing to absorb to reach unpainted ink without + /// terminating the stroke. 0 = always terminate when local lookahead + /// dries up (= stroke per blob). Higher values let the walker double + /// back across already-painted ink to bridge to a new ink region — + /// e.g. M's bottom-V apex, where one stroke can naturally cover both + /// diagonals if it can pass through the painted apex. + pub pen_lift_penalty: f32, + /// How far (in brush radii) the bridge lookahead can reach when the + /// normal lookahead's best score is below `min_score`. Bridging only + /// kicks in when this is > step_size_factor × lookahead_steps. + pub pen_lift_reach: f32, + /// Cap. + pub max_steps_per_stroke: u32, + pub max_strokes: u32, + /// Final stroke RDP epsilon and Chaikin passes. + pub output_rdp_eps: f32, + pub output_chaikin: u32, +} + +impl Default for PaintParams { + fn default() -> Self { + Self { + brush_radius_factor: 1.0, + brush_radius_offset_px: 0.5, + brush_radius_percentile: 0.99, + step_size_factor: 0.5, + n_directions: 24, + lookahead_steps: 4, + momentum_weight: 0.4, + overpaint_penalty: 0.05, + walk_bg_penalty: 0.3, + min_score_factor: 0.05, + polish_iters: 4, + polish_search_factor: 0.5, + outside_penalty: 2.0, + min_component_factor: 0.6, + pen_lift_penalty: 30.0, + pen_lift_reach: 6.0, + max_steps_per_stroke: 4000, + max_strokes: 12, + output_rdp_eps: 0.5, + output_chaikin: 2, + } + } +} + +#[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, + /// 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)>, +} + +/// Re-simulate the brush sweep over the final strokes and count how +/// 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)] +} + +/// many ink vs background pixels would be covered. The "bg_painted" +/// number is what gets drawn outside the glyph on the actual plot — +/// that's the off-glyph ink that visible artifacts come from. +fn measure_sweep(strokes: &[Vec<(f32, f32)>], grid: &Grid, brush_radius: f32) + -> (u32, u32) +{ + if strokes.is_empty() { return (0, 0); } + let mut swept = vec![false; grid.was_ink.len()]; + let r = (brush_radius + 1.0).ceil() as i32; + let r2 = brush_radius * brush_radius; + // Sample each stroke densely (~half-pixel along each segment) so we + // don't miss pixels between sparse waypoints after smoothing. + 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 ddy in -r..=r { + for ddx in -r..=r { + 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; } + swept[(ly * grid.width + lx) as usize] = true; + } + } + } + } + } + let mut bg = 0u32; + let mut total = 0u32; + for (i, &s) in swept.iter().enumerate() { + if !s { continue; } + total += 1; + if !grid.was_ink[i] { bg += 1; } + } + (bg, total) +} + +fn encode_coverage_b64(grid: &Grid) -> String { + let bw = grid.width.max(1) as u32; + let bh = grid.height.max(1) as u32; + let mut img: image::RgbaImage = image::ImageBuffer::new(bw, bh); + for ly in 0..grid.height { + for lx in 0..grid.width { + let idx = (ly * grid.width + lx) as usize; + // Was-ink test: we don't have a separate "was-ink" mask, but + // we can reconstruct from the grid's initial state (after + // construction, unpainted == ink). After tracing, unpainted=true + // means "was ink and STILL not painted" — the gaps. Anything + // else is either background or already-painted-ink. We can't + // distinguish those without a second mask. + // + // For the coverage view, paint UNPAINTED ink red (= missed). + // Painted/background stays transparent. + if grid.unpainted[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) +} + +// ── Coverage grid: bool per pixel, sized to the hull's bbox ───────────── + +struct Grid { + bx: i32, by: i32, + width: i32, height: i32, + /// `true` = ink pixel that hasn't been painted yet. + unpainted: Vec, + /// `true` = pixel was ink in the original glyph (immutable; never + /// changes after construction). Lets relaxation tell "ink" apart from + /// "background" without conflating it with painted state. + was_ink: Vec, + /// Chamfer 3-4 distance / 3 (≈ Euclidean px from boundary). Used to + /// snap raw start points up the gradient onto the medial-axis ridge, + /// so strokes begin at stroke-centerline rather than polygon-edge. + sdf: Vec, + /// Skeleton-endpoint pixel positions (degree-1 nodes of the thinned + /// glyph after spur pruning). These are the "legs" — the natural + /// pen-down anchors for a human writing the letter. A closed shape + /// (O, 0, etc.) has zero endpoints. + skel_endpoints: Vec<(i32, i32)>, + /// Total ink pixel count (for stop-when-fully-covered). + ink_total: i32, + /// Currently unpainted ink pixel count. + ink_remaining: i32, +} + +impl Grid { + fn from_hull(hull: &Hull) -> Self { + let bx = hull.bounds.x_min as i32; + let by = hull.bounds.y_min as i32; + let width = (hull.bounds.x_max as i32 - bx + 1).max(1); + let height = (hull.bounds.y_max as i32 - by + 1).max(1); + let cells = (width * height) as usize; + let mut unpainted = vec![false; cells]; + let mut was_ink = vec![false; 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; + unpainted[idx] = true; + was_ink[idx] = true; + count += 1; + } + // Chamfer distance (per-pixel, in approximate Euclidean units) + // for medial-axis snapping in pick_start. + 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; + } + // Skeleton + endpoint detection. Spur prune length is keyed to the + // glyph's max stroke half-width so tiny notches at thick strokes + // don't masquerade as legs. (zhang_suen + spur_prune is the same + // recipe the topo-stroke and skeleton-fill paths use.) + let sdf_max = dist.values().copied().fold(0.0_f32, f32::max).max(0.5); + 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)); + let skel_endpoints: Vec<(i32, i32)> = skel.iter() + .filter(|&&p| zs_neighbors(p.0, p.1).iter().filter(|n| skel.contains(n)).count() == 1) + .map(|&(x, y)| (x as i32, y as i32)) + .collect(); + + Self { bx, by, width, height, unpainted, was_ink, sdf, skel_endpoints, + ink_total: count, ink_remaining: count } + } + + /// 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.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. + 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.was_ink[(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), radius: f32) -> (i32, i32, i32) { + let cx_i = p.0.round() as i32; + let cy_i = p.1.round() as i32; + let r = (radius + 1.0).ceil() as i32; + let r2 = radius * radius; + let mut new_ink = 0; + let mut repaint_ink = 0; + let mut bg = 0; + for dy in -r..=r { + for dx in -r..=r { + // 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[idx] { + new_ink += 1; + } else if self.was_ink[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), radius: f32) -> i32 { + let cx_i = p.0.round() as i32; + let cy_i = p.1.round() as i32; + let r = (radius + 1.0).ceil() as i32; + let r2 = radius * radius; + let mut newly = 0; + for dy in -r..=r { + for dx in -r..=r { + 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[idx] { + self.unpainted[idx] = false; + 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[(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 not worth a separate stroke — we paint + /// them with a single disk stamp here and skip. 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, brush_radius: f32) + -> Option<(f32, f32)> + { + 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[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[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[nidx] && comp_id[nidx] < 0 { + stack.push((nx, ny)); + } + } + } + components.push((pixels, (top, left, bot, right))); + } + } + if components.is_empty() { return None; } + + // Drop sub-threshold components: paint them with a single disk + // stamp at their centroid and forget about them. They were just + // mask-edge artifacts from the previous stroke's brush sweep. + 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 { + // Paint each pixel once and move on (fast — pixels are + // already unpainted, so just flip them off and decrement). + for &idx in pixels { + if self.unpainted[idx] { + self.unpainted[idx] = false; + self.ink_remaining -= 1; + } + } + 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 => return None }; + + // 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. + // Fall back to the topmost-leftmost ink pixel if no endpoint is + // available (closed shapes like O, or after a partial fill where + // every endpoint sits in already-painted territory). + let (pixels, _) = &components[chosen]; + let comp_set: HashSet = pixels.iter().copied().collect(); + let mut best_endpoint: Option<(i32, i32)> = None; + for &(ex, ey) in &self.skel_endpoints { + 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; } + match best_endpoint { + None => best_endpoint = Some((ex, ey)), + Some((bx_e, by_e)) if ey < by_e || (ey == by_e && ex < bx_e) => { + best_endpoint = Some((ex, ey)); + } + _ => {} + } + } + let raw = match best_endpoint { + Some((ex, ey)) => (ex as f32, ey as f32), + 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) + } + }; + Some(self.snap_to_ridge(raw, 16)) + } +} + +// ── 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. Returns the +/// total new-coverage minus penalised overpaint. +fn lookahead_score(start: (f32, f32), dir: (f32, f32), + grid: &Grid, params: &PaintParams, + brush_radius: f32, step_size: f32) -> f32 +{ + // Walk k steps in direction `dir` and accumulate scores. We don't + // actually paint into a copy of the grid (that would be expensive); + // instead, we approximate by treating each step's coverage independently + // — fine because consecutive disks at step_size = 0.5*radius have + // ~75% overlap, so each step's NEW coverage is roughly the leading + // edge of the disk, which IS independent across steps. + 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, brush_radius); + 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 + - params.overpaint_penalty * total_repaint + - params.walk_bg_penalty * total_bg +} + +/// Find the nearest unpainted ink pixel reachable from `start` by walking +/// only through ink (painted OR unpainted), using SDF-weighted Dijkstra +/// so the path hugs centerlines (high-SDF ridges are cheap, near-edge +/// pixels are expensive). Caller passes `max_pixel_radius` — the search +/// is cut off beyond that euclidean distance from start. Returns the +/// next-step direction toward the target, or None if no target is +/// reachable inside the budget. Cost is in "step pixels" so the caller +/// can compare it directly against `pen_lift_penalty`. +fn nearest_unpainted_through_ink(start: (f32, f32), grid: &Grid, + max_pixel_radius: f32) + -> Option<((f32, f32), f32)> +{ + use std::collections::BinaryHeap; + use std::cmp::Reverse; + + let sx = start.0.round() as i32; + let sy = start.1.round() as i32; + if !grid.is_ink(sx, sy) { return None; } + if grid.is_unpainted(sx, sy) { + // Nothing to do — caller would have a positive score in that case. + return Some(((0.0, 0.0), 0.0)); + } + + let r = max_pixel_radius.ceil() as i32; + let r2 = max_pixel_radius * max_pixel_radius; + let bx = sx - r; + let by = sy - r; + let span = 2 * r as usize + 1; + let cells = span * span; + let mut dist = vec![f32::INFINITY; cells]; + let mut prev = vec![(-1i32, -1i32); cells]; + let local = |x: i32, y: i32| -> Option { + let lx = x - bx; let ly = y - by; + if lx < 0 || ly < 0 || lx as usize >= span || ly as usize >= span { return None; } + Some(ly as usize * span + lx as usize) + }; + + let s_idx = local(sx, sy)?; + dist[s_idx] = 0.0; + let mut heap: BinaryHeap<(Reverse, i32, i32)> = BinaryHeap::new(); + heap.push((Reverse(0), sx, sy)); + + while let Some((Reverse(d_int), x, y)) = heap.pop() { + let here = local(x, y)?; + let d = d_int as f32 / 1024.0; + if d > dist[here] + 1e-3 { continue; } + + if (x, y) != (sx, sy) && grid.is_unpainted(x, y) { + // Reconstruct: walk prev[] back to start, take FIRST step. + let mut cx = x; let mut cy = y; + loop { + let idx = local(cx, cy).unwrap(); + let (px, py) = prev[idx]; + if (px, py) == (sx, sy) || (px, py) == (-1, -1) { + let dx = (cx - sx) as f32; + let dy = (cy - sy) as f32; + let mag = (dx * dx + dy * dy).sqrt().max(1e-6); + return Some(((dx / mag, dy / mag), d)); + } + cx = px; cy = py; + } + } + + for &(dx, dy) in &[(1,0i32),(-1,0),(0,1),(0,-1),(1,1),(1,-1),(-1,1),(-1,-1)] { + let nx = x + dx; let ny = y + dy; + if !grid.is_ink(nx, ny) { continue; } + // Stay inside the radius budget. + let rdx = (nx - sx) as f32; let rdy = (ny - sy) as f32; + if rdx * rdx + rdy * rdy > r2 { continue; } + // Step cost: euclidean length × ridge-aversion factor. High + // SDF (ridge interior) → cheap. Low SDF (near edge) → expensive. + // The +0.5 keeps the factor finite at boundary pixels. + let step_len = if dx != 0 && dy != 0 { 1.41421356 } else { 1.0 }; + let ridge = grid.sdf_at(nx, ny); + let factor = 1.0 + 1.5 / (ridge + 0.5); + let nd = d + step_len * factor; + let nidx = match local(nx, ny) { Some(i) => i, None => continue }; + if nd < dist[nidx] { + dist[nidx] = nd; + prev[nidx] = (x, y); + heap.push((Reverse((nd * 1024.0) as u32), nx, ny)); + } + } + } + None +} + +/// 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). +fn walk_brush(start: (f32, f32), init_dir: Option<(f32, f32)>, + grid: &mut Grid, params: &PaintParams, brush_radius: f32) + -> 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, brush_radius); + + let mut prev_dir: Option<(f32, f32)> = init_dir.map(vec_unit); + + 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. + let mut best: Option<((f32, f32), f32)> = None; + 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()); + // Skip near-back-direction if we have momentum, to avoid + // immediately flipping back over what we just painted. + if has_momentum && vec_dot(dir, prev_dir_unit) < -0.7 { continue; } + let mut score = lookahead_score(p, dir, grid, params, brush_radius, step_size); + if has_momentum { + let align = vec_dot(dir, prev_dir_unit).max(0.0); + score += params.momentum_weight * align * brush_area; + } + match best { + None => best = Some((dir, score)), + Some((_, bs)) if score > bs => best = Some((dir, score)), + _ => {} + } + } + + let (dir, score) = match best { Some(b) => b, None => break }; + + // Decide whether to step normally or fall back to a repaint + // search. We fall back when either (a) the regular lookahead is + // too dim to justify a step, or (b) we'd land on already-painted + // ink and we're sitting on already-painted ink — the "stuck" case + // where the natural human move is to double back through painted + // territory to reach more unpainted ink. + let would_be_stuck = { + let new_p_probe = (p.0 + dir.0 * step_size, p.1 + dir.1 * step_size); + let (nc, _, _) = grid.evaluate_disk(new_p_probe, brush_radius); + nc == 0 && grid.evaluate_disk(p, brush_radius).0 == 0 + }; + + let chosen_dir = if score >= min_score && !would_be_stuck { + dir + } else { + // Stuck or low-score: do an SDF-guided Dijkstra through ink + // (painted or unpainted) to the nearest unpainted ink pixel. + // The path hugs centerlines (high-SDF cheap, low-SDF expensive) + // so the walker doubles back along the ridge of an existing + // stroke instead of cutting across bg territory. + if params.pen_lift_penalty <= 0.0 { break; } + let max_radius = params.pen_lift_reach * brush_radius; + match nearest_unpainted_through_ink(p, grid, max_radius) { + Some((rd, cost)) if cost <= params.pen_lift_penalty => rd, + _ => break, + } + }; + + let new_p = (p.0 + chosen_dir.0 * step_size, p.1 + chosen_dir.1 * step_size); + p = new_p; + path.push(p); + prev_dir = Some(chosen_dir); + grid.paint_disk(p, brush_radius); + } + path +} + +/// 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), grid: &mut Grid, + params: &PaintParams, brush_radius: f32) -> Vec<(f32, f32)> +{ + // Snapshot pre-stroke ink state so we can relax against the original + // unpainted mask (without our own path's contributions confusing the + // "is this pixel uncovered?" question). + let pre_unpainted = grid.unpainted.clone(); + let pre_ink_remaining = grid.ink_remaining; + + // ── Bidirectional walk ────────────────────────────────────────────── + // Forward walk biased downward — `pick_next_component` puts us at the + // topmost-leftmost ridge point of the component, so "down" is the + // direction a human pen would naturally take from the start. + let forward = walk_brush(start, Some((0.0, 1.0)), grid, params, brush_radius); + if forward.len() < 2 { return forward; } + + let combined = { + 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 { + forward + } else { + let back_init = (-dx / mag, -dy / mag); + let backward = walk_brush(start, Some(back_init), grid, params, brush_radius); + if backward.len() < 2 { + forward + } else { + let mut c: Vec<(f32, f32)> = Vec::with_capacity(forward.len() + backward.len()); + for &p in backward.iter().rev() { c.push(p); } + for &p in forward.iter().skip(1) { c.push(p); } + c + } + } + }; + + // ── Relaxation ────────────────────────────────────────────────────── + if params.polish_iters == 0 || combined.len() < 3 { + return combined; + } + + // Restore the pre-stroke unpainted mask so the relaxation sees the + // FULL set of pixels this stroke could potentially cover, not what's + // left over after the walk's painting. + grid.unpainted = pre_unpainted; + grid.ink_remaining = pre_ink_remaining; + + let polished = polish_path(combined, grid, brush_radius, params); + + // Now paint the final polished path back into the grid. + for &p in &polished { grid.paint_disk(p, brush_radius); } + + polished +} + +/// Tick-tock relax + shorten. Each round: +/// 1. Relax: nudge each interior waypoint toward unpainted ink (subject +/// to the "stay-on-shape" constraint and outside-penalty). +/// 2. Shorten: drop waypoints whose removal causes zero coverage loss. +fn polish_path(mut path: Vec<(f32, f32)>, grid: &Grid, + brush_radius: f32, params: &PaintParams) -> Vec<(f32, f32)> +{ + if path.len() < 3 { return path; } + + // Coverage count: how many waypoints' brushes cover each pixel. We + // maintain this incrementally across both relax and shorten passes. + let mut count: Vec = vec![0; grid.unpainted.len()]; + for &p in &path { stamp_count(&mut count, grid, p, brush_radius, 1); } + + for _ in 0..params.polish_iters { + let any_relaxed = relax_step(&mut path, &mut count, grid, brush_radius, params); + let any_shortened = shorten_step(&mut path, &mut count, grid, brush_radius); + if !any_relaxed && !any_shortened { break; } + } + path +} + +/// One sweep of waypoint relaxation. Returns true if any waypoint moved. +/// Only accepts perturbations that: +/// - Land the waypoint INSIDE the original glyph (no off-shape drift) +/// - Improve net score (ink-gain - ink-loss - outside_penalty * background-gain) +fn relax_step(path: &mut Vec<(f32, f32)>, count: &mut Vec, + grid: &Grid, brush_radius: f32, params: &PaintParams) -> bool +{ + let n = path.len(); + if n < 3 { return false; } + let max_perturb = brush_radius * 0.6; + let search_r = brush_radius * params.polish_search_factor; + let mut moved = false; + + // Iterate ALL waypoints including endpoints. A true dead-end has no + // unpainted ink nearby, so `nearest_uncovered_ink` returns None and + // the loop body bails — endpoints stay put. A misplaced edge-hugger + // gets pulled toward the centerline like any interior waypoint. + for i in 0..n { + let p_old = path[i]; + let target = match nearest_uncovered_ink(p_old, search_r, grid, count) { + Some(t) => t, None => continue, + }; + let dx = target.0 - p_old.0; + let dy = target.1 - p_old.1; + let dist = (dx * dx + dy * dy).sqrt(); + if dist < 0.3 { continue; } + let shift = (dist * 0.7).min(max_perturb); + let p_new = (p_old.0 + dx / dist * shift, + p_old.1 + dy / dist * shift); + + // Hard constraint: waypoint center must be inside the original + // glyph. Otherwise the gcode's pen would draw a line outside the + // letter — visible, ugly, fatal. + if !grid.is_ink(p_new.0.round() as i32, p_new.1.round() as i32) { continue; } + + let score = evaluate_perturbation(p_old, p_new, brush_radius, grid, count, params); + if score > 0.0 { + stamp_count(count, grid, p_old, brush_radius, -1); + stamp_count(count, grid, p_new, brush_radius, 1); + path[i] = p_new; + moved = true; + } + } + moved +} + +/// One sweep of waypoint pruning. Removes any interior waypoint whose +/// brush is FULLY redundant (every ink pixel under it is covered by some +/// other waypoint too). Returns true if any waypoint was removed. +fn shorten_step(path: &mut Vec<(f32, f32)>, count: &mut Vec, + grid: &Grid, brush_radius: f32) -> bool +{ + if path.len() < 3 { return false; } + let mut removed_any = false; + let mut i = 1usize; + while i + 1 < path.len() { + let p = path[i]; + if waypoint_is_redundant(p, brush_radius, grid, count) { + stamp_count(count, grid, p, brush_radius, -1); + path.remove(i); + removed_any = true; + // Don't increment i — the next waypoint shifted into i. + } else { + i += 1; + } + } + removed_any +} + +/// True iff every pre-stroke-unpainted ink pixel under disk(p, r) is +/// covered by at least one OTHER waypoint (i.e., count > 1 there). When +/// true, removing waypoint at p doesn't drop coverage anywhere. +fn waypoint_is_redundant(p: (f32, f32), brush_radius: f32, + grid: &Grid, count: &[u16]) -> bool +{ + let cx_i = p.0.round() as i32; + let cy_i = p.1.round() as i32; + let r = (brush_radius + 1.0).ceil() as i32; + let r2 = brush_radius * brush_radius; + for dy in -r..=r { + for dx in -r..=r { + 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 - grid.bx; + let ly = cy_i + dy - grid.by; + if lx < 0 || ly < 0 || lx >= grid.width || ly >= grid.height { continue; } + let idx = (ly * grid.width + lx) as usize; + if !grid.unpainted[idx] { continue; } // ineligible pixel + if count[idx] <= 1 { return false; } // we'd lose this one + } + } + true +} + +/// Add `delta` to coverage count over the disk(p, radius). Used to +/// install or remove a waypoint's brush contribution. +fn stamp_count(count: &mut [u16], grid: &Grid, p: (f32, f32), radius: f32, delta: i16) { + let cx_i = p.0.round() as i32; + let cy_i = p.1.round() as i32; + let r = (radius + 1.0).ceil() as i32; + let r2 = radius * radius; + for dy in -r..=r { + for dx in -r..=r { + 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 - grid.bx; + let ly = cy_i + dy - grid.by; + if lx < 0 || ly < 0 || lx >= grid.width || ly >= grid.height { continue; } + let idx = (ly * grid.width + lx) as usize; + count[idx] = (count[idx] as i32 + delta as i32).max(0) as u16; + } + } +} + +/// Find the nearest pre-stroke-unpainted ink pixel to `from`, within +/// `search_radius`, that isn't already covered by some other waypoint's +/// brush (count == 0). +fn nearest_uncovered_ink(from: (f32, f32), search_radius: f32, + grid: &Grid, count: &[u16]) -> Option<(f32, f32)> +{ + let r = search_radius.ceil() as i32; + let r2 = search_radius * search_radius; + let mut best: Option<((f32, f32), f32)> = None; + for dy in -r..=r { + for dx in -r..=r { + let d2 = (dx * dx + dy * dy) as f32; + if d2 > r2 { continue; } + let px = from.0 as i32 + dx; + let py = from.1 as i32 + dy; + let lx = px - grid.bx; + let ly = py - grid.by; + if lx < 0 || ly < 0 || lx >= grid.width || ly >= grid.height { continue; } + let idx = (ly * grid.width + lx) as usize; + if grid.unpainted[idx] && count[idx] == 0 { + match best { + None => best = Some(((px as f32, py as f32), d2)), + Some((_, bd2)) if d2 < bd2 => best = Some(((px as f32, py as f32), d2)), + _ => {} + } + } + } + } + best.map(|(p, _)| p) +} + +/// Score for moving waypoint p_old → p_new. Three terms: +/// + gain — pre-stroke-unpainted ink that newly becomes covered +/// - loss — uniquely-covered ink that would become uncovered +/// - background — extra background-pixel coverage by the new brush +/// position (waste; weighted by `outside_penalty`) +/// Net > 0 → keep the move. +fn evaluate_perturbation(p_old: (f32, f32), p_new: (f32, f32), brush_radius: f32, + grid: &Grid, count: &[u16], params: &PaintParams) -> f32 +{ + let r2 = brush_radius * brush_radius; + let mut gain = 0i32; + let mut loss = 0i32; + let mut bg_delta = 0i32; // bg_in_new - bg_in_old (positive = more wasted brush outside) + let cx = (p_old.0 + p_new.0) * 0.5; + let cy = (p_old.1 + p_new.1) * 0.5; + let dx = p_new.0 - p_old.0; + let dy = p_new.1 - p_old.1; + let half_dist = ((dx * dx + dy * dy).sqrt()) * 0.5; + let search_r = (brush_radius + half_dist).ceil() as i32; + for ddy in -search_r..=search_r { + for ddx in -search_r..=search_r { + let px = cx as i32 + ddx; + let py = cy as i32 + ddy; + let lx = px - grid.bx; + let ly = py - grid.by; + if lx < 0 || ly < 0 || lx >= grid.width || ly >= grid.height { continue; } + let idx = (ly * grid.width + lx) as usize; + + let dx_old = px as f32 - p_old.0; let dy_old = py as f32 - p_old.1; + let in_old = dx_old * dx_old + dy_old * dy_old <= r2; + let dx_new = px as f32 - p_new.0; let dy_new = py as f32 - p_new.1; + let in_new = dx_new * dx_new + dy_new * dy_new <= r2; + if !in_old && !in_new { continue; } + + if grid.was_ink[idx] { + if !grid.unpainted[idx] { continue; } // covered by prior stroke + let c_old = count[idx]; + let c_new = c_old as i32 - in_old as i32 + in_new as i32; + if c_old == 0 && c_new > 0 { gain += 1; } + if c_old > 0 && c_new == 0 { loss += 1; } + } else { + // Background pixel under brush. + if in_new && !in_old { bg_delta += 1; } + if !in_new && in_old { bg_delta -= 1; } + } + } + } + gain as f32 - loss as f32 - params.outside_penalty * bg_delta as f32 +} + +// ── 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 pixel_set: HashSet<(u32, u32)> = hull.pixels.iter().copied().collect(); + let dist = chamfer_distance(hull, &pixel_set); + let effective_sdf = sdf_percentile(&dist, 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(hull); + 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 _ in 0..params.max_strokes { + if grid.ink_remaining <= 0 { break; } + let start = match grid.pick_next_component(min_component_pixels, brush_radius) { + Some(s) => s, None => break, + }; + let path = trace_stroke(start, &mut grid, params, brush_radius); + if path.len() >= 2 { + let smoothed = smooth_stroke(&path, params.output_rdp_eps, params.output_chaikin); + strokes.push(snap_path_to_ink(&smoothed, &grid)); + } else { + grid.paint_disk(start, brush_radius); + } + } + + FillResult { + hull_id: hull.id, + strokes: strokes.into_iter().filter(|s| s.len() >= 2).collect(), + } +} + +/// Snap any post-smoothing waypoint that landed off-ink back onto the +/// nearest ink pixel. Chaikin's corner-cutting can dip outside the polygon +/// at sharp turns; this clamps those excursions while preserving the +/// smoothed character of the rest of the path. We search a small window +/// (3×3) — anything farther off than that is a bigger problem. +fn snap_path_to_ink(path: &[(f32, f32)], grid: &Grid) -> Vec<(f32, f32)> { + path.iter().map(|&(x, y)| { + let xi = x.round() as i32; + let yi = y.round() as i32; + if grid.is_ink(xi, yi) { return (x, y); } + // Find nearest ink pixel in an 11×11 neighborhood — wide enough to + // reel in Chaikin's worst corner-cuts on tight-angled strokes when + // the walker stays near the boundary (low polish_search_factor). + let mut best: Option<((i32, i32), f32)> = None; + for dy in -5..=5 { + for dx in -5..=5 { + let nx = xi + dx; + let ny = yi + dy; + if !grid.is_ink(nx, ny) { continue; } + let ddx = nx as f32 - x; + let ddy = ny as f32 - y; + let d2 = ddx * ddx + ddy * ddy; + match best { + None => best = Some(((nx, ny), d2)), + Some((_, bd)) if d2 < bd => best = Some(((nx, ny), d2)), + _ => {} + } + } + } + match best { + Some(((nx, ny), _)) => (nx as f32, ny as f32), + None => (x, y), + } + }).collect() +} + +pub fn paint_fill_debug(hull: &Hull, params: &PaintParams) -> 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 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 effective_sdf = sdf_percentile(&dist, 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(hull); + 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; + + for _ in 0..params.max_strokes { + if grid.ink_remaining <= 0 { break; } + let start = match grid.pick_next_component(min_component_pixels, brush_radius) { + Some(s) => s, None => break, + }; + let path = trace_stroke(start, &mut grid, params, brush_radius); + if path.len() >= 2 { + // Record path[0] as the "start" — that's where the gcode + // pen actually comes down. The `start` we passed to + // trace_stroke was just the bidirectional seed; the real + // pen-down is the dead-end of the backward walk, which + // becomes path[0] after stitching. + starts.push(path[0]); + trajectories.push(path); + } else { + grid.paint_disk(start, brush_radius); + } + } + + let strokes: Vec> = trajectories.iter() + .map(|t| smooth_stroke(t, params.output_rdp_eps, params.output_chaikin)) + .map(|s| snap_path_to_ink(&s, &grid)) + .filter(|s| s.len() >= 2) + .collect(); + + let (sdf_b64, _) = crate::streamline::encode_sdf_b64(hull); + let ink_unpainted = grid.ink_remaining.max(0) as u32; + let (bg_painted, total_swept) = measure_sweep(&strokes, &grid, brush_radius); + PaintDebug { + bounds, + source_b64: crate::streamline::encode_hull_pixels_b64(hull), + sdf_b64, + sdf_max, + brush_radius, + coverage_b64: encode_coverage_b64(&grid), + ink_total: grid.ink_total.max(0) as u32, + ink_unpainted, + bg_painted, + total_swept, + trajectories, + strokes, + start_points: starts, + } +} + +#[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); + } + } + } + + #[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] + 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() { + // The brush sweep MUST stay mostly inside the glyph. There IS a + // structural floor at small scales: brush_radius >= sdf_max + 0.5 + // overhangs the polygon by 0.5 px on each side, and at 2-4 px wide + // bars the half-pixel ratio is large. We use 0.40 here as the bar + // — it catches algorithmic shortcuts (the original 50%+ regression) + // while accepting that letters with sharp T/L junctions at thin + // scales will hit ~35-40% from junction-corner overflow. At the + // 425dpi target scale typical glyphs come in at 10-20%. + 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 { + 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.42 { + 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] + 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); + } + } + + #[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, walk_bg_penalty=0.3, outside_penalty=2.0, chaikin=2\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% | brush_r |").unwrap(); + writeln!(summary, "|------|---------|-----|---------|------|----|----|------|---------|").unwrap(); + + let mut totals = (0u32, 0u32, 0u32, 0u32, 0u32); // strokes, ink, painted, bg, swept + 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 stroke_count = 0u32; + let mut max_brush_r: f32 = 0.0; + let mut ink_total = 0u32; + let mut ink_painted_total = 0u32; + + for hull in &hulls { + let dbg = paint_fill_debug(hull, &p); + stroke_count += dbg.trajectories.len() as u32; + bg_total += dbg.bg_painted; + swept_total += dbg.total_swept; + ink_total += dbg.ink_total; + ink_painted_total += dbg.ink_total - dbg.ink_unpainted; + 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 }; + 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; + + writeln!(summary, + "| `{}` | {} | {} | {} | {:.1} | {} | {} | {:.1} | {:.2} |", + ch, stroke_count, ink_total, ink_painted_total, cov_pct, + bg_total, swept_total, off_pct, 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 }; + writeln!(summary, "\n**Totals:** {} strokes (avg {:.2}/char), coverage {:.1}%, off-glyph {:.1}%", + totals.0, avg_strokes, avg_cov, avg_off).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/fill.rs b/src/fill.rs index c5ead761..12661adb 100644 --- a/src/fill.rs +++ b/src/fill.rs @@ -536,7 +536,7 @@ pub fn spiral(hull: &Hull, spacing_px: f32) -> FillResult { /// 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). -fn chamfer_distance(hull: &Hull, pixel_set: &HashSet<(u32, u32)>) -> HashMap<(u32, u32), f32> { +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; @@ -924,637 +924,6 @@ pub fn centerline_fill(hull: &Hull, _spacing_px: f32) -> FillResult { FillResult { hull_id: hull.id, strokes } } -// ── Chordal axis transform (polygon-domain medial axis) ─────────────────────── -// -// Operates on the hull's contour polygon (with holes), not pixel skeletons. -// 1. Detect interior holes (background components inside hull bbox). -// 2. Trace + RDP each hole's contour → polygon list. -// 3. Constrained Delaunay triangulation (spade) using outer + holes as constraints. -// 4. Classify each interior triangle by # of constraint edges (Prasad's CAT): -// - 3 constrained (P): pure — degenerate, skip. -// - 2 constrained (T): termination — emit segment from the chord midpoint -// to the apex vertex (the corner where the two boundary edges meet). -// - 1 constrained (S): sleeve — emit segment between the two chord midpoints. -// - 0 constrained (J): junction — emit 3 segments from the triangle's -// barycenter to each chord midpoint. -// 5. Walk the resulting segment graph into polylines, then Chaikin-smooth. - -/// Find background components fully enclosed within the hull's bbox. Each is -/// a hole inside the glyph (e.g. the inside of an `O`). Uses 8-connectivity for -/// background flood so it doesn't leak through 4-connected diagonal touches. -fn detect_holes(hull: &Hull) -> Vec> { - let pixel_set: HashSet<(u32, u32)> = hull.pixels.iter().copied().collect(); - let b = &hull.bounds; - let (x0, y0, x1, y1) = (b.x_min, b.y_min, b.x_max, b.y_max); - if x1 <= x0 || y1 <= y0 { return vec![]; } - let mut visited: HashSet<(u32, u32)> = HashSet::new(); - let mut holes: Vec> = Vec::new(); - - for y in y0..=y1 { - for x in x0..=x1 { - let p = (x, y); - if pixel_set.contains(&p) || visited.contains(&p) { continue; } - let mut component: Vec<(u32, u32)> = Vec::new(); - let mut queue: VecDeque<(u32, u32)> = VecDeque::new(); - queue.push_back(p); - visited.insert(p); - let mut touches_edge = false; - while let Some(q) = queue.pop_front() { - component.push(q); - if q.0 == x0 || q.0 == x1 || q.1 == y0 || q.1 == y1 { touches_edge = true; } - for n in zs_neighbors(q.0, q.1) { - if n.0 < x0 || n.0 > x1 || n.1 < y0 || n.1 > y1 { continue; } - if pixel_set.contains(&n) { continue; } - if visited.insert(n) { queue.push_back(n); } - } - } - // Tiny holes (<4px) are usually rasterisation noise; skip. - if !touches_edge && component.len() >= 4 { holes.push(component); } - } - } - holes -} - -fn point_in_polygon(p: (f32, f32), poly: &[(f32, f32)]) -> bool { - let n = poly.len(); - if n < 3 { return false; } - let (px, py) = p; - let mut inside = false; - let mut j = n - 1; - for i in 0..n { - let (xi, yi) = poly[i]; - let (xj, yj) = poly[j]; - let cross = (yi > py) != (yj > py); - if cross && px < (xj - xi) * (py - yi) / (yj - yi) + xi { - inside = !inside; - } - j = i; - } - inside -} - -/// Kind of polyline emitted by segment-graph walking. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum PolylineKind { - /// Tail of a branch — at least one endpoint is degree-1 in the segment graph. - /// `tip_at_start` says whether index 0 is the tip (otherwise the tip is the last point). - Branch { tip_at_start: bool }, - /// Both endpoints are junctions (or the polyline is closed) — no free tip. - NonBranch, -} - -/// Walk a segment graph into polylines. Endpoints are degree-1 nodes; -/// junctions are degree-≥3. Walks pass through degree-2 nodes in one polyline. -fn segments_to_polylines_kinded( - segments: &[((f32, f32), (f32, f32))], -) -> Vec<(Vec<(f32, f32)>, PolylineKind)> { - type K = (i32, i32); - let to_key = |p: (f32, f32)| -> K { ((p.0 * 100.0).round() as i32, (p.1 * 100.0).round() as i32) }; - let edge = |a: K, b: K| -> (K, K) { if a <= b { (a, b) } else { (b, a) } }; - - let mut node_pos: HashMap = HashMap::new(); - let mut adj: HashMap> = HashMap::new(); - for &(a, b) in segments { - let (ka, kb) = (to_key(a), to_key(b)); - if ka == kb { continue; } - node_pos.entry(ka).or_insert(a); - node_pos.entry(kb).or_insert(b); - let na = adj.entry(ka).or_default(); - if !na.contains(&kb) { na.push(kb); } - let nb = adj.entry(kb).or_default(); - if !nb.contains(&ka) { nb.push(ka); } - } - - let mut used: HashSet<(K, K)> = HashSet::new(); - let mut polylines: Vec<(Vec<(f32, f32)>, PolylineKind)> = Vec::new(); - - let walk = |start: K, first: K, - used: &mut HashSet<(K, K)>, - adj: &HashMap>, - node_pos: &HashMap| -> Vec<(f32, f32)> { - let mut path = vec![node_pos[&start]]; - let mut prev = start; - let mut cur = first; - loop { - used.insert(edge(prev, cur)); - path.push(node_pos[&cur]); - let neighbors = adj.get(&cur).cloned().unwrap_or_default(); - if neighbors.len() != 2 { break; } - let next = neighbors.iter() - .copied() - .find(|&n| n != prev && !used.contains(&edge(cur, n))); - match next { - Some(n) => { prev = cur; cur = n; } - None => break, - } - } - path - }; - - // Pass 1: walks rooted at endpoints. These are branches by definition — - // start is degree-1 (a tip). - let mut endpoints: Vec = adj.iter() - .filter(|(_, ns)| ns.len() == 1) - .map(|(k, _)| *k) - .collect(); - endpoints.sort(); - for ep in endpoints { - let nbrs = adj.get(&ep).cloned().unwrap_or_default(); - for nbr in nbrs { - if used.contains(&edge(ep, nbr)) { continue; } - let p = walk(ep, nbr, &mut used, &adj, &node_pos); - polylines.push((p, PolylineKind::Branch { tip_at_start: true })); - } - } - - // Pass 2: walks rooted at junctions. Junction-to-junction or - // junction-to-cycle — no free tip. - let mut junctions: Vec = adj.iter() - .filter(|(_, ns)| ns.len() >= 3) - .map(|(k, _)| *k) - .collect(); - junctions.sort(); - for j in junctions { - let nbrs = adj.get(&j).cloned().unwrap_or_default(); - for nbr in nbrs { - if used.contains(&edge(j, nbr)) { continue; } - let p = walk(j, nbr, &mut used, &adj, &node_pos); - polylines.push((p, PolylineKind::NonBranch)); - } - } - - // Pass 3: remaining edges are pure cycles (every node degree 2). - let mut all: Vec = adj.keys().copied().collect(); - all.sort(); - for n in all { - let nbrs = adj.get(&n).cloned().unwrap_or_default(); - for nbr in nbrs { - if used.contains(&edge(n, nbr)) { continue; } - let mut p = walk(n, nbr, &mut used, &adj, &node_pos); - if p.first() != p.last() && p.len() > 2 { - if let Some(&first) = p.first() { p.push(first); } - } - polylines.push((p, PolylineKind::NonBranch)); - } - } - - polylines -} - -/// Back-compat helper: discards polyline kind. Used by tests / other strategies. -#[cfg(test)] -fn segments_to_polylines(segments: &[((f32, f32), (f32, f32))]) -> Vec> { - segments_to_polylines_kinded(segments).into_iter().map(|(p, _)| p).collect() -} - -/// Chordal axis fill with a single user-tunable knob. -/// `salience`: drop tail branches whose length is less than `salience` times -/// the local stroke half-width at the branch's tip. Scale-invariant — works -/// the same at 3mm and 8mm font sizes. 0 = keep everything. -/// 1.5–2.5 typically removes junction artifacts on letters like X/K/N -/// without affecting real tails on a/g/9. -pub fn chordal_axis_fill(hull: &Hull, salience: f32) -> FillResult { - if hull.pixels.is_empty() || hull.simplified.len() < 3 { - return FillResult { hull_id: hull.id, strokes: vec![] }; - } - - // Outer polygon: already RDP-simplified during hull extraction. - let outer: Vec<(f32, f32)> = hull.simplified.clone(); - let pixel_set: HashSet<(u32, u32)> = hull.pixels.iter().copied().collect(); - - // Holes: background-pixel components inside the bbox not touching the edge. - let hole_pixel_groups = detect_holes(hull); - let hole_polys: Vec> = hole_pixel_groups.iter() - .filter_map(|hole_pixels| { - let set: HashSet<(u32, u32)> = hole_pixels.iter().copied().collect(); - let contour = trace_contour(&set); - if contour.len() < 3 { return None; } - let f: Vec<(f32, f32)> = contour.into_iter().map(|(x, y)| (x as f32, y as f32)).collect(); - let simp = rdp_simplify_f32(&f, 1.0); - if simp.len() < 3 { None } else { Some(simp) } - }) - .collect(); - - // CDT: outer + each hole as closed constraint loops. We use - // try_add_constraint per-edge: when two constraint edges happen to - // overlap (e.g. RDP placed an outer and a hole vertex too close), - // it returns empty instead of panicking and we just lose that edge's - // constraint flag — the triangulation stays valid. - use spade::{ConstrainedDelaunayTriangulation, Triangulation, Point2}; - use std::panic::{catch_unwind, AssertUnwindSafe}; - - let result = catch_unwind(AssertUnwindSafe(|| -> Vec<((f32, f32), (f32, f32))> { - let mut cdt: ConstrainedDelaunayTriangulation> = - ConstrainedDelaunayTriangulation::new(); - - let insert_loop = |cdt: &mut ConstrainedDelaunayTriangulation>, - pts: &[(f32, f32)]| { - let mut handles = Vec::with_capacity(pts.len()); - for &(x, y) in pts { - if let Ok(h) = cdt.insert(Point2::new(x as f64, y as f64)) { - handles.push(h); - } - } - for i in 0..handles.len() { - let a = handles[i]; - let b = handles[(i + 1) % handles.len()]; - if a == b { continue; } - // Returns empty Vec on intersection — we ignore that and - // continue. Worst case: that edge isn't classified as - // boundary, which mildly affects triangle classification - // but doesn't crash the algorithm. - let _ = cdt.try_add_constraint(a, b); - } - }; - - insert_loop(&mut cdt, &outer); - for hp in &hole_polys { insert_loop(&mut cdt, hp); } - - // Classify every interior triangle (centroid inside outer, outside all holes). - let mut segments: Vec<((f32, f32), (f32, f32))> = Vec::new(); - classify_triangles(&cdt, &outer, &hole_polys, &mut segments); - segments - })); - - let segments = match result { - Ok(s) => s, - Err(_) => return FillResult { hull_id: hull.id, strokes: vec![] }, - }; - - let kinded = segments_to_polylines_kinded(&segments); - - // Salience-based pruning: a "branch" (polyline with a degree-1 tip in the - // CAT graph) is dropped when its length is shorter than `salience` × - // (local stroke half-width at the tip). The half-width is the boundary- - // distance value at the tip — naturally encoding "how thick the glyph is - // here". This is scale-invariant: doubling the font size doubles both - // the branch length AND the stroke half-width, so the ratio stays the - // same. Junction artifacts have ratio ~1 (length ≈ stroke half-width); - // real tails on a/g/j/9 have ratio >> 2. - let polyline_len = |p: &[(f32, f32)]| -> f32 { - p.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 dist_map = if salience > 0.0 { - Some(chamfer_distance(hull, &pixel_set)) - } else { None }; - let tip_clearance = |tip: (f32, f32)| -> f32 { - let dm = match &dist_map { Some(d) => d, None => return 0.0 }; - // Sample the chamfer field at the nearest hull pixel to the tip. - let key = (tip.0.round() as i32, tip.1.round() as i32); - if key.0 < 0 || key.1 < 0 { return 0.0; } - let p = (key.0 as u32, key.1 as u32); - if let Some(&d) = dm.get(&p) { return d; } - // Tip is right on a polygon vertex (boundary), distance ~0. Walk - // inward 1-3 px to find a sample. - for r in 1..=3 { - for dx in -r..=r { - for dy in -r..=r { - let p = ((key.0 + dx) as u32, (key.1 + dy) as u32); - if let Some(&d) = dm.get(&p) { return d; } - } - } - } - 0.0 - }; - let keep = |p: &[(f32, f32)], kind: PolylineKind| -> bool { - if salience <= 0.0 { return true; } - let (tip_idx, is_branch) = match kind { - PolylineKind::Branch { tip_at_start: true } => (0, true), - PolylineKind::Branch { tip_at_start: false } => (p.len() - 1, true), - PolylineKind::NonBranch => (0, false), - }; - if !is_branch { return true; } - let clear = tip_clearance(p[tip_idx]).max(0.5); - polyline_len(p) >= salience * clear - }; - - // Smooth: light RDP + a few Chaikin passes. Sub-pixel CAT output already - // beats pixel skeletons but Chaikin polishes corners further. - let strokes: Vec> = kinded.into_iter() - .filter(|(p, _)| p.len() >= 2) - .filter(|(p, k)| keep(p, *k)) - .map(|(p, _)| smooth_stroke(&p, /* rdp_eps */ 0.5, /* chaikin_iters */ 3)) - .filter(|p| p.len() >= 2) - .collect(); - - FillResult { hull_id: hull.id, strokes } -} - -// ── Chordal debug introspection ────────────────────────────────────────────── -// -// Same algorithm as chordal_axis_fill, but records every intermediate state -// for the dedicated debug view (so the user can see the polygon, holes, -// triangulation, classification, raw segments, polylines, and final -// smoothed strokes layered on one canvas). - -#[derive(Debug, Clone, serde::Serialize)] -pub struct DebugTriangle { - pub points: [(f32, f32); 3], - pub edge_constraint: [bool; 3], // edge i goes points[i]→points[(i+1)%3] - pub kind: &'static str, // "junction" | "sleeve" | "termination" | "pure" | "outside" -} - -#[derive(Debug, Clone, serde::Serialize)] -pub struct DebugPolyline { - pub points: Vec<(f32, f32)>, - pub branch: bool, - pub kept: bool, // false if salience-pruned -} - -#[derive(Debug, Clone, serde::Serialize)] -pub struct ChordalDebug { - pub bounds: [f32; 4], // x_min, y_min, x_max, y_max (for default SVG viewBox) - /// Hull-pixel raster as a base64 PNG (data URL). Sized exactly to the - /// inclusive bbox so it positions at (x_min, y_min) with width/height - /// = (x_max - x_min + 1, y_max - y_min + 1). Lets the user see the - /// original source rasterisation underneath the algorithm's geometry. - pub source_b64: String, - pub outer: Vec<(f32, f32)>, - pub holes: Vec>, - pub hole_pixels: Vec>, - pub triangles: Vec, - pub segments: Vec<((f32, f32), (f32, f32))>, - pub polylines: Vec, - pub strokes: Vec>, // final, after smoothing + prune -} - -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; - // White ink on transparent background. The SVG view has a dark backdrop, - // so white at any opacity stays visible (a dark colour at 40% on a dark - // background renders as effectively-invisible). - 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) -} - -pub fn chordal_axis_fill_debug(hull: &Hull, salience: f32) -> ChordalDebug { - 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 mut out = ChordalDebug { - bounds, - source_b64: encode_hull_pixels_b64(hull), - outer: hull.simplified.clone(), - holes: vec![], hole_pixels: vec![], - triangles: vec![], segments: vec![], - polylines: vec![], strokes: vec![], - }; - if hull.pixels.is_empty() || hull.simplified.len() < 3 { return out; } - - let pixel_set: HashSet<(u32, u32)> = hull.pixels.iter().copied().collect(); - - // Step 2-3: holes. - let hole_pixel_groups = detect_holes(hull); - let hole_polys: Vec> = hole_pixel_groups.iter() - .filter_map(|hp| { - let set: HashSet<(u32, u32)> = hp.iter().copied().collect(); - let contour = trace_contour(&set); - if contour.len() < 3 { return None; } - let f: Vec<(f32, f32)> = contour.into_iter().map(|(x, y)| (x as f32, y as f32)).collect(); - let s = rdp_simplify_f32(&f, 1.0); - if s.len() < 3 { None } else { Some(s) } - }) - .collect(); - out.hole_pixels = hole_pixel_groups; - out.holes = hole_polys.clone(); - - // Step 4-5-6: CDT + classification + segments. Inline a copy that - // also records every triangle (including outside) for visualization. - use spade::{ConstrainedDelaunayTriangulation, Triangulation, Point2}; - use std::panic::{catch_unwind, AssertUnwindSafe}; - - let outer = out.outer.clone(); - let dbg_result = catch_unwind(AssertUnwindSafe(|| { - let mut cdt: ConstrainedDelaunayTriangulation> = - ConstrainedDelaunayTriangulation::new(); - let insert_loop = |cdt: &mut ConstrainedDelaunayTriangulation>, - pts: &[(f32, f32)]| { - let mut h = Vec::with_capacity(pts.len()); - for &(x, y) in pts { - if let Ok(handle) = cdt.insert(Point2::new(x as f64, y as f64)) { - h.push(handle); - } - } - for i in 0..h.len() { - let a = h[i]; let b = h[(i + 1) % h.len()]; - if a == b { continue; } - let _ = cdt.try_add_constraint(a, b); - } - }; - insert_loop(&mut cdt, &outer); - for hp in &hole_polys { insert_loop(&mut cdt, hp); } - - let mut tris: Vec = Vec::new(); - let mut segs: Vec<((f32, f32), (f32, f32))> = Vec::new(); - for face in cdt.inner_faces() { - let pos = face.positions(); - let p0 = (pos[0].x as f32, pos[0].y as f32); - let p1 = (pos[1].x as f32, pos[1].y as f32); - let p2 = (pos[2].x as f32, pos[2].y as f32); - let cx = (p0.0 + p1.0 + p2.0) / 3.0; - let cy = (p0.1 + p1.1 + p2.1) / 3.0; - let edges = face.adjacent_edges(); - let is_b = [ - edges[0].is_constraint_edge(), - edges[1].is_constraint_edge(), - edges[2].is_constraint_edge(), - ]; - let inside = point_in_polygon((cx, cy), &outer) - && !hole_polys.iter().any(|h| point_in_polygon((cx, cy), h)); - let kind: &'static str = if !inside { - "outside" - } else { - match is_b.iter().filter(|&&v| v).count() { - 3 => "pure", - 2 => "termination", - 1 => "sleeve", - 0 => "junction", - _ => "outside", - } - }; - tris.push(DebugTriangle { - points: [p0, p1, p2], - edge_constraint: is_b, - kind, - }); - if !inside { continue; } - let mid = |i: usize| -> (f32, f32) { - let a = i; let b = (i + 1) % 3; - let pa = [p0, p1, p2][a]; let pb = [p0, p1, p2][b]; - ((pa.0 + pb.0) * 0.5, (pa.1 + pb.1) * 0.5) - }; - let pts3 = [p0, p1, p2]; - match is_b.iter().filter(|&&v| v).count() { - 3 => {} - 2 => { - let chord = is_b.iter().position(|&v| !v).unwrap(); - let apex = (chord + 2) % 3; - segs.push((mid(chord), pts3[apex])); - } - 1 => { - let mut chords: Vec<(f32, f32)> = Vec::new(); - for i in 0..3 { if !is_b[i] { chords.push(mid(i)); } } - if chords.len() == 2 { segs.push((chords[0], chords[1])); } - } - 0 => { for i in 0..3 { segs.push(((cx, cy), mid(i))); } } - _ => {} - } - } - (tris, segs) - })); - - let (triangles, segments) = match dbg_result { - Ok(v) => v, - Err(_) => return out, // panic in CDT — return what we have - }; - out.triangles = triangles; - out.segments = segments.clone(); - - // Step 7-8: polyline walk + record kinds. - let kinded = segments_to_polylines_kinded(&segments); - - // Salience setup mirroring chordal_axis_fill (so the debug view shows - // exactly the same kept/dropped state the production fill would produce - // at the same salience setting). - let polyline_len = |p: &[(f32, f32)]| -> f32 { - p.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 dist_map = if salience > 0.0 { Some(chamfer_distance(hull, &pixel_set)) } else { None }; - let tip_clearance = |tip: (f32, f32)| -> f32 { - let dm = match &dist_map { Some(d) => d, None => return 0.0 }; - let key = (tip.0.round() as i32, tip.1.round() as i32); - if key.0 < 0 || key.1 < 0 { return 0.0; } - let p = (key.0 as u32, key.1 as u32); - if let Some(&d) = dm.get(&p) { return d; } - for r in 1..=3 { - for dx in -r..=r { - for dy in -r..=r { - let p = ((key.0 + dx) as u32, (key.1 + dy) as u32); - if let Some(&d) = dm.get(&p) { return d; } - } - } - } - 0.0 - }; - - let mut polylines: Vec = Vec::with_capacity(kinded.len()); - for (p, k) in &kinded { - let (branch, kept) = match *k { - PolylineKind::NonBranch => (false, true), - PolylineKind::Branch { tip_at_start } => { - let tip_idx = if tip_at_start { 0 } else { p.len() - 1 }; - let kept = if salience <= 0.0 { - true - } else { - let clear = tip_clearance(p[tip_idx]).max(0.5); - polyline_len(p) >= salience * clear - }; - (true, kept) - } - }; - polylines.push(DebugPolyline { points: p.clone(), branch, kept }); - } - out.polylines = polylines; - - // Step 9: smoothed strokes (only the kept ones). - out.strokes = kinded.into_iter() - .filter(|(p, _)| p.len() >= 2) - .filter(|(p, k)| { - if salience <= 0.0 { return true; } - match *k { - PolylineKind::NonBranch => true, - PolylineKind::Branch { tip_at_start } => { - let tip_idx = if tip_at_start { 0 } else { p.len() - 1 }; - let clear = tip_clearance(p[tip_idx]).max(0.5); - polyline_len(p) >= salience * clear - } - } - }) - .map(|(p, _)| smooth_stroke(&p, 0.5, 3)) - .filter(|p| p.len() >= 2) - .collect(); - - out -} - -fn classify_triangles( - cdt: &spade::ConstrainedDelaunayTriangulation>, - outer: &[(f32, f32)], - hole_polys: &[Vec<(f32, f32)>], - segments: &mut Vec<((f32, f32), (f32, f32))>, -) { - use spade::Triangulation; - for face in cdt.inner_faces() { - let pos = face.positions(); - let cx = ((pos[0].x + pos[1].x + pos[2].x) / 3.0) as f32; - let cy = ((pos[0].y + pos[1].y + pos[2].y) / 3.0) as f32; - if !point_in_polygon((cx, cy), &outer) { continue; } - if hole_polys.iter().any(|h| point_in_polygon((cx, cy), h)) { continue; } - - // Edge i (from adjacent_edges) goes from vertices[i] to vertices[(i+1)%3]. - // Vertex (i+2)%3 is the apex opposite that edge. - let edges = face.adjacent_edges(); - let is_b = [ - edges[0].is_constraint_edge(), - edges[1].is_constraint_edge(), - edges[2].is_constraint_edge(), - ]; - let mid = |i: usize| -> (f32, f32) { - let a = i; - let b = (i + 1) % 3; - ((pos[a].x as f32 + pos[b].x as f32) * 0.5, - (pos[a].y as f32 + pos[b].y as f32) * 0.5) - }; - let n_b = is_b.iter().filter(|&&v| v).count(); - match n_b { - 3 => { /* pure triangle (shape was a triangle); skip */ } - 2 => { - // Termination: midpoint of the chord → apex (vertex opposite chord). - let chord = is_b.iter().position(|&v| !v).unwrap(); - let apex = (chord + 2) % 3; - segments.push((mid(chord), (pos[apex].x as f32, pos[apex].y as f32))); - } - 1 => { - // Sleeve: chord_mid → other_chord_mid. - let mut chords: Vec<(f32, f32)> = Vec::new(); - for i in 0..3 { if !is_b[i] { chords.push(mid(i)); } } - if chords.len() == 2 { - segments.push((chords[0], chords[1])); - } - } - 0 => { - // Junction: 3 segments from barycenter to each chord midpoint. - for i in 0..3 { segments.push(((cx, cy), mid(i))); } - } - _ => unreachable!(), - } - } -} - pub fn skeleton_fill(hull: &Hull, _spacing_px: f32) -> FillResult { if hull.pixels.is_empty() { return FillResult { hull_id: hull.id, strokes: vec![] }; @@ -1578,7 +947,7 @@ pub fn skeleton_fill(hull: &Hull, _spacing_px: f32) -> FillResult { /// 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. -fn prune_skeleton_spurs(skeleton: &mut HashSet<(u32, u32)>, max_spur_len: usize) { +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() } @@ -1617,7 +986,7 @@ fn prune_skeleton_spurs(skeleton: &mut HashSet<(u32, u32)>, max_spur_len: usize) /// 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. -fn zs_neighbors(x: u32, y: u32) -> [(u32, u32); 8] { +pub(crate) fn zs_neighbors(x: u32, y: u32) -> [(u32, u32); 8] { [ (x, y.wrapping_sub(1)), (x + 1, y.wrapping_sub(1)), @@ -1632,7 +1001,7 @@ fn zs_neighbors(x: u32, y: u32) -> [(u32, u32); 8] { /// Run Zhang-Suen thinning until idempotent. Two sub-iterations per round /// with mirrored conditions keep erosion symmetric. -fn zhang_suen_thin(pixels: &[(u32, u32)]) -> HashSet<(u32, u32)> { +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); @@ -2907,276 +2276,6 @@ mod tests { span, span / glyph_h * 100.0, glyph_h, r.strokes.len()); } - #[test] - fn chordal_detect_holes_finds_O_interior() { - // Letter 'O' has a single enclosed interior; non-hole letters do not. - let hulls = rasterize_text_to_hulls("O", 8.0, 150, 3); - assert_eq!(hulls.len(), 1, "'O' should produce exactly 1 hull"); - let holes = detect_holes(&hulls[0]); - assert_eq!(holes.len(), 1, - "'O' should have 1 interior hole, got {}", holes.len()); - - let hulls_c = rasterize_text_to_hulls("C", 8.0, 150, 3); - let holes_c = detect_holes(&hulls_c[0]); - assert_eq!(holes_c.len(), 0, - "'C' should have 0 holes, got {}", holes_c.len()); - } - - #[test] - fn chordal_letter_O_produces_strokes() { - // Without hole-aware triangulation, an 'O' would either fill - // its interior or fail entirely. With hole support we expect a - // closed stroke ring around the centerline of the ring. - let hulls = rasterize_text_to_hulls("O", 8.0, 150, 3); - let r = chordal_axis_fill(&hulls[0], 1.0); - assert!(!r.strokes.is_empty(), - "expected at least 1 stroke for 'O', got 0"); - let total_pts: usize = r.strokes.iter().map(|s| s.len()).sum(); - assert!(total_pts >= 8, - "'O' chordal output too small: {} total points across {} strokes", - total_pts, r.strokes.len()); - } - - #[test] - fn chordal_letter_I_is_one_stroke() { - // 'I' is a simple bar (no holes, no junctions). Should give one line. - let hulls = rasterize_text_to_hulls("I", 8.0, 150, 3); - let r = chordal_axis_fill(&hulls[0], 1.0); - assert_eq!(r.strokes.len(), 1, - "expected 1 stroke for 'I', got {}", r.strokes.len()); - } - - /// Render a multi-block text payload to a Vec at given paper/DPI. - fn rasterize_text_blocks_to_hulls( - blocks: &[(&str, f32, f32, f32)], - paper_w_mm: f32, paper_h_mm: f32, dpi: u32, thickness_px: u32, - ) -> Vec { - use crate::text::{TextBlockSpec, rasterize_blocks}; - use crate::hulls::{extract_hulls, HullParams, Connectivity}; - let specs: Vec = blocks.iter().map(|&(t, f, x, y)| { - TextBlockSpec { text: t.to_string(), font_size_mm: f, - line_spacing_mm: None, x_mm: x, y_mm: y } - }).collect(); - let rgb = rasterize_blocks(&specs, paper_w_mm, paper_h_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 chordal_does_not_panic_on_saved_texttest_input() { - // Reproduces the user's saved 'texttest' project that hit - // "The new constraint edge intersects an existing constraint edge." - // when running chordal on multi-line addresses at 3mm and 5mm. - // After the fix to use try_add_constraint + catch_unwind, this must - // run cleanly (every hull may produce 0 or many strokes — we don't - // care here, only that nothing panics). - let blocks = [ - ("Your Name\n123 Your St\nYour City, ST 12345", 3.0, 6.83, 6.36), - ("Recipient Name\n456 Their St\nTheir City, ST 67890", 5.0, 74.67, 48.05), - ]; - let hulls = rasterize_text_blocks_to_hulls(&blocks, 241.3, 104.775, 150, 3); - assert!(!hulls.is_empty(), "no hulls extracted from the text payload"); - for h in &hulls { - // Must complete without panicking. We don't assert on stroke count - // because some glyphs may legitimately produce nothing under - // try_add_constraint failures — the contract is "no panic". - let _ = chordal_axis_fill(h, 1.0); - } - } - - #[test] - fn chordal_no_panic_for_any_printable_ascii() { - // Every printable ASCII glyph at small + medium font sizes must - // process without panic, no matter how the contour or holes come out. - let chars: String = (0x20u8..=0x7E).map(|b| b as char).collect(); - for size in [3.0_f32, 5.0, 8.0] { - let hulls = rasterize_text_to_hulls(&chars, size, 200, 3); - for h in &hulls { - let _ = chordal_axis_fill(h, 1.0); - } - } - } - - #[test] - fn chordal_alphanumerics_produce_strokes_with_y_coverage() { - // Sweep similar to the centerline one: every letter/digit should - // produce at least one stroke, and the strokes (across all hulls of - // that glyph) should span ≥ 70% of the largest hull's height — a - // crude but effective "didn't lose the body of the letter" check. - let chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; - let mut bad: Vec<(char, String)> = Vec::new(); - let mut report = String::new(); - for ch in chars.chars() { - let hulls = rasterize_text_to_hulls(&ch.to_string(), 8.0, 200, 4); - // Pick the largest hull (skips 'i' / 'j' dot specks). - let main = hulls.iter().max_by_key(|h| h.area); - let (count, cov) = match main { - None => { bad.push((ch, "no hulls".into())); (0, 0.0) } - Some(h) => { - let r = chordal_axis_fill(h, 1.0); - let glyph_h = (h.bounds.y_max - h.bounds.y_min) as f32; - if r.strokes.is_empty() { - bad.push((ch, "no strokes".into())); - (0, 0.0) - } else { - let (lo, hi) = stroke_y_range(&r.strokes); - let cov = if glyph_h > 0.0 { (hi - lo) / glyph_h } else { 1.0 }; - if cov < 0.70 { - bad.push((ch, format!("only {:.0}% Y coverage", cov * 100.0))); - } - (r.strokes.len(), cov) - } - } - }; - report.push_str(&format!("'{}': {} stroke(s), Y-cov {:.2}\n", ch, count, cov)); - } - if !bad.is_empty() { - panic!("Chordal letters with issues: {:?}\n{}", bad, report); - } - } - - #[test] - fn chordal_letters_with_holes_produce_at_least_one_closed_stroke() { - // Holes mean the medial axis is a closed ring through the body of - // the letter. Our walk turns rings into a stroke whose first and - // last point are the same (or very close). At minimum, every - // letter with N holes should produce ≥ N strokes. - let cases: &[(&str, usize)] = &[ - ("O", 1), ("D", 1), ("P", 1), ("Q", 1), ("R", 1), - ("0", 1), ("4", 1), ("6", 1), ("9", 1), - ("B", 2), ("8", 2), - ]; - for &(ch, expected_holes) in cases { - let hulls = rasterize_text_to_hulls(ch, 10.0, 200, 4); - let main = hulls.iter().max_by_key(|h| h.area).expect("no hull"); - let detected = detect_holes(main).len(); - assert_eq!(detected, expected_holes, - "'{}' expected {} holes, detected {}", ch, expected_holes, detected); - let r = chordal_axis_fill(main, 1.0); - assert!(r.strokes.len() >= expected_holes, - "'{}' expected ≥ {} strokes for {} ring(s), got {}", - ch, expected_holes, expected_holes, r.strokes.len()); - } - } - - /// Stamp a filled disc into a boolean grid. - fn stamp_disc(grid: &mut [bool], w: u32, h: u32, x: f32, y: f32, radius: i32) { - let cx = x.round() as i32; - let cy = y.round() as i32; - let r2 = radius * radius; - for dy in -radius..=radius { - for dx in -radius..=radius { - if dx * dx + dy * dy > r2 { continue; } - let px = cx + dx; - let py = cy + dy; - if px < 0 || py < 0 || px >= w as i32 || py >= h as i32 { continue; } - grid[(py as u32 * w + px as u32) as usize] = true; - } - } - } - - /// Render strokes to a boolean raster with given pen radius, by stamping - /// densely along each line segment. Uses Euclidean step size of 0.5 px - /// so adjacent stamps overlap; output is a continuous "drawn" region. - fn rasterize_strokes_thick( - strokes: &[Vec<(f32, f32)>], w: u32, h: u32, radius: i32, - ) -> Vec { - let mut grid = vec![false; (w * h) as usize]; - for s in strokes { - for win in s.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 usize; - for i in 0..=n { - let t = i as f32 / n as f32; - let x = a.0 + dx * t; - let y = a.1 + dy * t; - stamp_disc(&mut grid, w, h, x, y, radius); - } - } - } - grid - } - - /// Intersection-over-union of two boolean rasters. - fn iou(a: &[bool], b: &[bool]) -> f32 { - let mut inter = 0u32; - let mut union_ = 0u32; - for (x, y) in a.iter().zip(b.iter()) { - if *x && *y { inter += 1; } - if *x || *y { union_ += 1; } - } - if union_ == 0 { 1.0 } else { inter as f32 / union_ as f32 } - } - - #[test] - fn chordal_pixel_similarity_to_source() { - // For each glyph: rasterize chordal output strokes back to a raster - // (dilated by ~half the source stroke thickness), and compare to the - // original glyph raster. Strong sign the centerline is right: the - // dilated centerline reproduces the glyph (high IoU). Loose threshold - // because corners shrink and rasterisation aliasing reduces overlap. - let chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; - let dpi = 200u32; - let thickness_px = 4u32; - let dilate_r = (thickness_px / 2) as i32; - - let mut report = String::new(); - let mut bad: Vec<(char, f32)> = Vec::new(); - let mut total_iou = 0.0_f32; - let mut count = 0_u32; - for ch in chars.chars() { - // Use a per-glyph small canvas so the comparison is local. - use crate::text::{TextBlockSpec, rasterize_blocks}; - use crate::hulls::{extract_hulls, HullParams, Connectivity}; - let block = TextBlockSpec { - text: ch.to_string(), font_size_mm: 8.0, - 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(); - // Source mask: any pixel darker than 253. - let source: Vec = luma.iter().map(|&p| p < 253).collect(); - let params = HullParams { - threshold: 253, min_area: 4, rdp_epsilon: 1.5, - connectivity: Connectivity::Four, - ..HullParams::default() - }; - let hulls = extract_hulls(&luma, &rgb, w, h, ¶ms); - let mut all_strokes: Vec> = Vec::new(); - for hh in &hulls { - let r = chordal_axis_fill(hh, 1.0); - all_strokes.extend(r.strokes); - } - let drawn = rasterize_strokes_thick(&all_strokes, w, h, dilate_r); - let score = iou(&source, &drawn); - total_iou += score; - count += 1; - report.push_str(&format!("'{}': IoU {:.3}\n", ch, score)); - // 0.55 is loose: chordal axis trims sharp corners, which loses - // ~15-25% of source pixels even on a perfect run. Below this is - // a real regression — strokes don't resemble the glyph. - if score < 0.55 { bad.push((ch, score)); } - } - let avg = total_iou / count as f32; - if !bad.is_empty() || avg < 0.65 { - panic!("Pixel similarity issues. Avg IoU = {:.3}\nBad: {:?}\n{}", - avg, bad, report); - } - } - #[test] fn centerline_letter_O_is_one_closed_stroke() { // Sanity: the algorithm must still handle simple shapes well. diff --git a/src/lib.rs b/src/lib.rs index 20cc03b3..452773e2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,9 @@ pub mod hulls; pub mod fill; pub mod gcode; pub mod text; +pub mod streamline; +pub mod topo_strokes; +pub mod brush_paint; use std::time::Instant; @@ -821,7 +824,9 @@ fn process_pass_work( "hilbert" => fill::hilbert_fill(hull, spacing), "skeleton" => fill::skeleton_fill(hull, spacing), "centerline" => fill::centerline_fill(hull, spacing), - "chordal" => fill::chordal_axis_fill(hull, param.max(0.0)), + "streamline" => streamline::streamline_fill(hull, param.max(0.0)), + "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)), @@ -852,7 +857,9 @@ fn process_pass_work( "hilbert" => fill::hilbert_fill(hull, spacing), "skeleton" => fill::skeleton_fill(hull, spacing), "centerline" => fill::centerline_fill(hull, spacing), - "chordal" => fill::chordal_axis_fill(hull, param.max(0.0)), + "streamline" => streamline::streamline_fill(hull, param.max(0.0)), + "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)), @@ -994,16 +1001,29 @@ fn list_hulls(pass_idx: usize, state: State>) -> Result>, -) -> Result { +) -> 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(fill::chordal_axis_fill_debug(h, salience.max(0.0))) + Ok(streamline::streamline_fill_debug(h, ¶ms)) +} + +#[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)) } #[tauri::command] @@ -2825,7 +2845,8 @@ pub fn run() { get_images_dir, set_pass_count, list_hulls, - get_chordal_debug, + get_streamline_debug, + get_paint_debug, process_pass, get_all_strokes, get_gcode_viz, diff --git a/src/streamline.rs b/src/streamline.rs new file mode 100644 index 00000000..38426585 --- /dev/null +++ b/src/streamline.rs @@ -0,0 +1,1141 @@ +// Streamline pen-stroke algorithm. +// +// Particle physics on the SDF (chamfer distance to nearest polygon boundary). +// A pen-tip particle travels along the medial-axis ridge with momentum, +// stays on the ridge via attraction (perpendicular component of ∇D), +// pivots at boundary V-tips by look-ahead when the gradient strongly +// opposes velocity, and pen-ups when no viable continuation exists. +// +// Junctions (where SDF is *high*, gradient is small/symmetric) get traversed +// by pure momentum — no decision-making fires there. Decisions fire only +// near actual polygon corners where the SDF is dropping into a wall. +// +// See the running discussion in this PR for the design rationale. + +use std::collections::HashSet; +use crate::fill::{FillResult, smooth_stroke, chamfer_distance}; +use crate::hulls::Hull; + +// ── Debug-image encoding helpers ──────────────────────────────────────── +// Render small base64 PNGs sized to the hull's bbox: source pixels (white +// ink on transparent), SDF heatmap (viridis-coloured chamfer distance), +// visited mask. Used only by the debug pathway; production fill skips them. + +pub(crate) fn colormap_viridis(t: f32) -> (u8, u8, u8) { + let stops: [(u8, u8, u8); 5] = [ + ( 68, 1, 84), // 0.00 — dark purple + ( 59, 82, 139), // 0.25 — blue + ( 33, 144, 141), // 0.50 — teal + ( 93, 201, 99), // 0.75 — green + (253, 231, 37), // 1.00 — yellow + ]; + 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)) +} + +pub(crate) 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) +} + +pub(crate) 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) +} + +#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] +#[serde(default)] +pub struct StreamlineParams { + /// Constant pen speed (pixels per step). Particle direction can rotate, + /// but its magnitude is renormalised to this every step. + pub speed: f32, + /// Time step size. Step distance per iteration = `speed × dt`. + pub dt: f32, + /// Direction-lerp rate toward the local ridge tangent (0..1). + /// Lower = stickier momentum, higher = snappier ridge-following. + pub ridge_lerp: f32, + /// Lateral centering force per step, in pixels. After each direction + /// update, the particle's position is nudged perpendicular to its + /// motion in the direction of the SDF gradient (toward higher SDF / + /// onto the ridge). Counteracts the lateral drift that accumulates + /// when following curved ridges with finite step size. + pub center_strength: f32, + /// Stop when SDF at the particle drops below `min_clearance × sdf_max`. + /// Scale-invariant (a ratio in [0,1]). 0.0 = only stop on hull exit, + /// 0.9 = stop almost immediately if drifting off the ridge spine. + pub min_clearance: f32, + /// `-∇D · v̂` value above which we trigger pivot look-ahead. The + /// gradient must oppose velocity at least this strongly. + pub pivot_threshold: f32, + /// Radius (px) for the look-ahead radial samples. + pub lookahead_radius: f32, + /// Direction-lerp rate when snapping toward a chosen pivot direction + /// (much higher than `ridge_lerp` — pivots are sharp). + pub pivot_steer_rate: f32, + /// Minimum mean-SDF along a candidate pivot direction for it to count + /// as a viable continuation (vs dead-end). Scale-invariant: ratio in + /// [0,1], multiplied by `sdf_max` at use site. + pub min_pivot_score: f32, + /// Multiplier on `sdf_max` for the visited-mask stamp radius. Each + /// step paints `visited_radius × sdf_max` pixels around the particle. + /// 1.0 = "stamp covers stroke half-width" (so the entire stroke gets + /// marked, not just a thin centerline). Scale-invariant. + pub visited_radius: f32, + /// Loop-closure: stop when the particle returns within this many pixels + /// of the stroke's starting point AND has travelled at least + /// `min_loop_distance` first. Handles closed glyphs like O without + /// killing figure-8s at the cross-over. + pub loop_close_radius: f32, + /// Path length below which loop-close is suppressed. Prevents the + /// particle from "closing" instantly because it's still near start. + pub min_loop_distance: f32, + /// Drop strokes whose total length is below `min_stroke_length × sdf_max`. + /// Scale-invariant: 1.0 = "drop strokes shorter than the stroke half-width." + /// Filters fringe artifacts where pick_start grabs an unmarked pixel + /// and the particle dies in 1-3 steps. + pub min_stroke_length: f32, + /// Safety cap on steps per stroke. + pub max_steps_per_stroke: u32, + /// Safety cap on strokes per hull. + pub max_strokes: u32, + /// Final stroke RDP epsilon. + pub output_rdp_eps: f32, + /// Final stroke Chaikin smoothing passes. + pub output_chaikin: u32, +} + +impl Default for StreamlineParams { + /// Defaults found by `streamline_optimize` coordinate-descent over the + /// 62-glyph alphabet at 8mm/200dpi. Loss = stroke-count + IoU-mismatch + + /// hit-the-cap penalty. See the `tests` module. + fn default() -> Self { + Self { + speed: 1.5, + dt: 0.5, + ridge_lerp: 0.3, + center_strength: 0.5, + min_clearance: 0.2, + pivot_threshold: 0.2, + lookahead_radius: 5.0, + pivot_steer_rate: 1.0, + min_pivot_score: 0.2, + visited_radius: 1.2, + loop_close_radius: 5.0, + min_loop_distance: 50.0, + min_stroke_length: 2.0, + max_steps_per_stroke: 4000, + max_strokes: 12, + output_rdp_eps: 0.5, + output_chaikin: 2, + } + } +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct StreamlineDebug { + pub bounds: [f32; 4], + pub source_b64: String, + pub sdf_b64: String, + pub sdf_max: f32, + /// Visited mask as a base64 PNG (semi-transparent dark overlay). + pub visited_b64: String, + pub start_points: Vec<(f32, f32)>, + /// Each stroke's raw trajectory (one entry per particle run). + pub trajectories: Vec>, + /// Final smoothed strokes (what would go to gcode). + pub strokes: Vec>, +} + +// ── SDF grid: dense 2D scalar field over the hull's bbox ───────────────── + +struct SdfGrid { + bx: i32, by: i32, + width: i32, height: i32, + data: Vec, + pub max: f32, +} + +impl SdfGrid { + fn from_hull(hull: &Hull) -> Self { + let bx = hull.bounds.x_min as i32; + let by = hull.bounds.y_min as i32; + let width = (hull.bounds.x_max as i32 - bx + 1).max(1); + let height = (hull.bounds.y_max as i32 - by + 1).max(1); + + let pixel_set: HashSet<(u32, u32)> = hull.pixels.iter().copied().collect(); + let dist = chamfer_distance(hull, &pixel_set); + let mut data = vec![0.0_f32; (width * height) as usize]; + let mut max = 0.0_f32; + 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; } + data[(ly * width + lx) as usize] = d; + if d > max { max = d; } + } + Self { bx, by, width, height, data, max } + } + + fn 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.data[(ly * self.width + lx) as usize] + } + + fn sample(&self, p: (f32, f32)) -> f32 { + let ix = p.0.floor() as i32; + let iy = p.1.floor() as i32; + let fx = p.0 - ix as f32; + let fy = p.1 - iy as f32; + let v00 = self.at(ix, iy ); + let v10 = self.at(ix + 1, iy ); + let v01 = self.at(ix, iy + 1); + let v11 = self.at(ix + 1, iy + 1); + (1.0 - fx) * (1.0 - fy) * v00 + + fx * (1.0 - fy) * v10 + + (1.0 - fx) * fy * v01 + + fx * fy * v11 + } + + fn gradient(&self, p: (f32, f32)) -> (f32, f32) { + let h = 1.0_f32; + let dx = (self.sample((p.0 + h, p.1)) - self.sample((p.0 - h, p.1))) / (2.0 * h); + let dy = (self.sample((p.0, p.1 + h)) - self.sample((p.0, p.1 - h))) / (2.0 * h); + (dx, dy) + } +} + +// ── Visited mask: per-pixel last-step-visited (0 = never) ──────────────── + +struct VisitedMask { + bx: i32, by: i32, + width: i32, height: i32, + age: Vec, + step: u32, +} + +impl VisitedMask { + fn from_hull(hull: &Hull) -> Self { + let bx = hull.bounds.x_min as i32; + let by = hull.bounds.y_min as i32; + let width = (hull.bounds.x_max as i32 - bx + 1).max(1); + let height = (hull.bounds.y_max as i32 - by + 1).max(1); + Self { bx, by, width, height, age: vec![0; (width * height) as usize], step: 0 } + } + + fn tick(&mut self) { self.step += 1; } + + fn mark(&mut self, p: (f32, f32), radius: f32) { + let cx = p.0; + let cy = p.1; + let r = radius.ceil() as i32; + let r2 = radius * radius; + for dy in -r..=r { + for dx in -r..=r { + let dxy = (dx * dx + dy * dy) as f32; + if dxy > r2 { continue; } + let lx = cx as i32 + dx - self.bx; + let ly = cy as i32 + dy - self.by; + if lx < 0 || ly < 0 || lx >= self.width || ly >= self.height { continue; } + self.age[(ly * self.width + lx) as usize] = self.step; + } + } + } + + fn age_at(&self, p: (f32, f32)) -> u32 { + let lx = p.0 as i32 - self.bx; + let ly = p.1 as i32 - self.by; + if lx < 0 || ly < 0 || lx >= self.width || ly >= self.height { return 0; } + self.age[(ly * self.width + lx) as usize] + } + + /// Visitedness in [0, 1] excluding very recent steps. 0 = unvisited or + /// just visited within `blackout` steps; 1 = visited longer ago than that. + fn visitedness(&self, p: (f32, f32), blackout: u32) -> f32 { + let age = self.age_at(p); + if age == 0 { return 0.0; } + let dt = self.step.saturating_sub(age); + if dt < blackout { return 0.0; } + 1.0 + } +} + +// ── Geometry 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 } + +/// Climb the SDF gradient from `p` toward the nearest ridge maximum, up to +/// `max_steps` 1-pixel steps. Returns the snapped position. +fn snap_to_ridge(p: (f32, f32), sdf: &SdfGrid, max_steps: u32) -> (f32, f32) { + let mut cur = p; + for _ in 0..max_steps { + let g = sdf.gradient(cur); + let n = vec_norm(g); + if n < 1e-3 { break; } // at ridge + cur = (cur.0 + g.0 / n * 0.5, cur.1 + g.1 / n * 0.5); + } + cur +} + +// ── Start-point selection ─────────────────────────────────────────────── + +/// Find the starting point for the next stroke: highest-SDF unvisited +/// pixel, with a top-left bias so glyphs are traced in writing order. +/// Returns None when nothing left worth tracing. +fn pick_start(sdf: &SdfGrid, visited: &VisitedMask, params: &StreamlineParams) + -> Option<(f32, f32)> +{ + let mut best: Option<(f32, (i32, i32))> = None; + // Same scale-invariant treatment for the start-pixel SDF threshold: + // a fraction of the hull's SDF max. Use a slightly higher fraction than + // trace_stroke's stop threshold so we always start on the spine. + let start_threshold = (params.min_clearance + 0.05).min(1.0) * sdf.max; + for ly in 0..sdf.height { + for lx in 0..sdf.width { + let d = sdf.data[(ly * sdf.width + lx) as usize]; + if d < start_threshold { continue; } + let p = ((lx + sdf.bx) as f32, (ly + sdf.by) as f32); + // Hard-skip already-painted cells. + if visited.visitedness(p, 0) > 0.5 { continue; } + // Composite score: SDF (prefer ridge tops) + small writing-order + // bias (prefer top, then left). Bias is gentle so it only breaks + // ties between near-equal ridge points. + let bias = -0.001 * (ly as f32) - 0.0005 * (lx as f32); + let score = d + bias; + match best { + None => best = Some((score, (lx, ly))), + Some((bs, _)) if score > bs => best = Some((score, (lx, ly))), + _ => {} + } + } + } + best.map(|(_, (lx, ly))| { + let p = ((lx + sdf.bx) as f32, (ly + sdf.by) as f32); + snap_to_ridge(p, sdf, 8) + }) +} + +/// Local ridge tangent at `p`: perpendicular to the SDF gradient. Returns +/// (tangent_a, tangent_b) — the two opposite directions along the ridge. +/// When the gradient is near zero (we're on a ridge maximum), returns +/// `None` and the caller should fall back to current motion direction. +fn ridge_tangent(p: (f32, f32), sdf: &SdfGrid) -> Option<((f32, f32), (f32, f32))> { + let g = sdf.gradient(p); + if vec_norm(g) < 1e-4 { return None; } + let g_unit = vec_unit(g); + let a = (-g_unit.1, g_unit.0); + let b = ( g_unit.1, -g_unit.0); + Some((a, b)) +} + +/// Choose the initial direction at a new stroke's start by sampling SDF +/// (with prior-visited penalty) along both ridge-tangent options, picking +/// whichever has more unvisited mass ahead. Falls back to "downward" if +/// the ridge tangent is undefined at the start. +fn initial_direction(p: (f32, f32), sdf: &SdfGrid, + prior: &VisitedMask, params: &StreamlineParams) -> (f32, f32) +{ + let (a, b) = match ridge_tangent(p, sdf) { Some(t) => t, None => return (0.0, 1.0) }; + let r = params.lookahead_radius.max(3.0); + let samples = 6; + let score_dir = |d: (f32, f32)| -> f32 { + let mut s = 0.0; + for k in 1..=samples { + let t = (k as f32 / samples as f32) * r; + let q = (p.0 + d.0 * t, p.1 + d.1 * t); + let sdf_v = sdf.sample(q); + let v = if prior.age_at(q) > 0 { 0.0 } else { 1.0 }; + s += sdf_v * v; + } + s / samples as f32 + }; + if score_dir(a) >= score_dir(b) { a } else { b } +} + +// ── Pivot look-ahead ──────────────────────────────────────────────────── + +/// Sample SDF along radial directions; pick the best non-back direction +/// scoring `mean_sdf · (1 − visited_score)`. Considers BOTH the +/// current-stroke visited mask (avoid backtracking on own trail) AND the +/// prior-strokes mask (avoid pivoting into already-drawn arms). Returns +/// (direction, score). +fn lookahead_pivot(p: (f32, f32), v_dir: (f32, f32), + sdf: &SdfGrid, + cur_visited: &VisitedMask, + prior_visited: &VisitedMask, + params: &StreamlineParams) -> Option<((f32, f32), f32)> +{ + const N_DIRS: usize = 24; + let v_unit = vec_unit(v_dir); + let mut best: Option<((f32, f32), f32)> = None; + let r = params.lookahead_radius.max(2.0); + for i in 0..N_DIRS { + let theta = 2.0 * std::f32::consts::PI * i as f32 / N_DIRS as f32; + let dir = (theta.cos(), theta.sin()); + // Skip near-back-directions. + if vec_dot(dir, v_unit) < -0.7 { continue; } + let samples = 6; + let mut sdf_sum = 0.0_f32; + let mut visited_sum = 0.0_f32; + for k in 1..=samples { + let t = (k as f32 / samples as f32) * r; + let q = (p.0 + dir.0 * t, p.1 + dir.1 * t); + sdf_sum += sdf.sample(q); + // Prior strokes are a hard penalty (we've drawn there); current- + // stroke trail is also a penalty but lighter (let figure-8 work). + let prior: f32 = if prior_visited.age_at(q) > 0 { 1.0 } else { 0.0 }; + let cur: f32 = if cur_visited.age_at(q) > 0 { 0.5 } else { 0.0 }; + visited_sum += (prior + cur).min(1.0); + } + let mean_sdf = sdf_sum / samples as f32; + let mean_visited = visited_sum / samples as f32; + let score = mean_sdf * (1.0 - mean_visited); + if score < params.min_pivot_score * sdf.max { continue; } + match best { + None => best = Some((dir, score)), + Some((_, bs)) if score > bs => best = Some((dir, score)), + _ => {} + } + } + best +} + +// ── Trace a single stroke ─────────────────────────────────────────────── + +/// Constant-speed particle integrator. Direction (unit vector) is what +/// changes step-to-step; magnitude is renormalised to `params.speed`. Stops +/// when the particle hits a wall with no viable pivot continuation, or +/// loops back to within `loop_close_radius` of `start` after travelling +/// at least `min_loop_distance`. +/// +/// `cur_visited` is the per-stroke visited mask used by the look-ahead +/// pivot to penalise back-tracking. It does NOT trigger stop conditions +/// directly — that's the loop-close-by-distance check. `prior_visited` +/// is the cross-stroke mask the look-ahead also consults when scoring +/// candidate pivot directions (so we don't pivot into already-drawn arms). +fn trace_stroke(start: (f32, f32), dir0: (f32, f32), + sdf: &SdfGrid, + cur_visited: &mut VisitedMask, + prior_visited: &VisitedMask, + params: &StreamlineParams) -> Vec<(f32, f32)> +{ + let mut p = start; + let mut dir = vec_unit(dir0); + if vec_norm(dir) < 1e-6 { dir = (0.0, 1.0); } + + let mut path = vec![p]; + let mut traveled = 0.0_f32; + let step_dist = params.speed * params.dt; + // Scale-invariant clearance threshold: as a fraction of this hull's + // SDF max. Same params then work across font sizes / thicknesses. + let clearance_threshold = params.min_clearance * sdf.max; + + for _ in 0..params.max_steps_per_stroke { + let d = sdf.sample(p); + if d < clearance_threshold { break; } + + let g = sdf.gradient(p); + let opposing = -vec_dot(g, dir); + + if opposing > params.pivot_threshold { + // Approaching a wall — try to pivot. + match lookahead_pivot(p, dir, sdf, cur_visited, prior_visited, params) { + Some((pivot_dir, _)) => { + // Snap direction toward pivot (high lerp rate). + let r = params.pivot_steer_rate.clamp(0.0, 1.0); + dir = lerp_dir(dir, pivot_dir, r); + } + None => break, // dead-end + } + } else { + // Normal flight — soft-pull toward ridge tangent if we're not + // already aligned with it. + if let Some((ta, tb)) = ridge_tangent(p, sdf) { + // Pick the tangent direction most aligned with current motion. + let t_pick = if vec_dot(ta, dir) >= vec_dot(tb, dir) { ta } else { tb }; + dir = lerp_dir(dir, t_pick, params.ridge_lerp.clamp(0.0, 1.0)); + } + } + + // Centering: shift position perpendicular to motion toward higher + // SDF, so we drift back onto the ridge instead of wandering off + // along a curved ridge. Magnitude is small and capped so the path + // stays smooth. + if params.center_strength > 0.0 { + let g = sdf.gradient(p); + let g_along = vec_dot(g, dir); + let perp = (g.0 - g_along * dir.0, g.1 - g_along * dir.1); + let mag = vec_norm(perp); + if mag > 1e-6 { + let cap = 0.5; // hard cap on centering step (px) — prevents + // overshoot if SDF gradient is steep. + let s = (params.center_strength * mag).min(cap) / mag; + p = (p.0 + perp.0 * s, p.1 + perp.1 * s); + } + } + + // Constant-speed forward step. + let v = (dir.0 * params.speed, dir.1 * params.speed); + let new_p = (p.0 + v.0 * params.dt, p.1 + v.1 * params.dt); + + // Reject the step if it would put us outside the hull. + if sdf.sample(new_p) < 0.05 { break; } + + p = new_p; + path.push(p); + traveled += step_dist; + cur_visited.tick(); + cur_visited.mark(p, params.visited_radius * sdf.max); + + // Loop closure: returned to within R of start after travelling far. + if traveled > params.min_loop_distance { + let dx = p.0 - start.0; let dy = p.1 - start.1; + if (dx * dx + dy * dy).sqrt() < params.loop_close_radius { + // Push start one more time so the polyline closes cleanly. + path.push(start); + break; + } + } + } + + path +} + +/// Lerp between two unit-ish direction vectors and re-normalise. `t` in [0,1]. +fn lerp_dir(a: (f32, f32), b: (f32, f32), t: f32) -> (f32, f32) { + let mixed = (a.0 * (1.0 - t) + b.0 * t, a.1 * (1.0 - t) + b.1 * t); + let n = vec_norm(mixed); + if n < 1e-9 { a } else { (mixed.0 / n, mixed.1 / n) } +} + +// ── Top-level compute ─────────────────────────────────────────────────── + +fn compute(hull: &Hull, params: &StreamlineParams) + -> (Vec<(f32, f32)>, Vec>, VisitedMask, SdfGrid) +{ + let sdf = SdfGrid::from_hull(hull); + // `prior` accumulates across strokes — used by pick_start (to find new + // beginnings) and by lookahead_pivot (avoid pivoting into drawn arms). + let mut prior = VisitedMask::from_hull(hull); + let mut starts: Vec<(f32, f32)> = Vec::new(); + let mut trajectories: Vec> = Vec::new(); + + for _ in 0..params.max_strokes { + let start = match pick_start(&sdf, &prior, params) { + Some(s) => s, + None => break, + }; + starts.push(start); + + // Pick the initial direction by scoring both ridge tangents' + // unvisited-mass. + let dir0 = initial_direction(start, &sdf, &prior, params); + + // Per-stroke visited mask (used by lookahead, not for stop conditions). + let mut cur = VisitedMask::from_hull(hull); + let path = trace_stroke(start, dir0, &sdf, &mut cur, &prior, params); + + // Bump prior's step counter once per stroke so the pixels we paint + // here record an age > 0. (`age == 0` is the "never visited" + // sentinel; without this tick all marks get age 0 and pick_start + // and lookahead both see them as unvisited — every stroke retraces + // the same ridge over and over.) + prior.tick(); + // Always paint the start area, even if the stroke was rejected, + // so we don't keep re-picking the same fringe pixel. + prior.mark(start, params.visited_radius * sdf.max); + if path.len() < 2 { continue; } + + // Reject tiny artifact strokes where the particle escaped pick_start's + // mask only to die at the boundary a few steps later. Threshold + // scales with sdf_max (= local stroke half-width) so the same + // ratio works at 3mm and 8mm. + 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(); + if length < params.min_stroke_length * sdf.max { continue; } + + for &q in &path { prior.mark(q, params.visited_radius * sdf.max); } + trajectories.push(path); + } + + (starts, trajectories, prior, sdf) +} + +// ── Public entry points ───────────────────────────────────────────────── + +pub fn streamline_fill(hull: &Hull, _intensity: f32) -> FillResult { + streamline_fill_with(hull, &StreamlineParams::default()) +} + +pub fn streamline_fill_with(hull: &Hull, params: &StreamlineParams) -> FillResult { + if hull.pixels.is_empty() { + return FillResult { hull_id: hull.id, strokes: vec![] }; + } + let (_, trajectories, _, _) = compute(hull, params); + let strokes: Vec> = trajectories.into_iter() + .map(|t| smooth_stroke(&t, params.output_rdp_eps, params.output_chaikin)) + .filter(|t| t.len() >= 2) + .collect(); + FillResult { hull_id: hull.id, strokes } +} + +pub fn streamline_fill_debug(hull: &Hull, params: &StreamlineParams) -> StreamlineDebug { + 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 (sdf_b64, sdf_max) = encode_sdf_b64(hull); + let mut out = StreamlineDebug { + bounds, + source_b64: encode_hull_pixels_b64(hull), + sdf_b64, + sdf_max, + visited_b64: String::new(), + start_points: Vec::new(), + trajectories: Vec::new(), + strokes: Vec::new(), + }; + if hull.pixels.is_empty() { return out; } + + let (starts, trajectories, visited, _sdf) = compute(hull, params); + out.start_points = starts; + out.visited_b64 = encode_visited_b64(&visited); + out.strokes = trajectories.iter() + .map(|t| smooth_stroke(t, params.output_rdp_eps, params.output_chaikin)) + .filter(|t| t.len() >= 2) + .collect(); + out.trajectories = trajectories; + out +} + +fn encode_visited_b64(v: &VisitedMask) -> String { + if v.width <= 0 || v.height <= 0 { return String::new(); } + let mut img: image::RgbaImage = image::ImageBuffer::new(v.width as u32, v.height as u32); + let max_age = v.step.max(1) as f32; + for ly in 0..v.height { + for lx in 0..v.width { + let age = v.age[(ly * v.width + lx) as usize]; + if age == 0 { continue; } + // Older = darker. Recent = brighter overlay. + let t = (age as f32 / max_age).clamp(0.0, 1.0); + let (r, g, b) = colormap_viridis(0.2 + 0.8 * t); + img.put_pixel(lx as u32, ly as u32, image::Rgba([r, g, b, 110])); + } + } + 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) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::text::{TextBlockSpec, rasterize_blocks}; + use crate::hulls::{extract_hulls, HullParams, Connectivity}; + + fn rasterize_letter(c: char) -> Vec { + rasterize_letter_at(c, 8.0, 200, 4) + } + + 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] + fn streamline_no_panic_for_any_printable_ascii() { + for b in 0x20u8..=0x7E { + let ch = b as char; + for h in rasterize_letter(ch) { + let _ = streamline_fill(&h, 0.0); + let _ = streamline_fill_debug(&h, &StreamlineParams::default()); + } + } + } + + #[test] + fn streamline_letter_I_produces_at_least_one_stroke() { + let hulls = rasterize_letter('I'); + let main = hulls.iter().max_by_key(|h| h.area).expect("no hull"); + let r = streamline_fill(main, 0.0); + assert!(!r.strokes.is_empty(), + "'I' should produce at least 1 stroke, got 0"); + } + + #[test] + fn streamline_letter_O_produces_at_least_one_stroke() { + let hulls = rasterize_letter('O'); + let main = hulls.iter().max_by_key(|h| h.area).expect("no hull"); + let r = streamline_fill(main, 0.0); + assert!(!r.strokes.is_empty(), + "'O' should produce at least 1 stroke (the ring), got 0"); + } + + /// Reproduces the user's texttest.trac3r rasterisation exactly, then + /// runs streamline on hull #N. Use to debug what's actually happening + /// at the production scale (dpi=425). + #[test] + #[ignore] + fn streamline_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(); + let hp = HullParams { + threshold: 253, min_area: 4, rdp_epsilon: 1.5, + connectivity: Connectivity::Four, + ..HullParams::default() + }; + let hulls = extract_hulls(&luma, &rgb, w, h, &hp); + println!("\n{} hulls extracted at dpi={}, thickness={}px", + hulls.len(), dpi, stroke_thickness); + + // Sweep every hull with current defaults; flag any that hit + // max_strokes (the user's reported failure mode). + let params = StreamlineParams::default(); + let mut bad_count = 0; + let mut bad_examples: Vec<(usize, &crate::hulls::Hull, usize, f32)> = Vec::new(); + for (i, h) in hulls.iter().enumerate() { + let r = streamline_fill_with(h, ¶ms); + let total_len: f32 = 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::() + }).sum(); + if r.strokes.len() >= params.max_strokes as usize { + bad_count += 1; + if bad_examples.len() < 10 { + bad_examples.push((i, h, r.strokes.len(), total_len)); + } + } + } + println!("\nHulls hitting max_strokes ({}): {} of {}", + params.max_strokes, bad_count, hulls.len()); + for &(i, h, n, len) in &bad_examples { + let bw = h.bounds.x_max - h.bounds.x_min; + let bh = h.bounds.y_max - h.bounds.y_min; + println!(" hull #{}: area {} bbox {}x{} → {} strokes, total len {:.1}px", + i, h.area, bw, bh, n, len); + } + if bad_examples.is_empty() { + println!("(none — every hull stays under cap)"); + // Pick the largest hull so we still produce a debug trace. + let (idx, hull) = hulls.iter().enumerate() + .max_by_key(|(_, h)| h.area).unwrap(); + return println!("\nLargest hull #{}: area {}, no further trace.", + idx, hull.area); + } + let (idx, hull, _, _) = bad_examples[0]; + println!("\nHull #{} matches: bbox {}x{}, area {}", + idx, + hull.bounds.x_max - hull.bounds.x_min, + hull.bounds.y_max - hull.bounds.y_min, + hull.area); + + let dbg = streamline_fill_debug(hull, ¶ms); + println!("\nTracing hull #{}:", idx); + println!("SDF max: {:.3} px", dbg.sdf_max); + println!("Start points: {}", dbg.start_points.len()); + println!("Trajectories: {}", dbg.trajectories.len()); + println!("Smooth strokes: {}", dbg.strokes.len()); + 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 · len {:.1}px", i, t.len(), len); + } + } + + /// Detailed dump of one letter — print every raw trajectory's start + /// and length. Use to diagnose why a glyph fragments. + #[test] + #[ignore] + fn streamline_letter_inspect_8() { + let hulls = rasterize_letter('8'); + let main = hulls.iter().max_by_key(|h| h.area).unwrap(); + println!("\nHull bbox: ({}, {}) to ({}, {}), area {}", + main.bounds.x_min, main.bounds.y_min, + main.bounds.x_max, main.bounds.y_max, main.area); + let dbg = streamline_fill_debug(main, &StreamlineParams::default()); + println!("SDF max: {:.3} px", dbg.sdf_max); + println!("Start points: {}", dbg.start_points.len()); + for (i, s) in dbg.start_points.iter().enumerate() { + println!(" [{}] start ({:.1}, {:.1})", i, s.0, s.1); + } + 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 f = t.first().unwrap(); + let l = t.last().unwrap(); + println!(" [{}] {} pts · len {:.1}px · {:?} → {:?}", + i, t.len(), len, (f.0, f.1), (l.0, l.1)); + } + } + + // ── Parameter optimizer ───────────────────────────────────────────── + // + // Coordinate descent over the alphabet. For each parameter, scan a + // range while holding the others fixed; pick the value that minimises + // a per-glyph loss summed across A-Z, a-z, 0-9. Three passes. + // + // Loss combines three signals: + // - stroke count (more strokes = more pen-ups = worse) + // - hit-the-cap penalty (heavy — algorithm is broken if it runs out) + // - 1 - IoU between dilated stroke render and source raster (heavy — + // this catches missing or off-glyph strokes; the algorithm could + // trivially zero out stroke count by drawing nothing, this stops it) + // + // Run with: + // cargo test --lib streamline_optimize -- --ignored --nocapture + + fn stamp_disc(grid: &mut [bool], w: i32, h: i32, cx: i32, cy: i32, r: i32) { + let r2 = r * r; + for dy in -r..=r { + for dx in -r..=r { + if dx * dx + dy * dy > r2 { continue; } + let x = cx + dx; let y = cy + dy; + if x < 0 || y < 0 || x >= w || y >= h { continue; } + grid[(y * w + x) as usize] = true; + } + } + } + + fn iou_for_hull(hull: &crate::hulls::Hull, strokes: &[Vec<(f32, f32)>]) -> f32 { + let bx = hull.bounds.x_min as i32; + let by = hull.bounds.y_min as i32; + let bw = (hull.bounds.x_max as i32 - bx + 1).max(1); + let bh = (hull.bounds.y_max as i32 - by + 1).max(1); + let n = (bw * bh) as usize; + let mut source = vec![false; n]; + for &(x, y) in &hull.pixels { + let lx = x as i32 - bx; let ly = y as i32 - by; + if lx < 0 || ly < 0 || lx >= bw || ly >= bh { continue; } + source[(ly * bw + lx) as usize] = true; + } + let mut drawn = vec![false; n]; + // Dilate strokes by half the source thickness (4 px) to compare + // a centerline to a filled glyph. + let radius = 2; + for s in strokes { + for win in s.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 steps = (len * 2.0).ceil().max(1.0) as i32; + for i in 0..=steps { + let t = i as f32 / steps as f32; + let px = a.0 + dx * t; + let py = a.1 + dy * t; + stamp_disc(&mut drawn, bw, bh, + px as i32 - bx, py as i32 - by, radius); + } + } + } + let mut inter = 0u32; + let mut union = 0u32; + for i in 0..n { + if source[i] && drawn[i] { inter += 1; } + if source[i] || drawn[i] { union += 1; } + } + if union == 0 { 1.0 } else { inter as f32 / union as f32 } + } + + /// Multi-scale loss. Evaluates the same alphabet at three font/DPI + /// pairs that bracket the realistic plotting range: + /// - 3mm @ 150dpi / 3px thickness (small text, the failing case) + /// - 5mm @ 200dpi / 4px thickness (mid) + /// - 8mm @ 200dpi / 4px thickness (large) + /// Average loss across the three is what we minimise. This keeps params + /// generalising across scales instead of overfitting to one. + fn alphabet_loss(params: &StreamlineParams) -> f32 { + let scales: &[(f32, u32, u32)] = &[ + (3.0, 150, 3), + (5.0, 200, 4), + (8.0, 200, 4), + ]; + let mut total = 0.0_f32; + for &(font_mm, dpi, thick) in scales { + total += alphabet_loss_at(params, font_mm, dpi, thick); + } + total / scales.len() as f32 + } + + fn alphabet_loss_at(params: &StreamlineParams, + font_mm: f32, dpi: u32, thick: u32) -> f32 { + let chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + let cap = params.max_strokes as usize; + let mut total = 0.0_f32; + let mut count = 0; + 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 = streamline_fill_with(main, params); + let n = r.strokes.len(); + let iou = iou_for_hull(main, &r.strokes); + let total_len: f32 = 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::() + }).sum(); + let bw = (main.bounds.x_max - main.bounds.x_min) as f32; + let bh = (main.bounds.y_max - main.bounds.y_min) as f32; + let perim = 2.0 * (bw + bh); + let overdraw = (total_len - perim).max(0.0) / perim.max(1.0); + + let count_pen = (n as f32 - 1.0).max(0.0); + let cap_pen = if n >= cap { 30.0 } else { 0.0 }; + let cov_pen = (1.0 - iou) * 40.0; + let over_pen = overdraw * 8.0; + total += count_pen + cap_pen + cov_pen + over_pen; + count += 1; + } + total / count.max(1) as f32 + } + + /// One axis of the search space. + struct Dim { + name: &'static str, + get: fn(&StreamlineParams) -> f32, + set: fn(&mut StreamlineParams, f32), + values: &'static [f32], + } + + fn search_dims() -> Vec { + vec![ + Dim { name: "ridge_lerp", get: |p| p.ridge_lerp, + set: |p, v| p.ridge_lerp = v, + values: &[0.1, 0.2, 0.3, 0.45, 0.6, 0.8] }, + Dim { name: "center_strength", get: |p| p.center_strength, + set: |p, v| p.center_strength = v, + values: &[0.0, 0.05, 0.1, 0.2, 0.3, 0.5] }, + Dim { name: "speed", get: |p| p.speed, + set: |p, v| p.speed = v, + values: &[0.5, 0.75, 1.0, 1.5, 2.0] }, + Dim { name: "dt", get: |p| p.dt, + set: |p, v| p.dt = v, + values: &[0.25, 0.4, 0.5, 0.7, 1.0] }, + Dim { name: "min_clearance", get: |p| p.min_clearance, + set: |p, v| p.min_clearance = v, + values: &[0.2, 0.3, 0.4, 0.6, 0.9] }, + Dim { name: "pivot_threshold", get: |p| p.pivot_threshold, + set: |p, v| p.pivot_threshold = v, + values: &[0.2, 0.3, 0.4, 0.5, 0.7, 1.0] }, + Dim { name: "lookahead_radius",get: |p| p.lookahead_radius, + set: |p, v| p.lookahead_radius = v, + values: &[3.0, 5.0, 7.0, 10.0, 15.0] }, + Dim { name: "pivot_steer_rate",get: |p| p.pivot_steer_rate, + set: |p, v| p.pivot_steer_rate = v, + values: &[0.2, 0.4, 0.6, 0.8, 1.0] }, + Dim { name: "min_pivot_score", get: |p| p.min_pivot_score, + set: |p, v| p.min_pivot_score = v, + values: &[0.2, 0.4, 0.6, 0.8, 1.2] }, + Dim { name: "visited_radius", get: |p| p.visited_radius, + set: |p, v| p.visited_radius = v, + values: &[0.5, 0.8, 1.0, 1.2, 1.5, 2.0] }, + Dim { name: "loop_close_radius", get: |p| p.loop_close_radius, + set: |p, v| p.loop_close_radius = v, + values: &[1.0, 2.0, 3.0, 5.0] }, + Dim { name: "min_loop_distance", get: |p| p.min_loop_distance, + set: |p, v| p.min_loop_distance = v, + values: &[10.0, 20.0, 30.0, 50.0] }, + Dim { name: "min_stroke_length", get: |p| p.min_stroke_length, + set: |p, v| p.min_stroke_length = v, + values: &[0.5, 1.0, 2.0, 4.0] }, + ] + } + + #[test] + #[ignore] + fn streamline_optimize() { + let mut best = StreamlineParams::default(); + let mut best_loss = alphabet_loss(&best); + println!("\nInitial loss: {:.3}", best_loss); + let dims = search_dims(); + for pass in 1..=3 { + println!("\n── Pass {} ──", pass); + for d in &dims { + let saved = (d.get)(&best); + let mut local_best = saved; + let mut local_loss = best_loss; + for &v in d.values { + let mut trial = best.clone(); + (d.set)(&mut trial, v); + let l = alphabet_loss(&trial); + if l < local_loss { local_loss = l; local_best = v; } + } + if local_loss < best_loss - 1e-3 { + (d.set)(&mut best, local_best); + println!(" {:>20} {:.3} → {:.3} loss {:.3} → {:.3}", + d.name, saved, local_best, best_loss, local_loss); + best_loss = local_loss; + } else { + println!(" {:>20} {:.3} (kept) loss {:.3}", + d.name, saved, best_loss); + } + } + } + println!("\n══ Optimized params (loss {:.3}) ══", best_loss); + println!("speed: {:.3}", best.speed); + println!("dt: {:.3}", best.dt); + println!("ridge_lerp: {:.3}", best.ridge_lerp); + println!("center_strength: {:.3}", best.center_strength); + println!("min_clearance: {:.3}", best.min_clearance); + println!("pivot_threshold: {:.3}", best.pivot_threshold); + println!("lookahead_radius: {:.3}", best.lookahead_radius); + println!("pivot_steer_rate: {:.3}", best.pivot_steer_rate); + println!("min_pivot_score: {:.3}", best.min_pivot_score); + println!("visited_radius: {:.3}", best.visited_radius); + println!("loop_close_radius: {:.3}", best.loop_close_radius); + println!("min_loop_distance: {:.3}", best.min_loop_distance); + println!("min_stroke_length: {:.3}", best.min_stroke_length); + } + + /// Diagnostic only — never asserts anything strong; prints a per-letter + /// stroke-count + total-points report so we can see where the algorithm + /// is fragmenting glyphs vs producing clean strokes. Run with + /// cargo test --lib streamline_alphabet_report -- --nocapture + #[test] + #[ignore] + fn streamline_alphabet_report() { + let chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + let params = StreamlineParams::default(); + // Run at all three scales the optimizer trains on. + 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); + run_alphabet_report(chars, ¶ms, font_mm, dpi, thick); + } + } + + fn run_alphabet_report(chars: &str, params: &StreamlineParams, + font_mm: f32, dpi: u32, thick: u32) { + let mut total_strokes = 0; + let mut counts: Vec<(char, usize, usize, f32)> = 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 => { println!("'{}': no hull", ch); continue; } + }; + let r = streamline_fill_with(main, ¶ms); + let n = r.strokes.len(); + let pts: usize = r.strokes.iter().map(|s| s.len()).sum(); + // Average stroke length (Euclidean) — cheap quality proxy. + let avg_len = if n == 0 { 0.0 } else { + let total_len: f32 = 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::() + }).sum(); + total_len / n as f32 + }; + counts.push((ch, n, pts, avg_len)); + total_strokes += n; + println!("'{}': {:>2} strokes · {:>4} pts · avg-len {:>5.1}px", + ch, n, pts, avg_len); + } + let avg = total_strokes as f32 / counts.len() as f32; + let worst: Vec<_> = counts.iter().filter(|&&(_, n, _, _)| n >= 6).collect(); + println!("\nTotal: {} strokes across {} chars (avg {:.1}/char)", + total_strokes, counts.len(), avg); + println!("Fragmented (≥6 strokes): {:?}", + worst.iter().map(|t| (t.0, t.1)).collect::>()); + } +} diff --git a/src/topo_strokes.rs b/src/topo_strokes.rs new file mode 100644 index 00000000..db9d1983 --- /dev/null +++ b/src/topo_strokes.rs @@ -0,0 +1,568 @@ +// 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); + } + } +}