├── 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 [](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 | 
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 |
--------------------------------------------------------------------------------