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 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())
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
Reference in New Issue
Block a user