#!/usr/bin/python3.14

'''
plasmac_gcode.py

Copyright (C) 2019 - 2025 Phillip A Carter
Copyright (C) 2020 - 2024 Gregory D Carl

This program is free software; you can redistribute it and/or modify it
under the terms of the GNU General Public License as published by the
Free Software Foundation; either version 2 of the License, or
(at your option) any later version.

This program is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License along
with this program; if not, write to the Free Software Foundation, Inc
31 Milk Street, # 960789 Boston, MA 02196 USA.
'''

import os
import sys
import linuxcnc
import math
import shutil
import time
from subprocess import run as RUN
from shutil import copy as COPY
import plasmac.block as CONVERT

INI = linuxcnc.ini(os.environ['INI_FILE_NAME'])
DIR = os.path.dirname(os.environ['INI_FILE_NAME'])
if 'axis' in INI.find('DISPLAY', 'DISPLAY'):
    from tkinter import Tk, Label, Text, Scrollbar, Button
    GUI = 'axis'
else:
    from qtpy.QtCore import Qt
    from qtpy.QtGui import QIcon
    from qtpy.QtWidgets import QApplication, QDialog, QScrollArea, QWidget, QVBoxLayout, QLabel, QPushButton, QStyle, QFrame
    GUI = 'qtplasmac'

