feat: Fill as a graph node — pipeline is now fully Source→Kernel→Hull→Fill

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 <noreply@anthropic.com>
This commit is contained in:
2026-04-26 22:15:29 -07:00
parent 949e5b77bb
commit 2f9b80c9f1
7 changed files with 203 additions and 297 deletions

View File

@@ -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)}
/>
</div>
</div>

View File

@@ -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
<span style={{ flex: 1, fontSize: 11, fontWeight: 600, color: '#e2e8f0', letterSpacing: '0.05em', pointerEvents: 'none' }}>
{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
</div>
</>)}
{node.kind === 'Fill' && (<>
{/* Strategy selector */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 2 }}>
{FILL_STRATEGIES.map(s => (
<button key={s} onMouseDown={e => e.stopPropagation()}
onClick={() => updateNode(node.id, {
strategy: s,
param: FILL_STRATEGY_PARAMS[s]?.default ?? 1.0,
})}
style={{
padding: '1px 4px', borderRadius: 3, fontSize: 9, cursor: 'pointer', border: 'none',
background: (node.strategy ?? 'hatch') === s ? '#7e22ce' : '#1e293b',
color: (node.strategy ?? 'hatch') === s ? '#fff' : '#94a3b8',
}}
>{s}</button>
))}
</div>
<Slider label="Spacing" value={node.spacing ?? 5} min={1} max={50} step={0.5} unit="px"
onChange={v => updateNode(node.id, { spacing: v })} />
{FILL_USES_ANGLE.has(node.strategy ?? 'hatch') && (
<Slider label="Angle" value={node.angle ?? 0} min={0} max={360} step={1} unit="°"
onChange={v => updateNode(node.id, { angle: v })} />
)}
{FILL_STRATEGY_PARAMS[node.strategy ?? 'hatch'] && (() => {
const p = FILL_STRATEGY_PARAMS[node.strategy]
return (
<Slider label={p.label} value={node.param ?? p.default}
min={p.min} max={p.max} step={p.step}
onChange={v => updateNode(node.id, { param: v })} />
)
})()}
<Slider label="Smooth RDP" value={node.smooth_rdp ?? 1.0} min={0} max={5} step={0.1}
onChange={v => updateNode(node.id, { smooth_rdp: v })} />
<Slider label="Chaikin" value={node.smooth_iters ?? 2} min={0} max={4} step={1}
onChange={v => updateNode(node.id, { smooth_iters: v })} />
</>)}
{/* Preview thumbnail */}
{preview && (
<img src={`data:image/jpeg;base64,${preview}`} alt="" draggable={false}

View File

@@ -1,22 +1,9 @@
import Section from './Section.jsx'
import Slider from './Slider.jsx'
import { FILL_STRATEGIES, FILL_STRATEGY_PARAMS, FILL_USES_ANGLE } from '../store.js'
// Colors that match the view-mode tab dots in the top bar
const C = {
detection: '#6366f1', // indigo
hulls: '#14b8a6', // teal
fill: '#a855f7', // purple
}
export default function PassPanel({
pass, onChange,
onDetectionChange,
onFillChange,
}) {
function set(patch) { onChange({ ...pass, ...patch }) }
function setDetection(patch) { onChange({ ...pass, ...patch }); onDetectionChange?.() }
function setFill(patch) { onChange({ ...pass, ...patch }); onFillChange?.() }
const colorHex = '#' + pass.penColor.map(c => c.toString(16).padStart(2, '0')).join('')
const isProcessing = pass.status === 'Processing…'
@@ -44,42 +31,6 @@ export default function PassPanel({
)}
</div>
{/* ── Fill ── how hulls become strokes */}
<Section title="Fill" defaultOpen accent={C.fill}>
<div className="flex flex-wrap gap-1 mb-2">
{FILL_STRATEGIES.map(s => (
<button key={s}
onClick={() => setFill({ strategy: s, param: FILL_STRATEGY_PARAMS[s]?.default ?? 1.0 })}
className={`px-2 py-0.5 rounded text-xs transition-colors ${
pass.strategy === s
? 'bg-purple-700 text-white'
: 'bg-neutral-800 text-neutral-400 hover:bg-neutral-700'
}`}
>{s}</button>
))}
</div>
<Slider label="Spacing" value={pass.spacing} min={1} max={50} step={0.5} unit="px"
onChange={v => setFill({ spacing: v })} />
{FILL_USES_ANGLE.has(pass.strategy) && (
<Slider label="Angle" value={pass.angle} min={0} max={360} step={1} unit="°"
onChange={v => setFill({ angle: v })} />
)}
{FILL_STRATEGY_PARAMS[pass.strategy] && (() => {
const p = FILL_STRATEGY_PARAMS[pass.strategy]
return (
<Slider label={p.label} value={pass.param ?? p.default}
min={p.min} max={p.max} step={p.step}
onChange={v => setFill({ param: v })} />
)
})()}
<div className="mt-2 pt-2 border-t border-neutral-800 space-y-1">
<Slider label="Smooth RDP" value={pass.smoothRdp} min={0} max={5} step={0.1}
onChange={v => setFill({ smoothRdp: v })} />
<Slider label="Chaikin" value={pass.smoothIters} min={0} max={4} step={1}
onChange={v => setFill({ smoothIters: v })} />
</div>
</Section>
{/* Status */}
<div className="px-3 py-2 min-h-8">
<p className={`text-xs ${pass.status?.startsWith('Error') ? 'text-red-400' : 'text-neutral-500'}`}>

View File

@@ -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 })
}

