diff --git a/Cargo.lock b/Cargo.lock index d4ef40f40d24..f3190a296606 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4577,6 +4577,7 @@ dependencies = [ "smallvec", "static_assertions", "thiserror", + "tinystl", "tobj", "type-map", "unindent", @@ -6285,6 +6286,15 @@ dependencies = [ "time-core", ] +[[package]] +name = "tinystl" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdbcdda2f86a57b89b5d9ac17cd4c9f3917ec8edcde403badf3d992d2947af2a" +dependencies = [ + "bytemuck", +] + [[package]] name = "tinytemplate" version = "1.2.1" diff --git a/Cargo.toml b/Cargo.toml index 816b4876974a..f853fed58e83 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -212,6 +212,7 @@ thiserror = "1.0" time = { version = "0.3", default-features = false, features = [ "wasm-bindgen", ] } +tinystl = { version = "0.0.3", default-features = false } tinyvec = { version = "1.6", features = ["alloc", "rustc_1_55"] } tobj = "4.0" tokio = { version = "1.24", default-features = false } diff --git a/crates/re_data_source/src/lib.rs b/crates/re_data_source/src/lib.rs index c18d50a1dfae..a268c48570df 100644 --- a/crates/re_data_source/src/lib.rs +++ b/crates/re_data_source/src/lib.rs @@ -50,7 +50,7 @@ pub const SUPPORTED_IMAGE_EXTENSIONS: &[&str] = &[ "pbm", "pgm", "png", "ppm", "tga", "tif", "tiff", "webp", ]; -pub const SUPPORTED_MESH_EXTENSIONS: &[&str] = &["glb", "gltf", "obj"]; +pub const SUPPORTED_MESH_EXTENSIONS: &[&str] = &["glb", "gltf", "obj", "stl"]; // TODO(#4532): `.ply` data loader should support 2D point cloud & meshes pub const SUPPORTED_POINT_CLOUD_EXTENSIONS: &[&str] = &["ply"]; diff --git a/crates/re_renderer/Cargo.toml b/crates/re_renderer/Cargo.toml index 3129ce1a26a2..052822bf4c7a 100644 --- a/crates/re_renderer/Cargo.toml +++ b/crates/re_renderer/Cargo.toml @@ -24,7 +24,7 @@ targets = ["x86_64-unknown-linux-gnu", "wasm32-unknown-unknown"] [features] -default = ["import-obj", "import-gltf"] +default = ["import-obj", "import-gltf", "import-stl"] ## Support for Arrow datatypes for end-to-end zero-copy. arrow = ["dep:arrow2"] @@ -35,6 +35,9 @@ import-obj = ["dep:tobj"] ## Support importing .gltf and .glb files import-gltf = ["dep:gltf"] +## Support importing binary & ascii .stl files +import-stl = ["dep:tinystl"] + ## Enable (de)serialization using serde. serde = ["dep:serde"] @@ -73,6 +76,7 @@ wgpu-core.workspace = true # Needed fo # optional arrow2 = { workspace = true, optional = true } gltf = { workspace = true, optional = true } +tinystl = { workspace = true, features = ["bytemuck"], optional = true } serde = { workspace = true, features = ["derive"], optional = true } tobj = { workspace = true, optional = true } diff --git a/crates/re_renderer/src/importer/mod.rs b/crates/re_renderer/src/importer/mod.rs index 2ecb21df9334..99ab50d15a7a 100644 --- a/crates/re_renderer/src/importer/mod.rs +++ b/crates/re_renderer/src/importer/mod.rs @@ -4,6 +4,9 @@ pub mod obj; #[cfg(feature = "import-gltf")] pub mod gltf; +#[cfg(feature = "import-stl")] +pub mod stl; + use macaw::Vec3Ext as _; use crate::renderer::MeshInstance; diff --git a/crates/re_renderer/src/importer/stl.rs b/crates/re_renderer/src/importer/stl.rs new file mode 100644 index 000000000000..09213345d84c --- /dev/null +++ b/crates/re_renderer/src/importer/stl.rs @@ -0,0 +1,80 @@ +use itertools::Itertools; +use smallvec::smallvec; +use tinystl::StlData; + +use crate::{mesh, renderer::MeshInstance, resource_managers::ResourceLifeTime, RenderContext}; + +#[derive(thiserror::Error, Debug)] +pub enum StlImportError { + #[error("Error loading STL mesh: {0}")] + TinyStl(tinystl::Error), + + #[error(transparent)] + MeshError(#[from] mesh::MeshError), + + #[error(transparent)] + ResourceManagerError(#[from] crate::resource_managers::ResourceManagerError), +} + +/// Load a [STL .stl file](https://en.wikipedia.org/wiki/STL_(file_format)) into the mesh manager. +pub fn load_stl_from_buffer( + buffer: &[u8], + ctx: &RenderContext, +) -> Result, StlImportError> { + re_tracing::profile_function!(); + + let cursor = std::io::Cursor::new(buffer); + let StlData { + name, + triangles, + normals, + .. + } = StlData::read_buffer(std::io::BufReader::new(cursor)).map_err(StlImportError::TinyStl)?; + + let num_vertices = triangles.len() * 3; + + let material = mesh::Material { + label: "default material".into(), + index_range: 0..num_vertices as u32, + albedo: ctx.texture_manager_2d.white_texture_unorm_handle().clone(), + albedo_multiplier: crate::Rgba::WHITE, + }; + + let mesh = mesh::Mesh { + label: name.into(), + triangle_indices: (0..num_vertices as u32) + .tuples::<(_, _, _)>() + .map(glam::UVec3::from) + .collect::>(), + vertex_positions: bytemuck::cast_slice(&triangles).to_vec(), + + // Normals on STL are per triangle, not per vertex. + // Yes, this makes STL always look faceted. + vertex_normals: normals + .into_iter() + .flat_map(|n| { + let n = glam::Vec3::from_array(n); + [n, n, n] + }) + .collect(), + + // STL has neither colors nor texcoords. + vertex_colors: vec![crate::Rgba32Unmul::WHITE; num_vertices], + vertex_texcoords: vec![glam::Vec2::ZERO; num_vertices], + + materials: smallvec![material], + }; + + mesh.sanity_check()?; + + let gpu_mesh = ctx + .mesh_manager + .write() + .create(ctx, &mesh, ResourceLifeTime::LongLived)?; + + Ok(vec![MeshInstance { + gpu_mesh, + mesh: Some(std::sync::Arc::new(mesh)), + ..Default::default() + }]) +} diff --git a/crates/re_space_view_spatial/Cargo.toml b/crates/re_space_view_spatial/Cargo.toml index d244051bc0c8..395fb8fa7226 100644 --- a/crates/re_space_view_spatial/Cargo.toml +++ b/crates/re_space_view_spatial/Cargo.toml @@ -25,7 +25,11 @@ re_log_types.workspace = true re_log.workspace = true re_query.workspace = true re_query_cache.workspace = true -re_renderer = { workspace = true, features = ["import-gltf", "import-obj"] } +re_renderer = { workspace = true, features = [ + "import-gltf", + "import-obj", + "import-stl", +] } re_types = { workspace = true, features = ["ecolor", "glam", "image"] } re_tracing.workspace = true re_ui.workspace = true diff --git a/crates/re_space_view_spatial/src/mesh_loader.rs b/crates/re_space_view_spatial/src/mesh_loader.rs index 06c56c0bc42e..e4c8961f7292 100644 --- a/crates/re_space_view_spatial/src/mesh_loader.rs +++ b/crates/re_space_view_spatial/src/mesh_loader.rs @@ -53,6 +53,7 @@ impl LoadedMesh { ResourceLifeTime::LongLived, render_ctx, )?, + MediaType::STL => re_renderer::importer::stl::load_stl_from_buffer(bytes, render_ctx)?, _ => anyhow::bail!("{media_type} files are not supported"), }; diff --git a/crates/re_types/definitions/rerun/archetypes/asset3d.fbs b/crates/re_types/definitions/rerun/archetypes/asset3d.fbs index 812c756a9313..6fd1d6f339a4 100644 --- a/crates/re_types/definitions/rerun/archetypes/asset3d.fbs +++ b/crates/re_types/definitions/rerun/archetypes/asset3d.fbs @@ -7,7 +7,7 @@ namespace rerun.archetypes; // --- -/// A prepacked 3D asset (`.gltf`, `.glb`, `.obj`, etc.). +/// A prepacked 3D asset (`.gltf`, `.glb`, `.obj`, `.stl`, etc.). /// /// \py See also [`Mesh3D`][rerun.archetypes.Mesh3D]. /// \rs See also [`Mesh3D`][crate::archetypes::Mesh3D]. @@ -28,7 +28,9 @@ table Asset3D ( /// /// Supported values: /// * `model/gltf-binary` + /// * `model/gltf+json` /// * `model/obj` (.mtl material files are not supported yet, references are silently ignored) + /// * `model/stl` /// /// If omitted, the viewer will try to guess from the data blob. /// If it cannot guess, it won't be able to render the asset. diff --git a/crates/re_types/src/archetypes/asset3d.rs b/crates/re_types/src/archetypes/asset3d.rs index b69fa510520e..d8627d3fec2d 100644 --- a/crates/re_types/src/archetypes/asset3d.rs +++ b/crates/re_types/src/archetypes/asset3d.rs @@ -21,7 +21,7 @@ use ::re_types_core::SerializationResult; use ::re_types_core::{ComponentBatch, MaybeOwnedComponentBatch}; use ::re_types_core::{DeserializationError, DeserializationResult}; -/// **Archetype**: A prepacked 3D asset (`.gltf`, `.glb`, `.obj`, etc.). +/// **Archetype**: A prepacked 3D asset (`.gltf`, `.glb`, `.obj`, `.stl`, etc.). /// /// See also [`Mesh3D`][crate::archetypes::Mesh3D]. /// @@ -34,7 +34,7 @@ use ::re_types_core::{DeserializationError, DeserializationResult}; /// fn main() -> anyhow::Result<()> { /// let args = std::env::args().collect::>(); /// let Some(path) = args.get(1) else { -/// anyhow::bail!("Usage: {} ", args[0]); +/// anyhow::bail!("Usage: {} ", args[0]); /// }; /// /// let rec = rerun::RecordingStreamBuilder::new("rerun_example_asset3d").spawn()?; @@ -63,7 +63,9 @@ pub struct Asset3D { /// /// Supported values: /// * `model/gltf-binary` + /// * `model/gltf+json` /// * `model/obj` (.mtl material files are not supported yet, references are silently ignored) + /// * `model/stl` /// /// If omitted, the viewer will try to guess from the data blob. /// If it cannot guess, it won't be able to render the asset. diff --git a/crates/re_types/src/components/media_type_ext.rs b/crates/re_types/src/components/media_type_ext.rs index 8fd3d94e00b3..d64d23b7ba8d 100644 --- a/crates/re_types/src/components/media_type_ext.rs +++ b/crates/re_types/src/components/media_type_ext.rs @@ -25,6 +25,12 @@ impl MediaType { /// /// pub const OBJ: &'static str = "model/obj"; + + /// [Stereolithography Model `stl`](https://en.wikipedia.org/wiki/STL_(file_format)): `model/stl`. + /// + /// Either binary or ASCII. + /// + pub const STL: &'static str = "model/stl"; } impl MediaType { @@ -57,6 +63,12 @@ impl MediaType { pub fn obj() -> Self { Self(Self::OBJ.into()) } + + /// `model/stl` + #[inline] + pub fn stl() -> Self { + Self(Self::STL.into()) + } } impl MediaType { @@ -71,15 +83,20 @@ impl MediaType { #[inline] pub fn guess_from_path(path: impl AsRef) -> Option { let path = path.as_ref(); - - // `mime_guess2` considers `.obj` to be a tgif… but really it's way more likely to be an obj. - if path + let extension = path .extension() - .and_then(|ext| ext.to_str().map(|s| s.to_lowercase())) - .as_deref() - == Some("obj") - { - return Some(Self::obj()); + .and_then(|ext| ext.to_str().map(|s| s.to_lowercase())); + + match extension.as_deref() { + // `mime_guess2` considers `.obj` to be a tgif… but really it's way more likely to be an obj. + Some("obj") => { + return Some(Self::obj()); + } + // `mime_guess2` considers `.stl` to be a `application/vnd.ms-pki.stl`. + Some("stl") => { + return Some(Self::stl()); + } + _ => {} } mime_guess2::from_path(path) @@ -95,6 +112,18 @@ impl MediaType { buf.len() >= 4 && buf[0] == b'g' && buf[1] == b'l' && buf[2] == b'T' && buf[3] == b'F' } + fn stl_matcher(buf: &[u8]) -> bool { + // ASCII STL + buf.len() >= 5 + && buf[0] == b's' + && buf[1] == b'o' + && buf[2] == b'l' + && buf[3] == b'i' + && buf[3] == b'd' + // Binary STL is hard to infer since it starts with an 80 byte header that is commonly ignored, see + // https://en.wikipedia.org/wiki/STL_(file_format)#Binary + } + // NOTE: // - gltf is simply json, so no magic byte // (also most gltf files contain file:// links, so not much point in sending that to @@ -103,6 +132,7 @@ impl MediaType { let mut inferer = infer::Infer::new(); inferer.add(Self::GLB, "", glb_matcher); + inferer.add(Self::STL, "", stl_matcher); inferer .get(data) diff --git a/docs/code-examples/all/asset3d_simple.cpp b/docs/code-examples/all/asset3d_simple.cpp index 1606aa9e6d5f..036aa254cab3 100644 --- a/docs/code-examples/all/asset3d_simple.cpp +++ b/docs/code-examples/all/asset3d_simple.cpp @@ -8,7 +8,7 @@ int main(int argc, char* argv[]) { if (argc < 2) { - std::cerr << "Usage: " << argv[0] << " " << std::endl; + std::cerr << "Usage: " << argv[0] << " " << std::endl; return 1; } diff --git a/docs/code-examples/all/asset3d_simple.py b/docs/code-examples/all/asset3d_simple.py index 8e694dec1c82..3a0ab487251e 100644 --- a/docs/code-examples/all/asset3d_simple.py +++ b/docs/code-examples/all/asset3d_simple.py @@ -4,7 +4,7 @@ import rerun as rr if len(sys.argv) < 2: - print(f"Usage: {sys.argv[0]} ") + print(f"Usage: {sys.argv[0]} ") sys.exit(1) rr.init("rerun_example_asset3d", spawn=True) diff --git a/docs/code-examples/all/asset3d_simple.rs b/docs/code-examples/all/asset3d_simple.rs index 5b999c1bfc82..63b05b2fe9cf 100644 --- a/docs/code-examples/all/asset3d_simple.rs +++ b/docs/code-examples/all/asset3d_simple.rs @@ -5,7 +5,7 @@ use rerun::external::anyhow; fn main() -> anyhow::Result<()> { let args = std::env::args().collect::>(); let Some(path) = args.get(1) else { - anyhow::bail!("Usage: {} ", args[0]); + anyhow::bail!("Usage: {} ", args[0]); }; let rec = rerun::RecordingStreamBuilder::new("rerun_example_asset3d").spawn()?; diff --git a/docs/content/reference/types/archetypes/asset3d.md b/docs/content/reference/types/archetypes/asset3d.md index 8b483cb4f80a..e133d0ee2279 100644 --- a/docs/content/reference/types/archetypes/asset3d.md +++ b/docs/content/reference/types/archetypes/asset3d.md @@ -2,7 +2,7 @@ title: "Asset3D" --- -A prepacked 3D asset (`.gltf`, `.glb`, `.obj`, etc.). +A prepacked 3D asset (`.gltf`, `.glb`, `.obj`, `.stl`, etc.). ## Components diff --git a/docs/cspell.json b/docs/cspell.json index 2d5116fe9d7a..7257bcdf80ca 100644 --- a/docs/cspell.json +++ b/docs/cspell.json @@ -324,6 +324,7 @@ "stacklevel", "startswith", "staticmethod", + "Stereolithography", "struct", "Struct", "structs", diff --git a/rerun_cpp/src/rerun/archetypes/asset3d.hpp b/rerun_cpp/src/rerun/archetypes/asset3d.hpp index ea8d886f86ac..d9cda837e762 100644 --- a/rerun_cpp/src/rerun/archetypes/asset3d.hpp +++ b/rerun_cpp/src/rerun/archetypes/asset3d.hpp @@ -19,7 +19,7 @@ #include namespace rerun::archetypes { - /// **Archetype**: A prepacked 3D asset (`.gltf`, `.glb`, `.obj`, etc.). + /// **Archetype**: A prepacked 3D asset (`.gltf`, `.glb`, `.obj`, `.stl`, etc.). /// /// ## Example /// @@ -35,7 +35,7 @@ namespace rerun::archetypes { /// /// int main(int argc, char* argv[]) { /// if (argc <2) { - /// std::cerr <<"Usage: " <" <" < static MediaType gltf() { return "model/gltf+json"; } - /// Binary `glTF`(https://en.wikipedia.org/wiki/GlTF): `model/gltf-binary`. + /// [Binary `glTF`](https://en.wikipedia.org/wiki/GlTF): `model/gltf-binary`. /// /// static MediaType glb() { return "model/gltf-binary"; } - /// [Wavefront .obj](https://en.wikipedia.org/wiki/Wavefront_.obj_file): `model/obj`. + /// [Wavefront `obj`](https://en.wikipedia.org/wiki/Wavefront_.obj_file): `model/obj`. /// /// static MediaType obj() { return "model/obj"; } + /// [Stereolithography Model `stl`](https://en.wikipedia.org/wiki/STL_(file_format)): `model/stl`. + /// + /// Either binary or ASCII. + /// + static MediaType stl() { + return "model/stl"; + } + public: MediaType() = default; diff --git a/rerun_cpp/src/rerun/components/media_type_ext.cpp b/rerun_cpp/src/rerun/components/media_type_ext.cpp index ada53b147f90..6e9d8579e89d 100644 --- a/rerun_cpp/src/rerun/components/media_type_ext.cpp +++ b/rerun_cpp/src/rerun/components/media_type_ext.cpp @@ -32,27 +32,35 @@ namespace rerun { return "text/markdown"; } - /// `glTF`(https://en.wikipedia.org/wiki/GlTF): `model/gltf+json`. + /// [`glTF`](https://en.wikipedia.org/wiki/GlTF): `model/gltf+json`. /// /// static MediaType gltf() { return "model/gltf+json"; } - /// Binary `glTF`(https://en.wikipedia.org/wiki/GlTF): `model/gltf-binary`. + /// [Binary `glTF`](https://en.wikipedia.org/wiki/GlTF): `model/gltf-binary`. /// /// static MediaType glb() { return "model/gltf-binary"; } - /// [Wavefront .obj](https://en.wikipedia.org/wiki/Wavefront_.obj_file): `model/obj`. + /// [Wavefront `obj`](https://en.wikipedia.org/wiki/Wavefront_.obj_file): `model/obj`. /// /// static MediaType obj() { return "model/obj"; } + /// [Stereolithography Model `stl`](https://en.wikipedia.org/wiki/STL_(file_format)): `model/stl`. + /// + /// Either binary or ASCII. + /// + static MediaType stl() { + return "model/stl"; + } + // } }; diff --git a/rerun_py/rerun_sdk/rerun/archetypes/asset3d.py b/rerun_py/rerun_sdk/rerun/archetypes/asset3d.py index 235a063f3b27..f6893e3e3007 100644 --- a/rerun_py/rerun_sdk/rerun/archetypes/asset3d.py +++ b/rerun_py/rerun_sdk/rerun/archetypes/asset3d.py @@ -17,7 +17,7 @@ @define(str=False, repr=False, init=False) class Asset3D(Asset3DExt, Archetype): """ - **Archetype**: A prepacked 3D asset (`.gltf`, `.glb`, `.obj`, etc.). + **Archetype**: A prepacked 3D asset (`.gltf`, `.glb`, `.obj`, `.stl`, etc.). See also [`Mesh3D`][rerun.archetypes.Mesh3D]. @@ -30,7 +30,7 @@ class Asset3D(Asset3DExt, Archetype): import rerun as rr if len(sys.argv) < 2: - print(f"Usage: {sys.argv[0]} ") + print(f"Usage: {sys.argv[0]} ") sys.exit(1) rr.init("rerun_example_asset3d", spawn=True) @@ -83,7 +83,9 @@ def _clear(cls) -> Asset3D: # # Supported values: # * `model/gltf-binary` + # * `model/gltf+json` # * `model/obj` (.mtl material files are not supported yet, references are silently ignored) + # * `model/stl` # # If omitted, the viewer will try to guess from the data blob. # If it cannot guess, it won't be able to render the asset. diff --git a/rerun_py/rerun_sdk/rerun/archetypes/asset3d_ext.py b/rerun_py/rerun_sdk/rerun/archetypes/asset3d_ext.py index 4ec1d07ac689..3d4522f61ca3 100644 --- a/rerun_py/rerun_sdk/rerun/archetypes/asset3d_ext.py +++ b/rerun_py/rerun_sdk/rerun/archetypes/asset3d_ext.py @@ -20,6 +20,8 @@ def guess_media_type(path: str | pathlib.Path) -> MediaType | None: return MediaType.GLTF elif ext == ".obj": return MediaType.OBJ + elif ext == ".stl": + return MediaType.STL else: return None @@ -53,7 +55,9 @@ def __init__( For instance: * `model/gltf-binary` + * `model/gltf+json` * `model/obj` + * `model/stl` If omitted, it will be guessed from the `path` (if any), or the viewer will try to guess from the contents (magic header). diff --git a/rerun_py/rerun_sdk/rerun/components/media_type_ext.py b/rerun_py/rerun_sdk/rerun/components/media_type_ext.py index 5679c8982c31..6b3a74d095dc 100644 --- a/rerun_py/rerun_sdk/rerun/components/media_type_ext.py +++ b/rerun_py/rerun_sdk/rerun/components/media_type_ext.py @@ -41,6 +41,14 @@ class MediaTypeExt: """ + STL: MediaType = None # type: ignore[assignment] + """ + [Stereolithography Model `stl`](https://en.wikipedia.org/wiki/STL_(file_format)): `model/stl`. + Either binary or ASCII. + + + """ + @staticmethod def deferred_patch_class(cls: Any) -> None: cls.TEXT = cls("text/plain") @@ -48,3 +56,4 @@ def deferred_patch_class(cls: Any) -> None: cls.GLB = cls("model/gltf-binary") cls.GLTF = cls("model/gltf+json") cls.OBJ = cls("model/obj") + cls.STL = cls("model/stl")