#![warn(missing_docs)]
use std::collections::HashMap;
use std::fmt;
pub mod builtin;
pub mod disk;
pub mod formats;
pub mod matching;
pub mod model;
pub use disk::{read_job, read_material, read_printer, read_slice_file};
pub use formats::ParseError;
pub use formats::parse_slice_bytes;
pub use formats::slice_summary::{
FilamentUsage, PlateSummary, SliceMeta, SliceSummary, SlotUsage, SourceFormat,
};
pub use matching::{Match, match_material, match_printer, material_from_slot};
pub use model::cost::{
CalcError, CostBreakdown, CostDefaults, SuggestedPrice, UnmappedSlot, calculate_cost,
};
pub use model::filament::{MaterialKind, MaterialProfile};
pub use model::job::{
HardwareItem, Job, JobSource, JobTier, LaborCost, ProfileRef, SINGLE_MATERIAL_SLOT,
};
pub use model::printer::{PrinterKind, PrinterProfile, WearProfile};
use model::cost;
#[derive(Debug)]
#[non_exhaustive]
pub enum Error {
Io(std::io::Error),
Toml(String),
Parse(ParseError),
Calc(CalcError),
ProfileNotFound {
kind: &'static str,
name: String,
},
DiskUnavailable,
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Error::Io(e) => write!(f, "io error: {e}"),
Error::Toml(e) => write!(f, "toml error: {e}"),
Error::Parse(e) => write!(f, "{e}"),
Error::Calc(e) => write!(f, "{e}"),
Error::ProfileNotFound { kind, name } => write!(f, "{kind} {name:?} not found"),
Error::DiskUnavailable => write!(
f,
"no filesystem on this target — embed the slice summary instead of using a path source"
),
}
}
}
impl std::error::Error for Error {}
impl From<std::io::Error> for Error {
fn from(e: std::io::Error) -> Self {
Error::Io(e)
}
}
impl From<ParseError> for Error {
fn from(e: ParseError) -> Self {
Error::Parse(e)
}
}
impl From<CalcError> for Error {
fn from(e: CalcError) -> Self {
Error::Calc(e)
}
}
pub fn parse_job(text: &str) -> Result<Job, Error> {
parse_toml(text).map(|(job, _)| job)
}
pub fn parse_printer(text: &str) -> Result<PrinterProfile, Error> {
parse_toml(text).map(|(printer, _)| printer)
}
pub fn parse_material(text: &str) -> Result<MaterialProfile, Error> {
parse_toml(text).map(|(material, _)| material)
}
pub fn parse_toml<T: serde::de::DeserializeOwned>(text: &str) -> Result<(T, Vec<String>), Error> {
let mut unknown = Vec::new();
let deserializer = toml::Deserializer::parse(text).map_err(|e| Error::Toml(e.to_string()))?;
let value = serde_ignored::deserialize(deserializer, |path| unknown.push(path.to_string()))
.map_err(|e| Error::Toml(e.to_string()))?;
Ok((value, unknown))
}
pub fn create_printer(
name: impl Into<String>,
kind: PrinterKind,
wear: WearProfile,
) -> PrinterProfile {
PrinterProfile::new(name, kind, wear)
}
pub fn create_material(
name: impl Into<String>,
kind: MaterialKind,
price: f64,
efficiency_factor: f64,
color: Option<String>,
vendor: Option<String>,
) -> MaterialProfile {
let mut profile = MaterialProfile::new(name, kind)
.with_price(price)
.with_efficiency_factor(efficiency_factor);
profile.color = color;
profile.vendor = vendor;
profile
}
pub fn new_job(
name: impl Into<String>,
source: impl Into<JobSource>,
printer: PrinterProfile,
materials: HashMap<String, ProfileRef<MaterialProfile>>,
labor: Option<LaborCost>,
hardware: Vec<HardwareItem>,
) -> Job {
Job::new(
name,
source,
ProfileRef::Embedded {
profile: Box::new(printer),
},
)
.with_materials(materials)
.with_labor(labor.unwrap_or(LaborCost::new(0.0)))
.with_hardware(hardware)
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct AutoJob {
pub job: Job,
pub slice: SliceSummary,
pub printer: Option<Match<PrinterProfile>>,
pub slots: Vec<AutoSlot>,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct AutoSlot {
pub usage: SlotUsage,
pub matched: Option<Match<MaterialProfile>>,
pub from_file: Option<MaterialProfile>,
}
impl AutoSlot {
pub fn profile(&self) -> Option<&MaterialProfile> {
self.matched
.as_ref()
.map(|m| &m.profile)
.or(self.from_file.as_ref())
}
}
pub fn auto_job<'a>(
name: impl Into<String>,
source: impl Into<JobSource>,
slice: SliceSummary,
candidate_printers: impl IntoIterator<Item = &'a PrinterProfile>,
candidate_materials: impl IntoIterator<Item = &'a MaterialProfile>,
default_efficiency_factor: f64,
) -> AutoJob {
let format = slice.source_format;
let printer_kind = model::cost::printer_kind_for(format);
let material_kind = model::cost::material_kind_for(format);
let printer = matching::match_printer(&slice, candidate_printers, printer_kind);
let materials: Vec<&MaterialProfile> = candidate_materials.into_iter().collect();
let slots: Vec<AutoSlot> = slice
.slots()
.into_iter()
.map(|usage| {
let matched =
matching::match_material(&usage, materials.iter().copied(), material_kind);
let from_file = matched.is_none().then(|| {
matching::material_from_slot(&usage, material_kind, default_efficiency_factor)
});
AutoSlot {
usage,
matched,
from_file: from_file.flatten(),
}
})
.collect();
let material: HashMap<String, ProfileRef<MaterialProfile>> = slots
.iter()
.filter_map(|slot| {
let profile = slot.profile()?;
Some((
slot.usage.slot_id.clone(),
ProfileRef::Embedded {
profile: Box::new(profile.clone()),
},
))
})
.collect();
let printer_profile = printer
.as_ref()
.map(|m| m.profile.clone())
.unwrap_or_else(|| PrinterProfile {
name: slice
.printer_model()
.unwrap_or("unknown printer")
.to_string(),
kind: printer_kind,
wear: WearProfile {
purchase_price: 0.0,
additional_upfront_cost: 0.0,
estimated_life_years: 0.0,
estimated_uptime_percent: 0.0,
annual_maintenance_cost: 0.0,
power_consumption_watts: 0.0,
cost_buffer_factor: 1.0,
print_time_rate_override: None,
},
});
let job = new_job(name, source, printer_profile, material, None, Vec::new());
AutoJob {
job,
slice,
printer,
slots,
}
}
pub fn calculate(job: &Job, defaults: &CostDefaults) -> Result<CostBreakdown, Error> {
let slice = job.slice_summary()?;
let printer = resolve_printer(&job.printer)?;
let material_profiles = resolve_materials(&job.material)?;
let default_material = job
.default_material
.as_ref()
.map(resolve_material)
.transpose()?;
Ok(cost::calculate_cost(
job,
&slice,
&printer,
&material_profiles,
default_material.as_ref(),
defaults,
)?)
}
pub(crate) fn resolve_printer(
printer_ref: &ProfileRef<PrinterProfile>,
) -> Result<PrinterProfile, Error> {
match printer_ref {
ProfileRef::Embedded { profile } => Ok((**profile).clone()),
ProfileRef::Linked { name } => {
builtin::printer(name)
.cloned()
.ok_or_else(|| Error::ProfileNotFound {
kind: "printer",
name: name.clone(),
})
}
}
}
pub(crate) fn resolve_material(
material_ref: &ProfileRef<MaterialProfile>,
) -> Result<MaterialProfile, Error> {
match material_ref {
ProfileRef::Embedded { profile } => Ok((**profile).clone()),
ProfileRef::Linked { name } => {
builtin::filament(name)
.cloned()
.ok_or_else(|| Error::ProfileNotFound {
kind: "filament",
name: name.clone(),
})
}
}
}
pub(crate) fn resolve_materials(
map: &HashMap<String, ProfileRef<MaterialProfile>>,
) -> Result<HashMap<String, MaterialProfile>, Error> {
map.iter()
.map(|(slot, r)| Ok((slot.clone(), resolve_material(r)?)))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn test_wear() -> WearProfile {
WearProfile {
purchase_price: 500.0,
additional_upfront_cost: 0.0,
estimated_life_years: 5.0,
estimated_uptime_percent: 0.5,
annual_maintenance_cost: 50.0,
power_consumption_watts: 150.0,
cost_buffer_factor: 1.2,
print_time_rate_override: None,
}
}
#[test]
fn calculate_resolves_embedded_profiles() {
let slice = SliceSummary {
source_format: SourceFormat::PrusaSl1,
meta: Default::default(),
plates: vec![PlateSummary {
plate_index: 1,
print_time_seconds: 3600.0,
filament_usage: vec![FilamentUsage {
slot_id: SINGLE_MATERIAL_SLOT.into(),
material_type: Some("Resin".into()),
ml: Some(10.0),
..Default::default()
}],
printer_model: None,
layer_height: None,
extra: HashMap::new(),
}],
};
let job = Job {
name: "bundled".into(),
source: JobSource::Embedded(slice),
plate_selection: None,
printer: ProfileRef::Embedded {
profile: Box::new(create_printer(
"sl1-printer",
PrinterKind::Resin,
WearProfile {
purchase_price: 400.0,
estimated_uptime_percent: 0.3,
..test_wear()
},
)),
},
material: HashMap::from([(
SINGLE_MATERIAL_SLOT.to_string(),
ProfileRef::Embedded {
profile: Box::new(create_material(
"resin-grey",
MaterialKind::Resin,
40.0,
1.0,
None,
None,
)),
},
)]),
default_material: None,
quantity: 1,
material_efficiency_factor_override: None,
electricity_cost_per_kwh_override: None,
labor: LaborCost {
rate_per_hour: None, minutes: 20.0,
},
hardware: Vec::new(),
margin_percentages_override: None,
};
let breakdown = calculate(&job, &CostDefaults::default()).unwrap();
assert!((breakdown.material_cost - 0.40).abs() < 1e-9);
assert!((breakdown.labor_cost - 5.0).abs() < 1e-9);
}
#[cfg(feature = "bambu")]
#[test]
fn auto_job_costs_a_bambu_file_with_nothing_supplied() {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../Testfiles/single_plate_multicolor.gcode.3mf");
let slice = read_slice_file(&path).unwrap();
let auto = auto_job("multicolor", slice.clone(), slice, [], [], 1.1);
let printer = auto.printer.expect("the file names a Bambu P2S");
assert_eq!(printer.profile.name, "bambu-p2s");
assert_eq!(auto.slots.len(), 2);
for slot in &auto.slots {
let matched = slot.matched.as_ref().expect("Bambu PLA Basic is built in");
assert_eq!(matched.profile.name, "bambu-pla-basic");
assert_eq!(matched.confidence, 1.0);
}
let breakdown = calculate(&auto.job, &CostDefaults::default()).unwrap();
assert!(breakdown.material_cost > 0.0);
assert!(breakdown.wear_cost > 0.0);
}
#[cfg(feature = "gcode")]
#[test]
fn auto_job_falls_back_to_the_files_own_filament_price() {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../Testfiles/Cube_0.2mm_PLA_Prusa MINI_20m7s.gcode");
let slice = read_slice_file(&path).unwrap();
let auto = auto_job("cube", path, slice, [], [], 1.5);
assert_eq!(auto.slots.len(), 1);
let slot = &auto.slots[0];
assert!(slot.matched.is_none(), "no preset is a Generic PLA");
let from_file = slot.from_file.as_ref().expect("the file states $20/kg");
assert_eq!(from_file.price, 20.0);
assert_eq!(from_file.efficiency_factor, 1.5);
let breakdown = calculate(&auto.job, &CostDefaults::default()).unwrap();
assert!((breakdown.material_cost - 0.093).abs() < 1e-4);
}
#[cfg(feature = "bambu")]
#[test]
fn auto_job_prefers_caller_supplied_profiles() {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../Testfiles/single_plate.gcode.3mf");
let slice = read_slice_file(&path).unwrap();
let mine = create_material(
"Bambu PLA Basic",
MaterialKind::Filament,
9.99,
1.0,
None,
Some("Bambu Lab".to_string()),
);
let auto = auto_job("widget", slice.clone(), slice, [], [&mine], 1.1);
let matched = auto.slots[0].matched.as_ref().unwrap();
assert_eq!(matched.profile.price, 9.99);
}
#[test]
fn linked_ref_to_unknown_profile_is_not_found() {
let err = resolve_printer(&ProfileRef::Linked {
name: "no-such-printer".into(),
})
.unwrap_err();
assert!(matches!(
err,
Error::ProfileNotFound {
kind: "printer",
..
}
));
}
#[test]
fn parse_profiles_from_toml_text() {
let printer = parse_printer(
r#"
name = "test-printer"
kind = "fdm"
purchase_price = 500.0
additional_upfront_cost = 0.0
estimated_life_years = 5.0
estimated_uptime_percent = 0.5
annual_maintenance_cost = 50.0
power_consumption_watts = 150.0
cost_buffer_factor = 1.2
"#,
)
.unwrap();
assert_eq!(printer.kind, PrinterKind::Fdm);
let material = parse_material(
r#"
name = "test-pla"
kind = "filament"
price = 20.0
efficiency_factor = 1.1
"#,
)
.unwrap();
assert_eq!(material.kind, MaterialKind::Filament);
assert!(matches!(
parse_material("not = [valid").unwrap_err(),
Error::Toml(_)
));
}
#[test]
fn printer_profile_toml_round_trips_with_flattened_wear() {
let printer = create_printer("test-printer", PrinterKind::Fdm, test_wear());
let text = printer.to_toml().unwrap();
assert!(!text.contains("[wear]"));
assert!(text.contains("purchase_price = 500.0"));
let round_tripped = parse_printer(&text).unwrap();
assert_eq!(round_tripped, printer);
assert_eq!(round_tripped.wear.purchase_price, 500.0);
}
#[test]
fn material_profile_toml_round_trips() {
let material = create_material(
"test-pla",
MaterialKind::Filament,
20.0,
1.1,
Some("black".to_string()),
None,
);
assert_eq!(material.price, 20.0);
assert_eq!(material.color.as_deref(), Some("black"));
assert_eq!(material.vendor, None);
let round_tripped = parse_material(&material.to_toml().unwrap()).unwrap();
assert_eq!(round_tripped, material);
}
#[test]
fn embed_toml_profiles_make_job_costable_without_presets() {
let slice_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../Testfiles/Cube_0.2mm_PLA_Prusa MINI_20m7s.gcode");
let mut job = new_job(
"widget",
slice_path.to_string_lossy().into_owned(),
create_printer("ignored", PrinterKind::Fdm, test_wear()),
HashMap::new(),
None,
Vec::new(),
);
job.embed_printer_toml(
r#"
name = "custom-printer"
kind = "fdm"
purchase_price = 500.0
additional_upfront_cost = 0.0
estimated_life_years = 5.0
estimated_uptime_percent = 0.5
annual_maintenance_cost = 50.0
power_consumption_watts = 150.0
cost_buffer_factor = 1.2
"#,
)
.unwrap();
job.embed_material_toml(
SINGLE_MATERIAL_SLOT,
r#"
name = "custom-pla"
kind = "filament"
price = 20.0
efficiency_factor = 1.1
"#,
)
.unwrap();
assert!(matches!(job.printer, ProfileRef::Embedded { .. }));
assert!(matches!(
job.material[SINGLE_MATERIAL_SLOT],
ProfileRef::Embedded { .. }
));
let breakdown = calculate(&job, &CostDefaults::default()).unwrap();
assert!(breakdown.total_cost > 0.0);
assert!(matches!(
job.embed_printer_toml("not = [valid").unwrap_err(),
Error::Toml(_)
));
}
#[test]
fn new_job_builds_costable_job_from_path_source() {
let slice_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../Testfiles/Cube_0.2mm_PLA_Prusa MINI_20m7s.gcode");
let job = new_job(
"widget",
slice_path.to_string_lossy().into_owned(),
create_printer("bambu-a1", PrinterKind::Fdm, test_wear()),
HashMap::from([(
SINGLE_MATERIAL_SLOT.to_string(),
ProfileRef::Linked {
name: "pla-black".into(),
},
)]),
Some(LaborCost {
rate_per_hour: None,
minutes: 10.0,
}),
Vec::new(),
);
assert_eq!(job.source, JobSource::Path(slice_path));
assert_eq!(
job.printer.embedded().map(|p| p.name.as_str()),
Some("bambu-a1")
);
let round_tripped = parse_job(&job.to_toml(JobTier::Slim).unwrap()).unwrap();
assert_eq!(round_tripped, job);
let breakdown = calculate(&job, &CostDefaults::default()).unwrap();
assert!(breakdown.total_cost > 0.0);
assert!((breakdown.labor_cost - 2.5).abs() < 1e-9);
}
#[test]
fn new_job_with_embedded_summary_costs_without_a_file() {
let slice_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../Testfiles/Cube_0.2mm_PLA_Prusa MINI_20m7s.gcode");
let summary = read_slice_file(&slice_path).unwrap();
let job = new_job(
"widget",
summary,
create_printer("bambu-a1", PrinterKind::Fdm, test_wear()),
HashMap::from([(
SINGLE_MATERIAL_SLOT.to_string(),
ProfileRef::Linked {
name: "pla-black".into(),
},
)]),
None,
Vec::new(),
);
assert!(matches!(job.source, JobSource::Embedded(_)));
let breakdown = calculate(&job, &CostDefaults::default()).unwrap();
assert!(breakdown.total_cost > 0.0);
}
fn linked_job() -> Job {
Job {
name: "widget".into(),
source: JobSource::Path("prints/widget.gcode".into()),
plate_selection: None,
printer: ProfileRef::Linked {
name: "generic-resin".into(),
},
material: HashMap::from([(
SINGLE_MATERIAL_SLOT.to_string(),
ProfileRef::Linked {
name: "generic-resin-grey".into(),
},
)]),
default_material: None,
quantity: 1,
material_efficiency_factor_override: None,
electricity_cost_per_kwh_override: None,
labor: LaborCost {
rate_per_hour: None,
minutes: 10.0,
},
hardware: Vec::new(),
margin_percentages_override: None,
}
}
#[test]
fn to_toml_slim_keeps_linked_refs() {
let job = linked_job();
let text = job.to_toml(JobTier::Slim).unwrap();
assert!(text.contains("ref = \"generic-resin\""));
assert!(!text.contains("[printer.profile]"));
let round_tripped = parse_job(&text).unwrap();
assert_eq!(round_tripped, job);
}
#[test]
fn to_toml_full_embeds_profiles_but_not_source() {
let job = linked_job();
let full = parse_job(&job.to_toml(JobTier::Full).unwrap()).unwrap();
assert_eq!(
full.printer.embedded().map(|p| p.name.as_str()),
Some("generic-resin")
);
assert!(matches!(
full.material[SINGLE_MATERIAL_SLOT],
ProfileRef::Embedded { .. }
));
assert_eq!(full.source, JobSource::Path("prints/widget.gcode".into()));
assert!(matches!(job.printer, ProfileRef::Linked { .. }));
}
#[test]
fn to_toml_bundled_embeds_profiles_and_summary() {
let slice_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../Testfiles/Cube_0.2mm_PLA_Prusa MINI_20m7s.gcode");
let job = Job {
source: JobSource::Path(slice_path),
..linked_job()
};
let bundled = parse_job(&job.to_toml(JobTier::Bundled).unwrap()).unwrap();
assert!(matches!(bundled.printer, ProfileRef::Embedded { .. }));
let JobSource::Embedded(summary) = &bundled.source else {
panic!("expected an embedded summary");
};
assert!(!summary.plates.is_empty());
}
#[test]
fn to_toml_full_and_bundled_fail_on_unknown_linked_profile() {
let mut job = linked_job();
job.printer = ProfileRef::Linked {
name: "no-such-printer".into(),
};
for tier in [JobTier::Full, JobTier::Bundled] {
assert!(matches!(
job.to_toml(tier).unwrap_err(),
Error::ProfileNotFound {
kind: "printer",
..
}
));
}
}
}