class Filter():
    def __init__(self, *args):
        super().__init__()
        self.inFile = args[0][1]
        # run-from-line files do not require processing
        if os.path.basename(self.inFile) == 'rfl.ngc':
            with open(self.inFile, 'r') as inLines:
                for line in inLines:
                    print(line.strip())
            sys.exit()
        self.set_gui_type()
        self.machine = INI.getstring('EMC', 'MACHINE', fallback="<unknown>")
        self.filteredBkp = f'{self.tmpPath}/filtered_bkp.ngc'
        self.errorFile = f'{self.tmpPath}/gcode_errors.txt'
        self.materialFile = f'{self.machine}_material.cfg'
        self.tmpMaterialFile = f'{self.tmpPath}/{self.machine}_material.gcode'
        self.tmpMatNum = 1000000
        self.tmpMatNam = ''
        self.prefsFile = self.machine + '.prefs'
        response = RUN(['halcmd', 'getp', self.cutTypePin], capture_output=True)
        self.cutType = int(response.stdout.decode())
        response = RUN(['halcmd', 'getp', self.matNumPin], capture_output=True)
        self.currentMat = int(response.stdout.decode())
        response = RUN(['halcmd', 'getp', 'plasmac.max-offset'], capture_output=True)
        zMaxOffset = float(response.stdout.decode())
        RUN(['halcmd', 'setp', 'plasmac.tube-cut', '0'])
        self.metric = ['mm', 4]
        self.imperial = ['in', 5]
        self.units, self.fmt = self.imperial if INI.find('TRAJ', 'LINEAR_UNITS').lower() == 'inch' else self.metric
        if self.units == 'mm':
            self.minDiameter = 32
            self.ocLength = 4
            self.unitsPerMm = 1
            self.blendTolerance = 0.1
        else:
            self.minDiameter = 1.26
            self.ocLength = 0.157
            self.unitsPerMm = 0.03937
            self.blendTolerance = 0.004
        self.unitMultiplier = 1
        self.offsetTopZ = zMaxOffset * self.unitsPerMm * self.unitMultiplier
        self.gcodeList = []
        self.firstMaterial = ''
        self.data = ''
        self.rapidLine = ''
        self.lastG = ''
        self.lastX = 0
        self.lastY = 0
        self.oBurnX = 0
        self.oBurnY = 0
        self.lineNum = 0
        self.lineNumOrg = 0
        self.distMode = 90  # absolute
        self.arcDistMode = 91.1  # incremental
        self.holeVelocity = 60
        self.currentMaterial = [0, False]
        self.overCut = False
        self.holeActive = False
        self.holeEnable = False
        self.arcEnable = False
        self.customDia = False
        self.customLen = False
        self.torchEnable = True
        self.pierceOnly = False
        self.scribing = False
        self.spotting = False  # cannot spot in pierce mode
        self.scribePierce = False  # cannot scribe in pierce mode
        self.offsetG4x = False
        self.zSetup = False
        self.zBypass = False
        self.tubeCut = False
        self.pathBlend = False
        self.firstMove = False
        self.subList = []
        self.pierceList = {'active': False, 'X': [], 'Y': []}
        self.codeError = False
        self.errors = 'The following errors will affect the process.\n'
        self.errors += 'Errors must be fixed before reloading this file.\n'
        self.errorMath = []
        self.errorMissMat = []
        self.errorNoMat = []
        self.errorBadMat = []
        self.errorTempMat = []
        self.errorTempValid = []
        self.errorTempParm = []
        self.errorNewMat = []
        self.errorEditMat = []
        self.errorWriteMat = []
        self.errorReadMat = []
        self.errorCompMat = []
        self.errorFirstMove = []
        self.errorLines = []
        self.errorG92Offset = []
        self.errorBlockHead = []
        self.errorBlockFormat = []
        self.codeWarn = False
        self.warnings = 'The following warnings may affect the quality of the process.\n'
        self.warnings += 'It is recommended that all warnings are fixed before running this file.\n'
        self.warnUnitsDep = []
        self.warnPierceScribe = []
        self.warnPierceLimit = []
        self.warnMatLoad = []
        self.warnHoleDir = []
        self.warnCompTorch = []
        self.warnCompVel = []
        self.warnFeed = []
        self.warnChar = []
       # if this is a conversational block file and it is not a current version then upgrade it
        with open(self.inFile, 'r') as inLines:
            line = inLines.readline().strip()
            if line[:21] == ';conversational block':
                if line == ';conversational block':
                    inputs, preCode, gCode, postCode = CONVERT.convert_v1_code(self.inFile)
                    if gCode:
                        name, ext = os.path.splitext(self.inFile)
                        COPY(self.inFile, f'{name}_conv_block_v1{ext}')
                        CONVERT.write_block(self.inFile, inputs, preCode, gCode, postCode)
                    else:
                        self.set_code_error()
                        self.errorBlockFormat.append(1)
                        self.errorLines.append(1)
                elif line[22:24] != 'V2':
                    self.set_code_error()
                    self.errorBlockHead.append(0)
                    self.errorLines.append(0)
        # create a dict of material numbers and kerf widths
        self.get_materials()
        # setup for custom filtering
        self.cfFile = os.path.join(DIR, 'custom_filter.py')
        if not os.path.isfile(self.cfFile):
            self.cfFile = None
        if self.cfFile:
            exec(open(self.cfFile).read())
        # process the g-code file line by line
        self.process_file()
        # for pierce only mode
        if self.pierceOnly:
            self.gcodeList.append('')
            if self.rapidLine:
                self.gcodeList.append(self.rapidLine)
            self.gcodeList.append('M02 (END)')
        # remove last G00 coordinates if no pierce afterwards
        if self.pierceList['active']:
            del self.pierceList['X'][-1:]
            del self.pierceList['Y'][-1:]
            self.pierceList['active'] = False
        # write the pierce extents hal pins
        if GUI == 'axis':
            RUN(['halcmd', 'setp', 'axisui.x_min_pierce_extent', str(min(self.pierceList['X']) if self.pierceList['X'] else 0)])
            RUN(['halcmd', 'setp', 'axisui.y_min_pierce_extent', str(min(self.pierceList['Y']) if self.pierceList['Y'] else 0)])
            RUN(['halcmd', 'setp', 'axisui.x_max_pierce_extent', str(max(self.pierceList['X']) if self.pierceList['X'] else 0)])
            RUN(['halcmd', 'setp', 'axisui.y_max_pierce_extent', str(max(self.pierceList['Y']) if self.pierceList['Y'] else 0)])
        else:
            RUN(['halcmd', 'setp', 'qtplasmac.x_min_pierce_extent', str(min(self.pierceList['X']) if self.pierceList['X'] else 0)])
            RUN(['halcmd', 'setp', 'qtplasmac.y_min_pierce_extent', str(min(self.pierceList['Y']) if self.pierceList['Y'] else 0)])
            RUN(['halcmd', 'setp', 'qtplasmac.x_max_pierce_extent', str(max(self.pierceList['X']) if self.pierceList['X'] else 0)])
            RUN(['halcmd', 'setp', 'qtplasmac.y_max_pierce_extent', str(max(self.pierceList['Y']) if self.pierceList['Y'] else 0)])
        # error and warning notifications
        if self.codeError or self.codeWarn:  # show errors if any
            self.write_errors()
        else:  # create empty error file if no errors
            with open(self.errorFile, 'w'):
                pass
        # write the final g-code
        self.write_gcode()


    def log(self, text):
        ''' helpful for debugging '''
        with open('/tmp/qtplasmac/filter.log', 'a') as logger:
            logger.write(f"{text}\n")


    def process_file(self):
        ''' process the file and parse any lines of code
        '''
        with open(self.inFile, 'r') as inLines:
            lines = inLines.readlines()
            text = ';qtplasmac filtered G-code file\n'
            # no need to process an already filtered file
            if text in lines:
                for line in lines:
                    self.gcodeList.append(line.strip())
                return
        with open(self.inFile, 'r') as inLines:
            for line in inLines:
                self.lineNum += 1
                self.lineNumOrg += 1
                # if conversational block header is found on any line other than #1 then it is an error
                if ';conversational block' in line and self.lineNum > 1:
                    self.gcodeList.append(line.strip())
                    self.gcodeList.append('; move the line above to line #1\n')
                    self.set_code_error()
                    self.errorBlockHead.append(self.lineNum)
                    self.errorLines.append(self.lineNumOrg)
                    return
                # allow custom processing before standard processing
                if self.cfFile:
                    line = self.custom_pre_process(line)
                    if not line:
                        continue
                # remove leading and trailing whitespace and trailing periods
                line = line.strip().rstrip('.')
                # if empty line then no need to process
                if not line:
                    self.gcodeList.append(line)
                    continue
                # remove line numbers
                if line[0] in 'nN':
                    line = self.remove_line_numbers(line)
                # remove lines with ;qtplasmac filtered G-code file
                if ';qtplasmac filtered G-code file' in line:
                    continue
                # if any obvious illegal characters then comment the line
                if line[0] != ';' and self.illegal_character(line):
                    continue
                # check for material edit
                if line[:3] == '(o=':
                    self.check_material_edit(line)
                    # add comment and material change for temporary material
                    if line[3] == '0':
                        self.lineNum += 3
                        self.gcodeList.append(f';temporary material #{self.tmpMatNum}')
                        self.gcodeList.append(line)
                        self.gcodeList.append(f'M190 P{self.tmpMatNum}')
                        self.gcodeList.append('M66 P3 L3 Q1')
                        if not self.firstMaterial:
                            self.firstMaterial = self.tmpMatNum
                        self.tmpMatNum += 1
                    else:
                        self.gcodeList.append(line)
                    continue
                # full line comments - only remove line numbers
                elif line[0] in ';(':
                    if len(line) > 1:
                        l0 = line[0]
                        tmp = line[1:].strip()
                        if tmp[0] in 'nN':
                            line = f'{l0}{self.remove_line_numbers(tmp)}'
                    self.gcodeList.append(line)
                    continue
                # comments after code - parse the code
                elif ';' in line or '(' in line:
                    for tag in ';(':
                        both = line.split(tag)
                        if len(both) == 1:
                            continue
                        code = self.parse_code(both[0])
                        cmnt = both[1]
                        if code:
                            line = f'{code}{tag}{cmnt}'
                        else:
                            line = f'{tag}{cmnt}'
                # code only - parse the code
                else:
                    line = self.parse_code(line)
                    if not line:
                        continue
                # restore velocity if required
                if self.holeActive:
                    self.lineNum += 1
                    line = f'{line}\nM67 E3 Q0 (arc complete, velocity 100%)'
                    self.holeActive = False
                if line:
                    if self.holeEnable and len(line) and ('X' in line or 'X' in line):
                        self.lastX, self.lastY = self.set_last_coordinates(line, self.lastX, self.lastY)
                    self.gcodeList.append(line)


    def parse_code(self, data):
        # set g and m codes to upper case
        data = self.set_to_upper_case(data)
        # allow custom parsing before standard code parsing
        if self.cfFile:
            data = self.custom_pre_parse(data)
            if not data:
                return(None)
        # set the current g-code
        self.lastG = self.set_last_gcode(data, self.lastG)
        # if data starts with axis then preface with last g-code
        if data[0] in 'XYZABC':
            data = f'G{self.lastG} {data}'
        # add leading 0's to G & M codes < 10
        tmp = ''
        while data:
            tmp += data[0]
            if data[0] in 'GM' and data[1].isdigit():
                if len(data) == 2:
                    tmp += '0'
                elif len(data) > 2:
                    if not data[2].isdigit():
                        tmp += '0'
            data = data[1:]
        data = tmp
        # get all G00 coordinates
        if (data[:3] == 'G00' and ('X' in data or 'Y' in data)):
            pierceX = self.lastX
            pierceY = self.lastY
            if 'X' in data and not self.check_math(data, 'X', 'pierce'):
                pierceX = self.get_axis_value(data, 'X')
            if 'Y' in data and not self.check_math(data, 'Y', 'pierce'):
                pierceY = self.get_axis_value(data, 'Y')
            self.pierceList['X'].append(pierceX)
            self.pierceList['Y'].append(pierceY)
            self.pierceList['active'] = True
        # reset G00 active flag
        if data[:3] == 'M03' and self.pierceList['active']:
            self.pierceList['active'] = False
        # disallow g92 offsets in gcode
        if 'G92' in data and 'G92.1' not in data:
            self.set_g92_detected()
        # if incremental distance mode fix overburn coordinates
        if data[:3] in ['G00', 'G01'] and self.distMode == 91 and (self.oBurnX or self.oBurnY) and not self.spotting:
            data = self.fix_overburn_incremental_coordinates(data)
        # set path blending
        if 'G64' in data:
            self.pathBlend = True
        # set default units
        if 'G20' in data or 'G21' in data:
            self.set_default_units(data)
        # check for G40 G41 or G42 offsets
        if 'G40' in data or 'G41' in data or 'G42' in data:
            data = self.set_g4x_offsets(data)
        # if z motion is to be kept
        if data.replace(' ', '').startswith('#<keep-z-motion>='):
            self.set_keep_z_motion(data)
        # remove any existing z max moves
        if '[#<_ini[axis_z]max_limit>' in data and not self.zBypass:  # and self.zSetup:
            return(None)
        # set first movement flag
        if not self.firstMove and not self.zBypass and (('G00' in data or 'G01' in data) and ('X' in data or 'Y' in data)):
            self.set_first_move()
        # is there an m3 before motion started
        if not self.firstMove and 'M03' in data:
            self.set_no_first_move()
        # if path blending not set and motion started
        if not self.pathBlend and 'M03' in data:
            self.set_default_blending()
        # if pierce only mode
        if self.pierceOnly:
            data = self.do_pierce_only(data)
            if not data:
                return(None)
        # is this a scribe
        if data.startswith('M03 $1 S') and not self.tubeCut:
            self.set_scribing()
        # is this a spot
        if data.startswith('M03 $2 S') and not self.pierceOnly and not self.tubeCut:
            self.spotting = True
        # test for pierce only mode
        elif data.replace(' ', '').startswith('#<pierce-only>=1') or self.cutType == 1:
            self.set_pierce_mode()
        # set overcut length
        elif data.startswith('#<oclength>'):
            self.set_overcut_length(data)
            return data
        # set hole type
        elif data.startswith('#<holes>'):
            self.set_hole_type(data)
            return data
        # set hole diameter
        elif data[:2] == '#<' and data[3:13] == '_diameter>':
            self.set_hole_diameter(data)
            return data
        # set hole velocity
        elif data.startswith('#<h_velocity>'):
            self.set_hole_velocity(data)
            return data
        # tube cutting
        if data.startswith('#<tube-cut>=1'):
            data = self.set_tube_cut(data)
        # change material
        if data[:4] == 'M190':
            self.do_material_change(data)
        # wait for material change
        if 'M66' in data:
            self.material_change_wait()
        # set arc modes
        if 'G90' in data and 'G90.' not in data:
            self.distMode = 90  # absolute distance mode
        elif 'G91' in data and 'G91.' not in data:
            self.distMode = 91  # incremental distance mode
        if 'G91.1' in data:
            self.arcDistMode = 91.1  # incremental arc distance mode
        elif 'G90.1' in data:
            self.arcDistMode = 90.1  # absolute arc distance mode
        # comment out z axis motion
        if 'Z' in data \
            and data.split('Z')[1][0] in '0123456789.- [' \
                and '[axis_z]max_limit' not in data \
                    and not self.zBypass:
            data = self.comment_z_commands(data)
        # check the feed rate
        if 'F' in data and not self.tubeCut:
            data = self.check_f_word(data)
        # if an arc command
        if (data[:3] == 'G02' or data[:3] == 'G03'):
            data = self.do_arc(data)
        # if torch off, flag it then self.gcodeList.append it
        elif data[:6] == 'M62 P3' or data[:6] == 'M64 P3':
            self.torchEnable = False
        # if torch on, flag it then self.gcodeList.append it
        elif data[:6] == 'M63 P3' or data[:6] == 'M65 P3':
            self.torchEnable = True
        # if spindle off
        elif data[:3] == 'M05':
            data = self.spindle_off(data)
        # if program end
        elif data[:3] in ['M02', 'M30'] or data[0] == '%':
            data = self.program_end(data)
        # allow custom parsing after standard code parsing
        if self.cfFile:
            data = self.custom_post_parse(data)
            if not data:
                return(None)
        return data

    def custom_pre_process(self, line):
        ''' placeholder function for custom processing
            before standard processing '''
        return(line)

    def custom_pre_parse(self, data):
        ''' placeholder function for custom parsing
            before standard code parsing '''
        return(data)

    def custom_post_parse(self, data):
        ''' placeholder function for custom parsing
            after standard code parsing '''
        return(data)

    def write_gcode(self):
        with open(self.filteredBkp, 'w') as outFile:
            for data in self.gcodeList:
                print(data)
                outFile.write(f'{data}\n')
            print(';qtplasmac filtered G-code file')
            outFile.write(';qtplasmac filtered G-code file')

    def set_to_upper_case(self, data):
        tmp = ''
        keep = False
        for d in data:
            if d in '#':
                keep = True
                tmp += d
            elif d in '>':
                keep = False
                tmp += d
            else:
                if keep:
                    tmp += d
                else:
                    tmp += d.upper()
        return tmp

    def get_axis_value(self, data, axis, block=False):
        tmp1 = data.split(axis)[1].replace(' ', '')
        # if first char is not valid return None
        if not tmp1[0].isdigit() and not tmp1[0] == '.' and not tmp1[0] == '-':
            return None
        n = 0
        tmp2 = ''
        while 1:
            if tmp1[n].isdigit() or tmp1[n] == '.' or tmp1[n] == '-':
                tmp2 += tmp1[n]
                n += 1
            else:
                break
            if n >= len(tmp1):
                break
        return float(tmp2)

    def set_last_coordinates(self, data, Xpos, Ypos):
        if data[0] in 'GXY':
            if 'X' in data:
                if self.get_axis_value(data, 'X') is not None:
                    if self.distMode == 91:  # get absolute X from incremental X position
                        Xpos += self.get_axis_value(data, 'X')
                    else:  # get absolute X
                        Xpos = self.get_axis_value(data, 'X')
            if 'Y' in data:
                if self.get_axis_value(data, 'Y') is not None:
                    if self.distMode == 91:  # get absolute Y from incremental Y position
                        Ypos += self.get_axis_value(data, 'Y')
                    else:  # get absolute X
                        Ypos = self.get_axis_value(data, 'Y')
        return Xpos, Ypos

    def check_math(self, data, axis, code='arc'):
        ''' check if math used or explicit values
        '''
        tmp1 = data.split(axis)[1]
        if tmp1.startswith('[') or tmp1.startswith('#'):
            if code == 'pierce':
                self.codeWarn = True
                if self.lineNum not in self.warnPierceLimit:
                    self.warnPierceLimit.append(self.lineNum)
                    self.errorLines.append(self.lineNumOrg)
            else:
                self.set_code_error()
                if self.lineNum not in self.errorMath:
                    self.errorMath.append(self.lineNum)
                    self.errorLines.append(self.lineNumOrg)
            return True
        return False

    def illegal_character(self, data):
        ''' if illegal characters found then comment the line
        '''
