···11+//! Mesh contract types: the renderer-agnostic [`ModelMesh`] and its provenance.
22+33+// `AxisAlignedBoundingBox` appears in the public `ModelMesh::aabb` signature, and
44+// `TriMesh` is a public field type, so both are re-exported alongside the contract.
55+pub use three_d_asset::{AxisAlignedBoundingBox, TriMesh};
66+77+/// Source format of a loaded mesh.
88+///
99+/// STL today; OBJ/glTF/3MF direct upload is reserved for PM-23. There is intentionally
1010+/// no binary/ASCII sub-label for STL — reliably telling them apart is the hard part, and
1111+/// both yield the same mesh.
1212+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1313+pub enum MeshFormat {
1414+ /// Stereolithography (binary or ASCII).
1515+ Stl,
1616+}
1717+1818+/// A length unit.
1919+///
2020+/// STL is unitless, so a loaded STL reports [`LengthUnit::Unknown`] as its source and
2121+/// relies on an assumed convention.
2222+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2323+pub enum LengthUnit {
2424+ Millimeter,
2525+ Centimeter,
2626+ Meter,
2727+ Inch,
2828+ /// A thousandth of an inch — "thou" in machining, "mil" in PCB/electronics.
2929+ Thou,
3030+ Foot,
3131+ Unknown,
3232+}
3333+3434+/// Unit handling for a loaded model.
3535+///
3636+/// `source` is the unit declared by the file; STL declares none, so this is
3737+/// [`LengthUnit::Unknown`]. `assumed` is the convention applied when the source is
3838+/// unknown — millimetres for Polymodel's STL ingestion.
3939+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4040+pub struct Units {
4141+ /// The unit declared by the file (STL: [`LengthUnit::Unknown`]).
4242+ pub source: LengthUnit,
4343+ /// The convention assumed when `source` is unknown.
4444+ pub assumed: Option<LengthUnit>,
4545+}
4646+4747+impl Units {
4848+ /// STL unit handling: source unknown, assume millimetres.
4949+ pub fn stl_assumed_mm() -> Self {
5050+ Self {
5151+ source: LengthUnit::Unknown,
5252+ assumed: Some(LengthUnit::Millimeter),
5353+ }
5454+ }
5555+}
5656+5757+/// Camera framing hint derived from a mesh's axis-aligned bounding box: the centre the
5858+/// camera should orbit and a bounding-radius estimate to frame it.
5959+#[derive(Debug, Clone, Copy, PartialEq)]
6060+pub struct CameraFit {
6161+ /// AABB centre.
6262+ pub center: [f32; 3],
6363+ /// Half the largest AABB extent.
6464+ pub radius: f32,
6565+}
6666+6767+/// A Polymodel mesh: a [`TriMesh`] plus format provenance and unit handling.
6868+///
6969+/// Normals are geometry-derived (via [`TriMesh::compute_normals`]); STL facet normals are
7070+/// not relied upon. Bounds and camera framing come from [`Self::aabb`].
7171+#[derive(Debug)]
7272+pub struct ModelMesh {
7373+ /// The underlying triangle mesh.
7474+ pub trimesh: TriMesh,
7575+ /// The format this mesh was loaded from.
7676+ pub format: MeshFormat,
7777+ /// Unit provenance and assumption.
7878+ pub units: Units,
7979+}
8080+8181+impl ModelMesh {
8282+ /// The axis-aligned bounding box over all vertex positions.
8383+ pub fn aabb(&self) -> AxisAlignedBoundingBox {
8484+ self.trimesh.compute_aabb()
8585+ }
8686+8787+ /// Camera framing derived from [`Self::aabb`]: `center` is the box centre and
8888+ /// `radius` is half the largest box extent.
8989+ pub fn camera_fit(&self) -> CameraFit {
9090+ let aabb = self.aabb();
9191+ let center = aabb.center();
9292+ let size = aabb.size();
9393+ CameraFit {
9494+ center: [center.x, center.y, center.z],
9595+ radius: size.x.max(size.y).max(size.z) / 2.0,
9696+ }
9797+ }
9898+}
9999+100100+/// Errors that can occur loading a mesh.
101101+#[derive(Debug, thiserror::Error)]
102102+pub enum MeshLoadError {
103103+ /// The input was empty (zero bytes).
104104+ #[error("mesh input is empty")]
105105+ Empty,
106106+ /// The bytes could not be parsed as a valid mesh of the expected format.
107107+ #[error("failed to parse STL: {0}")]
108108+ Parse(#[from] std::io::Error),
109109+ /// The mesh parsed but is degenerate: no triangles, a non-finite position, or a
110110+ /// point-sized bounding box.
111111+ #[error("mesh is degenerate")]
112112+ Degenerate,
113113+ /// The format is not yet supported (reserved for PM-23).
114114+ #[error("unsupported mesh format")]
115115+ UnsupportedFormat,
116116+}
+18
src/mesh/mod.rs
···11+//! Renderer-agnostic mesh contract and STL ingestion.
22+//!
33+//! Defines [`ModelMesh`] (in [`contract`]), a thin Polymodel wrapper over
44+//! [`three_d_asset::TriMesh`] that carries format provenance, unit assumptions, and a
55+//! camera framing hint. STL is parsed from in-memory bytes (no filesystem), so this
66+//! module compiles to `wasm32-unknown-unknown` and can be consumed by any future
77+//! renderer ([PM-14 research](https://radiant-industries.atlassian.net/wiki/spaces/PM/pages/131340)).
88+//!
99+//! Scope is STL-only for now; OBJ/glTF/3MF direct upload is tracked in PM-23.
1010+1111+// This module is a renderer-agnostic ingestion foundation: it is consumed by PM-19
1212+// (Dioxus scaffold) and PM-20/PM-21 (renderers), none of which are wired into the
1313+// binary's main path yet. Its public surface is therefore intentionally unused at
1414+// present, so dead-code analysis is relaxed here until those tickets land.
1515+#![allow(dead_code)]
1616+1717+pub mod contract;
1818+pub mod stl;
+344
src/mesh/stl.rs
···11+//! STL ingestion: parse in-memory bytes into a [`ModelMesh`] via `stl_io`.
22+//!
33+//! Both binary and ASCII STL are handled by `stl_io::read_stl`, which probes the
44+//! 80-byte header for a leading `"solid "` to pick its reader. A binary file whose
55+//! header happens to start with `"solid "` is mis-routed to the ASCII reader and fails;
66+//! on a parse error we neutralise the header in an owned copy and retry exactly once, so
77+//! `stl_io` re-probes and picks its binary reader. We deliberately do **not** try to
88+//! classify binary-vs-ASCII ourselves (size-based detection is an unreliable heuristic,
99+//! and `stl_io` exposes no public binary-only entry point).
1010+//!
1111+//! Normals are geometry-derived ([`three_d_asset::TriMesh::compute_normals`]); STL facet
1212+//! normals are not relied upon. No filesystem or network access, so this is safe to call
1313+//! from a `wasm32-unknown-unknown` build.
1414+1515+use std::io::Cursor;
1616+1717+use three_d_asset::{Indices, Positions, TriMesh, Vec3};
1818+1919+use crate::mesh::contract::{MeshFormat, MeshLoadError, ModelMesh, Units};
2020+2121+/// Parse STL bytes (binary or ASCII) into a [`ModelMesh`].
2222+pub fn load_stl(bytes: &[u8]) -> Result<ModelMesh, MeshLoadError> {
2323+ if bytes.is_empty() {
2424+ return Err(MeshLoadError::Empty);
2525+ }
2626+2727+ let mesh = stl_io::read_stl(&mut Cursor::new(bytes)).or_else(|_| {
2828+ // `stl_io` mis-routes binaries whose 80-byte header starts with "solid " to its
2929+ // ASCII reader. Neutralise the header so the probe routes to the binary reader
3030+ // and retry exactly once.
3131+ let mut buf = bytes.to_vec();
3232+ if buf.len() >= 80 {
3333+ buf[..80].fill(0);
3434+ }
3535+ stl_io::read_stl(&mut Cursor::new(buf))
3636+ });
3737+3838+ build_model_mesh(mesh?)
3939+}
4040+4141+/// Convert a parsed `stl_io` mesh into a validated [`ModelMesh`].
4242+fn build_model_mesh(mesh: stl_io::IndexedMesh) -> Result<ModelMesh, MeshLoadError> {
4343+ // No triangles → nothing to render.
4444+ if mesh.faces.is_empty() {
4545+ return Err(MeshLoadError::Degenerate);
4646+ }
4747+ // Reject non-finite positions up front so AABB / normal math can't be poisoned.
4848+ if mesh
4949+ .vertices
5050+ .iter()
5151+ .any(|v| !v.0.iter().copied().all(f32::is_finite))
5252+ {
5353+ return Err(MeshLoadError::Degenerate);
5454+ }
5555+5656+ let positions = Positions::F32(
5757+ mesh.vertices
5858+ .iter()
5959+ .map(|v| Vec3::new(v.0[0], v.0[1], v.0[2]))
6060+ .collect(),
6161+ );
6262+ let indices = Indices::U32(
6363+ mesh.faces
6464+ .iter()
6565+ .flat_map(|f| f.vertices)
6666+ .map(|i| i as u32)
6767+ .collect(),
6868+ );
6969+7070+ let mut trimesh = TriMesh {
7171+ positions,
7272+ indices,
7373+ normals: None,
7474+ tangents: None,
7575+ uvs: None,
7676+ colors: None,
7777+ };
7878+7979+ // Reject a point-sized bounding box (all three extents ~0). A flat plate or line —
8080+ // zero extent on one or two axes — is valid geometry and must parse.
8181+ let size = trimesh.compute_aabb().size();
8282+ if size.x.abs() < f32::EPSILON && size.y.abs() < f32::EPSILON && size.z.abs() < f32::EPSILON {
8383+ return Err(MeshLoadError::Degenerate);
8484+ }
8585+8686+ // Geometry-derived normals; STL facet normals are not relied upon.
8787+ trimesh.compute_normals();
8888+ // `compute_normals` normalizes each per-vertex face-normal sum. A vertex touched
8989+ // only by zero-area (collinear) triangles has a zero sum and normalizes to NaN. Such
9090+ // geometry is allowed to parse, so replace any non-finite normal with zero — the
9191+ // normal is genuinely undefined there, but the mesh must never carry NaN downstream.
9292+ if let Some(normals) = trimesh.normals.as_mut() {
9393+ for n in normals {
9494+ if !n.x.is_finite() || !n.y.is_finite() || !n.z.is_finite() {
9595+ *n = Vec3::new(0.0, 0.0, 0.0);
9696+ }
9797+ }
9898+ }
9999+100100+ Ok(ModelMesh {
101101+ trimesh,
102102+ format: MeshFormat::Stl,
103103+ units: Units::stl_assumed_mm(),
104104+ })
105105+}
106106+107107+#[cfg(test)]
108108+mod tests {
109109+ use super::load_stl;
110110+ use crate::mesh::contract::{LengthUnit, MeshFormat, MeshLoadError, Units};
111111+112112+ /// Build an STL [`stl_io::Triangle`] from three points with a zero facet normal
113113+ /// (normals are geometry-derived, so the facet normal is irrelevant).
114114+ fn tri(a: [f32; 3], b: [f32; 3], c: [f32; 3]) -> stl_io::Triangle {
115115+ stl_io::Triangle {
116116+ normal: stl_io::Normal::new([0.0, 0.0, 0.0]),
117117+ vertices: [
118118+ stl_io::Vertex::new(a),
119119+ stl_io::Vertex::new(b),
120120+ stl_io::Vertex::new(c),
121121+ ],
122122+ }
123123+ }
124124+125125+ /// Encode triangles as a binary STL (all-zero 80-byte header, per the spec).
126126+ fn binary_stl(triangles: &[stl_io::Triangle]) -> Vec<u8> {
127127+ let mut buf = Vec::new();
128128+ stl_io::write_stl(&mut buf, triangles.iter()).expect("write_stl should encode");
129129+ assert!(
130130+ buf.len() >= 80,
131131+ "binary STL should start with an 80-byte header"
132132+ );
133133+ buf
134134+ }
135135+136136+ /// Encode triangles as a binary STL whose header starts with "solid " (the edge case
137137+ /// that mis-routes `stl_io`'s ASCII probe).
138138+ fn solid_header_binary_stl(triangles: &[stl_io::Triangle]) -> Vec<u8> {
139139+ let mut buf = binary_stl(triangles);
140140+ buf[..6].copy_from_slice(b"solid ");
141141+ buf
142142+ }
143143+144144+ fn tetrahedron_stl() -> Vec<u8> {
145145+ let v = [
146146+ [0.0, 0.0, 0.0],
147147+ [1.0, 0.0, 0.0],
148148+ [0.0, 1.0, 0.0],
149149+ [0.0, 0.0, 1.0],
150150+ ];
151151+ binary_stl(&[
152152+ tri(v[0], v[2], v[1]),
153153+ tri(v[0], v[1], v[3]),
154154+ tri(v[0], v[3], v[2]),
155155+ tri(v[1], v[2], v[3]),
156156+ ])
157157+ }
158158+159159+ fn assert_approx(actual: f32, expected: f32) {
160160+ assert!(
161161+ (actual - expected).abs() < 1e-5,
162162+ "expected ~{expected}, got {actual}"
163163+ );
164164+ }
165165+166166+ #[test]
167167+ fn parses_binary_stl() {
168168+ let model = load_stl(&tetrahedron_stl()).expect("binary STL should parse");
169169+ assert_eq!(model.format, MeshFormat::Stl);
170170+ assert_eq!(
171171+ model.units,
172172+ Units {
173173+ source: LengthUnit::Unknown,
174174+ assumed: Some(LengthUnit::Millimeter),
175175+ }
176176+ );
177177+ assert_eq!(model.trimesh.triangle_count(), 4);
178178+ assert!(
179179+ model.trimesh.positions.len() >= 4,
180180+ "tetrahedron should have at least its 4 unique vertices"
181181+ );
182182+ // Normals are geometry-derived and populated by compute_normals.
183183+ let normals = model
184184+ .trimesh
185185+ .normals
186186+ .as_ref()
187187+ .expect("normals should be populated");
188188+ assert_eq!(normals.len(), model.trimesh.positions.len());
189189+ }
190190+191191+ #[test]
192192+ fn parses_ascii_stl() {
193193+ let ascii = b"\
194194+solid polymodel
195195+ facet normal 0 0 1
196196+ outer loop
197197+ vertex 0 0 0
198198+ vertex 1 0 0
199199+ vertex 0 1 0
200200+ endloop
201201+ endfacet
202202+endsolid polymodel\n";
203203+ let model = load_stl(ascii).expect("ASCII STL should parse");
204204+ assert_eq!(model.format, MeshFormat::Stl);
205205+ assert_eq!(model.trimesh.triangle_count(), 1);
206206+ assert!(model.trimesh.normals.is_some());
207207+ }
208208+209209+ #[test]
210210+ fn parses_solid_header_binary_via_retry() {
211211+ // Binary body with an "solid "-prefixed header: stl_io's ASCII probe mis-routes
212212+ // the first attempt; the header-neutralised retry must recover it.
213213+ let model = load_stl(&solid_header_binary_stl(&[tri(
214214+ [0.0, 0.0, 0.0],
215215+ [1.0, 0.0, 0.0],
216216+ [0.0, 1.0, 0.0],
217217+ )]))
218218+ .expect("'solid '-header binary should parse via retry");
219219+ assert_eq!(model.trimesh.triangle_count(), 1);
220220+ assert!(
221221+ model.trimesh.normals.is_some(),
222222+ "geometry normals must be populated even on the retry path"
223223+ );
224224+ }
225225+226226+ #[test]
227227+ fn parses_planar_mesh_is_not_degenerate() {
228228+ // A flat triangle (zero extent on Y) is valid geometry, not degenerate.
229229+ let model = load_stl(&binary_stl(&[tri(
230230+ [10.0, 0.0, 0.0],
231231+ [14.0, 0.0, 0.0],
232232+ [10.0, 0.0, 4.0],
233233+ )]))
234234+ .expect("planar mesh should parse");
235235+ assert_eq!(model.trimesh.triangle_count(), 1);
236236+ }
237237+238238+ #[test]
239239+ fn camera_fit_off_center() {
240240+ // Off-centre triangle: AABB x[10,14] z[0,4], y={0} → centre (12,0,2), radius 2.
241241+ let model = load_stl(&binary_stl(&[tri(
242242+ [10.0, 0.0, 0.0],
243243+ [14.0, 0.0, 0.0],
244244+ [10.0, 0.0, 4.0],
245245+ )]))
246246+ .expect("off-centre mesh should parse");
247247+ let fit = model.camera_fit();
248248+ assert_approx(fit.center[0], 12.0);
249249+ assert_approx(fit.center[1], 0.0);
250250+ assert_approx(fit.center[2], 2.0);
251251+ assert_approx(fit.radius, 2.0);
252252+ }
253253+254254+ #[test]
255255+ fn collinear_mesh_yields_finite_normals() {
256256+ // A collinear (1D) triangle is zero-area: geometry normals are undefined and
257257+ // would normalize to NaN. The mesh still parses (not degenerate), and its
258258+ // normals must be finite.
259259+ let model = load_stl(&binary_stl(&[tri(
260260+ [0.0, 0.0, 0.0],
261261+ [1.0, 0.0, 0.0],
262262+ [2.0, 0.0, 0.0],
263263+ )]))
264264+ .expect("collinear mesh should parse (not degenerate)");
265265+ let normals = model
266266+ .trimesh
267267+ .normals
268268+ .as_ref()
269269+ .expect("normals should be populated");
270270+ assert!(
271271+ normals
272272+ .iter()
273273+ .all(|n| { n.x.is_finite() && n.y.is_finite() && n.z.is_finite() }),
274274+ "normals must be finite even for zero-area geometry"
275275+ );
276276+ }
277277+278278+ #[test]
279279+ fn error_empty() {
280280+ assert!(matches!(load_stl(&[]), Err(MeshLoadError::Empty)));
281281+ }
282282+283283+ #[test]
284284+ fn error_parse_garbage() {
285285+ // Pure garbage fails both attempts (ASCII probe and binary reader).
286286+ let garbage = vec![b'G'; 200];
287287+ assert!(matches!(load_stl(&garbage), Err(MeshLoadError::Parse(_))));
288288+ }
289289+290290+ #[test]
291291+ fn error_parse_truncated() {
292292+ // A valid binary STL truncated mid-body fails both attempts.
293293+ let truncated = &tetrahedron_stl()[..90];
294294+ assert!(matches!(load_stl(truncated), Err(MeshLoadError::Parse(_))));
295295+ }
296296+297297+ #[test]
298298+ fn error_zero_triangles() {
299299+ // 80-byte header + a LE triangle count of zero is a valid but empty STL.
300300+ let mut empty = vec![0u8; 84];
301301+ empty[80..84].copy_from_slice(&0u32.to_le_bytes());
302302+ assert!(matches!(load_stl(&empty), Err(MeshLoadError::Degenerate)));
303303+ }
304304+305305+ #[test]
306306+ fn error_point_sized_aabb() {
307307+ // A triangle collapsed to a single point has a point-sized AABB.
308308+ let point = binary_stl(&[tri([5.0, 5.0, 5.0], [5.0, 5.0, 5.0], [5.0, 5.0, 5.0])]);
309309+ assert!(matches!(load_stl(&point), Err(MeshLoadError::Degenerate)));
310310+ }
311311+312312+ #[test]
313313+ fn error_non_finite_position() {
314314+ // An infinite coordinate would poison AABB / normal math; it is rejected.
315315+ let inf = binary_stl(&[tri(
316316+ [f32::INFINITY, 0.0, 0.0],
317317+ [1.0, 0.0, 0.0],
318318+ [0.0, 1.0, 0.0],
319319+ )]);
320320+ assert!(matches!(load_stl(&inf), Err(MeshLoadError::Degenerate)));
321321+ }
322322+323323+ /// End-to-end check against the committed real-world fixture.
324324+ #[test]
325325+ fn local_body_chest_fixture_parses() {
326326+ let path = concat!(
327327+ env!("CARGO_MANIFEST_DIR"),
328328+ "/assets/models/body_f_chest-v4.stl"
329329+ );
330330+ let bytes = std::fs::read(path)
331331+ .expect("local fixture assets/models/body_f_chest-v4.stl should be committed");
332332+ let model = load_stl(&bytes).expect("local STL fixture should parse");
333333+ assert_eq!(model.format, MeshFormat::Stl);
334334+ assert_eq!(
335335+ model.units,
336336+ Units {
337337+ source: LengthUnit::Unknown,
338338+ assumed: Some(LengthUnit::Millimeter),
339339+ }
340340+ );
341341+ assert!(model.trimesh.normals.is_some());
342342+ assert!(model.trimesh.triangle_count() > 0);
343343+ }
344344+}