From 182e6e0123e3c1f3c3bac2ffb319004fa5d831a6 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 @@

BIM Twin Viewer

v0.1.0 +
+ + Processing... +
-
-

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 } +} +