This commit is contained in:
Mitchell Hansen
2026-05-01 18:15:41 -07:00
parent 2e26069a35
commit afb1800057
11 changed files with 5033 additions and 1427 deletions

View File

@@ -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 */}
<div className="flex items-center gap-1 px-3 py-2 border-b border-neutral-800 bg-neutral-900/80">
{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 (
<button key={m}
@@ -615,8 +616,10 @@ export default function App() {
/>
) : viewMode === 'tuning' ? (
<TuningPanel printerUrl={gcodeConfig.printer_url ?? ''} />
) : viewMode === 'chordal' ? (
<ChordalDebugView passIdx={0} />
) : viewMode === 'streamline' ? (
<StreamlineDebugView passIdx={0} />
) : viewMode === 'paint' ? (
<PaintDebugView passIdx={0} />
) : viewMode === 'source' && sourceMode === 'text' ? (
<TextEditOverlay
paperWMm={gcodeConfig.paper_w_mm}

View File

@@ -1,505 +0,0 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import * as tauri from '../hooks/useTauri.js'
// macOS trackpads emit lots of small wheel events per gesture (versus a few
// large events from a discrete mouse wheel). Apply a much gentler per-event
// factor so a single two-finger swipe doesn't blast through the zoom range.
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
// Steps as labelled in the chordal_axis_fill explanation. Each layer renders
// one of the algorithm's intermediate states. Toggle them on/off to walk
// through the algorithm at high zoom.
const LAYERS = [
{ key: 'source', label: '0. Source pixels', on: true },
{ key: 'outer', label: '1. Outer polygon', on: true },
{ key: 'holePixels', label: '2. Hole pixels', on: false },
{ key: 'holes', label: '3. Hole polygons', on: true },
{ key: 'triangulation', label: '4. CDT triangulation', on: true },
{ key: 'classification',label: '5. Triangle classification',on: true },
{ key: 'segments', label: '6. CAT segments', on: false },
{ key: 'polylines', label: '7-8. Polylines (raw)', on: false },
{ key: 'pruned', label: '8.5 Pruned branches', on: true },
{ key: 'strokes', label: '9. Final smoothed strokes', on: true },
{ key: 'vertices', label: '· Polygon vertices', on: false },
]
const KIND_FILL = {
junction: 'rgba(244, 63, 94, 0.30)', // red
sleeve: 'rgba(56, 189, 248, 0.20)', // cyan
termination: 'rgba(250, 204, 21, 0.30)', // amber
pure: 'rgba(168, 85, 247, 0.30)', // purple
outside: 'rgba(120, 120, 120, 0.06)', // grey
}
export default function ChordalDebugView({ passIdx = 0 }) {
const [hulls, setHulls] = useState([])
const [hullIdx, setHullIdx] = useState(0)
const [salience, setSalience] = useState(0)
const [sourceOpacity, setSourceOpacity] = useState(0.4)
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) // {x, y, sx, sy} in image coords
const [selBox, setSelBox] = useState(null) // {x0, y0, x1, y1} in image coords during drag
const [toast, setToast] = useState(null) // ephemeral notification text
// Load hull list whenever the tab is mounted.
useEffect(() => {
let alive = true
tauri.listHulls(passIdx).then(list => {
if (!alive) return
// Sort by area desc so the largest glyph is index 0 in the dropdown.
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])
// Pull debug data when hull or salience changes.
useEffect(() => {
if (hulls.length === 0) return
let alive = true
tauri.getChordalDebug(passIdx, hullIdx, salience).then(d => {
if (!alive) return
setDebug(d)
// Reset view on new hull.
setView({ zoom: 1, panX: 0, panY: 0 })
}).catch(() => {})
return () => { alive = false }
}, [passIdx, hullIdx, salience, hulls.length])
// viewBox: hull bbox padded by 4px, optionally zoomed/panned via SVG.
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
// Continuous zoom proportional to scroll magnitude — feels right for
// both trackpad gestures and mouse wheel ticks.
const factor = Math.exp(-e.deltaY * ZOOM_SENSITIVITY)
const rect = svgRef.current.getBoundingClientRect()
// Cursor position normalised to the SVG element (independent of viewBox).
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
// viewBox.x = (x0 - pad) - panX, viewBox.width = wBase / zoom.
// Keeping (viewBox.x + u * width) constant across zoom yields:
// panX_new = panX + u * (width_new - width_old)
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,
}
})
}
// Convert clientXY to image-space coords using the SVG's CTM.
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
// Shift-drag = box select. Falls back to pan-drag when shift not held.
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
// Convert pixel drag to image-coord drag using current viewBox size.
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)
}
// Filter all debug data to what's inside the selection box, format as JSON,
// and copy to clipboard. Format is intentionally compact-ish — meant to be
// pasted back into a chat to describe an issue at a specific glyph corner.
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 // ignore tiny/click-only
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)],
outer_vertices_in_box: r2list(debug.outer.filter(ptIn)),
hole_vertices_in_box: debug.holes.map(h => r2list(h.filter(ptIn))).filter(h => h.length > 0),
triangles: debug.triangles.filter(t => anyIn(t.points))
.map(t => ({
points: r2list(t.points),
edge_constraint: t.edge_constraint,
kind: t.kind,
})),
segments: debug.segments.filter(([a, b]) => ptIn(a) || ptIn(b))
.map(([a, b]) => [r2(a), r2(b)]),
polylines: debug.polylines.filter(p => anyIn(p.points))
.map(p => ({
branch: p.branch,
kept: p.kept,
points: r2list(p.points),
})),
strokes: debug.strokes.filter(anyIn).map(r2list),
}
const json = JSON.stringify(out, null, 2)
navigator.clipboard.writeText(json).then(() => {
const summary = `${out.outer_vertices_in_box.length} outer · ` +
`${out.triangles.length} tris · ` +
`${out.segments.length} segs · ` +
`${out.polylines.length} polylines · ` +
`${out.strokes.length} strokes`
setToast(`Copied to clipboard — ${summary}`)
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 inv = ctm.inverse()
const ip = pt.matrixTransform(inv)
setHover({ x: ip.x, y: ip.y, sx: e.clientX, sy: e.clientY })
}
const toggleLayer = (key) => setEnabled(en => ({ ...en, [key]: !en[key] }))
if (!debug) {
return (
<div className="absolute inset-0 flex items-center justify-center bg-neutral-900 text-neutral-500">
<div className="text-center space-y-2">
<p>Chordal debug</p>
<p className="text-xs">No hulls available run the pipeline first (Source Kernel Hull).</p>
</div>
</div>
)
}
return (
<div ref={containerRef} className="absolute inset-0 bg-neutral-900 flex">
{/* Sidebar */}
<div className="w-64 shrink-0 border-r border-neutral-800 p-3 overflow-y-auto text-xs text-neutral-300 space-y-3">
<div>
<label className="block text-neutral-500 mb-1">Hull (largest first)</label>
<select
value={hullIdx}
onChange={e => setHullIdx(parseInt(e.target.value, 10))}
className="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-xs">
{hulls.map((h, i) => (
<option key={h.index} value={h.index}>
#{h.index} · {h.area}px · {h.bounds[2] - h.bounds[0]}×{h.bounds[3] - h.bounds[1]}
{i === 0 ? ' (largest)' : ''}
</option>
))}
</select>
</div>
<div>
<label className="block text-neutral-500 mb-1">
Salience: {salience.toFixed(1)}
</label>
<input type="range" min={0} max={4} step={0.1}
value={salience}
onChange={e => setSalience(parseFloat(e.target.value))}
className="w-full" />
</div>
<div>
<label className="block text-neutral-500 mb-1">
Source opacity: {(sourceOpacity * 100).toFixed(0)}%
</label>
<input type="range" min={0} max={1} step={0.05}
value={sourceOpacity}
onChange={e => setSourceOpacity(parseFloat(e.target.value))}
className="w-full" />
</div>
<div>
<div className="text-neutral-500 mb-1">Layers</div>
<div className="space-y-1">
{LAYERS.map(l => (
<label key={l.key} className="flex items-center gap-2 cursor-pointer">
<input type="checkbox"
checked={!!enabled[l.key]}
onChange={() => toggleLayer(l.key)} />
<span>{l.label}</span>
</label>
))}
</div>
</div>
<div className="pt-2 border-t border-neutral-800 text-neutral-500 leading-relaxed">
<p>Zoom: wheel · Pan: drag</p>
<p>Reset:
<button onClick={() => setView({ zoom: 1, panX: 0, panY: 0 })}
className="ml-2 px-2 py-0.5 bg-neutral-800 rounded">Fit</button>
</p>
<div className="mt-2 space-y-0.5">
<div>· {debug.outer.length} outer verts</div>
<div>· {debug.holes.length} holes ({debug.hole_pixels.reduce((s, h) => s + h.length, 0)} px)</div>
<div>· {debug.triangles.length} triangles
({debug.triangles.filter(t => t.kind !== 'outside').length} interior)</div>
<div>· {debug.segments.length} CAT segments</div>
<div>· {debug.polylines.length} polylines
({debug.polylines.filter(p => p.branch).length} branches,
{' '}{debug.polylines.filter(p => !p.kept).length} pruned)</div>
<div>· {debug.strokes.length} final strokes</div>
<div>· source_b64: {debug.source_b64
? `${(debug.source_b64.length / 1024).toFixed(1)} KB`
: 'MISSING (rebuild Rust)'}</div>
</div>
{hover && (
<div className="mt-2 font-mono">
({hover.x.toFixed(2)}, {hover.y.toFixed(2)})
</div>
)}
</div>
</div>
{/* Canvas */}
<div className="flex-1 relative overflow-hidden" onWheel={onWheel} onMouseDown={onMouseDown}>
<svg
ref={svgRef}
width="100%" height="100%"
viewBox={viewBox}
preserveAspectRatio="xMidYMid meet"
onMouseMove={onMouseMoveSvg}
style={{ cursor: 'grab', background: '#0f0f10' }}>
{/* 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 && (
<image
href={debug.source_b64}
xlinkHref={debug.source_b64}
x={debug.bounds[0]}
y={debug.bounds[1]}
width={debug.bounds[2] - debug.bounds[0] + 1}
height={debug.bounds[3] - debug.bounds[1] + 1}
opacity={sourceOpacity}
style={{ imageRendering: 'pixelated' }}
preserveAspectRatio="none"
/>
)}
{/* Hole pixel cells (raster underlay) */}
{enabled.holePixels && debug.hole_pixels.map((hp, hi) => (
<g key={`hp${hi}`} fill="rgba(56, 189, 248, 0.18)" stroke="none">
{hp.map(([x, y], i) => (
<rect key={i} x={x} y={y} width={1} height={1} />
))}
</g>
))}
{/* 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 (
<g key={`t${i}`}>
<polygon
points={t.points.map(p => `${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 (
<line key={ei} x1={a[0]} y1={a[1]} x2={b[0]} y2={b[1]}
stroke="rgba(255, 255, 255, 0.95)" strokeWidth={1.5}
vectorEffect="non-scaling-stroke" />
)
})}
</g>
)
})}
{/* Outer polygon (highlighted on top) */}
{enabled.outer && (
<polygon
points={debug.outer.map(p => `${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) => (
<polygon key={`h${i}`}
points={h.map(p => `${p[0]},${p[1]}`).join(' ')}
fill="none" stroke="#fbbf24" strokeWidth={1.0}
vectorEffect="non-scaling-stroke" />
))}
{/* Polygon vertices */}
{enabled.vertices && (
<g fill="#34d399">
{debug.outer.map((p, i) => (
<circle key={`ov${i}`} cx={p[0]} cy={p[1]} r={0.5}
vectorEffect="non-scaling-stroke" />
))}
{debug.holes.flatMap((h, hi) => h.map((p, i) => (
<circle key={`hv${hi}-${i}`} cx={p[0]} cy={p[1]} r={0.5}
fill="#fbbf24" vectorEffect="non-scaling-stroke" />
)))}
</g>
)}
{/* Raw CAT segments (before walking) */}
{enabled.segments && debug.segments.map(([a, b], i) => (
<line key={`s${i}`} x1={a[0]} y1={a[1]} x2={b[0]} y2={b[1]}
stroke="rgba(244, 63, 94, 0.7)" strokeWidth={0.6}
vectorEffect="non-scaling-stroke" />
))}
{/* Raw polylines (before smoothing) */}
{enabled.polylines && debug.polylines.map((pl, i) => (
<polyline key={`pl${i}`}
points={pl.points.map(p => `${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) => (
<polyline key={`pr${i}`}
points={pl.points.map(p => `${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) => (
<polyline key={`st${i}`}
points={s.map(p => `${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 && (
<rect
x={Math.min(selBox.x0, selBox.x1)}
y={Math.min(selBox.y0, selBox.y1)}
width={Math.abs(selBox.x1 - selBox.x0)}
height={Math.abs(selBox.y1 - selBox.y0)}
fill="rgba(99, 102, 241, 0.12)"
stroke="#818cf8"
strokeWidth={0.5}
strokeDasharray="2 1"
vectorEffect="non-scaling-stroke"
/>
)}
</svg>
{/* Shift+drag hint, bottom-left */}
<div className="absolute bottom-2 left-3 text-[10px] text-neutral-500 pointer-events-none">
Shift+drag to copy region data to clipboard
</div>
{/* Toast notification */}
{toast && (
<div className="absolute top-3 left-1/2 -translate-x-1/2 px-3 py-1.5 rounded
bg-neutral-800 border border-indigo-500/60 text-xs text-neutral-100 shadow-lg
pointer-events-none">
{toast}
</div>
)}
</div>
</div>
)
}

View File

@@ -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 (
<div className="absolute inset-0 flex items-center justify-center bg-neutral-900 text-neutral-500">
<div className="text-center space-y-2">
<p>Paint debug</p>
<p className="text-xs">No hulls available run the pipeline first (Source Kernel Hull).</p>
</div>
</div>
)
}
return (
<div ref={containerRef} className="absolute inset-0 bg-neutral-900 flex">
<div className="w-64 shrink-0 border-r border-neutral-800 p-3 overflow-y-auto text-xs text-neutral-300 space-y-3">
<div>
<label className="block text-neutral-500 mb-1">Hull (largest first)</label>
<select value={hullIdx}
onChange={e => setHullIdx(parseInt(e.target.value, 10))}
className="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-xs">
{hulls.map((h, i) => (
<option key={h.index} value={h.index}>
#{h.index} · {h.area}px · {h.bounds[2] - h.bounds[0]}×{h.bounds[3] - h.bounds[1]}
{i === 0 ? ' (largest)' : ''}
</option>
))}
</select>
</div>
<div>
<div className="text-neutral-500 mb-1">Layers</div>
<div className="space-y-1">
{LAYERS.map(l => (
<label key={l.key} className="flex items-center gap-2 cursor-pointer">
<input type="checkbox"
checked={!!enabled[l.key]}
onChange={() => toggleLayer(l.key)} />
<span>{l.label}</span>
</label>
))}
</div>
</div>
<div className="space-y-2 pt-1 border-t border-neutral-800">
<div className="flex items-center justify-between">
<span className="text-neutral-500">Brush</span>
<div className="flex gap-1">
<button onClick={() => dumpDebug(null)}
className="text-[10px] px-2 py-0.5 rounded bg-indigo-600/30 border border-indigo-500/60 hover:bg-indigo-600/50 text-indigo-200"
title="Copy full hull state (mask + params + output) to clipboard">
Dump
</button>
<button onClick={() => setParams({ ...DEFAULT_PAINT_PARAMS })}
className="text-[10px] px-2 py-0.5 rounded bg-neutral-800 hover:bg-neutral-700 text-neutral-400">
Reset
</button>
</div>
</div>
<ParamSlider label="Radius factor" value={params.brush_radius_factor} min={0} max={3} step={0.05}
onChange={v => setParam('brush_radius_factor', v)}
hint="× sdf_max. 1.0 ≈ matches stroke width given the offset below." />
<ParamSlider label="Radius offset px" value={params.brush_radius_offset_px} min={0} max={4} step={0.25}
onChange={v => setParam('brush_radius_offset_px', v)}
hint="Added to the radius after the multiplier. Compensates chamfer underestimate." />
<ParamSlider label="Step size factor" value={params.step_size_factor} min={0.05} max={1.5} step={0.05}
onChange={v => setParam('step_size_factor', v)}
hint="× brush radius. 0.5 = 50% disk overlap each step." />
</div>
<div className="space-y-2 pt-1 border-t border-neutral-800">
<span className="text-neutral-500">Direction scoring</span>
<ParamSlider label="N directions" value={params.n_directions} min={6} max={64} step={2}
onChange={v => setParam('n_directions', v)}
hint="Number of candidate directions sampled per step." />
<ParamSlider label="Lookahead steps" value={params.lookahead_steps} min={1} max={12} step={1}
onChange={v => setParam('lookahead_steps', v)}
hint="How many steps ahead to evaluate when scoring a direction." />
<ParamSlider label="Momentum weight" value={params.momentum_weight} min={0} max={2} step={0.05}
onChange={v => setParam('momentum_weight', v)}
hint="Bonus for directions aligned with previous velocity." />
<ParamSlider label="Overpaint penalty" value={params.overpaint_penalty} min={0} max={0.5} step={0.01}
onChange={v => setParam('overpaint_penalty', v)}
hint="Per-pixel cost for painting over already-painted pixels." />
<ParamSlider label="Min score factor" value={params.min_score_factor} min={0} max={0.5} step={0.01}
onChange={v => setParam('min_score_factor', v)}
hint="Stroke ends when best direction's score < this × brush area." />
</div>
<div className="space-y-2 pt-1 border-t border-neutral-800">
<span className="text-neutral-500">Path relaxation</span>
<ParamSlider label="Polish iters" value={params.polish_iters} min={0} max={20} step={1}
onChange={v => setParam('polish_iters', v)}
hint="Relax↔shorten tick-tock rounds. 0 = no relaxation." />
<ParamSlider label="Polish search ×r" value={params.polish_search_factor} min={0.5} max={6} step={0.25}
onChange={v => setParam('polish_search_factor', v)}
hint="How far (in brush radii) to search for unpainted ink near each waypoint." />
<ParamSlider label="Outside penalty" value={params.outside_penalty} min={0} max={10} step={0.25}
onChange={v => setParam('outside_penalty', v)}
hint="Cost per background-pixel under brush. Reject moves that drift the path off the glyph." />
<ParamSlider label="Min component" value={params.min_component_factor} min={0} max={2} step={0.1}
onChange={v => setParam('min_component_factor', v)}
hint="Smallest unpainted-ink connected component that warrants a new stroke, as a multiple of brush area. Smaller components get a single disk stamp instead." />
<ParamSlider label="Pen lift penalty" value={params.pen_lift_penalty} min={0} max={200} step={5}
onChange={v => 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." />
<ParamSlider label="Pen lift reach ×r" value={params.pen_lift_reach} min={0} max={16} step={0.25}
onChange={v => 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." />
</div>
<div className="space-y-2 pt-1 border-t border-neutral-800">
<span className="text-neutral-500">Caps</span>
<ParamSlider label="Max steps/stroke" value={params.max_steps_per_stroke} min={100} max={10000} step={100}
onChange={v => setParam('max_steps_per_stroke', v)} hint="Safety cap." />
<ParamSlider label="Max strokes" value={params.max_strokes} min={1} max={30} step={1}
onChange={v => setParam('max_strokes', v)} hint="Safety cap on strokes per hull." />
<ParamSlider label="Output RDP" value={params.output_rdp_eps} min={0} max={2} step={0.1}
onChange={v => setParam('output_rdp_eps', v)} hint="Final stroke RDP epsilon." />
<ParamSlider label="Output Chaikin" value={params.output_chaikin} min={0} max={6} step={1}
onChange={v => setParam('output_chaikin', v)} hint="Final stroke Chaikin smoothing passes." />
</div>
<div className="pt-2 border-t border-neutral-800 space-y-2">
<div>
<label className="block text-neutral-500 mb-1">
Source opacity: {(sourceOpacity * 100).toFixed(0)}%
</label>
<input type="range" min={0} max={1} step={0.05}
value={sourceOpacity} onChange={e => setSourceOpacity(parseFloat(e.target.value))}
className="w-full" />
</div>
<div>
<label className="block text-neutral-500 mb-1">
SDF opacity: {(sdfOpacity * 100).toFixed(0)}%
</label>
<input type="range" min={0} max={1} step={0.05}
value={sdfOpacity} onChange={e => setSdfOpacity(parseFloat(e.target.value))}
className="w-full" />
</div>
<div>
<label className="block text-neutral-500 mb-1">
Missed-pixel opacity: {(coverageOpacity * 100).toFixed(0)}%
</label>
<input type="range" min={0} max={1} step={0.05}
value={coverageOpacity} onChange={e => setCoverageOpacity(parseFloat(e.target.value))}
className="w-full" />
</div>
</div>
<div className="pt-2 border-t border-neutral-800 text-neutral-500 leading-relaxed">
<p>Wheel: zoom · Drag: pan · Shift+drag: copy region</p>
<button onClick={() => setView({ zoom: 1, panX: 0, panY: 0 })}
className="mt-1 text-xs px-2 py-0.5 bg-neutral-800 rounded">Fit</button>
<div className="mt-2 space-y-0.5">
<div>· brush r: {debug.brush_radius?.toFixed(2) ?? '—'} px (sdf max: {debug.sdf_max?.toFixed(2) ?? '—'})</div>
<div>· {debug.start_points.length} start points</div>
<div>· {debug.trajectories.length} raw trajectories</div>
<div>· {debug.strokes.length} smoothed strokes</div>
</div>
{hover && (
<div className="mt-2 font-mono">
({hover.x.toFixed(2)}, {hover.y.toFixed(2)})
</div>
)}
</div>
</div>
<div className="flex-1 relative overflow-hidden" onWheel={onWheel} onMouseDown={onMouseDown}>
<svg
ref={svgRef}
width="100%" height="100%"
viewBox={viewBox}
preserveAspectRatio="xMidYMid meet"
onMouseMove={onMouseMoveSvg}
style={{ cursor: 'grab', background: '#0f0f10' }}>
{enabled.source && debug.source_b64 && (
<image
href={debug.source_b64} xlinkHref={debug.source_b64}
x={debug.bounds[0]} y={debug.bounds[1]}
width={debug.bounds[2] - debug.bounds[0] + 1}
height={debug.bounds[3] - debug.bounds[1] + 1}
opacity={sourceOpacity}
style={{ imageRendering: 'pixelated' }}
preserveAspectRatio="none" />
)}
{enabled.sdf && debug.sdf_b64 && (
<image
href={debug.sdf_b64} xlinkHref={debug.sdf_b64}
x={debug.bounds[0]} y={debug.bounds[1]}
width={debug.bounds[2] - debug.bounds[0] + 1}
height={debug.bounds[3] - debug.bounds[1] + 1}
opacity={sdfOpacity}
style={{ imageRendering: 'pixelated' }}
preserveAspectRatio="none" />
)}
{enabled.coverage && debug.coverage_b64 && (
<image
href={debug.coverage_b64} xlinkHref={debug.coverage_b64}
x={debug.bounds[0]} y={debug.bounds[1]}
width={debug.bounds[2] - debug.bounds[0] + 1}
height={debug.bounds[3] - debug.bounds[1] + 1}
opacity={coverageOpacity}
style={{ imageRendering: 'pixelated' }}
preserveAspectRatio="none" />
)}
{/* 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) => (
<polyline key={`sw${i}`}
points={t.map(p => `${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) => (
<polyline key={`tr${i}`}
points={t.map(p => `${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) => (
<polyline key={`st${i}`}
points={s.map(p => `${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) => (
<g key={`sp${i}`}>
<circle cx={p[0]} cy={p[1]} r={1.5}
fill={strokeHue(i)} stroke="white" strokeWidth={0.4}
vectorEffect="non-scaling-stroke" />
<text x={p[0] + 2} y={p[1] - 2}
fill={strokeHue(i)} fontSize={3}
style={{ paintOrder: 'stroke', stroke: '#000', strokeWidth: 0.5 }}>
{i + 1}
</text>
</g>
))}
{selBox && (
<rect
x={Math.min(selBox.x0, selBox.x1)}
y={Math.min(selBox.y0, selBox.y1)}
width={Math.abs(selBox.x1 - selBox.x0)}
height={Math.abs(selBox.y1 - selBox.y0)}
fill="rgba(99, 102, 241, 0.12)"
stroke="#818cf8" strokeWidth={0.5}
strokeDasharray="2 1"
vectorEffect="non-scaling-stroke" />
)}
</svg>
<div className="absolute bottom-2 left-3 text-[10px] text-neutral-500 pointer-events-none">
Shift+drag to copy region data to clipboard
</div>
{toast && (
<div className="absolute top-3 left-1/2 -translate-x-1/2 px-3 py-1.5 rounded
bg-neutral-800 border border-indigo-500/60 text-xs text-neutral-100 shadow-lg
pointer-events-none">
{toast}
</div>
)}
</div>
</div>
)
}
function ParamSlider({ label, value, min, max, step, onChange, hint }) {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return (
<div className="flex items-center justify-between text-[10px] text-red-400">
<span>{label}</span><span></span>
</div>
)
}
const display = Number.isInteger(step) ? value.toString() : value.toFixed(2)
return (
<div title={hint}>
<div className="flex items-center justify-between text-[10px]">
<span className="text-neutral-400">{label}</span>
<span className="text-neutral-300 font-mono">{display}</span>
</div>
<input type="range" min={min} max={max} step={step}
value={value}
onChange={e => onChange(parseFloat(e.target.value))}
className="w-full" />
</div>
)
}

View File

@@ -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 (
<div className="absolute inset-0 flex items-center justify-center bg-neutral-900 text-neutral-500">
<div className="text-center space-y-2">
<p>Streamline debug</p>
<p className="text-xs">No hulls available run the pipeline first (Source Kernel Hull).</p>
</div>
</div>
)
}
return (
<div ref={containerRef} className="absolute inset-0 bg-neutral-900 flex">
{/* Sidebar */}
<div className="w-64 shrink-0 border-r border-neutral-800 p-3 overflow-y-auto text-xs text-neutral-300 space-y-3">
<div>
<label className="block text-neutral-500 mb-1">Hull (largest first)</label>
<select value={hullIdx}
onChange={e => setHullIdx(parseInt(e.target.value, 10))}
className="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-xs">
{hulls.map((h, i) => (
<option key={h.index} value={h.index}>
#{h.index} · {h.area}px · {h.bounds[2] - h.bounds[0]}×{h.bounds[3] - h.bounds[1]}
{i === 0 ? ' (largest)' : ''}
</option>
))}
</select>
</div>
<div>
<div className="text-neutral-500 mb-1">Layers</div>
<div className="space-y-1">
{LAYERS.map(l => (
<label key={l.key} className="flex items-center gap-2 cursor-pointer">
<input type="checkbox"
checked={!!enabled[l.key]}
onChange={() => toggleLayer(l.key)} />
<span>{l.label}</span>
</label>
))}
</div>
</div>
<div className="space-y-2 pt-1 border-t border-neutral-800">
<div className="flex items-center justify-between">
<span className="text-neutral-500">Dynamics</span>
<button onClick={() => setParams({ ...DEFAULT_STREAMLINE_PARAMS })}
className="text-[10px] px-2 py-0.5 rounded bg-neutral-800 hover:bg-neutral-700 text-neutral-400">
Reset
</button>
</div>
<ParamSlider label="Speed" value={params.speed} min={0.25} max={3} step={0.05}
onChange={v => setParam('speed', v)}
hint="Constant pen speed (px/step). Direction can rotate, magnitude is renormalised." />
<ParamSlider label="dt" value={params.dt} min={0.1} max={2} step={0.05}
onChange={v => setParam('dt', v)}
hint="Time step. Step distance per iteration = speed × dt." />
<ParamSlider label="Ridge lerp" value={params.ridge_lerp} min={0} max={1} step={0.05}
onChange={v => setParam('ridge_lerp', v)}
hint="Direction-lerp rate toward local ridge tangent. Lower = stickier momentum." />
<ParamSlider label="Center strength" value={params.center_strength} min={0} max={0.8} step={0.05}
onChange={v => setParam('center_strength', v)}
hint="Per-step lateral nudge toward higher SDF (counters drift on curves)." />
<ParamSlider label="Min clearance" value={params.min_clearance} min={0} max={2} step={0.05}
onChange={v => setParam('min_clearance', v)}
hint="Stop when SDF at the particle drops below this — drifted off ridge." />
</div>
<div className="space-y-2 pt-1 border-t border-neutral-800">
<span className="text-neutral-500">Pivot detection</span>
<ParamSlider label="Pivot trigger" value={params.pivot_threshold} min={0} max={1.5} step={0.05}
onChange={v => setParam('pivot_threshold', v)}
hint="∇D·v̂ value above which look-ahead fires (gradient opposing velocity)." />
<ParamSlider label="Look-ahead r" value={params.lookahead_radius} min={1} max={20} step={0.5}
onChange={v => setParam('lookahead_radius', v)}
hint="Radius (px) for pivot direction sampling." />
<ParamSlider label="Steer rate" value={params.pivot_steer_rate} min={0} max={1} step={0.05}
onChange={v => setParam('pivot_steer_rate', v)}
hint="How fast velocity snaps to chosen pivot direction." />
<ParamSlider label="Min pivot score" value={params.min_pivot_score} min={0} max={3} step={0.05}
onChange={v => setParam('min_pivot_score', v)}
hint="Minimum mean-SDF along a pivot direction to count as viable continuation." />
</div>
<div className="space-y-2 pt-1 border-t border-neutral-800">
<span className="text-neutral-500">Mask · loop · caps</span>
<ParamSlider label="Visited r" value={params.visited_radius} min={0.5} max={5} step={0.1}
onChange={v => setParam('visited_radius', v)}
hint="Radius (px) of the visited-mask stamp at each step." />
<ParamSlider label="Loop close r" value={params.loop_close_radius} min={0.5} max={8} step={0.5}
onChange={v => setParam('loop_close_radius', v)}
hint="Stop when the particle returns within this many px of stroke start." />
<ParamSlider label="Min loop dist" value={params.min_loop_distance} min={5} max={200} step={5}
onChange={v => setParam('min_loop_distance', v)}
hint="Don't let loop-close fire until particle has travelled at least this far." />
<ParamSlider label="Min stroke len" value={params.min_stroke_length} min={0} max={20} step={0.5}
onChange={v => setParam('min_stroke_length', v)}
hint="Drop strokes shorter than this — fringe artifacts from pick_start." />
<ParamSlider label="Max steps/stroke" value={params.max_steps_per_stroke} min={100} max={10000} step={100}
onChange={v => setParam('max_steps_per_stroke', v)}
hint="Safety cap." />
<ParamSlider label="Max strokes" value={params.max_strokes} min={1} max={30} step={1}
onChange={v => setParam('max_strokes', v)}
hint="Safety cap on strokes per hull." />
</div>
<div className="space-y-2 pt-1 border-t border-neutral-800">
<span className="text-neutral-500">Output smoothing</span>
<ParamSlider label="Output RDP" value={params.output_rdp_eps} min={0} max={2} step={0.1}
onChange={v => setParam('output_rdp_eps', v)}
hint="Final stroke RDP epsilon." />
<ParamSlider label="Output Chaikin" value={params.output_chaikin} min={0} max={6} step={1}
onChange={v => setParam('output_chaikin', v)}
hint="Final stroke Chaikin smoothing passes." />
</div>
<div className="pt-2 border-t border-neutral-800 space-y-2">
<div>
<label className="block text-neutral-500 mb-1">
Source opacity: {(sourceOpacity * 100).toFixed(0)}%
</label>
<input type="range" min={0} max={1} step={0.05}
value={sourceOpacity}
onChange={e => setSourceOpacity(parseFloat(e.target.value))}
className="w-full" />
</div>
<div>
<label className="block text-neutral-500 mb-1">
SDF opacity: {(sdfOpacity * 100).toFixed(0)}%
</label>
<input type="range" min={0} max={1} step={0.05}
value={sdfOpacity}
onChange={e => setSdfOpacity(parseFloat(e.target.value))}
className="w-full" />
</div>
</div>
<div className="pt-2 border-t border-neutral-800 text-neutral-500 leading-relaxed">
<p>Zoom: wheel · Pan: drag · Shift+drag: copy region</p>
<button onClick={() => setView({ zoom: 1, panX: 0, panY: 0 })}
className="mt-1 text-xs px-2 py-0.5 bg-neutral-800 rounded">Fit</button>
<div className="mt-2 space-y-0.5">
<div>· {debug.start_points.length} start points</div>
<div>· {debug.trajectories.length} raw trajectories</div>
<div>· {debug.strokes.length} smoothed strokes</div>
<div>· sdf max: {debug.sdf_max?.toFixed(2) ?? '—'} px</div>
</div>
{hover && (
<div className="mt-2 font-mono">
({hover.x.toFixed(2)}, {hover.y.toFixed(2)})
</div>
)}
</div>
</div>
{/* Canvas */}
<div className="flex-1 relative overflow-hidden" onWheel={onWheel} onMouseDown={onMouseDown}>
<svg
ref={svgRef}
width="100%" height="100%"
viewBox={viewBox}
preserveAspectRatio="xMidYMid meet"
onMouseMove={onMouseMoveSvg}
style={{ cursor: 'grab', background: '#0f0f10' }}>
{enabled.source && debug.source_b64 && (
<image
href={debug.source_b64} xlinkHref={debug.source_b64}
x={debug.bounds[0]} y={debug.bounds[1]}
width={debug.bounds[2] - debug.bounds[0] + 1}
height={debug.bounds[3] - debug.bounds[1] + 1}
opacity={sourceOpacity}
style={{ imageRendering: 'pixelated' }}
preserveAspectRatio="none" />
)}
{enabled.sdf && debug.sdf_b64 && (
<image
href={debug.sdf_b64} xlinkHref={debug.sdf_b64}
x={debug.bounds[0]} y={debug.bounds[1]}
width={debug.bounds[2] - debug.bounds[0] + 1}
height={debug.bounds[3] - debug.bounds[1] + 1}
opacity={sdfOpacity}
style={{ imageRendering: 'pixelated' }}
preserveAspectRatio="none" />
)}
{enabled.visited && debug.visited_b64 && (
<image
href={debug.visited_b64} xlinkHref={debug.visited_b64}
x={debug.bounds[0]} y={debug.bounds[1]}
width={debug.bounds[2] - debug.bounds[0] + 1}
height={debug.bounds[3] - debug.bounds[1] + 1}
opacity={0.7}
style={{ imageRendering: 'pixelated' }}
preserveAspectRatio="none" />
)}
{enabled.trajectory && debug.trajectories.map((t, i) => (
<polyline key={`tr${i}`}
points={t.map(p => `${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) => (
<polyline key={`st${i}`}
points={s.map(p => `${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) => (
<g key={`sp${i}`}>
<circle cx={p[0]} cy={p[1]} r={1.5}
fill={strokeHue(i)} stroke="white" strokeWidth={0.4}
vectorEffect="non-scaling-stroke" />
<text x={p[0] + 2} y={p[1] - 2}
fill={strokeHue(i)} fontSize={3}
style={{ paintOrder: 'stroke', stroke: '#000', strokeWidth: 0.5 }}>
{i + 1}
</text>
</g>
))}
{selBox && (
<rect
x={Math.min(selBox.x0, selBox.x1)}
y={Math.min(selBox.y0, selBox.y1)}
width={Math.abs(selBox.x1 - selBox.x0)}
height={Math.abs(selBox.y1 - selBox.y0)}
fill="rgba(99, 102, 241, 0.12)"
stroke="#818cf8" strokeWidth={0.5}
strokeDasharray="2 1"
vectorEffect="non-scaling-stroke" />
)}
</svg>
<div className="absolute bottom-2 left-3 text-[10px] text-neutral-500 pointer-events-none">
Shift+drag to copy region data to clipboard
</div>
{toast && (
<div className="absolute top-3 left-1/2 -translate-x-1/2 px-3 py-1.5 rounded
bg-neutral-800 border border-indigo-500/60 text-xs text-neutral-100 shadow-lg
pointer-events-none">
{toast}
</div>
)}
</div>
</div>
)
}
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 (
<div className="flex items-center justify-between text-[10px] text-red-400">
<span>{label}</span><span></span>
</div>
)
}
const display = Number.isInteger(step) ? value.toString() : value.toFixed(2)
return (
<div title={hint}>
<div className="flex items-center justify-between text-[10px]">
<span className="text-neutral-400">{label}</span>
<span className="text-neutral-300 font-mono">{display}</span>
</div>
<input type="range" min={min} max={max} step={step}
value={value}
onChange={e => onChange(parseFloat(e.target.value))}
className="w-full" />
</div>
)
}

View File

@@ -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() {

View File

@@ -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.52.5 removes junction artifacts; 0 keeps everything.' },
}
// Strategies that use the angle slider

2264
src/brush_paint.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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<Vec<(u32, u32)>> {
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<(u32, u32)>> = 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<K, (f32, f32)> = HashMap::new();
let mut adj: HashMap<K, Vec<K>> = 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<K, Vec<K>>,
node_pos: &HashMap<K, (f32, f32)>| -> 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<K> = 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<K> = 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<K> = 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<Vec<(f32, f32)>> {
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.52.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<Vec<(f32, f32)>> = 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<Point2<f64>> =
ConstrainedDelaunayTriangulation::new();
let insert_loop = |cdt: &mut ConstrainedDelaunayTriangulation<Point2<f64>>,
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<Vec<(f32, f32)>> = 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<Vec<(f32, f32)>>,
pub hole_pixels: Vec<Vec<(u32, u32)>>,
pub triangles: Vec<DebugTriangle>,
pub segments: Vec<((f32, f32), (f32, f32))>,
pub polylines: Vec<DebugPolyline>,
pub strokes: Vec<Vec<(f32, f32)>>, // 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<Vec<(f32, f32)>> = 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<Point2<f64>> =
ConstrainedDelaunayTriangulation::new();
let insert_loop = |cdt: &mut ConstrainedDelaunayTriangulation<Point2<f64>>,
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<DebugTriangle> = 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<DebugPolyline> = 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<spade::Point2<f64>>,
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(&current, 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<Hull> 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<crate::hulls::Hull> {
use crate::text::{TextBlockSpec, rasterize_blocks};
use crate::hulls::{extract_hulls, HullParams, Connectivity};
let specs: Vec<TextBlockSpec> = 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<u8> = 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, &params)
}
#[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<bool> {
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<u8> = 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<bool> = 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, &params);
let mut all_strokes: Vec<Vec<(f32, f32)>> = 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.

View File

@@ -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<Mutex<AppState>>) -> Result<Vec<Hull
}
#[tauri::command]
fn get_chordal_debug(
pass_idx: usize, hull_idx: usize, salience: f32,
fn get_streamline_debug(
pass_idx: usize, hull_idx: usize, params: streamline::StreamlineParams,
state: State<Mutex<AppState>>,
) -> Result<fill::ChordalDebug, String> {
) -> Result<streamline::StreamlineDebug, String> {
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, &params))
}
#[tauri::command]
fn get_paint_debug(
pass_idx: usize, hull_idx: usize, params: brush_paint::PaintParams,
state: State<Mutex<AppState>>,
) -> Result<brush_paint::PaintDebug, String> {
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, &params))
}
#[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,

1141
src/streamline.rs Normal file

File diff suppressed because it is too large Load Diff

568
src/topo_strokes.rs Normal file
View File

@@ -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<GraphEdge>,
/// adj[node_idx] = vec of edge indices incident to that node.
pub adj: Vec<Vec<usize>>,
}
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::<f32>() / n;
let cy = cluster.iter().map(|p| p.1 as f32).sum::<f32>() / 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<GraphEdge> = 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<usize> = 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<usize>> = 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<Vec<usize>> {
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<usize>> = Vec::new();
for component in components {
// Local mutable adjacency (so we can consume edges without
// touching other components).
let mut adj: Vec<Vec<usize>> = 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<usize> = 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<usize> = 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<Vec<usize>> {
let mut seen = vec![false; graph.nodes.len()];
let mut components: Vec<Vec<usize>> = 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<usize> = Vec::new();
let mut q: Vec<usize> = 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<usize>>) -> Vec<usize>
{
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<usize> = vec![start];
// Edge that brought us to each node (parallel to node_stack, with first
// entry being a sentinel).
let mut arrival_edge: Vec<Option<usize>> = vec![None];
let mut trail: Vec<usize> = 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<Vec<(f32, f32)>> = 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<dyn Iterator<Item = (f32, f32)>>, 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<crate::hulls::Hull>
{
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<u8> = 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, &params)
}
#[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);
}
}
}