This commit is contained in:
Mitchell Hansen
2026-04-30 01:42:40 -07:00
parent 14dc804112
commit 2e26069a35
21 changed files with 5033 additions and 140 deletions

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

View File

@@ -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() {
<div className="px-3 py-2 space-y-4">
{/* Source mode */}
<div className="space-y-1.5">
<p className="text-neutral-500 text-xs font-semibold uppercase tracking-wider">Source</p>
<div className="grid grid-cols-2 gap-1">
<button onClick={() => setSourceMode('image')}
className={`px-2 py-1 rounded text-xs ${sourceMode === 'image' ? 'bg-indigo-700 text-white' : 'bg-neutral-800 hover:bg-neutral-700 text-neutral-400'}`}>
Image
</button>
<button onClick={() => setSourceMode('text')}
className={`px-2 py-1 rounded text-xs ${sourceMode === 'text' ? 'bg-indigo-700 text-white' : 'bg-neutral-800 hover:bg-neutral-700 text-neutral-400'}`}>
Text
</button>
</div>
{sourceMode === 'text' && (
<div className="pt-1 space-y-1.5">
<p className="text-[11px] text-neutral-400 leading-snug">
Switch to the <span className="text-emerald-500">Source</span> 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.
</p>
<p className="text-[10px] text-neutral-600 leading-snug">
Blocks rasterise to a paper-sized image and run through the graph
(Detection Hull Fill) like any image source.
</p>
<button
onClick={() => setTextBlocks([])}
disabled={textBlocks.length === 0}
className="w-full px-2 py-1 rounded bg-neutral-800 hover:bg-neutral-700 text-xs text-neutral-400 disabled:opacity-40">
Clear all blocks ({textBlocks.length})
</button>
</div>
)}
</div>
{/* Graph */}
<div className="space-y-0.5">
<p className="text-neutral-500 text-xs font-semibold uppercase tracking-wider mb-1.5">Graph</p>
@@ -398,9 +473,10 @@ export default function App() {
<button key={ps.name}
onClick={() => {
const portrait = gcodeConfig.paper_w_mm <= gcodeConfig.paper_h_mm
setGcode(portrait
const next = portrait
? { paper_w_mm: ps.w, paper_h_mm: ps.h }
: { paper_w_mm: ps.h, paper_h_mm: ps.w })
: { paper_w_mm: ps.h, paper_h_mm: ps.w }
setGcode({ ...next, ...centerPaperOnBed({ ...gcodeConfig, ...next }) })
}}
className={`px-2 py-0.5 rounded text-xs transition-colors ${
isPortrait || isLandscape
@@ -410,7 +486,10 @@ export default function App() {
)
})}
<button
onClick={() => setGcode({ paper_w_mm: gcodeConfig.paper_h_mm, paper_h_mm: gcodeConfig.paper_w_mm })}
onClick={() => {
const next = { paper_w_mm: gcodeConfig.paper_h_mm, paper_h_mm: gcodeConfig.paper_w_mm }
setGcode({ ...next, ...centerPaperOnBed({ ...gcodeConfig, ...next }) })
}}
title={gcodeConfig.paper_w_mm <= gcodeConfig.paper_h_mm ? 'Switch to landscape' : 'Switch to portrait'}
className="px-2 py-0.5 rounded text-xs bg-neutral-800 text-neutral-400 hover:bg-neutral-700 transition-colors"
>Rotate</button>
@@ -453,27 +532,22 @@ export default function App() {
onChange={v => setGcode({ feed_travel: v })} unit=" mm/m" />
<Slider label="Pen lift height" value={gcodeConfig.pen_up_z_mm ?? 2} min={0.5} max={5} step={0.1} unit="mm"
onChange={v => setGcode({ pen_up_z_mm: v })} />
<Slider label="Pen settle" value={gcodeConfig.pen_dwell_ms ?? 250} min={0} max={1000} step={10} unit="ms"
onChange={v => setGcode({ pen_dwell_ms: v })} />
</div>
{/* Calibration: corner jog + axis-scale */}
<CalibrationButtons gcodeConfig={gcodeConfig} imgSize={image} setStatus={setGlobalStatus} />
<CalibrationAxis printerUrl={gcodeConfig.printer_url} setStatus={setGlobalStatus} />
{/* Export & upload */}
<div className="space-y-2">
<p className="text-neutral-500 text-xs font-semibold uppercase tracking-wider">Output</p>
<button onClick={exportAll} disabled={!passes.some(p => p.strokeCount > 0)}
<button onClick={exportAll} disabled={!hasOutput}
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
</button>
<div className="space-y-1">
<label className="text-neutral-400 text-xs">Printer URL</label>
<input type="text" value={gcodeConfig.printer_url ?? ''}
onChange={e => 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" />
</div>
<button onClick={uploadToPrinter}
disabled={!passes.some(p => p.strokeCount > 0) || !gcodeConfig.printer_url}
className="w-full px-3 py-1.5 rounded bg-emerald-700 hover:bg-emerald-600 text-xs text-white disabled:opacity-40 transition-colors">
Send to printer (upload only)
</button>
<p className="text-xs text-neutral-600">Use the <span className="text-emerald-500">Printer</span> tab to upload &amp; run.</p>
</div>
</div>
@@ -492,7 +566,7 @@ export default function App() {
{/* Top bar — accent colors match the section dots in the left panel */}
<div className="flex items-center gap-1 px-3 py-2 border-b border-neutral-800 bg-neutral-900/80">
{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 (
<button key={m}
@@ -532,6 +606,24 @@ export default function App() {
sourceImageB64={image?.preview_b64 ?? null}
nodeWidth={nodeWidth}
/>
) : viewMode === 'printer' ? (
<PrinterPanel
printerUrl={gcodeConfig.printer_url ?? ''}
setPrinterUrl={v => setGcode({ printer_url: v })}
gcodeConfig={gcodeConfig}
hasStrokes={hasOutput}
/>
) : viewMode === 'tuning' ? (
<TuningPanel printerUrl={gcodeConfig.printer_url ?? ''} />
) : viewMode === 'chordal' ? (
<ChordalDebugView passIdx={0} />
) : viewMode === 'source' && sourceMode === 'text' ? (
<TextEditOverlay
paperWMm={gcodeConfig.paper_w_mm}
paperHMm={gcodeConfig.paper_h_mm}
blocks={textBlocks}
setBlocks={setTextBlocks}
/>
) : (
<Viewport
imageB64={displayB64}
@@ -542,7 +634,7 @@ export default function App() {
/>
)}
{showPerf && <PerfPanel data={perfData} fps={fps} longTasks={longTasks} />}
{!image && (
{!image && sourceMode !== 'text' && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="text-center space-y-2">
<p className="text-neutral-600 text-lg">No image loaded</p>

View File

@@ -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 (
<div className="space-y-1.5 border border-neutral-800 rounded p-2">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold text-neutral-300">{axis} axis</p>
<button onClick={reset} disabled={busy}
className="text-[10px] text-neutral-600 hover:text-neutral-400">reset</button>
</div>
<div className="grid grid-cols-2 gap-1">
<button onClick={() => 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 ? <span className="text-emerald-500"></span> : ''}
</button>
<button onClick={() => 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 ? <span className="text-emerald-500"></span> : ''}
</button>
</div>
<div className="text-[10px] text-neutral-600 grid grid-cols-2 gap-1">
<span>A: {pointA != null ? pointA.toFixed(3) : '—'}</span>
<span>B: {pointB != null ? pointB.toFixed(3) : '—'}</span>
</div>
<div className="flex items-center gap-1 text-xs">
<label className="text-neutral-400 text-[11px]">Actual:</label>
<input type="number" value={knownDist} step="0.1" min="1" max="2000"
onChange={e => 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" />
<span className="text-neutral-600 text-[10px]">mm</span>
</div>
<div className="grid grid-cols-2 gap-1">
<button onClick={computeProposal} disabled={busy || pointA == null || pointB == null}
className="px-2 py-1 rounded bg-indigo-700 hover:bg-indigo-600 text-xs text-white disabled:opacity-40">
Compute
</button>
<button onClick={apply} disabled={busy || proposedSpm == null}
className="px-2 py-1 rounded bg-emerald-700 hover:bg-emerald-600 text-xs text-white disabled:opacity-40">
Apply{proposedSpm != null && (<span className="font-mono ml-1">{proposedSpm.toFixed(2)}</span>)}
</button>
</div>
{currentSpm != null && proposedSpm != null && (
<p className="text-[10px] text-amber-500/90">
Δ = {((proposedSpm - currentSpm) / currentSpm * 100).toFixed(2)}%; max_travel meaning shifts by this much. Re-home after apply.
</p>
)}
</div>
)
}
export default function CalibrationAxis({ printerUrl, setStatus }) {
return (
<div className="space-y-2">
<p className="text-neutral-500 text-xs font-semibold uppercase tracking-wider">Calibrate axis scale</p>
<p className="text-xs text-neutral-600">
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.
</p>
<AxisRow axis="X" axisIndex={0} printerUrl={printerUrl} setStatus={setStatus} />
<AxisRow axis="Y" axisIndex={1} printerUrl={printerUrl} setStatus={setStatus} />
</div>
)
}

View File

@@ -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 (
<button onClick={onClick} disabled={disabled}
className="px-2 py-1 rounded bg-neutral-800 hover:bg-neutral-700 border border-neutral-700 text-xs text-neutral-300 disabled:opacity-40 disabled:cursor-not-allowed transition-colors">
{label}
</button>
)
}
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 (
<div className="space-y-2">
<p className="text-neutral-500 text-xs font-semibold uppercase tracking-wider">Calibration jog</p>
<p className="text-xs text-neutral-600">Lifts pen, moves to a corner so you can align the paper. Requires a homed machine.</p>
<div className="space-y-1">
<p className="text-[10px] uppercase tracking-wider text-yellow-500/70">Paper</p>
<div className="grid grid-cols-2 gap-1">
<CornerBtn label={`TL ${paper.TL[0].toFixed(0)},${paper.TL[1].toFixed(0)}`} disabled={disabled}
onClick={goto('Paper TL', ...paper.TL)} />
<CornerBtn label={`TR ${paper.TR[0].toFixed(0)},${paper.TR[1].toFixed(0)}`} disabled={disabled}
onClick={goto('Paper TR', ...paper.TR)} />
<CornerBtn label={`BL ${paper.BL[0].toFixed(0)},${paper.BL[1].toFixed(0)}`} disabled={disabled}
onClick={goto('Paper BL', ...paper.BL)} />
<CornerBtn label={`BR ${paper.BR[0].toFixed(0)},${paper.BR[1].toFixed(0)}`} disabled={disabled}
onClick={goto('Paper BR', ...paper.BR)} />
</div>
</div>
{imgSize && (
<div className="space-y-1">
<p className="text-[10px] uppercase tracking-wider text-orange-400/70">Image</p>
<div className="grid grid-cols-2 gap-1">
<CornerBtn label={`TL ${image.TL[0].toFixed(0)},${image.TL[1].toFixed(0)}`} disabled={disabled}
onClick={goto('Image TL', ...image.TL)} />
<CornerBtn label={`TR ${image.TR[0].toFixed(0)},${image.TR[1].toFixed(0)}`} disabled={disabled}
onClick={goto('Image TR', ...image.TR)} />
<CornerBtn label={`BL ${image.BL[0].toFixed(0)},${image.BL[1].toFixed(0)}`} disabled={disabled}
onClick={goto('Image BL', ...image.BL)} />
<CornerBtn label={`BR ${image.BR[0].toFixed(0)},${image.BR[1].toFixed(0)}`} disabled={disabled}
onClick={goto('Image BR', ...image.BR)} />
</div>
</div>
)}
</div>
)
}

View File

@@ -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 (
<div className="absolute inset-0 flex items-center justify-center bg-neutral-900 text-neutral-500">
<div className="text-center space-y-2">
<p>Chordal debug</p>
<p className="text-xs">No hulls available run the pipeline first (Source Kernel Hull).</p>
</div>
</div>
)
}
return (
<div ref={containerRef} className="absolute inset-0 bg-neutral-900 flex">
{/* Sidebar */}
<div className="w-64 shrink-0 border-r border-neutral-800 p-3 overflow-y-auto text-xs text-neutral-300 space-y-3">
<div>
<label className="block text-neutral-500 mb-1">Hull (largest first)</label>
<select
value={hullIdx}
onChange={e => 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) => (
<option key={h.index} value={h.index}>
#{h.index} · {h.area}px · {h.bounds[2] - h.bounds[0]}×{h.bounds[3] - h.bounds[1]}
{i === 0 ? ' (largest)' : ''}
</option>
))}
</select>
</div>
<div>
<label className="block text-neutral-500 mb-1">
Salience: {salience.toFixed(1)}
</label>
<input type="range" min={0} max={4} step={0.1}
value={salience}
onChange={e => setSalience(parseFloat(e.target.value))}
className="w-full" />
</div>
<div>
<label className="block text-neutral-500 mb-1">
Source opacity: {(sourceOpacity * 100).toFixed(0)}%
</label>
<input type="range" min={0} max={1} step={0.05}
value={sourceOpacity}
onChange={e => setSourceOpacity(parseFloat(e.target.value))}
className="w-full" />
</div>
<div>
<div className="text-neutral-500 mb-1">Layers</div>
<div className="space-y-1">
{LAYERS.map(l => (
<label key={l.key} className="flex items-center gap-2 cursor-pointer">
<input type="checkbox"
checked={!!enabled[l.key]}
onChange={() => toggleLayer(l.key)} />
<span>{l.label}</span>
</label>
))}
</div>
</div>
<div className="pt-2 border-t border-neutral-800 text-neutral-500 leading-relaxed">
<p>Zoom: wheel · Pan: drag</p>
<p>Reset:
<button onClick={() => setView({ zoom: 1, panX: 0, panY: 0 })}
className="ml-2 px-2 py-0.5 bg-neutral-800 rounded">Fit</button>
</p>
<div className="mt-2 space-y-0.5">
<div>· {debug.outer.length} outer verts</div>
<div>· {debug.holes.length} holes ({debug.hole_pixels.reduce((s, h) => s + h.length, 0)} px)</div>
<div>· {debug.triangles.length} triangles
({debug.triangles.filter(t => t.kind !== 'outside').length} interior)</div>
<div>· {debug.segments.length} CAT segments</div>
<div>· {debug.polylines.length} polylines
({debug.polylines.filter(p => p.branch).length} branches,
{' '}{debug.polylines.filter(p => !p.kept).length} pruned)</div>
<div>· {debug.strokes.length} final strokes</div>
<div>· source_b64: {debug.source_b64
? `${(debug.source_b64.length / 1024).toFixed(1)} KB`
: 'MISSING (rebuild Rust)'}</div>
</div>
{hover && (
<div className="mt-2 font-mono">
({hover.x.toFixed(2)}, {hover.y.toFixed(2)})
</div>
)}
</div>
</div>
{/* Canvas */}
<div className="flex-1 relative overflow-hidden" onWheel={onWheel} onMouseDown={onMouseDown}>
<svg
ref={svgRef}
width="100%" height="100%"
viewBox={viewBox}
preserveAspectRatio="xMidYMid meet"
onMouseMove={onMouseMoveSvg}
style={{ cursor: 'grab', background: '#0f0f10' }}>
{/* 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 && (
<image
href={debug.source_b64}
xlinkHref={debug.source_b64}
x={debug.bounds[0]}
y={debug.bounds[1]}
width={debug.bounds[2] - debug.bounds[0] + 1}
height={debug.bounds[3] - debug.bounds[1] + 1}
opacity={sourceOpacity}
style={{ imageRendering: 'pixelated' }}
preserveAspectRatio="none"
/>
)}
{/* Hole pixel cells (raster underlay) */}
{enabled.holePixels && debug.hole_pixels.map((hp, hi) => (
<g key={`hp${hi}`} fill="rgba(56, 189, 248, 0.18)" stroke="none">
{hp.map(([x, y], i) => (
<rect key={i} x={x} y={y} width={1} height={1} />
))}
</g>
))}
{/* 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 (
<g key={`t${i}`}>
<polygon
points={t.points.map(p => `${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 (
<line key={ei} x1={a[0]} y1={a[1]} x2={b[0]} y2={b[1]}
stroke="rgba(255, 255, 255, 0.95)" strokeWidth={1.5}
vectorEffect="non-scaling-stroke" />
)
})}
</g>
)
})}
{/* Outer polygon (highlighted on top) */}
{enabled.outer && (
<polygon
points={debug.outer.map(p => `${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) => (
<polygon key={`h${i}`}
points={h.map(p => `${p[0]},${p[1]}`).join(' ')}
fill="none" stroke="#fbbf24" strokeWidth={1.0}
vectorEffect="non-scaling-stroke" />
))}
{/* Polygon vertices */}
{enabled.vertices && (
<g fill="#34d399">
{debug.outer.map((p, i) => (
<circle key={`ov${i}`} cx={p[0]} cy={p[1]} r={0.5}
vectorEffect="non-scaling-stroke" />
))}
{debug.holes.flatMap((h, hi) => h.map((p, i) => (
<circle key={`hv${hi}-${i}`} cx={p[0]} cy={p[1]} r={0.5}
fill="#fbbf24" vectorEffect="non-scaling-stroke" />
)))}
</g>
)}
{/* Raw CAT segments (before walking) */}
{enabled.segments && debug.segments.map(([a, b], i) => (
<line key={`s${i}`} x1={a[0]} y1={a[1]} x2={b[0]} y2={b[1]}
stroke="rgba(244, 63, 94, 0.7)" strokeWidth={0.6}
vectorEffect="non-scaling-stroke" />
))}
{/* Raw polylines (before smoothing) */}
{enabled.polylines && debug.polylines.map((pl, i) => (
<polyline key={`pl${i}`}
points={pl.points.map(p => `${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) => (
<polyline key={`pr${i}`}
points={pl.points.map(p => `${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) => (
<polyline key={`st${i}`}
points={s.map(p => `${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 && (
<rect
x={Math.min(selBox.x0, selBox.x1)}
y={Math.min(selBox.y0, selBox.y1)}
width={Math.abs(selBox.x1 - selBox.x0)}
height={Math.abs(selBox.y1 - selBox.y0)}
fill="rgba(99, 102, 241, 0.12)"
stroke="#818cf8"
strokeWidth={0.5}
strokeDasharray="2 1"
vectorEffect="non-scaling-stroke"
/>
)}
</svg>
{/* Shift+drag hint, bottom-left */}
<div className="absolute bottom-2 left-3 text-[10px] text-neutral-500 pointer-events-none">
Shift+drag to copy region data to clipboard
</div>
{/* Toast notification */}
{toast && (
<div className="absolute top-3 left-1/2 -translate-x-1/2 px-3 py-1.5 rounded
bg-neutral-800 border border-indigo-500/60 text-xs text-neutral-100 shadow-lg
pointer-events-none">
{toast}
</div>
)}
</div>
</div>
)
}

View File

@@ -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 (
<span className={`px-2 py-0.5 text-xs rounded font-medium uppercase tracking-wider ${c}`}>
{state || '—'}
</span>
)
}
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 (
<button
onClick={onClick}
disabled={disabled}
title={title}
className={`px-3 py-1.5 rounded text-xs ${variants[variant]} disabled:opacity-40 disabled:cursor-not-allowed transition-colors ${className}`}
>
{children}
</button>
)
}
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 (
<div className="h-full overflow-y-auto p-4 space-y-4 bg-neutral-950 text-neutral-300 text-sm">
{/* Connection */}
<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} />
<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' && (
<p className="text-[11px] text-neutral-400 font-mono mt-1.5">
MPos&nbsp;
X <span className="text-neutral-200">{(status.mpos[0] ?? 0).toFixed(2)}</span>{' '}
Y <span className="text-neutral-200">{(status.mpos[1] ?? 0).toFixed(2)}</span>{' '}
Z <span className="text-neutral-200">{(status.mpos[2] ?? 0).toFixed(2)}</span>
{status.feed > 0 && <span className="text-neutral-500 ml-2">F {status.feed.toFixed(0)}</span>}
</p>
)}
{msg && <p className="text-xs text-neutral-500 mt-1">{msg}</p>}
</section>
{/* Realtime control — always available */}
<section>
<p className="text-neutral-500 text-xs font-semibold uppercase tracking-wider mb-2">Control</p>
<div className="grid grid-cols-3 gap-1">
<Btn onClick={onHold} variant="warn" disabled={busy}>Hold (!)</Btn>
<Btn onClick={onResume} variant="good" disabled={busy}>Resume (~)</Btn>
<Btn onClick={onUnlock} disabled={busy}>Unlock ($X)</Btn>
</div>
<div className="grid grid-cols-2 gap-1 mt-1">
<Btn onClick={onReset} variant="warn" disabled={busy}
title="Sends rt-reset event (Ctrl-X equiv). Clears most alarms.">
Soft reset
</Btn>
<Btn onClick={onHardReset} variant="danger" disabled={busy}
title="[ESP444]RESTART — full controller reboot. Use to escape ConfigAlarm. ~3-5s unreachable.">
Hard reset (reboot)
</Btn>
</div>
</section>
{/* Jog — Idle only */}
<section>
<div className="flex items-center justify-between mb-2">
<p className="text-neutral-500 text-xs font-semibold uppercase tracking-wider">Jog</p>
<div className="flex gap-1">
{JOG_STEPS.map(s => (
<button key={s} onClick={() => 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
</button>
))}
</div>
</div>
<div className="grid grid-cols-3 gap-1 max-w-xs">
<div />
<Btn onClick={jog('Y', 1)} disabled={!idle || busy}>Y+</Btn>
<div />
<Btn onClick={jog('X', -1)} disabled={!idle || busy}>X-</Btn>
<Btn onClick={onHome} disabled={!idle || busy} variant="primary" title="$H — home all">Home</Btn>
<Btn onClick={jog('X', 1)} disabled={!idle || busy}>X+</Btn>
<div />
<Btn onClick={jog('Y', -1)} disabled={!idle || busy}>Y-</Btn>
<div />
</div>
<div className="grid grid-cols-2 gap-1 max-w-xs mt-1">
<Btn onClick={jog('Z', 1)} disabled={!idle || busy}>Z+ (pen up)</Btn>
<Btn onClick={jog('Z', -1)} disabled={!idle || busy}>Z- (pen down)</Btn>
</div>
</section>
{/* Print */}
<section>
<p className="text-neutral-500 text-xs font-semibold uppercase tracking-wider mb-2">Print</p>
<div className="space-y-1.5">
<Btn onClick={onUpAndRun} disabled={!hasStrokes || !idle || busy} variant="primary" className="w-full">
Send current project &amp; run
</Btn>
<div className="grid grid-cols-2 gap-1">
<Btn onClick={onUpload} disabled={!hasStrokes || !idle || busy}>Upload only</Btn>
<Btn onClick={refreshFiles} disabled={busy || !idle}>List SD files</Btn>
</div>
{upload && (
<div className="space-y-0.5">
<div className="flex justify-between text-[10px] text-neutral-500 font-mono">
<span className="truncate">{upload.file}</span>
<span>{(upload.sent/1024).toFixed(1)} / {(upload.total/1024).toFixed(1)} KB</span>
</div>
<div className="h-1.5 bg-neutral-800 rounded overflow-hidden">
<div className="h-full bg-indigo-500 transition-all duration-100"
style={{ width: `${Math.min(100, upload.total ? (upload.sent / upload.total * 100) : 0).toFixed(1)}%` }} />
</div>
</div>
)}
{sdFiles.length > 0 && (
<div className="border border-neutral-800 rounded max-h-40 overflow-y-auto">
{sdFiles.map(f => (
<label key={f.name}
className={`flex items-center gap-2 px-2 py-1 text-xs cursor-pointer hover:bg-neutral-800 ${selected === f.name ? 'bg-neutral-800' : ''}`}>
<input type="radio" name="sdfile" checked={selected === f.name}
onChange={() => setSelected(f.name)} className="accent-indigo-600" />
<span className="flex-1 truncate">{f.name}</span>
<span className="text-neutral-500">{(f.size / 1024).toFixed(1)} KB</span>
</label>
))}
</div>
)}
{selected && (
<Btn onClick={onRunSel} disabled={busy || !idle} variant="good" className="w-full">
Run {selected}
</Btn>
)}
</div>
</section>
<section className="text-xs text-neutral-600 pt-2 border-t border-neutral-800">
<p>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.</p>
</section>
</div>
)
}

