├── .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 |
--------------------------------------------------------------------------------