Skip to main content

lib3mf_core/parser/
bambu_config.rs

1//! Bambu Studio config file parsers.
2//!
3//! Parses the vendor-specific configuration files embedded in Bambu Studio 3MF archives:
4//! - `slice_info.config` (XML): Print time/weight estimates, filament usage, slicer warnings
5//! - `model_settings.config` (XML): Per-object metadata, parts, plates, assembly transforms
6//! - `project_settings.config` (JSON): Printer model, layer height, filament settings
7//! - Per-profile configs (JSON): `filament_settings_N.config`, `machine_settings_N.config`, etc.
8//!
9//! All parsers handle missing or malformed input gracefully without returning errors,
10//! following the principle that vendor data enrichment should not block model loading.
11
12use crate::error::Result;
13use crate::model::stats::{
14    AssemblyItem, BambuMeshStat, BambuObjectMetadata, BambuPartMetadata, BambuProfileConfig,
15    BambuProjectSettings, PartSubtype, PlateInfo, PlateModelInstance, SlicerWarning,
16};
17use crate::parser::xml_parser::{XmlParser, get_attribute};
18use quick_xml::events::Event;
19use serde_json::Value;
20use std::io::Cursor;
21
22// ── Slice info return type ────────────────────────────────────────────────────
23
24/// Parsed data from `Metadata/slice_info.config`.
25#[derive(Debug, Clone, Default)]
26pub struct SliceInfoData {
27    /// Slicer client type, e.g. "slicer"
28    pub client_type: Option<String>,
29    /// Slicer client version, e.g. "01.10.02.73"
30    pub client_version: Option<String>,
31    /// Per-plate slicing results
32    pub plates: Vec<SlicePlateInfo>,
33}
34
35/// Slicing results for a single plate.
36#[derive(Debug, Clone, Default)]
37pub struct SlicePlateInfo {
38    /// Plate index (1-based).
39    pub id: u32,
40    /// Estimated print time in seconds.
41    pub prediction: Option<u32>, // seconds
42    /// Estimated total filament weight in grams.
43    pub weight: Option<f32>, // grams
44    /// Per-filament usage statistics.
45    pub filaments: Vec<SliceFilamentUsage>,
46    /// Slicer warnings for this plate.
47    pub warnings: Vec<SlicerWarning>,
48    /// Objects included on this plate.
49    pub objects: Vec<SliceObjectInfo>,
50}
51
52/// Per-filament usage data for a plate.
53#[derive(Debug, Clone, Default)]
54pub struct SliceFilamentUsage {
55    /// Filament slot index.
56    pub id: u32,
57    /// AMS tray info index.
58    pub tray_info_idx: Option<String>,
59    /// Filament type string (e.g., `"PLA"`).
60    pub type_: Option<String>,
61    /// Display color in hex format.
62    pub color: Option<String>,
63    /// Filament used in meters.
64    pub used_m: Option<f32>,
65    /// Filament used in grams.
66    pub used_g: Option<f32>,
67}
68
69/// Object participation record within a plate.
70#[derive(Debug, Clone, Default)]
71pub struct SliceObjectInfo {
72    /// Object resource ID.
73    pub id: u32,
74    /// Object display name.
75    pub name: Option<String>,
76}
77
78// ── Model settings return type ────────────────────────────────────────────────
79
80/// Parsed data from `Metadata/model_settings.config`.
81#[derive(Debug, Clone, Default)]
82pub struct ModelSettingsData {
83    /// Plate layout and gcode/thumbnail paths
84    pub plates: Vec<PlateInfo>,
85    /// Per-object metadata (name, extruder, parts, overrides)
86    pub objects: Vec<BambuObjectMetadata>,
87    /// Assembly transforms for build instances
88    pub assembly: Vec<AssemblyItem>,
89}
90
91// ── parse_slice_info ──────────────────────────────────────────────────────────
92
93/// Parse `Metadata/slice_info.config` (XML).
94///
95/// Returns [`SliceInfoData`] on success. Returns `Ok(Default::default())` on
96/// empty or malformed content so callers can always proceed.
97pub fn parse_slice_info(content: &[u8]) -> Result<SliceInfoData> {
98    if content.is_empty() {
99        return Ok(SliceInfoData::default());
100    }
101
102    let mut parser = XmlParser::new(Cursor::new(content));
103    let mut data = SliceInfoData::default();
104
105    loop {
106        match parser.read_next_event()? {
107            Event::Start(e) | Event::Empty(e) => {
108                match e.name().as_ref() {
109                    b"header_item" => {
110                        // Flat header items directly under <header>
111                        let key = get_attribute(&e, b"key");
112                        let value = get_attribute(&e, b"value");
113                        if let (Some(k), Some(v)) = (key, value) {
114                            match k.as_ref() {
115                                "X-BBL-Client-Type" => {
116                                    data.client_type = Some(v.into_owned());
117                                }
118                                "X-BBL-Client-Version" => {
119                                    data.client_version = Some(v.into_owned());
120                                }
121                                _ => {}
122                            }
123                        }
124                    }
125                    b"plate" => {
126                        // Parse <plate> element and its children
127                        let plate = parse_slice_plate(&mut parser)?;
128                        data.plates.push(plate);
129                    }
130                    _ => {}
131                }
132            }
133            Event::Eof => break,
134            _ => {}
135        }
136    }
137
138    Ok(data)
139}
140
141fn parse_slice_plate(parser: &mut XmlParser<Cursor<&[u8]>>) -> Result<SlicePlateInfo> {
142    let mut plate = SlicePlateInfo::default();
143
144    loop {
145        match parser.read_next_event()? {
146            Event::Empty(e) | Event::Start(e) => match e.name().as_ref() {
147                b"metadata" => {
148                    let key = get_attribute(&e, b"key");
149                    let value = get_attribute(&e, b"value");
150                    if let (Some(k), Some(v)) = (key, value) {
151                        match k.as_ref() {
152                            "index" => {
153                                if let Ok(id) = v.parse::<u32>() {
154                                    plate.id = id;
155                                }
156                            }
157                            "prediction" => {
158                                plate.prediction = v.parse::<u32>().ok();
159                            }
160                            "weight" => {
161                                plate.weight = v.parse::<f32>().ok();
162                            }
163                            _ => {}
164                        }
165                    }
166                }
167                b"filament" => {
168                    let id = get_attribute(&e, b"id")
169                        .and_then(|v| v.parse::<u32>().ok())
170                        .unwrap_or(0);
171                    let tray_info_idx = get_attribute(&e, b"tray_info_idx").map(|v| v.into_owned());
172                    let type_ = get_attribute(&e, b"type").map(|v| v.into_owned());
173                    let color = get_attribute(&e, b"color").map(|v| v.into_owned());
174                    let used_m = get_attribute(&e, b"used_m").and_then(|v| v.parse::<f32>().ok());
175                    let used_g = get_attribute(&e, b"used_g").and_then(|v| v.parse::<f32>().ok());
176                    plate.filaments.push(SliceFilamentUsage {
177                        id,
178                        tray_info_idx,
179                        type_,
180                        color,
181                        used_m,
182                        used_g,
183                    });
184                }
185                b"warning" => {
186                    let msg = get_attribute(&e, b"msg")
187                        .map(|v| v.into_owned())
188                        .unwrap_or_default();
189                    let level = get_attribute(&e, b"level").map(|v| v.into_owned());
190                    let error_code = get_attribute(&e, b"error_code").map(|v| v.into_owned());
191                    plate.warnings.push(SlicerWarning {
192                        msg,
193                        level,
194                        error_code,
195                    });
196                }
197                b"object" => {
198                    let id = get_attribute(&e, b"identify_id")
199                        .and_then(|v| v.parse::<u32>().ok())
200                        .unwrap_or(0);
201                    let name = get_attribute(&e, b"name").map(|v| v.into_owned());
202                    plate.objects.push(SliceObjectInfo { id, name });
203                }
204                _ => {}
205            },
206            Event::End(end) if end.name().as_ref() == b"plate" => break,
207            Event::Eof => break,
208            _ => {}
209        }
210    }
211
212    Ok(plate)
213}
214
215// ── parse_model_settings ──────────────────────────────────────────────────────
216
217/// Parse `Metadata/model_settings.config` (XML).
218///
219/// Returns enriched [`ModelSettingsData`] containing plates, objects, and assembly.
220/// Returns `Ok(Default::default())` on empty or malformed content.
221pub fn parse_model_settings(content: &[u8]) -> Result<ModelSettingsData> {
222    if content.is_empty() {
223        return Ok(ModelSettingsData::default());
224    }
225
226    let mut parser = XmlParser::new(Cursor::new(content));
227    let mut data = ModelSettingsData::default();
228
229    loop {
230        match parser.read_next_event()? {
231            Event::Start(e) => match e.name().as_ref() {
232                b"object" => {
233                    let id = get_attribute(&e, b"id")
234                        .and_then(|v| v.parse::<u32>().ok())
235                        .unwrap_or(0);
236                    let obj = parse_model_object(&mut parser, id)?;
237                    data.objects.push(obj);
238                }
239                b"plate" => {
240                    let plate = parse_model_plate(&mut parser)?;
241                    data.plates.push(plate);
242                }
243                b"assemble" => {
244                    let items = parse_assemble(&mut parser)?;
245                    data.assembly.extend(items);
246                }
247                _ => {}
248            },
249            Event::Eof => break,
250            _ => {}
251        }
252    }
253
254    Ok(data)
255}
256
257fn parse_model_object(
258    parser: &mut XmlParser<Cursor<&[u8]>>,
259    id: u32,
260) -> Result<BambuObjectMetadata> {
261    let mut obj = BambuObjectMetadata {
262        id,
263        ..Default::default()
264    };
265
266    loop {
267        match parser.read_next_event()? {
268            Event::Empty(e) | Event::Start(e) => {
269                match e.name().as_ref() {
270                    b"metadata" => {
271                        // Two forms:
272                        // <metadata key="name" value="..." />  → keyed metadata
273                        // <metadata face_count="225154"/>      → attribute-style metadata (no key attr)
274                        let key = get_attribute(&e, b"key");
275                        let value = get_attribute(&e, b"value");
276                        if let Some(k) = key {
277                            if let Some(v) = value {
278                                match k.as_ref() {
279                                    "name" => obj.name = Some(v.into_owned()),
280                                    "extruder" => {
281                                        obj.extruder = v.parse::<u32>().ok();
282                                    }
283                                    _ => {}
284                                }
285                            }
286                        } else {
287                            // attribute-style: <metadata face_count="N"/>
288                            if let Some(fc) = get_attribute(&e, b"face_count") {
289                                obj.face_count = fc.parse::<u64>().ok();
290                            }
291                        }
292                    }
293                    b"part" => {
294                        let part_id = get_attribute(&e, b"id")
295                            .and_then(|v| v.parse::<u32>().ok())
296                            .unwrap_or(0);
297                        let subtype = get_attribute(&e, b"subtype")
298                            .map(|v| PartSubtype::parse(&v))
299                            .unwrap_or_default();
300                        // <part> has a full Start event (not Empty), parse its children
301                        let part = parse_part(parser, part_id, subtype)?;
302                        obj.parts.push(part);
303                    }
304                    _ => {}
305                }
306            }
307            Event::End(end) if end.name().as_ref() == b"object" => break,
308            Event::Eof => break,
309            _ => {}
310        }
311    }
312
313    Ok(obj)
314}
315
316fn parse_part(
317    parser: &mut XmlParser<Cursor<&[u8]>>,
318    id: u32,
319    subtype: PartSubtype,
320) -> Result<BambuPartMetadata> {
321    let mut part = BambuPartMetadata {
322        id,
323        subtype,
324        ..Default::default()
325    };
326
327    // Known metadata keys that go into dedicated fields (not print_overrides)
328    const KNOWN_KEYS: &[&str] = &[
329        "name",
330        "matrix",
331        "source_object_id",
332        "source_volume_id",
333        "source_offset_x",
334        "source_offset_y",
335        "source_offset_z",
336        "source_in_inches",
337    ];
338
339    loop {
340        match parser.read_next_event()? {
341            Event::Empty(e) | Event::Start(e) => match e.name().as_ref() {
342                b"metadata" => {
343                    let key = get_attribute(&e, b"key");
344                    let value = get_attribute(&e, b"value");
345                    if let (Some(k), Some(v)) = (key, value) {
346                        let k_str = k.as_ref();
347                        match k_str {
348                            "name" => part.name = Some(v.into_owned()),
349                            "matrix" => part.matrix = Some(v.into_owned()),
350                            "source_volume_id" => {
351                                let src = part.source.get_or_insert_with(Default::default);
352                                src.volume_id = v.parse::<u32>().ok();
353                            }
354                            "source_offset_x" => {
355                                let src = part.source.get_or_insert_with(Default::default);
356                                src.offset_x = v.parse::<f64>().ok();
357                            }
358                            "source_offset_y" => {
359                                let src = part.source.get_or_insert_with(Default::default);
360                                src.offset_y = v.parse::<f64>().ok();
361                            }
362                            "source_offset_z" => {
363                                let src = part.source.get_or_insert_with(Default::default);
364                                src.offset_z = v.parse::<f64>().ok();
365                            }
366                            other => {
367                                if !KNOWN_KEYS.contains(&other) {
368                                    part.print_overrides
369                                        .insert(other.to_string(), v.into_owned());
370                                }
371                            }
372                        }
373                    }
374                }
375                b"mesh_stat" => {
376                    let edges_fixed =
377                        get_attribute(&e, b"edges_fixed").and_then(|v| v.parse::<u32>().ok());
378                    let degenerate_facets =
379                        get_attribute(&e, b"degenerate_facets").and_then(|v| v.parse::<u32>().ok());
380                    let facets_removed =
381                        get_attribute(&e, b"facets_removed").and_then(|v| v.parse::<u32>().ok());
382                    let facets_reversed =
383                        get_attribute(&e, b"facets_reversed").and_then(|v| v.parse::<u32>().ok());
384                    let backwards_edges =
385                        get_attribute(&e, b"backwards_edges").and_then(|v| v.parse::<u32>().ok());
386                    part.mesh_stat = Some(BambuMeshStat {
387                        edges_fixed,
388                        degenerate_facets,
389                        facets_removed,
390                        facets_reversed,
391                        backwards_edges,
392                    });
393                }
394                _ => {}
395            },
396            Event::End(end) if end.name().as_ref() == b"part" => break,
397            Event::Eof => break,
398            _ => {}
399        }
400    }
401
402    Ok(part)
403}
404
405fn parse_model_plate(parser: &mut XmlParser<Cursor<&[u8]>>) -> Result<PlateInfo> {
406    let mut plate = PlateInfo::default();
407
408    loop {
409        match parser.read_next_event()? {
410            Event::Empty(e) | Event::Start(e) => match e.name().as_ref() {
411                b"metadata" => {
412                    let key = get_attribute(&e, b"key");
413                    let value = get_attribute(&e, b"value");
414                    if let (Some(k), Some(v)) = (key, value) {
415                        match k.as_ref() {
416                            "plater_id" => {
417                                if let Ok(id) = v.parse::<u32>() {
418                                    plate.id = id;
419                                }
420                            }
421                            "plater_name" => {
422                                if !v.is_empty() {
423                                    plate.name = Some(v.into_owned());
424                                }
425                            }
426                            "locked" => {
427                                plate.locked = v == "true";
428                            }
429                            "gcode_file" => {
430                                plate.gcode_file = Some(v.into_owned());
431                            }
432                            "thumbnail_file" => {
433                                plate.thumbnail_file = Some(v.into_owned());
434                            }
435                            _ => {}
436                        }
437                    }
438                }
439                b"model_instance" => {
440                    let instance = parse_model_instance(parser)?;
441                    plate.items.push(instance);
442                }
443                _ => {}
444            },
445            Event::End(end) if end.name().as_ref() == b"plate" => break,
446            Event::Eof => break,
447            _ => {}
448        }
449    }
450
451    Ok(plate)
452}
453
454fn parse_model_instance(parser: &mut XmlParser<Cursor<&[u8]>>) -> Result<PlateModelInstance> {
455    let mut instance = PlateModelInstance::default();
456
457    loop {
458        match parser.read_next_event()? {
459            Event::Empty(e) | Event::Start(e) => {
460                if e.name().as_ref() == b"metadata" {
461                    let key = get_attribute(&e, b"key");
462                    let value = get_attribute(&e, b"value");
463                    if let (Some(k), Some(v)) = (key, value) {
464                        match k.as_ref() {
465                            "object_id" => {
466                                instance.object_id = v.parse::<u32>().unwrap_or(0);
467                            }
468                            "instance_id" => {
469                                instance.instance_id = v.parse::<u32>().unwrap_or(0);
470                            }
471                            "identify_id" => {
472                                instance.identify_id = v.parse::<u32>().ok();
473                            }
474                            _ => {}
475                        }
476                    }
477                }
478            }
479            Event::End(end) if end.name().as_ref() == b"model_instance" => break,
480            Event::Eof => break,
481            _ => {}
482        }
483    }
484
485    Ok(instance)
486}
487
488fn parse_assemble(parser: &mut XmlParser<Cursor<&[u8]>>) -> Result<Vec<AssemblyItem>> {
489    let mut items = Vec::new();
490
491    loop {
492        match parser.read_next_event()? {
493            Event::Empty(e) | Event::Start(e) => {
494                if e.name().as_ref() == b"assemble_item" {
495                    let object_id = get_attribute(&e, b"object_id")
496                        .and_then(|v| v.parse::<u32>().ok())
497                        .unwrap_or(0);
498                    let instance_count = get_attribute(&e, b"instance_count")
499                        .and_then(|v| v.parse::<u32>().ok())
500                        .unwrap_or(1);
501                    let transform = get_attribute(&e, b"transform").map(|v| v.into_owned());
502                    let offset = get_attribute(&e, b"offset").map(|v| v.into_owned());
503                    items.push(AssemblyItem {
504                        object_id,
505                        instance_count,
506                        transform,
507                        offset,
508                    });
509                }
510            }
511            Event::End(end) if end.name().as_ref() == b"assemble" => break,
512            Event::Eof => break,
513            _ => {}
514        }
515    }
516
517    Ok(items)
518}
519
520// ── parse_project_settings ────────────────────────────────────────────────────
521
522/// Keys containing G-code blobs to skip (avoid large allocations in extras).
523const GCODE_BLOB_KEYS: &[&str] = &[
524    "change_filament_gcode",
525    "machine_end_gcode",
526    "machine_start_gcode",
527    "time_lapse_gcode",
528    "before_layer_change_gcode",
529    "layer_change_gcode",
530    "printer_start_gcode",
531    "printer_end_gcode",
532    "toolchange_gcode",
533    "gcode_end",
534    "gcode_start",
535];
536
537/// Parse `Metadata/project_settings.config` (JSON).
538///
539/// Extracts typed fields for the most important settings. All other fields go
540/// into `extras`, except G-code blob keys which are silently skipped.
541///
542/// Returns `Ok(Default::default())` on empty or invalid JSON.
543pub fn parse_project_settings(content: &[u8]) -> Result<BambuProjectSettings> {
544    if content.is_empty() {
545        return Ok(BambuProjectSettings::default());
546    }
547
548    let Ok(text) = std::str::from_utf8(content) else {
549        return Ok(BambuProjectSettings::default());
550    };
551
552    let Ok(json): std::result::Result<serde_json::Map<String, Value>, _> =
553        serde_json::from_str(text)
554    else {
555        return Ok(BambuProjectSettings::default());
556    };
557
558    let mut settings = BambuProjectSettings::default();
559
560    for (key, value) in &json {
561        // Skip G-code blobs
562        if GCODE_BLOB_KEYS.contains(&key.as_str()) {
563            continue;
564        }
565
566        match key.as_str() {
567            "printer_model" => {
568                settings.printer_model = value.as_str().map(|s| s.to_string());
569            }
570            "inherits" => {
571                settings.printer_inherits = value.as_str().map(|s| s.to_string());
572            }
573            "bed_type" | "curr_bed_type" => {
574                // bed_type may not exist; prefer curr_bed_type if present
575                if settings.bed_type.is_none() {
576                    settings.bed_type = json_to_string_opt(value);
577                }
578            }
579            "layer_height" => {
580                settings.layer_height = json_to_f32(value);
581            }
582            "first_layer_height" => {
583                settings.first_layer_height = json_to_f32(value);
584            }
585            "filament_type" => {
586                settings.filament_type = json_to_string_vec(value);
587            }
588            "filament_colour" => {
589                settings.filament_colour = json_to_string_vec(value);
590            }
591            "nozzle_diameter" => {
592                settings.nozzle_diameter = json_to_f32_vec(value);
593            }
594            "print_sequence" => {
595                settings.print_sequence = value.as_str().map(|s| s.to_string());
596            }
597            "wall_loops" => {
598                settings.wall_loops = json_to_u32(value);
599            }
600            "sparse_infill_density" => {
601                settings.infill_density = value.as_str().map(|s| s.to_string());
602            }
603            "support_type" => {
604                settings.support_type = value.as_str().map(|s| s.to_string());
605            }
606            _ => {
607                // Everything else → extras
608                settings.extras.insert(key.clone(), value.clone());
609            }
610        }
611    }
612
613    // curr_bed_type overrides bed_type if both present
614    if let Some(curr) = json.get("curr_bed_type").and_then(|v| v.as_str()) {
615        settings.bed_type = Some(curr.to_string());
616    }
617
618    Ok(settings)
619}
620
621// ── parse_profile_config ──────────────────────────────────────────────────────
622
623/// Parse a per-profile config JSON file (`filament_settings_N.config`, etc.).
624///
625/// Extracts `inherits` and `name` fields; everything else (minus gcode blobs)
626/// goes into `extras`.
627///
628/// - `config_type`: "filament", "machine", or "process"
629/// - `index`: the N in the filename
630pub fn parse_profile_config(
631    content: &[u8],
632    config_type: &str,
633    index: u32,
634) -> Result<BambuProfileConfig> {
635    if content.is_empty() {
636        return Ok(BambuProfileConfig {
637            config_type: config_type.to_string(),
638            index,
639            ..Default::default()
640        });
641    }
642
643    let Ok(text) = std::str::from_utf8(content) else {
644        return Ok(BambuProfileConfig {
645            config_type: config_type.to_string(),
646            index,
647            ..Default::default()
648        });
649    };
650
651    let Ok(json): std::result::Result<serde_json::Map<String, Value>, _> =
652        serde_json::from_str(text)
653    else {
654        return Ok(BambuProfileConfig {
655            config_type: config_type.to_string(),
656            index,
657            ..Default::default()
658        });
659    };
660
661    let mut profile = BambuProfileConfig {
662        config_type: config_type.to_string(),
663        index,
664        ..Default::default()
665    };
666
667    for (key, value) in &json {
668        if GCODE_BLOB_KEYS.contains(&key.as_str()) {
669            continue;
670        }
671        match key.as_str() {
672            "inherits" => {
673                profile.inherits = value.as_str().map(|s| s.to_string());
674            }
675            "name" => {
676                profile.name = value.as_str().map(|s| s.to_string());
677            }
678            _ => {
679                profile.extras.insert(key.clone(), value.clone());
680            }
681        }
682    }
683
684    Ok(profile)
685}
686
687// ── JSON helpers ──────────────────────────────────────────────────────────────
688
689fn json_to_string_opt(v: &Value) -> Option<String> {
690    match v {
691        Value::String(s) => Some(s.clone()),
692        Value::Array(arr) => arr.first().and_then(|x| x.as_str()).map(|s| s.to_string()),
693        _ => None,
694    }
695}
696
697fn json_to_f32(v: &Value) -> Option<f32> {
698    match v {
699        Value::Number(n) => n.as_f64().map(|x| x as f32),
700        Value::String(s) => s.trim().parse::<f32>().ok(),
701        _ => None,
702    }
703}
704
705fn json_to_u32(v: &Value) -> Option<u32> {
706    match v {
707        Value::Number(n) => n.as_u64().map(|x| x as u32),
708        Value::String(s) => s.trim().parse::<u32>().ok(),
709        _ => None,
710    }
711}
712
713fn json_to_string_vec(v: &Value) -> Vec<String> {
714    match v {
715        Value::Array(arr) => arr
716            .iter()
717            .filter_map(|x| x.as_str().map(|s| s.to_string()))
718            .collect(),
719        Value::String(s) => vec![s.clone()],
720        _ => vec![],
721    }
722}
723
724fn json_to_f32_vec(v: &Value) -> Vec<f32> {
725    match v {
726        Value::Array(arr) => arr
727            .iter()
728            .filter_map(|x| match x {
729                Value::Number(n) => n.as_f64().map(|f| f as f32),
730                Value::String(s) => s.trim().parse::<f32>().ok(),
731                _ => None,
732            })
733            .collect(),
734        Value::Number(n) => n.as_f64().map(|f| vec![f as f32]).unwrap_or_default(),
735        Value::String(s) => s.trim().parse::<f32>().map(|f| vec![f]).unwrap_or_default(),
736        _ => vec![],
737    }
738}
739
740// ── Unit tests ────────────────────────────────────────────────────────────────
741
742#[cfg(test)]
743mod tests {
744    use super::*;
745
746    // ── parse_slice_info ──
747
748    #[test]
749    fn test_parse_slice_info_empty() {
750        let result = parse_slice_info(b"").unwrap();
751        assert!(result.client_type.is_none());
752        assert!(result.plates.is_empty());
753    }
754
755    #[test]
756    fn test_parse_slice_info_invalid_xml() {
757        // Should not panic - just returns an error or partial result
758        let _ = parse_slice_info(b"not xml <<< garbage >>>");
759    }
760
761    #[test]
762    fn test_parse_slice_info_header_only() {
763        let xml = br#"<?xml version="1.0" encoding="UTF-8"?>
764<config>
765  <header>
766    <header_item key="X-BBL-Client-Type" value="slicer"/>
767    <header_item key="X-BBL-Client-Version" value="02.02.01.60"/>
768  </header>
769</config>"#;
770        let result = parse_slice_info(xml).unwrap();
771        assert_eq!(result.client_type.as_deref(), Some("slicer"));
772        assert_eq!(result.client_version.as_deref(), Some("02.02.01.60"));
773        assert!(result.plates.is_empty(), "SimplePyramid has no plates");
774    }
775
776    #[test]
777    fn test_parse_slice_info_with_plate() {
778        let xml = br##"<?xml version="1.0" encoding="UTF-8"?>
779<config>
780  <header>
781    <header_item key="X-BBL-Client-Type" value="slicer"/>
782    <header_item key="X-BBL-Client-Version" value="01.10.02.73"/>
783  </header>
784  <plate>
785    <metadata key="index" value="1"/>
786    <metadata key="prediction" value="1895"/>
787    <metadata key="weight" value="11.57"/>
788    <filament id="1" tray_info_idx="GFA00" type="PLA" color="#FFFFFF" used_m="3.82" used_g="11.57" />
789    <warning msg="bed_temp_too_high" level="1" error_code="1000C001" />
790    <object identify_id="145" name="3DBenchy.stl" skipped="false" />
791  </plate>
792</config>"##;
793        let result = parse_slice_info(xml).unwrap();
794        assert_eq!(result.client_type.as_deref(), Some("slicer"));
795        assert_eq!(result.plates.len(), 1);
796
797        let plate = &result.plates[0];
798        assert_eq!(plate.id, 1);
799        assert_eq!(plate.prediction, Some(1895));
800        assert!((plate.weight.unwrap() - 11.57_f32).abs() < 0.01);
801
802        assert_eq!(plate.filaments.len(), 1);
803        assert_eq!(plate.filaments[0].id, 1);
804        assert_eq!(plate.filaments[0].tray_info_idx.as_deref(), Some("GFA00"));
805        assert_eq!(plate.filaments[0].type_.as_deref(), Some("PLA"));
806        assert!((plate.filaments[0].used_g.unwrap() - 11.57_f32).abs() < 0.01);
807
808        assert_eq!(plate.warnings.len(), 1);
809        assert_eq!(plate.warnings[0].msg, "bed_temp_too_high");
810
811        assert_eq!(plate.objects.len(), 1);
812        assert_eq!(plate.objects[0].id, 145);
813        assert_eq!(plate.objects[0].name.as_deref(), Some("3DBenchy.stl"));
814    }
815
816    // ── parse_model_settings ──
817
818    #[test]
819    fn test_parse_model_settings_empty() {
820        let result = parse_model_settings(b"").unwrap();
821        assert!(result.plates.is_empty());
822        assert!(result.objects.is_empty());
823        assert!(result.assembly.is_empty());
824    }
825
826    #[test]
827    fn test_parse_model_settings_invalid_xml() {
828        let _ = parse_model_settings(b"<bad xml");
829    }
830
831    #[test]
832    fn test_parse_model_settings_with_object() {
833        let xml = br#"<?xml version="1.0" encoding="UTF-8"?>
834<config>
835  <object id="8">
836    <metadata key="name" value="3DBenchy.stl"/>
837    <metadata key="extruder" value="1"/>
838    <metadata face_count="225154"/>
839    <part id="1" subtype="normal_part">
840      <metadata key="name" value="3DBenchy.stl"/>
841      <metadata key="matrix" value="1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 1"/>
842      <metadata key="source_volume_id" value="0"/>
843      <metadata key="inner_wall_speed" value="50"/>
844      <mesh_stat face_count="225154" edges_fixed="0" degenerate_facets="0" facets_removed="0" facets_reversed="0" backwards_edges="0"/>
845    </part>
846  </object>
847  <plate>
848    <metadata key="plater_id" value="1"/>
849    <metadata key="plater_name" value=""/>
850    <metadata key="locked" value="false"/>
851    <metadata key="gcode_file" value="Metadata/plate_1.gcode"/>
852    <metadata key="thumbnail_file" value="Metadata/plate_1.png"/>
853    <model_instance>
854      <metadata key="object_id" value="8"/>
855      <metadata key="instance_id" value="0"/>
856      <metadata key="identify_id" value="145"/>
857    </model_instance>
858  </plate>
859  <assemble>
860    <assemble_item object_id="8" instance_id="0" transform="1 0 0 0 1 0 0 0 1 0 0 0" offset="0 0 0" />
861  </assemble>
862</config>"#;
863        let result = parse_model_settings(xml).unwrap();
864
865        // Objects
866        assert_eq!(result.objects.len(), 1);
867        let obj = &result.objects[0];
868        assert_eq!(obj.id, 8);
869        assert_eq!(obj.name.as_deref(), Some("3DBenchy.stl"));
870        assert_eq!(obj.extruder, Some(1));
871        assert_eq!(obj.face_count, Some(225154));
872        assert_eq!(obj.parts.len(), 1);
873
874        let part = &obj.parts[0];
875        assert_eq!(part.id, 1);
876        assert_eq!(part.subtype, PartSubtype::NormalPart);
877        assert_eq!(part.name.as_deref(), Some("3DBenchy.stl"));
878        assert!(part.matrix.is_some());
879        assert!(part.source.is_some());
880        assert!(part.mesh_stat.is_some());
881        // inner_wall_speed goes to print_overrides
882        assert!(part.print_overrides.contains_key("inner_wall_speed"));
883
884        // Plates
885        assert_eq!(result.plates.len(), 1);
886        let plate = &result.plates[0];
887        assert_eq!(plate.id, 1);
888        assert!(!plate.locked);
889        assert_eq!(plate.gcode_file.as_deref(), Some("Metadata/plate_1.gcode"));
890        assert_eq!(
891            plate.thumbnail_file.as_deref(),
892            Some("Metadata/plate_1.png")
893        );
894        assert_eq!(plate.items.len(), 1);
895        assert_eq!(plate.items[0].object_id, 8);
896        assert_eq!(plate.items[0].identify_id, Some(145));
897
898        // Assembly
899        assert_eq!(result.assembly.len(), 1);
900        assert_eq!(result.assembly[0].object_id, 8);
901        assert!(result.assembly[0].transform.is_some());
902    }
903
904    // ── parse_project_settings ──
905
906    #[test]
907    fn test_parse_project_settings_empty() {
908        let result = parse_project_settings(b"").unwrap();
909        assert!(result.printer_model.is_none());
910        assert!(result.filament_type.is_empty());
911    }
912
913    #[test]
914    fn test_parse_project_settings_invalid_json() {
915        let result = parse_project_settings(b"not json {{{").unwrap();
916        assert!(result.printer_model.is_none());
917    }
918
919    #[test]
920    fn test_parse_project_settings_empty_object() {
921        let result = parse_project_settings(b"{}").unwrap();
922        assert!(result.printer_model.is_none());
923        assert!(result.extras.is_empty());
924    }
925
926    #[test]
927    fn test_parse_project_settings_basic() {
928        let json = serde_json::json!({
929            "printer_model": "Bambu Lab A1",
930            "curr_bed_type": "Textured PEI Plate",
931            "layer_height": 0.2,
932            "filament_type": ["PLA"],
933            "filament_colour": ["#FFFFFF"],
934            "nozzle_diameter": ["0.4"],
935            "wall_loops": "3",
936            "sparse_infill_density": "15%",
937            "support_type": "normal(auto)",
938            "change_filament_gcode": "this is a big gcode blob that should be skipped",
939            "some_extra_key": "some_value"
940        })
941        .to_string();
942        let result = parse_project_settings(json.as_bytes()).unwrap();
943
944        assert_eq!(result.printer_model.as_deref(), Some("Bambu Lab A1"));
945        assert_eq!(result.bed_type.as_deref(), Some("Textured PEI Plate"));
946        assert!((result.layer_height.unwrap() - 0.2_f32).abs() < 0.001);
947        assert_eq!(result.filament_type, vec!["PLA"]);
948        assert_eq!(result.filament_colour, vec!["#FFFFFF"]);
949        assert_eq!(result.nozzle_diameter, vec![0.4_f32]);
950        assert_eq!(result.wall_loops, Some(3));
951        assert_eq!(result.infill_density.as_deref(), Some("15%"));
952        assert_eq!(result.support_type.as_deref(), Some("normal(auto)"));
953        // G-code key is skipped
954        assert!(!result.extras.contains_key("change_filament_gcode"));
955        // Unknown keys go to extras
956        assert!(result.extras.contains_key("some_extra_key"));
957    }
958
959    // ── parse_profile_config ──
960
961    #[test]
962    fn test_parse_profile_config_empty() {
963        let result = parse_profile_config(b"", "filament", 1).unwrap();
964        assert_eq!(result.config_type, "filament");
965        assert_eq!(result.index, 1);
966        assert!(result.inherits.is_none());
967        assert!(result.name.is_none());
968    }
969
970    #[test]
971    fn test_parse_profile_config_invalid_json() {
972        let result = parse_profile_config(b"not json", "machine", 2).unwrap();
973        assert_eq!(result.config_type, "machine");
974        assert_eq!(result.index, 2);
975    }
976
977    #[test]
978    fn test_parse_profile_config_basic() {
979        let json = serde_json::json!({
980            "inherits": "Bambu PLA Basic @BBL P1P",
981            "name": "Bambu PLA Basic @BBL P1P(project.3mf)",
982            "filament_type": ["PLA"],
983            "change_filament_gcode": "gcode blob to skip"
984        })
985        .to_string();
986        let result = parse_profile_config(json.as_bytes(), "filament", 1).unwrap();
987
988        assert_eq!(result.config_type, "filament");
989        assert_eq!(result.index, 1);
990        assert_eq!(result.inherits.as_deref(), Some("Bambu PLA Basic @BBL P1P"));
991        assert_eq!(
992            result.name.as_deref(),
993            Some("Bambu PLA Basic @BBL P1P(project.3mf)")
994        );
995        // G-code blob skipped
996        assert!(!result.extras.contains_key("change_filament_gcode"));
997        // Other keys in extras
998        assert!(result.extras.contains_key("filament_type"));
999    }
1000}