multi-Source pipeline: per-Source file_path, image cache, no global image
Each Source node now owns its own image file. The Pipeline graph can
have multiple Source nodes, each rooting an independent subtree.
Frontend enforces tree separation (wires that would merge two Sources'
subtrees into one downstream node are rejected at draw time); backend
trusts the well-formed graph and threads each subtree's RGB through.
NodeKind::Source { file_path } — new field, threaded through
GraphNodePayload, fp_source, frontend payload roundtrip. Sources with
no file_path produce nothing downstream (their subtree is dormant).
AppState changes:
- image_rgb / image_path → images: HashMap<path, RgbImage>. The
per-path cache survives across process_pass calls so picking the
same file twice doesn't re-decode.
- load_image now inserts into the cache instead of setting a global;
Tauri command signature unchanged (frontend Source card calls it
when the user picks a file).
evaluate_graph signature: takes a per-node RGB lookup
(node_rgbs: HashMap<String, RgbImage>) plus canvas dims. Each Kernel
finds its tree's Source RGB through the lookup.
process_pass:
- Each Source's image is letterbox-fit (preserve aspect, white pad)
to the paper-pixel canvas (paper_w × paper_h × dpi/25.4) so all
trees produce strokes in one coord frame and gcode export uses
one img_w/img_h.
- Topo-walks to compute node_to_source (each non-Source node's
upstream Source ID), builds node_rgbs from the source map, hands
it to evaluate_graph.
- Hull node uses its tree's Source RGB for color extraction.
- Source node previews are populated directly (graph_maps doesn't
carry Source raw_maps, so we encode the letterboxed canvas as JPEG
b64 per-Source for the card thumbnail).
- Synthetic-canvas-when-no-image fallback removed; replaced by the
natural "every Source contributes its own canvas" behaviour. Text-
only graphs work because the no-Source case still produces an
empty canvas of paper dims for the bg() of evaluate_graph.
Frontend:
- App.jsx: dropped `image`/`setImage`/`imageRef`/`displayB64` for
source previews, the sidebar Open Image button, the "no image
loaded" overlay, and the 'source' viewMode (default is now
'pipeline'). processPass runs whenever any Source has a loaded file
OR any Text node exists. Project save/load drops imagePath; on
load, every Source node's file_path gets re-loaded into the cache,
with missing files surfaced in globalStatus rather than failing.
- NodeGraph.jsx: Source card now has a "Pick image…" button; once
picked, the path shows under it and the title bar shows the
filename. Every Source is deletable (isFixed = false) and a +
Source toolbar entry adds new ones. Source preview comes from
nodePreviews like every other node.
- isCompatible() now also walks upstream from both endpoints; a wire
that would merge two Source subtrees is rejected on draw.
- store.js: defaultSourceParams() (file_path: null), defaultGraph
unchanged structurally — just adds the new field on the starter
Source node.
Verified: cargo test --lib (80 pass), npm run build, npm test
(72 pass).
This commit is contained in:
@@ -13,17 +13,16 @@ import * as tauri from './hooks/useTauri.js'
|
||||
import { serialize, deserialize } from './project.js'
|
||||
import { useFps } from './hooks/useFps.js'
|
||||
|
||||
const VIEW_MODES = ['source', 'pipeline', 'gcode', 'printer', 'tuning']
|
||||
const VIEW_MODES = ['pipeline', 'gcode', 'printer', 'tuning']
|
||||
|
||||
export default function App() {
|
||||
const [image, setImage] = useState(null)
|
||||
const [passes, setPasses] = useState([defaultPass(0)])
|
||||
// Single pass — multi-pass is replaced by PenOutput nodes in the graph
|
||||
const [gcodeConfig, setGcodeConfig] = useState(defaultGcodeConfig())
|
||||
const [viewMode, setViewMode] = useState('source')
|
||||
const [viewMode, setViewMode] = useState('pipeline')
|
||||
const [displayB64, setDisplayB64] = useState(null) // current image shown in viewport
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [globalStatus, setGlobalStatus] = useState('Open an image to start')
|
||||
const [globalStatus, setGlobalStatus] = useState('Add a Source node and pick an image, or add a Text node')
|
||||
const [strokes, setStrokes] = useState(null)
|
||||
const [showPerf, setShowPerf] = useState(false)
|
||||
const [perfData, setPerfData] = useState(null)
|
||||
@@ -37,6 +36,17 @@ export default function App() {
|
||||
const resizing = useRef(false)
|
||||
|
||||
const hasOutput = passes.some(p => p.strokeCount > 0)
|
||||
// Synthesised image-info for components that still want { width, height }
|
||||
// — derives the paper-pixel canvas the pipeline operates on.
|
||||
const canvasDims = {
|
||||
width: Math.round(gcodeConfig.paper_w_mm * dpi / 25.4),
|
||||
height: Math.round(gcodeConfig.paper_h_mm * dpi / 25.4),
|
||||
}
|
||||
// Has a Source node with a loaded file path? Used for the empty-state overlay.
|
||||
const anyLoadedSource = (passes[0]?.graph?.nodes ?? [])
|
||||
.some(n => n.kind === 'Source' && n.file_path)
|
||||
const anyTextNode = (passes[0]?.graph?.nodes ?? [])
|
||||
.some(n => n.kind === 'Text')
|
||||
|
||||
// Ctrl+S / Ctrl+Shift+S — ref pattern keeps listener stable across renders
|
||||
const saveProjectRef = useRef(null)
|
||||
@@ -84,11 +94,9 @@ export default function App() {
|
||||
|
||||
// Always-fresh refs so debounced callbacks never close over stale state
|
||||
const passesRef = useRef(passes)
|
||||
const imageRef = useRef(image)
|
||||
const dpiRef = useRef(dpi)
|
||||
const gcodeConfigRef = useRef(gcodeConfig)
|
||||
passesRef.current = passes
|
||||
imageRef.current = image
|
||||
dpiRef.current = dpi
|
||||
gcodeConfigRef.current = gcodeConfig
|
||||
|
||||
@@ -104,11 +112,7 @@ export default function App() {
|
||||
// ── Refresh viewport whenever view mode or active pass changes ─────────────
|
||||
useEffect(() => {
|
||||
async function refresh() {
|
||||
if (!image) { setDisplayB64(null); return }
|
||||
switch (viewMode) {
|
||||
case 'source':
|
||||
setDisplayB64(image.preview_b64)
|
||||
break
|
||||
case 'pipeline':
|
||||
setDisplayB64(passes[0]?.vizB64 ?? null)
|
||||
break
|
||||
@@ -130,50 +134,30 @@ export default function App() {
|
||||
}
|
||||
}
|
||||
refresh()
|
||||
}, [viewMode, image, passes[0]?.vizB64, passes[0]?.hullCount, totalStrokeCount])
|
||||
|
||||
// ── File open ──────────────────────────────────────────────────────────────
|
||||
async function openImage() {
|
||||
const path = await tauri.pickImageFile()
|
||||
if (!path) return
|
||||
setBusy(true)
|
||||
try {
|
||||
const info = await tauri.loadImage(path)
|
||||
setImage(info)
|
||||
imageRef.current = info // processPass checks this ref before React re-renders
|
||||
setDisplayB64(info.preview_b64)
|
||||
setViewMode('source')
|
||||
setStrokes(null)
|
||||
setGlobalStatus(`${info.width} × ${info.height}px`)
|
||||
processPass(0, true)
|
||||
} catch (e) {
|
||||
setGlobalStatus(`Error loading image: ${e}`)
|
||||
}
|
||||
setBusy(false)
|
||||
}
|
||||
}, [viewMode, passes[0]?.vizB64, passes[0]?.hullCount, totalStrokeCount])
|
||||
|
||||
// ── Process a pass ─────────────────────────────────────────────────────────
|
||||
// silent=true: auto-reprocess from slider change — doesn't block UI with global busy
|
||||
const processPass = useCallback(async (idx, silent = false) => {
|
||||
const pass = passesRef.current[idx]
|
||||
const hasImage = !!imageRef.current
|
||||
const hasTextNode = (pass.graph?.nodes ?? []).some(n => n.kind === 'Text')
|
||||
if (!hasImage && !hasTextNode) return
|
||||
const nodes = pass.graph?.nodes ?? []
|
||||
const hasLoadedSource = nodes.some(n => n.kind === 'Source' && n.file_path)
|
||||
const hasTextNode = nodes.some(n => n.kind === 'Text')
|
||||
if (!hasLoadedSource && !hasTextNode) return
|
||||
if (!silent) setBusy(true)
|
||||
// Reset counts so viewport doesn't show stale data during reprocessing.
|
||||
updatePass(idx, { status: 'Processing…', vizB64: null, hullCount: 0, strokeCount: 0 })
|
||||
const t0 = performance.now()
|
||||
try {
|
||||
// For text-only projects (no source image) the backend synthesises a
|
||||
// paper-sized blank canvas — we hand it both paper dimensions so it
|
||||
// knows what to allocate. Image-mode keeps the user's img_w_mm scale.
|
||||
// Backend letterboxes every Source into the paper canvas, so we hand
|
||||
// it the paper dimensions directly — no per-image scaling knob anymore.
|
||||
const paperW = gcodeConfigRef.current.paper_w_mm
|
||||
const paperH = gcodeConfigRef.current.paper_h_mm
|
||||
const result = await tauri.processPass({
|
||||
pass_index: idx,
|
||||
graph: pass.graph,
|
||||
dpi: dpiRef.current,
|
||||
img_w_mm: hasImage ? gcodeConfigRef.current.img_w_mm : paperW,
|
||||
img_w_mm: paperW,
|
||||
img_h_mm: paperH,
|
||||
})
|
||||
const js_process = Math.round(performance.now() - t0)
|
||||
@@ -205,8 +189,8 @@ export default function App() {
|
||||
scheduleProcessRef.current = scheduleProcess
|
||||
|
||||
useEffect(() => {
|
||||
if (imageRef.current) scheduleProcess()
|
||||
}, [dpi, gcodeConfig.img_w_mm])
|
||||
scheduleProcess()
|
||||
}, [dpi, gcodeConfig.paper_w_mm, gcodeConfig.paper_h_mm])
|
||||
|
||||
// ── Export ─────────────────────────────────────────────────────────────────
|
||||
async function exportAll() {
|
||||
@@ -226,15 +210,17 @@ export default function App() {
|
||||
async function saveProject(saveAs = false) {
|
||||
let path = saveAs ? null : projectPath
|
||||
if (!path) {
|
||||
const suggested = image
|
||||
? image.path.replace(/\.[^.]+$/, '.trac3r').split('/').pop()
|
||||
// Suggest a filename based on the first Source node's image, if any.
|
||||
const firstSrcPath = (passes[0]?.graph?.nodes ?? [])
|
||||
.find(n => n.kind === 'Source' && n.file_path)?.file_path
|
||||
const suggested = firstSrcPath
|
||||
? firstSrcPath.replace(/\.[^.]+$/, '.trac3r').split('/').pop()
|
||||
: 'project.trac3r'
|
||||
path = await tauri.pickProjectSavePath(suggested)
|
||||
if (!path) return
|
||||
}
|
||||
try {
|
||||
const json = serialize({
|
||||
imagePath: image?.path ?? null,
|
||||
dpi,
|
||||
nodeWidth,
|
||||
graph: passes[0].graph,
|
||||
@@ -270,23 +256,20 @@ export default function App() {
|
||||
setProjectPath(path)
|
||||
setStrokes(null)
|
||||
|
||||
// Load the image if the path is still valid
|
||||
if (restored.imagePath) {
|
||||
try {
|
||||
const info = await tauri.loadImage(restored.imagePath)
|
||||
setImage(info)
|
||||
imageRef.current = info
|
||||
setDisplayB64(info.preview_b64)
|
||||
setViewMode('source')
|
||||
// Re-load every Source node's referenced file into the backend cache.
|
||||
// Missing files don't error the load — the Source card just stays
|
||||
// pickerless until the user re-points it at a valid path.
|
||||
const srcNodes = (restored.graph?.nodes ?? []).filter(n => n.kind === 'Source' && n.file_path)
|
||||
const failures = []
|
||||
for (const n of srcNodes) {
|
||||
try { await tauri.loadImage(n.file_path) }
|
||||
catch { failures.push(n.file_path) }
|
||||
}
|
||||
if (failures.length === 0) {
|
||||
setGlobalStatus(`Loaded: ${path.split('/').pop()}`)
|
||||
processPass(0, true)
|
||||
} catch {
|
||||
setImage(null)
|
||||
setDisplayB64(null)
|
||||
setGlobalStatus(`Project loaded — image not found at: ${restored.imagePath}`)
|
||||
}
|
||||
} else {
|
||||
setGlobalStatus(`Loaded: ${path.split('/').pop()}`)
|
||||
setGlobalStatus(`Loaded with ${failures.length} missing source(s): ${failures.join(', ')}`)
|
||||
}
|
||||
} catch (e) {
|
||||
setGlobalStatus(`Load error: ${e}`)
|
||||
@@ -338,11 +321,6 @@ export default function App() {
|
||||
title="Save project (Ctrl+S). Ctrl+Shift+S to Save As.">
|
||||
{projectPath ? 'Save' : 'Save As…'}
|
||||
</button>
|
||||
<button onClick={openImage} disabled={busy}
|
||||
className="px-2 py-1 rounded bg-neutral-700 hover:bg-neutral-600 text-xs transition-colors disabled:opacity-40"
|
||||
title="Open a source image">
|
||||
Open Image…
|
||||
</button>
|
||||
<button onClick={() => window.location.reload()}
|
||||
className="px-2 py-1 rounded bg-neutral-800 hover:bg-neutral-700 text-neutral-500 hover:text-neutral-300 text-xs transition-colors"
|
||||
title="Reload UI (pick up code changes)">
|
||||
@@ -350,13 +328,6 @@ export default function App() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Image info */}
|
||||
{image && (
|
||||
<div className="px-3 py-1.5 border-b border-neutral-800 text-neutral-500 text-xs truncate">
|
||||
{image.path.split('/').pop()} · {image.width}×{image.height}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scrollable sidebar content */}
|
||||
<div className="flex-1 overflow-y-auto flex flex-col">
|
||||
|
||||
@@ -454,7 +425,7 @@ export default function App() {
|
||||
</div>
|
||||
|
||||
{/* Calibration: corner jog + axis-scale */}
|
||||
<CalibrationButtons gcodeConfig={gcodeConfig} imgSize={image} setStatus={setGlobalStatus} />
|
||||
<CalibrationButtons gcodeConfig={gcodeConfig} imgSize={canvasDims} setStatus={setGlobalStatus} />
|
||||
<CalibrationAxis printerUrl={gcodeConfig.printer_url} setStatus={setGlobalStatus} />
|
||||
|
||||
{/* Export & upload */}
|
||||
@@ -520,7 +491,6 @@ export default function App() {
|
||||
scheduleProcess()
|
||||
}}
|
||||
nodePreviews={passes[0].nodePreviews}
|
||||
sourceImageB64={image?.preview_b64 ?? null}
|
||||
nodeWidth={nodeWidth}
|
||||
/>
|
||||
) : viewMode === 'printer' ? (
|
||||
@@ -536,17 +506,17 @@ export default function App() {
|
||||
<Viewport
|
||||
imageB64={displayB64}
|
||||
strokes={viewMode === 'gcode' || viewMode === 'fill' ? strokes : null}
|
||||
imgSize={image}
|
||||
imgSize={canvasDims}
|
||||
viewMode={viewMode}
|
||||
gcodeConfig={gcodeConfig}
|
||||
/>
|
||||
)}
|
||||
{showPerf && <PerfPanel data={perfData} fps={fps} longTasks={longTasks} />}
|
||||
{!image && (
|
||||
{!anyLoadedSource && !anyTextNode && viewMode !== 'pipeline' && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div className="text-center space-y-2">
|
||||
<p className="text-neutral-600 text-lg">No image loaded</p>
|
||||
<p className="text-neutral-700 text-sm">Click Open… to get started</p>
|
||||
<p className="text-neutral-600 text-lg">Nothing to plot yet</p>
|
||||
<p className="text-neutral-700 text-sm">Open the Pipeline tab and add a Source or Text node</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useRef, useState, useCallback, useEffect } from 'react'
|
||||
import Slider from './Slider.jsx'
|
||||
import { KERNELS, BLEND_MODES, defaultKernelProps, defaultHullParams, defaultFillParams, defaultPenOutputParams, defaultTextParams, defaultColorFilter, newNodeId, FILL_STRATEGIES, FILL_STRATEGY_PARAMS, FILL_USES_ANGLE, HERSHEY_FONTS, rgbToHsv, buildColorIsolateFilter } from '../store.js'
|
||||
import { KERNELS, BLEND_MODES, defaultKernelProps, defaultHullParams, defaultFillParams, defaultPenOutputParams, defaultSourceParams, defaultTextParams, defaultColorFilter, newNodeId, FILL_STRATEGIES, FILL_STRATEGY_PARAMS, FILL_USES_ANGLE, HERSHEY_FONTS, rgbToHsv, buildColorIsolateFilter } from '../store.js'
|
||||
import * as tauri from '../hooks/useTauri.js'
|
||||
import ColorFilter from './ColorFilter.jsx'
|
||||
|
||||
// ── Layout constants ───────────────────────────────────────────────────────────
|
||||
@@ -54,9 +55,28 @@ function inputType(kind) {
|
||||
if (kind === 'PenOutput') return 'fill'
|
||||
return null
|
||||
}
|
||||
function isCompatible(fromKind, toKind, existingEdges, fromId, toId) {
|
||||
// Walk upstream from `id` through `edges` and return the Set of Source
|
||||
// node IDs that feed it. Multi-source pipeline rule: each subtree has at
|
||||
// most ONE Source ancestor; we use this to reject wires that would merge
|
||||
// two different Source trees.
|
||||
function upstreamSources(id, nodes, edges) {
|
||||
const kinds = new Map(nodes.map(n => [n.id, n.kind]))
|
||||
const sources = new Set()
|
||||
const visited = new Set()
|
||||
const queue = [id]
|
||||
while (queue.length) {
|
||||
const cur = queue.shift()
|
||||
if (visited.has(cur)) continue
|
||||
visited.add(cur)
|
||||
if (kinds.get(cur) === 'Source') { sources.add(cur); continue }
|
||||
edges.filter(e => e.to === cur).forEach(e => queue.push(e.from))
|
||||
}
|
||||
return sources
|
||||
}
|
||||
|
||||
function isCompatible(fromKind, toKind, existingEdges, fromId, toId, allNodes = []) {
|
||||
if (outputType(fromKind) !== inputType(toKind)) return false
|
||||
// cycle check: can toId reach fromId through existing edges?
|
||||
// Cycle check: can toId reach fromId through existing edges?
|
||||
const visited = new Set()
|
||||
const queue = [toId]
|
||||
while (queue.length) {
|
||||
@@ -66,11 +86,22 @@ function isCompatible(fromKind, toKind, existingEdges, fromId, toId) {
|
||||
visited.add(cur)
|
||||
existingEdges.filter(e => e.from === cur).forEach(e => queue.push(e.to))
|
||||
}
|
||||
// Cross-Source rule: the from-side's tree and the to-side's existing
|
||||
// tree must trace back to the same Source (or to no Source at all,
|
||||
// for Text-only / unrooted subtrees). Wires that would merge two
|
||||
// different Sources into one downstream node are illegal.
|
||||
if (allNodes.length) {
|
||||
const fromSrc = upstreamSources(fromId, allNodes, existingEdges)
|
||||
const toSrc = upstreamSources(toId, allNodes, existingEdges)
|
||||
// Pretend the new edge is in place to see what the merged set would be.
|
||||
const merged = new Set([...fromSrc, ...toSrc])
|
||||
if (merged.size > 1) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ── Component ──────────────────────────────────────────────────────────────────
|
||||
export default function NodeGraph({ graph, onChange, nodePreviews, sourceImageB64, nodeWidth = 220 }) {
|
||||
export default function NodeGraph({ graph, onChange, nodePreviews, nodeWidth = 220 }) {
|
||||
const canvasRef = useRef(null)
|
||||
const worldRef = useRef(null)
|
||||
const [wire, setWire] = useState(null) // { fromId, fromX, fromY, mouseX, mouseY }
|
||||
@@ -248,7 +279,7 @@ export default function NodeGraph({ graph, onChange, nodePreviews, sourceImageB6
|
||||
const fromNode = g.nodes.find(n => n.id === fromId)
|
||||
const toNode = g.nodes.find(n => n.id === toId)
|
||||
if (!fromNode || !toNode) return
|
||||
if (!isCompatible(fromNode.kind, toNode.kind, g.edges, fromId, toId)) return
|
||||
if (!isCompatible(fromNode.kind, toNode.kind, g.edges, fromId, toId, g.nodes)) return
|
||||
const filtered = g.edges.filter(ed => !(ed.to === toId && ed.port === port))
|
||||
if (!filtered.some(ed => ed.from === fromId && ed.to === toId && ed.port === port)) {
|
||||
onChangeRef.current({ ...g, edges: [...filtered, { from: fromId, to: toId, port }] })
|
||||
@@ -355,6 +386,7 @@ export default function NodeGraph({ graph, onChange, nodePreviews, sourceImageB6
|
||||
const node = kind === 'Kernel' ? { id, kind, x, y, ...defaultKernelProps() }
|
||||
: kind === 'Hull' ? { id, kind, x, y, ...defaultHullParams() }
|
||||
: kind === 'Fill' ? { id, kind, x, y, ...defaultFillParams() }
|
||||
: kind === 'Source' ? { id, kind, x, y, ...defaultSourceParams() }
|
||||
: kind === 'Text' ? { id, kind, x, y, ...defaultTextParams() }
|
||||
: kind === 'PenOutput' ? { id, kind, x, y, ...defaultPenOutputParams() }
|
||||
: { id, kind, x, y, blend_mode: 'Average', inputCount: 2 }
|
||||
@@ -388,14 +420,17 @@ export default function NodeGraph({ graph, onChange, nodePreviews, sourceImageB6
|
||||
|
||||
// ── Node rendering ─────────────────────────────────────────────────────────
|
||||
function renderNode(node) {
|
||||
const isFixed = node.kind === 'Source'
|
||||
const isFixed = false // Source nodes are now deletable like everything else
|
||||
const inputCnt = node.kind === 'Combine' ? (node.inputCount ?? 2)
|
||||
: (node.kind === 'Kernel' || node.kind === 'Output' || node.kind === 'Hull' || node.kind === 'Fill' || node.kind === 'PenOutput') ? 1 : 0
|
||||
const hasOut = node.kind !== 'Output' && node.kind !== 'PenOutput'
|
||||
// Text nodes have no inputs; their output ports use the same accent
|
||||
// as Fill since they produce the same `fill` data type downstream.
|
||||
|
||||
const preview = node.kind === 'Source' ? sourceImageB64 : nodePreviews?.[node.id]
|
||||
// Source previews now come from process_pass results like every other
|
||||
// node (the backend encodes the letterboxed canvas as JPEG b64 per
|
||||
// Source). No global "the source image" anymore.
|
||||
const preview = nodePreviews?.[node.id]
|
||||
|
||||
const accentColor = node.kind === 'Source' ? '#7c3aed'
|
||||
: node.kind === 'Hull' ? '#0d9488'
|
||||
@@ -460,7 +495,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 === 'Source' ? (node.file_path ? `Source · ${node.file_path.split('/').pop()}` : 'Source')
|
||||
: node.kind === 'Hull' ? 'Hull'
|
||||
: node.kind === 'Fill' ? (node.strategy ?? 'Fill')
|
||||
: node.kind === 'Text' ? `Text · ${node.font ?? 'futural'}`
|
||||
@@ -479,6 +514,36 @@ export default function NodeGraph({ graph, onChange, nodePreviews, sourceImageB6
|
||||
{/* Body */}
|
||||
<div style={{ padding: '6px 8px', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
|
||||
{node.kind === 'Source' && (<>
|
||||
<button
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
onClick={async () => {
|
||||
try {
|
||||
const path = await tauri.pickImageFile()
|
||||
if (!path) return
|
||||
// Load into the backend cache; result.preview_b64 is
|
||||
// surfaced via process_pass's nodePreviews on the next
|
||||
// run, so we only need the success signal here.
|
||||
await tauri.loadImage(path)
|
||||
updateNode(node.id, { file_path: path })
|
||||
} catch (e) {
|
||||
console.error('[source] load failed:', e)
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
width: '100%', padding: '4px 6px', borderRadius: 3, fontSize: 10, cursor: 'pointer',
|
||||
border: '1px solid #4f46e5',
|
||||
background: node.file_path ? '#1e293b' : '#312e81',
|
||||
color: '#c7d2fe',
|
||||
}}
|
||||
>{node.file_path ? 'Replace image…' : 'Pick image…'}</button>
|
||||
{node.file_path && (
|
||||
<div style={{ fontSize: 9, color: '#6b7280', wordBreak: 'break-all', lineHeight: 1.3 }}>
|
||||
{node.file_path}
|
||||
</div>
|
||||
)}
|
||||
</>)}
|
||||
|
||||
{node.kind === 'Kernel' && (<>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 2 }}>
|
||||
{KERNELS.map(k => (
|
||||
@@ -758,6 +823,7 @@ export default function NodeGraph({ graph, onChange, nodePreviews, sourceImageB6
|
||||
{/* Toolbar */}
|
||||
<div style={{ position: 'absolute', top: 8, left: 8, zIndex: 30, display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
{[
|
||||
['Source', '#7c3aed', '#c4b5fd'],
|
||||
['Kernel', '#374151', '#94a3b8'],
|
||||
['Combine', '#374151', '#94a3b8'],
|
||||
['Hull', '#0d9488', '#5eead4'],
|
||||
|
||||
@@ -122,11 +122,15 @@ export function defaultHullParams() {
|
||||
}
|
||||
}
|
||||
|
||||
export function defaultSourceParams() {
|
||||
return { file_path: null }
|
||||
}
|
||||
|
||||
export function defaultGraph() {
|
||||
const kId = newNodeId('kernel')
|
||||
return {
|
||||
nodes: [
|
||||
{ id: 'source', kind: 'Source', x: 60, y: 160 },
|
||||
{ id: 'source', kind: 'Source', x: 60, y: 160, ...defaultSourceParams() },
|
||||
{ id: kId, kind: 'Kernel', x: 310, y: 100, ...defaultKernelProps() },
|
||||
{ id: 'hull', kind: 'Hull', x: 560, y: 160, ...defaultHullParams() },
|
||||
{ id: 'fill', kind: 'Fill', x: 840, y: 160, ...defaultFillParams() },
|
||||
|
||||
@@ -466,7 +466,11 @@ impl BlendMode {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum NodeKind {
|
||||
Source,
|
||||
/// Each Source node owns its own image file; multiple Sources in one
|
||||
/// graph spawn independent subtrees. `file_path` is `None` until the
|
||||
/// user picks a file on the card. Sources with missing/unloaded files
|
||||
/// produce nothing downstream (their subtree contributes no strokes).
|
||||
Source { file_path: Option<String> },
|
||||
Kernel(DetectionLayer),
|
||||
Combine(BlendMode),
|
||||
Output,
|
||||
@@ -537,13 +541,20 @@ pub struct GraphMaps {
|
||||
pub raw_maps: std::collections::HashMap<String, Vec<u8>>,
|
||||
}
|
||||
|
||||
/// Run the detection graph. With multi-source graphs, every Kernel node
|
||||
/// is tied to its tree's Source via `node_rgbs`; the lookup happens per
|
||||
/// Kernel evaluation. `canvas_w/canvas_h` give the pixel dimensions for
|
||||
/// the response maps — every Source has been letterboxed to this size
|
||||
/// upstream so all maps share a coord frame.
|
||||
pub fn evaluate_graph(
|
||||
rgb: &RgbImage,
|
||||
graph: &DetectionGraph,
|
||||
node_rgbs: &std::collections::HashMap<String, RgbImage>,
|
||||
canvas_w: u32,
|
||||
canvas_h: u32,
|
||||
) -> GraphMaps {
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
|
||||
let n = (rgb.width() * rgb.height()) as usize;
|
||||
let n = (canvas_w * canvas_h) as usize;
|
||||
let bg = || vec![255u8; n];
|
||||
|
||||
if graph.nodes.is_empty() {
|
||||
@@ -593,8 +604,16 @@ pub fn evaluate_graph(
|
||||
for &id in &order {
|
||||
let node = node_map[id];
|
||||
let result: Option<Vec<u8>> = match &node.kind {
|
||||
NodeKind::Source => None,
|
||||
NodeKind::Source { .. } => None,
|
||||
NodeKind::Kernel(layer) => {
|
||||
// Find the source RGB for this Kernel's tree. With multi-source
|
||||
// graphs every node is assigned to exactly one Source upstream
|
||||
// (lib.rs builds the lookup), and that Source's RGB is what
|
||||
// this kernel operates on. Missing entry = source has no file
|
||||
// loaded; produce nothing.
|
||||
let src_rgb = match node_rgbs.get(id) { Some(r) => r, None => {
|
||||
outputs.insert(id, bg()); continue;
|
||||
}};
|
||||
// If an upstream response map exists (e.g. from a Combine node),
|
||||
// convert it to a grayscale RgbImage and apply the kernel to that
|
||||
// instead of the original source. This lets you chain transforms:
|
||||
@@ -602,13 +621,13 @@ pub fn evaluate_graph(
|
||||
let upstream = incoming[id].iter()
|
||||
.find_map(|(fid, _)| outputs.get(fid));
|
||||
let raw = if let Some(up) = upstream {
|
||||
let gray_rgb = RgbImage::from_fn(rgb.width(), rgb.height(), |x, y| {
|
||||
let v = up[(y * rgb.width() + x) as usize];
|
||||
let gray_rgb = RgbImage::from_fn(src_rgb.width(), src_rgb.height(), |x, y| {
|
||||
let v = up[(y * src_rgb.width() + x) as usize];
|
||||
image::Rgb([v, v, v])
|
||||
});
|
||||
apply_layer(&gray_rgb, layer)
|
||||
} else {
|
||||
apply_layer(rgb, layer)
|
||||
apply_layer(src_rgb, layer)
|
||||
};
|
||||
let w = layer.weight;
|
||||
Some(if (w - 1.0).abs() < 1e-6 {
|
||||
|
||||
248
src/lib.rs
248
src/lib.rs
@@ -57,11 +57,12 @@ struct FillCacheEntry {
|
||||
|
||||
// ── Fingerprint helpers ────────────────────────────────────────────────────────
|
||||
|
||||
fn fp_source(orig_w: u32, orig_h: u32, dpi: Option<u32>, img_w_mm: Option<f32>) -> u64 {
|
||||
fn fp_source(node: &GraphNodePayload, dpi: Option<u32>, img_w_mm: Option<f32>, img_h_mm: Option<f32>) -> u64 {
|
||||
let mut h = DefaultHasher::new();
|
||||
h.write_u32(orig_w); h.write_u32(orig_h);
|
||||
h.write(node.file_path.as_deref().unwrap_or("").as_bytes());
|
||||
h.write_u32(dpi.unwrap_or(0));
|
||||
h.write_u32(img_w_mm.map(|v| v.to_bits()).unwrap_or(0));
|
||||
h.write_u32(img_h_mm.map(|v| v.to_bits()).unwrap_or(0));
|
||||
h.finish()
|
||||
}
|
||||
fn fp_kernel(node: &GraphNodePayload, upstream_fp: u64) -> u64 {
|
||||
@@ -140,7 +141,7 @@ fn fp_text(node: &GraphNodePayload) -> u64 {
|
||||
/// Compute a fingerprint for every node in topological order.
|
||||
/// Fingerprints cascade: downstream nodes include upstream fps so any upstream
|
||||
/// change propagates automatically.
|
||||
fn compute_node_fingerprints(payload: &ProcessPassPayload, source_fp: u64)
|
||||
fn compute_node_fingerprints(payload: &ProcessPassPayload)
|
||||
-> std::collections::HashMap<String, u64>
|
||||
{
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
@@ -167,10 +168,10 @@ fn compute_node_fingerprints(payload: &ProcessPassPayload, source_fp: u64)
|
||||
let mut ins = incoming[id].clone();
|
||||
ins.sort_by_key(|&(_, p)| p);
|
||||
let up_fps: Vec<u64> = ins.iter()
|
||||
.map(|(fid, _)| fps.get(*fid).copied().unwrap_or(source_fp)).collect();
|
||||
let first = up_fps.first().copied().unwrap_or(source_fp);
|
||||
.map(|(fid, _)| fps.get(*fid).copied().unwrap_or(0)).collect();
|
||||
let first = up_fps.first().copied().unwrap_or(0);
|
||||
let fp = match node.kind.as_str() {
|
||||
"Source" => source_fp,
|
||||
"Source" => fp_source(node, payload.dpi, payload.img_w_mm, payload.img_h_mm),
|
||||
"Kernel" => fp_kernel(node, first),
|
||||
"Combine" => fp_combine(node, &up_fps),
|
||||
"Hull" => fp_hull(node, first),
|
||||
@@ -192,8 +193,10 @@ fn compute_node_fingerprints(payload: &ProcessPassPayload, source_fp: u64)
|
||||
// ── Shared app state ───────────────────────────────────────────────────────────
|
||||
|
||||
struct AppState {
|
||||
image_rgb: Option<image::RgbImage>,
|
||||
image_path: String,
|
||||
/// Image cache, keyed by absolute file path. Each Source node carries
|
||||
/// its own `file_path`; the cache lets repeated process_pass calls
|
||||
/// reuse the decoded pixels instead of re-reading from disk.
|
||||
images: std::collections::HashMap<String, image::RgbImage>,
|
||||
passes: Vec<PassState>,
|
||||
}
|
||||
|
||||
@@ -209,7 +212,7 @@ struct PassState {
|
||||
|
||||
impl Default for AppState {
|
||||
fn default() -> Self {
|
||||
Self { image_rgb: None, image_path: String::new(), passes: Vec::new() }
|
||||
Self { images: std::collections::HashMap::new(), passes: Vec::new() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,6 +262,8 @@ pub struct GraphNodePayload {
|
||||
pub pen_color: Option<Vec<u8>>, // [r, g, b]
|
||||
pub pen_label: Option<String>,
|
||||
pub pen_order: Option<u32>,
|
||||
// Source params (optional — only for kind="Source")
|
||||
pub file_path: Option<String>,
|
||||
// Text params (optional — only for kind="Text")
|
||||
pub text: Option<String>,
|
||||
pub font: Option<String>,
|
||||
@@ -390,7 +395,7 @@ fn to_detection_graph(payload: &DetectionGraphPayload) -> detect::DetectionGraph
|
||||
use detect::DetectionKernel::*;
|
||||
let nodes = payload.nodes.iter().map(|n| {
|
||||
let kind = match n.kind.as_str() {
|
||||
"Source" => detect::NodeKind::Source,
|
||||
"Source" => detect::NodeKind::Source { file_path: n.file_path.clone() },
|
||||
"Kernel" => {
|
||||
let kernel = match n.kernel.as_deref().unwrap_or("Luminance") {
|
||||
"Sobel" => Sobel,
|
||||
@@ -604,7 +609,9 @@ fn render_hull_preview(response: &[u8], hulls_list: &[hulls::Hull], w: u32, h: u
|
||||
}
|
||||
|
||||
fn process_pass_work(
|
||||
rgb: &image::RgbImage,
|
||||
source_rgbs: std::collections::HashMap<String, image::RgbImage>,
|
||||
canvas_w: u32,
|
||||
canvas_h: u32,
|
||||
payload: ProcessPassPayload,
|
||||
mut cache: NodeCache,
|
||||
) -> (Vec<hulls::Hull>, Vec<PenResult>, Vec<u8>, ProcessResult, NodeCache) {
|
||||
@@ -615,29 +622,60 @@ fn process_pass_work(
|
||||
let mut cache_hits = 0u32;
|
||||
let mut cache_misses = 0u32;
|
||||
|
||||
// ── DPI scale ─────────────────────────────────────────────────────────────
|
||||
let mut t = Instant::now();
|
||||
let (orig_w, orig_h) = rgb.dimensions();
|
||||
let scaled_opt: Option<image::RgbImage> = match (payload.dpi, payload.img_w_mm) {
|
||||
(Some(dpi), Some(img_w_mm)) if dpi > 0 && img_w_mm > 0.0 => {
|
||||
let target_w = ((img_w_mm * dpi as f32 / 25.4).round() as u32).max(1);
|
||||
let target_h = ((orig_h as f32 * target_w as f32 / orig_w as f32).round() as u32).max(1);
|
||||
if target_w != orig_w || target_h != orig_h {
|
||||
Some(image::DynamicImage::ImageRgb8(rgb.clone())
|
||||
.resize_exact(target_w, target_h, image::imageops::FilterType::CatmullRom)
|
||||
.to_rgb8())
|
||||
} else { None }
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let rgb: &image::RgbImage = scaled_opt.as_ref().unwrap_or(rgb);
|
||||
t = lap!(steps, "dpi_scale", t);
|
||||
// Each Source is already letterboxed to the paper canvas by the caller,
|
||||
// so all of them share `(canvas_w, canvas_h)` and downstream hulls land
|
||||
// in a single coord frame.
|
||||
let (w, h) = (canvas_w, canvas_h);
|
||||
|
||||
let (w, h) = rgb.dimensions();
|
||||
// ── Per-node Source RGB lookup ────────────────────────────────────────────
|
||||
// Trees can't merge (frontend enforces; backend assumes), so each non-
|
||||
// Source node has exactly one Source ancestor. Topo-walk to propagate
|
||||
// the source_id from each Source to all its descendants, then build a
|
||||
// node_id → &RgbImage map for evaluate_graph.
|
||||
let mut t = Instant::now();
|
||||
let node_to_source: std::collections::HashMap<String, String> = {
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
let mut owner: HashMap<String, String> = HashMap::new();
|
||||
let mut indeg: HashMap<&str, usize> = HashMap::new();
|
||||
let mut outs: HashMap<&str, Vec<&str>> = HashMap::new();
|
||||
for n in &payload.graph.nodes { indeg.entry(&n.id).or_insert(0); outs.entry(&n.id).or_default(); }
|
||||
for e in &payload.graph.edges {
|
||||
*indeg.entry(&e.to).or_insert(0) += 1;
|
||||
outs.entry(&e.from).or_default().push(&e.to);
|
||||
}
|
||||
let mut q: VecDeque<&str> = indeg.iter().filter(|(_, &d)| d == 0).map(|(&k, _)| k).collect();
|
||||
let kinds: HashMap<&str, &str> = payload.graph.nodes.iter().map(|n| (n.id.as_str(), n.kind.as_str())).collect();
|
||||
while let Some(id) = q.pop_front() {
|
||||
if kinds.get(id).copied() == Some("Source") {
|
||||
owner.insert(id.to_string(), id.to_string());
|
||||
}
|
||||
// descendants inherit owner from this node (if set)
|
||||
if let Some(o) = owner.get(id).cloned() {
|
||||
for &nx in outs.get(id).into_iter().flatten() {
|
||||
owner.entry(nx.to_string()).or_insert(o.clone());
|
||||
}
|
||||
}
|
||||
for &nx in outs.get(id).into_iter().flatten() {
|
||||
let d = indeg.get_mut(nx).unwrap();
|
||||
*d -= 1;
|
||||
if *d == 0 { q.push_back(nx); }
|
||||
}
|
||||
}
|
||||
owner
|
||||
};
|
||||
let node_rgbs: std::collections::HashMap<String, image::RgbImage> = {
|
||||
let mut m = std::collections::HashMap::new();
|
||||
for (node_id, src_id) in &node_to_source {
|
||||
if let Some(rgb) = source_rgbs.get(src_id) {
|
||||
m.insert(node_id.clone(), rgb.clone());
|
||||
}
|
||||
}
|
||||
m
|
||||
};
|
||||
t = lap!(steps, "source_resolve", t);
|
||||
|
||||
// ── Fingerprints ──────────────────────────────────────────────────────────
|
||||
let source_fp = fp_source(orig_w, orig_h, payload.dpi, payload.img_w_mm);
|
||||
let node_fps = compute_node_fingerprints(&payload, source_fp);
|
||||
let node_fps = compute_node_fingerprints(&payload);
|
||||
|
||||
// Detect-phase fingerprint: combines all Kernel/Combine/Source node fps.
|
||||
let detect_fp = {
|
||||
@@ -663,7 +701,7 @@ fn process_pass_work(
|
||||
}
|
||||
} else {
|
||||
cache_misses += 1;
|
||||
let maps = detect::evaluate_graph(rgb, &det_graph);
|
||||
let maps = detect::evaluate_graph(&det_graph, &node_rgbs, canvas_w, canvas_h);
|
||||
cache.detect_fp = detect_fp;
|
||||
cache.detect_response = maps.response.clone();
|
||||
cache.detect_maps = maps.raw_maps.clone();
|
||||
@@ -679,11 +717,21 @@ fn process_pass_work(
|
||||
|
||||
// ── Detect node previews (cached per-node) ────────────────────────────────
|
||||
let mut node_previews: std::collections::HashMap<String, String> = Default::default();
|
||||
// Source nodes get a thumbnail of their letterboxed canvas — the
|
||||
// graph_maps don't include Source raw_maps (Sources produce no
|
||||
// response map), so we add them here directly.
|
||||
for node in &det_graph.nodes {
|
||||
if let detect::NodeKind::Source { .. } = &node.kind {
|
||||
if let Some(rgb) = source_rgbs.get(&node.id) {
|
||||
node_previews.insert(node.id.clone(), rgb_to_b64_jpeg(rgb));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (id, map) in &graph_maps.raw_maps {
|
||||
let is_detect_node = det_graph.nodes.iter().find(|n| &n.id == id)
|
||||
.map_or(false, |n| !matches!(
|
||||
n.kind,
|
||||
detect::NodeKind::Source
|
||||
detect::NodeKind::Source { .. }
|
||||
| detect::NodeKind::Hull { .. }
|
||||
| detect::NodeKind::Fill { .. }
|
||||
| detect::NodeKind::PenOutput { .. }
|
||||
@@ -752,7 +800,12 @@ fn process_pass_work(
|
||||
}
|
||||
}
|
||||
cache_misses += 1;
|
||||
// Cache miss — compute
|
||||
// Cache miss — compute. Each Hull belongs to exactly one
|
||||
// Source tree; pull that tree's RGB for color extraction.
|
||||
let src_rgb = match node_rgbs.get(&node.id) {
|
||||
Some(r) => r,
|
||||
None => continue, // Hull with no upstream Source RGB
|
||||
};
|
||||
let hull_params = hulls::HullParams {
|
||||
threshold: *threshold,
|
||||
min_area: *min_area,
|
||||
@@ -760,7 +813,7 @@ fn process_pass_work(
|
||||
connectivity: if *eight_conn { hulls::Connectivity::Eight }
|
||||
else { hulls::Connectivity::Four },
|
||||
};
|
||||
let extracted = hulls::extract_hulls(response, rgb, w, h, &hull_params);
|
||||
let extracted = hulls::extract_hulls(response, src_rgb, w, h, &hull_params);
|
||||
let preview = render_hull_preview(response, &extracted, w, h);
|
||||
cache.hull_entries.insert(node.id.clone(), HullCacheEntry {
|
||||
fp: hull_fp,
|
||||
@@ -771,6 +824,10 @@ fn process_pass_work(
|
||||
(extracted, preview)
|
||||
} else {
|
||||
// No fingerprint — always compute, never cache
|
||||
let src_rgb = match node_rgbs.get(&node.id) {
|
||||
Some(r) => r,
|
||||
None => continue,
|
||||
};
|
||||
let hull_params = hulls::HullParams {
|
||||
threshold: *threshold,
|
||||
min_area: *min_area,
|
||||
@@ -778,7 +835,7 @@ fn process_pass_work(
|
||||
connectivity: if *eight_conn { hulls::Connectivity::Eight }
|
||||
else { hulls::Connectivity::Four },
|
||||
};
|
||||
let extracted = hulls::extract_hulls(response, rgb, w, h, &hull_params);
|
||||
let extracted = hulls::extract_hulls(response, src_rgb, w, h, &hull_params);
|
||||
let preview = render_hull_preview(response, &extracted, w, h);
|
||||
(extracted, preview)
|
||||
};
|
||||
@@ -1007,6 +1064,10 @@ fn process_pass_work(
|
||||
|
||||
// ── Tauri commands ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Load an image file into the per-path cache. Each Source node in the
|
||||
/// graph references files by absolute path; the cache holds decoded
|
||||
/// `RgbImage`s so repeated process_pass calls don't re-decode. Returns
|
||||
/// metadata + preview thumbnail the frontend uses for the Source card.
|
||||
#[tauri::command]
|
||||
fn load_image(path: String, state: State<Mutex<AppState>>) -> Result<ImageInfo, String> {
|
||||
let dyn_img = image::open(&path).map_err(|e| e.to_string())?;
|
||||
@@ -1015,14 +1076,10 @@ fn load_image(path: String, state: State<Mutex<AppState>>) -> Result<ImageInfo,
|
||||
let preview_b64 = rgb_to_b64_jpeg(&rgb);
|
||||
|
||||
let mut st = state.lock().unwrap();
|
||||
st.image_path = path.clone();
|
||||
st.image_rgb = Some(rgb);
|
||||
// Reset all pass state (including cache) when a new image is loaded
|
||||
for ps in &mut st.passes {
|
||||
ps.hulls.clear();
|
||||
ps.pen_results.clear();
|
||||
ps.node_cache = NodeCache::default();
|
||||
}
|
||||
st.images.insert(path.clone(), rgb);
|
||||
// Don't reset pass state here — only the Source node whose file_path
|
||||
// changed needs re-fingerprinting, and the per-node cache handles
|
||||
// that automatically via fp_source.
|
||||
|
||||
Ok(ImageInfo { width: w, height: h, path, preview_b64 })
|
||||
}
|
||||
@@ -1054,23 +1111,45 @@ fn set_pass_count(count: usize, state: State<Mutex<AppState>>) {
|
||||
|
||||
#[tauri::command]
|
||||
async fn process_pass(payload: ProcessPassPayload, state: State<'_, Mutex<AppState>>) -> Result<ProcessResult, String> {
|
||||
// Clone the image and release the lock immediately — the heavy work must
|
||||
// not hold the mutex, so other commands stay responsive during processing.
|
||||
// If no image is loaded, synthesize a blank paper-sized canvas so
|
||||
// Text-only graphs still have a coordinate frame to operate in.
|
||||
let rgb = {
|
||||
let st = state.lock().unwrap();
|
||||
match st.image_rgb.as_ref() {
|
||||
Some(img) => img.clone(),
|
||||
None => {
|
||||
// Resolve every Source node's file_path against the image cache, then
|
||||
// letterbox-fit each one into a paper-sized canvas so all Sources end
|
||||
// up at identical pixel dimensions — downstream hulls/strokes share
|
||||
// one coord frame, gcode export uses one img_w/img_h.
|
||||
let dpi = payload.dpi.unwrap_or(150).max(1) as f32;
|
||||
let paper_w = payload.img_w_mm.unwrap_or(210.0).max(1.0);
|
||||
let paper_h = payload.img_h_mm.unwrap_or(297.0).max(1.0);
|
||||
let w_px = ((paper_w * dpi / 25.4).round() as u32).max(1);
|
||||
let h_px = ((paper_h * dpi / 25.4).round() as u32).max(1);
|
||||
image::RgbImage::from_pixel(w_px, h_px, image::Rgb([255, 255, 255]))
|
||||
}
|
||||
let canvas_w = ((paper_w * dpi / 25.4).round() as u32).max(1);
|
||||
let canvas_h = ((paper_h * dpi / 25.4).round() as u32).max(1);
|
||||
|
||||
let source_rgbs: std::collections::HashMap<String, image::RgbImage> = {
|
||||
let st = state.lock().unwrap();
|
||||
let mut out = std::collections::HashMap::new();
|
||||
for n in &payload.graph.nodes {
|
||||
if n.kind != "Source" { continue; }
|
||||
let path = match n.file_path.as_deref() {
|
||||
Some(p) if !p.is_empty() => p,
|
||||
_ => continue, // Source with no file picked yet → skip
|
||||
};
|
||||
let raw = match st.images.get(path) {
|
||||
Some(img) => img,
|
||||
None => continue, // file referenced but not loaded → skip
|
||||
};
|
||||
// Letterbox-fit into the paper canvas: scale uniformly to fit
|
||||
// inside (canvas_w × canvas_h), centre, fill background white.
|
||||
let (rw, rh) = raw.dimensions();
|
||||
let scale = (canvas_w as f32 / rw as f32).min(canvas_h as f32 / rh as f32);
|
||||
let tw = ((rw as f32 * scale).round() as u32).max(1);
|
||||
let th = ((rh as f32 * scale).round() as u32).max(1);
|
||||
let resized = image::DynamicImage::ImageRgb8(raw.clone())
|
||||
.resize_exact(tw, th, image::imageops::FilterType::CatmullRom)
|
||||
.to_rgb8();
|
||||
let mut canvas = image::RgbImage::from_pixel(canvas_w, canvas_h, image::Rgb([255, 255, 255]));
|
||||
let off_x = (canvas_w.saturating_sub(tw)) / 2;
|
||||
let off_y = (canvas_h.saturating_sub(th)) / 2;
|
||||
image::imageops::overlay(&mut canvas, &resized, off_x as i64, off_y as i64);
|
||||
out.insert(n.id.clone(), canvas);
|
||||
}
|
||||
out
|
||||
};
|
||||
|
||||
let idx = payload.pass_index;
|
||||
@@ -1083,7 +1162,7 @@ async fn process_pass(payload: ProcessPassPayload, state: State<'_, Mutex<AppSta
|
||||
|
||||
let (new_hulls, new_fill, response_map, result, new_cache) =
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
process_pass_work(&rgb, payload, cache)
|
||||
process_pass_work(source_rgbs, canvas_w, canvas_h, payload, cache)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
@@ -2012,7 +2091,7 @@ fn get_all_strokes(
|
||||
let (img_width, img_height) = st.passes.first()
|
||||
.filter(|p| p.img_w > 0)
|
||||
.map(|p| (p.img_w, p.img_h))
|
||||
.unwrap_or_else(|| st.image_rgb.as_ref().map(|r| r.dimensions()).unwrap_or((1, 1)));
|
||||
.unwrap_or((1, 1));
|
||||
let mut all: Vec<PassStrokesPayload> = Vec::new();
|
||||
for ps in st.passes.iter() {
|
||||
let mut pens = ps.pen_results.clone();
|
||||
@@ -2033,11 +2112,10 @@ fn get_all_strokes(
|
||||
#[tauri::command]
|
||||
fn get_gcode_viz(state: State<Mutex<AppState>>) -> Result<String, String> {
|
||||
let st = state.lock().unwrap();
|
||||
let rgb = st.image_rgb.as_ref().ok_or("No image loaded")?;
|
||||
let (w, h) = st.passes.first()
|
||||
.filter(|p| p.img_w > 0)
|
||||
.map(|p| (p.img_w, p.img_h))
|
||||
.unwrap_or_else(|| rgb.dimensions());
|
||||
.ok_or("No processed pass yet")?;
|
||||
|
||||
let mut svg = format!(
|
||||
r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {w} {h}"><rect width="{w}" height="{h}" fill="#f5f0e8"/>"##
|
||||
@@ -2110,7 +2188,12 @@ fn export_debug_state(
|
||||
state: State<Mutex<AppState>>,
|
||||
) -> Result<String, String> {
|
||||
let st = state.lock().unwrap();
|
||||
let (w, h) = st.image_rgb.as_ref().map(|r| r.dimensions()).unwrap_or((0, 0));
|
||||
// With multi-source there's no single image dim; use the pipeline canvas
|
||||
// dims from the most-recently-processed pass.
|
||||
let (w, h) = st.passes.first()
|
||||
.filter(|p| p.img_w > 0)
|
||||
.map(|p| (p.img_w, p.img_h))
|
||||
.unwrap_or((0, 0));
|
||||
|
||||
let passes: Vec<serde_json::Value> = st.passes.iter().enumerate().map(|(i, ps)| {
|
||||
let total_pixels: usize = ps.hulls.iter().map(|h| h.pixels.len()).sum();
|
||||
@@ -2134,17 +2217,22 @@ fn export_debug_state(
|
||||
})
|
||||
}).collect();
|
||||
|
||||
// Collect every Source node's file_path across all loaded passes' graphs;
|
||||
// there's no single canonical "project image" anymore.
|
||||
let source_paths: Vec<String> = st.images.keys().cloned().collect();
|
||||
let dump = serde_json::json!({
|
||||
"image_path": st.image_path,
|
||||
"source_paths": source_paths,
|
||||
"image_width": w,
|
||||
"image_height": h,
|
||||
"passes": passes,
|
||||
});
|
||||
|
||||
let out_path = std::path::Path::new(&st.image_path)
|
||||
.parent()
|
||||
.unwrap_or(std::path::Path::new("/tmp"))
|
||||
.join("trac3r_debug.json");
|
||||
// Pick the first known source's directory for the dump file location;
|
||||
// fall back to /tmp if no images have been loaded.
|
||||
let out_dir = st.images.keys().next()
|
||||
.and_then(|p| std::path::Path::new(p).parent().map(|p| p.to_path_buf()))
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("/tmp"));
|
||||
let out_path = out_dir.join("trac3r_debug.json");
|
||||
|
||||
let json = serde_json::to_string_pretty(&dump).map_err(|e| e.to_string())?;
|
||||
std::fs::write(&out_path, &json).map_err(|e| e.to_string())?;
|
||||
@@ -2202,6 +2290,7 @@ mod blocking_tests {
|
||||
strategy: None, spacing: None, angle: None, param: None,
|
||||
smooth_rdp: None, smooth_iters: None,
|
||||
pen_color: None, pen_label: None, pen_order: None,
|
||||
file_path: None,
|
||||
text: None, font: None, font_size_mm: None, line_spacing_mm: None,
|
||||
x_mm: None, y_mm: None, align: None, underline: None,
|
||||
}
|
||||
@@ -2260,22 +2349,27 @@ mod blocking_tests {
|
||||
async fn process_pass_does_not_hold_mutex_during_computation() {
|
||||
let rgb = synthetic_image(800, 600);
|
||||
let state = Arc::new(Mutex::new(AppState {
|
||||
image_rgb: Some(rgb.clone()),
|
||||
image_path: String::new(),
|
||||
images: std::collections::HashMap::from([("test.png".to_string(), rgb.clone())]),
|
||||
passes: Vec::new(),
|
||||
}));
|
||||
|
||||
// Clone image and release lock — this is exactly what the command handler does.
|
||||
let work_rgb = {
|
||||
// Replicate what the command does: take a snapshot of the source RGBs
|
||||
// by cloning out of the mutex, then release the lock for the heavy work.
|
||||
let work_sources: std::collections::HashMap<String, image::RgbImage> = {
|
||||
let st = state.lock().unwrap();
|
||||
st.image_rgb.clone().unwrap()
|
||||
std::collections::HashMap::from([("source".to_string(), st.images["test.png"].clone())])
|
||||
};
|
||||
|
||||
let state_for_check = Arc::clone(&state);
|
||||
let payload = default_process_payload();
|
||||
let mut payload = default_process_payload();
|
||||
// Wire the test's Source node to the path the cache holds.
|
||||
for n in &mut payload.graph.nodes {
|
||||
if n.kind == "Source" { n.file_path = Some("test.png".into()); }
|
||||
}
|
||||
|
||||
let (cw, ch) = (800u32, 600u32);
|
||||
let work = tokio::task::spawn_blocking(move || {
|
||||
process_pass_work(&work_rgb, payload, NodeCache::default())
|
||||
process_pass_work(work_sources, cw, ch, payload, NodeCache::default())
|
||||
});
|
||||
|
||||
// Give the blocking thread a moment to start, then try to grab the mutex.
|
||||
@@ -2560,7 +2654,7 @@ mod viz_tests {
|
||||
};
|
||||
let graph = detect::DetectionGraph {
|
||||
nodes: vec![
|
||||
detect::GraphNode { id: "source".into(), kind: detect::NodeKind::Source },
|
||||
detect::GraphNode { id: "source".into(), kind: detect::NodeKind::Source { file_path: None } },
|
||||
detect::GraphNode { id: "k1".into(), kind: detect::NodeKind::Kernel(layer) },
|
||||
detect::GraphNode { id: "hull".into(), kind: detect::NodeKind::Hull {
|
||||
threshold: threshold as u8, min_area, rdp_epsilon: rdp_eps,
|
||||
@@ -2576,7 +2670,9 @@ mod viz_tests {
|
||||
detect::GraphEdge { from: "k1".into(), to: "hull".into(), port: 0 },
|
||||
],
|
||||
};
|
||||
let gm = detect::evaluate_graph(&img, &graph);
|
||||
let node_rgbs: std::collections::HashMap<String, image::RgbImage> =
|
||||
graph.nodes.iter().map(|n| (n.id.clone(), img.clone())).collect();
|
||||
let gm = detect::evaluate_graph(&graph, &node_rgbs, w, h);
|
||||
let response = gm.raw_maps.get("hull").cloned().unwrap_or(gm.response);
|
||||
let params = HullParams { threshold, min_area, rdp_epsilon: rdp_eps,
|
||||
connectivity: hulls::Connectivity::Four };
|
||||
|
||||
Reference in New Issue
Block a user