298 lines
9.9 KiB
Python
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)}")
|