delete text source + contours tab; rename Detection → Pipeline

Per separated-pipelines decision: text source is gone entirely.
The image pipeline (kernels → hulls → fills → gcode) stands alone.

Removed:
- src/text.rs (Hershey font parser, render_text, rasterize_blocks)
- src-frontend/.../components/TextEditOverlay.jsx
- App.jsx: sourceMode, textBlocks state, text-source toggle button,
  rasterise-on-edit useEffect, TextEditOverlay branch, "Clear all
  blocks" UI
- lib.rs: pub mod text, struct TextBlock, AppState.text_blocks,
  TextBlockPayload, set_text_blocks, TextPreview, get_text_preview,
  the two tauri handler entries
- useTauri.js: setTextBlocks
- project.{js,test.js}: sourceMode + textBlocks fields in
  serialize/deserialize (existing project files keep loading; the
  fields just get ignored)

Renamed:
- VIEW_MODES: 'detection' + 'contours' → single 'pipeline' tab
  (the contours-SVG mode was the only thing in get_pass_viz, so
  the entire command + its frontend wrapper get_pass_viz/getPassViz
  also went). The pipeline tab still shows the NodeGraph; if the
  user wants contours back, it's a fill-strategy choice now (outline).
- Accent colors map updated for the renamed tab; deleted 'paint'
  entry too.

Verified: cargo build, cargo test --lib (74 pass), npm run build,
npm test (72 pass — pre-existing hershey-fixture failure unchanged).
This commit is contained in:
Mitchell Hansen
2026-05-08 21:50:01 -07:00
parent b6e10ad4f6
commit 2988ef474f
7 changed files with 7 additions and 852 deletions

View File