# FIXME 1 we could probably do more here
# FIXME 2 not even sure we should bother with this
#        maybe just leave it to the interpreter
        code = data.replace(' ', '')
        err = 0
        # single character code with invalid character
        if len(code) == 1 and code not in '/;%':
            err = 1
        # comment is missing a parenthesis
        elif ('(' in code and code[-1] != ')') or ((code[-1] == ')' and '(' not in code)):
            err = 2
        # line starts with two alpha characters
        elif code[0].isalpha() and code[1].isalpha():
            err = 3
        # invalid first character
        elif not code[0].isalpha() and code[0] not in '/;(#@^%':
            err = 4
        # process numbered and named parameters
        if code[0] == '#' or code[:2] == '#<':
            code = code.lstrip('#')
            # remove trailing comment for further processing
            if '(' in code:
                code = code.split('(')[0].strip()
            # parameter is missing equals sign
            if '=' not in code:
                err = 5
            else:
                try:
                    # left = parameter, right = value (we don't process right side yet)
                    left, right = code.split('=')
                    # variable is not currently used
                    del right
                    # named parameter is missing a chevron
                    if left[0] == '<' and '>' not in left:
                        err = 6
                    # numbered parameter is not a number
                    elif left[0] != '<' and not left.isdigit():
                        err = 7
                except:
                    # parameter has no value
                    err = 8
        if err:
            errs = [None]
            errs.append('single character line with invalid character')
            errs.append('comment is missing a parenthesis')
            errs.append('line starts with two alpha characters')
            errs.append('invalid first character')
            errs.append('parameter is missing equals sign')
            errs.append('named parameter is missing a chevron')
            errs.append('numbered parameter is not a number')
            errs.append('parameter has no value')
            self.codeWarn = True
            self.warnChar.append(self.lineNum)
            self.errorLines.append(self.lineNumOrg)
            self.gcodeList.append(f';{data} |{errs[err]}')
        return err

    def remove_line_numbers(self, data):
        idx = 1
        while data[idx].isdigit() or data[idx] in ' .':
            idx += 1
        data = f'{data[idx:]}'
        return data

    def set_last_gcode(self, data, previous):
        new = ''
        idx = data.rfind('G') + 1
        if idx:
            while data[idx].isdigit():
                new += data[idx]
                idx += 1
                if idx == len(data):
                    break
        if len(new):
            return new
        else:
            return previous

    def set_default_units(self, data):
        if 'G21' in data:
            if self.units == 'in':
                self.unitMultiplier = 25.4
                if not self.customDia:
                    self.minDiameter = 32
                if not self.customLen:
                    self.ocLength = 4
        else:
            if self.units == 'mm':
                self.unitMultiplier = 0.03937
                if not self.customDia:
                    self.minDiameter = 1.26
                if not self.customLen:
                    self.ocLength = 0.157

    def set_g4x_offsets(self, data):
        if 'G40' in data:
            self.offsetG4x = False
        else:
            self.offsetG4x = True
            if 'kerf_width-f]>' in data and self.unitMultiplier != 1:
                data = data.replace('#<_hal[qtplasmac.kerf_width-f]>',
                                    f'[#<_hal[qtplasmac.kerf_width-f]> * {self.unitMultiplier}]')
        return(data)

    def set_first_move(self):
        self.firstMove = True
        if not self.zSetup and not self.zBypass:
            self.lineNum += 1
            moveTopZ = 'G53 G00 Z[[#<_ini[axis_z]max_limit> - '
            moveTopZ += f'{self.offsetTopZ:0.{self.fmt}f}] * {self.unitMultiplier:0.{self.fmt}f}]'
            moveTopZ += ' (Z just below max height)'
            self.gcodeList.append(moveTopZ)
            self.zSetup = True

    def set_no_first_move(self):
        self.set_code_error()
        self.errorFirstMove.append(self.lineNum)
        self.errorLines.append(self.lineNumOrg)

    def set_g92_detected(self):
        self.set_code_error()
        self.errorG92Offset.append(self.lineNum)
        self.errorLines.append(self.lineNumOrg)

    def set_default_blending(self):
        blend = self.blendTolerance * self.unitMultiplier
        self.gcodeList.append(f'G64 P{blend}')
        self.pathBlend = True

    def set_scribing(self):
        if self.pierceOnly:
            self.codeWarn = True
            self.warnPierceScribe.append(self.lineNum)
            self.errorLines.append(self.lineNumOrg)
            self.scribing = False
        else:
            self.scribing = True

    def set_pierce_mode(self):
        if self.scribing:
            self.codeWarn = True
            self.warnPierceScribe.append(self.lineNum)
            self.errorLines.append(self.lineNumOrg)
        elif not self.pierceOnly:
            self.pierceOnly = True
            self.pierces = 0
            self.rapidLine = ''

    def do_pierce_only(self, data):
            if 'Z' in data \
                and data.split('Z')[1][0] in '0123456789.- [' \
                    and '[axis_z]max_limit' not in data:
                data = self.comment_z_commands(data)
            # Don't pierce spotting operations
            if data[:6] == 'M03 $2':
                self.spotting = True
                self.gcodeList.append('(Ignoring spotting operation as pierce-only is active)')
                return None
            if data[:6] == 'M03 $1':
                self.scribePierce = True
                self.gcodeList.append('(Ignoring scribing operation as pierce-only is active)')
                return None
            # Ignore spotting blocks when pierceOnly
            if self.spotting:
                if data[:6] == 'M05 $2':
                    self.firstMove = False
                    self.spotting = False
                return None
            # Ignore spotting blocks when pierceOnly
            if self.scribePierce:
                if data[:6] == 'M05 $1':
                    self.firstMove = False
                    self.scribePierce = False
                return None
            # set offsets for pierce X/Y coordinates
            if data[:3] == 'G00':
                idx, brackets, start, end = 0, 0, 0, 0
                tmp = ''
                for axis in 'XY':
                    if axis in data:
                        if GUI == 'axis':
                            offset = f"[#<_hal[axisui.{axis.lower()}-pierce-offset]> * {self.unitMultiplier}]"
                        else:
                            offset = f"[#<_hal[qtplasmac.{axis.lower()}_pierce_offset-f]> * {self.unitMultiplier}]"
                        start = data.index(axis)
                        tmp = data[:start]
                        idx = start + 1
                        while data[idx] == ' ':
                            idx += 1
                        if data[idx] == '[':
                            while 1:
                                if data[idx] == '[':
                                    brackets += 1
                                elif data[idx] == ']':
                                    brackets -= 1
                                if not brackets:
                                    end = idx
                                    break
                                idx += 1
                                if idx == len(data):
                                    break
                            data = f"{tmp}{data[start]}[{data[start+1:end+1]} + {offset}]{data[end+1:]}"
                        else:
                            while 1:
                                if data[idx] in 'XYZABC ':
                                    if '2.3652' in data:
                                        print(f';idx:{idx}   char:{data[idx]}')
                                    break
                                idx += 1
                                if idx == len(data):
                                    break
                            end = idx
                            data = f"{tmp}{data[start]}[{data[start+1:end]} + {offset}]{data[end:]}"
                self.rapidLine = data
                return None
            # create the pierce only gcode
            elif data[:3] == 'M03':
                self.pierces += 1
                self.gcodeList.append(f'(Pierce #{self.pierces})')
                self.gcodeList.append(self.rapidLine)
                self.gcodeList.append('M03 $0 S1')
                self.gcodeList.append('G91')
                self.gcodeList.append('G01 X.000001')
                self.gcodeList.append('G90\nM05 $0')
                self.rapidLine = ''
                return None
            if not self.pierces or data.startswith('O') or data.startswith('#'):
                self.gcodeList.append(data)
            return None

    def set_keep_z_motion(self, data):
        if data.split('=')[1].strip() == '1':
            self.zBypass = True
        else:
            self.zBypass = False

    def comment_z_commands(self, data):
        # if no other axes comment the complete data
        if 1 not in [c in data for c in 'XYABCUVW']:
            return(f'({data} Z axis commented out)')
        # other axes in data so comment out the Z axis only
        else:
            newline = ''
            newz = ''
            commenting = 0
            maths = 0
            for bit in data:
                if commenting:
                    if bit == '[':
                        newz += bit
                        maths += 1
                    elif bit == ']':
                        newz += bit
                        maths -= 1
                    elif maths:
                        newz += bit
                    elif bit in '0123456789.- ':
                        newz += bit
                    else:
                        commenting = 0
                        if newz:
                            newz = newz.rstrip()
                        newline += bit
                elif bit == 'Z':
                    commenting = 1
                    newz += '(' + bit
                else:
                    newline += bit
            if self.holeActive:
                self.lineNum += 1
                self.gcodeList.append('M67 E3 Q0 (arc complete, velocity 100%)')
                self.holeActive = False
            return(f'{newline} {newz} Z axis commented out)')

    def check_f_word(self, data):
        begin, inFeed = data.split('F', 1)
        inFeed = inFeed.replace(' ', '')
        # if feed rate from material file
        if inFeed.startswith('#<_hal[plasmac.cut-feed-rate]>'):
            # change feed rate if g-code file not in same units as machine units
            if self.unitMultiplier != 1:
                data = f'{begin}F[#<_hal[plasmac.cut-feed-rate]> * {self.unitMultiplier}]'
            return data
        # if explicit feed rate
        rawFeed = ''
        codeFeed = 0.0
        # get feed rate if it is digits
        while len(inFeed) and (inFeed[0].isdigit() or inFeed[0] == '.'):
            rawFeed = rawFeed + inFeed[0]
            inFeed = inFeed[1:].lstrip()
        if not rawFeed:
            return data
        codeFeed = float(rawFeed)
        matFeed = float(self.materialDict[self.currentMaterial[0]][0]) * self.unitMultiplier
        # this may need scaling ...
        diff = 1
        if (codeFeed < matFeed - diff or codeFeed > matFeed + diff) and matFeed != 0:
            self.codeWarn = True
            self.warnFeed.append([self.lineNum, rawFeed, self.currentMaterial[0], self.materialDict[self.currentMaterial[0]][0]])
            self.errorLines.append(self.lineNumOrg)
        return data

    def set_tube_cut(self, data):
        self.tubeCut = True
        self.zBypass = True
        RUN(['halcmd', 'setp', 'plasmac.tube-cut', '1'])
        self.lineNum += 3
        data = f'\n;tube cutting is experimental\n{data}\n'
        return data

    def spindle_off(self, data):
        if len(data) == 3 or (len(data) > 3 and not data[3].isdigit()):
            self.firstMove = False
            # restore velocity if required
            if self.holeActive:
                self.lineNum += 1
                data = f'{data}\nM68 E3 Q0 (arc complete, velocity 100%)'
                self.holeActive = False
            # if torch off, allow torch on
            if not self.torchEnable:
                self.lineNum += 1
                data = f'{data}\nM65 P3 (enable torch)'
                self.torchEnable = True
            # if not pierce mode reset spotting flag
            if not self.pierceOnly:
                self.spotting = False
        return data

    def program_end(self, data):
        # restore velocity if required
        if self.holeActive:
            self.lineNum += 1
            data = f'M68 E3 Q0 (arc complete, velocity 100%)\n{data}'
            self.holeActive = False
        # if torch off, allow torch on
        if not self.torchEnable:
            self.lineNum += 1
            data = f'M65 P3 (enable torch)\n{data}'
            self.torchEnable = True
        # restore hole sensing to default
        if self.holeEnable:
            self.lineNum += 1
            data = f'#<holes>=0 (disable hole sensing)\n{data}'
            self.holeEnable = False
        if self.firstMaterial:
            RUN(['halcmd', 'setp', self.matNumPin, str(self.firstMaterial)])
        return data

    def set_gui_type(self):
        # assume gui to be qtplasmac unless a specific gui selected
        if GUI == 'axis':
            self.dialog = tkGui()
            self.tmpPath = '/tmp/plasmac'
            self.cutTypePin = 'axisui.cut-type'
            self.matNumPin = 'axisui.material-change-number'
            self.matTmpPin = 'axisui.material-temp'
            self.matReloadPin = 'axisui.material-reload'
        else:
            self.dialog = qtGui()
            self.tmpPath = '/tmp/qtplasmac'
            self.cutTypePin = 'qtplasmac.cut_type'
            self.matNumPin = 'qtplasmac.material_change_number'
            self.matTmpPin = 'qtplasmac.material_temp'
            self.matReloadPin = 'qtplasmac.material_reload'

