diff --git a/Cargo.toml b/Cargo.toml index 27d8360a..4dd5b5ce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,8 @@ base64 = "0.22" log = "0.4" env_logger = "0.11" reqwest = { version = "0.12", default-features = false, features = ["multipart", "rustls-tls", "blocking"] } +tungstenite = { version = "0.24", default-features = false, features = ["handshake"] } +spade = "2" [profile.dev] opt-level = 2 diff --git a/resources/images/navyseal.jpg b/resources/images/navyseal.jpg new file mode 100644 index 00000000..4662f4b6 Binary files /dev/null and b/resources/images/navyseal.jpg differ diff --git a/src-frontend/src/App.jsx b/src-frontend/src/App.jsx index 44ad5780..15ef234f 100644 --- a/src-frontend/src/App.jsx +++ b/src-frontend/src/App.jsx @@ -1,15 +1,21 @@ import { useState, useCallback, useEffect, useRef } from 'react' import Viewport from './components/Viewport.jsx' +import PrinterPanel from './components/PrinterPanel.jsx' +import TuningPanel from './components/TuningPanel.jsx' +import CalibrationButtons from './components/CalibrationButtons.jsx' +import CalibrationAxis from './components/CalibrationAxis.jsx' +import TextEditOverlay from './components/TextEditOverlay.jsx' +import ChordalDebugView from './components/ChordalDebugView.jsx' 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 } from './store.js' +import { defaultPass, defaultGcodeConfig, PAPER_SIZES, centerPaperOnBed } from './store.js' import * as tauri from './hooks/useTauri.js' import { serialize, deserialize } from './project.js' import { useFps } from './hooks/useFps.js' -const VIEW_MODES = ['source', 'detection', 'contours', 'gcode'] +const VIEW_MODES = ['source', 'detection', 'contours', 'gcode', 'chordal', 'printer', 'tuning'] export default function App() { const [image, setImage] = useState(null) @@ -30,8 +36,45 @@ export default function App() { const [nodeWidth, setNodeWidth] = useState(450) const [dpi, setDpi] = useState(150) const [projectPath, setProjectPath] = useState(null) // null = unsaved + const [sourceMode, setSourceMode] = useState('image') // 'image' | 'text' + const [textBlocks, setTextBlocks] = useState([ + // Sensible defaults for #10 envelope addressing. + { text: 'Your Name\n123 Your St\nYour City, ST 12345', + font_size_mm: 3, line_spacing_mm: 5, x_mm: 8, y_mm: 8 }, + { text: 'Recipient Name\n456 Their St\nTheir City, ST 67890', + font_size_mm: 5, line_spacing_mm: 8, x_mm: 35, y_mm: 95 }, + ]) const resizing = useRef(false) + // True when the project has something to plot. In text mode, "ready" = + // pipeline has produced strokes; just having text doesn't guarantee the + // graph processed it yet. Same check as image mode. + const hasOutput = passes.some(p => p.strokeCount > 0) + + // When in text mode, rasterise blocks into a paper-sized image source + // (debounced) and trigger pipeline processing on the new image. The + // image flows through Source → Kernel → Hull → Fill like any other. + useEffect(() => { + if (sourceMode !== 'text') return + const t = setTimeout(async () => { + try { + const info = await tauri.setTextBlocks( + textBlocks, + gcodeConfig.paper_w_mm, + gcodeConfig.paper_h_mm, + dpi, + /* strokeThicknessPx */ Math.max(2, Math.round(dpi / 50)), + ) + setImage(info) + setStrokes(null) + scheduleProcessRef.current?.() + } catch (e) { + setGlobalStatus(`Text render error: ${e.message ?? e}`) + } + }, 350) + return () => clearTimeout(t) + }, [sourceMode, textBlocks, gcodeConfig.paper_w_mm, gcodeConfig.paper_h_mm, dpi]) + // Ctrl+S / Ctrl+Shift+S — ref pattern keeps listener stable across renders const saveProjectRef = useRef(null) saveProjectRef.current = saveProject @@ -200,6 +243,11 @@ export default function App() { debounceTimers.current['detect'] = setTimeout(() => processPass(0, true), 400) }, [processPass]) + // Ref so other effects (e.g. text-source rasterise) can poke the same + // debounced reprocess without depending on the function identity. + const scheduleProcessRef = useRef(scheduleProcess) + scheduleProcessRef.current = scheduleProcess + useEffect(() => { if (imageRef.current) scheduleProcess() }, [dpi, gcodeConfig.img_w_mm]) @@ -216,18 +264,6 @@ export default function App() { } } - async function uploadToPrinter() { - const url = (gcodeConfig.printer_url || '').trim() - if (!url) { setGlobalStatus('Set Printer URL in the G-code panel first'); return } - try { - setGlobalStatus(`Uploading to ${url}…`) - const names = await tauri.uploadToPrinter(gcodeConfig, url) - setGlobalStatus(`Uploaded ${names.length} file(s) to ${url} — open the WebUI to run`) - } catch (e) { - setGlobalStatus(`Upload error: ${e.message ?? e}`) - } - } - function setGcode(patch) { setGcodeConfig(c => ({ ...c, ...patch })) } // ── Project save ─────────────────────────────────────────────────────────── @@ -247,6 +283,8 @@ export default function App() { nodeWidth, graph: passes[0].graph, gcodeConfig, + sourceMode, + textBlocks, }) await tauri.writeProjectFile(path, json) setProjectPath(path) @@ -269,6 +307,8 @@ export default function App() { if (restored.gcodeConfig) setGcodeConfig(restored.gcodeConfig) if (restored.dpi) setDpi(restored.dpi) if (restored.nodeWidth) setNodeWidth(restored.nodeWidth) + if (restored.sourceMode) setSourceMode(restored.sourceMode) + if (Array.isArray(restored.textBlocks)) setTextBlocks(restored.textBlocks) // Replace the pass graph if (restored.graph) { @@ -373,6 +413,41 @@ export default function App() {
+ {/* Source mode */} +
+

Source

+
+ + +
+ + {sourceMode === 'text' && ( +
+

+ Switch to the Source tab to place + text directly on the paper. Click empty paper to add a box, drag the + header to move, drag the SE corner to scale. +

+

+ Blocks rasterise to a paper-sized image and run through the graph + (Detection → Hull → Fill) like any image source. +

+ +
+ )} +
+ {/* Graph */}

Graph

@@ -398,9 +473,10 @@ export default function App() { @@ -453,27 +532,22 @@ export default function App() { onChange={v => setGcode({ feed_travel: v })} unit=" mm/m" /> setGcode({ pen_up_z_mm: v })} /> + setGcode({ pen_dwell_ms: v })} />
+ {/* Calibration: corner jog + axis-scale */} + + + {/* Export & upload */}

Output

- -
- - setGcode({ printer_url: e.target.value })} - placeholder="http://fluidnc.local" - className="w-full px-2 py-1 rounded bg-neutral-800 border border-neutral-700 text-xs text-neutral-200" /> -
- +

Use the Printer tab to upload & run.

@@ -492,7 +566,7 @@ export default function App() { {/* Top bar — accent colors match the section dots in the left panel */}
{VIEW_MODES.map(m => { - const accent = { detection: '#6366f1', contours: '#14b8a6', gcode: '#f59e0b' }[m] + const accent = { detection: '#6366f1', contours: '#14b8a6', gcode: '#f59e0b', chordal: '#ec4899', printer: '#10b981', tuning: '#a855f7' }[m] const label = m === 'gcode' ? 'G-code' : m.charAt(0).toUpperCase() + m.slice(1) return ( +
+
+ + +
+
+ A: {pointA != null ? pointA.toFixed(3) : '—'} + B: {pointB != null ? pointB.toFixed(3) : '—'} +
+
+ + setKnownDist(parseFloat(e.target.value) || 0)} + className="flex-1 px-1.5 py-0.5 rounded bg-neutral-900 border border-neutral-700 text-xs" /> + mm +
+
+ + +
+ {currentSpm != null && proposedSpm != null && ( +

+ Δ = {((proposedSpm - currentSpm) / currentSpm * 100).toFixed(2)}%; max_travel meaning shifts by this much. Re-home after apply. +

+ )} + + ) +} + +export default function CalibrationAxis({ printerUrl, setStatus }) { + return ( +
+

Calibrate axis scale

+

+ Place a known reference (paper, ruler) along an axis. Use the Printer + tab's jog to position the pen exactly at point A, click Mark A; jog + to point B, click Mark B; enter the known physical distance, Compute, + Apply. Re-home afterward. +

+ + +
+ ) +} diff --git a/src-frontend/src/components/CalibrationButtons.jsx b/src-frontend/src/components/CalibrationButtons.jsx new file mode 100644 index 00000000..3fc0ac77 --- /dev/null +++ b/src-frontend/src/components/CalibrationButtons.jsx @@ -0,0 +1,104 @@ +import { useState } from 'react' +import * as tauri from '../hooks/useTauri.js' + +// Send gcode that lifts the pen and moves to (x, y) in machine coords. +// Goes via the WebSocket-safe path (printer_run_gcode_ws), so it won't +// trigger the FluidNC FLASH-cache panic that the synchronousCommand path does. +function buildJogGcode(x, y, penUpZ, feed) { + return [ + 'G90 G21', + `G0 Z${penUpZ.toFixed(3)}`, + `G0 X${x.toFixed(3)} Y${y.toFixed(3)} F${feed}`, + ].join('\n') +} + +function CornerBtn({ label, onClick, disabled }) { + return ( + + ) +} + +export default function CalibrationButtons({ gcodeConfig, imgSize, setStatus }) { + const [busy, setBusy] = useState(false) + + const url = gcodeConfig.printer_url ?? '' + const penUpZ = gcodeConfig.pen_up_z_mm ?? 2 + const feed = gcodeConfig.feed_travel ?? 3000 + + // Paper rect in machine coords + const px = gcodeConfig.paper_offset_x_mm ?? 0 + const py = gcodeConfig.paper_offset_y_mm ?? 0 + const pw = gcodeConfig.paper_w_mm + const ph = gcodeConfig.paper_h_mm + + // Image rect in machine coords (image height derived from pixel aspect) + const imgWmm = gcodeConfig.img_w_mm + const imgHmm = imgSize && imgSize.width > 0 + ? imgWmm * imgSize.height / imgSize.width + : imgWmm + const ix = px + (gcodeConfig.offset_x_mm ?? 0) + const iy = py + (gcodeConfig.offset_y_mm ?? 0) + + const goto = (label, x, y) => async () => { + if (!url) { setStatus('Set Printer URL in the Printer tab first'); return } + try { + setBusy(true) + setStatus(`Jog → ${label} (${x.toFixed(1)}, ${y.toFixed(1)})…`) + await tauri.printerRunGcodeWs(url, buildJogGcode(x, y, penUpZ, feed)) + setStatus(`At ${label}`) + } catch (e) { + setStatus(`Jog error: ${e.message ?? e}`) + } finally { setBusy(false) } + } + + const disabled = busy || !url + + const corner = (rx, ry, rw, rh) => ({ + TL: [rx, ry ], + TR: [rx + rw, ry ], + BL: [rx, ry + rh ], + BR: [rx + rw, ry + rh ], + }) + const paper = corner(px, py, pw, ph) + const image = corner(ix, iy, imgWmm, imgHmm) + + return ( +
+

Calibration jog

+

Lifts pen, moves to a corner so you can align the paper. Requires a homed machine.

+ +
+

Paper

+
+ + + + +
+
+ + {imgSize && ( +
+

Image

+
+ + + + +
+
+ )} +
+ ) +} diff --git a/src-frontend/src/components/ChordalDebugView.jsx b/src-frontend/src/components/ChordalDebugView.jsx new file mode 100644 index 00000000..2aa47a52 --- /dev/null +++ b/src-frontend/src/components/ChordalDebugView.jsx @@ -0,0 +1,505 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import * as tauri from '../hooks/useTauri.js' + +// macOS trackpads emit lots of small wheel events per gesture (versus a few +// large events from a discrete mouse wheel). Apply a much gentler per-event +// factor so a single two-finger swipe doesn't blast through the zoom range. +const IS_DARWIN = typeof navigator !== 'undefined' && + /Mac|iPhone|iPad|iPod/i.test(navigator.platform || navigator.userAgent || '') +const ZOOM_SENSITIVITY = IS_DARWIN ? 0.0015 : 0.015 + +// Steps as labelled in the chordal_axis_fill explanation. Each layer renders +// one of the algorithm's intermediate states. Toggle them on/off to walk +// through the algorithm at high zoom. +const LAYERS = [ + { key: 'source', label: '0. Source pixels', on: true }, + { key: 'outer', label: '1. Outer polygon', on: true }, + { key: 'holePixels', label: '2. Hole pixels', on: false }, + { key: 'holes', label: '3. Hole polygons', on: true }, + { key: 'triangulation', label: '4. CDT triangulation', on: true }, + { key: 'classification',label: '5. Triangle classification',on: true }, + { key: 'segments', label: '6. CAT segments', on: false }, + { key: 'polylines', label: '7-8. Polylines (raw)', on: false }, + { key: 'pruned', label: '8.5 Pruned branches', on: true }, + { key: 'strokes', label: '9. Final smoothed strokes', on: true }, + { key: 'vertices', label: '· Polygon vertices', on: false }, +] + +const KIND_FILL = { + junction: 'rgba(244, 63, 94, 0.30)', // red + sleeve: 'rgba(56, 189, 248, 0.20)', // cyan + termination: 'rgba(250, 204, 21, 0.30)', // amber + pure: 'rgba(168, 85, 247, 0.30)', // purple + outside: 'rgba(120, 120, 120, 0.06)', // grey +} + +export default function ChordalDebugView({ passIdx = 0 }) { + const [hulls, setHulls] = useState([]) + const [hullIdx, setHullIdx] = useState(0) + const [salience, setSalience] = useState(0) + const [sourceOpacity, setSourceOpacity] = useState(0.4) + const [debug, setDebug] = useState(null) + const [enabled, setEnabled] = useState( + Object.fromEntries(LAYERS.map(l => [l.key, l.on])), + ) + const [view, setView] = useState({ zoom: 1, panX: 0, panY: 0 }) + const containerRef = useRef(null) + const svgRef = useRef(null) + const dragRef = useRef(null) + const [hover, setHover] = useState(null) // {x, y, sx, sy} in image coords + const [selBox, setSelBox] = useState(null) // {x0, y0, x1, y1} in image coords during drag + const [toast, setToast] = useState(null) // ephemeral notification text + + // Load hull list whenever the tab is mounted. + useEffect(() => { + let alive = true + tauri.listHulls(passIdx).then(list => { + if (!alive) return + // Sort by area desc so the largest glyph is index 0 in the dropdown. + const sorted = [...list].sort((a, b) => b.area - a.area) + setHulls(sorted) + if (sorted.length > 0) setHullIdx(sorted[0].index) + }).catch(() => {}) + return () => { alive = false } + }, [passIdx]) + + // Pull debug data when hull or salience changes. + useEffect(() => { + if (hulls.length === 0) return + let alive = true + tauri.getChordalDebug(passIdx, hullIdx, salience).then(d => { + if (!alive) return + setDebug(d) + // Reset view on new hull. + setView({ zoom: 1, panX: 0, panY: 0 }) + }).catch(() => {}) + return () => { alive = false } + }, [passIdx, hullIdx, salience, hulls.length]) + + // viewBox: hull bbox padded by 4px, optionally zoomed/panned via SVG. + const viewBox = useMemo(() => { + if (!debug) return '0 0 100 100' + const [x0, y0, x1, y1] = debug.bounds + const pad = Math.max(2, (x1 - x0) * 0.04) + const w = (x1 - x0) + 2 * pad + const h = (y1 - y0) + 2 * pad + return `${x0 - pad - view.panX} ${y0 - pad - view.panY} ${w / view.zoom} ${h / view.zoom}` + }, [debug, view]) + + const onWheel = (e) => { + e.preventDefault() + if (!debug || !svgRef.current) return + // Continuous zoom proportional to scroll magnitude — feels right for + // both trackpad gestures and mouse wheel ticks. + const factor = Math.exp(-e.deltaY * ZOOM_SENSITIVITY) + const rect = svgRef.current.getBoundingClientRect() + // Cursor position normalised to the SVG element (independent of viewBox). + const u = (e.clientX - rect.left) / rect.width + const vN = (e.clientY - rect.top) / rect.height + const [x0, y0, x1, y1] = debug.bounds + const pad = Math.max(2, (x1 - x0) * 0.04) + const wBase = (x1 - x0) + 2 * pad + const hBase = (y1 - y0) + 2 * pad + setView(v => { + const newZoom = Math.max(0.1, Math.min(200, v.zoom * factor)) + if (newZoom === v.zoom) return v + // viewBox.x = (x0 - pad) - panX, viewBox.width = wBase / zoom. + // Keeping (viewBox.x + u * width) constant across zoom yields: + // panX_new = panX + u * (width_new - width_old) + const dW = wBase * (1 / newZoom - 1 / v.zoom) + const dH = hBase * (1 / newZoom - 1 / v.zoom) + return { + zoom: newZoom, + panX: v.panX + u * dW, + panY: v.panY + vN * dH, + } + }) + } + + // Convert clientXY to image-space coords using the SVG's CTM. + const clientToImage = (clientX, clientY) => { + const svg = svgRef.current + if (!svg) return null + const pt = svg.createSVGPoint(); pt.x = clientX; pt.y = clientY + const ctm = svg.getScreenCTM(); if (!ctm) return null + const ip = pt.matrixTransform(ctm.inverse()) + return { x: ip.x, y: ip.y } + } + + const onMouseDown = (e) => { + if (e.button !== 0) return + + // Shift-drag = box select. Falls back to pan-drag when shift not held. + if (e.shiftKey) { + const start = clientToImage(e.clientX, e.clientY) + if (!start) return + e.preventDefault() + setSelBox({ x0: start.x, y0: start.y, x1: start.x, y1: start.y }) + const onMove = (ev) => { + const cur = clientToImage(ev.clientX, ev.clientY); if (!cur) return + setSelBox(b => b && { ...b, x1: cur.x, y1: cur.y }) + } + const onUp = () => { + document.removeEventListener('mousemove', onMove) + document.removeEventListener('mouseup', onUp) + setSelBox(b => { + if (!b) return null + finalizeSelection(b) + return null + }) + } + document.addEventListener('mousemove', onMove) + document.addEventListener('mouseup', onUp) + return + } + + dragRef.current = { + startX: e.clientX, startY: e.clientY, + origPanX: view.panX, origPanY: view.panY, + } + const onMove = (ev) => { + const s = dragRef.current; if (!s) return + // Convert pixel drag to image-coord drag using current viewBox size. + const rect = containerRef.current.getBoundingClientRect() + if (!debug) return + const [x0, y0, x1, y1] = debug.bounds + const w = (x1 - x0) * 1.08 / view.zoom + const dx = (ev.clientX - s.startX) / rect.width * w + const dy = (ev.clientY - s.startY) / rect.height * (y1 - y0) * 1.08 / view.zoom + setView(v => ({ ...v, panX: s.origPanX + dx, panY: s.origPanY + dy })) + } + const onUp = () => { + dragRef.current = null + document.removeEventListener('mousemove', onMove) + document.removeEventListener('mouseup', onUp) + } + document.addEventListener('mousemove', onMove) + document.addEventListener('mouseup', onUp) + } + + // Filter all debug data to what's inside the selection box, format as JSON, + // and copy to clipboard. Format is intentionally compact-ish — meant to be + // pasted back into a chat to describe an issue at a specific glyph corner. + function finalizeSelection(box) { + if (!debug) return + const lo = { x: Math.min(box.x0, box.x1), y: Math.min(box.y0, box.y1) } + const hi = { x: Math.max(box.x0, box.x1), y: Math.max(box.y0, box.y1) } + if (hi.x - lo.x < 0.5 || hi.y - lo.y < 0.5) return // ignore tiny/click-only + + const ptIn = (p) => p[0] >= lo.x && p[0] <= hi.x && p[1] >= lo.y && p[1] <= hi.y + const anyIn = (pts) => pts.some(ptIn) + + const round2 = (n) => Math.round(n * 100) / 100 + const r2 = (p) => [round2(p[0]), round2(p[1])] + const r2list = (pts) => pts.map(r2) + + const out = { + hull_index: hullIdx, + box: [round2(lo.x), round2(lo.y), round2(hi.x), round2(hi.y)], + outer_vertices_in_box: r2list(debug.outer.filter(ptIn)), + hole_vertices_in_box: debug.holes.map(h => r2list(h.filter(ptIn))).filter(h => h.length > 0), + triangles: debug.triangles.filter(t => anyIn(t.points)) + .map(t => ({ + points: r2list(t.points), + edge_constraint: t.edge_constraint, + kind: t.kind, + })), + segments: debug.segments.filter(([a, b]) => ptIn(a) || ptIn(b)) + .map(([a, b]) => [r2(a), r2(b)]), + polylines: debug.polylines.filter(p => anyIn(p.points)) + .map(p => ({ + branch: p.branch, + kept: p.kept, + points: r2list(p.points), + })), + strokes: debug.strokes.filter(anyIn).map(r2list), + } + + const json = JSON.stringify(out, null, 2) + navigator.clipboard.writeText(json).then(() => { + const summary = `${out.outer_vertices_in_box.length} outer · ` + + `${out.triangles.length} tris · ` + + `${out.segments.length} segs · ` + + `${out.polylines.length} polylines · ` + + `${out.strokes.length} strokes` + setToast(`Copied to clipboard — ${summary}`) + setTimeout(() => setToast(null), 3000) + }).catch(err => { + setToast(`Clipboard write failed: ${err.message ?? err}`) + setTimeout(() => setToast(null), 4000) + }) + } + + const onMouseMoveSvg = (e) => { + if (!debug) return + const svg = e.currentTarget + const pt = svg.createSVGPoint() + pt.x = e.clientX; pt.y = e.clientY + const ctm = svg.getScreenCTM() + if (!ctm) return + const inv = ctm.inverse() + const ip = pt.matrixTransform(inv) + setHover({ x: ip.x, y: ip.y, sx: e.clientX, sy: e.clientY }) + } + + const toggleLayer = (key) => setEnabled(en => ({ ...en, [key]: !en[key] })) + + if (!debug) { + return ( +
+
+

Chordal debug

+

No hulls available — run the pipeline first (Source → Kernel → Hull).

+
+
+ ) + } + + return ( +
+ {/* Sidebar */} +
+
+ + +
+ +
+ + setSalience(parseFloat(e.target.value))} + className="w-full" /> +
+ +
+ + setSourceOpacity(parseFloat(e.target.value))} + className="w-full" /> +
+ +
+
Layers
+
+ {LAYERS.map(l => ( + + ))} +
+
+ +
+

