diff --git a/app/routers/ui.py b/app/routers/ui.py
index 01140f0..caf9eff 100644
--- a/app/routers/ui.py
+++ b/app/routers/ui.py
@@ -26,7 +26,7 @@ from app.estimation import estimate # also used in start_job
from app.filamentdb import (available_colors, available_materials, find_matching_ams_roll,
get_stock, log_print_on_roll, spool_counts)
from app.stl import get_stl_dir, list_stl_files
-from app.stl_analysis import analyse_in_background, get_cache_map, is_computing # noqa: F401
+from app.stl_analysis import analyse_in_background, get_cache_map, is_computing, is_failed # noqa: F401
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
@@ -276,9 +276,11 @@ def job_detail(job_id: int, request: Request, background_tasks: BackgroundTasks,
else:
stl = db.query(StlCache).filter_by(filename=job.file_name).first()
if stl is None and not job.file_name.endswith(".gcode"):
- if not is_computing(job.file_name):
+ if is_failed(job.file_name):
+ file_missing = False # file exists, analysis just failed
+ elif not is_computing(job.file_name):
background_tasks.add_task(analyse_in_background, job.file_name)
- computing = True
+ computing = not is_failed(job.file_name)
estimation = estimate(stl.volume_ccm, job.job_material) if stl and stl.volume_ccm else None
return templates.TemplateResponse("job_detail.html", {
@@ -288,6 +290,7 @@ def job_detail(job_id: int, request: Request, background_tasks: BackgroundTasks,
"stl": stl,
"file_missing": file_missing,
"computing": computing,
+ "analysis_failed": job.file_name and is_failed(job.file_name),
"estimation": estimation,
"printers": db.query(Printer).order_by(Printer.name).all(),
"brackets": db.query(JobBracket).order_by(JobBracket.name).all(),
diff --git a/app/stl_analysis.py b/app/stl_analysis.py
index d2f8a20..fd3263c 100644
--- a/app/stl_analysis.py
+++ b/app/stl_analysis.py
@@ -1,6 +1,9 @@
+"""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
@@ -8,7 +11,27 @@ 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:
@@ -23,14 +46,7 @@ def analyse(filename: str, db: Session) -> StlCache | None:
return None
try:
- import trimesh
- loaded = trimesh.load(str(filepath))
- if isinstance(loaded, trimesh.Scene):
- mesh = trimesh.util.concatenate(list(loaded.geometry.values()))
- else:
- mesh = loaded
- extents = mesh.extents # [size_x, size_y, size_z] in mm
- volume_mm3 = abs(mesh.volume) # mm³ (negative for inverted normals)
+ extents, volume_mm3 = _load_geometry(str(filepath))
entry = StlCache(
filename=filename,
cube_x=round(float(extents[0]), 2),
@@ -46,18 +62,38 @@ def analyse(filename: str, db: Session) -> StlCache | None:
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:
- """Run analyse() in a background task with its own DB session."""
+ """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:
- from app.db import SessionLocal
- db = SessionLocal()
- try:
- analyse(filename, db)
- finally:
- db.close()
+ 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)
@@ -66,6 +102,10 @@ 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()
diff --git a/app/templates/job_detail.html b/app/templates/job_detail.html
index 8ef3049..08bcede 100644
--- a/app/templates/job_detail.html
+++ b/app/templates/job_detail.html
@@ -127,6 +127,34 @@
File {{ job.file_name }} not found on disk.
+ {% elif analysis_failed %}
+