Skip to content

Commit cddef5f

Browse files
alexstockerAlexander Stockersaw-jan
authored
[Feature] Extend supported file types #1 (#27)
* Added basic STL support * Added AxesHelper * Added basic FBX support * Added basic OBJ support and fixed unloading models on prev/next * Modified CHANGELOG * Lint fix * Update src/index.ts Co-authored-by: Sawjan Gurung <[email protected]> * Update src/App.vue Co-authored-by: Sawjan Gurung <[email protected]> * Update src/App.vue Co-authored-by: Sawjan Gurung <[email protected]> * Remove scene.remove for renderNewModel. no longer needed --------- Co-authored-by: Alexander Stocker <[email protected]> Co-authored-by: Sawjan Gurung <[email protected]>
1 parent 3a3b8fa commit cddef5f

File tree

5 files changed

+146
-24
lines changed

5 files changed

+146
-24
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
1+
.idea
12
node_modules/
2-
dist/
3+
dist/

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [Unreleased]
99

1010
### Added
11+
Added basic OBJ support
12+
Added light and ambient
13+
Added default material
14+
Added basic FBX support
15+
Added basic debugging mode
16+
Added basic STL support
1117

1218
### Fixed
19+
Fixed unloading models on prev/next
1320

1421
### Changed
1522

README.md

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,11 @@
22

33
![3d model viewer ui](./docs/ss-light.png)
44