Zoom: wheel · Pan: drag

+

Reset: + +

+
+
· {debug.outer.length} outer verts
+
· {debug.holes.length} holes ({debug.hole_pixels.reduce((s, h) => s + h.length, 0)} px)
+
· {debug.triangles.length} triangles + ({debug.triangles.filter(t => t.kind !== 'outside').length} interior)
+
· {debug.segments.length} CAT segments
+
· {debug.polylines.length} polylines + ({debug.polylines.filter(p => p.branch).length} branches, + {' '}{debug.polylines.filter(p => !p.kept).length} pruned)
+
· {debug.strokes.length} final strokes
+
· source_b64: {debug.source_b64 + ? `${(debug.source_b64.length / 1024).toFixed(1)} KB` + : 'MISSING (rebuild Rust)'}
+
+ {hover && ( +
+ ({hover.x.toFixed(2)}, {hover.y.toFixed(2)}) +
+ )} +
+
+ + {/* Canvas */} +
+ + + {/* Source pixels — bottom-most, so all algorithm layers render on top. + imageRendering pixelated keeps it crisp under zoom. xlinkHref + alongside href for max webview compatibility. */} + {enabled.source && debug.source_b64 && ( + + )} + + {/* Hole pixel cells (raster underlay) */} + {enabled.holePixels && debug.hole_pixels.map((hp, hi) => ( + + {hp.map(([x, y], i) => ( + + ))} + + ))} + + {/* Triangulation: fill by classification, stroke for edges. + strokeWidth is in screen pixels (vectorEffect:non-scaling-stroke), + so 0.6 = ~half a CSS pixel, visible at every zoom level. */} + {enabled.triangulation && debug.triangles.map((t, i) => { + const fill = enabled.classification ? (KIND_FILL[t.kind] ?? 'transparent') : 'transparent' + return ( + + `${p[0]},${p[1]}`).join(' ')} + fill={fill} + stroke="rgba(160, 160, 165, 0.85)" + strokeWidth={0.6} + vectorEffect="non-scaling-stroke" + /> + {/* Constraint edges drawn thicker + bright so they read as + "this is the polygon boundary, the rest are diagonals." */} + {t.edge_constraint.map((c, ei) => { + if (!c) return null + const a = t.points[ei] + const b = t.points[(ei + 1) % 3] + return ( + + ) + })} + + ) + })} + + {/* Outer polygon (highlighted on top) */} + {enabled.outer && ( + `${p[0]},${p[1]}`).join(' ')} + fill="none" stroke="#34d399" strokeWidth={1.2} + vectorEffect="non-scaling-stroke" /> + )} + + {/* Hole polygons */} + {enabled.holes && debug.holes.map((h, i) => ( + `${p[0]},${p[1]}`).join(' ')} + fill="none" stroke="#fbbf24" strokeWidth={1.0} + vectorEffect="non-scaling-stroke" /> + ))} + + {/* Polygon vertices */} + {enabled.vertices && ( + + {debug.outer.map((p, i) => ( + + ))} + {debug.holes.flatMap((h, hi) => h.map((p, i) => ( + + )))} + + )} + + {/* Raw CAT segments (before walking) */} + {enabled.segments && debug.segments.map(([a, b], i) => ( + + ))} + + {/* Raw polylines (before smoothing) */} + {enabled.polylines && debug.polylines.map((pl, i) => ( + `${p[0]},${p[1]}`).join(' ')} + fill="none" + stroke={pl.branch ? '#a78bfa' : '#22d3ee'} + strokeWidth={0.8} + vectorEffect="non-scaling-stroke" /> + ))} + + {/* Pruned (dropped by salience) — dashed red so it's visible against + the kept set. Only meaningful when salience > 0. */} + {enabled.pruned && debug.polylines.filter(p => !p.kept).map((pl, i) => ( + `${p[0]},${p[1]}`).join(' ')} + fill="none" + stroke="#ef4444" + strokeWidth={0.8} + strokeDasharray="2 1" + vectorEffect="non-scaling-stroke" /> + ))} + + {/* Final smoothed strokes (the gcode output) */} + {enabled.strokes && debug.strokes.map((s, i) => ( + `${p[0]},${p[1]}`).join(' ')} + fill="none" stroke="#f8fafc" strokeWidth={0.8} + strokeLinecap="round" strokeLinejoin="round" + vectorEffect="non-scaling-stroke" /> + ))} + + {/* Selection box (live during shift-drag) */} + {selBox && ( + + )} + + + {/* Shift+drag hint, bottom-left */} +
+ Shift+drag to copy region data to clipboard +
+ + {/* Toast notification */} + {toast && ( +
+ {toast} +
+ )} +
+
+ ) +} diff --git a/src-frontend/src/components/PrinterPanel.jsx b/src-frontend/src/components/PrinterPanel.jsx new file mode 100644 index 00000000..ae99d6ec --- /dev/null +++ b/src-frontend/src/components/PrinterPanel.jsx @@ -0,0 +1,265 @@ +import { useEffect, useRef, useState, useCallback } 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 +const JOG_FEED = 1500 +const JOG_STEPS = [0.1, 1, 10, 50] + +function StateBadge({ state }) { + const colors = { + Idle: 'bg-emerald-700 text-emerald-100', + Run: 'bg-sky-700 text-sky-100', + Jog: 'bg-sky-700 text-sky-100', + Home: 'bg-sky-700 text-sky-100', + Hold: 'bg-amber-600 text-amber-50', + Alarm: 'bg-red-700 text-red-100', + Door: 'bg-amber-600 text-amber-50', + Sleep: 'bg-neutral-600 text-neutral-100', + } + const c = colors[state] || 'bg-neutral-700 text-neutral-300' + return ( + + {state || '—'} + + ) +} + +function Btn({ children, onClick, disabled, variant = 'default', className = '', title }) { + const variants = { + default: 'bg-neutral-800 hover:bg-neutral-700 border border-neutral-700 text-neutral-200', + danger: 'bg-red-700 hover:bg-red-600 text-white', + warn: 'bg-amber-700 hover:bg-amber-600 text-white', + good: 'bg-emerald-700 hover:bg-emerald-600 text-white', + primary: 'bg-indigo-700 hover:bg-indigo-600 text-white', + } + return ( + + ) +} + +export default function PrinterPanel({ printerUrl, setPrinterUrl, gcodeConfig, hasStrokes, onStatus }) { + const [status, setStatus] = useState({ state: '' }) + 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]) + + // Listen for upload-progress events emitted by the Rust upload command + // (~10 Hz). Reset when an upload completes (sent === total). + useEffect(() => { + let unlisten = null + listen('upload-progress', e => { + const p = e.payload + setUpload(p) + if (p && p.sent >= p.total) { + // Clear the progress display ~1s after completion so the user sees 100% + setTimeout(() => setUpload(prev => (prev && prev.file === p.file && prev.sent >= prev.total) ? null : prev), 1200) + } + }).then(fn => { unlisten = fn }) + return () => { if (unlisten) unlisten() } + }, []) + + // ── 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') { + setMsg('Refusing to list SD files while machine is in motion.') + return + } + try { + setBusy(true) + const files = await tauri.printerListSd(printerUrl) + setSdFiles(files) + setMsg(`${files.length} gcode file(s) on SD`) + } catch (e) { + setMsg(`List error: ${e}`) + } finally { setBusy(false) } + } + + // ── Action wrappers ──────────────────────────────────────────────────────── + const wrap = (label, fn) => async () => { + try { setBusy(true); setMsg(label + '…'); const r = await fn(); setMsg(label + ' ok'); refreshStatus(); return r } + catch (e) { setMsg(`${label} error: ${e}`) } + finally { setBusy(false) } + } + + const onHold = wrap('Feed hold', () => tauri.printerFeedHold(printerUrl)) + const onResume = wrap('Resume', () => tauri.printerCycleStart(printerUrl)) + const onReset = wrap('Soft reset', () => tauri.printerSoftReset(printerUrl)) + const onHardReset = wrap('Hard reset', () => tauri.printerHardReset(printerUrl)) + const onUnlock = wrap('Unlock', () => tauri.printerCommand(printerUrl, '$X')) + // Home and Jog go via WebSocket (printer_home_ws / printer_jog_ws). Sending + // $H or $J= over /command?plain= holds an HTTP connection open during the + // 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)) + + const jog = (axis, dir) => wrap(`Jog ${axis}${dir > 0 ? '+' : '-'}`, () => + tauri.printerJogWs(printerUrl, `$J=G91 G21 F${JOG_FEED} ${axis}${dir * jogStep}`) + ) + + return ( +
+ {/* Connection */} +
+
+

