feat: FluidNC upload, bed/paper layout, pen-plotter gcode hardening

- 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) <noreply@anthropic.com>
This commit is contained in:
Mitchell Hansen
2026-04-28 13:17:22 -07:00
parent 889ff386c5
commit 14dc804112
9 changed files with 270 additions and 75 deletions

View File

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

View File

@@ -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..."

View File

@@ -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() {
</div>
</div>
{/* Paper on bed */}
<div className="space-y-0.5">
<p className="text-neutral-500 text-xs font-semibold uppercase tracking-wider mb-1.5">
Paper on bed ({gcodeConfig.bed_w_mm}×{gcodeConfig.bed_h_mm}mm)
</p>
<Slider label="Paper X" value={gcodeConfig.paper_offset_x_mm ?? 0}
min={0} max={Math.max(0, (gcodeConfig.bed_w_mm ?? 220) - gcodeConfig.paper_w_mm)} step={1} unit="mm"
onChange={v => setGcode({ paper_offset_x_mm: v })} />
<Slider label="Paper Y" value={gcodeConfig.paper_offset_y_mm ?? 0}
min={0} max={Math.max(0, (gcodeConfig.bed_h_mm ?? 320) - gcodeConfig.paper_h_mm)} step={1} unit="mm"
onChange={v => setGcode({ paper_offset_y_mm: v })} />
</div>
{/* Placement */}
<div className="space-y-0.5">
<p className="text-neutral-500 text-xs font-semibold uppercase tracking-wider mb-1.5">Placement</p>
<p className="text-neutral-500 text-xs font-semibold uppercase tracking-wider mb-1.5">Image on paper</p>
<Slider label="Width mm" value={gcodeConfig.img_w_mm} min={10} max={2000} step={1}
onChange={v => setGcode({ img_w_mm: v })} />
<Slider label="Offset X" value={gcodeConfig.offset_x_mm} min={-500} max={500} step={1} unit="mm"
@@ -426,14 +451,28 @@ export default function App() {
onChange={v => setGcode({ feed_draw: v })} unit=" mm/m" />
<Slider label="Travel speed" value={gcodeConfig.feed_travel} min={100} max={10000} step={100}
onChange={v => setGcode({ feed_travel: v })} unit=" mm/m" />
<Slider label="Pen lift height" value={gcodeConfig.pen_up_z_mm ?? 2} min={0.5} max={5} step={0.1} unit="mm"
onChange={v => setGcode({ pen_up_z_mm: v })} />
</div>
{/* Export */}
<div>
<p className="text-neutral-500 text-xs font-semibold uppercase tracking-wider mb-1.5">Export</p>
{/* Export & upload */}
<div className="space-y-2">
<p className="text-neutral-500 text-xs font-semibold uppercase tracking-wider">Output</p>
<button onClick={exportAll} disabled={!passes.some(p => p.strokeCount > 0)}
className="w-full px-3 py-1.5 rounded bg-indigo-700 hover:bg-indigo-600 text-xs text-white disabled:opacity-40 transition-colors">
Export G-code
Export G-code to folder
</button>
<div className="space-y-1">
<label className="text-neutral-400 text-xs">Printer URL</label>
<input type="text" value={gcodeConfig.printer_url ?? ''}
onChange={e => 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" />
</div>
<button onClick={uploadToPrinter}
disabled={!passes.some(p => p.strokeCount > 0) || !gcodeConfig.printer_url}
className="w-full px-3 py-1.5 rounded bg-emerald-700 hover:bg-emerald-600 text-xs text-white disabled:opacity-40 transition-colors">
Send to printer (upload only)
</button>
</div>

View File

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

View File

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

View File

@@ -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 = {

View File

@@ -136,14 +136,20 @@ export const PAPER_SIZES = [
{ 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',
}
}

View File

@@ -9,10 +9,19 @@ pub const PAPER_SIZES: &[(&str, f32, f32)] = &[
("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,
@@ -24,21 +33,25 @@ pub struct GcodeConfig {
pub feed_draw: u32,
pub feed_travel: u32,
pub pen_down: String,
pub pen_up: 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,
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: 3000,
pen_down: "M3 S1000".to_string(),
pen_up: "M5".to_string(),
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]

View File

@@ -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<AppSta
Ok(result)
}
#[tauri::command]
fn export_all_gcode(
gcode_config: GcodeConfigPayload,
out_dir: String,
state: State<Mutex<AppState>>,
) -> Result<Vec<String>, 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,17 +1020,103 @@ 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<Mutex<AppState>>,
) -> Result<Vec<String>, 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<Mutex<AppState>>,
) -> Result<Vec<GcodeFile>, 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<Mutex<AppState>>,
) -> Result<Vec<String>, 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 {
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,
@@ -1036,7 +1125,7 @@ fn to_gcode_config(p: &GcodeConfigPayload) -> gcode::GcodeConfig {
feed_draw: p.feed_draw,
feed_travel: p.feed_travel,
pen_down: p.pen_down.clone(),
pen_up: p.pen_up.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,