Compare commits

...

8 commits

Author SHA1 Message Date
warnason
0f3d99db57 Fix duplicate elements and robust GlobalId lookup
Some checks are pending
CI / backend-lint-and-test (push) Waiting to run
- Remove IfcWallStandardCase from parser (covered by IfcWall as parent type)
- Add seen_global_ids set to prevent duplicate element insertion
- Use scalars().first() instead of scalar_one_or_none() for resilient lookup
- Re-parse required: clears and rebuilds element data without duplicates
2026-04-22 16:43:28 +02:00
warnason
bf09d2a5dc Redesign UI layout: floating panels over full-width 3D viewer
- 3D viewer now fills the entire viewport
- Sidebar and property panel float over the viewer with transparency
- Collapsible panels via toggle buttons on panel edges
- Backdrop blur for readability while maintaining spatial awareness
2026-04-22 16:55:16 +02:00
warnason
2a647be6c4 Improve 3D navigation: animated pivot on double-click
- Double-click on a building element sets it as the new orbit center
- Smooth animated transition using cubic ease-out (300ms)
- Enable screen-space panning for consistent pan behavior
- Set min/max zoom distance to prevent clipping
2026-04-22 17:03:54 +02:00
warnason
b589027061 Add interactive Three.js 3D viewer with IFC element inspection
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
2026-04-20 16:14:28 +02:00
warnason
cf20eb152f Add server-side IFC to glb conversion for 3D viewing
Some checks are pending
CI / backend-lint-and-test (push) Waiting to run
- New ifc_converter service using IfcOpenShell geometry serializer
- Preserves IFC GlobalIds as mesh names for click-to-inspect
- glb files served via /api/projects/{id}/model.glb endpoint
- New endpoint: lookup element by GlobalId for viewer integration
- Excludes IfcSpace and IfcOpeningElement from 3D output
2026-04-20 14:30:52 +02:00
warnason
f9e284c5d7 Add database auto-creation and fix IFC parser flush logic
Some checks are pending
CI / backend-lint-and-test (push) Waiting to run
- Create tables on application startup via lifespan event
- Flush project before creating elements (generate project_id)
- Flush each element before creating properties (generate element_id)
- Add IfcWallStandardCase to parsed element types
- Include FZK-Haus sample IFC file for testing
- Tested: 58 elements, 3601 properties parsed successfully
2026-04-20 12:50:11 +02:00
warnason
9324b35432 Allow bim.stifting.at as Vite dev server host
Some checks are pending
CI / backend-lint-and-test (push) Waiting to run
2026-04-20 12:26:05 +02:00
warnason
1cb47df1aa Add database auto-creation on startup
Some checks are pending
CI / backend-lint-and-test (push) Waiting to run
2026-04-20 11:55:56 +02:00
10 changed files with 45258 additions and 17 deletions

View file

