import argparse import base64 import csv import io import json import math 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 # 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] @dataclass class PrinterSettings: layer_height_mm: float = 0.2 nozzle_diameter_mm: float = 0.4 perimeter_count: int = 2 top_layers: int = 4 bottom_layers: int = 4 infill_density: float = 0.2 # 0.0 - 1.0 perimeter_speed_mm_s: float = 40.0 infill_speed_mm_s: float = 60.0 travel_speed_mm_s: float = 120.0 filament_diameter_mm: float = 1.75 filament_density_g_cm3: float = 1.24 # PLA-ish travel_factor: float = 0.1 # additional travel distance as a fraction of extrusion distance @dataclass class StlStats: volume_mm3: float surface_area_mm2: float bounds_min: Vec3 bounds_max: Vec3 @dataclass class Estimate: file: str volume_mm3: float surface_area_mm2: float bounding_box_x_mm: float bounding_box_y_mm: float bounding_box_z_mm: float filament_length_mm: float filament_mass_g: float estimated_print_time_min: float 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", "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() def load_config(path: Path) -> PrinterSettings: if not path.exists(): return PrinterSettings() with path.open("r", encoding="utf-8") as f: raw = json.load(f) return PrinterSettings(**raw) def is_binary_stl(data: bytes) -> bool: if len(data) < 84: return True header = data[:80] tri_count = struct.unpack(" Tuple[float, float]: # Signed volume of tetrahedron (origin, v1, v2, v3) cross_x = v2[1] * v3[2] - v2[2] * v3[1] cross_y = v2[2] * v3[0] - v2[0] * v3[2] cross_z = v2[0] * v3[1] - v2[1] * v3[0] signed_volume = (v1[0] * cross_x + v1[1] * cross_y + v1[2] * cross_z) / 6.0 # Triangle area ab = (v2[0] - v1[0], v2[1] - v1[1], v2[2] - v1[2]) ac = (v3[0] - v1[0], v3[1] - v1[1], v3[2] - v1[2]) cross_area_x = ab[1] * ac[2] - ab[2] * ac[1] cross_area_y = ab[2] * ac[0] - ab[0] * ac[2] cross_area_z = ab[0] * ac[1] - ab[1] * ac[0] area = 0.5 * math.sqrt( cross_area_x * cross_area_x + cross_area_y * cross_area_y + cross_area_z * cross_area_z ) return signed_volume, area def read_stl(path: Path) -> StlStats: data = path.read_bytes() volume = 0.0 area = 0.0 min_pt = [math.inf, math.inf, math.inf] max_pt = [-math.inf, -math.inf, -math.inf] def update_bounds(pt: Vec3) -> None: min_pt[0] = min(min_pt[0], pt[0]) min_pt[1] = min(min_pt[1], pt[1]) min_pt[2] = min(min_pt[2], pt[2]) max_pt[0] = max(max_pt[0], pt[0]) max_pt[1] = max(max_pt[1], pt[1]) max_pt[2] = max(max_pt[2], pt[2]) if is_binary_stl(data): tri_count = struct.unpack("= 84 else 0 offset = 84 for _ in range(tri_count): chunk = data[offset : offset + 50] if len(chunk) < 50: break coords = struct.unpack("<12fH", chunk) v1 = (coords[3], coords[4], coords[5]) v2 = (coords[6], coords[7], coords[8]) v3 = (coords[9], coords[10], coords[11]) update_bounds(v1) update_bounds(v2) update_bounds(v3) v, a = accumulate_triangle_stats(v1, v2, v3) volume += v area += a offset += 50 else: lines = data.decode("utf-8", errors="ignore").splitlines() verts: List[Vec3] = [] for line in lines: parts = line.strip().split() if len(parts) >= 4 and parts[0].lower() == "vertex": try: v = (float(parts[1]), float(parts[2]), float(parts[3])) except ValueError: continue verts.append(v) if len(verts) == 3: v1, v2, v3 = verts update_bounds(v1) update_bounds(v2) update_bounds(v3) v, a = accumulate_triangle_stats(v1, v2, v3) volume += v area += a verts = [] if math.isinf(min_pt[0]): min_pt = [0.0, 0.0, 0.0] max_pt = [0.0, 0.0, 0.0] return StlStats(abs(volume), area, (min_pt[0], min_pt[1], min_pt[2]), (max_pt[0], max_pt[1], max_pt[2])) def estimate(stats: StlStats, settings: PrinterSettings, filename: str) -> Estimate: line_area = settings.nozzle_diameter_mm * settings.layer_height_mm shell_thickness = settings.perimeter_count * settings.nozzle_diameter_mm # Shell volume approximated from surface area (rough but better than box volume) shell_volume = stats.surface_area_mm2 * shell_thickness # Infill volume limited to remaining interior internal_volume = max(stats.volume_mm3 - shell_volume, 0.0) infill_volume = internal_volume * settings.infill_density total_extrusion_volume = shell_volume + infill_volume # Filament calculations filament_cross_section = math.pi * (settings.filament_diameter_mm / 2.0) ** 2 filament_length = total_extrusion_volume / filament_cross_section if filament_cross_section else 0.0 filament_mass = (total_extrusion_volume / 1000.0) * settings.filament_density_g_cm3 # Print time estimate perimeter_length = shell_volume / line_area if line_area else 0.0 infill_length = infill_volume / line_area if line_area else 0.0 travel_length = (perimeter_length + infill_length) * settings.travel_factor time_s = ( perimeter_length / settings.perimeter_speed_mm_s + infill_length / settings.infill_speed_mm_s + travel_length / settings.travel_speed_mm_s ) bbox = ( stats.bounds_max[0] - stats.bounds_min[0], stats.bounds_max[1] - stats.bounds_min[1], stats.bounds_max[2] - stats.bounds_min[2], ) return Estimate( file=filename, volume_mm3=round(stats.volume_mm3, 3), surface_area_mm2=round(stats.surface_area_mm2, 3), bounding_box_x_mm=round(bbox[0], 3), bounding_box_y_mm=round(bbox[1], 3), bounding_box_z_mm=round(bbox[2], 3), filament_length_mm=round(filament_length, 3), filament_mass_g=round(filament_mass, 3), estimated_print_time_min=round(time_s / 60.0, 2), ) def find_stl_files(folder: Path) -> List[Path]: return sorted([p for p in folder.iterdir() if p.is_file() and p.suffix.lower() == ".stl"]) 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())) writer.writeheader() for e in estimates: 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) if not stl_files: print("No STL files found in current directory.", file=sys.stderr) sys.exit(1) estimates: List[Estimate] = [] for stl_path in stl_files: stats = read_stl(stl_path) estimates.append(estimate(stats, settings, stl_path.name)) if out_path is None: out_path = folder / f"estimates.{fmt}" write_output(estimates, fmt, out_path) print(f"Wrote {len(estimates)} records to {out_path}") WEB_TEMPLATE = """ STL Print Estimator

