#!/usr/bin/env python3 """ gcode_layer_trim.py — V1.2 Trim layers from a G‑code file with full audit trail *and* range checking. Changes in 1.2 -------------- • First pass over the input counts layers to find the minimum/maximum index. The program aborts with an error if: – The file contains no recognised layer markers. – --remove-below N would keep zero layers (N > last layer). – --remove-above N would keep zero layers (N <= first layer). • Exit status is non‑zero on error, so scripts/CI can detect failure. Everything else (header, stats, streaming memory profile) is unchanged. """ from __future__ import annotations import argparse, datetime as _dt, pathlib, re, sys, textwrap, tempfile, shutil HEADER_TEMPLATE = textwrap.dedent("""\ ; ------------------------------------------------------------------------ ; *** FILE TRIMMED BY gcode_layer_trim.py *** ; Date : {now} ; Original file : {input} ; Kept layers : {keep_desc} ; Removed layers : {removed_desc} ; Command line : {cmd} ; ------------------------------------------------------------------------ """) # ─────────────────────────────── CLI ────────────────────────────────────────── def parse_args() -> argparse.Namespace: p = argparse.ArgumentParser(formatter_class=argparse.RawDescriptionHelpFormatter, description=__doc__) p.add_argument('-i', '--input', required=True, type=pathlib.Path, help='Source .gcode file') p.add_argument('-o', '--output', required=True, type=pathlib.Path, help='Destination .gcode file (overwritten on success)') m = p.add_mutually_exclusive_group(required=True) m.add_argument('--remove-below', type=int, metavar='N', help='Remove every layer strictly below N (keep N … last)') m.add_argument('--remove-above', type=int, metavar='N', help='Remove layer N and every layer above it (keep 0 … N‑1)') p.add_argument('--layer-regex', default=r";\s*LAYER:\s*(\d+)", help='Regex (one capturing group) that yields the layer number') return p.parse_args() # ─────────────────────────────── Helpers ────────────────────────────────────── def find_layer_bounds(fp, layer_re: re.Pattern[str]) -> tuple[int, int]: """Return (first_layer, last_layer). Raises ValueError if none found.""" first = last = None for line in fp: m = layer_re.search(line) if m: n = int(m.group(1)) first = n if first is None else first last = n if first is None: raise ValueError("No layer markers found.") return first, last def validate_cutoff(mode: str, cutoff: int, first: int, last: int) -> None: """Raise ValueError if cutoff makes no sense.""" if mode == 'below': # keep layers >= cutoff if cutoff > last: raise ValueError(f"Layer {cutoff} is above the last layer ({last}); " "nothing would be kept.") else: # mode == 'above' # keep layers < cutoff if cutoff <= first: raise ValueError(f"Layer {cutoff} is at or below the first layer " f"({first}); nothing would be kept.") def should_keep(layer: int | None, mode: str, cutoff: int) -> bool: if layer is None: return True # pre‑amble lines return (layer >= cutoff) if mode == 'below' else (layer < cutoff) # ─────────────────────────────── Main ───────────────────────────────────────── def main() -> None: ns = parse_args() mode = 'below' if ns.remove_below is not None else 'above' cutoff = ns.remove_below if mode == 'below' else ns.remove_above layer_re = re.compile(ns.layer_regex, re.IGNORECASE) # ---------- PASS 1: find layer range & validate -------------------------- with ns.input.open('r', encoding='utf-8', errors='ignore') as fp: try: first_layer, last_layer = find_layer_bounds(fp, layer_re) except ValueError as e: sys.stderr.write(f"ERROR: {e}\n") sys.exit(1) try: validate_cutoff(mode, cutoff, first_layer, last_layer) except ValueError as e: sys.stderr.write(f"ERROR: {e}\n") sys.exit(1) # ---------- PASS 2: copy & trim to a temp file --------------------------- stats = { 'first_kept': None, 'last_kept': None, 'first_dropped': None, 'last_dropped': None, 'lines_kept': 0, 'lines_dropped': 0, } current_layer = None # Write to a temp file first; replace destination only on success with tempfile.NamedTemporaryFile('w+', delete=False, encoding='utf-8') as tmp, \ ns.input.open('r', encoding='utf-8', errors='ignore') as fin: header_pos = tmp.tell() tmp.write("; header placeholder\n") for line in fin: m = layer_re.search(line) if m: current_layer = int(m.group(1)) keep = should_keep(current_layer, mode, cutoff) rk = 'kept' if keep else 'dropped' if current_layer is not None: fk, lk = f'first_{rk}', f'last_{rk}' if stats[fk] is None: stats[fk] = current_layer stats[lk] = current_layer if keep: tmp.write(line) stats['lines_kept'] += 1 else: stats['lines_dropped'] += 1 # ---- build header ---- keep_desc = f"{stats['first_kept']} – {stats['last_kept']}" removed_desc = (f"{stats['first_dropped']} – {stats['last_dropped']}" if stats['first_dropped'] is not None else "NONE") header = HEADER_TEMPLATE.format( now=_dt.datetime.now().isoformat(timespec='seconds'), input=ns.input.name, keep_desc=keep_desc, removed_desc=removed_desc, cmd=' '.join(sys.argv) ) tmp.flush() tmp.seek(header_pos) tmp.write(header) tmp.flush() # Atomically move temp → output shutil.move(tmp.name, ns.output) # ---------- Console summary --------------------------------------------- total = stats['lines_kept'] + stats['lines_dropped'] pct = lambda n: 100 * n / total if total else 0 print(f"Trim finished.") print(f" Source file : {ns.input}") print(f" Layers in source : {first_layer} – {last_layer}") print(f" Layers kept : {keep_desc}") print(f" Layers removed : {removed_desc}") print(f" Lines kept : {stats['lines_kept']:,} " f"({pct(stats['lines_kept']):5.1f} %)") print(f" Lines removed : {stats['lines_dropped']:,} " f"({pct(stats['lines_dropped']):5.1f} %)") print(f" Output file : {ns.output.resolve()}") if __name__ == '__main__': main()