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 })
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' ? (
<PrinterPanel

View File

@@ -101,7 +101,8 @@ function isCompatible(fromKind, toKind, existingEdges, fromId, toId, allNodes =
}
// ── 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 worldRef = useRef(null)
const [wire, setWire] = useState(null) // { fromId, fromX, fromY, mouseX, mouseY }
@@ -536,11 +537,15 @@ export default function NodeGraph({ graph, onChange, nodePreviews, nodeWidth = 2
try {
const path = await tauri.pickImageFile()
if (!path) return
// Load into the backend cache; result.preview_b64 is
// surfaced via process_pass's nodePreviews on the next
// run, so we only need the success signal here.
await tauri.loadImage(path)
updateNode(node.id, { file_path: path })
// Load into the backend cache; the returned ImageInfo
// gives us native dims so the card can display the
// on-paper scale derived from gcode-view img_w_mm.
const info = await tauri.loadImage(path)
updateNode(node.id, {
file_path: path,
file_w_px: info.width,
file_h_px: info.height,
})
} catch (e) {
console.error('[source] load failed:', e)
}
@@ -557,6 +562,21 @@ export default function NodeGraph({ graph, onChange, nodePreviews, nodeWidth = 2
{node.file_path}
</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' && (<>

View File

@@ -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]

View File

@@ -312,6 +312,14 @@ pub struct ProcessPassPayload {
pub pass_index: usize,
pub graph: DetectionGraphPayload,
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>,
/// 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<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();
// ── 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,