✨feature: Add windows batch file, add support for xlsx output
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
notes/
|
||||
__pycache__/
|
||||
12
README.md
12
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`
|
||||
|
||||
193
estimator.py
193
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 = """
|
||||
<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
17
print-estimate.bat
Normal 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%
|
||||
@@ -1 +1,2 @@
|
||||
Flask>=2.2
|
||||
openpyxl>=3.1
|
||||
|
||||
Reference in New Issue
Block a user