"""STL/3MF geometry analysis with background process isolation.""" import logging import multiprocessing as _mp from pathlib import Path import numpy as np from sqlalchemy.orm import Session from app.models.stl_cache import StlCache logger = logging.getLogger("pops") _ANALYSABLE = {".stl", ".3mf"} _TIMEOUT = 120 # seconds before we give up _in_progress: set[str] = set() _failed: set[str] = set() def _load_geometry(filepath: str): """Return (extents_array, volume_mm3). Handles Scene without concatenating.""" import trimesh loaded = trimesh.load(filepath) if isinstance(loaded, trimesh.Scene): meshes = list(loaded.geometry.values()) if not meshes: raise ValueError("3MF scene contains no meshes") bounds = np.array([m.bounds for m in meshes]) # (N, 2, 3) extents = bounds[:, 1, :].max(axis=0) - bounds[:, 0, :].min(axis=0) volume_mm3 = sum(abs(float(m.volume)) for m in meshes) else: extents = np.array(loaded.extents) volume_mm3 = abs(float(loaded.volume)) return extents, volume_mm3 def analyse(filename: str, db: Session) -> StlCache | None: """Return cached analysis for filename, computing it if missing.""" existing = db.query(StlCache).filter(StlCache.filename == filename).first() if existing: return existing from app.stl import get_stl_dir filepath = get_stl_dir() / filename if Path(filename).suffix.lower() not in _ANALYSABLE or not filepath.exists(): return None try: extents, volume_mm3 = _load_geometry(str(filepath)) entry = StlCache( filename=filename, cube_x=round(float(extents[0]), 2), cube_y=round(float(extents[1]), 2), cube_z=round(float(extents[2]), 2), volume_ccm=round(volume_mm3 / 1000, 3), ) db.add(entry) db.commit() return entry except Exception: logger.exception("STL analysis failed for %s", filename) return None def _worker(filename: str) -> None: """Child process entry point — creates its own DB session.""" from app.db import SessionLocal db = SessionLocal() try: analyse(filename, db) finally: db.close() def analyse_in_background(filename: str) -> None: """Spawn an isolated child process for analysis; kill it after _TIMEOUT.""" if filename in _in_progress: return _in_progress.add(filename) _failed.discard(filename) try: ctx = _mp.get_context("spawn") p = ctx.Process(target=_worker, args=(filename,), daemon=True) p.start() p.join(timeout=_TIMEOUT) if p.is_alive(): logger.warning("STL analysis timed out for %s — killing process", filename) p.kill() p.join() _failed.add(filename) elif p.exitcode != 0: logger.warning("STL analysis process exited with code %s for %s", p.exitcode, filename) _failed.add(filename) except Exception: logger.exception("STL background task error for %s", filename) _failed.add(filename) finally: _in_progress.discard(filename) def is_computing(filename: str) -> bool: return filename in _in_progress def is_failed(filename: str) -> bool: return filename in _failed def get_cache_map(filenames: list[str], db: Session) -> dict[str, StlCache]: """Bulk-fetch cached entries for a list of filenames.""" rows = db.query(StlCache).filter(StlCache.filename.in_(filenames)).all() return {r.filename: r for r in rows}