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
This commit is contained in:
warnason 2026-04-20 18:10:26 +02:00
parent fb674ab580
commit 182e6e0123
3 changed files with 638 additions and 11 deletions

View file

@ -3,14 +3,199 @@
<header class="header"> <header class="header">
<h1>BIM Twin Viewer</h1> <h1>BIM Twin Viewer</h1>
<span class="version">v0.1.0</span> <span class="version">v0.1.0</span>
<div class="header-actions">
<label v-if="!loading" class="upload-btn">
Upload IFC
<input type="file" accept=".ifc" @change="handleUpload" hidden />
</label>
<span v-else class="loading">Processing...</span>
</div>
</header> </header>
<main class="main">
<p>Interactive IFC-based 3D building model viewer.</p> <div class="content">
<p>Upload an IFC file to get started.</p> <!-- Sidebar -->
<aside class="sidebar" v-if="project">
<div class="project-info">
<h3>{{ project.name }}</h3>
<p class="meta">{{ project.ifc_schema }} · {{ elements.length }} elements</p>
</div>
<div class="filters">
<select v-model="filterType" @change="loadElements">
<option value="">All types</option>
<option v-for="t in elementTypes" :key="t" :value="t">{{ t }}</option>
</select>
<select v-model="filterStorey" @change="loadElements">
<option value="">All storeys</option>
<option v-for="s in storeys" :key="s" :value="s">{{ s }}</option>
</select>
</div>
<ul class="element-list">
<li
v-for="el in elements"
:key="el.id"
:class="{ active: selectedElement?.id === el.id }"
@click="selectElement(el)"
>
<span class="el-type">{{ el.ifc_type.replace('IfcWallStandardCase', 'Wall').replace('Ifc', '') }}</span>
<span class="el-name">{{ el.name || el.global_id }}</span>
<span class="el-storey" v-if="el.storey">{{ el.storey }}</span>
</li>
</ul>
</aside>
<!-- 3D Viewer -->
<main class="viewer-container" ref="viewerContainer">
<div v-if="!project" class="empty-state">
<p>Upload an IFC file to view the 3D model</p>
</div>
</main> </main>
<!-- Property Panel -->
<aside class="properties" v-if="selectedDetail">
<h3>{{ selectedDetail.name || selectedDetail.global_id }}</h3>
<p class="meta">{{ selectedDetail.ifc_type }} · {{ selectedDetail.storey }}</p>
<div v-for="(props, pset) in groupedProperties" :key="pset" class="pset">
<h4>{{ pset }}</h4>
<table>
<tr v-for="prop in props" :key="prop.id">
<td class="prop-name">{{ prop.name }}</td>
<td class="prop-value">{{ prop.value }}</td>
</tr>
</table>
</div>
</aside>
</div>
</div> </div>
</template> </template>
<script setup>
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { useApi } from './composables/useApi.js'
import { useViewer } from './composables/useViewer.js'
const api = useApi()
const viewer = useViewer()
const viewerContainer = ref(null)
const project = ref(null)
const elements = ref([])
const allElements = ref([])
const selectedElement = ref(null)
const selectedDetail = ref(null)
const loading = ref(false)
const filterType = ref('')
const filterStorey = ref('')
const elementTypes = computed(() => {
const types = new Set(allElements.value.map(e => e.ifc_type))
return [...types].sort()
})
const storeys = computed(() => {
const s = new Set(allElements.value.map(e => e.storey).filter(Boolean))
return [...s].sort()
})
const groupedProperties = computed(() => {
if (!selectedDetail.value?.properties) return {}
const groups = {}
for (const prop of selectedDetail.value.properties) {
const pset = prop.pset_name || 'General'
if (!groups[pset]) groups[pset] = []
groups[pset].push(prop)
}
return groups
})
async function handleUpload(event) {
const file = event.target.files[0]
if (!file) return
loading.value = true
try {
project.value = await api.uploadIfc(file)
await loadAllElements()
await loadElements()
const modelUrl = api.getModelUrl(project.value.id)
await viewer.loadModel(modelUrl)
} catch (err) {
alert('Upload failed: ' + (err.response?.data?.detail || err.message))
} finally {
loading.value = false
}
}
async function loadAllElements() {
if (!project.value) return
allElements.value = await api.getElements(project.value.id)
}
async function loadElements() {
if (!project.value) return
const filters = {}
if (filterType.value) filters.ifc_type = filterType.value
if (filterStorey.value) filters.storey = filterStorey.value
elements.value = await api.getElements(project.value.id, filters)
}
function initViewer() {
viewer.init(viewerContainer.value, onViewerClick)
}
async function onViewerClick(globalId) {
if (!globalId) {
selectedElement.value = null
selectedDetail.value = null
return
}
try {
const detail = await api.getElementByGlobalId(project.value.id, globalId)
selectedElement.value = detail
selectedDetail.value = detail
} catch {
selectedElement.value = null
selectedDetail.value = null
}
}
async function selectElement(el) {
selectedElement.value = el
try {
selectedDetail.value = await api.getElementByGlobalId(project.value.id, el.global_id)
viewer.highlightByGlobalId(el.global_id)
} catch {
selectedDetail.value = null
}
}
// Check if a project already exists on load
onMounted(async () => {
await nextTick()
initViewer()
try {
const projects = await api.getProjects()
if (projects.length > 0) {
project.value = projects[0]
await loadAllElements()
await loadElements()
const modelUrl = api.getModelUrl(project.value.id)
await viewer.loadModel(modelUrl)
}
} catch {
// No projects yet, that's fine
}
})
onUnmounted(() => {
viewer.dispose()
})
</script>
<style> <style>
* { * {
margin: 0; margin: 0;
@ -22,10 +207,11 @@ body {
font-family: system-ui, -apple-system, sans-serif; font-family: system-ui, -apple-system, sans-serif;
background: #1a1a2e; background: #1a1a2e;
color: #e0e0e0; color: #e0e0e0;
overflow: hidden;
} }
.app { .app {
min-height: 100vh; height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
@ -34,29 +220,213 @@ body {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 1rem; gap: 1rem;
padding: 1rem 2rem; padding: 0.6rem 1.5rem;
background: #16213e; background: #16213e;
border-bottom: 1px solid #0f3460; border-bottom: 1px solid #0f3460;
flex-shrink: 0;
} }
.header h1 { .header h1 {
font-size: 1.4rem; font-size: 1.2rem;
font-weight: 600; font-weight: 600;
} }
.version { .version {
font-size: 0.8rem; font-size: 0.75rem;
color: #666;
}
.header-actions {
margin-left: auto;
}
.upload-btn {
background: #0f3460;
color: #e0e0e0;
padding: 0.4rem 1rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
transition: background 0.2s;
}
.upload-btn:hover {
background: #1a5276;
}
.loading {
color: #00aaff;
font-size: 0.85rem;
}
.content {
flex: 1;
display: flex;
overflow: hidden;
}
/* Sidebar */
.sidebar {
width: 280px;
background: #16213e;
border-right: 1px solid #0f3460;
display: flex;
flex-direction: column;
flex-shrink: 0;
overflow: hidden;
}
.project-info {
padding: 1rem;
border-bottom: 1px solid #0f3460;
}
.project-info h3 {
font-size: 0.95rem;
margin-bottom: 0.25rem;
}
.meta {
font-size: 0.75rem;
color: #888; color: #888;
} }
.main { .filters {
padding: 0.5rem;
display: flex;
gap: 0.5rem;
border-bottom: 1px solid #0f3460;
}
.filters select {
flex: 1; flex: 1;
background: #1a1a2e;
color: #e0e0e0;
border: 1px solid #0f3460;
padding: 0.3rem;
border-radius: 3px;
font-size: 0.75rem;
}
.element-list {
flex: 1;
overflow-y: auto;
list-style: none;
}
.element-list li {
padding: 0.5rem 0.75rem;
border-bottom: 1px solid #0f346033;
cursor: pointer;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.1rem;
transition: background 0.15s;
}
.element-list li:hover {
background: #1a1a2e;
}
.element-list li.active {
background: #0f3460;
border-left: 3px solid #00aaff;
}
.el-type {
font-size: 0.65rem;
color: #00aaff;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.el-name {
font-size: 0.8rem;
}
.el-storey {
font-size: 0.65rem;
color: #666;
}
/* Viewer */
.viewer-container {
flex: 1;
position: relative;
overflow: hidden;
}
.empty-state {
display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 1rem; height: 100%;
padding: 2rem; color: #555;
}
/* Properties */
.properties {
width: 320px;
background: #16213e;
border-left: 1px solid #0f3460;
overflow-y: auto;
flex-shrink: 0;
padding: 1rem;
}
.properties h3 {
font-size: 0.95rem;
margin-bottom: 0.25rem;
}
.pset {
margin-top: 1rem;
}
.pset h4 {
font-size: 0.75rem;
color: #00aaff;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.4rem;
padding-bottom: 0.2rem;
border-bottom: 1px solid #0f3460;
}
.pset table {
width: 100%;
font-size: 0.75rem;
border-collapse: collapse;
}
.pset td {
padding: 0.2rem 0;
vertical-align: top;
}
.prop-name {
color: #aaa;
width: 45%;
padding-right: 0.5rem;
}
.prop-value {
color: #e0e0e0;
word-break: break-word;
}
/* Scrollbar */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: #1a1a2e;
}
::-webkit-scrollbar-thumb {
background: #0f3460;
border-radius: 3px;
} }
</style> </style>

View file

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

View file

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