fills regenerate when gcode-view image is resized

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.
This commit is contained in:
Mitchell Hansen
2026-05-09 02:26:40 -07:00
parent c3acf39f19
commit 4924f038b4
4 changed files with 81 additions and 42 deletions

View File

@@ -152,14 +152,18 @@ export default function App() {
updatePass(idx, { status: 'Processing…', vizB64: null, hullCount: 0, strokeCount: 0 }) updatePass(idx, { status: 'Processing…', vizB64: null, hullCount: 0, strokeCount: 0 })
const t0 = performance.now() const t0 = performance.now()
try { try {
// No more project DPI. Source images process at native pixel // Sources process at native dims; image-on-paper width
// dims; mm conversion happens at the polygon boundary. // (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 paperW = gcodeConfigRef.current.paper_w_mm
const paperH = gcodeConfigRef.current.paper_h_mm const paperH = gcodeConfigRef.current.paper_h_mm
const result = await tauri.processPass({ const result = await tauri.processPass({
pass_index: idx, pass_index: idx,
graph: pass.graph, 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, img_h_mm: paperH,
pen_tip_mm: gcodeConfigRef.current.pen_tip_mm ?? 0.5, pen_tip_mm: gcodeConfigRef.current.pen_tip_mm ?? 0.5,
}) })
@@ -193,7 +197,12 @@ export default function App() {
useEffect(() => { useEffect(() => {
scheduleProcess() 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 ───────────────────────────────────────────────────────────────── // ── Export ─────────────────────────────────────────────────────────────────
async function exportAll() { async function exportAll() {
@@ -469,6 +478,8 @@ export default function App() {
}} }}
nodePreviews={passes[0].nodePreviews} nodePreviews={passes[0].nodePreviews}
nodeWidth={nodeWidth} nodeWidth={nodeWidth}
imageWMm={gcodeConfig.img_w_mm ?? gcodeConfig.paper_w_mm}
paperWMm={gcodeConfig.paper_w_mm}
/> />
) : viewMode === 'printer' ? ( ) : viewMode === 'printer' ? (
<PrinterPanel <PrinterPanel

View File

@@ -101,7 +101,8 @@ function isCompatible(fromKind, toKind, existingEdges, fromId, toId, allNodes =
} }
// ── Component ────────────────────────────────────────────────────────────────── // ── Component ──────────────────────────────────────────────────────────────────
export default function NodeGraph({ graph, onChange, nodePreviews, nodeWidth = 220 }) { export default function NodeGraph({ graph, onChange, nodePreviews, nodeWidth = 220,
imageWMm = 210, paperWMm = 210 }) {
const canvasRef = useRef(null) const canvasRef = useRef(null)
const worldRef = useRef(null) const worldRef = useRef(null)
const [wire, setWire] = useState(null) // { fromId, fromX, fromY, mouseX, mouseY } const [wire, setWire] = useState(null) // { fromId, fromX, fromY, mouseX, mouseY }
@@ -536,11 +537,15 @@ export default function NodeGraph({ graph, onChange, nodePreviews, nodeWidth = 2
try { try {
const path = await tauri.pickImageFile() const path = await tauri.pickImageFile()
if (!path) return if (!path) return
// Load into the backend cache; result.preview_b64 is // Load into the backend cache; the returned ImageInfo
// surfaced via process_pass's nodePreviews on the next // gives us native dims so the card can display the
// run, so we only need the success signal here. // on-paper scale derived from gcode-view img_w_mm.
await tauri.loadImage(path) const info = await tauri.loadImage(path)
updateNode(node.id, { file_path: path }) updateNode(node.id, {
file_path: path,
file_w_px: info.width,
file_h_px: info.height,
})
} catch (e) { } catch (e) {
console.error('[source] load failed:', e) console.error('[source] load failed:', e)
} }
@@ -557,6 +562,21 @@ export default function NodeGraph({ graph, onChange, nodePreviews, nodeWidth = 2
{node.file_path} {node.file_path}
</div> </div>
)} )}
{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 (
<div style={{ fontSize: 9, color: '#94a3b8', lineHeight: 1.4 }}>
<div>Native: {node.file_w_px}×{node.file_h_px} px</div>
<div>On paper: {imageWMm.toFixed(1)}×{imgH.toFixed(1)} mm
<span style={{ color: '#6b7280' }}> ({(paperFrac * 100).toFixed(0)}% of paper width)</span></div>
<div style={{ color: '#6b7280' }}>1 px = {(1 / ppmm).toFixed(3)} mm · {ppmm.toFixed(1)} px/mm</div>
</div>
)
})()}
</>)} </>)}
{node.kind === 'Kernel' && (<> {node.kind === 'Kernel' && (<>

View File

@@ -90,18 +90,11 @@ impl GcodeConfig {
} }
/// Convert fill results to G-code. /// Convert fill results to G-code.
/// Strokes are in mm relative to the image origin. We apply /// Strokes are already in actual-paper-mm — image-on-paper scaling
/// `(paper_offset_x_mm + offset_x_mm, paper_offset_y_mm + offset_y_mm)` /// happens upstream in process_pass (so fills regenerate when the
/// to position the image on the bed, and `img_w_mm / paper_w_mm` (when /// user resizes the image). All this does is apply paper + image
/// the user has scaled the image away from paper width) to scale the /// offsets to position the strokes on the bed.
/// 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 { 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 ox = cfg.paper_offset_x_mm + cfg.offset_x_mm;
let oy = cfg.paper_offset_y_mm + cfg.offset_y_mm; let oy = cfg.paper_offset_y_mm + cfg.offset_y_mm;
@@ -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!("; 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", 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)); 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", out.push_str(&format!("; Image: {:.1} mm wide @ ({:.1}, {:.1})\n",
cfg.img_w_mm, scale * _img_h as f32, ox, oy)); cfg.img_w_mm, ox, oy));
out.push_str("G21 ; mm mode\n"); out.push_str("G21 ; mm mode\n");
out.push_str("G90 ; absolute positioning\n"); out.push_str("G90 ; absolute positioning\n");
// $H must be on its own line: FluidNC's $-command parser does not strip // $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; } if stroke.len() < 2 { continue; }
let (sx, sy) = stroke[0]; 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_str(&cfg.pen_down);
out.push('\n'); out.push('\n');
out.push_str(&dwell_line); // wait for pen to physically drop out.push_str(&dwell_line); // wait for pen to physically drop
out.push_str(&format!("G1 F{}\n", cfg.feed_draw)); out.push_str(&format!("G1 F{}\n", cfg.feed_draw));
for &(px, py) in &stroke[1..] { 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)); out.push_str(&format!("G0 Z{:.3}\n", cfg.pen_up_z_mm));
@@ -231,13 +224,15 @@ mod tests {
} }
#[test] #[test]
fn gcode_image_scale_below_paper_shrinks_strokes() { fn gcode_passes_strokes_through_unchanged() {
// img_w_mm = half paper width → image is half-size on paper. // Image-on-paper scaling is now baked into stroke generation
// Stroke at 100mm in the image plots at 50mm on paper. // 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 { let cfg = GcodeConfig {
paper_w_mm: 200.0, paper_w_mm: 200.0,
paper_h_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_x_mm: 0.0,
offset_y_mm: 0.0, offset_y_mm: 0.0,
paper_offset_x_mm: 0.0, paper_offset_x_mm: 0.0,
@@ -246,10 +241,10 @@ mod tests {
}; };
let result = FillResult { let result = FillResult {
hull_id: 0, 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); 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] #[test]

View File

@@ -312,6 +312,14 @@ pub struct ProcessPassPayload {
pub pass_index: usize, pub pass_index: usize,
pub graph: DetectionGraphPayload, pub graph: DetectionGraphPayload,
pub dpi: Option<u32>, pub dpi: Option<u32>,
/// Paper dimensions in mm — what gcode targets, what previews
/// render against.
pub paper_w_mm: Option<f32>,
pub paper_h_mm: Option<f32>,
/// 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<f32>, pub img_w_mm: Option<f32>,
/// Paper height in mm. Only used when there's no source image and /// Paper height in mm. Only used when there's no source image and
/// the graph relies on Text nodes — we synthesize a blank canvas /// 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_hits = 0u32;
let mut cache_misses = 0u32; let mut cache_misses = 0u32;
// Paper dims (mm). The image's mm width on paper is `paper_w_mm_for_scale` // Paper dims drive preview canvas sizing. Image-on-paper width
// (= img_w_mm payload, defaulted to A4); per-source pixels-per-mm is // drives source pixel-to-mm conversion — when the user shrinks
// computed below from each source's native dims. // the image via the gcode-view corner handle, that smaller width
let paper_w_mm_for_scale = payload.img_w_mm.unwrap_or(210.0).max(1.0); // backpropagates here, so fills regenerate at actual paper-mm
let paper_h_mm_for_scale = payload.img_h_mm.unwrap_or(297.0).max(1.0); // 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 // Per-source pixels-per-mm: divides each source's native width by
// mm width on paper. Used by Hull → MmHull conversion and gradient // its image-on-paper width. So strokes land in actual-paper-mm
// fills. Each source has its own rate. // space without any later export-time scaling.
let source_px_per_mm: std::collections::HashMap<String, f32> = source_rgbs.iter() let source_px_per_mm: std::collections::HashMap<String, f32> = 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(); .collect();
// ── Per-node Source RGB lookup ──────────────────────────────────────────── // ── Per-node Source RGB lookup ────────────────────────────────────────────
@@ -2381,6 +2392,8 @@ mod blocking_tests {
ProcessPassPayload { ProcessPassPayload {
pass_index: 0, pass_index: 0,
dpi: None, dpi: None,
paper_w_mm: None,
paper_h_mm: None,
img_w_mm: None, img_w_mm: None,
img_h_mm: None, img_h_mm: None,
pen_tip_mm: None, pen_tip_mm: None,