1
0
Fork 0
mirror of https://codeberg.org/forgejo/forgejo.git synced 2025-06-27 16:35:57 +00:00

add model viewer for .glb (GLTF) model in file view (#8111)

## 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/<pull request number>.md` to be be used for the release notes instead of the title.

<!--start release-notes-assistant-->

## Release notes
<!--URL:https://codeberg.org/forgejo/forgejo-->
- User Interface features
  - [PR](https://codeberg.org/forgejo/forgejo/pulls/8111): <!--number 8111 --><!--line 0 --><!--description YWRkIG1vZGVsIHZpZXdlciBmb3IgYC5nbGJgIChHTFRGKSBtb2RlbCBpbiBmaWxlIHZpZXc=-->add model viewer for `.glb` (GLTF) model in file view<!--description-->
<!--end release-notes-assistant-->

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/8111
Reviewed-by: oliverpool <oliverpool@noreply.codeberg.org>
Co-authored-by: Alex Smith <amsmith.pro@pm.me>
Co-committed-by: Alex Smith <amsmith.pro@pm.me>
This commit is contained in:
Alex Smith 2025-06-21 14:42:35 +02:00 committed by Earl Warren
parent b2c4fc9f94
commit 690532efb8
14 changed files with 256 additions and 4 deletions

View file

@ -23,6 +23,11 @@ var wellKnownMimeTypesLower = map[string]string{
".wasm": "application/wasm", ".wasm": "application/wasm",
".webp": "image/webp", ".webp": "image/webp",
".xml": "text/xml; charset=utf-8", ".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 // well, there are some types missing from the builtin list
".txt": "text/plain; charset=utf-8", ".txt": "text/plain; charset=utf-8",

View file

@ -24,6 +24,16 @@ const (
AvifMimeType = "image/avif" AvifMimeType = "image/avif"
// ApplicationOctetStream MIME type of binary files. // ApplicationOctetStream MIME type of binary files.
ApplicationOctetStream = "application/octet-stream" 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 ( var (
@ -67,6 +77,36 @@ func (ct SniffedType) IsAudio() bool {
return strings.Contains(ct.contentType, "audio/") 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 // IsRepresentableAsText returns true if file content can be represented as
// plain text or is empty. // plain text or is empty.
func (ct SniffedType) IsRepresentableAsText() bool { 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 // IsBrowsableBinaryType returns whether a non-text type can be displayed in a browser
func (ct SniffedType) IsBrowsableBinaryType() bool { 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 // 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 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} return SniffedType{ct}
} }

View file

@ -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 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) { func TestDetectContentTypeFromReader(t *testing.T) {
mp3, _ := base64.StdEncoding.DecodeString("SUQzBAAAAAABAFRYWFgAAAASAAADbWFqb3JfYnJhbmQAbXA0MgBUWFhYAAAAEQAAA21pbm9yX3Zl") mp3, _ := base64.StdEncoding.DecodeString("SUQzBAAAAAABAFRYWFgAAAASAAADbWFqb3JfYnJhbmQAbXA0MgBUWFhYAAAAEQAAA21pbm9yX3Zl")
st, err := DetectContentTypeFromReader(bytes.NewReader(mp3)) st, err := DetectContentTypeFromReader(bytes.NewReader(mp3))
@ -145,3 +153,15 @@ func TestDetectContentTypeAvif(t *testing.T) {
assert.True(t, st.IsImage()) 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())
}

116
package-lock.json generated
View file

@ -12,6 +12,7 @@
"@github/markdown-toolbar-element": "2.2.3", "@github/markdown-toolbar-element": "2.2.3",
"@github/quote-selection": "2.1.0", "@github/quote-selection": "2.1.0",
"@github/text-expander-element": "2.8.0", "@github/text-expander-element": "2.8.0",
"@google/model-viewer": "4.1.0",
"@mcaptcha/vanilla-glue": "0.1.0-alpha-3", "@mcaptcha/vanilla-glue": "0.1.0-alpha-3",
"@primer/octicons": "19.14.0", "@primer/octicons": "19.14.0",
"ansi_up": "6.0.5", "ansi_up": "6.0.5",
@ -1222,6 +1223,22 @@
"dom-input-range": "^1.2.0" "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": { "node_modules/@humanfs/core": {
"version": "0.19.1", "version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@ -2004,6 +2021,21 @@
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT" "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": { "node_modules/@mcaptcha/core-glue": {
"version": "0.1.0-alpha-5", "version": "0.1.0-alpha-5",
"resolved": "https://registry.npmjs.org/@mcaptcha/core-glue/-/core-glue-0.1.0-alpha-5.tgz", "resolved": "https://registry.npmjs.org/@mcaptcha/core-glue/-/core-glue-0.1.0-alpha-5.tgz",
@ -2064,6 +2096,18 @@
"langium": "3.3.1" "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": { "node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.11", "version": "0.2.11",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.11.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.11.tgz",
@ -3493,8 +3537,7 @@
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT", "license": "MIT"
"optional": true
}, },
"node_modules/@types/unist": { "node_modules/@types/unist": {
"version": "2.0.11", "version": "2.0.11",
@ -8768,6 +8811,12 @@
"node": ">= 4" "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": { "node_modules/immer": {
"version": "9.0.21", "version": "9.0.21",
"resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz", "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz",
@ -9307,6 +9356,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/is-proto-prop": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/is-proto-prop/-/is-proto-prop-3.0.1.tgz", "resolved": "https://registry.npmjs.org/is-proto-prop/-/is-proto-prop-3.0.1.tgz",
@ -10023,6 +10078,15 @@
"npm": ">=8" "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": { "node_modules/lilconfig": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
@ -10051,6 +10115,37 @@
"uc.micro": "^2.0.0" "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": { "node_modules/loader-runner": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz",
@ -12363,6 +12458,16 @@
"dev": true, "dev": true,
"license": "Unlicense" "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": { "node_modules/proto-list": {
"version": "1.2.4", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz",
@ -14425,6 +14530,13 @@
"node": ">=0.8" "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": { "node_modules/throttle-debounce": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.0.tgz", "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.0.tgz",

View file

@ -11,6 +11,7 @@
"@github/markdown-toolbar-element": "2.2.3", "@github/markdown-toolbar-element": "2.2.3",
"@github/quote-selection": "2.1.0", "@github/quote-selection": "2.1.0",
"@github/text-expander-element": "2.8.0", "@github/text-expander-element": "2.8.0",
"@google/model-viewer": "4.1.0",
"@mcaptcha/vanilla-glue": "0.1.0-alpha-3", "@mcaptcha/vanilla-glue": "0.1.0-alpha-3",
"@primer/octicons": "19.14.0", "@primer/octicons": "19.14.0",
"ansi_up": "6.0.5", "ansi_up": "6.0.5",
@ -78,8 +79,8 @@
"eslint-plugin-playwright": "2.2.0", "eslint-plugin-playwright": "2.2.0",
"eslint-plugin-regexp": "2.9.0", "eslint-plugin-regexp": "2.9.0",
"eslint-plugin-sonarjs": "3.0.2", "eslint-plugin-sonarjs": "3.0.2",
"eslint-plugin-unicorn": "59.0.1",
"eslint-plugin-toml": "0.12.0", "eslint-plugin-toml": "0.12.0",
"eslint-plugin-unicorn": "59.0.1",
"eslint-plugin-vitest-globals": "1.5.0", "eslint-plugin-vitest-globals": "1.5.0",
"eslint-plugin-vue": "10.2.0", "eslint-plugin-vue": "10.2.0",
"eslint-plugin-vue-scoped-css": "2.10.0", "eslint-plugin-vue-scoped-css": "2.10.0",

View file

@ -342,6 +342,20 @@ func LFSFileGet(ctx *context.Context) {
ctx.Data["IsVideoFile"] = true ctx.Data["IsVideoFile"] = true
case st.IsAudio(): case st.IsAudio():
ctx.Data["IsAudioFile"] = true 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()): case st.IsImage() && (setting.UI.SVG.Enabled || !st.IsSvgImage()):
ctx.Data["IsImageFile"] = true ctx.Data["IsImageFile"] = true
} }

View file

@ -624,6 +624,20 @@ func renderFile(ctx *context.Context, entry *git.TreeEntry) {
ctx.Data["IsVideoFile"] = true ctx.Data["IsVideoFile"] = true
case fInfo.st.IsAudio(): case fInfo.st.IsAudio():
ctx.Data["IsAudioFile"] = true 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()): case fInfo.st.IsImage() && (setting.UI.SVG.Enabled || !fInfo.st.IsSvgImage()):
ctx.Data["IsImageFile"] = true ctx.Data["IsImageFile"] = true
ctx.Data["CanCopyContent"] = true ctx.Data["CanCopyContent"] = true

View file

@ -32,6 +32,12 @@
</audio> </audio>
{{else if .IsPDFFile}} {{else if .IsPDFFile}}
<div class="pdf-content is-loading" data-src="{{$.RawFileLink}}" data-fallback-button-text="{{ctx.Locale.Tr "repo.diff.view_file"}}"></div> <div class="pdf-content is-loading" data-src="{{$.RawFileLink}}" data-fallback-button-text="{{ctx.Locale.Tr "repo.diff.view_file"}}"></div>
{{else if .Is3DModelFile}}
{{if .IsGLBFile}}
<model-viewer src="{{$.RawFileLink}}" ar shadow-intensity="2" camera-controls touch-action="pan-y"></model-viewer>
{{else}}
<a href="{{$.RawFileLink}}" rel="nofollow">{{ctx.Locale.Tr "repo.file_view_raw"}}!</a>
{{end}}
{{else}} {{else}}
<a href="{{$.RawFileLink}}" rel="nofollow">{{ctx.Locale.Tr "repo.file_view_raw"}}</a> <a href="{{$.RawFileLink}}" rel="nofollow">{{ctx.Locale.Tr "repo.file_view_raw"}}</a>
{{end}} {{end}}

View file

@ -116,6 +116,12 @@
</audio> </audio>
{{else if .IsPDFFile}} {{else if .IsPDFFile}}
<div class="pdf-content is-loading" data-src="{{$.RawFileLink}}" data-fallback-button-text="{{ctx.Locale.Tr "repo.diff.view_file"}}"></div> <div class="pdf-content is-loading" data-src="{{$.RawFileLink}}" data-fallback-button-text="{{ctx.Locale.Tr "repo.diff.view_file"}}"></div>
{{else if .Is3DModelFile}}
{{if .IsGLBFile}}
<model-viewer src="{{$.RawFileLink}}" ar shadow-intensity="2" camera-controls touch-action="pan-y"></model-viewer>
{{else}}
<a href="{{$.RawFileLink}}" rel="nofollow">{{ctx.Locale.Tr "repo.file_view_raw"}}</a>
{{end}}
{{else}} {{else}}
<a href="{{$.RawFileLink}}" rel="nofollow">{{ctx.Locale.Tr "repo.file_view_raw"}}</a> <a href="{{$.RawFileLink}}" rel="nofollow">{{ctx.Locale.Tr "repo.file_view_raw"}}</a>
{{end}} {{end}}

14
tests/testdata/data/viewer/README.md vendored Normal file
View file

@ -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.

Binary file not shown.

View file

@ -415,6 +415,11 @@ td .commit-summary {
max-width: 600px !important; max-width: 600px !important;
} }
model-viewer {
width: 100%;
height: 100vh;
}
.pdf-content { .pdf-content {
width: 100%; width: 100%;
height: 100vh; height: 100vh;

View file

@ -23,6 +23,7 @@ import {initStopwatch} from './features/stopwatch.js';
import {initFindFileInRepo} from './features/repo-findfile.js'; import {initFindFileInRepo} from './features/repo-findfile.js';
import {initCommentContent, initMarkupContent} from './markup/content.js'; import {initCommentContent, initMarkupContent} from './markup/content.js';
import {initPdfViewer} from './render/pdf.js'; import {initPdfViewer} from './render/pdf.js';
import {initGltfViewer} from './render/gltf.js';
import {initUserAuthOauth2, initUserAuth} from './features/user-auth.js'; import {initUserAuthOauth2, initUserAuth} from './features/user-auth.js';
import { import {
@ -187,6 +188,7 @@ onDomReady(() => {
initUserAuth(); initUserAuth();
initRepoDiffView(); initRepoDiffView();
initPdfViewer(); initPdfViewer();
initGltfViewer();
initScopedAccessTokenCategories(); initScopedAccessTokenCategories();
initColorPickers(); initColorPickers();
}); });

View file

@ -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');
}