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:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,3 +1,5 @@
|
||||
.idea/*
|
||||
target/*
|
||||
Cargo.lock
|
||||
.DS_Store
|
||||
node_modules/
|
||||
|
||||
5886
gen/schemas/macOS-schema.json
Normal file
5886
gen/schemas/macOS-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 & run
|
||||
</button>
|
||||
<p className="text-xs text-neutral-600">Use the <span className="text-emerald-500">Printer</span> tab to upload & 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} />}
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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
|
||||
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 & 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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
32
src-frontend/src/lib/gesture.js
Normal file
32
src-frontend/src/lib/gesture.js
Normal 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 }
|
||||
}
|
||||
21
src-frontend/src/lib/rotate.js
Normal file
21
src-frontend/src/lib/rotate.js
Normal 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]
|
||||
}
|
||||
38
src-frontend/src/lib/rotate.test.js
Normal file
38
src-frontend/src/lib/rotate.test.js
Normal 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])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
107
src/detect.rs
107
src/detect.rs
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
100
src/fill.rs
100
src/fill.rs
@@ -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);
|
||||
|
||||
69
src/gcode.rs
69
src/gcode.rs
@@ -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)
|
||||
|
||||
20
src/hulls.rs
20
src/hulls.rs
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
369
src/lib.rs
369
src/lib.rs
@@ -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 (~30–80 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,
|
||||
|
||||
@@ -45,7 +45,7 @@ fn main() {
|
||||
let response = apply_stack(&rgb, ¶ms);
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user