update
This commit is contained in:
@@ -21,6 +21,8 @@ base64 = "0.22"
|
|||||||
log = "0.4"
|
log = "0.4"
|
||||||
env_logger = "0.11"
|
env_logger = "0.11"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["multipart", "rustls-tls", "blocking"] }
|
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]
|
[profile.dev]
|
||||||
opt-level = 2
|
opt-level = 2
|
||||||
|
|||||||
BIN
resources/images/navyseal.jpg
Normal file
BIN
resources/images/navyseal.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 187 KiB |
@@ -1,15 +1,21 @@
|
|||||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||||
import Viewport from './components/Viewport.jsx'
|
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 NodeGraph from './components/NodeGraph.jsx'
|
||||||
import PassPanel from './components/PassPanel.jsx'
|
import PassPanel from './components/PassPanel.jsx'
|
||||||
import PerfPanel from './components/PerfPanel.jsx'
|
import PerfPanel from './components/PerfPanel.jsx'
|
||||||
import Slider from './components/Slider.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 * as tauri from './hooks/useTauri.js'
|
||||||
import { serialize, deserialize } from './project.js'
|
import { serialize, deserialize } from './project.js'
|
||||||
import { useFps } from './hooks/useFps.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() {
|
export default function App() {
|
||||||
const [image, setImage] = useState(null)
|
const [image, setImage] = useState(null)
|
||||||
@@ -30,8 +36,45 @@ export default function App() {
|
|||||||
const [nodeWidth, setNodeWidth] = useState(450)
|
const [nodeWidth, setNodeWidth] = useState(450)
|
||||||
const [dpi, setDpi] = useState(150)
|
const [dpi, setDpi] = useState(150)
|
||||||
const [projectPath, setProjectPath] = useState(null) // null = unsaved
|
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)
|
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
|
// Ctrl+S / Ctrl+Shift+S — ref pattern keeps listener stable across renders
|
||||||
const saveProjectRef = useRef(null)
|
const saveProjectRef = useRef(null)
|
||||||
saveProjectRef.current = saveProject
|
saveProjectRef.current = saveProject
|
||||||
@@ -200,6 +243,11 @@ export default function App() {
|
|||||||
debounceTimers.current['detect'] = setTimeout(() => processPass(0, true), 400)
|
debounceTimers.current['detect'] = setTimeout(() => processPass(0, true), 400)
|
||||||
}, [processPass])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (imageRef.current) scheduleProcess()
|
if (imageRef.current) scheduleProcess()
|
||||||
}, [dpi, gcodeConfig.img_w_mm])
|
}, [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 })) }
|
function setGcode(patch) { setGcodeConfig(c => ({ ...c, ...patch })) }
|
||||||
|
|
||||||
// ── Project save ───────────────────────────────────────────────────────────
|
// ── Project save ───────────────────────────────────────────────────────────
|
||||||
@@ -247,6 +283,8 @@ export default function App() {
|
|||||||
nodeWidth,
|
nodeWidth,
|
||||||
graph: passes[0].graph,
|
graph: passes[0].graph,
|
||||||
gcodeConfig,
|
gcodeConfig,
|
||||||
|
sourceMode,
|
||||||
|
textBlocks,
|
||||||
})
|
})
|
||||||
await tauri.writeProjectFile(path, json)
|
await tauri.writeProjectFile(path, json)
|
||||||
setProjectPath(path)
|
setProjectPath(path)
|
||||||
@@ -269,6 +307,8 @@ export default function App() {
|
|||||||
if (restored.gcodeConfig) setGcodeConfig(restored.gcodeConfig)
|
if (restored.gcodeConfig) setGcodeConfig(restored.gcodeConfig)
|
||||||
if (restored.dpi) setDpi(restored.dpi)
|
if (restored.dpi) setDpi(restored.dpi)
|
||||||
if (restored.nodeWidth) setNodeWidth(restored.nodeWidth)
|
if (restored.nodeWidth) setNodeWidth(restored.nodeWidth)
|
||||||
|
if (restored.sourceMode) setSourceMode(restored.sourceMode)
|
||||||
|
if (Array.isArray(restored.textBlocks)) setTextBlocks(restored.textBlocks)
|
||||||
|
|
||||||
// Replace the pass graph
|
// Replace the pass graph
|
||||||
if (restored.graph) {
|
if (restored.graph) {
|
||||||
@@ -373,6 +413,41 @@ export default function App() {
|
|||||||
|
|
||||||
<div className="px-3 py-2 space-y-4">
|
<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 */}
|
{/* Graph */}
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<p className="text-neutral-500 text-xs font-semibold uppercase tracking-wider mb-1.5">Graph</p>
|
<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}
|
<button key={ps.name}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const portrait = gcodeConfig.paper_w_mm <= gcodeConfig.paper_h_mm
|
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.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 ${
|
className={`px-2 py-0.5 rounded text-xs transition-colors ${
|
||||||
isPortrait || isLandscape
|
isPortrait || isLandscape
|
||||||
@@ -410,7 +486,10 @@ export default function App() {
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
<button
|
<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'}
|
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"
|
className="px-2 py-0.5 rounded text-xs bg-neutral-800 text-neutral-400 hover:bg-neutral-700 transition-colors"
|
||||||
>Rotate</button>
|
>Rotate</button>
|
||||||
@@ -453,27 +532,22 @@ export default function App() {
|
|||||||
onChange={v => setGcode({ feed_travel: v })} unit=" mm/m" />
|
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"
|
<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 })} />
|
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>
|
</div>
|
||||||
|
|
||||||
|
{/* Calibration: corner jog + axis-scale */}
|
||||||
|
<CalibrationButtons gcodeConfig={gcodeConfig} imgSize={image} setStatus={setGlobalStatus} />
|
||||||
|
<CalibrationAxis printerUrl={gcodeConfig.printer_url} setStatus={setGlobalStatus} />
|
||||||
|
|
||||||
{/* Export & upload */}
|
{/* Export & upload */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-neutral-500 text-xs font-semibold uppercase tracking-wider">Output</p>
|
<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">
|
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
|
Export G-code to folder
|
||||||
</button>
|
</button>
|
||||||
<div className="space-y-1">
|
<p className="text-xs text-neutral-600">Use the <span className="text-emerald-500">Printer</span> tab to upload & run.</p>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -492,7 +566,7 @@ export default function App() {
|
|||||||
{/* Top bar — accent colors match the section dots in the left panel */}
|
{/* 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">
|
<div className="flex items-center gap-1 px-3 py-2 border-b border-neutral-800 bg-neutral-900/80">
|
||||||
{VIEW_MODES.map(m => {
|
{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)
|
const label = m === 'gcode' ? 'G-code' : m.charAt(0).toUpperCase() + m.slice(1)
|
||||||
return (
|
return (
|
||||||
<button key={m}
|
<button key={m}
|
||||||
@@ -532,6 +606,24 @@ export default function App() {
|
|||||||
sourceImageB64={image?.preview_b64 ?? null}
|
sourceImageB64={image?.preview_b64 ?? null}
|
||||||
nodeWidth={nodeWidth}
|
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
|
<Viewport
|
||||||
imageB64={displayB64}
|
imageB64={displayB64}
|
||||||
@@ -542,7 +634,7 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{showPerf && <PerfPanel data={perfData} fps={fps} longTasks={longTasks} />}
|
{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="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||||
<div className="text-center space-y-2">
|
<div className="text-center space-y-2">
|
||||||
<p className="text-neutral-600 text-lg">No image loaded</p>
|
<p className="text-neutral-600 text-lg">No image loaded</p>
|
||||||
|
|||||||
137
src-frontend/src/components/CalibrationAxis.jsx
Normal file
137
src-frontend/src/components/CalibrationAxis.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
104
src-frontend/src/components/CalibrationButtons.jsx
Normal file
104
src-frontend/src/components/CalibrationButtons.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
505
src-frontend/src/components/ChordalDebugView.jsx
Normal file
505
src-frontend/src/components/ChordalDebugView.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
265
src-frontend/src/components/PrinterPanel.jsx
Normal file
265
src-frontend/src/components/PrinterPanel.jsx
Normal 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
|
||||||
|
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 & 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
319
src-frontend/src/components/TextEditOverlay.jsx
Normal file
319
src-frontend/src/components/TextEditOverlay.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
153
src-frontend/src/components/TuningPanel.jsx
Normal file
153
src-frontend/src/components/TuningPanel.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -175,30 +175,51 @@ export default function Viewport({ imageB64, strokes, imgSize, viewMode, gcodeCo
|
|||||||
|
|
||||||
const end = Math.min(idx + CHUNK_SIZE, flat.length)
|
const end = Math.min(idx + CHUNK_SIZE, flat.length)
|
||||||
|
|
||||||
// Batch consecutive same-color strokes into one path per color run
|
if (viewMode === 'gcode') {
|
||||||
let i = idx
|
// Debug view: every stroke gets its own hue (golden-ratio cycle for
|
||||||
while (i < end) {
|
// maximal visual separation). One beginPath per stroke since each has
|
||||||
const [r, g, b] = flat[i].color
|
// a unique strokeStyle.
|
||||||
octx.strokeStyle = `rgb(${r},${g},${b})`
|
octx.lineWidth = 1.5
|
||||||
octx.lineWidth = 1.5
|
octx.lineCap = 'round'
|
||||||
octx.lineCap = 'round'
|
for (let i = idx; i < end; i++) {
|
||||||
octx.beginPath()
|
const pts = flat[i].points
|
||||||
let j = i
|
if (pts.length < 2) continue
|
||||||
while (j < end &&
|
const hue = (i * 137.508) % 360
|
||||||
flat[j].color[0] === r &&
|
octx.strokeStyle = `hsl(${hue.toFixed(1)}, 80%, 50%)`
|
||||||
flat[j].color[1] === g &&
|
octx.beginPath()
|
||||||
flat[j].color[2] === b) {
|
octx.moveTo(pts[0][0], pts[0][1])
|
||||||
const pts = flat[j].points
|
for (let k = 1; k < pts.length; k++) {
|
||||||
if (pts.length >= 2) {
|
octx.lineTo(pts[k][0], pts[k][1])
|
||||||
octx.moveTo(pts[0][0], pts[0][1])
|
|
||||||
for (let k = 1; k < pts.length; k++) {
|
|
||||||
octx.lineTo(pts[k][0], pts[k][1])
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
j++
|
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
|
||||||
|
octx.strokeStyle = `rgb(${r},${g},${b})`
|
||||||
|
octx.lineWidth = 1.5
|
||||||
|
octx.lineCap = 'round'
|
||||||
|
octx.beginPath()
|
||||||
|
let j = i
|
||||||
|
while (j < end &&
|
||||||
|
flat[j].color[0] === r &&
|
||||||
|
flat[j].color[1] === g &&
|
||||||
|
flat[j].color[2] === b) {
|
||||||
|
const pts = flat[j].points
|
||||||
|
if (pts.length >= 2) {
|
||||||
|
octx.moveTo(pts[0][0], pts[0][1])
|
||||||
|
for (let k = 1; k < pts.length; k++) {
|
||||||
|
octx.lineTo(pts[k][0], pts[k][1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
octx.stroke()
|
||||||
|
i = j
|
||||||
}
|
}
|
||||||
octx.stroke()
|
|
||||||
i = j
|
|
||||||
}
|
}
|
||||||
|
|
||||||
state.idx = end
|
state.idx = end
|
||||||
|
|||||||
@@ -22,6 +22,14 @@ export async function processPass(payload) {
|
|||||||
return tracedInvoke('process_pass', { 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() {
|
export async function getAllStrokes() {
|
||||||
return tracedInvoke('get_all_strokes', {})
|
return tracedInvoke('get_all_strokes', {})
|
||||||
}
|
}
|
||||||
@@ -46,6 +54,89 @@ export async function uploadToPrinter(gcodeConfig, printerUrl) {
|
|||||||
return tracedInvoke('upload_to_printer', { 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) {
|
export async function exportDebugState(passConfigs) {
|
||||||
return tracedInvoke('export_debug_state', { passConfigs })
|
return tracedInvoke('export_debug_state', { passConfigs })
|
||||||
}
|
}
|
||||||
|
|||||||
110
src-frontend/src/lib/hershey.js
Normal file
110
src-frontend/src/lib/hershey.js
Normal 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 }
|
||||||
|
}
|
||||||
47
src-frontend/src/lib/hershey.test.js
Normal file
47
src-frontend/src/lib/hershey.test.js
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -21,16 +21,18 @@ const MIGRATIONS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
// ── Serialize ──────────────────────────────────────────────────────────────────
|
// ── Serialize ──────────────────────────────────────────────────────────────────
|
||||||
export function serialize({ imagePath, dpi, nodeWidth, graph, gcodeConfig }) {
|
export function serialize({ imagePath, dpi, nodeWidth, graph, gcodeConfig, sourceMode, textBlocks }) {
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
version: CURRENT_VERSION,
|
version: CURRENT_VERSION,
|
||||||
app: 'trac3r',
|
app: 'trac3r',
|
||||||
saved_at: new Date().toISOString(),
|
saved_at: new Date().toISOString(),
|
||||||
image_path: imagePath ?? null,
|
image_path: imagePath ?? null,
|
||||||
dpi,
|
dpi,
|
||||||
node_width: nodeWidth,
|
node_width: nodeWidth,
|
||||||
graph,
|
graph,
|
||||||
gcode: gcodeConfig,
|
gcode: gcodeConfig,
|
||||||
|
source_mode: sourceMode ?? 'image',
|
||||||
|
text_blocks: textBlocks ?? [],
|
||||||
}, null, 2)
|
}, null, 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,5 +76,7 @@ export function deserialize(json, { migrations: migs = MIGRATIONS, currentVersio
|
|||||||
nodeWidth: doc.node_width ?? 450,
|
nodeWidth: doc.node_width ?? 450,
|
||||||
graph: doc.graph ?? null,
|
graph: doc.graph ?? null,
|
||||||
gcodeConfig: doc.gcode ?? null,
|
gcodeConfig: doc.gcode ?? null,
|
||||||
|
sourceMode: doc.source_mode ?? 'image',
|
||||||
|
textBlocks: Array.isArray(doc.text_blocks) ? doc.text_blocks : [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -219,6 +219,7 @@ describe('deserialize — missing optional fields', () => {
|
|||||||
const result = deserialize(minimalDoc)
|
const result = deserialize(minimalDoc)
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
imagePath: null, dpi: 150, nodeWidth: 450, graph: null, gcodeConfig: null,
|
imagePath: null, dpi: 150, nodeWidth: 450, graph: null, gcodeConfig: null,
|
||||||
|
sourceMode: 'image', textBlocks: [],
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
export const KERNELS = ['Luminance','Sobel','ColorGradient','Laplacian','Canny','Saturation','XDoG','ColorIsolate']
|
export const KERNELS = ['Luminance','Sobel','ColorGradient','Laplacian','Canny','Saturation','XDoG','ColorIsolate']
|
||||||
export const BLEND_MODES = ['Average','Min','Max','Multiply','Screen','Difference']
|
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.
|
// Per-strategy secondary parameter exposed as a slider.
|
||||||
// Strategies not listed here have no secondary parameter.
|
// 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' },
|
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,
|
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' },
|
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.5–2.5 removes junction artifacts; 0 keeps everything.' },
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategies that use the angle slider
|
// Strategies that use the angle slider
|
||||||
@@ -131,25 +133,35 @@ export function defaultPass(index) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const PAPER_SIZES = [
|
export const PAPER_SIZES = [
|
||||||
{ name: 'A0', w: 841, h: 1189 },
|
{ name: 'A0', w: 841, h: 1189 },
|
||||||
{ name: 'A1', w: 594, h: 841 },
|
{ name: 'A1', w: 594, h: 841 },
|
||||||
{ name: 'A2', w: 420, h: 594 },
|
{ name: 'A2', w: 420, h: 594 },
|
||||||
{ name: 'A3', w: 297, h: 420 },
|
{ name: 'A3', w: 297, h: 420 },
|
||||||
{ name: 'A4', w: 210, h: 297 },
|
{ name: 'A4', w: 210, h: 297 },
|
||||||
{ name: 'A5', w: 148, h: 210 },
|
{ name: 'A5', w: 148, h: 210 },
|
||||||
{ name: 'Letter', w: 216, h: 279 },
|
{ name: 'Letter', w: 216, h: 279 },
|
||||||
{ name: 'Legal', w: 216, h: 356 },
|
{ name: 'Legal', w: 216, h: 356 },
|
||||||
|
{ name: '#10 envelope', w: 104.775, h: 241.3 }, // 4⅛ × 9½ in, US business
|
||||||
]
|
]
|
||||||
|
|
||||||
export function defaultGcodeConfig() {
|
export function defaultGcodeConfig() {
|
||||||
return {
|
const cfg = {
|
||||||
bed_w_mm: 220, bed_h_mm: 320,
|
bed_w_mm: 220, bed_h_mm: 320,
|
||||||
paper_offset_x_mm: 0, paper_offset_y_mm: 0,
|
paper_offset_x_mm: 0, paper_offset_y_mm: 0,
|
||||||
paper_w_mm: 210, paper_h_mm: 297,
|
paper_w_mm: 210, paper_h_mm: 297,
|
||||||
img_w_mm: 180,
|
img_w_mm: 180,
|
||||||
offset_x_mm: 15, offset_y_mm: 15,
|
offset_x_mm: 15, offset_y_mm: 15,
|
||||||
feed_draw: 1000, feed_travel: 5000,
|
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',
|
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 }
|
||||||
}
|
}
|
||||||
|
|||||||
1894
src/fill.rs
1894
src/fill.rs
File diff suppressed because it is too large
Load Diff
36
src/gcode.rs
36
src/gcode.rs
@@ -4,14 +4,15 @@ use crate::fill::FillResult;
|
|||||||
|
|
||||||
// Standard paper sizes in portrait orientation (width × height, mm).
|
// Standard paper sizes in portrait orientation (width × height, mm).
|
||||||
pub const PAPER_SIZES: &[(&str, f32, f32)] = &[
|
pub const PAPER_SIZES: &[(&str, f32, f32)] = &[
|
||||||
("A0", 841.0, 1189.0),
|
("A0", 841.0, 1189.0),
|
||||||
("A1", 594.0, 841.0),
|
("A1", 594.0, 841.0),
|
||||||
("A2", 420.0, 594.0),
|
("A2", 420.0, 594.0),
|
||||||
("A3", 297.0, 420.0),
|
("A3", 297.0, 420.0),
|
||||||
("A4", 210.0, 297.0),
|
("A4", 210.0, 297.0),
|
||||||
("A5", 148.0, 210.0),
|
("A5", 148.0, 210.0),
|
||||||
("Letter", 216.0, 279.0),
|
("Letter", 216.0, 279.0),
|
||||||
("Legal", 216.0, 356.0),
|
("Legal", 216.0, 356.0),
|
||||||
|
("#10 envelope", 104.775, 241.3), // 4⅛ × 9½ in
|
||||||
];
|
];
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -34,6 +35,7 @@ pub struct GcodeConfig {
|
|||||||
pub feed_travel: u32,
|
pub feed_travel: u32,
|
||||||
pub pen_down: String,
|
pub pen_down: String,
|
||||||
pub pen_up_z_mm: f32, // Z height (mm) for pen-up rapid moves
|
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 {
|
impl Default for GcodeConfig {
|
||||||
@@ -52,6 +54,7 @@ impl Default for GcodeConfig {
|
|||||||
feed_travel: 5000,
|
feed_travel: 5000,
|
||||||
pen_down: "G1 Z0.4 F1000".to_string(),
|
pen_down: "G1 Z0.4 F1000".to_string(),
|
||||||
pen_up_z_mm: 2.0,
|
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(&format!("G0 F{} ; travel feed\n", cfg.feed_travel));
|
||||||
out.push_str("G0 X0 Y0 ; go to origin\n\n");
|
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 result in results {
|
||||||
for stroke in &result.strokes {
|
for stroke in &result.strokes {
|
||||||
if stroke.len() < 2 { continue; }
|
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(&format!("G0 X{:.3} Y{:.3}\n", sx * scale + ox, sy * scale + oy));
|
||||||
out.push_str(&cfg.pen_down);
|
out.push_str(&cfg.pen_down);
|
||||||
out.push('\n');
|
out.push('\n');
|
||||||
|
out.push_str(&dwell_line); // wait for pen to physically drop
|
||||||
out.push_str(&format!("G1 F{}\n", cfg.feed_draw));
|
out.push_str(&format!("G1 F{}\n", cfg.feed_draw));
|
||||||
|
|
||||||
for &(px, py) in &stroke[1..] {
|
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!("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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
918
src/lib.rs
918
src/lib.rs
File diff suppressed because it is too large
Load Diff
241
src/text.rs
Normal file
241
src/text.rs
Normal 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
77
tests/text_parity.rs
Normal 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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user