From ea2821138013414c676224f1f20417b5c1b02a0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iwo=20Strzebo=C5=84ski?= Date: Fri, 28 Aug 2026 18:24:51 +0000 Subject: [PATCH] Simplify rust-core: single-format gcode header parser, fewer error variants parse_header now only handles '; key = value' lines instead of three different slicer comment formats at once. Error enum trimmed from 4 variants to 3 (Io, Parse, Other). Verified with cargo test (3/3 passing). --- packages/rust-core/src/error.rs | 9 +- packages/rust-core/src/gcode.rs | 152 ++++++-------------------------- 2 files changed, 30 insertions(+), 131 deletions(-) diff --git a/packages/rust-core/src/error.rs b/packages/rust-core/src/error.rs index f6ac4ad..5b9775d 100644 --- a/packages/rust-core/src/error.rs +++ b/packages/rust-core/src/error.rs @@ -1,15 +1,14 @@ use thiserror::Error; +/// One error type for everything in this crate. `Parse` covers G-code +/// parsing problems; `Other` is an escape hatch for anything else via `?`. #[derive(Debug, Error)] pub enum Error { #[error("io error: {0}")] Io(#[from] std::io::Error), - #[error("invalid g-code checksum on line: {line}")] - GcodeChecksum { line: String }, - - #[error("malformed g-code header: {reason}")] - GcodeHeader { reason: String }, + #[error("{0}")] + Parse(String), #[error("{0}")] Other(#[from] anyhow::Error), diff --git a/packages/rust-core/src/gcode.rs b/packages/rust-core/src/gcode.rs index b5a6ea3..fb28341 100644 --- a/packages/rust-core/src/gcode.rs +++ b/packages/rust-core/src/gcode.rs @@ -1,9 +1,9 @@ use crate::error::{Error, Result}; use std::collections::BTreeMap; -/// XORs every byte in `data` together, per the standard Marlin/RepRap -/// G-code checksum scheme (checksum covers everything before the `*`, -/// including any leading `N ` line number). +/// XORs every byte together — the standard Marlin/RepRap G-code checksum. +/// Covers everything before the `*`, including a leading `N ` line number +/// if present. pub fn compute_checksum(data: &[u8]) -> u8 { data.iter().fold(0u8, |acc, &b| acc ^ b) } @@ -20,14 +20,11 @@ pub fn split_checksum(line: &str) -> (&str, Option) { } /// Verifies a checksummed G-code line (e.g. `N12 G1 X10 Y20*43`). -/// Returns `Err` if the line carries no `*NN` suffix to verify against. pub fn verify_line_checksum(line: &str) -> Result { let (content, checksum) = split_checksum(line); match checksum { Some(expected) => Ok(compute_checksum(content.as_bytes()) == expected), - None => Err(Error::GcodeChecksum { - line: line.to_string(), - }), + None => Err(Error::Parse(format!("line has no checksum: {line}"))), } } @@ -36,110 +33,28 @@ pub fn append_checksum(line: &str) -> String { format!("{line}*{}", compute_checksum(line.as_bytes())) } -#[derive(Debug, Default, Clone, PartialEq, Eq)] -pub struct GcodeHeader { - pub slicer: Option, - pub filament_type: Option, - pub nozzle_diameter: Option, - pub estimated_print_time_secs: Option, - /// Every `; key = value` comment field found, keyed by `key`. - pub raw_fields: BTreeMap, -} +/// Every `; key = value` comment line found in a G-code file's header, +/// keyed by `key`. Slicers (PrusaSlicer, BambuStudio, ...) write their +/// settings this way at the top of the file. +/// +/// This only handles that one format. Real slicer output has more variety +/// (Cura's `;KEY:value` style, free-form "generated by ..." comments) — +/// add support for those once this simple version makes sense. +pub fn parse_header(gcode: &str) -> BTreeMap { + let mut fields = BTreeMap::new(); -/// Parses slicer metadata out of a G-code file's `;`-prefixed comment lines. -/// Handles the PrusaSlicer/SuperSlicer/BambuStudio `; key = value` config -/// block as well as Cura's `;KEY:value` header and both slicers' free-form -/// `generated by` / estimated-time comments. -pub fn parse_header(gcode: &str) -> GcodeHeader { - let mut header = GcodeHeader::default(); + for line in gcode.lines() { + let Some(comment) = line.trim().strip_prefix(';') else { continue }; + let Some((key, value)) = comment.split_once('=') else { continue }; - for raw_line in gcode.lines() { - let line = raw_line.trim(); - let comment = match line.strip_prefix(';') { - Some(rest) => rest.trim(), - None => continue, - }; - if comment.is_empty() { - continue; - } - - if header.slicer.is_none() { - if let Some(rest) = comment - .strip_prefix("generated by ") - .or_else(|| comment.strip_prefix("Generated by ")) - { - let name = rest.split(" on ").next().unwrap_or(rest).trim(); - header.slicer = Some(name.to_string()); - continue; - } - if let Some(rest) = comment - .strip_prefix("Generated with ") - .or_else(|| comment.strip_prefix("generated with ")) - { - header.slicer = Some(rest.trim().to_string()); - continue; - } - } - - if let Some((key, value)) = comment.split_once('=') { - let key = key.trim().to_string(); - let value = value.trim().to_string(); - if !key.is_empty() && !value.is_empty() { - match key.as_str() { - "filament_type" => header.filament_type = Some(value.clone()), - "nozzle_diameter" => header.nozzle_diameter = Some(value.clone()), - _ => {} - } - if key.starts_with("estimated printing time") { - header.estimated_print_time_secs = parse_duration(&value); - } - header.raw_fields.insert(key, value); - } - continue; - } - - if let Some((key, value)) = comment.split_once(':') { - let key = key.trim(); - let value = value.trim(); - if key.eq_ignore_ascii_case("TIME") { - header.estimated_print_time_secs = - value.parse::().ok().or_else(|| parse_duration(value)); - } + let key = key.trim().to_string(); + let value = value.trim().to_string(); + if !key.is_empty() && !value.is_empty() { + fields.insert(key, value); } } - header -} - -/// Parses slicer duration strings like `1h 23m 45s` or `45m 2s` into seconds. -fn parse_duration(s: &str) -> Option { - let mut total: u64 = 0; - let mut digits = String::new(); - let mut matched_any = false; - - for c in s.chars() { - if c.is_ascii_digit() { - digits.push(c); - } else if matches!(c, 'd' | 'h' | 'm' | 's') { - if digits.is_empty() { - continue; - } - let n: u64 = digits.parse().ok()?; - digits.clear(); - matched_any = true; - total += match c { - 'd' => n * 86_400, - 'h' => n * 3_600, - 'm' => n * 60, - 's' => n, - _ => 0, - }; - } else { - digits.clear(); - } - } - - matched_any.then_some(total) + fields } #[cfg(test)] @@ -161,25 +76,10 @@ mod tests { } #[test] - fn header_parses_prusaslicer_style() { - let gcode = "\ -; generated by PrusaSlicer 2.7.0+win64 on 2026-01-01 at 12:00:00 -; filament_type = PLA -; nozzle_diameter = 0.4 -; estimated printing time (normal mode) = 1h 23m 45s -G28 -"; - let header = parse_header(gcode); - assert_eq!(header.slicer.as_deref(), Some("PrusaSlicer 2.7.0+win64")); - assert_eq!(header.filament_type.as_deref(), Some("PLA")); - assert_eq!(header.estimated_print_time_secs, Some(5025)); - } - - #[test] - fn header_parses_cura_style() { - let gcode = ";Generated with Cura_SteamEngine 5.6.0\n;TIME:3725\nG28\n"; - let header = parse_header(gcode); - assert_eq!(header.slicer.as_deref(), Some("Cura_SteamEngine 5.6.0")); - assert_eq!(header.estimated_print_time_secs, Some(3725)); + fn header_parses_key_value_comments() { + let gcode = "; filament_type = PLA\n; nozzle_diameter = 0.4\nG28\n"; + let fields = parse_header(gcode); + assert_eq!(fields.get("filament_type").map(String::as_str), Some("PLA")); + assert_eq!(fields.get("nozzle_diameter").map(String::as_str), Some("0.4")); } }