Compare commits

..

19 Commits

Author SHA1 Message Date
Martin Hohenberg
ff2b618414 Duplicate job 2026-07-06 17:17:22 +02:00
Martin Hohenberg
38e6d38a2f Show open Vikunja task count as badge on To-Do sidebar link
Fetched async via /api/todos/count so it doesn't block page render.
Badge hidden when count is zero.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 19:16:19 +02:00
Martin Hohenberg
f713fbe2fb Count cancelled jobs as done in bracket progress bar
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 19:15:43 +02:00
Martin Hohenberg
7dec15d777 Exclude cancelled jobs from bracket progress bar count
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 12:26:02 +02:00
Martin Hohenberg
5270d96b92 Sort jobs: internal always last within each priority stratum
Order: priority customer > priority internal > normal customer > normal internal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 12:21:19 +02:00
Martin Hohenberg
5a61a35839 Default customer to 'internal' when left blank
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 12:19:52 +02:00
Martin Hohenberg
dc8918d323 Isolate STL analysis in child process; fix 3MF memory usage
- _load_geometry(): compute extents/volume from individual Scene meshes
  without concatenating — avoids the RAM spike that crashed the container
- analyse_in_background(): spawns a child process (spawn context, not fork)
  with a 120s timeout; OOM/crash in child no longer takes down the app
- _failed set: tracks files that timed out or crashed; spinner stops and
  a warning with inline override form is shown instead of looping forever
- analysis_failed state passed to template; override form shown immediately
  when analysis failed (no expand needed)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 11:32:15 +02:00
Martin Hohenberg
6a1f146d8e 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>
2026-06-19 11:07:44 +02:00
Martin Hohenberg
fc13d905db Add networkx and lxml for trimesh 3MF support
Both are required by trimesh's threemf loader as soft dependencies.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 10:59:43 +02:00
Martin Hohenberg
ab52721c7f Fix 3MF analysis: concatenate scene geometries instead of force="mesh"
trimesh loads 3MF files as a Scene; force="mesh" fails on those.
Detect Scene and concatenate all geometries before computing extents/volume.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 10:56:21 +02:00
Martin Hohenberg
1e74b3a149 Auto-analyse STL on job detail load; warn if file missing
- If file exists but has no cache entry, run analyse() on page load
- If file doesn't exist on disk, show a red alert in the File & Geometry card
- Hide Download/Compute buttons when file is missing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 10:54:21 +02:00
Martin Hohenberg
726a4d3c02 Fix UnboundLocalError for filament_low in start_job
Variable was only assigned inside a nested if-block; initialise to False
at the top of the filament-logging section so all code paths define it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 08:00:16 +02:00
Martin Hohenberg
5dfa700916 Tag all Vikunja tasks with #POPS label; fix Vikunja URL
- On create_task(), look up the 'POPS' label (create it if missing) and
  attach it to every task. Label ID is cached for the process lifetime.
- Corrected VIKUNJA_URL typo (tasks → task)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 07:58:20 +02:00
Martin Hohenberg
a2e8c9e194 Replace internal todos with Vikunja API wrapper
- New app/vikunja.py: get_tasks, create_task, update_task, delete_task
- /todos page now reads from and writes to Vikunja (create, mark done, delete)
- Auto-todos (clean build plate, filament low) post to Vikunja after DB commit
- /api/todos router proxies to Vikunja
- .env.install: VIKUNJA_URL, VIKUNJA_API_TOKEN, VIKUNJA_PROJECT_ID

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 07:40:54 +02:00
Martin Hohenberg
223a1b7f1e Auto-create todos on print events
- 'Clean build plate on {printer}' after every 3rd completed print per printer
- 'Check filament status' when a roll drops below 100 g after starting a job

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 07:37:16 +02:00
Martin Hohenberg
8ecace0493 Restyle job status badges
queued: black + yellow outline + yellow text
printing: yellow + black text
done: green (unchanged)
cancelled: grey
failed: red (unchanged)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 07:33:38 +02:00
Martin Hohenberg
0569cd4f02 Add printer cost (€0.35/h) to price estimation
Adds depreciation/electricity as a separate line item in the production
cost breakdown. Included in the markup base so it contributes to margin.
Configurable via PRINTER_COST_PER_HOUR env var (default 0.35).
Bracket gross total updates automatically.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 07:28:46 +02:00
Martin Hohenberg
c94eeb071f Show bracket gross total price on bracket filter view
Sums gross_price estimates for all bracket jobs with known STL volume
and displays it as a badge next to the bracket name.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 07:26:28 +02:00
Martin Hohenberg
cb5a2bc394 Add inline SVG logo to sidebar (nozzle + layers mark)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 05:49:51 +02:00
17 changed files with 601 additions and 82 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -6,6 +6,9 @@ MYSQL_PASSWORD=
STL_UPLOAD_DIR=/data/stl
TZ=Europe/Berlin
FILAMENTDB_URL=https://web.filamentdb.orb.local
VIKUNJA_URL=https://task.example.com
VIKUNJA_API_TOKEN=
VIKUNJA_PROJECT_ID=
VAT_RATE=19
FILAMENT_PRICE_PER_KG=15
MATERIAL_FACTOR=0.50

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

@@ -50,6 +50,7 @@ def estimate(volume_ccm: float, material: str | None) -> dict:
price_per_kg = float(os.environ.get("FILAMENT_PRICE_PER_KG", 15))
base_factor = float(os.environ.get("MATERIAL_FACTOR", 0.50))
print_speed = float(os.environ.get("PRINT_SPEED_G_PER_HOUR", 32))
printer_cost_per_hour = float(os.environ.get("PRINTER_COST_PER_HOUR", 0.35))
density = _DENSITY.get(material or "", _DEFAULT_DENSITY)
eff_factor = _effective_factor(volume_ccm, base_factor)
@@ -59,7 +60,8 @@ def estimate(volume_ccm: float, material: str | None) -> dict:
filament_cost = weight_g / 1000 * price_per_kg
after_misprint = filament_cost * 1.20
net_price = after_misprint * 1.30
printer_cost = (print_minutes / 60) * printer_cost_per_hour
net_price = (after_misprint + printer_cost) * 1.30
gross_price = net_price * (1 + vat_rate / 100)
return {
@@ -67,10 +69,12 @@ def estimate(volume_ccm: float, material: str | None) -> dict:
"weight_g": round(weight_g, 1),
"print_minutes": print_minutes,
"filament_cost": round(filament_cost, 2),
"printer_cost": round(printer_cost, 2),
"net_price": round(net_price, 2),
"gross_price": round(gross_price, 2),
"vat_rate": vat_rate,
"price_per_kg": price_per_kg,
"material_factor": eff_factor,
"print_speed": print_speed,
"printer_cost_per_hour": printer_cost_per_hour,
}

View File

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

View File

@@ -1,21 +1,30 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.db import get_db
from app.models.todo import Todo
from fastapi import APIRouter
from app.vikunja import create_task, delete_task, get_tasks, update_task
router = APIRouter(prefix="/todos", tags=["todos"])
@router.get("/count")
def count_todos():
tasks = get_tasks()
return {"open": sum(1 for t in tasks if not t.get("done"))}
@router.get("/")
def list_todos(db: Session = Depends(get_db)):
return [t.to_dict() for t in db.query(Todo).all()]
def list_todos():
return get_tasks()
@router.get("/{todo_id}")
def get_todo(todo_id: int, db: Session = Depends(get_db)):
todo = db.query(Todo).filter(Todo.id == todo_id).first()
if not todo:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Todo not found")
return todo.to_dict()
@router.post("/")
def create_todo(body: dict):
return create_task(body.get("title", ""))
@router.post("/{task_id}")
def update_todo(task_id: int, body: dict):
update_task(task_id, **body)
@router.delete("/{task_id}")
def delete_todo(task_id: int):
delete_task(task_id)

View File

