Compare commits
No commits in common. "354d6e8c140801cac2e871ecedc84d6ccd5d8954" and "eb2c662d0e60b18d309250fd5ed16a19cc0f564c" have entirely different histories.
354d6e8c14
...
eb2c662d0e
5 changed files with 32 additions and 177 deletions
109
README.md
109
README.md
|
|
@ -4,51 +4,12 @@ Interactive IFC-based 3D building model viewer. Upload an IFC file to
|
||||||
inspect building elements, their properties, and navigate the model in
|
inspect building elements, their properties, and navigate the model in
|
||||||
a browser-based 3D view.
|
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
|
## Tech stack
|
||||||
|
|
||||||
| Layer | Technology |
|
- **Backend:** Python 3.12, FastAPI, SQLAlchemy (async), IfcOpenShell
|
||||||
|----------------|------------------------------------------------|
|
- **Frontend:** Vue.js 3, Three.js
|
||||||
| Backend | Python 3.12, FastAPI, SQLAlchemy (async) |
|
- **Database:** PostgreSQL 16
|
||||||
| IFC processing | IfcOpenShell (parsing + geometry serialization) |
|
- **Infrastructure:** Docker Compose, Caddy, Forgejo CI
|
||||||
| 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
|
## Quick start
|
||||||
|
|
||||||
|
|
@ -59,27 +20,9 @@ cp .env.example .env
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Frontend:** http://localhost:5173
|
- **API:** http://localhost:8000
|
||||||
- **API docs:** http://localhost:8000/docs
|
- **API docs:** http://localhost:8000/docs
|
||||||
- **Health check:** http://localhost:8000/health
|
- **Frontend:** http://localhost:5173
|
||||||
|
|
||||||
## 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
|
## Project structure
|
||||||
|
|
||||||
|
|
@ -90,34 +33,44 @@ docker compose exec backend python -m pytest -v
|
||||||
│ │ ├── api/ # FastAPI route handlers
|
│ │ ├── api/ # FastAPI route handlers
|
||||||
│ │ ├── models/ # SQLAlchemy ORM models
|
│ │ ├── models/ # SQLAlchemy ORM models
|
||||||
│ │ ├── schemas/ # Pydantic request/response schemas
|
│ │ ├── schemas/ # Pydantic request/response schemas
|
||||||
│ │ └── services/ # IFC parsing and geometry conversion
|
│ │ └── services/ # Business logic (IFC parsing)
|
||||||
│ ├── tests/ # pytest test suite
|
│ ├── tests/
|
||||||
│ ├── Dockerfile
|
│ ├── Dockerfile
|
||||||
│ └── requirements.txt
|
│ └── requirements.txt
|
||||||
├── frontend/
|
├── frontend/
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
│ │ ├── components/ # Vue components
|
│ │ ├── components/ # Vue components (viewer, panels)
|
||||||
│ │ ├── composables/ # useApi, useViewer (Three.js)
|
│ │ ├── views/ # Page-level views
|
||||||
│ │ └── App.vue # Main application
|
│ │ └── composables/ # Reusable composition functions
|
||||||
│ ├── Dockerfile
|
│ ├── Dockerfile
|
||||||
│ └── package.json
|
│ └── package.json
|
||||||
├── data/sample/ # Example IFC files
|
├── data/
|
||||||
|
│ └── sample/ # Example IFC files for testing
|
||||||
|
├── docs/
|
||||||
├── docker-compose.yml
|
├── docker-compose.yml
|
||||||
└── README.md
|
└── README.md
|
||||||
```
|
```
|
||||||
|
|
||||||
## Design decisions
|
## API overview
|
||||||
|
|
||||||
- **Server-side IFC→glb conversion** rather than client-side WASM processing.
|
| Method | Endpoint | Description |
|
||||||
IfcOpenShell's geometry engine produces optimized meshes with IFC GlobalIds
|
|--------|---------------------------------------|---------------------------|
|
||||||
preserved as mesh names, enabling click-to-inspect without shipping heavy
|
| GET | `/health` | Health check |
|
||||||
WASM bundles to the browser.
|
| 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|
|
||||||
|
|
||||||
- **Async SQLAlchemy** for non-blocking database access during concurrent
|
## Running tests
|
||||||
file uploads and API queries.
|
|
||||||
|
|
||||||
- **Floating UI panels** over a full-viewport 3D canvas for maximum spatial
|
```bash
|
||||||
awareness while inspecting element metadata.
|
# Backend
|
||||||
|
docker compose exec backend pytest -v
|
||||||
|
|
||||||
|
# Or locally
|
||||||
|
cd backend
|
||||||
|
python -m pytest -v
|
||||||
|
```
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
"""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)
|
|
||||||
|
|
||||||
|
|
@ -8,10 +8,10 @@ from app.main import app
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_health_returns_ok():
|
async def test_health_returns_ok():
|
||||||
"""Health endpoint returns status ok."""
|
|
||||||
transport = ASGITransport(app=app)
|
transport = ASGITransport(app=app)
|
||||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
response = await client.get("/health")
|
response = await client.get("/health")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == {"status": "ok"}
|
assert response.json() == {"status": "ok"}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
"""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"
|
|
||||||
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
"""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
|
|
||||||
|
|
||||||
Loading…
Add table
Reference in a new issue