diff --git a/Cargo.lock b/Cargo.lock index 9a2fc28..31ec065 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -71,6 +71,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "approx" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f2a05fd1bd10b2527e20a2cd32d8873d115b8b39fe219ee25f42a8aca6ba278" +dependencies = [ + "num-traits", +] + [[package]] name = "arbitrary" version = "1.4.2" @@ -637,6 +646,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "cgmath" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a98d30140e3296250832bbaaff83b27dcd6fa3cc70fb6f1f3e5c9c0023b5317" +dependencies = [ + "approx", + "num-traits", +] + [[package]] name = "charset" version = "0.1.5" @@ -2994,6 +3013,7 @@ checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ "cfg-if", "crunchy", + "num-traits", "zerocopy", ] @@ -5180,7 +5200,9 @@ dependencies = [ "jacquard-derive", "serde", "serde_json", + "stl_io", "thiserror 2.0.18", + "three-d-asset", "tokio", "tower", "tower-http", @@ -6456,6 +6478,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "stl_io" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567641995c51a3b8befddb13e1826187bcf7eb7ca8d13746bfd1cc5a22e89fa8" + [[package]] name = "string_cache" version = "0.8.9" @@ -6732,6 +6760,18 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "three-d-asset" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd4e4e9bc05e4010efbaa0a0296870b2d28102171deffbf69de7b305cff2d47a" +dependencies = [ + "cgmath", + "half", + "thiserror 2.0.18", + "web-sys", +] + [[package]] name = "tiff" version = "0.11.3" diff --git a/Cargo.toml b/Cargo.toml index 39cdbb2..88ae599 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,8 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" thiserror = "2.0" tracing = { version = "0.1", default-features = false, features = ["std"] } +three-d-asset = { version = "0.10", default-features = false } +stl_io = "0.11" axum = { version = "0.8", optional = true } tower = { version = "0.5", optional = true } diff --git a/src/main.rs b/src/main.rs index 1617d1a..763f8cf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,5 @@ use dioxus::prelude::*; - +mod mesh; const FAVICON: Asset = asset!("/assets/favicon.jpg"); const THEME_CSS: Asset = asset!("/assets/styling/theme.css"); const BASE_CSS: Asset = asset!("/assets/styling/base.css"); diff --git a/src/mesh/contract.rs b/src/mesh/contract.rs new file mode 100644 index 0000000..6e18798 --- /dev/null +++ b/src/mesh/contract.rs @@ -0,0 +1,116 @@ +//! Mesh contract types: the renderer-agnostic [`ModelMesh`] and its provenance. + +// `AxisAlignedBoundingBox` appears in the public `ModelMesh::aabb` signature, and +// `TriMesh` is a public field type, so both are re-exported alongside the contract. +pub use three_d_asset::{AxisAlignedBoundingBox, TriMesh}; + +/// Source format of a loaded mesh. +/// +/// STL today; OBJ/glTF/3MF direct upload is reserved for PM-23. There is intentionally +/// no binary/ASCII sub-label for STL — reliably telling them apart is the hard part, and +/// both yield the same mesh. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MeshFormat { + /// Stereolithography (binary or ASCII). + Stl, +} + +/// A length unit. +/// +/// STL is unitless, so a loaded STL reports [`LengthUnit::Unknown`] as its source and +/// relies on an assumed convention. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LengthUnit { + Millimeter, + Centimeter, + Meter, + Inch, + /// A thousandth of an inch — "thou" in machining, "mil" in PCB/electronics. + Thou, + Foot, + Unknown, +} + +/// Unit handling for a loaded model. +/// +/// `source` is the unit declared by the file; STL declares none, so this is +/// [`LengthUnit::Unknown`]. `assumed` is the convention applied when the source is +/// unknown — millimetres for Polymodel's STL ingestion. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Units { + /// The unit declared by the file (STL: [`LengthUnit::Unknown`]). + pub source: LengthUnit, + /// The convention assumed when `source` is unknown. + pub assumed: Option, +} + +impl Units { + /// STL unit handling: source unknown, assume millimetres. + pub fn stl_assumed_mm() -> Self { + Self { + source: LengthUnit::Unknown, + assumed: Some(LengthUnit::Millimeter), + } + } +} + +/// Camera framing hint derived from a mesh's axis-aligned bounding box: the centre the +/// camera should orbit and a bounding-radius estimate to frame it. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct CameraFit { + /// AABB centre. + pub center: [f32; 3], + /// Half the largest AABB extent. + pub radius: f32, +} + +/// A Polymodel mesh: a [`TriMesh`] plus format provenance and unit handling. +/// +/// Normals are geometry-derived (via [`TriMesh::compute_normals`]); STL facet normals are +/// not relied upon. Bounds and camera framing come from [`Self::aabb`]. +#[derive(Debug)] +pub struct ModelMesh { + /// The underlying triangle mesh. + pub trimesh: TriMesh, + /// The format this mesh was loaded from. + pub format: MeshFormat, + /// Unit provenance and assumption. + pub units: Units, +} + +impl ModelMesh { + /// The axis-aligned bounding box over all vertex positions. + pub fn aabb(&self) -> AxisAlignedBoundingBox { + self.trimesh.compute_aabb() + } + + /// Camera framing derived from [`Self::aabb`]: `center` is the box centre and + /// `radius` is half the largest box extent. + pub fn camera_fit(&self) -> CameraFit { + let aabb = self.aabb(); + let center = aabb.center(); + let size = aabb.size(); + CameraFit { + center: [center.x, center.y, center.z], + radius: size.x.max(size.y).max(size.z) / 2.0, + } + } +} + +/// Errors that can occur loading a mesh. +#[derive(Debug, thiserror::Error)] +pub enum MeshLoadError { + /// The input was empty (zero bytes). + #[error("mesh input is empty")] + Empty, + /// The bytes could not be parsed as a valid mesh of the expected format. + #[error("failed to parse STL: {0}")] + Parse(#[from] std::io::Error), + /// The mesh parsed but is degenerate: no triangles, a non-finite position, or a + /// point-sized bounding box. + #[error("mesh is degenerate")] + Degenerate, + /// The format is not yet supported (reserved for PM-23). + #[error("unsupported mesh format")] + UnsupportedFormat, +} diff --git a/src/mesh/mod.rs b/src/mesh/mod.rs new file mode 100644 index 0000000..82ecf5e --- /dev/null +++ b/src/mesh/mod.rs @@ -0,0 +1,18 @@ +//! Renderer-agnostic mesh contract and STL ingestion. +//! +//! Defines [`ModelMesh`] (in [`contract`]), a thin Polymodel wrapper over +//! [`three_d_asset::TriMesh`] that carries format provenance, unit assumptions, and a +//! camera framing hint. STL is parsed from in-memory bytes (no filesystem), so this +//! module compiles to `wasm32-unknown-unknown` and can be consumed by any future +//! renderer ([PM-14 research](https://radiant-industries.atlassian.net/wiki/spaces/PM/pages/131340)). +//! +//! Scope is STL-only for now; OBJ/glTF/3MF direct upload is tracked in PM-23. + +// This module is a renderer-agnostic ingestion foundation: it is consumed by PM-19 +// (Dioxus scaffold) and PM-20/PM-21 (renderers), none of which are wired into the +// binary's main path yet. Its public surface is therefore intentionally unused at +// present, so dead-code analysis is relaxed here until those tickets land. +#![allow(dead_code)] + +pub mod contract; +pub mod stl; diff --git a/src/mesh/stl.rs b/src/mesh/stl.rs new file mode 100644 index 0000000..cc7cdfa --- /dev/null +++ b/src/mesh/stl.rs @@ -0,0 +1,344 @@ +//! STL ingestion: parse in-memory bytes into a [`ModelMesh`] via `stl_io`. +//! +//! Both binary and ASCII STL are handled by `stl_io::read_stl`, which probes the +//! 80-byte header for a leading `"solid "` to pick its reader. A binary file whose +//! header happens to start with `"solid "` is mis-routed to the ASCII reader and fails; +//! on a parse error we neutralise the header in an owned copy and retry exactly once, so +//! `stl_io` re-probes and picks its binary reader. We deliberately do **not** try to +//! classify binary-vs-ASCII ourselves (size-based detection is an unreliable heuristic, +//! and `stl_io` exposes no public binary-only entry point). +//! +//! Normals are geometry-derived ([`three_d_asset::TriMesh::compute_normals`]); STL facet +//! normals are not relied upon. No filesystem or network access, so this is safe to call +//! from a `wasm32-unknown-unknown` build. + +use std::io::Cursor; + +use three_d_asset::{Indices, Positions, TriMesh, Vec3}; + +use crate::mesh::contract::{MeshFormat, MeshLoadError, ModelMesh, Units}; + +/// Parse STL bytes (binary or ASCII) into a [`ModelMesh`]. +pub fn load_stl(bytes: &[u8]) -> Result { + if bytes.is_empty() { + return Err(MeshLoadError::Empty); + } + + let mesh = stl_io::read_stl(&mut Cursor::new(bytes)).or_else(|_| { + // `stl_io` mis-routes binaries whose 80-byte header starts with "solid " to its + // ASCII reader. Neutralise the header so the probe routes to the binary reader + // and retry exactly once. + let mut buf = bytes.to_vec(); + if buf.len() >= 80 { + buf[..80].fill(0); + } + stl_io::read_stl(&mut Cursor::new(buf)) + }); + + build_model_mesh(mesh?) +} + +/// Convert a parsed `stl_io` mesh into a validated [`ModelMesh`]. +fn build_model_mesh(mesh: stl_io::IndexedMesh) -> Result { + // No triangles → nothing to render. + if mesh.faces.is_empty() { + return Err(MeshLoadError::Degenerate); + } + // Reject non-finite positions up front so AABB / normal math can't be poisoned. + if mesh + .vertices + .iter() + .any(|v| !v.0.iter().copied().all(f32::is_finite)) + { + return Err(MeshLoadError::Degenerate); + } + + let positions = Positions::F32( + mesh.vertices + .iter() + .map(|v| Vec3::new(v.0[0], v.0[1], v.0[2])) + .collect(), + ); + let indices = Indices::U32( + mesh.faces + .iter() + .flat_map(|f| f.vertices) + .map(|i| i as u32) + .collect(), + ); + + let mut trimesh = TriMesh { + positions, + indices, + normals: None, + tangents: None, + uvs: None, + colors: None, + }; + + // Reject a point-sized bounding box (all three extents ~0). A flat plate or line — + // zero extent on one or two axes — is valid geometry and must parse. + let size = trimesh.compute_aabb().size(); + if size.x.abs() < f32::EPSILON && size.y.abs() < f32::EPSILON && size.z.abs() < f32::EPSILON { + return Err(MeshLoadError::Degenerate); + } + + // Geometry-derived normals; STL facet normals are not relied upon. + trimesh.compute_normals(); + // `compute_normals` normalizes each per-vertex face-normal sum. A vertex touched + // only by zero-area (collinear) triangles has a zero sum and normalizes to NaN. Such + // geometry is allowed to parse, so replace any non-finite normal with zero — the + // normal is genuinely undefined there, but the mesh must never carry NaN downstream. + if let Some(normals) = trimesh.normals.as_mut() { + for n in normals { + if !n.x.is_finite() || !n.y.is_finite() || !n.z.is_finite() { + *n = Vec3::new(0.0, 0.0, 0.0); + } + } + } + + Ok(ModelMesh { + trimesh, + format: MeshFormat::Stl, + units: Units::stl_assumed_mm(), + }) +} + +#[cfg(test)] +mod tests { + use super::load_stl; + use crate::mesh::contract::{LengthUnit, MeshFormat, MeshLoadError, Units}; + + /// Build an STL [`stl_io::Triangle`] from three points with a zero facet normal + /// (normals are geometry-derived, so the facet normal is irrelevant). + fn tri(a: [f32; 3], b: [f32; 3], c: [f32; 3]) -> stl_io::Triangle { + stl_io::Triangle { + normal: stl_io::Normal::new([0.0, 0.0, 0.0]), + vertices: [ + stl_io::Vertex::new(a), + stl_io::Vertex::new(b), + stl_io::Vertex::new(c), + ], + } + } + + /// Encode triangles as a binary STL (all-zero 80-byte header, per the spec). + fn binary_stl(triangles: &[stl_io::Triangle]) -> Vec { + let mut buf = Vec::new(); + stl_io::write_stl(&mut buf, triangles.iter()).expect("write_stl should encode"); + assert!( + buf.len() >= 80, + "binary STL should start with an 80-byte header" + ); + buf + } + + /// Encode triangles as a binary STL whose header starts with "solid " (the edge case + /// that mis-routes `stl_io`'s ASCII probe). + fn solid_header_binary_stl(triangles: &[stl_io::Triangle]) -> Vec { + let mut buf = binary_stl(triangles); + buf[..6].copy_from_slice(b"solid "); + buf + } + + fn tetrahedron_stl() -> Vec { + let v = [ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + ]; + binary_stl(&[ + tri(v[0], v[2], v[1]), + tri(v[0], v[1], v[3]), + tri(v[0], v[3], v[2]), + tri(v[1], v[2], v[3]), + ]) + } + + fn assert_approx(actual: f32, expected: f32) { + assert!( + (actual - expected).abs() < 1e-5, + "expected ~{expected}, got {actual}" + ); + } + + #[test] + fn parses_binary_stl() { + let model = load_stl(&tetrahedron_stl()).expect("binary STL should parse"); + assert_eq!(model.format, MeshFormat::Stl); + assert_eq!( + model.units, + Units { + source: LengthUnit::Unknown, + assumed: Some(LengthUnit::Millimeter), + } + ); + assert_eq!(model.trimesh.triangle_count(), 4); + assert!( + model.trimesh.positions.len() >= 4, + "tetrahedron should have at least its 4 unique vertices" + ); + // Normals are geometry-derived and populated by compute_normals. + let normals = model + .trimesh + .normals + .as_ref() + .expect("normals should be populated"); + assert_eq!(normals.len(), model.trimesh.positions.len()); + } + + #[test] + fn parses_ascii_stl() { + let ascii = b"\ +solid polymodel + facet normal 0 0 1 + outer loop + vertex 0 0 0 + vertex 1 0 0 + vertex 0 1 0 + endloop + endfacet +endsolid polymodel\n"; + let model = load_stl(ascii).expect("ASCII STL should parse"); + assert_eq!(model.format, MeshFormat::Stl); + assert_eq!(model.trimesh.triangle_count(), 1); + assert!(model.trimesh.normals.is_some()); + } + + #[test] + fn parses_solid_header_binary_via_retry() { + // Binary body with an "solid "-prefixed header: stl_io's ASCII probe mis-routes + // the first attempt; the header-neutralised retry must recover it. + let model = load_stl(&solid_header_binary_stl(&[tri( + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + )])) + .expect("'solid '-header binary should parse via retry"); + assert_eq!(model.trimesh.triangle_count(), 1); + assert!( + model.trimesh.normals.is_some(), + "geometry normals must be populated even on the retry path" + ); + } + + #[test] + fn parses_planar_mesh_is_not_degenerate() { + // A flat triangle (zero extent on Y) is valid geometry, not degenerate. + let model = load_stl(&binary_stl(&[tri( + [10.0, 0.0, 0.0], + [14.0, 0.0, 0.0], + [10.0, 0.0, 4.0], + )])) + .expect("planar mesh should parse"); + assert_eq!(model.trimesh.triangle_count(), 1); + } + + #[test] + fn camera_fit_off_center() { + // Off-centre triangle: AABB x[10,14] z[0,4], y={0} → centre (12,0,2), radius 2. + let model = load_stl(&binary_stl(&[tri( + [10.0, 0.0, 0.0], + [14.0, 0.0, 0.0], + [10.0, 0.0, 4.0], + )])) + .expect("off-centre mesh should parse"); + let fit = model.camera_fit(); + assert_approx(fit.center[0], 12.0); + assert_approx(fit.center[1], 0.0); + assert_approx(fit.center[2], 2.0); + assert_approx(fit.radius, 2.0); + } + + #[test] + fn collinear_mesh_yields_finite_normals() { + // A collinear (1D) triangle is zero-area: geometry normals are undefined and + // would normalize to NaN. The mesh still parses (not degenerate), and its + // normals must be finite. + let model = load_stl(&binary_stl(&[tri( + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [2.0, 0.0, 0.0], + )])) + .expect("collinear mesh should parse (not degenerate)"); + let normals = model + .trimesh + .normals + .as_ref() + .expect("normals should be populated"); + assert!( + normals + .iter() + .all(|n| { n.x.is_finite() && n.y.is_finite() && n.z.is_finite() }), + "normals must be finite even for zero-area geometry" + ); + } + + #[test] + fn error_empty() { + assert!(matches!(load_stl(&[]), Err(MeshLoadError::Empty))); + } + + #[test] + fn error_parse_garbage() { + // Pure garbage fails both attempts (ASCII probe and binary reader). + let garbage = vec![b'G'; 200]; + assert!(matches!(load_stl(&garbage), Err(MeshLoadError::Parse(_)))); + } + + #[test] + fn error_parse_truncated() { + // A valid binary STL truncated mid-body fails both attempts. + let truncated = &tetrahedron_stl()[..90]; + assert!(matches!(load_stl(truncated), Err(MeshLoadError::Parse(_)))); + } + + #[test] + fn error_zero_triangles() { + // 80-byte header + a LE triangle count of zero is a valid but empty STL. + let mut empty = vec![0u8; 84]; + empty[80..84].copy_from_slice(&0u32.to_le_bytes()); + assert!(matches!(load_stl(&empty), Err(MeshLoadError::Degenerate))); + } + + #[test] + fn error_point_sized_aabb() { + // A triangle collapsed to a single point has a point-sized AABB. + let point = binary_stl(&[tri([5.0, 5.0, 5.0], [5.0, 5.0, 5.0], [5.0, 5.0, 5.0])]); + assert!(matches!(load_stl(&point), Err(MeshLoadError::Degenerate))); + } + + #[test] + fn error_non_finite_position() { + // An infinite coordinate would poison AABB / normal math; it is rejected. + let inf = binary_stl(&[tri( + [f32::INFINITY, 0.0, 0.0], + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + )]); + assert!(matches!(load_stl(&inf), Err(MeshLoadError::Degenerate))); + } + + /// End-to-end check against the committed real-world fixture. + #[test] + fn local_body_chest_fixture_parses() { + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/assets/models/body_f_chest-v4.stl" + ); + let bytes = std::fs::read(path) + .expect("local fixture assets/models/body_f_chest-v4.stl should be committed"); + let model = load_stl(&bytes).expect("local STL fixture should parse"); + assert_eq!(model.format, MeshFormat::Stl); + assert_eq!( + model.units, + Units { + source: LengthUnit::Unknown, + assumed: Some(LengthUnit::Millimeter), + } + ); + assert!(model.trimesh.normals.is_some()); + assert!(model.trimesh.triangle_count() > 0); + } +}