View File

@@ -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: <svg> rendering the actual Hershey strokes (what will plot)
// 2. editable: a transparent <textarea> overlaid for caret + key handling
// 3. chrome: header (drag-to-move) and SE handle (drag-to-scale font)
//
// Background pan/zoom: drag empty space to pan, mouse wheel zooms to cursor,
// double-click on paper adds a new block.
const HEADER_H_PX = 18
export default function TextEditOverlay({
paperWMm, paperHMm,
blocks, setBlocks,
}) {
const containerRef = useRef(null)
const [layout, setLayout] = useState({ scale: 1, paperX: 0, paperY: 0, paperW: 0, paperH: 0 })
const [view, setView] = useState({ zoom: 1, panX: 0, panY: 0 })
const [selectedIdx, setSelectedIdx] = useState(null)
const panDragRef = useRef(null)
useLayoutEffect(() => {
const el = containerRef.current
if (!el) return
const update = () => {
const rect = el.getBoundingClientRect()
const pad = 24
const cw = Math.max(1, rect.width - pad * 2)
const ch = Math.max(1, rect.height - pad * 2)
const scale = Math.min(cw / paperWMm, ch / paperHMm)
const paperW = paperWMm * scale
const paperH = paperHMm * scale
const paperX = (rect.width - paperW) / 2
const paperY = (rect.height - paperH) / 2
setLayout({ scale, paperX, paperY, paperW, paperH })
}
update()
const ro = new ResizeObserver(update)
ro.observe(el)
return () => ro.disconnect()
}, [paperWMm, paperHMm])
// mm → screen-px (zoom-aware). Used by TextBox for positioning and sizing.
const mmToPx = (mm) => mm * layout.scale * view.zoom
const pxToMm = (px) => px / (layout.scale * view.zoom)
// Convert a clientXY (mouse event) to mm coordinates on the paper.
const clientToMm = (clientX, clientY) => {
const rect = containerRef.current.getBoundingClientRect()
const sx = clientX - rect.left - layout.paperX - view.panX
const sy = clientY - rect.top - layout.paperY - view.panY
return { x: sx / (layout.scale * view.zoom), y: sy / (layout.scale * view.zoom) }
}
const onPanMouseDown = (e) => {
if (e.target !== e.currentTarget) return
if (e.button !== 0) return
panDragRef.current = {
startX: e.clientX, startY: e.clientY,
origPanX: view.panX, origPanY: view.panY,
moved: false,
}
const onMove = (ev) => {
const s = panDragRef.current
if (!s) return
const dx = ev.clientX - s.startX
const dy = ev.clientY - s.startY
if (!s.moved && (Math.abs(dx) > 3 || Math.abs(dy) > 3)) s.moved = true
setView(v => ({ ...v, panX: s.origPanX + dx, panY: s.origPanY + dy }))
}
const onUp = (ev) => {
const s = panDragRef.current
panDragRef.current = null
document.removeEventListener('mousemove', onMove)
document.removeEventListener('mouseup', onUp)
// Click without drag on background → deselect any selected box.
if (s && !s.moved) setSelectedIdx(null)
}
document.addEventListener('mousemove', onMove)
document.addEventListener('mouseup', onUp)
}
const onWheel = (e) => {
e.preventDefault()
const rect = containerRef.current.getBoundingClientRect()
const cx = e.clientX - rect.left - layout.paperX
const cy = e.clientY - rect.top - layout.paperY
const factor = e.deltaY < 0 ? 1.1 : 1 / 1.1
setView(v => {
const nz = Math.max(0.1, Math.min(20, v.zoom * factor))
const r = nz / v.zoom
return { zoom: nz, panX: v.panX * r + cx * (1 - r), panY: v.panY * r + cy * (1 - r) }
})
}
const onPaperDoubleClick = (e) => {
if (e.target !== e.currentTarget) return
const { x, y } = clientToMm(e.clientX, e.clientY)
const newBlock = {
text: 'New text',
font_size_mm: 5, line_spacing_mm: 8,
x_mm: Math.max(0, x), y_mm: Math.max(0, y),
}
setBlocks(bs => [...bs, newBlock])
setSelectedIdx(blocks.length)
}
return (
<div ref={containerRef}
onMouseDown={onPanMouseDown}
onWheel={onWheel}
className="absolute inset-0 bg-neutral-900 select-none overflow-hidden">
<div className="absolute bg-neutral-100 shadow-lg"
onMouseDown={onPanMouseDown}
onDoubleClick={onPaperDoubleClick}
style={{
left: layout.paperX + view.panX,
top: layout.paperY + view.panY,
width: layout.paperW * view.zoom,
height: layout.paperH * view.zoom,
}}>
{blocks.map((b, i) => (
<TextBox key={i}
block={b}
selected={selectedIdx === i}
onSelect={() => 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}
/>
))}
</div>
<p className="absolute bottom-2 left-3 text-[10px] text-neutral-500 pointer-events-none">
Drag to pan Wheel to zoom Double-click paper to add a text box Drag header to move Drag SE corner to scale font
</p>
<p className="absolute top-2 right-3 text-[10px] text-neutral-500 pointer-events-none">
Paper {paperWMm.toFixed(0)}×{paperHMm.toFixed(0)}mm · {(view.zoom * 100).toFixed(0)}%
</p>
</div>
)
}
// ── 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 (
<div className={`absolute ${selected ? 'ring-2 ring-indigo-500' : 'ring-1 ring-neutral-400/40'}`}
style={{ left, top, width, height: height + HEADER_H_PX }}
onMouseDown={e => { e.stopPropagation(); onSelect() }}>
{/* Header — draggable */}
<div onMouseDown={onHeaderMouseDown}
className={`absolute top-0 left-0 right-0 flex items-center justify-between px-1 cursor-move
${selected ? 'bg-indigo-600 text-white' : 'bg-neutral-700/60 text-neutral-100'}`}
style={{ height: HEADER_H_PX, fontSize: 10 }}>
<span className="font-mono">
{block.x_mm.toFixed(0)},{block.y_mm.toFixed(0)} · {block.font_size_mm.toFixed(1)}mm
</span>
{selected && (
<button onClick={e => { e.stopPropagation(); onDelete() }}
className="text-white hover:text-red-300 text-xs leading-none px-1">×</button>
)}
</div>
{/* Body: SVG (visual) + textarea (caret) stacked at same coords. */}
<div className="absolute left-0 right-0" style={{ top: HEADER_H_PX, height }}>
{/* WYSIWYG Hershey rendering */}
<svg viewBox={`0 0 ${widthMm} ${heightMm}`}
width={width} height={height}
preserveAspectRatio="none"
className="absolute inset-0 pointer-events-none">
{strokes.map((pts, i) => (
<polyline key={i}
points={pts.map(([x, y]) => `${x},${y}`).join(' ')}
fill="none" stroke="black" strokeWidth={strokePx / mmToPx(1)}
strokeLinecap="round" strokeLinejoin="round" />
))}
</svg>
{/* 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. */}
<textarea ref={taRef}
value={block.text}
onChange={e => onChange({ text: e.target.value })}
onMouseDown={e => e.stopPropagation()}
spellCheck={false}
className="absolute inset-0 w-full bg-transparent border-none outline-none p-0 m-0
resize-none font-mono leading-tight"
style={{
color: 'transparent',
caretColor: selected ? '#4f46e5' : '#9ca3af',
fontSize: fontPx * 0.75, // visual approximation only
lineHeight: `${mmToPx(block.line_spacing_mm ?? block.font_size_mm * 1.6)}px`,
paddingTop: `${mmToPx(block.font_size_mm) * 0.15}px`,
}}
/>
</div>
{selected && (
<>
<div onMouseDown={onResizeMouseDown}
className="absolute -right-1 -bottom-1 w-3 h-3 bg-indigo-500 cursor-se-resize border border-white"
style={{ borderRadius: 1 }}
/>
{/* Floating format toolbar — pops above the box when selected. */}
<div onMouseDown={e => e.stopPropagation()}
className="absolute left-0 -top-7 flex items-center gap-2 bg-neutral-900/95 border border-neutral-700 rounded px-2 py-1 text-[10px] text-neutral-300 whitespace-nowrap shadow-lg">
<label className="flex items-center gap-1">
Line:
<input type="number" step={0.5} min={2} max={60}
value={(block.line_spacing_mm ?? block.font_size_mm * 1.6).toFixed(1)}
onChange={e => onChange({ line_spacing_mm: parseFloat(e.target.value) || (block.font_size_mm * 1.6) })}
className="w-12 px-1 py-0.5 rounded bg-neutral-800 border border-neutral-700 text-[10px] text-right" />
mm
</label>
<label className="flex items-center gap-1">
Font:
<input type="number" step={0.5} min={1} max={50}
value={block.font_size_mm.toFixed(1)}
onChange={e => onChange({ font_size_mm: Math.max(1, parseFloat(e.target.value) || block.font_size_mm) })}
className="w-12 px-1 py-0.5 rounded bg-neutral-800 border border-neutral-700 text-[10px] text-right" />
mm
</label>
</div>
</>
)}
</div>
)
}

