From 690532efb8022b8b8445b5a6296896b1b319024f Mon Sep 17 00:00:00 2001 From: Alex Smith Date: Sat, 21 Jun 2025 14:42:35 +0200 Subject: [PATCH] add model viewer for `.glb` (GLTF) model in file view (#8111) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Motivation The GLTF (`.gltf`, `.glb`) 3D model format is very popular for game development and visual productions. For an indie game studio, it would be convenient for a team to view textured 3D models directly from the Forgejo interface (otherwise they need to be downloaded and opened). [Perforce](https://www.perforce.com/products/helix-dam), [Diversion](https://www.diversion.dev/), and GitHub all have this capability to differing extents. Some discussion on 3D file support here: https://codeberg.org/forgejo/forgejo/issues/5188 ## Changes Adds a model viewer similar to [GitHub STL viewer](https://github.com/assimp/assimp/blob/master/test/models/STL/Spider_ascii.stl) for `.glb` model files, and lays some groundwork to support future files. Uses the [model-viewer](https://modelviewer.dev/) library by Google and three.js. The model viewer is interactive and can be rotated and scaled. ![Screen Recording 2025-06-08 at 15.27.15](/attachments/84c63dea-a0ce-45f9-b48b-c80867636639) ## How to Test 1) Create a new repository or use an existing one. 2) Upload a `.glb` file such as `tests/testdata/data/viewer/Unicode❤♻Test.glb` (CC0 1.0 Universal) 3) View the file in the repository. - Similar to image files, the 3D model should be rendered in a viewer. - Use mouse clicks to turn and zoom. ## Licenses Libraries used for this change include three.js and @google/model-viewer, which are MIT and Apache-2.0 licenses respectively. Both of these are compatible with Forgejo's GPL3.0 license. ## Future Plans 1) `.gltf` was not attempted because it is a multiple file format, referencing other files in the same directory. Still need to experiment with this to see if it can work. `.glb` is a single file containing a `.gltf` and all of its other file/texture dependencies so was easier to implement. 2) The PR diff still shows the model as an unviewable bin file, but clicking the "View File" button takes you to a view screen where this model viewer is used. It would be nice to view the before and after of the model in two side-by-side model viewers, akin to reviewing a change in an image. 3) Also inserted stubs for adding contexts for GLTF, STL, OBJ, and 3MF. These ultimately don't do anything yet as only `.glb` files can be detected by the type sniffer of all of these. ## Checklist The [contributor guide](https://forgejo.org/docs/next/contributor/) contains information that will be helpful to first time contributors. There also are a few [conditions for merging Pull Requests in Forgejo repositories](https://codeberg.org/forgejo/governance/src/branch/main/PullRequestsAgreement.md). You are also welcome to join the [Forgejo development chatroom](https://matrix.to/#/#forgejo-development:matrix.org). ### Tests - I added test coverage for checking GLB file content using the first few bytes. - [x] in their respective `typesniffer_test.go` for unit tests. ### Documentation - [ ] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs) to explain to Forgejo users how to use this change. - [x] I did not document these changes and I do not expect someone else to do it. ### Release notes - [ ] I do not want this change to show in the release notes. - [ ] I want the title to show in the release notes with a link to this pull request. - [ ] I want the content of the `release-notes/.md` to be be used for the release notes instead of the title. ## Release notes - User Interface features - [PR](https://codeberg.org/forgejo/forgejo/pulls/8111): add model viewer for `.glb` (GLTF) model in file view Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/8111 Reviewed-by: oliverpool Co-authored-by: Alex Smith Co-committed-by: Alex Smith --- modules/public/mime_types.go | 5 + modules/typesniffer/typesniffer.go | 49 +++++++- modules/typesniffer/typesniffer_test.go | 20 ++++ package-lock.json | 116 ++++++++++++++++++- package.json | 3 +- routers/web/repo/setting/lfs.go | 14 +++ routers/web/repo/view.go | 14 +++ templates/repo/settings/lfs_file.tmpl | 6 + templates/repo/view_file.tmpl | 6 + tests/testdata/data/viewer/README.md | 14 +++ tests/testdata/data/viewer/Unicode❤♻Test.glb | Bin 0 -> 8088 bytes web_src/css/repo.css | 5 + web_src/js/index.js | 2 + web_src/js/render/gltf.js | 6 + 14 files changed, 256 insertions(+), 4 deletions(-) create mode 100644 tests/testdata/data/viewer/README.md create mode 100644 tests/testdata/data/viewer/Unicode❤♻Test.glb create mode 100644 web_src/js/render/gltf.js diff --git a/modules/public/mime_types.go b/modules/public/mime_types.go index 32bdf3bfa2..87ee2854ae 100644 --- a/modules/public/mime_types.go +++ b/modules/public/mime_types.go @@ -23,6 +23,11 @@ var wellKnownMimeTypesLower = map[string]string{ ".wasm": "application/wasm", ".webp": "image/webp", ".xml": "text/xml; charset=utf-8", + ".glb": "model/gltf-binary", + ".gltf": "model/gltf+json", + ".obj": "model/obj", + ".stl": "model/stl", + ".3mf": "model/3mf", // well, there are some types missing from the builtin list ".txt": "text/plain; charset=utf-8", diff --git a/modules/typesniffer/typesniffer.go b/modules/typesniffer/typesniffer.go index a8fc70e54c..262feb2b05 100644 --- a/modules/typesniffer/typesniffer.go +++ b/modules/typesniffer/typesniffer.go @@ -24,6 +24,16 @@ const ( AvifMimeType = "image/avif" // ApplicationOctetStream MIME type of binary files. ApplicationOctetStream = "application/octet-stream" + // GLTFMimeType MIME type of GLTF files. + GLTFMimeType = "model/gltf+json" + // GLBMimeType MIME type of GLB files. + GLBMimeType = "model/gltf-binary" + // OBJMimeType MIME type of OBJ files. + OBJMimeType = "model/obj" + // STLMimeType MIME type of STL files. + STLMimeType = "model/stl" + // 3MFMimeType MIME type of 3MF files. + ThreeMFMimeType = "model/3mf" ) var ( @@ -67,6 +77,36 @@ func (ct SniffedType) IsAudio() bool { return strings.Contains(ct.contentType, "audio/") } +// Is3DModel detects if data is a 3D format +func (ct SniffedType) Is3DModel() bool { + return strings.Contains(ct.contentType, "model/") +} + +// IsGLTFFile detects if data is an SVG image format +func (ct SniffedType) IsGLTF() bool { + return strings.Contains(ct.contentType, GLTFMimeType) +} + +// IsGLBFile detects if data is an GLB image format +func (ct SniffedType) IsGLB() bool { + return strings.Contains(ct.contentType, GLBMimeType) +} + +// IsOBJFile detects if data is an OBJ image format +func (ct SniffedType) IsOBJ() bool { + return strings.Contains(ct.contentType, OBJMimeType) +} + +// IsSTLTextFile detects if data is an STL text format +func (ct SniffedType) IsSTL() bool { + return strings.Contains(ct.contentType, STLMimeType) +} + +// Is3MFFile detects if data is an 3MF image format +func (ct SniffedType) Is3MF() bool { + return strings.Contains(ct.contentType, ThreeMFMimeType) +} + // IsRepresentableAsText returns true if file content can be represented as // plain text or is empty. func (ct SniffedType) IsRepresentableAsText() bool { @@ -75,7 +115,7 @@ func (ct SniffedType) IsRepresentableAsText() bool { // IsBrowsableBinaryType returns whether a non-text type can be displayed in a browser func (ct SniffedType) IsBrowsableBinaryType() bool { - return ct.IsImage() || ct.IsSvgImage() || ct.IsPDF() || ct.IsVideo() || ct.IsAudio() + return ct.IsImage() || ct.IsSvgImage() || ct.IsPDF() || ct.IsVideo() || ct.IsAudio() || ct.Is3DModel() } // GetMimeType returns the mime type @@ -135,6 +175,13 @@ func DetectContentType(data []byte) SniffedType { ct = "audio/ogg" // for most cases, it is used as an audio container } } + + // GLTF is unsupported by http.DetectContentType + // hexdump -n 4 -C glTF.glb + if bytes.HasPrefix(data, []byte("glTF")) { + ct = GLBMimeType + } + return SniffedType{ct} } diff --git a/modules/typesniffer/typesniffer_test.go b/modules/typesniffer/typesniffer_test.go index 8d80b4ddb4..176d3658bb 100644 --- a/modules/typesniffer/typesniffer_test.go +++ b/modules/typesniffer/typesniffer_test.go @@ -117,6 +117,14 @@ func TestIsAudio(t *testing.T) { assert.True(t, DetectContentType([]byte("ID3Toy\n====\t* hi 🌞, ..."+"🌛"[0:2])).IsText()) // test ID3 tag with incomplete UTF8 char } +func TestIsGLB(t *testing.T) { + glb, _ := hex.DecodeString("676c5446") + assert.True(t, DetectContentType(glb).IsGLB()) + assert.True(t, DetectContentType(glb).Is3DModel()) + assert.False(t, DetectContentType([]byte("plain text")).IsGLB()) + assert.False(t, DetectContentType([]byte("plain text")).Is3DModel()) +} + func TestDetectContentTypeFromReader(t *testing.T) { mp3, _ := base64.StdEncoding.DecodeString("SUQzBAAAAAABAFRYWFgAAAASAAADbWFqb3JfYnJhbmQAbXA0MgBUWFhYAAAAEQAAA21pbm9yX3Zl") st, err := DetectContentTypeFromReader(bytes.NewReader(mp3)) @@ -145,3 +153,15 @@ func TestDetectContentTypeAvif(t *testing.T) { assert.True(t, st.IsImage()) } + +func TestDetectContentTypeModelGLB(t *testing.T) { + glb, err := hex.DecodeString("676c5446") + require.NoError(t, err) + + st, err := DetectContentTypeFromReader(bytes.NewReader(glb)) + require.NoError(t, err) + + // print st for debugging + assert.Equal(t, "model/gltf-binary", st.GetMimeType()) + assert.True(t, st.IsGLB()) +} diff --git a/package-lock.json b/package-lock.json index 1637629a83..9de06a8055 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@github/markdown-toolbar-element": "2.2.3", "@github/quote-selection": "2.1.0", "@github/text-expander-element": "2.8.0", + "@google/model-viewer": "4.1.0", "@mcaptcha/vanilla-glue": "0.1.0-alpha-3", "@primer/octicons": "19.14.0", "ansi_up": "6.0.5", @@ -1222,6 +1223,22 @@ "dom-input-range": "^1.2.0" } }, + "node_modules/@google/model-viewer": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@google/model-viewer/-/model-viewer-4.1.0.tgz", + "integrity": "sha512-7WB/jS6wfBfRl/tWhsUUvDMKFE1KlKME97coDLlZQfvJD0nCwjhES1lJ+k7wnmf7T3zMvCfn9mIjM/mgZapuig==", + "license": "Apache-2.0", + "dependencies": { + "@monogrid/gainmap-js": "^3.1.0", + "lit": "^3.2.1" + }, + "engines": { + "node": ">=6.0.0" + }, + "peerDependencies": { + "three": "^0.172.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -2004,6 +2021,21 @@ "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", "license": "MIT" }, + "node_modules/@lit-labs/ssr-dom-shim": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.3.0.tgz", + "integrity": "sha512-nQIWonJ6eFAvUUrSlwyHDm/aE8PBDu5kRpL0vHMg6K8fK3Diq1xdPjTnsJSwxABhaZ+5eBi1btQB5ShUTKo4nQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@lit/reactive-element": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-2.1.0.tgz", + "integrity": "sha512-L2qyoZSQClcBmq0qajBVbhYEcG6iK0XfLn66ifLe/RfC0/ihpc+pl0Wdn8bJ8o+hj38cG0fGXRgSS20MuXn7qA==", + "license": "BSD-3-Clause", + "dependencies": { + "@lit-labs/ssr-dom-shim": "^1.2.0" + } + }, "node_modules/@mcaptcha/core-glue": { "version": "0.1.0-alpha-5", "resolved": "https://registry.npmjs.org/@mcaptcha/core-glue/-/core-glue-0.1.0-alpha-5.tgz", @@ -2064,6 +2096,18 @@ "langium": "3.3.1" } }, + "node_modules/@monogrid/gainmap-js": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@monogrid/gainmap-js/-/gainmap-js-3.1.0.tgz", + "integrity": "sha512-Obb0/gEd/HReTlg8ttaYk+0m62gQJmCblMOjHSMHRrBP2zdfKMHLCRbh/6ex9fSUJMKdjjIEiohwkbGD3wj2Nw==", + "license": "MIT", + "dependencies": { + "promise-worker-transferable": "^1.0.4" + }, + "peerDependencies": { + "three": ">= 0.159.0" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.11", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.11.tgz", @@ -3493,8 +3537,7 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/@types/unist": { "version": "2.0.11", @@ -8768,6 +8811,12 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/immer": { "version": "9.0.21", "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz", @@ -9307,6 +9356,12 @@ "dev": true, "license": "MIT" }, + "node_modules/is-promise": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", + "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==", + "license": "MIT" + }, "node_modules/is-proto-prop": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/is-proto-prop/-/is-proto-prop-3.0.1.tgz", @@ -10023,6 +10078,15 @@ "npm": ">=8" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -10051,6 +10115,37 @@ "uc.micro": "^2.0.0" } }, + "node_modules/lit": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lit/-/lit-3.3.0.tgz", + "integrity": "sha512-DGVsqsOIHBww2DqnuZzW7QsuCdahp50ojuDaBPC7jUDRpYoH0z7kHBBYZewRzer75FwtrkmkKk7iOAwSaWdBmw==", + "license": "BSD-3-Clause", + "dependencies": { + "@lit/reactive-element": "^2.1.0", + "lit-element": "^4.2.0", + "lit-html": "^3.3.0" + } + }, + "node_modules/lit-element": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-4.2.0.tgz", + "integrity": "sha512-MGrXJVAI5x+Bfth/pU9Kst1iWID6GHDLEzFEnyULB/sFiRLgkd8NPK/PeeXxktA3T6EIIaq8U3KcbTU5XFcP2Q==", + "license": "BSD-3-Clause", + "dependencies": { + "@lit-labs/ssr-dom-shim": "^1.2.0", + "@lit/reactive-element": "^2.1.0", + "lit-html": "^3.3.0" + } + }, + "node_modules/lit-html": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.3.0.tgz", + "integrity": "sha512-RHoswrFAxY2d8Cf2mm4OZ1DgzCoBKUKSPvA1fhtSELxUERq2aQQ2h05pO9j81gS1o7RIRJ+CePLogfyahwmynw==", + "license": "BSD-3-Clause", + "dependencies": { + "@types/trusted-types": "^2.0.2" + } + }, "node_modules/loader-runner": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", @@ -12363,6 +12458,16 @@ "dev": true, "license": "Unlicense" }, + "node_modules/promise-worker-transferable": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/promise-worker-transferable/-/promise-worker-transferable-1.0.4.tgz", + "integrity": "sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==", + "license": "Apache-2.0", + "dependencies": { + "is-promise": "^2.1.0", + "lie": "^3.0.2" + } + }, "node_modules/proto-list": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", @@ -14425,6 +14530,13 @@ "node": ">=0.8" } }, + "node_modules/three": { + "version": "0.172.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.172.0.tgz", + "integrity": "sha512-6HMgMlzU97MsV7D/tY8Va38b83kz8YJX+BefKjspMNAv0Vx6dxMogHOrnRl/sbMIs3BPUKijPqDqJ/+UwJbIow==", + "license": "MIT", + "peer": true + }, "node_modules/throttle-debounce": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.0.tgz", diff --git a/package.json b/package.json index d7c3a5f79f..f7df1b3f38 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "@github/markdown-toolbar-element": "2.2.3", "@github/quote-selection": "2.1.0", "@github/text-expander-element": "2.8.0", + "@google/model-viewer": "4.1.0", "@mcaptcha/vanilla-glue": "0.1.0-alpha-3", "@primer/octicons": "19.14.0", "ansi_up": "6.0.5", @@ -78,8 +79,8 @@ "eslint-plugin-playwright": "2.2.0", "eslint-plugin-regexp": "2.9.0", "eslint-plugin-sonarjs": "3.0.2", - "eslint-plugin-unicorn": "59.0.1", "eslint-plugin-toml": "0.12.0", + "eslint-plugin-unicorn": "59.0.1", "eslint-plugin-vitest-globals": "1.5.0", "eslint-plugin-vue": "10.2.0", "eslint-plugin-vue-scoped-css": "2.10.0", diff --git a/routers/web/repo/setting/lfs.go b/routers/web/repo/setting/lfs.go index 2e9c34e8a7..b9cb86bd08 100644 --- a/routers/web/repo/setting/lfs.go +++ b/routers/web/repo/setting/lfs.go @@ -342,6 +342,20 @@ func LFSFileGet(ctx *context.Context) { ctx.Data["IsVideoFile"] = true case st.IsAudio(): ctx.Data["IsAudioFile"] = true + case st.Is3DModel(): + ctx.Data["Is3DModelFile"] = true + switch { + case st.IsGLB(): + ctx.Data["IsGLBFile"] = true + case st.IsSTL(): + ctx.Data["IsSTLFile"] = true + case st.IsGLTF(): + ctx.Data["IsGLTFFile"] = true + case st.IsOBJ(): + ctx.Data["IsOBJFile"] = true + case st.Is3MF(): + ctx.Data["Is3MFFile"] = true + } case st.IsImage() && (setting.UI.SVG.Enabled || !st.IsSvgImage()): ctx.Data["IsImageFile"] = true } diff --git a/routers/web/repo/view.go b/routers/web/repo/view.go index d958a11f55..bb3e1388a8 100644 --- a/routers/web/repo/view.go +++ b/routers/web/repo/view.go @@ -624,6 +624,20 @@ func renderFile(ctx *context.Context, entry *git.TreeEntry) { ctx.Data["IsVideoFile"] = true case fInfo.st.IsAudio(): ctx.Data["IsAudioFile"] = true + case fInfo.st.Is3DModel(): + ctx.Data["Is3DModelFile"] = true + switch { + case fInfo.st.IsGLB(): + ctx.Data["IsGLBFile"] = true + case fInfo.st.IsSTL(): + ctx.Data["IsSTLFile"] = true + case fInfo.st.IsGLTF(): + ctx.Data["IsGLTFFile"] = true + case fInfo.st.IsOBJ(): + ctx.Data["IsOBJFile"] = true + case fInfo.st.Is3MF(): + ctx.Data["Is3MFFile"] = true + } case fInfo.st.IsImage() && (setting.UI.SVG.Enabled || !fInfo.st.IsSvgImage()): ctx.Data["IsImageFile"] = true ctx.Data["CanCopyContent"] = true diff --git a/templates/repo/settings/lfs_file.tmpl b/templates/repo/settings/lfs_file.tmpl index 3b6b763536..9864ed01d6 100644 --- a/templates/repo/settings/lfs_file.tmpl +++ b/templates/repo/settings/lfs_file.tmpl @@ -32,6 +32,12 @@ {{else if .IsPDFFile}}
+ {{else if .Is3DModelFile}} + {{if .IsGLBFile}} + + {{else}} + {{ctx.Locale.Tr "repo.file_view_raw"}}! + {{end}} {{else}} {{ctx.Locale.Tr "repo.file_view_raw"}} {{end}} diff --git a/templates/repo/view_file.tmpl b/templates/repo/view_file.tmpl index bf668e1347..2a9d7d02ba 100644 --- a/templates/repo/view_file.tmpl +++ b/templates/repo/view_file.tmpl @@ -116,6 +116,12 @@ {{else if .IsPDFFile}}
+ {{else if .Is3DModelFile}} + {{if .IsGLBFile}} + + {{else}} + {{ctx.Locale.Tr "repo.file_view_raw"}} + {{end}} {{else}} {{ctx.Locale.Tr "repo.file_view_raw"}} {{end}} diff --git a/tests/testdata/data/viewer/README.md b/tests/testdata/data/viewer/README.md new file mode 100644 index 0000000000..e26d11f3c8 --- /dev/null +++ b/tests/testdata/data/viewer/README.md @@ -0,0 +1,14 @@ +# GLTF 3D Model Viewer + +⚠️ Currently supports `.glb` files only. + +3D models with the `.glb` format are rendered in repository file view. + +## 🔨 How to Test + +1) Create a new repository or use an existing one. +2) Upload a `.glb` file such as [Unicode❤♻Test.glb](./Unicode❤♻Test.glb) (CC0 1.0 Universal). +3) View the file in the repository. + - Similar to image files, the 3D model should be rendered in a viewer. + - Use mouse clicks to turn and zoom. + diff --git a/tests/testdata/data/viewer/Unicode❤♻Test.glb b/tests/testdata/data/viewer/Unicode❤♻Test.glb new file mode 100644 index 0000000000000000000000000000000000000000..32b0f4a0e8e3f701bf47538436eee8caee863e0e GIT binary patch literal 8088 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 { initUserAuth(); initRepoDiffView(); initPdfViewer(); + initGltfViewer(); initScopedAccessTokenCategories(); initColorPickers(); }); diff --git a/web_src/js/render/gltf.js b/web_src/js/render/gltf.js new file mode 100644 index 0000000000..2d48e9f8e6 --- /dev/null +++ b/web_src/js/render/gltf.js @@ -0,0 +1,6 @@ +export async function initGltfViewer() { + const els = document.querySelectorAll('model-viewer'); + if (!els.length) return; + + await import(/* webpackChunkName: "@google/model-viewer" */'@google/model-viewer'); +}