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:
@@ -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
|
||||
|
||||
@@ -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' && (<>
|
||||
|
||||
41
src/gcode.rs
41
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]
|
||||
|
||||
31
src/lib.rs
31
src/lib.rs
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user