Skip to content

Commit e87cfdf

Browse files
committed
support preview 3d file
1 parent cda90ec commit e87cfdf

File tree

9 files changed

+170
-6
lines changed

9 files changed

+170
-6
lines changed

modules/typesniffer/typesniffer.go

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"fmt"
1010
"io"
1111
"net/http"
12+
"path/filepath"
1213
"regexp"
1314
"slices"
1415
"strings"
@@ -26,6 +27,14 @@ const (
2627
MimeTypeApplicationOctetStream = "application/octet-stream"
2728
)
2829

30+
// list of supported CAD 3D file extensions
31+
var supportedCAD3DFileExtensions = []string{
32+
".3dm", ".3ds", ".3mf", ".amf", ".bim", ".brep",
33+
".dae", ".fbx", ".fcstd", ".glb", ".gltf",
34+
".ifc", ".igs", ".iges", ".stp", ".step",
35+
".stl", ".obj", ".off", ".ply", ".wrl",
36+
}
37+
2938
var (
3039
svgComment = regexp.MustCompile(`(?s)<!--.*?-->`)
3140
svgTagRegex = regexp.MustCompile(`(?si)\A\s*(?:(<!DOCTYPE\s+svg([\s:]+.*?>|>))\s*)*<svg\b`)
@@ -35,6 +44,7 @@ var (
3544
// SniffedType contains information about a blobs type.
3645
type SniffedType struct {
3746
contentType string
47+
fileName string
3848
}
3949

4050
// IsText etects if content format is plain text.
@@ -67,6 +77,20 @@ func (ct SniffedType) IsAudio() bool {
6777
return strings.Contains(ct.contentType, "audio/")
6878
}
6979

80+
// Is3DFile detects if data is a 3D model file format
81+
func (ct SniffedType) Is3DFile() bool {
82+
if ct.fileName == "" {
83+
return false
84+
}
85+
ext := strings.ToLower(filepath.Ext(ct.fileName))
86+
for _, supportedExt := range supportedCAD3DFileExtensions {
87+
if ext == supportedExt {
88+
return true
89+
}
90+
}
91+
return false
92+
}
93+
7094
// IsRepresentableAsText returns true if file content can be represented as
7195
// plain text or is empty.
7296
func (ct SniffedType) IsRepresentableAsText() bool {
@@ -75,7 +99,7 @@ func (ct SniffedType) IsRepresentableAsText() bool {
7599

76100
// IsBrowsableBinaryType returns whether a non-text type can be displayed in a browser
77101
func (ct SniffedType) IsBrowsableBinaryType() bool {
78-
return ct.IsImage() || ct.IsSvgImage() || ct.IsPDF() || ct.IsVideo() || ct.IsAudio()
102+
return ct.IsImage() || ct.IsSvgImage() || ct.IsPDF() || ct.IsVideo() || ct.IsAudio() || ct.Is3DFile()
79103
}
80104

81105
// GetMimeType returns the mime type
@@ -105,8 +129,13 @@ func detectFileTypeBox(data []byte) (brands []string, found bool) {
105129

106130
// DetectContentType extends http.DetectContentType with more content types. Defaults to text/unknown if input is empty.
107131
func DetectContentType(data []byte) SniffedType {
132+
return DetectContentTypeWithFilename(data, "")
133+
}
134+
135+
// DetectContentTypeWithFilename extends http.DetectContentType with more content types and uses filename for additional detection. Defaults to text/unknown if input is empty.
136+
func DetectContentTypeWithFilename(data []byte, fileName string) SniffedType {
108137
if len(data) == 0 {
109-
return SniffedType{"text/unknown"}
138+
return SniffedType{"text/unknown", fileName}
110139
}
111140

112141
ct := http.DetectContentType(data)
@@ -153,17 +182,22 @@ func DetectContentType(data []byte) SniffedType {
153182
ct = "audio/ogg" // for most cases, it is used as an audio container
154183
}
155184
}
156-
return SniffedType{ct}
185+
return SniffedType{ct, fileName}
157186
}
158187

159188
// DetectContentTypeFromReader guesses the content type contained in the reader.
160189
func DetectContentTypeFromReader(r io.Reader) (SniffedType, error) {
190+
return DetectContentTypeFromReaderWithFilename(r, "")
191+
}
192+
193+
// DetectContentTypeFromReaderWithFilename guesses the content type contained in the reader with given filename.
194+
func DetectContentTypeFromReaderWithFilename(r io.Reader, fileName string) (SniffedType, error) {
161195
buf := make([]byte, sniffLen)
162196
n, err := util.ReadAtMost(r, buf)
163197
if err != nil {
164198
return SniffedType{}, fmt.Errorf("DetectContentTypeFromReader io error: %w", err)
165199
}
166200
buf = buf[:n]
167201

168-
return DetectContentType(buf), nil
202+
return DetectContentTypeWithFilename(buf, fileName), nil
169203
}

package-lock.json

Lines changed: 51 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
"minimatch": "10.0.2",
4040
"monaco-editor": "0.52.2",
4141
"monaco-editor-webpack-plugin": "7.1.0",
42+
"online-3d-viewer": "0.16.0",
4243
"pdfobject": "2.3.1",
4344
"perfect-debounce": "1.0.0",
4445
"postcss": "8.5.5",

routers/web/repo/view.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ func getFileReader(ctx gocontext.Context, repoID int64, blob *git.Blob) ([]byte,
7676
n, _ := util.ReadAtMost(dataRc, buf)
7777
buf = buf[:n]
7878

79-
st := typesniffer.DetectContentType(buf)
79+
st := typesniffer.DetectContentTypeWithFilename(buf, blob.Name())
8080
isTextFile := st.IsText()
8181

8282
// FIXME: what happens when README file is an image?
@@ -110,7 +110,7 @@ func getFileReader(ctx gocontext.Context, repoID int64, blob *git.Blob) ([]byte,
110110
}
111111
buf = buf[:n]
112112

113-
st = typesniffer.DetectContentType(buf)
113+
st = typesniffer.DetectContentTypeWithFilename(buf, blob.Name())
114114

115115
return buf, dataRc, &fileInfo{st.IsText(), true, meta.Size, &meta.Pointer, st}, nil
116116
}

routers/web/repo/view_file.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,8 @@ func prepareToRenderFile(ctx *context.Context, entry *git.TreeEntry) {
239239

240240
case fInfo.st.IsPDF():
241241
ctx.Data["IsPDFFile"] = true
242+
case fInfo.st.Is3DFile():
243+
ctx.Data["Is3DFile"] = true
242244
case fInfo.st.IsVideo():
243245
ctx.Data["IsVideoFile"] = true
244246
case fInfo.st.IsAudio():

templates/repo/view_file.tmpl

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,8 @@
109109
</audio>
110110
{{else if .IsPDFFile}}
111111
<div class="pdf-content is-loading" data-global-init="initPdfViewer" data-src="{{$.RawFileLink}}" data-fallback-button-text="{{ctx.Locale.Tr "repo.diff.view_file"}}"></div>
112+
{{else if .Is3DFile}}
113+
<div class="model3d-content is-loading" data-global-init="init3DViewer" data-src="{{$.RawFileLink}}" data-fallback-button-text="{{ctx.Locale.Tr "repo.diff.view_file"}}"></div>
112114
{{else}}
113115
<a href="{{$.RawFileLink}}" rel="nofollow" class="tw-p-4">{{ctx.Locale.Tr "repo.file_view_raw"}}</a>
114116
{{end}}

web_src/css/repo.css

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2251,3 +2251,32 @@ tbody.commit-list {
22512251
.branch-selector-dropdown .scrolling.menu .loading-indicator {
22522252
height: 4em;
22532253
}
2254+
2255+
/* 3D model viewer styles */
2256+
.model3d-content {
2257+
width: 100%;
2258+
height: 600px;
2259+
border: none !important;
2260+
display: flex;
2261+
align-items: center;
2262+
justify-content: center;
2263+
}
2264+
2265+
.model3d-content.is-loading {
2266+
position: relative;
2267+
}
2268+
2269+
.model3d-content.is-loading:after {
2270+
content: '';
2271+
position: absolute;
2272+
left: 50%;
2273+
top: 50%;
2274+
width: 40px;
2275+
height: 40px;
2276+
margin-left: -20px;
2277+
margin-top: -20px;
2278+
border: 5px solid var(--color-secondary);
2279+
border-top-color: transparent;
2280+
border-radius: 50%;
2281+
animation: spin 1s linear infinite;
2282+
}

web_src/js/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {initStopwatch} from './features/stopwatch.ts';
2020
import {initFindFileInRepo} from './features/repo-findfile.ts';
2121
import {initMarkupContent} from './markup/content.ts';
2222
import {initPdfViewer} from './render/pdf.ts';
23+
import {init3DViewer} from './render/3d-viewer.ts';
2324
import {initUserAuthOauth2, initUserCheckAppUrl} from './features/user-auth.ts';
2425
import {initRepoPullRequestAllowMaintainerEdit, initRepoPullRequestReview, initRepoIssueSidebarDependency, initRepoIssueFilterItemLabel} from './features/repo-issue.ts';
2526
import {initRepoEllipsisButton, initCommitStatuses} from './features/repo-commit.ts';
@@ -160,6 +161,7 @@ onDomReady(() => {
160161
initUserSettings,
161162
initRepoDiffView,
162163
initPdfViewer,
164+
init3DViewer,
163165
initColorPickers,
164166

165167
initOAuth2SettingsDisableCheckbox,

web_src/js/render/3d-viewer.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import {registerGlobalInitFunc} from '../modules/observer.ts';
2+
3+
export async function init3DViewer() {
4+
registerGlobalInitFunc('init3DViewer', async (el: HTMLElement) => {
5+
try {
6+
// dynamically import online-3d-viewer library
7+
const OV = await import(/* webpackChunkName: "online-3d-viewer" */'online-3d-viewer');
8+
9+
const src = el.getAttribute('data-src');
10+
if (!src) {
11+
throw new Error('No source provided for 3D viewer');
12+
}
13+
14+
// set 3D viewer container styles
15+
el.style.height = '400px';
16+
el.style.width = '100%';
17+
18+
// initialize 3D viewer
19+
const viewer = new OV.EmbeddedViewer(el, {
20+
backgroundColor: new OV.RGBAColor(59, 68, 76, 0), // transparent
21+
defaultColor: new OV.RGBColor(65, 131, 196),
22+
edgeSettings: new OV.EdgeSettings(false, new OV.RGBColor(0, 0, 0), 1),
23+
});
24+
25+
// load model
26+
viewer.LoadModelFromUrlList([src]);
27+
28+
} catch (error) {
29+
console.error('Error initializing 3D viewer:', error);
30+
// show error message and fallback button
31+
const fallbackText = el.getAttribute('data-fallback-button-text') || '';
32+
const src = el.getAttribute('data-src') || '#';
33+
el.innerHTML = `
34+
<div class="ui error message">
35+
<a class="ui basic button" href="${src}" target="_blank">${fallbackText}</a>
36+
</div>
37+
`;
38+
} finally {
39+
// remove loading state
40+
el.classList.remove('is-loading');
41+
}
42+
});
43+
}

0 commit comments

Comments
 (0)