##############################################################################
# HOLES AND ARCS
##############################################################################
    def do_arc(self, data):
        if self.holeEnable:
            stop = False
            # check if we can read the values correctly
            if 'X' in data:
                stop = self.check_math(data, 'X')
            if 'Y' in data and not stop:
                stop = self.check_math(data, 'Y')
            if 'I' in data and not stop:
                stop = self.check_math(data, 'I')
            if 'J' in data and not stop:
                stop = self.check_math(data, 'J')
            if not stop:
                data = self.check_if_hole(data)
        return(data)

    def set_overcut_length(self, data):
        if '=' not in data:
            return
        self.ocLength = float(data.split('=')[1])
        self.customLen = True

    def set_hole_type(self, data):
        if '=' not in data:
            return
        hT = int(data.split('=')[1])
        hE = [None, True, True, True, True, False]
        aE = [None, False, False, True, True, False]
        oC = [None, False, True, False, True, False]
        self.holeEnable = hE[hT]
        self.arcEnable = aE[hT]
        self.overCut = oC[hT]

    def set_hole_diameter(self, data):
        if '=' not in data:
            return
        self.minDiameter = float(data.split('=')[1])
        self.customDia = True
        # m_diameter and i_diameter are kept for legacy purposes, they may be removed in future
        if '#<m_d' in data or '#<i_d' in data:
            self.codeWarn = True
            self.warnUnitsDep.append(self.lineNum)
            self.errorLines.append(self.lineNumOrg)

    def set_hole_velocity(self, data):
        if '=' not in data:
            return
        self.holeVelocity = float(data.split('=')[1])

    def check_if_hole(self, data):
        I, J, isHole = 0, 0, 0
        if self.distMode == 91:  # get absolute X & Y from incremental coordinates
            endX = self.lastX + self.get_axis_value(data, 'X') if 'X' in data else self.lastX
            endY = self.lastY + self.get_axis_value(data, 'Y') if 'Y' in data else self.lastY
        else:  # get absolute X & Y
            endX = self.get_axis_value(data, 'X') if 'X' in data else self.lastX
            endY = self.get_axis_value(data, 'Y') if 'Y' in data else self.lastY
        if self.arcDistMode == 90.1:  # convert I & J to incremental to make diameter calculations easier
            if 'I' in data:
                I = self.get_axis_value(data, 'I') - self.lastX
            if 'J' in data:
                J = self.get_axis_value('J') - self.lastY
        else:  # get incremental I & J
            if 'I' in data:
                I = self.get_axis_value(data, 'I')
            if 'J' in data:
                J = self.get_axis_value(data, 'J')
        if self.lastX and self.lastY and self.lastX == endX and self.lastY == endY:
            isHole = True
        diameter = self.get_hole_diameter(data, I, J, isHole)
        if isHole and self.overCut and diameter <= self.minDiameter and self.ocLength:
            data = self.overburn(data, I, J, diameter / 2)
        else:
            self.lastX = endX
            self.lastY = endY
        return(data)

    def get_hole_diameter(self, data, I, J, isHole):
        ''' get hole diameter and set the velocity percentage
        '''
        if self.offsetG4x:
            diameter = math.sqrt((I ** 2) + (J ** 2)) * 2
        else:
            if self.currentMaterial[0] in self.materialDict:
                kerfWidth = self.materialDict[self.currentMaterial[0]][1] / 2 * self.unitMultiplier
            else:
                kerfWidth = 0
            diameter = (math.sqrt((I ** 2) + (J ** 2)) * 2) + kerfWidth
        # velocity reduction is required
        if diameter <= self.minDiameter and (isHole or self.arcEnable):
            if self.offsetG4x:
                self.lineNum += 1
                self.gcodeList.append(';M67 E3 Q0 (inactive due to G41)')
                self.codeWarn = True
                self.warnCompVel.append(self.lineNum)
                self.errorLines.append(self.lineNumOrg)
            elif not self.holeActive:
                if diameter <= self.minDiameter:
                    self.lineNum += 1
                    self.gcodeList.append(f'M67 E3 Q{self.holeVelocity} (arc diameter:{diameter:0.{self.fmt}f}, velocity:{self.holeVelocity}%)')
                self.holeActive = True
            if data[:3] == 'G02' and isHole:
                self.codeWarn = True
                self.warnHoleDir.append(self.lineNum)
                self.errorLines.append(self.lineNumOrg)
        # no velocity reduction required
        else:
            if self.holeActive:
                self.lineNum += 1
                self.gcodeList.append('M67 E3 Q0 (arc complete, velocity 100%)')
                self.holeActive = False
        return diameter

    def overburn(self, data, I, J, radius):
        ''' turn torch off and move 4mm (0.157") past hole end
        '''
        centerX = self.lastX + I
        centerY = self.lastY + J
        cosA = math.cos(self.ocLength / radius)
        sinA = math.sin(self.ocLength / radius)
        cosB = ((self.lastX - centerX) / radius)
        sinB = ((self.lastY - centerY) / radius)
        self.lineNum += 1
        if self.offsetG4x:
            data = f'{data}\n;M62 P3 (inactive due to G41)'
            self.codeWarn = True
            self.warnCompTorch.append(self.lineNum)
            self.errorLines.append(self.lineNumOrg)
        else:
            data = f'{data}\nM62 P3 (disable torch)'
            self.torchEnable = False
        # clockwise arc
        if data[:3] == 'G02':
            X = centerX + radius * ((cosB * cosA) + (sinB * sinA))
            Y = centerY + radius * ((sinB * cosA) - (cosB * sinA))
            G = '02'
        # counterclockwise arc
        else:
            X = centerX + radius * ((cosB * cosA) - (sinB * sinA))
            Y = centerY + radius * ((sinB * cosA) + (cosB * sinA))
            G = '03'
        self.lineNum += 1
        # restore I & J back to absolute from incremental conversion in check_if_hole
        if self.arcDistMode == 90.1:
            I += self.lastX
            J += self.lastY
        self.oBurnX = X - self.lastX
        self.oBurnY = Y - self.lastY
        if self.distMode == 91:  # output incremental X & Y
            data = f'{data}\nG{G} X{self.oBurnX:0.{self.fmt}f} Y{self.oBurnY:0.{self.fmt}f} I{I:0.{self.fmt}f} J{J:0.{self.fmt}f} (overburn)'
        else:  # output absolute X & Y
            data = f'{data}\nG{G} X{X:0.{self.fmt}f} Y{Y:0.{self.fmt}f} I{I:0.{self.fmt}f} J{J:0.{self.fmt}f} (overburn)'
        return(data)

    def fix_overburn_incremental_coordinates(self, data):
        newData = data[:3]
        if 'X' in data and 'Y' in data:
            x = self.get_axis_value(data, 'X')
            if x is not None:
                newData += f'X{x - self.oBurnX:0.{self.fmt}f}'
            y = self.get_axis_value(data, 'Y')
            if y is not None:
                newData += f'Y{y - self.oBurnY:0.{self.fmt}f}'
            return newData
        elif 'X' in data:
            x = self.get_axis_value(data, 'X')
            if x is not None:
                newData += f'X{x - self.oBurnX:0.{self.fmt}f} Y{self.oBurnY:0.{self.fmt}f}'
            return newData
        elif 'Y' in data:
            y = self.get_axis_value(data, 'Y')
            if y is not None:
                newData += f'X{self.oBurnX:0.{self.fmt}f} Y{y - self.oBurnY:0.{self.fmt}f}'
            return newData
        else:
            return data

