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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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 : [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
162
src/lib.rs
162
src/lib.rs
@@ -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,
|
||||
|
||||
241
src/text.rs
241
src/text.rs
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user