use std::collections::HashMap;
use std::fmt;
use serde::{Deserialize, Serialize};
use super::filament::{MaterialKind, MaterialProfile};
use super::job::Job;
use super::printer::{PrinterKind, PrinterProfile, WearProfile};
use crate::formats::slice_summary::{PlateSummary, SliceSummary, SourceFormat};
const COST_DP: i32 = 4;
const PRICE_DP: i32 = 2;
const HOURS_PER_YEAR: f64 = 8760.0;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct CostDefaults {
pub electricity_cost_per_kwh: f64,
pub labor_rate_per_hour: f64,
#[serde(alias = "markup_percentages")]
pub margin_percentages: Vec<f64>,
#[serde(default = "default_currency_symbol")]
pub currency_symbol: String,
}
fn default_currency_symbol() -> String {
"$".to_string()
}
impl Default for CostDefaults {
fn default() -> Self {
CostDefaults {
electricity_cost_per_kwh: 0.14,
labor_rate_per_hour: 15.0,
margin_percentages: vec![50.0, 60.0, 70.0],
currency_symbol: default_currency_symbol(),
}
}
}
impl CostDefaults {
pub fn new() -> Self {
Self::default()
}
pub fn with_electricity_cost_per_kwh(mut self, value: f64) -> Self {
self.electricity_cost_per_kwh = value;
self
}
pub fn with_labor_rate_per_hour(mut self, value: f64) -> Self {
self.labor_rate_per_hour = value;
self
}
pub fn with_margin_percentages(mut self, value: Vec<f64>) -> Self {
self.margin_percentages = value;
self
}
pub fn with_currency_symbol(mut self, value: impl Into<String>) -> Self {
self.currency_symbol = value.into();
self
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
#[non_exhaustive]
pub struct CostBreakdown {
pub quantity: u32,
pub plate_breakdown: Vec<PlateLine>,
pub print_time_seconds: f64,
pub print_time_rate: f64,
pub material_cost: f64,
pub material_breakdown: Vec<MaterialLine>,
pub wear_cost: f64,
pub electricity_cost: f64,
pub labor_cost: f64,
pub hardware_cost: f64,
pub hardware_breakdown: Vec<HardwareLine>,
pub total_cost: f64,
pub unit_cost: f64,
pub suggested_prices: Vec<SuggestedPrice>,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
#[non_exhaustive]
pub struct PlateLine {
pub plate_index: u32,
pub print_time_seconds: f64,
pub grams: f64,
pub ml: f64,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
#[non_exhaustive]
pub struct MaterialLine {
pub filament_name: String,
pub amount: f64,
pub unit: &'static str,
pub unit_price: f64,
pub cost: f64,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
#[non_exhaustive]
pub struct HardwareLine {
pub name: String,
pub quantity: u32,
pub unit_cost: f64,
pub cost: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
#[non_exhaustive]
pub struct SuggestedPrice {
pub margin_percent: f64,
pub price: f64,
pub unit_price: f64,
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct UnmappedSlot {
pub slot_id: String,
pub profile_name: Option<String>,
pub material_type: Option<String>,
pub color: Option<String>,
}
impl fmt::Display for UnmappedSlot {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "slot {}", self.slot_id)?;
let described: Vec<&str> = [
self.profile_name.as_deref(),
self.material_type.as_deref(),
self.color.as_deref(),
]
.into_iter()
.flatten()
.filter(|s| !s.is_empty())
.collect();
if !described.is_empty() {
write!(f, " ({})", described.join(" "))?;
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum CalcError {
PlateNotFound(u32),
MissingMaterial(Vec<UnmappedSlot>),
PrinterKindMismatch {
printer_name: String,
printer_kind: PrinterKind,
format: SourceFormat,
},
FilamentKindMismatch {
filament_name: String,
filament_kind: MaterialKind,
format: SourceFormat,
},
InvalidMargin(f64),
ZeroQuantity,
}
pub fn printer_kind_for(format: SourceFormat) -> PrinterKind {
expected_printer_kind(format)
}
pub fn material_kind_for(format: SourceFormat) -> MaterialKind {
expected_material_kind(format)
}
fn expected_printer_kind(format: SourceFormat) -> PrinterKind {
match format {
SourceFormat::PrusaSl1 => PrinterKind::Resin,
SourceFormat::Bambu3mf | SourceFormat::Gcode | SourceFormat::PrusaBgcode => {
PrinterKind::Fdm
}
}
}
fn expected_material_kind(format: SourceFormat) -> MaterialKind {
match format {
SourceFormat::PrusaSl1 => MaterialKind::Resin,
SourceFormat::Bambu3mf | SourceFormat::Gcode | SourceFormat::PrusaBgcode => {
MaterialKind::Filament
}
}
}
impl fmt::Display for CalcError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
CalcError::PlateNotFound(i) => write!(f, "plate {i} not found in sliced file"),
CalcError::MissingMaterial(slots) => {
let listed: Vec<String> = slots.iter().map(|s| s.to_string()).collect();
write!(
f,
"no material profile for {} — map {} explicitly, or set a default material",
listed.join(", "),
if slots.len() == 1 { "it" } else { "them" }
)
}
CalcError::PrinterKindMismatch {
printer_name,
printer_kind,
format,
} => write!(
f,
"printer {printer_name:?} is {printer_kind:?}, but {format:?} sliced files are {:?} prints",
expected_printer_kind(*format)
),
CalcError::FilamentKindMismatch {
filament_name,
filament_kind,
format,
} => write!(
f,
"filament profile {filament_name:?} is {filament_kind:?}, but {format:?} sliced files need {:?}",
expected_material_kind(*format)
),
CalcError::InvalidMargin(m) => write!(
f,
"margin of {m}% has no finite price — a margin is the share of the sale price that isn't cost, so it must be under 100% (a 60% markup is a 37.5% margin)"
),
CalcError::ZeroQuantity => write!(f, "quantity is 0 — nothing to price"),
}
}
}
impl std::error::Error for CalcError {}
fn round_to(value: f64, dp: i32) -> f64 {
let scale = 10_f64.powi(dp);
(value * scale).round() / scale
}
fn price_materials(
plates: &[&PlateSummary],
material_profiles: &HashMap<String, MaterialProfile>,
default_material: Option<&MaterialProfile>,
efficiency_factor_override: Option<f64>,
quantity: f64,
) -> (f64, Vec<MaterialLine>) {
let mut total = 0.0;
let mut lines_by_material: HashMap<String, MaterialLine> = HashMap::new();
for plate in plates {
for usage in &plate.filament_usage {
let native_amount = match (
usage.grams.filter(|&g| g != 0.0),
usage.ml.filter(|&m| m != 0.0),
) {
(Some(g), _) => g,
(None, Some(ml)) => ml,
(None, None) => continue,
};
let profile = material_profiles
.get(&usage.slot_id)
.or(default_material)
.expect("every used slot was checked before pricing began");
let unit = match profile.kind {
MaterialKind::Filament => "g",
MaterialKind::Resin => "ml",
};
let factor = efficiency_factor_override.unwrap_or(profile.efficiency_factor);
let used_amount = native_amount * factor * quantity;
let cost = used_amount * (profile.price / 1000.0);
total += cost;
let line = lines_by_material
.entry(profile.name.clone())
.or_insert_with(|| MaterialLine {
filament_name: profile.name.clone(),
amount: 0.0,
unit,
unit_price: profile.price,
cost: 0.0,
});
line.amount += used_amount;
line.cost += cost;
}
}
let mut breakdown: Vec<MaterialLine> = lines_by_material
.into_values()
.map(|line| MaterialLine {
amount: round_to(line.amount, COST_DP),
cost: round_to(line.cost, COST_DP),
..line
})
.collect();
breakdown.sort_by(|a, b| a.filament_name.cmp(&b.filament_name));
(total, breakdown)
}
fn capital_cost_per_hour(wear: &WearProfile) -> f64 {
let total_lifetime_hours =
wear.estimated_life_years * HOURS_PER_YEAR * wear.estimated_uptime_percent;
if total_lifetime_hours == 0.0 {
return 0.0;
}
let lifetime_cost = wear.purchase_price
+ wear.additional_upfront_cost
+ wear.annual_maintenance_cost * wear.estimated_life_years;
lifetime_cost / total_lifetime_hours
}
pub fn calculate_cost(
job: &Job,
slice: &SliceSummary,
printer: &PrinterProfile,
material_profiles: &HashMap<String, MaterialProfile>,
default_material: Option<&MaterialProfile>,
defaults: &CostDefaults,
) -> Result<CostBreakdown, CalcError> {
if job.quantity == 0 {
return Err(CalcError::ZeroQuantity);
}
let quantity = job.quantity as f64;
let format = slice.source_format;
if printer.kind != expected_printer_kind(format) {
return Err(CalcError::PrinterKindMismatch {
printer_name: printer.name.clone(),
printer_kind: printer.kind,
format,
});
}
for profile in material_profiles.values().chain(default_material) {
if profile.kind != expected_material_kind(format) {
return Err(CalcError::FilamentKindMismatch {
filament_name: profile.name.clone(),
filament_kind: profile.kind,
format,
});
}
}
let selected_plates: Vec<&PlateSummary> = match job.plate_selection {
None => slice.plates.iter().collect(),
Some(i) => vec![
slice
.plates
.iter()
.find(|p| p.plate_index == i)
.ok_or(CalcError::PlateNotFound(i))?,
],
};
let total_print_time_seconds: f64 = selected_plates.iter().map(|p| p.print_time_seconds).sum();
let mut plate_breakdown: Vec<PlateLine> = selected_plates
.iter()
.map(|p| PlateLine {
plate_index: p.plate_index,
print_time_seconds: p.print_time_seconds,
grams: p
.filament_usage
.iter()
.fold(0.0, |acc, u| acc + u.grams.unwrap_or(0.0)),
ml: p
.filament_usage
.iter()
.fold(0.0, |acc, u| acc + u.ml.unwrap_or(0.0)),
})
.collect();
plate_breakdown.sort_by_key(|p| p.plate_index);
let mut unmapped: Vec<UnmappedSlot> = Vec::new();
if default_material.is_none() {
for slot in slice.slots() {
if !material_profiles.contains_key(&slot.slot_id) {
unmapped.push(UnmappedSlot {
slot_id: slot.slot_id,
profile_name: slot.profile_name,
material_type: slot.material_type,
color: slot.color,
});
}
}
}
if !unmapped.is_empty() {
return Err(CalcError::MissingMaterial(unmapped));
}
let (material_cost, material_breakdown) = price_materials(
&selected_plates,
material_profiles,
default_material,
job.material_efficiency_factor_override,
quantity,
);
let wear = &printer.wear;
let capital_cost_per_hour = capital_cost_per_hour(wear);
let electricity_rate = job
.electricity_cost_per_kwh_override
.unwrap_or(defaults.electricity_cost_per_kwh);
let electrical_cost_per_hour = (wear.power_consumption_watts / 1000.0) * electricity_rate;
let (wear_rate, electricity_rate_per_hour) = match wear.print_time_rate_override {
Some(rate) => (rate, 0.0),
None => (
capital_cost_per_hour * wear.cost_buffer_factor,
electrical_cost_per_hour * wear.cost_buffer_factor,
),
};
let print_time_rate = wear_rate + electricity_rate_per_hour;
let print_hours = total_print_time_seconds / 3600.0;
let wear_cost = wear_rate * print_hours * quantity;
let electricity_cost = electricity_rate_per_hour * print_hours * quantity;
let effective_labor_rate = job
.labor
.rate_per_hour
.unwrap_or(defaults.labor_rate_per_hour);
let labor_cost = effective_labor_rate * (job.labor.minutes / 60.0) * quantity;
let mut hardware_cost = 0.0;
let hardware_breakdown: Vec<HardwareLine> = job
.hardware
.iter()
.map(|h| {
let cost = h.cost * h.quantity as f64 * quantity;
hardware_cost += cost;
HardwareLine {
name: h.name.clone(),
quantity: h.quantity,
unit_cost: h.cost,
cost: round_to(cost, COST_DP),
}
})
.collect();
let total_cost = material_cost + wear_cost + electricity_cost + labor_cost + hardware_cost;
let margin_percentages = job
.margin_percentages_override
.as_deref()
.unwrap_or(&defaults.margin_percentages);
let suggested_prices = margin_percentages
.iter()
.map(|&margin_percent| {
let remainder = 1.0 - margin_percent / 100.0;
if remainder <= 0.0 {
return Err(CalcError::InvalidMargin(margin_percent));
}
let price = total_cost / remainder;
Ok(SuggestedPrice {
margin_percent,
price: round_to(price, PRICE_DP),
unit_price: round_to(price / quantity, PRICE_DP),
})
})
.collect::<Result<Vec<_>, _>>()?;
Ok(CostBreakdown {
quantity: job.quantity,
plate_breakdown,
print_time_seconds: total_print_time_seconds,
print_time_rate: round_to(print_time_rate, COST_DP),
material_cost: round_to(material_cost, COST_DP),
material_breakdown,
wear_cost: round_to(wear_cost, COST_DP),
electricity_cost: round_to(electricity_cost, COST_DP),
labor_cost: round_to(labor_cost, COST_DP),
hardware_cost: round_to(hardware_cost, COST_DP),
hardware_breakdown,
total_cost: round_to(total_cost, COST_DP),
unit_cost: round_to(total_cost / quantity, COST_DP),
suggested_prices,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::formats::slice_summary::{FilamentUsage, PlateSummary, SliceMeta, SourceFormat};
use crate::model::job::{HardwareItem, JobSource, LaborCost, ProfileRef, SINGLE_MATERIAL_SLOT};
use crate::model::printer::{PrinterKind, WearProfile};
fn bambu_a1() -> PrinterProfile {
PrinterProfile {
name: "bambu-a1".into(),
kind: PrinterKind::Fdm,
wear: WearProfile {
purchase_price: 800.0,
additional_upfront_cost: 0.0,
estimated_life_years: 5.0,
estimated_uptime_percent: 0.5,
annual_maintenance_cost: 80.0,
power_consumption_watts: 160.0,
cost_buffer_factor: 1.3,
print_time_rate_override: None,
},
}
}
fn filament(name: &str, price: f64) -> MaterialProfile {
MaterialProfile {
name: name.into(),
kind: MaterialKind::Filament,
price,
efficiency_factor: 1.1,
color: None,
vendor: None,
}
}
fn resin_printer() -> PrinterProfile {
PrinterProfile {
name: "sl1-printer".into(),
kind: PrinterKind::Resin,
wear: bambu_a1().wear,
}
}
fn resin(name: &str, price: f64) -> MaterialProfile {
MaterialProfile {
kind: MaterialKind::Resin,
..filament(name, price)
}
}
fn usage(slot_id: &str, grams: f64) -> FilamentUsage {
FilamentUsage {
slot_id: slot_id.into(),
material_type: Some("PLA".into()),
grams: Some(grams),
..FilamentUsage::default()
}
}
fn plate(index: u32, seconds: f64, filament_usage: Vec<FilamentUsage>) -> PlateSummary {
PlateSummary {
plate_index: index,
print_time_seconds: seconds,
filament_usage,
printer_model: None,
layer_height: None,
extra: HashMap::new(),
}
}
fn slice(format: SourceFormat, plates: Vec<PlateSummary>) -> SliceSummary {
SliceSummary {
source_format: format,
meta: SliceMeta::default(),
plates,
}
}
fn base_job(material: HashMap<String, ProfileRef<MaterialProfile>>, labor_minutes: f64) -> Job {
Job {
name: "test".into(),
source: JobSource::Path("test.gcode.3mf".into()),
plate_selection: None,
printer: ProfileRef::Linked {
name: "bambu-a1".into(),
},
material,
default_material: None,
quantity: 1,
material_efficiency_factor_override: None,
electricity_cost_per_kwh_override: None,
labor: LaborCost {
rate_per_hour: Some(15.0),
minutes: labor_minutes,
},
hardware: Vec::new(),
margin_percentages_override: None,
}
}
fn linked(name: &str) -> ProfileRef<MaterialProfile> {
ProfileRef::Linked { name: name.into() }
}
#[test]
fn wear_and_electricity_rate_per_hour() {
let wear = &bambu_a1().wear;
let capital_cost_per_hour = (wear.purchase_price
+ wear.annual_maintenance_cost * wear.estimated_life_years)
/ (wear.estimated_life_years * HOURS_PER_YEAR * wear.estimated_uptime_percent);
let electrical_cost_per_hour = (wear.power_consumption_watts / 1000.0) * 0.14;
assert!((capital_cost_per_hour - 0.0548).abs() < 1e-3);
assert!((electrical_cost_per_hour - 0.0224).abs() < 1e-4);
let combined = (capital_cost_per_hour + electrical_cost_per_hour) * wear.cost_buffer_factor;
assert!((combined - 0.10).abs() < 5e-3);
}
#[test]
fn documented_machine_rate_example() {
let printer = PrinterProfile {
name: "prusa-p1s".into(),
kind: PrinterKind::Fdm,
wear: WearProfile {
purchase_price: 699.0,
additional_upfront_cost: 0.0,
estimated_life_years: 5.0,
estimated_uptime_percent: 0.50,
annual_maintenance_cost: 80.0,
power_consumption_watts: 105.0,
cost_buffer_factor: 1.3,
print_time_rate_override: None,
},
};
let slice = slice(
SourceFormat::Bambu3mf,
vec![plate(1, 3600.0, vec![usage(SINGLE_MATERIAL_SLOT, 10.0)])],
);
let breakdown = calculate_cost(
&base_job(
HashMap::from([(SINGLE_MATERIAL_SLOT.to_string(), linked("pla-black"))]),
0.0,
),
&slice,
&printer,
&HashMap::from([(
SINGLE_MATERIAL_SLOT.to_string(),
filament("pla-black", 18.99),
)]),
None,
&CostDefaults::default(),
)
.unwrap();
assert_eq!(breakdown.print_time_rate, 0.0843);
assert!((breakdown.wear_cost - 0.050183 * 1.3).abs() < 1e-4);
assert!((breakdown.electricity_cost - 0.0147 * 1.3).abs() < 1e-4);
assert!((breakdown.wear_cost + breakdown.electricity_cost - 0.084347).abs() < 1e-4);
}
#[test]
fn multi_plate_single_filament_matches_worked_example() {
let slice = slice(
SourceFormat::Bambu3mf,
vec![
plate(1, 1037.0, vec![usage(SINGLE_MATERIAL_SLOT, 6.66)]),
plate(2, 988.0, vec![usage(SINGLE_MATERIAL_SLOT, 5.15)]),
],
);
let material_profiles = HashMap::from([(
SINGLE_MATERIAL_SLOT.to_string(),
filament("pla-black", 18.99),
)]);
let job = base_job(
HashMap::from([(SINGLE_MATERIAL_SLOT.to_string(), linked("pla-black"))]),
10.0,
);
let breakdown = calculate_cost(
&job,
&slice,
&bambu_a1(),
&material_profiles,
None,
&CostDefaults::default(),
)
.unwrap();
assert_eq!(breakdown.quantity, 1);
assert_eq!(
breakdown.plate_breakdown,
vec![
PlateLine {
plate_index: 1,
print_time_seconds: 1037.0,
grams: 6.66,
ml: 0.0
},
PlateLine {
plate_index: 2,
print_time_seconds: 988.0,
grams: 5.15,
ml: 0.0
},
]
);
assert!((breakdown.material_cost - 0.2467).abs() < 1e-3);
assert_eq!(breakdown.material_breakdown.len(), 1);
let line = &breakdown.material_breakdown[0];
assert_eq!(line.filament_name, "pla-black");
assert!((line.cost - breakdown.material_cost).abs() < 1e-9);
assert_eq!(line.unit, "g");
assert_eq!(line.unit_price, 18.99);
assert!((line.amount - 12.991).abs() < 1e-4);
assert!((breakdown.wear_cost - 0.0401).abs() < 1e-3);
assert!((breakdown.electricity_cost - 0.0164).abs() < 1e-3);
assert_eq!(breakdown.labor_cost, 2.5);
assert_eq!(breakdown.hardware_cost, 0.0);
assert!(
breakdown.hardware_cost.is_sign_positive(),
"hardware_cost must not be -0.0"
);
assert!(breakdown.hardware_breakdown.is_empty());
assert!((breakdown.total_cost - 2.8031).abs() < 1e-3);
assert_eq!(breakdown.unit_cost, breakdown.total_cost);
assert_eq!(breakdown.print_time_seconds, 2025.0);
assert!((breakdown.print_time_rate - 0.1005).abs() < 1e-3);
}
#[test]
fn suggested_prices_are_margins_not_markups() {
let slice = slice(
SourceFormat::Bambu3mf,
vec![plate(1, 3600.0, vec![usage("1", 100.0)])],
);
let mut job = base_job(HashMap::from([("1".to_string(), linked("pla"))]), 0.0);
job.margin_percentages_override = Some(vec![50.0, 60.0, 70.0]);
let profiles = HashMap::from([("1".to_string(), filament("pla", 20.0))]);
let breakdown = calculate_cost(
&job,
&slice,
&bambu_a1(),
&profiles,
None,
&CostDefaults::default(),
)
.unwrap();
let cost = breakdown.total_cost;
let prices = &breakdown.suggested_prices;
assert_eq!(prices.len(), 3);
assert!((prices[0].price - cost / 0.50).abs() < 0.01);
assert!((prices[1].price - cost / 0.40).abs() < 0.01);
assert!((prices[2].price - cost / 0.30).abs() < 0.01);
assert!((prices[1].price - cost * 1.60).abs() > 0.01);
}
#[test]
fn a_margin_of_100_percent_or_more_is_an_error() {
let slice = slice(
SourceFormat::Bambu3mf,
vec![plate(1, 100.0, vec![usage("1", 1.0)])],
);
let mut job = base_job(HashMap::from([("1".to_string(), linked("pla"))]), 0.0);
job.margin_percentages_override = Some(vec![100.0]);
let profiles = HashMap::from([("1".to_string(), filament("pla", 20.0))]);
let err = calculate_cost(
&job,
&slice,
&bambu_a1(),
&profiles,
None,
&CostDefaults::default(),
)
.unwrap_err();
assert_eq!(err, CalcError::InvalidMargin(100.0));
}
#[test]
fn quantity_scales_the_whole_batch() {
let slice = slice(
SourceFormat::Bambu3mf,
vec![plate(1, 3600.0, vec![usage("1", 20.0)])],
);
let profiles = HashMap::from([("1".to_string(), filament("pla", 20.0))]);
let one = base_job(HashMap::from([("1".to_string(), linked("pla"))]), 12.0);
let mut ten = one.clone();
ten.quantity = 10;
ten.hardware = vec![HardwareItem {
name: "magnet".into(),
cost: 0.10,
quantity: 2,
}];
let mut one = one;
one.hardware = ten.hardware.clone();
let defaults = CostDefaults::default();
let single = calculate_cost(&one, &slice, &bambu_a1(), &profiles, None, &defaults).unwrap();
let batch = calculate_cost(&ten, &slice, &bambu_a1(), &profiles, None, &defaults).unwrap();
assert_eq!(batch.quantity, 10);
for (batched, unit) in [
(batch.material_cost, single.material_cost),
(batch.wear_cost, single.wear_cost),
(batch.electricity_cost, single.electricity_cost),
(batch.labor_cost, single.labor_cost),
(batch.hardware_cost, single.hardware_cost),
(batch.total_cost, single.total_cost),
] {
assert!((batched - unit * 10.0).abs() < 1e-3, "{batched} vs {unit}");
}
assert_eq!(batch.hardware_breakdown[0].quantity, 2);
assert!((batch.hardware_breakdown[0].cost - 2.0).abs() < 1e-9);
assert!((batch.unit_cost - single.total_cost).abs() < 1e-3);
assert!(
(batch.suggested_prices[0].unit_price - single.suggested_prices[0].price).abs() < 0.01
);
assert_eq!(batch.plate_breakdown[0].grams, 20.0);
assert_eq!(batch.print_time_seconds, 3600.0);
}
#[test]
fn zero_quantity_is_an_error() {
let slice = slice(SourceFormat::Bambu3mf, vec![plate(1, 100.0, vec![])]);
let mut job = base_job(HashMap::new(), 0.0);
job.quantity = 0;
let err = calculate_cost(
&job,
&slice,
&bambu_a1(),
&HashMap::new(),
None,
&CostDefaults::default(),
)
.unwrap_err();
assert_eq!(err, CalcError::ZeroQuantity);
}
#[test]
fn zero_lifetime_hours_charges_no_depreciation() {
let mut printer = bambu_a1();
printer.wear.estimated_uptime_percent = 0.0;
let slice = slice(SourceFormat::Bambu3mf, vec![plate(1, 3600.0, vec![])]);
let job = base_job(HashMap::new(), 0.0);
let breakdown = calculate_cost(
&job,
&slice,
&printer,
&HashMap::new(),
None,
&CostDefaults::default(),
)
.unwrap();
assert_eq!(breakdown.wear_cost, 0.0);
assert!(breakdown.electricity_cost > 0.0);
assert!(breakdown.total_cost.is_finite());
}
#[test]
fn print_time_rate_override_replaces_the_computed_rate() {
let mut printer = bambu_a1();
printer.wear.print_time_rate_override = Some(2.50);
let slice = slice(SourceFormat::Bambu3mf, vec![plate(1, 7200.0, vec![])]);
let job = base_job(HashMap::new(), 0.0);
let breakdown = calculate_cost(
&job,
&slice,
&printer,
&HashMap::new(),
None,
&CostDefaults::default(),
)
.unwrap();
assert_eq!(breakdown.print_time_rate, 2.50);
assert_eq!(breakdown.wear_cost, 5.0); assert_eq!(breakdown.electricity_cost, 0.0);
}
#[test]
fn multi_slot_filament_splits_material_cost() {
let slice = slice(
SourceFormat::Bambu3mf,
vec![plate(
1,
1360.0, vec![
usage("1", 6.20),
usage("2", 3.40),
FilamentUsage {
material_type: Some("PETG".into()),
..usage("4", 1.85)
},
FilamentUsage {
material_type: None,
..usage("3", 0.0)
},
],
)],
);
let material_profiles = HashMap::from([
("1".to_string(), filament("pla-black", 18.99)),
("2".to_string(), filament("pla-red", 18.99)),
("4".to_string(), filament("petg-clear", 22.99)),
]);
let job = base_job(
HashMap::from([
("1".to_string(), linked("pla-black")),
("2".to_string(), linked("pla-red")),
("4".to_string(), linked("petg-clear")),
]),
15.0,
);
let breakdown = calculate_cost(
&job,
&slice,
&bambu_a1(),
&material_profiles,
None,
&CostDefaults::default(),
)
.unwrap();
assert!((breakdown.material_cost - 0.2474).abs() < 1e-3);
assert_eq!(breakdown.labor_cost, 3.75);
assert!((breakdown.total_cost - 4.0353).abs() < 1e-3);
let names: Vec<&str> = breakdown
.material_breakdown
.iter()
.map(|l| l.filament_name.as_str())
.collect();
assert_eq!(names, ["petg-clear", "pla-black", "pla-red"]);
let sum: f64 = breakdown.material_breakdown.iter().map(|l| l.cost).sum();
assert!((sum - breakdown.material_cost).abs() < 1e-3);
let pla_black = breakdown
.material_breakdown
.iter()
.find(|l| l.filament_name == "pla-black")
.unwrap();
assert!((pla_black.cost - 0.12955).abs() < 1e-3);
assert!((pla_black.amount - 6.82).abs() < 1e-4); }
#[test]
fn unmapped_slots_are_all_reported_with_what_the_file_said() {
let slice = slice(
SourceFormat::Bambu3mf,
vec![plate(
1,
100.0,
vec![
FilamentUsage {
color: Some("#FF6A13".into()),
..usage("2", 5.0)
},
FilamentUsage {
material_type: Some("PETG".into()),
..usage("4", 1.0)
},
],
)],
);
let job = base_job(HashMap::from([("1".to_string(), linked("pla-black"))]), 1.0);
let err = calculate_cost(
&job,
&slice,
&bambu_a1(),
&HashMap::new(),
None,
&CostDefaults::default(),
)
.unwrap_err();
let CalcError::MissingMaterial(slots) = err else {
panic!("expected MissingMaterial, got {err:?}");
};
assert_eq!(slots.len(), 2);
assert_eq!(slots[0].slot_id, "2");
assert_eq!(slots[0].color.as_deref(), Some("#FF6A13"));
assert_eq!(slots[0].to_string(), "slot 2 (PLA #FF6A13)");
assert_eq!(slots[1].to_string(), "slot 4 (PETG)");
}
#[test]
fn default_material_covers_unmapped_slots() {
let slice = slice(
SourceFormat::Bambu3mf,
vec![plate(1, 3600.0, vec![usage("1", 10.0), usage("2", 10.0)])],
);
let job = base_job(HashMap::from([("1".to_string(), linked("pla-black"))]), 0.0);
let profiles = HashMap::from([("1".to_string(), filament("pla-black", 20.0))]);
let fallback = filament("house-pla", 10.0);
let breakdown = calculate_cost(
&job,
&slice,
&bambu_a1(),
&profiles,
Some(&fallback),
&CostDefaults::default(),
)
.unwrap();
assert!((breakdown.material_cost - 0.33).abs() < 1e-9);
assert_eq!(breakdown.material_breakdown.len(), 2);
}
#[test]
fn unknown_plate_selection_is_an_error() {
let slice = slice(SourceFormat::Bambu3mf, vec![]);
let mut job = base_job(
HashMap::from([(SINGLE_MATERIAL_SLOT.to_string(), linked("pla-black"))]),
1.0,
);
job.plate_selection = Some(3);
let err = calculate_cost(
&job,
&slice,
&bambu_a1(),
&HashMap::new(),
None,
&CostDefaults::default(),
)
.unwrap_err();
assert_eq!(err, CalcError::PlateNotFound(3));
}
fn resin_slice(ml: f64) -> SliceSummary {
slice(
SourceFormat::PrusaSl1,
vec![plate(
1,
3600.0,
vec![FilamentUsage {
slot_id: SINGLE_MATERIAL_SLOT.into(),
ml: Some(ml),
..FilamentUsage::default()
}],
)],
)
}
#[test]
fn ml_usage_is_priced_per_liter() {
let material_profiles =
HashMap::from([(SINGLE_MATERIAL_SLOT.to_string(), resin("grey-resin", 40.0))]);
let job = base_job(
HashMap::from([(SINGLE_MATERIAL_SLOT.to_string(), linked("grey-resin"))]),
0.0,
);
let breakdown = calculate_cost(
&job,
&resin_slice(10.0),
&resin_printer(),
&material_profiles,
None,
&CostDefaults::default(),
)
.unwrap();
assert!((breakdown.material_cost - 0.44).abs() < 1e-9);
let line = &breakdown.material_breakdown[0];
assert_eq!(line.unit, "ml");
assert_eq!(line.unit_price, 40.0);
}
#[test]
fn fdm_printer_against_resin_slice_is_an_error() {
let job = base_job(
HashMap::from([(SINGLE_MATERIAL_SLOT.to_string(), linked("pla-basic"))]),
0.0,
);
let err = calculate_cost(
&job,
&resin_slice(10.0),
&bambu_a1(), &HashMap::new(),
None,
&CostDefaults::default(),
)
.unwrap_err();
assert_eq!(
err,
CalcError::PrinterKindMismatch {
printer_name: "bambu-a1".into(),
printer_kind: PrinterKind::Fdm,
format: SourceFormat::PrusaSl1,
}
);
}
#[test]
fn filament_kind_against_resin_slice_is_an_error() {
let material_profiles = HashMap::from([(
SINGLE_MATERIAL_SLOT.to_string(),
filament("pla-basic", 20.0), )]);
let job = base_job(
HashMap::from([(SINGLE_MATERIAL_SLOT.to_string(), linked("pla-basic"))]),
0.0,
);
let err = calculate_cost(
&job,
&resin_slice(10.0),
&resin_printer(),
&material_profiles,
None,
&CostDefaults::default(),
)
.unwrap_err();
assert_eq!(
err,
CalcError::FilamentKindMismatch {
filament_name: "pla-basic".into(),
filament_kind: MaterialKind::Filament,
format: SourceFormat::PrusaSl1,
}
);
}
#[test]
fn hardware_cost_breaks_down_by_item() {
let slice = slice(SourceFormat::Bambu3mf, vec![plate(1, 600.0, vec![])]);
let mut job = base_job(HashMap::new(), 0.0);
job.hardware = vec![
HardwareItem {
name: "brass insert M3x5".into(),
cost: 0.12,
quantity: 4,
},
HardwareItem {
name: "magnet 6x2mm".into(),
cost: 0.08,
quantity: 2,
},
];
let breakdown = calculate_cost(
&job,
&slice,
&bambu_a1(),
&HashMap::new(),
None,
&CostDefaults::default(),
)
.unwrap();
assert_eq!(
breakdown.hardware_breakdown,
vec![
HardwareLine {
name: "brass insert M3x5".into(),
quantity: 4,
unit_cost: 0.12,
cost: 0.48
},
HardwareLine {
name: "magnet 6x2mm".into(),
quantity: 2,
unit_cost: 0.08,
cost: 0.16
},
]
);
assert!((breakdown.hardware_cost - 0.64).abs() < 1e-9);
}
#[test]
fn results_are_rounded_when_assembled() {
let slice = slice(
SourceFormat::Bambu3mf,
vec![plate(1, 1234.0, vec![usage("1", 7.77)])],
);
let profiles = HashMap::from([("1".to_string(), filament("pla", 18.99))]);
let job = base_job(HashMap::from([("1".to_string(), linked("pla"))]), 7.0);
let b = calculate_cost(
&job,
&slice,
&bambu_a1(),
&profiles,
None,
&CostDefaults::default(),
)
.unwrap();
for value in [
b.material_cost,
b.wear_cost,
b.electricity_cost,
b.total_cost,
] {
assert_eq!(value, round_to(value, COST_DP), "{value} is not 4dp");
}
for price in &b.suggested_prices {
assert_eq!(price.price, round_to(price.price, PRICE_DP));
assert_eq!(price.unit_price, round_to(price.unit_price, PRICE_DP));
}
}
}