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:
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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'}`}>
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
297
src/lib.rs
297
src/lib.rs
@@ -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,15 +372,17 @@ 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
|
||||
// ── 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;
|
||||
|
||||
@@ -364,12 +392,10 @@ 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,
|
||||
@@ -383,68 +409,44 @@ 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
|
||||
let response_for_viz = first_hull_response.as_deref().unwrap_or(&graph_maps.response);
|
||||
let threshold = first_hull_threshold;
|
||||
// ── Fill nodes ─────────────────────────────────────────────────────────────
|
||||
let mut all_fill_results: Vec<fill::FillResult> = Vec::new();
|
||||
|
||||
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 };
|
||||
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 mut rgba = vec![0u8; (w * h * 4) as usize];
|
||||
for (i, &r) in response_for_viz.iter().enumerate() {
|
||||
let v = if r < threshold { 0u8 } else { 220u8 };
|
||||
rgba[i*4] = v; rgba[i*4+1] = v; rgba[i*4+2] = v; rgba[i*4+3] = 255;
|
||||
}
|
||||
t = lap!(steps, "viz build", t);
|
||||
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 viz_b64 = rgba_to_b64_png(&rgba, w, h);
|
||||
let hull_count = all_hulls.len();
|
||||
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 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| {
|
||||
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),
|
||||
@@ -455,24 +457,47 @@ fn generate_fill_work(
|
||||
"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)),
|
||||
"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();
|
||||
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))
|
||||
let smoothed: Vec<fill::FillResult> = raw.iter()
|
||||
.map(|r| fill::smooth_fill_result(r, smooth_rdp, smooth_iters))
|
||||
.collect();
|
||||
t = lap!(steps, "smooth", t);
|
||||
let optimised = fill::optimize_travel(&smoothed);
|
||||
|
||||
let optimised = vec![fill::optimize_travel(&smoothed)];
|
||||
lap!(steps, "travel opt", t);
|
||||
node_previews.insert(node.id.clone(), render_fill_preview(&optimised, w, h));
|
||||
all_fill_results.push(optimised);
|
||||
}
|
||||
}
|
||||
t = lap!(steps, "fill", t);
|
||||
|
||||
let stroke_count: usize = optimised.iter().map(|r| r.strokes.len()).sum();
|
||||
// ── 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() {
|
||||
let v = if r < threshold { 0u8 } else { 220u8 };
|
||||
rgba[i*4] = v; rgba[i*4+1] = v; rgba[i*4+2] = v; rgba[i*4+3] = 255;
|
||||
}
|
||||
t = lap!(steps, "viz build", t);
|
||||
|
||||
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 });
|
||||
|
||||
(optimised, FillResult { stroke_count, strokes_json: String::new(), timings: steps })
|
||||
let hull_count = all_hulls.len();
|
||||
let response_map = first_hull_response.unwrap_or_else(|| graph_maps.response);
|
||||
|
||||
(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,
|
||||
|
||||
Reference in New Issue
Block a user