├── .github └── workflows │ ├── docs-publish.yml │ └── npm-publish.yml ├── .gitignore ├── .npmignore ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── bin ├── svf-to-gltf.js └── svf2-to-gltf.js ├── logo.png ├── package.json ├── res ├── placeholder.bmp ├── placeholder.gif ├── placeholder.jpg └── placeholder.png ├── samples ├── Dockerfile ├── custom-gltf-attribute.js ├── download-f2d.js ├── download-svf.js ├── download-svf2.js ├── filter-by-area.js ├── local-svf-props.js ├── local-svf-to-gltf.js ├── local-svf-to-gltf.sh ├── remote-svf-props.js ├── remote-svf-to-gltf.js ├── remote-svf-to-gltf.sh ├── remote-svf2-to-gltf.js ├── remote-svf2-to-usdz.sh ├── serialize-msgpack.js └── shared.js ├── src ├── common │ ├── authentication-provider.ts │ ├── cancellation-token.ts │ ├── image-placeholders.ts │ ├── input-stream.ts │ ├── intermediate-format.ts │ ├── packfile-reader.ts │ └── propdb-reader.ts ├── f2d │ └── downloader.ts ├── gltf │ ├── schema.ts │ └── writer.ts ├── index.ts ├── svf │ ├── downloader.ts │ ├── fragments.ts │ ├── geometries.ts │ ├── materials.ts │ ├── meshes.ts │ ├── reader.ts │ └── schema.ts └── svf2 │ ├── clients │ ├── ModelDataHttpClient.ts │ ├── SharedDataHttpClient.ts │ └── SharedDataWebSocketClient.ts │ ├── downloader.ts │ ├── helpers │ ├── Fragment.ts │ ├── Geometry.ts │ ├── HashList.ts │ ├── Manifest.ts │ ├── Material.ts │ └── View.ts │ └── reader.ts ├── test └── remote-svf-to-gltf.sh ├── tools ├── consolidate-meshes.js └── validate.js ├── tsconfig.json └── yarn.lock /.github/workflows/docs-publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Docs 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | build-and-deploy: 13 | concurrency: ci-${{ github.ref }} # Recommended if you intend to make multiple deployments in quick succession. 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout 🛎️ 17 | uses: actions/checkout@v3 18 | 19 | - name: Install and Build 20 | run: | 21 | npm install 22 | npm run docs 23 | 24 | - name: Deploy 🚀 25 | uses: JamesIves/github-pages-deploy-action@v4 26 | with: 27 | folder: docs -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/publishing-nodejs-packages 3 | 4 | name: Publish to NPM 5 | 6 | on: 7 | push: 8 | tags: 9 | - '*' 10 | 11 | jobs: 12 | publish: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v2 16 | - uses: actions/setup-node@v1 17 | with: 18 | node-version: 20 19 | registry-url: https://registry.npmjs.org/ 20 | - run: yarn install 21 | - run: yarn run build 22 | - run: yarn run docs 23 | - run: yarn publish 24 | env: 25 | NODE_AUTH_TOKEN: ${{secrets.npm_token}} 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | node_modules/ 3 | lib/ 4 | tmp/ 5 | docs/ 6 | *.log 7 | .DS_Store 8 | Thumbs.db -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | node_modules/ 3 | test/ 4 | tmp/ 5 | docs/ 6 | *.log 7 | .DS_Store 8 | Thumbs.db 9 | .gitignore 10 | .travis.yml -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ## [7.0.0] - 2025-04-16 11 | 12 | - Modified 13 | - Method signature of all downloaders for consistency 14 | - Casing of all public classes for consistency (`SvfReader` -> `SVFReader`, `GltfWriter` -> `GLTFWriter`, etc) 15 | - Added 16 | - Optional cancellation token for early exit from all downloaders 17 | - Sample for F2D download 18 | 19 | ## [6.0.1] - 2025-04-08 20 | 21 | - Modified 22 | - Increased the minimum required Node.js version to 20 23 | 24 | ## [6.0.0] - 2025-04-08 25 | 26 | - Added 27 | - Experimental support for SVF2 downloading and parsing 28 | - Fixed 29 | - When exporting 3D lines, scale colors to 0..1 range 30 | - Technical debt 31 | - Replaced `commander` with built-in `parseArgs` 32 | - Removed deprecated `isUndefined` and `isNullOrUndefined` functions from `node:utils` 33 | 34 | ## [5.0.5] - 2024-09-11 35 | 36 | - Added 37 | - SVF/F2D downloads can now be configured for different regions 38 | - Fixed 39 | - Bug in URN resolution on Windows (https://github.com/petrbroz/svf-utils/issues/84) 40 | 41 | ## [5.0.4] - 2024-09-10 42 | 43 | - Added 44 | - Cleaning up error logs for Axios-related errors 45 | - Modified 46 | - Upgraded to newer version of APS SDK 47 | 48 | ## [5.0.3] - 2024-04-09 49 | 50 | - Modified 51 | - Increased the minimum required Node.js version to 16 52 | 53 | ## [5.0.0] - 2024-04-09 54 | 55 | - Modified 56 | - **[BREAKING CHANGE]** Library has been renamed from `forge-convert-utils` to `svf-utils` 57 | - **[BREAKING CHANGE]** SVF readers and downloaders now expect an `IAuthenticationProvider` interface 58 | for specifying how the requests to the Model Derivative service will be authenticated 59 | - Changed branding from Forge to APS everywhere 60 | - Migrated to the official APS SDKs 61 | 62 | ## [4.0.5] - 2023-09-29 63 | 64 | - Added 65 | - SVF materials that are not referenced by anything are excluded from the glTF output 66 | 67 | ## [4.0.4] - 2023-08-08 68 | 69 | - Added 70 | - Support for gltf output filtering based on fragment IDs. 71 | - Support for svf input filtering based on dbID or fragment ID. 72 | 73 | ## [4.0.3] - 2023-06-28 74 | 75 | - Fixed 76 | - Solved an issue with glTF geometry being broken in certain scenarios (kudos to [henrikbuchholz](https://github.com/henrikbuchholz)!) 77 | 78 | ## [4.0.2] - 2023-03-10 79 | 80 | - Added 81 | - Support for custom serialization of the glTF manifest 82 | - Fixed 83 | - Parsing of properties for the very last object (kudos to [johannesheesterman](https://github.com/johannesheesterman)!) 84 | 85 | ## [4.0.1] - 2022-03-24 86 | 87 | - Fixed 88 | - Deduplication of geometries now using maps instead of arrays, bringing dramatic speed improvements (kudos to [VFedyk](https://github.com/VFedyk)!) 89 | 90 | ## [4.0.0] - 2022-03-24 91 | 92 | - Removed 93 | - Support for the (experimental) OTG format download and parsing 94 | 95 | ## [3.6.3] - 2022-01-20 96 | 97 | - Fixed 98 | - Conversion of SVF 3x3 matrix + translation into glTF 4x4 matrix 99 | 100 | ## [3.6.2] - 2021-11-04 101 | 102 | - Fixed 103 | - Failing TypeScript build due to changes in `adm-zip` dependency 104 | 105 | ## [3.6.1] - 2021-11-04 106 | 107 | - Fixed 108 | - Updated dependencies 109 | - Added URL-encoding when downloading SVF viewables 110 | 111 | ## [3.6.0] - 2021-02-04 112 | 113 | - Added 114 | - Re-introduced code docs generator 115 | - Fixed 116 | - Updated dependencies 117 | - Changed 118 | - CI/CD now in Github Actions 119 | 120 | ## [3.5.2] - 2021-01-04 121 | 122 | - Fixed 123 | - Downloading of SVF assets with special characters (kudos [thejakobn](https://github.com/thejakobn)) 124 | - Parsing textures with custom scale (kudos [thejakobn](https://github.com/thejakobn)) 125 | - Bug when parsing props with undefined category 126 | 127 | ## [3.5.1] - 2020-11-20 128 | 129 | - Added 130 | - New version of forge-server-utils 131 | - Support for chunked download of Model Derivative assets 132 | 133 | ## [3.5.0] - 2020-03-30 134 | 135 | - Added 136 | - Mapping SVF/OTG glossiness to glTF roughness (need to confirm that the mapping is correct) 137 | 138 | ## [3.4.5] - 2020-03-26 139 | 140 | - Fixed 141 | - SVF parser property db config from 3.4.4 142 | 143 | ## [3.4.4] - 2020-03-26 144 | 145 | - Added 146 | - SVF parser can now be configured to skip property DB 147 | 148 | ## [3.4.3] - 2020-03-25 149 | 150 | - Fixed 151 | - Travis config 152 | 153 | ## [3.4.1] - 2020-03-25 154 | 155 | - Added 156 | - SVF downloader can now be initialized with custom host URL and region 157 | - SVF parser can now be initialized with custom host URL and region 158 | - Removed 159 | - Docs generator (due to audit warnings and lack of updates); will continue generating the docs manually. 160 | 161 | ## [3.4.0] - 2020-03-24 162 | 163 | - Added 164 | - Support for meshes with vertex colors 165 | 166 | ## [3.3.1] - 2020-03-17 167 | 168 | - Fixed 169 | - Npm dependency vulnerability 170 | 171 | ## [3.3.0] - 2020-03-13 172 | 173 | - Added 174 | - F2D downloader 175 | - Changed 176 | - SVF/OTG/F2D downloaders can now accept existing auth tokens 177 | - SVF/OTG/F2D downloaders can now be configured to ignore missing assets 178 | 179 | ## [3.2.0] - 2020-03-13 180 | 181 | - Added 182 | - SVF and OTG downloader classes 183 | 184 | ## [3.1.2] - 2020-03-12 185 | 186 | - Fixed 187 | - When converting to gltf, empty `textures` or `images` are removed to prevent validation errors (thanks [@AlexPiro](https://github.com/AlexPiro)!) 188 | - Added 189 | - Dev utility for validating gltf manifests (to be used in CI/CD) 190 | 191 | ## [3.1.1] - 2020-02-25 192 | 193 | - Fixed 194 | - Alpha blending only enabled when opacity is less than 1.0 ([#21](https://github.com/petrbroz/forge-convert-utils/issues/21)) 195 | 196 | ## [3.1.0] - 2020-01-15 197 | 198 | - Added 199 | - **[experimental]** OTG parser 200 | - Fixed 201 | - Flipped V component of texture coords ([#18](https://github.com/petrbroz/forge-convert-utils/issues/18), kudos to [@dykarohora](https://github.com/dykarohora)!) 202 | 203 | ## [3.0.0] - 2020-01-07 204 | 205 | - Changed 206 | - Updated to TypeScript version 3.7 207 | - **[BREAKING CHANGE]** loaders/writers now load/write a centralized _intermediate file format_ 208 | - Fixed 209 | - Extended fix from version 2.0.1: 1x1 black pixel images now used also when materials reference non-existent texture URIs 210 | 211 | ## [2.0.1] - 2019-12-20 212 | 213 | - Fixed 214 | - Missing SVF textures no longer cause the conversion to fail, and are instead replaced with 1x1 black pixel images 215 | 216 | ## [2.0.0] - 2019-12-17 217 | 218 | - Changed 219 | - **[BREAKING CHANGE]** removed post-processing options (Draco compression and binary output) 220 | - We encourage users to post-process the raw glTFs generated by this library in their own pipelines, 221 | using Node.js modules and CLI tools like [gltf-pipeline](https://github.com/AnalyticalGraphicsInc/gltf-pipeline) 222 | or [gltfpack](https://github.com/zeux/meshoptimizer#gltfpack) 223 | - See [test/remote-svf-to-gltf.sh](test/remote-svf-to-gltf.sh) for an example of such integration 224 | 225 | ## [1.2.1] - 2019-12-13 226 | 227 | - Added 228 | - Scaling the output model based on SVF distance units (added by @dykarohora) 229 | - Fixed 230 | - Sanitizing URNs (removing trailing '='s) 231 | 232 | ## [1.2.0] - 2019-12-10 233 | 234 | - Fixed 235 | - Missing folders when post-processing ([#11](https://github.com/petrbroz/forge-convert-utils/issues/11), fixed by @AlexPiro) 236 | - Added 237 | - Filtering of objects to be included in the output glTF 238 | 239 | ## [1.1.2] - 2019-11-30 240 | 241 | - Fixed 242 | - Multi-byte characters in derivative URNs (thanks @dykarohora!) 243 | 244 | ## [1.1.1] - 2019-11-13 245 | 246 | - Changed 247 | - When exporting to glTF+Draco, resources are no longer embedded into the manifest ([#7](https://github.com/petrbroz/forge-convert-utils/issues/7)) 248 | 249 | ## [1.1.0] - 2019-11-08 250 | 251 | - Added 252 | - Opt-in feature to move the model to origin 253 | - Changed 254 | - Forge models are now reoriented based on their metadata to align with the glTF coordinate system (X=left, Y=up, Z=front) 255 | 256 | > Note: scene hierarchies in the generated glTFs now contain two additional levels: 257 | all scene objects are grouped into an _xform node_ that applies additional 258 | transformations (for example, moving the model to origin), and the _xform node_ 259 | is a child of a _root node_ which transforms the entire scene to the glTF 260 | coordinate system. 261 | 262 | ## [1.0.2] - 2019-11-01 263 | 264 | - Removed 265 | - Support for sqlite output 266 | - Since [sqlite3](https://www.npmjs.com/package/sqlite3) is a native Node.js module, it was a [pain](https://css-tricks.com/what-i-learned-by-building-my-own-vs-code-extension/) to use this library in [vscode-forge-tools](https://github.com/petrbroz/vscode-forge-tools) 267 | - The experimental serialization/deserialization to/from sqlite is now developed in [forge-convert-sqlite](https://github.com/petrbroz/forge-convert-sqlite) 268 | 269 | ## [1.0.1] - 2019-10-31 270 | 271 | - Fixed 272 | - Calls to `GltfWriter.prototype.write` now await postprocessing (if there's any) 273 | 274 | ## [1.0.0] - 2019-10-31 275 | 276 | - Changed 277 | - **[BREAKING]** gltf/glb is now written with a single call (`await writer.write(svf, outputDir)`) 278 | - Removed 279 | - `debug` dependency (using `console.log` instead) 280 | 281 | ## [0.8.0] - 2019-10-29 282 | 283 | - Added 284 | - The `sqlite` flag now generates a sqlite manifest with both the glTF data and the property database 285 | - When deserializing sqlite back to glTF, you can now pass in a filter of dbids 286 | - The filter can be either a `SELECT dbid FROM properties WHERE ...`, or a list of dbids 287 | - Fixed 288 | - Iterating of object properties 289 | - Changed 290 | - Adding multiple SVFs into single glTF is now considered unsupported 291 | - Trying to do so will cause an exception in the `GltfWriter.write` method 292 | 293 | ## [0.7.2] - 2019-10-25 294 | 295 | - Added 296 | - deserialization of sqlite manifest back to glTF 297 | 298 | ## [0.7.1] - 2019-10-24 299 | 300 | - Fixed 301 | - glTF deduplication (incl. performance improvement) 302 | - sqlite serialization when ignoring mesh, line, or point geometries 303 | 304 | ## [0.7.0] - 2019-10-24 305 | 306 | - Added 307 | - More deduplication, now also on the glTF accessor and mesh level 308 | - Additional CLI options for ignoring mesh, line, or point geometry 309 | - (experimental) serialization of glTF manifest into sqlite 310 | - Can only be used when texture/buffer data is referenced and not embedded 311 | - Potentially could be used for dynamically generating glTF variants with subsets of the original model 312 | - Additional CLI option for serializing glTF manifest into sqlite 313 | - Note that the schema of the sqlite database might change 314 | 315 | ## [0.6.4] - 2019-10-22 316 | 317 | - Added 318 | - Skipping texture UVs when there's no material using them 319 | - Fixed 320 | - Computing position bounds 321 | 322 | ## [0.6.3] - 2019-10-17 323 | 324 | - Changed 325 | - Geometry deduplication now on BufferView (instead of Mesh) level 326 | - Sample scripts now using proper error catching 327 | 328 | ## [0.6.2] - 2019-10-17 329 | 330 | - Added 331 | - Opt-in deduplication of materials 332 | - Fixed 333 | - Caching of meshes 334 | 335 | ## [0.6.1] - 2019-10-14 336 | 337 | - Added 338 | - Progress logging when parsing SVF and writing glTF 339 | - Fixed 340 | - Typo in reference to package.json in CLI tool 341 | - Typo in CLI when accessing Forge credentials 342 | 343 | ## [0.6.0] - 2019-10-11 344 | 345 | - Added 346 | - Opt-in deduplication of exported geometries 347 | - Opt-in output to GLB 348 | - Opt-in output with Draco compression 349 | - Fixed 350 | - Normalizing windows/posix paths of SVF assets 351 | 352 | ## [0.5.0] - 2019-10-08 353 | 354 | - Added 355 | - Listing IDs of object children from SVF property database 356 | - Changed 357 | - Excluding internal attributes when parsing SVF property database 358 | 359 | ## [0.4.1] - 2019-10-07 360 | 361 | - Added 362 | - Access to internal SVF manifest 363 | - Fixed 364 | - Gltf schema now included in build output 365 | 366 | ## [0.4.0] - 2019-10-07 367 | 368 | - Added 369 | - Support for converting both remote and local SVFs using the CLI tool 370 | - Support for configuring glTF output (max. size of binary files, ignoring line/point geometries, ...) 371 | - Outputting multiple scenes in one glTF 372 | - Changed 373 | - Moved to new version of forge-server-utils 374 | 375 | ## [0.3.0] - 2019-10-07 376 | 377 | - Added 378 | - TypeScript definition files with glTF and SVF schemas 379 | - Changed 380 | - Code restructure 381 | - SVF parsing code moved from forge-server-utils back here 382 | 383 | ## [0.2.1] - 2019-10-04 384 | 385 | - Fixed 386 | - Images now extracted with both lower-cased and unmodified URIs 387 | 388 | ## [0.2.0] - 2019-10-03 389 | 390 | - Added 391 | - Support for line/point geometry, incl. colors 392 | 393 | ## [0.1.0] - 2019-10-03 394 | 395 | - Added 396 | - Parsing individual SVF assets in parallel 397 | - CI/CD pipeline setup 398 | - Support for basic material textures (texture transforms not yet supported) 399 | - Changed 400 | - Moved to TypeScript for additional type security, incl. official typings for the glTF 2.0 schema 401 | - Moved to yarn 402 | - Reusing SVF parser from [forge-server-utils](https://www.npmjs.com/package/forge-server-utils) module 403 | - Fixed 404 | - Crash when no materials are available 405 | 406 | ## [0.0.2] - 2019-09-24 407 | 408 | - Fixed 409 | - CLI script 410 | 411 | ## [0.0.1] - 2019-09-20 412 | 413 | - First release 414 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Autodesk 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # svf-utils 2 | 3 | ![Publish to NPM](https://github.com/petrbroz/svf-utils/workflows/Publish%20to%20NPM/badge.svg) 4 | [![npm version](https://badge.fury.io/js/svf-utils.svg)](https://badge.fury.io/js/svf-utils) 5 | ![node](https://img.shields.io/node/v/svf-utils.svg) 6 | ![npm downloads](https://img.shields.io/npm/dw/svf-utils.svg) 7 | ![platforms](https://img.shields.io/badge/platform-windows%20%7C%20osx%20%7C%20linux-lightgray.svg) 8 | [![license](https://img.shields.io/badge/license-MIT-blue.svg)](http://opensource.org/licenses/MIT) 9 | 10 | ![APS & glTF logos](./logo.png) 11 | 12 | *Experimental* utilities for working with [Autodesk Platform Services](https://aps.autodesk.com) SVF/SVF2 file formats. 13 | 14 | ## Usage 15 | 16 | ### Command line 17 | 18 | Install the package globally (`npm install --global svf-utils`), and use one of the commands listed below. 19 | 20 | #### SVF 21 | 22 | - run the `svf-to-gltf` command without parameters for usage info 23 | - run the command with a path to a local SVF file 24 | - run the command with a Model Derivative URN (and optionally viewable GUID) 25 | - to access APS you must also specify credentials (`APS_CLIENT_ID` and `APS_CLIENT_SECRET`) 26 | or an authentication token (`APS_ACCESS_TOKEN`) as env. variables 27 | - this will also download the property database in sqlite format 28 | - optionally, use any combination of the following command line args: 29 | - `--output-folder ` to change output folder (by default '.') 30 | - `--deduplicate` to try and remove duplicate geometries 31 | - `--skip-unused-uvs` to skip texture UVs that are not used by any material 32 | - `--ignore-meshes` to exclude mesh geometry from the output 33 | - `--ignore-lines` to exclude line geometry from the output 34 | - `--ignore-points` to exclude point geometry from the output 35 | - `--center` move the model to origin 36 | 37 | On Unix/macOS: 38 | 39 | ``` 40 | svf-to-gltf --output-folder 41 | ``` 42 | 43 | or 44 | 45 | ``` 46 | export APS_CLIENT_ID= 47 | export APS_CLIENT_SECRET= 48 | svf-to-gltf --output-folder 49 | ``` 50 | 51 | or 52 | 53 | ``` 54 | export APS_ACCESS_TOKEN= 55 | svf-to-gltf --output-folder 56 | ``` 57 | 58 | On Windows: 59 | 60 | ``` 61 | svf-to-gltf --output-folder 62 | ``` 63 | 64 | or 65 | 66 | ``` 67 | set APS_CLIENT_ID= 68 | set APS_CLIENT_SECRET= 69 | svf-to-gltf --output-folder 70 | ``` 71 | 72 | or 73 | 74 | ``` 75 | set APS_ACCESS_TOKEN= 76 | svf-to-gltf --output-folder 77 | ``` 78 | 79 | #### SVF2 80 | 81 | - run the `svf2-to-gltf` command without parameters for usage info 82 | - run the command with a Model Derivative URN 83 | - to access APS you must also specify credentials (`APS_CLIENT_ID` and `APS_CLIENT_SECRET`) 84 | or an authentication token (`APS_ACCESS_TOKEN`) as env. variables 85 | - the command also accepts the following options: 86 | - `--center` move the model to origin 87 | 88 | On Unix/macOS: 89 | 90 | ``` 91 | export APS_CLIENT_ID= 92 | export APS_CLIENT_SECRET= 93 | svf2-to-gltf 94 | ``` 95 | 96 | or 97 | 98 | ``` 99 | export APS_ACCESS_TOKEN= 100 | svf2-to-gltf 101 | ``` 102 | 103 | On Windows: 104 | 105 | ``` 106 | set APS_CLIENT_ID= 107 | set APS_CLIENT_SECRET= 108 | svf2-to-gltf 109 | ``` 110 | 111 | or 112 | 113 | ``` 114 | set APS_ACCESS_TOKEN= 115 | svf2-to-gltf 116 | ``` 117 | 118 | ### Node.js 119 | 120 | The library can be used at different levels of granularity. 121 | 122 | The easiest way to convert an SVF file is to read the entire model into memory using `SVFReader#read`/`SVF2Reader#read` methods, and save the model into glTF using `GLTFWriter#write`. See [samples/remote-svf-to-gltf.js](./samples/remote-svf-to-gltf.js) and [samples/remote-svf2-to-gltf.js](./samples/remote-svf2-to-gltf.js). 123 | 124 | If you don't want to read the entire model into memory (for example, when distributing the parsing of an SVF over multiple servers), you can use methods like `SVFReader#enumerateFragments`/`SVF2Reader#enumerateFragments` or `SVFReader#enumerateGeometries`/`SVF2Reader#enumerateGeometries` to _asynchronously_ iterate over individual elements: 125 | 126 | ```js 127 | const { SVFReader } = require('svf-utils'); 128 | 129 | // ... 130 | 131 | const reader = await SVFReader.FromDerivativeService(urn, guid, authProvider); 132 | for await (const fragment of reader.enumerateFragments()) { 133 | console.log(fragment); 134 | } 135 | ``` 136 | 137 | And finally, if you already have the individual SVF/SVF2 assets in memory, you can parse the binary data directly using _synchronous_ iterators like `parseMeshes`: 138 | 139 | ```js 140 | const { parseMeshes } = require('svf-utils/lib/svf/meshes'); 141 | 142 | // ... 143 | 144 | for (const mesh of parseMeshes(buffer)) { 145 | console.log(mesh); 146 | } 147 | ``` 148 | 149 | > For additional examples, see the [samples](./samples) subfolder. 150 | 151 | ### Customization 152 | 153 | You can customize the translation by sub-classing the reader and/or the writer class. For example: 154 | 155 | - [samples/custom-gltf-attribute.js](samples/custom-gltf-attribute.js) adds the dbID of each SVF node as a new attribute in its mesh 156 | - [samples/filter-by-area.js](samples/filter-by-area.js) only outputs geometries that are completely contained within a specified area 157 | 158 | ### Metadata 159 | 160 | When converting models from [Model Derivative service](https://aps.autodesk.com/en/docs/model-derivative/v2), you can retrieve the model properties and metadata in form of a sqlite database. The command line tool downloads this database automatically as _properties.sqlite_ file directly in your output folder. If you're using this library in your own Node.js code, you can find the database in the manifest by looking for an asset with type "resource", and role "Autodesk.CloudPlatform.PropertyDatabase": 161 | 162 | ```js 163 | ... 164 | const pdbDerivatives = manifestHelper.search({ type: 'resource', role: 'Autodesk.CloudPlatform.PropertyDatabase' }); 165 | if (pdbDerivatives.length > 0) { 166 | const databaseStream = modelDerivativeClient.getDerivativeChunked(urn, pdbDerivatives[0].urn, 1 << 20); 167 | databaseStream.pipe(fs.createWriteStream('./properties.sdb')); 168 | } 169 | ... 170 | ``` 171 | 172 | The structure of the sqlite database, and the way to extract model properties from it is explained in https://github.com/wallabyway/propertyServer/blob/master/pipeline.md. Here's a simple diagram showing the individual tables in the database, and the relationships between them: 173 | 174 | ![Property Database Diagram](https://user-images.githubusercontent.com/440241/42006177-35a1070e-7a2d-11e8-8c9e-48a0afeea00f.png) 175 | 176 | And here's an example query listing all objects with "Material" property containing the "Concrete" word: 177 | 178 | ```sql 179 | SELECT _objects_id.id AS dbId, _objects_id.external_id AS externalId, _objects_attr.name AS propName, _objects_val.value AS propValue 180 | FROM _objects_eav 181 | INNER JOIN _objects_id ON _objects_eav.entity_id = _objects_id.id 182 | INNER JOIN _objects_attr ON _objects_eav.attribute_id = _objects_attr.id 183 | INNER JOIN _objects_val ON _objects_eav.value_id = _objects_val.id 184 | WHERE propName = "Material" AND propValue LIKE "%Concrete%" 185 | ``` 186 | 187 | ### GLB, Draco, and other post-processing 188 | 189 | Following the Unix philosophy, we removed post-processing dependencies from this project, and instead leave it to developers to "pipe" the output of this library to other tools such as https://github.com/CesiumGS/gltf-pipeline or https://github.com/zeux/meshoptimizer. See [./samples/local-svf-to-gltf.sh](./samples/local-svf-to-gltf.sh) or 190 | [./samples/remote-svf-to-gltf.sh](./samples/remote-svf-to-gltf.sh) for examples. 191 | 192 | ## Development 193 | 194 | - clone the repository 195 | - install dependencies: `yarn install` 196 | - build the library (transpile TypeScript): `yarn run build` 197 | - run samples in the _test_ subfolder, for example: `APS_CLIENT_ID= APS_CLIENT_SECRET= node test/remote-svf-to-gltf.js ` 198 | 199 | If you're using [Visual Studio Code](https://code.visualstudio.com), you can use the following "task" and "launch" configurations: 200 | 201 | In _.vscode/tasks.json_: 202 | 203 | ```json 204 | ... 205 | { 206 | "label": "build", 207 | "type": "npm", 208 | "script": "build", 209 | "problemMatcher": [ 210 | "$tsc" 211 | ], 212 | "group": "build", 213 | "presentation": { 214 | "echo": true, 215 | "reveal": "silent", 216 | "focus": false, 217 | "panel": "shared", 218 | "showReuseMessage": false, 219 | "clear": false 220 | } 221 | } 222 | ... 223 | ``` 224 | 225 | In _.vscode/launch.json_: 226 | 227 | ```json 228 | ... 229 | { 230 | "type": "node", 231 | "request": "launch", 232 | "name": "Convert Model Derivative SVF to glTF", 233 | "program": "${workspaceFolder}/test/remote-svf-to-gltf.js", 234 | "args": ["", ""], 235 | "env": { 236 | "APS_CLIENT_ID": "", 237 | "APS_CLIENT_SECRET": "" 238 | }, 239 | "preLaunchTask": "build" 240 | }, 241 | { 242 | "type": "node", 243 | "request": "launch", 244 | "name": "Convert Local SVF to glTF", 245 | "program": "${workspaceFolder}/test/local-svf-to-gltf.js", 246 | "args": ["", ""], 247 | "preLaunchTask": "build" 248 | } 249 | ... 250 | ``` 251 | 252 | ### Intermediate Format 253 | 254 | The project provides a collection of interfaces for an [intermediate 3D format](./src/common/intermediate-format.ts) that is meant to be used by all loaders and writers. When implementing a new loader, make sure that its output implements the intermediate format's `IScene` interface. Similarly, this interface should also be expected as the input to all new writers. 255 | -------------------------------------------------------------------------------- /bin/svf-to-gltf.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const path = require('path'); 4 | const { parseArgs } = require('node:util'); 5 | const { Scopes } = require('@aps_sdk/authentication'); 6 | const { ModelDerivativeClient} = require('@aps_sdk/model-derivative'); 7 | const { SVFReader, GLTFWriter, BasicAuthenticationProvider, TwoLeggedAuthenticationProvider } = require('../lib'); 8 | 9 | const { APS_CLIENT_ID, APS_CLIENT_SECRET, APS_ACCESS_TOKEN, APS_REGION } = process.env; 10 | let authenticationProvider = null; 11 | if (APS_ACCESS_TOKEN) { 12 | authenticationProvider = new BasicAuthenticationProvider(APS_ACCESS_TOKEN); 13 | } else if (APS_CLIENT_ID && APS_CLIENT_SECRET) { 14 | authenticationProvider = new TwoLeggedAuthenticationProvider(APS_CLIENT_ID, APS_CLIENT_SECRET); 15 | } 16 | 17 | async function convertRemote(urn, guid, outputFolder, options) { 18 | console.log(`Converting urn ${urn}, guid ${guid}`); 19 | const reader = await SVFReader.FromDerivativeService(urn, guid, authenticationProvider); 20 | const scene = await reader.read({ log: console.log }); 21 | const writer = new GLTFWriter(options); 22 | await writer.write(scene, path.join(outputFolder, guid)); 23 | } 24 | 25 | async function convertLocal(svfPath, outputFolder, options) { 26 | console.log(`Converting local file ${svfPath}`); 27 | const reader = await SVFReader.FromFileSystem(svfPath); 28 | const scene = await reader.read({ log: console.log }); 29 | const writer = new GLTFWriter(options); 30 | await writer.write(scene, path.join(outputFolder)); 31 | } 32 | 33 | // Parse command line arguments 34 | const args = parseArgs({ 35 | options: { 36 | 'output-folder': { 37 | type: 'string', 38 | short: 'o', 39 | default: '.', 40 | description: 'Output folder.' 41 | }, 42 | 'deduplicate': { 43 | type: 'boolean', 44 | default: false, 45 | description: 'Deduplicate geometries (may increase processing time).' 46 | }, 47 | 'skip-unused-uvs': { 48 | type: 'boolean', 49 | short: 's', 50 | default: false, 51 | description: 'Skip unused texture coordinate data.' 52 | }, 53 | 'ignore-meshes': { 54 | type: 'boolean', 55 | short: 'im', 56 | default: false, 57 | description: 'Ignore mesh geometry.' 58 | }, 59 | 'ignore-lines': { 60 | type: 'boolean', 61 | short: 'il', 62 | default: false, 63 | description: 'Ignore line geometry.' 64 | }, 65 | 'ignore-points': { 66 | type: 'boolean', 67 | short: 'ip', 68 | default: false, 69 | description: 'Ignore point geometry.' 70 | }, 71 | 'center': { 72 | type: 'boolean', 73 | default: false, 74 | description: 'Move the model to the origin.', 75 | } 76 | }, 77 | allowPositionals: true 78 | }); 79 | const [urn, guid] = args.positionals; 80 | if (!urn || !guid) { 81 | console.error('Usage: svf-to-gltf.js '); 82 | process.exit(1); 83 | } 84 | 85 | const options = { 86 | deduplicate: args.values.deduplicate, 87 | skipUnusedUvs: args.values['skip-unused-uvs'], 88 | ignoreMeshGeometry: args.values['ignore-meshes'], 89 | ignoreLineGeometry: args.values['ignore-lines'], 90 | ignorePointGeometry: args.values['ignore-points'], 91 | center: args.values.center, 92 | log: console.log 93 | }; 94 | try { 95 | if (id.endsWith('.svf')) { 96 | // ID is a path to local SVF file 97 | const filepath = id; 98 | convertLocal(filepath, args.values['output-folder'], options); 99 | } else { 100 | // ID is the Model Derivative URN 101 | // Convert input guid or all guids 102 | if (!authenticationProvider) { 103 | console.warn('Missing environment variables for APS authentication.'); 104 | console.warn('Provide APS_CLIENT_ID and APS_CLIENT_SECRET, or APS_ACCESS_TOKEN.'); 105 | return; 106 | } 107 | 108 | const urn = id; 109 | const folder = path.join(args.values['output-folder'], urn); 110 | if (guid) { 111 | await convertRemote(urn, guid, folder, options); 112 | } else { 113 | const modelDerivativeClient = new ModelDerivativeClient(); 114 | const accessToken = await authenticationProvider.getToken([Scopes.ViewablesRead]); 115 | const manifest = await modelDerivativeClient.getManifest(urn, { accessToken, region: APS_REGION }); 116 | const derivatives = []; 117 | function traverse(derivative) { 118 | if (derivative.type === 'resource' && derivative.role === 'graphics' && derivative.mime === 'application/autodesk-svf') { 119 | derivatives.push(derivative); 120 | } 121 | if (derivative.children) { 122 | for (const child of derivative.children) { 123 | traverse(child); 124 | } 125 | } 126 | } 127 | for (const derivative of manifest.derivatives) { 128 | if (derivative.children) { 129 | for (const child of derivative.children) { 130 | traverse(child); 131 | } 132 | } 133 | } 134 | for (const derivative of derivatives) { 135 | await convertRemote(urn, derivative.guid, folder, options); 136 | } 137 | } 138 | } 139 | } catch (err) { 140 | console.error(err); 141 | } -------------------------------------------------------------------------------- /bin/svf2-to-gltf.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const path = require('path'); 4 | const { parseArgs } = require('node:util'); 5 | const { SVF2Reader, GLTFWriter, BasicAuthenticationProvider, TwoLeggedAuthenticationProvider } = require('..'); 6 | 7 | async function run(urn, outputDir, options, authenticationProvider) { 8 | const reader = await SVF2Reader.FromDerivativeService(urn, authenticationProvider); 9 | const views = await reader.listViews(); 10 | for (const view of views) { 11 | const scene = await reader.readView(view); 12 | const writer = new GLTFWriter({ 13 | deduplicate: false, 14 | center: options.center, 15 | ignoreLineGeometry: true, 16 | ignorePointGeometry: true, 17 | skipUnusedUvs: true, 18 | log: console.log 19 | }); 20 | await writer.write(scene, path.join(outputDir, view)); 21 | } 22 | } 23 | 24 | // Read authentication credentials from environment variables 25 | const { APS_CLIENT_ID, APS_CLIENT_SECRET, APS_ACCESS_TOKEN } = process.env; 26 | let authenticationProvider = null; 27 | if (APS_ACCESS_TOKEN) { 28 | authenticationProvider = new BasicAuthenticationProvider(APS_ACCESS_TOKEN); 29 | } else if (APS_CLIENT_ID && APS_CLIENT_SECRET) { 30 | authenticationProvider = new TwoLeggedAuthenticationProvider(APS_CLIENT_ID, APS_CLIENT_SECRET); 31 | } else { 32 | console.error('Missing authentication credentials. Set APS_ACCESS_TOKEN or APS_CLIENT_ID and APS_CLIENT_SECRET.'); 33 | process.exit(1); 34 | } 35 | 36 | // Parse command line arguments 37 | const args = parseArgs({ 38 | options: { 39 | center: { 40 | type: 'boolean', 41 | default: true, 42 | description: 'Move the model to the origin.' 43 | } 44 | }, 45 | allowPositionals: true 46 | }); 47 | const [urn, outputDir] = args.positionals; 48 | if (!urn || !outputDir) { 49 | console.error('Usage: svf2-to-gltf.js [--center]'); 50 | process.exit(1); 51 | } 52 | 53 | run(urn, outputDir, args.values, authenticationProvider) 54 | .then(() => console.log('Done!')) 55 | .catch(err => { console.error(err.message); process.exit(1); }); -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/petrbroz/svf-utils/08e735f81df370067c91a4cf55c86b11adc610af/logo.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "svf-utils", 3 | "version": "7.0.0", 4 | "description": "Tools for working with the SVF format used by Autodesk Platform Services.", 5 | "main": "lib/index.js", 6 | "bin": { 7 | "svf-to-gltf": "./bin/svf-to-gltf.js", 8 | "svf2-to-gltf": "./bin/svf2-to-gltf.js" 9 | }, 10 | "engines": { 11 | "node": ">=20.0.0" 12 | }, 13 | "scripts": { 14 | "build": "tsc", 15 | "docs": "typedoc --out docs src" 16 | }, 17 | "author": "Petr Broz ", 18 | "license": "MIT", 19 | "keywords": [ 20 | "autodesk-platform-services", 21 | "gltf", 22 | "typescript" 23 | ], 24 | "repository": { 25 | "type": "git", 26 | "url": "git+https://github.com/petrbroz/svf-utils.git" 27 | }, 28 | "bugs": { 29 | "url": "https://github.com/petrbroz/svf-utils/issues" 30 | }, 31 | "homepage": "https://github.com/petrbroz/svf-utils#readme", 32 | "devDependencies": { 33 | "@gltf-transform/core": "^4.1.1", 34 | "@gltf-transform/extensions": "^4.1.1", 35 | "@gltf-transform/functions": "^4.1.1", 36 | "@types/adm-zip": "^0.5.7", 37 | "@types/fs-extra": "^11.0.4", 38 | "@types/ws": "^8.5.14", 39 | "gltf-validator": "^2.0.0-dev.3.2", 40 | "typedoc": "^0.28.2", 41 | "typescript": "^5.8.3" 42 | }, 43 | "dependencies": { 44 | "@aps_sdk/authentication": "^1.0.0", 45 | "@aps_sdk/model-derivative": "^1.1.0", 46 | "adm-zip": "^0.5.9", 47 | "axios": "^1.8.4", 48 | "fs-extra": "^11.3.0", 49 | "ws": "^8.18.0", 50 | "zod": "^3.24.1" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /res/placeholder.bmp: -------------------------------------------------------------------------------- 1 | BM~zl#.#.BGRs -------------------------------------------------------------------------------- /res/placeholder.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/petrbroz/svf-utils/08e735f81df370067c91a4cf55c86b11adc610af/res/placeholder.gif -------------------------------------------------------------------------------- /res/placeholder.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/petrbroz/svf-utils/08e735f81df370067c91a4cf55c86b11adc610af/res/placeholder.jpg -------------------------------------------------------------------------------- /res/placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/petrbroz/svf-utils/08e735f81df370067c91a4cf55c86b11adc610af/res/placeholder.png -------------------------------------------------------------------------------- /samples/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:lts 2 | 3 | LABEL maintainer="petr.broz@autodesk.com" 4 | LABEL description="Docker image for experimenting with svf-utils and other glTF tools." 5 | 6 | # Prepare gltfpack 7 | RUN wget https://github.com/zeux/meshoptimizer/releases/download/v0.13/gltfpack-0.13-ubuntu.zip -O /tmp/gltfpack.zip 8 | RUN unzip -j -d /usr/local/bin /tmp/gltfpack.zip 9 | RUN chmod a+x /usr/local/bin/gltfpack 10 | RUN rm /tmp/gltfpack.zip 11 | 12 | # Install Node.js dependencies 13 | RUN npm install --global gltf-pipeline@^2.0.0 14 | RUN npm install --global svf-utils@^5.0.0 15 | 16 | # Add test scripts 17 | ADD *.sh *.js /tmp/ 18 | 19 | WORKDIR /tmp 20 | -------------------------------------------------------------------------------- /samples/custom-gltf-attribute.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Example: converting an SVF from Model Derivative service into glTF, 3 | * embedding object IDs into the COLOR_0 mesh channel. 4 | * Usage: 5 | * export APS_CLIENT_ID= 6 | * export APS_CLIENT_SECRET= 7 | * export APS_REGION= # optional, can be one of the following: "US", "EMEA", "AUS" 8 | * node custom-gltf-attribute.js 9 | */ 10 | 11 | const path = require('path'); 12 | const { getSvfDerivatives } = require('./shared.js'); 13 | const { SVFReader, GLTFWriter, TwoLeggedAuthenticationProvider } = require('..'); 14 | 15 | /* 16 | * Customized glTF writer, outputting meshes with an additional _CUSTOM_INDEX 17 | * mesh attribute (UNSIGNED_BYTE, vec4) encoding a 32-bit object ID. 18 | */ 19 | class CustomGLTFWriter extends GLTFWriter { 20 | constructor(options) { 21 | super(options); 22 | this._currentDbId = -1; 23 | } 24 | 25 | createNode(fragment /* IMF.IObjectNode */, imf /* IMF.IScene */, outputUvs /* boolean */) /* gltf.Node */ { 26 | this._currentDbId = fragment.dbid; 27 | return super.createNode(fragment, imf, outputUvs); 28 | } 29 | 30 | createMeshGeometry(geometry /* IMF.IMeshGeometry */, imf /* IMF.IScene */, outputUvs /* boolean */) /* gltf.Mesh */ { 31 | let mesh = super.createMeshGeometry(geometry, imf, outputUvs); 32 | let prim = mesh.primitives[0]; 33 | 34 | if (prim) { 35 | // Output custom attr buffer 36 | const vertexCount = geometry.getVertices().length / 3; 37 | const customBuffer = Buffer.alloc(vertexCount * 4); 38 | for (let i = 0; i < customBuffer.length; i += 4) { 39 | customBuffer[i] = this._currentDbId & 0xff; 40 | customBuffer[i + 1] = (this._currentDbId >> 8) & 0xff; 41 | customBuffer[i + 2] = (this._currentDbId >> 16) & 0xff; 42 | customBuffer[i + 3] = (this._currentDbId >> 24) & 0xff; 43 | } 44 | const customBufferView = this.createBufferView(customBuffer); 45 | const customBufferViewID = this.addBufferView(customBufferView); 46 | const customAccessor = this.createAccessor(customBufferViewID, 5121 /* UNSIGNED_BYTE */, customBufferView.byteLength / 4, 'VEC4'); 47 | customAccessor.normalized = true; 48 | const customAccessorID = this.addAccessor(customAccessor); 49 | prim.attributes['COLOR_0'] = customAccessorID; 50 | } 51 | 52 | return mesh; 53 | } 54 | 55 | computeMeshHash(mesh /* gltf.Mesh */) /* string */ { 56 | return mesh.primitives.map(p => { 57 | return `${p.mode || ''}/${p.material || ''}/${p.indices}` 58 | + `/${p.attributes['POSITION'] || ''}/${p.attributes['NORMAL'] || ''}/${p.attributes['TEXCOORD_0'] || ''}` 59 | + `/${p.attributes['COLOR_0'] || ''}`; 60 | }).join('/'); 61 | } 62 | } 63 | 64 | const { APS_CLIENT_ID, APS_CLIENT_SECRET, APS_REGION } = process.env; 65 | 66 | async function run(urn, outputDir) { 67 | try { 68 | const derivatives = await getSvfDerivatives(urn, APS_CLIENT_ID, APS_CLIENT_SECRET, APS_REGION); 69 | const authenticationProvider = new TwoLeggedAuthenticationProvider(APS_CLIENT_ID, APS_CLIENT_SECRET); 70 | const writer = new CustomGLTFWriter({ 71 | deduplicate: false, 72 | skipUnusedUvs: false, 73 | center: true, 74 | log: console.log 75 | }); 76 | for (const derivative of derivatives) { 77 | const reader = await SVFReader.FromDerivativeService(urn, derivative.guid, authenticationProvider); 78 | const scene = await reader.read({ log: console.log }); 79 | await writer.write(scene, path.join(outputDir, derivative.guid)); 80 | } 81 | } catch (err) { 82 | console.error(err); 83 | process.exit(1); 84 | } 85 | } 86 | 87 | run(process.argv[2], process.argv[3]); 88 | -------------------------------------------------------------------------------- /samples/download-f2d.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Example: downloading F2D assets for all viewables in a Model Derivative URN. 3 | * Usage: 4 | * export APS_CLIENT_ID= 5 | * export APS_CLIENT_SECRET= 6 | * export APS_REGION= # optional, can be one of the following: "US", "EMEA", "AUS" 7 | * node download-f2d.js 8 | */ 9 | 10 | const { F2DDownloader } = require('..'); 11 | const { initializeAuthenticationProvider } = require('./shared.js'); 12 | 13 | const [,, urn, outputDir] = process.argv; 14 | if (!urn || !outputDir) { 15 | console.error('Usage: node download-f2d.js '); 16 | process.exit(1); 17 | } 18 | 19 | const authenticationProvider = initializeAuthenticationProvider(); 20 | const downloader = new F2DDownloader(authenticationProvider); 21 | downloader.download(urn, { outputDir, log: console.log, region: process.env.APS_REGION }) 22 | .then(() => console.log('Done!')) 23 | .catch(err => { 24 | console.error(err); 25 | process.exit(1); 26 | }); -------------------------------------------------------------------------------- /samples/download-svf.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Example: downloading SVF assets for all viewables in a Model Derivative URN. 3 | * Usage: 4 | * export APS_CLIENT_ID= 5 | * export APS_CLIENT_SECRET= 6 | * export APS_REGION= # optional, can be one of the following: "US", "EMEA", "AUS" 7 | * node download-svf.js 8 | */ 9 | 10 | const { SVFDownloader } = require('..'); 11 | const { initializeAuthenticationProvider } = require('./shared.js'); 12 | 13 | const [,, urn, outputDir] = process.argv; 14 | if (!urn || !outputDir) { 15 | console.error('Usage: node download-svf.js '); 16 | process.exit(1); 17 | } 18 | 19 | const authenticationProvider = initializeAuthenticationProvider(); 20 | const downloader = new SVFDownloader(authenticationProvider); 21 | downloader.download(urn, { outputDir, log: console.log, region: process.env.APS_REGION }) 22 | .then(() => console.log('Done!')) 23 | .catch(err => { 24 | console.error(err); 25 | process.exit(1); 26 | }); -------------------------------------------------------------------------------- /samples/download-svf2.js: -------------------------------------------------------------------------------- 1 | /** 2 | * A sample script that downloads an SVF2 model from the Model Derivative service. 3 | * 4 | * Usage: 5 | * 6 | * node download-svf2.js 7 | * 8 | * - `urn` is the URN of the SVF2 file to download. 9 | * - `outputDir` is the directory to save the downloaded SVF2 file. 10 | * 11 | * Set the following environment variables: 12 | * 13 | * - `APS_CLIENT_ID`: client ID of your APS application. 14 | * - `APS_CLIENT_SECRET`: client secret of your APS application. 15 | * 16 | * Alternatively, you can set the following environment variable: 17 | * 18 | * - `APS_ACCESS_TOKEN`: existing access token (with "viewables:read" scope). 19 | */ 20 | 21 | const { SVF2Downloader } = require('..'); 22 | const { initializeAuthenticationProvider } = require('./shared.js'); 23 | 24 | const [,, urn, outputDir] = process.argv; 25 | if (!urn || !outputDir) { 26 | console.error('Usage: node download-svf2.js '); 27 | process.exit(1); 28 | } 29 | 30 | const authenticationProvider = initializeAuthenticationProvider(); 31 | const downloader = new SVF2Downloader(authenticationProvider); 32 | downloader.download(urn, { outputDir, log: console.log }) 33 | .then(() => console.log('Done!')) 34 | .catch(err => { 35 | console.error(err); 36 | process.exit(1); 37 | }); -------------------------------------------------------------------------------- /samples/filter-by-area.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Example: converting a subset of SVF (based on a specified area) into glTF. 3 | * Usage: 4 | * npm install --save gl-matrix 5 | * export APS_CLIENT_ID= 6 | * export APS_CLIENT_SECRET= 7 | * export APS_REGION= # optional, can be one of the following: "US", "EMEA", "AUS" 8 | * node filter-by-area.js 9 | */ 10 | 11 | const path = require('path'); 12 | const { mat4, vec3 } = require('gl-matrix'); 13 | const { getSvfDerivatives } = require('./shared.js'); 14 | const { SVFReader, GLTFWriter, TwoLeggedAuthenticationProvider } = require('..'); 15 | 16 | /* 17 | * Customized glTF writer, only outputting meshes completely contained in a specified area. 18 | */ 19 | class AreaFilteredGLTFWriter extends GLTFWriter { 20 | /** 21 | * Initializes the writer. 22 | * @param {IWriterOptions} [options={}] Additional writer options. 23 | * @param {number[]} min Minimum x/y/z values of the filtered area. 24 | * @param {number[]} max Maximum x/y/z values of the filtered area. 25 | */ 26 | constructor(options, min, max) { 27 | super(options); 28 | this._min = min; 29 | this._max = max; 30 | this._xform = mat4.create(); 31 | } 32 | 33 | createNode(fragment /* IMF.IObjectNode */, imf /* IMF.IScene */, outputUvs /* boolean */) { 34 | // A bit of a hack: we need to pass the transform down the call stack to compute world bounds 35 | mat4.identity(this._xform); 36 | if (fragment.transform) { 37 | if (fragment.transform.elements) { 38 | mat4.copy(this._xform, fragment.transform.elements); 39 | } else { 40 | const { rotation: r, translation: t, scale: s } = fragment.transform; 41 | mat4.fromRotationTranslationScale( 42 | this._xform, 43 | r ? [r.x, r.y, r.z, r.w] : [0, 0, 0, 1], 44 | t ? [t.x, t.y, t.z] : [0, 0, 0], 45 | s ? [s.x, s.y, s.z] : [1, 1, 1] 46 | ); 47 | } 48 | } 49 | return super.createNode(fragment, imf, outputUvs); 50 | } 51 | 52 | createMeshGeometry(geometry /* IMF.IMeshGeometry */, imf /* IMF.IScene */, outputUvs /* boolean */) /* gltf.Mesh */ { 53 | const bounds = this._computeWorldBoundsVec3(geometry.getVertices(), this._xform); 54 | // Ignore the geometry if it's not fully contained in the filtered area 55 | if (bounds.min[0] < this._min[0] || bounds.min[1] < this._min[1] || bounds.min[2] < this._min[2] 56 | || bounds.max[0] > this._max[0] || bounds.max[1] > this._max[1] || bounds.max[2] > this._max[2]) { 57 | console.log('Skipping mesh outside of the filtered area...'); 58 | return { primitives: [] }; 59 | } 60 | return super.createMeshGeometry(geometry, imf, outputUvs); 61 | } 62 | 63 | createLineGeometry(geometry /* IMF.ILineGeometry */, imf /* IMF.IScene */) { 64 | const bounds = this._computeWorldBoundsVec3(geometry.getVertices(), this._xform); 65 | // Ignore the geometry if it's not fully contained in the filtered area 66 | if (bounds.min[0] < this._min[0] || bounds.min[1] < this._min[1] || bounds.min[2] < this._min[2] 67 | || bounds.max[0] > this._max[0] || bounds.max[1] > this._max[1] || bounds.max[2] > this._max[2]) { 68 | console.log('Skipping mesh outside of the filtered area...'); 69 | return { primitives: [] }; 70 | } 71 | return super.createLineGeometry(geometry, imf); 72 | } 73 | 74 | createPointGeometry(geometry /* IMF.IPointGeometry */, imf /* IMF.IScene */) { 75 | const bounds = this._computeWorldBoundsVec3(geometry.getVertices(), this._xform); 76 | // Ignore the geometry if it's not fully contained in the filtered area 77 | if (bounds.min[0] < this._min[0] || bounds.min[1] < this._min[1] || bounds.min[2] < this._min[2] 78 | || bounds.max[0] > this._max[0] || bounds.max[1] > this._max[1] || bounds.max[2] > this._max[2]) { 79 | console.log('Skipping mesh outside of the filtered area...'); 80 | return { primitives: [] }; 81 | } 82 | return super.createPointGeometry(geometry, imf); 83 | } 84 | 85 | _computeWorldBoundsVec3(array /* Float32Array */, xform /* mat4 */) { 86 | const min = [Number.MAX_VALUE, Number.MAX_VALUE, Number.MAX_VALUE]; 87 | const max = [Number.MIN_VALUE, Number.MIN_VALUE, Number.MIN_VALUE]; 88 | let origPoint = vec3.create(); 89 | let xformPoint = vec3.create(); 90 | for (let i = 0; i < array.length; i += 3) { 91 | vec3.set(origPoint, array[i], array[i + 1], array[i + 2]); 92 | vec3.transformMat4(xformPoint, origPoint, xform); 93 | vec3.min(min, min, xformPoint); 94 | vec3.max(max, max, xformPoint); 95 | } 96 | return { min, max }; 97 | } 98 | } 99 | 100 | const { APS_CLIENT_ID, APS_CLIENT_SECRET, APS_REGION } = process.env; 101 | 102 | async function run(urn, outputDir) { 103 | const DefaultOptions = { 104 | deduplicate: false, 105 | skipUnusedUvs: false, 106 | center: true, 107 | log: console.log 108 | }; 109 | 110 | try { 111 | const derivatives = await getSvfDerivatives(urn, APS_CLIENT_ID, APS_CLIENT_SECRET, APS_REGION); 112 | const authenticationProvider = new TwoLeggedAuthenticationProvider(APS_CLIENT_ID, APS_CLIENT_SECRET); 113 | const writer = new AreaFilteredGLTFWriter(Object.assign({}, DefaultOptions), [-25.0, -25.0, -25.0], [25.0, 25.0, 25.0]); 114 | for (const derivative of derivatives) { 115 | const reader = await SVFReader.FromDerivativeService(urn, derivative.guid, authenticationProvider); 116 | const scene = await reader.read({ log: console.log }); 117 | await writer.write(scene, path.join(outputDir, derivative.guid)); 118 | } 119 | } catch(err) { 120 | console.error(err); 121 | process.exit(1); 122 | } 123 | } 124 | 125 | run(process.argv[2], process.argv[3]); -------------------------------------------------------------------------------- /samples/local-svf-props.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Example: parsing object properties from a local set of *.json.gz files. 3 | * Usage: 4 | * node local-svf-props.js 5 | */ 6 | 7 | const path = require('path'); 8 | const fs = require('fs'); 9 | const { PropDbReader } = require('../lib/common/propdb-reader.js'); 10 | 11 | function run(dir) { 12 | const ids = fs.readFileSync(path.join(dir, 'objects_ids.json.gz')); 13 | const offs = fs.readFileSync(path.join(dir, 'objects_offs.json.gz')); 14 | const avs = fs.readFileSync(path.join(dir, 'objects_avs.json.gz')); 15 | const attrs = fs.readFileSync(path.join(dir, 'objects_attrs.json.gz')); 16 | const vals = fs.readFileSync(path.join(dir, 'objects_vals.json.gz')); 17 | const db = new PropDbReader(ids, offs, avs, attrs, vals); 18 | const numObjects = offs.length - 1; 19 | for (let dbid = 1; dbid < numObjects; dbid++) { 20 | console.log(`Properties of #${dbid}`); 21 | for (const prop of db.enumerateProperties(dbid)) { 22 | console.log(`${prop.category}: ${prop.name} = ${prop.value}`); 23 | } 24 | } 25 | console.log(`Children of #1: ${db.getChildren(1).join(',')}`); 26 | } 27 | 28 | if (process.argv.length >= 3) { 29 | try { 30 | run(process.argv[2]); 31 | } catch (err) { 32 | console.error(err); 33 | process.exit(1); 34 | } 35 | } else { 36 | console.log('Usage:'); 37 | console.log(' node local-svf-props.js '); 38 | } 39 | -------------------------------------------------------------------------------- /samples/local-svf-to-gltf.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Example: converting an SVF (without property database) from local file system. 3 | * Usage: 4 | * node local-svf-to-gltf.js 5 | */ 6 | 7 | const path = require('path'); 8 | const { SVFReader, GLTFWriter } = require('..'); 9 | 10 | async function run(filepath, outputDir) { 11 | try { 12 | const reader = await SVFReader.FromFileSystem(filepath); 13 | const scene = await reader.read(); 14 | let writer; 15 | writer = new GLTFWriter({ deduplicate: false, skipUnusedUvs: false, center: true, log: console.log }); 16 | await writer.write(scene, path.join(outputDir, 'gltf-raw')); 17 | writer = new GLTFWriter({ deduplicate: true, skipUnusedUvs: true, center: true, log: console.log }); 18 | await writer.write(scene, path.join(outputDir, 'gltf-dedup')); 19 | } catch(err) { 20 | console.error(err); 21 | process.exit(1); 22 | } 23 | } 24 | 25 | run(process.argv[2], process.argv[3]); 26 | -------------------------------------------------------------------------------- /samples/local-svf-to-gltf.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Script converting an SVF (without property database) from local file system 4 | # into (1) vanilla gltf, (2) gltf with Draco compression, (3) binary gltf, and 5 | # (4) binary gltf with Draco compression. 6 | # Usage example: 7 | # ./local-svf-to-gltf.sh 8 | 9 | # Install dependencies 10 | npm install --global svf-utils gltf-pipeline 11 | 12 | # Convert SVF to glTF with [svf-utils](https://github.com/petrbroz/svf-utils) 13 | svf-to-gltf $1 --output-folder $2/gltf --deduplicate --skip-unused-uvs --ignore-lines --ignore-points 14 | 15 | # Validate glTF using [gltf-validator](https://github.com/KhronosGroup/glTF-Validator), if available 16 | if [ -x "$(command -v gltf_validator)" ]; then 17 | gltf_validator $2/gltf/output.gltf 18 | fi 19 | 20 | # Post-process with [gltf-pipeline](https://github.com/AnalyticalGraphicsInc/gltf-pipeline) 21 | gltf-pipeline -i $2/gltf/output.gltf -o $2/gltf-draco/output.gltf -d 22 | gltf-pipeline -i $2/gltf/output.gltf -o $2/glb/output.glb 23 | gltf-pipeline -i $2/gltf/output.gltf -o $2/glb-draco/output.glb -d 24 | 25 | # Post-process with [gltfpack](https://github.com/zeux/meshoptimizer#gltfpack), if available 26 | if [ -x "$(command -v gltfpack)" ]; then 27 | mkdir -p $2/glb-pack 28 | gltfpack -i $2/gltf/output.gltf -o $2/glb-pack/output.glb 29 | fi 30 | -------------------------------------------------------------------------------- /samples/remote-svf-props.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Example: parsing object properties for all viewables in a Model Derivative URN. 3 | * Usage: 4 | * export APS_CLIENT_ID= 5 | * export APS_CLIENT_SECRET= 6 | * export APS_REGION= # optional, can be one of the following: "US", "EMEA", "AUS" 7 | * node remote-svf-props.js 8 | */ 9 | 10 | const { getSvfDerivatives } = require('./shared.js'); 11 | const { SVFReader, TwoLeggedAuthenticationProvider } = require('..'); 12 | 13 | const { APS_CLIENT_ID, APS_CLIENT_SECRET, APS_REGION } = process.env; 14 | 15 | async function run(urn) { 16 | const derivatives = await getSvfDerivatives(urn, APS_CLIENT_ID, APS_CLIENT_SECRET, APS_REGION); 17 | const authenticationProvider = new TwoLeggedAuthenticationProvider(APS_CLIENT_ID, APS_CLIENT_SECRET); 18 | for (const derivative of derivatives) { 19 | const reader = await SVFReader.FromDerivativeService(urn, derivative.guid, authenticationProvider); 20 | const propdb = await reader.getPropertyDb(); 21 | const props = propdb.getProperties(1); 22 | for (const name of Object.keys(props)) { 23 | console.log(`${name}: ${props[name]}`); 24 | } 25 | console.log(`Children: ${propdb.getChildren(1).join(',')}`); 26 | } 27 | } 28 | 29 | run(process.argv[2]); 30 | -------------------------------------------------------------------------------- /samples/remote-svf-to-gltf.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Example: converting an SVF from Model Derivative service. 3 | * Usage: 4 | * export APS_CLIENT_ID= 5 | * export APS_CLIENT_SECRET= 6 | * node remote-svf-to-gltf.js 7 | */ 8 | 9 | const path = require('path'); 10 | const { getSvfDerivatives } = require('./shared.js'); 11 | const { SVFReader, GLTFWriter, TwoLeggedAuthenticationProvider } = require('..'); 12 | 13 | const { APS_CLIENT_ID, APS_CLIENT_SECRET, APS_REGION } = process.env; 14 | 15 | async function run(urn, outputDir) { 16 | try { 17 | const derivatives = await getSvfDerivatives(urn, APS_CLIENT_ID, APS_CLIENT_SECRET, APS_REGION); 18 | const writer0 = new GLTFWriter({ deduplicate: false, skipUnusedUvs: false, center: true, log: console.log }); 19 | const writer1 = new GLTFWriter({ deduplicate: true, skipUnusedUvs: true, center: true, log: console.log }); 20 | const authenticationProvider = new TwoLeggedAuthenticationProvider(APS_CLIENT_ID, APS_CLIENT_SECRET); 21 | for (const derivative of derivatives) { 22 | const reader = await SVFReader.FromDerivativeService(urn, derivative.guid, authenticationProvider); 23 | const scene = await reader.read({ log: console.log }); 24 | await writer0.write(scene, path.join(outputDir, derivative.guid, 'gltf-raw')); 25 | await writer1.write(scene, path.join(outputDir, derivative.guid, 'gltf-dedup')); 26 | } 27 | } catch(err) { 28 | console.error(err); 29 | process.exit(1); 30 | } 31 | } 32 | 33 | run(process.argv[2], process.argv[3]); 34 | -------------------------------------------------------------------------------- /samples/remote-svf-to-gltf.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Script converting an SVF (without property database) from Model Derivative service 4 | # to glTF, and post-processing it using various 3rd party tools. 5 | # 6 | # Usage example with APS credentials: 7 | # export APS_CLIENT_ID= 8 | # export APS_CLIENT_SECRET= 9 | # ./remote-svf-to-gltf.sh 10 | # 11 | # Usage example with an existing token: 12 | # export APS_ACCESS_TOKEN= 13 | # ./remote-svf-to-gltf.sh 14 | 15 | # Install dependencies 16 | npm install --global svf-utils gltf-pipeline 17 | 18 | # Convert SVF to glTF with [svf-utils](https://github.com/petrbroz/svf-utils) 19 | svf-to-gltf $1 --output-folder $2/gltf --deduplicate --skip-unused-uvs --ignore-lines --ignore-points 20 | 21 | # Iterate over glTFs generated for all viewables (in / subfolders) 22 | for gltf in $(find $2/gltf -name "output.gltf"); do 23 | guid_dir=$(dirname $gltf) 24 | guid=$(basename $guid_dir) 25 | urn_dir=$(dirname $guid_dir) 26 | urn=$(basename $urn_dir) 27 | 28 | echo "Postprocessing URN: $urn GUID: $guid" 29 | 30 | # Validate glTF using [gltf-validator](https://github.com/KhronosGroup/glTF-Validator), if available 31 | if [ -x "$(command -v gltf_validator)" ]; then 32 | echo "Validating glTF manifest" 33 | gltf_validator $gltf 34 | fi 35 | 36 | # Post-process with [gltf-pipeline](https://github.com/AnalyticalGraphicsInc/gltf-pipeline) 37 | echo "Post-processing into glTF with Draco" 38 | gltf-pipeline -i $gltf -o $2/gltf-draco/$urn/$guid/output.gltf -d 39 | echo "Post-processing into glb" 40 | gltf-pipeline -i $gltf -o $2/glb/$urn/$guid/output.glb 41 | echo "Post-processing into glb with Draco" 42 | gltf-pipeline -i $gltf -o $2/glb-draco/$urn/$guid/output.glb -d 43 | 44 | # Post-process with [gltfpack](https://github.com/zeux/meshoptimizer#gltfpack), if available 45 | if [ -x "$(command -v gltfpack)" ]; then 46 | echo "Post-processing into glb with gltfpack" 47 | mkdir -p $2/glb-gltfpack/$urn/$guid 48 | gltfpack -i $gltf -o $2/glb-gltfpack/$urn/$guid/output.glb 49 | echo "Post-processing gltfpack-ed glb with Draco" 50 | gltf-pipeline -i $2/glb-gltfpack/$urn/$guid/output.glb -o $2/glb-gltfpack-draco/$urn/$guid/output.glb -d 51 | fi 52 | done 53 | -------------------------------------------------------------------------------- /samples/remote-svf2-to-gltf.js: -------------------------------------------------------------------------------- 1 | /** 2 | * A sample script that converts an SVF2 model from the Model Derivative service to glTF. 3 | * 4 | * Usage: 5 | * 6 | * node remote-svf2-to-gltf.js 7 | * 8 | * - `urn` is the URN of the SVF2 file to convert. 9 | * - `outputDir` is the directory to save the converted glTF files. 10 | * 11 | * Set the following environment variables: 12 | * 13 | * - `APS_CLIENT_ID`: client ID of your APS application. 14 | * - `APS_CLIENT_SECRET`: client secret of your APS application. 15 | * 16 | * Alternatively, you can set the following environment variable: 17 | * 18 | * - `APS_ACCESS_TOKEN`: existing access token (with "viewables:read" scope). 19 | */ 20 | 21 | const path = require('path') 22 | const { SVF2Reader, GLTFWriter } = require('..'); 23 | const { initializeAuthenticationProvider } = require('./shared.js'); 24 | 25 | const [,, urn, outputDir] = process.argv; 26 | if (!urn || !outputDir) { 27 | console.error('Usage: node remote-svf2-to-gltf.js '); 28 | process.exit(1); 29 | } 30 | 31 | async function run() { 32 | const authenticationProvider = initializeAuthenticationProvider(); 33 | const reader = await SVF2Reader.FromDerivativeService(urn, authenticationProvider); 34 | const views = await reader.listViews(); 35 | for (const view of views) { 36 | const scene = await reader.readView(view); 37 | const writer = new GLTFWriter({ 38 | deduplicate: false, 39 | skipUnusedUvs: false, 40 | center: true, 41 | log: console.log 42 | }); 43 | await writer.write(scene, path.join(outputDir, view)); 44 | } 45 | } 46 | 47 | run() 48 | .then(() => console.log('Done!')) 49 | .catch(err => { 50 | console.error(err); 51 | process.exit(1); 52 | }); -------------------------------------------------------------------------------- /samples/remote-svf2-to-usdz.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Script converting an SVF2 (without property database) from Model Derivative service 4 | # to glTF, and post-processing it using various 3rd party tools. 5 | # 6 | # Usage example with APS credentials: 7 | # export APS_CLIENT_ID="your client id" 8 | # export APS_CLIENT_SECRET="your client secret" 9 | # ./remote-svf2-to-usdz.sh "your model urn" "path to output folder" 10 | # 11 | # Usage example with an existing token: 12 | # export APS_ACCESS_TOKEN="your token" 13 | # ./remote-svf2-to-usdz.sh "your model urn" "path to output folder" 14 | 15 | docker_image="marlon360/usd-from-gltf:latest" 16 | 17 | # Check if the required arguments are provided 18 | urn=$1 19 | output_dir=$2 20 | if [ -z "$urn" ] || [ -z "$output_dir" ]; then 21 | echo "Usage: remote-svf2-to-usdz.sh " 22 | exit 1 23 | fi 24 | 25 | # Convert SVF2 to glTF 26 | echo "Converting SVF2 to glTF..." 27 | npx svf2-to-gltf "$urn" "$output_dir" 28 | 29 | # Optimize glTFs 30 | echo "Optimizing glTF to glb..." 31 | for dir in "$output_dir"/*/; do 32 | view=$(basename $dir) 33 | echo "Processing view: $view..." 34 | # npx gltfpack -i "$output_dir/$view/output.gltf" -o "$output_dir/$view.glb" -cc # Cannot use gltfpack because the gltf-to-usdz tool does not support its extensions 35 | # npx gltf-pipeline -i "$output_dir/$view/output.gltf" -o "$output_dir/$view.glb" -d 36 | node tools/consolidate-meshes.js "$output_dir/$view/output.gltf" "$output_dir/$view.consolidated.glb" 37 | done 38 | 39 | # Convert glTFs of individual views to USDZ 40 | echo "Converting glTF to USDz..." 41 | for dir in "$output_dir"/*/; do 42 | view=$(basename $dir) 43 | echo "Processing view: $view..." 44 | docker run -it --rm -v "$output_dir":/usr/app $docker_image "$view/output.gltf" "$view.usdz" 45 | docker run -it --rm -v "$output_dir":/usr/app $docker_image "$view.consolidated.glb" "$view.consolidated.usdz" 46 | done -------------------------------------------------------------------------------- /samples/serialize-msgpack.js: -------------------------------------------------------------------------------- 1 | // Run `npm install msgpackr` first 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | const { pack } = require('msgpackr'); 6 | const { getSvfDerivatives } = require('./shared.js'); 7 | const { SVFReader, GLTFWriter, TwoLeggedAuthenticationProvider } = require('..'); 8 | 9 | class MsgpackGLTFWriter extends GLTFWriter { 10 | serializeManifest(manifest, outputPath) { 11 | // fs.writeFileSync(outputPath, JSON.stringify(manifest)); 12 | fs.writeFileSync(outputPath + '.msgpack', pack(manifest)); 13 | } 14 | } 15 | 16 | const { APS_CLIENT_ID, APS_CLIENT_SECRET, APS_REGION } = process.env; 17 | 18 | async function run(urn, outputDir) { 19 | try { 20 | const derivatives = await getSvfDerivatives(urn, APS_CLIENT_ID, APS_CLIENT_SECRET, APS_REGION); 21 | const authenticationProvider = new TwoLeggedAuthenticationProvider(APS_CLIENT_ID, APS_CLIENT_SECRET); 22 | const writer = new MsgpackGLTFWriter({ deduplicate: true, center: true, log: console.log }); 23 | for (const derivative of derivatives) { 24 | const reader = await SVFReader.FromDerivativeService(urn, derivative.guid, authenticationProvider); 25 | const scene = await reader.read({ log: console.log }); 26 | await writer.write(scene, path.join(outputDir, derivative.guid)); 27 | } 28 | } catch(err) { 29 | console.error(err); 30 | process.exit(1); 31 | } 32 | } 33 | 34 | run(process.argv[2], process.argv[3]); 35 | -------------------------------------------------------------------------------- /samples/shared.js: -------------------------------------------------------------------------------- 1 | const { AuthenticationClient, Scopes } = require('@aps_sdk/authentication'); 2 | const { ModelDerivativeClient } = require('@aps_sdk/model-derivative'); 3 | const { TwoLeggedAuthenticationProvider, BasicAuthenticationProvider } = require('..'); 4 | 5 | async function getSvfDerivatives(urn, clientId, clientSecret, region) { 6 | const authenticationClient = new AuthenticationClient(); 7 | const modelDerivativeClient = new ModelDerivativeClient(); 8 | const credentials = await authenticationClient.getTwoLeggedToken(clientId, clientSecret, [Scopes.ViewablesRead]); 9 | const manifest = await modelDerivativeClient.getManifest(urn, { accessToken: credentials.access_token, region }); 10 | const derivatives = []; 11 | function traverse(derivative) { 12 | if (derivative.type === 'resource' && derivative.role === 'graphics' && derivative.mime === 'application/autodesk-svf') { 13 | derivatives.push(derivative); 14 | } 15 | if (derivative.children) { 16 | for (const child of derivative.children) { 17 | traverse(child); 18 | } 19 | } 20 | } 21 | for (const derivative of manifest.derivatives) { 22 | if (derivative.children) { 23 | for (const child of derivative.children) { 24 | traverse(child); 25 | } 26 | } 27 | } 28 | return derivatives; 29 | } 30 | 31 | function initializeAuthenticationProvider() { 32 | if (process.env.APS_CLIENT_ID || process.env.APS_CLIENT_SECRET) { 33 | return new TwoLeggedAuthenticationProvider(process.env.APS_CLIENT_ID, process.env.APS_CLIENT_SECRET); 34 | } else if (process.env.APS_ACCESS_TOKEN) { 35 | return new BasicAuthenticationProvider(process.env.APS_ACCESS_TOKEN); 36 | } else { 37 | throw new Error('Please set APS_CLIENT_ID and APS_CLIENT_SECRET environment variables, or APS_ACCESS_TOKEN environment variable'); 38 | } 39 | } 40 | 41 | module.exports = { 42 | getSvfDerivatives, 43 | initializeAuthenticationProvider 44 | }; -------------------------------------------------------------------------------- /src/common/authentication-provider.ts: -------------------------------------------------------------------------------- 1 | import { AuthenticationClient, Scopes, TwoLeggedToken } from "@aps_sdk/authentication"; 2 | 3 | export interface IAuthenticationProvider { 4 | getToken(scopes: Scopes[]): Promise; 5 | } 6 | 7 | export class BasicAuthenticationProvider implements IAuthenticationProvider { 8 | constructor(protected accessToken: string) {} 9 | 10 | async getToken(scopes: Scopes[]): Promise { 11 | // TODO: check if the hard-coded token has all the needed scopes 12 | return this.accessToken; 13 | } 14 | } 15 | 16 | export class TwoLeggedAuthenticationProvider implements IAuthenticationProvider { 17 | protected authenticationClient: AuthenticationClient; 18 | protected lastCredentials: TwoLeggedToken | null = null; 19 | 20 | constructor(protected clientId: string, protected clientSecret: string) { 21 | this.authenticationClient = new AuthenticationClient(); 22 | } 23 | 24 | async getToken(scopes: Scopes[]): Promise { 25 | if (!this.lastCredentials || Date.now() > this.lastCredentials.expires_at!) { 26 | console.log('Refreshing token...'); 27 | this.lastCredentials = await this.authenticationClient.getTwoLeggedToken(this.clientId, this.clientSecret, scopes); 28 | } 29 | return this.lastCredentials.access_token; 30 | } 31 | } -------------------------------------------------------------------------------- /src/common/cancellation-token.ts: -------------------------------------------------------------------------------- 1 | export class CancellationToken { 2 | private isCancelled = false; 3 | 4 | cancel() { 5 | this.isCancelled = true; 6 | } 7 | 8 | get cancelled() { 9 | return this.isCancelled; 10 | } 11 | } -------------------------------------------------------------------------------- /src/common/image-placeholders.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as fse from 'fs-extra'; 3 | 4 | const ResFolder = path.join(__dirname, '..', '..', 'res'); 5 | 6 | export class ImagePlaceholder { 7 | private static _jpg: Buffer | undefined; 8 | private static _png: Buffer | undefined; 9 | private static _bmp: Buffer | undefined; 10 | private static _gif: Buffer | undefined; 11 | 12 | private constructor() {} 13 | 14 | public static get JPG(): Buffer { 15 | if (!this._jpg) { 16 | this._jpg = fse.readFileSync(path.join(ResFolder, 'placeholder.jpg')); 17 | } 18 | return this._jpg; 19 | } 20 | 21 | public static get PNG(): Buffer { 22 | if (!this._png) { 23 | this._png = fse.readFileSync(path.join(ResFolder, 'placeholder.png')); 24 | } 25 | return this._png; 26 | } 27 | 28 | public static get BMP(): Buffer { 29 | if (!this._bmp) { 30 | this._bmp = fse.readFileSync(path.join(ResFolder, 'placeholder.bmp')); 31 | } 32 | return this._bmp; 33 | } 34 | 35 | public static get GIF(): Buffer { 36 | if (!this._gif) { 37 | this._gif = fse.readFileSync(path.join(ResFolder, 'placeholder.gif')); 38 | } 39 | return this._gif; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/common/input-stream.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Simple binary stream reader. Used for parsing different types of SVF assets. 3 | */ 4 | export class InputStream { 5 | protected _buffer: Buffer; 6 | protected _offset: number; 7 | protected _length: number; 8 | 9 | public get offset(): number { 10 | return this._offset; 11 | } 12 | 13 | public get length(): number { 14 | return this._length; 15 | } 16 | 17 | constructor(buffer: Buffer) { 18 | this._buffer = buffer; 19 | this._offset = 0; 20 | this._length = buffer.length; 21 | } 22 | 23 | seek(offset: number) { 24 | this._offset = offset; 25 | } 26 | 27 | getUint8(): number { 28 | const val = this._buffer.readUInt8(this._offset); 29 | this._offset += 1; 30 | return val; 31 | } 32 | 33 | getUint16(): number { 34 | const val = this._buffer.readUInt16LE(this._offset); 35 | this._offset += 2; 36 | return val; 37 | } 38 | 39 | getInt16(): number { 40 | const val = this._buffer.readInt16LE(this._offset); 41 | this._offset += 2; 42 | return val; 43 | } 44 | 45 | getUint32(): number { 46 | const val = this._buffer.readUInt32LE(this._offset); 47 | this._offset += 4; 48 | return val; 49 | } 50 | 51 | getInt32(): number { 52 | const val = this._buffer.readInt32LE(this._offset); 53 | this._offset += 4; 54 | return val; 55 | } 56 | 57 | getFloat32(): number { 58 | const val = this._buffer.readFloatLE(this._offset); 59 | this._offset += 4; 60 | return val; 61 | } 62 | 63 | getFloat64(): number { 64 | const val = this._buffer.readDoubleLE(this._offset); 65 | this._offset += 8; 66 | return val; 67 | } 68 | 69 | getVarint(): number { 70 | let byte, val = 0, shift = 0; 71 | do { 72 | byte = this._buffer[this._offset++]; 73 | val |= (byte & 0x7f) << shift; 74 | shift += 7; 75 | } while (byte & 0x80); 76 | return val; 77 | } 78 | 79 | getString(len: number): string { 80 | const val = this._buffer.toString('utf8', this._offset, this._offset + len); 81 | this._offset += len; 82 | return val; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/common/intermediate-format.ts: -------------------------------------------------------------------------------- 1 | // Intermediate 3D format schema 2 | 3 | export type NodeID = number; 4 | export type GeometryID = number; 5 | export type MaterialID = number; 6 | export type CameraID = number; 7 | export type LightID = number; 8 | 9 | export interface IScene { 10 | getMetadata(): IMetadata; 11 | getNodeCount(): number; 12 | getNode(id: NodeID): Node; 13 | getGeometryCount(): number; 14 | getGeometry(id: GeometryID): Geometry; 15 | getMaterialCount(): number; 16 | getMaterial(id: MaterialID): Material; 17 | getImage(uri: string): Buffer | undefined; 18 | } 19 | 20 | export interface IMetadata { 21 | [key: string]: any; 22 | } 23 | 24 | export interface IVec2 { 25 | x: number; 26 | y: number; 27 | } 28 | 29 | export interface IVec3 { 30 | x: number; 31 | y: number; 32 | z: number; 33 | } 34 | 35 | export interface IQuaternion { 36 | x: number; 37 | y: number; 38 | z: number; 39 | w: number; 40 | } 41 | 42 | export enum TransformKind { 43 | Matrix, 44 | Decomposed 45 | } 46 | 47 | export interface IMatrixTransform { 48 | kind: TransformKind.Matrix; 49 | elements: number[]; 50 | } 51 | 52 | export interface IDecomposedTransform { 53 | kind: TransformKind.Decomposed; 54 | translation?: IVec3; 55 | rotation?: IQuaternion; 56 | scale?: IVec3; 57 | } 58 | 59 | export type Transform = IMatrixTransform | IDecomposedTransform; 60 | 61 | export enum NodeKind { 62 | Group, 63 | Object, 64 | Camera, 65 | Light 66 | } 67 | 68 | export interface IGroupNode { 69 | kind: NodeKind.Group; 70 | dbid: number; 71 | transform?: Transform; 72 | children: NodeID[]; 73 | } 74 | 75 | export interface IObjectNode { 76 | kind: NodeKind.Object; 77 | dbid: number; 78 | transform?: Transform; 79 | geometry: GeometryID; 80 | material: MaterialID; 81 | } 82 | 83 | export interface ICameraNode { 84 | kind: NodeKind.Camera; 85 | transform?: Transform; 86 | camera: CameraID; 87 | } 88 | 89 | export interface ILightNode { 90 | kind: NodeKind.Light; 91 | transform?: Transform; 92 | light: LightID; 93 | } 94 | 95 | export type Node = IGroupNode | IObjectNode | ICameraNode | ILightNode; 96 | 97 | export enum GeometryKind { 98 | Mesh, 99 | Lines, 100 | Points, 101 | Empty 102 | } 103 | 104 | export interface IMeshGeometry { 105 | kind: GeometryKind.Mesh; 106 | getIndices(): Uint16Array; 107 | getVertices(): Float32Array; 108 | getNormals(): Float32Array | undefined; 109 | getColors(): Float32Array | undefined; 110 | getUvChannelCount(): number; 111 | getUvs(channel: number): Float32Array; 112 | } 113 | 114 | export interface ILineGeometry { 115 | kind: GeometryKind.Lines; 116 | getIndices(): Uint16Array; 117 | getVertices(): Float32Array; 118 | getColors(): Float32Array | undefined; 119 | } 120 | 121 | export interface IPointGeometry { 122 | kind: GeometryKind.Points; 123 | getVertices(): Float32Array; 124 | getColors(): Float32Array | undefined; 125 | } 126 | 127 | export interface IEmptyGeometry { 128 | kind: GeometryKind.Empty; 129 | } 130 | 131 | export type Geometry = IMeshGeometry | ILineGeometry | IPointGeometry | IEmptyGeometry; 132 | 133 | export enum MaterialKind { 134 | Physical 135 | } 136 | 137 | export interface IPhysicalMaterial { 138 | kind: MaterialKind.Physical; 139 | diffuse: IVec3; 140 | metallic: number; 141 | roughness: number; 142 | opacity: number; 143 | maps?: { 144 | diffuse?: string; 145 | }; 146 | scale?: IVec2; 147 | } 148 | 149 | export type Material = IPhysicalMaterial; 150 | -------------------------------------------------------------------------------- /src/common/packfile-reader.ts: -------------------------------------------------------------------------------- 1 | import * as zlib from 'zlib'; 2 | 3 | import { InputStream } from './input-stream'; 4 | import { IVector3, IQuaternion, Matrix3x3, Transform } from '../svf/schema'; 5 | 6 | /** 7 | * Reader of "packfile" protocol used to encode various types of SVF assets, 8 | * for example, geometry metadata, or meshes. 9 | */ 10 | export class PackFileReader extends InputStream { 11 | protected _type: string; 12 | protected _version: number; 13 | protected _entries: any[] = []; // offsets to individual entries in the pack file 14 | protected _types: any[] = []; // types of all entries in the pack file 15 | 16 | constructor(buffer: Buffer) { 17 | super((buffer[0] === 31 && buffer[1] === 139) ? zlib.gunzipSync(buffer) : buffer); 18 | this._type = this.getString(this.getVarint()); 19 | this._version = this.getInt32(); 20 | this.parseContents(); 21 | } 22 | 23 | parseContents() { 24 | // Get offsets to TOC and type sets from the end of the file 25 | const originalOffset = this._offset; 26 | this.seek(this.length - 8); 27 | const entriesOffset = this.getUint32(); 28 | const typesOffset = this.getUint32(); 29 | 30 | // Populate entries 31 | this._entries = []; 32 | this.seek(entriesOffset); 33 | const entriesCount = this.getVarint(); 34 | for (let i = 0; i < entriesCount; i++) { 35 | this._entries.push(this.getUint32()); 36 | } 37 | 38 | // Populate type sets 39 | this.seek(typesOffset); 40 | const typesCount = this.getVarint(); 41 | for (let i = 0; i < typesCount; i++) { 42 | const _class = this.getString(this.getVarint()); 43 | const _type = this.getString(this.getVarint()); 44 | this._types.push({ 45 | _class, 46 | _type, 47 | version: this.getVarint() 48 | }); 49 | } 50 | 51 | // Restore offset 52 | this.seek(originalOffset); 53 | } 54 | 55 | numEntries() { 56 | return this._entries.length; 57 | } 58 | 59 | seekEntry(i: number) { 60 | if (i >= this.numEntries()) { 61 | return null; 62 | } 63 | 64 | // Read the type index and populate the entry data 65 | const offset = this._entries[i]; 66 | this.seek(offset); 67 | const type = this.getUint32(); 68 | if (type >= this._types.length) { 69 | return null; 70 | } 71 | return this._types[type]; 72 | } 73 | 74 | getVector3D(): IVector3 { 75 | return { 76 | x: this.getFloat64(), 77 | y: this.getFloat64(), 78 | z: this.getFloat64() 79 | }; 80 | } 81 | 82 | getQuaternion(): IQuaternion { 83 | return { 84 | x: this.getFloat32(), 85 | y: this.getFloat32(), 86 | z: this.getFloat32(), 87 | w: this.getFloat32() 88 | }; 89 | } 90 | 91 | getMatrix3x3(): Matrix3x3 { 92 | const elements = []; 93 | for (let i = 0; i < 3; i++) { 94 | for (let j = 0; j < 3; j++) { 95 | elements.push(this.getFloat32()); 96 | } 97 | } 98 | return elements; 99 | } 100 | 101 | getTransform(): Transform | null { 102 | const xformType = this.getUint8(); 103 | let q, t, s, matrix; 104 | switch (xformType) { 105 | case 0: // translation 106 | return { t: this.getVector3D() }; 107 | case 1: // rotation & translation 108 | q = this.getQuaternion(); 109 | t = this.getVector3D(); 110 | s = { x: 1, y: 1, z: 1 }; 111 | return { q, t, s }; 112 | case 2: // uniform scale & rotation & translation 113 | const scale = this.getFloat32(); 114 | q = this.getQuaternion(); 115 | t = this.getVector3D(); 116 | s = { x: scale, y: scale, z: scale }; 117 | return { q, t, s }; 118 | case 3: // affine matrix 119 | matrix = this.getMatrix3x3(); 120 | t = this.getVector3D(); 121 | return { matrix, t }; 122 | } 123 | return null; 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/common/propdb-reader.ts: -------------------------------------------------------------------------------- 1 | import * as zlib from 'zlib'; 2 | 3 | /** 4 | * Helper class for parsing and querying property database 5 | * stored in various 'objects_*.json.gz' assets in an SVF. 6 | */ 7 | export class PropDbReader { 8 | protected _ids: number[]; 9 | protected _offsets: number[]; 10 | protected _avs: number[]; 11 | protected _attrs: any[]; 12 | protected _vals: any[]; 13 | 14 | /** 15 | * Initializes the property database reader. 16 | * @param {Buffer} ids Content of objects_ids.json.gz file. 17 | * @param {Buffer} offsets Content of objects_offs.json.gz file. 18 | * @param {Buffer} avs Content of objects_avs.json.gz file. 19 | * @param {Buffer} attrs Content of objects_attrs.json.gz file. 20 | * @param {Buffer} vals Content of objects_vals.json.gz file. 21 | */ 22 | constructor(ids: Buffer, offsets: Buffer, avs: Buffer, attrs: Buffer, vals: Buffer) { 23 | this._ids = JSON.parse(zlib.gunzipSync(ids).toString()); 24 | this._offsets = JSON.parse(zlib.gunzipSync(offsets).toString()); 25 | this._avs = JSON.parse(zlib.gunzipSync(avs).toString()); 26 | this._attrs = JSON.parse(zlib.gunzipSync(attrs).toString()); 27 | this._vals = JSON.parse(zlib.gunzipSync(vals).toString()); 28 | } 29 | 30 | /** 31 | * Enumerates all properties (including internal ones such as "__child__" property 32 | * establishing the parent-child relationships) of given object. 33 | * @generator 34 | * @param {number} id Object ID. 35 | * @returns {Iterable<{ name: string; category: string; value: any }>} Name, category, and value of each property. 36 | */ 37 | *enumerateProperties(id: number): Iterable<{ name: string; category: string; value: any }> { 38 | if (id > 0 && id < this._offsets.length) { 39 | const avStart = 2 * this._offsets[id]; 40 | const avEnd = id == this._offsets.length - 1 ? this._avs.length : 2 * this._offsets[id + 1]; 41 | for (let i = avStart; i < avEnd; i += 2) { 42 | const attrOffset = this._avs[i]; 43 | const valOffset = this._avs[i + 1]; 44 | const attr = this._attrs[attrOffset]; 45 | const value = this._vals[valOffset]; 46 | yield { name: attr[0], category: attr[1], value }; 47 | } 48 | } 49 | } 50 | 51 | /** 52 | * Finds "public" properties of given object. 53 | * Additional properties like parent-child relationships are not included in the output. 54 | * @param {number} id Object ID. 55 | * @returns {{ [name: string]: any }} Dictionary of property names and values. 56 | */ 57 | getProperties(id: number): { [name: string]: any } { 58 | let props: { [name: string]: any } = {}; 59 | for (const prop of this.enumerateProperties(id)) { 60 | if (prop.category && prop.category.match(/^__\w+__$/)) { 61 | // Skip internal attributes 62 | } else { 63 | props[prop.name] = prop.value; 64 | } 65 | } 66 | return props; 67 | } 68 | 69 | /** 70 | * Finds IDs of all children of given object. 71 | * @param {number} id Object ID. 72 | * @returns {number[]} Children IDs. 73 | */ 74 | getChildren(id: number): number[] { 75 | let children: number[] = []; 76 | for (const prop of this.enumerateProperties(id)) { 77 | if (prop.category === '__child__') { 78 | children.push(prop.value as number); 79 | } 80 | } 81 | return children; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/f2d/downloader.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as zlib from 'zlib'; 3 | import * as fse from 'fs-extra'; 4 | import axios from 'axios'; 5 | import { ManifestResources, ModelDerivativeClient, Region } from '@aps_sdk/model-derivative'; 6 | import { IAuthenticationProvider } from '../common/authentication-provider'; 7 | import { Scopes } from '@aps_sdk/authentication'; 8 | import { CancellationToken } from '../common/cancellation-token'; 9 | 10 | export interface IDownloadOptions { 11 | region?: Region; 12 | outputDir?: string; 13 | log?: (message: string) => void; 14 | failOnMissingAssets?: boolean; 15 | cancellationToken?: CancellationToken; 16 | } 17 | 18 | export class Downloader { 19 | protected readonly modelDerivativeClient = new ModelDerivativeClient(); 20 | 21 | constructor(protected authenticationProvider: IAuthenticationProvider) {} 22 | 23 | async download(urn: string, options?: IDownloadOptions): Promise { 24 | const outputDir = options?.outputDir || '.'; 25 | const log = options?.log || ((message: string) => {}); 26 | log(`Downloading derivative ${urn} (region: ${options?.region || 'default'})`); 27 | const accessToken = await this.authenticationProvider.getToken([Scopes.ViewablesRead]); 28 | const manifest = await this.modelDerivativeClient.getManifest(urn, { accessToken, region: options?.region }); 29 | let derivatives: ManifestResources[] = []; 30 | function collectDerivatives(derivative: ManifestResources) { 31 | if (derivative.type === 'resource' && derivative.role === 'graphics' && (derivative as any).mime === 'application/autodesk-f2d') { 32 | derivatives.push(derivative); 33 | } 34 | if (derivative.children) { 35 | for (const child of derivative.children) { 36 | collectDerivatives(child); 37 | } 38 | } 39 | } 40 | for (const derivative of manifest.derivatives) { 41 | if (derivative.children) { 42 | for (const child of derivative.children) { 43 | collectDerivatives(child); 44 | } 45 | } 46 | } 47 | const urnDir = path.join(outputDir, urn); 48 | for (const derivative of derivatives) { 49 | if (options?.cancellationToken?.cancelled) { 50 | return; 51 | } 52 | const guid = derivative.guid; 53 | log(`Downloading viewable ${guid}`); 54 | const guidDir = path.join(urnDir, guid); 55 | fse.ensureDirSync(guidDir); 56 | const derivativeUrn = (derivative as any).urn; 57 | const baseUrn = derivativeUrn.substr(0, derivativeUrn.lastIndexOf('/')); 58 | const manifestGzip = await this.downloadDerivative(urn, baseUrn + '/manifest.json.gz', options?.region); 59 | fse.writeFileSync(path.join(guidDir, 'manifest.json.gz'), new Uint8Array(manifestGzip as Buffer)); 60 | const manifestGunzip = zlib.gunzipSync(manifestGzip); 61 | const manifest = JSON.parse(manifestGunzip.toString()); 62 | for (const asset of manifest.assets) { 63 | if (options?.cancellationToken?.cancelled) { 64 | return; 65 | } 66 | log(`Downloading asset ${asset.URI}`); 67 | try { 68 | const assetData = await this.downloadDerivative(urn, baseUrn + '/' + asset.URI, options?.region); 69 | fse.writeFileSync(path.join(guidDir, asset.URI), new Uint8Array(assetData)); 70 | } catch (err) { 71 | if (options?.failOnMissingAssets) { 72 | throw err; 73 | } else { 74 | log(`Could not download asset ${asset.URI}`); 75 | } 76 | } 77 | } 78 | } 79 | } 80 | 81 | private async downloadDerivative(urn: string, derivativeUrn: string, region?: Region) { 82 | try { 83 | const accessToken = await this.authenticationProvider.getToken([Scopes.ViewablesRead]); 84 | const downloadInfo = await this.modelDerivativeClient.getDerivativeUrl(derivativeUrn, urn, { accessToken, region }); 85 | const response = await axios.get(downloadInfo.url as string, { responseType: 'arraybuffer', decompress: false }); 86 | return response.data; 87 | } catch (error) { 88 | if (axios.isAxiosError(error)) { 89 | throw new Error(`Could not download derivative ${derivativeUrn}: ${error.message}`); 90 | } else { 91 | throw error; 92 | } 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/gltf/schema.ts: -------------------------------------------------------------------------------- 1 | export type GlTfId = number; 2 | /** 3 | * Indices of those attributes that deviate from their initialization value. 4 | */ 5 | export interface AccessorSparseIndices { 6 | /** 7 | * The index of the bufferView with sparse indices. Referenced bufferView can't have ARRAY_BUFFER or ELEMENT_ARRAY_BUFFER target. 8 | */ 9 | "bufferView": GlTfId; 10 | /** 11 | * The offset relative to the start of the bufferView in bytes. Must be aligned. 12 | */ 13 | "byteOffset"?: number; 14 | /** 15 | * The indices data type. 16 | */ 17 | "componentType": 5121 | 5123 | 5125 | number; 18 | "extensions"?: any; 19 | "extras"?: any; 20 | [k: string]: any; 21 | } 22 | /** 23 | * Array of size `accessor.sparse.count` times number of components storing the displaced accessor attributes pointed by `accessor.sparse.indices`. 24 | */ 25 | export interface AccessorSparseValues { 26 | /** 27 | * The index of the bufferView with sparse values. Referenced bufferView can't have ARRAY_BUFFER or ELEMENT_ARRAY_BUFFER target. 28 | */ 29 | "bufferView": GlTfId; 30 | /** 31 | * The offset relative to the start of the bufferView in bytes. Must be aligned. 32 | */ 33 | "byteOffset"?: number; 34 | "extensions"?: any; 35 | "extras"?: any; 36 | [k: string]: any; 37 | } 38 | /** 39 | * Sparse storage of attributes that deviate from their initialization value. 40 | */ 41 | export interface AccessorSparse { 42 | /** 43 | * Number of entries stored in the sparse array. 44 | */ 45 | "count": number; 46 | /** 47 | * Index array of size `count` that points to those accessor attributes that deviate from their initialization value. Indices must strictly increase. 48 | */ 49 | "indices": AccessorSparseIndices; 50 | /** 51 | * Array of size `count` times number of components, storing the displaced accessor attributes pointed by `indices`. Substituted values must have the same `componentType` and number of components as the base accessor. 52 | */ 53 | "values": AccessorSparseValues; 54 | "extensions"?: any; 55 | "extras"?: any; 56 | [k: string]: any; 57 | } 58 | /** 59 | * A typed view into a bufferView. A bufferView contains raw binary data. An accessor provides a typed view into a bufferView or a subset of a bufferView similar to how WebGL's `vertexAttribPointer()` defines an attribute in a buffer. 60 | */ 61 | export interface Accessor { 62 | /** 63 | * The index of the bufferView. 64 | */ 65 | "bufferView"?: GlTfId; 66 | /** 67 | * The offset relative to the start of the bufferView in bytes. 68 | */ 69 | "byteOffset"?: number; 70 | /** 71 | * The datatype of components in the attribute. 72 | */ 73 | "componentType": 5120 | 5121 | 5122 | 5123 | 5125 | 5126 | number; 74 | /** 75 | * Specifies whether integer data values should be normalized. 76 | */ 77 | "normalized"?: boolean; 78 | /** 79 | * The number of attributes referenced by this accessor. 80 | */ 81 | "count": number; 82 | /** 83 | * Specifies if the attribute is a scalar, vector, or matrix. 84 | */ 85 | "type": "SCALAR" | "VEC2" | "VEC3" | "VEC4" | "MAT2" | "MAT3" | "MAT4" | string; 86 | /** 87 | * Maximum value of each component in this attribute. 88 | */ 89 | "max"?: number[]; 90 | /** 91 | * Minimum value of each component in this attribute. 92 | */ 93 | "min"?: number[]; 94 | /** 95 | * Sparse storage of attributes that deviate from their initialization value. 96 | */ 97 | "sparse"?: AccessorSparse; 98 | "name"?: any; 99 | "extensions"?: any; 100 | "extras"?: any; 101 | [k: string]: any; 102 | } 103 | /** 104 | * The index of the node and TRS property that an animation channel targets. 105 | */ 106 | export interface AnimationChannelTarget { 107 | /** 108 | * The index of the node to target. 109 | */ 110 | "node"?: GlTfId; 111 | /** 112 | * The name of the node's TRS property to modify, or the "weights" of the Morph Targets it instantiates. For the "translation" property, the values that are provided by the sampler are the translation along the x, y, and z axes. For the "rotation" property, the values are a quaternion in the order (x, y, z, w), where w is the scalar. For the "scale" property, the values are the scaling factors along the x, y, and z axes. 113 | */ 114 | "path": "translation" | "rotation" | "scale" | "weights" | string; 115 | "extensions"?: any; 116 | "extras"?: any; 117 | [k: string]: any; 118 | } 119 | /** 120 | * Targets an animation's sampler at a node's property. 121 | */ 122 | export interface AnimationChannel { 123 | /** 124 | * The index of a sampler in this animation used to compute the value for the target. 125 | */ 126 | "sampler": GlTfId; 127 | /** 128 | * The index of the node and TRS property to target. 129 | */ 130 | "target": AnimationChannelTarget; 131 | "extensions"?: any; 132 | "extras"?: any; 133 | [k: string]: any; 134 | } 135 | /** 136 | * Combines input and output accessors with an interpolation algorithm to define a keyframe graph (but not its target). 137 | */ 138 | export interface AnimationSampler { 139 | /** 140 | * The index of an accessor containing keyframe input values, e.g., time. 141 | */ 142 | "input": GlTfId; 143 | /** 144 | * Interpolation algorithm. 145 | */ 146 | "interpolation"?: "LINEAR" | "STEP" | "CUBICSPLINE" | string; 147 | /** 148 | * The index of an accessor, containing keyframe output values. 149 | */ 150 | "output": GlTfId; 151 | "extensions"?: any; 152 | "extras"?: any; 153 | [k: string]: any; 154 | } 155 | /** 156 | * A keyframe animation. 157 | */ 158 | export interface Animation { 159 | /** 160 | * An array of channels, each of which targets an animation's sampler at a node's property. Different channels of the same animation can't have equal targets. 161 | */ 162 | "channels": AnimationChannel[]; 163 | /** 164 | * An array of samplers that combines input and output accessors with an interpolation algorithm to define a keyframe graph (but not its target). 165 | */ 166 | "samplers": AnimationSampler[]; 167 | "name"?: any; 168 | "extensions"?: any; 169 | "extras"?: any; 170 | [k: string]: any; 171 | } 172 | /** 173 | * Metadata about the glTF asset. 174 | */ 175 | export interface Asset { 176 | /** 177 | * A copyright message suitable for display to credit the content creator. 178 | */ 179 | "copyright"?: string; 180 | /** 181 | * Tool that generated this glTF model. Useful for debugging. 182 | */ 183 | "generator"?: string; 184 | /** 185 | * The glTF version that this asset targets. 186 | */ 187 | "version": string; 188 | /** 189 | * The minimum glTF version that this asset targets. 190 | */ 191 | "minVersion"?: string; 192 | "extensions"?: any; 193 | "extras"?: any; 194 | [k: string]: any; 195 | } 196 | /** 197 | * A buffer points to binary geometry, animation, or skins. 198 | */ 199 | export interface Buffer { 200 | /** 201 | * The uri of the buffer. 202 | */ 203 | "uri"?: string; 204 | /** 205 | * The length of the buffer in bytes. 206 | */ 207 | "byteLength": number; 208 | "name"?: any; 209 | "extensions"?: any; 210 | "extras"?: any; 211 | [k: string]: any; 212 | } 213 | /** 214 | * A view into a buffer generally representing a subset of the buffer. 215 | */ 216 | export interface BufferView { 217 | /** 218 | * The index of the buffer. 219 | */ 220 | "buffer": GlTfId; 221 | /** 222 | * The offset into the buffer in bytes. 223 | */ 224 | "byteOffset"?: number; 225 | /** 226 | * The total byte length of the buffer view. 227 | */ 228 | "byteLength": number; 229 | /** 230 | * The stride, in bytes. 231 | */ 232 | "byteStride"?: number; 233 | /** 234 | * The target that the GPU buffer should be bound to. 235 | */ 236 | "target"?: 34962 | 34963 | number; 237 | "name"?: any; 238 | "extensions"?: any; 239 | "extras"?: any; 240 | [k: string]: any; 241 | } 242 | /** 243 | * An orthographic camera containing properties to create an orthographic projection matrix. 244 | */ 245 | export interface CameraOrthographic { 246 | /** 247 | * The floating-point horizontal magnification of the view. Must not be zero. 248 | */ 249 | "xmag": number; 250 | /** 251 | * The floating-point vertical magnification of the view. Must not be zero. 252 | */ 253 | "ymag": number; 254 | /** 255 | * The floating-point distance to the far clipping plane. `zfar` must be greater than `znear`. 256 | */ 257 | "zfar": number; 258 | /** 259 | * The floating-point distance to the near clipping plane. 260 | */ 261 | "znear": number; 262 | "extensions"?: any; 263 | "extras"?: any; 264 | [k: string]: any; 265 | } 266 | /** 267 | * A perspective camera containing properties to create a perspective projection matrix. 268 | */ 269 | export interface CameraPerspective { 270 | /** 271 | * The floating-point aspect ratio of the field of view. 272 | */ 273 | "aspectRatio"?: number; 274 | /** 275 | * The floating-point vertical field of view in radians. 276 | */ 277 | "yfov": number; 278 | /** 279 | * The floating-point distance to the far clipping plane. 280 | */ 281 | "zfar"?: number; 282 | /** 283 | * The floating-point distance to the near clipping plane. 284 | */ 285 | "znear": number; 286 | "extensions"?: any; 287 | "extras"?: any; 288 | [k: string]: any; 289 | } 290 | /** 291 | * A camera's projection. A node can reference a camera to apply a transform to place the camera in the scene. 292 | */ 293 | export interface Camera { 294 | /** 295 | * An orthographic camera containing properties to create an orthographic projection matrix. 296 | */ 297 | "orthographic"?: CameraOrthographic; 298 | /** 299 | * A perspective camera containing properties to create a perspective projection matrix. 300 | */ 301 | "perspective"?: CameraPerspective; 302 | /** 303 | * Specifies if the camera uses a perspective or orthographic projection. 304 | */ 305 | "type": "perspective" | "orthographic" | string; 306 | "name"?: any; 307 | "extensions"?: any; 308 | "extras"?: any; 309 | [k: string]: any; 310 | } 311 | /** 312 | * Image data used to create a texture. Image can be referenced by URI or `bufferView` index. `mimeType` is required in the latter case. 313 | */ 314 | export interface Image { 315 | /** 316 | * The uri of the image. 317 | */ 318 | "uri"?: string; 319 | /** 320 | * The image's MIME type. Required if `bufferView` is defined. 321 | */ 322 | "mimeType"?: "image/jpeg" | "image/png" | string; 323 | /** 324 | * The index of the bufferView that contains the image. Use this instead of the image's uri property. 325 | */ 326 | "bufferView"?: GlTfId; 327 | "name"?: any; 328 | "extensions"?: any; 329 | "extras"?: any; 330 | [k: string]: any; 331 | } 332 | /** 333 | * Reference to a texture. 334 | */ 335 | export interface TextureInfo { 336 | /** 337 | * The index of the texture. 338 | */ 339 | "index": GlTfId; 340 | /** 341 | * The set index of texture's TEXCOORD attribute used for texture coordinate mapping. 342 | */ 343 | "texCoord"?: number; 344 | "extensions"?: any; 345 | "extras"?: any; 346 | [k: string]: any; 347 | } 348 | /** 349 | * A set of parameter values that are used to define the metallic-roughness material model from Physically-Based Rendering (PBR) methodology. 350 | */ 351 | export interface MaterialPbrMetallicRoughness { 352 | /** 353 | * The material's base color factor. 354 | */ 355 | "baseColorFactor"?: number[]; 356 | /** 357 | * The base color texture. 358 | */ 359 | "baseColorTexture"?: TextureInfo; 360 | /** 361 | * The metalness of the material. 362 | */ 363 | "metallicFactor"?: number; 364 | /** 365 | * The roughness of the material. 366 | */ 367 | "roughnessFactor"?: number; 368 | /** 369 | * The metallic-roughness texture. 370 | */ 371 | "metallicRoughnessTexture"?: TextureInfo; 372 | "extensions"?: any; 373 | "extras"?: any; 374 | [k: string]: any; 375 | } 376 | export interface MaterialNormalTextureInfo { 377 | "index"?: any; 378 | "texCoord"?: any; 379 | /** 380 | * The scalar multiplier applied to each normal vector of the normal texture. 381 | */ 382 | "scale"?: number; 383 | "extensions"?: any; 384 | "extras"?: any; 385 | [k: string]: any; 386 | } 387 | export interface MaterialOcclusionTextureInfo { 388 | "index"?: any; 389 | "texCoord"?: any; 390 | /** 391 | * A scalar multiplier controlling the amount of occlusion applied. 392 | */ 393 | "strength"?: number; 394 | "extensions"?: any; 395 | "extras"?: any; 396 | [k: string]: any; 397 | } 398 | /** 399 | * The material appearance of a primitive. 400 | */ 401 | export interface Material { 402 | "name"?: any; 403 | "extensions"?: any; 404 | "extras"?: any; 405 | /** 406 | * A set of parameter values that are used to define the metallic-roughness material model from Physically-Based Rendering (PBR) methodology. When not specified, all the default values of `pbrMetallicRoughness` apply. 407 | */ 408 | "pbrMetallicRoughness"?: MaterialPbrMetallicRoughness; 409 | /** 410 | * The normal map texture. 411 | */ 412 | "normalTexture"?: MaterialNormalTextureInfo; 413 | /** 414 | * The occlusion map texture. 415 | */ 416 | "occlusionTexture"?: MaterialOcclusionTextureInfo; 417 | /** 418 | * The emissive map texture. 419 | */ 420 | "emissiveTexture"?: TextureInfo; 421 | /** 422 | * The emissive color of the material. 423 | */ 424 | "emissiveFactor"?: number[]; 425 | /** 426 | * The alpha rendering mode of the material. 427 | */ 428 | "alphaMode"?: "OPAQUE" | "MASK" | "BLEND" | string; 429 | /** 430 | * The alpha cutoff value of the material. 431 | */ 432 | "alphaCutoff"?: number; 433 | /** 434 | * Specifies whether the material is double sided. 435 | */ 436 | "doubleSided"?: boolean; 437 | [k: string]: any; 438 | } 439 | /** 440 | * Geometry to be rendered with the given material. 441 | */ 442 | export interface MeshPrimitive { 443 | /** 444 | * A dictionary object, where each key corresponds to mesh attribute semantic and each value is the index of the accessor containing attribute's data. 445 | */ 446 | "attributes": { 447 | [k: string]: GlTfId; 448 | }; 449 | /** 450 | * The index of the accessor that contains the indices. 451 | */ 452 | "indices"?: GlTfId; 453 | /** 454 | * The index of the material to apply to this primitive when rendering. 455 | */ 456 | "material"?: GlTfId; 457 | /** 458 | * The type of primitives to render. 459 | */ 460 | "mode"?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | number; 461 | /** 462 | * An array of Morph Targets, each Morph Target is a dictionary mapping attributes (only `POSITION`, `NORMAL`, and `TANGENT` supported) to their deviations in the Morph Target. 463 | */ 464 | "targets"?: { 465 | [k: string]: GlTfId; 466 | }[]; 467 | "extensions"?: any; 468 | "extras"?: any; 469 | [k: string]: any; 470 | } 471 | /** 472 | * A set of primitives to be rendered. A node can contain one mesh. A node's transform places the mesh in the scene. 473 | */ 474 | export interface Mesh { 475 | /** 476 | * An array of primitives, each defining geometry to be rendered with a material. 477 | */ 478 | "primitives": MeshPrimitive[]; 479 | /** 480 | * Array of weights to be applied to the Morph Targets. 481 | */ 482 | "weights"?: number[]; 483 | "name"?: any; 484 | "extensions"?: any; 485 | "extras"?: any; 486 | [k: string]: any; 487 | } 488 | /** 489 | * A node in the node hierarchy. When the node contains `skin`, all `mesh.primitives` must contain `JOINTS_0` and `WEIGHTS_0` attributes. A node can have either a `matrix` or any combination of `translation`/`rotation`/`scale` (TRS) properties. TRS properties are converted to matrices and postmultiplied in the `T * R * S` order to compose the transformation matrix; first the scale is applied to the vertices, then the rotation, and then the translation. If none are provided, the transform is the identity. When a node is targeted for animation (referenced by an animation.channel.target), only TRS properties may be present; `matrix` will not be present. 490 | */ 491 | export interface Node { 492 | /** 493 | * The index of the camera referenced by this node. 494 | */ 495 | "camera"?: GlTfId; 496 | /** 497 | * The indices of this node's children. 498 | */ 499 | "children"?: GlTfId[]; 500 | /** 501 | * The index of the skin referenced by this node. 502 | */ 503 | "skin"?: GlTfId; 504 | /** 505 | * A floating-point 4x4 transformation matrix stored in column-major order. 506 | */ 507 | "matrix"?: number[]; 508 | /** 509 | * The index of the mesh in this node. 510 | */ 511 | "mesh"?: GlTfId; 512 | /** 513 | * The node's unit quaternion rotation in the order (x, y, z, w), where w is the scalar. 514 | */ 515 | "rotation"?: number[]; 516 | /** 517 | * The node's non-uniform scale, given as the scaling factors along the x, y, and z axes. 518 | */ 519 | "scale"?: number[]; 520 | /** 521 | * The node's translation along the x, y, and z axes. 522 | */ 523 | "translation"?: number[]; 524 | /** 525 | * The weights of the instantiated Morph Target. Number of elements must match number of Morph Targets of used mesh. 526 | */ 527 | "weights"?: number[]; 528 | "name"?: any; 529 | "extensions"?: any; 530 | "extras"?: any; 531 | [k: string]: any; 532 | } 533 | /** 534 | * Texture sampler properties for filtering and wrapping modes. 535 | */ 536 | export interface Sampler { 537 | /** 538 | * Magnification filter. 539 | */ 540 | "magFilter"?: 9728 | 9729 | number; 541 | /** 542 | * Minification filter. 543 | */ 544 | "minFilter"?: 9728 | 9729 | 9984 | 9985 | 9986 | 9987 | number; 545 | /** 546 | * s wrapping mode. 547 | */ 548 | "wrapS"?: 33071 | 33648 | 10497 | number; 549 | /** 550 | * t wrapping mode. 551 | */ 552 | "wrapT"?: 33071 | 33648 | 10497 | number; 553 | "name"?: any; 554 | "extensions"?: any; 555 | "extras"?: any; 556 | [k: string]: any; 557 | } 558 | /** 559 | * The root nodes of a scene. 560 | */ 561 | export interface Scene { 562 | /** 563 | * The indices of each root node. 564 | */ 565 | "nodes"?: GlTfId[]; 566 | "name"?: any; 567 | "extensions"?: any; 568 | "extras"?: any; 569 | [k: string]: any; 570 | } 571 | /** 572 | * Joints and matrices defining a skin. 573 | */ 574 | export interface Skin { 575 | /** 576 | * The index of the accessor containing the floating-point 4x4 inverse-bind matrices. The default is that each matrix is a 4x4 identity matrix, which implies that inverse-bind matrices were pre-applied. 577 | */ 578 | "inverseBindMatrices"?: GlTfId; 579 | /** 580 | * The index of the node used as a skeleton root. 581 | */ 582 | "skeleton"?: GlTfId; 583 | /** 584 | * Indices of skeleton nodes, used as joints in this skin. 585 | */ 586 | "joints": GlTfId[]; 587 | "name"?: any; 588 | "extensions"?: any; 589 | "extras"?: any; 590 | [k: string]: any; 591 | } 592 | /** 593 | * A texture and its sampler. 594 | */ 595 | export interface Texture { 596 | /** 597 | * The index of the sampler used by this texture. When undefined, a sampler with repeat wrapping and auto filtering should be used. 598 | */ 599 | "sampler"?: GlTfId; 600 | /** 601 | * The index of the image used by this texture. When undefined, it is expected that an extension or other mechanism will supply an alternate texture source, otherwise behavior is undefined. 602 | */ 603 | "source"?: GlTfId; 604 | "name"?: any; 605 | "extensions"?: any; 606 | "extras"?: any; 607 | [k: string]: any; 608 | } 609 | /** 610 | * The root object for a glTF asset. 611 | */ 612 | export interface GlTf { 613 | /** 614 | * Names of glTF extensions used somewhere in this asset. 615 | */ 616 | "extensionsUsed"?: string[]; 617 | /** 618 | * Names of glTF extensions required to properly load this asset. 619 | */ 620 | "extensionsRequired"?: string[]; 621 | /** 622 | * An array of accessors. 623 | */ 624 | "accessors"?: Accessor[]; 625 | /** 626 | * An array of keyframe animations. 627 | */ 628 | "animations"?: Animation[]; 629 | /** 630 | * Metadata about the glTF asset. 631 | */ 632 | "asset": Asset; 633 | /** 634 | * An array of buffers. 635 | */ 636 | "buffers"?: Buffer[]; 637 | /** 638 | * An array of bufferViews. 639 | */ 640 | "bufferViews"?: BufferView[]; 641 | /** 642 | * An array of cameras. 643 | */ 644 | "cameras"?: Camera[]; 645 | /** 646 | * An array of images. 647 | */ 648 | "images"?: Image[]; 649 | /** 650 | * An array of materials. 651 | */ 652 | "materials"?: Material[]; 653 | /** 654 | * An array of meshes. 655 | */ 656 | "meshes"?: Mesh[]; 657 | /** 658 | * An array of nodes. 659 | */ 660 | "nodes"?: Node[]; 661 | /** 662 | * An array of samplers. 663 | */ 664 | "samplers"?: Sampler[]; 665 | /** 666 | * The index of the default scene. 667 | */ 668 | "scene"?: GlTfId; 669 | /** 670 | * An array of scenes. 671 | */ 672 | "scenes"?: Scene[]; 673 | /** 674 | * An array of skins. 675 | */ 676 | "skins"?: Skin[]; 677 | /** 678 | * An array of textures. 679 | */ 680 | "textures"?: Texture[]; 681 | "extensions"?: any; 682 | "extras"?: any; 683 | [k: string]: any; 684 | } 685 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { Reader as SVFReader } from './svf/reader'; 2 | export { Reader as SVF2Reader } from './svf2/reader'; 3 | export { Downloader as SVFDownloader } from './svf/downloader'; 4 | export { Downloader as F2DDownloader } from './f2d/downloader'; 5 | export { Downloader as SVF2Downloader } from './svf2/downloader'; 6 | export { Writer as GLTFWriter } from './gltf/writer'; 7 | export { CancellationToken } from './common/cancellation-token'; 8 | export { IAuthenticationProvider, BasicAuthenticationProvider, TwoLeggedAuthenticationProvider } from './common/authentication-provider'; -------------------------------------------------------------------------------- /src/svf/downloader.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as fse from 'fs-extra'; 3 | import axios from 'axios'; 4 | import { SVFReader } from '..'; 5 | import { IAuthenticationProvider } from '../common/authentication-provider'; 6 | import { ManifestResources, ModelDerivativeClient, Region } from '@aps_sdk/model-derivative'; 7 | import { Scopes } from '@aps_sdk/authentication'; 8 | import { CancellationToken } from '../common/cancellation-token'; 9 | 10 | export interface IDownloadOptions { 11 | region?: Region 12 | outputDir?: string; 13 | log?: (message: string) => void; 14 | failOnMissingAssets?: boolean; 15 | cancellationToken?: CancellationToken; 16 | } 17 | 18 | export class Downloader { 19 | protected readonly modelDerivativeClient = new ModelDerivativeClient(); 20 | 21 | constructor(protected authenticationProvider: IAuthenticationProvider) {} 22 | 23 | async download(urn: string, options?: IDownloadOptions): Promise { 24 | const outputDir = options?.outputDir || '.'; 25 | const log = options?.log || ((message: string) => {}); 26 | log(`Downloading derivative ${urn} (region: ${options?.region || 'default'})`); 27 | const accessToken = await this.authenticationProvider.getToken([Scopes.ViewablesRead]); 28 | const manifest = await this.modelDerivativeClient.getManifest(urn, { accessToken, region: options?.region }); 29 | const urnDir = path.join(outputDir, urn); 30 | 31 | const derivatives: ManifestResources[] = []; 32 | function collectDerivatives(derivative: ManifestResources) { 33 | if (derivative.type === 'resource' && derivative.role === 'graphics' && (derivative as any).mime === 'application/autodesk-svf') { 34 | derivatives.push(derivative); 35 | } 36 | if (derivative.children) { 37 | for (const child of derivative.children) { 38 | collectDerivatives(child); 39 | } 40 | } 41 | } 42 | for (const derivative of manifest.derivatives) { 43 | if (derivative.children) { 44 | for (const child of derivative.children) { 45 | collectDerivatives(child); 46 | } 47 | } 48 | } 49 | 50 | for (const derivative of derivatives) { 51 | if (options?.cancellationToken?.cancelled) { 52 | return; 53 | } 54 | const guid = derivative.guid; 55 | log(`Downloading viewable ${guid}`); 56 | const guidDir = path.join(urnDir, guid); 57 | fse.ensureDirSync(guidDir); 58 | const svf = await this.downloadDerivative(urn, encodeURI((derivative as any).urn), options?.region); 59 | fse.writeFileSync(path.join(guidDir, 'output.svf'), new Uint8Array(svf)); 60 | const reader = await SVFReader.FromDerivativeService(urn, guid, this.authenticationProvider, options?.region); 61 | const manifest = await reader.getManifest(); 62 | for (const asset of manifest.assets) { 63 | if (options?.cancellationToken?.cancelled) { 64 | return; 65 | } 66 | if (!asset.URI.startsWith('embed:')) { 67 | log(`Downloading asset ${asset.URI}`); 68 | try { 69 | const assetData = await reader.getAsset(asset.URI); 70 | const assetPath = path.join(guidDir, asset.URI); 71 | const assetFolder = path.dirname(assetPath); 72 | fse.ensureDirSync(assetFolder); 73 | fse.writeFileSync(assetPath, assetData); 74 | } catch (err) { 75 | if (options?.failOnMissingAssets) { 76 | throw err; 77 | } else { 78 | log(`Could not download asset ${asset.URI}`); 79 | } 80 | } 81 | } 82 | } 83 | } 84 | } 85 | 86 | private async downloadDerivative(urn: string, derivativeUrn: string, region?: Region) { 87 | try { 88 | const accessToken = await this.authenticationProvider.getToken([Scopes.ViewablesRead]); 89 | const downloadInfo = await this.modelDerivativeClient.getDerivativeUrl(derivativeUrn, urn, { accessToken, region }); 90 | const response = await axios.get(downloadInfo.url as string, { responseType: 'arraybuffer', decompress: false }); 91 | return response.data; 92 | } catch (error) { 93 | if (axios.isAxiosError(error)) { 94 | throw new Error(`Could not download derivative ${derivativeUrn}: ${error.message}`); 95 | } else { 96 | throw error; 97 | } 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/svf/fragments.ts: -------------------------------------------------------------------------------- 1 | import { PackFileReader } from '../common/packfile-reader'; 2 | import { IFragment } from './schema'; 3 | 4 | /** 5 | * Parses fragments from a binary buffer, typically stored in a file called 'FragmentList.pack', 6 | * referenced in the SVF manifest as an asset of type 'Autodesk.CloudPlatform.FragmentList'. 7 | * @generator 8 | * @param {Buffer} buffer Binary buffer to parse. 9 | * @returns {Iterable} Instances of parsed fragments. 10 | */ 11 | export function *parseFragments(buffer: Buffer): Iterable { 12 | const pfr = new PackFileReader(buffer); 13 | for (let i = 0, len = pfr.numEntries(); i < len; i++) { 14 | const entryType = pfr.seekEntry(i); 15 | console.assert(entryType); 16 | console.assert(entryType.version > 4); 17 | 18 | const flags = pfr.getUint8(); 19 | const visible: boolean = (flags & 0x01) !== 0; 20 | const materialID = pfr.getVarint(); 21 | const geometryID = pfr.getVarint(); 22 | const transform = pfr.getTransform(); 23 | let bbox = [0, 0, 0, 0, 0, 0]; 24 | let bboxOffset = [0, 0, 0]; 25 | if (entryType.version > 3) { 26 | if (transform && 't' in transform) { 27 | bboxOffset[0] = transform.t.x; 28 | bboxOffset[1] = transform.t.y; 29 | bboxOffset[2] = transform.t.z; 30 | } 31 | } 32 | for (let j = 0; j < 6; j++) { 33 | bbox[j] = pfr.getFloat32() + bboxOffset[j % 3]; 34 | } 35 | const dbID = pfr.getVarint(); 36 | 37 | yield { visible, materialID, geometryID, dbID, transform, bbox }; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/svf/geometries.ts: -------------------------------------------------------------------------------- 1 | import { IGeometryMetadata } from "./schema"; 2 | import { PackFileReader } from "../common/packfile-reader"; 3 | 4 | /** 5 | * Parses geometries from a binary buffer, typically stored in a file called 'GeometryMetadata.pf', 6 | * referenced in the SVF manifest as an asset of type 'Autodesk.CloudPlatform.GeometryMetadataList'. 7 | * @generator 8 | * @param {Buffer} buffer Binary buffer to parse. 9 | * @returns {Iterable} Instances of parsed geometries. 10 | */ 11 | export function *parseGeometries(buffer: Buffer): Iterable { 12 | const pfr = new PackFileReader(buffer); 13 | for (let i = 0, len = pfr.numEntries(); i < len; i++) { 14 | const entry = pfr.seekEntry(i); 15 | console.assert(entry); 16 | console.assert(entry.version >= 3); 17 | 18 | const fragType = pfr.getUint8(); 19 | // Skip past object space bbox -- we don't use that 20 | pfr.seek(pfr.offset + 24); 21 | const primCount = pfr.getUint16(); 22 | const packID = parseInt(pfr.getString(pfr.getVarint())); 23 | const entityID = pfr.getVarint(); 24 | // geometry.topoID = this.stream.getInt32(); 25 | 26 | yield { fragType, primCount, packID, entityID }; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/svf/materials.ts: -------------------------------------------------------------------------------- 1 | import * as zlib from 'zlib'; 2 | import { IMaterial, IMaterialMap } from './schema'; 3 | 4 | namespace SvfInternal { 5 | export interface IMaterials { 6 | name: string; 7 | version: string; 8 | scene: { [key: string]: any }; 9 | materials: { [key: string]: IMaterialGroup }; 10 | } 11 | 12 | export interface IMaterialGroup { 13 | version: number; 14 | userassets: string[]; 15 | materials: { [key: string]: IMaterial }; 16 | } 17 | 18 | export interface IMaterial { 19 | tag: string; 20 | proteinType: string; 21 | definition: string; 22 | transparent: boolean; 23 | keywords?: string[]; 24 | categories?: string[]; 25 | properties: { 26 | integers?: { [key: string]: number; }; 27 | booleans?: { [key: string]: boolean; }; 28 | strings?: { [key: string]: { values: string[] }; }; 29 | uris?: { [key: string]: { values: string[] }; }; 30 | scalars?: { [key: string]: { units: string; values: number[] }; }; 31 | colors?: { [key: string]: { values: { r: number; g: number; b: number; a: number; }[]; connections?: string[]; }; }; 32 | choicelists?: { [key: string]: { values: number[] }; }; 33 | uuids?: { [key: string]: { values: number[] }; }; 34 | references?: any; // TODO 35 | }; 36 | textures?: { [key: string]: { connections: string[] }; }; 37 | } 38 | } 39 | 40 | /** 41 | * Parses materials from a binary buffer, typically stored in a file called 'Materials.json.gz', 42 | * referenced in the SVF manifest as an asset of type 'ProteinMaterials'. 43 | * @generator 44 | * @param {Buffer} buffer Binary buffer to parse. 45 | * @returns {Iterable} Instances of parsed materials, or null if there are none (or are not supported). 46 | */ 47 | export function *parseMaterials(buffer: Buffer): Iterable { 48 | if (buffer[0] === 31 && buffer[1] === 139) { 49 | buffer = zlib.gunzipSync(buffer); 50 | } 51 | if (buffer.byteLength > 0) { 52 | const json = JSON.parse(buffer.toString()) as SvfInternal.IMaterials; 53 | for (const key of Object.keys(json.materials)) { 54 | const group = json.materials[key]; 55 | const material = group.materials[group.userassets[0]]; 56 | switch (material.definition) { 57 | case 'SimplePhong': 58 | yield parseSimplePhongMaterial(group); 59 | break; 60 | default: 61 | console.warn('Unsupported material definition', material.definition); 62 | yield null; 63 | break; 64 | } 65 | } 66 | } 67 | } 68 | 69 | function parseSimplePhongMaterial(group: SvfInternal.IMaterialGroup): IMaterial { 70 | let result: IMaterial = {}; 71 | const material = group.materials[group.userassets[0]]; 72 | 73 | result.diffuse = parseColorProperty(material, 'generic_diffuse', [0, 0, 0, 1]); 74 | result.specular = parseColorProperty(material, 'generic_specular', [0, 0, 0, 1]); 75 | result.ambient = parseColorProperty(material, 'generic_ambient', [0, 0, 0, 1]); 76 | result.emissive = parseColorProperty(material, 'generic_emissive', [0, 0, 0, 1]); 77 | 78 | result.glossiness = parseScalarProperty(material, 'generic_glossiness', 30); 79 | result.reflectivity = parseScalarProperty(material, 'generic_reflectivity_at_0deg', 0); 80 | result.opacity = 1.0 - parseScalarProperty(material, 'generic_transparency', 0); 81 | 82 | result.metal = parseBooleanProperty(material, 'generic_is_metal', false); 83 | 84 | if (material.textures) { 85 | result.maps = {}; 86 | const diffuse = parseTextureProperty(material, group, 'generic_diffuse'); 87 | if (diffuse) { 88 | result.maps.diffuse = diffuse; 89 | } 90 | const specular = parseTextureProperty(material, group, 'generic_specular'); 91 | if (specular) { 92 | result.maps.specular = specular; 93 | } 94 | const alpha = parseTextureProperty(material, group, 'generic_alpha'); 95 | if (alpha) { 96 | result.maps.alpha = alpha; 97 | } 98 | const bump = parseTextureProperty(material, group, 'generic_bump'); 99 | if (bump) { 100 | if (parseBooleanProperty(material, 'generic_bump_is_normal', false)) { 101 | result.maps.normal = bump; 102 | } else { 103 | result.maps.bump = bump; 104 | } 105 | } 106 | } 107 | 108 | return result; 109 | } 110 | 111 | function parseBooleanProperty(material: SvfInternal.IMaterial, prop: string, defaultValue: boolean): boolean { 112 | if (material.properties.booleans && prop in material.properties.booleans) { 113 | return material.properties.booleans[prop]; 114 | } else { 115 | return defaultValue; 116 | } 117 | } 118 | 119 | function parseScalarProperty(material: SvfInternal.IMaterial, prop: string, defaultValue: number): number { 120 | if (material.properties.scalars && prop in material.properties.scalars) { 121 | return material.properties.scalars[prop].values[0]; 122 | } else { 123 | return defaultValue; 124 | } 125 | } 126 | 127 | function parseColorProperty(material: SvfInternal.IMaterial, prop: string, defaultValue: number[]): number[] { 128 | if (material.properties.colors && prop in material.properties.colors) { 129 | const color = material.properties.colors[prop].values[0]; 130 | return [color.r, color.g, color.b, color.a]; 131 | } else { 132 | return defaultValue; 133 | } 134 | } 135 | 136 | function parseTextureProperty(material: SvfInternal.IMaterial, group: SvfInternal.IMaterialGroup, prop: string): IMaterialMap | null { 137 | if (material.textures && prop in material.textures) { 138 | const connection = material.textures[prop].connections[0]; 139 | const texture = group.materials[connection]; 140 | if (texture && texture.properties.uris && 'unifiedbitmap_Bitmap' in texture.properties.uris) { 141 | const uri = texture.properties.uris['unifiedbitmap_Bitmap'].values[0]; 142 | // TODO: parse texture transforms aside from scale 143 | const texture_UScale = texture.properties.scalars?.texture_UScale?.values[0] as number; 144 | const texture_VScale = texture.properties.scalars?.texture_VScale?.values[0] as number; 145 | /* 146 | console.log('uri and scale', { 147 | uri: uri, 148 | u: texture_UScale, 149 | v: texture_VScale 150 | }) 151 | */ 152 | if (uri) { 153 | return { uri, scale: { 154 | texture_UScale, 155 | texture_VScale 156 | } }; 157 | } 158 | } 159 | } 160 | return null; 161 | } 162 | -------------------------------------------------------------------------------- /src/svf/meshes.ts: -------------------------------------------------------------------------------- 1 | import { PackFileReader } from '../common/packfile-reader'; 2 | import { IMesh, ILines, IPoints, IUVMap } from './schema'; 3 | 4 | /** 5 | * Parses meshes from a binary buffer, typically stored in files called '.pf', 6 | * referenced in the SVF manifest as an asset of type 'Autodesk.CloudPlatform.PackFile'. 7 | * @generator 8 | * @param {Buffer} buffer Binary buffer to parse. 9 | * @returns {Iterable} Instances of parsed meshes, or null values 10 | * if the mesh cannot be parsed (and to maintain the indices used in {@link IGeometry}). 11 | */ 12 | export function *parseMeshes(buffer: Buffer): Iterable { 13 | const pfr = new PackFileReader(buffer); 14 | for (let i = 0, len = pfr.numEntries(); i < len; i++) { 15 | const entry = pfr.seekEntry(i); 16 | console.assert(entry); 17 | console.assert(entry.version >= 1); 18 | 19 | switch (entry._type) { 20 | case 'Autodesk.CloudPlatform.OpenCTM': 21 | yield parseMeshOCTM(pfr); 22 | break; 23 | case 'Autodesk.CloudPlatform.Lines': 24 | yield parseLines(pfr, entry.version); 25 | break; 26 | case 'Autodesk.CloudPlatform.Points': 27 | yield parsePoints(pfr, entry.version); 28 | break; 29 | } 30 | } 31 | } 32 | 33 | function parseMeshOCTM(pfr: PackFileReader): IMesh | null { 34 | const fourcc = pfr.getString(4); 35 | console.assert(fourcc === 'OCTM'); 36 | const version = pfr.getInt32(); 37 | console.assert(version === 5); 38 | const method = pfr.getString(3); 39 | pfr.getUint8(); // Read the last 0 char of the RAW or MG2 fourCC 40 | 41 | switch (method) { 42 | case 'RAW': 43 | return parseMeshRAW(pfr); 44 | default: 45 | console.warn('Unsupported OpenCTM method', method); 46 | return null; 47 | } 48 | } 49 | 50 | function parseMeshRAW(pfr: PackFileReader): IMesh { 51 | // We will create a single ArrayBuffer to back both the vertex and index buffers. 52 | // The indices will be places after the vertex information, because we need alignment of 4 bytes. 53 | 54 | const vcount = pfr.getInt32(); // Num of vertices 55 | const tcount = pfr.getInt32(); // Num of triangles 56 | const uvcount = pfr.getInt32(); // Num of UV maps 57 | const attrs = pfr.getInt32(); // Number of custom attributes per vertex 58 | const flags = pfr.getInt32(); // Additional flags (e.g., whether normals are present) 59 | const comment = pfr.getString(pfr.getInt32()); 60 | 61 | // Indices 62 | let name = pfr.getString(4); 63 | console.assert(name === 'INDX'); 64 | const indices = new Uint16Array(tcount * 3); 65 | for (let i = 0; i < tcount * 3; i++) { 66 | indices[i] = pfr.getUint32(); 67 | } 68 | 69 | // Vertices 70 | name = pfr.getString(4); 71 | console.assert(name === 'VERT'); 72 | const vertices = new Float32Array(vcount * 3); 73 | const min = { x: Number.MAX_VALUE, y: Number.MAX_VALUE, z: Number.MAX_VALUE }; 74 | const max = { x: Number.MIN_VALUE, y: Number.MIN_VALUE, z: Number.MIN_VALUE }; 75 | for (let i = 0; i < vcount * 3; i += 3) { 76 | const x = vertices[i] = pfr.getFloat32(); 77 | const y = vertices[i + 1] = pfr.getFloat32(); 78 | const z = vertices[i + 2] = pfr.getFloat32(); 79 | min.x = Math.min(min.x, x); 80 | max.x = Math.max(max.x, x); 81 | min.y = Math.min(min.y, y); 82 | max.y = Math.max(max.y, y); 83 | min.z = Math.min(min.z, z); 84 | max.z = Math.max(max.z, z); 85 | } 86 | 87 | // Normals 88 | let normals: Float32Array | null = null; 89 | if (flags & 1) { 90 | name = pfr.getString(4); 91 | console.assert(name === 'NORM'); 92 | normals = new Float32Array(vcount * 3); 93 | for (let i = 0; i < vcount; i++) { 94 | let x = pfr.getFloat32(); 95 | let y = pfr.getFloat32(); 96 | let z = pfr.getFloat32(); 97 | // Make sure the normals have unit length 98 | const dot = x * x + y * y + z * z; 99 | if (dot !== 1.0) { 100 | const len = Math.sqrt(dot); 101 | x /= len; 102 | y /= len; 103 | z /= len; 104 | } 105 | normals[i * 3] = x; 106 | normals[i * 3 + 1] = y; 107 | normals[i * 3 + 2] = z; 108 | } 109 | } 110 | 111 | // Parse zero or more UV maps 112 | const uvmaps: IUVMap[] = []; 113 | for (let i = 0; i < uvcount; i++) { 114 | name = pfr.getString(4); 115 | console.assert(name === 'TEXC'); 116 | const uvmap: IUVMap = { 117 | name: '', 118 | file: '', 119 | uvs: new Float32Array() 120 | }; 121 | uvmap.name = pfr.getString(pfr.getInt32()); 122 | uvmap.file = pfr.getString(pfr.getInt32()); 123 | uvmap.uvs = new Float32Array(vcount * 2); 124 | for (let j = 0; j < vcount; j++) { 125 | uvmap.uvs[j * 2] = pfr.getFloat32(); 126 | uvmap.uvs[j * 2 + 1] = 1.0 - pfr.getFloat32(); 127 | } 128 | uvmaps.push(uvmap); 129 | } 130 | 131 | // Parse custom attributes (currently we only support "Color" attrs) 132 | let colors: Float32Array | null = null; 133 | if (attrs > 0) { 134 | name = pfr.getString(4); 135 | console.assert(name === 'ATTR'); 136 | for (let i = 0; i < attrs; i++) { 137 | const attrName = pfr.getString(pfr.getInt32()); 138 | if (attrName === 'Color') { 139 | colors = new Float32Array(vcount * 4); 140 | for (let j = 0; j < vcount; j++) { 141 | colors[j * 4] = pfr.getFloat32(); 142 | colors[j * 4 + 1] = pfr.getFloat32(); 143 | colors[j * 4 + 2] = pfr.getFloat32(); 144 | colors[j * 4 + 3] = pfr.getFloat32(); 145 | } 146 | } else { 147 | pfr.seek(pfr.offset + vcount * 4); 148 | } 149 | } 150 | } 151 | 152 | const mesh: IMesh = { vcount, tcount, uvcount, attrs, flags, comment, uvmaps, indices, vertices, min, max }; 153 | if (normals) { 154 | mesh.normals = normals; 155 | } 156 | if (colors) { 157 | mesh.colors = colors; 158 | } 159 | return mesh; 160 | } 161 | 162 | function parseLines(pfr: PackFileReader, entryVersion: number): ILines { 163 | console.assert(entryVersion >= 2); 164 | 165 | const vertexCount = pfr.getUint16(); 166 | const indexCount = pfr.getUint16(); 167 | const boundsCount = pfr.getUint16(); // Ignoring for now 168 | const lineWidth = (entryVersion > 2) ? pfr.getFloat32() : 1.0; 169 | const hasColors = pfr.getUint8() !== 0; 170 | const lines: ILines = { 171 | isLines: true, 172 | vcount: vertexCount, 173 | lcount: indexCount / 2, 174 | vertices: new Float32Array(vertexCount * 3), 175 | indices: new Uint16Array(indexCount), 176 | lineWidth 177 | }; 178 | 179 | // Parse vertices 180 | for (let i = 0, len = vertexCount * 3; i < len; i++) { 181 | lines.vertices[i] = pfr.getFloat32(); 182 | } 183 | 184 | // Parse colors 185 | if (hasColors) { 186 | lines.colors = new Float32Array(vertexCount * 3); 187 | for (let i = 0, len = vertexCount * 3; i < len; i++) { 188 | lines.colors[i] = pfr.getFloat32(); 189 | } 190 | } 191 | 192 | // Parse indices 193 | for (let i = 0, len = indexCount; i < len; i++) { 194 | lines.indices[i] = pfr.getUint16(); 195 | } 196 | 197 | // TODO: Parse polyline bounds 198 | 199 | return lines; 200 | } 201 | 202 | function parsePoints(pfr: PackFileReader, entryVersion: number): IPoints { 203 | console.assert(entryVersion >= 2); 204 | 205 | const vertexCount = pfr.getUint16(); 206 | const indexCount = pfr.getUint16(); 207 | const pointSize = pfr.getFloat32(); 208 | const hasColors = pfr.getUint8() !== 0; 209 | const points: IPoints = { 210 | isPoints: true, 211 | vcount: vertexCount, 212 | vertices: new Float32Array(vertexCount * 3), 213 | pointSize 214 | }; 215 | 216 | // Parse vertices 217 | for (let i = 0, len = vertexCount * 3; i < len; i++) { 218 | points.vertices[i] = pfr.getFloat32(); 219 | } 220 | 221 | // Parse colors 222 | if (hasColors) { 223 | points.colors = new Float32Array(vertexCount * 3); 224 | for (let i = 0, len = vertexCount * 3; i < len; i++) { 225 | points.colors[i] = pfr.getFloat32(); 226 | } 227 | } 228 | 229 | return points; 230 | } 231 | -------------------------------------------------------------------------------- /src/svf/schema.ts: -------------------------------------------------------------------------------- 1 | export enum AssetType { 2 | Image = 'Autodesk.CloudPlatform.Image', 3 | PropertyViewables = 'Autodesk.CloudPlatform.PropertyViewables', 4 | PropertyOffsets = 'Autodesk.CloudPlatform.PropertyOffsets', 5 | PropertyAttributes = 'Autodesk.CloudPlatform.PropertyAttributes', 6 | PropertyValues = 'Autodesk.CloudPlatform.PropertyValues', 7 | PropertyIDs = 'Autodesk.CloudPlatform.PropertyIDs', 8 | PropertyAVs = 'Autodesk.CloudPlatform.PropertyAVs', 9 | PropertyRCVs = 'Autodesk.CloudPlatform.PropertyRCVs', 10 | ProteinMaterials = 'ProteinMaterials', 11 | PackFile = 'Autodesk.CloudPlatform.PackFile', 12 | FragmentList = 'Autodesk.CloudPlatform.FragmentList', 13 | GeometryMetadataList = 'Autodesk.CloudPlatform.GeometryMetadataList', 14 | InstanceTree = 'Autodesk.CloudPlatform.InstanceTree' 15 | } 16 | 17 | /** 18 | * Parsed content of an actual *.svf file. 19 | */ 20 | export interface ISvfRoot { 21 | manifest: ISvfManifest; 22 | metadata: ISvfMetadata; 23 | embedded: { [key: string]: Buffer }; 24 | } 25 | 26 | /** 27 | * Top-level manifest containing URIs and types of all assets 28 | * referenced by or embedded in a specific SVF file. 29 | * The URIs are typically relative to the SVF file itself. 30 | */ 31 | export interface ISvfManifest { 32 | name: string; 33 | manifestversion: number; 34 | toolkitversion: string; 35 | assets: ISvfManifestAsset[]; 36 | typesets: ISvfManifestTypeSet[]; 37 | } 38 | 39 | /** 40 | * Description of a specific asset referenced by or embedded in an SVF, 41 | * including the URI, compressed and uncompressed size, type of the asset itself, 42 | * and types of all entities inside the asset. 43 | */ 44 | export interface ISvfManifestAsset { 45 | id: string; 46 | type: AssetType; 47 | typeset?: string; 48 | URI: string; 49 | size: number; 50 | usize: number; 51 | } 52 | 53 | /** 54 | * Collection of type definitions. 55 | */ 56 | export interface ISvfManifestTypeSet { 57 | id: string; 58 | types: ISvfManifestType[]; 59 | } 60 | 61 | /** 62 | * Single type definition. 63 | */ 64 | export interface ISvfManifestType { 65 | class: string; 66 | type: string; 67 | version: number; 68 | } 69 | 70 | /** 71 | * Additional metadata for SVF such as the definition of "up" vector, 72 | * default background, etc. 73 | */ 74 | export interface ISvfMetadata { 75 | version: string; 76 | metadata: { [key: string]: any }; 77 | } 78 | 79 | /** 80 | * Fragment represents a single scene object, 81 | * linking together material, geometry, and database IDs, 82 | * and providing world transform and bounding box on top of that. 83 | */ 84 | export interface IFragment { 85 | visible: boolean; 86 | materialID: number; 87 | geometryID: number; 88 | dbID: number; 89 | transform: Transform | null; 90 | bbox: number[]; 91 | } 92 | 93 | /** 94 | * Lightweight data structure pointing to a mesh in a specific packfile and entry. 95 | * Contains additional information about the type of mesh and its primitive count. 96 | */ 97 | export interface IGeometryMetadata { 98 | fragType: number; 99 | primCount: number; 100 | packID: number; 101 | entityID: number; 102 | topoID?: number; 103 | } 104 | 105 | export interface IMaterial { 106 | diffuse?: number[]; 107 | specular?: number[]; 108 | ambient?: number[]; 109 | emissive?: number[]; 110 | glossiness?: number; 111 | reflectivity?: number; 112 | opacity?: number; 113 | metal?: boolean; 114 | maps?: { 115 | diffuse?: IMaterialMap; 116 | specular?: IMaterialMap; 117 | normal?: IMaterialMap; 118 | bump?: IMaterialMap; 119 | alpha?: IMaterialMap; 120 | }; 121 | } 122 | 123 | export interface IMaterialMap { 124 | uri: string; 125 | scale: { 126 | texture_UScale: number , 127 | texture_VScale: number 128 | } 129 | } 130 | 131 | /** 132 | * Triangular mesh data, including indices, vertices, optional normals and UVs. 133 | */ 134 | export interface IMesh { 135 | vcount: number; // Num of vertices 136 | tcount: number; // Num of triangles 137 | uvcount: number; // Num of UV maps 138 | attrs: number; // Number of attributes per vertex 139 | flags: number; 140 | comment: string; 141 | uvmaps: IUVMap[]; 142 | indices: Uint16Array; 143 | vertices: Float32Array; 144 | normals?: Float32Array; 145 | colors?: Float32Array; 146 | min: IVector3; 147 | max: IVector3; 148 | } 149 | 150 | /** 151 | * Line segment data. 152 | */ 153 | export interface ILines { 154 | isLines: true; 155 | vcount: number; // Number of vertices 156 | lcount: number; // Number of line segments 157 | vertices: Float32Array; // Vertex buffer (of length vcount*3) 158 | indices: Uint16Array; // Index buffer (of length lcount*2) 159 | colors?: Float32Array; // Optional color buffer (of length vcount*3) 160 | lineWidth: number; 161 | } 162 | 163 | /** 164 | * Point cloud data. 165 | */ 166 | export interface IPoints { 167 | isPoints: true; 168 | vcount: number; // Number of vertices/points 169 | vertices: Float32Array; // Vertex buffer (of length vcount*3) 170 | colors?: Float32Array; // Optional color buffer (of length vcount*3) 171 | pointSize: number; 172 | } 173 | 174 | /** 175 | * Single UV channel. {@link IMesh} can have more of these. 176 | */ 177 | export interface IUVMap { 178 | name: string; 179 | file: string; 180 | uvs: Float32Array; 181 | } 182 | 183 | export interface IVector3 { 184 | x: number; 185 | y: number; 186 | z: number; 187 | } 188 | 189 | export interface IQuaternion { 190 | x: number; 191 | y: number; 192 | z: number; 193 | w: number; 194 | } 195 | 196 | export type Matrix3x3 = number[]; 197 | 198 | export type Transform = { t: IVector3 } | { t: IVector3, s: IVector3, q: IQuaternion } | { matrix: Matrix3x3, t: IVector3 }; 199 | -------------------------------------------------------------------------------- /src/svf2/clients/ModelDataHttpClient.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { Scopes } from '@aps_sdk/authentication'; 3 | import { IAuthenticationProvider } from '../../common/authentication-provider'; 4 | import { Manifest, parse } from '../helpers/Manifest'; 5 | 6 | export class ModelDataHttpClient { 7 | protected readonly axios = axios.create({ 8 | baseURL: 'https://cdn.derivative.autodesk.com/modeldata', 9 | headers: { 10 | 'Pragma': 'no-cache' 11 | } 12 | }); 13 | 14 | constructor(protected readonly authenticationProvider: IAuthenticationProvider) { 15 | this.axios.interceptors.request.use(async config => { 16 | const accessToken = await this.authenticationProvider.getToken([Scopes.ViewablesRead]); 17 | config.headers['Authorization'] = `Bearer ${accessToken}`; 18 | return config; 19 | }); 20 | } 21 | 22 | /** 23 | * Retrieves the Model Derivative manifest augmented with OTG information. 24 | * @async 25 | * @param urn Model Derivative model URN. 26 | * @returns Model Derivative manifest augmented with OTG information. 27 | * @throws Error when the request fails, for example, due to insufficient rights, or incorrect scopes. 28 | */ 29 | async getManifest(urn: string): Promise { 30 | const { data } = await this.axios.get(`manifest/${urn}`); 31 | return parse(data); 32 | } 33 | 34 | /** 35 | * Retrieves raw data of specific OTG asset. 36 | * @async 37 | * @param {string} urn Model Derivative model URN. 38 | * @param {string} assetUrn OTG asset URN, typically composed from OTG "version root" or "shared root", 39 | * path to OTG view JSON, etc. 40 | * @returns {Promise} Asset data. 41 | * @throws Error when the request fails, for example, due to insufficient rights, or incorrect scopes. 42 | */ 43 | async getAsset(urn: string, assetUrn: string): Promise { 44 | const { data } = await this.axios.get(`file/${assetUrn}?acmsession=${urn}`, { responseType: 'arraybuffer' }); 45 | return data; 46 | } 47 | } -------------------------------------------------------------------------------- /src/svf2/clients/SharedDataHttpClient.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { Scopes } from '@aps_sdk/authentication'; 3 | import { IAuthenticationProvider } from '../../common/authentication-provider'; 4 | 5 | export class SharedDataHttpClient { 6 | protected readonly axios = axios.create({ 7 | baseURL: 'https://cdn.derivative.autodesk.com/cdn', 8 | headers: { 9 | 'Pragma': 'no-cache' 10 | } 11 | }); 12 | 13 | constructor(protected readonly authenticationProvider: IAuthenticationProvider) { 14 | this.axios.interceptors.request.use(async config => { 15 | const accessToken = await this.authenticationProvider.getToken([Scopes.ViewablesRead]); 16 | config.headers['Authorization'] = `Bearer ${accessToken}`; 17 | return config; 18 | }); 19 | } 20 | 21 | async getAsset(urn: string, assetUrn: string): Promise { 22 | const [_, account, type, hash] = assetUrn.split('/'); 23 | const cdnUrn = [hash.substring(0, 4), account, type, hash.substring(4)].join('/'); 24 | const { data } = await this.axios.get(cdnUrn + `?acmsession=${urn}`, { responseType: 'arraybuffer' }); 25 | return data; 26 | } 27 | } -------------------------------------------------------------------------------- /src/svf2/clients/SharedDataWebSocketClient.ts: -------------------------------------------------------------------------------- 1 | import WebSocket from 'ws'; 2 | import { gunzipSync } from 'zlib'; 3 | import { Scopes } from '@aps_sdk/authentication'; 4 | import { IAuthenticationProvider } from '../../common/authentication-provider'; 5 | 6 | export enum AssetType { 7 | Geometry = 'g', 8 | Material = 'm', 9 | } 10 | 11 | interface Resource { 12 | type: AssetType; 13 | hash: string; 14 | data: Uint8Array; 15 | } 16 | 17 | const HashByteLength = 20; 18 | const HashHexLength = 40; 19 | 20 | export class SharedDataWebSocketClient { 21 | protected requestedResources: number = 0; 22 | protected receivedResources: number = 0; 23 | protected lastSentAccountID: string = ''; 24 | protected lastSentURN: string = ''; 25 | protected lastSentAccessToken: string = ''; 26 | 27 | public static async Connect(authenticationProvider: IAuthenticationProvider, url: string = 'wss://cdn.derivative.autodesk.com/cdnws'): Promise { 28 | return new Promise((resolve, reject) => { 29 | const ws = new WebSocket(url); 30 | ws.binaryType = 'arraybuffer'; 31 | const onOpen = () => { 32 | detachListeners(); 33 | resolve(new SharedDataWebSocketClient(ws, authenticationProvider)); 34 | }; 35 | const onError = (err: Error) => { 36 | detachListeners(); 37 | reject(err); 38 | }; 39 | const attachListeners = () => ws.on('open', onOpen).on('error', onError); 40 | const detachListeners = () => ws.off('open', onOpen).off('error', onError); 41 | attachListeners(); 42 | }); 43 | } 44 | 45 | protected static HashToBinary = (hash: string): Uint8Array => { 46 | console.assert(hash.length === HashHexLength); 47 | return Uint8Array.from(Buffer.from(hash, 'hex')); 48 | } 49 | 50 | protected static BinaryToHash = (arr: Uint8Array): string => { 51 | console.assert(arr.byteLength === HashByteLength); 52 | return Array.from(arr).map(byte => byte.toString(16).padStart(2, '0')).join(''); 53 | } 54 | 55 | protected static EncodeRequest = (type: AssetType, hashes: string[]): Uint8Array => { 56 | const arr = new Uint8Array(1 + hashes.length * HashByteLength); 57 | arr[0] = type.charCodeAt(0); 58 | for (const [i, hash] of hashes.entries()) { 59 | arr.set(SharedDataWebSocketClient.HashToBinary(hash), 1 + i * HashByteLength); 60 | } 61 | return arr; 62 | } 63 | 64 | protected static DecodeResponse = (data: ArrayBuffer): Resource[] => { 65 | if (data.byteLength < 4) { 66 | throw new Error('Invalid message length.'); 67 | } 68 | 69 | const view = new DataView(data); 70 | const magicNumber = view.getUint32(0, true); 71 | if (magicNumber !== 0x314B504F) { 72 | throw new Error('Invalid magic number.'); 73 | } 74 | 75 | const resourceType = String.fromCharCode(view.getUint32(4, true) & 0xff); 76 | const numResources = view.getUint32(8, true); 77 | const offsets = new Uint32Array(data, 12, numResources); 78 | const content = new Uint8Array(data, 12 + offsets.byteLength); 79 | const resources: Resource[] = []; 80 | for (let i = 0; i < numResources; i++) { 81 | const start = offsets[i]; 82 | const end = ((i < offsets.length - 1) ? offsets[i + 1] : content.byteLength); 83 | const hash = SharedDataWebSocketClient.BinaryToHash(content.slice(start, start + HashByteLength)); 84 | const data = content.slice(start + HashByteLength, end); 85 | if (resourceType === 'e') { 86 | // The first four bytes are a HTTP-statuscode-like error code. It doesn't add anything to the message so we ignore it. 87 | // See https://git.autodesk.com/A360/platform-ds-ss/blob/6c439e82f3138eed3935b68096d2d980ffe95616/src/ws-server/ws-server.js#L310 88 | const errorMessage = new TextDecoder().decode(data.subarray(4)); 89 | throw new Error(`Error from WebSocket server: ${errorMessage}`); 90 | } else { 91 | resources.push({ type: resourceType as AssetType, hash: hash, data }); 92 | } 93 | } 94 | return resources; 95 | } 96 | 97 | constructor(protected readonly ws: WebSocket, protected readonly authenticationProvider: IAuthenticationProvider) {} 98 | 99 | close() { 100 | this.ws.close(); 101 | } 102 | 103 | async getAssets(urn: string, account: string, type: AssetType, hashes: string[]): Promise> { 104 | const accessToken = await this.authenticationProvider.getToken([Scopes.ViewablesRead]); 105 | const resources = await this.requestResources(urn, account, type, hashes, accessToken); 106 | const assets = new Map(); 107 | for (const { hash, data } of resources) { 108 | assets.set(hash, gunzipSync(Buffer.from(data.buffer))); 109 | } 110 | return assets; 111 | } 112 | 113 | protected requestResources(urn: string, accountID: string, type: AssetType, hashes: string[], accessToken: string): Promise { 114 | if (this.ws.readyState !== WebSocket.OPEN) { 115 | throw new Error('WebSocket connection is not open.'); 116 | } 117 | if (this.receivedResources < this.requestedResources) { 118 | throw new Error('Previous requests are still being processed.'); 119 | } 120 | this.requestedResources += hashes.length; 121 | 122 | if (this.lastSentAccessToken !== accessToken) { 123 | this.ws.send(`/headers/{"Authorization":"Bearer ${accessToken}"}`); 124 | this.ws.send(`/options/{"batch_responses":true,"report_errors":true}`); 125 | this.lastSentAccessToken = accessToken; 126 | } 127 | if (this.lastSentURN !== urn) { 128 | this.ws.send(`/auth/${urn}`); 129 | this.lastSentURN = urn; 130 | } 131 | if (this.lastSentAccountID !== accountID) { 132 | this.ws.send(`/account_id/${accountID}`); 133 | this.lastSentAccountID = accountID; 134 | } 135 | 136 | return new Promise(async (resolve, reject) => { 137 | const results: Resource[] = []; 138 | const onMessage = (data: WebSocket.Data) => { 139 | try { 140 | const resources = SharedDataWebSocketClient.DecodeResponse(data as ArrayBuffer); 141 | results.push(...resources); 142 | this.receivedResources += resources.length; 143 | if (this.receivedResources === this.requestedResources) { 144 | detachListeners(); 145 | resolve(results); 146 | } 147 | } catch (err) { 148 | detachListeners(); 149 | reject(err); 150 | } 151 | }; 152 | const onError = (err: Error) => { 153 | detachListeners(); 154 | reject(new Error(`WebSocket connection error: ${err.message}.`)); 155 | }; 156 | const onClose = (code: number, reason: Buffer) => { 157 | detachListeners(); 158 | reject(new Error(`WebSocket connection closed with code ${code}: ${reason.toString()}.`)); 159 | }; 160 | const attachListeners = () => this.ws.on('message', onMessage).on('error', onError).on('close', onClose); 161 | const detachListeners = () => this.ws.off('message', onMessage).off('error', onError).off('close', onClose); 162 | attachListeners(); 163 | const requestBuffer = SharedDataWebSocketClient.EncodeRequest(type, hashes); 164 | this.ws.send(requestBuffer); 165 | }); 166 | } 167 | } -------------------------------------------------------------------------------- /src/svf2/downloader.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'node:path'; 2 | import * as fse from 'fs-extra'; 3 | import { IAuthenticationProvider } from '../common/authentication-provider'; 4 | import { ModelDataHttpClient } from './clients/ModelDataHttpClient'; 5 | import { SharedDataHttpClient } from './clients/SharedDataHttpClient'; 6 | import { SharedDataWebSocketClient, AssetType } from './clients/SharedDataWebSocketClient'; 7 | import { findManifestSVF2, resolveViewURN, OTGManifest } from './helpers/Manifest'; 8 | import { parseHashes } from './helpers/HashList'; 9 | import { getViewAccount, parse, resolveAssetUrn, resolveGeometryUrn, resolveMaterialUrn, resolveTextureUrn, View } from './helpers/View'; 10 | import { CancellationToken } from '../common/cancellation-token'; 11 | 12 | const UseWebSockets = true; 13 | const BatchSize = 32; 14 | 15 | export interface IDownloadOptions { 16 | outputDir?: string; 17 | log?: (message: string) => void; 18 | cancellationToken?: CancellationToken; 19 | } 20 | 21 | export class Downloader { 22 | protected readonly modelDataClient: ModelDataHttpClient; 23 | protected readonly sharedDataClient: SharedDataHttpClient; 24 | protected sharedDataWebSocketClient?: SharedDataWebSocketClient; 25 | 26 | constructor(protected readonly authenticationProvider: IAuthenticationProvider) { 27 | this.modelDataClient = new ModelDataHttpClient(authenticationProvider); 28 | this.sharedDataClient = new SharedDataHttpClient(authenticationProvider); 29 | } 30 | 31 | async download(urn: string, options?: IDownloadOptions): Promise { 32 | const outputDir = options?.outputDir || '.'; 33 | const log = options?.log || ((message: string) => {}); 34 | log(`Downloading ${urn}...`); 35 | const urnDir = path.join(outputDir, urn); 36 | await fse.ensureDir(urnDir); 37 | const sharedAssetsDir = urnDir; // Update to store shared assets in the urn directory 38 | const derivativeManifest = await this.modelDataClient.getManifest(urn); 39 | await fse.writeFile(path.join(urnDir, 'manifest.json'), JSON.stringify(derivativeManifest, null, 2)); 40 | const manifest = findManifestSVF2(derivativeManifest); 41 | this.sharedDataWebSocketClient = await SharedDataWebSocketClient.Connect(this.authenticationProvider); 42 | for (const [id, view] of Object.entries(manifest.views)) { 43 | if (options?.cancellationToken?.cancelled) { 44 | break; 45 | } 46 | if (view.role === 'graphics' && view.mime === 'application/autodesk-otg') { 47 | await this.downloadView(urn, manifest, id, path.join(urnDir, id), sharedAssetsDir, options); 48 | } 49 | } 50 | this.sharedDataWebSocketClient.close(); 51 | } 52 | 53 | protected async downloadView(urn: string, manifest: OTGManifest, viewId: string, outputDir: string, sharedAssetsDir: string, options?: IDownloadOptions): Promise { 54 | const log = options?.log || ((message: string) => {}); 55 | log(`Downloading view ${viewId}...`); 56 | await fse.ensureDir(outputDir); 57 | const resolvedViewURN = resolveViewURN(manifest, manifest.views[viewId]); 58 | const viewManifestBuffer = await this.modelDataClient.getAsset(urn, encodeURIComponent(resolvedViewURN)); 59 | const view = parse(JSON.parse(viewManifestBuffer.toString())); 60 | const viewFilePath = path.join(outputDir, manifest.views[viewId].urn); 61 | const viewFolderPath = path.dirname(viewFilePath); 62 | await fse.ensureDir(viewFolderPath); 63 | await fse.writeFile(viewFilePath, viewManifestBuffer); 64 | await this.downloadFragments(urn, resolvedViewURN, view, viewFolderPath, sharedAssetsDir, options); 65 | if (view.manifest.assets.geometry_ptrs) { 66 | if (UseWebSockets) { 67 | await this.downloadGeometriesBatch(urn, resolvedViewURN, view, viewFolderPath, sharedAssetsDir, options); 68 | } else { 69 | await this.downloadGeometries(urn, resolvedViewURN, view, viewFolderPath, sharedAssetsDir, options); 70 | } 71 | } 72 | if (view.manifest.assets.materials_ptrs) { 73 | if (UseWebSockets) { 74 | await this.downloadMaterialsBatch(urn, resolvedViewURN, view, viewFolderPath, sharedAssetsDir, options); 75 | } else { 76 | await this.downloadMaterials(urn, resolvedViewURN, view, viewFolderPath, sharedAssetsDir, options); 77 | } 78 | } 79 | await this.downloadTextures(urn, resolvedViewURN, view, viewFolderPath, sharedAssetsDir, options); 80 | await this.downloadProperties(urn, resolvedViewURN, view, viewFolderPath, sharedAssetsDir, options); 81 | } 82 | 83 | protected async downloadFragments(urn: string, resolvedViewURN: string, view: View, outputDir: string, sharedAssetsDir: string, options?: IDownloadOptions): Promise { 84 | const log = options?.log || ((message: string) => {}); 85 | log(`Downloading fragment list...`); 86 | const resolvedFragmentListUrn = resolveAssetUrn(resolvedViewURN, view.manifest.assets.fragments); 87 | const fragmentListBuffer = await this.modelDataClient.getAsset(urn, encodeURIComponent(resolvedFragmentListUrn)); 88 | await fse.writeFile(path.join(outputDir, 'fragments.fl'), fragmentListBuffer); 89 | } 90 | 91 | protected async downloadGeometries(urn: string, resolvedViewURN: string, view: View, outputDir: string, sharedAssetsDir: string, options?: IDownloadOptions): Promise { 92 | const log = options?.log || ((message: string) => {}); 93 | log(`Downloading geometry list...`); 94 | const resolvedGeometryListUrn = resolveAssetUrn(resolvedViewURN, view.manifest.assets.geometry_ptrs!); 95 | const geometryListBuffer = await this.modelDataClient.getAsset(urn, encodeURIComponent(resolvedGeometryListUrn)); 96 | await fse.writeFile(path.join(outputDir, 'geometry_ptrs.hl'), geometryListBuffer); 97 | const geometryFolderPath = path.join(sharedAssetsDir, view.manifest.shared_assets.geometry); 98 | await fse.ensureDir(geometryFolderPath); 99 | for (const hash of parseHashes(geometryListBuffer)) { 100 | if (options?.cancellationToken?.cancelled) { 101 | break; 102 | } 103 | const geometryFilePath = path.join(geometryFolderPath, hash); 104 | if (await fse.pathExists(geometryFilePath)) { 105 | log(`Geometry ${hash} already exists, skipping...`); 106 | continue; 107 | } 108 | log(`Downloading geometry ${hash}...`); 109 | const geometryUrn = resolveGeometryUrn(view, hash); 110 | const geometryBuffer = await this.sharedDataClient.getAsset(urn, geometryUrn); 111 | await fse.writeFile(geometryFilePath, geometryBuffer); 112 | } 113 | } 114 | 115 | protected async downloadGeometriesBatch(urn: string, resolvedViewURN: string, view: View, outputDir: string, sharedAssetsDir: string, options?: IDownloadOptions): Promise { 116 | const log = options?.log || ((message: string) => {}); 117 | log(`Downloading geometry list...`); 118 | const resolvedGeometryListUrn = resolveAssetUrn(resolvedViewURN, view.manifest.assets.geometry_ptrs!); 119 | const geometryListBuffer = await this.modelDataClient.getAsset(urn, encodeURIComponent(resolvedGeometryListUrn)); 120 | await fse.writeFile(path.join(outputDir, 'geometry_ptrs.hl'), geometryListBuffer); 121 | const geometryFolderPath = path.join(sharedAssetsDir, view.manifest.shared_assets.geometry); 122 | await fse.ensureDir(geometryFolderPath); 123 | const account = getViewAccount(view); 124 | 125 | let batch: { hash: string; path: string; }[] = []; 126 | const processBatch = async () => { 127 | log(`Downloading geometry batch ${batch.map(e => e.hash.substring(0, 4))}...`); 128 | const buffers = await this.sharedDataWebSocketClient!.getAssets(urn, account, AssetType.Geometry, batch.map(e => e.hash)); 129 | await Promise.all(batch.map(({ hash, path }) => fse.writeFile(path, buffers.get(hash)!))); 130 | batch = []; 131 | } 132 | 133 | for (const hash of parseHashes(geometryListBuffer)) { 134 | if (options?.cancellationToken?.cancelled) { 135 | break; 136 | } 137 | const geometryFilePath = path.join(geometryFolderPath, hash); 138 | if (await fse.pathExists(geometryFilePath)) { 139 | log(`Geometry ${hash} already exists, skipping...`); 140 | continue; 141 | } 142 | batch.push({ hash, path: geometryFilePath }); 143 | if (batch.length === BatchSize) { 144 | await processBatch(); 145 | } 146 | } 147 | if (batch.length > 0) { 148 | await processBatch(); 149 | } 150 | } 151 | 152 | protected async downloadMaterials(urn: string, resolvedViewURN: string, view: View, outputDir: string, sharedAssetsDir: string, options?: IDownloadOptions): Promise { 153 | const log = options?.log || ((message: string) => {}); 154 | log(`Downloading material list...`); 155 | const resolvedMaterialListUrn = resolveAssetUrn(resolvedViewURN, view.manifest.assets.materials_ptrs!); 156 | const materialListBuffer = await this.modelDataClient.getAsset(urn, encodeURIComponent(resolvedMaterialListUrn)); 157 | await fse.writeFile(path.join(outputDir, 'materials_ptrs.hl'), materialListBuffer); 158 | const materialFolderPath = path.join(sharedAssetsDir, view.manifest.shared_assets.materials); 159 | await fse.ensureDir(materialFolderPath); 160 | for (const hash of parseHashes(materialListBuffer)) { 161 | if (options?.cancellationToken?.cancelled) { 162 | break; 163 | } 164 | const materialFilePath = path.join(materialFolderPath, hash); 165 | if (await fse.pathExists(materialFilePath)) { 166 | log(`Material ${hash} already exists, skipping...`); 167 | continue; 168 | } 169 | log(`Downloading material ${hash}...`); 170 | const materialUrn = resolveMaterialUrn(view, hash); 171 | const materialBuffer = await this.sharedDataClient.getAsset(urn, materialUrn); 172 | await fse.writeFile(materialFilePath, materialBuffer); 173 | } 174 | } 175 | 176 | protected async downloadMaterialsBatch(urn: string, resolvedViewURN: string, view: View, outputDir: string, sharedAssetsDir: string, options?: IDownloadOptions): Promise { 177 | const log = options?.log || ((message: string) => {}); 178 | log(`Downloading material list...`); 179 | const resolvedMaterialListUrn = resolveAssetUrn(resolvedViewURN, view.manifest.assets.materials_ptrs!); 180 | const materialListBuffer = await this.modelDataClient.getAsset(urn, encodeURIComponent(resolvedMaterialListUrn)); 181 | await fse.writeFile(path.join(outputDir, 'materials_ptrs.hl'), materialListBuffer); 182 | const materialFolderPath = path.join(sharedAssetsDir, view.manifest.shared_assets.materials); 183 | await fse.ensureDir(materialFolderPath); 184 | const account = getViewAccount(view); 185 | 186 | let batch: { hash: string; path: string }[] = []; 187 | const processBatch = async () => { 188 | log(`Downloading material batch ${batch.map(e => e.hash.substring(0, 4))}...`); 189 | const buffers = await this.sharedDataWebSocketClient!.getAssets(urn, account, AssetType.Material, batch.map(e => e.hash)); 190 | await Promise.all(batch.map(({ hash, path }) => fse.writeFile(path, buffers.get(hash)!))); 191 | batch = []; 192 | } 193 | 194 | for (const hash of parseHashes(materialListBuffer)) { 195 | if (options?.cancellationToken?.cancelled) { 196 | break; 197 | } 198 | const materialFilePath = path.join(materialFolderPath, hash); 199 | if (await fse.pathExists(materialFilePath)) { 200 | log(`Material ${hash} already exists, skipping...`); 201 | continue; 202 | } 203 | batch.push({ hash, path: materialFilePath }); 204 | if (batch.length === BatchSize) { 205 | await processBatch(); 206 | } 207 | } 208 | if (batch.length > 0) { 209 | await processBatch(); 210 | } 211 | } 212 | 213 | protected async downloadTextures(urn: string, resolvedViewUrn: string, view: View, outputDir: string, sharedAssetsDir: string, options?: IDownloadOptions): Promise { 214 | if (!view.manifest.assets.texture_manifest) { 215 | return; 216 | } 217 | const log = options?.log || ((message: string) => {}); 218 | log(`Downloading texture manifest...`); 219 | const resolvedTextureManifestUrn = resolveAssetUrn(resolvedViewUrn, view.manifest.assets.texture_manifest); 220 | const textureManifestBuffer = await this.modelDataClient.getAsset(urn, encodeURIComponent(resolvedTextureManifestUrn)); 221 | await fse.writeFile(path.join(outputDir, 'texture_manifest.json'), textureManifestBuffer); 222 | const textureFolderPath = path.join(sharedAssetsDir, view.manifest.shared_assets.textures); 223 | await fse.ensureDir(textureFolderPath); 224 | const textureManifest = JSON.parse(textureManifestBuffer.toString()) as { [key: string]: string }; 225 | for (const [_, uri] of Object.entries(textureManifest)) { 226 | if (options?.cancellationToken?.cancelled) { 227 | break; 228 | } 229 | const textureFilePath = path.join(textureFolderPath, uri); 230 | if (await fse.pathExists(textureFilePath)) { 231 | log(`Texture ${uri} already exists, skipping...`); 232 | continue; 233 | } 234 | log(`Downloading texture ${uri}...`); 235 | const textureUrn = resolveTextureUrn(view, uri); 236 | const textureBuffer = await this.sharedDataClient.getAsset(urn, textureUrn); 237 | await fse.writeFile(textureFilePath, textureBuffer); 238 | } 239 | } 240 | 241 | protected async downloadProperties(urn: string, resolvedViewURN: string, view: View, outputDir: string, sharedAssetsDir: string, options?: IDownloadOptions): Promise { 242 | const log = options?.log || ((message: string) => {}); 243 | log(`Downloading property assets...`); 244 | const write = async (uri?: string) => { 245 | if (uri) { 246 | log(`Downloading ${uri}...`); 247 | const resolvedAssetUrn = resolveAssetUrn(resolvedViewURN, uri); 248 | const buffer = await this.modelDataClient.getAsset(urn, encodeURIComponent(resolvedAssetUrn)); 249 | const filePath = path.join(outputDir, uri); 250 | await fse.ensureDir(path.dirname(filePath)); 251 | await fse.writeFile(filePath, buffer); 252 | } 253 | }; 254 | const { avs, dbid, offsets } = view.manifest.assets.pdb; 255 | const { attrs, ids, values } = view.manifest.shared_assets.pdb; 256 | await Promise.all([ 257 | write(avs), 258 | write(dbid), 259 | write(offsets), 260 | write(attrs), 261 | write(ids), 262 | write(values), 263 | ]); 264 | } 265 | } -------------------------------------------------------------------------------- /src/svf2/helpers/Fragment.ts: -------------------------------------------------------------------------------- 1 | import { InputStream } from '../../common/input-stream'; 2 | 3 | export interface Fragment { 4 | geomId: number; 5 | materialId: number; 6 | dbId: number; 7 | flags: number; 8 | transform: Transform; 9 | } 10 | 11 | export interface Transform { 12 | translation: Vec3; 13 | quaternion: Quaternion; 14 | scale: Vec3; 15 | } 16 | 17 | export interface Quaternion { 18 | x: number; 19 | y: number; 20 | z: number; 21 | w: number; 22 | } 23 | 24 | export interface Vec3 { 25 | x: number; 26 | y: number; 27 | z: number; 28 | } 29 | 30 | export interface Vec2 { 31 | x: number; 32 | y: number; 33 | } 34 | 35 | /** 36 | * Parses fragments from a given buffer and yields them as an iterable of Fragment objects. 37 | * 38 | * @param buffer The buffer containing the fragment data. 39 | * @param fragmentOffset An optional offset to apply to the fragment's translation. Defaults to { x: 0, y: 0, z: 0 }. 40 | * @yields An iterable of IFragment objects. 41 | * 42 | * @remarks 43 | * The function reads the buffer using an InputStream, extracts fragment data, and applies the given offset to the translation. 44 | * The buffer is expected to have a specific structure, with each fragment's data being read in a loop until the end of the buffer is reached. 45 | * 46 | * @example 47 | * ```typescript 48 | * const buffer = getBufferFromSomeSource(); 49 | * const fragmentOffset = { x: 10, y: 20, z: 30 }; 50 | * for (const fragment of parseFragments(buffer, fragmentOffset)) { 51 | * console.log(fragment); 52 | * } 53 | * ``` 54 | */ 55 | export function* parseFragments(buffer: Buffer, fragmentOffset: Vec3 = { x: 0, y: 0, z: 0 }): Iterable { 56 | const stream = new InputStream(buffer); 57 | const byteStride = stream.getUint16(); 58 | console.assert(byteStride % 4 === 0); 59 | const version = stream.getUint16(); 60 | const chunk = new Uint8Array(byteStride); 61 | const floats = new Float32Array(chunk.buffer); 62 | const uints = new Uint32Array(chunk.buffer); 63 | stream.seek(byteStride); 64 | while (stream.offset < stream.length - 1) { 65 | for (let i = 0; i < chunk.length; i++) { 66 | chunk[i] = stream.getUint8(); 67 | } 68 | yield { 69 | geomId: uints[0], 70 | materialId: uints[1] - 1, 71 | dbId: uints[2], 72 | flags: uints[3], 73 | transform: { 74 | translation: { 75 | x: floats[4] + fragmentOffset.x, 76 | y: floats[5] + fragmentOffset.y, 77 | z: floats[6] + fragmentOffset.z, 78 | }, 79 | quaternion: { 80 | x: floats[7], 81 | y: floats[8], 82 | z: floats[9], 83 | w: floats[10], 84 | }, 85 | scale: { 86 | x: floats[11], 87 | y: floats[12], 88 | z: floats[13], 89 | } 90 | } 91 | }; 92 | } 93 | } -------------------------------------------------------------------------------- /src/svf2/helpers/Geometry.ts: -------------------------------------------------------------------------------- 1 | import { InputStream } from '../../common/input-stream'; 2 | 3 | export type Geometry = IMeshGeometry | ILineGeometry; 4 | 5 | export interface IMeshGeometry { 6 | type: GeometryType.Triangles; 7 | indices: Uint16Array; 8 | vertices: Float32Array; 9 | normals?: Float32Array; 10 | colors?: Float32Array; 11 | uvs?: Float32Array 12 | } 13 | 14 | export interface ILineGeometry { 15 | type: GeometryType.Lines; 16 | indices: Uint16Array; 17 | vertices: Float32Array; 18 | } 19 | 20 | export enum GeometryType { 21 | Triangles = 0, 22 | Lines = 1, 23 | Points = 2, 24 | WideLines = 3, 25 | } 26 | 27 | interface IGeometryAttribute { 28 | attributeType: AttributeType; 29 | componentType: ComponentType; // Type of individual components of each item for this attribute (for example, FLOAT for vec3 vertices) 30 | itemSize: number; // Number of components in each item for this attribute (for example, 3 for vec3 vertices) 31 | itemOffset: number; 32 | itemStride: number; 33 | bufferId: number; 34 | } 35 | 36 | enum AttributeType { 37 | Index = 0, 38 | IndexEdges = 1, 39 | Position = 2, 40 | Normal = 3, 41 | TextureUV = 4, 42 | Color = 5, 43 | } 44 | 45 | enum ComponentType { 46 | BYTE = 0, 47 | SHORT = 1, 48 | UBYTE = 2, 49 | USHORT = 3, 50 | 51 | BYTE_NORM = 4, 52 | SHORT_NORM = 5, 53 | UBYTE_NORM = 6, 54 | USHORT_NORM = 7, 55 | 56 | FLOAT = 8, 57 | INT = 9, 58 | UINT = 10, 59 | //DOUBLE = 11 60 | } 61 | 62 | function attributeTypeSize(componentType: ComponentType): number { 63 | switch (componentType) { 64 | case ComponentType.BYTE: 65 | case ComponentType.UBYTE: 66 | case ComponentType.BYTE_NORM: 67 | case ComponentType.UBYTE_NORM: 68 | return 1; 69 | case ComponentType.SHORT: 70 | case ComponentType.USHORT: 71 | case ComponentType.SHORT_NORM: 72 | case ComponentType.USHORT_NORM: 73 | return 2; 74 | case ComponentType.FLOAT: 75 | case ComponentType.INT: 76 | case ComponentType.UINT: 77 | return 4; 78 | default: 79 | throw new Error(`Unknown component type: ${componentType}`); 80 | } 81 | } 82 | 83 | /** 84 | * Parses the geometry data from the given buffer. 85 | * 86 | * @param buffer The buffer containing the geometry data. 87 | * @returns An object representing the parsed geometry. 88 | * @throws Will throw an error if the magic string is not 'OTG0'. 89 | */ 90 | export function parseGeometry(buffer: Buffer): Geometry { 91 | const stream = new InputStream(buffer); 92 | const magic = stream.getString(4); 93 | console.assert(magic === 'OTG0'); 94 | const flags = stream.getUint16(); 95 | const geomType: GeometryType = flags & 0x03; 96 | const buffCount = stream.getUint8(); 97 | const attrCount = stream.getUint8(); 98 | const buffOffsets = [0]; 99 | for (let i = 1; i < buffCount; i++) { 100 | buffOffsets.push(stream.getUint32()); 101 | } 102 | const attributes: IGeometryAttribute[] = []; 103 | for (let i = 0; i < attrCount; i++) { 104 | attributes.push(parseGeometryAttribute(stream)); 105 | } 106 | let dataOffset = stream.offset; 107 | if (dataOffset % 4 !== 0) { 108 | dataOffset += 4 - (dataOffset % 4); 109 | } 110 | let buffers: Buffer[] = []; 111 | for (let i = 0; i < buffCount; i++) { 112 | const offset = dataOffset + buffOffsets[i]; 113 | const length = (i + 1 < buffCount) ? buffOffsets[i + 1] - buffOffsets[i] : buffer.length - offset; 114 | const buff = Buffer.alloc(length); 115 | buffer.copy(buff, 0, offset, offset + length); 116 | buffers.push(buff); 117 | } 118 | 119 | switch (geomType) { 120 | case GeometryType.Triangles: 121 | return parseTriangleGeometry(attributes, buffers); 122 | case GeometryType.Lines: 123 | return parseLineGeometry(attributes, buffers); 124 | default: 125 | throw new Error(`Unsupported geometry type: ${geomType}`); 126 | } 127 | } 128 | 129 | function parseGeometryAttribute(stream: InputStream): IGeometryAttribute { 130 | const attributeType: AttributeType = stream.getUint8(); 131 | const b = stream.getUint8(); 132 | const itemSize: number = b & 0x0f; 133 | const componentType: ComponentType = (b >> 4) & 0x0f; 134 | const itemOffset: number = stream.getUint8(); // offset in bytes 135 | const itemStride: number = stream.getUint8(); // stride in bytes 136 | const bufferId: number = stream.getUint8(); 137 | return { 138 | attributeType, 139 | componentType, 140 | itemSize, 141 | itemOffset, 142 | itemStride, 143 | bufferId 144 | }; 145 | } 146 | 147 | function parseTriangleGeometry(attributes: IGeometryAttribute[], buffers: Buffer[]): IMeshGeometry { 148 | return { 149 | type: GeometryType.Triangles, 150 | indices: getIndices(attributes, buffers, false), 151 | vertices: getVertices(attributes, buffers), 152 | normals: getNormals(attributes, buffers), 153 | colors: getColors(attributes, buffers), 154 | uvs: getUvs(attributes, buffers) 155 | }; 156 | } 157 | 158 | function parseLineGeometry(attributes: IGeometryAttribute[], buffers: Buffer[]): ILineGeometry { 159 | return { 160 | type: GeometryType.Lines, 161 | indices: getIndices(attributes, buffers, true), 162 | vertices: getVertices(attributes, buffers), 163 | }; 164 | } 165 | 166 | function deltaDecodeIndexBuffer3(ib: any) { 167 | if (!ib.length) 168 | return; 169 | ib[1] += ib[0]; 170 | ib[2] += ib[0]; 171 | for (var i = 3; i < ib.length; i += 3) { 172 | ib[i] += ib[i - 3]; 173 | ib[i + 1] += ib[i]; 174 | ib[i + 2] += ib[i]; 175 | } 176 | } 177 | 178 | function deltaDecodeIndexBuffer2(ib: any) { 179 | if (!ib.length) 180 | return; 181 | ib[1] += ib[0]; 182 | for (var i = 2; i < ib.length; i += 2) { 183 | ib[i] += ib[i - 2]; 184 | ib[i + 1] += ib[i]; 185 | } 186 | } 187 | 188 | function decodeNormal(enc: { x: number; y: number; }): ({ x: number; y: number; z: number; }) { 189 | let ang = { x: enc.x * 2.0 - 1.0, y: enc.y * 2.0 - 1.0 }; 190 | let scth = { x: Math.sin(ang.x * Math.PI), y: Math.cos(ang.x * Math.PI) }; 191 | let scphi = { x: Math.sqrt(1.0 - ang.y * ang.y), y: ang.y }; 192 | return { x: scth.y * scphi.x, y: scth.x * scphi.x, z: scphi.y }; 193 | } 194 | 195 | function getIndices(attributes: IGeometryAttribute[], buffers: Buffer[], isLines: boolean): Uint16Array { 196 | const indicesAttr: IGeometryAttribute = attributes.filter((a: IGeometryAttribute) => a.attributeType === AttributeType.Index)[0]; 197 | if (indicesAttr) { 198 | const buffer = buffers[indicesAttr.bufferId]; 199 | const is = new InputStream(buffer); 200 | const ind: number[] = []; 201 | is.seek(indicesAttr.itemOffset); 202 | while (is.offset < is.length) { 203 | ind.push(is.getUint16()); 204 | } 205 | if (isLines) { 206 | deltaDecodeIndexBuffer2(ind); 207 | } else { 208 | deltaDecodeIndexBuffer3(ind); 209 | } 210 | return new Uint16Array(ind); 211 | } 212 | return new Uint16Array(); 213 | } 214 | 215 | function getVertices(attributes: IGeometryAttribute[], buffers: Buffer[]): Float32Array { 216 | const verticesAttr: IGeometryAttribute = attributes.filter((a: IGeometryAttribute) => a.attributeType === AttributeType.Position)[0]; 217 | if (verticesAttr) { 218 | const buffer = buffers[verticesAttr.bufferId]; 219 | const is = new InputStream(buffer); 220 | const vert: number[] = []; 221 | is.seek(verticesAttr.itemOffset); 222 | while (is.offset < is.length) { 223 | const originalOffset = is.offset; 224 | for (let i = 0; i < verticesAttr.itemSize; i++) { 225 | vert.push(is.getFloat32()); 226 | } 227 | is.seek(originalOffset + verticesAttr.itemStride); 228 | } 229 | return new Float32Array(vert); 230 | } 231 | return new Float32Array(); 232 | } 233 | 234 | function getColors(attributes: IGeometryAttribute[], buffers: Buffer[]): Float32Array | undefined { 235 | const colorsAttr: IGeometryAttribute = attributes.filter((a: IGeometryAttribute) => a.attributeType === AttributeType.Color)[0]; 236 | if (colorsAttr) { 237 | const buffer = buffers[colorsAttr.bufferId]; 238 | const is = new InputStream(buffer); 239 | const colors: number[] = []; 240 | is.seek(colorsAttr.itemOffset); 241 | while (is.offset < is.length) { 242 | const originalOffset = is.offset; 243 | for (let i = 0; i < colorsAttr.itemSize; i++) { 244 | colors.push(is.getFloat32()); 245 | } 246 | 247 | is.seek(originalOffset + colorsAttr.itemStride); 248 | } 249 | 250 | return new Float32Array(colors); 251 | 252 | } 253 | return undefined; 254 | } 255 | 256 | function getNormals(attributes: IGeometryAttribute[], buffers: Buffer[]): Float32Array | undefined { 257 | const normalsAttr: IGeometryAttribute = attributes.filter((a: IGeometryAttribute) => a.attributeType === AttributeType.Normal)[0]; 258 | if (normalsAttr) { 259 | const buffer = buffers[normalsAttr.bufferId]; 260 | // const componentType = normalsAttr.componentType; 261 | const is = new InputStream(buffer); 262 | const normals: number[] = []; 263 | is.seek(normalsAttr.itemOffset); 264 | while (is.offset < is.length) { 265 | const originalOffset = is.offset; 266 | const encodedNorm = []; 267 | for (let i = 0; i < normalsAttr.itemSize; i++) { 268 | encodedNorm.push((is.getUint16() / 65535)) 269 | } 270 | const decodedNorm = decodeNormal({ x: encodedNorm[0], y: encodedNorm[1] }); 271 | normals.push(decodedNorm.x, decodedNorm.y, decodedNorm.z); 272 | is.seek(originalOffset + normalsAttr.itemStride); 273 | } 274 | return new Float32Array(normals); 275 | } 276 | return undefined; 277 | } 278 | 279 | // TODO: handle uvmaps with multiple channels as done in svf ? 280 | function getUvs(attributes: IGeometryAttribute[], buffers: Buffer[]): Float32Array | undefined { 281 | const uvsAttr: IGeometryAttribute = attributes.filter((a: IGeometryAttribute) => a.attributeType === AttributeType.TextureUV)[0]; 282 | if (uvsAttr) { 283 | const buffer = buffers[uvsAttr.bufferId]; 284 | const is = new InputStream(buffer); 285 | const uvs: number[] = []; 286 | is.seek(uvsAttr.itemOffset); 287 | while (is.offset < is.length) { 288 | const originalOffset = is.offset; 289 | if (uvsAttr.itemSize === 2) { 290 | uvs.push(is.getFloat32()); 291 | uvs.push(1.0 - is.getFloat32()); 292 | } else { 293 | console.log(`Can't parse uvs with this itemSize`); 294 | } 295 | is.seek(originalOffset + uvsAttr.itemStride); 296 | } 297 | return new Float32Array(uvs); 298 | } 299 | return undefined; 300 | } -------------------------------------------------------------------------------- /src/svf2/helpers/HashList.ts: -------------------------------------------------------------------------------- 1 | import { InputStream } from '../../common/input-stream'; 2 | 3 | /** 4 | * Parses a buffer containing hash values and yields each hash as a hexadecimal string. 5 | * 6 | * @param buffer The buffer containing the hash values. 7 | * @yields {string} Each hash value as a hexadecimal string. 8 | * 9 | * @example 10 | * ```typescript 11 | * const buffer = Buffer.from([...]); 12 | * for (const hash of parseHashes(buffer)) { 13 | * console.log(hash); 14 | * } 15 | * ``` 16 | */ 17 | export function* parseHashes(buffer: Buffer): Iterable { 18 | const stream = new InputStream(buffer); 19 | const hashSize = stream.getUint16(); 20 | console.assert(hashSize % 4 === 0); 21 | const version = stream.getUint16(); 22 | const count = stream.getUint16(); 23 | for (let i = 1; i <= count; i++) { 24 | yield buffer.toString('hex', i * hashSize, (i + 1) * hashSize); 25 | } 26 | } -------------------------------------------------------------------------------- /src/svf2/helpers/Manifest.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'node:path'; 2 | import { z } from 'zod'; 3 | 4 | const ViewSchema = z.object({ 5 | role: z.string(), 6 | mime: z.string(), 7 | urn: z.string() 8 | }); 9 | 10 | export type View = z.infer; 11 | 12 | const PDBManifestSchema = z.object({ 13 | pdb_version_rel_path: z.string().optional(), 14 | pdb_shared_rel_path: z.string().optional(), 15 | assets: z.array(z.object({ 16 | uri: z.string().optional(), 17 | tag: z.string().optional(), 18 | type: z.string().optional(), 19 | isShared: z.boolean().optional() 20 | })).optional() 21 | }); 22 | 23 | export type PDBManifest = z.infer; 24 | 25 | const OTGManifestSchema = z.object({ 26 | version: z.number(), 27 | creator: z.string().optional(), 28 | first_started_at: z.string().optional(), 29 | last_started_at: z.string().optional(), 30 | last_modified_at: z.string().optional(), 31 | invocations: z.number().optional(), 32 | status: z.string().optional(), 33 | success: z.string().optional(), 34 | progress: z.string().optional(), 35 | urn: z.string().optional(), 36 | pdb_manifest: PDBManifestSchema.optional(), 37 | views: z.record(ViewSchema), 38 | account_id: z.string().optional(), 39 | paths: z.object({ 40 | version_root: z.string(), 41 | shared_root: z.string(), 42 | global_root: z.string(), 43 | global_sharding: z.number(), 44 | region: z.string() 45 | }) 46 | }); 47 | 48 | export type OTGManifest = z.infer; 49 | 50 | const ChildSchema = z.object({ 51 | guid: z.string(), 52 | role: z.string().optional(), 53 | hasThumbnail: z.enum(['true', 'false']).optional(), 54 | progress: z.string().optional(), 55 | type: z.string(), 56 | status: z.string().optional(), 57 | version: z.string().optional(), 58 | urn: z.string().optional(), 59 | inputFileSize: z.number().optional(), 60 | inputFileType: z.string().optional(), 61 | name: z.string().optional(), 62 | properties: z.object({}).optional(), 63 | otg_manifest: OTGManifestSchema.optional(), 64 | children: z.array(z.lazy((): z.ZodTypeAny => ChildSchema)).optional() 65 | }); 66 | 67 | export type Child = z.infer; 68 | 69 | const ManifestSchema = z.object({ 70 | guid: z.string(), 71 | owner: z.string().optional(), 72 | hasThumbnail: z.enum(['true', 'false']), 73 | startedAt: z.string().optional(), 74 | type: z.string(), 75 | urn: z.string(), 76 | success: z.string(), 77 | progress: z.string(), 78 | region: z.string().optional(), 79 | status: z.string(), 80 | // registerKeys: z.map(z.string(), z.array(z.string())).optional(), 81 | children: z.array(ChildSchema) 82 | }); 83 | 84 | export type Manifest = z.infer; 85 | 86 | /** 87 | * Parse a manifest JSON object. 88 | * @param json The manifest JSON object. 89 | * @returns The parsed manifest. 90 | * @throws If the manifest is invalid. 91 | */ 92 | export function parse(json: any): Manifest { 93 | return ManifestSchema.parse(json); 94 | } 95 | 96 | /** 97 | * Find the SVF2 manifest in a derivative manifest. 98 | * @param manifest The derivative manifest. 99 | * @returns The SVF2 manifest. 100 | * @throws If the SVF2 manifest is not found. 101 | */ 102 | export function findManifestSVF2(manifest: Manifest): OTGManifest { 103 | const viewable = manifest.children.find((child) => child.role === 'viewable' && child.otg_manifest); 104 | if (!viewable || !viewable.otg_manifest) { 105 | throw new Error('Could not find a viewable with SVF2 data.'); 106 | } 107 | console.assert(viewable.otg_manifest.version === 1); 108 | console.assert(viewable.otg_manifest.progress === 'complete'); 109 | console.assert(viewable.otg_manifest.status === 'success'); 110 | return viewable.otg_manifest; 111 | } 112 | 113 | /** 114 | * Resolve the URN of a view. 115 | * @param manifest The SVF2 manifest. 116 | * @param view The view. 117 | * @returns The resolved URN. 118 | */ 119 | export function resolveViewURN(manifest: OTGManifest, view: View) { 120 | return path.normalize(path.join(manifest.paths.version_root, view.urn)) 121 | } -------------------------------------------------------------------------------- /src/svf2/helpers/Material.ts: -------------------------------------------------------------------------------- 1 | import * as zlib from 'zlib'; 2 | 3 | export interface Material { 4 | diffuse?: number[]; 5 | specular?: number[]; 6 | ambient?: number[]; 7 | emissive?: number[]; 8 | glossiness?: number; 9 | reflectivity?: number; 10 | opacity?: number; 11 | metal?: boolean; 12 | maps?: { 13 | diffuse?: MaterialMap; 14 | specular?: MaterialMap; 15 | normal?: MaterialMap; 16 | bump?: MaterialMap; 17 | alpha?: MaterialMap; 18 | }; 19 | } 20 | 21 | export interface MaterialMap { 22 | uri: string; 23 | scale: { 24 | texture_UScale: number; 25 | texture_VScale: number; 26 | }; 27 | } 28 | 29 | /** 30 | * Parses a buffer to extract material information. 31 | * If the buffer is gzipped, it will be decompressed first. 32 | * The function currently supports only 'SimplePhong' material definition. 33 | * 34 | * @param buffer The buffer containing material data. 35 | * @returns The parsed material. 36 | * @throws Will throw an error if the material definition is unsupported. 37 | */ 38 | export function parseMaterial(buffer: Buffer): Material { 39 | if (buffer[0] === 31 && buffer[1] === 139) { 40 | buffer = zlib.gunzipSync(buffer); 41 | } 42 | console.assert(buffer.byteLength > 0); 43 | const group = JSON.parse(buffer.toString()); 44 | const material = group.materials['0']; 45 | switch (material.definition) { 46 | case 'SimplePhong': 47 | return parseSimplePhongMaterial(group); 48 | default: 49 | throw new Error('Unsupported material definition: ' + material.definition); 50 | } 51 | } 52 | 53 | function parseSimplePhongMaterial(group: any): Material { 54 | let result: Material = {}; 55 | const material = group.materials[0]; 56 | 57 | result.diffuse = parseColorProperty(material, 'generic_diffuse', [0, 0, 0, 1]); 58 | result.specular = parseColorProperty(material, 'generic_specular', [0, 0, 0, 1]); 59 | result.ambient = parseColorProperty(material, 'generic_ambient', [0, 0, 0, 1]); 60 | result.emissive = parseColorProperty(material, 'generic_emissive', [0, 0, 0, 1]); 61 | 62 | result.glossiness = parseScalarProperty(material, 'generic_glossiness', 30); 63 | result.reflectivity = parseScalarProperty(material, 'generic_reflectivity_at_0deg', 0); 64 | result.opacity = 1.0 - parseScalarProperty(material, 'generic_transparency', 0); 65 | 66 | result.metal = parseBooleanProperty(material, 'generic_is_metal', false); 67 | 68 | if (material.textures) { 69 | result.maps = {}; 70 | const diffuse = parseTextureProperty(material, group, 'generic_diffuse'); 71 | if (diffuse) { 72 | result.maps.diffuse = diffuse; 73 | } 74 | const specular = parseTextureProperty(material, group, 'generic_specular'); 75 | if (specular) { 76 | result.maps.specular = specular; 77 | } 78 | const alpha = parseTextureProperty(material, group, 'generic_alpha'); 79 | if (alpha) { 80 | result.maps.alpha = alpha; 81 | } 82 | const bump = parseTextureProperty(material, group, 'generic_bump'); 83 | if (bump) { 84 | if (parseBooleanProperty(material, 'generic_bump_is_normal', false)) { 85 | result.maps.normal = bump; 86 | } else { 87 | result.maps.bump = bump; 88 | } 89 | } 90 | } 91 | 92 | return result; 93 | } 94 | 95 | function parseBooleanProperty(material: any, prop: string, defaultValue: boolean): boolean { 96 | if (material.properties.booleans && prop in material.properties.booleans) { 97 | return material.properties.booleans[prop]; 98 | } else { 99 | return defaultValue; 100 | } 101 | } 102 | 103 | function parseScalarProperty(material: any, prop: string, defaultValue: number): number { 104 | if (material.properties.scalars && prop in material.properties.scalars) { 105 | return material.properties.scalars[prop].values[0]; 106 | } else { 107 | return defaultValue; 108 | } 109 | } 110 | 111 | function parseColorProperty(material: any, prop: string, defaultValue: number[]): number[] { 112 | if (material.properties.colors && prop in material.properties.colors) { 113 | const color = material.properties.colors[prop].values[0]; 114 | return [color.r, color.g, color.b, color.a]; 115 | } else { 116 | return defaultValue; 117 | } 118 | } 119 | 120 | function parseTextureProperty(material: any, group: any, prop: string): any | null { 121 | if (material.textures && prop in material.textures) { 122 | const connection = material.textures[prop].connections[0]; 123 | const texture = group.materials[connection]; 124 | if (texture && texture.properties.uris && 'unifiedbitmap_Bitmap' in texture.properties.uris) { 125 | const uri = texture.properties.uris['unifiedbitmap_Bitmap'].values[0]; 126 | // TODO: parse texture transforms aside from scale 127 | const texture_UScale = texture.properties.scalars?.texture_UScale?.values[0] as number || 1; 128 | const texture_VScale = texture.properties.scalars?.texture_VScale?.values[0] as number || 1; 129 | /* 130 | console.log('uri and scale', { 131 | uri: uri, 132 | u: texture_UScale, 133 | v: texture_VScale 134 | }) 135 | */ 136 | if (uri) { 137 | return { 138 | uri, 139 | scale: { 140 | texture_UScale, 141 | texture_VScale 142 | } 143 | }; 144 | } 145 | } 146 | } 147 | return null; 148 | } -------------------------------------------------------------------------------- /src/svf2/helpers/View.ts: -------------------------------------------------------------------------------- 1 | import { posix } from 'node:path'; 2 | import { z } from 'zod'; 3 | 4 | const PrivatePDBSchema = z.object({ 5 | avs: z.string(), 6 | offsets: z.string(), 7 | dbid: z.string() 8 | }); 9 | 10 | const SharedPDBSchema = z.object({ 11 | attrs: z.string(), 12 | values: z.string(), 13 | ids: z.string() 14 | }); 15 | 16 | const PrivateAssetsSchema = z.object({ 17 | pdb: PrivatePDBSchema, 18 | fragments: z.string(), 19 | fragments_extra: z.string(), 20 | materials_ptrs: z.string().optional(), 21 | geometry_ptrs: z.string().optional(), 22 | texture_manifest: z.string().optional() 23 | }); 24 | 25 | const SharedAssetsSchema = z.object({ 26 | pdb: SharedPDBSchema, 27 | geometry: z.string(), 28 | materials: z.string(), 29 | textures: z.string(), 30 | global_sharding: z.number() 31 | }); 32 | 33 | const ManifestSchema = z.object({ 34 | assets: PrivateAssetsSchema, 35 | shared_assets: SharedAssetsSchema 36 | }); 37 | 38 | const StatsSchema = z.object({ 39 | num_fragments: z.number().optional(), 40 | num_polys: z.number().optional(), 41 | num_materials: z.number().optional(), 42 | num_geoms: z.number().optional(), 43 | num_textures: z.number().optional() 44 | }); 45 | 46 | const GeoreferenceSchema = z.object({ 47 | positionLL84: z.array(z.number()).optional(), 48 | refPointLMV: z.array(z.number()).optional() 49 | }); 50 | 51 | const FragmentTransformsOffsetSchema = z.object({ 52 | x: z.number(), 53 | y: z.number(), 54 | z: z.number() 55 | }); 56 | 57 | const ViewSchema = z.object({ 58 | name: z.string(), 59 | version: z.number(), 60 | manifest: ManifestSchema, 61 | stats: StatsSchema.optional(), 62 | georeference: GeoreferenceSchema.optional(), 63 | fragmentTransformsOffset: FragmentTransformsOffsetSchema.optional() 64 | }); 65 | 66 | export type View = z.infer; 67 | 68 | export function parse(json: any): View { 69 | return ViewSchema.parse(json); 70 | } 71 | 72 | export function getViewMetadata(view: View): { [key: string]: any } { 73 | // Only map necessary values 74 | const map = view as any; 75 | let metadata = { 76 | "world bounding box": map["world bounding box"], 77 | "world up vector": map["world up vector"], 78 | "world front vector": map["world front vector"], 79 | "world north vector": map["world north vector"], 80 | "distance unit": map["distance unit"], 81 | } 82 | return metadata; 83 | } 84 | 85 | export function getViewAccount(view: View): string { 86 | let baseUrl = view.manifest.shared_assets.geometry; 87 | if (baseUrl.startsWith('$otg_cdn$')) { 88 | baseUrl = baseUrl.substring(baseUrl.indexOf('/')); 89 | } 90 | const [_, account] = baseUrl.split('/'); 91 | return account; 92 | } 93 | 94 | export function resolveAssetUrn(resolvedViewUrn: string, assetUrn: string): string { 95 | if (assetUrn.startsWith('urn:')) { 96 | return assetUrn; 97 | } else { 98 | return posix.normalize(posix.join(posix.dirname(resolvedViewUrn), assetUrn)); 99 | } 100 | } 101 | 102 | export function resolveGeometryUrn(view: View, hash: string): string { 103 | let baseUrl = view.manifest.shared_assets.geometry; 104 | if (baseUrl.startsWith('$otg_cdn$')) { 105 | baseUrl = baseUrl.substring(baseUrl.indexOf('/')); 106 | } 107 | return baseUrl + encodeURI(hash); 108 | } 109 | 110 | export function resolveMaterialUrn(view: View, hash: string): string { 111 | return view.manifest.shared_assets.materials + encodeURI(hash); 112 | } 113 | 114 | export function resolveTextureUrn(view: View, hash: string): string { 115 | return view.manifest.shared_assets.textures + encodeURI(hash); 116 | } -------------------------------------------------------------------------------- /src/svf2/reader.ts: -------------------------------------------------------------------------------- 1 | import * as IMF from '../common/intermediate-format'; 2 | import { IAuthenticationProvider } from '../common/authentication-provider'; 3 | import { PropDbReader } from '../common/propdb-reader'; 4 | import { ModelDataHttpClient } from './clients/ModelDataHttpClient'; 5 | import { SharedDataHttpClient } from './clients/SharedDataHttpClient'; 6 | import { SharedDataWebSocketClient, AssetType } from './clients/SharedDataWebSocketClient'; 7 | import { parseHashes } from './helpers/HashList'; 8 | import { Fragment, parseFragments } from './helpers/Fragment'; 9 | import { Geometry, GeometryType, parseGeometry } from './helpers/Geometry'; 10 | import { Material, parseMaterial } from './helpers/Material'; 11 | import { findManifestSVF2, resolveViewURN, OTGManifest } from './helpers/Manifest'; 12 | import { getViewAccount, getViewMetadata, parse, resolveAssetUrn, resolveGeometryUrn, resolveMaterialUrn, resolveTextureUrn, View } from './helpers/View'; 13 | 14 | const UseWebSockets = true; 15 | const BatchSize = 32; 16 | 17 | export class Reader { 18 | static async FromDerivativeService(urn: string, authenticationProvider: IAuthenticationProvider): Promise { 19 | const modelDataClient = new ModelDataHttpClient(authenticationProvider); 20 | const sharedDataClient = new SharedDataHttpClient(authenticationProvider); 21 | const derivativeManifest = await modelDataClient.getManifest(urn); 22 | const manifest = findManifestSVF2(derivativeManifest); 23 | return new Reader(urn, manifest, modelDataClient, sharedDataClient, authenticationProvider); 24 | } 25 | 26 | protected constructor( 27 | protected urn: string, 28 | protected manifest: OTGManifest, 29 | protected modelDataClient: ModelDataHttpClient, 30 | protected sharedDataClient: SharedDataHttpClient, 31 | protected authenticationProvider: IAuthenticationProvider 32 | ) {} 33 | 34 | protected properties: PropDbReader | undefined; 35 | 36 | async listViews(): Promise { 37 | const ids: string[] = []; 38 | for (const [id, view] of Object.entries(this.manifest.views)) { 39 | if (view.role === 'graphics' && view.mime === 'application/autodesk-otg') { 40 | ids.push(id); 41 | } 42 | } 43 | return ids; 44 | } 45 | 46 | async readView(viewId: string): Promise { 47 | // TODO: Decode property database 48 | console.log(`Reading view ${viewId}...`); 49 | const resolvedViewURN = resolveViewURN(this.manifest, this.manifest.views[viewId]); 50 | const viewManifestBuffer = await this.modelDataClient.getAsset(this.urn, encodeURIComponent(resolvedViewURN)); 51 | const view = parse(JSON.parse(viewManifestBuffer.toString())); 52 | const { assets } = view.manifest; 53 | const fragments = await this.readFragments(view, resolveAssetUrn(resolvedViewURN, assets.fragments)); 54 | const geometries: Geometry[] = assets.geometry_ptrs 55 | ? (UseWebSockets 56 | ? await this.readGeometriesBatch(view, resolveAssetUrn(resolvedViewURN, assets.geometry_ptrs)) 57 | : await this.readGeometries(view, resolveAssetUrn(resolvedViewURN, assets.geometry_ptrs))) 58 | : []; 59 | const materials: Material[] = assets.materials_ptrs 60 | ? (UseWebSockets 61 | ? await this.readMaterialsBatch(view, resolveAssetUrn(resolvedViewURN, assets.materials_ptrs)) 62 | : await this.readMaterials(view, resolveAssetUrn(resolvedViewURN, assets.materials_ptrs))) 63 | : []; 64 | const textures = assets.texture_manifest 65 | ? await this.readTextures(view, resolveAssetUrn(resolvedViewURN, assets.texture_manifest)) 66 | : new Map(); 67 | const metadata = getViewMetadata(view); 68 | return new Scene(metadata, fragments, geometries, materials, textures); 69 | } 70 | 71 | protected async readFragments(view: View, resolvedfragListUrn: string): Promise { 72 | console.log('Reading fragment list...'); 73 | const fragmentData = await this.modelDataClient.getAsset(this.urn, encodeURIComponent(resolvedfragListUrn)); 74 | const fragments = Array.from(parseFragments(fragmentData, view.fragmentTransformsOffset)); 75 | return fragments; 76 | } 77 | 78 | protected async readGeometries(view: View, resolvedGeomHashListUrn: string): Promise { 79 | console.log('Reading geometry list...'); 80 | const geometryPromises: Promise[] = []; 81 | const geometryListBuffer = await this.modelDataClient.getAsset(this.urn, encodeURIComponent(resolvedGeomHashListUrn)); 82 | for (const hash of parseHashes(geometryListBuffer)) { 83 | console.log(`Reading geometry ${hash}...`); 84 | const geometryUrn = resolveGeometryUrn(view, hash); 85 | geometryPromises.push(this.sharedDataClient.getAsset(this.urn, geometryUrn).then(parseGeometry)); 86 | } 87 | const geometries = await Promise.all(geometryPromises); 88 | return geometries; 89 | } 90 | 91 | protected async readGeometriesBatch(view: View, resolvedGeomHashListUrn: string): Promise { 92 | console.log('Reading geometry list...'); 93 | const sharedDataWebSocketClient = await SharedDataWebSocketClient.Connect(this.authenticationProvider); 94 | const account = getViewAccount(view); 95 | const geometryListBuffer = await this.modelDataClient.getAsset(this.urn, encodeURIComponent(resolvedGeomHashListUrn)); 96 | const geometries: Geometry[] = []; 97 | 98 | let batch: string[] = []; 99 | const processBatch = async () => { 100 | console.log(`Reading geometry batch ${batch.map(hash => hash.substring(0, 4))}...`); 101 | const buffers = await sharedDataWebSocketClient.getAssets(this.urn, account, AssetType.Geometry, batch); 102 | geometries.push(...batch.map(hash => parseGeometry(buffers.get(hash)!))); 103 | batch = []; 104 | }; 105 | 106 | for (const hash of parseHashes(geometryListBuffer)) { 107 | batch.push(hash); 108 | if (batch.length === BatchSize) { 109 | await processBatch(); 110 | } 111 | } 112 | if (batch.length > 0) { 113 | await processBatch(); 114 | } 115 | sharedDataWebSocketClient.close(); 116 | return geometries; 117 | } 118 | 119 | protected async readMaterials(view: View, resolvedMaterialHashListUrn: string): Promise { 120 | console.log('Reading material list...'); 121 | const materialListBuffer = await this.modelDataClient.getAsset(this.urn, encodeURIComponent(resolvedMaterialHashListUrn)); 122 | const materials: Material[] = []; 123 | for (const hash of parseHashes(materialListBuffer)) { 124 | console.log(`Reading material ${hash}...`); 125 | const materialUrn = resolveMaterialUrn(view, hash); 126 | const materialData = await this.sharedDataClient.getAsset(this.urn, materialUrn); 127 | materials.push(parseMaterial(materialData)); 128 | } 129 | return materials; 130 | } 131 | 132 | protected async readMaterialsBatch(view: View, resolvedMaterialHashListUrn: string): Promise { 133 | console.log('Reading material list...'); 134 | const sharedDataWebSocketClient = await SharedDataWebSocketClient.Connect(this.authenticationProvider); 135 | const account = getViewAccount(view); 136 | const materialListBuffer = await this.modelDataClient.getAsset(this.urn, encodeURIComponent(resolvedMaterialHashListUrn)); 137 | const materials: Material[] = []; 138 | 139 | let batch: string[] = []; 140 | const processBatch = async () => { 141 | console.log(`Reading material batch ${batch.map(hash => hash.substring(0, 4))}...`); 142 | const buffers = await sharedDataWebSocketClient.getAssets(this.urn, account, AssetType.Material, batch); 143 | materials.push(...batch.map(hash => parseMaterial(buffers.get(hash)!))); 144 | batch = []; 145 | }; 146 | 147 | for (const hash of parseHashes(materialListBuffer)) { 148 | batch.push(hash); 149 | if (batch.length === BatchSize) { 150 | await processBatch(); 151 | } 152 | } 153 | if (batch.length > 0) { 154 | await processBatch(); 155 | } 156 | sharedDataWebSocketClient.close(); 157 | return materials; 158 | } 159 | 160 | protected async readTextures(view: View, textureManifestUri: string): Promise> { 161 | console.log('Reading texture list...'); 162 | const map = new Map(); 163 | const textureListBuffer = await this.modelDataClient.getAsset(this.urn, encodeURIComponent(textureManifestUri)); 164 | const textureManifest = JSON.parse(textureListBuffer.toString()) as { [key: string]: string }; 165 | for (const [_, uri] of Object.entries(textureManifest)) { 166 | console.log(`Reading texture ${uri} ...`); 167 | const textureUrn = resolveTextureUrn(view, uri); 168 | const textureData = await this.sharedDataClient.getAsset(this.urn, textureUrn); 169 | map.set(uri, textureData); 170 | } 171 | return map; 172 | } 173 | 174 | // protected async getPropertyDb(view: View): Promise { 175 | // // const privateDbAssets = viewHelper.listPrivateDatabaseAssets(); 176 | // // const sharedDbAssets = viewHelper.listSharedDatabaseAssets(); 177 | 178 | // const privateDbAssets = view.manifest.assets.pdb; 179 | // const sharedDbAssets = view.manifest.shared_assets.pdb; 180 | // if (!privateDbAssets || !sharedDbAssets) { 181 | // throw new Error('Could not parse property database. Some of the database assets are missing.'); 182 | // } 183 | 184 | // const offsetsAsset = privateDbAssets['offsets']; 185 | // const avsAsset = privateDbAssets['avs']; 186 | // const dbIdAsset = privateDbAssets['dbid']; 187 | 188 | // const idsAsset = sharedDbAssets['ids']; 189 | // const attrsAsset = sharedDbAssets['attrs']; 190 | // const valsAsset = sharedDbAssets['values']; 191 | 192 | // const buffers = await Promise.all([ 193 | // this.modelDataClient.getAsset(this.urn, encodeURIComponent(resolveAssetUrn(view, idsAsset))), 194 | // this.modelDataClient.getAsset(this.urn, encodeURIComponent(offsetsAsset.resolvedUrn)), 195 | // this.modelDataClient.getAsset(this.urn, encodeURIComponent(avsAsset.resolvedUrn)), 196 | // this.modelDataClient.getAsset(this.urn, encodeURIComponent(attrsAsset.resolvedUrn)), 197 | // this.modelDataClient.getAsset(this.urn, encodeURIComponent(valsAsset.resolvedUrn)), 198 | // this.modelDataClient.getAsset(this.urn, encodeURIComponent(dbIdAsset.resolvedUrn)), 199 | // ]); 200 | 201 | // // SVF common function not working with private db assets 202 | // return new PropDbReader(buffers[0], buffers[1], buffers[2], buffers[3], buffers[4]); 203 | // } 204 | } 205 | 206 | export class Scene implements IMF.IScene { 207 | constructor( 208 | protected metadata: { [key: string]: any }, 209 | protected fragments: Fragment[], 210 | protected geometries: Geometry[], 211 | protected materials: Material[], 212 | protected textures: Map 213 | ) {} 214 | 215 | getMetadata(): IMF.IMetadata { 216 | return this.metadata; 217 | } 218 | 219 | getNodeCount(): number { 220 | return this.fragments.length; 221 | } 222 | 223 | getNode(id: number): IMF.Node { 224 | const frag = this.fragments[id]; 225 | const node: IMF.IObjectNode = { 226 | kind: IMF.NodeKind.Object, 227 | dbid: frag.dbId, 228 | geometry: frag.geomId, 229 | material: frag.materialId 230 | }; 231 | if (frag.transform) { 232 | node.transform = { 233 | kind: IMF.TransformKind.Decomposed, 234 | translation: frag.transform.translation, 235 | rotation: frag.transform.quaternion, 236 | scale: frag.transform.scale, 237 | }; 238 | } 239 | return node; 240 | } 241 | 242 | getGeometryCount(): number { 243 | return this.geometries.length; 244 | } 245 | 246 | getGeometry(id: number): IMF.Geometry { 247 | if (id > this.geometries.length || id === 0) { 248 | return { kind: IMF.GeometryKind.Empty }; 249 | } 250 | const geom = this.geometries[id - 1]; 251 | switch (geom.type) { 252 | case GeometryType.Triangles: 253 | const meshGeometry: IMF.IMeshGeometry = { 254 | kind: IMF.GeometryKind.Mesh, 255 | getIndices: () => geom.indices, 256 | getVertices: () => geom.vertices, 257 | getNormals: () => geom.normals, 258 | getColors: () => geom.colors, 259 | getUvChannelCount: () => geom.uvs ? 1 : 0, 260 | getUvs: (channel: number) => geom.uvs || new Float32Array(), 261 | } 262 | return meshGeometry; 263 | case GeometryType.Lines: 264 | const lineGeometry: IMF.ILineGeometry = { 265 | kind: IMF.GeometryKind.Lines, 266 | getIndices: () => geom.indices, 267 | getVertices: () => geom.vertices, 268 | getColors: () => undefined 269 | }; 270 | return lineGeometry; 271 | } 272 | return { kind: IMF.GeometryKind.Empty }; 273 | } 274 | 275 | getMaterialCount(): number { 276 | return this.materials.length; 277 | } 278 | 279 | getMaterial(id: number): IMF.Material { 280 | const _mat = this.materials[id]; // should fix this remove one array level 281 | const mat: IMF.IPhysicalMaterial = { 282 | kind: IMF.MaterialKind.Physical, 283 | diffuse: { x: 0, y: 0, z: 0 }, 284 | metallic: _mat?.metal ? 1.0 : 0.0, 285 | opacity: _mat?.opacity ?? 1.0, 286 | roughness: _mat?.glossiness ? (20.0 / _mat.glossiness) : 1.0, // TODO: how to map glossiness to roughness properly? 287 | scale: { x: _mat?.maps?.diffuse?.scale.texture_UScale ?? 1.0, y: _mat?.maps?.diffuse?.scale.texture_VScale ?? 1.0 } 288 | }; 289 | if (_mat?.diffuse) { 290 | mat.diffuse.x = _mat.diffuse[0]; 291 | mat.diffuse.y = _mat.diffuse[1]; 292 | mat.diffuse.z = _mat.diffuse[2]; 293 | } 294 | if (_mat?.metal && _mat.specular && _mat.glossiness) { 295 | mat.diffuse.x = _mat.specular[0]; 296 | mat.diffuse.y = _mat.specular[1]; 297 | mat.diffuse.z = _mat.specular[2]; 298 | mat.roughness = 60 / _mat.glossiness; 299 | } 300 | if (_mat?.maps?.diffuse) { 301 | mat.maps = mat.maps || {}; 302 | mat.maps.diffuse = _mat.maps.diffuse.uri 303 | } 304 | return mat; 305 | } 306 | 307 | getImage(uri: string): Buffer | undefined { 308 | return this.textures.get(uri); 309 | } 310 | } -------------------------------------------------------------------------------- /test/remote-svf-to-gltf.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Basic SVF-to-glTF conversion test. 4 | # Usage: 5 | # export APS_CLIENT_ID= 6 | # export APS_CLIENT_SECRET= 7 | # ./remote-svf-to-gltf.sh 8 | 9 | # Convert SVF to glTF 10 | node ./bin/svf-to-gltf.js $1 --output-folder $2/gltf --deduplicate --skip-unused-uvs --ignore-lines --ignore-points 11 | 12 | # Iterate over glTFs generated for all viewables (in / subfolders) 13 | for gltf in $(find $2/gltf -name "output.gltf"); do 14 | guid_dir=$(dirname $gltf) 15 | guid=$(basename $guid_dir) 16 | urn_dir=$(dirname $guid_dir) 17 | urn=$(basename $urn_dir) 18 | echo "Validating URN: $urn GUID: $guid" 19 | node ./tools/validate.js $gltf 20 | done 21 | -------------------------------------------------------------------------------- /tools/consolidate-meshes.js: -------------------------------------------------------------------------------- 1 | const { parseArgs } = require('node:util'); 2 | const { NodeIO } = require('@gltf-transform/core'); 3 | const { PropertyType } = require('@gltf-transform/core'); 4 | const { join, flatten, dedup, prune, meshopt } = require('@gltf-transform/functions'); 5 | 6 | async function mergeMeshes(inputPath, outputPath) { 7 | const io = new NodeIO(); 8 | 9 | // Load the GLTF file into a Document 10 | const document = await io.read(inputPath); 11 | 12 | // Create a simple material 13 | const material = document.createMaterial('SimpleMaterial').setBaseColorFactor([1.0, 0.8, 0.8, 1.0]); 14 | 15 | // Iterate over all meshes and primitives in the glTF 16 | const root = document.getRoot(); 17 | for (const node of root.listNodes()) { 18 | const mesh = node.getMesh(); 19 | if (mesh) { 20 | for (const primitive of mesh.listPrimitives()) { 21 | // Ensure all primitives use the new material 22 | primitive.setMaterial(material); 23 | } 24 | } 25 | } 26 | 27 | await document.transform( 28 | dedup({ propertyTypes: [PropertyType.MATERIAL] }), 29 | flatten(), 30 | join({ keepNamed: false }), 31 | prune(), 32 | meshopt() 33 | ); 34 | 35 | // Save the updated document back to a new file 36 | await io.write(outputPath, document); 37 | } 38 | 39 | // Parse command line arguments 40 | const args = parseArgs({ 41 | options: {}, 42 | allowPositionals: true 43 | }); 44 | const [inputPath, outputPath] = args.positionals; 45 | if (!inputPath || !outputPath) { 46 | console.error('Usage: node consolidate-meshes.js path/to/input.gltf path/to/output.glb'); 47 | process.exit(1); 48 | } 49 | mergeMeshes(inputPath, outputPath) 50 | .then(() => console.log('Done.')) 51 | .catch(err => { console.error(err); process.exit(1); }); -------------------------------------------------------------------------------- /tools/validate.js: -------------------------------------------------------------------------------- 1 | const fse = require('fs-extra'); 2 | const validator = require('gltf-validator'); 3 | 4 | async function validate(gltfPath) { 5 | try { 6 | const manifest = fse.readFileSync(gltfPath); 7 | const { validatorVersion, validatedAt, issues } = await validator.validateBytes(new Uint8Array(manifest)); 8 | console.log('Validator version:', validatorVersion); 9 | console.log('Validated at:', validatedAt); 10 | console.log('Number of errors:', issues.numErrors); 11 | console.log('Number of warnings:', issues.numWarnings); 12 | console.log('Number of infos:', issues.numInfos); 13 | console.log('Number of hits:', issues.numHints); 14 | console.table(issues.messages); 15 | process.exit(issues.numErrors > 0 ? 1 : 0); 16 | } catch (err) { 17 | console.error(err); 18 | process.exit(2); 19 | } 20 | } 21 | 22 | validate(process.argv[2]); 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "target": "es2022", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017','ES2018' or 'ESNEXT'. */ 5 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 6 | // "lib": [], /* Specify library files to be included in the compilation. */ 7 | // "allowJs": true, /* Allow javascript files to be compiled. */ 8 | // "checkJs": true, /* Report errors in .js files. */ 9 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 10 | "declaration": true, /* Generates corresponding '.d.ts' file. */ 11 | "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 12 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 13 | // "outFile": "./", /* Concatenate and emit output to single file. */ 14 | "outDir": "./lib", /* Redirect output structure to the directory. */ 15 | "rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 16 | // "composite": true, /* Enable project compilation */ 17 | // "removeComments": true, /* Do not emit comments to output. */ 18 | // "noEmit": true, /* Do not emit outputs. */ 19 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 20 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 21 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 22 | 23 | /* Strict Type-Checking Options */ 24 | "strict": true, /* Enable all strict type-checking options. */ 25 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 26 | // "strictNullChecks": true, /* Enable strict null checks. */ 27 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 28 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 29 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 30 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 31 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 32 | 33 | /* Additional Checks */ 34 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 35 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 36 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 37 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 38 | 39 | /* Module Resolution Options */ 40 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 41 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 42 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 43 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 44 | // "typeRoots": [], /* List of folders to include type definitions from. */ 45 | // "types": [], /* Type declaration files to be included in compilation. */ 46 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 47 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 48 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 49 | 50 | /* Source Map Options */ 51 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 52 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 53 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 54 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 55 | 56 | /* Experimental Options */ 57 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 58 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 59 | } 60 | } 61 | --------------------------------------------------------------------------------