atproto Thingiverse but good
10

Configure Feed

Select the types of activity you want to include in your feed.

PM-23: OBJ/glTF direct-upload ingestion + remote-ready parser framework

Orual (Jun 29, 2026, 7:06 PM EDT) 34fe556f 916a0344

+1548 -144
+94 -21
Cargo.lock
··· 380 380 dependencies = [ 381 381 "axum-core", 382 382 "axum-macros", 383 - "base64", 383 + "base64 0.22.1", 384 384 "bytes", 385 385 "form_urlencoded", 386 386 "futures-util", ··· 471 471 checksum = "08bf5c1e7d60af9632c55c23bbfa208d0fe08aa59fcda87216c9119e30a9d6d4" 472 472 dependencies = [ 473 473 "axum-core", 474 - "base64", 474 + "base64 0.22.1", 475 475 "bytes", 476 476 "futures-util", 477 477 "http", ··· 542 542 543 543 [[package]] 544 544 name = "base64" 545 + version = "0.13.1" 546 + source = "registry+https://github.com/rust-lang/crates.io-index" 547 + checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" 548 + 549 + [[package]] 550 + name = "base64" 545 551 version = "0.22.1" 546 552 source = "registry+https://github.com/rust-lang/crates.io-index" 547 553 checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" ··· 843 849 source = "registry+https://github.com/rust-lang/crates.io-index" 844 850 checksum = "f1f927b07c74ba84c7e5fe4db2baeb3e996ab2688992e39ac68ce3220a677c7e" 845 851 dependencies = [ 846 - "base64", 852 + "base64 0.22.1", 847 853 "encoding_rs", 848 854 ] 849 855 ··· 1146 1152 checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" 1147 1153 dependencies = [ 1148 1154 "aes-gcm", 1149 - "base64", 1155 + "base64 0.22.1", 1150 1156 "percent-encoding", 1151 1157 "rand 0.8.6", 1152 1158 "subtle", ··· 1565 1571 ] 1566 1572 1567 1573 [[package]] 1574 + name = "data-url" 1575 + version = "0.3.2" 1576 + source = "registry+https://github.com/rust-lang/crates.io-index" 1577 + checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" 1578 + 1579 + [[package]] 1568 1580 name = "der" 1569 1581 version = "0.7.10" 1570 1582 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1807 1819 dependencies = [ 1808 1820 "anyhow", 1809 1821 "async-trait", 1810 - "base64", 1822 + "base64 0.22.1", 1811 1823 "bytes", 1812 1824 "cocoa", 1813 1825 "core-foundation 0.10.1", ··· 1918 1930 "axum", 1919 1931 "axum-core", 1920 1932 "axum-extra", 1921 - "base64", 1933 + "base64 0.22.1", 1922 1934 "bytes", 1923 1935 "ciborium", 1924 1936 "const-str 0.7.1", ··· 1979 1991 dependencies = [ 1980 1992 "anyhow", 1981 1993 "axum-core", 1982 - "base64", 1994 + "base64 0.22.1", 1983 1995 "ciborium", 1984 1996 "dioxus-core", 1985 1997 "dioxus-document", ··· 2196 2208 "anyhow", 2197 2209 "async-trait", 2198 2210 "axum", 2199 - "base64", 2211 + "base64 0.22.1", 2200 2212 "bytes", 2201 2213 "chrono", 2202 2214 "ciborium", ··· 3361 3373 ] 3362 3374 3363 3375 [[package]] 3376 + name = "gltf" 3377 + version = "1.4.1" 3378 + source = "registry+https://github.com/rust-lang/crates.io-index" 3379 + checksum = "e3ce1918195723ce6ac74e80542c5a96a40c2b26162c1957a5cd70799b8cacf7" 3380 + dependencies = [ 3381 + "base64 0.13.1", 3382 + "byteorder", 3383 + "gltf-json", 3384 + "image", 3385 + "lazy_static", 3386 + "serde_json", 3387 + "urlencoding", 3388 + ] 3389 + 3390 + [[package]] 3391 + name = "gltf-derive" 3392 + version = "1.4.1" 3393 + source = "registry+https://github.com/rust-lang/crates.io-index" 3394 + checksum = "14070e711538afba5d6c807edb74bcb84e5dbb9211a3bf5dea0dfab5b24f4c51" 3395 + dependencies = [ 3396 + "inflections", 3397 + "proc-macro2", 3398 + "quote", 3399 + "syn 2.0.118", 3400 + ] 3401 + 3402 + [[package]] 3403 + name = "gltf-json" 3404 + version = "1.4.1" 3405 + source = "registry+https://github.com/rust-lang/crates.io-index" 3406 + checksum = "e6176f9d60a7eab0a877e8e96548605dedbde9190a7ae1e80bbcc1c9af03ab14" 3407 + dependencies = [ 3408 + "gltf-derive", 3409 + "serde", 3410 + "serde_derive", 3411 + "serde_json", 3412 + ] 3413 + 3414 + [[package]] 3364 3415 name = "gobject-sys" 3365 3416 version = "0.18.0" 3366 3417 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3523 3574 source = "registry+https://github.com/rust-lang/crates.io-index" 3524 3575 checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" 3525 3576 dependencies = [ 3526 - "base64", 3577 + "base64 0.22.1", 3527 3578 "bytes", 3528 3579 "headers-core", 3529 3580 "http", ··· 3846 3897 source = "registry+https://github.com/rust-lang/crates.io-index" 3847 3898 checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" 3848 3899 dependencies = [ 3849 - "base64", 3900 + "base64 0.22.1", 3850 3901 "bytes", 3851 3902 "futures-channel", 3852 3903 "futures-util", ··· 4065 4116 ] 4066 4117 4067 4118 [[package]] 4119 + name = "inflections" 4120 + version = "1.1.1" 4121 + source = "registry+https://github.com/rust-lang/crates.io-index" 4122 + checksum = "a257582fdcde896fd96463bf2d40eefea0580021c0712a0e2b028b60b47a837a" 4123 + 4124 + [[package]] 4068 4125 name = "inout" 4069 4126 version = "0.1.4" 4070 4127 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4238 4295 dependencies = [ 4239 4296 "axum", 4240 4297 "axum-extra", 4241 - "base64", 4298 + "base64 0.22.1", 4242 4299 "bytes", 4243 4300 "chrono", 4244 4301 "jacquard", ··· 4263 4320 source = "registry+https://github.com/rust-lang/crates.io-index" 4264 4321 checksum = "9631f08f1e65d19e204bc6774d00b4e0b69fb649d7d7ac59ccf97797bd9a196e" 4265 4322 dependencies = [ 4266 - "base64", 4323 + "base64 0.22.1", 4267 4324 "bon", 4268 4325 "bytes", 4269 4326 "chrono", ··· 4316 4373 name = "jacquard-common" 4317 4374 version = "0.12.1" 4318 4375 dependencies = [ 4319 - "base64", 4376 + "base64 0.22.1", 4320 4377 "bon", 4321 4378 "bytes", 4322 4379 "chrono", ··· 4489 4546 name = "jacquard-oauth" 4490 4547 version = "0.12.1" 4491 4548 dependencies = [ 4492 - "base64", 4549 + "base64 0.22.1", 4493 4550 "bytes", 4494 4551 "chrono", 4495 4552 "dashmap", ··· 6205 6262 "anyhow", 6206 6263 "axum", 6207 6264 "axum-extra", 6208 - "base64", 6265 + "base64 0.22.1", 6209 6266 "chrono", 6210 6267 "console_error_panic_hook", 6211 6268 "dioxus", ··· 6853 6910 source = "registry+https://github.com/rust-lang/crates.io-index" 6854 6911 checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" 6855 6912 dependencies = [ 6856 - "base64", 6913 + "base64 0.22.1", 6857 6914 "bytes", 6858 6915 "cookie", 6859 6916 "cookie_store", ··· 7372 7429 source = "registry+https://github.com/rust-lang/crates.io-index" 7373 7430 checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" 7374 7431 dependencies = [ 7375 - "base64", 7432 + "base64 0.22.1", 7376 7433 "bs58", 7377 7434 "chrono", 7378 7435 "hex", ··· 7697 7754 source = "registry+https://github.com/rust-lang/crates.io-index" 7698 7755 checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" 7699 7756 dependencies = [ 7700 - "base64", 7757 + "base64 0.22.1", 7701 7758 "bytes", 7702 7759 "cfg-if", 7703 7760 "crc", ··· 7795 7852 checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" 7796 7853 dependencies = [ 7797 7854 "atoi", 7798 - "base64", 7855 + "base64 0.22.1", 7799 7856 "bitflags 2.13.0", 7800 7857 "byteorder", 7801 7858 "crc", ··· 8214 8271 checksum = "cd4e4e9bc05e4010efbaa0a0296870b2d28102171deffbf69de7b305cff2d47a" 8215 8272 dependencies = [ 8216 8273 "cgmath", 8274 + "data-url", 8275 + "gltf", 8217 8276 "half", 8277 + "image", 8218 8278 "thiserror 2.0.18", 8279 + "wavefront_obj", 8219 8280 "web-sys", 8220 8281 ] 8221 8282 ··· 8419 8480 checksum = "d52efb639344a7c6adb8e62c6f3d2c19c001ff1b79a5041ba1c6ed42e19c6aa5" 8420 8481 dependencies = [ 8421 8482 "aws-lc-rs", 8422 - "base64", 8483 + "base64 0.22.1", 8423 8484 "bytes", 8424 8485 "futures-core", 8425 8486 "futures-sink", ··· 8893 8954 ] 8894 8955 8895 8956 [[package]] 8957 + name = "urlencoding" 8958 + version = "2.1.3" 8959 + source = "registry+https://github.com/rust-lang/crates.io-index" 8960 + checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" 8961 + 8962 + [[package]] 8896 8963 name = "utf-8" 8897 8964 version = "0.7.6" 8898 8965 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 9084 9151 "wasm-bindgen-futures", 9085 9152 "web-sys", 9086 9153 ] 9154 + 9155 + [[package]] 9156 + name = "wavefront_obj" 9157 + version = "11.0.0" 9158 + source = "registry+https://github.com/rust-lang/crates.io-index" 9159 + checksum = "934504ea79927c25f18e980053b64269490284129b327334fc2591daf1539b9c" 9087 9160 9088 9161 [[package]] 9089 9162 name = "web-sys" ··· 9715 9788 source = "registry+https://github.com/rust-lang/crates.io-index" 9716 9789 checksum = "728b7d4c8ec8d81cab295e0b5b8a4c263c0d41a785fb8f8c4df284e5411140a2" 9717 9790 dependencies = [ 9718 - "base64", 9791 + "base64 0.22.1", 9719 9792 "block2", 9720 9793 "cookie", 9721 9794 "crossbeam-channel",
+1 -1
Cargo.toml
··· 71 71 serde_json = "1.0" 72 72 thiserror = { workspace = true } 73 73 tracing = { version = "0.1", default-features = false, features = ["std"] } 74 - three-d-asset = { version = "0.10", default-features = false } 74 + three-d-asset = { version = "0.10", default-features = false, features = ["gltf", "obj", "data-url", "png"] } 75 75 stl_io = "0.11" 76 76 77 77 axum = { version = "0.8", optional = true, features = ["multipart"] }
public/models/box.bin

This is a binary file and will not be displayed.

public/models/box.glb

This is a binary file and will not be displayed.

+1
public/models/box.gltf
··· 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"}]}
+1
public/models/box_embedded.gltf
··· 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"}]}
public/models/box_textured.bin

This is a binary file and will not be displayed.

public/models/box_textured.glb

This is a binary file and will not be displayed.

+1
public/models/box_textured.gltf
··· 1 + {"asset":{"generator":"COLLADA2GLTF","version":"2.0"},"scene":0,"scenes":[{"nodes":[0]}],"nodes":[{"children":[1],"matrix":[1,0,0,0,0,0,-1,0,0,1,0,0,0,0,0,1]},{"mesh":0}],"meshes":[{"primitives":[{"attributes":{"NORMAL":1,"POSITION":2,"TEXCOORD_0":3},"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,1,1],"min":[-1,-1,-1],"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"},{"bufferView":2,"byteOffset":0,"componentType":5126,"count":24,"max":[6,1],"min":[0,0],"type":"VEC2"}],"materials":[{"pbrMetallicRoughness":{"baseColorTexture":{"index":0},"metallicFactor":0},"name":"Texture"}],"textures":[{"sampler":0,"source":0}],"images":[{"uri":"box_textured.png"}],"samplers":[{"magFilter":9729,"minFilter":9986,"wrapS":10497,"wrapT":10497}],"bufferViews":[{"buffer":0,"byteOffset":768,"byteLength":72,"target":34963},{"buffer":0,"byteOffset":0,"byteLength":576,"byteStride":12,"target":34962},{"buffer":0,"byteOffset":576,"byteLength":192,"byteStride":8,"target":34962},{"buffer":0,"byteOffset":840,"byteLength":3750}],"buffers":[{"byteLength":4592,"uri":"box_textured.bin"}]}
public/models/box_textured.png

This is a binary file and will not be displayed.

public/models/box_translated.glb

This is a binary file and will not be displayed.

+18
public/models/cube.obj
··· 1 + # Polymodel test fixture: convex quad cube. 2 + # 8 vertices, 6 quad faces. wavefront_obj fan-triangulates each convex quad into 3 + # two triangles at parse time, yielding 12 triangles total (AC.2). Unitless like 4 + # real-world OBJ exports, so the loader assumes millimetres. 5 + v 0 0 0 6 + v 1 0 0 7 + v 1 1 0 8 + v 0 1 0 9 + v 0 0 1 10 + v 1 0 1 11 + v 1 1 1 12 + v 0 1 1 13 + f 1 2 3 4 14 + f 5 6 7 8 15 + f 1 2 6 5 16 + f 3 4 8 7 17 + f 1 4 8 5 18 + f 2 3 7 6
+110
public/models/gen-derived-fixtures.py
··· 1 + #!/usr/bin/env python3 2 + # Provenance generator for the DERIVED glTF test fixtures in this directory. 3 + # 4 + # This script is NOT part of the build or test path. The fixtures it produces are 5 + # committed as static files and read directly by the Rust tests (mirroring the 6 + # existing STL fixture tests that read public/models/*.stl). It exists only so the 7 + # derived binary/minified fixtures have clear, reproducible provenance. 8 + # 9 + # ── Canonical base fixtures (downloaded once, committed as-is) ──────────────── 10 + # box.glb, box.gltf, box.bin <- Khronos glTF-Sample-Assets "Box" 11 + # box_textured.glb <- Khronos glTF-Sample-Assets "BoxTextured" 12 + # Fetched from: 13 + # https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Assets/main/Models/Box/glTF-Binary/Box.glb 14 + # https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Assets/main/Models/Box/glTF/Box.gltf 15 + # https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Assets/main/Models/Box/glTF/Box0.bin (saved as box.bin; box.gltf buffer uri patched Box0.bin -> box.bin) 16 + # https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Assets/main/Models/BoxTextured/glTF-Binary/BoxTextured.glb 17 + # 18 + # ── Derived fixtures this script (re)generates ──────────────────────────────── 19 + # box_translated.glb Box + node x-translation(10) composed into the existing 20 + # rotation matrix, AND its single primitive duplicated -> 21 + # 2 primitives. Covers AC.3a: non-identity, nonzero- 22 + # determinant transform (translation makes baking 23 + # observable via the AABB x-shift) + multi-primitive 24 + # flattening with per-primitive index offsets. 25 + # box_embedded.gltf box.gltf with its external buffer inlined as a base64 26 + # data URL. Covers AC.3b (embedded data-URL buffer). 27 + # box_textured.gltf box_textured.glb decomposed into separate companions: 28 + # + box_textured.bin the geometry buffer (external .bin) 29 + # + box_textured.png the embedded image extracted to an external PNG 30 + # Covers AC.3d (external texture companion, PNG decoded 31 + # then discarded) and multi-companion gathering. 32 + # 33 + # Re-run anytime with: python3 public/models/gen-derived-fixtures.py 34 + 35 + import base64 36 + import json 37 + import struct 38 + 39 + 40 + def read_glb(path): 41 + data = open(path, "rb").read() 42 + assert data[:4] == b"glTF", path 43 + total = struct.unpack("<I", data[8:12])[0] 44 + off = 12 45 + jlen = struct.unpack("<I", data[off : off + 4])[0] 46 + off += 8 47 + jstr = data[off : off + jlen] 48 + off += jlen 49 + blen = struct.unpack("<I", data[off : off + 4])[0] 50 + off += 8 51 + binb = data[off : off + blen] 52 + return json.loads(jstr), binb 53 + 54 + 55 + def write_glb(path, doc, binb): 56 + jstr = json.dumps(doc, separators=(",", ":")).encode() 57 + while len(jstr) % 4: 58 + jstr += b" " 59 + b = bytearray(binb) 60 + while len(b) % 4: 61 + b.append(0) 62 + total = 12 + 8 + len(jstr) + 8 + len(b) 63 + out = bytearray() 64 + out += b"glTF" + struct.pack("<I", 2) + struct.pack("<I", total) 65 + out += struct.pack("<I", len(jstr)) + b"JSON" + jstr 66 + out += struct.pack("<I", len(b)) + b"BIN\x00" + b 67 + open(path, "wb").write(out) 68 + 69 + 70 + def main(): 71 + # 1) box_translated.glb: keep Box rotation, compose translation[10,0,0], 72 + # duplicate the primitive. 73 + doc, binb = read_glb("box.glb") 74 + node = doc["nodes"][0] 75 + matrix = node["matrix"] 76 + matrix[12] = 10.0 # column-major: element 12 is the x-translation 77 + node["matrix"] = matrix 78 + prim = doc["meshes"][0]["primitives"][0] 79 + doc["meshes"][0]["primitives"] = [prim, dict(prim)] 80 + write_glb("box_translated.glb", doc, binb) 81 + 82 + # 2) box_embedded.gltf: inline box.bin as a base64 data URL. 83 + doc = json.load(open("box.gltf")) 84 + binb = open("box.bin", "rb").read() 85 + doc["buffers"][0]["uri"] = ( 86 + "data:application/octet-stream;base64," + base64.b64encode(binb).decode() 87 + ) 88 + json.dump(doc, open("box_embedded.gltf", "w"), separators=(",", ":")) 89 + 90 + # 3) box_textured.gltf + box_textured.bin + box_textured.png: decompose 91 + # box_textured.glb into external companions. 92 + doc, binb = read_glb("box_textured.glb") 93 + doc["buffers"][0]["uri"] = "box_textured.bin" 94 + img = doc["images"][0] 95 + if "bufferView" in img: 96 + view = doc["bufferViews"][img["bufferView"]] 97 + png = binb[view["byteOffset"] : view["byteOffset"] + view["byteLength"]] 98 + open("box_textured.png", "wb").write(png) 99 + img.clear() 100 + img["uri"] = "box_textured.png" 101 + elif img.get("uri", "").startswith("data:"): 102 + raw = base64.b64decode(img["uri"].partition(",")[2]) 103 + open("box_textured.png", "wb").write(raw) 104 + img["uri"] = "box_textured.png" 105 + open("box_textured.bin", "wb").write(binb) 106 + json.dump(doc, open("box_textured.gltf", "w"), separators=(",", ":")) 107 + 108 + 109 + if __name__ == "__main__": 110 + main()
+180
src/mesh/companions.rs
··· 1 + //! glTF companion-resource discovery and the SSRF/path-containment guard. 2 + //! 3 + //! `three-d-asset`'s glTF dependency discovery is private, so Polymodel scans the 4 + //! primary `.gltf` JSON itself for the non-`data:` URIs it must fetch (external buffers 5 + //! and images). The guard ensures only safe, in-directory, relative URIs are ever 6 + //! fetched: absolute (`https://…`, `/etc/…`), arbitrary-scheme (`file:`, `blob:`), 7 + //! protocol-relative (`//host`), backslash, and `..`-traversal URIs are rejected — 8 + //! including percent-encoded or backslash-obfuscated traversal. 9 + 10 + use serde_json::Value; 11 + 12 + /// Discover companion-resource URIs referenced by a `.gltf` document: every non-`data:` 13 + /// `buffers[].uri` and `images[].uri`. 14 + /// 15 + /// Returns empty if `primary` is not a JSON object (e.g. a GLB, which is binary), so 16 + /// callers can feed any primary without branching on format. 17 + pub(crate) fn discover_companion_uris(primary: &[u8]) -> Vec<String> { 18 + let Ok(Value::Object(root)) = serde_json::from_slice::<Value>(primary) else { 19 + return Vec::new(); 20 + }; 21 + let mut uris = Vec::new(); 22 + for key in ["buffers", "images"] { 23 + if let Some(Value::Array(items)) = root.get(key) { 24 + for item in items { 25 + if let Some(Value::String(uri)) = item.get("uri") 26 + && !uri.starts_with("data:") 27 + { 28 + uris.push(uri.clone()); 29 + } 30 + } 31 + } 32 + } 33 + uris 34 + } 35 + 36 + /// A companion URI is fetchable only if it is safe and relative: no scheme, no 37 + /// backslash, not an absolute or protocol-relative path, and its normalized path does 38 + /// not escape the asset's directory via `..`. 39 + /// 40 + /// Validation runs on a **percent-decoded** copy so encoded traversal (`%2e%2e`, 41 + /// `..%2f`, `%5c`) cannot bypass it; any residual `%` after one decode (double encoding 42 + /// like `%252e`) or invalid encoding is rejected. Only the decoded form is validated; 43 + /// callers keep the original raw URI as the companion key. 44 + pub(crate) fn is_safe_relative_uri(uri: &str) -> bool { 45 + // No literal backslash (a path separator on some platforms / URL contexts and a 46 + // common traversal obfuscation). 47 + if uri.contains('\\') { 48 + return false; 49 + } 50 + // Decode percent-encoding once. Invalid or non-UTF-8 -> reject. 51 + let Some(decoded) = percent_decode_once(uri) else { 52 + return false; 53 + }; 54 + // Reject anything still percent-encoded after one decode (double encoding such as 55 + // `%252e`, which decodes to `%2e` then to `.`). 56 + if decoded.contains('%') { 57 + return false; 58 + } 59 + // No scheme (a `:` anywhere after decoding), no absolute or protocol-relative path. 60 + if decoded.contains('\\') || decoded.contains(':') || decoded.starts_with('/') { 61 + return false; 62 + } 63 + // No traversal that escapes the base directory: track path depth — each real 64 + // segment descends, ".." ascends; dropping below 0 escapes the asset's directory. 65 + let mut depth = 0isize; 66 + for segment in decoded.split('/') { 67 + match segment { 68 + "" | "." => {} 69 + ".." => { 70 + depth -= 1; 71 + if depth < 0 { 72 + return false; 73 + } 74 + } 75 + _ => depth += 1, 76 + } 77 + } 78 + true 79 + } 80 + 81 + /// Percent-decode `%XX` sequences once. Returns `None` on a truncated or invalid 82 + /// escape. The result is a `String`, so non-UTF-8 decodings are rejected too. 83 + fn percent_decode_once(input: &str) -> Option<String> { 84 + let bytes = input.as_bytes(); 85 + let mut out = Vec::with_capacity(bytes.len()); 86 + let mut i = 0; 87 + while i < bytes.len() { 88 + if bytes[i] == b'%' { 89 + let h = hex_digit(bytes.get(i + 1).copied())?; 90 + let l = hex_digit(bytes.get(i + 2).copied())?; 91 + out.push((h << 4) | l); 92 + i += 3; 93 + } else { 94 + out.push(bytes[i]); 95 + i += 1; 96 + } 97 + } 98 + String::from_utf8(out).ok() 99 + } 100 + 101 + fn hex_digit(b: Option<u8>) -> Option<u8> { 102 + let b = b?; 103 + match b { 104 + b'0'..=b'9' => Some(b - b'0'), 105 + b'a'..=b'f' => Some(b - b'a' + 10), 106 + b'A'..=b'F' => Some(b - b'A' + 10), 107 + _ => None, 108 + } 109 + } 110 + 111 + #[cfg(test)] 112 + mod tests { 113 + use super::*; 114 + 115 + #[test] 116 + fn discovers_buffer_and_image_uris_skipping_data_urls() { 117 + let gltf = br#"{ 118 + "asset": {"version": "2.0"}, 119 + "buffers": [ 120 + {"uri": "cube.bin"}, 121 + {"uri": "data:application/octet-stream;base64,AAAA"} 122 + ], 123 + "images": [{"uri": "tex.png"}, {"bufferView": 0}] 124 + }"#; 125 + let mut uris = discover_companion_uris(gltf); 126 + uris.sort(); 127 + assert_eq!(uris, vec!["cube.bin".to_string(), "tex.png".to_string()]); 128 + } 129 + 130 + #[test] 131 + fn discovers_no_uris_from_non_json() { 132 + // A GLB is binary; JSON parse fails -> no companions (they are embedded). 133 + assert!(discover_companion_uris(b"glTF\x00\x00\x00\x00").is_empty()); 134 + assert!(discover_companion_uris(b"not json at all").is_empty()); 135 + } 136 + 137 + #[test] 138 + fn accepts_safe_relative_uris() { 139 + assert!(is_safe_relative_uri("cube.bin")); 140 + assert!(is_safe_relative_uri("textures/foo.png")); 141 + assert!(is_safe_relative_uri("a/b/c.bin")); 142 + assert!(is_safe_relative_uri("./cube.bin")); 143 + assert!(is_safe_relative_uri("a/../b.bin")); // net in-directory 144 + // A legitimate percent-encoded filename (space) is fine. 145 + assert!(is_safe_relative_uri("my%20file.bin")); 146 + } 147 + 148 + #[test] 149 + fn rejects_absolute_scheme_protocol_relative_and_traversal_uris() { 150 + // Absolute / arbitrary scheme. 151 + assert!(!is_safe_relative_uri("https://evil.example/x.bin")); 152 + assert!(!is_safe_relative_uri("http://evil.example/x.bin")); 153 + assert!(!is_safe_relative_uri("file:///etc/passwd")); 154 + assert!(!is_safe_relative_uri("blob:abc")); 155 + // Absolute path and protocol-relative. 156 + assert!(!is_safe_relative_uri("/etc/passwd")); 157 + assert!(!is_safe_relative_uri("//evil.example/x.bin")); 158 + // Literal traversal escaping the base directory. 159 + assert!(!is_safe_relative_uri("../escape.bin")); 160 + assert!(!is_safe_relative_uri("a/../../escape.bin")); 161 + } 162 + 163 + #[test] 164 + fn rejects_encoded_and_backslash_traversal() { 165 + // Percent-encoded dots/slashes. 166 + assert!(!is_safe_relative_uri("%2e%2e/escape.bin")); 167 + assert!(!is_safe_relative_uri("..%2fescape.bin")); 168 + assert!(!is_safe_relative_uri("%2e%2e%2fescape.bin")); 169 + assert!(!is_safe_relative_uri("textures/%2e%2e/%2e%2e/escape.bin")); 170 + // Double-encoded traversal (%252e -> %2e -> .). 171 + assert!(!is_safe_relative_uri("%252e%252e/escape.bin")); 172 + // Backslash as separator / obfuscation. 173 + assert!(!is_safe_relative_uri("..\\escape.bin")); 174 + assert!(!is_safe_relative_uri("textures\\..\\escape.bin")); 175 + assert!(!is_safe_relative_uri("%5c%5e..")); // encoded backslash 176 + // Truncated/invalid percent-encoding. 177 + assert!(!is_safe_relative_uri("ab%2")); 178 + assert!(!is_safe_relative_uri("ab%zz")); 179 + } 180 + }
+120 -13
src/mesh/contract.rs
··· 6 6 7 7 /// Source format of a loaded mesh. 8 8 /// 9 - /// STL today; OBJ/glTF/3MF direct upload is reserved for PM-23. There is intentionally 10 - /// no binary/ASCII sub-label for STL — reliably telling them apart is the hard part, and 9 + /// STL, OBJ, and glTF (GLB, embedded, or multi-file `.gltf` with external buffers) are 10 + /// parsed client-side from in-memory bytes. `Threemf` is **reserved**: 3MF cannot 11 + /// compile to `wasm32`, so it is served by a remote (server-side) parser that returns 12 + /// GLB — see the `RemoteMeshParser` drop-in contract (PM-49). There is intentionally no 13 + /// binary/ASCII sub-label for STL — reliably telling them apart is the hard part, and 11 14 /// both yield the same mesh. 12 15 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 13 16 pub enum MeshFormat { 14 17 /// Stereolithography (binary or ASCII). 15 18 Stl, 19 + /// Wavefront OBJ (convex polygons fan-triangulated; lines/points dropped). 20 + Obj, 21 + /// glTF 2.0 — GLB, embedded-base64 `.gltf`, or multi-file `.gltf` + companions. 22 + Gltf, 23 + /// 3D Manufacturing Format. Not parsed client-side; routed to a remote parser 24 + /// (PM-49). Kept here so format detection and the parser seam are remote-ready. 25 + Threemf, 26 + } 27 + 28 + impl MeshFormat { 29 + /// Detect a mesh format from a filename extension. 30 + /// 31 + /// The extension is taken from the final `.`-delimited segment, lower-cased, and 32 + /// matched with no leading dot. Returns `None` for empty or unknown extensions, 33 + /// which the viewer surfaces as [`crate::viewer::ViewerIssue::MeshUnavailable`]. 34 + pub fn from_extension(path: impl AsRef<str>) -> Option<Self> { 35 + let path = path.as_ref(); 36 + let ext = path.rsplit('.').next()?.to_ascii_lowercase(); 37 + // `rsplit('.').next()` yields the whole string when there is no dot; only a 38 + // genuine extension suffix (shorter than the input) is accepted. 39 + if ext.len() >= path.len() { 40 + return None; 41 + } 42 + match ext.as_str() { 43 + "stl" => Some(Self::Stl), 44 + "obj" => Some(Self::Obj), 45 + "gltf" | "glb" => Some(Self::Gltf), 46 + "3mf" => Some(Self::Threemf), 47 + _ => None, 48 + } 49 + } 16 50 } 17 51 18 52 /// A length unit. ··· 33 67 34 68 /// Unit handling for a loaded model. 35 69 /// 36 - /// `source` is the unit declared by the file; STL declares none, so this is 37 - /// [`LengthUnit::Unknown`]. `assumed` is the convention applied when the source is 38 - /// unknown — millimetres for Polymodel's STL ingestion. 70 + /// `source` is the unit declared by the file; STL/OBJ declare none, so they report 71 + /// [`LengthUnit::Unknown`] and rely on [`Units::unitless_assumed_mm`]. glTF's base unit 72 + /// is the metre, so a loaded glTF reports [`LengthUnit::Meter`] with no assumption. 39 73 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 40 74 pub struct Units { 41 - /// The unit declared by the file (STL: [`LengthUnit::Unknown`]). 75 + /// The unit declared by the file (STL/OBJ: [`LengthUnit::Unknown`]; glTF: [`LengthUnit::Meter`]). 42 76 pub source: LengthUnit, 43 77 /// The convention assumed when `source` is unknown. 44 78 pub assumed: Option<LengthUnit>, 45 79 } 46 80 47 81 impl Units { 48 - /// STL unit handling: source unknown, assume millimetres. 49 - pub fn stl_assumed_mm() -> Self { 82 + /// Unit handling for a unitless format (STL, OBJ): source unknown, assume millimetres. 83 + pub fn unitless_assumed_mm() -> Self { 50 84 Self { 51 85 source: LengthUnit::Unknown, 52 86 assumed: Some(LengthUnit::Millimeter), 53 87 } 54 88 } 89 + 90 + /// Unit handling for a format that declares its own unit (e.g. glTF → metres), 91 + /// so no assumption is applied. 92 + pub fn declared(source: LengthUnit) -> Self { 93 + Self { 94 + source, 95 + assumed: None, 96 + } 97 + } 55 98 } 56 99 57 100 /// Camera framing hint derived from a mesh's axis-aligned bounding box: the centre the ··· 66 109 67 110 /// A Polymodel mesh: a [`TriMesh`] plus format provenance and unit handling. 68 111 /// 69 - /// Normals are geometry-derived (via [`TriMesh::compute_normals`]); STL facet normals are 70 - /// not relied upon. Bounds and camera framing come from [`Self::aabb`]. 112 + /// Normals are geometry-derived (via [`TriMesh::compute_normals`]); source facet 113 + /// normals are not relied upon. Bounds and camera framing come from [`Self::aabb`]. 71 114 #[derive(Debug)] 72 115 pub struct ModelMesh { 73 116 /// The underlying triangle mesh. ··· 106 149 /// The bytes could not be parsed as a valid mesh of the expected format. 107 150 #[error("failed to parse STL: {0}")] 108 151 Parse(#[from] std::io::Error), 109 - /// The mesh parsed but is degenerate: no triangles, a non-finite position, or a 110 - /// point-sized bounding box. 152 + /// A format parsed by `three-d-asset` (OBJ/glTF) failed to decode, including a 153 + /// missing or undecodable companion resource (buffer/texture). 154 + #[error("mesh decode failed: {0}")] 155 + Decode(String), 156 + /// The mesh parsed but is degenerate: no triangles, an out-of-range index, a 157 + /// non-finite position, or a point-sized bounding box. 111 158 #[error("mesh is degenerate")] 112 159 Degenerate, 113 - /// The format is not yet supported (reserved for PM-23). 160 + /// The format is not supported by the active parser. `Threemf` returns this when no 161 + /// remote parser is wired in (PM-49 will supply one). 114 162 #[error("unsupported mesh format")] 115 163 UnsupportedFormat, 116 164 } 165 + 166 + #[cfg(test)] 167 + mod tests { 168 + use super::*; 169 + 170 + #[test] 171 + fn from_extension_maps_known_formats_case_insensitively() { 172 + assert_eq!( 173 + MeshFormat::from_extension("cube.stl"), 174 + Some(MeshFormat::Stl) 175 + ); 176 + assert_eq!( 177 + MeshFormat::from_extension("cube.obj"), 178 + Some(MeshFormat::Obj) 179 + ); 180 + assert_eq!( 181 + MeshFormat::from_extension("cube.gltf"), 182 + Some(MeshFormat::Gltf) 183 + ); 184 + assert_eq!( 185 + MeshFormat::from_extension("cube.glb"), 186 + Some(MeshFormat::Gltf) 187 + ); 188 + assert_eq!( 189 + MeshFormat::from_extension("cube.3mf"), 190 + Some(MeshFormat::Threemf) 191 + ); 192 + // Mixed case and leading dots. 193 + assert_eq!( 194 + MeshFormat::from_extension("CUBE.GLB"), 195 + Some(MeshFormat::Gltf) 196 + ); 197 + assert_eq!(MeshFormat::from_extension(".OBJ"), Some(MeshFormat::Obj)); 198 + assert_eq!( 199 + MeshFormat::from_extension("/models/Body_F_Chest-v4.STL"), 200 + Some(MeshFormat::Stl) 201 + ); 202 + } 203 + 204 + #[test] 205 + fn from_extension_handles_dot_and_extension_edge_cases() { 206 + // Trailing/leading dot and dot-only strings have no real extension. 207 + assert_eq!(MeshFormat::from_extension("cube."), None); 208 + assert_eq!(MeshFormat::from_extension("."), None); 209 + // Only the final segment is the extension. 210 + assert_eq!(MeshFormat::from_extension("archive.tar.gz"), None); 211 + assert_eq!( 212 + MeshFormat::from_extension("model.backup.GLB"), 213 + Some(MeshFormat::Gltf) 214 + ); 215 + } 216 + 217 + #[test] 218 + fn from_extension_rejects_unknown_and_extensionless() { 219 + assert_eq!(MeshFormat::from_extension("cube.ply"), None); 220 + assert_eq!(MeshFormat::from_extension("cube"), None); 221 + assert_eq!(MeshFormat::from_extension(""), None); 222 + } 223 + }
+240
src/mesh/gltf.rs
··· 1 + //! glTF/GLB ingestion: parse in-memory bytes (+ companions) into a [`ModelMesh`] via 2 + //! `three-d-asset`. 3 + //! 4 + //! GLB BIN chunks and embedded-base64 `data:` buffers resolve from the primary bytes 5 + //! alone. A multi-file `.gltf` resolves its external `.bin`/texture companions from the 6 + //! source's [`MeshResources`], inserted under their exact raw URI so `three-d-asset`'s 7 + //! `base_path.join(uri)` lookup is exact (it is not asked to discover dependencies 8 + //! itself). Textures are decoded during deserialize (the `image`/`png` features are on) 9 + //! and then discarded — [`TriMesh`](three_d_asset::TriMesh) carries no materials — but a 10 + //! referenced-but-missing or undecodable texture fails the whole parse. glTF's base unit 11 + //! is the metre. No filesystem or network access, so this is safe from 12 + //! `wasm32-unknown-unknown`. 13 + 14 + use three_d_asset::io; 15 + 16 + use crate::mesh::contract::{LengthUnit, MeshFormat, MeshLoadError, ModelMesh, Units}; 17 + use crate::mesh::parser::MeshSource; 18 + 19 + /// Parse a glTF/GLB asset (plus companions) into a [`ModelMesh`]. 20 + /// 21 + /// The GLB magic (`b"glTF"`) is sniffed to pick the `.glb` vs `.gltf` key. Node world 22 + /// transforms are baked into positions by the shared flattener; geometry normals are 23 + /// derived by the shared validator. 24 + pub(crate) fn load_gltf(source: &MeshSource<'_>) -> Result<ModelMesh, MeshLoadError> { 25 + if source.primary.is_empty() { 26 + return Err(MeshLoadError::Empty); 27 + } 28 + let key = if source.primary.starts_with(b"glTF") { 29 + "asset.glb" 30 + } else { 31 + "asset.gltf" 32 + }; 33 + let mut assets = io::RawAssets::new(); 34 + assets.insert(key, source.primary.to_vec()); 35 + for (uri, bytes) in source.resources.iter() { 36 + // Insert under the exact raw URI so `base_path.join(uri)` is an exact lookup, 37 + // never relying on `RawAssets`' substring-`contains` fallback. 38 + assets.insert(uri, bytes.to_vec()); 39 + } 40 + let model: three_d_asset::Model = assets 41 + .deserialize(key) 42 + .map_err(|e| MeshLoadError::Decode(e.to_string()))?; 43 + let mut trimesh = crate::mesh::flatten_model(model); 44 + crate::mesh::validate(&mut trimesh)?; 45 + Ok(ModelMesh { 46 + trimesh, 47 + format: MeshFormat::Gltf, 48 + units: Units::declared(LengthUnit::Meter), 49 + }) 50 + } 51 + 52 + #[cfg(test)] 53 + mod tests { 54 + use super::load_gltf; 55 + use crate::mesh::contract::{LengthUnit, MeshFormat, MeshLoadError, ModelMesh, Units}; 56 + use crate::mesh::parser::{MeshResources, MeshSource}; 57 + 58 + fn fixture(name: &str) -> Vec<u8> { 59 + let path = format!("{}/public/models/{name}", env!("CARGO_MANIFEST_DIR")); 60 + std::fs::read(&path) 61 + .unwrap_or_else(|_| panic!("glTF fixture {name} should be committed at {path}")) 62 + } 63 + 64 + /// Read named companion files into `(raw_uri, bytes)` pairs. 65 + fn companions(files: &[(&str, &str)]) -> Vec<(String, Vec<u8>)> { 66 + files 67 + .iter() 68 + .map(|(uri, file)| (uri.to_string(), fixture(file))) 69 + .collect() 70 + } 71 + 72 + fn assert_meter_units(model: &ModelMesh) { 73 + assert_eq!( 74 + model.units, 75 + Units { 76 + source: LengthUnit::Meter, 77 + assumed: None, 78 + } 79 + ); 80 + } 81 + 82 + #[test] 83 + fn parses_self_contained_glb() { 84 + let primary = fixture("box.glb"); 85 + let source = MeshSource { 86 + format: MeshFormat::Gltf, 87 + primary: &primary, 88 + resources: MeshResources::empty(), 89 + }; 90 + let model = load_gltf(&source).expect("box.glb should parse"); 91 + assert_eq!(model.format, MeshFormat::Gltf); 92 + assert_meter_units(&model); 93 + assert_eq!(model.trimesh.triangle_count(), 12); 94 + assert!( 95 + model.trimesh.normals.is_some(), 96 + "geometry normals should be populated" 97 + ); 98 + } 99 + 100 + #[test] 101 + fn parses_glb_with_baked_transform_and_multiple_primitives() { 102 + // box_translated.glb: two primitives (12 tris each) under a node transform that 103 + // rotates and translates +10 on X. After flattening + baking, the unit cube 104 + // (centred at the origin locally) has its AABB centre shifted to x ~= 10. 105 + let primary = fixture("box_translated.glb"); 106 + let source = MeshSource { 107 + format: MeshFormat::Gltf, 108 + primary: &primary, 109 + resources: MeshResources::empty(), 110 + }; 111 + let model = load_gltf(&source).expect("box_translated.glb should parse"); 112 + assert_eq!( 113 + model.trimesh.triangle_count(), 114 + 24, 115 + "two primitives flatten to 24 triangles" 116 + ); 117 + let center = model.aabb().center(); 118 + assert!( 119 + (center.x - 10.0).abs() < 0.5, 120 + "node translation (+10 X) is baked into world-space positions: got center.x = {}", 121 + center.x 122 + ); 123 + } 124 + 125 + #[test] 126 + fn parses_embedded_data_url_gltf() { 127 + let primary = fixture("box_embedded.gltf"); 128 + let source = MeshSource { 129 + format: MeshFormat::Gltf, 130 + primary: &primary, 131 + resources: MeshResources::empty(), 132 + }; 133 + let model = load_gltf(&source).expect("embedded-base64 .gltf should parse"); 134 + assert_meter_units(&model); 135 + assert_eq!(model.trimesh.triangle_count(), 12); 136 + } 137 + 138 + #[test] 139 + fn parses_external_buffer_gltf() { 140 + let primary = fixture("box.gltf"); 141 + let comps = companions(&[("box.bin", "box.bin")]); 142 + let source = MeshSource { 143 + format: MeshFormat::Gltf, 144 + primary: &primary, 145 + resources: MeshResources::new(&comps), 146 + }; 147 + let model = load_gltf(&source).expect("external-buffer .gltf should parse"); 148 + assert_meter_units(&model); 149 + assert_eq!(model.trimesh.triangle_count(), 12); 150 + } 151 + 152 + #[test] 153 + fn external_buffer_resolves_exact_companion_not_substring_fallback() { 154 + // `RawAssets` falls back to substring matching when an exact key is absent. This 155 + // proves the exact raw-URI key wins even when a second companion key contains it 156 + // as a substring: the decoy bytes (garbage) must not be substituted for the real 157 + // buffer, so the geometry still parses to 12 triangles. 158 + let primary = fixture("box.gltf"); 159 + let real = fixture("box.bin"); 160 + let comps = vec![ 161 + ("box.bin".to_string(), real), 162 + ( 163 + "box.bin.bak".to_string(), 164 + b"garbage that is not a glTF buffer".to_vec(), 165 + ), 166 + ]; 167 + let source = MeshSource { 168 + format: MeshFormat::Gltf, 169 + primary: &primary, 170 + resources: MeshResources::new(&comps), 171 + }; 172 + let model = load_gltf(&source).expect("exact box.bin must win over box.bin.bak"); 173 + assert_eq!(model.trimesh.triangle_count(), 12); 174 + } 175 + 176 + #[test] 177 + fn parses_textured_gltf_with_external_png() { 178 + // The PNG texture is decoded during deserialize then discarded by TriMesh; it 179 + // must still be present and decodable or the whole parse fails. 180 + let primary = fixture("box_textured.gltf"); 181 + let comps = companions(&[ 182 + ("box_textured.bin", "box_textured.bin"), 183 + ("box_textured.png", "box_textured.png"), 184 + ]); 185 + let source = MeshSource { 186 + format: MeshFormat::Gltf, 187 + primary: &primary, 188 + resources: MeshResources::new(&comps), 189 + }; 190 + let model = load_gltf(&source).expect("textured .gltf should parse"); 191 + assert_meter_units(&model); 192 + assert_eq!(model.trimesh.triangle_count(), 12); 193 + } 194 + 195 + #[test] 196 + fn error_missing_buffer_companion() { 197 + // box.gltf references an external buffer that is not provided. 198 + let primary = fixture("box.gltf"); 199 + let source = MeshSource { 200 + format: MeshFormat::Gltf, 201 + primary: &primary, 202 + resources: MeshResources::empty(), 203 + }; 204 + assert!(matches!(load_gltf(&source), Err(MeshLoadError::Decode(_)))); 205 + } 206 + 207 + #[test] 208 + fn error_missing_texture_companion() { 209 + // Buffer present, but the referenced PNG texture is missing -> decode fails 210 + // (three-d-asset requires every material-referenced texture). 211 + let primary = fixture("box_textured.gltf"); 212 + let comps = companions(&[("box_textured.bin", "box_textured.bin")]); 213 + let source = MeshSource { 214 + format: MeshFormat::Gltf, 215 + primary: &primary, 216 + resources: MeshResources::new(&comps), 217 + }; 218 + assert!(matches!(load_gltf(&source), Err(MeshLoadError::Decode(_)))); 219 + } 220 + 221 + #[test] 222 + fn error_empty() { 223 + let source = MeshSource { 224 + format: MeshFormat::Gltf, 225 + primary: &[], 226 + resources: MeshResources::empty(), 227 + }; 228 + assert!(matches!(load_gltf(&source), Err(MeshLoadError::Empty))); 229 + } 230 + 231 + #[test] 232 + fn error_parse_garbage() { 233 + let source = MeshSource { 234 + format: MeshFormat::Gltf, 235 + primary: b"{ not valid gltf json", 236 + resources: MeshResources::empty(), 237 + }; 238 + assert!(matches!(load_gltf(&source), Err(MeshLoadError::Decode(_)))); 239 + } 240 + }
+258 -9
src/mesh/mod.rs
··· 1 - //! Renderer-agnostic mesh contract and STL ingestion. 1 + //! Renderer-agnostic mesh contract and multi-format ingestion. 2 2 //! 3 3 //! Defines [`ModelMesh`] (in [`contract`]), a thin Polymodel wrapper over 4 4 //! [`three_d_asset::TriMesh`] that carries format provenance, unit assumptions, and a 5 - //! camera framing hint. STL is parsed from in-memory bytes (no filesystem), so this 6 - //! module compiles to `wasm32-unknown-unknown` and can be consumed by any future 7 - //! renderer ([format ingestion research](https://radiant-industries.atlassian.net/wiki/spaces/PM/pages/131340)). 5 + //! camera framing hint. STL, OBJ, and glTF (GLB, embedded-base64, or multi-file `.gltf` 6 + //! with external companions) are parsed client-side from in-memory bytes via 7 + //! `three-d-asset`, so this module compiles to `wasm32-unknown-unknown`. 3MF cannot 8 + //! target `wasm32` and is routed to a remote parser (PM-49); the parser seam in 9 + //! [`parser`] is remote-ready. See the [PM-14 research][1] and the [PM-23 plan][2]. 8 10 //! 9 - //! Scope is STL-only for now; OBJ/glTF/3MF direct upload is tracked in PM-23. 11 + //! [1]: https://radiant-industries.atlassian.net/wiki/spaces/PM/pages/131340 12 + //! [2]: https://radiant-industries.atlassian.net/browse/PM-23 10 13 11 - // This module is a renderer-agnostic ingestion foundation: it is consumed by the 12 - // Dioxus viewer scaffold and the renderer, which are not yet wired into the 13 - // binary's main path. Its public surface is therefore intentionally unused at 14 - // present, so dead-code analysis is relaxed here. 14 + // This module is a renderer-agnostic ingestion foundation. Its public surface is 15 + // consumed by the viewer and renderer; some items are exercised only through 16 + // format-specific parsers and not yet on the binary's main path, so dead-code analysis 17 + // is relaxed here. 15 18 #![allow(dead_code)] 16 19 20 + pub mod companions; 17 21 pub mod contract; 22 + pub mod gltf; 23 + pub mod obj; 24 + pub mod parser; 18 25 pub mod stl; 26 + 27 + use three_d_asset::{Geometry, Indices, Mat4, Positions, TriMesh, Vec3}; 28 + 29 + use crate::mesh::contract::MeshLoadError; 30 + 31 + /// Validate and finalize a parsed [`TriMesh`] shared by every loader. 32 + /// 33 + /// Runs the geometry checks lifted from the STL path so STL, OBJ, and glTF all enforce 34 + /// the same invariants, then derives geometry normals and normalizes positions to F32: 35 + /// 36 + /// * no triangles, an out-of-range index, a non-finite position, or a point-sized 37 + /// bounding box → [`MeshLoadError::Degenerate`]; 38 + /// * normals are geometry-derived (source facet/vertex normals are not relied upon); 39 + /// * a vertex touched only by zero-area triangles gets a NaN normal, which is scrubbed to zero; 40 + /// * OBJ's `Positions::F64` is downcast to `Positions::F32` (the renderer invariant). 41 + /// 42 + /// Call this **after** any per-format transform baking, so the AABB and normals reflect 43 + /// world-space positions. 44 + pub(crate) fn validate(trimesh: &mut TriMesh) -> Result<(), MeshLoadError> { 45 + if trimesh.triangle_count() == 0 { 46 + return Err(MeshLoadError::Degenerate); 47 + } 48 + // Reject non-finite positions (F32 or F64) up front so AABB / normal math can't be 49 + // poisoned. 50 + if !positions_all_finite(&trimesh.positions) { 51 + return Err(MeshLoadError::Degenerate); 52 + } 53 + // Reject out-of-range indices before compute_normals, which indexes the position 54 + // buffer directly and would panic on malformed geometry. 55 + let position_count = trimesh.positions.len() as u32; 56 + if trimesh 57 + .indices 58 + .to_u32() 59 + .is_some_and(|indices| indices.iter().any(|&i| i >= position_count)) 60 + { 61 + return Err(MeshLoadError::Degenerate); 62 + } 63 + // Reject a point-sized bounding box (all three extents ~0). A flat plate or line — 64 + // zero extent on one or two axes — is valid geometry and must parse. 65 + let size = trimesh.compute_aabb().size(); 66 + if size.x.abs() < f32::EPSILON && size.y.abs() < f32::EPSILON && size.z.abs() < f32::EPSILON { 67 + return Err(MeshLoadError::Degenerate); 68 + } 69 + // Geometry-derived normals; source normals are not relied upon. 70 + trimesh.compute_normals(); 71 + // `compute_normals` normalizes each per-vertex face-normal sum. A vertex touched 72 + // only by zero-area (collinear) triangles has a zero sum and normalizes to NaN. Such 73 + // geometry is allowed to parse, so replace any non-finite normal with zero — the 74 + // normal is genuinely undefined there, but the mesh must never carry NaN downstream. 75 + if let Some(normals) = trimesh.normals.as_mut() { 76 + for n in normals { 77 + if !n.x.is_finite() || !n.y.is_finite() || !n.z.is_finite() { 78 + *n = Vec3::new(0.0, 0.0, 0.0); 79 + } 80 + } 81 + } 82 + // Normalize OBJ F64 positions to F32 (STL/glTF are already F32). 83 + if let Positions::F64(f64s) = &trimesh.positions { 84 + trimesh.positions = Positions::F32( 85 + f64s.iter() 86 + .map(|v| Vec3::new(v.x as f32, v.y as f32, v.z as f32)) 87 + .collect(), 88 + ); 89 + } 90 + Ok(()) 91 + } 92 + 93 + fn positions_all_finite(positions: &Positions) -> bool { 94 + match positions { 95 + Positions::F32(vs) => vs 96 + .iter() 97 + .all(|v| v.x.is_finite() && v.y.is_finite() && v.z.is_finite()), 98 + Positions::F64(vs) => vs 99 + .iter() 100 + .all(|v| v.x.is_finite() && v.y.is_finite() && v.z.is_finite()), 101 + } 102 + } 103 + 104 + /// Flatten a parsed `three-d-asset` [`Model`](three_d_asset::Model) into a single 105 + /// [`TriMesh`], shared by the OBJ and glTF loaders. 106 + /// 107 + /// `three-d-asset`'s `Scene → Model` conversion accumulates each glTF node chain's world 108 + /// transform onto [`Primitive::transformation`](three_d_asset::Primitive) while leaving 109 + /// positions in local space, so each geometry is baked here (local → world) as it is 110 + /// folded into one mesh with per-primitive index offsets. Non-triangle geometry (e.g. 111 + /// point clouds) is dropped — Polymodel renders triangle meshes only. All positions are 112 + /// unified to F32 (OBJ arrives as F64); [`validate`] recomputes normals afterwards. 113 + pub(crate) fn flatten_model(model: three_d_asset::Model) -> TriMesh { 114 + let mut positions: Vec<Vec3> = Vec::new(); 115 + let mut indices: Vec<u32> = Vec::new(); 116 + for primitive in model.geometries { 117 + let Geometry::Triangles(mesh) = primitive.geometry else { 118 + continue; 119 + }; 120 + let base = positions.len() as u32; 121 + for p in mesh.positions.to_f32() { 122 + positions.push(transform_point(primitive.transformation, p)); 123 + } 124 + match mesh.indices { 125 + Indices::U32(values) => indices.extend(values.into_iter().map(|i| i + base)), 126 + Indices::U16(values) => indices.extend(values.into_iter().map(|i| i as u32 + base)), 127 + Indices::U8(values) => indices.extend(values.into_iter().map(|i| i as u32 + base)), 128 + // Non-indexed geometry: synthesize a sequential index per vertex. 129 + Indices::None => indices.extend((0..mesh.positions.len() as u32).map(|i| i + base)), 130 + } 131 + } 132 + TriMesh { 133 + positions: Positions::F32(positions), 134 + indices: Indices::U32(indices), 135 + normals: None, 136 + tangents: None, 137 + uvs: None, 138 + colors: None, 139 + } 140 + } 141 + 142 + /// Apply an affine node transform (`Mat4`, cgmath column-major) to a point. 143 + /// 144 + /// glTF node transforms are affine, so the homogeneous row is `(0, 0, 0, 1)` and the 145 + /// translation lives in the fourth column (`m.w`); there is no perspective divide. 146 + fn transform_point(m: Mat4, p: Vec3) -> Vec3 { 147 + Vec3::new( 148 + p.x * m.x.x + p.y * m.y.x + p.z * m.z.x + m.w.x, 149 + p.x * m.x.y + p.y * m.y.y + p.z * m.z.y + m.w.y, 150 + p.x * m.x.z + p.y * m.y.z + p.z * m.z.z + m.w.z, 151 + ) 152 + } 153 + 154 + #[cfg(test)] 155 + mod tests { 156 + use super::*; 157 + use three_d_asset::{Indices, SquareMatrix}; 158 + 159 + /// Build a `TriMesh` from explicit F32 positions and a flat index list. 160 + fn trimesh(positions: &[[f32; 3]], indices: &[u32]) -> TriMesh { 161 + TriMesh { 162 + positions: Positions::F32( 163 + positions 164 + .iter() 165 + .map(|p| Vec3::new(p[0], p[1], p[2])) 166 + .collect(), 167 + ), 168 + indices: Indices::U32(indices.to_vec()), 169 + normals: None, 170 + tangents: None, 171 + uvs: None, 172 + colors: None, 173 + } 174 + } 175 + 176 + #[test] 177 + fn validate_rejects_zero_triangles() { 178 + let mut m = trimesh(&[[0.0, 0.0, 0.0]], &[]); 179 + assert!(matches!(validate(&mut m), Err(MeshLoadError::Degenerate))); 180 + } 181 + 182 + #[test] 183 + fn validate_rejects_non_finite_position() { 184 + let mut m = trimesh( 185 + &[[f32::INFINITY, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], 186 + &[0, 1, 2], 187 + ); 188 + assert!(matches!(validate(&mut m), Err(MeshLoadError::Degenerate))); 189 + } 190 + 191 + #[test] 192 + fn validate_rejects_out_of_range_indices() { 193 + // Index 99 is beyond the 3 positions; compute_normals would panic, so validate 194 + // must surface a clean load error instead. 195 + let mut m = trimesh( 196 + &[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], 197 + &[0, 1, 99], 198 + ); 199 + assert!(matches!(validate(&mut m), Err(MeshLoadError::Degenerate))); 200 + } 201 + 202 + #[test] 203 + fn validate_rejects_point_sized_aabb() { 204 + let mut m = trimesh(&[[5.0, 5.0, 5.0]; 3], &[0, 1, 2]); 205 + assert!(matches!(validate(&mut m), Err(MeshLoadError::Degenerate))); 206 + } 207 + 208 + #[test] 209 + fn validate_derives_normals_and_normalizes_f64() { 210 + let mut m = TriMesh { 211 + positions: Positions::F64( 212 + [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]] 213 + .iter() 214 + .map(|p| three_d_asset::Vector3::new(p[0], p[1], p[2])) 215 + .collect(), 216 + ), 217 + indices: Indices::U32(vec![0, 1, 2]), 218 + normals: None, 219 + tangents: None, 220 + uvs: None, 221 + colors: None, 222 + }; 223 + validate(&mut m).expect("a valid triangle parses"); 224 + assert!(matches!(m.positions, Positions::F32(_)), "F64 -> F32"); 225 + let normals = m.normals.expect("normals populated"); 226 + assert_eq!(normals.len(), 3); 227 + assert!( 228 + normals 229 + .iter() 230 + .all(|n| n.x.is_finite() && n.y.is_finite() && n.z.is_finite()) 231 + ); 232 + } 233 + 234 + #[test] 235 + fn flatten_bakes_translation_into_positions() { 236 + // Two identity-transform triangles -> 6 positions, 2 triangles, no offset drift. 237 + let model = three_d_asset::Model { 238 + name: "test".into(), 239 + materials: Vec::new(), 240 + geometries: vec![ 241 + three_d_asset::Primitive { 242 + name: "a".into(), 243 + transformation: Mat4::identity(), 244 + animations: Vec::new(), 245 + geometry: Geometry::Triangles(trimesh( 246 + &[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], 247 + &[0, 1, 2], 248 + )), 249 + material_index: None, 250 + }, 251 + three_d_asset::Primitive { 252 + name: "b".into(), 253 + transformation: Mat4::identity(), 254 + animations: Vec::new(), 255 + geometry: Geometry::Triangles(trimesh( 256 + &[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], 257 + &[0, 1, 2], 258 + )), 259 + material_index: None, 260 + }, 261 + ], 262 + }; 263 + let flat = flatten_model(model); 264 + assert_eq!(flat.positions.len(), 6); 265 + assert_eq!(flat.indices.to_u32().unwrap(), vec![0, 1, 2, 3, 4, 5]); 266 + } 267 + }
+102
src/mesh/obj.rs
··· 1 + //! OBJ ingestion: parse in-memory bytes into a [`ModelMesh`] via `three-d-asset`. 2 + //! 3 + //! `three-d-asset` parses Wavefront OBJ through `wavefront_obj`, which 4 + //! fan-triangulates convex polygons at parse time and drops Line/Point primitives; 5 + //! concave polygons are out of scope. OBJ carries no unit, so the loader assumes 6 + //! millimetres. There is no filesystem or network access — an `.mtl` material sidecar is 7 + //! not fetched (out of scope) — so this is safe to call from a `wasm32-unknown-unknown` 8 + //! build. 9 + 10 + use three_d_asset::io; 11 + 12 + use crate::mesh::contract::{MeshFormat, MeshLoadError, ModelMesh, Units}; 13 + 14 + /// Parse OBJ bytes into a [`ModelMesh`]. 15 + /// 16 + /// Convex polygons are fan-triangulated; Line and Point primitives are dropped. OBJ is 17 + /// unitless, so the result assumes millimetres. Positions are normalized to F32 by the 18 + /// shared validator (OBJ arrives as F64). 19 + pub fn load_obj(bytes: &[u8]) -> Result<ModelMesh, MeshLoadError> { 20 + if bytes.is_empty() { 21 + return Err(MeshLoadError::Empty); 22 + } 23 + let model: three_d_asset::Model = io::deserialize("asset.obj", bytes.to_vec()) 24 + .map_err(|e| MeshLoadError::Decode(e.to_string()))?; 25 + let mut trimesh = crate::mesh::flatten_model(model); 26 + crate::mesh::validate(&mut trimesh)?; 27 + Ok(ModelMesh { 28 + trimesh, 29 + format: MeshFormat::Obj, 30 + units: Units::unitless_assumed_mm(), 31 + }) 32 + } 33 + 34 + #[cfg(test)] 35 + mod tests { 36 + use super::load_obj; 37 + use crate::mesh::contract::{LengthUnit, MeshFormat, MeshLoadError, Units}; 38 + use three_d_asset::Positions; 39 + 40 + fn fixture(name: &str) -> Vec<u8> { 41 + let path = format!("{}/public/models/{name}", env!("CARGO_MANIFEST_DIR")); 42 + std::fs::read(&path) 43 + .unwrap_or_else(|_| panic!("OBJ fixture {name} should be committed at {path}")) 44 + } 45 + 46 + #[test] 47 + fn parses_quad_cube_fixture() { 48 + let model = load_obj(&fixture("cube.obj")).expect("cube.obj should parse"); 49 + assert_eq!(model.format, MeshFormat::Obj); 50 + assert_eq!( 51 + model.units, 52 + Units { 53 + source: LengthUnit::Unknown, 54 + assumed: Some(LengthUnit::Millimeter), 55 + } 56 + ); 57 + // 6 convex quads fan-triangulate to 12 triangles. 58 + assert_eq!(model.trimesh.triangle_count(), 12); 59 + // OBJ's F64 positions are normalized to F32. 60 + assert!(matches!(model.trimesh.positions, Positions::F32(_))); 61 + let normals = model 62 + .trimesh 63 + .normals 64 + .as_ref() 65 + .expect("geometry normals should be populated"); 66 + assert_eq!(normals.len(), model.trimesh.positions.len()); 67 + assert!( 68 + normals 69 + .iter() 70 + .all(|n| n.x.is_finite() && n.y.is_finite() && n.z.is_finite()), 71 + "normals must be finite" 72 + ); 73 + } 74 + 75 + #[test] 76 + fn error_empty() { 77 + assert!(matches!(load_obj(&[]), Err(MeshLoadError::Empty))); 78 + } 79 + 80 + #[test] 81 + fn error_parse_garbage() { 82 + let garbage = b"this is definitely not a valid OBJ file !@#$"; 83 + assert!(matches!(load_obj(garbage), Err(MeshLoadError::Decode(_)))); 84 + } 85 + 86 + #[test] 87 + fn error_zero_triangles() { 88 + // Vertices but no faces: parses to an empty mesh. 89 + let verts_only = b"v 0 0 0\nv 1 0 0\nv 0 1 0\n"; 90 + assert!(matches!( 91 + load_obj(verts_only), 92 + Err(MeshLoadError::Degenerate) 93 + )); 94 + } 95 + 96 + #[test] 97 + fn error_point_sized_aabb() { 98 + // A face collapsed to a single point has a point-sized AABB. 99 + let point = b"v 5 5 5\nf 1 1 1\n"; 100 + assert!(matches!(load_obj(point), Err(MeshLoadError::Degenerate))); 101 + } 102 + }
+255
src/mesh/parser.rs
··· 1 + //! Parser seam — shared parse boundary, format dispatch, and the remote drop-in hook. 2 + //! 3 + //! The data types here ([`MeshResources`], [`MeshSource`]) are the only thing that 4 + //! crosses the parse boundary. The dispatcher [`load_mesh`] is synchronous; the viewer is 5 + //! the sole async layer (it prefetches glTF companions). 3MF cannot compile to `wasm32`, 6 + //! so it is served by a remote parser (PM-49); [`RemoteMeshParser`] + the `Threemf` arm 7 + //! are the drop-in site, and wiring them in does not change the [`MeshParser`] trait 8 + //! shape or the viewer pipeline. 9 + 10 + use crate::mesh::contract::{MeshFormat, MeshLoadError, ModelMesh}; 11 + 12 + /// Borrowed views of a mesh's companion resources (external glTF buffers/textures), 13 + /// keyed by their exact raw URI as referenced by the primary asset. 14 + /// 15 + /// Empty for STL, OBJ, GLB, and embedded-base64 `.gltf`. The viewer keeps an owned 16 + /// companion store alive across the async parse and borrows it here; [`crate::mesh::gltf`] 17 + /// inserts each entry into a `three-d-asset` `RawAssets` under the exact same URI so its 18 + /// `base_path.join(uri)` lookup is an exact match (avoiding `RawAssets`' substring 19 + /// fallback foot-gun). 20 + pub(crate) struct MeshResources<'a> { 21 + companions: &'a [(String, Vec<u8>)], 22 + } 23 + 24 + impl<'a> MeshResources<'a> { 25 + /// No companion resources. 26 + pub(crate) fn empty() -> MeshResources<'static> { 27 + MeshResources { companions: &[] } 28 + } 29 + 30 + /// Wrap an owned companion store `(raw_uri, bytes)`. 31 + pub(crate) fn new(companions: &'a [(String, Vec<u8>)]) -> Self { 32 + MeshResources { companions } 33 + } 34 + 35 + /// Iterate `(raw_uri, bytes)` pairs. 36 + pub(crate) fn iter(&self) -> impl Iterator<Item = (&str, &[u8])> { 37 + self.companions 38 + .iter() 39 + .map(|(uri, bytes)| (uri.as_str(), bytes.as_slice())) 40 + } 41 + } 42 + 43 + /// The bytes needed to parse one mesh: its primary asset plus any companion resources. 44 + /// 45 + /// Passed by reference through the parse boundary so the trait shape stays fixed for the 46 + /// remote-parser drop-in (PM-49). 47 + pub(crate) struct MeshSource<'a> { 48 + /// Detected source format. 49 + pub format: MeshFormat, 50 + /// Primary asset bytes (the `.stl`/`.obj`/`.gltf`/`.glb`/`.3mf` itself). 51 + pub primary: &'a [u8], 52 + /// Companion resource bytes, keyed by exact raw URI. 53 + pub resources: MeshResources<'a>, 54 + } 55 + 56 + /// Synchronously parse a [`MeshSource`] into a [`ModelMesh`], dispatching by format. 57 + /// 58 + /// `Stl`/`Obj`/`Gltf` are parsed client-side from the in-memory bytes. `Threemf` is not 59 + /// supported here — it is routed to a remote parser (PM-49) by [`ProductionMeshParser`]. 60 + pub(crate) fn load_mesh(source: &MeshSource<'_>) -> Result<ModelMesh, MeshLoadError> { 61 + match source.format { 62 + MeshFormat::Stl => crate::mesh::stl::load_stl(source.primary), 63 + MeshFormat::Obj => crate::mesh::obj::load_obj(source.primary), 64 + MeshFormat::Gltf => crate::mesh::gltf::load_gltf(source), 65 + // PM-49: ThreemfRemoteParser converts to GLB, then re-enters via Gltf. 66 + MeshFormat::Threemf => Err(MeshLoadError::UnsupportedFormat), 67 + } 68 + } 69 + 70 + /// Parse boundary: turn a [`MeshSource`] into a [`ModelMesh`]. Generic for static 71 + /// dispatch (no boxed `dyn`); tests inject a counter to assert one parse per load. 72 + /// 73 + /// **Drop-in contract (PM-49):** the only changes are a `RemoteMeshParser` 74 + /// implementation (e.g. `ThreemfRemoteParser`) and constructing 75 + /// `ProductionMeshParser::new(remote)` at the call site. This trait shape, the viewer 76 + /// pipeline, and `build_parsed_mesh` are unchanged. 77 + pub(crate) trait MeshParser { 78 + /// Parse the source into a mesh. 79 + async fn parse(&self, source: &MeshSource<'_>) -> Result<ModelMesh, MeshLoadError>; 80 + } 81 + 82 + /// A remote (server-side) parser for formats that cannot compile to `wasm32` (3MF). 83 + /// 84 + /// **Contract:** [`RemoteMeshParser::convert`] MUST return a **self-contained GLB** 85 + /// (embedded buffer), because the `Threemf` arm re-enters the pipeline via `Gltf` with no 86 + /// companion resources. 87 + pub(crate) trait RemoteMeshParser { 88 + /// Convert the source bytes (e.g. a `.3mf`) into self-contained GLB bytes. 89 + async fn convert(&self, bytes: &[u8]) -> Result<Vec<u8>, MeshLoadError>; 90 + } 91 + 92 + /// No remote parser wired in: `Threemf` is unsupported until PM-49 supplies one. 93 + pub(crate) struct NoRemote; 94 + 95 + impl RemoteMeshParser for NoRemote { 96 + async fn convert(&self, _bytes: &[u8]) -> Result<Vec<u8>, MeshLoadError> { 97 + Err(MeshLoadError::UnsupportedFormat) 98 + } 99 + } 100 + 101 + /// Production parser: routes by format. `Stl`/`Obj`/`Gltf` parse in-memory; `Threemf` 102 + /// delegates to a [`RemoteMeshParser`] (converting to a self-contained GLB, then 103 + /// re-entering via `Gltf` with no companions). 104 + pub(crate) struct ProductionMeshParser<R: RemoteMeshParser = NoRemote> { 105 + remote: R, 106 + } 107 + 108 + impl Default for ProductionMeshParser<NoRemote> { 109 + fn default() -> Self { 110 + Self { remote: NoRemote } 111 + } 112 + } 113 + 114 + impl<R: RemoteMeshParser> ProductionMeshParser<R> { 115 + /// Construct with a remote parser (PM-49 wires `ThreemfRemoteParser` here). 116 + pub(crate) fn new(remote: R) -> Self { 117 + Self { remote } 118 + } 119 + } 120 + 121 + impl<R: RemoteMeshParser> MeshParser for ProductionMeshParser<R> { 122 + async fn parse(&self, source: &MeshSource<'_>) -> Result<ModelMesh, MeshLoadError> { 123 + match source.format { 124 + MeshFormat::Stl | MeshFormat::Obj | MeshFormat::Gltf => load_mesh(source), 125 + MeshFormat::Threemf => { 126 + let glb = self.remote.convert(source.primary).await?; 127 + let reentry = MeshSource { 128 + format: MeshFormat::Gltf, 129 + primary: &glb, 130 + resources: MeshResources::empty(), 131 + }; 132 + load_mesh(&reentry) 133 + } 134 + } 135 + } 136 + } 137 + 138 + #[cfg(test)] 139 + mod tests { 140 + use super::*; 141 + use crate::mesh::contract::MeshFormat; 142 + 143 + fn fixture(name: &str) -> Vec<u8> { 144 + let path = format!("{}/public/models/{name}", env!("CARGO_MANIFEST_DIR")); 145 + std::fs::read(&path) 146 + .unwrap_or_else(|_| panic!("fixture {name} should be committed at {path}")) 147 + } 148 + 149 + #[test] 150 + fn load_mesh_dispatches_each_format_and_rejects_threemf() { 151 + // Stl: a minimal binary STL tetrahedron (80-byte header + count + 4 triangles). 152 + let mut stl = Vec::new(); 153 + stl.extend_from_slice(&[0u8; 80]); 154 + stl.extend_from_slice(&4u32.to_le_bytes()); 155 + let v = [ 156 + [0.0f32; 3], 157 + [1.0, 0.0, 0.0], 158 + [0.0, 1.0, 0.0], 159 + [0.0, 0.0, 1.0], 160 + ]; 161 + let faces = [[0, 2, 1], [0, 1, 3], [0, 3, 2], [1, 2, 3]]; 162 + for f in faces { 163 + stl.extend_from_slice(&[0u8; 12]); // facet normal (3 zero f32) 164 + for i in f { 165 + for &c in &v[i] { 166 + stl.extend_from_slice(&c.to_le_bytes()); 167 + } 168 + } 169 + stl.extend_from_slice(&0u16.to_le_bytes()); // attribute 170 + } 171 + let stl_mesh = load_mesh(&MeshSource { 172 + format: MeshFormat::Stl, 173 + primary: &stl, 174 + resources: MeshResources::empty(), 175 + }) 176 + .expect("STL dispatches to load_stl"); 177 + assert_eq!(stl_mesh.format, MeshFormat::Stl); 178 + 179 + // Obj: a single triangle. 180 + let obj_mesh = load_mesh(&MeshSource { 181 + format: MeshFormat::Obj, 182 + primary: b"v 0 0 0\nv 1 0 0\nv 0 1 0\nf 1 2 3\n", 183 + resources: MeshResources::empty(), 184 + }) 185 + .expect("OBJ dispatches to load_obj"); 186 + assert_eq!(obj_mesh.format, MeshFormat::Obj); 187 + 188 + // Gltf: a self-contained GLB. 189 + let glb = fixture("box.glb"); 190 + let gltf_mesh = load_mesh(&MeshSource { 191 + format: MeshFormat::Gltf, 192 + primary: &glb, 193 + resources: MeshResources::empty(), 194 + }) 195 + .expect("GLB dispatches to load_gltf"); 196 + assert_eq!(gltf_mesh.format, MeshFormat::Gltf); 197 + 198 + // Threemf: unsupported without a remote parser. 199 + assert!(matches!( 200 + load_mesh(&MeshSource { 201 + format: MeshFormat::Threemf, 202 + primary: b"not real 3mf", 203 + resources: MeshResources::empty(), 204 + }), 205 + Err(MeshLoadError::UnsupportedFormat) 206 + )); 207 + } 208 + 209 + #[test] 210 + fn production_parser_with_no_remote_rejects_threemf() { 211 + let parser = ProductionMeshParser::default(); 212 + let source = MeshSource { 213 + format: MeshFormat::Threemf, 214 + primary: b"not real 3mf", 215 + resources: MeshResources::empty(), 216 + }; 217 + // Drive the async parse on a current-thread runtime (no Send bound needed). 218 + let rt = tokio::runtime::Builder::new_current_thread() 219 + .enable_all() 220 + .build() 221 + .unwrap(); 222 + let result = rt.block_on(parser.parse(&source)); 223 + assert!(matches!(result, Err(MeshLoadError::UnsupportedFormat))); 224 + } 225 + 226 + // AC.6 — drop-in proof: a stub remote returning a self-contained GLB makes a 227 + // `Threemf` source parse via the GLB loader, with the trait shape unchanged. 228 + struct StubRemote; 229 + 230 + impl RemoteMeshParser for StubRemote { 231 + async fn convert(&self, _bytes: &[u8]) -> Result<Vec<u8>, MeshLoadError> { 232 + // Returns a real self-contained GLB regardless of input. 233 + Ok(fixture("box.glb")) 234 + } 235 + } 236 + 237 + #[test] 238 + fn production_parser_with_stub_remote_parses_threemf_via_glb() { 239 + let parser = ProductionMeshParser::new(StubRemote); 240 + let source = MeshSource { 241 + format: MeshFormat::Threemf, 242 + primary: b"fake 3mf bytes", 243 + resources: MeshResources::empty(), 244 + }; 245 + let rt = tokio::runtime::Builder::new_current_thread() 246 + .enable_all() 247 + .build() 248 + .unwrap(); 249 + let mesh = rt 250 + .block_on(parser.parse(&source)) 251 + .expect("stub remote -> GLB -> ModelMesh"); 252 + assert_eq!(mesh.format, MeshFormat::Gltf); 253 + assert_eq!(mesh.trimesh.triangle_count(), 12); 254 + } 255 + }
+6 -34
src/mesh/stl.rs
··· 39 39 } 40 40 41 41 /// Convert a parsed `stl_io` mesh into a validated [`ModelMesh`]. 42 + /// 43 + /// The `stl_io::IndexedMesh` → [`TriMesh`] conversion is STL-specific; geometry 44 + /// validation, normal derivation, and F32 normalization are shared via 45 + /// [`crate::mesh::validate`]. 42 46 fn build_model_mesh(mesh: stl_io::IndexedMesh) -> Result<ModelMesh, MeshLoadError> { 43 - // No triangles → nothing to render. 44 - if mesh.faces.is_empty() { 45 - return Err(MeshLoadError::Degenerate); 46 - } 47 - // Reject non-finite positions up front so AABB / normal math can't be poisoned. 48 - if mesh 49 - .vertices 50 - .iter() 51 - .any(|v| !v.0.iter().copied().all(f32::is_finite)) 52 - { 53 - return Err(MeshLoadError::Degenerate); 54 - } 55 - 56 47 let positions = Positions::F32( 57 48 mesh.vertices 58 49 .iter() ··· 76 67 colors: None, 77 68 }; 78 69 79 - // Reject a point-sized bounding box (all three extents ~0). A flat plate or line — 80 - // zero extent on one or two axes — is valid geometry and must parse. 81 - let size = trimesh.compute_aabb().size(); 82 - if size.x.abs() < f32::EPSILON && size.y.abs() < f32::EPSILON && size.z.abs() < f32::EPSILON { 83 - return Err(MeshLoadError::Degenerate); 84 - } 85 - 86 - // Geometry-derived normals; STL facet normals are not relied upon. 87 - trimesh.compute_normals(); 88 - // `compute_normals` normalizes each per-vertex face-normal sum. A vertex touched 89 - // only by zero-area (collinear) triangles has a zero sum and normalizes to NaN. Such 90 - // geometry is allowed to parse, so replace any non-finite normal with zero — the 91 - // normal is genuinely undefined there, but the mesh must never carry NaN downstream. 92 - if let Some(normals) = trimesh.normals.as_mut() { 93 - for n in normals { 94 - if !n.x.is_finite() || !n.y.is_finite() || !n.z.is_finite() { 95 - *n = Vec3::new(0.0, 0.0, 0.0); 96 - } 97 - } 98 - } 70 + crate::mesh::validate(&mut trimesh)?; 99 71 100 72 Ok(ModelMesh { 101 73 trimesh, 102 74 format: MeshFormat::Stl, 103 - units: Units::stl_assumed_mm(), 75 + units: Units::unitless_assumed_mm(), 104 76 }) 105 77 } 106 78
+161 -66
src/viewer.rs
··· 1 - //! Asset-backed STL viewer. 1 + //! Asset-backed mesh viewer. 2 2 //! 3 - //! The viewer renders committed STL assets with [`three-d`] on a Dioxus-owned 4 - //! canvas. The design (see the "STL viewer renderer decision" Confluence note) 5 - //! rests on three production decisions: 3 + //! The viewer renders committed mesh assets ([STL], OBJ, glTF/GLB) with [`three-d`] on a 4 + //! Dioxus-owned canvas. The design (see the "STL viewer renderer decision" Confluence 5 + //! note) rests on three production decisions: 6 6 //! 7 7 //! * **One WebGL2 session per page mount.** The session is retained across 8 8 //! model switches and only the GPU mesh resources are replaced in place ··· 11 11 //! through the async loading window, and drives a [`RendererLifecycle`] 12 12 //! reconcile on signal changes — so the WebGL context is acquired exactly 13 13 //! once per page mount. 14 - //! * **One parsed mesh per load.** STL bytes are fetched on demand and parsed a 14 + //! * **One parsed mesh per load.** Asset bytes are fetched on demand and parsed a 15 15 //! single time into a [`ParsedMesh`], which then flows to the renderer. The 16 - //! renderer never parses STL itself. 17 - //! * **On-demand assets.** Demo STLs live under `public/models/` and are fetched 18 - //! from `/models/*.stl` at runtime instead of being embedded in the WASM bundle. 19 - 16 + //! renderer never parses itself. 17 + //! * **On-demand assets.** Demo models live under `public/models/` and are fetched 18 + //! at runtime instead of being embedded in the WASM bundle. 19 + //! 20 + //! [STL]: crate::mesh::stl 20 21 // The renderer session machinery (the three-d adapter, the reuse lifecycle, and 21 22 // the typed error) is production-live only in the web target, where the wasm 22 23 // `BrowserRendererSession` drives it. Off-web it exists to compile and to back ··· 24 25 // abstraction out of the non-wasm build. 25 26 #![allow(dead_code)] 26 27 27 - use crate::mesh::contract::{MeshLoadError, ModelMesh}; 28 - use crate::mesh::stl::load_stl; 28 + use crate::mesh::companions::{discover_companion_uris, is_safe_relative_uri}; 29 + use crate::mesh::contract::{MeshFormat, MeshLoadError, ModelMesh}; 30 + use crate::mesh::parser::{MeshParser, MeshResources, MeshSource, ProductionMeshParser}; 29 31 use dioxus::prelude::*; 30 32 use std::hash::{Hash, Hasher}; 31 33 use std::sync::Arc; ··· 98 100 99 101 impl Eq for ParsedMesh {} 100 102 101 - /// Deterministic, in-process identity hash of mesh source bytes. 102 - fn hash_bytes(bytes: &[u8]) -> u64 { 103 + /// Deterministic, in-process identity hash of a mesh source. 104 + /// 105 + /// Covers the primary bytes plus the sorted set of `(companion URI, companion bytes)`, 106 + /// so changing a companion (or none vs. some) changes identity and forces a reload. For 107 + /// a companion-free source this reduces to hashing the primary bytes alone. 108 + fn identity_hash(primary: &[u8], companions: &[(String, Vec<u8>)]) -> u64 { 103 109 let mut hasher = std::collections::hash_map::DefaultHasher::new(); 104 - bytes.hash(&mut hasher); 110 + primary.hash(&mut hasher); 111 + if !companions.is_empty() { 112 + // A marker so "primary alone" can never collide with "primary + companions". 113 + 0xFFu8.hash(&mut hasher); 114 + let mut sorted: Vec<(&str, &[u8])> = companions 115 + .iter() 116 + .map(|(uri, bytes)| (uri.as_str(), bytes.as_slice())) 117 + .collect(); 118 + sorted.sort_unstable(); 119 + for (uri, bytes) in &sorted { 120 + uri.hash(&mut hasher); 121 + bytes.hash(&mut hasher); 122 + } 123 + } 105 124 hasher.finish() 106 125 } 107 126 108 - /// Parse boundary for the viewer: a mockable parser turns source bytes into a 109 - /// [`ModelMesh`]. Production uses [`StlParser`] (which wraps the shared 110 - /// [`load_stl`]); tests inject a counting parser to assert one parse per load. 111 - /// This is the only place in the viewer that calls [`load_stl`]. 112 - trait MeshParser { 113 - fn parse(&self, bytes: &[u8]) -> Result<ModelMesh, MeshLoadError>; 114 - } 115 - 116 - /// Default STL parser. [`StlParser::parse`] is the single [`load_stl`] call site 117 - /// in the viewer; the renderer consumes a [`ParsedMesh`] and never parses. 118 - struct StlParser; 119 - 120 - impl MeshParser for StlParser { 121 - fn parse(&self, bytes: &[u8]) -> Result<ModelMesh, MeshLoadError> { 122 - load_stl(bytes) 123 - } 127 + /// Hash a single byte slice (companion-free sources). 128 + fn hash_bytes(bytes: &[u8]) -> u64 { 129 + identity_hash(bytes, &[]) 124 130 } 125 131 126 - /// Build a [`ParsedMesh`] from source bytes: hash the bytes, then parse once. 127 - fn build_parsed_mesh( 128 - bytes: &[u8], 132 + /// Parse boundary: the shared [`MeshParser`] (in [`crate::mesh::parser`]) turns a 133 + /// [`MeshSource`] into a [`ModelMesh`]. Production uses 134 + /// [`ProductionMeshParser`] (which dispatches by format); tests inject a counting parser 135 + /// to assert one parse per load. 136 + /// 137 + /// Build a [`ParsedMesh`] from a mesh source: hash the primary plus companions, then 138 + /// parse once through the parser. 139 + async fn build_parsed_mesh( 140 + source: &MeshSource<'_>, 129 141 parser: &impl MeshParser, 130 142 ) -> Result<Arc<ParsedMesh>, MeshLoadError> { 131 - let content_hash = hash_bytes(bytes); 132 - let mesh = parser.parse(bytes)?; 143 + let companions: Vec<(String, Vec<u8>)> = source 144 + .resources 145 + .iter() 146 + .map(|(uri, bytes)| (uri.to_string(), bytes.to_vec())) 147 + .collect(); 148 + let content_hash = identity_hash(source.primary, &companions); 149 + let mesh = parser.parse(source).await?; 133 150 Ok(Arc::new(ParsedMesh { content_hash, mesh })) 134 151 } 135 152 ··· 159 176 pub parsed: Arc<ParsedMesh>, 160 177 } 161 178 162 - /// Load a demo model's STL on demand (fetch on wasm, disk off wasm), parsing it 163 - /// exactly once into a [`LoadedMesh`]. 179 + /// Load a demo model on demand (fetch the primary, prefetch glTF companions, parse 180 + /// once) into a [`LoadedMesh`]. The format is derived from the asset extension; an 181 + /// unrecognized extension surfaces as [`ViewerIssue::MeshUnavailable`]. 164 182 async fn load_demo_mesh(model: DemoModel) -> Result<LoadedMesh, ViewerIssue> { 165 - let bytes = read_model_bytes(model).await?; 166 - let parsed = build_parsed_mesh(&bytes, &StlParser).map_err(|_| ViewerIssue::MeshUnavailable)?; 183 + let format = 184 + MeshFormat::from_extension(model.asset_path).ok_or(ViewerIssue::MeshUnavailable)?; 185 + let primary = read_bytes(model.asset_path).await?; 186 + let companions = prefetch_companions(&primary, model.asset_path, format).await?; 187 + let source = MeshSource { 188 + format, 189 + primary: &primary, 190 + resources: if companions.is_empty() { 191 + MeshResources::empty() 192 + } else { 193 + MeshResources::new(&companions) 194 + }, 195 + }; 196 + let parsed = build_parsed_mesh(&source, &ProductionMeshParser::default()) 197 + .await 198 + .map_err(|_| ViewerIssue::MeshUnavailable)?; 167 199 let summary = mesh_summary_from_model(model, &parsed.mesh); 168 200 Ok(LoadedMesh { 169 201 model, ··· 172 204 }) 173 205 } 174 206 175 - /// Read a demo model's source bytes: `fetch` on the web target, a hermetic disk 176 - /// read elsewhere (so the non-wasm build compiles and tests stay filesystem-backed). 177 - async fn read_model_bytes(model: DemoModel) -> Result<Vec<u8>, ViewerIssue> { 207 + /// Read a model resource's bytes: `fetch` on the web target, a hermetic disk read 208 + /// elsewhere (so the non-wasm build compiles and tests stay filesystem-backed). 209 + async fn read_bytes(asset_path: &str) -> Result<Vec<u8>, ViewerIssue> { 178 210 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 179 211 { 180 - fetch_bytes(model.asset_path).await 212 + fetch_bytes(asset_path).await 181 213 } 182 214 #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 183 215 { 184 - let path = format!("{}/public{}", env!("CARGO_MANIFEST_DIR"), model.asset_path); 216 + let path = format!("{}/public{}", env!("CARGO_MANIFEST_DIR"), asset_path); 185 217 std::fs::read(&path).map_err(|_| ViewerIssue::MeshUnavailable) 186 218 } 219 + } 220 + 221 + /// For a multi-file `.gltf`, discover and fetch its companion buffers/textures — only 222 + /// safe, relative, in-directory URIs. GLB (magic `glTF`) and embedded-base64 assets are 223 + /// self-contained and need no companions. Companions are keyed by their exact raw URI. 224 + async fn prefetch_companions( 225 + primary: &[u8], 226 + asset_path: &str, 227 + format: MeshFormat, 228 + ) -> Result<Vec<(String, Vec<u8>)>, ViewerIssue> { 229 + if format != MeshFormat::Gltf || primary.starts_with(b"glTF") { 230 + return Ok(Vec::new()); 231 + } 232 + let base_dir = asset_path 233 + .rsplit_once('/') 234 + .map(|(dir, _)| dir) 235 + .unwrap_or(""); 236 + let mut companions = Vec::new(); 237 + for uri in discover_companion_uris(primary) { 238 + if !is_safe_relative_uri(&uri) { 239 + return Err(ViewerIssue::MeshUnavailable); 240 + } 241 + let bytes = read_bytes(&format!("{base_dir}/{uri}")).await?; 242 + companions.push((uri, bytes)); 243 + } 244 + Ok(companions) 187 245 } 188 246 189 247 #[cfg(all(target_family = "wasm", target_os = "unknown"))] ··· 1280 1338 mod tests { 1281 1339 use super::*; 1282 1340 use crate::mesh::contract::{LengthUnit, MeshFormat, Units}; 1341 + use crate::mesh::stl::load_stl; 1283 1342 use std::cell::Cell; 1284 1343 use std::rc::Rc; 1285 1344 1286 1345 fn fixture_bytes(asset_path: &str) -> Vec<u8> { 1287 1346 let path = format!("{}/public{}", env!("CARGO_MANIFEST_DIR"), asset_path); 1288 1347 std::fs::read(&path) 1289 - .unwrap_or_else(|_| panic!("demo STL asset {asset_path} should be committed at {path}")) 1348 + .unwrap_or_else(|_| panic!("demo asset {asset_path} should be committed at {path}")) 1290 1349 } 1291 1350 1292 - fn parsed_for(model: DemoModel) -> Arc<ParsedMesh> { 1293 - build_parsed_mesh(&fixture_bytes(model.asset_path), &StlParser) 1351 + async fn parsed_for(model: DemoModel) -> Arc<ParsedMesh> { 1352 + let bytes = fixture_bytes(model.asset_path); 1353 + let source = MeshSource { 1354 + format: MeshFormat::Stl, 1355 + primary: &bytes, 1356 + resources: MeshResources::empty(), 1357 + }; 1358 + build_parsed_mesh(&source, &ProductionMeshParser::default()) 1359 + .await 1294 1360 .expect("demo STL asset should parse") 1295 1361 } 1296 1362 1297 - fn mount_for(model: DemoModel) -> RendererMount { 1298 - let parsed = parsed_for(model); 1363 + async fn mount_for(model: DemoModel) -> RendererMount { 1364 + let parsed = parsed_for(model).await; 1299 1365 let summary = mesh_summary_from_model(model, &parsed.mesh); 1300 1366 RendererMount::new(SessionKey::new(model.id, model.generation), parsed, summary) 1301 1367 } ··· 1388 1454 assert!(!ViewerStatus::MeshError(ViewerIssue::MeshUnavailable).should_have_session()); 1389 1455 } 1390 1456 1391 - #[test] 1392 - fn parsed_mesh_equality_uses_content_hash() { 1393 - let chest = parsed_for(DEMO_MODELS[0]); 1394 - let chest_again = parsed_for(DEMO_MODELS[0]); 1395 - let forearm = parsed_for(DEMO_MODELS[1]); 1457 + #[tokio::test] 1458 + async fn parsed_mesh_equality_uses_content_hash() { 1459 + let chest = parsed_for(DEMO_MODELS[0]).await; 1460 + let chest_again = parsed_for(DEMO_MODELS[0]).await; 1461 + let forearm = parsed_for(DEMO_MODELS[1]).await; 1396 1462 1397 1463 assert_eq!(chest, chest_again, "same source bytes must be equal"); 1398 1464 assert_ne!(chest, forearm, "different source bytes must differ"); 1399 1465 assert_eq!(&chest, &chest_again, "Arc/ParsedMesh compare by hash"); 1400 1466 } 1401 1467 1402 - #[test] 1403 - fn parses_each_loaded_mesh_once() { 1404 - #[derive(Default)] 1468 + #[tokio::test] 1469 + async fn parses_each_loaded_mesh_once() { 1405 1470 struct CountingParser { 1406 1471 calls: Rc<Cell<usize>>, 1407 1472 } 1473 + impl CountingParser { 1474 + fn new() -> Self { 1475 + Self { 1476 + calls: Rc::new(Cell::new(0)), 1477 + } 1478 + } 1479 + } 1408 1480 impl MeshParser for CountingParser { 1409 - fn parse(&self, bytes: &[u8]) -> Result<ModelMesh, MeshLoadError> { 1481 + async fn parse(&self, source: &MeshSource<'_>) -> Result<ModelMesh, MeshLoadError> { 1410 1482 self.calls.set(self.calls.get() + 1); 1411 - load_stl(bytes) 1483 + crate::mesh::stl::load_stl(source.primary) 1412 1484 } 1413 1485 } 1414 1486 1415 - let parser = CountingParser::default(); 1487 + let parser = CountingParser::new(); 1416 1488 let bytes = fixture_bytes(DEMO_MODELS[0].asset_path); 1417 - let parsed = build_parsed_mesh(&bytes, &parser).expect("parses"); 1489 + let source = MeshSource { 1490 + format: MeshFormat::Stl, 1491 + primary: &bytes, 1492 + resources: MeshResources::empty(), 1493 + }; 1494 + let parsed = build_parsed_mesh(&source, &parser).await.expect("parses"); 1418 1495 assert_eq!( 1419 1496 parser.calls.get(), 1420 1497 1, 1421 1498 "the boundary must parse once per load" 1422 1499 ); 1423 1500 1424 - let again = build_parsed_mesh(&bytes, &parser).unwrap(); 1501 + let again = build_parsed_mesh(&source, &parser).await.unwrap(); 1425 1502 assert_eq!( 1426 1503 again.content_hash, parsed.content_hash, 1427 1504 "the same bytes must hash to the same identity" ··· 1429 1506 } 1430 1507 1431 1508 #[test] 1509 + fn identity_changes_when_a_companion_changes() { 1510 + // No companions -> reduces to hashing the primary. 1511 + let primary = b"primary bytes"; 1512 + let none = identity_hash(primary, &[]); 1513 + assert_eq!(none, hash_bytes(primary)); 1514 + 1515 + let with_a = identity_hash(primary, &[("box.bin".to_string(), b"AAA".to_vec())]); 1516 + let with_b = identity_hash(primary, &[("box.bin".to_string(), b"BBB".to_vec())]); 1517 + assert_ne!(none, with_a, "adding a companion changes identity"); 1518 + assert_ne!(with_a, with_b, "changing a companion changes identity"); 1519 + assert_eq!( 1520 + with_a, 1521 + identity_hash(primary, &[("box.bin".to_string(), b"AAA".to_vec())]), 1522 + "same companions hash to the same identity" 1523 + ); 1524 + } 1525 + 1526 + #[test] 1432 1527 fn typed_renderer_error_covers_all_cases_and_maps_to_renderer_init() { 1433 1528 let errors: Vec<ThreeDRendererError> = vec![ 1434 1529 ThreeDRendererError::WindowUnavailable, ··· 1462 1557 ); 1463 1558 } 1464 1559 1465 - #[test] 1466 - fn renderer_lifecycle_mounts_replaces_resizes_and_drops() { 1560 + #[tokio::test] 1561 + async fn renderer_lifecycle_mounts_replaces_resizes_and_drops() { 1467 1562 let events = Rc::new(Cell::new(0)); 1468 1563 let drops = Rc::new(Cell::new(0)); 1469 1564 let canvas = (FakeCanvas, Rc::clone(&events), Rc::clone(&drops)); 1470 1565 let mut lifecycle = RendererLifecycle::<FakeRenderer>::new(); 1471 - let chest = mount_for(DEMO_MODELS[0]); 1472 - let forearm = mount_for(DEMO_MODELS[1]); 1566 + let chest = mount_for(DEMO_MODELS[0]).await; 1567 + let forearm = mount_for(DEMO_MODELS[1]).await; 1473 1568 let ready_chest = ViewerStatus::Ready(chest.key.clone()); 1474 1569 1475 1570 // Initial mount.