##############################################################################
# MATERIAL HANDLING
##############################################################################

    def do_material_change(self, data):
        code = data.replace('M190', '').strip()
        # check for missing p or material
        if not len(code) or code[0] != 'P' or code == 'P':
            self.set_code_error()
            self.errorNoMat.append(self.lineNum)
            self.errorLines.append(self.lineNumOrg)
            return
        # get the material number
        try:
            num = int(code.replace('P', ''))
        except:
            num = -2
        if num < -1:
            self.set_code_error()
            self.errorBadMat.append(self.lineNum)
            self.errorLines.append(self.lineNumOrg)
            return
        self.currentMaterial[0] = num
        self.currentMaterial[1] = True
        # check if material exists in dict
        if self.currentMaterial[0] not in self.materialDict and self.currentMaterial[0] < 1000000 and self.currentMaterial[0] != -1:
            self.set_code_error()
            self.errorMissMat.append(self.lineNum)
            self.errorLines.append(self.lineNumOrg)
            return
        if not self.firstMaterial:
            self.firstMaterial = self.currentMaterial[0]

    def material_change_wait(self):
        if self.offsetG4x:
            self.set_code_error()
            self.errorCompMat.append(self.lineNum)
            self.errorLines.append(self.lineNumOrg)

    def check_material_edit(self, data):
        tmpMaterial = False
        newMaterial = []
        th = 0
        kw = jh = jd = pe = gp = mt = 0.0
        cm = 1
        ca = 15
        cv = 100
        if self.tubeCut:
            ph = ch = fr = 0.0
        try:
            if ('ph=' in data and 'pd=' in data and 'ch=' in data and 'fr=' in data) or \
               ('pd=' in data and self.tubeCut):
                if '(o=0' in data:
                    tmpMaterial = True
                    nu = self.tmpMatNum
                    na = f'Temporary {self.tmpMatNum}'
                    self.tmpMatNam = na
                    newMaterial.append(0)
                elif '(o=1' in data and 'nu=' in data and 'na=' in data:
                    newMaterial.append(1)
                elif '(o=2' in data and 'nu=' in data and 'na=' in data:
                    newMaterial.append(2)
                if newMaterial[0] in [0, 1, 2]:
                    for item in data.split('(')[1].split(')')[0].split(','):
                        # mandatory items
                        if 'nu=' in item and not tmpMaterial:
                            nu = int(item.split('=')[1])
                        elif 'na=' in item:
                            na = item.split('=')[1].strip()
                            if tmpMaterial:
                                self.tmpMatNam = na
                        elif 'ph=' in item:
                            ph = float(item.split('=')[1])
                            if self.unitMultiplier != 1:
                                ph = ph / self.unitMultiplier
                        elif 'pd=' in item:
                            pd = float(item.split('=')[1])
                        elif 'ch=' in item:
                            ch = float(item.split('=')[1])
                            if self.unitMultiplier != 1:
                                ch = ch / self.unitMultiplier
                        elif 'fr=' in item:
                            fr = float(item.split('=')[1])
                            if self.unitMultiplier != 1:
                                fr = fr / self.unitMultiplier
                        # optional items
                        elif 'kw=' in item:
                            kw = float(item.split('=')[1])
                            if self.unitMultiplier != 1:
                                kw = kw / self.unitMultiplier
                        elif 'th=' in item:
                            th = int(item.split('=')[1])
                        elif 'jh=' in item:
                            jh = float(item.split('=')[1])
                            if self.unitMultiplier != 1:
                                jh = jh / self.unitMultiplier
                        elif 'jd=' in item:
                            jd = float(item.split('=')[1])
                        elif 'ca=' in item:
                            ca = float(item.split('=')[1])
                        elif 'cv=' in item:
                            cv = float(item.split('=')[1])
                        elif 'pe=' in item:
                            pe = float(item.split('=')[1])
                        elif 'gp=' in item:
                            gp = float(item.split('=')[1])
                        elif 'cm=' in item:
                            cm = float(item.split('=')[1])
                        elif 'mt=' in item:
                            mt = float(item.split('=')[1])
                            if self.unitMultiplier != 1:
                                mt = mt / self.unitMultiplier
                    for i in [nu, na, kw, th, ph, pd, jh, jd, ch, fr, ca, cv, pe, gp, cm, mt]:
                        newMaterial.append(i)
                    if newMaterial[0] == 0:
                        self.set_temporary_material(newMaterial)
                    elif nu in self.materialDict and newMaterial[0] == 1:
                        self.set_code_error()
                        self.errorNewMat.append(self.lineNum)
                        self.errorLines.append(self.lineNumOrg)
                    else:
                        self.rewrite_material_file(data, newMaterial)
                else:
                    self.set_code_error()
                    self.errorEditMat.append(self.lineNum)
                    self.errorLines.append(self.lineNumOrg)
            else:
                self.set_code_error()
                self.errorTempParm.append(self.lineNum)
                self.errorLines.append(self.lineNumOrg)
        except:
            self.set_code_error()
            self.errorTempValid.append(self.lineNum)
            self.errorLines.append(self.lineNumOrg)

    def set_temporary_material(self, data):
        outFile = open(self.tmpMaterialFile, 'w')
        self.write_one_material(data, outFile, self.errorTempMat)
        outFile.close()
        self.materialDict[self.tmpMatNum] = [data[10], data[3]]
        RUN(['halcmd', 'setp', self.matTmpPin, str(self.tmpMatNum)])
        self.currentMaterial[0] = self.tmpMatNum
        matDelay = time.time()
        while 1:
            if time.time() > matDelay + 3:
                self.codeWarn = True
                self.warnMatLoad.append(self.lineNum)
                self.errorLines.append(self.lineNumOrg)
                break
            response = RUN(['halcmd', 'getp', self.matTmpPin], capture_output=True)
            if not int(response.stdout.decode()):
                break

    def rewrite_material_file(self, data, newMaterial):
        copyFile = f'{self.materialFile}.bkp'
        shutil.copy(self.materialFile, copyFile)
        inFile = open(copyFile, 'r')
        outFile = open(self.materialFile, 'w')
        while 1:
            data = inFile.readline()
            if not data:
                break
            if not data.strip().startswith('[MATERIAL_NUMBER_'):
                outFile.write(data)
            else:
                break
        while 1:
            if not data:
                self.write_one_material(newMaterial, outFile, self.errorWriteMat)
                break
            if data.strip().startswith('[MATERIAL_NUMBER_'):
                mNum = int(data.split('NUMBER_')[1].replace(']', ''))
                if mNum == newMaterial[1]:
                    self.write_one_material(newMaterial, outFile, self.errorWriteMat)
            if mNum != newMaterial[1]:
                outFile.write(data)
            data = inFile.readline()
            if not data:
                break
        if newMaterial[1] not in self.materialDict:
            self.write_one_material(newMaterial, outFile, self.errorWriteMat)
        inFile.close()
        outFile.close()
        RUN(['halcmd', 'setp', self.matReloadPin, '1'])
        self.get_materials()
        matDelay = time.time()
        while 1:
            if time.time() > matDelay + 3:
                self.codeWarn = True
                self.warnMatLoad.append(self.lineNum)
                self.errorLines.append(self.lineNumOrg)
                break
            response = RUN(['halcmd', 'getp', self.matReloadPin], capture_output=True)
            raw = response.stdout.decode().strip().upper()
            if raw in ['0', 'FALSE']:
                break

    def write_one_material(self, mat, file, err):
        try:
            file.write(f'[MATERIAL_NUMBER_{mat[1]}]\n')
            file.write(f'NAME               = {mat[2]}\n')
            file.write(f'KERF_WIDTH         = {mat[3]}\n')
            file.write(f'THC                = {mat[4]}\n')
            file.write(f'PIERCE_HEIGHT      = {mat[5]}\n')
            file.write(f'PIERCE_DELAY       = {mat[6]}\n')
            file.write(f'PUDDLE_JUMP_HEIGHT = {mat[7]}\n')
            file.write(f'PUDDLE_JUMP_DELAY  = {mat[8]}\n')
            file.write(f'CUT_HEIGHT         = {mat[9]}\n')
            file.write(f'CUT_SPEED          = {mat[10]}\n')
            file.write(f'CUT_AMPS           = {mat[11]}\n')
            file.write(f'CUT_VOLTS          = {mat[12]}\n')
            file.write(f'PAUSE_AT_END       = {mat[13]}\n')
            file.write(f'GAS_PRESSURE       = {mat[14]}\n')
            file.write(f'CUT_MODE           = {mat[15]}\n')
            file.write(f'THICKNESS          = {mat[16]}\n')
            file.write('\n')
        except:
            self.set_code_error()
            err.append(self.lineNum)
            self.errorLines.append(self.lineNumOrg)

    def get_materials(self):
        ''' create a dict of material numbers, feed rates and kerf widths
        '''
        try:
            with open(self.prefsFile, 'r') as rFile:
                fRate = kWidth = 0.0
                for data in rFile:
                    if data.startswith('Cut feed rate'):
                        fRate = float(data.split('=')[1].strip())
                    if data.startswith('Kerf width'):
                        kWidth = float(data.split('=')[1].strip())
            mNumber = 0
            with open(self.materialFile, 'r') as mFile:
                self.materialDict = {mNumber: [fRate, kWidth]}
                while 1:
                    data = mFile.readline()
                    if not data:
                        break
                    elif data.startswith('[MATERIAL_NUMBER_') and data.strip().endswith(']'):
                        mNumber = int(data.rsplit('_', 1)[1].strip().strip(']'))
                        break
                while 1:
                    data = mFile.readline()
                    if not data:
                        self.materialDict[mNumber] = [fRate, kWidth]
                        break
                    elif data.startswith('[MATERIAL_NUMBER_') and data.strip().endswith(']'):
                        self.materialDict[mNumber] = [fRate, kWidth]
                        mNumber = int(data.rsplit('_', 1)[1].strip().strip(']'))
                    elif data.startswith('CUT_SPEED'):
                        fRate = float(data.split('=')[1].strip())
                    elif data.startswith('KERF_WIDTH'):
                        kWidth = float(data.split('=')[1].strip())
        except:
            self.set_code_error()
            self.errorReadMat.append(self.lineNum)
            self.errorLines.append(self.lineNumOrg)

