No image loaded
diff --git a/src-frontend/src/components/CalibrationAxis.jsx b/src-frontend/src/components/CalibrationAxis.jsx
new file mode 100644
index 00000000..7645cfb2
--- /dev/null
+++ b/src-frontend/src/components/CalibrationAxis.jsx
@@ -0,0 +1,137 @@
+import { useState } from 'react'
+import * as tauri from '../hooks/useTauri.js'
+
+// Calibrate steps_per_mm against a known physical reference (paper edge,
+// ruler, etc.). User marks two points along an axis, enters the known
+// physical distance, app computes the corrected setting and writes it.
+//
+// Math:
+// reported_distance_mm = | mposB - mposA | (in current commanded mm)
+// actual_distance_mm = user-entered known physical distance
+// new_steps_per_mm = current * reported_distance_mm / actual_distance_mm
+//
+// After applying, the user must re-home (the controller's current position
+// is in the old scale and won't be accurate until rehomed).
+
+const SETTING_PATH = {
+ X: 'axes/x/steps_per_mm',
+ Y: 'axes/y/steps_per_mm',
+}
+
+function AxisRow({ axis, axisIndex, printerUrl, setStatus }) {
+ const [pointA, setPointA] = useState(null) // f32 — MPos on this axis
+ const [pointB, setPointB] = useState(null)
+ const [knownDist, setKnownDist] = useState(axis === 'X' ? 210 : 297) // A4 width/height
+ const [busy, setBusy] = useState(false)
+ const [currentSpm, setCurrentSpm] = useState(null)
+ const [proposedSpm, setProposedSpm] = useState(null)
+
+ const mark = async (which) => {
+ if (!printerUrl) { setStatus('Set Printer URL first'); return }
+ try {
+ setBusy(true); setStatus(`Reading ${axis} position…`)
+ const s = await tauri.printerStatusWs(printerUrl)
+ const v = s.mpos[axisIndex]
+ if (which === 'A') setPointA(v); else setPointB(v)
+ setStatus(`${axis} ${which} = ${v.toFixed(3)} mm`)
+ } catch (e) {
+ setStatus(`Read error: ${e.message ?? e}`)
+ } finally { setBusy(false) }
+ }
+
+ const computeProposal = async () => {
+ if (pointA == null || pointB == null) { setStatus('Mark both A and B first'); return }
+ if (!knownDist || knownDist <= 0) { setStatus('Enter known distance > 0'); return }
+ try {
+ setBusy(true)
+ const cur = parseFloat(await tauri.printerGetSetting(printerUrl, SETTING_PATH[axis]))
+ if (Number.isNaN(cur)) throw new Error('Could not read current steps_per_mm')
+ const reported = Math.abs(pointB - pointA)
+ const proposed = cur * reported / knownDist
+ setCurrentSpm(cur)
+ setProposedSpm(proposed)
+ setStatus(`${axis}: reported ${reported.toFixed(3)}mm vs known ${knownDist}mm → proposed steps_per_mm = ${proposed.toFixed(3)} (was ${cur.toFixed(3)})`)
+ } catch (e) {
+ setStatus(`Compute error: ${e.message ?? e}`)
+ } finally { setBusy(false) }
+ }
+
+ const apply = async () => {
+ if (proposedSpm == null) return
+ try {
+ setBusy(true); setStatus(`Writing ${axis} steps_per_mm = ${proposedSpm.toFixed(3)}…`)
+ await tauri.printerSetSetting(printerUrl, SETTING_PATH[axis], proposedSpm.toFixed(3))
+ setStatus(`${axis} steps_per_mm updated. Re-home before further use; max_travel may also need adjustment.`)
+ setCurrentSpm(proposedSpm)
+ setProposedSpm(null)
+ setPointA(null)
+ setPointB(null)
+ } catch (e) {
+ setStatus(`Write error: ${e.message ?? e}`)
+ } finally { setBusy(false) }
+ }
+
+ const reset = () => { setPointA(null); setPointB(null); setProposedSpm(null); setCurrentSpm(null) }
+
+ return (
+
+
+
+ mark('A')} disabled={busy || !printerUrl}
+ className="px-2 py-1 rounded bg-neutral-800 hover:bg-neutral-700 border border-neutral-700 text-xs disabled:opacity-40">
+ Mark A {pointA != null ? ✓ : ''}
+
+ mark('B')} disabled={busy || !printerUrl}
+ className="px-2 py-1 rounded bg-neutral-800 hover:bg-neutral-700 border border-neutral-700 text-xs disabled:opacity-40">
+ Mark B {pointB != null ? ✓ : ''}
+
+
+
+ A: {pointA != null ? pointA.toFixed(3) : '—'}
+ B: {pointB != null ? pointB.toFixed(3) : '—'}
+
+
+ Actual:
+ 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
+
+
+
+ Compute
+
+
+ Apply{proposedSpm != null && ({proposedSpm.toFixed(2)} )}
+
+
+ {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 (
+
+ {label}
+
+ )
+}
+
+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.
+
+
+
+ {imgSize && (
+
+ )}
+
+ )
+}
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 */}
+
+
+ Hull (largest first)
+ setHullIdx(parseInt(e.target.value, 10))}
+ className="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-xs">
+ {hulls.map((h, i) => (
+
+ #{h.index} · {h.area}px · {h.bounds[2] - h.bounds[0]}×{h.bounds[3] - h.bounds[1]}
+ {i === 0 ? ' (largest)' : ''}
+
+ ))}
+
+
+
+
+
+ Salience: {salience.toFixed(1)}
+
+ setSalience(parseFloat(e.target.value))}
+ className="w-full" />
+
+
+
+
+ Source opacity: {(sourceOpacity * 100).toFixed(0)}%
+
+ setSourceOpacity(parseFloat(e.target.value))}
+ className="w-full" />
+
+
+
+
Layers
+
+ {LAYERS.map(l => (
+
+ toggleLayer(l.key)} />
+ {l.label}
+
+ ))}
+
+
+
+
+
Zoom: wheel · Pan: drag
+
Reset:
+ setView({ zoom: 1, panX: 0, panY: 0 })}
+ className="ml-2 px-2 py-0.5 bg-neutral-800 rounded">Fit
+
+
+
· {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 (
+
+ {children}
+
+ )
+}
+
+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 */}
+
+
+ {/* Realtime control — always available */}
+
+ Control
+
+ Hold (!)
+ Resume (~)
+ Unlock ($X)
+
+
+
+ Soft reset
+
+
+ Hard reset (reboot)
+
+
+
+
+ {/* Jog — Idle only */}
+
+
+
Jog
+
+ {JOG_STEPS.map(s => (
+ setJogStep(s)}
+ className={`px-2 py-0.5 text-xs rounded ${jogStep === s ? 'bg-indigo-700 text-white' : 'bg-neutral-800 text-neutral-400 hover:bg-neutral-700'}`}>
+ {s}mm
+
+ ))}
+
+
+
+
+
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 => (
+
+ setSelected(f.name)} className="accent-indigo-600" />
+ {f.name}
+ {(f.size / 1024).toFixed(1)} KB
+
+ ))}
+
+ )}
+ {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