Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: CI

on:
push:
branches: [main]
pull_request:

jobs:
web:
name: Web (typecheck, test, build)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
# package-lock.json is intentionally untracked, so npm ci is not usable.
- run: npm install
- run: npx tsc -b
working-directory: apps/web
- run: npm run test:web
- run: npm run build:web

api:
name: API (pytest)
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/api
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: pip
cache-dependency-path: apps/api/requirements.txt
- run: pip install -r requirements.txt
- run: python -m pytest -q
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ coverage/
# Runtime data
data/studies/**
!data/studies/.gitkeep
data/*.db
data/*.db-wal
data/*.db-shm

# Logs
*.log
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ pip install -r requirements.txt
uvicorn app.main:app --reload --port 8000
```

Optional environment overrides (`OPENTOS_DATA_DIR`, `OPENTOS_MAX_WORKERS`) are documented in `apps/api/README.md`.

## 2) Web

```bash
Expand Down
14 changes: 14 additions & 0 deletions apps/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,17 @@ uvicorn app.main:app --reload --port 8000
```

The API persists study artifacts under `../../data/studies` and metadata in SQLite at `../../data/opentos.db`.

## Configuration

Optional environment variables (defaults in parentheses):

- `OPENTOS_DATA_DIR`: root directory for SQLite metadata and study artifacts (`<repo>/data`).
- `OPENTOS_MAX_WORKERS`: solver thread pool size (`2`).

## Test

```bash
cd apps/api
python3 -m pytest
```
13 changes: 10 additions & 3 deletions apps/api/app/api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,17 @@ def get_study(study_id: str, manager: JobManager = Depends(get_job_manager)) ->


@router.post("/studies/{study_id}/run", response_model=StudyRunResponse)
def run_study(study_id: str, body: RunOptions, request: Request, manager: JobManager = Depends(get_job_manager)) -> StudyRunResponse:
validate_run_options_payload(body.model_dump(mode="json", exclude_none=True))
def run_study(
study_id: str,
request: Request,
body: RunOptions | None = None,
manager: JobManager = Depends(get_job_manager),
) -> StudyRunResponse:
# Run options are documented as optional; an absent body means defaults.
options = body if body is not None else RunOptions()
validate_run_options_payload(options.model_dump(mode="json", exclude_none=True))
try:
job_id = manager.run_study(study_id, body)
job_id = manager.run_study(study_id, options)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc

Expand Down
28 changes: 26 additions & 2 deletions apps/api/app/core/config.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,39 @@
from __future__ import annotations

import os
from pathlib import Path


def _int_env(name: str, default: int) -> int:
raw = os.environ.get(name)
if raw is None:
return default
try:
value = int(raw)
except ValueError as exc:
raise ValueError(f"{name} must be an integer, got {raw!r}") from exc
if value < 1:
raise ValueError(f"{name} must be >= 1, got {value}")
return value


class Settings:
"""Runtime configuration.

Defaults keep all state under the repository's ``data/`` directory; the
optional environment variables below override them for deployments:

- ``OPENTOS_DATA_DIR``: root directory for SQLite metadata and artifacts.
- ``OPENTOS_MAX_WORKERS``: solver thread pool size (default 2).
"""

def __init__(self) -> None:
self.repo_root = Path(__file__).resolve().parents[4]
self.data_root = self.repo_root / "data"
data_dir = os.environ.get("OPENTOS_DATA_DIR")
self.data_root = Path(data_dir).resolve() if data_dir else self.repo_root / "data"
self.studies_root = self.data_root / "studies"
self.sqlite_path = self.data_root / "opentos.db"
self.max_workers = 2
self.max_workers = _int_env("OPENTOS_MAX_WORKERS", 2)
self.default_quality_profile = "balanced"


Expand Down
5 changes: 0 additions & 5 deletions apps/api/app/core/schema_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,3 @@ def validate_study_payload(payload: dict) -> None:
def validate_run_options_payload(payload: dict) -> None:
errors = sorted(_run_options_validator().iter_errors(payload), key=lambda e: e.path)
_raise_first_schema_error(errors)


def validate_solve_payload(payload: dict) -> None:
# Backward shim for any stale imports inside the codebase during transition.
validate_study_payload(payload)
17 changes: 15 additions & 2 deletions apps/api/app/db/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,21 @@
from app.core.config import settings


def _connect() -> sqlite3.Connection:
# Jobs update progress from worker threads while request handlers poll, so
# the database needs WAL (concurrent reader/writer) and a busy timeout
# instead of failing immediately with "database is locked".
conn = sqlite3.connect(settings.sqlite_path, timeout=10.0)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=10000")
return conn


def init_db() -> None:
settings.data_root.mkdir(parents=True, exist_ok=True)
settings.studies_root.mkdir(parents=True, exist_ok=True)

with sqlite3.connect(settings.sqlite_path) as conn:
with _connect() as conn:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS studies_v2 (
Expand Down Expand Up @@ -150,10 +160,13 @@ def init_db() -> None:

@contextmanager
def db_conn() -> sqlite3.Connection:
conn = sqlite3.connect(settings.sqlite_path)
conn = _connect()
conn.row_factory = sqlite3.Row
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
36 changes: 12 additions & 24 deletions apps/api/app/db/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,16 @@ def save_outcome_v2(
)


def _parse_outcome_row(row: Any) -> dict[str, Any]:
return {
"outcome_id": row["outcome_id"],
"glb_path": row["glb_path"],
"metrics": json.loads(row["metrics_json"]),
"params": json.loads(row["params_json"]),
"warnings": json.loads(row["warnings_json"]),
}


def get_outcomes_by_job_v2(job_id: str) -> list[dict[str, Any]]:
with db_conn() as conn:
rows = conn.execute(
Expand All @@ -212,18 +222,7 @@ def get_outcomes_by_job_v2(job_id: str) -> list[dict[str, Any]]:
(job_id,),
).fetchall()

parsed: list[dict[str, Any]] = []
for row in rows:
parsed.append(
{
"outcome_id": row["outcome_id"],
"glb_path": row["glb_path"],
"metrics": json.loads(row["metrics_json"]),
"params": json.loads(row["params_json"]),
"warnings": json.loads(row["warnings_json"]),
}
)
return parsed
return [_parse_outcome_row(row) for row in rows]


def get_outcomes_by_study_v2(study_id: str) -> list[dict[str, Any]]:
Expand All @@ -238,18 +237,7 @@ def get_outcomes_by_study_v2(study_id: str) -> list[dict[str, Any]]:
(study_id,),
).fetchall()

parsed: list[dict[str, Any]] = []
for row in rows:
parsed.append(
{
"outcome_id": row["outcome_id"],
"glb_path": row["glb_path"],
"metrics": json.loads(row["metrics_json"]),
"params": json.loads(row["params_json"]),
"warnings": json.loads(row["warnings_json"]),
}
)
return parsed
return [_parse_outcome_row(row) for row in rows]


def get_benchmark_v2(benchmark_id: str) -> dict[str, Any] | None:
Expand Down
26 changes: 20 additions & 6 deletions apps/api/app/main.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
from __future__ import annotations

from contextlib import asynccontextmanager
from typing import AsyncIterator

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

Expand All @@ -8,26 +11,37 @@
from app.workers.job_manager import JobManager


@asynccontextmanager
async def _lifespan(app: FastAPI) -> AsyncIterator[None]:
init_db()
# Shut down the manager this lifespan created, even if a test swapped
# app.state.job_manager for a stub in the meantime.
manager = JobManager()
app.state.job_manager = manager
try:
yield
finally:
manager.shutdown()


def create_app() -> FastAPI:
app = FastAPI(
title="OpenTOS Generative Design API",
version="0.1.0",
description="Autodesk-inspired generative design study service",
lifespan=_lifespan,
)

# The API is unauthenticated and cookie-free, so wildcard origins are
# acceptable; credentials must stay disabled for that to remain true.
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)

@app.on_event("startup")
def _startup() -> None:
init_db()
app.state.job_manager = JobManager()

app.include_router(router)

return app
Expand Down
28 changes: 16 additions & 12 deletions apps/api/app/solver/fusion_solver.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,13 @@ def _choose_pitch(mesh: trimesh.Trimesh) -> float:
return float(np.clip(pitch, floor, max_extent / 36.0))


def _world_to_index(point: np.ndarray, transform: np.ndarray, shape: tuple[int, int, int]) -> tuple[int, int, int]:
def _world_to_indices(points: np.ndarray, transform: np.ndarray, shape: tuple[int, int, int]) -> np.ndarray:
"""Map world-space points (N, 3) to clamped integer voxel indices (N, 3)."""
inv = np.linalg.inv(transform)
hom = np.concatenate([point, np.array([1.0])])
idxf = inv @ hom
idx = np.round(idxf[:3]).astype(int)
idx = np.clip(idx, [0, 0, 0], np.array(shape) - 1)
return int(idx[0]), int(idx[1]), int(idx[2])
hom = np.concatenate([points, np.ones((points.shape[0], 1))], axis=1)
idxf = (inv @ hom.T).T[:, :3]
idx = np.rint(idxf).astype(int)
return np.clip(idx, [0, 0, 0], np.asarray(shape) - 1)


def _voxel_centers(shape: tuple[int, int, int], transform: np.ndarray) -> np.ndarray:
Expand All @@ -65,9 +65,10 @@ def _voxel_mask_from_centers(
dilation: int,
) -> np.ndarray:
mask = np.zeros(shape, dtype=bool)
for center in centers:
x, y, z = _world_to_index(np.asarray(center, dtype=np.float64), transform, shape)
mask[x, y, z] = True
centers = np.asarray(centers, dtype=np.float64).reshape(-1, 3)
if centers.shape[0] > 0:
idx = _world_to_indices(centers, transform, shape)
mask[idx[:, 0], idx[:, 1], idx[:, 2]] = True
if dilation > 0:
mask = ndi.binary_dilation(mask, iterations=dilation)
return mask & solid_mask
Expand All @@ -81,6 +82,7 @@ def _force_seed_mask(
pitch: float,
) -> np.ndarray:
mask = np.zeros(shape, dtype=bool)
samples: list[np.ndarray] = []
for force in forces:
base = np.asarray(force.point_m, dtype=np.float64)
direction = np.asarray(force.direction, dtype=np.float64)
Expand All @@ -93,9 +95,11 @@ def _force_seed_mask(
radius = int(np.clip(np.sqrt(max(force.magnitude_n, 1.0)) / 40.0, 1, 2))
trail_steps = radius + 1
for step in range(trail_steps + 1):
sample = base - direction * pitch * step
x, y, z = _world_to_index(sample, transform, shape)
mask[x, y, z] = True
samples.append(base - direction * pitch * step)

if samples:
idx = _world_to_indices(np.asarray(samples), transform, shape)
mask[idx[:, 0], idx[:, 1], idx[:, 2]] = True

mask = ndi.binary_dilation(mask, iterations=1)
return mask & solid_mask
Expand Down
2 changes: 1 addition & 1 deletion apps/api/app/solver/normalization.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ def _to_mesh(payload: bytes, model_format: str) -> trimesh.Trimesh:
loaded = trimesh.load(io.BytesIO(payload), file_type=model_format)

if isinstance(loaded, trimesh.Scene):
flattened = loaded.dump(concatenate=True)
flattened = loaded.to_geometry()
if isinstance(flattened, trimesh.Trimesh):
return flattened

Expand Down
Loading
Loading