diff --git a/.env.install b/.env.install
index b90be90..96b024d 100644
--- a/.env.install
+++ b/.env.install
@@ -6,3 +6,5 @@ MYSQL_PASSWORD=
STL_UPLOAD_DIR=/data/stl
TZ=Europe/Berlin
FILAMENTDB_URL=https://web.filamentdb.orb.local
+VAT_RATE=19
+FILAMENT_PRICE_PER_KG=15
diff --git a/app/estimation.py b/app/estimation.py
new file mode 100644
index 0000000..80c274c
--- /dev/null
+++ b/app/estimation.py
@@ -0,0 +1,55 @@
+"""Job cost and time estimation from STL geometry."""
+import os
+
+# g/cm³ per material — used to convert volume → weight
+_DENSITY: dict[str, float] = {
+ "PLA": 1.24,
+ "PETG": 1.27,
+ "ABS": 1.04,
+ "ASA": 1.07,
+ "TPU": 1.20,
+ "Nylon": 1.14,
+ "PC": 1.20,
+}
+_DEFAULT_DENSITY = 1.24
+
+# Conservative average FDM print speed for weight-based time estimate
+_PRINT_SPEED_G_PER_HOUR = 20.0
+
+
+def estimate(volume_ccm: float, material: str | None) -> dict:
+ """
+ Returns a cost/time estimate dict for one job.
+
+ Assumptions:
+ - 20 % infill → material used = volume * 0.20
+ - filament density from _DENSITY (default 1.24 g/cm³)
+ - print speed: 20 g/h (conservative FDM average)
+ - filament cost: FILAMENT_PRICE_PER_KG EUR / kg (env, default 15)
+ - misprint buffer: +20 %
+ - gross profit margin: +30 %
+ - VAT: VAT_RATE % (env, default 19)
+ """
+ vat_rate = float(os.environ.get("VAT_RATE", 19))
+ price_per_kg = float(os.environ.get("FILAMENT_PRICE_PER_KG", 15))
+ density = _DENSITY.get(material or "", _DEFAULT_DENSITY)
+
+ material_ccm = volume_ccm * 0.20
+ weight_g = material_ccm * density
+ print_minutes = round((weight_g / _PRINT_SPEED_G_PER_HOUR) * 60)
+
+ filament_cost = weight_g / 1000 * price_per_kg
+ after_misprint = filament_cost * 1.20
+ net_price = after_misprint * 1.30
+ gross_price = net_price * (1 + vat_rate / 100)
+
+ return {
+ "material_ccm": round(material_ccm, 2),
+ "weight_g": round(weight_g, 1),
+ "print_minutes": print_minutes,
+ "filament_cost": round(filament_cost, 2),
+ "net_price": round(net_price, 2),
+ "gross_price": round(gross_price, 2),
+ "vat_rate": vat_rate,
+ "price_per_kg": price_per_kg,
+ }
diff --git a/app/routers/ui.py b/app/routers/ui.py
index 891ba38..3bfde9e 100644
--- a/app/routers/ui.py
+++ b/app/routers/ui.py
@@ -21,6 +21,7 @@ from app.models.printer_type import PrinterType
from app.models.pricing import PricingConfig
from app.models.todo import Todo
from app.constants import COLORS, MATERIALS
+from app.estimation import estimate
from app.filamentdb import available_colors, available_materials, get_stock, 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
@@ -247,11 +248,13 @@ def job_detail(job_id: int, request: Request, db: Session = Depends(get_db)):
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
+ 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,
+ "estimation": estimation,
"printers": db.query(Printer).order_by(Printer.name).all(),
"brackets": db.query(JobBracket).order_by(JobBracket.name).all(),
"materials": available_materials() or MATERIALS,
diff --git a/app/templates/job_detail.html b/app/templates/job_detail.html
index 39f0e8c..c2bbaaa 100644
--- a/app/templates/job_detail.html
+++ b/app/templates/job_detail.html
@@ -111,6 +111,66 @@
+{# ── Estimate ── #}
+{% if estimation %}
+
+
+
+
+
+
+
Material & Time
+
+
+ | Volume used |
+ {{ estimation.material_ccm }} cm³ |
+ | Weight |
+ {{ estimation.weight_g }} g |
+ | Print time |
+
+ {% set h = (estimation.print_minutes // 60) %}
+ {% set m = (estimation.print_minutes % 60) %}
+ {% if h %}{{ h }}h {% endif %}{{ m }}min
+ (est.)
+ |
+
+
+
+
+
+
Production cost
+
+
+ | Filament |
+ {{ "%.2f"|format(estimation.filament_cost) }} € |
+ | + 20 % misprint |
+ {{ "%.2f"|format(estimation.filament_cost * 1.2) }} € |
+
+
+
+
+
+
Suggested price
+
+
+ | Net (ex. VAT) |
+ {{ "%.2f"|format(estimation.net_price) }} € |
+ | Gross ({{ estimation.vat_rate }}% VAT) |
+ {{ "%.2f"|format(estimation.gross_price) }} € |
+
+
+
+
+
+
+
+{% endif %}
+
{# ── Log ── #}
Log