Some checks failed
CI / backend-lint-and-test (push) Has been cancelled
- 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
432 lines
8.8 KiB
Vue
432 lines
8.8 KiB
Vue
<template>
|
|
<div class="app">
|
|
<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>
|
|
|
|
<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;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
body {
|
|
font-family: system-ui, -apple-system, sans-serif;
|
|
background: #1a1a2e;
|
|
color: #e0e0e0;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.app {
|
|
height: 100vh;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 1rem;
|
|
padding: 0.6rem 1.5rem;
|
|
background: #16213e;
|
|
border-bottom: 1px solid #0f3460;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.header h1 {
|
|
font-size: 1.2rem;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.version {
|
|
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;
|
|
}
|
|
|
|
.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;
|
|
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>
|
|
|