use std::collections::HashMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum SourceFormat {
#[serde(rename = "bambu-3mf")]
Bambu3mf,
#[serde(rename = "gcode", alias = "prusa-gcode")]
Gcode,
#[serde(rename = "prusa-bgcode")]
PrusaBgcode,
#[serde(rename = "prusa-sl1")]
PrusaSl1,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
#[non_exhaustive]
pub struct SliceMeta {
#[serde(skip_serializing_if = "Option::is_none")]
pub printer_profile: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub print_profile: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub printer_variant: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub nozzle_diameter: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub slicer: Option<String>,
}
impl SliceMeta {
pub fn with_printer_profile(mut self, value: impl Into<String>) -> Self {
self.printer_profile = Some(value.into());
self
}
pub fn with_print_profile(mut self, value: impl Into<String>) -> Self {
self.print_profile = Some(value.into());
self
}
pub fn with_printer_variant(mut self, value: impl Into<String>) -> Self {
self.printer_variant = Some(value.into());
self
}
pub fn with_nozzle_diameter(mut self, value: f64) -> Self {
self.nozzle_diameter = Some(value);
self
}
pub fn with_slicer(mut self, value: impl Into<String>) -> Self {
self.slicer = Some(value.into());
self
}
pub fn is_empty(&self) -> bool {
*self == SliceMeta::default()
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct SliceSummary {
pub source_format: SourceFormat,
#[serde(default, skip_serializing_if = "SliceMeta::is_empty")]
pub meta: SliceMeta,
pub plates: Vec<PlateSummary>,
}
impl SliceSummary {
pub fn new(source_format: SourceFormat, plates: Vec<PlateSummary>) -> Self {
SliceSummary {
source_format,
meta: SliceMeta::default(),
plates,
}
}
pub fn with_meta(mut self, meta: SliceMeta) -> Self {
self.meta = meta;
self
}
pub fn distinct_filament_slots(&self) -> Vec<String> {
self.slots().into_iter().map(|s| s.slot_id).collect()
}
pub fn slots(&self) -> Vec<SlotUsage> {
let mut slots: Vec<SlotUsage> = Vec::new();
for usage in self.plates.iter().flat_map(|p| &p.filament_usage) {
if usage.grams.unwrap_or(0.0) == 0.0 && usage.ml.unwrap_or(0.0) == 0.0 {
continue; }
match slots.iter_mut().find(|s| s.slot_id == usage.slot_id) {
Some(slot) => slot.add(usage),
None => slots.push(SlotUsage::from_usage(usage)),
}
}
slots.sort_by(|a, b| a.slot_id.cmp(&b.slot_id));
slots
}
pub fn printer_model(&self) -> Option<&str> {
self.plates.iter().find_map(|p| p.printer_model.as_deref())
}
pub fn layer_height(&self) -> Option<f64> {
self.plates.iter().find_map(|p| p.layer_height)
}
pub fn total_print_time_seconds(&self) -> f64 {
self.plates.iter().map(|p| p.print_time_seconds).sum()
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct PlateSummary {
pub plate_index: u32,
pub print_time_seconds: f64,
pub filament_usage: Vec<FilamentUsage>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub printer_model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub layer_height: Option<f64>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub extra: HashMap<String, String>,
}
impl PlateSummary {
pub fn new(
plate_index: u32,
print_time_seconds: f64,
filament_usage: Vec<FilamentUsage>,
) -> Self {
PlateSummary {
plate_index,
print_time_seconds,
filament_usage,
printer_model: None,
layer_height: None,
extra: HashMap::new(),
}
}
pub fn with_printer_model(mut self, value: impl Into<String>) -> Self {
self.printer_model = Some(value.into());
self
}
pub fn with_layer_height(mut self, value: f64) -> Self {
self.layer_height = Some(value);
self
}
pub fn with_extra(mut self, extra: HashMap<String, String>) -> Self {
self.extra = extra;
self
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct FilamentUsage {
pub slot_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub material_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vendor: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub price: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub density: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub meters: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub grams: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ml: Option<f64>,
}
impl FilamentUsage {
pub fn grams(slot_id: impl Into<String>, grams: f64) -> Self {
FilamentUsage {
slot_id: slot_id.into(),
grams: Some(grams),
..Default::default()
}
}
pub fn ml(slot_id: impl Into<String>, ml: f64) -> Self {
FilamentUsage {
slot_id: slot_id.into(),
ml: Some(ml),
..Default::default()
}
}
pub fn with_material_type(mut self, value: impl Into<String>) -> Self {
self.material_type = Some(value.into());
self
}
pub fn with_color(mut self, value: impl Into<String>) -> Self {
self.color = Some(value.into());
self
}
pub fn with_profile_name(mut self, value: impl Into<String>) -> Self {
self.profile_name = Some(value.into());
self
}
pub fn with_vendor(mut self, value: impl Into<String>) -> Self {
self.vendor = Some(value.into());
self
}
pub fn with_price(mut self, value: f64) -> Self {
self.price = Some(value);
self
}
pub fn with_density(mut self, value: f64) -> Self {
self.density = Some(value);
self
}
pub fn with_meters(mut self, value: f64) -> Self {
self.meters = Some(value);
self
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct SlotUsage {
pub slot_id: String,
pub material_type: Option<String>,
pub color: Option<String>,
pub profile_name: Option<String>,
pub vendor: Option<String>,
pub price: Option<f64>,
pub grams: f64,
pub ml: f64,
}
impl SlotUsage {
fn from_usage(usage: &FilamentUsage) -> Self {
SlotUsage {
slot_id: usage.slot_id.clone(),
material_type: nonempty(usage.material_type.as_deref()),
color: nonempty(usage.color.as_deref()),
profile_name: nonempty(usage.profile_name.as_deref()),
vendor: nonempty(usage.vendor.as_deref()),
price: usage.price,
grams: usage.grams.unwrap_or(0.0),
ml: usage.ml.unwrap_or(0.0),
}
}
fn add(&mut self, usage: &FilamentUsage) {
self.grams += usage.grams.unwrap_or(0.0);
self.ml += usage.ml.unwrap_or(0.0);
fill(&mut self.material_type, usage.material_type.as_deref());
fill(&mut self.color, usage.color.as_deref());
fill(&mut self.profile_name, usage.profile_name.as_deref());
fill(&mut self.vendor, usage.vendor.as_deref());
if self.price.is_none() {
self.price = usage.price;
}
}
pub fn label(&self) -> String {
let mut parts: Vec<&str> = Vec::new();
if let Some(name) = self.profile_name.as_deref().filter(|s| !s.is_empty()) {
parts.push(name);
} else if let Some(kind) = self.material_type.as_deref().filter(|s| !s.is_empty()) {
parts.push(kind);
}
if let Some(color) = self.color.as_deref().filter(|s| !s.is_empty()) {
parts.push(color);
}
if parts.is_empty() {
format!("slot {}", self.slot_id)
} else {
format!("slot {} ({})", self.slot_id, parts.join(" "))
}
}
}
fn nonempty(value: Option<&str>) -> Option<String> {
value.filter(|s| !s.is_empty()).map(String::from)
}
fn fill(target: &mut Option<String>, value: Option<&str>) {
if target.is_none() {
*target = nonempty(value);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn usage(slot_id: &str, grams: f64) -> FilamentUsage {
FilamentUsage {
slot_id: slot_id.into(),
grams: Some(grams),
..FilamentUsage::default()
}
}
fn plate(index: u32, usage: Vec<FilamentUsage>) -> PlateSummary {
PlateSummary {
plate_index: index,
print_time_seconds: 100.0,
filament_usage: usage,
printer_model: None,
layer_height: None,
extra: HashMap::new(),
}
}
#[test]
fn distinct_filament_slots_dedupes_sorts_and_skips_zero_usage() {
let summary = SliceSummary {
source_format: SourceFormat::Bambu3mf,
meta: SliceMeta::default(),
plates: vec![
plate(1, vec![usage("2", 1.0), usage("1", 2.0), usage("3", 0.0)]),
plate(2, vec![usage("1", 1.0)]),
],
};
assert_eq!(
summary.distinct_filament_slots(),
vec!["1".to_string(), "2".to_string()]
);
}
#[test]
fn slots_aggregate_usage_and_carry_slicer_metadata() {
let described = FilamentUsage {
slot_id: "1".into(),
material_type: Some("PLA".into()),
color: Some("#AE6342".into()),
profile_name: Some("Bambu PLA Basic @BBL P2S".into()),
vendor: Some("Bambu Lab".into()),
price: Some(24.99),
grams: Some(4.0),
..FilamentUsage::default()
};
let summary = SliceSummary {
source_format: SourceFormat::Bambu3mf,
meta: SliceMeta::default(),
plates: vec![plate(1, vec![described]), plate(2, vec![usage("1", 2.5)])],
};
let slots = summary.slots();
assert_eq!(slots.len(), 1);
assert_eq!(slots[0].grams, 6.5);
assert_eq!(slots[0].price, Some(24.99));
assert_eq!(
slots[0].profile_name.as_deref(),
Some("Bambu PLA Basic @BBL P2S")
);
assert_eq!(
slots[0].label(),
"slot 1 (Bambu PLA Basic @BBL P2S #AE6342)"
);
}
#[test]
fn bare_slot_label_is_just_the_slot_id() {
let summary = SliceSummary {
source_format: SourceFormat::Gcode,
meta: SliceMeta::default(),
plates: vec![plate(1, vec![usage("0", 1.0)])],
};
assert_eq!(summary.slots()[0].label(), "slot 0");
}
#[test]
fn empty_meta_is_skipped_when_serializing() {
let summary = SliceSummary {
source_format: SourceFormat::Gcode,
meta: SliceMeta::default(),
plates: vec![plate(1, vec![usage("1", 1.0)])],
};
let text = toml::to_string(&summary).unwrap();
assert!(!text.contains("meta"));
assert_eq!(toml::from_str::<SliceSummary>(&text).unwrap(), summary);
}
}