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
This commit is contained in:
commit
5cb6f1403b
29 changed files with 795 additions and 0 deletions
3
.env.example
Normal file
3
.env.example
Normal 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
35
.forgejo/workflows/ci.yml
Normal 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
38
.gitignore
vendored
Normal 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
78
README.md
Normal 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
18
backend/Dockerfile
Normal 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
15
backend/app/__init__.py
Normal 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()
|
||||||
|
|
||||||
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
70
backend/app/api/elements.py
Normal file
70
backend/app/api/elements.py
Normal 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
11
backend/app/api/health.py
Normal 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
31
backend/app/api/upload.py
Normal 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
25
backend/app/main.py
Normal 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")
|
||||||
|
|
||||||
0
backend/app/models/__init__.py
Normal file
0
backend/app/models/__init__.py
Normal file
21
backend/app/models/database.py
Normal file
21
backend/app/models/database.py
Normal 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
|
||||||
|
|
||||||
55
backend/app/models/element.py
Normal file
55
backend/app/models/element.py
Normal 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")
|
||||||
|
|
||||||
0
backend/app/schemas/__init__.py
Normal file
0
backend/app/schemas/__init__.py
Normal file
41
backend/app/schemas/element.py
Normal file
41
backend/app/schemas/element.py
Normal 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}
|
||||||
|
|
||||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
127
backend/app/services/ifc_parser.py
Normal file
127
backend/app/services/ifc_parser.py
Normal 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
0
backend/pytest.ini
Normal file
14
backend/requirements.txt
Normal file
14
backend/requirements.txt
Normal 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
|
||||||
|
|
||||||
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
17
backend/tests/test_health.py
Normal file
17
backend/tests/test_health.py
Normal 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
66
docker-compose.yml
Normal 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
13
frontend/Dockerfile
Normal 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
13
frontend/index.html
Normal 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
22
frontend/package.json
Normal 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
62
frontend/src/App.vue
Normal 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
5
frontend/src/main.js
Normal 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
15
frontend/vite.config.js
Normal 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
Loading…
Add table
Reference in a new issue