diff --git a/Cargo.lock b/Cargo.lock index 5c3a289f1e7c..ad3c177c2115 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4481,9 +4481,12 @@ dependencies = [ name = "re_data_source" version = "0.12.0-alpha.1+dev" dependencies = [ + "ahash 0.8.6", "anyhow", "image", "itertools 0.11.0", + "once_cell", + "parking_lot 0.12.1", "rayon", "re_build_tools", "re_log", @@ -4494,6 +4497,7 @@ dependencies = [ "re_types", "re_ws_comms", "thiserror", + "walkdir", ] [[package]] diff --git a/crates/re_data_source/Cargo.toml b/crates/re_data_source/Cargo.toml index 98cea1d68252..8c1c82725e06 100644 --- a/crates/re_data_source/Cargo.toml +++ b/crates/re_data_source/Cargo.toml @@ -29,12 +29,15 @@ re_tracing.workspace = true re_types = { workspace = true, features = ["image"] } re_ws_comms = { workspace = true, features = ["client"] } +ahash.workspace = true anyhow.workspace = true image.workspace = true itertools.workspace = true +once_cell.workspace = true +parking_lot.workspace = true rayon.workspace = true thiserror.workspace = true - +walkdir.workspace = true [build-dependencies] re_build_tools.workspace = true diff --git a/crates/re_data_source/src/data_loader/loader_archetype.rs b/crates/re_data_source/src/data_loader/loader_archetype.rs new file mode 100644 index 000000000000..ebf0f645663e --- /dev/null +++ b/crates/re_data_source/src/data_loader/loader_archetype.rs @@ -0,0 +1,155 @@ +use re_log_types::{DataRow, EntityPath, RowId, TimePoint}; + +use crate::{DataLoader, DataLoaderError, LoadedData}; + +// --- + +/// Loads data from any supported file or in-memory contents as native [`re_types::Archetype`]s. +/// +/// This is a simple generic [`DataLoader`] for filetypes that match 1-to-1 with our builtin +/// archetypes. +pub struct ArchetypeLoader; + +impl DataLoader for ArchetypeLoader { + #[inline] + fn name(&self) -> String { + "rerun.data_loaders.Archetype".into() + } + + #[cfg(not(target_arch = "wasm32"))] + fn load_from_path( + &self, + store_id: re_log_types::StoreId, + filepath: std::path::PathBuf, + tx: std::sync::mpsc::Sender, + ) -> Result<(), crate::DataLoaderError> { + use anyhow::Context as _; + + if filepath.is_dir() { + return Ok(()); // simply not interested + } + + re_tracing::profile_function!(filepath.display().to_string()); + + let contents = std::fs::read(&filepath) + .with_context(|| format!("Failed to read file {filepath:?}"))?; + let contents = std::borrow::Cow::Owned(contents); + + self.load_from_file_contents(store_id, filepath, contents, tx) + } + + fn load_from_file_contents( + &self, + _store_id: re_log_types::StoreId, + filepath: std::path::PathBuf, + contents: std::borrow::Cow<'_, [u8]>, + tx: std::sync::mpsc::Sender, + ) -> Result<(), crate::DataLoaderError> { + re_tracing::profile_function!(filepath.display().to_string()); + + let entity_path = EntityPath::from_file_path(&filepath); + + let mut timepoint = TimePoint::timeless(); + // TODO(cmc): log these once heuristics (I think?) are fixed + if false { + if let Ok(metadata) = filepath.metadata() { + use re_log_types::{Time, Timeline}; + + if let Some(created) = metadata.created().ok().and_then(|t| Time::try_from(t).ok()) + { + timepoint.insert(Timeline::new_temporal("created_at"), created.into()); + } + if let Some(modified) = metadata + .modified() + .ok() + .and_then(|t| Time::try_from(t).ok()) + { + timepoint.insert(Timeline::new_temporal("modified_at"), modified.into()); + } + if let Some(accessed) = metadata + .accessed() + .ok() + .and_then(|t| Time::try_from(t).ok()) + { + timepoint.insert(Timeline::new_temporal("accessed_at"), accessed.into()); + } + } + } + + let extension = crate::extension(&filepath); + + let mut rows = Vec::new(); + + if crate::SUPPORTED_MESH_EXTENSIONS.contains(&extension.as_str()) { + re_log::debug!(?filepath, loader = self.name(), "Loading 3D model…",); + rows.extend(load_mesh( + filepath, + timepoint, + entity_path, + contents.into_owned(), + )?); + } else if crate::SUPPORTED_IMAGE_EXTENSIONS.contains(&extension.as_str()) { + re_log::debug!(?filepath, loader = self.name(), "Loading image…",); + rows.extend(load_image( + &filepath, + timepoint, + entity_path, + contents.into_owned(), + )?); + }; + + for row in rows { + if tx.send(row.into()).is_err() { + break; // The other end has decided to hang up, not our problem. + } + } + + Ok(()) + } +} + +// --- + +fn load_mesh( + filepath: std::path::PathBuf, + timepoint: TimePoint, + entity_path: EntityPath, + contents: Vec, +) -> Result, DataLoaderError> { + re_tracing::profile_function!(); + + let rows = [ + { + let arch = re_types::archetypes::Asset3D::from_file_contents( + contents, + re_types::components::MediaType::guess_from_path(filepath), + ); + DataRow::from_archetype(RowId::new(), timepoint, entity_path, &arch)? + }, + // + ]; + + Ok(rows.into_iter()) +} + +fn load_image( + filepath: &std::path::Path, + timepoint: TimePoint, + entity_path: EntityPath, + contents: Vec, +) -> Result, DataLoaderError> { + re_tracing::profile_function!(); + + let rows = [ + { + let arch = re_types::archetypes::Image::from_file_contents( + contents, + image::ImageFormat::from_path(filepath).ok(), + )?; + DataRow::from_archetype(RowId::new(), timepoint, entity_path, &arch)? + }, + // + ]; + + Ok(rows.into_iter()) +} diff --git a/crates/re_data_source/src/data_loader/loader_rrd.rs b/crates/re_data_source/src/data_loader/loader_rrd.rs new file mode 100644 index 000000000000..f04b487638a5 --- /dev/null +++ b/crates/re_data_source/src/data_loader/loader_rrd.rs @@ -0,0 +1,98 @@ +use re_log_encoding::decoder::Decoder; + +// --- + +/// Loads data from any `rrd` file or in-memory contents. +pub struct RrdLoader; + +impl crate::DataLoader for RrdLoader { + #[inline] + fn name(&self) -> String { + "rerun.data_loaders.Rrd".into() + } + + #[cfg(not(target_arch = "wasm32"))] + fn load_from_path( + &self, + // NOTE: The Store ID comes from the rrd file itself. + _store_id: re_log_types::StoreId, + filepath: std::path::PathBuf, + tx: std::sync::mpsc::Sender, + ) -> Result<(), crate::DataLoaderError> { + use anyhow::Context as _; + + re_tracing::profile_function!(filepath.display().to_string()); + + let extension = crate::extension(&filepath); + if extension != "rrd" { + return Ok(()); // simply not interested + } + + re_log::debug!( + ?filepath, + loader = self.name(), + "Loading rrd data from filesystem…", + ); + + let version_policy = re_log_encoding::decoder::VersionPolicy::Warn; + let file = std::fs::File::open(&filepath) + .with_context(|| format!("Failed to open file {filepath:?}"))?; + let file = std::io::BufReader::new(file); + + let decoder = re_log_encoding::decoder::Decoder::new(version_policy, file)?; + decode_and_stream(&filepath, &tx, decoder); + + Ok(()) + } + + fn load_from_file_contents( + &self, + // NOTE: The Store ID comes from the rrd file itself. + _store_id: re_log_types::StoreId, + filepath: std::path::PathBuf, + contents: std::borrow::Cow<'_, [u8]>, + tx: std::sync::mpsc::Sender, + ) -> Result<(), crate::DataLoaderError> { + re_tracing::profile_function!(filepath.display().to_string()); + + let extension = crate::extension(&filepath); + if extension != "rrd" { + return Ok(()); // simply not interested + } + + let version_policy = re_log_encoding::decoder::VersionPolicy::Warn; + let contents = std::io::Cursor::new(contents); + let decoder = match re_log_encoding::decoder::Decoder::new(version_policy, contents) { + Ok(decoder) => decoder, + Err(err) => match err { + // simply not interested + re_log_encoding::decoder::DecodeError::NotAnRrd + | re_log_encoding::decoder::DecodeError::Options(_) => return Ok(()), + _ => return Err(err.into()), + }, + }; + decode_and_stream(&filepath, &tx, decoder); + Ok(()) + } +} + +fn decode_and_stream( + filepath: &std::path::Path, + tx: &std::sync::mpsc::Sender, + decoder: Decoder, +) { + re_tracing::profile_function!(filepath.display().to_string()); + + for msg in decoder { + let msg = match msg { + Ok(msg) => msg, + Err(err) => { + re_log::warn_once!("Failed to decode message in {filepath:?}: {err}"); + continue; + } + }; + if tx.send(msg.into()).is_err() { + break; // The other end has decided to hang up, not our problem. + } + } +} diff --git a/crates/re_data_source/src/data_loader/mod.rs b/crates/re_data_source/src/data_loader/mod.rs new file mode 100644 index 000000000000..f0a4ef31c95f --- /dev/null +++ b/crates/re_data_source/src/data_loader/mod.rs @@ -0,0 +1,221 @@ +use std::sync::Arc; + +use once_cell::sync::Lazy; + +use re_log_types::{ArrowMsg, DataRow, LogMsg}; + +// --- + +/// A [`DataLoader`] loads data from a file path and/or a file's contents. +/// +/// Files can be loaded in 3 different ways: +/// - via the Rerun CLI (`rerun myfile.jpeg`), +/// - using drag-and-drop, +/// - using the open dialog in the Rerun Viewer. +/// +/// All these file loading methods support loading a single file, many files at once, or even +/// folders. +/// ⚠ Drag-and-drop of folders does not yet work on the web version of Rerun Viewer ⚠ +/// +/// We only support loading files from the local filesystem at the moment, and consequently only +/// accept filepaths as input. +/// [There are plans to make this generic over any URI](https://github.com/rerun-io/rerun/issues/4525). +/// +/// Rerun comes with a few [`DataLoader`]s by default: +/// - [`RrdLoader`] for [Rerun files], +/// - [`ArchetypeLoader`] for: +/// - [3D models] +/// - [Images] +/// +/// ## Execution +/// +/// **All** registered [`DataLoader`]s get called when a user tries to open a file, unconditionally. +/// This gives [`DataLoader`]s maximum flexibility to decide what files they are interested in, as +/// opposed to e.g. only being able to look at files' extensions. +/// +/// On native, [`DataLoader`]s are executed in parallel. +/// +/// [Rerun extensions]: crate::SUPPORTED_RERUN_EXTENSIONS +/// [3D models]: crate::SUPPORTED_MESH_EXTENSIONS +/// [Images]: crate::SUPPORTED_IMAGE_EXTENSIONS +// +// TODO(#4525): `DataLoader`s should support arbitrary URIs +// TODO(#4526): `DataLoader`s should be exposed to the SDKs +// TODO(#4527): Web Viewer `?url` parameter should accept anything our `DataLoader`s support +pub trait DataLoader: Send + Sync { + /// Name of the [`DataLoader`]. + /// + /// Doesn't need to be unique. + fn name(&self) -> String; + + /// Loads data from a file on the local filesystem and sends it to `tx`. + /// + /// This is generally called when opening files with the Rerun CLI or via the open menu in the + /// Rerun Viewer on native platforms. + /// + /// The passed-in `store_id` is a shared recording created by the file loading machinery: + /// implementers can decide to use it or not (e.g. it might make sense to log all images with a + /// similar name in a shared recording, while an rrd file is already its own recording). + /// + /// `path` isn't necessarily a _file_ path, but can be a directory as well: implementers are + /// free to handle that however they decide. + /// + /// ## Error handling + /// + /// Most implementers of `load_from_path` are expected to be asynchronous in nature. + /// + /// Asynchronous implementers should make sure to fail early (and thus synchronously) when + /// possible (e.g. didn't even manage to open the file). + /// Otherwise, they should log errors that happen in an asynchronous context. + /// + /// If a [`DataLoader`] has no interest in the given file, it should successfully return + /// without pushing any data into `tx`. + #[cfg(not(target_arch = "wasm32"))] + fn load_from_path( + &self, + store_id: re_log_types::StoreId, + path: std::path::PathBuf, + tx: std::sync::mpsc::Sender, + ) -> Result<(), DataLoaderError>; + + /// Loads data from in-memory file contents and sends it to `tx`. + /// + /// This is generally called when opening files via drag-and-drop or when using the web viewer. + /// + /// The passed-in `store_id` is a shared recording created by the file loading machinery: + /// implementers can decide to use it or not (e.g. it might make sense to log all images with a + /// similar name in a shared recording, while an rrd file is already its own recording). + /// + /// The `path` of the file is given for informational purposes (e.g. to extract the file's + /// extension): implementers should _not_ try to read from disk as there is likely isn't a + /// filesystem available to begin with. + /// `path` is guaranteed to be a file path. + /// + /// When running on the web (wasm), `filepath` only contains the file name. + /// + /// ## Error handling + /// + /// Most implementers of `load_from_file_contents` are expected to be asynchronous in nature. + /// + /// Asynchronous implementers should make sure to fail early (and thus synchronously) when + /// possible (e.g. didn't even manage to open the file). + /// Otherwise, they should log errors that happen in an asynchronous context. + /// + /// If a [`DataLoader`] has no interest in the given file, it should successfully return + /// without pushing any data into `tx`. + fn load_from_file_contents( + &self, + store_id: re_log_types::StoreId, + filepath: std::path::PathBuf, + contents: std::borrow::Cow<'_, [u8]>, + tx: std::sync::mpsc::Sender, + ) -> Result<(), DataLoaderError>; +} + +/// Errors that might happen when loading data through a [`DataLoader`]. +#[derive(thiserror::Error, Debug)] +pub enum DataLoaderError { + #[cfg(not(target_arch = "wasm32"))] + #[error(transparent)] + IO(#[from] std::io::Error), + + #[error(transparent)] + Arrow(#[from] re_log_types::DataCellError), + + #[error(transparent)] + Decode(#[from] re_log_encoding::decoder::DecodeError), + + #[error(transparent)] + Other(#[from] anyhow::Error), +} + +impl DataLoaderError { + #[inline] + pub fn is_path_not_found(&self) -> bool { + match self { + #[cfg(not(target_arch = "wasm32"))] + DataLoaderError::IO(err) => err.kind() == std::io::ErrorKind::NotFound, + _ => false, + } + } +} + +/// What [`DataLoader`]s load. +/// +/// This makes it trivial for [`DataLoader`]s to build the data in whatever form is +/// most convenient for them, whether it is raw components, arrow chunks or even +/// full-on [`LogMsg`]s. +pub enum LoadedData { + DataRow(DataRow), + ArrowMsg(ArrowMsg), + LogMsg(LogMsg), +} + +impl From for LoadedData { + #[inline] + fn from(value: DataRow) -> Self { + Self::DataRow(value) + } +} + +impl From for LoadedData { + #[inline] + fn from(value: ArrowMsg) -> Self { + LoadedData::ArrowMsg(value) + } +} + +impl From for LoadedData { + #[inline] + fn from(value: LogMsg) -> Self { + LoadedData::LogMsg(value) + } +} + +impl LoadedData { + /// Pack the data into a [`LogMsg`]. + pub fn into_log_msg( + self, + store_id: &re_log_types::StoreId, + ) -> Result { + match self { + Self::DataRow(row) => { + let mut table = + re_log_types::DataTable::from_rows(re_log_types::TableId::new(), [row]); + table.compute_all_size_bytes(); + + Ok(LogMsg::ArrowMsg(store_id.clone(), table.to_arrow_msg()?)) + } + + Self::ArrowMsg(msg) => Ok(LogMsg::ArrowMsg(store_id.clone(), msg)), + + Self::LogMsg(msg) => Ok(msg), + } + } +} + +// --- + +/// Keeps track of all builtin [`DataLoader`]s. +/// +/// Lazy initialized the first time a file is opened. +static BUILTIN_LOADERS: Lazy>> = Lazy::new(|| { + vec![ + Arc::new(RrdLoader) as Arc, + Arc::new(ArchetypeLoader), + ] +}); + +/// Iterator over all registered [`DataLoader`]s. +#[inline] +pub fn iter_loaders() -> impl ExactSizeIterator> { + BUILTIN_LOADERS.clone().into_iter() +} + +// --- + +mod loader_archetype; +mod loader_rrd; + +pub use self::loader_archetype::ArchetypeLoader; +pub use self::loader_rrd::RrdLoader; diff --git a/crates/re_data_source/src/data_source.rs b/crates/re_data_source/src/data_source.rs index a3e2039627fe..e46928a8ba68 100644 --- a/crates/re_data_source/src/data_source.rs +++ b/crates/re_data_source/src/data_source.rs @@ -1,12 +1,13 @@ -use anyhow::Context as _; - use re_log_types::LogMsg; use re_smart_channel::{Receiver, SmartChannelSource, SmartMessageSource}; use crate::FileContents; +#[cfg(not(target_arch = "wasm32"))] +use anyhow::Context as _; + /// Somewhere we can get Rerun data from. -#[derive(Clone)] +#[derive(Debug, Clone)] pub enum DataSource { /// A remote RRD file, served over http. RrdHttpUrl(String), @@ -17,7 +18,7 @@ pub enum DataSource { /// The contents of a file. /// - /// This is what you get when loading a file on Web. + /// This is what you get when loading a file on Web, or when using drag-n-drop. FileContents(re_log_types::FileSource, FileContents), /// A remote Rerun server. @@ -63,7 +64,7 @@ impl DataSource { true // No dots. Weird. Let's assume it is a file path. } else if parts.len() == 2 { // Extension or `.com` etc? - is_known_file_extension(parts[1]) + crate::is_supported_file_extension(parts[1]) } else { false // Too many dots; assume an url } @@ -129,32 +130,45 @@ impl DataSource { SmartMessageSource::File(path.clone()), SmartChannelSource::File(path.clone()), ); + + // This `StoreId` will be communicated to all `DataLoader`s, which may or may not + // decide to use it depending on whether they want to share a common recording + // or not. let store_id = re_log_types::StoreId::random(re_log_types::StoreKind::Recording); - crate::load_file_path::load_file_path(store_id, file_source, path.clone(), tx) + crate::load_from_path(&store_id, file_source, &path, &tx) .with_context(|| format!("{path:?}"))?; + if let Some(on_msg) = on_msg { on_msg(); } + Ok(rx) } + // When loading a file on Web, or when using drag-n-drop. DataSource::FileContents(file_source, file_contents) => { let name = file_contents.name.clone(); let (tx, rx) = re_smart_channel::smart_channel( SmartMessageSource::File(name.clone().into()), SmartChannelSource::File(name.clone().into()), ); + + // This `StoreId` will be communicated to all `DataLoader`s, which may or may not + // decide to use it depending on whether they want to share a common recording + // or not. let store_id = re_log_types::StoreId::random(re_log_types::StoreKind::Recording); - crate::load_file_contents::load_file_contents( - store_id, + crate::load_from_file_contents( + &store_id, file_source, - file_contents, - tx, - ) - .with_context(|| format!("{name:?}"))?; + &std::path::PathBuf::from(file_contents.name), + std::borrow::Cow::Borrowed(&file_contents.bytes), + &tx, + )?; + if let Some(on_msg) = on_msg { on_msg(); } + Ok(rx) } @@ -181,13 +195,6 @@ impl DataSource { } } -#[cfg(not(target_arch = "wasm32"))] -fn is_known_file_extension(extension: &str) -> bool { - extension == "rrd" - || crate::SUPPORTED_MESH_EXTENSIONS.contains(&extension) - || crate::SUPPORTED_IMAGE_EXTENSIONS.contains(&extension) -} - #[cfg(not(target_arch = "wasm32"))] #[test] fn test_data_source_from_uri() { diff --git a/crates/re_data_source/src/lib.rs b/crates/re_data_source/src/lib.rs index ceb6498ff7be..8d169c2be269 100644 --- a/crates/re_data_source/src/lib.rs +++ b/crates/re_data_source/src/lib.rs @@ -10,33 +10,59 @@ //! - images //! - meshes +mod data_loader; mod data_source; - mod load_file; -mod load_file_contents; mod web_sockets; #[cfg(not(target_arch = "wasm32"))] mod load_stdin; +pub use self::data_loader::{ + iter_loaders, ArchetypeLoader, DataLoader, DataLoaderError, LoadedData, RrdLoader, +}; +pub use self::data_source::DataSource; +pub use self::load_file::{extension, load_from_file_contents}; +pub use self::web_sockets::connect_to_ws_url; + #[cfg(not(target_arch = "wasm32"))] -mod load_file_path; +pub use self::load_file::load_from_path; -pub use data_source::DataSource; -pub use web_sockets::connect_to_ws_url; +// --- -/// The contents of as file -#[derive(Clone)] +/// The contents of a file. +/// +/// This is what you get when loading a file on Web, or when using drag-n-drop. +// +// TODO(#4554): drag-n-drop streaming support +#[derive(Clone, Debug)] pub struct FileContents { pub name: String, - pub bytes: std::sync::Arc<[u8]>, } -pub const SUPPORTED_MESH_EXTENSIONS: &[&str] = &["glb", "gltf", "obj"]; - // …given that all feature flags are turned on for the `image` crate. pub const SUPPORTED_IMAGE_EXTENSIONS: &[&str] = &[ "avif", "bmp", "dds", "exr", "farbfeld", "ff", "gif", "hdr", "ico", "jpeg", "jpg", "pam", "pbm", "pgm", "png", "ppm", "tga", "tif", "tiff", "webp", ]; + +pub const SUPPORTED_MESH_EXTENSIONS: &[&str] = &["glb", "gltf", "obj"]; + +pub const SUPPORTED_RERUN_EXTENSIONS: &[&str] = &["rrd"]; + +/// All file extension supported by our builtin [`DataLoader`]s. +pub fn supported_extensions() -> impl Iterator { + SUPPORTED_RERUN_EXTENSIONS + .iter() + .chain(SUPPORTED_IMAGE_EXTENSIONS) + .chain(SUPPORTED_MESH_EXTENSIONS) + .copied() +} + +/// Is this a supported file extension by any of our builtin [`DataLoader`]s? +pub fn is_supported_file_extension(extension: &str) -> bool { + SUPPORTED_IMAGE_EXTENSIONS.contains(&extension) + || SUPPORTED_MESH_EXTENSIONS.contains(&extension) + || SUPPORTED_RERUN_EXTENSIONS.contains(&extension) +} diff --git a/crates/re_data_source/src/load_file.rs b/crates/re_data_source/src/load_file.rs index f36fa23145f9..beb0ab4bd635 100644 --- a/crates/re_data_source/src/load_file.rs +++ b/crates/re_data_source/src/load_file.rs @@ -1,134 +1,269 @@ -use image::{guess_format, ImageFormat}; -use re_log_types::{DataCell, DataCellError}; +use std::borrow::Cow; +use std::sync::Arc; -/// Errors from [`data_cells_from_file_path`]. -#[derive(thiserror::Error, Debug)] -pub enum FromFileError { - #[cfg(not(target_arch = "wasm32"))] - #[error(transparent)] - FileRead(#[from] std::io::Error), - - #[error(transparent)] - DataCellError(#[from] DataCellError), +use re_log_types::{FileSource, LogMsg}; +use re_smart_channel::Sender; - #[error(transparent)] - TensorImageLoad(#[from] re_types::tensor_data::TensorImageLoadError), +use crate::{DataLoaderError, LoadedData}; - #[error(transparent)] - Other(#[from] anyhow::Error), -} +// --- -/// Read the file at the given path. +/// Loads the given `path` using all [`crate::DataLoader`]s available. /// -/// Supported file extensions are: -/// * `glb`, `gltf`, `obj`: encoded meshes, leaving it to the viewer to decode -/// * `jpg`, `jpeg`: encoded JPEG, leaving it to the viewer to decode. Requires the `image` feature. -/// * `png` and other image formats: decoded here. Requires the `image` feature. +/// A single `path` might be handled by more than one loader. /// -/// All other extensions will return an error. +/// Synchronously checks whether the file exists and can be loaded. Beyond that, all +/// errors are asynchronous and handled directly by the [`crate::DataLoader`]s themselves +/// (i.e. they're logged). #[cfg(not(target_arch = "wasm32"))] -pub fn data_cells_from_file_path( - file_path: &std::path::Path, -) -> Result, FromFileError> { - let extension = file_path - .extension() - .unwrap_or_default() - .to_ascii_lowercase() - .to_string_lossy() - .to_string(); - - match extension.as_str() { - "glb" | "gltf" | "obj" => { - use re_types::{archetypes::Asset3D, AsComponents as _}; - let cells: Result, _> = Asset3D::from_file(file_path)? - // TODO(#3414): this should be a method of `Archetype` - .as_component_batches() - .into_iter() - .map(|comp_batch| { - Ok(DataCell::from_arrow( - comp_batch.name(), - comp_batch - .to_arrow() - .map_err(|err| anyhow::anyhow!("serialization failed: {err}"))?, - )) - }) - .collect(); - cells - } +pub fn load_from_path( + store_id: &re_log_types::StoreId, + file_source: FileSource, + path: &std::path::Path, + // NOTE: This channel must be unbounded since we serialize all operations when running on wasm. + tx: &Sender, +) -> Result<(), DataLoaderError> { + re_tracing::profile_function!(path.to_string_lossy()); - // Assume an image (there are so many image formats) - _ => { - // Assume an image (there are so many image extensions): - let tensor = re_types::components::TensorData( - re_types::datatypes::TensorData::from_image_file(file_path)?, - ); - Ok(vec![ - image_indicator_cell(), - DataCell::try_from_native(std::iter::once(&tensor))?, - ]) + if !path.exists() { + return Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "path does not exist: {path:?}", + ) + .into()); + } + + re_log::info!("Loading {path:?}…"); + + let store_info = prepare_store_info(store_id, file_source, path, path.is_dir()); + if let Some(store_info) = store_info { + if tx.send(store_info).is_err() { + return Ok(()); // other end has hung up. } } + + let data = load(store_id, path, path.is_dir(), None)?; + send(store_id, data, tx); + + Ok(()) } -fn image_indicator_cell() -> DataCell { - use re_types::Archetype as _; - let indicator = re_types::archetypes::Image::indicator(); - DataCell::from_arrow( - indicator.name(), - indicator - .to_arrow() - .expect("Serializing an indicator component should always work"), - ) +/// Loads the given `contents` using all [`crate::DataLoader`]s available. +/// +/// A single file might be handled by more than one loader. +/// +/// Synchronously checks that the file can be loaded. Beyond that, all errors are asynchronous +/// and handled directly by the [`crate::DataLoader`]s themselves (i.e. they're logged). +/// +/// `path` is only used for informational purposes, no data is ever read from the filesystem. +pub fn load_from_file_contents( + store_id: &re_log_types::StoreId, + file_source: FileSource, + filepath: &std::path::Path, + contents: std::borrow::Cow<'_, [u8]>, + // NOTE: This channel must be unbounded since we serialize all operations when running on wasm. + tx: &Sender, +) -> Result<(), DataLoaderError> { + re_tracing::profile_function!(filepath.to_string_lossy()); + + re_log::info!("Loading {filepath:?}…"); + + let store_info = prepare_store_info(store_id, file_source, filepath, false); + if let Some(store_info) = store_info { + if tx.send(store_info).is_err() { + return Ok(()); // other end has hung up. + } + } + + let data = load(store_id, filepath, false, Some(contents))?; + send(store_id, data, tx); + + Ok(()) } -pub fn data_cells_from_file_contents( - file_name: &str, - bytes: Vec, -) -> Result, FromFileError> { - re_tracing::profile_function!(file_name); +// --- - let extension = std::path::Path::new(file_name) - .extension() +/// Empty string if no extension. +#[inline] +pub fn extension(path: &std::path::Path) -> String { + path.extension() .unwrap_or_default() .to_ascii_lowercase() .to_string_lossy() - .to_string(); - - match extension.as_str() { - "glb" | "gltf" | "obj" => { - use re_types::{archetypes::Asset3D, components::MediaType, AsComponents as _}; - let cells: Result, _> = - Asset3D::from_bytes(bytes, MediaType::guess_from_path(file_name)) - .as_component_batches() - .into_iter() - .map(|comp_batch| { - Ok(DataCell::from_arrow( - comp_batch.name(), - comp_batch - .to_arrow() - .map_err(|err| anyhow::anyhow!("serialization failed: {err}"))?, - )) - }) - .collect(); - cells + .to_string() +} + +/// Returns whether the given path is supported by builtin [`crate::DataLoader`]s. +/// +/// This does _not_ access the filesystem. +#[inline] +pub fn is_associated_with_builtin_loader(path: &std::path::Path, is_dir: bool) -> bool { + !is_dir && crate::is_supported_file_extension(&extension(path)) +} + +/// Prepares an adequate [`re_log_types::StoreInfo`] [`LogMsg`] given the input. +fn prepare_store_info( + store_id: &re_log_types::StoreId, + file_source: FileSource, + path: &std::path::Path, + is_dir: bool, +) -> Option { + re_tracing::profile_function!(path.display().to_string()); + + use re_log_types::SetStoreInfo; + + let app_id = re_log_types::ApplicationId(path.display().to_string()); + let store_source = re_log_types::StoreSource::File { file_source }; + + let is_builtin = is_associated_with_builtin_loader(path, is_dir); + let is_rrd = crate::SUPPORTED_RERUN_EXTENSIONS.contains(&extension(path).as_str()); + + (!is_rrd && is_builtin).then(|| { + LogMsg::SetStoreInfo(SetStoreInfo { + row_id: re_log_types::RowId::new(), + info: re_log_types::StoreInfo { + application_id: app_id.clone(), + store_id: store_id.clone(), + is_official_example: false, + started: re_log_types::Time::now(), + store_source, + store_kind: re_log_types::StoreKind::Recording, + }, + }) + }) +} + +/// Loads the data at `path` using all available [`crate::DataLoader`]s. +/// +/// Returns a channel with all the [`LoadedData`]: +/// - On native, this is filled asynchronously from other threads. +/// - On wasm, this is pre-filled synchronously. +#[cfg_attr(target_arch = "wasm32", allow(clippy::needless_pass_by_value))] +fn load( + store_id: &re_log_types::StoreId, + path: &std::path::Path, + is_dir: bool, + contents: Option>, +) -> Result, DataLoaderError> { + let extension = extension(path); + let is_builtin = is_associated_with_builtin_loader(path, is_dir); + + if !is_builtin { + return if extension.is_empty() { + Err(anyhow::anyhow!("files without extensions (file.XXX) are not supported").into()) + } else { + Err(anyhow::anyhow!(".{extension} files are not supported").into()) + }; + } + + // On native we run loaders in parallel so this needs to become static. + #[cfg(not(target_arch = "wasm32"))] + let contents: Option>> = + contents.map(|contents| Arc::new(Cow::Owned(contents.into_owned()))); + + let rx_loader = { + let (tx_loader, rx_loader) = std::sync::mpsc::channel(); + + for loader in crate::iter_loaders() { + let loader = Arc::clone(&loader); + let store_id = store_id.clone(); + let tx_loader = tx_loader.clone(); + let path = path.to_owned(); + + #[cfg(not(target_arch = "wasm32"))] + spawn({ + let contents = contents.clone(); // arc + move || { + if let Some(contents) = contents.as_deref() { + let contents = Cow::Borrowed(contents.as_ref()); + + if let Err(err) = loader.load_from_file_contents( + store_id, + path.clone(), + contents, + tx_loader, + ) { + re_log::error!(?path, loader = loader.name(), %err, "Failed to load data from file"); + } + } else if let Err(err) = + loader.load_from_path(store_id, path.clone(), tx_loader) + { + re_log::error!(?path, loader = loader.name(), %err, "Failed to load data from file"); + } + } + }); + + #[cfg(target_arch = "wasm32")] + spawn(|| { + if let Some(contents) = contents.as_deref() { + let contents = Cow::Borrowed(contents); + + if let Err(err) = + loader.load_from_file_contents(store_id, path.clone(), contents, tx_loader) + { + re_log::error!(?path, loader = loader.name(), %err, "Failed to load data from file"); + } + } + }); } - // Assume an image (there are so many image formats) - _ => { - let format = if let Some(format) = ImageFormat::from_extension(extension) { - format - } else { - guess_format(&bytes).map_err(re_types::tensor_data::TensorImageLoadError::from)? - }; - - // Assume an image (there are so many image extensions): - let tensor = re_types::components::TensorData( - re_types::datatypes::TensorData::from_image_bytes(bytes, format)?, - ); - Ok(vec![ - image_indicator_cell(), - DataCell::try_from_native(std::iter::once(&tensor))?, - ]) + // Implicitly closing `tx_loader`! + + rx_loader + }; + + Ok(rx_loader) +} + +/// Forwards the data in `rx_loader` to `tx`, taking care of necessary conversions, if any. +/// +/// Runs asynchronously from another thread on native, synchronously on wasm. +fn send( + store_id: &re_log_types::StoreId, + rx_loader: std::sync::mpsc::Receiver, + tx: &Sender, +) { + spawn({ + let tx = tx.clone(); + let store_id = store_id.clone(); + move || { + // ## Ignoring channel errors + // + // Not our problem whether or not the other end has hung up, but we still want to + // poll the channel in any case so as to make sure that the data producer + // doesn't get stuck. + for data in rx_loader { + let msg = match data.into_log_msg(&store_id) { + Ok(msg) => msg, + Err(err) => { + re_log::error!(%err, %store_id, "Couldn't serialize component data"); + continue; + } + }; + tx.send(msg).ok(); + } + + tx.quit(None).ok(); } - } + }); +} + +// NOTE: +// - On native, we parallelize using `rayon`. +// - On wasm, we serialize everything, which works because the data-loading channels are unbounded. + +#[cfg(not(target_arch = "wasm32"))] +fn spawn(f: F) +where + F: FnOnce() + Send + 'static, +{ + rayon::spawn(f); +} + +#[cfg(target_arch = "wasm32")] +fn spawn(f: F) +where + F: FnOnce(), +{ + f(); } diff --git a/crates/re_data_source/src/load_file_contents.rs b/crates/re_data_source/src/load_file_contents.rs deleted file mode 100644 index 06dd8f8302d6..000000000000 --- a/crates/re_data_source/src/load_file_contents.rs +++ /dev/null @@ -1,114 +0,0 @@ -use re_log_encoding::decoder::VersionPolicy; -use re_log_types::{FileSource, LogMsg}; -use re_smart_channel::Sender; - -use crate::{load_file::data_cells_from_file_contents, FileContents}; - -#[allow(clippy::needless_pass_by_value)] // false positive on some feature flags -pub fn load_file_contents( - store_id: re_log_types::StoreId, - file_source: FileSource, - file_contents: FileContents, - tx: Sender, -) -> anyhow::Result<()> { - let file_name = file_contents.name.clone(); - re_tracing::profile_function!(file_name.as_str()); - re_log::info!("Loading {file_name:?}…"); - - if file_name.ends_with(".rrd") { - if cfg!(target_arch = "wasm32") { - load_rrd_sync(&file_contents, &tx) - } else { - // Load in background thread on native: - rayon::spawn(move || { - if let Err(err) = load_rrd_sync(&file_contents, &tx) { - re_log::error!("Failed to load {file_name:?}: {err}"); - } - }); - Ok(()) - } - } else { - // non-rrd = image or mesh: - if cfg!(target_arch = "wasm32") { - load_and_send(store_id, file_source, file_contents, &tx) - } else { - rayon::spawn(move || { - let name = file_contents.name.clone(); - if let Err(err) = load_and_send(store_id, file_source, file_contents, &tx) { - re_log::error!("Failed to load {name:?}: {err}"); - } - }); - Ok(()) - } - } -} - -fn load_and_send( - store_id: re_log_types::StoreId, - file_source: FileSource, - file_contents: FileContents, - tx: &Sender, -) -> anyhow::Result<()> { - use re_log_types::SetStoreInfo; - - re_tracing::profile_function!(file_contents.name.as_str()); - - // First, set a store info since this is the first thing the application expects. - tx.send(LogMsg::SetStoreInfo(SetStoreInfo { - row_id: re_log_types::RowId::new(), - info: re_log_types::StoreInfo { - application_id: re_log_types::ApplicationId(file_contents.name.clone()), - store_id: store_id.clone(), - is_official_example: false, - started: re_log_types::Time::now(), - store_source: re_log_types::StoreSource::File { file_source }, - store_kind: re_log_types::StoreKind::Recording, - }, - })) - .ok(); - // .ok(): we may be running in a background thread, so who knows if the receiver is still open - - // Send actual file. - let log_msg = log_msg_from_file_contents(store_id, file_contents)?; - tx.send(log_msg).ok(); - tx.quit(None).ok(); - Ok(()) -} - -fn log_msg_from_file_contents( - store_id: re_log_types::StoreId, - file_contents: FileContents, -) -> anyhow::Result { - let FileContents { name, bytes } = file_contents; - - let entity_path = re_log_types::EntityPath::from_single_string(name.clone()); - let cells = data_cells_from_file_contents(&name, bytes.to_vec())?; - - let num_instances = cells.first().map_or(0, |cell| cell.num_instances()); - - let timepoint = re_log_types::TimePoint::default(); - - let data_row = re_log_types::DataRow::from_cells( - re_log_types::RowId::new(), - timepoint, - entity_path, - num_instances, - cells, - )?; - - let data_table = re_log_types::DataTable::from_rows(re_log_types::TableId::new(), [data_row]); - let arrow_msg = data_table.to_arrow_msg()?; - Ok(LogMsg::ArrowMsg(store_id, arrow_msg)) -} - -fn load_rrd_sync(file_contents: &FileContents, tx: &Sender) -> anyhow::Result<()> { - re_tracing::profile_function!(file_contents.name.as_str()); - - let bytes: &[u8] = &file_contents.bytes; - let decoder = re_log_encoding::decoder::Decoder::new(VersionPolicy::Warn, bytes)?; - for msg in decoder { - tx.send(msg?)?; - } - re_log::debug!("Finished loading {:?}.", file_contents.name); - Ok(()) -} diff --git a/crates/re_data_source/src/load_file_path.rs b/crates/re_data_source/src/load_file_path.rs deleted file mode 100644 index 00999447b40c..000000000000 --- a/crates/re_data_source/src/load_file_path.rs +++ /dev/null @@ -1,125 +0,0 @@ -use anyhow::Context as _; - -use re_log_types::{FileSource, LogMsg}; -use re_smart_channel::Sender; - -use crate::load_file::data_cells_from_file_path; - -/// Non-blocking. -#[allow(clippy::needless_pass_by_value)] // false positive on some feature flags -pub fn load_file_path( - store_id: re_log_types::StoreId, - file_source: FileSource, - path: std::path::PathBuf, - tx: Sender, -) -> anyhow::Result<()> { - re_tracing::profile_function!(path.to_string_lossy()); - re_log::info!("Loading {path:?}…"); - - if !path.exists() { - anyhow::bail!("Failed to find file {path:?}."); - } - - let extension = path - .extension() - .unwrap_or_default() - .to_ascii_lowercase() - .to_string_lossy() - .to_string(); - - if extension == "rrd" { - stream_rrd_file(path, tx) - } else { - rayon::spawn(move || { - if let Err(err) = load_and_send(store_id, file_source, &path, &tx) { - re_log::error!("Failed to load {path:?}: {err}"); - } - }); - Ok(()) - } -} - -fn load_and_send( - store_id: re_log_types::StoreId, - file_source: FileSource, - path: &std::path::Path, - tx: &Sender, -) -> anyhow::Result<()> { - re_tracing::profile_function!(path.display().to_string()); - - use re_log_types::SetStoreInfo; - - let store_source = re_log_types::StoreSource::File { file_source }; - - // First, set a store info since this is the first thing the application expects. - tx.send(LogMsg::SetStoreInfo(SetStoreInfo { - row_id: re_log_types::RowId::new(), - info: re_log_types::StoreInfo { - application_id: re_log_types::ApplicationId(path.display().to_string()), - store_id: store_id.clone(), - is_official_example: false, - started: re_log_types::Time::now(), - store_source, - store_kind: re_log_types::StoreKind::Recording, - }, - })) - .ok(); - // .ok(): we may be running in a background thread, so who knows if the receiver is still open - - // Send actual file. - let log_msg = log_msg_from_file_path(store_id, path)?; - tx.send(log_msg).ok(); - tx.quit(None).ok(); - Ok(()) -} - -fn log_msg_from_file_path( - store_id: re_log_types::StoreId, - file_path: &std::path::Path, -) -> anyhow::Result { - let entity_path = re_log_types::EntityPath::from_file_path_as_single_string(file_path); - let cells = data_cells_from_file_path(file_path)?; - - let num_instances = cells.first().map_or(0, |cell| cell.num_instances()); - - let timepoint = re_log_types::TimePoint::default(); - - let data_row = re_log_types::DataRow::from_cells( - re_log_types::RowId::new(), - timepoint, - entity_path, - num_instances, - cells, - )?; - - let data_table = re_log_types::DataTable::from_rows(re_log_types::TableId::new(), [data_row]); - let arrow_msg = data_table.to_arrow_msg()?; - Ok(LogMsg::ArrowMsg(store_id, arrow_msg)) -} - -// Non-blocking -fn stream_rrd_file( - path: std::path::PathBuf, - tx: re_smart_channel::Sender, -) -> anyhow::Result<()> { - let version_policy = re_log_encoding::decoder::VersionPolicy::Warn; - let file = std::fs::File::open(&path).context("Failed to open file")?; - let decoder = re_log_encoding::decoder::Decoder::new(version_policy, file)?; - - rayon::spawn(move || { - re_tracing::profile_scope!("stream_rrd_file"); - for msg in decoder { - match msg { - Ok(msg) => { - tx.send(msg).ok(); // .ok(): we're running in a background thread, so who knows if the receiver is still open - } - Err(err) => { - re_log::warn_once!("Failed to decode message in {path:?}: {err}"); - } - } - } - tx.quit(None).ok(); // .ok(): we're running in a background thread, so who knows if the receiver is still open - }); - - Ok(()) -} diff --git a/crates/re_types/src/archetypes/asset3d_ext.rs b/crates/re_types/src/archetypes/asset3d_ext.rs index 065d26ba57d5..98c55c058ec7 100644 --- a/crates/re_types/src/archetypes/asset3d_ext.rs +++ b/crates/re_types/src/archetypes/asset3d_ext.rs @@ -12,27 +12,30 @@ impl Asset3D { /// from the data at render-time. If it can't, rendering will fail with an error. #[cfg(not(target_arch = "wasm32"))] #[inline] - pub fn from_file(path: impl AsRef) -> anyhow::Result { + pub fn from_file(filepath: impl AsRef) -> anyhow::Result { use anyhow::Context as _; - let path = path.as_ref(); - let data = std::fs::read(path) - .with_context(|| format!("could not read file contents: {path:?}"))?; - Ok(Self::from_bytes(data, MediaType::guess_from_path(path))) + let filepath = filepath.as_ref(); + let contents = std::fs::read(filepath) + .with_context(|| format!("could not read file contents: {filepath:?}"))?; + Ok(Self::from_file_contents( + contents, + MediaType::guess_from_path(filepath), + )) } - /// Creates a new [`Asset3D`] from the given `bytes`. + /// Creates a new [`Asset3D`] from the given `contents`. /// /// The [`MediaType`] will be guessed from magic bytes in the data. /// /// If no [`MediaType`] can be guessed at the moment, the Rerun Viewer will try to guess one /// from the data at render-time. If it can't, rendering will fail with an error. #[inline] - pub fn from_bytes(bytes: impl AsRef<[u8]>, media_type: Option>) -> Self { - let bytes = bytes.as_ref(); + pub fn from_file_contents(contents: Vec, media_type: Option>) -> Self { let media_type = media_type.map(Into::into); + let media_type = MediaType::or_guess_from_data(media_type, &contents); Self { - blob: bytes.to_vec().into(), - media_type: MediaType::or_guess_from_data(media_type, bytes), + blob: contents.into(), + media_type, transform: None, } } diff --git a/crates/re_types/src/archetypes/image_ext.rs b/crates/re_types/src/archetypes/image_ext.rs index 3813191a2182..2ce43189dc5d 100644 --- a/crates/re_types/src/archetypes/image_ext.rs +++ b/crates/re_types/src/archetypes/image_ext.rs @@ -1,3 +1,5 @@ +use image::ImageFormat; + use crate::{ datatypes::TensorData, image::{find_non_empty_dim_indices, ImageConstructionError}, @@ -43,6 +45,39 @@ impl Image { draw_order: None, }) } + + /// Creates a new [`Image`] from a file. + /// + /// The image format will be inferred from the path (extension), or the contents if that fails. + #[cfg(not(target_arch = "wasm32"))] + #[inline] + pub fn from_file_path(filepath: impl AsRef) -> anyhow::Result { + let filepath = filepath.as_ref(); + Ok(Self::new(crate::datatypes::TensorData::from_image_file( + filepath, + )?)) + } + + /// Creates a new [`Image`] from the contents of a file. + /// + /// If unspecified, the image format will be inferred from the contents. + #[inline] + pub fn from_file_contents( + contents: Vec, + format: Option, + ) -> anyhow::Result { + let format = if let Some(format) = format { + format + } else { + image::guess_format(&contents)? + }; + + let tensor = crate::components::TensorData(crate::datatypes::TensorData::from_image_bytes( + contents, format, + )?); + + Ok(Self::new(tensor)) + } } fn assign_if_none(name: &mut Option<::re_types_core::ArrowString>, new_name: &str) { @@ -80,5 +115,3 @@ forward_array_views!(i64, Image); forward_array_views!(half::f16, Image); forward_array_views!(f32, Image); forward_array_views!(f64, Image); - -// ---------------------------------------------------------------------------- diff --git a/crates/re_types/tests/asset3d.rs b/crates/re_types/tests/asset3d.rs index bd1aae2ed9ef..b01b524dbc79 100644 --- a/crates/re_types/tests/asset3d.rs +++ b/crates/re_types/tests/asset3d.rs @@ -31,7 +31,7 @@ fn roundtrip() { )), // }; - let arch = Asset3D::from_bytes(BYTES, Some(MediaType::gltf())).with_transform( + let arch = Asset3D::from_file_contents(BYTES.to_vec(), Some(MediaType::gltf())).with_transform( re_types::datatypes::Transform3D::from_translation_rotation_scale( [1.0, 2.0, 3.0], RotationAxisAngle::new([0.2, 0.2, 0.8], Angle::Radians(0.5 * TAU)), diff --git a/crates/re_viewer/src/app.rs b/crates/re_viewer/src/app.rs index b8a9854a5af3..4e408ca2f1c5 100644 --- a/crates/re_viewer/src/app.rs +++ b/crates/re_viewer/src/app.rs @@ -1297,20 +1297,19 @@ fn file_saver_progress_ui(egui_ctx: &egui::Context, background_tasks: &mut Backg #[cfg(not(target_arch = "wasm32"))] fn open_file_dialog_native() -> Vec { re_tracing::profile_function!(); + let supported: Vec<_> = re_data_source::supported_extensions().collect(); rfd::FileDialog::new() - .add_filter("Rerun data file", &["rrd"]) - .add_filter("Meshes", re_data_source::SUPPORTED_MESH_EXTENSIONS) - .add_filter("Images", re_data_source::SUPPORTED_IMAGE_EXTENSIONS) + .add_filter("Supported files", &supported) .pick_files() .unwrap_or_default() } #[cfg(target_arch = "wasm32")] async fn async_open_rrd_dialog() -> Vec { + let supported: Vec<_> = re_data_source::supported_extensions().collect(); + let files = rfd::AsyncFileDialog::new() - .add_filter("Rerun data file", &["rrd"]) - .add_filter("Meshes", re_data_source::SUPPORTED_MESH_EXTENSIONS) - .add_filter("Images", re_data_source::SUPPORTED_IMAGE_EXTENSIONS) + .add_filter("Supported files", &supported) .pick_files() .await .unwrap_or_default(); diff --git a/examples/assets/.gitignore b/examples/assets/.gitignore new file mode 100644 index 000000000000..5f2fb0a1311f --- /dev/null +++ b/examples/assets/.gitignore @@ -0,0 +1 @@ +!** diff --git a/examples/assets/example.glb b/examples/assets/example.glb new file mode 100644 index 000000000000..7c410ccc8a31 Binary files /dev/null and b/examples/assets/example.glb differ diff --git a/examples/assets/example.gltf b/examples/assets/example.gltf new file mode 100644 index 000000000000..202bce7903f2 --- /dev/null +++ b/examples/assets/example.gltf @@ -0,0 +1,121 @@ +{ + "asset": { + "generator": "Khronos glTF Blender I/O v3.6.6", + "version": "2.0" + }, + "scene": 0, + "scenes": [ + { + "name": "Scene", + "nodes": [ + 0 + ] + } + ], + "nodes": [ + { + "mesh": 0, + "name": "Cube" + } + ], + "materials": [ + { + "doubleSided": true, + "name": "Material", + "pbrMetallicRoughness": { + "baseColorFactor": [ + 0.800000011920929, + 0.800000011920929, + 0.800000011920929, + 1 + ], + "metallicFactor": 0, + "roughnessFactor": 0.5 + } + } + ], + "meshes": [ + { + "name": "Cube", + "primitives": [ + { + "attributes": { + "POSITION": 0, + "TEXCOORD_0": 1, + "NORMAL": 2 + }, + "indices": 3, + "material": 0 + } + ] + } + ], + "accessors": [ + { + "bufferView": 0, + "componentType": 5126, + "count": 24, + "max": [ + 1, + 1, + 1 + ], + "min": [ + -1, + -1, + -1 + ], + "type": "VEC3" + }, + { + "bufferView": 1, + "componentType": 5126, + "count": 24, + "type": "VEC2" + }, + { + "bufferView": 2, + "componentType": 5126, + "count": 24, + "type": "VEC3" + }, + { + "bufferView": 3, + "componentType": 5123, + "count": 36, + "type": "SCALAR" + } + ], + "bufferViews": [ + { + "buffer": 0, + "byteLength": 288, + "byteOffset": 0, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 192, + "byteOffset": 288, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 288, + "byteOffset": 480, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 72, + "byteOffset": 768, + "target": 34963 + } + ], + "buffers": [ + { + "byteLength": 840, + "uri": "data:application/octet-stream;base64,AACAPwAAgD8AAIC/AACAPwAAgD8AAIC/AACAPwAAgD8AAIC/AACAPwAAgL8AAIC/AACAPwAAgL8AAIC/AACAPwAAgL8AAIC/AACAPwAAgD8AAIA/AACAPwAAgD8AAIA/AACAPwAAgD8AAIA/AACAPwAAgL8AAIA/AACAPwAAgL8AAIA/AACAPwAAgL8AAIA/AACAvwAAgD8AAIC/AACAvwAAgD8AAIC/AACAvwAAgD8AAIC/AACAvwAAgL8AAIC/AACAvwAAgL8AAIC/AACAvwAAgL8AAIC/AACAvwAAgD8AAIA/AACAvwAAgD8AAIA/AACAvwAAgD8AAIA/AACAvwAAgL8AAIA/AACAvwAAgL8AAIA/AACAvwAAgL8AAIA/AAAgPwAAAD8AACA/AAAAPwAAID8AAAA/AADAPgAAAD8AAMA+AAAAPwAAwD4AAAA/AAAgPwAAgD4AACA/AACAPgAAID8AAIA+AADAPgAAgD4AAMA+AACAPgAAwD4AAIA+AAAgPwAAQD8AACA/AABAPwAAYD8AAAA/AAAAPgAAAD8AAMA+AABAPwAAwD4AAEA/AAAgPwAAAAAAACA/AACAPwAAYD8AAIA+AAAAPgAAgD4AAMA+AAAAAAAAwD4AAIA/AAAAAAAAAAAAAIC/AAAAAAAAgD8AAACAAACAPwAAAAAAAACAAAAAAAAAgL8AAACAAAAAAAAAAAAAAIC/AACAPwAAAAAAAACAAAAAAAAAAAAAAIA/AAAAAAAAgD8AAACAAACAPwAAAAAAAACAAAAAAAAAgL8AAACAAAAAAAAAAAAAAIA/AACAPwAAAAAAAACAAACAvwAAAAAAAACAAAAAAAAAAAAAAIC/AAAAAAAAgD8AAACAAAAAAAAAgL8AAACAAACAvwAAAAAAAACAAAAAAAAAAAAAAIC/AAAAAAAAAAAAAIA/AACAvwAAAAAAAACAAAAAAAAAgD8AAACAAAAAAAAAgL8AAACAAAAAAAAAAAAAAIA/AACAvwAAAAAAAACAAQAOABQAAQAUAAcACgAGABIACgASABYAFwATAAwAFwAMABAADwADAAkADwAJABUABQACAAgABQAIAAsAEQANAAAAEQAAAAQA" + } + ] +} diff --git a/examples/assets/example.jpg b/examples/assets/example.jpg new file mode 100644 index 000000000000..aa6db0690360 Binary files /dev/null and b/examples/assets/example.jpg differ diff --git a/examples/assets/example.mtl b/examples/assets/example.mtl new file mode 100644 index 000000000000..ab51d30badaa --- /dev/null +++ b/examples/assets/example.mtl @@ -0,0 +1,12 @@ +# Blender 3.6.5 MTL File: 'None' +# www.blender.org + +newmtl Material +Ns 250.000000 +Ka 1.000000 1.000000 1.000000 +Kd 0.800000 0.800000 0.800000 +Ks 0.500000 0.500000 0.500000 +Ke 0.000000 0.000000 0.000000 +Ni 1.450000 +d 1.000000 +illum 2 diff --git a/examples/assets/example.obj b/examples/assets/example.obj new file mode 100644 index 000000000000..017d9c121485 --- /dev/null +++ b/examples/assets/example.obj @@ -0,0 +1,40 @@ +# Blender 3.6.5 +# www.blender.org +mtllib example.mtl +o Cube +v 1.000000 1.000000 -1.000000 +v 1.000000 -1.000000 -1.000000 +v 1.000000 1.000000 1.000000 +v 1.000000 -1.000000 1.000000 +v -1.000000 1.000000 -1.000000 +v -1.000000 -1.000000 -1.000000 +v -1.000000 1.000000 1.000000 +v -1.000000 -1.000000 1.000000 +vn -0.0000 1.0000 -0.0000 +vn -0.0000 -0.0000 1.0000 +vn -1.0000 -0.0000 -0.0000 +vn -0.0000 -1.0000 -0.0000 +vn 1.0000 -0.0000 -0.0000 +vn -0.0000 -0.0000 -1.0000 +vt 0.625000 0.500000 +vt 0.875000 0.500000 +vt 0.875000 0.750000 +vt 0.625000 0.750000 +vt 0.375000 0.750000 +vt 0.625000 1.000000 +vt 0.375000 1.000000 +vt 0.375000 0.000000 +vt 0.625000 0.000000 +vt 0.625000 0.250000 +vt 0.375000 0.250000 +vt 0.125000 0.500000 +vt 0.375000 0.500000 +vt 0.125000 0.750000 +s 0 +usemtl Material +f 1/1/1 5/2/1 7/3/1 3/4/1 +f 4/5/2 3/4/2 7/6/2 8/7/2 +f 8/8/3 7/9/3 5/10/3 6/11/3 +f 6/12/4 2/13/4 4/5/4 8/14/4 +f 2/13/5 1/1/5 3/4/5 4/5/5 +f 6/11/6 5/10/6 1/1/6 2/13/6 diff --git a/examples/assets/example.png b/examples/assets/example.png new file mode 100644 index 000000000000..91705b8adb5c Binary files /dev/null and b/examples/assets/example.png differ diff --git a/examples/assets/example.rrd b/examples/assets/example.rrd new file mode 100644 index 000000000000..4bb525ac9294 Binary files /dev/null and b/examples/assets/example.rrd differ