View File

@@ -0,0 +1,153 @@
import { useEffect, useRef, useState } from 'react'
import * as tauri from '../hooks/useTauri.js'
// Each entry: { name (FluidNC $/path without the leading $/), label, group,
// min, max, step, unit, fmt }. fmt formats the JS number on
// write so FluidNC parses it cleanly (avoids 1e3 etc.).
const TUNABLES = [
// Motion
{ name: 'axes/x/max_rate_mm_per_min', label: 'X max rate', group: 'Motion', min: 100, max: 20000, step: 100, unit: 'mm/m', fmt: 'int' },
{ name: 'axes/y/max_rate_mm_per_min', label: 'Y max rate', group: 'Motion', min: 100, max: 20000, step: 100, unit: 'mm/m', fmt: 'int' },
{ name: 'axes/x/acceleration_mm_per_sec2', label: 'X acceleration', group: 'Motion', min: 10, max: 5000, step: 10, unit: 'mm/s²', fmt: 'int' },
{ name: 'axes/y/acceleration_mm_per_sec2', label: 'Y acceleration', group: 'Motion', min: 10, max: 5000, step: 10, unit: 'mm/s²', fmt: 'int' },
{ name: 'junction_deviation_mm', label: 'Junction dev', group: 'Motion', min: 0.01, max: 1.0, step: 0.01, unit: 'mm', fmt: 'fix2',
help: 'Max distance the path can deviate from the exact corner. Higher = faster cornering (carriage carries more speed through angle changes), lower = sharper corners (decelerates more at each junction). For pen plotters with hundreds of short segments this dominates total drawing time. 0.01 = very precise/slow, 0.05 = balanced (recommended), 0.1+ = visibly rounded but fast.' },
{ name: 'stepping/idle_ms', label: 'Idle ms (255=on)', group: 'Motion', min: 0, max: 255, step: 5, unit: 'ms', fmt: 'int' },
// Pen / Z servo
{ name: 'axes/z/motor0/rc_servo/min_pulse_us', label: 'Servo min pulse', group: 'Pen', min: 500, max: 2500, step: 10, unit: 'µs', fmt: 'int' },
{ name: 'axes/z/motor0/rc_servo/max_pulse_us', label: 'Servo max pulse', group: 'Pen', min: 500, max: 2500, step: 10, unit: 'µs', fmt: 'int' },
// TMC currents
{ name: 'axes/x/motor0/tmc_2209/run_amps', label: 'X run amps', group: 'Driver', min: 0.1, max: 1.7, step: 0.05, unit: 'A', fmt: 'fix2' },
{ name: 'axes/y/motor0/tmc_2209/run_amps', label: 'Y run amps', group: 'Driver', min: 0.1, max: 1.7, step: 0.05, unit: 'A', fmt: 'fix2' },
{ name: 'axes/x/motor0/tmc_2209/hold_amps', label: 'X hold amps', group: 'Driver', min: 0.0, max: 1.5, step: 0.05, unit: 'A', fmt: 'fix2' },
{ name: 'axes/y/motor0/tmc_2209/hold_amps', label: 'Y hold amps', group: 'Driver', min: 0.0, max: 1.5, step: 0.05, unit: 'A', fmt: 'fix2' },
]
const formatValue = (v, fmt) => fmt === 'int' ? String(Math.round(v)) : Number(v).toFixed(2)
const groupOrder = ['Motion', 'Pen', 'Driver']
// React-rendered hover tooltip — Tauri's WKWebView doesn't show native
// title attribute tooltips reliably, so we draw our own.
function HelpTip({ children }) {
const [show, setShow] = useState(false)
return (
<span className="relative inline-flex shrink-0"
onMouseEnter={() => setShow(true)}
onMouseLeave={() => setShow(false)}>
<span className="text-neutral-600 cursor-help text-[10px] leading-none border border-neutral-700 rounded-full w-3.5 h-3.5 inline-flex items-center justify-center hover:text-neutral-300 hover:border-neutral-500">?</span>
{show && (
<span className="absolute left-full ml-2 top-0 z-50 w-72 p-2 bg-neutral-800 border border-neutral-600 rounded text-[11px] leading-snug text-neutral-200 shadow-lg pointer-events-none whitespace-normal">
{children}
</span>
)}
</span>
)
}
export default function TuningPanel({ printerUrl }) {
const [values, setValues] = useState({}) // name → number
const [pending, setPending] = useState({}) // name → in-flight value
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('')
const [err, setErr] = useState({}) // name → last error
const debounceRef = useRef({}) // name → timeoutId
// ── Pull-all on mount + when URL changes ───────────────────────────────────
const pullAll = async () => {
if (!printerUrl) return
setBusy(true); setMsg('Reading current values…')
const next = {}, errs = {}
for (const t of TUNABLES) {
try {
const raw = await tauri.printerGetSetting(printerUrl, t.name)
const num = parseFloat(raw)
if (!Number.isNaN(num)) next[t.name] = num
} catch (e) {
errs[t.name] = String(e)
}
}
setValues(next)
setErr(errs)
setBusy(false)
const okCount = Object.keys(next).length
setMsg(`Read ${okCount}/${TUNABLES.length} settings`)
}
useEffect(() => { pullAll() /* eslint-disable-line react-hooks/exhaustive-deps */ }, [printerUrl])
// ── Slider change → debounced write ────────────────────────────────────────
const onChange = (t, v) => {
setValues(prev => ({ ...prev, [t.name]: v }))
setPending(prev => ({ ...prev, [t.name]: v }))
clearTimeout(debounceRef.current[t.name])
debounceRef.current[t.name] = setTimeout(async () => {
try {
await tauri.printerSetSetting(printerUrl, t.name, formatValue(v, t.fmt))
setErr(prev => { const c = { ...prev }; delete c[t.name]; return c })
setMsg(`Set ${t.label} = ${formatValue(v, t.fmt)} ${t.unit}`)
} catch (e) {
setErr(prev => ({ ...prev, [t.name]: String(e) }))
setMsg(`Failed: ${t.label}${e}`)
} finally {
setPending(prev => { const c = { ...prev }; delete c[t.name]; return c })
}
}, 350)
}
const grouped = groupOrder.map(g => ({
group: g,
items: TUNABLES.filter(t => t.group === g),
}))
return (
<div className="h-full overflow-y-auto p-4 space-y-4 bg-neutral-950 text-neutral-300 text-sm">
<section>
<div className="flex items-center gap-3 mb-2">
<p className="text-neutral-500 text-xs font-semibold uppercase tracking-wider">Live tuning</p>
<div className="flex-1" />
<button onClick={pullAll} disabled={busy}
className="px-2 py-1 rounded bg-neutral-800 hover:bg-neutral-700 border border-neutral-700 text-xs disabled:opacity-40">
Pull current
</button>
</div>
{!printerUrl && (
<p className="text-amber-500 text-xs">Set Printer URL in the Printer tab first.</p>
)}
{msg && <p className="text-xs text-neutral-500">{msg}</p>}
<p className="text-xs text-neutral-600 mt-1">
Changes apply <span className="text-emerald-500">live</span> they're stored in RAM and reset on reboot.
To persist, edit config.yaml. (Save flow coming.)
</p>
</section>
{grouped.map(({ group, items }) => (
<section key={group}>
<p className="text-neutral-500 text-xs font-semibold uppercase tracking-wider mb-2">{group}</p>
<div className="space-y-2">
{items.map(t => {
const v = values[t.name]
const has = v !== undefined
const e = err[t.name]
const inFlight = pending[t.name] !== undefined
return (
<div key={t.name} className="grid grid-cols-[10rem_1fr_5rem] gap-2 items-center">
<label className="text-xs text-neutral-400 truncate flex items-center gap-1">
{t.label}
{t.help && <HelpTip>{t.help}</HelpTip>}
</label>
<input type="range" min={t.min} max={t.max} step={t.step}
value={has ? v : t.min}
disabled={!has || !printerUrl}
onChange={ev => onChange(t, parseFloat(ev.target.value))}
className="w-full accent-indigo-600 disabled:opacity-30" />
<span className={`text-xs font-mono tabular-nums text-right ${inFlight ? 'text-amber-500' : e ? 'text-red-500' : 'text-neutral-300'}`}>
{has ? `${formatValue(v, t.fmt)} ${t.unit}` : (e ? '' : '')}
</span>
</div>
)
})}
</div>
</section>
))}
</div>
)
}