Printer

+ +
+ +
+ 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' && ( +

+ MPos  + X {(status.mpos[0] ?? 0).toFixed(2)}{' '} + Y {(status.mpos[1] ?? 0).toFixed(2)}{' '} + Z {(status.mpos[2] ?? 0).toFixed(2)} + {status.feed > 0 && F {status.feed.toFixed(0)}} +

+ )} + {msg &&

{msg}

} +
+ + {/* Realtime control — always available */} +
+

Control

+
+ Hold (!) + Resume (~) + Unlock ($X) +
+
+ + Soft reset + + + Hard reset (reboot) + +
+
+ + {/* Jog — Idle only */} +
+
+

Jog

+
+ {JOG_STEPS.map(s => ( + + ))} +
+
+
+
+ Y+ +
+ X- + Home + X+ +
+ Y- +
+
+
+ Z+ (pen up) + Z- (pen down) +
+
+ + {/* Print */} +
+

Print

+
+ + ▶ Send current project & run + +
+ Upload only + List SD files +
+ {upload && ( +
+
+ {upload.file} + {(upload.sent/1024).toFixed(1)} / {(upload.total/1024).toFixed(1)} KB +
+
+
+
+
+ )} + {sdFiles.length > 0 && ( +
+ {sdFiles.map(f => ( + + ))} +
+ )} + {selected && ( + + ▶ Run {selected} + + )} +
+
+ +
+

