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 [nodeWidth, setNodeWidth] = useState(450)
|
||||
const [dpi, setDpi] = useState(150)
|
||||
const [projectPath, setProjectPath] = useState(null) // null = unsaved
|
||||
const resizing = useRef(false)
|
||||
|
||||
@@ -42,10 +41,12 @@ export default function App() {
|
||||
// deps; without memoisation, every gcodeConfig drag tick would
|
||||
// recreate the object, recreate `draw`, and restart Viewport's
|
||||
// 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(() => ({
|
||||
width: Math.round(gcodeConfig.paper_w_mm * dpi / 25.4),
|
||||
height: Math.round(gcodeConfig.paper_h_mm * dpi / 25.4),
|
||||
}), [gcodeConfig.paper_w_mm, gcodeConfig.paper_h_mm, dpi])
|
||||
width: Math.round(gcodeConfig.paper_w_mm * 150 / 25.4),
|
||||
height: Math.round(gcodeConfig.paper_h_mm * 150 / 25.4),
|
||||
}), [gcodeConfig.paper_w_mm, gcodeConfig.paper_h_mm])
|
||||
// Has a Source node with a loaded file path? Used for the empty-state overlay.
|
||||
const anyLoadedSource = (passes[0]?.graph?.nodes ?? [])
|
||||
.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
|
||||
const passesRef = useRef(passes)
|
||||
const dpiRef = useRef(dpi)
|
||||
const gcodeConfigRef = useRef(gcodeConfig)
|
||||
passesRef.current = passes
|
||||
dpiRef.current = dpi
|
||||
gcodeConfigRef.current = gcodeConfig
|
||||
|
||||
// Debounce timers: { 'idx-detection': timer, 'idx-fill': timer }
|
||||
@@ -155,14 +154,20 @@ export default function App() {
|
||||
try {
|
||||
// Backend letterboxes every Source into the paper canvas, so we hand
|
||||
// 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 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({
|
||||
pass_index: idx,
|
||||
graph: pass.graph,
|
||||
dpi: dpiRef.current,
|
||||
dpi: projectDpi,
|
||||
img_w_mm: paperW,
|
||||
img_h_mm: paperH,
|
||||
pen_tip_mm: gcodeConfigRef.current.pen_tip_mm ?? 0.5,
|
||||
})
|
||||
const js_process = Math.round(performance.now() - t0)
|
||||
setPerfData(pd => ({ ...(pd ?? {}), process: result.timings, js_process }))
|
||||
@@ -194,7 +199,7 @@ export default function App() {
|
||||
|
||||
useEffect(() => {
|
||||
scheduleProcess()
|
||||
}, [dpi, gcodeConfig.paper_w_mm, gcodeConfig.paper_h_mm])
|
||||
}, [gcodeConfig.paper_w_mm, gcodeConfig.paper_h_mm, gcodeConfig.pen_tip_mm])
|
||||
|
||||
// ── Export ─────────────────────────────────────────────────────────────────
|
||||
async function exportAll() {
|
||||
@@ -225,7 +230,6 @@ export default function App() {
|
||||
}
|
||||
try {
|
||||
const json = serialize({
|
||||
dpi,
|
||||
nodeWidth,
|
||||
graph: passes[0].graph,
|
||||
gcodeConfig,
|
||||
@@ -249,7 +253,6 @@ export default function App() {
|
||||
|
||||
// Apply non-image state immediately
|
||||
if (restored.gcodeConfig) setGcodeConfig(restored.gcodeConfig)
|
||||
if (restored.dpi) setDpi(restored.dpi)
|
||||
if (restored.nodeWidth) setNodeWidth(restored.nodeWidth)
|
||||
|
||||
// Replace the pass graph
|
||||
@@ -340,83 +343,83 @@ export default function App() {
|
||||
|
||||
<div className="px-3 py-2 space-y-4">
|
||||
|
||||
{/* Graph */}
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-neutral-500 text-xs font-semibold uppercase tracking-wider mb-1.5">Graph</p>
|
||||
<Slider label="Card width" value={nodeWidth} min={160} max={800} step={10}
|
||||
onChange={v => setNodeWidth(v)} unit="px" />
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
{/* ── Pipeline view: graph layout, DPI, paper size ──────────── */}
|
||||
{viewMode === 'pipeline' && (<>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-neutral-500 text-xs font-semibold uppercase tracking-wider mb-1.5">Graph</p>
|
||||
<Slider label="Card width" value={nodeWidth} min={160} max={800} step={10}
|
||||
onChange={v => setNodeWidth(v)} unit="px" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Plotter */}
|
||||
<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>
|
||||
<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>
|
||||
<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 */}
|
||||
<CalibrationButtons gcodeConfig={gcodeConfig} imgSize={canvasDims} setStatus={setGlobalStatus} />
|
||||
<CalibrationAxis printerUrl={gcodeConfig.printer_url} setStatus={setGlobalStatus} />
|
||||
{/* ── G-code view: plotter motion params, corner-jog, export ── */}
|
||||
{viewMode === 'gcode' && (<>
|
||||
<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 */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-neutral-500 text-xs font-semibold uppercase tracking-wider">Output</p>
|
||||
<button onClick={exportAll} disabled={!hasOutput}
|
||||
className="w-full px-3 py-1.5 rounded bg-indigo-700 hover:bg-indigo-600 text-xs text-white disabled:opacity-40 transition-colors">
|
||||
Export G-code to folder
|
||||
</button>
|
||||
<p className="text-xs text-neutral-600">Use the <span className="text-emerald-500">Printer</span> tab to upload & run.</p>
|
||||
</div>
|
||||
<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>
|
||||
<button onClick={exportAll} disabled={!hasOutput}
|
||||
className="w-full px-3 py-1.5 rounded bg-indigo-700 hover:bg-indigo-600 text-xs text-white disabled:opacity-40 transition-colors">
|
||||
Export G-code to folder
|
||||
</button>
|
||||
<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>
|
||||
|
||||
@@ -557,6 +557,8 @@ export default function NodeGraph({ graph, onChange, nodePreviews, nodeWidth = 2
|
||||
{node.file_path}
|
||||
</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' && (<>
|
||||
@@ -619,6 +621,10 @@ export default function NodeGraph({ graph, onChange, nodePreviews, nodeWidth = 2
|
||||
Invert
|
||||
</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' && (<>
|
||||
@@ -647,8 +653,6 @@ export default function NodeGraph({ graph, onChange, nodePreviews, nodeWidth = 2
|
||||
onChange={v => updateNode(node.id, { threshold: v })} />
|
||||
<Slider label="Min area" value={node.min_area ?? 4} min={1} max={5000} step={1}
|
||||
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' }}>
|
||||
<span style={{ fontSize: 10, color: '#6b7280' }}>Connectivity</span>
|
||||
{['four','eight'].map(c => (
|
||||
@@ -684,7 +688,7 @@ export default function NodeGraph({ graph, onChange, nodePreviews, nodeWidth = 2
|
||||
>{s}</button>
|
||||
))}
|
||||
</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 })} />
|
||||
{FILL_USES_ANGLE.has(node.strategy ?? 'hatch') && (
|
||||
<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) {
|
||||
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'
|
||||
// Bed/paper/image rectangles are drawn by the SVG overlay below;
|
||||
// canvas only renders the strokes inside the image rect.
|
||||
if (imgSize) {
|
||||
ctx.fillStyle = '#f5f0e8'
|
||||
ctx.fillRect(ox, oy, iw * scale, ih * scale)
|
||||
}
|
||||
// Strokes live in mm inside the image rect; the SVG overlay draws
|
||||
// the bed/paper/image rectangles, this just paints the rasterized
|
||||
// strokes onto the image rect.
|
||||
ctx.fillStyle = '#f5f0e8'
|
||||
ctx.fillRect(L.image_x, L.image_y, L.image_w_screen, L.image_h_screen)
|
||||
const off = offscreenRef.current
|
||||
if (off && imgSize) {
|
||||
ctx.drawImage(off, ox, oy, iw * scale, ih * scale)
|
||||
if (off) {
|
||||
ctx.drawImage(off, L.image_x, L.image_y, L.image_w_screen, L.image_h_screen)
|
||||
}
|
||||
} else {
|
||||
// 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
|
||||
}
|
||||
|
||||
if (!strokes || !imgSize || (viewMode !== 'gcode' && viewMode !== 'fill')) {
|
||||
if (!strokes || viewMode !== 'gcode') {
|
||||
offscreenRef.current = null
|
||||
return
|
||||
}
|
||||
@@ -190,18 +189,28 @@ export default function Viewport({ imageB64, strokes, imgSize, viewMode, gcodeCo
|
||||
p.strokes.map(s => ({ color: p.color, points: s }))
|
||||
)
|
||||
|
||||
// Offscreen canvas sized to stroke coordinate space (pipeline dims, after DPI scaling).
|
||||
const sw = strokes.img_width ?? imgSize.width
|
||||
const sh = strokes.img_height ?? imgSize.height
|
||||
// Offscreen canvas sized to paper × INTERNAL_PX_PER_MM. Strokes are
|
||||
// mm coords drawn through an octx.scale that maps mm → offscreen px.
|
||||
// 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')
|
||||
off.width = sw * 4
|
||||
off.height = sh * 4
|
||||
off.width = sw
|
||||
off.height = sh
|
||||
const octx = off.getContext('2d')
|
||||
octx.fillStyle = '#f5f0e8'
|
||||
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
|
||||
|
||||
// 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 }
|
||||
|
||||
function drawChunk() {
|
||||
@@ -211,32 +220,13 @@ export default function Viewport({ imageB64, strokes, imgSize, viewMode, gcodeCo
|
||||
|
||||
const end = Math.min(idx + CHUNK_SIZE, flat.length)
|
||||
|
||||
if (viewMode === 'gcode') {
|
||||
// Debug view: every stroke gets its own hue (golden-ratio cycle for
|
||||
// maximal visual separation). One beginPath per stroke since each has
|
||||
// a unique strokeStyle.
|
||||
octx.lineWidth = 1.5
|
||||
octx.lineCap = 'round'
|
||||
for (let i = idx; i < end; i++) {
|
||||
const pts = flat[i].points
|
||||
if (pts.length < 2) continue
|
||||
const hue = (i * 137.508) % 360
|
||||
octx.strokeStyle = `hsl(${hue.toFixed(1)}, 80%, 50%)`
|
||||
octx.beginPath()
|
||||
octx.moveTo(pts[0][0], pts[0][1])
|
||||
for (let k = 1; k < pts.length; k++) {
|
||||
octx.lineTo(pts[k][0], pts[k][1])
|
||||
}
|
||||
octx.stroke()
|
||||
}
|
||||
} else {
|
||||
// Fill view: pen-color batching (consecutive same-color strokes
|
||||
// share one beginPath for performance).
|
||||
let i = idx
|
||||
while (i < end) {
|
||||
// Pen-color batching: consecutive same-color strokes share one
|
||||
// beginPath for perf. Color comes from each PenOutput's color.
|
||||
let i = idx
|
||||
while (i < end) {
|
||||
const [r, g, b] = flat[i].color
|
||||
octx.strokeStyle = `rgb(${r},${g},${b})`
|
||||
octx.lineWidth = 1.5
|
||||
octx.lineWidth = lineWidthMm
|
||||
octx.lineCap = 'round'
|
||||
octx.beginPath()
|
||||
let j = i
|
||||
@@ -255,7 +245,6 @@ export default function Viewport({ imageB64, strokes, imgSize, viewMode, gcodeCo
|
||||
}
|
||||
octx.stroke()
|
||||
i = j
|
||||
}
|
||||
}
|
||||
|
||||
state.idx = end
|
||||
@@ -276,7 +265,12 @@ export default function Viewport({ imageB64, strokes, imgSize, viewMode, gcodeCo
|
||||
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])
|
||||
|
||||
|
||||
@@ -21,13 +21,15 @@ const MIGRATIONS = [
|
||||
]
|
||||
|
||||
// ── 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({
|
||||
version: CURRENT_VERSION,
|
||||
app: 'trac3r',
|
||||
saved_at: new Date().toISOString(),
|
||||
image_path: imagePath ?? null,
|
||||
dpi,
|
||||
node_width: nodeWidth,
|
||||
graph,
|
||||
gcode: gcodeConfig,
|
||||
@@ -70,7 +72,6 @@ export function deserialize(json, { migrations: migs = MIGRATIONS, currentVersio
|
||||
|
||||
return {
|
||||
imagePath: doc.image_path ?? null,
|
||||
dpi: doc.dpi ?? 150,
|
||||
nodeWidth: doc.node_width ?? 450,
|
||||
graph: doc.graph ?? null,
|
||||
gcodeConfig: doc.gcode ?? null,
|
||||
|
||||
@@ -96,11 +96,6 @@ describe('serialize', () => {
|
||||
expect(doc.image_path).toBeNull()
|
||||
})
|
||||
|
||||
it('includes dpi', () => {
|
||||
const doc = JSON.parse(serialize(FULL_STATE))
|
||||
expect(doc.dpi).toBe(300)
|
||||
})
|
||||
|
||||
it('includes node_width', () => {
|
||||
const doc = JSON.parse(serialize(FULL_STATE))
|
||||
expect(doc.node_width).toBe(500)
|
||||
@@ -138,7 +133,6 @@ describe('deserialize — happy path', () => {
|
||||
it('loads a well-formed v1 document', () => {
|
||||
const result = deserialize(makeV1Doc())
|
||||
expect(result.imagePath).toBe('/some/image.jpg')
|
||||
expect(result.dpi).toBe(150)
|
||||
expect(result.nodeWidth).toBe(450)
|
||||
expect(result.graph.nodes).toHaveLength(MINIMAL_GRAPH.nodes.length)
|
||||
expect(result.graph.edges).toHaveLength(MINIMAL_GRAPH.edges.length)
|
||||
@@ -175,12 +169,6 @@ describe('deserialize — happy path', () => {
|
||||
// ── deserialize — missing optional fields use defaults ─────────────────────────
|
||||
|
||||
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', () => {
|
||||
const doc = JSON.parse(makeV1Doc())
|
||||
delete doc.node_width
|
||||
@@ -218,7 +206,7 @@ describe('deserialize — missing optional fields', () => {
|
||||
const minimalDoc = JSON.stringify({ version: 1, app: 'trac3r' })
|
||||
const result = deserialize(minimalDoc)
|
||||
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)
|
||||
const result = deserialize(makeV1Doc(), { migrations: [spy] })
|
||||
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', () => {
|
||||
@@ -373,7 +361,6 @@ describe('round-trip: serialize → deserialize', () => {
|
||||
const json = serialize(FULL_STATE)
|
||||
const result = deserialize(json)
|
||||
expect(result.imagePath).toBe(FULL_STATE.imagePath)
|
||||
expect(result.dpi).toBe(FULL_STATE.dpi)
|
||||
expect(result.nodeWidth).toBe(FULL_STATE.nodeWidth)
|
||||
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,
|
||||
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,
|
||||
// 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() {
|
||||
// spacing is in mm (DPI-independent, paper-relative). Backend converts
|
||||
// to pipeline pixels via canvas_w / paper_w_mm at process time.
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -117,13 +122,18 @@ export function defaultTextParams() {
|
||||
|
||||
export function defaultHullParams() {
|
||||
return {
|
||||
threshold: 128, min_area: 4, rdp_epsilon: 1.5, connectivity: 'four',
|
||||
threshold: 128, min_area: 4, connectivity: 'four',
|
||||
color_filter: defaultColorFilter(),
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
@@ -178,6 +188,7 @@ export function defaultGcodeConfig() {
|
||||
offset_x_mm: 15, offset_y_mm: 15,
|
||||
feed_draw: 1000, feed_travel: 5000,
|
||||
pen_down: 'G1 Z0.4 F1000', pen_up_z_mm: 2, pen_dwell_ms: 250,
|
||||
pen_tip_mm: 0.5, // visual only — gcode preview renders strokes at this physical ink width
|
||||
printer_url: 'http://fluidnc.local',
|
||||
}
|
||||
return { ...cfg, ...centerPaperOnBed(cfg) }
|
||||
|
||||
@@ -56,6 +56,12 @@ pub struct DetectionLayer {
|
||||
pub ci_hue_min: f32, pub ci_hue_max: f32,
|
||||
pub ci_sat_min: f32, pub ci_sat_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 {
|
||||
@@ -74,10 +80,37 @@ impl Default for DetectionLayer {
|
||||
ci_hue_min: 0.0, ci_hue_max: 360.0,
|
||||
ci_sat_min: 0.0, ci_sat_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.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DetectionParams {
|
||||
@@ -477,7 +510,6 @@ pub enum NodeKind {
|
||||
Hull {
|
||||
threshold: u8,
|
||||
min_area: u32,
|
||||
rdp_epsilon: f32,
|
||||
eight_conn: bool,
|
||||
cf_enabled: bool,
|
||||
cf_hue_min: f32, cf_hue_max: f32,
|
||||
@@ -551,6 +583,7 @@ pub fn evaluate_graph(
|
||||
node_rgbs: &std::collections::HashMap<String, RgbImage>,
|
||||
canvas_w: u32,
|
||||
canvas_h: u32,
|
||||
canvas_dpi: f32,
|
||||
) -> GraphMaps {
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
|
||||
@@ -625,9 +658,9 @@ pub fn evaluate_graph(
|
||||
let v = up[(y * src_rgb.width() + x) as usize];
|
||||
image::Rgb([v, v, v])
|
||||
});
|
||||
apply_layer(&gray_rgb, layer)
|
||||
apply_layer_with_dpi(&gray_rgb, layer, canvas_dpi)
|
||||
} else {
|
||||
apply_layer(src_rgb, layer)
|
||||
apply_layer_with_dpi(src_rgb, layer, canvas_dpi)
|
||||
};
|
||||
let w = layer.weight;
|
||||
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.
|
||||
// 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 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.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FillResult {
|
||||
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)>>,
|
||||
}
|
||||
|
||||
// ── 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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Horizontal scan lines through the hull at `spacing_px` pixel intervals,
|
||||
@@ -1109,6 +1292,100 @@ mod tests {
|
||||
.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 ─────────────────────────────────────────────────────────
|
||||
|
||||
fn make_square_hull(x0: u32, y0: u32, side: u32) -> Hull {
|
||||
@@ -1639,8 +1916,9 @@ mod tests {
|
||||
..Default::default()
|
||||
};
|
||||
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 {
|
||||
threshold, min_area, rdp_epsilon: rdp_eps,
|
||||
threshold, min_area,
|
||||
connectivity: crate::hulls::Connectivity::Four,
|
||||
};
|
||||
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.
|
||||
/// Pixel coordinates are scaled by `img_w_mm / img_w` (uniform, aspect-correct),
|
||||
/// then offset by `(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 {
|
||||
let scale = cfg.px_to_mm(img_w);
|
||||
/// Strokes are in mm relative to the image origin. We apply
|
||||
/// `(paper_offset_x_mm + offset_x_mm, paper_offset_y_mm + offset_y_mm)`
|
||||
/// to position the image on the bed, and `img_w_mm / paper_w_mm` (when
|
||||
/// 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 oy = cfg.paper_offset_y_mm + cfg.offset_y_mm;
|
||||
|
||||
@@ -200,22 +208,48 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gcode_aspect_ratio_preserved() {
|
||||
// 200×100 px image → 200mm wide → scale = 1.0 px/mm
|
||||
// A point at (50, 50) should map to (50*1.0 + ox, 50*1.0 + oy)
|
||||
fn gcode_strokes_are_mm_with_offsets_applied() {
|
||||
// Strokes are mm. img_w_mm == paper_w_mm so on-paper scale = 1.
|
||||
// Point at (50, 50) mm should land at (50 + offset_x, 50 + offset_y).
|
||||
let cfg = GcodeConfig {
|
||||
paper_w_mm: 200.0,
|
||||
paper_h_mm: 200.0,
|
||||
img_w_mm: 200.0,
|
||||
offset_x_mm: 10.0,
|
||||
offset_y_mm: 20.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), (50.0, 50.0)]],
|
||||
};
|
||||
let code = to_gcode(&[result], 200, 100, &cfg);
|
||||
assert!(code.contains("X60.000"), "expected X=50*1.0+10=60");
|
||||
assert!(code.contains("Y70.000"), "expected Y=50*1.0+20=70");
|
||||
let code = to_gcode(&[result], 0, 0, &cfg);
|
||||
assert!(code.contains("X60.000"), "expected X=50+10=60, got: {code}");
|
||||
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]
|
||||
|
||||
169
src/hulls.rs
169
src/hulls.rs
@@ -15,6 +15,50 @@ pub struct Hull {
|
||||
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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// 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 threshold: u8, // pixels strictly darker than this = ink
|
||||
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
|
||||
}
|
||||
|
||||
@@ -106,7 +149,6 @@ impl Default for HullParams {
|
||||
Self {
|
||||
threshold: 128,
|
||||
min_area: 4,
|
||||
rdp_epsilon: 1.5,
|
||||
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)| {
|
||||
let pixel_set: HashSet<(u32, u32)> = pixels.iter().copied().collect();
|
||||
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 xx, mut yx) = (0u32, 0u32);
|
||||
@@ -274,47 +319,6 @@ pub(crate) fn trace_contour(component: &HashSet<(u32, u32)>) -> Vec<(u32, u32)>
|
||||
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 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -343,6 +347,28 @@ pub mod tests {
|
||||
(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).
|
||||
pub fn rect_outline(x0: u32, y0: u32, x1: u32, y1: u32) -> Vec<(u32, u32)> {
|
||||
let mut v = Vec::new();
|
||||
@@ -397,7 +423,7 @@ pub mod tests {
|
||||
fn single_filled_square_one_hull() {
|
||||
let dark = filled_rect(10, 10, 29, 29); // 20×20
|
||||
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);
|
||||
assert_eq!(hulls.len(), 1, "one square → one hull");
|
||||
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
|
||||
dark.extend(filled_rect(20, 20, 29, 29)); // 10×10, separated by gap
|
||||
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);
|
||||
assert_eq!(hulls.len(), 2, "two squares → two hulls");
|
||||
let mut areas: Vec<u32> = hulls.iter().map(|h| h.area).collect();
|
||||
@@ -421,7 +447,7 @@ pub mod tests {
|
||||
#[test]
|
||||
fn single_pixel_hull() {
|
||||
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);
|
||||
assert_eq!(hulls.len(), 1);
|
||||
assert_eq!(hulls[0].area, 1);
|
||||
@@ -431,7 +457,7 @@ pub mod tests {
|
||||
#[test]
|
||||
fn all_dark_image_one_hull() {
|
||||
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);
|
||||
assert_eq!(hulls.len(), 1);
|
||||
assert_eq!(hulls[0].area, 256);
|
||||
@@ -452,7 +478,7 @@ pub mod tests {
|
||||
dark.push((0, 0)); // 1px noise
|
||||
dark.push((31, 31)); // 1px noise
|
||||
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);
|
||||
assert_eq!(hulls.len(), 1, "min_area=4 must remove single-pixel noise");
|
||||
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)))
|
||||
.collect();
|
||||
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);
|
||||
assert_eq!(hulls.len(), 1, "horizontal line → one hull");
|
||||
assert_eq!(hulls[0].area, 64 * 3);
|
||||
@@ -477,7 +503,7 @@ pub mod tests {
|
||||
.flat_map(|&cy| (0..64u32).map(move |x| (x, cy)))
|
||||
.collect();
|
||||
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);
|
||||
assert_eq!(hulls.len(), 3, "three separate lines → three hulls");
|
||||
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);
|
||||
dark.extend(filled_rect(5, 5, 9, 9)); // touches corner (4,4)↔(5,5) diagonally
|
||||
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);
|
||||
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);
|
||||
dark.extend(filled_rect(5, 5, 9, 9));
|
||||
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);
|
||||
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() {
|
||||
let dark = filled_rect(5, 5, 20, 20);
|
||||
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);
|
||||
assert_eq!(hulls.len(), 1);
|
||||
assert!(!hulls[0].contour.is_empty());
|
||||
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 ───────────────────────────────────────────────────
|
||||
|
||||
/// Reconstruct a binary mask from hull pixels and compare with original.
|
||||
@@ -579,7 +575,7 @@ pub mod tests {
|
||||
fn coverage_score_perfect_for_square() {
|
||||
let dark = filled_rect(5, 5, 25, 25);
|
||||
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 (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");
|
||||
@@ -592,7 +588,7 @@ pub mod tests {
|
||||
.flat_map(|&cy| (0..64u32).map(move |x| (x, cy)))
|
||||
.collect();
|
||||
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 (prec, rec) = coverage_score(&luma, 64, 64, &hulls, p.threshold);
|
||||
assert_eq!(prec, 1.0);
|
||||
@@ -613,6 +609,7 @@ pub mod tests {
|
||||
let rgb = dyn_img.to_rgb8();
|
||||
let (w, h) = img.dimensions();
|
||||
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);
|
||||
assert_eq!(hulls.len(), 128, "checkerboard has 128 dark cells");
|
||||
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.val_min.to_bits()); h.write_u32(cf.val_max.to_bits());
|
||||
}
|
||||
h.write_u32(node.kernel_dpi.unwrap_or(0));
|
||||
h.finish()
|
||||
}
|
||||
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_u8(node.threshold.unwrap_or(128));
|
||||
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());
|
||||
if let Some(cf) = &node.color_filter {
|
||||
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.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();
|
||||
h.write_u64(upstream_fp);
|
||||
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_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()
|
||||
}
|
||||
fn fp_text(node: &GraphNodePayload) -> u64 {
|
||||
@@ -176,7 +179,7 @@ fn compute_node_fingerprints(payload: &ProcessPassPayload)
|
||||
"Combine" => fp_combine(node, &up_fps),
|
||||
"Hull" => fp_hull(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),
|
||||
_ => 0,
|
||||
};
|
||||
@@ -207,6 +210,8 @@ struct PassState {
|
||||
response_map: Vec<u8>,
|
||||
img_w: 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,
|
||||
}
|
||||
|
||||
@@ -245,10 +250,10 @@ pub struct GraphNodePayload {
|
||||
pub xdog_phi: Option<f32>,
|
||||
// Combine params (optional)
|
||||
pub blend_mode: Option<String>,
|
||||
pub kernel_dpi: Option<u32>,
|
||||
// Hull params (optional — only for kind="Hull")
|
||||
pub threshold: Option<u8>,
|
||||
pub min_area: Option<u32>,
|
||||
pub rdp_epsilon: Option<f32>,
|
||||
pub connectivity: Option<String>,
|
||||
pub color_filter: Option<ColorFilterPayload>,
|
||||
// Fill params (optional — only for kind="Fill")
|
||||
@@ -311,6 +316,9 @@ pub struct ProcessPassPayload {
|
||||
/// projects work without an image load. Image-input projects
|
||||
/// ignore this and derive height from aspect ratio.
|
||||
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)]
|
||||
@@ -377,9 +385,12 @@ fn default_pen_dwell() -> u32 { 250 }
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct AllStrokesPayload {
|
||||
pub passes: Vec<PassStrokesPayload>,
|
||||
pub img_width: u32,
|
||||
pub img_height: u32,
|
||||
pub passes: Vec<PassStrokesPayload>,
|
||||
/// Stroke coords are in mm. paper_w_mm / paper_h_mm describe the
|
||||
/// 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)]
|
||||
@@ -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_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),
|
||||
kernel_dpi: n.kernel_dpi,
|
||||
})
|
||||
}
|
||||
"Combine" => {
|
||||
@@ -466,7 +478,6 @@ fn to_detection_graph(payload: &DetectionGraphPayload) -> detect::DetectionGraph
|
||||
detect::NodeKind::Hull {
|
||||
threshold: n.threshold.unwrap_or(128),
|
||||
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"),
|
||||
cf_enabled: cf.map(|f| f.enabled).unwrap_or(false),
|
||||
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) ─────────────────────────────
|
||||
|
||||
/// Rasterize fill strokes in the pen's color on a light background at full image resolution.
|
||||
fn render_pen_preview(color: [u8; 3], fill: &fill::FillResult, img_w: u32, img_h: u32) -> String {
|
||||
/// Fixed preview resolution — paper × this rate gives the preview
|
||||
/// 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];
|
||||
|
||||
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) {
|
||||
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);
|
||||
@@ -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 mut err = dx + dy;
|
||||
loop {
|
||||
if x >= 0 && y >= 0 && (x as u32) < img_w && (y as u32) < img_h {
|
||||
pix[(y as u32 * img_w + x as u32) as usize] = color;
|
||||
}
|
||||
stamp(&mut pix, x, y);
|
||||
if x == x1 && y == y1 { break; }
|
||||
let e2 = 2 * err;
|
||||
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())
|
||||
}
|
||||
|
||||
/// Rasterize fill strokes into a JPEG preview at full image resolution.
|
||||
fn render_fill_preview(result: &fill::FillResult, img_w: u32, img_h: u32) -> String {
|
||||
/// Convert a FillResult's mm strokes into pixel strokes for the preview canvas.
|
||||
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];
|
||||
|
||||
for stroke in &result.strokes {
|
||||
for stroke in &scaled.strokes {
|
||||
for pair in stroke.windows(2) {
|
||||
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);
|
||||
@@ -591,21 +652,33 @@ fn render_fill_preview(result: &fill::FillResult, img_w: u32, img_h: u32) -> Str
|
||||
B64.encode(buf.into_inner())
|
||||
}
|
||||
|
||||
fn render_hull_preview(response: &[u8], hulls_list: &[hulls::Hull], w: u32, h: u32) -> String {
|
||||
let mut rgba = vec![15u8; (w * h * 4) as usize];
|
||||
/// Hull preview: paint each hull's pixel set onto a fixed-resolution
|
||||
/// 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 hull in hulls_list {
|
||||
let (hr, hg, hb) = hash_color(hull.id);
|
||||
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 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+1] = (hg 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(
|
||||
@@ -627,6 +700,13 @@ fn process_pass_work(
|
||||
// in a single coord frame.
|
||||
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 ────────────────────────────────────────────
|
||||
// Trees can't merge (frontend enforces; backend assumes), so each non-
|
||||
// Source node has exactly one Source ancestor. Topo-walk to propagate
|
||||
@@ -701,7 +781,8 @@ fn process_pass_work(
|
||||
}
|
||||
} else {
|
||||
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_response = maps.response.clone();
|
||||
cache.detect_maps = maps.raw_maps.clone();
|
||||
@@ -767,7 +848,7 @@ fn process_pass_work(
|
||||
|
||||
for node in &det_graph.nodes {
|
||||
if let detect::NodeKind::Hull {
|
||||
threshold, min_area, rdp_epsilon, eight_conn, ..
|
||||
threshold, min_area, eight_conn, ..
|
||||
} = &node.kind {
|
||||
let response = match graph_maps.raw_maps.get(&node.id) {
|
||||
Some(m) => m,
|
||||
@@ -791,7 +872,7 @@ fn process_pass_work(
|
||||
}
|
||||
all_hulls.extend(entry.hulls.clone());
|
||||
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()));
|
||||
p
|
||||
});
|
||||
@@ -809,12 +890,11 @@ fn process_pass_work(
|
||||
let hull_params = hulls::HullParams {
|
||||
threshold: *threshold,
|
||||
min_area: *min_area,
|
||||
rdp_epsilon: *rdp_epsilon,
|
||||
connectivity: if *eight_conn { hulls::Connectivity::Eight }
|
||||
else { hulls::Connectivity::Four },
|
||||
};
|
||||
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 {
|
||||
fp: hull_fp,
|
||||
hulls: extracted.clone(),
|
||||
@@ -831,12 +911,11 @@ fn process_pass_work(
|
||||
let hull_params = hulls::HullParams {
|
||||
threshold: *threshold,
|
||||
min_area: *min_area,
|
||||
rdp_epsilon: *rdp_epsilon,
|
||||
connectivity: if *eight_conn { hulls::Connectivity::Eight }
|
||||
else { hulls::Connectivity::Four },
|
||||
};
|
||||
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)
|
||||
};
|
||||
|
||||
@@ -871,16 +950,16 @@ fn process_pass_work(
|
||||
|
||||
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 entry.fp == fill_fp {
|
||||
// Cache hit
|
||||
let preview = cache.preview_cache.get(&node.id)
|
||||
.filter(|(fp, _)| *fp == fill_fp)
|
||||
.map(|(_, p)| p.clone());
|
||||
cache_hits += 1;
|
||||
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()));
|
||||
p
|
||||
});
|
||||
@@ -890,61 +969,43 @@ fn process_pass_work(
|
||||
}
|
||||
}
|
||||
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) =
|
||||
(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);
|
||||
}
|
||||
|
||||
// Compute. Convert pixel hulls to mm hulls once; mm fills handle
|
||||
// their own internal rasterization at FILL_INTERNAL_PX_PER_MM.
|
||||
let response_arc: std::sync::Arc<[u8]> = resp_for_fill.into();
|
||||
let (strategy, spacing_mm, angle, param, smooth_rdp, smooth_iters) =
|
||||
(strategy.clone(), *spacing, *angle, *param, *smooth_rdp, *smooth_iters);
|
||||
let mm_hulls: Vec<hulls::MmHull> = hulls_for_fill.iter()
|
||||
.map(|h| hulls::MmHull::from_pixel_hull(h, px_per_mm_fill))
|
||||
.collect();
|
||||
let img_w = w;
|
||||
let src_px_per_mm = px_per_mm_fill;
|
||||
let raw: Vec<fill::FillResult> = mm_hulls.par_iter().map(|mh| {
|
||||
match strategy.as_str() {
|
||||
"outline" => fill::outline_mm(mh),
|
||||
"zigzag" => fill::zigzag_hatch_mm(mh, spacing_mm, angle),
|
||||
"offset" => fill::contour_offset_mm(mh, spacing_mm),
|
||||
"spiral" => fill::spiral_mm(mh, spacing_mm),
|
||||
"circles" => fill::circle_pack_mm(mh, spacing_mm, param.max(0.1)),
|
||||
"voronoi" => fill::voronoi_fill_mm(mh, spacing_mm),
|
||||
"hilbert" => fill::hilbert_fill_mm(mh, spacing_mm),
|
||||
"waves" => fill::wave_interference_mm(mh, spacing_mm, param.round().max(1.0) as usize),
|
||||
"flow" => fill::flow_field_mm(mh, spacing_mm, angle, param.max(0.0)),
|
||||
"gradient_hatch" => fill::gradient_hatch_mm(mh, &response_arc, img_w, src_px_per_mm, spacing_mm, angle, param.clamp(0.05, 1.0)),
|
||||
"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.preview_cache.insert(node.id.clone(), (fill_fp, preview.clone()));
|
||||
(opt, preview)
|
||||
} else {
|
||||
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)
|
||||
};
|
||||
}
|
||||
let (optimised, preview) = (opt, preview);
|
||||
|
||||
node_previews.insert(node.id.clone(), preview);
|
||||
fill_outputs.insert(node.id.clone(), optimised);
|
||||
@@ -953,30 +1014,21 @@ fn process_pass_work(
|
||||
t = lap!(steps, "fill", t);
|
||||
|
||||
// ── Text nodes ─────────────────────────────────────────────────────────────
|
||||
// Text nodes produce strokes in mm directly via Hershey; we convert to
|
||||
// pixel coords matching the rest of the pipeline so PenOutput can pull
|
||||
// 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);
|
||||
// Text nodes produce strokes in mm directly via Hershey — they're already
|
||||
// in the FillResult coord system (mm), so just pass through.
|
||||
for node in &det_graph.nodes {
|
||||
if let detect::NodeKind::Text {
|
||||
text, font, font_size_mm, line_spacing_mm,
|
||||
x_mm, y_mm, align, underline,
|
||||
} = &node.kind {
|
||||
let mm_strokes = text::render_text(
|
||||
let strokes = text::render_text(
|
||||
text, font, *font_size_mm, *line_spacing_mm,
|
||||
*x_mm, *y_mm,
|
||||
text::Align::from_str(align),
|
||||
*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 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);
|
||||
fill_outputs.insert(node.id.clone(), fill);
|
||||
}
|
||||
@@ -984,6 +1036,11 @@ fn process_pass_work(
|
||||
t = lap!(steps, "text", t);
|
||||
|
||||
// ── 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_output_results: Vec<PenOutputResult> = Vec::new();
|
||||
|
||||
@@ -1003,18 +1060,18 @@ fn process_pass_work(
|
||||
cached_p.clone()
|
||||
} else {
|
||||
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()));
|
||||
p
|
||||
}
|
||||
} else {
|
||||
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()));
|
||||
p
|
||||
}
|
||||
} 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);
|
||||
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].img_w = result.img_w;
|
||||
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;
|
||||
|
||||
Ok(result)
|
||||
@@ -2086,12 +2145,11 @@ fn get_all_strokes(
|
||||
state: State<Mutex<AppState>>,
|
||||
) -> Result<AllStrokesPayload, String> {
|
||||
let st = state.lock().unwrap();
|
||||
// Use the scaled pipeline dimensions (stored after process_pass) so the
|
||||
// viewport offscreen canvas matches the coordinate space of the strokes.
|
||||
let (img_width, img_height) = st.passes.first()
|
||||
.filter(|p| p.img_w > 0)
|
||||
.map(|p| (p.img_w, p.img_h))
|
||||
.unwrap_or((1, 1));
|
||||
// Strokes are in mm; frontend uses paper dims to scale to screen.
|
||||
let (paper_w_mm, paper_h_mm) = st.passes.first()
|
||||
.filter(|p| p.paper_w_mm > 0.0)
|
||||
.map(|p| (p.paper_w_mm, p.paper_h_mm))
|
||||
.unwrap_or((210.0, 297.0));
|
||||
let mut all: Vec<PassStrokesPayload> = Vec::new();
|
||||
for ps in st.passes.iter() {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
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.
|
||||
@@ -2285,7 +2343,8 @@ mod blocking_tests {
|
||||
sat_min_value: None, canny_low: None, canny_high: None,
|
||||
xdog_sigma2: None, xdog_tau: None, xdog_phi: 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,
|
||||
strategy: None, spacing: None, angle: None, param: None,
|
||||
smooth_rdp: None, smooth_iters: None,
|
||||
@@ -2311,7 +2370,6 @@ mod blocking_tests {
|
||||
k1.xdog_phi = Some(10.0);
|
||||
hull.threshold = Some(128);
|
||||
hull.min_area = Some(10);
|
||||
hull.rdp_epsilon = Some(2.0);
|
||||
hull.connectivity = Some("four".into());
|
||||
let mut fill_node = node("fill", "Fill");
|
||||
fill_node.strategy = Some("hatch".into());
|
||||
@@ -2330,6 +2388,7 @@ mod blocking_tests {
|
||||
dpi: None,
|
||||
img_w_mm: None,
|
||||
img_h_mm: None,
|
||||
pen_tip_mm: None,
|
||||
graph: DetectionGraphPayload {
|
||||
nodes: vec![node("source", "Source"), k1, hull, fill_node, pen_node],
|
||||
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 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)
|
||||
}
|
||||
|
||||
@@ -2444,7 +2503,7 @@ mod viz_tests {
|
||||
let (w, h) = img.dimensions();
|
||||
let luma: Vec<u8> = img.pixels().map(|p| p[0]).collect();
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -2624,7 +2683,6 @@ mod viz_tests {
|
||||
let config = &json["passes"][0]["config"];
|
||||
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 rdp_eps = config["rdp_epsilon"].as_f64().unwrap_or(1.5) as f32;
|
||||
|
||||
let stored = json["image_path"].as_str().unwrap_or("");
|
||||
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: "k1".into(), kind: detect::NodeKind::Kernel(layer) },
|
||||
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,
|
||||
cf_enabled: false,
|
||||
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> =
|
||||
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 params = HullParams { threshold, min_area, rdp_epsilon: rdp_eps,
|
||||
let params = HullParams { threshold, min_area,
|
||||
connectivity: hulls::Connectivity::Four };
|
||||
let hs = hulls::extract_hulls(&response, &img, w, h, ¶ms);
|
||||
(hs, w, h)
|
||||
|
||||
Reference in New Issue
Block a user