From 2f9b80c9f130f7215fd5dfcb790d3a16b552423a Mon Sep 17 00:00:00 2001 From: mitchellhansen Date: Sun, 26 Apr 2026 22:15:29 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20Fill=20as=20a=20graph=20node=20?= =?UTF-8?q?=E2=80=94=20pipeline=20is=20now=20fully=20Source=E2=86=92Kernel?= =?UTF-8?q?=E2=86=92Hull=E2=86=92Fill?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fill extraction moves from the sidebar and a separate generate_fill Tauri command into a first-class graph node. process_pass now runs the complete pipeline: detect → hull → fill in one shot. detect.rs: NodeKind::Fill { strategy, spacing, angle, param, smooth_rdp, smooth_iters } Fill nodes return None in evaluate_graph (processed in lib.rs post-hull). lib.rs: GraphNodePayload: 6 new optional fill fields. ProcessResult gains stroke_count. to_detection_graph: Fill case. render_fill_preview: Bresenham line rasterizer → 256×256 grayscale JPEG for the Fill node thumbnail. process_pass_work: 4-tuple return; after Hull processing, iterates Fill nodes, looks up upstream hull set, runs the full fill strategy (including gradient_hatch), applies smoothing + travel optimisation, stores a preview. process_pass command stores fill_results from the 4-tuple. Removed: FillPayload, FillResult (IPC), generate_fill_work, generate_fill command, generate_fill mutex test. Frontend: store.js: defaultFillParams(), Fill node in defaultGraph() at x=840 wired from Hull. Removed strategy/spacing/angle/param/smoothRdp/smoothIters/ filling from defaultPass. NodeGraph: Full Fill node UI — strategy pill buttons (resets param to strategy default on switch), spacing, angle (conditional), secondary param (conditional), smooth RDP, Chaikin; purple accent; fixed/non-deletable; 1 input port, no output port. Header label shows current strategy name. PassPanel: Fill section removed entirely; onFillChange prop removed; Section/Slider/fill-strategy imports removed; C colour object removed. App.jsx: generateFillInner/generateFill/scheduleFill removed; processPass reads stroke_count and calls getAllStrokes directly; filling guard removed from contours case and useEffect deps. useTauri.js: generateFill export removed. Co-Authored-By: Claude Sonnet 4.6 --- src-frontend/src/App.jsx | 61 +---- src-frontend/src/components/NodeGraph.jsx | 48 +++- src-frontend/src/components/PassPanel.jsx | 49 ---- src-frontend/src/hooks/useTauri.js | 4 - src-frontend/src/store.js | 18 +- src/detect.rs | 11 + src/lib.rs | 309 ++++++++++------------ 7 files changed, 203 insertions(+), 297 deletions(-) diff --git a/src-frontend/src/App.jsx b/src-frontend/src/App.jsx index 1ecc9354..baf444dd 100644 --- a/src-frontend/src/App.jsx +++ b/src-frontend/src/App.jsx @@ -87,9 +87,6 @@ export default function App() { setDisplayB64(passes[activePass]?.vizB64 ?? null) break case 'contours': - // Don't race getPassViz against generateFill — both need the AppState mutex. - // filling=true means fill hasn't finished yet; the effect will re-run when it does. - if (passes[activePass]?.filling) { setDisplayB64(null); break } if (passes[activePass]?.hullCount > 0) { try { const tv = performance.now() @@ -126,7 +123,7 @@ export default function App() { } } refresh() - }, [viewMode, activePass, image, passes[activePass]?.vizB64, passes[activePass]?.hullCount, passes[activePass]?.filling, totalStrokeCount]) + }, [viewMode, activePass, image, passes[activePass]?.vizB64, passes[activePass]?.hullCount, totalStrokeCount]) // ── File open ────────────────────────────────────────────────────────────── async function openImage() { @@ -166,13 +163,14 @@ export default function App() { const js_process = Math.round(performance.now() - t0) setPerfData(pd => ({ ...(pd ?? {}), process: result.timings, js_process })) updatePass(idx, { - status: `${result.hull_count} hulls · ${result.coverage_pct}% coverage`, + status: `${result.hull_count} hulls · ${result.stroke_count} strokes`, vizB64: result.viz_b64, hullCount: result.hull_count, - strokeCount: 0, + strokeCount: result.stroke_count, nodePreviews: result.node_previews ?? {}, }) - await generateFillInner(idx, true) + const colors = passesRef.current.map(p => p.penColor) + tauri.getAllStrokes(colors).then(s => setStrokes(s)).catch(() => {}) } catch (e) { updatePass(idx, { status: `Error: ${e}` }) setGlobalStatus(`Process error: ${e}`) @@ -180,44 +178,6 @@ export default function App() { if (!silent) setBusy(false) }, []) // stable — uses refs - // Inner fill logic shared by both manual and auto paths - const generateFillInner = useCallback(async (idx, silent = false) => { - if (!silent) setBusy(true) - const pass = passesRef.current[idx] - // Set filling=true BEFORE the first await so React batches it with any - // concurrent hullCount update, preventing the viz useEffect from racing - // generateFill for the same AppState mutex. - updatePass(idx, { filling: true, status: 'Generating fill…' }) - const t1 = performance.now() - try { - const result = await tauri.generateFill({ - pass_index: idx, - strategy: pass.strategy, - spacing: pass.spacing, - angle: pass.angle, - param: pass.param ?? 1.0, - smooth_rdp: pass.smoothRdp, - smooth_iters: pass.smoothIters, - }) - const js_fill = Math.round(performance.now() - t1) - setPerfData(pd => ({ ...(pd ?? {}), fill: result.timings, js_fill })) - updatePass(idx, { - filling: false, - status: `${result.stroke_count} strokes`, - strokeCount: result.stroke_count, - }) - // Fetch full stroke data for canvas rendering (no subsampling — WYSIWYG) - const colors = passesRef.current.map(p => p.penColor) - tauri.getAllStrokes(colors).then(s => setStrokes(s)).catch(() => {}) - } catch (e) { - updatePass(idx, { filling: false, status: `Error: ${e}` }) - setGlobalStatus(`Fill error: ${e}`) - } - if (!silent) setBusy(false) - }, []) // stable — uses refs - - const generateFill = useCallback((idx) => generateFillInner(idx, false), [generateFillInner]) - // ── Debounced auto-reprocess triggered by slider changes ─────────────────── const scheduleProcess = useCallback((idx) => { const key = `${idx}-detect` @@ -225,16 +185,6 @@ export default function App() { debounceTimers.current[key] = setTimeout(() => processPass(idx, true), 400) }, [processPass]) - const scheduleFill = useCallback((idx) => { - const key = `${idx}-fill` - clearTimeout(debounceTimers.current[key]) - debounceTimers.current[key] = setTimeout(() => { - if ((passesRef.current[idx]?.hullCount ?? 0) > 0) { - generateFillInner(idx, true) - } - }, 400) - }, [generateFillInner]) - // ── Export ───────────────────────────────────────────────────────────────── async function exportActivePass() { const pass = passes[activePass] @@ -354,7 +304,6 @@ export default function App() { pass={passes[activePass]} onChange={p => updatePass(activePass, p)} onDetectionChange={() => scheduleProcess(activePass)} - onFillChange={() => scheduleFill(activePass)} /> diff --git a/src-frontend/src/components/NodeGraph.jsx b/src-frontend/src/components/NodeGraph.jsx index 8bdc500e..29950959 100644 --- a/src-frontend/src/components/NodeGraph.jsx +++ b/src-frontend/src/components/NodeGraph.jsx @@ -1,6 +1,6 @@ import { useRef, useState, useCallback, useEffect } from 'react' import Slider from './Slider.jsx' -import { KERNELS, BLEND_MODES, defaultKernelProps, defaultHullParams, defaultColorFilter, newNodeId } from '../store.js' +import { KERNELS, BLEND_MODES, defaultKernelProps, defaultHullParams, defaultFillParams, defaultColorFilter, newNodeId, FILL_STRATEGIES, FILL_STRATEGY_PARAMS, FILL_USES_ANGLE } from '../store.js' import ColorFilter from './ColorFilter.jsx' // ── Layout constants ─────────────────────────────────────────────────────────── @@ -222,18 +222,20 @@ export default function NodeGraph({ graph, onChange, nodePreviews, sourceImageB6 // ── Node rendering ───────────────────────────────────────────────────────── function renderNode(node) { - const isFixed = node.kind === 'Source' || node.kind === 'Hull' + const isFixed = node.kind === 'Source' || node.kind === 'Hull' || node.kind === 'Fill' const inputCnt = node.kind === 'Combine' ? (node.inputCount ?? 2) - : (node.kind === 'Kernel' || node.kind === 'Output' || node.kind === 'Hull') ? 1 : 0 - const hasOut = node.kind !== 'Output' && node.kind !== 'Hull' + : (node.kind === 'Kernel' || node.kind === 'Output' || node.kind === 'Hull' || node.kind === 'Fill') ? 1 : 0 + const hasOut = node.kind !== 'Output' && node.kind !== 'Hull' && node.kind !== 'Fill' const preview = node.kind === 'Source' ? sourceImageB64 : nodePreviews?.[node.id] const accentColor = node.kind === 'Source' ? '#7c3aed' : node.kind === 'Hull' ? '#0d9488' + : node.kind === 'Fill' ? '#9333ea' : '#374151' const headerBg = node.kind === 'Source' ? '#2e1065' : node.kind === 'Hull' ? '#042f2e' + : node.kind === 'Fill' ? '#3b0764' : '#1e293b' return ( @@ -280,6 +282,7 @@ export default function NodeGraph({ graph, onChange, nodePreviews, sourceImageB6 {node.kind === 'Source' ? 'Source' : node.kind === 'Hull' ? 'Hull' + : node.kind === 'Fill' ? (node.strategy ?? 'Fill') : node.kind === 'Kernel' ? (node.kernel ?? 'Kernel') : node.kind === 'Combine' ? 'Combine' : 'Output'} @@ -371,6 +374,43 @@ export default function NodeGraph({ graph, onChange, nodePreviews, sourceImageB6 )} + {node.kind === 'Fill' && (<> + {/* Strategy selector */} +
+ {FILL_STRATEGIES.map(s => ( + + ))} +
+ updateNode(node.id, { spacing: v })} /> + {FILL_USES_ANGLE.has(node.strategy ?? 'hatch') && ( + updateNode(node.id, { angle: v })} /> + )} + {FILL_STRATEGY_PARAMS[node.strategy ?? 'hatch'] && (() => { + const p = FILL_STRATEGY_PARAMS[node.strategy] + return ( + updateNode(node.id, { param: v })} /> + ) + })()} + updateNode(node.id, { smooth_rdp: v })} /> + updateNode(node.id, { smooth_iters: v })} /> + )} + {/* Preview thumbnail */} {preview && ( c.toString(16).padStart(2, '0')).join('') const isProcessing = pass.status === 'Processing…' @@ -44,42 +31,6 @@ export default function PassPanel({ )} - {/* ── Fill ── how hulls become strokes */} -
-
- {FILL_STRATEGIES.map(s => ( - - ))} -
- setFill({ spacing: v })} /> - {FILL_USES_ANGLE.has(pass.strategy) && ( - setFill({ angle: v })} /> - )} - {FILL_STRATEGY_PARAMS[pass.strategy] && (() => { - const p = FILL_STRATEGY_PARAMS[pass.strategy] - return ( - setFill({ param: v })} /> - ) - })()} -
- setFill({ smoothRdp: v })} /> - setFill({ smoothIters: v })} /> -
-
- {/* Status */}

