add
This commit is contained in:
@@ -5,7 +5,8 @@ import TuningPanel from './components/TuningPanel.jsx'
|
|||||||
import CalibrationButtons from './components/CalibrationButtons.jsx'
|
import CalibrationButtons from './components/CalibrationButtons.jsx'
|
||||||
import CalibrationAxis from './components/CalibrationAxis.jsx'
|
import CalibrationAxis from './components/CalibrationAxis.jsx'
|
||||||
import TextEditOverlay from './components/TextEditOverlay.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 NodeGraph from './components/NodeGraph.jsx'
|
||||||
import PassPanel from './components/PassPanel.jsx'
|
import PassPanel from './components/PassPanel.jsx'
|
||||||
import PerfPanel from './components/PerfPanel.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 { serialize, deserialize } from './project.js'
|
||||||
import { useFps } from './hooks/useFps.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() {
|
export default function App() {
|
||||||
const [image, setImage] = useState(null)
|
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 */}
|
{/* 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">
|
<div className="flex items-center gap-1 px-3 py-2 border-b border-neutral-800 bg-neutral-900/80">
|
||||||
{VIEW_MODES.map(m => {
|
{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)
|
const label = m === 'gcode' ? 'G-code' : m.charAt(0).toUpperCase() + m.slice(1)
|
||||||
return (
|
return (
|
||||||
<button key={m}
|
<button key={m}
|
||||||
@@ -615,8 +616,10 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
) : viewMode === 'tuning' ? (
|
) : viewMode === 'tuning' ? (
|
||||||
<TuningPanel printerUrl={gcodeConfig.printer_url ?? ''} />
|
<TuningPanel printerUrl={gcodeConfig.printer_url ?? ''} />
|
||||||
) : viewMode === 'chordal' ? (
|
) : viewMode === 'streamline' ? (
|
||||||
<ChordalDebugView passIdx={0} />
|
<StreamlineDebugView passIdx={0} />
|
||||||
|
) : viewMode === 'paint' ? (
|
||||||
|
<PaintDebugView passIdx={0} />
|
||||||
) : viewMode === 'source' && sourceMode === 'text' ? (
|
) : viewMode === 'source' && sourceMode === 'text' ? (
|
||||||
<TextEditOverlay
|
<TextEditOverlay
|
||||||
paperWMm={gcodeConfig.paper_w_mm}
|
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 })
|
return tracedInvoke('list_hulls', { passIdx })
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getChordalDebug(passIdx, hullIdx, salience = 0) {
|
// Default StreamlineParams must match Rust's `impl Default for StreamlineParams`.
|
||||||
return tracedInvoke('get_chordal_debug', { passIdx, hullIdx, salience })
|
// 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() {
|
export async function getAllStrokes() {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
export const KERNELS = ['Luminance','Sobel','ColorGradient','Laplacian','Canny','Saturation','XDoG','ColorIsolate']
|
export const KERNELS = ['Luminance','Sobel','ColorGradient','Laplacian','Canny','Saturation','XDoG','ColorIsolate']
|
||||||
export const BLEND_MODES = ['Average','Min','Max','Multiply','Screen','Difference']
|
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.
|
// Per-strategy secondary parameter exposed as a slider.
|
||||||
// Strategies not listed here have no secondary parameter.
|
// 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' },
|
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,
|
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' },
|
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
|
// 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
|
/// Chamfer 3-4 distance transform: cheaper than full Euclidean, but the
|
||||||
/// 3:4 weights closely approximate (1:√2), so contours are near-circular
|
/// 3:4 weights closely approximate (1:√2), so contours are near-circular
|
||||||
/// instead of L-shaped. Returns scaled distances (units of 1/3 pixel).
|
/// 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(); }
|
if hull.pixels.is_empty() { return HashMap::new(); }
|
||||||
let inf = i32::MAX / 4;
|
let inf = i32::MAX / 4;
|
||||||
let mut bx = u32::MAX;
|
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 }
|
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 {
|
pub fn skeleton_fill(hull: &Hull, _spacing_px: f32) -> FillResult {
|
||||||
if hull.pixels.is_empty() {
|
if hull.pixels.is_empty() {
|
||||||
return FillResult { hull_id: hull.id, strokes: vec![] };
|
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.
|
/// Iteratively remove dead-end branches up to `max_spur_len` pixels long.
|
||||||
/// Pruning a spur can turn its parent junction into an endpoint, exposing
|
/// Pruning a spur can turn its parent junction into an endpoint, exposing
|
||||||
/// further removable spurs — so we loop until no further removals.
|
/// 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)> {
|
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()
|
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:
|
/// Zhang-Suen 8-neighbor positions in clockwise order starting from north:
|
||||||
/// index 0..7 == P2, P3, P4, P5, P6, P7, P8, P9.
|
/// 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.
|
/// 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, y.wrapping_sub(1)),
|
||||||
(x + 1, 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
|
/// Run Zhang-Suen thinning until idempotent. Two sub-iterations per round
|
||||||
/// with mirrored conditions keep erosion symmetric.
|
/// 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();
|
let mut current: HashSet<(u32, u32)> = pixels.iter().copied().collect();
|
||||||
loop {
|
loop {
|
||||||
let to_remove1 = zs_mark(¤t, true);
|
let to_remove1 = zs_mark(¤t, true);
|
||||||
@@ -2907,276 +2276,6 @@ mod tests {
|
|||||||
span, span / glyph_h * 100.0, glyph_h, r.strokes.len());
|
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]
|
#[test]
|
||||||
fn centerline_letter_O_is_one_closed_stroke() {
|
fn centerline_letter_O_is_one_closed_stroke() {
|
||||||
// Sanity: the algorithm must still handle simple shapes well.
|
// 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 fill;
|
||||||
pub mod gcode;
|
pub mod gcode;
|
||||||
pub mod text;
|
pub mod text;
|
||||||
|
pub mod streamline;
|
||||||
|
pub mod topo_strokes;
|
||||||
|
pub mod brush_paint;
|
||||||
|
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
@@ -821,7 +824,9 @@ fn process_pass_work(
|
|||||||
"hilbert" => fill::hilbert_fill(hull, spacing),
|
"hilbert" => fill::hilbert_fill(hull, spacing),
|
||||||
"skeleton" => fill::skeleton_fill(hull, spacing),
|
"skeleton" => fill::skeleton_fill(hull, spacing),
|
||||||
"centerline" => fill::centerline_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),
|
"waves" => fill::wave_interference(hull, spacing, param.round().max(1.0) as usize),
|
||||||
"flow" => fill::flow_field(hull, spacing, angle, param.max(0.0)),
|
"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)),
|
"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),
|
"hilbert" => fill::hilbert_fill(hull, spacing),
|
||||||
"skeleton" => fill::skeleton_fill(hull, spacing),
|
"skeleton" => fill::skeleton_fill(hull, spacing),
|
||||||
"centerline" => fill::centerline_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),
|
"waves" => fill::wave_interference(hull, spacing, param.round().max(1.0) as usize),
|
||||||
"flow" => fill::flow_field(hull, spacing, angle, param.max(0.0)),
|
"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)),
|
"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]
|
#[tauri::command]
|
||||||
fn get_chordal_debug(
|
fn get_streamline_debug(
|
||||||
pass_idx: usize, hull_idx: usize, salience: f32,
|
pass_idx: usize, hull_idx: usize, params: streamline::StreamlineParams,
|
||||||
state: State<Mutex<AppState>>,
|
state: State<Mutex<AppState>>,
|
||||||
) -> Result<fill::ChordalDebug, String> {
|
) -> Result<streamline::StreamlineDebug, String> {
|
||||||
let st = state.lock().unwrap();
|
let st = state.lock().unwrap();
|
||||||
let ps = st.passes.get(pass_idx)
|
let ps = st.passes.get(pass_idx)
|
||||||
.ok_or_else(|| format!("pass {pass_idx} out of range"))?;
|
.ok_or_else(|| format!("pass {pass_idx} out of range"))?;
|
||||||
let h = ps.hulls.get(hull_idx)
|
let h = ps.hulls.get(hull_idx)
|
||||||
.ok_or_else(|| format!("hull {hull_idx} out of range (pass has {})", ps.hulls.len()))?;
|
.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]
|
#[tauri::command]
|
||||||
@@ -2825,7 +2845,8 @@ pub fn run() {
|
|||||||
get_images_dir,
|
get_images_dir,
|
||||||
set_pass_count,
|
set_pass_count,
|
||||||
list_hulls,
|
list_hulls,
|
||||||
get_chordal_debug,
|
get_streamline_debug,
|
||||||
|
get_paint_debug,
|
||||||
process_pass,
|
process_pass,
|
||||||
get_all_strokes,
|
get_all_strokes,
|
||||||
get_gcode_viz,
|
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