Last active
May 23, 2025 13:02
-
-
Save stephenfeather/a8d4ba9745c26ce7d1b53a6c95942bcc to your computer and use it in GitHub Desktop.
Python script to trim layers out of a .gcode file along with a Klipper macro to run it from server.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
####################################################################### | |
# G‑code layer‑trimmer integration | |
# ‑‑ Place this in printer.cfg (or an included file) and | |
# `RESTART` Klipper after saving. v1.0 # | |
####################################################################### | |
# 1) Host‑side shell command wrapper | |
[gcode_shell_command TRIM_GCODE] | |
# Path to your copy of gcode_layer_trim.py ‑‑ adjust as needed | |
command: python3 /home/pi/scripts/gcode_layer_trim.py {CMD} | |
# stdout / stderr from the script will be echoed to the console | |
verbose: True | |
# generous timeout for large files (milliseconds) | |
timeout: 600000 | |
# 2) User‑facing macro | |
[gcode_macro TRIM_GCODE] | |
description: | | |
Trim layers from a .gcode file with gcode_layer_trim.py. | |
Required: | |
SOURCE=<path> – file to trim (absolute or relative to ~/gcode_files) | |
(either) REMOVE_BELOW=<N> – drop layers < N (keep N … last) | |
or REMOVE_ABOVE=<N> – drop N and up (keep 0 … N‑1) | |
Optional: | |
DEST=<path> – output file (defaults to "<source>_trim.gcode") | |
LAYER_REGEX=<regex> – custom layer comment regex | |
Example: | |
TRIM_GCODE SOURCE=Benchy.gcode REMOVE_BELOW=420 | |
TRIM_GCODE SOURCE=/mnt/usb/dragster.gcode REMOVE_ABOVE=175 \ | |
DEST=dragster_base.gcode \ | |
LAYER_REGEX=";\\s*LAYERHEIGHT:\\s*(\\d+)" | |
gcode: | |
{% if 'SOURCE' not in params %} | |
RESPOND PREFIX="error" MSG="TRIM_GCODE error – SOURCE=<file> is required" | |
{% return %} | |
{% endif %} | |
{% if not ('REMOVE_BELOW' in params or 'REMOVE_ABOVE' in params) %} | |
RESPOND PREFIX="error" MSG="Specify REMOVE_BELOW=<N> or REMOVE_ABOVE=<N>" | |
{% return %} | |
{% endif %} | |
# ── build CLI ---------------------------------------------------------------- | |
{% set source = params.SOURCE %} | |
{% set dest = params.DEST|default(source|replace('.gcode','_trim.gcode')) %} | |
{% if 'REMOVE_BELOW' in params %} | |
{% set cutopt = '--remove-below ' ~ params.REMOVE_BELOW %} | |
{% else %} | |
{% set cutopt = '--remove-above ' ~ params.REMOVE_ABOVE %} | |
{% endif %} | |
{% set regexopt = '' %} | |
{% if 'LAYER_REGEX' in params %} | |
{% set regexopt = '--layer-regex "' ~ params.LAYER_REGEX ~ '"' %} | |
{% endif %} | |
{% set cli = '-i "' ~ source ~ '" -o "' ~ dest ~ '" ' ~ cutopt ~ ' ' ~ regexopt %} | |
RESPOND PREFIX="info" MSG="Trimming {{source}} → {{dest}} ({{cutopt}})" | |
RUN_SHELL_COMMAND TRIM_GCODE CMD="{{cli}}" | |
RESPOND PREFIX="info" MSG="Trim job dispatched – check console for report." |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment