diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..5196914
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+notes/
+__pycache__/
diff --git a/README.md b/README.md
index 1f4e7a2..5d382b2 100644
--- a/README.md
+++ b/README.md
@@ -6,11 +6,14 @@ Command line and optional web tool to estimate filament usage and print time for
```bash
python estimator.py --config printer-config.json --output-format csv --out estimates.csv
+python estimator.py --config printer-config.json --output-format xlsx --out estimates.xlsx
```
Defaults:
+
- Reads all `.stl` files in the current directory (non-recursive).
-- Writes `estimates.csv` or `estimates.json` if `--out` is not provided.
+- Writes `estimates.csv`, `estimates.json`, or `estimates.xlsx` if `--out` is not provided.
+- XLSX output requires `openpyxl` (installed via `pip install -r requirements.txt`).
## Web interface
@@ -19,7 +22,7 @@ pip install -r requirements.txt
python estimator.py --serve --port 5000 --config printer-config.json --output-format csv
```
-Open `http://localhost:5000`, upload a single STL or a ZIP containing multiple STLs, choose CSV or JSON, and optionally tick “Provide download” to get the generated file.
+Open `http://localhost:5000`, upload a single STL or a ZIP containing multiple STLs, choose CSV, JSON, or XLSX, and optionally tick “Provide download” to get the generated file.
## Configuration
@@ -50,6 +53,11 @@ Adjust values to match your machine and material. `travel_factor` is the fractio
- Shell volume is approximated from surface area and perimeter thickness; infill volume is scaled by `infill_density`.
- Print time is a heuristic using perimeter/infill/travel speeds; real slicer output will differ.
+## XLSX layout
+
+- The spreadsheet has columns for File, Filament Length (mm), Filament Mass (g), Estimated Print Time (min), and a time string in hr/min.
+- Totals are placed in columns F/G with formulas: total filament mass (SUM of column C) and total time (SUM of column D converted to hr/min via the same formula used per row).
+
## Examples
- JSON output: `python estimator.py -f json -o result.json`
diff --git a/estimator.py b/estimator.py
index b094e96..38224e9 100644
--- a/estimator.py
+++ b/estimator.py
@@ -1,4 +1,5 @@
import argparse
+import base64
import csv
import io
import json
@@ -7,17 +8,15 @@ import os
import struct
import sys
import tempfile
+import types
import zipfile
from dataclasses import dataclass, asdict
from pathlib import Path
from typing import Iterable, List, Tuple, Dict, Any, Optional
-try:
- from flask import Flask, request, render_template_string
-except Exception: # Flask is optional for CLI usage
- Flask = None # type: ignore
- request = None # type: ignore
- render_template_string = None # type: ignore
+# Some bundled tools (e.g., MySQL Workbench) inject their own Python stdlib zip into sys.path.
+# That can ship an incompatible ctypes build and crash Flask/colorama imports. Strip it out early.
+sys.path = [p for p in sys.path if "MySQL Workbench" not in p]
Vec3 = Tuple[float, float, float]
@@ -63,8 +62,8 @@ class Estimate:
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Estimate filament usage and print time for STL files.")
parser.add_argument("--config", "-c", default="printer-config.json", help="Path to printer configuration JSON.")
- parser.add_argument("--output-format", "-f", choices=["csv", "json"], default="csv", help="Output format.")
- parser.add_argument("--out", "-o", default=None, help="Output file path. Defaults to estimates.csv/json.")
+ parser.add_argument("--output-format", "-f", choices=["csv", "json", "xlsx"], default="csv", help="Output format.")
+ parser.add_argument("--out", "-o", default=None, help="Output file path. Defaults to estimates.csv/json/xlsx.")
parser.add_argument("--serve", action="store_true", help="Run web interface instead of CLI batch mode.")
parser.add_argument("--port", type=int, default=5000, help="Port for the web interface.")
return parser.parse_args()
@@ -225,6 +224,8 @@ def write_output(estimates: List[Estimate], fmt: str, out_path: Path) -> None:
if fmt == "json":
with out_path.open("w", encoding="utf-8") as f:
json.dump([asdict(e) for e in estimates], f, indent=2)
+ elif fmt == "xlsx":
+ write_xlsx(estimates, out_path)
else:
with out_path.open("w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=list(asdict(estimates[0]).keys()))
@@ -233,6 +234,159 @@ def write_output(estimates: List[Estimate], fmt: str, out_path: Path) -> None:
writer.writerow(asdict(e))
+def minutes_to_hours_formula(expr: str) -> str:
+ return f'INT({expr}/60) & " hr" & IF(ROUND(MOD({expr},60),0)>0, " " & ROUND(MOD({expr},60),0) & " min","")'
+
+
+def minutes_to_hours_formula_text(minutes: float) -> str:
+ hours = int(minutes // 60)
+ mins = int(round(minutes % 60))
+ if mins > 0:
+ return f"{hours} hr {mins} min" if hours else f"{mins} min"
+ return f"{hours} hr"
+
+
+def require_openpyxl():
+ block_broken_numpy()
+ try:
+ import openpyxl # type: ignore
+ except Exception:
+ print("XLSX output requires openpyxl. Install it with: pip install openpyxl", file=sys.stderr)
+ sys.exit(1)
+ return openpyxl
+
+
+def block_broken_numpy():
+ # Some environments ship a corrupted/bundled numpy (e.g., via other apps) that can crash ctypes.
+ # openpyxl only needs numpy optionally; force it to skip by blocking the import.
+ class _Blocker:
+ def find_spec(self, fullname, path=None, target=None):
+ if fullname.startswith("numpy"):
+ raise ImportError("Blocked numpy to avoid crashing openpyxl")
+ return None
+
+ sys.meta_path.insert(0, _Blocker())
+ for key in list(sys.modules.keys()):
+ if key.startswith("numpy"):
+ sys.modules.pop(key, None)
+
+
+def block_colorama():
+ # Avoid loading colorama (ctypes-dependent) which crashes in this environment.
+ dummy = types.SimpleNamespace(
+ Fore=types.SimpleNamespace(RESET=""),
+ Style=types.SimpleNamespace(RESET_ALL=""),
+ Back=types.SimpleNamespace(RESET=""),
+ )
+
+ def _init(*args, **kwargs):
+ return None
+
+ class _Ansi:
+ def __init__(self, stream=None, convert=None, strip=None, autoreset=None):
+ self.stream = stream
+
+ def write(self, text):
+ if self.stream:
+ return self.stream.write(text)
+
+ def flush(self):
+ if self.stream:
+ return self.stream.flush()
+
+ mod = types.SimpleNamespace(
+ init=_init,
+ Fore=dummy.Fore,
+ Back=dummy.Back,
+ Style=dummy.Style,
+ AnsiToWin32=_Ansi,
+ )
+
+ for name in ["colorama", "colorama.initialise", "colorama.ansitowin32", "colorama.winterm"]:
+ sys.modules[name] = mod
+
+
+def block_click_winconsole():
+ # Avoid click's _winconsole ctypes usage on this system.
+ def _get_windows_console_stream(stream, encoding=None, errors=None):
+ return stream
+
+ def get_best_encoding(stream):
+ return "utf-8"
+
+ def _default_text_stdout(): # noqa: N802
+ return sys.stdout
+
+ def _default_text_stdin(): # noqa: N802
+ return sys.stdin
+
+ def _default_text_stderr(): # noqa: N802
+ return sys.stderr
+
+ dummy = types.SimpleNamespace(
+ _get_windows_console_stream=_get_windows_console_stream,
+ get_best_encoding=get_best_encoding,
+ _default_text_stdout=_default_text_stdout,
+ _default_text_stdin=_default_text_stdin,
+ _default_text_stderr=_default_text_stderr,
+ )
+ sys.modules["click._winconsole"] = dummy
+
+
+def build_workbook(estimates: List[Estimate]):
+ openpyxl = require_openpyxl()
+ wb = openpyxl.Workbook()
+ ws = wb.active
+ ws.title = "Estimates"
+ headers = [
+ "File",
+ "Filament Length (mm)",
+ "Filament Mass (g)",
+ "Estimated Print Time (min)",
+ "Estimated Print Time (hrs)",
+ "Estimated Print Time (hrs text)",
+ ]
+ ws.append(headers)
+ ws.cell(row=1, column=7, value="Totals")
+
+ start_row = 2
+ for idx, est in enumerate(estimates, start=start_row):
+ ws.cell(row=idx, column=1, value=est.file)
+ ws.cell(row=idx, column=2, value=est.filament_length_mm)
+ ws.cell(row=idx, column=3, value=est.filament_mass_g)
+ ws.cell(row=idx, column=4, value=est.estimated_print_time_min)
+ time_formula = minutes_to_hours_formula(f"D{idx}")
+ ws.cell(row=idx, column=5, value=f"={time_formula}")
+ ws.cell(row=idx, column=6, value=minutes_to_hours_formula_text(est.estimated_print_time_min))
+
+ end_row = start_row + len(estimates) - 1
+ if estimates:
+ ws.cell(row=start_row, column=7, value="Filament (g)")
+ ws.cell(row=start_row, column=8, value=f"=SUM(C{start_row}:C{end_row})")
+ ws.cell(row=start_row + 1, column=7, value="Time")
+ total_minutes_expr = f"SUM(D{start_row}:D{end_row})"
+ total_time_formula = minutes_to_hours_formula(total_minutes_expr)
+ ws.cell(row=start_row + 1, column=8, value=f"={total_time_formula}")
+ ws.cell(row=start_row + 1, column=9, value=minutes_to_hours_formula_text(sum(e.estimated_print_time_min for e in estimates)))
+
+ # Force Excel/Sheets to recalc on open so formulas show values immediately
+ from openpyxl.workbook.properties import CalcProperties # type: ignore
+ wb.calculation_properties = CalcProperties(calcMode="auto", fullCalcOnLoad=True)
+ return wb
+
+
+def write_xlsx(estimates: List[Estimate], out_path: Path) -> None:
+ wb = build_workbook(estimates)
+ wb.save(out_path)
+
+
+def xlsx_bytes(estimates: List[Estimate]) -> bytes:
+ wb = build_workbook(estimates)
+ buf = io.BytesIO()
+ wb.save(buf)
+ return buf.getvalue()
+
+
def batch_cli(settings: PrinterSettings, fmt: str, out_path: Optional[Path]) -> None:
folder = Path.cwd()
stl_files = find_stl_files(folder)
@@ -270,6 +424,7 @@ WEB_TEMPLATE = """
+
@@ -312,10 +467,15 @@ def to_csv_string(rows: List[Dict[str, Any]]) -> str:
return buf.getvalue()
-def ensure_flask():
- if Flask is None:
+def load_flask():
+ block_colorama()
+ block_click_winconsole()
+ try:
+ from flask import Flask, request, render_template_string
+ except Exception:
print("Flask is not installed. Install it with: pip install Flask", file=sys.stderr)
sys.exit(1)
+ return Flask, request, render_template_string
def handle_uploaded_file(file_storage, tmpdir: Path) -> List[Path]:
@@ -334,7 +494,7 @@ def handle_uploaded_file(file_storage, tmpdir: Path) -> List[Path]:
def run_server(settings: PrinterSettings, fmt: str, port: int) -> None:
- ensure_flask()
+ Flask, request, render_template_string = load_flask()
app = Flask(__name__)
@app.route("/", methods=["GET", "POST"])
@@ -361,12 +521,21 @@ def run_server(settings: PrinterSettings, fmt: str, port: int) -> None:
payload = json.dumps(estimates_rows, indent=2)
mime = "application/json"
download_name = "estimates.json"
+ elif chosen_format == "xlsx":
+ estimate_objs = [Estimate(**row) for row in estimates_rows]
+ payload_bytes = xlsx_bytes(estimate_objs)
+ payload = base64.b64encode(payload_bytes).decode("ascii")
+ mime = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
+ download_name = "estimates.xlsx"
else:
payload = to_csv_string(estimates_rows)
mime = "text/csv"
download_name = "estimates.csv"
if provide_download:
- download_link = f"data:{mime};charset=utf-8,{payload.replace('%','%25').replace('\\n','%0A').replace(' ','%20')}"
+ if chosen_format == "xlsx":
+ download_link = f"data:{mime};base64,{payload}"
+ else:
+ download_link = f"data:{mime};charset=utf-8,{payload.replace('%','%25').replace('\\n','%0A').replace(' ','%20')}"
headers = list(estimates_rows[0].keys()) if estimates_rows else []
return render_template_string(
WEB_TEMPLATE,
diff --git a/print-estimate.bat b/print-estimate.bat
new file mode 100644
index 0000000..bec5281
--- /dev/null
+++ b/print-estimate.bat
@@ -0,0 +1,17 @@
+@echo off
+setlocal
+
+:: Location of the project (adjust if you move the repo)
+set "PROJECT_DIR=C:\Users\ksolo\Projects\3D Print Estimator"
+set "PY_SCRIPT=%PROJECT_DIR%\estimator.py"
+set "CONFIG=%PROJECT_DIR%\printer-config.json"
+
+:: First arg = output format (csv/json/xlsx), default to csv if omitted
+set "FMT=%~1"
+if "%FMT%"=="" set "FMT=csv"
+
+:: Second arg = optional output path. Only pass --out when provided to avoid argparse errors.
+set "OUT_ARG="
+if not "%~2"=="" set "OUT_ARG=--out \"%~2\""
+
+python "%PY_SCRIPT%" --config "%CONFIG%" --output-format "%FMT%" %OUT_ARG%
diff --git a/requirements.txt b/requirements.txt
index b998f85..ab50d17 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1 +1,2 @@
Flask>=2.2
+openpyxl>=3.1