##############################################################################
# ERROR AND WARNING MESSAGING
##############################################################################

    def set_code_error(self):
        if not self.codeError:
            self.lineNum -= 1
        self.codeError = True

    def write_errors(self):
        errorText = ''
        warnText = ''
        with open(self.errorFile, 'w') as errFile:
            for data in self.errorLines:
                    errFile.write(f'{data}\n')
        if self.codeError:
            print('M02 (end program)')
            if self.errorMath:
                msg = 'G02 and G03 moves require explicit values if hole sensing is enabled.\n'
                errorText += self.message_set(self.errorMath, msg)
            if self.errorMissMat:
                msg = 'The Material selected is missing from the material file.\n'
                errorText += self.message_set(self.errorMissMat, msg)
            if self.errorNoMat:
                msg = 'A Material was not specified after M190.\n'
                errorText += self.message_set(self.errorNoMat, msg)
            if self.errorBadMat:
                msg = 'An invalid Material was specified after M190 P.\n'
                errorText += self.message_set(self.errorBadMat, msg)
            if self.errorTempMat:
                msg = 'Error attempting to add a temporary material.\n'
                errorText += self.message_set(self.errorTempMat, msg)
            if self.errorTempValid:
                msg = 'Invalid parameter in temporary material.\n'
                errorText += self.message_set(self.errorTempValid, msg)
            if self.errorTempParm:
                msg = 'Parameter missing from temporary material.\n'
                errorText += self.message_set(self.errorTempParm, msg)
            if self.errorNewMat:
                msg = 'Cannot add new material, number is in use.\n'
                errorText += self.message_set(self.errorNewMat, msg)
            if self.errorEditMat:
                msg = 'Cannot add or edit material from G-Code file with invalid parameter or value.\n'
                errorText += self.message_set(self.errorEditMat, msg)
            if self.errorWriteMat:
                msg = 'Error attempting to write to the material file.\n'
                errorText += self.message_set(self.errorWriteMat, msg)
            if self.errorReadMat:
                msg = 'Error attempting to read from the material file.\n'
                errorText += self.message_set(self.errorReadMat, msg)
            if self.errorCompMat:
                msg = 'Cannot validate a material change with cutter compensation active.\n'
                errorText += self.message_set(self.errorCompMat, msg)
            if self.errorFirstMove:
                msg = 'M03 command detected before movement.\n'
                errorText += self.message_set(self.errorFirstMove, msg)
            if self.errorG92Offset:
                msg = 'G92 offsets are not allowed.\n'
                errorText += self.message_set(self.errorG92Offset, msg)
            if self.errorBlockHead:
                if self.errorLines[0] == 0:
                    msg = 'Conversational block header is invalid.\n'
                    errorText += self.message_set(self.errorBlockHead, msg)
                else:
                    errorText = f'Conversational block header must be line 1, it is currently line {self.errorLines[0]}:\n'
            if self.errorBlockFormat:
                msg = 'Conversational block code format is inconsistent with header or is invalid.\n'
                errorText += self.message_set(self.errorBlockFormat, msg)
                errorText = msg
        if self.codeWarn:
            if self.warnUnitsDep:
                msg = '<m_diameter> and #<i_diameter> are deprecated in favour of #<h_diameter>.\n'
                msg += 'The diameter will be set in the current units of the G-Code file.\n'
                warnText += self.message_set(self.warnUnitsDep, msg)
            if self.warnPierceScribe:
                msg = 'Pierce only mode is invalid while scribing.\n'
                warnText += self.message_set(self.warnPierceScribe, msg)
            if self.warnPierceLimit:
                msg = 'Pierce limit checks require explicit X and Y values (no math) for G00 moves.\n'
                msg += 'Pierce points will be disregarded in G-Code limit checking.\n'
                warnText += self.message_set(self.warnPierceLimit, msg)
            if self.warnMatLoad:
                msg = 'Materials were not reloaded in a timely manner.\n'
                msg = 'Try reloading the G-Code file.\n'
                warnText += self.message_set(self.warnMatLoad, msg)
            if self.warnHoleDir:
                msg = 'This cut appears to be a hole, did you mean to cut it clockwise?\n'
                warnText += self.message_set(self.warnHoleDir, msg)
            if self.warnCompTorch:
                msg = 'Cannot enable/disable torch with G41/G42 compensation active.\n'
                warnText += self.message_set(self.warnCompTorch, msg)
            if self.warnCompVel:
                msg = 'Cannot reduce velocity with G41/G42 compensation active.\n'
                warnText += self.message_set(self.warnCompVel, msg)
            if self.warnFeed:
                for n in range(0, len(self.warnFeed)):
                    msg0 = 'Line'
                    msg1 = 'does not match Material'
                    msg2 = 'feed rate of'
                    warnText += f'{msg0} {self.warnFeed[n][0]:0.0f}: F{self.warnFeed[n][1]} {msg1}_{self.warnFeed[n][2]}\'s {msg2} {self.warnFeed[n][3]:0.0f}\n'
            if self.warnChar:
                msg = 'Invalid characters, data has been commented out.\n'
                warnText += self.message_set(self.warnChar, msg)
        self.dialog.dialog_box(self, 'G-Code Errors & Warnings', errorText, warnText)

    def message_set(self, msgType, msg):
        if len(msgType) > 1:
            msg += 'Lines: '
        else:
            msg += 'Line: '
        count = 0
        for data in msgType:
            if self.codeError:
                data += 1
            if count > 0:
                msg += f', {data}'
            else:
                msg += f'{data}'
            count += 1
        msg += '\n\n'
        return msg


