mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-13 22:12:12 +00:00
craft-mesh: 3MF reader (threemf): core and the materials extension (#920)
Reads a 3MF package into the crate's Scene: mesh objects, component objects and build items with their transforms (as a node hierarchy; an object placed several times is one mesh), the unit, base materials, colour groups, texture coordinate groups and their textures. The package is opened with craft-opc (3MF is an OPC package) and the model part is read with craft-xml's pull reader, so a large part never becomes a tree. read_model_part reads a model part on its own. Ported from storytold/deckcraft#743 (crates/mesh/src/threemf.rs). What that reader did for DeckCraft and stays there: baking placements into world-space triangles, the turn to Y-up, scaling to metres, and decoding the textures. Here placements stay nodes, positions stay in the file's unit and axes (Model::unit says which), and a texture is its bytes. Co-authored-by: uvxszi <uvxszi@gmail.com>
This commit is contained in:
1 parent
f2d658d08f
commit
9e9c044fab
8 files changed
+789
-7
No files matched your search
Generated
+2
@@ -1170,6 +1170,8 @@ dependencies = [
|
||||
name = "craft-mesh"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"craft-opc",
|
||||
"craft-xml",
|
||||
"miniz_oxide 0.9.1",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
@@ -176,6 +176,7 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
|
||||
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,
|
||||
glTF 2.0 / GLB and 3MF, one feature each. Readers are bounded by `Limits`, never open a file themselves
|
||||
glTF 2.0 / GLB and FBX, one feature each. Readers are bounded by `Limits`, never open a file themselves
|
||||
and never panic. Carved from EffectCraft, SolidCraft, TextureCraft and MaterialCraft.
|
||||
- [`craft-midi`](crates/midi/README.md): Standard MIDI Files: a bounded reader for formats 0, 1 and
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[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 and an FBX reader, bounded on hostile input"
|
||||
description = "3D mesh file formats behind one neutral model: STL, Wavefront OBJ/MTL, PLY and glTF 2.0 / GLB readers and writers and FBX and 3MF readers, bounded on hostile input"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
@@ -11,14 +11,18 @@ repository.workspace = true
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
default = ["stl", "obj", "ply", "gltf", "fbx"]
|
||||
default = ["stl", "obj", "ply", "gltf", "fbx", "threemf"]
|
||||
stl = []
|
||||
obj = []
|
||||
ply = []
|
||||
gltf = ["dep:serde_json"]
|
||||
fbx = ["dep:miniz_oxide"]
|
||||
threemf = ["dep:craft-opc", "dep:craft-xml"]
|
||||
|
||||
[dependencies]
|
||||
serde_json = { workspace = true, optional = true }
|
||||
# zlib-compressed arrays in binary FBX.
|
||||
miniz_oxide = { version = "0.9", optional = true }
|
||||
# 3MF is an OPC package of XML parts.
|
||||
craft-opc = { workspace = true, optional = true }
|
||||
craft-xml = { workspace = true, optional = true }
|
||||
+24
-2
@@ -9,9 +9,10 @@ a `Scene` into bytes. One Cargo feature per format, all on by default.
|
||||
| `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` |
|
||||
| `threemf` | 3MF, core and materials extension | yes | no | `craft-opc`, `craft-xml` |
|
||||
| `fbx` | FBX 6.x and 7.x, binary and ASCII | yes | no | `miniz_oxide` |
|
||||
|
||||
USD, Alembic, COLLADA and 3MF are not here yet.
|
||||
USD, Alembic and COLLADA are not here yet.
|
||||
|
||||
## The neutral model
|
||||
|
||||
@@ -84,6 +85,17 @@ cameras; `KHR_lights_punctual`. Primitives of a mesh that name the same accessor
|
||||
vertices. `write_glb` writes nodes, meshes (positions, normals, UV sets, colours), materials and
|
||||
images.
|
||||
|
||||
**3MF.** The package is opened with `craft-opc` and the model part found through its `3dmodel`
|
||||
relationship; `threemf::read_model_part` reads a model part's XML on its own. Mesh objects,
|
||||
component objects and build items with their transforms (every build item a root node, every
|
||||
component a child node, an object placed several times one mesh), the unit (`Model::unit`; the
|
||||
scene is not scaled), base materials, colour groups (a position per vertex and colour, since
|
||||
colours are per position here), texture coordinate groups (a UV pool indexed per corner) and
|
||||
their textures (the package part's bytes as `Image::data`, tile styles as wrap modes). Not read:
|
||||
components in another model part (the production extension), composite materials,
|
||||
multi-properties, slices and beam lattices; the first three are named in a warning.
|
||||
|
||||
Not read: sparse accessors and required geometry extensions (Draco, meshopt) are
|
||||
**FBX.** Binary files (32-bit record offsets before 7.5, 64-bit from it; every property type;
|
||||
arrays plain or zlib-compressed) and ASCII files give the same record tree, which
|
||||
`fbx::read_document` returns as it is. From it: `Geometry` meshes with whole polygons and the
|
||||
@@ -112,9 +124,19 @@ 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_vertices` | 20 000 000 | entries per attribute pool (OBJ: each of `v`, `vt`, `vn`, counting unused ones; glTF: vertices over all meshes; 3MF: vertices, colours and texture coordinates of the file together) |
|
||||
| `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, 3MF objects |
|
||||
| `max_parts` | 100 000 | groups, material runs, glTF primitives |
|
||||
| `max_materials` | 100 000 | materials; images; OBJ material libraries; 3MF base materials and textures together |
|
||||
| `max_nodes` | 100 000 | nodes, and node visits when the hierarchy is walked (ends cycles); 3MF placed objects, build items and components |
|
||||
| `max_depth` | 64 | node nesting; 3MF component nesting (ends cycles) |
|
||||
| `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; what a 3MF package inflates to, and its textures |
|
||||
| `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; 10 000 parts in a 3MF package.
|
||||
| `max_meshes` | 100 000 | OBJ objects, glTF meshes, FBX meshes |
|
||||
| `max_parts` | 100 000 | groups, material runs, glTF primitives, material slots of an FBX mesh |
|
||||
| `max_materials` | 100 000 | materials; images; OBJ material libraries |
|
||||
|
||||
+10
-3
@@ -1,7 +1,8 @@
|
||||
//! 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`], [`gltf`] (glTF 2.0 and GLB) and [`fbx`]. A reader turns bytes into a [`Scene`]; a writer turns
|
||||
//! [`ply`], [`gltf`] (glTF 2.0 and GLB), [`fbx`] and [`threemf`] (3MF). 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.
|
||||
@@ -24,11 +25,15 @@
|
||||
//! (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.
|
||||
//! Depends on no other Craft crate, except that `threemf` opens its package with `craft-opc`
|
||||
//! and reads its XML with `craft-xml`.
|
||||
#![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", feature = "fbx")), allow(dead_code, unused_imports))]
|
||||
#![cfg_attr(
|
||||
not(all(feature = "stl", feature = "obj", feature = "ply", feature = "gltf", feature = "fbx", feature = "threemf")),
|
||||
allow(dead_code, unused_imports)
|
||||
)]
|
||||
|
||||
#[cfg(feature = "fbx")]
|
||||
pub mod fbx;
|
||||
@@ -40,6 +45,8 @@ pub mod obj;
|
||||
pub mod ply;
|
||||
#[cfg(feature = "stl")]
|
||||
pub mod stl;
|
||||
#[cfg(feature = "threemf")]
|
||||
pub mod threemf;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fmt;
|
||||
|
||||
@@ -12,6 +12,8 @@ mod obj;
|
||||
mod ply;
|
||||
#[cfg(feature = "stl")]
|
||||
mod stl;
|
||||
#[cfg(feature = "threemf")]
|
||||
mod threemf;
|
||||
|
||||
/// 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.
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
//! 3MF: a package with every property kind, placements, limits and hostile files.
|
||||
|
||||
use super::hammer;
|
||||
use crate::threemf::{Model, Unit, is_zip, read, read_model, read_model_part};
|
||||
use crate::{AlphaMode, Corner, Error, IDENTITY, Limits, NONE, Scene, Wrap, no_external, transform_point};
|
||||
|
||||
const RELS: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Target="/3D/3dmodel.model" Id="rel0" Type="http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel"/>
|
||||
</Relationships>"#;
|
||||
|
||||
const TYPES: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||||
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
||||
<Default Extension="model" ContentType="application/vnd.ms-package.3dmanufacturing-3dmodel+xml"/>
|
||||
</Types>"#;
|
||||
|
||||
/// Object 1: a square of two triangles, one in a base material and one textured. Object 2: a
|
||||
/// triangle with a colour per corner and one without a property. Object 3 places 1 and 2.
|
||||
/// The build places 3 (moved) and 1 again.
|
||||
const MODEL: &str = r##"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<model unit="inch" xml:lang="en-US" xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02" xmlns:m="http://schemas.microsoft.com/3dmanufacturing/material/2015/02">
|
||||
<resources>
|
||||
<basematerials id="10">
|
||||
<base name="Red" displaycolor="#FF0000"/>
|
||||
<base name="Glass" displaycolor="#0000FF40"/>
|
||||
</basematerials>
|
||||
<m:colorgroup id="20">
|
||||
<m:color color="#FF0000"/>
|
||||
<m:color color="#00FF0080"/>
|
||||
</m:colorgroup>
|
||||
<m:texture2d id="30" path="/3D/Textures/wood.png" contenttype="image/png" tilestyleu="mirror" tilestylev="clamp"/>
|
||||
<m:texture2dgroup id="40" texid="30">
|
||||
<m:tex2coord u="0" v="0"/>
|
||||
<m:tex2coord u="1" v="0.25"/>
|
||||
<m:tex2coord u="1" v="1"/>
|
||||
</m:texture2dgroup>
|
||||
<object id="1" name="Square" type="model" pid="10" pindex="1">
|
||||
<mesh>
|
||||
<vertices>
|
||||
<vertex x="0" y="0" z="0"/>
|
||||
<vertex x="1" y="0" z="0"/>
|
||||
<vertex x="1" y="1" z="0"/>
|
||||
<vertex x="0" y="1" z="0"/>
|
||||
</vertices>
|
||||
<triangles>
|
||||
<triangle v1="0" v2="1" v3="2"/>
|
||||
<triangle v1="0" v2="2" v3="3" pid="40" p1="0" p2="2" p3="1"/>
|
||||
<triangle v1="0" v2="2" v3="9"/>
|
||||
</triangles>
|
||||
</mesh>
|
||||
</object>
|
||||
<object id="2" name="Tri">
|
||||
<mesh>
|
||||
<vertices>
|
||||
<vertex x="0" y="0" z="1"/>
|
||||
<vertex x="1" y="0" z="1"/>
|
||||
<vertex x="0" y="1" z="1"/>
|
||||
</vertices>
|
||||
<triangles>
|
||||
<triangle v1="0" v2="1" v3="2" pid="20" p1="0" p2="1" p3="0"/>
|
||||
<triangle v1="2" v2="1" v3="0" pid="20" p1="1"/>
|
||||
<triangle v1="0" v2="1" v3="2"/>
|
||||
</triangles>
|
||||
</mesh>
|
||||
</object>
|
||||
<object id="3" name="Pair">
|
||||
<components>
|
||||
<component objectid="1"/>
|
||||
<component objectid="2" transform="2 0 0 0 2 0 0 0 2 0 0 5"/>
|
||||
<component objectid="77"/>
|
||||
</components>
|
||||
</object>
|
||||
</resources>
|
||||
<build>
|
||||
<item objectid="3" transform="1 0 0 0 1 0 0 0 1 10 20 30"/>
|
||||
<item objectid="1"/>
|
||||
</build>
|
||||
</model>"##;
|
||||
|
||||
fn package(model: &str) -> Vec<u8> {
|
||||
let part = |name: &str, data: &[u8]| (name.to_string(), data.to_vec());
|
||||
craft_opc::zip_entries(&[
|
||||
part("[Content_Types].xml", TYPES.as_bytes()),
|
||||
part("_rels/.rels", RELS.as_bytes()),
|
||||
part("3D/3dmodel.model", model.as_bytes()),
|
||||
part("3D/Textures/wood.png", b"wood image"),
|
||||
])
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn parse(bytes: &[u8]) -> crate::Result<Scene> {
|
||||
read(bytes, &Limits::default())
|
||||
}
|
||||
|
||||
fn part(model: &str, limits: &Limits) -> crate::Result<Model> {
|
||||
read_model_part(model.as_bytes(), limits, &mut no_external)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_package_with_every_property_kind() {
|
||||
let bytes = package(MODEL);
|
||||
assert!(is_zip(&bytes));
|
||||
let Model { scene: s, unit } = read_model(&bytes, &Limits::default()).unwrap();
|
||||
s.validate().unwrap();
|
||||
assert_eq!((unit, unit.metres()), (Unit::Inch, 0.0254));
|
||||
|
||||
// Placements: the build's items are roots, components their children; one mesh per object.
|
||||
let names: Vec<&str> = s.nodes.iter().map(|n| n.name.as_str()).collect();
|
||||
assert_eq!(names, ["Pair", "Square", "Tri", "Square"]);
|
||||
assert_eq!((s.roots.as_slice(), s.nodes[0].children.as_slice()), (&[0, 3][..], &[1, 2][..]));
|
||||
assert_eq!(s.nodes.iter().map(|n| n.mesh).collect::<Vec<_>>(), [None, Some(0), Some(1), Some(0)]);
|
||||
assert_eq!(s.meshes.len(), 2);
|
||||
let placed = s.instances(&Limits::default()).unwrap();
|
||||
assert_eq!(placed.iter().map(|p| p.0).collect::<Vec<_>>(), [0, 1, 0]);
|
||||
// The item's move, then the component's scale and lift (a row vector times the matrix).
|
||||
assert_eq!(transform_point(&placed[1].1, [1.0, 0.0, 1.0]), [12.0, 20.0, 37.0]);
|
||||
assert_eq!(placed[2].1, IDENTITY);
|
||||
|
||||
// The square: the object's default base material, then a textured triangle. The triangle
|
||||
// that names vertex 9 is left out. Positions are the file's vertices.
|
||||
let square = &s.meshes[0];
|
||||
assert_eq!((square.name.as_str(), square.positions.len(), square.colors.len(), square.parts.len()), ("Square", 4, 0, 2));
|
||||
let glass = &s.materials[square.parts[0].material.unwrap()];
|
||||
assert_eq!((glass.name.as_str(), glass.base_color, glass.alpha_mode), ("Glass", [0.0, 0.0, 1.0, 64.0 / 255.0], AlphaMode::Blend));
|
||||
assert_eq!(square.parts[0].corners, [0, 1, 2].map(Corner::at));
|
||||
// Coordinates through p1 p2 p3, v flipped; the pool holds only what is used, once.
|
||||
assert_eq!(square.parts[1].corners, [(0, 0), (2, 1), (3, 2)].map(|(position, uv)| Corner { position, normal: NONE, uv }));
|
||||
assert_eq!(square.uv_sets, [vec![[0.0, 1.0], [1.0, 0.0], [1.0, 0.75]]]);
|
||||
let wood = &s.materials[square.parts[1].material.unwrap()];
|
||||
let texture = wood.base_color_texture.unwrap();
|
||||
assert_eq!((texture.wrap, wood.base_color), ([Wrap::Mirror, Wrap::Clamp], [1.0; 4]));
|
||||
let image = &s.images[texture.image];
|
||||
assert_eq!(
|
||||
(image.uri.as_deref(), image.mime_type.as_deref(), image.data.as_deref()),
|
||||
(Some("/3D/Textures/wood.png"), Some("image/png"), Some(&b"wood image"[..]))
|
||||
);
|
||||
|
||||
// The triangle: a colour per corner makes a position per vertex and colour; a triangle
|
||||
// without a property is white.
|
||||
let tri = &s.meshes[1];
|
||||
let (red, green, white) = ([1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 128.0 / 255.0], [1.0; 4]);
|
||||
assert_eq!(tri.colors, [red, green, red, green, green, white, white, white]);
|
||||
assert_eq!((tri.positions[0], tri.positions[2]), (tri.positions[4], tri.positions[3]));
|
||||
assert_eq!(tri.parts.len(), 2);
|
||||
assert_eq!(tri.parts[0].corners, [0, 1, 2, 3, 1, 4].map(Corner::at));
|
||||
assert_eq!((tri.parts[1].material, tri.parts[1].corners.len()), (None, 3));
|
||||
assert_eq!(s.materials[tri.parts[0].material.unwrap()].base_color, [1.0; 4]);
|
||||
|
||||
assert_eq!(s.warnings.len(), 2, "{:?}", s.warnings);
|
||||
assert!(s.warnings[0].starts_with("1 triangle") && s.warnings[1].starts_with("1 build item"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_model_part_alone_and_what_is_not_read() {
|
||||
// No package: textures are asked of the caller, who may decline.
|
||||
let m = part(MODEL, &Limits::default()).unwrap();
|
||||
assert_eq!((m.scene.images[0].data.as_ref(), m.scene.external.as_slice()), (None, &["/3D/Textures/wood.png".to_string()][..]));
|
||||
// A model without build items places every object once; the default unit is the millimetre.
|
||||
let bare = "<model><resources><object id='5'><mesh><vertices><vertex x='0' y='0' z='0'/><vertex x='1' y='0' z='0'/><vertex x='0' y='1' z='0'/></vertices>\
|
||||
<triangles><triangle v1='0' v2='1' v3='2'/></triangles></mesh></object><object id='6'><components><component objectid='5' path='/3D/other.model'/>\
|
||||
<component objectid='5' transform='1 2 3'/></components></object><m:compositematerials id='9' matid='1' matindices='0 1'/></resources></model>";
|
||||
let m = part(bare, &Limits::default()).unwrap();
|
||||
m.scene.validate().unwrap();
|
||||
assert_eq!((m.unit, m.scene.roots.len(), m.scene.nodes.len()), (Unit::Millimeter, 2, 3));
|
||||
// A transform that is not twelve numbers is the identity.
|
||||
assert_eq!(m.scene.nodes[2].transform, IDENTITY);
|
||||
assert_eq!(m.scene.warnings.len(), 2, "{:?}", m.scene.warnings);
|
||||
// A package without the relationship still finds its model part; one without a model part,
|
||||
// and XML that is not a model, are refused.
|
||||
let entry = |name: &str, data: &str| (name.to_string(), data.as_bytes().to_vec());
|
||||
let loose = craft_opc::zip_entries(&[entry("Model/thing.model", bare)]).unwrap();
|
||||
assert_eq!(parse(&loose).unwrap().meshes.len(), 1);
|
||||
assert!(matches!(parse(&craft_opc::zip_entries(&[entry("readme.txt", "hello")]).unwrap()), Err(Error::Malformed(_))));
|
||||
assert!(matches!(part("<svg/>", &Limits::default()), Err(Error::Malformed(_))));
|
||||
assert!(matches!(part("", &Limits::default()), Err(Error::Malformed(_))));
|
||||
assert!(matches!(parse(b"not a zip"), Err(Error::Malformed(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_limit_is_kept() {
|
||||
let d = Limits::default;
|
||||
let refused = |limits: Limits| matches!(read(&package(MODEL), &limits), Err(Error::TooLarge(_)));
|
||||
// 7 vertices, 2 colours and 3 coordinates; 6 triangles; 3 objects; 4 parts; 2 base
|
||||
// materials and a texture; 4 placed objects (5 components and items); components 1 deep.
|
||||
for (at, under) in [
|
||||
(Limits { max_vertices: 12, ..d() }, Limits { max_vertices: 11, ..d() }),
|
||||
(Limits { max_triangles: 6, ..d() }, Limits { max_triangles: 5, ..d() }),
|
||||
(Limits { max_meshes: 3, ..d() }, Limits { max_meshes: 2, ..d() }),
|
||||
(Limits { max_parts: 4, ..d() }, Limits { max_parts: 3, ..d() }),
|
||||
(Limits { max_materials: 3, ..d() }, Limits { max_materials: 2, ..d() }),
|
||||
(Limits { max_nodes: 4, ..d() }, Limits { max_nodes: 3, ..d() }),
|
||||
(Limits { max_depth: 1, ..d() }, Limits { max_depth: 0, ..d() }),
|
||||
] {
|
||||
assert!(!refused(at.clone()), "{at:?}");
|
||||
assert!(refused(under.clone()), "{under:?}");
|
||||
}
|
||||
// The package may not inflate past the data limit, and a texture counts against it.
|
||||
assert!(refused(Limits { max_data_bytes: MODEL.len() - 1, ..d() }));
|
||||
let texture = |max_data_bytes: usize| read_model_part(MODEL.as_bytes(), &Limits { max_data_bytes, ..d() }, &mut |_: &str| Some(vec![0; 100]));
|
||||
assert!(texture(100).is_ok());
|
||||
assert!(matches!(texture(99), Err(Error::TooLarge(_))));
|
||||
// An object that is its own component ends at the depth limit instead of going round.
|
||||
let cycle = "<model><resources><object id='1'><components><component objectid='1'/></components></object></resources><build><item objectid='1'/></build></model>";
|
||||
assert!(matches!(part(cycle, &d()), Err(Error::TooLarge(_))));
|
||||
// Two objects placing each other twice double at every level: the node limit ends it.
|
||||
let fan = "<model><resources><object id='1'><components><component objectid='2'/><component objectid='2'/></components></object>\
|
||||
<object id='2'><components><component objectid='1'/><component objectid='1'/></components></object></resources><build><item objectid='1'/></build></model>";
|
||||
assert!(matches!(part(fan, &Limits { max_nodes: 1000, max_depth: 64, ..d() }), Err(Error::TooLarge(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_files_fail_cleanly() {
|
||||
hammer(MODEL.as_bytes(), &|b| read_model_part(b, &Limits::default(), &mut no_external).map(|m| m.scene));
|
||||
// The package: every 7th truncation and mutation (inflating each is slow).
|
||||
let good = package(MODEL);
|
||||
for i in (0..good.len()).step_by(7) {
|
||||
let mut b = good.clone();
|
||||
b[i] ^= 0xA5;
|
||||
for bytes in [&good[..i], &b[..]] {
|
||||
if let Ok(s) = parse(bytes) {
|
||||
s.validate().unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,518 @@
|
||||
//! 3MF, read: the 3MF Consortium's core specification and its materials and properties
|
||||
//! extension.
|
||||
//!
|
||||
//! A 3MF file is an OPC package (a ZIP archive with relationships): [`read`] and [`read_model`]
|
||||
//! open it with `craft-opc`, find the model part through the package's `3dmodel` relationship
|
||||
//! (or, without one, the first `.model` part) and read it. [`read_model_part`] reads a model
|
||||
//! part's XML on its own, for a caller that has the parts some other way.
|
||||
//!
|
||||
//! Read: mesh objects (vertices and triangles), component objects and build items with their
|
||||
//! transforms, the model's unit, base materials (`displaycolor`), colour groups and texture
|
||||
//! coordinate groups with their textures.
|
||||
//!
|
||||
//! - Every build item is a root [`Node`] and every component a child node, each with its
|
||||
//! transform; an object placed several times is one [`Mesh`] named by several nodes. A file
|
||||
//! without build items places every object once.
|
||||
//! - A triangle's property group decides its [`Part`]: one per base material, one for vertex
|
||||
//! colours, one per texture, one for triangles without a property. Base materials become
|
||||
//! materials of that colour (linear); the colour part has a white material; a texture part's
|
||||
//! material has the texture as its base colour, with the tile styles as wrap modes.
|
||||
//! - Colour groups are per corner in the file and per position in the model, so a vertex that
|
||||
//! takes different colours in different triangles becomes one position per colour. Without
|
||||
//! colours the positions are the file's vertices, in order.
|
||||
//! - Texture coordinates are a pool indexed per corner; v is flipped to point down.
|
||||
//! - A texture's image is the package part its `path` names: its bytes are [`Image::data`],
|
||||
//! its path [`Image::uri`].
|
||||
//!
|
||||
//! Positions stay in the file's unit ([`Model::unit`]) and axes (3MF is Z-up).
|
||||
//!
|
||||
//! Not read, and named in a warning when present: components in another model part (the
|
||||
//! production extension), composite materials and multi-properties. Slices, beam lattices and
|
||||
//! other extensions are passed over. A triangle that names a vertex that is not there is left
|
||||
//! out and counted in a warning.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use craft_xml::{Event, Options, to_utf8};
|
||||
|
||||
use crate::{
|
||||
AlphaMode, Corner, Error, IDENTITY, Image, Limits, Mat4, Material, Mesh, Node, Part, Resolver, Result, Scene, TextureRef, Wrap, count_warning,
|
||||
malformed, srgb_to_linear, too_large,
|
||||
};
|
||||
|
||||
const KIND: &str = "3MF";
|
||||
/// Most parts a package may have.
|
||||
pub const MAX_ENTRIES: usize = 10_000;
|
||||
|
||||
/// The unit of a model's coordinates.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Unit {
|
||||
Micron,
|
||||
#[default]
|
||||
Millimeter,
|
||||
Centimeter,
|
||||
Inch,
|
||||
Foot,
|
||||
Meter,
|
||||
}
|
||||
|
||||
impl Unit {
|
||||
pub fn metres(self) -> f64 {
|
||||
match self {
|
||||
Unit::Micron => 1e-6,
|
||||
Unit::Millimeter => 0.001,
|
||||
Unit::Centimeter => 0.01,
|
||||
Unit::Inch => 0.0254,
|
||||
Unit::Foot => 0.3048,
|
||||
Unit::Meter => 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a 3MF file holds: the scene, and the unit its positions are in.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Model {
|
||||
pub scene: Scene,
|
||||
pub unit: Unit,
|
||||
}
|
||||
|
||||
/// Is this a ZIP archive (which a 3MF file is)? Other formats are ZIP archives too.
|
||||
pub fn is_zip(bytes: &[u8]) -> bool {
|
||||
bytes.starts_with(b"PK\x03\x04")
|
||||
}
|
||||
|
||||
/// Read a 3MF file into a scene. [`read_model`] also gives the unit.
|
||||
pub fn read(bytes: &[u8], limits: &Limits) -> Result<Scene> {
|
||||
Ok(read_model(bytes, limits)?.scene)
|
||||
}
|
||||
|
||||
fn package_error(e: craft_opc::Error) -> Error {
|
||||
if e.is_limit() { too_large(KIND, e) } else { malformed(KIND, e) }
|
||||
}
|
||||
|
||||
/// Read a 3MF file. The package may inflate to [`Limits::max_data_bytes`] in all.
|
||||
pub fn read_model(bytes: &[u8], limits: &Limits) -> Result<Model> {
|
||||
let most = limits.max_data_bytes as u64;
|
||||
let mut package = craft_opc::LazyPackage::open(bytes, &craft_opc::Limits::new(MAX_ENTRIES, most, most)).map_err(package_error)?;
|
||||
let related = package.rels("").map_err(package_error)?.first("3dmodel").filter(|r| !r.external).map(|r| r.target.clone());
|
||||
let root = related
|
||||
.filter(|r| package.has(r))
|
||||
.or_else(|| package.names().into_iter().find(|n| n.ends_with(".model")).map(str::to_string))
|
||||
.ok_or_else(|| malformed(KIND, "the package has no model part"))?;
|
||||
let xml = package.read(&root).map_err(package_error)?.ok_or_else(|| malformed(KIND, "the package has no model part"))?;
|
||||
// A texture that cannot be read leaves its image without data.
|
||||
read_model_part(&xml, limits, &mut |path: &str| package.read_optional_limited(path, most).ok().flatten())
|
||||
}
|
||||
|
||||
/// A 3MF transform is twelve numbers, the rows of a 4×3 matrix that a row vector multiplies
|
||||
/// from the left; anything else is the identity.
|
||||
fn transform(s: Option<&str>) -> Mat4 {
|
||||
let v: Vec<f64> = s.unwrap_or("").split_whitespace().take(13).filter_map(|t| t.parse::<f64>().ok()).filter(|f| f.is_finite()).collect();
|
||||
let mut m = IDENTITY;
|
||||
if let Ok(v) = <[f64; 12]>::try_from(v) {
|
||||
for (i, x) in v.into_iter().enumerate() {
|
||||
m[(i / 3) * 4 + i % 3] = x;
|
||||
}
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
fn wrap(s: Option<&str>) -> Wrap {
|
||||
match s {
|
||||
Some("mirror") => Wrap::Mirror,
|
||||
Some("clamp" | "none") => Wrap::Clamp,
|
||||
_ => Wrap::Repeat,
|
||||
}
|
||||
}
|
||||
|
||||
/// `#RRGGBB` or `#RRGGBBAA` as display values in 0..1.
|
||||
fn color(s: Option<&str>) -> Option<[f32; 4]> {
|
||||
let hex = s?.trim().strip_prefix('#')?;
|
||||
let byte = |i: usize| hex.get(i * 2..i * 2 + 2).and_then(|h| u8::from_str_radix(h, 16).ok()).map(|b| f32::from(b) / 255.0);
|
||||
match hex.len() {
|
||||
6 => Some([byte(0)?, byte(1)?, byte(2)?, 1.0]),
|
||||
8 => Some([byte(0)?, byte(1)?, byte(2)?, byte(3)?]),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
struct Triangle {
|
||||
v: [u32; 3],
|
||||
pid: Option<u32>,
|
||||
p: [Option<u32>; 3],
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Object {
|
||||
name: String,
|
||||
vertices: Vec<[f64; 3]>,
|
||||
triangles: Vec<Triangle>,
|
||||
pid: Option<u32>,
|
||||
pindex: Option<u32>,
|
||||
components: Vec<(u32, Mat4)>,
|
||||
}
|
||||
|
||||
struct Texture {
|
||||
path: String,
|
||||
content_type: Option<String>,
|
||||
wrap: [Wrap; 2],
|
||||
}
|
||||
|
||||
/// The resource group being read.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Group {
|
||||
Base(u32),
|
||||
Colors(u32),
|
||||
Coords(u32),
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Parsed {
|
||||
unit: Unit,
|
||||
base: HashMap<u32, Vec<(String, [f32; 4])>>,
|
||||
colors: HashMap<u32, Vec<[f32; 4]>>,
|
||||
textures: HashMap<u32, Texture>,
|
||||
/// Coordinate group id → (texture id, coordinates).
|
||||
coords: HashMap<u32, (u32, Vec<[f32; 2]>)>,
|
||||
/// In file order.
|
||||
objects: Vec<(u32, Object)>,
|
||||
build: Vec<(u32, Mat4)>,
|
||||
/// Elements that are not read, for the warnings.
|
||||
foreign_components: usize,
|
||||
mixed_materials: usize,
|
||||
}
|
||||
|
||||
fn parse(xml: &[u8], limits: &Limits) -> Result<Parsed> {
|
||||
let text = to_utf8(xml);
|
||||
let mut events = Options::local().events(&text);
|
||||
let mut m = Parsed::default();
|
||||
let mut group: Option<Group> = None;
|
||||
let mut object: Option<(u32, Object)> = None;
|
||||
// Over the whole file, not each object or group alone.
|
||||
let (mut vertices, mut triangles, mut properties, mut components) = (0usize, 0usize, 0usize, 0usize);
|
||||
let room = |count: &mut usize, most: usize, what: &str| {
|
||||
*count += 1;
|
||||
if *count > most { Err(too_large(KIND, format!("more than {most} {what}"))) } else { Ok(()) }
|
||||
};
|
||||
let mut rooted = false;
|
||||
while let Some(event) = events.next().map_err(|e| if e.is_limit() { too_large(KIND, e) } else { malformed(KIND, e) })? {
|
||||
let (name, attrs) = match event {
|
||||
Event::Start { name, attrs } => (name, attrs),
|
||||
Event::End { name } => {
|
||||
match name.as_str() {
|
||||
"basematerials" | "colorgroup" | "texture2dgroup" => group = None,
|
||||
"object" => m.objects.extend(object.take()),
|
||||
_ => {}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
let attr = |key: &str| attrs.iter().find(|(k, _)| k == key).map(|(_, v)| v.as_str());
|
||||
let id = |key: &str| attr(key).and_then(|s| s.trim().parse::<u32>().ok());
|
||||
let number = |key: &str| attr(key).and_then(|s| s.trim().parse::<f64>().ok()).filter(|v| v.is_finite()).unwrap_or(0.0);
|
||||
if !rooted {
|
||||
if name != "model" {
|
||||
return Err(malformed(KIND, "the model part's root element is not `model`"));
|
||||
}
|
||||
rooted = true;
|
||||
}
|
||||
match name.as_str() {
|
||||
"model" => {
|
||||
m.unit = match attr("unit") {
|
||||
Some("micron") => Unit::Micron,
|
||||
Some("centimeter") => Unit::Centimeter,
|
||||
Some("inch") => Unit::Inch,
|
||||
Some("foot") => Unit::Foot,
|
||||
Some("meter") => Unit::Meter,
|
||||
_ => Unit::Millimeter,
|
||||
}
|
||||
}
|
||||
"basematerials" => group = id("id").map(Group::Base),
|
||||
"colorgroup" => group = id("id").map(Group::Colors),
|
||||
"texture2dgroup" => {
|
||||
group = id("id").map(Group::Coords);
|
||||
if let (Some(id), Some(texture)) = (id("id"), id("texid")) {
|
||||
m.coords.entry(id).or_insert((texture, vec![]));
|
||||
}
|
||||
}
|
||||
"compositematerials" | "multiproperties" => m.mixed_materials += 1,
|
||||
"base" => {
|
||||
if let Some(Group::Base(id)) = group {
|
||||
room(&mut properties, limits.max_materials, "base materials")?;
|
||||
let c = color(attr("displaycolor")).unwrap_or([0.8, 0.8, 0.8, 1.0]);
|
||||
m.base.entry(id).or_default().push((attr("name").unwrap_or("").to_string(), c));
|
||||
}
|
||||
}
|
||||
"color" => {
|
||||
if let Some(Group::Colors(id)) = group {
|
||||
room(&mut vertices, limits.max_vertices, "vertices, colours and texture coordinates")?;
|
||||
m.colors.entry(id).or_default().push(color(attr("color")).unwrap_or([1.0; 4]));
|
||||
}
|
||||
}
|
||||
"tex2coord" => {
|
||||
if let Some(Group::Coords(id)) = group
|
||||
&& let Some((_, list)) = m.coords.get_mut(&id)
|
||||
{
|
||||
room(&mut vertices, limits.max_vertices, "vertices, colours and texture coordinates")?;
|
||||
// 3MF's v runs up from the bottom of the image.
|
||||
list.push([number("u") as f32, 1.0 - number("v") as f32]);
|
||||
}
|
||||
}
|
||||
"texture2d" => {
|
||||
if let (Some(id), Some(path)) = (id("id"), attr("path")) {
|
||||
room(&mut properties, limits.max_materials, "base materials and textures")?;
|
||||
let texture = Texture {
|
||||
path: path.to_string(),
|
||||
content_type: attr("contenttype").map(str::to_string),
|
||||
wrap: [wrap(attr("tilestyleu")), wrap(attr("tilestylev"))],
|
||||
};
|
||||
m.textures.entry(id).or_insert(texture);
|
||||
}
|
||||
}
|
||||
"object" => {
|
||||
if m.objects.len() >= limits.max_meshes {
|
||||
return Err(too_large(KIND, format!("more than {} objects", limits.max_meshes)));
|
||||
}
|
||||
let o = Object { name: attr("name").unwrap_or("").to_string(), pid: id("pid"), pindex: id("pindex"), ..Object::default() };
|
||||
object = id("id").map(|id| (id, o));
|
||||
}
|
||||
"vertex" => {
|
||||
if let Some((_, o)) = object.as_mut() {
|
||||
room(&mut vertices, limits.max_vertices, "vertices, colours and texture coordinates")?;
|
||||
o.vertices.push([number("x"), number("y"), number("z")]);
|
||||
}
|
||||
}
|
||||
"triangle" => {
|
||||
if let Some((_, o)) = object.as_mut() {
|
||||
room(&mut triangles, limits.max_triangles, "triangles")?;
|
||||
// One without its three vertices is counted as left out when the mesh is built.
|
||||
let v = [id("v1"), id("v2"), id("v3")].map(|v| v.unwrap_or(u32::MAX));
|
||||
o.triangles.push(Triangle { v, pid: id("pid"), p: [id("p1"), id("p2"), id("p3")] });
|
||||
}
|
||||
}
|
||||
"component" => {
|
||||
if let (Some((_, o)), Some(target)) = (object.as_mut(), id("objectid")) {
|
||||
if attr("path").is_some() {
|
||||
m.foreign_components += 1;
|
||||
} else {
|
||||
// Each one becomes a node; the node limit is checked when they are placed.
|
||||
room(&mut components, limits.max_nodes, "components")?;
|
||||
o.components.push((target, transform(attr("transform"))));
|
||||
}
|
||||
}
|
||||
}
|
||||
"item" => {
|
||||
if let Some(target) = id("objectid") {
|
||||
if m.build.len() >= limits.max_nodes {
|
||||
return Err(too_large(KIND, format!("more than {} build items", limits.max_nodes)));
|
||||
}
|
||||
m.build.push((target, transform(attr("transform"))));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if !rooted {
|
||||
return Err(malformed(KIND, "the model part has no `model` element"));
|
||||
}
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
/// What a triangle is drawn with.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
|
||||
enum Look {
|
||||
Plain,
|
||||
Base(u32, u32),
|
||||
Colors,
|
||||
Texture(u32),
|
||||
}
|
||||
|
||||
struct Build<'a, 'r> {
|
||||
parsed: &'a Parsed,
|
||||
limits: &'a Limits,
|
||||
texture: Resolver<'r>,
|
||||
scene: Scene,
|
||||
materials: HashMap<Look, usize>,
|
||||
data_left: usize,
|
||||
parts: usize,
|
||||
dropped: usize,
|
||||
}
|
||||
|
||||
impl Build<'_, '_> {
|
||||
/// The scene's material for `look`, made on first use.
|
||||
fn material(&mut self, look: Look) -> Result<Option<usize>> {
|
||||
if look == Look::Plain {
|
||||
return Ok(None);
|
||||
}
|
||||
if let Some(i) = self.materials.get(&look) {
|
||||
return Ok(Some(*i));
|
||||
}
|
||||
let m = self.parsed;
|
||||
let material = match look {
|
||||
Look::Base(group, index) => {
|
||||
let (name, c) = m.base.get(&group).and_then(|l| l.get(index as usize)).cloned().unwrap_or((String::new(), [0.8, 0.8, 0.8, 1.0]));
|
||||
let base_color = [srgb_to_linear(c[0]), srgb_to_linear(c[1]), srgb_to_linear(c[2]), c[3]];
|
||||
Material { alpha_mode: if c[3] < 0.999 { AlphaMode::Blend } else { AlphaMode::Opaque }, ..Material::plain(name, base_color) }
|
||||
}
|
||||
Look::Texture(id) => {
|
||||
let texture = match m.textures.get(&id) {
|
||||
Some(t) => {
|
||||
let data = (self.texture)(&t.path);
|
||||
self.data_left = self
|
||||
.data_left
|
||||
.checked_sub(data.as_ref().map_or(0, Vec::len))
|
||||
.ok_or_else(|| too_large(KIND, format!("textures of more than {} bytes", self.limits.max_data_bytes)))?;
|
||||
self.scene.external.push(t.path.clone());
|
||||
self.scene.images.push(Image { name: String::new(), uri: Some(t.path.clone()), mime_type: t.content_type.clone(), data });
|
||||
Some(TextureRef { image: self.scene.images.len() - 1, uv_set: 0, wrap: t.wrap })
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
Material { base_color_texture: texture, ..Material::plain("", [1.0; 4]) }
|
||||
}
|
||||
Look::Colors | Look::Plain => Material::plain("", [1.0; 4]),
|
||||
};
|
||||
self.scene.materials.push(material);
|
||||
self.materials.insert(look, self.scene.materials.len() - 1);
|
||||
Ok(Some(self.scene.materials.len() - 1))
|
||||
}
|
||||
|
||||
/// The mesh of an object's triangles; `None` when none of them can be kept.
|
||||
fn mesh(&mut self, o: &Object) -> Result<Option<Mesh>> {
|
||||
let m = self.parsed;
|
||||
// What each triangle is drawn with: its own property group or the object's.
|
||||
let look_of = |t: &Triangle| {
|
||||
let (pid, p1) = match t.pid {
|
||||
Some(pid) => (Some(pid), t.p[0].unwrap_or(0)),
|
||||
None => (o.pid, o.pindex.unwrap_or(0)),
|
||||
};
|
||||
let look = match pid {
|
||||
Some(g) if m.base.contains_key(&g) => Look::Base(g, p1),
|
||||
Some(g) if m.colors.contains_key(&g) => Look::Colors,
|
||||
Some(g) => m.coords.get(&g).map_or(Look::Plain, |c| Look::Texture(c.0)),
|
||||
None => Look::Plain,
|
||||
};
|
||||
(look, pid, [p1, t.p[1].unwrap_or(p1), t.p[2].unwrap_or(p1)])
|
||||
};
|
||||
let colored = o.triangles.iter().any(|t| look_of(t).0 == Look::Colors);
|
||||
let mut mesh = Mesh { name: o.name.clone(), ..Mesh::default() };
|
||||
if !colored {
|
||||
mesh.positions.clone_from(&o.vertices);
|
||||
}
|
||||
// With colours: a position per vertex and colour.
|
||||
let mut position_of: HashMap<(u32, [u32; 4]), u32> = HashMap::new();
|
||||
let mut uvs: Vec<[f32; 2]> = vec![];
|
||||
let mut uv_of: HashMap<(u32, u32), u32> = HashMap::new();
|
||||
let mut part_of: HashMap<Look, usize> = HashMap::new();
|
||||
for t in &o.triangles {
|
||||
if t.v.iter().any(|v| *v as usize >= o.vertices.len()) {
|
||||
self.dropped += 1;
|
||||
continue;
|
||||
}
|
||||
let (look, pid, p) = look_of(t);
|
||||
let at = match part_of.get(&look) {
|
||||
Some(at) => *at,
|
||||
None => {
|
||||
self.parts += 1;
|
||||
if self.parts > self.limits.max_parts {
|
||||
return Err(too_large(KIND, format!("more than {} parts", self.limits.max_parts)));
|
||||
}
|
||||
mesh.parts.push(Part { material: self.material(look)?, ..Part::default() });
|
||||
part_of.insert(look, mesh.parts.len() - 1);
|
||||
mesh.parts.len() - 1
|
||||
}
|
||||
};
|
||||
for (v, p) in t.v.into_iter().zip(p) {
|
||||
let mut corner = Corner::at(v);
|
||||
if colored {
|
||||
let c = match look {
|
||||
Look::Colors => pid.and_then(|g| m.colors.get(&g)).and_then(|l| l.get(p as usize)).copied().unwrap_or([1.0; 4]),
|
||||
_ => [1.0; 4],
|
||||
};
|
||||
corner.position = *position_of.entry((v, c.map(f32::to_bits))).or_insert_with(|| {
|
||||
mesh.positions.extend(o.vertices.get(v as usize));
|
||||
mesh.colors.push(c);
|
||||
(mesh.positions.len() - 1) as u32
|
||||
});
|
||||
}
|
||||
if let (Look::Texture(_), Some(group)) = (look, pid)
|
||||
&& let Some(uv) = m.coords.get(&group).and_then(|c| c.1.get(p as usize))
|
||||
{
|
||||
corner.uv = *uv_of.entry((group, p)).or_insert_with(|| {
|
||||
uvs.push(*uv);
|
||||
(uvs.len() - 1) as u32
|
||||
});
|
||||
}
|
||||
if let Some(part) = mesh.parts.get_mut(at) {
|
||||
part.corners.push(corner);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !uvs.is_empty() {
|
||||
mesh.uv_sets.push(uvs);
|
||||
}
|
||||
Ok((!mesh.parts.is_empty()).then_some(mesh))
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a model part's XML. `texture` is asked for the package parts textures name (by their
|
||||
/// `path`, as written: a part name such as `/3D/Textures/wood.png`), and may decline.
|
||||
pub fn read_model_part(xml: &[u8], limits: &Limits, texture: Resolver) -> Result<Model> {
|
||||
let parsed = parse(xml, limits)?;
|
||||
let mut b = Build {
|
||||
parsed: &parsed,
|
||||
limits,
|
||||
texture,
|
||||
scene: Scene::default(),
|
||||
materials: HashMap::new(),
|
||||
data_left: limits.max_data_bytes,
|
||||
parts: 0,
|
||||
dropped: 0,
|
||||
};
|
||||
// Each object's mesh once, however often it is placed. The first object of an id wins.
|
||||
let mut objects: HashMap<u32, (&Object, Option<usize>)> = HashMap::new();
|
||||
for (id, o) in &parsed.objects {
|
||||
if objects.contains_key(id) {
|
||||
continue;
|
||||
}
|
||||
let mesh = match b.mesh(o)? {
|
||||
Some(mesh) => {
|
||||
b.scene.meshes.push(mesh);
|
||||
Some(b.scene.meshes.len() - 1)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
objects.insert(*id, (o, mesh));
|
||||
}
|
||||
// Build items, or every object once; components below them, in order.
|
||||
let items: Vec<(u32, Mat4)> =
|
||||
if parsed.build.is_empty() { parsed.objects.iter().map(|(id, _)| (*id, IDENTITY)).collect() } else { parsed.build.clone() };
|
||||
let mut missing = 0usize;
|
||||
let mut stack: Vec<(u32, Mat4, Option<usize>, usize)> = items.into_iter().rev().map(|(id, t)| (id, t, None, 0)).collect();
|
||||
while let Some((id, transform, parent, depth)) = stack.pop() {
|
||||
if depth > limits.max_depth {
|
||||
return Err(too_large(KIND, format!("components nest deeper than {}", limits.max_depth)));
|
||||
}
|
||||
let Some((o, mesh)) = objects.get(&id) else {
|
||||
missing += 1;
|
||||
continue;
|
||||
};
|
||||
if b.scene.nodes.len() >= limits.max_nodes {
|
||||
return Err(too_large(KIND, format!("more than {} placed objects", limits.max_nodes)));
|
||||
}
|
||||
let node = b.scene.nodes.len();
|
||||
b.scene.nodes.push(Node { name: o.name.clone(), transform, mesh: *mesh, ..Node::default() });
|
||||
match parent.and_then(|p| b.scene.nodes.get_mut(p)) {
|
||||
Some(p) => p.children.push(node),
|
||||
None => b.scene.roots.push(node),
|
||||
}
|
||||
stack.extend(o.components.iter().rev().map(|(target, t)| (*target, *t, Some(node), depth + 1)));
|
||||
}
|
||||
let mut scene = b.scene;
|
||||
count_warning(&mut scene.warnings, b.dropped, "triangle(s) named a vertex that is not there and were left out");
|
||||
count_warning(&mut scene.warnings, missing, "build item(s) or component(s) named an object that is not there");
|
||||
count_warning(&mut scene.warnings, parsed.foreign_components, "component(s) in another model part were not read");
|
||||
count_warning(&mut scene.warnings, parsed.mixed_materials, "composite material or multi-property group(s) were not read");
|
||||
Ok(Model { scene, unit: parsed.unit })
|
||||
}
|
||||
Reference in new issue
Block a user