From bb8b06a25923c7768f5767b93d6b299887c37992 Mon Sep 17 00:00:00 2001 From: warnason <276599704+warnason@users.noreply.github.com> Date: Mon, 20 Apr 2026 19:11:55 +0200 Subject: [PATCH] Add server-side IFC to glb conversion for 3D viewing - 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 --- backend/app/api/elements.py | 20 +++++++ backend/app/api/upload.py | 34 ++++++++++++ backend/app/services/ifc_converter.py | 79 +++++++++++++++++++++++++++ 3 files changed, 133 insertions(+) create mode 100644 backend/app/services/ifc_converter.py diff --git a/backend/app/api/elements.py b/backend/app/api/elements.py index 357fa61..b88355b 100644 --- a/backend/app/api/elements.py +++ b/backend/app/api/elements.py @@ -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.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) async def get_element(element_id: UUID, db: AsyncSession = Depends(get_db)): diff --git a/backend/app/api/upload.py b/backend/app/api/upload.py index cb27e99..cd2650d 100644 --- a/backend/app/api/upload.py +++ b/backend/app/api/upload.py @@ -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", + ) + diff --git a/backend/app/services/ifc_converter.py b/backend/app/services/ifc_converter.py new file mode 100644 index 0000000..64a9747 --- /dev/null +++ b/backend/app/services/ifc_converter.py @@ -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) +