class qtGui():
    def dialog_box(self, parent, title, errorText, warnText):
        icon = QStyle.SP_MessageBoxCritical
        app = QApplication(sys.argv)
        dlg = QDialog()
        scroll = QScrollArea(dlg)
        widget = QWidget()
        vbox = QVBoxLayout()
        labelN = QLabel(objectName='labelN')
        lineE = QFrame(objectName='lineE')
        lineE.setFrameShape(QFrame.HLine)
        labelE1 = QLabel(objectName='labelE1')
        labelE2 = QLabel()
        lineW = QFrame(objectName='lineW')
        lineW.setFrameShape(QFrame.HLine)
        labelW1 = QLabel(objectName='labelW1')
        labelW2 = QLabel()
        vbox.addWidget(labelN)
        vbox.addWidget(lineE)
        vbox.addWidget(labelE1)
        vbox.addWidget(labelE2)
        vbox.addWidget(lineW)
        vbox.addWidget(labelW1)
        vbox.addWidget(labelW2)
        widget.setLayout(vbox)
        btn = QPushButton('OK', dlg)
        dlg.setWindowTitle(title)
        dlg.setWindowIcon(QIcon(dlg.style().standardIcon(icon)))
        dlg.setWindowFlags(Qt.WindowStaysOnTopHint)
        dlg.setModal(False)
        dlg.setFixedWidth(600)
        dlg.setFixedHeight(310)
        scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
        scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
        scroll.setWidgetResizable(True)
        scroll.setWidget(widget)
        scroll.setGeometry(5, 5, 590, 250)
        btn.move(270, 260)
        btn.clicked.connect(dlg.accept)
        notice = 'If the G-code editor is used to resolve the following issues, the lines with errors\n'
        notice += 'will be highlighted. The data numbers may differ from what is shown below.\n\n'
        labelN.setText(notice)
        if errorText:
            labelE1.setText('errors')
            labelE2.setText(errorText)
        else:
            lineE.hide()
            labelE1.hide()
            labelE2.hide()
        if warnText:
            labelW1.setText('warnings')
            labelW2.setText(warnText)
        else:
            lineW.hide()
            labelW1.hide()
            labelW2.hide()
        fgColor, bgColor, bgAltColor = None, None, None
        with open(parent.prefsFile, 'r') as inFile:
            for line in inFile:
                if line.startswith('Foreground ='):
                    fgColor = line.split('=')[1].strip()
                elif line.startswith('Background ='):
                    bgColor = line.split('=')[1].strip()
                elif line.startswith('Background Alt ='):
                    bgAltColor = line.split('=')[1].strip()
                elif fgColor and bgColor and bgAltColor:
                    break
        dlg.setStyleSheet(f' \
                        * {{ color: {fgColor}; background: {bgColor}}} \
                        QScrollArea {{color:{fgColor}; background:{bgColor}; border:1px solid {fgColor}; border-radius:4px; padding:4px}} \
                        QPushButton {{border:2px solid {fgColor}; border-radius:4px; font:12pt; width:60px; height:40px}} \
                        QPushButton:pressed {{border:1px solid {fgColor}}} \
                        QScrollBar:vertical {{background:{bgAltColor}; border:0px; border-radius:4px; margin: 0px; width:20px}} \
                        QScrollBar::handle:vertical {{background:{fgColor}; border:2px solid {fgColor}; border-radius:4px; margin:2px; min-height:40px}} \
                        QScrollBar::add-line:vertical {{height:0px}} \
                        QScrollBar::sub-line:vertical {{height:0px}} \
                        QVboxLayout {{margin:100}} \
                        #labelN {{font-style:italic}} \
                        #lineE, #lineW {{border:1px solid {fgColor}}} \
                        #labelE1, #labelW1 {{font-weight:bold}}')
        dlg.exec()

        # prevents linter complaints
        return app


