diff --git a/Cargo.lock b/Cargo.lock index 0eb05fc..f41acda 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "allocator-api2" version = "0.2.21" @@ -47,6 +53,12 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + [[package]] name = "bit-set" version = "0.10.0" @@ -103,6 +115,18 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "cfg-if" version = "1.0.5" @@ -126,6 +150,15 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + [[package]] name = "crunchy" version = "0.2.4" @@ -166,6 +199,26 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + [[package]] name = "foldhash" version = "0.2.0" @@ -243,6 +296,45 @@ dependencies = [ "web-sys", ] +[[package]] +name = "gltf" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ce1918195723ce6ac74e80542c5a96a40c2b26162c1957a5cd70799b8cacf7" +dependencies = [ + "base64", + "byteorder", + "gltf-json", + "image", + "lazy_static", + "serde_json", + "urlencoding", +] + +[[package]] +name = "gltf-derive" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14070e711538afba5d6c807edb74bcb84e5dbb9211a3bf5dea0dfab5b24f4c51" +dependencies = [ + "inflections", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "gltf-json" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6176f9d60a7eab0a877e8e96548605dedbde9190a7ae1e80bbcc1c9af03ab14" +dependencies = [ + "gltf-derive", + "serde", + "serde_derive", + "serde_json", +] + [[package]] name = "glutin_wgl_sys" version = "0.6.1" @@ -298,6 +390,21 @@ dependencies = [ "foldhash", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", + "zune-core", + "zune-jpeg", +] + [[package]] name = "indexmap" version = "2.14.2" @@ -308,6 +415,18 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "inflections" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a257582fdcde896fd96463bf2d40eefea0580021c0712a0e2b028b60b47a837a" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + [[package]] name = "jni-sys" version = "0.3.1" @@ -364,6 +483,12 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + [[package]] name = "libc" version = "0.2.190" @@ -417,6 +542,42 @@ dependencies = [ "rawpointer", ] +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "naga" version = "30.0.1" @@ -680,6 +841,19 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + [[package]] name = "pollster" version = "1.0.1" @@ -722,6 +896,12 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + [[package]] name = "quote" version = "1.0.47" @@ -782,9 +962,11 @@ version = "0.1.0" dependencies = [ "approx", "bytemuck", + "gltf", "log", "nalgebra", "pollster", + "stl_io", "thiserror", "wgpu", ] @@ -853,6 +1035,19 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "simba" version = "0.10.2" @@ -865,6 +1060,12 @@ dependencies = [ "wide", ] +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "slab" version = "0.4.12" @@ -901,6 +1102,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "stl_io" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567641995c51a3b8befddb13e1826187bcf7eb7ca8d13746bfd1cc5a22e89fa8" + [[package]] name = "syn" version = "2.0.119" @@ -979,6 +1186,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "version_check" version = "0.9.5" @@ -1399,3 +1612,30 @@ dependencies = [ "quote", "syn 2.0.119", ] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/crates/resolved/Cargo.toml b/crates/resolved/Cargo.toml index bbdef5b..44f4125 100644 --- a/crates/resolved/Cargo.toml +++ b/crates/resolved/Cargo.toml @@ -18,6 +18,8 @@ wgpu = "30.0.1" pollster = "1.0" bytemuck = { version = "1.0", features = ["derive"] } log = "0.4" +gltf = "1.4" +stl_io = "0.11" [dev-dependencies] approx = "0.5" diff --git a/crates/resolved/src/error.rs b/crates/resolved/src/error.rs index 8465f96..bdf34a5 100644 --- a/crates/resolved/src/error.rs +++ b/crates/resolved/src/error.rs @@ -17,6 +17,10 @@ pub enum Error { available: Vec, }, + /// A model file that could not be read or describes no usable geometry. + #[error("model: {0}")] + Model(String), + /// A request this version cannot carry out yet. #[error("unsupported: {0}")] Unsupported(String), diff --git a/crates/resolved/src/lib.rs b/crates/resolved/src/lib.rs index a857043..4f525a6 100644 --- a/crates/resolved/src/lib.rs +++ b/crates/resolved/src/lib.rs @@ -21,6 +21,7 @@ pub mod bodies; pub mod camera; pub mod error; pub mod gpu; +pub mod model; pub mod radiometry; pub mod scene; mod solar_data; @@ -28,6 +29,7 @@ pub mod spectrum; pub use camera::{CameraConfig, Channel, Detector, Intrinsics}; pub use error::Error; +pub use model::{LoadOptions, Material, Mesh}; pub use radiometry::Radiometry; pub use scene::{CameraRelative, Placement, Pose, Scene, Target}; pub use spectrum::Spectrum; diff --git a/crates/resolved/src/model.rs b/crates/resolved/src/model.rs new file mode 100644 index 0000000..1e9fbac --- /dev/null +++ b/crates/resolved/src/model.rs @@ -0,0 +1,532 @@ +//! Spacecraft models: loading STL and glTF into one triangle mesh. +//! +//! A [`Mesh`] is in the model's body frame, in meters. It is split into parts, one per +//! STL file or glTF primitive, and each part has a [`Material`]. Normals are per +//! vertex. Where the file has none (STL's are ignored, since exporters often get them +//! wrong), each triangle gets its own flat normal from its winding, which suits the +//! flat panels most spacecraft are built from. + +use std::io::{Read, Seek}; +use std::path::Path; + +use nalgebra::{Matrix3, Matrix4, Point3, Vector3}; + +use crate::spectrum::Spectrum; +use crate::Error; + +/// How a surface reflects light. Lambertian for now; BRDFs arrive in M4. +#[derive(Clone, Debug, PartialEq)] +pub struct Material { + /// A name for logs and labels. + pub name: String, + /// Fraction of light reflected, by wavelength. + pub albedo: Spectrum, +} + +impl Material { + /// A material with the same albedo at every wavelength. + pub fn gray(name: &str, albedo: f64) -> Self { + Self { + name: name.to_owned(), + albedo: Spectrum::constant(albedo), + } + } + + /// A material from a red, green, blue reflectance, as glTF stores base color. + /// + /// This is a rough spectrum: blue below 490 nm, green from 500 to 580 nm, red above + /// 590 nm (red extends into the infrared), with linear steps between. An RGB triple + /// does not determine a spectrum, so a material whose reflectance matters should be + /// given one measured spectrum instead. + pub fn from_rgb(name: &str, rgb: [f64; 3]) -> Self { + let [r, g, b] = rgb; + let nm = [200.0, 490.0, 500.0, 580.0, 590.0, 2800.0].map(|x| x * 1e-9); + Self { + name: name.to_owned(), + albedo: Spectrum::sampled(nm.to_vec(), vec![b, b, g, g, r, r]) + .expect("fixed wavelengths"), + } + } +} + +/// A named piece of a model, drawn with one material. +#[derive(Clone, Debug, PartialEq)] +pub struct Part { + /// The glTF node or mesh name, or the STL file name. + pub name: String, + /// Index into [`Mesh::materials`]. + pub material: usize, +} + +/// How to interpret a model file. +#[derive(Clone, Debug, PartialEq)] +pub struct LoadOptions { + /// Multiplies every coordinate. glTF is in meters by definition; STL has no units, + /// and CAD tools usually export millimeters, which needs 0.001. + pub scale: f64, + /// For STL files, and glTF primitives that name no material. + pub default_material: Material, +} + +impl Default for LoadOptions { + fn default() -> Self { + Self { + scale: 1.0, + default_material: Material::gray("default", 0.5), + } + } +} + +/// A triangle mesh in the model's body frame. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Mesh { + /// Vertex positions, in meters. + pub positions: Vec<[f32; 3]>, + /// Unit vertex normals. + pub normals: Vec<[f32; 3]>, + /// The part each vertex belongs to, as an index into [`Mesh::parts`]. + pub part_of: Vec, + /// Three vertex indices per triangle, counterclockwise seen from outside. + pub indices: Vec, + /// The model's parts. + pub parts: Vec, + /// The materials parts refer to. + pub materials: Vec, +} + +impl Mesh { + /// Loads an `.stl`, `.gltf` or `.glb` file, chosen by extension. + pub fn load(path: &Path, options: &LoadOptions) -> Result { + let ext = path + .extension() + .and_then(|e| e.to_str()) + .map(str::to_lowercase); + match ext.as_deref() { + Some("stl") => { + let name = path.file_stem().and_then(|s| s.to_str()).unwrap_or("stl"); + let mut file = std::fs::File::open(path).map_err(|e| model_error(path, e))?; + Self::from_stl(&mut file, name, options) + } + Some("gltf" | "glb") => { + let (document, buffers, _) = + gltf::import(path).map_err(|e| model_error(path, e))?; + Self::from_gltf(&document, &buffers, options) + } + _ => Err(Error::Model(format!( + "{}: expected an .stl, .gltf or .glb file", + path.display() + ))), + } + } + + /// Reads an ASCII or binary STL. The whole file becomes one part named `name`. + pub fn from_stl( + reader: &mut R, + name: &str, + options: &LoadOptions, + ) -> Result { + let triangles = stl_io::create_stl_reader(reader) + .and_then(|tris| tris.collect::>>()) + .map_err(|e| Error::Model(format!("{name}: {e}")))?; + let mut mesh = Mesh { + parts: vec![Part { + name: name.to_owned(), + material: 0, + }], + materials: vec![options.default_material.clone()], + ..Default::default() + }; + let scale = options.scale as f32; + let corners: Vec<[Vector3; 3]> = triangles + .iter() + .map(|t| t.vertices.map(|v| Vector3::from(v.0) * scale)) + .collect(); + mesh.push_flat(&corners, 0); + mesh.validated() + } + + /// Builds a mesh from an imported glTF document: every triangle primitive in the + /// default scene (or the first), with node transforms applied. + pub fn from_gltf( + document: &gltf::Document, + buffers: &[gltf::buffer::Data], + options: &LoadOptions, + ) -> Result { + let mut mesh = Mesh::default(); + let mut material_index = std::collections::HashMap::new(); + let scene = document + .default_scene() + .or_else(|| document.scenes().next()) + .ok_or_else(|| Error::Model("glTF file has no scene".into()))?; + let scale = Matrix4::new_scaling(options.scale); + let mut stack: Vec<(gltf::Node, Matrix4)> = + scene.nodes().map(|n| (n, scale)).collect(); + while let Some((node, parent)) = stack.pop() { + let local = Matrix4::from(node.transform().matrix()).cast::(); + let world = parent * local; + stack.extend(node.children().map(|c| (c, world))); + let Some(gmesh) = node.mesh() else { continue }; + for (p, primitive) in gmesh.primitives().enumerate() { + if primitive.mode() != gltf::mesh::Mode::Triangles { + log::warn!( + "skipping {:?} primitive in mesh {:?}", + primitive.mode(), + gmesh.name() + ); + continue; + } + let material = match primitive.material().index() { + None => *material_index.entry(None).or_insert_with(|| { + mesh.materials.push(options.default_material.clone()); + mesh.materials.len() - 1 + }), + Some(i) => *material_index.entry(Some(i)).or_insert_with(|| { + let m = primitive.material(); + let [r, g, b, _] = m.pbr_metallic_roughness().base_color_factor(); + let name = m + .name() + .map_or_else(|| format!("material {i}"), str::to_owned); + if m.pbr_metallic_roughness().base_color_texture().is_some() { + log::warn!("{name}: base color texture ignored; using its factor"); + } + mesh.materials + .push(Material::from_rgb(&name, [r, g, b].map(f64::from))); + mesh.materials.len() - 1 + }), + }; + let name = node + .name() + .or(gmesh.name()) + .map_or_else(|| format!("node {}", node.index()), str::to_owned); + mesh.parts.push(Part { + name: format!("{name}/{p}"), + material, + }); + mesh.push_primitive(&primitive, buffers, &world)?; + } + } + mesh.validated() + } + + /// Appends one glTF primitive, transformed by `world`, as the newest part. + fn push_primitive( + &mut self, + primitive: &gltf::Primitive, + buffers: &[gltf::buffer::Data], + world: &Matrix4, + ) -> Result<(), Error> { + let part = (self.parts.len() - 1) as u32; + let reader = primitive.reader(|b| buffers.get(b.index()).map(|d| &d.0[..])); + let positions: Vec> = reader + .read_positions() + .ok_or_else(|| Error::Model("primitive has no positions".into()))? + .map(|p| world.transform_point(&Point3::from(p).cast()).coords.cast()) + .collect(); + let indices: Vec = match reader.read_indices() { + Some(i) => i.into_u32().collect(), + None => (0..positions.len() as u32).collect(), + }; + if indices.len() % 3 != 0 || indices.iter().any(|&i| i as usize >= positions.len()) { + return Err(Error::Model("primitive has malformed indices".into())); + } + let Some(normals) = reader.read_normals() else { + let corners: Vec<_> = indices + .chunks_exact(3) + .map(|t| [0, 1, 2].map(|k| positions[t[k] as usize])) + .collect(); + self.push_flat(&corners, part); + return Ok(()); + }; + // Normals transform by the inverse transpose, which keeps them perpendicular + // to surfaces under non-uniform scaling. + let normal_matrix = world + .fixed_view::<3, 3>(0, 0) + .into_owned() + .try_inverse() + .unwrap_or_else(Matrix3::identity) + .transpose(); + let base = self.positions.len() as u32; + for (p, n) in positions.iter().zip(normals) { + let n = (normal_matrix * Vector3::from(n).cast::()) + .normalize() + .cast::(); + self.positions.push((*p).into()); + self.normals.push(n.into()); + self.part_of.push(part); + } + self.indices.extend(indices.iter().map(|i| base + i)); + Ok(()) + } + + /// Appends unindexed triangles with flat normals from their winding. Triangles with + /// no area have no normal and are dropped. + fn push_flat(&mut self, triangles: &[[Vector3; 3]], part: u32) { + let mut dropped = 0; + for [a, b, c] in triangles { + let Some(n) = (b - a).cross(&(c - a)).try_normalize(0.0) else { + dropped += 1; + continue; + }; + for p in [a, b, c] { + self.indices.push(self.positions.len() as u32); + self.positions.push((*p).into()); + self.normals.push(n.into()); + self.part_of.push(part); + } + } + if dropped > 0 { + log::warn!("dropped {dropped} zero-area triangles"); + } + } + + fn validated(self) -> Result { + if self.indices.is_empty() { + return Err(Error::Model("model has no triangles".into())); + } + if !self.positions.iter().flatten().all(|x| x.is_finite()) { + return Err(Error::Model("model has non-finite coordinates".into())); + } + Ok(self) + } + + /// Number of triangles. + pub fn triangle_count(&self) -> usize { + self.indices.len() / 3 + } + + /// Distance from the body-frame origin to the farthest vertex, in meters. + pub fn bounding_radius(&self) -> f64 { + self.positions + .iter() + .map(|p| Vector3::from(*p).cast::().norm()) + .fold(0.0, f64::max) + } +} + +fn model_error(path: &Path, e: impl std::fmt::Display) -> Error { + Error::Model(format!("{}: {e}", path.display())) +} + +#[cfg(test)] +mod tests { + use super::*; + use approx::assert_relative_eq; + use std::io::Cursor; + + /// A 2 mm cube centered on the origin, as an ASCII STL with deliberately wrong + /// facet normals (zeros, as some exporters write). + fn cube_stl() -> String { + let v = |i: usize| { + let c = [(i & 1) as f32, ((i >> 1) & 1) as f32, ((i >> 2) & 1) as f32]; + c.map(|x| 2.0 * x - 1.0) + }; + // Each face counterclockwise from outside, as two triangles. + let faces = [ + [0, 2, 3, 1], // -z + [4, 5, 7, 6], // +z + [0, 1, 5, 4], // -y + [2, 6, 7, 3], // +y + [0, 4, 6, 2], // -x + [1, 3, 7, 5], // +x + ]; + let mut s = String::from("solid cube\n"); + for f in faces { + for tri in [[f[0], f[1], f[2]], [f[0], f[2], f[3]]] { + s += "facet normal 0 0 0\nouter loop\n"; + for i in tri { + let [x, y, z] = v(i); + s += &format!("vertex {x} {y} {z}\n"); + } + s += "endloop\nendfacet\n"; + } + } + s + "endsolid cube\n" + } + + #[test] + fn stl_cube_gets_outward_flat_normals_and_scale() { + let options = LoadOptions { + scale: 1e-3, + ..Default::default() + }; + let mesh = Mesh::from_stl(&mut Cursor::new(cube_stl()), "cube", &options).unwrap(); + assert_eq!(mesh.triangle_count(), 12); + assert_eq!(mesh.positions.len(), 36); + assert_relative_eq!( + mesh.bounding_radius(), + 3f64.sqrt() * 1e-3, + max_relative = 1e-6 + ); + for (p, n) in mesh.positions.iter().zip(&mesh.normals) { + // Outward: the normal points the same way as the face center from the origin, + // and is one of the six axes. + let (p, n) = (Vector3::from(*p), Vector3::from(*n)); + assert!(p.dot(&n) > 0.0); + assert_relative_eq!(n.abs().max(), 1.0, epsilon = 1e-6); + } + assert_eq!( + mesh.parts, + vec![Part { + name: "cube".into(), + material: 0 + }] + ); + assert!(mesh.part_of.iter().all(|&p| p == 0)); + } + + #[test] + fn binary_stl_reads_the_same() { + let ascii = + Mesh::from_stl(&mut Cursor::new(cube_stl()), "cube", &Default::default()).unwrap(); + let mut bytes = vec![0u8; 80]; + bytes.extend((ascii.triangle_count() as u32).to_le_bytes()); + for tri in ascii.positions.chunks_exact(3) { + bytes.extend([0f32; 3].iter().flat_map(|x| x.to_le_bytes())); + bytes.extend(tri.iter().flatten().flat_map(|x| x.to_le_bytes())); + bytes.extend([0u8; 2]); + } + let binary = Mesh::from_stl(&mut Cursor::new(bytes), "cube", &Default::default()).unwrap(); + assert_eq!(binary, ascii); + } + + #[test] + fn degenerate_and_empty_stl() { + let flat = "solid x\nfacet normal 0 0 1\nouter loop\nvertex 0 0 0\nvertex 1 0 0\nvertex 2 0 0\nendloop\nendfacet\nendsolid x\n"; + assert!(Mesh::from_stl(&mut Cursor::new(flat), "x", &Default::default()).is_err()); + assert!(Mesh::from_stl(&mut Cursor::new("not an stl"), "x", &Default::default()).is_err()); + } + + /// One triangle (0,0,0), (1,0,0), (0,1,0) with no normals or indices, used by two + /// nodes: `bus` translated 10 m along X with a red material, and its child `boom`, + /// scaled 2x with no material. + const GLTF: &str = r#"{ + "asset": {"version": "2.0"}, + "scene": 0, + "scenes": [{"nodes": [0]}], + "nodes": [ + {"name": "bus", "mesh": 0, "translation": [10, 0, 0], "children": [1]}, + {"name": "boom", "mesh": 1, "scale": [2, 2, 2]} + ], + "meshes": [ + {"primitives": [{"attributes": {"POSITION": 0}, "material": 0}]}, + {"primitives": [{"attributes": {"POSITION": 0}}]} + ], + "materials": [{"name": "red paint", "pbrMetallicRoughness": {"baseColorFactor": [0.8, 0.1, 0.05, 1]}}], + "accessors": [{"bufferView": 0, "componentType": 5126, "count": 3, "type": "VEC3", + "min": [0, 0, 0], "max": [1, 1, 0]}], + "bufferViews": [{"buffer": 0, "byteLength": 36}], + "buffers": [{"byteLength": 36, + "uri": "data:application/octet-stream;base64,AAAAAAAAAAAAAAAAAACAPwAAAAAAAAAAAAAAAAAAgD8AAAAA"}] + }"#; + + fn gltf_mesh(options: &LoadOptions) -> Mesh { + let (document, buffers, _) = gltf::import_slice(GLTF.as_bytes()).unwrap(); + Mesh::from_gltf(&document, &buffers, options).unwrap() + } + + #[test] + fn gltf_applies_node_transforms_and_materials() { + let mesh = gltf_mesh(&Default::default()); + assert_eq!(mesh.triangle_count(), 2); + let names: Vec<_> = mesh.parts.iter().map(|p| p.name.as_str()).collect(); + assert_eq!(names, ["bus/0", "boom/0"]); + + let part = |name: &str| mesh.parts.iter().position(|p| p.name == name).unwrap() as u32; + let corners = |part: u32| -> Vec<[f32; 3]> { + (0..mesh.positions.len()) + .filter(|&i| mesh.part_of[i] == part) + .map(|i| mesh.positions[i]) + .collect() + }; + assert_eq!( + corners(part("bus/0")), + [[10.0, 0.0, 0.0], [11.0, 0.0, 0.0], [10.0, 1.0, 0.0]] + ); + // The child inherits the parent's translation, then scales. + assert_eq!( + corners(part("boom/0")), + [[10.0, 0.0, 0.0], [12.0, 0.0, 0.0], [10.0, 2.0, 0.0]] + ); + assert!(mesh.normals.iter().all(|n| *n == [0.0, 0.0, 1.0])); + + let bus = &mesh.materials[mesh.parts[part("bus/0") as usize].material]; + assert_eq!(bus.name, "red paint"); + assert_relative_eq!(bus.albedo.at(650e-9), 0.8, epsilon = 1e-6); + assert_relative_eq!(bus.albedo.at(540e-9), 0.1, epsilon = 1e-6); + assert_relative_eq!(bus.albedo.at(450e-9), 0.05, epsilon = 1e-6); + let boom = &mesh.materials[mesh.parts[part("boom/0") as usize].material]; + assert_eq!(boom.name, "default"); + } + + #[test] + fn gltf_scale_option_multiplies_everything() { + let options = LoadOptions { + scale: 0.5, + ..Default::default() + }; + let mesh = gltf_mesh(&options); + assert_relative_eq!(mesh.bounding_radius(), 6.0, epsilon = 1e-6); + } + + /// The triangle (0,0,0), (1,0,0), (0,1,1) with stored normals and a u16 index + /// buffer, on a node stretched 3x along Y. + const GLTF_NORMALS: &str = r#"{ + "asset": {"version": "2.0"}, + "scenes": [{"nodes": [0]}], + "nodes": [{"name": "panel", "mesh": 0, "scale": [1, 3, 1]}], + "meshes": [{"primitives": [{"attributes": {"POSITION": 0, "NORMAL": 1}, "indices": 2}]}], + "accessors": [ + {"bufferView": 0, "componentType": 5126, "count": 3, "type": "VEC3", + "min": [0, 0, 0], "max": [1, 1, 1]}, + {"bufferView": 1, "componentType": 5126, "count": 3, "type": "VEC3"}, + {"bufferView": 2, "componentType": 5123, "count": 3, "type": "SCALAR"} + ], + "bufferViews": [ + {"buffer": 0, "byteOffset": 0, "byteLength": 36}, + {"buffer": 0, "byteOffset": 36, "byteLength": 36}, + {"buffer": 0, "byteOffset": 72, "byteLength": 6} + ], + "buffers": [{"byteLength": 80, + "uri": "data:application/octet-stream;base64,AAAAAAAAAAAAAAAAAACAPwAAAAAAAAAAAAAAAAAAgD8AAIA/AAAAAPMENb/zBDU/AAAAAPMENb/zBDU/AAAAAPMENb/zBDU/AAABAAIAAAA="}] + }"#; + + #[test] + fn gltf_normals_stay_perpendicular_under_stretch() { + let (document, buffers, _) = gltf::import_slice(GLTF_NORMALS.as_bytes()).unwrap(); + let mesh = Mesh::from_gltf(&document, &buffers, &Default::default()).unwrap(); + assert_eq!(mesh.indices, [0, 1, 2]); + // Stretched, the triangle is (0,0,0), (1,0,0), (0,3,1), whose normal is + // (0,-1,3)/sqrt(10). Transforming the normal like a position would give + // (0,-3,1)/sqrt(10) instead. + let expected = Vector3::new(0.0, -1.0, 3.0) / 10f32.sqrt(); + for n in &mesh.normals { + assert_relative_eq!(Vector3::from(*n), expected, epsilon = 1e-6); + } + assert_eq!(mesh.positions[2], [0.0, 3.0, 1.0]); + } + + #[test] + fn load_reads_files_by_extension() { + let dir = std::env::temp_dir().join(format!("resolved-model-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let stl = dir.join("cube.STL"); + std::fs::write(&stl, cube_stl()).unwrap(); + let gltf = dir.join("pair.gltf"); + std::fs::write(&gltf, GLTF).unwrap(); + + let cube = Mesh::load(&stl, &Default::default()).unwrap(); + assert_eq!(cube.parts[0].name, "cube"); + assert_eq!(cube.triangle_count(), 12); + assert_eq!( + Mesh::load(&gltf, &Default::default()).unwrap(), + gltf_mesh(&Default::default()) + ); + assert!(Mesh::load(&dir.join("missing.stl"), &Default::default()).is_err()); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn load_rejects_unknown_extension() { + assert!(Mesh::load(Path::new("model.obj"), &Default::default()).is_err()); + } +} diff --git a/docs/optics.md b/docs/optics.md index 6c949f2..a9a4c18 100644 --- a/docs/optics.md +++ b/docs/optics.md @@ -368,6 +368,36 @@ geometry section. The target spacecraft, drawn after the bodies, uses the same depth buffer. So a target in front of Earth covers it, and a target behind Earth's limb is hidden. +## The target model and its materials + +Code: `model.rs`. + +The target spacecraft is a triangle mesh loaded from STL or glTF. Each +piece (an STL file, or a glTF primitive) is a part with one material, and +for now a material is a Lambertian albedo spectrum, as for Earth and the +Moon. + +Surface normals decide how bright each triangle is, through the cos(i) +factor. STL files store a normal per triangle, but exporters often write +zeros or wrong values, so the loader ignores them. It recomputes each +normal from the order of the triangle's corners: counterclockwise as seen +from outside. Every triangle is then lit as a flat facet, which matches the +flat panels and boxes most spacecraft are built from. glTF files that store +normals keep them. + +glTF describes a material's color as red, green and blue reflectances (its +"base color"). Three numbers do not define a spectrum: very different +spectra can look the same color to the eye. The loader turns them into a +stepped spectrum, blue below 490 nm, green from 500 to 580 nm, and red above +590 nm. That is a reasonable guess for painted surfaces seen by an RGB +camera. It is a poor one for a camera whose channels differ from the eye's, +or for anything in the near infrared, where the red value simply continues. +For absolute radiometry of a real spacecraft, give the important materials +measured reflectance spectra (M4 adds those, and BRDFs). + +STL has no units. CAD tools usually export millimeters, so loading a CAD +model usually needs `scale: 0.001`. glTF is always meters. + ## Not modeled yet | Effect | Where |