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:
bherbruckandClaude Opus 5.5 authored and GitHub committed 2026-10-15 17:27:29 -04:00
1 parent 5823900439
commit a13124acdd
19 files changed
+1889 -71

No files matched your search

+11
View File
@@ -139,6 +139,17 @@ work cooperative and adds checked run lengths, zero-run rejection, width validat
header/input/radiance bounds and cancellation. Original licence texts are retained
in THIRD-PARTY-LICENSES.txt; no images or artwork from that project are copied.
## File-format crates
| Crate | Used for | Licence |
|---|---|---|
| gltf 1.4.1, gltf-json 1.4.1, gltf-derive 1.4.1 (https://github.com/gltf-rs/gltf) | glTF 2.0 import (crates/io/src/gltf_in.rs): the JSON document model and its reference checks; the GLB container, buffers and accessors are read by SolidCraft's own code | MIT OR Apache-2.0 |
| inflections 1.1.1 (build-time, through gltf-derive) | case conversion in gltf's derive macros | MIT |
| lazy_static 1.5.1 (through gltf-json) | | MIT OR Apache-2.0 |
The glTF test files are built by the tests themselves (crates/io/src/gltf_in_tests.rs,
crates/engine/src/tests/gltf_import.rs); no third-party models are committed.
## Recovered contributions
| SolidCraft file | Original contribution | Licence |
Generated
+49
View File
@@ -1531,6 +1531,42 @@ dependencies = [
"web-sys",
]
[[package]]
name = "gltf"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3ce1918195723ce6ac74e80542c5a96a40c2b26162c1957a5cd70799b8cacf7"
dependencies = [
"byteorder",
"gltf-json",
"lazy_static",
"serde_json",
]
[[package]]
name = "gltf-derive"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "14070e711538afba5d6c807edb74bcb84e5dbb9211a3bf5dea0dfab5b24f4c51"
dependencies = [
"inflections",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "gltf-json"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6176f9d60a7eab0a877e8e96548605dedbde9190a7ae1e80bbcc1c9af03ab14"
dependencies = [
"gltf-derive",
"serde",
"serde_derive",
"serde_json",
]
[[package]]
name = "glutin"
version = "0.32.3"
@@ -1877,6 +1913,12 @@ dependencies = [
"hashbrown 0.17.1",
]
[[package]]
name = "inflections"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a257582fdcde896fd96463bf2d40eefea0580021c0712a0e2b028b60b47a837a"
[[package]]
name = "inout"
version = "0.1.4"
@@ -2047,6 +2089,12 @@ dependencies = [
"smallvec",
]
[[package]]
name = "lazy_static"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
[[package]]
name = "libc"
version = "0.2.190"
@@ -3808,6 +3856,7 @@ dependencies = [
name = "solidcraft-io"
version = "0.13.0"
dependencies = [
"gltf",
"quick-xml",
"serde",
"serde_json",
+3
View File
@@ -53,6 +53,9 @@ rayon = "1.12"
# packages (3MF)
zip = { version = "3", default-features = false, features = ["deflate"] }
quick-xml = "0.41"
# glTF 2.0 import (MIT OR Apache-2.0): the document model and GLB container only; buffers are
# read by crates/io itself, so no image decoding or file access comes with it.
gltf = { version = "1.4", default-features = false, features = ["names"] }
# fonts (sketch text outlines; MIT OR Apache-2.0, pure Rust)
ttf-parser = { version = "0.25", default-features = false, features = ["std"] }
+3 -2
View File
@@ -129,7 +129,8 @@ rebuild with no errors, replayed here at 2× speed.
components as assemblies; revolved faces as native planes, cylinders, cones, spheres and tori),
IGES 5.3 (manifold solid B-reps with names and colours), 3MF, STL (binary/ASCII) and OBJ, and
assemblies with joints as URDF for robot simulators (docs/urdf.md);
3MF, STL and OBJ import as mesh bodies (they render, measure, move and export; solid features
3MF, STL, OBJ and glTF 2.0 (`.glb`/`.gltf`, its node tree as components and its materials as
appearances) import as mesh bodies (they render, measure, move and export; solid features
need B-rep bodies). The `mesh.convert` command reconstructs supported closed constant-section parts
as parametric solids with planar and circular cylindrical faces;
STEP import (AP203/AP214/AP242 solids, assemblies, units, names, colours) and IGES import
@@ -230,7 +231,7 @@ configuration and an example session: [docs/mcp.md](docs/mcp.md).
| L0 | `geom` | vectors, planes, profiles, meshes and their measures |
| L1 | `sketch`, `kernel`, `render` | constraint solver and profiles; the B-rep kernel boundary (truck); view math and CPU rasterizer |
| L2 | `doc` | parameters, expressions, the feature timeline and its incremental evaluation |
| L3 | `io` | design files, STL/OBJ/STEP/3MF export, STEP and 3MF/STL import features |
| L3 | `io` | design files, STL/OBJ/STEP/3MF export, STEP and 3MF/STL/OBJ/glTF import features |
| L4 | `engine` | the session and the command registry (everything is a command) |
| L5 | `gpu`, `ui-egui`, `mcp` | compute-shader evaluation of display tessellation plans; the swappable desktop front end; the MCP server (headless or bridged to the app) |
| apps | `solidcraft`, `solidcraft-cli` | desktop app, headless CLI |
+118 -28
View File
@@ -1,6 +1,6 @@
Third-party software in SolidCraft
SolidCraft (the desktop app, solidcraft-cli and the web app) is built from the 508 crates below.
SolidCraft (the desktop app, solidcraft-cli and the web app) is built from the 513 crates below.
Generated by `cargo xtask licences` from Cargo.lock and the crates' own licence files.
SolidCraft's own licence is in LICENSE-MIT and LICENSE-APACHE, and NOTICE.
@@ -145,6 +145,9 @@ getrandom 0.3.4 (MIT OR Apache-2.0)
getrandom 0.4.3 (MIT OR Apache-2.0)
glifo 0.2.0 (Apache-2.0 OR MIT)
glow 0.17.0 (MIT OR Apache-2.0 OR Zlib)
gltf 1.4.1 (MIT OR Apache-2.0)
gltf-derive 1.4.1 (MIT OR Apache-2.0)
gltf-json 1.4.1 (MIT OR Apache-2.0)
glutin 0.32.3 (Apache-2.0)
glutin-winit 0.5.0 (MIT)
glutin_egl_sys 0.7.1 (Apache-2.0)
@@ -176,6 +179,7 @@ idna 1.1.0 (MIT OR Apache-2.0)
idna_adapter 1.2.2 (Apache-2.0 OR MIT)
image 0.25.10 (MIT OR Apache-2.0)
indexmap 2.14.2 (Apache-2.0 OR MIT)
inflections 1.1.1 (MIT)
inout 0.1.4 (MIT OR Apache-2.0)
itertools 0.13.0 (MIT OR Apache-2.0)
itertools 0.15.0 (MIT OR Apache-2.0)
@@ -190,6 +194,7 @@ katexit 0.1.5 (MIT OR Apache-2.0)
keyring 3.6.3 (MIT OR Apache-2.0)
khronos-egl 6.0.0 (MIT/Apache-2.0)
kurbo 0.13.1 (Apache-2.0 OR MIT)
lazy_static 1.5.1 (MIT OR Apache-2.0)
libc 0.2.190 (MIT OR Apache-2.0)
libdbus-sys 0.2.7 (Apache-2.0/MIT)
libloading 0.8.9 (ISC)
@@ -536,17 +541,17 @@ async-io 2.6.0, async-lock 3.4.2, async-process 2.5.0, async-recursion 1.2.0, as
async-task 4.7.1, async-trait 0.1.92, atomic-waker 1.1.2, blocking 1.7.0, concurrent-queue 2.5.0,
displaydoc 0.2.7, endi 1.1.1, enumn 0.1.14, event-listener 5.4.2, event-listener-strategy 0.5.4,
fastrand 2.5.0, futures-lite 2.6.1, glow 0.17.0, hermit-abi 0.5.3, itoa 1.0.18, khronos-egl 6.0.0,
linux-raw-sys 0.12.1, linux-raw-sys 0.4.15, num_enum 0.7.6, num_enum_derive 0.7.6, once_cell 1.21.4,
ordered-stream 0.2.0, parking 2.2.1, pin-project 1.1.13, pin-project-internal 1.1.13,
pin-project-lite 0.2.17, piper 0.2.5, polling 3.11.0, portable-atomic 1.15.0, portable-atomic-util
0.2.8, proc-macro-crate 3.5.0, proc-macro2 1.0.107, quote 1.0.47, rustc-hash 1.1.0, rustc-hash
2.1.3, rustix 0.38.44, rustix 1.1.5, rustversion 1.0.23, semver 1.0.28, serde 1.0.229, serde_core
1.0.229, serde_derive 1.0.229, serde_json 1.0.151, serde_repr 0.1.21, simd_cesu8 1.2.0, smol_str
0.2.2, syn 1.0.109, syn 2.0.119, syn 3.0.6, thiserror 1.0.69, thiserror 2.0.21, thiserror-impl
1.0.69, thiserror-impl 2.0.21, typeid 1.0.3, unicode-ident 1.0.26, utf8-zero 0.8.1, wasi
0.11.1+wasi-snapshot-preview1, wasip2 1.0.4+wasi-0.2.12, wit-bindgen 0.57.1, x11-dl 2.21.0, xdg-home
1.3.0, zbus-lockstep 0.5.2, zbus-lockstep-macros 0.5.2, zbus_names 3.0.0, zmij 1.0.23,
zvariant_utils 2.1.0, zvariant_utils 4.2.0
lazy_static 1.5.1, linux-raw-sys 0.12.1, linux-raw-sys 0.4.15, num_enum 0.7.6, num_enum_derive
0.7.6, once_cell 1.21.4, ordered-stream 0.2.0, parking 2.2.1, pin-project 1.1.13,
pin-project-internal 1.1.13, pin-project-lite 0.2.17, piper 0.2.5, polling 3.11.0, portable-atomic
1.15.0, portable-atomic-util 0.2.8, proc-macro-crate 3.5.0, proc-macro2 1.0.107, quote 1.0.47,
rustc-hash 1.1.0, rustc-hash 2.1.3, rustix 0.38.44, rustix 1.1.5, rustversion 1.0.23, semver 1.0.28,
serde 1.0.229, serde_core 1.0.229, serde_derive 1.0.229, serde_json 1.0.151, serde_repr 0.1.21,
simd_cesu8 1.2.0, smol_str 0.2.2, syn 1.0.109, syn 2.0.119, syn 3.0.6, thiserror 1.0.69, thiserror
2.0.21, thiserror-impl 1.0.69, thiserror-impl 2.0.21, typeid 1.0.3, unicode-ident 1.0.26, utf8-zero
0.8.1, wasi 0.11.1+wasi-snapshot-preview1, wasip2 1.0.4+wasi-0.2.12, wit-bindgen 0.57.1, x11-dl
2.21.0, xdg-home 1.3.0, zbus-lockstep 0.5.2, zbus-lockstep-macros 0.5.2, zbus_names 3.0.0, zmij
1.0.23, zvariant_utils 2.1.0, zvariant_utils 4.2.0
================================================================================
Permission is hereby granted, free of charge, to any
@@ -1055,22 +1060,23 @@ blocking 1.7.0, bumpalo 3.20.3, cfg-if 1.0.5, cgl 0.3.2, concurrent-queue 2.5.0,
0.1.3, crossbeam-deque 0.8.8, crossbeam-epoch 0.9.21, crossbeam-utils 0.8.23, dbus-secret-service
4.1.0, derive_arbitrary 1.5.0, displaydoc 0.2.7, either 1.19.0, equivalent 1.0.2, errno 0.3.14,
euclid 0.22.14, event-listener 5.4.2, event-listener-strategy 0.5.4, fastrand 2.5.0, flate2 1.1.10,
form_urlencoded 1.2.2, futures-lite 2.6.1, gethostname 1.1.0, glow 0.17.0, hashbrown 0.16.1,
hashbrown 0.17.1, hermit-abi 0.5.3, httparse 1.10.1, idna 1.1.0, idna_adapter 1.2.2, indexmap
2.14.2, itertools 0.13.0, itertools 0.15.0, js-sys 0.3.106, khronos-egl 6.0.0, linux-raw-sys 0.12.1,
linux-raw-sys 0.4.15, lock_api 0.4.14, log 0.4.34, num 0.4.3, num-bigint 0.4.8, num-complex 0.4.6,
num-integer 0.1.47, num-iter 0.1.46, num-rational 0.4.2, num-traits 0.2.19, once_cell 1.21.4,
openssl-probe 0.2.1, ordered-stream 0.2.0, parking 2.2.1, parking_lot 0.12.5, parking_lot_core
0.9.12, percent-encoding 2.3.2, piper 0.2.5, plain 0.2.3, png 0.18.1, polling 3.11.0, pollster
0.4.0, pollster 1.0.1, rayon 1.12.0, rayon-core 1.13.0, renderdoc-sys 1.1.0, robust 1.2.0, ron
0.12.2, rustc-hash 1.1.0, rustix 0.38.44, rustix 1.1.5, rustls 0.23.46, rustls-native-certs 0.8.5,
scoped-tls 1.0.1, scopeguard 1.2.0, secret-service 4.0.0, security-framework 2.11.1,
security-framework 3.7.0, security-framework-sys 2.17.0, signal-hook-registry 1.4.8, simd_cesu8
1.2.0, smallvec 1.16.2, smol_str 0.2.2, stable_deref_trait 1.2.1, syn 1.0.109, tempfile 3.27.0,
ttf-parser 0.25.1, unicode-segmentation 1.13.3, unicode-width 0.2.2, url 2.5.8, uuid 1.27.0, wasi
0.11.1+wasi-snapshot-preview1, wasip2 1.0.4+wasi-0.2.12, wasm-bindgen 0.2.129, wasm-bindgen-futures
0.4.79, wasm-bindgen-macro 0.2.129, wasm-bindgen-macro-support 0.2.129, wasm-bindgen-shared 0.2.129,
web-sys 0.3.106, weezl 0.1.12, wit-bindgen 0.57.1
form_urlencoded 1.2.2, futures-lite 2.6.1, gethostname 1.1.0, glow 0.17.0, gltf 1.4.1, gltf-derive
1.4.1, gltf-json 1.4.1, hashbrown 0.16.1, hashbrown 0.17.1, hermit-abi 0.5.3, httparse 1.10.1, idna
1.1.0, idna_adapter 1.2.2, indexmap 2.14.2, itertools 0.13.0, itertools 0.15.0, js-sys 0.3.106,
khronos-egl 6.0.0, lazy_static 1.5.1, linux-raw-sys 0.12.1, linux-raw-sys 0.4.15, lock_api 0.4.14,
log 0.4.34, num 0.4.3, num-bigint 0.4.8, num-complex 0.4.6, num-integer 0.1.47, num-iter 0.1.46,
num-rational 0.4.2, num-traits 0.2.19, once_cell 1.21.4, openssl-probe 0.2.1, ordered-stream 0.2.0,
parking 2.2.1, parking_lot 0.12.5, parking_lot_core 0.9.12, percent-encoding 2.3.2, piper 0.2.5,
plain 0.2.3, png 0.18.1, polling 3.11.0, pollster 0.4.0, pollster 1.0.1, rayon 1.12.0, rayon-core
1.13.0, renderdoc-sys 1.1.0, robust 1.2.0, ron 0.12.2, rustc-hash 1.1.0, rustix 0.38.44, rustix
1.1.5, rustls 0.23.46, rustls-native-certs 0.8.5, scoped-tls 1.0.1, scopeguard 1.2.0, secret-service
4.0.0, security-framework 2.11.1, security-framework 3.7.0, security-framework-sys 2.17.0,
signal-hook-registry 1.4.8, simd_cesu8 1.2.0, smallvec 1.16.2, smol_str 0.2.2, stable_deref_trait
1.2.1, syn 1.0.109, tempfile 3.27.0, ttf-parser 0.25.1, unicode-segmentation 1.13.3, unicode-width
0.2.2, url 2.5.8, uuid 1.27.0, wasi 0.11.1+wasi-snapshot-preview1, wasip2 1.0.4+wasi-0.2.12,
wasm-bindgen 0.2.129, wasm-bindgen-futures 0.4.79, wasm-bindgen-macro 0.2.129,
wasm-bindgen-macro-support 0.2.129, wasm-bindgen-shared 0.2.129, web-sys 0.3.106, weezl 0.1.12,
wit-bindgen 0.57.1
================================================================================
Apache License
@@ -5922,6 +5928,65 @@ freely, subject to the following restrictions:
misrepresented as being the original software.
3. This notice may not be removed or altered from any source distribution.
================================================================================
gltf 1.4.1, gltf-json 1.4.1
================================================================================
Copyright (c) 2017 The gltf Library Developers
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
gltf-derive 1.4.1
================================================================================
`gltf_derive` was adapted from `validator_derive` (https://github.com/Keats/validator)
which has the following license:
The MIT License (MIT)
Copyright (c) 2016 Vincent Prouillet
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================================================
glutin 0.32.3, glutin_egl_sys 0.7.1, glutin_glx_sys 0.6.1, glutin_wgl_sys 0.6.1
================================================================================
@@ -7187,6 +7252,31 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
inflections 1.1.1
================================================================================
Copyright (c) 2016 Caleb Meredith
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be included
in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
================================================================================
inout 0.1.4
================================================================================
+1 -1
View File
@@ -458,7 +458,7 @@ fn input_picker(files: &Rc<Files>, intent: Intent) {
let Some(doc) = web_sys::window().and_then(|w| w.document()) else { return };
let Ok(input) = doc.create_element("input").map(|e| e.unchecked_into::<web_sys::HtmlInputElement>()) else { return };
input.set_type("file");
input.set_accept(".solidcraft,.solvecraft,.json,.step,.stp,.3mf,.stl");
input.set_accept(".solidcraft,.solvecraft,.json,.step,.stp,.3mf,.stl,.obj,.glb,.gltf");
// Attached (hidden) while it is open: some browsers ignore a click on a detached input.
let _ = input.set_attribute("style", "display:none");
if let Some(body) = doc.body() {
+10 -1
View File
@@ -85,7 +85,16 @@ mod web {
for (text, error) in self.files.messages.borrow_mut().drain(..) {
self.app.set_status(text, error);
}
let incoming: Vec<_> = self.files.incoming.borrow_mut().drain(..).collect();
let mut incoming: Vec<_> = self.files.incoming.borrow_mut().drain(..).collect();
// A glTF file's buffers picked with it are kept next to it, not opened.
incoming.retain_mut(|(name, bytes, _)| {
let companion =
std::path::Path::new(name.as_str()).extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("bin"));
if companion {
vfs::insert(&format!("{}{name}", files::UPLOAD), std::mem::take(bytes));
}
!companion
});
for (name, bytes, intent) in incoming {
let design = solidcraft_engine::io::is_design_path(&name);
let path = if design && intent == Intent::Open {
+8 -2
View File
@@ -6,7 +6,7 @@
//! the software renderer; see `startup` and docs/book/src/guide/troubleshooting.md.
//!
//! A `.step`/`.stp` file opens as a new design holding the file's bodies (an Import feature); a
//! `.3mf`/`.stl` file as a new design holding mesh bodies.
//! `.3mf`/`.stl`/`.obj`/`.glb`/`.gltf` file as a new design holding mesh bodies.
//!
//! `--control <port>` (or `SOLIDCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control
//! server: `{"id":1,"method":"engine.execute","params":{"command":"solid.box","params":{…}}}`.
@@ -125,12 +125,18 @@ fn services(cc: &eframe::CreationContext<'_>) -> Services {
"3MF",
"stl",
"STL",
"obj",
"OBJ",
"glb",
"GLB",
"gltf",
"GLTF",
],
)
.add_filter("SolidCraft design (container or JSON)", &[io::DESIGN_EXT, io::LEGACY_DESIGN_EXT, "json"])
.add_filter("STEP", &["step", "stp", "STEP", "STP"])
.add_filter("IGES", &["igs", "iges", "IGS", "IGES"])
.add_filter("Mesh (3MF, STL)", &["3mf", "3MF", "stl", "STL"])
.add_filter("Mesh (3MF, STL, OBJ, glTF)", &["3mf", "3MF", "stl", "STL", "obj", "OBJ", "glb", "GLB", "gltf", "GLTF"])
.add_filter("Sketch drawings (DXF, SVG)", &["dxf", "DXF", "svg", "SVG"])
.add_filter("All files", &["*"])
.pick_file();
+72 -22
View File
@@ -20,7 +20,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.icon("open")
.key("Ctrl+O")
.noundo()
.params("path: .solidcraft design, or a .step/.stp/.igs/.3mf/.stl/.obj file (opens as a new design holding the file as a component named after it, as file.insert_step makes); up?: z|y and names?: solid|product|instance for a STEP or IGES file (see file.insert_step)"),
.params("path: .solidcraft design, or a .step/.stp/.igs/.3mf/.stl/.obj/.glb/.gltf file (opens as a new design holding the file as a component named after it, as file.insert_step and file.insert_mesh make); up?: z|y for a STEP, IGES or glTF file (see file.insert_step and file.insert_mesh); names?: solid|product|instance for a STEP or IGES file (see file.insert_step)"),
CommandSpec::new("file.insert_step", "Insert STEP", insert_step)
.at("SOLID", "INSERT")
.icon("import")
@@ -28,7 +28,7 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("file.insert_mesh", "Insert Mesh", insert_mesh)
.at("SOLID", "INSERT")
.icon("import")
.params("path: .3mf, .stl or .obj file (its meshes join the design as mesh bodies in a new component named after the file → component, component_name, occurrence); name?"),
.params("path: .3mf, .stl, .obj, .glb or .gltf file (its meshes join the design as mesh bodies in a new component named after the file → component, component_name, occurrence; a glTF file's node tree becomes nested components held as one rigid group until component.unlock, a mesh placed again another occurrence, its materials the bodies' appearances, and its metres millimetres); name?; up?: z|y for a glTF file (default y: glTF has Y up, so the import's component is turned a quarter turn about X, Y becoming Z → stand_up: its occurrence; z: the file's axes are kept)"),
CommandSpec::new("file.save", "Save", save)
.icon("save")
.key("Ctrl+S")
@@ -113,19 +113,10 @@ fn add_step(s: &mut Session, path: &str, name: Option<&str>, options: &StepOptio
let sf = read_step(path, options.names)?;
let fname = name.filter(|n| !n.trim().is_empty()).unwrap_or(&sf.name).to_string();
let cname = import::component_name(path, name);
let (id, placed) = import::add_in_component(s, sf.kind, &fname, sf.body_names, &cname, Some(&sf.import))?;
let (id, placed) = import::add_in_component(s, sf.kind, &fname, sf.body_names, &cname, import::Tree::Step(&sf.import))?;
// Y up: the import's component is turned a quarter turn about X (Y becomes Z), so an
// assembly's parts and instances turn together.
let turned = if options.y_up {
let occ = placed.occurrence.ok_or_else(|| EngineError::Other("the import has no placement to turn".into()))?;
if let Some(o) = s.doc_mut().occurrences.iter_mut().find(|o| o.id == occ) {
o.transform = solidcraft_doc::mat_mul(&quarter_turn_x(), &o.transform);
}
s.refresh();
Some(occ)
} else {
None
};
let turned = if options.y_up { turn_up(s, &placed)? } else { None };
let created = created_bodies(s, id);
let fname = s.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default();
let mut out = json!({"path": path, "feature": id, "name": fname, "bodies": created, "created_bodies": created, "warnings": sf.warnings});
@@ -141,27 +132,83 @@ fn created_bodies(s: &Session, feature: u64) -> Vec<String> {
s.model.state().bodies.iter().filter(|b| b.feature == feature).map(|b| b.name.clone()).collect()
}
/// Add a MeshImport feature for a 3MF, STL or OBJ file, in a new component named after the file.
fn add_mesh(s: &mut Session, path: &str, name: Option<&str>) -> Result<Value> {
/// Add a MeshImport feature for a 3MF, STL, OBJ or glTF file, in a new component named after the
/// file. A glTF file's nodes become components in it, and its materials the bodies' looks; it is
/// in metres with Y up, so unless `y_up` is false its component is turned to Z up (#1505).
fn add_mesh(s: &mut Session, path: &str, name: Option<&str>, y_up: Option<bool>) -> Result<Value> {
let err = |e: solidcraft_io::IoError| EngineError::Other(format!("{path}: {e}"));
let bytes = solidcraft_io::vfs::read(path, solidcraft_io::MAX_3MF_BYTES).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let mf = solidcraft_io::mesh_import_feature(&bytes, path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let gltf = solidcraft_io::is_gltf_path(path);
let (mf, plan, looks) = if gltf {
let dir = std::path::Path::new(path).parent().map(std::path::Path::to_path_buf).unwrap_or_default();
let mut load =
|name: &str| solidcraft_io::vfs::read(&dir.join(name).to_string_lossy(), solidcraft_io::MAX_GLTF_BYTES).map_err(|e| e.to_string());
let mut g = solidcraft_io::read_gltf(&bytes, path, &mut load).map_err(err)?;
if !import::mesh_plan_fits(&g.plan) {
g.flatten();
}
(g.feature, Some(g.plan), g.looks)
} else {
(solidcraft_io::mesh_import_feature(&bytes, path).map_err(err)?, None, Vec::new())
};
let fname = name.filter(|n| !n.trim().is_empty()).unwrap_or(&mf.name).to_string();
let cname = import::component_name(path, name);
let (id, placed) = import::add_in_component(s, mf.kind, &fname, mf.body_names, &cname, None)?;
let tree = match &plan {
Some(p) if !p.places.is_empty() => import::Tree::Mesh(p),
_ => import::Tree::Flat,
};
let (id, placed) = import::add_in_component(s, mf.kind, &fname, mf.body_names, &cname, tree)?;
let bodies = created_bodies(s, id);
// The materials' looks, body by body (the bodies are built in the file's order).
if looks.iter().any(Option::is_some) && bodies.len() == looks.len() {
for (body, look) in bodies.iter().zip(looks) {
if let Some(look) = look {
s.doc_mut().appearances.bodies.insert(body.clone(), look);
}
}
s.refresh();
}
let turned = if gltf && y_up.unwrap_or(true) { turn_up(s, &placed)? } else { None };
let fname = s.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default();
let mut out = json!({"path": path, "feature": id, "name": fname, "bodies": bodies, "created_bodies": bodies, "warnings": mf.warnings});
placed.report(&mut out);
if let Some(turned) = turned {
out["stand_up"] = json!(turned);
}
Ok(out)
}
/// Turn an import's component a quarter turn about X (the file's Y up becomes Z up), contents
/// and all. Returns its occurrence.
fn turn_up(s: &mut Session, placed: &import::Placed) -> Result<Option<u64>> {
let occ = placed.occurrence.ok_or_else(|| EngineError::Other("the import has no placement to turn".into()))?;
if let Some(o) = s.doc_mut().occurrences.iter_mut().find(|o| o.id == occ) {
o.transform = solidcraft_doc::mat_mul(&quarter_turn_x(), &o.transform);
}
s.refresh();
Ok(Some(occ))
}
/// `up` for a glTF file: `y` (the default: glTF is Y up) turns it to Z up, `z` keeps its axes.
/// Other mesh files take no `up`.
fn mesh_up(p: &Value, path: &str, cmd: &str) -> Result<Option<bool>> {
if p.get("up").is_none_or(Value::is_null) {
return Ok(None);
}
if !solidcraft_io::is_gltf_path(path) {
return Err(bad(cmd, "`up` applies to STEP, IGES and glTF files only"));
}
Ok(Some(step_options(p, cmd)?.y_up))
}
fn insert_mesh(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "file.insert_mesh";
let path = path_arg(p, cmd)?;
if !solidcraft_io::is_mesh_path(path) {
return Err(bad(cmd, "only 3MF and STL files (.3mf, .stl) can be inserted as meshes"));
return Err(bad(cmd, "only 3MF, STL, OBJ and glTF files (.3mf, .stl, .obj, .glb, .gltf) can be inserted as meshes"));
}
add_mesh(s, path, str_(p, "name"))
let up = mesh_up(p, path, cmd)?;
add_mesh(s, path, str_(p, "name"), up)
}
fn insert_step(s: &mut Session, p: &Value) -> Result<Value> {
@@ -176,8 +223,11 @@ fn insert_step(s: &mut Session, p: &Value) -> Result<Value> {
fn open(s: &mut Session, p: &Value) -> Result<Value> {
let path = path_arg(p, "doc.open")?;
if !solidcraft_io::is_step_path(path) && ["up", "names"].iter().any(|k| p.get(*k).is_some_and(|v| !v.is_null())) {
return Err(bad("doc.open", "`up` and `names` apply to STEP and IGES files only"));
if !solidcraft_io::is_step_path(path) && p.get("names").is_some_and(|v| !v.is_null()) {
return Err(bad("doc.open", "`names` applies to STEP and IGES files only"));
}
if !solidcraft_io::is_step_path(path) && !solidcraft_io::is_gltf_path(path) && p.get("up").is_some_and(|v| !v.is_null()) {
return Err(bad("doc.open", "`up` applies to STEP, IGES and glTF files only"));
}
if solidcraft_io::is_step_path(path) || solidcraft_io::is_mesh_path(path) {
// A new, unsaved design holding the file's bodies.
@@ -186,7 +236,7 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
let r = if solidcraft_io::is_step_path(path) {
add_step(&mut fresh, path, None, &step_options(p, "doc.open")?)?
} else {
add_mesh(&mut fresh, path, None)?
add_mesh(&mut fresh, path, None, mesh_up(p, path, "doc.open")?)?
};
// The design is the file: its component is the one being worked in.
if let Some(c) = r["component"].as_u64() {
+48 -2
View File
@@ -51,7 +51,47 @@ fn rigid(m: &Mat) -> bool {
crate::cmd::component::valid_position(m)
}
/// What an import's file says about its structure.
#[derive(Clone, Copy)]
pub(crate) enum Tree<'a> {
/// Nothing: every body in the import's component.
Flat,
/// A STEP file's products and placements.
Step(&'a StepImport),
/// A mesh file's components (glTF nodes), bodies already in their component's frame
/// (checked with [`mesh_plan_fits`] first).
Mesh(&'a solidcraft_io::MeshPlan),
}
/// Can the components of `plan` be made as it says? (Otherwise its bodies are built in place.)
pub(crate) fn mesh_plan_fits(plan: &solidcraft_io::MeshPlan) -> bool {
Plan::of_mesh(plan).is_some()
}
impl Plan {
/// A glTF file's node plan, as it is; `None` when an occurrence can't hold it (a placement
/// that isn't rigid, or components out of order), so the caller builds the bodies in place.
fn of_mesh(plan: &solidcraft_io::MeshPlan) -> Option<Plan> {
let mut made = 0usize;
let mut places = Vec::with_capacity(plan.places.len());
for p in &plan.places {
// Components are made in the order of their first placement, inside one made before.
if p.slot > made || p.slot >= plan.slots.len() || p.parent.is_some_and(|q| q >= made || q == p.slot) || !rigid(&p.transform) {
return None;
}
if p.slot == made {
made += 1;
}
places.push(Place { slot: p.slot, parent: p.parent, transform: p.transform });
}
if plan.bodies.iter().flatten().any(|s| *s >= made) {
return None;
}
let slots = plan.slots.iter().take(made).map(|n| Slot { name: n.clone() }).collect();
let bodies = plan.bodies.iter().enumerate().filter_map(|(b, slot)| slot.map(|s| (b, s, IDENTITY))).collect();
Some(Plan { slots, places, bodies, skip: Vec::new() })
}
fn of(imp: &StepImport) -> Plan {
let mut plan = Plan::default();
// One root product is the import's own component; several are components under it.
@@ -200,10 +240,14 @@ pub(crate) fn add_in_component(
feature_name: &str,
body_names: Vec<String>,
name: &str,
imp: Option<&StepImport>,
tree: Tree<'_>,
) -> Result<(u64, Placed)> {
let (top, top_occ) = new_component(s, name)?;
let plan = imp.map(Plan::of).unwrap_or_default();
let (plan, imp) = match tree {
Tree::Flat => (Plan::default(), None),
Tree::Step(imp) => (Plan::of(imp), Some(imp)),
Tree::Mesh(m) => (Plan::of_mesh(m).ok_or_else(|| EngineError::Other("import: the file's node tree can't be placed".into()))?, None),
};
// The components, each made at its first placement; the others are further occurrences.
let mut comps: Vec<u64> = Vec::with_capacity(plan.slots.len());
let mut first_occ: Vec<Option<u64>> = Vec::with_capacity(plan.slots.len());
@@ -238,6 +282,8 @@ pub(crate) fn add_in_component(
// The bodies' names, as the feature will give them (checked once it is built).
let names = match (&kind, imp) {
(FeatureKind::Import { skip, .. }, Some(imp)) => solidcraft_doc::import_body_names(&s.model.state(), &body_names, imp, skip),
// Mesh bodies keep their names unless taken (then they are put right once built).
(FeatureKind::MeshImport { .. }, _) => body_names.iter().cloned().map(Some).collect(),
_ => Vec::new(),
};
let id = s.doc_mut().add_feature(kind, Some(feature_name))?;
+1
View File
@@ -307,6 +307,7 @@ mod blend_fit;
mod boolean_diagnostics;
mod component_frames;
mod curved_seam_round;
mod gltf_import;
mod import_component;
mod large_step_import;
mod loft_cut;
+210
View File
@@ -0,0 +1,210 @@
//! glTF files import as mesh bodies (#1505): in a component named after the file, the node tree
//! as nested components held as one rigid group, a mesh placed again another occurrence of one
//! component, metres as millimetres, Y up turned to Z up (unless `up: z`), and the materials as
//! the bodies' appearances. Every file here is built by the test.
use serde_json::{Value, json};
use super::run;
use crate::*;
fn temp_dir(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("solidcraft-gltf-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
/// A glTF document with one buffer holding a cube of side 0.01 m (10 mm) at the origin.
struct Cube {
bin: Vec<u8>,
json: Value,
}
fn cube_file() -> Cube {
let s = 0.01f32;
let p: Vec<[f32; 3]> = (0..8).map(|i| [(i & 1) as f32 * s, ((i >> 1) & 1) as f32 * s, ((i >> 2) & 1) as f32 * s]).collect();
let t: [u16; 36] = [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];
let mut bin: Vec<u8> = p.iter().flatten().flat_map(|v| v.to_le_bytes()).collect();
bin.extend(t.iter().flat_map(|v| v.to_le_bytes()));
let json = json!({
"asset": {"version": "2.0"},
"scene": 0,
"scenes": [{"nodes": []}],
"nodes": [],
"meshes": [
{"name": "Block", "primitives": [{"attributes": {"POSITION": 0}, "indices": 1, "material": 0}]},
{"name": "Peg", "primitives": [{"attributes": {"POSITION": 0}, "indices": 1, "material": 1}]},
],
"materials": [
{"name": "Brass", "pbrMetallicRoughness": {"baseColorFactor": [0.8, 0.5, 0.1, 1.0], "metallicFactor": 1.0, "roughnessFactor": 0.3}},
{"name": "Blue", "pbrMetallicRoughness": {"baseColorFactor": [0.0, 0.0, 1.0, 1.0], "metallicFactor": 0.0, "roughnessFactor": 0.8}},
],
"accessors": [
{"bufferView": 0, "componentType": 5126, "count": 8, "type": "VEC3", "min": [0.0, 0.0, 0.0], "max": [s, s, s]},
{"bufferView": 1, "componentType": 5123, "count": 36, "type": "SCALAR"},
],
"bufferViews": [{"buffer": 0, "byteOffset": 0, "byteLength": 96}, {"buffer": 0, "byteOffset": 96, "byteLength": 72}],
"buffers": [{"byteLength": bin.len()}],
});
Cube { bin, json }
}
impl Cube {
fn glb(&self, dir: &std::path::Path, name: &str) -> String {
let path = dir.join(name);
std::fs::write(&path, solidcraft_io::glb_container(self.json.to_string().as_bytes(), &self.bin)).unwrap();
path.to_string_lossy().to_string()
}
}
/// World bounding boxes of every shown body, rounded to 0.001 mm, sorted.
fn shown_boxes(s: &Session) -> Vec<[i64; 6]> {
let mut out: Vec<[i64; 6]> = s
.world_state()
.bodies
.iter()
.map(|b| {
let bb = solidcraft_kernel::measure(&b.body).unwrap().bbox;
[bb.min.x, bb.min.y, bb.min.z, bb.max.x, bb.max.y, bb.max.z].map(|v| (v * 1000.0).round() as i64)
})
.collect();
out.sort();
out
}
fn mm(b: [f64; 6]) -> [i64; 6] {
b.map(|v| (v * 1000.0).round() as i64)
}
fn components(s: &mut Session) -> Vec<Value> {
run(s, "component.list", json!({}))["components"].as_array().unwrap().clone()
}
/// `robot.glb`: a `Base` (Block, at x = 0.1 m) with an `Arm` (Peg, 0.05 m up Y, a quarter turn
/// about Y), and three `Bolt` nodes all placing the Peg mesh (0.03 m apart along X).
fn robot(dir: &std::path::Path, up: Option<&str>) -> (Session, Value, String) {
let mut c = cube_file();
c.json["nodes"] = json!([
{"name": "Base", "mesh": 0, "translation": [0.1, 0.0, 0.0], "children": [1]},
{"name": "Arm", "mesh": 1, "translation": [0.0, 0.05, 0.0], "rotation": [0.0, 0.70710677, 0.0, 0.70710677]},
{"name": "Bolt", "mesh": 1, "translation": [0.0, 0.0, 0.0]},
{"name": "Bolt", "mesh": 1, "translation": [0.03, 0.0, 0.0]},
{"name": "Bolt", "mesh": 1, "translation": [0.06, 0.0, 0.0]},
]);
c.json["scenes"][0]["nodes"] = json!([0, 2, 3, 4]);
let path = c.glb(dir, "robot.glb");
let mut s = Session::default();
let mut p = json!({"path": path});
if let Some(up) = up {
p["up"] = json!(up);
}
let r = run(&mut s, "file.insert_mesh", p);
(s, r, path)
}
#[test]
fn a_gltf_node_tree_comes_in_as_components_with_instances() {
let dir = temp_dir("tree");
let (mut s, r, path) = robot(&dir, Some("z"));
assert!(r.get("stand_up").is_none(), "{r}");
let list = components(&mut s);
let names: Vec<&str> = list.iter().filter_map(|c| c["name"].as_str()).collect();
// The arm and the bolts place the same mesh the same way inside: one component, named
// after the mesh.
for n in ["robot", "Base", "Peg"] {
assert!(names.contains(&n), "{n} in {names:?}");
}
assert_eq!(r["component_name"], "robot");
assert_eq!(r["subcomponents"], 2, "{r}");
let id = |n: &str| list.iter().find(|c| c["name"] == n).unwrap()["id"].as_u64().unwrap();
// Placed four times; its mesh is built once.
assert_eq!(s.doc.occurrences.iter().filter(|o| o.component == id("Peg")).count(), 4);
assert_eq!(s.model.state().bodies.len(), 2, "Block and Peg, once each");
let peg_parent = list.iter().find(|c| c["name"] == "Peg").unwrap()["parent"].as_u64().unwrap();
assert_eq!(peg_parent, id("Base"), "made where it is first placed");
// Metres are millimetres, and each body is where the file puts it. The arm is turned a
// quarter about Y: its X becomes -Z.
let want = vec![
mm([0.0, 0.0, 0.0, 10.0, 10.0, 10.0]),
mm([30.0, 0.0, 0.0, 40.0, 10.0, 10.0]),
mm([60.0, 0.0, 0.0, 70.0, 10.0, 10.0]),
mm([100.0, 0.0, 0.0, 110.0, 10.0, 10.0]),
mm([100.0, 50.0, -10.0, 110.0, 60.0, 0.0]),
];
assert_eq!(shown_boxes(&s), want);
// Materials are the bodies' looks, with their metal and roughness.
let brass = s.doc.appearances.bodies.get("Block").expect("the block's look");
assert_eq!(brass.name, "Brass");
assert_eq!(brass.color, [231, 188, 89]);
let pbr = brass.pbr.expect("PBR");
assert!((pbr.metallic() - 1.0).abs() < 1e-9 && (pbr.roughness() - 0.3).abs() < 1e-6);
assert_eq!(s.doc.appearances.bodies.values().filter(|l| l.name == "Blue").count(), 1);
// Rigid until unlocked.
let robot = id("robot");
assert_eq!(s.contents_joints(robot).len(), 5, "one for each placement");
let r = run(&mut s, "component.unlock", json!({"component": "robot"}));
assert_eq!(r["joints"].as_array().unwrap().len(), 5, "{r}");
assert!(s.contents_joints(robot).is_empty());
run(&mut s, "edit.undo", json!({}));
// Saved and opened again, it is the same (nothing new is saved for glTF).
let design = dir.join("robot.solidcraft").to_string_lossy().to_string();
run(&mut s, "file.save_as", json!({"path": design}));
let mut again = Session::default();
run(&mut again, "doc.open", json!({"path": design}));
assert_eq!(shown_boxes(&again), want);
assert_eq!(again.doc.appearances.bodies.get("Block").map(|l| l.name.as_str()), Some("Brass"));
// Opened by itself, the file is a new design of that component.
let mut opened = Session::default();
let r = run(&mut opened, "doc.open", json!({"path": path, "up": "z"}));
assert_eq!(r["component_name"], "robot", "{r}");
assert_eq!(shown_boxes(&opened), want);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn gltf_y_up_stands_up_by_default() {
let dir = temp_dir("up");
let (s, r, _) = robot(&dir, None);
assert!(r["stand_up"].as_u64().is_some(), "{r}");
// Y up becomes Z up: a body at file y in 50..60 mm is at z 50..60.
let boxes = shown_boxes(&s);
assert!(boxes.contains(&mm([100.0, 0.0, 50.0, 110.0, 10.0, 60.0])), "{boxes:?}");
assert!(boxes.iter().all(|b| b[2] >= -1), "nothing below the ground: {boxes:?}");
let e = Session::default().execute("file.insert_mesh", &json!({"path": "x.stl", "up": "y"})).unwrap_err().to_string();
assert!(e.contains("glTF"), "{e}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_gltf_with_its_bin_next_to_it_opens_and_broken_files_say_why() {
let dir = temp_dir("bin");
let mut c = cube_file();
c.json["nodes"] = json!([{"name": "Only", "mesh": 0}]);
c.json["scenes"][0]["nodes"] = json!([0]);
c.json["buffers"][0]["uri"] = json!("cube.bin");
std::fs::write(dir.join("cube.bin"), &c.bin).unwrap();
let path = dir.join("part.gltf").to_string_lossy().to_string();
std::fs::write(&path, c.json.to_string()).unwrap();
let mut s = Session::default();
let r = run(&mut s, "doc.open", json!({"path": path}));
assert_eq!(r["bodies"], json!(["Block"]), "{r}");
assert_eq!(s.doc.name, "part");
assert_eq!(shown_boxes(&s), vec![mm([0.0, -10.0, 0.0, 10.0, 0.0, 10.0])], "turned Z up about X");
// The .bin missing, the file truncated: errors that say so, and the design is untouched.
std::fs::remove_file(dir.join("cube.bin")).unwrap();
let before = s.doc.to_json();
let e = s.execute("file.insert_mesh", &json!({"path": path})).unwrap_err().to_string();
assert!(e.contains("cube.bin"), "{e}");
let glb = dir.join("cut.glb");
let whole = solidcraft_io::glb_container(c.json.to_string().as_bytes(), &c.bin);
std::fs::write(&glb, &whole[..whole.len() / 2]).unwrap();
let e = s.execute("file.insert_mesh", &json!({"path": glb.to_string_lossy()})).unwrap_err().to_string();
assert!(e.contains("truncated") || e.contains("GLB"), "{e}");
assert_eq!(s.doc.to_json(), before);
let _ = std::fs::remove_dir_all(&dir);
}
+2 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "solidcraft-io"
description = "SolidCraft file formats: designs (JSON), STL, OBJ, STEP and 3MF"
description = "SolidCraft file formats: designs (JSON), STL, OBJ, STEP, 3MF and glTF"
version.workspace = true
edition.workspace = true
license.workspace = true
@@ -15,6 +15,7 @@ solidcraft-doc = { workspace = true }
thiserror = { workspace = true }
zip = { workspace = true }
quick-xml = { workspace = true }
gltf = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
sha2 = { workspace = true }
+871
View File
@@ -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;
+445
View File
@@ -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
View File
@@ -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)));
}
+7 -5
View File
@@ -250,13 +250,15 @@ pub fn tool_definitions() -> Vec<Value> {
"Open a design",
"Open a .solidcraft design file, a STEP file (.step/.stp: a new design whose bodies come from the file, as an \
Import base feature in a component named after the file; an assembly's parts become nested components) or a \
3MF/STL mesh (.3mf/.stl: mesh bodies in such a component); the result reports the component (id, component_name). Replaces the current design; insert into it \
instead with the file.insert_step (STEP) or file.insert_mesh (3MF/STL) commands. A STEP file from a system \
that has Y up opens lying on its back: `up: \"y\"` turns its component a quarter turn about X (Y becomes Z). `names` picks what the bodies are named after when the solids' own names say nothing useful.",
mesh (.3mf/.stl/.obj/.glb/.gltf: mesh bodies in such a component; a glTF file's nodes become nested components and its \
materials appearances); the result reports the component (id, component_name). Replaces the current design; insert into it \
instead with the file.insert_step (STEP) or file.insert_mesh (mesh) commands. A STEP file from a system \
that has Y up opens lying on its back: `up: \"y\"` turns its component a quarter turn about X (Y becomes Z). glTF is Y up, \
so it is turned by default (`up: \"z\"` keeps its axes). `names` picks what the bodies are named after when the solids' own names say nothing useful.",
obj(
json!({
"path": string("Path to a .solidcraft, .step/.stp, .3mf or .stl file"),
"up": {"type": "string", "enum": ["z", "y"], "description": "STEP only. z (default) keeps the file's axes; y: the file has Y up, so its bodies are turned to stand Z up"},
"path": string("Path to a .solidcraft, .step/.stp, .3mf, .stl, .obj, .glb or .gltf file"),
"up": {"type": "string", "enum": ["z", "y"], "description": "STEP and glTF only. z keeps the file's axes; y: the file has Y up, so its bodies are turned to stand Z up. Default: z for STEP, y for glTF"},
"names": {"type": "string", "enum": ["solid", "product", "instance"], "description": "STEP only. What the bodies are named after: solid (default: the solid's own name, else the product's), product (the part's name) or instance (the assembly occurrence's name, else the product's)"},
}),
&["path"],
+2 -2
View File
@@ -953,7 +953,7 @@ impl SolveApp {
file_load::begin(self, path, true);
}
/// Insert a STEP file's bodies (an Import base feature) or a 3MF/STL file's meshes into the
/// Insert a STEP file's bodies (an Import base feature) or a mesh file's meshes into the
/// current design.
pub fn insert_path(&mut self, path: &str) {
let cmd = if solidcraft_engine::io::is_mesh_path(path) { "file.insert_mesh" } else { "file.insert_step" };
@@ -967,7 +967,7 @@ impl SolveApp {
}
}
/// A dropped file: a STEP, 3MF or STL file joins a design that has features, anything else
/// A dropped file: a STEP or mesh file (3MF, STL, OBJ, glTF) joins a design that has features, anything else
/// opens.
pub fn drop_path(&mut self, path: &str) {
let import = solidcraft_engine::io::is_step_path(path) || solidcraft_engine::io::is_mesh_path(path);
+11 -1
View File
@@ -7,6 +7,7 @@
| STEP (AP203, AP214, AP242) | solids, assemblies, units, names, colours | AP242 with names, colours and the component tree |
| IGES 5.3 | manifold solids, or trimmed surfaces sewn into solids | manifold solids with names and colours |
| 3MF, STL, OBJ | mesh bodies | yes |
| glTF 2.0 (`.glb`, `.gltf` with its `.bin` files) | mesh bodies in the node tree's components, materials as appearances | |
| 3MF with face tags | as 3MF | each triangle tagged with its B-rep face, exact coordinates |
| URDF | | the assembly as a robot: links with meshes, mass and inertia, joints from the assembly's joints |
| DXF, SVG | into a sketch | sketches and flat patterns as DXF |
@@ -16,13 +17,22 @@ Opening a STEP or IGES file creates a design with the file's bodies as a base fe
inserting one adds them to the current design. Later features build on them. Mesh bodies render,
measure, move and export, but solid features need B-rep bodies.
Every imported file (STEP, IGES, 3MF, STL or OBJ) comes in as a new component named after the
Every imported file (STEP, IGES, 3MF, STL, OBJ or glTF) comes in as a new component named after the
file, so its bodies never mix with yours. A STEP assembly keeps its structure: its parts and
sub-assemblies become nested components, and a part used several times is one component with an
occurrence for each place. The imported assembly is rigid: moving its component moves everything,
and its parts don't move on their own. Right-click the component and choose **Unlock Contents** to
joint the parts one by one. Undo removes a whole import in one step.
A glTF file (`.glb`, or `.gltf` with the `.bin` files it names in the same folder) comes in the
same way: its nodes become nested components, a mesh placed several times is one component with
an occurrence for each place, and the whole import is rigid until you unlock it. glTF is in metres
with Y up, so the import is scaled to millimetres and turned to stand Z up (`up: "z"` keeps the
file's axes). Each material's base colour, metal and roughness become the body's appearance, so
the model looks right in Render; textures, skins and animations are not imported. Scaled or
mirrored nodes are baked into their meshes. Compressed meshes (Draco, meshopt) are refused with a
message: save the file uncompressed first.
**File → Save as mesh…** (also on the Mesh tab under Modify, and in the right-click menu of a
body) saves the selected bodies as STL, 3MF or 3MF with face tags (below). With **Send to slicer** ticked, it writes the file to
a temporary folder and opens it in the chosen slicer instead of asking for a file name. The slicer