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