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 <noreply@anthropic.com>
This commit is contained in:
Martin Hohenberg
2026-06-19 11:07:44 +02:00
parent fc13d905db
commit 6a1f146d8e
5 changed files with 126 additions and 9 deletions

View File

@@ -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")

View File

@@ -1,6 +1,6 @@
from datetime import datetime 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 sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base from app.models.base import Base
@@ -15,4 +15,5 @@ class StlCache(Base):
cube_y: Mapped[float | None] = mapped_column(Float) # mm cube_y: Mapped[float | None] = mapped_column(Float) # mm
cube_z: 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³ 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()) created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())

View File

@@ -4,7 +4,7 @@ import uuid as _uuid
from datetime import datetime, timedelta from datetime import datetime, timedelta
from pathlib import Path 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.responses import FileResponse, HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from sqlalchemy import case, or_ 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, from app.filamentdb import (available_colors, available_materials, find_matching_ams_roll,
get_stock, log_print_on_roll, spool_counts) get_stock, log_print_on_roll, spool_counts)
from app.stl import get_stl_dir, list_stl_files 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() router = APIRouter()
templates = Jinja2Templates(directory="app/templates") templates = Jinja2Templates(directory="app/templates")
@@ -260,13 +260,14 @@ async def create_job(
@router.get("/jobs/{job_id}", response_class=HTMLResponse) @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 from fastapi import HTTPException
job = db.query(PrintJob).filter(PrintJob.id == job_id).first() job = db.query(PrintJob).filter(PrintJob.id == job_id).first()
if not job: if not job:
raise HTTPException(status_code=404, detail="Job not found") raise HTTPException(status_code=404, detail="Job not found")
file_missing = False file_missing = False
computing = False
stl = None stl = None
if job.file_name: if job.file_name:
file_path = get_stl_dir() / 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: else:
stl = db.query(StlCache).filter_by(filename=job.file_name).first() stl = db.query(StlCache).filter_by(filename=job.file_name).first()
if stl is None and not job.file_name.endswith(".gcode"): 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 estimation = estimate(stl.volume_ccm, job.job_material) if stl and stl.volume_ccm else None
return templates.TemplateResponse("job_detail.html", { 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, "job": job,
"stl": stl, "stl": stl,
"file_missing": file_missing, "file_missing": file_missing,
"computing": computing,
"estimation": estimation, "estimation": estimation,
"printers": db.query(Printer).order_by(Printer.name).all(), "printers": db.query(Printer).order_by(Printer.name).all(),
"brackets": db.query(JobBracket).order_by(JobBracket.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") @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 from fastapi import HTTPException
job = db.query(PrintJob).filter(PrintJob.id == job_id).first() job = db.query(PrintJob).filter(PrintJob.id == job_id).first()
if not job or not job.file_name: if not job or not job.file_name:
raise HTTPException(status_code=404, detail="Job or file not found") raise HTTPException(status_code=404, detail="Job or file not found")
db.query(StlCache).filter_by(filename=job.file_name).delete() db.query(StlCache).filter_by(filename=job.file_name).delete()
db.commit() 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) return RedirectResponse(url=f"/jobs/{job_id}", status_code=HTTP_303_SEE_OTHER)

View File

@@ -8,6 +8,7 @@ from app.models.stl_cache import StlCache
logger = logging.getLogger("pops") logger = logging.getLogger("pops")
_ANALYSABLE = {".stl", ".3mf"} _ANALYSABLE = {".stl", ".3mf"}
_in_progress: set[str] = set()
def analyse(filename: str, db: Session) -> StlCache | None: def analyse(filename: str, db: Session) -> StlCache | None:
@@ -45,6 +46,26 @@ def analyse(filename: str, db: Session) -> StlCache | None:
return 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]: def get_cache_map(filenames: list[str], db: Session) -> dict[str, StlCache]:
"""Bulk-fetch cached entries for a list of filenames.""" """Bulk-fetch cached entries for a list of filenames."""
rows = db.query(StlCache).filter(StlCache.filename.in_(filenames)).all() rows = db.query(StlCache).filter(StlCache.filename.in_(filenames)).all()

View File

@@ -127,17 +127,54 @@
<i class="bi bi-exclamation-triangle-fill me-2"></i> <i class="bi bi-exclamation-triangle-fill me-2"></i>
File <code>{{ job.file_name }}</code> not found on disk. File <code>{{ job.file_name }}</code> not found on disk.
</div> </div>
{% elif computing %}
<div class="text-secondary small d-flex align-items-center gap-2" id="computing-state">
<div class="spinner-border spinner-border-sm text-secondary" role="status"></div>
Computing geometry…
</div>
<script>setTimeout(() => location.reload(), 3000);</script>
{% elif stl %} {% elif stl %}
<table class="table table-dark table-sm mb-0" style="font-size:.875rem"> <table class="table table-dark table-sm mb-2" style="font-size:.875rem">
<tbody> <tbody>
<tr><td class="text-secondary border-0">Bounding box</td> <tr><td class="text-secondary border-0">Bounding box</td>
<td class="border-0"> <td class="border-0">
{{ stl.cube_x|round(1) }} × {{ stl.cube_y|round(1) }} × {{ stl.cube_z|round(1) }} mm {{ stl.cube_x|round(1) }} × {{ stl.cube_y|round(1) }} × {{ stl.cube_z|round(1) }} mm
</td></tr> </td></tr>
<tr><td class="text-secondary">Volume</td> <tr><td class="text-secondary">Volume</td>
<td>{{ stl.volume_ccm }} cm³</td></tr> <td>
{{ stl.volume_ccm }} cm³
{% if stl.manually_set %}<span class="badge bg-secondary ms-1" style="font-size:.65rem">manual</span>{% endif %}
</td></tr>
</tbody> </tbody>
</table> </table>
<details class="mt-1" style="font-size:.8rem">
<summary class="text-secondary" style="cursor:pointer">Override values</summary>
<form method="POST" action="/jobs/{{ job.id }}/geometry" class="mt-2 d-flex flex-column gap-2">
<div class="d-flex gap-2 align-items-center">
<label class="text-secondary" style="width:70px">X mm</label>
<input type="number" step="0.1" name="cube_x" value="{{ stl.cube_x|round(2) }}"
class="form-control form-control-sm bg-dark border-secondary text-white" style="width:100px">
</div>
<div class="d-flex gap-2 align-items-center">
<label class="text-secondary" style="width:70px">Y mm</label>
<input type="number" step="0.1" name="cube_y" value="{{ stl.cube_y|round(2) }}"
class="form-control form-control-sm bg-dark border-secondary text-white" style="width:100px">
</div>
<div class="d-flex gap-2 align-items-center">
<label class="text-secondary" style="width:70px">Z mm</label>
<input type="number" step="0.1" name="cube_z" value="{{ stl.cube_z|round(2) }}"
class="form-control form-control-sm bg-dark border-secondary text-white" style="width:100px">
</div>
<div class="d-flex gap-2 align-items-center">
<label class="text-secondary" style="width:70px">Vol cm³</label>
<input type="number" step="0.001" name="volume_ccm" value="{{ stl.volume_ccm }}"
class="form-control form-control-sm bg-dark border-secondary text-white" style="width:100px">
</div>
<div>
<button type="submit" class="btn btn-sm btn-outline-secondary py-0 px-2">Save</button>
</div>
</form>
</details>
{% elif job.file_name and job.file_name.endswith('.gcode') %} {% elif job.file_name and job.file_name.endswith('.gcode') %}
<p class="text-secondary small mb-0">Geometry not available for gcode files.</p> <p class="text-secondary small mb-0">Geometry not available for gcode files.</p>
{% elif job.file_name %} {% elif job.file_name %}