"""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)}")