Compare commits

..

2 commits

Author SHA1 Message Date
warnason
354d6e8c14 Add test suite and polish project documentation
Some checks failed
CI / backend-lint-and-test (push) Has been cancelled
- 8 pytest tests covering health, upload validation, API responses, schemas
- Updated README with architecture diagram, feature list, design decisions
- Live demo link and complete API reference
2026-04-27 11:10:41 +02:00
warnason
97f5dfadae Add test suite and polish project documentation
- 7 pytest tests covering health, upload validation, API responses, schemas
- Updated README with architecture diagram, feature list, design decisions
- Live demo link and complete API reference
2026-04-27 10:53:53 +02:00
5 changed files with 177 additions and 32 deletions

109
README.md
View file

@ -4,12 +4,51 @@ 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.
**Live demo:** [bim.stifting.at](https://bim.stifting.at)
## Features
- Upload IFC files (IFC2x3, IFC4) via drag-and-drop or file picker
- Server-side conversion to glTF binary for efficient 3D rendering
- Interactive Three.js viewer with orbit, pan, and zoom controls
- Click any building element to inspect its IFC properties
- Filter elements by type (wall, door, window, slab, ...) and storey
- Double-click to set a new orbit pivot point
- Collapsible floating panels for unobstructed 3D viewing
- Full REST API with automatic OpenAPI documentation
## 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
| Layer | Technology |
|----------------|------------------------------------------------|
| Backend | Python 3.12, FastAPI, SQLAlchemy (async) |
| IFC processing | IfcOpenShell (parsing + geometry serialization) |
| Frontend | Vue.js 3, Three.js, Vite |
| Database | PostgreSQL 16 |
| Infrastructure | Docker Compose, Caddy, Forgejo CI, Linux |
## Architecture
```
Browser Server
┌─────────────┐ HTTPS ┌──────────────────────────┐
│ Vue.js │◄───────────►│ Caddy (reverse proxy) │
│ Three.js │ └──────┬───────────────────┘
│ GLTFLoader │ │
└─────────────┘ ┌──────▼───────────────────┐
│ FastAPI │
│ ├─ /api/upload │
│ ├─ /api/projects │
│ ├─ /api/elements │
│ └─ /api/projects/*/model │
└──────┬───────────────────┘
┌──────────────┼──────────────┐
▼ ▼ ▼
PostgreSQL IfcOpenShell glb files
(elements, (IFC parser + (3D models)
properties) geometry engine)
```
## Quick start
@ -20,9 +59,27 @@ cp .env.example .env
docker compose up -d
```
- **API:** http://localhost:8000
- **API docs:** http://localhost:8000/docs
- **Frontend:** http://localhost:5173
- **API docs:** http://localhost:8000/docs
- **Health check:** http://localhost:8000/health
## 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/projects/{id}/elements/by-global-id/{gid}` | Lookup element by GlobalId |
| GET | `/api/elements/{id}` | Element detail + properties|
| GET | `/api/projects/{id}/model.glb` | Download 3D model |
## Running tests
```bash
docker compose exec backend python -m pytest -v
```
## Project structure
@ -33,44 +90,34 @@ docker compose up -d
│ │ ├── api/ # FastAPI route handlers
│ │ ├── models/ # SQLAlchemy ORM models
│ │ ├── schemas/ # Pydantic request/response schemas
│ │ └── services/ # Business logic (IFC parsing)
│ ├── tests/
│ │ └── services/ # IFC parsing and geometry conversion
│ ├── tests/ # pytest test suite
│ ├── Dockerfile
│ └── requirements.txt
├── frontend/
│ ├── src/
│ │ ├── components/ # Vue components (viewer, panels)
│ │ ├── views/ # Page-level views
│ │ └── composables/ # Reusable composition functions
│ │ ├── components/ # Vue components
│ │ ├── composables/ # useApi, useViewer (Three.js)
│ │ └── App.vue # Main application
│ ├── Dockerfile
│ └── package.json
├── data/
│ └── sample/ # Example IFC files for testing
├── docs/
├── data/sample/ # Example IFC files
├── docker-compose.yml
└── README.md
```
## API overview
## Design decisions
| 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|
- **Server-side IFC→glb conversion** rather than client-side WASM processing.
IfcOpenShell's geometry engine produces optimized meshes with IFC GlobalIds
preserved as mesh names, enabling click-to-inspect without shipping heavy
WASM bundles to the browser.
## Running tests
- **Async SQLAlchemy** for non-blocking database access during concurrent
file uploads and API queries.
```bash
# Backend
docker compose exec backend pytest -v
# Or locally
cd backend
python -m pytest -v
```
- **Floating UI panels** over a full-viewport 3D canvas for maximum spatial
awareness while inspecting element metadata.
## License

View file

@ -0,0 +1,18 @@
"""Test the elements API endpoints."""
import pytest
from httpx import ASGITransport, AsyncClient
from app.main import app
@pytest.mark.asyncio
async def test_list_projects_returns_list():
"""Projects endpoint returns a list."""
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/projects")
assert response.status_code == 200
assert isinstance(response.json(), list)

View file

@ -8,10 +8,10 @@ from app.main import app
@pytest.mark.asyncio
async def test_health_returns_ok():
"""Health endpoint returns status 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"}

View file

@ -0,0 +1,46 @@
"""Test database models and schema validation."""
from app.schemas.element import ProjectOut, ElementOut, PropertyOut
from uuid import uuid4
def test_project_schema_validates():
"""ProjectOut schema accepts valid data."""
data = {
"id": uuid4(),
"name": "Test Project",
"filename": "test.ifc",
"description": "A test",
"ifc_schema": "IFC4",
"element_count": 42,
}
project = ProjectOut(**data)
assert project.name == "Test Project"
assert project.element_count == 42
def test_element_schema_validates():
"""ElementOut schema accepts valid data."""
data = {
"id": uuid4(),
"global_id": "2XPyKWY018sA1ygZKgQPtU",
"ifc_type": "IfcWall",
"name": "Wall-1",
"description": "",
"storey": "Ground Floor",
}
element = ElementOut(**data)
assert element.ifc_type == "IfcWall"
def test_property_schema_validates():
"""PropertyOut schema accepts valid data."""
data = {
"id": uuid4(),
"pset_name": "Pset_WallCommon",
"name": "ThermalTransmittance",
"value": "1.5",
}
prop = PropertyOut(**data)
assert prop.pset_name == "Pset_WallCommon"

View file

@ -0,0 +1,34 @@
"""Test the IFC upload endpoint."""
import pytest
from httpx import ASGITransport, AsyncClient
from app.main import app
@pytest.mark.asyncio
async def test_upload_rejects_non_ifc():
"""Upload endpoint rejects files without .ifc extension."""
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/api/upload",
files={"file": ("model.obj", b"dummy content", "application/octet-stream")},
)
assert response.status_code == 400
assert "Only .ifc files" in response.json()["detail"]
@pytest.mark.asyncio
async def test_upload_rejects_empty_filename():
"""Upload endpoint rejects files with wrong extension."""
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/api/upload",
files={"file": ("readme.txt", b"not an ifc file", "text/plain")},
)
assert response.status_code == 400