lib3mf_converters/stl.rs
1//! Binary STL format import and export.
2//!
3//! This module provides conversion between binary STL files and 3MF [`Model`] structures.
4//!
5//! ## STL Format
6//!
7//! The binary STL format consists of:
8//! - 80-byte header (typically unused, set to zeros)
9//! - 4-byte little-endian unsigned integer triangle count
10//! - For each triangle:
11//! - 12 bytes: normal vector (3 × f32, often ignored by importers)
12//! - 12 bytes: vertex 1 (x, y, z as f32)
13//! - 12 bytes: vertex 2 (x, y, z as f32)
14//! - 12 bytes: vertex 3 (x, y, z as f32)
15//! - 2 bytes: attribute byte count (typically 0)
16//!
17//! **Note:** ASCII STL format is not supported.
18//!
19//! ## Examples
20//!
21//! ### Importing STL
22//!
23//! ```no_run
24//! use lib3mf_converters::stl::StlImporter;
25//! use std::fs::File;
26//!
27//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
28//! let file = File::open("model.stl")?;
29//! let model = StlImporter::read(file)?;
30//! println!("Imported {} vertices",
31//! model.resources.iter_objects()
32//! .filter_map(|obj| match &obj.geometry {
33//! lib3mf_core::model::Geometry::Mesh(m) => Some(m.vertices.len()),
34//! _ => None,
35//! })
36//! .sum::<usize>()
37//! );
38//! # Ok(())
39//! # }
40//! ```
41//!
42//! ### Exporting STL
43//!
44//! ```no_run
45//! use lib3mf_converters::stl::StlExporter;
46//! use lib3mf_core::model::Model;
47//! use std::fs::File;
48//!
49//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
50//! # let model = Model::default();
51//! let file = File::create("output.stl")?;
52//! StlExporter::write(&model, file)?;
53//! # Ok(())
54//! # }
55//! ```
56//!
57//! [`Model`]: lib3mf_core::model::Model
58
59use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt};
60use lib3mf_core::error::{Lib3mfError, Result};
61use lib3mf_core::model::resources::ResourceId;
62use lib3mf_core::model::{BuildItem, Mesh, Model, Triangle, Vertex};
63use std::io::{Read, Write};
64
65/// Imports binary STL files into 3MF [`Model`] structures.
66///
67/// The importer reads binary STL format and creates a single mesh object with ResourceId(1).
68/// Vertices are deduplicated using bitwise float comparison during import.
69///
70/// [`Model`]: lib3mf_core::model::Model
71pub struct StlImporter;
72
73impl Default for StlImporter {
74 fn default() -> Self {
75 Self::new()
76 }
77}
78
79impl StlImporter {
80 /// Creates a new STL importer instance.
81 pub fn new() -> Self {
82 Self
83 }
84
85 /// Reads a binary STL file and converts it to a 3MF [`Model`].
86 ///
87 /// # Arguments
88 ///
89 /// * `reader` - Any type implementing [`Read`] containing binary STL data
90 ///
91 /// # Returns
92 ///
93 /// A [`Model`] containing:
94 /// - Single mesh object with ResourceId(1) named "STL Import"
95 /// - All triangles from the STL file
96 /// - Deduplicated vertices (using bitwise float comparison)
97 /// - Single build item referencing the mesh object
98 ///
99 /// # Errors
100 ///
101 /// Returns [`Lib3mfError::Io`] if:
102 /// - Cannot read 80-byte header
103 /// - Cannot read triangle count
104 /// - Cannot read triangle data (normals, vertices, attribute bytes)
105 ///
106 /// Returns [`Lib3mfError::Validation`] if triangle count field cannot be parsed.
107 ///
108 /// # Format Details
109 ///
110 /// - **Vertex deduplication**: Uses HashMap with bitwise float comparison `[x.to_bits(), y.to_bits(), z.to_bits()]`
111 /// as key. Only exactly identical vertices (bitwise) are merged.
112 /// - **Normal vectors**: Read from STL but ignored (not stored in Model).
113 /// - **Attribute bytes**: Read but ignored (2-byte field after each triangle).
114 ///
115 /// # Examples
116 ///
117 /// ```no_run
118 /// use lib3mf_converters::stl::StlImporter;
119 /// use std::fs::File;
120 ///
121 /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
122 /// let file = File::open("cube.stl")?;
123 /// let model = StlImporter::read(file)?;
124 ///
125 /// // Access the imported mesh
126 /// let obj = model.resources.get_object(lib3mf_core::model::resources::ResourceId(1))
127 /// .expect("STL import creates object with ID 1");
128 /// if let lib3mf_core::model::Geometry::Mesh(mesh) = &obj.geometry {
129 /// println!("Imported {} vertices, {} triangles",
130 /// mesh.vertices.len(), mesh.triangles.len());
131 /// }
132 /// # Ok(())
133 /// # }
134 /// ```
135 ///
136 /// [`Model`]: lib3mf_core::model::Model
137 /// [`Lib3mfError::Io`]: lib3mf_core::error::Lib3mfError::Io
138 /// [`Lib3mfError::Validation`]: lib3mf_core::error::Lib3mfError::Validation
139 pub fn read<R: Read>(mut reader: R) -> Result<Model> {
140 // STL Format:
141 // 80 bytes header
142 // 4 bytes triangle info (u32)
143 // Triangles...
144
145 let mut header = [0u8; 80];
146 reader.read_exact(&mut header).map_err(Lib3mfError::Io)?;
147
148 let triangle_count = reader.read_u32::<LittleEndian>().map_err(|_| {
149 Lib3mfError::Validation("Failed to read STL triangle count".to_string())
150 })?;
151
152 let mut mesh = Mesh::default();
153 use std::collections::HashMap;
154 let mut vert_map: HashMap<[u32; 3], u32> = HashMap::new();
155
156 for _ in 0..triangle_count {
157 // Normal (3 floats) - Ignored
158 let _nx = reader.read_f32::<LittleEndian>().map_err(Lib3mfError::Io)?;
159 let _ny = reader.read_f32::<LittleEndian>().map_err(Lib3mfError::Io)?;
160 let _nz = reader.read_f32::<LittleEndian>().map_err(Lib3mfError::Io)?;
161
162 let mut indices = [0u32; 3];
163
164 for index in &mut indices {
165 let x = reader.read_f32::<LittleEndian>().map_err(Lib3mfError::Io)?;
166 let y = reader.read_f32::<LittleEndian>().map_err(Lib3mfError::Io)?;
167 let z = reader.read_f32::<LittleEndian>().map_err(Lib3mfError::Io)?;
168
169 let key = [x.to_bits(), y.to_bits(), z.to_bits()];
170
171 let idx = *vert_map.entry(key).or_insert_with(|| {
172 let new_idx = mesh.vertices.len() as u32;
173 mesh.vertices.push(Vertex { x, y, z });
174 new_idx
175 });
176 *index = idx;
177 }
178
179 let _attr_byte_count = reader.read_u16::<LittleEndian>().map_err(Lib3mfError::Io)?;
180
181 mesh.triangles.push(Triangle {
182 v1: indices[0],
183 v2: indices[1],
184 v3: indices[2],
185 ..Default::default()
186 });
187 }
188
189 let mut model = Model::default();
190 let resource_id = ResourceId(1); // Default ID
191
192 let object = lib3mf_core::model::Object {
193 id: resource_id,
194 object_type: lib3mf_core::model::ObjectType::Model,
195 name: Some("STL Import".to_string()),
196 part_number: None,
197 uuid: None,
198 pid: None,
199 pindex: None,
200 thumbnail: None,
201 geometry: lib3mf_core::model::Geometry::Mesh(mesh),
202 };
203
204 let _ = model.resources.add_object(object);
205
206 model.build.items.push(BuildItem {
207 object_id: resource_id,
208 transform: glam::Mat4::IDENTITY,
209 part_number: None,
210 uuid: None, // Generate one?
211 path: None,
212 printable: None,
213 });
214
215 Ok(model)
216 }
217}
218
219/// Exports 3MF [`Model`] structures to binary STL files.
220///
221/// The exporter flattens all mesh objects referenced in build items into a single STL file,
222/// applying build item transformations to vertex coordinates.
223///
224/// [`Model`]: lib3mf_core::model::Model
225pub struct StlExporter;
226
227impl StlExporter {
228 /// Writes a 3MF [`Model`] to binary STL format.
229 ///
230 /// # Arguments
231 ///
232 /// * `model` - The 3MF model to export
233 /// * `writer` - Any type implementing [`Write`] to receive STL data
234 ///
235 /// # Returns
236 ///
237 /// `Ok(())` on successful export.
238 ///
239 /// # Errors
240 ///
241 /// Returns [`Lib3mfError::Io`] if any write operation fails.
242 ///
243 /// # Format Details
244 ///
245 /// - **Header**: 80 zero bytes (standard for most STL files)
246 /// - **Normals**: Written as (0, 0, 0) - viewers must compute face normals
247 /// - **Transformations**: Build item transforms are applied to vertex coordinates
248 /// - **Attribute bytes**: Written as 0 (no extended attributes)
249 ///
250 /// # Behavior
251 ///
252 /// - Only mesh objects from `model.build.items` are exported
253 /// - Non-mesh geometries (Components, BooleanShape, etc.) are skipped
254 /// - Each build item's transformation matrix is applied to its mesh vertices
255 /// - All triangles from all build items are combined into a single STL file
256 ///
257 /// # Examples
258 ///
259 /// ```no_run
260 /// use lib3mf_converters::stl::StlExporter;
261 /// use lib3mf_core::model::Model;
262 /// use std::fs::File;
263 ///
264 /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
265 /// # let model = Model::default();
266 /// let output = File::create("exported.stl")?;
267 /// StlExporter::write(&model, output)?;
268 /// println!("Model exported successfully");
269 /// # Ok(())
270 /// # }
271 /// ```
272 ///
273 /// [`Model`]: lib3mf_core::model::Model
274 /// [`Lib3mfError::Io`]: lib3mf_core::error::Lib3mfError::Io
275 pub fn write<W: Write>(model: &Model, mut writer: W) -> Result<()> {
276 // 1. Collect all triangles from all build items
277 let mut triangles: Vec<(glam::Vec3, glam::Vec3, glam::Vec3)> = Vec::new(); // v1, v2, v3
278
279 for item in &model.build.items {
280 #[allow(clippy::collapsible_if)]
281 if let Some(object) = model.resources.get_object(item.object_id) {
282 if let lib3mf_core::model::Geometry::Mesh(mesh) = &object.geometry {
283 let transform = item.transform;
284
285 for tri in &mesh.triangles {
286 let v1_local = mesh.vertices[tri.v1 as usize];
287 let v2_local = mesh.vertices[tri.v2 as usize];
288 let v3_local = mesh.vertices[tri.v3 as usize];
289
290 let v1 = transform
291 .transform_point3(glam::Vec3::new(v1_local.x, v1_local.y, v1_local.z));
292 let v2 = transform
293 .transform_point3(glam::Vec3::new(v2_local.x, v2_local.y, v2_local.z));
294 let v3 = transform
295 .transform_point3(glam::Vec3::new(v3_local.x, v3_local.y, v3_local.z));
296
297 triangles.push((v1, v2, v3));
298 }
299 }
300 }
301 }
302
303 // 2. Write Header (80 bytes)
304 let header = [0u8; 80];
305 writer.write_all(&header).map_err(Lib3mfError::Io)?;
306
307 // 3. Write Count
308 writer
309 .write_u32::<LittleEndian>(triangles.len() as u32)
310 .map_err(Lib3mfError::Io)?;
311
312 // 4. Write Triangles
313 for (v1, v2, v3) in triangles {
314 // Normal (0,0,0) - let viewer calculate
315 writer
316 .write_f32::<LittleEndian>(0.0)
317 .map_err(Lib3mfError::Io)?;
318 writer
319 .write_f32::<LittleEndian>(0.0)
320 .map_err(Lib3mfError::Io)?;
321 writer
322 .write_f32::<LittleEndian>(0.0)
323 .map_err(Lib3mfError::Io)?;
324
325 // v1
326 writer
327 .write_f32::<LittleEndian>(v1.x)
328 .map_err(Lib3mfError::Io)?;
329 writer
330 .write_f32::<LittleEndian>(v1.y)
331 .map_err(Lib3mfError::Io)?;
332 writer
333 .write_f32::<LittleEndian>(v1.z)
334 .map_err(Lib3mfError::Io)?;
335
336 // v2
337 writer
338 .write_f32::<LittleEndian>(v2.x)
339 .map_err(Lib3mfError::Io)?;
340 writer
341 .write_f32::<LittleEndian>(v2.y)
342 .map_err(Lib3mfError::Io)?;
343 writer
344 .write_f32::<LittleEndian>(v2.z)
345 .map_err(Lib3mfError::Io)?;
346
347 // v3
348 writer
349 .write_f32::<LittleEndian>(v3.x)
350 .map_err(Lib3mfError::Io)?;
351 writer
352 .write_f32::<LittleEndian>(v3.y)
353 .map_err(Lib3mfError::Io)?;
354 writer
355 .write_f32::<LittleEndian>(v3.z)
356 .map_err(Lib3mfError::Io)?;
357
358 // Attribute byte count (0)
359 writer
360 .write_u16::<LittleEndian>(0)
361 .map_err(Lib3mfError::Io)?;
362 }
363
364 Ok(())
365 }
366
367 /// Writes a 3MF [`Model`] to binary STL format with support for multi-part 3MF files.
368 ///
369 /// This method extends [`write`] by recursively resolving component references and external
370 /// model parts using a [`PartResolver`]. This is necessary for 3MF files with the Production
371 /// Extension that reference objects from external model parts.
372 ///
373 /// # Arguments
374 ///
375 /// * `model` - The root 3MF model to export
376 /// * `resolver` - A [`PartResolver`] for loading external model parts from the 3MF archive
377 /// * `writer` - Any type implementing [`Write`] to receive STL data
378 ///
379 /// # Returns
380 ///
381 /// `Ok(())` on successful export.
382 ///
383 /// # Errors
384 ///
385 /// Returns [`Lib3mfError::Io`] if any write operation fails.
386 ///
387 /// Returns errors from the resolver if external parts cannot be loaded.
388 ///
389 /// # Behavior
390 ///
391 /// - Recursively resolves component hierarchies using the PartResolver
392 /// - Follows external references via component `path` attributes
393 /// - Applies accumulated transformations through the component tree
394 /// - Flattens all resolved meshes into a single STL file
395 ///
396 /// # Examples
397 ///
398 /// ```no_run
399 /// use lib3mf_converters::stl::StlExporter;
400 /// use lib3mf_core::archive::ZipArchiver;
401 /// use lib3mf_core::model::resolver::PartResolver;
402 /// use std::fs::File;
403 ///
404 /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
405 /// # let model = lib3mf_core::model::Model::default();
406 /// let archive_file = File::open("multipart.3mf")?;
407 /// let mut archiver = ZipArchiver::new(archive_file)?;
408 /// let resolver = PartResolver::new(&mut archiver, model.clone());
409 ///
410 /// let output = File::create("output.stl")?;
411 /// StlExporter::write_with_resolver(&model, resolver, output)?;
412 /// # Ok(())
413 /// # }
414 /// ```
415 ///
416 /// [`write`]: StlExporter::write
417 /// [`Model`]: lib3mf_core::model::Model
418 /// [`PartResolver`]: lib3mf_core::model::resolver::PartResolver
419 /// [`Lib3mfError::Io`]: lib3mf_core::error::Lib3mfError::Io
420 pub fn write_with_resolver<W: Write, A: lib3mf_core::archive::ArchiveReader>(
421 model: &Model,
422 mut resolver: lib3mf_core::model::resolver::PartResolver<A>,
423 mut writer: W,
424 ) -> Result<()> {
425 // 1. Collect all triangles from all build items (recursively)
426 let mut triangles: Vec<(glam::Vec3, glam::Vec3, glam::Vec3)> = Vec::new();
427
428 for item in &model.build.items {
429 collect_triangles(
430 &mut resolver,
431 item.object_id,
432 item.transform,
433 None, // Start with root path (None)
434 &mut triangles,
435 )?;
436 }
437
438 // 2. Write Header (80 bytes)
439 let header = [0u8; 80];
440 writer.write_all(&header).map_err(Lib3mfError::Io)?;
441
442 // 3. Write Count
443 writer
444 .write_u32::<LittleEndian>(triangles.len() as u32)
445 .map_err(Lib3mfError::Io)?;
446
447 // 4. Write Triangles
448 for (v1, v2, v3) in triangles {
449 // Normal (0,0,0) - let viewer calculate
450 writer
451 .write_f32::<LittleEndian>(0.0)
452 .map_err(Lib3mfError::Io)?;
453 writer
454 .write_f32::<LittleEndian>(0.0)
455 .map_err(Lib3mfError::Io)?;
456 writer
457 .write_f32::<LittleEndian>(0.0)
458 .map_err(Lib3mfError::Io)?;
459
460 // v1
461 writer
462 .write_f32::<LittleEndian>(v1.x)
463 .map_err(Lib3mfError::Io)?;
464 writer
465 .write_f32::<LittleEndian>(v1.y)
466 .map_err(Lib3mfError::Io)?;
467 writer
468 .write_f32::<LittleEndian>(v1.z)
469 .map_err(Lib3mfError::Io)?;
470
471 // v2
472 writer
473 .write_f32::<LittleEndian>(v2.x)
474 .map_err(Lib3mfError::Io)?;
475 writer
476 .write_f32::<LittleEndian>(v2.y)
477 .map_err(Lib3mfError::Io)?;
478 writer
479 .write_f32::<LittleEndian>(v2.z)
480 .map_err(Lib3mfError::Io)?;
481
482 // v3
483 writer
484 .write_f32::<LittleEndian>(v3.x)
485 .map_err(Lib3mfError::Io)?;
486 writer
487 .write_f32::<LittleEndian>(v3.y)
488 .map_err(Lib3mfError::Io)?;
489 writer
490 .write_f32::<LittleEndian>(v3.z)
491 .map_err(Lib3mfError::Io)?;
492
493 // Attribute byte count (0)
494 writer
495 .write_u16::<LittleEndian>(0)
496 .map_err(Lib3mfError::Io)?;
497 }
498
499 Ok(())
500 }
501}
502
503fn collect_triangles<A: lib3mf_core::archive::ArchiveReader>(
504 resolver: &mut lib3mf_core::model::resolver::PartResolver<A>,
505 object_id: ResourceId,
506 transform: glam::Mat4,
507 path: Option<&str>,
508 triangles: &mut Vec<(glam::Vec3, glam::Vec3, glam::Vec3)>,
509) -> Result<()> {
510 // Resolve geometry
511 // Note: We need to clone the geometry or handle the borrow of resolver carefully.
512 // resolving returns a reference to Model and Object, which borrows from resolver.
513 // We can't keep that borrow while recursing (mutably borrowing resolver).
514 // So we need to clone the relevant data (Geometry) or restructure.
515 // Cloning Geometry (which contains Mesh) might be expensive but safe.
516 // OR: resolve_object returns reference, we inspect it, then drop reference before recursing.
517
518 let geometry = {
519 let res = resolver.resolve_object(object_id, path)?;
520 if let Some((_, obj)) = res {
521 Some(obj.geometry.clone()) // Cloning geometry to release borrow
522 } else {
523 None
524 }
525 };
526
527 if let Some(geo) = geometry {
528 match geo {
529 lib3mf_core::model::Geometry::Mesh(mesh) => {
530 for tri in &mesh.triangles {
531 let v1_local = mesh.vertices[tri.v1 as usize];
532 let v2_local = mesh.vertices[tri.v2 as usize];
533 let v3_local = mesh.vertices[tri.v3 as usize];
534
535 let v1 = transform
536 .transform_point3(glam::Vec3::new(v1_local.x, v1_local.y, v1_local.z));
537 let v2 = transform
538 .transform_point3(glam::Vec3::new(v2_local.x, v2_local.y, v2_local.z));
539 let v3 = transform
540 .transform_point3(glam::Vec3::new(v3_local.x, v3_local.y, v3_local.z));
541
542 triangles.push((v1, v2, v3));
543 }
544 }
545 lib3mf_core::model::Geometry::Components(comps) => {
546 for comp in comps.components {
547 let new_transform = transform * comp.transform;
548 // Path handling: If component references a different path/part, update it?
549 // Currently lib3mf-core resolver handles relative paths if we pass them.
550 // The component struct has a 'path' field? (Checking core definition...)
551 // Assuming Component has 'path' field which is Option<String>
552 // Wait, I need to check lib3mf_core::model::Component definition.
553 // Assuming it does based on previous experience, but let's be safe.
554 // Actually, looking at `print_tree` in commands.rs, it uses `comp.path`.
555
556 let next_path_store = comp.path.clone();
557 // If component has path, it overrides/is relative to current?
558 // Usually in 3MF, if path represents a model part.
559 // resolver logic: "if path is provided, look there".
560
561 let next_path = next_path_store.as_deref().or(path);
562
563 collect_triangles(
564 resolver,
565 comp.object_id,
566 new_transform,
567 next_path,
568 triangles,
569 )?;
570 }
571 }
572 _ => {}
573 }
574 }
575
576 Ok(())
577}