Gcode-view rotation, trackpad gestures, fit-to-view, printer timeout fixes

UI/pipeline:
- macOS trackpad gestures: two-finger pan + pinch-zoom in pipeline and
  gcode views (lib/gesture.js), with mouse-wheel zoom toned down
- NodeGraph fit-to-view + dynamic default-graph auto-layout so spawned
  cards never overlap; framed on first load and on project open
- New Project button (keeps printer/paper/machine config)

Gcode placement:
- Rotate the toolpath 90° increments at gcode-placement time
  (gcode.rs rotate_place, mirrored in the preview via lib/rotate.js) plus
  a paper-orientation rotate button on the gcode page
- Preview cache keys on paper dims so Fill/Pen/Hull thumbnails refresh on
  paper resize/rotate

Printer robustness:
- WebSocket status connect now uses a bounded TCP connect_timeout and the
  subscribe/unsubscribe path no longer blocks the UI thread; debounced
  re-subscribe so typing a URL doesn't storm connections
- Upload timeout scales with file size (120s..900s) instead of a flat 120s

- Fix stale pipeline_bench (drop removed HullParams.rdp_epsilon)
This commit is contained in:
Mitchell Hansen
2026-06-17 13:13:29 -07:00
parent 4924f038b4
commit 31be01bd1d
17 changed files with 7054 additions and 179 deletions

2
.gitignore vendored
View File

