From 1245316aa458888b175db8760974297012f2df23 Mon Sep 17 00:00:00 2001
From: warnason <276599704+warnason@users.noreply.github.com>
Date: Mon, 20 Apr 2026 18:10:26 +0200
Subject: [PATCH] Add interactive Three.js 3D viewer with IFC element
inspection
- GLTFLoader renders server-converted glb model
- Click on 3D element to view properties (linked via IFC GlobalId)
- Sidebar with element list, filterable by type and storey
- Property panel shows all IFC PropertySets for selected element
- OrbitControls for camera navigation (rotate, zoom, pan)
- Auto-loads existing project on page visit
- Upload button for new IFC files
---
frontend/src/App.vue | 392 +++++++++++++++++++++++++-
frontend/src/composables/useApi.js | 39 +++
frontend/src/composables/useViewer.js | 218 ++++++++++++++
3 files changed, 638 insertions(+), 11 deletions(-)
create mode 100644 frontend/src/composables/useApi.js
create mode 100644 frontend/src/composables/useViewer.js
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
index 267b344..14c0965 100644
--- a/frontend/src/App.vue
+++ b/frontend/src/App.vue
@@ -3,14 +3,199 @@
-
- Interactive IFC-based 3D building model viewer.
- Upload an IFC file to get started.
-
+
+
+
+
+
+
+
+
+
Upload an IFC file to view the 3D model
+
+
+
+
+
+
+
+
diff --git a/frontend/src/composables/useApi.js b/frontend/src/composables/useApi.js
new file mode 100644
index 0000000..2c80b90
--- /dev/null
+++ b/frontend/src/composables/useApi.js
@@ -0,0 +1,39 @@
+import axios from 'axios'
+
+const api = axios.create({
+ baseURL: '/api',
+})
+
+export function useApi() {
+ async function uploadIfc(file) {
+ const formData = new FormData()
+ formData.append('file', file)
+ const { data } = await api.post('/upload', formData)
+ return data
+ }
+
+ async function getProjects() {
+ const { data } = await api.get('/projects')
+ return data
+ }
+
+ async function getElements(projectId, filters = {}) {
+ const params = new URLSearchParams()
+ if (filters.ifc_type) params.append('ifc_type', filters.ifc_type)
+ if (filters.storey) params.append('storey', filters.storey)
+ const { data } = await api.get(`/projects/${projectId}/elements?${params}`)
+ return data
+ }
+
+ async function getElementByGlobalId(projectId, globalId) {
+ const { data } = await api.get(`/projects/${projectId}/elements/by-global-id/${globalId}`)
+ return data
+ }
+
+ function getModelUrl(projectId) {
+ return `/api/projects/${projectId}/model.glb`
+ }
+
+ return { uploadIfc, getProjects, getElements, getElementByGlobalId, getModelUrl }
+}
+
diff --git a/frontend/src/composables/useViewer.js b/frontend/src/composables/useViewer.js
new file mode 100644
index 0000000..a94ed33
--- /dev/null
+++ b/frontend/src/composables/useViewer.js
@@ -0,0 +1,218 @@
+import * as THREE from 'three'
+import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
+import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'
+
+export function useViewer() {
+ let scene, camera, renderer, controls
+ let model = null
+ let raycaster = new THREE.Raycaster()
+ let mouse = new THREE.Vector2()
+ let selectedMesh = null
+ let originalMaterials = new Map()
+ let onElementClick = null
+
+ const highlightMaterial = new THREE.MeshStandardMaterial({
+ color: 0x00aaff,
+ emissive: 0x003366,
+ transparent: true,
+ opacity: 0.9,
+ })
+
+ function init(container, onClick) {
+ onElementClick = onClick
+
+ // Scene
+ scene = new THREE.Scene()
+ scene.background = new THREE.Color(0x1a1a2e)
+
+ // Camera
+ camera = new THREE.PerspectiveCamera(
+ 60,
+ container.clientWidth / container.clientHeight,
+ 0.1,
+ 1000
+ )
+ camera.position.set(15, 15, 15)
+
+ // Renderer
+ renderer = new THREE.WebGLRenderer({ antialias: true })
+ renderer.setSize(container.clientWidth, container.clientHeight)
+ renderer.setPixelRatio(window.devicePixelRatio)
+ renderer.shadowMap.enabled = true
+ container.appendChild(renderer.domElement)
+
+ // Controls
+ controls = new OrbitControls(camera, renderer.domElement)
+ controls.enableDamping = true
+ controls.dampingFactor = 0.05
+ controls.target.set(0, 3, 0)
+ controls.update()
+
+ // Lights
+ const ambientLight = new THREE.AmbientLight(0xffffff, 0.6)
+ scene.add(ambientLight)
+
+ const dirLight = new THREE.DirectionalLight(0xffffff, 0.8)
+ dirLight.position.set(20, 30, 10)
+ dirLight.castShadow = true
+ scene.add(dirLight)
+
+ const dirLight2 = new THREE.DirectionalLight(0xffffff, 0.3)
+ dirLight2.position.set(-10, 20, -10)
+ scene.add(dirLight2)
+
+ // Grid
+ const grid = new THREE.GridHelper(50, 50, 0x333355, 0x222244)
+ scene.add(grid)
+
+ // Click handler
+ renderer.domElement.addEventListener('click', onCanvasClick)
+
+ // Resize handler
+ window.addEventListener('resize', () => onResize(container))
+
+ // Start render loop
+ animate()
+ }
+
+ function loadModel(url) {
+ return new Promise((resolve, reject) => {
+ if (model) {
+ scene.remove(model)
+ model = null
+ originalMaterials.clear()
+ }
+
+ const loader = new GLTFLoader()
+ loader.load(
+ url,
+ (gltf) => {
+ model = gltf.scene
+
+ // Store original materials for highlight/unhighlight
+ model.traverse((child) => {
+ if (child.isMesh) {
+ originalMaterials.set(child, child.material.clone())
+ child.castShadow = true
+ child.receiveShadow = true
+ }
+ })
+
+ scene.add(model)
+ fitCameraToModel()
+ resolve(model)
+ },
+ undefined,
+ (error) => {
+ reject(error)
+ }
+ )
+ })
+ }
+
+ function fitCameraToModel() {
+ if (!model) return
+
+ const box = new THREE.Box3().setFromObject(model)
+ const center = box.getCenter(new THREE.Vector3())
+ const size = box.getSize(new THREE.Vector3())
+ const maxDim = Math.max(size.x, size.y, size.z)
+ const distance = maxDim * 1.5
+
+ camera.position.set(
+ center.x + distance,
+ center.y + distance * 0.7,
+ center.z + distance
+ )
+ controls.target.copy(center)
+ controls.update()
+ }
+
+ function onCanvasClick(event) {
+ const rect = renderer.domElement.getBoundingClientRect()
+ mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1
+ mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1
+
+ raycaster.setFromCamera(mouse, camera)
+
+ if (!model) return
+
+ const meshes = []
+ model.traverse((child) => {
+ if (child.isMesh) meshes.push(child)
+ })
+
+ const intersects = raycaster.intersectObjects(meshes, false)
+
+ if (intersects.length > 0) {
+ const mesh = intersects[0].object
+ highlightElement(mesh)
+
+ // Walk up to find the named parent (GlobalId)
+ let globalId = null
+ let current = mesh
+ while (current) {
+ if (current.name && current.name.length >= 22) {
+ globalId = current.name
+ break
+ }
+ current = current.parent
+ }
+
+ if (globalId && onElementClick) {
+ onElementClick(globalId)
+ }
+ } else {
+ clearHighlight()
+ if (onElementClick) onElementClick(null)
+ }
+ }
+
+ function highlightElement(mesh) {
+ clearHighlight()
+ selectedMesh = mesh
+ mesh.material = highlightMaterial
+ }
+
+ function highlightByGlobalId(globalId) {
+ if (!model) return
+ clearHighlight()
+
+ model.traverse((child) => {
+ if (child.name === globalId && child.isMesh) {
+ highlightElement(child)
+ } else if (child.name === globalId) {
+ child.traverse((sub) => {
+ if (sub.isMesh) highlightElement(sub)
+ })
+ }
+ })
+ }
+
+ function clearHighlight() {
+ if (selectedMesh && originalMaterials.has(selectedMesh)) {
+ selectedMesh.material = originalMaterials.get(selectedMesh)
+ selectedMesh = null
+ }
+ }
+
+ function onResize(container) {
+ camera.aspect = container.clientWidth / container.clientHeight
+ camera.updateProjectionMatrix()
+ renderer.setSize(container.clientWidth, container.clientHeight)
+ }
+
+ function animate() {
+ requestAnimationFrame(animate)
+ controls.update()
+ renderer.render(scene, camera)
+ }
+
+ function dispose() {
+ renderer.domElement.removeEventListener('click', onCanvasClick)
+ renderer.dispose()
+ }
+
+ return { init, loadModel, highlightByGlobalId, clearHighlight, dispose }
+}
+