View File

@@ -175,7 +175,27 @@ export default function Viewport({ imageB64, strokes, imgSize, viewMode, gcodeCo
const end = Math.min(idx + CHUNK_SIZE, flat.length)
// Batch consecutive same-color strokes into one path per color run
if (viewMode === 'gcode') {
// Debug view: every stroke gets its own hue (golden-ratio cycle for
// maximal visual separation). One beginPath per stroke since each has
// a unique strokeStyle.
octx.lineWidth = 1.5
octx.lineCap = 'round'
for (let i = idx; i < end; i++) {
const pts = flat[i].points
if (pts.length < 2) continue
const hue = (i * 137.508) % 360
octx.strokeStyle = `hsl(${hue.toFixed(1)}, 80%, 50%)`
octx.beginPath()
octx.moveTo(pts[0][0], pts[0][1])
for (let k = 1; k < pts.length; k++) {
octx.lineTo(pts[k][0], pts[k][1])
}
octx.stroke()
}
} else {
// Fill view: pen-color batching (consecutive same-color strokes
// share one beginPath for performance).
let i = idx
while (i < end) {
const [r, g, b] = flat[i].color
@@ -200,6 +220,7 @@ export default function Viewport({ imageB64, strokes, imgSize, viewMode, gcodeCo
octx.stroke()
i = j
}
}
state.idx = end
draw() // refresh main canvas with current offscreen state

View File

@@ -22,6 +22,14 @@ export async function processPass(payload) {
return tracedInvoke('process_pass', { payload })
}
export async function listHulls(passIdx = 0) {
return tracedInvoke('list_hulls', { passIdx })
}
export async function getChordalDebug(passIdx, hullIdx, salience = 0) {
return tracedInvoke('get_chordal_debug', { passIdx, hullIdx, salience })
}
export async function getAllStrokes() {
return tracedInvoke('get_all_strokes', {})
}
@@ -46,6 +54,89 @@ export async function uploadToPrinter(gcodeConfig, printerUrl) {
return tracedInvoke('upload_to_printer', { gcodeConfig, printerUrl })
}
export async function uploadAndRun(gcodeConfig, printerUrl) {
return tracedInvoke('upload_and_run', { gcodeConfig, printerUrl })
}
export async function printerStatus(printerUrl) {
return tracedInvoke('printer_status', { printerUrl })
}
export async function printerCommand(printerUrl, command) {
return tracedInvoke('printer_command', { printerUrl, command })
}
export async function printerSoftReset(printerUrl) {
return tracedInvoke('printer_soft_reset', { printerUrl })
}
export async function printerHardReset(printerUrl) {
return tracedInvoke('printer_hard_reset', { printerUrl })
}
export async function printerFeedHold(printerUrl) {
return tracedInvoke('printer_feed_hold', { printerUrl })
}
export async function printerCycleStart(printerUrl) {
return tracedInvoke('printer_cycle_start', { printerUrl })
}
export async function printerListSd(printerUrl) {
return tracedInvoke('printer_list_sd', { printerUrl })
}
export async function printerRunSdFile(printerUrl, filename) {
return tracedInvoke('printer_run_sd_file', { printerUrl, filename })
}
export async function printerGetSetting(printerUrl, name) {
return tracedInvoke('printer_get_setting', { printerUrl, name })
}
export async function printerSetSetting(printerUrl, name, value) {
return tracedInvoke('printer_set_setting', { printerUrl, name, value })
}
// Motion commands ($H, $J=…) go via WebSocket to avoid the synchronousCommand
// path that holds an HTTP connection open during motion (FluidNC bug #1295).
export async function printerHomeWs(printerUrl) {
return tracedInvoke('printer_home_ws', { printerUrl })
}
export async function printerJogWs(printerUrl, command) {
return tracedInvoke('printer_jog_ws', { printerUrl, command })
}
export async function printerRunGcodeWs(printerUrl, gcode) {
return tracedInvoke('printer_run_gcode_ws', { printerUrl, gcode })
}
// One-shot status read via WebSocket — returns full PrinterStatus including MPos.
// Use sparingly (each call opens & closes a connection, ~0.3-1s).
export async function printerStatusWs(printerUrl) {
return tracedInvoke('printer_status_ws', { printerUrl })
}
// ── Text source ────────────────────────────────────────────────────────────────
// Rasterises the provided blocks into a paper-sized image and sets it as
// the project source. Returns `ImageInfo` (same shape as `load_image`) so
// the frontend treats it like a fresh image load. `blocks` is an array of
// `{ text, font_size_mm, line_spacing_mm, x_mm, y_mm }`.
export async function setTextBlocks(blocks, paperWMm, paperHMm, dpi, strokeThicknessPx = 4) {
return tracedInvoke('set_text_blocks', {
blocks: blocks.map(b => ({
text: b.text,
font_size_mm: b.font_size_mm,
line_spacing_mm: b.line_spacing_mm ?? null,
x_mm: b.x_mm,
y_mm: b.y_mm,
})),
paperWMm, paperHMm, dpi,
strokeThicknessPx,
})
}
export async function exportDebugState(passConfigs) {
return tracedInvoke('export_debug_state', { passConfigs })
}

View File

@@ -0,0 +1,110 @@
// JS port of the Hershey-font renderer that lives in `src/text.rs`.
// Lets the in-app text editor render WYSIWYG strokes without round-tripping
// through Tauri on every keystroke. Format details mirror the Rust side —
// see src/text.rs for the spec walk-through.
//
// We import the JHF data as a raw string via Vite's ?raw suffix; the file
// lives under /resources so the same bytes Rust embeds are also delivered
// to the front-end build.
import jhfRaw from '../../../resources/futural.jhf?raw'
const CAP_HEIGHT = 14 // Hershey cap-height in font units; font_size_mm == cap height
function emptyGlyph() { return { left: -8, right: 8, strokes: [] } }
function parseJhf(text) {
const glyphs = []
for (const line of text.split('\n')) {
if (line.length < 8) { glyphs.push(emptyGlyph()); continue }
const nv = parseInt(line.slice(5, 8).trim(), 10) || 0
const body = line.slice(8)
if (body.length < 2 || nv === 0) { glyphs.push(emptyGlyph()); continue }
const left = body.charCodeAt(0) - 82 // 'R' = 82
const right = body.charCodeAt(1) - 82
const strokes = []
let current = []
let i = 2
const pairsRemaining = Math.max(0, nv - 1)
for (let p = 0; p < pairsRemaining; p++) {
if (i + 1 >= body.length) break
const a = body.charCodeAt(i)
const b = body.charCodeAt(i + 1)
i += 2
if (a === 32 && b === 82) {
// " R" pen-up sentinel — close current stroke, open a new one.
if (current.length >= 2) strokes.push(current)
current = []
continue
}
current.push([a - 82, b - 82])
}
if (current.length >= 2) strokes.push(current)
glyphs.push({ left, right, strokes })
}
while (glyphs.length < 96) glyphs.push(emptyGlyph())
return glyphs
}
let GLYPHS_CACHE = null
function glyphs() {
if (!GLYPHS_CACHE) GLYPHS_CACHE = parseJhf(jhfRaw)
return GLYPHS_CACHE
}
function glyphFor(ch) {
const g = glyphs()
const idx = ch.charCodeAt(0) - 32
return (idx >= 0 && idx < g.length) ? g[idx] : g[0]
}
/**
* Render text into an array of strokes, where each stroke is an array of
* `[x_mm, y_mm]` pairs, all in mm. `fontSizeMm` is the cap height of an
* uppercase X. `lineSpacingMm` defaults to 1.6 × font size.
*
* Coordinates start at (0, 0) — the caller can offset as needed.
*/
export function renderText(text, fontSizeMm, lineSpacingMm = null) {
const mmPerUnit = fontSizeMm / CAP_HEIGHT
const lineH = lineSpacingMm ?? fontSizeMm * 1.6
const out = []
const lines = (text ?? '').split('\n')
for (let li = 0; li < lines.length; li++) {
const baselineY = lineH * (li + 1)
let cursorX = 0
for (const ch of lines[li]) {
const g = glyphFor(ch)
const penX = cursorX - g.left * mmPerUnit
for (const stroke of g.strokes) {
const pts = stroke.map(([gx, gy]) => [
penX + gx * mmPerUnit,
baselineY + gy * mmPerUnit,
])
if (pts.length >= 2) out.push(pts)
}
cursorX += (g.right - g.left) * mmPerUnit
}
}
return out
}
/** Compute the bounding box of rendered text without producing strokes. */
export function measureText(text, fontSizeMm, lineSpacingMm = null) {
const mmPerUnit = fontSizeMm / CAP_HEIGHT
const lineH = lineSpacingMm ?? fontSizeMm * 1.6
const lines = (text ?? '').split('\n')
let maxW = 0
for (const line of lines) {
let w = 0
for (const ch of line) {
const g = glyphFor(ch)
w += (g.right - g.left) * mmPerUnit
}
maxW = Math.max(maxW, w)
}
// Hershey glyphs descend ~7u below baseline and rise ~9u above. Use 1.6×
// font size as a generous height per line so the SVG never clips.
const linesCount = Math.max(1, lines.length)
return { width: maxW, height: linesCount * lineH + fontSizeMm * 0.5 }
}

View File

@@ -0,0 +1,47 @@
// Cross-language parity test: verifies the JS Hershey renderer produces
// stroke output identical to the Rust source of truth (`src/text.rs`).
// Both this test and `tests/text_parity.rs` validate against the same
// committed fixture (`tests/text_parity_fixture.json`); if either drifts
// the test fails until they're brought back in sync.
//
// Updating: run `UPDATE_TEXT_FIXTURE=1 cargo test --test text_parity` to
// regenerate the fixture from Rust, then re-run this test to confirm the
// JS port still matches.
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { renderText } from './hershey.js'
const FIXTURE_PATH = resolve(__dirname, '../../../tests/text_parity_fixture.json')
// Acceptable per-coordinate drift in mm. Rust f32 ↔ JS f64 round-trip
// through JSON is exact for the values Hershey produces (small integers
// times a rational scale), so we use a tight epsilon.
const EPSILON_MM = 1e-4
describe('Hershey JS↔Rust parity', () => {
const fixture = JSON.parse(readFileSync(FIXTURE_PATH, 'utf8'))
for (const c of fixture) {
const label = JSON.stringify(c.text).slice(0, 40)
it(`text=${label} size=${c.font_size_mm} line=${c.line_spacing_mm}`, () => {
const actual = renderText(c.text, c.font_size_mm, c.line_spacing_mm)
// Stroke count must match — different counts means structurally divergent output.
expect(actual.length, 'stroke count').toBe(c.strokes.length)
for (let i = 0; i < actual.length; i++) {
const aStroke = actual[i]
const eStroke = c.strokes[i]
expect(aStroke.length, `stroke[${i}] point count`).toBe(eStroke.length)
for (let p = 0; p < aStroke.length; p++) {
const [ax, ay] = aStroke[p]
const [ex, ey] = eStroke[p]
expect(Math.abs(ax - ex), `stroke[${i}].point[${p}].x`).toBeLessThan(EPSILON_MM)
expect(Math.abs(ay - ey), `stroke[${i}].point[${p}].y`).toBeLessThan(EPSILON_MM)
}
}
})
}
})

View File

@@ -21,7 +21,7 @@ const MIGRATIONS = [
]
// ── Serialize ──────────────────────────────────────────────────────────────────
export function serialize({ imagePath, dpi, nodeWidth, graph, gcodeConfig }) {
export function serialize({ imagePath, dpi, nodeWidth, graph, gcodeConfig, sourceMode, textBlocks }) {
return JSON.stringify({
version: CURRENT_VERSION,
app: 'trac3r',
@@ -31,6 +31,8 @@ export function serialize({ imagePath, dpi, nodeWidth, graph, gcodeConfig }) {
node_width: nodeWidth,
graph,
gcode: gcodeConfig,
source_mode: sourceMode ?? 'image',
text_blocks: textBlocks ?? [],
}, null, 2)
}
@@ -74,5 +76,7 @@ export function deserialize(json, { migrations: migs = MIGRATIONS, currentVersio
nodeWidth: doc.node_width ?? 450,
graph: doc.graph ?? null,
gcodeConfig: doc.gcode ?? null,
sourceMode: doc.source_mode ?? 'image',
textBlocks: Array.isArray(doc.text_blocks) ? doc.text_blocks : [],
}
}

View File

@@ -219,6 +219,7 @@ describe('deserialize — missing optional fields', () => {
const result = deserialize(minimalDoc)
expect(result).toEqual({
imagePath: null, dpi: 150, nodeWidth: 450, graph: null, gcodeConfig: null,
sourceMode: 'image', textBlocks: [],
})
})
})

View File

@@ -4,7 +4,7 @@
export const KERNELS = ['Luminance','Sobel','ColorGradient','Laplacian','Canny','Saturation','XDoG','ColorIsolate']
export const BLEND_MODES = ['Average','Min','Max','Multiply','Screen','Difference']
export const FILL_STRATEGIES = ['hatch','zigzag','offset','spiral','outline','circles','voronoi','hilbert','waves','flow','gradient_hatch','gradient_cross_hatch']
export const FILL_STRATEGIES = ['hatch','zigzag','offset','spiral','outline','circles','voronoi','hilbert','waves','flow','gradient_hatch','gradient_cross_hatch','skeleton','centerline','chordal']
// Per-strategy secondary parameter exposed as a slider.
// Strategies not listed here have no secondary parameter.
@@ -19,6 +19,8 @@ export const FILL_STRATEGY_PARAMS = {
hint: '1.0 = uniform · 0.05 = 20× denser at darkest ink' },
gradient_cross_hatch: { label: 'Min Scale', min: 0.05, max: 1.0, step: 0.05, default: 0.25,
hint: '1.0 = uniform · 0.05 = 20× denser at darkest ink' },
chordal: { label: 'Prune', min: 0, max: 4, step: 0.1, default: 0,
hint: 'Drop centerline tails shorter than N× local stroke width. Scale-invariant. 1.52.5 removes junction artifacts; 0 keeps everything.' },
}
// Strategies that use the angle slider
@@ -139,17 +141,27 @@ export const PAPER_SIZES = [
{ name: 'A5', w: 148, h: 210 },
{ name: 'Letter', w: 216, h: 279 },
{ name: 'Legal', w: 216, h: 356 },
{ name: '#10 envelope', w: 104.775, h: 241.3 }, // 4⅛ × 9½ in, US business
]
export function defaultGcodeConfig() {
return {
const cfg = {
bed_w_mm: 220, bed_h_mm: 320,
paper_offset_x_mm: 0, paper_offset_y_mm: 0,
paper_w_mm: 210, paper_h_mm: 297,
img_w_mm: 180,
offset_x_mm: 15, offset_y_mm: 15,
feed_draw: 1000, feed_travel: 5000,
pen_down: 'G1 Z0.4 F1000', pen_up_z_mm: 2,
pen_down: 'G1 Z0.4 F1000', pen_up_z_mm: 2, pen_dwell_ms: 250,
printer_url: 'http://fluidnc.local',
}
return { ...cfg, ...centerPaperOnBed(cfg) }
}
// When paper size or bed size changes, place the paper centered in the bed.
// Returns the corrected paper offsets without mutating the input.
export function centerPaperOnBed(cfg) {
const ox = Math.max(0, ((cfg.bed_w_mm ?? 220) - cfg.paper_w_mm) / 2)
const oy = Math.max(0, ((cfg.bed_h_mm ?? 320) - cfg.paper_h_mm) / 2)
return { paper_offset_x_mm: ox, paper_offset_y_mm: oy }
}

File diff suppressed because it is too large Load Diff

View File

@@ -12,6 +12,7 @@ pub const PAPER_SIZES: &[(&str, f32, f32)] = &[
("A5", 148.0, 210.0),
("Letter", 216.0, 279.0),
("Legal", 216.0, 356.0),
("#10 envelope", 104.775, 241.3), // 4⅛ × 9½ in
];
#[derive(Debug, Clone)]
@@ -34,6 +35,7 @@ pub struct GcodeConfig {
pub feed_travel: u32,
pub pen_down: String,
pub pen_up_z_mm: f32, // Z height (mm) for pen-up rapid moves
pub pen_dwell_ms: u32, // Dwell after pen up/down so the RC servo can physically settle
}
impl Default for GcodeConfig {
@@ -52,6 +54,7 @@ impl Default for GcodeConfig {
feed_travel: 5000,
pen_down: "G1 Z0.4 F1000".to_string(),
pen_up_z_mm: 2.0,
pen_dwell_ms: 250,
}
}
}
@@ -113,6 +116,18 @@ pub fn to_gcode(results: &[FillResult], img_w: u32, _img_h: u32, cfg: &GcodeConf
out.push_str(&format!("G0 F{} ; travel feed\n", cfg.feed_travel));
out.push_str("G0 X0 Y0 ; go to origin\n\n");
// FluidNC has no servo feedback — the Z move "completes" the moment it
// sets the PWM pulse, but the RC servo physically takes ~200-500ms to
// rotate. Without a dwell, the planner starts the next XY move while the
// pen is still travelling, leaving streaks/gaps. G4 P<sec> blocks the
// planner the way feedback would. Tunable via `pen_dwell_ms` (0 = off).
let dwell_sec = cfg.pen_dwell_ms as f32 / 1000.0;
let dwell_line = if cfg.pen_dwell_ms > 0 {
format!("G4 P{:.3}\n", dwell_sec)
} else {
String::new()
};
for result in results {
for stroke in &result.strokes {
if stroke.len() < 2 { continue; }
@@ -121,13 +136,16 @@ pub fn to_gcode(results: &[FillResult], img_w: u32, _img_h: u32, cfg: &GcodeConf
out.push_str(&format!("G0 X{:.3} Y{:.3}\n", sx * scale + ox, sy * scale + oy));
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 * scale + ox, py * scale + oy));
}
out.push_str(&format!("G0 Z{:.3}\n\n", cfg.pen_up_z_mm));
out.push_str(&format!("G0 Z{:.3}\n", cfg.pen_up_z_mm));
out.push_str(&dwell_line); // wait for pen to physically lift
out.push('\n');
}
}

File diff suppressed because it is too large Load Diff

241
src/text.rs Normal file
View File

@@ -0,0 +1,241 @@
// Hershey-font text → stroke rendering for pen plotter output.
//
// Embeds the public-domain Hershey "futural" single-stroke sans-serif font,
// parses it on first use, and renders ASCII text into stroke lists in mm
// coordinates suitable for direct gcode emission.
//
// Font data: 96 glyphs covering ASCII 32 ('space') through 127 ('~').
// JHF format reference: each glyph line is `<id-5ch><nv-3ch><pairs>`. Each
// pair is two ASCII chars; subtract 'R' (=82) to get signed (x, y) offsets.
// First pair = bounding box (left-bearing, right-bearing). Remaining pairs
// are stroke vertices. The literal pair " R" (0x20 0x52) is a pen-up marker
// that ends the current stroke and starts a new one.
use std::sync::OnceLock;
const JHF: &str = include_str!("../resources/futural.jhf");
#[derive(Debug, Clone)]
pub struct Glyph {
pub left: i32, // bbox left bearing in Hershey units (~negative)
pub right: i32, // bbox right bearing
pub strokes: Vec<Vec<(i32, i32)>>, // pen-down stroke list
}
impl Glyph {
pub fn width(&self) -> i32 { self.right - self.left }
}
fn parse_glyphs() -> Vec<Glyph> {
// Hershey "futural" has glyphs indexed by line position; we treat line N
// as ASCII (31 + N) — line 1 = ' ' (32), line 2 = '!' (33), ... line 95 = '~' (126).
let mut out = Vec::with_capacity(96);
for line in JHF.lines() {
// Skip glyph-id (5 chars), read vertex count (next 3 chars).
if line.len() < 8 { out.push(empty_glyph()); continue; }
let nv: usize = line[5..8].trim().parse().unwrap_or(0);
let body = &line[8..];
let bytes = body.as_bytes();
if bytes.len() < 2 || nv == 0 { out.push(empty_glyph()); continue; }
// Pair 1 is the bounding box.
let left = bytes[0] as i32 - b'R' as i32;
let right = bytes[1] as i32 - b'R' as i32;
// Remaining pairs: pen-up sentinel " R" splits strokes.
let mut strokes: Vec<Vec<(i32, i32)>> = Vec::new();
let mut current: Vec<(i32, i32)> = Vec::new();
let mut i = 2;
let pairs_remaining = nv.saturating_sub(1);
for _ in 0..pairs_remaining {
if i + 1 >= bytes.len() { break; }
let a = bytes[i];
let b = bytes[i + 1];
i += 2;
if a == b' ' && b == b'R' {
if current.len() >= 2 { strokes.push(std::mem::take(&mut current)); }
else { current.clear(); }
continue;
}
// Hershey y-axis points DOWN (away from baseline). Glyph baseline
// is at y=0 with letters extending UP into negative y. We flip
// sign here so positive y is "down" in our output, matching paper
// coordinates where y grows from top of paper to bottom.
let x = a as i32 - b'R' as i32;
let y = b as i32 - b'R' as i32;
current.push((x, y));
}
if current.len() >= 2 { strokes.push(current); }
out.push(Glyph { left, right, strokes });
}
// Pad to 96 in case the file was shorter than expected.
while out.len() < 96 { out.push(empty_glyph()); }
out
}
fn empty_glyph() -> Glyph { Glyph { left: -8, right: 8, strokes: vec![] } }
fn glyphs() -> &'static [Glyph] {
static GLYPHS: OnceLock<Vec<Glyph>> = OnceLock::new();
GLYPHS.get_or_init(parse_glyphs)
}
fn glyph_for(c: char) -> &'static Glyph {
let g = glyphs();
let idx = (c as u32).saturating_sub(32) as usize;
g.get(idx).unwrap_or(&g[0])
}
/// Hershey "futural" cap height in font units. Used to scale font_size_mm
/// (which we define as cap height) into mm-per-unit.
const CAP_HEIGHT_UNITS: f32 = 14.0;
/// Render a multi-line text block into strokes positioned at `(origin_x_mm,
/// origin_y_mm)` (top-left of the text block).
///
/// `font_size_mm` is the cap height (height of an uppercase 'X'). Line
/// spacing defaults to 1.6× font size; pass `Some(...)` to override.
///
/// Output strokes are in MM, ready to drop into a FillResult.
pub fn render_text(
text: &str,
font_size_mm: f32,
line_spacing_mm: Option<f32>,
origin_x_mm: f32,
origin_y_mm: f32,
) -> Vec<Vec<(f32, f32)>> {
let mm_per_unit = font_size_mm / CAP_HEIGHT_UNITS;
let line_h_mm = line_spacing_mm.unwrap_or(font_size_mm * 1.6);
let mut strokes: Vec<Vec<(f32, f32)>> = Vec::new();
for (line_idx, line) in text.lines().enumerate() {
let baseline_y = origin_y_mm + line_h_mm * (line_idx as f32 + 1.0);
let mut cursor_x = origin_x_mm;
for ch in line.chars() {
let g = glyph_for(ch);
let pen_x = cursor_x - g.left as f32 * mm_per_unit;
for stroke in &g.strokes {
let pts: Vec<(f32, f32)> = stroke.iter().map(|&(gx, gy)| {
let mx = pen_x + gx as f32 * mm_per_unit;
let my = baseline_y + gy as f32 * mm_per_unit;
(mx, my)
}).collect();
if pts.len() >= 2 { strokes.push(pts); }
}
cursor_x += g.width() as f32 * mm_per_unit;
}
}
strokes
}
// ── Rasterization ────────────────────────────────────────────────────────────
//
// Renders mm-coord strokes onto a paper-sized RgbImage so the text becomes a
// regular image source the existing graph pipeline can process. Strokes are
// drawn in black with configurable thickness so detection/hull extraction
// finds them as solid glyph blobs.
#[derive(Clone, Debug)]
pub struct TextBlockSpec {
pub text: String,
pub font_size_mm: f32,
pub line_spacing_mm: Option<f32>,
pub x_mm: f32,
pub y_mm: f32,
}
/// Compute paper dimensions in pixels for the given DPI.
pub fn paper_pixels(paper_w_mm: f32, paper_h_mm: f32, dpi: u32) -> (u32, u32) {
let scale = dpi as f32 / 25.4;
let w = (paper_w_mm * scale).round() as u32;
let h = (paper_h_mm * scale).round() as u32;
(w.max(1), h.max(1))
}
/// Draw every block's text onto a fresh white RgbImage of paper size at the
/// requested DPI. Strokes are black, drawn at `stroke_thickness_px` so the
/// detection kernel sees solid forms instead of skeletal hairlines.
pub fn rasterize_blocks(
blocks: &[TextBlockSpec],
paper_w_mm: f32,
paper_h_mm: f32,
dpi: u32,
stroke_thickness_px: u32,
) -> image::RgbImage {
let (w_px, h_px) = paper_pixels(paper_w_mm, paper_h_mm, dpi);
let mut img = image::RgbImage::from_pixel(w_px, h_px, image::Rgb([255, 255, 255]));
let scale_px_per_mm = dpi as f32 / 25.4;
let half = (stroke_thickness_px as i32) / 2;
for block in blocks {
if block.text.trim().is_empty() { continue; }
let strokes = render_text(
&block.text, block.font_size_mm, block.line_spacing_mm,
block.x_mm, block.y_mm,
);
for stroke in &strokes {
for win in stroke.windows(2) {
let p0 = (win[0].0 * scale_px_per_mm, win[0].1 * scale_px_per_mm);
let p1 = (win[1].0 * scale_px_per_mm, win[1].1 * scale_px_per_mm);
draw_line_thick(&mut img, p0, p1, half);
}
}
}
img
}
// Bresenham line + a centred square brush of half-width `half`. Slow but
// simple, and rasterization happens once per text edit (debounced).
fn draw_line_thick(img: &mut image::RgbImage, p0: (f32, f32), p1: (f32, f32), half: i32) {
let mut x0 = p0.0.round() as i32;
let mut y0 = p0.1.round() as i32;
let x1 = p1.0.round() as i32;
let y1 = p1.1.round() as i32;
let dx = (x1 - x0).abs();
let dy = -(y1 - y0).abs();
let sx = if x0 < x1 { 1 } else { -1 };
let sy = if y0 < y1 { 1 } else { -1 };
let mut err = dx + dy;
let (w, h) = (img.width() as i32, img.height() as i32);
let black = image::Rgb([0u8, 0u8, 0u8]);
loop {
for ty in (y0 - half)..=(y0 + half) {
for tx in (x0 - half)..=(x0 + half) {
if tx >= 0 && ty >= 0 && tx < w && ty < h {
img.put_pixel(tx as u32, ty as u32, black);
}
}
}
if x0 == x1 && y0 == y1 { break; }
let e2 = 2 * err;
if e2 >= dy { err += dy; x0 += sx; }
if e2 <= dx { err += dx; y0 += sy; }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn loads_full_ascii_range() {
let g = glyphs();
assert_eq!(g.len(), 96, "expected 96 glyphs covering ASCII 32-127");
}
#[test]
fn renders_basic_text() {
let strokes = render_text("Hi", 8.0, None, 0.0, 0.0);
assert!(!strokes.is_empty(), "should produce at least one stroke");
// Every output point must be finite.
for s in &strokes { for &(x, y) in s { assert!(x.is_finite() && y.is_finite()); } }
}
#[test]
fn space_advances_cursor_without_strokes() {
let only_space = render_text(" ", 8.0, None, 0.0, 0.0);
assert!(only_space.is_empty(), "space should emit no strokes");
let with_word = render_text("X X", 8.0, None, 0.0, 0.0);
assert!(with_word.len() > with_word.iter().filter(|s| s.is_empty()).count());
}
}

77
tests/text_parity.rs Normal file
View File

@@ -0,0 +1,77 @@
// Cross-language parity test for the Hershey text renderer.
//
// `src/text.rs` (Rust, source of truth) and
// `src-frontend/src/lib/hershey.js` (JS port) must produce identical
// stroke output for the same inputs. This test pins the Rust output to a
// committed fixture; a sibling JS test (`hershey.test.js`) verifies the
// JS port against the same fixture.
//
// To regenerate after an intentional Rust change:
// UPDATE_TEXT_FIXTURE=1 cargo test --test text_parity
use serde::Serialize;
use trac3r_lib::text::render_text;
#[derive(Serialize)]
struct Case {
text: String,
font_size_mm: f32,
line_spacing_mm: Option<f32>,
strokes: Vec<Vec<[f32; 2]>>,
}
fn cases() -> Vec<(String, f32, Option<f32>)> {
vec![
("Hello".into(), 5.0, None),
("Multi\nline".into(), 6.0, Some(10.0)),
("0123456789".into(), 4.0, None),
("ABC".into(), 10.0, None),
("abc xyz .,!?".into(), 5.0, None),
("x".into(), 3.0, None),
("".into(), 5.0, None),
// Block 0 of our default envelope template — biggest realistic case
("Your Name\n123 Your St\nYour City, ST 12345".into(), 3.0, Some(5.0)),
]
}
fn render_cases() -> Vec<Case> {
cases().into_iter().map(|(text, font_size_mm, line_spacing_mm)| {
let strokes = render_text(&text, font_size_mm, line_spacing_mm, 0.0, 0.0);
Case {
text, font_size_mm, line_spacing_mm,
strokes: strokes.into_iter()
.map(|s| s.into_iter().map(|(x, y)| [x, y]).collect())
.collect(),
}
}).collect()
}
#[test]
fn rust_text_render_matches_fixture() {
let actual = serde_json::to_string_pretty(&render_cases()).expect("serialise");
let fixture_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests").join("text_parity_fixture.json");
let regenerate = std::env::var("UPDATE_TEXT_FIXTURE").is_ok();
if regenerate || !fixture_path.exists() {
std::fs::write(&fixture_path, format!("{}\n", actual))
.expect("write fixture");
if regenerate {
eprintln!("Updated fixture: {}", fixture_path.display());
return;
}
}
let expected = std::fs::read_to_string(&fixture_path).expect("read fixture");
if actual.trim() != expected.trim() {
let diff_path = fixture_path.with_extension("json.actual");
std::fs::write(&diff_path, format!("{}\n", actual)).ok();
panic!(
"Rust text rendering drifted from fixture.\n\
- Compare: diff {} {}\n\
- To accept: UPDATE_TEXT_FIXTURE=1 cargo test --test text_parity",
fixture_path.display(),
diff_path.display(),
);
}
}