Compare commits
8 commits
eb2c662d0e
...
0f3d99db57
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f3d99db57 | ||
|
|
bf09d2a5dc | ||
|
|
2a647be6c4 | ||
|
|
b589027061 | ||
|
|
cf20eb152f | ||
|
|
f9e284c5d7 | ||
|
|
9324b35432 | ||
|
|
1cb47df1aa |
10 changed files with 45258 additions and 17 deletions
|
|
@ -51,6 +51,26 @@ async def list_elements(
|
|||
result = await db.execute(query)
|
||||
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)
|
||||
async def get_element(element_id: UUID, db: AsyncSession = Depends(get_db)):
|
||||
|
|
|
|||
|
|
@ -1,14 +1,23 @@
|
|||
"""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.responses import FileResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.models.database import get_db
|
||||
from app.schemas.element import ProjectOut
|
||||
from app.services.ifc_parser import parse_ifc_file
|
||||
from app.services.ifc_converter import convert_ifc_bytes_to_glb
|
||||
|
||||
router = APIRouter(tags=["upload"])
|
||||
|
||||
GLB_DIR = Path(settings.upload_dir) / "glb"
|
||||
|
||||
|
||||
@router.post("/upload", response_model=ProjectOut)
|
||||
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()
|
||||
|
||||
# Parse semantic data into database
|
||||
try:
|
||||
project = await parse_ifc_file(
|
||||
filename=file.filename,
|
||||
|
|
@ -27,5 +37,29 @@ async def upload_ifc(file: UploadFile, db: AsyncSession = Depends(get_db)):
|
|||
except Exception as 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
|
||||
|
||||
|
||||
@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",
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,19 +1,35 @@
|
|||
"""BIM Twin Viewer — FastAPI application entry point."""
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
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(
|
||||
title="BIM Twin Viewer API",
|
||||
description="REST API for IFC-based 3D building model inspection",
|
||||
version="0.1.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # tighten in production
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
|
|
|
|||
79
backend/app/services/ifc_converter.py
Normal file
79
backend/app/services/ifc_converter.py
Normal 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)
|
||||
|
||||
|
|
@ -30,7 +30,7 @@ async def parse_ifc_file(
|
|||
finally:
|
||||
tmp_path.unlink()
|
||||
|
||||
# Create project record
|
||||
# Create project record
|
||||
project = Project(
|
||||
name=_extract_project_name(ifc, filename),
|
||||
filename=filename,
|
||||
|
|
@ -38,6 +38,7 @@ async def parse_ifc_file(
|
|||
ifc_schema=ifc.schema,
|
||||
)
|
||||
db.add(project)
|
||||
await db.flush()
|
||||
|
||||
# Extract elements
|
||||
element_types = [
|
||||
|
|
@ -46,8 +47,13 @@ async def parse_ifc_file(
|
|||
"IfcSpace", "IfcFurnishingElement", "IfcBuildingElementProxy",
|
||||
]
|
||||
|
||||
seen_global_ids = set()
|
||||
|
||||
for ifc_type in element_types:
|
||||
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)
|
||||
|
||||
element = Element(
|
||||
|
|
@ -59,6 +65,7 @@ async def parse_ifc_file(
|
|||
storey=storey,
|
||||
)
|
||||
db.add(element)
|
||||
await db.flush()
|
||||
|
||||
# Extract property sets
|
||||
for pset_name, props in _get_properties(ifc_element).items():
|
||||
|
|
@ -75,7 +82,6 @@ async def parse_ifc_file(
|
|||
await db.refresh(project)
|
||||
return project
|
||||
|
||||
|
||||
def _extract_project_name(ifc, fallback_filename: str) -> str:
|
||||
"""Try to get the project name from the IFC file."""
|
||||
try:
|
||||
|
|
|
|||
44259
data/sample/AC20-FZK-Haus.ifc
Normal file
44259
data/sample/AC20-FZK-Haus.ifc
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -3,14 +3,218 @@
|
|||
<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">
|
||||
<!-- 3D Viewer (full area) -->
|
||||
<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>
|
||||
</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>
|
||||
* {
|
||||
margin: 0;
|
||||
|
|
@ -22,10 +226,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 +239,283 @@ 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;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.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;
|
||||
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;
|
||||
}
|
||||
|
||||
.main {
|
||||
.filters {
|
||||
padding: 0.5rem;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
border-bottom: 1px solid #0f3460;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.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;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
padding: 2rem;
|
||||
gap: 0.1rem;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.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>
|
||||
|
||||
|
|
|
|||
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 }
|
||||
}
|
||||
|
||||
328
frontend/src/composables/useViewer.js
Normal file
328
frontend/src/composables/useViewer.js
Normal 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 }
|
||||
}
|
||||
|
||||
|
|
@ -4,6 +4,7 @@ import vue from '@vitejs/plugin-vue'
|
|||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
allowedHosts: ['bim.stifting.at'],
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://backend:8000',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue