diff --git a/src-frontend/src/App.jsx b/src-frontend/src/App.jsx
index 922f7f16..a651775a 100644
--- a/src-frontend/src/App.jsx
+++ b/src-frontend/src/App.jsx
@@ -5,7 +5,6 @@ import TuningPanel from './components/TuningPanel.jsx'
import CalibrationButtons from './components/CalibrationButtons.jsx'
import CalibrationAxis from './components/CalibrationAxis.jsx'
import TextEditOverlay from './components/TextEditOverlay.jsx'
-import StreamlineDebugView from './components/StreamlineDebugView.jsx'
import PaintDebugView from './components/PaintDebugView.jsx'
import NodeGraph from './components/NodeGraph.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 { 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() {
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 */}
{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)
return (
) : viewMode === 'tuning' ? (
- ) : viewMode === 'streamline' ? (
-
) : viewMode === 'paint' ? (
) : viewMode === 'source' && sourceMode === 'text' ? (
diff --git a/src-frontend/src/components/StreamlineDebugView.jsx b/src-frontend/src/components/StreamlineDebugView.jsx
deleted file mode 100644
index 21a63ca8..00000000
--- a/src-frontend/src/components/StreamlineDebugView.jsx
+++ /dev/null
@@ -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 (
-
-
-
Streamline debug
-
No hulls available — run the pipeline first (Source → Kernel → Hull).
-
-
- )
- }
-
- return (
-
- {/* Sidebar */}
-
-
- Hull (largest first)
- 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) => (
-
- #{h.index} · {h.area}px · {h.bounds[2] - h.bounds[0]}×{h.bounds[3] - h.bounds[1]}
- {i === 0 ? ' (largest)' : ''}
-
- ))}
-
-
-
-
-
Layers
-
- {LAYERS.map(l => (
-
- toggleLayer(l.key)} />
- {l.label}
-
- ))}
-
-
-
-
-
- Dynamics
- setParams({ ...DEFAULT_STREAMLINE_PARAMS })}
- className="text-[10px] px-2 py-0.5 rounded bg-neutral-800 hover:bg-neutral-700 text-neutral-400">
- Reset
-
-
-
setParam('speed', v)}
- hint="Constant pen speed (px/step). Direction can rotate, magnitude is renormalised." />
- setParam('dt', v)}
- hint="Time step. Step distance per iteration = speed × dt." />
- setParam('ridge_lerp', v)}
- hint="Direction-lerp rate toward local ridge tangent. Lower = stickier momentum." />
- setParam('center_strength', v)}
- hint="Per-step lateral nudge toward higher SDF (counters drift on curves)." />
- setParam('min_clearance', v)}
- hint="Stop when SDF at the particle drops below this — drifted off ridge." />
-
-
-
-
Pivot detection
-
setParam('pivot_threshold', v)}
- hint="−∇D·v̂ value above which look-ahead fires (gradient opposing velocity)." />
- setParam('lookahead_radius', v)}
- hint="Radius (px) for pivot direction sampling." />
- setParam('pivot_steer_rate', v)}
- hint="How fast velocity snaps to chosen pivot direction." />
- setParam('min_pivot_score', v)}
- hint="Minimum mean-SDF along a pivot direction to count as viable continuation." />
-
-
-
-
Mask · loop · caps
-
setParam('visited_radius', v)}
- hint="Radius (px) of the visited-mask stamp at each step." />
- setParam('loop_close_radius', v)}
- hint="Stop when the particle returns within this many px of stroke start." />
- setParam('min_loop_distance', v)}
- hint="Don't let loop-close fire until particle has travelled at least this far." />
- setParam('min_stroke_length', v)}
- hint="Drop strokes shorter than this — fringe artifacts from pick_start." />
- setParam('max_steps_per_stroke', v)}
- hint="Safety cap." />
- setParam('max_strokes', v)}
- hint="Safety cap on strokes per hull." />
-
-
-
-
Output smoothing
-
setParam('output_rdp_eps', v)}
- hint="Final stroke RDP epsilon." />
- setParam('output_chaikin', v)}
- hint="Final stroke Chaikin smoothing passes." />
-
-
-
-
-
-
Zoom: wheel · Pan: drag · Shift+drag: copy region
-
setView({ zoom: 1, panX: 0, panY: 0 })}
- className="mt-1 text-xs px-2 py-0.5 bg-neutral-800 rounded">Fit
-
-
· {debug.start_points.length} start points
-
· {debug.trajectories.length} raw trajectories
-
· {debug.strokes.length} smoothed strokes
-
· sdf max: {debug.sdf_max?.toFixed(2) ?? '—'} px
-
- {hover && (
-
- ({hover.x.toFixed(2)}, {hover.y.toFixed(2)})
-
- )}
-
-
-
- {/* Canvas */}
-
-
-
- {enabled.source && debug.source_b64 && (
-
- )}
-
- {enabled.sdf && debug.sdf_b64 && (
-
- )}
-
- {enabled.visited && debug.visited_b64 && (
-
- )}
-
- {enabled.trajectory && debug.trajectories.map((t, i) => (
- `${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) => (
- `${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) => (
-
-
-
- {i + 1}
-
-
- ))}
-
- {selBox && (
-
- )}
-
-
-
- Shift+drag to copy region data to clipboard
-
-
- {toast && (
-
- {toast}
-
- )}
-
-
- )
-}
-
-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 (
-
- {label} —
-
- )
- }
- const display = Number.isInteger(step) ? value.toString() : value.toFixed(2)
- return (
-
-
- {label}
- {display}
-
-
onChange(parseFloat(e.target.value))}
- className="w-full" />
-
- )
-}
diff --git a/src-frontend/src/hooks/useTauri.js b/src-frontend/src/hooks/useTauri.js
index 0cb0ee83..600ed982 100644
--- a/src-frontend/src/hooks/useTauri.js
+++ b/src-frontend/src/hooks/useTauri.js
@@ -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`.
export const DEFAULT_PAINT_PARAMS = {
brush_radius_factor: 0.88,
diff --git a/src-frontend/src/store.js b/src-frontend/src/store.js
index 599e6e00..046693e4 100644
--- a/src-frontend/src/store.js
+++ b/src-frontend/src/store.js
@@ -4,7 +4,7 @@
export const KERNELS = ['Luminance','Sobel','ColorGradient','Laplacian','Canny','Saturation','XDoG','ColorIsolate']
export const BLEND_MODES = ['Average','Min','Max','Multiply','Screen','Difference']
-export const FILL_STRATEGIES = ['hatch','zigzag','offset','spiral','outline','circles','voronoi','hilbert','waves','flow','gradient_hatch','gradient_cross_hatch','skeleton','centerline','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.
// Strategies not listed here have no secondary parameter.
diff --git a/src/brush_paint.rs b/src/brush_paint.rs
index 35d4981e..04c2662b 100644
--- a/src/brush_paint.rs
+++ b/src/brush_paint.rs
@@ -369,6 +369,77 @@ fn measure_sweep_full(strokes: &[Vec<(f32, f32)>], grid: &Grid, brush_radius: f3
(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 {
let bw = grid.width.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)
.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 (bg_painted, total_swept, repaint) = measure_sweep_full(&strokes, &grid, brush_radius);
let skeleton_length = grid.skeleton_length;
let unpainted_clusters = grid.unpainted_cluster_sizes();
PaintDebug {
bounds,
- source_b64: crate::streamline::encode_hull_pixels_b64(hull),
+ source_b64: encode_hull_pixels_b64(hull),
sdf_b64,
sdf_max,
brush_radius,
diff --git a/src/lib.rs b/src/lib.rs
index 18de3df7..50c1f001 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -3,7 +3,6 @@ pub mod hulls;
pub mod fill;
pub mod gcode;
pub mod text;
-pub mod streamline;
pub mod topo_strokes;
pub mod brush_paint;
pub mod brush_paint_opt;
@@ -825,7 +824,6 @@ fn process_pass_work(
"hilbert" => fill::hilbert_fill(hull, spacing),
"skeleton" => fill::skeleton_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)),
"paint" => brush_paint::paint_fill(hull, param.max(0.0)),
"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),
"skeleton" => fill::skeleton_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)),
"paint" => brush_paint::paint_fill(hull, param.max(0.0)),
"waves" => fill::wave_interference(hull, spacing, param.round().max(1.0) as usize),
@@ -1029,19 +1026,6 @@ fn load_test_letter(
}).collect())
}
-#[tauri::command]
-fn get_streamline_debug(
- pass_idx: usize, hull_idx: usize, params: streamline::StreamlineParams,
- state: State
>,
-) -> Result {
- 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]
fn get_paint_debug(
pass_idx: usize, hull_idx: usize, params: brush_paint::PaintParams,
@@ -3003,7 +2987,6 @@ pub fn run() {
set_pass_count,
list_hulls,
load_test_letter,
- get_streamline_debug,
get_paint_debug,
optimize_paint_params,
process_pass,
diff --git a/src/streamline.rs b/src/streamline.rs
deleted file mode 100644
index 38426585..00000000
--- a/src/streamline.rs
+++ /dev/null
@@ -1,1141 +0,0 @@
-// Streamline pen-stroke algorithm.
-//
-// Particle physics on the SDF (chamfer distance to nearest polygon boundary).
-// A pen-tip particle travels along the medial-axis ridge with momentum,
-// stays on the ridge via attraction (perpendicular component of ∇D),
-// pivots at boundary V-tips by look-ahead when the gradient strongly
-// opposes velocity, and pen-ups when no viable continuation exists.
-//
-// Junctions (where SDF is *high*, gradient is small/symmetric) get traversed
-// by pure momentum — no decision-making fires there. Decisions fire only
-// near actual polygon corners where the SDF is dropping into a wall.
-//
-// See the running discussion in this PR for the design rationale.
-
-use std::collections::HashSet;
-use crate::fill::{FillResult, smooth_stroke, chamfer_distance};
-use crate::hulls::Hull;
-
-// ── Debug-image encoding helpers ────────────────────────────────────────
-// Render small base64 PNGs sized to the hull's bbox: source pixels (white
-// ink on transparent), SDF heatmap (viridis-coloured chamfer distance),
-// visited mask. Used only by the debug pathway; production fill skips them.
-
-pub(crate) fn colormap_viridis(t: f32) -> (u8, u8, u8) {
- let stops: [(u8, u8, u8); 5] = [
- ( 68, 1, 84), // 0.00 — dark purple
- ( 59, 82, 139), // 0.25 — blue
- ( 33, 144, 141), // 0.50 — teal
- ( 93, 201, 99), // 0.75 — green
- (253, 231, 37), // 1.00 — yellow
- ];
- 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))
-}
-
-pub(crate) 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)
-}
-
-pub(crate) 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)
-}
-
-#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
-#[serde(default)]
-pub struct StreamlineParams {
- /// Constant pen speed (pixels per step). Particle direction can rotate,
- /// but its magnitude is renormalised to this every step.
- pub speed: f32,
- /// Time step size. Step distance per iteration = `speed × dt`.
- pub dt: f32,
- /// Direction-lerp rate toward the local ridge tangent (0..1).
- /// Lower = stickier momentum, higher = snappier ridge-following.
- pub ridge_lerp: f32,
- /// Lateral centering force per step, in pixels. After each direction
- /// update, the particle's position is nudged perpendicular to its
- /// motion in the direction of the SDF gradient (toward higher SDF /
- /// onto the ridge). Counteracts the lateral drift that accumulates
- /// when following curved ridges with finite step size.
- pub center_strength: f32,
- /// Stop when SDF at the particle drops below `min_clearance × sdf_max`.
- /// Scale-invariant (a ratio in [0,1]). 0.0 = only stop on hull exit,
- /// 0.9 = stop almost immediately if drifting off the ridge spine.
- pub min_clearance: f32,
- /// `-∇D · v̂` value above which we trigger pivot look-ahead. The
- /// gradient must oppose velocity at least this strongly.
- pub pivot_threshold: f32,
- /// Radius (px) for the look-ahead radial samples.
- pub lookahead_radius: f32,
- /// Direction-lerp rate when snapping toward a chosen pivot direction
- /// (much higher than `ridge_lerp` — pivots are sharp).
- pub pivot_steer_rate: f32,
- /// Minimum mean-SDF along a candidate pivot direction for it to count
- /// as a viable continuation (vs dead-end). Scale-invariant: ratio in
- /// [0,1], multiplied by `sdf_max` at use site.
- pub min_pivot_score: f32,
- /// Multiplier on `sdf_max` for the visited-mask stamp radius. Each
- /// step paints `visited_radius × sdf_max` pixels around the particle.
- /// 1.0 = "stamp covers stroke half-width" (so the entire stroke gets
- /// marked, not just a thin centerline). Scale-invariant.
- pub visited_radius: f32,
- /// Loop-closure: stop when the particle returns within this many pixels
- /// of the stroke's starting point AND has travelled at least
- /// `min_loop_distance` first. Handles closed glyphs like O without
- /// killing figure-8s at the cross-over.
- pub loop_close_radius: f32,
- /// Path length below which loop-close is suppressed. Prevents the
- /// particle from "closing" instantly because it's still near start.
- pub min_loop_distance: f32,
- /// Drop strokes whose total length is below `min_stroke_length × sdf_max`.
- /// Scale-invariant: 1.0 = "drop strokes shorter than the stroke half-width."
- /// Filters fringe artifacts where pick_start grabs an unmarked pixel
- /// and the particle dies in 1-3 steps.
- pub min_stroke_length: f32,
- /// Safety cap on steps per stroke.
- pub max_steps_per_stroke: u32,
- /// Safety cap on strokes per hull.
- pub max_strokes: u32,
- /// Final stroke RDP epsilon.
- pub output_rdp_eps: f32,
- /// Final stroke Chaikin smoothing passes.
- pub output_chaikin: u32,
-}
-
-impl Default for StreamlineParams {
- /// Defaults found by `streamline_optimize` coordinate-descent over the
- /// 62-glyph alphabet at 8mm/200dpi. Loss = stroke-count + IoU-mismatch +
- /// hit-the-cap penalty. See the `tests` module.
- fn default() -> Self {
- Self {
- 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,
- }
- }
-}
-
-#[derive(Debug, Clone, serde::Serialize)]
-pub struct StreamlineDebug {
- pub bounds: [f32; 4],
- pub source_b64: String,
- pub sdf_b64: String,
- pub sdf_max: f32,
- /// Visited mask as a base64 PNG (semi-transparent dark overlay).
- pub visited_b64: String,
- pub start_points: Vec<(f32, f32)>,
- /// Each stroke's raw trajectory (one entry per particle run).
- pub trajectories: Vec>,
- /// Final smoothed strokes (what would go to gcode).
- pub strokes: Vec>,
-}
-
-// ── SDF grid: dense 2D scalar field over the hull's bbox ─────────────────
-
-struct SdfGrid {
- bx: i32, by: i32,
- width: i32, height: i32,
- data: Vec,
- pub max: f32,
-}
-
-impl SdfGrid {
- fn from_hull(hull: &Hull) -> Self {
- let bx = hull.bounds.x_min as i32;
- let by = hull.bounds.y_min as i32;
- let width = (hull.bounds.x_max as i32 - bx + 1).max(1);
- let height = (hull.bounds.y_max as i32 - by + 1).max(1);
-
- let pixel_set: HashSet<(u32, u32)> = hull.pixels.iter().copied().collect();
- let dist = chamfer_distance(hull, &pixel_set);
- let mut data = vec![0.0_f32; (width * height) as usize];
- let mut max = 0.0_f32;
- for (&(x, y), &d) in dist.iter() {
- let lx = x as i32 - bx;
- let ly = y as i32 - by;
- if lx < 0 || ly < 0 || lx >= width || ly >= height { continue; }
- data[(ly * width + lx) as usize] = d;
- if d > max { max = d; }
- }
- Self { bx, by, width, height, data, max }
- }
-
- fn at(&self, x: i32, y: i32) -> f32 {
- let lx = x - self.bx;
- let ly = y - self.by;
- if lx < 0 || ly < 0 || lx >= self.width || ly >= self.height { return 0.0; }
- self.data[(ly * self.width + lx) as usize]
- }
-
- fn sample(&self, p: (f32, f32)) -> f32 {
- let ix = p.0.floor() as i32;
- let iy = p.1.floor() as i32;
- let fx = p.0 - ix as f32;
- let fy = p.1 - iy as f32;
- let v00 = self.at(ix, iy );
- let v10 = self.at(ix + 1, iy );
- let v01 = self.at(ix, iy + 1);
- let v11 = self.at(ix + 1, iy + 1);
- (1.0 - fx) * (1.0 - fy) * v00
- + fx * (1.0 - fy) * v10
- + (1.0 - fx) * fy * v01
- + fx * fy * v11
- }
-
- fn gradient(&self, p: (f32, f32)) -> (f32, f32) {
- let h = 1.0_f32;
- let dx = (self.sample((p.0 + h, p.1)) - self.sample((p.0 - h, p.1))) / (2.0 * h);
- let dy = (self.sample((p.0, p.1 + h)) - self.sample((p.0, p.1 - h))) / (2.0 * h);
- (dx, dy)
- }
-}
-
-// ── Visited mask: per-pixel last-step-visited (0 = never) ────────────────
-
-struct VisitedMask {
- bx: i32, by: i32,
- width: i32, height: i32,
- age: Vec,
- step: u32,
-}
-
-impl VisitedMask {
- fn from_hull(hull: &Hull) -> Self {
- let bx = hull.bounds.x_min as i32;
- let by = hull.bounds.y_min as i32;
- let width = (hull.bounds.x_max as i32 - bx + 1).max(1);
- let height = (hull.bounds.y_max as i32 - by + 1).max(1);
- Self { bx, by, width, height, age: vec![0; (width * height) as usize], step: 0 }
- }
-
- fn tick(&mut self) { self.step += 1; }
-
- fn mark(&mut self, p: (f32, f32), radius: f32) {
- let cx = p.0;
- let cy = p.1;
- let r = radius.ceil() as i32;
- let r2 = radius * radius;
- for dy in -r..=r {
- for dx in -r..=r {
- let dxy = (dx * dx + dy * dy) as f32;
- if dxy > r2 { continue; }
- let lx = cx as i32 + dx - self.bx;
- let ly = cy as i32 + dy - self.by;
- if lx < 0 || ly < 0 || lx >= self.width || ly >= self.height { continue; }
- self.age[(ly * self.width + lx) as usize] = self.step;
- }
- }
- }
-
- fn age_at(&self, p: (f32, f32)) -> u32 {
- let lx = p.0 as i32 - self.bx;
- let ly = p.1 as i32 - self.by;
- if lx < 0 || ly < 0 || lx >= self.width || ly >= self.height { return 0; }
- self.age[(ly * self.width + lx) as usize]
- }
-
- /// Visitedness in [0, 1] excluding very recent steps. 0 = unvisited or
- /// just visited within `blackout` steps; 1 = visited longer ago than that.
- fn visitedness(&self, p: (f32, f32), blackout: u32) -> f32 {
- let age = self.age_at(p);
- if age == 0 { return 0.0; }
- let dt = self.step.saturating_sub(age);
- if dt < blackout { return 0.0; }
- 1.0
- }
-}
-
-// ── Geometry helpers ────────────────────────────────────────────────────
-
-fn vec_norm(v: (f32, f32)) -> f32 { (v.0 * v.0 + v.1 * v.1).sqrt() }
-fn vec_unit(v: (f32, f32)) -> (f32, f32) {
- let n = vec_norm(v); if n < 1e-9 { (0.0, 0.0) } else { (v.0 / n, v.1 / n) }
-}
-fn vec_dot(a: (f32, f32), b: (f32, f32)) -> f32 { a.0 * b.0 + a.1 * b.1 }
-
-/// Climb the SDF gradient from `p` toward the nearest ridge maximum, up to
-/// `max_steps` 1-pixel steps. Returns the snapped position.
-fn snap_to_ridge(p: (f32, f32), sdf: &SdfGrid, max_steps: u32) -> (f32, f32) {
- let mut cur = p;
- for _ in 0..max_steps {
- let g = sdf.gradient(cur);
- let n = vec_norm(g);
- if n < 1e-3 { break; } // at ridge
- cur = (cur.0 + g.0 / n * 0.5, cur.1 + g.1 / n * 0.5);
- }
- cur
-}
-
-// ── Start-point selection ───────────────────────────────────────────────
-
-/// Find the starting point for the next stroke: highest-SDF unvisited
-/// pixel, with a top-left bias so glyphs are traced in writing order.
-/// Returns None when nothing left worth tracing.
-fn pick_start(sdf: &SdfGrid, visited: &VisitedMask, params: &StreamlineParams)
- -> Option<(f32, f32)>
-{
- let mut best: Option<(f32, (i32, i32))> = None;
- // Same scale-invariant treatment for the start-pixel SDF threshold:
- // a fraction of the hull's SDF max. Use a slightly higher fraction than
- // trace_stroke's stop threshold so we always start on the spine.
- let start_threshold = (params.min_clearance + 0.05).min(1.0) * sdf.max;
- for ly in 0..sdf.height {
- for lx in 0..sdf.width {
- let d = sdf.data[(ly * sdf.width + lx) as usize];
- if d < start_threshold { continue; }
- let p = ((lx + sdf.bx) as f32, (ly + sdf.by) as f32);
- // Hard-skip already-painted cells.
- if visited.visitedness(p, 0) > 0.5 { continue; }
- // Composite score: SDF (prefer ridge tops) + small writing-order
- // bias (prefer top, then left). Bias is gentle so it only breaks
- // ties between near-equal ridge points.
- let bias = -0.001 * (ly as f32) - 0.0005 * (lx as f32);
- let score = d + bias;
- match best {
- None => best = Some((score, (lx, ly))),
- Some((bs, _)) if score > bs => best = Some((score, (lx, ly))),
- _ => {}
- }
- }
- }
- best.map(|(_, (lx, ly))| {
- let p = ((lx + sdf.bx) as f32, (ly + sdf.by) as f32);
- snap_to_ridge(p, sdf, 8)
- })
-}
-
-/// Local ridge tangent at `p`: perpendicular to the SDF gradient. Returns
-/// (tangent_a, tangent_b) — the two opposite directions along the ridge.
-/// When the gradient is near zero (we're on a ridge maximum), returns
-/// `None` and the caller should fall back to current motion direction.
-fn ridge_tangent(p: (f32, f32), sdf: &SdfGrid) -> Option<((f32, f32), (f32, f32))> {
- let g = sdf.gradient(p);
- if vec_norm(g) < 1e-4 { return None; }
- let g_unit = vec_unit(g);
- let a = (-g_unit.1, g_unit.0);
- let b = ( g_unit.1, -g_unit.0);
- Some((a, b))
-}
-
-/// Choose the initial direction at a new stroke's start by sampling SDF
-/// (with prior-visited penalty) along both ridge-tangent options, picking
-/// whichever has more unvisited mass ahead. Falls back to "downward" if
-/// the ridge tangent is undefined at the start.
-fn initial_direction(p: (f32, f32), sdf: &SdfGrid,
- prior: &VisitedMask, params: &StreamlineParams) -> (f32, f32)
-{
- let (a, b) = match ridge_tangent(p, sdf) { Some(t) => t, None => return (0.0, 1.0) };
- let r = params.lookahead_radius.max(3.0);
- let samples = 6;
- let score_dir = |d: (f32, f32)| -> f32 {
- let mut s = 0.0;
- for k in 1..=samples {
- let t = (k as f32 / samples as f32) * r;
- let q = (p.0 + d.0 * t, p.1 + d.1 * t);
- let sdf_v = sdf.sample(q);
- let v = if prior.age_at(q) > 0 { 0.0 } else { 1.0 };
- s += sdf_v * v;
- }
- s / samples as f32
- };
- if score_dir(a) >= score_dir(b) { a } else { b }
-}
-
-// ── Pivot look-ahead ────────────────────────────────────────────────────
-
-/// Sample SDF along radial directions; pick the best non-back direction
-/// scoring `mean_sdf · (1 − visited_score)`. Considers BOTH the
-/// current-stroke visited mask (avoid backtracking on own trail) AND the
-/// prior-strokes mask (avoid pivoting into already-drawn arms). Returns
-/// (direction, score).
-fn lookahead_pivot(p: (f32, f32), v_dir: (f32, f32),
- sdf: &SdfGrid,
- cur_visited: &VisitedMask,
- prior_visited: &VisitedMask,
- params: &StreamlineParams) -> Option<((f32, f32), f32)>
-{
- const N_DIRS: usize = 24;
- let v_unit = vec_unit(v_dir);
- let mut best: Option<((f32, f32), f32)> = None;
- let r = params.lookahead_radius.max(2.0);
- for i in 0..N_DIRS {
- let theta = 2.0 * std::f32::consts::PI * i as f32 / N_DIRS as f32;
- let dir = (theta.cos(), theta.sin());
- // Skip near-back-directions.
- if vec_dot(dir, v_unit) < -0.7 { continue; }
- let samples = 6;
- let mut sdf_sum = 0.0_f32;
- let mut visited_sum = 0.0_f32;
- for k in 1..=samples {
- let t = (k as f32 / samples as f32) * r;
- let q = (p.0 + dir.0 * t, p.1 + dir.1 * t);
- sdf_sum += sdf.sample(q);
- // Prior strokes are a hard penalty (we've drawn there); current-
- // stroke trail is also a penalty but lighter (let figure-8 work).
- let prior: f32 = if prior_visited.age_at(q) > 0 { 1.0 } else { 0.0 };
- let cur: f32 = if cur_visited.age_at(q) > 0 { 0.5 } else { 0.0 };
- visited_sum += (prior + cur).min(1.0);
- }
- let mean_sdf = sdf_sum / samples as f32;
- let mean_visited = visited_sum / samples as f32;
- let score = mean_sdf * (1.0 - mean_visited);
- if score < params.min_pivot_score * sdf.max { continue; }
- match best {
- None => best = Some((dir, score)),
- Some((_, bs)) if score > bs => best = Some((dir, score)),
- _ => {}
- }
- }
- best
-}
-
-// ── Trace a single stroke ───────────────────────────────────────────────
-
-/// Constant-speed particle integrator. Direction (unit vector) is what
-/// changes step-to-step; magnitude is renormalised to `params.speed`. Stops
-/// when the particle hits a wall with no viable pivot continuation, or
-/// loops back to within `loop_close_radius` of `start` after travelling
-/// at least `min_loop_distance`.
-///
-/// `cur_visited` is the per-stroke visited mask used by the look-ahead
-/// pivot to penalise back-tracking. It does NOT trigger stop conditions
-/// directly — that's the loop-close-by-distance check. `prior_visited`
-/// is the cross-stroke mask the look-ahead also consults when scoring
-/// candidate pivot directions (so we don't pivot into already-drawn arms).
-fn trace_stroke(start: (f32, f32), dir0: (f32, f32),
- sdf: &SdfGrid,
- cur_visited: &mut VisitedMask,
- prior_visited: &VisitedMask,
- params: &StreamlineParams) -> Vec<(f32, f32)>
-{
- let mut p = start;
- let mut dir = vec_unit(dir0);
- if vec_norm(dir) < 1e-6 { dir = (0.0, 1.0); }
-
- let mut path = vec![p];
- let mut traveled = 0.0_f32;
- let step_dist = params.speed * params.dt;
- // Scale-invariant clearance threshold: as a fraction of this hull's
- // SDF max. Same params then work across font sizes / thicknesses.
- let clearance_threshold = params.min_clearance * sdf.max;
-
- for _ in 0..params.max_steps_per_stroke {
- let d = sdf.sample(p);
- if d < clearance_threshold { break; }
-
- let g = sdf.gradient(p);
- let opposing = -vec_dot(g, dir);
-
- if opposing > params.pivot_threshold {
- // Approaching a wall — try to pivot.
- match lookahead_pivot(p, dir, sdf, cur_visited, prior_visited, params) {
- Some((pivot_dir, _)) => {
- // Snap direction toward pivot (high lerp rate).
- let r = params.pivot_steer_rate.clamp(0.0, 1.0);
- dir = lerp_dir(dir, pivot_dir, r);
- }
- None => break, // dead-end
- }
- } else {
- // Normal flight — soft-pull toward ridge tangent if we're not
- // already aligned with it.
- if let Some((ta, tb)) = ridge_tangent(p, sdf) {
- // Pick the tangent direction most aligned with current motion.
- let t_pick = if vec_dot(ta, dir) >= vec_dot(tb, dir) { ta } else { tb };
- dir = lerp_dir(dir, t_pick, params.ridge_lerp.clamp(0.0, 1.0));
- }
- }
-
- // Centering: shift position perpendicular to motion toward higher
- // SDF, so we drift back onto the ridge instead of wandering off
- // along a curved ridge. Magnitude is small and capped so the path
- // stays smooth.
- if params.center_strength > 0.0 {
- let g = sdf.gradient(p);
- let g_along = vec_dot(g, dir);
- let perp = (g.0 - g_along * dir.0, g.1 - g_along * dir.1);
- let mag = vec_norm(perp);
- if mag > 1e-6 {
- let cap = 0.5; // hard cap on centering step (px) — prevents
- // overshoot if SDF gradient is steep.
- let s = (params.center_strength * mag).min(cap) / mag;
- p = (p.0 + perp.0 * s, p.1 + perp.1 * s);
- }
- }
-
- // Constant-speed forward step.
- let v = (dir.0 * params.speed, dir.1 * params.speed);
- let new_p = (p.0 + v.0 * params.dt, p.1 + v.1 * params.dt);
-
- // Reject the step if it would put us outside the hull.
- if sdf.sample(new_p) < 0.05 { break; }
-
- p = new_p;
- path.push(p);
- traveled += step_dist;
- cur_visited.tick();
- cur_visited.mark(p, params.visited_radius * sdf.max);
-
- // Loop closure: returned to within R of start after travelling far.
- if traveled > params.min_loop_distance {
- let dx = p.0 - start.0; let dy = p.1 - start.1;
- if (dx * dx + dy * dy).sqrt() < params.loop_close_radius {
- // Push start one more time so the polyline closes cleanly.
- path.push(start);
- break;
- }
- }
- }
-
- path
-}
-
-/// Lerp between two unit-ish direction vectors and re-normalise. `t` in [0,1].
-fn lerp_dir(a: (f32, f32), b: (f32, f32), t: f32) -> (f32, f32) {
- let mixed = (a.0 * (1.0 - t) + b.0 * t, a.1 * (1.0 - t) + b.1 * t);
- let n = vec_norm(mixed);
- if n < 1e-9 { a } else { (mixed.0 / n, mixed.1 / n) }
-}
-
-// ── Top-level compute ───────────────────────────────────────────────────
-
-fn compute(hull: &Hull, params: &StreamlineParams)
- -> (Vec<(f32, f32)>, Vec>, VisitedMask, SdfGrid)
-{
- let sdf = SdfGrid::from_hull(hull);
- // `prior` accumulates across strokes — used by pick_start (to find new
- // beginnings) and by lookahead_pivot (avoid pivoting into drawn arms).
- let mut prior = VisitedMask::from_hull(hull);
- let mut starts: Vec<(f32, f32)> = Vec::new();
- let mut trajectories: Vec> = Vec::new();
-
- for _ in 0..params.max_strokes {
- let start = match pick_start(&sdf, &prior, params) {
- Some(s) => s,
- None => break,
- };
- starts.push(start);
-
- // Pick the initial direction by scoring both ridge tangents'
- // unvisited-mass.
- let dir0 = initial_direction(start, &sdf, &prior, params);
-
- // Per-stroke visited mask (used by lookahead, not for stop conditions).
- let mut cur = VisitedMask::from_hull(hull);
- let path = trace_stroke(start, dir0, &sdf, &mut cur, &prior, params);
-
- // Bump prior's step counter once per stroke so the pixels we paint
- // here record an age > 0. (`age == 0` is the "never visited"
- // sentinel; without this tick all marks get age 0 and pick_start
- // and lookahead both see them as unvisited — every stroke retraces
- // the same ridge over and over.)
- prior.tick();
- // Always paint the start area, even if the stroke was rejected,
- // so we don't keep re-picking the same fringe pixel.
- prior.mark(start, params.visited_radius * sdf.max);
- if path.len() < 2 { continue; }
-
- // Reject tiny artifact strokes where the particle escaped pick_start's
- // mask only to die at the boundary a few steps later. Threshold
- // scales with sdf_max (= local stroke half-width) so the same
- // ratio works at 3mm and 8mm.
- 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();
- if length < params.min_stroke_length * sdf.max { continue; }
-
- for &q in &path { prior.mark(q, params.visited_radius * sdf.max); }
- trajectories.push(path);
- }
-
- (starts, trajectories, prior, sdf)
-}
-
-// ── Public entry points ─────────────────────────────────────────────────
-
-pub fn streamline_fill(hull: &Hull, _intensity: f32) -> FillResult {
- streamline_fill_with(hull, &StreamlineParams::default())
-}
-
-pub fn streamline_fill_with(hull: &Hull, params: &StreamlineParams) -> FillResult {
- if hull.pixels.is_empty() {
- return FillResult { hull_id: hull.id, strokes: vec![] };
- }
- let (_, trajectories, _, _) = compute(hull, params);
- let strokes: Vec> = trajectories.into_iter()
- .map(|t| smooth_stroke(&t, params.output_rdp_eps, params.output_chaikin))
- .filter(|t| t.len() >= 2)
- .collect();
- FillResult { hull_id: hull.id, strokes }
-}
-
-pub fn streamline_fill_debug(hull: &Hull, params: &StreamlineParams) -> StreamlineDebug {
- 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 (sdf_b64, sdf_max) = encode_sdf_b64(hull);
- let mut out = StreamlineDebug {
- bounds,
- source_b64: encode_hull_pixels_b64(hull),
- sdf_b64,
- sdf_max,
- visited_b64: String::new(),
- start_points: Vec::new(),
- trajectories: Vec::new(),
- strokes: Vec::new(),
- };
- if hull.pixels.is_empty() { return out; }
-
- let (starts, trajectories, visited, _sdf) = compute(hull, params);
- out.start_points = starts;
- out.visited_b64 = encode_visited_b64(&visited);
- out.strokes = trajectories.iter()
- .map(|t| smooth_stroke(t, params.output_rdp_eps, params.output_chaikin))
- .filter(|t| t.len() >= 2)
- .collect();
- out.trajectories = trajectories;
- out
-}
-
-fn encode_visited_b64(v: &VisitedMask) -> String {
- if v.width <= 0 || v.height <= 0 { return String::new(); }
- let mut img: image::RgbaImage = image::ImageBuffer::new(v.width as u32, v.height as u32);
- let max_age = v.step.max(1) as f32;
- for ly in 0..v.height {
- for lx in 0..v.width {
- let age = v.age[(ly * v.width + lx) as usize];
- if age == 0 { continue; }
- // Older = darker. Recent = brighter overlay.
- let t = (age as f32 / max_age).clamp(0.0, 1.0);
- let (r, g, b) = colormap_viridis(0.2 + 0.8 * t);
- img.put_pixel(lx as u32, ly as u32, image::Rgba([r, g, b, 110]));
- }
- }
- 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)
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use crate::text::{TextBlockSpec, rasterize_blocks};
- use crate::hulls::{extract_hulls, HullParams, Connectivity};
-
- fn rasterize_letter(c: char) -> Vec {
- rasterize_letter_at(c, 8.0, 200, 4)
- }
-
- fn rasterize_letter_at(c: char, font_size_mm: f32, dpi: u32, thickness_px: u32)
- -> Vec
- {
- 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 = 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 streamline_no_panic_for_any_printable_ascii() {
- for b in 0x20u8..=0x7E {
- let ch = b as char;
- for h in rasterize_letter(ch) {
- let _ = streamline_fill(&h, 0.0);
- let _ = streamline_fill_debug(&h, &StreamlineParams::default());
- }
- }
- }
-
- #[test]
- fn streamline_letter_I_produces_at_least_one_stroke() {
- let hulls = rasterize_letter('I');
- let main = hulls.iter().max_by_key(|h| h.area).expect("no hull");
- let r = streamline_fill(main, 0.0);
- assert!(!r.strokes.is_empty(),
- "'I' should produce at least 1 stroke, got 0");
- }
-
- #[test]
- fn streamline_letter_O_produces_at_least_one_stroke() {
- let hulls = rasterize_letter('O');
- let main = hulls.iter().max_by_key(|h| h.area).expect("no hull");
- let r = streamline_fill(main, 0.0);
- assert!(!r.strokes.is_empty(),
- "'O' should produce at least 1 stroke (the ring), got 0");
- }
-
- /// Reproduces the user's texttest.trac3r rasterisation exactly, then
- /// runs streamline on hull #N. Use to debug what's actually happening
- /// at the production scale (dpi=425).
- #[test]
- #[ignore]
- fn streamline_inspect_texttest() {
- use crate::text::{TextBlockSpec, rasterize_blocks};
- use crate::hulls::{extract_hulls, HullParams, Connectivity};
- let blocks = vec![
- TextBlockSpec {
- text: "Your Name\n123 Your St\nYour City, ST 12345".into(),
- font_size_mm: 3.0, line_spacing_mm: Some(7.0),
- x_mm: 6.83, y_mm: 6.36,
- },
- TextBlockSpec {
- text: "Recipient Name\n456 Their St\nTheir City, ST 67890".into(),
- font_size_mm: 5.0, line_spacing_mm: Some(10.0),
- x_mm: 74.67, y_mm: 48.05,
- },
- ];
- let dpi = 425;
- let stroke_thickness = ((dpi as f32 / 50.0).round() as u32).max(2);
- let rgb = rasterize_blocks(&blocks, 241.3, 104.775, dpi, stroke_thickness);
- let (w, h) = rgb.dimensions();
- let luma: Vec = rgb.pixels()
- .map(|p| ((p[0] as u32 + p[1] as u32 + p[2] as u32) / 3) as u8)
- .collect();
- let hp = HullParams {
- threshold: 253, min_area: 4, rdp_epsilon: 1.5,
- connectivity: Connectivity::Four,
- ..HullParams::default()
- };
- let hulls = extract_hulls(&luma, &rgb, w, h, &hp);
- println!("\n{} hulls extracted at dpi={}, thickness={}px",
- hulls.len(), dpi, stroke_thickness);
-
- // Sweep every hull with current defaults; flag any that hit
- // max_strokes (the user's reported failure mode).
- let params = StreamlineParams::default();
- let mut bad_count = 0;
- let mut bad_examples: Vec<(usize, &crate::hulls::Hull, usize, f32)> = Vec::new();
- for (i, h) in hulls.iter().enumerate() {
- let r = streamline_fill_with(h, ¶ms);
- let total_len: f32 = r.strokes.iter().map(|s| {
- s.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::()
- }).sum();
- if r.strokes.len() >= params.max_strokes as usize {
- bad_count += 1;
- if bad_examples.len() < 10 {
- bad_examples.push((i, h, r.strokes.len(), total_len));
- }
- }
- }
- println!("\nHulls hitting max_strokes ({}): {} of {}",
- params.max_strokes, bad_count, hulls.len());
- for &(i, h, n, len) in &bad_examples {
- let bw = h.bounds.x_max - h.bounds.x_min;
- let bh = h.bounds.y_max - h.bounds.y_min;
- println!(" hull #{}: area {} bbox {}x{} → {} strokes, total len {:.1}px",
- i, h.area, bw, bh, n, len);
- }
- if bad_examples.is_empty() {
- println!("(none — every hull stays under cap)");
- // Pick the largest hull so we still produce a debug trace.
- let (idx, hull) = hulls.iter().enumerate()
- .max_by_key(|(_, h)| h.area).unwrap();
- return println!("\nLargest hull #{}: area {}, no further trace.",
- idx, hull.area);
- }
- let (idx, hull, _, _) = bad_examples[0];
- println!("\nHull #{} matches: bbox {}x{}, area {}",
- idx,
- hull.bounds.x_max - hull.bounds.x_min,
- hull.bounds.y_max - hull.bounds.y_min,
- hull.area);
-
- let dbg = streamline_fill_debug(hull, ¶ms);
- println!("\nTracing hull #{}:", idx);
- println!("SDF max: {:.3} px", dbg.sdf_max);
- println!("Start points: {}", dbg.start_points.len());
- println!("Trajectories: {}", dbg.trajectories.len());
- println!("Smooth strokes: {}", dbg.strokes.len());
- for (i, t) in dbg.trajectories.iter().enumerate() {
- let len: f32 = t.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();
- println!(" [{}] {} pts · len {:.1}px", i, t.len(), len);
- }
- }
-
- /// Detailed dump of one letter — print every raw trajectory's start
- /// and length. Use to diagnose why a glyph fragments.
- #[test]
- #[ignore]
- fn streamline_letter_inspect_8() {
- let hulls = rasterize_letter('8');
- let main = hulls.iter().max_by_key(|h| h.area).unwrap();
- println!("\nHull bbox: ({}, {}) to ({}, {}), area {}",
- main.bounds.x_min, main.bounds.y_min,
- main.bounds.x_max, main.bounds.y_max, main.area);
- let dbg = streamline_fill_debug(main, &StreamlineParams::default());
- println!("SDF max: {:.3} px", dbg.sdf_max);
- println!("Start points: {}", dbg.start_points.len());
- for (i, s) in dbg.start_points.iter().enumerate() {
- println!(" [{}] start ({:.1}, {:.1})", i, s.0, s.1);
- }
- for (i, t) in dbg.trajectories.iter().enumerate() {
- let len: f32 = t.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 f = t.first().unwrap();
- let l = t.last().unwrap();
- println!(" [{}] {} pts · len {:.1}px · {:?} → {:?}",
- i, t.len(), len, (f.0, f.1), (l.0, l.1));
- }
- }
-
- // ── Parameter optimizer ─────────────────────────────────────────────
- //
- // Coordinate descent over the alphabet. For each parameter, scan a
- // range while holding the others fixed; pick the value that minimises
- // a per-glyph loss summed across A-Z, a-z, 0-9. Three passes.
- //
- // Loss combines three signals:
- // - stroke count (more strokes = more pen-ups = worse)
- // - hit-the-cap penalty (heavy — algorithm is broken if it runs out)
- // - 1 - IoU between dilated stroke render and source raster (heavy —
- // this catches missing or off-glyph strokes; the algorithm could
- // trivially zero out stroke count by drawing nothing, this stops it)
- //
- // Run with:
- // cargo test --lib streamline_optimize -- --ignored --nocapture
-
- fn stamp_disc(grid: &mut [bool], w: i32, h: i32, cx: i32, cy: i32, r: i32) {
- let r2 = r * r;
- for dy in -r..=r {
- for dx in -r..=r {
- if dx * dx + dy * dy > r2 { continue; }
- let x = cx + dx; let y = cy + dy;
- if x < 0 || y < 0 || x >= w || y >= h { continue; }
- grid[(y * w + x) as usize] = true;
- }
- }
- }
-
- fn iou_for_hull(hull: &crate::hulls::Hull, strokes: &[Vec<(f32, f32)>]) -> f32 {
- let bx = hull.bounds.x_min as i32;
- let by = hull.bounds.y_min as i32;
- let bw = (hull.bounds.x_max as i32 - bx + 1).max(1);
- let bh = (hull.bounds.y_max as i32 - by + 1).max(1);
- let n = (bw * bh) as usize;
- let mut source = vec![false; n];
- for &(x, y) in &hull.pixels {
- let lx = x as i32 - bx; let ly = y as i32 - by;
- if lx < 0 || ly < 0 || lx >= bw || ly >= bh { continue; }
- source[(ly * bw + lx) as usize] = true;
- }
- let mut drawn = vec![false; n];
- // Dilate strokes by half the source thickness (4 px) to compare
- // a centerline to a filled glyph.
- let radius = 2;
- 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 steps = (len * 2.0).ceil().max(1.0) as i32;
- for i in 0..=steps {
- let t = i as f32 / steps as f32;
- let px = a.0 + dx * t;
- let py = a.1 + dy * t;
- stamp_disc(&mut drawn, bw, bh,
- px as i32 - bx, py as i32 - by, radius);
- }
- }
- }
- let mut inter = 0u32;
- let mut union = 0u32;
- for i in 0..n {
- if source[i] && drawn[i] { inter += 1; }
- if source[i] || drawn[i] { union += 1; }
- }
- if union == 0 { 1.0 } else { inter as f32 / union as f32 }
- }
-
- /// Multi-scale loss. Evaluates the same alphabet at three font/DPI
- /// pairs that bracket the realistic plotting range:
- /// - 3mm @ 150dpi / 3px thickness (small text, the failing case)
- /// - 5mm @ 200dpi / 4px thickness (mid)
- /// - 8mm @ 200dpi / 4px thickness (large)
- /// Average loss across the three is what we minimise. This keeps params
- /// generalising across scales instead of overfitting to one.
- fn alphabet_loss(params: &StreamlineParams) -> f32 {
- let scales: &[(f32, u32, u32)] = &[
- (3.0, 150, 3),
- (5.0, 200, 4),
- (8.0, 200, 4),
- ];
- let mut total = 0.0_f32;
- for &(font_mm, dpi, thick) in scales {
- total += alphabet_loss_at(params, font_mm, dpi, thick);
- }
- total / scales.len() as f32
- }
-
- fn alphabet_loss_at(params: &StreamlineParams,
- font_mm: f32, dpi: u32, thick: u32) -> f32 {
- let chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
- let cap = params.max_strokes as usize;
- let mut total = 0.0_f32;
- let mut count = 0;
- 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 = streamline_fill_with(main, params);
- let n = r.strokes.len();
- let iou = iou_for_hull(main, &r.strokes);
- let total_len: f32 = r.strokes.iter().map(|s| {
- s.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::()
- }).sum();
- let bw = (main.bounds.x_max - main.bounds.x_min) as f32;
- let bh = (main.bounds.y_max - main.bounds.y_min) as f32;
- let perim = 2.0 * (bw + bh);
- let overdraw = (total_len - perim).max(0.0) / perim.max(1.0);
-
- let count_pen = (n as f32 - 1.0).max(0.0);
- let cap_pen = if n >= cap { 30.0 } else { 0.0 };
- let cov_pen = (1.0 - iou) * 40.0;
- let over_pen = overdraw * 8.0;
- total += count_pen + cap_pen + cov_pen + over_pen;
- count += 1;
- }
- total / count.max(1) as f32
- }
-
- /// One axis of the search space.
- struct Dim {
- name: &'static str,
- get: fn(&StreamlineParams) -> f32,
- set: fn(&mut StreamlineParams, f32),
- values: &'static [f32],
- }
-
- fn search_dims() -> Vec {
- vec![
- Dim { name: "ridge_lerp", get: |p| p.ridge_lerp,
- set: |p, v| p.ridge_lerp = v,
- values: &[0.1, 0.2, 0.3, 0.45, 0.6, 0.8] },
- Dim { name: "center_strength", get: |p| p.center_strength,
- set: |p, v| p.center_strength = v,
- values: &[0.0, 0.05, 0.1, 0.2, 0.3, 0.5] },
- Dim { name: "speed", get: |p| p.speed,
- set: |p, v| p.speed = v,
- values: &[0.5, 0.75, 1.0, 1.5, 2.0] },
- Dim { name: "dt", get: |p| p.dt,
- set: |p, v| p.dt = v,
- values: &[0.25, 0.4, 0.5, 0.7, 1.0] },
- Dim { name: "min_clearance", get: |p| p.min_clearance,
- set: |p, v| p.min_clearance = v,
- values: &[0.2, 0.3, 0.4, 0.6, 0.9] },
- Dim { name: "pivot_threshold", get: |p| p.pivot_threshold,
- set: |p, v| p.pivot_threshold = v,
- values: &[0.2, 0.3, 0.4, 0.5, 0.7, 1.0] },
- Dim { name: "lookahead_radius",get: |p| p.lookahead_radius,
- set: |p, v| p.lookahead_radius = v,
- values: &[3.0, 5.0, 7.0, 10.0, 15.0] },
- Dim { name: "pivot_steer_rate",get: |p| p.pivot_steer_rate,
- set: |p, v| p.pivot_steer_rate = v,
- values: &[0.2, 0.4, 0.6, 0.8, 1.0] },
- Dim { name: "min_pivot_score", get: |p| p.min_pivot_score,
- set: |p, v| p.min_pivot_score = v,
- values: &[0.2, 0.4, 0.6, 0.8, 1.2] },
- Dim { name: "visited_radius", get: |p| p.visited_radius,
- set: |p, v| p.visited_radius = v,
- values: &[0.5, 0.8, 1.0, 1.2, 1.5, 2.0] },
- Dim { name: "loop_close_radius", get: |p| p.loop_close_radius,
- set: |p, v| p.loop_close_radius = v,
- values: &[1.0, 2.0, 3.0, 5.0] },
- Dim { name: "min_loop_distance", get: |p| p.min_loop_distance,
- set: |p, v| p.min_loop_distance = v,
- values: &[10.0, 20.0, 30.0, 50.0] },
- Dim { name: "min_stroke_length", get: |p| p.min_stroke_length,
- set: |p, v| p.min_stroke_length = v,
- values: &[0.5, 1.0, 2.0, 4.0] },
- ]
- }
-
- #[test]
- #[ignore]
- fn streamline_optimize() {
- let mut best = StreamlineParams::default();
- let mut best_loss = alphabet_loss(&best);
- println!("\nInitial loss: {:.3}", best_loss);
- let dims = search_dims();
- for pass in 1..=3 {
- println!("\n── Pass {} ──", pass);
- for d in &dims {
- let saved = (d.get)(&best);
- let mut local_best = saved;
- let mut local_loss = best_loss;
- for &v in d.values {
- let mut trial = best.clone();
- (d.set)(&mut trial, v);
- let l = alphabet_loss(&trial);
- if l < local_loss { local_loss = l; local_best = v; }
- }
- if local_loss < best_loss - 1e-3 {
- (d.set)(&mut best, local_best);
- println!(" {:>20} {:.3} → {:.3} loss {:.3} → {:.3}",
- d.name, saved, local_best, best_loss, local_loss);
- best_loss = local_loss;
- } else {
- println!(" {:>20} {:.3} (kept) loss {:.3}",
- d.name, saved, best_loss);
- }
- }
- }
- println!("\n══ Optimized params (loss {:.3}) ══", best_loss);
- println!("speed: {:.3}", best.speed);
- println!("dt: {:.3}", best.dt);
- println!("ridge_lerp: {:.3}", best.ridge_lerp);
- println!("center_strength: {:.3}", best.center_strength);
- println!("min_clearance: {:.3}", best.min_clearance);
- println!("pivot_threshold: {:.3}", best.pivot_threshold);
- println!("lookahead_radius: {:.3}", best.lookahead_radius);
- println!("pivot_steer_rate: {:.3}", best.pivot_steer_rate);
- println!("min_pivot_score: {:.3}", best.min_pivot_score);
- println!("visited_radius: {:.3}", best.visited_radius);
- println!("loop_close_radius: {:.3}", best.loop_close_radius);
- println!("min_loop_distance: {:.3}", best.min_loop_distance);
- println!("min_stroke_length: {:.3}", best.min_stroke_length);
- }
-
- /// Diagnostic only — never asserts anything strong; prints a per-letter
- /// stroke-count + total-points report so we can see where the algorithm
- /// is fragmenting glyphs vs producing clean strokes. Run with
- /// cargo test --lib streamline_alphabet_report -- --nocapture
- #[test]
- #[ignore]
- fn streamline_alphabet_report() {
- let chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
- let params = StreamlineParams::default();
- // Run at all three scales the optimizer trains on.
- 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);
- run_alphabet_report(chars, ¶ms, font_mm, dpi, thick);
- }
- }
-
- fn run_alphabet_report(chars: &str, params: &StreamlineParams,
- font_mm: f32, dpi: u32, thick: u32) {
- let mut total_strokes = 0;
- let mut counts: Vec<(char, usize, usize, f32)> = 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 => { println!("'{}': no hull", ch); continue; }
- };
- let r = streamline_fill_with(main, ¶ms);
- let n = r.strokes.len();
- let pts: usize = r.strokes.iter().map(|s| s.len()).sum();
- // Average stroke length (Euclidean) — cheap quality proxy.
- let avg_len = if n == 0 { 0.0 } else {
- let total_len: f32 = r.strokes.iter().map(|s| {
- s.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::()
- }).sum();
- total_len / n as f32
- };
- counts.push((ch, n, pts, avg_len));
- total_strokes += n;
- println!("'{}': {:>2} strokes · {:>4} pts · avg-len {:>5.1}px",
- ch, n, pts, avg_len);
- }
- let avg = total_strokes as f32 / counts.len() as f32;
- let worst: Vec<_> = counts.iter().filter(|&&(_, n, _, _)| n >= 6).collect();
- println!("\nTotal: {} strokes across {} chars (avg {:.1}/char)",
- total_strokes, counts.len(), avg);
- println!("Fragmented (≥6 strokes): {:?}",
- worst.iter().map(|t| (t.0, t.1)).collect::>());
- }
-}