diff --git a/src-frontend/src/components/NodeGraph.jsx b/src-frontend/src/components/NodeGraph.jsx index be408a7f..97fab861 100644 --- a/src-frontend/src/components/NodeGraph.jsx +++ b/src-frontend/src/components/NodeGraph.jsx @@ -621,10 +621,6 @@ 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' && (<> @@ -702,6 +698,8 @@ export default function NodeGraph({ graph, onChange, nodePreviews, nodeWidth = 2 onChange={v => updateNode(node.id, { param: v })} /> ) })()} + updateNode(node.id, { dpi: v })} /> updateNode(node.id, { smooth_rdp: v })} /> , } impl Default for DetectionLayer { @@ -80,37 +74,10 @@ 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 { @@ -523,6 +490,9 @@ pub enum NodeKind { param: f32, smooth_rdp: f32, smooth_iters: u32, + /// Internal raster resolution (DPI) the fill uses when sampling + /// the hull polygon. Higher = smoother fill paths, slower. + dpi: u32, }, PenOutput { color: [u8; 3], @@ -583,7 +553,6 @@ pub fn evaluate_graph( node_rgbs: &std::collections::HashMap, canvas_w: u32, canvas_h: u32, - canvas_dpi: f32, ) -> GraphMaps { use std::collections::{HashMap, VecDeque}; @@ -658,9 +627,9 @@ pub fn evaluate_graph( let v = up[(y * src_rgb.width() + x) as usize]; image::Rgb([v, v, v]) }); - apply_layer_with_dpi(&gray_rgb, layer, canvas_dpi) + apply_layer(&gray_rgb, layer) } else { - apply_layer_with_dpi(src_rgb, layer, canvas_dpi) + apply_layer(src_rgb, layer) }; let w = layer.weight; Some(if (w - 1.0).abs() < 1e-6 { diff --git a/src/fill.rs b/src/fill.rs index e0664318..ba7ceee0 100644 --- a/src/fill.rs +++ b/src/fill.rs @@ -119,10 +119,12 @@ pub fn outline_mm(mm: &MmHull) -> FillResult { /// Wrapper macro: rasterize → run a pixel-space fill that takes /// `(hull, spacing_px)` → convert strokes back to mm. +/// `internal_px_per_mm` is the per-Fill DPI knob — higher = smoother +/// fill paths at the cost of more allocation + compute. 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; + pub fn $name(mm: &MmHull, spacing_mm: f32, internal_px_per_mm: f32) -> FillResult { + let s = internal_px_per_mm.max(1.0); let h = rasterize_mm_hull(mm, s); let spacing_px = (spacing_mm * s).max(0.5); let r = $px_fn(&h, spacing_px); @@ -138,8 +140,8 @@ 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; + pub fn $name(mm: &MmHull, spacing_mm: f32, angle_deg: f32, internal_px_per_mm: f32) -> FillResult { + let s = internal_px_per_mm.max(1.0); 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); @@ -150,24 +152,24 @@ macro_rules! mm_wrap_hatch { 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; +pub fn circle_pack_mm(mm: &MmHull, spacing_mm: f32, min_radius_factor: f32, internal_px_per_mm: f32) -> FillResult { + let s = internal_px_per_mm.max(1.0); 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; +pub fn wave_interference_mm(mm: &MmHull, spacing_mm: f32, num_sources: usize, internal_px_per_mm: f32) -> FillResult { + let s = internal_px_per_mm.max(1.0); 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; +pub fn flow_field_mm(mm: &MmHull, spacing_mm: f32, angle_deg: f32, amplitude_scale: f32, internal_px_per_mm: f32) -> FillResult { + let s = internal_px_per_mm.max(1.0); 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); @@ -1351,7 +1353,7 @@ mod tests { #[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); + let r = parallel_hatch_mm(&mm, 5.0, 0.0, FILL_INTERNAL_PX_PER_MM); assert!(!r.strokes.is_empty()); for stroke in &r.strokes { for &(x, y) in stroke { @@ -1366,8 +1368,8 @@ mod tests { // 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); + let r150 = parallel_hatch_mm(&pixel_rect_to_mm_hull(150.0), 5.0, 0.0, FILL_INTERNAL_PX_PER_MM); + let r600 = parallel_hatch_mm(&pixel_rect_to_mm_hull(600.0), 5.0, 0.0, FILL_INTERNAL_PX_PER_MM); assert_eq!(r150.strokes.len(), r600.strokes.len(), "stroke count differs across DPI: 150→{}, 600→{}", r150.strokes.len(), r600.strokes.len()); diff --git a/src/lib.rs b/src/lib.rs index 8869c491..4466c6a4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -83,7 +83,6 @@ 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 { @@ -115,6 +114,7 @@ fn fp_fill(node: &GraphNodePayload, upstream_fp: u64) -> u64 { h.write_u32(node.param.unwrap_or(1.0).to_bits()); h.write_u32(node.smooth_rdp.unwrap_or(1.0).to_bits()); h.write_u32(node.smooth_iters.unwrap_or(2)); + h.write_u32(node.dpi.unwrap_or(254)); h.finish() } fn fp_pen(node: &GraphNodePayload, upstream_fp: u64, pen_tip_mm: Option) -> u64 { @@ -250,7 +250,6 @@ 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, @@ -263,6 +262,10 @@ pub struct GraphNodePayload { pub param: Option, pub smooth_rdp: Option, pub smooth_iters: Option, + /// Per-node internal DPI. Used by Fill (sets the rasterisation + /// resolution when sampling the hull polygon) and by Source + /// (project canvas DPI, surfaced via the App-level max). + pub dpi: Option, // PenOutput params (optional — only for kind="PenOutput") pub pen_color: Option>, // [r, g, b] pub pen_label: Option, @@ -436,7 +439,6 @@ 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" => { @@ -462,6 +464,7 @@ fn to_detection_graph(payload: &DetectionGraphPayload) -> detect::DetectionGraph param: n.param.unwrap_or(1.0), smooth_rdp: n.smooth_rdp.unwrap_or(1.0), smooth_iters: n.smooth_iters.unwrap_or(2), + dpi: n.dpi.unwrap_or(254), }, "Text" => detect::NodeKind::Text { text: n.text.clone().unwrap_or_default(), @@ -781,8 +784,7 @@ fn process_pass_work( } } else { cache_misses += 1; - let maps = detect::evaluate_graph(&det_graph, &node_rgbs, canvas_w, canvas_h, - payload.dpi.unwrap_or(150) as f32); + let maps = detect::evaluate_graph(&det_graph, &node_rgbs, canvas_w, canvas_h); cache.detect_fp = detect_fp; cache.detect_response = maps.response.clone(); cache.detect_maps = maps.raw_maps.clone(); @@ -936,7 +938,7 @@ fn process_pass_work( for node in &det_graph.nodes { if let detect::NodeKind::Fill { - strategy, spacing, angle, param, smooth_rdp, smooth_iters + strategy, spacing, angle, param, smooth_rdp, smooth_iters, dpi: fill_dpi } = &node.kind { let upstream = det_graph.edges.iter().find(|e| e.to == node.id && e.port == 0); let (hulls_for_fill, resp_for_fill) = match upstream { @@ -972,10 +974,11 @@ fn process_pass_work( } // Compute. Convert pixel hulls to mm hulls once; mm fills handle - // their own internal rasterization at FILL_INTERNAL_PX_PER_MM. + // their own internal rasterization at the per-Fill `dpi` knob. 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 (strategy, spacing_mm, angle, param, smooth_rdp, smooth_iters, fill_dpi) = + (strategy.clone(), *spacing, *angle, *param, *smooth_rdp, *smooth_iters, *fill_dpi); + let internal_px_per_mm = (fill_dpi as f32 / 25.4).max(1.0); let mm_hulls: Vec = hulls_for_fill.iter() .map(|h| hulls::MmHull::from_pixel_hull(h, px_per_mm_fill)) .collect(); @@ -984,17 +987,17 @@ fn process_pass_work( 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)), + "zigzag" => fill::zigzag_hatch_mm(mh, spacing_mm, angle, internal_px_per_mm), + "offset" => fill::contour_offset_mm(mh, spacing_mm, internal_px_per_mm), + "spiral" => fill::spiral_mm(mh, spacing_mm, internal_px_per_mm), + "circles" => fill::circle_pack_mm(mh, spacing_mm, param.max(0.1), internal_px_per_mm), + "voronoi" => fill::voronoi_fill_mm(mh, spacing_mm, internal_px_per_mm), + "hilbert" => fill::hilbert_fill_mm(mh, spacing_mm, internal_px_per_mm), + "waves" => fill::wave_interference_mm(mh, spacing_mm, param.round().max(1.0) as usize, internal_px_per_mm), + "flow" => fill::flow_field_mm(mh, spacing_mm, angle, param.max(0.0), internal_px_per_mm), "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), + _ => fill::parallel_hatch_mm(mh, spacing_mm, angle, internal_px_per_mm), } }).collect(); let smoothed: Vec = raw.iter() @@ -2343,11 +2346,10 @@ 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, - 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, + smooth_rdp: None, smooth_iters: None, dpi: None, pen_color: None, pen_label: None, pen_order: None, file_path: None, text: None, font: None, font_size_mm: None, line_spacing_mm: None, @@ -2730,7 +2732,7 @@ 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, 150.0); + let gm = detect::evaluate_graph(&graph, &node_rgbs, w, h); let response = gm.raw_maps.get("hull").cloned().unwrap_or(gm.response); let params = HullParams { threshold, min_area, connectivity: hulls::Connectivity::Four };