@ -51,6 +51,26 @@ async def list_elements(
result = await db.execute(query) result = await db.execute(query)
return result.scalars().all() return result.scalars().all()
@router.get("/projects/{project_id}/elements/by-global-id/{global_id}", response_model=ElementDetailOut)
async def get_element_by_global_id(
project_id: UUID,
global_id: str,
db: AsyncSession = Depends(get_db),
):
"""Look up an element by its IFC GlobalId — used by the 3D viewer on click."""
query = (
select(Element)
.options(selectinload(Element.properties))
.where(Element.project_id == project_id)
.where(Element.global_id == global_id)
)
result = await db.execute(query)
element = result.scalars().first()
if not element:
raise HTTPException(status_code=404, detail="Element not found")
return element
@router.get("/elements/{element_id}", response_model=ElementDetailOut) @router.get("/elements/{element_id}", response_model=ElementDetailOut)
async def get_element(element_id: UUID, db: AsyncSession = Depends(get_db)): async def get_element(element_id: UUID, db: AsyncSession = Depends(get_db)):

View file

@ -1,14 +1,23 @@
"""API route for IFC file upload and parsing.""" """API route for IFC file upload and parsing."""
import os
from pathlib import Path
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, UploadFile from fastapi import APIRouter, Depends, HTTPException, UploadFile
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.models.database import get_db from app.models.database import get_db
from app.schemas.element import ProjectOut from app.schemas.element import ProjectOut
from app.services.ifc_parser import parse_ifc_file from app.services.ifc_parser import parse_ifc_file
from app.services.ifc_converter import convert_ifc_bytes_to_glb
router = APIRouter(tags=["upload"]) router = APIRouter(tags=["upload"])
GLB_DIR = Path(settings.upload_dir) / "glb"
@router.post("/upload", response_model=ProjectOut) @router.post("/upload", response_model=ProjectOut)
async def upload_ifc(file: UploadFile, db: AsyncSession = Depends(get_db)): async def upload_ifc(file: UploadFile, db: AsyncSession = Depends(get_db)):
@ -18,6 +27,7 @@ async def upload_ifc(file: UploadFile, db: AsyncSession = Depends(get_db)):
contents = await file.read() contents = await file.read()
# Parse semantic data into database
try: try:
project = await parse_ifc_file( project = await parse_ifc_file(
filename=file.filename, filename=file.filename,
@ -27,5 +37,29 @@ async def upload_ifc(file: UploadFile, db: AsyncSession = Depends(get_db)):
except Exception as e: except Exception as e:
raise HTTPException(status_code=422, detail=f"Failed to parse IFC file: {e}") raise HTTPException(status_code=422, detail=f"Failed to parse IFC file: {e}")
# Convert geometry to glb
try:
GLB_DIR.mkdir(parents=True, exist_ok=True)
glb_path = str(GLB_DIR / f"{project.id}.glb")
await convert_ifc_bytes_to_glb(contents, glb_path)
except Exception as e:
# Log but don't fail — semantic data is still usable without 3D
print(f"WARNING: glb conversion failed: {e}")
return project return project
@router.get("/projects/{project_id}/model.glb")
async def get_model_glb(project_id: UUID):
"""Serve the pre-converted glb file for 3D viewing."""
glb_path = GLB_DIR / f"{project_id}.glb"
if not glb_path.exists():
raise HTTPException(status_code=404, detail="3D model not found")
return FileResponse(
path=str(glb_path),
media_type="model/gltf-binary",
filename=f"{project_id}.glb",
)

View file

@ -1,19 +1,35 @@
"""BIM Twin Viewer — FastAPI application entry point.""" """BIM Twin Viewer — FastAPI application entry point."""
from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.api import health, elements, upload from app.api import health, elements, upload
from app.models.database import engine, Base
# Import all models so Base.metadata knows about them
from app.models import element # noqa: F401
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Create database tables on startup."""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield
app = FastAPI( app = FastAPI(
title="BIM Twin Viewer API", title="BIM Twin Viewer API",
description="REST API for IFC-based 3D building model inspection", description="REST API for IFC-based 3D building model inspection",
version="0.1.0", version="0.1.0",
lifespan=lifespan,
) )
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], # tighten in production allow_origins=["*"],
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],

View file

@ -0,0 +1,79 @@
"""Service for converting IFC files to glTF/glb for 3D visualization."""
import multiprocessing
import tempfile
from pathlib import Path
import ifcopenshell
import ifcopenshell.geom
def convert_ifc_to_glb(ifc_path: str, glb_path: str) -> None:
"""
Convert an IFC file to glTF Binary (.glb) format.
Uses IfcOpenShell's geometry serializer with element GUIDs preserved
as mesh names, enabling click-to-inspect in the 3D viewer.
"""
ifc_file = ifcopenshell.open(ifc_path)
settings = ifcopenshell.geom.settings()
settings.set(
"dimensionality",
ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS,
)
settings.set("apply-default-materials", True)
serialiser_settings = ifcopenshell.geom.serializer_settings()
serialiser_settings.set("use-element-guids", True)
serialiser = ifcopenshell.geom.serializers.gltf(
glb_path, settings, serialiser_settings
)
serialiser.setFile(ifc_file)
serialiser.setUnitNameAndMagnitude("METER", 1.0)
serialiser.writeHeader()
num_threads = max(1, multiprocessing.cpu_count() - 1)
iterator = ifcopenshell.geom.iterator(
settings,
ifc_file,
num_threads,
exclude=(
"IfcSpace",
"IfcOpeningElement",
),
)
if iterator.initialize():
while True:
serialiser.write(iterator.get())
if not iterator.next():
break
serialiser.finalize()
del serialiser # Ensures temp files are cleaned up
async def convert_ifc_bytes_to_glb(content: bytes, glb_output_path: str) -> None:
"""
Convert IFC file content (bytes) to a glb file.
Writes IFC to a temporary file first, since IfcOpenShell
requires a file path.
"""
import asyncio
with tempfile.NamedTemporaryFile(suffix=".ifc", delete=False) as tmp:
tmp.write(content)
tmp_ifc = tmp.name
try:
# Run CPU-intensive conversion in a thread pool
loop = asyncio.get_event_loop()
await loop.run_in_executor(
None, convert_ifc_to_glb, tmp_ifc, glb_output_path
)
finally:
Path(tmp_ifc).unlink(missing_ok=True)

View file

@ -30,7 +30,7 @@ async def parse_ifc_file(
finally: finally:
tmp_path.unlink() tmp_path.unlink()
# Create project record # Create project record
project = Project( project = Project(
name=_extract_project_name(ifc, filename), name=_extract_project_name(ifc, filename),
filename=filename, filename=filename,
@ -38,6 +38,7 @@ async def parse_ifc_file(
ifc_schema=ifc.schema, ifc_schema=ifc.schema,
) )
db.add(project) db.add(project)
await db.flush()
# Extract elements # Extract elements
element_types = [ element_types = [
@ -46,8 +47,13 @@ async def parse_ifc_file(
"IfcSpace", "IfcFurnishingElement", "IfcBuildingElementProxy", "IfcSpace", "IfcFurnishingElement", "IfcBuildingElementProxy",
] ]
seen_global_ids = set()
for ifc_type in element_types: for ifc_type in element_types:
for ifc_element in ifc.by_type(ifc_type): for ifc_element in ifc.by_type(ifc_type):
if ifc_element.GlobalId in seen_global_ids:
continue
seen_global_ids.add(ifc_element.GlobalId)
storey = _get_storey(ifc_element) storey = _get_storey(ifc_element)
element = Element( element = Element(
@ -59,6 +65,7 @@ async def parse_ifc_file(
storey=storey, storey=storey,
) )
db.add(element) db.add(element)
await db.flush()
# Extract property sets # Extract property sets
for pset_name, props in _get_properties(ifc_element).items(): for pset_name, props in _get_properties(ifc_element).items():
@ -75,7 +82,6 @@ async def parse_ifc_file(
await db.refresh(project) await db.refresh(project)
return project return project
def _extract_project_name(ifc, fallback_filename: str) -> str: def _extract_project_name(ifc, fallback_filename: str) -> str:
"""Try to get the project name from the IFC file.""" """Try to get the project name from the IFC file."""
try: try:

44259
data/sample/AC20-FZK-Haus.ifc Normal file

File diff suppressed because one or more lines are too long

View file

@ -3,14 +3,218 @@
<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> <!-- 3D Viewer (full area) -->
</main> <div class="viewer-container" ref="viewerContainer">
<div v-if="!project" class="empty-state">
<p>Upload an IFC file to view the 3D model</p>
</div>
</div>
<!-- Sidebar (floating) -->
<aside class="sidebar" :class="{ collapsed: !showSidebar }" v-if="project">
<button class="panel-toggle-attached toggle-left-attached" @click="showSidebar = !showSidebar">
{{ showSidebar ? '◀' : '▶' }}
</button>
<div class="panel-inner sidebar-inner">
<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">{{ shortType(el.ifc_type) }}</span>
<span class="el-name">{{ el.name || el.global_id }}</span>
<span class="el-storey" v-if="el.storey">{{ el.storey }}</span>
</li>
</ul>
</div>
</aside>
<!-- Properties (floating) -->
<aside class="properties" :class="{ collapsed: !showProperties }" v-if="selectedDetail">
<button class="panel-toggle-attached toggle-right-attached" @click="showProperties = !showProperties">
{{ showProperties ? '▶' : '◀' }}
</button>
<div class="panel-inner properties-inner">
<div class="properties-header">
<h3>{{ selectedDetail.name || selectedDetail.global_id }}</h3>
<p class="meta">{{ shortType(selectedDetail.ifc_type) }} · {{ selectedDetail.storey }}</p>
</div>
<div class="properties-body">
<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>
</div>
</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 showSidebar = ref(true)
const showProperties = ref(true)
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
})
function shortType(ifcType) {
return ifcType
.replace('IfcWallStandardCase', 'Wall')
.replace('Ifc', '')
}
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)
}
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
showProperties.value = true
} 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)
showProperties.value = true
} catch {
selectedDetail.value = null
}
}
onMounted(async () => {
await nextTick()
viewer.init(viewerContainer.value, onViewerClick)
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
}
})
onUnmounted(() => {
viewer.dispose()
})
</script>
<style> <style>
* { * {
margin: 0; margin: 0;
@ -22,10 +226,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 +239,283 @@ 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;
z-index: 100;
} }
.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;
position: relative;
overflow: hidden;
}
.viewer-container {
position: absolute;
inset: 0;
}
.empty-state {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #555;
}
/* Toggle buttons — attached to panels but visually outside */
.panel-toggle-attached {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 25;
background: #16213ecc;
border: 1px solid #0f3460;
color: #e0e0e0;
padding: 0.8rem 0.3rem;
cursor: pointer;
font-size: 0.7rem;
transition: background 0.2s;
}
.panel-toggle-attached:hover {
background: #0f3460;
}
.toggle-left-attached {
right: -20px;
border-radius: 0 4px 4px 0;
}
.toggle-right-attached {
left: -20px;
border-radius: 4px 0 0 4px;
}
/* Panels use overflow:visible so toggle buttons are not clipped.
Scrolling is handled by the inner container. */
.sidebar {
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: 280px;
z-index: 10;
transition: transform 0.25s ease;
overflow: visible;
}
.sidebar.collapsed {
transform: translateX(-100%);
}
.properties {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 320px;
z-index: 10;
transition: transform 0.25s ease;
overflow: visible;
}
.properties.collapsed {
transform: translateX(100%);
}
/* Inner containers handle background, border, and scrolling */
.panel-inner {
position: absolute;
inset: 0;
background: #16213eee;
backdrop-filter: blur(8px);
display: flex;
flex-direction: column;
overflow: hidden;
}
.sidebar-inner {
border-right: 1px solid #0f3460;
}
.properties-inner {
border-left: 1px solid #0f3460;
}
.project-info {
padding: 1rem;
border-bottom: 1px solid #0f3460;
flex-shrink: 0;
}
.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;
flex-shrink: 0;
}
.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;
align-items: center; gap: 0.1rem;
justify-content: center; transition: background 0.15s;
gap: 1rem; }
padding: 2rem;
.element-list li:hover {
background: #1a1a2eaa;
}
.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;
}
.properties-header {
padding: 1rem;
border-bottom: 1px solid #0f3460;
flex-shrink: 0;
}
.properties-header h3 {
font-size: 0.95rem;
margin-bottom: 0.25rem;
}
.properties-body {
flex: 1;
overflow-y: auto;
padding: 0.5rem 1rem;
}
.pset {
margin-top: 0.75rem;
}
.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;
}
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-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,328 @@
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 selectedMeshes = []
let originalMaterials = new Map()
let onElementClick = null
// Map: GlobalId -> array of meshes belonging to that element
let globalIdMeshMap = new Map()
const highlightMaterial = new THREE.MeshStandardMaterial({
color: 0x00aaff,
emissive: 0x003366,
transparent: true,
opacity: 0.9,
})
function init(container, onClick) {
onElementClick = onClick
scene = new THREE.Scene()
scene.background = new THREE.Color(0x1a1a2e)
camera = new THREE.PerspectiveCamera(
60,
container.clientWidth / container.clientHeight,
0.1,
1000
)
camera.position.set(15, 15, 15)
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 = new OrbitControls(camera, renderer.domElement)
controls.enableDamping = true
controls.dampingFactor = 0.05
controls.screenSpacePanning = true
controls.minDistance = 1
controls.maxDistance = 200
controls.target.set(0, 3, 0)
controls.update()
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)
const grid = new THREE.GridHelper(50, 50, 0x333355, 0x222244)
scene.add(grid)
renderer.domElement.addEventListener('click', onCanvasClick)
renderer.domElement.addEventListener('dblclick', onCanvasDoubleClick)
window.addEventListener('resize', () => onResize(container))
animate()
}
function loadModel(url) {
return new Promise((resolve, reject) => {
if (model) {
scene.remove(model)
model = null
originalMaterials.clear()
globalIdMeshMap.clear()
}
const loader = new GLTFLoader()
loader.load(
url,
(gltf) => {
model = gltf.scene
// Build GlobalId -> meshes map
buildGlobalIdMap(model)
scene.add(model)
fitCameraToModel()
resolve(model)
},
undefined,
(error) => {
reject(error)
}
)
})
}
/**
* Walk the scene graph and build a map from GlobalId to all meshes
* belonging to that element. IFC GlobalIds are 22 characters long.
*
* The glb tree typically looks like:
* Node "2XPyKWY018sA1ygZKgQPtU" <- GlobalId on a group node
* Mesh (frame)
* Mesh (panel)
* Mesh (handle)
*
* We also handle cases where the GlobalId is directly on a mesh.
*/
function buildGlobalIdMap(root) {
globalIdMeshMap.clear()
root.traverse((node) => {
const globalId = findGlobalId(node)
if (!globalId) return
// Collect all meshes under this node
const meshes = []
if (node.isMesh) {
meshes.push(node)
originalMaterials.set(node, node.material.clone())
node.castShadow = true
node.receiveShadow = true
} else {
node.traverse((child) => {
if (child.isMesh) {
meshes.push(child)
if (!originalMaterials.has(child)) {
originalMaterials.set(child, child.material.clone())
child.castShadow = true
child.receiveShadow = true
}
}
})
}
if (meshes.length > 0) {
// A GlobalId might appear at multiple levels; merge meshes
if (globalIdMeshMap.has(globalId)) {
const existing = globalIdMeshMap.get(globalId)
for (const m of meshes) {
if (!existing.includes(m)) existing.push(m)
}
} else {
globalIdMeshMap.set(globalId, meshes)
}
}
})
}
/**
* Check if a node's name looks like an IFC GlobalId.
* IFC GlobalIds are exactly 22 characters of base64-like encoding.
*/
function isGlobalId(name) {
if (!name || name.length !== 22) return false
return /^[0-9A-Za-z_$]+$/.test(name)
}
/**
* Find the GlobalId for a node either its own name or a parent's name.
*/
function findGlobalId(node) {
if (isGlobalId(node.name)) return node.name
return null
}
/**
* Given a mesh, find the GlobalId by walking up the parent chain.
*/
function findGlobalIdForMesh(mesh) {
let current = mesh
while (current) {
if (isGlobalId(current.name)) return current.name
current = current.parent
}
return null
}
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
const globalId = findGlobalIdForMesh(mesh)
if (globalId) {
highlightByGlobalId(globalId)
if (onElementClick) onElementClick(globalId)
}
} else {
clearHighlight()
if (onElementClick) onElementClick(null)
}
}
function onCanvasDoubleClick(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 targetPoint = intersects[0].point.clone()
animatePivot(targetPoint)
}
}
function animatePivot(newTarget) {
const startTarget = controls.target.clone()
const startCamera = camera.position.clone()
const offset = startCamera.clone().sub(startTarget)
const duration = 300
const startTime = performance.now()
function step(now) {
const elapsed = now - startTime
const t = Math.min(elapsed / duration, 1)
const ease = 1 - Math.pow(1 - t, 3)
controls.target.lerpVectors(startTarget, newTarget, ease)
camera.position.copy(controls.target).add(offset)
controls.update()
if (t < 1) {
requestAnimationFrame(step)
}
}
requestAnimationFrame(step)
}
/**
* Highlight all meshes belonging to an IFC element by its GlobalId.
* This is the single source of truth for highlighting used by both
* 3D click and sidebar click.
*/
function highlightByGlobalId(globalId) {
clearHighlight()
const meshes = globalIdMeshMap.get(globalId)
if (!meshes || meshes.length === 0) return
for (const mesh of meshes) {
mesh.material = highlightMaterial
selectedMeshes.push(mesh)
}
}
function clearHighlight() {
for (const mesh of selectedMeshes) {
if (originalMaterials.has(mesh)) {
mesh.material = originalMaterials.get(mesh)
}
}
selectedMeshes = []
}
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.domElement.removeEventListener('dblclick', onCanvasDoubleClick)
renderer.dispose()
}
return { init, loadModel, highlightByGlobalId, clearHighlight, dispose }
}

View file

@ -4,6 +4,7 @@ import vue from '@vitejs/plugin-vue'
export default defineConfig({ export default defineConfig({
plugins: [vue()], plugins: [vue()],
server: { server: {
allowedHosts: ['bim.stifting.at'],
proxy: { proxy: {
'/api': { '/api': {
target: 'http://backend:8000', target: 'http://backend:8000',