libprintcalc 0.2.0

Library for calculating 3D print job costs (material, print time, printer wear, electricity, labor, hardware) from sliced-file output.
Documentation
//! Plain-text FDM `.gcode` parser (PrusaSlicer-style metadata comments, as
//! emitted by most slicers: PrusaSlicer, OrcaSlicer, SuperSlicer, ...).

use super::slice_summary::{SliceSummary, SourceFormat};
use super::{ParseError, SliceParser, gcode_metadata};

/// [`SliceParser`] for plain-text `.gcode` — see the module docs.
pub struct Gcode;

impl SliceParser for Gcode {
    fn detect(bytes: &[u8], filename: &str) -> bool {
        if filename.to_ascii_lowercase().ends_with(".gcode") {
            return true;
        }
        looks_like_plain_gcode(bytes)
    }

    fn parse(bytes: &[u8]) -> Result<SliceSummary, ParseError> {
        // Non-UTF-8 means a binary file, which isn't this format at all —
        // the decode error's byte offset adds nothing to that verdict.
        let text = std::str::from_utf8(bytes).map_err(|_utf8_err| ParseError::NotThisFormat)?;
        let comment_lines = text
            .lines()
            .filter_map(|line| line.trim_start().strip_prefix(';'));
        let metadata = gcode_metadata::parse_lines(comment_lines);
        gcode_metadata::build_slice_summary(&metadata, SourceFormat::Gcode)
    }
}

/// Content-sniff fallback for a renamed file: not a zip (3MF/sl1), not a
/// `.bgcode` (`GCDE` magic), and decodes as UTF-8 text.
fn looks_like_plain_gcode(bytes: &[u8]) -> bool {
    !bytes.starts_with(b"PK\x03\x04")
        && !bytes.starts_with(b"GCDE")
        && std::str::from_utf8(bytes).is_ok()
}

#[cfg(test)]
mod tests {
    use super::*;

    fn read_test_file(name: &str) -> Vec<u8> {
        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
            .join("../Testfiles")
            .join(name);
        std::fs::read(path).expect("test fixture missing")
    }

    #[test]
    fn detects_by_extension() {
        assert!(Gcode::detect(b"", "widget.gcode"));
        // A renamed zip (3MF/sl1/sl1s) must not be picked up by the
        // plain-text fallback just because its extension doesn't match.
        assert!(!Gcode::detect(
            b"PK\x03\x04not actually gcode",
            "widget.sl1"
        ));
        assert!(!Gcode::detect(
            b"GCDE not actually gcode either",
            "widget.bgcode"
        ));
    }

    #[test]
    fn parses_real_fixture() {
        let bytes = read_test_file("Cube_0.2mm_PLA_Prusa MINI_20m7s.gcode");
        assert!(Gcode::detect(
            &bytes,
            "Cube_0.2mm_PLA_Prusa MINI_20m7s.gcode"
        ));

        let summary = Gcode::parse(&bytes).unwrap();
        assert_eq!(summary.source_format, SourceFormat::Gcode);
        assert_eq!(summary.plates.len(), 1);
        let plate = &summary.plates[0];
        assert_eq!(plate.plate_index, 1);
        // "20m 7s"
        assert!((plate.print_time_seconds - 1207.0).abs() < 1e-9);
        assert_eq!(plate.printer_model.as_deref(), Some("Prusa MINI"));
        assert_eq!(plate.layer_height, Some(0.2));
        assert_eq!(plate.filament_usage.len(), 1);
        let usage = &plate.filament_usage[0];
        assert_eq!(usage.slot_id, "1");
        assert!((usage.grams.unwrap() - 3.10).abs() < 1e-9);
        assert_eq!(usage.ml, None);
        assert_eq!(usage.material_type.as_deref(), Some("PLA"));
    }

    /// The config block at the end of the file names the filament preset
    /// and its price, which is what makes costing possible without the
    /// user naming a profile at all.
    #[test]
    fn reads_profile_names_and_prices_from_the_config_block() {
        let bytes = read_test_file("Cube_0.2mm_PLA_Prusa MINI_20m7s.gcode");
        let summary = Gcode::parse(&bytes).unwrap();

        assert_eq!(
            summary.meta.printer_profile.as_deref(),
            Some("Prusa MINI 0.4 nozzle")
        );
        assert_eq!(
            summary.meta.print_profile.as_deref(),
            Some("0.20mm Standard @MINI")
        );
        assert_eq!(summary.meta.nozzle_diameter, Some(0.4));
        assert_eq!(summary.meta.printer_variant.as_deref(), Some("0.4"));
        // The `; generated by ...` banner, timestamp trimmed.
        assert_eq!(summary.meta.slicer.as_deref(), Some("OrcaSlicer 2.4.2"));

        let usage = &summary.plates[0].filament_usage[0];
        assert_eq!(usage.profile_name.as_deref(), Some("Generic PLA @System"));
        assert_eq!(usage.vendor.as_deref(), Some("Generic"));
        assert_eq!(usage.price, Some(20.0));
        assert_eq!(usage.density, Some(1.24));
        assert_eq!(usage.color.as_deref(), Some("#26A69A"));
        assert!((usage.meters.unwrap() - 1.03974).abs() < 1e-9);
    }

    /// Core One IDEX, sliced single-extruder: `filament used [g]` is still
    /// comma-shaped (`"2.82, 0.00, ..."`, 8 slots — the printer's capable
    /// of more, unused ones are 0.00), so this is the milestone-6
    /// comma-list path even though only one slot ends up nonzero. slot_id
    /// is therefore the 0-based list position ("0"), not the scalar-case
    /// "1" — a real, intended asymmetry.
    #[test]
    fn parses_core_one_idex_single_extruder_fixture() {
        let bytes = read_test_file("Shape-Box_0.4n_0.2mm_PLA_COREONEINDX_19m.gcode");
        let summary = Gcode::parse(&bytes).unwrap();
        let plate = &summary.plates[0];
        assert_eq!(plate.printer_model.as_deref(), Some("COREONE_INDX8T"));
        assert_eq!(plate.filament_usage.len(), 1);
        let usage = &plate.filament_usage[0];
        assert_eq!(usage.slot_id, "0");
        assert!((usage.grams.unwrap() - 2.82).abs() < 1e-9);
        assert_eq!(usage.material_type.as_deref(), Some("PLA"));
        assert_eq!(usage.color.as_deref(), Some("#FF8000"));
    }

    /// Same model, dual-extruder slice of the same part: two nonzero
    /// entries, slot_id "0" and "1".
    #[test]
    fn parses_core_one_idex_dual_extruder_fixture() {
        let bytes = read_test_file("Shape-Box_0.4n_0.2mm_PLA,PLA_COREONEINDX_19m.gcode");
        let summary = Gcode::parse(&bytes).unwrap();
        let plate = &summary.plates[0];
        assert_eq!(plate.filament_usage.len(), 2);
        assert_eq!(plate.filament_usage[0].slot_id, "0");
        assert!((plate.filament_usage[0].grams.unwrap() - 1.38).abs() < 1e-9);
        assert_eq!(plate.filament_usage[1].slot_id, "1");
        assert!((plate.filament_usage[1].grams.unwrap() - 1.47).abs() < 1e-9);
        // Every per-filament list is read at the slot's own index.
        for u in &plate.filament_usage {
            assert_eq!(
                u.profile_name.as_deref(),
                Some("Generic PLA Silk @COREONEINDX HF0.4")
            );
            assert_eq!(u.price, Some(25.4));
        }
    }
}