Skip to content

Instantly share code, notes, and snippets.

@stephenfeather
Last active May 23, 2025 13:02
Show Gist options
  • Save stephenfeather/a8d4ba9745c26ce7d1b53a6c95942bcc to your computer and use it in GitHub Desktop.
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.
#!/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()
#######################################################################
# 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