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:
Mitchell Hansen
2026-05-08 22:34:00 -07:00
parent da77d7b3e9
commit 2d2b76757f
5 changed files with 328 additions and 173 deletions

View File

@@ -13,17 +13,16 @@ import * as tauri from './hooks/useTauri.js'
import { serialize, deserialize } from './project.js' import { serialize, deserialize } from './project.js'
import { useFps } from './hooks/useFps.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() { export default function App() {
const [image, setImage] = useState(null)
const [passes, setPasses] = useState([defaultPass(0)]) const [passes, setPasses] = useState([defaultPass(0)])
// Single pass — multi-pass is replaced by PenOutput nodes in the graph // Single pass — multi-pass is replaced by PenOutput nodes in the graph
const [gcodeConfig, setGcodeConfig] = useState(defaultGcodeConfig()) 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 [displayB64, setDisplayB64] = useState(null) // current image shown in viewport
const [busy, setBusy] = useState(false) 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 [strokes, setStrokes] = useState(null)
const [showPerf, setShowPerf] = useState(false) const [showPerf, setShowPerf] = useState(false)
const [perfData, setPerfData] = useState(null) const [perfData, setPerfData] = useState(null)
@@ -37,6 +36,17 @@ export default function App() {
const resizing = useRef(false) const resizing = useRef(false)
const hasOutput = passes.some(p => p.strokeCount > 0) 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 // Ctrl+S / Ctrl+Shift+S — ref pattern keeps listener stable across renders
const saveProjectRef = useRef(null) const saveProjectRef = useRef(null)
@@ -84,11 +94,9 @@ export default function App() {
// Always-fresh refs so debounced callbacks never close over stale state // Always-fresh refs so debounced callbacks never close over stale state
const passesRef = useRef(passes) const passesRef = useRef(passes)
const imageRef = useRef(image)
const dpiRef = useRef(dpi) const dpiRef = useRef(dpi)
const gcodeConfigRef = useRef(gcodeConfig) const gcodeConfigRef = useRef(gcodeConfig)
passesRef.current = passes passesRef.current = passes
imageRef.current = image
dpiRef.current = dpi dpiRef.current = dpi
gcodeConfigRef.current = gcodeConfig gcodeConfigRef.current = gcodeConfig
@@ -104,11 +112,7 @@ export default function App() {
// ── Refresh viewport whenever view mode or active pass changes ───────────── // ── Refresh viewport whenever view mode or active pass changes ─────────────
useEffect(() => { useEffect(() => {
async function refresh() { async function refresh() {
if (!image) { setDisplayB64(null); return }
switch (viewMode) { switch (viewMode) {
case 'source':
setDisplayB64(image.preview_b64)
break
case 'pipeline': case 'pipeline':
setDisplayB64(passes[0]?.vizB64 ?? null) setDisplayB64(passes[0]?.vizB64 ?? null)
break break
@@ -130,50 +134,30 @@ export default function App() {
} }
} }
refresh() refresh()
}, [viewMode, image, passes[0]?.vizB64, passes[0]?.hullCount, totalStrokeCount]) }, [viewMode, 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)
}
// ── Process a pass ───────────────────────────────────────────────────────── // ── Process a pass ─────────────────────────────────────────────────────────
// silent=true: auto-reprocess from slider change — doesn't block UI with global busy // silent=true: auto-reprocess from slider change — doesn't block UI with global busy
const processPass = useCallback(async (idx, silent = false) => { const processPass = useCallback(async (idx, silent = false) => {
const pass = passesRef.current[idx] const pass = passesRef.current[idx]
const hasImage = !!imageRef.current const nodes = pass.graph?.nodes ?? []
const hasTextNode = (pass.graph?.nodes ?? []).some(n => n.kind === 'Text') const hasLoadedSource = nodes.some(n => n.kind === 'Source' && n.file_path)
if (!hasImage && !hasTextNode) return const hasTextNode = nodes.some(n => n.kind === 'Text')
if (!hasLoadedSource && !hasTextNode) return
if (!silent) setBusy(true) if (!silent) setBusy(true)
// Reset counts so viewport doesn't show stale data during reprocessing. // Reset counts so viewport doesn't show stale data during reprocessing.
updatePass(idx, { status: 'Processing…', vizB64: null, hullCount: 0, strokeCount: 0 }) updatePass(idx, { status: 'Processing…', vizB64: null, hullCount: 0, strokeCount: 0 })
const t0 = performance.now() const t0 = performance.now()
try { try {
// For text-only projects (no source image) the backend synthesises a // Backend letterboxes every Source into the paper canvas, so we hand
// paper-sized blank canvas — we hand it both paper dimensions so it // it the paper dimensions directly — no per-image scaling knob anymore.
// knows what to allocate. Image-mode keeps the user's img_w_mm scale.
const paperW = gcodeConfigRef.current.paper_w_mm const paperW = gcodeConfigRef.current.paper_w_mm
const paperH = gcodeConfigRef.current.paper_h_mm const paperH = gcodeConfigRef.current.paper_h_mm
const result = await tauri.processPass({ const result = await tauri.processPass({
pass_index: idx, pass_index: idx,
graph: pass.graph, graph: pass.graph,
dpi: dpiRef.current, dpi: dpiRef.current,
img_w_mm: hasImage ? gcodeConfigRef.current.img_w_mm : paperW, img_w_mm: paperW,
img_h_mm: paperH, img_h_mm: paperH,
}) })
const js_process = Math.round(performance.now() - t0) const js_process = Math.round(performance.now() - t0)
@@ -205,8 +189,8 @@ export default function App() {
scheduleProcessRef.current = scheduleProcess scheduleProcessRef.current = scheduleProcess
useEffect(() => { useEffect(() => {
if (imageRef.current) scheduleProcess() scheduleProcess()
}, [dpi, gcodeConfig.img_w_mm]) }, [dpi, gcodeConfig.paper_w_mm, gcodeConfig.paper_h_mm])
// ── Export ───────────────────────────────────────────────────────────────── // ── Export ─────────────────────────────────────────────────────────────────
async function exportAll() { async function exportAll() {
@@ -226,15 +210,17 @@ export default function App() {
async function saveProject(saveAs = false) { async function saveProject(saveAs = false) {
let path = saveAs ? null : projectPath let path = saveAs ? null : projectPath
if (!path) { if (!path) {
const suggested = image // Suggest a filename based on the first Source node's image, if any.
? image.path.replace(/\.[^.]+$/, '.trac3r').split('/').pop() 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' : 'project.trac3r'
path = await tauri.pickProjectSavePath(suggested) path = await tauri.pickProjectSavePath(suggested)
if (!path) return if (!path) return
} }
try { try {
const json = serialize({ const json = serialize({
imagePath: image?.path ?? null,
dpi, dpi,
nodeWidth, nodeWidth,
graph: passes[0].graph, graph: passes[0].graph,
@@ -270,23 +256,20 @@ export default function App() {
setProjectPath(path) setProjectPath(path)
setStrokes(null) setStrokes(null)
// Load the image if the path is still valid // Re-load every Source node's referenced file into the backend cache.
if (restored.imagePath) { // Missing files don't error the load — the Source card just stays
try { // pickerless until the user re-points it at a valid path.
const info = await tauri.loadImage(restored.imagePath) const srcNodes = (restored.graph?.nodes ?? []).filter(n => n.kind === 'Source' && n.file_path)
setImage(info) const failures = []
imageRef.current = info for (const n of srcNodes) {
setDisplayB64(info.preview_b64) try { await tauri.loadImage(n.file_path) }
setViewMode('source') catch { failures.push(n.file_path) }
}
if (failures.length === 0) {
setGlobalStatus(`Loaded: ${path.split('/').pop()}`) setGlobalStatus(`Loaded: ${path.split('/').pop()}`)
processPass(0, true) processPass(0, true)
} catch {
setImage(null)
setDisplayB64(null)
setGlobalStatus(`Project loaded — image not found at: ${restored.imagePath}`)
}
} else { } else {
setGlobalStatus(`Loaded: ${path.split('/').pop()}`) setGlobalStatus(`Loaded with ${failures.length} missing source(s): ${failures.join(', ')}`)
} }
} catch (e) { } catch (e) {
setGlobalStatus(`Load error: ${e}`) setGlobalStatus(`Load error: ${e}`)
@@ -338,11 +321,6 @@ export default function App() {
title="Save project (Ctrl+S). Ctrl+Shift+S to Save As."> title="Save project (Ctrl+S). Ctrl+Shift+S to Save As.">
{projectPath ? 'Save' : 'Save As…'} {projectPath ? 'Save' : 'Save As…'}
</button> </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()} <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" 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)"> title="Reload UI (pick up code changes)">
@@ -350,13 +328,6 @@ export default function App() {
</button> </button>
</div> </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 */} {/* Scrollable sidebar content */}
<div className="flex-1 overflow-y-auto flex flex-col"> <div className="flex-1 overflow-y-auto flex flex-col">
@@ -454,7 +425,7 @@ export default function App() {
</div> </div>
{/* Calibration: corner jog + axis-scale */} {/* 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} /> <CalibrationAxis printerUrl={gcodeConfig.printer_url} setStatus={setGlobalStatus} />
{/* Export & upload */} {/* Export & upload */}
@@ -520,7 +491,6 @@ export default function App() {
scheduleProcess() scheduleProcess()
}} }}
nodePreviews={passes[0].nodePreviews} nodePreviews={passes[0].nodePreviews}
sourceImageB64={image?.preview_b64 ?? null}
nodeWidth={nodeWidth} nodeWidth={nodeWidth}
/> />
) : viewMode === 'printer' ? ( ) : viewMode === 'printer' ? (
@@ -536,17 +506,17 @@ export default function App() {
<Viewport <Viewport
imageB64={displayB64} imageB64={displayB64}
strokes={viewMode === 'gcode' || viewMode === 'fill' ? strokes : null} strokes={viewMode === 'gcode' || viewMode === 'fill' ? strokes : null}
imgSize={image} imgSize={canvasDims}
viewMode={viewMode} viewMode={viewMode}
gcodeConfig={gcodeConfig} gcodeConfig={gcodeConfig}
/> />
)} )}
{showPerf && <PerfPanel data={perfData} fps={fps} longTasks={longTasks} />} {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="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="text-center space-y-2"> <div className="text-center space-y-2">
<p className="text-neutral-600 text-lg">No image loaded</p> <p className="text-neutral-600 text-lg">Nothing to plot yet</p>
<p className="text-neutral-700 text-sm">Click Open to get started</p> <p className="text-neutral-700 text-sm">Open the Pipeline tab and add a Source or Text node</p>
</div> </div>
</div> </div>
)} )}

View File

@@ -1,6 +1,7 @@
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, 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' import ColorFilter from './ColorFilter.jsx'
// ── Layout constants ─────────────────────────────────────────────────────────── // ── Layout constants ───────────────────────────────────────────────────────────
@@ -54,9 +55,28 @@ function inputType(kind) {
if (kind === 'PenOutput') return 'fill' if (kind === 'PenOutput') return 'fill'
return null 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 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 visited = new Set()
const queue = [toId] const queue = [toId]
while (queue.length) { while (queue.length) {
@@ -66,11 +86,22 @@ function isCompatible(fromKind, toKind, existingEdges, fromId, toId) {
visited.add(cur) visited.add(cur)
existingEdges.filter(e => e.from === cur).forEach(e => queue.push(e.to)) 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 return true
} }
// ── Component ────────────────────────────────────────────────────────────────── // ── 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 canvasRef = useRef(null)
const worldRef = useRef(null) const worldRef = useRef(null)
const [wire, setWire] = useState(null) // { fromId, fromX, fromY, mouseX, mouseY } 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 fromNode = g.nodes.find(n => n.id === fromId)
const toNode = g.nodes.find(n => n.id === toId) const toNode = g.nodes.find(n => n.id === toId)
if (!fromNode || !toNode) return 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)) 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)) { if (!filtered.some(ed => ed.from === fromId && ed.to === toId && ed.port === port)) {
onChangeRef.current({ ...g, edges: [...filtered, { from: fromId, to: toId, 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() } const node = kind === 'Kernel' ? { id, kind, x, y, ...defaultKernelProps() }
: kind === 'Hull' ? { id, kind, x, y, ...defaultHullParams() } : kind === 'Hull' ? { id, kind, x, y, ...defaultHullParams() }
: kind === 'Fill' ? { id, kind, x, y, ...defaultFillParams() } : kind === 'Fill' ? { id, kind, x, y, ...defaultFillParams() }
: kind === 'Source' ? { id, kind, x, y, ...defaultSourceParams() }
: kind === 'Text' ? { id, kind, x, y, ...defaultTextParams() } : kind === 'Text' ? { id, kind, x, y, ...defaultTextParams() }
: kind === 'PenOutput' ? { id, kind, x, y, ...defaultPenOutputParams() } : kind === 'PenOutput' ? { id, kind, x, y, ...defaultPenOutputParams() }
: { id, kind, x, y, blend_mode: 'Average', inputCount: 2 } : { id, kind, x, y, blend_mode: 'Average', inputCount: 2 }
@@ -388,14 +420,17 @@ export default function NodeGraph({ graph, onChange, nodePreviews, sourceImageB6
// ── Node rendering ───────────────────────────────────────────────────────── // ── Node rendering ─────────────────────────────────────────────────────────
function renderNode(node) { 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) 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 : (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' const hasOut = node.kind !== 'Output' && node.kind !== 'PenOutput'
// Text nodes have no inputs; their output ports use the same accent // Text nodes have no inputs; their output ports use the same accent
// as Fill since they produce the same `fill` data type downstream. // 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' const accentColor = node.kind === 'Source' ? '#7c3aed'
: node.kind === 'Hull' ? '#0d9488' : 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' }}> <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 === 'Hull' ? 'Hull'
: node.kind === 'Fill' ? (node.strategy ?? 'Fill') : node.kind === 'Fill' ? (node.strategy ?? 'Fill')
: node.kind === 'Text' ? `Text · ${node.font ?? 'futural'}` : node.kind === 'Text' ? `Text · ${node.font ?? 'futural'}`
@@ -479,6 +514,36 @@ export default function NodeGraph({ graph, onChange, nodePreviews, sourceImageB6
{/* Body */} {/* Body */}
<div style={{ padding: '6px 8px', display: 'flex', flexDirection: 'column', gap: 4 }}> <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' && (<> {node.kind === 'Kernel' && (<>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 2 }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: 2 }}>
{KERNELS.map(k => ( {KERNELS.map(k => (
@@ -758,6 +823,7 @@ export default function NodeGraph({ graph, onChange, nodePreviews, sourceImageB6
{/* Toolbar */} {/* Toolbar */}
<div style={{ position: 'absolute', top: 8, left: 8, zIndex: 30, display: 'flex', gap: 4, alignItems: 'center' }}> <div style={{ position: 'absolute', top: 8, left: 8, zIndex: 30, display: 'flex', gap: 4, alignItems: 'center' }}>
{[ {[
['Source', '#7c3aed', '#c4b5fd'],
['Kernel', '#374151', '#94a3b8'], ['Kernel', '#374151', '#94a3b8'],
['Combine', '#374151', '#94a3b8'], ['Combine', '#374151', '#94a3b8'],
['Hull', '#0d9488', '#5eead4'], ['Hull', '#0d9488', '#5eead4'],

View File

@@ -122,11 +122,15 @@ export function defaultHullParams() {
} }
} }
export function defaultSourceParams() {
return { file_path: null }
}
export function defaultGraph() { export function defaultGraph() {
const kId = newNodeId('kernel') const kId = newNodeId('kernel')
return { return {
nodes: [ 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: kId, kind: 'Kernel', x: 310, y: 100, ...defaultKernelProps() },
{ id: 'hull', kind: 'Hull', x: 560, y: 160, ...defaultHullParams() }, { id: 'hull', kind: 'Hull', x: 560, y: 160, ...defaultHullParams() },
{ id: 'fill', kind: 'Fill', x: 840, y: 160, ...defaultFillParams() }, { id: 'fill', kind: 'Fill', x: 840, y: 160, ...defaultFillParams() },

View File

@@ -466,7 +466,11 @@ impl BlendMode {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum NodeKind { 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), Kernel(DetectionLayer),
Combine(BlendMode), Combine(BlendMode),
Output, Output,
@@ -537,13 +541,20 @@ pub struct GraphMaps {
pub raw_maps: std::collections::HashMap<String, Vec<u8>>, 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( pub fn evaluate_graph(
rgb: &RgbImage,
graph: &DetectionGraph, graph: &DetectionGraph,
node_rgbs: &std::collections::HashMap<String, RgbImage>,
canvas_w: u32,
canvas_h: u32,
) -> GraphMaps { ) -> GraphMaps {
use std::collections::{HashMap, VecDeque}; 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]; let bg = || vec![255u8; n];
if graph.nodes.is_empty() { if graph.nodes.is_empty() {
@@ -593,8 +604,16 @@ pub fn evaluate_graph(
for &id in &order { for &id in &order {
let node = node_map[id]; let node = node_map[id];
let result: Option<Vec<u8>> = match &node.kind { let result: Option<Vec<u8>> = match &node.kind {
NodeKind::Source => None, NodeKind::Source { .. } => None,
NodeKind::Kernel(layer) => { 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), // If an upstream response map exists (e.g. from a Combine node),
// convert it to a grayscale RgbImage and apply the kernel to that // convert it to a grayscale RgbImage and apply the kernel to that
// instead of the original source. This lets you chain transforms: // instead of the original source. This lets you chain transforms:
@@ -602,13 +621,13 @@ pub fn evaluate_graph(
let upstream = incoming[id].iter() let upstream = incoming[id].iter()
.find_map(|(fid, _)| outputs.get(fid)); .find_map(|(fid, _)| outputs.get(fid));
let raw = if let Some(up) = upstream { let raw = if let Some(up) = upstream {
let gray_rgb = RgbImage::from_fn(rgb.width(), rgb.height(), |x, y| { let gray_rgb = RgbImage::from_fn(src_rgb.width(), src_rgb.height(), |x, y| {
let v = up[(y * rgb.width() + x) as usize]; let v = up[(y * src_rgb.width() + x) as usize];
image::Rgb([v, v, v]) image::Rgb([v, v, v])
}); });
apply_layer(&gray_rgb, layer) apply_layer(&gray_rgb, layer)
} else { } else {
apply_layer(rgb, layer) apply_layer(src_rgb, layer)
}; };
let w = layer.weight; let w = layer.weight;
Some(if (w - 1.0).abs() < 1e-6 { Some(if (w - 1.0).abs() < 1e-6 {

View File

@@ -57,11 +57,12 @@ struct FillCacheEntry {
// ── Fingerprint helpers ──────────────────────────────────────────────────────── // ── 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(); 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(dpi.unwrap_or(0));
h.write_u32(img_w_mm.map(|v| v.to_bits()).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() h.finish()
} }
fn fp_kernel(node: &GraphNodePayload, upstream_fp: u64) -> u64 { 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. /// Compute a fingerprint for every node in topological order.
/// Fingerprints cascade: downstream nodes include upstream fps so any upstream /// Fingerprints cascade: downstream nodes include upstream fps so any upstream
/// change propagates automatically. /// change propagates automatically.
fn compute_node_fingerprints(payload: &ProcessPassPayload, source_fp: u64) fn compute_node_fingerprints(payload: &ProcessPassPayload)
-> std::collections::HashMap<String, u64> -> std::collections::HashMap<String, u64>
{ {
use std::collections::{HashMap, VecDeque}; use std::collections::{HashMap, VecDeque};
@@ -167,10 +168,10 @@ fn compute_node_fingerprints(payload: &ProcessPassPayload, source_fp: u64)
let mut ins = incoming[id].clone(); let mut ins = incoming[id].clone();
ins.sort_by_key(|&(_, p)| p); ins.sort_by_key(|&(_, p)| p);
let up_fps: Vec<u64> = ins.iter() let up_fps: Vec<u64> = ins.iter()
.map(|(fid, _)| fps.get(*fid).copied().unwrap_or(source_fp)).collect(); .map(|(fid, _)| fps.get(*fid).copied().unwrap_or(0)).collect();
let first = up_fps.first().copied().unwrap_or(source_fp); let first = up_fps.first().copied().unwrap_or(0);
let fp = match node.kind.as_str() { 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), "Kernel" => fp_kernel(node, first),
"Combine" => fp_combine(node, &up_fps), "Combine" => fp_combine(node, &up_fps),
"Hull" => fp_hull(node, first), "Hull" => fp_hull(node, first),
@@ -192,8 +193,10 @@ fn compute_node_fingerprints(payload: &ProcessPassPayload, source_fp: u64)
// ── Shared app state ─────────────────────────────────────────────────────────── // ── Shared app state ───────────────────────────────────────────────────────────
struct AppState { struct AppState {
image_rgb: Option<image::RgbImage>, /// Image cache, keyed by absolute file path. Each Source node carries
image_path: String, /// 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>, passes: Vec<PassState>,
} }
@@ -209,7 +212,7 @@ struct PassState {
impl Default for AppState { impl Default for AppState {
fn default() -> Self { 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_color: Option<Vec<u8>>, // [r, g, b]
pub pen_label: Option<String>, pub pen_label: Option<String>,
pub pen_order: Option<u32>, pub pen_order: Option<u32>,
// Source params (optional — only for kind="Source")
pub file_path: Option<String>,
// Text params (optional — only for kind="Text") // Text params (optional — only for kind="Text")
pub text: Option<String>, pub text: Option<String>,
pub font: Option<String>, pub font: Option<String>,
@@ -390,7 +395,7 @@ fn to_detection_graph(payload: &DetectionGraphPayload) -> detect::DetectionGraph
use detect::DetectionKernel::*; use detect::DetectionKernel::*;
let nodes = payload.nodes.iter().map(|n| { let nodes = payload.nodes.iter().map(|n| {
let kind = match n.kind.as_str() { let kind = match n.kind.as_str() {
"Source" => detect::NodeKind::Source, "Source" => detect::NodeKind::Source { file_path: n.file_path.clone() },
"Kernel" => { "Kernel" => {
let kernel = match n.kernel.as_deref().unwrap_or("Luminance") { let kernel = match n.kernel.as_deref().unwrap_or("Luminance") {
"Sobel" => Sobel, "Sobel" => Sobel,
@@ -604,7 +609,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, source_rgbs: std::collections::HashMap<String, image::RgbImage>,
canvas_w: u32,
canvas_h: u32,
payload: ProcessPassPayload, payload: ProcessPassPayload,
mut cache: NodeCache, mut cache: NodeCache,
) -> (Vec<hulls::Hull>, Vec<PenResult>, Vec<u8>, ProcessResult, 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_hits = 0u32;
let mut cache_misses = 0u32; let mut cache_misses = 0u32;
// ── DPI scale ───────────────────────────────────────────────────────────── // Each Source is already letterboxed to the paper canvas by the caller,
let mut t = Instant::now(); // so all of them share `(canvas_w, canvas_h)` and downstream hulls land
let (orig_w, orig_h) = rgb.dimensions(); // in a single coord frame.
let scaled_opt: Option<image::RgbImage> = match (payload.dpi, payload.img_w_mm) { let (w, h) = (canvas_w, canvas_h);
(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);
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 ────────────────────────────────────────────────────────── // ── Fingerprints ──────────────────────────────────────────────────────────
let source_fp = fp_source(orig_w, orig_h, payload.dpi, payload.img_w_mm); let node_fps = compute_node_fingerprints(&payload);
let node_fps = compute_node_fingerprints(&payload, source_fp);
// Detect-phase fingerprint: combines all Kernel/Combine/Source node fps. // Detect-phase fingerprint: combines all Kernel/Combine/Source node fps.
let detect_fp = { let detect_fp = {
@@ -663,7 +701,7 @@ fn process_pass_work(
} }
} else { } else {
cache_misses += 1; 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_fp = detect_fp;
cache.detect_response = maps.response.clone(); cache.detect_response = maps.response.clone();
cache.detect_maps = maps.raw_maps.clone(); cache.detect_maps = maps.raw_maps.clone();
@@ -679,11 +717,21 @@ fn process_pass_work(
// ── Detect node previews (cached per-node) ──────────────────────────────── // ── Detect node previews (cached per-node) ────────────────────────────────
let mut node_previews: std::collections::HashMap<String, String> = Default::default(); 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 { for (id, map) in &graph_maps.raw_maps {
let is_detect_node = det_graph.nodes.iter().find(|n| &n.id == id) let is_detect_node = 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::Source { .. }
| detect::NodeKind::Hull { .. } | detect::NodeKind::Hull { .. }
| detect::NodeKind::Fill { .. } | detect::NodeKind::Fill { .. }
| detect::NodeKind::PenOutput { .. } | detect::NodeKind::PenOutput { .. }
@@ -752,7 +800,12 @@ fn process_pass_work(
} }
} }
cache_misses += 1; 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 { let hull_params = hulls::HullParams {
threshold: *threshold, threshold: *threshold,
min_area: *min_area, min_area: *min_area,
@@ -760,7 +813,7 @@ fn process_pass_work(
connectivity: if *eight_conn { hulls::Connectivity::Eight } connectivity: if *eight_conn { hulls::Connectivity::Eight }
else { hulls::Connectivity::Four }, 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); let preview = render_hull_preview(response, &extracted, w, h);
cache.hull_entries.insert(node.id.clone(), HullCacheEntry { cache.hull_entries.insert(node.id.clone(), HullCacheEntry {
fp: hull_fp, fp: hull_fp,
@@ -771,6 +824,10 @@ fn process_pass_work(
(extracted, preview) (extracted, preview)
} else { } else {
// No fingerprint — always compute, never cache // 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 { let hull_params = hulls::HullParams {
threshold: *threshold, threshold: *threshold,
min_area: *min_area, min_area: *min_area,
@@ -778,7 +835,7 @@ fn process_pass_work(
connectivity: if *eight_conn { hulls::Connectivity::Eight } connectivity: if *eight_conn { hulls::Connectivity::Eight }
else { hulls::Connectivity::Four }, 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); let preview = render_hull_preview(response, &extracted, w, h);
(extracted, preview) (extracted, preview)
}; };
@@ -1007,6 +1064,10 @@ fn process_pass_work(
// ── Tauri commands ───────────────────────────────────────────────────────────── // ── 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] #[tauri::command]
fn load_image(path: String, state: State<Mutex<AppState>>) -> Result<ImageInfo, String> { fn load_image(path: String, state: State<Mutex<AppState>>) -> Result<ImageInfo, String> {
let dyn_img = image::open(&path).map_err(|e| e.to_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 preview_b64 = rgb_to_b64_jpeg(&rgb);
let mut st = state.lock().unwrap(); let mut st = state.lock().unwrap();
st.image_path = path.clone(); st.images.insert(path.clone(), rgb);
st.image_rgb = Some(rgb); // Don't reset pass state here — only the Source node whose file_path
// Reset all pass state (including cache) when a new image is loaded // changed needs re-fingerprinting, and the per-node cache handles
for ps in &mut st.passes { // that automatically via fp_source.
ps.hulls.clear();
ps.pen_results.clear();
ps.node_cache = NodeCache::default();
}
Ok(ImageInfo { width: w, height: h, path, preview_b64 }) 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] #[tauri::command]
async fn process_pass(payload: ProcessPassPayload, state: State<'_, Mutex<AppState>>) -> Result<ProcessResult, String> { 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 // Resolve every Source node's file_path against the image cache, then
// not hold the mutex, so other commands stay responsive during processing. // letterbox-fit each one into a paper-sized canvas so all Sources end
// If no image is loaded, synthesize a blank paper-sized canvas so // up at identical pixel dimensions — downstream hulls/strokes share
// Text-only graphs still have a coordinate frame to operate in. // one coord frame, gcode export uses one img_w/img_h.
let rgb = {
let st = state.lock().unwrap();
match st.image_rgb.as_ref() {
Some(img) => img.clone(),
None => {
let dpi = payload.dpi.unwrap_or(150).max(1) as f32; 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_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 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 canvas_w = ((paper_w * dpi / 25.4).round() as u32).max(1);
let h_px = ((paper_h * dpi / 25.4).round() as u32).max(1); let canvas_h = ((paper_h * dpi / 25.4).round() as u32).max(1);
image::RgbImage::from_pixel(w_px, h_px, image::Rgb([255, 255, 255]))
} 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; 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) = let (new_hulls, new_fill, response_map, result, new_cache) =
tauri::async_runtime::spawn_blocking(move || { tauri::async_runtime::spawn_blocking(move || {
process_pass_work(&rgb, payload, cache) process_pass_work(source_rgbs, canvas_w, canvas_h, payload, cache)
}) })
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
@@ -2012,7 +2091,7 @@ fn get_all_strokes(
let (img_width, img_height) = st.passes.first() let (img_width, img_height) = st.passes.first()
.filter(|p| p.img_w > 0) .filter(|p| p.img_w > 0)
.map(|p| (p.img_w, p.img_h)) .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(); let mut all: Vec<PassStrokesPayload> = Vec::new();
for ps in st.passes.iter() { for ps in st.passes.iter() {
let mut pens = ps.pen_results.clone(); let mut pens = ps.pen_results.clone();
@@ -2033,11 +2112,10 @@ fn get_all_strokes(
#[tauri::command] #[tauri::command]
fn get_gcode_viz(state: State<Mutex<AppState>>) -> Result<String, String> { fn get_gcode_viz(state: State<Mutex<AppState>>) -> Result<String, String> {
let st = state.lock().unwrap(); let st = state.lock().unwrap();
let rgb = st.image_rgb.as_ref().ok_or("No image loaded")?;
let (w, h) = st.passes.first() let (w, h) = st.passes.first()
.filter(|p| p.img_w > 0) .filter(|p| p.img_w > 0)
.map(|p| (p.img_w, p.img_h)) .map(|p| (p.img_w, p.img_h))
.unwrap_or_else(|| rgb.dimensions()); .ok_or("No processed pass yet")?;
let mut svg = format!( 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"/>"## 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>>, state: State<Mutex<AppState>>,
) -> Result<String, String> { ) -> Result<String, String> {
let st = state.lock().unwrap(); 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 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(); let total_pixels: usize = ps.hulls.iter().map(|h| h.pixels.len()).sum();
@@ -2134,17 +2217,22 @@ fn export_debug_state(
}) })
}).collect(); }).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!({ let dump = serde_json::json!({
"image_path": st.image_path, "source_paths": source_paths,
"image_width": w, "image_width": w,
"image_height": h, "image_height": h,
"passes": passes, "passes": passes,
}); });
let out_path = std::path::Path::new(&st.image_path) // Pick the first known source's directory for the dump file location;
.parent() // fall back to /tmp if no images have been loaded.
.unwrap_or(std::path::Path::new("/tmp")) let out_dir = st.images.keys().next()
.join("trac3r_debug.json"); .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())?; 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())?; 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, strategy: None, spacing: None, angle: None, param: None,
smooth_rdp: None, smooth_iters: None, smooth_rdp: None, smooth_iters: None,
pen_color: None, pen_label: None, pen_order: 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, text: None, font: None, font_size_mm: None, line_spacing_mm: None,
x_mm: None, y_mm: None, align: None, underline: 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() { async fn process_pass_does_not_hold_mutex_during_computation() {
let rgb = synthetic_image(800, 600); let rgb = synthetic_image(800, 600);
let state = Arc::new(Mutex::new(AppState { let state = Arc::new(Mutex::new(AppState {
image_rgb: Some(rgb.clone()), images: std::collections::HashMap::from([("test.png".to_string(), rgb.clone())]),
image_path: String::new(),
passes: Vec::new(), passes: Vec::new(),
})); }));
// Clone image and release lock — this is exactly what the command handler does. // Replicate what the command does: take a snapshot of the source RGBs
let work_rgb = { // 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(); 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 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 || { 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. // 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 { let graph = detect::DetectionGraph {
nodes: vec![ 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: "k1".into(), kind: detect::NodeKind::Kernel(layer) },
detect::GraphNode { id: "hull".into(), kind: detect::NodeKind::Hull { detect::GraphNode { id: "hull".into(), kind: detect::NodeKind::Hull {
threshold: threshold as u8, min_area, rdp_epsilon: rdp_eps, 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 }, 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 response = gm.raw_maps.get("hull").cloned().unwrap_or(gm.response);
let params = HullParams { threshold, min_area, rdp_epsilon: rdp_eps, let params = HullParams { threshold, min_area, rdp_epsilon: rdp_eps,
connectivity: hulls::Connectivity::Four }; connectivity: hulls::Connectivity::Four };