Initial project scaffold: FastAPI backend + Vue.js frontend
Some checks are pending
CI / backend-lint-and-test (push) Waiting to run

- FastAPI with async SQLAlchemy models for IFC elements
- IFC file upload and parsing via IfcOpenShell
- REST API for projects, elements, and properties
- Vue.js 3 frontend shell with Three.js dependency
- Docker Compose for full-stack local development
- PostgreSQL 16 as database
- CI pipeline for Forgejo Actions
- Project documentation and API overview
This commit is contained in:
warnason 2026-04-20 11:21:30 +02:00
commit 520d55259f
29 changed files with 795 additions and 0 deletions

3
.env.example Normal file
View file

@ -0,0 +1,3 @@
# Copy to .env and set a real password
DB_PASSWORD=change-me-to-a-strong-random-password

35
.forgejo/workflows/ci.yml Normal file
View file

@ -0,0 +1,35 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
backend-lint-and-test:
runs-on: docker
container:
image: python:3.12-slim
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: bim
POSTGRES_PASSWORD: testpassword
POSTGRES_DB: bim_test
env:
DATABASE_URL: postgresql+asyncpg://bim:testpassword@postgres:5432/bim_test
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: |
cd backend
pip install -r requirements.txt
- name: Run tests
run: |
cd backend
python -m pytest -v

38
.gitignore vendored Normal file
View file

