add
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
496
src-frontend/src/components/PaintDebugView.jsx
Normal file
496
src-frontend/src/components/PaintDebugView.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
471
src-frontend/src/components/StreamlineDebugView.jsx
Normal file
471
src-frontend/src/components/StreamlineDebugView.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
export const KERNELS = ['Luminance','Sobel','ColorGradient','Laplacian','Canny','Saturation','XDoG','ColorIsolate']
|
||||
export const BLEND_MODES = ['Average','Min','Max','Multiply','Screen','Difference']
|
||||
|
||||
export const FILL_STRATEGIES = ['hatch','zigzag','offset','spiral','outline','circles','voronoi','hilbert','waves','flow','gradient_hatch','gradient_cross_hatch','skeleton','centerline','chordal']
|
||||
export const FILL_STRATEGIES = ['hatch','zigzag','offset','spiral','outline','circles','voronoi','hilbert','waves','flow','gradient_hatch','gradient_cross_hatch','skeleton','centerline','streamline','topo','paint']
|
||||
|
||||
// Per-strategy secondary parameter exposed as a slider.
|
||||
// Strategies not listed here have no secondary parameter.
|
||||
@@ -19,8 +19,6 @@ export const FILL_STRATEGY_PARAMS = {
|
||||
hint: '1.0 = uniform · 0.05 = 20× denser at darkest ink' },
|
||||
gradient_cross_hatch: { label: 'Min Scale', min: 0.05, max: 1.0, step: 0.05, default: 0.25,
|
||||
hint: '1.0 = uniform · 0.05 = 20× denser at darkest ink' },
|
||||
chordal: { label: 'Prune', min: 0, max: 4, step: 0.1, default: 0,
|
||||
hint: 'Drop centerline tails shorter than N× local stroke width. Scale-invariant. 1.5–2.5 removes junction artifacts; 0 keeps everything.' },
|
||||
}
|
||||
|
||||
// Strategies that use the angle slider
|
||||
|
||||
2264
src/brush_paint.rs
Normal file
2264
src/brush_paint.rs
Normal file
File diff suppressed because it is too large
Load Diff
909
src/fill.rs
909
src/fill.rs
@@ -536,7 +536,7 @@ pub fn spiral(hull: &Hull, spacing_px: f32) -> FillResult {
|
||||
/// Chamfer 3-4 distance transform: cheaper than full Euclidean, but the
|
||||
/// 3:4 weights closely approximate (1:√2), so contours are near-circular
|
||||
/// instead of L-shaped. Returns scaled distances (units of 1/3 pixel).
|
||||
fn chamfer_distance(hull: &Hull, pixel_set: &HashSet<(u32, u32)>) -> HashMap<(u32, u32), f32> {
|
||||
pub(crate) fn chamfer_distance(hull: &Hull, pixel_set: &HashSet<(u32, u32)>) -> HashMap<(u32, u32), f32> {
|
||||
if hull.pixels.is_empty() { return HashMap::new(); }
|
||||
let inf = i32::MAX / 4;
|
||||
let mut bx = u32::MAX;
|
||||
@@ -924,637 +924,6 @@ pub fn centerline_fill(hull: &Hull, _spacing_px: f32) -> FillResult {
|
||||
FillResult { hull_id: hull.id, strokes }
|
||||
}
|
||||
|
||||
// ── Chordal axis transform (polygon-domain medial axis) ───────────────────────
|
||||
//
|
||||
// Operates on the hull's contour polygon (with holes), not pixel skeletons.
|
||||
// 1. Detect interior holes (background components inside hull bbox).
|
||||
// 2. Trace + RDP each hole's contour → polygon list.
|
||||
// 3. Constrained Delaunay triangulation (spade) using outer + holes as constraints.
|
||||
// 4. Classify each interior triangle by # of constraint edges (Prasad's CAT):
|
||||
// - 3 constrained (P): pure — degenerate, skip.
|
||||
// - 2 constrained (T): termination — emit segment from the chord midpoint
|
||||
// to the apex vertex (the corner where the two boundary edges meet).
|
||||
// - 1 constrained (S): sleeve — emit segment between the two chord midpoints.
|
||||
// - 0 constrained (J): junction — emit 3 segments from the triangle's
|
||||
// barycenter to each chord midpoint.
|
||||
// 5. Walk the resulting segment graph into polylines, then Chaikin-smooth.
|
||||
|
||||
/// Find background components fully enclosed within the hull's bbox. Each is
|
||||
/// a hole inside the glyph (e.g. the inside of an `O`). Uses 8-connectivity for
|
||||
/// background flood so it doesn't leak through 4-connected diagonal touches.
|
||||
fn detect_holes(hull: &Hull) -> Vec<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.5–2.5 typically removes junction artifacts on letters like X/K/N
|
||||
/// without affecting real tails on a/g/9.
|
||||
pub fn chordal_axis_fill(hull: &Hull, salience: f32) -> FillResult {
|
||||
if hull.pixels.is_empty() || hull.simplified.len() < 3 {
|
||||
return FillResult { hull_id: hull.id, strokes: vec![] };
|
||||
}
|
||||
|
||||
// Outer polygon: already RDP-simplified during hull extraction.
|
||||
let outer: Vec<(f32, f32)> = hull.simplified.clone();
|
||||
let pixel_set: HashSet<(u32, u32)> = hull.pixels.iter().copied().collect();
|
||||
|
||||
// Holes: background-pixel components inside the bbox not touching the edge.
|
||||
let hole_pixel_groups = detect_holes(hull);
|
||||
let hole_polys: Vec<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(¤t, true);
|
||||
@@ -2907,276 +2276,6 @@ mod tests {
|
||||
span, span / glyph_h * 100.0, glyph_h, r.strokes.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chordal_detect_holes_finds_O_interior() {
|
||||
// Letter 'O' has a single enclosed interior; non-hole letters do not.
|
||||
let hulls = rasterize_text_to_hulls("O", 8.0, 150, 3);
|
||||
assert_eq!(hulls.len(), 1, "'O' should produce exactly 1 hull");
|
||||
let holes = detect_holes(&hulls[0]);
|
||||
assert_eq!(holes.len(), 1,
|
||||
"'O' should have 1 interior hole, got {}", holes.len());
|
||||
|
||||
let hulls_c = rasterize_text_to_hulls("C", 8.0, 150, 3);
|
||||
let holes_c = detect_holes(&hulls_c[0]);
|
||||
assert_eq!(holes_c.len(), 0,
|
||||
"'C' should have 0 holes, got {}", holes_c.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chordal_letter_O_produces_strokes() {
|
||||
// Without hole-aware triangulation, an 'O' would either fill
|
||||
// its interior or fail entirely. With hole support we expect a
|
||||
// closed stroke ring around the centerline of the ring.
|
||||
let hulls = rasterize_text_to_hulls("O", 8.0, 150, 3);
|
||||
let r = chordal_axis_fill(&hulls[0], 1.0);
|
||||
assert!(!r.strokes.is_empty(),
|
||||
"expected at least 1 stroke for 'O', got 0");
|
||||
let total_pts: usize = r.strokes.iter().map(|s| s.len()).sum();
|
||||
assert!(total_pts >= 8,
|
||||
"'O' chordal output too small: {} total points across {} strokes",
|
||||
total_pts, r.strokes.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chordal_letter_I_is_one_stroke() {
|
||||
// 'I' is a simple bar (no holes, no junctions). Should give one line.
|
||||
let hulls = rasterize_text_to_hulls("I", 8.0, 150, 3);
|
||||
let r = chordal_axis_fill(&hulls[0], 1.0);
|
||||
assert_eq!(r.strokes.len(), 1,
|
||||
"expected 1 stroke for 'I', got {}", r.strokes.len());
|
||||
}
|
||||
|
||||
/// Render a multi-block text payload to a Vec<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, ¶ms)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chordal_does_not_panic_on_saved_texttest_input() {
|
||||
// Reproduces the user's saved 'texttest' project that hit
|
||||
// "The new constraint edge intersects an existing constraint edge."
|
||||
// when running chordal on multi-line addresses at 3mm and 5mm.
|
||||
// After the fix to use try_add_constraint + catch_unwind, this must
|
||||
// run cleanly (every hull may produce 0 or many strokes — we don't
|
||||
// care here, only that nothing panics).
|
||||
let blocks = [
|
||||
("Your Name\n123 Your St\nYour City, ST 12345", 3.0, 6.83, 6.36),
|
||||
("Recipient Name\n456 Their St\nTheir City, ST 67890", 5.0, 74.67, 48.05),
|
||||
];
|
||||
let hulls = rasterize_text_blocks_to_hulls(&blocks, 241.3, 104.775, 150, 3);
|
||||
assert!(!hulls.is_empty(), "no hulls extracted from the text payload");
|
||||
for h in &hulls {
|
||||
// Must complete without panicking. We don't assert on stroke count
|
||||
// because some glyphs may legitimately produce nothing under
|
||||
// try_add_constraint failures — the contract is "no panic".
|
||||
let _ = chordal_axis_fill(h, 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chordal_no_panic_for_any_printable_ascii() {
|
||||
// Every printable ASCII glyph at small + medium font sizes must
|
||||
// process without panic, no matter how the contour or holes come out.
|
||||
let chars: String = (0x20u8..=0x7E).map(|b| b as char).collect();
|
||||
for size in [3.0_f32, 5.0, 8.0] {
|
||||
let hulls = rasterize_text_to_hulls(&chars, size, 200, 3);
|
||||
for h in &hulls {
|
||||
let _ = chordal_axis_fill(h, 1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chordal_alphanumerics_produce_strokes_with_y_coverage() {
|
||||
// Sweep similar to the centerline one: every letter/digit should
|
||||
// produce at least one stroke, and the strokes (across all hulls of
|
||||
// that glyph) should span ≥ 70% of the largest hull's height — a
|
||||
// crude but effective "didn't lose the body of the letter" check.
|
||||
let chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
let mut bad: Vec<(char, String)> = Vec::new();
|
||||
let mut report = String::new();
|
||||
for ch in chars.chars() {
|
||||
let hulls = rasterize_text_to_hulls(&ch.to_string(), 8.0, 200, 4);
|
||||
// Pick the largest hull (skips 'i' / 'j' dot specks).
|
||||
let main = hulls.iter().max_by_key(|h| h.area);
|
||||
let (count, cov) = match main {
|
||||
None => { bad.push((ch, "no hulls".into())); (0, 0.0) }
|
||||
Some(h) => {
|
||||
let r = chordal_axis_fill(h, 1.0);
|
||||
let glyph_h = (h.bounds.y_max - h.bounds.y_min) as f32;
|
||||
if r.strokes.is_empty() {
|
||||
bad.push((ch, "no strokes".into()));
|
||||
(0, 0.0)
|
||||
} else {
|
||||
let (lo, hi) = stroke_y_range(&r.strokes);
|
||||
let cov = if glyph_h > 0.0 { (hi - lo) / glyph_h } else { 1.0 };
|
||||
if cov < 0.70 {
|
||||
bad.push((ch, format!("only {:.0}% Y coverage", cov * 100.0)));
|
||||
}
|
||||
(r.strokes.len(), cov)
|
||||
}
|
||||
}
|
||||
};
|
||||
report.push_str(&format!("'{}': {} stroke(s), Y-cov {:.2}\n", ch, count, cov));
|
||||
}
|
||||
if !bad.is_empty() {
|
||||
panic!("Chordal letters with issues: {:?}\n{}", bad, report);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chordal_letters_with_holes_produce_at_least_one_closed_stroke() {
|
||||
// Holes mean the medial axis is a closed ring through the body of
|
||||
// the letter. Our walk turns rings into a stroke whose first and
|
||||
// last point are the same (or very close). At minimum, every
|
||||
// letter with N holes should produce ≥ N strokes.
|
||||
let cases: &[(&str, usize)] = &[
|
||||
("O", 1), ("D", 1), ("P", 1), ("Q", 1), ("R", 1),
|
||||
("0", 1), ("4", 1), ("6", 1), ("9", 1),
|
||||
("B", 2), ("8", 2),
|
||||
];
|
||||
for &(ch, expected_holes) in cases {
|
||||
let hulls = rasterize_text_to_hulls(ch, 10.0, 200, 4);
|
||||
let main = hulls.iter().max_by_key(|h| h.area).expect("no hull");
|
||||
let detected = detect_holes(main).len();
|
||||
assert_eq!(detected, expected_holes,
|
||||
"'{}' expected {} holes, detected {}", ch, expected_holes, detected);
|
||||
let r = chordal_axis_fill(main, 1.0);
|
||||
assert!(r.strokes.len() >= expected_holes,
|
||||
"'{}' expected ≥ {} strokes for {} ring(s), got {}",
|
||||
ch, expected_holes, expected_holes, r.strokes.len());
|
||||
}
|
||||
}
|
||||
|
||||
/// Stamp a filled disc into a boolean grid.
|
||||
fn stamp_disc(grid: &mut [bool], w: u32, h: u32, x: f32, y: f32, radius: i32) {
|
||||
let cx = x.round() as i32;
|
||||
let cy = y.round() as i32;
|
||||
let r2 = radius * radius;
|
||||
for dy in -radius..=radius {
|
||||
for dx in -radius..=radius {
|
||||
if dx * dx + dy * dy > r2 { continue; }
|
||||
let px = cx + dx;
|
||||
let py = cy + dy;
|
||||
if px < 0 || py < 0 || px >= w as i32 || py >= h as i32 { continue; }
|
||||
grid[(py as u32 * w + px as u32) as usize] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render strokes to a boolean raster with given pen radius, by stamping
|
||||
/// densely along each line segment. Uses Euclidean step size of 0.5 px
|
||||
/// so adjacent stamps overlap; output is a continuous "drawn" region.
|
||||
fn rasterize_strokes_thick(
|
||||
strokes: &[Vec<(f32, f32)>], w: u32, h: u32, radius: i32,
|
||||
) -> Vec<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, ¶ms);
|
||||
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.
|
||||
|
||||
35
src/lib.rs
35
src/lib.rs
@@ -3,6 +3,9 @@ pub mod hulls;
|
||||
pub mod fill;
|
||||
pub mod gcode;
|
||||
pub mod text;
|
||||
pub mod streamline;
|
||||
pub mod topo_strokes;
|
||||
pub mod brush_paint;
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
@@ -821,7 +824,9 @@ fn process_pass_work(
|
||||
"hilbert" => fill::hilbert_fill(hull, spacing),
|
||||
"skeleton" => fill::skeleton_fill(hull, spacing),
|
||||
"centerline" => fill::centerline_fill(hull, spacing),
|
||||
"chordal" => fill::chordal_axis_fill(hull, param.max(0.0)),
|
||||
"streamline" => streamline::streamline_fill(hull, param.max(0.0)),
|
||||
"topo" => topo_strokes::topo_fill(hull, param.max(0.0)),
|
||||
"paint" => brush_paint::paint_fill(hull, param.max(0.0)),
|
||||
"waves" => fill::wave_interference(hull, spacing, param.round().max(1.0) as usize),
|
||||
"flow" => fill::flow_field(hull, spacing, angle, param.max(0.0)),
|
||||
"gradient_hatch" => fill::gradient_hatch(hull, &response_arc, img_w, spacing, angle, param.clamp(0.05, 1.0)),
|
||||
@@ -852,7 +857,9 @@ fn process_pass_work(
|
||||
"hilbert" => fill::hilbert_fill(hull, spacing),
|
||||
"skeleton" => fill::skeleton_fill(hull, spacing),
|
||||
"centerline" => fill::centerline_fill(hull, spacing),
|
||||
"chordal" => fill::chordal_axis_fill(hull, param.max(0.0)),
|
||||
"streamline" => streamline::streamline_fill(hull, param.max(0.0)),
|
||||
"topo" => topo_strokes::topo_fill(hull, param.max(0.0)),
|
||||
"paint" => brush_paint::paint_fill(hull, param.max(0.0)),
|
||||
"waves" => fill::wave_interference(hull, spacing, param.round().max(1.0) as usize),
|
||||
"flow" => fill::flow_field(hull, spacing, angle, param.max(0.0)),
|
||||
"gradient_hatch" => fill::gradient_hatch(hull, &response_arc, img_w, spacing, angle, param.clamp(0.05, 1.0)),
|
||||
@@ -994,16 +1001,29 @@ fn list_hulls(pass_idx: usize, state: State<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, ¶ms))
|
||||
}
|
||||
|
||||
#[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, ¶ms))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -2825,7 +2845,8 @@ pub fn run() {
|
||||
get_images_dir,
|
||||
set_pass_count,
|
||||
list_hulls,
|
||||
get_chordal_debug,
|
||||
get_streamline_debug,
|
||||
get_paint_debug,
|
||||
process_pass,
|
||||
get_all_strokes,
|
||||
get_gcode_viz,
|
||||
|
||||
1141
src/streamline.rs
Normal file
1141
src/streamline.rs
Normal file
File diff suppressed because it is too large
Load Diff
568
src/topo_strokes.rs
Normal file
568
src/topo_strokes.rs
Normal 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, ¶ms)
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn topo_alphabet_report() {
|
||||
let chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
let p = TopoParams::default();
|
||||
for &(font_mm, dpi, thick) in &[(3.0_f32, 150_u32, 3_u32), (5.0, 200, 4), (8.0, 200, 4)] {
|
||||
println!("\n══ font={}mm, dpi={}, thickness={}px ══", font_mm, dpi, thick);
|
||||
let mut total = 0;
|
||||
let mut bad: Vec<(char, usize)> = Vec::new();
|
||||
for ch in chars.chars() {
|
||||
let hulls = rasterize_letter_at(ch, font_mm, dpi, thick);
|
||||
let main = match hulls.iter().max_by_key(|h| h.area) {
|
||||
Some(h) => h, None => continue
|
||||
};
|
||||
let r = topo_fill_with(main, &p);
|
||||
let n = r.strokes.len();
|
||||
total += n;
|
||||
if n > 4 { bad.push((ch, n)); }
|
||||
println!("'{}': {} strokes", ch, n);
|
||||
}
|
||||
println!("Total: {} / 62 chars (avg {:.2})", total, total as f32 / 62.0);
|
||||
println!("Over-4-strokes: {:?}", bad);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn topo_letter_I_is_one_stroke() {
|
||||
let hulls = rasterize_letter_at('I', 8.0, 200, 4);
|
||||
let main = hulls.iter().max_by_key(|h| h.area).unwrap();
|
||||
let r = topo_fill(main, 0.0);
|
||||
assert_eq!(r.strokes.len(), 1, "expected 1 stroke for 'I', got {}", r.strokes.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn topo_letter_O_is_one_stroke() {
|
||||
let hulls = rasterize_letter_at('O', 8.0, 200, 4);
|
||||
let main = hulls.iter().max_by_key(|h| h.area).unwrap();
|
||||
let r = topo_fill(main, 0.0);
|
||||
assert_eq!(r.strokes.len(), 1, "expected 1 stroke for 'O' (closed loop), got {}",
|
||||
r.strokes.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn topo_no_panic_for_any_printable_ascii() {
|
||||
for b in 0x20u8..=0x7E {
|
||||
let ch = b as char;
|
||||
for h in rasterize_letter_at(ch, 8.0, 200, 4) {
|
||||
let _ = topo_fill(&h, 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn topo_alphabet_max_5_strokes() {
|
||||
// Strict bound: every alphanumeric should decompose to ≤5 strokes
|
||||
// at typical font sizes. If something exceeds this, the user will
|
||||
// see a fragmented glyph.
|
||||
let chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
let p = TopoParams::default();
|
||||
let mut bad: Vec<(char, usize, f32, u32)> = Vec::new();
|
||||
for &(font_mm, dpi, thick) in &[(3.0_f32, 150_u32, 3_u32), (5.0, 200, 4), (8.0, 200, 4)] {
|
||||
for ch in chars.chars() {
|
||||
let hulls = rasterize_letter_at(ch, font_mm, dpi, thick);
|
||||
let main = match hulls.iter().max_by_key(|h| h.area) {
|
||||
Some(h) => h, None => continue
|
||||
};
|
||||
let r = topo_fill_with(main, &p);
|
||||
if r.strokes.len() > 5 {
|
||||
bad.push((ch, r.strokes.len(), font_mm, dpi));
|
||||
}
|
||||
}
|
||||
}
|
||||
if !bad.is_empty() {
|
||||
panic!("Glyphs over the 5-stroke bound: {:?}", bad);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user