mm-coord pipeline: fills/pen/gcode in mm, DPI per Source + per Kernel
Major refactor: everything downstream of the Hull stage now operates
in mm coords on float polygons + strokes, with DPI confined to the
two stages where it actually matters (Source sampling, Kernel
processing).
Hull stage:
- Drop RDP simplification entirely. The Hull contour is just the raw
pixel boundary trace (cast to f32 for the `simplified` field that
legacy code still references). Polygon vertex density was
DPI-dependent through the rdp_epsilon-in-pixels knob; now there's
no epsilon at all.
- New `MmHull { contour, holes, area_mm2, bounds, avg_color }` —
polygon-only hull in mm. `MmHull::from_pixel_hull(h, px_per_mm)`
divides pixel coords by px_per_mm to land in mm.
Fill stage:
- Each existing pixel-based fill function is wrapped by a `*_mm`
variant that takes MmHull, locally rasterizes at FILL_INTERNAL_PX_PER_MM
(= 10 px/mm = 254 DPI; finer than any plotter resolves), runs the
pixel fill, converts output strokes back to mm. DPI-independent
output by construction.
- `outline_mm` is polygon-pure (no raster). Gradient fills must
rasterize at the source DPI to align with their response-map input.
- FillResult.strokes are now mm coords; this is the new contract for
everything downstream.
Pen / gcode / viewport / previews:
- gcode export drops pixel→mm scaling; strokes are mm, just apply
paper/image offsets and an `img_w_mm/paper_w_mm` ratio for image
scale. Two new tests pin this contract.
- Viewport's chunked stroke renderer paints onto an offscreen at
fixed `paper × 10 px/mm`, drawing strokes directly in mm via
ctx.scale. Line width = pen_tip_mm directly.
- render_pen_preview / render_fill_preview / render_hull_preview
all render at fixed `paper × 5 px/mm` (PREVIEW_PX_PER_MM) — Hull,
Fill, and Pen card thumbnails are now visually identical regardless
of project DPI, because their underlying data is DPI-independent.
DPI relocation:
- Project-wide DPI slider gone from the sidebar.
- DPI now lives on each Source node card. App.jsx derives the project
canvas DPI as max(source.dpi) so the highest-detail source isn't
limited by the lowest.
- Each Kernel node has its own `kernel_dpi` slider (0 / null = use
canvas DPI). When set, the kernel resamples its input down to that
DPI internally, applies the layer, and upsamples the output back to
canvas dims so downstream nodes see consistent map sizes.
`apply_layer_with_dpi` in detect.rs handles the resample math;
`evaluate_graph` now takes canvas_dpi as a parameter.
Tests:
- New: MmHull conversion + scaling, rasterize_mm_hull bounds,
outline_mm closed-stroke shape, parallel_hatch_mm mm range,
parallel_hatch_mm DPI-independence (150 vs 600 DPI source produces
matching mm strokes), px_strokes_to_mm correctness.
- Updated: gcode tests for mm semantics + image-scale-vs-paper.
- Removed: square_rdp_yields_four_corners,
single_line_rdp_yields_two_endpoints (RDP gone).
- Project file no longer carries top-level dpi (lives on Source
nodes); old projects deserialize fine.
This commit is contained in:
@@ -31,7 +31,6 @@ export default function App() {
|
|||||||
|
|
||||||
const [sidebarWidth, setSidebarWidth] = useState(320)
|
const [sidebarWidth, setSidebarWidth] = useState(320)
|
||||||
const [nodeWidth, setNodeWidth] = useState(450)
|
const [nodeWidth, setNodeWidth] = useState(450)
|
||||||
const [dpi, setDpi] = useState(150)
|
|
||||||
const [projectPath, setProjectPath] = useState(null) // null = unsaved
|
const [projectPath, setProjectPath] = useState(null) // null = unsaved
|
||||||
const resizing = useRef(false)
|
const resizing = useRef(false)
|
||||||
|
|
||||||
@@ -42,10 +41,12 @@ export default function App() {
|
|||||||
// deps; without memoisation, every gcodeConfig drag tick would
|
// deps; without memoisation, every gcodeConfig drag tick would
|
||||||
// recreate the object, recreate `draw`, and restart Viewport's
|
// recreate the object, recreate `draw`, and restart Viewport's
|
||||||
// chunked-stroke renderer mid-render.
|
// chunked-stroke renderer mid-render.
|
||||||
|
// 150 DPI is just a sensible default for components that want a
|
||||||
|
// canvas-pixel size — actual processing DPI lives per-Source now.
|
||||||
const canvasDims = useMemo(() => ({
|
const canvasDims = useMemo(() => ({
|
||||||
width: Math.round(gcodeConfig.paper_w_mm * dpi / 25.4),
|
width: Math.round(gcodeConfig.paper_w_mm * 150 / 25.4),
|
||||||
height: Math.round(gcodeConfig.paper_h_mm * dpi / 25.4),
|
height: Math.round(gcodeConfig.paper_h_mm * 150 / 25.4),
|
||||||
}), [gcodeConfig.paper_w_mm, gcodeConfig.paper_h_mm, dpi])
|
}), [gcodeConfig.paper_w_mm, gcodeConfig.paper_h_mm])
|
||||||
// Has a Source node with a loaded file path? Used for the empty-state overlay.
|
// Has a Source node with a loaded file path? Used for the empty-state overlay.
|
||||||
const anyLoadedSource = (passes[0]?.graph?.nodes ?? [])
|
const anyLoadedSource = (passes[0]?.graph?.nodes ?? [])
|
||||||
.some(n => n.kind === 'Source' && n.file_path)
|
.some(n => n.kind === 'Source' && n.file_path)
|
||||||
@@ -98,10 +99,8 @@ export default function App() {
|
|||||||
|
|
||||||
// Always-fresh refs so debounced callbacks never close over stale state
|
// Always-fresh refs so debounced callbacks never close over stale state
|
||||||
const passesRef = useRef(passes)
|
const passesRef = useRef(passes)
|
||||||
const dpiRef = useRef(dpi)
|
|
||||||
const gcodeConfigRef = useRef(gcodeConfig)
|
const gcodeConfigRef = useRef(gcodeConfig)
|
||||||
passesRef.current = passes
|
passesRef.current = passes
|
||||||
dpiRef.current = dpi
|
|
||||||
gcodeConfigRef.current = gcodeConfig
|
gcodeConfigRef.current = gcodeConfig
|
||||||
|
|
||||||
// Debounce timers: { 'idx-detection': timer, 'idx-fill': timer }
|
// Debounce timers: { 'idx-detection': timer, 'idx-fill': timer }
|
||||||
@@ -155,14 +154,20 @@ export default function App() {
|
|||||||
try {
|
try {
|
||||||
// Backend letterboxes every Source into the paper canvas, so we hand
|
// Backend letterboxes every Source into the paper canvas, so we hand
|
||||||
// it the paper dimensions directly — no per-image scaling knob anymore.
|
// it the paper dimensions directly — no per-image scaling knob anymore.
|
||||||
|
// DPI lives per-Source now; the project canvas runs at the highest
|
||||||
|
// source DPI so no source loses detail. Default 150 if no Source.
|
||||||
const paperW = gcodeConfigRef.current.paper_w_mm
|
const paperW = gcodeConfigRef.current.paper_w_mm
|
||||||
const paperH = gcodeConfigRef.current.paper_h_mm
|
const paperH = gcodeConfigRef.current.paper_h_mm
|
||||||
|
const projectDpi = Math.max(150, ...(pass.graph?.nodes ?? [])
|
||||||
|
.filter(n => n.kind === 'Source')
|
||||||
|
.map(n => n.dpi ?? 150))
|
||||||
const result = await tauri.processPass({
|
const result = await tauri.processPass({
|
||||||
pass_index: idx,
|
pass_index: idx,
|
||||||
graph: pass.graph,
|
graph: pass.graph,
|
||||||
dpi: dpiRef.current,
|
dpi: projectDpi,
|
||||||
img_w_mm: paperW,
|
img_w_mm: paperW,
|
||||||
img_h_mm: paperH,
|
img_h_mm: paperH,
|
||||||
|
pen_tip_mm: gcodeConfigRef.current.pen_tip_mm ?? 0.5,
|
||||||
})
|
})
|
||||||
const js_process = Math.round(performance.now() - t0)
|
const js_process = Math.round(performance.now() - t0)
|
||||||
setPerfData(pd => ({ ...(pd ?? {}), process: result.timings, js_process }))
|
setPerfData(pd => ({ ...(pd ?? {}), process: result.timings, js_process }))
|
||||||
@@ -194,7 +199,7 @@ export default function App() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
scheduleProcess()
|
scheduleProcess()
|
||||||
}, [dpi, gcodeConfig.paper_w_mm, gcodeConfig.paper_h_mm])
|
}, [gcodeConfig.paper_w_mm, gcodeConfig.paper_h_mm, gcodeConfig.pen_tip_mm])
|
||||||
|
|
||||||
// ── Export ─────────────────────────────────────────────────────────────────
|
// ── Export ─────────────────────────────────────────────────────────────────
|
||||||
async function exportAll() {
|
async function exportAll() {
|
||||||
@@ -225,7 +230,6 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const json = serialize({
|
const json = serialize({
|
||||||
dpi,
|
|
||||||
nodeWidth,
|
nodeWidth,
|
||||||
graph: passes[0].graph,
|
graph: passes[0].graph,
|
||||||
gcodeConfig,
|
gcodeConfig,
|
||||||
@@ -249,7 +253,6 @@ export default function App() {
|
|||||||
|
|
||||||
// Apply non-image state immediately
|
// Apply non-image state immediately
|
||||||
if (restored.gcodeConfig) setGcodeConfig(restored.gcodeConfig)
|
if (restored.gcodeConfig) setGcodeConfig(restored.gcodeConfig)
|
||||||
if (restored.dpi) setDpi(restored.dpi)
|
|
||||||
if (restored.nodeWidth) setNodeWidth(restored.nodeWidth)
|
if (restored.nodeWidth) setNodeWidth(restored.nodeWidth)
|
||||||
|
|
||||||
// Replace the pass graph
|
// Replace the pass graph
|
||||||
@@ -340,83 +343,83 @@ export default function App() {
|
|||||||
|
|
||||||
<div className="px-3 py-2 space-y-4">
|
<div className="px-3 py-2 space-y-4">
|
||||||
|
|
||||||
{/* Graph */}
|
{/* ── Pipeline view: graph layout, DPI, paper size ──────────── */}
|
||||||
<div className="space-y-0.5">
|
{viewMode === 'pipeline' && (<>
|
||||||
<p className="text-neutral-500 text-xs font-semibold uppercase tracking-wider mb-1.5">Graph</p>
|
<div className="space-y-0.5">
|
||||||
<Slider label="Card width" value={nodeWidth} min={160} max={800} step={10}
|
<p className="text-neutral-500 text-xs font-semibold uppercase tracking-wider mb-1.5">Graph</p>
|
||||||
onChange={v => setNodeWidth(v)} unit="px" />
|
<Slider label="Card width" value={nodeWidth} min={160} max={800} step={10}
|
||||||
</div>
|
onChange={v => setNodeWidth(v)} unit="px" />
|
||||||
|
|
||||||
{/* Pipeline */}
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<p className="text-neutral-500 text-xs font-semibold uppercase tracking-wider mb-1.5">Pipeline</p>
|
|
||||||
<Slider label="DPI" value={dpi} min={50} max={600} step={25}
|
|
||||||
onChange={v => setDpi(v)} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Paper */}
|
|
||||||
<div>
|
|
||||||
<p className="text-neutral-500 text-xs font-semibold uppercase tracking-wider mb-1.5">Paper</p>
|
|
||||||
<div className="flex gap-1 flex-wrap mb-1">
|
|
||||||
{PAPER_SIZES.map(ps => {
|
|
||||||
const isPortrait = Math.abs(gcodeConfig.paper_w_mm - ps.w) < 1 && Math.abs(gcodeConfig.paper_h_mm - ps.h) < 1
|
|
||||||
const isLandscape = Math.abs(gcodeConfig.paper_w_mm - ps.h) < 1 && Math.abs(gcodeConfig.paper_h_mm - ps.w) < 1
|
|
||||||
return (
|
|
||||||
<button key={ps.name}
|
|
||||||
onClick={() => {
|
|
||||||
const portrait = gcodeConfig.paper_w_mm <= gcodeConfig.paper_h_mm
|
|
||||||
const next = portrait
|
|
||||||
? { paper_w_mm: ps.w, paper_h_mm: ps.h }
|
|
||||||
: { paper_w_mm: ps.h, paper_h_mm: ps.w }
|
|
||||||
setGcode({ ...next, ...centerPaperOnBed({ ...gcodeConfig, ...next }) })
|
|
||||||
}}
|
|
||||||
className={`px-2 py-0.5 rounded text-xs transition-colors ${
|
|
||||||
isPortrait || isLandscape
|
|
||||||
? 'bg-indigo-700 text-white' : 'bg-neutral-800 text-neutral-400 hover:bg-neutral-700'
|
|
||||||
}`}
|
|
||||||
>{ps.name}</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
const next = { paper_w_mm: gcodeConfig.paper_h_mm, paper_h_mm: gcodeConfig.paper_w_mm }
|
|
||||||
setGcode({ ...next, ...centerPaperOnBed({ ...gcodeConfig, ...next }) })
|
|
||||||
}}
|
|
||||||
title={gcodeConfig.paper_w_mm <= gcodeConfig.paper_h_mm ? 'Switch to landscape' : 'Switch to portrait'}
|
|
||||||
className="px-2 py-0.5 rounded text-xs bg-neutral-800 text-neutral-400 hover:bg-neutral-700 transition-colors"
|
|
||||||
>Rotate</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Plotter */}
|
<div>
|
||||||
<div className="space-y-0.5">
|
<p className="text-neutral-500 text-xs font-semibold uppercase tracking-wider mb-1.5">Paper</p>
|
||||||
<div className="flex items-center gap-2 mb-1.5">
|
<div className="flex gap-1 flex-wrap mb-1">
|
||||||
<p className="text-neutral-500 text-xs font-semibold uppercase tracking-wider">Plotter</p>
|
{PAPER_SIZES.map(ps => {
|
||||||
<span className="w-1.5 h-1.5 rounded-full shrink-0 bg-amber-500" />
|
const isPortrait = Math.abs(gcodeConfig.paper_w_mm - ps.w) < 1 && Math.abs(gcodeConfig.paper_h_mm - ps.h) < 1
|
||||||
|
const isLandscape = Math.abs(gcodeConfig.paper_w_mm - ps.h) < 1 && Math.abs(gcodeConfig.paper_h_mm - ps.w) < 1
|
||||||
|
return (
|
||||||
|
<button key={ps.name}
|
||||||
|
onClick={() => {
|
||||||
|
const portrait = gcodeConfig.paper_w_mm <= gcodeConfig.paper_h_mm
|
||||||
|
const next = portrait
|
||||||
|
? { paper_w_mm: ps.w, paper_h_mm: ps.h }
|
||||||
|
: { paper_w_mm: ps.h, paper_h_mm: ps.w }
|
||||||
|
setGcode({ ...next, ...centerPaperOnBed({ ...gcodeConfig, ...next }) })
|
||||||
|
}}
|
||||||
|
className={`px-2 py-0.5 rounded text-xs transition-colors ${
|
||||||
|
isPortrait || isLandscape
|
||||||
|
? 'bg-indigo-700 text-white' : 'bg-neutral-800 text-neutral-400 hover:bg-neutral-700'
|
||||||
|
}`}
|
||||||
|
>{ps.name}</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const next = { paper_w_mm: gcodeConfig.paper_h_mm, paper_h_mm: gcodeConfig.paper_w_mm }
|
||||||
|
setGcode({ ...next, ...centerPaperOnBed({ ...gcodeConfig, ...next }) })
|
||||||
|
}}
|
||||||
|
title={gcodeConfig.paper_w_mm <= gcodeConfig.paper_h_mm ? 'Switch to landscape' : 'Switch to portrait'}
|
||||||
|
className="px-2 py-0.5 rounded text-xs bg-neutral-800 text-neutral-400 hover:bg-neutral-700 transition-colors"
|
||||||
|
>Rotate</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Slider label="Pen speed" value={gcodeConfig.feed_draw} min={100} max={5000} step={100}
|
</>)}
|
||||||
onChange={v => setGcode({ feed_draw: v })} unit=" mm/m" />
|
|
||||||
<Slider label="Travel speed" value={gcodeConfig.feed_travel} min={100} max={10000} step={100}
|
|
||||||
onChange={v => setGcode({ feed_travel: v })} unit=" mm/m" />
|
|
||||||
<Slider label="Pen lift height" value={gcodeConfig.pen_up_z_mm ?? 2} min={0.5} max={5} step={0.1} unit="mm"
|
|
||||||
onChange={v => setGcode({ pen_up_z_mm: v })} />
|
|
||||||
<Slider label="Pen settle" value={gcodeConfig.pen_dwell_ms ?? 250} min={0} max={1000} step={10} unit="ms"
|
|
||||||
onChange={v => setGcode({ pen_dwell_ms: v })} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Calibration: corner jog + axis-scale */}
|
{/* ── G-code view: plotter motion params, corner-jog, export ── */}
|
||||||
<CalibrationButtons gcodeConfig={gcodeConfig} imgSize={canvasDims} setStatus={setGlobalStatus} />
|
{viewMode === 'gcode' && (<>
|
||||||
<CalibrationAxis printerUrl={gcodeConfig.printer_url} setStatus={setGlobalStatus} />
|
<div className="space-y-0.5">
|
||||||
|
<div className="flex items-center gap-2 mb-1.5">
|
||||||
|
<p className="text-neutral-500 text-xs font-semibold uppercase tracking-wider">Plotter</p>
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full shrink-0 bg-amber-500" />
|
||||||
|
</div>
|
||||||
|
<Slider label="Pen speed" value={gcodeConfig.feed_draw} min={100} max={5000} step={100}
|
||||||
|
onChange={v => setGcode({ feed_draw: v })} unit=" mm/m" />
|
||||||
|
<Slider label="Travel speed" value={gcodeConfig.feed_travel} min={100} max={10000} step={100}
|
||||||
|
onChange={v => setGcode({ feed_travel: v })} unit=" mm/m" />
|
||||||
|
<Slider label="Pen lift height" value={gcodeConfig.pen_up_z_mm ?? 2} min={0.5} max={5} step={0.1} unit="mm"
|
||||||
|
onChange={v => setGcode({ pen_up_z_mm: v })} />
|
||||||
|
<Slider label="Pen settle" value={gcodeConfig.pen_dwell_ms ?? 250} min={0} max={1000} step={10} unit="ms"
|
||||||
|
onChange={v => setGcode({ pen_dwell_ms: v })} />
|
||||||
|
<Slider label="Pen tip dia" value={gcodeConfig.pen_tip_mm ?? 0.5} min={0.05} max={3} step={0.05} unit="mm"
|
||||||
|
onChange={v => setGcode({ pen_tip_mm: v })} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Export & upload */}
|
<CalibrationButtons gcodeConfig={gcodeConfig} imgSize={canvasDims} setStatus={setGlobalStatus} />
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-neutral-500 text-xs font-semibold uppercase tracking-wider">Output</p>
|
<div className="space-y-2">
|
||||||
<button onClick={exportAll} disabled={!hasOutput}
|
<p className="text-neutral-500 text-xs font-semibold uppercase tracking-wider">Output</p>
|
||||||
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">
|
<button onClick={exportAll} disabled={!hasOutput}
|
||||||
Export G-code to folder
|
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">
|
||||||
</button>
|
Export G-code to folder
|
||||||
<p className="text-xs text-neutral-600">Use the <span className="text-emerald-500">Printer</span> tab to upload & run.</p>
|
</button>
|
||||||
</div>
|
<p className="text-xs text-neutral-600">Use the <span className="text-emerald-500">Printer</span> tab to upload & run.</p>
|
||||||
|
</div>
|
||||||
|
</>)}
|
||||||
|
|
||||||
|
{/* ── Printer view: axis-scale calibration ──────────────────── */}
|
||||||
|
{viewMode === 'printer' && (
|
||||||
|
<CalibrationAxis printerUrl={gcodeConfig.printer_url} setStatus={setGlobalStatus} />
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -557,6 +557,8 @@ export default function NodeGraph({ graph, onChange, nodePreviews, nodeWidth = 2
|
|||||||
{node.file_path}
|
{node.file_path}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<Slider label="Sampling DPI" value={node.dpi ?? 150} min={50} max={600} step={25}
|
||||||
|
onChange={v => updateNode(node.id, { dpi: v })} />
|
||||||
</>)}
|
</>)}
|
||||||
|
|
||||||
{node.kind === 'Kernel' && (<>
|
{node.kind === 'Kernel' && (<>
|
||||||
@@ -619,6 +621,10 @@ export default function NodeGraph({ graph, onChange, nodePreviews, nodeWidth = 2
|
|||||||
Invert
|
Invert
|
||||||
</label>
|
</label>
|
||||||
</>)}
|
</>)}
|
||||||
|
{/* Per-kernel internal DPI: 0 = canvas DPI (no resample),
|
||||||
|
lower values downsample input for speed. */}
|
||||||
|
<Slider label="Kernel DPI" value={node.kernel_dpi ?? 0} min={0} max={600} step={25}
|
||||||
|
onChange={v => updateNode(node.id, { kernel_dpi: v > 0 ? v : null })} />
|
||||||
</>)}
|
</>)}
|
||||||
|
|
||||||
{node.kind === 'Combine' && (<>
|
{node.kind === 'Combine' && (<>
|
||||||
@@ -647,8 +653,6 @@ export default function NodeGraph({ graph, onChange, nodePreviews, nodeWidth = 2
|
|||||||
onChange={v => updateNode(node.id, { threshold: v })} />
|
onChange={v => updateNode(node.id, { threshold: v })} />
|
||||||
<Slider label="Min area" value={node.min_area ?? 4} min={1} max={5000} step={1}
|
<Slider label="Min area" value={node.min_area ?? 4} min={1} max={5000} step={1}
|
||||||
onChange={v => updateNode(node.id, { min_area: v })} />
|
onChange={v => updateNode(node.id, { min_area: v })} />
|
||||||
<Slider label="RDP ε" value={node.rdp_epsilon ?? 1.5} min={0.1} max={10} step={0.1}
|
|
||||||
onChange={v => updateNode(node.id, { rdp_epsilon: v })} />
|
|
||||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: 4, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
<span style={{ fontSize: 10, color: '#6b7280' }}>Connectivity</span>
|
<span style={{ fontSize: 10, color: '#6b7280' }}>Connectivity</span>
|
||||||
{['four','eight'].map(c => (
|
{['four','eight'].map(c => (
|
||||||
@@ -684,7 +688,7 @@ export default function NodeGraph({ graph, onChange, nodePreviews, nodeWidth = 2
|
|||||||
>{s}</button>
|
>{s}</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<Slider label="Spacing" value={node.spacing ?? 5} min={1} max={50} step={0.5} unit="px"
|
<Slider label="Spacing" value={node.spacing ?? 2} min={0.1} max={20} step={0.1} unit="mm"
|
||||||
onChange={v => updateNode(node.id, { spacing: v })} />
|
onChange={v => updateNode(node.id, { spacing: v })} />
|
||||||
{FILL_USES_ANGLE.has(node.strategy ?? 'hatch') && (
|
{FILL_USES_ANGLE.has(node.strategy ?? 'hatch') && (
|
||||||
<Slider label="Angle" value={node.angle ?? 0} min={0} max={360} step={1} unit="°"
|
<Slider label="Angle" value={node.angle ?? 0} min={0} max={360} step={1} unit="°"
|
||||||
|
|||||||
@@ -128,17 +128,16 @@ export default function Viewport({ imageB64, strokes, imgSize, viewMode, gcodeCo
|
|||||||
if (off && imgSize) {
|
if (off && imgSize) {
|
||||||
ctx.drawImage(off, ox, oy, iw * scale, ih * scale)
|
ctx.drawImage(off, ox, oy, iw * scale, ih * scale)
|
||||||
}
|
}
|
||||||
} else if (viewMode === 'gcode') {
|
} else if (viewMode === 'gcode' && L.mode === 'gcode') {
|
||||||
if (svgImg) svgImg.style.display = 'none'
|
if (svgImg) svgImg.style.display = 'none'
|
||||||
// Bed/paper/image rectangles are drawn by the SVG overlay below;
|
// Strokes live in mm inside the image rect; the SVG overlay draws
|
||||||
// canvas only renders the strokes inside the image rect.
|
// the bed/paper/image rectangles, this just paints the rasterized
|
||||||
if (imgSize) {
|
// strokes onto the image rect.
|
||||||
ctx.fillStyle = '#f5f0e8'
|
ctx.fillStyle = '#f5f0e8'
|
||||||
ctx.fillRect(ox, oy, iw * scale, ih * scale)
|
ctx.fillRect(L.image_x, L.image_y, L.image_w_screen, L.image_h_screen)
|
||||||
}
|
|
||||||
const off = offscreenRef.current
|
const off = offscreenRef.current
|
||||||
if (off && imgSize) {
|
if (off) {
|
||||||
ctx.drawImage(off, ox, oy, iw * scale, ih * scale)
|
ctx.drawImage(off, L.image_x, L.image_y, L.image_w_screen, L.image_h_screen)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// All raster views (source=JPEG, detection=JPEG, contours=SVG) go through ctx.drawImage
|
// All raster views (source=JPEG, detection=JPEG, contours=SVG) go through ctx.drawImage
|
||||||
@@ -180,7 +179,7 @@ export default function Viewport({ imageB64, strokes, imgSize, viewMode, gcodeCo
|
|||||||
chunkRef.current.raf = null
|
chunkRef.current.raf = null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!strokes || !imgSize || (viewMode !== 'gcode' && viewMode !== 'fill')) {
|
if (!strokes || viewMode !== 'gcode') {
|
||||||
offscreenRef.current = null
|
offscreenRef.current = null
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -190,18 +189,28 @@ export default function Viewport({ imageB64, strokes, imgSize, viewMode, gcodeCo
|
|||||||
p.strokes.map(s => ({ color: p.color, points: s }))
|
p.strokes.map(s => ({ color: p.color, points: s }))
|
||||||
)
|
)
|
||||||
|
|
||||||
// Offscreen canvas sized to stroke coordinate space (pipeline dims, after DPI scaling).
|
// Offscreen canvas sized to paper × INTERNAL_PX_PER_MM. Strokes are
|
||||||
const sw = strokes.img_width ?? imgSize.width
|
// mm coords drawn through an octx.scale that maps mm → offscreen px.
|
||||||
const sh = strokes.img_height ?? imgSize.height
|
// Then drawImage scales the offscreen onto the image rect on screen
|
||||||
|
// — pan/zoom doesn't invalidate this canvas.
|
||||||
|
const INTERNAL_PX_PER_MM = 10
|
||||||
|
const paperWmm = strokes.paper_w_mm ?? 210
|
||||||
|
const paperHmm = strokes.paper_h_mm ?? 297
|
||||||
|
const sw = Math.max(1, Math.round(paperWmm * INTERNAL_PX_PER_MM))
|
||||||
|
const sh = Math.max(1, Math.round(paperHmm * INTERNAL_PX_PER_MM))
|
||||||
const off = document.createElement('canvas')
|
const off = document.createElement('canvas')
|
||||||
off.width = sw * 4
|
off.width = sw
|
||||||
off.height = sh * 4
|
off.height = sh
|
||||||
const octx = off.getContext('2d')
|
const octx = off.getContext('2d')
|
||||||
octx.fillStyle = '#f5f0e8'
|
octx.fillStyle = '#f5f0e8'
|
||||||
octx.fillRect(0, 0, off.width, off.height)
|
octx.fillRect(0, 0, off.width, off.height)
|
||||||
octx.scale(4, 4)
|
octx.scale(INTERNAL_PX_PER_MM, INTERNAL_PX_PER_MM)
|
||||||
offscreenRef.current = off
|
offscreenRef.current = off
|
||||||
|
|
||||||
|
// Line width directly in mm — physical pen tip diameter. The
|
||||||
|
// octx.scale transform converts to offscreen pixels.
|
||||||
|
const lineWidthMm = Math.max(0.05, gcodeConfig?.pen_tip_mm ?? 0.5)
|
||||||
|
|
||||||
chunkRef.current = { flat, idx: 0, raf: null }
|
chunkRef.current = { flat, idx: 0, raf: null }
|
||||||
|
|
||||||
function drawChunk() {
|
function drawChunk() {
|
||||||
@@ -211,32 +220,13 @@ 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)
|
||||||
|
|
||||||
if (viewMode === 'gcode') {
|
// Pen-color batching: consecutive same-color strokes share one
|
||||||
// Debug view: every stroke gets its own hue (golden-ratio cycle for
|
// beginPath for perf. Color comes from each PenOutput's color.
|
||||||
// maximal visual separation). One beginPath per stroke since each has
|
let i = idx
|
||||||
// a unique strokeStyle.
|
while (i < end) {
|
||||||
octx.lineWidth = 1.5
|
|
||||||
octx.lineCap = 'round'
|
|
||||||
for (let i = idx; i < end; i++) {
|
|
||||||
const pts = flat[i].points
|
|
||||||
if (pts.length < 2) continue
|
|
||||||
const hue = (i * 137.508) % 360
|
|
||||||
octx.strokeStyle = `hsl(${hue.toFixed(1)}, 80%, 50%)`
|
|
||||||
octx.beginPath()
|
|
||||||
octx.moveTo(pts[0][0], pts[0][1])
|
|
||||||
for (let k = 1; k < pts.length; k++) {
|
|
||||||
octx.lineTo(pts[k][0], pts[k][1])
|
|
||||||
}
|
|
||||||
octx.stroke()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Fill view: pen-color batching (consecutive same-color strokes
|
|
||||||
// share one beginPath for performance).
|
|
||||||
let i = idx
|
|
||||||
while (i < end) {
|
|
||||||
const [r, g, b] = flat[i].color
|
const [r, g, b] = flat[i].color
|
||||||
octx.strokeStyle = `rgb(${r},${g},${b})`
|
octx.strokeStyle = `rgb(${r},${g},${b})`
|
||||||
octx.lineWidth = 1.5
|
octx.lineWidth = lineWidthMm
|
||||||
octx.lineCap = 'round'
|
octx.lineCap = 'round'
|
||||||
octx.beginPath()
|
octx.beginPath()
|
||||||
let j = i
|
let j = i
|
||||||
@@ -255,7 +245,6 @@ export default function Viewport({ imageB64, strokes, imgSize, viewMode, gcodeCo
|
|||||||
}
|
}
|
||||||
octx.stroke()
|
octx.stroke()
|
||||||
i = j
|
i = j
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
state.idx = end
|
state.idx = end
|
||||||
@@ -276,7 +265,12 @@ export default function Viewport({ imageB64, strokes, imgSize, viewMode, gcodeCo
|
|||||||
chunkRef.current.raf = null
|
chunkRef.current.raf = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [strokes, imgSize, viewMode, draw])
|
}, [strokes, imgSize, viewMode, draw,
|
||||||
|
gcodeConfig?.pen_tip_mm, gcodeConfig?.paper_w_mm])
|
||||||
|
// pen_tip_mm + paper_w_mm: only fields that affect the offscreen
|
||||||
|
// line width — listed individually so changes here re-render the
|
||||||
|
// strokes, but unrelated gcodeConfig drag mutations (offsets,
|
||||||
|
// img_w_mm) do NOT.
|
||||||
|
|
||||||
useEffect(() => { draw() }, [draw])
|
useEffect(() => { draw() }, [draw])
|
||||||
|
|
||||||
|
|||||||
@@ -21,13 +21,15 @@ const MIGRATIONS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
// ── Serialize ──────────────────────────────────────────────────────────────────
|
// ── Serialize ──────────────────────────────────────────────────────────────────
|
||||||
export function serialize({ imagePath, dpi, nodeWidth, graph, gcodeConfig }) {
|
export function serialize({ imagePath, nodeWidth, graph, gcodeConfig }) {
|
||||||
|
// dpi used to live here; now per-Source on the graph node, so just stops
|
||||||
|
// being emitted. Old projects with top-level `dpi` deserialize fine —
|
||||||
|
// the Source nodes already have their own dpi default.
|
||||||
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,
|
|
||||||
node_width: nodeWidth,
|
node_width: nodeWidth,
|
||||||
graph,
|
graph,
|
||||||
gcode: gcodeConfig,
|
gcode: gcodeConfig,
|
||||||
@@ -70,7 +72,6 @@ export function deserialize(json, { migrations: migs = MIGRATIONS, currentVersio
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
imagePath: doc.image_path ?? null,
|
imagePath: doc.image_path ?? null,
|
||||||
dpi: doc.dpi ?? 150,
|
|
||||||
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,
|
||||||
|
|||||||
@@ -96,11 +96,6 @@ describe('serialize', () => {
|
|||||||
expect(doc.image_path).toBeNull()
|
expect(doc.image_path).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('includes dpi', () => {
|
|
||||||
const doc = JSON.parse(serialize(FULL_STATE))
|
|
||||||
expect(doc.dpi).toBe(300)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('includes node_width', () => {
|
it('includes node_width', () => {
|
||||||
const doc = JSON.parse(serialize(FULL_STATE))
|
const doc = JSON.parse(serialize(FULL_STATE))
|
||||||
expect(doc.node_width).toBe(500)
|
expect(doc.node_width).toBe(500)
|
||||||
@@ -138,7 +133,6 @@ describe('deserialize — happy path', () => {
|
|||||||
it('loads a well-formed v1 document', () => {
|
it('loads a well-formed v1 document', () => {
|
||||||
const result = deserialize(makeV1Doc())
|
const result = deserialize(makeV1Doc())
|
||||||
expect(result.imagePath).toBe('/some/image.jpg')
|
expect(result.imagePath).toBe('/some/image.jpg')
|
||||||
expect(result.dpi).toBe(150)
|
|
||||||
expect(result.nodeWidth).toBe(450)
|
expect(result.nodeWidth).toBe(450)
|
||||||
expect(result.graph.nodes).toHaveLength(MINIMAL_GRAPH.nodes.length)
|
expect(result.graph.nodes).toHaveLength(MINIMAL_GRAPH.nodes.length)
|
||||||
expect(result.graph.edges).toHaveLength(MINIMAL_GRAPH.edges.length)
|
expect(result.graph.edges).toHaveLength(MINIMAL_GRAPH.edges.length)
|
||||||
@@ -175,12 +169,6 @@ describe('deserialize — happy path', () => {
|
|||||||
// ── deserialize — missing optional fields use defaults ─────────────────────────
|
// ── deserialize — missing optional fields use defaults ─────────────────────────
|
||||||
|
|
||||||
describe('deserialize — missing optional fields', () => {
|
describe('deserialize — missing optional fields', () => {
|
||||||
it('defaults dpi to 150 when missing', () => {
|
|
||||||
const { dpi: _, ...doc } = JSON.parse(makeV1Doc())
|
|
||||||
const result = deserialize(JSON.stringify(doc))
|
|
||||||
expect(result.dpi).toBe(150)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('defaults node_width to 450 when missing', () => {
|
it('defaults node_width to 450 when missing', () => {
|
||||||
const doc = JSON.parse(makeV1Doc())
|
const doc = JSON.parse(makeV1Doc())
|
||||||
delete doc.node_width
|
delete doc.node_width
|
||||||
@@ -218,7 +206,7 @@ describe('deserialize — missing optional fields', () => {
|
|||||||
const minimalDoc = JSON.stringify({ version: 1, app: 'trac3r' })
|
const minimalDoc = JSON.stringify({ version: 1, app: 'trac3r' })
|
||||||
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, nodeWidth: 450, graph: null, gcodeConfig: null,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -299,7 +287,7 @@ describe('deserialize — version handling', () => {
|
|||||||
// Inject a migration that should NOT run (file is already at current version)
|
// Inject a migration that should NOT run (file is already at current version)
|
||||||
const result = deserialize(makeV1Doc(), { migrations: [spy] })
|
const result = deserialize(makeV1Doc(), { migrations: [spy] })
|
||||||
expect(spy).not.toHaveBeenCalled()
|
expect(spy).not.toHaveBeenCalled()
|
||||||
expect(result.dpi).toBe(150)
|
expect(result.nodeWidth).toBe(450)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('warns (not throws) when file version is ahead of the app', () => {
|
it('warns (not throws) when file version is ahead of the app', () => {
|
||||||
@@ -373,7 +361,6 @@ describe('round-trip: serialize → deserialize', () => {
|
|||||||
const json = serialize(FULL_STATE)
|
const json = serialize(FULL_STATE)
|
||||||
const result = deserialize(json)
|
const result = deserialize(json)
|
||||||
expect(result.imagePath).toBe(FULL_STATE.imagePath)
|
expect(result.imagePath).toBe(FULL_STATE.imagePath)
|
||||||
expect(result.dpi).toBe(FULL_STATE.dpi)
|
|
||||||
expect(result.nodeWidth).toBe(FULL_STATE.nodeWidth)
|
expect(result.nodeWidth).toBe(FULL_STATE.nodeWidth)
|
||||||
expect(result.gcodeConfig).toEqual(FULL_STATE.gcodeConfig)
|
expect(result.gcodeConfig).toEqual(FULL_STATE.gcodeConfig)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ export function defaultKernelProps() {
|
|||||||
xdog_sigma2: 1.6, xdog_tau: 0.98, xdog_phi: 10.0,
|
xdog_sigma2: 1.6, xdog_tau: 0.98, xdog_phi: 10.0,
|
||||||
color_filter: buildColorIsolateFilter(ci_color, ci_hue_tolerance, ci_sat_min, ci_val_min),
|
color_filter: buildColorIsolateFilter(ci_color, ci_hue_tolerance, ci_sat_min, ci_val_min),
|
||||||
ci_color, ci_hue_tolerance, ci_sat_min, ci_val_min,
|
ci_color, ci_hue_tolerance, ci_sat_min, ci_val_min,
|
||||||
|
// null = run at canvas DPI (= max source DPI). Lower values
|
||||||
|
// downsample the kernel's input internally for speed.
|
||||||
|
kernel_dpi: null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,8 +85,10 @@ export function defaultColorFilter() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function defaultFillParams() {
|
export function defaultFillParams() {
|
||||||
|
// spacing is in mm (DPI-independent, paper-relative). Backend converts
|
||||||
|
// to pipeline pixels via canvas_w / paper_w_mm at process time.
|
||||||
return {
|
return {
|
||||||
strategy: 'hatch', spacing: 5, angle: 0, param: 1.0,
|
strategy: 'hatch', spacing: 2.0, angle: 0, param: 1.0,
|
||||||
smooth_rdp: 1.0, smooth_iters: 2,
|
smooth_rdp: 1.0, smooth_iters: 2,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -117,13 +122,18 @@ export function defaultTextParams() {
|
|||||||
|
|
||||||
export function defaultHullParams() {
|
export function defaultHullParams() {
|
||||||
return {
|
return {
|
||||||
threshold: 128, min_area: 4, rdp_epsilon: 1.5, connectivity: 'four',
|
threshold: 128, min_area: 4, connectivity: 'four',
|
||||||
color_filter: defaultColorFilter(),
|
color_filter: defaultColorFilter(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function defaultSourceParams() {
|
export function defaultSourceParams() {
|
||||||
return { file_path: null }
|
// dpi is the source's sampling resolution: how many pixels per mm of
|
||||||
|
// paper the source image gets letterboxed into. Higher = finer detail
|
||||||
|
// captured from the source. With multi-source projects, the project
|
||||||
|
// canvas runs at max(source.dpi) so the highest-detail source isn't
|
||||||
|
// limited by the lowest.
|
||||||
|
return { file_path: null, dpi: 150 }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function defaultGraph() {
|
export function defaultGraph() {
|
||||||
@@ -178,6 +188,7 @@ export function defaultGcodeConfig() {
|
|||||||
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_dwell_ms: 250,
|
pen_down: 'G1 Z0.4 F1000', pen_up_z_mm: 2, pen_dwell_ms: 250,
|
||||||
|
pen_tip_mm: 0.5, // visual only — gcode preview renders strokes at this physical ink width
|
||||||
printer_url: 'http://fluidnc.local',
|
printer_url: 'http://fluidnc.local',
|
||||||
}
|
}
|
||||||
return { ...cfg, ...centerPaperOnBed(cfg) }
|
return { ...cfg, ...centerPaperOnBed(cfg) }
|
||||||
|
|||||||
@@ -56,6 +56,12 @@ pub struct DetectionLayer {
|
|||||||
pub ci_hue_min: f32, pub ci_hue_max: f32,
|
pub ci_hue_min: f32, pub ci_hue_max: f32,
|
||||||
pub ci_sat_min: f32, pub ci_sat_max: f32,
|
pub ci_sat_min: f32, pub ci_sat_max: f32,
|
||||||
pub ci_val_min: f32, pub ci_val_max: f32,
|
pub ci_val_min: f32, pub ci_val_max: f32,
|
||||||
|
/// Per-kernel internal DPI. None = use canvas DPI. If set lower
|
||||||
|
/// than canvas DPI, the kernel's input gets downsampled to that
|
||||||
|
/// resolution before applying the layer (faster for slow kernels
|
||||||
|
/// like Canny on big images), then upsampled back to canvas dims
|
||||||
|
/// so downstream nodes see a consistent map.
|
||||||
|
pub kernel_dpi: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for DetectionLayer {
|
impl Default for DetectionLayer {
|
||||||
@@ -74,10 +80,37 @@ impl Default for DetectionLayer {
|
|||||||
ci_hue_min: 0.0, ci_hue_max: 360.0,
|
ci_hue_min: 0.0, ci_hue_max: 360.0,
|
||||||
ci_sat_min: 0.0, ci_sat_max: 1.0,
|
ci_sat_min: 0.0, ci_sat_max: 1.0,
|
||||||
ci_val_min: 0.0, ci_val_max: 1.0,
|
ci_val_min: 0.0, ci_val_max: 1.0,
|
||||||
|
kernel_dpi: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Apply a layer with optional DPI downsampling. If `layer.kernel_dpi`
|
||||||
|
/// is set lower than `canvas_dpi`, the input is downsampled to that
|
||||||
|
/// resolution before applying the kernel and the result upsampled back
|
||||||
|
/// to the input's dimensions — same output shape, kernel works on a
|
||||||
|
/// smaller image internally.
|
||||||
|
pub fn apply_layer_with_dpi(rgb: &RgbImage, layer: &DetectionLayer, canvas_dpi: f32) -> Vec<u8> {
|
||||||
|
if let Some(kdpi) = layer.kernel_dpi {
|
||||||
|
let kdpi = kdpi.max(1) as f32;
|
||||||
|
if kdpi + 0.5 < canvas_dpi {
|
||||||
|
let ratio = kdpi / canvas_dpi;
|
||||||
|
let new_w = (rgb.width() as f32 * ratio).round().max(1.0) as u32;
|
||||||
|
let new_h = (rgb.height() as f32 * ratio).round().max(1.0) as u32;
|
||||||
|
let small = image::DynamicImage::ImageRgb8(rgb.clone())
|
||||||
|
.resize_exact(new_w, new_h, image::imageops::FilterType::Triangle)
|
||||||
|
.to_rgb8();
|
||||||
|
let small_resp = apply_layer(&small, layer);
|
||||||
|
let small_gray = image::GrayImage::from_raw(new_w, new_h, small_resp).expect("gray buf");
|
||||||
|
let up = image::DynamicImage::ImageLuma8(small_gray)
|
||||||
|
.resize_exact(rgb.width(), rgb.height(), image::imageops::FilterType::Triangle)
|
||||||
|
.to_luma8();
|
||||||
|
return up.into_raw();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
apply_layer(rgb, layer)
|
||||||
|
}
|
||||||
|
|
||||||
/// Ordered stack of detection layers combined by weighted average.
|
/// Ordered stack of detection layers combined by weighted average.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct DetectionParams {
|
pub struct DetectionParams {
|
||||||
@@ -477,7 +510,6 @@ pub enum NodeKind {
|
|||||||
Hull {
|
Hull {
|
||||||
threshold: u8,
|
threshold: u8,
|
||||||
min_area: u32,
|
min_area: u32,
|
||||||
rdp_epsilon: f32,
|
|
||||||
eight_conn: bool,
|
eight_conn: bool,
|
||||||
cf_enabled: bool,
|
cf_enabled: bool,
|
||||||
cf_hue_min: f32, cf_hue_max: f32,
|
cf_hue_min: f32, cf_hue_max: f32,
|
||||||
@@ -551,6 +583,7 @@ pub fn evaluate_graph(
|
|||||||
node_rgbs: &std::collections::HashMap<String, RgbImage>,
|
node_rgbs: &std::collections::HashMap<String, RgbImage>,
|
||||||
canvas_w: u32,
|
canvas_w: u32,
|
||||||
canvas_h: u32,
|
canvas_h: u32,
|
||||||
|
canvas_dpi: f32,
|
||||||
) -> GraphMaps {
|
) -> GraphMaps {
|
||||||
use std::collections::{HashMap, VecDeque};
|
use std::collections::{HashMap, VecDeque};
|
||||||
|
|
||||||
@@ -625,9 +658,9 @@ pub fn evaluate_graph(
|
|||||||
let v = up[(y * src_rgb.width() + x) as usize];
|
let v = up[(y * src_rgb.width() + x) as usize];
|
||||||
image::Rgb([v, v, v])
|
image::Rgb([v, v, v])
|
||||||
});
|
});
|
||||||
apply_layer(&gray_rgb, layer)
|
apply_layer_with_dpi(&gray_rgb, layer, canvas_dpi)
|
||||||
} else {
|
} else {
|
||||||
apply_layer(src_rgb, layer)
|
apply_layer_with_dpi(src_rgb, layer, canvas_dpi)
|
||||||
};
|
};
|
||||||
let w = layer.weight;
|
let w = layer.weight;
|
||||||
Some(if (w - 1.0).abs() < 1e-6 {
|
Some(if (w - 1.0).abs() < 1e-6 {
|
||||||
|
|||||||
286
src/fill.rs
286
src/fill.rs
@@ -1,17 +1,200 @@
|
|||||||
// Fill-path generation for pen-plotter G-code output.
|
// Fill-path generation for pen-plotter G-code output.
|
||||||
// All algorithms work in pixel coordinates.
|
//
|
||||||
|
// Two layers:
|
||||||
|
// • Internal pixel-coord algorithms — `parallel_hatch`, `outline`, etc.
|
||||||
|
// These take a pixel `Hull` and produce strokes in that hull's pixel
|
||||||
|
// coord system. Their existing tests still pass.
|
||||||
|
// • Mm-coord wrappers — `*_mm` functions take an `MmHull` (polygon in
|
||||||
|
// mm), locally rasterize it to a pixel hull at a fixed internal
|
||||||
|
// resolution, run the pixel algorithm, and convert the output strokes
|
||||||
|
// back to mm. Result is DPI-independent: a 2 mm hatch on A4 looks
|
||||||
|
// the same regardless of project DPI.
|
||||||
|
//
|
||||||
|
// FillResult.strokes are mm coords when produced by the `*_mm` variants
|
||||||
|
// (the path the lib.rs dispatcher takes); pixel coords when produced by
|
||||||
|
// the legacy direct calls (test-only).
|
||||||
|
|
||||||
use std::collections::{HashMap, HashSet, VecDeque};
|
use std::collections::{HashMap, HashSet, VecDeque};
|
||||||
use crate::hulls::{Hull, trace_contour};
|
use crate::hulls::{Hull, MmHull, Bounds, trace_contour};
|
||||||
|
|
||||||
|
/// Internal raster resolution used by the mm-wrapper layer when a fill
|
||||||
|
/// algorithm needs a pixel grid (inside-tests, distance transforms, etc).
|
||||||
|
/// 10 px / mm = 254 DPI — finer than a 0.5 mm pen tip resolves, plenty
|
||||||
|
/// for plotter-quality output. Picked to be DPI-independent (downstream
|
||||||
|
/// of Hull, project DPI no longer matters).
|
||||||
|
pub const FILL_INTERNAL_PX_PER_MM: f32 = 10.0;
|
||||||
|
|
||||||
/// One hull's worth of fill strokes.
|
/// One hull's worth of fill strokes.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct FillResult {
|
pub struct FillResult {
|
||||||
pub hull_id: u32,
|
pub hull_id: u32,
|
||||||
/// Each inner Vec is one polyline stroke (sequence of pixel-space points).
|
/// Polyline strokes. Coordinate frame depends on producer:
|
||||||
|
/// the `*_mm` wrappers emit mm; the legacy direct fills emit pixels
|
||||||
|
/// at the input hull's resolution.
|
||||||
pub strokes: Vec<Vec<(f32, f32)>>,
|
pub strokes: Vec<Vec<(f32, f32)>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Mm wrapper layer: rasterize MmHull → run pixel fill → mm strokes ──────
|
||||||
|
|
||||||
|
/// Polygon scanline fill: rasterizes an `MmHull` into a `Hull` at
|
||||||
|
/// `px_per_mm` resolution (a private pixel grid for internal use by mm
|
||||||
|
/// fill wrappers). Coords inside the returned Hull are in *that*
|
||||||
|
/// rasterization's pixel space; multiply by 1/px_per_mm to go back to mm.
|
||||||
|
pub fn rasterize_mm_hull(mm: &MmHull, px_per_mm: f32) -> Hull {
|
||||||
|
let s = px_per_mm.max(0.01);
|
||||||
|
// Pixel bbox — pad by 1 to give scanline some slack at the boundary.
|
||||||
|
let x_min = (mm.bounds.x_min * s).floor().max(0.0) as u32;
|
||||||
|
let y_min = (mm.bounds.y_min * s).floor().max(0.0) as u32;
|
||||||
|
let x_max = (mm.bounds.x_max * s).ceil() as u32;
|
||||||
|
let y_max = (mm.bounds.y_max * s).ceil() as u32;
|
||||||
|
if x_max < x_min || y_max < y_min {
|
||||||
|
return Hull {
|
||||||
|
id: mm.id, pixels: vec![], contour: vec![], simplified: vec![],
|
||||||
|
area: 0, avg_luminance: 0.0, avg_color: mm.avg_color,
|
||||||
|
bounds: Bounds { x_min, y_min, x_max: x_min, y_max: y_min },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Polygon in pixel coords for scanline math.
|
||||||
|
let poly: Vec<(f32, f32)> = mm.contour.iter().map(|&(x, y)| (x * s, y * s)).collect();
|
||||||
|
let n = poly.len();
|
||||||
|
let mut pixels: Vec<(u32, u32)> = Vec::new();
|
||||||
|
if n >= 3 {
|
||||||
|
for py in y_min..=y_max {
|
||||||
|
let y = py as f32 + 0.5;
|
||||||
|
// Find x-intersections of polygon edges with horizontal scanline y.
|
||||||
|
let mut xs: Vec<f32> = Vec::new();
|
||||||
|
for i in 0..n {
|
||||||
|
let (ax, ay) = poly[i];
|
||||||
|
let (bx, by) = poly[(i + 1) % n];
|
||||||
|
// Skip horizontal edges (don't change crossing parity).
|
||||||
|
if (ay > y) == (by > y) { continue; }
|
||||||
|
let t = (y - ay) / (by - ay);
|
||||||
|
xs.push(ax + t * (bx - ax));
|
||||||
|
}
|
||||||
|
xs.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
|
// Pair-walk: even-odd fill rule.
|
||||||
|
let mut i = 0;
|
||||||
|
while i + 1 < xs.len() {
|
||||||
|
let x_lo = xs[i].max(0.0).ceil() as i64;
|
||||||
|
let x_hi = xs[i + 1].floor() as i64;
|
||||||
|
if x_hi >= x_lo {
|
||||||
|
for px in x_lo..=x_hi {
|
||||||
|
if px >= x_min as i64 && px <= x_max as i64 {
|
||||||
|
pixels.push((px as u32, py));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i += 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let pixel_set: HashSet<(u32, u32)> = pixels.iter().copied().collect();
|
||||||
|
let contour = trace_contour(&pixel_set);
|
||||||
|
let simplified = contour.iter().map(|&(x, y)| (x as f32, y as f32)).collect();
|
||||||
|
let area = pixel_set.len() as u32;
|
||||||
|
Hull {
|
||||||
|
id: mm.id,
|
||||||
|
pixels: pixel_set.into_iter().collect(),
|
||||||
|
contour, simplified,
|
||||||
|
area, avg_luminance: 0.0, avg_color: mm.avg_color,
|
||||||
|
bounds: Bounds { x_min, y_min, x_max, y_max },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert pixel-space strokes (at `px_per_mm` resolution) back to mm strokes.
|
||||||
|
fn px_strokes_to_mm(strokes: Vec<Vec<(f32, f32)>>, px_per_mm: f32) -> Vec<Vec<(f32, f32)>> {
|
||||||
|
let inv = if px_per_mm > 0.0 { 1.0 / px_per_mm } else { 0.0 };
|
||||||
|
strokes.into_iter().map(|s| s.into_iter().map(|(x, y)| (x * inv, y * inv)).collect()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Outline fill (closed polygon stroke) — direct from MmHull, no raster needed.
|
||||||
|
pub fn outline_mm(mm: &MmHull) -> FillResult {
|
||||||
|
if mm.contour.len() < 2 {
|
||||||
|
return FillResult { hull_id: mm.id, strokes: vec![] };
|
||||||
|
}
|
||||||
|
let mut s: Vec<(f32, f32)> = mm.contour.clone();
|
||||||
|
if let Some(&first) = s.first() { s.push(first); }
|
||||||
|
FillResult { hull_id: mm.id, strokes: vec![s] }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wrapper macro: rasterize → run a pixel-space fill that takes
|
||||||
|
/// `(hull, spacing_px)` → convert strokes back to mm.
|
||||||
|
macro_rules! mm_wrap_simple {
|
||||||
|
($name:ident, $px_fn:ident) => {
|
||||||
|
pub fn $name(mm: &MmHull, spacing_mm: f32) -> FillResult {
|
||||||
|
let s = FILL_INTERNAL_PX_PER_MM;
|
||||||
|
let h = rasterize_mm_hull(mm, s);
|
||||||
|
let spacing_px = (spacing_mm * s).max(0.5);
|
||||||
|
let r = $px_fn(&h, spacing_px);
|
||||||
|
FillResult { hull_id: mm.id, strokes: px_strokes_to_mm(r.strokes, s) }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
mm_wrap_simple!(contour_offset_mm, contour_offset);
|
||||||
|
mm_wrap_simple!(spiral_mm, spiral);
|
||||||
|
mm_wrap_simple!(voronoi_fill_mm, voronoi_fill);
|
||||||
|
mm_wrap_simple!(hilbert_fill_mm, hilbert_fill);
|
||||||
|
|
||||||
|
/// Hatch + zigzag share the (spacing, angle) signature.
|
||||||
|
macro_rules! mm_wrap_hatch {
|
||||||
|
($name:ident, $px_fn:ident) => {
|
||||||
|
pub fn $name(mm: &MmHull, spacing_mm: f32, angle_deg: f32) -> FillResult {
|
||||||
|
let s = FILL_INTERNAL_PX_PER_MM;
|
||||||
|
let h = rasterize_mm_hull(mm, s);
|
||||||
|
let spacing_px = (spacing_mm * s).max(0.5);
|
||||||
|
let r = $px_fn(&h, spacing_px, angle_deg);
|
||||||
|
FillResult { hull_id: mm.id, strokes: px_strokes_to_mm(r.strokes, s) }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
mm_wrap_hatch!(parallel_hatch_mm, parallel_hatch);
|
||||||
|
mm_wrap_hatch!(zigzag_hatch_mm, zigzag_hatch);
|
||||||
|
|
||||||
|
pub fn circle_pack_mm(mm: &MmHull, spacing_mm: f32, min_radius_factor: f32) -> FillResult {
|
||||||
|
let s = FILL_INTERNAL_PX_PER_MM;
|
||||||
|
let h = rasterize_mm_hull(mm, s);
|
||||||
|
let spacing_px = (spacing_mm * s).max(0.5);
|
||||||
|
let r = circle_pack(&h, spacing_px, min_radius_factor);
|
||||||
|
FillResult { hull_id: mm.id, strokes: px_strokes_to_mm(r.strokes, s) }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn wave_interference_mm(mm: &MmHull, spacing_mm: f32, num_sources: usize) -> FillResult {
|
||||||
|
let s = FILL_INTERNAL_PX_PER_MM;
|
||||||
|
let h = rasterize_mm_hull(mm, s);
|
||||||
|
let spacing_px = (spacing_mm * s).max(0.5);
|
||||||
|
let r = wave_interference(&h, spacing_px, num_sources);
|
||||||
|
FillResult { hull_id: mm.id, strokes: px_strokes_to_mm(r.strokes, s) }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn flow_field_mm(mm: &MmHull, spacing_mm: f32, angle_deg: f32, amplitude_scale: f32) -> FillResult {
|
||||||
|
let s = FILL_INTERNAL_PX_PER_MM;
|
||||||
|
let h = rasterize_mm_hull(mm, s);
|
||||||
|
let spacing_px = (spacing_mm * s).max(0.5);
|
||||||
|
let r = flow_field(&h, spacing_px, angle_deg, amplitude_scale);
|
||||||
|
FillResult { hull_id: mm.id, strokes: px_strokes_to_mm(r.strokes, s) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gradient fills sample from the kernel response map, which lives at the
|
||||||
|
/// project's source DPI. We rasterize the hull at that same resolution
|
||||||
|
/// (`source_px_per_mm = canvas_w / paper_w_mm`) so sample coords align.
|
||||||
|
/// As a result these fills are NOT fully DPI-independent — that's a
|
||||||
|
/// deliberate compromise; gradient density is information from the image.
|
||||||
|
pub fn gradient_hatch_mm(mm: &MmHull, response: &[u8], img_w: u32, source_px_per_mm: f32,
|
||||||
|
spacing_mm: f32, angle_deg: f32, min_scale: f32) -> FillResult {
|
||||||
|
let h = rasterize_mm_hull(mm, source_px_per_mm);
|
||||||
|
let spacing_px = (spacing_mm * source_px_per_mm).max(0.5);
|
||||||
|
let r = gradient_hatch(&h, response, img_w, spacing_px, angle_deg, min_scale);
|
||||||
|
FillResult { hull_id: mm.id, strokes: px_strokes_to_mm(r.strokes, source_px_per_mm) }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn gradient_cross_hatch_mm(mm: &MmHull, response: &[u8], img_w: u32, source_px_per_mm: f32,
|
||||||
|
spacing_mm: f32, angle_deg: f32, min_scale: f32) -> FillResult {
|
||||||
|
let h = rasterize_mm_hull(mm, source_px_per_mm);
|
||||||
|
let spacing_px = (spacing_mm * source_px_per_mm).max(0.5);
|
||||||
|
let r = gradient_cross_hatch(&h, response, img_w, spacing_px, angle_deg, min_scale);
|
||||||
|
FillResult { hull_id: mm.id, strokes: px_strokes_to_mm(r.strokes, source_px_per_mm) }
|
||||||
|
}
|
||||||
|
|
||||||
// ── Parallel hatch ─────────────────────────────────────────────────────────────
|
// ── Parallel hatch ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Horizontal scan lines through the hull at `spacing_px` pixel intervals,
|
/// Horizontal scan lines through the hull at `spacing_px` pixel intervals,
|
||||||
@@ -1109,6 +1292,100 @@ mod tests {
|
|||||||
.fold(0.0f32, f32::max)
|
.fold(0.0f32, f32::max)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── MmHull / mm-fill tests ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn rect_mm_hull(w_mm: f32, h_mm: f32) -> MmHull {
|
||||||
|
MmHull {
|
||||||
|
id: 0,
|
||||||
|
contour: vec![(0.0, 0.0), (w_mm, 0.0), (w_mm, h_mm), (0.0, h_mm)],
|
||||||
|
holes: vec![],
|
||||||
|
bounds: crate::hulls::BoundsMm { x_min: 0.0, y_min: 0.0, x_max: w_mm, y_max: h_mm },
|
||||||
|
area_mm2: w_mm * h_mm,
|
||||||
|
avg_color: [0, 0, 0],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a pixel Hull representing a (50 × 30) mm rect at the given DPI,
|
||||||
|
/// then convert via MmHull and run mm-fill — for DPI-independence checks.
|
||||||
|
fn pixel_rect_to_mm_hull(dpi: f32) -> MmHull {
|
||||||
|
let px_per_mm = dpi / 25.4;
|
||||||
|
let w = (50.0 * px_per_mm).round() as u32;
|
||||||
|
let h = (30.0 * px_per_mm).round() as u32;
|
||||||
|
let pixels: Vec<(u32, u32)> = (0..h)
|
||||||
|
.flat_map(|y| (0..w).map(move |x| (x, y)))
|
||||||
|
.collect();
|
||||||
|
// hull_from_pixels is only available later in this module; build directly.
|
||||||
|
let hull = Hull {
|
||||||
|
id: 0,
|
||||||
|
pixels: pixels.clone(),
|
||||||
|
contour: pixels.clone(),
|
||||||
|
simplified: vec![(0.0, 0.0), (w as f32, 0.0), (w as f32, h as f32), (0.0, h as f32)],
|
||||||
|
area: w * h, avg_luminance: 0.0, avg_color: [0, 0, 0],
|
||||||
|
bounds: crate::hulls::Bounds { x_min: 0, y_min: 0, x_max: w - 1, y_max: h - 1 },
|
||||||
|
};
|
||||||
|
MmHull::from_pixel_hull(&hull, px_per_mm)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rasterize_mm_hull_produces_pixels_in_bounds() {
|
||||||
|
let mm = rect_mm_hull(20.0, 10.0);
|
||||||
|
let h = rasterize_mm_hull(&mm, 10.0); // 10 px/mm → 200 × 100 px target
|
||||||
|
assert!(!h.pixels.is_empty(), "rasterized rect produced 0 pixels");
|
||||||
|
assert!(h.area > 18_000, "rasterized 20×10mm @ 10px/mm should fill ≥18k px, got {}", h.area);
|
||||||
|
for (x, y) in &h.pixels {
|
||||||
|
assert!(*x <= 200 && *y <= 100, "pixel ({x},{y}) out of bounds");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn outline_mm_emits_single_closed_polyline() {
|
||||||
|
let mm = rect_mm_hull(50.0, 30.0);
|
||||||
|
let r = outline_mm(&mm);
|
||||||
|
assert_eq!(r.strokes.len(), 1);
|
||||||
|
let s = &r.strokes[0];
|
||||||
|
// 4 polygon vertices + closing point = 5
|
||||||
|
assert_eq!(s.len(), 5);
|
||||||
|
assert_eq!(s[0], s[4], "first and last point must match (closed)");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parallel_hatch_mm_strokes_are_in_mm_range() {
|
||||||
|
let mm = rect_mm_hull(50.0, 30.0);
|
||||||
|
let r = parallel_hatch_mm(&mm, 5.0, 0.0);
|
||||||
|
assert!(!r.strokes.is_empty());
|
||||||
|
for stroke in &r.strokes {
|
||||||
|
for &(x, y) in stroke {
|
||||||
|
assert!(x >= -1.0 && x <= 51.0, "stroke x={x} outside mm bounds");
|
||||||
|
assert!(y >= -1.0 && y <= 31.0, "stroke y={y} outside mm bounds");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parallel_hatch_mm_dpi_independent() {
|
||||||
|
// Same 50×30 mm rect built at 150 DPI vs 600 DPI should produce
|
||||||
|
// matching mm strokes — the wrapper rasterizes at a fixed internal
|
||||||
|
// resolution, so source DPI is decoupled from output.
|
||||||
|
let r150 = parallel_hatch_mm(&pixel_rect_to_mm_hull(150.0), 5.0, 0.0);
|
||||||
|
let r600 = parallel_hatch_mm(&pixel_rect_to_mm_hull(600.0), 5.0, 0.0);
|
||||||
|
assert_eq!(r150.strokes.len(), r600.strokes.len(),
|
||||||
|
"stroke count differs across DPI: 150→{}, 600→{}",
|
||||||
|
r150.strokes.len(), r600.strokes.len());
|
||||||
|
// First stroke endpoints should match within ½mm (rounding wiggle).
|
||||||
|
let (x1, y1) = r150.strokes[0][0];
|
||||||
|
let (x2, y2) = r600.strokes[0][0];
|
||||||
|
assert!((x1 - x2).abs() < 0.5 && (y1 - y2).abs() < 0.5,
|
||||||
|
"first stroke at 150dpi=({x1:.2},{y1:.2}) vs 600dpi=({x2:.2},{y2:.2})");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn px_strokes_to_mm_divides_by_px_per_mm() {
|
||||||
|
let strokes = vec![vec![(20.0, 30.0), (40.0, 60.0)]];
|
||||||
|
let mm = px_strokes_to_mm(strokes, 10.0);
|
||||||
|
assert_eq!(mm[0][0], (2.0, 3.0));
|
||||||
|
assert_eq!(mm[0][1], (4.0, 6.0));
|
||||||
|
}
|
||||||
|
|
||||||
// ── Hull builders ─────────────────────────────────────────────────────────
|
// ── Hull builders ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
fn make_square_hull(x0: u32, y0: u32, side: u32) -> Hull {
|
fn make_square_hull(x0: u32, y0: u32, side: u32) -> Hull {
|
||||||
@@ -1639,8 +1916,9 @@ mod tests {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let response = crate::detect::apply_stack(&img, &crate::detect::DetectionParams { layers: vec![layer] });
|
let response = crate::detect::apply_stack(&img, &crate::detect::DetectionParams { layers: vec![layer] });
|
||||||
|
let _ = rdp_eps; // legacy debug-dump field; RDP no longer applied at extract time
|
||||||
let hull_params = crate::hulls::HullParams {
|
let hull_params = crate::hulls::HullParams {
|
||||||
threshold, min_area, rdp_epsilon: rdp_eps,
|
threshold, min_area,
|
||||||
connectivity: crate::hulls::Connectivity::Four,
|
connectivity: crate::hulls::Connectivity::Four,
|
||||||
};
|
};
|
||||||
let hulls = crate::hulls::extract_hulls(&response, &img, w, h, &hull_params);
|
let hulls = crate::hulls::extract_hulls(&response, &img, w, h, &hull_params);
|
||||||
|
|||||||
54
src/gcode.rs
54
src/gcode.rs
@@ -90,10 +90,18 @@ impl GcodeConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Convert fill results to G-code.
|
/// Convert fill results to G-code.
|
||||||
/// Pixel coordinates are scaled by `img_w_mm / img_w` (uniform, aspect-correct),
|
/// Strokes are in mm relative to the image origin. We apply
|
||||||
/// then offset by `(paper_offset_x_mm + offset_x_mm, paper_offset_y_mm + offset_y_mm)`.
|
/// `(paper_offset_x_mm + offset_x_mm, paper_offset_y_mm + offset_y_mm)`
|
||||||
pub fn to_gcode(results: &[FillResult], img_w: u32, _img_h: u32, cfg: &GcodeConfig) -> String {
|
/// to position the image on the bed, and `img_w_mm / paper_w_mm` (when
|
||||||
let scale = cfg.px_to_mm(img_w);
|
/// the user has scaled the image away from paper width) to scale the
|
||||||
|
/// strokes — but typically that's 1.0. The legacy `_img_w` / `_img_h`
|
||||||
|
/// args are kept so older callers compile; only `img_w` is consulted to
|
||||||
|
/// derive the scale-on-paper ratio if `img_w_mm != paper_w_mm`. Most of
|
||||||
|
/// the time `scale == 1.0` and we're just adding offsets.
|
||||||
|
pub fn to_gcode(results: &[FillResult], _img_w: u32, _img_h: u32, cfg: &GcodeConfig) -> String {
|
||||||
|
// Image-on-paper scale: lets the user resize the image rect. 1.0 means
|
||||||
|
// strokes plot at their native mm dimensions.
|
||||||
|
let scale = if cfg.paper_w_mm > 0.0 { cfg.img_w_mm / cfg.paper_w_mm } else { 1.0 };
|
||||||
let ox = cfg.paper_offset_x_mm + cfg.offset_x_mm;
|
let ox = cfg.paper_offset_x_mm + cfg.offset_x_mm;
|
||||||
let oy = cfg.paper_offset_y_mm + cfg.offset_y_mm;
|
let oy = cfg.paper_offset_y_mm + cfg.offset_y_mm;
|
||||||
|
|
||||||
@@ -200,22 +208,48 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn gcode_aspect_ratio_preserved() {
|
fn gcode_strokes_are_mm_with_offsets_applied() {
|
||||||
// 200×100 px image → 200mm wide → scale = 1.0 px/mm
|
// Strokes are mm. img_w_mm == paper_w_mm so on-paper scale = 1.
|
||||||
// A point at (50, 50) should map to (50*1.0 + ox, 50*1.0 + oy)
|
// Point at (50, 50) mm should land at (50 + offset_x, 50 + offset_y).
|
||||||
let cfg = GcodeConfig {
|
let cfg = GcodeConfig {
|
||||||
|
paper_w_mm: 200.0,
|
||||||
|
paper_h_mm: 200.0,
|
||||||
img_w_mm: 200.0,
|
img_w_mm: 200.0,
|
||||||
offset_x_mm: 10.0,
|
offset_x_mm: 10.0,
|
||||||
offset_y_mm: 20.0,
|
offset_y_mm: 20.0,
|
||||||
|
paper_offset_x_mm: 0.0,
|
||||||
|
paper_offset_y_mm: 0.0,
|
||||||
..GcodeConfig::default()
|
..GcodeConfig::default()
|
||||||
};
|
};
|
||||||
let result = FillResult {
|
let result = FillResult {
|
||||||
hull_id: 0,
|
hull_id: 0,
|
||||||
strokes: vec![vec![(0.0, 0.0), (50.0, 50.0)]],
|
strokes: vec![vec![(0.0, 0.0), (50.0, 50.0)]],
|
||||||
};
|
};
|
||||||
let code = to_gcode(&[result], 200, 100, &cfg);
|
let code = to_gcode(&[result], 0, 0, &cfg);
|
||||||
assert!(code.contains("X60.000"), "expected X=50*1.0+10=60");
|
assert!(code.contains("X60.000"), "expected X=50+10=60, got: {code}");
|
||||||
assert!(code.contains("Y70.000"), "expected Y=50*1.0+20=70");
|
assert!(code.contains("Y70.000"), "expected Y=50+20=70, got: {code}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gcode_image_scale_below_paper_shrinks_strokes() {
|
||||||
|
// img_w_mm = half paper width → image is half-size on paper.
|
||||||
|
// Stroke at 100mm in the image plots at 50mm on paper.
|
||||||
|
let cfg = GcodeConfig {
|
||||||
|
paper_w_mm: 200.0,
|
||||||
|
paper_h_mm: 200.0,
|
||||||
|
img_w_mm: 100.0,
|
||||||
|
offset_x_mm: 0.0,
|
||||||
|
offset_y_mm: 0.0,
|
||||||
|
paper_offset_x_mm: 0.0,
|
||||||
|
paper_offset_y_mm: 0.0,
|
||||||
|
..GcodeConfig::default()
|
||||||
|
};
|
||||||
|
let result = FillResult {
|
||||||
|
hull_id: 0,
|
||||||
|
strokes: vec![vec![(0.0, 0.0), (100.0, 0.0)]],
|
||||||
|
};
|
||||||
|
let code = to_gcode(&[result], 0, 0, &cfg);
|
||||||
|
assert!(code.contains("X50.000"), "expected X=100*0.5=50, got: {code}");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
169
src/hulls.rs
169
src/hulls.rs
@@ -15,6 +15,50 @@ pub struct Hull {
|
|||||||
pub bounds: Bounds,
|
pub bounds: Bounds,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Mm-coordinate hull ─────────────────────────────────────────────────────────
|
||||||
|
// Polygon-only hull, in mm. Produced from a pixel `Hull` after extraction by
|
||||||
|
// dividing pixel coords by `px_per_mm`. Downstream of the Hull stage, fills
|
||||||
|
// take this and produce mm strokes — DPI-independent. Fills that internally
|
||||||
|
// need a pixel grid (parallel_hatch's row inside-test, contour_offset's
|
||||||
|
// distance transform, etc.) rasterize the polygon themselves at whatever
|
||||||
|
// resolution they want via `mm_hull_to_pixel_hull`.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct MmHull {
|
||||||
|
pub id: u32,
|
||||||
|
pub contour: Vec<(f32, f32)>, // outer polygon, mm coords
|
||||||
|
pub holes: Vec<Vec<(f32, f32)>>, // inner polygons (holes); empty for now
|
||||||
|
pub area_mm2: f32,
|
||||||
|
pub avg_color: [u8; 3],
|
||||||
|
pub bounds: BoundsMm,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct BoundsMm {
|
||||||
|
pub x_min: f32, pub y_min: f32,
|
||||||
|
pub x_max: f32, pub y_max: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MmHull {
|
||||||
|
/// Build an MmHull from a pixel `Hull` — divides the simplified polygon
|
||||||
|
/// and bounds by `px_per_mm` to land in mm coords.
|
||||||
|
pub fn from_pixel_hull(h: &Hull, px_per_mm: f32) -> Self {
|
||||||
|
let inv = if px_per_mm > 0.0 { 1.0 / px_per_mm } else { 0.0 };
|
||||||
|
let contour: Vec<(f32, f32)> = h.simplified.iter()
|
||||||
|
.map(|&(x, y)| (x * inv, y * inv)).collect();
|
||||||
|
let bounds = BoundsMm {
|
||||||
|
x_min: h.bounds.x_min as f32 * inv,
|
||||||
|
y_min: h.bounds.y_min as f32 * inv,
|
||||||
|
x_max: (h.bounds.x_max as f32 + 1.0) * inv,
|
||||||
|
y_max: (h.bounds.y_max as f32 + 1.0) * inv,
|
||||||
|
};
|
||||||
|
let area_mm2 = h.area as f32 * inv * inv;
|
||||||
|
Self {
|
||||||
|
id: h.id, contour, holes: vec![],
|
||||||
|
area_mm2, avg_color: h.avg_color, bounds,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Color filter ───────────────────────────────────────────────────────────────
|
// ── Color filter ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Per-pass HSV range filter. A hull passes if its average color falls in all three ranges.
|
/// Per-pass HSV range filter. A hull passes if its average color falls in all three ranges.
|
||||||
@@ -97,7 +141,6 @@ pub enum Connectivity {
|
|||||||
pub struct HullParams {
|
pub struct HullParams {
|
||||||
pub threshold: u8, // pixels strictly darker than this = ink
|
pub threshold: u8, // pixels strictly darker than this = ink
|
||||||
pub min_area: u32, // discard components with fewer pixels (noise filter)
|
pub min_area: u32, // discard components with fewer pixels (noise filter)
|
||||||
pub rdp_epsilon: f32, // RDP tolerance in pixels
|
|
||||||
pub connectivity: Connectivity, // 4- or 8-connected flood fill
|
pub connectivity: Connectivity, // 4- or 8-connected flood fill
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,7 +149,6 @@ impl Default for HullParams {
|
|||||||
Self {
|
Self {
|
||||||
threshold: 128,
|
threshold: 128,
|
||||||
min_area: 4,
|
min_area: 4,
|
||||||
rdp_epsilon: 1.5,
|
|
||||||
connectivity: Connectivity::Four,
|
connectivity: Connectivity::Four,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -123,7 +165,10 @@ pub fn extract_hulls(luma: &[u8], rgb: &image::RgbImage, width: u32, height: u32
|
|||||||
components.into_iter().enumerate().map(|(id, pixels)| {
|
components.into_iter().enumerate().map(|(id, pixels)| {
|
||||||
let pixel_set: HashSet<(u32, u32)> = pixels.iter().copied().collect();
|
let pixel_set: HashSet<(u32, u32)> = pixels.iter().copied().collect();
|
||||||
let contour = trace_contour(&pixel_set);
|
let contour = trace_contour(&pixel_set);
|
||||||
let simplified = rdp_simplify(&contour, params.rdp_epsilon);
|
// No more RDP — `simplified` keeps its name for source compat but
|
||||||
|
// is just contour cast to f32. Downstream fills internally rasterize
|
||||||
|
// at fixed resolution and re-simplify in mm-space if desired.
|
||||||
|
let simplified: Vec<(f32, f32)> = contour.iter().map(|&(x, y)| (x as f32, y as f32)).collect();
|
||||||
|
|
||||||
let (mut xn, mut yn) = (u32::MAX, u32::MAX);
|
let (mut xn, mut yn) = (u32::MAX, u32::MAX);
|
||||||
let (mut xx, mut yx) = (0u32, 0u32);
|
let (mut xx, mut yx) = (0u32, 0u32);
|
||||||
@@ -274,47 +319,6 @@ pub(crate) fn trace_contour(component: &HashSet<(u32, u32)>) -> Vec<(u32, u32)>
|
|||||||
contour
|
contour
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Ramer-Douglas-Peucker ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
fn rdp_simplify(pts: &[(u32, u32)], epsilon: f32) -> Vec<(f32, f32)> {
|
|
||||||
let fp: Vec<(f32, f32)> = pts.iter().map(|&(x, y)| (x as f32, y as f32)).collect();
|
|
||||||
if fp.len() <= 2 { return fp; }
|
|
||||||
rdp_rec(&fp, epsilon)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn rdp_rec(pts: &[(f32, f32)], eps: f32) -> Vec<(f32, f32)> {
|
|
||||||
if pts.len() <= 2 { return pts.to_vec(); }
|
|
||||||
|
|
||||||
let (first, last) = (pts[0], *pts.last().unwrap());
|
|
||||||
let (mut dmax, mut idx) = (0f32, 0);
|
|
||||||
|
|
||||||
for (i, &p) in pts[1..pts.len() - 1].iter().enumerate() {
|
|
||||||
let d = perp_dist(p, first, last);
|
|
||||||
if d > dmax { dmax = d; idx = i + 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
if dmax > eps {
|
|
||||||
let mut out = rdp_rec(&pts[..=idx], eps);
|
|
||||||
out.pop();
|
|
||||||
out.extend(rdp_rec(&pts[idx..], eps));
|
|
||||||
out
|
|
||||||
} else {
|
|
||||||
vec![first, last]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn perp_dist(p: (f32, f32), a: (f32, f32), b: (f32, f32)) -> f32 {
|
|
||||||
let (dx, dy) = (b.0 - a.0, b.1 - a.1);
|
|
||||||
let len2 = dx * dx + dy * dy;
|
|
||||||
if len2 < 1e-10 {
|
|
||||||
return ((p.0 - a.0).powi(2) + (p.1 - a.1).powi(2)).sqrt();
|
|
||||||
}
|
|
||||||
let t = ((p.0 - a.0) * dx + (p.1 - a.1) * dy) / len2;
|
|
||||||
let cx = a.0 + t.clamp(0.0, 1.0) * dx;
|
|
||||||
let cy = a.1 + t.clamp(0.0, 1.0) * dy;
|
|
||||||
((p.0 - cx).powi(2) + (p.1 - cy).powi(2)).sqrt()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -343,6 +347,28 @@ pub mod tests {
|
|||||||
(y0..=y1).flat_map(|y| (x0..=x1).map(move |x| (x, y))).collect()
|
(y0..=y1).flat_map(|y| (x0..=x1).map(move |x| (x, y))).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mm_hull_from_pixel_hull_scales_coords() {
|
||||||
|
// A 60×40 px hull at 10 px/mm should be 6×4 mm.
|
||||||
|
let h = Hull {
|
||||||
|
id: 0,
|
||||||
|
pixels: filled_rect(0, 0, 59, 39),
|
||||||
|
contour: vec![(0,0),(59,0),(59,39),(0,39)],
|
||||||
|
simplified: vec![(0.0,0.0),(59.0,0.0),(59.0,39.0),(0.0,39.0)],
|
||||||
|
area: 60*40,
|
||||||
|
avg_luminance: 0.0,
|
||||||
|
avg_color: [0,0,0],
|
||||||
|
bounds: Bounds { x_min: 0, y_min: 0, x_max: 59, y_max: 39 },
|
||||||
|
};
|
||||||
|
let mm = MmHull::from_pixel_hull(&h, 10.0);
|
||||||
|
assert_eq!(mm.id, 0);
|
||||||
|
assert!((mm.bounds.x_max - 6.0).abs() < 1e-3);
|
||||||
|
assert!((mm.bounds.y_max - 4.0).abs() < 1e-3);
|
||||||
|
assert_eq!(mm.contour.len(), 4);
|
||||||
|
assert!((mm.contour[1].0 - 5.9).abs() < 1e-3);
|
||||||
|
assert!((mm.area_mm2 - 24.0).abs() < 1e-3);
|
||||||
|
}
|
||||||
|
|
||||||
/// 1px border of a rectangle (outline only).
|
/// 1px border of a rectangle (outline only).
|
||||||
pub fn rect_outline(x0: u32, y0: u32, x1: u32, y1: u32) -> Vec<(u32, u32)> {
|
pub fn rect_outline(x0: u32, y0: u32, x1: u32, y1: u32) -> Vec<(u32, u32)> {
|
||||||
let mut v = Vec::new();
|
let mut v = Vec::new();
|
||||||
@@ -397,7 +423,7 @@ pub mod tests {
|
|||||||
fn single_filled_square_one_hull() {
|
fn single_filled_square_one_hull() {
|
||||||
let dark = filled_rect(10, 10, 29, 29); // 20×20
|
let dark = filled_rect(10, 10, 29, 29); // 20×20
|
||||||
let luma = make_image(64, 64, &dark);
|
let luma = make_image(64, 64, &dark);
|
||||||
let p = HullParams { threshold: 128, min_area: 1, rdp_epsilon: 1.0, ..HullParams::default() };
|
let p = HullParams { threshold: 128, min_area: 1, ..HullParams::default() };
|
||||||
let hulls = extract_hulls(&luma, &blank_rgb(64, 64), 64, 64, &p);
|
let hulls = extract_hulls(&luma, &blank_rgb(64, 64), 64, 64, &p);
|
||||||
assert_eq!(hulls.len(), 1, "one square → one hull");
|
assert_eq!(hulls.len(), 1, "one square → one hull");
|
||||||
assert_eq!(hulls[0].area, 400, "20×20 = 400 px");
|
assert_eq!(hulls[0].area, 400, "20×20 = 400 px");
|
||||||
@@ -409,7 +435,7 @@ pub mod tests {
|
|||||||
let mut dark = filled_rect(2, 2, 11, 11); // 10×10
|
let mut dark = filled_rect(2, 2, 11, 11); // 10×10
|
||||||
dark.extend(filled_rect(20, 20, 29, 29)); // 10×10, separated by gap
|
dark.extend(filled_rect(20, 20, 29, 29)); // 10×10, separated by gap
|
||||||
let luma = make_image(40, 40, &dark);
|
let luma = make_image(40, 40, &dark);
|
||||||
let p = HullParams { threshold: 128, min_area: 1, rdp_epsilon: 1.0, ..HullParams::default() };
|
let p = HullParams { threshold: 128, min_area: 1, ..HullParams::default() };
|
||||||
let hulls = extract_hulls(&luma, &blank_rgb(40, 40), 40, 40, &p);
|
let hulls = extract_hulls(&luma, &blank_rgb(40, 40), 40, 40, &p);
|
||||||
assert_eq!(hulls.len(), 2, "two squares → two hulls");
|
assert_eq!(hulls.len(), 2, "two squares → two hulls");
|
||||||
let mut areas: Vec<u32> = hulls.iter().map(|h| h.area).collect();
|
let mut areas: Vec<u32> = hulls.iter().map(|h| h.area).collect();
|
||||||
@@ -421,7 +447,7 @@ pub mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn single_pixel_hull() {
|
fn single_pixel_hull() {
|
||||||
let luma = make_image(16, 16, &[(8, 8)]);
|
let luma = make_image(16, 16, &[(8, 8)]);
|
||||||
let p = HullParams { threshold: 128, min_area: 1, rdp_epsilon: 0.5, ..HullParams::default() };
|
let p = HullParams { threshold: 128, min_area: 1, ..HullParams::default() };
|
||||||
let hulls = extract_hulls(&luma, &blank_rgb(16, 16), 16, 16, &p);
|
let hulls = extract_hulls(&luma, &blank_rgb(16, 16), 16, 16, &p);
|
||||||
assert_eq!(hulls.len(), 1);
|
assert_eq!(hulls.len(), 1);
|
||||||
assert_eq!(hulls[0].area, 1);
|
assert_eq!(hulls[0].area, 1);
|
||||||
@@ -431,7 +457,7 @@ pub mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn all_dark_image_one_hull() {
|
fn all_dark_image_one_hull() {
|
||||||
let luma = vec![0u8; 16 * 16];
|
let luma = vec![0u8; 16 * 16];
|
||||||
let p = HullParams { threshold: 128, min_area: 1, rdp_epsilon: 1.0, ..HullParams::default() };
|
let p = HullParams { threshold: 128, min_area: 1, ..HullParams::default() };
|
||||||
let hulls = extract_hulls(&luma, &blank_rgb(16, 16), 16, 16, &p);
|
let hulls = extract_hulls(&luma, &blank_rgb(16, 16), 16, 16, &p);
|
||||||
assert_eq!(hulls.len(), 1);
|
assert_eq!(hulls.len(), 1);
|
||||||
assert_eq!(hulls[0].area, 256);
|
assert_eq!(hulls[0].area, 256);
|
||||||
@@ -452,7 +478,7 @@ pub mod tests {
|
|||||||
dark.push((0, 0)); // 1px noise
|
dark.push((0, 0)); // 1px noise
|
||||||
dark.push((31, 31)); // 1px noise
|
dark.push((31, 31)); // 1px noise
|
||||||
let luma = make_image(32, 32, &dark);
|
let luma = make_image(32, 32, &dark);
|
||||||
let p = HullParams { threshold: 128, min_area: 4, rdp_epsilon: 1.0, ..HullParams::default() };
|
let p = HullParams { threshold: 128, min_area: 4, ..HullParams::default() };
|
||||||
let hulls = extract_hulls(&luma, &blank_rgb(32, 32), 32, 32, &p);
|
let hulls = extract_hulls(&luma, &blank_rgb(32, 32), 32, 32, &p);
|
||||||
assert_eq!(hulls.len(), 1, "min_area=4 must remove single-pixel noise");
|
assert_eq!(hulls.len(), 1, "min_area=4 must remove single-pixel noise");
|
||||||
assert_eq!(hulls[0].area, 100);
|
assert_eq!(hulls[0].area, 100);
|
||||||
@@ -464,7 +490,7 @@ pub mod tests {
|
|||||||
.flat_map(|x| [31u32, 32, 33].iter().map(move |&y| (x, y)))
|
.flat_map(|x| [31u32, 32, 33].iter().map(move |&y| (x, y)))
|
||||||
.collect();
|
.collect();
|
||||||
let luma = make_image(64, 64, &dark);
|
let luma = make_image(64, 64, &dark);
|
||||||
let p = HullParams { threshold: 128, min_area: 1, rdp_epsilon: 1.0, ..HullParams::default() };
|
let p = HullParams { threshold: 128, min_area: 1, ..HullParams::default() };
|
||||||
let hulls = extract_hulls(&luma, &blank_rgb(64, 64), 64, 64, &p);
|
let hulls = extract_hulls(&luma, &blank_rgb(64, 64), 64, 64, &p);
|
||||||
assert_eq!(hulls.len(), 1, "horizontal line → one hull");
|
assert_eq!(hulls.len(), 1, "horizontal line → one hull");
|
||||||
assert_eq!(hulls[0].area, 64 * 3);
|
assert_eq!(hulls[0].area, 64 * 3);
|
||||||
@@ -477,7 +503,7 @@ pub mod tests {
|
|||||||
.flat_map(|&cy| (0..64u32).map(move |x| (x, cy)))
|
.flat_map(|&cy| (0..64u32).map(move |x| (x, cy)))
|
||||||
.collect();
|
.collect();
|
||||||
let luma = make_image(64, 64, &dark);
|
let luma = make_image(64, 64, &dark);
|
||||||
let p = HullParams { threshold: 128, min_area: 1, rdp_epsilon: 1.0, ..HullParams::default() };
|
let p = HullParams { threshold: 128, min_area: 1, ..HullParams::default() };
|
||||||
let hulls = extract_hulls(&luma, &blank_rgb(64, 64), 64, 64, &p);
|
let hulls = extract_hulls(&luma, &blank_rgb(64, 64), 64, 64, &p);
|
||||||
assert_eq!(hulls.len(), 3, "three separate lines → three hulls");
|
assert_eq!(hulls.len(), 3, "three separate lines → three hulls");
|
||||||
let mut areas: Vec<u32> = hulls.iter().map(|h| h.area).collect();
|
let mut areas: Vec<u32> = hulls.iter().map(|h| h.area).collect();
|
||||||
@@ -493,7 +519,7 @@ pub mod tests {
|
|||||||
let mut dark = filled_rect(0, 0, 4, 4);
|
let mut dark = filled_rect(0, 0, 4, 4);
|
||||||
dark.extend(filled_rect(5, 5, 9, 9)); // touches corner (4,4)↔(5,5) diagonally
|
dark.extend(filled_rect(5, 5, 9, 9)); // touches corner (4,4)↔(5,5) diagonally
|
||||||
let luma = make_image(16, 16, &dark);
|
let luma = make_image(16, 16, &dark);
|
||||||
let p = HullParams { threshold: 128, min_area: 1, rdp_epsilon: 1.0, connectivity: Connectivity::Four };
|
let p = HullParams { threshold: 128, min_area: 1, ..HullParams::default() };
|
||||||
let hulls = extract_hulls(&luma, &blank_rgb(16, 16), 16, 16, &p);
|
let hulls = extract_hulls(&luma, &blank_rgb(16, 16), 16, 16, &p);
|
||||||
assert_eq!(hulls.len(), 2, "diagonally touching squares = two 4-connected hulls");
|
assert_eq!(hulls.len(), 2, "diagonally touching squares = two 4-connected hulls");
|
||||||
}
|
}
|
||||||
@@ -504,7 +530,7 @@ pub mod tests {
|
|||||||
let mut dark = filled_rect(0, 0, 4, 4);
|
let mut dark = filled_rect(0, 0, 4, 4);
|
||||||
dark.extend(filled_rect(5, 5, 9, 9));
|
dark.extend(filled_rect(5, 5, 9, 9));
|
||||||
let luma = make_image(16, 16, &dark);
|
let luma = make_image(16, 16, &dark);
|
||||||
let p = HullParams { threshold: 128, min_area: 1, rdp_epsilon: 1.0, connectivity: Connectivity::Eight };
|
let p = HullParams { threshold: 128, min_area: 1, connectivity: Connectivity::Eight };
|
||||||
let hulls = extract_hulls(&luma, &blank_rgb(16, 16), 16, 16, &p);
|
let hulls = extract_hulls(&luma, &blank_rgb(16, 16), 16, 16, &p);
|
||||||
assert_eq!(hulls.len(), 1, "diagonally touching squares = one 8-connected hull");
|
assert_eq!(hulls.len(), 1, "diagonally touching squares = one 8-connected hull");
|
||||||
}
|
}
|
||||||
@@ -515,43 +541,13 @@ pub mod tests {
|
|||||||
fn contour_pixels_are_on_boundary() {
|
fn contour_pixels_are_on_boundary() {
|
||||||
let dark = filled_rect(5, 5, 20, 20);
|
let dark = filled_rect(5, 5, 20, 20);
|
||||||
let luma = make_image(32, 32, &dark);
|
let luma = make_image(32, 32, &dark);
|
||||||
let p = HullParams { threshold: 128, min_area: 1, rdp_epsilon: 0.5, ..HullParams::default() };
|
let p = HullParams { threshold: 128, min_area: 1, ..HullParams::default() };
|
||||||
let hulls = extract_hulls(&luma, &blank_rgb(32, 32), 32, 32, &p);
|
let hulls = extract_hulls(&luma, &blank_rgb(32, 32), 32, 32, &p);
|
||||||
assert_eq!(hulls.len(), 1);
|
assert_eq!(hulls.len(), 1);
|
||||||
assert!(!hulls[0].contour.is_empty());
|
assert!(!hulls[0].contour.is_empty());
|
||||||
assert_contour_on_boundary(&hulls[0]);
|
assert_contour_on_boundary(&hulls[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn square_rdp_yields_four_corners() {
|
|
||||||
// 40×40 square starting at (10,10) → corners at (10,10),(49,10),(49,49),(10,49)
|
|
||||||
let dark = filled_rect(10, 10, 49, 49);
|
|
||||||
let luma = make_image(64, 64, &dark);
|
|
||||||
// epsilon=2.0: straight edges collapse, only corners survive
|
|
||||||
let p = HullParams { threshold: 128, min_area: 1, rdp_epsilon: 2.0, ..HullParams::default() };
|
|
||||||
let hulls = extract_hulls(&luma, &blank_rgb(64, 64), 64, 64, &p);
|
|
||||||
assert_eq!(hulls.len(), 1);
|
|
||||||
let n = hulls[0].simplified.len();
|
|
||||||
assert!(n >= 4 && n <= 6,
|
|
||||||
"40×40 square should simplify to 4 corners (±1 for loop endpoint), got {n}: {:?}",
|
|
||||||
hulls[0].simplified);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn single_line_rdp_yields_two_endpoints() {
|
|
||||||
// Thin 1px horizontal line — only start and end should survive RDP
|
|
||||||
let dark: Vec<(u32, u32)> = (0..64u32).map(|x| (x, 32)).collect();
|
|
||||||
let luma = make_image(64, 64, &dark);
|
|
||||||
let p = HullParams { threshold: 128, min_area: 1, rdp_epsilon: 1.0, ..HullParams::default() };
|
|
||||||
let hulls = extract_hulls(&luma, &blank_rgb(64, 64), 64, 64, &p);
|
|
||||||
assert_eq!(hulls.len(), 1);
|
|
||||||
let n = hulls[0].simplified.len();
|
|
||||||
// A straight line: start + end = 2 points, maybe 3-4 for the thin-hull two-sided contour
|
|
||||||
assert!(n <= 6,
|
|
||||||
"straight line should simplify to very few points, got {n}: {:?}",
|
|
||||||
hulls[0].simplified);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Coverage comparison ───────────────────────────────────────────────────
|
// ── Coverage comparison ───────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Reconstruct a binary mask from hull pixels and compare with original.
|
/// Reconstruct a binary mask from hull pixels and compare with original.
|
||||||
@@ -579,7 +575,7 @@ pub mod tests {
|
|||||||
fn coverage_score_perfect_for_square() {
|
fn coverage_score_perfect_for_square() {
|
||||||
let dark = filled_rect(5, 5, 25, 25);
|
let dark = filled_rect(5, 5, 25, 25);
|
||||||
let luma = make_image(32, 32, &dark);
|
let luma = make_image(32, 32, &dark);
|
||||||
let p = HullParams { threshold: 128, min_area: 1, rdp_epsilon: 1.0, ..HullParams::default() };
|
let p = HullParams { threshold: 128, min_area: 1, ..HullParams::default() };
|
||||||
let hulls = extract_hulls(&luma, &blank_rgb(32, 32), 32, 32, &p);
|
let hulls = extract_hulls(&luma, &blank_rgb(32, 32), 32, 32, &p);
|
||||||
let (prec, rec) = coverage_score(&luma, 32, 32, &hulls, p.threshold);
|
let (prec, rec) = coverage_score(&luma, 32, 32, &hulls, p.threshold);
|
||||||
assert_eq!(prec, 1.0, "precision must be 1.0: no hull pixel is light");
|
assert_eq!(prec, 1.0, "precision must be 1.0: no hull pixel is light");
|
||||||
@@ -592,7 +588,7 @@ pub mod tests {
|
|||||||
.flat_map(|&cy| (0..64u32).map(move |x| (x, cy)))
|
.flat_map(|&cy| (0..64u32).map(move |x| (x, cy)))
|
||||||
.collect();
|
.collect();
|
||||||
let luma = make_image(64, 64, &dark);
|
let luma = make_image(64, 64, &dark);
|
||||||
let p = HullParams { threshold: 128, min_area: 1, rdp_epsilon: 1.0, ..HullParams::default() };
|
let p = HullParams { threshold: 128, min_area: 1, ..HullParams::default() };
|
||||||
let hulls = extract_hulls(&luma, &blank_rgb(64, 64), 64, 64, &p);
|
let hulls = extract_hulls(&luma, &blank_rgb(64, 64), 64, 64, &p);
|
||||||
let (prec, rec) = coverage_score(&luma, 64, 64, &hulls, p.threshold);
|
let (prec, rec) = coverage_score(&luma, 64, 64, &hulls, p.threshold);
|
||||||
assert_eq!(prec, 1.0);
|
assert_eq!(prec, 1.0);
|
||||||
@@ -613,6 +609,7 @@ pub mod tests {
|
|||||||
let rgb = dyn_img.to_rgb8();
|
let rgb = dyn_img.to_rgb8();
|
||||||
let (w, h) = img.dimensions();
|
let (w, h) = img.dimensions();
|
||||||
let params = HullParams { threshold: 128, min_area: 4, ..HullParams::default() };
|
let params = HullParams { threshold: 128, min_area: 4, ..HullParams::default() };
|
||||||
|
let p = HullParams { threshold: 128, min_area: 1, ..HullParams::default() };
|
||||||
let hulls = extract_hulls(img.as_raw(), &rgb, w, h, ¶ms);
|
let hulls = extract_hulls(img.as_raw(), &rgb, w, h, ¶ms);
|
||||||
assert_eq!(hulls.len(), 128, "checkerboard has 128 dark cells");
|
assert_eq!(hulls.len(), 128, "checkerboard has 128 dark cells");
|
||||||
for hull in &hulls {
|
for hull in &hulls {
|
||||||
|
|||||||
290
src/lib.rs
290
src/lib.rs
@@ -83,6 +83,7 @@ fn fp_kernel(node: &GraphNodePayload, upstream_fp: u64) -> u64 {
|
|||||||
h.write_u32(cf.sat_min.to_bits()); h.write_u32(cf.sat_max.to_bits());
|
h.write_u32(cf.sat_min.to_bits()); h.write_u32(cf.sat_max.to_bits());
|
||||||
h.write_u32(cf.val_min.to_bits()); h.write_u32(cf.val_max.to_bits());
|
h.write_u32(cf.val_min.to_bits()); h.write_u32(cf.val_max.to_bits());
|
||||||
}
|
}
|
||||||
|
h.write_u32(node.kernel_dpi.unwrap_or(0));
|
||||||
h.finish()
|
h.finish()
|
||||||
}
|
}
|
||||||
fn fp_combine(node: &GraphNodePayload, upstream_fps: &[u64]) -> u64 {
|
fn fp_combine(node: &GraphNodePayload, upstream_fps: &[u64]) -> u64 {
|
||||||
@@ -96,7 +97,6 @@ fn fp_hull(node: &GraphNodePayload, upstream_fp: u64) -> u64 {
|
|||||||
h.write_u64(upstream_fp);
|
h.write_u64(upstream_fp);
|
||||||
h.write_u8(node.threshold.unwrap_or(128));
|
h.write_u8(node.threshold.unwrap_or(128));
|
||||||
h.write_u32(node.min_area.unwrap_or(4));
|
h.write_u32(node.min_area.unwrap_or(4));
|
||||||
h.write_u32(node.rdp_epsilon.unwrap_or(1.5).to_bits());
|
|
||||||
h.write(node.connectivity.as_deref().unwrap_or("four").as_bytes());
|
h.write(node.connectivity.as_deref().unwrap_or("four").as_bytes());
|
||||||
if let Some(cf) = &node.color_filter {
|
if let Some(cf) = &node.color_filter {
|
||||||
h.write_u8(cf.enabled as u8);
|
h.write_u8(cf.enabled as u8);
|
||||||
@@ -117,12 +117,15 @@ fn fp_fill(node: &GraphNodePayload, upstream_fp: u64) -> u64 {
|
|||||||
h.write_u32(node.smooth_iters.unwrap_or(2));
|
h.write_u32(node.smooth_iters.unwrap_or(2));
|
||||||
h.finish()
|
h.finish()
|
||||||
}
|
}
|
||||||
fn fp_pen(node: &GraphNodePayload, upstream_fp: u64) -> u64 {
|
fn fp_pen(node: &GraphNodePayload, upstream_fp: u64, pen_tip_mm: Option<f32>) -> u64 {
|
||||||
let mut h = DefaultHasher::new();
|
let mut h = DefaultHasher::new();
|
||||||
h.write_u64(upstream_fp);
|
h.write_u64(upstream_fp);
|
||||||
for &v in node.pen_color.as_deref().unwrap_or(&[20, 20, 20]) { h.write_u8(v); }
|
for &v in node.pen_color.as_deref().unwrap_or(&[20, 20, 20]) { h.write_u8(v); }
|
||||||
h.write(node.pen_label.as_deref().unwrap_or("").as_bytes());
|
h.write(node.pen_label.as_deref().unwrap_or("").as_bytes());
|
||||||
h.write_u32(node.pen_order.unwrap_or(0));
|
h.write_u32(node.pen_order.unwrap_or(0));
|
||||||
|
// Pen tip diameter affects the rendered preview's stroke width; bake
|
||||||
|
// it into the fingerprint so changing the slider re-renders the card.
|
||||||
|
h.write_u32(pen_tip_mm.unwrap_or(0.5).to_bits());
|
||||||
h.finish()
|
h.finish()
|
||||||
}
|
}
|
||||||
fn fp_text(node: &GraphNodePayload) -> u64 {
|
fn fp_text(node: &GraphNodePayload) -> u64 {
|
||||||
@@ -176,7 +179,7 @@ fn compute_node_fingerprints(payload: &ProcessPassPayload)
|
|||||||
"Combine" => fp_combine(node, &up_fps),
|
"Combine" => fp_combine(node, &up_fps),
|
||||||
"Hull" => fp_hull(node, first),
|
"Hull" => fp_hull(node, first),
|
||||||
"Fill" => fp_fill(node, first),
|
"Fill" => fp_fill(node, first),
|
||||||
"PenOutput" => fp_pen(node, first),
|
"PenOutput" => fp_pen(node, first, payload.pen_tip_mm),
|
||||||
"Text" => fp_text(node),
|
"Text" => fp_text(node),
|
||||||
_ => 0,
|
_ => 0,
|
||||||
};
|
};
|
||||||
@@ -207,6 +210,8 @@ struct PassState {
|
|||||||
response_map: Vec<u8>,
|
response_map: Vec<u8>,
|
||||||
img_w: u32,
|
img_w: u32,
|
||||||
img_h: u32,
|
img_h: u32,
|
||||||
|
paper_w_mm: f32, // mm dims of the paper this pass was rendered for
|
||||||
|
paper_h_mm: f32,
|
||||||
node_cache: NodeCache,
|
node_cache: NodeCache,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,10 +250,10 @@ pub struct GraphNodePayload {
|
|||||||
pub xdog_phi: Option<f32>,
|
pub xdog_phi: Option<f32>,
|
||||||
// Combine params (optional)
|
// Combine params (optional)
|
||||||
pub blend_mode: Option<String>,
|
pub blend_mode: Option<String>,
|
||||||
|
pub kernel_dpi: Option<u32>,
|
||||||
// Hull params (optional — only for kind="Hull")
|
// Hull params (optional — only for kind="Hull")
|
||||||
pub threshold: Option<u8>,
|
pub threshold: Option<u8>,
|
||||||
pub min_area: Option<u32>,
|
pub min_area: Option<u32>,
|
||||||
pub rdp_epsilon: Option<f32>,
|
|
||||||
pub connectivity: Option<String>,
|
pub connectivity: Option<String>,
|
||||||
pub color_filter: Option<ColorFilterPayload>,
|
pub color_filter: Option<ColorFilterPayload>,
|
||||||
// Fill params (optional — only for kind="Fill")
|
// Fill params (optional — only for kind="Fill")
|
||||||
@@ -311,6 +316,9 @@ pub struct ProcessPassPayload {
|
|||||||
/// projects work without an image load. Image-input projects
|
/// projects work without an image load. Image-input projects
|
||||||
/// ignore this and derive height from aspect ratio.
|
/// ignore this and derive height from aspect ratio.
|
||||||
pub img_h_mm: Option<f32>,
|
pub img_h_mm: Option<f32>,
|
||||||
|
/// Pen tip diameter in mm — drives the pen-card preview stroke
|
||||||
|
/// width so previews show the physical ink width rather than 1 px.
|
||||||
|
pub pen_tip_mm: Option<f32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Clone, Default)]
|
#[derive(Serialize, Clone, Default)]
|
||||||
@@ -377,9 +385,12 @@ fn default_pen_dwell() -> u32 { 250 }
|
|||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct AllStrokesPayload {
|
pub struct AllStrokesPayload {
|
||||||
pub passes: Vec<PassStrokesPayload>,
|
pub passes: Vec<PassStrokesPayload>,
|
||||||
pub img_width: u32,
|
/// Stroke coords are in mm. paper_w_mm / paper_h_mm describe the
|
||||||
pub img_height: u32,
|
/// paper rect the strokes are positioned within so the frontend
|
||||||
|
/// knows how to scale them to screen.
|
||||||
|
pub paper_w_mm: f32,
|
||||||
|
pub paper_h_mm: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -425,6 +436,7 @@ fn to_detection_graph(payload: &DetectionGraphPayload) -> detect::DetectionGraph
|
|||||||
ci_sat_max: cf.map(|f| f.sat_max).unwrap_or(1.0),
|
ci_sat_max: cf.map(|f| f.sat_max).unwrap_or(1.0),
|
||||||
ci_val_min: cf.map(|f| f.val_min).unwrap_or(0.0),
|
ci_val_min: cf.map(|f| f.val_min).unwrap_or(0.0),
|
||||||
ci_val_max: cf.map(|f| f.val_max).unwrap_or(1.0),
|
ci_val_max: cf.map(|f| f.val_max).unwrap_or(1.0),
|
||||||
|
kernel_dpi: n.kernel_dpi,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
"Combine" => {
|
"Combine" => {
|
||||||
@@ -466,7 +478,6 @@ fn to_detection_graph(payload: &DetectionGraphPayload) -> detect::DetectionGraph
|
|||||||
detect::NodeKind::Hull {
|
detect::NodeKind::Hull {
|
||||||
threshold: n.threshold.unwrap_or(128),
|
threshold: n.threshold.unwrap_or(128),
|
||||||
min_area: n.min_area.unwrap_or(4),
|
min_area: n.min_area.unwrap_or(4),
|
||||||
rdp_epsilon: n.rdp_epsilon.unwrap_or(1.5),
|
|
||||||
eight_conn: n.connectivity.as_deref() == Some("eight"),
|
eight_conn: n.connectivity.as_deref() == Some("eight"),
|
||||||
cf_enabled: cf.map(|f| f.enabled).unwrap_or(false),
|
cf_enabled: cf.map(|f| f.enabled).unwrap_or(false),
|
||||||
cf_hue_min: cf.map(|f| f.hue_min).unwrap_or(0.0),
|
cf_hue_min: cf.map(|f| f.hue_min).unwrap_or(0.0),
|
||||||
@@ -530,11 +541,51 @@ fn rgb_to_b64_jpeg(rgb: &image::RgbImage) -> String {
|
|||||||
|
|
||||||
// ── Pipeline inner functions (no Tauri, no mutex) ─────────────────────────────
|
// ── Pipeline inner functions (no Tauri, no mutex) ─────────────────────────────
|
||||||
|
|
||||||
/// Rasterize fill strokes in the pen's color on a light background at full image resolution.
|
/// Fixed preview resolution — paper × this rate gives the preview
|
||||||
fn render_pen_preview(color: [u8; 3], fill: &fill::FillResult, img_w: u32, img_h: u32) -> String {
|
/// thumbnail's pixel dimensions. DPI-independent so Hull/Fill/Pen
|
||||||
|
/// thumbnails look identical regardless of project DPI; they're just
|
||||||
|
/// rendering polygon/mm-stroke data that doesn't change with DPI.
|
||||||
|
const PREVIEW_PX_PER_MM: f32 = 5.0;
|
||||||
|
|
||||||
|
fn preview_dims_for_paper(paper_w_mm: f32, paper_h_mm: f32) -> (u32, u32) {
|
||||||
|
let w = (paper_w_mm * PREVIEW_PX_PER_MM).round().max(1.0) as u32;
|
||||||
|
let h = (paper_h_mm * PREVIEW_PX_PER_MM).round().max(1.0) as u32;
|
||||||
|
(w, h)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rasterize mm-coord fill strokes onto a preview canvas. The preview
|
||||||
|
/// dimensions are fixed at `paper × PREVIEW_PX_PER_MM`, decoupled from
|
||||||
|
/// project DPI — pen-output data is purely mm so thumbnails should look
|
||||||
|
/// identical regardless of DPI.
|
||||||
|
fn render_pen_preview(color: [u8; 3], fill: &fill::FillResult,
|
||||||
|
paper_w_mm: f32, paper_h_mm: f32, pen_tip_mm: f32) -> String {
|
||||||
|
let (img_w, img_h) = preview_dims_for_paper(paper_w_mm, paper_h_mm);
|
||||||
|
let radius_px = (pen_tip_mm * PREVIEW_PX_PER_MM / 2.0).max(0.5);
|
||||||
|
let scaled = fill_strokes_mm_to_preview_px(fill, paper_w_mm, img_w);
|
||||||
let mut pix = vec![[235u8, 235u8, 235u8]; (img_w * img_h) as usize];
|
let mut pix = vec![[235u8, 235u8, 235u8]; (img_w * img_h) as usize];
|
||||||
|
|
||||||
for stroke in &fill.strokes {
|
// Pre-compute pen-disk pixel offsets so each Bresenham step stamps a
|
||||||
|
// circle, giving the preview the actual physical ink width.
|
||||||
|
let r_ceil = radius_px.ceil() as i32;
|
||||||
|
let r2 = radius_px * radius_px;
|
||||||
|
let mut disk: Vec<(i32, i32)> = Vec::with_capacity((r_ceil * r_ceil * 4) as usize + 1);
|
||||||
|
for dy in -r_ceil..=r_ceil {
|
||||||
|
for dx in -r_ceil..=r_ceil {
|
||||||
|
let d2 = (dx * dx + dy * dy) as f32;
|
||||||
|
if d2 <= r2 { disk.push((dx, dy)); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let stamp = |pix: &mut Vec<[u8; 3]>, x: i32, y: i32| {
|
||||||
|
for &(dx, dy) in &disk {
|
||||||
|
let nx = x + dx;
|
||||||
|
let ny = y + dy;
|
||||||
|
if nx < 0 || ny < 0 || nx >= img_w as i32 || ny >= img_h as i32 { continue; }
|
||||||
|
pix[(ny as u32 * img_w + nx as u32) as usize] = color;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for stroke in &scaled.strokes {
|
||||||
for pair in stroke.windows(2) {
|
for pair in stroke.windows(2) {
|
||||||
let (mut x, mut y) = (pair[0].0.round() as i32, pair[0].1.round() as i32);
|
let (mut x, mut y) = (pair[0].0.round() as i32, pair[0].1.round() as i32);
|
||||||
let (x1, y1) = (pair[1].0.round() as i32, pair[1].1.round() as i32);
|
let (x1, y1) = (pair[1].0.round() as i32, pair[1].1.round() as i32);
|
||||||
@@ -542,9 +593,7 @@ fn render_pen_preview(color: [u8; 3], fill: &fill::FillResult, img_w: u32, img_h
|
|||||||
let dy = -(y1 - y).abs(); let sy_ = if y < y1 { 1i32 } else { -1 };
|
let dy = -(y1 - y).abs(); let sy_ = if y < y1 { 1i32 } else { -1 };
|
||||||
let mut err = dx + dy;
|
let mut err = dx + dy;
|
||||||
loop {
|
loop {
|
||||||
if x >= 0 && y >= 0 && (x as u32) < img_w && (y as u32) < img_h {
|
stamp(&mut pix, x, y);
|
||||||
pix[(y as u32 * img_w + x as u32) as usize] = color;
|
|
||||||
}
|
|
||||||
if x == x1 && y == y1 { break; }
|
if x == x1 && y == y1 { break; }
|
||||||
let e2 = 2 * err;
|
let e2 = 2 * err;
|
||||||
if e2 >= dy { err += dy; x += sx_; }
|
if e2 >= dy { err += dy; x += sx_; }
|
||||||
@@ -560,11 +609,23 @@ fn render_pen_preview(color: [u8; 3], fill: &fill::FillResult, img_w: u32, img_h
|
|||||||
B64.encode(buf.into_inner())
|
B64.encode(buf.into_inner())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rasterize fill strokes into a JPEG preview at full image resolution.
|
/// Convert a FillResult's mm strokes into pixel strokes for the preview canvas.
|
||||||
fn render_fill_preview(result: &fill::FillResult, img_w: u32, img_h: u32) -> String {
|
fn fill_strokes_mm_to_preview_px(result: &fill::FillResult, paper_w_mm: f32, img_w: u32) -> fill::FillResult {
|
||||||
|
let s = if paper_w_mm > 0.0 { img_w as f32 / paper_w_mm } else { 1.0 };
|
||||||
|
let strokes: Vec<Vec<(f32, f32)>> = result.strokes.iter()
|
||||||
|
.map(|stk| stk.iter().map(|&(x, y)| (x * s, y * s)).collect())
|
||||||
|
.collect();
|
||||||
|
fill::FillResult { hull_id: result.hull_id, strokes }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rasterize mm-coord fill strokes into a JPEG preview at fixed
|
||||||
|
/// preview resolution (DPI-independent).
|
||||||
|
fn render_fill_preview(result: &fill::FillResult, paper_w_mm: f32, paper_h_mm: f32) -> String {
|
||||||
|
let (img_w, img_h) = preview_dims_for_paper(paper_w_mm, paper_h_mm);
|
||||||
|
let scaled = fill_strokes_mm_to_preview_px(result, paper_w_mm, img_w);
|
||||||
let mut pix = vec![20u8; (img_w * img_h) as usize];
|
let mut pix = vec![20u8; (img_w * img_h) as usize];
|
||||||
|
|
||||||
for stroke in &result.strokes {
|
for stroke in &scaled.strokes {
|
||||||
for pair in stroke.windows(2) {
|
for pair in stroke.windows(2) {
|
||||||
let (mut x, mut y) = (pair[0].0.round() as i32, pair[0].1.round() as i32);
|
let (mut x, mut y) = (pair[0].0.round() as i32, pair[0].1.round() as i32);
|
||||||
let (x1, y1) = (pair[1].0.round() as i32, pair[1].1.round() as i32);
|
let (x1, y1) = (pair[1].0.round() as i32, pair[1].1.round() as i32);
|
||||||
@@ -591,21 +652,33 @@ fn render_fill_preview(result: &fill::FillResult, img_w: u32, img_h: u32) -> Str
|
|||||||
B64.encode(buf.into_inner())
|
B64.encode(buf.into_inner())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_hull_preview(response: &[u8], hulls_list: &[hulls::Hull], w: u32, h: u32) -> String {
|
/// Hull preview: paint each hull's pixel set onto a fixed-resolution
|
||||||
let mut rgba = vec![15u8; (w * h * 4) as usize];
|
/// preview canvas. Pixel coords are scaled from canvas pixels (where the
|
||||||
|
/// hulls live) to preview pixels via `paper_w_mm`-derived ratios — so
|
||||||
|
/// the rendered thumbnail is DPI-independent.
|
||||||
|
fn render_hull_preview(response: &[u8], hulls_list: &[hulls::Hull],
|
||||||
|
canvas_w: u32, canvas_h: u32,
|
||||||
|
paper_w_mm: f32, paper_h_mm: f32) -> String {
|
||||||
|
let (pw, ph) = preview_dims_for_paper(paper_w_mm, paper_h_mm);
|
||||||
|
let sx = if canvas_w > 0 { pw as f32 / canvas_w as f32 } else { 1.0 };
|
||||||
|
let sy = if canvas_h > 0 { ph as f32 / canvas_h as f32 } else { 1.0 };
|
||||||
|
let mut rgba = vec![15u8; (pw * ph * 4) as usize];
|
||||||
for chunk in rgba.chunks_mut(4) { chunk[3] = 255; }
|
for chunk in rgba.chunks_mut(4) { chunk[3] = 255; }
|
||||||
for hull in hulls_list {
|
for hull in hulls_list {
|
||||||
let (hr, hg, hb) = hash_color(hull.id);
|
let (hr, hg, hb) = hash_color(hull.id);
|
||||||
for &(px, py) in &hull.pixels {
|
for &(px, py) in &hull.pixels {
|
||||||
let resp = response.get((py * w + px) as usize).copied().unwrap_or(0);
|
let nx = (px as f32 * sx) as u32;
|
||||||
|
let ny = (py as f32 * sy) as u32;
|
||||||
|
if nx >= pw || ny >= ph { continue; }
|
||||||
|
let resp = response.get((py * canvas_w + px) as usize).copied().unwrap_or(0);
|
||||||
let intensity = (255u32 - resp as u32) as f32 / 255.0;
|
let intensity = (255u32 - resp as u32) as f32 / 255.0;
|
||||||
let i = ((py * w + px) * 4) as usize;
|
let i = ((ny * pw + nx) * 4) as usize;
|
||||||
rgba[i] = (hr as f32 * intensity) as u8;
|
rgba[i] = (hr as f32 * intensity) as u8;
|
||||||
rgba[i+1] = (hg as f32 * intensity) as u8;
|
rgba[i+1] = (hg as f32 * intensity) as u8;
|
||||||
rgba[i+2] = (hb as f32 * intensity) as u8;
|
rgba[i+2] = (hb as f32 * intensity) as u8;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
rgba_to_b64_png(&rgba, w, h)
|
rgba_to_b64_png(&rgba, pw, ph)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn process_pass_work(
|
fn process_pass_work(
|
||||||
@@ -627,6 +700,13 @@ fn process_pass_work(
|
|||||||
// in a single coord frame.
|
// in a single coord frame.
|
||||||
let (w, h) = (canvas_w, canvas_h);
|
let (w, h) = (canvas_w, canvas_h);
|
||||||
|
|
||||||
|
// Paper dims (mm) — shared by mm-fill conversion, preview rendering,
|
||||||
|
// and gradient-fill source sampling. Hoisted so every stage uses the
|
||||||
|
// same numbers.
|
||||||
|
let paper_w_mm_for_scale = payload.img_w_mm.unwrap_or(210.0).max(1.0);
|
||||||
|
let paper_h_mm_for_scale = payload.img_h_mm.unwrap_or(297.0).max(1.0);
|
||||||
|
let px_per_mm_fill = w as f32 / paper_w_mm_for_scale;
|
||||||
|
|
||||||
// ── Per-node Source RGB lookup ────────────────────────────────────────────
|
// ── Per-node Source RGB lookup ────────────────────────────────────────────
|
||||||
// Trees can't merge (frontend enforces; backend assumes), so each non-
|
// Trees can't merge (frontend enforces; backend assumes), so each non-
|
||||||
// Source node has exactly one Source ancestor. Topo-walk to propagate
|
// Source node has exactly one Source ancestor. Topo-walk to propagate
|
||||||
@@ -701,7 +781,8 @@ fn process_pass_work(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
cache_misses += 1;
|
cache_misses += 1;
|
||||||
let maps = detect::evaluate_graph(&det_graph, &node_rgbs, canvas_w, canvas_h);
|
let maps = detect::evaluate_graph(&det_graph, &node_rgbs, canvas_w, canvas_h,
|
||||||
|
payload.dpi.unwrap_or(150) as f32);
|
||||||
cache.detect_fp = detect_fp;
|
cache.detect_fp = detect_fp;
|
||||||
cache.detect_response = maps.response.clone();
|
cache.detect_response = maps.response.clone();
|
||||||
cache.detect_maps = maps.raw_maps.clone();
|
cache.detect_maps = maps.raw_maps.clone();
|
||||||
@@ -767,7 +848,7 @@ fn process_pass_work(
|
|||||||
|
|
||||||
for node in &det_graph.nodes {
|
for node in &det_graph.nodes {
|
||||||
if let detect::NodeKind::Hull {
|
if let detect::NodeKind::Hull {
|
||||||
threshold, min_area, rdp_epsilon, eight_conn, ..
|
threshold, min_area, eight_conn, ..
|
||||||
} = &node.kind {
|
} = &node.kind {
|
||||||
let response = match graph_maps.raw_maps.get(&node.id) {
|
let response = match graph_maps.raw_maps.get(&node.id) {
|
||||||
Some(m) => m,
|
Some(m) => m,
|
||||||
@@ -791,7 +872,7 @@ fn process_pass_work(
|
|||||||
}
|
}
|
||||||
all_hulls.extend(entry.hulls.clone());
|
all_hulls.extend(entry.hulls.clone());
|
||||||
let p = preview.unwrap_or_else(|| {
|
let p = preview.unwrap_or_else(|| {
|
||||||
let p = render_hull_preview(response, &entry.hulls, w, h);
|
let p = render_hull_preview(response, &entry.hulls, w, h, paper_w_mm_for_scale, paper_h_mm_for_scale);
|
||||||
cache.preview_cache.insert(node.id.clone(), (hull_fp, p.clone()));
|
cache.preview_cache.insert(node.id.clone(), (hull_fp, p.clone()));
|
||||||
p
|
p
|
||||||
});
|
});
|
||||||
@@ -809,12 +890,11 @@ fn process_pass_work(
|
|||||||
let hull_params = hulls::HullParams {
|
let hull_params = hulls::HullParams {
|
||||||
threshold: *threshold,
|
threshold: *threshold,
|
||||||
min_area: *min_area,
|
min_area: *min_area,
|
||||||
rdp_epsilon: *rdp_epsilon,
|
|
||||||
connectivity: if *eight_conn { hulls::Connectivity::Eight }
|
connectivity: if *eight_conn { hulls::Connectivity::Eight }
|
||||||
else { hulls::Connectivity::Four },
|
else { hulls::Connectivity::Four },
|
||||||
};
|
};
|
||||||
let extracted = hulls::extract_hulls(response, src_rgb, w, h, &hull_params);
|
let extracted = hulls::extract_hulls(response, src_rgb, w, h, &hull_params);
|
||||||
let preview = render_hull_preview(response, &extracted, w, h);
|
let preview = render_hull_preview(response, &extracted, w, h, paper_w_mm_for_scale, paper_h_mm_for_scale);
|
||||||
cache.hull_entries.insert(node.id.clone(), HullCacheEntry {
|
cache.hull_entries.insert(node.id.clone(), HullCacheEntry {
|
||||||
fp: hull_fp,
|
fp: hull_fp,
|
||||||
hulls: extracted.clone(),
|
hulls: extracted.clone(),
|
||||||
@@ -831,12 +911,11 @@ fn process_pass_work(
|
|||||||
let hull_params = hulls::HullParams {
|
let hull_params = hulls::HullParams {
|
||||||
threshold: *threshold,
|
threshold: *threshold,
|
||||||
min_area: *min_area,
|
min_area: *min_area,
|
||||||
rdp_epsilon: *rdp_epsilon,
|
|
||||||
connectivity: if *eight_conn { hulls::Connectivity::Eight }
|
connectivity: if *eight_conn { hulls::Connectivity::Eight }
|
||||||
else { hulls::Connectivity::Four },
|
else { hulls::Connectivity::Four },
|
||||||
};
|
};
|
||||||
let extracted = hulls::extract_hulls(response, src_rgb, w, h, &hull_params);
|
let extracted = hulls::extract_hulls(response, src_rgb, w, h, &hull_params);
|
||||||
let preview = render_hull_preview(response, &extracted, w, h);
|
let preview = render_hull_preview(response, &extracted, w, h, paper_w_mm_for_scale, paper_h_mm_for_scale);
|
||||||
(extracted, preview)
|
(extracted, preview)
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -871,16 +950,16 @@ fn process_pass_work(
|
|||||||
|
|
||||||
let fill_fp = node_fps.get(&node.id).copied().unwrap_or(0);
|
let fill_fp = node_fps.get(&node.id).copied().unwrap_or(0);
|
||||||
|
|
||||||
let (optimised, preview) = if fill_fp != 0 {
|
// Try the cache first.
|
||||||
|
if fill_fp != 0 {
|
||||||
if let Some(entry) = cache.fill_entries.get(&node.id) {
|
if let Some(entry) = cache.fill_entries.get(&node.id) {
|
||||||
if entry.fp == fill_fp {
|
if entry.fp == fill_fp {
|
||||||
// Cache hit
|
|
||||||
let preview = cache.preview_cache.get(&node.id)
|
let preview = cache.preview_cache.get(&node.id)
|
||||||
.filter(|(fp, _)| *fp == fill_fp)
|
.filter(|(fp, _)| *fp == fill_fp)
|
||||||
.map(|(_, p)| p.clone());
|
.map(|(_, p)| p.clone());
|
||||||
cache_hits += 1;
|
cache_hits += 1;
|
||||||
let p = preview.unwrap_or_else(|| {
|
let p = preview.unwrap_or_else(|| {
|
||||||
let p = render_fill_preview(&entry.fill, w, h);
|
let p = render_fill_preview(&entry.fill, paper_w_mm_for_scale, paper_h_mm_for_scale);
|
||||||
cache.preview_cache.insert(node.id.clone(), (fill_fp, p.clone()));
|
cache.preview_cache.insert(node.id.clone(), (fill_fp, p.clone()));
|
||||||
p
|
p
|
||||||
});
|
});
|
||||||
@@ -890,61 +969,43 @@ fn process_pass_work(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
cache_misses += 1;
|
cache_misses += 1;
|
||||||
// Cache miss — compute
|
}
|
||||||
let response_arc: std::sync::Arc<[u8]> = resp_for_fill.into();
|
|
||||||
let (strategy, spacing, angle, param, smooth_rdp, smooth_iters) =
|
// Compute. Convert pixel hulls to mm hulls once; mm fills handle
|
||||||
(strategy.clone(), *spacing, *angle, *param, *smooth_rdp, *smooth_iters);
|
// their own internal rasterization at FILL_INTERNAL_PX_PER_MM.
|
||||||
let img_w = w;
|
let response_arc: std::sync::Arc<[u8]> = resp_for_fill.into();
|
||||||
let raw: Vec<fill::FillResult> = hulls_for_fill.par_iter().map(|hull| {
|
let (strategy, spacing_mm, angle, param, smooth_rdp, smooth_iters) =
|
||||||
match strategy.as_str() {
|
(strategy.clone(), *spacing, *angle, *param, *smooth_rdp, *smooth_iters);
|
||||||
"outline" => fill::outline(hull),
|
let mm_hulls: Vec<hulls::MmHull> = hulls_for_fill.iter()
|
||||||
"zigzag" => fill::zigzag_hatch(hull, spacing, angle),
|
.map(|h| hulls::MmHull::from_pixel_hull(h, px_per_mm_fill))
|
||||||
"offset" => fill::contour_offset(hull, spacing),
|
.collect();
|
||||||
"spiral" => fill::spiral(hull, spacing),
|
let img_w = w;
|
||||||
"circles" => fill::circle_pack(hull, spacing, param.max(0.1)),
|
let src_px_per_mm = px_per_mm_fill;
|
||||||
"voronoi" => fill::voronoi_fill(hull, spacing),
|
let raw: Vec<fill::FillResult> = mm_hulls.par_iter().map(|mh| {
|
||||||
"hilbert" => fill::hilbert_fill(hull, spacing),
|
match strategy.as_str() {
|
||||||
"waves" => fill::wave_interference(hull, spacing, param.round().max(1.0) as usize),
|
"outline" => fill::outline_mm(mh),
|
||||||
"flow" => fill::flow_field(hull, spacing, angle, param.max(0.0)),
|
"zigzag" => fill::zigzag_hatch_mm(mh, spacing_mm, angle),
|
||||||
"gradient_hatch" => fill::gradient_hatch(hull, &response_arc, img_w, spacing, angle, param.clamp(0.05, 1.0)),
|
"offset" => fill::contour_offset_mm(mh, spacing_mm),
|
||||||
"gradient_cross_hatch" => fill::gradient_cross_hatch(hull, &response_arc, img_w, spacing, angle, param.clamp(0.05, 1.0)),
|
"spiral" => fill::spiral_mm(mh, spacing_mm),
|
||||||
_ => fill::parallel_hatch(hull, spacing, angle),
|
"circles" => fill::circle_pack_mm(mh, spacing_mm, param.max(0.1)),
|
||||||
}
|
"voronoi" => fill::voronoi_fill_mm(mh, spacing_mm),
|
||||||
}).collect();
|
"hilbert" => fill::hilbert_fill_mm(mh, spacing_mm),
|
||||||
let smoothed: Vec<fill::FillResult> = raw.iter()
|
"waves" => fill::wave_interference_mm(mh, spacing_mm, param.round().max(1.0) as usize),
|
||||||
.map(|r| fill::smooth_fill_result(r, smooth_rdp, smooth_iters)).collect();
|
"flow" => fill::flow_field_mm(mh, spacing_mm, angle, param.max(0.0)),
|
||||||
let opt = fill::optimize_travel(&smoothed);
|
"gradient_hatch" => fill::gradient_hatch_mm(mh, &response_arc, img_w, src_px_per_mm, spacing_mm, angle, param.clamp(0.05, 1.0)),
|
||||||
let preview = render_fill_preview(&opt, w, h);
|
"gradient_cross_hatch" => fill::gradient_cross_hatch_mm(mh, &response_arc, img_w, src_px_per_mm, spacing_mm, angle, param.clamp(0.05, 1.0)),
|
||||||
|
_ => fill::parallel_hatch_mm(mh, spacing_mm, angle),
|
||||||
|
}
|
||||||
|
}).collect();
|
||||||
|
let smoothed: Vec<fill::FillResult> = raw.iter()
|
||||||
|
.map(|r| fill::smooth_fill_result(r, smooth_rdp, smooth_iters)).collect();
|
||||||
|
let opt = fill::optimize_travel(&smoothed);
|
||||||
|
let preview = render_fill_preview(&opt, paper_w_mm_for_scale, paper_h_mm_for_scale);
|
||||||
|
if fill_fp != 0 {
|
||||||
cache.fill_entries.insert(node.id.clone(), FillCacheEntry { fp: fill_fp, fill: opt.clone() });
|
cache.fill_entries.insert(node.id.clone(), FillCacheEntry { fp: fill_fp, fill: opt.clone() });
|
||||||
cache.preview_cache.insert(node.id.clone(), (fill_fp, preview.clone()));
|
cache.preview_cache.insert(node.id.clone(), (fill_fp, preview.clone()));
|
||||||
(opt, preview)
|
}
|
||||||
} else {
|
let (optimised, preview) = (opt, preview);
|
||||||
let response_arc: std::sync::Arc<[u8]> = resp_for_fill.into();
|
|
||||||
let (strategy, spacing, angle, param, smooth_rdp, smooth_iters) =
|
|
||||||
(strategy.clone(), *spacing, *angle, *param, *smooth_rdp, *smooth_iters);
|
|
||||||
let img_w = w;
|
|
||||||
let raw: Vec<fill::FillResult> = hulls_for_fill.par_iter().map(|hull| {
|
|
||||||
match strategy.as_str() {
|
|
||||||
"outline" => fill::outline(hull),
|
|
||||||
"zigzag" => fill::zigzag_hatch(hull, spacing, angle),
|
|
||||||
"offset" => fill::contour_offset(hull, spacing),
|
|
||||||
"spiral" => fill::spiral(hull, spacing),
|
|
||||||
"circles" => fill::circle_pack(hull, spacing, param.max(0.1)),
|
|
||||||
"voronoi" => fill::voronoi_fill(hull, spacing),
|
|
||||||
"hilbert" => fill::hilbert_fill(hull, spacing),
|
|
||||||
"waves" => fill::wave_interference(hull, spacing, param.round().max(1.0) as usize),
|
|
||||||
"flow" => fill::flow_field(hull, spacing, angle, param.max(0.0)),
|
|
||||||
"gradient_hatch" => fill::gradient_hatch(hull, &response_arc, img_w, spacing, angle, param.clamp(0.05, 1.0)),
|
|
||||||
"gradient_cross_hatch" => fill::gradient_cross_hatch(hull, &response_arc, img_w, spacing, angle, param.clamp(0.05, 1.0)),
|
|
||||||
_ => fill::parallel_hatch(hull, spacing, angle),
|
|
||||||
}
|
|
||||||
}).collect();
|
|
||||||
let smoothed: Vec<fill::FillResult> = raw.iter()
|
|
||||||
.map(|r| fill::smooth_fill_result(r, smooth_rdp, smooth_iters)).collect();
|
|
||||||
let opt = fill::optimize_travel(&smoothed);
|
|
||||||
let preview = render_fill_preview(&opt, w, h);
|
|
||||||
(opt, preview)
|
|
||||||
};
|
|
||||||
|
|
||||||
node_previews.insert(node.id.clone(), preview);
|
node_previews.insert(node.id.clone(), preview);
|
||||||
fill_outputs.insert(node.id.clone(), optimised);
|
fill_outputs.insert(node.id.clone(), optimised);
|
||||||
@@ -953,30 +1014,21 @@ fn process_pass_work(
|
|||||||
t = lap!(steps, "fill", t);
|
t = lap!(steps, "fill", t);
|
||||||
|
|
||||||
// ── Text nodes ─────────────────────────────────────────────────────────────
|
// ── Text nodes ─────────────────────────────────────────────────────────────
|
||||||
// Text nodes produce strokes in mm directly via Hershey; we convert to
|
// Text nodes produce strokes in mm directly via Hershey — they're already
|
||||||
// pixel coords matching the rest of the pipeline so PenOutput can pull
|
// in the FillResult coord system (mm), so just pass through.
|
||||||
// them from `fill_outputs` exactly like a Fill node.
|
|
||||||
let dpi = payload.dpi.unwrap_or(150).max(1) as f32;
|
|
||||||
let paper_w_mm_for_scale = payload.img_w_mm.unwrap_or(w as f32 * 25.4 / dpi);
|
|
||||||
let px_per_mm = w as f32 / paper_w_mm_for_scale.max(1e-3);
|
|
||||||
for node in &det_graph.nodes {
|
for node in &det_graph.nodes {
|
||||||
if let detect::NodeKind::Text {
|
if let detect::NodeKind::Text {
|
||||||
text, font, font_size_mm, line_spacing_mm,
|
text, font, font_size_mm, line_spacing_mm,
|
||||||
x_mm, y_mm, align, underline,
|
x_mm, y_mm, align, underline,
|
||||||
} = &node.kind {
|
} = &node.kind {
|
||||||
let mm_strokes = text::render_text(
|
let strokes = text::render_text(
|
||||||
text, font, *font_size_mm, *line_spacing_mm,
|
text, font, *font_size_mm, *line_spacing_mm,
|
||||||
*x_mm, *y_mm,
|
*x_mm, *y_mm,
|
||||||
text::Align::from_str(align),
|
text::Align::from_str(align),
|
||||||
*underline,
|
*underline,
|
||||||
);
|
);
|
||||||
let strokes: Vec<Vec<(f32, f32)>> = mm_strokes.into_iter()
|
|
||||||
.map(|s| s.into_iter()
|
|
||||||
.map(|(mx, my)| (mx * px_per_mm, my * px_per_mm))
|
|
||||||
.collect())
|
|
||||||
.collect();
|
|
||||||
let fill = fill::FillResult { hull_id: 0, strokes };
|
let fill = fill::FillResult { hull_id: 0, strokes };
|
||||||
let preview = render_fill_preview(&fill, w, h);
|
let preview = render_fill_preview(&fill, paper_w_mm_for_scale, paper_h_mm_for_scale);
|
||||||
node_previews.insert(node.id.clone(), preview);
|
node_previews.insert(node.id.clone(), preview);
|
||||||
fill_outputs.insert(node.id.clone(), fill);
|
fill_outputs.insert(node.id.clone(), fill);
|
||||||
}
|
}
|
||||||
@@ -984,6 +1036,11 @@ fn process_pass_work(
|
|||||||
t = lap!(steps, "text", t);
|
t = lap!(steps, "text", t);
|
||||||
|
|
||||||
// ── PenOutput nodes ────────────────────────────────────────────────────────
|
// ── PenOutput nodes ────────────────────────────────────────────────────────
|
||||||
|
// Pen preview stamps a disk at each Bresenham step; radius scales the
|
||||||
|
// physical pen tip into preview-canvas pixels via px_per_mm_fill.
|
||||||
|
let pen_tip_mm = payload.pen_tip_mm.unwrap_or(0.5).max(0.05);
|
||||||
|
let pen_radius_px = (pen_tip_mm * px_per_mm_fill / 2.0).max(0.5);
|
||||||
|
|
||||||
let mut pen_results: Vec<PenResult> = Vec::new();
|
let mut pen_results: Vec<PenResult> = Vec::new();
|
||||||
let mut pen_output_results: Vec<PenOutputResult> = Vec::new();
|
let mut pen_output_results: Vec<PenOutputResult> = Vec::new();
|
||||||
|
|
||||||
@@ -1003,18 +1060,18 @@ fn process_pass_work(
|
|||||||
cached_p.clone()
|
cached_p.clone()
|
||||||
} else {
|
} else {
|
||||||
cache_misses += 1;
|
cache_misses += 1;
|
||||||
let p = render_pen_preview(*color, &fill, w, h);
|
let p = render_pen_preview(*color, &fill, paper_w_mm_for_scale, paper_h_mm_for_scale, pen_tip_mm);
|
||||||
cache.preview_cache.insert(node.id.clone(), (pen_fp, p.clone()));
|
cache.preview_cache.insert(node.id.clone(), (pen_fp, p.clone()));
|
||||||
p
|
p
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
cache_misses += 1;
|
cache_misses += 1;
|
||||||
let p = render_pen_preview(*color, &fill, w, h);
|
let p = render_pen_preview(*color, &fill, paper_w_mm_for_scale, paper_h_mm_for_scale, pen_tip_mm);
|
||||||
cache.preview_cache.insert(node.id.clone(), (pen_fp, p.clone()));
|
cache.preview_cache.insert(node.id.clone(), (pen_fp, p.clone()));
|
||||||
p
|
p
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
render_pen_preview(*color, &fill, w, h)
|
render_pen_preview(*color, &fill, paper_w_mm_for_scale, paper_h_mm_for_scale, pen_tip_mm)
|
||||||
};
|
};
|
||||||
node_previews.insert(node.id.clone(), preview);
|
node_previews.insert(node.id.clone(), preview);
|
||||||
pen_output_results.push(PenOutputResult {
|
pen_output_results.push(PenOutputResult {
|
||||||
@@ -1176,6 +1233,8 @@ async fn process_pass(payload: ProcessPassPayload, state: State<'_, Mutex<AppSta
|
|||||||
st.passes[idx].response_map = response_map;
|
st.passes[idx].response_map = response_map;
|
||||||
st.passes[idx].img_w = result.img_w;
|
st.passes[idx].img_w = result.img_w;
|
||||||
st.passes[idx].img_h = result.img_h;
|
st.passes[idx].img_h = result.img_h;
|
||||||
|
st.passes[idx].paper_w_mm = paper_w;
|
||||||
|
st.passes[idx].paper_h_mm = paper_h;
|
||||||
st.passes[idx].node_cache = new_cache;
|
st.passes[idx].node_cache = new_cache;
|
||||||
|
|
||||||
Ok(result)
|
Ok(result)
|
||||||
@@ -2086,12 +2145,11 @@ fn get_all_strokes(
|
|||||||
state: State<Mutex<AppState>>,
|
state: State<Mutex<AppState>>,
|
||||||
) -> Result<AllStrokesPayload, String> {
|
) -> Result<AllStrokesPayload, String> {
|
||||||
let st = state.lock().unwrap();
|
let st = state.lock().unwrap();
|
||||||
// Use the scaled pipeline dimensions (stored after process_pass) so the
|
// Strokes are in mm; frontend uses paper dims to scale to screen.
|
||||||
// viewport offscreen canvas matches the coordinate space of the strokes.
|
let (paper_w_mm, paper_h_mm) = st.passes.first()
|
||||||
let (img_width, img_height) = st.passes.first()
|
.filter(|p| p.paper_w_mm > 0.0)
|
||||||
.filter(|p| p.img_w > 0)
|
.map(|p| (p.paper_w_mm, p.paper_h_mm))
|
||||||
.map(|p| (p.img_w, p.img_h))
|
.unwrap_or((210.0, 297.0));
|
||||||
.unwrap_or((1, 1));
|
|
||||||
let mut all: Vec<PassStrokesPayload> = Vec::new();
|
let mut all: Vec<PassStrokesPayload> = Vec::new();
|
||||||
for ps in st.passes.iter() {
|
for ps in st.passes.iter() {
|
||||||
let mut pens = ps.pen_results.clone();
|
let mut pens = ps.pen_results.clone();
|
||||||
@@ -2103,7 +2161,7 @@ fn get_all_strokes(
|
|||||||
all.push(PassStrokesPayload { pass_index: i, color: pr.color, strokes });
|
all.push(PassStrokesPayload { pass_index: i, color: pr.color, strokes });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(AllStrokesPayload { passes: all, img_width, img_height })
|
Ok(AllStrokesPayload { passes: all, paper_w_mm, paper_h_mm })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns base64-encoded SVG — one <path> per pen with subsampled points.
|
/// Returns base64-encoded SVG — one <path> per pen with subsampled points.
|
||||||
@@ -2285,7 +2343,8 @@ mod blocking_tests {
|
|||||||
sat_min_value: None, canny_low: None, canny_high: None,
|
sat_min_value: None, canny_low: None, canny_high: None,
|
||||||
xdog_sigma2: None, xdog_tau: None, xdog_phi: None,
|
xdog_sigma2: None, xdog_tau: None, xdog_phi: None,
|
||||||
blend_mode: None,
|
blend_mode: None,
|
||||||
threshold: None, min_area: None, rdp_epsilon: None,
|
kernel_dpi: None,
|
||||||
|
threshold: None, min_area: None,
|
||||||
connectivity: None, color_filter: None,
|
connectivity: None, color_filter: None,
|
||||||
strategy: None, spacing: None, angle: None, param: None,
|
strategy: None, spacing: None, angle: None, param: None,
|
||||||
smooth_rdp: None, smooth_iters: None,
|
smooth_rdp: None, smooth_iters: None,
|
||||||
@@ -2311,7 +2370,6 @@ mod blocking_tests {
|
|||||||
k1.xdog_phi = Some(10.0);
|
k1.xdog_phi = Some(10.0);
|
||||||
hull.threshold = Some(128);
|
hull.threshold = Some(128);
|
||||||
hull.min_area = Some(10);
|
hull.min_area = Some(10);
|
||||||
hull.rdp_epsilon = Some(2.0);
|
|
||||||
hull.connectivity = Some("four".into());
|
hull.connectivity = Some("four".into());
|
||||||
let mut fill_node = node("fill", "Fill");
|
let mut fill_node = node("fill", "Fill");
|
||||||
fill_node.strategy = Some("hatch".into());
|
fill_node.strategy = Some("hatch".into());
|
||||||
@@ -2330,6 +2388,7 @@ mod blocking_tests {
|
|||||||
dpi: None,
|
dpi: None,
|
||||||
img_w_mm: None,
|
img_w_mm: None,
|
||||||
img_h_mm: None,
|
img_h_mm: None,
|
||||||
|
pen_tip_mm: None,
|
||||||
graph: DetectionGraphPayload {
|
graph: DetectionGraphPayload {
|
||||||
nodes: vec![node("source", "Source"), k1, hull, fill_node, pen_node],
|
nodes: vec![node("source", "Source"), k1, hull, fill_node, pen_node],
|
||||||
edges: vec![
|
edges: vec![
|
||||||
@@ -2433,7 +2492,7 @@ mod viz_tests {
|
|||||||
|
|
||||||
let rgb = image::RgbImage::from_pixel(img_w, img_h, image::Rgb([255,255,255]));
|
let rgb = image::RgbImage::from_pixel(img_w, img_h, image::Rgb([255,255,255]));
|
||||||
let params = HullParams { threshold: 128, min_area: 1,
|
let params = HullParams { threshold: 128, min_area: 1,
|
||||||
rdp_epsilon: 1.0, connectivity: hulls::Connectivity::Four };
|
connectivity: hulls::Connectivity::Four };
|
||||||
hulls::extract_hulls(&luma, &rgb, img_w, img_h, ¶ms)
|
hulls::extract_hulls(&luma, &rgb, img_w, img_h, ¶ms)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2444,7 +2503,7 @@ mod viz_tests {
|
|||||||
let (w, h) = img.dimensions();
|
let (w, h) = img.dimensions();
|
||||||
let luma: Vec<u8> = img.pixels().map(|p| p[0]).collect();
|
let luma: Vec<u8> = img.pixels().map(|p| p[0]).collect();
|
||||||
let params = HullParams { threshold: 128, min_area: 4,
|
let params = HullParams { threshold: 128, min_area: 4,
|
||||||
rdp_epsilon: 1.0, connectivity: hulls::Connectivity::Four };
|
connectivity: hulls::Connectivity::Four };
|
||||||
hulls::extract_hulls(&luma, &img, w, h, ¶ms)
|
hulls::extract_hulls(&luma, &img, w, h, ¶ms)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2624,7 +2683,6 @@ mod viz_tests {
|
|||||||
let config = &json["passes"][0]["config"];
|
let config = &json["passes"][0]["config"];
|
||||||
let threshold = config["threshold"].as_u64().unwrap_or(128) as u8;
|
let threshold = config["threshold"].as_u64().unwrap_or(128) as u8;
|
||||||
let min_area = config["min_area"].as_u64().unwrap_or(4) as u32;
|
let min_area = config["min_area"].as_u64().unwrap_or(4) as u32;
|
||||||
let rdp_eps = config["rdp_epsilon"].as_f64().unwrap_or(1.5) as f32;
|
|
||||||
|
|
||||||
let stored = json["image_path"].as_str().unwrap_or("");
|
let stored = json["image_path"].as_str().unwrap_or("");
|
||||||
let img_path = if std::path::Path::new(stored).exists() {
|
let img_path = if std::path::Path::new(stored).exists() {
|
||||||
@@ -2657,7 +2715,7 @@ mod viz_tests {
|
|||||||
detect::GraphNode { id: "source".into(), kind: detect::NodeKind::Source { file_path: None } },
|
detect::GraphNode { id: "source".into(), kind: detect::NodeKind::Source { file_path: None } },
|
||||||
detect::GraphNode { id: "k1".into(), kind: detect::NodeKind::Kernel(layer) },
|
detect::GraphNode { id: "k1".into(), kind: detect::NodeKind::Kernel(layer) },
|
||||||
detect::GraphNode { id: "hull".into(), kind: detect::NodeKind::Hull {
|
detect::GraphNode { id: "hull".into(), kind: detect::NodeKind::Hull {
|
||||||
threshold: threshold as u8, min_area, rdp_epsilon: rdp_eps,
|
threshold: threshold as u8, min_area,
|
||||||
eight_conn: false,
|
eight_conn: false,
|
||||||
cf_enabled: false,
|
cf_enabled: false,
|
||||||
cf_hue_min: 0.0, cf_hue_max: 360.0,
|
cf_hue_min: 0.0, cf_hue_max: 360.0,
|
||||||
@@ -2672,9 +2730,9 @@ mod viz_tests {
|
|||||||
};
|
};
|
||||||
let node_rgbs: std::collections::HashMap<String, image::RgbImage> =
|
let node_rgbs: std::collections::HashMap<String, image::RgbImage> =
|
||||||
graph.nodes.iter().map(|n| (n.id.clone(), img.clone())).collect();
|
graph.nodes.iter().map(|n| (n.id.clone(), img.clone())).collect();
|
||||||
let gm = detect::evaluate_graph(&graph, &node_rgbs, w, h);
|
let gm = detect::evaluate_graph(&graph, &node_rgbs, w, h, 150.0);
|
||||||
let response = gm.raw_maps.get("hull").cloned().unwrap_or(gm.response);
|
let response = gm.raw_maps.get("hull").cloned().unwrap_or(gm.response);
|
||||||
let params = HullParams { threshold, min_area, rdp_epsilon: rdp_eps,
|
let params = HullParams { threshold, min_area,
|
||||||
connectivity: hulls::Connectivity::Four };
|
connectivity: hulls::Connectivity::Four };
|
||||||
let hs = hulls::extract_hulls(&response, &img, w, h, ¶ms);
|
let hs = hulls::extract_hulls(&response, &img, w, h, ¶ms);
|
||||||
(hs, w, h)
|
(hs, w, h)
|
||||||
|
|||||||
Reference in New Issue
Block a user