✨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
|
```bash
|
||||||
python estimator.py --config printer-config.json --output-format csv --out estimates.csv
|
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:
|
Defaults:
|
||||||
|
|
||||||
- Reads all `.stl` files in the current directory (non-recursive).
|
- 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
|
## Web interface
|
||||||
|
|
||||||
@@ -19,7 +22,7 @@ pip install -r requirements.txt
|
|||||||
python estimator.py --serve --port 5000 --config printer-config.json --output-format csv
|
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
|
## 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`.
|
- 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.
|
- 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
|
## Examples
|
||||||
|
|
||||||
- JSON output: `python estimator.py -f json -o result.json`
|
- JSON output: `python estimator.py -f json -o result.json`
|
||||||
|
|||||||
191
estimator.py
191
estimator.py
@@ -1,4 +1,5 @@
|
|||||||
import argparse
|
import argparse
|
||||||
|
import base64
|
||||||
import csv
|
import csv
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
@@ -7,17 +8,15 @@ import os
|
|||||||
import struct
|
import struct
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import types
|
||||||
import zipfile
|
import zipfile
|
||||||
from dataclasses import dataclass, asdict
|
from dataclasses import dataclass, asdict
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Iterable, List, Tuple, Dict, Any, Optional
|
from typing import Iterable, List, Tuple, Dict, Any, Optional
|
||||||
|
|
||||||
try:
|
# Some bundled tools (e.g., MySQL Workbench) inject their own Python stdlib zip into sys.path.
|
||||||
from flask import Flask, request, render_template_string
|
# That can ship an incompatible ctypes build and crash Flask/colorama imports. Strip it out early.
|
||||||
except Exception: # Flask is optional for CLI usage
|
sys.path = [p for p in sys.path if "MySQL Workbench" not in p]
|
||||||
Flask = None # type: ignore
|
|
||||||
request = None # type: ignore
|
|
||||||
render_template_string = None # type: ignore
|
|
||||||
|
|
||||||
|
|
||||||
Vec3 = Tuple[float, float, float]
|
Vec3 = Tuple[float, float, float]
|
||||||
@@ -63,8 +62,8 @@ class Estimate:
|
|||||||
def parse_args() -> argparse.Namespace:
|
def parse_args() -> argparse.Namespace:
|
||||||
parser = argparse.ArgumentParser(description="Estimate filament usage and print time for STL files.")
|
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("--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("--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.")
|
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("--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.")
|
parser.add_argument("--port", type=int, default=5000, help="Port for the web interface.")
|
||||||
return parser.parse_args()
|
return parser.parse_args()
|
||||||
@@ -225,6 +224,8 @@ def write_output(estimates: List[Estimate], fmt: str, out_path: Path) -> None:
|
|||||||
if fmt == "json":
|
if fmt == "json":
|
||||||
with out_path.open("w", encoding="utf-8") as f:
|
with out_path.open("w", encoding="utf-8") as f:
|
||||||
json.dump([asdict(e) for e in estimates], f, indent=2)
|
json.dump([asdict(e) for e in estimates], f, indent=2)
|
||||||
|
elif fmt == "xlsx":
|
||||||
|
write_xlsx(estimates, out_path)
|
||||||
else:
|
else:
|
||||||
with out_path.open("w", newline="", encoding="utf-8") as f:
|
with out_path.open("w", newline="", encoding="utf-8") as f:
|
||||||
writer = csv.DictWriter(f, fieldnames=list(asdict(estimates[0]).keys()))
|
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))
|
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:
|
def batch_cli(settings: PrinterSettings, fmt: str, out_path: Optional[Path]) -> None:
|
||||||
folder = Path.cwd()
|
folder = Path.cwd()
|
||||||
stl_files = find_stl_files(folder)
|
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>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="csv" checked> CSV</label>
|
||||||
<label><input type="radio" name="format" value="json"> JSON</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>
|
<label><input type="checkbox" name="download"> Provide download</label><br><br>
|
||||||
<button type="submit">Analyze</button>
|
<button type="submit">Analyze</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -312,10 +467,15 @@ def to_csv_string(rows: List[Dict[str, Any]]) -> str:
|
|||||||
return buf.getvalue()
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
def ensure_flask():
|
def load_flask():
|
||||||
if Flask is None:
|
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)
|
print("Flask is not installed. Install it with: pip install Flask", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
return Flask, request, render_template_string
|
||||||
|
|
||||||
|
|
||||||
def handle_uploaded_file(file_storage, tmpdir: Path) -> List[Path]:
|
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:
|
def run_server(settings: PrinterSettings, fmt: str, port: int) -> None:
|
||||||
ensure_flask()
|
Flask, request, render_template_string = load_flask()
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
@app.route("/", methods=["GET", "POST"])
|
@app.route("/", methods=["GET", "POST"])
|
||||||
@@ -361,11 +521,20 @@ def run_server(settings: PrinterSettings, fmt: str, port: int) -> None:
|
|||||||
payload = json.dumps(estimates_rows, indent=2)
|
payload = json.dumps(estimates_rows, indent=2)
|
||||||
mime = "application/json"
|
mime = "application/json"
|
||||||
download_name = "estimates.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:
|
else:
|
||||||
payload = to_csv_string(estimates_rows)
|
payload = to_csv_string(estimates_rows)
|
||||||
mime = "text/csv"
|
mime = "text/csv"
|
||||||
download_name = "estimates.csv"
|
download_name = "estimates.csv"
|
||||||
if provide_download:
|
if provide_download:
|
||||||
|
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')}"
|
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 []
|
headers = list(estimates_rows[0].keys()) if estimates_rows else []
|
||||||
return render_template_string(
|
return render_template_string(
|
||||||
|
|||||||
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
|
Flask>=2.2
|
||||||
|
openpyxl>=3.1
|
||||||
|
|||||||
Reference in New Issue
Block a user