lib3mf_converters/
stl.rs

1//! STL format import and export (binary and ASCII).
2//!
3//! This module provides conversion between STL files and 3MF [`Model`] structures.
4//! Both binary and ASCII STL formats are supported.
5//!
6//! ## STL Formats
7//!
8//! ### Binary STL
9//!
10//! The binary STL format consists of:
11//! - 80-byte header (typically unused, set to zeros)
12//! - 4-byte little-endian unsigned integer triangle count
13//! - For each triangle:
14//!   - 12 bytes: normal vector (3 × f32, often ignored by importers)
15//!   - 12 bytes: vertex 1 (x, y, z as f32)
16//!   - 12 bytes: vertex 2 (x, y, z as f32)
17//!   - 12 bytes: vertex 3 (x, y, z as f32)
18//!   - 2 bytes: attribute byte count (typically 0)
19//!
20//! ### ASCII STL
21//!
22//! The ASCII STL format is a text-based format with keyword-delimited geometry:
23//! - Keywords are case-insensitive (real-world files use both uppercase and lowercase)
24//! - Multiple solids per file are supported (each becomes a separate object)
25//! - Solid names with spaces are supported
26//!
27//! ## Auto-Detection
28//!
29//! [`StlImporter::read()`] automatically detects the format using the file size formula.
30//! Use [`StlImporter::read_binary()`] or [`StlImporter::read_ascii()`] for explicit format selection.
31//!
32//! ## Examples
33//!
34//! ### Importing STL (auto-detect)
35//!
36//! ```no_run
37//! use lib3mf_converters::stl::StlImporter;
38//! use std::fs::File;
39//!
40//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
41//! let file = File::open("model.stl")?;
42//! let model = StlImporter::read(file)?;
43//! println!("Imported {} objects", model.build.items.len());
44//! # Ok(())
45//! # }
46//! ```
47//!
48//! ### Exporting Binary STL
49//!
50//! ```no_run
51//! use lib3mf_converters::stl::BinaryStlExporter;
52//! use lib3mf_core::model::Model;
53//! use std::fs::File;
54//!
55//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
56//! # let model = Model::default();
57//! let file = File::create("output.stl")?;
58//! BinaryStlExporter::write(&model, file)?;
59//! # Ok(())
60//! # }
61//! ```
62//!
63//! ### Exporting ASCII STL
64//!
65//! ```no_run
66//! use lib3mf_converters::stl::AsciiStlExporter;
67//! use lib3mf_core::model::Model;
68//! use std::fs::File;
69//!
70//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
71//! # let model = Model::default();
72//! let file = File::create("output.stl")?;
73//! AsciiStlExporter::write(&model, file)?;
74//! # Ok(())
75//! # }
76//! ```
77//!
78//! [`Model`]: lib3mf_core::model::Model
79
80use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt};
81use lib3mf_core::error::{Lib3mfError, Result};
82use lib3mf_core::model::resources::ResourceId;
83use lib3mf_core::model::{BuildItem, Mesh, Model, Triangle, Vertex};
84use std::collections::HashMap;
85use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write};
86
87/// Detected STL file format.
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub enum StlFormat {
90    /// Binary STL — compact 80-byte header followed by packed triangle records.
91    Binary,
92    /// ASCII STL — human-readable `solid`/`facet normal`/`vertex` text format.
93    Ascii,
94}
95
96/// Detects whether an STL file is binary or ASCII.
97///
98/// Uses the reliable size-formula check to disambiguate binary files whose headers
99/// begin with the ASCII text "solid" (a common case with many CAD tools).
100///
101/// # Arguments
102///
103/// * `reader` - Any type implementing [`Read`] + [`Seek`]
104///
105/// # Returns
106///
107/// - [`StlFormat::Binary`] if the file matches the binary STL size formula, or if the
108///   first 5 bytes are not `solid` (case-insensitive).
109/// - [`StlFormat::Ascii`] otherwise.
110///
111/// After return, the reader position is reset to 0.
112pub fn detect_stl_format<R: Read + Seek>(reader: &mut R) -> Result<StlFormat> {
113    let mut buf = [0u8; 84];
114    let n = reader.read(&mut buf).map_err(Lib3mfError::Io)?;
115    reader.seek(SeekFrom::Start(0)).map_err(Lib3mfError::Io)?;
116
117    if n < 5 {
118        // Very small file — treat as ASCII
119        return Ok(StlFormat::Ascii);
120    }
121
122    if !buf[..5].eq_ignore_ascii_case(b"solid") {
123        return Ok(StlFormat::Binary);
124    }
125
126    // First 5 bytes are "solid" — could be ASCII or binary with "solid" in header.
127    // Use size formula: binary STL = 84 + triangle_count * 50 bytes.
128    if n >= 84 {
129        let tri_count = u32::from_le_bytes([buf[80], buf[81], buf[82], buf[83]]);
130        let expected_binary_size = 84u64 + tri_count as u64 * 50;
131        let file_size = reader.seek(SeekFrom::End(0)).map_err(Lib3mfError::Io)?;
132        reader.seek(SeekFrom::Start(0)).map_err(Lib3mfError::Io)?;
133        if file_size == expected_binary_size {
134            return Ok(StlFormat::Binary);
135        }
136    }
137
138    Ok(StlFormat::Ascii)
139}
140
141/// Imports STL files (binary or ASCII) into 3MF [`Model`] structures.
142///
143/// The importer supports both binary and ASCII STL formats:
144///
145/// - [`read()`]: Auto-detects the format using the file size formula, then dispatches
146///   to the appropriate parser. Requires `Read + Seek`.
147/// - [`read_binary()`]: Explicit binary-format parser. Requires only `Read`.
148/// - [`read_ascii()`]: Explicit ASCII-format parser. Requires only `Read`.
149///
150/// Vertices are deduplicated using bitwise float comparison during import.
151///
152/// [`read()`]: StlImporter::read
153/// [`read_binary()`]: StlImporter::read_binary
154/// [`read_ascii()`]: StlImporter::read_ascii
155/// [`Model`]: lib3mf_core::model::Model
156pub struct StlImporter;
157
158impl Default for StlImporter {
159    fn default() -> Self {
160        Self::new()
161    }
162}
163
164impl StlImporter {
165    /// Creates a new STL importer instance.
166    pub fn new() -> Self {
167        Self
168    }
169
170    /// Reads an STL file, auto-detecting binary vs ASCII format.
171    ///
172    /// Uses the file size formula to distinguish binary files (even those whose headers
173    /// begin with "solid") from ASCII files.
174    ///
175    /// # Arguments
176    ///
177    /// * `reader` - Any type implementing [`Read`] + [`Seek`] containing STL data
178    ///
179    /// # Returns
180    ///
181    /// A [`Model`] containing the parsed geometry. Binary STL produces a single object;
182    /// ASCII STL produces one object per solid.
183    ///
184    /// # Errors
185    ///
186    /// Returns [`Lib3mfError::Io`] if reading or seeking fails.
187    /// Returns [`Lib3mfError::Validation`] or [`Lib3mfError::InvalidStructure`] if parsing fails.
188    ///
189    /// [`Model`]: lib3mf_core::model::Model
190    /// [`Lib3mfError::Io`]: lib3mf_core::error::Lib3mfError::Io
191    pub fn read<R: Read + Seek>(mut reader: R) -> Result<Model> {
192        let format = detect_stl_format(&mut reader)?;
193        match format {
194            StlFormat::Binary => Self::read_binary(reader),
195            StlFormat::Ascii => Self::read_ascii(reader),
196        }
197    }
198
199    /// Reads a binary STL file and converts it to a 3MF [`Model`].
200    ///
201    /// # Arguments
202    ///
203    /// * `reader` - Any type implementing [`Read`] containing binary STL data
204    ///
205    /// # Returns
206    ///
207    /// A [`Model`] containing:
208    /// - Single mesh object with ResourceId(1) named "STL Import"
209    /// - All triangles from the STL file
210    /// - Deduplicated vertices (using bitwise float comparison)
211    /// - Single build item referencing the mesh object
212    ///
213    /// # Errors
214    ///
215    /// Returns [`Lib3mfError::Io`] if:
216    /// - Cannot read 80-byte header
217    /// - Cannot read triangle count
218    /// - Cannot read triangle data (normals, vertices, attribute bytes)
219    ///
220    /// Returns [`Lib3mfError::Validation`] if triangle count field cannot be parsed.
221    ///
222    /// # Format Details
223    ///
224    /// - **Vertex deduplication**: Uses HashMap with bitwise float comparison `[x.to_bits(), y.to_bits(), z.to_bits()]`
225    ///   as key. Only exactly identical vertices (bitwise) are merged.
226    /// - **Normal vectors**: Read from STL but ignored (not stored in Model).
227    /// - **Attribute bytes**: Read but ignored (2-byte field after each triangle).
228    ///
229    /// # Examples
230    ///
231    /// ```no_run
232    /// use lib3mf_converters::stl::StlImporter;
233    /// use std::fs::File;
234    ///
235    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
236    /// let file = File::open("cube.stl")?;
237    /// let model = StlImporter::read_binary(file)?;
238    ///
239    /// // Access the imported mesh
240    /// let obj = model.resources.get_object(lib3mf_core::model::resources::ResourceId(1))
241    ///     .expect("STL import creates object with ID 1");
242    /// if let lib3mf_core::model::Geometry::Mesh(mesh) = &obj.geometry {
243    ///     println!("Imported {} vertices, {} triangles",
244    ///         mesh.vertices.len(), mesh.triangles.len());
245    /// }
246    /// # Ok(())
247    /// # }
248    /// ```
249    ///
250    /// [`Model`]: lib3mf_core::model::Model
251    /// [`Lib3mfError::Io`]: lib3mf_core::error::Lib3mfError::Io
252    /// [`Lib3mfError::Validation`]: lib3mf_core::error::Lib3mfError::Validation
253    pub fn read_binary<R: Read>(mut reader: R) -> Result<Model> {
254        // STL Format:
255        // 80 bytes header
256        // 4 bytes triangle info (u32)
257        // Triangles...
258
259        let mut header = [0u8; 80];
260        reader.read_exact(&mut header).map_err(Lib3mfError::Io)?;
261
262        let triangle_count = reader.read_u32::<LittleEndian>().map_err(|_| {
263            Lib3mfError::Validation("Failed to read STL triangle count".to_string())
264        })?;
265
266        let mut mesh = Mesh::default();
267        let mut vert_map: HashMap<[u32; 3], u32> = HashMap::new();
268
269        for _ in 0..triangle_count {
270            // Normal (3 floats) - Ignored
271            let _nx = reader.read_f32::<LittleEndian>().map_err(Lib3mfError::Io)?;
272            let _ny = reader.read_f32::<LittleEndian>().map_err(Lib3mfError::Io)?;
273            let _nz = reader.read_f32::<LittleEndian>().map_err(Lib3mfError::Io)?;
274
275            let mut indices = [0u32; 3];
276
277            for index in &mut indices {
278                let x = reader.read_f32::<LittleEndian>().map_err(Lib3mfError::Io)?;
279                let y = reader.read_f32::<LittleEndian>().map_err(Lib3mfError::Io)?;
280                let z = reader.read_f32::<LittleEndian>().map_err(Lib3mfError::Io)?;
281
282                let key = [x.to_bits(), y.to_bits(), z.to_bits()];
283
284                let idx = *vert_map.entry(key).or_insert_with(|| {
285                    let new_idx = mesh.vertices.len() as u32;
286                    mesh.vertices.push(Vertex { x, y, z });
287                    new_idx
288                });
289                *index = idx;
290            }
291
292            let _attr_byte_count = reader.read_u16::<LittleEndian>().map_err(Lib3mfError::Io)?;
293
294            mesh.triangles.push(Triangle {
295                v1: indices[0],
296                v2: indices[1],
297                v3: indices[2],
298                ..Default::default()
299            });
300        }
301
302        let mut model = Model::default();
303        let resource_id = ResourceId(1); // Default ID
304
305        let object = lib3mf_core::model::Object {
306            id: resource_id,
307            object_type: lib3mf_core::model::ObjectType::Model,
308            name: Some("STL Import".to_string()),
309            part_number: None,
310            uuid: None,
311            pid: None,
312            pindex: None,
313            thumbnail: None,
314            geometry: lib3mf_core::model::Geometry::Mesh(mesh),
315        };
316
317        let _ = model.resources.add_object(object);
318
319        model.build.items.push(BuildItem {
320            object_id: resource_id,
321            transform: glam::Mat4::IDENTITY,
322            part_number: None,
323            uuid: None,
324            path: None,
325            printable: None,
326        });
327
328        Ok(model)
329    }
330
331    /// Reads an ASCII STL file and converts it to a 3MF [`Model`].
332    ///
333    /// Parses one or more `solid ... endsolid` blocks. Each solid becomes a separate
334    /// [`Object`] with its own [`ResourceId`] and [`BuildItem`].
335    ///
336    /// # Arguments
337    ///
338    /// * `reader` - Any type implementing [`Read`] containing ASCII STL text
339    ///
340    /// # Returns
341    ///
342    /// A [`Model`] containing:
343    /// - One mesh object per solid, with ResourceIds starting at 1
344    /// - Object names taken from the solid name (if any)
345    /// - Deduplicated vertices per solid (using bitwise float comparison)
346    /// - One build item per solid with identity transform
347    ///
348    /// # Errors
349    ///
350    /// Returns [`Lib3mfError::Io`] if reading fails.
351    /// Returns [`Lib3mfError::InvalidStructure`] if vertex coordinates cannot be parsed.
352    ///
353    /// # Behavior
354    ///
355    /// - Keywords are matched case-insensitively (`SOLID`, `Facet`, `VERTEX`, etc.)
356    /// - Solid names with spaces are supported (`solid My Cool Part`)
357    /// - `endsolid` name is not validated (may differ from `solid` name or be absent)
358    /// - Files that end without `endsolid` are handled leniently
359    /// - Normal vectors from `facet normal` lines are read and discarded
360    ///
361    /// [`Model`]: lib3mf_core::model::Model
362    /// [`Object`]: lib3mf_core::model::Object
363    /// [`ResourceId`]: lib3mf_core::model::resources::ResourceId
364    /// [`BuildItem`]: lib3mf_core::model::BuildItem
365    pub fn read_ascii<R: Read>(reader: R) -> Result<Model> {
366        let buf_reader = BufReader::new(reader);
367        let mut model = Model::default();
368        let mut next_id = 1u32;
369
370        let mut current_mesh: Option<(Mesh, String)> = None;
371        let mut vert_map: HashMap<[u32; 3], u32> = HashMap::new();
372        // Buffer for the 3 vertices of the current facet
373        let mut facet_verts: Vec<(f32, f32, f32)> = Vec::with_capacity(3);
374
375        for line_res in buf_reader.lines() {
376            let line = line_res.map_err(Lib3mfError::Io)?;
377            let trimmed = line.trim();
378            if trimmed.is_empty() {
379                continue;
380            }
381
382            let parts: Vec<&str> = trimmed.split_whitespace().collect();
383            if parts.is_empty() {
384                continue;
385            }
386
387            match parts[0].to_ascii_lowercase().as_str() {
388                "solid" => {
389                    // Name is everything after "solid" (joined with spaces)
390                    let name = if parts.len() > 1 {
391                        parts[1..].join(" ")
392                    } else {
393                        String::new()
394                    };
395                    current_mesh = Some((Mesh::default(), name));
396                    vert_map.clear();
397                    facet_verts.clear();
398                }
399                "facet" => {
400                    // facet normal nx ny nz — read and discard normals
401                    facet_verts.clear();
402                }
403                "vertex" => {
404                    if parts.len() >= 4 {
405                        let x = parts[1].parse::<f32>().map_err(|_| {
406                            Lib3mfError::InvalidStructure("Invalid STL vertex x coordinate".into())
407                        })?;
408                        let y = parts[2].parse::<f32>().map_err(|_| {
409                            Lib3mfError::InvalidStructure("Invalid STL vertex y coordinate".into())
410                        })?;
411                        let z = parts[3].parse::<f32>().map_err(|_| {
412                            Lib3mfError::InvalidStructure("Invalid STL vertex z coordinate".into())
413                        })?;
414                        facet_verts.push((x, y, z));
415                    }
416                }
417                "endfacet" => {
418                    if facet_verts.len() != 3 {
419                        return Err(Lib3mfError::InvalidStructure(format!(
420                            "STL facet must have exactly 3 vertices, found {}",
421                            facet_verts.len()
422                        )));
423                    }
424                    if let Some((ref mut mesh, _)) = current_mesh {
425                        let mut indices = [0u32; 3];
426                        for (i, &(x, y, z)) in facet_verts.iter().enumerate() {
427                            let key = [x.to_bits(), y.to_bits(), z.to_bits()];
428                            let idx = *vert_map.entry(key).or_insert_with(|| {
429                                let new_idx = mesh.vertices.len() as u32;
430                                mesh.vertices.push(Vertex { x, y, z });
431                                new_idx
432                            });
433                            indices[i] = idx;
434                        }
435                        mesh.triangles.push(Triangle {
436                            v1: indices[0],
437                            v2: indices[1],
438                            v3: indices[2],
439                            ..Default::default()
440                        });
441                    }
442                    facet_verts.clear();
443                }
444                "endsolid" => {
445                    if let Some((mesh, name)) = current_mesh.take() {
446                        finalize_solid(&mut model, mesh, name, next_id);
447                        next_id += 1;
448                    }
449                }
450                // Skip: "outer", "loop", "endloop", and any other unrecognized lines
451                _ => {}
452            }
453        }
454
455        // Handle file that ended without endsolid (lenient parsing)
456        if let Some((mesh, name)) = current_mesh.take() {
457            finalize_solid(&mut model, mesh, name, next_id);
458        }
459
460        Ok(model)
461    }
462}
463
464/// Finalizes a parsed solid into a Model object and build item.
465fn finalize_solid(model: &mut Model, mesh: Mesh, name: String, id: u32) {
466    let resource_id = ResourceId(id);
467    let object_name = if name.is_empty() { None } else { Some(name) };
468
469    let object = lib3mf_core::model::Object {
470        id: resource_id,
471        object_type: lib3mf_core::model::ObjectType::Model,
472        name: object_name,
473        part_number: None,
474        uuid: None,
475        pid: None,
476        pindex: None,
477        thumbnail: None,
478        geometry: lib3mf_core::model::Geometry::Mesh(mesh),
479    };
480
481    let _ = model.resources.add_object(object);
482
483    model.build.items.push(BuildItem {
484        object_id: resource_id,
485        transform: glam::Mat4::IDENTITY,
486        part_number: None,
487        uuid: None,
488        path: None,
489        printable: None,
490    });
491}
492
493/// Computes a face normal from three vertices using the cross product.
494///
495/// Returns a zero vector for degenerate (zero-area) triangles.
496fn compute_face_normal(v1: glam::Vec3, v2: glam::Vec3, v3: glam::Vec3) -> glam::Vec3 {
497    let edge1 = v2 - v1;
498    let edge2 = v3 - v1;
499    edge1.cross(edge2).normalize_or_zero()
500}
501
502/// Exports 3MF [`Model`] structures to binary STL files.
503///
504/// The exporter flattens all mesh objects referenced in build items into a single STL file,
505/// applying build item transformations to vertex coordinates.
506///
507/// This was previously named `StlExporter`. If you were using `StlExporter`, update your
508/// code to use `BinaryStlExporter`.
509///
510/// [`Model`]: lib3mf_core::model::Model
511pub struct BinaryStlExporter;
512
513impl BinaryStlExporter {
514    /// Writes a 3MF [`Model`] to binary STL format.
515    ///
516    /// # Arguments
517    ///
518    /// * `model` - The 3MF model to export
519    /// * `writer` - Any type implementing [`Write`] to receive STL data
520    ///
521    /// # Returns
522    ///
523    /// `Ok(())` on successful export.
524    ///
525    /// # Errors
526    ///
527    /// Returns [`Lib3mfError::Io`] if any write operation fails.
528    ///
529    /// # Format Details
530    ///
531    /// - **Header**: 80 zero bytes (standard for most STL files)
532    /// - **Normals**: Written as (0, 0, 0) - viewers must compute face normals
533    /// - **Transformations**: Build item transforms are applied to vertex coordinates
534    /// - **Attribute bytes**: Written as 0 (no extended attributes)
535    ///
536    /// # Behavior
537    ///
538    /// - Only mesh objects from `model.build.items` are exported
539    /// - Non-mesh geometries (Components, BooleanShape, etc.) are skipped
540    /// - Each build item's transformation matrix is applied to its mesh vertices
541    /// - All triangles from all build items are combined into a single STL file
542    ///
543    /// # Examples
544    ///
545    /// ```no_run
546    /// use lib3mf_converters::stl::BinaryStlExporter;
547    /// use lib3mf_core::model::Model;
548    /// use std::fs::File;
549    ///
550    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
551    /// # let model = Model::default();
552    /// let output = File::create("exported.stl")?;
553    /// BinaryStlExporter::write(&model, output)?;
554    /// println!("Model exported successfully");
555    /// # Ok(())
556    /// # }
557    /// ```
558    ///
559    /// [`Model`]: lib3mf_core::model::Model
560    /// [`Lib3mfError::Io`]: lib3mf_core::error::Lib3mfError::Io
561    pub fn write<W: Write>(model: &Model, mut writer: W) -> Result<()> {
562        // 1. Collect all triangles from all build items
563        let mut triangles: Vec<(glam::Vec3, glam::Vec3, glam::Vec3)> = Vec::new(); // v1, v2, v3
564
565        for item in &model.build.items {
566            #[allow(clippy::collapsible_if)]
567            if let Some(object) = model.resources.get_object(item.object_id) {
568                if let lib3mf_core::model::Geometry::Mesh(mesh) = &object.geometry {
569                    let transform = item.transform;
570
571                    for tri in &mesh.triangles {
572                        let v1_local = mesh.vertices[tri.v1 as usize];
573                        let v2_local = mesh.vertices[tri.v2 as usize];
574                        let v3_local = mesh.vertices[tri.v3 as usize];
575
576                        let v1 = transform
577                            .transform_point3(glam::Vec3::new(v1_local.x, v1_local.y, v1_local.z));
578                        let v2 = transform
579                            .transform_point3(glam::Vec3::new(v2_local.x, v2_local.y, v2_local.z));
580                        let v3 = transform
581                            .transform_point3(glam::Vec3::new(v3_local.x, v3_local.y, v3_local.z));
582
583                        triangles.push((v1, v2, v3));
584                    }
585                }
586            }
587        }
588
589        // 2. Write Header (80 bytes)
590        let header = [0u8; 80];
591        writer.write_all(&header).map_err(Lib3mfError::Io)?;
592
593        // 3. Write Count
594        writer
595            .write_u32::<LittleEndian>(triangles.len() as u32)
596            .map_err(Lib3mfError::Io)?;
597
598        // 4. Write Triangles
599        for (v1, v2, v3) in triangles {
600            // Normal (0,0,0) - let viewer calculate
601            writer
602                .write_f32::<LittleEndian>(0.0)
603                .map_err(Lib3mfError::Io)?;
604            writer
605                .write_f32::<LittleEndian>(0.0)
606                .map_err(Lib3mfError::Io)?;
607            writer
608                .write_f32::<LittleEndian>(0.0)
609                .map_err(Lib3mfError::Io)?;
610
611            // v1
612            writer
613                .write_f32::<LittleEndian>(v1.x)
614                .map_err(Lib3mfError::Io)?;
615            writer
616                .write_f32::<LittleEndian>(v1.y)
617                .map_err(Lib3mfError::Io)?;
618            writer
619                .write_f32::<LittleEndian>(v1.z)
620                .map_err(Lib3mfError::Io)?;
621
622            // v2
623            writer
624                .write_f32::<LittleEndian>(v2.x)
625                .map_err(Lib3mfError::Io)?;
626            writer
627                .write_f32::<LittleEndian>(v2.y)
628                .map_err(Lib3mfError::Io)?;
629            writer
630                .write_f32::<LittleEndian>(v2.z)
631                .map_err(Lib3mfError::Io)?;
632
633            // v3
634            writer
635                .write_f32::<LittleEndian>(v3.x)
636                .map_err(Lib3mfError::Io)?;
637            writer
638                .write_f32::<LittleEndian>(v3.y)
639                .map_err(Lib3mfError::Io)?;
640            writer
641                .write_f32::<LittleEndian>(v3.z)
642                .map_err(Lib3mfError::Io)?;
643
644            // Attribute byte count (0)
645            writer
646                .write_u16::<LittleEndian>(0)
647                .map_err(Lib3mfError::Io)?;
648        }
649
650        Ok(())
651    }
652
653    /// Writes a 3MF [`Model`] to binary STL format with support for multi-part 3MF files.
654    ///
655    /// This method extends [`write`] by recursively resolving component references and external
656    /// model parts using a [`PartResolver`]. This is necessary for 3MF files with the Production
657    /// Extension that reference objects from external model parts.
658    ///
659    /// # Arguments
660    ///
661    /// * `model` - The root 3MF model to export
662    /// * `resolver` - A [`PartResolver`] for loading external model parts from the 3MF archive
663    /// * `writer` - Any type implementing [`Write`] to receive STL data
664    ///
665    /// # Returns
666    ///
667    /// `Ok(())` on successful export.
668    ///
669    /// # Errors
670    ///
671    /// Returns [`Lib3mfError::Io`] if any write operation fails.
672    ///
673    /// Returns errors from the resolver if external parts cannot be loaded.
674    ///
675    /// # Behavior
676    ///
677    /// - Recursively resolves component hierarchies using the PartResolver
678    /// - Follows external references via component `path` attributes
679    /// - Applies accumulated transformations through the component tree
680    /// - Flattens all resolved meshes into a single STL file
681    ///
682    /// # Examples
683    ///
684    /// ```no_run
685    /// use lib3mf_converters::stl::BinaryStlExporter;
686    /// use lib3mf_core::archive::ZipArchiver;
687    /// use lib3mf_core::model::resolver::PartResolver;
688    /// use std::fs::File;
689    ///
690    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
691    /// # let model = lib3mf_core::model::Model::default();
692    /// let archive_file = File::open("multipart.3mf")?;
693    /// let mut archiver = ZipArchiver::new(archive_file)?;
694    /// let resolver = PartResolver::new(&mut archiver, model.clone());
695    ///
696    /// let output = File::create("output.stl")?;
697    /// BinaryStlExporter::write_with_resolver(&model, resolver, output)?;
698    /// # Ok(())
699    /// # }
700    /// ```
701    ///
702    /// [`write`]: BinaryStlExporter::write
703    /// [`Model`]: lib3mf_core::model::Model
704    /// [`PartResolver`]: lib3mf_core::model::resolver::PartResolver
705    /// [`Lib3mfError::Io`]: lib3mf_core::error::Lib3mfError::Io
706    pub fn write_with_resolver<W: Write, A: lib3mf_core::archive::ArchiveReader>(
707        model: &Model,
708        mut resolver: lib3mf_core::model::resolver::PartResolver<A>,
709        mut writer: W,
710    ) -> Result<()> {
711        // 1. Collect all triangles from all build items (recursively)
712        let mut triangles: Vec<(glam::Vec3, glam::Vec3, glam::Vec3)> = Vec::new();
713
714        for item in &model.build.items {
715            collect_triangles(
716                &mut resolver,
717                item.object_id,
718                item.transform,
719                None, // Start with root path (None)
720                &mut triangles,
721            )?;
722        }
723
724        // 2. Write Header (80 bytes)
725        let header = [0u8; 80];
726        writer.write_all(&header).map_err(Lib3mfError::Io)?;
727
728        // 3. Write Count
729        writer
730            .write_u32::<LittleEndian>(triangles.len() as u32)
731            .map_err(Lib3mfError::Io)?;
732
733        // 4. Write Triangles
734        for (v1, v2, v3) in triangles {
735            // Normal (0,0,0) - let viewer calculate
736            writer
737                .write_f32::<LittleEndian>(0.0)
738                .map_err(Lib3mfError::Io)?;
739            writer
740                .write_f32::<LittleEndian>(0.0)
741                .map_err(Lib3mfError::Io)?;
742            writer
743                .write_f32::<LittleEndian>(0.0)
744                .map_err(Lib3mfError::Io)?;
745
746            // v1
747            writer
748                .write_f32::<LittleEndian>(v1.x)
749                .map_err(Lib3mfError::Io)?;
750            writer
751                .write_f32::<LittleEndian>(v1.y)
752                .map_err(Lib3mfError::Io)?;
753            writer
754                .write_f32::<LittleEndian>(v1.z)
755                .map_err(Lib3mfError::Io)?;
756
757            // v2
758            writer
759                .write_f32::<LittleEndian>(v2.x)
760                .map_err(Lib3mfError::Io)?;
761            writer
762                .write_f32::<LittleEndian>(v2.y)
763                .map_err(Lib3mfError::Io)?;
764            writer
765                .write_f32::<LittleEndian>(v2.z)
766                .map_err(Lib3mfError::Io)?;
767
768            // v3
769            writer
770                .write_f32::<LittleEndian>(v3.x)
771                .map_err(Lib3mfError::Io)?;
772            writer
773                .write_f32::<LittleEndian>(v3.y)
774                .map_err(Lib3mfError::Io)?;
775            writer
776                .write_f32::<LittleEndian>(v3.z)
777                .map_err(Lib3mfError::Io)?;
778
779            // Attribute byte count (0)
780            writer
781                .write_u16::<LittleEndian>(0)
782                .map_err(Lib3mfError::Io)?;
783        }
784
785        Ok(())
786    }
787}
788
789/// Exports 3MF [`Model`] structures to ASCII STL files.
790///
791/// Each mesh object in the model's build items is written as a separate ASCII STL solid.
792/// Face normals are computed from the triangle edges using the cross product.
793///
794/// [`Model`]: lib3mf_core::model::Model
795pub struct AsciiStlExporter;
796
797impl AsciiStlExporter {
798    /// Writes a 3MF [`Model`] to ASCII STL format.
799    ///
800    /// Each mesh object referenced in the model's build items is written as a separate
801    /// `solid ... endsolid` block. The solid name is taken from the object's name field,
802    /// or left empty if the object has no name.
803    ///
804    /// # Arguments
805    ///
806    /// * `model` - The 3MF model to export
807    /// * `writer` - Any type implementing [`Write`] to receive ASCII STL text
808    ///
809    /// # Returns
810    ///
811    /// `Ok(())` on successful export.
812    ///
813    /// # Errors
814    ///
815    /// Returns [`Lib3mfError::Io`] if any write operation fails.
816    ///
817    /// # Format Details
818    ///
819    /// - **Normals**: Computed via cross product of triangle edges (`normalize_or_zero()`)
820    /// - **Degenerate triangles**: Emit zero normal `(0 0 0)`, triangle is not skipped
821    /// - **Normal format**: Scientific notation with 6 decimal places (`{:.6e}`)
822    /// - **Vertex format**: Fixed-point with 6 decimal places (`{:.6}`)
823    /// - **Transformations**: Build item transforms are applied to vertex coordinates
824    /// - **Solid names**: Taken from `object.name`, empty string if `None`
825    ///
826    /// # Examples
827    ///
828    /// ```no_run
829    /// use lib3mf_converters::stl::AsciiStlExporter;
830    /// use lib3mf_core::model::Model;
831    /// use std::fs::File;
832    ///
833    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
834    /// # let model = Model::default();
835    /// let output = File::create("exported.stl")?;
836    /// AsciiStlExporter::write(&model, output)?;
837    /// # Ok(())
838    /// # }
839    /// ```
840    ///
841    /// [`Model`]: lib3mf_core::model::Model
842    /// [`Lib3mfError::Io`]: lib3mf_core::error::Lib3mfError::Io
843    pub fn write<W: Write>(model: &Model, mut writer: W) -> Result<()> {
844        for item in &model.build.items {
845            #[allow(clippy::collapsible_if)]
846            if let Some(object) = model.resources.get_object(item.object_id) {
847                if let lib3mf_core::model::Geometry::Mesh(mesh) = &object.geometry {
848                    let name = object.name.as_deref().unwrap_or("");
849                    let transform = item.transform;
850
851                    writeln!(writer, "solid {name}").map_err(Lib3mfError::Io)?;
852
853                    for tri in &mesh.triangles {
854                        let v1_local = mesh.vertices[tri.v1 as usize];
855                        let v2_local = mesh.vertices[tri.v2 as usize];
856                        let v3_local = mesh.vertices[tri.v3 as usize];
857
858                        let v1 = transform
859                            .transform_point3(glam::Vec3::new(v1_local.x, v1_local.y, v1_local.z));
860                        let v2 = transform
861                            .transform_point3(glam::Vec3::new(v2_local.x, v2_local.y, v2_local.z));
862                        let v3 = transform
863                            .transform_point3(glam::Vec3::new(v3_local.x, v3_local.y, v3_local.z));
864
865                        let normal = compute_face_normal(v1, v2, v3);
866
867                        writeln!(
868                            writer,
869                            "  facet normal {:.6e} {:.6e} {:.6e}",
870                            normal.x, normal.y, normal.z
871                        )
872                        .map_err(Lib3mfError::Io)?;
873                        writeln!(writer, "    outer loop").map_err(Lib3mfError::Io)?;
874                        writeln!(writer, "      vertex {:.6} {:.6} {:.6}", v1.x, v1.y, v1.z)
875                            .map_err(Lib3mfError::Io)?;
876                        writeln!(writer, "      vertex {:.6} {:.6} {:.6}", v2.x, v2.y, v2.z)
877                            .map_err(Lib3mfError::Io)?;
878                        writeln!(writer, "      vertex {:.6} {:.6} {:.6}", v3.x, v3.y, v3.z)
879                            .map_err(Lib3mfError::Io)?;
880                        writeln!(writer, "    endloop").map_err(Lib3mfError::Io)?;
881                        writeln!(writer, "  endfacet").map_err(Lib3mfError::Io)?;
882                    }
883
884                    writeln!(writer, "endsolid {name}").map_err(Lib3mfError::Io)?;
885                }
886            }
887        }
888        Ok(())
889    }
890
891    /// Writes a 3MF [`Model`] to ASCII STL format with support for multi-part 3MF files.
892    ///
893    /// Resolves component references and external model parts using a [`PartResolver`],
894    /// then writes all collected triangles as a single ASCII STL solid.
895    ///
896    /// # Arguments
897    ///
898    /// * `model` - The root 3MF model to export
899    /// * `resolver` - A [`PartResolver`] for loading external model parts from the 3MF archive
900    /// * `writer` - Any type implementing [`Write`] to receive ASCII STL text
901    ///
902    /// # Returns
903    ///
904    /// `Ok(())` on successful export.
905    ///
906    /// # Errors
907    ///
908    /// Returns [`Lib3mfError::Io`] if any write operation fails.
909    /// Returns errors from the resolver if external parts cannot be loaded.
910    ///
911    /// [`Model`]: lib3mf_core::model::Model
912    /// [`PartResolver`]: lib3mf_core::model::resolver::PartResolver
913    pub fn write_with_resolver<W: Write, A: lib3mf_core::archive::ArchiveReader>(
914        model: &Model,
915        mut resolver: lib3mf_core::model::resolver::PartResolver<A>,
916        mut writer: W,
917    ) -> Result<()> {
918        // Collect all triangles from all build items (recursively)
919        let mut triangles: Vec<(glam::Vec3, glam::Vec3, glam::Vec3)> = Vec::new();
920
921        for item in &model.build.items {
922            collect_triangles(
923                &mut resolver,
924                item.object_id,
925                item.transform,
926                None,
927                &mut triangles,
928            )?;
929        }
930
931        // Write as a single solid (resolver flattens all objects)
932        writeln!(writer, "solid ").map_err(Lib3mfError::Io)?;
933
934        for (v1, v2, v3) in triangles {
935            let normal = compute_face_normal(v1, v2, v3);
936
937            writeln!(
938                writer,
939                "  facet normal {:.6e} {:.6e} {:.6e}",
940                normal.x, normal.y, normal.z
941            )
942            .map_err(Lib3mfError::Io)?;
943            writeln!(writer, "    outer loop").map_err(Lib3mfError::Io)?;
944            writeln!(writer, "      vertex {:.6} {:.6} {:.6}", v1.x, v1.y, v1.z)
945                .map_err(Lib3mfError::Io)?;
946            writeln!(writer, "      vertex {:.6} {:.6} {:.6}", v2.x, v2.y, v2.z)
947                .map_err(Lib3mfError::Io)?;
948            writeln!(writer, "      vertex {:.6} {:.6} {:.6}", v3.x, v3.y, v3.z)
949                .map_err(Lib3mfError::Io)?;
950            writeln!(writer, "    endloop").map_err(Lib3mfError::Io)?;
951            writeln!(writer, "  endfacet").map_err(Lib3mfError::Io)?;
952        }
953
954        writeln!(writer, "endsolid ").map_err(Lib3mfError::Io)?;
955
956        Ok(())
957    }
958}
959
960fn collect_triangles<A: lib3mf_core::archive::ArchiveReader>(
961    resolver: &mut lib3mf_core::model::resolver::PartResolver<A>,
962    object_id: ResourceId,
963    transform: glam::Mat4,
964    path: Option<&str>,
965    triangles: &mut Vec<(glam::Vec3, glam::Vec3, glam::Vec3)>,
966) -> Result<()> {
967    // Resolve geometry
968    // Note: We need to clone the geometry or handle the borrow of resolver carefully.
969    // resolving returns a reference to Model and Object, which borrows from resolver.
970    // We can't keep that borrow while recursing (mutably borrowing resolver).
971    // So we need to clone the relevant data (Geometry) or restructure.
972    // Cloning Geometry (which contains Mesh) might be expensive but safe.
973    // OR: resolve_object returns reference, we inspect it, then drop reference before recursing.
974
975    let geometry = {
976        let res = resolver.resolve_object(object_id, path)?;
977        if let Some((_, obj)) = res {
978            Some(obj.geometry.clone()) // Cloning geometry to release borrow
979        } else {
980            None
981        }
982    };
983
984    if let Some(geo) = geometry {
985        match geo {
986            lib3mf_core::model::Geometry::Mesh(mesh) => {
987                for tri in &mesh.triangles {
988                    let v1_local = mesh.vertices[tri.v1 as usize];
989                    let v2_local = mesh.vertices[tri.v2 as usize];
990                    let v3_local = mesh.vertices[tri.v3 as usize];
991
992                    let v1 = transform
993                        .transform_point3(glam::Vec3::new(v1_local.x, v1_local.y, v1_local.z));
994                    let v2 = transform
995                        .transform_point3(glam::Vec3::new(v2_local.x, v2_local.y, v2_local.z));
996                    let v3 = transform
997                        .transform_point3(glam::Vec3::new(v3_local.x, v3_local.y, v3_local.z));
998
999                    triangles.push((v1, v2, v3));
1000                }
1001            }
1002            lib3mf_core::model::Geometry::Components(comps) => {
1003                for comp in comps.components {
1004                    let new_transform = transform * comp.transform;
1005                    let next_path_store = comp.path.clone();
1006                    let next_path = next_path_store.as_deref().or(path);
1007
1008                    collect_triangles(
1009                        resolver,
1010                        comp.object_id,
1011                        new_transform,
1012                        next_path,
1013                        triangles,
1014                    )?;
1015                }
1016            }
1017            _ => {}
1018        }
1019    }
1020
1021    Ok(())
1022}
1023
1024#[cfg(test)]
1025mod tests {
1026    use super::*;
1027    use std::io::Cursor;
1028
1029    // ===== Helper functions =====
1030
1031    /// Build a minimal binary STL with the given triangles.
1032    fn make_binary_stl(
1033        header: &[u8; 80],
1034        triangles: &[(f32, f32, f32, f32, f32, f32, f32, f32, f32)],
1035    ) -> Vec<u8> {
1036        // Each element: (v1x, v1y, v1z, v2x, v2y, v2z, v3x, v3y, v3z)
1037        use byteorder::{LittleEndian, WriteBytesExt};
1038        let mut buf = Vec::new();
1039        buf.extend_from_slice(header);
1040        buf.write_u32::<LittleEndian>(triangles.len() as u32)
1041            .unwrap();
1042        for &(v1x, v1y, v1z, v2x, v2y, v2z, v3x, v3y, v3z) in triangles {
1043            // normal (0,0,0)
1044            buf.write_f32::<LittleEndian>(0.0).unwrap();
1045            buf.write_f32::<LittleEndian>(0.0).unwrap();
1046            buf.write_f32::<LittleEndian>(0.0).unwrap();
1047            // v1
1048            buf.write_f32::<LittleEndian>(v1x).unwrap();
1049            buf.write_f32::<LittleEndian>(v1y).unwrap();
1050            buf.write_f32::<LittleEndian>(v1z).unwrap();
1051            // v2
1052            buf.write_f32::<LittleEndian>(v2x).unwrap();
1053            buf.write_f32::<LittleEndian>(v2y).unwrap();
1054            buf.write_f32::<LittleEndian>(v2z).unwrap();
1055            // v3
1056            buf.write_f32::<LittleEndian>(v3x).unwrap();
1057            buf.write_f32::<LittleEndian>(v3y).unwrap();
1058            buf.write_f32::<LittleEndian>(v3z).unwrap();
1059            // attribute byte count
1060            buf.write_u16::<LittleEndian>(0).unwrap();
1061        }
1062        buf
1063    }
1064
1065    /// Build a simple model with one mesh object containing the given vertices and triangles.
1066    fn make_simple_model(
1067        vertices: Vec<(f32, f32, f32)>,
1068        triangles: Vec<(u32, u32, u32)>,
1069        name: Option<&str>,
1070    ) -> Model {
1071        use lib3mf_core::model::{Geometry, Object, ObjectType};
1072
1073        let mut mesh = Mesh::default();
1074        for (x, y, z) in vertices {
1075            mesh.vertices.push(Vertex { x, y, z });
1076        }
1077        for (v1, v2, v3) in triangles {
1078            mesh.triangles.push(Triangle {
1079                v1,
1080                v2,
1081                v3,
1082                ..Default::default()
1083            });
1084        }
1085
1086        let resource_id = ResourceId(1);
1087        let object = Object {
1088            id: resource_id,
1089            object_type: ObjectType::Model,
1090            name: name.map(|s| s.to_string()),
1091            part_number: None,
1092            uuid: None,
1093            pid: None,
1094            pindex: None,
1095            thumbnail: None,
1096            geometry: Geometry::Mesh(mesh),
1097        };
1098
1099        let mut model = Model::default();
1100        let _ = model.resources.add_object(object);
1101        model.build.items.push(BuildItem {
1102            object_id: resource_id,
1103            transform: glam::Mat4::IDENTITY,
1104            part_number: None,
1105            uuid: None,
1106            path: None,
1107            printable: None,
1108        });
1109        model
1110    }
1111
1112    // ===== Test 1: detect_stl_format returns Binary for binary STL =====
1113
1114    #[test]
1115    fn test_detect_binary_format() {
1116        let header = [0u8; 80];
1117        let tris = vec![(0.0f32, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0)];
1118        let data = make_binary_stl(&header, &tris);
1119        let mut cursor = Cursor::new(data);
1120        let fmt = detect_stl_format(&mut cursor).expect("detect should succeed");
1121        assert_eq!(fmt, StlFormat::Binary);
1122    }
1123
1124    // ===== Test 2: detect_stl_format returns Ascii for ASCII STL =====
1125
1126    #[test]
1127    fn test_detect_ascii_format() {
1128        let ascii = "solid test\nendsolid test\n";
1129        let mut cursor = Cursor::new(ascii.as_bytes().to_vec());
1130        let fmt = detect_stl_format(&mut cursor).expect("detect should succeed");
1131        assert_eq!(fmt, StlFormat::Ascii);
1132    }
1133
1134    // ===== Test 3: detect returns Binary even when header starts with "solid" =====
1135
1136    #[test]
1137    fn test_detect_binary_with_solid_header() {
1138        // Build binary STL whose 80-byte header starts with "solid"
1139        let mut header = [0u8; 80];
1140        let solid_bytes = b"solid binary_test_header";
1141        header[..solid_bytes.len()].copy_from_slice(solid_bytes);
1142
1143        let tris = vec![(0.0f32, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0)];
1144        let data = make_binary_stl(&header, &tris);
1145
1146        // Verify the size formula: 84 + 1 * 50 = 134
1147        assert_eq!(data.len(), 134);
1148
1149        let mut cursor = Cursor::new(data);
1150        let fmt = detect_stl_format(&mut cursor).expect("detect should succeed");
1151        assert_eq!(
1152            fmt,
1153            StlFormat::Binary,
1154            "Binary STL with 'solid' in header must be detected as Binary"
1155        );
1156    }
1157
1158    // ===== Test 4: read_ascii parses a simple single-triangle STL =====
1159
1160    #[test]
1161    fn test_read_ascii_simple_triangle() {
1162        let ascii = "\
1163solid triangle
1164  facet normal 0 0 1
1165    outer loop
1166      vertex 0 0 0
1167      vertex 1 0 0
1168      vertex 0 1 0
1169    endloop
1170  endfacet
1171endsolid triangle
1172";
1173        let model = StlImporter::read_ascii(Cursor::new(ascii)).expect("parse should succeed");
1174
1175        assert_eq!(model.build.items.len(), 1);
1176        let obj = model
1177            .resources
1178            .get_object(ResourceId(1))
1179            .expect("object 1 should exist");
1180        if let lib3mf_core::model::Geometry::Mesh(mesh) = &obj.geometry {
1181            assert_eq!(mesh.vertices.len(), 3, "should have 3 unique vertices");
1182            assert_eq!(mesh.triangles.len(), 1, "should have 1 triangle");
1183            // Check vertex coordinates
1184            assert!((mesh.vertices[0].x - 0.0).abs() < 1e-6);
1185            assert!((mesh.vertices[1].x - 1.0).abs() < 1e-6);
1186            assert!((mesh.vertices[2].y - 1.0).abs() < 1e-6);
1187        } else {
1188            panic!("expected Mesh geometry");
1189        }
1190    }
1191
1192    // ===== Test 5: case-insensitive keywords =====
1193
1194    #[test]
1195    fn test_read_ascii_case_insensitive() {
1196        let ascii = "\
1197SOLID uppercase
1198  FACET NORMAL 0 0 1
1199    OUTER LOOP
1200      VERTEX 0 0 0
1201      VERTEX 1 0 0
1202      VERTEX 0 1 0
1203    ENDLOOP
1204  ENDFACET
1205ENDSOLID uppercase
1206";
1207        let model = StlImporter::read_ascii(Cursor::new(ascii)).expect("parse should succeed");
1208        assert_eq!(model.build.items.len(), 1);
1209        let obj = model.resources.get_object(ResourceId(1)).unwrap();
1210        if let lib3mf_core::model::Geometry::Mesh(mesh) = &obj.geometry {
1211            assert_eq!(mesh.triangles.len(), 1);
1212        } else {
1213            panic!("expected Mesh geometry");
1214        }
1215    }
1216
1217    // ===== Test 6: multi-solid creates multiple objects =====
1218
1219    #[test]
1220    fn test_read_ascii_multi_solid() {
1221        let ascii = "\
1222solid part_a
1223  facet normal 0 0 1
1224    outer loop
1225      vertex 0 0 0
1226      vertex 1 0 0
1227      vertex 0 1 0
1228    endloop
1229  endfacet
1230endsolid part_a
1231solid part_b
1232  facet normal 0 0 -1
1233    outer loop
1234      vertex 0 0 1
1235      vertex 1 0 1
1236      vertex 0 1 1
1237    endloop
1238  endfacet
1239endsolid part_b
1240";
1241        let model = StlImporter::read_ascii(Cursor::new(ascii)).expect("parse should succeed");
1242        assert_eq!(model.build.items.len(), 2, "should have 2 build items");
1243
1244        let obj1 = model.resources.get_object(ResourceId(1)).expect("object 1");
1245        let obj2 = model.resources.get_object(ResourceId(2)).expect("object 2");
1246
1247        if let lib3mf_core::model::Geometry::Mesh(m) = &obj1.geometry {
1248            assert_eq!(m.triangles.len(), 1);
1249        }
1250        if let lib3mf_core::model::Geometry::Mesh(m) = &obj2.geometry {
1251            assert_eq!(m.triangles.len(), 1);
1252        }
1253    }
1254
1255    // ===== Test 7: solid name with spaces =====
1256
1257    #[test]
1258    fn test_read_ascii_solid_name_with_spaces() {
1259        let ascii = "\
1260solid My Cool Part
1261  facet normal 0 0 1
1262    outer loop
1263      vertex 0 0 0
1264      vertex 1 0 0
1265      vertex 0 1 0
1266    endloop
1267  endfacet
1268endsolid My Cool Part
1269";
1270        let model = StlImporter::read_ascii(Cursor::new(ascii)).expect("parse should succeed");
1271        let obj = model.resources.get_object(ResourceId(1)).expect("object 1");
1272        assert_eq!(obj.name, Some("My Cool Part".to_string()));
1273    }
1274
1275    // ===== Test 8: vertex deduplication =====
1276
1277    #[test]
1278    fn test_read_ascii_vertex_dedup() {
1279        // Two triangles sharing two vertices
1280        let ascii = "\
1281solid dedup
1282  facet normal 0 0 1
1283    outer loop
1284      vertex 0 0 0
1285      vertex 1 0 0
1286      vertex 0 1 0
1287    endloop
1288  endfacet
1289  facet normal 0 0 1
1290    outer loop
1291      vertex 1 0 0
1292      vertex 1 1 0
1293      vertex 0 1 0
1294    endloop
1295  endfacet
1296endsolid dedup
1297";
1298        let model = StlImporter::read_ascii(Cursor::new(ascii)).expect("parse should succeed");
1299        let obj = model.resources.get_object(ResourceId(1)).expect("object 1");
1300        if let lib3mf_core::model::Geometry::Mesh(mesh) = &obj.geometry {
1301            assert_eq!(mesh.triangles.len(), 2);
1302            // 4 unique vertices: (0,0,0), (1,0,0), (0,1,0), (1,1,0)
1303            assert_eq!(
1304                mesh.vertices.len(),
1305                4,
1306                "shared vertices should be deduplicated"
1307            );
1308        } else {
1309            panic!("expected Mesh");
1310        }
1311    }
1312
1313    // ===== Test 9: mismatched endsolid name is accepted =====
1314
1315    #[test]
1316    fn test_read_ascii_mismatched_endsolid() {
1317        let ascii = "\
1318solid foo
1319  facet normal 0 0 1
1320    outer loop
1321      vertex 0 0 0
1322      vertex 1 0 0
1323      vertex 0 1 0
1324    endloop
1325  endfacet
1326endsolid bar
1327";
1328        let model = StlImporter::read_ascii(Cursor::new(ascii)).expect("parse should succeed");
1329        assert_eq!(
1330            model.build.items.len(),
1331            1,
1332            "mismatched endsolid name should not cause error"
1333        );
1334    }
1335
1336    // ===== Test 10: file ends without endsolid =====
1337
1338    #[test]
1339    fn test_read_ascii_no_endsolid() {
1340        let ascii = "\
1341solid truncated
1342  facet normal 0 0 1
1343    outer loop
1344      vertex 0 0 0
1345      vertex 1 0 0
1346      vertex 0 1 0
1347    endloop
1348  endfacet
1349";
1350        let model = StlImporter::read_ascii(Cursor::new(ascii)).expect("parse should succeed");
1351        assert_eq!(
1352            model.build.items.len(),
1353            1,
1354            "truncated file should still produce one object"
1355        );
1356        let obj = model.resources.get_object(ResourceId(1)).expect("object 1");
1357        if let lib3mf_core::model::Geometry::Mesh(mesh) = &obj.geometry {
1358            assert_eq!(mesh.triangles.len(), 1);
1359        }
1360    }
1361
1362    // ===== Test 11: write_ascii_simple produces expected keywords =====
1363
1364    #[test]
1365    fn test_write_ascii_simple() {
1366        // A single triangle in the XY plane with normal pointing +Z
1367        let model = make_simple_model(
1368            vec![(0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (0.0, 1.0, 0.0)],
1369            vec![(0, 1, 2)],
1370            Some("test"),
1371        );
1372
1373        let mut output = Vec::new();
1374        AsciiStlExporter::write(&model, &mut output).expect("write should succeed");
1375        let text = String::from_utf8(output).expect("valid UTF-8");
1376
1377        assert!(
1378            text.contains("solid test"),
1379            "should contain solid keyword with name"
1380        );
1381        assert!(text.contains("facet normal"), "should contain facet normal");
1382        assert!(text.contains("outer loop"), "should contain outer loop");
1383        assert!(text.contains("vertex"), "should contain vertex lines");
1384        assert!(text.contains("endloop"), "should contain endloop");
1385        assert!(text.contains("endfacet"), "should contain endfacet");
1386        assert!(
1387            text.contains("endsolid test"),
1388            "should contain endsolid keyword with name"
1389        );
1390
1391        // Normal should be non-zero for a valid triangle
1392        // The normal should be (0, 0, 1) for the XY-plane triangle
1393        let has_nonzero_normal = text
1394            .lines()
1395            .filter(|l| l.trim().starts_with("facet normal"))
1396            .any(|l| {
1397                let parts: Vec<&str> = l.split_whitespace().collect();
1398                if parts.len() >= 5 {
1399                    let nz: f64 = parts[4].parse().unwrap_or(0.0);
1400                    nz.abs() > 0.5
1401                } else {
1402                    false
1403                }
1404            });
1405        assert!(
1406            has_nonzero_normal,
1407            "normal should be non-zero for valid triangle"
1408        );
1409    }
1410
1411    // ===== Test 12: degenerate triangle emits zero normal =====
1412
1413    #[test]
1414    fn test_write_ascii_degenerate_normal() {
1415        // All three vertices are collinear — degenerate triangle
1416        let model = make_simple_model(
1417            vec![(0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (2.0, 0.0, 0.0)],
1418            vec![(0, 1, 2)],
1419            None,
1420        );
1421
1422        let mut output = Vec::new();
1423        AsciiStlExporter::write(&model, &mut output).expect("write should succeed");
1424        let text = String::from_utf8(output).expect("valid UTF-8");
1425
1426        let normal_line = text
1427            .lines()
1428            .find(|l| l.trim().starts_with("facet normal"))
1429            .expect("should have facet normal line");
1430
1431        let parts: Vec<&str> = normal_line.split_whitespace().collect();
1432        assert!(parts.len() >= 5, "facet normal line should have 5 parts");
1433        let nx: f64 = parts[2].parse().unwrap_or(f64::NAN);
1434        let ny: f64 = parts[3].parse().unwrap_or(f64::NAN);
1435        let nz: f64 = parts[4].parse().unwrap_or(f64::NAN);
1436        assert!(
1437            nx.abs() < 1e-6 && ny.abs() < 1e-6 && nz.abs() < 1e-6,
1438            "degenerate triangle normal should be (0,0,0), got ({nx}, {ny}, {nz})"
1439        );
1440    }
1441
1442    // ===== Test 13: object name in solid/endsolid =====
1443
1444    #[test]
1445    fn test_write_ascii_object_name() {
1446        let model = make_simple_model(
1447            vec![(0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (0.0, 1.0, 0.0)],
1448            vec![(0, 1, 2)],
1449            Some("MyPart"),
1450        );
1451
1452        let mut output = Vec::new();
1453        AsciiStlExporter::write(&model, &mut output).expect("write should succeed");
1454        let text = String::from_utf8(output).expect("valid UTF-8");
1455
1456        let first_line = text.lines().next().expect("should have lines");
1457        assert_eq!(
1458            first_line, "solid MyPart",
1459            "first line should be 'solid MyPart'"
1460        );
1461
1462        let last_line = text
1463            .lines()
1464            .filter(|l| !l.is_empty())
1465            .last()
1466            .expect("should have lines");
1467        assert_eq!(
1468            last_line, "endsolid MyPart",
1469            "last line should be 'endsolid MyPart'"
1470        );
1471    }
1472
1473    // ===== Test 14: roundtrip ASCII STL -> Model -> ASCII STL -> Model =====
1474
1475    #[test]
1476    fn test_roundtrip_ascii() {
1477        // Create a simple model: 4 vertices, 2 triangles (a flat square)
1478        let model = make_simple_model(
1479            vec![
1480                (0.0, 0.0, 0.0),
1481                (1.0, 0.0, 0.0),
1482                (1.0, 1.0, 0.0),
1483                (0.0, 1.0, 0.0),
1484            ],
1485            vec![(0, 1, 2), (0, 2, 3)],
1486            Some("RoundtripTest"),
1487        );
1488
1489        // Write to ASCII
1490        let mut buf1 = Vec::new();
1491        AsciiStlExporter::write(&model, &mut buf1).expect("first write should succeed");
1492
1493        // Parse back
1494        let model2 =
1495            StlImporter::read_ascii(Cursor::new(&buf1)).expect("first re-read should succeed");
1496
1497        // Write again
1498        let mut buf2 = Vec::new();
1499        AsciiStlExporter::write(&model2, &mut buf2).expect("second write should succeed");
1500
1501        // Parse again
1502        let model3 =
1503            StlImporter::read_ascii(Cursor::new(&buf2)).expect("second re-read should succeed");
1504
1505        // Compare: same vertex count, triangle count, and positions
1506        let get_mesh_info = |m: &Model| -> (usize, usize, Vec<(f32, f32, f32)>) {
1507            let obj = m.resources.get_object(ResourceId(1)).expect("object 1");
1508            if let lib3mf_core::model::Geometry::Mesh(mesh) = &obj.geometry {
1509                let verts: Vec<(f32, f32, f32)> =
1510                    mesh.vertices.iter().map(|v| (v.x, v.y, v.z)).collect();
1511                (mesh.vertices.len(), mesh.triangles.len(), verts)
1512            } else {
1513                panic!("expected Mesh");
1514            }
1515        };
1516
1517        let (v_count2, t_count2, verts2) = get_mesh_info(&model2);
1518        let (v_count3, t_count3, verts3) = get_mesh_info(&model3);
1519
1520        assert_eq!(
1521            v_count2, v_count3,
1522            "vertex count must be stable across roundtrips"
1523        );
1524        assert_eq!(
1525            t_count2, t_count3,
1526            "triangle count must be stable across roundtrips"
1527        );
1528
1529        // Vertex positions should match within f32 formatting tolerance (1e-5)
1530        for (i, (&(x2, y2, z2), &(x3, y3, z3))) in verts2.iter().zip(verts3.iter()).enumerate() {
1531            assert!(
1532                (x2 - x3).abs() < 1e-5 && (y2 - y3).abs() < 1e-5 && (z2 - z3).abs() < 1e-5,
1533                "vertex {i} position mismatch: ({x2},{y2},{z2}) vs ({x3},{y3},{z3})"
1534            );
1535        }
1536
1537        // Original model should also match second model structurally
1538        assert_eq!(v_count2, 4, "should have 4 vertices");
1539        assert_eq!(t_count2, 2, "should have 2 triangles");
1540    }
1541
1542    // ===== Test 15: BinaryStlExporter::write produces correct binary output =====
1543
1544    #[test]
1545    fn test_write_binary_simple() {
1546        use byteorder::{LittleEndian, ReadBytesExt};
1547
1548        // One triangle: vertices at (0,0,0), (1,0,0), (0,1,0)
1549        let model = make_simple_model(
1550            vec![(0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (0.0, 1.0, 0.0)],
1551            vec![(0, 1, 2)],
1552            None,
1553        );
1554
1555        let mut buf = Vec::new();
1556        BinaryStlExporter::write(&model, Cursor::new(&mut buf)).expect("write should succeed");
1557
1558        // Total: 80-byte header + 4-byte count + 1 * 50-byte triangle = 134
1559        assert_eq!(
1560            buf.len(),
1561            134,
1562            "binary STL size should be 134 bytes for 1 triangle"
1563        );
1564
1565        // Triangle count at bytes 80..84
1566        let mut count_bytes = Cursor::new(&buf[80..84]);
1567        let tri_count = count_bytes.read_u32::<LittleEndian>().unwrap();
1568        assert_eq!(tri_count, 1, "triangle count should be 1");
1569
1570        // Triangle data layout (per triangle):
1571        //   bytes 84..96:  normal (3 x f32 = 12 bytes)
1572        //   bytes 96..108: vertex 1 (3 x f32 = 12 bytes)
1573        //   bytes 108..120: vertex 2 (3 x f32 = 12 bytes)
1574        //   bytes 120..132: vertex 3 (3 x f32 = 12 bytes)
1575        //   bytes 132..134: attribute byte count (u16 = 2 bytes)
1576        let mut tri_cursor = Cursor::new(&buf[96..]);
1577
1578        // Vertex 1: (0,0,0)
1579        let v1x = tri_cursor.read_f32::<LittleEndian>().unwrap();
1580        let v1y = tri_cursor.read_f32::<LittleEndian>().unwrap();
1581        let v1z = tri_cursor.read_f32::<LittleEndian>().unwrap();
1582        assert!((v1x - 0.0).abs() < 1e-6, "v1.x should be 0.0, got {v1x}");
1583        assert!((v1y - 0.0).abs() < 1e-6, "v1.y should be 0.0, got {v1y}");
1584        assert!((v1z - 0.0).abs() < 1e-6, "v1.z should be 0.0, got {v1z}");
1585
1586        // Vertex 2: (1,0,0)
1587        let v2x = tri_cursor.read_f32::<LittleEndian>().unwrap();
1588        let v2y = tri_cursor.read_f32::<LittleEndian>().unwrap();
1589        let v2z = tri_cursor.read_f32::<LittleEndian>().unwrap();
1590        assert!((v2x - 1.0).abs() < 1e-6, "v2.x should be 1.0, got {v2x}");
1591        assert!((v2y - 0.0).abs() < 1e-6, "v2.y should be 0.0, got {v2y}");
1592        assert!((v2z - 0.0).abs() < 1e-6, "v2.z should be 0.0, got {v2z}");
1593
1594        // Vertex 3: (0,1,0)
1595        let v3x = tri_cursor.read_f32::<LittleEndian>().unwrap();
1596        let v3y = tri_cursor.read_f32::<LittleEndian>().unwrap();
1597        let v3z = tri_cursor.read_f32::<LittleEndian>().unwrap();
1598        assert!((v3x - 0.0).abs() < 1e-6, "v3.x should be 0.0, got {v3x}");
1599        assert!((v3y - 1.0).abs() < 1e-6, "v3.y should be 1.0, got {v3y}");
1600        assert!((v3z - 0.0).abs() < 1e-6, "v3.z should be 0.0, got {v3z}");
1601    }
1602
1603    // ===== Test 16: BinaryStlExporter::write roundtrip preserves triangle count =====
1604
1605    #[test]
1606    fn test_roundtrip_binary() {
1607        // Create a flat square with 4 vertices and 2 triangles
1608        let model = make_simple_model(
1609            vec![
1610                (0.0, 0.0, 0.0),
1611                (1.0, 0.0, 0.0),
1612                (1.0, 1.0, 0.0),
1613                (0.0, 1.0, 0.0),
1614            ],
1615            vec![(0, 1, 2), (0, 2, 3)],
1616            None,
1617        );
1618
1619        // Write to binary STL
1620        let mut buf = Vec::new();
1621        BinaryStlExporter::write(&model, Cursor::new(&mut buf)).expect("write should succeed");
1622
1623        // Read back using StlImporter
1624        let model2 =
1625            StlImporter::read(Cursor::new(buf)).expect("binary roundtrip read should succeed");
1626
1627        // Binary STL import deduplicates vertices, so only check triangle count
1628        let obj2 = model2
1629            .resources
1630            .get_object(ResourceId(1))
1631            .expect("object 1");
1632        if let lib3mf_core::model::Geometry::Mesh(mesh2) = &obj2.geometry {
1633            assert_eq!(
1634                mesh2.triangles.len(),
1635                2,
1636                "read-back should have 2 triangles"
1637            );
1638        } else {
1639            panic!("expected Mesh geometry");
1640        }
1641    }
1642
1643    // ===== Test 17: BinaryStlExporter::write combines triangles from multiple build items =====
1644
1645    #[test]
1646    fn test_write_binary_multi_object() {
1647        use byteorder::{LittleEndian, ReadBytesExt};
1648        use lib3mf_core::model::{Geometry, Object, ObjectType};
1649
1650        // Object 1: 1 triangle
1651        let mut mesh1 = Mesh::default();
1652        mesh1.vertices.push(Vertex {
1653            x: 0.0,
1654            y: 0.0,
1655            z: 0.0,
1656        });
1657        mesh1.vertices.push(Vertex {
1658            x: 1.0,
1659            y: 0.0,
1660            z: 0.0,
1661        });
1662        mesh1.vertices.push(Vertex {
1663            x: 0.0,
1664            y: 1.0,
1665            z: 0.0,
1666        });
1667        mesh1.triangles.push(Triangle {
1668            v1: 0,
1669            v2: 1,
1670            v3: 2,
1671            ..Default::default()
1672        });
1673
1674        let obj1 = Object {
1675            id: ResourceId(1),
1676            object_type: ObjectType::Model,
1677            name: None,
1678            part_number: None,
1679            uuid: None,
1680            pid: None,
1681            pindex: None,
1682            thumbnail: None,
1683            geometry: Geometry::Mesh(mesh1),
1684        };
1685
1686        // Object 2: 2 triangles
1687        let mut mesh2 = Mesh::default();
1688        mesh2.vertices.push(Vertex {
1689            x: 0.0,
1690            y: 0.0,
1691            z: 1.0,
1692        });
1693        mesh2.vertices.push(Vertex {
1694            x: 1.0,
1695            y: 0.0,
1696            z: 1.0,
1697        });
1698        mesh2.vertices.push(Vertex {
1699            x: 1.0,
1700            y: 1.0,
1701            z: 1.0,
1702        });
1703        mesh2.vertices.push(Vertex {
1704            x: 0.0,
1705            y: 1.0,
1706            z: 1.0,
1707        });
1708        mesh2.triangles.push(Triangle {
1709            v1: 0,
1710            v2: 1,
1711            v3: 2,
1712            ..Default::default()
1713        });
1714        mesh2.triangles.push(Triangle {
1715            v1: 0,
1716            v2: 2,
1717            v3: 3,
1718            ..Default::default()
1719        });
1720
1721        let obj2 = Object {
1722            id: ResourceId(2),
1723            object_type: ObjectType::Model,
1724            name: None,
1725            part_number: None,
1726            uuid: None,
1727            pid: None,
1728            pindex: None,
1729            thumbnail: None,
1730            geometry: Geometry::Mesh(mesh2),
1731        };
1732
1733        let mut model = Model::default();
1734        let _ = model.resources.add_object(obj1);
1735        let _ = model.resources.add_object(obj2);
1736        model.build.items.push(BuildItem {
1737            object_id: ResourceId(1),
1738            transform: glam::Mat4::IDENTITY,
1739            part_number: None,
1740            uuid: None,
1741            path: None,
1742            printable: None,
1743        });
1744        model.build.items.push(BuildItem {
1745            object_id: ResourceId(2),
1746            transform: glam::Mat4::IDENTITY,
1747            part_number: None,
1748            uuid: None,
1749            path: None,
1750            printable: None,
1751        });
1752
1753        let mut buf = Vec::new();
1754        BinaryStlExporter::write(&model, Cursor::new(&mut buf)).expect("write should succeed");
1755
1756        // Total: 80 + 4 + 3 * 50 = 234 bytes
1757        assert_eq!(
1758            buf.len(),
1759            234,
1760            "binary STL size should be 234 bytes for 3 triangles"
1761        );
1762
1763        // Triangle count at bytes 80..84 should be 3
1764        let mut count_cursor = Cursor::new(&buf[80..84]);
1765        let tri_count = count_cursor.read_u32::<LittleEndian>().unwrap();
1766        assert_eq!(tri_count, 3, "combined triangle count should be 3 (1 + 2)");
1767    }
1768
1769    // ===== Test 18: auto-detect dispatches to binary parser =====
1770
1771    #[test]
1772    fn test_auto_detect_read_binary() {
1773        let header = [0u8; 80];
1774        let tris = vec![(0.0f32, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0)];
1775        let data = make_binary_stl(&header, &tris);
1776        let cursor = Cursor::new(data);
1777        let model = StlImporter::read(cursor).expect("auto-detect binary should succeed");
1778
1779        assert_eq!(model.build.items.len(), 1);
1780        let obj = model.resources.get_object(ResourceId(1)).expect("object 1");
1781        if let lib3mf_core::model::Geometry::Mesh(mesh) = &obj.geometry {
1782            assert_eq!(mesh.triangles.len(), 1, "should have 1 triangle");
1783        } else {
1784            panic!("expected Mesh");
1785        }
1786    }
1787
1788    // ===== Test 19: auto-detect dispatches to ASCII parser =====
1789
1790    #[test]
1791    fn test_auto_detect_read_ascii() {
1792        let ascii = "\
1793solid autotest
1794  facet normal 0 0 1
1795    outer loop
1796      vertex 0 0 0
1797      vertex 1 0 0
1798      vertex 0 1 0
1799    endloop
1800  endfacet
1801endsolid autotest
1802";
1803        let cursor = Cursor::new(ascii.as_bytes().to_vec());
1804        let model = StlImporter::read(cursor).expect("auto-detect ASCII should succeed");
1805
1806        assert_eq!(model.build.items.len(), 1);
1807        let obj = model.resources.get_object(ResourceId(1)).expect("object 1");
1808        assert_eq!(obj.name, Some("autotest".to_string()));
1809        if let lib3mf_core::model::Geometry::Mesh(mesh) = &obj.geometry {
1810            assert_eq!(mesh.triangles.len(), 1, "should have 1 triangle");
1811        } else {
1812            panic!("expected Mesh");
1813        }
1814    }
1815}