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 */}
+
-
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,