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