mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-12 08:52:32 +00:00
Add craft-mesh: STL, OBJ/MTL, PLY and glTF/GLB behind one neutral mesh model (#819)
* Add craft-mesh: neutral mesh model with STL, OBJ/MTL and PLY * craft-mesh: glTF 2.0 and GLB * craft-mesh: README, crate list entry, a test for the UV set bound
This commit is contained in:
Generated
+7
@@ -1054,6 +1054,13 @@ dependencies = [
|
||||
"objc2-app-kit 0.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-mesh"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-mime"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -36,6 +36,7 @@ craft-font-catalog = { path = "crates/font-catalog" }
|
||||
craft-font-manager = { path = "crates/font-manager" }
|
||||
craft-font-store = { path = "crates/font-store" }
|
||||
craft-gcode = { path = "crates/gcode" }
|
||||
craft-mesh = { path = "crates/mesh" }
|
||||
craft-heif = { path = "crates/heif" }
|
||||
craft-fonts-build = { path = "crates/fonts-build" }
|
||||
craft-i18n = { path = "crates/i18n" }
|
||||
|
||||
@@ -106,6 +106,10 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
|
||||
(app menu, Window, Help, system-key clashes), a platform backend trait, and key equivalents
|
||||
fed back to egui as the input egui-winit would have made. Commands and their enabled state
|
||||
stay in each app.
|
||||
- [`craft-mesh`](crates/mesh/README.md): 3D mesh file formats behind one neutral model (meshes
|
||||
of polygons with per-corner indices, materials, a node hierarchy): STL, OBJ with MTL, PLY and
|
||||
glTF 2.0 / GLB, one feature each. Readers are bounded by `Limits`, never open a file themselves
|
||||
and never panic. Carved from EffectCraft, SolidCraft, TextureCraft and MaterialCraft.
|
||||
- [`craft-mime`](crates/mime/README.md): Internet messages and MIME, read and written: RFC 5322
|
||||
header fields, MIME parts and multiparts, encoded-words, RFC 2231 parameters, base64 and
|
||||
quoted-printable, character sets, address lists, dates, mbox files and the `multipart/related`
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "craft-mesh"
|
||||
description = "3D mesh file formats behind one neutral model: STL, Wavefront OBJ/MTL, PLY and glTF 2.0 / GLB readers and writers, bounded on hostile input"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
default = ["stl", "obj", "ply", "gltf"]
|
||||
stl = []
|
||||
obj = []
|
||||
ply = []
|
||||
gltf = ["dep:serde_json"]
|
||||
|
||||
[dependencies]
|
||||
serde_json = { workspace = true, optional = true }
|
||||
@@ -0,0 +1,149 @@
|
||||
# craft-mesh
|
||||
|
||||
3D mesh file formats behind one neutral model. A reader turns bytes into a `Scene`; a writer turns
|
||||
a `Scene` into bytes. One Cargo feature per format, all on by default.
|
||||
|
||||
| Feature | Format | Read | Write | Dependency |
|
||||
|---|---|---|---|---|
|
||||
| `stl` | STL, binary and ASCII | yes | binary and ASCII | none |
|
||||
| `obj` | Wavefront OBJ with MTL | yes | OBJ and MTL | none |
|
||||
| `ply` | PLY, ASCII and both binary byte orders | yes | no (no app writes PLY) | none |
|
||||
| `gltf` | glTF 2.0 (`.gltf`) and GLB | yes | GLB | `serde_json` |
|
||||
|
||||
FBX, USD, Alembic, COLLADA and 3MF are not here yet.
|
||||
|
||||
## The neutral model
|
||||
|
||||
- `Scene`: `meshes`, `materials`, `images`, `nodes` with `roots`, `skins`, `animations`, `cameras`,
|
||||
`lights`, plus `external` (every other file the file names) and `warnings` (what was left out or
|
||||
repaired).
|
||||
- `Mesh`: a name, attribute pools (`positions` and `normals` as `f64`, `uv_sets`, per-position
|
||||
`colors`, `joints` and `weights`) and `parts`.
|
||||
- `Part`: the faces of a mesh that share a group name, a material and a smoothing group. A face is
|
||||
a polygon of three or more `Corner`s; each corner indexes a position and optionally a normal and
|
||||
a UV. Formats with separate indices (OBJ) are kept exactly, and polygons stay whole.
|
||||
`Part::triangles()` fans them; `Mesh::unified()` gives one vertex per distinct corner as a
|
||||
triangle list, which is what a renderer wants.
|
||||
- `Material`: metallic-roughness factors, alpha mode, double-sided, unlit, and five texture slots.
|
||||
A `TextureRef` names an `Image`, a UV set and wrap modes. An `Image` is a URI the caller
|
||||
resolves or the encoded bytes the file embedded; nothing here decodes images.
|
||||
- `Node`: a name, a column-major 4×4 `transform` (and the same as `Trs` when the file gave it in
|
||||
parts), optional mesh, skin, camera and light, and children. Every reader fills `nodes`: a
|
||||
format without a hierarchy gets one root node per mesh. `Scene::instances()` walks the hierarchy
|
||||
and returns every placed mesh with its world transform.
|
||||
|
||||
Conventions, whatever the file:
|
||||
|
||||
- Positions are in the file's own axes and units. Nothing is turned or scaled (STL and PLY files
|
||||
are usually Z-up, glTF is Y-up in metres).
|
||||
- UVs have their origin at the top left, v down (glTF). The OBJ and PLY readers and the OBJ writer
|
||||
flip v.
|
||||
- Material colours are linear. MTL and STL colours are display colours and are converted. Vertex
|
||||
colours are the stored values scaled to 0..1.
|
||||
- Missing normals stay missing. STL facets get the normal of their winding.
|
||||
|
||||
## Coverage
|
||||
|
||||
**STL.** Binary and ASCII, told apart by the exact binary size first, then by `solid` with a
|
||||
`facet` or `endsolid`. Binary headers that begin with `solid`, trailing bytes, a facet count left
|
||||
at 0, several ASCII solids, ASCII loops of more than three vertices, the Magics (`COLOR=` header)
|
||||
and VisCAM/SolidView per-facet colours (a material and a part per colour). ASCII coordinates keep
|
||||
`f64` precision. Facets with a non-finite coordinate are left out with a warning. Writers apply
|
||||
the node transforms and write one solid; colours are not written.
|
||||
|
||||
**OBJ and MTL.** `v` with optional `r g b`, `vt`, `vn`, `f` with all four reference forms and
|
||||
negative references, polygons of any size, `o` (one mesh each, holding only its own vertices in
|
||||
file order), `g`, `usemtl`, `s`, `mtllib`, `\` continuations, `#` comments. A vertex or face line
|
||||
that cannot be read is left out and counted in one warning; later vertices keep their numbers.
|
||||
MTL: `Kd`, `d`, `Tr`, `Ke`, `Ns`, `Pr`, `Pm`, `map_Kd`, `map_Ke`, `map_Bump`/`bump`/`norm`. The
|
||||
writer writes objects, vertex colours, the first UV set, normals, groups, materials, smoothing
|
||||
groups and whole polygons, and an MTL library with unique material names. Lines, points and
|
||||
free-form geometry are passed over.
|
||||
|
||||
**PLY.** ASCII, little- and big-endian binary; `\n`, `\r\n` and `\r` header lines; elements in
|
||||
any order; scalar and list properties of every type. From the `vertex` element: `x y z`,
|
||||
`nx ny nz`, `s t` / `u v` / `texture_u texture_v`, `red green blue alpha` (also `r g b a` and
|
||||
`diffuse_*`), integer colours scaled by their type. From the `face` element: `vertex_indices` or
|
||||
`vertex_index`, polygons kept whole. A file without faces is a point cloud (positions, no parts).
|
||||
`ply::parse_header` and `ply::Rows` are public for callers that need every property as a column,
|
||||
`edge` elements or thinning.
|
||||
|
||||
**glTF and GLB.** The default scene's node tree (matrix or TRS); `TRIANGLES`, `TRIANGLE_STRIP` and
|
||||
`TRIANGLE_FAN`, indexed or not; POSITION, NORMAL, every TEXCOORD_n (up to 8), COLOR_0, JOINTS_0,
|
||||
WEIGHTS_0 in any component type the specification or `KHR_mesh_quantization` allows; strided
|
||||
buffer views; accessors without a buffer view (zeros); materials with their texture slots, UV set
|
||||
and sampler wrap modes, `KHR_materials_emissive_strength`, `KHR_materials_unlit`; images embedded
|
||||
in a buffer view or a `data:` URI, or named; skins; translation, rotation and scale animations;
|
||||
cameras; `KHR_lights_punctual`. Primitives of a mesh that name the same accessors share their
|
||||
vertices. `write_glb` writes nodes, meshes (positions, normals, UV sets, colours), materials and
|
||||
images.
|
||||
|
||||
Not read: sparse accessors and required geometry extensions (Draco, meshopt) are
|
||||
`Error::Unsupported`; points, lines, morph targets, tangents and other extensions are passed over.
|
||||
Not written: skins, animations, cameras, lights, and `.gltf` with separate files.
|
||||
|
||||
## Hostile input
|
||||
|
||||
Every reader takes `Limits` and returns `Error::TooLarge` past one. A count is checked against the
|
||||
bytes that are there before anything is allocated from it. Nothing indexes with a value from the
|
||||
file unchecked. Each limit has a test, and every reader is run over all truncations and two
|
||||
mutations of every byte of good files.
|
||||
|
||||
| Limit | Default | Applies to |
|
||||
|---|---|---|
|
||||
| `max_vertices` | 20 000 000 | entries per attribute pool (OBJ: each of `v`, `vt`, `vn`, counting unused ones; glTF: vertices over all meshes) |
|
||||
| `max_triangles` | 20 000 000 | triangles after fanning polygons, strips and fans |
|
||||
| `max_face_corners` | 4096 | corners of one face; PLY list lengths |
|
||||
| `max_meshes` | 100 000 | OBJ objects, glTF meshes |
|
||||
| `max_parts` | 100 000 | groups, material runs, glTF primitives |
|
||||
| `max_materials` | 100 000 | materials; images; OBJ material libraries |
|
||||
| `max_nodes` | 100 000 | nodes, and node visits when the hierarchy is walked (ends cycles) |
|
||||
| `max_depth` | 64 | node nesting |
|
||||
| `max_data_bytes` | 1 GiB | buffers and material libraries taken from the resolver; embedded image bytes; skin and animation values; the binary chunk `write_glb` builds |
|
||||
| `max_extras` | 10 000 | skins, animations, cameras, lights, buffers (each); channels of one animation |
|
||||
|
||||
Fixed bounds: a PLY header of 1 MiB, 64 elements and 256 properties per element; 2^24 components
|
||||
for glTF accessors without a buffer view, over the whole file.
|
||||
|
||||
**External files.** No reader opens a file. A reader that needs another file to parse (an OBJ
|
||||
material library, a glTF buffer) asks the caller's `Resolver` for it by the name the file writes;
|
||||
the resolver may decline (`None`). Images are never asked for: they are named in `Image::uri`.
|
||||
Every name is listed in `Scene::external`. Names come from the file, so a resolver must not trust
|
||||
them as paths: they may be absolute or contain `..`.
|
||||
|
||||
## What stays in the apps
|
||||
|
||||
Mapping a `Scene` onto the app's mesh, body or document types; computing normals, tangents and
|
||||
UVs; welding; unit scaling and up-axis turns; flattening the hierarchy into world space (use
|
||||
`Scene::instances()`); decoding images; choosing where external files come from; sampling
|
||||
animations and skinning; file-size limits before reading.
|
||||
|
||||
## Origin
|
||||
|
||||
All code is from Craft apps (MIT OR Apache-2.0), written from the public format descriptions. No
|
||||
sample files are shipped: every test generates its input.
|
||||
|
||||
| Module | Base | Ported in |
|
||||
|---|---|---|
|
||||
| `stl` reader | EffectCraft `crates/model/src/stl.rs` (detection, colour extensions, degenerate facets) | SolidCraft `read_stl`: a facet count left at 0, non-UTF-8 ASCII names. TextureCraft `mesh_import/stl.rs`: same detection, nothing extra. The Z-up to Y-up turn EffectCraft did stays in EffectCraft. |
|
||||
| `stl` writers | SolidCraft `stl_binary`, `stl_ascii` | Node transforms, a header that never starts with `solid` |
|
||||
| `obj` reader | EffectCraft `crates/model/src/obj.rs` (own parser, MTL, continuations, library name candidates) | SolidCraft `obj.rs`: checked references, objects holding only their vertices, bad lines counted not fatal, smoothing groups kept, pool caps. TextureCraft `mesh_import/obj.rs`: vertex colours, whole polygons, group names. MaterialCraft `mesh.rs`: bounds on unused `v`/`vt`/`vn` definitions. |
|
||||
| `obj` writer | TextureCraft `mesh_export.rs` (`obj_text`, unique material names) | SolidCraft `obj_files`: several objects with running indices, control characters in names |
|
||||
| `ply` | SolidCraft `ply.rs` (header bounds, three line endings, size checks before reading) | TextureCraft `mesh_import/ply.rs`: normals, UVs, alpha, whole polygons, faces before vertices |
|
||||
| `gltf` reader | EffectCraft `crates/model/src/gltf.rs` (own reader over `serde_json`; skins, animations, cameras, lights) | SolidCraft `gltf_in.rs`: stricter GLB chunk checks, the required-extension policy, hierarchy caps. TextureCraft `mesh_import/gltf.rs`: COLOR_0, sparse accessors refused instead of read wrong. MaterialCraft `mesh.rs`: a bad index drops its triangle and later ones keep their vertices. |
|
||||
| `gltf` writer | SolidCraft `glb.rs` (`glb_container`, accessor layout) | TextureCraft `glb_bytes`: UV sets, embedded images and texture slots |
|
||||
|
||||
TextureCraft, MaterialCraft and SolidCraft read OBJ and glTF through the `tobj` and `gltf` crates;
|
||||
this crate does not depend on either.
|
||||
|
||||
## Migration
|
||||
|
||||
No app uses this crate yet.
|
||||
|
||||
| App | Replace | With | Keep in the app |
|
||||
|---|---|---|---|
|
||||
| EffectCraft | `crates/model/src/{stl,obj,gltf}.rs` | `stl::read`, `obj::read`, `gltf::read` | `Scene` to `Model`; the STL Y-up turn; computed normals per smoothing group and tangents; image decoding and the sibling-name lookup for textures; animation sampling. FBX stays. |
|
||||
| TextureCraft | `mesh_import/{stl,obj,ply,gltf}.rs`, `obj_text`, the geometry part of `glb_bytes` | readers, `obj::write`, `gltf::write_glb` | `Scene` to `RawMesh` (world space through `instances()`), unwrap, tangents, cameras to orbit cameras, texture baking. GLB clearcoat and sheen extensions are not in the model yet, so `glb_bytes` keeps them until then. Drops `tobj` and `gltf`. |
|
||||
| MaterialCraft | `crates/formats/src/mesh.rs` `obj` and `gltf` | `obj::read`, `gltf::read` with its 4M-triangle `Limits` | Non-finite normal and UV rejection, box UVs, normalising to the unit sphere. Drops `tobj` and `gltf`. |
|
||||
| SolidCraft | `crates/io/src/{obj,ply}.rs`, `read_stl`, `stl_binary`, `stl_ascii`, `gltf_in.rs` parsing, `glb.rs` container | readers, `ply::parse_header`/`Rows` under `ply_complex.rs`, STL writers, `gltf::write_glb` | Millimetre scaling, crease normals, mesh bodies, component plans from the node tree, point-cloud thinning and columns, looks. An OBJ without `o` now gives one mesh with a part per `g`; SolidCraft splits by part to keep one body per group. Drops `gltf`. |
|
||||
| DeckCraft | nothing merged; open PRs #1380 and #743 each add a `crates/mesh` | this crate | Rasterising |
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,711 @@
|
||||
//! 3D mesh file formats behind one neutral model.
|
||||
//!
|
||||
//! Each format is a module behind a Cargo feature of the same name: [`stl`], [`obj`] (with MTL),
|
||||
//! [`ply`] and [`gltf`] (glTF 2.0 and GLB). A reader turns bytes into a [`Scene`]; a writer turns
|
||||
//! a [`Scene`] into bytes. Mapping a [`Scene`] onto an application's own mesh, body or document
|
||||
//! types is the caller's business, as are computing missing normals, tangents and UVs, welding,
|
||||
//! unit scaling and turning a file's up axis.
|
||||
//!
|
||||
//! Conventions of the model, whatever the file:
|
||||
//!
|
||||
//! - Positions and normals are `f64` in the file's own axes and units (STL and PLY files are
|
||||
//! usually Z-up, glTF is Y-up in metres; nothing is turned or scaled here).
|
||||
//! - UVs have their origin at the top left with v pointing down (glTF's convention). OBJ and PLY
|
||||
//! store v up; their readers and writers flip it.
|
||||
//! - Material colours are linear RGB(A). MTL and STL colours are display (sRGB) colours and are
|
||||
//! converted. Vertex colours are the stored values scaled to 0..1.
|
||||
//! - A face is a polygon of three or more corners. Each corner names a position, and optionally
|
||||
//! a normal and a UV, by index into the mesh's pools, so a format with separate indices (OBJ)
|
||||
//! is kept exactly; [`Mesh::unified`] gives the one-index triangle list a renderer wants.
|
||||
//!
|
||||
//! Input is untrusted. Every reader takes [`Limits`], checks every count, index and length
|
||||
//! against the bytes that are there, and returns an [`Error`] or a warning in
|
||||
//! [`Scene::warnings`] instead of panicking. No reader opens a file: a reference to another file
|
||||
//! (an OBJ material library, a glTF buffer or image) is listed in [`Scene::external`] and asked
|
||||
//! of the caller's [`Resolver`], which may decline.
|
||||
//!
|
||||
//! Depends on no other Craft crate.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
// The helpers the formats share are not all used by every subset of them.
|
||||
#![cfg_attr(not(all(feature = "stl", feature = "obj", feature = "ply", feature = "gltf")), allow(dead_code, unused_imports))]
|
||||
|
||||
#[cfg(feature = "gltf")]
|
||||
pub mod gltf;
|
||||
#[cfg(feature = "obj")]
|
||||
pub mod obj;
|
||||
#[cfg(feature = "ply")]
|
||||
pub mod ply;
|
||||
#[cfg(feature = "stl")]
|
||||
pub mod stl;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fmt;
|
||||
|
||||
/// Why a file could not be read or a scene could not be written.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Error {
|
||||
/// The bytes are not a valid file of the format.
|
||||
Malformed(String),
|
||||
/// The file is valid but exceeds one of the [`Limits`]; names the limit.
|
||||
TooLarge(String),
|
||||
/// The file needs something this crate does not read (a compression extension, …).
|
||||
Unsupported(String),
|
||||
/// The file needs another file that the resolver did not supply; holds its name.
|
||||
Missing(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Error::Malformed(m) | Error::TooLarge(m) | Error::Unsupported(m) => f.write_str(m),
|
||||
Error::Missing(name) => write!(f, "`{name}`, which the file needs, was not supplied"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Error {}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
pub(crate) fn malformed(kind: &str, m: impl fmt::Display) -> Error {
|
||||
Error::Malformed(format!("{kind}: {m}"))
|
||||
}
|
||||
|
||||
pub(crate) fn too_large(kind: &str, m: impl fmt::Display) -> Error {
|
||||
Error::TooLarge(format!("{kind}: {m}"))
|
||||
}
|
||||
|
||||
/// The bounds a reader keeps. The defaults suit a desktop application; lower them for a
|
||||
/// browser or a preview.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Limits {
|
||||
/// Most entries in any one attribute pool of the file (positions, normals, UVs).
|
||||
pub max_vertices: usize,
|
||||
/// Most triangles in the file, counting a polygon of `n` corners as `n - 2`.
|
||||
pub max_triangles: usize,
|
||||
/// Most corners of one face.
|
||||
pub max_face_corners: usize,
|
||||
/// Most meshes (OBJ objects, glTF meshes).
|
||||
pub max_meshes: usize,
|
||||
/// Most parts (groups, material runs, glTF primitives) in the file.
|
||||
pub max_parts: usize,
|
||||
/// Most materials, and most images.
|
||||
pub max_materials: usize,
|
||||
/// Most nodes, and most node visits when a hierarchy is walked.
|
||||
pub max_nodes: usize,
|
||||
/// Deepest node nesting.
|
||||
pub max_depth: usize,
|
||||
/// Most bytes of external and embedded data (buffers, material libraries) taken in total.
|
||||
pub max_data_bytes: usize,
|
||||
/// Most skins, animations, cameras and lights (each), and most channels of one animation.
|
||||
pub max_extras: usize,
|
||||
}
|
||||
|
||||
impl Default for Limits {
|
||||
fn default() -> Self {
|
||||
Limits {
|
||||
max_vertices: 20_000_000,
|
||||
max_triangles: 20_000_000,
|
||||
max_face_corners: 4096,
|
||||
max_meshes: 100_000,
|
||||
max_parts: 100_000,
|
||||
max_materials: 100_000,
|
||||
max_nodes: 100_000,
|
||||
max_depth: 64,
|
||||
max_data_bytes: 1 << 30,
|
||||
max_extras: 10_000,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Answers a file's reference to another file (a name exactly as the file writes it) with that
|
||||
/// file's bytes, or `None`. The crate never opens anything itself.
|
||||
pub type Resolver<'a> = &'a mut dyn FnMut(&str) -> Option<Vec<u8>>;
|
||||
|
||||
/// A [`Resolver`] that declines everything: for self-contained files.
|
||||
pub fn no_external(_: &str) -> Option<Vec<u8>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Marks a corner without a normal or a UV.
|
||||
pub const NONE: u32 = u32::MAX;
|
||||
|
||||
/// One corner of a face: indices into [`Mesh::positions`], [`Mesh::normals`] and the UV sets
|
||||
/// ([`NONE`] when the corner has no normal or no UV).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct Corner {
|
||||
pub position: u32,
|
||||
pub normal: u32,
|
||||
pub uv: u32,
|
||||
}
|
||||
|
||||
impl Corner {
|
||||
/// A corner with only a position.
|
||||
pub fn at(position: u32) -> Corner {
|
||||
Corner { position, normal: NONE, uv: NONE }
|
||||
}
|
||||
|
||||
/// A corner whose position, normal and UV share one index.
|
||||
pub fn all(index: u32) -> Corner {
|
||||
Corner { position: index, normal: index, uv: index }
|
||||
}
|
||||
}
|
||||
|
||||
/// Faces of a mesh that share a group name, a material and a smoothing group.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Part {
|
||||
/// The group's name (OBJ `g`); empty when the file has none.
|
||||
pub name: String,
|
||||
/// Index into [`Scene::materials`].
|
||||
pub material: Option<usize>,
|
||||
/// OBJ smoothing group; 0 is off.
|
||||
pub smoothing: u32,
|
||||
/// Corners of each face, in order. Empty means every face is a triangle.
|
||||
pub face_sizes: Vec<u32>,
|
||||
/// The corners of all faces, one face after another.
|
||||
pub corners: Vec<Corner>,
|
||||
}
|
||||
|
||||
impl Part {
|
||||
/// The faces, each a slice of its corners. Stops at the first face the corner list is too
|
||||
/// short for.
|
||||
pub fn faces(&self) -> impl Iterator<Item = &[Corner]> {
|
||||
let mut rest = self.corners.as_slice();
|
||||
let mut sizes = self.face_sizes.iter();
|
||||
let all_triangles = self.face_sizes.is_empty();
|
||||
std::iter::from_fn(move || {
|
||||
let n = if all_triangles { 3 } else { *sizes.next()? as usize };
|
||||
let (face, tail) = rest.split_at_checked(n)?;
|
||||
rest = tail;
|
||||
(n > 0).then_some(face)
|
||||
})
|
||||
}
|
||||
|
||||
/// The faces as triangles: a polygon becomes a fan from its first corner.
|
||||
pub fn triangles(&self) -> impl Iterator<Item = [Corner; 3]> + '_ {
|
||||
self.faces().flat_map(|f| {
|
||||
let first = f.first().copied();
|
||||
f.get(1..).unwrap_or_default().windows(2).filter_map(move |w| match (first, w) {
|
||||
(Some(a), [b, c]) => Some([a, *b, *c]),
|
||||
_ => None,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Triangles the faces fan into.
|
||||
pub fn triangle_count(&self) -> usize {
|
||||
if self.face_sizes.is_empty() { self.corners.len() / 3 } else { self.face_sizes.iter().map(|n| (*n as usize).saturating_sub(2)).sum() }
|
||||
}
|
||||
}
|
||||
|
||||
/// One object: attribute pools and the parts whose corners index them.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Mesh {
|
||||
pub name: String,
|
||||
pub positions: Vec<[f64; 3]>,
|
||||
pub normals: Vec<[f64; 3]>,
|
||||
/// UV sets; every set has the same length and is indexed by [`Corner::uv`].
|
||||
pub uv_sets: Vec<Vec<[f32; 2]>>,
|
||||
/// RGBA per position (same length as `positions`), or empty.
|
||||
pub colors: Vec<[f32; 4]>,
|
||||
/// Skinning (glTF): up to four joints (indices into the skin's joint list) and weights per
|
||||
/// position; both the length of `positions`, or empty.
|
||||
pub joints: Vec<[u16; 4]>,
|
||||
pub weights: Vec<[f32; 4]>,
|
||||
pub parts: Vec<Part>,
|
||||
}
|
||||
|
||||
/// A triangle list with one index per vertex, as [`Mesh::unified`] builds it.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Unified {
|
||||
pub positions: Vec<[f64; 3]>,
|
||||
/// One per vertex when every corner has a normal, else empty.
|
||||
pub normals: Vec<[f64; 3]>,
|
||||
/// The first UV set, one per vertex when every corner has a UV, else empty.
|
||||
pub uvs: Vec<[f32; 2]>,
|
||||
/// One per vertex when the mesh has colours, else empty.
|
||||
pub colors: Vec<[f32; 4]>,
|
||||
/// The corner of the mesh each vertex came from: its position index finds the joints and
|
||||
/// weights, its UV index the other UV sets.
|
||||
pub corners: Vec<Corner>,
|
||||
/// Triangles per part, in the order of [`Mesh::parts`].
|
||||
pub parts: Vec<Vec<[u32; 3]>>,
|
||||
}
|
||||
|
||||
impl Mesh {
|
||||
pub fn triangle_count(&self) -> usize {
|
||||
self.parts.iter().map(Part::triangle_count).sum()
|
||||
}
|
||||
|
||||
/// Checks what every reader guarantees and every writer needs: each corner's indices are in
|
||||
/// range, the face sizes add up, and the per-position arrays have the positions' length.
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
let bad = |m: &str| Err(Error::Malformed(format!("mesh `{}`: {m}", self.name)));
|
||||
let uvs = self.uv_sets.first().map_or(0, Vec::len);
|
||||
if self.uv_sets.iter().any(|s| s.len() != uvs) {
|
||||
return bad("UV sets differ in length");
|
||||
}
|
||||
for (what, len) in [("colours", self.colors.len()), ("joints", self.joints.len()), ("weights", self.weights.len())] {
|
||||
if len != 0 && len != self.positions.len() {
|
||||
return bad(&format!("{what} do not match the positions"));
|
||||
}
|
||||
}
|
||||
for p in &self.parts {
|
||||
let total = if p.face_sizes.is_empty() {
|
||||
(p.corners.len() % 3 == 0).then_some(p.corners.len())
|
||||
} else {
|
||||
p.face_sizes.iter().try_fold(0usize, |n, s| if *s >= 3 { n.checked_add(*s as usize) } else { None })
|
||||
};
|
||||
if total != Some(p.corners.len()) {
|
||||
return bad("face sizes do not match the corners");
|
||||
}
|
||||
for c in &p.corners {
|
||||
let ok = (c.position as usize) < self.positions.len()
|
||||
&& (c.normal == NONE || (c.normal as usize) < self.normals.len())
|
||||
&& (c.uv == NONE || (c.uv as usize) < uvs);
|
||||
if !ok {
|
||||
return bad("a corner index is out of range");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The mesh as triangles with one vertex per distinct corner. Corners whose indices are out
|
||||
/// of range are dropped with their triangle.
|
||||
pub fn unified(&self) -> Unified {
|
||||
let mut out = Unified::default();
|
||||
let all = |f: &dyn Fn(&Corner) -> bool| self.parts.iter().all(|p| p.corners.iter().all(f));
|
||||
let normals = !self.normals.is_empty() && all(&|c| c.normal != NONE);
|
||||
let uv_set = self.uv_sets.first().filter(|_| all(&|c| c.uv != NONE));
|
||||
let colors = self.colors.len() == self.positions.len() && !self.colors.is_empty();
|
||||
let mut ids: HashMap<Corner, u32> = HashMap::new();
|
||||
for part in &self.parts {
|
||||
let mut tris = Vec::with_capacity(part.triangle_count());
|
||||
'tri: for t in part.triangles() {
|
||||
let mut tri = [0u32; 3];
|
||||
for (slot, c) in tri.iter_mut().zip(t) {
|
||||
let key = Corner {
|
||||
position: c.position,
|
||||
normal: if normals { c.normal } else { NONE },
|
||||
uv: if uv_set.is_some() { c.uv } else { NONE },
|
||||
};
|
||||
if let Some(id) = ids.get(&key) {
|
||||
*slot = *id;
|
||||
continue;
|
||||
}
|
||||
let Some(p) = self.positions.get(c.position as usize) else { continue 'tri };
|
||||
let n = self.normals.get(c.normal as usize).filter(|_| normals);
|
||||
let uv = uv_set.and_then(|s| s.get(c.uv as usize));
|
||||
if (normals && n.is_none()) || (uv_set.is_some() && uv.is_none()) {
|
||||
continue 'tri;
|
||||
}
|
||||
let Ok(id) = u32::try_from(out.positions.len()) else { continue 'tri };
|
||||
out.positions.push(*p);
|
||||
out.normals.extend(n);
|
||||
out.uvs.extend(uv);
|
||||
if colors {
|
||||
out.colors.extend(self.colors.get(c.position as usize));
|
||||
}
|
||||
out.corners.push(c);
|
||||
ids.insert(key, id);
|
||||
*slot = id;
|
||||
}
|
||||
tris.push(tri);
|
||||
}
|
||||
out.parts.push(tris);
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// How a material's alpha is used (glTF `alphaMode`).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub enum AlphaMode {
|
||||
#[default]
|
||||
Opaque,
|
||||
/// Visible where alpha is at least the cutoff.
|
||||
Mask(f32),
|
||||
Blend,
|
||||
}
|
||||
|
||||
/// How a texture continues outside 0..1.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
|
||||
pub enum Wrap {
|
||||
#[default]
|
||||
Repeat,
|
||||
Clamp,
|
||||
Mirror,
|
||||
}
|
||||
|
||||
/// A texture slot: an image of the scene, the UV set it reads and how it wraps along u and v.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
|
||||
pub struct TextureRef {
|
||||
/// Index into [`Scene::images`].
|
||||
pub image: usize,
|
||||
pub uv_set: u32,
|
||||
pub wrap: [Wrap; 2],
|
||||
}
|
||||
|
||||
/// A metallic-roughness material: the simple parameters every format here can carry.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Material {
|
||||
pub name: String,
|
||||
/// Linear RGBA.
|
||||
pub base_color: [f32; 4],
|
||||
pub metallic: f32,
|
||||
pub roughness: f32,
|
||||
/// Linear RGB.
|
||||
pub emissive: [f32; 3],
|
||||
pub alpha_mode: AlphaMode,
|
||||
pub double_sided: bool,
|
||||
/// Shown as it is, without lighting (glTF `KHR_materials_unlit`).
|
||||
pub unlit: bool,
|
||||
pub base_color_texture: Option<TextureRef>,
|
||||
pub metallic_roughness_texture: Option<TextureRef>,
|
||||
pub normal_texture: Option<TextureRef>,
|
||||
pub normal_scale: f32,
|
||||
pub occlusion_texture: Option<TextureRef>,
|
||||
pub occlusion_strength: f32,
|
||||
pub emissive_texture: Option<TextureRef>,
|
||||
}
|
||||
|
||||
impl Default for Material {
|
||||
fn default() -> Self {
|
||||
Material {
|
||||
name: String::new(),
|
||||
base_color: [1.0; 4],
|
||||
metallic: 1.0,
|
||||
roughness: 1.0,
|
||||
emissive: [0.0; 3],
|
||||
alpha_mode: AlphaMode::Opaque,
|
||||
double_sided: false,
|
||||
unlit: false,
|
||||
base_color_texture: None,
|
||||
metallic_roughness_texture: None,
|
||||
normal_texture: None,
|
||||
normal_scale: 1.0,
|
||||
occlusion_texture: None,
|
||||
occlusion_strength: 1.0,
|
||||
emissive_texture: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Material {
|
||||
/// A plain dielectric of one colour.
|
||||
pub fn plain(name: impl Into<String>, base_color: [f32; 4]) -> Material {
|
||||
Material { name: name.into(), base_color, metallic: 0.0, roughness: 0.5, ..Material::default() }
|
||||
}
|
||||
}
|
||||
|
||||
/// An image a material uses: a reference the caller resolves, or bytes the file embedded. The
|
||||
/// bytes are the encoded image (PNG, JPEG, …); decoding is the caller's.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Image {
|
||||
pub name: String,
|
||||
/// The reference as the file writes it, when the image is another file.
|
||||
pub uri: Option<String>,
|
||||
pub mime_type: Option<String>,
|
||||
/// The encoded bytes, when the file embeds them or the resolver supplied them.
|
||||
pub data: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// A 4×4 transform, column-major (as glTF writes it): a point is `M · [x, y, z, 1]`.
|
||||
pub type Mat4 = [f64; 16];
|
||||
|
||||
pub const IDENTITY: Mat4 = [1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0];
|
||||
|
||||
/// `a · b`: `b` applies first.
|
||||
pub fn mat_mul(a: &Mat4, b: &Mat4) -> Mat4 {
|
||||
let mut m = [0.0; 16];
|
||||
for (i, v) in m.iter_mut().enumerate() {
|
||||
let (col, row) = (i / 4, i % 4);
|
||||
*v = (0..4).map(|k| a[k * 4 + row] * b[col * 4 + k]).sum();
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
/// The point `p` moved by `m`.
|
||||
pub fn transform_point(m: &Mat4, p: [f64; 3]) -> [f64; 3] {
|
||||
[0, 1, 2].map(|r| m[r] * p[0] + m[4 + r] * p[1] + m[8 + r] * p[2] + m[12 + r])
|
||||
}
|
||||
|
||||
/// Does `m` mirror (a negative determinant)? A mirrored mesh needs its winding reversed to
|
||||
/// keep facing out; the writers of formats without a hierarchy do that.
|
||||
pub fn is_mirror(m: &Mat4) -> bool {
|
||||
let l = |r: usize, c: usize| m[c * 4 + r];
|
||||
l(0, 0) * (l(1, 1) * l(2, 2) - l(1, 2) * l(2, 1)) - l(0, 1) * (l(1, 0) * l(2, 2) - l(1, 2) * l(2, 0))
|
||||
+ l(0, 2) * (l(1, 0) * l(2, 1) - l(1, 1) * l(2, 0))
|
||||
< 0.0
|
||||
}
|
||||
|
||||
/// The normal `n` under `m` (by the inverse transpose of its linear part, so scale, shear and
|
||||
/// mirrors are right), normalised; `n` itself when the result has no length.
|
||||
pub fn transform_normal(m: &Mat4, n: [f64; 3]) -> [f64; 3] {
|
||||
let l = |r: usize, c: usize| m[c * 4 + r];
|
||||
// Cyclic indices give each cofactor its sign; the cofactors are the inverse transpose
|
||||
// times the determinant, whose sign a mirror flips.
|
||||
let cof = |r: usize, c: usize| {
|
||||
let (r1, r2, c1, c2) = ((r + 1) % 3, (r + 2) % 3, (c + 1) % 3, (c + 2) % 3);
|
||||
l(r1, c1) * l(r2, c2) - l(r1, c2) * l(r2, c1)
|
||||
};
|
||||
let sign = if is_mirror(m) { -1.0 } else { 1.0 };
|
||||
let v = [0, 1, 2].map(|r| sign * (cof(r, 0) * n[0] + cof(r, 1) * n[1] + cof(r, 2) * n[2]));
|
||||
let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
|
||||
if len.is_finite() && len > 0.0 { v.map(|x| x / len) } else { n }
|
||||
}
|
||||
|
||||
/// A placement as translation, rotation (a unit quaternion `x, y, z, w`) and scale.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Trs {
|
||||
pub translation: [f64; 3],
|
||||
pub rotation: [f64; 4],
|
||||
pub scale: [f64; 3],
|
||||
}
|
||||
|
||||
impl Trs {
|
||||
/// `T · R · S`.
|
||||
pub fn matrix(&self) -> Mat4 {
|
||||
let [x, y, z, w] = self.rotation;
|
||||
let r = [
|
||||
[1.0 - 2.0 * (y * y + z * z), 2.0 * (x * y + z * w), 2.0 * (x * z - y * w)],
|
||||
[2.0 * (x * y - z * w), 1.0 - 2.0 * (x * x + z * z), 2.0 * (y * z + x * w)],
|
||||
[2.0 * (x * z + y * w), 2.0 * (y * z - x * w), 1.0 - 2.0 * (x * x + y * y)],
|
||||
];
|
||||
let mut m = IDENTITY;
|
||||
for (c, (col, s)) in r.iter().zip(self.scale).enumerate() {
|
||||
for (row, v) in col.iter().enumerate() {
|
||||
m[c * 4 + row] = v * s;
|
||||
}
|
||||
}
|
||||
m[12] = self.translation[0];
|
||||
m[13] = self.translation[1];
|
||||
m[14] = self.translation[2];
|
||||
m
|
||||
}
|
||||
}
|
||||
|
||||
/// A node of the scene's hierarchy.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Node {
|
||||
pub name: String,
|
||||
/// Placement relative to the parent.
|
||||
pub transform: Mat4,
|
||||
/// The same placement in parts, when the file gave it so (animations move the parts).
|
||||
pub trs: Option<Trs>,
|
||||
/// Index into [`Scene::cameras`].
|
||||
pub camera: Option<usize>,
|
||||
/// Index into [`Scene::lights`].
|
||||
pub light: Option<usize>,
|
||||
/// Index into [`Scene::meshes`].
|
||||
pub mesh: Option<usize>,
|
||||
/// Index into [`Scene::skins`].
|
||||
pub skin: Option<usize>,
|
||||
/// Indices into [`Scene::nodes`].
|
||||
pub children: Vec<usize>,
|
||||
}
|
||||
|
||||
impl Default for Node {
|
||||
fn default() -> Self {
|
||||
Node { name: String::new(), transform: IDENTITY, trs: None, camera: None, light: None, mesh: None, skin: None, children: Vec::new() }
|
||||
}
|
||||
}
|
||||
|
||||
/// A skin (glTF): the joint nodes and their inverse bind matrices.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Skin {
|
||||
pub name: String,
|
||||
/// Indices into [`Scene::nodes`].
|
||||
pub joints: Vec<usize>,
|
||||
/// One per joint, or empty for identity.
|
||||
pub inverse_bind: Vec<Mat4>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Projection {
|
||||
/// Vertical field of view in radians; the aspect ratio when the file fixes one.
|
||||
Perspective { yfov: f64, aspect: Option<f64>, znear: f64, zfar: Option<f64> },
|
||||
/// Half the view's width and height.
|
||||
Orthographic { xmag: f64, ymag: f64, znear: f64, zfar: f64 },
|
||||
}
|
||||
|
||||
/// A camera, looking along its node's −Z with +Y up.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Camera {
|
||||
pub name: String,
|
||||
pub projection: Projection,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum LightKind {
|
||||
Directional,
|
||||
Point,
|
||||
/// Cone angles in radians from the axis.
|
||||
Spot {
|
||||
inner: f64,
|
||||
outer: f64,
|
||||
},
|
||||
}
|
||||
|
||||
/// A punctual light, shining along its node's −Z.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Light {
|
||||
pub name: String,
|
||||
pub kind: LightKind,
|
||||
/// Linear RGB.
|
||||
pub color: [f64; 3],
|
||||
/// Candela for point and spot lights, lux for directional ones.
|
||||
pub intensity: f64,
|
||||
pub range: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ChannelPath {
|
||||
Translation,
|
||||
Rotation,
|
||||
Scale,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Interpolation {
|
||||
Step,
|
||||
Linear,
|
||||
/// Three values per key: in-tangent, value, out-tangent.
|
||||
CubicSpline,
|
||||
}
|
||||
|
||||
/// The keys of one part of one node's placement. Sampling them is the caller's.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Channel {
|
||||
/// Index into [`Scene::nodes`].
|
||||
pub node: usize,
|
||||
pub path: ChannelPath,
|
||||
pub interpolation: Interpolation,
|
||||
/// Key times in seconds.
|
||||
pub times: Vec<f32>,
|
||||
/// The keys' values, flat: 3 per key for translation and scale, 4 for rotation (times 3
|
||||
/// for a cubic spline).
|
||||
pub values: Vec<f32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Animation {
|
||||
pub name: String,
|
||||
pub channels: Vec<Channel>,
|
||||
}
|
||||
|
||||
/// What a file holds.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Scene {
|
||||
pub meshes: Vec<Mesh>,
|
||||
pub materials: Vec<Material>,
|
||||
pub images: Vec<Image>,
|
||||
/// Every format fills this: a format without a hierarchy gets one root node per mesh.
|
||||
pub nodes: Vec<Node>,
|
||||
/// The top-level nodes.
|
||||
pub roots: Vec<usize>,
|
||||
pub skins: Vec<Skin>,
|
||||
pub animations: Vec<Animation>,
|
||||
pub cameras: Vec<Camera>,
|
||||
pub lights: Vec<Light>,
|
||||
/// Every other file this one names, as written, whether or not the resolver supplied it.
|
||||
pub external: Vec<String>,
|
||||
/// What was left out or repaired while reading.
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
impl Scene {
|
||||
/// A scene of meshes without a hierarchy: one root node per mesh.
|
||||
pub fn from_meshes(meshes: Vec<Mesh>, materials: Vec<Material>) -> Scene {
|
||||
let nodes: Vec<Node> = meshes.iter().enumerate().map(|(i, m)| Node { name: m.name.clone(), mesh: Some(i), ..Node::default() }).collect();
|
||||
Scene { roots: (0..nodes.len()).collect(), nodes, meshes, materials, ..Scene::default() }
|
||||
}
|
||||
|
||||
/// Every placed mesh: the mesh's index and its world transform, walking the hierarchy from
|
||||
/// the roots. A hierarchy that is deeper than `limits.max_depth`, takes more than
|
||||
/// `limits.max_nodes` visits (a cycle, or a shared subtree expanded too often) or names a
|
||||
/// node that is not there is an error.
|
||||
pub fn instances(&self, limits: &Limits) -> Result<Vec<(usize, Mat4)>> {
|
||||
let mut out = Vec::new();
|
||||
let mut stack: Vec<(usize, Mat4, usize)> = self.roots.iter().rev().map(|r| (*r, IDENTITY, 0)).collect();
|
||||
let mut visits = 0usize;
|
||||
while let Some((index, parent, depth)) = stack.pop() {
|
||||
visits += 1;
|
||||
if visits > limits.max_nodes {
|
||||
return Err(Error::TooLarge(format!("the node hierarchy takes more than {} visits", limits.max_nodes)));
|
||||
}
|
||||
if depth > limits.max_depth {
|
||||
return Err(Error::TooLarge(format!("the node hierarchy is deeper than {}", limits.max_depth)));
|
||||
}
|
||||
let node = self.nodes.get(index).ok_or_else(|| Error::Malformed(format!("node {index} does not exist")))?;
|
||||
let world = mat_mul(&parent, &node.transform);
|
||||
if let Some(m) = node.mesh.filter(|m| *m < self.meshes.len()) {
|
||||
out.push((m, world));
|
||||
}
|
||||
stack.extend(node.children.iter().rev().map(|c| (*c, world, depth + 1)));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// [`Mesh::validate`] for every mesh, and every material, image, mesh, skin, camera, light
|
||||
/// and node index in range.
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
let bad = |m: String| Err(Error::Malformed(m));
|
||||
for m in &self.meshes {
|
||||
m.validate()?;
|
||||
if m.parts.iter().any(|p| p.material.is_some_and(|i| i >= self.materials.len())) {
|
||||
return bad(format!("mesh `{}` names a material that does not exist", m.name));
|
||||
}
|
||||
}
|
||||
for m in &self.materials {
|
||||
let slots = [m.base_color_texture, m.metallic_roughness_texture, m.normal_texture, m.occlusion_texture, m.emissive_texture];
|
||||
if slots.iter().flatten().any(|t| t.image >= self.images.len()) {
|
||||
return bad(format!("material `{}` names an image that does not exist", m.name));
|
||||
}
|
||||
}
|
||||
let n = self.nodes.len();
|
||||
for node in &self.nodes {
|
||||
let refs =
|
||||
[(node.mesh, self.meshes.len()), (node.skin, self.skins.len()), (node.camera, self.cameras.len()), (node.light, self.lights.len())];
|
||||
if refs.iter().any(|(i, len)| i.is_some_and(|i| i >= *len)) || node.children.iter().any(|c| *c >= n) {
|
||||
return bad(format!("node `{}` names something that does not exist", node.name));
|
||||
}
|
||||
}
|
||||
let channels = self.animations.iter().flat_map(|a| &a.channels).any(|c| c.node >= n);
|
||||
if self.roots.iter().any(|r| *r >= n) || self.skins.iter().any(|s| s.joints.iter().any(|j| *j >= n)) || channels {
|
||||
return bad("a root, joint or animation channel names a node that does not exist".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// sRGB-encoded component to linear.
|
||||
pub fn srgb_to_linear(c: f32) -> f32 {
|
||||
if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) }
|
||||
}
|
||||
|
||||
/// Linear component to sRGB-encoded.
|
||||
pub fn linear_to_srgb(c: f32) -> f32 {
|
||||
if c <= 0.003_130_8 { c * 12.92 } else { 1.055 * c.powf(1.0 / 2.4) - 0.055 }
|
||||
}
|
||||
|
||||
/// Warnings that would repeat are counted and reported once.
|
||||
pub(crate) fn count_warning(warnings: &mut Vec<String>, count: usize, what: &str) {
|
||||
if count > 0 {
|
||||
warnings.push(format!("{count} {what}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// The unit normal of triangle `v` from its counter-clockwise winding; `None` when it has no
|
||||
/// area.
|
||||
pub fn face_normal(v: [[f64; 3]; 3]) -> Option<[f64; 3]> {
|
||||
let (a, b) = ([0, 1, 2].map(|k| v[1][k] - v[0][k]), [0, 1, 2].map(|k| v[2][k] - v[0][k]));
|
||||
let n = [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
|
||||
let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
|
||||
(len.is_finite() && len > 0.0).then(|| n.map(|x| x / len))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,571 @@
|
||||
//! Wavefront OBJ geometry and MTL materials, read and written.
|
||||
//!
|
||||
//! OBJ read: `v x y z` with optional vertex colours (`v x y z r g b`), `vt`, `vn`, `f` with
|
||||
//! `v`, `v/vt`, `v//vn` and `v/vt/vn` references (negative ones count back from the end),
|
||||
//! polygons of any size kept whole, `o` objects (one [`Mesh`] each, holding only the vertices
|
||||
//! it uses), `g` groups, `usemtl` and `s` smoothing groups (a [`Part`] per combination),
|
||||
//! `mtllib` (asked of the resolver), `\` line continuations and `#` comments. Lines (`l`),
|
||||
//! points (`p`) and free-form geometry are passed over. A vertex or face line that cannot be
|
||||
//! read is left out and counted in one warning; the numbering of later vertices is kept.
|
||||
//!
|
||||
//! MTL materials map onto metallic-roughness: `Kd` → base colour, `d`/`Tr` → alpha (blended
|
||||
//! when below 1), `Ke` → emissive, `Pr`/`Pm` (the PBR extension) → roughness/metallic,
|
||||
//! otherwise roughness from the Phong exponent `Ns` (`sqrt(2 / (Ns + 2))`) and metallic 0;
|
||||
//! `map_Kd`, `map_Ke`, `map_Bump`/`bump`/`norm` name base colour, emissive and normal images.
|
||||
//! An image is only named ([`Image::uri`]); reading and decoding it is the caller's.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use crate::{
|
||||
AlphaMode, Corner, Error, Image, Limits, Material, Mesh, NONE, Part, Resolver, Result, Scene, TextureRef, count_warning, is_mirror,
|
||||
linear_to_srgb, malformed, srgb_to_linear, too_large, transform_normal, transform_point,
|
||||
};
|
||||
|
||||
const KIND: &str = "OBJ";
|
||||
|
||||
/// The last component of a referenced path, treating `\` as a separator too: a path from a
|
||||
/// machine of another kind (`C:\textures\checker.png`) still names `checker.png`. `None` when
|
||||
/// the name carries no path at all.
|
||||
pub fn sibling_name(name: &str) -> Option<&str> {
|
||||
let file = name.rsplit(['/', '\\']).next()?;
|
||||
(!file.is_empty() && file.len() < name.len()).then_some(file)
|
||||
}
|
||||
|
||||
/// The image a `map_*` line names: the rest of the line (a name with spaces is one name),
|
||||
/// or its final token when the line carries options (`-bm 1 file.png`).
|
||||
fn map_name(rest: &str) -> &str {
|
||||
if rest.starts_with('-') { rest.split_whitespace().last().unwrap_or(rest) } else { rest }
|
||||
}
|
||||
|
||||
/// Materials of an MTL library and the images they name. `TextureRef::image` indexes the
|
||||
/// returned images.
|
||||
pub fn read_mtl(bytes: &[u8], limits: &Limits) -> Result<(Vec<Material>, Vec<Image>)> {
|
||||
let text = String::from_utf8_lossy(bytes);
|
||||
let mut out: Vec<Material> = vec![];
|
||||
let mut images: Vec<Image> = vec![];
|
||||
let mut pr_seen = false;
|
||||
for line in text.lines() {
|
||||
let line = line.trim();
|
||||
let (key, rest) = line.split_once(char::is_whitespace).map(|(k, r)| (k, r.trim())).unwrap_or((line, ""));
|
||||
if key == "newmtl" {
|
||||
if out.len() >= limits.max_materials {
|
||||
return Err(too_large("MTL", format!("more than {} materials", limits.max_materials)));
|
||||
}
|
||||
pr_seen = false;
|
||||
out.push(Material::plain(rest, [1.0; 4]));
|
||||
continue;
|
||||
}
|
||||
let Some(m) = out.last_mut() else { continue };
|
||||
mtl_line(m, key, rest, &mut pr_seen, &mut images, limits)?;
|
||||
}
|
||||
for m in &mut out {
|
||||
if m.base_color[3] < 0.999 {
|
||||
m.alpha_mode = AlphaMode::Blend;
|
||||
}
|
||||
}
|
||||
Ok((out, images))
|
||||
}
|
||||
|
||||
fn mtl_line(m: &mut Material, key: &str, rest: &str, pr_seen: &mut bool, images: &mut Vec<Image>, limits: &Limits) -> Result<()> {
|
||||
let finite = |x: &str| x.parse::<f32>().ok().filter(|v| v.is_finite());
|
||||
let f3 = || -> [f32; 3] {
|
||||
let v: Vec<f32> = rest.split_whitespace().take(3).filter_map(finite).collect();
|
||||
match v[..] {
|
||||
[a, b, c] => [a, b, c],
|
||||
[a, ..] => [a; 3],
|
||||
[] => [0.0; 3],
|
||||
}
|
||||
};
|
||||
let f1 = || rest.split_whitespace().next().and_then(finite);
|
||||
let mut tex = || -> Result<Option<TextureRef>> {
|
||||
let name = map_name(rest);
|
||||
if name.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let image = match images.iter().position(|i| i.uri.as_deref() == Some(name)) {
|
||||
Some(i) => i,
|
||||
None if images.len() >= limits.max_materials => return Err(too_large("MTL", format!("more than {} images", limits.max_materials))),
|
||||
None => {
|
||||
images.push(Image { name: name.to_string(), uri: Some(name.to_string()), ..Image::default() });
|
||||
images.len() - 1
|
||||
}
|
||||
};
|
||||
Ok(Some(TextureRef { image, ..TextureRef::default() }))
|
||||
};
|
||||
match key {
|
||||
"Kd" => {
|
||||
let c = f3().map(srgb_to_linear);
|
||||
m.base_color = [c[0], c[1], c[2], m.base_color[3]];
|
||||
}
|
||||
"Ke" => m.emissive = f3().map(srgb_to_linear),
|
||||
"d" => m.base_color[3] = f1().unwrap_or(1.0).clamp(0.0, 1.0),
|
||||
"Tr" => m.base_color[3] = (1.0 - f1().unwrap_or(0.0)).clamp(0.0, 1.0),
|
||||
"Ns" if !*pr_seen => m.roughness = (2.0 / (f1().unwrap_or(0.0).max(0.0) + 2.0)).sqrt(),
|
||||
"Pr" => {
|
||||
m.roughness = f1().unwrap_or(0.5).clamp(0.0, 1.0);
|
||||
*pr_seen = true;
|
||||
}
|
||||
"Pm" => m.metallic = f1().unwrap_or(0.0).clamp(0.0, 1.0),
|
||||
"map_Kd" => m.base_color_texture = tex()?,
|
||||
"map_Ke" => {
|
||||
m.emissive_texture = tex()?;
|
||||
if m.emissive == [0.0; 3] {
|
||||
m.emissive = [1.0; 3];
|
||||
}
|
||||
}
|
||||
"map_Bump" | "map_bump" | "bump" | "norm" => m.normal_texture = tex()?,
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A 1-based (or negative, counting back) OBJ reference as an index into `len` entries.
|
||||
fn index(token: &str, len: usize) -> Option<u32> {
|
||||
let value: i64 = token.parse().ok()?;
|
||||
let len = i64::try_from(len).ok()?;
|
||||
let value = if value < 0 { len.checked_add(value)? } else { value.checked_sub(1)? };
|
||||
(0..len).contains(&value).then(|| u32::try_from(value).ok()).flatten()
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Object {
|
||||
name: String,
|
||||
parts: Vec<Part>,
|
||||
slots: HashMap<(String, Option<usize>, u32), usize>,
|
||||
}
|
||||
|
||||
struct Reader<'a> {
|
||||
limits: &'a Limits,
|
||||
v: Vec<[f64; 3]>,
|
||||
colors: Vec<[f32; 4]>,
|
||||
vt: Vec<[f32; 2]>,
|
||||
vn: Vec<[f64; 3]>,
|
||||
objects: Vec<Object>,
|
||||
scene: Scene,
|
||||
material_ids: HashMap<String, usize>,
|
||||
material: Option<usize>,
|
||||
group: String,
|
||||
smoothing: u32,
|
||||
/// The part faces go to until the object, group, material or smoothing changes.
|
||||
slot: Option<usize>,
|
||||
triangles: usize,
|
||||
parts: usize,
|
||||
bad_lines: usize,
|
||||
data_bytes: usize,
|
||||
}
|
||||
|
||||
const NAN3: [f64; 3] = [f64::NAN; 3];
|
||||
|
||||
/// `N` finite numbers from the next `N` tokens.
|
||||
fn numbers<'a, const N: usize>(mut it: impl Iterator<Item = &'a str>) -> Option<[f64; N]> {
|
||||
let mut out = [0.0; N];
|
||||
for v in &mut out {
|
||||
*v = it.next()?.parse::<f64>().ok().filter(|v| v.is_finite())?;
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
impl Reader<'_> {
|
||||
fn pool_room(&self, len: usize, what: &str) -> Result<()> {
|
||||
if len >= self.limits.max_vertices {
|
||||
return Err(too_large(KIND, format!("more than {} {what}", self.limits.max_vertices)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn vertex(&mut self, rest: &[&str]) -> Result<()> {
|
||||
self.pool_room(self.v.len(), "vertices")?;
|
||||
let p = numbers::<3>(rest.iter().copied());
|
||||
// Keep the numbering: later faces count vertices by position.
|
||||
self.v.push(p.unwrap_or(NAN3));
|
||||
self.bad_lines += usize::from(p.is_none());
|
||||
// `v x y z r g b`: vertices without a colour are white.
|
||||
if let (true, Some([r, g, b])) = (rest.len() >= 6, numbers::<3>(rest.iter().skip(3).copied())) {
|
||||
self.colors.resize(self.v.len() - 1, [1.0; 4]);
|
||||
self.colors.push([r as f32, g as f32, b as f32, 1.0]);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn start_object(&mut self, name: String) -> Result<()> {
|
||||
self.slot = None;
|
||||
let full = self.objects.len() >= self.limits.max_meshes;
|
||||
match self.objects.last_mut() {
|
||||
// An empty object takes the name instead of starting another.
|
||||
Some(o) if o.parts.is_empty() => o.name = name,
|
||||
_ if full => return Err(too_large(KIND, format!("more than {} objects", self.limits.max_meshes))),
|
||||
_ => self.objects.push(Object { name, ..Object::default() }),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn use_material(&mut self, name: &str) -> Result<()> {
|
||||
self.slot = None;
|
||||
self.material = match self.material_ids.get(name) {
|
||||
Some(i) => Some(*i),
|
||||
None if self.scene.materials.len() >= self.limits.max_materials => {
|
||||
return Err(too_large(KIND, format!("more than {} materials", self.limits.max_materials)));
|
||||
}
|
||||
None => {
|
||||
// Not in any library read: a named grey default.
|
||||
self.scene.materials.push(Material::plain(name, [0.8, 0.8, 0.8, 1.0]));
|
||||
self.material_ids.insert(name.to_string(), self.scene.materials.len() - 1);
|
||||
Some(self.scene.materials.len() - 1)
|
||||
}
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn library(&mut self, name: &str, resolve: Resolver) -> Result<()> {
|
||||
if name.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
if self.scene.external.len() >= self.limits.max_materials {
|
||||
return Err(too_large(KIND, format!("more than {} material libraries", self.limits.max_materials)));
|
||||
}
|
||||
self.scene.external.push(name.to_string());
|
||||
// The name as written (it may hold spaces), then each of several names, then the file
|
||||
// name of a path from another machine.
|
||||
let Some(bytes) = std::iter::once(name).chain(name.split_whitespace()).chain(sibling_name(name)).find_map(resolve) else {
|
||||
self.scene.warnings.push(format!("material library `{name}` was not supplied"));
|
||||
return Ok(());
|
||||
};
|
||||
self.data_bytes = self.data_bytes.saturating_add(bytes.len());
|
||||
if self.data_bytes > self.limits.max_data_bytes {
|
||||
return Err(too_large(KIND, format!("material libraries of more than {} bytes", self.limits.max_data_bytes)));
|
||||
}
|
||||
let (materials, images) = read_mtl(&bytes, self.limits)?;
|
||||
let base = self.scene.images.len();
|
||||
if base.saturating_add(images.len()) > self.limits.max_materials
|
||||
|| self.scene.materials.len().saturating_add(materials.len()) > self.limits.max_materials
|
||||
{
|
||||
return Err(too_large(KIND, format!("more than {} materials or images", self.limits.max_materials)));
|
||||
}
|
||||
self.scene.external.extend(images.iter().filter_map(|i| i.uri.clone()));
|
||||
self.scene.images.extend(images);
|
||||
for mut m in materials {
|
||||
for t in [&mut m.base_color_texture, &mut m.emissive_texture, &mut m.normal_texture].into_iter().flatten() {
|
||||
t.image += base;
|
||||
}
|
||||
// The first definition of a name wins, as for a reader that looks names up in order.
|
||||
self.material_ids.entry(m.name.clone()).or_insert(self.scene.materials.len());
|
||||
self.scene.materials.push(m);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn corner(&self, token: &str) -> Option<Corner> {
|
||||
let mut fields = token.split('/');
|
||||
let position = index(fields.next()?, self.v.len())?;
|
||||
self.v.get(position as usize).filter(|p| p[0].is_finite())?;
|
||||
let uv = fields.next().and_then(|t| index(t, self.vt.len())).filter(|i| self.vt.get(*i as usize).is_some_and(|t| t[0].is_finite()));
|
||||
let normal = fields.next().and_then(|t| index(t, self.vn.len())).filter(|i| self.vn.get(*i as usize).is_some_and(|n| n[0].is_finite()));
|
||||
Some(Corner { position, normal: normal.unwrap_or(NONE), uv: uv.unwrap_or(NONE) })
|
||||
}
|
||||
|
||||
fn part(&mut self) -> Result<Option<&mut Part>> {
|
||||
if self.objects.is_empty() {
|
||||
self.objects.push(Object::default());
|
||||
}
|
||||
let Some(object) = self.objects.last_mut() else { return Ok(None) };
|
||||
let slot = match self.slot {
|
||||
Some(s) => s,
|
||||
None => match object.slots.get(&(self.group.clone(), self.material, self.smoothing)) {
|
||||
Some(s) => *s,
|
||||
None => {
|
||||
if self.parts >= self.limits.max_parts {
|
||||
return Err(too_large(KIND, format!("more than {} groups", self.limits.max_parts)));
|
||||
}
|
||||
self.parts += 1;
|
||||
object.parts.push(Part { name: self.group.clone(), material: self.material, smoothing: self.smoothing, ..Part::default() });
|
||||
object.slots.insert((self.group.clone(), self.material, self.smoothing), object.parts.len() - 1);
|
||||
object.parts.len() - 1
|
||||
}
|
||||
},
|
||||
};
|
||||
self.slot = Some(slot);
|
||||
Ok(object.parts.get_mut(slot))
|
||||
}
|
||||
|
||||
fn face(&mut self, rest: &[&str]) -> Result<()> {
|
||||
if rest.len() > self.limits.max_face_corners {
|
||||
return Err(too_large(KIND, format!("a face of more than {} corners", self.limits.max_face_corners)));
|
||||
}
|
||||
let corners: Option<Vec<Corner>> = rest.iter().map(|t| self.corner(t)).collect();
|
||||
let Some(corners) = corners.filter(|c| c.len() >= 3) else {
|
||||
self.bad_lines += 1;
|
||||
return Ok(());
|
||||
};
|
||||
self.triangles = self.triangles.saturating_add(corners.len() - 2);
|
||||
if self.triangles > self.limits.max_triangles {
|
||||
return Err(too_large(KIND, format!("more than {} triangles", self.limits.max_triangles)));
|
||||
}
|
||||
let Some(part) = self.part()? else { return Ok(()) };
|
||||
// Sizes are only kept once a face is not a triangle.
|
||||
if corners.len() != 3 && part.face_sizes.is_empty() {
|
||||
part.face_sizes = vec![3; part.corners.len() / 3];
|
||||
}
|
||||
if corners.len() != 3 || !part.face_sizes.is_empty() {
|
||||
part.face_sizes.push(corners.len() as u32);
|
||||
}
|
||||
part.corners.extend(corners);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn line(&mut self, line: &str, resolve: Resolver) -> Result<()> {
|
||||
let mut it = line.split_whitespace();
|
||||
let Some(key) = it.next() else { return Ok(()) };
|
||||
let after = line.get(key.len()..).unwrap_or("").trim();
|
||||
match key {
|
||||
"v" => self.vertex(&it.collect::<Vec<_>>())?,
|
||||
"vt" => {
|
||||
self.pool_room(self.vt.len(), "texture coordinates")?;
|
||||
// `vt u` alone is a 1D coordinate; OBJ's v axis points up the image.
|
||||
let uv = numbers::<2>(it.chain(["0"]));
|
||||
self.vt.push(uv.map_or([f32::NAN; 2], |[u, v]| [u as f32, 1.0 - v as f32]));
|
||||
self.bad_lines += usize::from(uv.is_none());
|
||||
}
|
||||
"vn" => {
|
||||
self.pool_room(self.vn.len(), "normals")?;
|
||||
let n = numbers::<3>(it);
|
||||
self.vn.push(n.unwrap_or(NAN3));
|
||||
self.bad_lines += usize::from(n.is_none());
|
||||
}
|
||||
"s" => {
|
||||
self.slot = None;
|
||||
self.smoothing = match it.next() {
|
||||
Some("off" | "0") | None => 0,
|
||||
Some("on") => 1,
|
||||
Some(v) => v.parse::<u32>().unwrap_or(0),
|
||||
};
|
||||
}
|
||||
"o" => self.start_object(after.to_string())?,
|
||||
"g" => {
|
||||
self.slot = None;
|
||||
self.group = after.to_string();
|
||||
}
|
||||
"usemtl" => self.use_material(after)?,
|
||||
"mtllib" => self.library(after, resolve)?,
|
||||
"f" => self.face(&it.collect::<Vec<_>>())?,
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Each object as a mesh holding only the vertices it uses.
|
||||
fn finish(mut self) -> Result<Scene> {
|
||||
let colored = !self.colors.is_empty();
|
||||
self.colors.resize(self.v.len(), [1.0; 4]);
|
||||
let mut meshes = Vec::new();
|
||||
for object in std::mem::take(&mut self.objects) {
|
||||
if object.parts.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut mesh = Mesh { name: object.name, parts: object.parts, ..Mesh::default() };
|
||||
// The file's order is kept within each pool.
|
||||
let used = |of: fn(&Corner) -> u32| {
|
||||
let mut u: Vec<u32> = mesh.parts.iter().flat_map(|p| p.corners.iter().map(of)).filter(|i| *i != NONE).collect();
|
||||
u.sort_unstable();
|
||||
u.dedup();
|
||||
u
|
||||
};
|
||||
let (pu, nu, tu) = (used(|c| c.position), used(|c| c.normal), used(|c| c.uv));
|
||||
let place = |u: &[u32], i: u32| if i == NONE { NONE } else { u.binary_search(&i).map_or(NONE, |k| k as u32) };
|
||||
for c in mesh.parts.iter_mut().flat_map(|p| p.corners.iter_mut()) {
|
||||
*c = Corner { position: place(&pu, c.position), normal: place(&nu, c.normal), uv: place(&tu, c.uv) };
|
||||
}
|
||||
mesh.positions = pu.iter().map(|i| self.v.get(*i as usize).copied().unwrap_or_default()).collect();
|
||||
if colored {
|
||||
mesh.colors = pu.iter().map(|i| self.colors.get(*i as usize).copied().unwrap_or([1.0; 4])).collect();
|
||||
}
|
||||
mesh.normals = nu.iter().map(|i| self.vn.get(*i as usize).copied().unwrap_or_default()).collect();
|
||||
let uvs: Vec<[f32; 2]> = tu.iter().map(|i| self.vt.get(*i as usize).copied().unwrap_or_default()).collect();
|
||||
if !uvs.is_empty() {
|
||||
mesh.uv_sets.push(uvs);
|
||||
}
|
||||
meshes.push(mesh);
|
||||
}
|
||||
if meshes.is_empty() {
|
||||
return Err(malformed(KIND, "no faces"));
|
||||
}
|
||||
let mut scene = Scene::from_meshes(meshes, std::mem::take(&mut self.scene.materials));
|
||||
scene.images = std::mem::take(&mut self.scene.images);
|
||||
scene.external = std::mem::take(&mut self.scene.external);
|
||||
scene.warnings = std::mem::take(&mut self.scene.warnings);
|
||||
count_warning(&mut scene.warnings, self.bad_lines, "vertex or face line(s) could not be read and were left out");
|
||||
Ok(scene)
|
||||
}
|
||||
}
|
||||
|
||||
/// Read an OBJ file. Its material libraries are asked of `resolve` by the name the file
|
||||
/// writes; every name asked, and every image a library names, is listed in
|
||||
/// [`Scene::external`].
|
||||
pub fn read(bytes: &[u8], limits: &Limits, resolve: Resolver) -> Result<Scene> {
|
||||
let text = String::from_utf8_lossy(bytes);
|
||||
let mut r = Reader {
|
||||
limits,
|
||||
v: vec![],
|
||||
colors: vec![],
|
||||
vt: vec![],
|
||||
vn: vec![],
|
||||
objects: vec![],
|
||||
scene: Scene::default(),
|
||||
material_ids: HashMap::new(),
|
||||
material: None,
|
||||
group: String::new(),
|
||||
smoothing: 0,
|
||||
slot: None,
|
||||
triangles: 0,
|
||||
parts: 0,
|
||||
bad_lines: 0,
|
||||
data_bytes: 0,
|
||||
};
|
||||
// A line ending in `\` continues on the next.
|
||||
let mut joined = String::new();
|
||||
for raw in text.lines() {
|
||||
let line = raw.split('#').next().unwrap_or("");
|
||||
if let Some(head) = line.trim_end().strip_suffix('\\') {
|
||||
joined.push_str(head);
|
||||
joined.push(' ');
|
||||
continue;
|
||||
}
|
||||
if joined.is_empty() {
|
||||
r.line(line.trim(), resolve)?;
|
||||
} else {
|
||||
joined.push_str(line);
|
||||
r.line(joined.trim(), resolve)?;
|
||||
joined.clear();
|
||||
}
|
||||
}
|
||||
r.line(joined.trim(), resolve)?;
|
||||
r.finish()
|
||||
}
|
||||
|
||||
/// A name as one OBJ token: whitespace and control characters become `_`.
|
||||
fn token(name: &str) -> String {
|
||||
let t: String = name.trim().chars().map(|c| if c.is_whitespace() || c.is_control() { '_' } else { c }).collect();
|
||||
if t.is_empty() { "material".into() } else { t }
|
||||
}
|
||||
|
||||
/// The name each material is written with: its [`token`], made unique with `_2`, `_3`… where
|
||||
/// two would be the same, also ignoring case (the names may become file names).
|
||||
pub fn material_names(scene: &Scene) -> Vec<String> {
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
scene
|
||||
.materials
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let base = token(&m.name);
|
||||
let mut unique = base.clone();
|
||||
let mut n = 1u32;
|
||||
while !seen.insert(unique.to_lowercase()) {
|
||||
n = n.saturating_add(1);
|
||||
unique = format!("{base}_{n}");
|
||||
}
|
||||
unique
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The scene as OBJ text: every mesh placed by its nodes as an object (`o`), with its vertex
|
||||
/// colours, first UV set and normals, a group (`g`), material (`usemtl`) and smoothing group
|
||||
/// (`s`) per part, and polygons kept whole. `mtllib` is the material library's file name, to
|
||||
/// be written next to the OBJ from [`write_mtl`]; `None` writes no `mtllib` line.
|
||||
pub fn write(scene: &Scene, mtllib: Option<&str>, limits: &Limits) -> Result<String> {
|
||||
let mut text = String::new();
|
||||
if let Some(lib) = mtllib.filter(|_| !scene.materials.is_empty()) {
|
||||
let _ = writeln!(text, "mtllib {}", lib.replace(['\n', '\r'], " "));
|
||||
}
|
||||
let materials = material_names(scene);
|
||||
// OBJ indices run on across objects.
|
||||
let (mut vbase, mut tbase, mut nbase) = (1u64, 1u64, 1u64);
|
||||
let mut smoothing = 0;
|
||||
for (index, world) in scene.instances(limits)? {
|
||||
let Some(mesh) = scene.meshes.get(index) else { continue };
|
||||
mesh.validate()?;
|
||||
if !mesh.name.trim().is_empty() {
|
||||
let _ = writeln!(text, "o {}", mesh.name.trim().replace(|c: char| c.is_control(), " "));
|
||||
}
|
||||
let colors = mesh.colors.len() == mesh.positions.len();
|
||||
let mirror = is_mirror(&world);
|
||||
for (i, p) in mesh.positions.iter().enumerate() {
|
||||
let p = transform_point(&world, *p);
|
||||
let _ = write!(text, "v {} {} {}", p[0], p[1], p[2]);
|
||||
if let Some(c) = mesh.colors.get(i).filter(|_| colors) {
|
||||
let _ = write!(text, " {} {} {}", c[0], c[1], c[2]);
|
||||
}
|
||||
text.push('\n');
|
||||
}
|
||||
let uvs = mesh.uv_sets.first().map_or(&[][..], Vec::as_slice);
|
||||
for t in uvs {
|
||||
let _ = writeln!(text, "vt {} {}", t[0], 1.0 - t[1]);
|
||||
}
|
||||
for n in &mesh.normals {
|
||||
let n = transform_normal(&world, *n);
|
||||
let _ = writeln!(text, "vn {} {} {}", n[0], n[1], n[2]);
|
||||
}
|
||||
for part in &mesh.parts {
|
||||
if !part.name.trim().is_empty() {
|
||||
let _ = writeln!(text, "g {}", part.name.trim().replace(|c: char| c.is_control(), " "));
|
||||
}
|
||||
if let Some(m) = part.material.and_then(|m| materials.get(m)) {
|
||||
let _ = writeln!(text, "usemtl {m}");
|
||||
}
|
||||
if part.smoothing != smoothing {
|
||||
smoothing = part.smoothing;
|
||||
let _ = if smoothing == 0 { writeln!(text, "s off") } else { writeln!(text, "s {smoothing}") };
|
||||
}
|
||||
for face in part.faces() {
|
||||
text.push('f');
|
||||
// A mirrored placement turns the winding; turn it back so the face faces out.
|
||||
let mut face = face.to_vec();
|
||||
if mirror {
|
||||
face.reverse();
|
||||
}
|
||||
for c in face {
|
||||
let v = vbase + u64::from(c.position);
|
||||
let _ = match (c.uv != NONE, c.normal != NONE) {
|
||||
(true, true) => write!(text, " {v}/{}/{}", tbase + u64::from(c.uv), nbase + u64::from(c.normal)),
|
||||
(true, false) => write!(text, " {v}/{}", tbase + u64::from(c.uv)),
|
||||
(false, true) => write!(text, " {v}//{}", nbase + u64::from(c.normal)),
|
||||
(false, false) => write!(text, " {v}"),
|
||||
};
|
||||
}
|
||||
text.push('\n');
|
||||
}
|
||||
}
|
||||
vbase += mesh.positions.len() as u64;
|
||||
tbase += uvs.len() as u64;
|
||||
nbase += mesh.normals.len() as u64;
|
||||
}
|
||||
if vbase == 1 {
|
||||
return Err(Error::Malformed("OBJ: the scene has no placed mesh to write".into()));
|
||||
}
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
/// The scene's materials as an MTL library, named as [`write`] names them: `Kd`, `d`, `Ke`,
|
||||
/// `Ns` (for readers without the PBR extension) with `Pr` and `Pm`, and `map_Kd`, `map_Ke`
|
||||
/// and `norm` for images that have a [`Image::uri`].
|
||||
pub fn write_mtl(scene: &Scene) -> String {
|
||||
let mut mtl = String::new();
|
||||
for (m, name) in scene.materials.iter().zip(material_names(scene)) {
|
||||
let kd = [m.base_color[0], m.base_color[1], m.base_color[2]].map(linear_to_srgb);
|
||||
let ke = m.emissive.map(linear_to_srgb);
|
||||
let r = m.roughness.clamp(0.0, 1.0);
|
||||
let ns = if r > 0.0 { (2.0 / (r * r) - 2.0).clamp(0.0, 1000.0) } else { 1000.0 };
|
||||
let _ = writeln!(
|
||||
mtl,
|
||||
"newmtl {name}\nKd {} {} {}\nd {}\nKe {} {} {}\nNs {ns}\nPr {r}\nPm {}",
|
||||
kd[0], kd[1], kd[2], m.base_color[3], ke[0], ke[1], ke[2], m.metallic
|
||||
);
|
||||
for (key, tex) in [("map_Kd", m.base_color_texture), ("map_Ke", m.emissive_texture), ("norm", m.normal_texture)] {
|
||||
if let Some(uri) = tex.and_then(|t| scene.images.get(t.image)).and_then(|i| i.uri.as_deref()) {
|
||||
let _ = writeln!(mtl, "{key} {}", uri.replace(['\n', '\r'], " "));
|
||||
}
|
||||
}
|
||||
mtl.push('\n');
|
||||
}
|
||||
mtl
|
||||
}
|
||||
@@ -0,0 +1,529 @@
|
||||
//! PLY (polygon file format), read.
|
||||
//!
|
||||
//! From the format's public description: a text header naming elements and their properties,
|
||||
//! then the elements' rows in ASCII or binary (little- or big-endian). [`read`] takes the
|
||||
//! `vertex` element (`x`, `y`, `z` of any numeric type; `nx`, `ny`, `nz`; `s`/`t`, `u`/`v` or
|
||||
//! `texture_u`/`texture_v`; `red`, `green`, `blue` and `alpha`) and the `face` element's
|
||||
//! `vertex_indices` list, polygons kept whole. Every other element and property is passed
|
||||
//! over. A file without faces is a point cloud: a mesh with positions and no parts.
|
||||
//!
|
||||
//! Positions are kept as stored, so the indices of a point cloud stay the file's; a face that
|
||||
//! names a vertex that is not there, or one whose position is not finite, is left out and
|
||||
//! counted in a warning.
|
||||
//!
|
||||
//! [`parse_header`] and [`Rows`] are public for callers that need more than a mesh from a PLY
|
||||
//! file (every scalar property as a column, `edge` elements, thinning a large cloud): they
|
||||
//! read the same header and rows with the same checks.
|
||||
|
||||
use crate::{Corner, Limits, Mesh, Part, Result, Scene, count_warning, malformed, too_large};
|
||||
|
||||
const KIND: &str = "PLY";
|
||||
/// Largest header read (bytes).
|
||||
pub const MAX_HEADER: usize = 1 << 20;
|
||||
/// Most elements a header may declare.
|
||||
pub const MAX_ELEMENTS: usize = 64;
|
||||
/// Most properties one element may have.
|
||||
pub const MAX_PROPERTIES: usize = 256;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Scalar {
|
||||
I8,
|
||||
U8,
|
||||
I16,
|
||||
U16,
|
||||
I32,
|
||||
U32,
|
||||
F32,
|
||||
F64,
|
||||
}
|
||||
|
||||
impl Scalar {
|
||||
fn parse(s: &str) -> Option<Scalar> {
|
||||
Some(match s {
|
||||
"char" | "int8" => Scalar::I8,
|
||||
"uchar" | "uint8" => Scalar::U8,
|
||||
"short" | "int16" => Scalar::I16,
|
||||
"ushort" | "uint16" => Scalar::U16,
|
||||
"int" | "int32" => Scalar::I32,
|
||||
"uint" | "uint32" => Scalar::U32,
|
||||
"float" | "float32" => Scalar::F32,
|
||||
"double" | "float64" => Scalar::F64,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Bytes of one value in a binary file.
|
||||
pub fn size(self) -> usize {
|
||||
match self {
|
||||
Scalar::I8 | Scalar::U8 => 1,
|
||||
Scalar::I16 | Scalar::U16 => 2,
|
||||
Scalar::I32 | Scalar::U32 | Scalar::F32 => 4,
|
||||
Scalar::F64 => 8,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_integer(self) -> bool {
|
||||
!matches!(self, Scalar::F32 | Scalar::F64)
|
||||
}
|
||||
|
||||
/// The value at the start of `b`.
|
||||
pub fn read(self, b: &[u8], big: bool) -> Option<f64> {
|
||||
fn take<const N: usize>(b: &[u8], big: bool) -> Option<[u8; N]> {
|
||||
let mut a: [u8; N] = b.get(..N)?.try_into().ok()?;
|
||||
if big {
|
||||
a.reverse();
|
||||
}
|
||||
Some(a)
|
||||
}
|
||||
Some(match self {
|
||||
Scalar::I8 => f64::from(i8::from_le_bytes(take(b, big)?)),
|
||||
Scalar::U8 => f64::from(*b.first()?),
|
||||
Scalar::I16 => f64::from(i16::from_le_bytes(take(b, big)?)),
|
||||
Scalar::U16 => f64::from(u16::from_le_bytes(take(b, big)?)),
|
||||
Scalar::I32 => f64::from(i32::from_le_bytes(take(b, big)?)),
|
||||
Scalar::U32 => f64::from(u32::from_le_bytes(take(b, big)?)),
|
||||
Scalar::F32 => f64::from(f32::from_le_bytes(take(b, big)?)),
|
||||
Scalar::F64 => f64::from_le_bytes(take(b, big)?),
|
||||
})
|
||||
}
|
||||
|
||||
/// A colour channel of this type as 0..1: integers by their range, floats as they are.
|
||||
pub fn unit(self, v: f64) -> f32 {
|
||||
let v = match self {
|
||||
Scalar::F32 | Scalar::F64 => v,
|
||||
Scalar::U8 | Scalar::I8 => v / 255.0,
|
||||
Scalar::U16 | Scalar::I16 => v / 65_535.0,
|
||||
Scalar::U32 | Scalar::I32 => v / 4_294_967_295.0,
|
||||
};
|
||||
if v.is_finite() { v.clamp(0.0, 1.0) as f32 } else { 0.0 }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Kind {
|
||||
Scalar(Scalar),
|
||||
/// The count's type and the items' type.
|
||||
List(Scalar, Scalar),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Property {
|
||||
pub name: String,
|
||||
pub kind: Kind,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Element {
|
||||
pub name: String,
|
||||
/// Rows the header declares; not yet checked against the file.
|
||||
pub count: u64,
|
||||
pub properties: Vec<Property>,
|
||||
}
|
||||
|
||||
impl Element {
|
||||
/// Bytes per row in a binary file, when every property is a scalar.
|
||||
pub fn row_size(&self) -> Option<usize> {
|
||||
self.properties.iter().try_fold(0usize, |n, p| match p.kind {
|
||||
Kind::Scalar(s) => n.checked_add(s.size()),
|
||||
Kind::List(..) => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The first scalar property with one of `names` (ASCII case ignored), and its type.
|
||||
pub fn find(&self, names: &[&str]) -> Option<(usize, Scalar)> {
|
||||
names.iter().find_map(|n| {
|
||||
self.properties.iter().enumerate().find_map(|(i, p)| match p.kind {
|
||||
Kind::Scalar(s) if p.name.eq_ignore_ascii_case(n) => Some((i, s)),
|
||||
_ => None,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Encoding {
|
||||
Ascii,
|
||||
Little,
|
||||
Big,
|
||||
}
|
||||
|
||||
/// What the header says, and where the rows start.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Header {
|
||||
pub encoding: Encoding,
|
||||
pub elements: Vec<Element>,
|
||||
/// Offset of the first row.
|
||||
pub body: usize,
|
||||
/// The text of the `comment` lines.
|
||||
pub comments: Vec<String>,
|
||||
}
|
||||
|
||||
impl Header {
|
||||
/// Rows the header declares for the element `name` (0 when it has none).
|
||||
pub fn count(&self, name: &str) -> u64 {
|
||||
self.elements.iter().find(|e| e.name == name).map_or(0, |e| e.count)
|
||||
}
|
||||
}
|
||||
|
||||
/// Does `bytes` start like a PLY file?
|
||||
pub fn is_ply(bytes: &[u8]) -> bool {
|
||||
bytes.starts_with(b"ply") && bytes.get(3).is_some_and(|b| matches!(b, b'\n' | b'\r'))
|
||||
}
|
||||
|
||||
fn property(e: &mut Element, it: &mut std::str::SplitWhitespace<'_>) -> Result<()> {
|
||||
if e.properties.len() >= MAX_PROPERTIES {
|
||||
return Err(too_large(KIND, format!("more than {MAX_PROPERTIES} properties in an element")));
|
||||
}
|
||||
let first = it.next().unwrap_or("");
|
||||
let kind = if first == "list" {
|
||||
let count = it.next().and_then(Scalar::parse).ok_or_else(|| malformed(KIND, "a list property without a count type"))?;
|
||||
let item = it.next().and_then(Scalar::parse).ok_or_else(|| malformed(KIND, "a list property without an item type"))?;
|
||||
if !count.is_integer() {
|
||||
return Err(malformed(KIND, "a list's count must be an integer"));
|
||||
}
|
||||
Kind::List(count, item)
|
||||
} else {
|
||||
Kind::Scalar(Scalar::parse(first).ok_or_else(|| malformed(KIND, format!("unknown property type `{}`", short(first))))?)
|
||||
};
|
||||
let name = it.next().ok_or_else(|| malformed(KIND, "a property without a name"))?.to_string();
|
||||
e.properties.push(Property { name, kind });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn short(s: &str) -> String {
|
||||
s.chars().take(32).collect()
|
||||
}
|
||||
|
||||
/// Read the header: the encoding, the elements with their properties, and where the rows start.
|
||||
pub fn parse_header(bytes: &[u8]) -> Result<Header> {
|
||||
if !is_ply(bytes) {
|
||||
return Err(malformed(KIND, "not a PLY file (it must start with `ply`)"));
|
||||
}
|
||||
let head = bytes.get(..bytes.len().min(MAX_HEADER)).unwrap_or(bytes);
|
||||
// `end_header` at the start of a line, so a comment that mentions it does not end the header.
|
||||
let end = head
|
||||
.windows(11)
|
||||
.position(|w| matches!(w.first(), Some(b'\n' | b'\r')) && w.get(1..) == Some(b"end_header"))
|
||||
.map(|p| p + 1)
|
||||
.ok_or_else(|| malformed(KIND, "no `end_header` in the first megabyte"))?;
|
||||
// The rows start after the end of that line (`\n`, `\r\n` or a lone `\r`).
|
||||
let mut body = end + 10;
|
||||
while head.get(body).is_some_and(|b| *b == b' ' || *b == b'\t') {
|
||||
body += 1;
|
||||
}
|
||||
match (head.get(body), head.get(body + 1)) {
|
||||
(Some(b'\r'), Some(b'\n')) => body += 2,
|
||||
(Some(b'\n' | b'\r'), _) => body += 1,
|
||||
(None, _) => {}
|
||||
_ => return Err(malformed(KIND, "`end_header` is not on a line of its own")),
|
||||
}
|
||||
let text = String::from_utf8_lossy(head.get(..end).unwrap_or_default());
|
||||
let mut encoding = None;
|
||||
let mut elements: Vec<Element> = Vec::new();
|
||||
let mut comments = Vec::new();
|
||||
for line in text.split(['\n', '\r']).skip(1) {
|
||||
if let Some(c) = line.strip_prefix("comment ") {
|
||||
comments.push(c.trim().to_string());
|
||||
continue;
|
||||
}
|
||||
let mut it = line.split_whitespace();
|
||||
match it.next() {
|
||||
Some("format") => {
|
||||
encoding = Some(match it.next() {
|
||||
Some("ascii") => Encoding::Ascii,
|
||||
Some("binary_little_endian") => Encoding::Little,
|
||||
Some("binary_big_endian") => Encoding::Big,
|
||||
other => return Err(malformed(KIND, format!("unknown format `{}`", short(other.unwrap_or(""))))),
|
||||
});
|
||||
}
|
||||
Some("element") => {
|
||||
let name = it.next().ok_or_else(|| malformed(KIND, "an element without a name"))?.to_string();
|
||||
let count = it
|
||||
.next()
|
||||
.and_then(|c| c.parse::<u64>().ok())
|
||||
.ok_or_else(|| malformed(KIND, format!("element `{}` has no count", short(&name))))?;
|
||||
if elements.len() >= MAX_ELEMENTS {
|
||||
return Err(too_large(KIND, format!("more than {MAX_ELEMENTS} elements")));
|
||||
}
|
||||
elements.push(Element { name, count, properties: Vec::new() });
|
||||
}
|
||||
Some("property") => property(elements.last_mut().ok_or_else(|| malformed(KIND, "a property before any element"))?, &mut it)?,
|
||||
// `comment`, `obj_info` and blank lines.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let encoding = encoding.ok_or_else(|| malformed(KIND, "no `format` line"))?;
|
||||
Ok(Header { encoding, elements, body, comments })
|
||||
}
|
||||
|
||||
fn space(b: &u8) -> bool {
|
||||
b.is_ascii_whitespace()
|
||||
}
|
||||
|
||||
/// The values of the rows, read one after another in the header's order.
|
||||
pub enum Rows<'a> {
|
||||
Binary { b: &'a [u8], pos: usize, big: bool },
|
||||
Ascii { tokens: std::slice::Split<'a, u8, fn(&u8) -> bool>, left: usize },
|
||||
}
|
||||
|
||||
impl<'a> Rows<'a> {
|
||||
pub fn new(h: &Header, bytes: &'a [u8]) -> Rows<'a> {
|
||||
let b = bytes.get(h.body..).unwrap_or_default();
|
||||
match h.encoding {
|
||||
Encoding::Ascii => Rows::Ascii { tokens: b.split(space as fn(&u8) -> bool), left: b.len() },
|
||||
Encoding::Little => Rows::Binary { b, pos: 0, big: false },
|
||||
Encoding::Big => Rows::Binary { b, pos: 0, big: true },
|
||||
}
|
||||
}
|
||||
|
||||
/// Bytes not yet read. Every row takes at least one, so an element that declares more
|
||||
/// rows than this is damaged.
|
||||
pub fn remaining(&self) -> usize {
|
||||
match self {
|
||||
Rows::Binary { b, pos, .. } => b.len().saturating_sub(*pos),
|
||||
Rows::Ascii { left, .. } => *left,
|
||||
}
|
||||
}
|
||||
|
||||
/// The next value, of type `t`.
|
||||
pub fn value(&mut self, t: Scalar) -> Result<f64> {
|
||||
match self {
|
||||
Rows::Binary { b, pos, big } => {
|
||||
let v = b.get(*pos..).and_then(|r| t.read(r, *big)).ok_or_else(|| malformed(KIND, "the file ends early"))?;
|
||||
*pos += t.size();
|
||||
Ok(v)
|
||||
}
|
||||
Rows::Ascii { tokens, left } => loop {
|
||||
let tok = tokens.next().ok_or_else(|| malformed(KIND, "the file ends early"))?;
|
||||
*left = left.saturating_sub(tok.len() + 1);
|
||||
if tok.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let s = std::str::from_utf8(tok).map_err(|_| malformed(KIND, "a value is not a number"))?;
|
||||
// Integers written as decimals (`255.0`) are taken too.
|
||||
return s.parse::<f64>().map_err(|_| malformed(KIND, format!("`{}` is not a number", short(s))));
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Pass over `n` values of type `t`.
|
||||
pub fn skip(&mut self, t: Scalar, n: usize) -> Result<()> {
|
||||
match self {
|
||||
Rows::Binary { b, pos, .. } => {
|
||||
*pos = n
|
||||
.checked_mul(t.size())
|
||||
.and_then(|k| pos.checked_add(k))
|
||||
.filter(|p| *p <= b.len())
|
||||
.ok_or_else(|| malformed(KIND, "the file ends early"))?;
|
||||
Ok(())
|
||||
}
|
||||
Rows::Ascii { .. } => (0..n).try_for_each(|_| self.value(t).map(|_| ())),
|
||||
}
|
||||
}
|
||||
|
||||
/// A list's length, of type `t`, at most `max`.
|
||||
pub fn count(&mut self, t: Scalar, max: usize) -> Result<usize> {
|
||||
let n = self.value(t)?;
|
||||
if !(n >= 0.0 && n <= max as f64 && n.fract() == 0.0) {
|
||||
return Err(too_large(KIND, format!("a list of more than {max} items (or a damaged length)")));
|
||||
}
|
||||
Ok(n as usize)
|
||||
}
|
||||
|
||||
/// Checks that the rows of `e` can be in what is left of the file, before any is read: a
|
||||
/// row takes at least a byte, and exactly [`Element::row_size`] in a binary file.
|
||||
pub fn check(&self, e: &Element) -> Result<()> {
|
||||
let per_row = match self {
|
||||
Rows::Binary { .. } => e.row_size().unwrap_or(1).max(1),
|
||||
Rows::Ascii { .. } => 1,
|
||||
};
|
||||
match e.count.checked_mul(per_row as u64) {
|
||||
Some(n) if n <= self.remaining() as u64 => Ok(()),
|
||||
_ => Err(malformed(KIND, format!("the file is too short for its {} `{}` rows", e.count, short(&e.name)))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Pass over all rows of `e`.
|
||||
pub fn skip_element(&mut self, e: &Element, max_list: usize) -> Result<()> {
|
||||
self.check(e)?;
|
||||
if let (Rows::Binary { pos, .. }, Some(size)) = (&mut *self, e.row_size()) {
|
||||
// `check` has shown that the block fits.
|
||||
*pos = pos.saturating_add((e.count as usize).saturating_mul(size));
|
||||
return Ok(());
|
||||
}
|
||||
for _ in 0..e.count {
|
||||
for p in &e.properties {
|
||||
match p.kind {
|
||||
Kind::Scalar(t) => self.skip(t, 1)?,
|
||||
Kind::List(c, t) => {
|
||||
let n = self.count(c, max_list)?;
|
||||
self.skip(t, n)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the vertex element keeps what a mesh takes.
|
||||
struct VertexColumns {
|
||||
xyz: [usize; 3],
|
||||
normal: Option<[usize; 3]>,
|
||||
uv: Option<[usize; 2]>,
|
||||
rgb: Option<[(usize, Scalar); 3]>,
|
||||
alpha: Option<(usize, Scalar)>,
|
||||
}
|
||||
|
||||
impl VertexColumns {
|
||||
fn of(e: &Element) -> Result<VertexColumns> {
|
||||
let at = |names: &[&str]| e.find(names).map(|(i, _)| i);
|
||||
let three = |a: &[&str], b: &[&str], c: &[&str]| Some([at(a)?, at(b)?, at(c)?]);
|
||||
let xyz = three(&["x"], &["y"], &["z"]).ok_or_else(|| malformed(KIND, "the vertices have no `x`, `y` and `z`"))?;
|
||||
let uv = (|| Some([at(&["s", "u", "texture_u"])?, at(&["t", "v", "texture_v"])?]))();
|
||||
let rgb =
|
||||
(|| Some([e.find(&["red", "r", "diffuse_red"])?, e.find(&["green", "g", "diffuse_green"])?, e.find(&["blue", "b", "diffuse_blue"])?]))();
|
||||
Ok(VertexColumns { xyz, normal: three(&["nx"], &["ny"], &["nz"]), uv, rgb, alpha: e.find(&["alpha", "a"]) })
|
||||
}
|
||||
}
|
||||
|
||||
fn vertices(e: &Element, rows: &mut Rows<'_>, mesh: &mut Mesh, limits: &Limits) -> Result<()> {
|
||||
let w = VertexColumns::of(e)?;
|
||||
if e.count > limits.max_vertices as u64 {
|
||||
return Err(too_large(KIND, format!("{} vertices (limit {})", e.count, limits.max_vertices)));
|
||||
}
|
||||
rows.check(e)?;
|
||||
let mut uvs = Vec::new();
|
||||
let mut slots = vec![0.0f64; e.properties.len()];
|
||||
for _ in 0..e.count {
|
||||
for (p, slot) in e.properties.iter().zip(&mut slots) {
|
||||
match p.kind {
|
||||
Kind::Scalar(t) => *slot = rows.value(t)?,
|
||||
Kind::List(c, t) => {
|
||||
let n = rows.count(c, limits.max_face_corners)?;
|
||||
rows.skip(t, n)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
let v = |i: usize| slots.get(i).copied().unwrap_or(0.0);
|
||||
mesh.positions.push(w.xyz.map(v));
|
||||
if let Some(n) = w.normal {
|
||||
mesh.normals.push(n.map(v));
|
||||
}
|
||||
if let Some([s, t]) = w.uv {
|
||||
// PLY texture coordinates have their origin at the bottom left.
|
||||
uvs.push([v(s) as f32, 1.0 - v(t) as f32]);
|
||||
}
|
||||
if let Some(rgb) = w.rgb {
|
||||
let c = rgb.map(|(i, t)| t.unit(v(i)));
|
||||
mesh.colors.push([c[0], c[1], c[2], w.alpha.map_or(1.0, |(i, t)| t.unit(v(i)))]);
|
||||
}
|
||||
}
|
||||
if w.uv.is_some() {
|
||||
mesh.uv_sets.push(uvs);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct Faces {
|
||||
part: Part,
|
||||
triangles: usize,
|
||||
bad: usize,
|
||||
}
|
||||
|
||||
fn faces(e: &Element, list: usize, rows: &mut Rows<'_>, vertex_count: u64, out: &mut Faces, limits: &Limits) -> Result<()> {
|
||||
rows.check(e)?;
|
||||
let mut face: Vec<f64> = Vec::new();
|
||||
for _ in 0..e.count {
|
||||
for (i, p) in e.properties.iter().enumerate() {
|
||||
match p.kind {
|
||||
Kind::Scalar(t) => rows.skip(t, 1)?,
|
||||
Kind::List(c, t) if i == list => {
|
||||
let n = rows.count(c, limits.max_face_corners)?;
|
||||
face.clear();
|
||||
for _ in 0..n {
|
||||
face.push(rows.value(t)?);
|
||||
}
|
||||
}
|
||||
Kind::List(c, t) => {
|
||||
let n = rows.count(c, limits.max_face_corners)?;
|
||||
rows.skip(t, n)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
let corners: Option<Vec<Corner>> = face
|
||||
.iter()
|
||||
.map(|v| (*v >= 0.0 && *v < vertex_count as f64 && v.fract() == 0.0).then(|| u32::try_from(*v as u64).ok().map(Corner::at)).flatten())
|
||||
.collect();
|
||||
let Some(corners) = corners.filter(|c| c.len() >= 3) else {
|
||||
out.bad += 1;
|
||||
continue;
|
||||
};
|
||||
out.triangles = out.triangles.saturating_add(corners.len() - 2);
|
||||
if out.triangles > limits.max_triangles {
|
||||
return Err(too_large(KIND, format!("more than {} triangles", limits.max_triangles)));
|
||||
}
|
||||
out.part.face_sizes.push(corners.len() as u32);
|
||||
out.part.corners.extend(corners);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read a PLY file into a scene of one mesh (unnamed: PLY has no names). The elements may come
|
||||
/// in any order.
|
||||
pub fn read(bytes: &[u8], limits: &Limits) -> Result<Scene> {
|
||||
let h = parse_header(bytes)?;
|
||||
let vertex_count = h.elements.iter().find(|e| e.name == "vertex").ok_or_else(|| malformed(KIND, "no `vertex` element"))?.count;
|
||||
let mut rows = Rows::new(&h, bytes);
|
||||
let mut mesh = Mesh::default();
|
||||
let mut out = Faces { part: Part::default(), triangles: 0, bad: 0 };
|
||||
let (mut seen_vertices, mut seen_faces) = (false, false);
|
||||
for e in &h.elements {
|
||||
let list = e.properties.iter().position(|p| matches!(p.name.as_str(), "vertex_indices" | "vertex_index") && matches!(p.kind, Kind::List(..)));
|
||||
match (e.name.as_str(), list) {
|
||||
("vertex", _) if !seen_vertices => {
|
||||
seen_vertices = true;
|
||||
vertices(e, &mut rows, &mut mesh, limits)?;
|
||||
}
|
||||
("face", Some(list)) if !seen_faces => {
|
||||
seen_faces = true;
|
||||
faces(e, list, &mut rows, vertex_count, &mut out, limits)?;
|
||||
}
|
||||
_ => rows.skip_element(e, limits.max_face_corners)?,
|
||||
}
|
||||
// Nothing after the vertices and faces is wanted.
|
||||
if seen_vertices && (seen_faces || !h.elements.iter().any(|e| e.name == "face")) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Faces that name a vertex whose position is not a number are left out.
|
||||
let finite = |c: &Corner| mesh.positions.get(c.position as usize).is_some_and(|p| p.iter().all(|x| x.is_finite()));
|
||||
let mut part = out.part;
|
||||
if !mesh.positions.iter().flatten().all(|x| x.is_finite()) {
|
||||
let mut kept = Part::default();
|
||||
for f in part.faces() {
|
||||
if f.iter().all(finite) {
|
||||
kept.face_sizes.push(f.len() as u32);
|
||||
kept.corners.extend(f);
|
||||
} else {
|
||||
out.bad += 1;
|
||||
}
|
||||
}
|
||||
part = kept;
|
||||
}
|
||||
if !mesh.normals.is_empty() {
|
||||
part.corners.iter_mut().for_each(|c| c.normal = c.position);
|
||||
}
|
||||
if !mesh.uv_sets.is_empty() {
|
||||
part.corners.iter_mut().for_each(|c| c.uv = c.position);
|
||||
}
|
||||
if part.face_sizes.iter().all(|n| *n == 3) {
|
||||
part.face_sizes.clear();
|
||||
}
|
||||
if !part.corners.is_empty() {
|
||||
mesh.parts.push(part);
|
||||
}
|
||||
let mut scene = Scene::from_meshes(vec![mesh], Vec::new());
|
||||
count_warning(&mut scene.warnings, out.bad, "face(s) could not be read and were left out");
|
||||
Ok(scene)
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
//! STL (stereolithography) meshes, binary and ASCII, read and written.
|
||||
//!
|
||||
//! From the public description of the format (3D Systems' StereoLithography Interface
|
||||
//! Specification, 1989, and its widely documented colour extensions):
|
||||
//!
|
||||
//! - Binary: an 80-byte header, a little-endian `u32` facet count, then 50 bytes per facet: a
|
||||
//! normal and three vertices (12 `f32`) and a 16-bit attribute word. Bytes after the last
|
||||
//! facet are ignored. A count of 0 over a file that holds facets (some writers leave it
|
||||
//! unset) is read for the facets that are there, with a warning; a count larger than the
|
||||
//! file holds is a truncated file and an error.
|
||||
//! - ASCII: `solid name`, then `facet normal nx ny nz` / `outer loop` / `vertex x y z`… /
|
||||
//! `endloop` / `endfacet`, closed by `endsolid`; several solids may follow each other.
|
||||
//! Loops with more than three vertices are fan-triangulated.
|
||||
//! - Colour (binary only): with `COLOR=r g b a` (four bytes) in the header (Materialise Magics),
|
||||
//! that is the part's colour and a facet whose attribute has bit 15 clear has its own colour
|
||||
//! (red in bits 0–4, green 5–9, blue 10–14); otherwise (VisCAM / SolidView) a facet with bit 15
|
||||
//! set has its own colour (blue in bits 0–4, green 5–9, red 10–14). Each colour becomes a
|
||||
//! plain material named `#RRGGBB` and a [`Part`] of the mesh; uncoloured files get neither.
|
||||
//!
|
||||
//! Detection: a file whose size is exactly what its binary header says is binary (many binary
|
||||
//! headers begin with `solid`); otherwise a file that starts with `solid` and has a `facet` or
|
||||
//! `endsolid` is ASCII; otherwise it is binary.
|
||||
//!
|
||||
//! STL has no units, no UVs and no shared vertices: every facet keeps its own three positions.
|
||||
//! Its normal is the face normal of its counter-clockwise winding; the stored normal is used
|
||||
//! only when the triangle is degenerate (stored normals are often zero or wrong). Facets with a
|
||||
//! non-finite coordinate are left out with a warning.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use crate::{
|
||||
Corner, Error, Limits, Material, Mesh, Part, Result, Scene, count_warning, face_normal, is_mirror, malformed, srgb_to_linear, too_large,
|
||||
transform_point,
|
||||
};
|
||||
|
||||
const KIND: &str = "STL";
|
||||
/// Header (80) plus facet count (4).
|
||||
const HEADER: usize = 84;
|
||||
/// Bytes per binary facet.
|
||||
const FACET: usize = 50;
|
||||
|
||||
/// Does `bytes` look like an ASCII STL (`solid` first, and a `facet` or `endsolid` in the first
|
||||
/// 512 bytes)? Binary STL has no signature to test for.
|
||||
pub fn is_ascii(bytes: &[u8]) -> bool {
|
||||
let head = bytes.get(..bytes.len().min(512)).unwrap_or(&[]);
|
||||
let text = String::from_utf8_lossy(head);
|
||||
let t = text.trim_start();
|
||||
t.get(..5).is_some_and(|k| k.eq_ignore_ascii_case("solid")) && (t.contains("facet") || t.contains("endsolid"))
|
||||
}
|
||||
|
||||
/// Read an STL file (binary or ASCII, detected from the contents) into a scene of one mesh.
|
||||
pub fn read(bytes: &[u8], limits: &Limits) -> Result<Scene> {
|
||||
let count = bytes.get(80..HEADER).and_then(|b| <[u8; 4]>::try_from(b).ok()).map(|b| u32::from_le_bytes(b) as usize);
|
||||
let needed = count.and_then(|n| n.checked_mul(FACET)).and_then(|n| n.checked_add(HEADER));
|
||||
if needed != Some(bytes.len()) && is_ascii(bytes) {
|
||||
return ascii(bytes, limits);
|
||||
}
|
||||
match count {
|
||||
Some(count) => binary(bytes, count, limits),
|
||||
None => Err(malformed(KIND, "neither a binary file (shorter than its 84-byte header) nor an ASCII one (no `solid`)")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Facets grouped into parts by colour, in order of first use.
|
||||
struct Builder<'a> {
|
||||
mesh: Mesh,
|
||||
colors: Vec<Option<[u8; 3]>>,
|
||||
slots: HashMap<Option<[u8; 3]>, usize>,
|
||||
facets: usize,
|
||||
skipped: usize,
|
||||
limits: &'a Limits,
|
||||
}
|
||||
|
||||
impl<'a> Builder<'a> {
|
||||
fn new(limits: &'a Limits) -> Self {
|
||||
Builder { mesh: Mesh::default(), colors: Vec::new(), slots: HashMap::new(), facets: 0, skipped: 0, limits }
|
||||
}
|
||||
|
||||
/// Add one triangle; `stored` is the file's facet normal.
|
||||
fn tri(&mut self, color: Option<[u8; 3]>, stored: [f64; 3], v: [[f64; 3]; 3]) -> Result<()> {
|
||||
if v.iter().flatten().any(|c| !c.is_finite()) {
|
||||
self.skipped += 1;
|
||||
return Ok(());
|
||||
}
|
||||
if self.facets >= self.limits.max_triangles {
|
||||
return Err(too_large(KIND, format!("more than {} triangles", self.limits.max_triangles)));
|
||||
}
|
||||
if self.mesh.positions.len().saturating_add(3) > self.limits.max_vertices {
|
||||
return Err(too_large(KIND, format!("more than {} vertices", self.limits.max_vertices)));
|
||||
}
|
||||
let unit = |n: [f64; 3]| {
|
||||
let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
|
||||
(len.is_finite() && len > 0.0).then(|| n.map(|x| x / len))
|
||||
};
|
||||
let n = face_normal(v).or_else(|| unit(stored)).unwrap_or([0.0, 0.0, 1.0]);
|
||||
let slot = *self.slots.entry(color).or_insert_with(|| {
|
||||
self.mesh.parts.push(Part::default());
|
||||
self.colors.push(color);
|
||||
self.mesh.parts.len() - 1
|
||||
});
|
||||
let (Some(part), Ok(base), Ok(normal)) =
|
||||
(self.mesh.parts.get_mut(slot), u32::try_from(self.mesh.positions.len()), u32::try_from(self.mesh.normals.len()))
|
||||
else {
|
||||
return Err(too_large(KIND, "more vertices than a 32-bit index holds"));
|
||||
};
|
||||
self.mesh.normals.push(n);
|
||||
for (k, p) in (0u32..).zip(v) {
|
||||
self.mesh.positions.push(p);
|
||||
part.corners.push(Corner { position: base + k, normal, uv: crate::NONE });
|
||||
}
|
||||
self.facets += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn finish(mut self, name: String, mut warnings: Vec<String>) -> Result<Scene> {
|
||||
count_warning(&mut warnings, self.skipped, "facet(s) with a non-finite coordinate were left out");
|
||||
if self.facets == 0 {
|
||||
return Err(malformed(KIND, "no facets"));
|
||||
}
|
||||
let mut materials = Vec::new();
|
||||
let coloured = self.colors.iter().any(Option::is_some);
|
||||
for (part, color) in self.mesh.parts.iter_mut().zip(&self.colors) {
|
||||
let material = match color {
|
||||
Some(c) => {
|
||||
let lin = c.map(|x| srgb_to_linear(f32::from(x) / 255.0));
|
||||
Material::plain(format!("#{:02X}{:02X}{:02X}", c[0], c[1], c[2]), [lin[0], lin[1], lin[2], 1.0])
|
||||
}
|
||||
// Uncoloured facets next to coloured ones: a neutral grey of their own.
|
||||
None if coloured => Material::plain("STL", [0.6, 0.6, 0.6, 1.0]),
|
||||
None => continue,
|
||||
};
|
||||
part.material = Some(materials.len());
|
||||
materials.push(material);
|
||||
}
|
||||
self.mesh.name = name;
|
||||
let mut scene = Scene::from_meshes(vec![self.mesh], materials);
|
||||
scene.warnings = warnings;
|
||||
Ok(scene)
|
||||
}
|
||||
}
|
||||
|
||||
fn f32_at(b: &[u8], at: usize) -> f32 {
|
||||
b.get(at..at + 4).and_then(|s| <[u8; 4]>::try_from(s).ok()).map_or(f32::NAN, f32::from_le_bytes)
|
||||
}
|
||||
|
||||
/// A 5-bit channel expanded to 8 bits.
|
||||
fn five(x: u16) -> u8 {
|
||||
let x = (x & 31) as u8;
|
||||
(x << 3) | (x >> 2)
|
||||
}
|
||||
|
||||
/// The Magics `COLOR=` part colour in the header (RGB; alpha is ignored).
|
||||
fn header_color(header: &[u8]) -> Option<[u8; 3]> {
|
||||
let at = header.windows(6).position(|w| w == b"COLOR=")?;
|
||||
let c = header.get(at + 6..at + 10)?;
|
||||
Some([*c.first()?, *c.get(1)?, *c.get(2)?])
|
||||
}
|
||||
|
||||
fn binary(bytes: &[u8], count: usize, limits: &Limits) -> Result<Scene> {
|
||||
let header = bytes.get(..80).ok_or_else(|| malformed(KIND, "header truncated"))?;
|
||||
let magics = header_color(header);
|
||||
let body = bytes.get(HEADER..).unwrap_or(&[]);
|
||||
let room = body.len() / FACET;
|
||||
let mut warnings = Vec::new();
|
||||
if count > room {
|
||||
return Err(malformed(KIND, format!("the header counts {count} facets, the file holds {room} (truncated)")));
|
||||
}
|
||||
// Some writers leave the count unset.
|
||||
let take = if count == 0 { room } else { count };
|
||||
if count == 0 && room > 0 {
|
||||
warnings.push(format!("the header counts no facets, the file holds {room}"));
|
||||
}
|
||||
if take > limits.max_triangles {
|
||||
return Err(too_large(KIND, format!("{take} triangles (limit {})", limits.max_triangles)));
|
||||
}
|
||||
let mut b = Builder::new(limits);
|
||||
for f in body.as_chunks::<FACET>().0.iter().take(take) {
|
||||
let n = [f32_at(f, 0), f32_at(f, 4), f32_at(f, 8)];
|
||||
let v = [0usize, 1, 2].map(|k| [f32_at(f, 12 + 12 * k), f32_at(f, 16 + 12 * k), f32_at(f, 20 + 12 * k)]);
|
||||
let attr = f.get(48..50).and_then(|a| <[u8; 2]>::try_from(a).ok()).map_or(0, u16::from_le_bytes);
|
||||
let color = match magics {
|
||||
Some(part) => Some(if attr & 0x8000 == 0 { [five(attr), five(attr >> 5), five(attr >> 10)] } else { part }),
|
||||
None => (attr & 0x8000 != 0).then(|| [five(attr >> 10), five(attr >> 5), five(attr)]),
|
||||
};
|
||||
b.tri(color, n.map(f64::from), v.map(|p| p.map(f64::from)))?;
|
||||
}
|
||||
let text = String::from_utf8_lossy(header);
|
||||
let name = text.trim_matches(|c: char| c == '\0' || c.is_whitespace());
|
||||
b.finish(name.strip_prefix("solid").unwrap_or(name).trim().to_string(), warnings)
|
||||
}
|
||||
|
||||
/// Three numbers from the rest of an ASCII line.
|
||||
fn three<'a>(it: &mut impl Iterator<Item = &'a str>) -> Option<[f64; 3]> {
|
||||
Some([it.next()?.parse().ok()?, it.next()?.parse().ok()?, it.next()?.parse().ok()?])
|
||||
}
|
||||
|
||||
fn ascii(bytes: &[u8], limits: &Limits) -> Result<Scene> {
|
||||
let text = String::from_utf8_lossy(bytes);
|
||||
let mut b = Builder::new(limits);
|
||||
let mut name: Option<String> = None;
|
||||
let mut normal = [0.0f64; 3];
|
||||
let mut loop_v: Vec<[f64; 3]> = vec![];
|
||||
for (ln, line) in text.lines().enumerate() {
|
||||
let line = line.trim();
|
||||
let mut it = line.split_whitespace();
|
||||
let Some(key) = it.next() else { continue };
|
||||
match key.to_ascii_lowercase().as_str() {
|
||||
"solid" if name.is_none() => name = Some(line.get(5..).unwrap_or("").trim().to_string()),
|
||||
"facet" => {
|
||||
// `facet normal nx ny nz`
|
||||
normal = it.next().and_then(|_| three(&mut it)).unwrap_or([0.0; 3]);
|
||||
loop_v.clear();
|
||||
}
|
||||
"vertex" => {
|
||||
if loop_v.len() >= limits.max_face_corners {
|
||||
return Err(too_large(KIND, format!("line {}: a loop of more than {} vertices", ln + 1, limits.max_face_corners)));
|
||||
}
|
||||
loop_v.push(three(&mut it).ok_or_else(|| malformed(KIND, format!("line {}: expected three numbers", ln + 1)))?);
|
||||
}
|
||||
"endloop" | "endfacet" => {
|
||||
if let Some((&first, rest)) = loop_v.split_first() {
|
||||
for w in rest.windows(2) {
|
||||
if let [a, c] = w {
|
||||
b.tri(None, normal, [first, *a, *c])?;
|
||||
}
|
||||
}
|
||||
}
|
||||
loop_v.clear();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
b.finish(name.unwrap_or_default(), Vec::new())
|
||||
}
|
||||
|
||||
/// Every triangle of the scene in world space, with its face normal.
|
||||
fn triangles(scene: &Scene, limits: &Limits, mut each: impl FnMut([f64; 3], [[f64; 3]; 3])) -> Result<()> {
|
||||
for (index, world) in scene.instances(limits)? {
|
||||
let Some(mesh) = scene.meshes.get(index) else { continue };
|
||||
for t in mesh.parts.iter().flat_map(Part::triangles) {
|
||||
let p = t.map(|c| mesh.positions.get(c.position as usize).copied());
|
||||
let [Some(a), Some(b), Some(c)] = p else { return Err(malformed(KIND, "a corner index is out of range")) };
|
||||
// A mirrored placement turns the winding; turn it back so the facet faces out.
|
||||
let v = if is_mirror(&world) { [a, c, b] } else { [a, b, c] }.map(|p| transform_point(&world, p));
|
||||
each(face_normal(v).unwrap_or([0.0; 3]), v);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Binary STL of every mesh of the scene, placed by its nodes, as one solid. `name` goes in the
|
||||
/// header (cut to 80 bytes; a leading `solid` would make readers take the file for ASCII, so it
|
||||
/// is written after a prefix). Colours are not written.
|
||||
pub fn write_binary(scene: &Scene, name: &str, limits: &Limits) -> Result<Vec<u8>> {
|
||||
let mut header = format!("STL {name}").into_bytes();
|
||||
header.resize(80, b' ');
|
||||
let mut out = header;
|
||||
out.extend_from_slice(&[0; 4]);
|
||||
let mut n = 0u64;
|
||||
triangles(scene, limits, |normal, v| {
|
||||
n += 1;
|
||||
for x in std::iter::once(normal).chain(v).flatten() {
|
||||
out.extend_from_slice(&(x as f32).to_le_bytes());
|
||||
}
|
||||
out.extend_from_slice(&[0, 0]);
|
||||
})?;
|
||||
let count = u32::try_from(n).map_err(|_| Error::TooLarge("STL: more triangles than the 32-bit count holds".into()))?;
|
||||
if let Some(slot) = out.get_mut(80..HEADER) {
|
||||
slot.copy_from_slice(&count.to_le_bytes());
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// ASCII STL of every mesh of the scene, placed by its nodes, as one solid named `name` (its
|
||||
/// graphic ASCII characters).
|
||||
pub fn write_ascii(scene: &Scene, name: &str, limits: &Limits) -> Result<String> {
|
||||
let name: String = name.chars().filter(|c| c.is_ascii_graphic()).collect();
|
||||
let mut s = format!("solid {name}\n");
|
||||
triangles(scene, limits, |n, v| {
|
||||
let _ = write!(s, " facet normal {:e} {:e} {:e}\n outer loop\n", n[0], n[1], n[2]);
|
||||
for p in v {
|
||||
let _ = writeln!(s, " vertex {:e} {:e} {:e}", p[0], p[1], p[2]);
|
||||
}
|
||||
s += " endloop\n endfacet\n";
|
||||
})?;
|
||||
let _ = writeln!(s, "endsolid {name}");
|
||||
Ok(s)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
//! Tests. Every sample is generated here; the crate ships no asset files.
|
||||
|
||||
use super::*;
|
||||
|
||||
#[cfg(feature = "gltf")]
|
||||
mod gltf;
|
||||
#[cfg(feature = "obj")]
|
||||
mod obj;
|
||||
#[cfg(feature = "ply")]
|
||||
mod ply;
|
||||
#[cfg(feature = "stl")]
|
||||
mod stl;
|
||||
|
||||
/// Every truncation of `good` and two mutations of every byte go through `read`: it may refuse
|
||||
/// them, it must not panic, and what it accepts must be a valid scene.
|
||||
pub(crate) fn hammer(good: &[u8], read: &dyn Fn(&[u8]) -> Result<Scene>) {
|
||||
let check = |bytes: &[u8]| {
|
||||
if let Ok(scene) = read(bytes) {
|
||||
scene.validate().unwrap();
|
||||
}
|
||||
};
|
||||
for len in 0..good.len() {
|
||||
check(&good[..len]);
|
||||
}
|
||||
for i in 0..good.len() {
|
||||
let mut b = good.to_vec();
|
||||
b[i] ^= 0xA5;
|
||||
check(&b);
|
||||
b[i] = 0xFF;
|
||||
check(&b);
|
||||
}
|
||||
check(b"\x00\x01garbage\xff\xfe");
|
||||
}
|
||||
|
||||
pub(crate) fn tri_mesh() -> Mesh {
|
||||
Mesh {
|
||||
name: "Tri".into(),
|
||||
positions: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
|
||||
parts: vec![Part { corners: [0, 1, 2, 0, 3, 1].map(Corner::at).to_vec(), ..Part::default() }],
|
||||
..Mesh::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn part_faces_and_fans() {
|
||||
let quad = Part { face_sizes: vec![4, 3], corners: (0..7).map(Corner::at).collect(), ..Part::default() };
|
||||
assert_eq!(quad.faces().map(<[Corner]>::len).collect::<Vec<_>>(), [4, 3]);
|
||||
assert_eq!(quad.triangle_count(), 3);
|
||||
let tris: Vec<[u32; 3]> = quad.triangles().map(|t| t.map(|c| c.position)).collect();
|
||||
assert_eq!(tris, [[0, 1, 2], [0, 2, 3], [4, 5, 6]]);
|
||||
// Sizes that overrun the corners stop the iteration instead of panicking.
|
||||
let short = Part { face_sizes: vec![3, 9], corners: (0..5).map(Corner::at).collect(), ..Part::default() };
|
||||
assert_eq!(short.faces().count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_catches_what_writers_cannot_take() {
|
||||
let mut m = tri_mesh();
|
||||
m.validate().unwrap();
|
||||
m.parts[0].corners[0].position = 4;
|
||||
assert!(m.validate().is_err());
|
||||
let mut m = tri_mesh();
|
||||
m.parts[0].corners[0].normal = 0;
|
||||
assert!(m.validate().is_err(), "a normal index without normals");
|
||||
let mut m = tri_mesh();
|
||||
m.parts[0].face_sizes = vec![4, 3];
|
||||
assert!(m.validate().is_err());
|
||||
let mut m = tri_mesh();
|
||||
m.colors = vec![[1.0; 4]];
|
||||
assert!(m.validate().is_err());
|
||||
let mut s = Scene::from_meshes(vec![tri_mesh()], vec![]);
|
||||
s.validate().unwrap();
|
||||
s.meshes[0].parts[0].material = Some(0);
|
||||
assert!(s.validate().is_err());
|
||||
let mut s = Scene::from_meshes(vec![tri_mesh()], vec![]);
|
||||
s.nodes[0].children.push(7);
|
||||
assert!(s.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unified_gives_one_vertex_per_distinct_corner() {
|
||||
// Two triangles sharing an edge; the shared positions carry different normals.
|
||||
let mut m = tri_mesh();
|
||||
m.normals = vec![[0.0, 0.0, 1.0], [0.0, -1.0, 0.0]];
|
||||
m.colors = vec![[1.0, 0.0, 0.0, 1.0]; 4];
|
||||
for (i, c) in m.parts[0].corners.iter_mut().enumerate() {
|
||||
c.normal = (i / 3) as u32;
|
||||
}
|
||||
let u = m.unified();
|
||||
assert_eq!(u.positions.len(), 6);
|
||||
assert_eq!(u.normals.len(), 6);
|
||||
assert_eq!(u.colors.len(), 6);
|
||||
assert!(u.uvs.is_empty());
|
||||
assert_eq!(u.corners.iter().map(|c| c.position).collect::<Vec<_>>(), [0, 1, 2, 0, 3, 1]);
|
||||
assert_eq!(u.parts, [vec![[0, 1, 2], [3, 4, 5]]]);
|
||||
// Without normals the shared positions are shared vertices.
|
||||
let u = tri_mesh().unified();
|
||||
assert_eq!((u.positions.len(), u.normals.len()), (4, 0));
|
||||
assert_eq!(u.parts, [vec![[0, 1, 2], [0, 3, 1]]]);
|
||||
// A corner out of range drops its triangle, not the process.
|
||||
let mut bad = tri_mesh();
|
||||
bad.parts[0].corners[4].position = 99;
|
||||
assert_eq!(bad.unified().parts, [vec![[0, 1, 2]]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transforms() {
|
||||
let mut t = IDENTITY;
|
||||
t[12] = 5.0;
|
||||
let mut s = IDENTITY;
|
||||
s[0] = 2.0;
|
||||
// Scale first, then move.
|
||||
assert_eq!(transform_point(&mat_mul(&t, &s), [1.0, 1.0, 1.0]), [7.0, 1.0, 1.0]);
|
||||
// A non-uniform scale tilts a normal the other way.
|
||||
let n = transform_normal(&s, [1.0, 1.0, 0.0]);
|
||||
assert!((n[0] * 2.0 - n[1]).abs() < 1e-12 && n[1] > 0.0, "{n:?}");
|
||||
// A mirror keeps the normal pointing out of the mirrored surface.
|
||||
let mut mirror = IDENTITY;
|
||||
mirror[0] = -1.0;
|
||||
assert_eq!(transform_normal(&mirror, [1.0, 0.0, 0.0]).map(|x| x.round()), [-1.0, 0.0, 0.0]);
|
||||
assert_eq!(transform_normal(&[0.0; 16], [0.0, 0.0, 1.0]), [0.0, 0.0, 1.0]);
|
||||
}
|
||||
|
||||
fn chain(len: usize) -> Scene {
|
||||
let mut s = Scene::from_meshes(vec![tri_mesh()], vec![]);
|
||||
s.nodes = (0..len)
|
||||
.map(|i| Node { children: if i + 1 < len { vec![i + 1] } else { vec![] }, mesh: (i + 1 == len).then_some(0), ..Node::default() })
|
||||
.collect();
|
||||
s.roots = vec![0];
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instances_walk_the_hierarchy_within_limits() {
|
||||
let mut s = chain(3);
|
||||
s.nodes[0].transform[12] = 1.0;
|
||||
s.nodes[2].transform[13] = 2.0;
|
||||
let placed = s.instances(&Limits::default()).unwrap();
|
||||
assert_eq!(placed.len(), 1);
|
||||
assert_eq!(transform_point(&placed[0].1, [0.0; 3]), [1.0, 2.0, 0.0]);
|
||||
// Limit: depth.
|
||||
let limits = Limits { max_depth: 4, ..Limits::default() };
|
||||
assert!(chain(5).instances(&limits).is_ok());
|
||||
assert!(matches!(chain(6).instances(&limits), Err(Error::TooLarge(_))));
|
||||
// Limit: visits. A cycle ends there too.
|
||||
let limits = Limits { max_nodes: 5, max_depth: usize::MAX, ..Limits::default() };
|
||||
assert!(chain(5).instances(&limits).is_ok());
|
||||
assert!(matches!(chain(6).instances(&limits), Err(Error::TooLarge(_))));
|
||||
let mut cycle = chain(2);
|
||||
cycle.nodes[1].children = vec![0];
|
||||
assert!(matches!(cycle.instances(&Limits::default()), Err(Error::TooLarge(_))));
|
||||
// A child that does not exist is an error, not a panic.
|
||||
let mut missing = chain(1);
|
||||
missing.nodes[0].children = vec![9];
|
||||
assert!(matches!(missing.instances(&Limits::default()), Err(Error::Malformed(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn srgb_round_trips() {
|
||||
for c in [0.0f32, 0.002, 0.2, 0.5, 1.0] {
|
||||
assert!((linear_to_srgb(srgb_to_linear(c)) - c).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,718 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::hammer;
|
||||
use crate::gltf::{MAX_UV_SETS, MAX_ZERO_COMPONENTS, base64_decode, glb_container, read, split_glb, uri_decode, write_glb};
|
||||
use crate::{
|
||||
AlphaMode, ChannelPath, Corner, Error, IDENTITY, Image, Interpolation, LightKind, Limits, Material, Mesh, NONE, Node, Part, Projection, Scene,
|
||||
TextureRef, Wrap, face_normal, no_external, transform_point,
|
||||
};
|
||||
|
||||
/// Builds the binary chunk with its buffer views and accessors.
|
||||
#[derive(Default)]
|
||||
struct B {
|
||||
bin: Vec<u8>,
|
||||
views: Vec<Value>,
|
||||
accessors: Vec<Value>,
|
||||
}
|
||||
|
||||
impl B {
|
||||
fn view(&mut self, bytes: &[u8]) -> usize {
|
||||
self.bin.resize(self.bin.len().next_multiple_of(4), 0);
|
||||
self.views.push(json!({ "buffer": 0, "byteOffset": self.bin.len(), "byteLength": bytes.len() }));
|
||||
self.bin.extend_from_slice(bytes);
|
||||
self.views.len() - 1
|
||||
}
|
||||
|
||||
fn acc(&mut self, bytes: &[u8], component: u32, count: usize, kind: &str) -> usize {
|
||||
let view = self.view(bytes);
|
||||
self.accessors.push(json!({ "bufferView": view, "componentType": component, "count": count, "type": kind }));
|
||||
self.accessors.len() - 1
|
||||
}
|
||||
|
||||
fn f32s(&mut self, v: &[f32], kind: &str, per: usize) -> usize {
|
||||
self.acc(&v.iter().flat_map(|x| x.to_le_bytes()).collect::<Vec<_>>(), 5126, v.len() / per, kind)
|
||||
}
|
||||
|
||||
fn u16s(&mut self, v: &[u16]) -> usize {
|
||||
self.acc(&v.iter().flat_map(|x| x.to_le_bytes()).collect::<Vec<_>>(), 5123, v.len(), "SCALAR")
|
||||
}
|
||||
|
||||
/// The document: `rest` with the accessors, views and buffer added.
|
||||
fn json(&self, mut rest: Value) -> Value {
|
||||
let o = rest.as_object_mut().unwrap();
|
||||
o.entry("asset").or_insert(json!({ "version": "2.0" }));
|
||||
o.insert("accessors".into(), json!(self.accessors));
|
||||
o.insert("bufferViews".into(), json!(self.views));
|
||||
o.entry("buffers").or_insert(json!([{ "byteLength": self.bin.len() }]));
|
||||
rest
|
||||
}
|
||||
|
||||
fn glb(&self, rest: Value) -> Vec<u8> {
|
||||
glb_container(&serde_json::to_vec(&self.json(rest)).unwrap(), &self.bin).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
const QUAD: [f32; 12] = [0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 1.0, 0.0];
|
||||
|
||||
fn parse(bytes: &[u8]) -> crate::Result<Scene> {
|
||||
read(bytes, &Limits::default(), &mut no_external)
|
||||
}
|
||||
|
||||
/// A quad with normals and UVs under a node tree: a root moved by TRS, its child placed by a
|
||||
/// matrix, with a material.
|
||||
fn quad() -> Vec<u8> {
|
||||
let mut b = B::default();
|
||||
let p = b.f32s(&QUAD, "VEC3", 3);
|
||||
let n = b.f32s(&[0.0, 0.0, 1.0].repeat(4), "VEC3", 3);
|
||||
let t = b.f32s(&[0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0], "VEC2", 2);
|
||||
let i = b.u16s(&[0, 1, 2, 0, 2, 3]);
|
||||
let mut m = IDENTITY;
|
||||
m[13] = 5.0;
|
||||
let h = std::f64::consts::FRAC_1_SQRT_2;
|
||||
b.glb(json!({
|
||||
"scene": 0,
|
||||
"scenes": [{ "nodes": [0] }],
|
||||
"nodes": [
|
||||
{ "name": "root", "translation": [1.0, 0.0, 0.0], "rotation": [0.0, 0.0, h, h], "scale": [2.0, 2.0, 2.0], "children": [1] },
|
||||
{ "name": "leaf", "matrix": m, "mesh": 0 },
|
||||
{ "name": "not in the scene", "mesh": 0 }
|
||||
],
|
||||
"meshes": [{ "name": "Quad", "primitives": [{ "attributes": { "POSITION": p, "NORMAL": n, "TEXCOORD_0": t }, "indices": i, "material": 0 }] }],
|
||||
"materials": [{ "name": "Paint", "pbrMetallicRoughness": { "baseColorFactor": [1.0, 0.5, 0.25, 1.0], "metallicFactor": 0.0, "roughnessFactor": 0.4 }, "doubleSided": true, "alphaMode": "MASK" }]
|
||||
}))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glb_quad_with_a_node_tree_and_a_material() {
|
||||
let s = parse(&quad()).unwrap();
|
||||
s.validate().unwrap();
|
||||
assert!(s.warnings.is_empty() && s.external.is_empty());
|
||||
let m = &s.meshes[0];
|
||||
assert_eq!((m.name.as_str(), m.positions.len(), m.normals.len(), m.uv_sets[0].len(), m.triangle_count()), ("Quad", 4, 4, 4, 2));
|
||||
assert_eq!(m.parts[0].corners[4], Corner::all(2));
|
||||
assert_eq!(m.parts[0].material, Some(0));
|
||||
// UVs are glTF's own convention: kept as stored.
|
||||
assert_eq!(m.uv_sets[0][2], [1.0, 1.0]);
|
||||
let paint = &s.materials[0];
|
||||
assert_eq!((paint.name.as_str(), paint.base_color, paint.metallic, paint.roughness), ("Paint", [1.0, 0.5, 0.25, 1.0], 0.0, 0.4));
|
||||
assert_eq!((paint.double_sided, paint.alpha_mode), (true, AlphaMode::Mask(0.5)));
|
||||
// The default scene's roots; the tree places the mesh: up 5, scaled by 2, turned a quarter
|
||||
// about Z, moved 1 along X.
|
||||
assert_eq!(s.roots, [0]);
|
||||
assert!(s.nodes[0].trs.is_some() && s.nodes[1].trs.is_none());
|
||||
let placed = s.instances(&Limits::default()).unwrap();
|
||||
assert_eq!(placed.len(), 1);
|
||||
let p = transform_point(&placed[0].1, [1.0, 0.0, 0.0]);
|
||||
assert!((p[0] - -9.0).abs() < 1e-9 && (p[1] - 2.0).abs() < 1e-9 && p[2].abs() < 1e-9, "{p:?}");
|
||||
}
|
||||
|
||||
fn base64(data: &[u8]) -> String {
|
||||
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut out = String::new();
|
||||
for c in data.chunks(3) {
|
||||
let n = c.iter().enumerate().fold(0u32, |n, (k, b)| n | u32::from(*b) << (16 - 8 * k));
|
||||
for k in 0..4 {
|
||||
out.push(if k <= c.len() { T[(n >> (18 - 6 * k)) as usize & 63] as char } else { '=' });
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// One triangle as a `.gltf` document whose buffer and image are named by `buffer` and `image`.
|
||||
fn gltf_text(buffer: &str, image: &str) -> (Vec<u8>, Vec<u8>) {
|
||||
let mut b = B::default();
|
||||
let p = b.f32s(&QUAD[..9], "VEC3", 3);
|
||||
let doc = b.json(json!({
|
||||
"buffers": [{ "byteLength": b.bin.len(), "uri": buffer }],
|
||||
"nodes": [{ "mesh": 0 }],
|
||||
"meshes": [{ "primitives": [{ "attributes": { "POSITION": p }, "material": 0 }] }],
|
||||
"materials": [{ "pbrMetallicRoughness": { "baseColorTexture": { "index": 0, "texCoord": 1 } } }],
|
||||
"textures": [{ "source": 0, "sampler": 0 }],
|
||||
"samplers": [{ "wrapS": 33071, "wrapT": 33648 }],
|
||||
"images": [{ "uri": image }]
|
||||
}));
|
||||
(serde_json::to_vec(&doc).unwrap(), b.bin)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_and_external_resources() {
|
||||
// Embedded: data URIs for the buffer and the image.
|
||||
let bin = gltf_text("", "").1;
|
||||
let png = b"\x89PNG not really";
|
||||
let (doc, _) = gltf_text(&format!("data:application/octet-stream;base64,{}", base64(&bin)), &format!("data:image/png;base64,{}", base64(png)));
|
||||
let mut asked = 0;
|
||||
let s = read(&doc, &Limits::default(), &mut |_| {
|
||||
asked += 1;
|
||||
None
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!((asked, s.external.len(), s.meshes[0].triangle_count()), (0, 0, 1));
|
||||
assert_eq!(
|
||||
(s.images[0].data.as_deref(), s.images[0].mime_type.as_deref(), s.images[0].uri.as_deref()),
|
||||
(Some(&png[..]), Some("image/png"), None)
|
||||
);
|
||||
// The texture slot keeps its UV set and wrap modes.
|
||||
assert_eq!(s.materials[0].base_color_texture, Some(TextureRef { image: 0, uv_set: 1, wrap: [Wrap::Clamp, Wrap::Mirror] }));
|
||||
// No scene in the file: every node that is nobody's child is a root.
|
||||
assert_eq!(s.roots, [0]);
|
||||
|
||||
// External: the buffer is asked for by its decoded name; the image is only named.
|
||||
let (doc, bin) = gltf_text("my%20data.bin", "../textures/wood.png");
|
||||
let mut asked = Vec::new();
|
||||
let s = read(&doc, &Limits::default(), &mut |name| {
|
||||
asked.push(name.to_string());
|
||||
Some(bin.clone())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(asked, ["my data.bin"]);
|
||||
assert_eq!(s.external, ["my data.bin", "../textures/wood.png"]);
|
||||
assert_eq!((s.images[0].uri.as_deref(), s.images[0].data.as_ref()), (Some("../textures/wood.png"), None));
|
||||
s.validate().unwrap();
|
||||
// A buffer that is not supplied is named in the error.
|
||||
assert_eq!(parse(&doc), Err(Error::Missing("my data.bin".into())));
|
||||
// An image that cannot be decoded is left without bytes.
|
||||
let (doc, _) = gltf_text(&format!("data:;base64,{}", base64(&bin)), "data:image/png;base64,!!!");
|
||||
assert_eq!(parse(&doc).unwrap().images[0].data, None);
|
||||
assert!(parse(&gltf_text("data:;base64,!!!", "x.png").0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base64_and_uri_decoding() {
|
||||
assert_eq!(base64_decode("aGVsbG8=").unwrap(), b"hello");
|
||||
assert_eq!(base64_decode("aGVs\nbG8").unwrap(), b"hello");
|
||||
assert_eq!(base64_decode("-_8").unwrap(), [0xfb, 0xff]);
|
||||
assert!(base64_decode("a$b").is_none());
|
||||
assert_eq!(uri_decode("a%20b%2Fc"), "a b/c");
|
||||
assert_eq!(uri_decode("a%+1b"), "a%+1b");
|
||||
assert_eq!(uri_decode("a%4"), "a%4");
|
||||
assert_eq!(uri_decode("%"), "%");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glb_container_checks() {
|
||||
let glb = quad();
|
||||
let (json, bin) = split_glb(&glb).unwrap();
|
||||
assert!(json.starts_with(b"{") && bin.unwrap().len() >= 12 * 4);
|
||||
assert!(split_glb(b"glTF").is_err());
|
||||
assert!(split_glb(b"nope").is_err());
|
||||
let mut v1 = glb.clone();
|
||||
v1[4] = 1;
|
||||
assert!(matches!(split_glb(&v1), Err(Error::Unsupported(_))));
|
||||
// The header's length is checked against the file.
|
||||
assert!(split_glb(&glb[..glb.len() - 1]).is_err());
|
||||
// A chunk that runs past the end.
|
||||
let mut long = glb.clone();
|
||||
long[12..16].copy_from_slice(&u32::MAX.to_le_bytes());
|
||||
assert!(split_glb(&long).is_err());
|
||||
// A binary chunk before the JSON one.
|
||||
let mut swapped = glb.clone();
|
||||
swapped[16..20].copy_from_slice(b"BIN\0");
|
||||
assert!(split_glb(&swapped).is_err());
|
||||
// An unknown chunk is skipped.
|
||||
let mut extra = glb.clone();
|
||||
extra.extend(4u32.to_le_bytes());
|
||||
extra.extend(b"XTRAdata");
|
||||
let len = extra.len() as u32;
|
||||
extra[8..12].copy_from_slice(&len.to_le_bytes());
|
||||
assert_eq!(parse(&extra).unwrap().meshes, parse(&glb).unwrap().meshes);
|
||||
}
|
||||
|
||||
fn one_mesh(b: &B, primitive: Value) -> Vec<u8> {
|
||||
b.glb(json!({ "nodes": [{ "mesh": 0 }], "meshes": [{ "primitives": [primitive] }] }))
|
||||
}
|
||||
|
||||
fn positions_of(s: &Scene) -> Vec<[u32; 3]> {
|
||||
s.meshes[0].parts.iter().flat_map(Part::triangles).map(|t| t.map(|c| c.position)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_fans_and_unindexed_primitives() {
|
||||
let mut b = B::default();
|
||||
let p = b.f32s(&QUAD, "VEC3", 3);
|
||||
let i = b.u16s(&[0, 1, 3, 2]);
|
||||
let s = parse(&one_mesh(&b, json!({ "attributes": { "POSITION": p }, "indices": i, "mode": 5 }))).unwrap();
|
||||
// Every second triangle of a strip is turned so that all face the same way.
|
||||
assert_eq!(positions_of(&s), [[0, 1, 3], [3, 1, 2]]);
|
||||
let s = parse(&one_mesh(&b, json!({ "attributes": { "POSITION": p }, "mode": 6 }))).unwrap();
|
||||
assert_eq!(positions_of(&s), [[0, 1, 2], [0, 2, 3]]);
|
||||
// Without indices the vertices are taken in order; a vertex left over is dropped.
|
||||
let s = parse(&one_mesh(&b, json!({ "attributes": { "POSITION": p } }))).unwrap();
|
||||
assert_eq!((positions_of(&s), s.warnings.len()), (vec![[0, 1, 2]], 1));
|
||||
// Points and lines are left out, with a warning; the mesh stays so indices keep their meaning.
|
||||
let s = parse(&one_mesh(&b, json!({ "attributes": { "POSITION": p }, "mode": 1 }))).unwrap();
|
||||
assert_eq!((s.meshes.len(), s.meshes[0].parts.len(), s.warnings.len()), (1, 0, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strides_quantized_attributes_and_accessors_without_a_buffer() {
|
||||
let mut b = B::default();
|
||||
// Positions and normals interleaved: 24 bytes per vertex.
|
||||
let interleaved: Vec<u8> =
|
||||
QUAD.chunks(3).flat_map(|p| p.iter().chain(&[0.0, 0.0, 1.0]).flat_map(|x| x.to_le_bytes()).collect::<Vec<_>>()).collect();
|
||||
let view = b.view(&interleaved);
|
||||
b.views[view]["byteStride"] = json!(24);
|
||||
b.accessors.push(json!({ "bufferView": view, "componentType": 5126, "count": 4, "type": "VEC3" }));
|
||||
b.accessors.push(json!({ "bufferView": view, "byteOffset": 12, "componentType": 5126, "count": 4, "type": "VEC3" }));
|
||||
// Normalised bytes for UVs and colours, as KHR_mesh_quantization allows.
|
||||
let uv = b.acc(&[0, 0, 255, 0, 255, 255, 0, 255], 5121, 4, "VEC2");
|
||||
b.accessors[uv]["normalized"] = json!(true);
|
||||
let color = b.acc(&[255, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 255], 5121, 4, "VEC3");
|
||||
b.accessors[color]["normalized"] = json!(true);
|
||||
// A second UV set with no buffer view: all zeros.
|
||||
b.accessors.push(json!({ "componentType": 5126, "count": 4, "type": "VEC2" }));
|
||||
let zero = b.accessors.len() - 1;
|
||||
let i = b.acc(&[0, 1, 2, 0, 2, 3], 5121, 6, "SCALAR");
|
||||
let glb =
|
||||
one_mesh(&b, json!({ "attributes": { "POSITION": 0, "NORMAL": 1, "TEXCOORD_0": uv, "TEXCOORD_1": zero, "COLOR_0": color }, "indices": i }));
|
||||
let s = parse(&glb).unwrap();
|
||||
s.validate().unwrap();
|
||||
let m = &s.meshes[0];
|
||||
assert_eq!(m.positions[2], [1.0, 1.0, 0.0]);
|
||||
assert_eq!(m.normals, vec![[0.0, 0.0, 1.0]; 4]);
|
||||
assert_eq!(m.uv_sets, [vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]], vec![[0.0; 2]; 4]]);
|
||||
// A colour without alpha is opaque.
|
||||
assert_eq!(m.colors[1], [0.0, 1.0, 0.0, 1.0]);
|
||||
assert_eq!(m.triangle_count(), 2);
|
||||
// A stride smaller than the element is refused.
|
||||
b.views[view]["byteStride"] = json!(8);
|
||||
assert!(parse(&one_mesh(&b, json!({ "attributes": { "POSITION": 0 } }))).is_err());
|
||||
// Accessors without a buffer share one budget of zeros, however often they are named.
|
||||
let mut b = B::default();
|
||||
let keys = b.f32s(&[0.0; 3], "VEC3", 3);
|
||||
b.accessors.push(json!({ "componentType": 5126, "count": MAX_ZERO_COMPONENTS, "type": "SCALAR" }));
|
||||
let zeros = b.accessors.len() - 1;
|
||||
let animated = |output: usize| {
|
||||
b.glb(json!({ "nodes": [{}], "animations": [{ "samplers": [{ "input": zeros, "output": output }], "channels": [{ "sampler": 0, "target": { "node": 0, "path": "translation" } }] }] }))
|
||||
};
|
||||
assert_eq!(parse(&animated(keys)).unwrap().animations[0].channels[0].times.len(), MAX_ZERO_COMPONENTS);
|
||||
assert!(matches!(parse(&animated(zeros)), Err(Error::TooLarge(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn primitives_of_a_mesh_share_its_pools() {
|
||||
let mut b = B::default();
|
||||
let p = b.f32s(&QUAD, "VEC3", 3);
|
||||
let n = b.f32s(&[0.0, 0.0, 1.0].repeat(4), "VEC3", 3);
|
||||
let c = b.f32s(&[1.0, 0.0, 0.0, 0.5].repeat(4), "VEC4", 4);
|
||||
let t = b.f32s(&[0.25; 8], "VEC2", 2);
|
||||
let i = b.u16s(&[0, 1, 2]);
|
||||
let glb = b.glb(json!({ "meshes": [{ "primitives": [
|
||||
{ "attributes": { "POSITION": p }, "indices": i },
|
||||
{ "attributes": { "POSITION": p, "NORMAL": n, "COLOR_0": c, "TEXCOORD_0": t }, "indices": i },
|
||||
{ "attributes": { "POSITION": p }, "indices": i }
|
||||
] }] }));
|
||||
let s = parse(&glb).unwrap();
|
||||
s.validate().unwrap();
|
||||
let m = &s.meshes[0];
|
||||
// The first and third name the same accessors and share their vertices.
|
||||
assert_eq!((m.positions.len(), m.normals.len(), m.uv_sets[0].len(), m.colors.len(), m.parts.len()), (8, 4, 4, 8, 3));
|
||||
// The second primitive's corners count from where its vertices start in each pool.
|
||||
assert_eq!(m.parts[1].corners[1], Corner { position: 5, normal: 1, uv: 1 });
|
||||
assert_eq!(m.parts[2].corners[1], Corner { position: 1, normal: NONE, uv: NONE });
|
||||
// Vertices of primitives without colours are white.
|
||||
assert_eq!((m.colors[0], m.colors[5]), ([1.0; 4], [1.0, 0.0, 0.0, 0.5]));
|
||||
// An attribute that does not fit the positions is passed over.
|
||||
let short = b.f32s(&[0.0, 0.0, 1.0], "VEC3", 3);
|
||||
let s = parse(&one_mesh(&b, json!({ "attributes": { "POSITION": p, "NORMAL": short } }))).unwrap();
|
||||
assert!(s.meshes[0].normals.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uv_sets_past_the_bound_are_not_read() {
|
||||
let mut b = B::default();
|
||||
let p = b.f32s(&QUAD[..9], "VEC3", 3);
|
||||
let t = b.f32s(&[0.5; 6], "VEC2", 2);
|
||||
let mut attrs = json!({ "POSITION": p });
|
||||
for k in 0..MAX_UV_SETS + 2 {
|
||||
attrs[format!("TEXCOORD_{k}")] = json!(t);
|
||||
}
|
||||
let s = parse(&one_mesh(&b, json!({ "attributes": attrs }))).unwrap();
|
||||
assert_eq!(s.meshes[0].uv_sets.len(), MAX_UV_SETS);
|
||||
s.validate().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_triangles_are_left_out_and_later_ones_keep_their_vertices() {
|
||||
let mut b = B::default();
|
||||
let p = b.f32s(&QUAD, "VEC3", 3);
|
||||
// Triangles: (0,1,2), (0,1,9) out of range, (0,2,3), and two indices left over.
|
||||
let i = b.u16s(&[0, 1, 2, 0, 1, 9, 0, 2, 3, 1, 2]);
|
||||
let s = parse(&one_mesh(&b, json!({ "attributes": { "POSITION": p }, "indices": i }))).unwrap();
|
||||
assert_eq!(positions_of(&s), [[0, 1, 2], [0, 2, 3]]);
|
||||
assert_eq!(s.warnings, ["2 triangle(s) could not be read and were left out"]);
|
||||
// A position that is not finite takes its triangles with it.
|
||||
let mut nan = QUAD;
|
||||
nan[3] = f32::NAN;
|
||||
let p = b.f32s(&nan, "VEC3", 3);
|
||||
let i = b.u16s(&[0, 1, 2, 0, 2, 3]);
|
||||
let s = parse(&one_mesh(&b, json!({ "attributes": { "POSITION": p }, "indices": i }))).unwrap();
|
||||
assert_eq!(positions_of(&s), [[0, 2, 3]]);
|
||||
s.validate().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn counts_are_checked_against_the_buffer_before_anything_is_allocated() {
|
||||
// A one-triangle buffer whose accessors claim far more.
|
||||
for (kind, count) in [("VEC3", 1u64 << 40), ("MAT4", u64::MAX / 16), ("VEC3", u64::MAX), ("VEC3", 4)] {
|
||||
let mut b = B::default();
|
||||
b.f32s(&QUAD[..9], "VEC3", 3);
|
||||
b.accessors[0]["count"] = json!(count);
|
||||
b.accessors[0]["type"] = json!(kind);
|
||||
let limits = Limits { max_vertices: usize::MAX, max_triangles: usize::MAX, ..Limits::default() };
|
||||
assert!(read(&one_mesh(&b, json!({ "attributes": { "POSITION": 0 } })), &limits, &mut no_external).is_err(), "{kind} {count}");
|
||||
}
|
||||
// Integer accessors too.
|
||||
let mut b = B::default();
|
||||
let p = b.f32s(&QUAD, "VEC3", 3);
|
||||
let i = b.u16s(&[0, 1, 2]);
|
||||
b.accessors[i]["count"] = json!(1u64 << 33);
|
||||
let limits = Limits { max_triangles: usize::MAX, ..Limits::default() };
|
||||
assert!(read(&one_mesh(&b, json!({ "attributes": { "POSITION": p }, "indices": i })), &limits, &mut no_external).is_err());
|
||||
// A view that runs past its buffer, an accessor offset past its view, things that are not there.
|
||||
let mut b = B::default();
|
||||
let p = b.f32s(&QUAD, "VEC3", 3);
|
||||
b.views[0]["byteLength"] = json!(1u64 << 40);
|
||||
assert!(parse(&one_mesh(&b, json!({ "attributes": { "POSITION": p } }))).is_err());
|
||||
b.views[0]["byteLength"] = json!(48);
|
||||
b.accessors[0]["byteOffset"] = json!(40);
|
||||
assert!(parse(&one_mesh(&b, json!({ "attributes": { "POSITION": p } }))).is_err());
|
||||
assert!(parse(&one_mesh(&b, json!({ "attributes": { "POSITION": 7 } }))).is_err());
|
||||
b.accessors[0]["bufferView"] = json!(3);
|
||||
assert!(parse(&one_mesh(&b, json!({ "attributes": { "POSITION": p } }))).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_is_not_read_is_refused_or_named() {
|
||||
let mut b = B::default();
|
||||
let p = b.f32s(&QUAD[..9], "VEC3", 3);
|
||||
let doc = |extra: Value| {
|
||||
let mut v = b.json(json!({ "meshes": [{ "primitives": [{ "attributes": { "POSITION": p } }] }] }));
|
||||
v.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
|
||||
glb_container(&serde_json::to_vec(&v).unwrap(), &b.bin).unwrap()
|
||||
};
|
||||
assert!(parse(&doc(json!({}))).is_ok());
|
||||
assert!(matches!(parse(&doc(json!({ "asset": { "version": "1.0" } }))), Err(Error::Unsupported(_))));
|
||||
assert!(matches!(parse(&doc(json!({ "asset": {} }))), Err(Error::Unsupported(_))));
|
||||
assert!(matches!(parse(&doc(json!({ "extensionsRequired": ["KHR_draco_mesh_compression"] }))), Err(Error::Unsupported(_))));
|
||||
assert!(matches!(parse(&doc(json!({ "extensionsRequired": ["VENDOR_something_new"] }))), Err(Error::Unsupported(_))));
|
||||
// Extensions that only change looks are named in a warning; the ones the reader handles are not.
|
||||
let s = parse(&doc(
|
||||
json!({ "extensionsRequired": ["KHR_texture_transform", "KHR_materials_clearcoat", "KHR_mesh_quantization", "KHR_materials_unlit"] }),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(s.warnings.len(), 2);
|
||||
// Sparse accessors.
|
||||
let mut sparse = B::default();
|
||||
let p = sparse.f32s(&QUAD, "VEC3", 3);
|
||||
sparse.accessors[p]["sparse"] = json!({ "count": 1 });
|
||||
assert!(matches!(parse(&one_mesh(&sparse, json!({ "attributes": { "POSITION": p } }))), Err(Error::Unsupported(_))));
|
||||
assert!(parse(b"{ not json").is_err());
|
||||
assert!(parse(b"").is_err());
|
||||
}
|
||||
|
||||
/// A skinned, animated triangle with a camera and lights.
|
||||
fn rigged() -> Vec<u8> {
|
||||
let mut b = B::default();
|
||||
let p = b.f32s(&QUAD[..9], "VEC3", 3);
|
||||
let joints = b.acc(&[0, 1, 0, 0].repeat(3), 5121, 3, "VEC4");
|
||||
let weights = b.f32s(&[0.75, 0.25, 0.0, 0.0].repeat(3), "VEC4", 4);
|
||||
let mut bind = IDENTITY.map(|x| x as f32).to_vec();
|
||||
bind.extend(IDENTITY.map(|x| x as f32));
|
||||
bind[12] = -1.0;
|
||||
let ibm = b.f32s(&bind, "MAT4", 16);
|
||||
let times = b.f32s(&[0.0, 1.0], "SCALAR", 1);
|
||||
let moves = b.f32s(&[0.0, 0.0, 0.0, 0.0, 2.0, 0.0], "VEC3", 3);
|
||||
b.glb(json!({
|
||||
"extensions": { "KHR_lights_punctual": { "lights": [
|
||||
{ "type": "spot", "color": [1.0, 0.5, 0.0], "intensity": 40.0, "range": 9.0, "spot": { "innerConeAngle": 0.1, "outerConeAngle": 0.5 } },
|
||||
{ "name": "Sun", "type": "directional" }
|
||||
] } },
|
||||
"scenes": [{ "nodes": [0, 3] }],
|
||||
"nodes": [
|
||||
{ "name": "body", "mesh": 0, "skin": 0, "children": [1] },
|
||||
{ "name": "hip", "children": [2] },
|
||||
{ "name": "knee" },
|
||||
{ "name": "eye", "camera": 0, "extensions": { "KHR_lights_punctual": { "light": 1 } } }
|
||||
],
|
||||
"meshes": [{ "primitives": [{ "attributes": { "POSITION": p, "JOINTS_0": joints, "WEIGHTS_0": weights } }] }],
|
||||
"skins": [{ "name": "rig", "joints": [1, 2, 99], "inverseBindMatrices": ibm }],
|
||||
"animations": [{ "name": "hop", "samplers": [{ "input": times, "output": moves, "interpolation": "STEP" }],
|
||||
"channels": [{ "sampler": 0, "target": { "node": 1, "path": "translation" } }, { "sampler": 0, "target": { "node": 1, "path": "weights" } }, { "sampler": 0, "target": { "node": 77, "path": "scale" } }] }],
|
||||
"cameras": [{ "name": "Main", "type": "perspective", "perspective": { "yfov": 0.6, "aspectRatio": 1.5, "znear": 0.1 } }, { "type": "orthographic", "orthographic": { "xmag": 2.0, "ymag": 1.0, "znear": 0.1, "zfar": 50.0 } }]
|
||||
}))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skins_animations_cameras_and_lights() {
|
||||
let s = parse(&rigged()).unwrap();
|
||||
s.validate().unwrap();
|
||||
let m = &s.meshes[0];
|
||||
assert_eq!((m.joints[0], m.weights[0]), ([0, 1, 0, 0], [0.75, 0.25, 0.0, 0.0]));
|
||||
// A joint that names no node is dropped.
|
||||
let skin = &s.skins[0];
|
||||
assert_eq!((skin.name.as_str(), skin.joints.as_slice(), skin.inverse_bind.len()), ("rig", &[1, 2][..], 2));
|
||||
assert_eq!(skin.inverse_bind[0][12], -1.0);
|
||||
assert_eq!(s.nodes[0].skin, Some(0));
|
||||
// Only the channels this model holds, on nodes that exist.
|
||||
let hop = &s.animations[0];
|
||||
assert_eq!((hop.name.as_str(), hop.channels.len()), ("hop", 1));
|
||||
let c = &hop.channels[0];
|
||||
assert_eq!((c.node, c.path, c.interpolation), (1, ChannelPath::Translation, Interpolation::Step));
|
||||
assert_eq!((c.times.as_slice(), c.values.as_slice()), (&[0.0, 1.0][..], &[0.0, 0.0, 0.0, 0.0, 2.0, 0.0][..]));
|
||||
assert_eq!(s.cameras[0].projection, Projection::Perspective { yfov: 0.6, aspect: Some(1.5), znear: 0.1, zfar: None });
|
||||
assert_eq!(s.cameras[1].projection, Projection::Orthographic { xmag: 2.0, ymag: 1.0, znear: 0.1, zfar: 50.0 });
|
||||
assert_eq!((s.nodes[3].camera, s.nodes[3].light), (Some(0), Some(1)));
|
||||
assert_eq!((s.lights[0].kind, s.lights[0].color, s.lights[0].range), (LightKind::Spot { inner: 0.1, outer: 0.5 }, [1.0, 0.5, 0.0], Some(9.0)));
|
||||
assert_eq!((s.lights[1].name.as_str(), s.lights[1].kind, s.lights[1].intensity), ("Sun", LightKind::Directional, 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn materials_and_embedded_images() {
|
||||
let mut b = B::default();
|
||||
let p = b.f32s(&QUAD, "VEC3", 3);
|
||||
let image = b.view(b"\xff\xd8 jpeg bytes");
|
||||
let glb = b.glb(json!({
|
||||
"meshes": [{ "primitives": [{ "attributes": { "POSITION": p }, "material": 5 }] }],
|
||||
"materials": [{
|
||||
"pbrMetallicRoughness": { "baseColorTexture": { "index": 0 }, "metallicRoughnessTexture": { "index": 9 } },
|
||||
"normalTexture": { "index": 0, "scale": 0.5 }, "occlusionTexture": { "index": 0, "strength": 0.25 }, "emissiveTexture": { "index": 1 },
|
||||
"emissiveFactor": [1.0, 0.5, 0.0], "alphaMode": "BLEND",
|
||||
"extensions": { "KHR_materials_emissive_strength": { "emissiveStrength": 4.0 }, "KHR_materials_unlit": {} }
|
||||
}, {}],
|
||||
"textures": [{ "source": 0 }, { "source": 3 }],
|
||||
"images": [{ "name": "wood", "bufferView": image, "mimeType": "image/jpeg" }]
|
||||
}));
|
||||
let s = parse(&glb).unwrap();
|
||||
s.validate().unwrap();
|
||||
let m = &s.materials[0];
|
||||
assert_eq!(
|
||||
(s.images[0].name.as_str(), s.images[0].data.as_deref(), s.images[0].mime_type.as_deref()),
|
||||
("wood", Some(&b"\xff\xd8 jpeg bytes"[..]), Some("image/jpeg"))
|
||||
);
|
||||
assert_eq!(m.base_color_texture, Some(TextureRef::default()));
|
||||
// Slots that name a texture or an image that is not there stay empty.
|
||||
assert_eq!((m.metallic_roughness_texture, m.emissive_texture), (None, None));
|
||||
assert_eq!((m.normal_scale, m.occlusion_strength, m.emissive, m.alpha_mode, m.unlit), (0.5, 0.25, [4.0, 2.0, 0.0], AlphaMode::Blend, true));
|
||||
// glTF's defaults; a material index that does not exist leaves the part without one.
|
||||
assert_eq!(s.materials[1], Material::default());
|
||||
assert_eq!(s.meshes[0].parts[0].material, None);
|
||||
}
|
||||
|
||||
fn too(r: crate::Result<Scene>) -> bool {
|
||||
matches!(r, Err(Error::TooLarge(_)))
|
||||
}
|
||||
|
||||
fn with(glb: &[u8], f: &dyn Fn(&mut Limits)) -> crate::Result<Scene> {
|
||||
let mut l = Limits::default();
|
||||
f(&mut l);
|
||||
read(glb, &l, &mut no_external)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn limits() {
|
||||
let q = quad();
|
||||
assert!(with(&q, &|l| l.max_vertices = 4).is_ok());
|
||||
assert!(too(with(&q, &|l| l.max_vertices = 3)));
|
||||
assert!(with(&q, &|l| l.max_triangles = 2).is_ok());
|
||||
assert!(too(with(&q, &|l| l.max_triangles = 1)));
|
||||
assert!(with(&q, &|l| l.max_parts = 1).is_ok());
|
||||
assert!(too(with(&q, &|l| l.max_parts = 0)));
|
||||
assert!(with(&q, &|l| l.max_meshes = 1).is_ok());
|
||||
assert!(too(with(&q, &|l| l.max_meshes = 0)));
|
||||
assert!(with(&q, &|l| l.max_materials = 1).is_ok());
|
||||
assert!(too(with(&q, &|l| l.max_materials = 0)));
|
||||
assert!(with(&q, &|l| l.max_nodes = 3).is_ok());
|
||||
assert!(too(with(&q, &|l| l.max_nodes = 2)));
|
||||
assert!(with(&q, &|l| l.max_depth = 1).is_ok());
|
||||
assert!(too(with(&q, &|l| l.max_depth = 0)));
|
||||
let bin = split_glb(&q).unwrap().1.unwrap().len();
|
||||
assert!(with(&q, &|l| l.max_data_bytes = bin).is_ok());
|
||||
assert!(too(with(&q, &|l| l.max_data_bytes = bin - 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn limits_count_shared_accessors_expansions_and_extras() {
|
||||
// One accessor named by the primitives of one mesh is one set of vertices; named by
|
||||
// several meshes, each keeps its own, so the count that matters is the sum.
|
||||
let mut b = B::default();
|
||||
let p = b.f32s(&QUAD, "VEC3", 3);
|
||||
let i = b.u16s(&[0, 1, 2]);
|
||||
let prims: Vec<Value> = (0..5).map(|_| json!({ "attributes": { "POSITION": p }, "indices": i })).collect();
|
||||
let one = b.glb(json!({ "meshes": [{ "primitives": prims }] }));
|
||||
assert!(with(&one, &|l| l.max_vertices = 4).is_ok());
|
||||
assert_eq!(parse(&one).unwrap().meshes[0].positions.len(), 4);
|
||||
let meshes: Vec<Value> = (0..5).map(|_| json!({ "primitives": [prims[0]] })).collect();
|
||||
let shared = b.glb(json!({ "meshes": meshes }));
|
||||
assert!(with(&shared, &|l| l.max_vertices = 20).is_ok());
|
||||
assert!(too(with(&shared, &|l| l.max_vertices = 19)));
|
||||
assert!(with(&shared, &|l| l.max_triangles = 5).is_ok());
|
||||
assert!(too(with(&shared, &|l| l.max_triangles = 4)));
|
||||
assert!(with(&one, &|l| l.max_parts = 5).is_ok());
|
||||
assert!(too(with(&one, &|l| l.max_parts = 4)));
|
||||
// Strips and unindexed primitives count the triangles they expand to.
|
||||
let strip = one_mesh(&b, json!({ "attributes": { "POSITION": p }, "mode": 5 }));
|
||||
assert!(with(&strip, &|l| l.max_triangles = 2).is_ok());
|
||||
assert!(too(with(&strip, &|l| l.max_triangles = 1)));
|
||||
|
||||
// Skins, animations, cameras, lights and the values they read.
|
||||
let r = rigged();
|
||||
assert!(with(&r, &|l| l.max_extras = 3).is_ok());
|
||||
for (key, extra) in
|
||||
[("skins", json!({})), ("animations", json!({})), ("cameras", json!({ "type": "perspective" })), ("buffers", json!({ "byteLength": 0 }))]
|
||||
{
|
||||
let (json, bin) = split_glb(&r).unwrap();
|
||||
let mut doc: Value = serde_json::from_slice(json).unwrap();
|
||||
let list = doc[key].as_array_mut().unwrap();
|
||||
while list.len() < 4 {
|
||||
list.push(extra.clone());
|
||||
}
|
||||
let glb = glb_container(&serde_json::to_vec(&doc).unwrap(), bin.unwrap()).unwrap();
|
||||
assert!(with(&glb, &|l| l.max_extras = 4).is_ok(), "{key}");
|
||||
assert!(too(with(&glb, &|l| l.max_extras = 3)), "{key}");
|
||||
}
|
||||
let image_and_keys = with(&r, &|l| l.max_data_bytes = split_glb(&r).unwrap().1.unwrap().len());
|
||||
assert!(image_and_keys.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hierarchies_with_cycles_or_missing_nodes() {
|
||||
let mut b = B::default();
|
||||
let p = b.f32s(&QUAD, "VEC3", 3);
|
||||
let tree = |nodes: Value| {
|
||||
b.glb(json!({ "scenes": [{ "nodes": [0] }], "nodes": nodes, "meshes": [{ "primitives": [{ "attributes": { "POSITION": p } }] }] }))
|
||||
};
|
||||
// A node that is its own descendant is refused instead of walked for ever.
|
||||
assert!(matches!(parse(&tree(json!([{ "children": [1] }, { "children": [0], "mesh": 0 }]))), Err(Error::TooLarge(_))));
|
||||
assert!(matches!(parse(&tree(json!([{ "children": [0] }]))), Err(Error::TooLarge(_))));
|
||||
// Children, meshes and skins that do not exist are dropped.
|
||||
let s = parse(&tree(json!([{ "children": [1, 40], "mesh": 3, "skin": 2, "camera": 1 }, { "mesh": 0 }]))).unwrap();
|
||||
s.validate().unwrap();
|
||||
assert_eq!((s.nodes[0].children.as_slice(), s.nodes[0].mesh, s.nodes[0].skin, s.nodes[0].camera), (&[1][..], None, None, None));
|
||||
// A shared subtree expanded too often runs into the visit limit, not out of memory.
|
||||
let fan: Vec<Value> = (0..20).map(|i| if i < 19 { json!({ "children": [i + 1, i + 1] }) } else { json!({ "mesh": 0 }) }).collect();
|
||||
assert!(matches!(parse(&tree(json!(fan))), Err(Error::TooLarge(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_files_never_panic() {
|
||||
hammer(&quad(), &parse);
|
||||
hammer(&rigged(), &parse);
|
||||
let (doc, _) = gltf_text(&format!("data:application/octet-stream;base64,{}", base64(&gltf_text("", "").1)), "wood.png");
|
||||
hammer(&doc, &parse);
|
||||
}
|
||||
|
||||
fn painted() -> Scene {
|
||||
let mesh = Mesh {
|
||||
name: "Card".into(),
|
||||
positions: QUAD.chunks(3).map(|p| [f64::from(p[0]), f64::from(p[1]), f64::from(p[2])]).collect(),
|
||||
normals: vec![[0.0, 0.0, 1.0]],
|
||||
uv_sets: vec![vec![[0.0, 1.0], [1.0, 1.0], [1.0, 0.0], [0.0, 0.0]], vec![[0.5, 0.5]; 4]],
|
||||
colors: vec![[1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0], [1.0; 4]],
|
||||
parts: vec![
|
||||
// A quad: written as two triangles.
|
||||
Part {
|
||||
name: "front".into(),
|
||||
material: Some(0),
|
||||
face_sizes: vec![4],
|
||||
corners: (0..4).map(|i| Corner { position: i, normal: 0, uv: i }).collect(),
|
||||
..Part::default()
|
||||
},
|
||||
Part { material: Some(1), corners: [0, 2, 1].map(|i| Corner { position: i, normal: 0, uv: i }).to_vec(), ..Part::default() },
|
||||
Part::default(),
|
||||
],
|
||||
..Mesh::default()
|
||||
};
|
||||
let wood = TextureRef { image: 0, uv_set: 1, wrap: [Wrap::Clamp, Wrap::Repeat] };
|
||||
let materials = vec![
|
||||
Material {
|
||||
name: "Wood".into(),
|
||||
base_color: [0.5, 0.25, 0.125, 1.0],
|
||||
metallic: 0.0,
|
||||
roughness: 0.75,
|
||||
emissive: [2.0, 1.0, 0.0],
|
||||
alpha_mode: AlphaMode::Mask(0.25),
|
||||
double_sided: true,
|
||||
unlit: true,
|
||||
base_color_texture: Some(wood),
|
||||
normal_texture: Some(TextureRef { image: 1, ..TextureRef::default() }),
|
||||
normal_scale: 0.5,
|
||||
occlusion_texture: Some(TextureRef { image: 2, ..TextureRef::default() }),
|
||||
..Material::default()
|
||||
},
|
||||
Material { alpha_mode: AlphaMode::Blend, ..Material::plain("Glass", [1.0, 1.0, 1.0, 0.5]) },
|
||||
];
|
||||
let mut scene = Scene::from_meshes(vec![mesh, Mesh { name: "empty".into(), ..Mesh::default() }], materials);
|
||||
scene.images = vec![
|
||||
Image { name: "wood".into(), data: Some(b"\x89PNG wood".to_vec()), ..Image::default() },
|
||||
Image { uri: Some("bump.png".into()), ..Image::default() },
|
||||
Image::default(),
|
||||
];
|
||||
// root → the card, moved; the empty mesh's node stays, without a mesh.
|
||||
scene.nodes[0].transform[12] = 3.0;
|
||||
scene.nodes.push(Node { name: "root".into(), children: vec![0, 1], ..Node::default() });
|
||||
scene.roots = vec![2];
|
||||
scene
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn written_files_read_back() {
|
||||
let scene = painted();
|
||||
let glb = write_glb(&scene, &Limits::default()).unwrap();
|
||||
assert_eq!(glb.len() % 4, 0);
|
||||
let back = parse(&glb).unwrap();
|
||||
back.validate().unwrap();
|
||||
assert!(back.warnings.is_empty(), "{:?}", back.warnings);
|
||||
// The hierarchy: names, placement, and the mesh without triangles gone from its node.
|
||||
assert_eq!(back.roots, [2]);
|
||||
assert_eq!(
|
||||
back.nodes.iter().map(|n| (n.name.as_str(), n.mesh, n.children.len())).collect::<Vec<_>>(),
|
||||
[("Card", Some(0), 0), ("empty", None, 0), ("root", None, 2)]
|
||||
);
|
||||
assert_eq!(back.nodes[0].transform, scene.nodes[0].transform);
|
||||
// The geometry, as one vertex per distinct corner.
|
||||
let (a, b) = (scene.meshes[0].unified(), back.meshes[0].unified());
|
||||
assert_eq!((back.meshes.len(), back.meshes[0].name.as_str()), (1, "Card"));
|
||||
assert_eq!((&a.positions, &a.normals, &a.uvs, &a.colors), (&b.positions, &b.normals, &b.uvs, &b.colors));
|
||||
assert_eq!(b.parts, [vec![[0, 1, 2], [0, 2, 3]], vec![[0, 2, 1]]]);
|
||||
assert_eq!(back.meshes[0].uv_sets[1], vec![[0.5, 0.5]; 4]);
|
||||
assert_eq!(back.meshes[0].parts.iter().map(|p| p.material).collect::<Vec<_>>(), [Some(0), Some(1)]);
|
||||
// The materials; the image without bytes or a name takes its texture slot with it.
|
||||
let mut wood = scene.materials[0].clone();
|
||||
wood.occlusion_texture = None;
|
||||
assert_eq!(back.materials, [wood, scene.materials[1].clone()]);
|
||||
assert_eq!(back.images.len(), 2);
|
||||
assert_eq!((back.images[0].data.as_deref(), back.images[0].mime_type.as_deref()), (Some(&b"\x89PNG wood"[..]), Some("image/png")));
|
||||
assert_eq!(back.images[1].uri.as_deref(), Some("bump.png"));
|
||||
assert_eq!(back.external, ["bump.png"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writer_refuses_what_is_not_gltf() {
|
||||
let limits = Limits::default();
|
||||
let mut s = painted();
|
||||
s.nodes[0].transform[12] = f64::NAN;
|
||||
assert!(write_glb(&s, &limits).is_err());
|
||||
let mut s = painted();
|
||||
s.meshes[0].positions[0][0] = f64::INFINITY;
|
||||
assert!(write_glb(&s, &limits).is_err());
|
||||
let mut s = painted();
|
||||
s.materials[0].roughness = f32::NAN;
|
||||
assert!(write_glb(&s, &limits).is_err());
|
||||
let mut s = painted();
|
||||
s.meshes[0].parts[0].corners[0].position = 50;
|
||||
assert!(write_glb(&s, &limits).is_err());
|
||||
let mut s = painted();
|
||||
s.nodes[0].children = vec![2];
|
||||
assert!(write_glb(&s, &limits).is_err(), "a cycle");
|
||||
assert!(matches!(write_glb(&painted(), &Limits { max_data_bytes: 64, ..Limits::default() }), Err(Error::TooLarge(_))));
|
||||
// A scene with nothing in it is still a file.
|
||||
let empty = parse(&write_glb(&Scene::default(), &limits).unwrap()).unwrap();
|
||||
assert!(empty.meshes.is_empty() && empty.nodes.is_empty());
|
||||
// A mirrored node keeps its matrix: glTF viewers turn the winding themselves.
|
||||
let mut s = Scene::from_meshes(vec![super::tri_mesh()], vec![]);
|
||||
s.nodes[0].transform[0] = -1.0;
|
||||
let back = parse(&write_glb(&s, &limits).unwrap()).unwrap();
|
||||
assert_eq!(back.nodes[0].transform[0], -1.0);
|
||||
let t = back.meshes[0].unified().parts[0][0].map(|i| back.meshes[0].positions[i as usize]);
|
||||
assert_eq!(face_normal(t), Some([0.0, 0.0, 1.0]));
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
use super::hammer;
|
||||
use crate::obj::{material_names, read, read_mtl, sibling_name, write, write_mtl};
|
||||
use crate::{AlphaMode, Corner, Error, Limits, Material, Mesh, NONE, Part, Scene, no_external};
|
||||
|
||||
/// A unit cube with texture coordinates, normals, two materials and one face written with
|
||||
/// negative references.
|
||||
const CUBE: &str = "# a unit cube\nmtllib cube.mtl\no Cube\n\
|
||||
v -0.5 -0.5 0.5\nv 0.5 -0.5 0.5\nv 0.5 0.5 0.5\nv -0.5 0.5 0.5\nv -0.5 -0.5 -0.5\nv 0.5 -0.5 -0.5\nv 0.5 0.5 -0.5\nv -0.5 0.5 -0.5\n\
|
||||
vt 0 0\nvt 1 0\nvt 1 1\nvt 0 1\nvn 0 0 1\nvn 0 0 -1\nvn 1 0 0\nvn -1 0 0\nvn 0 1 0\nvn 0 -1 0\n\
|
||||
usemtl Red\nf 1/1/1 2/2/1 3/3/1 4/4/1\nf 6/1/2 5/2/2 8/3/2 7/4/2\nf 2/1/3 6/2/3 7/3/3 3/4/3\n\
|
||||
usemtl Gold\nf 5/1/4 1/2/4 4/3/4 8/4/4\nf 4/1/5 3/2/5 7/3/5 8/4/5\nf -4/1/6 -3/2/6 -7/3/6 -8/4/6\n";
|
||||
const CUBE_MTL: &str =
|
||||
"# materials for the cube\nnewmtl Red\nKd 1.0 0.0 0.0\nNs 98\nd 1.0\n\nnewmtl Gold\nKd 1.0 0.766 0.336\nKe 0.0 0.0 0.0\nPr 0.3\nPm 1.0\n";
|
||||
|
||||
fn parse(bytes: &[u8]) -> crate::Result<Scene> {
|
||||
read(bytes, &Limits::default(), &mut no_external)
|
||||
}
|
||||
|
||||
fn cube() -> Scene {
|
||||
read(CUBE.as_bytes(), &Limits::default(), &mut |name| (name == "cube.mtl").then(|| CUBE_MTL.as_bytes().to_vec())).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cube_with_its_material_library() {
|
||||
let s = cube();
|
||||
s.validate().unwrap();
|
||||
assert!(s.warnings.is_empty(), "{:?}", s.warnings);
|
||||
assert_eq!(s.external, ["cube.mtl"]);
|
||||
assert_eq!(s.materials.len(), 2);
|
||||
let red = &s.materials[0];
|
||||
assert_eq!((red.name.as_str(), red.base_color, red.metallic), ("Red", [1.0, 0.0, 0.0, 1.0], 0.0));
|
||||
assert!((red.roughness - (2.0f32 / 100.0).sqrt()).abs() < 1e-6);
|
||||
assert_eq!((s.materials[1].metallic, s.materials[1].roughness), (1.0, 0.3));
|
||||
let m = &s.meshes[0];
|
||||
assert_eq!((m.name.as_str(), m.parts.len(), m.triangle_count()), ("Cube", 2, 12));
|
||||
assert_eq!((m.parts[0].material, m.parts[1].material), (Some(0), Some(1)));
|
||||
// Polygons stay whole, and the pools are the file's: 8 positions, 4 UVs, 6 normals.
|
||||
assert_eq!(m.parts[0].face_sizes, [4, 4, 4]);
|
||||
assert_eq!((m.positions.len(), m.uv_sets[0].len(), m.normals.len()), (8, 4, 6));
|
||||
// Four distinct (v, vt, vn) corners per face.
|
||||
let u = m.unified();
|
||||
assert_eq!((u.positions.len(), u.normals.len(), u.uvs.len()), (24, 24, 24));
|
||||
// Every face is wound counter-clockwise from outside (its face normal agrees with `vn`).
|
||||
for t in u.parts.iter().flatten() {
|
||||
let p = t.map(|i| u.positions[i as usize]);
|
||||
let f = crate::face_normal(p).unwrap();
|
||||
let n = u.normals[t[0] as usize];
|
||||
assert!(f[0] * n[0] + f[1] * n[1] + f[2] * n[2] > 0.99);
|
||||
}
|
||||
// V is flipped to the image's top-down rows.
|
||||
assert_eq!(m.uv_sets[0][0], [0.0, 1.0]);
|
||||
// The negative references of the last face name the same vertices as positive ones would.
|
||||
let last: Vec<u32> = m.parts[1].faces().last().unwrap().iter().map(|c| c.position).collect();
|
||||
assert_eq!(last, [4, 5, 1, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_library_that_is_not_supplied_leaves_named_defaults() {
|
||||
let s = parse(CUBE.as_bytes()).unwrap();
|
||||
assert_eq!(s.external, ["cube.mtl"]);
|
||||
assert_eq!(s.warnings.len(), 1);
|
||||
assert_eq!(s.materials.iter().map(|m| m.name.as_str()).collect::<Vec<_>>(), ["Red", "Gold"]);
|
||||
assert_eq!(s.materials[0].base_color, [0.8, 0.8, 0.8, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_names_with_spaces_several_names_and_foreign_paths() {
|
||||
let src = |lib: &str| format!("mtllib {lib}\nv 0 0 0\nv 1 0 0\nv 0 1 0\nusemtl Red\nf 1 2 3\n");
|
||||
for (lib, supplied) in [("my materials.mtl", "my materials.mtl"), ("a.mtl b.mtl", "b.mtl"), ("C:\\models\\cube.mtl", "cube.mtl")] {
|
||||
let mut asked = Vec::new();
|
||||
let s = read(src(lib).as_bytes(), &Limits::default(), &mut |name| {
|
||||
asked.push(name.to_string());
|
||||
(name == supplied).then(|| CUBE_MTL.as_bytes().to_vec())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(s.materials[0].base_color, [1.0, 0.0, 0.0, 1.0], "{lib}: asked {asked:?}");
|
||||
// The name as written is asked first and is what the scene lists.
|
||||
assert_eq!((asked[0].as_str(), s.external[0].as_str()), (lib, lib));
|
||||
}
|
||||
assert_eq!(sibling_name("C:\\textures\\checker.png"), Some("checker.png"));
|
||||
assert_eq!(sibling_name("checker.png"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mtl_images_are_named_not_opened() {
|
||||
let mtl = "newmtl A\nmap_Kd my checker.png\nmap_Bump -bm 1 bump.png\nmap_Ke glow.png\nd 0.5\nnewmtl B\nmap_Kd my checker.png\nTr 0.25\n";
|
||||
let (materials, images) = read_mtl(mtl.as_bytes(), &Limits::default()).unwrap();
|
||||
// A complete name wins over its final token; options are passed over; a name is one image.
|
||||
assert_eq!(images.iter().map(|i| i.uri.as_deref().unwrap()).collect::<Vec<_>>(), ["my checker.png", "bump.png", "glow.png"]);
|
||||
assert!(images.iter().all(|i| i.data.is_none()));
|
||||
assert_eq!(materials[0].base_color_texture.unwrap().image, 0);
|
||||
assert_eq!(materials[0].normal_texture.unwrap().image, 1);
|
||||
assert_eq!(materials[0].emissive, [1.0; 3], "an emissive map without a colour shows");
|
||||
assert_eq!(materials[1].base_color_texture.unwrap().image, 0);
|
||||
assert_eq!((materials[0].alpha_mode, materials[0].base_color[3]), (AlphaMode::Blend, 0.5));
|
||||
assert_eq!(materials[1].base_color[3], 0.75);
|
||||
// Through an OBJ the image names are listed after the library.
|
||||
let s =
|
||||
read(b"mtllib m.mtl\nv 0 0 0\nv 1 0 0\nv 0 1 0\nusemtl A\nf 1 2 3\n", &Limits::default(), &mut |_| Some(mtl.as_bytes().to_vec())).unwrap();
|
||||
assert_eq!(s.external, ["m.mtl", "my checker.png", "bump.png", "glow.png"]);
|
||||
s.validate().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn objects_groups_quads_and_negative_indices() {
|
||||
let src = "# cube\no Cube\nv 0 0 0\nv 1 0 0\nv 1 1 0\nv 0 1 0\nv 0 0 1\nv 1 0 1\nv 1 1 1\nv 0 1 1\n\
|
||||
f 1 4 3 2\nf 5 6 7 8\ng side walls\nf 1 2 6 5\nf 2/1 3/2 7/3 6/4\nf 3//1 4//1 8//1 7//1\nf -4 -8 -5 -1\n\
|
||||
o Tri\nv 5 5 5\nv 6 5 5\nv 5 6 5\nf -3 -2 -1\n";
|
||||
let s = parse(src.as_bytes()).unwrap();
|
||||
s.validate().unwrap();
|
||||
assert!(s.warnings.is_empty(), "{:?}", s.warnings);
|
||||
assert_eq!(
|
||||
s.meshes.iter().map(|m| (m.name.as_str(), m.triangle_count(), m.positions.len())).collect::<Vec<_>>(),
|
||||
[("Cube", 12, 8), ("Tri", 1, 3)]
|
||||
);
|
||||
// Each object holds only its own vertices, and has a node.
|
||||
assert_eq!(s.meshes[1].positions[0], [5.0, 5.0, 5.0]);
|
||||
assert_eq!(s.nodes.iter().map(|n| n.mesh).collect::<Vec<_>>(), [Some(0), Some(1)]);
|
||||
// A group is a part; its name keeps its spaces.
|
||||
assert_eq!(s.meshes[0].parts.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(), ["", "side walls"]);
|
||||
// References to texture coordinates and normals that are not there leave the corner without.
|
||||
assert!(s.meshes[0].parts[1].corners.iter().all(|c| c.uv == NONE && c.normal == NONE));
|
||||
// Without `o`, the file is one unnamed mesh.
|
||||
let s = parse(b"v 0 0 0\nv 1 0 0\nv 0 1 0\ng a\nf 1 2 3\ng b\nf 3 2 1\n").unwrap();
|
||||
assert_eq!((s.meshes.len(), s.meshes[0].name.as_str(), s.meshes[0].parts.len()), (1, "", 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corner_normals_and_smoothing_groups() {
|
||||
let vertices = "v 0 0 0\nv 1 0 0\nv 0 1 0\nv 0 -0.8660254037844386 0.5\n";
|
||||
let authored = format!("{vertices}vn 0 0 1\nvn 0 0.5 0.8660254037844386\nf 1//-2 2//-2 3//-2\nf 2//-1 1//-1 4//-1\n");
|
||||
let s = parse(authored.as_bytes()).unwrap();
|
||||
let m = &s.meshes[0];
|
||||
assert_eq!((m.positions.len(), m.normals.len()), (4, 2));
|
||||
// Normals keep the precision of the text.
|
||||
assert_eq!(m.normals[1], [0.0, 0.5, 0.8660254037844386]);
|
||||
assert_eq!(m.parts[0].corners.iter().map(|c| c.normal).collect::<Vec<_>>(), [0, 0, 0, 1, 1, 1]);
|
||||
// A smoothing group is a part; `s off` and `s 0` end it.
|
||||
let s = parse(format!("{vertices}s 5\nf 1 2 3\ns off\nf 2 1 4\ns on\nf 1 2 3\ns 0\nf 1 2 3\n").as_bytes()).unwrap();
|
||||
assert_eq!(s.meshes[0].parts.iter().map(|p| (p.smoothing, p.triangle_count())).collect::<Vec<_>>(), [(5, 1), (0, 2), (1, 1)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertex_colours_continuations_and_polygons() {
|
||||
let src = "v 0 0 0 1 0 0\nv 1 0 0\nv 1 1 0 0 0 1\nv 0 1 0\nv 0.5 2 0\nf 1 2 \\\n 3 4 5\n";
|
||||
let s = parse(src.as_bytes()).unwrap();
|
||||
let m = &s.meshes[0];
|
||||
// Vertices without a colour are white once any has one.
|
||||
assert_eq!(m.colors, [[1.0, 0.0, 0.0, 1.0], [1.0; 4], [0.0, 0.0, 1.0, 1.0], [1.0; 4], [1.0; 4]]);
|
||||
assert_eq!((m.parts[0].face_sizes.as_slice(), m.triangle_count()), (&[5][..], 3));
|
||||
// Triangles alone keep no sizes; a later polygon fills them in.
|
||||
let s = parse(b"v 0 0 0\nv 1 0 0\nv 1 1 0\nv 0 1 0\nf 1 2 3\nf 1 2 3 4\n").unwrap();
|
||||
assert_eq!(s.meshes[0].parts[0].face_sizes, [3, 4]);
|
||||
assert!(parse(b"v 0 0 0\nv 1 0 0\nv 1 1 0\nf 1 2 3\n").unwrap().meshes[0].parts[0].face_sizes.is_empty());
|
||||
assert!(s.meshes[0].colors.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_lines_are_left_out_and_counted() {
|
||||
// A vertex that cannot be read keeps its number; faces that use it are left out.
|
||||
let s = parse(b"v nan 0 0\nv 1 0 0\nv 0 1 0\nv 0 0 1\nf 1 2 3\nf 2 3 4\nf 2 3 x\nf 2 3\n").unwrap();
|
||||
assert_eq!(s.meshes[0].triangle_count(), 1);
|
||||
assert_eq!(s.meshes[0].positions, [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]);
|
||||
assert_eq!(s.warnings, ["4 vertex or face line(s) could not be read and were left out"]);
|
||||
// Non-finite normals and texture coordinates leave their corners without.
|
||||
let s = parse(b"v 0 0 0\nv 1 0 0\nv 0 1 0\nvn nan 0 1\nvt inf 0\nf 1/1/1 2/1/1 3/1/1\n").unwrap();
|
||||
assert!(s.meshes[0].normals.is_empty() && s.meshes[0].uv_sets.is_empty());
|
||||
assert_eq!(s.warnings.len(), 1);
|
||||
for src in [
|
||||
"",
|
||||
"f 1 2 3",
|
||||
"v 1 2\nf 1 1 1",
|
||||
"v 0 0 0\n",
|
||||
"v 0 0 0\nv 1 0 0\nv 0 1 0\nf 1 2 99999999999999999999",
|
||||
"v 0 0 0\nv 1 0 0\nv 0 1 0\nf -1 -2 -9",
|
||||
"o\ng\nf",
|
||||
] {
|
||||
assert!(parse(src.as_bytes()).is_err(), "{src:?}");
|
||||
}
|
||||
assert!(parse(b"\x00\xffv 1 1 1").is_err());
|
||||
hammer(CUBE.as_bytes(), &parse);
|
||||
hammer(CUBE_MTL.as_bytes(), &|b| read(CUBE.as_bytes(), &Limits::default(), &mut |_| Some(b.to_vec())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn limits() {
|
||||
let too = |r: crate::Result<Scene>| matches!(r, Err(Error::TooLarge(_)));
|
||||
let lim = |f: &dyn Fn(&mut Limits)| {
|
||||
let mut l = Limits::default();
|
||||
f(&mut l);
|
||||
l
|
||||
};
|
||||
let read_with = |src: &str, l: &Limits| read(src.as_bytes(), l, &mut |_| Some(CUBE_MTL.as_bytes().to_vec()));
|
||||
// Each pool on its own, counting definitions no face uses.
|
||||
assert!(read_with(CUBE, &lim(&|l| l.max_vertices = 8)).is_ok());
|
||||
assert!(too(read_with(CUBE, &lim(&|l| l.max_vertices = 7))));
|
||||
let unused = "v 0 0 0\nv 1 0 0\nv 0 1 0\nvt 0 0\nvt 0 0\nvt 0 0\nvt 0 0\nf 1 2 3\n";
|
||||
assert!(too(read_with(unused, &lim(&|l| l.max_vertices = 3))), "texture coordinates");
|
||||
assert!(too(read_with(&unused.replace("vt 0 0", "vn 0 0 1"), &lim(&|l| l.max_vertices = 3))), "normals");
|
||||
assert!(read_with(CUBE, &lim(&|l| l.max_triangles = 12)).is_ok());
|
||||
assert!(too(read_with(CUBE, &lim(&|l| l.max_triangles = 11))));
|
||||
assert!(read_with(CUBE, &lim(&|l| l.max_face_corners = 4)).is_ok());
|
||||
assert!(too(read_with(CUBE, &lim(&|l| l.max_face_corners = 3))));
|
||||
assert!(read_with(CUBE, &lim(&|l| l.max_parts = 2)).is_ok());
|
||||
assert!(too(read_with(CUBE, &lim(&|l| l.max_parts = 1))));
|
||||
assert!(read_with(CUBE, &lim(&|l| l.max_materials = 2)).is_ok());
|
||||
assert!(too(read_with(CUBE, &lim(&|l| l.max_materials = 1))));
|
||||
// Materials named by `usemtl` alone count too.
|
||||
assert!(too(read(CUBE.as_bytes(), &lim(&|l| l.max_materials = 1), &mut no_external)));
|
||||
let two = "o a\nv 0 0 0\nv 1 0 0\nv 0 1 0\nf 1 2 3\no b\nf 1 2 3\n";
|
||||
assert!(read_with(two, &lim(&|l| l.max_meshes = 2)).is_ok());
|
||||
assert!(too(read_with(two, &lim(&|l| l.max_meshes = 1))));
|
||||
assert!(read_with(CUBE, &lim(&|l| l.max_data_bytes = CUBE_MTL.len())).is_ok());
|
||||
assert!(too(read_with(CUBE, &lim(&|l| l.max_data_bytes = CUBE_MTL.len() - 1))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn written_files_read_back() {
|
||||
let scene = cube();
|
||||
let limits = Limits::default();
|
||||
let text = write(&scene, Some("cube.mtl"), &limits).unwrap();
|
||||
let mtl = write_mtl(&scene);
|
||||
let back = read(text.as_bytes(), &limits, &mut |name| (name == "cube.mtl").then(|| mtl.as_bytes().to_vec())).unwrap();
|
||||
assert!(back.warnings.is_empty(), "{:?}", back.warnings);
|
||||
assert_eq!(back.meshes, scene.meshes);
|
||||
for (a, b) in back.materials.iter().zip(&scene.materials) {
|
||||
assert_eq!((a.name.as_str(), a.metallic), (b.name.as_str(), b.metallic));
|
||||
assert!(a.base_color.iter().zip(b.base_color).all(|(x, y)| (x - y).abs() < 1e-5), "{a:?} {b:?}");
|
||||
assert!((a.roughness - b.roughness).abs() < 1e-5);
|
||||
}
|
||||
// Without a library name no `mtllib` is written; `usemtl` still names the materials.
|
||||
let alone = write(&scene, None, &limits).unwrap();
|
||||
assert!(!alone.contains("mtllib") && alone.contains("usemtl Red\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writer_places_meshes_keeps_colours_and_names_materials_apart() {
|
||||
let mesh = Mesh {
|
||||
name: "A\nB".into(),
|
||||
positions: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
|
||||
normals: vec![[0.0, 0.0, 1.0]],
|
||||
colors: vec![[1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0]],
|
||||
parts: vec![
|
||||
Part {
|
||||
name: "g1".into(),
|
||||
material: Some(0),
|
||||
smoothing: 2,
|
||||
corners: [0, 1, 2].map(|p| Corner { position: p, normal: 0, uv: NONE }).to_vec(),
|
||||
..Part::default()
|
||||
},
|
||||
Part { material: Some(1), corners: [0, 1, 2].map(Corner::at).to_vec(), ..Part::default() },
|
||||
],
|
||||
..Mesh::default()
|
||||
};
|
||||
let mut scene =
|
||||
Scene::from_meshes(vec![mesh], vec![Material::plain("Steel", [1.0; 4]), Material::plain("steel", [1.0; 4]), Material::plain(" ", [1.0; 4])]);
|
||||
assert_eq!(material_names(&scene), ["Steel", "steel_2", "material"]);
|
||||
// The mesh placed twice: moved, and mirrored.
|
||||
scene.nodes.push(scene.nodes[0].clone());
|
||||
scene.roots.push(1);
|
||||
scene.nodes[0].transform[14] = 3.0;
|
||||
scene.nodes[1].transform[0] = -1.0;
|
||||
let text = write(&scene, Some("m.mtl"), &Limits::default()).unwrap();
|
||||
assert!(text.contains("o A B\n"), "a control character cannot split the line: {text}");
|
||||
assert!(text.contains("v 0 0 3 1 0 0\n") && text.contains("vn 0 0 1\n"));
|
||||
assert!(text.contains("g g1\nusemtl Steel\ns 2\nf 1//1 2//1 3//1\nusemtl steel_2\ns off\nf 1 2 3\n"), "{text}");
|
||||
// The second object's indices run on, and its mirrored faces are turned back.
|
||||
assert!(text.contains("s 2\nf 6//2 5//2 4//2\n"), "{text}");
|
||||
let back = parse(text.as_bytes()).unwrap();
|
||||
assert_eq!(back.meshes.len(), 2);
|
||||
assert_eq!(back.meshes[0].colors, scene.meshes[0].colors);
|
||||
for m in &back.meshes {
|
||||
let u = m.unified();
|
||||
let t = u.parts[0][0].map(|i| u.positions[i as usize]);
|
||||
assert!(crate::face_normal(t).unwrap()[2] > 0.99, "both copies face +Z");
|
||||
}
|
||||
// Nothing placed, or a mesh that fails validation, is an error.
|
||||
let mut empty = scene.clone();
|
||||
empty.roots.clear();
|
||||
assert!(write(&empty, None, &Limits::default()).is_err());
|
||||
scene.meshes[0].parts[0].corners[0].position = 9;
|
||||
assert!(write(&scene, None, &Limits::default()).is_err());
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
use super::hammer;
|
||||
use crate::ply::{Encoding, Kind, MAX_ELEMENTS, MAX_PROPERTIES, Rows, Scalar, is_ply, parse_header, read};
|
||||
use crate::{Error, Limits, Scene};
|
||||
|
||||
fn parse(bytes: &[u8]) -> crate::Result<Scene> {
|
||||
read(bytes, &Limits::default())
|
||||
}
|
||||
|
||||
/// A binary file with CRLF header lines, an element before the vertices, a property after the
|
||||
/// colours and one triangle.
|
||||
fn binary(big: bool) -> Vec<u8> {
|
||||
let order = if big { "big" } else { "little" };
|
||||
let mut b = format!(
|
||||
"ply\r\nformat binary_{order}_endian 1.0\r\ncomment made by hand\r\nelement camera 1\r\nproperty double k\r\nelement vertex 3\r\nproperty float32 x\r\nproperty float32 y\r\nproperty float32 z\r\nproperty uchar red\r\nproperty uchar green\r\nproperty uchar blue\r\nproperty float intensity\r\nelement face 1\r\nproperty list uchar int vertex_indices\r\nend_header\r\n"
|
||||
)
|
||||
.into_bytes();
|
||||
let f = |v: f32| if big { v.to_be_bytes() } else { v.to_le_bytes() };
|
||||
b.extend(if big { 9.5f64.to_be_bytes() } else { 9.5f64.to_le_bytes() });
|
||||
for (i, p) in [[0.0f32, 0.0, 0.0], [1.5, 0.0, 0.0], [0.0, 2.5, -3.0]].iter().enumerate() {
|
||||
for v in p {
|
||||
b.extend(f(*v));
|
||||
}
|
||||
b.extend([10 * i as u8, 20, 255]);
|
||||
b.extend(f(0.5));
|
||||
}
|
||||
b.push(3);
|
||||
for i in [0i32, 1, 2] {
|
||||
b.extend(if big { i.to_be_bytes() } else { i.to_le_bytes() });
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
/// A quad with normals, texture coordinates and 16-bit colours with alpha.
|
||||
const FULL: &str = "ply\nformat ascii 1.0\ncomment the end_header is not here\nelement vertex 4\nproperty double x\nproperty double y\nproperty double z\n\
|
||||
property float nx\nproperty float ny\nproperty float nz\nproperty float s\nproperty float t\n\
|
||||
property ushort red\nproperty ushort green\nproperty ushort blue\nproperty ushort alpha\n\
|
||||
element face 1\nproperty list uchar uint vertex_index\nend_header\n\
|
||||
0 0 0 0 0 1 0 0 65535 0 0 65535\n1 0 0 0 0 1 1 0 0 65535 0 65535\n1 1 0 0 0 1 1 1 0 0 65535 0\n0.1 1 0 0 0 1 0 0.25 0 0 0 65535\n4 0 1 2 3\n";
|
||||
|
||||
#[test]
|
||||
fn binary_little_and_big_endian_with_colours_and_other_elements() {
|
||||
for big in [false, true] {
|
||||
let bytes = binary(big);
|
||||
let h = parse_header(&bytes).unwrap();
|
||||
assert_eq!(h.encoding, if big { Encoding::Big } else { Encoding::Little });
|
||||
assert_eq!((h.count("vertex"), h.count("face"), h.count("edge")), (3, 1, 0));
|
||||
assert_eq!(h.comments, ["made by hand"]);
|
||||
let s = parse(&bytes).unwrap();
|
||||
s.validate().unwrap();
|
||||
let m = &s.meshes[0];
|
||||
assert_eq!(m.positions, [[0.0, 0.0, 0.0], [1.5, 0.0, 0.0], [0.0, 2.5, -3.0]]);
|
||||
assert_eq!(m.colors[1], [10.0 / 255.0, 20.0 / 255.0, 1.0, 1.0]);
|
||||
assert_eq!(m.parts[0].corners.iter().map(|c| c.position).collect::<Vec<_>>(), [0, 1, 2]);
|
||||
assert!(m.parts[0].face_sizes.is_empty() && m.normals.is_empty() && m.uv_sets.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ascii_with_normals_uvs_colours_and_a_quad() {
|
||||
let s = parse(FULL.as_bytes()).unwrap();
|
||||
s.validate().unwrap();
|
||||
let m = &s.meshes[0];
|
||||
// Doubles keep their precision.
|
||||
assert_eq!(m.positions[3], [0.1, 1.0, 0.0]);
|
||||
assert_eq!(m.normals, vec![[0.0, 0.0, 1.0]; 4]);
|
||||
// V is flipped to the image's top-down rows.
|
||||
assert_eq!(m.uv_sets[0], [[0.0, 1.0], [1.0, 1.0], [1.0, 0.0], [0.0, 0.75]]);
|
||||
// Integer channels are scaled by their type's range.
|
||||
assert_eq!(m.colors, [[1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 1.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]]);
|
||||
// The polygon stays whole, and each corner names its vertex's normal and UV.
|
||||
assert_eq!(m.parts[0].face_sizes, [4]);
|
||||
assert!(m.parts[0].corners.iter().all(|c| c.normal == c.position && c.uv == c.position));
|
||||
// Float colours are taken as they are; `u`/`v` name texture coordinates too.
|
||||
let floats = "ply\nformat ascii 1.0\nelement vertex 3\nproperty float x\nproperty float y\nproperty float z\nproperty float red\nproperty float green\nproperty float blue\nproperty float u\nproperty float v\nelement face 1\nproperty list uchar int vertex_indices\nend_header\n0 0 0 1 0 0 0 0\n1 0 0 0 0.5 0 1 0\n0 1 0 0 0 1 0 1\n3 0 1 2\n";
|
||||
let s = parse(floats.as_bytes()).unwrap();
|
||||
assert_eq!(s.meshes[0].colors[1], [0.0, 0.5, 0.0, 1.0]);
|
||||
assert_eq!(s.meshes[0].uv_sets[0][2], [0.0, 0.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_clouds_and_faces_before_vertices() {
|
||||
let points = "ply\nformat ascii 1.0\nelement vertex 2\nproperty float32 x\nproperty float32 y\nproperty float32 z\nend_header\n1 2 3\n4 5 6\n";
|
||||
let s = parse(points.as_bytes()).unwrap();
|
||||
assert_eq!((s.meshes[0].positions.as_slice(), s.meshes[0].parts.len()), (&[[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]][..], 0));
|
||||
assert!(s.meshes[0].colors.is_empty());
|
||||
let face_first = "ply\nformat ascii 1.0\nelement face 1\nproperty list uchar int vertex_indices\nelement vertex 3\nproperty float x\nproperty float y\nproperty float z\nend_header\n3 0 1 2\n0 0 0\n1 0 0\n0 1 0\n";
|
||||
let s = parse(face_first.as_bytes()).unwrap();
|
||||
assert_eq!((s.meshes[0].triangle_count(), s.meshes[0].positions.len()), (1, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn faces_that_cannot_be_read_are_left_out() {
|
||||
let head = "ply\nformat ascii 1.0\nelement vertex 3\nproperty float x\nproperty float y\nproperty float z\nelement face 2\nproperty list uchar int vertex_indices\nend_header\n";
|
||||
// A face naming a vertex that is not there.
|
||||
let s = parse(format!("{head}0 0 0 1 0 0 0 1 0\n3 0 1 2\n3 0 1 99\n").as_bytes()).unwrap();
|
||||
assert_eq!((s.meshes[0].triangle_count(), s.warnings.len()), (1, 1));
|
||||
// A face naming a vertex whose position is not a number; the position itself is kept.
|
||||
let s = parse(format!("{head}0 0 0 1 0 0 nan 1 0\n3 0 1 2\n2 0 1\n").as_bytes()).unwrap();
|
||||
assert_eq!((s.meshes[0].triangle_count(), s.meshes[0].positions.len()), (0, 3));
|
||||
assert_eq!(s.warnings, ["2 face(s) could not be read and were left out"]);
|
||||
s.validate().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_serve_callers_that_read_more_than_a_mesh() {
|
||||
// Every scalar property of the vertices, as a point-cloud reader would take them.
|
||||
let bytes = binary(true);
|
||||
let h = parse_header(&bytes).unwrap();
|
||||
let mut rows = Rows::new(&h, &bytes);
|
||||
rows.skip_element(&h.elements[0], 16).unwrap();
|
||||
let vertex = &h.elements[1];
|
||||
rows.check(vertex).unwrap();
|
||||
assert_eq!(vertex.row_size(), Some(19));
|
||||
assert_eq!(vertex.find(&["Intensity"]), Some((6, Scalar::F32)));
|
||||
let mut intensity = Vec::new();
|
||||
for _ in 0..vertex.count {
|
||||
for (i, p) in vertex.properties.iter().enumerate() {
|
||||
let Kind::Scalar(t) = p.kind else { panic!("the vertices have no lists") };
|
||||
let v = rows.value(t).unwrap();
|
||||
if i == 6 {
|
||||
intensity.push(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(intensity, [0.5; 3]);
|
||||
assert_eq!(rows.count(Scalar::U8, 16).unwrap(), 3);
|
||||
assert!(Scalar::I16.is_integer() && !Scalar::F64.is_integer());
|
||||
assert_eq!(Scalar::U16.unit(65_535.0), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_ply_is_refused_without_a_panic() {
|
||||
assert!(is_ply(b"ply\n") && !is_ply(b"plyx") && !is_ply(b"pl"));
|
||||
let cases: [&[u8]; 14] = [
|
||||
b"",
|
||||
b"ply",
|
||||
b"ply\nformat ascii 1.0\n",
|
||||
b"ply\nformat ascii 1.0\nelement vertex 18446744073709551615\nproperty float x\nproperty float y\nproperty float z\nend_header\n1 2 3",
|
||||
b"ply\nformat binary_little_endian 1.0\nelement vertex 18446744073709551615\nproperty float x\nproperty float y\nproperty float z\nend_header\n1234",
|
||||
b"ply\nformat binary_little_endian 1.0\nelement vertex 1\nproperty list uchar float x\nproperty float y\nproperty float z\nend_header\n\xff",
|
||||
b"ply\nformat ascii 1.0\nelement vertex 1\nproperty float x\nend_header\n1",
|
||||
b"ply\nformat ascii 1.0\nproperty float x\nend_header\n",
|
||||
b"ply\nformat binary_big_endian 1.0\nelement face 2\nproperty list float int vertex_indices\nend_header\n",
|
||||
b"ply\nformat utf8 1.0\nelement vertex 0\nend_header\n",
|
||||
b"ply\nelement vertex 0\nproperty float x\nproperty float y\nproperty float z\nend_header\n",
|
||||
b"ply\nformat ascii 1.0\nelement vertex 1\nproperty quad x\nend_header\n",
|
||||
b"ply\nformat ascii 1.0\nelement vertex 1\nproperty float x\nproperty float y\nproperty float z\nend_header trailing\n1 2 3\n",
|
||||
b"ply\nformat ascii 1.0\nelement vertex 1\nproperty float x\nproperty float y\nproperty float z\nend_header\n1 two 3\n",
|
||||
];
|
||||
for c in cases {
|
||||
assert!(parse(c).is_err(), "{:?}", String::from_utf8_lossy(c));
|
||||
}
|
||||
// A cut-off file is refused, wherever it is cut.
|
||||
let good = binary(false);
|
||||
for n in 0..good.len() {
|
||||
assert!(parse(&good[..n]).is_err(), "cut at {n}");
|
||||
}
|
||||
// A header that never ends is not searched past its first megabyte.
|
||||
let mut endless = b"ply\nformat ascii 1.0\n".to_vec();
|
||||
endless.resize((1 << 20) + 64, b'c');
|
||||
endless.extend(b"\nend_header\n");
|
||||
assert!(parse(&endless).is_err());
|
||||
for good in [binary(false), binary(true), FULL.as_bytes().to_vec()] {
|
||||
hammer(&good, &parse);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn limits() {
|
||||
let too = |r: crate::Result<Scene>| matches!(r, Err(Error::TooLarge(_)));
|
||||
let full = FULL.as_bytes();
|
||||
assert!(read(full, &Limits { max_vertices: 4, ..Limits::default() }).is_ok());
|
||||
assert!(too(read(full, &Limits { max_vertices: 3, ..Limits::default() })));
|
||||
assert!(read(full, &Limits { max_triangles: 2, ..Limits::default() }).is_ok());
|
||||
assert!(too(read(full, &Limits { max_triangles: 1, ..Limits::default() })));
|
||||
assert!(read(full, &Limits { max_face_corners: 4, ..Limits::default() }).is_ok());
|
||||
assert!(too(read(full, &Limits { max_face_corners: 3, ..Limits::default() })));
|
||||
// The header's own bounds.
|
||||
let elements = |n: usize| {
|
||||
format!(
|
||||
"ply\nformat ascii 1.0\n{}element vertex 0\nproperty float x\nproperty float y\nproperty float z\nend_header\n",
|
||||
"element e 0\n".repeat(n)
|
||||
)
|
||||
};
|
||||
assert!(parse(elements(MAX_ELEMENTS - 1).as_bytes()).is_ok());
|
||||
assert!(too(parse(elements(MAX_ELEMENTS).as_bytes())));
|
||||
let properties = |n: usize| {
|
||||
format!(
|
||||
"ply\nformat ascii 1.0\nelement vertex 0\n{}end_header\n",
|
||||
"property float x\nproperty float y\nproperty float z\n".repeat(n).lines().take(n).map(|l| format!("{l}\n")).collect::<String>()
|
||||
)
|
||||
};
|
||||
assert!(parse(properties(MAX_PROPERTIES).as_bytes()).is_ok());
|
||||
assert!(too(parse(properties(MAX_PROPERTIES + 1).as_bytes())));
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
use super::hammer;
|
||||
use crate::stl::{read, write_ascii, write_binary};
|
||||
use crate::{Error, IDENTITY, Limits, Scene};
|
||||
|
||||
/// A facet: normal, vertices, attribute word.
|
||||
type Facet = ([f32; 3], [[f32; 3]; 3], u16);
|
||||
|
||||
/// A binary STL: `header` (padded to 80 bytes) and its facets.
|
||||
fn binary_stl(header: &[u8], facets: &[Facet]) -> Vec<u8> {
|
||||
let mut b = header.to_vec();
|
||||
b.resize(80, 0);
|
||||
b.extend((facets.len() as u32).to_le_bytes());
|
||||
for (n, v, attr) in facets {
|
||||
for c in n.iter().chain(v.iter().flatten()) {
|
||||
b.extend(c.to_le_bytes());
|
||||
}
|
||||
b.extend(attr.to_le_bytes());
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
/// The unit square in the XY plane, facing +Z, as two facets.
|
||||
const SQUARE: [[[f32; 3]; 3]; 2] = [[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0]], [[0.0, 0.0, 0.0], [1.0, 1.0, 0.0], [0.0, 1.0, 0.0]]];
|
||||
|
||||
fn parse(bytes: &[u8]) -> crate::Result<Scene> {
|
||||
read(bytes, &Limits::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_is_faceted_and_keeps_the_files_axes() {
|
||||
// A header that begins with `solid`, as many binary exporters write it.
|
||||
let bytes = binary_stl(b"solid part", &[([0.0, 0.0, 1.0], SQUARE[0], 0), ([0.0, 0.0, 1.0], SQUARE[1], 0)]);
|
||||
let s = parse(&bytes).unwrap();
|
||||
s.validate().unwrap();
|
||||
let m = &s.meshes[0];
|
||||
assert_eq!(m.name, "part");
|
||||
assert!(s.materials.is_empty() && s.warnings.is_empty());
|
||||
assert_eq!((m.triangle_count(), m.positions.len(), m.parts.len()), (2, 6, 1));
|
||||
assert!(m.uv_sets.is_empty());
|
||||
assert_eq!(m.normals, vec![[0.0, 0.0, 1.0]; 2]);
|
||||
assert_eq!(m.positions[2], [1.0, 1.0, 0.0]);
|
||||
// One root node places the mesh.
|
||||
assert_eq!((s.roots.as_slice(), s.nodes[0].mesh, s.nodes[0].transform), (&[0][..], Some(0), IDENTITY));
|
||||
// Trailing bytes after the last facet are ignored.
|
||||
let mut longer = bytes.clone();
|
||||
longer.extend([0u8; 7]);
|
||||
assert_eq!(parse(&longer).unwrap().meshes, s.meshes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ascii_with_polygon_loops_and_several_solids() {
|
||||
let src = "solid Bracket\n facet normal 0 0 1\n outer loop\n vertex 0 0 0\n vertex 1 0 0\n vertex 1 1 0\n vertex 0 1 0\n endloop\n endfacet\nendsolid Bracket\n\
|
||||
solid second\nfacet normal 0 0 0\nouter loop\nvertex 0 0 1\nvertex 1 0 1\nvertex 1E0 1 1\nendloop\nendfacet\nendsolid\n";
|
||||
let s = parse(src.as_bytes()).unwrap();
|
||||
assert_eq!(s.meshes[0].name, "Bracket");
|
||||
// The quad fans into two triangles; the second solid adds one.
|
||||
assert_eq!(s.meshes[0].triangle_count(), 3);
|
||||
assert!(parse(b"solid x\nfacet normal 0 0 1\nouter loop\nvertex 0 zero 0\n").is_err());
|
||||
// A name that is not UTF-8, and a facet with a non-finite vertex, which is left out.
|
||||
let mut ascii = b"solid caf\xe9\n".to_vec();
|
||||
ascii.extend(b"facet normal 0 0 1\nouter loop\nvertex 0 0 0\nvertex 1 0 0\nvertex 0 1 0\nendloop\nendfacet\n");
|
||||
ascii.extend(b"facet normal 0 0 1\nouter loop\nvertex 0 0 0\nvertex nan 0 0\nvertex 0 1 0\nendloop\nendfacet\nendsolid\n");
|
||||
let s = parse(&ascii).unwrap();
|
||||
assert_eq!((s.meshes[0].triangle_count(), s.warnings.len()), (1, 1));
|
||||
// ASCII keeps the precision of its text.
|
||||
let s = parse(b"solid p\nfacet normal 0 0 1\nouter loop\nvertex 0.1 0 0\nvertex 1 0 0\nvertex 0 1 0\nendloop\nendfacet\nendsolid\n").unwrap();
|
||||
assert_eq!(s.meshes[0].positions[0][0], 0.1f64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_colours() {
|
||||
// VisCAM / SolidView: bit 15 set, red in bits 10–14.
|
||||
let red = 0x8000 | (31 << 10);
|
||||
let s = parse(&binary_stl(b"", &[([0.0; 3], SQUARE[0], red), ([0.0; 3], SQUARE[1], 0)])).unwrap();
|
||||
s.validate().unwrap();
|
||||
assert_eq!((s.meshes[0].parts.len(), s.materials.len()), (2, 2));
|
||||
assert!(s.materials[0].base_color.iter().zip([1.0, 0.0, 0.0, 1.0]).all(|(x, y)| (x - y).abs() < 1e-5));
|
||||
assert_eq!((s.materials[0].name.as_str(), s.materials[0].metallic), ("#FF0000", 0.0));
|
||||
assert_eq!(s.meshes[0].parts[1].material, Some(1));
|
||||
// Materialise Magics: a part colour in the header; bit 15 clear = the facet's own colour
|
||||
// with red in bits 0–4.
|
||||
let mut header = b"COLOR=".to_vec();
|
||||
header.extend([0, 255, 0, 255]);
|
||||
let s = parse(&binary_stl(&header, &[([0.0; 3], SQUARE[0], 31), ([0.0; 3], SQUARE[1], 0x8000)])).unwrap();
|
||||
let close = |a: [f32; 4], b: [f32; 4]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-5);
|
||||
assert!(close(s.materials[0].base_color, [1.0, 0.0, 0.0, 1.0]), "{:?}", s.materials[0]);
|
||||
assert!(close(s.materials[1].base_color, [0.0, 1.0, 0.0, 1.0]), "{:?}", s.materials[1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quirks_and_hostile_input() {
|
||||
// Claims four billion facets but has one: truncated.
|
||||
let mut bytes = binary_stl(b"", &[([0.0; 3], SQUARE[0], 0)]);
|
||||
bytes.splice(80..84, u32::MAX.to_le_bytes());
|
||||
assert!(parse(&bytes).is_err());
|
||||
// A count left at 0 over a file with facets: they are read, with a warning.
|
||||
bytes.splice(80..84, 0u32.to_le_bytes());
|
||||
let s = parse(&bytes).unwrap();
|
||||
assert_eq!((s.meshes[0].triangle_count(), s.warnings.len()), (1, 1));
|
||||
assert!(parse(b"").is_err());
|
||||
assert!(parse(&[0u8; 83]).is_err());
|
||||
assert!(parse(b"solid").is_err());
|
||||
assert!(parse(&[0xff; 200]).is_err());
|
||||
// No facets, or only non-finite ones.
|
||||
assert!(parse(&binary_stl(b"", &[])).is_err());
|
||||
assert!(parse(&binary_stl(b"", &[([0.0; 3], [[f32::NAN, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], 0)])).is_err());
|
||||
assert!(parse(b"solid empty\nendsolid empty\n").is_err());
|
||||
// A non-finite facet among good ones is left out, with a warning.
|
||||
let s =
|
||||
parse(&binary_stl(b"", &[([0.0; 3], SQUARE[0], 0), ([0.0; 3], [[f32::INFINITY, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], 0)])).unwrap();
|
||||
assert_eq!((s.meshes[0].triangle_count(), s.warnings.len()), (1, 1));
|
||||
// A degenerate facet keeps its stored normal; a garbage stored normal loses to the winding.
|
||||
let s = parse(&binary_stl(b"", &[([1.0, 0.0, 0.0], [[0.0; 3]; 3], 0), ([f32::NAN, 9.0, -7.0], SQUARE[0], 0)])).unwrap();
|
||||
assert_eq!(s.meshes[0].normals, [[1.0, 0.0, 0.0], [0.0, 0.0, 1.0]]);
|
||||
hammer(&binary_stl(b"solid x", &[([0.0; 3], SQUARE[0], 0x8000 | 31), ([0.0; 3], SQUARE[1], 0)]), &parse);
|
||||
hammer(b"solid a\nfacet normal 0 0 1\nouter loop\nvertex 0 0 0\nvertex 1 0 0\nvertex 0 1 0\nendloop\nendfacet\nendsolid a\n", &parse);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn limits() {
|
||||
let two = binary_stl(b"", &[([0.0; 3], SQUARE[0], 0), ([0.0; 3], SQUARE[1], 0)]);
|
||||
let ascii = write_ascii(&parse(&two).unwrap(), "sq", &Limits::default()).unwrap();
|
||||
for bytes in [two.as_slice(), ascii.as_bytes()] {
|
||||
assert!(read(bytes, &Limits { max_triangles: 2, ..Limits::default() }).is_ok());
|
||||
assert!(matches!(read(bytes, &Limits { max_triangles: 1, ..Limits::default() }), Err(Error::TooLarge(_))));
|
||||
assert!(read(bytes, &Limits { max_vertices: 6, ..Limits::default() }).is_ok());
|
||||
assert!(matches!(read(bytes, &Limits { max_vertices: 5, ..Limits::default() }), Err(Error::TooLarge(_))));
|
||||
}
|
||||
let quad = "solid q\nfacet normal 0 0 1\nouter loop\nvertex 0 0 0\nvertex 1 0 0\nvertex 1 1 0\nvertex 0 1 0\nendloop\nendfacet\nendsolid\n";
|
||||
assert!(read(quad.as_bytes(), &Limits { max_face_corners: 4, ..Limits::default() }).is_ok());
|
||||
assert!(matches!(read(quad.as_bytes(), &Limits { max_face_corners: 3, ..Limits::default() }), Err(Error::TooLarge(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn written_files_read_back() {
|
||||
let mut scene = Scene::from_meshes(vec![super::tri_mesh()], vec![]);
|
||||
// The node's placement is applied: STL has no hierarchy.
|
||||
scene.nodes[0].transform[12] = 10.0;
|
||||
let limits = Limits::default();
|
||||
let binary = write_binary(&scene, "solid tri", &limits).unwrap();
|
||||
assert_eq!(binary.len(), 84 + 2 * 50);
|
||||
assert!(!binary.starts_with(b"solid"), "a binary header that starts with `solid` confuses readers");
|
||||
let ascii = write_ascii(&scene, "tri one", &limits).unwrap();
|
||||
assert!(ascii.starts_with("solid trione\n") && ascii.ends_with("endsolid trione\n"));
|
||||
for bytes in [binary.as_slice(), ascii.as_bytes()] {
|
||||
let back = parse(bytes).unwrap();
|
||||
let m = &back.meshes[0];
|
||||
assert_eq!(m.triangle_count(), 2);
|
||||
assert_eq!(m.positions, [[10.0, 0.0, 0.0], [11.0, 0.0, 0.0], [10.0, 1.0, 0.0], [10.0, 0.0, 0.0], [10.0, 0.0, 1.0], [11.0, 0.0, 0.0]]);
|
||||
assert_eq!(m.normals, [[0.0, 0.0, 1.0], [0.0, 1.0, 0.0]]);
|
||||
}
|
||||
// A scene that cannot be walked or indexed is an error.
|
||||
let mut bad = scene.clone();
|
||||
bad.meshes[0].parts[0].corners[0].position = 99;
|
||||
assert!(write_binary(&bad, "x", &limits).is_err());
|
||||
bad.nodes[0].children = vec![0];
|
||||
assert!(write_ascii(&bad, "x", &limits).is_err());
|
||||
}
|
||||
Reference in New Issue
Block a user