@@ -1,10 +1,11 @@
import math
import os
import re
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_
@@ -20,13 +21,13 @@ from app.models.printer import Printer, PrinterStatus
from app.models.printer_log import PrinterLog
from app.models.printer_type import PrinterType
from app.models.pricing import PricingConfig
from app.models.todo import Todo
from app.vikunja import create_task as vikunja_create, delete_task as vikunja_delete, get_tasks as vikunja_tasks, update_task as vikunja_update
from app.constants import COLORS, MATERIALS
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, is_failed # noqa: F401
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
@@ -90,10 +91,17 @@ def printer_detail(printer_id: int, request: Request, db: Session = Depends(get_
printer = db.query(Printer).filter(Printer.id == printer_id).first()
if not printer:
raise HTTPException(status_code=404, detail="Printer not found")
current_job = (
db.query(PrintJob)
.filter(PrintJob.printer_id == printer_id, PrintJob.status == JobStatus.printing)
.order_by(PrintJob.started_at.desc())
.first()
)
return templates.TemplateResponse("printer_detail.html", {
"request": request,
"active": "printers",
"printer": printer,
"current_job": current_job,
})
@@ -155,13 +163,29 @@ def jobs_page(request: Request, q: str = "", bracket_id: str = "", show_all: str
or_(~PrintJob.status.in_(finished), PrintJob.created_at >= cutoff)
)
# Sort: printing → queued → rest, then priority, then oldest first
# Sort: printing → queued → rest, then priority flag, then internal last, then oldest first
status_order = case(
(PrintJob.status == JobStatus.printing, 0),
(PrintJob.status == JobStatus.queued, 1),
else_=2,
)
jobs = query.order_by(status_order, PrintJob.priority.desc(), PrintJob.created_at.asc()).all()
internal_last = case((PrintJob.customer == "internal", 1), else_=0)
jobs = query.order_by(status_order, PrintJob.priority.desc(), internal_last, PrintJob.created_at.asc()).all()
stl_files = list_stl_files()
stl_cache = get_cache_map(stl_files, db)
bracket_gross_total = None
if active_bracket:
total = 0.0
has_any = False
for j in active_bracket.jobs:
cached = stl_cache.get(j.file_name)
if cached and cached.volume_ccm:
total += estimate(cached.volume_ccm, j.job_material)["gross_price"]
has_any = True
if has_any:
bracket_gross_total = round(total, 2)
return templates.TemplateResponse("jobs.html", {
"request": request,
@@ -172,10 +196,11 @@ def jobs_page(request: Request, q: str = "", bracket_id: str = "", show_all: str
"show_all": bool(show_all),
"hidden_count": hidden_count,
"active_bracket": active_bracket,
"bracket_gross_total": bracket_gross_total,
"printers": db.query(Printer).order_by(Printer.name).all(),
"brackets": db.query(JobBracket).order_by(JobBracket.name).all(),
"stl_files": (stl_files := list_stl_files()),
"stl_cache": get_cache_map(stl_files, db),
"stl_files": stl_files,
"stl_cache": stl_cache,
"materials": available_materials() or MATERIALS,
"colors": available_colors() or COLORS,
"spool_map": spool_counts(),
@@ -205,7 +230,7 @@ async def create_job(
filename = stl_select
amount = max(1, amount)
customer_str = customer.strip() or None
customer_str = customer.strip() or "internal"
job_name = Path(filename).stem if filename else (customer_str or "Unnamed")
# Resolve bracket — auto-create one when printing multiple copies
@@ -244,18 +269,37 @@ 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")
stl = db.query(StlCache).filter_by(filename=job.file_name).first() if job.file_name else None
file_missing = False
computing = False
stl = None
if job.file_name:
file_path = get_stl_dir() / job.file_name
if not file_path.exists():
file_missing = True
else:
stl = db.query(StlCache).filter_by(filename=job.file_name).first()
if stl is None and not job.file_name.endswith(".gcode"):
if is_failed(job.file_name):
file_missing = False # file exists, analysis just failed
elif not is_computing(job.file_name):
background_tasks.add_task(analyse_in_background, job.file_name)
computing = not is_failed(job.file_name)
estimation = estimate(stl.volume_ccm, job.job_material) if stl and stl.volume_ccm else None
return templates.TemplateResponse("job_detail.html", {
"request": request,
"active": "jobs",
"job": job,
"stl": stl,
"file_missing": file_missing,
"computing": computing,
"analysis_failed": job.file_name and is_failed(job.file_name),
"estimation": estimation,
"printers": db.query(Printer).order_by(Printer.name).all(),
"brackets": db.query(JobBracket).order_by(JobBracket.name).all(),
@@ -285,7 +329,7 @@ def edit_job(
job = db.query(PrintJob).filter(PrintJob.id == job_id).first()
if not job:
raise HTTPException(status_code=404, detail="Job not found")
job.customer = customer.strip() or None
job.customer = customer.strip() or "internal"
job.job_material = job_material or None
job.job_color = job_color or None
job.priority = bool(priority)
@@ -299,6 +343,45 @@ def edit_job(
return RedirectResponse(url=f"/jobs/{job_id}", status_code=HTTP_303_SEE_OTHER)
@router.post("/jobs/{job_id}/duplicate")
def duplicate_job(job_id: int, db: Session = Depends(get_db)):
from fastapi import HTTPException
job = db.query(PrintJob).filter(PrintJob.id == job_id).first()
if not job:
raise HTTPException(404, "Job not found")
copy = PrintJob(
job_uuid=str(_uuid.uuid4()),
name=job.name,
file_name=job.file_name,
printer_id=None,
bracket_id=job.bracket_id,
customer=job.customer,
job_material=job.job_material,
job_color=job.job_color,
priority=job.priority,
status=JobStatus.queued,
notes=job.notes,
)
db.add(copy)
# If the original was in a bracket, extend the "×N" count in its name to match.
if job.bracket and job.bracket.name:
job.bracket.name = re.sub(
r"×(\d+)\s*$",
lambda m: f"×{int(m.group(1)) + 1}",
job.bracket.name,
)
db.flush()
db.add(JobLog(job_id=copy.id, message=f"Job created (duplicate of #{job.id})"))
db.commit()
if copy.bracket_id:
return RedirectResponse(url=f"/jobs?bracket_id={copy.bracket_id}", status_code=HTTP_303_SEE_OTHER)
return RedirectResponse(url="/jobs", status_code=HTTP_303_SEE_OTHER)
@router.get("/files/{filename}")
def download_file(filename: str):
from fastapi import HTTPException
@@ -329,6 +412,7 @@ def start_job(job_id: int, printer_id: int = Form(...), db: Session = Depends(ge
db.add(JobLog(job_id=job_id, message=f"Job started on {printer.name}"))
# Log filament usage against matching AMS roll in FilamentDB
filament_low = False
if job.job_material and job.job_color:
stl = db.query(StlCache).filter_by(filename=job.file_name).first() if job.file_name else None
if stl and stl.volume_ccm:
@@ -343,6 +427,8 @@ def start_job(job_id: int, printer_id: int = Form(...), db: Session = Depends(ge
f"(AMS slot {roll['slot']}{roll['material']} {roll['color']}, "
f"{roll['weight_remaining_g']} g remaining, least-full roll selected)"
)))
if roll["weight_remaining_g"] - weight_g < 100:
filament_low = True
else:
db.add(JobLog(job_id=job_id, message=f"FilamentDB: print log failed for roll #{roll['roll_id']}"))
else:
@@ -351,6 +437,8 @@ def start_job(job_id: int, printer_id: int = Form(...), db: Session = Depends(ge
)))
db.commit()
if filament_low:
vikunja_create("Check filament status")
return RedirectResponse(url=f"/jobs/{job_id}", status_code=HTTP_303_SEE_OTHER)
@@ -406,19 +494,65 @@ def finish_job(job_id: int, db: Session = Depends(get_db)):
job.printer.status = PrinterStatus.idle
db.add(JobLog(job_id=job_id, message=f"Job finished on {printer_name}"))
# Every 3rd completed print on this printer → cleaning reminder
cleaning_todo = False
if job.printer_id:
done_count = db.query(PrintJob).filter(
PrintJob.printer_id == job.printer_id,
PrintJob.status == JobStatus.done,
).count()
if (done_count + 1) % 3 == 0:
cleaning_todo = True
db.commit()
if cleaning_todo:
vikunja_create(f"Clean build plate on {printer_name}")
return RedirectResponse(url=f"/jobs/{job_id}", status_code=HTTP_303_SEE_OTHER)
@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)
@@ -457,17 +591,37 @@ def bracket_detail_redirect(bracket_id: int):
return RedirectResponse(url=f"/jobs?bracket_id={bracket_id}", status_code=HTTP_303_SEE_OTHER)
# ── To-Do ──────────────────────────────────────────────────────────────────────
# ── To-Do (Vikunja) ────────────────────────────────────────────────────────────
@router.get("/todos", response_class=HTMLResponse)
def todos_page(request: Request, db: Session = Depends(get_db)):
def todos_page(request: Request):
tasks = vikunja_tasks()
tasks.sort(key=lambda t: (t.get("done", False), t.get("created", "")), reverse=False)
return templates.TemplateResponse("todos.html", {
"request": request,
"active": "todos",
"todos": db.query(Todo).order_by(Todo.done, Todo.created_at.desc()).all(),
"todos": tasks,
})
@router.post("/todos")
def create_todo(title: str = Form(...)):
vikunja_create(title.strip())
return RedirectResponse(url="/todos", status_code=HTTP_303_SEE_OTHER)
@router.post("/todos/{task_id}/done")
def done_todo(task_id: int):
vikunja_update(task_id, done=True)
return RedirectResponse(url="/todos", status_code=HTTP_303_SEE_OTHER)
@router.post("/todos/{task_id}/delete")
def delete_todo(task_id: int):
vikunja_delete(task_id)
return RedirectResponse(url="/todos", status_code=HTTP_303_SEE_OTHER)
# ── Pricing ────────────────────────────────────────────────────────────────────
@router.get("/pricing", response_class=HTMLResponse)

View File

@@ -1,6 +1,9 @@
"""STL/3MF geometry analysis with background process isolation."""
import logging
import multiprocessing as _mp
from pathlib import Path
import numpy as np
from sqlalchemy.orm import Session
from app.models.stl_cache import StlCache
@@ -8,6 +11,27 @@ from app.models.stl_cache import StlCache
logger = logging.getLogger("pops")
_ANALYSABLE = {".stl", ".3mf"}
_TIMEOUT = 120 # seconds before we give up
_in_progress: set[str] = set()
_failed: set[str] = set()
def _load_geometry(filepath: str):
"""Return (extents_array, volume_mm3). Handles Scene without concatenating."""
import trimesh
loaded = trimesh.load(filepath)
if isinstance(loaded, trimesh.Scene):
meshes = list(loaded.geometry.values())
if not meshes:
raise ValueError("3MF scene contains no meshes")
bounds = np.array([m.bounds for m in meshes]) # (N, 2, 3)
extents = bounds[:, 1, :].max(axis=0) - bounds[:, 0, :].min(axis=0)
volume_mm3 = sum(abs(float(m.volume)) for m in meshes)
else:
extents = np.array(loaded.extents)
volume_mm3 = abs(float(loaded.volume))
return extents, volume_mm3
def analyse(filename: str, db: Session) -> StlCache | None:
@@ -22,10 +46,7 @@ def analyse(filename: str, db: Session) -> StlCache | None:
return None
try:
import trimesh
mesh = trimesh.load(str(filepath), force="mesh")
extents = mesh.extents # [size_x, size_y, size_z] in mm
volume_mm3 = abs(mesh.volume) # mm³ (negative for inverted normals)
extents, volume_mm3 = _load_geometry(str(filepath))
entry = StlCache(
filename=filename,
cube_x=round(float(extents[0]), 2),
@@ -41,6 +62,50 @@ def analyse(filename: str, db: Session) -> StlCache | None:
return None
def _worker(filename: str) -> None:
"""Child process entry point — creates its own DB session."""
from app.db import SessionLocal
db = SessionLocal()
try:
analyse(filename, db)
finally:
db.close()
def analyse_in_background(filename: str) -> None:
"""Spawn an isolated child process for analysis; kill it after _TIMEOUT."""
if filename in _in_progress:
return
_in_progress.add(filename)
_failed.discard(filename)
try:
ctx = _mp.get_context("spawn")
p = ctx.Process(target=_worker, args=(filename,), daemon=True)
p.start()
p.join(timeout=_TIMEOUT)
if p.is_alive():
logger.warning("STL analysis timed out for %s — killing process", filename)
p.kill()
p.join()
_failed.add(filename)
elif p.exitcode != 0:
logger.warning("STL analysis process exited with code %s for %s", p.exitcode, filename)
_failed.add(filename)
except Exception:
logger.exception("STL background task error for %s", filename)
_failed.add(filename)
finally:
_in_progress.discard(filename)
def is_computing(filename: str) -> bool:
return filename in _in_progress
def is_failed(filename: str) -> bool:
return filename in _failed
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()

View File

@@ -37,7 +37,17 @@
<nav id="sidebar" class="p-3 d-flex flex-column gap-3">
<a href="/" class="brand text-decoration-none pb-3">
<div class="d-flex align-items-center gap-2">
<i class="bi bi-printer-fill text-primary fs-5"></i>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="#58a6ff">
<!-- extruder head -->
<rect x="7" y="1" width="10" height="5" rx="1.5"/>
<!-- nozzle taper -->
<path d="M8 6h8l-2.5 5.5h-3z"/>
<!-- filament bead -->
<circle cx="12" cy="13.5" r="1.25"/>
<!-- printed layers -->
<rect x="1.5" y="16.5" width="21" height="2.5" rx="1.25"/>
<rect x="1.5" y="21" width="21" height="2" rx="1" fill-opacity="0.55"/>
</svg>
<div>
<div class="text-white fw-semibold lh-sm">POPS</div>
<div class="text-secondary lh-sm" style="font-size:.68rem;letter-spacing:.03em">PRINT FARM MANAGER</div>
@@ -50,7 +60,7 @@
<li><a href="/printers" class="nav-link {% if active == 'printers' %}active{% endif %}"><i class="bi bi-printer me-2"></i>Printers</a></li>
<li><a href="/jobs" class="nav-link {% if active == 'jobs' %}active{% endif %}"><i class="bi bi-list-task me-2"></i>Jobs</a></li>
<li><a href="/brackets" class="nav-link {% if active == 'brackets' %}active{% endif %}"><i class="bi bi-collection me-2"></i>Brackets</a></li>
<li><a href="/todos" class="nav-link {% if active == 'todos' %}active{% endif %}"><i class="bi bi-check2-square me-2"></i>To-Do</a></li>
<li><a href="/todos" class="nav-link {% if active == 'todos' %}active{% endif %} d-flex align-items-center justify-content-between"><span><i class="bi bi-check2-square me-2"></i>To-Do</span><span id="todo-count-badge" class="badge rounded-pill bg-primary ms-1" style="display:none!important"></span></a></li>
<li><a href="/pricing" class="nav-link {% if active == 'pricing' %}active{% endif %}"><i class="bi bi-currency-euro me-2"></i>Pricing</a></li>
</ul>
</nav>
@@ -61,5 +71,14 @@
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
fetch('/api/todos/count').then(r => r.json()).then(d => {
if (d.open > 0) {
const b = document.getElementById('todo-count-badge');
b.textContent = d.open;
b.style.removeProperty('display');
}
}).catch(() => {});
</script>
</body>
</html>

View File

@@ -23,7 +23,7 @@
<tbody>
{% for b in brackets %}
{% set total = b.jobs | length %}
{% set done = b.jobs | selectattr('status.value', 'equalto', 'done') | list | length %}
{% set done = b.jobs | selectattr('status.value', 'in', ['done', 'cancelled']) | list | length %}
{% set pct = (done / total * 100) | int if total else 0 %}
<tr>
<td><a href="/jobs?bracket_id={{ b.id }}" class="text-white text-decoration-none fw-medium">{{ b.name }}</a></td>

View File

@@ -86,11 +86,11 @@
<td class="text-secondary small">{{ j.printer.name if j.printer else '—' }}</td>
<td>
{% set s = j.status.value %}
{% if s == 'printing' %}<span class="badge bg-primary">printing</span>
{% if s == 'printing' %}<span class="badge bg-warning text-dark">printing</span>
{% elif s == 'done' %}<span class="badge bg-success">done</span>
{% elif s == 'failed' %}<span class="badge bg-danger">failed</span>
{% elif s == 'cancelled' %}<span class="badge bg-warning text-dark">cancelled</span>
{% else %}<span class="badge bg-secondary">queued</span>
{% elif s == 'cancelled' %}<span class="badge bg-secondary">cancelled</span>
{% else %}<span class="badge border border-warning text-warning" style="background:#000">queued</span>
{% endif %}
</td>
<td class="text-secondary small text-end">{{ j.created_at.strftime('%d.%m %H:%M') }}</td>

View File

@@ -11,11 +11,11 @@
<h5 class="text-white mb-0">{{ job.file_name or job.name }}</h5>
{% set s = job.status.value %}
{% if job.priority %}<span class="badge bg-warning text-dark"><i class="bi bi-exclamation-circle me-1"></i>Priority</span>{% endif %}
{% if s == 'printing' %}<span class="badge bg-primary">printing</span>
{% if s == 'printing' %}<span class="badge bg-warning text-dark">printing</span>
{% elif s == 'done' %}<span class="badge bg-success">done</span>
{% elif s == 'failed' %}<span class="badge bg-danger">failed</span>
{% elif s == 'cancelled' %}<span class="badge bg-warning text-dark">cancelled</span>
{% else %}<span class="badge bg-secondary">queued</span>
{% elif s == 'cancelled' %}<span class="badge bg-secondary">cancelled</span>
{% else %}<span class="badge border border-warning text-warning" style="background:#000">queued</span>
{% endif %}
</div>
<div class="text-secondary small font-monospace mt-1">{{ job.job_uuid or '—' }}</div>
@@ -103,7 +103,7 @@
<div class="card h-100">
<div class="card-header border-secondary d-flex align-items-center justify-content-between" style="border-color:#21262d">
<span class="text-secondary small text-uppercase fw-normal">File &amp; Geometry</span>
{% if job.file_name %}
{% if job.file_name and not file_missing %}
<div class="d-flex gap-2">
<a href="/files/{{ job.file_name }}" class="btn btn-outline-primary btn-sm py-0 px-2" style="font-size:.75rem" download>
<i class="bi bi-download me-1"></i>Download
@@ -122,21 +122,91 @@
{% if job.file_name %}
<div class="text-white mb-3 font-monospace" style="font-size:.85rem">{{ job.file_name }}</div>
{% endif %}
{% if stl %}
<table class="table table-dark table-sm mb-0" style="font-size:.875rem">
{% if file_missing %}
<div class="alert alert-danger py-2 px-3 mb-0" style="font-size:.875rem">
<i class="bi bi-exclamation-triangle-fill me-2"></i>
File <code>{{ job.file_name }}</code> not found on disk.
</div>
{% elif analysis_failed %}
<div class="alert alert-warning py-2 px-3 mb-2" style="font-size:.875rem">
<i class="bi bi-exclamation-triangle me-2"></i>
Analysis failed or timed out — enter values from your slicer below.
</div>
<form method="POST" action="/jobs/{{ job.id }}/geometry" class="d-flex flex-column gap-2" style="font-size:.8rem">
<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="" required
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="" required
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="" required
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="" required
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>
{% 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-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 %}
<p class="text-secondary small mb-0">Not yet analysed.</p>
<p class="text-secondary small mb-0">Analysis failed — check file format.</p>
{% else %}
<p class="text-secondary small mb-0">No file attached.</p>
{% endif %}
@@ -155,6 +225,7 @@
{{ (estimation.material_factor * 100) | round | int }}% material factor
· {{ estimation.print_speed | int }} g/h
· {{ estimation.price_per_kg }} €/kg
· {{ estimation.printer_cost_per_hour }} €/h printer
· {{ estimation.vat_rate }}% VAT
</span>
</div>
@@ -188,6 +259,8 @@
<td class="border-0">{{ "%.2f"|format(estimation.filament_cost) }} €</td></tr>
<tr><td class="text-secondary">+ 20 % misprint</td>
<td>{{ "%.2f"|format(estimation.filament_cost * 1.2) }} €</td></tr>
<tr><td class="text-secondary">Printer ({{ estimation.printer_cost_per_hour }} €/h)</td>
<td>{{ "%.2f"|format(estimation.printer_cost) }} €</td></tr>
</tbody>
</table>
</div>

View File

@@ -26,6 +26,11 @@
<span class="badge bg-secondary fs-6 fw-normal px-3 py-2">
<i class="bi bi-collection me-1"></i>{{ active_bracket.name }}
</span>
{% if bracket_gross_total is not none %}
<span class="badge bg-dark border border-secondary fs-6 fw-normal px-3 py-2 text-white">
<i class="bi bi-currency-euro me-1 text-secondary"></i>{{ "%.2f"|format(bracket_gross_total) }} <span class="text-secondary fw-normal" style="font-size:.75em">gross total</span>
</span>
{% endif %}
<a href="/jobs{% if q %}?q={{ q }}{% endif %}" class="text-secondary text-decoration-none small">× clear filter</a>
</div>
{% elif q %}
@@ -45,6 +50,7 @@
<th class="fw-normal">Material</th>
<th class="fw-normal">Status</th>
<th class="fw-normal text-end">Created</th>
<th class="fw-normal"></th>
</tr>
</thead>
<tbody>
@@ -74,14 +80,21 @@
</td>
<td>
{% set s = j.status.value %}
{% if s == 'printing' %}<span class="badge bg-primary">printing</span>
{% if s == 'printing' %}<span class="badge bg-warning text-dark">printing</span>
{% elif s == 'done' %}<span class="badge bg-success">done</span>
{% elif s == 'failed' %}<span class="badge bg-danger">failed</span>
{% elif s == 'cancelled' %}<span class="badge bg-warning text-dark">cancelled</span>
{% else %}<span class="badge bg-secondary">queued</span>
{% elif s == 'cancelled' %}<span class="badge bg-secondary">cancelled</span>
{% else %}<span class="badge border border-warning text-warning" style="background:#000">queued</span>
{% endif %}
</td>
<td class="text-secondary text-end">{{ j.created_at.strftime('%d.%m.%Y %H:%M') }}</td>
<td class="text-end" style="width:32px">
<form method="POST" action="/jobs/{{ j.id }}/duplicate" class="d-inline">
<button type="submit" class="btn btn-sm btn-link text-secondary p-0" title="Duplicate job">
<i class="bi bi-files"></i>
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>

View File

@@ -16,6 +16,23 @@
{% endif %}
</div>
{# ── Currently printing ── #}
{% if current_job %}
<a href="/jobs/{{ current_job.id }}" class="text-decoration-none">
<div class="card mb-4 border-primary">
<div class="card-body d-flex align-items-center gap-3">
<i class="bi bi-printer text-primary fs-4"></i>
<div>
<div class="text-secondary small text-uppercase">Currently printing</div>
<div class="text-white fw-semibold">{{ current_job.file_name or current_job.name or '—' }}</div>
{% if current_job.customer %}<div class="text-secondary small">{{ current_job.customer }}</div>{% endif %}
</div>
<i class="bi bi-chevron-right text-secondary ms-auto"></i>
</div>
</div>
</a>
{% endif %}
<div class="row g-3 mb-4">
{# ── Info card ── #}

View File

@@ -2,39 +2,63 @@
{% block title %}To-Do — POPS{% endblock %}
{% block content %}
<h5 class="text-white mb-4">To-Do</h5>
<div class="d-flex align-items-center justify-content-between mb-4">
<h5 class="text-white mb-0">To-Do</h5>
</div>
{# ── Add task ── #}
<form method="POST" action="/todos" class="mb-3 d-flex gap-2" style="max-width:480px">
<input type="text" name="title" required
class="form-control form-control-sm bg-dark border-secondary text-white"
placeholder="New task…">
<button type="submit" class="btn btn-sm btn-primary px-3">Add</button>
</form>
<div class="card">
{% if todos %}
<table class="table table-dark table-hover mb-0 align-middle">
<table class="table table-dark table-hover mb-0 align-middle" style="font-size:.875rem">
<thead>
<tr class="text-secondary small">
<th class="fw-normal" style="width:32px"></th>
<th class="fw-normal">Task</th>
<th class="fw-normal">Printer</th>
<th class="fw-normal text-center">Done</th>
<th class="fw-normal text-end">Created</th>
<th class="fw-normal" style="width:40px"></th>
</tr>
</thead>
<tbody>
{% for t in todos %}
<tr class="{% if t.done %}opacity-50{% endif %}">
<td class="{% if t.done %}text-decoration-line-through text-secondary{% else %}text-white{% endif %}">
{{ t.title }}
</td>
<td class="text-secondary small">{{ t.printer.name if t.printer else '—' }}</td>
<td class="text-center">
{% if t.done %}
{% set done = t.done %}
<tr class="{% if done %}opacity-50{% endif %}">
<td>
{% if done %}
<i class="bi bi-check-circle-fill text-success"></i>
{% else %}
<i class="bi bi-circle text-secondary"></i>
<form method="POST" action="/todos/{{ t.id }}/done" class="m-0">
<button type="submit" class="btn btn-link p-0 text-secondary" title="Mark done">
<i class="bi bi-circle"></i>
</button>
</form>
{% endif %}
</td>
<td class="text-secondary small text-end">{{ t.created_at.strftime('%d.%m.%Y') }}</td>
<td class="{% if done %}text-decoration-line-through text-secondary{% else %}text-white{% endif %}">
{{ t.title }}
</td>
<td class="text-secondary small text-end text-nowrap">
{{ t.created[:10] if t.created else '—' }}
</td>
<td>
<form method="POST" action="/todos/{{ t.id }}/delete" class="m-0 text-end">
<button type="submit" class="btn btn-link p-0 text-secondary" title="Delete">
<i class="bi bi-trash3"></i>
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="card-body text-secondary">No to-do items yet.</div>
<div class="card-body text-secondary">No tasks. Nice.</div>
{% endif %}
</div>
{% endblock %}

114
app/vikunja.py Normal file
View File

@@ -0,0 +1,114 @@
"""Vikunja task API client."""
import logging
import os
import httpx
logger = logging.getLogger("pops")
_pops_label_id: int | None = None
def _base() -> str:
return os.environ.get("VIKUNJA_URL", "").rstrip("/")
def _headers() -> dict:
return {
"Authorization": f"Bearer {os.environ.get('VIKUNJA_API_TOKEN', '')}",
"Content-Type": "application/json",
}
def _project_id() -> int:
return int(os.environ.get("VIKUNJA_PROJECT_ID", 0))
def _get_or_create_pops_label() -> int | None:
"""Return the ID of the 'POPS' label, creating it if it doesn't exist."""
global _pops_label_id
if _pops_label_id is not None:
return _pops_label_id
try:
r = httpx.get(f"{_base()}/api/v1/labels", headers=_headers(), timeout=5)
r.raise_for_status()
for label in r.json() or []:
if label.get("title") == "POPS":
_pops_label_id = label["id"]
return _pops_label_id
# Not found — create it
r = httpx.put(
f"{_base()}/api/v1/labels",
headers=_headers(),
json={"title": "POPS"},
timeout=5,
)
r.raise_for_status()
_pops_label_id = r.json()["id"]
return _pops_label_id
except Exception:
logger.warning("Vikunja: failed to get or create POPS label")
return None
def get_tasks() -> list[dict]:
try:
r = httpx.get(
f"{_base()}/api/v1/projects/{_project_id()}/tasks",
headers=_headers(),
params={"sort_by": "created", "order_by": "desc"},
timeout=5,
)
r.raise_for_status()
return r.json() or []
except Exception:
logger.warning("Vikunja: failed to fetch tasks")
return []
def create_task(title: str) -> dict | None:
label_id = _get_or_create_pops_label()
payload: dict = {"title": title}
if label_id:
payload["labels"] = [{"id": label_id}]
try:
r = httpx.put(
f"{_base()}/api/v1/projects/{_project_id()}/tasks",
headers=_headers(),
json=payload,
timeout=5,
)
r.raise_for_status()
return r.json()
except Exception:
logger.warning("Vikunja: failed to create task %r", title)
return None
def update_task(task_id: int, **fields) -> bool:
try:
r = httpx.post(
f"{_base()}/api/v1/tasks/{task_id}",
headers=_headers(),
json=fields,
timeout=5,
)
r.raise_for_status()
return True
except Exception:
logger.warning("Vikunja: failed to update task %s", task_id)
return False
def delete_task(task_id: int) -> bool:
try:
r = httpx.delete(
f"{_base()}/api/v1/tasks/{task_id}",
headers=_headers(),
timeout=5,
)
r.raise_for_status()
return True
except Exception:
logger.warning("Vikunja: failed to delete task %s", task_id)
return False

View File

@@ -7,4 +7,6 @@ cryptography==44.0.3
jinja2==3.1.4
python-multipart==0.0.20
trimesh==4.5.3
networkx
lxml
httpx==0.28.1