5-
This is an extension for [ownCloud web](https://github.com/owncloud/web) for viewing 3D files. Currently, it can only display 3D models in `.glb` file format.
5+
This is an extension for [ownCloud web](https://github.com/owncloud/web) for viewing 3D files.
66

77
## Feature Highlights ✨
88

9-
- Supported formats:
10-
- `.glb`
9+
- Supported formats: [`.glb`, `.stl`, `.fbx`, `.obj`]
1110
- Zoom/Rotate model
1211
- Fullscreen view
1312
- Navigate between model files
@@ -51,7 +50,7 @@ Now, you can access the app at https://localhost:9200
5150

5251
## 3D models
5352

54-
The app currently only supports 3D models in `.glb` format. You can find models on the following platforms:
53+
You can find models on the following platforms:
5554

5655
- [sketchfab](https://sketchfab.com/)
5756
- [3Dexport](https://3dexport.com/free-3d-models)

src/App.vue

Lines changed: 122 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -45,18 +45,26 @@
4545
<script setup lang="ts">
4646
import { ref, unref, onMounted, onBeforeUnmount, computed } from 'vue'
4747
import {
48+
AmbientLight,
49+
AxesHelper,
4850
Scene,
51+
Mesh,
4952
PerspectiveCamera,
53+
PointLight,
5054
WebGLRenderer,
5155
ACESFilmicToneMapping,
5256
EquirectangularReflectionMapping,
5357
Box3,
5458
Vector3,
5559
Euler,
56-
TextureLoader
60+
TextureLoader,
61+
MeshPhongMaterial
5762
} from 'three'
5863
import WebGL from 'three/examples/jsm/capabilities/WebGL'
5964
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
65+
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader'
66+
import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader'
67+
import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader'
6068
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
6169
import {
6270
AppLoadingSpinner,
@@ -75,7 +83,7 @@ import PreviewControls from './components/PreviewControls.vue'
7583
import { id as appId } from '../public/manifest.json'
7684
7785
const environment = new URL('./assets/custom_light.jpg', import.meta.url).href
78-
const supportExtensions = ['glb']
86+
const supportExtensions = ['glb', 'stl', 'fbx', 'obj']
7987
8088
const router = useRouter()
8189
const route = useRoute()
@@ -92,6 +100,7 @@ let iniCamPosition: Vector3 | null = null
92100
let iniCamZPosition: number = 0
93101
const iniCamRotation: Euler = new Euler(0, 0, 0)
94102
const animTimeoutSec = 1
103+
const debugIsEnabled = false
95104
96105
// =====================
97106
// props
@@ -137,13 +146,14 @@ onMounted(async () => {
137146
138147
// camera controls
139148
controls = new OrbitControls(camera, renderer.domElement)
140-
controls.minDistance = 1
149+
controls.minDistance = 0
141150
controls.maxDistance = 100
142151
143152
// load environment texture
144153
try {
145154
await loadEnvironment()
146-
await renderModel()
155+
await renderModel(unref(fileType))
156+
loadLights()
147157
} catch (e) {
148158
cleanup3dScene()
149159
hasError.value = true
@@ -182,6 +192,7 @@ const modelFiles = computed<Resource[]>(() => {
182192
return sortHelper(files, [{ name: unref(sortBy) }], unref(sortBy), unref(sortDir))
183193
})
184194
const activeModelFile = computed(() => unref(modelFiles)[unref(activeIndex)])
195+
const fileType = computed(() => unref(activeModelFile)?.extension)
185196
186197
// =====================
187198
// methods
@@ -192,22 +203,65 @@ async function updateUrl() {
192203
unref(activeModelFile)
193204
)
194205
}
206+
195207
async function loadEnvironment() {
196208
const texture = await new TextureLoader().loadAsync(environment)
197209
texture.mapping = EquirectangularReflectionMapping
198210
scene.environment = texture
199211
}
200-
async function renderModel() {
201-
const model = await new GLTFLoader().loadAsync(unref(currentUrl), (xhr) => {
212+
213+
const LoaderMap = {
214+
glb: GLTFLoader,
215+
stl: STLLoader,
216+
fbx: FBXLoader,
217+
obj: OBJLoader
218+
}
219+
220+
const materialParams = {
221+
transparent: true,
222+
opacity: 0.8,
223+
color: 0xd7d7d7,
224+
flatShading: true
225+
}
226+
227+
const lightParams = {
228+
color: 0xffffff,
229+
intensity: 1000,
230+
posX: 2.5,
231+
posY: 15,
232+
posZ: 25,
233+
ambient: true
234+
}
235+
236+
async function renderModel(extension: string) {
237+
const ModelLoader = LoaderMap[extension]
238+
239+
const model = await new ModelLoader().loadAsync(unref(currentUrl), (xhr) => {
202240
const downloaded = Math.floor((xhr.loaded / xhr.total) * 100)
203241
if (downloaded % 5 === 0) {
204242
loadingProgress.value = downloaded
205243
}
206244
})
207245
208-
const modelScene = model.scene
209-
// model size
210-
const box = new Box3().setFromObject(modelScene)
246+
debug(model)
247+
248+
const box = new Box3()
249+
if (!model.hasOwnProperty('scene') && extension === 'stl') {
250+
const mesh = new Mesh(model, defaultMaterial())
251+
scene.add(mesh)
252+
box.setFromBufferAttribute(model.attributes.position)
253+
} else if (!model.hasOwnProperty('scene') && (extension === 'fbx' || extension === 'obj')) {
254+
box.setFromObject(model)
255+
model.traverse(function (child) {
256+
if (child.isMesh) {
257+
child.material = defaultMaterial()
258+
}
259+
})
260+
scene.add(model)
261+
} else {
262+
box.setFromObject(model.scene)
263+
}
264+
211265
iniCamPosition = box.getCenter(new Vector3())
212266
213267
// direct camera at model
@@ -216,15 +270,36 @@ async function renderModel() {
216270
camera.position.z = iniCamZPosition
217271
camera.lookAt(iniCamPosition)
218272
219-
// center model
220-
modelScene.position.sub(iniCamPosition)
221-
scene.add(modelScene)
222-
223273
loadingModel.value = false
224-
currentModel.value = modelScene
274+
if (extension === 'glb') {
275+
const modelScene = model.scene
276+
// center model
277+
modelScene.position.sub(iniCamPosition)
278+
scene.add(modelScene)
279+
currentModel.value = modelScene
280+
} else {
281+
currentModel.value = scene
282+
}
283+
225284
unref(sceneWrapper).appendChild(renderer.domElement)
226285
render(Date.now())
227286
}
287+
288+
function loadLights(): void {
289+
const light = new PointLight(lightParams.color, lightParams.intensity)
290+
light.position.set(lightParams.posX, lightParams.posY, lightParams.posZ)
291+
scene.add(light)
292+
293+
if (lightParams.ambient) {
294+
const ambientLight = new AmbientLight()
295+
scene.add(ambientLight)
296+
}
297+
}
298+
299+
function defaultMaterial(): MeshPhongMaterial {
300+
return new MeshPhongMaterial(materialParams)
301+
}
302+
228303
function render(animStartTime: number) {
229304
animationId.value = requestAnimationFrame(() => render(animStartTime))
230305
// TODO: enable animation
@@ -236,29 +311,29 @@ function render(animStartTime: number) {
236311
controls.update()
237312
renderer.render(scene, camera)
238313
}
314+
239315
async function renderNewModel() {
240316
cancelAnimationFrame(unref(animationId))
241-
scene.remove(scene.getObjectByName(unref(currentModel).name))
242317
243318
await updateUrl()
244319
245320
loadingModel.value = true
246321
hasError.value = false
247-
await renderModel()
322+
await renderModel(unref(fileType))
248323
}
324+
249325
function cleanup3dScene() {
250-
scene.traverse((obj) => {
251-
scene.remove(obj)
252-
})
253326
cancelAnimationFrame(unref(animationId))
254327
renderer.dispose()
255328
}
329+
256330
function changeCursor(state: string) {
257331
const el = unref(sceneWrapper)
258332
if (el.classList.contains('model-viewport')) {
259333
el.style.cursor = state
260334
}
261335
}
336+
262337
async function setActiveModel(driveAliasAndItem: string) {
263338
for (let i = 0; i < unref(modelFiles).length; i++) {
264339
if (
@@ -271,6 +346,7 @@ async function setActiveModel(driveAliasAndItem: string) {
271346
}
272347
}
273348
}
349+
274350
function updateLocalHistory() {
275351
if (!unref(currentFileContext)) {
276352
return
@@ -286,38 +362,54 @@ function updateLocalHistory() {
286362
query: { ...unref(route).query, ...query }
287363
})
288364
}
365+
289366
async function next() {
290367
if (!unref(isModelReady)) {
291368
return
292369
}
370+
293371
if (unref(activeIndex) + 1 >= unref(modelFiles).length) {
294372
activeIndex.value = 0
295373
} else {
296374
activeIndex.value++
297375
}
298376
299377
updateLocalHistory()
378+
await unloadModels()
300379
// TODO: how to prevent activeFiles from being reduced
301380
// load activeFiles
302381
await loadFolderForFileContext(unref(currentFileContext))
303382
await renderNewModel()
304383
}
384+
305385
async function prev() {
306386
if (!unref(isModelReady)) {
307387
return
308388
}
389+
309390
if (unref(activeIndex) === 0) {
310391
activeIndex.value = unref(modelFiles).length - 1
311392
} else {
312393
activeIndex.value--
313394
}
314395
315396
updateLocalHistory()
397+
await unloadModels()
316398
// TODO: how to prevent activeFiles from being reduced
317399
// load activeFiles
318400
await loadFolderForFileContext(unref(currentFileContext))
319401
await renderNewModel()
320402
}
403+
404+
async function unloadModels(): Promise<void> {
405+
for (let i = scene.children.length - 1; i >= 0; i--) {
406+
let obj = scene.children[i]
407+
if (unref(obj.type) === 'Group' || unref(obj.type) === 'Mesh') {
408+
scene.remove(obj)
409+
}
410+
}
411+
}
412+
321413
function toggleFullscreenMode() {
322414
const activateFullscreen = !unref(isFullScreenModeActivated)
323415
const el = unref(sceneWrapper)
@@ -332,6 +424,7 @@ function toggleFullscreenMode() {
332424
}
333425
}
334426
}
427+
335428
function resetModelPosition() {
336429
if (unref(isModelReady)) {
337430
camera.position.copy(iniCamPosition)
@@ -340,6 +433,15 @@ function resetModelPosition() {
340433
camera.lookAt(iniCamPosition)
341434
}
342435
}
436+
437+
function debug(output) {
438+
if (debugIsEnabled) {
439+
scene.add(new AxesHelper(10))
440+
console.log('####### DEBUG 3D MODEL #######')
441+
console.log(output)
442+
console.log('#####################')
443+
}
444+
}
343445
</script>
344446

345447
<style lang="scss" scoped>
@@ -352,6 +454,7 @@ function resetModelPosition() {
352454
cursor: grab;
353455
}
354456
}
457+
355458
#spinner {
356459
& > div {
357460
width: unset;

src/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,18 @@ export default defineWebApplication({
3333
{
3434
extension: 'glb',
3535
label: 'View 3D Model'
36+
},
37+
{
38+
extension: 'stl',
39+
label: 'View 3D Model'
40+
},
41+
{
42+
extension: 'fbx',
43+
label: 'View 3D Model'
44+
},
45+
{
46+
extension: 'obj',
47+
label: 'View 3D Model'
3648
}
3749
]
3850
},

0 commit comments

Comments
 (0)