Tip: don't open the FluidNC WebUI Files tab during a run or upload — it races the SD/FLASH driver and can panic the controller or truncate uploads.

+
+
+ ) +} diff --git a/src-frontend/src/components/TextEditOverlay.jsx b/src-frontend/src/components/TextEditOverlay.jsx new file mode 100644 index 00000000..b462e8d0 --- /dev/null +++ b/src-frontend/src/components/TextEditOverlay.jsx @@ -0,0 +1,319 @@ +import { useEffect, useMemo, useRef, useState, useLayoutEffect } from 'react' +import { renderText, measureText } from '../lib/hershey.js' + +// MS-Paint-style WYSIWYG text editor for the Source tab. +// +// Each box is a stack of three layers in mm-coord SVG-space: +// 1. visual: rendering the actual Hershey strokes (what will plot) +// 2. editable: a transparent
+
+ {blocks.map((b, i) => ( + setSelectedIdx(i)} + onChange={patch => setBlocks(bs => bs.map((bb, j) => j === i ? { ...bb, ...patch } : bb))} + onDelete={() => { setBlocks(bs => bs.filter((_, j) => j !== i)); setSelectedIdx(null) }} + mmToPx={mmToPx} pxToMm={pxToMm} + /> + ))} +
+

+ Drag to pan • Wheel to zoom • Double-click paper to add a text box • Drag header to move • Drag SE corner to scale font +

