use std::collections::HashMap;
use std::fmt;
use std::fs;
use std::path::{Path, PathBuf};
use libprintcalc::builtin;
use libprintcalc::formats::slice_summary::SliceSummary;
use libprintcalc::formats::{self, ParseError};
use libprintcalc::model::cost::{self, CalcError, CostBreakdown, CostDefaults};
use libprintcalc::model::filament::MaterialProfile;
use libprintcalc::model::job::{HardwareItem, Job, JobSource, LaborCost, ProfileRef};
use libprintcalc::model::printer::PrinterProfile;
use crate::disk::{self, LibraryError};
#[derive(Debug)]
#[non_exhaustive]
pub enum OpsError {
Io(std::io::Error),
Parse(ParseError),
Calc(CalcError),
Library(LibraryError),
Lib(libprintcalc::Error),
Invalid(String),
}
impl fmt::Display for OpsError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
OpsError::Io(e) => write!(f, "io error: {e}"),
OpsError::Parse(e) => write!(f, "{e}"),
OpsError::Calc(e) => write!(f, "{e}"),
OpsError::Library(e) => write!(f, "{e}"),
OpsError::Lib(e) => write!(f, "{e}"),
OpsError::Invalid(e) => write!(f, "{e}"),
}
}
}
impl std::error::Error for OpsError {}
impl From<std::io::Error> for OpsError {
fn from(e: std::io::Error) -> Self {
OpsError::Io(e)
}
}
impl From<ParseError> for OpsError {
fn from(e: ParseError) -> Self {
OpsError::Parse(e)
}
}
impl From<CalcError> for OpsError {
fn from(e: CalcError) -> Self {
OpsError::Calc(e)
}
}
impl From<LibraryError> for OpsError {
fn from(e: LibraryError) -> Self {
OpsError::Library(e)
}
}
impl From<libprintcalc::Error> for OpsError {
fn from(e: libprintcalc::Error) -> Self {
OpsError::Lib(e)
}
}
pub fn show_config() -> Result<CostDefaults, OpsError> {
Ok(disk::load_config()?)
}
pub fn set_config(config: &CostDefaults) -> Result<(), OpsError> {
Ok(disk::save_config(config)?)
}
pub fn add_printer(profile: &PrinterProfile) -> Result<(), OpsError> {
Ok(disk::save_printer(profile)?)
}
pub fn list_printers() -> Result<Vec<String>, OpsError> {
let mut names = disk::list_printers()?;
for profile in builtin::printers() {
if !names.contains(&profile.name) {
names.push(profile.name.clone());
}
}
names.sort();
Ok(names)
}
pub fn show_printer(name: &str) -> Result<PrinterProfile, OpsError> {
match disk::load_printer(name) {
Ok(profile) => Ok(profile),
Err(LibraryError::NotFound { .. }) => builtin::printer(name).cloned().ok_or_else(|| {
OpsError::Library(LibraryError::NotFound {
kind: "printer",
name: name.to_string(),
})
}),
Err(e) => Err(e.into()),
}
}
pub fn list_saved_printers() -> Result<Vec<String>, OpsError> {
Ok(disk::list_printers()?)
}
pub fn remove_printer(name: &str) -> Result<(), OpsError> {
match disk::remove_printer(name) {
Err(LibraryError::NotFound { .. }) if builtin::printer(name).is_some() => {
Err(OpsError::Invalid(format!(
"printer {name:?} is a built-in preset, not a saved profile — nothing to remove"
)))
}
other => Ok(other?),
}
}
pub fn add_filament(profile: &MaterialProfile) -> Result<(), OpsError> {
Ok(disk::save_filament(profile)?)
}
pub fn list_saved_filaments() -> Result<Vec<String>, OpsError> {
Ok(disk::list_filaments()?)
}
pub fn remove_filament(name: &str) -> Result<(), OpsError> {
match disk::remove_filament(name) {
Err(LibraryError::NotFound { .. }) if builtin::filament(name).is_some() => {
Err(OpsError::Invalid(format!(
"filament {name:?} is a built-in preset, not a saved profile — nothing to remove"
)))
}
other => Ok(other?),
}
}
pub fn list_filaments() -> Result<Vec<String>, OpsError> {
let mut names = disk::list_filaments()?;
for profile in builtin::filaments() {
if !names.contains(&profile.name) {
names.push(profile.name.clone());
}
}
names.sort();
Ok(names)
}
pub fn show_filament(name: &str) -> Result<MaterialProfile, OpsError> {
match disk::load_filament(name) {
Ok(profile) => Ok(profile),
Err(LibraryError::NotFound { .. }) => builtin::filament(name).cloned().ok_or_else(|| {
OpsError::Library(LibraryError::NotFound {
kind: "filament",
name: name.to_string(),
})
}),
Err(e) => Err(e.into()),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JobTier {
Default,
Full,
Bundled,
}
#[derive(Debug, Clone)]
pub enum ProfileChoice<T> {
Named(String),
Profile(Box<T>),
}
impl<T> From<String> for ProfileChoice<T> {
fn from(name: String) -> Self {
ProfileChoice::Named(name)
}
}
pub struct NewJobParams {
pub name: String,
pub slice_path: PathBuf,
pub printer: ProfileChoice<PrinterProfile>,
pub filaments: HashMap<String, ProfileChoice<MaterialProfile>>,
pub plate_selection: Option<u32>,
pub labor_minutes: f64,
pub labor_rate_per_hour: Option<f64>,
pub default_filament: Option<ProfileChoice<MaterialProfile>>,
pub quantity: u32,
pub hardware: Vec<HardwareItem>,
pub electricity_cost_per_kwh_override: Option<f64>,
pub margin_percentages_override: Option<Vec<f64>>,
pub material_efficiency_factor_override: Option<f64>,
pub tier: JobTier,
}
pub struct NewJobOutcome {
pub job: Job,
pub slice: SliceSummary,
pub plate_selection_ignored: bool,
}
pub struct Detected {
pub printer: Option<libprintcalc::Match<PrinterProfile>>,
pub slots: Vec<libprintcalc::AutoSlot>,
}
pub fn detect_profiles(slice: &SliceSummary) -> Detected {
let printers: Vec<PrinterProfile> = disk::list_printers()
.unwrap_or_default()
.iter()
.filter_map(|name| disk::load_printer(name).ok())
.collect();
let filaments: Vec<MaterialProfile> = disk::list_filaments()
.unwrap_or_default()
.iter()
.filter_map(|name| disk::load_filament(name).ok())
.collect();
let efficiency_factor = default_efficiency_factor(&filaments);
let auto = libprintcalc::auto_job(
"detect",
slice.clone(),
slice.clone(),
printers.iter(),
filaments.iter(),
efficiency_factor,
);
Detected {
printer: auto.printer,
slots: auto.slots,
}
}
fn default_efficiency_factor(saved: &[MaterialProfile]) -> f64 {
let mut factors: Vec<f64> = saved
.iter()
.map(|m| m.efficiency_factor)
.filter(|f| f.is_finite() && *f > 0.0)
.collect();
if factors.is_empty() {
return libprintcalc::model::filament::DEFAULT_EFFICIENCY_FACTOR;
}
factors.sort_by(|a, b| a.partial_cmp(b).expect("filtered to finite values"));
factors
.get(factors.len() / 2)
.copied()
.unwrap_or(libprintcalc::model::filament::DEFAULT_EFFICIENCY_FACTOR)
}
pub fn parse_slice(path: &Path) -> Result<SliceSummary, OpsError> {
let bytes = fs::read(path)?;
let filename = path.file_name().and_then(|f| f.to_str()).unwrap_or("");
Ok(formats::parse_slice_bytes(&bytes, filename)?)
}
pub fn new_job(params: NewJobParams, summary: SliceSummary) -> Result<NewJobOutcome, OpsError> {
let plate_selection_ignored = params.plate_selection.is_some() && summary.plates.len() <= 1;
let plate_selection = if plate_selection_ignored {
None
} else {
params.plate_selection
};
let printer = resolve_choice(params.printer, params.tier, show_printer)?;
let material: HashMap<String, ProfileRef<MaterialProfile>> = params
.filaments
.into_iter()
.map(|(slot, choice)| Ok((slot, resolve_choice(choice, params.tier, show_filament)?)))
.collect::<Result<_, OpsError>>()?;
let default_material = params
.default_filament
.map(|choice| resolve_choice(choice, params.tier, show_filament))
.transpose()?;
let source = match params.tier {
JobTier::Bundled => JobSource::Embedded(summary.clone()),
JobTier::Default | JobTier::Full => JobSource::Path(params.slice_path),
};
let mut labor = LaborCost::new(params.labor_minutes);
labor.rate_per_hour = params.labor_rate_per_hour;
let job = Job::new(params.name, source, printer)
.with_plate_selection(plate_selection)
.with_materials(material)
.with_default_material(default_material)
.with_quantity(params.quantity)
.with_labor(labor)
.with_hardware(params.hardware)
.with_electricity_cost_per_kwh_override(params.electricity_cost_per_kwh_override)
.with_margin_percentages_override(params.margin_percentages_override)
.with_material_efficiency_factor_override(params.material_efficiency_factor_override);
Ok(NewJobOutcome {
job,
slice: summary,
plate_selection_ignored,
})
}
pub fn save_job(job: &Job, path: &Path) -> Result<(), OpsError> {
let text = job.to_toml(libprintcalc::JobTier::Slim)?;
fs::write(path, text)?;
Ok(())
}
pub fn load_job(path: &Path) -> Result<Job, OpsError> {
let text = fs::read_to_string(path)?;
let (job, unknown) = libprintcalc::parse_toml(&text)?;
disk::warn_unknown_keys(path, &unknown);
Ok(job)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PlateOverride {
Keep,
All,
Only(u32),
}
impl PlateOverride {
pub fn apply(self, job: &mut Job) {
match self {
PlateOverride::Keep => {}
PlateOverride::All => job.plate_selection = None,
PlateOverride::Only(index) => job.plate_selection = Some(index),
}
}
pub fn selection(self) -> Option<u32> {
match self {
PlateOverride::Keep | PlateOverride::All => None,
PlateOverride::Only(index) => Some(index),
}
}
}
#[cfg(test)]
pub fn calc_job(
job_path: &Path,
plate: PlateOverride,
hardware: Vec<HardwareItem>,
) -> Result<CostBreakdown, OpsError> {
let mut job = load_job(job_path)?;
plate.apply(&mut job);
for item in hardware {
job.add_hardware(item);
}
calc_job_at(&job, job_path)
}
pub fn calc_job_at(job: &Job, job_path: &Path) -> Result<CostBreakdown, OpsError> {
let slice = resolve_slice_summary(job, job_path)?;
calc_job_data(job, &slice)
}
pub fn calc_job_data(job: &Job, slice: &SliceSummary) -> Result<CostBreakdown, OpsError> {
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()?;
let defaults = disk::load_config()?;
Ok(cost::calculate_cost(
job,
slice,
&printer,
&material_profiles,
default_material.as_ref(),
&defaults,
)?)
}
pub fn retier_job(job_path: &Path, tier: JobTier) -> Result<Job, OpsError> {
if tier == JobTier::Default {
return Err(OpsError::Invalid(
"can't re-tier to Default — a Full/Bundled job's linked names and original file path \
aren't tracked anymore once embedded"
.to_string(),
));
}
let job = load_job(job_path)?;
let printer = ProfileRef::Embedded {
profile: Box::new(resolve_printer(&job.printer)?),
};
let material = resolve_materials(&job.material)?
.into_iter()
.map(|(slot, profile)| {
(
slot,
ProfileRef::Embedded {
profile: Box::new(profile),
},
)
})
.collect();
let mut job = job;
if tier == JobTier::Bundled {
job.source = JobSource::Embedded(resolve_slice_summary(&job, job_path)?);
}
job.printer = printer;
job.material = material;
Ok(job)
}
fn resolve_slice_summary(job: &Job, job_path: &Path) -> Result<SliceSummary, OpsError> {
match &job.source {
JobSource::Embedded(summary) => Ok(summary.clone()),
JobSource::Path(path) => {
let resolved = if path.is_absolute() {
path.clone()
} else {
job_path
.parent()
.unwrap_or_else(|| Path::new("."))
.join(path)
};
let bytes = fs::read(&resolved)?;
let filename = resolved.file_name().and_then(|f| f.to_str()).unwrap_or("");
Ok(formats::parse_slice_bytes(&bytes, filename)?)
}
}
}
fn resolve_printer(printer_ref: &ProfileRef<PrinterProfile>) -> Result<PrinterProfile, OpsError> {
match printer_ref {
ProfileRef::Embedded { profile } => Ok((**profile).clone()),
ProfileRef::Linked { name } => show_printer(name),
}
}
fn resolve_choice<T>(
choice: ProfileChoice<T>,
tier: JobTier,
load: impl FnOnce(&str) -> Result<T, OpsError>,
) -> Result<ProfileRef<T>, OpsError> {
Ok(match (choice, tier) {
(ProfileChoice::Named(name), JobTier::Default) => ProfileRef::Linked { name },
(ProfileChoice::Named(name), _) => ProfileRef::Embedded {
profile: Box::new(load(&name)?),
},
(ProfileChoice::Profile(profile), _) => ProfileRef::Embedded { profile },
})
}
fn resolve_material(
material_ref: &ProfileRef<MaterialProfile>,
) -> Result<MaterialProfile, OpsError> {
match material_ref {
ProfileRef::Embedded { profile } => Ok((**profile).clone()),
ProfileRef::Linked { name } => show_filament(name),
}
}
fn resolve_materials(
map: &HashMap<String, ProfileRef<MaterialProfile>>,
) -> Result<HashMap<String, MaterialProfile>, OpsError> {
map.iter()
.map(|(slot, r)| Ok((slot.clone(), resolve_material(r)?)))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use libprintcalc::formats::slice_summary::{FilamentUsage, PlateSummary, SourceFormat};
use libprintcalc::model::filament::MaterialKind;
use libprintcalc::model::job::SINGLE_MATERIAL_SLOT;
use libprintcalc::model::printer::{PrinterKind, WearProfile};
fn sl1_fixture_path() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../Testfiles/Shape-Box.sl1s")
}
#[test]
fn calc_job_resolves_the_bundled_example_fixture() {
let path =
Path::new(env!("CARGO_MANIFEST_DIR")).join("../Testfiles/bundled-job-example.toml");
let breakdown = calc_job(&path, PlateOverride::Keep, Vec::new()).unwrap();
assert!((breakdown.material_cost - 0.2654538075).abs() < 1e-4);
assert_eq!(breakdown.material_breakdown.len(), 1);
assert_eq!(
breakdown.material_breakdown[0].filament_name,
"prusament-resin-orange"
);
assert_eq!(breakdown.labor_cost, 3.75); assert_eq!(breakdown.hardware_cost, 0.64); assert_eq!(breakdown.hardware_breakdown.len(), 2);
let plate1 = calc_job(&path, PlateOverride::Only(1), Vec::new()).unwrap();
assert!((plate1.material_cost - 0.136426395).abs() < 1e-4);
assert!(matches!(
calc_job(&path, PlateOverride::Only(3), Vec::new()).unwrap_err(),
OpsError::Calc(CalcError::PlateNotFound(3))
));
}
#[test]
fn new_job_parses_slice_and_builds_default_tier_job() {
let outcome = new_job(
NewJobParams {
name: "test-print".into(),
slice_path: sl1_fixture_path(),
printer: "sl1-printer".to_string().into(),
filaments: HashMap::from([(
SINGLE_MATERIAL_SLOT.to_string(),
"resin-grey".to_string().into(),
)]),
plate_selection: None,
labor_minutes: 10.0,
labor_rate_per_hour: None,
default_filament: None,
quantity: 1,
hardware: Vec::new(),
electricity_cost_per_kwh_override: None,
margin_percentages_override: None,
material_efficiency_factor_override: None,
tier: JobTier::Default,
},
parse_slice(&sl1_fixture_path()).unwrap(),
)
.unwrap();
assert!(!outcome.plate_selection_ignored);
assert_eq!(outcome.job.name, "test-print");
assert_eq!(outcome.job.plate_selection, None);
assert_eq!(
outcome.job.printer,
ProfileRef::Linked {
name: "sl1-printer".into()
}
);
assert_eq!(
outcome.job.material[SINGLE_MATERIAL_SLOT],
ProfileRef::Linked {
name: "resin-grey".into()
}
);
}
#[test]
fn new_job_warns_when_plate_selection_is_moot() {
let outcome = new_job(
NewJobParams {
name: "test-print".into(),
slice_path: sl1_fixture_path(),
printer: "sl1-printer".to_string().into(),
filaments: HashMap::from([(
SINGLE_MATERIAL_SLOT.to_string(),
"resin-grey".to_string().into(),
)]),
plate_selection: Some(2),
labor_minutes: 0.0,
labor_rate_per_hour: None,
default_filament: None,
quantity: 1,
hardware: Vec::new(),
electricity_cost_per_kwh_override: None,
margin_percentages_override: None,
material_efficiency_factor_override: None,
tier: JobTier::Default,
},
parse_slice(&sl1_fixture_path()).unwrap(),
)
.unwrap();
assert!(outcome.plate_selection_ignored);
assert_eq!(outcome.job.plate_selection, None);
}
fn sl1_wear() -> WearProfile {
WearProfile::new()
.with_purchase_price(400.0)
.with_estimated_life_years(5.0)
.with_estimated_uptime_percent(0.3)
.with_annual_maintenance_cost(20.0)
.with_power_consumption_watts(60.0)
.with_cost_buffer_factor(1.2)
}
fn remove_dir_if_present(path: &Path) {
match fs::remove_dir_all(path) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => panic!("couldn't remove {}: {e}", path.display()),
}
}
fn remove_file_if_present(path: &Path) {
match fs::remove_file(path) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => panic!("couldn't remove {}: {e}", path.display()),
}
}
fn temp_job_path(name: &str) -> PathBuf {
std::env::temp_dir().join(format!("printcalc-ops-test-{name}.toml"))
}
#[test]
fn calc_job_resolves_bundled_job_from_disk() {
let slice = SliceSummary::new(
SourceFormat::PrusaSl1,
vec![PlateSummary::new(
1,
3600.0,
vec![FilamentUsage::ml(SINGLE_MATERIAL_SLOT, 10.0).with_material_type("Resin")],
)],
);
let printer = PrinterProfile::new("sl1-printer", PrinterKind::Resin, sl1_wear());
let resin = MaterialProfile::new("resin-grey", MaterialKind::Resin)
.with_price(40.0)
.with_efficiency_factor(1.0);
let mut job = Job::new(
"bundled",
JobSource::Embedded(slice),
ProfileRef::Embedded {
profile: Box::new(printer),
},
)
.with_labor(LaborCost::new(20.0).with_rate_per_hour(15.0))
.with_hardware(vec![HardwareItem::new("resin vat film", 2.0)]);
job.add_material(
SINGLE_MATERIAL_SLOT,
ProfileRef::Embedded {
profile: Box::new(resin),
},
);
let path = temp_job_path("bundled");
save_job(&job, &path).unwrap();
let breakdown = calc_job(&path, PlateOverride::Keep, Vec::new()).unwrap();
remove_file_if_present(&path);
assert!((breakdown.material_cost - 0.40).abs() < 1e-9);
assert!((breakdown.labor_cost - 5.0).abs() < 1e-9);
assert_eq!(breakdown.hardware_cost, 2.0);
}
#[test]
fn calc_job_resolves_default_tier_job_against_library() {
let config_dir = std::env::temp_dir().join("printcalc-ops-test-config-dir");
remove_dir_if_present(&config_dir);
unsafe {
std::env::set_var("PRINTCALC_CONFIG_DIR", &config_dir);
}
assert_eq!(show_printer("bambu-a1").unwrap().kind, PrinterKind::Fdm);
assert!(list_printers().unwrap().contains(&"bambu-a1".to_string()));
add_printer(&PrinterProfile::new(
"sl1-printer",
PrinterKind::Resin,
sl1_wear(),
))
.unwrap();
add_filament(
&MaterialProfile::new("resin-grey", MaterialKind::Resin)
.with_price(40.0)
.with_efficiency_factor(1.0),
)
.unwrap();
let printers = list_printers().unwrap();
assert!(printers.contains(&"sl1-printer".to_string()));
assert!(printers.contains(&"bambu-a1".to_string())); let filaments = list_filaments().unwrap();
assert!(filaments.contains(&"resin-grey".to_string()));
assert!(filaments.contains(&"pla-black".to_string())); assert_eq!(
show_printer("sl1-printer").unwrap().kind,
PrinterKind::Resin
);
add_printer(&PrinterProfile::new(
"bambu-a1",
PrinterKind::Fdm,
builtin::printer("bambu-a1")
.unwrap()
.wear
.with_purchase_price(999.0),
))
.unwrap();
assert_eq!(show_printer("bambu-a1").unwrap().wear.purchase_price, 999.0);
let outcome = new_job(
NewJobParams {
name: "test-print".into(),
slice_path: sl1_fixture_path(),
printer: "sl1-printer".to_string().into(),
filaments: HashMap::from([(
SINGLE_MATERIAL_SLOT.to_string(),
"resin-grey".to_string().into(),
)]),
plate_selection: None,
labor_minutes: 6.0,
labor_rate_per_hour: None,
default_filament: None,
quantity: 1,
hardware: Vec::new(),
electricity_cost_per_kwh_override: None,
margin_percentages_override: None,
material_efficiency_factor_override: None,
tier: JobTier::Default,
},
parse_slice(&sl1_fixture_path()).unwrap(),
)
.unwrap();
let path = temp_job_path("default-tier");
save_job(&outcome.job, &path).unwrap();
let breakdown = calc_job(&path, PlateOverride::Keep, Vec::new()).unwrap();
let bundled = retier_job(&path, JobTier::Bundled).unwrap();
assert!(matches!(bundled.printer, ProfileRef::Embedded { .. }));
assert!(matches!(
bundled.material[SINGLE_MATERIAL_SLOT],
ProfileRef::Embedded { .. }
));
assert!(matches!(bundled.source, JobSource::Embedded(_)));
let bundled_path = temp_job_path("bundled-retiered");
save_job(&bundled, &bundled_path).unwrap();
remove_dir_if_present(&config_dir); unsafe {
std::env::remove_var("PRINTCALC_CONFIG_DIR");
}
let bundled_breakdown = calc_job(&bundled_path, PlateOverride::Keep, Vec::new()).unwrap(); assert_eq!(bundled_breakdown.material_cost, breakdown.material_cost);
assert!(matches!(
retier_job(&path, JobTier::Default),
Err(OpsError::Invalid(_))
));
remove_file_if_present(&path);
remove_file_if_present(&bundled_path);
assert!((breakdown.material_cost - 2.598598 * (40.0 / 1000.0)).abs() < 1e-4);
}
}