├── public ├── favicon.ico ├── env.hdr ├── index.html ├── index.css └── index.js ├── .gitignore ├── .rgignore ├── .eslintrc.js ├── package.json ├── LICENSE ├── src ├── options.js └── server.js └── README.md /public/favicon.ico: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | -------------------------------------------------------------------------------- /.rgignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | -------------------------------------------------------------------------------- /public/env.hdr: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brianpeiris/model-browser/HEAD/public/env.hdr -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: "eslint:recommended", 3 | env: { 4 | browser: true, 5 | node: true, 6 | es2020: true 7 | }, 8 | parserOptions: { 9 | sourceType: "module" 10 | } 11 | }; 12 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | model-browser 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "model-browser", 3 | "version": "0.0.4", 4 | "description": "A tool for browsing local 3D models via a web browser interface. Currently only supports GLB files.", 5 | "license": "MIT", 6 | "keywords": ["tool", "gltf", "glb", "3d"], 7 | "repository": "github:brianpeiris/model-browser", 8 | "bin": "src/server.js", 9 | "scripts": { 10 | "dev": "cross-env NODE_ENV=development nodemon -w 'src/*.js' src/server.js" 11 | }, 12 | "dependencies": { 13 | "express": "^4.17.1", 14 | "open": "^8.0.2", 15 | "portfinder": "^1.0.28", 16 | "react": "^17.0.1", 17 | "react-dom": "^17.0.1", 18 | "recursive-readdir": "^2.2.2", 19 | "three": "^0.126.1", 20 | "yargs": "^16.2.0" 21 | }, 22 | "devDependencies": { 23 | "cross-env": "^7.0.3", 24 | "easy-livereload": "^1.4.3", 25 | "nodemon": "^2.0.7" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Brian Peiris 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 | -------------------------------------------------------------------------------- /src/options.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | port: { 3 | describe: "Port to run the model-browser server on.", 4 | type: "number", 5 | defaultDescription: "auto", 6 | alias: "p", 7 | }, 8 | flip: { 9 | describe: "Whether models should be flipped.", 10 | type: "boolean", 11 | default: false, 12 | alias: "f", 13 | }, 14 | linear: { 15 | describe: 16 | "Whether models should be rendered using linear encoding. The default is sRGB encoding.", 17 | type: "boolean", 18 | default: false, 19 | alias: "l", 20 | }, 21 | recursive: { 22 | describe: 23 | "Whether files should be listed recursively, if [files] is a directory.", 24 | type: "boolean", 25 | default: false, 26 | alias: "r", 27 | }, 28 | open: { 29 | describe: "Whether model-browser should automatically open your browser. Disable this behavior with --no-open.", 30 | type: "boolean", 31 | default: true, 32 | alias: "o", 33 | }, 34 | "allow-cors": { 35 | describe: 36 | "A comma-separated list of origins to allow requests from. Can also be set to '*', but you should understand the security implications first.", 37 | type: "string", 38 | alias: "c", 39 | }, 40 | "timeout-minutes": { 41 | describe: 42 | "Kill the server if it hasn't received a request in this many minutes. The server will remain running as long as you have model-browser open in a browser tab. Set this to 0 to disable the timeout.", 43 | type: "number", 44 | default: 15, 45 | alias: "t", 46 | }, 47 | help: { alias: "h" }, 48 | version: { alias: "v" }, 49 | }; 50 | -------------------------------------------------------------------------------- /public/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: sans-serif; 4 | font-size: 10pt; 5 | overflow: hidden; 6 | } 7 | 8 | body, input { 9 | background: #22222b; 10 | } 11 | 12 | body, input, a{ 13 | color: #eee; 14 | text-decoration: none; 15 | } 16 | 17 | h1 { 18 | font-size: 18pt; 19 | } 20 | 21 | h2 { 22 | margin: 0.5em; 23 | margin-top: 0; 24 | text-align: center; 25 | } 26 | 27 | input { 28 | border: 1px solid #aaa; 29 | } 30 | 31 | #root { 32 | display: flex; 33 | flex-direction: column; 34 | align-items: center; 35 | height: 100vh; 36 | } 37 | 38 | .loading, 39 | input { 40 | font-size: 12pt; 41 | display: flex; 42 | margin: 5px; 43 | text-align: center; 44 | max-height: 2em; 45 | min-height: 2em; 46 | align-items: center; 47 | } 48 | 49 | .failures { 50 | position: absolute; 51 | z-index: 1; 52 | bottom: 0; 53 | background: maroon; 54 | padding: 4px; 55 | margin: 4px; 56 | display: flex; 57 | align-items: center; 58 | } 59 | 60 | .failures button { 61 | display: flex; 62 | align-items: center; 63 | justify-content: center; 64 | margin-left: 8px; 65 | font-size: 10px; 66 | background: maroon; 67 | color: white; 68 | border: 1px solid #bbb; 69 | width: 20px; 70 | height: 20px; 71 | } 72 | .failures button:hover { 73 | background: #a11; 74 | } 75 | 76 | .model { 77 | position: absolute; 78 | } 79 | 80 | .thumbnails { 81 | display: flex; 82 | justify-content: center; 83 | flex-wrap: wrap; 84 | overflow-y: auto; 85 | padding: 0 20px; 86 | } 87 | 88 | .thumbnail { 89 | display: flex; 90 | flex-direction: column; 91 | align-items: center; 92 | margin: 10px; 93 | } 94 | 95 | .thumbnail img { 96 | margin-bottom: 5px; 97 | } 98 | 99 | .thumbnail > a { 100 | max-width: 200px; 101 | text-overflow: ellipsis; 102 | white-space: nowrap; 103 | overflow: hidden; 104 | } 105 | 106 | .thumbnail > a:hover { 107 | text-decoration: underline; 108 | } 109 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # model-browser [![npm badge](https://img.shields.io/npm/v/model-browser?color=blue&label=npm)](https://www.npmjs.com/package/model-browser) 2 | 3 | model-browser is a command line tool for browsing local 3D models via a web browser. It currently only supports GLB files. 4 | 5 | ## Installation and Usage 6 | 7 | ``` 8 | $ npm install -g model-browser 9 | $ model-browser 10 | ``` 11 | 12 | OR 13 | 14 | ``` 15 | $ npx model-browser 16 | ``` 17 | 18 | --- 19 | 20 | ``` 21 | model-browser [files..] 22 | 23 | Positionals: 24 | files Path to a directory containing models you want to browse, or a 25 | list of file paths. Files can also be piped in. 26 | 27 | Options: 28 | -h, --help [boolean] 29 | -v, --version [boolean] 30 | -p, --port Port to run the model-browser server on. 31 | [number] [default: auto] 32 | -f, --flip Whether models should be flipped. 33 | [boolean] [default: false] 34 | -l, --linear Whether models should be rendered using linear 35 | encoding. The default is sRGB encoding. 36 | [boolean] [default: false] 37 | -r, --recursive Whether files should be listed recursively, if 38 | [files] is a directory. [boolean] [default: false] 39 | -o, --open Whether model-browser should automatically open 40 | your browser. Disable this behavior with 41 | --no-open. [boolean] [default: true] 42 | -c, --allow-cors A comma-separated list of origins to allow 43 | requests from. Can also be set to '*', but you 44 | should understand the security implications first. 45 | [string] 46 | -t, --timeout-minutes Kill the server if it hasn't received a request in 47 | this many minutes. The server will remain running 48 | as long as you have model-browser open in a 49 | browser tab. Set this to 0 to disable the timeout. 50 | [number] [default: 15] 51 | ``` 52 | 53 | --- 54 | 55 | ![A screenshot of model-browser](https://user-images.githubusercontent.com/79419/111898136-84eebd80-89fa-11eb-945b-0ec4e249e9c5.png) 56 | 57 | 58 | -------------------------------------------------------------------------------- /src/server.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const fs = require("fs"); 4 | const path = require("path"); 5 | const express = require("express"); 6 | const open = require("open"); 7 | const portfinder = require("portfinder"); 8 | const recursive = require("recursive-readdir"); 9 | const yargs = require("yargs/yargs"); 10 | const { hideBin } = require("yargs/helpers"); 11 | 12 | const options = require("./options"); 13 | 14 | const { argv } = yargs(hideBin(process.argv)) 15 | .scriptName("model-browser") 16 | .wrap(require("yargs").terminalWidth()) 17 | .command("$0 [files..]", "model-browser", (yargs) => { 18 | yargs.positional("files", { 19 | describe: 20 | "Path to a directory containing models you want to browse, or a list of file paths. Files can also be piped in.", 21 | type: "array", 22 | }); 23 | yargs.options(options); 24 | }); 25 | 26 | function cleanPath(filePath) { 27 | // The replaces here get rid of quotes that some terminals add. 28 | return filePath.trim().replace(/^'/, "").replace(/'$/, "").replace(/"$/, ""); 29 | } 30 | 31 | const state = { 32 | stdin: "", 33 | filesIsList: null, 34 | filesList: [], 35 | basePath: null, 36 | timeoutId: null, 37 | }; 38 | 39 | try { 40 | state.stdin = fs.readFileSync(process.stdin.fd, "utf-8"); 41 | } catch (e) { 42 | // no content in stdin. 43 | } 44 | state.filesIsList = cleanPath(state.stdin || (argv.files && argv.files[0]) || "").endsWith( 45 | ".glb" 46 | ); 47 | 48 | state.basePath = state.filesIsList 49 | ? null 50 | : path.resolve(cleanPath(argv.files && argv.files[0] || ".")); 51 | const app = express(); 52 | 53 | if (process.env.NODE_ENV === "development") { 54 | const livereload = require("easy-livereload"); 55 | app.use( 56 | livereload({ 57 | watchDirs: [path.join(__dirname, "..", "public")], 58 | checkFunc: (file) => /\.(css|js|html)$/.test(file), 59 | }) 60 | ); 61 | } 62 | 63 | app.use(express.static(path.resolve(__dirname, "..", "public"))); 64 | app.use( 65 | "/node_modules", 66 | express.static(path.resolve(__dirname, "..", "node_modules")) 67 | ); 68 | 69 | function fullUrl(origin) { 70 | if (!origin.startsWith("http")) return `https://${origin}`; 71 | else return origin; 72 | } 73 | 74 | function maybeAddCors(req, res) { 75 | const requestOrigin = req.get("origin"); 76 | if (!requestOrigin) return; 77 | if (!argv.allowCors) return; 78 | 79 | const allowedOrigins = argv.allowCors 80 | .split(",") 81 | .map((s) => s.trim()) 82 | .map(fullUrl); 83 | 84 | if (argv.allowCors === "*" || allowedOrigins.includes(requestOrigin)) { 85 | res.set("access-control-allow-origin", requestOrigin); 86 | console.log(`Allowing request to ${req.path} from ${requestOrigin}`); 87 | } 88 | } 89 | 90 | function restartTimeout() { 91 | if (state.timeoutId) clearTimeout(state.timeoutId); 92 | 93 | if (argv.timeoutMinutes === 0) return; 94 | 95 | state.timeoutId = setTimeout(() => { 96 | console.log( 97 | `No requests received in ${argv.timeoutMinutes} minutes. Killing server.` 98 | ); 99 | process.exit(0); 100 | }, argv.timeoutMinutes * 60 * 1000); 101 | } 102 | 103 | app.get("/files", async (req, res) => { 104 | res.send({ basePath: state.basePath, files: state.filesList }); 105 | }); 106 | 107 | app.get(/\/files\/.*/, (req, res) => { 108 | maybeAddCors(req, res); 109 | 110 | const filePath = decodeURIComponent(req.path.substring(7)); 111 | 112 | if (!state.filesList.includes(filePath)) { 113 | res.sendStatus(404); 114 | return; 115 | } 116 | if (state.filesIsList) { 117 | res.sendFile(filePath); 118 | } else { 119 | res.sendFile(path.join(state.basePath, filePath)); 120 | } 121 | }); 122 | 123 | app.get("/heartbeat", (req, res) => { 124 | if (req.get("origin")) { 125 | res.sendStatus(404); 126 | return; 127 | } 128 | 129 | restartTimeout(); 130 | 131 | res.send("ok"); 132 | }); 133 | 134 | (async () => { 135 | if (state.filesIsList) { 136 | state.filesList = state.stdin ? state.stdin.split(/[\r\n]/) : argv.files; 137 | state.filesList = state.filesList 138 | .flatMap((s) => s.split(/[\r\n]/)) 139 | .map((s) => s.trim()) 140 | .filter((s) => s.length) 141 | .map((s) => path.resolve(s)); 142 | } else { 143 | if (argv.recursive) { 144 | state.filesList = (await recursive(state.basePath)).map((file) => 145 | file.replace(state.basePath + path.sep, "") 146 | ); 147 | } else { 148 | state.filesList = fs.readdirSync(state.basePath); 149 | } 150 | } 151 | state.filesList = state.filesList.filter((file) => file.endsWith(".glb")); 152 | 153 | let port = argv.port || (await portfinder.getPortPromise()); 154 | app.listen(port, () => { 155 | const params = []; 156 | if (argv.flip) params.push("flip"); 157 | if (argv.linear) params.push("linear"); 158 | 159 | const url = `http://localhost:${port}${ 160 | params.length ? "?" + params.join("&") : "" 161 | }`; 162 | 163 | if (argv.open === false) { 164 | console.log(`Model browser running at ${url}.`); 165 | } else { 166 | console.log(`Opening model browser at ${url}.`); 167 | open(url); 168 | } 169 | 170 | restartTimeout(); 171 | }); 172 | })(); 173 | -------------------------------------------------------------------------------- /public/index.js: -------------------------------------------------------------------------------- 1 | import * as THREE from "./node_modules/three/build/three.module.js"; 2 | import { GLTFLoader } from "./node_modules/three/examples/jsm/loaders/GLTFLoader.js"; 3 | import { OrbitControls } from "./node_modules/three/examples/jsm/controls/OrbitControls.js"; 4 | import { RGBELoader } from "./node_modules/three/examples/jsm/loaders/RGBELoader.js"; 5 | import "./node_modules/react/umd/react.production.min.js"; 6 | import "./node_modules/react-dom/umd/react-dom.production.min.js"; 7 | 8 | const React = window.React; 9 | const ReactDOM = window.ReactDOM; 10 | const el = React.createElement; 11 | const { useState, useEffect, useRef, useCallback } = React; 12 | 13 | const query = new URLSearchParams(location.search); 14 | const flip = query.get("flip") !== null; 15 | const linear = query.get("linear") !== null; 16 | 17 | async function setupThree(backgroundColor) { 18 | const scene = new THREE.Scene(); 19 | 20 | const model = new THREE.Group(); 21 | 22 | if (flip) { 23 | model.rotateY(-Math.PI / 2); 24 | } 25 | scene.add(model); 26 | 27 | const renderer = new THREE.WebGLRenderer({ antialias: true }); 28 | if (!linear) renderer.outputEncoding = THREE.sRGBEncoding; 29 | renderer.setClearColor(backgroundColor); 30 | 31 | const envMap = await new Promise((resolve, reject) => { 32 | const pmremGenerator = new THREE.PMREMGenerator(renderer); 33 | new RGBELoader().setDataType(THREE.UnsignedByteType).load( 34 | "env.hdr", 35 | (texture) => { 36 | try { 37 | const renderTarget = pmremGenerator.fromEquirectangular(texture); 38 | resolve(renderTarget.texture); 39 | } catch (e) { 40 | reject(e); 41 | } 42 | }, 43 | null, 44 | reject 45 | ); 46 | }); 47 | 48 | scene.environment = envMap; 49 | 50 | const camera = new THREE.OrthographicCamera(); 51 | camera.near = 0; 52 | camera.far = 100; 53 | camera.position.set(1, 1, 1); 54 | 55 | camera.lookAt(new THREE.Vector3()); 56 | 57 | const size = 200; 58 | renderer.setSize(size, size); 59 | camera.aspect = size / size; 60 | camera.updateProjectionMatrix(); 61 | 62 | const loader = new GLTFLoader(); 63 | 64 | return { renderer, scene, camera, model, loader }; 65 | } 66 | 67 | function addGltf(gltfScene, group, camera) { 68 | const box = new THREE.Box3(); 69 | box.setFromObject(gltfScene); 70 | let size = new THREE.Vector3(); 71 | box.getSize(size); 72 | let maxSize = Math.max(size.x, size.y, size.z); 73 | 74 | if (maxSize > 2) { 75 | gltfScene.scale.setScalar(1 / maxSize); 76 | box.setFromObject(gltfScene); 77 | box.getSize(size); 78 | maxSize = Math.max(size.x, size.y, size.z); 79 | } 80 | 81 | const center = new THREE.Vector3(); 82 | box.getCenter(center); 83 | gltfScene.position.sub(center); 84 | 85 | group.clear(); 86 | group.add(gltfScene); 87 | 88 | camera.top = maxSize; 89 | camera.right = maxSize; 90 | camera.bottom = -maxSize; 91 | camera.left = -maxSize; 92 | 93 | camera.updateProjectionMatrix(); 94 | } 95 | 96 | const recentCache = []; 97 | function loadFile(loader, file) { 98 | const cached = recentCache.find((entry) => entry.file === file); 99 | if (cached) return Promise.resolve(cached.gltf); 100 | return new Promise((resolve, reject) => 101 | loader.load( 102 | `/files/${encodeURIComponent(file)}`, 103 | (gltf) => { 104 | recentCache.push({ file, gltf }); 105 | if (recentCache.length > 10) recentCache.shift(); 106 | resolve(gltf); 107 | }, 108 | null, 109 | reject 110 | ) 111 | ); 112 | } 113 | 114 | const renderThumbnail = (() => { 115 | const setupPromise = setupThree("#444"); 116 | 117 | async function renderThumbnail(file) { 118 | const { renderer, scene, camera, model, loader } = await setupPromise; 119 | 120 | const gltf = await loadFile(loader, file); 121 | addGltf(gltf.scene, model, camera); 122 | 123 | renderer.render(scene, camera); 124 | 125 | const blob = await new Promise((resolve, reject) => { 126 | try { 127 | renderer.domElement.toBlob(resolve); 128 | } catch (e) { 129 | reject(e); 130 | } 131 | }); 132 | return URL.createObjectURL(blob); 133 | } 134 | 135 | return renderThumbnail; 136 | })(); 137 | 138 | function getName(name) { 139 | if (!name) return; 140 | if (name.includes("/")) { 141 | return name.substring(name.lastIndexOf("/") + 1); 142 | } else { 143 | return name.substring(name.lastIndexOf("\\") + 1); 144 | } 145 | } 146 | 147 | function Thumbnail({ file, onPointerMove, onPointerDown }) { 148 | const name = file.file; 149 | 150 | return el( 151 | "div", 152 | { className: "thumbnail" }, 153 | el("img", { 154 | draggable: false, 155 | src: file.thumbnail, 156 | onPointerMove, 157 | onPointerDown, 158 | }), 159 | el("a", { title: name, href: `/files/${encodeURIComponent(file.file)}` }, getName(name)) 160 | ); 161 | } 162 | 163 | function Model({ elem, file, onMouseLeave }) { 164 | const modelDiv = useRef(); 165 | const modelGroup = useRef(); 166 | const gltfLoader = useRef(); 167 | const cameraObj = useRef(); 168 | const controls = useRef(); 169 | const render = useRef(); 170 | const fileUrl = useRef(); 171 | const [style, setStyle] = useState({ top: 0, left: 0 }); 172 | 173 | useEffect(async () => { 174 | const { renderer, scene, camera, model, loader } = await setupThree("#555"); 175 | 176 | modelGroup.current = model; 177 | gltfLoader.current = loader; 178 | cameraObj.current = camera; 179 | 180 | modelDiv.current.append(renderer.domElement); 181 | 182 | controls.current = new OrbitControls(camera, renderer.domElement); 183 | 184 | render.current = () => { 185 | renderer.render(scene, camera); 186 | }; 187 | 188 | controls.current.addEventListener("change", render.current); 189 | }, [modelDiv]); 190 | 191 | useEffect(async () => { 192 | setStyle((style) => ({ ...style, display: "none" })); 193 | 194 | if (!file) return; 195 | 196 | fileUrl.current = file.file; 197 | 198 | controls.current.reset(); 199 | 200 | const gltf = await loadFile(gltfLoader.current, file.file); 201 | 202 | if (file.file !== fileUrl.current) return; 203 | 204 | addGltf(gltf.scene, modelGroup.current, cameraObj.current); 205 | 206 | render.current(); 207 | 208 | const setPosition = () => { 209 | if (!elem) return; 210 | const rect = elem.getClientRects()[0]; 211 | if (!rect) return; 212 | setStyle((style) => ({ ...style, top: rect.top, left: rect.left })); 213 | }; 214 | 215 | setPosition(); 216 | 217 | setStyle((style) => ({ ...style, display: "block" })); 218 | 219 | window.addEventListener("loaded-file", setPosition); 220 | window.addEventListener("resize", setPosition); 221 | window.addEventListener("thumbnails-scroll", setPosition); 222 | 223 | return () => { 224 | window.removeEventListener("loaded-file", setPosition); 225 | window.removeEventListener("resize", setPosition); 226 | window.removeEventListener("thumbnails-scroll", setPosition); 227 | }; 228 | }, [elem, file]); 229 | 230 | return el("div", { ref: modelDiv, className: "model", style, onMouseLeave }); 231 | } 232 | 233 | function App() { 234 | const [basePath, setBasePath] = useState(); 235 | const [files, setFiles] = useState([]); 236 | const [filter, setFilter] = useState(); 237 | const [progress, setProgress] = useState(); 238 | const [previewThumbnail, setPreviewThumbnail] = useState(); 239 | const [previewFile, setPreviewFile] = useState(); 240 | const [failures, setFailures] = useState([]); 241 | const [failuresDismissed, setFailuresDismissed] = useState(false); 242 | 243 | useEffect(() => { 244 | fetch(`/files`) 245 | .then((r) => r.json()) 246 | .then(async ({ basePath, files }) => { 247 | setBasePath(basePath); 248 | 249 | const results = []; 250 | //files = files.filter((f) => f.includes("")).slice(0); 251 | 252 | setProgress({ num: 0, total: files.length }); 253 | 254 | for (let i = 0; i < files.length; i++) { 255 | const file = files[i]; 256 | 257 | try { 258 | const thumbnail = await renderThumbnail(file); 259 | results.push({ file, thumbnail }); 260 | setFiles(results.slice(0)); 261 | window.dispatchEvent(new CustomEvent("loaded-file")); 262 | } catch(e) { 263 | setFailures(failures => failures.concat([file])); 264 | } 265 | 266 | setProgress({ num: i + 1, total: files.length }); 267 | } 268 | }); 269 | 270 | setInterval(() => { 271 | fetch("/heartbeat"); 272 | }, 30 * 1000); 273 | }, []); 274 | 275 | const clearPreviewModel = useCallback(() => { 276 | setPreviewThumbnail(null); 277 | setPreviewFile(null); 278 | }, [setPreviewThumbnail, setPreviewFile]); 279 | 280 | const previewModel = useCallback( 281 | (thumbnail, file) => { 282 | setPreviewThumbnail(thumbnail); 283 | setPreviewFile(file); 284 | }, 285 | [setPreviewThumbnail, setPreviewFile] 286 | ); 287 | 288 | const finishedLoading = progress && progress.num === progress.total; 289 | const noFiles = progress && progress.total === 0; 290 | 291 | const filteredFiles = files.filter( 292 | (file) => 293 | !filter || 294 | getName(file.file)?.toLowerCase().includes(filter.toLowerCase()) 295 | ); 296 | 297 | // There are zero-width spaces before and after the replaced slashes here. 298 | const formattedBasePath = basePath 299 | ?.trim() 300 | .replace(/[\\]/g, "​\\​") 301 | .replace(/[/]/g, "​/​"); 302 | 303 | return el( 304 | React.Fragment, 305 | {}, 306 | el("h1", {}, "model-browser"), 307 | basePath && el("h2", {}, formattedBasePath), 308 | el("input", { 309 | type: "search", 310 | placeholder: "filter", 311 | value: filter, 312 | onChange: (e) => { 313 | clearPreviewModel(); 314 | setFilter(e.target.value); 315 | }, 316 | }), 317 | !finishedLoading && 318 | el( 319 | "span", 320 | { className: "loading" }, 321 | progress && `loading ${progress.num}/${progress.total}` 322 | ), 323 | finishedLoading && 324 | noFiles && 325 | el("span", { className: "loading" }, `no files found`), 326 | el( 327 | "div", 328 | { 329 | className: "thumbnails", 330 | onPointerDown: (e) => 331 | e.currentTarget === e.target && clearPreviewModel(), 332 | onContextMenu: (e) => e.target.nodeName !== "A" && e.preventDefault(), 333 | onScroll: () => 334 | window.dispatchEvent(new CustomEvent("thumbnails-scroll")), 335 | }, 336 | filteredFiles.map((file) => 337 | el(Thumbnail, { 338 | key: file.file, 339 | file, 340 | onPointerMove: (e) => 341 | e.buttons === 0 && previewModel(e.currentTarget, file), 342 | onPointerDown: (e) => previewModel(e.currentTarget, file), 343 | }) 344 | ) 345 | ), 346 | !!(failures.length) && !failuresDismissed && el( 347 | "div", 348 | { className: "failures" }, 349 | `${failures.length} models failed to load`, 350 | el("button", { onClick: () => setFailuresDismissed(true) }, "X") 351 | ), 352 | el(Model, { 353 | elem: previewThumbnail, 354 | file: previewFile, 355 | onMouseLeave: (e) => e.buttons === 0 && clearPreviewModel(), 356 | }) 357 | ); 358 | } 359 | 360 | ReactDOM.render(el(App), document.getElementById("root")); 361 | --------------------------------------------------------------------------------