From 4924f038b45883a47018a9ed347baba4da9bd445 Mon Sep 17 00:00:00 2001 From: Mitchell Hansen Date: Sat, 9 May 2026 02:26:40 -0700 Subject: [PATCH] fills regenerate when gcode-view image is resized MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gcode-view corner handle changes gcodeConfig.img_w_mm — the image's on-paper width — but until now that scale was applied at gcode-export time, so the same fill paths got plotted at different sizes. Pen tip diameter is constant, so a half-size image with the same fill paths plots at half the spacing on paper. User wanted fills to regenerate at the new on-paper density instead. Backpropagation: - App.jsx now sends gcodeConfig.img_w_mm as payload.img_w_mm; paper dims as payload.paper_w_mm/paper_h_mm (new fields). - process_pass uses image_w_mm for source_px_per_mm — MmHull lands in actual-paper-mm, so fills generate at on-paper density. - gcode export drops the img_w_mm/paper_w_mm scale factor; strokes flow through unchanged. - Reprocess effect now lists img_w_mm — corner-drag triggers debounced reprocess (400ms via scheduleProcess), fills regenerate live. Source card now shows the on-paper scale: - Stashes file_w_px / file_h_px on the node when an image is loaded (loadImage returns ImageInfo with native dims). - Renders three lines under the file path: Native: WxH px On paper: W×H mm (XX% of paper width) 1 px = X mm · Y px/mm Updates live as the user drags the gcode corner handle. NodeGraph takes new props: imageWMm + paperWMm. Source card uses them to derive the on-paper height (aspect-correct) and px/mm rate. gcode test updated: pixel-scale-shrinks-strokes case is gone — the new contract is "strokes pass through unchanged, scale is upstream." 85 lib tests pass; 70 frontend tests pass. --- src-frontend/src/App.jsx | 19 ++++++++--- src-frontend/src/components/NodeGraph.jsx | 32 ++++++++++++++---- src/gcode.rs | 41 ++++++++++------------- src/lib.rs | 31 ++++++++++++----- 4 files changed, 81 insertions(+), 42 deletions(-) diff --git a/src-frontend/src/App.jsx b/src-frontend/src/App.jsx index 9e56039d..208813c1 100644 --- a/src-frontend/src/App.jsx +++ b/src-frontend/src/App.jsx @@ -152,14 +152,18 @@ export default function App() { updatePass(idx, { status: 'Processing…', vizB64: null, hullCount: 0, strokeCount: 0 }) const t0 = performance.now() try { - // No more project DPI. Source images process at native pixel - // dims; mm conversion happens at the polygon boundary. + // Sources process at native dims; image-on-paper width + // (gcodeConfig.img_w_mm) drives the source-pixel→mm conversion so + // resizing the image in the gcode view actually re-fills the + // hulls at the new on-paper density. const paperW = gcodeConfigRef.current.paper_w_mm const paperH = gcodeConfigRef.current.paper_h_mm const result = await tauri.processPass({ pass_index: idx, graph: pass.graph, - img_w_mm: paperW, + paper_w_mm: paperW, + paper_h_mm: paperH, + img_w_mm: gcodeConfigRef.current.img_w_mm ?? paperW, img_h_mm: paperH, pen_tip_mm: gcodeConfigRef.current.pen_tip_mm ?? 0.5, }) @@ -193,7 +197,12 @@ export default function App() { useEffect(() => { scheduleProcess() - }, [gcodeConfig.paper_w_mm, gcodeConfig.paper_h_mm, gcodeConfig.pen_tip_mm]) + }, [gcodeConfig.paper_w_mm, gcodeConfig.paper_h_mm, gcodeConfig.pen_tip_mm, + gcodeConfig.img_w_mm]) + // img_w_mm in deps so the gcode-view corner handle (which mutates + // it) backpropagates: drag corner → debounced reprocess → fills + // regenerate at the new on-paper image density. The 400ms debounce + // in scheduleProcess keeps mid-drag updates from thrashing. // ── Export ───────────────────────────────────────────────────────────────── async function exportAll() { @@ -469,6 +478,8 @@ export default function App() { }} nodePreviews={passes[0].nodePreviews} nodeWidth={nodeWidth} + imageWMm={gcodeConfig.img_w_mm ?? gcodeConfig.paper_w_mm} + paperWMm={gcodeConfig.paper_w_mm} /> ) : viewMode === 'printer' ? ( )} + {node.file_w_px && node.file_h_px && (() => { + // On-paper dimensions derived from gcode-view img_w_mm. + // Aspect-correct height: image_h = image_w × (px_h / px_w). + const imgH = imageWMm * node.file_h_px / node.file_w_px + const ppmm = node.file_w_px / Math.max(0.001, imageWMm) + const paperFrac = paperWMm > 0 ? imageWMm / paperWMm : 1 + return ( +
+
Native: {node.file_w_px}×{node.file_h_px} px
+
On paper: {imageWMm.toFixed(1)}×{imgH.toFixed(1)} mm + ({(paperFrac * 100).toFixed(0)}% of paper width)
+
1 px = {(1 / ppmm).toFixed(3)} mm · {ppmm.toFixed(1)} px/mm
+
+ ) + })()} )} {node.kind === 'Kernel' && (<> diff --git a/src/gcode.rs b/src/gcode.rs index 63ac4936..8bddd3a1 100644 --- a/src/gcode.rs +++ b/src/gcode.rs @@ -90,20 +90,13 @@ impl GcodeConfig { } /// Convert fill results to G-code. -/// 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. +/// Strokes are already in actual-paper-mm — image-on-paper scaling +/// happens upstream in process_pass (so fills regenerate when the +/// user resizes the image). All this does is apply paper + image +/// offsets to position the strokes on the bed. 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; + let ox = cfg.paper_offset_x_mm + cfg.offset_x_mm; + let oy = cfg.paper_offset_y_mm + cfg.offset_y_mm; let mut out = String::with_capacity(4096); @@ -111,8 +104,8 @@ pub fn to_gcode(results: &[FillResult], _img_w: u32, _img_h: u32, cfg: &GcodeCon out.push_str(&format!("; Bed: {:.0}x{:.0} mm\n", cfg.bed_w_mm, cfg.bed_h_mm)); out.push_str(&format!("; Paper: {:.0}x{:.0} mm @ ({:.1}, {:.1})\n", cfg.paper_w_mm, cfg.paper_h_mm, cfg.paper_offset_x_mm, cfg.paper_offset_y_mm)); - out.push_str(&format!("; Image: {:.1}x{:.1} mm @ ({:.1}, {:.1})\n", - cfg.img_w_mm, scale * _img_h as f32, ox, oy)); + out.push_str(&format!("; Image: {:.1} mm wide @ ({:.1}, {:.1})\n", + cfg.img_w_mm, ox, oy)); out.push_str("G21 ; mm mode\n"); out.push_str("G90 ; absolute positioning\n"); // $H must be on its own line: FluidNC's $-command parser does not strip @@ -141,14 +134,14 @@ pub fn to_gcode(results: &[FillResult], _img_w: u32, _img_h: u32, cfg: &GcodeCon if stroke.len() < 2 { continue; } let (sx, sy) = stroke[0]; - out.push_str(&format!("G0 X{:.3} Y{:.3}\n", sx * scale + ox, sy * scale + oy)); + out.push_str(&format!("G0 X{:.3} Y{:.3}\n", sx + ox, sy + oy)); out.push_str(&cfg.pen_down); out.push('\n'); out.push_str(&dwell_line); // wait for pen to physically drop out.push_str(&format!("G1 F{}\n", cfg.feed_draw)); for &(px, py) in &stroke[1..] { - out.push_str(&format!("G1 X{:.3} Y{:.3}\n", px * scale + ox, py * scale + oy)); + out.push_str(&format!("G1 X{:.3} Y{:.3}\n", px + ox, py + oy)); } out.push_str(&format!("G0 Z{:.3}\n", cfg.pen_up_z_mm)); @@ -231,13 +224,15 @@ mod tests { } #[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. + fn gcode_passes_strokes_through_unchanged() { + // Image-on-paper scaling is now baked into stroke generation + // upstream (process_pass uses img_w_mm to compute the source's + // px-to-mm rate). gcode export just adds offsets — strokes flow + // through at their generated mm values regardless of img_w_mm. let cfg = GcodeConfig { paper_w_mm: 200.0, paper_h_mm: 200.0, - img_w_mm: 100.0, + img_w_mm: 100.0, // ignored by export offset_x_mm: 0.0, offset_y_mm: 0.0, paper_offset_x_mm: 0.0, @@ -246,10 +241,10 @@ mod tests { }; let result = FillResult { hull_id: 0, - strokes: vec![vec![(0.0, 0.0), (100.0, 0.0)]], + strokes: vec![vec![(0.0, 0.0), (50.0, 0.0)]], }; let code = to_gcode(&[result], 0, 0, &cfg); - assert!(code.contains("X50.000"), "expected X=100*0.5=50, got: {code}"); + assert!(code.contains("X50.000"), "expected X=50 (unchanged), got: {code}"); } #[test] diff --git a/src/lib.rs b/src/lib.rs index 40e387d7..2a322893 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -312,6 +312,14 @@ pub struct ProcessPassPayload { pub pass_index: usize, pub graph: DetectionGraphPayload, pub dpi: Option, + /// Paper dimensions in mm — what gcode targets, what previews + /// render against. + pub paper_w_mm: Option, + pub paper_h_mm: Option, + /// Image-on-paper width in mm. The user sets this via the gcode- + /// view corner handle; backpropagating it to fill processing makes + /// fills regenerate at the actual on-paper density (constant pen + /// tip, scaled image = different fill spacing per unit paper). pub img_w_mm: Option, /// Paper height in mm. Only used when there's no source image and /// the graph relies on Text nodes — we synthesize a blank canvas @@ -696,17 +704,20 @@ fn process_pass_work( let mut cache_hits = 0u32; let mut cache_misses = 0u32; - // Paper dims (mm). The image's mm width on paper is `paper_w_mm_for_scale` - // (= img_w_mm payload, defaulted to A4); per-source pixels-per-mm is - // computed below from each source's native dims. - 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); + // Paper dims drive preview canvas sizing. Image-on-paper width + // drives source pixel-to-mm conversion — when the user shrinks + // the image via the gcode-view corner handle, that smaller width + // backpropagates here, so fills regenerate at actual paper-mm + // density (the pen tip stays a constant 0.5 mm regardless). + let paper_w_mm_for_scale = payload.paper_w_mm.unwrap_or(210.0).max(1.0); + let paper_h_mm_for_scale = payload.paper_h_mm.unwrap_or(297.0).max(1.0); + let image_w_mm = payload.img_w_mm.unwrap_or(paper_w_mm_for_scale).max(1.0); - // Per-source pixels-per-mm: divides each source's native width by its - // mm width on paper. Used by Hull → MmHull conversion and gradient - // fills. Each source has its own rate. + // Per-source pixels-per-mm: divides each source's native width by + // its image-on-paper width. So strokes land in actual-paper-mm + // space without any later export-time scaling. let source_px_per_mm: std::collections::HashMap = source_rgbs.iter() - .map(|(id, rgb)| (id.clone(), rgb.width() as f32 / paper_w_mm_for_scale)) + .map(|(id, rgb)| (id.clone(), rgb.width() as f32 / image_w_mm)) .collect(); // ── Per-node Source RGB lookup ──────────────────────────────────────────── @@ -2381,6 +2392,8 @@ mod blocking_tests { ProcessPassPayload { pass_index: 0, dpi: None, + paper_w_mm: None, + paper_h_mm: None, img_w_mm: None, img_h_mm: None, pen_tip_mm: None,