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