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##""## @@ -2110,7 +2188,12 @@ fn export_debug_state( state: State>, ) -> Result { 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 = 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 = 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(), - passes: Vec::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 = { 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 = + 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 };