multi-Source pipeline: per-Source file_path, image cache, no global image
Each Source node now owns its own image file. The Pipeline graph can
have multiple Source nodes, each rooting an independent subtree.
Frontend enforces tree separation (wires that would merge two Sources'
subtrees into one downstream node are rejected at draw time); backend
trusts the well-formed graph and threads each subtree's RGB through.
NodeKind::Source { file_path } — new field, threaded through
GraphNodePayload, fp_source, frontend payload roundtrip. Sources with
no file_path produce nothing downstream (their subtree is dormant).
AppState changes:
- image_rgb / image_path → images: HashMap<path, RgbImage>. The
per-path cache survives across process_pass calls so picking the
same file twice doesn't re-decode.
- load_image now inserts into the cache instead of setting a global;
Tauri command signature unchanged (frontend Source card calls it
when the user picks a file).
evaluate_graph signature: takes a per-node RGB lookup
(node_rgbs: HashMap<String, RgbImage>) plus canvas dims. Each Kernel
finds its tree's Source RGB through the lookup.
process_pass:
- Each Source's image is letterbox-fit (preserve aspect, white pad)
to the paper-pixel canvas (paper_w × paper_h × dpi/25.4) so all
trees produce strokes in one coord frame and gcode export uses
one img_w/img_h.
- Topo-walks to compute node_to_source (each non-Source node's
upstream Source ID), builds node_rgbs from the source map, hands
it to evaluate_graph.
- Hull node uses its tree's Source RGB for color extraction.
- Source node previews are populated directly (graph_maps doesn't
carry Source raw_maps, so we encode the letterboxed canvas as JPEG
b64 per-Source for the card thumbnail).
- Synthetic-canvas-when-no-image fallback removed; replaced by the
natural "every Source contributes its own canvas" behaviour. Text-
only graphs work because the no-Source case still produces an
empty canvas of paper dims for the bg() of evaluate_graph.
Frontend:
- App.jsx: dropped `image`/`setImage`/`imageRef`/`displayB64` for
source previews, the sidebar Open Image button, the "no image
loaded" overlay, and the 'source' viewMode (default is now
'pipeline'). processPass runs whenever any Source has a loaded file
OR any Text node exists. Project save/load drops imagePath; on
load, every Source node's file_path gets re-loaded into the cache,
with missing files surfaced in globalStatus rather than failing.
- NodeGraph.jsx: Source card now has a "Pick image…" button; once
picked, the path shows under it and the title bar shows the
filename. Every Source is deletable (isFixed = false) and a +
Source toolbar entry adds new ones. Source preview comes from
nodePreviews like every other node.
- isCompatible() now also walks upstream from both endpoints; a wire
that would merge two Source subtrees is rejected on draw.
- store.js: defaultSourceParams() (file_path: null), defaultGraph
unchanged structurally — just adds the new field on the starter
Source node.
Verified: cargo test --lib (80 pass), npm run build, npm test
(72 pass).
This commit is contained in:
@@ -13,17 +13,16 @@ import * as tauri from './hooks/useTauri.js'
|
|||||||
import { serialize, deserialize } from './project.js'
|
import { 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) }
|
||||||
setGlobalStatus(`Loaded: ${path.split('/').pop()}`)
|
}
|
||||||
processPass(0, true)
|
if (failures.length === 0) {
|
||||||
} catch {
|
|
||||||
setImage(null)
|
|
||||||
setDisplayB64(null)
|
|
||||||
setGlobalStatus(`Project loaded — image not found at: ${restored.imagePath}`)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setGlobalStatus(`Loaded: ${path.split('/').pop()}`)
|
setGlobalStatus(`Loaded: ${path.split('/').pop()}`)
|
||||||
|
processPass(0, true)
|
||||||
|
} else {
|
||||||
|
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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
|||||||
@@ -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() },
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
256
src/lib.rs
256
src/lib.rs
@@ -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,9 +193,11 @@ 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
|
||||||
passes: Vec<PassState>,
|
/// reuse the decoded pixels instead of re-reading from disk.
|
||||||
|
images: std::collections::HashMap<String, image::RgbImage>,
|
||||||
|
passes: Vec<PassState>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
@@ -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 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<String, image::RgbImage> = {
|
||||||
let st = state.lock().unwrap();
|
let st = state.lock().unwrap();
|
||||||
match st.image_rgb.as_ref() {
|
let mut out = std::collections::HashMap::new();
|
||||||
Some(img) => img.clone(),
|
for n in &payload.graph.nodes {
|
||||||
None => {
|
if n.kind != "Source" { continue; }
|
||||||
let dpi = payload.dpi.unwrap_or(150).max(1) as f32;
|
let path = match n.file_path.as_deref() {
|
||||||
let paper_w = payload.img_w_mm.unwrap_or(210.0).max(1.0);
|
Some(p) if !p.is_empty() => p,
|
||||||
let paper_h = payload.img_h_mm.unwrap_or(297.0).max(1.0);
|
_ => continue, // Source with no file picked yet → skip
|
||||||
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);
|
let raw = match st.images.get(path) {
|
||||||
image::RgbImage::from_pixel(w_px, h_px, image::Rgb([255, 255, 255]))
|
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 };
|
||||||
|
|||||||
Reference in New Issue
Block a user