diff --git a/src-frontend/src/hooks/useTauri.js b/src-frontend/src/hooks/useTauri.js index 404360df..c852730f 100644 --- a/src-frontend/src/hooks/useTauri.js +++ b/src-frontend/src/hooks/useTauri.js @@ -21,10 +21,6 @@ export async function processPass(payload) { return tracedInvoke('process_pass', { payload }) } -export async function generateFill(payload) { - return tracedInvoke('generate_fill', { payload }) -} - export async function getAllStrokes(passColors) { return tracedInvoke('get_all_strokes', { passColors }) } diff --git a/src-frontend/src/store.js b/src-frontend/src/store.js index 1fbf6839..8a5cd033 100644 --- a/src-frontend/src/store.js +++ b/src-frontend/src/store.js @@ -38,6 +38,13 @@ export function defaultColorFilter() { return { enabled: false, hue_min: 0, hue_max: 360, sat_min: 0, sat_max: 1, val_min: 0, val_max: 1 } } +export function defaultFillParams() { + return { + strategy: 'hatch', spacing: 5, angle: 0, param: 1.0, + smooth_rdp: 1.0, smooth_iters: 2, + } +} + export function defaultHullParams() { return { threshold: 128, min_area: 4, rdp_epsilon: 1.5, connectivity: 'four', @@ -51,11 +58,13 @@ export function defaultGraph() { nodes: [ { id: 'source', kind: 'Source', x: 60, y: 160 }, { id: kId, kind: 'Kernel', x: 310, y: 100, ...defaultKernelProps() }, - { id: 'hull', kind: 'Hull', x: 580, y: 160, ...defaultHullParams() }, + { id: 'hull', kind: 'Hull', x: 560, y: 160, ...defaultHullParams() }, + { id: 'fill', kind: 'Fill', x: 840, y: 160, ...defaultFillParams() }, ], edges: [ { from: 'source', to: kId, port: 0 }, { from: kId, to: 'hull', port: 0 }, + { from: 'hull', to: 'fill', port: 0 }, ], } } @@ -67,18 +76,11 @@ export function defaultPass(index) { penColor: colors[index] ?? [128,128,128], graph: defaultGraph(), nodePreviews: {}, - strategy: 'hatch', - spacing: 5, - angle: 0, - param: 1.0, - smoothRdp: 1.0, - smoothIters: 2, // runtime status: 'Not processed', vizB64: null, hullCount: 0, strokeCount: 0, - filling: false, } } diff --git a/src/detect.rs b/src/detect.rs index 8183671f..84835dd1 100644 --- a/src/detect.rs +++ b/src/detect.rs @@ -434,6 +434,14 @@ pub enum NodeKind { cf_sat_min: f32, cf_sat_max: f32, cf_val_min: f32, cf_val_max: f32, }, + Fill { + strategy: String, + spacing: f32, + angle: f32, + param: f32, + smooth_rdp: f32, + smooth_iters: u32, + }, } #[derive(Debug, Clone)] @@ -552,6 +560,8 @@ pub fn evaluate_graph( incoming[id].iter() .find_map(|(fid, _)| outputs.get(fid).cloned()) } + // Fill nodes are processed in lib.rs after hull extraction. + NodeKind::Fill { .. } => None, }; if let Some(map) = result { outputs.insert(id, map); @@ -567,6 +577,7 @@ pub fn evaluate_graph( // Final response: prefer an explicit Output node; fall back to the upstream // map of the first Hull node (which was stored under the Hull node's id). + // Fill nodes produce no output here. let response = graph.nodes.iter() .find(|n| matches!(n.kind, NodeKind::Output)) .and_then(|n| raw_maps.get(&n.id).cloned()) diff --git a/src/lib.rs b/src/lib.rs index 61132f7c..53102aa3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -74,6 +74,13 @@ pub struct GraphNodePayload { pub rdp_epsilon: Option, pub connectivity: Option, pub color_filter: Option, + // Fill params (optional — only for kind="Fill") + pub strategy: Option, + pub spacing: Option, + pub angle: Option, + pub param: Option, + pub smooth_rdp: Option, + pub smooth_iters: Option, } #[derive(Deserialize, Clone, Debug)] @@ -116,37 +123,12 @@ pub struct StepTime { pub struct ProcessResult { pub hull_count: usize, pub coverage_pct: usize, + pub stroke_count: usize, pub viz_b64: String, pub node_previews: std::collections::HashMap, pub timings: Vec, } -#[derive(Deserialize, Clone, Debug)] -pub struct FillPayload { - pub pass_index: usize, - pub strategy: String, - pub spacing: f32, - pub angle: f32, - /// Strategy-specific secondary parameter: - /// circles → min_radius_factor (default 1.0) - /// waves → num_sources (default 5) - /// flow → amplitude_scale (default 1.0) - #[serde(default = "default_param")] - pub param: f32, - pub smooth_rdp: f32, - pub smooth_iters: u32, -} -fn default_param() -> f32 { 1.0 } - -#[derive(Serialize)] -pub struct FillResult { - pub stroke_count: usize, - pub timings: Vec, - /// All passes' strokes serialised as flat arrays for the canvas renderer: - /// [[pass_idx, r, g, b, x0, y0, x1, y1, ...], ...] - pub strokes_json: String, -} - #[derive(Deserialize, Clone, Debug)] pub struct GcodeConfigPayload { pub paper_w_mm: f32, @@ -211,6 +193,14 @@ fn to_detection_graph(payload: &DetectionGraphPayload) -> detect::DetectionGraph ); detect::NodeKind::Combine(mode) } + "Fill" => detect::NodeKind::Fill { + strategy: n.strategy.clone().unwrap_or_else(|| "hatch".into()), + spacing: n.spacing.unwrap_or(5.0), + angle: n.angle.unwrap_or(0.0), + param: n.param.unwrap_or(1.0), + smooth_rdp: n.smooth_rdp.unwrap_or(1.0), + smooth_iters: n.smooth_iters.unwrap_or(2), + }, "Hull" => { let cf = n.color_filter.as_ref(); detect::NodeKind::Hull { @@ -308,6 +298,40 @@ fn rgb_to_b64_jpeg(rgb: &image::RgbImage) -> String { // ── Pipeline inner functions (no Tauri, no mutex) ───────────────────────────── +/// Rasterize fill strokes into a small JPEG preview (256×256). +fn render_fill_preview(result: &fill::FillResult, img_w: u32, img_h: u32) -> String { + const P: u32 = 256; + let sx = P as f32 / img_w.max(1) as f32; + let sy = P as f32 / img_h.max(1) as f32; + let mut pix = vec![20u8; (P * P) as usize]; + + for stroke in &result.strokes { + for pair in stroke.windows(2) { + let (mut x, mut y) = ((pair[0].0 * sx).round() as i32, (pair[0].1 * sy).round() as i32); + let (x1, y1) = ((pair[1].0 * sx).round() as i32, (pair[1].1 * sy).round() as i32); + let dx = (x1 - x).abs(); let sx_ = if x < x1 { 1i32 } else { -1 }; + let dy = -(y1 - y).abs(); let sy_ = if y < y1 { 1i32 } else { -1 }; + let mut err = dx + dy; + loop { + if x >= 0 && y >= 0 && (x as u32) < P && (y as u32) < P { + pix[(y as u32 * P + x as u32) as usize] = 210; + } + if x == x1 && y == y1 { break; } + let e2 = 2 * err; + if e2 >= dy { err += dy; x += sx_; } + if e2 <= dx { err += dx; y += sy_; } + } + } + } + + let img = image::GrayImage::from_raw(P, P, pix).expect("fill preview buffer"); + let mut buf = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageLuma8(img) + .write_to(&mut buf, image::ImageFormat::Jpeg) + .unwrap(); + B64.encode(buf.into_inner()) +} + fn render_hull_preview(response: &[u8], hulls_list: &[hulls::Hull], w: u32, h: u32) -> String { let mut rgba = vec![15u8; (w * h * 4) as usize]; for chunk in rgba.chunks_mut(4) { chunk[3] = 255; } @@ -328,7 +352,9 @@ fn render_hull_preview(response: &[u8], hulls_list: &[hulls::Hull], w: u32, h: u fn process_pass_work( rgb: &image::RgbImage, payload: ProcessPassPayload, -) -> (Vec, Vec, ProcessResult) { +) -> (Vec, Vec, Vec, ProcessResult) { + use rayon::prelude::*; + let t0 = Instant::now(); let mut steps: Vec = Vec::new(); let (w, h) = rgb.dimensions(); @@ -346,16 +372,18 @@ fn process_pass_work( det_graph.nodes.iter().find(|n| &n.id == *id) .map_or(false, |n| !matches!( n.kind, - detect::NodeKind::Source | detect::NodeKind::Hull { .. } + detect::NodeKind::Source | detect::NodeKind::Hull { .. } | detect::NodeKind::Fill { .. } )) }) .map(|(id, map)| (id.clone(), map_to_b64_small(map, w, h))) .collect(); t = lap!(steps, "detect previews", t); - // Process every Hull node in the graph - let mut all_hulls: Vec = Vec::new(); - let mut first_hull_response: Option> = None; + // ── Hull nodes ───────────────────────────────────────────────────────────── + let mut all_hulls: Vec = Vec::new(); + let mut hull_outputs: std::collections::HashMap> = Default::default(); + let mut hull_resp_maps: std::collections::HashMap> = Default::default(); + let mut first_hull_response: Option> = None; let mut first_hull_threshold: u8 = 128; for node in &det_graph.nodes { @@ -364,18 +392,16 @@ fn process_pass_work( cf_enabled, cf_hue_min, cf_hue_max, cf_sat_min, cf_sat_max, cf_val_min, cf_val_max, } = &node.kind { - // The upstream Map was stored under the Hull node's own id let response = match graph_maps.raw_maps.get(&node.id) { Some(m) => m, - None => continue, // disconnected hull node — skip + None => continue, }; - let hull_params = hulls::HullParams { threshold: *threshold, min_area: *min_area, rdp_epsilon: *rdp_epsilon, connectivity: if *eight_conn { hulls::Connectivity::Eight } - else { hulls::Connectivity::Four }, + else { hulls::Connectivity::Four }, }; let color_filter = hulls::ColorFilter { enabled: *cf_enabled, @@ -383,29 +409,78 @@ fn process_pass_work( sat_min: *cf_sat_min, sat_max: *cf_sat_max, val_min: *cf_val_min, val_max: *cf_val_max, }; - let extracted = hulls::extract_hulls(response, rgb, w, h, &hull_params); let filtered = hulls::filter_hulls_by_color(extracted, &color_filter); - - // Hull node thumbnail — gradient hue by response intensity node_previews.insert(node.id.clone(), render_hull_preview(response, &filtered, w, h)); - if first_hull_response.is_none() { first_hull_response = Some(response.clone()); first_hull_threshold = *threshold; } + hull_outputs.insert(node.id.clone(), filtered.clone()); + hull_resp_maps.insert(node.id.clone(), response.clone()); all_hulls.extend(filtered); } } t = lap!(steps, "hull extract", t); - // Coverage and binary viz use the first Hull node's data + // ── Fill nodes ───────────────────────────────────────────────────────────── + let mut all_fill_results: Vec = Vec::new(); + + for node in &det_graph.nodes { + if let detect::NodeKind::Fill { + strategy, spacing, angle, param, smooth_rdp, smooth_iters + } = &node.kind { + let upstream = det_graph.edges.iter() + .find(|e| e.to == node.id && e.port == 0); + let (hulls_for_fill, resp_for_fill) = match upstream { + Some(e) => ( + hull_outputs.get(&e.from).cloned().unwrap_or_default(), + hull_resp_maps.get(&e.from).cloned().unwrap_or_default(), + ), + None => (vec![], vec![]), + }; + if hulls_for_fill.is_empty() { continue; } + + let response_arc: std::sync::Arc<[u8]> = resp_for_fill.into(); + let (strategy, spacing, angle, param, smooth_rdp, smooth_iters) = + (strategy.clone(), *spacing, *angle, *param, *smooth_rdp, *smooth_iters); + let img_w = w; + + let raw: Vec = hulls_for_fill.par_iter().map(|hull| { + match strategy.as_str() { + "outline" => fill::outline(hull), + "zigzag" => fill::zigzag_hatch(hull, spacing, angle), + "offset" => fill::contour_offset(hull, spacing), + "spiral" => fill::spiral(hull, spacing), + "circles" => fill::circle_pack(hull, spacing, param.max(0.1)), + "voronoi" => fill::voronoi_fill(hull, spacing), + "hilbert" => fill::hilbert_fill(hull, spacing), + "waves" => fill::wave_interference(hull, spacing, param.round().max(1.0) as usize), + "flow" => fill::flow_field(hull, spacing, angle, param.max(0.0)), + "gradient_hatch" => fill::gradient_hatch(hull, &response_arc, img_w, spacing, angle, param.clamp(0.05, 1.0)), + _ => fill::parallel_hatch(hull, spacing, angle), + } + }).collect(); + + let smoothed: Vec = raw.iter() + .map(|r| fill::smooth_fill_result(r, smooth_rdp, smooth_iters)) + .collect(); + let optimised = fill::optimize_travel(&smoothed); + + node_previews.insert(node.id.clone(), render_fill_preview(&optimised, w, h)); + all_fill_results.push(optimised); + } + } + t = lap!(steps, "fill", t); + + // ── Coverage + binary viz ────────────────────────────────────────────────── let response_for_viz = first_hull_response.as_deref().unwrap_or(&graph_maps.response); let threshold = first_hull_threshold; let total_dark = response_for_viz.iter().filter(|&&p| p < threshold).count(); let hull_px: usize = all_hulls.iter().map(|h| h.pixels.len()).sum(); let coverage_pct = if total_dark > 0 { hull_px * 100 / total_dark } else { 0 }; + let stroke_count: usize = all_fill_results.iter().map(|r| r.strokes.len()).sum(); let mut rgba = vec![0u8; (w * h * 4) as usize]; for (i, &r) in response_for_viz.iter().enumerate() { @@ -414,65 +489,15 @@ fn process_pass_work( } t = lap!(steps, "viz build", t); - let viz_b64 = rgba_to_b64_png(&rgba, w, h); - let hull_count = all_hulls.len(); + let viz_b64 = rgba_to_b64_png(&rgba, w, h); lap!(steps, "png encode", t); steps.push(StepTime { label: "total".into(), ms: t0.elapsed().as_millis() as u64 }); - // response_map for gradient fill = the map fed into the first Hull node + let hull_count = all_hulls.len(); let response_map = first_hull_response.unwrap_or_else(|| graph_maps.response); - (all_hulls, response_map, ProcessResult { hull_count, coverage_pct, viz_b64, node_previews, timings: steps }) -} - -fn generate_fill_work( - hulls: Vec, - response_map: Vec, - img_width: u32, - payload: FillPayload, -) -> (Vec, FillResult) { - use rayon::prelude::*; - - let t0 = Instant::now(); - let strategy = payload.strategy.clone(); - let spacing = payload.spacing; - let angle = payload.angle; - let param = payload.param; - let mut steps: Vec = Vec::new(); - - // Share the response map across rayon threads without cloning it per-hull - let response_arc: std::sync::Arc<[u8]> = response_map.into(); - - let mut t = Instant::now(); - let raw_results: Vec = hulls.par_iter().map(|hull| { - match strategy.as_str() { - "outline" => fill::outline(hull), - "zigzag" => fill::zigzag_hatch(hull, spacing, angle), - "offset" => fill::contour_offset(hull, spacing), - "spiral" => fill::spiral(hull, spacing), - "circles" => fill::circle_pack(hull, spacing, param.max(0.1)), - "voronoi" => fill::voronoi_fill(hull, spacing), - "hilbert" => fill::hilbert_fill(hull, spacing), - "waves" => fill::wave_interference(hull, spacing, param.round().max(1.0) as usize), - "flow" => fill::flow_field(hull, spacing, angle, param.max(0.0)), - "gradient_hatch" => fill::gradient_hatch(hull, &response_arc, img_width, spacing, angle, param.clamp(0.05, 1.0)), - _ => fill::parallel_hatch(hull, spacing, angle), - } - }).collect(); - t = lap!(steps, "fill gen", t); - - let smoothed: Vec = raw_results.iter() - .map(|r| fill::smooth_fill_result(r, payload.smooth_rdp, payload.smooth_iters)) - .collect(); - t = lap!(steps, "smooth", t); - - let optimised = vec![fill::optimize_travel(&smoothed)]; - lap!(steps, "travel opt", t); - - let stroke_count: usize = optimised.iter().map(|r| r.strokes.len()).sum(); - steps.push(StepTime { label: "total".into(), ms: t0.elapsed().as_millis() as u64 }); - - (optimised, FillResult { stroke_count, strokes_json: String::new(), timings: steps }) + (all_hulls, all_fill_results, response_map, + ProcessResult { hull_count, coverage_pct, stroke_count, viz_b64, node_previews, timings: steps }) } // ── Tauri commands ───────────────────────────────────────────────────────────── @@ -513,7 +538,7 @@ async fn process_pass(payload: ProcessPassPayload, state: State<'_, Mutex>) -> Result { - let idx = payload.pass_index; - - // Clone hulls + response map and release the lock before handing off to the blocking pool. - let (hulls, response_map, img_width) = { - let st = state.lock().unwrap(); - if idx >= st.passes.len() || st.passes[idx].hulls.is_empty() { - return Err("Process image first".into()); - } - let w = st.image_rgb.as_ref().map(|i| i.width()).unwrap_or(0); - (st.passes[idx].hulls.clone(), st.passes[idx].response_map.clone(), w) - }; - - let (optimised, result) = tauri::async_runtime::spawn_blocking(move || { - generate_fill_work(hulls, response_map, img_width, payload) - }) - .await - .map_err(|e| e.to_string())?; - - let mut st = state.lock().unwrap(); - st.passes[idx].fill_results = optimised; - - Ok(result) -} - - #[tauri::command] fn export_gcode( pass_index: usize, @@ -941,6 +939,8 @@ mod blocking_tests { blend_mode: None, threshold: None, min_area: None, rdp_epsilon: None, connectivity: None, color_filter: None, + strategy: None, spacing: None, angle: None, param: None, + smooth_rdp: None, smooth_iters: None, } } @@ -961,14 +961,22 @@ mod blocking_tests { hull.min_area = Some(10); hull.rdp_epsilon = Some(2.0); hull.connectivity = Some("four".into()); + let mut fill_node = node("fill", "Fill"); + fill_node.strategy = Some("hatch".into()); + fill_node.spacing = Some(5.0); + fill_node.angle = Some(0.0); + fill_node.param = Some(1.0); + fill_node.smooth_rdp = Some(1.0); + fill_node.smooth_iters = Some(2); ProcessPassPayload { pass_index: 0, graph: DetectionGraphPayload { - nodes: vec![node("source", "Source"), k1, hull], + nodes: vec![node("source", "Source"), k1, hull, fill_node], edges: vec![ GraphEdgePayload { from: "source".into(), to: "k1".into(), port: 0 }, GraphEdgePayload { from: "k1".into(), to: "hull".into(), port: 0 }, + GraphEdgePayload { from: "hull".into(), to: "fill".into(), port: 0 }, ], }, } @@ -1009,60 +1017,10 @@ mod blocking_tests { "mutex was blocked during heavy processing" ); - let (hulls, _, result) = work.await.unwrap(); + let (hulls, _, _, result) = work.await.unwrap(); assert!(result.timings.iter().any(|t| t.label == "total")); assert!(!hulls.is_empty(), "expected hulls from checkerboard image"); } - - /// Verify that generate_fill_work can run while the AppState mutex is free. - #[tokio::test] - async fn generate_fill_does_not_hold_mutex_during_computation() { - let rgb = synthetic_image(400, 300); - let (hulls, response_map, _) = process_pass_work(&rgb, default_process_payload()); - assert!(!hulls.is_empty(), "need hulls to test fill"); - let img_width = rgb.width(); - - let state = Arc::new(Mutex::new(AppState { - image_rgb: Some(rgb), - image_path: String::new(), - passes: vec![PassState { hulls: hulls.clone(), fill_results: Vec::new(), response_map: response_map.clone() }], - })); - - // Clone hulls and release lock — mirrors what the command handler does. - let work_hulls = { - let st = state.lock().unwrap(); - st.passes[0].hulls.clone() - }; - - let state_for_check = Arc::clone(&state); - let payload = FillPayload { - pass_index: 0, - strategy: "hatch".into(), - spacing: 5.0, - angle: 45.0, - param: 1.0, - smooth_rdp: 0.0, - smooth_iters: 0, - }; - - let work = tokio::task::spawn_blocking(move || { - generate_fill_work(work_hulls, response_map, img_width, payload) - }); - - tokio::time::sleep(Duration::from_millis(5)).await; - - let lock_start = Instant::now(); - { let _g = state_for_check.lock().unwrap(); } - assert!( - lock_start.elapsed() < Duration::from_millis(50), - "mutex was blocked during fill generation" - ); - - let (fill_results, result) = work.await.unwrap(); - assert!(!fill_results.is_empty()); - assert!(result.stroke_count > 0); - assert!(result.timings.iter().any(|t| t.label == "total")); - } } #[cfg(test)] @@ -1504,7 +1462,6 @@ pub fn run() { get_images_dir, set_pass_count, process_pass, - generate_fill, get_all_strokes, get_gcode_viz, get_pass_viz,