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:
@@ -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' ? (
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
17
src/lib.rs
17
src/lib.rs
@@ -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, ¶ms))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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,
|
||||||
|
|||||||
1141
src/streamline.rs
1141
src/streamline.rs
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user