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)