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)
|
setDisplayB64(passes[activePass]?.vizB64 ?? null)
|
||||||
break
|
break
|
||||||
case 'contours':
|
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) {
|
if (passes[activePass]?.hullCount > 0) {
|
||||||
try {
|
try {
|
||||||
const tv = performance.now()
|
const tv = performance.now()
|
||||||
@@ -126,7 +123,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
refresh()
|
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 ──────────────────────────────────────────────────────────────
|
// ── File open ──────────────────────────────────────────────────────────────
|
||||||
async function openImage() {
|
async function openImage() {
|
||||||
@@ -166,13 +163,14 @@ export default function App() {
|
|||||||
const js_process = Math.round(performance.now() - t0)
|
const js_process = Math.round(performance.now() - t0)
|
||||||
setPerfData(pd => ({ ...(pd ?? {}), process: result.timings, js_process }))
|
setPerfData(pd => ({ ...(pd ?? {}), process: result.timings, js_process }))
|
||||||
updatePass(idx, {
|
updatePass(idx, {
|
||||||
status: `${result.hull_count} hulls · ${result.coverage_pct}% coverage`,
|
status: `${result.hull_count} hulls · ${result.stroke_count} strokes`,
|
||||||
vizB64: result.viz_b64,
|
vizB64: result.viz_b64,
|
||||||
hullCount: result.hull_count,
|
hullCount: result.hull_count,
|
||||||
strokeCount: 0,
|
strokeCount: result.stroke_count,
|
||||||
nodePreviews: result.node_previews ?? {},
|
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) {
|
} catch (e) {
|
||||||
updatePass(idx, { status: `Error: ${e}` })
|
updatePass(idx, { status: `Error: ${e}` })
|
||||||
setGlobalStatus(`Process error: ${e}`)
|
setGlobalStatus(`Process error: ${e}`)
|
||||||
@@ -180,44 +178,6 @@ export default function App() {
|
|||||||
if (!silent) setBusy(false)
|
if (!silent) setBusy(false)
|
||||||
}, []) // stable — uses refs
|
}, []) // 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 ───────────────────
|
// ── Debounced auto-reprocess triggered by slider changes ───────────────────
|
||||||
const scheduleProcess = useCallback((idx) => {
|
const scheduleProcess = useCallback((idx) => {
|
||||||
const key = `${idx}-detect`
|
const key = `${idx}-detect`
|
||||||
@@ -225,16 +185,6 @@ export default function App() {
|
|||||||
debounceTimers.current[key] = setTimeout(() => processPass(idx, true), 400)
|
debounceTimers.current[key] = setTimeout(() => processPass(idx, true), 400)
|
||||||
}, [processPass])
|
}, [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 ─────────────────────────────────────────────────────────────────
|
// ── Export ─────────────────────────────────────────────────────────────────
|
||||||
async function exportActivePass() {
|
async function exportActivePass() {
|
||||||
const pass = passes[activePass]
|
const pass = passes[activePass]
|
||||||
@@ -354,7 +304,6 @@ export default function App() {
|
|||||||
pass={passes[activePass]}
|
pass={passes[activePass]}
|
||||||
onChange={p => updatePass(activePass, p)}
|
onChange={p => updatePass(activePass, p)}
|
||||||
onDetectionChange={() => scheduleProcess(activePass)}
|
onDetectionChange={() => scheduleProcess(activePass)}
|
||||||
onFillChange={() => scheduleFill(activePass)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useRef, useState, useCallback, useEffect } from 'react'
|
import { useRef, useState, useCallback, useEffect } from 'react'
|
||||||
import Slider from './Slider.jsx'
|
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'
|
import ColorFilter from './ColorFilter.jsx'
|
||||||
|
|
||||||
// ── Layout constants ───────────────────────────────────────────────────────────
|
// ── Layout constants ───────────────────────────────────────────────────────────
|
||||||
@@ -222,18 +222,20 @@ export default function NodeGraph({ graph, onChange, nodePreviews, sourceImageB6
|
|||||||
|
|
||||||
// ── Node rendering ─────────────────────────────────────────────────────────
|
// ── Node rendering ─────────────────────────────────────────────────────────
|
||||||
function renderNode(node) {
|
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)
|
const inputCnt = node.kind === 'Combine' ? (node.inputCount ?? 2)
|
||||||
: (node.kind === 'Kernel' || node.kind === 'Output' || node.kind === 'Hull') ? 1 : 0
|
: (node.kind === 'Kernel' || node.kind === 'Output' || node.kind === 'Hull' || node.kind === 'Fill') ? 1 : 0
|
||||||
const hasOut = node.kind !== 'Output' && node.kind !== 'Hull'
|
const hasOut = node.kind !== 'Output' && node.kind !== 'Hull' && node.kind !== 'Fill'
|
||||||
|
|
||||||
const preview = node.kind === 'Source' ? sourceImageB64 : nodePreviews?.[node.id]
|
const preview = node.kind === 'Source' ? sourceImageB64 : nodePreviews?.[node.id]
|
||||||
|
|
||||||
const accentColor = node.kind === 'Source' ? '#7c3aed'
|
const accentColor = node.kind === 'Source' ? '#7c3aed'
|
||||||
: node.kind === 'Hull' ? '#0d9488'
|
: node.kind === 'Hull' ? '#0d9488'
|
||||||
|
: node.kind === 'Fill' ? '#9333ea'
|
||||||
: '#374151'
|
: '#374151'
|
||||||
const headerBg = node.kind === 'Source' ? '#2e1065'
|
const headerBg = node.kind === 'Source' ? '#2e1065'
|
||||||
: node.kind === 'Hull' ? '#042f2e'
|
: node.kind === 'Hull' ? '#042f2e'
|
||||||
|
: node.kind === 'Fill' ? '#3b0764'
|
||||||
: '#1e293b'
|
: '#1e293b'
|
||||||
|
|
||||||
return (
|
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' }}>
|
<span style={{ flex: 1, fontSize: 11, fontWeight: 600, color: '#e2e8f0', letterSpacing: '0.05em', pointerEvents: 'none' }}>
|
||||||
{node.kind === 'Source' ? 'Source'
|
{node.kind === 'Source' ? 'Source'
|
||||||
: node.kind === 'Hull' ? 'Hull'
|
: node.kind === 'Hull' ? 'Hull'
|
||||||
|
: node.kind === 'Fill' ? (node.strategy ?? 'Fill')
|
||||||
: node.kind === 'Kernel' ? (node.kernel ?? 'Kernel')
|
: node.kind === 'Kernel' ? (node.kernel ?? 'Kernel')
|
||||||
: node.kind === 'Combine' ? 'Combine'
|
: node.kind === 'Combine' ? 'Combine'
|
||||||
: 'Output'}
|
: 'Output'}
|
||||||
@@ -371,6 +374,43 @@ export default function NodeGraph({ graph, onChange, nodePreviews, sourceImageB6
|
|||||||
</div>
|
</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 thumbnail */}
|
||||||
{preview && (
|
{preview && (
|
||||||
<img src={`data:image/jpeg;base64,${preview}`} alt="" draggable={false}
|
<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({
|
export default function PassPanel({
|
||||||
pass, onChange,
|
pass, onChange,
|
||||||
onDetectionChange,
|
onDetectionChange,
|
||||||
onFillChange,
|
|
||||||
}) {
|
}) {
|
||||||
function set(patch) { onChange({ ...pass, ...patch }) }
|
function set(patch) { onChange({ ...pass, ...patch }) }
|
||||||
function setDetection(patch) { onChange({ ...pass, ...patch }); onDetectionChange?.() }
|
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 colorHex = '#' + pass.penColor.map(c => c.toString(16).padStart(2, '0')).join('')
|
||||||
const isProcessing = pass.status === 'Processing…'
|
const isProcessing = pass.status === 'Processing…'
|
||||||
@@ -44,42 +31,6 @@ export default function PassPanel({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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 */}
|
{/* Status */}
|
||||||
<div className="px-3 py-2 min-h-8">
|
<div className="px-3 py-2 min-h-8">
|
||||||
<p className={`text-xs ${pass.status?.startsWith('Error') ? 'text-red-400' : 'text-neutral-500'}`}>
|
<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 })
|
return tracedInvoke('process_pass', { payload })
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateFill(payload) {
|
|
||||||
return tracedInvoke('generate_fill', { payload })
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getAllStrokes(passColors) {
|
export async function getAllStrokes(passColors) {
|
||||||
return tracedInvoke('get_all_strokes', { 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 }
|
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() {
|
export function defaultHullParams() {
|
||||||
return {
|
return {
|
||||||
threshold: 128, min_area: 4, rdp_epsilon: 1.5, connectivity: 'four',
|
threshold: 128, min_area: 4, rdp_epsilon: 1.5, connectivity: 'four',
|
||||||
@@ -51,11 +58,13 @@ export function defaultGraph() {
|
|||||||
nodes: [
|
nodes: [
|
||||||
{ id: 'source', kind: 'Source', x: 60, y: 160 },
|
{ id: 'source', kind: 'Source', x: 60, y: 160 },
|
||||||
{ id: kId, kind: 'Kernel', x: 310, y: 100, ...defaultKernelProps() },
|
{ 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: [
|
edges: [
|
||||||
{ from: 'source', to: kId, port: 0 },
|
{ from: 'source', to: kId, port: 0 },
|
||||||
{ from: kId, to: 'hull', 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],
|
penColor: colors[index] ?? [128,128,128],
|
||||||
graph: defaultGraph(),
|
graph: defaultGraph(),
|
||||||
nodePreviews: {},
|
nodePreviews: {},
|
||||||
strategy: 'hatch',
|
|
||||||
spacing: 5,
|
|
||||||
angle: 0,
|
|
||||||
param: 1.0,
|
|
||||||
smoothRdp: 1.0,
|
|
||||||
smoothIters: 2,
|
|
||||||
// runtime
|
// runtime
|
||||||
status: 'Not processed',
|
status: 'Not processed',
|
||||||
vizB64: null,
|
vizB64: null,
|
||||||
hullCount: 0,
|
hullCount: 0,
|
||||||
strokeCount: 0,
|
strokeCount: 0,
|
||||||
filling: false,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -434,6 +434,14 @@ pub enum NodeKind {
|
|||||||
cf_sat_min: f32, cf_sat_max: f32,
|
cf_sat_min: f32, cf_sat_max: f32,
|
||||||
cf_val_min: f32, cf_val_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)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -552,6 +560,8 @@ pub fn evaluate_graph(
|
|||||||
incoming[id].iter()
|
incoming[id].iter()
|
||||||
.find_map(|(fid, _)| outputs.get(fid).cloned())
|
.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 {
|
if let Some(map) = result {
|
||||||
outputs.insert(id, map);
|
outputs.insert(id, map);
|
||||||
@@ -567,6 +577,7 @@ pub fn evaluate_graph(
|
|||||||
|
|
||||||
// Final response: prefer an explicit Output node; fall back to the upstream
|
// 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).
|
// 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()
|
let response = graph.nodes.iter()
|
||||||
.find(|n| matches!(n.kind, NodeKind::Output))
|
.find(|n| matches!(n.kind, NodeKind::Output))
|
||||||
.and_then(|n| raw_maps.get(&n.id).cloned())
|
.and_then(|n| raw_maps.get(&n.id).cloned())
|
||||||
|
|||||||
309
src/lib.rs
309
src/lib.rs
@@ -74,6 +74,13 @@ pub struct GraphNodePayload {
|
|||||||
pub rdp_epsilon: Option<f32>,
|
pub rdp_epsilon: Option<f32>,
|
||||||
pub connectivity: Option<String>,
|
pub connectivity: Option<String>,
|
||||||
pub color_filter: Option<ColorFilterPayload>,
|
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)]
|
#[derive(Deserialize, Clone, Debug)]
|
||||||
@@ -116,37 +123,12 @@ pub struct StepTime {
|
|||||||
pub struct ProcessResult {
|
pub struct ProcessResult {
|
||||||
pub hull_count: usize,
|
pub hull_count: usize,
|
||||||
pub coverage_pct: usize,
|
pub coverage_pct: usize,
|
||||||
|
pub stroke_count: usize,
|
||||||
pub viz_b64: String,
|
pub viz_b64: String,
|
||||||
pub node_previews: std::collections::HashMap<String, String>,
|
pub node_previews: std::collections::HashMap<String, String>,
|
||||||
pub timings: Vec<StepTime>,
|
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)]
|
#[derive(Deserialize, Clone, Debug)]
|
||||||
pub struct GcodeConfigPayload {
|
pub struct GcodeConfigPayload {
|
||||||
pub paper_w_mm: f32,
|
pub paper_w_mm: f32,
|
||||||
@@ -211,6 +193,14 @@ fn to_detection_graph(payload: &DetectionGraphPayload) -> detect::DetectionGraph
|
|||||||
);
|
);
|
||||||
detect::NodeKind::Combine(mode)
|
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" => {
|
"Hull" => {
|
||||||
let cf = n.color_filter.as_ref();
|
let cf = n.color_filter.as_ref();
|
||||||
detect::NodeKind::Hull {
|
detect::NodeKind::Hull {
|
||||||
@@ -308,6 +298,40 @@ fn rgb_to_b64_jpeg(rgb: &image::RgbImage) -> String {
|
|||||||
|
|
||||||
// ── Pipeline inner functions (no Tauri, no mutex) ─────────────────────────────
|
// ── 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 {
|
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];
|
let mut rgba = vec![15u8; (w * h * 4) as usize];
|
||||||
for chunk in rgba.chunks_mut(4) { chunk[3] = 255; }
|
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(
|
fn process_pass_work(
|
||||||
rgb: &image::RgbImage,
|
rgb: &image::RgbImage,
|
||||||
payload: ProcessPassPayload,
|
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 t0 = Instant::now();
|
||||||
let mut steps: Vec<StepTime> = Vec::new();
|
let mut steps: Vec<StepTime> = Vec::new();
|
||||||
let (w, h) = rgb.dimensions();
|
let (w, h) = rgb.dimensions();
|
||||||
@@ -346,16 +372,18 @@ fn process_pass_work(
|
|||||||
det_graph.nodes.iter().find(|n| &n.id == *id)
|
det_graph.nodes.iter().find(|n| &n.id == *id)
|
||||||
.map_or(false, |n| !matches!(
|
.map_or(false, |n| !matches!(
|
||||||
n.kind,
|
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)))
|
.map(|(id, map)| (id.clone(), map_to_b64_small(map, w, h)))
|
||||||
.collect();
|
.collect();
|
||||||
t = lap!(steps, "detect previews", t);
|
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 all_hulls: Vec<hulls::Hull> = Vec::new();
|
||||||
let mut first_hull_response: Option<Vec<u8>> = None;
|
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;
|
let mut first_hull_threshold: u8 = 128;
|
||||||
|
|
||||||
for node in &det_graph.nodes {
|
for node in &det_graph.nodes {
|
||||||
@@ -364,18 +392,16 @@ fn process_pass_work(
|
|||||||
cf_enabled, cf_hue_min, cf_hue_max,
|
cf_enabled, cf_hue_min, cf_hue_max,
|
||||||
cf_sat_min, cf_sat_max, cf_val_min, cf_val_max,
|
cf_sat_min, cf_sat_max, cf_val_min, cf_val_max,
|
||||||
} = &node.kind {
|
} = &node.kind {
|
||||||
// The upstream Map was stored under the Hull node's own id
|
|
||||||
let response = match graph_maps.raw_maps.get(&node.id) {
|
let response = match graph_maps.raw_maps.get(&node.id) {
|
||||||
Some(m) => m,
|
Some(m) => m,
|
||||||
None => continue, // disconnected hull node — skip
|
None => continue,
|
||||||
};
|
};
|
||||||
|
|
||||||
let hull_params = hulls::HullParams {
|
let hull_params = hulls::HullParams {
|
||||||
threshold: *threshold,
|
threshold: *threshold,
|
||||||
min_area: *min_area,
|
min_area: *min_area,
|
||||||
rdp_epsilon: *rdp_epsilon,
|
rdp_epsilon: *rdp_epsilon,
|
||||||
connectivity: if *eight_conn { hulls::Connectivity::Eight }
|
connectivity: if *eight_conn { hulls::Connectivity::Eight }
|
||||||
else { hulls::Connectivity::Four },
|
else { hulls::Connectivity::Four },
|
||||||
};
|
};
|
||||||
let color_filter = hulls::ColorFilter {
|
let color_filter = hulls::ColorFilter {
|
||||||
enabled: *cf_enabled,
|
enabled: *cf_enabled,
|
||||||
@@ -383,29 +409,78 @@ fn process_pass_work(
|
|||||||
sat_min: *cf_sat_min, sat_max: *cf_sat_max,
|
sat_min: *cf_sat_min, sat_max: *cf_sat_max,
|
||||||
val_min: *cf_val_min, val_max: *cf_val_max,
|
val_min: *cf_val_min, val_max: *cf_val_max,
|
||||||
};
|
};
|
||||||
|
|
||||||
let extracted = hulls::extract_hulls(response, rgb, w, h, &hull_params);
|
let extracted = hulls::extract_hulls(response, rgb, w, h, &hull_params);
|
||||||
let filtered = hulls::filter_hulls_by_color(extracted, &color_filter);
|
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));
|
node_previews.insert(node.id.clone(), render_hull_preview(response, &filtered, w, h));
|
||||||
|
|
||||||
if first_hull_response.is_none() {
|
if first_hull_response.is_none() {
|
||||||
first_hull_response = Some(response.clone());
|
first_hull_response = Some(response.clone());
|
||||||
first_hull_threshold = *threshold;
|
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);
|
all_hulls.extend(filtered);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
t = lap!(steps, "hull extract", t);
|
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 response_for_viz = first_hull_response.as_deref().unwrap_or(&graph_maps.response);
|
||||||
let threshold = first_hull_threshold;
|
let threshold = first_hull_threshold;
|
||||||
|
|
||||||
let total_dark = response_for_viz.iter().filter(|&&p| p < threshold).count();
|
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 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 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];
|
let mut rgba = vec![0u8; (w * h * 4) as usize];
|
||||||
for (i, &r) in response_for_viz.iter().enumerate() {
|
for (i, &r) in response_for_viz.iter().enumerate() {
|
||||||
@@ -414,65 +489,15 @@ fn process_pass_work(
|
|||||||
}
|
}
|
||||||
t = lap!(steps, "viz build", t);
|
t = lap!(steps, "viz build", t);
|
||||||
|
|
||||||
let viz_b64 = rgba_to_b64_png(&rgba, w, h);
|
let viz_b64 = rgba_to_b64_png(&rgba, w, h);
|
||||||
let hull_count = all_hulls.len();
|
|
||||||
lap!(steps, "png encode", t);
|
lap!(steps, "png encode", t);
|
||||||
steps.push(StepTime { label: "total".into(), ms: t0.elapsed().as_millis() as u64 });
|
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);
|
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 })
|
(all_hulls, all_fill_results, response_map,
|
||||||
}
|
ProcessResult { hull_count, coverage_pct, stroke_count, 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 })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Tauri commands ─────────────────────────────────────────────────────────────
|
// ── Tauri commands ─────────────────────────────────────────────────────────────
|
||||||
@@ -513,7 +538,7 @@ async fn process_pass(payload: ProcessPassPayload, state: State<'_, Mutex<AppSta
|
|||||||
|
|
||||||
let idx = payload.pass_index;
|
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)
|
process_pass_work(&rgb, payload)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -524,39 +549,12 @@ async fn process_pass(payload: ProcessPassPayload, state: State<'_, Mutex<AppSta
|
|||||||
st.passes.push(PassState::default());
|
st.passes.push(PassState::default());
|
||||||
}
|
}
|
||||||
st.passes[idx].hulls = new_hulls;
|
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;
|
st.passes[idx].response_map = response_map;
|
||||||
|
|
||||||
Ok(result)
|
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]
|
#[tauri::command]
|
||||||
fn export_gcode(
|
fn export_gcode(
|
||||||
pass_index: usize,
|
pass_index: usize,
|
||||||
@@ -941,6 +939,8 @@ mod blocking_tests {
|
|||||||
blend_mode: None,
|
blend_mode: None,
|
||||||
threshold: None, min_area: None, rdp_epsilon: None,
|
threshold: None, min_area: None, rdp_epsilon: None,
|
||||||
connectivity: None, color_filter: 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.min_area = Some(10);
|
||||||
hull.rdp_epsilon = Some(2.0);
|
hull.rdp_epsilon = Some(2.0);
|
||||||
hull.connectivity = Some("four".into());
|
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 {
|
ProcessPassPayload {
|
||||||
pass_index: 0,
|
pass_index: 0,
|
||||||
graph: DetectionGraphPayload {
|
graph: DetectionGraphPayload {
|
||||||
nodes: vec![node("source", "Source"), k1, hull],
|
nodes: vec![node("source", "Source"), k1, hull, fill_node],
|
||||||
edges: vec![
|
edges: vec![
|
||||||
GraphEdgePayload { from: "source".into(), to: "k1".into(), port: 0 },
|
GraphEdgePayload { from: "source".into(), to: "k1".into(), port: 0 },
|
||||||
GraphEdgePayload { from: "k1".into(), to: "hull".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"
|
"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!(result.timings.iter().any(|t| t.label == "total"));
|
||||||
assert!(!hulls.is_empty(), "expected hulls from checkerboard image");
|
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)]
|
#[cfg(test)]
|
||||||
@@ -1504,7 +1462,6 @@ pub fn run() {
|
|||||||
get_images_dir,
|
get_images_dir,
|
||||||
set_pass_count,
|
set_pass_count,
|
||||||
process_pass,
|
process_pass,
|
||||||
generate_fill,
|
|
||||||
get_all_strokes,
|
get_all_strokes,
|
||||||
get_gcode_viz,
|
get_gcode_viz,
|
||||||
get_pass_viz,
|
get_pass_viz,
|
||||||
|
|||||||
Reference in New Issue
Block a user