@@ -4,7 +4,6 @@ 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 NodeGraph from './components/NodeGraph.jsx'
import PassPanel from './components/PassPanel.jsx'
import PerfPanel from './components/PerfPanel.jsx'
@@ -14,7 +13,7 @@ import * as tauri from './hooks/useTauri.js'
import { serialize, deserialize } from './project.js'
import { useFps } from './hooks/useFps.js'
const VIEW_MODES = ['source', 'detection', 'contours', 'gcode', 'printer', 'tuning']
const VIEW_MODES = ['source', 'pipeline', 'gcode', 'printer', 'tuning']
export default function App() {
const [image, setImage] = useState(null)
@@ -35,45 +34,10 @@ export default function App() {
const [nodeWidth, setNodeWidth] = useState(450)
const [dpi, setDpi] = useState(150)
const [projectPath, setProjectPath] = useState(null) // null = unsaved
const [sourceMode, setSourceMode] = useState('image') // 'image' | 'text'
const [textBlocks, setTextBlocks] = useState([
// Sensible defaults for #10 envelope addressing.
{ text: 'Your Name\n123 Your St\nYour City, ST 12345',
font_size_mm: 3, line_spacing_mm: 5, x_mm: 8, y_mm: 8 },
{ text: 'Recipient Name\n456 Their St\nTheir City, ST 67890',
font_size_mm: 5, line_spacing_mm: 8, x_mm: 35, y_mm: 95 },
])
const resizing = useRef(false)
// True when the project has something to plot. In text mode, "ready" =
// pipeline has produced strokes; just having text doesn't guarantee the
// graph processed it yet. Same check as image mode.
const hasOutput = passes.some(p => p.strokeCount > 0)
// When in text mode, rasterise blocks into a paper-sized image source
// (debounced) and trigger pipeline processing on the new image. The
// image flows through Source → Kernel → Hull → Fill like any other.
useEffect(() => {
if (sourceMode !== 'text') return
const t = setTimeout(async () => {
try {
const info = await tauri.setTextBlocks(
textBlocks,
gcodeConfig.paper_w_mm,
gcodeConfig.paper_h_mm,
dpi,
/* strokeThicknessPx */ Math.max(2, Math.round(dpi / 50)),
)
setImage(info)
setStrokes(null)
scheduleProcessRef.current?.()
} catch (e) {
setGlobalStatus(`Text render error: ${e.message ?? e}`)
}
}, 350)
return () => clearTimeout(t)
}, [sourceMode, textBlocks, gcodeConfig.paper_w_mm, gcodeConfig.paper_h_mm, dpi])
// Ctrl+S / Ctrl+Shift+S — ref pattern keeps listener stable across renders
const saveProjectRef = useRef(null)
saveProjectRef.current = saveProject
@@ -145,24 +109,9 @@ export default function App() {
case 'source':
setDisplayB64(image.preview_b64)
break
case 'detection':
case 'pipeline':
setDisplayB64(passes[0]?.vizB64 ?? null)
break
case 'contours':
if (passes[0]?.hullCount > 0) {
try {
const tv = performance.now()
const b64 = await tauri.getPassViz(0, viewMode)
setPerfData(pd => ({ ...(pd ?? {}), js_viz: Math.round(performance.now() - tv) }))
setDisplayB64(b64)
} catch (e) {
setGlobalStatus(`Viz error: ${e}`)
setDisplayB64(null)
}
} else {
setDisplayB64(null)
}
break
case 'gcode':
if (passes.some(p => p.strokeCount > 0)) {
try {
@@ -282,8 +231,6 @@ export default function App() {
nodeWidth,
graph: passes[0].graph,
gcodeConfig,
sourceMode,
textBlocks,
})
await tauri.writeProjectFile(path, json)
setProjectPath(path)
@@ -306,8 +253,6 @@ export default function App() {
if (restored.gcodeConfig) setGcodeConfig(restored.gcodeConfig)
if (restored.dpi) setDpi(restored.dpi)
if (restored.nodeWidth) setNodeWidth(restored.nodeWidth)
if (restored.sourceMode) setSourceMode(restored.sourceMode)
if (Array.isArray(restored.textBlocks)) setTextBlocks(restored.textBlocks)
// Replace the pass graph
if (restored.graph) {
@@ -412,41 +357,6 @@ export default function App() {
<div className="px-3 py-2 space-y-4">
{/* Source mode */}
<div className="space-y-1.5">
<p className="text-neutral-500 text-xs font-semibold uppercase tracking-wider">Source</p>
<div className="grid grid-cols-2 gap-1">
<button onClick={() => setSourceMode('image')}
className={`px-2 py-1 rounded text-xs ${sourceMode === 'image' ? 'bg-indigo-700 text-white' : 'bg-neutral-800 hover:bg-neutral-700 text-neutral-400'}`}>
Image
</button>
<button onClick={() => setSourceMode('text')}
className={`px-2 py-1 rounded text-xs ${sourceMode === 'text' ? 'bg-indigo-700 text-white' : 'bg-neutral-800 hover:bg-neutral-700 text-neutral-400'}`}>
Text
</button>
</div>
{sourceMode === 'text' && (
<div className="pt-1 space-y-1.5">
<p className="text-[11px] text-neutral-400 leading-snug">
Switch to the <span className="text-emerald-500">Source</span> tab to place
text directly on the paper. Click empty paper to add a box, drag the
header to move, drag the SE corner to scale.
</p>
<p className="text-[10px] text-neutral-600 leading-snug">
Blocks rasterise to a paper-sized image and run through the graph
(Detection Hull Fill) like any image source.
</p>
<button
onClick={() => setTextBlocks([])}
disabled={textBlocks.length === 0}
className="w-full px-2 py-1 rounded bg-neutral-800 hover:bg-neutral-700 text-xs text-neutral-400 disabled:opacity-40">
Clear all blocks ({textBlocks.length})
</button>
</div>
)}
</div>
{/* Graph */}
<div className="space-y-0.5">
<p className="text-neutral-500 text-xs font-semibold uppercase tracking-wider mb-1.5">Graph</p>
@@ -565,7 +475,7 @@ export default function App() {
{/* Top bar — accent colors match the section dots in the left panel */}
<div className="flex items-center gap-1 px-3 py-2 border-b border-neutral-800 bg-neutral-900/80">
{VIEW_MODES.map(m => {
const accent = { detection: '#6366f1', contours: '#14b8a6', gcode: '#f59e0b', printer: '#10b981', tuning: '#a855f7' }[m]
const accent = { pipeline: '#6366f1', gcode: '#f59e0b', printer: '#10b981', tuning: '#a855f7' }[m]
const label = m === 'gcode' ? 'G-code' : m.charAt(0).toUpperCase() + m.slice(1)
return (
<button key={m}
@@ -594,7 +504,7 @@ export default function App() {
{/* Viewport / Node graph */}
<div className="flex-1 relative overflow-hidden">
{viewMode === 'detection' ? (
{viewMode === 'pipeline' ? (
<NodeGraph
graph={passes[0].graph}
onChange={graph => {
@@ -614,13 +524,6 @@ export default function App() {
/>
) : viewMode === 'tuning' ? (
<TuningPanel printerUrl={gcodeConfig.printer_url ?? ''} />
) : viewMode === 'source' && sourceMode === 'text' ? (
<TextEditOverlay
paperWMm={gcodeConfig.paper_w_mm}
paperHMm={gcodeConfig.paper_h_mm}
blocks={textBlocks}
setBlocks={setTextBlocks}
/>
) : (
<Viewport
imageB64={displayB64}
@@ -631,7 +534,7 @@ export default function App() {
/>
)}
{showPerf && <PerfPanel data={perfData} fps={fps} longTasks={longTasks} />}
{!image && sourceMode !== 'text' && (
{!image && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="text-center space-y-2">
<p className="text-neutral-600 text-lg">No image loaded</p>

View File

@@ -1,319 +0,0 @@
import { useEffect, useMemo, useRef, useState, useLayoutEffect } from 'react'
import { renderText, measureText } from '../lib/hershey.js'
// MS-Paint-style WYSIWYG text editor for the Source tab.
//
// Each box is a stack of three layers in mm-coord SVG-space:
// 1. visual: <svg> rendering the actual Hershey strokes (what will plot)
// 2. editable: a transparent <textarea> overlaid for caret + key handling
// 3. chrome: header (drag-to-move) and SE handle (drag-to-scale font)
//
// Background pan/zoom: drag empty space to pan, mouse wheel zooms to cursor,
// double-click on paper adds a new block.
const HEADER_H_PX = 18
export default function TextEditOverlay({
paperWMm, paperHMm,
blocks, setBlocks,
}) {
const containerRef = useRef(null)
const [layout, setLayout] = useState({ scale: 1, paperX: 0, paperY: 0, paperW: 0, paperH: 0 })
const [view, setView] = useState({ zoom: 1, panX: 0, panY: 0 })
const [selectedIdx, setSelectedIdx] = useState(null)
const panDragRef = useRef(null)
useLayoutEffect(() => {
const el = containerRef.current
if (!el) return
const update = () => {
const rect = el.getBoundingClientRect()
const pad = 24
const cw = Math.max(1, rect.width - pad * 2)
const ch = Math.max(1, rect.height - pad * 2)
const scale = Math.min(cw / paperWMm, ch / paperHMm)
const paperW = paperWMm * scale
const paperH = paperHMm * scale
const paperX = (rect.width - paperW) / 2
const paperY = (rect.height - paperH) / 2
setLayout({ scale, paperX, paperY, paperW, paperH })
}
update()
const ro = new ResizeObserver(update)
ro.observe(el)
return () => ro.disconnect()
}, [paperWMm, paperHMm])
// mm → screen-px (zoom-aware). Used by TextBox for positioning and sizing.
const mmToPx = (mm) => mm * layout.scale * view.zoom
const pxToMm = (px) => px / (layout.scale * view.zoom)
// Convert a clientXY (mouse event) to mm coordinates on the paper.
const clientToMm = (clientX, clientY) => {
const rect = containerRef.current.getBoundingClientRect()
const sx = clientX - rect.left - layout.paperX - view.panX
const sy = clientY - rect.top - layout.paperY - view.panY
return { x: sx / (layout.scale * view.zoom), y: sy / (layout.scale * view.zoom) }
}
const onPanMouseDown = (e) => {
if (e.target !== e.currentTarget) return
if (e.button !== 0) return
panDragRef.current = {
startX: e.clientX, startY: e.clientY,
origPanX: view.panX, origPanY: view.panY,
moved: false,
}
const onMove = (ev) => {
const s = panDragRef.current
if (!s) return
const dx = ev.clientX - s.startX
const dy = ev.clientY - s.startY
if (!s.moved && (Math.abs(dx) > 3 || Math.abs(dy) > 3)) s.moved = true
setView(v => ({ ...v, panX: s.origPanX + dx, panY: s.origPanY + dy }))
}
const onUp = (ev) => {
const s = panDragRef.current
panDragRef.current = null
document.removeEventListener('mousemove', onMove)
document.removeEventListener('mouseup', onUp)
// Click without drag on background → deselect any selected box.
if (s && !s.moved) setSelectedIdx(null)
}
document.addEventListener('mousemove', onMove)
document.addEventListener('mouseup', onUp)
}
const onWheel = (e) => {
e.preventDefault()
const rect = containerRef.current.getBoundingClientRect()
const cx = e.clientX - rect.left - layout.paperX
const cy = e.clientY - rect.top - layout.paperY
const factor = e.deltaY < 0 ? 1.1 : 1 / 1.1
setView(v => {
const nz = Math.max(0.1, Math.min(20, v.zoom * factor))
const r = nz / v.zoom
return { zoom: nz, panX: v.panX * r + cx * (1 - r), panY: v.panY * r + cy * (1 - r) }
})
}
const onPaperDoubleClick = (e) => {
if (e.target !== e.currentTarget) return
const { x, y } = clientToMm(e.clientX, e.clientY)
const newBlock = {
text: 'New text',
font_size_mm: 5, line_spacing_mm: 8,
x_mm: Math.max(0, x), y_mm: Math.max(0, y),
}
setBlocks(bs => [...bs, newBlock])
setSelectedIdx(blocks.length)
}
return (
<div ref={containerRef}
onMouseDown={onPanMouseDown}
onWheel={onWheel}
className="absolute inset-0 bg-neutral-900 select-none overflow-hidden">
<div className="absolute bg-neutral-100 shadow-lg"
onMouseDown={onPanMouseDown}
onDoubleClick={onPaperDoubleClick}
style={{
left: layout.paperX + view.panX,
top: layout.paperY + view.panY,
width: layout.paperW * view.zoom,
height: layout.paperH * view.zoom,
}}>
{blocks.map((b, i) => (
<TextBox key={i}
block={b}
selected={selectedIdx === i}
onSelect={() => setSelectedIdx(i)}
onChange={patch => setBlocks(bs => bs.map((bb, j) => j === i ? { ...bb, ...patch } : bb))}
onDelete={() => { setBlocks(bs => bs.filter((_, j) => j !== i)); setSelectedIdx(null) }}
mmToPx={mmToPx} pxToMm={pxToMm}
/>
))}
</div>
<p className="absolute bottom-2 left-3 text-[10px] text-neutral-500 pointer-events-none">
Drag to pan Wheel to zoom Double-click paper to add a text box Drag header to move Drag SE corner to scale font
</p>
<p className="absolute top-2 right-3 text-[10px] text-neutral-500 pointer-events-none">
Paper {paperWMm.toFixed(0)}×{paperHMm.toFixed(0)}mm · {(view.zoom * 100).toFixed(0)}%
</p>
</div>
)
}
// ── Single box ──────────────────────────────────────────────────────────────
function TextBox({ block, selected, onSelect, onChange, onDelete, mmToPx, pxToMm }) {
const taRef = useRef(null)
const dragStateRef = useRef(null)
// Real Hershey rendering: actual strokes & accurate measurement.
const { strokes, widthMm, heightMm } = useMemo(() => {
const { width, height } = measureText(block.text || ' ', block.font_size_mm, block.line_spacing_mm)
const strokes = renderText(block.text || '', block.font_size_mm, block.line_spacing_mm)
return {
strokes,
widthMm: Math.max(8, width + 2), // small breathing room so caret has space
heightMm: Math.max(block.font_size_mm, height),
}
}, [block.text, block.font_size_mm, block.line_spacing_mm])
const left = mmToPx(block.x_mm)
const top = mmToPx(block.y_mm)
const width = mmToPx(widthMm)
const height = mmToPx(heightMm)
const fontPx = mmToPx(block.font_size_mm)
// Hershey-pen-thickness ≈ 1/14 cap-height — emulate visually with a thin
// SVG stroke. Scale with font size so it stays visible.
const strokePx = Math.max(0.6, fontPx / 14)
const onHeaderMouseDown = (e) => {
e.stopPropagation()
e.preventDefault()
onSelect()
dragStateRef.current = {
kind: 'move',
startX: e.clientX, startY: e.clientY,
origMm: { x: block.x_mm, y: block.y_mm },
}
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
}
const onResizeMouseDown = (e) => {
e.stopPropagation()
e.preventDefault()
onSelect()
dragStateRef.current = {
kind: 'resize',
startX: e.clientX, startY: e.clientY,
origFontMm: block.font_size_mm,
origLineMm: block.line_spacing_mm ?? block.font_size_mm * 1.6,
origWidthPx: width,
origHeightPx: height,
}
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
}
function onMouseMove(e) {
const s = dragStateRef.current
if (!s) return
const dx = e.clientX - s.startX
const dy = e.clientY - s.startY
if (s.kind === 'move') {
onChange({
x_mm: Math.max(0, s.origMm.x + pxToMm(dx)),
y_mm: Math.max(0, s.origMm.y + pxToMm(dy)),
})
} else if (s.kind === 'resize') {
const sx = (s.origWidthPx + dx) / s.origWidthPx
const sy = (s.origHeightPx + dy) / s.origHeightPx
const factor = Math.max(0.25, Math.min(8, Math.max(sx, sy)))
onChange({
font_size_mm: Math.max(1, s.origFontMm * factor),
line_spacing_mm: Math.max(2, s.origLineMm * factor),
})
}
}
function onMouseUp() {
dragStateRef.current = null
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
}
useEffect(() => () => onMouseUp(), [])
// Auto-grow textarea height on text change so the caret tracks the
// expanding box.
useEffect(() => {
if (taRef.current) taRef.current.style.height = `${height}px`
}, [height])
return (
<div className={`absolute ${selected ? 'ring-2 ring-indigo-500' : 'ring-1 ring-neutral-400/40'}`}
style={{ left, top, width, height: height + HEADER_H_PX }}
onMouseDown={e => { e.stopPropagation(); onSelect() }}>
{/* Header — draggable */}
<div onMouseDown={onHeaderMouseDown}
className={`absolute top-0 left-0 right-0 flex items-center justify-between px-1 cursor-move
${selected ? 'bg-indigo-600 text-white' : 'bg-neutral-700/60 text-neutral-100'}`}
style={{ height: HEADER_H_PX, fontSize: 10 }}>
<span className="font-mono">
{block.x_mm.toFixed(0)},{block.y_mm.toFixed(0)} · {block.font_size_mm.toFixed(1)}mm
</span>
{selected && (
<button onClick={e => { e.stopPropagation(); onDelete() }}
className="text-white hover:text-red-300 text-xs leading-none px-1">×</button>
)}
</div>
{/* Body: SVG (visual) + textarea (caret) stacked at same coords. */}
<div className="absolute left-0 right-0" style={{ top: HEADER_H_PX, height }}>
{/* WYSIWYG Hershey rendering */}
<svg viewBox={`0 0 ${widthMm} ${heightMm}`}
width={width} height={height}
preserveAspectRatio="none"
className="absolute inset-0 pointer-events-none">
{strokes.map((pts, i) => (
<polyline key={i}
points={pts.map(([x, y]) => `${x},${y}`).join(' ')}
fill="none" stroke="black" strokeWidth={strokePx / mmToPx(1)}
strokeLinecap="round" strokeLinejoin="round" />
))}
</svg>
{/* Editable layer: caret-only, transparent text. Lining up the
character cells exactly with Hershey is impossible (Hershey is
proportional, system mono is not), but a monospace placeholder
keeps the caret tracking close enough for editing. */}
<textarea ref={taRef}
value={block.text}
onChange={e => onChange({ text: e.target.value })}
onMouseDown={e => e.stopPropagation()}
spellCheck={false}
className="absolute inset-0 w-full bg-transparent border-none outline-none p-0 m-0
resize-none font-mono leading-tight"
style={{
color: 'transparent',
caretColor: selected ? '#4f46e5' : '#9ca3af',
fontSize: fontPx * 0.75, // visual approximation only
lineHeight: `${mmToPx(block.line_spacing_mm ?? block.font_size_mm * 1.6)}px`,
paddingTop: `${mmToPx(block.font_size_mm) * 0.15}px`,
}}
/>
</div>
{selected && (
<>
<div onMouseDown={onResizeMouseDown}
className="absolute -right-1 -bottom-1 w-3 h-3 bg-indigo-500 cursor-se-resize border border-white"
style={{ borderRadius: 1 }}
/>
{/* Floating format toolbar — pops above the box when selected. */}
<div onMouseDown={e => e.stopPropagation()}
className="absolute left-0 -top-7 flex items-center gap-2 bg-neutral-900/95 border border-neutral-700 rounded px-2 py-1 text-[10px] text-neutral-300 whitespace-nowrap shadow-lg">
<label className="flex items-center gap-1">
Line:
<input type="number" step={0.5} min={2} max={60}
value={(block.line_spacing_mm ?? block.font_size_mm * 1.6).toFixed(1)}
onChange={e => onChange({ line_spacing_mm: parseFloat(e.target.value) || (block.font_size_mm * 1.6) })}
className="w-12 px-1 py-0.5 rounded bg-neutral-800 border border-neutral-700 text-[10px] text-right" />
mm
</label>
<label className="flex items-center gap-1">
Font:
<input type="number" step={0.5} min={1} max={50}
value={block.font_size_mm.toFixed(1)}
onChange={e => onChange({ font_size_mm: Math.max(1, parseFloat(e.target.value) || block.font_size_mm) })}
className="w-12 px-1 py-0.5 rounded bg-neutral-800 border border-neutral-700 text-[10px] text-right" />
mm
</label>
</div>
</>
)}
</div>
)
}

View File

@@ -34,10 +34,6 @@ export async function getGcodeViz() {
return tracedInvoke('get_gcode_viz', {})
}
export async function getPassViz(passIndex, mode) {
return tracedInvoke('get_pass_viz', { passIndex, mode })
}
export async function exportAllGcode(gcodeConfig, outDir) {
return tracedInvoke('export_all_gcode', { gcodeConfig, outDir })
}
@@ -114,25 +110,6 @@ export async function printerStatusWs(printerUrl) {
return tracedInvoke('printer_status_ws', { printerUrl })
}
// ── Text source ────────────────────────────────────────────────────────────────
// Rasterises the provided blocks into a paper-sized image and sets it as
// the project source. Returns `ImageInfo` (same shape as `load_image`) so
// the frontend treats it like a fresh image load. `blocks` is an array of
// `{ text, font_size_mm, line_spacing_mm, x_mm, y_mm }`.
export async function setTextBlocks(blocks, paperWMm, paperHMm, dpi, strokeThicknessPx = 4) {
return tracedInvoke('set_text_blocks', {
blocks: blocks.map(b => ({
text: b.text,
font_size_mm: b.font_size_mm,
line_spacing_mm: b.line_spacing_mm ?? null,
x_mm: b.x_mm,
y_mm: b.y_mm,
})),
paperWMm, paperHMm, dpi,
strokeThicknessPx,
})
}
export async function exportDebugState(passConfigs) {
return tracedInvoke('export_debug_state', { passConfigs })
}

View File

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

View File

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

View File

@@ -2,7 +2,6 @@ pub mod detect;
pub mod hulls;
pub mod fill;
pub mod gcode;
pub mod text;
use std::time::Instant;
@@ -182,16 +181,6 @@ struct AppState {
image_rgb: Option<image::RgbImage>,
image_path: String,
passes: Vec<PassState>,
text_blocks: Vec<TextBlock>, // when non-empty, gcode output uses these in lieu of image pipeline
}
#[derive(Clone, Debug)]
struct TextBlock {
text: String,
font_size_mm: f32,
line_spacing_mm: Option<f32>,
x_mm: f32, // top-left of the block, in paper-relative mm
y_mm: f32,
}
#[derive(Default)]
@@ -206,7 +195,7 @@ struct PassState {
impl Default for AppState {
fn default() -> Self {
Self { image_rgb: None, image_path: String::new(), passes: Vec::new(), text_blocks: Vec::new() }
Self { image_rgb: None, image_path: String::new(), passes: Vec::new() }
}
}
@@ -1066,95 +1055,6 @@ fn build_all_gcode(st: &AppState, cfg: &gcode::GcodeConfig) -> Vec<(String, Stri
out
}
#[derive(Deserialize)]
pub struct TextBlockPayload {
pub text: String,
pub font_size_mm: f32,
pub line_spacing_mm: Option<f32>,
pub x_mm: f32,
pub y_mm: f32,
}
/// Replace the project's text blocks AND rasterize them into a paper-sized
/// RgbImage that becomes the project's source image. The graph pipeline
/// (Detection → Hull → Fill) then runs on it like any other image.
///
/// Returns the same `ImageInfo` shape as `load_image` so the frontend can
/// wire it through the existing post-load flow (set image, trigger
/// process_pass, etc).
///
/// `paper_w_mm` / `paper_h_mm` come from the user's gcode config so the
/// rasterized canvas matches the active paper size. `dpi` matches the
/// pipeline DPI knob so detection works at the same resolution.
#[tauri::command]
fn set_text_blocks(
blocks: Vec<TextBlockPayload>,
paper_w_mm: f32,
paper_h_mm: f32,
dpi: u32,
stroke_thickness_px: u32,
state: State<Mutex<AppState>>,
) -> Result<ImageInfo, String> {
let specs: Vec<text::TextBlockSpec> = blocks.into_iter().map(|p| text::TextBlockSpec {
text: p.text,
font_size_mm: p.font_size_mm,
line_spacing_mm: p.line_spacing_mm,
x_mm: p.x_mm,
y_mm: p.y_mm,
}).collect();
let img = text::rasterize_blocks(&specs, paper_w_mm, paper_h_mm, dpi, stroke_thickness_px.max(1));
let (w, h) = img.dimensions();
let preview_b64 = rgb_to_b64_jpeg(&img);
let mut st = state.lock().unwrap();
st.image_path = format!("(text source: {} block(s))", specs.len());
st.image_rgb = Some(img);
st.text_blocks = specs.into_iter().map(|s| TextBlock {
text: s.text, font_size_mm: s.font_size_mm,
line_spacing_mm: s.line_spacing_mm,
x_mm: s.x_mm, y_mm: s.y_mm,
}).collect();
for ps in &mut st.passes {
ps.hulls.clear();
ps.pen_results.clear();
ps.node_cache = NodeCache::default();
}
Ok(ImageInfo { width: w, height: h, path: st.image_path.clone(), preview_b64 })
}
/// Render all text blocks at their absolute positions and return the
/// resulting strokes (in mm) plus their bounding box. Used by the Source
/// tab's viewport for live preview.
#[derive(Serialize)]
pub struct TextPreview {
pub strokes: Vec<Vec<(f32, f32)>>,
pub bounds: [f32; 4], // [x_min, y_min, x_max, y_max]
}
#[tauri::command]
fn get_text_preview(state: State<Mutex<AppState>>) -> Result<TextPreview, String> {
let st = state.lock().unwrap();
let mut strokes: Vec<Vec<(f32, f32)>> = Vec::new();
for tb in &st.text_blocks {
if tb.text.trim().is_empty() { continue; }
strokes.extend(text::render_text(
&tb.text, tb.font_size_mm, tb.line_spacing_mm,
tb.x_mm, tb.y_mm,
));
}
let mut b = [f32::MAX, f32::MAX, f32::MIN, f32::MIN];
for s in &strokes {
for &(x, y) in s {
b[0] = b[0].min(x); b[1] = b[1].min(y);
b[2] = b[2].max(x); b[3] = b[3].max(y);
}
}
if strokes.is_empty() { b = [0.0, 0.0, 0.0, 0.0]; }
Ok(TextPreview { strokes, bounds: b })
}
#[tauri::command]
fn export_all_gcode(
gcode_config: GcodeConfigPayload,
@@ -2019,62 +1919,6 @@ fn compute_hull_holes(
hull_holes
}
/// Return a contour SVG for an already-processed pass.
#[tauri::command]
fn get_pass_viz(pass_index: usize, mode: String, state: State<Mutex<AppState>>) -> Result<String, String> {
let st = state.lock().unwrap();
let rgb = st.image_rgb.as_ref().ok_or("No image loaded")?;
let (w, h) = rgb.dimensions();
let pass = st.passes.get(pass_index).ok_or("Pass not processed yet")?;
fn contour_path_d(contour: &[(u32, u32)]) -> Option<String> {
if contour.len() < 2 { return None; }
let mut d = format!("M{:.1},{:.1}", contour[0].0 as f32, contour[0].1 as f32);
for &(x, y) in &contour[1..] { d.push_str(&format!("L{:.1},{:.1}", x as f32, y as f32)); }
d.push('Z');
Some(d)
}
fn hole_path_d(hole: &std::collections::HashSet<(u32, u32)>) -> Option<String> {
use std::collections::HashSet;
let contour = hulls::trace_contour(hole as &HashSet<_>);
if contour.len() < 2 { return None; }
let mut d = format!("M{:.1},{:.1}", contour[0].0 as f32, contour[0].1 as f32);
for &(x, y) in &contour[1..] { d.push_str(&format!("L{:.1},{:.1}", x as f32, y as f32)); }
d.push('Z');
Some(d)
}
let hull_holes = compute_hull_holes(&pass.hulls, w, h);
match mode.as_str() {
"contours" => {
let mut svg = format!(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" viewBox="0 0 {w} {h}"><rect width="{w}" height="{h}" fill="#0f0f0f"/>"##
);
for hull in &pass.hulls {
let Some(d) = contour_path_d(&hull.contour) else { continue };
let (r, g, b) = hash_color(hull.id);
svg.push_str(&format!(
r#"<path d="{d}" fill="none" stroke="rgb({r},{g},{b})" stroke-width="1.5" stroke-linejoin="round"/>"#
));
if let Some(holes) = hull_holes.get(&hull.id) {
for hole in holes {
if let Some(hd) = hole_path_d(hole) {
svg.push_str(&format!(
r#"<path d="{hd}" fill="none" stroke="rgb({r},{g},{b})" stroke-width="1.5" stroke-linejoin="round"/>"#
));
}
}
}
}
svg.push_str("</svg>");
Ok(B64.encode(svg.as_bytes()))
}
_ => Err(format!("Unknown viz mode: {mode}")),
}
}
/// Generate an SVG preview of all passes' fill strokes.
#[tauri::command]
fn get_all_strokes(
@@ -2334,7 +2178,6 @@ mod blocking_tests {
image_rgb: Some(rgb.clone()),
image_path: String::new(),
passes: Vec::new(),
text_blocks: Vec::new(),
}));
// Clone image and release lock — this is exactly what the command handler does.
@@ -2809,10 +2652,7 @@ pub fn run() {
process_pass,
get_all_strokes,
get_gcode_viz,
get_pass_viz,
export_all_gcode,
set_text_blocks,
get_text_preview,
get_all_gcode,
upload_to_printer,
upload_and_run,

View File

@@ -1,241 +0,0 @@
// 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());
}
}