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:
@@ -20,6 +20,7 @@ serde_json = "1"
|
|||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
env_logger = "0.11"
|
env_logger = "0.11"
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["multipart", "rustls-tls", "blocking"] }
|
||||||
|
|
||||||
[profile.dev]
|
[profile.dev]
|
||||||
opt-level = 2
|
opt-level = 2
|
||||||
|
|||||||
@@ -4,6 +4,12 @@
|
|||||||
# Frontend changes: edit code, click ↺ in the app (no restart needed).
|
# Frontend changes: edit code, click ↺ in the app (no restart needed).
|
||||||
cd "$(dirname "$0")"
|
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
|
# Start Vite if it isn't already running
|
||||||
if ! lsof -ti:1420 > /dev/null 2>&1; then
|
if ! lsof -ti:1420 > /dev/null 2>&1; then
|
||||||
echo "Starting Vite dev server..."
|
echo "Starting Vite dev server..."
|
||||||
|
|||||||
@@ -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 })) }
|
function setGcode(patch) { setGcodeConfig(c => ({ ...c, ...patch })) }
|
||||||
|
|
||||||
// ── Project save ───────────────────────────────────────────────────────────
|
// ── Project save ───────────────────────────────────────────────────────────
|
||||||
@@ -405,9 +417,22 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Placement */}
|
||||||
<div className="space-y-0.5">
|
<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}
|
<Slider label="Width mm" value={gcodeConfig.img_w_mm} min={10} max={2000} step={1}
|
||||||
onChange={v => setGcode({ img_w_mm: v })} />
|
onChange={v => setGcode({ img_w_mm: v })} />
|
||||||
<Slider label="Offset X" value={gcodeConfig.offset_x_mm} min={-500} max={500} step={1} unit="mm"
|
<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" />
|
onChange={v => setGcode({ feed_draw: v })} unit=" mm/m" />
|
||||||
<Slider label="Travel speed" value={gcodeConfig.feed_travel} min={100} max={10000} step={100}
|
<Slider label="Travel speed" value={gcodeConfig.feed_travel} min={100} max={10000} step={100}
|
||||||
onChange={v => setGcode({ feed_travel: v })} unit=" mm/m" />
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Export */}
|
{/* Export & upload */}
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<p className="text-neutral-500 text-xs font-semibold uppercase tracking-wider mb-1.5">Export</p>
|
<p className="text-neutral-500 text-xs font-semibold uppercase tracking-wider">Output</p>
|
||||||
<button onClick={exportAll} disabled={!passes.some(p => p.strokeCount > 0)}
|
<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">
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -101,11 +101,29 @@ export default function Viewport({ imageB64, strokes, imgSize, viewMode, gcodeCo
|
|||||||
const imgWmm = gcodeConfig.img_w_mm
|
const imgWmm = gcodeConfig.img_w_mm
|
||||||
if (!imgWmm || imgWmm <= 0) return
|
if (!imgWmm || imgWmm <= 0) return
|
||||||
const spm = (iw * scale) / imgWmm
|
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 px = ox - gcodeConfig.offset_x_mm * spm
|
||||||
const py = oy - gcodeConfig.offset_y_mm * spm
|
const py = oy - gcodeConfig.offset_y_mm * spm
|
||||||
const pw = gcodeConfig.paper_w_mm * spm
|
const pw = gcodeConfig.paper_w_mm * spm
|
||||||
const ph = gcodeConfig.paper_h_mm * spm
|
const ph = gcodeConfig.paper_h_mm * spm
|
||||||
ctx.save()
|
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.strokeStyle = 'rgba(255, 220, 50, 0.8)'
|
||||||
ctx.lineWidth = 2
|
ctx.lineWidth = 2
|
||||||
ctx.setLineDash([6, 4])
|
ctx.setLineDash([6, 4])
|
||||||
|
|||||||
@@ -38,6 +38,14 @@ export async function exportAllGcode(gcodeConfig, outDir) {
|
|||||||
return tracedInvoke('export_all_gcode', { 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) {
|
export async function exportDebugState(passConfigs) {
|
||||||
return tracedInvoke('export_debug_state', { passConfigs })
|
return tracedInvoke('export_debug_state', { passConfigs })
|
||||||
}
|
}
|
||||||
@@ -59,7 +67,7 @@ export async function pickSaveFile(defaultName) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function pickFolder() {
|
export async function pickFolder() {
|
||||||
return openDialog({ directory: true })
|
return openDialog({ directory: true, title: 'Choose export folder for G-code files' })
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Project file I/O ───────────────────────────────────────────────────────────
|
// ── Project file I/O ───────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const MINIMAL_GCODE = {
|
|||||||
paper_w_mm: 594, paper_h_mm: 841,
|
paper_w_mm: 594, paper_h_mm: 841,
|
||||||
img_w_mm: 540, offset_x_mm: 27, offset_y_mm: 27,
|
img_w_mm: 540, offset_x_mm: 27, offset_y_mm: 27,
|
||||||
feed_draw: 1000, feed_travel: 3000,
|
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 = {
|
const FULL_STATE = {
|
||||||
|
|||||||
@@ -136,14 +136,20 @@ export const PAPER_SIZES = [
|
|||||||
{ name: 'A2', w: 420, h: 594 },
|
{ name: 'A2', w: 420, h: 594 },
|
||||||
{ name: 'A3', w: 297, h: 420 },
|
{ name: 'A3', w: 297, h: 420 },
|
||||||
{ name: 'A4', w: 210, h: 297 },
|
{ 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() {
|
export function defaultGcodeConfig() {
|
||||||
return {
|
return {
|
||||||
paper_w_mm: 594, paper_h_mm: 841,
|
bed_w_mm: 220, bed_h_mm: 320,
|
||||||
img_w_mm: 540,
|
paper_offset_x_mm: 0, paper_offset_y_mm: 0,
|
||||||
offset_x_mm: 27, offset_y_mm: 27,
|
paper_w_mm: 210, paper_h_mm: 297,
|
||||||
feed_draw: 1000, feed_travel: 3000,
|
img_w_mm: 180,
|
||||||
pen_down: 'M3 S1000', pen_up: 'M5',
|
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',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
62
src/gcode.rs
62
src/gcode.rs
@@ -9,10 +9,19 @@ pub const PAPER_SIZES: &[(&str, f32, f32)] = &[
|
|||||||
("A2", 420.0, 594.0),
|
("A2", 420.0, 594.0),
|
||||||
("A3", 297.0, 420.0),
|
("A3", 297.0, 420.0),
|
||||||
("A4", 210.0, 297.0),
|
("A4", 210.0, 297.0),
|
||||||
|
("A5", 148.0, 210.0),
|
||||||
|
("Letter", 216.0, 279.0),
|
||||||
|
("Legal", 216.0, 356.0),
|
||||||
];
|
];
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct GcodeConfig {
|
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
|
// Paper
|
||||||
pub paper_w_mm: f32,
|
pub paper_w_mm: f32,
|
||||||
pub paper_h_mm: f32,
|
pub paper_h_mm: f32,
|
||||||
@@ -24,21 +33,25 @@ pub struct GcodeConfig {
|
|||||||
pub feed_draw: u32,
|
pub feed_draw: u32,
|
||||||
pub feed_travel: u32,
|
pub feed_travel: u32,
|
||||||
pub pen_down: String,
|
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 {
|
impl Default for GcodeConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
paper_w_mm: 594.0, // A1 portrait
|
bed_w_mm: 220.0,
|
||||||
paper_h_mm: 841.0,
|
bed_h_mm: 320.0,
|
||||||
img_w_mm: 540.0, // fills most of the width; fit/center adjusts this
|
paper_offset_x_mm: 0.0,
|
||||||
offset_x_mm: 27.0,
|
paper_offset_y_mm: 0.0,
|
||||||
offset_y_mm: 27.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_draw: 1000,
|
||||||
feed_travel: 3000,
|
feed_travel: 5000,
|
||||||
pen_down: "M3 S1000".to_string(),
|
pen_down: "G1 Z0.4 F1000".to_string(),
|
||||||
pen_up: "M5".to_string(),
|
pen_up_z_mm: 2.0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -75,22 +88,30 @@ impl GcodeConfig {
|
|||||||
|
|
||||||
/// Convert fill results to G-code.
|
/// Convert fill results to G-code.
|
||||||
/// Pixel coordinates are scaled by `img_w_mm / img_w` (uniform, aspect-correct),
|
/// 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 {
|
pub fn to_gcode(results: &[FillResult], img_w: u32, _img_h: u32, cfg: &GcodeConfig) -> String {
|
||||||
let scale = cfg.px_to_mm(img_w);
|
let scale = cfg.px_to_mm(img_w);
|
||||||
let ox = cfg.offset_x_mm;
|
let ox = cfg.paper_offset_x_mm + cfg.offset_x_mm;
|
||||||
let oy = cfg.offset_y_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);
|
||||||
|
|
||||||
out.push_str("; Generated by Trac3r\n");
|
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",
|
out.push_str(&format!("; Image: {:.1}x{:.1} mm @ ({:.1}, {:.1})\n",
|
||||||
cfg.img_w_mm, scale * _img_h as f32, ox, oy));
|
cfg.img_w_mm, scale * _img_h as f32, 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
|
||||||
|
// ';' 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(&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 result in results {
|
||||||
for stroke in &result.strokes {
|
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(&format!("G1 X{:.3} Y{:.3}\n", px * scale + ox, py * scale + oy));
|
||||||
}
|
}
|
||||||
|
|
||||||
out.push_str(&cfg.pen_up);
|
out.push_str(&format!("G0 Z{:.3}\n\n", cfg.pen_up_z_mm));
|
||||||
out.push_str("\n\n");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(&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.push_str("; End\n");
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
@@ -152,7 +173,12 @@ mod tests {
|
|||||||
];
|
];
|
||||||
let cfg = GcodeConfig::default();
|
let cfg = GcodeConfig::default();
|
||||||
let code = to_gcode(&results, 100, 100, &cfg);
|
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]
|
#[test]
|
||||||
|
|||||||
127
src/lib.rs
127
src/lib.rs
@@ -320,6 +320,10 @@ pub struct ProcessResult {
|
|||||||
|
|
||||||
#[derive(Deserialize, Clone, Debug)]
|
#[derive(Deserialize, Clone, Debug)]
|
||||||
pub struct GcodeConfigPayload {
|
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_w_mm: f32,
|
||||||
pub paper_h_mm: f32,
|
pub paper_h_mm: f32,
|
||||||
pub img_w_mm: f32,
|
pub img_w_mm: f32,
|
||||||
@@ -328,8 +332,11 @@ pub struct GcodeConfigPayload {
|
|||||||
pub feed_draw: u32,
|
pub feed_draw: u32,
|
||||||
pub feed_travel: u32,
|
pub feed_travel: u32,
|
||||||
pub pen_down: String,
|
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)]
|
#[derive(Serialize)]
|
||||||
@@ -992,23 +999,19 @@ async fn process_pass(payload: ProcessPassPayload, state: State<'_, Mutex<AppSta
|
|||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
/// Build (filename, gcode_string) for every pen with strokes, in order.
|
||||||
fn export_all_gcode(
|
/// Strokes are produced in the DPI-scaled pixel coordinate space, so the
|
||||||
gcode_config: GcodeConfigPayload,
|
/// gcode scale must use the per-pass scaled dimensions, not the original
|
||||||
out_dir: String,
|
/// image dimensions.
|
||||||
state: State<Mutex<AppState>>,
|
fn build_all_gcode(st: &AppState, cfg: &gcode::GcodeConfig) -> Vec<(String, String)> {
|
||||||
) -> Result<Vec<String>, String> {
|
let mut out = Vec::new();
|
||||||
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();
|
|
||||||
|
|
||||||
for ps in st.passes.iter() {
|
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();
|
let mut pens = ps.pen_results.clone();
|
||||||
pens.sort_by_key(|pr| pr.order);
|
pens.sort_by_key(|pr| pr.order);
|
||||||
for pr in &pens {
|
for pr in &pens {
|
||||||
if pr.fill.strokes.is_empty() { continue; }
|
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() {
|
let slug = if pr.label.is_empty() {
|
||||||
format!("{:02x}{:02x}{:02x}", pr.color[0], pr.color[1], pr.color[2])
|
format!("{:02x}{:02x}{:02x}", pr.color[0], pr.color[1], pr.color[2])
|
||||||
} else {
|
} else {
|
||||||
@@ -1017,17 +1020,103 @@ fn export_all_gcode(
|
|||||||
.collect()
|
.collect()
|
||||||
};
|
};
|
||||||
let fname = format!("{:02}_{slug}.gcode", pr.order + 1);
|
let fname = format!("{:02}_{slug}.gcode", pr.order + 1);
|
||||||
let path = std::path::Path::new(&out_dir).join(&fname);
|
out.push((fname, code));
|
||||||
std::fs::write(&path, &code).map_err(|e| e.to_string())?;
|
|
||||||
saved.push(path.to_string_lossy().into_owned());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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 {
|
fn to_gcode_config(p: &GcodeConfigPayload) -> gcode::GcodeConfig {
|
||||||
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_w_mm: p.paper_w_mm,
|
||||||
paper_h_mm: p.paper_h_mm,
|
paper_h_mm: p.paper_h_mm,
|
||||||
img_w_mm: p.img_w_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_draw: p.feed_draw,
|
||||||
feed_travel: p.feed_travel,
|
feed_travel: p.feed_travel,
|
||||||
pen_down: p.pen_down.clone(),
|
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_gcode_viz,
|
||||||
get_pass_viz,
|
get_pass_viz,
|
||||||
export_all_gcode,
|
export_all_gcode,
|
||||||
|
get_all_gcode,
|
||||||
|
upload_to_printer,
|
||||||
export_debug_state,
|
export_debug_state,
|
||||||
write_project_file,
|
write_project_file,
|
||||||
read_project_file,
|
read_project_file,
|
||||||
|
|||||||
Reference in New Issue
Block a user