From 5661d5111c009c6fdb1e7c44cf41cbf4e974ee42 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 11:32:11 +0200 Subject: [PATCH] #729: import STL 3D models (binary and ASCII) Problem: After Effects 26 imports STL models besides glTF and OBJ; we refused .stl files, and the Media Browser and import dialog did not list them as models. Fix: a pure-Rust STL reader in effectcraft-model (binary and ASCII, detected from the contents; several ASCII solids and polygon loops; VisCAM/SolidView and Materialise Magics facet colours as plain materials; Z-up turned to the model's Y-up; faceted normals from the winding). Truncated, empty or non-finite input is an error, never a crash. .stl is now a model extension for import, probing, the Media Browser, the import dialog and the system report. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/app_more.rs | 1 + crates/engine/src/commands/model3d.rs | 4 +- crates/engine/src/media_browser.rs | 2 +- crates/media/src/lib.rs | 2 +- crates/media/src/probe.rs | 3 +- crates/media/tests/footage.rs | 10 ++ crates/model/Cargo.toml | 2 +- crates/model/src/lib.rs | 8 +- crates/model/src/stl.rs | 203 +++++++++++++++++++++++++ crates/model/src/tests.rs | 90 +++++++++++ crates/ui-egui/src/menus.rs | 2 +- docs/file-format-parity.md | 4 +- docs/gaps.md | 2 +- 13 files changed, 321 insertions(+), 12 deletions(-) create mode 100644 crates/model/src/stl.rs diff --git a/crates/engine/src/commands/app_more.rs b/crates/engine/src/commands/app_more.rs index b6101c5a..e4ece7c7 100644 --- a/crates/engine/src/commands/app_more.rs +++ b/crates/engine/src/commands/app_more.rs @@ -316,6 +316,7 @@ pub const IMPORT_FORMATS: &[&str] = &[ "Opus", "glTF/GLB", "OBJ", + "STL", "Lottie JSON", ]; diff --git a/crates/engine/src/commands/model3d.rs b/crates/engine/src/commands/model3d.rs index 0fd840ff..9bcb3665 100644 --- a/crates/engine/src/commands/model3d.rs +++ b/crates/engine/src/commands/model3d.rs @@ -1,4 +1,4 @@ -//! Advanced 3D: model layers (File ▸ Import of glTF/OBJ, Layer ▸ New ▸ 3D primitives), the +//! Advanced 3D: model layers (File ▸ Import of glTF/OBJ/STL, Layer ▸ New ▸ 3D primitives), the //! comp renderer switch, Layer ▸ Environment Layer, Geometry Options for extruded text and //! shapes, and Layer ▸ Material commands. @@ -376,7 +376,7 @@ pub fn specs() -> Vec { "3D Model Layer", [], None, - "{item?: id or name of a 3D model footage item, path?: a .gltf, .glb or .obj file to import, name?, time? (s), index? (1-based stack position), position? ([x, y] comp px)}; switches a Classic 3D comp to Advanced 3D", + "{item?: id or name of a 3D model footage item, path?: a .gltf, .glb, .obj or .stl file to import, name?, time? (s), index? (1-based stack position), position? ([x, y] comp px)}; switches a Classic 3D comp to Advanced 3D", has_comp, new_model ), diff --git a/crates/engine/src/media_browser.rs b/crates/engine/src/media_browser.rs index 3247a588..87b1b475 100644 --- a/crates/engine/src/media_browser.rs +++ b/crates/engine/src/media_browser.rs @@ -197,7 +197,7 @@ const VIDEO: &[&str] = &["mp4", "m4v", "mov", "webm", "mkv", "avi", "mxf", "gif" const IMAGE: &[&str] = &["png", "jpg", "jpeg", "tif", "tiff", "exr", "bmp", "webp", "psd", "psb", "svg", "tga", "dpx", "hdr"]; const AUDIO: &[&str] = &["wav", "aif", "aiff", "mp3", "m4a", "aac", "flac", "ogg", "opus"]; const DATA: &[&str] = &["json", "csv", "tsv", "mgjson"]; -const MODEL: &[&str] = &["gltf", "glb", "obj"]; +const MODEL: &[&str] = &["gltf", "glb", "obj", "stl"]; /// Kind of file from its extension. pub fn kind_of(path: &str) -> Option<&'static str> { diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index 000c1624..d9c0b320 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -58,7 +58,7 @@ pub const AUDIO_EXTENSIONS: &[&str] = filmcraft_media::AUDIO_EXTENSIONS; pub const VIDEO_EXTENSIONS: &[&str] = &["mp4", "m4v", "mov", "mkv", "webm"]; /// File extensions recognised as 3D models (Advanced 3D model layers). -pub const MODEL_EXTENSIONS: &[&str] = &["gltf", "glb", "obj"]; +pub const MODEL_EXTENSIONS: &[&str] = &["gltf", "glb", "obj", "stl"]; /// Whether `path` has an extension we can import. pub fn is_importable(path: &std::path::Path) -> bool { diff --git a/crates/media/src/probe.rs b/crates/media/src/probe.rs index 6e8718fd..099450e8 100644 --- a/crates/media/src/probe.rs +++ b/crates/media/src/probe.rs @@ -23,7 +23,7 @@ pub fn probe(path: impl AsRef) -> Result { probe_bytes(&path.to_string_lossy(), bytes.into()) } -/// Probe a 3D model (glTF 2.0 / OBJ): a [`FootageKind::Model`] item whose duration is its +/// Probe a 3D model (glTF 2.0 / OBJ / STL): a [`FootageKind::Model`] item whose duration is its /// longest animation. `resolve` reads sibling resources. pub fn probe_model(path: &str, bytes: &[u8], resolve: &dyn Fn(&str) -> Option>) -> Result { let m = effectcraft_model::load(path, bytes, resolve).map_err(|e| MediaError::Decode(format!("{path}: {e}")))?; @@ -31,6 +31,7 @@ pub fn probe_model(path: &str, bytes: &[u8], resolve: &dyn Fn(&str) -> Option "OBJ", + Some("stl") => "STL", Some("glb") => "GLB", _ => "glTF", }; diff --git a/crates/media/tests/footage.rs b/crates/media/tests/footage.rs index d26b1a1d..ecfca32e 100644 --- a/crates/media/tests/footage.rs +++ b/crates/media/tests/footage.rs @@ -674,3 +674,13 @@ fn conformed_flac_audio_reads_back_like_the_decoder() { assert_eq!(pool.audio_samples(&f, at, 4800, 48_000), decoded); let _ = std::fs::remove_dir_all(&folder); } + +/// File ▸ Import of an STL part: importable by extension and probed as a 3D model item. +#[test] +fn stl_imports_as_a_model() { + assert!(effectcraft_media::is_importable(Path::new("part.stl"))); + let ascii = "solid part\nfacet normal 0 0 1\nouter loop\nvertex 0 0 0\nvertex 1 0 0\nvertex 0 1 0\nendloop\nendfacet\nendsolid part\n"; + let f = effectcraft_media::probe_bytes("part.stl", Arc::from(ascii.as_bytes())).expect("probe"); + assert_eq!(f.kind, FootageKind::Model); + assert_eq!(f.codec, "STL (1 meshes, 1 triangles)"); +} diff --git a/crates/model/Cargo.toml b/crates/model/Cargo.toml index 20a1fde0..345eea99 100644 --- a/crates/model/Cargo.toml +++ b/crates/model/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "effectcraft-model" -description = "3D models: glTF 2.0 (.gltf/.glb) and Wavefront OBJ/MTL import, PBR metallic-roughness materials, node hierarchy, skinning and animation, parametric primitives, and extruded/bevelled outline meshes" +description = "3D models: glTF 2.0 (.gltf/.glb), Wavefront OBJ/MTL and STL import, PBR metallic-roughness materials, node hierarchy, skinning and animation, parametric primitives, and extruded/bevelled outline meshes" version.workspace = true edition.workspace = true license.workspace = true diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 9480f29c..6c3da5eb 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -5,6 +5,7 @@ //! metallic-roughness, normal, occlusion and emissive maps), the node hierarchy, skins and //! animations. //! - [`obj`]: Wavefront OBJ geometry with MTL materials (mapped onto the metallic-roughness model). +//! - [`stl`]: STL meshes (binary and ASCII, with the binary per-facet and part colours). //! - [`prim`]: parametric primitives (cube, sphere, plane, torus, cone, cylinder). //! - [`extrude`]: extruded, bevelled meshes from 2D outlines (text and shape layers), with //! [`triangulate`] for the caps. @@ -18,6 +19,7 @@ pub mod extrude; pub mod gltf; pub mod obj; pub mod prim; +pub mod stl; pub mod triangulate; use std::sync::Arc; @@ -720,13 +722,14 @@ pub fn skin_point(inst: &Instance, p: &Primitive, k: usize, v: [f32; 3]) -> [f64 [q.x, q.y, q.z] } -/// Which loader handles a file name (`gltf`, `glb`, `obj`), by extension. +/// Which loader handles a file name (`gltf`, `glb`, `obj`, `stl`), by extension. pub fn format_of(path: &str) -> Option<&'static str> { let ext = path.rsplit('.').next()?.to_ascii_lowercase(); match ext.as_str() { "gltf" => Some("gltf"), "glb" => Some("glb"), "obj" => Some("obj"), + "stl" => Some("stl"), _ => None, } } @@ -736,9 +739,10 @@ pub fn format_of(path: &str) -> Option<&'static str> { pub fn load(path: &str, bytes: &[u8], resolve: &dyn Fn(&str) -> Option>) -> Result { match format_of(path) { Some("obj") => obj::parse(bytes, resolve), + Some("stl") => stl::parse(bytes), Some(_) => gltf::parse(bytes, resolve), None if bytes.starts_with(b"glTF") => gltf::parse(bytes, resolve), - None => Err(ModelError::Unsupported(format!("{path}: not a glTF or OBJ file"))), + None => Err(ModelError::Unsupported(format!("{path}: not a glTF, OBJ or STL file"))), } } diff --git a/crates/model/src/stl.rs b/crates/model/src/stl.rs new file mode 100644 index 00000000..6684858a --- /dev/null +++ b/crates/model/src/stl.rs @@ -0,0 +1,203 @@ +//! STL (stereolithography) meshes, binary and ASCII. +//! +//! Written from the public description of the format (3D Systems' StereoLithography Interface +//! Specification, 1989, and its widely documented colour extensions): +//! +//! - Binary: an 80-byte header, a little-endian `u32` facet count, then 50 bytes per facet: a +//! normal and three vertices (12 `f32`) and a 16-bit attribute word. Bytes after the last +//! facet are ignored. +//! - ASCII: `solid name`, then `facet normal nx ny nz` / `outer loop` / `vertex x y z`… / +//! `endloop` / `endfacet`, closed by `endsolid`; several solids may follow each other. +//! Loops with more than three vertices are fan-triangulated. +//! - Colour (binary only): with `COLOR=r g b a` (four bytes) in the header (Materialise Magics), +//! that is the part's colour and a facet whose attribute has bit 15 clear has its own colour +//! (red in bits 0–4, green 5–9, blue 10–14); otherwise (VisCAM / SolidView) a facet with bit 15 +//! set has its own colour (blue in bits 0–4, green 5–9, red 10–14). Each colour becomes a +//! plain dielectric material; uncoloured files get none (the renderer's default material). +//! +//! STL has no units, no UVs and no shared vertices: every facet keeps its own three vertices +//! with the face normal from its counter-clockwise winding (the stored normal is only used when +//! the triangle is degenerate), which gives the faceted look of an STL part. STL files are +//! Z-up (3D printing and CAD); they are turned to the +Y-up convention of [`crate::Model`] +//! (`(x, y, z)` → `(x, z, −y)`, a proper rotation, so winding is kept). Facets with non-finite +//! coordinates are skipped. + +use std::collections::HashMap; + +use crate::{Material, Mesh, Model, Primitive, Result, cross, normalize, obj::srgb_to_linear, perr, sub}; + +/// Header (80) plus facet count (4). +const HEADER: usize = 84; +/// Bytes per binary facet. +const FACET: usize = 50; + +/// Parse an STL file (binary or ASCII, detected from the contents). +pub fn parse(bytes: &[u8]) -> Result { + let count = bytes.get(80..HEADER).and_then(|b| <[u8; 4]>::try_from(b).ok()).map(u32::from_le_bytes); + let needed = count.and_then(|n| (n as usize).checked_mul(FACET)).and_then(|n| n.checked_add(HEADER)); + // A binary file's size is exact; many binary headers still begin with `solid`, so the size + // decides first, then the ASCII keyword. + if needed.is_some_and(|n| n == bytes.len()) { + return binary(bytes); + } + if looks_ascii(bytes) { + return ascii(bytes); + } + if needed.is_some_and(|n| n <= bytes.len()) { + return binary(bytes); + } + Err(perr("not an STL file (truncated binary facets or no `solid` keyword)")) +} + +fn looks_ascii(bytes: &[u8]) -> bool { + let head = bytes.get(..bytes.len().min(512)).unwrap_or(&[]); + let text = String::from_utf8_lossy(head); + let t = text.trim_start(); + t.get(..5).is_some_and(|k| k.eq_ignore_ascii_case("solid")) && (t.contains("facet") || t.contains("endsolid")) +} + +/// The Z-up → Y-up turn. +fn y_up(v: [f32; 3]) -> [f32; 3] { + [v[0], v[2], -v[1]] +} + +/// Facets grouped into primitives by colour, in order of first use. +#[derive(Default)] +struct Builder { + /// (colour key, primitive); `None` = uncoloured. + prims: Vec<(Option<[u8; 3]>, Primitive)>, + /// Colour → index into `prims` (up to 32 768 colours, so not a linear search). + slots: HashMap, usize>, + facets: usize, +} + +impl Builder { + /// Add one triangle (file coordinates); `stored` is the file's facet normal. + fn tri(&mut self, color: Option<[u8; 3]>, stored: [f32; 3], v: [[f32; 3]; 3]) { + if v.iter().flatten().any(|c| !c.is_finite()) { + return; + } + let v = v.map(y_up); + let n = normalize(cross(sub(v[1], v[0]), sub(v[2], v[0]))) + .or_else(|| stored.iter().all(|c| c.is_finite()).then(|| normalize(y_up(stored))).flatten()) + .unwrap_or([0.0, 1.0, 0.0]); + let slot = *self.slots.entry(color).or_insert_with(|| { + self.prims.push((color, Primitive::default())); + self.prims.len() - 1 + }); + let Some((_, p)) = self.prims.get_mut(slot) else { return }; + for c in v { + p.indices.push(p.positions.len() as u32); + p.positions.push(c); + p.normals.push(n); + } + self.facets += 1; + } + + fn finish(self, name: String) -> Result { + if self.facets == 0 { + return Err(perr("STL file without facets")); + } + let mut materials = vec![]; + let mut mesh = Mesh { name, primitives: vec![] }; + for (color, mut p) in self.prims { + if let Some(c) = color { + let lin = c.map(|x| srgb_to_linear(x as f32 / 255.0)); + let name = format!("#{:02X}{:02X}{:02X}", c[0], c[1], c[2]); + materials.push(Material { name, ..Material::plain([lin[0], lin[1], lin[2], 1.0], 0.0, 0.5) }); + p.material = Some(materials.len() - 1); + } + mesh.primitives.push(p); + } + // Uncoloured facets next to coloured ones: a neutral grey of their own. + if !materials.is_empty() { + for p in mesh.primitives.iter_mut().filter(|p| p.material.is_none()) { + materials.push(Material { name: "STL".into(), ..Material::plain([0.6, 0.6, 0.6, 1.0], 0.0, 0.5) }); + p.material = Some(materials.len() - 1); + } + } + Ok(Model::single(mesh, materials)) + } +} + +fn f32_at(b: &[u8], at: usize) -> f32 { + b.get(at..at + 4).and_then(|s| <[u8; 4]>::try_from(s).ok()).map_or(f32::NAN, f32::from_le_bytes) +} + +/// A 5-bit channel expanded to 8 bits. +fn five(x: u16) -> u8 { + let x = (x & 31) as u8; + (x << 3) | (x >> 2) +} + +/// The Magics `COLOR=` part colour in the header (RGB; alpha is ignored). +fn header_color(header: &[u8]) -> Option<[u8; 3]> { + let at = header.windows(6).position(|w| w == b"COLOR=")?; + let c = header.get(at + 6..at + 10)?; + Some([*c.first()?, *c.get(1)?, *c.get(2)?]) +} + +fn binary(bytes: &[u8]) -> Result { + let header = bytes.get(..80).ok_or_else(|| perr("STL header truncated"))?; + let magics = header_color(header); + let body = bytes.get(HEADER..).unwrap_or(&[]); + let count = bytes.get(80..HEADER).and_then(|b| <[u8; 4]>::try_from(b).ok()).map_or(0, u32::from_le_bytes) as usize; + let mut b = Builder::default(); + for f in body.as_chunks::().0.iter().take(count) { + let n = [f32_at(f, 0), f32_at(f, 4), f32_at(f, 8)]; + let v = [0usize, 1, 2].map(|k| [f32_at(f, 12 + 12 * k), f32_at(f, 16 + 12 * k), f32_at(f, 20 + 12 * k)]); + let attr = f.get(48..50).and_then(|a| <[u8; 2]>::try_from(a).ok()).map_or(0, u16::from_le_bytes); + let color = match magics { + Some(part) => Some(if attr & 0x8000 == 0 { [five(attr), five(attr >> 5), five(attr >> 10)] } else { part }), + None => (attr & 0x8000 != 0).then(|| [five(attr >> 10), five(attr >> 5), five(attr)]), + }; + b.tri(color, n, v); + } + let text = String::from_utf8_lossy(header); + let name = text.trim_matches(|c: char| c == '\0' || c.is_whitespace()); + b.finish(name.strip_prefix("solid").unwrap_or(name).trim().to_string()) +} + +/// Three numbers from the rest of an ASCII line. +fn three<'a>(it: &mut impl Iterator) -> Option<[f32; 3]> { + Some([it.next()?.parse().ok()?, it.next()?.parse().ok()?, it.next()?.parse().ok()?]) +} + +fn ascii(bytes: &[u8]) -> Result { + let text = String::from_utf8_lossy(bytes); + let mut b = Builder::default(); + let mut name: Option = None; + let mut normal = [0.0f32; 3]; + let mut loop_v: Vec<[f32; 3]> = vec![]; + for (ln, line) in text.lines().enumerate() { + let line = line.trim(); + let mut it = line.split_whitespace(); + let Some(key) = it.next() else { continue }; + let bad = || perr(format!("STL line {}: expected three numbers", ln + 1)); + match key.to_ascii_lowercase().as_str() { + "solid" => { + if name.is_none() { + name = Some(line.get(5..).unwrap_or("").trim().to_string()); + } + } + "facet" => { + // `facet normal nx ny nz` + normal = it.next().and_then(|_| three(&mut it)).unwrap_or([0.0; 3]); + loop_v.clear(); + } + "vertex" => loop_v.push(three(&mut it).ok_or_else(bad)?), + "endloop" | "endfacet" => { + if let Some((&first, rest)) = loop_v.split_first() { + for w in rest.windows(2) { + if let [a, c] = w { + b.tri(None, normal, [first, *a, *c]); + } + } + } + loop_v.clear(); + } + _ => {} + } + } + b.finish(name.unwrap_or_default()) +} diff --git a/crates/model/src/tests.rs b/crates/model/src/tests.rs index 032ca47d..54fda596 100644 --- a/crates/model/src/tests.rs +++ b/crates/model/src/tests.rs @@ -373,3 +373,93 @@ fn stacked_extrusions_move_towards_the_camera() { assert_eq!((a[0], a[1]), (b[0], b[1])); } } + +/// A binary STL: `header` (padded to 80 bytes) and facets of (normal, vertices, attribute). +fn binary_stl(header: &[u8], facets: &[([f32; 3], [[f32; 3]; 3], u16)]) -> Vec { + let mut b = header.to_vec(); + b.resize(80, 0); + b.extend((facets.len() as u32).to_le_bytes()); + for (n, v, attr) in facets { + for c in n.iter().chain(v.iter().flatten()) { + b.extend(c.to_le_bytes()); + } + b.extend(attr.to_le_bytes()); + } + b +} + +/// The unit square in the XY plane (Z-up file), facing +Z, as two facets. +const SQUARE: [[[f32; 3]; 3]; 2] = [[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0]], [[0.0, 0.0, 0.0], [1.0, 1.0, 0.0], [0.0, 1.0, 0.0]]]; + +#[test] +fn stl_binary_is_faceted_and_turned_y_up() { + // A header that begins with `solid`, as many binary exporters write it. + let bytes = binary_stl(b"solid part", &[([0.0, 0.0, 1.0], SQUARE[0], 0), ([0.0, 0.0, 1.0], SQUARE[1], 0)]); + let m = load("part.STL", &bytes, &|_| None).unwrap(); + assert_eq!(format_of("part.STL"), Some("stl")); + assert_eq!(m.meshes[0].name, "part"); + assert!(m.materials.is_empty()); + let p = &m.meshes[0].primitives[0]; + assert_eq!((p.triangle_count(), p.positions.len()), (2, 6)); + assert!(p.uvs.is_empty()); + // Z-up +Z becomes Y-up +Y; file Y becomes −Z. + assert_eq!(p.normals, vec![[0.0, 1.0, 0.0]; 6]); + assert_eq!(p.positions[2], [1.0, 0.0, -1.0]); + // Counter-clockwise from outside after the turn. + let (a, b, c) = (p.positions[0], p.positions[1], p.positions[2]); + assert!(dot(cross(sub(b, a), sub(c, a)), p.normals[0]) > 0.0); + // Trailing bytes after the last facet are ignored. + let mut longer = bytes.clone(); + longer.extend([0u8; 7]); + assert_eq!(stl::parse(&longer).unwrap().meshes, m.meshes); +} + +#[test] +fn stl_ascii_with_polygon_loops_and_several_solids() { + let src = "solid Bracket\n facet normal 0 0 1\n outer loop\n vertex 0 0 0\n vertex 1 0 0\n vertex 1 1 0\n vertex 0 1 0\n endloop\n endfacet\nendsolid Bracket\n\ + solid second\nfacet normal 0 0 0\nouter loop\nvertex 0 0 1\nvertex 1 0 1\nvertex 1E0 1 1\nendloop\nendfacet\nendsolid\n"; + let m = load("bracket.stl", src.as_bytes(), &|_| None).unwrap(); + assert_eq!(m.meshes[0].name, "Bracket"); + // The quad fans into two triangles; the second solid adds one. + assert_eq!(m.triangle_count(), 3); + let (lo, hi) = m.bounds().unwrap(); + assert_eq!((lo, hi), ([0.0, 0.0, -1.0], [1.0, 1.0, 0.0])); + assert!(stl::parse(b"solid x\nfacet normal 0 0 1\nouter loop\nvertex 0 zero 0\n").is_err()); +} + +#[test] +fn stl_binary_colours() { + // VisCAM / SolidView: bit 15 set, red in bits 10–14. + let red = 0x8000 | (31 << 10); + let m = stl::parse(&binary_stl(b"", &[([0.0; 3], SQUARE[0], red), ([0.0; 3], SQUARE[1], 0)])).unwrap(); + assert_eq!(m.meshes[0].primitives.len(), 2); + assert_eq!(m.materials.len(), 2); + assert!(m.materials[0].base_color.iter().zip([1.0, 0.0, 0.0, 1.0]).all(|(x, y)| (x - y).abs() < 1e-5)); + assert_eq!(m.materials[0].metallic, 0.0); + assert_eq!(m.meshes[0].primitives[1].material, Some(1)); + // Materialise Magics: a part colour in the header; bit 15 clear = the facet's own colour + // with red in bits 0–4. + let mut header = b"COLOR=".to_vec(); + header.extend([0, 255, 0, 255]); + let m = stl::parse(&binary_stl(&header, &[([0.0; 3], SQUARE[0], 31), ([0.0; 3], SQUARE[1], 0x8000)])).unwrap(); + let close = |a: [f32; 4], b: [f32; 4]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-5); + assert!(close(m.materials[0].base_color, [1.0, 0.0, 0.0, 1.0]), "{:?}", m.materials[0]); + assert!(close(m.materials[1].base_color, [0.0, 1.0, 0.0, 1.0]), "{:?}", m.materials[1]); +} + +#[test] +fn stl_hostile_input_is_an_error() { + // Claims four billion facets but has one: truncated. + let mut bytes = binary_stl(b"", &[([0.0; 3], SQUARE[0], 0)]); + bytes.splice(80..84, u32::MAX.to_le_bytes()); + assert!(stl::parse(&bytes).is_err()); + assert!(stl::parse(b"").is_err()); + assert!(stl::parse(&[0u8; 83]).is_err()); + // No facets, or only non-finite ones. + assert!(stl::parse(&binary_stl(b"", &[])).is_err()); + assert!(stl::parse(&binary_stl(b"", &[([0.0; 3], [[f32::NAN, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], 0)])).is_err()); + assert!(stl::parse(b"solid empty\nendsolid empty\n").is_err()); + // A degenerate facet keeps its stored normal. + let m = stl::parse(&binary_stl(b"", &[([1.0, 0.0, 0.0], [[0.0; 3]; 3], 0)])).unwrap(); + assert_eq!(m.meshes[0].primitives[0].normals[0], [1.0, 0.0, 0.0]); +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 5c61b497..f5829d30 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1226,7 +1226,7 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option