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:
parent
fb674ab580
commit
182e6e0123
3 changed files with 638 additions and 11 deletions
|
|
@ -3,14 +3,199 @@
|
|||
<header class="header">
|
||||
<h1>BIM Twin Viewer</h1>
|
||||
<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>
|
||||
<main class="main">
|
||||
<p>Interactive IFC-based 3D building model viewer.</p>
|
||||
<p>Upload an IFC file to get started.</p>
|
||||
</main>
|
||||
|
||||
<div class="content">
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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>
|
||||
</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>
|
||||
* {
|
||||
margin: 0;
|
||||
|
|
@ -22,10 +207,11 @@ body {
|
|||
font-family: system-ui, -apple-system, sans-serif;
|
||||
background: #1a1a2e;
|
||||
color: #e0e0e0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app {
|
||||
min-height: 100vh;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
|
@ -34,29 +220,213 @@ body {
|
|||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem 2rem;
|
||||
padding: 0.6rem 1.5rem;
|
||||
background: #16213e;
|
||||
border-bottom: 1px solid #0f3460;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 1.4rem;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.main {
|
||||
.filters {
|
||||
padding: 0.5rem;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
border-bottom: 1px solid #0f3460;
|
||||
}
|
||||
|
||||
.filters select {
|
||||
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;
|
||||
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;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
padding: 2rem;
|
||||
height: 100%;
|
||||
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>
|
||||
|
||||
|
|
|
|||
39
frontend/src/composables/useApi.js
Normal file
39
frontend/src/composables/useApi.js
Normal 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 }
|
||||
}
|
||||
|
||||
218
frontend/src/composables/useViewer.js
Normal file
218
frontend/src/composables/useViewer.js
Normal 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 }
|
||||
}
|
||||
|
||||
Loading…
Add table
Reference in a new issue