From 14dc80411298ab53ae3dd08fa531ef86b96500a5 Mon Sep 17 00:00:00 2001 From: Mitchell Hansen Date: Tue, 28 Apr 2026 13:17:22 -0700 Subject: [PATCH] feat: FluidNC upload, bed/paper layout, pen-plotter gcode hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Send-to-printer button POSTs gcode to FluidNC's /upload from Rust (avoids CORS) - Bed dimensions + paper-on-bed offset; Viewport draws nested bed/paper outlines - Pen lift height as a slider (default 2mm); replaces freeform pen_up string - Generated gcode now auto-homes ($H on its own line — FluidNC \$ parser doesn't strip ; comments) and adds safety pen-ups at start/end - Fix: export_all_gcode was scaling stroke coords by original image dims instead of DPI-scaled per-pass dims, blowing up output coords - Defaults: A4 paper, feed_travel 5000, Letter/Legal/A5 added to picker Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.toml | 1 + run-dev.sh | 6 + src-frontend/src/App.jsx | 49 +++++++- src-frontend/src/components/Viewport.jsx | 18 +++ src-frontend/src/hooks/useTauri.js | 10 +- src-frontend/src/project.test.js | 2 +- src-frontend/src/store.js | 26 +++-- src/gcode.rs | 90 +++++++++----- src/lib.rs | 143 ++++++++++++++++++----- 9 files changed, 270 insertions(+), 75 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 2940cdc0..27d8360a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ serde_json = "1" base64 = "0.22" log = "0.4" env_logger = "0.11" +reqwest = { version = "0.12", default-features = false, features = ["multipart", "rustls-tls", "blocking"] } [profile.dev] opt-level = 2 diff --git a/run-dev.sh b/run-dev.sh index 384177a3..9b48ba77 100755 --- a/run-dev.sh +++ b/run-dev.sh @@ -4,6 +4,12 @@ # Frontend changes: edit code, click ↺ in the app (no restart needed). cd "$(dirname "$0")" +# Install frontend deps if missing +if [ ! -d src-frontend/node_modules ]; then + echo "Installing frontend dependencies..." + npm --prefix src-frontend install +fi + # Start Vite if it isn't already running if ! lsof -ti:1420 > /dev/null 2>&1; then echo "Starting Vite dev server..." diff --git a/src-frontend/src/App.jsx b/src-frontend/src/App.jsx index cdae5eaf..44ad5780 100644 --- a/src-frontend/src/App.jsx +++ b/src-frontend/src/App.jsx @@ -216,6 +216,18 @@ export default function App() { } } + async function uploadToPrinter() { + const url = (gcodeConfig.printer_url || '').trim() + if (!url) { setGlobalStatus('Set Printer URL in the G-code panel first'); return } + try { + setGlobalStatus(`Uploading to ${url}…`) + const names = await tauri.uploadToPrinter(gcodeConfig, url) + setGlobalStatus(`Uploaded ${names.length} file(s) to ${url} — open the WebUI to run`) + } catch (e) { + setGlobalStatus(`Upload error: ${e.message ?? e}`) + } + } + function setGcode(patch) { setGcodeConfig(c => ({ ...c, ...patch })) } // ── Project save ─────────────────────────────────────────────────────────── @@ -405,9 +417,22 @@ export default function App() { + {/* Paper on bed */} +
+

+ Paper on bed ({gcodeConfig.bed_w_mm}×{gcodeConfig.bed_h_mm}mm) +

+ setGcode({ paper_offset_x_mm: v })} /> + setGcode({ paper_offset_y_mm: v })} /> +
+ {/* Placement */}
-

Placement

+

Image on paper

setGcode({ img_w_mm: v })} /> setGcode({ feed_draw: v })} unit=" mm/m" /> setGcode({ feed_travel: v })} unit=" mm/m" /> + setGcode({ pen_up_z_mm: v })} />
- {/* Export */} -
-

Export

+ {/* Export & upload */} +
+

Output