@@ -1,3 +1,5 @@
.idea/*
target/*
Cargo.lock
.DS_Store
node_modules/

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,5 @@
import { useState, useCallback, useEffect, useMemo, useRef } from 'react'
import { listen } from '@tauri-apps/api/event'
import Viewport from './components/Viewport.jsx'
import PrinterPanel from './components/PrinterPanel.jsx'
import TuningPanel from './components/TuningPanel.jsx'
@@ -8,7 +9,7 @@ import NodeGraph from './components/NodeGraph.jsx'
import PassPanel from './components/PassPanel.jsx'
import PerfPanel from './components/PerfPanel.jsx'
import Slider from './components/Slider.jsx'
import { defaultPass, defaultGcodeConfig, PAPER_SIZES, centerPaperOnBed } from './store.js'
import { defaultPass, defaultGraph, autoLayout, defaultGcodeConfig, PAPER_SIZES, centerPaperOnBed, DEFAULT_NODE_WIDTH } from './store.js'
import * as tauri from './hooks/useTauri.js'
import { serialize, deserialize } from './project.js'
import { useFps } from './hooks/useFps.js'
@@ -27,10 +28,15 @@ export default function App() {
const [showPerf, setShowPerf] = useState(false)
const [perfData, setPerfData] = useState(null)
const [longTasks, setLongTasks] = useState([])
const [printerStatus, setPrinterStatus] = useState({ state: '', mpos: [0, 0, 0], feed: 0 })
// GCode-view rendering mode. False = pen-color (what gets plotted);
// true = unique color per stroke (debug — makes pen lifts visible).
const [colorByStroke, setColorByStroke] = useState(false)
const fps = useFps()
const [sidebarWidth, setSidebarWidth] = useState(320)
const [nodeWidth, setNodeWidth] = useState(450)
const [nodeWidth, setNodeWidth] = useState(DEFAULT_NODE_WIDTH)
const [fitSignal, setFitSignal] = useState(0) // bump → NodeGraph re-fits camera
const [projectPath, setProjectPath] = useState(null) // null = unsaved
const resizing = useRef(false)
@@ -81,6 +87,34 @@ export default function App() {
return () => obs.disconnect()
}, [])
// ── Live printer status subscription ───────────────────────────────────────
// One long-lived WebSocket on the Rust side; we just listen for events
// and update local state. Re-subscribes when the URL changes.
useEffect(() => {
const url = gcodeConfig.printer_url
if (!url) {
setPrinterStatus({ state: '', mpos: [0, 0, 0], feed: 0 })
return
}
let unlisten = null
let cancelled = false
// Debounce: only (re)subscribe once the URL stops changing, so typing an
// address doesn't fire a connection attempt per keystroke at half-typed IPs.
const timer = setTimeout(async () => {
unlisten = await listen('printer-status', e => {
if (!cancelled) setPrinterStatus(e.payload)
})
try { await tauri.printerStatusSubscribe(url) }
catch { if (!cancelled) setPrinterStatus({ state: 'Offline', mpos: [0, 0, 0], feed: 0 }) }
}, 600)
return () => {
cancelled = true
clearTimeout(timer)
if (unlisten) unlisten()
tauri.printerStatusUnsubscribe().catch(() => {})
}
}, [gcodeConfig.printer_url])
function startResize(e) {
e.preventDefault()
resizing.current = true
@@ -204,17 +238,29 @@ export default function App() {
// regenerate at the new on-paper image density. The 400ms debounce
// in scheduleProcess keeps mid-drag updates from thrashing.
// ── Export ─────────────────────────────────────────────────────────────────
async function exportAll() {
const dir = await tauri.pickFolder()
if (!dir) return
// ── Send to printer ───────────────────────────────────────────────────────
// Wrap both upload paths so Send-and-Run / Upload-only can live in the
// sidebar AND the Printer panel without duplicating the busy/feedback
// glue. Disables itself via App-level `busy` so nothing else triggers
// mid-flight.
const printerUrl = gcodeConfig.printer_url ?? ''
const printerIdle = printerStatus?.state === 'Idle'
const sendDisabled = !hasOutput || !printerUrl || !printerIdle || busy
async function runSendAction(label, fn) {
if (sendDisabled) return
try {
const saved = await tauri.exportAllGcode(gcodeConfig, dir)
setGlobalStatus(`Saved ${saved.length} file(s) to ${dir}`)
setBusy(true)
setGlobalStatus(`${label}`)
await fn()
setGlobalStatus(`${label} ok`)
} catch (e) {
setGlobalStatus(`Export error: ${e}`)
setGlobalStatus(`${label} error: ${e}`)
} finally {
setBusy(false)
}
}
function sendAndRun() { runSendAction('Send & run', () => tauri.uploadAndRun(gcodeConfig, printerUrl)) }
function uploadOnly() { runSendAction('Upload', () => tauri.uploadToPrinter(gcodeConfig, printerUrl)) }
function setGcode(patch) { setGcodeConfig(c => ({ ...c, ...patch })) }
@@ -245,6 +291,22 @@ export default function App() {
}
}
// ── New project ────────────────────────────────────────────────────────────
// Reset the pipeline to a fresh default graph and drop the current
// file/strokes. Machine + paper config (gcodeConfig: printer URL, bed, pen,
// paper) and the user's card width are intentionally preserved — they're
// workstation settings, not per-drawing state. The fresh graph is laid out at
// the current card width so it never overlaps.
function newProject() {
if (!window.confirm('Start a new project? Any unsaved changes will be lost.')) return
setPasses([{ ...defaultPass(0), graph: autoLayout(defaultGraph(), { nodeWidth }) }])
setProjectPath(null)
setStrokes(null)
setDisplayB64(null)
setFitSignal(s => s + 1)
setGlobalStatus('Add a Source node and pick an image, or add a Text node')
}
// ── Project load ───────────────────────────────────────────────────────────
async function loadProject() {
const path = await tauri.pickProjectOpenPath()
@@ -261,6 +323,7 @@ export default function App() {
// Replace the pass graph
if (restored.graph) {
setPasses([{ ...defaultPass(0), graph: restored.graph }])
setFitSignal(s => s + 1) // frame the loaded graph in view
}
setProjectPath(path)
@@ -321,6 +384,11 @@ export default function App() {
title="Export debug state">
Dump
</button>
<button onClick={newProject} disabled={busy}
className="px-2 py-1 rounded bg-neutral-700 hover:bg-neutral-600 text-xs transition-colors disabled:opacity-40"
title="Start a new project (keeps printer & paper settings)">
New
</button>
<button onClick={loadProject} 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 saved project (.trac3r)">
@@ -405,17 +473,64 @@ export default function App() {
onChange={v => setGcode({ pen_dwell_ms: v })} />
<Slider label="Pen tip dia" value={gcodeConfig.pen_tip_mm ?? 0.5} min={0.05} max={3} step={0.05} unit="mm"
onChange={v => setGcode({ pen_tip_mm: v })} />
<label className="flex items-center gap-2 pt-1.5 text-xs text-neutral-400 cursor-pointer select-none">
<input type="checkbox" checked={colorByStroke}
onChange={e => setColorByStroke(e.target.checked)}
className="accent-indigo-500" />
Color by stroke (show pen lifts)
</label>
</div>
<div className="space-y-1.5">
<p className="text-neutral-500 text-xs font-semibold uppercase tracking-wider mb-1.5">Layout</p>
<div className="flex items-center gap-2">
<button
onClick={() => setGcode({ rotate_quarter_turns: (((gcodeConfig.rotate_quarter_turns ?? 0) + 1) % 4) })}
className="px-2 py-1 rounded text-xs bg-neutral-800 hover:bg-neutral-700 text-neutral-200 border border-neutral-700 transition-colors"
title="Rotate the toolpath 90° clockwise within the paper">
Image 90°
</button>
<span className="text-xs text-neutral-500">
{((gcodeConfig.rotate_quarter_turns ?? 0) * 90)}°
</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => {
const next = { paper_w_mm: gcodeConfig.paper_h_mm, paper_h_mm: gcodeConfig.paper_w_mm }
setGcode({ ...next, ...centerPaperOnBed({ ...gcodeConfig, ...next }) })
}}
className="px-2 py-1 rounded text-xs bg-neutral-800 hover:bg-neutral-700 text-neutral-200 border border-neutral-700 transition-colors"
title="Swap paper orientation (portrait ↔ landscape) and re-center on the bed">
Paper
</button>
<span className="text-xs text-neutral-500">
{gcodeConfig.paper_w_mm <= gcodeConfig.paper_h_mm ? 'portrait' : 'landscape'}
</span>
</div>
</div>
<CalibrationButtons gcodeConfig={gcodeConfig} imgSize={canvasDims} setStatus={setGlobalStatus} />
<div className="space-y-2">
<p className="text-neutral-500 text-xs font-semibold uppercase tracking-wider">Output</p>
<button onClick={exportAll} disabled={!hasOutput}
<div className="space-y-1.5">
<div className="flex items-center gap-2 mb-1.5">
<p className="text-neutral-500 text-xs font-semibold uppercase tracking-wider">Send to printer</p>
<span className={`w-1.5 h-1.5 rounded-full shrink-0 ${printerIdle ? 'bg-emerald-500' : 'bg-neutral-600'}`} />
</div>
<button onClick={sendAndRun} disabled={sendDisabled}
className="w-full px-3 py-1.5 rounded bg-indigo-700 hover:bg-indigo-600 text-xs text-white disabled:opacity-40 transition-colors">
Export G-code to folder
Send current project &amp; run
</button>
<p className="text-xs text-neutral-600">Use the <span className="text-emerald-500">Printer</span> tab to upload &amp; run.</p>
<button onClick={uploadOnly} disabled={sendDisabled}
className="w-full px-3 py-1.5 rounded bg-neutral-800 hover:bg-neutral-700 border border-neutral-700 text-xs text-neutral-200 disabled:opacity-40 transition-colors">
Upload only
</button>
{!printerUrl && (
<p className="text-xs text-neutral-600">Set the printer URL in the <span className="text-emerald-500">Printer</span> tab first.</p>
)}
{printerUrl && !printerIdle && (
<p className="text-xs text-neutral-600">Printer is <span className="text-amber-500">{printerStatus?.state || 'offline'}</span> wait for Idle.</p>
)}
</div>
</>)}
@@ -480,13 +595,13 @@ export default function App() {
nodeWidth={nodeWidth}
imageWMm={gcodeConfig.img_w_mm ?? gcodeConfig.paper_w_mm}
paperWMm={gcodeConfig.paper_w_mm}
fitSignal={fitSignal}
/>
) : viewMode === 'printer' ? (
<PrinterPanel
printerUrl={gcodeConfig.printer_url ?? ''}
setPrinterUrl={v => setGcode({ printer_url: v })}
gcodeConfig={gcodeConfig}
hasStrokes={hasOutput}
status={printerStatus}
/>
) : viewMode === 'tuning' ? (
<TuningPanel printerUrl={gcodeConfig.printer_url ?? ''} />
@@ -498,6 +613,8 @@ export default function App() {
viewMode={viewMode}
gcodeConfig={gcodeConfig}
setGcode={setGcode}
printerStatus={printerStatus}
colorByStroke={colorByStroke}
/>
)}
{showPerf && <PerfPanel data={perfData} fps={fps} longTasks={longTasks} />}

View File

@@ -1,8 +1,9 @@
import { useRef, useState, useCallback, useEffect } from 'react'
import Slider from './Slider.jsx'
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 { KERNELS, BLEND_MODES, defaultKernelProps, defaultHullParams, defaultFillParams, defaultPenOutputParams, defaultSourceParams, defaultTextParams, defaultMaskParams, 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 { classifyWheel } from '../lib/gesture.js'
// ── Layout constants ───────────────────────────────────────────────────────────
const PORT_R = 6
@@ -43,14 +44,14 @@ function bezier(from, to) {
// Kernel nodes use their upstream response map when one is present, falling back
// to raw source RGB when connected only to Source (which produces no map).
function outputType(kind) {
if (kind === 'Source' || kind === 'Kernel' || kind === 'Combine') return 'map'
if (kind === 'Source' || kind === 'Kernel' || kind === 'Combine' || kind === 'Mask') return 'map'
if (kind === 'Hull') return 'hulls'
if (kind === 'Fill') return 'fill'
if (kind === 'Text') return 'fill'
return null
}
function inputType(kind) {
if (kind === 'Kernel' || kind === 'Combine' || kind === 'Hull') return 'map'
if (kind === 'Kernel' || kind === 'Combine' || kind === 'Mask' || kind === 'Hull') return 'map'
if (kind === 'Fill') return 'hulls'
if (kind === 'PenOutput') return 'fill'
return null
@@ -102,7 +103,7 @@ function isCompatible(fromKind, toKind, existingEdges, fromId, toId, allNodes =
// ── Component ──────────────────────────────────────────────────────────────────
export default function NodeGraph({ graph, onChange, nodePreviews, nodeWidth = 220,
imageWMm = 210, paperWMm = 210 }) {
imageWMm = 210, paperWMm = 210, fitSignal = 0 }) {
const canvasRef = useRef(null)
const worldRef = useRef(null)
const [wire, setWire] = useState(null) // { fromId, fromX, fromY, mouseX, mouseY }
@@ -161,22 +162,29 @@ export default function NodeGraph({ graph, onChange, nodePreviews, nodeWidth = 2
}
}, [graph, nodePreviews])
// ── Wheel zoom — zoom to cursor, computed from refs to handle rapid scroll ──
// ── Wheel/trackpad gesture — pan or zoom-to-cursor, computed from refs ──────
const onWheel = useCallback(e => {
e.preventDefault()
const g = classifyWheel(e)
const p = panRef.current
if (g.kind === 'pan') {
panRef.current = { x: p.x + g.dx, y: p.y + g.dy }
applyTransform()
return
}
// pinch / wheel → zoom toward the cursor
const r = canvasRef.current.getBoundingClientRect()
const cx = e.clientX - r.left
const cy = e.clientY - r.top
const factor = e.deltaY < 0 ? 1.1 : 1 / 1.1
const z = zoomRef.current
const p = panRef.current
const nz = Math.min(Math.max(z * factor, 0.05), 30)
const np = {
const nz = Math.min(Math.max(z * g.scale, 0.05), 30)
panRef.current = {
x: cx - (cx - p.x) * (nz / z),
y: cy - (cy - p.y) * (nz / z),
}
zoomRef.current = nz
panRef.current = np
applyTransform()
}, []) // stable — reads from refs
@@ -186,6 +194,43 @@ export default function NodeGraph({ graph, onChange, nodePreviews, nodeWidth = 2
return () => el.removeEventListener('wheel', onWheel)
}, [onWheel])
// ── Fit-to-view — frame all nodes within the canvas ────────────────────────
// Measures the *rendered* node DOM (offset* are unscaled layout values,
// unaffected by the world transform), so it stays correct no matter how the
// cards are scaled. Reused on first mount and after loading a save file.
const fitView = useCallback(() => {
const canvas = canvasRef.current
const world = worldRef.current
if (!canvas || !world) return
const els = world.querySelectorAll('[data-node]')
if (!els.length) return
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
els.forEach(el => {
minX = Math.min(minX, el.offsetLeft)
minY = Math.min(minY, el.offsetTop)
maxX = Math.max(maxX, el.offsetLeft + el.offsetWidth)
maxY = Math.max(maxY, el.offsetTop + el.offsetHeight)
})
const contentW = maxX - minX, contentH = maxY - minY
const cw = canvas.clientWidth, ch = canvas.clientHeight
const pad = 60
// Fit to the tighter axis; never zoom past 1:1 so small graphs aren't blown up.
const z = Math.max(0.05, Math.min(1, (cw - pad * 2) / contentW, (ch - pad * 2) / contentH))
zoomRef.current = z
panRef.current = {
x: (cw - contentW * z) / 2 - minX * z,
y: (ch - contentH * z) / 2 - minY * z,
}
applyTransform()
}, []) // stable — reads canvas/world refs
// Run on mount and whenever the parent bumps fitSignal (e.g. after a load).
// rAF lets the freshly-rendered nodes settle their layout before measuring.
useEffect(() => {
const id = requestAnimationFrame(fitView)
return () => cancelAnimationFrame(id)
}, [fitSignal, fitView])
// ── Global mouse move / up / keydown — stable, reads from refs ─────────────
useEffect(() => {
function onMove(e) {
@@ -404,6 +449,7 @@ export default function NodeGraph({ graph, onChange, nodePreviews, nodeWidth = 2
: kind === 'Fill' ? { id, kind, x, y, ...defaultFillParams() }
: kind === 'Source' ? { id, kind, x, y, ...defaultSourceParams() }
: kind === 'Text' ? { id, kind, x, y, ...defaultTextParams() }
: kind === 'Mask' ? { id, kind, x, y, ...defaultMaskParams() }
: kind === 'PenOutput' ? { id, kind, x, y, ...defaultPenOutputParams() }
: { id, kind, x, y, blend_mode: 'Average', inputCount: 2 }
const g = graphRef.current
@@ -438,7 +484,7 @@ export default function NodeGraph({ graph, onChange, nodePreviews, nodeWidth = 2
function renderNode(node) {
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
: (node.kind === 'Kernel' || node.kind === 'Mask' || 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.
@@ -452,12 +498,14 @@ export default function NodeGraph({ graph, onChange, nodePreviews, nodeWidth = 2
: node.kind === 'Hull' ? '#0d9488'
: node.kind === 'Fill' ? '#9333ea'
: node.kind === 'Text' ? '#0891b2'
: node.kind === 'Mask' ? '#dc2626'
: node.kind === 'PenOutput' ? '#d97706'
: '#374151'
const headerBg = node.kind === 'Source' ? '#2e1065'
: node.kind === 'Hull' ? '#042f2e'
: node.kind === 'Fill' ? '#3b0764'
: node.kind === 'Text' ? '#164e63'
: node.kind === 'Mask' ? '#450a0a'
: node.kind === 'PenOutput' ? '#451a03'
: '#1e293b'
@@ -465,7 +513,7 @@ export default function NodeGraph({ graph, onChange, nodePreviews, nodeWidth = 2
const wireActive = !!(wireRef.current || clickWireRef.current) || !!wire
return (
<div key={node.id} style={{ position: 'absolute', left: node.x, top: node.y, width: nodeWidth }}>
<div key={node.id} data-node="1" style={{ position: 'absolute', left: node.x, top: node.y, width: nodeWidth }}>
{/* Input ports */}
{Array.from({ length: inputCnt }, (_, i) => (
@@ -515,6 +563,7 @@ export default function NodeGraph({ graph, onChange, nodePreviews, nodeWidth = 2
: node.kind === 'Hull' ? 'Hull'
: node.kind === 'Fill' ? (node.strategy ?? 'Fill')
: node.kind === 'Text' ? `Text · ${node.font ?? 'futural'}`
: node.kind === 'Mask' ? (node.invert ? 'Mask · ink' : 'Mask · erase')
: node.kind === 'PenOutput' ? (node.pen_label || 'Pen')
: node.kind === 'Kernel' ? (node.kernel ?? 'Kernel')
: node.kind === 'Combine' ? 'Combine'
@@ -662,6 +711,33 @@ export default function NodeGraph({ graph, onChange, nodePreviews, nodeWidth = 2
</div>
</>)}
{node.kind === 'Mask' && (<>
<Slider label="X" value={node.mask_x ?? 0} min={0} max={1} step={0.005}
onChange={v => updateNode(node.id, { mask_x: v })} />
<Slider label="Y" value={node.mask_y ?? 0} min={0} max={1} step={0.005}
onChange={v => updateNode(node.id, { mask_y: v })} />
<Slider label="Width" value={node.mask_w ?? 0} min={0} max={1} step={0.005}
onChange={v => updateNode(node.id, { mask_w: v })} />
<Slider label="Height" value={node.mask_h ?? 0} min={0} max={1} step={0.005}
onChange={v => updateNode(node.id, { mask_h: v })} />
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<span style={{ fontSize: 10, color: '#6b7280' }}>Fill</span>
{[
['erase', false, 'wipe to white (no ink)'],
['ink', true, 'wipe to black (solid ink)'],
].map(([label, val, tip]) => (
<button key={label} title={tip} onMouseDown={e => e.stopPropagation()}
onClick={() => updateNode(node.id, { invert: val })}
style={{
padding: '1px 5px', borderRadius: 3, fontSize: 10, cursor: 'pointer', border: 'none',
background: (node.invert ?? false) === val ? '#7f1d1d' : '#1e293b',
color: (node.invert ?? false) === val ? '#fee2e2' : '#94a3b8',
}}
>{label}</button>
))}
</div>
</>)}
{node.kind === 'Hull' && (<>
<Slider label="Threshold" value={node.threshold ?? 128} min={1} max={254} step={1}
onChange={v => updateNode(node.id, { threshold: v })} />
@@ -861,6 +937,7 @@ export default function NodeGraph({ graph, onChange, nodePreviews, nodeWidth = 2
['Source', '#7c3aed', '#c4b5fd'],
['Kernel', '#374151', '#94a3b8'],
['Combine', '#374151', '#94a3b8'],
['Mask', '#dc2626', '#fca5a5'],
['Hull', '#0d9488', '#5eead4'],
['Fill', '#7c3aed', '#c4b5fd'],
['Text', '#0891b2', '#67e8f9'],

View File

@@ -1,11 +1,9 @@
import { useEffect, useRef, useState, useCallback } from 'react'
import { useEffect, useState } from 'react'
import { listen } from '@tauri-apps/api/event'
import * as tauri from '../hooks/useTauri.js'
// Pollable runs every POLL_MS while the panel is mounted. Status is fetched
// via $T over HTTP — state name only. Live MPos requires WebSocket and is
// a follow-up.
const POLL_MS = 2000
// Status arrives via the App-level streaming subscription (one long-lived WS
// emitting `printer-status` events). This panel just consumes the prop.
const JOG_FEED = 1500
const JOG_STEPS = [0.1, 1, 10, 50]
@@ -48,38 +46,15 @@ function Btn({ children, onClick, disabled, variant = 'default', className = '',
)
}
export default function PrinterPanel({ printerUrl, setPrinterUrl, gcodeConfig, hasStrokes, onStatus }) {
const [status, setStatus] = useState({ state: '' })
export default function PrinterPanel({ printerUrl, setPrinterUrl, status }) {
const [sdFiles, setSdFiles] = useState([])
const [selected, setSelected] = useState(null)
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('')
const [jogStep, setJogStep] = useState(10)
const [upload, setUpload] = useState(null) // { file, sent, total } | null
const stopRef = useRef(false)
const idle = status.state === 'Idle'
// ── Status polling ─────────────────────────────────────────────────────────
// Status is fetched via WebSocket — works in every state (including motion),
// returns live MPos. HTTP $T was the prior approach but returns 503 during
// motion / ConfigAlarm and was incorrectly showing "Offline" for those.
const refreshStatus = useCallback(async () => {
if (!printerUrl) return
try {
const s = await tauri.printerStatusWs(printerUrl)
setStatus(s)
onStatus?.(s)
} catch (e) {
setStatus({ state: 'Offline', mpos: [0, 0, 0] })
}
}, [printerUrl, onStatus])
// POLLING DISABLED for crash diagnosis — see why-this-might-crash discussion.
// Single fetch on mount/url change, then stop. Manual refresh button still works.
useEffect(() => {
refreshStatus()
}, [refreshStatus])
const idle = status?.state === 'Idle'
// Listen for upload-progress events emitted by the Rust upload command
// (~10 Hz). Reset when an upload completes (sent === total).
@@ -99,7 +74,7 @@ export default function PrinterPanel({ printerUrl, setPrinterUrl, gcodeConfig, h
// ── File list (only when Idle, never during run) ──────────────────────────
const refreshFiles = async () => {
if (!printerUrl) return
if (status.state && status.state !== 'Idle' && status.state !== 'Alarm' && status.state !== 'Offline') {
if (status?.state && status.state !== 'Idle' && status.state !== 'Alarm' && status.state !== 'Offline') {
setMsg('Refusing to list SD files while machine is in motion.')
return
}
@@ -115,7 +90,7 @@ export default function PrinterPanel({ printerUrl, setPrinterUrl, gcodeConfig, h
// ── Action wrappers ────────────────────────────────────────────────────────
const wrap = (label, fn) => async () => {
try { setBusy(true); setMsg(label + '…'); const r = await fn(); setMsg(label + ' ok'); refreshStatus(); return r }
try { setBusy(true); setMsg(label + '…'); const r = await fn(); setMsg(label + ' ok'); return r }
catch (e) { setMsg(`${label} error: ${e}`) }
finally { setBusy(false) }
}
@@ -130,8 +105,8 @@ export default function PrinterPanel({ printerUrl, setPrinterUrl, gcodeConfig, h
// entire motion and triggers the FluidNC FLASH-cache panic bug (#1295).
const onHome = wrap('Home', () => tauri.printerHomeWs(printerUrl))
const onRunSel = wrap('Run', () => tauri.printerRunSdFile(printerUrl, selected))
const onUpload = wrap('Upload', () => tauri.uploadToPrinter(gcodeConfig, printerUrl))
const onUpAndRun = wrap('Upload+Run', () => tauri.uploadAndRun(gcodeConfig, printerUrl))
// Project upload + run lives in the GCode-view sidebar now. This panel is
// pure printer control: jog / SD-file management / realtime stops.
const jog = (axis, dir) => wrap(`Jog ${axis}${dir > 0 ? '+' : '-'}`, () =>
tauri.printerJogWs(printerUrl, `$J=G91 G21 F${JOG_FEED} ${axis}${dir * jogStep}`)
@@ -143,15 +118,14 @@ export default function PrinterPanel({ printerUrl, setPrinterUrl, gcodeConfig, h
<section>
<div className="flex items-center gap-3 mb-2">
<p className="text-neutral-500 text-xs font-semibold uppercase tracking-wider">Printer</p>
<StateBadge state={status.state} />
<StateBadge state={status?.state} />
<div className="flex-1" />
<Btn onClick={refreshStatus} title="Refresh status (via WebSocket)"></Btn>
</div>
<input type="text" value={printerUrl ?? ''}
onChange={e => setPrinterUrl(e.target.value)}
placeholder="http://fluidnc.local"
className="w-full px-2 py-1 rounded bg-neutral-800 border border-neutral-700 text-xs" />
{status.mpos && status.state && status.state !== 'Offline' && (
{status?.mpos && status.state && status.state !== 'Offline' && (
<p className="text-[11px] text-neutral-400 font-mono mt-1.5">
MPos&nbsp;
X <span className="text-neutral-200">{(status.mpos[0] ?? 0).toFixed(2)}</span>{' '}
@@ -215,15 +189,9 @@ export default function PrinterPanel({ printerUrl, setPrinterUrl, gcodeConfig, h
{/* Print */}
<section>
<p className="text-neutral-500 text-xs font-semibold uppercase tracking-wider mb-2">Print</p>
<p className="text-neutral-500 text-xs font-semibold uppercase tracking-wider mb-2">SD files</p>
<div className="space-y-1.5">
<Btn onClick={onUpAndRun} disabled={!hasStrokes || !idle || busy} variant="primary" className="w-full">
Send current project &amp; run
</Btn>
<div className="grid grid-cols-2 gap-1">
<Btn onClick={onUpload} disabled={!hasStrokes || !idle || busy}>Upload only</Btn>
<Btn onClick={refreshFiles} disabled={busy || !idle}>List SD files</Btn>
</div>
<Btn onClick={refreshFiles} disabled={busy || !idle} className="w-full">List SD files</Btn>
{upload && (
<div className="space-y-0.5">
<div className="flex justify-between text-[10px] text-neutral-500 font-mono">

View File

@@ -1,4 +1,6 @@
import { useRef, useEffect, useCallback, useState } from 'react'
import { classifyWheel } from '../lib/gesture.js'
import { rotatePlace, footprint } from '../lib/rotate.js'
// Strokes per requestAnimationFrame chunk.
// Each chunk draws this many strokes then yields back to the browser.
@@ -7,7 +9,7 @@ const CHUNK_SIZE = 300
// Pixel size of the corner-resize handles drawn over the image rect.
const HANDLE_PX = 10
export default function Viewport({ imageB64, strokes, imgSize, viewMode, gcodeConfig, setGcode }) {
export default function Viewport({ imageB64, strokes, imgSize, viewMode, gcodeConfig, setGcode, printerStatus, colorByStroke }) {
const canvasRef = useRef(null)
const svgImgRef = useRef(null)
const stateRef = useRef({ zoom: 1, pan: { x: 0, y: 0 }, dragging: false, last: null })
@@ -31,6 +33,14 @@ export default function Viewport({ imageB64, strokes, imgSize, viewMode, gcodeCo
// via setGcode on each delta tick.
const handleDragRef = useRef(null) // { kind, startX, startY, startVals }
// Live preview values for the corner-resize drag — applied to layout()
// visually so the rect tracks the cursor, but NOT pushed through to
// gcodeConfig until mouseup. This keeps the slow process_pass +
// gcode-regen off the mousemove path; the offscreen-stroke canvas
// simply stretches inside the resizing rect for visual feedback.
const [dragPreview, setDragPreview] = useState(null)
// null | { img_w_mm, offset_x_mm, offset_y_mm }
// gcodeConfig is read by layout() but kept OUT of draw's deps so
// that the chunked-stroke offscreen renderer (which depends on `draw`)
// doesn't restart from scratch every time the user drags a handle —
@@ -70,13 +80,27 @@ export default function Viewport({ imageB64, strokes, imgSize, viewMode, gcodeCo
const bed_y = H / 2 - cfg.bed_h_mm * spm / 2 + pan.y
const paper_x = bed_x + (cfg.paper_offset_x_mm ?? 0) * spm
const paper_y = bed_y + (cfg.paper_offset_y_mm ?? 0) * spm
const image_x = paper_x + (cfg.offset_x_mm ?? 0) * spm
const image_y = paper_y + (cfg.offset_y_mm ?? 0) * spm
const image_w_screen = (cfg.img_w_mm ?? 0) * spm
// Image height in mm derived from pixel aspect — matches Rust's
// GcodeConfig::img_h_mm. Image at-paper height in screen px.
const img_h_mm = iw > 0 ? (ih / iw) * (cfg.img_w_mm ?? 0) : 0
const image_h_screen = img_h_mm * spm
// dragPreview wins over cfg during a corner-resize drag so the rect
// tracks the cursor without pushing through to gcodeConfig (which
// would trigger reprocess + gcode regen). Aspect during preview
// comes from the strokes payload so the rect keeps source aspect.
const liveOffX = dragPreview?.offset_x_mm ?? cfg.offset_x_mm ?? 0
const liveOffY = dragPreview?.offset_y_mm ?? cfg.offset_y_mm ?? 0
const image_x = paper_x + liveOffX * spm
const image_y = paper_y + liveOffY * spm
const liveImgW = dragPreview?.img_w_mm ?? strokes?.image_w_mm ?? cfg.img_w_mm ?? 0
const aspect = (strokes?.image_w_mm && strokes?.image_h_mm)
? strokes.image_h_mm / strokes.image_w_mm
: (iw > 0 ? ih / iw : 1)
const img_w_mm = liveImgW
const img_h_mm = liveImgW * aspect
// Footprint = on-bed rect after rotation (axes swap for 90°/270°). The
// offscreen stroke canvas is rendered pre-rotated to this footprint, so
// the image rect / drag handles are sized to it directly.
const q = ((cfg.rotate_quarter_turns ?? 0) % 4 + 4) % 4
const [foot_w_mm, foot_h_mm] = footprint(img_w_mm, img_h_mm, q)
const image_w_screen = foot_w_mm * spm
const image_h_screen = foot_h_mm * spm
// Stroke pixel → screen pixel scale (used by canvas drawImage of the
// offscreen stroke canvas onto the image rect).
const stroke_scale = iw > 0 ? image_w_screen / iw : 0
@@ -184,20 +208,36 @@ export default function Viewport({ imageB64, strokes, imgSize, viewMode, gcodeCo
return
}
// Flatten passes → [{color, points}] for linear chunk iteration
const flat = strokes.passes.flatMap(p =>
p.strokes.map(s => ({ color: p.color, points: s }))
)
// Flatten passes → [{color, points}] for linear chunk iteration.
// `color` is a CSS string here (not [r,g,b]) so the batching loop
// below can compare with === regardless of mode.
let strokeIdx = 0
const flat = strokes.passes.flatMap(p => {
const [r, g, b] = p.color
const penCss = `rgb(${r},${g},${b})`
return p.strokes.map(s => {
// Golden-angle hue rotation: adjacent strokes always look distinct,
// and the assignment is stable per index so re-renders are coherent.
const hue = (strokeIdx++ * 137.5) % 360
const css = colorByStroke ? `hsl(${hue}, 70%, 55%)` : penCss
return { color: css, points: s }
})
})
// Offscreen canvas sized to paper × INTERNAL_PX_PER_MM. Strokes are
// mm coords drawn through an octx.scale that maps mm → offscreen px.
// Then drawImage scales the offscreen onto the image rect on screen
// — pan/zoom doesn't invalidate this canvas.
// Offscreen canvas sized to the IMAGE rect × INTERNAL_PX_PER_MM.
// Strokes are in image-relative mm (0..image_w_mm × 0..image_h_mm),
// so the canvas must match that aspect — sizing it to paper dims
// would stretch strokes when the image aspect differs from paper
// aspect. drawImage then blits this onto the image rect on screen.
const INTERNAL_PX_PER_MM = 10
const paperWmm = strokes.paper_w_mm ?? 210
const paperHmm = strokes.paper_h_mm ?? 297
const sw = Math.max(1, Math.round(paperWmm * INTERNAL_PX_PER_MM))
const sh = Math.max(1, Math.round(paperHmm * INTERNAL_PX_PER_MM))
const imgWmm = strokes.image_w_mm ?? strokes.paper_w_mm ?? 210
const imgHmm = strokes.image_h_mm ?? strokes.paper_h_mm ?? 297
// Pre-rotate strokes into the footprint frame so the preview matches the
// exported gcode (same transform as src/gcode.rs::rotate_place).
const q = ((gcodeConfig?.rotate_quarter_turns ?? 0) % 4 + 4) % 4
const [footWmm, footHmm] = footprint(imgWmm, imgHmm, q)
const sw = Math.max(1, Math.round(footWmm * INTERNAL_PX_PER_MM))
const sh = Math.max(1, Math.round(footHmm * INTERNAL_PX_PER_MM))
const off = document.createElement('canvas')
off.width = sw
off.height = sh
@@ -220,25 +260,25 @@ export default function Viewport({ imageB64, strokes, imgSize, viewMode, gcodeCo
const end = Math.min(idx + CHUNK_SIZE, flat.length)
// Pen-color batching: consecutive same-color strokes share one
// beginPath for perf. Color comes from each PenOutput's color.
// Color batching: consecutive same-color strokes share one beginPath
// for perf. In pen-color mode this groups all strokes from a pen; in
// color-by-stroke mode every stroke is unique so each draws on its own.
let i = idx
while (i < end) {
const [r, g, b] = flat[i].color
octx.strokeStyle = `rgb(${r},${g},${b})`
const color = flat[i].color
octx.strokeStyle = color
octx.lineWidth = lineWidthMm
octx.lineCap = 'round'
octx.beginPath()
let j = i
while (j < end &&
flat[j].color[0] === r &&
flat[j].color[1] === g &&
flat[j].color[2] === b) {
while (j < end && flat[j].color === color) {
const pts = flat[j].points
if (pts.length >= 2) {
octx.moveTo(pts[0][0], pts[0][1])
const p0 = rotatePlace(pts[0][0], pts[0][1], imgWmm, imgHmm, q)
octx.moveTo(p0[0], p0[1])
for (let k = 1; k < pts.length; k++) {
octx.lineTo(pts[k][0], pts[k][1])
const p = rotatePlace(pts[k][0], pts[k][1], imgWmm, imgHmm, q)
octx.lineTo(p[0], p[1])
}
}
j++
@@ -265,8 +305,8 @@ export default function Viewport({ imageB64, strokes, imgSize, viewMode, gcodeCo
chunkRef.current.raf = null
}
}
}, [strokes, imgSize, viewMode, draw,
gcodeConfig?.pen_tip_mm, gcodeConfig?.paper_w_mm])
}, [strokes, imgSize, viewMode, draw, colorByStroke,
gcodeConfig?.pen_tip_mm, gcodeConfig?.paper_w_mm, gcodeConfig?.rotate_quarter_turns])
// pen_tip_mm + paper_w_mm: only fields that affect the offscreen
// line width — listed individually so changes here re-render the
// strokes, but unrelated gcodeConfig drag mutations (offsets,
@@ -279,18 +319,34 @@ export default function Viewport({ imageB64, strokes, imgSize, viewMode, gcodeCo
// doesn't see a deps change and stays uninterrupted.
useEffect(() => { draw() }, [gcodeConfig, draw])
// Live corner-resize preview — `dragPreview` is only consumed by
// layout(), so we need an explicit redraw when it changes.
useEffect(() => { draw() }, [dragPreview, draw])
// ── Zoom to cursor ────────────────────────────────────────────────────────
function onWheel(e) {
e.preventDefault()
const canvas = canvasRef.current
const rect = canvas.getBoundingClientRect()
const factor = e.deltaY < 0 ? 1.1 : 0.9
const g = classifyWheel(e)
const st = stateRef.current
if (g.kind === 'pan') {
// Two-finger trackpad drag → pan in screen pixels.
st.pan.x += g.dx
st.pan.y += g.dy
draw()
bumpLayout()
return
}
// pinch / mouse-wheel → zoom toward the cursor.
const factor = g.scale
const dx = e.clientX - rect.left - canvas.width / 2
const dy = e.clientY - rect.top - canvas.height / 2
const { zoom, pan } = stateRef.current
stateRef.current.zoom = Math.max(0.05, Math.min(50, zoom * factor))
stateRef.current.pan.x = pan.x * factor + dx * (1 - factor)
stateRef.current.pan.y = pan.y * factor + dy * (1 - factor)
st.zoom = Math.max(0.05, Math.min(50, st.zoom * factor))
st.pan.x = st.pan.x * factor + dx * (1 - factor)
st.pan.y = st.pan.y * factor + dy * (1 - factor)
draw()
bumpLayout()
}
@@ -357,7 +413,9 @@ export default function Viewport({ imageB64, strokes, imgSize, viewMode, gcodeCo
if (corner === 'tl' || corner === 'tr') {
new_offset_y = startVals.offset_y_mm + (old_h - new_h)
}
setGcode({
// Preview-only — gcodeConfig isn't touched until mouseup so
// process_pass and gcode regen don't run on every delta.
setDragPreview({
img_w_mm: new_w,
offset_x_mm: new_offset_x,
offset_y_mm: new_offset_y,
@@ -367,6 +425,18 @@ export default function Viewport({ imageB64, strokes, imgSize, viewMode, gcodeCo
function end() {
window.removeEventListener('mousemove', move)
window.removeEventListener('mouseup', end)
// Commit the corner-resize once on release. Use a functional read
// off the latest preview so we don't capture a stale closure.
setDragPreview(p => {
if (p) {
setGcode({
img_w_mm: p.img_w_mm,
offset_x_mm: p.offset_x_mm,
offset_y_mm: p.offset_y_mm,
})
}
return null
})
}
window.addEventListener('mousemove', move)
window.addEventListener('mouseup', end)
@@ -461,6 +531,33 @@ export default function Viewport({ imageB64, strokes, imgSize, viewMode, gcodeCo
offset_y_mm: gcodeConfig.offset_y_mm ?? 0,
})} />
))}
{/* Live printer head — drawn over everything else. mpos is in machine
mm; we map it onto the bed using the same origin convention as
the gcode generator (machine 0,0 = top-left of bed). */}
{printerStatus?.state && printerStatus.state !== 'Offline' && printerStatus?.mpos && (() => {
const mx = printerStatus.mpos[0] ?? 0
const my = printerStatus.mpos[1] ?? 0
const hx = L.bed_x + mx * L.spm
const hy = L.bed_y + my * L.spm
const color =
printerStatus.state === 'Idle' ? '#10b981' :
printerStatus.state === 'Alarm' ? '#ef4444' :
printerStatus.state === 'Hold' || printerStatus.state === 'Door' ? '#f59e0b' :
'#38bdf8' // Run / Jog / Home / etc.
const ARM = 14
return (
<g style={{ pointerEvents: 'none' }}>
<line x1={hx - ARM} y1={hy} x2={hx + ARM} y2={hy} stroke={color} strokeWidth={1.5} />
<line x1={hx} y1={hy - ARM} x2={hx} y2={hy + ARM} stroke={color} strokeWidth={1.5} />
<circle cx={hx} cy={hy} r={5} fill="none" stroke={color} strokeWidth={1.5} />
<circle cx={hx} cy={hy} r={1.5} fill={color} />
<text x={hx + 10} y={hy - 8} fill={color} fontSize={10} style={{ userSelect: 'none' }}>
{mx.toFixed(1)}, {my.toFixed(1)}
</text>
</g>
)
})()}
</svg>
)
}

View File

@@ -110,6 +110,16 @@ export async function printerStatusWs(printerUrl) {
return tracedInvoke('printer_status_ws', { printerUrl })
}
// Long-lived status subscription. The Rust side holds one WebSocket open
// and emits `printer-status` events as FluidNC autoReports stream in
// (~5Hz). Listen with `listen('printer-status', ...)`.
export async function printerStatusSubscribe(printerUrl) {
return tracedInvoke('printer_status_subscribe', { printerUrl })
}
export async function printerStatusUnsubscribe() {
return tracedInvoke('printer_status_unsubscribe', {})
}
export async function exportDebugState(passConfigs) {
return tracedInvoke('export_debug_state', { passConfigs })
}

View File

@@ -0,0 +1,32 @@
// Classify a wheel event into a pan/zoom gesture, giving macOS trackpads a
// native feel: two-finger drag pans, pinch zooms — while a real mouse wheel
// keeps zooming as before.
//
// How the browser/WebView surfaces these on macOS (Chrome + WKWebView/Tauri):
// • Trackpad pinch → a `wheel` event with `ctrlKey` synthesized true.
// • Trackpad two-finger → `wheel` events in pixel mode (deltaMode 0),
// drag usually fractional and/or with a deltaX.
// • Mouse wheel notch → coarse, vertical-only, integer deltas.
//
// Returns one of:
// { kind: 'pinch', scale } multiply zoom by `scale` (zoom to cursor)
// { kind: 'pan', dx, dy } translate the view by (dx, dy) screen px
// { kind: 'wheel', scale } mouse-wheel zoom step (zoom to cursor)
export function classifyWheel(e) {
// Pinch — exponential so fast/slow pinches feel proportional. Tuned to be
// smooth on a trackpad; magnitudes here are small per event.
if (e.ctrlKey) {
return { kind: 'pinch', scale: Math.exp(-e.deltaY * 0.01) }
}
const trackpad = e.deltaMode === 0 &&
(e.deltaX !== 0 || !Number.isInteger(e.deltaY) || Math.abs(e.deltaY) < 4)
if (trackpad) {
// Two-finger drag → pan. Natural direction: content follows the fingers.
return { kind: 'pan', dx: -e.deltaX, dy: -e.deltaY }
}
// Mouse wheel → discrete zoom step (matches the old feel, already toned down).
return { kind: 'wheel', scale: e.deltaY < 0 ? 1.05 : 1 / 1.05 }
}

View File

@@ -0,0 +1,21 @@
// Quarter-turn placement transform for gcode-view rotation. MUST stay in
// lock-step with `rotate_place` in src/gcode.rs so the on-screen preview
// matches the exported toolpath exactly.
//
// Points live in the image-mm frame [0,w]×[0,h] (screen-style, y-down). `q`
// quarter-turns clockwise maps them into the rotated footprint frame whose
// top-left is the origin; for odd `q` the footprint is h×w (axes swapped).
export function rotatePlace(x, y, w, h, q) {
switch (((q % 4) + 4) % 4) {
case 1: return [h - y, x]
case 2: return [w - x, h - y]
case 3: return [y, w - x]
default: return [x, y]
}
}
// Footprint (placed-on-bed) dimensions of a w×h image after `q` quarter-turns.
export function footprint(w, h, q) {
return (((q % 4) + 4) % 4) % 2 === 1 ? [h, w] : [w, h]
}

View File

@@ -0,0 +1,38 @@
import { describe, it, expect } from 'vitest'
import { rotatePlace, footprint } from './rotate.js'
// These must mirror src/gcode.rs::rotate_place exactly — the preview is only
// trustworthy if it transforms strokes identically to the exporter.
describe('rotatePlace', () => {
const W = 100, H = 50
it('q=0 is identity', () => {
expect(rotatePlace(10, 20, W, H, 0)).toEqual([10, 20])
})
it('q=1 (90° CW): (x,y) -> (h - y, x)', () => {
expect(rotatePlace(10, 0, W, H, 1)).toEqual([50, 10])
})
it('q=2 (180°): (x,y) -> (w - x, h - y)', () => {
expect(rotatePlace(10, 20, W, H, 2)).toEqual([90, 30])
})
it('q=3 (270° CW): (x,y) -> (y, w - x)', () => {
expect(rotatePlace(10, 20, W, H, 3)).toEqual([20, 90])
})
it('normalizes negative / out-of-range turns', () => {
expect(rotatePlace(10, 20, W, H, -3)).toEqual(rotatePlace(10, 20, W, H, 1))
expect(rotatePlace(10, 20, W, H, 4)).toEqual(rotatePlace(10, 20, W, H, 0))
})
})
describe('footprint', () => {
it('keeps dims for even turns, swaps for odd', () => {
expect(footprint(100, 50, 0)).toEqual([100, 50])
expect(footprint(100, 50, 1)).toEqual([50, 100])
expect(footprint(100, 50, 2)).toEqual([100, 50])
expect(footprint(100, 50, 3)).toEqual([50, 100])
})
})

View File

@@ -119,6 +119,16 @@ export function defaultTextParams() {
}
}
// Mask = wipe a rectangle of the upstream response map. Coords are
// normalized to source pixel space so the rect stays put when DPI changes.
// `invert: false` writes 255 (no ink); `invert: true` writes 0 (solid ink).
export function defaultMaskParams() {
return {
mask_x: 0.7, mask_y: 0.0, mask_w: 0.3, mask_h: 0.15,
invert: false,
}
}
export function defaultHullParams() {
return {
threshold: 128, min_area: 4, connectivity: 'four',
@@ -135,15 +145,64 @@ export function defaultSourceParams() {
return { file_path: null }
}
// Default card width (px). Shared so the default-graph layout and the
// NodeGraph's nodeWidth state agree, keeping the spawned cards non-overlapping
// regardless of how we scale cards later.
export const DEFAULT_NODE_WIDTH = 450
// Position every node in `graph` on a grid derived from the pipeline's shape.
// Each node's column = its longest-path depth from a root (no incoming edge),
// so a linear chain lays out left→right and branches stack vertically within a
// column. Column stride scales with `nodeWidth`, so cards never overlap no
// matter how wide we make them. Pure function — returns a new graph.
export function autoLayout(graph, {
nodeWidth = DEFAULT_NODE_WIDTH, gapX = 70, gapY = 40,
originX = 60, originY = 60, rowH = 360,
} = {}) {
const { nodes, edges } = graph
const incoming = new Map(nodes.map(n => [n.id, []]))
edges.forEach(e => { if (incoming.has(e.to)) incoming.get(e.to).push(e.from) })
const depthCache = new Map()
const visiting = new Set()
function depth(id) {
if (depthCache.has(id)) return depthCache.get(id)
if (visiting.has(id)) return 0 // cycle guard — shouldn't happen for a DAG
visiting.add(id)
const parents = incoming.get(id) ?? []
const d = parents.length ? Math.max(...parents.map(depth)) + 1 : 0
visiting.delete(id)
depthCache.set(id, d)
return d
}
const cols = new Map() // depth → nodes in that column (for vertical stacking)
nodes.forEach(n => {
const d = depth(n.id)
if (!cols.has(d)) cols.set(d, [])
cols.get(d).push(n)
})
const colStride = nodeWidth + gapX
return {
...graph,
nodes: nodes.map(n => {
const d = depth(n.id)
const row = cols.get(d).indexOf(n)
return { ...n, x: originX + d * colStride, y: originY + row * (rowH + gapY) }
}),
}
}
export function defaultGraph() {
const kId = newNodeId('kernel')
return {
return autoLayout({
nodes: [
{ 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() },
{ id: 'pen1', kind: 'PenOutput', x: 1110, y: 160, ...defaultPenOutputParams() },
{ id: 'source', kind: 'Source', ...defaultSourceParams() },
{ id: kId, kind: 'Kernel', ...defaultKernelProps() },
{ id: 'hull', kind: 'Hull', ...defaultHullParams() },
{ id: 'fill', kind: 'Fill', ...defaultFillParams() },
{ id: 'pen1', kind: 'PenOutput', ...defaultPenOutputParams() },
],
edges: [
{ from: 'source', to: kId, port: 0 },
@@ -151,7 +210,7 @@ export function defaultGraph() {
{ from: 'hull', to: 'fill', port: 0 },
{ from: 'fill', to: 'pen1', port: 0 },
],
}
})
}
export function defaultPass(index) {
@@ -185,6 +244,7 @@ export function defaultGcodeConfig() {
paper_w_mm: 210, paper_h_mm: 297,
img_w_mm: 180,
offset_x_mm: 15, offset_y_mm: 15,
rotate_quarter_turns: 0, // 0/1/2/3 = 0/90/180/270° CW, applied at gcode placement
feed_draw: 1000, feed_travel: 5000,
pen_down: 'G1 Z0.4 F1000', pen_up_z_mm: 2, pen_dwell_ms: 250,
pen_tip_mm: 0.5, // visual only — gcode preview renders strokes at this physical ink width

View File

@@ -473,6 +473,15 @@ pub enum NodeKind {
Source { file_path: Option<String> },
Kernel(DetectionLayer),
Combine(BlendMode),
/// Wipe a rectangular region of the upstream response map. Coords are
/// normalized to the source's pixel dims so the rect tracks the image
/// regardless of resolution. `invert: false` writes 255 (no ink); `true`
/// writes 0 (solid ink) — same toggle works for "erase a watermark"
/// and "force-paint a region" without two separate node kinds.
Mask {
rect_x: f32, rect_y: f32, rect_w: f32, rect_h: f32,
invert: bool,
},
Output,
Hull {
threshold: u8,
@@ -654,6 +663,33 @@ pub fn evaluate_graph(
let n = maps.first().map(|m| m.len()).unwrap_or(0);
Some(if maps.is_empty() { bg_for(id) } else { blend_maps(&maps, *mode, n) })
}
NodeKind::Mask { rect_x, rect_y, rect_w, rect_h, invert } => {
// Mask needs source dims to map normalized rect → pixels.
// No source = nothing to do; emit the empty default and bail.
let src_rgb = match node_rgbs.get(id) {
Some(r) => r,
None => { outputs.insert(id, bg_for(id)); continue; }
};
let upstream = incoming[id].iter()
.find_map(|(fid, _)| outputs.get(fid).cloned());
let mut map = upstream.unwrap_or_else(|| bg_for(id));
let (w, h) = src_rgb.dimensions();
if map.len() == (w * h) as usize {
let clamp01 = |v: f32| v.clamp(0.0, 1.0);
let x0 = (clamp01(*rect_x) * w as f32).floor() as u32;
let y0 = (clamp01(*rect_y) * h as f32).floor() as u32;
let x1 = (clamp01(*rect_x + *rect_w) * w as f32).ceil() as u32;
let y1 = (clamp01(*rect_y + *rect_h) * h as f32).ceil() as u32;
let val = if *invert { 0u8 } else { 255u8 };
for y in y0..y1.min(h) {
let row = (y * w) as usize;
for x in x0..x1.min(w) {
map[row + x as usize] = val;
}
}
}
Some(map)
}
NodeKind::Output => {
let upstream = incoming[id].iter()
.find_map(|(fid, _)| outputs.get(fid).cloned());
@@ -886,4 +922,75 @@ mod tests {
assert!(lum_dark.iter().all(|&v| v < 10));
assert!(lum_light.iter().all(|&v| v > 245));
}
/// Mask wipes a normalized rect in the upstream response map. With
/// invert=false (default), pixels inside the rect become 255 (no ink);
/// with invert=true they become 0 (solid ink). Outside the rect is
/// unchanged.
#[test]
fn mask_erases_rectangle_to_white() {
// 10×10 dark image → luminance map all ~0.
let img = solid(10, 10, 0, 0, 0);
let graph = DetectionGraph {
nodes: vec![
GraphNode { id: "src".into(), kind: NodeKind::Source { file_path: None } },
GraphNode { id: "k".into(), kind: NodeKind::Kernel(DetectionLayer {
kernel: DetectionKernel::Luminance, ..Default::default()
})},
GraphNode { id: "m".into(), kind: NodeKind::Mask {
// Right half: x=0.5..1.0, full height.
rect_x: 0.5, rect_y: 0.0, rect_w: 0.5, rect_h: 1.0,
invert: false,
}},
],
edges: vec![
GraphEdge { from: "src".into(), to: "k".into(), port: 0 },
GraphEdge { from: "k".into(), to: "m".into(), port: 0 },
],
};
let node_rgbs: std::collections::HashMap<String, RgbImage> =
graph.nodes.iter().map(|n| (n.id.clone(), img.clone())).collect();
let gm = evaluate_graph(&graph, &node_rgbs);
let map = gm.raw_maps.get("m").expect("mask node should produce a map");
// Left half: original dark luminance (~0).
for y in 0..10 { for x in 0..5 {
assert!(map[(y * 10 + x) as usize] < 30,
"left half should still be dark at ({x},{y}); got {}", map[(y * 10 + x) as usize]);
}}
// Right half: wiped to 255.
for y in 0..10 { for x in 5..10 {
assert_eq!(map[(y * 10 + x) as usize], 255,
"right half should be white at ({x},{y})");
}}
}
#[test]
fn mask_invert_paints_rectangle_solid_ink() {
// Start with a fully-white luminance map (white source).
let img = solid(10, 10, 255, 255, 255);
let graph = DetectionGraph {
nodes: vec![
GraphNode { id: "src".into(), kind: NodeKind::Source { file_path: None } },
GraphNode { id: "k".into(), kind: NodeKind::Kernel(DetectionLayer {
kernel: DetectionKernel::Luminance, ..Default::default()
})},
GraphNode { id: "m".into(), kind: NodeKind::Mask {
rect_x: 0.2, rect_y: 0.2, rect_w: 0.6, rect_h: 0.6,
invert: true, // paint solid ink
}},
],
edges: vec![
GraphEdge { from: "src".into(), to: "k".into(), port: 0 },
GraphEdge { from: "k".into(), to: "m".into(), port: 0 },
],
};
let node_rgbs: std::collections::HashMap<String, RgbImage> =
graph.nodes.iter().map(|n| (n.id.clone(), img.clone())).collect();
let gm = evaluate_graph(&graph, &node_rgbs);
let map = gm.raw_maps.get("m").expect("mask node should produce a map");
// Centre of the rect (5,5): should be 0 (solid ink).
assert_eq!(map[5 * 10 + 5], 0, "rect interior should be solid ink");
// Corner (0,0): outside rect, should be untouched white.
assert_eq!(map[0], 255, "outside-rect pixels must be unchanged");
}
}

View File

@@ -54,15 +54,26 @@ pub fn rasterize_mm_hull(mm: &MmHull, px_per_mm: f32) -> Hull {
bounds: Bounds { x_min, y_min, x_max: x_min, y_max: y_min },
};
}
// Polygon in pixel coords for scanline math.
let poly: Vec<(f32, f32)> = mm.contour.iter().map(|&(x, y)| (x * s, y * s)).collect();
let n = poly.len();
// Outer + every hole as a single flat list of polygons. The even-odd
// parity rule handles cut-outs automatically: a scanline that crosses
// outer→hole→outer has 4 crossings and the middle pair is skipped.
let outer: Vec<(f32, f32)> = mm.contour.iter().map(|&(x, y)| (x * s, y * s)).collect();
let holes: Vec<Vec<(f32, f32)>> = mm.holes.iter()
.map(|h| h.iter().map(|&(x, y)| (x * s, y * s)).collect())
.collect();
let polys: Vec<&[(f32, f32)]> = std::iter::once(outer.as_slice())
.chain(holes.iter().map(|h| h.as_slice()))
.filter(|p| p.len() >= 3)
.collect();
let mut pixels: Vec<(u32, u32)> = Vec::new();
if n >= 3 {
if !polys.is_empty() {
for py in y_min..=y_max {
let y = py as f32 + 0.5;
// Find x-intersections of polygon edges with horizontal scanline y.
// X-intersections of every polygon edge (outer + holes) with this scanline.
let mut xs: Vec<f32> = Vec::new();
for poly in &polys {
let n = poly.len();
for i in 0..n {
let (ax, ay) = poly[i];
let (bx, by) = poly[(i + 1) % n];
@@ -71,6 +82,7 @@ pub fn rasterize_mm_hull(mm: &MmHull, px_per_mm: f32) -> Hull {
let t = (y - ay) / (by - ay);
xs.push(ax + t * (bx - ax));
}
}
xs.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
// Pair-walk: even-odd fill rule.
let mut i = 0;
@@ -108,13 +120,20 @@ fn px_strokes_to_mm(strokes: Vec<Vec<(f32, f32)>>, px_per_mm: f32) -> Vec<Vec<(f
}
/// Outline fill (closed polygon stroke) — direct from MmHull, no raster needed.
/// Emits the outer contour plus one closed stroke per hole, so loops in
/// line-art-derived hulls trace their inner boundaries too.
pub fn outline_mm(mm: &MmHull) -> FillResult {
if mm.contour.len() < 2 {
return FillResult { hull_id: mm.id, strokes: vec![] };
}
let mut s: Vec<(f32, f32)> = mm.contour.clone();
let mut strokes: Vec<Vec<(f32, f32)>> = Vec::with_capacity(1 + mm.holes.len());
let close = |poly: &[(f32, f32)]| -> Vec<(f32, f32)> {
let mut s: Vec<(f32, f32)> = poly.to_vec();
if let Some(&first) = s.first() { s.push(first); }
FillResult { hull_id: mm.id, strokes: vec![s] }
s
};
if mm.contour.len() >= 2 { strokes.push(close(&mm.contour)); }
for h in &mm.holes {
if h.len() >= 2 { strokes.push(close(h)); }
}
FillResult { hull_id: mm.id, strokes }
}
/// Wrapper macro: rasterize → run a pixel-space fill that takes
@@ -1350,6 +1369,67 @@ mod tests {
assert_eq!(s[0], s[4], "first and last point must match (closed)");
}
/// 40×40 mm rect with a 10×10 mm hole centred inside.
fn rect_mm_hull_with_hole() -> MmHull {
MmHull {
id: 0,
contour: vec![(0.0, 0.0), (40.0, 0.0), (40.0, 40.0), (0.0, 40.0)],
holes: vec![vec![(15.0, 15.0), (25.0, 15.0), (25.0, 25.0), (15.0, 25.0)]],
bounds: crate::hulls::BoundsMm { x_min: 0.0, y_min: 0.0, x_max: 40.0, y_max: 40.0 },
area_mm2: 40.0 * 40.0 - 10.0 * 10.0,
avg_color: [0, 0, 0],
}
}
#[test]
fn outline_mm_emits_stroke_per_hole() {
let mm = rect_mm_hull_with_hole();
let r = outline_mm(&mm);
assert_eq!(r.strokes.len(), 2, "expected outer + 1 hole = 2 strokes");
for s in &r.strokes {
assert_eq!(s.first(), s.last(), "each hole/contour stroke must be closed");
}
}
#[test]
fn rasterize_mm_hull_carves_out_hole() {
let mm = rect_mm_hull_with_hole();
let h = rasterize_mm_hull(&mm, 10.0); // 10 px/mm
let pixel_set: HashSet<(u32, u32)> = h.pixels.iter().copied().collect();
// Centre of the hole — should NOT be filled.
assert!(!pixel_set.contains(&(200, 200)),
"hole centre at (200,200) was filled — even-odd scanline didn't cut it out");
// Corner of the rect — should be filled.
assert!(pixel_set.contains(&(5, 5)),
"outer corner at (5,5) wasn't filled");
// Hole's interior should be empty across a wide neighbourhood.
let hole_count = (160..=240).flat_map(|y| (160..=240).map(move |x| (x, y)))
.filter(|p| pixel_set.contains(p)).count();
assert!(hole_count < 50,
"expected ~0 pixels inside hole, got {hole_count} — even-odd parity is leaking");
}
#[test]
fn parallel_hatch_mm_skips_holes() {
let mm = rect_mm_hull_with_hole();
let r = parallel_hatch_mm(&mm, 1.0, 0.0, FILL_INTERNAL_PX_PER_MM);
// Count stroke points that fall inside the hole's mm bbox.
let mut inside_hole = 0;
let mut total = 0;
for s in &r.strokes {
for &(x, y) in s {
total += 1;
// Sample interior — exclude the boundary so we don't false-positive
// on edges that touch the hole's perimeter exactly.
if x > 15.5 && x < 24.5 && y > 15.5 && y < 24.5 { inside_hole += 1; }
}
}
assert!(total > 100, "hatch should produce many points; got {total}");
assert_eq!(inside_hole, 0,
"{inside_hole}/{total} hatch points landed inside the carved hole — \
even-odd scanline is not honouring MmHull.holes");
}
#[test]
fn parallel_hatch_mm_strokes_are_in_mm_range() {
let mm = rect_mm_hull(50.0, 30.0);

View File

@@ -30,6 +30,7 @@ pub struct GcodeConfig {
pub img_w_mm: f32, // image width in mm; height derived from pixel aspect ratio
pub offset_x_mm: f32, // image left edge from paper left (mm)
pub offset_y_mm: f32, // image top edge from paper top (mm)
pub rotate_quarter_turns: u8, // 0/1/2/3 = 0/90/180/270° clockwise, applied at placement
// Machine
pub feed_draw: u32,
pub feed_travel: u32,
@@ -50,6 +51,7 @@ impl Default for GcodeConfig {
img_w_mm: 180.0, // leaves a margin
offset_x_mm: 15.0,
offset_y_mm: 15.0,
rotate_quarter_turns: 0,
feed_draw: 1000,
feed_travel: 5000,
pen_down: "G1 Z0.4 F1000".to_string(),
@@ -89,14 +91,36 @@ impl GcodeConfig {
}
}
/// Rotate a point in the image-mm frame `[0,w]×[0,h]` by `q` quarter-turns
/// clockwise (screen-style y-down coords), returning coords in the rotated
/// footprint frame whose top-left is the origin. For odd `q` the footprint is
/// `h×w` (width/height swap). Must stay in lock-step with the frontend's
/// `rotatePlace` in gesture/viewport so the preview matches the exported path.
fn rotate_place(x: f32, y: f32, w: f32, h: f32, q: u8) -> (f32, f32) {
match q & 3 {
0 => (x, y),
1 => (h - y, x),
2 => (w - x, h - y),
_ => (y, w - x),
}
}
/// Convert fill results to G-code.
/// Strokes are already in actual-paper-mm — image-on-paper scaling
/// happens upstream in process_pass (so fills regenerate when the
/// user resizes the image). All this does is apply paper + image
/// offsets to position the strokes on the bed.
pub fn to_gcode(results: &[FillResult], _img_w: u32, _img_h: u32, cfg: &GcodeConfig) -> String {
/// user resizes the image). All this does is rotate (optional) and apply
/// paper + image offsets to position the strokes on the bed.
pub fn to_gcode(results: &[FillResult], img_w: u32, img_h: u32, cfg: &GcodeConfig) -> String {
let ox = cfg.paper_offset_x_mm + cfg.offset_x_mm;
let oy = cfg.paper_offset_y_mm + cfg.offset_y_mm;
// Image bounding box in mm — the frame strokes live in and rotate within.
let bbox_w = cfg.img_w_mm;
let bbox_h = cfg.img_h_mm(img_w, img_h);
let q = cfg.rotate_quarter_turns;
let place = |x: f32, y: f32| {
let (rx, ry) = rotate_place(x, y, bbox_w, bbox_h, q);
(rx + ox, ry + oy)
};
let mut out = String::with_capacity(4096);
@@ -133,15 +157,16 @@ pub fn to_gcode(results: &[FillResult], _img_w: u32, _img_h: u32, cfg: &GcodeCon
for stroke in &result.strokes {
if stroke.len() < 2 { continue; }
let (sx, sy) = stroke[0];
out.push_str(&format!("G0 X{:.3} Y{:.3}\n", sx + ox, sy + oy));
let (sx, sy) = place(stroke[0].0, stroke[0].1);
out.push_str(&format!("G0 X{:.3} Y{:.3}\n", sx, sy));
out.push_str(&cfg.pen_down);
out.push('\n');
out.push_str(&dwell_line); // wait for pen to physically drop
out.push_str(&format!("G1 F{}\n", cfg.feed_draw));
for &(px, py) in &stroke[1..] {
out.push_str(&format!("G1 X{:.3} Y{:.3}\n", px + ox, py + oy));
let (gx, gy) = place(px, py);
out.push_str(&format!("G1 X{:.3} Y{:.3}\n", gx, gy));
}
out.push_str(&format!("G0 Z{:.3}\n", cfg.pen_up_z_mm));
@@ -247,6 +272,38 @@ mod tests {
assert!(code.contains("X50.000"), "expected X=50 (unchanged), got: {code}");
}
#[test]
fn gcode_rotate_90_swaps_axes() {
// 100×50 mm image bbox (img_w_mm=100, pixel aspect 2:1 → h=50).
// 90° CW: image-frame (x,y) → footprint (h - y, x). Point (10,0) at the
// top edge maps to footprint (50, 10), then + offsets (0).
let cfg = GcodeConfig {
paper_w_mm: 200.0, paper_h_mm: 200.0,
img_w_mm: 100.0,
offset_x_mm: 0.0, offset_y_mm: 0.0,
paper_offset_x_mm: 0.0, paper_offset_y_mm: 0.0,
rotate_quarter_turns: 1,
..GcodeConfig::default()
};
let result = FillResult { hull_id: 0, strokes: vec![vec![(10.0, 0.0), (10.0, 0.0)]] };
let code = to_gcode(&[result], 100, 50, &cfg); // pixel dims 100×50 → bbox 100×50 mm
assert!(code.contains("X50.000"), "expected X=h-y=50, got: {code}");
assert!(code.contains("Y10.000"), "expected Y=x=10, got: {code}");
}
#[test]
fn gcode_rotate_0_is_identity() {
let cfg = GcodeConfig {
img_w_mm: 100.0, offset_x_mm: 0.0, offset_y_mm: 0.0,
paper_offset_x_mm: 0.0, paper_offset_y_mm: 0.0,
rotate_quarter_turns: 0,
..GcodeConfig::default()
};
let result = FillResult { hull_id: 0, strokes: vec![vec![(10.0, 20.0), (10.0, 20.0)]] };
let code = to_gcode(&[result], 100, 50, &cfg);
assert!(code.contains("X10.000") && code.contains("Y20.000"), "got: {code}");
}
#[test]
fn fit_to_paper_fills_paper() {
// 1000×500 px image, A4 portrait (210×297 mm)

View File

@@ -40,8 +40,20 @@ pub struct BoundsMm {
impl MmHull {
/// Build an MmHull from a pixel `Hull` — divides the simplified polygon
/// and bounds by `px_per_mm` to land in mm coords.
/// and bounds by `px_per_mm` to land in mm coords. No holes.
pub fn from_pixel_hull(h: &Hull, px_per_mm: f32) -> Self {
Self::from_pixel_hull_with_holes(h, px_per_mm, &[])
}
/// Same as `from_pixel_hull` but threads pre-traced hole polygons
/// (in the same pixel space as the source hull) through to the mm
/// representation. Holes with fewer than 3 vertices are dropped —
/// scanline math needs an actual closed shape.
pub fn from_pixel_hull_with_holes(
h: &Hull,
px_per_mm: f32,
hole_polys_px: &[Vec<(u32, u32)>],
) -> Self {
let inv = if px_per_mm > 0.0 { 1.0 / px_per_mm } else { 0.0 };
let contour: Vec<(f32, f32)> = h.simplified.iter()
.map(|&(x, y)| (x * inv, y * inv)).collect();
@@ -52,8 +64,12 @@ impl MmHull {
y_max: (h.bounds.y_max as f32 + 1.0) * inv,
};
let area_mm2 = h.area as f32 * inv * inv;
let holes: Vec<Vec<(f32, f32)>> = hole_polys_px.iter()
.filter(|p| p.len() >= 3)
.map(|p| p.iter().map(|&(x, y)| (x as f32 * inv, y as f32 * inv)).collect())
.collect();
Self {
id: h.id, contour, holes: vec![],
id: h.id, contour, holes,
area_mm2, avg_color: h.avg_color, bounds,
}
}

View File

@@ -15,6 +15,8 @@ macro_rules! lap {
}
use std::sync::Mutex;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::collections::hash_map::DefaultHasher;
use std::hash::Hasher;
use base64::{engine::general_purpose::STANDARD as B64, Engine};
@@ -47,6 +49,10 @@ struct HullCacheEntry {
fp: u64,
hulls: Vec<hulls::Hull>,
resp_map: Vec<u8>,
/// Per-hull traced hole polygons in the same pixel space as the hull.
/// Computed once at hull-extract time and cached; the Fill stage just
/// reads them. Outer key = hull id; inner Vec = one polygon per hole.
holes: std::collections::HashMap<u32, Vec<Vec<(u32, u32)>>>,
}
#[derive(Clone)]
@@ -91,6 +97,16 @@ fn fp_combine(node: &GraphNodePayload, upstream_fps: &[u64]) -> u64 {
for &fp in upstream_fps { h.write_u64(fp); }
h.finish()
}
fn fp_mask(node: &GraphNodePayload, upstream_fp: u64) -> u64 {
let mut h = DefaultHasher::new();
h.write_u64(upstream_fp);
h.write_u32(node.mask_x.unwrap_or(0.0).to_bits());
h.write_u32(node.mask_y.unwrap_or(0.0).to_bits());
h.write_u32(node.mask_w.unwrap_or(0.0).to_bits());
h.write_u32(node.mask_h.unwrap_or(0.0).to_bits());
h.write_u8(node.invert.unwrap_or(false) as u8);
h.finish()
}
fn fp_hull(node: &GraphNodePayload, upstream_fp: u64) -> u64 {
let mut h = DefaultHasher::new();
h.write_u64(upstream_fp);
@@ -141,6 +157,20 @@ fn fp_text(node: &GraphNodePayload) -> u64 {
h.finish()
}
/// Fingerprint for the cached JPEG *preview* of a Fill/Pen node. Folds paper
/// dimensions into the node fingerprint: the fill strokes are pure mm and don't
/// change when paper is resized/rotated, but the preview canvas is paper-shaped
/// (`paper × PREVIEW_PX_PER_MM`), so its thumbnail must regenerate. Keeping this
/// separate from the fill-compute fingerprint means paper changes refresh the
/// thumbnail without recomputing the (unchanged) fill.
fn fp_preview(node_fp: u64, paper_w_mm: f32, paper_h_mm: f32) -> u64 {
let mut h = DefaultHasher::new();
h.write_u64(node_fp);
h.write_u32(paper_w_mm.to_bits());
h.write_u32(paper_h_mm.to_bits());
h.finish()
}
/// Compute a fingerprint for every node in topological order.
/// Fingerprints cascade: downstream nodes include upstream fps so any upstream
/// change propagates automatically.
@@ -177,6 +207,7 @@ fn compute_node_fingerprints(payload: &ProcessPassPayload)
"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),
"Mask" => fp_mask(node, first),
"Hull" => fp_hull(node, first),
"Fill" => fp_fill(node, first),
"PenOutput" => fp_pen(node, first, payload.pen_tip_mm),
@@ -212,6 +243,8 @@ struct PassState {
img_h: u32,
paper_w_mm: f32, // mm dims of the paper this pass was rendered for
paper_h_mm: f32,
image_w_mm: f32, // image-on-paper width (= gcodeConfig.img_w_mm)
image_h_mm: f32, // image-on-paper height (= width × first source aspect)
node_cache: NodeCache,
}
@@ -250,6 +283,12 @@ pub struct GraphNodePayload {
pub xdog_phi: Option<f32>,
// Combine params (optional)
pub blend_mode: Option<String>,
// Mask params (optional — only for kind="Mask"). Rect is normalized to
// [0..1] in source pixel space so resizing the source doesn't shift it.
pub mask_x: Option<f32>,
pub mask_y: Option<f32>,
pub mask_w: Option<f32>,
pub mask_h: Option<f32>,
// Hull params (optional — only for kind="Hull")
pub threshold: Option<u8>,
pub min_area: Option<u32>,
@@ -382,6 +421,7 @@ pub struct GcodeConfigPayload {
pub img_w_mm: f32,
pub offset_x_mm: f32,
pub offset_y_mm: f32,
#[serde(default)] pub rotate_quarter_turns: u8,
pub feed_draw: u32,
pub feed_travel: u32,
pub pen_down: String,
@@ -397,11 +437,15 @@ fn default_pen_dwell() -> u32 { 250 }
#[derive(Serialize)]
pub struct AllStrokesPayload {
pub passes: Vec<PassStrokesPayload>,
/// Stroke coords are in mm. paper_w_mm / paper_h_mm describe the
/// paper rect the strokes are positioned within so the frontend
/// knows how to scale them to screen.
/// Paper rect the strokes ultimately plot onto.
pub paper_w_mm: f32,
pub paper_h_mm: f32,
/// Image-on-paper rect — strokes are in image-relative mm coords
/// (0..image_w_mm, 0..image_h_mm). image_h_mm is derived from the
/// first source's pixel aspect × image_w_mm so the gcode-view
/// image rect matches the source's aspect, not paper aspect.
pub image_w_mm: f32,
pub image_h_mm: f32,
}
#[derive(Serialize)]
@@ -455,6 +499,13 @@ fn to_detection_graph(payload: &DetectionGraphPayload) -> detect::DetectionGraph
);
detect::NodeKind::Combine(mode)
}
"Mask" => detect::NodeKind::Mask {
rect_x: n.mask_x.unwrap_or(0.0),
rect_y: n.mask_y.unwrap_or(0.0),
rect_w: n.mask_w.unwrap_or(0.0),
rect_h: n.mask_h.unwrap_or(0.0),
invert: n.invert.unwrap_or(false),
},
"PenOutput" => detect::NodeKind::PenOutput {
color: {
let c = n.pen_color.as_deref().unwrap_or(&[20, 20, 20]);
@@ -774,7 +825,7 @@ fn process_pass_work(
let detect_fp = {
let mut h = DefaultHasher::new();
let mut fps: Vec<u64> = payload.graph.nodes.iter()
.filter(|n| matches!(n.kind.as_str(), "Source" | "Kernel" | "Combine"))
.filter(|n| matches!(n.kind.as_str(), "Source" | "Kernel" | "Combine" | "Mask"))
.filter_map(|n| node_fps.get(&n.id)).copied().collect();
fps.sort_unstable();
for fp in &fps { h.write_u64(*fp); }
@@ -802,7 +853,7 @@ fn process_pass_work(
cache.preview_cache.retain(|id, _| {
payload.graph.nodes.iter()
.find(|n| &n.id == id)
.map_or(true, |n| !matches!(n.kind.as_str(), "Kernel" | "Combine"))
.map_or(true, |n| !matches!(n.kind.as_str(), "Kernel" | "Combine" | "Mask"))
});
maps
};
@@ -860,6 +911,11 @@ fn process_pass_work(
let mut all_hulls: Vec<hulls::Hull> = Vec::new();
let mut hull_outputs: std::collections::HashMap<String, Vec<hulls::Hull>> = Default::default();
let mut hull_resp_maps: std::collections::HashMap<String, Vec<u8>> = Default::default();
// Per-Hull-node hole geometry (pixel-coord polygons), keyed by Hull-node
// id. Read by the Fill stage to populate `MmHull.holes`. Computed once
// here at hull-extract time so multiple Fill children share the work.
let mut hull_holes_per_node: std::collections::HashMap<String, std::collections::HashMap<u32, Vec<Vec<(u32, u32)>>>> = Default::default();
let mut hull_dims_per_node: std::collections::HashMap<String, (u32, u32)> = Default::default();
let mut first_hull_response: Option<Vec<u8>> = None;
let mut first_hull_threshold: u8 = 128;
let mut first_hull_dims: Option<(u32, u32)> = None;
@@ -879,17 +935,23 @@ fn process_pass_work(
};
let (sw, sh) = src_rgb.dimensions();
let hull_fp = node_fps.get(&node.id).copied().unwrap_or(0);
// Paper dims fold into the preview key — the hull pixels are
// unchanged on paper resize/rotate, but the paper-shaped thumbnail
// canvas is not.
let preview_fp = fp_preview(hull_fp, paper_w_mm_for_scale, paper_h_mm_for_scale);
let (filtered, preview) = if hull_fp != 0 {
let (filtered, preview, holes) = if hull_fp != 0 {
if let Some(entry) = cache.hull_entries.get(&node.id) {
if entry.fp == hull_fp {
// Cache hit — reuse hulls and preview
// Cache hit — reuse hulls, preview, and holes
let preview = cache.preview_cache.get(&node.id)
.filter(|(fp, _)| *fp == hull_fp)
.filter(|(fp, _)| *fp == preview_fp)
.map(|(_, p)| p.clone());
cache_hits += 1;
hull_outputs.insert(node.id.clone(), entry.hulls.clone());
hull_resp_maps.insert(node.id.clone(), entry.resp_map.clone());
hull_holes_per_node.insert(node.id.clone(), entry.holes.clone());
hull_dims_per_node.insert(node.id.clone(), (sw, sh));
if first_hull_response.is_none() {
first_hull_response = Some(entry.resp_map.clone());
first_hull_threshold = *threshold;
@@ -898,7 +960,7 @@ fn process_pass_work(
all_hulls.extend(entry.hulls.clone());
let p = preview.unwrap_or_else(|| {
let p = render_hull_preview(response, &entry.hulls, sw, sh, paper_w_mm_for_scale, paper_h_mm_for_scale);
cache.preview_cache.insert(node.id.clone(), (hull_fp, p.clone()));
cache.preview_cache.insert(node.id.clone(), (preview_fp, p.clone()));
p
});
node_previews.insert(node.id.clone(), p);
@@ -913,14 +975,16 @@ fn process_pass_work(
else { hulls::Connectivity::Four },
};
let extracted = hulls::extract_hulls(response, src_rgb, sw, sh, &hull_params);
let holes = compute_hole_polys(&extracted, sw, sh);
let preview = render_hull_preview(response, &extracted, sw, sh, paper_w_mm_for_scale, paper_h_mm_for_scale);
cache.hull_entries.insert(node.id.clone(), HullCacheEntry {
fp: hull_fp,
hulls: extracted.clone(),
resp_map: response.clone(),
holes: holes.clone(),
});
cache.preview_cache.insert(node.id.clone(), (hull_fp, preview.clone()));
(extracted, preview)
cache.preview_cache.insert(node.id.clone(), (preview_fp, preview.clone()));
(extracted, preview, holes)
} else {
// No fingerprint — always compute, never cache
let hull_params = hulls::HullParams {
@@ -930,8 +994,9 @@ fn process_pass_work(
else { hulls::Connectivity::Four },
};
let extracted = hulls::extract_hulls(response, src_rgb, sw, sh, &hull_params);
let holes = compute_hole_polys(&extracted, sw, sh);
let preview = render_hull_preview(response, &extracted, sw, sh, paper_w_mm_for_scale, paper_h_mm_for_scale);
(extracted, preview)
(extracted, preview, holes)
};
node_previews.insert(node.id.clone(), preview);
@@ -942,6 +1007,8 @@ fn process_pass_work(
}
hull_outputs.insert(node.id.clone(), filtered.clone());
hull_resp_maps.insert(node.id.clone(), response.clone());
hull_holes_per_node.insert(node.id.clone(), holes);
hull_dims_per_node.insert(node.id.clone(), (sw, sh));
all_hulls.extend(filtered);
}
}
@@ -955,28 +1022,32 @@ fn process_pass_work(
strategy, spacing, angle, param, smooth_rdp, smooth_iters, dpi: fill_dpi
} = &node.kind {
let upstream = det_graph.edges.iter().find(|e| e.to == node.id && e.port == 0);
let (hulls_for_fill, resp_for_fill) = match upstream {
let (hulls_for_fill, resp_for_fill, holes_for_fill) = match upstream {
Some(e) => (
hull_outputs.get(&e.from).cloned().unwrap_or_default(),
hull_resp_maps.get(&e.from).cloned().unwrap_or_default(),
hull_holes_per_node.get(&e.from).cloned().unwrap_or_default(),
),
None => (vec![], vec![]),
None => (vec![], vec![], Default::default()),
};
if hulls_for_fill.is_empty() { continue; }
let fill_fp = node_fps.get(&node.id).copied().unwrap_or(0);
// Preview cache keys on paper dims too — the fill itself doesn't
// change on paper resize/rotate, but the paper-shaped thumbnail does.
let preview_fp = fp_preview(fill_fp, paper_w_mm_for_scale, paper_h_mm_for_scale);
// Try the cache first.
if fill_fp != 0 {
if let Some(entry) = cache.fill_entries.get(&node.id) {
if entry.fp == fill_fp {
let preview = cache.preview_cache.get(&node.id)
.filter(|(fp, _)| *fp == fill_fp)
.filter(|(fp, _)| *fp == preview_fp)
.map(|(_, p)| p.clone());
cache_hits += 1;
let p = preview.unwrap_or_else(|| {
let p = render_fill_preview(&entry.fill, paper_w_mm_for_scale, paper_h_mm_for_scale);
cache.preview_cache.insert(node.id.clone(), (fill_fp, p.clone()));
cache.preview_cache.insert(node.id.clone(), (preview_fp, p.clone()));
p
});
node_previews.insert(node.id.clone(), p);
@@ -1001,8 +1072,12 @@ fn process_pass_work(
.and_then(|sid| source_rgbs.get(sid))
.map(|r| r.width())
.unwrap_or(0);
let empty_holes: Vec<Vec<(u32, u32)>> = Vec::new();
let mm_hulls: Vec<hulls::MmHull> = hulls_for_fill.iter()
.map(|h| hulls::MmHull::from_pixel_hull(h, src_px_per_mm))
.map(|h| {
let holes = holes_for_fill.get(&h.id).unwrap_or(&empty_holes);
hulls::MmHull::from_pixel_hull_with_holes(h, src_px_per_mm, holes)
})
.collect();
let raw: Vec<fill::FillResult> = mm_hulls.par_iter().map(|mh| {
match strategy.as_str() {
@@ -1026,7 +1101,7 @@ fn process_pass_work(
let preview = render_fill_preview(&opt, paper_w_mm_for_scale, paper_h_mm_for_scale);
if fill_fp != 0 {
cache.fill_entries.insert(node.id.clone(), FillCacheEntry { fp: fill_fp, fill: opt.clone() });
cache.preview_cache.insert(node.id.clone(), (fill_fp, preview.clone()));
cache.preview_cache.insert(node.id.clone(), (preview_fp, preview.clone()));
}
let (optimised, preview) = (opt, preview);
@@ -1074,22 +1149,26 @@ fn process_pass_work(
None => continue,
};
let pen_fp = node_fps.get(&node.id).copied().unwrap_or(0);
// Paper dims fold into the preview key so rotating/resizing paper
// refreshes the (paper-shaped) thumbnail even though the pen strokes
// are unchanged mm coords.
let preview_fp = fp_preview(pen_fp, paper_w_mm_for_scale, paper_h_mm_for_scale);
let stroke_count = fill.strokes.len();
let preview = if pen_fp != 0 {
if let Some((cached_fp, cached_p)) = cache.preview_cache.get(&node.id) {
if *cached_fp == pen_fp {
if *cached_fp == preview_fp {
cache_hits += 1;
cached_p.clone()
} else {
cache_misses += 1;
let p = render_pen_preview(*color, &fill, paper_w_mm_for_scale, paper_h_mm_for_scale, pen_tip_mm);
cache.preview_cache.insert(node.id.clone(), (pen_fp, p.clone()));
cache.preview_cache.insert(node.id.clone(), (preview_fp, p.clone()));
p
}
} else {
cache_misses += 1;
let p = render_pen_preview(*color, &fill, paper_w_mm_for_scale, paper_h_mm_for_scale, pen_tip_mm);
cache.preview_cache.insert(node.id.clone(), (pen_fp, p.clone()));
cache.preview_cache.insert(node.id.clone(), (preview_fp, p.clone()));
p
}
} else {
@@ -1196,9 +1275,10 @@ async fn process_pass(payload: ProcessPassPayload, state: State<'_, Mutex<AppSta
// out at its NATIVE pixel dims. No letterbox, no synthetic canvas —
// kernels and Hull operate at the source image's own resolution,
// mm-conversion happens at the polygon boundary using
// `source.width_px / img_w_mm` as the per-source pixels-per-mm rate.
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);
// `source.width_px / image_w_mm` as the per-source pixels-per-mm rate.
let paper_w = payload.paper_w_mm.unwrap_or(210.0).max(1.0);
let paper_h = payload.paper_h_mm.unwrap_or(297.0).max(1.0);
let image_w = payload.img_w_mm.unwrap_or(paper_w).max(1.0);
let source_rgbs: std::collections::HashMap<String, image::RgbImage> = {
let st = state.lock().unwrap();
@@ -1216,6 +1296,14 @@ async fn process_pass(payload: ProcessPassPayload, state: State<'_, Mutex<AppSta
out
};
// Image-on-paper height: derived from the first source's pixel aspect.
// Tells the frontend's gcode-view how tall the image rect should be
// for the user's chosen image_w_mm; otherwise the image rect would
// inherit paper aspect and stretch the strokes.
let image_h = source_rgbs.values().next()
.map(|rgb| image_w * rgb.height() as f32 / rgb.width().max(1) as f32)
.unwrap_or(paper_h);
let idx = payload.pass_index;
// Extract cache before releasing the lock — it moves into the blocking task.
@@ -1242,6 +1330,8 @@ async fn process_pass(payload: ProcessPassPayload, state: State<'_, Mutex<AppSta
st.passes[idx].img_h = result.img_h;
st.passes[idx].paper_w_mm = paper_w;
st.passes[idx].paper_h_mm = paper_h;
st.passes[idx].image_w_mm = image_w;
st.passes[idx].image_h_mm = image_h;
st.passes[idx].node_cache = new_cache;
Ok(result)
@@ -1658,8 +1748,15 @@ async fn upload_to_printer(
// gets its own client with a longer timeout for big gcode files.
let probe_client = http_client()?;
require_idle(&probe_client, &base)?;
// reqwest's `.timeout()` is a TOTAL per-request deadline, so a big file
// sent at the ESP32's slow SD-accept rate (~3080 KB/s) blows past a
// flat 120s and dies mid-upload. Scale the deadline to the largest file
// assuming a pessimistic ~20 KB/s floor, plus 60s of headroom, capped at
// 15 min so a genuinely wedged controller still surfaces an error.
let max_bytes = files.iter().map(|(_, c)| c.len()).max().unwrap_or(0) as u64;
let upload_secs = (60 + max_bytes / 20_000).clamp(120, 900);
let upload_client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(120)) // big files at the ESP32's accept rate
.timeout(std::time::Duration::from_secs(upload_secs))
.connect_timeout(std::time::Duration::from_secs(5))
.build()
.map_err(|e| e.to_string())?;
@@ -1819,6 +1916,47 @@ fn ws_url_from(printer_url: &str) -> Result<String, String> {
Ok(format!("ws://{}/", s)) // assume plain ws if scheme missing
}
type WsSocket = tungstenite::WebSocket<tungstenite::stream::MaybeTlsStream<std::net::TcpStream>>;
/// Open a WebSocket to the controller with a bounded connect time. Plain
/// `tungstenite::connect` calls `TcpStream::connect` with no timeout, so a
/// wrong/unreachable/half-typed address hangs on the OS's default SYN timeout
/// (~75s). We resolve host:port and use `connect_timeout` instead, then run the
/// blocking handshake under a read/write timeout so it can't wedge either.
fn ws_connect(printer_url: &str, timeout: std::time::Duration) -> Result<WsSocket, String> {
use tungstenite::client::IntoClientRequest;
use std::net::ToSocketAddrs;
let url = ws_url_from(printer_url)?;
let req = url.as_str().into_client_request().map_err(|e| e.to_string())?;
// TLS (wss://) is not used by LAN FluidNC; fall back to the library path so
// we don't have to wire up a TLS connector for a case that never fires.
if url.starts_with("wss://") {
return tungstenite::connect(req).map(|(s, _)| s).map_err(|e| format!("ws connect: {}", e));
}
let uri = req.uri();
let host = uri.host().ok_or("ws url has no host")?.to_string();
let port = uri.port_u16().unwrap_or(80);
let addrs = (host.as_str(), port).to_socket_addrs().map_err(|e| format!("resolve: {}", e))?;
let mut last = "no addresses".to_string();
let mut stream = None;
for addr in addrs {
match std::net::TcpStream::connect_timeout(&addr, timeout) {
Ok(s) => { stream = Some(s); break; }
Err(e) => { last = e.to_string(); }
}
}
let stream = stream.ok_or(format!("connect: {}", last))?;
stream.set_read_timeout(Some(timeout)).ok();
stream.set_write_timeout(Some(timeout)).ok();
let (socket, _) = tungstenite::client(req, tungstenite::stream::MaybeTlsStream::Plain(stream))
.map_err(|e| format!("ws handshake: {}", e))?;
Ok(socket)
}
/// Send `command` to the controller over a fresh WebSocket connection, then
/// wait until the machine reports a non-motion state (or timeout). Returns
/// the last observed status. The connection is held open for the whole
@@ -1974,6 +2112,150 @@ async fn printer_status_ws(printer_url: String) -> Result<PrinterStatus, String>
}).await.map_err(|e| e.to_string())?
}
// ── Streaming status subscription ───────────────────────────────────────────
//
// One long-lived WebSocket per printer URL. FluidNC's autoReport pushes
// `<...>` status frames at ~5Hz on every connected channel, so we just hold
// the socket open, parse each frame, and emit a `printer-status` event.
//
// We deliberately avoid periodic open/close polling: per FluidNC bug #1295,
// closing a WS mid-job is itself a crash trigger. The WebUI sidesteps this
// by keeping one socket open for the lifetime of the page; we do the same.
#[derive(Default)]
struct PrinterStatusState {
sub: Option<PrinterStatusSub>,
}
struct PrinterStatusSub {
stop: Arc<AtomicBool>,
join: Option<std::thread::JoinHandle<()>>,
}
impl PrinterStatusSub {
fn shutdown(mut self) {
// Signal stop and DETACH — never join here. These subscribe/unsubscribe
// commands are synchronous, so they run on the UI thread; joining a
// worker that may be mid-connect would freeze the UI. The worker checks
// `stop` between its (now time-bounded, 3s) operations and exits on its
// own. A new subscription's socket is independent, so brief overlap is
// harmless.
self.stop.store(true, Ordering::SeqCst);
self.join.take(); // drop the handle without joining
}
}
#[tauri::command]
fn printer_status_subscribe(
printer_url: String,
app: AppHandle,
state: State<'_, Mutex<PrinterStatusState>>,
) -> Result<(), String> {
if printer_url.trim().is_empty() {
return Err("Printer URL is empty".into());
}
// Replace any existing subscription unconditionally — a fresh socket is
// cheap, and url changes / explicit re-subscribes shouldn't have to dedupe.
let old = state.lock().unwrap().sub.take();
if let Some(o) = old { o.shutdown(); }
let stop = Arc::new(AtomicBool::new(false));
let stop2 = stop.clone();
let join = std::thread::Builder::new()
.name("printer-status-sub".into())
.spawn(move || run_status_subscription(printer_url, app, stop2))
.map_err(|e| e.to_string())?;
state.lock().unwrap().sub = Some(PrinterStatusSub { stop, join: Some(join) });
Ok(())
}
#[tauri::command]
fn printer_status_unsubscribe(
state: State<'_, Mutex<PrinterStatusState>>,
) -> Result<(), String> {
let old = state.lock().unwrap().sub.take();
if let Some(o) = old { o.shutdown(); }
Ok(())
}
fn run_status_subscription(printer_url: String, app: AppHandle, stop: Arc<AtomicBool>) {
use tungstenite::Message;
while !stop.load(Ordering::SeqCst) {
// Bail early on a structurally bad URL so we don't spin reconnecting.
if ws_url_from(&printer_url).is_err() { return; }
let mut socket = match ws_connect(&printer_url, std::time::Duration::from_secs(3)) {
Ok(s) => s,
Err(_) => {
let _ = app.emit("printer-status", PrinterStatus {
state: "Offline".into(),
..Default::default()
});
sleep_or_stop(&stop, 2000);
continue;
}
};
if let tungstenite::stream::MaybeTlsStream::Plain(s) = socket.get_ref() {
let _ = s.set_read_timeout(Some(std::time::Duration::from_millis(200)));
}
// Prime: ? is the FluidNC realtime status-request char, forces an
// immediate <...> response instead of waiting on the next autoReport.
let _ = socket.send(Message::Text("?".into()));
loop {
if stop.load(Ordering::SeqCst) {
let _ = socket.close(None);
return;
}
match socket.read() {
Ok(Message::Binary(bytes)) => {
if let Ok(s) = std::str::from_utf8(&bytes) {
for ln in s.lines() {
if let Some(start) = ln.find('<') {
if let Some(end) = ln[start..].find('>') {
let frame = &ln[start..=start+end];
let st = parse_status_line(frame);
if !st.state.is_empty() {
let _ = app.emit("printer-status", st);
}
}
}
}
}
}
Ok(Message::Text(_)) => {}
Ok(Message::Ping(p)) => { let _ = socket.send(Message::Pong(p)); }
Ok(Message::Close(_)) => { let _ = socket.close(None); break; }
Ok(_) => {}
Err(tungstenite::Error::Io(e))
if e.kind() == std::io::ErrorKind::WouldBlock
|| e.kind() == std::io::ErrorKind::TimedOut => continue,
Err(_) => { let _ = socket.close(None); break; }
}
}
// Inner loop exited (network blip / printer reboot). Surface offline,
// then back off and reconnect — keeps the subscription "set and forget".
let _ = app.emit("printer-status", PrinterStatus {
state: "Offline".into(),
..Default::default()
});
sleep_or_stop(&stop, 1500);
}
}
fn sleep_or_stop(stop: &AtomicBool, ms: u64) {
let step = std::time::Duration::from_millis(100);
let total = std::time::Duration::from_millis(ms);
let started = std::time::Instant::now();
while started.elapsed() < total {
if stop.load(Ordering::SeqCst) { return; }
std::thread::sleep(step);
}
}
// ── Live tuning via $/ settings tree ─────────────────────────────────────────
//
// FluidNC v4 exposes the loaded YAML config tree at `$/<path>` for both
@@ -2043,6 +2325,7 @@ fn to_gcode_config(p: &GcodeConfigPayload) -> gcode::GcodeConfig {
img_w_mm: p.img_w_mm,
offset_x_mm: p.offset_x_mm,
offset_y_mm: p.offset_y_mm,
rotate_quarter_turns: p.rotate_quarter_turns,
feed_draw: p.feed_draw,
feed_travel: p.feed_travel,
pen_down: p.pen_down.clone(),
@@ -2053,6 +2336,26 @@ fn to_gcode_config(p: &GcodeConfigPayload) -> gcode::GcodeConfig {
/// BFS from image border through non-hull pixels; unreachable non-hull pixels are holes.
/// Returns hull_id → list of hole pixel sets (4-connected components of enclosed background).
/// Run `compute_hull_holes` and trace each hole's boundary into a polygon
/// in the same pixel space as the hulls. The polygons land in
/// `MmHull.holes` (after px→mm scaling) where the rasterizer's even-odd
/// scanline cuts them out of every area-fill, and where `outline_mm`
/// emits one stroke per hole.
fn compute_hole_polys(
hulls_list: &[hulls::Hull],
w: u32,
h: u32,
) -> std::collections::HashMap<u32, Vec<Vec<(u32, u32)>>> {
let raw = compute_hull_holes(hulls_list, w, h);
raw.into_iter().map(|(hid, holes)| {
let polys: Vec<Vec<(u32, u32)>> = holes.iter()
.map(|set| hulls::trace_contour(set))
.filter(|poly| poly.len() >= 3)
.collect();
(hid, polys)
}).collect()
}
fn compute_hull_holes(
hulls_list: &[hulls::Hull],
w: u32,
@@ -2152,11 +2455,11 @@ fn get_all_strokes(
state: State<Mutex<AppState>>,
) -> Result<AllStrokesPayload, String> {
let st = state.lock().unwrap();
// Strokes are in mm; frontend uses paper dims to scale to screen.
let (paper_w_mm, paper_h_mm) = st.passes.first()
.filter(|p| p.paper_w_mm > 0.0)
.map(|p| (p.paper_w_mm, p.paper_h_mm))
.unwrap_or((210.0, 297.0));
let first = st.passes.first().filter(|p| p.paper_w_mm > 0.0);
let paper_w_mm = first.map(|p| p.paper_w_mm).unwrap_or(210.0);
let paper_h_mm = first.map(|p| p.paper_h_mm).unwrap_or(297.0);
let image_w_mm = first.map(|p| p.image_w_mm).unwrap_or(paper_w_mm);
let image_h_mm = first.map(|p| p.image_h_mm).unwrap_or(paper_h_mm);
let mut all: Vec<PassStrokesPayload> = Vec::new();
for ps in st.passes.iter() {
let mut pens = ps.pen_results.clone();
@@ -2168,7 +2471,7 @@ fn get_all_strokes(
all.push(PassStrokesPayload { pass_index: i, color: pr.color, strokes });
}
}
Ok(AllStrokesPayload { passes: all, paper_w_mm, paper_h_mm })
Ok(AllStrokesPayload { passes: all, paper_w_mm, paper_h_mm, image_w_mm, image_h_mm })
}
/// Returns base64-encoded SVG — one <path> per pen with subsampled points.
@@ -2350,6 +2653,7 @@ mod blocking_tests {
sat_min_value: None, canny_low: None, canny_high: None,
xdog_sigma2: None, xdog_tau: None, xdog_phi: None,
blend_mode: None,
mask_x: None, mask_y: None, mask_w: None, mask_h: None,
threshold: None, min_area: None,
connectivity: None, color_filter: None,
strategy: None, spacing: None, angle: None, param: None,
@@ -2890,6 +3194,7 @@ pub fn run() {
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init())
.manage(Mutex::new(AppState::default()))
.manage(Mutex::new(PrinterStatusState::default()))
.invoke_handler(tauri::generate_handler![
load_image,
get_images_dir,
@@ -2916,6 +3221,8 @@ pub fn run() {
printer_jog_ws,
printer_run_gcode_ws,
printer_status_ws,
printer_status_subscribe,
printer_status_unsubscribe,
export_debug_state,
write_project_file,
read_project_file,

View File

@@ -45,7 +45,7 @@ fn main() {
let response = apply_stack(&rgb, &params);
let now = t("detect", now);
let hull_params = HullParams { threshold: 128, min_area: 4, rdp_epsilon: 1.5,
let hull_params = HullParams { threshold: 128, min_area: 4,
connectivity: Connectivity::Four };
let hulls = extract_hulls(&response, &rgb, w, h, &hull_params);
let total_px: usize = hulls.iter().map(|h| h.pixels.len()).sum();