@ -0,0 +1,38 @@
# Python
__pycache__/
*.py[cod]
*.egg-info/
.venv/
venv/
.pytest_cache/
htmlcov/
.coverage
# Node / Vue
node_modules/
frontend/dist/
# Environment
.env
*.env.local
# Editor
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Docker
volumes/
# Data (keep sample, ignore large uploads)
data/uploads/
# IFC files (can be large)
*.ifc
!data/sample/*.ifc

78
README.md Normal file
View file

@ -0,0 +1,78 @@
# BIM Twin Viewer
Interactive IFC-based 3D building model viewer. Upload an IFC file to
inspect building elements, their properties, and navigate the model in
a browser-based 3D view.
## Tech stack
- **Backend:** Python 3.12, FastAPI, SQLAlchemy (async), IfcOpenShell
- **Frontend:** Vue.js 3, Three.js
- **Database:** PostgreSQL 16
- **Infrastructure:** Docker Compose, Caddy, Forgejo CI
## Quick start
```bash
cp .env.example .env
# Edit .env and set DB_PASSWORD
docker compose up -d
```
- **API:** http://localhost:8000
- **API docs:** http://localhost:8000/docs
- **Frontend:** http://localhost:5173
## Project structure
```
.
├── backend/
│ ├── app/
│ │ ├── api/ # FastAPI route handlers
│ │ ├── models/ # SQLAlchemy ORM models
│ │ ├── schemas/ # Pydantic request/response schemas
│ │ └── services/ # Business logic (IFC parsing)
│ ├── tests/
│ ├── Dockerfile
│ └── requirements.txt
├── frontend/
│ ├── src/
│ │ ├── components/ # Vue components (viewer, panels)
│ │ ├── views/ # Page-level views
│ │ └── composables/ # Reusable composition functions
│ ├── Dockerfile
│ └── package.json
├── data/
│ └── sample/ # Example IFC files for testing
├── docs/
├── docker-compose.yml
└── README.md
```
## API overview
| Method | Endpoint | Description |
|--------|---------------------------------------|---------------------------|
| GET | `/health` | Health check |
| POST | `/api/upload` | Upload and parse IFC file |
| GET | `/api/projects` | List all projects |
| GET | `/api/projects/{id}/elements` | List elements (filterable)|
| GET | `/api/elements/{id}` | Element detail + properties|
## Running tests
```bash
# Backend
docker compose exec backend pytest -v
# Or locally
cd backend
python -m pytest -v
```
## License
MIT

18
backend/Dockerfile Normal file
View file

@ -0,0 +1,18 @@
FROM python:3.12-slim
WORKDIR /app
# System dependencies for IfcOpenShell
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

15
backend/app/__init__.py Normal file
View file

@ -0,0 +1,15 @@
"""Application configuration via environment variables."""
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str = "postgresql+asyncpg://bim:bim@localhost:5432/bim"
upload_dir: str = "/data/uploads"
max_upload_size_mb: int = 100
model_config = {"env_file": ".env"}
settings = Settings()

View file

View file

@ -0,0 +1,70 @@
"""API routes for querying building elements."""
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.database import get_db
from app.models.element import Element, Project
from app.schemas.element import ElementDetailOut, ElementOut, ProjectOut
router = APIRouter(tags=["elements"])
@router.get("/projects", response_model=list[ProjectOut])
async def list_projects(db: AsyncSession = Depends(get_db)):
"""List all uploaded IFC projects."""
query = select(Project)
result = await db.execute(query)
projects = result.scalars().all()
response = []
for project in projects:
count_query = select(func.count()).where(Element.project_id == project.id)
count_result = await db.execute(count_query)
count = count_result.scalar()
out = ProjectOut.model_validate(project)
out.element_count = count
response.append(out)
return response
@router.get("/projects/{project_id}/elements", response_model=list[ElementOut])
async def list_elements(
project_id: UUID,
ifc_type: str | None = None,
storey: str | None = None,
db: AsyncSession = Depends(get_db),
):
"""List elements for a project, optionally filtered by type or storey."""
query = select(Element).where(Element.project_id == project_id)
if ifc_type:
query = query.where(Element.ifc_type == ifc_type)
if storey:
query = query.where(Element.storey == storey)
result = await db.execute(query)
return result.scalars().all()
@router.get("/elements/{element_id}", response_model=ElementDetailOut)
async def get_element(element_id: UUID, db: AsyncSession = Depends(get_db)):
"""Get a single element with all its properties."""
query = (
select(Element)
.options(selectinload(Element.properties))
.where(Element.id == element_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
i

11
backend/app/api/health.py Normal file
View file

@ -0,0 +1,11 @@
"""Health check endpoint."""
from fastapi import APIRouter
router = APIRouter()
@router.get("/health")
async def health_check():
return {"status": "ok"}

31
backend/app/api/upload.py Normal file
View file

@ -0,0 +1,31 @@
"""API route for IFC file upload and parsing."""
from fastapi import APIRouter, Depends, HTTPException, UploadFile
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.database import get_db
from app.schemas.element import ProjectOut
from app.services.ifc_parser import parse_ifc_file
router = APIRouter(tags=["upload"])
@router.post("/upload", response_model=ProjectOut)
async def upload_ifc(file: UploadFile, db: AsyncSession = Depends(get_db)):
"""Upload an IFC file, parse it, and store elements in the database."""
if not file.filename.lower().endswith(".ifc"):
raise HTTPException(status_code=400, detail="Only .ifc files are accepted")
contents = await file.read()
try:
project = await parse_ifc_file(
filename=file.filename,
content=contents,
db=db,
)
except Exception as e:
raise HTTPException(status_code=422, detail=f"Failed to parse IFC file: {e}")
return project

25
backend/app/main.py Normal file
View file

@ -0,0 +1,25 @@
"""BIM Twin Viewer — FastAPI application entry point."""
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api import health, elements, upload
app = FastAPI(
title="BIM Twin Viewer API",
description="REST API for IFC-based 3D building model inspection",
version="0.1.0",
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # tighten in production
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(health.router)
app.include_router(upload.router, prefix="/api")
app.include_router(elements.router, prefix="/api")

View file

View file

@ -0,0 +1,21 @@
"""Database engine and session factory."""
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker
from app.config import settings
engine = create_async_engine(settings.database_url, echo=False)
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
class Base(DeclarativeBase):
pass
async def get_db():
"""Dependency: yield a database session."""
async with async_session() as session:
yield session

View file

@ -0,0 +1,55 @@
"""SQLAlchemy models for IFC building elements."""
import uuid
from sqlalchemy import Column, Float, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from app.models.database import Base
class Project(Base):
"""An uploaded IFC project / building model."""
__tablename__ = "projects"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name = Column(String(255), nullable=False)
filename = Column(String(255), nullable=False)
description = Column(Text, default="")
ifc_schema = Column(String(50), default="")
elements = relationship("Element", back_populates="project", cascade="all, delete-orphan")
class Element(Base):
"""A single IFC building element (wall, door, slab, etc.)."""
__tablename__ = "elements"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
project_id = Column(UUID(as_uuid=True), ForeignKey("projects.id"), nullable=False)
global_id = Column(String(64), nullable=False, index=True)
ifc_type = Column(String(100), nullable=False, index=True)
name = Column(String(255), default="")
description = Column(Text, default="")
storey = Column(String(255), default="")
project = relationship("Project", back_populates="elements")
properties = relationship("Property", back_populates="element", cascade="all, delete-orphan")
class Property(Base):
"""A property (key-value pair) attached to an IFC element."""
__tablename__ = "properties"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
element_id = Column(UUID(as_uuid=True), ForeignKey("elements.id"), nullable=False)
pset_name = Column(String(255), default="")
name = Column(String(255), nullable=False)
value = Column(Text, default="")
element = relationship("Element", back_populates="properties")

View file

View file

@ -0,0 +1,41 @@
"""Pydantic response schemas for API serialization."""
from uuid import UUID
from pydantic import BaseModel
class PropertyOut(BaseModel):
id: UUID
pset_name: str
name: str
value: str
model_config = {"from_attributes": True}
class ElementOut(BaseModel):
id: UUID
global_id: str
ifc_type: str
name: str
description: str
storey: str
model_config = {"from_attributes": True}
class ElementDetailOut(ElementOut):
properties: list[PropertyOut] = []
class ProjectOut(BaseModel):
id: UUID
name: str
filename: str
description: str
ifc_schema: str
element_count: int = 0
model_config = {"from_attributes": True}

View file

View file

@ -0,0 +1,127 @@
"""Service for parsing IFC files and storing elements in the database."""
import tempfile
from pathlib import Path
import ifcopenshell
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.element import Element, Project, Property
async def parse_ifc_file(
filename: str,
content: bytes,
db: AsyncSession,
) -> Project:
"""
Parse an IFC file and persist its structure to the database.
Extracts building elements (walls, doors, slabs, windows, etc.)
along with their property sets into the relational model.
"""
# Write to temp file (ifcopenshell needs a file path)
with tempfile.NamedTemporaryFile(suffix=".ifc", delete=False) as tmp:
tmp.write(content)
tmp_path = Path(tmp.name)
try:
ifc = ifcopenshell.open(str(tmp_path))
finally:
tmp_path.unlink()
# Create project record
project = Project(
name=_extract_project_name(ifc, filename),
filename=filename,
description=_extract_project_description(ifc),
ifc_schema=ifc.schema,
)
db.add(project)
# Extract elements
element_types = [
"IfcWall", "IfcDoor", "IfcWindow", "IfcSlab",
"IfcColumn", "IfcBeam", "IfcStair", "IfcRoof",
"IfcSpace", "IfcFurnishingElement", "IfcBuildingElementProxy",
]
for ifc_type in element_types:
for ifc_element in ifc.by_type(ifc_type):
storey = _get_storey(ifc_element)
element = Element(
project_id=project.id,
global_id=ifc_element.GlobalId,
ifc_type=ifc_element.is_a(),
name=ifc_element.Name or "",
description=ifc_element.Description or "",
storey=storey,
)
db.add(element)
# Extract property sets
for pset_name, props in _get_properties(ifc_element).items():
for prop_name, prop_value in props.items():
prop = Property(
element_id=element.id,
pset_name=pset_name,
name=prop_name,
value=str(prop_value) if prop_value is not None else "",
)
db.add(prop)
await db.commit()
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:
ifc_project = ifc.by_type("IfcProject")[0]
if ifc_project.Name:
return ifc_project.Name
except (IndexError, AttributeError):
pass
return Path(fallback_filename).stem
def _extract_project_description(ifc) -> str:
"""Try to get the project description from the IFC file."""
try:
ifc_project = ifc.by_type("IfcProject")[0]
return ifc_project.Description or ""
except (IndexError, AttributeError):
return ""
def _get_storey(element) -> str:
"""Determine which building storey an element belongs to."""
try:
for rel in element.ContainedInStructure:
if rel.RelatingStructure.is_a("IfcBuildingStorey"):
return rel.RelatingStructure.Name or ""
except AttributeError:
pass
return ""
def _get_properties(element) -> dict[str, dict[str, any]]:
"""Extract all property sets from an IFC element."""
result = {}
try:
for definition in element.IsDefinedBy:
if definition.is_a("IfcRelDefinesByProperties"):
pset = definition.RelatingPropertyDefinition
if pset.is_a("IfcPropertySet"):
props = {}
for prop in pset.HasProperties:
if prop.is_a("IfcPropertySingleValue"):
value = prop.NominalValue.wrappedValue if prop.NominalValue else None
props[prop.Name] = value
result[pset.Name] = props
except AttributeError:
pass
return result

0
backend/pytest.ini Normal file
View file

14
backend/requirements.txt Normal file
View file

@ -0,0 +1,14 @@
fastapi==0.115.12
uvicorn[standard]==0.34.2
sqlalchemy[asyncio]==2.0.40
asyncpg==0.30.0
alembic==1.15.2
python-multipart==0.0.20
pydantic==2.11.1
pydantic-settings==2.9.1
ifcopenshell==0.8.1
jinja2==3.1.6
httpx==0.28.1
pytest==8.3.5
pytest-asyncio==0.26.0

View file

View file

@ -0,0 +1,17 @@
"""Test the health check endpoint."""
import pytest
from httpx import ASGITransport, AsyncClient
from app.main import app
@pytest.mark.asyncio
async def test_health_returns_ok():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}

66
docker-compose.yml Normal file
View file

@ -0,0 +1,66 @@
services:
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: bim-backend
restart: unless-stopped
ports:
- "8000:8000"
environment:
- DATABASE_URL=postgresql+asyncpg://bim:${DB_PASSWORD}@db:5432/bim
- UPLOAD_DIR=/data/uploads
volumes:
- ./backend:/app
- upload_data:/data/uploads
depends_on:
db:
condition: service_healthy
networks:
- bim-internal
- web
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: bim-frontend
restart: unless-stopped
ports:
- "5173:5173"
volumes:
- ./frontend:/app
- /app/node_modules
networks:
- web
command: npm run dev -- --host 0.0.0.0
db:
image: postgres:16-alpine
container_name: bim-db
restart: unless-stopped
environment:
- POSTGRES_USER=bim
- POSTGRES_PASSWORD=${DB_PASSWORD}
- POSTGRES_DB=bim
volumes:
- bim_db:/var/lib/postgresql/data
networks:
- bim-internal
healthcheck:
test: ["CMD-SHELL", "pg_isready -U bim"]
interval: 5s
timeout: 5s
retries: 5
volumes:
bim_db:
upload_data:
networks:
web:
external: true
bim-internal:
driver: bridge

13
frontend/Dockerfile Normal file
View file

@ -0,0 +1,13 @@
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]

13
frontend/index.html Normal file
View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>BIM Twin Viewer</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

22
frontend/package.json Normal file
View file

@ -0,0 +1,22 @@
{
"name": "bim-twin-viewer",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint . --ext .vue,.js,.ts"
},
"dependencies": {
"vue": "^3.5.13",
"three": "^0.172.0",
"axios": "^1.9.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.3",
"vite": "^6.3.2"
}
}

62
frontend/src/App.vue Normal file
View file

@ -0,0 +1,62 @@
<template>
<div class="app">
<header class="header">
<h1>BIM Twin Viewer</h1>
<span class="version">v0.1.0</span>
</header>
<main class="main">
<p>Interactive IFC-based 3D building model viewer.</p>
<p>Upload an IFC file to get started.</p>
</main>
</div>
</template>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: system-ui, -apple-system, sans-serif;
background: #1a1a2e;
color: #e0e0e0;
}
.app {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.header {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem 2rem;
background: #16213e;
border-bottom: 1px solid #0f3460;
}
.header h1 {
font-size: 1.4rem;
font-weight: 600;
}
.version {
font-size: 0.8rem;
color: #888;
}
.main {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
padding: 2rem;
}
</style>

5
frontend/src/main.js Normal file
View file

@ -0,0 +1,5 @@
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')

15
frontend/vite.config.js Normal file
View file

@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
proxy: {
'/api': {
target: 'http://backend:8000',
changeOrigin: true,
},
},
},
})