+
+ + setGcode({ printer_url: e.target.value })} + placeholder="http://fluidnc.local" + className="w-full px-2 py-1 rounded bg-neutral-800 border border-neutral-700 text-xs text-neutral-200" /> +
+
diff --git a/src-frontend/src/components/Viewport.jsx b/src-frontend/src/components/Viewport.jsx index a95e00f3..14f6b191 100644 --- a/src-frontend/src/components/Viewport.jsx +++ b/src-frontend/src/components/Viewport.jsx @@ -101,11 +101,29 @@ export default function Viewport({ imageB64, strokes, imgSize, viewMode, gcodeCo const imgWmm = gcodeConfig.img_w_mm if (!imgWmm || imgWmm <= 0) return const spm = (iw * scale) / imgWmm + const paperOffX = gcodeConfig.paper_offset_x_mm ?? 0 + const paperOffY = gcodeConfig.paper_offset_y_mm ?? 0 const px = ox - gcodeConfig.offset_x_mm * spm const py = oy - gcodeConfig.offset_y_mm * spm const pw = gcodeConfig.paper_w_mm * spm const ph = gcodeConfig.paper_h_mm * spm ctx.save() + // Bed outline (outermost, cyan) + if (gcodeConfig.bed_w_mm && gcodeConfig.bed_h_mm) { + const bx = px - paperOffX * spm + const by = py - paperOffY * spm + const bw = gcodeConfig.bed_w_mm * spm + const bh = gcodeConfig.bed_h_mm * spm + ctx.strokeStyle = 'rgba(80, 200, 220, 0.8)' + ctx.lineWidth = 2 + ctx.setLineDash([10, 6]) + ctx.strokeRect(bx, by, bw, bh) + ctx.setLineDash([]) + ctx.fillStyle = 'rgba(80, 200, 220, 0.7)' + ctx.font = `${Math.max(10, spm * 5)}px sans-serif` + ctx.fillText(`Bed ${gcodeConfig.bed_w_mm}×${gcodeConfig.bed_h_mm}mm`, bx + 4, by - 4) + } + // Paper outline (yellow dashed) ctx.strokeStyle = 'rgba(255, 220, 50, 0.8)' ctx.lineWidth = 2 ctx.setLineDash([6, 4]) diff --git a/src-frontend/src/hooks/useTauri.js b/src-frontend/src/hooks/useTauri.js index 6c239f4f..08763a21 100644 --- a/src-frontend/src/hooks/useTauri.js +++ b/src-frontend/src/hooks/useTauri.js @@ -38,6 +38,14 @@ export async function exportAllGcode(gcodeConfig, outDir) { return tracedInvoke('export_all_gcode', { gcodeConfig, outDir }) } +export async function getAllGcode(gcodeConfig) { + return tracedInvoke('get_all_gcode', { gcodeConfig }) +} + +export async function uploadToPrinter(gcodeConfig, printerUrl) { + return tracedInvoke('upload_to_printer', { gcodeConfig, printerUrl }) +} + export async function exportDebugState(passConfigs) { return tracedInvoke('export_debug_state', { passConfigs }) } @@ -59,7 +67,7 @@ export async function pickSaveFile(defaultName) { } export async function pickFolder() { - return openDialog({ directory: true }) + return openDialog({ directory: true, title: 'Choose export folder for G-code files' }) } // ── Project file I/O ─────────────────────────────────────────────────────────── diff --git a/src-frontend/src/project.test.js b/src-frontend/src/project.test.js index d6c501b9..77ed73d0 100644 --- a/src-frontend/src/project.test.js +++ b/src-frontend/src/project.test.js @@ -24,7 +24,7 @@ const MINIMAL_GCODE = { paper_w_mm: 594, paper_h_mm: 841, img_w_mm: 540, offset_x_mm: 27, offset_y_mm: 27, feed_draw: 1000, feed_travel: 3000, - pen_down: 'M3 S1000', pen_up: 'M5', + pen_down: 'M3 S1000', pen_up_z_mm: 2, } const FULL_STATE = { diff --git a/src-frontend/src/store.js b/src-frontend/src/store.js index b10ef5dd..987928aa 100644 --- a/src-frontend/src/store.js +++ b/src-frontend/src/store.js @@ -131,19 +131,25 @@ export function defaultPass(index) { } export const PAPER_SIZES = [ - { name: 'A0', w: 841, h: 1189 }, - { name: 'A1', w: 594, h: 841 }, - { name: 'A2', w: 420, h: 594 }, - { name: 'A3', w: 297, h: 420 }, - { name: 'A4', w: 210, h: 297 }, + { name: 'A0', w: 841, h: 1189 }, + { name: 'A1', w: 594, h: 841 }, + { name: 'A2', w: 420, h: 594 }, + { name: 'A3', w: 297, h: 420 }, + { name: 'A4', w: 210, h: 297 }, + { name: 'A5', w: 148, h: 210 }, + { name: 'Letter', w: 216, h: 279 }, + { name: 'Legal', w: 216, h: 356 }, ] export function defaultGcodeConfig() { return { - paper_w_mm: 594, paper_h_mm: 841, - img_w_mm: 540, - offset_x_mm: 27, offset_y_mm: 27, - feed_draw: 1000, feed_travel: 3000, - pen_down: 'M3 S1000', pen_up: 'M5', + bed_w_mm: 220, bed_h_mm: 320, + paper_offset_x_mm: 0, paper_offset_y_mm: 0, + paper_w_mm: 210, paper_h_mm: 297, + img_w_mm: 180, + offset_x_mm: 15, offset_y_mm: 15, + feed_draw: 1000, feed_travel: 5000, + pen_down: 'G1 Z0.4 F1000', pen_up_z_mm: 2, + printer_url: 'http://fluidnc.local', } } diff --git a/src/gcode.rs b/src/gcode.rs index 3329d54d..9d52ff6f 100644 --- a/src/gcode.rs +++ b/src/gcode.rs @@ -4,41 +4,54 @@ use crate::fill::FillResult; // Standard paper sizes in portrait orientation (width × height, mm). pub const PAPER_SIZES: &[(&str, f32, f32)] = &[ - ("A0", 841.0, 1189.0), - ("A1", 594.0, 841.0), - ("A2", 420.0, 594.0), - ("A3", 297.0, 420.0), - ("A4", 210.0, 297.0), + ("A0", 841.0, 1189.0), + ("A1", 594.0, 841.0), + ("A2", 420.0, 594.0), + ("A3", 297.0, 420.0), + ("A4", 210.0, 297.0), + ("A5", 148.0, 210.0), + ("Letter", 216.0, 279.0), + ("Legal", 216.0, 356.0), ]; #[derive(Debug, Clone)] pub struct GcodeConfig { + // Bed (machine work area) + pub bed_w_mm: f32, + pub bed_h_mm: f32, + // Paper placement on bed + pub paper_offset_x_mm: f32, // paper left edge from bed left (mm) + pub paper_offset_y_mm: f32, // paper top edge from bed top (mm) // Paper - pub paper_w_mm: f32, - pub paper_h_mm: f32, + pub paper_w_mm: f32, + pub paper_h_mm: f32, // Image placement on paper - pub img_w_mm: f32, // image width in mm; height derived from pixel aspect ratio - pub offset_x_mm: f32, // image left edge from paper left (mm) - pub offset_y_mm: f32, // image top edge from paper top (mm) + pub img_w_mm: f32, // image width in mm; height derived from pixel aspect ratio + pub offset_x_mm: f32, // image left edge from paper left (mm) + pub offset_y_mm: f32, // image top edge from paper top (mm) // Machine - pub feed_draw: u32, - pub feed_travel: u32, - pub pen_down: String, - pub pen_up: String, + pub feed_draw: u32, + pub feed_travel: u32, + pub pen_down: String, + pub pen_up_z_mm: f32, // Z height (mm) for pen-up rapid moves } impl Default for GcodeConfig { fn default() -> Self { Self { - paper_w_mm: 594.0, // A1 portrait - paper_h_mm: 841.0, - img_w_mm: 540.0, // fills most of the width; fit/center adjusts this - offset_x_mm: 27.0, - offset_y_mm: 27.0, - feed_draw: 1000, - feed_travel: 3000, - pen_down: "M3 S1000".to_string(), - pen_up: "M5".to_string(), + bed_w_mm: 220.0, + bed_h_mm: 320.0, + paper_offset_x_mm: 0.0, + paper_offset_y_mm: 0.0, + paper_w_mm: 210.0, // A4 portrait — fits inside 220×320 bed + paper_h_mm: 297.0, + img_w_mm: 180.0, // leaves a margin + offset_x_mm: 15.0, + offset_y_mm: 15.0, + feed_draw: 1000, + feed_travel: 5000, + pen_down: "G1 Z0.4 F1000".to_string(), + pen_up_z_mm: 2.0, } } } @@ -75,22 +88,30 @@ impl GcodeConfig { /// Convert fill results to G-code. /// Pixel coordinates are scaled by `img_w_mm / img_w` (uniform, aspect-correct), -/// then offset by `(offset_x_mm, offset_y_mm)`. +/// then offset by `(paper_offset_x_mm + offset_x_mm, paper_offset_y_mm + offset_y_mm)`. pub fn to_gcode(results: &[FillResult], img_w: u32, _img_h: u32, cfg: &GcodeConfig) -> String { let scale = cfg.px_to_mm(img_w); - let ox = cfg.offset_x_mm; - let oy = 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); out.push_str("; Generated by Trac3r\n"); - out.push_str(&format!("; Paper: {:.0}x{:.0} mm\n", cfg.paper_w_mm, cfg.paper_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", + 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("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 + // ';' comments (only the gcode path does), so any inline comment becomes + // part of the command name lookup and fails with error 3. + out.push_str("; home X and Y\n"); + out.push_str("$H\n"); + out.push_str(&format!("G0 Z{:.3} ; pen up before any moves\n", cfg.pen_up_z_mm)); out.push_str(&format!("G0 F{} ; travel feed\n", cfg.feed_travel)); - out.push_str("G0 X0 Y0 ; home\n\n"); + out.push_str("G0 X0 Y0 ; go to origin\n\n"); for result in results { for stroke in &result.strokes { @@ -106,13 +127,13 @@ pub fn to_gcode(results: &[FillResult], img_w: u32, _img_h: u32, cfg: &GcodeConf out.push_str(&format!("G1 X{:.3} Y{:.3}\n", px * scale + ox, py * scale + oy)); } - out.push_str(&cfg.pen_up); - out.push_str("\n\n"); + out.push_str(&format!("G0 Z{:.3}\n\n", cfg.pen_up_z_mm)); } } + out.push_str(&format!("G0 Z{:.3} ; pen up before final return\n", cfg.pen_up_z_mm)); out.push_str(&format!("G0 F{}\n", cfg.feed_travel)); - out.push_str("G0 X0 Y0 ; return home\n"); + out.push_str("G0 X0 Y0 ; return to origin\n"); out.push_str("; End\n"); out } @@ -152,7 +173,12 @@ mod tests { ]; let cfg = GcodeConfig::default(); let code = to_gcode(&results, 100, 100, &cfg); - assert_eq!(code.matches(&cfg.pen_down).count(), code.matches(&cfg.pen_up).count()); + // Two extra pen_ups: one at start (before any move) and one at end (before return). + let pen_up_marker = format!("G0 Z{:.3}", cfg.pen_up_z_mm); + assert_eq!( + code.matches(&pen_up_marker).count(), + code.matches(&cfg.pen_down).count() + 2 + ); } #[test] diff --git a/src/lib.rs b/src/lib.rs index 6178056f..1cde81b0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -320,6 +320,10 @@ pub struct ProcessResult { #[derive(Deserialize, Clone, Debug)] pub struct GcodeConfigPayload { + #[serde(default = "default_bed_w")] pub bed_w_mm: f32, + #[serde(default = "default_bed_h")] pub bed_h_mm: f32, + #[serde(default)] pub paper_offset_x_mm: f32, + #[serde(default)] pub paper_offset_y_mm: f32, pub paper_w_mm: f32, pub paper_h_mm: f32, pub img_w_mm: f32, @@ -328,8 +332,11 @@ pub struct GcodeConfigPayload { pub feed_draw: u32, pub feed_travel: u32, pub pen_down: String, - pub pen_up: String, + #[serde(default = "default_pen_up_z")] pub pen_up_z_mm: f32, } +fn default_bed_w() -> f32 { 220.0 } +fn default_bed_h() -> f32 { 320.0 } +fn default_pen_up_z() -> f32 { 2.0 } #[derive(Serialize)] @@ -992,23 +999,19 @@ async fn process_pass(payload: ProcessPassPayload, state: State<'_, Mutex>, -) -> Result, String> { - let st = state.lock().unwrap(); - let (iw, ih) = st.image_rgb.as_ref().map(|r| r.dimensions()).unwrap_or((512, 512)); - let cfg = to_gcode_config(&gcode_config); - let mut saved = Vec::new(); - +/// Build (filename, gcode_string) for every pen with strokes, in order. +/// Strokes are produced in the DPI-scaled pixel coordinate space, so the +/// gcode scale must use the per-pass scaled dimensions, not the original +/// image dimensions. +fn build_all_gcode(st: &AppState, cfg: &gcode::GcodeConfig) -> Vec<(String, String)> { + let mut out = Vec::new(); for ps in st.passes.iter() { + let (iw, ih) = (ps.img_w.max(1), ps.img_h.max(1)); let mut pens = ps.pen_results.clone(); pens.sort_by_key(|pr| pr.order); for pr in &pens { if pr.fill.strokes.is_empty() { continue; } - let code = gcode::to_gcode(&[pr.fill.clone()], iw, ih, &cfg); + let code = gcode::to_gcode(&[pr.fill.clone()], iw, ih, cfg); let slug = if pr.label.is_empty() { format!("{:02x}{:02x}{:02x}", pr.color[0], pr.color[1], pr.color[2]) } else { @@ -1017,26 +1020,112 @@ fn export_all_gcode( .collect() }; let fname = format!("{:02}_{slug}.gcode", pr.order + 1); - let path = std::path::Path::new(&out_dir).join(&fname); - std::fs::write(&path, &code).map_err(|e| e.to_string())?; - saved.push(path.to_string_lossy().into_owned()); + out.push((fname, code)); } } + out +} - if saved.is_empty() { Err("No pens with fill data to export".into()) } else { Ok(saved) } +#[tauri::command] +fn export_all_gcode( + gcode_config: GcodeConfigPayload, + out_dir: String, + state: State>, +) -> Result, String> { + let st = state.lock().unwrap(); + let cfg = to_gcode_config(&gcode_config); + let files = build_all_gcode(&st, &cfg); + if files.is_empty() { return Err("No pens with fill data to export".into()); } + let mut saved = Vec::with_capacity(files.len()); + for (fname, code) in &files { + let path = std::path::Path::new(&out_dir).join(fname); + std::fs::write(&path, code).map_err(|e| e.to_string())?; + saved.push(path.to_string_lossy().into_owned()); + } + Ok(saved) +} + +#[derive(Serialize)] +pub struct GcodeFile { + pub name: String, + pub code: String, +} + +/// Returns the same files `export_all_gcode` would write, but in-memory. +/// Used by the "Send to printer" flow to upload directly without touching disk. +#[tauri::command] +fn get_all_gcode( + gcode_config: GcodeConfigPayload, + state: State>, +) -> Result, String> { + let st = state.lock().unwrap(); + let cfg = to_gcode_config(&gcode_config); + let files = build_all_gcode(&st, &cfg); + if files.is_empty() { return Err("No pens with fill data".into()); } + Ok(files.into_iter().map(|(name, code)| GcodeFile { name, code }).collect()) +} + +/// Upload all generated gcode files to a FluidNC controller via its WebUI's +/// `/upload` endpoint. We do this from Rust (not the WebView's fetch) because +/// FluidNC doesn't send CORS headers, which would block a browser POST. +#[tauri::command] +fn upload_to_printer( + gcode_config: GcodeConfigPayload, + printer_url: String, + state: State>, +) -> Result, String> { + let files = { + let st = state.lock().unwrap(); + let cfg = to_gcode_config(&gcode_config); + build_all_gcode(&st, &cfg) + }; + if files.is_empty() { return Err("No pens with fill data".into()); } + + let base = printer_url.trim().trim_end_matches('/').to_string(); + if base.is_empty() { return Err("Printer URL is empty".into()); } + let endpoint = format!("{}/upload", base); + + let client = reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|e| e.to_string())?; + + let mut uploaded = Vec::with_capacity(files.len()); + for (fname, code) in files { + let part = reqwest::blocking::multipart::Part::bytes(code.into_bytes()) + .file_name(fname.clone()) + .mime_str("text/plain") + .map_err(|e| e.to_string())?; + let form = reqwest::blocking::multipart::Form::new() + .text("path", "/") + .part("myfile", part); + let resp = client.post(&endpoint) + .multipart(form) + .send() + .map_err(|e| format!("{}: {}", fname, e))?; + if !resp.status().is_success() { + return Err(format!("{}: HTTP {}", fname, resp.status())); + } + uploaded.push(fname); + } + Ok(uploaded) } fn to_gcode_config(p: &GcodeConfigPayload) -> gcode::GcodeConfig { gcode::GcodeConfig { - paper_w_mm: p.paper_w_mm, - paper_h_mm: p.paper_h_mm, - img_w_mm: p.img_w_mm, - offset_x_mm: p.offset_x_mm, - offset_y_mm: p.offset_y_mm, - feed_draw: p.feed_draw, - feed_travel: p.feed_travel, - pen_down: p.pen_down.clone(), - pen_up: p.pen_up.clone(), + bed_w_mm: p.bed_w_mm, + bed_h_mm: p.bed_h_mm, + paper_offset_x_mm: p.paper_offset_x_mm, + paper_offset_y_mm: p.paper_offset_y_mm, + paper_w_mm: p.paper_w_mm, + paper_h_mm: p.paper_h_mm, + img_w_mm: p.img_w_mm, + offset_x_mm: p.offset_x_mm, + offset_y_mm: p.offset_y_mm, + feed_draw: p.feed_draw, + feed_travel: p.feed_travel, + pen_down: p.pen_down.clone(), + pen_up_z_mm: p.pen_up_z_mm, } } @@ -1925,6 +2014,8 @@ pub fn run() { get_gcode_viz, get_pass_viz, export_all_gcode, + get_all_gcode, + upload_to_printer, export_debug_state, write_project_file, read_project_file,