Interactive IFC-based 3D building model viewer.
+Upload an IFC file to get started.
+From 5cb6f1403bc9eb40b48715c82a4b8b0dd37639aa Mon Sep 17 00:00:00 2001 From: warnason <276599704+warnason@users.noreply.github.com> Date: Mon, 20 Apr 2026 18:01:12 +0200 Subject: [PATCH] Initial project scaffold: FastAPI backend + Vue.js frontend - 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 --- .env.example | 3 + .forgejo/workflows/ci.yml | 35 ++++++++ .gitignore | 38 +++++++++ README.md | 78 ++++++++++++++++++ backend/Dockerfile | 18 ++++ backend/app/__init__.py | 15 ++++ backend/app/api/__init__.py | 0 backend/app/api/elements.py | 70 ++++++++++++++++ backend/app/api/health.py | 11 +++ backend/app/api/upload.py | 31 +++++++ backend/app/main.py | 25 ++++++ backend/app/models/__init__.py | 0 backend/app/models/database.py | 21 +++++ backend/app/models/element.py | 55 +++++++++++++ backend/app/schemas/__init__.py | 0 backend/app/schemas/element.py | 41 ++++++++++ backend/app/services/__init__.py | 0 backend/app/services/ifc_parser.py | 127 +++++++++++++++++++++++++++++ backend/pytest.ini | 0 backend/requirements.txt | 14 ++++ backend/tests/__init__.py | 0 backend/tests/test_health.py | 17 ++++ docker-compose.yml | 66 +++++++++++++++ frontend/Dockerfile | 13 +++ frontend/index.html | 13 +++ frontend/package.json | 22 +++++ frontend/src/App.vue | 62 ++++++++++++++ frontend/src/main.js | 5 ++ frontend/vite.config.js | 15 ++++ 29 files changed, 795 insertions(+) create mode 100644 .env.example create mode 100644 .forgejo/workflows/ci.yml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 backend/Dockerfile create mode 100644 backend/app/__init__.py create mode 100644 backend/app/api/__init__.py create mode 100644 backend/app/api/elements.py create mode 100644 backend/app/api/health.py create mode 100644 backend/app/api/upload.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/database.py create mode 100644 backend/app/models/element.py create mode 100644 backend/app/schemas/__init__.py create mode 100644 backend/app/schemas/element.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/ifc_parser.py create mode 100644 backend/pytest.ini create mode 100644 backend/requirements.txt create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/test_health.py create mode 100644 docker-compose.yml create mode 100644 frontend/Dockerfile create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/main.js create mode 100644 frontend/vite.config.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b3de48e --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +# Copy to .env and set a real password +DB_PASSWORD=change-me-to-a-strong-random-password + diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml new file mode 100644 index 0000000..4133d47 --- /dev/null +++ b/.forgejo/workflows/ci.yml @@ -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 + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..61922af --- /dev/null +++ b/.gitignore @@ -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 + diff --git a/README.md b/README.md new file mode 100644 index 0000000..403f041 --- /dev/null +++ b/README.md @@ -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 + diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..a1fbae0 --- /dev/null +++ b/backend/Dockerfile @@ -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"] + diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..36748ff --- /dev/null +++ b/backend/app/__init__.py @@ -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() + diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/elements.py b/backend/app/api/elements.py new file mode 100644 index 0000000..388309c --- /dev/null +++ b/backend/app/api/elements.py @@ -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 diff --git a/backend/app/api/health.py b/backend/app/api/health.py new file mode 100644 index 0000000..a5a425d --- /dev/null +++ b/backend/app/api/health.py @@ -0,0 +1,11 @@ +"""Health check endpoint.""" + +from fastapi import APIRouter + +router = APIRouter() + + +@router.get("/health") +async def health_check(): + return {"status": "ok"} + diff --git a/backend/app/api/upload.py b/backend/app/api/upload.py new file mode 100644 index 0000000..cb27e99 --- /dev/null +++ b/backend/app/api/upload.py @@ -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 + diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..84c6263 --- /dev/null +++ b/backend/app/main.py @@ -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") + diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/models/database.py b/backend/app/models/database.py new file mode 100644 index 0000000..5bf8d5d --- /dev/null +++ b/backend/app/models/database.py @@ -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 + diff --git a/backend/app/models/element.py b/backend/app/models/element.py new file mode 100644 index 0000000..14e62df --- /dev/null +++ b/backend/app/models/element.py @@ -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") + diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/schemas/element.py b/backend/app/schemas/element.py new file mode 100644 index 0000000..44f38e8 --- /dev/null +++ b/backend/app/schemas/element.py @@ -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} + diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/ifc_parser.py b/backend/app/services/ifc_parser.py new file mode 100644 index 0000000..dd89d9a --- /dev/null +++ b/backend/app/services/ifc_parser.py @@ -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 + diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 0000000..e69de29 diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..3247bd9 --- /dev/null +++ b/backend/requirements.txt @@ -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 + diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py new file mode 100644 index 0000000..fb28fc7 --- /dev/null +++ b/backend/tests/test_health.py @@ -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"} + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e872730 --- /dev/null +++ b/docker-compose.yml @@ -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 + diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..2867223 --- /dev/null +++ b/frontend/Dockerfile @@ -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"] + diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..f4a2309 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + +
+ + +Interactive IFC-based 3D building model viewer.
+Upload an IFC file to get started.
+