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
50b55cee42
commit
fb674ab580
3 changed files with 133 additions and 0 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.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)):
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
||||
|
|
|
|||
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