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 })
|
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
|
||||||
|
|||||||
@@ -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' && (<>
|
||||||
|
|||||||
41
src/gcode.rs
41
src/gcode.rs
@@ -90,20 +90,13 @@ 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
|
let ox = cfg.paper_offset_x_mm + cfg.offset_x_mm;
|
||||||
// strokes plot at their native mm dimensions.
|
let oy = cfg.paper_offset_y_mm + cfg.offset_y_mm;
|
||||||
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 mut out = String::with_capacity(4096);
|
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!("; 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]
|
||||||
|
|||||||
31
src/lib.rs
31
src/lib.rs
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user