diff --git a/src-frontend/src/App.jsx b/src-frontend/src/App.jsx
index f869a876..ee3eb8d8 100644
--- a/src-frontend/src/App.jsx
+++ b/src-frontend/src/App.jsx
@@ -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')
- setGlobalStatus(`Loaded: ${path.split('/').pop()}`)
- processPass(0, true)
- } catch {
- setImage(null)
- setDisplayB64(null)
- setGlobalStatus(`Project loaded — image not found at: ${restored.imagePath}`)
- }
- } else {
+ // 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)
+ } else {
+ 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…'}
-
- {/* Image info */}
- {image && (
-
- {image.path.split('/').pop()} · {image.width}×{image.height}
-
- )}
-
{/* Scrollable sidebar content */}
@@ -454,7 +425,7 @@ export default function App() {
{/* Calibration: corner jog + axis-scale */}
-
+
{/* 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() {
)}
{showPerf && }
- {!image && (
+ {!anyLoadedSource && !anyTextNode && viewMode !== 'pipeline' && (
-
No image loaded
-
Click Open… to get started
+
Nothing to plot yet
+
Open the Pipeline tab and add a Source or Text node
)}
diff --git a/src-frontend/src/components/NodeGraph.jsx b/src-frontend/src/components/NodeGraph.jsx
index 9992a92c..0752f448 100644
--- a/src-frontend/src/components/NodeGraph.jsx
+++ b/src-frontend/src/components/NodeGraph.jsx
@@ -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
}}
>
- {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 */}
+ {node.kind === 'Source' && (<>
+
+ {node.file_path && (
+
+ {node.file_path}
+
+ )}
+ >)}
+
{node.kind === 'Kernel' && (<>
{KERNELS.map(k => (
@@ -758,6 +823,7 @@ export default function NodeGraph({ graph, onChange, nodePreviews, sourceImageB6
{/* Toolbar */}
{[
+ ['Source', '#7c3aed', '#c4b5fd'],
['Kernel', '#374151', '#94a3b8'],
['Combine', '#374151', '#94a3b8'],
['Hull', '#0d9488', '#5eead4'],
diff --git a/src-frontend/src/store.js b/src-frontend/src/store.js
index 60fe5ead..0a24c9e6 100644
--- a/src-frontend/src/store.js
+++ b/src-frontend/src/store.js
@@ -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() },
diff --git a/src/detect.rs b/src/detect.rs
index c0b11630..15c97ca0 100644
--- a/src/detect.rs
+++ b/src/detect.rs
@@ -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
},
Kernel(DetectionLayer),
Combine(BlendMode),
Output,
@@ -537,13 +541,20 @@ pub struct GraphMaps {
pub raw_maps: std::collections::HashMap>,
}
+/// 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,
+ 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> = 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 {
diff --git a/src/lib.rs b/src/lib.rs
index ff8a8012..f2e25c07 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -57,11 +57,12 @@ struct FillCacheEntry {
// ── Fingerprint helpers ────────────────────────────────────────────────────────
-fn fp_source(orig_w: u32, orig_h: u32, dpi: Option, img_w_mm: Option) -> u64 {
+fn fp_source(node: &GraphNodePayload, dpi: Option, img_w_mm: Option, img_h_mm: Option) -> 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
{
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 = 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,9 +193,11 @@ fn compute_node_fingerprints(payload: &ProcessPassPayload, source_fp: u64)
// ── Shared app state ───────────────────────────────────────────────────────────
struct AppState {
- image_rgb: Option,
- image_path: String,
- passes: Vec,
+ /// 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,
+ passes: Vec,
}
#[derive(Default)]
@@ -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>, // [r, g, b]
pub pen_label: Option,
pub pen_order: Option,
+ // Source params (optional — only for kind="Source")
+ pub file_path: Option,
// Text params (optional — only for kind="Text")
pub text: Option,
pub font: Option,
@@ -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,
+ canvas_w: u32,
+ canvas_h: u32,
payload: ProcessPassPayload,
mut cache: NodeCache,
) -> (Vec, Vec, Vec, 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 = 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 = {
+ use std::collections::{HashMap, VecDeque};
+ let mut owner: HashMap = 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 = {
+ 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 = 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>) -> Result {
let dyn_img = image::open(&path).map_err(|e| e.to_string())?;
@@ -1015,14 +1076,10 @@ fn load_image(path: String, state: State>) -> Result>) {
#[tauri::command]
async fn process_pass(payload: ProcessPassPayload, state: State<'_, Mutex>) -> Result {
- // 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 = {
+ // 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 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 = {
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 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 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 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 = 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>) -> Result {
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##"