remove streamline fill strategy — abandoned approach

Drops src/streamline.rs (49KB), StreamlineDebugView.jsx (20KB), the
"streamline" arm in the fill dispatch, the get_streamline_debug
Tauri command, the streamline view mode, and DEFAULT_STREAMLINE_PARAMS.

Two small debug-image helpers (encode_hull_pixels_b64, encode_sdf_b64,
colormap_viridis) moved into brush_paint.rs since paint_fill_debug
was the only remaining caller.
This commit is contained in:
Mitchell Hansen
2026-05-05 22:33:18 -07:00
parent 50c8be46ee
commit e53d73d5cb
7 changed files with 76 additions and 1663 deletions

View File

@@ -5,7 +5,6 @@ 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 StreamlineDebugView from './components/StreamlineDebugView.jsx'
import PaintDebugView from './components/PaintDebugView.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'
@@ -16,7 +15,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', 'streamline', 'paint', 'printer', 'tuning'] const VIEW_MODES = ['source', 'detection', 'contours', 'gcode', 'paint', 'printer', 'tuning']
export default function App() { export default function App() {
const [image, setImage] = useState(null) const [image, setImage] = useState(null)
@@ -567,7 +566,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', streamline: '#ec4899', paint: '#22d3ee', printer: '#10b981', tuning: '#a855f7' }[m] const accent = { detection: '#6366f1', contours: '#14b8a6', gcode: '#f59e0b', 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}
@@ -616,8 +615,6 @@ export default function App() {
/> />
) : viewMode === 'tuning' ? ( ) : viewMode === 'tuning' ? (
<TuningPanel printerUrl={gcodeConfig.printer_url ?? ''} /> <TuningPanel printerUrl={gcodeConfig.printer_url ?? ''} />
) : viewMode === 'streamline' ? (
<StreamlineDebugView passIdx={0} />
) : viewMode === 'paint' ? ( ) : viewMode === 'paint' ? (
<PaintDebugView passIdx={0} /> <PaintDebugView passIdx={0} />
) : viewMode === 'source' && sourceMode === 'text' ? ( ) : viewMode === 'source' && sourceMode === 'text' ? (

View File

@@ -1,471 +0,0 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import * as tauri from '../hooks/useTauri.js'
import { DEFAULT_STREAMLINE_PARAMS } from '../hooks/useTauri.js'
// macOS trackpads emit lots of small wheel events per gesture. Apply a
// gentler per-event factor on Darwin.
const IS_DARWIN = typeof navigator !== 'undefined' &&
/Mac|iPhone|iPad|iPod/i.test(navigator.platform || navigator.userAgent || '')
const ZOOM_SENSITIVITY = IS_DARWIN ? 0.0015 : 0.015
const LAYERS = [
{ key: 'source', label: '0. Source pixels', on: true },
{ key: 'sdf', label: '1. SDF heatmap', on: false },
{ key: 'visited', label: '2. Visited mask', on: false },
{ key: 'starts', label: '3. Start points', on: true },
{ key: 'trajectory',label: '4. Raw trajectories', on: true },
{ key: 'strokes', label: '5. Smoothed strokes', on: true },
]
// Per-stroke colour cycle for clarity (golden-ratio hue rotation).
const strokeHue = (i) => `hsl(${((i * 137.508) % 360).toFixed(1)}, 80%, 55%)`
export default function StreamlineDebugView({ passIdx = 0 }) {
const [hulls, setHulls] = useState([])
const [hullIdx, setHullIdx] = useState(0)
const [params, setParams] = useState({ ...DEFAULT_STREAMLINE_PARAMS })
const setParam = (k, v) => setParams(p => ({ ...p, [k]: v }))
const [sourceOpacity, setSourceOpacity] = useState(0.4)
const [sdfOpacity, setSdfOpacity] = useState(0.7)
const [debug, setDebug] = useState(null)
const [enabled, setEnabled] = useState(
Object.fromEntries(LAYERS.map(l => [l.key, l.on])),
)
const [view, setView] = useState({ zoom: 1, panX: 0, panY: 0 })
const containerRef = useRef(null)
const svgRef = useRef(null)
const dragRef = useRef(null)
const [hover, setHover] = useState(null)
const [selBox, setSelBox] = useState(null)
const [toast, setToast] = useState(null)
useEffect(() => {
let alive = true
tauri.listHulls(passIdx).then(list => {
if (!alive) return
const sorted = [...list].sort((a, b) => b.area - a.area)
setHulls(sorted)
if (sorted.length > 0) setHullIdx(sorted[0].index)
}).catch(() => {})
return () => { alive = false }
}, [passIdx])
useEffect(() => {
if (hulls.length === 0) return
let alive = true
tauri.getStreamlineDebug(passIdx, hullIdx, params).then(d => {
if (!alive) return
setDebug(d)
}).catch(() => {})
return () => { alive = false }
}, [passIdx, hullIdx, params, hulls.length])
useEffect(() => {
setView({ zoom: 1, panX: 0, panY: 0 })
}, [hullIdx])
const viewBox = useMemo(() => {
if (!debug) return '0 0 100 100'
const [x0, y0, x1, y1] = debug.bounds
const pad = Math.max(2, (x1 - x0) * 0.04)
const w = (x1 - x0) + 2 * pad
const h = (y1 - y0) + 2 * pad
return `${x0 - pad - view.panX} ${y0 - pad - view.panY} ${w / view.zoom} ${h / view.zoom}`
}, [debug, view])
const onWheel = (e) => {
e.preventDefault()
if (!debug || !svgRef.current) return
const factor = Math.exp(-e.deltaY * ZOOM_SENSITIVITY)
const rect = svgRef.current.getBoundingClientRect()
const u = (e.clientX - rect.left) / rect.width
const vN = (e.clientY - rect.top) / rect.height
const [x0, y0, x1, y1] = debug.bounds
const pad = Math.max(2, (x1 - x0) * 0.04)
const wBase = (x1 - x0) + 2 * pad
const hBase = (y1 - y0) + 2 * pad
setView(v => {
const newZoom = Math.max(0.1, Math.min(200, v.zoom * factor))
if (newZoom === v.zoom) return v
const dW = wBase * (1 / newZoom - 1 / v.zoom)
const dH = hBase * (1 / newZoom - 1 / v.zoom)
return { zoom: newZoom, panX: v.panX + u * dW, panY: v.panY + vN * dH }
})
}
const clientToImage = (clientX, clientY) => {
const svg = svgRef.current
if (!svg) return null
const pt = svg.createSVGPoint(); pt.x = clientX; pt.y = clientY
const ctm = svg.getScreenCTM(); if (!ctm) return null
const ip = pt.matrixTransform(ctm.inverse())
return { x: ip.x, y: ip.y }
}
const onMouseDown = (e) => {
if (e.button !== 0) return
if (e.shiftKey) {
const start = clientToImage(e.clientX, e.clientY)
if (!start) return
e.preventDefault()
setSelBox({ x0: start.x, y0: start.y, x1: start.x, y1: start.y })
const onMove = (ev) => {
const cur = clientToImage(ev.clientX, ev.clientY); if (!cur) return
setSelBox(b => b && { ...b, x1: cur.x, y1: cur.y })
}
const onUp = () => {
document.removeEventListener('mousemove', onMove)
document.removeEventListener('mouseup', onUp)
setSelBox(b => {
if (!b) return null
finalizeSelection(b)
return null
})
}
document.addEventListener('mousemove', onMove)
document.addEventListener('mouseup', onUp)
return
}
dragRef.current = {
startX: e.clientX, startY: e.clientY,
origPanX: view.panX, origPanY: view.panY,
}
const onMove = (ev) => {
const s = dragRef.current; if (!s) return
const rect = containerRef.current.getBoundingClientRect()
if (!debug) return
const [x0, y0, x1, y1] = debug.bounds
const w = (x1 - x0) * 1.08 / view.zoom
const dx = (ev.clientX - s.startX) / rect.width * w
const dy = (ev.clientY - s.startY) / rect.height * (y1 - y0) * 1.08 / view.zoom
setView(v => ({ ...v, panX: s.origPanX + dx, panY: s.origPanY + dy }))
}
const onUp = () => {
dragRef.current = null
document.removeEventListener('mousemove', onMove)
document.removeEventListener('mouseup', onUp)
}
document.addEventListener('mousemove', onMove)
document.addEventListener('mouseup', onUp)
}
function finalizeSelection(box) {
if (!debug) return
const lo = { x: Math.min(box.x0, box.x1), y: Math.min(box.y0, box.y1) }
const hi = { x: Math.max(box.x0, box.x1), y: Math.max(box.y0, box.y1) }
if (hi.x - lo.x < 0.5 || hi.y - lo.y < 0.5) return
const ptIn = (p) => p[0] >= lo.x && p[0] <= hi.x && p[1] >= lo.y && p[1] <= hi.y
const anyIn = (pts) => pts.some(ptIn)
const round2 = (n) => Math.round(n * 100) / 100
const r2 = (p) => [round2(p[0]), round2(p[1])]
const r2list = (pts) => pts.map(r2)
const out = {
hull_index: hullIdx,
box: [round2(lo.x), round2(lo.y), round2(hi.x), round2(hi.y)],
start_points_in_box: r2list(debug.start_points.filter(ptIn)),
trajectories: debug.trajectories.filter(anyIn).map(r2list),
strokes: debug.strokes.filter(anyIn).map(r2list),
}
const json = JSON.stringify(out, null, 2)
navigator.clipboard.writeText(json).then(() => {
setToast(`Copied — ${out.trajectories.length} traj · ${out.strokes.length} strokes`)
setTimeout(() => setToast(null), 3000)
}).catch(err => {
setToast(`Clipboard write failed: ${err.message ?? err}`)
setTimeout(() => setToast(null), 4000)
})
}
const onMouseMoveSvg = (e) => {
if (!debug) return
const svg = e.currentTarget
const pt = svg.createSVGPoint(); pt.x = e.clientX; pt.y = e.clientY
const ctm = svg.getScreenCTM(); if (!ctm) return
const ip = pt.matrixTransform(ctm.inverse())
setHover({ x: ip.x, y: ip.y })
}
const toggleLayer = (key) => setEnabled(en => ({ ...en, [key]: !en[key] }))
if (!debug) {
return (
<div className="absolute inset-0 flex items-center justify-center bg-neutral-900 text-neutral-500">
<div className="text-center space-y-2">
<p>Streamline debug</p>
<p className="text-xs">No hulls available run the pipeline first (Source Kernel Hull).</p>
</div>
</div>
)
}
return (
<div ref={containerRef} className="absolute inset-0 bg-neutral-900 flex">
{/* Sidebar */}
<div className="w-64 shrink-0 border-r border-neutral-800 p-3 overflow-y-auto text-xs text-neutral-300 space-y-3">
<div>
<label className="block text-neutral-500 mb-1">Hull (largest first)</label>
<select value={hullIdx}
onChange={e => setHullIdx(parseInt(e.target.value, 10))}
className="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-xs">
{hulls.map((h, i) => (
<option key={h.index} value={h.index}>
#{h.index} · {h.area}px · {h.bounds[2] - h.bounds[0]}×{h.bounds[3] - h.bounds[1]}
{i === 0 ? ' (largest)' : ''}
</option>
))}
</select>
</div>
<div>
<div className="text-neutral-500 mb-1">Layers</div>
<div className="space-y-1">
{LAYERS.map(l => (
<label key={l.key} className="flex items-center gap-2 cursor-pointer">
<input type="checkbox"
checked={!!enabled[l.key]}
onChange={() => toggleLayer(l.key)} />
<span>{l.label}</span>
</label>
))}
</div>
</div>
<div className="space-y-2 pt-1 border-t border-neutral-800">
<div className="flex items-center justify-between">
<span className="text-neutral-500">Dynamics</span>
<button onClick={() => setParams({ ...DEFAULT_STREAMLINE_PARAMS })}
className="text-[10px] px-2 py-0.5 rounded bg-neutral-800 hover:bg-neutral-700 text-neutral-400">
Reset
</button>
</div>
<ParamSlider label="Speed" value={params.speed} min={0.25} max={3} step={0.05}
onChange={v => setParam('speed', v)}
hint="Constant pen speed (px/step). Direction can rotate, magnitude is renormalised." />
<ParamSlider label="dt" value={params.dt} min={0.1} max={2} step={0.05}
onChange={v => setParam('dt', v)}
hint="Time step. Step distance per iteration = speed × dt." />
<ParamSlider label="Ridge lerp" value={params.ridge_lerp} min={0} max={1} step={0.05}
onChange={v => setParam('ridge_lerp', v)}
hint="Direction-lerp rate toward local ridge tangent. Lower = stickier momentum." />
<ParamSlider label="Center strength" value={params.center_strength} min={0} max={0.8} step={0.05}
onChange={v => setParam('center_strength', v)}
hint="Per-step lateral nudge toward higher SDF (counters drift on curves)." />
<ParamSlider label="Min clearance" value={params.min_clearance} min={0} max={2} step={0.05}
onChange={v => setParam('min_clearance', v)}
hint="Stop when SDF at the particle drops below this — drifted off ridge." />
</div>
<div className="space-y-2 pt-1 border-t border-neutral-800">
<span className="text-neutral-500">Pivot detection</span>
<ParamSlider label="Pivot trigger" value={params.pivot_threshold} min={0} max={1.5} step={0.05}
onChange={v => setParam('pivot_threshold', v)}
hint="∇D·v̂ value above which look-ahead fires (gradient opposing velocity)." />
<ParamSlider label="Look-ahead r" value={params.lookahead_radius} min={1} max={20} step={0.5}
onChange={v => setParam('lookahead_radius', v)}
hint="Radius (px) for pivot direction sampling." />
<ParamSlider label="Steer rate" value={params.pivot_steer_rate} min={0} max={1} step={0.05}
onChange={v => setParam('pivot_steer_rate', v)}
hint="How fast velocity snaps to chosen pivot direction." />
<ParamSlider label="Min pivot score" value={params.min_pivot_score} min={0} max={3} step={0.05}
onChange={v => setParam('min_pivot_score', v)}
hint="Minimum mean-SDF along a pivot direction to count as viable continuation." />
</div>
<div className="space-y-2 pt-1 border-t border-neutral-800">
<span className="text-neutral-500">Mask · loop · caps</span>
<ParamSlider label="Visited r" value={params.visited_radius} min={0.5} max={5} step={0.1}
onChange={v => setParam('visited_radius', v)}
hint="Radius (px) of the visited-mask stamp at each step." />
<ParamSlider label="Loop close r" value={params.loop_close_radius} min={0.5} max={8} step={0.5}
onChange={v => setParam('loop_close_radius', v)}
hint="Stop when the particle returns within this many px of stroke start." />
<ParamSlider label="Min loop dist" value={params.min_loop_distance} min={5} max={200} step={5}
onChange={v => setParam('min_loop_distance', v)}
hint="Don't let loop-close fire until particle has travelled at least this far." />
<ParamSlider label="Min stroke len" value={params.min_stroke_length} min={0} max={20} step={0.5}
onChange={v => setParam('min_stroke_length', v)}
hint="Drop strokes shorter than this — fringe artifacts from pick_start." />
<ParamSlider label="Max steps/stroke" value={params.max_steps_per_stroke} min={100} max={10000} step={100}
onChange={v => setParam('max_steps_per_stroke', v)}
hint="Safety cap." />
<ParamSlider label="Max strokes" value={params.max_strokes} min={1} max={30} step={1}
onChange={v => setParam('max_strokes', v)}
hint="Safety cap on strokes per hull." />
</div>
<div className="space-y-2 pt-1 border-t border-neutral-800">
<span className="text-neutral-500">Output smoothing</span>
<ParamSlider label="Output RDP" value={params.output_rdp_eps} min={0} max={2} step={0.1}
onChange={v => setParam('output_rdp_eps', v)}
hint="Final stroke RDP epsilon." />
<ParamSlider label="Output Chaikin" value={params.output_chaikin} min={0} max={6} step={1}
onChange={v => setParam('output_chaikin', v)}
hint="Final stroke Chaikin smoothing passes." />
</div>
<div className="pt-2 border-t border-neutral-800 space-y-2">
<div>
<label className="block text-neutral-500 mb-1">
Source opacity: {(sourceOpacity * 100).toFixed(0)}%
</label>
<input type="range" min={0} max={1} step={0.05}
value={sourceOpacity}
onChange={e => setSourceOpacity(parseFloat(e.target.value))}
className="w-full" />
</div>
<div>
<label className="block text-neutral-500 mb-1">
SDF opacity: {(sdfOpacity * 100).toFixed(0)}%
</label>
<input type="range" min={0} max={1} step={0.05}
value={sdfOpacity}
onChange={e => setSdfOpacity(parseFloat(e.target.value))}
className="w-full" />
</div>
</div>
<div className="pt-2 border-t border-neutral-800 text-neutral-500 leading-relaxed">
<p>Zoom: wheel · Pan: drag · Shift+drag: copy region</p>
<button onClick={() => setView({ zoom: 1, panX: 0, panY: 0 })}
className="mt-1 text-xs px-2 py-0.5 bg-neutral-800 rounded">Fit</button>
<div className="mt-2 space-y-0.5">
<div>· {debug.start_points.length} start points</div>
<div>· {debug.trajectories.length} raw trajectories</div>
<div>· {debug.strokes.length} smoothed strokes</div>
<div>· sdf max: {debug.sdf_max?.toFixed(2) ?? '—'} px</div>
</div>
{hover && (
<div className="mt-2 font-mono">
({hover.x.toFixed(2)}, {hover.y.toFixed(2)})
</div>
)}
</div>
</div>
{/* Canvas */}
<div className="flex-1 relative overflow-hidden" onWheel={onWheel} onMouseDown={onMouseDown}>
<svg
ref={svgRef}
width="100%" height="100%"
viewBox={viewBox}
preserveAspectRatio="xMidYMid meet"
onMouseMove={onMouseMoveSvg}
style={{ cursor: 'grab', background: '#0f0f10' }}>
{enabled.source && debug.source_b64 && (
<image
href={debug.source_b64} xlinkHref={debug.source_b64}
x={debug.bounds[0]} y={debug.bounds[1]}
width={debug.bounds[2] - debug.bounds[0] + 1}
height={debug.bounds[3] - debug.bounds[1] + 1}
opacity={sourceOpacity}
style={{ imageRendering: 'pixelated' }}
preserveAspectRatio="none" />
)}
{enabled.sdf && debug.sdf_b64 && (
<image
href={debug.sdf_b64} xlinkHref={debug.sdf_b64}
x={debug.bounds[0]} y={debug.bounds[1]}
width={debug.bounds[2] - debug.bounds[0] + 1}
height={debug.bounds[3] - debug.bounds[1] + 1}
opacity={sdfOpacity}
style={{ imageRendering: 'pixelated' }}
preserveAspectRatio="none" />
)}
{enabled.visited && debug.visited_b64 && (
<image
href={debug.visited_b64} xlinkHref={debug.visited_b64}
x={debug.bounds[0]} y={debug.bounds[1]}
width={debug.bounds[2] - debug.bounds[0] + 1}
height={debug.bounds[3] - debug.bounds[1] + 1}
opacity={0.7}
style={{ imageRendering: 'pixelated' }}
preserveAspectRatio="none" />
)}
{enabled.trajectory && debug.trajectories.map((t, i) => (
<polyline key={`tr${i}`}
points={t.map(p => `${p[0]},${p[1]}`).join(' ')}
fill="none" stroke={strokeHue(i)} strokeWidth={1}
strokeOpacity={0.85}
vectorEffect="non-scaling-stroke" />
))}
{enabled.strokes && debug.strokes.map((s, i) => (
<polyline key={`st${i}`}
points={s.map(p => `${p[0]},${p[1]}`).join(' ')}
fill="none" stroke={strokeHue(i)} strokeWidth={2}
strokeLinecap="round" strokeLinejoin="round"
vectorEffect="non-scaling-stroke" />
))}
{enabled.starts && debug.start_points.map((p, i) => (
<g key={`sp${i}`}>
<circle cx={p[0]} cy={p[1]} r={1.5}
fill={strokeHue(i)} stroke="white" strokeWidth={0.4}
vectorEffect="non-scaling-stroke" />
<text x={p[0] + 2} y={p[1] - 2}
fill={strokeHue(i)} fontSize={3}
style={{ paintOrder: 'stroke', stroke: '#000', strokeWidth: 0.5 }}>
{i + 1}
</text>
</g>
))}
{selBox && (
<rect
x={Math.min(selBox.x0, selBox.x1)}
y={Math.min(selBox.y0, selBox.y1)}
width={Math.abs(selBox.x1 - selBox.x0)}
height={Math.abs(selBox.y1 - selBox.y0)}
fill="rgba(99, 102, 241, 0.12)"
stroke="#818cf8" strokeWidth={0.5}
strokeDasharray="2 1"
vectorEffect="non-scaling-stroke" />
)}
</svg>
<div className="absolute bottom-2 left-3 text-[10px] text-neutral-500 pointer-events-none">
Shift+drag to copy region data to clipboard
</div>
{toast && (
<div className="absolute top-3 left-1/2 -translate-x-1/2 px-3 py-1.5 rounded
bg-neutral-800 border border-indigo-500/60 text-xs text-neutral-100 shadow-lg
pointer-events-none">
{toast}
</div>
)}
</div>
</div>
)
}
function ParamSlider({ label, value, min, max, step, onChange, hint }) {
// Guard: if a default-params entry is missing (e.g. backend renamed a
// field), don't blow up the whole UI. Show "—" and let the user notice.
if (typeof value !== 'number' || !Number.isFinite(value)) {
return (
<div className="flex items-center justify-between text-[10px] text-red-400">
<span>{label}</span><span></span>
</div>
)
}
const display = Number.isInteger(step) ? value.toString() : value.toFixed(2)
return (
<div title={hint}>
<div className="flex items-center justify-between text-[10px]">
<span className="text-neutral-400">{label}</span>
<span className="text-neutral-300 font-mono">{display}</span>
</div>
<input type="range" min={min} max={max} step={step}
value={value}
onChange={e => onChange(parseFloat(e.target.value))}
className="w-full" />
</div>
)
}

View File

@@ -35,32 +35,6 @@ export async function loadTestLetter(passIdx, ch, fontMm, dpi, thicknessPx) {
}) })
} }
// Default StreamlineParams must match Rust's `impl Default for StreamlineParams`.
// Values from streamline_optimize coordinate descent over 62-glyph alphabet.
export const DEFAULT_STREAMLINE_PARAMS = {
speed: 1.5,
dt: 0.5,
ridge_lerp: 0.3,
center_strength: 0.5,
min_clearance: 0.2,
pivot_threshold: 0.2,
lookahead_radius: 5.0,
pivot_steer_rate: 1.0,
min_pivot_score: 0.2,
visited_radius: 1.2,
loop_close_radius: 5.0,
min_loop_distance: 50.0,
min_stroke_length: 2.0,
max_steps_per_stroke: 4000,
max_strokes: 12,
output_rdp_eps: 0.5,
output_chaikin: 2,
}
export async function getStreamlineDebug(passIdx, hullIdx, params = DEFAULT_STREAMLINE_PARAMS) {
return tracedInvoke('get_streamline_debug', { passIdx, hullIdx, params })
}
// Default PaintParams must match Rust's `impl Default for PaintParams`. // Default PaintParams must match Rust's `impl Default for PaintParams`.
export const DEFAULT_PAINT_PARAMS = { export const DEFAULT_PAINT_PARAMS = {
brush_radius_factor: 0.88, brush_radius_factor: 0.88,

View File

@@ -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','streamline','topo','paint'] export const FILL_STRATEGIES = ['hatch','zigzag','offset','spiral','outline','circles','voronoi','hilbert','waves','flow','gradient_hatch','gradient_cross_hatch','skeleton','centerline','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.

View File

@@ -369,6 +369,77 @@ fn measure_sweep_full(strokes: &[Vec<(f32, f32)>], grid: &Grid, brush_radius: f3
(bg, total, repaint) (bg, total, repaint)
} }
fn colormap_viridis(t: f32) -> (u8, u8, u8) {
let stops: [(u8, u8, u8); 5] = [
( 68, 1, 84),
( 59, 82, 139),
( 33, 144, 141),
( 93, 201, 99),
(253, 231, 37),
];
let t = t.clamp(0.0, 1.0);
let n = stops.len() - 1;
let pos = t * n as f32;
let i = (pos as usize).min(n - 1);
let f = pos - i as f32;
let lerp = |a: u8, b: u8| (a as f32 + (b as f32 - a as f32) * f).round() as u8;
(lerp(stops[i].0, stops[i + 1].0),
lerp(stops[i].1, stops[i + 1].1),
lerp(stops[i].2, stops[i + 1].2))
}
fn encode_hull_pixels_b64(hull: &Hull) -> String {
let bx = hull.bounds.x_min;
let by = hull.bounds.y_min;
let bw = hull.bounds.x_max.saturating_sub(bx) + 1;
let bh = hull.bounds.y_max.saturating_sub(by) + 1;
let mut img: image::RgbaImage = image::ImageBuffer::new(bw, bh);
for &(x, y) in &hull.pixels {
if x < bx || y < by { continue; }
let lx = x - bx;
let ly = y - by;
if lx < bw && ly < bh {
img.put_pixel(lx, ly, image::Rgba([255, 255, 255, 255]));
}
}
let mut buf = std::io::Cursor::new(Vec::new());
if img.write_to(&mut buf, image::ImageFormat::Png).is_err() {
return String::new();
}
use base64::Engine as _;
let b64 = base64::engine::general_purpose::STANDARD.encode(buf.get_ref());
format!("data:image/png;base64,{}", b64)
}
fn encode_sdf_b64(hull: &Hull) -> (String, f32) {
let bx = hull.bounds.x_min;
let by = hull.bounds.y_min;
let bw = hull.bounds.x_max.saturating_sub(bx) + 1;
let bh = hull.bounds.y_max.saturating_sub(by) + 1;
if hull.pixels.is_empty() || bw == 0 || bh == 0 { return (String::new(), 0.0); }
let pixel_set: HashSet<(u32, u32)> = hull.pixels.iter().copied().collect();
let dist = chamfer_distance(hull, &pixel_set);
let max_d = dist.values().cloned().fold(0.0_f32, f32::max);
if max_d <= 0.0 { return (String::new(), 0.0); }
let mut img: image::RgbaImage = image::ImageBuffer::new(bw, bh);
for (&(x, y), &d) in dist.iter() {
if x < bx || y < by { continue; }
let lx = x - bx;
let ly = y - by;
if lx >= bw || ly >= bh { continue; }
let t = d / max_d;
let (r, g, b) = colormap_viridis(t);
img.put_pixel(lx, ly, image::Rgba([r, g, b, 230]));
}
let mut buf = std::io::Cursor::new(Vec::new());
if img.write_to(&mut buf, image::ImageFormat::Png).is_err() {
return (String::new(), 0.0);
}
use base64::Engine as _;
let b64 = base64::engine::general_purpose::STANDARD.encode(buf.get_ref());
(format!("data:image/png;base64,{}", b64), max_d)
}
fn encode_coverage_b64(grid: &Grid) -> String { fn encode_coverage_b64(grid: &Grid) -> String {
let bw = grid.width.max(1) as u32; let bw = grid.width.max(1) as u32;
let bh = grid.height.max(1) as u32; let bh = grid.height.max(1) as u32;
@@ -1872,14 +1943,14 @@ pub fn paint_fill_debug(hull: &Hull, params: &PaintParams) -> PaintDebug {
.filter(|s| s.len() >= 2) .filter(|s| s.len() >= 2)
.collect(); .collect();
let (sdf_b64, _) = crate::streamline::encode_sdf_b64(hull); let (sdf_b64, _) = encode_sdf_b64(hull);
let ink_unpainted = grid.ink_remaining.max(0) as u32; let ink_unpainted = grid.ink_remaining.max(0) as u32;
let (bg_painted, total_swept, repaint) = measure_sweep_full(&strokes, &grid, brush_radius); let (bg_painted, total_swept, repaint) = measure_sweep_full(&strokes, &grid, brush_radius);
let skeleton_length = grid.skeleton_length; let skeleton_length = grid.skeleton_length;
let unpainted_clusters = grid.unpainted_cluster_sizes(); let unpainted_clusters = grid.unpainted_cluster_sizes();
PaintDebug { PaintDebug {
bounds, bounds,
source_b64: crate::streamline::encode_hull_pixels_b64(hull), source_b64: encode_hull_pixels_b64(hull),
sdf_b64, sdf_b64,
sdf_max, sdf_max,
brush_radius, brush_radius,

View File

@@ -3,7 +3,6 @@ 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 topo_strokes;
pub mod brush_paint; pub mod brush_paint;
pub mod brush_paint_opt; pub mod brush_paint_opt;
@@ -825,7 +824,6 @@ 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),
"streamline" => streamline::streamline_fill(hull, param.max(0.0)),
"topo" => topo_strokes::topo_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)), "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),
@@ -858,7 +856,6 @@ 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),
"streamline" => streamline::streamline_fill(hull, param.max(0.0)),
"topo" => topo_strokes::topo_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)), "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),
@@ -1029,19 +1026,6 @@ fn load_test_letter(
}).collect()) }).collect())
} }
#[tauri::command]
fn get_streamline_debug(
pass_idx: usize, hull_idx: usize, params: streamline::StreamlineParams,
state: State<Mutex<AppState>>,
) -> Result<streamline::StreamlineDebug, String> {
let st = state.lock().unwrap();
let ps = st.passes.get(pass_idx)
.ok_or_else(|| format!("pass {pass_idx} out of range"))?;
let h = ps.hulls.get(hull_idx)
.ok_or_else(|| format!("hull {hull_idx} out of range (pass has {})", ps.hulls.len()))?;
Ok(streamline::streamline_fill_debug(h, &params))
}
#[tauri::command] #[tauri::command]
fn get_paint_debug( fn get_paint_debug(
pass_idx: usize, hull_idx: usize, params: brush_paint::PaintParams, pass_idx: usize, hull_idx: usize, params: brush_paint::PaintParams,
@@ -3003,7 +2987,6 @@ pub fn run() {
set_pass_count, set_pass_count,
list_hulls, list_hulls,
load_test_letter, load_test_letter,
get_streamline_debug,
get_paint_debug, get_paint_debug,
optimize_paint_params, optimize_paint_params,
process_pass, process_pass,

File diff suppressed because it is too large Load Diff