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 });
213
214 Ok(model)
215 }
216}
217
218/// Exports 3MF [`Model`] structures to binary STL files.
219///
220/// The exporter flattens all mesh objects referenced in build items into a single STL file,
221/// applying build item transformations to vertex coordinates.
222///
223/// [`Model`]: lib3mf_core::model::Model
224pub struct StlExporter;
225
226impl StlExporter {
227 /// Writes a 3MF [`Model`] to binary STL format.
228 ///
229 /// # Arguments
230 ///
231 /// * `model` - The 3MF model to export
232 /// * `writer` - Any type implementing [`Write`] to receive STL data
233 ///
234 /// # Returns
235 ///
236 /// `Ok(())` on successful export.
237 ///
238 /// # Errors
239 ///
240 /// Returns [`Lib3mfError::Io`] if any write operation fails.
241 ///
242 /// # Format Details
243 ///
244 /// - **Header**: 80 zero bytes (standard for most STL files)
245 /// - **Normals**: Written as (0, 0, 0) - viewers must compute face normals
246 /// - **Transformations**: Build item transforms are applied to vertex coordinates
247 /// - **Attribute bytes**: Written as 0 (no extended attributes)
248 ///
249 /// # Behavior
250 ///
251 /// - Only mesh objects from `model.build.items` are exported
252 /// - Non-mesh geometries (Components, BooleanShape, etc.) are skipped
253 /// - Each build item's transformation matrix is applied to its mesh vertices
254 /// - All triangles from all build items are combined into a single STL file
255 ///
256 /// # Examples
257 ///
258 /// ```no_run
259 /// use lib3mf_converters::stl::StlExporter;
260 /// use lib3mf_core::model::Model;
261 /// use std::fs::File;
262 ///
263 /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
264 /// # let model = Model::default();
265 /// let output = File::create("exported.stl")?;
266 /// StlExporter::write(&model, output)?;
267 /// println!("Model exported successfully");
268 /// # Ok(())
269 /// # }
270 /// ```
271 ///
272 /// [`Model`]: lib3mf_core::model::Model
273 /// [`Lib3mfError::Io`]: lib3mf_core::error::Lib3mfError::Io
274 pub fn write<W: Write>(model: &Model, mut writer: W) -> Result<()> {
275 // 1. Collect all triangles from all build items
276 let mut triangles: Vec<(glam::Vec3, glam::Vec3, glam::Vec3)> = Vec::new(); // v1, v2, v3
277
278 for item in &model.build.items {
279 #[allow(clippy::collapsible_if)]
280 if let Some(object) = model.resources.get_object(item.object_id) {
281 if let lib3mf_core::model::Geometry::Mesh(mesh) = &object.geometry {
282 let transform = item.transform;
283
284 for tri in &mesh.triangles {
285 let v1_local = mesh.vertices[tri.v1 as usize];
286 let v2_local = mesh.vertices[tri.v2 as usize];
287 let v3_local = mesh.vertices[tri.v3 as usize];
288
289 let v1 = transform
290 .transform_point3(glam::Vec3::new(v1_local.x, v1_local.y, v1_local.z));
291 let v2 = transform
292 .transform_point3(glam::Vec3::new(v2_local.x, v2_local.y, v2_local.z));
293 let v3 = transform
294 .transform_point3(glam::Vec3::new(v3_local.x, v3_local.y, v3_local.z));
295
296 triangles.push((v1, v2, v3));
297 }
298 }
299 }
300 }
301
302 // 2. Write Header (80 bytes)
303 let header = [0u8; 80];
304 writer.write_all(&header).map_err(Lib3mfError::Io)?;
305
306 // 3. Write Count
307 writer
308 .write_u32::<LittleEndian>(triangles.len() as u32)
309 .map_err(Lib3mfError::Io)?;
310
311 // 4. Write Triangles
312 for (v1, v2, v3) in triangles {
313 // Normal (0,0,0) - let viewer calculate
314 writer
315 .write_f32::<LittleEndian>(0.0)
316 .map_err(Lib3mfError::Io)?;
317 writer
318 .write_f32::<LittleEndian>(0.0)
319 .map_err(Lib3mfError::Io)?;
320 writer
321 .write_f32::<LittleEndian>(0.0)
322 .map_err(Lib3mfError::Io)?;
323
324 // v1
325 writer
326 .write_f32::<LittleEndian>(v1.x)
327 .map_err(Lib3mfError::Io)?;
328 writer
329 .write_f32::<LittleEndian>(v1.y)
330 .map_err(Lib3mfError::Io)?;
331 writer
332 .write_f32::<LittleEndian>(v1.z)
333 .map_err(Lib3mfError::Io)?;
334
335 // v2
336 writer
337 .write_f32::<LittleEndian>(v2.x)
338 .map_err(Lib3mfError::Io)?;
339 writer
340 .write_f32::<LittleEndian>(v2.y)
341 .map_err(Lib3mfError::Io)?;
342 writer
343 .write_f32::<LittleEndian>(v2.z)
344 .map_err(Lib3mfError::Io)?;
345
346 // v3
347 writer
348 .write_f32::<LittleEndian>(v3.x)
349 .map_err(Lib3mfError::Io)?;
350 writer
351 .write_f32::<LittleEndian>(v3.y)
352 .map_err(Lib3mfError::Io)?;
353 writer
354 .write_f32::<LittleEndian>(v3.z)
355 .map_err(Lib3mfError::Io)?;
356
357 // Attribute byte count (0)
358 writer
359 .write_u16::<LittleEndian>(0)
360 .map_err(Lib3mfError::Io)?;
361 }
362
363 Ok(())
364 }
365
366 /// Writes a 3MF [`Model`] to binary STL format with support for multi-part 3MF files.
367 ///
368 /// This method extends [`write`] by recursively resolving component references and external
369 /// model parts using a [`PartResolver`]. This is necessary for 3MF files with the Production
370 /// Extension that reference objects from external model parts.
371 ///
372 /// # Arguments
373 ///
374 /// * `model` - The root 3MF model to export
375 /// * `resolver` - A [`PartResolver`] for loading external model parts from the 3MF archive
376 /// * `writer` - Any type implementing [`Write`] to receive STL data
377 ///
378 /// # Returns
379 ///
380 /// `Ok(())` on successful export.
381 ///
382 /// # Errors
383 ///
384 /// Returns [`Lib3mfError::Io`] if any write operation fails.
385 ///
386 /// Returns errors from the resolver if external parts cannot be loaded.
387 ///
388 /// # Behavior
389 ///
390 /// - Recursively resolves component hierarchies using the PartResolver
391 /// - Follows external references via component `path` attributes
392 /// - Applies accumulated transformations through the component tree
393 /// - Flattens all resolved meshes into a single STL file
394 ///
395 /// # Examples
396 ///
397 /// ```no_run
398 /// use lib3mf_converters::stl::StlExporter;
399 /// use lib3mf_core::archive::ZipArchiver;
400 /// use lib3mf_core::model::resolver::PartResolver;
401 /// use std::fs::File;
402 ///
403 /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
404 /// # let model = lib3mf_core::model::Model::default();
405 /// let archive_file = File::open("multipart.3mf")?;
406 /// let mut archiver = ZipArchiver::new(archive_file)?;
407 /// let resolver = PartResolver::new(&mut archiver, model.clone());
408 ///
409 /// let output = File::create("output.stl")?;
410 /// StlExporter::write_with_resolver(&model, resolver, output)?;
411 /// # Ok(())
412 /// # }
413 /// ```
414 ///
415 /// [`write`]: StlExporter::write
416 /// [`Model`]: lib3mf_core::model::Model
417 /// [`PartResolver`]: lib3mf_core::model::resolver::PartResolver
418 /// [`Lib3mfError::Io`]: lib3mf_core::error::Lib3mfError::Io
419 pub fn write_with_resolver<W: Write, A: lib3mf_core::archive::ArchiveReader>(
420 model: &Model,
421 mut resolver: lib3mf_core::model::resolver::PartResolver<A>,
422 mut writer: W,
423 ) -> Result<()> {
424 // 1. Collect all triangles from all build items (recursively)
425 let mut triangles: Vec<(glam::Vec3, glam::Vec3, glam::Vec3)> = Vec::new();
426
427 for item in &model.build.items {
428 collect_triangles(
429 &mut resolver,
430 item.object_id,
431 item.transform,
432 None, // Start with root path (None)
433 &mut triangles,
434 )?;
435 }
436
437 // 2. Write Header (80 bytes)
438 let header = [0u8; 80];
439 writer.write_all(&header).map_err(Lib3mfError::Io)?;
440
441 // 3. Write Count
442 writer
443 .write_u32::<LittleEndian>(triangles.len() as u32)
444 .map_err(Lib3mfError::Io)?;
445
446 // 4. Write Triangles
447 for (v1, v2, v3) in triangles {
448 // Normal (0,0,0) - let viewer calculate
449 writer
450 .write_f32::<LittleEndian>(0.0)
451 .map_err(Lib3mfError::Io)?;
452 writer
453 .write_f32::<LittleEndian>(0.0)
454 .map_err(Lib3mfError::Io)?;
455 writer
456 .write_f32::<LittleEndian>(0.0)
457 .map_err(Lib3mfError::Io)?;
458
459 // v1
460 writer
461 .write_f32::<LittleEndian>(v1.x)
462 .map_err(Lib3mfError::Io)?;
463 writer
464 .write_f32::<LittleEndian>(v1.y)
465 .map_err(Lib3mfError::Io)?;
466 writer
467 .write_f32::<LittleEndian>(v1.z)
468 .map_err(Lib3mfError::Io)?;
469
470 // v2
471 writer
472 .write_f32::<LittleEndian>(v2.x)
473 .map_err(Lib3mfError::Io)?;
474 writer
475 .write_f32::<LittleEndian>(v2.y)
476 .map_err(Lib3mfError::Io)?;
477 writer
478 .write_f32::<LittleEndian>(v2.z)
479 .map_err(Lib3mfError::Io)?;
480
481 // v3
482 writer
483 .write_f32::<LittleEndian>(v3.x)
484 .map_err(Lib3mfError::Io)?;
485 writer
486 .write_f32::<LittleEndian>(v3.y)
487 .map_err(Lib3mfError::Io)?;
488 writer
489 .write_f32::<LittleEndian>(v3.z)
490 .map_err(Lib3mfError::Io)?;
491
492 // Attribute byte count (0)
493 writer
494 .write_u16::<LittleEndian>(0)
495 .map_err(Lib3mfError::Io)?;
496 }
497
498 Ok(())
499 }
500}
501
502fn collect_triangles<A: lib3mf_core::archive::ArchiveReader>(
503 resolver: &mut lib3mf_core::model::resolver::PartResolver<A>,
504 object_id: ResourceId,
505 transform: glam::Mat4,
506 path: Option<&str>,
507 triangles: &mut Vec<(glam::Vec3, glam::Vec3, glam::Vec3)>,
508) -> Result<()> {
509 // Resolve geometry
510 // Note: We need to clone the geometry or handle the borrow of resolver carefully.
511 // resolving returns a reference to Model and Object, which borrows from resolver.
512 // We can't keep that borrow while recursing (mutably borrowing resolver).
513 // So we need to clone the relevant data (Geometry) or restructure.
514 // Cloning Geometry (which contains Mesh) might be expensive but safe.
515 // OR: resolve_object returns reference, we inspect it, then drop reference before recursing.
516
517 let geometry = {
518 let res = resolver.resolve_object(object_id, path)?;
519 if let Some((_, obj)) = res {
520 Some(obj.geometry.clone()) // Cloning geometry to release borrow
521 } else {
522 None
523 }
524 };
525
526 if let Some(geo) = geometry {
527 match geo {
528 lib3mf_core::model::Geometry::Mesh(mesh) => {
529 for tri in &mesh.triangles {
530 let v1_local = mesh.vertices[tri.v1 as usize];
531 let v2_local = mesh.vertices[tri.v2 as usize];
532 let v3_local = mesh.vertices[tri.v3 as usize];
533
534 let v1 = transform
535 .transform_point3(glam::Vec3::new(v1_local.x, v1_local.y, v1_local.z));
536 let v2 = transform
537 .transform_point3(glam::Vec3::new(v2_local.x, v2_local.y, v2_local.z));
538 let v3 = transform
539 .transform_point3(glam::Vec3::new(v3_local.x, v3_local.y, v3_local.z));
540
541 triangles.push((v1, v2, v3));
542 }
543 }
544 lib3mf_core::model::Geometry::Components(comps) => {
545 for comp in comps.components {
546 let new_transform = transform * comp.transform;
547 // Path handling: If component references a different path/part, update it?
548 // Currently lib3mf-core resolver handles relative paths if we pass them.
549 // The component struct has a 'path' field? (Checking core definition...)
550 // Assuming Component has 'path' field which is Option<String>
551 // Wait, I need to check lib3mf_core::model::Component definition.
552 // Assuming it does based on previous experience, but let's be safe.
553 // Actually, looking at `print_tree` in commands.rs, it uses `comp.path`.
554
555 let next_path_store = comp.path.clone();
556 // If component has path, it overrides/is relative to current?
557 // Usually in 3MF, if path represents a model part.
558 // resolver logic: "if path is provided, look there".
559
560 let next_path = next_path_store.as_deref().or(path);
561
562 collect_triangles(
563 resolver,
564 comp.object_id,
565 new_transform,
566 next_path,
567 triangles,
568 )?;
569 }
570 }
571 _ => {}
572 }
573 }
574
575 Ok(())
576}