feature: Add windows batch file, add support for xlsx output

This commit is contained in:
Keith Solomon
2025-12-07 17:31:43 -06:00
parent 4bd45bf2de
commit 26fa801aca
5 changed files with 211 additions and 14 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
notes/
__pycache__/

View File

@@ -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`

View File

@@ -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 = """
<label>Upload STL or ZIP of STLs: <input type="file" name="file" required></label><br><br>
<label><input type="radio" name="format" value="csv" checked> CSV</label>
<label><input type="radio" name="format" value="json"> JSON</label>
<label><input type="radio" name="format" value="xlsx"> XLSX</label>
<label><input type="checkbox" name="download"> Provide download</label><br><br>
<button type="submit">Analyze</button>
</form>
@@ -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,

17
print-estimate.bat Normal file
View File

@@ -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%

View File

@@ -1 +1,2 @@
Flask>=2.2
openpyxl>=3.1