From 34fe556fae95fe8872575e88dc96207eef04035d Mon Sep 17 00:00:00 2001 From: Orual Date: Mon, 22 Jun 2026 12:09:43 -0400 Subject: [PATCH] PM-23: OBJ/glTF direct-upload ingestion + remote-ready parser framework --- Cargo.lock | 115 +++++++++-- Cargo.toml | 2 +- public/models/box.bin | Bin 0 -> 648 bytes public/models/box.glb | Bin 0 -> 1664 bytes public/models/box.gltf | 1 + public/models/box_embedded.gltf | 1 + public/models/box_textured.bin | Bin 0 -> 4592 bytes public/models/box_textured.glb | Bin 0 -> 5956 bytes public/models/box_textured.gltf | 1 + public/models/box_textured.png | Bin 0 -> 3750 bytes public/models/box_translated.glb | Bin 0 -> 1740 bytes public/models/cube.obj | 18 ++ public/models/gen-derived-fixtures.py | 110 +++++++++++ src/mesh/companions.rs | 180 +++++++++++++++++ src/mesh/contract.rs | 133 +++++++++++-- src/mesh/gltf.rs | 240 +++++++++++++++++++++++ src/mesh/mod.rs | 267 +++++++++++++++++++++++++- src/mesh/obj.rs | 102 ++++++++++ src/mesh/parser.rs | 255 ++++++++++++++++++++++++ src/mesh/stl.rs | 40 +--- src/viewer.rs | 227 +++++++++++++++------- 21 files changed, 1548 insertions(+), 144 deletions(-) create mode 100644 public/models/box.bin create mode 100644 public/models/box.glb create mode 100644 public/models/box.gltf create mode 100644 public/models/box_embedded.gltf create mode 100644 public/models/box_textured.bin create mode 100644 public/models/box_textured.glb create mode 100644 public/models/box_textured.gltf create mode 100644 public/models/box_textured.png create mode 100644 public/models/box_translated.glb create mode 100644 public/models/cube.obj create mode 100644 public/models/gen-derived-fixtures.py create mode 100644 src/mesh/companions.rs create mode 100644 src/mesh/gltf.rs create mode 100644 src/mesh/obj.rs create mode 100644 src/mesh/parser.rs diff --git a/Cargo.lock b/Cargo.lock index 6611c2e..857be8c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -380,7 +380,7 @@ checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", "axum-macros", - "base64", + "base64 0.22.1", "bytes", "form_urlencoded", "futures-util", @@ -471,7 +471,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08bf5c1e7d60af9632c55c23bbfa208d0fe08aa59fcda87216c9119e30a9d6d4" dependencies = [ "axum-core", - "base64", + "base64 0.22.1", "bytes", "futures-util", "http", @@ -540,6 +540,12 @@ version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240e56f4d3c453c36faacb695c535a4d5f8c7d23dac175014f32eb0a71012a03" +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + [[package]] name = "base64" version = "0.22.1" @@ -843,7 +849,7 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1f927b07c74ba84c7e5fe4db2baeb3e996ab2688992e39ac68ce3220a677c7e" dependencies = [ - "base64", + "base64 0.22.1", "encoding_rs", ] @@ -1146,7 +1152,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" dependencies = [ "aes-gcm", - "base64", + "base64 0.22.1", "percent-encoding", "rand 0.8.6", "subtle", @@ -1564,6 +1570,12 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + [[package]] name = "der" version = "0.7.10" @@ -1807,7 +1819,7 @@ checksum = "662cd78c73ca3f17346adbf2d64757df40dd0ce20536c05123097fd31828d2bd" dependencies = [ "anyhow", "async-trait", - "base64", + "base64 0.22.1", "bytes", "cocoa", "core-foundation 0.10.1", @@ -1918,7 +1930,7 @@ dependencies = [ "axum", "axum-core", "axum-extra", - "base64", + "base64 0.22.1", "bytes", "ciborium", "const-str 0.7.1", @@ -1979,7 +1991,7 @@ checksum = "cc634b28b4b1e3eab1e8df4f98510e2d2fa39d686321467f977213155e86ed2b" dependencies = [ "anyhow", "axum-core", - "base64", + "base64 0.22.1", "ciborium", "dioxus-core", "dioxus-document", @@ -2196,7 +2208,7 @@ dependencies = [ "anyhow", "async-trait", "axum", - "base64", + "base64 0.22.1", "bytes", "chrono", "ciborium", @@ -3360,6 +3372,45 @@ dependencies = [ "web-sys", ] +[[package]] +name = "gltf" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ce1918195723ce6ac74e80542c5a96a40c2b26162c1957a5cd70799b8cacf7" +dependencies = [ + "base64 0.13.1", + "byteorder", + "gltf-json", + "image", + "lazy_static", + "serde_json", + "urlencoding", +] + +[[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.118", +] + +[[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 = "gobject-sys" version = "0.18.0" @@ -3523,7 +3574,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "headers-core", "http", @@ -3846,7 +3897,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -4064,6 +4115,12 @@ dependencies = [ "cfb", ] +[[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" @@ -4238,7 +4295,7 @@ version = "0.12.1" dependencies = [ "axum", "axum-extra", - "base64", + "base64 0.22.1", "bytes", "chrono", "jacquard", @@ -4263,7 +4320,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9631f08f1e65d19e204bc6774d00b4e0b69fb649d7d7ac59ccf97797bd9a196e" dependencies = [ - "base64", + "base64 0.22.1", "bon", "bytes", "chrono", @@ -4316,7 +4373,7 @@ dependencies = [ name = "jacquard-common" version = "0.12.1" dependencies = [ - "base64", + "base64 0.22.1", "bon", "bytes", "chrono", @@ -4489,7 +4546,7 @@ dependencies = [ name = "jacquard-oauth" version = "0.12.1" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "chrono", "dashmap", @@ -6205,7 +6262,7 @@ dependencies = [ "anyhow", "axum", "axum-extra", - "base64", + "base64 0.22.1", "chrono", "console_error_panic_hook", "dioxus", @@ -6853,7 +6910,7 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "cookie", "cookie_store", @@ -7372,7 +7429,7 @@ version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" dependencies = [ - "base64", + "base64 0.22.1", "bs58", "chrono", "hex", @@ -7697,7 +7754,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "cfg-if", "crc", @@ -7795,7 +7852,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" dependencies = [ "atoi", - "base64", + "base64 0.22.1", "bitflags 2.13.0", "byteorder", "crc", @@ -8214,8 +8271,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd4e4e9bc05e4010efbaa0a0296870b2d28102171deffbf69de7b305cff2d47a" dependencies = [ "cgmath", + "data-url", + "gltf", "half", + "image", "thiserror 2.0.18", + "wavefront_obj", "web-sys", ] @@ -8419,7 +8480,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d52efb639344a7c6adb8e62c6f3d2c19c001ff1b79a5041ba1c6ed42e19c6aa5" dependencies = [ "aws-lc-rs", - "base64", + "base64 0.22.1", "bytes", "futures-core", "futures-sink", @@ -8892,6 +8953,12 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf-8" version = "0.7.6" @@ -9085,6 +9152,12 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wavefront_obj" +version = "11.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "934504ea79927c25f18e980053b64269490284129b327334fc2591daf1539b9c" + [[package]] name = "web-sys" version = "0.3.98" @@ -9715,7 +9788,7 @@ version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "728b7d4c8ec8d81cab295e0b5b8a4c263c0d41a785fb8f8c4df284e5411140a2" dependencies = [ - "base64", + "base64 0.22.1", "block2", "cookie", "crossbeam-channel", diff --git a/Cargo.toml b/Cargo.toml index 1fe68db..d7b1fa9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -71,7 +71,7 @@ serde = { workspace = true } serde_json = "1.0" thiserror = { workspace = true } tracing = { version = "0.1", default-features = false, features = ["std"] } -three-d-asset = { version = "0.10", default-features = false } +three-d-asset = { version = "0.10", default-features = false, features = ["gltf", "obj", "data-url", "png"] } stl_io = "0.11" axum = { version = "0.8", optional = true, features = ["multipart"] } diff --git a/public/models/box.bin b/public/models/box.bin new file mode 100644 index 0000000000000000000000000000000000000000..d7798abb5161eca17ac8a6883a1f53c6786b484d GIT binary patch literal 648 zcmZO7g9dvzlYtPsVLy^u0&GHNflNYHgU&~m!^S4XEg-XDm}q?<|A8$j*dZcATbyXV}tl04C8}n5C(}MV{Bp| zd6+nihKWJ>j0{W+%nVEnj0`LctPE@n>Jv~1F literal 0 HcmV?d00001 diff --git a/public/models/box.glb b/public/models/box.glb new file mode 100644 index 0000000000000000000000000000000000000000..95ec886b6b92b134291fd41d34ac9d5349306e0a GIT binary patch literal 1664 zcmYb!32|d$U|?uqV_>+$%)sCk?C)2tlvrGxTB2lCt(2acms*rql3%1`rR41IOT_l2x=}td3G{Vo6bE1&E_(pksgq>lz{nBD0Yd8|oRv*6LI%<)#*A zC|MbRYy>gE4yaZtD9X&uEXgc`auZ8RiZYW*OF-OeB|rZlUq>G$D?=Tn0RLdm5Kn(U zB`c#^9i_~?l+0w18Ur1r-29YOB`XuKb5n~l6LX-Jxo(Z?}J3FKvDOAK|C2wGwWx5NnSACM)W zV1;71ZQyW+69}urT%C;xS!`rs0kQ?%f7q-x&@4+>ZJ=ji00xGJmPQ7aMwajt z2TDLXO1V&DU>Xec3~F_hpphJunxX`XV?=;MtcUP1V%XH&3=$hYsd?!o8A?{>MmkC* ziACw4{AFxnX=aRwJq*nTs9ND>1(y_Mf>NR(nps8&hd~`xS&|B~*v!PDHnz5diGjh% z(~kiG8tjQ@H|$4JOMp#^xiC{n(L;(`VCI8pqV<9N2g0E60bv*$MuWuAF-#6bgD^}U z#74(}!Q@~xNFK%p(J(%UhGCF6ItIys#9%ax4dR0^j1Qtg7$k;_v5A4?Vd5|vCI;m* zGB7bPGcYkQGO#ePGO#hQGq5qRGH@_(GH@|)GjK6*GVn0)GVn3*Gw?C+G6*mTG6*pU WGYByVGKesUGKevVGl(&WG5`Sf3~c!T literal 0 HcmV?d00001 diff --git a/public/models/box.gltf b/public/models/box.gltf new file mode 100644 index 0000000..43f2b29 --- /dev/null +++ b/public/models/box.gltf @@ -0,0 +1 @@ +{"asset":{"generator":"COLLADA2GLTF","version":"2.0"},"scene":0,"scenes":[{"nodes":[0]}],"nodes":[{"children":[1],"matrix":[1.0,0.0,0.0,0.0,0.0,0.0,-1.0,0.0,0.0,1.0,0.0,0.0,0.0,0.0,0.0,1.0]},{"mesh":0}],"meshes":[{"primitives":[{"attributes":{"NORMAL":1,"POSITION":2},"indices":0,"mode":4,"material":0}],"name":"Mesh"}],"accessors":[{"bufferView":0,"byteOffset":0,"componentType":5123,"count":36,"max":[23],"min":[0],"type":"SCALAR"},{"bufferView":1,"byteOffset":0,"componentType":5126,"count":24,"max":[1.0,1.0,1.0],"min":[-1.0,-1.0,-1.0],"type":"VEC3"},{"bufferView":1,"byteOffset":288,"componentType":5126,"count":24,"max":[0.5,0.5,0.5],"min":[-0.5,-0.5,-0.5],"type":"VEC3"}],"materials":[{"pbrMetallicRoughness":{"baseColorFactor":[0.800000011920929,0.0,0.0,1.0],"metallicFactor":0.0},"name":"Red"}],"bufferViews":[{"buffer":0,"byteOffset":576,"byteLength":72,"target":34963},{"buffer":0,"byteOffset":0,"byteLength":576,"byteStride":12,"target":34962}],"buffers":[{"byteLength":648,"uri":"box.bin"}]} \ No newline at end of file diff --git a/public/models/box_embedded.gltf b/public/models/box_embedded.gltf new file mode 100644 index 0000000..ccec34b --- /dev/null +++ b/public/models/box_embedded.gltf @@ -0,0 +1 @@ +{"asset":{"generator":"COLLADA2GLTF","version":"2.0"},"scene":0,"scenes":[{"nodes":[0]}],"nodes":[{"children":[1],"matrix":[1.0,0.0,0.0,0.0,0.0,0.0,-1.0,0.0,0.0,1.0,0.0,0.0,0.0,0.0,0.0,1.0]},{"mesh":0}],"meshes":[{"primitives":[{"attributes":{"NORMAL":1,"POSITION":2},"indices":0,"mode":4,"material":0}],"name":"Mesh"}],"accessors":[{"bufferView":0,"byteOffset":0,"componentType":5123,"count":36,"max":[23],"min":[0],"type":"SCALAR"},{"bufferView":1,"byteOffset":0,"componentType":5126,"count":24,"max":[1.0,1.0,1.0],"min":[-1.0,-1.0,-1.0],"type":"VEC3"},{"bufferView":1,"byteOffset":288,"componentType":5126,"count":24,"max":[0.5,0.5,0.5],"min":[-0.5,-0.5,-0.5],"type":"VEC3"}],"materials":[{"pbrMetallicRoughness":{"baseColorFactor":[0.800000011920929,0.0,0.0,1.0],"metallicFactor":0.0},"name":"Red"}],"bufferViews":[{"buffer":0,"byteOffset":576,"byteLength":72,"target":34963},{"buffer":0,"byteOffset":0,"byteLength":576,"byteStride":12,"target":34962}],"buffers":[{"byteLength":648,"uri":"data:application/octet-stream;base64,AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAvwAAAL8AAAA/AAAAPwAAAL8AAAA/AAAAvwAAAD8AAAA/AAAAPwAAAD8AAAA/AAAAPwAAAL8AAAA/AAAAvwAAAL8AAAA/AAAAPwAAAL8AAAC/AAAAvwAAAL8AAAC/AAAAPwAAAD8AAAA/AAAAPwAAAL8AAAA/AAAAPwAAAD8AAAC/AAAAPwAAAL8AAAC/AAAAvwAAAD8AAAA/AAAAPwAAAD8AAAA/AAAAvwAAAD8AAAC/AAAAPwAAAD8AAAC/AAAAvwAAAL8AAAA/AAAAvwAAAD8AAAA/AAAAvwAAAL8AAAC/AAAAvwAAAD8AAAC/AAAAvwAAAL8AAAC/AAAAvwAAAD8AAAC/AAAAPwAAAL8AAAC/AAAAPwAAAD8AAAC/AAABAAIAAwACAAEABAAFAAYABwAGAAUACAAJAAoACwAKAAkADAANAA4ADwAOAA0AEAARABIAEwASABEAFAAVABYAFwAWABUA"}]} \ No newline at end of file diff --git a/public/models/box_textured.bin b/public/models/box_textured.bin new file mode 100644 index 0000000000000000000000000000000000000000..fabe166a56d183dfd5d9e51a3206bfa9cc3c1ef5 GIT binary patch literal 4592 zcmZO7g9dvzlK~$apB&5tWHlf$WIj>s{UBWs+(3X$NFHPgvKkN{gptMYvO)HPFerRL z7{-RtATfLxT^)=MqG1@DevlkU97ZFvVPYT}gh6s33}WNLFnJIS!szO-iGkF>#9=f{ z49Y*?018b8h6PaifWyE4_4Xh>gl>R}!{kAHP#OU7K{NwY9?S+A<-h<=e;_^xJHX_j zG>8uphnWN7gS5fS0r5fdAU>D|OM(cfdqD1H0NDZIGcqtSFf%YQFfy<(urjbQurshR zurhEka58W)a5Hc*a5C^P@G|f*@H6l+@G=N62r>vU2r~#V2r`H;h%$&Vh%<;Wh%$5r z___0PNpUeSFz|YMxCAjUFfxEJ2Qvc$L-v}85C#T@gaDrq*E@Ia?7RBn%B_2A&pexc z=xNc$N11Ex=d8V-u=0NWwny!|9zT5d^Z)<K%> zPxnmjHHd0cjA>U7Y+~(Rp1t?f`enN&p1E@P!TpCf?p(fq@9v^G?^ZD|@G*M2IEGZ* zdULIP=MxJa=7dg0g_|wE-hWbyy(MEa?aQ@#_J*$qbDr;v(VKqyX_4gp59}`azW=T+ z{jL}L@2Xu1Z_)M8vum{evYFRS4ozKl>*=~$hsB=Zn@j90{xd7QEV);CuX}=--G$Bh zi?XJ^e5dlxqbZ)D;mhnb>D=bC+w1rJC~|+$cfVD=>aO<-qr7c@KYjmll7r!oCxgpuaCuC&jJ8-Zn|TgT!E2+%j2C$d7&}5{?yURVI(tst z?>O#A41Qck94}M`f$}AAgcQaQ8rp%+z?owD=4=JuuOS|^f^Rf>1CactF>SzgWJAU|*Fb%O@-w_@&{ zd@CGzV5L#QqKo(EpAO%|-y2{g#i@~yBUik$Mt!;4WPwYc&o!KQ`f2i=S5rRRjXL<` zxD|t{X0b|8cDjviIzPvzm}eVueOO<-<>oyUAh1+NQShw#&6f;`8Y#t*O3_@o3$iVY zVptV5PfKK7_o~WD;7VQ?Gi_qp=@o}r=NwPvXRz2WXEO0nD8It*dCNQ=GF;!y^kN3j zmRAfMixmTpFw6}K(%;DD)plAaN26py)GEcthbvC3y~0>)%#}aSm!om|rk0Nuks2$1 z*C!=tp6ILkda0^Hv~8VI(R9nZw!a=8otP!l>fNC7D{RGHG6_!lIg{~MY1M&i z&CLe8E`7?@o1b!Zp5D%vGnivMxLHnkJ`3Mu(WJLbrl>Jnm$^YBN?GZco03NIDMQ04 zhmL+Y`1?V#l#6iycVI)f+!mX%TGN=xq2CTaFnRFXJ@35l=I0CQ|70y&`A+Z1-!y)X z!tV@!?j92R%l_}NP~wz0!)k8Ds%%E3Pv@eNk3_8g=W43^fA-|Kvxa3>(Y%a-C2mFM z{@7cl8~Vi_eo_?p@L>Ifq^*3$(&Bk!Cz>*>R{=FBA1?L;` zIGd#M9u4vT_;*jbK z`>^qKDX-%J)-Ugh%_jO!ohQ&@&96{eYagW7`@}f7aeMVskKEf`7t|Xhb?Q8rD%P{U zmW_93+_vTLa@Ir2|0-6O*U0S2A3XVOhH^r;zwS+UzP9rHANi8xl^qNvs)vN*c-HP-nsV~aUM-U^ zN0jVU7_7=Z7rdGOu|!hPVRp%q9ZwJI{N?|wcf9*Sg(%1M#e8d*v)=n6sq)B)`@sB< zf1$PGW1=P>U*!4Rx@2lnVCn-|Ox@QUfT+V<_`_ZOcR|xp>rU$8vvz^(H9~bo}cQ zChhh=ym6aV6T{9nCIhp1Of&i)#+~@7Xg5iN&3`_p#4%-#icZ!Gx~?DeQW_>R-kZpG zut4YP_;Kkib!5jhyyJj1l z+5J*daizzclf8YfHY#!?h=_AMV0yylQr5O0!@2LG64MW3@vs<$md77&HpCpN(#hWL z5_%xF^p%8mUbBdR3{PiH!wUBTmL<~}*6)a2vV)QN=*F820t~EQJp>%I@}5o$I}rVV zZQD8fb?#?%=k0&1=%Cl~$Zf+7-WP7>4X-wOPU~CG@P^@7`|_xc#x}+IWm_KYas^?v$d}9Kq@|NgA!!zwoMa+AcU)Tt;G+b6#xZ_3KFo zh&6n2S6FbkbO(oqYfE=ew0Ju(ijJKMJYy_fT#NoHl_uW1c7533p1>?mPt zNW6B$s6bfJsdAf~qgGFTTg6e;I}Q~(Tz42Adu%O_4z6ox(Cx~ew(TH$uSA3Q@Kx0|qlSn=&KbvV z9ti64Nsfz)?7zlSx#$so3K6Q1m z`Bfi=v>tWQ69-|J_QDg(y>d*-khxfOF~vFw-2)ohRuj$w8voUhL;5&HPc&Sb@gcxEdG z_75rM46Ftjt!jEl8Ptw4oe+~b@36FoLG7`>Y{jz*UYXg9g@%T1&x}1~I+7)i)Uuzy z#Z~h4k8q;`f7^u}|N7_K7i8^WRM2}|7uo2vSop!bXoqz^(sr44So{Csm-F>YukL|vR+~tawvD zz1$~nePPVUv(`_`zeu{Ad>m)Df6vvh2n!Y+AI|H&D9`k+62qf3 zF8zY+a*u{fDt!8eGxH2MC7Lbw9FzXU`>lbG`%a$y3D*audadpA8ThhUmRt;HXcxUA z#c#(b5*8@=Bdx)RYfWmC%VRmFvx&OdCm!E#T9at_aR%=OjvU9-Gj|#MuO

wI09E zxMrc~%QW7CpC>DJFwD}(IkvXyez<6kM(060nJ1r)Gd)^4>u}r_b%UD!(T?l)N(J59S@*%R zu_iL}EbomU_r(QW3vbM4eWLAlU?r!_p{$*8Oh+V+JQR2(Bpko~@9aZ?Axc8AS>JH8lra?hE>mjz1uXGPT4+5 z=vdaf2L@cmHS8`W*|~-JTX9M|2!ta@15~W&G$XrvCETJSLu9**i^g2mz+cy zLnWrHUgepw(Pzf9MH%1T-(7coUV&EOy0}o;W6##T3aaMlD9&C|KPA|!WA6{Gd)@na z+1uru4}MwwXF^@$4R7-u+P`OBk#fq`JrW&C@^Jn}C#y@+{9P8 lxo7{?MgDz*0^xgE8$L{l`7Fx7z`)??>gTe~DWQph0RXemM7sb0 literal 0 HcmV?d00001 diff --git a/public/models/box_textured.glb b/public/models/box_textured.glb new file mode 100644 index 0000000000000000000000000000000000000000..cfd92e78f50ecf81f4ae8a9663638b88513ab025 GIT binary patch literal 5956 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

u`8AK^v?{SvG9X=(GKf;rcYLM>%VT_c2kJ8 zz-YzJyp0d!*0p^7vG)JNFZGd(+>AZ4dcS2kIrzV?)8BvRAKQHsx%JGf4DN?mqn_Qe z{i||j?>Sbhe^WyrZ_6z_*sO4mGqm`5-pl2N!3%y>>i)aBuXfqab%zodt}&gnG-gv| zS@EWRdbv;D`ofrxXRV)>f01-K`8dvQ|DLO15f&^uKAQ7Ye9!)ya-jXllrR}{*?CLk z<1ssvFg{Y^lMc@8Y+Jcs zE5}cjuSjdbO072s+%3hIuG+vY{czD7jn0F1GEY7oXL_`9*5SA<>IOCcqaD}pl?uAI zv+jdsV@+h{S>78z?u!e$7T%c8`b699z)DV;Ls>iHn2tysc_{EoNH~7|-`R%(LzIMK zv%Uwr$o4J1W)Sbu^{7B+weaQAW&004^oiQ#!g3~%r|)^zLW9dc`}cQR&vAVCCC+1x zmqF|19a}Z$N-!=x{@8w^_nGG})AvuFdG(M_x@mp$=AE-yZ0wlQa+2N^*oCitdxN7x zGTF3rN83Inx$jMqf{B@*H2wz(F7a`{0@(d{&5-uh8>2eW5TWSPtz6H z#FwgV^|fvO8x^1bH~i_UuxOXGMQ`JkeV4KZmhLbKpBg4$oI9Pd)-z((46B|4d$(yW zow9wB(6Ov{4-B}BZ!VJ#nEx{=%|N8wcw&Ii{&`G--#g=%n(uqKW0xnduG0Aqv8i^4 zFFA=ahDuCXy~;CVqtA?Ii!#2wzq{`GyaKJlb#bAx$DXZw6;#d9QJlS`eoC-c$KD@W z_qzA8^I4REfq}u()z4*}Q$iB}kDKTY literal 0 HcmV?d00001 diff --git a/public/models/box_translated.glb b/public/models/box_translated.glb new file mode 100644 index 0000000000000000000000000000000000000000..340bfbc7f2beb40263b594d6875e10ad7d1272f2 GIT binary patch literal 1740 zcmYb!32|d$U|=}I#=xM#!oc7a?C)2tlvrGxTB2lCt(2acms*rql3%1`rR41IOT_l2x=}td3G{Vo6bE1&E_(pksgq>lz{nqOg%w8|oRv*6LI%<)#*A zC|MbRtOPN^E~r*2D9X&uEXgc`auZ8RiZYW*OF-OeB|rZlUq>G$D?=Tn0RLdm5Kn(U zB`c#^9i_~?l+0w18Ur1r-29YOB`XuKdsB-t6LUb8=u|6F%P3F~CFZ6oStYtVdjw}NmrR4nFg8aPHypoX0f>b3d zQ$r(TkYH(EiISDE8OYV3AT}}v1$ZVX=nZ0ZluE#Am4cleeH??7K%s|hiJ^`XK}*cw zmKcG924o2+il7*78#oT(1j6btS7&2F78_YufNVkcA2zEE^h|Z27-2O?Ko>@!TMbf{ zSOShvNH7;975S!?BJV+!5rKTu>;usO&5bGg)j2JdGH-p56PikIz zNrsY@xsi@iNn%ktD6bluSeh9lVh=;J0jgHGS-~YmnV>{%h-Q`%!eLNHRhFc}EH*Q- zsEw`dU}9i!^7LasfChWw*$w-V)DmEmVlK>7QuL7G7MS@UnrM9>|A8$j*dZcATbyXV}tl04C8}n5C(}MV{Bp| zd6+nihKWJ>j0{W+%nVEnj0`LctPE@n> box.bin) +# https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Assets/main/Models/BoxTextured/glTF-Binary/BoxTextured.glb +# +# ── Derived fixtures this script (re)generates ──────────────────────────────── +# box_translated.glb Box + node x-translation(10) composed into the existing +# rotation matrix, AND its single primitive duplicated -> +# 2 primitives. Covers AC.3a: non-identity, nonzero- +# determinant transform (translation makes baking +# observable via the AABB x-shift) + multi-primitive +# flattening with per-primitive index offsets. +# box_embedded.gltf box.gltf with its external buffer inlined as a base64 +# data URL. Covers AC.3b (embedded data-URL buffer). +# box_textured.gltf box_textured.glb decomposed into separate companions: +# + box_textured.bin the geometry buffer (external .bin) +# + box_textured.png the embedded image extracted to an external PNG +# Covers AC.3d (external texture companion, PNG decoded +# then discarded) and multi-companion gathering. +# +# Re-run anytime with: python3 public/models/gen-derived-fixtures.py + +import base64 +import json +import struct + + +def read_glb(path): + data = open(path, "rb").read() + assert data[:4] == b"glTF", path + total = struct.unpack(" Vec { + let Ok(Value::Object(root)) = serde_json::from_slice::(primary) else { + return Vec::new(); + }; + let mut uris = Vec::new(); + for key in ["buffers", "images"] { + if let Some(Value::Array(items)) = root.get(key) { + for item in items { + if let Some(Value::String(uri)) = item.get("uri") + && !uri.starts_with("data:") + { + uris.push(uri.clone()); + } + } + } + } + uris +} + +/// A companion URI is fetchable only if it is safe and relative: no scheme, no +/// backslash, not an absolute or protocol-relative path, and its normalized path does +/// not escape the asset's directory via `..`. +/// +/// Validation runs on a **percent-decoded** copy so encoded traversal (`%2e%2e`, +/// `..%2f`, `%5c`) cannot bypass it; any residual `%` after one decode (double encoding +/// like `%252e`) or invalid encoding is rejected. Only the decoded form is validated; +/// callers keep the original raw URI as the companion key. +pub(crate) fn is_safe_relative_uri(uri: &str) -> bool { + // No literal backslash (a path separator on some platforms / URL contexts and a + // common traversal obfuscation). + if uri.contains('\\') { + return false; + } + // Decode percent-encoding once. Invalid or non-UTF-8 -> reject. + let Some(decoded) = percent_decode_once(uri) else { + return false; + }; + // Reject anything still percent-encoded after one decode (double encoding such as + // `%252e`, which decodes to `%2e` then to `.`). + if decoded.contains('%') { + return false; + } + // No scheme (a `:` anywhere after decoding), no absolute or protocol-relative path. + if decoded.contains('\\') || decoded.contains(':') || decoded.starts_with('/') { + return false; + } + // No traversal that escapes the base directory: track path depth — each real + // segment descends, ".." ascends; dropping below 0 escapes the asset's directory. + let mut depth = 0isize; + for segment in decoded.split('/') { + match segment { + "" | "." => {} + ".." => { + depth -= 1; + if depth < 0 { + return false; + } + } + _ => depth += 1, + } + } + true +} + +/// Percent-decode `%XX` sequences once. Returns `None` on a truncated or invalid +/// escape. The result is a `String`, so non-UTF-8 decodings are rejected too. +fn percent_decode_once(input: &str) -> Option { + let bytes = input.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' { + let h = hex_digit(bytes.get(i + 1).copied())?; + let l = hex_digit(bytes.get(i + 2).copied())?; + out.push((h << 4) | l); + i += 3; + } else { + out.push(bytes[i]); + i += 1; + } + } + String::from_utf8(out).ok() +} + +fn hex_digit(b: Option) -> Option { + let b = b?; + match b { + b'0'..=b'9' => Some(b - b'0'), + b'a'..=b'f' => Some(b - b'a' + 10), + b'A'..=b'F' => Some(b - b'A' + 10), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn discovers_buffer_and_image_uris_skipping_data_urls() { + let gltf = br#"{ + "asset": {"version": "2.0"}, + "buffers": [ + {"uri": "cube.bin"}, + {"uri": "data:application/octet-stream;base64,AAAA"} + ], + "images": [{"uri": "tex.png"}, {"bufferView": 0}] + }"#; + let mut uris = discover_companion_uris(gltf); + uris.sort(); + assert_eq!(uris, vec!["cube.bin".to_string(), "tex.png".to_string()]); + } + + #[test] + fn discovers_no_uris_from_non_json() { + // A GLB is binary; JSON parse fails -> no companions (they are embedded). + assert!(discover_companion_uris(b"glTF\x00\x00\x00\x00").is_empty()); + assert!(discover_companion_uris(b"not json at all").is_empty()); + } + + #[test] + fn accepts_safe_relative_uris() { + assert!(is_safe_relative_uri("cube.bin")); + assert!(is_safe_relative_uri("textures/foo.png")); + assert!(is_safe_relative_uri("a/b/c.bin")); + assert!(is_safe_relative_uri("./cube.bin")); + assert!(is_safe_relative_uri("a/../b.bin")); // net in-directory + // A legitimate percent-encoded filename (space) is fine. + assert!(is_safe_relative_uri("my%20file.bin")); + } + + #[test] + fn rejects_absolute_scheme_protocol_relative_and_traversal_uris() { + // Absolute / arbitrary scheme. + assert!(!is_safe_relative_uri("https://evil.example/x.bin")); + assert!(!is_safe_relative_uri("http://evil.example/x.bin")); + assert!(!is_safe_relative_uri("file:///etc/passwd")); + assert!(!is_safe_relative_uri("blob:abc")); + // Absolute path and protocol-relative. + assert!(!is_safe_relative_uri("/etc/passwd")); + assert!(!is_safe_relative_uri("//evil.example/x.bin")); + // Literal traversal escaping the base directory. + assert!(!is_safe_relative_uri("../escape.bin")); + assert!(!is_safe_relative_uri("a/../../escape.bin")); + } + + #[test] + fn rejects_encoded_and_backslash_traversal() { + // Percent-encoded dots/slashes. + assert!(!is_safe_relative_uri("%2e%2e/escape.bin")); + assert!(!is_safe_relative_uri("..%2fescape.bin")); + assert!(!is_safe_relative_uri("%2e%2e%2fescape.bin")); + assert!(!is_safe_relative_uri("textures/%2e%2e/%2e%2e/escape.bin")); + // Double-encoded traversal (%252e -> %2e -> .). + assert!(!is_safe_relative_uri("%252e%252e/escape.bin")); + // Backslash as separator / obfuscation. + assert!(!is_safe_relative_uri("..\\escape.bin")); + assert!(!is_safe_relative_uri("textures\\..\\escape.bin")); + assert!(!is_safe_relative_uri("%5c%5e..")); // encoded backslash + // Truncated/invalid percent-encoding. + assert!(!is_safe_relative_uri("ab%2")); + assert!(!is_safe_relative_uri("ab%zz")); + } +} diff --git a/src/mesh/contract.rs b/src/mesh/contract.rs index 6e18798..51da3e5 100644 --- a/src/mesh/contract.rs +++ b/src/mesh/contract.rs @@ -6,13 +6,47 @@ pub use three_d_asset::{AxisAlignedBoundingBox, TriMesh}; /// Source format of a loaded mesh. /// -/// STL today; OBJ/glTF/3MF direct upload is reserved for PM-23. There is intentionally -/// no binary/ASCII sub-label for STL — reliably telling them apart is the hard part, and +/// STL, OBJ, and glTF (GLB, embedded, or multi-file `.gltf` with external buffers) are +/// parsed client-side from in-memory bytes. `Threemf` is **reserved**: 3MF cannot +/// compile to `wasm32`, so it is served by a remote (server-side) parser that returns +/// GLB — see the `RemoteMeshParser` drop-in contract (PM-49). There is intentionally no +/// binary/ASCII sub-label for STL — reliably telling them apart is the hard part, and /// both yield the same mesh. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MeshFormat { /// Stereolithography (binary or ASCII). Stl, + /// Wavefront OBJ (convex polygons fan-triangulated; lines/points dropped). + Obj, + /// glTF 2.0 — GLB, embedded-base64 `.gltf`, or multi-file `.gltf` + companions. + Gltf, + /// 3D Manufacturing Format. Not parsed client-side; routed to a remote parser + /// (PM-49). Kept here so format detection and the parser seam are remote-ready. + Threemf, +} + +impl MeshFormat { + /// Detect a mesh format from a filename extension. + /// + /// The extension is taken from the final `.`-delimited segment, lower-cased, and + /// matched with no leading dot. Returns `None` for empty or unknown extensions, + /// which the viewer surfaces as [`crate::viewer::ViewerIssue::MeshUnavailable`]. + pub fn from_extension(path: impl AsRef) -> Option { + let path = path.as_ref(); + let ext = path.rsplit('.').next()?.to_ascii_lowercase(); + // `rsplit('.').next()` yields the whole string when there is no dot; only a + // genuine extension suffix (shorter than the input) is accepted. + if ext.len() >= path.len() { + return None; + } + match ext.as_str() { + "stl" => Some(Self::Stl), + "obj" => Some(Self::Obj), + "gltf" | "glb" => Some(Self::Gltf), + "3mf" => Some(Self::Threemf), + _ => None, + } + } } /// A length unit. @@ -33,25 +67,34 @@ pub enum LengthUnit { /// Unit handling for a loaded model. /// -/// `source` is the unit declared by the file; STL declares none, so this is -/// [`LengthUnit::Unknown`]. `assumed` is the convention applied when the source is -/// unknown — millimetres for Polymodel's STL ingestion. +/// `source` is the unit declared by the file; STL/OBJ declare none, so they report +/// [`LengthUnit::Unknown`] and rely on [`Units::unitless_assumed_mm`]. glTF's base unit +/// is the metre, so a loaded glTF reports [`LengthUnit::Meter`] with no assumption. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Units { - /// The unit declared by the file (STL: [`LengthUnit::Unknown`]). + /// The unit declared by the file (STL/OBJ: [`LengthUnit::Unknown`]; glTF: [`LengthUnit::Meter`]). pub source: LengthUnit, /// The convention assumed when `source` is unknown. pub assumed: Option, } impl Units { - /// STL unit handling: source unknown, assume millimetres. - pub fn stl_assumed_mm() -> Self { + /// Unit handling for a unitless format (STL, OBJ): source unknown, assume millimetres. + pub fn unitless_assumed_mm() -> Self { Self { source: LengthUnit::Unknown, assumed: Some(LengthUnit::Millimeter), } } + + /// Unit handling for a format that declares its own unit (e.g. glTF → metres), + /// so no assumption is applied. + pub fn declared(source: LengthUnit) -> Self { + Self { + source, + assumed: None, + } + } } /// Camera framing hint derived from a mesh's axis-aligned bounding box: the centre the @@ -66,8 +109,8 @@ pub struct CameraFit { /// A Polymodel mesh: a [`TriMesh`] plus format provenance and unit handling. /// -/// Normals are geometry-derived (via [`TriMesh::compute_normals`]); STL facet normals are -/// not relied upon. Bounds and camera framing come from [`Self::aabb`]. +/// Normals are geometry-derived (via [`TriMesh::compute_normals`]); source facet +/// normals are not relied upon. Bounds and camera framing come from [`Self::aabb`]. #[derive(Debug)] pub struct ModelMesh { /// The underlying triangle mesh. @@ -106,11 +149,75 @@ pub enum MeshLoadError { /// The bytes could not be parsed as a valid mesh of the expected format. #[error("failed to parse STL: {0}")] Parse(#[from] std::io::Error), - /// The mesh parsed but is degenerate: no triangles, a non-finite position, or a - /// point-sized bounding box. + /// A format parsed by `three-d-asset` (OBJ/glTF) failed to decode, including a + /// missing or undecodable companion resource (buffer/texture). + #[error("mesh decode failed: {0}")] + Decode(String), + /// The mesh parsed but is degenerate: no triangles, an out-of-range index, a + /// non-finite position, or a point-sized bounding box. #[error("mesh is degenerate")] Degenerate, - /// The format is not yet supported (reserved for PM-23). + /// The format is not supported by the active parser. `Threemf` returns this when no + /// remote parser is wired in (PM-49 will supply one). #[error("unsupported mesh format")] UnsupportedFormat, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn from_extension_maps_known_formats_case_insensitively() { + assert_eq!( + MeshFormat::from_extension("cube.stl"), + Some(MeshFormat::Stl) + ); + assert_eq!( + MeshFormat::from_extension("cube.obj"), + Some(MeshFormat::Obj) + ); + assert_eq!( + MeshFormat::from_extension("cube.gltf"), + Some(MeshFormat::Gltf) + ); + assert_eq!( + MeshFormat::from_extension("cube.glb"), + Some(MeshFormat::Gltf) + ); + assert_eq!( + MeshFormat::from_extension("cube.3mf"), + Some(MeshFormat::Threemf) + ); + // Mixed case and leading dots. + assert_eq!( + MeshFormat::from_extension("CUBE.GLB"), + Some(MeshFormat::Gltf) + ); + assert_eq!(MeshFormat::from_extension(".OBJ"), Some(MeshFormat::Obj)); + assert_eq!( + MeshFormat::from_extension("/models/Body_F_Chest-v4.STL"), + Some(MeshFormat::Stl) + ); + } + + #[test] + fn from_extension_handles_dot_and_extension_edge_cases() { + // Trailing/leading dot and dot-only strings have no real extension. + assert_eq!(MeshFormat::from_extension("cube."), None); + assert_eq!(MeshFormat::from_extension("."), None); + // Only the final segment is the extension. + assert_eq!(MeshFormat::from_extension("archive.tar.gz"), None); + assert_eq!( + MeshFormat::from_extension("model.backup.GLB"), + Some(MeshFormat::Gltf) + ); + } + + #[test] + fn from_extension_rejects_unknown_and_extensionless() { + assert_eq!(MeshFormat::from_extension("cube.ply"), None); + assert_eq!(MeshFormat::from_extension("cube"), None); + assert_eq!(MeshFormat::from_extension(""), None); + } +} diff --git a/src/mesh/gltf.rs b/src/mesh/gltf.rs new file mode 100644 index 0000000..5b96af5 --- /dev/null +++ b/src/mesh/gltf.rs @@ -0,0 +1,240 @@ +//! glTF/GLB ingestion: parse in-memory bytes (+ companions) into a [`ModelMesh`] via +//! `three-d-asset`. +//! +//! GLB BIN chunks and embedded-base64 `data:` buffers resolve from the primary bytes +//! alone. A multi-file `.gltf` resolves its external `.bin`/texture companions from the +//! source's [`MeshResources`], inserted under their exact raw URI so `three-d-asset`'s +//! `base_path.join(uri)` lookup is exact (it is not asked to discover dependencies +//! itself). Textures are decoded during deserialize (the `image`/`png` features are on) +//! and then discarded — [`TriMesh`](three_d_asset::TriMesh) carries no materials — but a +//! referenced-but-missing or undecodable texture fails the whole parse. glTF's base unit +//! is the metre. No filesystem or network access, so this is safe from +//! `wasm32-unknown-unknown`. + +use three_d_asset::io; + +use crate::mesh::contract::{LengthUnit, MeshFormat, MeshLoadError, ModelMesh, Units}; +use crate::mesh::parser::MeshSource; + +/// Parse a glTF/GLB asset (plus companions) into a [`ModelMesh`]. +/// +/// The GLB magic (`b"glTF"`) is sniffed to pick the `.glb` vs `.gltf` key. Node world +/// transforms are baked into positions by the shared flattener; geometry normals are +/// derived by the shared validator. +pub(crate) fn load_gltf(source: &MeshSource<'_>) -> Result { + if source.primary.is_empty() { + return Err(MeshLoadError::Empty); + } + let key = if source.primary.starts_with(b"glTF") { + "asset.glb" + } else { + "asset.gltf" + }; + let mut assets = io::RawAssets::new(); + assets.insert(key, source.primary.to_vec()); + for (uri, bytes) in source.resources.iter() { + // Insert under the exact raw URI so `base_path.join(uri)` is an exact lookup, + // never relying on `RawAssets`' substring-`contains` fallback. + assets.insert(uri, bytes.to_vec()); + } + let model: three_d_asset::Model = assets + .deserialize(key) + .map_err(|e| MeshLoadError::Decode(e.to_string()))?; + let mut trimesh = crate::mesh::flatten_model(model); + crate::mesh::validate(&mut trimesh)?; + Ok(ModelMesh { + trimesh, + format: MeshFormat::Gltf, + units: Units::declared(LengthUnit::Meter), + }) +} + +#[cfg(test)] +mod tests { + use super::load_gltf; + use crate::mesh::contract::{LengthUnit, MeshFormat, MeshLoadError, ModelMesh, Units}; + use crate::mesh::parser::{MeshResources, MeshSource}; + + fn fixture(name: &str) -> Vec { + let path = format!("{}/public/models/{name}", env!("CARGO_MANIFEST_DIR")); + std::fs::read(&path) + .unwrap_or_else(|_| panic!("glTF fixture {name} should be committed at {path}")) + } + + /// Read named companion files into `(raw_uri, bytes)` pairs. + fn companions(files: &[(&str, &str)]) -> Vec<(String, Vec)> { + files + .iter() + .map(|(uri, file)| (uri.to_string(), fixture(file))) + .collect() + } + + fn assert_meter_units(model: &ModelMesh) { + assert_eq!( + model.units, + Units { + source: LengthUnit::Meter, + assumed: None, + } + ); + } + + #[test] + fn parses_self_contained_glb() { + let primary = fixture("box.glb"); + let source = MeshSource { + format: MeshFormat::Gltf, + primary: &primary, + resources: MeshResources::empty(), + }; + let model = load_gltf(&source).expect("box.glb should parse"); + assert_eq!(model.format, MeshFormat::Gltf); + assert_meter_units(&model); + assert_eq!(model.trimesh.triangle_count(), 12); + assert!( + model.trimesh.normals.is_some(), + "geometry normals should be populated" + ); + } + + #[test] + fn parses_glb_with_baked_transform_and_multiple_primitives() { + // box_translated.glb: two primitives (12 tris each) under a node transform that + // rotates and translates +10 on X. After flattening + baking, the unit cube + // (centred at the origin locally) has its AABB centre shifted to x ~= 10. + let primary = fixture("box_translated.glb"); + let source = MeshSource { + format: MeshFormat::Gltf, + primary: &primary, + resources: MeshResources::empty(), + }; + let model = load_gltf(&source).expect("box_translated.glb should parse"); + assert_eq!( + model.trimesh.triangle_count(), + 24, + "two primitives flatten to 24 triangles" + ); + let center = model.aabb().center(); + assert!( + (center.x - 10.0).abs() < 0.5, + "node translation (+10 X) is baked into world-space positions: got center.x = {}", + center.x + ); + } + + #[test] + fn parses_embedded_data_url_gltf() { + let primary = fixture("box_embedded.gltf"); + let source = MeshSource { + format: MeshFormat::Gltf, + primary: &primary, + resources: MeshResources::empty(), + }; + let model = load_gltf(&source).expect("embedded-base64 .gltf should parse"); + assert_meter_units(&model); + assert_eq!(model.trimesh.triangle_count(), 12); + } + + #[test] + fn parses_external_buffer_gltf() { + let primary = fixture("box.gltf"); + let comps = companions(&[("box.bin", "box.bin")]); + let source = MeshSource { + format: MeshFormat::Gltf, + primary: &primary, + resources: MeshResources::new(&comps), + }; + let model = load_gltf(&source).expect("external-buffer .gltf should parse"); + assert_meter_units(&model); + assert_eq!(model.trimesh.triangle_count(), 12); + } + + #[test] + fn external_buffer_resolves_exact_companion_not_substring_fallback() { + // `RawAssets` falls back to substring matching when an exact key is absent. This + // proves the exact raw-URI key wins even when a second companion key contains it + // as a substring: the decoy bytes (garbage) must not be substituted for the real + // buffer, so the geometry still parses to 12 triangles. + let primary = fixture("box.gltf"); + let real = fixture("box.bin"); + let comps = vec![ + ("box.bin".to_string(), real), + ( + "box.bin.bak".to_string(), + b"garbage that is not a glTF buffer".to_vec(), + ), + ]; + let source = MeshSource { + format: MeshFormat::Gltf, + primary: &primary, + resources: MeshResources::new(&comps), + }; + let model = load_gltf(&source).expect("exact box.bin must win over box.bin.bak"); + assert_eq!(model.trimesh.triangle_count(), 12); + } + + #[test] + fn parses_textured_gltf_with_external_png() { + // The PNG texture is decoded during deserialize then discarded by TriMesh; it + // must still be present and decodable or the whole parse fails. + let primary = fixture("box_textured.gltf"); + let comps = companions(&[ + ("box_textured.bin", "box_textured.bin"), + ("box_textured.png", "box_textured.png"), + ]); + let source = MeshSource { + format: MeshFormat::Gltf, + primary: &primary, + resources: MeshResources::new(&comps), + }; + let model = load_gltf(&source).expect("textured .gltf should parse"); + assert_meter_units(&model); + assert_eq!(model.trimesh.triangle_count(), 12); + } + + #[test] + fn error_missing_buffer_companion() { + // box.gltf references an external buffer that is not provided. + let primary = fixture("box.gltf"); + let source = MeshSource { + format: MeshFormat::Gltf, + primary: &primary, + resources: MeshResources::empty(), + }; + assert!(matches!(load_gltf(&source), Err(MeshLoadError::Decode(_)))); + } + + #[test] + fn error_missing_texture_companion() { + // Buffer present, but the referenced PNG texture is missing -> decode fails + // (three-d-asset requires every material-referenced texture). + let primary = fixture("box_textured.gltf"); + let comps = companions(&[("box_textured.bin", "box_textured.bin")]); + let source = MeshSource { + format: MeshFormat::Gltf, + primary: &primary, + resources: MeshResources::new(&comps), + }; + assert!(matches!(load_gltf(&source), Err(MeshLoadError::Decode(_)))); + } + + #[test] + fn error_empty() { + let source = MeshSource { + format: MeshFormat::Gltf, + primary: &[], + resources: MeshResources::empty(), + }; + assert!(matches!(load_gltf(&source), Err(MeshLoadError::Empty))); + } + + #[test] + fn error_parse_garbage() { + let source = MeshSource { + format: MeshFormat::Gltf, + primary: b"{ not valid gltf json", + resources: MeshResources::empty(), + }; + assert!(matches!(load_gltf(&source), Err(MeshLoadError::Decode(_)))); + } +} diff --git a/src/mesh/mod.rs b/src/mesh/mod.rs index c584ab4..5d75ffb 100644 --- a/src/mesh/mod.rs +++ b/src/mesh/mod.rs @@ -1,18 +1,267 @@ -//! Renderer-agnostic mesh contract and STL ingestion. +//! Renderer-agnostic mesh contract and multi-format ingestion. //! //! Defines [`ModelMesh`] (in [`contract`]), a thin Polymodel wrapper over //! [`three_d_asset::TriMesh`] that carries format provenance, unit assumptions, and a -//! camera framing hint. STL is parsed from in-memory bytes (no filesystem), so this -//! module compiles to `wasm32-unknown-unknown` and can be consumed by any future -//! renderer ([format ingestion research](https://radiant-industries.atlassian.net/wiki/spaces/PM/pages/131340)). +//! camera framing hint. STL, OBJ, and glTF (GLB, embedded-base64, or multi-file `.gltf` +//! with external companions) are parsed client-side from in-memory bytes via +//! `three-d-asset`, so this module compiles to `wasm32-unknown-unknown`. 3MF cannot +//! target `wasm32` and is routed to a remote parser (PM-49); the parser seam in +//! [`parser`] is remote-ready. See the [PM-14 research][1] and the [PM-23 plan][2]. //! -//! Scope is STL-only for now; OBJ/glTF/3MF direct upload is tracked in PM-23. +//! [1]: https://radiant-industries.atlassian.net/wiki/spaces/PM/pages/131340 +//! [2]: https://radiant-industries.atlassian.net/browse/PM-23 -// This module is a renderer-agnostic ingestion foundation: it is consumed by the -// Dioxus viewer scaffold and the renderer, which are not yet wired into the -// binary's main path. Its public surface is therefore intentionally unused at -// present, so dead-code analysis is relaxed here. +// This module is a renderer-agnostic ingestion foundation. Its public surface is +// consumed by the viewer and renderer; some items are exercised only through +// format-specific parsers and not yet on the binary's main path, so dead-code analysis +// is relaxed here. #![allow(dead_code)] +pub mod companions; pub mod contract; +pub mod gltf; +pub mod obj; +pub mod parser; pub mod stl; + +use three_d_asset::{Geometry, Indices, Mat4, Positions, TriMesh, Vec3}; + +use crate::mesh::contract::MeshLoadError; + +/// Validate and finalize a parsed [`TriMesh`] shared by every loader. +/// +/// Runs the geometry checks lifted from the STL path so STL, OBJ, and glTF all enforce +/// the same invariants, then derives geometry normals and normalizes positions to F32: +/// +/// * no triangles, an out-of-range index, a non-finite position, or a point-sized +/// bounding box → [`MeshLoadError::Degenerate`]; +/// * normals are geometry-derived (source facet/vertex normals are not relied upon); +/// * a vertex touched only by zero-area triangles gets a NaN normal, which is scrubbed to zero; +/// * OBJ's `Positions::F64` is downcast to `Positions::F32` (the renderer invariant). +/// +/// Call this **after** any per-format transform baking, so the AABB and normals reflect +/// world-space positions. +pub(crate) fn validate(trimesh: &mut TriMesh) -> Result<(), MeshLoadError> { + if trimesh.triangle_count() == 0 { + return Err(MeshLoadError::Degenerate); + } + // Reject non-finite positions (F32 or F64) up front so AABB / normal math can't be + // poisoned. + if !positions_all_finite(&trimesh.positions) { + return Err(MeshLoadError::Degenerate); + } + // Reject out-of-range indices before compute_normals, which indexes the position + // buffer directly and would panic on malformed geometry. + let position_count = trimesh.positions.len() as u32; + if trimesh + .indices + .to_u32() + .is_some_and(|indices| indices.iter().any(|&i| i >= position_count)) + { + return Err(MeshLoadError::Degenerate); + } + // Reject a point-sized bounding box (all three extents ~0). A flat plate or line — + // zero extent on one or two axes — is valid geometry and must parse. + let size = trimesh.compute_aabb().size(); + if size.x.abs() < f32::EPSILON && size.y.abs() < f32::EPSILON && size.z.abs() < f32::EPSILON { + return Err(MeshLoadError::Degenerate); + } + // Geometry-derived normals; source normals are not relied upon. + trimesh.compute_normals(); + // `compute_normals` normalizes each per-vertex face-normal sum. A vertex touched + // only by zero-area (collinear) triangles has a zero sum and normalizes to NaN. Such + // geometry is allowed to parse, so replace any non-finite normal with zero — the + // normal is genuinely undefined there, but the mesh must never carry NaN downstream. + if let Some(normals) = trimesh.normals.as_mut() { + for n in normals { + if !n.x.is_finite() || !n.y.is_finite() || !n.z.is_finite() { + *n = Vec3::new(0.0, 0.0, 0.0); + } + } + } + // Normalize OBJ F64 positions to F32 (STL/glTF are already F32). + if let Positions::F64(f64s) = &trimesh.positions { + trimesh.positions = Positions::F32( + f64s.iter() + .map(|v| Vec3::new(v.x as f32, v.y as f32, v.z as f32)) + .collect(), + ); + } + Ok(()) +} + +fn positions_all_finite(positions: &Positions) -> bool { + match positions { + Positions::F32(vs) => vs + .iter() + .all(|v| v.x.is_finite() && v.y.is_finite() && v.z.is_finite()), + Positions::F64(vs) => vs + .iter() + .all(|v| v.x.is_finite() && v.y.is_finite() && v.z.is_finite()), + } +} + +/// Flatten a parsed `three-d-asset` [`Model`](three_d_asset::Model) into a single +/// [`TriMesh`], shared by the OBJ and glTF loaders. +/// +/// `three-d-asset`'s `Scene → Model` conversion accumulates each glTF node chain's world +/// transform onto [`Primitive::transformation`](three_d_asset::Primitive) while leaving +/// positions in local space, so each geometry is baked here (local → world) as it is +/// folded into one mesh with per-primitive index offsets. Non-triangle geometry (e.g. +/// point clouds) is dropped — Polymodel renders triangle meshes only. All positions are +/// unified to F32 (OBJ arrives as F64); [`validate`] recomputes normals afterwards. +pub(crate) fn flatten_model(model: three_d_asset::Model) -> TriMesh { + let mut positions: Vec = Vec::new(); + let mut indices: Vec = Vec::new(); + for primitive in model.geometries { + let Geometry::Triangles(mesh) = primitive.geometry else { + continue; + }; + let base = positions.len() as u32; + for p in mesh.positions.to_f32() { + positions.push(transform_point(primitive.transformation, p)); + } + match mesh.indices { + Indices::U32(values) => indices.extend(values.into_iter().map(|i| i + base)), + Indices::U16(values) => indices.extend(values.into_iter().map(|i| i as u32 + base)), + Indices::U8(values) => indices.extend(values.into_iter().map(|i| i as u32 + base)), + // Non-indexed geometry: synthesize a sequential index per vertex. + Indices::None => indices.extend((0..mesh.positions.len() as u32).map(|i| i + base)), + } + } + TriMesh { + positions: Positions::F32(positions), + indices: Indices::U32(indices), + normals: None, + tangents: None, + uvs: None, + colors: None, + } +} + +/// Apply an affine node transform (`Mat4`, cgmath column-major) to a point. +/// +/// glTF node transforms are affine, so the homogeneous row is `(0, 0, 0, 1)` and the +/// translation lives in the fourth column (`m.w`); there is no perspective divide. +fn transform_point(m: Mat4, p: Vec3) -> Vec3 { + Vec3::new( + p.x * m.x.x + p.y * m.y.x + p.z * m.z.x + m.w.x, + p.x * m.x.y + p.y * m.y.y + p.z * m.z.y + m.w.y, + p.x * m.x.z + p.y * m.y.z + p.z * m.z.z + m.w.z, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use three_d_asset::{Indices, SquareMatrix}; + + /// Build a `TriMesh` from explicit F32 positions and a flat index list. + fn trimesh(positions: &[[f32; 3]], indices: &[u32]) -> TriMesh { + TriMesh { + positions: Positions::F32( + positions + .iter() + .map(|p| Vec3::new(p[0], p[1], p[2])) + .collect(), + ), + indices: Indices::U32(indices.to_vec()), + normals: None, + tangents: None, + uvs: None, + colors: None, + } + } + + #[test] + fn validate_rejects_zero_triangles() { + let mut m = trimesh(&[[0.0, 0.0, 0.0]], &[]); + assert!(matches!(validate(&mut m), Err(MeshLoadError::Degenerate))); + } + + #[test] + fn validate_rejects_non_finite_position() { + let mut m = trimesh( + &[[f32::INFINITY, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], + &[0, 1, 2], + ); + assert!(matches!(validate(&mut m), Err(MeshLoadError::Degenerate))); + } + + #[test] + fn validate_rejects_out_of_range_indices() { + // Index 99 is beyond the 3 positions; compute_normals would panic, so validate + // must surface a clean load error instead. + let mut m = trimesh( + &[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], + &[0, 1, 99], + ); + assert!(matches!(validate(&mut m), Err(MeshLoadError::Degenerate))); + } + + #[test] + fn validate_rejects_point_sized_aabb() { + let mut m = trimesh(&[[5.0, 5.0, 5.0]; 3], &[0, 1, 2]); + assert!(matches!(validate(&mut m), Err(MeshLoadError::Degenerate))); + } + + #[test] + fn validate_derives_normals_and_normalizes_f64() { + let mut m = TriMesh { + positions: Positions::F64( + [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]] + .iter() + .map(|p| three_d_asset::Vector3::new(p[0], p[1], p[2])) + .collect(), + ), + indices: Indices::U32(vec![0, 1, 2]), + normals: None, + tangents: None, + uvs: None, + colors: None, + }; + validate(&mut m).expect("a valid triangle parses"); + assert!(matches!(m.positions, Positions::F32(_)), "F64 -> F32"); + let normals = m.normals.expect("normals populated"); + assert_eq!(normals.len(), 3); + assert!( + normals + .iter() + .all(|n| n.x.is_finite() && n.y.is_finite() && n.z.is_finite()) + ); + } + + #[test] + fn flatten_bakes_translation_into_positions() { + // Two identity-transform triangles -> 6 positions, 2 triangles, no offset drift. + let model = three_d_asset::Model { + name: "test".into(), + materials: Vec::new(), + geometries: vec![ + three_d_asset::Primitive { + name: "a".into(), + transformation: Mat4::identity(), + animations: Vec::new(), + geometry: Geometry::Triangles(trimesh( + &[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], + &[0, 1, 2], + )), + material_index: None, + }, + three_d_asset::Primitive { + name: "b".into(), + transformation: Mat4::identity(), + animations: Vec::new(), + geometry: Geometry::Triangles(trimesh( + &[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], + &[0, 1, 2], + )), + material_index: None, + }, + ], + }; + let flat = flatten_model(model); + assert_eq!(flat.positions.len(), 6); + assert_eq!(flat.indices.to_u32().unwrap(), vec![0, 1, 2, 3, 4, 5]); + } +} diff --git a/src/mesh/obj.rs b/src/mesh/obj.rs new file mode 100644 index 0000000..e44ac38 --- /dev/null +++ b/src/mesh/obj.rs @@ -0,0 +1,102 @@ +//! OBJ ingestion: parse in-memory bytes into a [`ModelMesh`] via `three-d-asset`. +//! +//! `three-d-asset` parses Wavefront OBJ through `wavefront_obj`, which +//! fan-triangulates convex polygons at parse time and drops Line/Point primitives; +//! concave polygons are out of scope. OBJ carries no unit, so the loader assumes +//! millimetres. There is no filesystem or network access — an `.mtl` material sidecar is +//! not fetched (out of scope) — so this is safe to call from a `wasm32-unknown-unknown` +//! build. + +use three_d_asset::io; + +use crate::mesh::contract::{MeshFormat, MeshLoadError, ModelMesh, Units}; + +/// Parse OBJ bytes into a [`ModelMesh`]. +/// +/// Convex polygons are fan-triangulated; Line and Point primitives are dropped. OBJ is +/// unitless, so the result assumes millimetres. Positions are normalized to F32 by the +/// shared validator (OBJ arrives as F64). +pub fn load_obj(bytes: &[u8]) -> Result { + if bytes.is_empty() { + return Err(MeshLoadError::Empty); + } + let model: three_d_asset::Model = io::deserialize("asset.obj", bytes.to_vec()) + .map_err(|e| MeshLoadError::Decode(e.to_string()))?; + let mut trimesh = crate::mesh::flatten_model(model); + crate::mesh::validate(&mut trimesh)?; + Ok(ModelMesh { + trimesh, + format: MeshFormat::Obj, + units: Units::unitless_assumed_mm(), + }) +} + +#[cfg(test)] +mod tests { + use super::load_obj; + use crate::mesh::contract::{LengthUnit, MeshFormat, MeshLoadError, Units}; + use three_d_asset::Positions; + + fn fixture(name: &str) -> Vec { + let path = format!("{}/public/models/{name}", env!("CARGO_MANIFEST_DIR")); + std::fs::read(&path) + .unwrap_or_else(|_| panic!("OBJ fixture {name} should be committed at {path}")) + } + + #[test] + fn parses_quad_cube_fixture() { + let model = load_obj(&fixture("cube.obj")).expect("cube.obj should parse"); + assert_eq!(model.format, MeshFormat::Obj); + assert_eq!( + model.units, + Units { + source: LengthUnit::Unknown, + assumed: Some(LengthUnit::Millimeter), + } + ); + // 6 convex quads fan-triangulate to 12 triangles. + assert_eq!(model.trimesh.triangle_count(), 12); + // OBJ's F64 positions are normalized to F32. + assert!(matches!(model.trimesh.positions, Positions::F32(_))); + let normals = model + .trimesh + .normals + .as_ref() + .expect("geometry normals should be populated"); + assert_eq!(normals.len(), model.trimesh.positions.len()); + assert!( + normals + .iter() + .all(|n| n.x.is_finite() && n.y.is_finite() && n.z.is_finite()), + "normals must be finite" + ); + } + + #[test] + fn error_empty() { + assert!(matches!(load_obj(&[]), Err(MeshLoadError::Empty))); + } + + #[test] + fn error_parse_garbage() { + let garbage = b"this is definitely not a valid OBJ file !@#$"; + assert!(matches!(load_obj(garbage), Err(MeshLoadError::Decode(_)))); + } + + #[test] + fn error_zero_triangles() { + // Vertices but no faces: parses to an empty mesh. + let verts_only = b"v 0 0 0\nv 1 0 0\nv 0 1 0\n"; + assert!(matches!( + load_obj(verts_only), + Err(MeshLoadError::Degenerate) + )); + } + + #[test] + fn error_point_sized_aabb() { + // A face collapsed to a single point has a point-sized AABB. + let point = b"v 5 5 5\nf 1 1 1\n"; + assert!(matches!(load_obj(point), Err(MeshLoadError::Degenerate))); + } +} diff --git a/src/mesh/parser.rs b/src/mesh/parser.rs new file mode 100644 index 0000000..12700d2 --- /dev/null +++ b/src/mesh/parser.rs @@ -0,0 +1,255 @@ +//! Parser seam — shared parse boundary, format dispatch, and the remote drop-in hook. +//! +//! The data types here ([`MeshResources`], [`MeshSource`]) are the only thing that +//! crosses the parse boundary. The dispatcher [`load_mesh`] is synchronous; the viewer is +//! the sole async layer (it prefetches glTF companions). 3MF cannot compile to `wasm32`, +//! so it is served by a remote parser (PM-49); [`RemoteMeshParser`] + the `Threemf` arm +//! are the drop-in site, and wiring them in does not change the [`MeshParser`] trait +//! shape or the viewer pipeline. + +use crate::mesh::contract::{MeshFormat, MeshLoadError, ModelMesh}; + +/// Borrowed views of a mesh's companion resources (external glTF buffers/textures), +/// keyed by their exact raw URI as referenced by the primary asset. +/// +/// Empty for STL, OBJ, GLB, and embedded-base64 `.gltf`. The viewer keeps an owned +/// companion store alive across the async parse and borrows it here; [`crate::mesh::gltf`] +/// inserts each entry into a `three-d-asset` `RawAssets` under the exact same URI so its +/// `base_path.join(uri)` lookup is an exact match (avoiding `RawAssets`' substring +/// fallback foot-gun). +pub(crate) struct MeshResources<'a> { + companions: &'a [(String, Vec)], +} + +impl<'a> MeshResources<'a> { + /// No companion resources. + pub(crate) fn empty() -> MeshResources<'static> { + MeshResources { companions: &[] } + } + + /// Wrap an owned companion store `(raw_uri, bytes)`. + pub(crate) fn new(companions: &'a [(String, Vec)]) -> Self { + MeshResources { companions } + } + + /// Iterate `(raw_uri, bytes)` pairs. + pub(crate) fn iter(&self) -> impl Iterator { + self.companions + .iter() + .map(|(uri, bytes)| (uri.as_str(), bytes.as_slice())) + } +} + +/// The bytes needed to parse one mesh: its primary asset plus any companion resources. +/// +/// Passed by reference through the parse boundary so the trait shape stays fixed for the +/// remote-parser drop-in (PM-49). +pub(crate) struct MeshSource<'a> { + /// Detected source format. + pub format: MeshFormat, + /// Primary asset bytes (the `.stl`/`.obj`/`.gltf`/`.glb`/`.3mf` itself). + pub primary: &'a [u8], + /// Companion resource bytes, keyed by exact raw URI. + pub resources: MeshResources<'a>, +} + +/// Synchronously parse a [`MeshSource`] into a [`ModelMesh`], dispatching by format. +/// +/// `Stl`/`Obj`/`Gltf` are parsed client-side from the in-memory bytes. `Threemf` is not +/// supported here — it is routed to a remote parser (PM-49) by [`ProductionMeshParser`]. +pub(crate) fn load_mesh(source: &MeshSource<'_>) -> Result { + match source.format { + MeshFormat::Stl => crate::mesh::stl::load_stl(source.primary), + MeshFormat::Obj => crate::mesh::obj::load_obj(source.primary), + MeshFormat::Gltf => crate::mesh::gltf::load_gltf(source), + // PM-49: ThreemfRemoteParser converts to GLB, then re-enters via Gltf. + MeshFormat::Threemf => Err(MeshLoadError::UnsupportedFormat), + } +} + +/// Parse boundary: turn a [`MeshSource`] into a [`ModelMesh`]. Generic for static +/// dispatch (no boxed `dyn`); tests inject a counter to assert one parse per load. +/// +/// **Drop-in contract (PM-49):** the only changes are a `RemoteMeshParser` +/// implementation (e.g. `ThreemfRemoteParser`) and constructing +/// `ProductionMeshParser::new(remote)` at the call site. This trait shape, the viewer +/// pipeline, and `build_parsed_mesh` are unchanged. +pub(crate) trait MeshParser { + /// Parse the source into a mesh. + async fn parse(&self, source: &MeshSource<'_>) -> Result; +} + +/// A remote (server-side) parser for formats that cannot compile to `wasm32` (3MF). +/// +/// **Contract:** [`RemoteMeshParser::convert`] MUST return a **self-contained GLB** +/// (embedded buffer), because the `Threemf` arm re-enters the pipeline via `Gltf` with no +/// companion resources. +pub(crate) trait RemoteMeshParser { + /// Convert the source bytes (e.g. a `.3mf`) into self-contained GLB bytes. + async fn convert(&self, bytes: &[u8]) -> Result, MeshLoadError>; +} + +/// No remote parser wired in: `Threemf` is unsupported until PM-49 supplies one. +pub(crate) struct NoRemote; + +impl RemoteMeshParser for NoRemote { + async fn convert(&self, _bytes: &[u8]) -> Result, MeshLoadError> { + Err(MeshLoadError::UnsupportedFormat) + } +} + +/// Production parser: routes by format. `Stl`/`Obj`/`Gltf` parse in-memory; `Threemf` +/// delegates to a [`RemoteMeshParser`] (converting to a self-contained GLB, then +/// re-entering via `Gltf` with no companions). +pub(crate) struct ProductionMeshParser { + remote: R, +} + +impl Default for ProductionMeshParser { + fn default() -> Self { + Self { remote: NoRemote } + } +} + +impl ProductionMeshParser { + /// Construct with a remote parser (PM-49 wires `ThreemfRemoteParser` here). + pub(crate) fn new(remote: R) -> Self { + Self { remote } + } +} + +impl MeshParser for ProductionMeshParser { + async fn parse(&self, source: &MeshSource<'_>) -> Result { + match source.format { + MeshFormat::Stl | MeshFormat::Obj | MeshFormat::Gltf => load_mesh(source), + MeshFormat::Threemf => { + let glb = self.remote.convert(source.primary).await?; + let reentry = MeshSource { + format: MeshFormat::Gltf, + primary: &glb, + resources: MeshResources::empty(), + }; + load_mesh(&reentry) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mesh::contract::MeshFormat; + + fn fixture(name: &str) -> Vec { + let path = format!("{}/public/models/{name}", env!("CARGO_MANIFEST_DIR")); + std::fs::read(&path) + .unwrap_or_else(|_| panic!("fixture {name} should be committed at {path}")) + } + + #[test] + fn load_mesh_dispatches_each_format_and_rejects_threemf() { + // Stl: a minimal binary STL tetrahedron (80-byte header + count + 4 triangles). + let mut stl = Vec::new(); + stl.extend_from_slice(&[0u8; 80]); + stl.extend_from_slice(&4u32.to_le_bytes()); + let v = [ + [0.0f32; 3], + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + ]; + let faces = [[0, 2, 1], [0, 1, 3], [0, 3, 2], [1, 2, 3]]; + for f in faces { + stl.extend_from_slice(&[0u8; 12]); // facet normal (3 zero f32) + for i in f { + for &c in &v[i] { + stl.extend_from_slice(&c.to_le_bytes()); + } + } + stl.extend_from_slice(&0u16.to_le_bytes()); // attribute + } + let stl_mesh = load_mesh(&MeshSource { + format: MeshFormat::Stl, + primary: &stl, + resources: MeshResources::empty(), + }) + .expect("STL dispatches to load_stl"); + assert_eq!(stl_mesh.format, MeshFormat::Stl); + + // Obj: a single triangle. + let obj_mesh = load_mesh(&MeshSource { + format: MeshFormat::Obj, + primary: b"v 0 0 0\nv 1 0 0\nv 0 1 0\nf 1 2 3\n", + resources: MeshResources::empty(), + }) + .expect("OBJ dispatches to load_obj"); + assert_eq!(obj_mesh.format, MeshFormat::Obj); + + // Gltf: a self-contained GLB. + let glb = fixture("box.glb"); + let gltf_mesh = load_mesh(&MeshSource { + format: MeshFormat::Gltf, + primary: &glb, + resources: MeshResources::empty(), + }) + .expect("GLB dispatches to load_gltf"); + assert_eq!(gltf_mesh.format, MeshFormat::Gltf); + + // Threemf: unsupported without a remote parser. + assert!(matches!( + load_mesh(&MeshSource { + format: MeshFormat::Threemf, + primary: b"not real 3mf", + resources: MeshResources::empty(), + }), + Err(MeshLoadError::UnsupportedFormat) + )); + } + + #[test] + fn production_parser_with_no_remote_rejects_threemf() { + let parser = ProductionMeshParser::default(); + let source = MeshSource { + format: MeshFormat::Threemf, + primary: b"not real 3mf", + resources: MeshResources::empty(), + }; + // Drive the async parse on a current-thread runtime (no Send bound needed). + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let result = rt.block_on(parser.parse(&source)); + assert!(matches!(result, Err(MeshLoadError::UnsupportedFormat))); + } + + // AC.6 — drop-in proof: a stub remote returning a self-contained GLB makes a + // `Threemf` source parse via the GLB loader, with the trait shape unchanged. + struct StubRemote; + + impl RemoteMeshParser for StubRemote { + async fn convert(&self, _bytes: &[u8]) -> Result, MeshLoadError> { + // Returns a real self-contained GLB regardless of input. + Ok(fixture("box.glb")) + } + } + + #[test] + fn production_parser_with_stub_remote_parses_threemf_via_glb() { + let parser = ProductionMeshParser::new(StubRemote); + let source = MeshSource { + format: MeshFormat::Threemf, + primary: b"fake 3mf bytes", + resources: MeshResources::empty(), + }; + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let mesh = rt + .block_on(parser.parse(&source)) + .expect("stub remote -> GLB -> ModelMesh"); + assert_eq!(mesh.format, MeshFormat::Gltf); + assert_eq!(mesh.trimesh.triangle_count(), 12); + } +} diff --git a/src/mesh/stl.rs b/src/mesh/stl.rs index 4c1bb60..6319834 100644 --- a/src/mesh/stl.rs +++ b/src/mesh/stl.rs @@ -39,20 +39,11 @@ pub fn load_stl(bytes: &[u8]) -> Result { } /// Convert a parsed `stl_io` mesh into a validated [`ModelMesh`]. +/// +/// The `stl_io::IndexedMesh` → [`TriMesh`] conversion is STL-specific; geometry +/// validation, normal derivation, and F32 normalization are shared via +/// [`crate::mesh::validate`]. fn build_model_mesh(mesh: stl_io::IndexedMesh) -> Result { - // No triangles → nothing to render. - if mesh.faces.is_empty() { - return Err(MeshLoadError::Degenerate); - } - // Reject non-finite positions up front so AABB / normal math can't be poisoned. - if mesh - .vertices - .iter() - .any(|v| !v.0.iter().copied().all(f32::is_finite)) - { - return Err(MeshLoadError::Degenerate); - } - let positions = Positions::F32( mesh.vertices .iter() @@ -76,31 +67,12 @@ fn build_model_mesh(mesh: stl_io::IndexedMesh) -> Result u64 { +/// Deterministic, in-process identity hash of a mesh source. +/// +/// Covers the primary bytes plus the sorted set of `(companion URI, companion bytes)`, +/// so changing a companion (or none vs. some) changes identity and forces a reload. For +/// a companion-free source this reduces to hashing the primary bytes alone. +fn identity_hash(primary: &[u8], companions: &[(String, Vec)]) -> u64 { let mut hasher = std::collections::hash_map::DefaultHasher::new(); - bytes.hash(&mut hasher); + primary.hash(&mut hasher); + if !companions.is_empty() { + // A marker so "primary alone" can never collide with "primary + companions". + 0xFFu8.hash(&mut hasher); + let mut sorted: Vec<(&str, &[u8])> = companions + .iter() + .map(|(uri, bytes)| (uri.as_str(), bytes.as_slice())) + .collect(); + sorted.sort_unstable(); + for (uri, bytes) in &sorted { + uri.hash(&mut hasher); + bytes.hash(&mut hasher); + } + } hasher.finish() } -/// Parse boundary for the viewer: a mockable parser turns source bytes into a -/// [`ModelMesh`]. Production uses [`StlParser`] (which wraps the shared -/// [`load_stl`]); tests inject a counting parser to assert one parse per load. -/// This is the only place in the viewer that calls [`load_stl`]. -trait MeshParser { - fn parse(&self, bytes: &[u8]) -> Result; -} - -/// Default STL parser. [`StlParser::parse`] is the single [`load_stl`] call site -/// in the viewer; the renderer consumes a [`ParsedMesh`] and never parses. -struct StlParser; - -impl MeshParser for StlParser { - fn parse(&self, bytes: &[u8]) -> Result { - load_stl(bytes) - } +/// Hash a single byte slice (companion-free sources). +fn hash_bytes(bytes: &[u8]) -> u64 { + identity_hash(bytes, &[]) } -/// Build a [`ParsedMesh`] from source bytes: hash the bytes, then parse once. -fn build_parsed_mesh( - bytes: &[u8], +/// Parse boundary: the shared [`MeshParser`] (in [`crate::mesh::parser`]) turns a +/// [`MeshSource`] into a [`ModelMesh`]. Production uses +/// [`ProductionMeshParser`] (which dispatches by format); tests inject a counting parser +/// to assert one parse per load. +/// +/// Build a [`ParsedMesh`] from a mesh source: hash the primary plus companions, then +/// parse once through the parser. +async fn build_parsed_mesh( + source: &MeshSource<'_>, parser: &impl MeshParser, ) -> Result, MeshLoadError> { - let content_hash = hash_bytes(bytes); - let mesh = parser.parse(bytes)?; + let companions: Vec<(String, Vec)> = source + .resources + .iter() + .map(|(uri, bytes)| (uri.to_string(), bytes.to_vec())) + .collect(); + let content_hash = identity_hash(source.primary, &companions); + let mesh = parser.parse(source).await?; Ok(Arc::new(ParsedMesh { content_hash, mesh })) } @@ -159,11 +176,26 @@ pub struct LoadedMesh { pub parsed: Arc, } -/// Load a demo model's STL on demand (fetch on wasm, disk off wasm), parsing it -/// exactly once into a [`LoadedMesh`]. +/// Load a demo model on demand (fetch the primary, prefetch glTF companions, parse +/// once) into a [`LoadedMesh`]. The format is derived from the asset extension; an +/// unrecognized extension surfaces as [`ViewerIssue::MeshUnavailable`]. async fn load_demo_mesh(model: DemoModel) -> Result { - let bytes = read_model_bytes(model).await?; - let parsed = build_parsed_mesh(&bytes, &StlParser).map_err(|_| ViewerIssue::MeshUnavailable)?; + let format = + MeshFormat::from_extension(model.asset_path).ok_or(ViewerIssue::MeshUnavailable)?; + let primary = read_bytes(model.asset_path).await?; + let companions = prefetch_companions(&primary, model.asset_path, format).await?; + let source = MeshSource { + format, + primary: &primary, + resources: if companions.is_empty() { + MeshResources::empty() + } else { + MeshResources::new(&companions) + }, + }; + let parsed = build_parsed_mesh(&source, &ProductionMeshParser::default()) + .await + .map_err(|_| ViewerIssue::MeshUnavailable)?; let summary = mesh_summary_from_model(model, &parsed.mesh); Ok(LoadedMesh { model, @@ -172,20 +204,46 @@ async fn load_demo_mesh(model: DemoModel) -> Result { }) } -/// Read a demo model's source bytes: `fetch` on the web target, a hermetic disk -/// read elsewhere (so the non-wasm build compiles and tests stay filesystem-backed). -async fn read_model_bytes(model: DemoModel) -> Result, ViewerIssue> { +/// Read a model resource's bytes: `fetch` on the web target, a hermetic disk read +/// elsewhere (so the non-wasm build compiles and tests stay filesystem-backed). +async fn read_bytes(asset_path: &str) -> Result, ViewerIssue> { #[cfg(all(target_family = "wasm", target_os = "unknown"))] { - fetch_bytes(model.asset_path).await + fetch_bytes(asset_path).await } #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] { - let path = format!("{}/public{}", env!("CARGO_MANIFEST_DIR"), model.asset_path); + let path = format!("{}/public{}", env!("CARGO_MANIFEST_DIR"), asset_path); std::fs::read(&path).map_err(|_| ViewerIssue::MeshUnavailable) } } +/// For a multi-file `.gltf`, discover and fetch its companion buffers/textures — only +/// safe, relative, in-directory URIs. GLB (magic `glTF`) and embedded-base64 assets are +/// self-contained and need no companions. Companions are keyed by their exact raw URI. +async fn prefetch_companions( + primary: &[u8], + asset_path: &str, + format: MeshFormat, +) -> Result)>, ViewerIssue> { + if format != MeshFormat::Gltf || primary.starts_with(b"glTF") { + return Ok(Vec::new()); + } + let base_dir = asset_path + .rsplit_once('/') + .map(|(dir, _)| dir) + .unwrap_or(""); + let mut companions = Vec::new(); + for uri in discover_companion_uris(primary) { + if !is_safe_relative_uri(&uri) { + return Err(ViewerIssue::MeshUnavailable); + } + let bytes = read_bytes(&format!("{base_dir}/{uri}")).await?; + companions.push((uri, bytes)); + } + Ok(companions) +} + #[cfg(all(target_family = "wasm", target_os = "unknown"))] async fn fetch_bytes(url: &str) -> Result, ViewerIssue> { use wasm_bindgen::JsCast; @@ -1280,22 +1338,30 @@ fn mark_canvas_mounted(event: MountedEvent, mut mounted: Signal) { mod tests { use super::*; use crate::mesh::contract::{LengthUnit, MeshFormat, Units}; + use crate::mesh::stl::load_stl; use std::cell::Cell; use std::rc::Rc; fn fixture_bytes(asset_path: &str) -> Vec { let path = format!("{}/public{}", env!("CARGO_MANIFEST_DIR"), asset_path); std::fs::read(&path) - .unwrap_or_else(|_| panic!("demo STL asset {asset_path} should be committed at {path}")) + .unwrap_or_else(|_| panic!("demo asset {asset_path} should be committed at {path}")) } - fn parsed_for(model: DemoModel) -> Arc { - build_parsed_mesh(&fixture_bytes(model.asset_path), &StlParser) + async fn parsed_for(model: DemoModel) -> Arc { + let bytes = fixture_bytes(model.asset_path); + let source = MeshSource { + format: MeshFormat::Stl, + primary: &bytes, + resources: MeshResources::empty(), + }; + build_parsed_mesh(&source, &ProductionMeshParser::default()) + .await .expect("demo STL asset should parse") } - fn mount_for(model: DemoModel) -> RendererMount { - let parsed = parsed_for(model); + async fn mount_for(model: DemoModel) -> RendererMount { + let parsed = parsed_for(model).await; let summary = mesh_summary_from_model(model, &parsed.mesh); RendererMount::new(SessionKey::new(model.id, model.generation), parsed, summary) } @@ -1388,46 +1454,75 @@ mod tests { assert!(!ViewerStatus::MeshError(ViewerIssue::MeshUnavailable).should_have_session()); } - #[test] - fn parsed_mesh_equality_uses_content_hash() { - let chest = parsed_for(DEMO_MODELS[0]); - let chest_again = parsed_for(DEMO_MODELS[0]); - let forearm = parsed_for(DEMO_MODELS[1]); + #[tokio::test] + async fn parsed_mesh_equality_uses_content_hash() { + let chest = parsed_for(DEMO_MODELS[0]).await; + let chest_again = parsed_for(DEMO_MODELS[0]).await; + let forearm = parsed_for(DEMO_MODELS[1]).await; assert_eq!(chest, chest_again, "same source bytes must be equal"); assert_ne!(chest, forearm, "different source bytes must differ"); assert_eq!(&chest, &chest_again, "Arc/ParsedMesh compare by hash"); } - #[test] - fn parses_each_loaded_mesh_once() { - #[derive(Default)] + #[tokio::test] + async fn parses_each_loaded_mesh_once() { struct CountingParser { calls: Rc>, } + impl CountingParser { + fn new() -> Self { + Self { + calls: Rc::new(Cell::new(0)), + } + } + } impl MeshParser for CountingParser { - fn parse(&self, bytes: &[u8]) -> Result { + async fn parse(&self, source: &MeshSource<'_>) -> Result { self.calls.set(self.calls.get() + 1); - load_stl(bytes) + crate::mesh::stl::load_stl(source.primary) } } - let parser = CountingParser::default(); + let parser = CountingParser::new(); let bytes = fixture_bytes(DEMO_MODELS[0].asset_path); - let parsed = build_parsed_mesh(&bytes, &parser).expect("parses"); + let source = MeshSource { + format: MeshFormat::Stl, + primary: &bytes, + resources: MeshResources::empty(), + }; + let parsed = build_parsed_mesh(&source, &parser).await.expect("parses"); assert_eq!( parser.calls.get(), 1, "the boundary must parse once per load" ); - let again = build_parsed_mesh(&bytes, &parser).unwrap(); + let again = build_parsed_mesh(&source, &parser).await.unwrap(); assert_eq!( again.content_hash, parsed.content_hash, "the same bytes must hash to the same identity" ); } + #[test] + fn identity_changes_when_a_companion_changes() { + // No companions -> reduces to hashing the primary. + let primary = b"primary bytes"; + let none = identity_hash(primary, &[]); + assert_eq!(none, hash_bytes(primary)); + + let with_a = identity_hash(primary, &[("box.bin".to_string(), b"AAA".to_vec())]); + let with_b = identity_hash(primary, &[("box.bin".to_string(), b"BBB".to_vec())]); + assert_ne!(none, with_a, "adding a companion changes identity"); + assert_ne!(with_a, with_b, "changing a companion changes identity"); + assert_eq!( + with_a, + identity_hash(primary, &[("box.bin".to_string(), b"AAA".to_vec())]), + "same companions hash to the same identity" + ); + } + #[test] fn typed_renderer_error_covers_all_cases_and_maps_to_renderer_init() { let errors: Vec = vec![ @@ -1462,14 +1557,14 @@ mod tests { ); } - #[test] - fn renderer_lifecycle_mounts_replaces_resizes_and_drops() { + #[tokio::test] + async fn renderer_lifecycle_mounts_replaces_resizes_and_drops() { let events = Rc::new(Cell::new(0)); let drops = Rc::new(Cell::new(0)); let canvas = (FakeCanvas, Rc::clone(&events), Rc::clone(&drops)); let mut lifecycle = RendererLifecycle::::new(); - let chest = mount_for(DEMO_MODELS[0]); - let forearm = mount_for(DEMO_MODELS[1]); + let chest = mount_for(DEMO_MODELS[0]).await; + let forearm = mount_for(DEMO_MODELS[1]).await; let ready_chest = ViewerStatus::Ready(chest.key.clone()); // Initial mount. -- knotmirror 0.0.1