diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 047d10d0..856817e3 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -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 | diff --git a/Cargo.lock b/Cargo.lock index 0e4b61c2..137e4c9a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index a35256e3..07107d92 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/README.md b/README.md index 233e6406..e99eef6d 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/THIRD-PARTY-LICENSES.txt b/THIRD-PARTY-LICENSES.txt index f9063778..a1ae0e02 100644 --- a/THIRD-PARTY-LICENSES.txt +++ b/THIRD-PARTY-LICENSES.txt @@ -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 ================================================================================ diff --git a/apps/solidcraft-web/src/files.rs b/apps/solidcraft-web/src/files.rs index 5eab0454..126bede0 100644 --- a/apps/solidcraft-web/src/files.rs +++ b/apps/solidcraft-web/src/files.rs @@ -458,7 +458,7 @@ fn input_picker(files: &Rc, 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::()) 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() { diff --git a/apps/solidcraft-web/src/main.rs b/apps/solidcraft-web/src/main.rs index f3a788de..b9ff0d74 100644 --- a/apps/solidcraft-web/src/main.rs +++ b/apps/solidcraft-web/src/main.rs @@ -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 { diff --git a/apps/solidcraft/src/main.rs b/apps/solidcraft/src/main.rs index 75f05b7f..c2c8cfee 100644 --- a/apps/solidcraft/src/main.rs +++ b/apps/solidcraft/src/main.rs @@ -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 ` (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(); diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 1fdf684a..4424794d 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -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 { 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 { +/// 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) -> Result { + 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> { + 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> { + 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 { 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 { @@ -176,8 +223,11 @@ fn insert_step(s: &mut Session, p: &Value) -> Result { fn open(s: &mut Session, p: &Value) -> Result { 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 { 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() { diff --git a/crates/engine/src/cmd/file_import.rs b/crates/engine/src/cmd/file_import.rs index ee016fa9..399b4f5d 100644 --- a/crates/engine/src/cmd/file_import.rs +++ b/crates/engine/src/cmd/file_import.rs @@ -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 { + 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, 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 = Vec::with_capacity(plan.slots.len()); let mut first_occ: Vec> = 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))?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index b990ace9..8e188328 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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; diff --git a/crates/engine/src/tests/gltf_import.rs b/crates/engine/src/tests/gltf_import.rs new file mode 100644 index 00000000..16c3ecc4 --- /dev/null +++ b/crates/engine/src/tests/gltf_import.rs @@ -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, + 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 = 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 { + 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); +} diff --git a/crates/io/Cargo.toml b/crates/io/Cargo.toml index 3fbcadc3..fc9a7030 100644 --- a/crates/io/Cargo.toml +++ b/crates/io/Cargo.toml @@ -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 } diff --git a/crates/io/src/gltf_in.rs b/crates/io/src/gltf_in.rs new file mode 100644 index 00000000..bc0af454 --- /dev/null +++ b/crates/io/src/gltf_in.rs @@ -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, + 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, + pub places: Vec, + pub bodies: Vec>, +} + +/// 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>, +} + +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> = 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, 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 { + 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 { + 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 = 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 { + 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, 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, 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 { + 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> { + 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, + looks: Vec>, + plan: MeshPlan, + /// Instance keys of the components made, to their slots. + keys: HashMap, + has_mesh: HashMap, + /// Each component's mesh's name. + slot_meshes: Vec>, + /// The components being filled (the current node's ancestors). + open: Vec, + placements: usize, + vertices: usize, + triangles: usize, + /// What was left out, and why. + skipped: Vec, + cut_off: bool, + stem: String, +} + +impl<'d> Reader<'d, '_, '_> { + fn node(&self, index: usize) -> Option> { + 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 = 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, 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 = 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, 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 = 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 = 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, 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, Vec, 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, 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 = raw.as_chunks::<3>().0.iter().map(|c| Vec3::new(c[0], c[1], c[2])).collect(); + let n = positions.len(); + let indices: Vec = 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 = 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, 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 { + 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; diff --git a/crates/io/src/gltf_in_tests.rs b/crates/io/src/gltf_in_tests.rs new file mode 100644 index 00000000..2d6f5852 --- /dev/null +++ b/crates/io/src/gltf_in_tests.rs @@ -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, + pub views: Vec, + pub accessors: Vec, + pub meshes: Vec, + pub nodes: Vec, + pub materials: Vec, + pub roots: Vec, +} + +/// A cube of side `s` (metres) with its corner at the origin, outward facing. +pub(crate) fn cube(s: f32) -> (Vec<[f32; 3]>, Vec) { + 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 { + let mut out = Vec::new(); + for (positions, indices, material) in prims { + let bytes: Vec = 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::>(); + let hi = (0..3).map(|k| positions.iter().map(|p| p[k]).fold(f32::NEG_INFINITY, f32::max)).collect::>(); + 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 = 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 { + glb_container(self.json(None).to_string().as_bytes(), &self.bin) + } +} + +fn no_files(name: &str) -> std::result::Result, String> { + Err(format!("no file `{name}`")) +} + +fn read(bytes: &[u8]) -> Result { + 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 = 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::>(), ["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)> = 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()); + } +} diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 5b28962e..8d1db8e9 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -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, } -/// 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 { + 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))); } diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index fafbb136..6330899d 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -250,13 +250,15 @@ pub fn tool_definitions() -> Vec { "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"], diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index b9eb20f9..46106d4d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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); diff --git a/docs/book/src/guide/files.md b/docs/book/src/guide/files.md index 2338ad0c..990719fe 100644 --- a/docs/book/src/guide/files.md +++ b/docs/book/src/guide/files.md @@ -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