class tkGui():
    def dialog_box(self, parent, title, errorText, warnText):
        dlg = Tk()
        dlg.attributes('-type', 'popup_menu')
        dlg.overrideredirect(True)
        dlg.resizable(False, False)
        dlg.eval(f'tk::PlaceWindow {dlg} pointer')
        dlg.grid_columnconfigure(0, weight=1)
        dlg.grid_rowconfigure(1, weight=1)
        dlg['highlightthickness'] = 2
        dlg.wm_attributes("-topmost", True)
        dlg.option_add("*Font", ['sans', 10, 'normal'])
        dlg.geometry('566x360')
        lbl = Label(text=title)
        lbl.grid(row=0, column=0, columnspan=2, sticky='EW')
        txt = Text(dlg, padx=4, pady=4)
        txt.grid(row=1, column=0, sticky='EW', padx=[4, 0])
        sbr = Scrollbar(dlg, orient='vertical', command=txt.yview)
        sbr.grid(row=1, column=1, sticky='NS')
        txt['yscrollcommand'] = sbr.set
        btn = Button(text='OK', width=10, command=dlg.destroy)
        btn.grid(row=2, column=0, columnspan=2, pady=2)
        text = '\nThe line numbers in the original file may differ from what is shown below.\n\n'
        line = '____________________________________________________________________________\n'
        if errorText:
            text += line
            text += 'ERRORS:\n'
            text += errorText
        if warnText:
            text += line
            text += 'WARNINGS:\n'
            text += warnText
        txt.insert('end', text)
        txt['state'] = 'disabled'
        fgColor, bgColor, tColor = None, None, None
        with open(parent.prefsFile, 'r') as inFile:
            for line in inFile:
                if line.startswith('Foreground color'):
                    fgColor = line.split('=')[1].strip()
                elif line.startswith('Background color'):
                    bgColor = line.split('=')[1].strip()
                elif line.startswith('Trough color'):
                    tColor = line.split('=')[1].strip()
                elif fgColor and bgColor and tColor:
                    break
        lbl['bg'] = fgColor
        txt['fg'] = fgColor
        btn['fg'] = fgColor
        dlg['bg'] = bgColor
        lbl['fg'] = bgColor
        txt['bg'] = bgColor
        txt['highlightbackground'] = bgColor
        sbr['bg'] = bgColor
        sbr['activebackground'] = bgColor
        sbr['troughcolor'] = tColor
        btn['bg'] = bgColor
        btn['activebackground'] = bgColor
        btn['highlightbackground'] = bgColor
        dlg.mainloop()


app = Filter(sys.argv)
