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 ( -
- 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." /> -
- -
-
- - setSourceOpacity(parseFloat(e.target.value))} - className="w-full" /> -
-
- - setSdfOpacity(parseFloat(e.target.value))} - className="w-full" /> -
-
- -
-

Zoom: wheel · Pan: drag · Shift+drag: copy region

- -
-
· {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::>()); - } -}