cdshelat/MorphAI
0
1"""2MorphAI — FastAPI backend3Wraps the existing optimizer / materials / geometry pipeline and serves the React frontend.4"""5from __future__ import annotations6 7import json8import uuid9from pathlib import Path10 11import numpy as np12from fastapi import FastAPI, HTTPException13from fastapi.responses import FileResponse, Response14from fastapi.staticfiles import StaticFiles15from pydantic import BaseModel16 17from materials import MATERIALS18from optimizer import simp_core, build_filter, build_load_cases19from geometry import to_stl_bytes20from utils import estimate_print21 22# ── Storage paths ──────────────────────────────────────────────────────────────23MORPHAI_DIR = Path.home() / ".morphai"24HISTORY_FILE = MORPHAI_DIR / "history.json"25STL_DIR = MORPHAI_DIR / "stls"26 27 28def _ensure_dirs():29 MORPHAI_DIR.mkdir(parents=True, exist_ok=True)30 STL_DIR.mkdir(parents=True, exist_ok=True)31 32 33def _load_history() -> dict:34 _ensure_dirs()35 if HISTORY_FILE.exists():36 try:37 return json.loads(HISTORY_FILE.read_text(encoding="utf-8"))38 except Exception:39 pass40 return {"runs": []}41 42 43def _save_history(data: dict):44 _ensure_dirs()45 HISTORY_FILE.write_text(json.dumps(data, indent=2), encoding="utf-8")46 47 48# ── Material name mapping (frontend short → backend full key) ──────────────────49_MAT_MAP = {50 "PLA": "PLA (Bioplastic)",51 "PETG": "PETG (Engineering Plastic)",52 "ABS": "ABS (Acrylonitrile Butadiene Styrene)",53 "Nylon": "Nylon PA12",54 "CF-PETG": "Carbon Fiber PETG (Composite)",55 "Aluminum (ref)": "Aluminum 6061 (Reference)",56 "Titanium (ref)": "Titanium Ti-6Al-4V (Reference)",57}58 59# ── Face name mapping (frontend short → backend full) ─────────────────────────60_FACE_MAP = {61 "Left": "Left (X=0)",62 "Right": "Right (X=W)",63 "Top": "Top (Y=H)",64 "Bottom": "Bottom (Y=0)",65 "Front": "Front (Z=0)",66 "Back": "Back (Z=D)",67}68 69# ── Force direction unit vectors (frontend dir → (fx, fy)) ────────────────────70_DIR_UNIT = {71 "-X": (-1.0, 0.0),72 "+X": ( 1.0, 0.0),73 "-Y": ( 0.0, -1.0),74 "+Y": ( 0.0, 1.0),75 "-Z": ( 0.0, 0.0),76 "+Z": ( 0.0, 0.0),77}78 79 80# ── Metrics helpers ────────────────────────────────────────────────────────────81 82def _compute_bimodality(xPhys: np.ndarray) -> float:83 flat = xPhys.flatten()84 return float(((flat < 0.2) | (flat > 0.8)).sum() / len(flat))85 86 87def _check_load_path(xPhys: np.ndarray, fixed_face: str, load_face: str) -> bool:88 """BFS flood-fill: is there a solid-element path between the two faces?"""89 nely, nelx = xPhys.shape90 solid = xPhys > 0.4591 92 def _face_elements(face: str) -> set:93 s: set = set()94 if "Left" in face:95 for iy in range(nely):96 if solid[iy, 0]: s.add((iy, 0))97 elif "Right" in face:98 for iy in range(nely):99 if solid[iy, nelx - 1]: s.add((iy, nelx - 1))100 elif "Bottom" in face:101 for ix in range(nelx):102 if solid[0, ix]: s.add((0, ix))103 elif "Top" in face:104 for ix in range(nelx):105 if solid[nely - 1, ix]: s.add((nely - 1, ix))106 return s107 108 seeds = _face_elements(fixed_face)109 targets = _face_elements(load_face)110 111 if not seeds or not targets:112 return False113 if seeds & targets:114 return True115 116 visited = set(seeds)117 frontier = list(seeds)118 while frontier:119 iy, ix = frontier.pop()120 if (iy, ix) in targets:121 return True122 for dy, dx in ((-1, 0), (1, 0), (0, -1), (0, 1)):123 ny, nx = iy + dy, ix + dx124 if (0 <= ny < nely and 0 <= nx < nelx125 and solid[ny, nx] and (ny, nx) not in visited):126 visited.add((ny, nx))127 frontier.append((ny, nx))128 return False129 130 131def _check_non_convergent(history: list[float], threshold: float = 8) -> bool:132 """Return True if compliance stopped improving for the last `threshold` iters."""133 if len(history) < threshold + 2:134 return False135 recent = history[-int(threshold):]136 span = max(recent) - min(recent)137 reference = abs(history[0]) if history[0] else 1.0138 return span < reference * 0.001139 140 141# ── Fidelity presets ───────────────────────────────────────────────────────────142_FIDELITY = {143 "quick": {"nelx_base": 14, "max_iter": 40},144 "standard": {"nelx_base": 22, "max_iter": 60},145 "detail": {"nelx_base": 34, "max_iter": 80},146}147 148 149# ── Request / response models ──────────────────────────────────────────────────150 151class OptimizeRequest(BaseModel):152 material: str = "PLA"153 w: float = 100.0 # mm154 h: float = 60.0155 d: float = 30.0156 fixedFace: str = "Left"157 loadFace: str = "Right"158 forceDir: str = "-Y"159 force: float = 150.0 # N160 vf: float = 0.40161 sf: float = 2.0162 fidelity: str = "standard"163 infill: str = "Gyroid"164 165 166# ── App ────────────────────────────────────────────────────────────────────────167app = FastAPI(title="MorphAI API", version="1.0")168 169 170@app.get("/api/materials")171def get_materials():172 """Return material catalogue using frontend short names."""173 result = {}174 for short, full in _MAT_MAP.items():175 m = MATERIALS.get(full)176 if m is None:177 continue178 result[short] = {179 "E": m["E_gpa"],180 "rho": m["rho_gcc"],181 "yield": m["yield_strength_mpa"],182 "color": m["color"],183 "printable": m["printable"],184 "temp": m["print_temp"],185 "cost": round(m["cost_per_kg"] / 1000, 4), # per gram186 "desc": m.get("notes", ""),187 }188 return result189 190 191@app.post("/api/optimize")192def run_optimize(req: OptimizeRequest):193 # ── Resolve material ───────────────────────────────────────────────────────194 mat_full = _MAT_MAP.get(req.material, "PLA (Bioplastic)")195 mat = MATERIALS.get(mat_full)196 if mat is None:197 raise HTTPException(400, f"Unknown material: {req.material}")198 199 nu = mat["nu"]200 201 # ── Mesh resolution from fidelity ─────────────────────────────────────────202 fid = _FIDELITY.get(req.fidelity, _FIDELITY["standard"])203 nelx = fid["nelx_base"]204 nely = max(6, round(nelx * req.h / req.w))205 max_iter = fid["max_iter"]206 207 # ── Build filter ───────────────────────────────────────────────────────────208 H, Hs = build_filter(nelx, nely, rmin=1.5)209 210 # ── Build load/support specs ───────────────────────────────────────────────211 fixed_face_full = _FACE_MAP.get(req.fixedFace, "Left (X=0)")212 load_face_full = _FACE_MAP.get(req.loadFace, "Right (X=W)")213 fx_unit, fy_unit = _DIR_UNIT.get(req.forceDir, (0.0, -1.0))214 215 load_specs = [{"type": "surface", "face": load_face_full,216 "fx": fx_unit * req.force, "fy": fy_unit * req.force}]217 support_specs = [{"type": "fixed", "face": fixed_face_full}]218 219 load_cases, fixed_dofs, F_mag = build_load_cases(load_specs, support_specs, nelx, nely)220 221 # ── Run SIMP ───────────────────────────────────────────────────────────────222 xPhys, history, stress_field = simp_core(223 nelx=nelx, nely=nely,224 volfrac=req.vf,225 nu=nu,226 penal=3.0,227 H=H, Hs=Hs,228 max_iter=max_iter,229 load_cases=load_cases,230 fixed_dofs=fixed_dofs,231 )232 233 # ── Metrics ────────────────────────────────────────────────────────────────234 bimodality = _compute_bimodality(xPhys)235 non_convergent = _check_non_convergent(history)236 has_load_path = _check_load_path(xPhys, fixed_face_full, load_face_full)237 238 # Physical stress: σ_phys [MPa] = σ_norm × F_mag [N] / (dx_mm × thickness_mm)239 dx_mm = req.w / nelx240 sigma_phys_max = float(stress_field.max()) * F_mag / (dx_mm * req.d)241 yield_exceeded = sigma_phys_max * req.sf > mat["yield_strength_mpa"]242 243 # Mass: volume × vf × density244 mass_g = round(float(req.w * req.h * req.d * req.vf * mat["rho_gcc"]) / 1000.0, 2)245 246 # Print time estimate247 print_est = estimate_print(248 mass_g=mass_g,249 rho_gcc=mat["rho_gcc"],250 cost_per_kg=mat.get("cost_per_kg", 20.0),251 )252 253 # ── STL ────────────────────────────────────────────────────────────────────254 stl_bytes, _n_faces = to_stl_bytes(xPhys, req.w, req.h, req.d, iso=0.45)255 256 run_id = str(uuid.uuid4())[:8]257 _ensure_dirs()258 stl_available = False259 if stl_bytes:260 (STL_DIR / f"{run_id}.stl").write_bytes(stl_bytes)261 stl_available = True262 263 # ── Persist thin run record (no large arrays) ─────────────────────────────264 run_meta = {265 "id": run_id,266 "label": f"{req.material} · {int(req.w)}×{int(req.h)}×{int(req.d)}",267 "material": req.material,268 "w": req.w, "h": req.h, "d": req.d,269 "fixedFace": req.fixedFace,270 "loadFace": req.loadFace,271 "forceDir": req.forceDir,272 "force": req.force,273 "vf": req.vf,274 "sf": req.sf,275 "fidelity": req.fidelity,276 "infill": req.infill,277 "compliance": round(float(history[-1]), 3) if history else 0.0,278 "max_stress": round(sigma_phys_max, 2),279 "iterations": len(history),280 "mass_g": mass_g,281 "bimodality": round(bimodality, 3),282 "stl_available": stl_available,283 }284 hist = _load_history()285 hist["runs"].insert(0, run_meta)286 hist["runs"] = hist["runs"][:20]287 _save_history(hist)288 289 # ── Full response (includes arrays for charts) ────────────────────────────290 return {291 **run_meta,292 "non_convergent": non_convergent,293 "no_load_path": not has_load_path,294 "yield_exceeded": yield_exceeded,295 "print_time_h": print_est["time_h"],296 "filament_cost": print_est["material_cost_usd"],297 "convergence": [round(v, 3) for v in history],298 "density_grid": xPhys.tolist(), # list[list[float]], shape nely × nelx299 }300 301 302@app.get("/api/runs")303def get_runs():304 return _load_history()305 306 307@app.get("/api/runs/{run_id}/stl")308def download_stl(run_id: str):309 stl_path = STL_DIR / f"{run_id}.stl"310 if not stl_path.exists():311 raise HTTPException(404, "STL not found — run the optimizer first")312 fname = f"morphai_{run_id}.stl"313 return Response(314 content=stl_path.read_bytes(),315 media_type="model/stl",316 headers={"Content-Disposition": f'attachment; filename="{fname}"'},317 )318 319 320# ── Serve React frontend (must be last — catches everything else) ──────────────321app.mount("/", StaticFiles(directory="frontend", html=True), name="frontend")322 