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
This commit is contained in:
warnason 2026-04-20 14:30:52 +02:00
parent f9e284c5d7
commit cf20eb152f
3 changed files with 133 additions and 0 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.scalar_one_or_none()
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

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