STL Print Estimator





{% if estimates %}
{% if download_link %} Download {{ download_name }} {% endif %}
{% for key in headers %} {% endfor %} {% for row in estimates %} {% for key in headers %} {% endfor %} {% endfor %}
{{ key }}
{{ row[key] }}
{% endif %} """ def to_csv_string(rows: List[Dict[str, Any]]) -> str: buf = io.StringIO() writer = csv.DictWriter(buf, fieldnames=list(rows[0].keys())) writer.writeheader() for r in rows: writer.writerow(r) return buf.getvalue() 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]: filename = file_storage.filename or "upload" dest = tmpdir / filename file_storage.save(dest) paths: List[Path] = [] if dest.suffix.lower() == ".zip": with zipfile.ZipFile(dest, "r") as zf: zf.extractall(tmpdir) for p in tmpdir.rglob("*.stl"): paths.append(p) elif dest.suffix.lower() == ".stl": paths.append(dest) return paths def run_server(settings: PrinterSettings, fmt: str, port: int) -> None: Flask, request, render_template_string = load_flask() app = Flask(__name__) @app.route("/", methods=["GET", "POST"]) def index(): estimates_rows: List[Dict[str, Any]] = [] download_link = None download_name = None chosen_format = fmt if request.method == "POST": chosen_format = request.form.get("format", fmt) provide_download = request.form.get("download") is not None upload = request.files.get("file") if not upload: return render_template_string(WEB_TEMPLATE, estimates=None, headers=[], download_link=None) with tempfile.TemporaryDirectory() as td: tmpdir = Path(td) stl_paths = handle_uploaded_file(upload, tmpdir) for p in stl_paths: stats = read_stl(p) est = estimate(stats, settings, p.name) estimates_rows.append(asdict(est)) if estimates_rows: if chosen_format == "json": 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: 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, estimates=estimates_rows or None, headers=headers, download_link=download_link, download_name=download_name, ) app.run(host="0.0.0.0", port=port, debug=False) def main() -> None: args = parse_args() settings = load_config(Path(args.config)) if args.serve: run_server(settings, args.output_format, args.port) else: batch_cli(settings, args.output_format, Path(args.out) if args.out else None) if __name__ == "__main__": main()