From 6a1f146d8ecb1bf4a7b14d69809d9922eb6cee06 Mon Sep 17 00:00:00 2001 From: Martin Hohenberg Date: Fri, 19 Jun 2026 11:07:44 +0200 Subject: [PATCH] Background STL analysis with computing spinner; manual geometry override - analyse_in_background() runs in FastAPI BackgroundTasks with its own DB session; _in_progress set prevents duplicate concurrent analyses - Job detail shows a spinner + auto-reloads every 3s while computing - 'Compute now' button also triggers background analysis - StlCache.manually_set flag (migration 0011): entries marked manual are never overwritten by auto-analysis - 'Override values' collapsible form on job detail lets user set bounding box and volume directly from slicer data Co-Authored-By: Claude Sonnet 4.6 --- .../versions/0011_stl_cache_manually_set.py | 21 ++++++++ app/models/stl_cache.py | 3 +- app/routers/ui.py | 49 ++++++++++++++++--- app/stl_analysis.py | 21 ++++++++ app/templates/job_detail.html | 41 +++++++++++++++- 5 files changed, 126 insertions(+), 9 deletions(-) create mode 100644 alembic/versions/0011_stl_cache_manually_set.py diff --git a/alembic/versions/0011_stl_cache_manually_set.py b/alembic/versions/0011_stl_cache_manually_set.py new file mode 100644 index 0000000..fc06015 --- /dev/null +++ b/alembic/versions/0011_stl_cache_manually_set.py @@ -0,0 +1,21 @@ +"""stl_cache manually_set flag + +Revision ID: 0011 +Revises: 0010 +Create Date: 2026-06-19 +""" +from alembic import op +import sqlalchemy as sa + +revision = "0011" +down_revision = "0010" +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column("stl_cache", sa.Column("manually_set", sa.Boolean(), server_default="0", nullable=False)) + + +def downgrade(): + op.drop_column("stl_cache", "manually_set") diff --git a/app/models/stl_cache.py b/app/models/stl_cache.py index bba6379..caabc8d 100644 --- a/app/models/stl_cache.py +++ b/app/models/stl_cache.py @@ -1,6 +1,6 @@ from datetime import datetime -from sqlalchemy import DateTime, Float, String, func +from sqlalchemy import Boolean, DateTime, Float, String, func from sqlalchemy.orm import Mapped, mapped_column from app.models.base import Base @@ -15,4 +15,5 @@ class StlCache(Base): cube_y: Mapped[float | None] = mapped_column(Float) # mm cube_z: Mapped[float | None] = mapped_column(Float) # mm volume_ccm: Mapped[float | None] = mapped_column(Float) # cm³ + manually_set: Mapped[bool] = mapped_column(Boolean, default=False, server_default="0") created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) diff --git a/app/routers/ui.py b/app/routers/ui.py index 1f23768..01140f0 100644 --- a/app/routers/ui.py +++ b/app/routers/ui.py @@ -4,7 +4,7 @@ import uuid as _uuid from datetime import datetime, timedelta from pathlib import Path -from fastapi import APIRouter, Depends, File, Form, Request, UploadFile +from fastapi import APIRouter, BackgroundTasks, Depends, File, Form, Request, UploadFile from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse from fastapi.templating import Jinja2Templates from sqlalchemy import case, or_ @@ -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, get_cache_map # noqa: F401 used in job_detail route +from app.stl_analysis import analyse_in_background, get_cache_map, is_computing # noqa: F401 router = APIRouter() templates = Jinja2Templates(directory="app/templates") @@ -260,13 +260,14 @@ async def create_job( @router.get("/jobs/{job_id}", response_class=HTMLResponse) -def job_detail(job_id: int, request: Request, db: Session = Depends(get_db)): +def job_detail(job_id: int, request: Request, background_tasks: BackgroundTasks, db: Session = Depends(get_db)): from fastapi import HTTPException job = db.query(PrintJob).filter(PrintJob.id == job_id).first() if not job: raise HTTPException(status_code=404, detail="Job not found") file_missing = False + computing = False stl = None if job.file_name: file_path = get_stl_dir() / job.file_name @@ -275,7 +276,9 @@ def job_detail(job_id: int, request: Request, db: Session = Depends(get_db)): else: stl = db.query(StlCache).filter_by(filename=job.file_name).first() if stl is None and not job.file_name.endswith(".gcode"): - stl = analyse(job.file_name, db) + if not is_computing(job.file_name): + background_tasks.add_task(analyse_in_background, job.file_name) + computing = True estimation = estimate(stl.volume_ccm, job.job_material) if stl and stl.volume_ccm else None return templates.TemplateResponse("job_detail.html", { @@ -284,6 +287,7 @@ def job_detail(job_id: int, request: Request, db: Session = Depends(get_db)): "job": job, "stl": stl, "file_missing": file_missing, + "computing": computing, "estimation": estimation, "printers": db.query(Printer).order_by(Printer.name).all(), "brackets": db.query(JobBracket).order_by(JobBracket.name).all(), @@ -457,14 +461,47 @@ def finish_job(job_id: int, db: Session = Depends(get_db)): @router.post("/jobs/{job_id}/analyse") -def analyse_job_file(job_id: int, db: Session = Depends(get_db)): +def analyse_job_file(job_id: int, background_tasks: BackgroundTasks, db: Session = Depends(get_db)): from fastapi import HTTPException job = db.query(PrintJob).filter(PrintJob.id == job_id).first() if not job or not job.file_name: raise HTTPException(status_code=404, detail="Job or file not found") db.query(StlCache).filter_by(filename=job.file_name).delete() db.commit() - analyse(job.file_name, db) + background_tasks.add_task(analyse_in_background, job.file_name) + return RedirectResponse(url=f"/jobs/{job_id}", status_code=HTTP_303_SEE_OTHER) + + +@router.post("/jobs/{job_id}/geometry") +def set_geometry( + job_id: int, + volume_ccm: float = Form(...), + cube_x: float = Form(...), + cube_y: float = Form(...), + cube_z: float = Form(...), + db: Session = Depends(get_db), +): + from fastapi import HTTPException + job = db.query(PrintJob).filter(PrintJob.id == job_id).first() + if not job or not job.file_name: + raise HTTPException(status_code=404, detail="Job or file not found") + stl = db.query(StlCache).filter_by(filename=job.file_name).first() + if stl: + stl.volume_ccm = volume_ccm + stl.cube_x = cube_x + stl.cube_y = cube_y + stl.cube_z = cube_z + stl.manually_set = True + else: + db.add(StlCache( + filename=job.file_name, + volume_ccm=volume_ccm, + cube_x=cube_x, + cube_y=cube_y, + cube_z=cube_z, + manually_set=True, + )) + db.commit() return RedirectResponse(url=f"/jobs/{job_id}", status_code=HTTP_303_SEE_OTHER) diff --git a/app/stl_analysis.py b/app/stl_analysis.py index 2a0a33f..d2f8a20 100644 --- a/app/stl_analysis.py +++ b/app/stl_analysis.py @@ -8,6 +8,7 @@ from app.models.stl_cache import StlCache logger = logging.getLogger("pops") _ANALYSABLE = {".stl", ".3mf"} +_in_progress: set[str] = set() def analyse(filename: str, db: Session) -> StlCache | None: @@ -45,6 +46,26 @@ def analyse(filename: str, db: Session) -> StlCache | None: return None +def analyse_in_background(filename: str) -> None: + """Run analyse() in a background task with its own DB session.""" + if filename in _in_progress: + return + _in_progress.add(filename) + try: + from app.db import SessionLocal + db = SessionLocal() + try: + analyse(filename, db) + finally: + db.close() + finally: + _in_progress.discard(filename) + + +def is_computing(filename: str) -> bool: + return filename in _in_progress + + 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 21d93f3..8ef3049 100644 --- a/app/templates/job_detail.html +++ b/app/templates/job_detail.html @@ -127,17 +127,54 @@ File {{ job.file_name }} not found on disk. + {% elif computing %} +
+
+ Computing geometry… +
+ {% elif stl %} - +
- +
Bounding box {{ stl.cube_x|round(1) }} × {{ stl.cube_y|round(1) }} × {{ stl.cube_z|round(1) }} mm
Volume{{ stl.volume_ccm }} cm³
+ {{ stl.volume_ccm }} cm³ + {% if stl.manually_set %}manual{% endif %} +
+
+ Override values +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
{% elif job.file_name and job.file_name.endswith('.gcode') %}

Geometry not available for gcode files.

{% elif job.file_name %}