from __future__ import annotations import json import os import subprocess import tempfile import uuid from pathlib import Path from typing import Any from fastapi import FastAPI, File, Form, HTTPException, UploadFile from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles APP_ROOT = Path(__file__).resolve().parent.parent WEB_ROOT = APP_ROOT / "web" PRESETS_DIRS = [APP_ROOT / "presets", APP_ROOT / "docker"] DEFAULT_PIPELINE_CONFIG = Path( os.environ.get("PIPELINE_CONFIG", APP_ROOT / "docker" / "default_pipeline.json") ) DOTSTOSIRFACE_BIN = Path(os.environ.get("DOTSTOSIRFACE_BIN", "/usr/local/bin/DotsToSirface")) app = FastAPI(title="DotsToSirface Web API", version="1.0.0") app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) def preset_to_pipeline_config(preset: dict[str, Any]) -> dict[str, Any]: if "preprocessPlugins" in preset and "reconstructionPlugin" in preset: return preset preprocess_plugins: list[str] = [] reconstruction_plugin = "surface_fallback" stage_defaults: dict[str, str] = {} for stage in preset.get("stages", []): if not stage.get("enabled", True): continue stage_id = str(stage.get("id", "")).strip() if not stage_id: continue family = str(stage.get("family", "")).strip() if family == "preprocess": preprocess_plugins.append(stage_id) elif family == "reconstruction": reconstruction_plugin = stage_id defaults = str(stage.get("defaults", "")).strip() if defaults: stage_defaults[stage_id] = defaults return { "profile": preset.get("profile", "desktop_debug"), "preprocessPlugins": preprocess_plugins, "reconstructionPlugin": reconstruction_plugin, "stageDefaults": stage_defaults, } def list_preset_files() -> list[Path]: files: list[Path] = [] seen: set[str] = set() for directory in PRESETS_DIRS: if not directory.is_dir(): continue for path in sorted(directory.glob("*.json")): if path.name in seen: continue seen.add(path.name) files.append(path) return files @app.get("/api/health") def health() -> dict[str, str]: return { "status": "ok", "binary": str(DOTSTOSIRFACE_BIN), "binaryExists": str(DOTSTOSIRFACE_BIN.is_file()), } @app.get("/api/presets") def presets() -> list[dict[str, Any]]: items: list[dict[str, Any]] = [] for path in list_preset_files(): with path.open("r", encoding="utf-8") as handle: data = json.load(handle) items.append( { "id": path.stem, "filename": path.name, "title": data.get("title", path.stem), "config": preset_to_pipeline_config(data), } ) return items @app.get("/api/default-config") def default_config() -> dict[str, Any]: if not DEFAULT_PIPELINE_CONFIG.is_file(): raise HTTPException(status_code=500, detail="Default pipeline config is missing.") with DEFAULT_PIPELINE_CONFIG.open("r", encoding="utf-8") as handle: return json.load(handle) @app.post("/api/run") async def run_pipeline( file: UploadFile = File(...), preset_id: str | None = Form(default=None), config_json: str | None = Form(default=None), ) -> dict[str, Any]: if not DOTSTOSIRFACE_BIN.is_file(): raise HTTPException(status_code=500, detail=f"Binary not found: {DOTSTOSIRFACE_BIN}") suffix = Path(file.filename or "cloud.ply").suffix or ".ply" work_id = uuid.uuid4().hex work_dir = Path(tempfile.gettempdir()) / "dotstosirface" / work_id work_dir.mkdir(parents=True, exist_ok=True) input_path = work_dir / f"input{suffix}" config_path = work_dir / "pipeline_config.json" output_path = work_dir / "result.json" try: content = await file.read() input_path.write_bytes(content) if config_json: pipeline_config = json.loads(config_json) elif preset_id: preset_path = next((p for p in list_preset_files() if p.stem == preset_id), None) if preset_path is None: raise HTTPException(status_code=400, detail=f"Unknown preset: {preset_id}") with preset_path.open("r", encoding="utf-8") as handle: pipeline_config = preset_to_pipeline_config(json.load(handle)) else: with DEFAULT_PIPELINE_CONFIG.open("r", encoding="utf-8") as handle: pipeline_config = json.load(handle) config_path.write_text(json.dumps(pipeline_config, indent=2), encoding="utf-8") command = [ str(DOTSTOSIRFACE_BIN), "--cli", "--input", str(input_path), "--config-json", str(config_path), "--output-json", str(output_path), ] completed = subprocess.run( command, capture_output=True, text=True, check=False, ) if completed.returncode != 0: detail = completed.stderr.strip() or completed.stdout.strip() or "Pipeline failed." raise HTTPException(status_code=500, detail=detail) if not output_path.is_file(): raise HTTPException(status_code=500, detail="Pipeline finished without output JSON.") with output_path.open("r", encoding="utf-8") as handle: result = json.load(handle) result["stdout"] = completed.stdout.strip() result["workId"] = work_id return result except json.JSONDecodeError as exc: raise HTTPException(status_code=400, detail=f"Invalid config JSON: {exc}") from exc except HTTPException: raise except Exception as exc: # pragma: no cover - defensive path for API boundary raise HTTPException(status_code=500, detail=str(exc)) from exc @app.get("/") def index() -> FileResponse: return FileResponse(WEB_ROOT / "index.html") app.mount("/static", StaticFiles(directory=WEB_ROOT), name="static")