Import glTF 2.0 files (.glb, .gltf) as mesh bodies (#1512)
glTF files open, insert and drop like 3MF/STL/OBJ: a component named after the file, the node tree as nested rigid components, repeated meshes as occurrences, metres to mm, Y up turned to Z up by default (up: z keeps axes), materials as PBR appearances. Bounded, hostile-input tested reader; no new persisted data. Closes #1505. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5823900439
commit
a13124acdd
19 files changed
+1889
-71
No files matched your search
@@ -0,0 +1,871 @@
|
||||
//! glTF 2.0 import (`.glb`, `.gltf` with its `.bin` files next to it or embedded as `data:`
|
||||
//! URIs): triangle meshes become mesh bodies, the node tree a component plan (#1505).
|
||||
//!
|
||||
//! The `gltf` crate reads the JSON document and checks its references; the GLB container and
|
||||
//! every buffer, view and accessor are read here, with each length checked against the bytes
|
||||
//! that are really there. The file is untrusted: sizes, counts, nesting and the nodes visited
|
||||
//! are capped, and anything wrong is an error or a warning, never a panic.
|
||||
//!
|
||||
//! Geometry: glTF is in metres with Y up. Coordinates are scaled to millimetres here; turning
|
||||
//! Y up into Z up is the caller's (the import component's placement, as for STEP). A node's
|
||||
//! placement becomes an occurrence when it can be one (the rigid part of its transform); what
|
||||
//! is left (scale, shear, a mirror) is baked into the node's meshes, so the hierarchy is kept
|
||||
//! even under a scaled parent. A node that places the same mesh (and the same subtree) the
|
||||
//! same way as an earlier one is another occurrence of that node's component.
|
||||
//!
|
||||
//! Materials: one body per mesh and material; the material's base colour (linear in glTF,
|
||||
//! sRGB here), alpha (blended materials only) and metallic/roughness factors give the body's
|
||||
//! look. Textures are not read.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
use gltf::accessor::{DataType, Dimensions};
|
||||
use gltf::mesh::Mode;
|
||||
use solidcraft_doc::appearance::Look;
|
||||
use solidcraft_doc::{Mat, MeshData, mat_mul};
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
use crate::{IoError, MAX_3MF_BYTES, MeshFeature, Result};
|
||||
|
||||
/// Largest `.glb` / `.gltf` file read.
|
||||
pub const MAX_GLTF_BYTES: usize = MAX_3MF_BYTES;
|
||||
/// Most bytes of external and embedded buffers read for one file.
|
||||
const MAX_BUFFER_BYTES: usize = 1 << 30;
|
||||
/// Most vertices and triangles, over all bodies.
|
||||
const MAX_VERTICES: usize = 20_000_000;
|
||||
const MAX_TRIANGLES: usize = 20_000_000;
|
||||
/// Most node placements followed (a node tree that shares or loops is cut off here).
|
||||
const MAX_PLACEMENTS: usize = 100_000;
|
||||
/// Deepest nesting kept as components; deeper nodes are baked into their parent's.
|
||||
const MAX_COMPONENT_DEPTH: usize = 32;
|
||||
/// Deepest nesting followed at all.
|
||||
const MAX_DEPTH: usize = 64;
|
||||
/// Most components one file makes.
|
||||
const MAX_COMPONENTS: usize = 5_000;
|
||||
/// Millimetres in a metre.
|
||||
const MM: f64 = 1000.0;
|
||||
|
||||
/// Is this a glTF file (`.glb`, `.gltf`, any case)?
|
||||
pub fn is_gltf_path(path: &str) -> bool {
|
||||
std::path::Path::new(path).extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("glb") || e.eq_ignore_ascii_case("gltf"))
|
||||
}
|
||||
|
||||
/// A placement of a component of the plan in another (`parent`, or the import's own component
|
||||
/// when `None`), by a rigid transform in millimetres.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PlanPlace {
|
||||
pub slot: usize,
|
||||
pub parent: Option<usize>,
|
||||
pub transform: Mat,
|
||||
}
|
||||
|
||||
/// How a glTF file's nodes become components below the import's own: `slots` are the
|
||||
/// components (by name), `places` their placements (the first one makes the component, later
|
||||
/// ones are more occurrences of it), and `bodies` the component each mesh body goes into
|
||||
/// (`None`: the import's own). Each body's coordinates are already in its component's frame.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct MeshPlan {
|
||||
pub slots: Vec<String>,
|
||||
pub places: Vec<PlanPlace>,
|
||||
pub bodies: Vec<Option<usize>>,
|
||||
}
|
||||
|
||||
/// A glTF file read for import.
|
||||
pub struct GltfImport {
|
||||
/// The MeshImport feature: one mesh body per glTF mesh and material.
|
||||
pub feature: MeshFeature,
|
||||
/// Where the bodies go.
|
||||
pub plan: MeshPlan,
|
||||
/// Each body's look from its material (`None`: no material).
|
||||
pub looks: Vec<Option<Look>>,
|
||||
}
|
||||
|
||||
impl GltfImport {
|
||||
/// Build every body where its component's first placement puts it, in the import's own
|
||||
/// frame, and drop the components.
|
||||
pub fn flatten(&mut self) {
|
||||
if self.plan.places.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut world: Vec<Option<Mat>> = vec![None; self.plan.slots.len()];
|
||||
for p in &self.plan.places {
|
||||
let parent = match p.parent {
|
||||
None => Some(solidcraft_doc::IDENTITY),
|
||||
Some(q) => world.get(q).copied().flatten(),
|
||||
};
|
||||
if let (Some(parent), Some(slot @ None)) = (parent, world.get_mut(p.slot)) {
|
||||
*slot = Some(mat_mul(&parent, &p.transform));
|
||||
}
|
||||
}
|
||||
if let solidcraft_doc::FeatureKind::MeshImport { meshes, .. } = &mut self.feature.kind {
|
||||
for (m, slot) in meshes.iter_mut().zip(&self.plan.bodies) {
|
||||
let Some(frame) = slot.and_then(|s| world.get(s).copied().flatten()) else { continue };
|
||||
for p in m.positions.as_chunks_mut::<3>().0 {
|
||||
let q = solidcraft_doc::apply_point(&frame, Vec3::new(p[0], p[1], p[2]));
|
||||
*p = [q.x, q.y, q.z];
|
||||
}
|
||||
}
|
||||
}
|
||||
self.plan = MeshPlan::default();
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads an external file the glTF names (`uri`, relative to the glTF file's folder, already
|
||||
/// percent-decoded and checked to stay in that folder).
|
||||
pub type Loader<'a> = &'a mut dyn FnMut(&str) -> std::result::Result<Vec<u8>, String>;
|
||||
|
||||
/// Read a `.glb` or `.gltf` file (`file` names it; its stem names the meshes when nothing in the
|
||||
/// file does). External buffers are read through `load`.
|
||||
pub fn read_gltf(bytes: &[u8], file: &str, load: Loader<'_>) -> Result<GltfImport> {
|
||||
if bytes.len() > MAX_GLTF_BYTES {
|
||||
return Err(IoError::Invalid(format!("glTF file too large ({} MB)", bytes.len() >> 20)));
|
||||
}
|
||||
let (json, blob) = if bytes.starts_with(b"glTF") { split_glb(bytes)? } else { (bytes, None) };
|
||||
let doc = parse_document(json)?;
|
||||
let stem = std::path::Path::new(file)
|
||||
.file_stem()
|
||||
.map(|s| s.to_string_lossy().trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| "Mesh".into());
|
||||
let mut reader = Reader {
|
||||
doc: &doc,
|
||||
buffers: Buffers { blob, data: HashMap::new(), total: 0, load },
|
||||
meshes: Vec::new(),
|
||||
looks: Vec::new(),
|
||||
plan: MeshPlan::default(),
|
||||
keys: HashMap::new(),
|
||||
has_mesh: HashMap::new(),
|
||||
open: Vec::new(),
|
||||
slot_meshes: Vec::new(),
|
||||
placements: 0,
|
||||
vertices: 0,
|
||||
triangles: 0,
|
||||
skipped: Vec::new(),
|
||||
cut_off: false,
|
||||
stem: stem.clone(),
|
||||
};
|
||||
let roots = scene_roots(&doc);
|
||||
if roots.is_empty() {
|
||||
return Err(IoError::Invalid("glTF has no nodes to import".into()));
|
||||
}
|
||||
// One root node is the import's own component (its placement baked in); several are
|
||||
// components in it.
|
||||
let units = scale(MM);
|
||||
if let [root] = roots.as_slice() {
|
||||
let m = reader.node(*root).map(|n| mat_mul(&units, &node_matrix(&n))).unwrap_or(units);
|
||||
reader.walk_flat(*root, None, &m, 0)?;
|
||||
} else {
|
||||
for r in &roots {
|
||||
reader.walk(*r, None, &units, 0)?;
|
||||
}
|
||||
}
|
||||
if reader.meshes.is_empty() {
|
||||
let why = reader.skipped.first().map(|s| format!(": {s}")).unwrap_or_default();
|
||||
return Err(IoError::Invalid(format!("glTF has no triangle meshes{why}")));
|
||||
}
|
||||
let mut warnings = Vec::new();
|
||||
if !reader.skipped.is_empty() {
|
||||
let n = reader.skipped.len();
|
||||
let first = reader.skipped.first().cloned().unwrap_or_default();
|
||||
warnings.push(if n == 1 { format!("left out: {first}") } else { format!("{n} parts left out (first: {first})") });
|
||||
}
|
||||
if reader.cut_off {
|
||||
warnings.push(format!("the node tree is too large or too deep: only the first {MAX_PLACEMENTS} placements were read"));
|
||||
}
|
||||
if doc.textures().len() > 0 {
|
||||
warnings.push("textures are not imported: bodies take their materials' base colours".into());
|
||||
}
|
||||
if doc.skins().len() > 0 || doc.animations().len() > 0 {
|
||||
warnings.push("skins and animations are not imported: meshes are placed in their rest pose".into());
|
||||
}
|
||||
let name = match reader.meshes.as_slice() {
|
||||
[only] if !only.name.trim().is_empty() => only.name.clone(),
|
||||
_ => stem,
|
||||
};
|
||||
let file_name = std::path::Path::new(file).file_name().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| file.to_string());
|
||||
let body_names = reader.meshes.iter().map(|m| m.name.clone()).collect();
|
||||
Ok(GltfImport {
|
||||
feature: MeshFeature { name, body_names, kind: solidcraft_doc::FeatureKind::MeshImport { file: file_name, meshes: reader.meshes }, warnings },
|
||||
plan: reader.plan,
|
||||
looks: reader.looks,
|
||||
})
|
||||
}
|
||||
|
||||
/// The JSON and binary chunks of a GLB container.
|
||||
fn split_glb(bytes: &[u8]) -> Result<(&[u8], Option<&[u8]>)> {
|
||||
let bad = |m: &str| IoError::Invalid(format!("GLB: {m}"));
|
||||
let u32_at = |at: usize| bytes.get(at..at + 4).and_then(|b| b.try_into().ok()).map(u32::from_le_bytes);
|
||||
let version = u32_at(4).ok_or_else(|| bad("truncated header"))?;
|
||||
if version != 2 {
|
||||
return Err(bad(&format!("version {version} is not glTF 2.0")));
|
||||
}
|
||||
let length = u32_at(8).ok_or_else(|| bad("truncated header"))? as usize;
|
||||
if length > bytes.len() {
|
||||
return Err(bad(&format!("truncated: the header says {length} bytes, the file has {}", bytes.len())));
|
||||
}
|
||||
let mut at = 12usize;
|
||||
let (mut json, mut bin) = (None, None);
|
||||
while at + 8 <= length {
|
||||
let (Some(len), Some(kind)) = (u32_at(at), u32_at(at + 4)) else { break };
|
||||
let start = at + 8;
|
||||
let end = start.checked_add(len as usize).filter(|e| *e <= length).ok_or_else(|| bad("a chunk runs past the end of the file"))?;
|
||||
let data = bytes.get(start..end).ok_or_else(|| bad("a chunk runs past the end of the file"))?;
|
||||
match kind {
|
||||
0x4E4F_534A if json.is_none() => json = Some(data),
|
||||
0x004E_4942 if json.is_some() && bin.is_none() => bin = Some(data),
|
||||
0x4E4F_534A | 0x004E_4942 => return Err(bad("chunks out of order")),
|
||||
// Chunks of unknown types are skipped, as the specification asks.
|
||||
_ => {}
|
||||
}
|
||||
at = end;
|
||||
}
|
||||
Ok((json.ok_or_else(|| bad("no JSON chunk"))?, bin))
|
||||
}
|
||||
|
||||
/// Required extensions that only change how a file looks (textures, material models): the
|
||||
/// geometry reads without them.
|
||||
const LOOK_EXTENSIONS: [&str; 5] = ["KHR_texture_transform", "KHR_texture_basisu", "EXT_texture_webp", "MSFT_texture_dds", "KHR_materials_unlit"];
|
||||
|
||||
/// The checked glTF document.
|
||||
fn parse_document(json: &[u8]) -> Result<gltf::Document> {
|
||||
let parsed = solidcraft_kernel::guard("glTF", || {
|
||||
let mut root: gltf::json::Root = match gltf::json::deserialize::from_slice(json) {
|
||||
Ok(r) => r,
|
||||
Err(e) => return Ok(Err(format!("not a glTF file: {e}"))),
|
||||
};
|
||||
if let Some(asset) = root.asset.version.split('.').next().filter(|major| *major != "2") {
|
||||
return Ok(Err(format!("glTF version {asset} (only 2.x is read)")));
|
||||
}
|
||||
let geometry: Vec<String> = root
|
||||
.extensions_required
|
||||
.iter()
|
||||
.filter(|e| !LOOK_EXTENSIONS.contains(&e.as_str()) && !e.starts_with("KHR_materials_") && e.as_str() != "KHR_mesh_quantization")
|
||||
.cloned()
|
||||
.collect();
|
||||
if !geometry.is_empty() {
|
||||
let hint = if geometry.iter().any(|e| e.contains("draco") || e.contains("meshopt")) {
|
||||
" (compressed meshes: save the file uncompressed)"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
return Ok(Err(format!("the file needs glTF extensions that aren't supported: {}{hint}", geometry.join(", "))));
|
||||
}
|
||||
// The ones left only change looks (or are the quantized attributes read below).
|
||||
root.extensions_required.clear();
|
||||
Ok(gltf::Document::from_json(root).map_err(|e| format!("invalid glTF: {e}")))
|
||||
});
|
||||
match parsed {
|
||||
Ok(Ok(doc)) => Ok(doc),
|
||||
Ok(Err(e)) => Err(IoError::Invalid(e)),
|
||||
Err(e) => Err(IoError::Invalid(format!("glTF could not be read: {e}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The nodes to import: the default scene's (else the first scene's), else every node that is
|
||||
/// no other's child.
|
||||
fn scene_roots(doc: &gltf::Document) -> Vec<usize> {
|
||||
if let Some(scene) = doc.default_scene().or_else(|| doc.scenes().next()) {
|
||||
return scene.nodes().map(|n| n.index()).collect();
|
||||
}
|
||||
let mut child = vec![false; doc.nodes().len()];
|
||||
for n in doc.nodes() {
|
||||
for c in n.children() {
|
||||
if let Some(x) = child.get_mut(c.index()) {
|
||||
*x = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
child.iter().enumerate().filter(|(_, c)| !**c).map(|(i, _)| i).collect()
|
||||
}
|
||||
|
||||
/// The file's buffers, read when first needed.
|
||||
struct Buffers<'a, 'l> {
|
||||
blob: Option<&'a [u8]>,
|
||||
data: HashMap<usize, std::result::Result<Vec<u8>, String>>,
|
||||
total: usize,
|
||||
load: Loader<'l>,
|
||||
}
|
||||
|
||||
impl Buffers<'_, '_> {
|
||||
fn get(&mut self, buffer: &gltf::Buffer<'_>) -> std::result::Result<&[u8], String> {
|
||||
let index = buffer.index();
|
||||
if let gltf::buffer::Source::Bin = buffer.source() {
|
||||
let blob = self.blob.ok_or("the file has no binary chunk for its buffer")?;
|
||||
if blob.len() < buffer.length() {
|
||||
return Err(format!("the binary chunk is shorter ({} bytes) than its buffer says ({})", blob.len(), buffer.length()));
|
||||
}
|
||||
return Ok(blob);
|
||||
}
|
||||
if !self.data.contains_key(&index) {
|
||||
let r = self.read(buffer);
|
||||
if let Ok(d) = &r {
|
||||
self.total = self.total.saturating_add(d.len());
|
||||
}
|
||||
self.data.insert(index, r);
|
||||
}
|
||||
match self.data.get(&index) {
|
||||
Some(Ok(d)) => Ok(d.as_slice()),
|
||||
Some(Err(e)) => Err(e.clone()),
|
||||
None => Err("buffer".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn read(&mut self, buffer: &gltf::Buffer<'_>) -> std::result::Result<Vec<u8>, String> {
|
||||
let gltf::buffer::Source::Uri(uri) = buffer.source() else { return Err("buffer".into()) };
|
||||
if buffer.length() > MAX_BUFFER_BYTES.saturating_sub(self.total) {
|
||||
return Err(format!("buffers larger than {} MB in all", MAX_BUFFER_BYTES >> 20));
|
||||
}
|
||||
let data = if let Some(rest) = uri.strip_prefix("data:") {
|
||||
let (_, payload) = rest.split_once(";base64,").ok_or("an embedded buffer that isn't base64")?;
|
||||
base64(payload).ok_or("an embedded buffer with bad base64")?
|
||||
} else {
|
||||
let path = relative_path(uri)?;
|
||||
let data = (self.load)(&path).map_err(|e| format!("buffer file `{path}`: {e}"))?;
|
||||
if data.len() > MAX_BUFFER_BYTES.saturating_sub(self.total) {
|
||||
return Err(format!("buffers larger than {} MB in all", MAX_BUFFER_BYTES >> 20));
|
||||
}
|
||||
data
|
||||
};
|
||||
if data.len() < buffer.length() {
|
||||
return Err(format!("buffer `{}` is shorter ({} bytes) than the file says ({})", short(uri), data.len(), buffer.length()));
|
||||
}
|
||||
Ok(data)
|
||||
}
|
||||
}
|
||||
|
||||
/// A URI for messages: an embedded buffer by its kind only.
|
||||
fn short(uri: &str) -> String {
|
||||
if uri.starts_with("data:") { "(embedded)".into() } else { uri.chars().take(80).collect() }
|
||||
}
|
||||
|
||||
/// A buffer's URI as a path relative to the glTF file, percent-decoded. Absolute paths, other
|
||||
/// schemes and paths leaving the file's folder are refused.
|
||||
fn relative_path(uri: &str) -> std::result::Result<String, String> {
|
||||
let mut bytes = Vec::with_capacity(uri.len());
|
||||
let raw = uri.as_bytes();
|
||||
let mut i = 0;
|
||||
while let Some(&b) = raw.get(i) {
|
||||
let hex = |k: usize| raw.get(k).and_then(|c| (*c as char).to_digit(16));
|
||||
match (b, hex(i + 1), hex(i + 2)) {
|
||||
(b'%', Some(h), Some(l)) => {
|
||||
bytes.push((h * 16 + l) as u8);
|
||||
i += 3;
|
||||
}
|
||||
_ => {
|
||||
bytes.push(b);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let path = String::from_utf8_lossy(&bytes).replace('\\', "/");
|
||||
let outside = path.starts_with('/') || path.contains(':') || path.split('/').any(|p| p == "..");
|
||||
if path.trim().is_empty() || outside {
|
||||
return Err(format!("buffer `{}` is not a file next to the glTF file", short(uri)));
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Standard base64 (padding optional).
|
||||
fn base64(s: &str) -> Option<Vec<u8>> {
|
||||
let value = |c: u8| match c {
|
||||
b'A'..=b'Z' => Some(c - b'A'),
|
||||
b'a'..=b'z' => Some(c - b'a' + 26),
|
||||
b'0'..=b'9' => Some(c - b'0' + 52),
|
||||
b'+' | b'-' => Some(62),
|
||||
b'/' | b'_' => Some(63),
|
||||
_ => None,
|
||||
};
|
||||
let s = s.trim_end_matches('=').as_bytes();
|
||||
let mut out = Vec::with_capacity(s.len() / 4 * 3 + 3);
|
||||
for chunk in s.chunks(4) {
|
||||
let mut n = 0u32;
|
||||
for (k, c) in chunk.iter().enumerate() {
|
||||
n |= u32::from(value(*c)?) << (18 - 6 * k);
|
||||
}
|
||||
let bytes = [(n >> 16) as u8, (n >> 8) as u8, n as u8];
|
||||
match chunk.len() {
|
||||
4 => out.extend_from_slice(&bytes),
|
||||
3 => out.extend_from_slice(&bytes[..2]),
|
||||
2 => out.push(bytes[0]),
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Uniform scale.
|
||||
fn scale(s: f64) -> Mat {
|
||||
[[s, 0.0, 0.0, 0.0], [0.0, s, 0.0, 0.0], [0.0, 0.0, s, 0.0], [0.0, 0.0, 0.0, 1.0]]
|
||||
}
|
||||
|
||||
/// A node's transform (in the file's units).
|
||||
fn node_matrix(node: &gltf::Node<'_>) -> Mat {
|
||||
match node.transform() {
|
||||
gltf::scene::Transform::Matrix { matrix } => matrix.map(|c| c.map(f64::from)),
|
||||
gltf::scene::Transform::Decomposed { translation: t, rotation: r, scale: s } => {
|
||||
let [mut x, mut y, mut z, mut w] = r.map(f64::from);
|
||||
let n = (x * x + y * y + z * z + w * w).sqrt();
|
||||
if n.is_finite() && n > 1e-12 {
|
||||
(x, y, z, w) = (x / n, y / n, z / n, w / n);
|
||||
} else {
|
||||
(x, y, z, w) = (0.0, 0.0, 0.0, 1.0);
|
||||
}
|
||||
let [sx, sy, sz] = s.map(f64::from);
|
||||
let rot = [
|
||||
[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 col = |c: usize, k: f64| {
|
||||
let r = rot.get(c).copied().unwrap_or([0.0; 3]);
|
||||
[r[0] * k, r[1] * k, r[2] * k, 0.0]
|
||||
};
|
||||
[col(0, sx), col(1, sy), col(2, sz), [f64::from(t[0]), f64::from(t[1]), f64::from(t[2]), 1.0]]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `m` as a rigid placement and what is left: `m = place · rest`, `rest` without translation.
|
||||
/// `None` when `m` is not finite or its translation is out of range.
|
||||
fn split_rigid(m: &Mat) -> Option<(Mat, Mat)> {
|
||||
if !m.iter().flatten().all(|v| v.is_finite()) || m[3][..3].iter().any(|v| v.abs() > 1e9) {
|
||||
return None;
|
||||
}
|
||||
let col = |i: usize| Vec3::new(m[i][0], m[i][1], m[i][2]);
|
||||
let frame = (|| {
|
||||
let x = col(0).normalized()?;
|
||||
let y = (col(1) - x * col(1).dot(x)).normalized()?;
|
||||
Some((x, y, x.cross(y)))
|
||||
})();
|
||||
let (x, y, z) = frame.unwrap_or((Vec3::X, Vec3::Y, Vec3::Z));
|
||||
let place = [[x.x, x.y, x.z, 0.0], [y.x, y.y, y.z, 0.0], [z.x, z.y, z.z, 0.0], [m[3][0], m[3][1], m[3][2], 1.0]];
|
||||
// The inverse of a rotation is its transpose; the translation goes with the placement.
|
||||
let inverse_rotation = [[x.x, y.x, z.x, 0.0], [x.y, y.y, z.y, 0.0], [x.z, y.z, z.z, 0.0], [0.0, 0.0, 0.0, 1.0]];
|
||||
let mut linear = *m;
|
||||
linear[3] = [0.0, 0.0, 0.0, 1.0];
|
||||
Some((place, mat_mul(&inverse_rotation, &linear)))
|
||||
}
|
||||
|
||||
fn hash_mat(m: &Mat, h: &mut impl Hasher) {
|
||||
for v in m.iter().flatten() {
|
||||
// Equal to within rounding is equal.
|
||||
((v * 1e6).round() as i64).hash(h);
|
||||
}
|
||||
}
|
||||
|
||||
fn det3(m: &Mat) -> f64 {
|
||||
let a = |r: usize, c: usize| m[c][r];
|
||||
a(0, 0) * (a(1, 1) * a(2, 2) - a(1, 2) * a(2, 1)) - a(0, 1) * (a(1, 0) * a(2, 2) - a(1, 2) * a(2, 0))
|
||||
+ a(0, 2) * (a(1, 0) * a(2, 1) - a(1, 1) * a(2, 0))
|
||||
}
|
||||
|
||||
fn clean_name(s: &str) -> String {
|
||||
s.trim().chars().filter(|c| !c.is_control()).take(128).collect()
|
||||
}
|
||||
|
||||
struct Reader<'d, 'a, 'l> {
|
||||
doc: &'d gltf::Document,
|
||||
buffers: Buffers<'a, 'l>,
|
||||
meshes: Vec<MeshData>,
|
||||
looks: Vec<Option<Look>>,
|
||||
plan: MeshPlan,
|
||||
/// Instance keys of the components made, to their slots.
|
||||
keys: HashMap<u64, usize>,
|
||||
has_mesh: HashMap<usize, bool>,
|
||||
/// Each component's mesh's name.
|
||||
slot_meshes: Vec<Option<String>>,
|
||||
/// The components being filled (the current node's ancestors).
|
||||
open: Vec<usize>,
|
||||
placements: usize,
|
||||
vertices: usize,
|
||||
triangles: usize,
|
||||
/// What was left out, and why.
|
||||
skipped: Vec<String>,
|
||||
cut_off: bool,
|
||||
stem: String,
|
||||
}
|
||||
|
||||
impl<'d> Reader<'d, '_, '_> {
|
||||
fn node(&self, index: usize) -> Option<gltf::Node<'d>> {
|
||||
self.doc.nodes().nth(index)
|
||||
}
|
||||
|
||||
/// Does the subtree of node `index` hold a mesh? (Loops count as no.)
|
||||
fn subtree_has_mesh(&mut self, index: usize, depth: usize) -> bool {
|
||||
if let Some(&known) = self.has_mesh.get(&index) {
|
||||
return known;
|
||||
}
|
||||
if depth > MAX_DEPTH {
|
||||
return false;
|
||||
}
|
||||
// In progress: a loop back here adds nothing.
|
||||
self.has_mesh.insert(index, false);
|
||||
let Some(node) = self.node(index) else { return false };
|
||||
let children: Vec<usize> = node.children().map(|c| c.index()).collect();
|
||||
let mut any = node.mesh().is_some();
|
||||
for c in children {
|
||||
any |= self.subtree_has_mesh(c, depth + 1);
|
||||
}
|
||||
self.has_mesh.insert(index, any);
|
||||
any
|
||||
}
|
||||
|
||||
fn budget(&mut self, depth: usize) -> bool {
|
||||
self.placements += 1;
|
||||
if self.placements > MAX_PLACEMENTS || depth > MAX_DEPTH {
|
||||
self.cut_off = true;
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Node `index` under component `parent` (`None`: the import's own), whose frame sees the
|
||||
/// node's parent through `outer` (the parent's leftover scale, with the units).
|
||||
fn walk(&mut self, index: usize, parent: Option<usize>, outer: &Mat, depth: usize) -> Result<()> {
|
||||
if !self.subtree_has_mesh(index, 0) || !self.budget(depth) {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(node) = self.node(index) else { return Ok(()) };
|
||||
let local = mat_mul(outer, &node_matrix(&node));
|
||||
let split = split_rigid(&local);
|
||||
let Some((place, rest)) = split.filter(|_| depth < MAX_COMPONENT_DEPTH && self.plan.slots.len() < MAX_COMPONENTS) else {
|
||||
// No component of its own: built where its parent is.
|
||||
return self.walk_flat(index, parent, &local, depth);
|
||||
};
|
||||
let key = self.key(index, &rest, 0);
|
||||
if let Some(&slot) = self.keys.get(&key) {
|
||||
if self.open.contains(&slot) {
|
||||
// A node inside itself (the file's tree loops): not followed.
|
||||
self.cut_off = true;
|
||||
return Ok(());
|
||||
}
|
||||
// The same mesh and subtree placed again: another occurrence. Placed by nodes of
|
||||
// other names, it goes by its mesh's.
|
||||
let name = node.name().map(clean_name).unwrap_or_default();
|
||||
if let (Some(own), Some(Some(mesh))) = (self.plan.slots.get_mut(slot), self.slot_meshes.get(slot))
|
||||
&& *own != name
|
||||
{
|
||||
own.clone_from(mesh);
|
||||
}
|
||||
self.plan.places.push(PlanPlace { slot, parent, transform: place });
|
||||
return Ok(());
|
||||
}
|
||||
let slot = self.plan.slots.len();
|
||||
let name = node.name().map(clean_name).filter(|n| !n.is_empty());
|
||||
let name = name.or_else(|| node.mesh().and_then(|m| m.name().map(clean_name)).filter(|n| !n.is_empty()));
|
||||
self.plan.slots.push(name.unwrap_or_else(|| format!("Node {index}")));
|
||||
self.slot_meshes.push(node.mesh().and_then(|m| m.name().map(clean_name)).filter(|n| !n.is_empty()));
|
||||
self.plan.places.push(PlanPlace { slot, parent, transform: place });
|
||||
self.keys.insert(key, slot);
|
||||
self.node_meshes(index, Some(slot), &rest)?;
|
||||
let children: Vec<usize> = node.children().map(|c| c.index()).collect();
|
||||
self.open.push(slot);
|
||||
for c in children {
|
||||
self.walk(c, Some(slot), &rest, depth + 1)?;
|
||||
}
|
||||
self.open.pop();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Node `index` built in component `parent`, `local` being its whole placement there (scale
|
||||
/// and all): its meshes are baked with it.
|
||||
fn walk_flat(&mut self, index: usize, parent: Option<usize>, local: &Mat, depth: usize) -> Result<()> {
|
||||
if !self.subtree_has_mesh(index, 0) || !self.budget(depth) {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(node) = self.node(index) else { return Ok(()) };
|
||||
self.node_meshes(index, parent, local)?;
|
||||
let children: Vec<usize> = node.children().map(|c| c.index()).collect();
|
||||
for c in children {
|
||||
if depth + 1 < MAX_COMPONENT_DEPTH && self.plan.slots.len() < MAX_COMPONENTS {
|
||||
self.walk(c, parent, local, depth + 1)?;
|
||||
} else {
|
||||
let Some(child) = self.node(c) else { continue };
|
||||
let m = mat_mul(local, &node_matrix(&child));
|
||||
self.walk_flat(c, parent, &m, depth + 1)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What makes two placements the same component: the node's mesh, how it is baked, and the
|
||||
/// same children placed the same way below it.
|
||||
fn key(&mut self, index: usize, rest: &Mat, depth: usize) -> u64 {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
let Some(node) = self.node(index) else { return 0 };
|
||||
node.mesh().map(|m| m.index()).hash(&mut h);
|
||||
hash_mat(rest, &mut h);
|
||||
if depth < 8 {
|
||||
let children: Vec<usize> = node.children().map(|c| c.index()).collect();
|
||||
for c in children {
|
||||
if !self.subtree_has_mesh(c, 0) {
|
||||
continue;
|
||||
}
|
||||
let Some(child) = self.node(c) else { continue };
|
||||
let local = mat_mul(rest, &node_matrix(&child));
|
||||
match split_rigid(&local) {
|
||||
Some((place, child_rest)) => {
|
||||
hash_mat(&place, &mut h);
|
||||
self.key(c, &child_rest, depth + 1).hash(&mut h);
|
||||
}
|
||||
None => c.hash(&mut h),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Too deep to compare: only the very same node is the same.
|
||||
index.hash(&mut h);
|
||||
}
|
||||
h.finish()
|
||||
}
|
||||
|
||||
/// Bodies of node `index`'s mesh, baked with `bake` (its frame in component `slot`).
|
||||
fn node_meshes(&mut self, index: usize, slot: Option<usize>, bake: &Mat) -> Result<()> {
|
||||
let Some(node) = self.node(index) else { return Ok(()) };
|
||||
let Some(mesh) = node.mesh() else { return Ok(()) };
|
||||
let base = mesh
|
||||
.name()
|
||||
.map(clean_name)
|
||||
.filter(|n| !n.is_empty())
|
||||
.or_else(|| node.name().map(clean_name).filter(|n| !n.is_empty()))
|
||||
.unwrap_or_else(|| self.stem.clone());
|
||||
if !bake.iter().flatten().all(|v| v.is_finite()) || det3(bake).abs() < 1e-18 {
|
||||
self.skipped.push(format!("mesh `{base}` (its node scales it to nothing)"));
|
||||
return Ok(());
|
||||
}
|
||||
let flip = det3(bake) < 0.0;
|
||||
// Primitives by material, in the order they first appear.
|
||||
let mut groups: Vec<(Option<usize>, Vec<Vec3>, Vec<[u32; 3]>)> = Vec::new();
|
||||
for (k, prim) in mesh.primitives().enumerate() {
|
||||
let tris = match self.primitive(&prim) {
|
||||
Ok(Some(t)) => t,
|
||||
Ok(None) => continue,
|
||||
Err(e) => {
|
||||
self.skipped.push(format!("mesh `{base}` part {k}: {e}"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let material = prim.material().index();
|
||||
let at = match groups.iter().position(|g| g.0 == material) {
|
||||
Some(at) => at,
|
||||
None => {
|
||||
groups.push((material, Vec::new(), Vec::new()));
|
||||
groups.len() - 1
|
||||
}
|
||||
};
|
||||
let Some(group) = groups.get_mut(at) else { continue };
|
||||
let offset = u32::try_from(group.1.len()).map_err(|_| IoError::Invalid("glTF mesh too large".into()))?;
|
||||
group.1.extend(tris.0.iter().map(|p| solidcraft_doc::apply_point(bake, *p)));
|
||||
group.2.extend(tris.1.iter().map(|t| {
|
||||
let t = t.map(|i| i + offset);
|
||||
if flip { [t[0], t[2], t[1]] } else { t }
|
||||
}));
|
||||
}
|
||||
let several = groups.len() > 1;
|
||||
for (k, (material, positions, triangles)) in groups.into_iter().enumerate() {
|
||||
if triangles.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let look = material.and_then(|m| self.doc.materials().nth(m)).map(|m| look_of(&m));
|
||||
let name = if several {
|
||||
let suffix = look.as_ref().map(|l| l.name.clone()).filter(|n| !n.is_empty()).unwrap_or_else(|| format!("{}", k + 1));
|
||||
format!("{base} ({suffix})")
|
||||
} else {
|
||||
base.clone()
|
||||
};
|
||||
if let Err(e) = solidcraft_kernel::mesh_body(&positions, &triangles) {
|
||||
self.skipped.push(format!("mesh `{name}`: {e}"));
|
||||
continue;
|
||||
}
|
||||
self.meshes.push(MeshData {
|
||||
name,
|
||||
positions: positions.iter().flat_map(|p| [p.x, p.y, p.z]).collect(),
|
||||
triangles: triangles.iter().flatten().copied().collect(),
|
||||
color: look.as_ref().map(Look::rgb),
|
||||
});
|
||||
self.looks.push(look);
|
||||
self.plan.bodies.push(slot);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A primitive's vertices (file units) and triangles; `None` for points and lines.
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn primitive(&mut self, prim: &gltf::Primitive<'_>) -> std::result::Result<Option<(Vec<Vec3>, Vec<[u32; 3]>)>, String> {
|
||||
let mode = prim.mode();
|
||||
if !matches!(mode, Mode::Triangles | Mode::TriangleStrip | Mode::TriangleFan) {
|
||||
return Ok(None);
|
||||
}
|
||||
let acc = prim.get(&gltf::Semantic::Positions).ok_or("no positions")?;
|
||||
let raw = self.accessor(&acc, Dimensions::Vec3)?;
|
||||
let positions: Vec<Vec3> = raw.as_chunks::<3>().0.iter().map(|c| Vec3::new(c[0], c[1], c[2])).collect();
|
||||
let n = positions.len();
|
||||
let indices: Vec<u32> = match prim.indices() {
|
||||
Some(a) => {
|
||||
if !matches!(a.data_type(), DataType::U8 | DataType::U16 | DataType::U32) {
|
||||
return Err("indices that aren't unsigned integers".into());
|
||||
}
|
||||
self.accessor(&a, Dimensions::Scalar)?.into_iter().map(|v| v as u32).collect()
|
||||
}
|
||||
None => (0..u32::try_from(n).map_err(|_| "too many vertices")?).collect(),
|
||||
};
|
||||
if let Some(bad) = indices.iter().find(|i| **i as usize >= n) {
|
||||
return Err(format!("index {bad} past its {n} vertices"));
|
||||
}
|
||||
let at = |k: usize| indices.get(k).copied().unwrap_or(0);
|
||||
let mut tris = Vec::new();
|
||||
match mode {
|
||||
Mode::TriangleStrip => {
|
||||
for k in 0..indices.len().saturating_sub(2) {
|
||||
tris.push(if k % 2 == 0 { [at(k), at(k + 1), at(k + 2)] } else { [at(k + 1), at(k), at(k + 2)] });
|
||||
}
|
||||
}
|
||||
Mode::TriangleFan => {
|
||||
for k in 1..indices.len().saturating_sub(1) {
|
||||
tris.push([at(0), at(k), at(k + 1)]);
|
||||
}
|
||||
}
|
||||
_ => tris.extend(indices.as_chunks::<3>().0.iter().copied()),
|
||||
}
|
||||
// Degenerate triangles (a repeated corner) close nothing.
|
||||
tris.retain(|t| t[0] != t[1] && t[1] != t[2] && t[0] != t[2]);
|
||||
if tris.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
// Only the vertices used.
|
||||
let mut map: HashMap<u32, u32> = HashMap::new();
|
||||
let mut used = Vec::new();
|
||||
for t in &mut tris {
|
||||
for i in t.iter_mut() {
|
||||
let next = used.len() as u32;
|
||||
*i = *map.entry(*i).or_insert_with(|| {
|
||||
used.push(positions.get(*i as usize).copied().unwrap_or(Vec3::ZERO));
|
||||
next
|
||||
});
|
||||
}
|
||||
}
|
||||
self.vertices = self.vertices.saturating_add(used.len());
|
||||
self.triangles = self.triangles.saturating_add(tris.len());
|
||||
if self.vertices > MAX_VERTICES || self.triangles > MAX_TRIANGLES {
|
||||
return Err(format!("more than {MAX_TRIANGLES} triangles or {MAX_VERTICES} vertices in all"));
|
||||
}
|
||||
Ok(Some((used, tris)))
|
||||
}
|
||||
|
||||
/// An accessor's values (`dims` per element) as numbers; normalized integers as fractions.
|
||||
fn accessor(&mut self, acc: &gltf::Accessor<'_>, dims: Dimensions) -> std::result::Result<Vec<f64>, String> {
|
||||
if acc.sparse().is_some() {
|
||||
return Err("sparse accessors aren't supported".into());
|
||||
}
|
||||
if acc.dimensions() != dims {
|
||||
return Err(format!("accessor {} has {:?} values, not {dims:?}", acc.index(), acc.dimensions()));
|
||||
}
|
||||
let view = acc.view().ok_or("an accessor without data")?;
|
||||
let width = dims.multiplicity();
|
||||
let size = acc.data_type().size();
|
||||
let elem = width * size;
|
||||
let stride = view.stride().unwrap_or(elem);
|
||||
let count = acc.count();
|
||||
if stride < elem || count > MAX_VERTICES.max(3 * MAX_TRIANGLES) {
|
||||
return Err(format!("accessor {} is malformed", acc.index()));
|
||||
}
|
||||
// Every element must lie in the view, and the view in its buffer.
|
||||
let span = if count == 0 { 0 } else { (count - 1).checked_mul(stride).and_then(|s| s.checked_add(elem)).ok_or("accessor too large")? };
|
||||
let end = acc.offset().checked_add(span).ok_or("accessor too large")?;
|
||||
if end > view.length() {
|
||||
return Err(format!("accessor {} runs past its buffer view", acc.index()));
|
||||
}
|
||||
let view_end = view.offset().checked_add(view.length()).ok_or("buffer view too large")?;
|
||||
let data = self.buffers.get(&view.buffer())?;
|
||||
if view_end > data.len() || view_end > view.buffer().length() {
|
||||
return Err(format!("buffer view {} runs past its buffer (the file may be truncated)", view.index()));
|
||||
}
|
||||
let base = view.offset() + acc.offset();
|
||||
let normalized = acc.normalized();
|
||||
let ty = acc.data_type();
|
||||
let mut out = Vec::with_capacity(count * width);
|
||||
for e in 0..count {
|
||||
for c in 0..width {
|
||||
let at = base + e * stride + c * size;
|
||||
let b = data.get(at..at + size).ok_or("accessor out of range")?;
|
||||
let v = match ty {
|
||||
DataType::F32 => f64::from(f32::from_le_bytes([b[0], b[1], b[2], b[3]])),
|
||||
DataType::U32 => f64::from(u32::from_le_bytes([b[0], b[1], b[2], b[3]])),
|
||||
DataType::U16 => {
|
||||
let v = f64::from(u16::from_le_bytes([b[0], b[1]]));
|
||||
if normalized { v / 65535.0 } else { v }
|
||||
}
|
||||
DataType::I16 => {
|
||||
let v = f64::from(i16::from_le_bytes([b[0], b[1]]));
|
||||
if normalized { (v / 32767.0).max(-1.0) } else { v }
|
||||
}
|
||||
DataType::U8 => {
|
||||
let v = f64::from(b[0]);
|
||||
if normalized { v / 255.0 } else { v }
|
||||
}
|
||||
DataType::I8 => {
|
||||
let v = f64::from(b[0] as i8);
|
||||
if normalized { (v / 127.0).max(-1.0) } else { v }
|
||||
}
|
||||
};
|
||||
if !v.is_finite() {
|
||||
return Err(format!("accessor {} holds a number that isn't finite", acc.index()));
|
||||
}
|
||||
out.push(v);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// Linear to sRGB (0–1).
|
||||
fn srgb(c: f32) -> f32 {
|
||||
let c = if c.is_finite() { c.clamp(0.0, 1.0) } else { 0.8 };
|
||||
if c <= 0.003_130_8 { 12.92 * c } else { 1.055 * c.powf(1.0 / 2.4) - 0.055 }
|
||||
}
|
||||
|
||||
/// A material's look: base colour, alpha when blended, metallic/roughness when opaque.
|
||||
fn look_of(m: &gltf::Material<'_>) -> Look {
|
||||
let pbr = m.pbr_metallic_roughness();
|
||||
let [r, g, b, a] = pbr.base_color_factor();
|
||||
let color = [r, g, b].map(|c| (srgb(c) * 255.0).round() as u8);
|
||||
let opacity = match m.alpha_mode() {
|
||||
gltf::material::AlphaMode::Blend if a.is_finite() => f64::from(a).clamp(0.0, 1.0),
|
||||
_ => 1.0,
|
||||
};
|
||||
let name = m.name().map(clean_name).filter(|n| !n.is_empty()).unwrap_or_else(|| "glTF material".into());
|
||||
let finite = |v: f32, d: f64| if v.is_finite() { f64::from(v).clamp(0.0, 1.0) } else { d };
|
||||
let pbr = (opacity >= 1.0)
|
||||
.then(|| {
|
||||
solidcraft_doc::pbr_appearance::PbrParameters::new(finite(pbr.metallic_factor(), 1.0), finite(pbr.roughness_factor(), 1.0), 1.5).ok()
|
||||
})
|
||||
.flatten();
|
||||
Look { name, color, opacity, pbr }
|
||||
}
|
||||
|
||||
/// A GLB container of `json` and an optional binary chunk (each padded to four bytes).
|
||||
pub fn glb_container(json: &[u8], bin: &[u8]) -> Vec<u8> {
|
||||
let pad = |n: usize| (4 - n % 4) % 4;
|
||||
let json_len = json.len() + pad(json.len());
|
||||
let bin_len = bin.len() + pad(bin.len());
|
||||
let total = 12 + 8 + json_len + if bin.is_empty() { 0 } else { 8 + bin_len };
|
||||
let mut out = Vec::with_capacity(total);
|
||||
out.extend_from_slice(b"glTF");
|
||||
out.extend_from_slice(&2u32.to_le_bytes());
|
||||
out.extend_from_slice(&(total as u32).to_le_bytes());
|
||||
out.extend_from_slice(&(json_len as u32).to_le_bytes());
|
||||
out.extend_from_slice(b"JSON");
|
||||
out.extend_from_slice(json);
|
||||
out.extend(std::iter::repeat_n(b' ', pad(json.len())));
|
||||
if !bin.is_empty() {
|
||||
out.extend_from_slice(&(bin_len as u32).to_le_bytes());
|
||||
out.extend_from_slice(b"BIN\0");
|
||||
out.extend_from_slice(bin);
|
||||
out.extend(std::iter::repeat_n(0u8, pad(bin.len())));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "gltf_in_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,445 @@
|
||||
//! glTF import tests. Every file here is built by the tests themselves.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A glTF file under construction: one binary buffer, meshes, nodes and materials.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Fixture {
|
||||
pub bin: Vec<u8>,
|
||||
pub views: Vec<Value>,
|
||||
pub accessors: Vec<Value>,
|
||||
pub meshes: Vec<Value>,
|
||||
pub nodes: Vec<Value>,
|
||||
pub materials: Vec<Value>,
|
||||
pub roots: Vec<usize>,
|
||||
}
|
||||
|
||||
/// A cube of side `s` (metres) with its corner at the origin, outward facing.
|
||||
pub(crate) fn cube(s: f32) -> (Vec<[f32; 3]>, Vec<u16>) {
|
||||
let p = (0..8).map(|i| [(i & 1) as f32 * s, ((i >> 1) & 1) as f32 * s, ((i >> 2) & 1) as f32 * s]).collect();
|
||||
let t = vec![0, 2, 1, 1, 2, 3, 4, 5, 6, 5, 7, 6, 0, 1, 4, 1, 5, 4, 2, 6, 3, 3, 6, 7, 0, 4, 2, 2, 4, 6, 1, 3, 5, 3, 7, 5];
|
||||
(p, t)
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
fn view(&mut self, bytes: &[u8]) -> usize {
|
||||
while !self.bin.len().is_multiple_of(4) {
|
||||
self.bin.push(0);
|
||||
}
|
||||
self.views.push(json!({"buffer": 0, "byteOffset": self.bin.len(), "byteLength": bytes.len()}));
|
||||
self.bin.extend_from_slice(bytes);
|
||||
self.views.len() - 1
|
||||
}
|
||||
|
||||
/// A mesh of one primitive per (positions, indices, material).
|
||||
pub fn mesh(&mut self, name: &str, prims: &[(&[[f32; 3]], &[u16], Option<usize>)]) -> usize {
|
||||
let mut out = Vec::new();
|
||||
for (positions, indices, material) in prims {
|
||||
let bytes: Vec<u8> = positions.iter().flatten().flat_map(|v| v.to_le_bytes()).collect();
|
||||
let v = self.view(&bytes);
|
||||
let lo = (0..3).map(|k| positions.iter().map(|p| p[k]).fold(f32::INFINITY, f32::min)).collect::<Vec<_>>();
|
||||
let hi = (0..3).map(|k| positions.iter().map(|p| p[k]).fold(f32::NEG_INFINITY, f32::max)).collect::<Vec<_>>();
|
||||
self.accessors.push(json!({"bufferView": v, "componentType": 5126, "count": positions.len(), "type": "VEC3", "min": lo, "max": hi}));
|
||||
let pa = self.accessors.len() - 1;
|
||||
let bytes: Vec<u8> = indices.iter().flat_map(|v| v.to_le_bytes()).collect();
|
||||
let v = self.view(&bytes);
|
||||
self.accessors.push(json!({"bufferView": v, "componentType": 5123, "count": indices.len(), "type": "SCALAR"}));
|
||||
let mut p = json!({"attributes": {"POSITION": pa}, "indices": self.accessors.len() - 1});
|
||||
if let Some(m) = material {
|
||||
p["material"] = json!(m);
|
||||
}
|
||||
out.push(p);
|
||||
}
|
||||
self.meshes.push(json!({"name": name, "primitives": out}));
|
||||
self.meshes.len() - 1
|
||||
}
|
||||
|
||||
pub fn cube_mesh(&mut self, name: &str, s: f32) -> usize {
|
||||
let (p, t) = cube(s);
|
||||
self.mesh(name, &[(&p, &t, None)])
|
||||
}
|
||||
|
||||
pub fn node(&mut self, node: Value) -> usize {
|
||||
self.nodes.push(node);
|
||||
self.nodes.len() - 1
|
||||
}
|
||||
|
||||
pub fn json(&self, uri: Option<&str>) -> Value {
|
||||
let mut buffer = json!({"byteLength": self.bin.len()});
|
||||
if let Some(u) = uri {
|
||||
buffer["uri"] = json!(u);
|
||||
}
|
||||
let mut j = json!({
|
||||
"asset": {"version": "2.0", "generator": "solidcraft tests"},
|
||||
"scene": 0,
|
||||
"scenes": [{"nodes": self.roots}],
|
||||
"nodes": self.nodes,
|
||||
"meshes": self.meshes,
|
||||
"accessors": self.accessors,
|
||||
"bufferViews": self.views,
|
||||
"buffers": [buffer],
|
||||
});
|
||||
if !self.materials.is_empty() {
|
||||
j["materials"] = json!(self.materials);
|
||||
}
|
||||
j
|
||||
}
|
||||
|
||||
pub fn glb(&self) -> Vec<u8> {
|
||||
glb_container(self.json(None).to_string().as_bytes(), &self.bin)
|
||||
}
|
||||
}
|
||||
|
||||
fn no_files(name: &str) -> std::result::Result<Vec<u8>, String> {
|
||||
Err(format!("no file `{name}`"))
|
||||
}
|
||||
|
||||
fn read(bytes: &[u8]) -> Result<GltfImport> {
|
||||
read_gltf(bytes, "model.glb", &mut no_files)
|
||||
}
|
||||
|
||||
fn meshes(g: &GltfImport) -> &[MeshData] {
|
||||
match &g.feature.kind {
|
||||
solidcraft_doc::FeatureKind::MeshImport { meshes, .. } => meshes,
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
fn bounds(m: &MeshData) -> ([f64; 3], [f64; 3]) {
|
||||
let mut lo = [f64::INFINITY; 3];
|
||||
let mut hi = [f64::NEG_INFINITY; 3];
|
||||
for p in m.positions.as_chunks::<3>().0 {
|
||||
for k in 0..3 {
|
||||
lo[k] = lo[k].min(p[k]);
|
||||
hi[k] = hi[k].max(p[k]);
|
||||
}
|
||||
}
|
||||
(lo, hi)
|
||||
}
|
||||
|
||||
/// Signed volume (positive for outward-facing triangles).
|
||||
fn volume(m: &MeshData) -> f64 {
|
||||
let p = |i: u32| {
|
||||
let i = i as usize * 3;
|
||||
Vec3::new(m.positions[i], m.positions[i + 1], m.positions[i + 2])
|
||||
};
|
||||
m.triangles.as_chunks::<3>().0.iter().map(|t| p(t[0]).dot(p(t[1]).cross(p(t[2]))) / 6.0).sum()
|
||||
}
|
||||
|
||||
fn close(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 1e-6 * (1.0 + b.abs())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_mesh_in_millimetres() {
|
||||
let mut f = Fixture::default();
|
||||
let m = f.cube_mesh("Cube", 0.01);
|
||||
let n = f.node(json!({"name": "Cube node", "mesh": m}));
|
||||
f.roots = vec![n];
|
||||
let g = read(&f.glb()).unwrap();
|
||||
assert!(g.feature.warnings.is_empty(), "{:?}", g.feature.warnings);
|
||||
assert_eq!(g.feature.name, "Cube");
|
||||
let ms = meshes(&g);
|
||||
assert_eq!(ms.len(), 1);
|
||||
assert_eq!(ms[0].triangles.len(), 36);
|
||||
let (lo, hi) = bounds(&ms[0]);
|
||||
assert!(lo.iter().all(|v| close(*v, 0.0)) && hi.iter().all(|v| close(*v, 10.0)), "{lo:?} {hi:?}");
|
||||
assert!(close(volume(&ms[0]), 1000.0), "{}", volume(&ms[0]));
|
||||
// One root node is the import's own component.
|
||||
assert!(g.plan.slots.is_empty() && g.plan.places.is_empty());
|
||||
assert_eq!(g.plan.bodies, vec![None]);
|
||||
assert_eq!(g.looks, vec![None]);
|
||||
// The axes are the file's: Y up stays Y here (the caller turns the component).
|
||||
let mut f = Fixture::default();
|
||||
let (p, t) = cube(0.01);
|
||||
let tall: Vec<[f32; 3]> = p.iter().map(|q| [q[0], q[1] * 3.0, q[2]]).collect();
|
||||
let m = f.mesh("Tall", &[(&tall, &t, None)]);
|
||||
let n = f.node(json!({"mesh": m}));
|
||||
f.roots = vec![n];
|
||||
let g = read(&f.glb()).unwrap();
|
||||
let (_, hi) = bounds(&meshes(&g)[0]);
|
||||
assert!(close(hi[1], 30.0) && close(hi[2], 10.0), "{hi:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_hierarchy_becomes_components() {
|
||||
let mut f = Fixture::default();
|
||||
let m = f.cube_mesh("Block", 0.01);
|
||||
let arm = f.node(json!({"name": "Arm", "mesh": m, "translation": [0.0, 0.05, 0.0], "rotation": [0.0, 0.0, 0.70710677, 0.70710677]}));
|
||||
let base = f.node(json!({"name": "Base", "mesh": m, "translation": [0.1, 0.0, 0.0], "children": [arm]}));
|
||||
let block = f.cube_mesh("Other block", 0.01);
|
||||
let other = f.node(json!({"name": "Other", "mesh": block, "translation": [0.0, 0.0, 0.2]}));
|
||||
f.roots = vec![base, other];
|
||||
let g = read(&f.glb()).unwrap();
|
||||
assert_eq!(g.plan.slots, vec!["Base", "Arm", "Other"]);
|
||||
assert_eq!(g.plan.places.len(), 3);
|
||||
let base_place = &g.plan.places[0];
|
||||
assert_eq!((base_place.slot, base_place.parent), (0, None));
|
||||
assert!(close(base_place.transform[3][0], 100.0));
|
||||
let arm_place = &g.plan.places[1];
|
||||
assert_eq!((arm_place.slot, arm_place.parent), (1, Some(0)));
|
||||
assert!(close(arm_place.transform[3][1], 50.0));
|
||||
// A quarter turn about Z: X goes to Y.
|
||||
assert!(close(arm_place.transform[0][1], 1.0) && arm_place.transform[0][0].abs() < 1e-6, "{:?}", arm_place.transform);
|
||||
assert_eq!(g.plan.bodies, vec![Some(0), Some(1), Some(2)]);
|
||||
// Bodies are in their component's frame.
|
||||
for m in meshes(&g) {
|
||||
let (lo, hi) = bounds(m);
|
||||
assert!(lo.iter().all(|v| close(*v, 0.0)) && hi.iter().all(|v| close(*v, 10.0)), "{} {lo:?} {hi:?}", m.name);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_meshes_are_instances() {
|
||||
let mut f = Fixture::default();
|
||||
let m = f.cube_mesh("Bolt", 0.005);
|
||||
f.roots = (0..4).map(|k| f.node(json!({"name": format!("Bolt.{k}"), "mesh": m, "translation": [k as f32 * 0.02, 0.0, 0.0]}))).collect();
|
||||
let g = read(&f.glb()).unwrap();
|
||||
assert_eq!(g.plan.slots, vec!["Bolt"], "named after the mesh: the nodes' names differ");
|
||||
assert_eq!(g.plan.places.len(), 4);
|
||||
assert!(g.plan.places.iter().all(|p| p.slot == 0 && p.parent.is_none()));
|
||||
assert!(close(g.plan.places[3].transform[3][0], 60.0));
|
||||
assert_eq!(meshes(&g).len(), 1, "the mesh is built once");
|
||||
// The same mesh scaled differently is not the same part.
|
||||
f.nodes[3]["scale"] = json!([2.0, 2.0, 2.0]);
|
||||
let g = read(&f.glb()).unwrap();
|
||||
assert_eq!(g.plan.slots.len(), 2);
|
||||
assert_eq!(meshes(&g).len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_and_mirror_are_baked_and_the_tree_kept() {
|
||||
let mut f = Fixture::default();
|
||||
let m = f.cube_mesh("Cube", 0.01);
|
||||
let child = f.node(json!({"name": "Child", "mesh": m, "translation": [0.1, 0.0, 0.0]}));
|
||||
let mirrored = f.node(json!({"name": "Mirror", "mesh": m, "scale": [-1.0, 1.0, 1.0]}));
|
||||
let scaled = f.node(json!({"name": "Half", "scale": [0.5, 0.5, 0.5], "children": [child]}));
|
||||
f.roots = vec![scaled, mirrored];
|
||||
let g = read(&f.glb()).unwrap();
|
||||
assert_eq!(g.plan.slots, vec!["Half", "Child", "Mirror"]);
|
||||
// The child sits half as far out, and is half the size.
|
||||
assert!(close(g.plan.places[1].transform[3][0], 50.0), "{:?}", g.plan.places[1].transform);
|
||||
let ms = meshes(&g);
|
||||
assert!(close(volume(&ms[0]), 125.0), "{}", volume(&ms[0]));
|
||||
// A mirrored mesh still faces out.
|
||||
assert!(close(volume(&ms[1]), 1000.0), "{}", volume(&ms[1]));
|
||||
// Placed, it lies on the other side of the origin.
|
||||
let place = &g.plan.places[2].transform;
|
||||
let xs: Vec<f64> = ms[1].positions.as_chunks::<3>().0.iter().map(|p| solidcraft_doc::apply_point(place, Vec3::new(p[0], p[1], p[2])).x).collect();
|
||||
assert!(
|
||||
close(xs.iter().copied().fold(f64::INFINITY, f64::min), -10.0) && close(xs.iter().copied().fold(f64::NEG_INFINITY, f64::max), 0.0),
|
||||
"{xs:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn materials_give_looks() {
|
||||
let mut f = Fixture {
|
||||
materials: vec![
|
||||
json!({"name": "Red metal", "pbrMetallicRoughness": {"baseColorFactor": [1.0, 0.0, 0.0, 1.0], "metallicFactor": 1.0, "roughnessFactor": 0.25}}),
|
||||
json!({"name": "Glass", "alphaMode": "BLEND", "pbrMetallicRoughness": {"baseColorFactor": [0.214, 0.214, 0.214, 0.5]}}),
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
let (p, t) = cube(0.01);
|
||||
let q: Vec<[f32; 3]> = p.iter().map(|v| [v[0] + 0.02, v[1], v[2]]).collect();
|
||||
let m = f.mesh("Two", &[(&p, &t, Some(0)), (&q, &t, Some(1))]);
|
||||
let n = f.node(json!({"mesh": m}));
|
||||
f.roots = vec![n];
|
||||
let g = read(&f.glb()).unwrap();
|
||||
let ms = meshes(&g);
|
||||
assert_eq!(ms.iter().map(|m| m.name.as_str()).collect::<Vec<_>>(), ["Two (Red metal)", "Two (Glass)"]);
|
||||
let red = g.looks[0].as_ref().unwrap();
|
||||
assert_eq!((red.name.as_str(), red.color, red.opacity), ("Red metal", [255, 0, 0], 1.0));
|
||||
let pbr = red.pbr.unwrap();
|
||||
assert!(close(pbr.metallic(), 1.0) && close(pbr.roughness(), 0.25));
|
||||
assert_eq!(ms[0].color, Some([1.0, 0.0, 0.0]));
|
||||
// Linear 0.214 is sRGB 128; a blended material is see-through and not PBR.
|
||||
let glass = g.looks[1].as_ref().unwrap();
|
||||
assert!((i32::from(glass.color[0]) - 128).abs() <= 1, "{:?}", glass.color);
|
||||
assert!(close(glass.opacity, 0.5) && glass.pbr.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gltf_with_external_and_embedded_buffers() {
|
||||
let mut f = Fixture::default();
|
||||
let m = f.cube_mesh("Cube", 0.01);
|
||||
let n = f.node(json!({"mesh": m}));
|
||||
f.roots = vec![n];
|
||||
let text = f.json(Some("cube%20data.bin")).to_string();
|
||||
let bin = f.bin.clone();
|
||||
let mut asked = Vec::new();
|
||||
let mut load = |name: &str| {
|
||||
asked.push(name.to_string());
|
||||
Ok(bin.clone())
|
||||
};
|
||||
let g = read_gltf(text.as_bytes(), "dir/model.gltf", &mut load).unwrap();
|
||||
assert_eq!(meshes(&g).len(), 1);
|
||||
assert_eq!(asked, ["cube data.bin"]);
|
||||
// Missing: a clear error naming the file.
|
||||
let e = read_gltf(text.as_bytes(), "model.gltf", &mut no_files).err().unwrap().to_string();
|
||||
assert!(e.contains("cube data.bin"), "{e}");
|
||||
// Outside the file's folder: refused before anything is read.
|
||||
for uri in ["../secret.bin", "/etc/passwd", "C:/x.bin", "file:///x.bin", "a/%2e%2e/../b.bin"] {
|
||||
let text = f.json(Some(uri)).to_string();
|
||||
let mut called = false;
|
||||
let mut load = |_: &str| {
|
||||
called = true;
|
||||
Ok(Vec::new())
|
||||
};
|
||||
let r = read_gltf(text.as_bytes(), "model.gltf", &mut load);
|
||||
assert!(r.is_err() && !called, "{uri}");
|
||||
}
|
||||
// Embedded as a data URI.
|
||||
let b64 = {
|
||||
const A: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut s = String::new();
|
||||
for c in f.bin.chunks(3) {
|
||||
let n = (u32::from(c[0]) << 16) | (u32::from(*c.get(1).unwrap_or(&0)) << 8) | u32::from(*c.get(2).unwrap_or(&0));
|
||||
for k in 0..4 {
|
||||
s.push(if k <= c.len() { A[((n >> (18 - 6 * k)) & 63) as usize] as char } else { '=' });
|
||||
}
|
||||
}
|
||||
s
|
||||
};
|
||||
let text = f.json(Some(&format!("data:application/octet-stream;base64,{b64}"))).to_string();
|
||||
let g = read_gltf(text.as_bytes(), "model.gltf", &mut no_files).unwrap();
|
||||
assert!(close(volume(&meshes(&g)[0]), 1000.0));
|
||||
// The GLB route through mesh_import_feature builds every body where it is.
|
||||
let mut f2 = Fixture::default();
|
||||
let m = f2.cube_mesh("Cube", 0.01);
|
||||
f2.roots = vec![f2.node(json!({"mesh": m, "translation": [1.0, 0.0, 0.0]})), f2.node(json!({"mesh": m, "translation": [2.0, 0.0, 0.0]}))];
|
||||
let mf = crate::mesh_import_feature(&f2.glb(), "two.glb").unwrap();
|
||||
let solidcraft_doc::FeatureKind::MeshImport { meshes, .. } = &mf.kind else { panic!() };
|
||||
assert_eq!(meshes.len(), 1);
|
||||
assert!(close(bounds(&meshes[0]).0[0], 1000.0));
|
||||
assert!(crate::is_mesh_path("a.GLB") && crate::is_mesh_path("b.gltf") && is_gltf_path("c.Gltf") && !is_gltf_path("d.obj"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_fans_and_unsupported_parts() {
|
||||
let mut f = Fixture::default();
|
||||
// A square as a strip and as a fan, and a line primitive that is skipped.
|
||||
let sq: Vec<[f32; 3]> = vec![[0.0, 0.0, 0.0], [0.01, 0.0, 0.0], [0.0, 0.01, 0.0], [0.01, 0.01, 0.0]];
|
||||
let m = f.mesh("Sheet", &[(&sq, &[0, 1, 2, 3], None)]);
|
||||
f.meshes[m]["primitives"][0]["mode"] = json!(5);
|
||||
let n = f.node(json!({"mesh": m}));
|
||||
f.roots = vec![n];
|
||||
let g = read(&f.glb()).unwrap();
|
||||
assert_eq!(meshes(&g)[0].triangles.len(), 6);
|
||||
f.meshes[m]["primitives"][0]["mode"] = json!(1);
|
||||
let e = read(&f.glb()).err().unwrap().to_string();
|
||||
assert!(e.contains("no triangle meshes"), "{e}");
|
||||
}
|
||||
|
||||
/// The plan makes each component at its first placement, inside one made before (never inside
|
||||
/// itself), and puts bodies only in components it makes.
|
||||
fn plan_ok(p: &MeshPlan) -> bool {
|
||||
let mut made = 0;
|
||||
for place in &p.places {
|
||||
if place.slot > made || place.parent.is_some_and(|q| q >= made || q == place.slot) {
|
||||
return false;
|
||||
}
|
||||
made += usize::from(place.slot == made);
|
||||
}
|
||||
made == p.slots.len() && p.bodies.iter().flatten().all(|b| *b < made)
|
||||
}
|
||||
|
||||
fn assert_no_panic(bytes: &[u8], what: &str) {
|
||||
let r = std::panic::catch_unwind(|| read(bytes).map(|g| (meshes(&g).len(), plan_ok(&g.plan), g.looks.len() == meshes(&g).len())));
|
||||
assert!(r.is_ok(), "panicked on {what}");
|
||||
if let Ok(Ok((_, plan, looks))) = r {
|
||||
assert!(plan && looks, "inconsistent plan on {what}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_gltf_never_panics() {
|
||||
let mut f = Fixture::default();
|
||||
let m = f.cube_mesh("Cube", 0.01);
|
||||
let child = f.node(json!({"name": "C", "mesh": m, "translation": [0.0, 0.1, 0.0]}));
|
||||
let n = f.node(json!({"mesh": m, "children": [child], "scale": [2.0, 1.0, 1.0]}));
|
||||
f.roots = vec![n, child];
|
||||
let glb = f.glb();
|
||||
assert!(read(&glb).is_ok());
|
||||
// Every truncation is an error, never a panic.
|
||||
for k in 0..glb.len() {
|
||||
assert_no_panic(&glb[..k], &format!("truncated to {k}"));
|
||||
assert!(read(&glb[..k]).is_err(), "truncated to {k}");
|
||||
}
|
||||
// Flipped bytes anywhere.
|
||||
for k in 0..glb.len() {
|
||||
let mut bad = glb.clone();
|
||||
bad[k] ^= 0xA5;
|
||||
assert_no_panic(&bad, &format!("byte {k} flipped"));
|
||||
}
|
||||
// Lengths in the container that lie.
|
||||
for (at, v) in [(8, 0u32), (8, 5), (8, u32::MAX), (12, 0), (12, u32::MAX), (4, 1)] {
|
||||
let mut bad = glb.clone();
|
||||
bad[at..at + 4].copy_from_slice(&v.to_le_bytes());
|
||||
assert_no_panic(&bad, &format!("{v} at {at}"));
|
||||
assert!(read(&bad).is_err(), "{v} at {at}");
|
||||
}
|
||||
// Documents that lie.
|
||||
let base = f.json(None);
|
||||
let cases: Vec<(&str, Box<dyn Fn(&mut Value)>)> = vec![
|
||||
("huge count", Box::new(|j| j["accessors"][0]["count"] = json!(u32::MAX))),
|
||||
("past the view", Box::new(|j| j["accessors"][0]["byteOffset"] = json!(1000))),
|
||||
("view past buffer", Box::new(|j| j["bufferViews"][0]["byteLength"] = json!(1 << 20))),
|
||||
("buffer longer than chunk", Box::new(|j| j["buffers"][0]["byteLength"] = json!(1 << 20))),
|
||||
("bad accessor index", Box::new(|j| j["meshes"][0]["primitives"][0]["attributes"]["POSITION"] = json!(99))),
|
||||
("bad mesh index", Box::new(|j| j["nodes"][0]["mesh"] = json!(99))),
|
||||
("bad child", Box::new(|j| j["nodes"][1]["children"] = json!([99]))),
|
||||
("loop", Box::new(|j| j["nodes"][0]["children"] = json!([1, 0]))),
|
||||
("two-node loop", Box::new(|j| j["nodes"][0]["children"] = json!([1]))),
|
||||
("index past vertices", Box::new(|j| j["accessors"][1]["componentType"] = json!(5125))),
|
||||
("float indices", Box::new(|j| j["accessors"][1]["componentType"] = json!(5126))),
|
||||
("stride too small", Box::new(|j| j["bufferViews"][0]["byteStride"] = json!(4))),
|
||||
("nan transform", Box::new(|j| j["nodes"][1]["matrix"] = json!([f32::MAX, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 1e30, 0, 0, 1]))),
|
||||
("zero scale", Box::new(|j| j["nodes"][1]["scale"] = json!([0.0, 0.0, 0.0]))),
|
||||
("draco", Box::new(|j| j["extensionsRequired"] = json!(["KHR_draco_mesh_compression"]))),
|
||||
("version 1", Box::new(|j| j["asset"]["version"] = json!("1.0"))),
|
||||
("no scenes", Box::new(|j| j.as_object_mut().unwrap().retain(|k, _| k != "scenes" && k != "scene"))),
|
||||
(
|
||||
"sparse",
|
||||
Box::new(|j| {
|
||||
j["accessors"][0]["sparse"] = json!({"count": 1, "indices": {"bufferView": 1, "componentType": 5123}, "values": {"bufferView": 0}})
|
||||
}),
|
||||
),
|
||||
("no buffer", Box::new(|j| j["buffers"] = json!([]))),
|
||||
("not json", Box::new(|j| *j = json!("nope"))),
|
||||
];
|
||||
for (what, edit) in &cases {
|
||||
let mut j = base.clone();
|
||||
edit(&mut j);
|
||||
let bytes = glb_container(j.to_string().as_bytes(), &f.bin);
|
||||
assert_no_panic(&bytes, what);
|
||||
let text = j.to_string();
|
||||
let r = std::panic::catch_unwind(|| read_gltf(text.as_bytes(), "x.gltf", &mut no_files).map(|g| meshes(&g).len()));
|
||||
assert!(r.is_ok(), "panicked on {what} (gltf)");
|
||||
}
|
||||
let mut j = base.clone();
|
||||
j["extensionsRequired"] = json!(["KHR_draco_mesh_compression"]);
|
||||
let e = read(&glb_container(j.to_string().as_bytes(), &f.bin)).err().unwrap().to_string();
|
||||
assert!(e.contains("KHR_draco_mesh_compression") && e.contains("uncompressed"), "{e}");
|
||||
// Looks-only extensions don't stop a file.
|
||||
j["extensionsRequired"] = json!(["KHR_texture_transform", "KHR_materials_emissive_strength"]);
|
||||
assert!(read(&glb_container(j.to_string().as_bytes(), &f.bin)).is_ok());
|
||||
// A deep chain is cut off or flattened, not followed forever.
|
||||
let mut f = Fixture::default();
|
||||
let m = f.cube_mesh("Cube", 0.01);
|
||||
let mut last = f.node(json!({"mesh": m}));
|
||||
for _ in 0..200 {
|
||||
last = f.node(json!({"mesh": m, "translation": [0.0, 0.0, 0.01], "children": [last]}));
|
||||
}
|
||||
f.roots = vec![last, 0];
|
||||
let g = read(&f.glb()).unwrap();
|
||||
assert!(g.plan.slots.len() <= MAX_COMPONENTS);
|
||||
assert!(g.feature.warnings.iter().any(|w| w.contains("too large or too deep")), "{:?}", g.feature.warnings);
|
||||
for junk in [&b""[..], b"glTF", b"glTF\x02\0\0\0", b"{", b"\xff\xfe", b"[]", b"{\"asset\":{\"version\":\"2.0\"}}"] {
|
||||
assert_no_panic(junk, &format!("{junk:?}"));
|
||||
assert!(read(junk).is_err());
|
||||
}
|
||||
}
|
||||
+17
-4
@@ -31,6 +31,8 @@ pub use legacy_dir::{APP_ID, LEGACY_APP_ID, adopt_legacy_dir, legacy_flatpak_pat
|
||||
pub use safe_write::{backup_path, write_atomic};
|
||||
pub use sketch2d::{Geom2, MAX_DRAWING_BYTES};
|
||||
pub use svg::read_svg;
|
||||
mod gltf_in;
|
||||
pub use gltf_in::{GltfImport, Loader, MAX_GLTF_BYTES, MeshPlan, PlanPlace, glb_container, is_gltf_path, read_gltf};
|
||||
mod obj;
|
||||
pub use obj::read_obj;
|
||||
pub use threemf::{MAX_3MF_BYTES, MeshObject, model_xml, model_xml_with, read_3mf, weld, write_3mf, write_3mf_with};
|
||||
@@ -188,12 +190,15 @@ pub fn step_import_feature_named(bytes: &[u8], file: &str, names: StepNames) ->
|
||||
})
|
||||
}
|
||||
|
||||
/// Is this a mesh file we import (`.3mf`, `.stl`, `.obj`, any case)?
|
||||
/// Is this a mesh file we import (`.3mf`, `.stl`, `.obj`, `.glb`, `.gltf`, any case)?
|
||||
pub fn is_mesh_path(path: &str) -> bool {
|
||||
std::path::Path::new(path).extension().and_then(|e| e.to_str()).is_some_and(|e| ["3mf", "stl", "obj"].iter().any(|x| e.eq_ignore_ascii_case(x)))
|
||||
std::path::Path::new(path)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.is_some_and(|e| ["3mf", "stl", "obj", "glb", "gltf"].iter().any(|x| e.eq_ignore_ascii_case(x)))
|
||||
}
|
||||
|
||||
/// A 3MF, STL or OBJ file read as a mesh import feature.
|
||||
/// A 3MF, STL, OBJ or glTF file read as a mesh import feature.
|
||||
pub struct MeshFeature {
|
||||
/// Timeline name: the only mesh's name, else the file name.
|
||||
pub name: String,
|
||||
@@ -202,8 +207,16 @@ pub struct MeshFeature {
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// Read 3MF, STL or OBJ bytes (the format from `file`'s extension) into a MeshImport feature.
|
||||
/// Read 3MF, STL, OBJ or glTF bytes (the format from `file`'s extension) into a MeshImport
|
||||
/// feature. A glTF file's bodies are all built where they are (see [`read_gltf`] for its node
|
||||
/// tree), and it can't read buffers in other files.
|
||||
pub fn mesh_import_feature(bytes: &[u8], file: &str) -> Result<MeshFeature> {
|
||||
if is_gltf_path(file) {
|
||||
let mut no_files = |name: &str| Err(format!("`{name}` can't be read here"));
|
||||
let mut g = read_gltf(bytes, file, &mut no_files)?;
|
||||
g.flatten();
|
||||
return Ok(g.feature);
|
||||
}
|
||||
if bytes.len() > MAX_3MF_BYTES {
|
||||
return Err(IoError::Invalid(format!("mesh file too large ({} MB)", bytes.len() >> 20)));
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user