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 %}
+
| 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 %} + |
Geometry not available for gcode files.
{% elif job.file_name %}