View File

@@ -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,
}
}

View File

@@ -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())

View File

@@ -74,6 +74,13 @@ pub struct GraphNodePayload {
pub rdp_epsilon: Option<f32>,
pub connectivity: Option<String>,
pub color_filter: Option<ColorFilterPayload>,
// Fill params (optional — only for kind="Fill")
pub strategy: Option<String>,
pub spacing: Option<f32>,
pub angle: Option<f32>,
pub param: Option<f32>,
pub smooth_rdp: Option<f32>,
pub smooth_iters: Option<u32>,
}
#[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<String, String>,
pub timings: Vec<StepTime>,
}
#[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<StepTime>,
/// 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<hulls::Hull>, Vec<u8>, ProcessResult) {
) -> (Vec<hulls::Hull>, Vec<fill::FillResult>, Vec<u8>, ProcessResult) {
use rayon::prelude::*;
let t0 = Instant::now();
let mut steps: Vec<StepTime> = 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<hulls::Hull> = Vec::new();
let mut first_hull_response: Option<Vec<u8>> = None;
// ── Hull nodes ─────────────────────────────────────────────────────────────
let mut all_hulls: Vec<hulls::Hull> = Vec::new();
let mut hull_outputs: std::collections::HashMap<String, Vec<hulls::Hull>> = Default::default();
let mut hull_resp_maps: std::collections::HashMap<String, Vec<u8>> = Default::default();
let mut first_hull_response: Option<Vec<u8>> = 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<fill::FillResult> = 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<fill::FillResult> = 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<fill::FillResult> = 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<hulls::Hull>,
response_map: Vec<u8>,
img_width: u32,
payload: FillPayload,
) -> (Vec<fill::FillResult>, 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<StepTime> = 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<fill::FillResult> = 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<fill::FillResult> = 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<AppSta
let idx = payload.pass_index;
let (new_hulls, response_map, result) = tauri::async_runtime::spawn_blocking(move || {
let (new_hulls, new_fill, response_map, result) = tauri::async_runtime::spawn_blocking(move || {
process_pass_work(&rgb, payload)
})
.await
@@ -524,39 +549,12 @@ async fn process_pass(payload: ProcessPassPayload, state: State<'_, Mutex<AppSta
st.passes.push(PassState::default());
}
st.passes[idx].hulls = new_hulls;
st.passes[idx].fill_results = Vec::new();
st.passes[idx].fill_results = new_fill;
st.passes[idx].response_map = response_map;
Ok(result)
}
#[tauri::command]
async fn generate_fill(payload: FillPayload, state: State<'_, Mutex<AppState>>) -> Result<FillResult, String> {
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,