From de8f0ff24daec38133ffa34d0528c7a7d5cbee4b Mon Sep 17 00:00:00 2001 From: Mitchell Hansen Date: Sat, 9 May 2026 00:55:51 -0700 Subject: [PATCH] mm-coord pipeline: fills/pen/gcode in mm, DPI per Source + per Kernel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src-frontend/src/App.jsx | 167 +++++++------ src-frontend/src/components/NodeGraph.jsx | 10 +- src-frontend/src/components/Viewport.jsx | 78 +++--- src-frontend/src/project.js | 7 +- src-frontend/src/project.test.js | 17 +- src-frontend/src/store.js | 17 +- src/detect.rs | 39 ++- src/fill.rs | 286 ++++++++++++++++++++- src/gcode.rs | 54 +++- src/hulls.rs | 169 +++++++------ src/lib.rs | 290 +++++++++++++--------- 11 files changed, 767 insertions(+), 367 deletions(-) diff --git a/src-frontend/src/App.jsx b/src-frontend/src/App.jsx index 94e621b6..9eff1157 100644 --- a/src-frontend/src/App.jsx +++ b/src-frontend/src/App.jsx @@ -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() {
- {/* Graph */} -
-

Graph

- setNodeWidth(v)} unit="px" /> -
- - {/* Pipeline */} -
-

Pipeline

- setDpi(v)} /> -
- - {/* Paper */} -
-

Paper

-
- {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 ( - - ) - })} - + {/* ── Pipeline view: graph layout, DPI, paper size ──────────── */} + {viewMode === 'pipeline' && (<> +
+

Graph

+ setNodeWidth(v)} unit="px" />
-
- {/* Plotter */} -
-
-

Plotter

- +
+

Paper

+
+ {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 ( + + ) + })} + +
- setGcode({ feed_draw: v })} unit=" mm/m" /> - setGcode({ feed_travel: v })} unit=" mm/m" /> - setGcode({ pen_up_z_mm: v })} /> - setGcode({ pen_dwell_ms: v })} /> -
+ )} - {/* Calibration: corner jog + axis-scale */} - - + {/* ── G-code view: plotter motion params, corner-jog, export ── */} + {viewMode === 'gcode' && (<> +
+
+

Plotter

+ +
+ setGcode({ feed_draw: v })} unit=" mm/m" /> + setGcode({ feed_travel: v })} unit=" mm/m" /> + setGcode({ pen_up_z_mm: v })} /> + setGcode({ pen_dwell_ms: v })} /> + setGcode({ pen_tip_mm: v })} /> +
- {/* Export & upload */} -
-

Output

- -

Use the Printer tab to upload & run.

-
+ + +
+

Output

+ +

Use the Printer tab to upload & run.

+
+ )} + + {/* ── Printer view: axis-scale calibration ──────────────────── */} + {viewMode === 'printer' && ( + + )}
diff --git a/src-frontend/src/components/NodeGraph.jsx b/src-frontend/src/components/NodeGraph.jsx index e0198c04..be408a7f 100644 --- a/src-frontend/src/components/NodeGraph.jsx +++ b/src-frontend/src/components/NodeGraph.jsx @@ -557,6 +557,8 @@ export default function NodeGraph({ graph, onChange, nodePreviews, nodeWidth = 2 {node.file_path}
)} + updateNode(node.id, { dpi: v })} /> )} {node.kind === 'Kernel' && (<> @@ -619,6 +621,10 @@ export default function NodeGraph({ graph, onChange, nodePreviews, nodeWidth = 2 Invert )} + {/* Per-kernel internal DPI: 0 = canvas DPI (no resample), + lower values downsample input for speed. */} + 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 })} /> updateNode(node.id, { min_area: v })} /> - updateNode(node.id, { rdp_epsilon: v })} />
Connectivity {['four','eight'].map(c => ( @@ -684,7 +688,7 @@ export default function NodeGraph({ graph, onChange, nodePreviews, nodeWidth = 2 >{s} ))}
- updateNode(node.id, { spacing: v })} /> {FILL_USES_ANGLE.has(node.strategy ?? 'hatch') && ( ({ 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]) diff --git a/src-frontend/src/project.js b/src-frontend/src/project.js index 04f61d59..c7711055 100644 --- a/src-frontend/src/project.js +++ b/src-frontend/src/project.js @@ -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, diff --git a/src-frontend/src/project.test.js b/src-frontend/src/project.test.js index 77ed73d0..43983d82 100644 --- a/src-frontend/src/project.test.js +++ b/src-frontend/src/project.test.js @@ -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) }) diff --git a/src-frontend/src/store.js b/src-frontend/src/store.js index 0a24c9e6..65b0899b 100644 --- a/src-frontend/src/store.js +++ b/src-frontend/src/store.js @@ -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) } diff --git a/src/detect.rs b/src/detect.rs index 15c97ca0..6ad16347 100644 --- a/src/detect.rs +++ b/src/detect.rs @@ -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, } 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 { + 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, 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 { diff --git a/src/fill.rs b/src/fill.rs index 9efa263f..e0664318 100644 --- a/src/fill.rs +++ b/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>, } +// ── 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 = 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>, px_per_mm: f32) -> Vec> { + 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); diff --git a/src/gcode.rs b/src/gcode.rs index 46baae0d..63ac4936 100644 --- a/src/gcode.rs +++ b/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] diff --git a/src/hulls.rs b/src/hulls.rs index 916796f9..2216ae22 100644 --- a/src/hulls.rs +++ b/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>, // 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 = 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 = 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 { diff --git a/src/lib.rs b/src/lib.rs index f2e25c07..8869c491 100644 --- a/src/lib.rs +++ b/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) -> 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, 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, // Combine params (optional) pub blend_mode: Option, + pub kernel_dpi: Option, // Hull params (optional — only for kind="Hull") pub threshold: Option, pub min_area: Option, - pub rdp_epsilon: Option, pub connectivity: Option, pub color_filter: Option, // 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, + /// 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, } #[derive(Serialize, Clone, Default)] @@ -377,9 +385,12 @@ fn default_pen_dwell() -> u32 { 250 } #[derive(Serialize)] pub struct AllStrokesPayload { - pub passes: Vec, - pub img_width: u32, - pub img_height: u32, + pub passes: Vec, + /// 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> = 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 = 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 = 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_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 = 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 = 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 = 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 = 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> = 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 = Vec::new(); let mut pen_output_results: Vec = 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>, ) -> Result { 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 = 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 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 = 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 = 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)