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:
21
alembic/versions/0011_stl_cache_manually_set.py
Normal file
21
alembic/versions/0011_stl_cache_manually_set.py
Normal 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")
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -127,17 +127,54 @@
|
||||
<i class="bi bi-exclamation-triangle-fill me-2"></i>
|
||||
File <code>{{ job.file_name }}</code> not found on disk.
|
||||
</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 %}
|
||||
<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>
|
||||
<tr><td class="text-secondary border-0">Bounding box</td>
|
||||
<td class="border-0">
|
||||
{{ stl.cube_x|round(1) }} × {{ stl.cube_y|round(1) }} × {{ stl.cube_z|round(1) }} mm
|
||||
</td></tr>
|
||||
<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>
|
||||
</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') %}
|
||||
<p class="text-secondary small mb-0">Geometry not available for gcode files.</p>
|
||||
{% elif job.file_name %}
|
||||
|
||||
Reference in New Issue
Block a user