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
This commit is contained in:
parent
4951671f63
commit
bb8b06a259
3 changed files with 133 additions and 0 deletions
|
|
@ -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)):
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
|
||||||
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)
|
||||||
|
|
||||||
Loading…
Add table
Reference in a new issue