+

+ Paper {paperWMm.toFixed(0)}×{paperHMm.toFixed(0)}mm · {(view.zoom * 100).toFixed(0)}% +

+
+ ) +} + +// ── Single box ────────────────────────────────────────────────────────────── +function TextBox({ block, selected, onSelect, onChange, onDelete, mmToPx, pxToMm }) { + const taRef = useRef(null) + const dragStateRef = useRef(null) + + // Real Hershey rendering: actual strokes & accurate measurement. + const { strokes, widthMm, heightMm } = useMemo(() => { + const { width, height } = measureText(block.text || ' ', block.font_size_mm, block.line_spacing_mm) + const strokes = renderText(block.text || '', block.font_size_mm, block.line_spacing_mm) + return { + strokes, + widthMm: Math.max(8, width + 2), // small breathing room so caret has space + heightMm: Math.max(block.font_size_mm, height), + } + }, [block.text, block.font_size_mm, block.line_spacing_mm]) + + const left = mmToPx(block.x_mm) + const top = mmToPx(block.y_mm) + const width = mmToPx(widthMm) + const height = mmToPx(heightMm) + const fontPx = mmToPx(block.font_size_mm) + // Hershey-pen-thickness ≈ 1/14 cap-height — emulate visually with a thin + // SVG stroke. Scale with font size so it stays visible. + const strokePx = Math.max(0.6, fontPx / 14) + + const onHeaderMouseDown = (e) => { + e.stopPropagation() + e.preventDefault() + onSelect() + dragStateRef.current = { + kind: 'move', + startX: e.clientX, startY: e.clientY, + origMm: { x: block.x_mm, y: block.y_mm }, + } + document.addEventListener('mousemove', onMouseMove) + document.addEventListener('mouseup', onMouseUp) + } + + const onResizeMouseDown = (e) => { + e.stopPropagation() + e.preventDefault() + onSelect() + dragStateRef.current = { + kind: 'resize', + startX: e.clientX, startY: e.clientY, + origFontMm: block.font_size_mm, + origLineMm: block.line_spacing_mm ?? block.font_size_mm * 1.6, + origWidthPx: width, + origHeightPx: height, + } + document.addEventListener('mousemove', onMouseMove) + document.addEventListener('mouseup', onMouseUp) + } + + function onMouseMove(e) { + const s = dragStateRef.current + if (!s) return + const dx = e.clientX - s.startX + const dy = e.clientY - s.startY + if (s.kind === 'move') { + onChange({ + x_mm: Math.max(0, s.origMm.x + pxToMm(dx)), + y_mm: Math.max(0, s.origMm.y + pxToMm(dy)), + }) + } else if (s.kind === 'resize') { + const sx = (s.origWidthPx + dx) / s.origWidthPx + const sy = (s.origHeightPx + dy) / s.origHeightPx + const factor = Math.max(0.25, Math.min(8, Math.max(sx, sy))) + onChange({ + font_size_mm: Math.max(1, s.origFontMm * factor), + line_spacing_mm: Math.max(2, s.origLineMm * factor), + }) + } + } + + function onMouseUp() { + dragStateRef.current = null + document.removeEventListener('mousemove', onMouseMove) + document.removeEventListener('mouseup', onMouseUp) + } + useEffect(() => () => onMouseUp(), []) + + // Auto-grow textarea height on text change so the caret tracks the + // expanding box. + useEffect(() => { + if (taRef.current) taRef.current.style.height = `${height}px` + }, [height]) + + return ( +
{ e.stopPropagation(); onSelect() }}> + + {/* Header — draggable */} +
+ + {block.x_mm.toFixed(0)},{block.y_mm.toFixed(0)} · {block.font_size_mm.toFixed(1)}mm + + {selected && ( + + )} +
+ + {/* Body: SVG (visual) + textarea (caret) stacked at same coords. */} +
+ {/* WYSIWYG Hershey rendering */} + + {strokes.map((pts, i) => ( + `${x},${y}`).join(' ')} + fill="none" stroke="black" strokeWidth={strokePx / mmToPx(1)} + strokeLinecap="round" strokeLinejoin="round" /> + ))} + + {/* Editable layer: caret-only, transparent text. Lining up the + character cells exactly with Hershey is impossible (Hershey is + proportional, system mono is not), but a monospace placeholder + keeps the caret tracking close enough for editing. */} +