diff --git a/src/Mod/CAM/Path/Post/Command.py b/src/Mod/CAM/Path/Post/Command.py index 042fcd4689..7cd45f54f2 100644 --- a/src/Mod/CAM/Path/Post/Command.py +++ b/src/Mod/CAM/Path/Post/Command.py @@ -27,21 +27,17 @@ Processor entries in PathJob """ import FreeCAD import FreeCADGui import Path -import Path.Base.Util as PathUtil -import Path.Main.Job as PathJob from PathScripts import PathUtils +from Path.Post.Utils import FilenameGenerator import os -import re - -from Path.Post.Processor import PostProcessor +from Path.Post.Processor import PostProcessor, PostProcessorFactory from PySide import QtCore, QtGui -from datetime import datetime from PySide.QtCore import QT_TRANSLATE_NOOP LOG_MODULE = Path.Log.thisModule() -debugmodule = False -if debugmodule: +DEBUG = False +if DEBUG: Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) Path.Log.trackModule(Path.Log.thisModule()) else: @@ -51,372 +47,29 @@ else: translate = FreeCAD.Qt.translate -class _TempObject: - Path = None - Name = "Fixture" - InList = [] - Label = "Fixture" - - -def processFileNameSubstitutions( - job, - subpartname, - sequencenumber, - outputpath, - filename, - ext, -): - """Process any substitutions in the outputpath or filename.""" - - # The following section allows substitution within the path part - Path.Log.track(f"path before substitution: {outputpath}") - - if "%D" in outputpath: # Directory of active document - D = FreeCAD.ActiveDocument.FileName - if D: - D = os.path.dirname(D) - # in case the document is in the current working directory - if not D: - D = "." - else: - FreeCAD.Console.PrintError( - "Please save document in order to resolve output path!\n" - ) - return None - outputpath = outputpath.replace("%D", D) - - if "%M" in outputpath: - M = FreeCAD.getUserMacroDir() - outputpath = outputpath.replace("%M", M) - - # Use the file label - if "%d" in outputpath: - d = FreeCAD.ActiveDocument.Label - outputpath = outputpath.replace("%d", d) - - # Use the name of the active job object - if "%j" in outputpath: - j = job.Label - outputpath = outputpath.replace("%j", j) - - Path.Log.track(f"path after substitution: {outputpath}") - - # The following section allows substitution within the filename part - Path.Log.track(f"filename before substitution: {filename}") - - # Use the file label - if "%d" in filename: - d = FreeCAD.ActiveDocument.Label - filename = filename.replace("%d", d) - - # Use the name of the active job object - if "%j" in filename: - j = job.Label - filename = filename.replace("%j", j) - - # Use the sequence number if explicitly called - if "%S" in filename: - j = job.Label - filename = filename.replace("%S", str(sequencenumber)) - - # This section handles unique names for splitting output - if job.SplitOutput: - Path.Log.track() - if "%T" in filename and job.OrderOutputBy == "Tool": - filename = filename.replace("%T", subpartname) - - if "%t" in filename and job.OrderOutputBy == "Tool": - filename = filename.replace("%t", subpartname) - - if "%W" in filename and job.OrderOutputBy == "Fixture": - filename = filename.replace("%W", subpartname) - - if "%O" in filename and job.OrderOutputBy == "Operation": - filename = filename.replace("%O", subpartname) - - if ( - "%S" in filename - ): # We always add a sequence number but the user can say where - filename = filename.replace("%S", str(sequencenumber)) - else: - filename = f"{filename}-{sequencenumber}" - - Path.Log.track(f"filename after substitution: {filename}") - - if not ext: - ext = ".nc" - Path.Log.track(f"file extension: {ext}") - - fullPath = f"{outputpath}{os.path.sep}{filename}{ext}" - - Path.Log.track(f"full filepath: {fullPath}") - return fullPath - - -def resolveFileName(job, subpartname, sequencenumber): - """Generate the file name to use as output.""" - - Path.Log.track(subpartname, sequencenumber) - - validPathSubstitutions = ["D", "d", "M", "j"] - validFilenameSubstitutions = ["j", "d", "T", "t", "W", "O", "S"] - - # Look for preference default - outputpath, filename = os.path.split(Path.Preferences.defaultOutputFile()) - filename, ext = os.path.splitext(filename) - - # Override with document default if it exists - if job.PostProcessorOutputFile: - candidateOutputPath, candidateFilename = os.path.split( - job.PostProcessorOutputFile - ) - - if candidateOutputPath: - outputpath = candidateOutputPath - - if candidateFilename: - filename, ext = os.path.splitext(candidateFilename) - - # Strip any invalid substitutions from the outputpath - for match in re.findall("%(.)", outputpath): - if match not in validPathSubstitutions: - outputpath = outputpath.replace(f"%{match}", "") - - # if nothing else, use current directory - if not outputpath: - outputpath = "." - - # Strip any invalid substitutions from the filename - for match in re.findall("%(.)", filename): - if match not in validFilenameSubstitutions: - filename = filename.replace(f"%{match}", "") - - # if no filename, use the active document label - if not filename: - filename = FreeCAD.ActiveDocument.Label - - # if no extension, use something sensible - if not ext: - ext = ".nc" - - # By now we should have a sanitized path, filename and extension to work with - Path.Log.track(f"path: {outputpath} name: {filename} ext: {ext}") - - fullPath = processFileNameSubstitutions( - job, - subpartname, - sequencenumber, - outputpath, - filename, - ext, - ) - - # This section determines whether user interaction is necessary - policy = Path.Preferences.defaultOutputPolicy() - - openDialog = policy == "Open File Dialog" - # if os.path.isdir(filename) or not os.path.isdir(os.path.dirname(filename)): - # # Either the entire filename resolves into a directory or the parent - # # directory doesn't exist. - # # Either way I don't know what to do - ask for help - # openDialog = True - - if not FreeCAD.GuiUp: # if testing, or scripting, never open dialog. - policy = "Append Unique ID on conflict" - openDialog = False - - if os.path.isfile(fullPath) and not openDialog: - if policy == "Open File Dialog on conflict": - openDialog = True - elif policy == "Append Unique ID on conflict": - fn, ext = os.path.splitext(fullPath) - nr = fn[-3:] - n = 1 - if nr.isdigit(): - n = int(nr) - while os.path.isfile(f"{fn}{n:03d}{ext}"): - n = n + 1 - fullPath = f"{fn}{n:03d}{ext}" - - if openDialog: - requestedfile = QtGui.QFileDialog.getSaveFileName( - QtGui.QApplication.activeWindow(), "Output File", fullPath - ) - if requestedfile[0]: - fullPath = requestedfile[0] - else: - fullPath = None - - if fullPath: - # remove any unused substitution strings: - for s in validPathSubstitutions + validFilenameSubstitutions: - fullPath = fullPath.replace(f"%{s}", "") - fullPath = os.path.normpath(fullPath) - Path.Log.track(fullPath) - - return fullPath - - -def fixtureSetup(order, fixture, job): - """Convert a Fixure setting to _TempObject instance with a G0 move to a - safe height every time the fixture coordinate system change. Skip - the move for first fixture, to avoid moving before tool and tool - height compensation is enabled. - - """ - - fobj = _TempObject() - c1 = Path.Command(fixture) - fobj.Path = Path.Path([c1]) - # Avoid any tool move after G49 in preamble and before tool change - # and G43 in case tool height compensation is in use, to avoid - # dangerous move without tool compesation. - if order != 0: - c2 = Path.Command( - "G0 Z" - + str( - job.Stock.Shape.BoundBox.ZMax - + job.SetupSheet.ClearanceHeightOffset.Value - ) - ) - fobj.Path.addCommands(c2) - fobj.InList.append(job) - return fobj - - -def buildPostList(job): - """Takes the job and determines the specific objects and order to - postprocess Returns a list of objects which can be passed to - exportObjectsWith() for final posting.""" - wcslist = job.Fixtures - orderby = job.OrderOutputBy - - postlist = [] - - if orderby == "Fixture": - Path.Log.debug("Ordering by Fixture") - # Order by fixture means all operations and tool changes will be - # completed in one fixture before moving to the next. - - currTool = None - for index, f in enumerate(wcslist): - # create an object to serve as the fixture path - sublist = [fixtureSetup(index, f, job)] - - # Now generate the gcode - for obj in job.Operations.Group: - tc = PathUtil.toolControllerForOp(obj) - if tc is not None and PathUtil.opProperty(obj, "Active"): - if tc.ToolNumber != currTool: - sublist.append(tc) - Path.Log.debug(f"Appending TC: {tc.Name}") - currTool = tc.ToolNumber - sublist.append(obj) - postlist.append((f, sublist)) - - elif orderby == "Tool": - Path.Log.debug("Ordering by Tool") - # Order by tool means tool changes are minimized. - # all operations with the current tool are processed in the current - # fixture before moving to the next fixture. - - toolstring = "None" - currTool = None - - # Build the fixture list - fixturelist = [] - for index, f in enumerate(wcslist): - # create an object to serve as the fixture path - fixturelist.append(fixtureSetup(index, f, job)) - - # Now generate the gcode - curlist = [] # list of ops for tool, will repeat for each fixture - sublist = [] # list of ops for output splitting - - Path.Log.track(job.PostProcessorOutputFile) - for idx, obj in enumerate(job.Operations.Group): - Path.Log.track(obj.Label) - - # check if the operation is active - if not getattr(obj, "Active", True): - Path.Log.track() - continue - - # Determine the proper string for the Op's TC - tc = PathUtil.toolControllerForOp(obj) - if tc is None: - tcstring = "None" - elif "%T" in job.PostProcessorOutputFile: - tcstring = f"{tc.ToolNumber}" - else: - tcstring = re.sub(r"[^\w\d-]", "_", tc.Label) - Path.Log.track(toolstring) - - if tc is None or tc.ToolNumber == currTool: - curlist.append(obj) - elif tc.ToolNumber != currTool and currTool is None: # first TC - sublist.append(tc) - curlist.append(obj) - currTool = tc.ToolNumber - toolstring = tcstring - - elif tc.ToolNumber != currTool and currTool is not None: # TC - for fixture in fixturelist: - sublist.append(fixture) - sublist.extend(curlist) - postlist.append((toolstring, sublist)) - sublist = [tc] - curlist = [obj] - currTool = tc.ToolNumber - toolstring = tcstring - - if idx == len(job.Operations.Group) - 1: # Last operation. - for fixture in fixturelist: - sublist.append(fixture) - sublist.extend(curlist) - - postlist.append((toolstring, sublist)) - - elif orderby == "Operation": - Path.Log.debug("Ordering by Operation") - # Order by operation means ops are done in each fixture in - # sequence. - currTool = None - - # Now generate the gcode - for obj in job.Operations.Group: - - # check if the operation is active - if not getattr(obj, "Active", True): - continue - - sublist = [] - Path.Log.debug(f"obj: {obj.Name}") - - for index, f in enumerate(wcslist): - sublist.append(fixtureSetup(index, f, job)) - tc = PathUtil.toolControllerForOp(obj) - if tc is not None: - if job.SplitOutput or (tc.ToolNumber != currTool): - sublist.append(tc) - currTool = tc.ToolNumber - sublist.append(obj) - postlist.append((obj.Label, sublist)) - - if job.SplitOutput: - Path.Log.track() - return postlist - - Path.Log.track() - finalpostlist = [ - ("allitems", [item for slist in postlist for item in slist[1]]) - ] - return finalpostlist +def _resolve_post_processor_name(job): + Path.Log.debug("_resolve_post_processor_name()") + if job.PostProcessor: + valid_name = job.PostProcessor + elif Path.Preferences.defaultPostProcessor(): + valid_name = Path.Preferences.defaultPostProcessor() + elif FreeCAD.GuiUp: + valid_name = ( + DlgSelectPostProcessor().exec_() + ) # Ensure DlgSelectPostProcessor is defined + else: + valid_name = None + + if valid_name and PostProcessor.exists(valid_name): + return valid_name + else: + raise ValueError(f"Post processor not identified.") class DlgSelectPostProcessor: """Provide user with list of available and active post processor choices.""" + def __init__(self): self.dialog = FreeCADGui.PySideUic.loadUi(":/panels/DlgSelectPostProcessor.ui") firstItem = None @@ -454,18 +107,6 @@ class DlgSelectPostProcessor: class CommandPathPost: - subpart = 1 - - def resolvePostProcessor(self, job): - if hasattr(job, "PostProcessor"): - post = Path.Preferences.defaultPostProcessor() - if job.PostProcessor: - post = job.PostProcessor - if post and PostProcessor.exists(post): - return post - dlg = DlgSelectPostProcessor() - return dlg.exec_() - def GetResources(self): return { "Pixmap": "CAM_Post", @@ -475,144 +116,97 @@ class CommandPathPost: } def IsActive(self): - if FreeCAD.ActiveDocument is not None: - if FreeCADGui.Selection.getCompleteSelection(): - for o in FreeCAD.ActiveDocument.Objects: - if o.Name[:3] == "Job": - return True + selected = FreeCADGui.Selection.getSelectionEx() + if len(selected) != 1: + return False - return False + selected_object = selected[0].Object + self.candidate = PathUtils.findParentJob(selected_object) - def exportObjectsWith(self, objs, partname, job, sequence, extraargs=None): - Path.Log.track(extraargs) - # check if the user has a project and has set the default post and - # output filename - # extraargs can be passed in at this time - Path.Log.track(partname, sequence) - Path.Log.track(objs) + return self.candidate is not None - # partname = objs[0] - # slist = objs[1] - Path.Log.track(objs, partname) + def _write_file(self, filename, gcode, policy): + if policy == "Open File Dialog": + dlg = QtGui.QFileDialog() + dlg.setFileMode(QtGui.QFileDialog.FileMode.AnyFile) + dlg.setAcceptMode(QtGui.QFileDialog.AcceptMode.AcceptSave) + dlg.setDirectory(os.path.dirname(filename)) + dlg.selectFile(os.path.basename(filename)) + if dlg.exec_(): + filename = dlg.selectedFiles()[0] + Path.Log.debug(filename) + with open(filename, "w") as f: + f.write(gcode) - postArgs = Path.Preferences.defaultPostProcessorArgs() - if hasattr(job, "PostProcessorArgs") and job.PostProcessorArgs: - postArgs = job.PostProcessorArgs - elif hasattr(job, "PostProcessor") and job.PostProcessor: - postArgs = "" + elif policy == "Append Unique ID on conflict": + while os.path.isfile(filename): + base, ext = os.path.splitext(filename) + filename = f"{base}-1{ext}" + with open(filename, "w") as f: + f.write(gcode) - if extraargs is not None: - postArgs += f" {extraargs}" - - Path.Log.track(postArgs) - - postname = self.resolvePostProcessor(job) - # filename = "-" - filename = resolveFileName(job, partname, sequence) - # if postname and needFilename: - # filename = resolveFileName(job) - - if postname and filename: - print("post: %s(%s, %s)" % (postname, filename, postArgs)) - processor = PostProcessor.load(postname) - gcode = processor.export(objs, filename, postArgs) - return (False, gcode, filename) - else: - return (True, "", filename) + elif policy == "Open File Dialog on conflict": + if os.path.isfile(filename): + dlg = QtGui.QFileDialog() + dlg.setFileMode(QtGui.QFileDialog.FileMode.AnyFile) + dlg.setAcceptMode(QtGui.QFileDialog.AcceptSave) + dlg.setDirectory(os.path.dirname(filename)) + dlg.selectFile(os.path.basename(filename)) + if dlg.exec_(): + filename = dlg.selectedFiles()[0] + Path.Log.debug(filename) + with open(filename, "w") as f: + f.write(gcode) + else: + with open(filename, "w") as f: + f.write(gcode) + else: # Overwrite + with open(filename, "w") as f: + f.write(gcode) + FreeCAD.Console.PrintMessage(f"File written to {filename}\n") def Activated(self): - Path.Log.track() - FreeCAD.ActiveDocument.openTransaction("Post Process the Selected path(s)") - FreeCADGui.addModule("Path.Post.Command") + """ + Handles the activation of post processing, initiating the process based + on user selection and document context. + """ + Path.Log.debug(self.candidate.Name) + FreeCAD.ActiveDocument.openTransaction("Post Process the Selected Job") - # Attempt to figure out what the user wants to post-process - # If a job is selected, post that. - # If there's only one job in a document, post it. - # If a user has selected a subobject of a job, post the job. - # If multiple jobs and can't guess, ask them. + postprocessor_name = _resolve_post_processor_name(self.candidate) + Path.Log.debug(f"Post Processor: {postprocessor_name}") - selected = FreeCADGui.Selection.getSelectionEx() - if len(selected) > 1: - FreeCAD.Console.PrintError( - "Please select a single job or other path object\n" - ) - return - elif len(selected) == 1: - sel = selected[0].Object - if sel.Name[:3] == "Job": - job = sel - elif hasattr(sel, "Path"): - try: - job = PathUtils.findParentJob(sel) - except Exception: - job = None - else: - job = None - - if job is None: - targetlist = [] - for o in FreeCAD.ActiveDocument.Objects: - if hasattr(o, "Proxy"): - if isinstance(o.Proxy, PathJob.ObjectJob): - targetlist.append(o.Label) - Path.Log.debug(f"Possible post objects: {targetlist}") - if len(targetlist) > 1: - jobname, result = QtGui.QInputDialog.getItem( - None, translate("Path", "Choose a Path Job"), None, targetlist - ) - - if result is False: - return - else: - jobname = targetlist[0] - job = FreeCAD.ActiveDocument.getObject(jobname) - - Path.Log.debug(f"about to postprocess job: {job.Name}") - - postlist = buildPostList(job) - # filename = resolveFileName(job, "allitems", 0) - - filenames = [] - - success = True - finalgcode = "" - for idx, section in enumerate(postlist): - partname = section[0] - sublist = section[1] - - result, gcode, name = self.exportObjectsWith(sublist, partname, job, idx) - filenames.append(name) - Path.Log.track(result, gcode, name) - - if name is None: - success = False - else: - finalgcode += gcode - - # if job.SplitOutput: - # for idx, sublist in enumerate(postlist): # name, slist in postlist: - # result = self.exportObjectsWith(sublist[1], sublist[0], job, idx) - - # if result is None: - # success = False - # else: - # gcode += result - - # else: - # finalpostlist = [item for (_, slist) in postlist for item in slist] - # gcode = self.exportObjectsWith(finalpostlist, "allitems", job, 1) - # success = gcode is not None - - Path.Log.track(success) - if success: - if hasattr(job, "LastPostProcessDate"): - job.LastPostProcessDate = str(datetime.now()) - if hasattr(job, "LastPostProcessOutput"): - job.LastPostProcessOutput = " \n".join(filenames) - Path.Log.track(job.LastPostProcessOutput) - FreeCAD.ActiveDocument.commitTransaction() - else: + if not postprocessor_name: FreeCAD.ActiveDocument.abortTransaction() + return + + # get a postprocessor + postprocessor = PostProcessorFactory.get_post_processor( + self.candidate, postprocessor_name + ) + + post_data = postprocessor.export() + if not post_data: + FreeCAD.ActiveDocument.abortTransaction() + return + + policy = Path.Preferences.defaultOutputPolicy() + generator = FilenameGenerator(job=self.candidate) + generated_filename = generator.generate_filenames() + + for item in post_data: + subpart, gcode = item + + # get a name for the file + subpart = "" if subpart == "allitems" else subpart + Path.Log.debug(subpart) + generator.set_subpartname(subpart) + fname = next(generated_filename) + + # write the results to the file + self._write_file(fname, gcode, policy) + + FreeCAD.ActiveDocument.commitTransaction() FreeCAD.ActiveDocument.recompute() diff --git a/src/Mod/CAM/Path/Post/Processor.py b/src/Mod/CAM/Path/Post/Processor.py index 64573a0dcc..0419fad049 100644 --- a/src/Mod/CAM/Path/Post/Processor.py +++ b/src/Mod/CAM/Path/Post/Processor.py @@ -19,62 +19,324 @@ # * USA * # * * # *************************************************************************** - -import Path -import sys +""" +The base classes for post processors in CAM workbench. +""" +from PySide import QtCore, QtGui from importlib import reload +import FreeCAD +import Path +import Path.Base.Util as PathUtil +import importlib.util +import os +import sys Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule()) +debug = True +if debug: + Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) + Path.Log.trackModule(Path.Log.thisModule()) +else: + Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule()) + + +class _TempObject: + Path = None + Name = "Fixture" + InList = [] + Label = "Fixture" + + +class PostProcessorFactory: + """Factory class for creating post processors.""" + + @staticmethod + def get_post_processor(job, postname): + # Log initial debug message + Path.Log.debug("PostProcessorFactory.get_post_processor()") + + # Posts have to be in a place we can find them + syspath = sys.path + paths = Path.Preferences.searchPathsPost() + paths.extend(sys.path) + + module_name = f"{postname}_post" + class_name = postname.title() + + # Iterate all the paths to find the module + for path in paths: + module_path = os.path.join(path, f"{module_name}.py") + spec = importlib.util.spec_from_file_location(module_name, module_path) + + if spec and spec.loader: + module = importlib.util.module_from_spec(spec) + try: + spec.loader.exec_module(module) + Path.Log.debug(f"found module {module_name} at {module_path}") + + except (FileNotFoundError, ImportError, ModuleNotFoundError): + continue + + try: + PostClass = getattr(module, class_name) + return PostClass(job) + except AttributeError: + # Return an instance of WrapperPost if no valid module is found + Path.Log.debug(f"Post processor {postname} is a script") + return WrapperPost(job, module_path) + class PostProcessor: + """Base Class. All postprocessors should inherit from this class.""" + + def __init__(self, job, tooltip, tooltipargs, units, *args, **kwargs): + self._tooltip = tooltip + self._tooltipargs = tooltipargs + self._units = units + self._job = job + self._args = args + self._kwargs = kwargs + @classmethod def exists(cls, processor): return processor in Path.Preferences.allAvailablePostProcessors() - @classmethod - def load(cls, processor): - Path.Log.track(processor) - syspath = sys.path - paths = Path.Preferences.searchPathsPost() - paths.extend(sys.path) - sys.path = paths + def export(self): + raise NotImplementedError("Subclass must implement abstract method") - postname = processor + "_post" - namespace = {} + @property + def tooltip(self): + """Get the tooltip text for the post processor.""" + return self._tooltip - # can't modify function local scope with exec in python3 - exec("import %s as current_post" % postname, namespace) - current_post = namespace["current_post"] + @property + def tooltipargs(self): + """Get the tooltip arguments for the post processor.""" + return self._tooltipargs - # make sure the script is reloaded if it was previously loaded - # should the script have been imported for the first time above - # then the initialization code of the script gets executed twice - # resulting in 2 load messages if the script outputs one of those. + @property + def units(self): + """Get the units used by the post processor.""" + return self._units - exec("reload(%s)" % "current_post") + def _buildPostList(self): + """ + determines the specific objects and order to + postprocess Returns a list of objects which can be passed to + exportObjectsWith() for final posting.""" - sys.path = syspath + def __fixtureSetup(order, fixture, job): + """Convert a Fixure setting to _TempObject instance with a G0 move to a + safe height every time the fixture coordinate system change. Skip + the move for first fixture, to avoid moving before tool and tool + height compensation is enabled. - instance = PostProcessor(current_post) - if hasattr(current_post, "UNITS"): - if current_post.UNITS == "G21": - instance.units = "Metric" - else: - instance.units = "Inch" + """ - if hasattr(current_post, "TOOLTIP"): - instance.tooltip = current_post.TOOLTIP - if hasattr(current_post, "TOOLTIP_ARGS"): - instance.tooltipArgs = current_post.TOOLTIP_ARGS - return instance + fobj = _TempObject() + c1 = Path.Command(fixture) + fobj.Path = Path.Path([c1]) + # Avoid any tool move after G49 in preamble and before tool change + # and G43 in case tool height compensation is in use, to avoid + # dangerous move without toolgg compesation. + if order != 0: + c2 = Path.Command( + "G0 Z" + + str( + job.Stock.Shape.BoundBox.ZMax + + job.SetupSheet.ClearanceHeightOffset.Value + ) + ) + fobj.Path.addCommands(c2) + fobj.InList.append(job) + return fobj - def __init__(self, script): - self.script = script - self.tooltip = None - self.tooltipArgs = None - self.units = None - self.machineName = None + wcslist = self._job.Fixtures + orderby = self._job.OrderOutputBy + Path.Log.debug(f"Ordering by {orderby}") - def export(self, obj, filename, args): - return self.script.export(obj, filename, args) + postlist = [] + + if orderby == "Fixture": + Path.Log.debug("Ordering by Fixture") + # Order by fixture means all operations and tool changes will be + # completed in one fixture before moving to the next. + + currTool = None + for index, f in enumerate(wcslist): + # create an object to serve as the fixture path + sublist = [__fixtureSetup(index, f, self._job)] + + # Now generate the gcode + for obj in self._job.Operations.Group: + tc = PathUtil.toolControllerForOp(obj) + if tc is not None and PathUtil.opProperty(obj, "Active"): + if tc.ToolNumber != currTool: + sublist.append(tc) + Path.Log.debug(f"Appending TC: {tc.Name}") + currTool = tc.ToolNumber + sublist.append(obj) + postlist.append((f, sublist)) + + elif orderby == "Tool": + Path.Log.debug("Ordering by Tool") + # Order by tool means tool changes are minimized. + # all operations with the current tool are processed in the current + # fixture before moving to the next fixture. + + toolstring = "None" + currTool = None + + # Build the fixture list + fixturelist = [] + for index, f in enumerate(wcslist): + # create an object to serve as the fixture path + fixturelist.append(__fixtureSetup(index, f, self._job)) + + # Now generate the gcode + curlist = [] # list of ops for tool, will repeat for each fixture + sublist = [] # list of ops for output splitting + + Path.Log.track(self._job.PostProcessorOutputFile) + for idx, obj in enumerate(self._job.Operations.Group): + Path.Log.track(obj.Label) + + # check if the operation is active + if not getattr(obj, "Active", True): + Path.Log.track() + continue + + # Determine the proper string for the Op's TC + tc = PathUtil.toolControllerForOp(obj) + if tc is None: + tcstring = "None" + elif "%T" in self._job.PostProcessorOutputFile: + tcstring = f"{tc.ToolNumber}" + else: + tcstring = re.sub(r"[^\w\d-]", "_", tc.Label) + Path.Log.track(toolstring) + + if tc is None or tc.ToolNumber == currTool: + curlist.append(obj) + elif tc.ToolNumber != currTool and currTool is None: # first TC + sublist.append(tc) + curlist.append(obj) + currTool = tc.ToolNumber + toolstring = tcstring + + elif tc.ToolNumber != currTool and currTool is not None: # TC + for fixture in fixturelist: + sublist.append(fixture) + sublist.extend(curlist) + postlist.append((toolstring, sublist)) + sublist = [tc] + curlist = [obj] + currTool = tc.ToolNumber + toolstring = tcstring + + if idx == len(self._job.Operations.Group) - 1: # Last operation. + for fixture in fixturelist: + sublist.append(fixture) + sublist.extend(curlist) + + postlist.append((toolstring, sublist)) + + elif orderby == "Operation": + Path.Log.debug("Ordering by Operation") + # Order by operation means ops are done in each fixture in + # sequence. + currTool = None + + # Now generate the gcode + for obj in self._job.Operations.Group: + + # check if the operation is active + if not getattr(obj, "Active", True): + continue + + sublist = [] + Path.Log.debug(f"obj: {obj.Name}") + + for index, f in enumerate(wcslist): + sublist.append(_fixtureSetup(index, f, self._job)) + tc = PathUtil.toolControllerForOp(obj) + if tc is not None: + if self._job.SplitOutput or (tc.ToolNumber != currTool): + sublist.append(tc) + currTool = tc.ToolNumber + sublist.append(obj) + postlist.append((obj.Label, sublist)) + + Path.Log.debug(f"Postlist: {postlist}") + + if self._job.SplitOutput: + Path.Log.track() + return postlist + + Path.Log.track() + finalpostlist = [ + ("allitems", [item for slist in postlist for item in slist[1]]) + ] + Path.Log.debug(f"Postlist: {postlist}") + return finalpostlist + + +class WrapperPost(PostProcessor): + """Wrapper class for old post processors that are scripts.""" + + def __init__(self, job, script_path, *args, **kwargs): + super().__init__( + job, tooltip=None, tooltipargs=None, units=None, *args, **kwargs + ) + self.script_path = script_path + Path.Log.debug(f"WrapperPost.__init__({script_path})") + self.load_script() + + def load_script(self): + # Dynamically load the script as a module + try: + spec = importlib.util.spec_from_file_location( + "script_module", self.script_path + ) + self.script_module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(self.script_module) + except Exception as e: + raise ImportError(f"Failed to load script: {e}") + + if not hasattr(self.script_module, "export"): + raise AttributeError("The script does not have an 'export' function.") + + # Set properties based on attributes of the module + self._units = ( + "Metric" if getattr(self.script_module, "UNITS", "G21") == "G21" else "Inch" + ) + self._tooltip = getattr(self.script_module, "TOOLTIP", "No tooltip provided") + self._tooltipargs = getattr(self.script_module, "TOOLTIP_ARGS", []) + + def export(self): + # Dynamically reload the module for the export to ensure up-to-date usage + + postables = self._buildPostList() + Path.Log.debug(f"postables count: {len(postables)}") + + g_code_sections = [] + for idx, section in enumerate(postables): + partname, sublist = section + + gcode = self.script_module.export(sublist, "-", self._job.PostProcessorArgs) + Path.Log.debug(f"Exported {partname}") + g_code_sections.append((partname, gcode)) + return g_code_sections + + @property + def tooltip(self): + return self._tooltip + + @property + def tooltipargs(self): + return self._tooltipargs + + @property + def units(self): + return self._units diff --git a/src/Mod/CAM/Path/Post/Utils.py b/src/Mod/CAM/Path/Post/Utils.py index e61ef4db47..32aa99e4c0 100644 --- a/src/Mod/CAM/Path/Post/Utils.py +++ b/src/Mod/CAM/Path/Post/Utils.py @@ -27,21 +27,155 @@ These are common functions and classes for creating custom post processors. """ -from PySide import QtCore, QtGui - -import FreeCAD - -import Path -import Part from Path.Base.MachineState import MachineState +from PySide import QtCore, QtGui +import FreeCAD +import Part +import Path +import os +import re + +debug = False +if debug: + Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) + Path.Log.trackModule(Path.Log.thisModule()) +else: + Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule()) translate = FreeCAD.Qt.translate + FreeCADGui = None if FreeCAD.GuiUp: import FreeCADGui +class FilenameGenerator: + def __init__(self, job): + self.job = job + self.subpartname = "" + self.sequencenumber = 0 + path, filename, ext = self.get_path_and_filename_default() + + self.qualified_path = self._apply_path_substitutions(path) + self.qualified_filename = self._apply_filename_substitutions(filename) + self.extension = ext + + def get_path_and_filename_default(self): + outputpath = "" + filename = "" + ext = ".nc" + + validPathSubstitutions = ["D", "d", "M", "j"] + validFilenameSubstitutions = ["j", "d", "T", "t", "W", "O", "S"] + + if self.job.PostProcessorOutputFile: + candidateOutputPath, candidateFilename = os.path.split( + self.job.PostProcessorOutputFile + ) + + if candidateOutputPath: + outputpath = candidateOutputPath + + if candidateFilename: + filename, ext = os.path.splitext(candidateFilename) + else: + outputpath, filename = os.path.split(Path.Preferences.defaultOutputFile()) + filename, ext = os.path.splitext(filename) + + # Make sure we have something to work with + if not filename: + filename = FreeCAD.ActiveDocument.Label + + if not outputpath: + outputpath = "." + + if not ext: + ext = ".nc" + + # Check for invalid matches + for match in re.findall("%(.)", outputpath): + Path.Log.debug(f"match: {match}") + if match not in validPathSubstitutions: + outputpath = outputpath.replace(f"%{match}", "") + FreeCAD.Console.PrintWarning( + "Invalid substitution strings will be ignored in output path: %s\n" + % match + ) + + for match in re.findall("%(.)", filename): + Path.Log.debug(f"match: {match}") + if match not in validFilenameSubstitutions: + filename = filename.replace(f"%{match}", "") + FreeCAD.Console.PrintWarning( + "Invalid substitution strings will be ignored in file path: %s\n" + % match + ) + + Path.Log.debug(f"outputpath: {outputpath} filename: {filename} ext: {ext}") + return outputpath, filename, ext + + def set_subpartname(self, subpartname): + self.subpartname = subpartname + + def _apply_path_substitutions(self, file_path): + """Apply substitutions based on job settings and other parameters.""" + substitutions = { + "%D": os.path.dirname(self.job.Document.FileName or "."), + "%d": self.job.Document.Label, + "%j": self.job.Label, + "%M": os.path.dirname(FreeCAD.getUserMacroDir()), + } + for key, value in substitutions.items(): + file_path = file_path.replace(key, value) + + Path.Log.debug(f"file_path: {file_path}") + return file_path + + def _apply_filename_substitutions(self, file_name): + Path.Log.debug(f"file_name: {file_name}") + """Apply substitutions based on job settings and other parameters.""" + substitutions = { + "%d": self.job.Document.Label, + "%j": self.job.Label, + "%T": self.subpartname, # Tool + "%t": self.subpartname, # Tool + "%W": self.subpartname, # Fixture + "%O": self.subpartname, # Operation + } + for key, value in substitutions.items(): + file_name = file_name.replace(key, value) + + Path.Log.debug(f"file_name: {file_name}") + return file_name + + def generate_filenames(self): + """Yield filenames indefinitely with proper substitutions.""" + while True: + temp_filename = self.qualified_filename + Path.Log.debug(f"temp_filename: {temp_filename}") + explicit_sequence = False + matches = re.findall("%S", temp_filename) + if matches: + Path.Log.debug(f"matches: {matches}") + temp_filename = re.sub("%S", str(self.sequencenumber), temp_filename) + explicit_sequence = True + + subpart = f"-{self.subpartname}" if self.subpartname else "" + sequence = ( + f"-{self.sequencenumber}" + if not explicit_sequence and self.sequencenumber + else "" + ) + filename = f"{temp_filename}{subpart}{sequence}{self.extension}" + full_path = os.path.join(self.qualified_path, filename) + + self.sequencenumber += 1 + Path.Log.debug(f"yielding filename: {full_path}") + yield os.path.normpath(full_path) + + + class GCodeHighlighter(QtGui.QSyntaxHighlighter): def __init__(self, parent=None): super(GCodeHighlighter, self).__init__(parent) diff --git a/src/Mod/CAM/Path/Post/scripts/dumper_post.py b/src/Mod/CAM/Path/Post/scripts/dumper_post.py index b3d471ae25..4f4209ce31 100644 --- a/src/Mod/CAM/Path/Post/scripts/dumper_post.py +++ b/src/Mod/CAM/Path/Post/scripts/dumper_post.py @@ -27,7 +27,7 @@ import PathScripts.PathUtils as PathUtils from builtins import open as pyopen TOOLTIP = """ -Dumper is an extremely simple postprocessor file for the Path workbench. It is used +Dumper is an extremely simple postprocessor file for the CAM workbench. It is used to dump the command list from one or more Path objects for simple inspection. This post doesn't do any manipulation of the path and doesn't write anything to disk. It just shows the dialog so you can see it. Useful for debugging, but not much else. diff --git a/src/Mod/CAM/TestCAMApp.py b/src/Mod/CAM/TestCAMApp.py index 413d73a076..847555751f 100644 --- a/src/Mod/CAM/TestCAMApp.py +++ b/src/Mod/CAM/TestCAMApp.py @@ -43,10 +43,13 @@ from Tests.TestPathHelixGenerator import TestPathHelixGenerator from Tests.TestPathLog import TestPathLog from Tests.TestPathOpUtil import TestPathOpUtil -# from Tests.TestPathPost import TestPathPost +#from Tests.TestPathPost import TestPathPost from Tests.TestPathPost import TestPathPostUtils from Tests.TestPathPost import TestBuildPostList from Tests.TestPathPost import TestOutputNameSubstitution +from Tests.TestPathPost import TestPostProcessorFactory +from Tests.TestPathPost import TestResolvingPostProcessorName +from Tests.TestPathPost import TestFileNameGenerator from Tests.TestPathPreferences import TestPathPreferences from Tests.TestPathProfile import TestPathProfile @@ -81,6 +84,7 @@ False if TestApp.__name__ else True False if TestBuildPostList.__name__ else True False if TestDressupDogbone.__name__ else True False if TestDressupDogboneII.__name__ else True +False if TestFileNameGenerator.__name__ else True False if TestGeneratorDogboneII.__name__ else True False if TestHoldingTags.__name__ else True False if TestPathLanguage.__name__ else True @@ -94,7 +98,9 @@ False if TestPathHelpers.__name__ else True # False if TestPathHelix.__name__ else True False if TestPathLog.__name__ else True False if TestPathOpUtil.__name__ else True -# False if TestPathPost.__name__ else True +#False if TestPathPost.__name__ else True +False if TestPostProcessorFactory.__name__ else True +False if TestResolvingPostProcessorName.__name__ else True False if TestPathPostUtils.__name__ else True False if TestPathPreferences.__name__ else True False if TestPathProfile.__name__ else True diff --git a/src/Mod/CAM/Tests/TestPathPost.py b/src/Mod/CAM/Tests/TestPathPost.py index ecbb9adcab..b2a6d41df9 100644 --- a/src/Mod/CAM/Tests/TestPathPost.py +++ b/src/Mod/CAM/Tests/TestPathPost.py @@ -24,189 +24,362 @@ import difflib import os import unittest +from unittest.mock import patch, MagicMock import FreeCAD import Path -import Path.Post.Command as PathPost +import Path.Post.Command as PathCommand import Path.Post.Utils as PostUtils -from Path.Post.Processor import PostProcessor +from Path.Post.Processor import PostProcessor, PostProcessorFactory + +from Path.Post.Command import processFileNameSubstitutions, DlgSelectPostProcessor # If KEEP_DEBUG_OUTPUT is False, remove the gcode file after the test succeeds. # If KEEP_DEBUG_OUTPUT is True or the test fails leave the gcode file behind # so it can be looked at easily. KEEP_DEBUG_OUTPUT = False -PathPost.LOG_MODULE = Path.Log.thisModule() -Path.Log.setLevel(Path.Log.Level.INFO, PathPost.LOG_MODULE) +PathCommand.LOG_MODULE = Path.Log.thisModule() +Path.Log.setLevel(Path.Log.Level.DEBUG, PathCommand.LOG_MODULE) -class TestPathPost(unittest.TestCase): - """Test some of the output of the postprocessors. - - So far there are three tests each for the linuxcnc - and centroid postprocessors. - """ +class TestFileNameGenerator(unittest.TestCase): def setUp(self): - """Set up the postprocessor tests.""" - pass + self.doc = FreeCAD.open(FreeCAD.getHomePath() + "/Mod/CAM/Tests/boxtest.fcstd") + self.job = self.doc.getObject("Job") def tearDown(self): - """Tear down after the postprocessor tests.""" - pass + FreeCAD.closeDocument("boxtest") - # - # You can run just this test using: - # ./FreeCAD -c -t Tests.TestPathPost.TestPathPost.test_postprocessors - # - def test_postprocessors(self): - """Test the postprocessors.""" - # - # The tests are performed in the order they are listed: - # one test performed on all of the postprocessors - # then the next test on all of the postprocessors, etc. - # You can comment out the tuples for tests that you don't want - # to use. - # - tests_to_perform = ( - # (output_file_id, freecad_document, job_name, postprocessor_arguments, - # postprocessor_list) - # - # test with all of the defaults (metric mode, etc.) - ("default", "boxtest1", "Job", "--no-show-editor", ()), - # test in Imperial mode - ("imperial", "boxtest1", "Job", "--no-show-editor --inches", ()), - # test in metric, G55, M4, the other way around the part - ("other_way", "boxtest1", "Job001", "--no-show-editor", ()), - # test in metric, split by fixtures, G54, G55, G56 - ("split", "boxtest1", "Job002", "--no-show-editor", ()), - # test in metric mode without the header - ("no_header", "boxtest1", "Job", "--no-header --no-show-editor", ()), - # test translating G81, G82, and G83 to G00 and G01 commands - ( - "drill_translate", - "drill_test1", - "Job", - "--no-show-editor --translate_drill", - ("grbl", "refactored_grbl"), - ), - ) - # - # The postprocessors to test. - # You can comment out any postprocessors that you don't want - # to test. - # - postprocessors_to_test = ( - "centroid", - # "fanuc", - "grbl", - "linuxcnc", - "mach3_mach4", - "refactored_centroid", - # "refactored_fanuc", - "refactored_grbl", - "refactored_linuxcnc", - "refactored_mach3_mach4", - "refactored_test", - ) - # - # Enough of the path to where the tests are stored so that - # they can be found by the python interpreter. - # - PATHTESTS_LOCATION = "Mod/CAM/Tests" - # - # The following code tries to re-use an open FreeCAD document - # as much as possible. It compares the current document with - # the document for the next test. If the names are different - # then the current document is closed and the new document is - # opened. The final document is closed at the end of the code. - # - current_document = "" - for ( - output_file_id, - freecad_document, - job_name, - postprocessor_arguments, - postprocessor_list, - ) in tests_to_perform: - if current_document != freecad_document: - if current_document != "": - FreeCAD.closeDocument(current_document) - current_document = freecad_document - current_document_path = ( - FreeCAD.getHomePath() - + PATHTESTS_LOCATION - + os.path.sep - + current_document - + ".fcstd" - ) - FreeCAD.open(current_document_path) - job = FreeCAD.ActiveDocument.getObject(job_name) - # Create the objects to be written by the postprocessor. - postlist = PathPost.buildPostList(job) - for postprocessor_id in postprocessors_to_test: - if postprocessor_list == () or postprocessor_id in postprocessor_list: - print( - "\nRunning %s test on %s postprocessor:\n" - % (output_file_id, postprocessor_id) - ) - processor = PostProcessor.load(postprocessor_id) - output_file_path = FreeCAD.getHomePath() + PATHTESTS_LOCATION - output_file_pattern = "test_%s_%s" % ( - postprocessor_id, - output_file_id, - ) - output_file_extension = ".ngc" - for idx, section in enumerate(postlist): - partname = section[0] - sublist = section[1] - output_filename = PathPost.processFileNameSubstitutions( - job, - partname, - idx, - output_file_path, - output_file_pattern, - output_file_extension, - ) - # print("output file: " + output_filename) - file_path, extension = os.path.splitext(output_filename) - reference_file_name = "%s%s%s" % (file_path, "_ref", extension) - # print("reference file: " + reference_file_name) - gcode = processor.export( - sublist, output_filename, postprocessor_arguments - ) - if not gcode: - print("no gcode") - with open(reference_file_name, "r") as fp: - reference_gcode = fp.read() - if not reference_gcode: - print("no reference gcode") - # Remove the "Output Time:" line in the header from the - # comparison if it is present because it changes with - # every test. - gcode_lines = [ - i for i in gcode.splitlines(True) if "Output Time:" not in i - ] - reference_gcode_lines = [ - i - for i in reference_gcode.splitlines(True) - if "Output Time:" not in i - ] - if gcode_lines != reference_gcode_lines: - msg = "".join( - difflib.ndiff(gcode_lines, reference_gcode_lines) - ) - self.fail( - os.path.basename(output_filename) - + " output doesn't match:\n" - + msg - ) - if not KEEP_DEBUG_OUTPUT: - os.remove(output_filename) - if current_document != "": - FreeCAD.closeDocument(current_document) + # def test010(self): + # # Assuming PostUtils.FilenameGenerator has been imported correctly + # generator = PostUtils.FilenameGenerator(job=self.job, base_output_path="output", + # filename_template="file", extension=".nc") + + # filename_generator = generator.generate_filenames() + # for i in range(5): + # generated_filename = next(filename_generator) + # expected_filename = f"output/file-{i}.nc" + # self.assertEqual(generated_filename, expected_filename) + + + def test010(self): + generator = PostUtils.FilenameGenerator(job=self.job, base_output_path="output", + filename_template="file", extension=".nc") + filename_generator = generator.generate_filenames() + expected_filenames = ["output/file.nc"] + [f"output/file-{i}.nc" for i in range(1, 5)] + for expected_filename in expected_filenames: + generated_filename = next(filename_generator) + self.assertEqual(generated_filename, expected_filename) + +class TestResolvingPostProcessorName(unittest.TestCase): + def setUp(self): + self.doc = FreeCAD.open(FreeCAD.getHomePath() + "/Mod/CAM/Tests/boxtest.fcstd") + self.job = self.doc.getObject("Job") + pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Mod/CAM") + pref.SetString("PostProcessorDefault", "") + + def tearDown(self): + FreeCAD.closeDocument("boxtest") + + def test010(self): + # Test if post is defined in job + with patch('Path.Post.Processor.PostProcessor.exists', return_value=True): + postname = PathCommand._resolve_post_processor_name(self.job) + self.assertEqual(postname, "linuxcnc") + + def test020(self): + # Test if post is invalid + with patch('Path.Post.Processor.PostProcessor.exists', return_value=False): + with self.assertRaises(ValueError): + PathCommand._resolve_post_processor_name(self.job) + + def test030(self): + # Test if post is defined in prefs + pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Mod/CAM") + pref.SetString("PostProcessorDefault", "grbl") + self.job.PostProcessor = "" + with patch('Path.Post.Processor.PostProcessor.exists', return_value=True): + postname = PathCommand._resolve_post_processor_name(self.job) + self.assertEqual(postname, "grbl") + + def test040(self): + # Test if user interaction is correctly handled + self.job.PostProcessor = "" + if FreeCAD.GuiUp: + with patch('Path.Post.Command.DlgSelectPostProcessor') as mock_dlg, \ + patch('Path.Post.Processor.PostProcessor.exists', return_value=True): + mock_dlg.return_value.exec_.return_value = 'generic' + postname = PathCommand._resolve_post_processor_name(self.job) + self.assertEqual(postname, 'generic') + else: + with self.assertRaises(ValueError): + PathCommand._resolve_post_processor_name(self.job) + +class TestPostProcessorFactory(unittest.TestCase): + """Test creation of postprocessor objects.""" + + def setUp(self): + self.doc = FreeCAD.open(FreeCAD.getHomePath() + "/Mod/CAM/Tests/boxtest.fcstd") + self.job = self.doc.getObject("Job") + + def tearDown(self): + FreeCAD.closeDocument("boxtest") + + + def test020(self): + # test creation of postprocessor object + post = PostProcessorFactory.get_post_processor(self.job, "generic") + self.assertTrue(post is not None) + self.assertTrue(hasattr(post, "export")) + self.assertTrue(hasattr(post, "_buildPostList")) + + def test030(self): + # test wrapping of old school postprocessor scripts + post = PostProcessorFactory.get_post_processor(self.job, "linuxcnc") + self.assertTrue(post is not None) + self.assertTrue(hasattr(post, "_buildPostList")) + + + def test100(self): + """Test the processFileNameSubstitutions function.""" + + document_dir = os.path.dirname(FreeCAD.ActiveDocument.FileName) + Path.Log.debug(f"document_dir: {document_dir}") + + user_macro_dir = os.path.dirname(FreeCAD.getUserMacroDir()) + Path.Log.debug(f"user_macro_dir: {user_macro_dir}") + + outputpath = "%D/output" + filename = "outputfile-%j-%d" + ext = ".txt" + expected_path = f"{document_dir}{os.path.sep}output/outputfile-{self.job.Label}-{self.doc.Label}{ext}" + + # Call the function + result = processFileNameSubstitutions(self.job, "Subpart", 1, outputpath, filename, ext) + + Path.Log.debug(f"result: {result}") + + self.assertEqual(result, expected_path) + + # test macro path substitution + outputpath = "%M/output" + filename = "file-%j" + ext = ".txt" + + result = processFileNameSubstitutions(self.job, "Subpart", 1, outputpath, filename, ext) + expected_path = f"{user_macro_dir}/output/file-{self.job.Label}.txt" + + self.assertEqual(result, expected_path) + + # test job name substitution + document_label = FreeCAD.ActiveDocument.Label + outputpath = "output/%d" + filename = "file-%d-%j" + ext = ".txt" + + result = processFileNameSubstitutions(self.job, "Subpart", 1, outputpath, filename, ext) + expected_path = f"output/{document_label}/file-{document_label}-{self.job.Label}.txt" + + self.assertEqual(result, expected_path) + + # test sequence number substitution + outputpath = "output" + filename = "file-%S" + ext = ".txt" + + result = processFileNameSubstitutions(self.job, "Subpart", 42, outputpath, filename, ext) + expected_path = f"output/file-42.txt" + + self.assertEqual(result, expected_path) + + # test tool number substitution + outputpath = "output" + filename = "file" + ext = "" + + result = processFileNameSubstitutions(self.job, "Subpart", 1, outputpath, filename, ext) + expected_path = f"output/file.nc" # Expect default .nc extension + + self.assertEqual(result, expected_path) + + +class TestPostProcessorClass(unittest.TestCase): + """Test new post structure objects.""" + + def setUp(self): + self.doc = FreeCAD.open(FreeCAD.getHomePath() + "/Mod/CAM/Tests/boxtest.fcstd") + self.job = self.doc.getObject("Job") + post = PostProcessorFactory.get_post_processor(self.job, "generic") + + def tearDown(self): + FreeCAD.closeDocument("boxtest") + +class TestPostProcessorScript(unittest.TestCase): + """Test old-school posts""" + + def setUp(self): + self.doc = FreeCAD.open(FreeCAD.getHomePath() + "/Mod/CAM/Tests/boxtest.fcstd") + self.job = self.doc.getObject("Job") + post = PostProcessorFactory.get_post_processor(self.job, "linuxcnc") + + def tearDown(self): + FreeCAD.closeDocument("boxtest") + + + ## + ## You can run just this test using: + ## ./FreeCAD -c -t Tests.TestPathPost.TestPathPost.test_postprocessors + ## + #def test_postprocessors(self): + # """Test the postprocessors.""" + # # + # # The tests are performed in the order they are listed: + # # one test performed on all of the postprocessors + # # then the next test on all of the postprocessors, etc. + # # You can comment out the tuples for tests that you don't want + # # to use. + # # + # tests_to_perform = ( + # # (output_file_id, freecad_document, job_name, postprocessor_arguments, + # # postprocessor_list) + # # + # # test with all of the defaults (metric mode, etc.) + # ("default", "boxtest1", "Job", "--no-show-editor", ()), + # # test in Imperial mode + # ("imperial", "boxtest1", "Job", "--no-show-editor --inches", ()), + # # test in metric, G55, M4, the other way around the part + # ("other_way", "boxtest1", "Job001", "--no-show-editor", ()), + # # test in metric, split by fixtures, G54, G55, G56 + # ("split", "boxtest1", "Job002", "--no-show-editor", ()), + # # test in metric mode without the header + # ("no_header", "boxtest1", "Job", "--no-header --no-show-editor", ()), + # # test translating G81, G82, and G83 to G00 and G01 commands + # ( + # "drill_translate", + # "drill_test1", + # "Job", + # "--no-show-editor --translate_drill", + # ("grbl", "refactored_grbl"), + # ), + # ) + # # + # # The postprocessors to test. + # # You can comment out any postprocessors that you don't want + # # to test. + # # + # postprocessors_to_test = ( + # "centroid", + # # "fanuc", + # "grbl", + # "linuxcnc", + # "mach3_mach4", + # "refactored_centroid", + # # "refactored_fanuc", + # "refactored_grbl", + # "refactored_linuxcnc", + # "refactored_mach3_mach4", + # "refactored_test", + # ) + # # + # # Enough of the path to where the tests are stored so that + # # they can be found by the python interpreter. + # # + # PATHTESTS_LOCATION = "Mod/CAM/Tests" + # # + # # The following code tries to re-use an open FreeCAD document + # # as much as possible. It compares the current document with + # # the document for the next test. If the names are different + # # then the current document is closed and the new document is + # # opened. The final document is closed at the end of the code. + # # + # current_document = "" + # for ( + # output_file_id, + # freecad_document, + # job_name, + # postprocessor_arguments, + # postprocessor_list, + # ) in tests_to_perform: + # if current_document != freecad_document: + # if current_document != "": + # FreeCAD.closeDocument(current_document) + # current_document = freecad_document + # current_document_path = ( + # FreeCAD.getHomePath() + # + PATHTESTS_LOCATION + # + os.path.sep + # + current_document + # + ".fcstd" + # ) + # FreeCAD.open(current_document_path) + # job = FreeCAD.ActiveDocument.getObject(job_name) + # # Create the objects to be written by the postprocessor. + # postlist = PathPost.buildPostList(job) + # for postprocessor_id in postprocessors_to_test: + # if postprocessor_list == () or postprocessor_id in postprocessor_list: + # print( + # "\nRunning %s test on %s postprocessor:\n" + # % (output_file_id, postprocessor_id) + # ) + # processor = PostProcessor.load(postprocessor_id) + # output_file_path = FreeCAD.getHomePath() + PATHTESTS_LOCATION + # output_file_pattern = "test_%s_%s" % ( + # postprocessor_id, + # output_file_id, + # ) + # output_file_extension = ".ngc" + # for idx, section in enumerate(postlist): + # partname = section[0] + # sublist = section[1] + # output_filename = PathPost.processFileNameSubstitutions( + # job, + # partname, + # idx, + # output_file_path, + # output_file_pattern, + # output_file_extension, + # ) + # # print("output file: " + output_filename) + # file_path, extension = os.path.splitext(output_filename) + # reference_file_name = "%s%s%s" % (file_path, "_ref", extension) + # # print("reference file: " + reference_file_name) + # gcode = processor.export( + # sublist, output_filename, postprocessor_arguments + # ) + # if not gcode: + # print("no gcode") + # with open(reference_file_name, "r") as fp: + # reference_gcode = fp.read() + # if not reference_gcode: + # print("no reference gcode") + # # Remove the "Output Time:" line in the header from the + # # comparison if it is present because it changes with + # # every test. + # gcode_lines = [ + # i for i in gcode.splitlines(True) if "Output Time:" not in i + # ] + # reference_gcode_lines = [ + # i + # for i in reference_gcode.splitlines(True) + # if "Output Time:" not in i + # ] + # if gcode_lines != reference_gcode_lines: + # msg = "".join( + # difflib.ndiff(gcode_lines, reference_gcode_lines) + # ) + # self.fail( + # os.path.basename(output_filename) + # + " output doesn't match:\n" + # + msg + # ) + # if not KEEP_DEBUG_OUTPUT: + # os.remove(output_filename) + # if current_document != "": + # FreeCAD.closeDocument(current_document) class TestPathPostUtils(unittest.TestCase): @@ -237,13 +410,13 @@ class TestPathPostUtils(unittest.TestCase): ) -def dumpgroup(group): - print("====Dump Group======") - for i in group: - print(i[0]) - for j in i[1]: - print(f"--->{j.Name}") - print("====================") +#def dumpgroup(group): + #print("====Dump Group======") + #for i in group: + # print(i[0]) + # for j in i[1]: + # print(f"--->{j.Name}") + #print("====================") class TestBuildPostList(unittest.TestCase):