├── .gitignore ├── .prettierrc ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── cli-example ├── README.md ├── index.js ├── package-lock.json └── package.json ├── package-lock.json ├── package.json ├── set-version.sh ├── src ├── Auditor.ts ├── EdgeUv.ts ├── EdgeXyz.ts ├── Glb.ts ├── GltfBin.ts ├── GltfJson.ts ├── GltfValidatorReport.ts ├── Image.ts ├── LoadableAttribute.ts ├── Model.ts ├── NodeTransform.ts ├── Primitive.ts ├── ProductInfo.ts ├── ProductInfoJSON.ts ├── Report.ts ├── ReportItem.ts ├── ReportJSON.ts ├── Schema.ts ├── SchemaJSON.ts ├── SquareUv.ts ├── TriangleUv.ts ├── TriangleXyz.ts ├── UV.ts ├── UvIsland.ts ├── VertexUv.ts └── VertexXyz.ts ├── tests ├── auditor.ts ├── blender │ ├── default-cube-bad-transform.blend │ ├── default-cube-bad-uvs.blend │ ├── default-cube-beveled.blend │ ├── default-cube-density.blend │ ├── default-cube-failing.blend │ ├── default-cube-multi-material.blend │ ├── default-cube-no-materials.blend │ ├── default-cube-non-manifold.blend │ ├── default-cube-passing.blend │ ├── default-cube-pbr-safe-colors.blend │ ├── default-cube-pbr-unsafe-colors.blend │ ├── default-cube-uv-margin-grid-aligned.blend │ ├── default-cube-uv-margin.blend │ ├── default-cube-uv-overlaps.blend │ └── monkey-uv-margin.blend ├── cleanTransform.ts ├── dimensions.ts ├── edges.ts ├── fileSize.ts ├── gltfNoTextures.ts ├── materialCount.ts ├── model.ts ├── models │ ├── Box0.bin │ ├── blender-default-cube-20cm.glb │ ├── blender-default-cube-20m.glb │ ├── blender-default-cube-2m-10x-scale.glb │ ├── blender-default-cube-2m.glb │ ├── blender-default-cube-bad-transform.glb │ ├── blender-default-cube-bad-uvs.glb │ ├── blender-default-cube-beveled.glb │ ├── blender-default-cube-density.glb │ ├── blender-default-cube-empty-nodes.glb │ ├── blender-default-cube-failing.glb │ ├── blender-default-cube-inverted-uvs.glb │ ├── blender-default-cube-multi-material.glb │ ├── blender-default-cube-no-materials.glb │ ├── blender-default-cube-non-manifold.glb │ ├── blender-default-cube-passing.glb │ ├── blender-default-cube-pbr-safe-colors.bin │ ├── blender-default-cube-pbr-safe-colors.glb │ ├── blender-default-cube-pbr-safe-colors.gltf │ ├── blender-default-cube-pbr-unsafe-colors.glb │ ├── blender-default-cube-uv-margin-grid-aligned.glb │ ├── blender-default-cube-uv-margin.glb │ ├── blender-default-cube-uv-overlaps.glb │ ├── blender-monkey-uv-margin.glb │ └── box.gltf ├── objectCount.ts ├── pbrColorRange.ts ├── productInfo.ts ├── products │ ├── blender-default-cube-failing.json │ └── blender-default-cube-passing.json ├── report.ts ├── schema.ts ├── schemas │ ├── clean-transform │ │ ├── clean-transform-not-required.json │ │ └── clean-transform-required.json │ ├── dimensions │ │ ├── dimensions-max-10m.json │ │ ├── dimensions-max-1m.json │ │ ├── dimensions-min-10m.json │ │ ├── dimensions-min-1m.json │ │ ├── dimensions-not-required.json │ │ └── dimensions-range-1m-10m.json │ ├── edges │ │ ├── beveled-edges-required.json │ │ └── must-be-manifold.json │ ├── fail.json │ ├── file-size │ │ ├── file-size-no-check.json │ │ ├── file-size-no-max-fail.json │ │ ├── file-size-no-max-pass.json │ │ ├── file-size-no-min-fail.json │ │ ├── file-size-no-min-pass.json │ │ ├── file-size-within-range-fail.json │ │ └── file-size-within-range-pass.json │ ├── khronos-recommended.json │ ├── material-count │ │ ├── material-count-no-check.json │ │ ├── material-count-no-max-fail.json │ │ ├── material-count-no-max-pass.json │ │ ├── material-count-no-min-fail.json │ │ └── material-count-no-min-pass.json │ ├── object-count │ │ ├── object-count-fail.json │ │ ├── object-count-no-check.json │ │ └── object-count-pass.json │ ├── pass.json │ ├── textures │ │ ├── pbr-color-range-fail.json │ │ ├── pbr-color-range-no-check.json │ │ └── pbr-color-range-pass.json │ ├── triangle-count │ │ ├── triangle-count-fail.json │ │ ├── triangle-count-no-check.json │ │ └── triangle-count-pass.json │ └── uv │ │ ├── uv-gutter.json │ │ └── uv-overlaps.json ├── textures │ ├── 1024.png │ ├── 256.png │ ├── 256x512.png │ ├── 500x500.png │ ├── 512.png │ ├── pbr-0-255.png │ └── pbr-30-240.png └── triangleCount.ts ├── tsconfig.json └── web-example ├── .prettierrc ├── README.md ├── index.html ├── package-lock.json ├── package.json ├── src └── index.ts ├── tsconfig.json └── webpack.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | *.blend1 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "printWidth": 120, 4 | "semi": true, 5 | "singleQuote": true, 6 | "tabWidth": 2, 7 | "trailingComma": "all" 8 | } 9 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | A reminder that this issue tracker is managed by the Khronos Group. 2 | Interactions here should follow the Khronos Code of Conduct 3 | ([https://www.khronos.org/developers/code-of-conduct](https://www.khronos.org/developers/code-of-conduct)), 4 | which prohibits aggressive or derogatory language. 5 | Please keep the discussion friendly and civil. 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Khronos glTF Asset Auditor 2 | 3 | ## SPDX-License-Identifier: Apache-2.0 4 | 5 | Try out the Web Example: https://www.khronos.org/gltf/gltf-asset-auditor/ 6 | 7 | This is a typescript package that contains classes for checking a 3D file, in glTF format (.glb or .gltf), against a requirements schema definition in JSON. The schema file determines which of the checks, listed below, get run and what the passing values are. The result of each test is Pass, Fail, or Not Tested and some additional information may be available in a message. 8 | 9 | This package can be used by both a command line interface (node), as well as a front-end web interface. The samples directory includes a command line interface (cli) as well as a web-based implementation to demonstrate how the package can be used in your own project. 10 | 11 | Some of the checks can be **_SLOW_** for files with a lot of triangles. This is because the gltf format only stores geometry as individual triangles with independent vertices. If a test relies on shared edges, those edges have to be computed by checking each vertex's XYZ and/or UV location for a match. Beveled Edges and Non-Manifold edges both require XYZ edge computation. UV Gutter Width and UV Overlaps both require UV triangle computation. Each of those computations take about the same O(n log n) time, where n is the number of triangles. Typical run time without either of those computations is under 5 seconds, but if both types need to be run the test can take over a minute. 12 | 13 | ## Checks available 14 | 15 | - File Size 16 | - Triangle Count 17 | - Material Count 18 | - Node Count 19 | - Mesh Count 20 | - Primitive Count 21 | - Clean Origin for Root Node 22 | - Beveled Edges (no hard edges >= 90 degrees) (**_SLOW_**) 23 | - Non-Manifold Edges (**_SLOW_**) 24 | - Dimensions 25 | - Dimensions (product within tolerance) 26 | - PBR Safe Colors 27 | - Texture Map Resolution 28 | - Texture Map Resolution Power of 2 29 | - Texture Map Resolution Quadratic 30 | - Texel Density 31 | - 0-1 UV Texture Space 32 | - Inverted UVs 33 | - UV Overlaps (**_SLOW_**) 34 | - UV Gutter Width (**_SLOW_**) 35 | 36 | ## Example Implementations 37 | 38 | There are two example implementations included: 39 | 40 | - cli-example: Runs from a command line interface using Node.js 41 | - web-example: Single page application that runs from the browser 42 | 43 | ## Unit Tests 44 | 45 | Unit tests can be run with 46 | 47 | ``` 48 | npm run test 49 | ``` 50 | 51 | ## Known Issues 52 | 53 | - Files with draco mesh compression are not working - see: https://github.com/KhronosGroup/gltf-asset-auditor/issues/2 54 | 55 | ## Product Info JSON file 56 | 57 | For testing product dimensional tolerance, we need to know the dimensions of the product. The product info json file is used to provide that information. The product dimensions specified in the schema json file are different and more like a viewer bounding box check, but the percent tolerance value is used for both. 58 | 59 | # Schema JSON file 60 | 61 | The schema is used to specify which checks are run and what the passing values are. Omitted values will use the default recommendations of the Khronos 3D Commerce Asset Creation Guidelines, which are set in Schema.ts. To turn off a test that would normally run by default, -1 should be specified for parameters of type number and false for booleans. 62 | 63 | ## Version 64 | 65 | **_version: string_** 66 | 67 | This is the only required value. It corresponds to the version of this package and is used to identify which settings may be available. Features that were added after the version specified, starting with 1.0.0, will be turned off, rather than use default values. 68 | 69 | ## File Size 70 | 71 | **_fileSizeInKb?: { maximum?: number }_** (5120) 72 | 73 | **_fileSizeInKb?: { minimum?: number }_** (1) 74 | 75 | The size of the file in kilobytes. 76 | 77 | ## Materials Count 78 | 79 | **_materials?: { maximum?: number }_** (5) 80 | 81 | **_materials?: { minimum?: number }_** (-1) 82 | 83 | The number of all materials used in the entire file. 84 | 85 | ## Model Attributes 86 | 87 | **_model?: {...}, _** 88 | 89 | This group of values is related to objects and geometry 90 | 91 | ### Object Count 92 | 93 | **_objectCount?: {...}_** 94 | 95 | The number of objects can impact performance. Each primitive uses a separate draw call(s), based on the number of textures in its material. 96 | 97 | --- 98 | 99 | **_nodes?: { maximum?: number }_** (-1) 100 | 101 | **_nodes?: { minimum?: number }_** (-1) 102 | 103 | Nodes establish parent / child structure between meshes. 104 | 105 | --- 106 | 107 | **_meshes?: { maximum?: number }_** (-1) 108 | 109 | **_meshes?: { minimum?: number }_** (-1) 110 | 111 | Meshes are a groups of one or more primitives. 112 | 113 | --- 114 | 115 | **_primitives?: { maximum?: number }_** (-1) 116 | 117 | **_primitives?: { minimum?: number }_** (-1) 118 | 119 | Primitives are collection of triangles that use one material. 120 | 121 | --- 122 | 123 | ### Beveled Edges 124 | 125 | **_requireBeveledEdges?: boolean_** (false) 126 | 127 | Most objects in the real world do not have perfectly sharp edges, they are slightly rounded, so rendering non-beveled edges looks less realistic. This checks that the angle between all faces is greater than 90 degrees. 128 | 129 | ### Clean Transform 130 | 131 | **_requireCleanRootNodeTransform?: boolean_** (false) 132 | 133 | The object's transform center should be (0,0,0), it's rotation should be (0,0,0) and it's scale should be (1,1,1). 134 | 135 | ### Manifold Edges 136 | 137 | **_requireManifoldEdges?: boolean_** (false) 138 | 139 | Checks that all edges have 2 faces connected to them. 140 | 141 | ### Triangle Count 142 | 143 | **_triangles?: { maximum?: number }_** (100,000) 144 | 145 | **_triangles?: { minimum?: number }_** (-1) 146 | 147 | Specifies the range of number of triangles in the file. 148 | 149 | ## Product Info 150 | 151 | ### Dimensions 152 | 153 | These dimensions can be thought of as a bounding box of what range a model size should fall within and is defined at the schema level, making it a test for all models. The test can help identify assets that are scaled incorrectly. 154 | 155 | Dimensions provided separately with a product info json file are specific to a single model, which would likely come from an internal database. Both dimensional checks will use the percent tolerance value here to determine pass/fail. 156 | 157 | **_product?: { dimensions?: { height?: { maximum?: number } } }_** 158 | 159 | **_product?: { dimensions?: { height?: { minimum?: number } } }_** 160 | 161 | **_product?: { dimensions?: { height?: { percentTolerance?: number } } }_** 162 | 163 | --- 164 | 165 | **_product?: { dimensions?: { length?: { maximum?: number } } }_** 166 | 167 | **_product?: { dimensions?: { length?: { minimum?: number } } }_** 168 | 169 | **_product?: { dimensions?: { length?: { percentTolerance?: number } } }_** 170 | 171 | --- 172 | 173 | **_product?: { dimensions?: { width?: { maximum?: number } } }_** 174 | 175 | **_product?: { dimensions?: { width?: { minimum?: number } } }_** 176 | 177 | **_product?: { dimensions?: { width?: { percentTolerance?: number } } }_** 178 | 179 | ## Textures 180 | 181 | **_textures?: {...}_** 182 | 183 | ### Height 184 | 185 | **_height?: { maximum?: number }_** (2048) 186 | 187 | **_height?: { minimum?: number }_** (512) 188 | 189 | The height of the texture maps. 190 | 191 | ### PBR Color Range 192 | 193 | **_pbrColorRange?: { maximum?: number }_** (243) 194 | 195 | **_pbrColorRange?: { minimum?: number }_** (30) 196 | 197 | The min/max luminosity value of every pixel in the base color texture images. For the rendering engine to be able to add or subtract light from the texture, additional headroom should be available. 198 | 199 | ### Dimensions Power of Two 200 | 201 | **_requireDimensionsBePowersOfTwo?: boolean_** (true) 202 | 203 | For optimal processing on the GPU and for mip mapping, the file size should be a power of 2 (256, 512, 1024, 2048, 4096, ...) 204 | 205 | ### Dimensions Quadratic 206 | 207 | **_requireDimensionsBeQuadratic?: boolean_** (false) 208 | 209 | When dimensions are quadratic, the height and width are the same. 210 | 211 | ### Width 212 | 213 | **_width?: { maximum?: number }_** (2048) 214 | 215 | **_width?: { minimum?: number }_** (512) 216 | 217 | The width of the texture maps. 218 | 219 | ## UVs 220 | 221 | **_uvs?: {...}_** 222 | 223 | ### Gutter Width 224 | 225 | **_gutterWidth?: { resolution256?: number }_** 226 | 227 | **_gutterWidth?: { resolution512?: number }_** 228 | 229 | **_gutterWidth?: { resolution1024?: number }_** 230 | 231 | **_gutterWidth?: { resolution2048?: number }_** 232 | 233 | **_gutterWidth?: { resolution4096?: number }_** 234 | 235 | The gutter width is related to spacing between UV islands to prevent texture bleed when scaling to various resolutions, typically through mip mapping. 236 | 237 | The number of pixels of padding required can be specified against various base resolutions. Only one of these needs to be specified and if there are more than one, the smallest computed grid size will be used. For example, specifying a value of 8 for resolution1024 yields grid size of 128, meaning that there needs to be at least 1px buffer between islands when resized to 128px. A value of 2 for resolution256 gives the same grid size of 128, whereas a value of 4 for resolution256 gives 64. If both resolution256: 4 and resolution1024: 8 are provided, the smaller grid size of 64px will be used for the test. 238 | 239 | ### Not Inverted 240 | 241 | **_requireNotInverted?: boolean_** (true) 242 | 243 | UV faces should face upwards (wind in a clockwise direction) 244 | 245 | ### Not Overlapping 246 | 247 | **_requireNotOverlapping?: boolean_** (true) 248 | 249 | UV triangles should not overlap 250 | 251 | ### Texel Density 252 | 253 | **_pixelsPerMeter?: { maximum?: number }_** (-1) 254 | 255 | **_pixelsPerMeter?: { minimum?: number }_** (-1) 256 | 257 | The min and max texel density of all triangles in the model based upon the largest and smallest texture sizes. This value is high when the UV area contains a lot of pixels that get squeezed into a small face and low when the UV area doesn't cover many pixels that get spread across a large face. 258 | 259 | ### UVs in 0-1 Range 260 | 261 | **_requireRangeZeroToOne?: boolean_** (false) 262 | 263 | UV triangles should be in the 0-1 space when using atlas based textures that do not repeat, which is common practice for realtime assets. 264 | -------------------------------------------------------------------------------- /cli-example/README.md: -------------------------------------------------------------------------------- 1 | # Khronos glTF Asset Auditor 2 | 3 | # Command Line Interface Example 4 | 5 | ## SPDX-License-Identifier: Apache-2.0 6 | 7 | This is a sub-project of the glTF Asset Auditor which shows how to implement a version from the command line, using Node.js 8 | 9 | ### Usage 10 | 11 | This folder, /cli, is designed to be extracted from the main project (glTF Asset Auditor) and run on its own. 12 | 13 | You need to have **Node.js** installed on your system. This project was developed with v16.13.0, but any modern version should work. 14 | 15 | Dependencies need to be installed using npm. The main dependency is the glTF Asset Auditor and chalk is just used to add some color to the output. 16 | 17 | ``` 18 | npm i 19 | ``` 20 | 21 | The glTF Asset Auditor is now ready to run and the most basic usage is: 22 | 23 | ``` 24 | node index.js {schema-json-filepath}.json {glb-model-filepath}.glb 25 | ``` 26 | 27 | ### glTF Multi-file (.gltf + .bin + images) 28 | 29 | When using a multi-file .gltf, all of the files should be provided, separated by a space. They will be identified by file extension. A .gltf and .bin file are required at a minimum. 30 | 31 | ``` 32 | node index.js {schema-json-filepath}.json {gltf-model-filepath}.gltf {bin-filepath}.bin {texture-1}.jpg {texture-2}.png ... 33 | ``` 34 | 35 | ### Command Line Flags 36 | 37 | Additional flags can be used to provide product information, save the report as .csv and/or .json (both can be used with two -o's) 38 | 39 | - -o ; without a name argument creates a .json file with the model file name and a date + timestamp. This can go anywhere except before the schema file name (which also ends with .json and would be treated as the output file name) 40 | - -o {output-file-name}.json ; creates a json file with the report results 41 | - -o {output-file-name}.csv ; creates a comma separated values file with the report results 42 | - -p {product-info-file-name}.json ; Provides product dimensions to check against 43 | - -s {schema-file-name}.json ; using the -s flag is optional, but highly recommended if providing other .json files 44 | - -q ; quiet mode, console output will be suppressed 45 | 46 | ### Output 47 | 48 | The report will be printed to the console by default and will show PASS / FAIL / NOT TESTED for each test available. It also shows how long it took to run. 49 | 50 | Example output: 51 | 52 | ``` 53 | -- glTF Asset Auditor -- 54 | * Version: 1.0.0 55 | ==== Audit Report ==== 56 | glTF Validator: PASS | Errors: 0, Warnings: 0, Hints: 4, Info: 0 57 | File Size: NOT TESTED | 9kb 58 | Triangle Count: NOT TESTED | 12 59 | Material Count: NOT TESTED | 1 60 | Node Count: NOT TESTED | 1 61 | Mesh Count: NOT TESTED | 1 62 | Primitive Count: NOT TESTED | 1 63 | Root Node has Clean Transform: NOT TESTED | true 64 | Require Beveled Edges: NOT TESTED | Not Computed (slow) 65 | Require Manifold Edges: NOT TESTED | Not Computed (slow) 66 | Overall Dimensions: NOT TESTED | (L:2.00 x W:2.00 x H:2.00) 67 | Dimensions Match Product: NOT TESTED | No Product Info Loaded 68 | Maximum HSV color value for PBR safe colors: PASS | 240 <= 240 69 | Minimum HSV color value for PBR safe colors: PASS | 30 >= 30 70 | Texture Height <= Max: NOT TESTED | 256 71 | Texture Height >= Min: NOT TESTED | 256 72 | Texture Width <= Max: NOT TESTED | 256 73 | Texture Width >= Min: NOT TESTED | 256 74 | Texture Dimensions are Powers of 2: NOT TESTED | true 75 | Texture Dimensions are Square (width=height): NOT TESTED | true 76 | Maximum Pixels per Meter: NOT TESTED | 1,024 77 | Minimum Pixels per Meter: NOT TESTED | 1,024 78 | UVs in 0 to 1 Range: NOT TESTED | u: 0.13 to 0.88, v: 0.00 to 1.00 79 | Inverted UVs: NOT TESTED | 0 80 | Overlapping UVs: NOT TESTED | Not Computed (slow) 81 | UV Gutter Wide Enough: NOT TESTED | Not Computed (slow) 82 | =========================== 83 | Total Time: 0.125 seconds. 84 | ``` 85 | -------------------------------------------------------------------------------- /cli-example/index.js: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; // For colored output 2 | import fs from 'fs'; 3 | import path from 'path'; 4 | import { Auditor } from '@khronosgroup/gltf-asset-auditor'; 5 | import { argv, exit } from 'process'; 6 | 7 | let glbFilename = ''; 8 | let gltfFileName = ''; 9 | let gltfSupportingFiles = []; 10 | let i = 0; 11 | let jsonNoArgumentFileCount = 0; 12 | let jsonNoArgumentFilename = ''; 13 | let outputIndex = -1; 14 | let outputCsvFilename = ''; 15 | let outputJsonFilename = ''; 16 | let productInfoIndex = -1; 17 | let productInfoFilename = ''; 18 | let schemaIndex = -1; 19 | let schemaFilename = ''; 20 | let quietMode = false; 21 | 22 | // Suppress console.log messages if the -q flag was set 23 | const printIfNotQuiet = message => { 24 | if (!quietMode) { 25 | console.log(message); 26 | } 27 | }; 28 | 29 | argv.forEach(arg => { 30 | // 0: node full path 31 | // 1: index.js location 32 | // optional arguments: 33 | // -o output filename (ends with .json or .csv) 34 | // -p product dimensions (ends with .json) 35 | // required arguments: 36 | // -s schema (ends with .json) 37 | // -q quiet mode 38 | // .glb or .gltf + .bin (+ images) 39 | 40 | // Supported file extensions 41 | if (arg.endsWith('.csv')) { 42 | outputCsvFilename = arg; 43 | } else if (arg.endsWith('.glb')) { 44 | glbFilename = arg; 45 | } else if (arg.endsWith('.gltf')) { 46 | gltfFileName = arg; 47 | } else if (arg.endsWith('.json')) { 48 | // .json can be for the schema, product info, or output 49 | if (i == outputIndex) { 50 | // -o 51 | outputJsonFilename = arg; 52 | } else if (i == productInfoIndex) { 53 | // -p 54 | productInfoFilename = arg; 55 | } else if (i == schemaIndex) { 56 | // -s 57 | schemaFilename = arg; 58 | } else { 59 | // json file without an argument 60 | jsonNoArgumentFilename = arg; 61 | // If only one .json filename is provided without an argument, treat it as the schema 62 | jsonNoArgumentFileCount++; 63 | } 64 | } else if (['.bin', '.jpg', 'jpeg', '.ktx', 'ktx2', '.png', 'webp'].includes(arg.slice(-4))) { 65 | // TODO: I believe these are the only file extensions supported, but user testing my reveal others 66 | gltfSupportingFiles.push(arg); 67 | } 68 | 69 | // Flags 70 | if (arg == '-o') { 71 | // output to a .csv or .json or both 72 | // If no file name is provided, it defaults to {model_name} + timestamp.json 73 | outputIndex = i + 1; 74 | } 75 | if (arg == '-p') { 76 | // specify product information (dimensions) 77 | productInfoIndex = i + 1; 78 | } 79 | if (arg == '-s') { 80 | // specify the schema (optional). A .json file without a preceding argument will be treated as the schema. 81 | schemaIndex = i + 1; 82 | } 83 | if (arg == '-q') { 84 | // Prevent console messages 85 | quietMode = true; 86 | } 87 | 88 | i++; 89 | }); 90 | 91 | // Verify that either a glb or gltf was provided 92 | if (glbFilename == '' && gltfFileName == '') { 93 | printIfNotQuiet(chalk.red('A .glb or .gltf file needs to be provided')); 94 | exit(1); 95 | } 96 | 97 | // If only one .json file name was provided without an argument, treat it as the schema 98 | if (jsonNoArgumentFileCount == 1) { 99 | schemaFilename = jsonNoArgumentFilename; 100 | } 101 | 102 | // If just -o was provided without a filename ending in csv or json, generate a filename 103 | if (outputIndex > 0 && outputJsonFilename == '' && outputCsvFilename == '') { 104 | const date = new Date(); 105 | const timestamp = [ 106 | date.getFullYear(), 107 | ('0' + (date.getMonth() + 1)).slice(-2), 108 | ('0' + date.getDate()).slice(-2), 109 | ('0' + date.getHours()).slice(-2), 110 | ('0' + date.getMinutes()).slice(-2), 111 | ('0' + date.getSeconds()).slice(-2), 112 | ].join('-'); 113 | if (glbFilename) { 114 | outputJsonFilename = 115 | glbFilename.substring(glbFilename.lastIndexOf(path.sep) + 1, glbFilename.length - 4) + 116 | '-3DQC-' + 117 | timestamp + 118 | '.json'; 119 | } else if (gltfFileName) { 120 | outputJsonFilename = 121 | gltfFileName.substring(gltfFileName.lastIndexOf(path.sep) + 1, gltfFileName.length - 5) + 122 | '-3DQC-' + 123 | timestamp + 124 | '.json'; 125 | } 126 | } 127 | 128 | // Verify that a schema was provided 129 | if (schemaFilename == '') { 130 | printIfNotQuiet(chalk.red('A schema file needs to be provided')); 131 | exit(1); 132 | } 133 | 134 | if (gltfFileName && !gltfSupportingFiles.map(i => i.slice(-4)).includes('.bin')) { 135 | printIfNotQuiet(chalk.red('Using a .gltf file also needs to include a .bin file plus external textures')); 136 | exit(1); 137 | } 138 | 139 | // Print a message at the start of the program 140 | const printWelcomeMessage = version => { 141 | printIfNotQuiet(chalk.green('-- glTF Asset Auditor --')); 142 | printIfNotQuiet(chalk.yellow('* Version: ' + version)); 143 | }; 144 | 145 | // START 146 | try { 147 | const startTime = Date.now(); 148 | const auditor = new Auditor(); 149 | auditor.decimalDisplayPrecision = 2; 150 | printWelcomeMessage(auditor.version); 151 | 152 | // 1. Load Schema (schema should be loaded before model to know if index calculation is required) 153 | await auditor.schema.loadFromFileSystem(schemaFilename); 154 | 155 | // 2. Load GLB or GLTF + files 156 | // If a glb path exists, use it, otherwise use the gltf and supporting files 157 | let fileList = glbFilename ? [glbFilename] : [gltfFileName, ...gltfSupportingFiles]; 158 | await auditor.model.loadFromFileSystem(fileList); 159 | 160 | // 3. Load Product Info (optional) 161 | if (productInfoFilename) { 162 | await auditor.productInfo.loadFromFileSystem(productInfoFilename); 163 | } 164 | 165 | // 4. Run Audit 166 | auditor.generateReport(); 167 | 168 | // 5. Show Report 169 | // for formatting, find the length of the longest name 170 | let longestNameLength = 0; 171 | let hasNotTestedItems = false; 172 | auditor.report.getItems().forEach(item => { 173 | if (item.name.length > longestNameLength) { 174 | longestNameLength = item.name.length; 175 | } 176 | if (item.tested === false) { 177 | hasNotTestedItems = true; 178 | } 179 | }); 180 | printIfNotQuiet(chalk.magenta('==== Audit Report ====')); 181 | // Loop through all available items in the report and print their status 182 | auditor.report.getItems().forEach(item => { 183 | let itemNameFormatted = item.name + ': '; 184 | for (let i = item.name.length; i < longestNameLength; i++) { 185 | itemNameFormatted = ' ' + itemNameFormatted; 186 | } 187 | printIfNotQuiet( 188 | itemNameFormatted + 189 | (item.tested 190 | ? item.pass 191 | ? chalk.green('PASS' + (hasNotTestedItems ? ' ' : '')) 192 | : chalk.red('FAIL' + (hasNotTestedItems ? ' ' : '')) 193 | : chalk.gray('NOT TESTED')) + 194 | ' | ' + 195 | chalk.gray(item.message), 196 | ); 197 | }); 198 | printIfNotQuiet(chalk.magenta('===========================')); 199 | printIfNotQuiet('Total Time: ' + ((Date.now() - startTime) / 1000).toFixed(3) + ' seconds.'); 200 | if (outputCsvFilename) { 201 | await fs.writeFileSync(outputCsvFilename, auditor.getReportCsv(), {}, err => { 202 | if (err) { 203 | printIfNotQuiet('there was an error writing the csv file'); 204 | } 205 | }); 206 | } 207 | if (outputJsonFilename) { 208 | await fs.writeFileSync(outputJsonFilename, auditor.getReportJson(), {}, err => { 209 | if (err) { 210 | printIfNotQuiet('there was an error writing the csv file'); 211 | } 212 | }); 213 | } 214 | } catch (err) { 215 | if (err) { 216 | printIfNotQuiet(chalk.red('ERROR: ' + err.message)); 217 | } else { 218 | printIfNotQuiet(chalk.red('ERROR: unknown')); 219 | } 220 | } 221 | 222 | exit(0); 223 | -------------------------------------------------------------------------------- /cli-example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@khronosgroup/gltf-asset-auditor-cli", 3 | "version": "1.0.3", 4 | "author": "Mike Festa", 5 | "description": "Read a glTF file and a JSON schema definition to perform a series of pass/fail checks based on the technical requirements and best practices described in the 3D Commerce Real-time Asset Creation Guidelines", 6 | "license": "SPDX-License-Identifier: Apache-2.0", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/KhronosGroup/gltf-asset-auditor/tree/main/cli-example" 10 | }, 11 | "main": "index.js", 12 | "type": "module", 13 | "dependencies": { 14 | "@khronosgroup/gltf-asset-auditor": "^1.0.3", 15 | "chalk": "^5.0.1" 16 | }, 17 | "devDependencies": { 18 | "tslib": "^2.4.0" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@khronosgroup/gltf-asset-auditor", 3 | "version": "1.0.3", 4 | "author": "Mike Festa", 5 | "description": "Package for checking a 3D model file against a 3D Commerce use case schema to provide PASS/FAIL validation", 6 | "license": "SPDX-License-Identifier: Apache-2.0", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/KhronosGroup/gltf-asset-auditor" 10 | }, 11 | "exports": { 12 | ".": "./dist/Auditor.js" 13 | }, 14 | "main": "dist/Auditor.js", 15 | "type": "module", 16 | "keywords": [ 17 | "3d", 18 | "asset", 19 | "commerce", 20 | "glb", 21 | "gltf", 22 | "khronos", 23 | "auditor", 24 | "validator" 25 | ], 26 | "scripts": { 27 | "build": "tsc", 28 | "test": "mocha -n loader=ts-node/esm 'tests/**/*.ts'" 29 | }, 30 | "dependencies": { 31 | "@babylonjs/core": "^5.18.0", 32 | "@babylonjs/loaders": "^5.18.0", 33 | "canvas": "^2.10.1", 34 | "gltf-validator": "^2.0.0-dev.3.9" 35 | }, 36 | "devDependencies": { 37 | "@types/chai": "^4.3.3", 38 | "@types/mocha": "^9.1.1", 39 | "@types/node": "^18.0.4", 40 | "chai": "^4.3.6", 41 | "mocha": "^10.0.0", 42 | "ts-node": "^10.9.1", 43 | "tslib": "^2.4.0", 44 | "typescript": "^4.7.4" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /set-version.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | version=$1 4 | if [ "$version" = "" ] 5 | then 6 | echo "The new version number needs to be provided as an argument" 7 | exit 1 8 | fi 9 | echo "Changing the version to ${version}" 10 | 11 | packageJson="s/\"version\": \"[a-z0-9.-]*\"/\"version\": \"${version}\"/g" 12 | packageJsonAuditor="s/\"@khronosgroup\/gltf-asset-auditor\": \"^[a-z0-9.-]*\"/\"@khronosgroup\/gltf-asset-auditor\": \"^${version}\"/g" 13 | sed -i '' "${packageJson}" package.json 14 | sed -i '' "${packageJson}" cli-example/package.json 15 | sed -i '' "${packageJsonAuditor}" cli-example/package.json 16 | sed -i '' "${packageJson}" web-example/package.json 17 | sed -i '' "${packageJsonAuditor}" web-example/package.json 18 | 19 | auditor="s/version = '[a-z0-9.-]*';/version = '${version}';/g" 20 | sed -i '' "${auditor}" src/Auditor.ts 21 | 22 | schema="s/LoadableAttribute('Version', '[a-z0-9.-]*');/LoadableAttribute('Version', '${version}');/g" 23 | sed -i '' "${schema}" src/Schema.ts 24 | 25 | # package-lock.json 26 | npm i 27 | # Note: can't pull the new auditor package version until it's published 28 | #cd cli-example && npm i && cd .. 29 | #cd cli-example && npm i 30 | 31 | # TODO: cli + web example projects 32 | # TODO: test schemas 33 | -------------------------------------------------------------------------------- /src/EdgeUv.ts: -------------------------------------------------------------------------------- 1 | import { TriangleUvInterface } from './TriangleUv'; 2 | import { VertexUvInterface } from './VertexUv'; 3 | 4 | export interface EdgeUvInterface { 5 | index: number; 6 | triangles: TriangleUvInterface[]; 7 | vertexA: VertexUvInterface; 8 | vertexB: VertexUvInterface; 9 | zeroLength: boolean; 10 | checkForMatch(edge: EdgeUvInterface): boolean; 11 | } 12 | 13 | // A 2D line connecting two UV vertices 14 | export default class EdgeUv implements EdgeUvInterface { 15 | index = undefined as unknown as number; 16 | triangles = [] as TriangleUvInterface[]; 17 | vertexA = null as unknown as VertexUvInterface; 18 | vertexB = null as unknown as VertexUvInterface; 19 | zeroLength = false; 20 | 21 | constructor(a: VertexUvInterface, b: VertexUvInterface) { 22 | this.vertexA = a; 23 | this.vertexB = b; 24 | // if both vertices are in the same position, it has zero length 25 | // V2: Add zero length UV edges to the report 26 | this.zeroLength = this.vertexA.index === this.vertexB.index; 27 | } 28 | 29 | // Check if this edge matches another one, which is true if they have the same vertices 30 | public checkForMatch(edge: EdgeUvInterface): boolean { 31 | // Treat AB and BA as equal by testing min/max of the index 32 | if ( 33 | Math.min(this.vertexA.index, this.vertexB.index) === Math.min(edge.vertexA.index, edge.vertexB.index) && 34 | Math.max(this.vertexA.index, this.vertexB.index) === Math.max(edge.vertexA.index, edge.vertexB.index) 35 | ) { 36 | return true; 37 | } 38 | return false; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/EdgeXyz.ts: -------------------------------------------------------------------------------- 1 | import { float, Vector3 } from '@babylonjs/core'; 2 | import { TriangleXyzInterface } from './TriangleXyz'; 3 | import { VertexXyzInterface } from './VertexXyz'; 4 | 5 | export interface EdgeXyzInterface { 6 | faceAngleInRadians: float; 7 | index: number; 8 | nonManifold: boolean; 9 | triangles: TriangleXyzInterface[]; 10 | vertexA: VertexXyzInterface; 11 | vertexB: VertexXyzInterface; 12 | calculateAttributes(): void; 13 | checkForMatch(edge: EdgeXyzInterface): boolean; 14 | } 15 | 16 | // A 3D line connecting two XYZ vertices 17 | export default class EdgeXyz implements EdgeXyzInterface { 18 | faceAngleInRadians = undefined as unknown as number; 19 | index = undefined as unknown as number; 20 | nonManifold = undefined as unknown as boolean; 21 | triangles = [] as TriangleXyzInterface[]; 22 | vertexA = null as unknown as VertexXyzInterface; 23 | vertexB = null as unknown as VertexXyzInterface; 24 | 25 | constructor(a: VertexXyzInterface, b: VertexXyzInterface) { 26 | this.vertexA = a; 27 | this.vertexB = b; 28 | } 29 | 30 | // Attributes that are calculated after all triangles are linked 31 | public calculateAttributes(): void { 32 | if (this.triangles.length === 2) { 33 | // Compute the angle between the normal vectors (used to check beveled edges vs hard edges) 34 | this.faceAngleInRadians = Vector3.GetAngleBetweenVectors( 35 | this.triangles[0].normal, 36 | this.triangles[1].normal, 37 | Vector3.Cross(this.triangles[0].normal, this.triangles[1].normal), 38 | ); 39 | // Currently, having only 2 faces for the edge is enough to consider it manifold 40 | // Other factors to check for manifoldness (V2 update): 41 | // - Opposite facing normals 42 | // - Surfaces connected to one vertex 43 | // https://cgtyphoon.com/fundamentals/types-of-non-manifold-geometry/ 44 | this.nonManifold = false; 45 | } else if (this.triangles.length === 1) { 46 | // Open Geometry (2-manifold with boundaries) - OK to have 47 | this.nonManifold = false; 48 | } else { 49 | // More than 2 faces can indicate these non-manifold conditions: 50 | // - T-type 51 | // - Internal Faces 52 | this.nonManifold = true; 53 | } 54 | } 55 | 56 | // Check if this edge matches another one, which is true if they have the same vertices 57 | public checkForMatch(edge: EdgeXyzInterface): boolean { 58 | // Treat AB and BA as equal by testing min/max of the index 59 | if ( 60 | Math.min(this.vertexA.index, this.vertexB.index) === Math.min(edge.vertexA.index, edge.vertexB.index) && 61 | Math.max(this.vertexA.index, this.vertexB.index) === Math.max(edge.vertexA.index, edge.vertexB.index) 62 | ) { 63 | return true; 64 | } 65 | return false; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Glb.ts: -------------------------------------------------------------------------------- 1 | import { GltfBinInterface } from './GltfBin'; 2 | import { GltfJsonInterface } from './GltfJson'; 3 | import { EncodeArrayBufferToBase64 } from '@babylonjs/core/Misc/stringTools.js'; 4 | import { NullEngine } from '@babylonjs/core/Engines/nullEngine.js'; 5 | import { Scene } from '@babylonjs/core/scene.js'; 6 | import { GLTFFileLoader } from '@babylonjs/loaders'; 7 | import '@babylonjs/loaders/glTF/2.0/glTFLoader.js'; 8 | import { buffer } from 'stream/consumers'; 9 | 10 | export interface GlbInterface { 11 | arrayBuffer: ArrayBuffer; 12 | bin: GltfBinInterface; 13 | filename: string; 14 | json: GltfJsonInterface; 15 | loaded: boolean; 16 | getBase64String(): string; 17 | getBytes(): Uint8Array; 18 | initFromGlbFile(file: File): void; 19 | initFromGlbFilePath(filePath: string): void; 20 | initFromGltfFiles(files: File[]): void; 21 | initFromGltfFilePaths(filePaths: string[]): void; 22 | } 23 | 24 | // GLB is the binary version of glTF and this class is used to load and access that data 25 | export default class Glb implements GlbInterface { 26 | arrayBuffer = null as unknown as ArrayBuffer; 27 | bin = null as unknown as GltfBinInterface; 28 | filename = ''; 29 | json = null as unknown as GltfJsonInterface; 30 | loaded = false; 31 | 32 | // Returns the glb data as a data string for loading with Babylon.js 33 | public getBase64String(): string { 34 | return 'data:;base64,' + EncodeArrayBufferToBase64(this.arrayBuffer); 35 | } 36 | 37 | // Returns the generic ArrayBuffer as an unsigned byte array 38 | public getBytes() { 39 | return new Uint8Array(this.arrayBuffer); 40 | } 41 | 42 | // Loads a single .glb file that comes from the browser element 43 | public async initFromGlbFile(file: File) { 44 | if (!file.name.endsWith('.glb')) { 45 | throw new Error('When only a single file is provided, it must be a .glb'); 46 | } 47 | try { 48 | this.arrayBuffer = await this.getBufferFromFileInput(file); 49 | this.filename = file.name; 50 | } catch (err) { 51 | throw new Error('Unable to get buffer from file input'); 52 | } 53 | await this.loadBinAndJson(); 54 | this.loaded = true; 55 | } 56 | 57 | // Loads a single .glb file that is on the filesystem (Node.js) 58 | public async initFromGlbFilePath(filePath: string) { 59 | if (!filePath.endsWith('.glb')) { 60 | throw new Error('When only a single file is provided, it must be a .glb'); 61 | } 62 | try { 63 | // Need to import this way to compile webpack 64 | // webpack.config.js also needs: 65 | // config.resolve.fallback.fs = false 66 | // config.resolve.fallback.path = false 67 | const { promises } = await import('fs'); 68 | const { sep } = await import('path'); 69 | this.arrayBuffer = await promises.readFile(filePath); 70 | this.filename = filePath.substring(filePath.lastIndexOf(sep) + 1); 71 | } catch (err) { 72 | throw new Error('Unable to get buffer from filepath'); 73 | } 74 | await this.loadBinAndJson(); 75 | this.loaded = true; 76 | } 77 | 78 | // Loads a multi-file .gltf that comes from the browser element 79 | public async initFromGltfFiles(files: File[]) { 80 | let binAndImagesBufferSize = 0; 81 | let binFile = null as unknown as File; 82 | const bufferMap = new Map(); 83 | let gltfFile = null as unknown as File; 84 | let imageFiles = [] as File[]; 85 | 86 | // Find files by extension 87 | files.forEach(file => { 88 | if (file.name.endsWith('.gltf')) { 89 | this.filename = file.name; 90 | gltfFile = file; 91 | } else if (file.name.endsWith('.bin')) { 92 | binFile = file; 93 | } else { 94 | imageFiles.push(file); 95 | } 96 | }); 97 | 98 | // Check that .gltf and .bin are provided 99 | if (!binFile) { 100 | throw new Error('No .bin file provided'); 101 | } 102 | if (!gltfFile) { 103 | throw new Error('No .gltf file provided'); 104 | } 105 | 106 | // Load the json data from the .gltf 107 | const gltfBuffer = new Uint8Array(await this.getBufferFromFileInput(gltfFile)); 108 | const dec = new TextDecoder(); 109 | const gltfJson = JSON.parse(dec.decode(gltfBuffer)); 110 | const originalBufferViewCount = gltfJson.bufferViews.length; 111 | 112 | // Load the binary data from the .bin 113 | const binBuffer = new Uint8Array(await this.getBufferFromFileInput(binFile)); 114 | binAndImagesBufferSize = this.alignedLength(binBuffer.byteLength); 115 | 116 | // Load the binary data from all images and add to bufferView[] 117 | let imageBuffers = [] as unknown as Uint8Array[]; 118 | for (let i = 0; i < imageFiles.length; i++) { 119 | const imageFile = imageFiles[i]; 120 | // Note: this assumes that all files are in the same directory 121 | imageBuffers.push(new Uint8Array(await this.getBufferFromFileInput(imageFile))); 122 | // Map the bufferIndex to the uri, which is used to update gltfJson.images 123 | bufferMap.set(imageFile.name, originalBufferViewCount + i); 124 | gltfJson.bufferViews.push({ 125 | buffer: 0, 126 | byteOffset: binAndImagesBufferSize, 127 | byteLength: imageBuffers[i].byteLength, 128 | }); 129 | binAndImagesBufferSize += this.alignedLength(imageBuffers[i].byteLength); 130 | } 131 | 132 | this.arrayBuffer = this.combineBuffersToGlb(binAndImagesBufferSize, bufferMap, binBuffer, gltfJson, imageBuffers); 133 | this.loadBinAndJson(); 134 | this.loaded = true; 135 | } 136 | 137 | // Loads a multi-file .gltf that is on the filesystem (Node.js) 138 | public async initFromGltfFilePaths(filePaths: string[]) { 139 | // Need to import this way to compile webpack 140 | // webpack.config.js also needs: 141 | // config.resolve.fallback.fs = false 142 | // config.resolve.fallback.path = false 143 | const { promises } = await import('fs'); 144 | const { sep } = await import('path'); 145 | 146 | let binAndImagesBufferSize = 0; 147 | let binFilePath = ''; 148 | const bufferMap = new Map(); 149 | let gltfFilePath = ''; 150 | let imageFilePaths = [] as string[]; 151 | 152 | // Find files by extension 153 | filePaths.forEach(filePath => { 154 | if (filePath.endsWith('.gltf')) { 155 | gltfFilePath = filePath; 156 | this.filename = filePath.substring(filePath.lastIndexOf(sep) + 1); 157 | } else if (filePath.endsWith('.bin')) { 158 | binFilePath = filePath; 159 | } else { 160 | imageFilePaths.push(filePath); 161 | } 162 | }); 163 | 164 | // Check that .gltf and .bin are provided 165 | if (!binFilePath) { 166 | throw new Error('No .bin file provided'); 167 | } 168 | if (!gltfFilePath) { 169 | throw new Error('No .gltf file provided'); 170 | } 171 | 172 | // Load the json data from the .gltf 173 | const gltfBuffer = await promises.readFile(gltfFilePath); 174 | const gltfJson = JSON.parse(gltfBuffer.toString('utf-8')); 175 | const originalBufferViewCount = gltfJson.bufferViews.length; 176 | 177 | // Load the binary data from the .bin 178 | const binBuffer = await promises.readFile(binFilePath); 179 | binAndImagesBufferSize = this.alignedLength(binBuffer.length); 180 | 181 | // Load the binary data from all images and add to bufferView[] 182 | let imageBuffers = [] as unknown as Buffer[]; 183 | for (let i = 0; i < imageFilePaths.length; i++) { 184 | const imageFilePath = imageFilePaths[i]; 185 | // Note: this assumes that all files are in the same directory 186 | const imageFileName = imageFilePath.substring(imageFilePath.lastIndexOf(sep) + 1); 187 | imageBuffers.push(await promises.readFile(imageFilePath)); 188 | // Map the bufferIndex to the uri, which is used to update gltfJson.images 189 | bufferMap.set(imageFileName, originalBufferViewCount + i); 190 | gltfJson.bufferViews.push({ 191 | buffer: 0, 192 | byteOffset: binAndImagesBufferSize, 193 | byteLength: imageBuffers[i].length, 194 | }); 195 | binAndImagesBufferSize += this.alignedLength(imageBuffers[i].length); 196 | } 197 | 198 | this.arrayBuffer = this.combineBuffersToGlb(binAndImagesBufferSize, bufferMap, binBuffer, gltfJson, imageBuffers); 199 | this.loadBinAndJson(); 200 | this.loaded = true; 201 | } 202 | 203 | /////////////////////// 204 | // PRIVATE FUNCTIONS // 205 | /////////////////////// 206 | 207 | // Round the length up to the nearest 4 bytes (32 bits) 208 | private alignedLength(initialLength: number): number { 209 | if (initialLength == 0) { 210 | return initialLength; 211 | } 212 | const alignValue = 4; 213 | var modRemainder = initialLength % alignValue; 214 | if (modRemainder === 0) { 215 | return initialLength; 216 | } 217 | return initialLength + (alignValue - modRemainder); 218 | } 219 | 220 | // Get data loaded from a multi-file .gltf in the equivalent .glb format 221 | private combineBuffersToGlb( 222 | binAndImagesBufferSize: number, 223 | bufferMap: Map, 224 | binBuffer: Uint8Array, 225 | gltfJson: GltfJsonInterface, 226 | imageBuffers: Uint8Array[], 227 | ): ArrayBuffer { 228 | /** 229 | * Babylon.js does not have a way to load multiple files, so for 230 | * multi-file .gltf + .bin + images, we'll convert to the .glb format 231 | * The original .gltf has one buffer that references the external .bin 232 | * We're going to remove the external uri reference and merge the 233 | * binary data from the .bin and all the image files. 234 | * The uris in the image data is replaced with a bufferView reference 235 | * and the bufferViews need to be expanded to include the image data references 236 | */ 237 | 238 | // Note: new bufferViews for the images were added to the incoming gltfJson when loaded 239 | 240 | // Update the buffer with the new size and remove the uri that was for the bin file 241 | if (gltfJson.buffers.length !== 1) { 242 | throw new Error('The gltf should have one buffer and it has ' + gltfJson.buffers.length); 243 | } 244 | gltfJson.buffers[0].byteLength = binAndImagesBufferSize; 245 | delete gltfJson.buffers[0].uri; 246 | 247 | // Replace the uri with a bufferView for all matched images 248 | if (gltfJson.images) { 249 | gltfJson.images.forEach(image => { 250 | // Note: not checking if the uri is base64 251 | const bufferIndex = bufferMap.get(image.uri); 252 | if (bufferIndex) { 253 | delete image.uri; 254 | image.bufferView = bufferIndex; 255 | // Note: mimeType should already be set 256 | } 257 | }); 258 | } 259 | 260 | // reference: https://github.com/sbtron/makeglb/blob/master/index.html 261 | const enc = new TextEncoder(); 262 | const jsonBuffer = enc.encode(JSON.stringify(gltfJson)); 263 | const jsonAlignedLength = this.alignedLength(jsonBuffer.length); 264 | const totalSize = 265 | 12 + // file header: magic + version + length 266 | 8 + // json chunk header: json length + type 267 | jsonAlignedLength + 268 | 8 + // bin chunk header: chunk length + type 269 | binAndImagesBufferSize; 270 | 271 | const arrayBuffer = new ArrayBuffer(totalSize); 272 | const dataView = new DataView(arrayBuffer); 273 | let bufferIndex = 0; 274 | 275 | // Binary Magic 276 | dataView.setUint32(bufferIndex, 0x46546c67, true); 277 | bufferIndex += 4; 278 | dataView.setUint32(bufferIndex, 2, true); 279 | bufferIndex += 4; 280 | dataView.setUint32(bufferIndex, totalSize, true); 281 | bufferIndex += 4; 282 | 283 | // JSON 284 | dataView.setUint32(bufferIndex, jsonAlignedLength, true); 285 | bufferIndex += 4; 286 | dataView.setUint32(bufferIndex, 0x4e4f534a, true); 287 | bufferIndex += 4; 288 | for (let i = 0; i < jsonBuffer.length; i++) { 289 | dataView.setUint8(bufferIndex, jsonBuffer[i]); 290 | bufferIndex++; 291 | } 292 | let padding = jsonAlignedLength - jsonBuffer.length; 293 | for (let i = 0; i < padding; i++) { 294 | dataView.setUint8(bufferIndex, 0x20); // space 295 | bufferIndex++; 296 | } 297 | 298 | // BIN (+images) 299 | dataView.setUint32(bufferIndex, binAndImagesBufferSize, true); 300 | bufferIndex += 4; 301 | dataView.setUint32(bufferIndex, 0x004e4942, true); 302 | bufferIndex += 4; 303 | 304 | // .bin 305 | for (let i = 0; i < binBuffer.length; i++) { 306 | dataView.setUint8(bufferIndex, binBuffer[i]); 307 | bufferIndex++; 308 | } 309 | // The bufferViews have byte offsets that are 32-bit aligned 310 | // The bin and images write 8 bits at a time and may not take up 311 | // all of the allocated space, so extra space at the end can be skipped 312 | bufferIndex = this.alignedLength(bufferIndex); 313 | 314 | // images 315 | imageBuffers.forEach(imageBuffer => { 316 | for (let i = 0; i < imageBuffer.length; i++) { 317 | dataView.setUint8(bufferIndex, imageBuffer[i]); // read 8 bits at a time to support 8-bit grayscale 318 | bufferIndex++; 319 | } 320 | bufferIndex = this.alignedLength(bufferIndex); 321 | }); 322 | 323 | return arrayBuffer; 324 | } 325 | 326 | // Extract json and binary data from the arrayBuffer 327 | private async loadBinAndJson(): Promise { 328 | if (!this.arrayBuffer) { 329 | throw new Error('The array buffer must be loaded before json and bin data can be extracted'); 330 | } 331 | // Creating an empty scene for the purpose of this extraction 332 | const engine = new NullEngine(); 333 | const scene = new Scene(engine); 334 | return await new Promise((resolve, reject) => { 335 | const fileLoader = new GLTFFileLoader(); 336 | fileLoader.loadFile( 337 | scene, 338 | this.getBase64String(), 339 | data => { 340 | this.json = data.json; 341 | this.bin = data.bin; 342 | resolve(); 343 | }, 344 | ev => { 345 | // progress. nothing to do 346 | }, 347 | true, 348 | err => { 349 | reject(); 350 | }, 351 | ); 352 | }); 353 | } 354 | 355 | // Read a file from a web browser element 356 | private async getBufferFromFileInput(file: File): Promise { 357 | return new Promise((resolve, reject) => { 358 | try { 359 | const reader = new FileReader(); 360 | reader.onload = function () { 361 | if (reader.result) { 362 | const buffer = reader.result as ArrayBuffer; 363 | resolve(buffer); 364 | } else { 365 | reject(); 366 | } 367 | }; 368 | reader.readAsArrayBuffer(file); 369 | } catch (err) { 370 | reject(); 371 | } 372 | }); 373 | } 374 | } 375 | -------------------------------------------------------------------------------- /src/GltfBin.ts: -------------------------------------------------------------------------------- 1 | export interface GltfBinInterface { 2 | byteLength: number; 3 | readAsync(byteOffset: number, byteLength: number): any; 4 | } 5 | -------------------------------------------------------------------------------- /src/GltfJson.ts: -------------------------------------------------------------------------------- 1 | // Note: these interfaces are not exhaustive and generally only cover the 2 | // attributes that are needed by the asset auditor 3 | // Full Schema: https://github.com/KhronosGroup/glTF/tree/main/specification/2.0/schema 4 | 5 | export interface GltfJsonBufferInterface { 6 | byteLength: number; 7 | uri?: string; 8 | } 9 | 10 | export interface GltfJsonBufferViewInterface { 11 | buffer: number; 12 | byteLength: number; 13 | byteOffset: number; 14 | } 15 | 16 | export interface GltfJsonImageInterface { 17 | bufferView?: number; 18 | mimeType: string; 19 | name: string; 20 | uri?: string; 21 | } 22 | 23 | export interface GltfJsonMaterialInterface { 24 | alphaCutoff: number; 25 | alphaMode: string; 26 | doubleSided: boolean; 27 | emissiveFactor: [number, number, number]; 28 | emissiveTexture: GltfJsonTextureInfoInterface; 29 | extensions: object; 30 | extras: object; 31 | name: string; 32 | normalTexture: object; 33 | occlusionTexture: object; 34 | pbrMetallicRoughness: GltfJsonPbrMetallicRoughnessInterface; 35 | } 36 | 37 | export interface GltfJsonMeshInterface { 38 | name: string; 39 | primitives: GltfJsonPrimitiveInterface[]; 40 | } 41 | 42 | export interface GltfJsonPrimitiveInterface { 43 | indices: number; 44 | attributes: object; 45 | material: number; 46 | } 47 | 48 | export interface GltfJsonPbrMetallicRoughnessInterface { 49 | baseColorFactor: [number, number, number, number]; 50 | baseColorTexture: GltfJsonTextureInfoInterface; 51 | metallicFactor: number; 52 | roughnessFactor: number; 53 | metallicRoughnessTexture: GltfJsonTextureInfoInterface; 54 | } 55 | 56 | export interface GltfJsonTextureInfoInterface { 57 | extensions: object; 58 | extras: object; 59 | index: number; 60 | texCoord: number; 61 | } 62 | 63 | export interface GltfJsonTextureInterface { 64 | extensions: object; 65 | extras: object; 66 | name: string; 67 | sampler: number; 68 | source: number; 69 | } 70 | 71 | // Top Level Interface 72 | export interface GltfJsonInterface { 73 | accessors: object[]; 74 | asset: { 75 | copyright: string; 76 | generator: string; 77 | version: string; 78 | }; 79 | bufferViews: GltfJsonBufferViewInterface[]; 80 | buffers: GltfJsonBufferInterface[]; 81 | extensionsUsed: string[]; 82 | images: GltfJsonImageInterface[]; 83 | materials: GltfJsonMaterialInterface[]; 84 | meshes: GltfJsonMeshInterface[]; 85 | nodes: object[]; 86 | samplers: object[]; 87 | scene: number; 88 | scenes: object[]; 89 | textures: GltfJsonTextureInterface[]; 90 | } 91 | -------------------------------------------------------------------------------- /src/GltfValidatorReport.ts: -------------------------------------------------------------------------------- 1 | // These interfaces map to the output of the glTF-Validator 2 | // https://github.com/KhronosGroup/glTF-Validator 3 | 4 | export interface GltfValidatorReportInfoInterface { 5 | version: string; 6 | generator: string; 7 | extensionsUsed: Array; 8 | resources: Array; 9 | animationCount: number; 10 | materialCount: number; 11 | hasMorphTargets: boolean; 12 | hasSkins: boolean; 13 | hasTextures: boolean; 14 | hasDefaultScene: boolean; 15 | drawCallCount: number; 16 | totalVertexCount: number; 17 | totalTriangleCount: number; 18 | maxUVs: number; 19 | maxInfluences: number; 20 | maxAttributes: number; 21 | } 22 | 23 | export interface GltfValidatorReportInfoResourceImageInterface { 24 | width: number; 25 | height: number; 26 | format: string; 27 | primaries: string; 28 | transfer: string; 29 | bits: number; 30 | } 31 | 32 | export interface GltfValidatorReportInfoResourceInterface { 33 | pointer: string; 34 | mimeType: string; 35 | storage: string; 36 | byteLength?: number; 37 | image?: GltfValidatorReportInfoResourceImageInterface; 38 | } 39 | 40 | export interface GltfValidatorReportIssuesInterface { 41 | numErrors: number; 42 | numWarnings: number; 43 | numInfos: number; 44 | numHints: number; 45 | messages: Array; 46 | truncated: boolean; 47 | } 48 | 49 | export interface GltfValidatorReportIssuesMessageInterface { 50 | code: string; 51 | message: string; 52 | pointer: string; 53 | severity: number; 54 | } 55 | 56 | // Top Level Interface 57 | export interface GltfValidatorReportInterface { 58 | uri: string; 59 | mimeType: string; 60 | validatorVersion: string; 61 | validatedAt: string; 62 | issues: GltfValidatorReportIssuesInterface; 63 | info: GltfValidatorReportInfoInterface; 64 | } 65 | -------------------------------------------------------------------------------- /src/Image.ts: -------------------------------------------------------------------------------- 1 | import { GltfJsonImageInterface } from './GltfJson'; 2 | import { loadImage, Image as CanvasImage, createCanvas } from 'canvas'; 3 | 4 | export interface ImageInterface { 5 | canvasImage: CanvasImage; 6 | height: number; 7 | maxValue: number; 8 | minValue: number; 9 | mimeType: string; 10 | name: string; 11 | usedForBaseColor: boolean; // Identifies if it should be used in the PBR range test 12 | width: number; 13 | initFromBrowser(arrayBuffer: ArrayBuffer): Promise; 14 | init(buffer: Buffer): Promise; 15 | isPowerOfTwo(): boolean; 16 | isQuadratic(): boolean; 17 | } 18 | 19 | // 2D Image used for a texture 20 | // Note that CanvasImage is used because Babylon's NullEngine does not load images 21 | export class Image implements ImageInterface { 22 | canvasImage = undefined as unknown as CanvasImage; 23 | height = undefined as unknown as number; 24 | maxValue = undefined as unknown as number; 25 | minValue = undefined as unknown as number; 26 | mimeType = ''; 27 | name = ''; 28 | usedForBaseColor = false; 29 | width = undefined as unknown as number; 30 | 31 | constructor(imageJson: GltfJsonImageInterface) { 32 | this.name = imageJson.name; 33 | this.mimeType = imageJson.mimeType; 34 | // await this.init should be called externally to load the data 35 | } 36 | 37 | // constructor cannot be async, but we need to await loadImage 38 | public init = async (buffer: string | Buffer): Promise => { 39 | try { 40 | this.canvasImage = await loadImage(buffer); 41 | this.height = this.canvasImage.naturalHeight; 42 | this.width = this.canvasImage.naturalWidth; 43 | } catch (err) { 44 | console.log('error creating image from binary data'); 45 | console.log(err); 46 | } 47 | this.calculateColorValueMaxMin(); 48 | }; 49 | 50 | public initFromBrowser = async (arrayBuffer: ArrayBuffer): Promise => { 51 | // The browser does not have Buffer, so we need to get the image as a data uri 52 | const dataUri = await this.getDataUriFromArrayBuffer(arrayBuffer); 53 | await this.init(dataUri); 54 | }; 55 | 56 | public isPowerOfTwo(): boolean { 57 | return this.numberIsPowerOfTwo(this.height) && this.numberIsPowerOfTwo(this.width); 58 | } 59 | 60 | public isQuadratic(): boolean { 61 | return this.height === this.width; 62 | } 63 | 64 | /////////////////////// 65 | // PRIVATE FUNCTIONS // 66 | /////////////////////// 67 | private calculateColorValueMaxMin = () => { 68 | try { 69 | // create a canvas to write the pixels to 70 | const canvas = createCanvas(this.canvasImage.naturalWidth, this.canvasImage.naturalHeight); 71 | 72 | // draw the image on the canvas 73 | const ctx = canvas.getContext('2d'); 74 | ctx.drawImage(this.canvasImage, 0, 0, this.canvasImage.naturalWidth, this.canvasImage.naturalHeight); 75 | 76 | // read the pixels from the canvas 77 | const pixelData = ctx.getImageData(0, 0, this.canvasImage.naturalWidth, this.canvasImage.naturalHeight); 78 | 79 | // loop through the pixels to find the min/max values 80 | for (let i = 0; i < pixelData.data.length; i = i + 4) { 81 | // Stride length is 4: [R, G, B, A] 82 | const r = pixelData.data[4 * i + 0]; 83 | const g = pixelData.data[4 * i + 1]; 84 | const b = pixelData.data[4 * i + 2]; 85 | //const a = pixelData.data[4 * i + 3]; 86 | 87 | // Value in HSV is just the biggest channel 88 | const v = Math.max(r, g, b); 89 | if (this.maxValue === undefined || v > this.maxValue) { 90 | this.maxValue = v; 91 | } 92 | if (this.minValue === undefined || v < this.minValue) { 93 | this.minValue = v; 94 | } 95 | } 96 | } catch (err) { 97 | console.log('Error reading color values'); 98 | console.log(err); 99 | } 100 | }; 101 | 102 | private async getDataUriFromArrayBuffer(arrayBuffer: ArrayBuffer): Promise { 103 | return new Promise((resolve, reject) => { 104 | try { 105 | const reader = new FileReader(); 106 | reader.onload = function () { 107 | if (reader.result) { 108 | resolve(reader.result as string); 109 | } else { 110 | reject(); 111 | } 112 | }; 113 | reader.readAsDataURL(new Blob([arrayBuffer])); 114 | } catch (err) { 115 | reject(); 116 | } 117 | }); 118 | } 119 | 120 | // bitwise check that all trailing bits are 0 121 | private numberIsPowerOfTwo(n: number): boolean { 122 | // Power of two numbers are 0x100...00 123 | return n > 0 && (n & (n - 1)) === 0; 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/LoadableAttribute.ts: -------------------------------------------------------------------------------- 1 | export interface LoadableAttributeInterface { 2 | loaded: boolean; 3 | name: string; 4 | value: boolean | number | string; 5 | loadValue: (value: boolean | number | string) => void; 6 | } 7 | 8 | // Helper class to keep track of when values have been loaded 9 | export class LoadableAttribute implements LoadableAttributeInterface { 10 | loaded = false; 11 | name = ''; 12 | value = undefined as unknown as boolean | number | string; 13 | 14 | constructor(name: string, defaultValue: boolean | number | string) { 15 | this.name = name; 16 | this.value = defaultValue; 17 | } 18 | 19 | loadValue(value: boolean | number | string) { 20 | this.value = value; 21 | this.loaded = true; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/NodeTransform.ts: -------------------------------------------------------------------------------- 1 | import { LoadableAttribute, LoadableAttributeInterface } from './LoadableAttribute.js'; 2 | 3 | export interface Vector3LoadableAttributeInterface { 4 | x: LoadableAttributeInterface; 5 | y: LoadableAttributeInterface; 6 | z: LoadableAttributeInterface; 7 | } 8 | 9 | export interface NodeTransformInterface { 10 | location: Vector3LoadableAttributeInterface; 11 | rotation: Vector3LoadableAttributeInterface; 12 | scale: Vector3LoadableAttributeInterface; 13 | isClean: () => boolean; 14 | locationIsZero: () => boolean; 15 | rotationIsZero: () => boolean; 16 | scaleIsOne: () => boolean; 17 | } 18 | 19 | // Location, Rotation, and Scale data, used to check the Root Node 20 | export class NodeTransform implements NodeTransformInterface { 21 | location = { 22 | x: new LoadableAttribute('X Location', 0), 23 | y: new LoadableAttribute('Y Location', 0), 24 | z: new LoadableAttribute('Z Location', 0), 25 | }; 26 | rotation = { 27 | x: new LoadableAttribute('X Rotation', 0), 28 | y: new LoadableAttribute('Y Rotation', 0), 29 | z: new LoadableAttribute('Z Rotation', 0), 30 | }; 31 | scale = { 32 | x: new LoadableAttribute('X Scale', 1), 33 | y: new LoadableAttribute('Y Scale', 1), 34 | z: new LoadableAttribute('Z Scale', 1), 35 | }; 36 | 37 | isClean = () => { 38 | // Location should be 0,0,0 39 | // Rotation should be 0,0,0 40 | // Scale should be 1,1,1 41 | return this.locationIsZero() && this.rotationIsZero() && this.scaleIsOne(); 42 | }; 43 | 44 | locationIsZero = () => { 45 | return this.location.x.value === 0 && this.location.y.value === 0 && this.location.z.value === 0; 46 | }; 47 | rotationIsZero = () => { 48 | // Note: Rotation value appears to be 1/PI radians. 49 | return this.rotation.x.value === 0 && this.rotation.y.value === 0 && this.rotation.z.value === 0; 50 | }; 51 | scaleIsOne = () => { 52 | return this.scale.x.value === 1 && this.scale.y.value === 1 && this.scale.z.value === 1; 53 | }; 54 | } 55 | -------------------------------------------------------------------------------- /src/ProductInfo.ts: -------------------------------------------------------------------------------- 1 | import { LoadableAttribute, LoadableAttributeInterface } from './LoadableAttribute.js'; 2 | import { ProductInfoJSONInterface } from './ProductInfoJSON.js'; 3 | 4 | export interface ProductInfoInterface { 5 | height: LoadableAttributeInterface; 6 | length: LoadableAttributeInterface; 7 | width: LoadableAttributeInterface; 8 | loaded: boolean; 9 | getAttributes: () => LoadableAttributeInterface[]; 10 | loadFromFileInput(file: File): Promise; 11 | loadFromFileSystem(filepath: string): Promise; 12 | } 13 | 14 | // Product dimensions that can be provided from a database to check tolerances 15 | export class ProductInfo implements ProductInfoInterface { 16 | height = new LoadableAttribute('Product Height', -1); // -1 indicates not to test (default) 17 | length = new LoadableAttribute('Product Length', -1); // -1 indicates not to test (default) 18 | width = new LoadableAttribute('Product Width', -1); // -1 indicates not to test (default) 19 | 20 | loaded = false; 21 | 22 | getAttributes() { 23 | return [this.length, this.height, this.width]; 24 | } 25 | 26 | // (Browser) - The file comes from an element 27 | public async loadFromFileInput(file: File): Promise { 28 | const loader = new Promise((resolve, reject) => { 29 | const fileReader = new FileReader(); // FileReader is not available in node.js 30 | fileReader.onload = async function () { 31 | const schemaText = fileReader.result as string; 32 | const schemaData = JSON.parse(schemaText) as ProductInfoJSONInterface; 33 | // FileReader is not async be default, so this wrapper is needed. 34 | resolve(schemaData); 35 | }; 36 | fileReader.onerror = function (e) { 37 | reject(e); 38 | }; 39 | fileReader.readAsText(file); 40 | }); 41 | 42 | const obj = (await loader) as ProductInfoJSONInterface; 43 | this.loadFromProductInfoObject(obj); 44 | } 45 | 46 | // (Node.js) - The file comes from the file system 47 | public async loadFromFileSystem(filepath: string): Promise { 48 | // Need to import promises this way to compile webpack 49 | // webpack.config.js also needs config.resolve.fallback.fs = false 50 | const { promises } = await import('fs'); 51 | const schemaText = await promises.readFile(filepath, 'utf-8'); 52 | const obj = JSON.parse(schemaText) as ProductInfoJSONInterface; 53 | this.loadFromProductInfoObject(obj); 54 | } 55 | 56 | /////////////////////// 57 | // PRIVATE FUNCTIONS // 58 | /////////////////////// 59 | 60 | // Populate product info after reading the data from a file 61 | private loadFromProductInfoObject(obj: ProductInfoJSONInterface) { 62 | this.height.loadValue(obj.dimensions.height); 63 | this.length.loadValue(obj.dimensions.length); 64 | this.width.loadValue(obj.dimensions.width); 65 | this.loaded = true; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/ProductInfoJSON.ts: -------------------------------------------------------------------------------- 1 | // Product information that can be used for checking the dimensions 2 | export interface ProductInfoJSONInterface { 3 | dimensions: { 4 | length: number; 5 | width: number; 6 | height: number; 7 | }; 8 | } 9 | -------------------------------------------------------------------------------- /src/Report.ts: -------------------------------------------------------------------------------- 1 | import { ReportItem, ReportItemInterface } from './ReportItem.js'; 2 | 3 | export interface ReportInterface { 4 | fileSize: ReportItemInterface; 5 | gltfValidator: ReportItemInterface; 6 | materialCount: ReportItemInterface; 7 | meshCount: ReportItemInterface; 8 | nodeCount: ReportItemInterface; 9 | overallDimensionsWithinTolerance: ReportItemInterface; 10 | pbrColorMax: ReportItemInterface; 11 | pbrColorMin: ReportItemInterface; 12 | pixelsPerMeterMax: ReportItemInterface; 13 | pixelsPerMeterMin: ReportItemInterface; 14 | primitiveCount: ReportItemInterface; 15 | productDimensionsWithinTolerance: ReportItemInterface; 16 | requireBeveledEdges: ReportItemInterface; 17 | requireManifoldEdges: ReportItemInterface; 18 | rootNodeCleanTransform: ReportItemInterface; 19 | textureDimensionsMaxHeight: ReportItemInterface; 20 | textureDimensionsMaxWidth: ReportItemInterface; 21 | textureDimensionsMinHeight: ReportItemInterface; 22 | textureDimensionsMinWidth: ReportItemInterface; 23 | texturesPowerOfTwo: ReportItemInterface; 24 | texturesQuadratic: ReportItemInterface; 25 | triangleCount: ReportItemInterface; 26 | uvGutterWideEnough: ReportItemInterface; 27 | uvsInverted: ReportItemInterface; 28 | uvsInZeroToOneRange: ReportItemInterface; 29 | uvsOverlap: ReportItemInterface; 30 | getItems: () => ReportItemInterface[]; 31 | } 32 | 33 | // All of the checks that are available. Will either be PASS, FAIL, or NOT TESTED 34 | // Specifies a link to the Asset Auditor for more information about what the test is checking and why it is important. 35 | export class Report implements ReportInterface { 36 | fileSize = new ReportItem( 37 | 'File Size', 38 | 'https://github.com/KhronosGroup/3DC-Asset-Creation/blob/main/asset-creation-guidelines/full-version/sec01_FileFormatsAndAssetStructure/FileFormatsAndAssetStructure.md', 39 | ); 40 | gltfValidator = new ReportItem('glTF Validator', 'http://github.khronos.org/glTF-Validator/'); 41 | materialCount = new ReportItem( 42 | 'Material Count', 43 | 'https://github.com/KhronosGroup/3DC-Asset-Creation/blob/main/asset-creation-guidelines/full-version/sec05_MaterialsAndTextures/MaterialsAndTextures.md#multiple-materials-per-model', 44 | ); 45 | meshCount = new ReportItem( 46 | 'Mesh Count', 47 | 'https://github.com/KhronosGroup/3DC-Asset-Creation/blob/main/asset-creation-guidelines/full-version/sec99_PublishingTargets/PublishingTargets.md#maximum-number-of-draw-calls-and-triangles', 48 | ); 49 | nodeCount = new ReportItem( 50 | 'Node Count', 51 | 'https://github.com/KhronosGroup/3DC-Asset-Creation/blob/main/asset-creation-guidelines/full-version/sec99_PublishingTargets/PublishingTargets.md#maximum-number-of-draw-calls-and-triangles', 52 | ); 53 | overallDimensionsWithinTolerance = new ReportItem( 54 | 'Overall Dimensions', 55 | 'https://github.com/KhronosGroup/3DC-Asset-Creation/blob/main/asset-creation-guidelines/full-version/sec02_CoordinateSystemAndScaleUnit/CoordinateSystemAndScaleUnit.md', 56 | ); 57 | pbrColorMax = new ReportItem( 58 | 'Maximum HSV color value for PBR safe colors', 59 | 'https://github.com/KhronosGroup/3DC-Asset-Creation/blob/main/asset-creation-guidelines/full-version/sec05_MaterialsAndTextures/MaterialsAndTextures.md#pbr-colors-and-values', 60 | ); 61 | pbrColorMin = new ReportItem( 62 | 'Minimum HSV color value for PBR safe colors', 63 | 'https://github.com/KhronosGroup/3DC-Asset-Creation/blob/main/asset-creation-guidelines/full-version/sec05_MaterialsAndTextures/MaterialsAndTextures.md#pbr-colors-and-values', 64 | ); 65 | pixelsPerMeterMax = new ReportItem( 66 | 'Maximum Pixels per Meter', 67 | 'https://github.com/KhronosGroup/3DC-Asset-Creation/blob/main/asset-creation-guidelines/full-version/sec02_CoordinateSystemAndScaleUnit/CoordinateSystemAndScaleUnit.md', 68 | ); 69 | pixelsPerMeterMin = new ReportItem( 70 | 'Minimum Pixels per Meter', 71 | 'https://github.com/KhronosGroup/3DC-Asset-Creation/blob/main/asset-creation-guidelines/full-version/sec02_CoordinateSystemAndScaleUnit/CoordinateSystemAndScaleUnit.md', 72 | ); 73 | primitiveCount = new ReportItem( 74 | 'Primitive Count', 75 | 'https://github.com/KhronosGroup/3DC-Asset-Creation/blob/main/asset-creation-guidelines/full-version/sec99_PublishingTargets/PublishingTargets.md#maximum-number-of-draw-calls-and-triangles', 76 | ); 77 | productDimensionsWithinTolerance = new ReportItem( 78 | 'Dimensions Match Product', 79 | 'https://github.com/KhronosGroup/3DC-Asset-Creation/blob/main/asset-creation-guidelines/full-version/sec02_CoordinateSystemAndScaleUnit/CoordinateSystemAndScaleUnit.md', 80 | ); 81 | requireBeveledEdges = new ReportItem( 82 | 'Require Beveled Edges', 83 | 'https://github.com/KhronosGroup/3DC-Asset-Creation/blob/main/asset-creation-guidelines/full-version/sec03_Geometry/Geometry.md#topology--mesh-optimization', 84 | ); 85 | requireManifoldEdges = new ReportItem( 86 | 'Require Manifold Edges', 87 | 'https://github.com/KhronosGroup/3DC-Asset-Creation/blob/main/asset-creation-guidelines/full-version/sec03_Geometry/Geometry.md#watertight-vs-open-mesh-geometry', 88 | ); 89 | rootNodeCleanTransform = new ReportItem( 90 | 'Root Node has Clean Transform', 91 | 'https://github.com/KhronosGroup/3DC-Asset-Creation/blob/main/asset-creation-guidelines/full-version/sec03_Geometry/Geometry.md#best-practice', 92 | ); 93 | textureDimensionsMaxHeight = new ReportItem( 94 | 'Texture Height <= Max', 95 | 'https://github.com/KhronosGroup/3DC-Asset-Creation/blob/main/asset-creation-guidelines/full-version/sec99_PublishingTargets/PublishingTargets.md#3d-commerce-publishing-guidelines-v10', 96 | ); 97 | textureDimensionsMaxWidth = new ReportItem( 98 | 'Texture Width <= Max', 99 | 'https://github.com/KhronosGroup/3DC-Asset-Creation/blob/main/asset-creation-guidelines/full-version/sec99_PublishingTargets/PublishingTargets.md#3d-commerce-publishing-guidelines-v10', 100 | ); 101 | textureDimensionsMinHeight = new ReportItem( 102 | 'Texture Height >= Min', 103 | 'https://github.com/KhronosGroup/3DC-Asset-Creation/blob/main/asset-creation-guidelines/full-version/sec99_PublishingTargets/PublishingTargets.md#3d-commerce-publishing-guidelines-v10', 104 | ); 105 | textureDimensionsMinWidth = new ReportItem( 106 | 'Texture Width >= Min', 107 | 'https://github.com/KhronosGroup/3DC-Asset-Creation/blob/main/asset-creation-guidelines/full-version/sec99_PublishingTargets/PublishingTargets.md#3d-commerce-publishing-guidelines-v10', 108 | ); 109 | texturesPowerOfTwo = new ReportItem( 110 | 'Texture Dimensions are Powers of 2', 111 | 'https://github.com/KhronosGroup/3DC-Asset-Creation/blob/main/asset-creation-guidelines/full-version/sec05_MaterialsAndTextures/MaterialsAndTextures.md#powers-of-two', 112 | ); 113 | texturesQuadratic = new ReportItem( 114 | 'Texture Dimensions are Square (width=height)', 115 | 'https://github.com/KhronosGroup/3DC-Asset-Creation/blob/main/asset-creation-guidelines/full-version/sec05_MaterialsAndTextures/MaterialsAndTextures.md#texture-dimensions-square-vs-rectangular', 116 | ); 117 | triangleCount = new ReportItem( 118 | 'Triangle Count', 119 | 'https://github.com/KhronosGroup/3DC-Asset-Creation/blob/main/asset-creation-guidelines/full-version/sec03_Geometry/Geometry.md#polygonal-count', 120 | ); 121 | // TODO: A section explaining gutter width needs to be added to the guidelines and this link can be more specific. 122 | uvGutterWideEnough = new ReportItem( 123 | 'UV Gutter Wide Enough', 124 | 'https://github.com/KhronosGroup/3DC-Asset-Creation/blob/main/asset-creation-guidelines/full-version/sec04_UVCoordinates/UVCoordinates.md', 125 | ); 126 | uvsInverted = new ReportItem( 127 | 'Inverted UVs', 128 | 'https://github.com/KhronosGroup/3DC-Asset-Creation/blob/main/asset-creation-guidelines/full-version/sec04_UVCoordinates/UVCoordinates.md', 129 | ); 130 | uvsInZeroToOneRange = new ReportItem( 131 | 'UVs in 0 to 1 Range', 132 | 'https://github.com/KhronosGroup/3DC-Asset-Creation/blob/main/asset-creation-guidelines/full-version/sec04_UVCoordinates/UVCoordinates.md', 133 | ); 134 | uvsOverlap = new ReportItem( 135 | 'Overlapping UVs', 136 | 'https://github.com/KhronosGroup/3DC-Asset-Creation/blob/main/asset-creation-guidelines/full-version/sec04_UVCoordinates/UVCoordinates.md#overlapping-uvs-considerations-in-an-atlas-layout', 137 | ); 138 | 139 | // Return an iterable list of all the items 140 | getItems() { 141 | return [ 142 | this.gltfValidator, 143 | this.fileSize, 144 | this.triangleCount, 145 | this.materialCount, 146 | this.nodeCount, 147 | this.meshCount, 148 | this.primitiveCount, 149 | this.rootNodeCleanTransform, 150 | this.requireBeveledEdges, 151 | this.requireManifoldEdges, 152 | this.overallDimensionsWithinTolerance, 153 | this.productDimensionsWithinTolerance, 154 | this.pbrColorMax, 155 | this.pbrColorMin, 156 | this.textureDimensionsMaxHeight, 157 | this.textureDimensionsMinHeight, 158 | this.textureDimensionsMaxWidth, 159 | this.textureDimensionsMinWidth, 160 | this.texturesPowerOfTwo, 161 | this.texturesQuadratic, 162 | this.pixelsPerMeterMax, 163 | this.pixelsPerMeterMin, 164 | this.uvsInZeroToOneRange, 165 | this.uvsInverted, 166 | this.uvsOverlap, 167 | this.uvGutterWideEnough, 168 | ]; 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /src/ReportItem.ts: -------------------------------------------------------------------------------- 1 | export interface ReportItemInterface { 2 | componentMessage: string; 3 | guidelinesUrl: string; 4 | message: string; 5 | name: string; 6 | pass: boolean; 7 | tested: boolean; 8 | test: (passCondition: boolean) => void; 9 | } 10 | 11 | // Each item in the report which provides the test status as well as messages and a reference link 12 | export class ReportItem implements ReportItemInterface { 13 | componentMessage = ''; 14 | guidelinesUrl = ''; 15 | message = ''; 16 | name = ''; 17 | pass = false; 18 | tested = false; 19 | 20 | constructor(name: string, guidelinesUrl?: string) { 21 | this.guidelinesUrl = 22 | guidelinesUrl ?? 23 | 'https://github.com/KhronosGroup/3DC-Asset-Creation/blob/main/asset-creation-guidelines/RealtimeAssetCreationGuidelines.md'; 24 | this.name = name; 25 | } 26 | 27 | // Set the test results to PASS or FAIL with messages 28 | public test(passCondition: boolean, message?: string, componentMessage?: string) { 29 | this.componentMessage = componentMessage ?? ''; 30 | this.message = message ?? ''; 31 | this.pass = passCondition; 32 | this.tested = true; 33 | } 34 | 35 | // Do not run the test and leave the output as NOT TESTED 36 | public skipTestWithMessage(message: string) { 37 | this.message = message; 38 | this.tested = false; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/ReportJSON.ts: -------------------------------------------------------------------------------- 1 | export interface ReportJSONInterface { 2 | version: string; 3 | pass: boolean; 4 | gltfValidator: { 5 | errors: number; 6 | hints: number; 7 | info: number; 8 | pass: boolean; 9 | warnings: number; 10 | }; 11 | fileSizeInKb: { 12 | pass: boolean | null; 13 | tested: boolean; 14 | value: number | null; 15 | }; 16 | materialCount: { 17 | pass: boolean | null; 18 | tested: boolean; 19 | value: number | null; 20 | }; 21 | model: { 22 | objectCount: { 23 | nodes: { 24 | pass: boolean | null; 25 | tested: boolean; 26 | value: number | null; 27 | }; 28 | meshes: { 29 | pass: boolean | null; 30 | tested: boolean; 31 | value: number | null; 32 | }; 33 | primitives: { 34 | pass: boolean | null; 35 | tested: boolean; 36 | value: number | null; 37 | }; 38 | }; 39 | requireBeveledEdges: { 40 | pass: boolean | null; 41 | tested: boolean; 42 | }; 43 | requireCleanRootNodeTransform: { 44 | pass: boolean | null; 45 | tested: boolean; 46 | }; 47 | requireManifoldEdges: { 48 | pass: boolean | null; 49 | tested: boolean; 50 | }; 51 | triangles: { 52 | pass: boolean | null; 53 | tested: boolean; 54 | value: number | null; 55 | }; 56 | }; 57 | product: { 58 | overallDimensions: { 59 | pass: boolean | null; 60 | tested: boolean; 61 | height: { 62 | value: number | null; 63 | }; 64 | length: { 65 | value: number | null; 66 | }; 67 | width: { 68 | value: number | null; 69 | }; 70 | }; 71 | productDimensions: { 72 | pass: boolean | null; 73 | tested: boolean; 74 | height: { 75 | value: number | null; 76 | }; 77 | length: { 78 | value: number | null; 79 | }; 80 | width: { 81 | value: number | null; 82 | }; 83 | }; 84 | }; 85 | textures: { 86 | height: { 87 | maximum: { 88 | pass: boolean | null; 89 | tested: boolean; 90 | value: number | null; 91 | }; 92 | minimum: { 93 | pass: boolean | null; 94 | tested: boolean; 95 | value: number | null; 96 | }; 97 | }; 98 | pbrColorRange: { 99 | maximum: { 100 | pass: boolean | null; 101 | tested: boolean; 102 | value: number | null; 103 | }; 104 | minimum: { 105 | pass: boolean | null; 106 | tested: boolean; 107 | value: number | null; 108 | }; 109 | }; 110 | requireDimensionsBePowersOfTwo: { 111 | pass: boolean | null; 112 | tested: boolean; 113 | }; 114 | requireDimensionsBeQuadratic: { 115 | pass: boolean | null; 116 | tested: boolean; 117 | }; 118 | width: { 119 | maximum: { 120 | pass: boolean | null; 121 | tested: boolean; 122 | value: number | null; 123 | }; 124 | minimum: { 125 | pass: boolean | null; 126 | tested: boolean; 127 | value: number | null; 128 | }; 129 | }; 130 | }; 131 | uvs: { 132 | gutterWidth: { 133 | pass: boolean | null; 134 | tested: boolean; 135 | }; 136 | pixelsPerMeter: { 137 | maximum: { 138 | pass: boolean | null; 139 | tested: boolean; 140 | value: number; 141 | }; 142 | minimum: { 143 | pass: boolean | null; 144 | tested: boolean; 145 | value: number; 146 | }; 147 | }; 148 | requireNotInverted: { 149 | pass: boolean | null; 150 | tested: boolean; 151 | }; 152 | requireNotOverlapping: { 153 | pass: boolean | null; 154 | tested: boolean; 155 | }; 156 | requireRangeZeroToOne: { 157 | pass: boolean | null; 158 | tested: boolean; 159 | }; 160 | }; 161 | } 162 | 163 | // Returns the report as a JSON object that can be ingested by other automated systems 164 | export class ReportJSON implements ReportJSONInterface { 165 | version = ''; 166 | pass = false; 167 | gltfValidator = { 168 | errors: 0, 169 | hints: 0, 170 | info: 0, 171 | pass: false, 172 | warnings: 0, 173 | }; 174 | fileSizeInKb = { 175 | pass: null as unknown as boolean, 176 | tested: false, 177 | value: null as unknown as number, 178 | }; 179 | materialCount = { 180 | pass: null as unknown as boolean, 181 | tested: false, 182 | value: null as unknown as number, 183 | }; 184 | model = { 185 | objectCount: { 186 | nodes: { 187 | pass: null as unknown as boolean, 188 | tested: false, 189 | value: null as unknown as number, 190 | }, 191 | meshes: { 192 | pass: null as unknown as boolean, 193 | tested: false, 194 | value: null as unknown as number, 195 | }, 196 | primitives: { 197 | pass: null as unknown as boolean, 198 | tested: false, 199 | value: null as unknown as number, 200 | }, 201 | }, 202 | requireBeveledEdges: { 203 | pass: null as unknown as boolean, 204 | tested: false, 205 | }, 206 | requireCleanRootNodeTransform: { 207 | pass: null as unknown as boolean, 208 | tested: false, 209 | }, 210 | requireManifoldEdges: { 211 | pass: null as unknown as boolean, 212 | tested: false, 213 | }, 214 | triangles: { 215 | pass: null as unknown as boolean, 216 | tested: false, 217 | value: null as unknown as number, 218 | }, 219 | }; 220 | product = { 221 | overallDimensions: { 222 | pass: null as unknown as boolean, 223 | tested: false, 224 | height: { 225 | value: null as unknown as number, 226 | }, 227 | length: { 228 | value: null as unknown as number, 229 | }, 230 | width: { 231 | value: null as unknown as number, 232 | }, 233 | }, 234 | productDimensions: { 235 | pass: null as unknown as boolean, 236 | tested: false, 237 | height: { 238 | value: null as unknown as number, 239 | }, 240 | length: { 241 | value: null as unknown as number, 242 | }, 243 | width: { 244 | value: null as unknown as number, 245 | }, 246 | }, 247 | }; 248 | textures = { 249 | height: { 250 | maximum: { 251 | pass: null as unknown as boolean, 252 | tested: false, 253 | value: null as unknown as number, 254 | }, 255 | minimum: { 256 | pass: null as unknown as boolean, 257 | tested: false, 258 | value: null as unknown as number, 259 | }, 260 | }, 261 | pbrColorRange: { 262 | maximum: { 263 | pass: null as unknown as boolean, 264 | tested: false, 265 | value: null as unknown as number, 266 | }, 267 | minimum: { 268 | pass: null as unknown as boolean, 269 | tested: false, 270 | value: null as unknown as number, 271 | }, 272 | }, 273 | requireDimensionsBePowersOfTwo: { 274 | pass: null as unknown as boolean, 275 | tested: false, 276 | }, 277 | requireDimensionsBeQuadratic: { 278 | pass: null as unknown as boolean, 279 | tested: false, 280 | }, 281 | width: { 282 | maximum: { 283 | pass: null as unknown as boolean, 284 | tested: false, 285 | value: null as unknown as number, 286 | }, 287 | minimum: { 288 | pass: null as unknown as boolean, 289 | tested: false, 290 | value: null as unknown as number, 291 | }, 292 | }, 293 | }; 294 | uvs = { 295 | gutterWidth: { 296 | pass: null as unknown as boolean, 297 | tested: false, 298 | }, 299 | pixelsPerMeter: { 300 | maximum: { 301 | pass: null as unknown as boolean, 302 | tested: false, 303 | value: null as unknown as number, 304 | }, 305 | minimum: { 306 | pass: null as unknown as boolean, 307 | tested: false, 308 | value: null as unknown as number, 309 | }, 310 | }, 311 | requireNotInverted: { 312 | pass: null as unknown as boolean, 313 | tested: false, 314 | }, 315 | requireNotOverlapping: { 316 | pass: null as unknown as boolean, 317 | tested: false, 318 | }, 319 | requireRangeZeroToOne: { 320 | pass: null as unknown as boolean, 321 | tested: false, 322 | }, 323 | }; 324 | 325 | constructor( 326 | version: string, 327 | pass: boolean, 328 | gltfValidatorErrors: number, 329 | gltfValidatorHints: number, 330 | gltfValidatorInfo: number, 331 | gltfValidatorWarnings: number, 332 | ) { 333 | this.version = version; 334 | this.pass = pass; 335 | this.gltfValidator.errors = gltfValidatorErrors; 336 | this.gltfValidator.hints = gltfValidatorHints; 337 | this.gltfValidator.info = gltfValidatorInfo; 338 | this.gltfValidator.pass = gltfValidatorErrors === 0; 339 | this.gltfValidator.warnings = gltfValidatorWarnings; 340 | } 341 | } 342 | -------------------------------------------------------------------------------- /src/SchemaJSON.ts: -------------------------------------------------------------------------------- 1 | // This is the format for specifying a requirements schema. All values except version are optional. 2 | export interface SchemaJSONInterface { 3 | version: string; 4 | fileSizeInKb?: { 5 | maximum?: number; 6 | minimum?: number; 7 | }; 8 | materials?: { 9 | maximum?: number; 10 | minimum?: number; 11 | }; 12 | model?: { 13 | objectCount?: { 14 | nodes?: { 15 | maximum?: number; 16 | minimum?: number; 17 | }; 18 | meshes?: { 19 | maximum?: number; 20 | minimum?: number; 21 | }; 22 | primitives?: { 23 | maximum?: number; 24 | minimum?: number; 25 | }; 26 | }; 27 | requireBeveledEdges?: boolean; 28 | requireCleanRootNodeTransform?: boolean; 29 | requireManifoldEdges?: boolean; 30 | triangles?: { 31 | maximum?: number; 32 | minimum?: number; 33 | }; 34 | }; 35 | product?: { 36 | dimensions?: { 37 | height?: { 38 | maximum?: number; 39 | minimum?: number; 40 | percentTolerance?: number; 41 | }; 42 | length?: { 43 | maximum?: number; 44 | minimum?: number; 45 | percentTolerance?: number; 46 | }; 47 | width?: { 48 | maximum?: number; 49 | minimum?: number; 50 | percentTolerance?: number; 51 | }; 52 | }; 53 | }; 54 | textures?: { 55 | height?: { 56 | maximum?: number; 57 | minimum?: number; 58 | }; 59 | pbrColorRange?: { 60 | maximum?: number; 61 | minimum?: number; 62 | }; 63 | requireDimensionsBePowersOfTwo?: boolean; 64 | requireDimensionsBeQuadratic?: boolean; 65 | width?: { 66 | maximum?: number; 67 | minimum?: number; 68 | }; 69 | }; 70 | uvs?: { 71 | gutterWidth?: { 72 | resolution256?: number; 73 | resolution512?: number; 74 | resolution1024?: number; 75 | resolution2048?: number; 76 | resolution4096?: number; 77 | }; 78 | pixelsPerMeter?: { 79 | maximum?: number; 80 | minimum?: number; 81 | }; 82 | requireNotInverted?: boolean; 83 | requireNotOverlapping?: boolean; 84 | requireRangeZeroToOne?: boolean; 85 | }; 86 | } 87 | -------------------------------------------------------------------------------- /src/SquareUv.ts: -------------------------------------------------------------------------------- 1 | import { TriangleUvInterface } from './TriangleUv.js'; 2 | import VertexUv, { VertexUvInterface } from './VertexUv.js'; 3 | 4 | export interface SquareUvInterface { 5 | a: VertexUvInterface; 6 | b: VertexUvInterface; 7 | c: VertexUvInterface; 8 | d: VertexUvInterface; 9 | islandIndex: number; 10 | uCenter: number; 11 | uMax: number; 12 | uMin: number; 13 | vCenter: number; 14 | vMax: number; 15 | vMin: number; 16 | size: number; 17 | overlapsTriangle(triangle: TriangleUvInterface): boolean; 18 | vertexInside(point: VertexUvInterface): boolean; 19 | } 20 | 21 | // Represents a 2D square for a UV map 22 | // Created for a 2D pixel grid for gutter width testing 23 | export default class SquareUv implements SquareUvInterface { 24 | a = null as unknown as VertexUvInterface; 25 | b = null as unknown as VertexUvInterface; 26 | c = null as unknown as VertexUvInterface; 27 | d = null as unknown as VertexUvInterface; 28 | islandIndex = undefined as unknown as number; 29 | uCenter = undefined as unknown as number; 30 | uMax = undefined as unknown as number; 31 | uMin = undefined as unknown as number; 32 | vCenter = undefined as unknown as number; 33 | vMax = undefined as unknown as number; 34 | vMin = undefined as unknown as number; 35 | size = undefined as unknown as number; 36 | 37 | constructor(uCenter: number, vCenter: number, size: number) { 38 | // a---b 39 | // | + | 40 | // c---d 41 | this.size = size; 42 | this.uMax = uCenter + size / 2; 43 | this.uMin = uCenter - size / 2; 44 | this.vMax = vCenter + size / 2; 45 | this.vMin = vCenter - size / 2; 46 | this.a = new VertexUv(this.uMin, this.vMin); 47 | this.b = new VertexUv(this.uMax, this.vMin); 48 | this.c = new VertexUv(this.uMin, this.vMax); 49 | this.d = new VertexUv(this.uMax, this.vMax); 50 | } 51 | 52 | // Checks if this square overlaps a given triangle 53 | public overlapsTriangle(triangle: TriangleUvInterface): boolean { 54 | // Step 1 - check triangle bounding box 55 | if ( 56 | this.uMin >= triangle.maxU || // right 57 | this.uMax <= triangle.minU || // left 58 | this.vMin >= triangle.maxV || // above 59 | this.vMax <= triangle.minV // below 60 | ) { 61 | return false; // not overlapping 62 | } 63 | // Step 2 - check if any points are inside 64 | if (this.vertexInside(triangle.a) || this.vertexInside(triangle.b) || this.vertexInside(triangle.c)) { 65 | return true; // one or more points is inside 66 | } 67 | // Step 3 - Check if any edges intersect (4x3=12 checks) 68 | if ( 69 | triangle.lineIntersects(this.a, this.b) || 70 | triangle.lineIntersects(this.b, this.d) || 71 | triangle.lineIntersects(this.d, this.c) || 72 | triangle.lineIntersects(this.c, this.a) 73 | ) { 74 | return true; 75 | } 76 | return false; // made it here without finding an overlap 77 | } 78 | 79 | // Checks if a UV vertex is inside this square 80 | public vertexInside(point: VertexUvInterface): boolean { 81 | return this.pointInside(point.u, point.v); 82 | } 83 | 84 | /////////////////////// 85 | // PRIVATE FUNCTIONS // 86 | /////////////////////// 87 | 88 | // Checks if a point is inside this square 89 | private pointInside(u: number, v: number): boolean { 90 | return u < this.uMax && u > this.uMin && v < this.vMax && v > this.vMin; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/TriangleUv.ts: -------------------------------------------------------------------------------- 1 | import VertexUv, { VertexUvInterface } from './VertexUv.js'; 2 | import { Vector2 } from '@babylonjs/core'; 3 | 4 | export interface TriangleUvInterface { 5 | a: VertexUvInterface; 6 | area: number; 7 | b: VertexUvInterface; 8 | c: VertexUvInterface; 9 | id: number; 10 | inverted: boolean; 11 | islandIndex: number; 12 | maxU: number; 13 | maxV: number; 14 | minU: number; 15 | minV: number; 16 | overlapping: boolean; 17 | calculateIslandIndex(): void; 18 | lineIntersects(p1: VertexUvInterface, p2: VertexUvInterface): boolean; 19 | overlapsTriangle(triangle: TriangleUvInterface): boolean; 20 | vertexInside(point: VertexUvInterface): boolean; 21 | } 22 | 23 | // A 2D triangle of a UV map 24 | export default class TriangleUv implements TriangleUvInterface { 25 | a = null as unknown as VertexUvInterface; 26 | area = undefined as unknown as number; 27 | b = null as unknown as VertexUvInterface; 28 | c = null as unknown as VertexUvInterface; 29 | id = undefined as unknown as number; 30 | inverted = false; 31 | islandIndex = undefined as unknown as number; 32 | maxU = undefined as unknown as number; 33 | maxV = undefined as unknown as number; 34 | minU = undefined as unknown as number; 35 | minV = undefined as unknown as number; 36 | overlapping = false; 37 | 38 | constructor(id: number, a: VertexUv, b: VertexUv, c: VertexUv) { 39 | this.id = id; 40 | this.a = a; 41 | this.b = b; 42 | this.c = c; 43 | 44 | this.calculateArea(); 45 | this.calculateInverted(); 46 | this.loadMinMax(); 47 | } 48 | 49 | // Recursively groups itself into an island with other triangles it is connected with 50 | // The minimum vertex index is passed to all neighbors and the smallest one becomes the island index 51 | public calculateIslandIndex(): void { 52 | try { 53 | if (this.a.islandIndex === this.b.islandIndex && this.b.islandIndex === this.c.islandIndex) { 54 | this.islandIndex = this.a.islandIndex; 55 | // End of recursive propagation when all 3 vertices are the same 56 | return; 57 | } else { 58 | this.islandIndex = Math.min(this.a.islandIndex, this.b.islandIndex, this.c.islandIndex); 59 | if (this.a.islandIndex != this.islandIndex) { 60 | this.a.islandIndex = this.islandIndex; 61 | if (this.b.islandIndex === this.islandIndex && this.c.islandIndex === this.islandIndex) { 62 | // "tail" return should reduce recursion depth issues 63 | return this.a.computeIslandIndexForTriangles(); 64 | } else { 65 | this.a.computeIslandIndexForTriangles(); 66 | } 67 | } 68 | if (this.b.islandIndex != this.islandIndex) { 69 | this.b.islandIndex = this.islandIndex; 70 | if (this.c.islandIndex === this.islandIndex) { 71 | // "tail" return should reduce recursion depth issues 72 | return this.b.computeIslandIndexForTriangles(); 73 | } else { 74 | // c's triangles need to be checked too, so can't return here 75 | this.b.computeIslandIndexForTriangles(); 76 | } 77 | } 78 | if (this.c.islandIndex != this.islandIndex) { 79 | this.c.islandIndex = this.islandIndex; 80 | // "tail" return should reduce recursion depth issues 81 | return this.c.computeIslandIndexForTriangles(); 82 | } 83 | } 84 | } catch (err) { 85 | // The recursive function may exceed the stack depth if there are too many triangles 86 | throw new Error('Unable to merge UV triangles into islands. The model may be too complex'); 87 | } 88 | } 89 | 90 | // Check if a line intersects this triangle (any of its 3 edges) 91 | public lineIntersects(p1: VertexUvInterface, p2: VertexUvInterface): boolean { 92 | return ( 93 | TriangleUv.edgesIntersect(this.a, this.b, p1, p2) || 94 | TriangleUv.edgesIntersect(this.b, this.c, p1, p2) || 95 | TriangleUv.edgesIntersect(this.c, this.a, p1, p2) 96 | ); 97 | } 98 | 99 | // Check if another triangle overlaps this one 100 | public overlapsTriangle(otherTriangle: TriangleUvInterface): boolean { 101 | // Step 1 - skip if it is the same triangle (fastest) 102 | if (this.id === otherTriangle.id) { 103 | return false; // not overlapping 104 | } 105 | // Step 2 - skip any triangle with no area. ensures all 3 points are different 106 | if (this.area === 0 || otherTriangle.area === 0) { 107 | return false; // not overlapping 108 | } 109 | // Step 3 - rectangle check using min/max values from each (fast) 110 | if ( 111 | this.minU >= otherTriangle.maxU || // right 112 | this.maxU <= otherTriangle.minU || // left 113 | this.minV >= otherTriangle.maxV || // above 114 | this.maxV <= otherTriangle.minV // below 115 | ) { 116 | return false; // not overlapping 117 | } 118 | // Step 4 - check for shared points (order is not sorted, so 9 checks needed) 119 | // Vertex indices were already calculated and can be used for matching here 120 | const aMatchesA = this.a.index === otherTriangle.a.index; 121 | const aMatchesB = this.a.index === otherTriangle.b.index; 122 | const aMatchesC = this.a.index === otherTriangle.c.index; 123 | const bMatchesA = this.b.index === otherTriangle.a.index; 124 | const bMatchesB = this.b.index === otherTriangle.b.index; 125 | const bMatchesC = this.b.index === otherTriangle.c.index; 126 | const cMatchesA = this.c.index === otherTriangle.a.index; 127 | const cMatchesB = this.c.index === otherTriangle.b.index; 128 | const cMatchesC = this.c.index === otherTriangle.c.index; 129 | 130 | const aMatches = aMatchesA || aMatchesB || aMatchesC; 131 | const bMatches = bMatchesA || bMatchesB || bMatchesC; 132 | const cMatches = cMatchesA || cMatchesB || cMatchesC; 133 | const matchCount = (aMatches ? 1 : 0) + (bMatches ? 1 : 0) + (cMatches ? 1 : 0); 134 | 135 | if (matchCount === 3) { 136 | // (fast) 137 | return true; // vertex positions are the same 138 | } else if (matchCount === 2) { 139 | // (somewhat fast) 140 | // The non-matching points need to be on opposite sides of the edge to not overlap 141 | const edgeP1 = aMatches ? this.a : this.b; // if not a, it must be BC 142 | const edgeP2 = cMatches ? this.c : this.b; // if not c, it must be AB 143 | const point1 = !aMatches ? this.a : !bMatches ? this.b : this.c; // the one that doesn't match 144 | let point2 = otherTriangle.a; 145 | if (!aMatchesB && !bMatchesB && !cMatchesB) { 146 | point2 = otherTriangle.b; 147 | } else if (!aMatchesC && !bMatchesC && !cMatchesC) { 148 | point2 = otherTriangle.c; 149 | } 150 | 151 | // Linear equation to test which side of the line each point is on. Negative result is one side, positive is the other side 152 | const side1 = TriangleUv.isCounterClockwise(point1.u, point1.v, edgeP1.u, edgeP1.v, edgeP2.u, edgeP2.v); 153 | const side2 = TriangleUv.isCounterClockwise(point2.u, point2.v, edgeP1.u, edgeP1.v, edgeP2.u, edgeP2.v); 154 | 155 | // If both sides are the same (positive * positive) or (negative * negative), the value will be > 0 156 | if (side1 == side2) { 157 | return true; 158 | } else { 159 | return false; // not overlapping 160 | } 161 | } else if (matchCount === 1) { 162 | // (somewhat slow) 163 | const commonPoint = aMatches ? this.a : bMatches ? this.b : this.c; 164 | const point1 = !aMatches ? this.a : this.b; // if a is the common point, points are [b and c] 165 | const point2 = !cMatches ? this.c : this.b; // if c is the common point, points are [a and b] 166 | // start with the assumption that other C is the common point, so check [a and b] 167 | let otherPoint1 = otherTriangle.a; 168 | let otherPoint2 = otherTriangle.b; 169 | if (aMatchesA || bMatchesA || cMatchesA) { 170 | // A is the common point, so check [b and c] 171 | otherPoint1 = otherTriangle.b; 172 | otherPoint2 = otherTriangle.c; 173 | } else if (aMatchesB || bMatchesB || cMatchesB) { 174 | // B is the common point, so check [a and c] 175 | otherPoint1 = otherTriangle.a; 176 | otherPoint2 = otherTriangle.c; 177 | } else { 178 | // C is the common point, so check [a and b] (defaults) 179 | } 180 | 181 | // 4a. Check if either point is inside the other triangle 182 | if ( 183 | this.vertexInside(otherPoint1) || 184 | this.vertexInside(otherPoint2) || 185 | otherTriangle.vertexInside(point1) || 186 | otherTriangle.vertexInside(point2) 187 | ) { 188 | return true; 189 | } 190 | 191 | // 4b. Check for edge intersections 192 | // For each triangle, check the edge with the non-shared vertex against the two edges that are shared 193 | if ( 194 | TriangleUv.edgesIntersect(commonPoint, otherPoint1, point1, point2) || 195 | TriangleUv.edgesIntersect(commonPoint, otherPoint2, point1, point2) || 196 | TriangleUv.edgesIntersect(commonPoint, point1, otherPoint1, otherPoint2) || 197 | TriangleUv.edgesIntersect(commonPoint, point2, otherPoint1, otherPoint2) 198 | ) { 199 | return true; 200 | } 201 | return false; // not overlapping 202 | } 203 | 204 | // Step 5 - check if any of the 3 vertices are inside the other (same as 4a, but with 6 checks) 205 | if ( 206 | this.vertexInside(otherTriangle.a) || 207 | this.vertexInside(otherTriangle.b) || 208 | this.vertexInside(otherTriangle.c) || 209 | otherTriangle.vertexInside(this.a) || 210 | otherTriangle.vertexInside(this.b) || 211 | otherTriangle.vertexInside(this.c) 212 | ) { 213 | return true; 214 | } 215 | 216 | // Step 6 - check for edge intersects (same as 4b, but with 9 checks) 217 | if ( 218 | TriangleUv.edgesIntersect(this.a, this.b, otherTriangle.a, otherTriangle.b) || 219 | TriangleUv.edgesIntersect(this.b, this.c, otherTriangle.a, otherTriangle.b) || 220 | TriangleUv.edgesIntersect(this.c, this.a, otherTriangle.a, otherTriangle.b) || 221 | TriangleUv.edgesIntersect(this.a, this.b, otherTriangle.b, otherTriangle.c) || 222 | TriangleUv.edgesIntersect(this.b, this.c, otherTriangle.b, otherTriangle.c) || 223 | TriangleUv.edgesIntersect(this.c, this.a, otherTriangle.b, otherTriangle.c) || 224 | TriangleUv.edgesIntersect(this.a, this.b, otherTriangle.c, otherTriangle.a) || 225 | TriangleUv.edgesIntersect(this.b, this.c, otherTriangle.c, otherTriangle.a) || 226 | TriangleUv.edgesIntersect(this.c, this.a, otherTriangle.c, otherTriangle.a) 227 | ) { 228 | return true; 229 | } 230 | return false; // make it here without finding an overlap 231 | } 232 | 233 | // Check if a vertex is inside this triangle 234 | public vertexInside(point: VertexUvInterface): boolean { 235 | return this.pointInside(point.u, point.v); 236 | } 237 | 238 | /////////////////////// 239 | // PRIVATE FUNCTIONS // 240 | /////////////////////// 241 | 242 | // Compute the UV area using Heron's formula 243 | private calculateArea() { 244 | // Note: units are a percentage of the 0-1 UV area. They get converted to pixels per meter later 245 | // V2: if the image texture dimensions were available here, this could be pixels per UV space 246 | const uvA = new Vector2(this.a.u, this.a.v); 247 | const uvB = new Vector2(this.b.u, this.b.v); 248 | const uvC = new Vector2(this.c.u, this.c.v); 249 | const uvAB = Vector2.Distance(uvA, uvB); 250 | const uvAC = Vector2.Distance(uvA, uvC); 251 | const uvBC = Vector2.Distance(uvB, uvC); 252 | const uvHalfPerimeter = (uvAB + uvBC + uvAC) / 2; 253 | this.area = Math.sqrt( 254 | uvHalfPerimeter * (uvHalfPerimeter - uvAB) * (uvHalfPerimeter - uvBC) * (uvHalfPerimeter - uvAC), 255 | ); 256 | } 257 | 258 | // Check inversion based on winding direction 259 | private calculateInverted() { 260 | // https://stackoverflow.com/questions/17592800/how-to-find-the-orientation-of-three-points-in-a-two-dimensional-space-given-coo 261 | this.inverted = TriangleUv.isCounterClockwise(this.a.u, this.a.v, this.b.u, this.b.v, this.c.u, this.c.v); 262 | } 263 | 264 | // Check if two edges intersect, based on 2 points each 265 | private static edgesIntersect( 266 | p1: VertexUvInterface, 267 | p2: VertexUvInterface, 268 | q1: VertexUvInterface, 269 | q2: VertexUvInterface, 270 | ) { 271 | //https://bryceboe.com/2006/10/23/line-segment-intersection-algorithm/ 272 | return ( 273 | TriangleUv.isCounterClockwise(p1.u, p1.v, q1.u, q1.v, q2.u, q2.v) != 274 | TriangleUv.isCounterClockwise(p2.u, p2.v, q1.u, q1.v, q2.u, q2.v) && 275 | TriangleUv.isCounterClockwise(p1.u, p1.v, p2.u, p2.v, q1.u, q1.v) != 276 | TriangleUv.isCounterClockwise(p1.u, p1.v, p2.u, p2.v, q2.u, q2.v) 277 | ); 278 | } 279 | 280 | // Checks if the winding direction is counter clockwise 281 | private static isCounterClockwise(p1u: number, p1v: number, p2u: number, p2v: number, p3u: number, p3v: number) { 282 | return (p3v - p1v) * (p2u - p1u) > (p2v - p1v) * (p3u - p1u); 283 | } 284 | 285 | // Get the min/max UV values, which is later used to check if all triangles are in the 0-1 range 286 | private loadMinMax() { 287 | this.maxU = Math.max(this.a.u, this.b.u, this.c.u); 288 | this.maxV = Math.max(this.a.v, this.b.v, this.c.v); 289 | this.minU = Math.min(this.a.u, this.b.u, this.c.u); 290 | this.minV = Math.min(this.a.v, this.b.v, this.c.v); 291 | } 292 | 293 | // Check if a point is inside this triangle 294 | private pointInside(u: number, v: number): boolean { 295 | // https://stackoverflow.com/questions/2049582/how-to-determine-if-a-point-is-in-a-2d-triangle 296 | // https://www.gamedev.net/forums/topic.asp?topic_id=295943 297 | const b1 = TriangleUv.isCounterClockwise(u, v, this.a.u, this.a.v, this.b.u, this.b.v); 298 | const b2 = TriangleUv.isCounterClockwise(u, v, this.b.u, this.b.v, this.c.u, this.c.v); 299 | const b3 = TriangleUv.isCounterClockwise(u, v, this.c.u, this.c.v, this.a.u, this.a.v); 300 | return b1 == b2 && b2 == b3; 301 | } 302 | } 303 | -------------------------------------------------------------------------------- /src/TriangleXyz.ts: -------------------------------------------------------------------------------- 1 | // This represents a 3D triangle of a primitive 2 | import { TriangleUvInterface } from './TriangleUv.js'; 3 | import { VertexXyzInterface } from './VertexXyz.js'; 4 | import { Vector3 } from '@babylonjs/core'; 5 | 6 | export interface TriangleXyzInterface { 7 | a: VertexXyzInterface; 8 | area: number; 9 | b: VertexXyzInterface; 10 | c: VertexXyzInterface; 11 | normal: Vector3; 12 | uv: TriangleUvInterface; 13 | } 14 | 15 | // A 3D triangle in the primitive mesh 16 | export default class TriangleXyz implements TriangleXyzInterface { 17 | a = null as unknown as VertexXyzInterface; 18 | area = 0; 19 | b = null as unknown as VertexXyzInterface; 20 | c = null as unknown as VertexXyzInterface; 21 | normal = null as unknown as Vector3; 22 | uv = null as unknown as TriangleUvInterface; 23 | 24 | constructor(a: VertexXyzInterface, b: VertexXyzInterface, c: VertexXyzInterface) { 25 | this.a = a; 26 | this.b = b; 27 | this.c = c; 28 | this.calculateArea(); 29 | this.calculateNormal(); 30 | } 31 | 32 | /////////////////////// 33 | // PRIVATE FUNCTIONS // 34 | /////////////////////// 35 | 36 | // Compute the area using Heron's formula 37 | private calculateArea() { 38 | const positionA = new Vector3(this.a.x, this.a.y, this.a.z); 39 | const positionB = new Vector3(this.b.x, this.b.y, this.b.z); 40 | const positionC = new Vector3(this.c.x, this.c.y, this.c.z); 41 | const positionAB = Vector3.Distance(positionA, positionB); 42 | const positionAC = Vector3.Distance(positionA, positionC); 43 | const positionBC = Vector3.Distance(positionB, positionC); 44 | const positionHalfPerimeter = (positionAB + positionBC + positionAC) / 2; 45 | this.area = Math.sqrt( 46 | positionHalfPerimeter * 47 | (positionHalfPerimeter - positionAB) * 48 | (positionHalfPerimeter - positionBC) * 49 | (positionHalfPerimeter - positionAC), 50 | ); 51 | } 52 | 53 | // Calculate the normal vector, which is used to get edge angles for the Hard Edge check 54 | private calculateNormal() { 55 | const positionBminusA = new Vector3(this.b.x - this.a.x, this.b.y - this.a.y, this.b.z - this.a.z); 56 | const positionCminusA = new Vector3(this.c.x - this.a.x, this.c.y - this.a.y, this.c.z - this.a.z); 57 | this.normal = Vector3.Normalize(Vector3.Cross(positionBminusA, positionCminusA)); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/UV.ts: -------------------------------------------------------------------------------- 1 | import { EdgeUvInterface } from './EdgeUv.js'; 2 | import { LoadableAttribute, LoadableAttributeInterface } from './LoadableAttribute.js'; 3 | import SquareUv, { SquareUvInterface } from './SquareUv.js'; 4 | import { TriangleUvInterface } from './TriangleUv.js'; 5 | import UvIsland, { UvIslandInterface } from './UvIsland.js'; 6 | import { VertexUvInterface } from './VertexUv.js'; 7 | 8 | // Group min/max into a single object. Only used in this file for now. 9 | interface MaxMinLoadableAttributeInterface { 10 | max: LoadableAttributeInterface; 11 | min: LoadableAttributeInterface; 12 | } 13 | 14 | export interface UVInterface { 15 | edges: EdgeUvInterface[]; 16 | invertedTriangleCount: LoadableAttributeInterface; 17 | islands: UvIslandInterface[]; 18 | name: string; 19 | overlapCount: LoadableAttributeInterface; 20 | triangles: TriangleUvInterface[]; 21 | pixelGrid: SquareUvInterface[]; 22 | u: MaxMinLoadableAttributeInterface; 23 | v: MaxMinLoadableAttributeInterface; 24 | vertices: VertexUvInterface[]; 25 | isInRangeZeroToOne: () => boolean; 26 | hasEnoughMarginAtResolution: (resolution: number) => boolean; 27 | } 28 | 29 | // Data related to the UV map for a primitive 30 | export class UV implements UVInterface { 31 | edges = [] as EdgeUvInterface[]; 32 | invertedTriangleCount = new LoadableAttribute('Number of inverted triangles', 0); 33 | islands = [] as UvIslandInterface[]; 34 | name = ''; 35 | overlapCount = new LoadableAttribute('Number of overlapping triangles', 0); 36 | triangles = [] as TriangleUvInterface[]; 37 | pixelGrid = [] as SquareUvInterface[]; 38 | u = { 39 | max: new LoadableAttribute('Max U value', 0), 40 | min: new LoadableAttribute('Min U value', 0), 41 | }; 42 | v = { 43 | max: new LoadableAttribute('Max V value', 0), 44 | min: new LoadableAttribute('Min V value', 0), 45 | }; 46 | vertices = [] as VertexUvInterface[]; 47 | 48 | constructor(name: string, triangles: TriangleUvInterface[], uvIndicesAvailable: boolean) { 49 | this.name = name; 50 | this.triangles = triangles; 51 | 52 | this.calculateInvertedTriangleCount(); 53 | this.calculateMaxMinExtents(); 54 | if (uvIndicesAvailable) { 55 | // These functions depend upon the UV vertices having pre-computed indices, which is slow and only available when required 56 | this.calculateUvIslands(this.triangles); 57 | this.calculateOverlapCount(); 58 | } 59 | } 60 | 61 | // Check that UV values are in the 0-1 range, which is desired for atlas textures 62 | public isInRangeZeroToOne = () => { 63 | return ( 64 | (this.u.max.value as number) <= 1 && 65 | (this.u.min.value as number) >= 0 && 66 | (this.v.max.value as number) <= 1 && 67 | (this.v.min.value as number) >= 0 68 | ); 69 | }; 70 | 71 | // Check the island margin for a given grid size 72 | public hasEnoughMarginAtResolution = (resolution: number): boolean => { 73 | // Quantize the UV area based on the given resolution in pixels. 74 | // If a pixel grid is overlapped more than once, there is a collision and therefore not enough margin 75 | // [+][+][+][+] 76 | // [+][+][+][+] 77 | // [+][+][+][+] 78 | // [+][+][+][+] 79 | if (resolution < 0) { 80 | // safety check that resolution is not negative 81 | return false; 82 | } 83 | this.pixelGrid = new Array(resolution * resolution); 84 | const pixelSize = 1 / resolution; 85 | for (let i = 0; i < this.pixelGrid.length; i++) { 86 | const row = Math.floor(i / resolution); 87 | const column = i % resolution; 88 | const uCenter = row * pixelSize + pixelSize / 2; 89 | const vCenter = column * pixelSize + pixelSize / 2; 90 | this.pixelGrid[i] = new SquareUv(uCenter, vCenter, pixelSize * 2); 91 | // Pixel size is 2x the grid spacing to catch cases where triangles are separated by a grid line 92 | // a---b 93 | // |[+]| 94 | // c---d 95 | 96 | // a---b 97 | // |[+]|+][+][+] 98 | // c---d+][+][+] 99 | // [+][+][+][+] 100 | // [+][+][+][+] 101 | 102 | // [+a---b+][+] 103 | // [+|[+]|+][+] 104 | // [+c---d+][+] 105 | // [+][+][+][+] 106 | 107 | // Without up-scaling, close triangles separated at a grid line boundary (such as 0.5) wouldn't be caught 108 | // +--+ | +--+ 109 | // | / | \ | 110 | // |/ | \| 111 | // + 0.5 + 112 | } 113 | 114 | // check each triangle for overlaps 115 | this.triangles.forEach((triangle: TriangleUvInterface) => { 116 | // only check pixels within the triangle's min/max (+ margin) 117 | let gridXStart = Math.floor((triangle.minU - pixelSize / 2) * resolution); 118 | if (gridXStart < 0) { 119 | gridXStart = 0; 120 | } 121 | let gridXEnd = Math.ceil((triangle.maxU + pixelSize / 2) * resolution); 122 | if (gridXEnd > resolution) { 123 | gridXEnd = resolution; 124 | } 125 | let gridYStart = Math.floor((triangle.minV - pixelSize / 2) * resolution); 126 | if (gridYStart < 0) { 127 | gridYStart = 0; 128 | } 129 | let gridYEnd = Math.ceil((triangle.maxV + pixelSize / 2) * resolution); 130 | if (gridYEnd > resolution) { 131 | gridYEnd = resolution; 132 | } 133 | for (let i = gridXStart; i < gridXEnd; i++) { 134 | for (let j = gridYStart; j < gridYEnd; j++) { 135 | const index = i * resolution + j; 136 | const gridPixel = this.pixelGrid[index]; 137 | if (gridPixel.overlapsTriangle(triangle)) { 138 | if (gridPixel.islandIndex === undefined) { 139 | gridPixel.islandIndex = triangle.islandIndex; 140 | } else if (gridPixel.islandIndex != triangle.islandIndex) { 141 | // A collision was found, no need to continue checking 142 | return false; 143 | } 144 | } 145 | } 146 | } 147 | }); 148 | 149 | return true; // made it here without finding a collision 150 | }; 151 | 152 | /////////////////////// 153 | // PRIVATE FUNCTIONS // 154 | /////////////////////// 155 | 156 | // Add up all triangles that are inverted 157 | private calculateInvertedTriangleCount = () => { 158 | let invertedTriangles = 0; 159 | this.triangles.forEach((triangle: TriangleUvInterface) => { 160 | if (triangle.inverted) { 161 | invertedTriangles++; 162 | } 163 | }); 164 | this.invertedTriangleCount.loadValue(invertedTriangles); 165 | }; 166 | 167 | // Find the min/max U and V values 168 | private calculateMaxMinExtents = () => { 169 | let maxU = undefined as unknown as number; 170 | let maxV = undefined as unknown as number; 171 | let minU = undefined as unknown as number; 172 | let minV = undefined as unknown as number; 173 | 174 | // loop through all triangles and record the min and the max 175 | this.triangles.forEach((triangle: TriangleUvInterface) => { 176 | if (maxU === undefined || triangle.maxU > maxU) { 177 | maxU = triangle.maxU; 178 | } 179 | if (maxV === undefined || triangle.maxV > maxV) { 180 | maxV = triangle.maxV; 181 | } 182 | if (minU === undefined || triangle.minU < minU) { 183 | minU = triangle.minU; 184 | } 185 | if (minV === undefined || triangle.minV < minV) { 186 | minV = triangle.minV; 187 | } 188 | }); 189 | 190 | this.u.max.loadValue(maxU); 191 | this.v.max.loadValue(maxV); 192 | this.u.min.loadValue(minU); 193 | this.v.min.loadValue(minV); 194 | }; 195 | 196 | // Test each triangle against each other looking for overlaps 197 | private calculateOverlapCount = () => { 198 | // This can be slow for large models. O(n*n) 199 | this.triangles.forEach((triangle: TriangleUvInterface) => { 200 | this.triangles.forEach((triangleToCompare: TriangleUvInterface) => { 201 | if (triangle.overlapsTriangle(triangleToCompare)) { 202 | triangle.overlapping = true; 203 | triangleToCompare.overlapping = true; 204 | } 205 | }); 206 | }); 207 | let overlappingTrianglesCount = 0; 208 | this.triangles.forEach((triangle: TriangleUvInterface) => { 209 | if (triangle.overlapping) { 210 | overlappingTrianglesCount++; 211 | } 212 | }); 213 | this.overlapCount.loadValue(overlappingTrianglesCount); 214 | }; 215 | 216 | // Group triangles into UV islands (with the island indices already known) 217 | private calculateUvIslands = (triangles: TriangleUvInterface[]) => { 218 | triangles.forEach((triangle: TriangleUvInterface) => { 219 | let existingIsland = false; 220 | this.islands.forEach((island: UvIslandInterface) => { 221 | if (island.index === triangle.islandIndex) { 222 | existingIsland = true; 223 | island.triangles.push(triangle); 224 | } 225 | }); 226 | if (!existingIsland) { 227 | this.islands.push(new UvIsland(triangle)); 228 | } 229 | }); 230 | }; 231 | } 232 | -------------------------------------------------------------------------------- /src/UvIsland.ts: -------------------------------------------------------------------------------- 1 | import { TriangleUvInterface } from './TriangleUv.js'; 2 | 3 | export interface UvIslandInterface { 4 | index: number; 5 | triangles: TriangleUvInterface[]; 6 | } 7 | 8 | // A group of triangles that are connected by shared vertices 9 | export default class UvIsland implements UvIslandInterface { 10 | index = undefined as unknown as number; 11 | triangles = [] as TriangleUvInterface[]; 12 | 13 | constructor(triangle: TriangleUvInterface) { 14 | this.index = triangle.islandIndex; 15 | this.triangles = [triangle]; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/VertexUv.ts: -------------------------------------------------------------------------------- 1 | import { EdgeUvInterface } from './EdgeUv'; 2 | import { TriangleUvInterface } from './TriangleUv'; 3 | 4 | export interface VertexUvInterface { 5 | edges: EdgeUvInterface[]; 6 | index: number; 7 | islandIndex: number; 8 | u: number; 9 | v: number; 10 | triangles: TriangleUvInterface[]; 11 | checkForMatch(vertex: VertexUvInterface): boolean; 12 | computeIslandIndexForTriangles(): void; 13 | setIndex(index: number): void; 14 | } 15 | 16 | // A 2D point for the UV map 17 | export default class VertexUv implements VertexUvInterface { 18 | edges = [] as EdgeUvInterface[]; 19 | index = undefined as unknown as number; 20 | islandIndex = undefined as unknown as number; 21 | triangles = [] as TriangleUvInterface[]; 22 | u = undefined as unknown as number; 23 | v = undefined as unknown as number; 24 | 25 | constructor(u: number, v: number) { 26 | // edges are set externally 27 | // index and island index initialized externally with setIndex 28 | // triangles are set externally 29 | this.u = u; 30 | this.v = v; 31 | } 32 | 33 | // Check if this and another vertex are in the same location 34 | public checkForMatch(vertex: VertexUvInterface): boolean { 35 | // Note: I found that on a complex mesh, blender's UV unwrap created some vertices in the same location that were +/- 0.000001 36 | // This resulted in missed matches with precision = 6 using Math.round 37 | // Example) 38 | // 0.918 134 510 517 120 4 : Rounds To >> 0.918 135 39 | // 0.918 134 450 912 475 6 : Rounds To >> 0.918 134 40 | // 0.000 000 059 604 644 8 is the difference (which is 2^-24) 41 | // This pointing to reaching the limit of floating point precision for 32-bit numbers 42 | // IEEE 754 32-bit floats have 23 bits in the significand (2^-23) 43 | // 2^-24 precision for a biased exponent of -1 44 | // Due to edge cases like the above, precision of 5 is used, which is still a very small difference 45 | const precision = 5; 46 | const e = 10 ** precision; // don't use caret ^ because it's XOR 47 | if (Math.round(vertex.u * e) == Math.round(this.u * e) && Math.round(vertex.v * e) == Math.round(this.v * e)) { 48 | return true; 49 | } 50 | return false; 51 | } 52 | 53 | // This recursive function sets triangle and vertex island indices to the smallest index of all connected vertices 54 | public computeIslandIndexForTriangles(): void { 55 | for (let i = 0; i < this.triangles.length; i++) { 56 | this.triangles[i].calculateIslandIndex(); 57 | } 58 | } 59 | 60 | // Set the initial index and make the island index the same 61 | public setIndex(index: number): void { 62 | this.index = index; 63 | // The island index starts initially the same as the vertex 64 | this.islandIndex = index; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/VertexXyz.ts: -------------------------------------------------------------------------------- 1 | export interface VertexXyzInterface { 2 | index: number; 3 | x: number; 4 | y: number; 5 | z: number; 6 | checkForMatch(vertex: VertexXyzInterface): boolean; 7 | } 8 | 9 | // 3D point for the mesh 10 | export default class VertexXyz implements VertexXyzInterface { 11 | index = undefined as unknown as number; 12 | x = undefined as unknown as number; 13 | y = undefined as unknown as number; 14 | z = undefined as unknown as number; 15 | 16 | constructor(x: number, y: number, z: number) { 17 | this.x = x; 18 | this.y = y; 19 | this.z = z; 20 | } 21 | 22 | // Check if this an another vertex are in the same location 23 | public checkForMatch(vertex: VertexXyzInterface): boolean { 24 | // See VertexUv.checkForMatch for an explanation of why this value is 5 25 | const precision = 5; 26 | const e = 10 ** precision; // don't use caret ^ because it's XOR 27 | if ( 28 | Math.round(vertex.x * e) == Math.round(this.x * e) && 29 | Math.round(vertex.y * e) == Math.round(this.y * e) && 30 | Math.round(vertex.z * e) == Math.round(this.z * e) 31 | ) { 32 | return true; 33 | } 34 | return false; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /tests/auditor.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { Auditor } from '../src/Auditor.js'; 3 | 4 | describe('auditor', function () { 5 | const a = new Auditor(); 6 | 7 | describe('version', function () { 8 | it('should match the current version', function () { 9 | expect(a.version).to.equal('1.0.2'); 10 | }); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /tests/blender/default-cube-bad-transform.blend: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KhronosGroup/gltf-asset-auditor/659e8724d56505954a3b48a4f1628b4b0ad4cbe8/tests/blender/default-cube-bad-transform.blend -------------------------------------------------------------------------------- /tests/blender/default-cube-bad-uvs.blend: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KhronosGroup/gltf-asset-auditor/659e8724d56505954a3b48a4f1628b4b0ad4cbe8/tests/blender/default-cube-bad-uvs.blend -------------------------------------------------------------------------------- /tests/blender/default-cube-beveled.blend: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KhronosGroup/gltf-asset-auditor/659e8724d56505954a3b48a4f1628b4b0ad4cbe8/tests/blender/default-cube-beveled.blend -------------------------------------------------------------------------------- /tests/blender/default-cube-density.blend: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KhronosGroup/gltf-asset-auditor/659e8724d56505954a3b48a4f1628b4b0ad4cbe8/tests/blender/default-cube-density.blend -------------------------------------------------------------------------------- /tests/blender/default-cube-failing.blend: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KhronosGroup/gltf-asset-auditor/659e8724d56505954a3b48a4f1628b4b0ad4cbe8/tests/blender/default-cube-failing.blend -------------------------------------------------------------------------------- /tests/blender/default-cube-multi-material.blend: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KhronosGroup/gltf-asset-auditor/659e8724d56505954a3b48a4f1628b4b0ad4cbe8/tests/blender/default-cube-multi-material.blend -------------------------------------------------------------------------------- /tests/blender/default-cube-no-materials.blend: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KhronosGroup/gltf-asset-auditor/659e8724d56505954a3b48a4f1628b4b0ad4cbe8/tests/blender/default-cube-no-materials.blend -------------------------------------------------------------------------------- /tests/blender/default-cube-non-manifold.blend: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KhronosGroup/gltf-asset-auditor/659e8724d56505954a3b48a4f1628b4b0ad4cbe8/tests/blender/default-cube-non-manifold.blend -------------------------------------------------------------------------------- /tests/blender/default-cube-passing.blend: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KhronosGroup/gltf-asset-auditor/659e8724d56505954a3b48a4f1628b4b0ad4cbe8/tests/blender/default-cube-passing.blend -------------------------------------------------------------------------------- /tests/blender/default-cube-pbr-safe-colors.blend: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KhronosGroup/gltf-asset-auditor/659e8724d56505954a3b48a4f1628b4b0ad4cbe8/tests/blender/default-cube-pbr-safe-colors.blend -------------------------------------------------------------------------------- /tests/blender/default-cube-pbr-unsafe-colors.blend: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KhronosGroup/gltf-asset-auditor/659e8724d56505954a3b48a4f1628b4b0ad4cbe8/tests/blender/default-cube-pbr-unsafe-colors.blend -------------------------------------------------------------------------------- /tests/blender/default-cube-uv-margin-grid-aligned.blend: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KhronosGroup/gltf-asset-auditor/659e8724d56505954a3b48a4f1628b4b0ad4cbe8/tests/blender/default-cube-uv-margin-grid-aligned.blend -------------------------------------------------------------------------------- /tests/blender/default-cube-uv-margin.blend: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KhronosGroup/gltf-asset-auditor/659e8724d56505954a3b48a4f1628b4b0ad4cbe8/tests/blender/default-cube-uv-margin.blend -------------------------------------------------------------------------------- /tests/blender/default-cube-uv-overlaps.blend: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KhronosGroup/gltf-asset-auditor/659e8724d56505954a3b48a4f1628b4b0ad4cbe8/tests/blender/default-cube-uv-overlaps.blend -------------------------------------------------------------------------------- /tests/blender/monkey-uv-margin.blend: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KhronosGroup/gltf-asset-auditor/659e8724d56505954a3b48a4f1628b4b0ad4cbe8/tests/blender/monkey-uv-margin.blend -------------------------------------------------------------------------------- /tests/cleanTransform.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { Auditor } from '../src/Auditor.js'; 3 | 4 | describe('clean transform passing report', function () { 5 | const a = new Auditor(); 6 | 7 | before('load model', async function () { 8 | try { 9 | await a.model.loadFromFileSystem(['tests/models/blender-default-cube-passing.glb']); 10 | } catch (err) { 11 | throw new Error('Unable to load test model: blender-default-cube-passing.glb'); 12 | } 13 | }); 14 | 15 | describe('no clean transform check', function () { 16 | before('load schema', async function () { 17 | try { 18 | await a.schema.loadFromFileSystem('tests/schemas/clean-transform/clean-transform-not-required.json'); 19 | } catch (err) { 20 | throw new Error('Unable to load test schema: clean-transform-not-required.json'); 21 | } 22 | await a.generateReport(); 23 | }); 24 | it('should report not required, but indicate pass in the message', function () { 25 | expect(a.reportReady).to.be.true; 26 | expect(a.report.rootNodeCleanTransform.tested).to.be.false; 27 | expect(a.report.rootNodeCleanTransform.message).to.equal('true'); 28 | }); 29 | }); 30 | 31 | describe('root node has clean transform', function () { 32 | before('load schema', async function () { 33 | try { 34 | await a.schema.loadFromFileSystem('tests/schemas/clean-transform/clean-transform-required.json'); 35 | } catch (err) { 36 | throw new Error('Unable to load test schema: clean-transform-required.json'); 37 | } 38 | await a.generateReport(); 39 | }); 40 | it('should pass having a clean root node transform', function () { 41 | expect(a.reportReady).to.be.true; 42 | expect(a.report.rootNodeCleanTransform.tested).to.be.true; 43 | expect(a.report.rootNodeCleanTransform.pass).to.be.true; 44 | expect(a.report.rootNodeCleanTransform.message).to.equal(''); 45 | }); 46 | }); 47 | }); 48 | 49 | describe('root node clean transform failing report', function () { 50 | const a = new Auditor(); 51 | 52 | before('load model', async function () { 53 | try { 54 | await a.model.loadFromFileSystem(['tests/models/blender-default-cube-bad-transform.glb']); 55 | } catch (err) { 56 | throw new Error('Unable to load test model: blender-default-cube-bad-transform.glb'); 57 | } 58 | }); 59 | 60 | describe('root node does not have a clean transform when it is required', function () { 61 | before('load schema', async function () { 62 | try { 63 | await a.schema.loadFromFileSystem('tests/schemas/clean-transform/clean-transform-required.json'); 64 | } catch (err) { 65 | throw new Error('Unable to load test schema: clean-transform-required.json'); 66 | } 67 | await a.generateReport(); 68 | }); 69 | it('should report the root node not having a clean transform', function () { 70 | expect(a.reportReady).to.be.true; 71 | expect(a.report.rootNodeCleanTransform.tested).to.be.true; 72 | expect(a.report.rootNodeCleanTransform.pass).to.be.false; 73 | expect(a.report.rootNodeCleanTransform.message).to.equal( 74 | 'Location: (1.000,1.000,-1.000) Rotation: (0.172,0.122,0.110) Scale: (1.412,1.412,1.412)', 75 | ); 76 | }); 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /tests/edges.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { Auditor } from '../src/Auditor.js'; 3 | 4 | describe('no hard edges on beveled cube', function () { 5 | const a = new Auditor(); 6 | 7 | before('load beveled cube', async function () { 8 | try { 9 | await a.model.loadFromFileSystem(['tests/models/blender-default-cube-beveled.glb']); 10 | } catch (err) { 11 | throw new Error('Unable to load test model: blender-default-cube-beveled.glb'); 12 | } 13 | }); 14 | describe('loaded', function () { 15 | it('should load the blender-default-cube-beveled', function () { 16 | expect(a.model.loaded).to.be.true; 17 | }); 18 | }); 19 | describe('hard edges', function () { 20 | before('load schema', async function () { 21 | try { 22 | await a.schema.loadFromFileSystem('tests/schemas/edges/beveled-edges-required.json'); 23 | } catch (err) { 24 | throw new Error('Unable to load test schema: beveled-edges-required.json'); 25 | } 26 | await a.generateReport(); 27 | }); 28 | it('should be zero', function () { 29 | expect(a.model.hardEdgeCount.value as number).to.equal(0); 30 | expect(a.schema.requireBeveledEdges.value).to.be.true; 31 | expect(a.report.requireBeveledEdges.tested).to.be.true; 32 | expect(a.report.requireBeveledEdges.pass).to.be.true; 33 | expect(a.report.requireBeveledEdges.message).to.equal('0 hard edges (>= 90 degrees)'); 34 | }); 35 | }); 36 | }); 37 | 38 | describe('edges not beveled on default cube', function () { 39 | const a = new Auditor(); 40 | 41 | before('load schema', async function () { 42 | try { 43 | await a.schema.loadFromFileSystem('tests/schemas/edges/beveled-edges-required.json'); 44 | } catch (err) { 45 | throw new Error('Unable to load test schema: beveled-edges-required.json'); 46 | } 47 | }); 48 | 49 | describe('hard edges', function () { 50 | before('load beveled cube', async function () { 51 | try { 52 | await a.model.loadFromFileSystem(['tests/models/blender-default-cube-passing.glb']); 53 | await a.generateReport(); 54 | } catch (err) { 55 | throw new Error('Unable to load test model: blender-default-cube-passing.glb'); 56 | } 57 | }); 58 | it('should fail for having hard edges', function () { 59 | expect(a.schema.checksRequireXyzIndices).to.be.true; 60 | expect(a.model.hardEdgeCount.value as number).to.equal(12); 61 | expect(a.schema.requireBeveledEdges.value).to.be.true; 62 | expect(a.report.requireBeveledEdges.tested).to.be.true; 63 | expect(a.report.requireBeveledEdges.pass).to.be.false; 64 | expect(a.report.requireBeveledEdges.message).to.equal('12 hard edges (>= 90 degrees)'); 65 | }); 66 | }); 67 | }); 68 | 69 | describe('non-manifold edges on non-manifold cube', function () { 70 | const a = new Auditor(); 71 | 72 | // load the schema first so indices and edges will be calculated 73 | before('load schema', async function () { 74 | try { 75 | await a.schema.loadFromFileSystem('tests/schemas/edges/must-be-manifold.json'); 76 | } catch (err) { 77 | throw new Error('Unable to load test schema: must-be-manifold.json'); 78 | } 79 | }); 80 | 81 | describe('non-manifold edges', function () { 82 | before('load non-manifold cube', async function () { 83 | try { 84 | await a.model.loadFromFileSystem(['tests/models/blender-default-cube-non-manifold.glb']); 85 | await a.generateReport(); 86 | } catch (err) { 87 | throw new Error('Unable to load test model: blender-default-cube-non-manifold.glb'); 88 | } 89 | }); 90 | it('should be 6', function () { 91 | expect(a.schema.checksRequireXyzIndices).to.be.true; 92 | expect(a.model.nonManifoldEdgeCount.value as number).to.equal(6); 93 | expect(a.schema.requireManifoldEdges.value).to.be.true; 94 | expect(a.report.requireManifoldEdges.tested).to.be.true; 95 | expect(a.report.requireManifoldEdges.pass).to.be.false; 96 | expect(a.report.requireManifoldEdges.message).to.equal('6 non-manifold edges'); 97 | }); 98 | }); 99 | }); 100 | 101 | describe('no non-manifold edges on default cube', function () { 102 | const a = new Auditor(); 103 | 104 | before('load beveled cube', async function () { 105 | try { 106 | await a.model.loadFromFileSystem(['tests/models/blender-default-cube-passing.glb']); 107 | } catch (err) { 108 | throw new Error('Unable to load test model: blender-default-cube-passing.glb'); 109 | } 110 | }); 111 | describe('loaded', function () { 112 | it('should load the blender-default-cube-passing', function () { 113 | expect(a.model.loaded).to.be.true; 114 | }); 115 | }); 116 | describe('hard edges', function () { 117 | before('load schema', async function () { 118 | try { 119 | await a.schema.loadFromFileSystem('tests/schemas/edges/must-be-manifold.json'); 120 | } catch (err) { 121 | throw new Error('Unable to load test schema: must-be-manifold.json'); 122 | } 123 | await a.generateReport(); 124 | }); 125 | it('should be zero', function () { 126 | expect(a.model.nonManifoldEdgeCount.value as number).to.equal(0); 127 | expect(a.schema.requireManifoldEdges.value).to.be.true; 128 | expect(a.report.requireManifoldEdges.tested).to.be.true; 129 | expect(a.report.requireManifoldEdges.pass).to.be.true; 130 | expect(a.report.requireManifoldEdges.message).to.equal('0 non-manifold edges'); 131 | }); 132 | }); 133 | }); 134 | -------------------------------------------------------------------------------- /tests/fileSize.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { Auditor } from '../src/Auditor.js'; 3 | 4 | describe('file size passing report', function () { 5 | const a = new Auditor(); 6 | 7 | before('load model', async function () { 8 | try { 9 | await a.model.loadFromFileSystem(['tests/models/blender-default-cube-passing.glb']); 10 | } catch (err) { 11 | throw new Error('Unable to load test model: blender-default-cube-passing.glb'); 12 | } 13 | }); 14 | 15 | describe('no file size checks', function () { 16 | before('load schema', async function () { 17 | try { 18 | await a.schema.loadFromFileSystem('tests/schemas/file-size/file-size-no-check.json'); 19 | } catch (err) { 20 | throw new Error('Unable to load test schema: file-size-no-check.json'); 21 | } 22 | await a.generateReport(); 23 | }); 24 | it('should report not tested, but have the file size in the message', function () { 25 | expect(a.schema.maxFileSizeInKb.value).to.equal(-1); 26 | expect(a.schema.minFileSizeInKb.value).to.equal(-1); 27 | expect(a.reportReady).to.be.true; 28 | expect(a.report.fileSize.tested).to.be.false; 29 | expect(a.report.fileSize.message).to.equal('2kb'); 30 | }); 31 | }); 32 | 33 | describe('max file size', function () { 34 | before('load schema', async function () { 35 | try { 36 | await a.schema.loadFromFileSystem('tests/schemas/file-size/file-size-no-min-pass.json'); 37 | } catch (err) { 38 | throw new Error('Unable to load test schema: file-size-no-min-pass.json'); 39 | } 40 | await a.generateReport(); 41 | }); 42 | it('should report being under the max file size', function () { 43 | expect(a.schema.minFileSizeInKb.value).to.equal(-1); 44 | expect(a.reportReady).to.be.true; 45 | expect(a.report.fileSize.tested).to.be.true; 46 | expect(a.report.fileSize.pass).to.be.true; 47 | expect(a.report.fileSize.message).to.equal('2kb <= 5,120kb'); 48 | }); 49 | }); 50 | 51 | describe('min file size', function () { 52 | before('load schema', async function () { 53 | try { 54 | await a.schema.loadFromFileSystem('tests/schemas/file-size/file-size-no-max-pass.json'); 55 | } catch (err) { 56 | throw new Error('Unable to load test schema: file-size-no-max-pass.json'); 57 | } 58 | await a.generateReport(); 59 | }); 60 | it('should report being over the min file size', function () { 61 | expect(a.schema.maxFileSizeInKb.value).to.equal(-1); 62 | expect(a.reportReady).to.be.true; 63 | expect(a.report.fileSize.tested).to.be.true; 64 | expect(a.report.fileSize.pass).to.be.true; 65 | expect(a.report.fileSize.message).to.equal('2kb >= 1kb'); 66 | }); 67 | }); 68 | 69 | describe('file size within range', function () { 70 | before('load schema', async function () { 71 | try { 72 | await a.schema.loadFromFileSystem('tests/schemas/file-size/file-size-within-range-pass.json'); 73 | } catch (err) { 74 | throw new Error('Unable to load test schema: file-size-within-range-pass.json'); 75 | } 76 | await a.generateReport(); 77 | }); 78 | it('should report being within range (min-max)', function () { 79 | expect(a.reportReady).to.be.true; 80 | expect(a.report.fileSize.tested).to.be.true; 81 | expect(a.report.fileSize.pass).to.be.true; 82 | expect(a.report.fileSize.message).to.equal('1kb <= 2kb <= 5,120kb'); 83 | }); 84 | }); 85 | }); 86 | 87 | describe('file size failing report', function () { 88 | const a = new Auditor(); 89 | 90 | before('load model', async function () { 91 | try { 92 | await a.model.loadFromFileSystem(['tests/models/blender-default-cube-failing.glb']); 93 | } catch (err) { 94 | throw new Error('Unable to load test model: blender-default-cube-failing.glb'); 95 | } 96 | }); 97 | 98 | describe('max file size', function () { 99 | before('load schema', async function () { 100 | try { 101 | await a.schema.loadFromFileSystem('tests/schemas/file-size/file-size-no-min-fail.json'); 102 | } catch (err) { 103 | throw new Error('Unable to load test schema: file-size-no-min-fail.json'); 104 | } 105 | await a.generateReport(); 106 | }); 107 | it('should report being over the max file size', function () { 108 | expect(a.schema.minFileSizeInKb.value).to.equal(-1); 109 | expect(a.reportReady).to.be.true; 110 | expect(a.report.fileSize.tested).to.be.true; 111 | expect(a.report.fileSize.pass).to.be.false; 112 | expect(a.report.fileSize.message).to.equal('13kb > 1kb'); 113 | }); 114 | }); 115 | 116 | describe('min file size', function () { 117 | before('load schema', async function () { 118 | try { 119 | await a.schema.loadFromFileSystem('tests/schemas/file-size/file-size-no-max-fail.json'); 120 | } catch (err) { 121 | throw new Error('Unable to load test schema: file-size-no-max-fail.json'); 122 | } 123 | await a.generateReport(); 124 | }); 125 | it('should report being under the min file size', function () { 126 | expect(a.schema.maxFileSizeInKb.value).to.equal(-1); 127 | expect(a.reportReady).to.be.true; 128 | expect(a.report.fileSize.tested).to.be.true; 129 | expect(a.report.fileSize.pass).to.be.false; 130 | expect(a.report.fileSize.message).to.equal('13kb < 1,024kb'); 131 | }); 132 | }); 133 | 134 | describe('file size out of range', function () { 135 | before('load schema', async function () { 136 | try { 137 | await a.schema.loadFromFileSystem('tests/schemas/file-size/file-size-within-range-fail.json'); 138 | } catch (err) { 139 | throw new Error('Unable to load test schema: file-size-within-range-fail.json'); 140 | } 141 | await a.generateReport(); 142 | }); 143 | it('should report being out of range (min-max)', function () { 144 | expect(a.reportReady).to.be.true; 145 | expect(a.report.fileSize.tested).to.be.true; 146 | expect(a.report.fileSize.pass).to.be.false; 147 | expect(a.report.fileSize.message).to.equal('13kb > 2kb'); 148 | }); 149 | }); 150 | }); 151 | -------------------------------------------------------------------------------- /tests/gltfNoTextures.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { Auditor } from '../src/Auditor.js'; 3 | 4 | describe('gltf model with no textures', function () { 5 | const a = new Auditor(); 6 | 7 | before('load model', async function () { 8 | try { 9 | await a.model.loadFromFileSystem(['tests/models/box.gltf', 'tests/models/Box0.bin']); 10 | } catch (err) { 11 | throw new Error('Unable to load test model: box.gltf'); 12 | } 13 | }); 14 | 15 | describe('load and run a passing audit', function () { 16 | before('load schema', async function () { 17 | try { 18 | await a.schema.loadFromFileSystem('tests/schemas/pass.json'); 19 | } catch (err) { 20 | throw new Error('Unable to load test schema: pass.json'); 21 | } 22 | await a.generateReport(); 23 | }); 24 | it('should pass all checks', function () { 25 | expect(a.reportReady).to.be.true; 26 | expect(a.report.gltfValidator.tested).to.be.true; 27 | expect(a.report.gltfValidator.pass).to.be.true; 28 | expect(a.report.fileSize.tested).to.be.true; 29 | expect(a.report.fileSize.pass).to.be.true; 30 | expect(a.report.triangleCount.tested).to.be.true; 31 | expect(a.report.triangleCount.pass).to.be.true; 32 | expect(a.report.materialCount.tested).to.be.true; 33 | expect(a.report.materialCount.pass).to.be.true; 34 | expect(a.report.nodeCount.tested).to.be.true; 35 | expect(a.report.nodeCount.pass).to.be.true; 36 | expect(a.report.meshCount.tested).to.be.true; 37 | expect(a.report.meshCount.pass).to.be.true; 38 | expect(a.report.primitiveCount.tested).to.be.true; 39 | expect(a.report.primitiveCount.pass).to.be.true; 40 | expect(a.report.rootNodeCleanTransform.tested).to.be.true; 41 | expect(a.report.rootNodeCleanTransform.pass).to.be.true; 42 | expect(a.report.requireManifoldEdges.tested).to.be.true; 43 | expect(a.report.requireManifoldEdges.pass).to.be.true; 44 | expect(a.report.overallDimensionsWithinTolerance.tested).to.be.true; 45 | expect(a.report.overallDimensionsWithinTolerance.pass).to.be.true; 46 | }); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /tests/materialCount.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { Auditor } from '../src/Auditor.js'; 3 | 4 | describe('material count passing report', function () { 5 | const a = new Auditor(); 6 | 7 | before('load model', async function () { 8 | try { 9 | await a.model.loadFromFileSystem(['tests/models/blender-default-cube-passing.glb']); 10 | } catch (err) { 11 | throw new Error('Unable to load test model: blender-default-cube-passing.glb'); 12 | } 13 | }); 14 | 15 | describe('no material count checks', function () { 16 | before('load schema', async function () { 17 | try { 18 | await a.schema.loadFromFileSystem('tests/schemas/material-count/material-count-no-check.json'); 19 | } catch (err) { 20 | throw new Error('Unable to load test schema: material-count-no-check.json'); 21 | } 22 | await a.generateReport(); 23 | }); 24 | it('should report not tested, but have the material count in the message', function () { 25 | expect(a.schema.maxMaterialCount.value).to.equal(-1); 26 | expect(a.model.materialCount.value).to.equal(1); 27 | expect(a.reportReady).to.be.true; 28 | expect(a.report.materialCount.tested).to.be.false; 29 | expect(a.report.materialCount.message).to.equal('1'); 30 | }); 31 | }); 32 | 33 | describe('max material count with no minimum', function () { 34 | before('load schema', async function () { 35 | try { 36 | await a.schema.loadFromFileSystem('tests/schemas/material-count/material-count-no-min-pass.json'); 37 | } catch (err) { 38 | throw new Error('Unable to load test schema: material-count-no-min-pass.json'); 39 | } 40 | await a.generateReport(); 41 | }); 42 | it('should report being under the max material count', function () { 43 | expect(a.schema.maxMaterialCount.value).to.equal(5); 44 | expect(a.model.materialCount.value).to.equal(1); 45 | expect(a.reportReady).to.be.true; 46 | expect(a.report.materialCount.tested).to.be.true; 47 | expect(a.report.materialCount.pass).to.be.true; 48 | expect(a.report.materialCount.message).to.equal('1 <= 5'); 49 | }); 50 | }); 51 | }); 52 | 53 | describe('material count failing report', function () { 54 | const a = new Auditor(); 55 | 56 | before('load model', async function () { 57 | try { 58 | await a.model.loadFromFileSystem(['tests/models/blender-default-cube-failing.glb']); 59 | } catch (err) { 60 | throw new Error('Unable to load test model: blender-default-cube-failing.glb'); 61 | } 62 | }); 63 | 64 | describe('max material count with no minimum', function () { 65 | before('load schema', async function () { 66 | try { 67 | await a.schema.loadFromFileSystem('tests/schemas/material-count/material-count-no-min-fail.json'); 68 | } catch (err) { 69 | throw new Error('Unable to load test schema: material-count-no-min-fail.json'); 70 | } 71 | await a.generateReport(); 72 | }); 73 | it('should report being over the max material count', function () { 74 | expect(a.schema.maxMaterialCount.value).to.equal(1); 75 | expect(a.model.materialCount.value).to.equal(3); 76 | expect(a.reportReady).to.be.true; 77 | expect(a.report.materialCount.tested).to.be.true; 78 | expect(a.report.materialCount.pass).to.be.false; 79 | expect(a.report.materialCount.message).to.equal('3 > 1'); 80 | }); 81 | }); 82 | }); 83 | 84 | describe('material count - no materials - passing', function () { 85 | const a = new Auditor(); 86 | 87 | before('load model', async function () { 88 | try { 89 | await a.model.loadFromFileSystem(['tests/models/blender-default-cube-no-materials.glb']); 90 | } catch (err) { 91 | throw new Error('Unable to load test model: blender-default-cube-no-materials.glb'); 92 | } 93 | }); 94 | 95 | describe('max material count with no minimum', function () { 96 | before('load schema', async function () { 97 | try { 98 | await a.schema.loadFromFileSystem('tests/schemas/material-count/material-count-no-min-pass.json'); 99 | } catch (err) { 100 | throw new Error('Unable to load test schema: material-count-no-min-pass.json'); 101 | } 102 | await a.generateReport(); 103 | }); 104 | it('should report being under the max material count', function () { 105 | expect(a.schema.maxMaterialCount.value).to.equal(5); 106 | expect(a.model.materialCount.value).to.equal(0); 107 | expect(a.reportReady).to.be.true; 108 | expect(a.report.materialCount.tested).to.be.true; 109 | expect(a.report.materialCount.pass).to.be.true; 110 | expect(a.report.materialCount.message).to.equal('0 <= 5'); 111 | }); 112 | }); 113 | }); 114 | 115 | describe('material count - no materials - failing', function () { 116 | const a = new Auditor(); 117 | 118 | before('load model', async function () { 119 | try { 120 | await a.model.loadFromFileSystem(['tests/models/blender-default-cube-no-materials.glb']); 121 | } catch (err) { 122 | throw new Error('Unable to load test model: blender-default-cube-no-materials.glb'); 123 | } 124 | }); 125 | 126 | describe('min material count with no maximum', function () { 127 | before('load schema', async function () { 128 | try { 129 | await a.schema.loadFromFileSystem('tests/schemas/material-count/material-count-no-max-fail.json'); 130 | } catch (err) { 131 | throw new Error('Unable to load test schema: material-count-no-max-fail.json'); 132 | } 133 | await a.generateReport(); 134 | }); 135 | it('should report being under the material count', function () { 136 | expect(a.schema.minMaterialCount.value).to.equal(4); 137 | expect(a.model.materialCount.value).to.equal(0); 138 | expect(a.reportReady).to.be.true; 139 | expect(a.report.materialCount.tested).to.be.true; 140 | expect(a.report.materialCount.pass).to.be.false; 141 | expect(a.report.materialCount.message).to.equal('0 < 4'); 142 | }); 143 | }); 144 | }); 145 | -------------------------------------------------------------------------------- /tests/model.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { Auditor } from '../src/Auditor.js'; 3 | 4 | describe('loading passing model', function () { 5 | const a = new Auditor(); 6 | 7 | before('load model', async function () { 8 | try { 9 | await a.model.loadFromFileSystem(['tests/models/blender-default-cube-passing.glb']); 10 | } catch (err) { 11 | throw new Error('Unable to load test model: blender-default-cube-passing.glb'); 12 | } 13 | }); 14 | describe('loaded', function () { 15 | it('should load the blender-default-cube-passing', function () { 16 | expect(a.model.loaded).to.be.true; 17 | }); 18 | }); 19 | describe('file size', function () { 20 | it('should match the blender-default-cube-passing file size of 2kb', function () { 21 | expect(a.model.fileSizeInKb.value as number).to.equal(2); // test cube is currenly only 2kb, but this will change once adding some materials 22 | }); 23 | }); 24 | describe('triangle count', function () { 25 | it('should match the blender-default-cube-passing triangle count of 12', function () { 26 | expect(a.model.triangleCount.value as number).to.equal(12); 27 | }); 28 | }); 29 | describe('material count', function () { 30 | it('should match the blender-default-cube-passing material count of 1', function () { 31 | expect(a.model.materialCount.value as number).to.equal(1); 32 | }); 33 | }); 34 | describe('length', function () { 35 | it('should match the blender-default-cube-passing length of 2m', function () { 36 | expect(a.model.length.value as number).to.equal(2); 37 | }); 38 | }); 39 | describe('width', function () { 40 | it('should match the blender-default-cube-passing width of 2m', function () { 41 | expect(a.model.width.value as number).to.equal(2); 42 | }); 43 | }); 44 | describe('height', function () { 45 | it('should match the blender-default-cube-passing height of 2m', function () { 46 | expect(a.model.height.value as number).to.equal(2); 47 | }); 48 | }); 49 | }); 50 | 51 | describe('loading failing model', function () { 52 | const a = new Auditor(); 53 | 54 | before('load model', async function () { 55 | try { 56 | await a.model.loadFromFileSystem(['tests/models/blender-default-cube-failing.glb']); 57 | } catch (err) { 58 | throw new Error('Unable to load test model: blender-default-cube-failing.glb'); 59 | } 60 | }); 61 | describe('loaded', function () { 62 | it('should load the blender-default-cube-failing model', function () { 63 | expect(a.model.loaded).to.be.true; 64 | }); 65 | }); 66 | describe('triangle count', function () { 67 | it('should match the blender-default-cube-failing triangle count of 12', function () { 68 | expect(a.model.triangleCount.value as number).to.equal(12); 69 | }); 70 | }); 71 | describe('material count', function () { 72 | it('should match the blender-default-cube-failing material count of 3', function () { 73 | expect(a.model.materialCount.value as number).to.equal(3); 74 | }); 75 | }); 76 | describe('length', function () { 77 | it('should match the blender-default-cube-failing length of 12m', function () { 78 | expect(a.model.length.value as number).to.equal(12); 79 | }); 80 | }); 81 | describe('width', function () { 82 | it('should match the blender-default-cube-failing width of 12m', function () { 83 | expect(a.model.width.value as number).to.equal(12); 84 | }); 85 | }); 86 | describe('height', function () { 87 | it('should match the blender-default-cube-failing height of 0.2m', function () { 88 | expect(a.model.height.value as number).to.equal(0.2); 89 | }); 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /tests/models/Box0.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KhronosGroup/gltf-asset-auditor/659e8724d56505954a3b48a4f1628b4b0ad4cbe8/tests/models/Box0.bin -------------------------------------------------------------------------------- /tests/models/blender-default-cube-20cm.glb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KhronosGroup/gltf-asset-auditor/659e8724d56505954a3b48a4f1628b4b0ad4cbe8/tests/models/blender-default-cube-20cm.glb -------------------------------------------------------------------------------- /tests/models/blender-default-cube-20m.glb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KhronosGroup/gltf-asset-auditor/659e8724d56505954a3b48a4f1628b4b0ad4cbe8/tests/models/blender-default-cube-20m.glb -------------------------------------------------------------------------------- /tests/models/blender-default-cube-2m-10x-scale.glb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KhronosGroup/gltf-asset-auditor/659e8724d56505954a3b48a4f1628b4b0ad4cbe8/tests/models/blender-default-cube-2m-10x-scale.glb -------------------------------------------------------------------------------- /tests/models/blender-default-cube-2m.glb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KhronosGroup/gltf-asset-auditor/659e8724d56505954a3b48a4f1628b4b0ad4cbe8/tests/models/blender-default-cube-2m.glb -------------------------------------------------------------------------------- /tests/models/blender-default-cube-bad-transform.glb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KhronosGroup/gltf-asset-auditor/659e8724d56505954a3b48a4f1628b4b0ad4cbe8/tests/models/blender-default-cube-bad-transform.glb -------------------------------------------------------------------------------- /tests/models/blender-default-cube-bad-uvs.glb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KhronosGroup/gltf-asset-auditor/659e8724d56505954a3b48a4f1628b4b0ad4cbe8/tests/models/blender-default-cube-bad-uvs.glb -------------------------------------------------------------------------------- /tests/models/blender-default-cube-beveled.glb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KhronosGroup/gltf-asset-auditor/659e8724d56505954a3b48a4f1628b4b0ad4cbe8/tests/models/blender-default-cube-beveled.glb -------------------------------------------------------------------------------- /tests/models/blender-default-cube-density.glb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KhronosGroup/gltf-asset-auditor/659e8724d56505954a3b48a4f1628b4b0ad4cbe8/tests/models/blender-default-cube-density.glb -------------------------------------------------------------------------------- /tests/models/blender-default-cube-empty-nodes.glb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KhronosGroup/gltf-asset-auditor/659e8724d56505954a3b48a4f1628b4b0ad4cbe8/tests/models/blender-default-cube-empty-nodes.glb -------------------------------------------------------------------------------- /tests/models/blender-default-cube-failing.glb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KhronosGroup/gltf-asset-auditor/659e8724d56505954a3b48a4f1628b4b0ad4cbe8/tests/models/blender-default-cube-failing.glb -------------------------------------------------------------------------------- /tests/models/blender-default-cube-inverted-uvs.glb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KhronosGroup/gltf-asset-auditor/659e8724d56505954a3b48a4f1628b4b0ad4cbe8/tests/models/blender-default-cube-inverted-uvs.glb -------------------------------------------------------------------------------- /tests/models/blender-default-cube-multi-material.glb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KhronosGroup/gltf-asset-auditor/659e8724d56505954a3b48a4f1628b4b0ad4cbe8/tests/models/blender-default-cube-multi-material.glb -------------------------------------------------------------------------------- /tests/models/blender-default-cube-no-materials.glb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KhronosGroup/gltf-asset-auditor/659e8724d56505954a3b48a4f1628b4b0ad4cbe8/tests/models/blender-default-cube-no-materials.glb -------------------------------------------------------------------------------- /tests/models/blender-default-cube-non-manifold.glb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KhronosGroup/gltf-asset-auditor/659e8724d56505954a3b48a4f1628b4b0ad4cbe8/tests/models/blender-default-cube-non-manifold.glb -------------------------------------------------------------------------------- /tests/models/blender-default-cube-passing.glb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KhronosGroup/gltf-asset-auditor/659e8724d56505954a3b48a4f1628b4b0ad4cbe8/tests/models/blender-default-cube-passing.glb -------------------------------------------------------------------------------- /tests/models/blender-default-cube-pbr-safe-colors.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KhronosGroup/gltf-asset-auditor/659e8724d56505954a3b48a4f1628b4b0ad4cbe8/tests/models/blender-default-cube-pbr-safe-colors.bin -------------------------------------------------------------------------------- /tests/models/blender-default-cube-pbr-safe-colors.glb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KhronosGroup/gltf-asset-auditor/659e8724d56505954a3b48a4f1628b4b0ad4cbe8/tests/models/blender-default-cube-pbr-safe-colors.glb -------------------------------------------------------------------------------- /tests/models/blender-default-cube-pbr-safe-colors.gltf: -------------------------------------------------------------------------------- 1 | { 2 | "asset": { 3 | "generator": "Khronos glTF Blender I/O v3.2.43", 4 | "version": "2.0" 5 | }, 6 | "scene": 0, 7 | "scenes": [ 8 | { 9 | "name": "Scene", 10 | "nodes": [0] 11 | } 12 | ], 13 | "nodes": [ 14 | { 15 | "mesh": 0, 16 | "name": "Cube" 17 | } 18 | ], 19 | "materials": [ 20 | { 21 | "doubleSided": true, 22 | "name": "Material", 23 | "pbrMetallicRoughness": { 24 | "baseColorTexture": { 25 | "index": 0 26 | }, 27 | "metallicFactor": 0, 28 | "roughnessFactor": 0.4000000059604645 29 | } 30 | } 31 | ], 32 | "meshes": [ 33 | { 34 | "name": "Cube", 35 | "primitives": [ 36 | { 37 | "attributes": { 38 | "POSITION": 0, 39 | "NORMAL": 1, 40 | "TEXCOORD_0": 2 41 | }, 42 | "indices": 3, 43 | "material": 0 44 | } 45 | ] 46 | } 47 | ], 48 | "textures": [ 49 | { 50 | "sampler": 0, 51 | "source": 0 52 | } 53 | ], 54 | "images": [ 55 | { 56 | "mimeType": "image/png", 57 | "name": "pbr-30-240", 58 | "uri": "pbr-30-240.png" 59 | } 60 | ], 61 | "accessors": [ 62 | { 63 | "bufferView": 0, 64 | "componentType": 5126, 65 | "count": 24, 66 | "max": [1, 1, 1], 67 | "min": [-1, -1, -1], 68 | "type": "VEC3" 69 | }, 70 | { 71 | "bufferView": 1, 72 | "componentType": 5126, 73 | "count": 24, 74 | "type": "VEC3" 75 | }, 76 | { 77 | "bufferView": 2, 78 | "componentType": 5126, 79 | "count": 24, 80 | "type": "VEC2" 81 | }, 82 | { 83 | "bufferView": 3, 84 | "componentType": 5123, 85 | "count": 36, 86 | "type": "SCALAR" 87 | } 88 | ], 89 | "bufferViews": [ 90 | { 91 | "buffer": 0, 92 | "byteLength": 288, 93 | "byteOffset": 0 94 | }, 95 | { 96 | "buffer": 0, 97 | "byteLength": 288, 98 | "byteOffset": 288 99 | }, 100 | { 101 | "buffer": 0, 102 | "byteLength": 192, 103 | "byteOffset": 576 104 | }, 105 | { 106 | "buffer": 0, 107 | "byteLength": 72, 108 | "byteOffset": 768 109 | } 110 | ], 111 | "samplers": [ 112 | { 113 | "magFilter": 9729, 114 | "minFilter": 9987 115 | } 116 | ], 117 | "buffers": [ 118 | { 119 | "byteLength": 840, 120 | "uri": "blender-default-cube-pbr-safe-colors.bin" 121 | } 122 | ] 123 | } 124 | -------------------------------------------------------------------------------- /tests/models/blender-default-cube-pbr-unsafe-colors.glb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KhronosGroup/gltf-asset-auditor/659e8724d56505954a3b48a4f1628b4b0ad4cbe8/tests/models/blender-default-cube-pbr-unsafe-colors.glb -------------------------------------------------------------------------------- /tests/models/blender-default-cube-uv-margin-grid-aligned.glb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KhronosGroup/gltf-asset-auditor/659e8724d56505954a3b48a4f1628b4b0ad4cbe8/tests/models/blender-default-cube-uv-margin-grid-aligned.glb -------------------------------------------------------------------------------- /tests/models/blender-default-cube-uv-margin.glb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KhronosGroup/gltf-asset-auditor/659e8724d56505954a3b48a4f1628b4b0ad4cbe8/tests/models/blender-default-cube-uv-margin.glb -------------------------------------------------------------------------------- /tests/models/blender-default-cube-uv-overlaps.glb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KhronosGroup/gltf-asset-auditor/659e8724d56505954a3b48a4f1628b4b0ad4cbe8/tests/models/blender-default-cube-uv-overlaps.glb -------------------------------------------------------------------------------- /tests/models/blender-monkey-uv-margin.glb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KhronosGroup/gltf-asset-auditor/659e8724d56505954a3b48a4f1628b4b0ad4cbe8/tests/models/blender-monkey-uv-margin.glb -------------------------------------------------------------------------------- /tests/models/box.gltf: -------------------------------------------------------------------------------- 1 | { 2 | "asset": { 3 | "generator": "COLLADA2GLTF", 4 | "version": "2.0" 5 | }, 6 | "scene": 0, 7 | "scenes": [ 8 | { 9 | "nodes": [ 10 | 0 11 | ] 12 | } 13 | ], 14 | "nodes": [ 15 | { 16 | "children": [ 17 | 1 18 | ], 19 | "matrix": [ 20 | 1.0, 21 | 0.0, 22 | 0.0, 23 | 0.0, 24 | 0.0, 25 | 0.0, 26 | -1.0, 27 | 0.0, 28 | 0.0, 29 | 1.0, 30 | 0.0, 31 | 0.0, 32 | 0.0, 33 | 0.0, 34 | 0.0, 35 | 1.0 36 | ] 37 | }, 38 | { 39 | "mesh": 0 40 | } 41 | ], 42 | "meshes": [ 43 | { 44 | "primitives": [ 45 | { 46 | "attributes": { 47 | "NORMAL": 1, 48 | "POSITION": 2 49 | }, 50 | "indices": 0, 51 | "mode": 4, 52 | "material": 0 53 | } 54 | ], 55 | "name": "Mesh" 56 | } 57 | ], 58 | "accessors": [ 59 | { 60 | "bufferView": 0, 61 | "byteOffset": 0, 62 | "componentType": 5123, 63 | "count": 36, 64 | "max": [ 65 | 23 66 | ], 67 | "min": [ 68 | 0 69 | ], 70 | "type": "SCALAR" 71 | }, 72 | { 73 | "bufferView": 1, 74 | "byteOffset": 0, 75 | "componentType": 5126, 76 | "count": 24, 77 | "max": [ 78 | 1.0, 79 | 1.0, 80 | 1.0 81 | ], 82 | "min": [ 83 | -1.0, 84 | -1.0, 85 | -1.0 86 | ], 87 | "type": "VEC3" 88 | }, 89 | { 90 | "bufferView": 1, 91 | "byteOffset": 288, 92 | "componentType": 5126, 93 | "count": 24, 94 | "max": [ 95 | 0.5, 96 | 0.5, 97 | 0.5 98 | ], 99 | "min": [ 100 | -0.5, 101 | -0.5, 102 | -0.5 103 | ], 104 | "type": "VEC3" 105 | } 106 | ], 107 | "materials": [ 108 | { 109 | "pbrMetallicRoughness": { 110 | "baseColorFactor": [ 111 | 0.800000011920929, 112 | 0.0, 113 | 0.0, 114 | 1.0 115 | ], 116 | "metallicFactor": 0.0 117 | }, 118 | "name": "Red" 119 | } 120 | ], 121 | "bufferViews": [ 122 | { 123 | "buffer": 0, 124 | "byteOffset": 576, 125 | "byteLength": 72, 126 | "target": 34963 127 | }, 128 | { 129 | "buffer": 0, 130 | "byteOffset": 0, 131 | "byteLength": 576, 132 | "byteStride": 12, 133 | "target": 34962 134 | } 135 | ], 136 | "buffers": [ 137 | { 138 | "byteLength": 648, 139 | "uri": "Box0.bin" 140 | } 141 | ] 142 | } 143 | -------------------------------------------------------------------------------- /tests/objectCount.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { Auditor } from '../src/Auditor.js'; 3 | 4 | // TODO: add new tests for minimum node/mesh/primitive 5 | 6 | describe('object count passing report', function () { 7 | const a = new Auditor(); 8 | 9 | before('load model', async function () { 10 | try { 11 | await a.model.loadFromFileSystem(['tests/models/blender-default-cube-passing.glb']); 12 | } catch (err) { 13 | throw new Error('Unable to load test model: blender-default-cube-passing.glb'); 14 | } 15 | }); 16 | 17 | describe('no object count checks', function () { 18 | before('load schema', async function () { 19 | try { 20 | await a.schema.loadFromFileSystem('tests/schemas/object-count/object-count-no-check.json'); 21 | } catch (err) { 22 | throw new Error('Unable to load test schema: object-count-no-check.json'); 23 | } 24 | await a.generateReport(); 25 | }); 26 | it('should report not tested, but have the object count in the message', function () { 27 | expect(a.schema.maxMeshCount.value).to.equal(-1); 28 | expect(a.model.meshCount.value).to.equal(1); 29 | expect(a.reportReady).to.be.true; 30 | expect(a.report.meshCount.tested).to.be.false; 31 | expect(a.report.meshCount.message).to.equal('1'); 32 | }); 33 | }); 34 | 35 | // meshes 36 | describe('max mesh count', function () { 37 | before('load schema', async function () { 38 | try { 39 | await a.schema.loadFromFileSystem('tests/schemas/object-count/object-count-pass.json'); 40 | } catch (err) { 41 | throw new Error('Unable to load test schema: object-count-pass.json'); 42 | } 43 | await a.generateReport(); 44 | }); 45 | it('should report being under the max mesh count', function () { 46 | expect(a.schema.maxMeshCount.value).to.equal(5); 47 | expect(a.model.meshCount.value).to.equal(1); 48 | expect(a.reportReady).to.be.true; 49 | expect(a.report.meshCount.tested).to.be.true; 50 | expect(a.report.meshCount.pass).to.be.true; 51 | expect(a.report.meshCount.message).to.equal('1 <= 5'); 52 | }); 53 | }); 54 | 55 | // nodes 56 | describe('max node count', function () { 57 | before('load schema', async function () { 58 | try { 59 | await a.schema.loadFromFileSystem('tests/schemas/object-count/object-count-pass.json'); 60 | } catch (err) { 61 | throw new Error('Unable to load test schema: object-count-pass.json'); 62 | } 63 | await a.generateReport(); 64 | }); 65 | it('should report being under the max node count', function () { 66 | expect(a.schema.maxNodeCount.value).to.equal(1); 67 | expect(a.model.nodeCount.value).to.equal(1); 68 | expect(a.reportReady).to.be.true; 69 | expect(a.report.nodeCount.tested).to.be.true; 70 | expect(a.report.nodeCount.pass).to.be.true; 71 | expect(a.report.nodeCount.message).to.equal('1 <= 1'); 72 | }); 73 | }); 74 | 75 | // primitives 76 | describe('max primitive count', function () { 77 | before('load schema', async function () { 78 | try { 79 | await a.schema.loadFromFileSystem('tests/schemas/object-count/object-count-pass.json'); 80 | } catch (err) { 81 | throw new Error('Unable to load test schema: object-count-pass.json'); 82 | } 83 | await a.generateReport(); 84 | }); 85 | it('should report being under the max primitive count', function () { 86 | expect(a.schema.maxPrimitiveCount.value).to.equal(3); 87 | expect(a.model.primitiveCount.value).to.equal(1); 88 | expect(a.reportReady).to.be.true; 89 | expect(a.report.primitiveCount.tested).to.be.true; 90 | expect(a.report.primitiveCount.pass).to.be.true; 91 | expect(a.report.primitiveCount.message).to.equal('1 <= 3'); 92 | }); 93 | }); 94 | }); 95 | 96 | describe('object count failing report', function () { 97 | const a = new Auditor(); 98 | 99 | before('load model and schema', async function () { 100 | try { 101 | await a.model.loadFromFileSystem(['tests/models/blender-default-cube-failing.glb']); 102 | } catch (err) { 103 | throw new Error('Unable to load test model: blender-default-cube-failing.glb'); 104 | } 105 | try { 106 | await a.schema.loadFromFileSystem('tests/schemas/object-count/object-count-fail.json'); 107 | } catch (err) { 108 | throw new Error('Unable to load test schema: object-count-fail.json'); 109 | } 110 | await a.generateReport(); 111 | }); 112 | 113 | // meshes 114 | describe('max mesh count', function () { 115 | it('should report being over the max mesh count', function () { 116 | expect(a.schema.maxMeshCount.value).to.equal(1); 117 | expect(a.model.meshCount.value).to.equal(2); 118 | expect(a.reportReady).to.be.true; 119 | expect(a.report.meshCount.tested).to.be.true; 120 | expect(a.report.meshCount.pass).to.be.false; 121 | expect(a.report.meshCount.message).to.equal('2 > 1'); 122 | }); 123 | }); 124 | 125 | // nodes 126 | // TODO: Disabling this until I can create nodes without vertices in blender 127 | /* 128 | describe('max node count', function () { 129 | it('should report being over the max node count', function () { 130 | expect(v.schema.maxNodeCount.value).to.equal(1); 131 | expect(v.model.nodeCount.value).to.equal(2); // TODO: add 2 nodes to model 132 | expect(v.reportReady).to.be.true; 133 | expect(v.report.nodeCount.tested).to.be.true; 134 | expect(v.report.nodeCount.pass).to.be.false; 135 | expect(v.report.nodeCount.message).to.equal('2 > 1'); 136 | }); 137 | }); 138 | */ 139 | 140 | // primitives 141 | describe('max primitive count', function () { 142 | it('should report being over the max primitive count', function () { 143 | expect(a.schema.maxPrimitiveCount.value).to.equal(1); 144 | expect(a.model.primitiveCount.value).to.equal(3); 145 | expect(a.reportReady).to.be.true; 146 | expect(a.report.primitiveCount.tested).to.be.true; 147 | expect(a.report.primitiveCount.pass).to.be.false; 148 | expect(a.report.primitiveCount.message).to.equal('3 > 1'); 149 | }); 150 | }); 151 | }); 152 | -------------------------------------------------------------------------------- /tests/pbrColorRange.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { Auditor } from '../src/Auditor.js'; 3 | 4 | describe('pbr color check passing report', function () { 5 | const a = new Auditor(); 6 | 7 | before('load model', async function () { 8 | try { 9 | await a.model.loadFromFileSystem([ 10 | 'tests/models/blender-default-cube-pbr-safe-colors.gltf', 11 | 'tests/models/blender-default-cube-pbr-safe-colors.bin', 12 | 'tests/textures/pbr-30-240.png', 13 | ]); 14 | } catch (err) { 15 | throw new Error('Unable to load test model: blender-default-cube-pbr-safe-colors.gltf'); 16 | } 17 | }); 18 | 19 | describe('max/min values not tested', function () { 20 | before('load schema', async function () { 21 | try { 22 | await a.schema.loadFromFileSystem('tests/schemas/textures/pbr-color-range-no-check.json'); 23 | } catch (err) { 24 | throw new Error('Unable to load test schema: pbr-color-range-no-check.json'); 25 | } 26 | await a.generateReport(); 27 | }); 28 | it('should report not tested, but have the min and max values in the message', function () { 29 | expect(a.schema.pbrColorMax.value).to.equal(-1); 30 | expect(a.schema.pbrColorMin.value).to.equal(-1); 31 | expect(a.model.colorValueMax.value).to.equal(240); 32 | expect(a.model.colorValueMin.value).to.equal(30); 33 | expect(a.reportReady).to.be.true; 34 | expect(a.report.pbrColorMax.tested).to.be.false; 35 | expect(a.report.pbrColorMax.message).to.equal('240'); 36 | expect(a.report.pbrColorMin.tested).to.be.false; 37 | expect(a.report.pbrColorMin.message).to.equal('30'); 38 | }); 39 | }); 40 | 41 | describe('max/min values within range', function () { 42 | before('load schema', async function () { 43 | try { 44 | await a.schema.loadFromFileSystem('tests/schemas/textures/pbr-color-range-pass.json'); 45 | } catch (err) { 46 | throw new Error('Unable to load test schema: pbr-color-range-pass.json'); 47 | } 48 | await a.generateReport(); 49 | }); 50 | it('the min and max pbr values should pass', function () { 51 | expect(a.schema.pbrColorMax.value).to.equal(240); 52 | expect(a.schema.pbrColorMin.value).to.equal(30); 53 | expect(a.model.colorValueMax.value).to.equal(240); 54 | expect(a.model.colorValueMin.value).to.equal(30); 55 | expect(a.reportReady).to.be.true; 56 | expect(a.report.pbrColorMax.tested).to.be.true; 57 | expect(a.report.pbrColorMax.message).to.equal('240 <= 240'); 58 | expect(a.report.pbrColorMin.tested).to.be.true; 59 | expect(a.report.pbrColorMin.message).to.equal('30 >= 30'); 60 | }); 61 | }); 62 | }); 63 | 64 | describe('pbr color check failing report', function () { 65 | const a = new Auditor(); 66 | 67 | before('load model', async function () { 68 | try { 69 | await a.model.loadFromFileSystem(['tests/models/blender-default-cube-pbr-unsafe-colors.glb']); 70 | } catch (err) { 71 | throw new Error('Unable to load test model: blender-default-cube-pbr-unsafe-colors.glb'); 72 | } 73 | }); 74 | 75 | describe('max/min values outside of range', function () { 76 | before('load schema', async function () { 77 | try { 78 | await a.schema.loadFromFileSystem('tests/schemas/textures/pbr-color-range-fail.json'); 79 | } catch (err) { 80 | throw new Error('Unable to load test schema: pbr-color-range-fail.json'); 81 | } 82 | await a.generateReport(); 83 | }); 84 | it('the min and max pbr values should pass', function () { 85 | expect(a.schema.pbrColorMax.value).to.equal(200); 86 | expect(a.schema.pbrColorMin.value).to.equal(50); 87 | expect(a.model.colorValueMax.value).to.equal(255); 88 | expect(a.model.colorValueMin.value).to.equal(0); 89 | expect(a.reportReady).to.be.true; 90 | expect(a.report.pbrColorMax.tested).to.be.true; 91 | expect(a.report.pbrColorMax.message).to.equal('255 > 200'); 92 | expect(a.report.pbrColorMin.tested).to.be.true; 93 | expect(a.report.pbrColorMin.message).to.equal('0 < 50'); 94 | }); 95 | }); 96 | }); 97 | -------------------------------------------------------------------------------- /tests/productInfo.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { Auditor } from '../src/Auditor.js'; 3 | 4 | describe('loading passing product info', function () { 5 | const a = new Auditor(); 6 | 7 | before('load product info', async function () { 8 | try { 9 | await a.productInfo.loadFromFileSystem('tests/products/blender-default-cube-passing.json'); 10 | } catch (err) { 11 | throw new Error('Unable to load product info: blender-default-cube-passing.json'); 12 | } 13 | }); 14 | describe('loaded', function () { 15 | it('should load the passing product info', function () { 16 | expect(a.productInfo.loaded).to.be.true; 17 | }); 18 | }); 19 | describe('dimensions', function () { 20 | it('should be (L:2.02 x W:2.01 x H:1.99)', function () { 21 | expect(a.productInfo.length.value as number).to.equal(2.02); 22 | expect(a.productInfo.width.value as number).to.equal(2.01); 23 | expect(a.productInfo.height.value as number).to.equal(1.99); 24 | }); 25 | }); 26 | }); 27 | 28 | describe('loading failing product info', function () { 29 | const a = new Auditor(); 30 | 31 | before('load product info', async function () { 32 | try { 33 | await a.productInfo.loadFromFileSystem('tests/products/blender-default-cube-failing.json'); 34 | } catch (err) { 35 | throw new Error('Unable to load product info: blender-default-cube-failing.json'); 36 | } 37 | }); 38 | describe('loaded', function () { 39 | it('should load the failing product info', function () { 40 | expect(a.productInfo.loaded).to.be.true; 41 | }); 42 | }); 43 | describe('dimensions', function () { 44 | it('should be (L:1 x W:2 x H:3)', function () { 45 | expect(a.productInfo.length.value as number).to.equal(1); 46 | expect(a.productInfo.width.value as number).to.equal(2); 47 | expect(a.productInfo.height.value as number).to.equal(3); 48 | }); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /tests/products/blender-default-cube-failing.json: -------------------------------------------------------------------------------- 1 | { 2 | "dimensions": { 3 | "length": 1, 4 | "width": 2, 5 | "height": 3 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /tests/products/blender-default-cube-passing.json: -------------------------------------------------------------------------------- 1 | { 2 | "dimensions": { 3 | "length": 2.02, 4 | "width": 2.01, 5 | "height": 1.99 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /tests/report.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { Auditor } from '../src/Auditor.js'; 3 | 4 | describe('generating passing report', function () { 5 | const a = new Auditor(); 6 | 7 | before('load report', async function () { 8 | try { 9 | await a.schema.loadFromFileSystem('tests/schemas/pass.json'); 10 | } catch (err) { 11 | throw new Error('Unable to load test schema: pass.json'); 12 | } 13 | try { 14 | await a.model.loadFromFileSystem(['tests/models/blender-default-cube-passing.glb']); 15 | } catch (err) { 16 | throw new Error('Unable to load test model: blender-default-cube-passing.glb'); 17 | } 18 | await a.generateReport(); 19 | }); 20 | 21 | describe('ready', function () { 22 | it('should have the passing report ready', function () { 23 | expect(a.reportReady).to.be.true; 24 | }); 25 | }); 26 | describe('file size', function () { 27 | it('should pass for blender-default-cube-passing', function () { 28 | expect(a.report.fileSize.tested).to.be.true; 29 | expect(a.report.fileSize.pass).to.be.true; 30 | }); 31 | }); 32 | describe('triangle count', function () { 33 | it('should pass for blender-default-cube-passing', function () { 34 | expect(a.report.triangleCount.tested).to.be.true; 35 | expect(a.report.triangleCount.pass).to.be.true; 36 | }); 37 | }); 38 | describe('material count', function () { 39 | it('should pass for blender-default-cube-passing', function () { 40 | expect(a.report.materialCount.tested).to.be.true; 41 | expect(a.report.materialCount.pass).to.be.true; 42 | }); 43 | }); 44 | describe('texture dimensions are powers of 2', function () { 45 | it('should be skipped for blender-default-cube-passing', function () { 46 | expect(a.report.texturesPowerOfTwo.tested).to.be.false; 47 | }); 48 | }); 49 | describe('overall dimensions', function () { 50 | it('should be greater than 0.01x0.01x0.01 and less than 100x100x100', function () { 51 | expect(a.report.overallDimensionsWithinTolerance.tested).to.be.true; 52 | expect(a.report.overallDimensionsWithinTolerance.pass).to.be.true; 53 | }); 54 | }); 55 | }); 56 | 57 | describe('generating failing report', function () { 58 | const a = new Auditor(); 59 | 60 | before('load report', async function () { 61 | try { 62 | await a.schema.loadFromFileSystem('tests/schemas/fail.json'); 63 | } catch (err) { 64 | throw new Error('Unable to load test schema: fail.json'); 65 | } 66 | try { 67 | await a.model.loadFromFileSystem(['tests/models/blender-default-cube-failing.glb']); 68 | } catch (err) { 69 | throw new Error('Unable to load test model: blender-default-cube-failing.glb'); 70 | } 71 | await a.generateReport(); 72 | }); 73 | describe('ready', function () { 74 | it('should have the failing report ready', function () { 75 | expect(a.reportReady).to.be.true; 76 | }); 77 | }); 78 | describe('file size', function () { 79 | it('should fail for blender-default-cube-failing', function () { 80 | expect(a.report.fileSize.tested).to.be.true; 81 | expect(a.report.fileSize.pass).to.be.false; 82 | }); 83 | }); 84 | describe('triangle count', function () { 85 | it('should fail for blender-default-cube-failing', function () { 86 | expect(a.report.triangleCount.tested).to.be.true; 87 | expect(a.report.triangleCount.pass).to.be.false; 88 | }); 89 | }); 90 | describe('texture dimensions are powers of 2', function () { 91 | it('should fail for blender-default-cube-failing', function () { 92 | expect(a.report.texturesPowerOfTwo.tested).to.be.true; 93 | expect(a.report.texturesPowerOfTwo.pass).to.be.false; 94 | }); 95 | }); 96 | describe('material count', function () { 97 | it('should fail for blender-default-cube-failing', function () { 98 | expect(a.report.materialCount.tested).to.be.true; 99 | expect(a.report.materialCount.pass).to.be.false; 100 | }); 101 | }); 102 | describe('overall dimensions over max', function () { 103 | it('should fail for being larger than 10m width and depth', function () { 104 | expect(a.schema.maxHeight.value).to.equal(10); 105 | expect(a.schema.maxLength.value).to.equal(10); 106 | expect(a.schema.maxWidth.value).to.equal(10); 107 | expect(a.schema.minHeight.value).to.equal(1); 108 | expect(a.schema.minLength.value).to.equal(1); 109 | expect(a.schema.minWidth.value).to.equal(1); 110 | expect(a.model.height.value).to.equal(0.2); 111 | expect(a.model.length.value).to.equal(12); 112 | expect(a.model.width.value).to.equal(12); 113 | expect(a.reportReady).to.be.true; 114 | expect(a.report.overallDimensionsWithinTolerance.tested).to.be.true; 115 | expect(a.report.overallDimensionsWithinTolerance.pass).to.be.false; 116 | }); 117 | }); 118 | describe('overall dimensions under min', function () { 119 | it('should fail for height smaller than 1m', function () { 120 | expect(a.report.overallDimensionsWithinTolerance.tested).to.be.true; 121 | expect(a.report.overallDimensionsWithinTolerance.pass).to.be.false; 122 | }); 123 | }); 124 | }); 125 | -------------------------------------------------------------------------------- /tests/schema.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { Auditor } from '../src/Auditor.js'; 3 | 4 | describe('loading passing schema', function () { 5 | const a = new Auditor(); 6 | 7 | before('load schema', async function () { 8 | try { 9 | await a.schema.loadFromFileSystem('tests/schemas/pass.json'); 10 | } catch (err) { 11 | throw new Error('Unable to load schema: pass.json'); 12 | } 13 | }); 14 | describe('loaded', function () { 15 | it('should load the pass schema', function () { 16 | expect(a.schema.loaded).to.be.true; 17 | }); 18 | }); 19 | describe('min file size', function () { 20 | it('should match the pass schema min of -1 for no check', function () { 21 | expect(a.schema.minFileSizeInKb.value as number).to.equal(-1); 22 | }); 23 | }); 24 | describe('max file size', function () { 25 | it('should match the pass schema max file size of 5,120kb', function () { 26 | expect(a.schema.maxFileSizeInKb.value as number).to.equal(5120); 27 | }); 28 | }); 29 | describe('max triangle count', function () { 30 | it('should match the pass schema max triangle count of 30,000', function () { 31 | expect(a.schema.maxTriangleCount.value as number).to.equal(30000); 32 | }); 33 | }); 34 | describe('max material count', function () { 35 | it('should match the pass schema max material count of 2', function () { 36 | expect(a.schema.maxMaterialCount.value as number).to.equal(2); 37 | }); 38 | }); 39 | describe('require texture dimensions be powers of 2', function () { 40 | it('should be set to true', function () { 41 | expect(a.schema.requireTextureDimensionsBePowersOfTwo.value as boolean).to.be.true; 42 | }); 43 | }); 44 | describe('minimum dimensions', function () { 45 | it('should be (L:0.01 x W:0.01 x H:0.01)', function () { 46 | expect(a.schema.minLength.value as number).to.equal(0.01); 47 | expect(a.schema.minWidth.value as number).to.equal(0.01); 48 | expect(a.schema.minHeight.value as number).to.equal(0.01); 49 | }); 50 | }); 51 | describe('maximum dimensions', function () { 52 | it('should be (L:100 x W:100 x H:100)', function () { 53 | expect(a.schema.maxLength.value as number).to.equal(100); 54 | expect(a.schema.maxWidth.value as number).to.equal(100); 55 | expect(a.schema.maxHeight.value as number).to.equal(100); 56 | }); 57 | }); 58 | }); 59 | 60 | describe('loading failing schema', function () { 61 | const a = new Auditor(); 62 | 63 | before('load schema', async function () { 64 | try { 65 | await a.schema.loadFromFileSystem('tests/schemas/fail.json'); 66 | } catch (err) { 67 | throw new Error('Unable to load schema: fail.json'); 68 | } 69 | }); 70 | describe('loaded', function () { 71 | it('should load the fail schema', function () { 72 | expect(a.schema.loaded).to.be.true; 73 | }); 74 | }); 75 | describe('min file size', function () { 76 | it('should match the fail schema min file size of 100kb', function () { 77 | expect(a.schema.minFileSizeInKb.value as number).to.equal(100); 78 | }); 79 | }); 80 | describe('max file size', function () { 81 | it('should match the fail schema max file size of 1024kb', function () { 82 | expect(a.schema.maxFileSizeInKb.value as number).to.equal(1024); 83 | }); 84 | }); 85 | describe('max triangle count', function () { 86 | it('should match the fail schema max triangle count of 6', function () { 87 | expect(a.schema.maxTriangleCount.value as number).to.equal(6); 88 | }); 89 | }); 90 | describe('max material count', function () { 91 | it('should match the fail schema max material count of 1', function () { 92 | expect(a.schema.maxMaterialCount.value as number).to.equal(1); 93 | }); 94 | }); 95 | describe('require texture dimensions be powers of 2', function () { 96 | it('should be set to true', function () { 97 | expect(a.schema.requireTextureDimensionsBePowersOfTwo.value as boolean).to.be.true; 98 | }); 99 | }); 100 | describe('minimum dimensions', function () { 101 | it('should be (L:1 x W:1 x H:1)', function () { 102 | expect(a.schema.minLength.value as number).to.equal(1); 103 | expect(a.schema.minWidth.value as number).to.equal(1); 104 | expect(a.schema.minHeight.value as number).to.equal(1); 105 | }); 106 | }); 107 | describe('maximum dimensions', function () { 108 | it('should be (L:10 x W:10 x H:10)', function () { 109 | expect(a.schema.maxLength.value as number).to.equal(10); 110 | expect(a.schema.maxWidth.value as number).to.equal(10); 111 | expect(a.schema.maxHeight.value as number).to.equal(10); 112 | }); 113 | }); 114 | }); 115 | -------------------------------------------------------------------------------- /tests/schemas/clean-transform/clean-transform-not-required.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.0-rc.1", 3 | "model": { 4 | "requireCleanRootNodeTransform": false 5 | } 6 | } -------------------------------------------------------------------------------- /tests/schemas/clean-transform/clean-transform-required.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.0-rc.1", 3 | "model": { 4 | "requireCleanRootNodeTransform": true 5 | } 6 | } -------------------------------------------------------------------------------- /tests/schemas/dimensions/dimensions-max-10m.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.0-rc.1", 3 | "product": { 4 | "dimensions": { 5 | "height": { 6 | "maximum": 10, 7 | "minimum": -1, 8 | "percentTolerance": 3 9 | }, 10 | "length": { 11 | "maximum": 10, 12 | "minimum": -1, 13 | "percentTolerance": 3 14 | }, 15 | "width": { 16 | "maximum": 10, 17 | "minimum": -1, 18 | "percentTolerance": 3 19 | } 20 | } 21 | } 22 | } -------------------------------------------------------------------------------- /tests/schemas/dimensions/dimensions-max-1m.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.0-rc.1", 3 | "product": { 4 | "dimensions": { 5 | "height": { 6 | "maximum": 1, 7 | "minimum": -1, 8 | "percentTolerance": 3 9 | }, 10 | "length": { 11 | "maximum": 1, 12 | "minimum": -1, 13 | "percentTolerance": 3 14 | }, 15 | "width": { 16 | "maximum": 1, 17 | "minimum": -1, 18 | "percentTolerance": 3 19 | } 20 | } 21 | } 22 | } -------------------------------------------------------------------------------- /tests/schemas/dimensions/dimensions-min-10m.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.0-rc.1", 3 | "product": { 4 | "dimensions": { 5 | "height": { 6 | "maximum": -1, 7 | "minimum": 10, 8 | "percentTolerance": 3 9 | }, 10 | "length": { 11 | "maximum": -1, 12 | "minimum": 10, 13 | "percentTolerance": 3 14 | }, 15 | "width": { 16 | "maximum": -1, 17 | "minimum": 10, 18 | "percentTolerance": 3 19 | } 20 | } 21 | } 22 | } -------------------------------------------------------------------------------- /tests/schemas/dimensions/dimensions-min-1m.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.0-rc.1", 3 | "product": { 4 | "dimensions": { 5 | "height": { 6 | "maximum": -1, 7 | "minimum": 1, 8 | "percentTolerance": 3 9 | }, 10 | "length": { 11 | "maximum": -1, 12 | "minimum": 1, 13 | "percentTolerance": 3 14 | }, 15 | "width": { 16 | "maximum": -1, 17 | "minimum": 1, 18 | "percentTolerance": 3 19 | } 20 | } 21 | } 22 | } -------------------------------------------------------------------------------- /tests/schemas/dimensions/dimensions-not-required.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.0-rc.1", 3 | "product": { 4 | "dimensions": { 5 | "height": { 6 | "maximum": -1, 7 | "minimum": -1 8 | }, 9 | "length": { 10 | "maximum": -1, 11 | "minimum": -1 12 | }, 13 | "width": { 14 | "maximum": -1, 15 | "minimum": -1 16 | } 17 | } 18 | } 19 | } -------------------------------------------------------------------------------- /tests/schemas/dimensions/dimensions-range-1m-10m.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.0-rc.1", 3 | "product": { 4 | "dimensions": { 5 | "height": { 6 | "maximum": 10, 7 | "minimum": 1, 8 | "percentTolerance": 3 9 | }, 10 | "length": { 11 | "maximum": 10, 12 | "minimum": 1, 13 | "percentTolerance": 3 14 | }, 15 | "width": { 16 | "maximum": 10, 17 | "minimum": 1, 18 | "percentTolerance": 3 19 | } 20 | } 21 | } 22 | } -------------------------------------------------------------------------------- /tests/schemas/edges/beveled-edges-required.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.0-rc.1", 3 | "model": { 4 | "requireBeveledEdges": true 5 | } 6 | } -------------------------------------------------------------------------------- /tests/schemas/edges/must-be-manifold.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.0-rc.1", 3 | "model": { 4 | "requireManifoldEdges": true 5 | } 6 | } -------------------------------------------------------------------------------- /tests/schemas/fail.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.0-rc.1", 3 | "fileSizeInKb": { 4 | "maximum": 1024, 5 | "minimum": 100 6 | }, 7 | "materials": { 8 | "maximum": 1, 9 | "minimum": 0 10 | }, 11 | "model": { 12 | "objectCount": { 13 | "nodes": { 14 | "maximum": 5, 15 | "minimum": 1 16 | }, 17 | "meshes": { 18 | "maximum": 5, 19 | "minimum": 1 20 | }, 21 | "primitives": { 22 | "maximum": 5, 23 | "minimum": 1 24 | } 25 | }, 26 | "requireBeveledEdges": true, 27 | "requireCleanRootNodeTransform": true, 28 | "requireManifoldEdges": true, 29 | "triangles": { 30 | "maximum": 6, 31 | "minimum": 1 32 | } 33 | }, 34 | "product": { 35 | "dimensions": { 36 | "height": { 37 | "maximum": 10, 38 | "minimum": 1, 39 | "percentTolerance": 2 40 | }, 41 | "length": { 42 | "maximum": 10, 43 | "minimum": 1, 44 | "percentTolerance": 2 45 | }, 46 | "width": { 47 | "maximum": 10, 48 | "minimum": 1, 49 | "percentTolerance": 2 50 | } 51 | } 52 | }, 53 | "textures": { 54 | "height": { 55 | "maximum": 2048, 56 | "minimum": 512 57 | }, 58 | "pbrColorRange": { 59 | "maximum": 240, 60 | "minimum": 30 61 | }, 62 | "requireDimensionsBePowersOfTwo": true, 63 | "requireDimensionsBeQuadratic": true, 64 | "width": { 65 | "maximum": 2048, 66 | "minimum": 512 67 | } 68 | } 69 | } -------------------------------------------------------------------------------- /tests/schemas/file-size/file-size-no-check.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.0-rc.1", 3 | "fileSizeInKb": { 4 | "maximum": -1, 5 | "minimum": -1 6 | } 7 | } -------------------------------------------------------------------------------- /tests/schemas/file-size/file-size-no-max-fail.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.0-rc.1", 3 | "fileSizeInKb": { 4 | "maximum": -1, 5 | "minimum": 1024 6 | } 7 | } -------------------------------------------------------------------------------- /tests/schemas/file-size/file-size-no-max-pass.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.0-rc.1", 3 | "fileSizeInKb": { 4 | "maximum": -1, 5 | "minimum": 1 6 | } 7 | } -------------------------------------------------------------------------------- /tests/schemas/file-size/file-size-no-min-fail.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.0-rc.1", 3 | "fileSizeInKb": { 4 | "minimum": -1, 5 | "maximum": 1 6 | } 7 | } -------------------------------------------------------------------------------- /tests/schemas/file-size/file-size-no-min-pass.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.0-rc.1", 3 | "fileSizeInKb": { 4 | "maximum": 5120, 5 | "minimum": -1 6 | } 7 | } -------------------------------------------------------------------------------- /tests/schemas/file-size/file-size-within-range-fail.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.0-rc.1", 3 | "fileSizeInKb": { 4 | "maximum": 2, 5 | "minimum": 1 6 | } 7 | } -------------------------------------------------------------------------------- /tests/schemas/file-size/file-size-within-range-pass.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.0-rc.1", 3 | "fileSizeInKb": { 4 | "maximum": 5120, 5 | "minimum": 1 6 | } 7 | } -------------------------------------------------------------------------------- /tests/schemas/khronos-recommended.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.0-rc.1", 3 | "fileSizeInKb": { 4 | "maximum": 5120, 5 | "minimum": -1 6 | }, 7 | "materials": { 8 | "maximum": 5, 9 | "minimum": -1 10 | }, 11 | "model": { 12 | "objectCount": { 13 | "nodes": { 14 | "maximum": 5, 15 | "minimum": -1 16 | }, 17 | "meshes": { 18 | "maximum": 5, 19 | "minimum": -1 20 | }, 21 | "primitives": { 22 | "maximum": 5, 23 | "minimum": -1 24 | } 25 | }, 26 | "requireBeveledEdges": false, 27 | "requireCleanRootNodeTransform": true, 28 | "requireManifoldEdges": false, 29 | "triangles": { 30 | "maximum": 100000, 31 | "minimum": -1 32 | } 33 | }, 34 | "product": { 35 | "dimensions": { 36 | "height": { 37 | "maximum": 100.0, 38 | "minimum": 0.01, 39 | "percentTolerance": 3 40 | }, 41 | "length": { 42 | "maximum": 100.0, 43 | "minimum": 0.01, 44 | "percentTolerance": 3 45 | }, 46 | "width": { 47 | "maximum": 100.0, 48 | "minimum": 0.01, 49 | "percentTolerance": 3 50 | } 51 | } 52 | }, 53 | "textures": { 54 | "height": { 55 | "maximum": 2048, 56 | "minimum": 512 57 | }, 58 | "pbrColorRange": { 59 | "maximum": 240, 60 | "minimum": 30 61 | }, 62 | "requireDimensionsBePowersOfTwo": true, 63 | "requireDimensionsBeQuadratic": false, 64 | "width": { 65 | "maximum": 2048, 66 | "minimum": 512 67 | } 68 | }, 69 | "uvs": { 70 | "pixelsPerMeter": { 71 | "maximum": 100000, 72 | "minimum": -1 73 | }, 74 | "requireNotInverted": true, 75 | "requireNotOverlapping": true, 76 | "requireRangeZeroToOne": true 77 | } 78 | } -------------------------------------------------------------------------------- /tests/schemas/material-count/material-count-no-check.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.0-rc.1", 3 | "materials": { 4 | "maximum": -1, 5 | "minimum": -1 6 | } 7 | } -------------------------------------------------------------------------------- /tests/schemas/material-count/material-count-no-max-fail.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.0-rc.1", 3 | "materials": { 4 | "maximum": -1, 5 | "minimum": 4 6 | } 7 | } -------------------------------------------------------------------------------- /tests/schemas/material-count/material-count-no-max-pass.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.0-rc.1", 3 | "materials": { 4 | "maximum": -1, 5 | "minimum": 1 6 | } 7 | } -------------------------------------------------------------------------------- /tests/schemas/material-count/material-count-no-min-fail.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.0-rc.1", 3 | "materials": { 4 | "maximum": 1, 5 | "minimum": -1 6 | } 7 | } -------------------------------------------------------------------------------- /tests/schemas/material-count/material-count-no-min-pass.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.0-rc.1", 3 | "materials": { 4 | "maximum": 5, 5 | "minimum": -1 6 | } 7 | } -------------------------------------------------------------------------------- /tests/schemas/object-count/object-count-fail.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.0-rc.1", 3 | "model": { 4 | "objectCount": { 5 | "meshes": { 6 | "maximum": 1 7 | }, 8 | "nodes": { 9 | "maximum": 1 10 | }, 11 | "primitives": { 12 | "maximum": 1 13 | } 14 | } 15 | } 16 | } -------------------------------------------------------------------------------- /tests/schemas/object-count/object-count-no-check.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.0-rc.1", 3 | "model": { 4 | "objectCount": { 5 | "meshes": { 6 | "maximum": -1 7 | }, 8 | "nodes": { 9 | "maximum": -1 10 | }, 11 | "primitives": { 12 | "maximum": -1 13 | } 14 | } 15 | } 16 | } -------------------------------------------------------------------------------- /tests/schemas/object-count/object-count-pass.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.0-rc.1", 3 | "model": { 4 | "objectCount": { 5 | "meshes": { 6 | "maximum": 5 7 | }, 8 | "nodes": { 9 | "maximum": 1 10 | }, 11 | "primitives": { 12 | "maximum": 3 13 | } 14 | } 15 | } 16 | } -------------------------------------------------------------------------------- /tests/schemas/pass.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.0-rc.1", 3 | "fileSizeInKb": { 4 | "maximum": 5120, 5 | "minimum": -1 6 | }, 7 | "materials": { 8 | "maximum": 2, 9 | "minimum": 0 10 | }, 11 | "model": { 12 | "objectCount": { 13 | "nodes": { 14 | "maximum": 5, 15 | "minimum": 1 16 | }, 17 | "meshes": { 18 | "maximum": 5, 19 | "minimum": 1 20 | }, 21 | "primitives": { 22 | "maximum": 5, 23 | "minimum": 1 24 | } 25 | }, 26 | "requireBeveledEdges": false, 27 | "requireCleanRootNodeTransform": true, 28 | "requireManifoldEdges": true, 29 | "triangles": { 30 | "maximum": 30000, 31 | "minimum": 1 32 | } 33 | }, 34 | "product": { 35 | "dimensions": { 36 | "height": { 37 | "maximum": 100.0, 38 | "minimum": 0.01, 39 | "percentTolerance": 3 40 | }, 41 | "length": { 42 | "maximum": 100.0, 43 | "minimum": 0.01, 44 | "percentTolerance": 1 45 | }, 46 | "width": { 47 | "maximum": 100.0, 48 | "minimum": 0.01, 49 | "percentTolerance": 2 50 | } 51 | } 52 | }, 53 | "textures": { 54 | "height": { 55 | "maximum": 2048, 56 | "minimum": 512 57 | }, 58 | "pbrColorRange": { 59 | "maximum": 243, 60 | "minimum": 30 61 | }, 62 | "requireDimensionsBePowersOfTwo": true, 63 | "requireDimensionsBeQuadratic": true, 64 | "width": { 65 | "maximum": 2048, 66 | "minimum": 512 67 | } 68 | } 69 | } -------------------------------------------------------------------------------- /tests/schemas/textures/pbr-color-range-fail.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.0-rc.1", 3 | "textures": { 4 | "pbrColorRange": { 5 | "maximum": 200, 6 | "minimum": 50 7 | } 8 | } 9 | } -------------------------------------------------------------------------------- /tests/schemas/textures/pbr-color-range-no-check.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.0-rc.1", 3 | "textures": { 4 | "pbrColorRange": { 5 | "maximum": -1, 6 | "minimum": -1 7 | } 8 | } 9 | } -------------------------------------------------------------------------------- /tests/schemas/textures/pbr-color-range-pass.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.0-rc.1", 3 | "textures": { 4 | "pbrColorRange": { 5 | "maximum": 240, 6 | "minimum": 30 7 | } 8 | } 9 | } -------------------------------------------------------------------------------- /tests/schemas/triangle-count/triangle-count-fail.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.0-rc.1", 3 | "model": { 4 | "triangles": { 5 | "maximum": 10 6 | } 7 | } 8 | } -------------------------------------------------------------------------------- /tests/schemas/triangle-count/triangle-count-no-check.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.0-rc.1", 3 | "model": { 4 | "triangles": { 5 | "maximum": -1 6 | } 7 | } 8 | } -------------------------------------------------------------------------------- /tests/schemas/triangle-count/triangle-count-pass.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.0-rc.1", 3 | "model": { 4 | "triangles": { 5 | "maximum": 30000 6 | } 7 | } 8 | } -------------------------------------------------------------------------------- /tests/schemas/uv/uv-gutter.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.0-rc.1", 3 | "uvs": { 4 | "gutterWidth": { 5 | "resolution256": 8 6 | } 7 | } 8 | } -------------------------------------------------------------------------------- /tests/schemas/uv/uv-overlaps.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.0-rc.3", 3 | "uvs": { 4 | "requireNotOverlapping": "true" 5 | } 6 | } -------------------------------------------------------------------------------- /tests/textures/1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KhronosGroup/gltf-asset-auditor/659e8724d56505954a3b48a4f1628b4b0ad4cbe8/tests/textures/1024.png -------------------------------------------------------------------------------- /tests/textures/256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KhronosGroup/gltf-asset-auditor/659e8724d56505954a3b48a4f1628b4b0ad4cbe8/tests/textures/256.png -------------------------------------------------------------------------------- /tests/textures/256x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KhronosGroup/gltf-asset-auditor/659e8724d56505954a3b48a4f1628b4b0ad4cbe8/tests/textures/256x512.png -------------------------------------------------------------------------------- /tests/textures/500x500.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KhronosGroup/gltf-asset-auditor/659e8724d56505954a3b48a4f1628b4b0ad4cbe8/tests/textures/500x500.png -------------------------------------------------------------------------------- /tests/textures/512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KhronosGroup/gltf-asset-auditor/659e8724d56505954a3b48a4f1628b4b0ad4cbe8/tests/textures/512.png -------------------------------------------------------------------------------- /tests/textures/pbr-0-255.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KhronosGroup/gltf-asset-auditor/659e8724d56505954a3b48a4f1628b4b0ad4cbe8/tests/textures/pbr-0-255.png -------------------------------------------------------------------------------- /tests/textures/pbr-30-240.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KhronosGroup/gltf-asset-auditor/659e8724d56505954a3b48a4f1628b4b0ad4cbe8/tests/textures/pbr-30-240.png -------------------------------------------------------------------------------- /tests/triangleCount.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { Auditor } from '../src/Auditor.js'; 3 | 4 | describe('triangle count passing report', function () { 5 | const a = new Auditor(); 6 | 7 | before('load model', async function () { 8 | try { 9 | await a.model.loadFromFileSystem(['tests/models/blender-default-cube-passing.glb']); 10 | } catch (err) { 11 | throw new Error('Unable to load test model: blender-default-cube-passing.glb'); 12 | } 13 | }); 14 | 15 | describe('no triangle count check', function () { 16 | before('load schema', async function () { 17 | try { 18 | await a.schema.loadFromFileSystem('tests/schemas/triangle-count/triangle-count-no-check.json'); 19 | } catch (err) { 20 | throw new Error('Unable to load test schema: triangle-count-no-check.json'); 21 | } 22 | await a.generateReport(); 23 | }); 24 | it('should report not tested, but have the triangle count in the message', function () { 25 | expect(a.reportReady).to.be.true; 26 | expect(a.report.triangleCount.tested).to.be.false; 27 | expect(a.report.triangleCount.message).to.equal('12'); 28 | }); 29 | }); 30 | 31 | describe('max triangle count', function () { 32 | before('load schema', async function () { 33 | try { 34 | await a.schema.loadFromFileSystem('tests/schemas/triangle-count/triangle-count-pass.json'); 35 | } catch (err) { 36 | throw new Error('Unable to load test schema: triangle-count-pass.json'); 37 | } 38 | await a.generateReport(); 39 | }); 40 | it('should report being under the max triangle count', function () { 41 | expect(a.reportReady).to.be.true; 42 | expect(a.report.triangleCount.tested).to.be.true; 43 | expect(a.report.triangleCount.pass).to.be.true; 44 | expect(a.report.triangleCount.message).to.equal('12 <= 30,000'); 45 | }); 46 | }); 47 | }); 48 | 49 | describe('triangle count failing report', function () { 50 | const a = new Auditor(); 51 | 52 | before('load model', async function () { 53 | try { 54 | await a.model.loadFromFileSystem(['tests/models/blender-default-cube-failing.glb']); 55 | } catch (err) { 56 | throw new Error('Unable to load test model: blender-default-cube-failing.glb'); 57 | } 58 | }); 59 | 60 | describe('max triangle count', function () { 61 | before('load schema', async function () { 62 | try { 63 | await a.schema.loadFromFileSystem('tests/schemas/triangle-count/triangle-count-fail.json'); 64 | } catch (err) { 65 | throw new Error('Unable to load test schema: triangle-count-fail.json'); 66 | } 67 | await a.generateReport(); 68 | }); 69 | it('should report being over the max triangle count', function () { 70 | expect(a.reportReady).to.be.true; 71 | expect(a.report.triangleCount.tested).to.be.true; 72 | expect(a.report.triangleCount.pass).to.be.false; 73 | expect(a.report.triangleCount.message).to.equal('12 > 10'); 74 | }); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "outDir": "dist", 5 | "target": "es2021", 6 | "module": "ESNext", 7 | "strict": true, 8 | "importHelpers": true, 9 | "moduleResolution": "node", 10 | "experimentalDecorators": true, 11 | "skipLibCheck": false, 12 | "esModuleInterop": true, 13 | "allowSyntheticDefaultImports": true, 14 | "sourceMap": true, 15 | "types": [ 16 | "node", 17 | "mocha" 18 | ], 19 | "baseUrl": ".", 20 | "paths": { 21 | "@/*": ["src/*"] 22 | }, 23 | "lib": ["es2021", "dom", "dom.iterable", "scripthost"], 24 | }, 25 | "include": ["src/**/*.ts", "tests/**/*.ts"], 26 | "exclude": ["node_modules", "tests"] 27 | } 28 | -------------------------------------------------------------------------------- /web-example/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "printWidth": 120, 4 | "semi": true, 5 | "singleQuote": true, 6 | "tabWidth": 2, 7 | "trailingComma": "all" 8 | } 9 | -------------------------------------------------------------------------------- /web-example/README.md: -------------------------------------------------------------------------------- 1 | # Khronos glTF Asset Auditor 2 | 3 | # Web Implementation (single page app) 4 | 5 | ## SPDX-License-Identifier: Apache-2.0 6 | 7 | This is a sub-project of the glTF Asset Auditor which shows how to implement a version from a web browser stand alone page. 8 | 9 | ### Usage - Compile and Test Locally 10 | 11 | This project uses Node + webpack to build dist/main.js. 12 | 13 | Run npm i to install dependencies (glTF Asset Auditor and Babylon.js). 14 | 15 | ``` 16 | # Install Dependencies 17 | npm i 18 | ``` 19 | 20 | Build the project to generate main.js in the dist/ folder. 21 | 22 | ``` 23 | # Compile the code with Webpack 24 | npm run build 25 | ``` 26 | 27 | A simple web server can be run using python for local testing. 28 | 29 | ``` 30 | # Run a simple server on http://localhost:3000 31 | python3 -m http.server 3000 32 | ``` 33 | 34 | Open http://localhost:3000 in a modern web browser to test it out. 35 | 36 | ***Important Note*** 37 | 38 | webpack.config.js needs to include a fallback section that sets fs and path to false, as seen here in the example: https://github.com/KhronosGroup/gltf-asset-auditor/blob/main/web-example/webpack.config.js#L40 39 | 40 | ``` 41 | fallback: { 42 | fs: false, 43 | path: false, 44 | }, 45 | ``` 46 | 47 | The reason that those values are needed is because the Asset Auditor was built to be used in both a Node.js environment as well as in a web browser and the fs (filesystem) and path modules are only available in Node.js. The frontend code doesn't call those functions, but the compiler (webpack) tries to link them anyway and setting them to false in the config allows for them to be ignored. 48 | 49 | ### Deployment 50 | 51 | index.html and the dist/ folder are all of the required files and can be statically hosted from any server after they are built. 52 | -------------------------------------------------------------------------------- /web-example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@khronosgroup/gltf-asset-auditor-web", 3 | "version": "1.0.3", 4 | "description": "Web based single page application implementation of the gltf-asset-auditor package", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "test", 8 | "help": "webpack --help", 9 | "init": "webpack-cli init", 10 | "start": "webpack --config webpack.config.js --watch", 11 | "build": "webpack --mode=production --node-env=production", 12 | "build:dev": "webpack --mode=development", 13 | "build:prod": "webpack --mode=production --node-env=production", 14 | "watch": "webpack --watch" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "https://github.com/KhronosGroup/gltf-asset-auditor/tree/main/web-example" 19 | }, 20 | "keywords": [ 21 | "3d", 22 | "asset", 23 | "commerce", 24 | "glb", 25 | "gltf", 26 | "khronos", 27 | "auditor", 28 | "validator", 29 | "web" 30 | ], 31 | "author": "Mike Festa", 32 | "license": "Apache-2.0", 33 | "dependencies": { 34 | "@babylonjs/core": "^5.35.1", 35 | "@babylonjs/loaders": "^5.35.1", 36 | "@khronosgroup/gltf-asset-auditor": "^1.0.3" 37 | }, 38 | "devDependencies": { 39 | "ts-loader": "^9.3.1", 40 | "tslib": "^2.4.0", 41 | "webpack": "^5.73.0", 42 | "webpack-cli": "^4.10.0" 43 | } 44 | } -------------------------------------------------------------------------------- /web-example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "noImplicitAny": true, 5 | "module": "es6", 6 | "target": "es5", 7 | "allowJs": true 8 | }, 9 | "files": ["src/index.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /web-example/webpack.config.js: -------------------------------------------------------------------------------- 1 | // Generated using webpack-cli https://github.com/webpack/webpack-cli 2 | 3 | const path = require('path'); 4 | 5 | const isProduction = process.env.NODE_ENV == 'production'; 6 | 7 | const stylesHandler = 'style-loader'; 8 | 9 | const config = { 10 | entry: './src/index.ts', 11 | output: { 12 | path: path.resolve(__dirname, 'dist'), 13 | }, 14 | plugins: [ 15 | // Add your plugins here 16 | // Learn more about plugins from https://webpack.js.org/configuration/plugins/ 17 | ], 18 | module: { 19 | rules: [ 20 | { 21 | test: /\.(ts|tsx)$/i, 22 | loader: 'ts-loader', 23 | exclude: ['/node_modules/'], 24 | }, 25 | { 26 | test: /\.css$/i, 27 | use: [stylesHandler, 'css-loader'], 28 | }, 29 | { 30 | test: /\.(eot|svg|ttf|woff|woff2|png|jpg|gif)$/i, 31 | type: 'asset', 32 | }, 33 | 34 | // Add your rules for custom modules here 35 | // Learn more about loaders from https://webpack.js.org/loaders/ 36 | ], 37 | }, 38 | resolve: { 39 | extensions: ['.tsx', '.ts', '.jsx', '.js', '...'], 40 | fallback: { 41 | // This allows webpack to ignore imports to fs and path, which are Node.js specific but used in the glTF Asset Auditor module 42 | fs: false, 43 | path: false, 44 | }, 45 | }, 46 | }; 47 | 48 | module.exports = () => { 49 | if (isProduction) { 50 | config.mode = 'production'; 51 | } else { 52 | config.mode = 'development'; 53 | } 54 | return config; 55 | }; 56 | --------------------------------------------------------------------------------