Files
Vignesh Suresh a4b9a311dd 30.05
2026-05-30 15:19:12 +02:00

298 lines
9.9 KiB
Python

"""NC file parser for the Beckhoff 5-axis metal DED slicer.
Parses the machine NC dialect emitted by ``stl_slicer.py`` (and the real
Beckhoff controller) into structured Python data. No visualisation — this
module only reads NC text and extracts motion + weld information.
The NC dialect understood here:
COMMENTS
(Part name: Hemisphere-v1) -> part name (header)
(New Slice of body X No.: 3, Z15.00) -> slice (Z layer) number + Z
(X, Slice 3, Fraction 2 Outer ...) -> fraction (contour) number
MOTION
G00 X.. Y.. Z.. [A..] [B..] -> rapid move (no welding)
G01 X.. Y.. Z.. [A..] [B..] [M61] -> cut move (M61 = laser ON)
WELD CONTROL
M60=1 / M60=11 -> weld start markers (tracked, not moves)
M61 -> laser ON (inline on a G01 line)
M62 -> laser OFF (standalone line)
M01 -> optional stop between slices (ignored)
IGNORED
#TRAFO ON, G54, F800.0, M63, M71, blank lines, ``;`` comments.
Units: X/Y/Z in millimetres, A/B in degrees.
Standard library only (re, math, pathlib) — importable with zero installs.
"""
import re
import math
from pathlib import Path
# Regexes compiled once at import time.
_PART_NAME_RE = re.compile(r"\(Part name:\s*(.+?)\)")
_SLICE_RE = re.compile(r"\(New Slice of body .*?No\.:\s*(\d+)")
_FRACTION_RE = re.compile(r"Fraction\s+(\d+)")
# Axis word followed by an optional-sign float (incl. scientific notation).
_AXIS_RE = re.compile(r"\b([XYZAB])(-?\d+\.?\d*(?:[eE][-+]?\d+)?)")
def _extract_axes(line: str) -> dict:
"""Pull X/Y/Z/A/B axis words out of a motion line.
Inputs:
line -- a single G00/G01 NC line, e.g. "G01 X-1.2 Y3.4 Z2.5 A0.0 B1.0".
Output:
dict mapping the upper-case axis letter ("X".."B") to its float value
(mm for X/Y/Z, degrees for A/B). Only axes present in the line appear.
"""
return {m.group(1): float(m.group(2)) for m in _AXIS_RE.finditer(line)}
def parse_nc(filepath: str) -> dict:
"""Parse a Beckhoff DED NC file into structured move data.
Inputs:
filepath -- path to the .nc file to read.
Output:
dict with two keys:
"part_name" : str -- from the "(Part name: ...)" header comment,
or the filename stem if that comment is absent.
"moves" : list[dict] -- one dict per G00/G01 motion line, each:
{
"line_no" : int, # 1-based line number in the source file
"x" : float, # mm
"y" : float, # mm
"z" : float, # mm
"a" : float, # degrees (0.0 if absent)
"b" : float, # degrees (0.0 if absent)
"move_type" : str, # "rapid" (G00) or "cut" (G01)
"weld_state" : str, # "on" or "off"
"slice_no" : int, # Z layer this move belongs to
"fraction_no" : int, # contour within the layer
}
Behaviour / error handling:
* Weld state starts "off"; M61 (inline) turns it "on", M62 turns it
"off". The state carries forward across G01 lines until M62.
* A G00/G01 line missing X, Y or Z prints a warning with the line
number and is skipped (never crashes).
* Missing A or B silently default to 0.0.
* The whole parse is wrapped in try/except; on error the file path is
reported and a dict with an empty move list is returned.
"""
part_name = None
moves = []
weld_state = "off"
slice_no = 0
fraction_no = 0
try:
text = Path(filepath).read_text()
lines = text.splitlines()
for idx, raw in enumerate(lines, start=1):
line = raw.strip()
# Skip blanks and ';' comments outright.
if not line or line.startswith(";"):
continue
# Parenthesised comments: extract part name / slice / fraction.
if line.startswith("("):
if part_name is None:
m = _PART_NAME_RE.search(line)
if m:
part_name = m.group(1).strip()
continue
m = _SLICE_RE.search(line)
if m:
slice_no = int(m.group(1))
continue
m = _FRACTION_RE.search(line)
if m:
fraction_no = int(m.group(1))
continue
# Weld control / stop markers.
if line.startswith("M62"):
weld_state = "off"
continue
if line.startswith("M60") or line.startswith("M01"):
# M60=1 / M60=11 = weld start markers, M01 = optional stop.
# Tracked by context only; not moves themselves.
continue
# Motion lines.
if line.startswith("G00") or line.startswith("G01"):
move_type = "rapid" if line.startswith("G00") else "cut"
axes = _extract_axes(line)
missing = [ax for ax in ("X", "Y", "Z") if ax not in axes]
if missing:
print(
f"Warning: line {idx} missing {','.join(missing)} "
f"-> skipped: {line!r}"
)
continue
# M61 appears inline on a G01 line and turns the laser ON for
# this move and the ones that follow.
if "M61" in line:
weld_state = "on"
moves.append({
"line_no": idx,
"x": axes["X"],
"y": axes["Y"],
"z": axes["Z"],
"a": axes.get("A", 0.0),
"b": axes.get("B", 0.0),
"move_type": move_type,
"weld_state": weld_state,
"slice_no": slice_no,
"fraction_no": fraction_no,
})
continue
# Everything else (#TRAFO ON, G54, F800.0, M63, M71, ...) ignored.
except Exception as exc: # noqa: BLE001 - report and degrade gracefully
print(f"Error parsing NC file {filepath!r}: {exc}")
return {"part_name": Path(filepath).stem, "moves": moves}
if part_name is None:
part_name = Path(filepath).stem
return {"part_name": part_name, "moves": moves}
def compute_tool_vector(a_deg: float, b_deg: float) -> tuple[float, float, float]:
"""Convert A/B axis angles to a unit tool-direction vector.
Inputs:
a_deg -- A-axis angle in degrees.
b_deg -- B-axis angle in degrees.
Output:
(u, v, w) -- a unit direction vector for where the extruder/welding
head is pointing, where:
u = X component
v = Y component
w = Z component
Formula:
u = sin(B)
v = -sin(A) * cos(B)
w = cos(A) * cos(B)
With A=0 and B=0 the result is (0.0, 0.0, 1.0) — the head points straight
up along +Z. The vector is already unit length for any A/B.
"""
a = math.radians(a_deg)
b = math.radians(b_deg)
u = math.sin(b)
v = -math.sin(a) * math.cos(b)
w = math.cos(a) * math.cos(b)
return (u, v, w)
def summarise(parsed: dict) -> None:
"""Print a clean human-readable summary of parsed NC data.
Inputs:
parsed -- the dict returned by ``parse_nc`` (keys "part_name",
"moves").
Output:
None. Prints to the terminal: part name, total move count, number of
distinct Z layers (slices) and fractions, weld-ON vs rapid point
counts, and the Z (mm) / A (deg) / B (deg) ranges seen across moves.
"""
moves = parsed.get("moves", [])
part_name = parsed.get("part_name", "?")
total = len(moves)
layers = len({m["slice_no"] for m in moves})
fractions = len({(m["slice_no"], m["fraction_no"]) for m in moves})
weld_on = sum(1 for m in moves if m["weld_state"] == "on")
rapid = sum(1 for m in moves if m["move_type"] == "rapid")
print(f"Part name : {part_name}")
print(f"Total moves : {total}")
print(f"Layers : {layers}")
print(f"Fractions : {fractions}")
print(f"Weld ON pts : {weld_on}")
print(f"Rapid pts : {rapid}")
if moves:
zs = [m["z"] for m in moves]
as_ = [m["a"] for m in moves]
bs = [m["b"] for m in moves]
print(f"Z range : {min(zs):.2f} mm → {max(zs):.2f} mm")
print(f"A range : {min(as_):.2f}° → {max(as_):.2f}°")
print(f"B range : {min(bs):.2f}° → {max(bs):.2f}°")
else:
print("Z range : (no moves)")
print("A range : (no moves)")
print("B range : (no moves)")
if __name__ == "__main__":
import tempfile
TEST_NC = """(Part name: Hemisphere-v1)
#TRAFO ON
G54
M63
M71
(New Slice of body Hemisphere-v1 No.: 1, Z2.50)
(Change material to Steel)
(Hemisphere-v1, Slice 1, Fraction 1 Outer Perimeter 1)
F800.0
G00 X-48.09 Y-13.29 Z2.50 A0.00 B0.00
M60=1
G01 X-47.29 Y-15.96 Z2.50 A0.00 B0.00 M61
G01 X-46.78 Y-17.40 Z2.50 A0.00 B0.00
G01 X-45.71 Y-20.01 Z2.50 A2.50 B-1.20
M62
(Hemisphere-v1, Slice 1, Fraction 2 Outer Perimeter 2)
G00 X-31.23 Y-24.83 Z2.50 A0.00 B0.00
M60=11
G01 X-31.90 Y-23.97 Z2.50 A0.00 B0.00 M61
G01 X-32.64 Y-22.94 Z2.50 A1.80 B0.50
M62
M01
(New Slice of body Hemisphere-v1 No.: 2, Z7.50)
(Hemisphere-v1, Slice 2, Fraction 1 Outer Perimeter 1)
F800.0
G00 X-45.12 Y-10.55 Z7.50 A5.10 B-2.30
M60=11
G01 X-44.50 Y-12.80 Z7.50 A5.10 B-2.30 M61
G01 X-43.90 Y-15.10 Z7.50 A6.20 B-1.80
M62
M01
"""
with tempfile.NamedTemporaryFile(
"w", suffix=".nc", delete=False
) as fh:
fh.write(TEST_NC)
tmp_path = fh.name
parsed = parse_nc(tmp_path)
summarise(parsed)
# Quick sanity check on the tool-vector helper.
print()
print(f"tool vector @ A0 B0 : {compute_tool_vector(0.0, 0.0)}")
print(f"tool vector @ A5 B-2 : {compute_tool_vector(5.0, -2.0)}")