├── .github └── FUNDING.yml ├── .gitignore ├── .prettierrc.json ├── LICENSE ├── README.md ├── cors.json ├── index.html ├── package.json ├── public └── favicon.ico ├── src ├── app.js ├── components │ ├── footer.jsx │ ├── validator-report.jsx │ ├── validator-table.jsx │ └── validator-toggle.jsx ├── environments.js ├── validator.js └── viewer.js ├── style.css ├── vercel.json └── yarn.lock /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [donmccurdy] 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .cache 4 | .vercel 5 | .DS_Store 6 | npm-debug.log* 7 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "useTabs": true, 4 | "tabWidth": 4, 5 | "printWidth": 100, 6 | "bracketSpacing": true 7 | } 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2023 Don McCurdy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # glTF Viewer 2 | 3 | Preview glTF 2.0 models in WebGL using three.js and a drag-and-drop interface. 4 | 5 | Viewer: [gltf-viewer.donmccurdy.com](https://gltf-viewer.donmccurdy.com/) 6 | 7 | ![screenshot](https://user-images.githubusercontent.com/1848368/31580352-b7354096-b101-11e7-86d7-f07677835812.png) 8 | 9 | ## Quickstart 10 | 11 | ``` 12 | npm install 13 | npm run dev 14 | ``` 15 | 16 | ## glTF 2.0 Resources 17 | 18 | - [THREE.GLTFLoader](https://threejs.org/docs/#examples/en/loaders/GLTFLoader) 19 | - [glTF 2.0 Specification](https://github.com/KhronosGroup/glTF/blob/master/specification/2.0/README.md) 20 | - [glTF 2.0 Sample Models](https://github.com/KhronosGroup/glTF-Sample-Models/tree/master/2.0/) 21 | 22 | ## Known Issues 23 | 24 | - [ ] Limited drag-and-drop support in Safari. 25 | -------------------------------------------------------------------------------- /cors.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "method": ["GET"], 4 | "origin": ["https://*.donmccurdy.com", "http://localhost:*", "https://localhost:*"], 5 | "responseHeader": ["Content-Type"], 6 | "maxAgeSeconds": 3600 7 | } 8 | ] 9 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | glTF Viewer 6 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 |

glTF Viewer

21 |
22 |
23 |
24 |
25 |

Drag glTF 2.0 file or folder here

26 |
27 |
28 | 29 | 42 |
43 |
44 |
45 |
46 | 47 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "version": "1.5.1", 4 | "description": "Preview glTF models using three.js and a drag-and-drop interface.", 5 | "author": "Don McCurdy (https://www.donmccurdy.com)", 6 | "license": "MIT", 7 | "main": "public/app.js", 8 | "browserslist": [ 9 | ">1%", 10 | "not dead" 11 | ], 12 | "staticFiles": { 13 | "staticPath": [ 14 | { 15 | "staticPath": "assets", 16 | "staticOutDir": "assets" 17 | } 18 | ] 19 | }, 20 | "scripts": { 21 | "dev": "vite --port 3000", 22 | "build": "vite build", 23 | "clean": "rm -rf dist/* || true", 24 | "test": "node test/gen_test.js", 25 | "deploy": "npm run build && vercel --local-config vercel.json --prod", 26 | "postversion": "git push && git push --tags" 27 | }, 28 | "dependencies": { 29 | "dat.gui": "^0.7.9", 30 | "gltf-validator": "^2.0.0-dev.3.10", 31 | "query-string": "^8.1.0", 32 | "simple-dropzone": "^0.8.3", 33 | "three": "^0.176.0", 34 | "vhtml": "^2.2.0" 35 | }, 36 | "devDependencies": { 37 | "prettier": "^3.2.5", 38 | "vite": "^5.1.8" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/donmccurdy/three-gltf-viewer/bd83b39339a525708387006aba976342f036713e/public/favicon.ico -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | import WebGL from 'three/addons/capabilities/WebGL.js'; 3 | import { Viewer } from './viewer.js'; 4 | import { SimpleDropzone } from 'simple-dropzone'; 5 | import { Validator } from './validator.js'; 6 | import { Footer } from './components/footer'; 7 | import queryString from 'query-string'; 8 | 9 | window.THREE = THREE; 10 | window.VIEWER = {}; 11 | 12 | if (!(window.File && window.FileReader && window.FileList && window.Blob)) { 13 | console.error('The File APIs are not fully supported in this browser.'); 14 | } else if (!WebGL.isWebGL2Available()) { 15 | console.error('WebGL is not supported in this browser.'); 16 | } 17 | 18 | class App { 19 | /** 20 | * @param {Element} el 21 | * @param {Location} location 22 | */ 23 | constructor(el, location) { 24 | const hash = location.hash ? queryString.parse(location.hash) : {}; 25 | this.options = { 26 | kiosk: Boolean(hash.kiosk), 27 | model: hash.model || '', 28 | preset: hash.preset || '', 29 | cameraPosition: hash.cameraPosition ? hash.cameraPosition.split(',').map(Number) : null, 30 | }; 31 | 32 | this.el = el; 33 | this.viewer = null; 34 | this.viewerEl = null; 35 | this.spinnerEl = el.querySelector('.spinner'); 36 | this.dropEl = el.querySelector('.dropzone'); 37 | this.inputEl = el.querySelector('#file-input'); 38 | this.validator = new Validator(el); 39 | 40 | this.createDropzone(); 41 | this.hideSpinner(); 42 | 43 | const options = this.options; 44 | 45 | if (options.kiosk) { 46 | const headerEl = document.querySelector('header'); 47 | headerEl.style.display = 'none'; 48 | } 49 | 50 | if (options.model) { 51 | this.view(options.model, '', new Map()); 52 | } 53 | } 54 | 55 | /** 56 | * Sets up the drag-and-drop controller. 57 | */ 58 | createDropzone() { 59 | const dropCtrl = new SimpleDropzone(this.dropEl, this.inputEl); 60 | dropCtrl.on('drop', ({ files }) => this.load(files)); 61 | dropCtrl.on('dropstart', () => this.showSpinner()); 62 | dropCtrl.on('droperror', () => this.hideSpinner()); 63 | } 64 | 65 | /** 66 | * Sets up the view manager. 67 | * @return {Viewer} 68 | */ 69 | createViewer() { 70 | this.viewerEl = document.createElement('div'); 71 | this.viewerEl.classList.add('viewer'); 72 | this.dropEl.innerHTML = ''; 73 | this.dropEl.appendChild(this.viewerEl); 74 | this.viewer = new Viewer(this.viewerEl, this.options); 75 | return this.viewer; 76 | } 77 | 78 | /** 79 | * Loads a fileset provided by user action. 80 | * @param {Map} fileMap 81 | */ 82 | load(fileMap) { 83 | let rootFile; 84 | let rootPath; 85 | Array.from(fileMap).forEach(([path, file]) => { 86 | if (file.name.match(/\.(gltf|glb)$/)) { 87 | rootFile = file; 88 | rootPath = path.replace(file.name, ''); 89 | } 90 | }); 91 | 92 | if (!rootFile) { 93 | this.onError('No .gltf or .glb asset found.'); 94 | } 95 | 96 | this.view(rootFile, rootPath, fileMap); 97 | } 98 | 99 | /** 100 | * Passes a model to the viewer, given file and resources. 101 | * @param {File|string} rootFile 102 | * @param {string} rootPath 103 | * @param {Map} fileMap 104 | */ 105 | view(rootFile, rootPath, fileMap) { 106 | if (this.viewer) this.viewer.clear(); 107 | 108 | const viewer = this.viewer || this.createViewer(); 109 | 110 | const fileURL = typeof rootFile === 'string' ? rootFile : URL.createObjectURL(rootFile); 111 | 112 | const cleanup = () => { 113 | this.hideSpinner(); 114 | if (typeof rootFile === 'object') URL.revokeObjectURL(fileURL); 115 | }; 116 | 117 | viewer 118 | .load(fileURL, rootPath, fileMap) 119 | .catch((e) => this.onError(e)) 120 | .then((gltf) => { 121 | // TODO: GLTFLoader parsing can fail on invalid files. Ideally, 122 | // we could run the validator either way. 123 | if (!this.options.kiosk) { 124 | this.validator.validate(fileURL, rootPath, fileMap, gltf); 125 | } 126 | cleanup(); 127 | }); 128 | } 129 | 130 | /** 131 | * @param {Error} error 132 | */ 133 | onError(error) { 134 | let message = (error || {}).message || error.toString(); 135 | if (message.match(/ProgressEvent/)) { 136 | message = 'Unable to retrieve this file. Check JS console and browser network tab.'; 137 | } else if (message.match(/Unexpected token/)) { 138 | message = `Unable to parse file content. Verify that this file is valid. Error: "${message}"`; 139 | } else if (error && error.target && error.target instanceof Image) { 140 | message = 'Missing texture: ' + error.target.src.split('/').pop(); 141 | } 142 | window.alert(message); 143 | console.error(error); 144 | } 145 | 146 | showSpinner() { 147 | this.spinnerEl.style.display = ''; 148 | } 149 | 150 | hideSpinner() { 151 | this.spinnerEl.style.display = 'none'; 152 | } 153 | } 154 | 155 | document.body.innerHTML += Footer(); 156 | 157 | document.addEventListener('DOMContentLoaded', () => { 158 | const app = new App(document.body, location); 159 | 160 | window.VIEWER.app = app; 161 | 162 | console.info('[glTF Viewer] Debugging data exported as `window.VIEWER`.'); 163 | }); 164 | -------------------------------------------------------------------------------- /src/components/footer.jsx: -------------------------------------------------------------------------------- 1 | import vhtml from 'vhtml'; 2 | import { REVISION } from 'three'; 3 | 4 | /** @jsx vhtml */ 5 | 6 | export function Footer() { 7 | return ( 8 | 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /src/components/validator-report.jsx: -------------------------------------------------------------------------------- 1 | import vhtml from 'vhtml'; 2 | import { ValidatorTable } from './validator-table'; 3 | 4 | /** @jsx vhtml */ 5 | 6 | export function ValidatorReport({ 7 | info, 8 | validatorVersion, 9 | issues, 10 | errors, 11 | warnings, 12 | hints, 13 | infos, 14 | }) { 15 | return ( 16 |
17 |

Validation report

18 |
    19 |
  • 20 | Format: glTF {info.version} 21 |
  • 22 |
  • 23 | Generator: {info.generator} 24 |
  • 25 | {info?.extras?.title && ( 26 |
  • 27 | Title:{' '} 28 | 29 |
  • 30 | )} 31 | {info?.extras?.author && ( 32 |
  • 33 | Author:{' '} 34 | 35 |
  • 36 | )} 37 | {info?.extras?.license && ( 38 |
  • 39 | License:{' '} 40 | 41 |
  • 42 | )} 43 | {info?.extras?.source && ( 44 |
  • 45 | Source:{' '} 46 | 47 |
  • 48 | )} 49 |
  • 50 | Stats: 51 |
      52 |
    • {info.drawCallCount || '0'} draw calls
    • 53 |
    • {info.animationCount || '0'} animations
    • 54 |
    • {info.materialCount || '0'} materials
    • 55 |
    • {info.totalVertexCount || '0'} vertices
    • 56 |
    • {info.totalTriangleCount || '0'} triangles
    • 57 |
    58 |
  • 59 |
  • 60 | Extensions: 61 |
      62 | {info.extensionsUsed?.length ? ( 63 | info.extensionsUsed.map((extension) =>
    • {extension}
    • ) 64 | ) : ( 65 |
    • None
    • 66 | )} 67 |
    68 | {info.extensionsUsed?.length && ( 69 |

    70 | 71 | NOTE: Extensions above are present in the model, but may or may not 72 | be recognized by this viewer. Any "UNSUPPORTED_EXTENSION" warnings 73 | below refer only to extensions that could not be scanned by the 74 | validation suite, and may still have rendered correctly. See:{' '} 75 | 79 | three-gltf-viewer#122 80 | 81 | . 82 | 83 |

    84 | )} 85 |
  • 86 |
87 |
88 |

89 | Report generated by 90 | 91 | KhronosGroup/glTF-Validator 92 | {' '} 93 | {validatorVersion}. 94 |

95 | {issues.numErrors && } 96 | {issues.numWarnings && ( 97 | 98 | )} 99 | {issues.numHints && } 100 | {issues.numInfos && } 101 |
102 | ); 103 | } 104 | -------------------------------------------------------------------------------- /src/components/validator-table.jsx: -------------------------------------------------------------------------------- 1 | import vhtml from 'vhtml'; 2 | 3 | /** @jsx vhtml */ 4 | 5 | export function ValidatorTable({ title, color, messages }) { 6 | return ( 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | {messages.map(({ code, message, pointer }) => { 17 | return ( 18 | 19 | 22 | 23 | 26 | 27 | ); 28 | })} 29 | {messages.length === 0 && ( 30 | 31 | 32 | 33 | )} 34 | 35 |
{title}MessagePointer
20 | {code} 21 | {message} 24 | {pointer} 25 |
No issues found.
36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /src/components/validator-toggle.jsx: -------------------------------------------------------------------------------- 1 | import vhtml from 'vhtml'; 2 | 3 | /** @jsx vhtml */ 4 | 5 | export function ValidatorToggle({ issues, reportError }) { 6 | let levelClassName = ''; 7 | let message = ''; 8 | 9 | if (issues) { 10 | if (issues.numErrors) { 11 | message = `${issues.numErrors} errors.`; 12 | } else if (issues.numWarnings) { 13 | message = `${issues.numWarnings} warnings.`; 14 | } else if (issues.numHints) { 15 | message = `${issues.numHints} hints.`; 16 | } else if (issues.numInfos) { 17 | message = `${issues.numInfos} notes.`; 18 | } else { 19 | message = 'Model details'; 20 | } 21 | levelClassName = `level-${issues.maxSeverity}`; 22 | } else if (reportError) { 23 | message = `Validation could not run: ${reportError}.`; 24 | } else { 25 | message = 'Validation could not run.'; 26 | } 27 | 28 | return ( 29 |
30 |
{message}
31 |
32 | × 33 |
34 |
35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /src/environments.js: -------------------------------------------------------------------------------- 1 | export const environments = [ 2 | { 3 | id: '', 4 | name: 'None', 5 | path: null, 6 | }, 7 | { 8 | id: 'neutral', // THREE.RoomEnvironment 9 | name: 'Neutral', 10 | path: null, 11 | }, 12 | { 13 | id: 'venice-sunset', 14 | name: 'Venice Sunset', 15 | path: 'https://storage.googleapis.com/donmccurdy-static/venice_sunset_1k.exr', 16 | format: '.exr', 17 | }, 18 | { 19 | id: 'footprint-court', 20 | name: 'Footprint Court (HDR Labs)', 21 | path: 'https://storage.googleapis.com/donmccurdy-static/footprint_court_2k.exr', 22 | format: '.exr', 23 | }, 24 | ]; 25 | -------------------------------------------------------------------------------- /src/validator.js: -------------------------------------------------------------------------------- 1 | import { LoaderUtils } from 'three'; 2 | import { validateBytes } from 'gltf-validator'; 3 | import { ValidatorToggle } from './components/validator-toggle'; 4 | import { ValidatorReport } from './components/validator-report'; 5 | 6 | const SEVERITY_MAP = ['Errors', 'Warnings', 'Infos', 'Hints']; 7 | 8 | export class Validator { 9 | /** 10 | * @param {Element} el 11 | */ 12 | constructor(el) { 13 | this.el = el; 14 | this.report = null; 15 | 16 | this.toggleEl = document.createElement('div'); 17 | this.toggleEl.classList.add('report-toggle-wrap', 'hidden'); 18 | this.el.appendChild(this.toggleEl); 19 | } 20 | 21 | /** 22 | * Runs validation against the given file URL and extra resources. 23 | * @param {string} rootFile 24 | * @param {string} rootPath 25 | * @param {Map} assetMap 26 | * @param {Object} response 27 | * @return {Promise} 28 | */ 29 | validate(rootFile, rootPath, assetMap, response) { 30 | // TODO: This duplicates a request of the three.js loader, and could 31 | // take advantage of THREE.Cache after r90. 32 | return fetch(rootFile) 33 | .then((response) => response.arrayBuffer()) 34 | .then((buffer) => 35 | validateBytes(new Uint8Array(buffer), { 36 | externalResourceFunction: (uri) => 37 | this.resolveExternalResource(uri, rootFile, rootPath, assetMap), 38 | }), 39 | ) 40 | .then((report) => this.setReport(report, response)) 41 | .catch((e) => this.setReportException(e)); 42 | } 43 | 44 | /** 45 | * Loads a resource (either locally or from the network) and returns it. 46 | * @param {string} uri 47 | * @param {string} rootFile 48 | * @param {string} rootPath 49 | * @param {Map} assetMap 50 | * @return {Promise} 51 | */ 52 | resolveExternalResource(uri, rootFile, rootPath, assetMap) { 53 | const baseURL = LoaderUtils.extractUrlBase(rootFile); 54 | const normalizedURL = 55 | rootPath + 56 | decodeURI(uri) // validator applies URI encoding. 57 | .replace(baseURL, '') 58 | .replace(/^(\.?\/)/, ''); 59 | 60 | let objectURL; 61 | 62 | if (assetMap.has(normalizedURL)) { 63 | const object = assetMap.get(normalizedURL); 64 | objectURL = URL.createObjectURL(object); 65 | } 66 | 67 | return fetch(objectURL || baseURL + uri) 68 | .then((response) => response.arrayBuffer()) 69 | .then((buffer) => { 70 | if (objectURL) URL.revokeObjectURL(objectURL); 71 | return new Uint8Array(buffer); 72 | }); 73 | } 74 | 75 | /** 76 | * @param {GLTFValidator.Report} report 77 | * @param {Object} response 78 | */ 79 | setReport(report, response) { 80 | report.generator = (report && report.info && report.info.generator) || ''; 81 | report.issues.maxSeverity = -1; 82 | SEVERITY_MAP.forEach((severity, index) => { 83 | if (report.issues[`num${severity}`] > 0 && report.issues.maxSeverity === -1) { 84 | report.issues.maxSeverity = index; 85 | } 86 | }); 87 | report.errors = report.issues.messages.filter((msg) => msg.severity === 0); 88 | report.warnings = report.issues.messages.filter((msg) => msg.severity === 1); 89 | report.infos = report.issues.messages.filter((msg) => msg.severity === 2); 90 | report.hints = report.issues.messages.filter((msg) => msg.severity === 3); 91 | groupMessages(report); 92 | this.report = report; 93 | 94 | this.setResponse(response); 95 | 96 | this.toggleEl.innerHTML = ValidatorToggle(report); 97 | this.showToggle(); 98 | this.bindListeners(); 99 | 100 | function groupMessages(report) { 101 | const CODES = { 102 | ACCESSOR_NON_UNIT: { 103 | message: '{count} accessor elements not of unit length: 0. [AGGREGATED]', 104 | pointerCounts: {}, 105 | }, 106 | ACCESSOR_ANIMATION_INPUT_NON_INCREASING: { 107 | message: 108 | '{count} animation input accessor elements not in ascending order. [AGGREGATED]', 109 | pointerCounts: {}, 110 | }, 111 | }; 112 | 113 | report.errors.forEach((message) => { 114 | if (!CODES[message.code]) return; 115 | if (!CODES[message.code].pointerCounts[message.pointer]) { 116 | CODES[message.code].pointerCounts[message.pointer] = 0; 117 | } 118 | CODES[message.code].pointerCounts[message.pointer]++; 119 | }); 120 | report.errors = report.errors.filter((message) => { 121 | if (!CODES[message.code]) return true; 122 | if (!CODES[message.code].pointerCounts[message.pointer]) return true; 123 | return CODES[message.code].pointerCounts[message.pointer] < 2; 124 | }); 125 | Object.keys(CODES).forEach((code) => { 126 | Object.keys(CODES[code].pointerCounts).forEach((pointer) => { 127 | report.errors.push({ 128 | code: code, 129 | pointer: pointer, 130 | message: CODES[code].message.replace( 131 | '{count}', 132 | CODES[code].pointerCounts[pointer], 133 | ), 134 | }); 135 | }); 136 | }); 137 | } 138 | } 139 | 140 | /** 141 | * @param {Object} response 142 | */ 143 | setResponse(response) { 144 | const json = response && response.parser && response.parser.json; 145 | 146 | if (!json) return; 147 | 148 | if (json.asset && json.asset.extras) { 149 | const extras = json.asset.extras; 150 | this.report.info.extras = {}; 151 | if (extras.author) { 152 | this.report.info.extras.author = linkify(escapeHTML(extras.author)); 153 | } 154 | if (extras.license) { 155 | this.report.info.extras.license = linkify(escapeHTML(extras.license)); 156 | } 157 | if (extras.source) { 158 | this.report.info.extras.source = linkify(escapeHTML(extras.source)); 159 | } 160 | if (extras.title) { 161 | this.report.info.extras.title = extras.title; 162 | } 163 | } 164 | } 165 | 166 | /** 167 | * @param {Error} e 168 | */ 169 | setReportException(e) { 170 | this.report = null; 171 | this.toggleEl.innerHTML = this.toggleTpl({ reportError: e, level: 0 }); 172 | this.showToggle(); 173 | this.bindListeners(); 174 | } 175 | 176 | bindListeners() { 177 | const reportToggleBtn = this.toggleEl.querySelector('.report-toggle'); 178 | reportToggleBtn.addEventListener('click', () => this.showLightbox()); 179 | 180 | const reportToggleCloseBtn = this.toggleEl.querySelector('.report-toggle-close'); 181 | reportToggleCloseBtn.addEventListener('click', (e) => { 182 | this.hideToggle(); 183 | e.stopPropagation(); 184 | }); 185 | } 186 | 187 | showToggle() { 188 | this.toggleEl.classList.remove('hidden'); 189 | } 190 | 191 | hideToggle() { 192 | this.toggleEl.classList.add('hidden'); 193 | } 194 | 195 | showLightbox() { 196 | if (!this.report) return; 197 | const tab = window.open('', '_blank'); 198 | tab.document.body.innerHTML = ` 199 | 200 | glTF 2.0 validation report 201 | 202 | 203 | 207 | ${ValidatorReport({ ...this.report, location })}`; 208 | } 209 | } 210 | 211 | function escapeHTML(unsafe) { 212 | return unsafe 213 | .replace(/&/g, '&') 214 | .replace(//g, '>') 216 | .replace(/"/g, '"') 217 | .replace(/'/g, '''); 218 | } 219 | 220 | function linkify(text) { 221 | const urlPattern = /\b(?:https?):\/\/[a-z0-9-+&@#\/%?=~_|!:,.;]*[a-z0-9-+&@#\/%=~_|]/gim; 222 | const emailAddressPattern = /(([a-zA-Z0-9_\-\.]+)@[a-zA-Z_]+?(?:\.[a-zA-Z]{2,6}))+/gim; 223 | return text 224 | .replace(urlPattern, '$&') 225 | .replace(emailAddressPattern, '$1'); 226 | } 227 | -------------------------------------------------------------------------------- /src/viewer.js: -------------------------------------------------------------------------------- 1 | import { 2 | AmbientLight, 3 | AnimationMixer, 4 | AxesHelper, 5 | Box3, 6 | Cache, 7 | Color, 8 | DirectionalLight, 9 | GridHelper, 10 | HemisphereLight, 11 | LoaderUtils, 12 | LoadingManager, 13 | PMREMGenerator, 14 | PerspectiveCamera, 15 | PointsMaterial, 16 | REVISION, 17 | Scene, 18 | SkeletonHelper, 19 | Vector3, 20 | WebGLRenderer, 21 | LinearToneMapping, 22 | ACESFilmicToneMapping, 23 | } from 'three'; 24 | import Stats from 'three/addons/libs/stats.module.js'; 25 | import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; 26 | import { KTX2Loader } from 'three/addons/loaders/KTX2Loader.js'; 27 | import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js'; 28 | import { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js'; 29 | import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; 30 | import { EXRLoader } from 'three/addons/loaders/EXRLoader.js'; 31 | import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js'; 32 | 33 | import { GUI } from 'dat.gui'; 34 | 35 | import { environments } from './environments.js'; 36 | 37 | const DEFAULT_CAMERA = '[default]'; 38 | 39 | const MANAGER = new LoadingManager(); 40 | const THREE_PATH = `https://unpkg.com/three@0.${REVISION}.x`; 41 | const DRACO_LOADER = new DRACOLoader(MANAGER).setDecoderPath( 42 | `${THREE_PATH}/examples/jsm/libs/draco/gltf/`, 43 | ); 44 | const KTX2_LOADER = new KTX2Loader(MANAGER).setTranscoderPath( 45 | `${THREE_PATH}/examples/jsm/libs/basis/`, 46 | ); 47 | 48 | const IS_IOS = isIOS(); 49 | 50 | const Preset = { ASSET_GENERATOR: 'assetgenerator' }; 51 | 52 | Cache.enabled = true; 53 | 54 | export class Viewer { 55 | constructor(el, options) { 56 | this.el = el; 57 | this.options = options; 58 | 59 | this.lights = []; 60 | this.content = null; 61 | this.mixer = null; 62 | this.clips = []; 63 | this.gui = null; 64 | 65 | this.state = { 66 | environment: 67 | options.preset === Preset.ASSET_GENERATOR 68 | ? environments.find((e) => e.id === 'footprint-court').name 69 | : environments[1].name, 70 | background: false, 71 | playbackSpeed: 1.0, 72 | actionStates: {}, 73 | camera: DEFAULT_CAMERA, 74 | wireframe: false, 75 | skeleton: false, 76 | grid: false, 77 | autoRotate: false, 78 | 79 | // Lights 80 | punctualLights: true, 81 | exposure: 0.0, 82 | toneMapping: LinearToneMapping, 83 | ambientIntensity: 0.3, 84 | ambientColor: '#FFFFFF', 85 | directIntensity: 0.8 * Math.PI, // TODO(#116) 86 | directColor: '#FFFFFF', 87 | bgColor: '#191919', 88 | 89 | pointSize: 1.0, 90 | }; 91 | 92 | this.prevTime = 0; 93 | 94 | this.stats = new Stats(); 95 | this.stats.dom.height = '48px'; 96 | [].forEach.call(this.stats.dom.children, (child) => (child.style.display = '')); 97 | 98 | this.backgroundColor = new Color(this.state.bgColor); 99 | 100 | this.scene = new Scene(); 101 | this.scene.background = this.backgroundColor; 102 | 103 | const fov = options.preset === Preset.ASSET_GENERATOR ? (0.8 * 180) / Math.PI : 60; 104 | const aspect = el.clientWidth / el.clientHeight; 105 | this.defaultCamera = new PerspectiveCamera(fov, aspect, 0.01, 1000); 106 | this.activeCamera = this.defaultCamera; 107 | this.scene.add(this.defaultCamera); 108 | 109 | this.renderer = window.renderer = new WebGLRenderer({ antialias: true }); 110 | this.renderer.setClearColor(0xcccccc); 111 | this.renderer.setPixelRatio(window.devicePixelRatio); 112 | this.renderer.setSize(el.clientWidth, el.clientHeight); 113 | 114 | this.pmremGenerator = new PMREMGenerator(this.renderer); 115 | this.pmremGenerator.compileEquirectangularShader(); 116 | 117 | this.neutralEnvironment = this.pmremGenerator.fromScene(new RoomEnvironment()).texture; 118 | 119 | this.controls = new OrbitControls(this.defaultCamera, this.renderer.domElement); 120 | this.controls.screenSpacePanning = true; 121 | 122 | this.el.appendChild(this.renderer.domElement); 123 | 124 | this.cameraCtrl = null; 125 | this.cameraFolder = null; 126 | this.animFolder = null; 127 | this.animCtrls = []; 128 | this.morphFolder = null; 129 | this.morphCtrls = []; 130 | this.skeletonHelpers = []; 131 | this.gridHelper = null; 132 | this.axesHelper = null; 133 | 134 | this.addAxesHelper(); 135 | this.addGUI(); 136 | if (options.kiosk) this.gui.close(); 137 | 138 | this.animate = this.animate.bind(this); 139 | requestAnimationFrame(this.animate); 140 | window.addEventListener('resize', this.resize.bind(this), false); 141 | } 142 | 143 | animate(time) { 144 | requestAnimationFrame(this.animate); 145 | 146 | const dt = (time - this.prevTime) / 1000; 147 | 148 | this.controls.update(); 149 | this.stats.update(); 150 | this.mixer && this.mixer.update(dt); 151 | this.render(); 152 | 153 | this.prevTime = time; 154 | } 155 | 156 | render() { 157 | this.renderer.render(this.scene, this.activeCamera); 158 | if (this.state.grid) { 159 | this.axesCamera.position.copy(this.defaultCamera.position); 160 | this.axesCamera.lookAt(this.axesScene.position); 161 | this.axesRenderer.render(this.axesScene, this.axesCamera); 162 | } 163 | } 164 | 165 | resize() { 166 | const { clientHeight, clientWidth } = this.el.parentElement; 167 | 168 | this.defaultCamera.aspect = clientWidth / clientHeight; 169 | this.defaultCamera.updateProjectionMatrix(); 170 | this.renderer.setSize(clientWidth, clientHeight); 171 | 172 | this.axesCamera.aspect = this.axesDiv.clientWidth / this.axesDiv.clientHeight; 173 | this.axesCamera.updateProjectionMatrix(); 174 | this.axesRenderer.setSize(this.axesDiv.clientWidth, this.axesDiv.clientHeight); 175 | } 176 | 177 | load(url, rootPath, assetMap) { 178 | const baseURL = LoaderUtils.extractUrlBase(url); 179 | 180 | // Load. 181 | return new Promise((resolve, reject) => { 182 | // Intercept and override relative URLs. 183 | MANAGER.setURLModifier((url, path) => { 184 | // URIs in a glTF file may be escaped, or not. Assume that assetMap is 185 | // from an un-escaped source, and decode all URIs before lookups. 186 | // See: https://github.com/donmccurdy/three-gltf-viewer/issues/146 187 | const normalizedURL = 188 | rootPath + 189 | decodeURI(url) 190 | .replace(baseURL, '') 191 | .replace(/^(\.?\/)/, ''); 192 | 193 | if (assetMap.has(normalizedURL)) { 194 | const blob = assetMap.get(normalizedURL); 195 | const blobURL = URL.createObjectURL(blob); 196 | blobURLs.push(blobURL); 197 | return blobURL; 198 | } 199 | 200 | return (path || '') + url; 201 | }); 202 | 203 | const loader = new GLTFLoader(MANAGER) 204 | .setCrossOrigin('anonymous') 205 | .setDRACOLoader(DRACO_LOADER) 206 | .setKTX2Loader(KTX2_LOADER.detectSupport(this.renderer)) 207 | .setMeshoptDecoder(MeshoptDecoder); 208 | 209 | const blobURLs = []; 210 | 211 | loader.load( 212 | url, 213 | (gltf) => { 214 | window.VIEWER.json = gltf; 215 | 216 | const scene = gltf.scene || gltf.scenes[0]; 217 | const clips = gltf.animations || []; 218 | 219 | if (!scene) { 220 | // Valid, but not supported by this viewer. 221 | throw new Error( 222 | 'This model contains no scene, and cannot be viewed here. However,' + 223 | ' it may contain individual 3D resources.', 224 | ); 225 | } 226 | 227 | this.setContent(scene, clips); 228 | 229 | blobURLs.forEach(URL.revokeObjectURL); 230 | 231 | // See: https://github.com/google/draco/issues/349 232 | // DRACOLoader.releaseDecoderModule(); 233 | 234 | resolve(gltf); 235 | }, 236 | undefined, 237 | reject, 238 | ); 239 | }); 240 | } 241 | 242 | /** 243 | * @param {THREE.Object3D} object 244 | * @param {Array { 295 | if (node.isLight) { 296 | this.state.punctualLights = false; 297 | } 298 | }); 299 | 300 | this.setClips(clips); 301 | 302 | this.updateLights(); 303 | this.updateGUI(); 304 | this.updateEnvironment(); 305 | this.updateDisplay(); 306 | 307 | window.VIEWER.scene = this.content; 308 | 309 | this.printGraph(this.content); 310 | } 311 | 312 | printGraph(node) { 313 | console.group(' <' + node.type + '> ' + node.name); 314 | node.children.forEach((child) => this.printGraph(child)); 315 | console.groupEnd(); 316 | } 317 | 318 | /** 319 | * @param {Array { 336 | this.mixer.clipAction(clip).reset().play(); 337 | this.state.actionStates[clip.name] = true; 338 | }); 339 | } 340 | 341 | /** 342 | * @param {string} name 343 | */ 344 | setCamera(name) { 345 | if (name === DEFAULT_CAMERA) { 346 | this.controls.enabled = true; 347 | this.activeCamera = this.defaultCamera; 348 | } else { 349 | this.controls.enabled = false; 350 | this.content.traverse((node) => { 351 | if (node.isCamera && node.name === name) { 352 | this.activeCamera = node; 353 | } 354 | }); 355 | } 356 | } 357 | 358 | updateLights() { 359 | const state = this.state; 360 | const lights = this.lights; 361 | 362 | if (state.punctualLights && !lights.length) { 363 | this.addLights(); 364 | } else if (!state.punctualLights && lights.length) { 365 | this.removeLights(); 366 | } 367 | 368 | this.renderer.toneMapping = Number(state.toneMapping); 369 | this.renderer.toneMappingExposure = Math.pow(2, state.exposure); 370 | 371 | if (lights.length === 2) { 372 | lights[0].intensity = state.ambientIntensity; 373 | lights[0].color.set(state.ambientColor); 374 | lights[1].intensity = state.directIntensity; 375 | lights[1].color.set(state.directColor); 376 | } 377 | } 378 | 379 | addLights() { 380 | const state = this.state; 381 | 382 | if (this.options.preset === Preset.ASSET_GENERATOR) { 383 | const hemiLight = new HemisphereLight(); 384 | hemiLight.name = 'hemi_light'; 385 | this.scene.add(hemiLight); 386 | this.lights.push(hemiLight); 387 | return; 388 | } 389 | 390 | const light1 = new AmbientLight(state.ambientColor, state.ambientIntensity); 391 | light1.name = 'ambient_light'; 392 | this.defaultCamera.add(light1); 393 | 394 | const light2 = new DirectionalLight(state.directColor, state.directIntensity); 395 | light2.position.set(0.5, 0, 0.866); // ~60º 396 | light2.name = 'main_light'; 397 | this.defaultCamera.add(light2); 398 | 399 | this.lights.push(light1, light2); 400 | } 401 | 402 | removeLights() { 403 | this.lights.forEach((light) => light.parent.remove(light)); 404 | this.lights.length = 0; 405 | } 406 | 407 | updateEnvironment() { 408 | const environment = environments.filter( 409 | (entry) => entry.name === this.state.environment, 410 | )[0]; 411 | 412 | this.getCubeMapTexture(environment).then(({ envMap }) => { 413 | this.scene.environment = envMap; 414 | this.scene.background = this.state.background ? envMap : this.backgroundColor; 415 | }); 416 | } 417 | 418 | getCubeMapTexture(environment) { 419 | const { id, path } = environment; 420 | 421 | // neutral (THREE.RoomEnvironment) 422 | if (id === 'neutral') { 423 | return Promise.resolve({ envMap: this.neutralEnvironment }); 424 | } 425 | 426 | // none 427 | if (id === '') { 428 | return Promise.resolve({ envMap: null }); 429 | } 430 | 431 | return new Promise((resolve, reject) => { 432 | new EXRLoader().load( 433 | path, 434 | (texture) => { 435 | const envMap = this.pmremGenerator.fromEquirectangular(texture).texture; 436 | this.pmremGenerator.dispose(); 437 | 438 | resolve({ envMap }); 439 | }, 440 | undefined, 441 | reject, 442 | ); 443 | }); 444 | } 445 | 446 | updateDisplay() { 447 | if (this.skeletonHelpers.length) { 448 | this.skeletonHelpers.forEach((helper) => this.scene.remove(helper)); 449 | } 450 | 451 | traverseMaterials(this.content, (material) => { 452 | material.wireframe = this.state.wireframe; 453 | 454 | if (material instanceof PointsMaterial) { 455 | material.size = this.state.pointSize; 456 | } 457 | }); 458 | 459 | this.content.traverse((node) => { 460 | if (node.geometry && node.skeleton && this.state.skeleton) { 461 | const helper = new SkeletonHelper(node.skeleton.bones[0].parent); 462 | helper.material.linewidth = 3; 463 | this.scene.add(helper); 464 | this.skeletonHelpers.push(helper); 465 | } 466 | }); 467 | 468 | if (this.state.grid !== Boolean(this.gridHelper)) { 469 | if (this.state.grid) { 470 | this.gridHelper = new GridHelper(); 471 | this.axesHelper = new AxesHelper(); 472 | this.axesHelper.renderOrder = 999; 473 | this.axesHelper.onBeforeRender = (renderer) => renderer.clearDepth(); 474 | this.scene.add(this.gridHelper); 475 | this.scene.add(this.axesHelper); 476 | } else { 477 | this.scene.remove(this.gridHelper); 478 | this.scene.remove(this.axesHelper); 479 | this.gridHelper = null; 480 | this.axesHelper = null; 481 | this.axesRenderer.clear(); 482 | } 483 | } 484 | 485 | this.controls.autoRotate = this.state.autoRotate; 486 | } 487 | 488 | updateBackground() { 489 | this.backgroundColor.set(this.state.bgColor); 490 | } 491 | 492 | /** 493 | * Adds AxesHelper. 494 | * 495 | * See: https://stackoverflow.com/q/16226693/1314762 496 | */ 497 | addAxesHelper() { 498 | this.axesDiv = document.createElement('div'); 499 | this.el.appendChild(this.axesDiv); 500 | this.axesDiv.classList.add('axes'); 501 | 502 | const { clientWidth, clientHeight } = this.axesDiv; 503 | 504 | this.axesScene = new Scene(); 505 | this.axesCamera = new PerspectiveCamera(50, clientWidth / clientHeight, 0.1, 10); 506 | this.axesScene.add(this.axesCamera); 507 | 508 | this.axesRenderer = new WebGLRenderer({ alpha: true }); 509 | this.axesRenderer.setPixelRatio(window.devicePixelRatio); 510 | this.axesRenderer.setSize(this.axesDiv.clientWidth, this.axesDiv.clientHeight); 511 | 512 | this.axesCamera.up = this.defaultCamera.up; 513 | 514 | this.axesCorner = new AxesHelper(5); 515 | this.axesScene.add(this.axesCorner); 516 | this.axesDiv.appendChild(this.axesRenderer.domElement); 517 | } 518 | 519 | addGUI() { 520 | const gui = (this.gui = new GUI({ 521 | autoPlace: false, 522 | width: 260, 523 | hideable: true, 524 | })); 525 | 526 | // Display controls. 527 | const dispFolder = gui.addFolder('Display'); 528 | const envBackgroundCtrl = dispFolder.add(this.state, 'background'); 529 | envBackgroundCtrl.onChange(() => this.updateEnvironment()); 530 | const autoRotateCtrl = dispFolder.add(this.state, 'autoRotate'); 531 | autoRotateCtrl.onChange(() => this.updateDisplay()); 532 | const wireframeCtrl = dispFolder.add(this.state, 'wireframe'); 533 | wireframeCtrl.onChange(() => this.updateDisplay()); 534 | const skeletonCtrl = dispFolder.add(this.state, 'skeleton'); 535 | skeletonCtrl.onChange(() => this.updateDisplay()); 536 | const gridCtrl = dispFolder.add(this.state, 'grid'); 537 | gridCtrl.onChange(() => this.updateDisplay()); 538 | dispFolder.add(this.controls, 'screenSpacePanning'); 539 | const pointSizeCtrl = dispFolder.add(this.state, 'pointSize', 1, 16); 540 | pointSizeCtrl.onChange(() => this.updateDisplay()); 541 | const bgColorCtrl = dispFolder.addColor(this.state, 'bgColor'); 542 | bgColorCtrl.onChange(() => this.updateBackground()); 543 | 544 | // Lighting controls. 545 | const lightFolder = gui.addFolder('Lighting'); 546 | const envMapCtrl = lightFolder.add( 547 | this.state, 548 | 'environment', 549 | environments.map((env) => env.name), 550 | ); 551 | envMapCtrl.onChange(() => this.updateEnvironment()); 552 | [ 553 | lightFolder.add(this.state, 'toneMapping', { 554 | Linear: LinearToneMapping, 555 | 'ACES Filmic': ACESFilmicToneMapping, 556 | }), 557 | lightFolder.add(this.state, 'exposure', -10, 10, 0.01), 558 | lightFolder.add(this.state, 'punctualLights').listen(), 559 | lightFolder.add(this.state, 'ambientIntensity', 0, 2), 560 | lightFolder.addColor(this.state, 'ambientColor'), 561 | lightFolder.add(this.state, 'directIntensity', 0, 4), // TODO(#116) 562 | lightFolder.addColor(this.state, 'directColor'), 563 | ].forEach((ctrl) => ctrl.onChange(() => this.updateLights())); 564 | 565 | // Animation controls. 566 | this.animFolder = gui.addFolder('Animation'); 567 | this.animFolder.domElement.style.display = 'none'; 568 | const playbackSpeedCtrl = this.animFolder.add(this.state, 'playbackSpeed', 0, 1); 569 | playbackSpeedCtrl.onChange((speed) => { 570 | if (this.mixer) this.mixer.timeScale = speed; 571 | }); 572 | this.animFolder.add({ playAll: () => this.playAllClips() }, 'playAll'); 573 | 574 | // Morph target controls. 575 | this.morphFolder = gui.addFolder('Morph Targets'); 576 | this.morphFolder.domElement.style.display = 'none'; 577 | 578 | // Camera controls. 579 | this.cameraFolder = gui.addFolder('Cameras'); 580 | this.cameraFolder.domElement.style.display = 'none'; 581 | 582 | // Stats. 583 | const perfFolder = gui.addFolder('Performance'); 584 | const perfLi = document.createElement('li'); 585 | this.stats.dom.style.position = 'static'; 586 | perfLi.appendChild(this.stats.dom); 587 | perfLi.classList.add('gui-stats'); 588 | perfFolder.__ul.appendChild(perfLi); 589 | 590 | const guiWrap = document.createElement('div'); 591 | this.el.appendChild(guiWrap); 592 | guiWrap.classList.add('gui-wrap'); 593 | guiWrap.appendChild(gui.domElement); 594 | gui.open(); 595 | } 596 | 597 | updateGUI() { 598 | this.cameraFolder.domElement.style.display = 'none'; 599 | 600 | this.morphCtrls.forEach((ctrl) => ctrl.remove()); 601 | this.morphCtrls.length = 0; 602 | this.morphFolder.domElement.style.display = 'none'; 603 | 604 | this.animCtrls.forEach((ctrl) => ctrl.remove()); 605 | this.animCtrls.length = 0; 606 | this.animFolder.domElement.style.display = 'none'; 607 | 608 | const cameraNames = []; 609 | const morphMeshes = []; 610 | this.content.traverse((node) => { 611 | if (node.geometry && node.morphTargetInfluences) { 612 | morphMeshes.push(node); 613 | } 614 | if (node.isCamera) { 615 | node.name = node.name || `VIEWER__camera_${cameraNames.length + 1}`; 616 | cameraNames.push(node.name); 617 | } 618 | }); 619 | 620 | if (cameraNames.length) { 621 | this.cameraFolder.domElement.style.display = ''; 622 | if (this.cameraCtrl) this.cameraCtrl.remove(); 623 | const cameraOptions = [DEFAULT_CAMERA].concat(cameraNames); 624 | this.cameraCtrl = this.cameraFolder.add(this.state, 'camera', cameraOptions); 625 | this.cameraCtrl.onChange((name) => this.setCamera(name)); 626 | } 627 | 628 | if (morphMeshes.length) { 629 | this.morphFolder.domElement.style.display = ''; 630 | morphMeshes.forEach((mesh) => { 631 | if (mesh.morphTargetInfluences.length) { 632 | const nameCtrl = this.morphFolder.add( 633 | { name: mesh.name || 'Untitled' }, 634 | 'name', 635 | ); 636 | this.morphCtrls.push(nameCtrl); 637 | } 638 | for (let i = 0; i < mesh.morphTargetInfluences.length; i++) { 639 | const ctrl = this.morphFolder 640 | .add(mesh.morphTargetInfluences, i, 0, 1, 0.01) 641 | .listen(); 642 | Object.keys(mesh.morphTargetDictionary).forEach((key) => { 643 | if (key && mesh.morphTargetDictionary[key] === i) ctrl.name(key); 644 | }); 645 | this.morphCtrls.push(ctrl); 646 | } 647 | }); 648 | } 649 | 650 | if (this.clips.length) { 651 | this.animFolder.domElement.style.display = ''; 652 | const actionStates = (this.state.actionStates = {}); 653 | this.clips.forEach((clip, clipIndex) => { 654 | clip.name = `${clipIndex + 1}. ${clip.name}`; 655 | 656 | // Autoplay the first clip. 657 | let action; 658 | if (clipIndex === 0) { 659 | actionStates[clip.name] = true; 660 | action = this.mixer.clipAction(clip); 661 | action.play(); 662 | } else { 663 | actionStates[clip.name] = false; 664 | } 665 | 666 | // Play other clips when enabled. 667 | const ctrl = this.animFolder.add(actionStates, clip.name).listen(); 668 | ctrl.onChange((playAnimation) => { 669 | action = action || this.mixer.clipAction(clip); 670 | action.setEffectiveTimeScale(1); 671 | playAnimation ? action.play() : action.stop(); 672 | }); 673 | this.animCtrls.push(ctrl); 674 | }); 675 | } 676 | } 677 | 678 | clear() { 679 | if (!this.content) return; 680 | 681 | this.scene.remove(this.content); 682 | 683 | // dispose geometry 684 | this.content.traverse((node) => { 685 | if (!node.geometry) return; 686 | 687 | node.geometry.dispose(); 688 | }); 689 | 690 | // dispose textures 691 | traverseMaterials(this.content, (material) => { 692 | for (const key in material) { 693 | if (key !== 'envMap' && material[key] && material[key].isTexture) { 694 | material[key].dispose(); 695 | } 696 | } 697 | }); 698 | } 699 | } 700 | 701 | function traverseMaterials(object, callback) { 702 | object.traverse((node) => { 703 | if (!node.geometry) return; 704 | const materials = Array.isArray(node.material) ? node.material : [node.material]; 705 | materials.forEach(callback); 706 | }); 707 | } 708 | 709 | // https://stackoverflow.com/a/9039885/1314762 710 | function isIOS() { 711 | return ( 712 | ['iPad Simulator', 'iPhone Simulator', 'iPod Simulator', 'iPad', 'iPhone', 'iPod'].includes( 713 | navigator.platform, 714 | ) || 715 | // iPad on iOS 13 detection 716 | (navigator.userAgent.includes('Mac') && 'ontouchend' in document) 717 | ); 718 | } 719 | -------------------------------------------------------------------------------- /style.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: 'Raleway', sans-serif; 5 | background: #191919; 6 | height: 100%; 7 | overflow: hidden; 8 | } 9 | 10 | * { 11 | box-sizing: border-box; 12 | } 13 | 14 | body { 15 | display: flex; 16 | flex-direction: column; 17 | } 18 | 19 | .wrap { 20 | display: flex; 21 | width: 100vw; 22 | flex-grow: 1; 23 | position: relative; 24 | } 25 | 26 | .dropzone { 27 | display: flex; 28 | flex-grow: 1; 29 | flex-direction: column; 30 | justify-content: center; 31 | align-items: center; 32 | } 33 | 34 | .placeholder { 35 | width: 100%; 36 | max-width: 500px; 37 | border-radius: 0.5em; 38 | background: #252525; 39 | padding: 2em; 40 | text-align: center; 41 | } 42 | 43 | .placeholder p { 44 | font-size: 1.2rem; 45 | color: #999; 46 | } 47 | 48 | .viewer { 49 | width: 100%; 50 | height: 100%; 51 | flex-grow: 1; 52 | flex-shrink: 1; 53 | position: absolute; 54 | top: 0; 55 | z-index: 0; 56 | } 57 | 58 | .axes { 59 | width: 100px; 60 | height: 100px; 61 | margin: 20px; 62 | padding: 0px; 63 | position: absolute; 64 | left: 0px; 65 | bottom: 0px; 66 | z-index: 10; 67 | pointer-events: none; 68 | } 69 | 70 | /****************************************************************************** 71 | * Header 72 | */ 73 | 74 | header { 75 | display: flex; 76 | background: #353535; 77 | padding: 0 2em; 78 | height: 4rem; 79 | line-height: 4rem; 80 | align-items: center; 81 | overflow-x: auto; 82 | overflow-y: hidden; 83 | white-space: nowrap; 84 | box-shadow: 0px 0px 8px 2px rgba(0, 0, 0, 0.3); 85 | z-index: 1; 86 | 87 | -webkit-app-region: drag; 88 | } 89 | 90 | header h1 { 91 | color: #F5F5F5; 92 | font-size: 1.4rem; 93 | font-weight: 300; 94 | line-height: 4rem; 95 | margin: 0; 96 | } 97 | 98 | header h1 > a { 99 | color: inherit; 100 | font-size: inherit; 101 | text-decoration: inherit; 102 | } 103 | 104 | .gui-wrap { 105 | position: absolute; 106 | top: 0; 107 | right: 0; 108 | bottom: 0; 109 | pointer-events: none; 110 | } 111 | 112 | .gui-wrap > .main { 113 | pointer-events: all; 114 | max-height: 100%; 115 | overflow: auto; 116 | } 117 | 118 | .dg li.gui-stats:not(.folder) { 119 | height: auto; 120 | } 121 | 122 | @media screen and (max-width: 700px) { 123 | header h1 { 124 | font-size: 1em; 125 | } 126 | } 127 | 128 | /****************************************************************************** 129 | * Footer 130 | */ 131 | 132 | footer { 133 | position: absolute; 134 | bottom: 0.5em; 135 | right: 0.5em; 136 | font-family: monospace; 137 | color: #fff; 138 | } 139 | 140 | footer a { 141 | color: inherit; 142 | opacity: 0.5; 143 | text-decoration: inherit; 144 | } 145 | 146 | footer a:hover { 147 | opacity: 1; 148 | text-decoration: underline; 149 | } 150 | 151 | footer .separator { 152 | margin: 0 0.5em; 153 | opacity: 0.2; 154 | } 155 | 156 | /****************************************************************************** 157 | * Upload Button 158 | * 159 | * https://tympanus.net/Tutorials/CustomFileInputs/ 160 | */ 161 | 162 | .upload-btn { 163 | margin-top: 2em; 164 | } 165 | 166 | .upload-btn input { 167 | width: 0.1px; 168 | height: 0.1px; 169 | opacity: 0; 170 | overflow: hidden; 171 | position: absolute; 172 | z-index: -1; 173 | } 174 | 175 | .upload-btn label { 176 | color: #808080; 177 | border: 0; 178 | border-radius: 3px; 179 | transition: ease 0.2s background; 180 | font-size: 1rem; 181 | font-weight: 700; 182 | text-overflow: ellipsis; 183 | white-space: nowrap; 184 | cursor: pointer; 185 | display: inline-block; 186 | overflow: hidden; 187 | padding: 0.625rem 1.25rem; 188 | } 189 | 190 | .upload-btn label:hover { 191 | background: #252525; 192 | } 193 | 194 | .upload-btn svg { 195 | width: 1em; 196 | height: 1em; 197 | vertical-align: middle; 198 | fill: currentColor; 199 | margin-top: -0.25em; 200 | margin-right: 0.25em; 201 | } 202 | 203 | 204 | /****************************************************************************** 205 | * Validation report 206 | */ 207 | 208 | .report { 209 | padding: 2em; 210 | max-width: 860px; 211 | } 212 | 213 | .report h1 { 214 | margin-top: 0; 215 | } 216 | 217 | .report p, 218 | .report ul { 219 | line-height: 1.5em; 220 | } 221 | 222 | .report-table { 223 | text-align: left; 224 | border-collapse: collapse; 225 | width: 100%; 226 | } 227 | 228 | .report-table thead tr { 229 | background: #404040; 230 | color: #FFF; 231 | } 232 | 233 | .report-table th, 234 | .report-table td { 235 | padding: 0.5em 1em; 236 | } 237 | 238 | .report-table tr:nth-child(2n) { 239 | background: #F0F0F0; 240 | } 241 | 242 | /****************************************************************************** 243 | * Validation toggle 244 | */ 245 | 246 | .report-toggle-wrap.hidden { display: none; } 247 | 248 | .report-toggle { 249 | cursor: pointer; 250 | display: flex; 251 | position: absolute; 252 | bottom: 0; 253 | left: 20px; 254 | height: 30px; 255 | box-shadow: 0px 0px 5px 0 rgba(0, 0, 0, 0.25); 256 | background: #FFF; 257 | box-sizing: border-box; 258 | 259 | color: #f0f0f0; 260 | background: #000; 261 | border-left: 6px solid #000; 262 | } 263 | 264 | .report-toggle.level-1 { color: #444; background: #ffeda0; border-left-color: #feb24c; } 265 | .report-toggle.level-0 { color: #444; background: #f4c2be; border-left-color: #b10026; } 266 | 267 | .report-toggle-text { 268 | line-height: 32px; 269 | padding: 0 0.5em; 270 | font-weight: 300; 271 | font-size: 0.8em; 272 | } 273 | 274 | .report-toggle-close { 275 | width: 30px; 276 | height: 30px; 277 | line-height: 30px; 278 | font-size: 1.5em; 279 | text-align: center; 280 | } 281 | 282 | /****************************************************************************** 283 | * CSS Spinner 284 | * 285 | * http://tobiasahlin.com/spinkit/ 286 | */ 287 | 288 | .spinner { 289 | width: 40px; 290 | height: 40px; 291 | position: absolute; 292 | left: 50%; 293 | top: 50%; 294 | margin: -20px; 295 | 296 | background-color: #333; 297 | 298 | border-radius: 100%; 299 | -webkit-animation: sk-scaleout 1.0s infinite ease-in-out; 300 | animation: sk-scaleout 1.0s infinite ease-in-out; 301 | } 302 | 303 | @-webkit-keyframes sk-scaleout { 304 | 0% { -webkit-transform: scale(0) } 305 | 100% { 306 | -webkit-transform: scale(1.0); 307 | opacity: 0; 308 | } 309 | } 310 | 311 | @keyframes sk-scaleout { 312 | 0% { 313 | -webkit-transform: scale(0); 314 | transform: scale(0); 315 | } 100% { 316 | -webkit-transform: scale(1.0); 317 | transform: scale(1.0); 318 | opacity: 0; 319 | } 320 | } 321 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "public": true, 3 | "routes": [ 4 | { 5 | "src": "/assets/(.*)", 6 | "headers": { "cache-control": "max-age=604800, public" }, 7 | "dest": "/assets/$1" 8 | }, 9 | { 10 | "src": "/(.*)", 11 | "dest": "/public/$1" 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | "@esbuild/aix-ppc64@0.21.5": 6 | version "0.21.5" 7 | resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz#c7184a326533fcdf1b8ee0733e21c713b975575f" 8 | integrity sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ== 9 | 10 | "@esbuild/android-arm64@0.21.5": 11 | version "0.21.5" 12 | resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz#09d9b4357780da9ea3a7dfb833a1f1ff439b4052" 13 | integrity sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A== 14 | 15 | "@esbuild/android-arm@0.21.5": 16 | version "0.21.5" 17 | resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz#9b04384fb771926dfa6d7ad04324ecb2ab9b2e28" 18 | integrity sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg== 19 | 20 | "@esbuild/android-x64@0.21.5": 21 | version "0.21.5" 22 | resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz#29918ec2db754cedcb6c1b04de8cd6547af6461e" 23 | integrity sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA== 24 | 25 | "@esbuild/darwin-arm64@0.21.5": 26 | version "0.21.5" 27 | resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz#e495b539660e51690f3928af50a76fb0a6ccff2a" 28 | integrity sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ== 29 | 30 | "@esbuild/darwin-x64@0.21.5": 31 | version "0.21.5" 32 | resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz#c13838fa57372839abdddc91d71542ceea2e1e22" 33 | integrity sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw== 34 | 35 | "@esbuild/freebsd-arm64@0.21.5": 36 | version "0.21.5" 37 | resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz#646b989aa20bf89fd071dd5dbfad69a3542e550e" 38 | integrity sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g== 39 | 40 | "@esbuild/freebsd-x64@0.21.5": 41 | version "0.21.5" 42 | resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz#aa615cfc80af954d3458906e38ca22c18cf5c261" 43 | integrity sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ== 44 | 45 | "@esbuild/linux-arm64@0.21.5": 46 | version "0.21.5" 47 | resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz#70ac6fa14f5cb7e1f7f887bcffb680ad09922b5b" 48 | integrity sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q== 49 | 50 | "@esbuild/linux-arm@0.21.5": 51 | version "0.21.5" 52 | resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz#fc6fd11a8aca56c1f6f3894f2bea0479f8f626b9" 53 | integrity sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA== 54 | 55 | "@esbuild/linux-ia32@0.21.5": 56 | version "0.21.5" 57 | resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz#3271f53b3f93e3d093d518d1649d6d68d346ede2" 58 | integrity sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg== 59 | 60 | "@esbuild/linux-loong64@0.21.5": 61 | version "0.21.5" 62 | resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz#ed62e04238c57026aea831c5a130b73c0f9f26df" 63 | integrity sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg== 64 | 65 | "@esbuild/linux-mips64el@0.21.5": 66 | version "0.21.5" 67 | resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz#e79b8eb48bf3b106fadec1ac8240fb97b4e64cbe" 68 | integrity sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg== 69 | 70 | "@esbuild/linux-ppc64@0.21.5": 71 | version "0.21.5" 72 | resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz#5f2203860a143b9919d383ef7573521fb154c3e4" 73 | integrity sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w== 74 | 75 | "@esbuild/linux-riscv64@0.21.5": 76 | version "0.21.5" 77 | resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz#07bcafd99322d5af62f618cb9e6a9b7f4bb825dc" 78 | integrity sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA== 79 | 80 | "@esbuild/linux-s390x@0.21.5": 81 | version "0.21.5" 82 | resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz#b7ccf686751d6a3e44b8627ababc8be3ef62d8de" 83 | integrity sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A== 84 | 85 | "@esbuild/linux-x64@0.21.5": 86 | version "0.21.5" 87 | resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz#6d8f0c768e070e64309af8004bb94e68ab2bb3b0" 88 | integrity sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ== 89 | 90 | "@esbuild/netbsd-x64@0.21.5": 91 | version "0.21.5" 92 | resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz#bbe430f60d378ecb88decb219c602667387a6047" 93 | integrity sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg== 94 | 95 | "@esbuild/openbsd-x64@0.21.5": 96 | version "0.21.5" 97 | resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz#99d1cf2937279560d2104821f5ccce220cb2af70" 98 | integrity sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow== 99 | 100 | "@esbuild/sunos-x64@0.21.5": 101 | version "0.21.5" 102 | resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz#08741512c10d529566baba837b4fe052c8f3487b" 103 | integrity sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg== 104 | 105 | "@esbuild/win32-arm64@0.21.5": 106 | version "0.21.5" 107 | resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz#675b7385398411240735016144ab2e99a60fc75d" 108 | integrity sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A== 109 | 110 | "@esbuild/win32-ia32@0.21.5": 111 | version "0.21.5" 112 | resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz#1bfc3ce98aa6ca9a0969e4d2af72144c59c1193b" 113 | integrity sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA== 114 | 115 | "@esbuild/win32-x64@0.21.5": 116 | version "0.21.5" 117 | resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz#acad351d582d157bb145535db2a6ff53dd514b5c" 118 | integrity sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw== 119 | 120 | "@rollup/rollup-android-arm-eabi@4.40.2": 121 | version "4.40.2" 122 | resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.40.2.tgz#c228d00a41f0dbd6fb8b7ea819bbfbf1c1157a10" 123 | integrity sha512-JkdNEq+DFxZfUwxvB58tHMHBHVgX23ew41g1OQinthJ+ryhdRk67O31S7sYw8u2lTjHUPFxwar07BBt1KHp/hg== 124 | 125 | "@rollup/rollup-android-arm64@4.40.2": 126 | version "4.40.2" 127 | resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.40.2.tgz#e2b38d0c912169fd55d7e38d723aada208d37256" 128 | integrity sha512-13unNoZ8NzUmnndhPTkWPWbX3vtHodYmy+I9kuLxN+F+l+x3LdVF7UCu8TWVMt1POHLh6oDHhnOA04n8oJZhBw== 129 | 130 | "@rollup/rollup-darwin-arm64@4.40.2": 131 | version "4.40.2" 132 | resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.40.2.tgz#1fddb3690f2ae33df16d334c613377f05abe4878" 133 | integrity sha512-Gzf1Hn2Aoe8VZzevHostPX23U7N5+4D36WJNHK88NZHCJr7aVMG4fadqkIf72eqVPGjGc0HJHNuUaUcxiR+N/w== 134 | 135 | "@rollup/rollup-darwin-x64@4.40.2": 136 | version "4.40.2" 137 | resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.40.2.tgz#818298d11c8109e1112590165142f14be24b396d" 138 | integrity sha512-47N4hxa01a4x6XnJoskMKTS8XZ0CZMd8YTbINbi+w03A2w4j1RTlnGHOz/P0+Bg1LaVL6ufZyNprSg+fW5nYQQ== 139 | 140 | "@rollup/rollup-freebsd-arm64@4.40.2": 141 | version "4.40.2" 142 | resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.40.2.tgz#91a28dc527d5bed7f9ecf0e054297b3012e19618" 143 | integrity sha512-8t6aL4MD+rXSHHZUR1z19+9OFJ2rl1wGKvckN47XFRVO+QL/dUSpKA2SLRo4vMg7ELA8pzGpC+W9OEd1Z/ZqoQ== 144 | 145 | "@rollup/rollup-freebsd-x64@4.40.2": 146 | version "4.40.2" 147 | resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.40.2.tgz#28acadefa76b5c7bede1576e065b51d335c62c62" 148 | integrity sha512-C+AyHBzfpsOEYRFjztcYUFsH4S7UsE9cDtHCtma5BK8+ydOZYgMmWg1d/4KBytQspJCld8ZIujFMAdKG1xyr4Q== 149 | 150 | "@rollup/rollup-linux-arm-gnueabihf@4.40.2": 151 | version "4.40.2" 152 | resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.40.2.tgz#819691464179cbcd9a9f9d3dc7617954840c6186" 153 | integrity sha512-de6TFZYIvJwRNjmW3+gaXiZ2DaWL5D5yGmSYzkdzjBDS3W+B9JQ48oZEsmMvemqjtAFzE16DIBLqd6IQQRuG9Q== 154 | 155 | "@rollup/rollup-linux-arm-musleabihf@4.40.2": 156 | version "4.40.2" 157 | resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.40.2.tgz#d149207039e4189e267e8724050388effc80d704" 158 | integrity sha512-urjaEZubdIkacKc930hUDOfQPysezKla/O9qV+O89enqsqUmQm8Xj8O/vh0gHg4LYfv7Y7UsE3QjzLQzDYN1qg== 159 | 160 | "@rollup/rollup-linux-arm64-gnu@4.40.2": 161 | version "4.40.2" 162 | resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.40.2.tgz#fa72ebddb729c3c6d88973242f1a2153c83e86ec" 163 | integrity sha512-KlE8IC0HFOC33taNt1zR8qNlBYHj31qGT1UqWqtvR/+NuCVhfufAq9fxO8BMFC22Wu0rxOwGVWxtCMvZVLmhQg== 164 | 165 | "@rollup/rollup-linux-arm64-musl@4.40.2": 166 | version "4.40.2" 167 | resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.40.2.tgz#2054216e34469ab8765588ebf343d531fc3c9228" 168 | integrity sha512-j8CgxvfM0kbnhu4XgjnCWJQyyBOeBI1Zq91Z850aUddUmPeQvuAy6OiMdPS46gNFgy8gN1xkYyLgwLYZG3rBOg== 169 | 170 | "@rollup/rollup-linux-loongarch64-gnu@4.40.2": 171 | version "4.40.2" 172 | resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.40.2.tgz#818de242291841afbfc483a84f11e9c7a11959bc" 173 | integrity sha512-Ybc/1qUampKuRF4tQXc7G7QY9YRyeVSykfK36Y5Qc5dmrIxwFhrOzqaVTNoZygqZ1ZieSWTibfFhQ5qK8jpWxw== 174 | 175 | "@rollup/rollup-linux-powerpc64le-gnu@4.40.2": 176 | version "4.40.2" 177 | resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.40.2.tgz#0bb4cb8fc4a2c635f68c1208c924b2145eb647cb" 178 | integrity sha512-3FCIrnrt03CCsZqSYAOW/k9n625pjpuMzVfeI+ZBUSDT3MVIFDSPfSUgIl9FqUftxcUXInvFah79hE1c9abD+Q== 179 | 180 | "@rollup/rollup-linux-riscv64-gnu@4.40.2": 181 | version "4.40.2" 182 | resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.40.2.tgz#4b3b8e541b7b13e447ae07774217d98c06f6926d" 183 | integrity sha512-QNU7BFHEvHMp2ESSY3SozIkBPaPBDTsfVNGx3Xhv+TdvWXFGOSH2NJvhD1zKAT6AyuuErJgbdvaJhYVhVqrWTg== 184 | 185 | "@rollup/rollup-linux-riscv64-musl@4.40.2": 186 | version "4.40.2" 187 | resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.40.2.tgz#e065405e67d8bd64a7d0126c931bd9f03910817f" 188 | integrity sha512-5W6vNYkhgfh7URiXTO1E9a0cy4fSgfE4+Hl5agb/U1sa0kjOLMLC1wObxwKxecE17j0URxuTrYZZME4/VH57Hg== 189 | 190 | "@rollup/rollup-linux-s390x-gnu@4.40.2": 191 | version "4.40.2" 192 | resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.40.2.tgz#dda3265bbbfe16a5d0089168fd07f5ebb2a866fe" 193 | integrity sha512-B7LKIz+0+p348JoAL4X/YxGx9zOx3sR+o6Hj15Y3aaApNfAshK8+mWZEf759DXfRLeL2vg5LYJBB7DdcleYCoQ== 194 | 195 | "@rollup/rollup-linux-x64-gnu@4.40.2": 196 | version "4.40.2" 197 | resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.40.2.tgz#90993269b8b995b4067b7b9d72ff1c360ef90a17" 198 | integrity sha512-lG7Xa+BmBNwpjmVUbmyKxdQJ3Q6whHjMjzQplOs5Z+Gj7mxPtWakGHqzMqNER68G67kmCX9qX57aRsW5V0VOng== 199 | 200 | "@rollup/rollup-linux-x64-musl@4.40.2": 201 | version "4.40.2" 202 | resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.40.2.tgz#fdf5b09fd121eb8d977ebb0fda142c7c0167b8de" 203 | integrity sha512-tD46wKHd+KJvsmije4bUskNuvWKFcTOIM9tZ/RrmIvcXnbi0YK/cKS9FzFtAm7Oxi2EhV5N2OpfFB348vSQRXA== 204 | 205 | "@rollup/rollup-win32-arm64-msvc@4.40.2": 206 | version "4.40.2" 207 | resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.40.2.tgz#6397e1e012db64dfecfed0774cb9fcf89503d716" 208 | integrity sha512-Bjv/HG8RRWLNkXwQQemdsWw4Mg+IJ29LK+bJPW2SCzPKOUaMmPEppQlu/Fqk1d7+DX3V7JbFdbkh/NMmurT6Pg== 209 | 210 | "@rollup/rollup-win32-ia32-msvc@4.40.2": 211 | version "4.40.2" 212 | resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.40.2.tgz#df0991464a52a35506103fe18d29913bf8798a0c" 213 | integrity sha512-dt1llVSGEsGKvzeIO76HToiYPNPYPkmjhMHhP00T9S4rDern8P2ZWvWAQUEJ+R1UdMWJ/42i/QqJ2WV765GZcA== 214 | 215 | "@rollup/rollup-win32-x64-msvc@4.40.2": 216 | version "4.40.2" 217 | resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.40.2.tgz#8dae04d01a2cbd84d6297d99356674c6b993f0fc" 218 | integrity sha512-bwspbWB04XJpeElvsp+DCylKfF4trJDa2Y9Go8O6A7YLX2LIKGcNK/CYImJN6ZP4DcuOHB4Utl3iCbnR62DudA== 219 | 220 | "@types/estree@1.0.7": 221 | version "1.0.7" 222 | resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.7.tgz#4158d3105276773d5b7695cd4834b1722e4f37a8" 223 | integrity sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ== 224 | 225 | dat.gui@^0.7.9: 226 | version "0.7.9" 227 | resolved "https://registry.yarnpkg.com/dat.gui/-/dat.gui-0.7.9.tgz#860cab06053b028e327820eabdf25a13cf07b17e" 228 | integrity sha512-sCNc1OHobc+Erc1HqiswYgHdVNpSJUlk/Hz8vzOCsER7rl+oF/4+v8GXFUyCgtXpoCX6+bnmg07DedLvBLwYKQ== 229 | 230 | decode-uri-component@^0.4.1: 231 | version "0.4.1" 232 | resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.4.1.tgz#2ac4859663c704be22bf7db760a1494a49ab2cc5" 233 | integrity sha512-+8VxcR21HhTy8nOt6jf20w0c9CADrw1O8d+VZ/YzzCt4bJ3uBjw+D1q2osAB8RnpwwaeYBxy0HyKQxD5JBMuuQ== 234 | 235 | esbuild@^0.21.3: 236 | version "0.21.5" 237 | resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.21.5.tgz#9ca301b120922959b766360d8ac830da0d02997d" 238 | integrity sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw== 239 | optionalDependencies: 240 | "@esbuild/aix-ppc64" "0.21.5" 241 | "@esbuild/android-arm" "0.21.5" 242 | "@esbuild/android-arm64" "0.21.5" 243 | "@esbuild/android-x64" "0.21.5" 244 | "@esbuild/darwin-arm64" "0.21.5" 245 | "@esbuild/darwin-x64" "0.21.5" 246 | "@esbuild/freebsd-arm64" "0.21.5" 247 | "@esbuild/freebsd-x64" "0.21.5" 248 | "@esbuild/linux-arm" "0.21.5" 249 | "@esbuild/linux-arm64" "0.21.5" 250 | "@esbuild/linux-ia32" "0.21.5" 251 | "@esbuild/linux-loong64" "0.21.5" 252 | "@esbuild/linux-mips64el" "0.21.5" 253 | "@esbuild/linux-ppc64" "0.21.5" 254 | "@esbuild/linux-riscv64" "0.21.5" 255 | "@esbuild/linux-s390x" "0.21.5" 256 | "@esbuild/linux-x64" "0.21.5" 257 | "@esbuild/netbsd-x64" "0.21.5" 258 | "@esbuild/openbsd-x64" "0.21.5" 259 | "@esbuild/sunos-x64" "0.21.5" 260 | "@esbuild/win32-arm64" "0.21.5" 261 | "@esbuild/win32-ia32" "0.21.5" 262 | "@esbuild/win32-x64" "0.21.5" 263 | 264 | filter-obj@^5.1.0: 265 | version "5.1.0" 266 | resolved "https://registry.yarnpkg.com/filter-obj/-/filter-obj-5.1.0.tgz#5bd89676000a713d7db2e197f660274428e524ed" 267 | integrity sha512-qWeTREPoT7I0bifpPUXtxkZJ1XJzxWtfoWWkdVGqa+eCr3SHW/Ocp89o8vLvbUuQnadybJpjOKu4V+RwO6sGng== 268 | 269 | fsevents@~2.3.2, fsevents@~2.3.3: 270 | version "2.3.3" 271 | resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" 272 | integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== 273 | 274 | gltf-validator@^2.0.0-dev.3.10: 275 | version "2.0.0-dev.3.10" 276 | resolved "https://registry.yarnpkg.com/gltf-validator/-/gltf-validator-2.0.0-dev.3.10.tgz#9b09225db864fe3f0a584259f65d087e2213a93a" 277 | integrity sha512-odJ4k0tRkGXiDGn78yDBg+fBbAIvBnXxh3RwAta0emSxGtyagFE8B4xELB1oYe3S5RD8Ci3uZAsZaascH2LAEQ== 278 | 279 | nanoid@^3.3.8: 280 | version "3.3.11" 281 | resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b" 282 | integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w== 283 | 284 | picocolors@^1.1.1: 285 | version "1.1.1" 286 | resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" 287 | integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== 288 | 289 | postcss@^8.4.43: 290 | version "8.5.3" 291 | resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.3.tgz#1463b6f1c7fb16fe258736cba29a2de35237eafb" 292 | integrity sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A== 293 | dependencies: 294 | nanoid "^3.3.8" 295 | picocolors "^1.1.1" 296 | source-map-js "^1.2.1" 297 | 298 | prettier@^3.2.5: 299 | version "3.5.3" 300 | resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.5.3.tgz#4fc2ce0d657e7a02e602549f053b239cb7dfe1b5" 301 | integrity sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw== 302 | 303 | query-string@^8.1.0: 304 | version "8.2.0" 305 | resolved "https://registry.yarnpkg.com/query-string/-/query-string-8.2.0.tgz#f0b0ef6caa85f525dbdb745a67d3f8c08d71cc6b" 306 | integrity sha512-tUZIw8J0CawM5wyGBiDOAp7ObdRQh4uBor/fUR9ZjmbZVvw95OD9If4w3MQxr99rg0DJZ/9CIORcpEqU5hQG7g== 307 | dependencies: 308 | decode-uri-component "^0.4.1" 309 | filter-obj "^5.1.0" 310 | split-on-first "^3.0.0" 311 | 312 | rollup@^4.20.0: 313 | version "4.40.2" 314 | resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.40.2.tgz#778e88b7a197542682b3e318581f7697f55f0619" 315 | integrity sha512-tfUOg6DTP4rhQ3VjOO6B4wyrJnGOX85requAXvqYTHsOgb2TFJdZ3aWpT8W2kPoypSGP7dZUyzxJ9ee4buM5Fg== 316 | dependencies: 317 | "@types/estree" "1.0.7" 318 | optionalDependencies: 319 | "@rollup/rollup-android-arm-eabi" "4.40.2" 320 | "@rollup/rollup-android-arm64" "4.40.2" 321 | "@rollup/rollup-darwin-arm64" "4.40.2" 322 | "@rollup/rollup-darwin-x64" "4.40.2" 323 | "@rollup/rollup-freebsd-arm64" "4.40.2" 324 | "@rollup/rollup-freebsd-x64" "4.40.2" 325 | "@rollup/rollup-linux-arm-gnueabihf" "4.40.2" 326 | "@rollup/rollup-linux-arm-musleabihf" "4.40.2" 327 | "@rollup/rollup-linux-arm64-gnu" "4.40.2" 328 | "@rollup/rollup-linux-arm64-musl" "4.40.2" 329 | "@rollup/rollup-linux-loongarch64-gnu" "4.40.2" 330 | "@rollup/rollup-linux-powerpc64le-gnu" "4.40.2" 331 | "@rollup/rollup-linux-riscv64-gnu" "4.40.2" 332 | "@rollup/rollup-linux-riscv64-musl" "4.40.2" 333 | "@rollup/rollup-linux-s390x-gnu" "4.40.2" 334 | "@rollup/rollup-linux-x64-gnu" "4.40.2" 335 | "@rollup/rollup-linux-x64-musl" "4.40.2" 336 | "@rollup/rollup-win32-arm64-msvc" "4.40.2" 337 | "@rollup/rollup-win32-ia32-msvc" "4.40.2" 338 | "@rollup/rollup-win32-x64-msvc" "4.40.2" 339 | fsevents "~2.3.2" 340 | 341 | simple-dropzone@^0.8.3: 342 | version "0.8.3" 343 | resolved "https://registry.yarnpkg.com/simple-dropzone/-/simple-dropzone-0.8.3.tgz#1f46245b7ca8f3d840c3fbef7ab683ae45cf82d1" 344 | integrity sha512-y0i8Tf1O9whdRh2NXE2a7y3U0wbQXTbPnRJeHD6XP/tWoLEIwqYxPtnI/Fst3mRASGqMD8hXaRFjgKsO1nbvcg== 345 | dependencies: 346 | zip-js-esm "^1.1.1" 347 | 348 | source-map-js@^1.2.1: 349 | version "1.2.1" 350 | resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" 351 | integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== 352 | 353 | split-on-first@^3.0.0: 354 | version "3.0.0" 355 | resolved "https://registry.yarnpkg.com/split-on-first/-/split-on-first-3.0.0.tgz#f04959c9ea8101b9b0bbf35a61b9ebea784a23e7" 356 | integrity sha512-qxQJTx2ryR0Dw0ITYyekNQWpz6f8dGd7vffGNflQQ3Iqj9NJ6qiZ7ELpZsJ/QBhIVAiDfXdag3+Gp8RvWa62AA== 357 | 358 | three@^0.176.0: 359 | version "0.176.0" 360 | resolved "https://registry.yarnpkg.com/three/-/three-0.176.0.tgz#a30c1974e46db5745e4f96dd9ee2028d71e16ecf" 361 | integrity sha512-PWRKYWQo23ojf9oZSlRGH8K09q7nRSWx6LY/HF/UUrMdYgN9i1e2OwJYHoQjwc6HF/4lvvYLC5YC1X8UJL2ZpA== 362 | 363 | vhtml@^2.2.0: 364 | version "2.2.0" 365 | resolved "https://registry.yarnpkg.com/vhtml/-/vhtml-2.2.0.tgz#369e6823ed6c32cbb9f6e33395bae7c65faa014c" 366 | integrity sha512-TPXrXrxBOslRUVnlVkiAqhoXneiertIg86bdvzionrUYhEuiROvyPZNiiP6GIIJ2Q7oPNVyEtIx8gMAZZE9lCQ== 367 | 368 | vite@^5.1.8: 369 | version "5.4.19" 370 | resolved "https://registry.yarnpkg.com/vite/-/vite-5.4.19.tgz#20efd060410044b3ed555049418a5e7d1998f959" 371 | integrity sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA== 372 | dependencies: 373 | esbuild "^0.21.3" 374 | postcss "^8.4.43" 375 | rollup "^4.20.0" 376 | optionalDependencies: 377 | fsevents "~2.3.3" 378 | 379 | zip-js-esm@^1.1.1: 380 | version "1.1.1" 381 | resolved "https://registry.yarnpkg.com/zip-js-esm/-/zip-js-esm-1.1.1.tgz#9726cd6cf1e97f05ee1b1ea8e06529a59ac67003" 382 | integrity sha512-8fSLIpssGjX8mqTduIjlUStH1uBmAFWCmkiAo7/G8uDAY+rEnwE6nll5Cyvu+Ytqmw5FIQ7XAhohNrrZL2PheQ== 383 | --------------------------------------------------------------------------------