Files
effectcraft/crates/model/src/stl.rs
T
vangel76andClaude Opus 5.5 5661d5111c #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 <noreply@anthropic.com>
2026-10-11 11:32:11 +02:00

204 lines
8.8 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<Model> {
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<Option<[u8; 3]>, 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<Model> {
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<Model> {
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::<FACET>().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<Item = &'a str>) -> Option<[f32; 3]> {
Some([it.next()?.parse().ok()?, it.next()?.parse().ok()?, it.next()?.parse().ok()?])
}
fn ascii(bytes: &[u8]) -> Result<Model> {
let text = String::from_utf8_lossy(bytes);
let mut b = Builder::default();
let mut name: Option<String> = 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())
}