diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a02364a --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +cut_list/__pycache__/ \ No newline at end of file diff --git a/InitGui.py b/InitGui.py index 338c7d5..6377008 100644 --- a/InitGui.py +++ b/InitGui.py @@ -147,9 +147,29 @@ def Initialize(self): self.utilsList=["selectSolids","queryModel","moveWorkPlane","offsetWorkPlane","rotateWorkPlane","hackedL","moveHandle","dpCalc"] self.appendToolbar("Utils",self.utilsList) Log ('Loading Utils: done\n') + import CFrame - self.frameList=["frameIt","FrameBranchManager","insertSection","spinSect","reverseBeam","shiftBeam","pivotBeam","levelBeam","alignEdge","rotJoin","alignFlange","stretchBeam","extend","adjustFrameAngle","insertPath"] + from cut_list.cut_list_commands import cutListCommand + + self.frameList=["frameIt", + "FrameBranchManager", + "insertSection", + "spinSect", + "reverseBeam", + "shiftBeam", + "pivotBeam", + "levelBeam", + "alignEdge", + "rotJoin", + "alignFlange", + "stretchBeam", + "extend", + "adjustFrameAngle", + "insertPath", + "createCutList"] + self.appendToolbar("frameTools",self.frameList) + Log ('Loading Frame tools: done\n') import CPipe self.pypeList=["insertPipe","insertElbow","insertReduct","insertCap","insertValve","insertFlange","insertUbolt","insertPypeLine","insertBranch","insertTank","insertRoute","breakPipe","mateEdges","flat","extend2intersection","extend1intersection","makeHeader","laydown","raiseup","attach2tube","point2point","insertAnyz"]#,"joinPype"] diff --git a/cut_list/README.md b/cut_list/README.md new file mode 100644 index 0000000..ad63af2 --- /dev/null +++ b/cut_list/README.md @@ -0,0 +1,64 @@ +# Cut List Creation Command for Dodo Workbench + +This Module can be used to create a cut list of beams created by the DODO-Workbench.\ +The cut list can use one or more profiles (sections/sketches).\ +A position number will be generated for each profile & length combination (rounded to 0.01mm).\ +The script will create a new speadsheet object with each cut list. + +# How to use it +[Cut_List.webm](https://github.com/FilePhil/dodo_Cutlist_Macro_Version/assets/16101101/e992a925-0a02-4560-8e7c-a22eef86234d) + +# Options +## Group Parts by Size +The Option "Group Parts by Size" will count all Pieces with the same profile and Length (rounded to 0.01mm). + +### Example: Group Party by Size + +| Beam No. 1 | | | | +|-----------------------------|--|--|--| +| Used 2855.0 mm | | | | +| Pos. | Profil | Length | Quantity | +| 1 | 10X10 | 610,00 mm | 2 | +| 2 | 10X10 | 600,00 mm | 2 | +| 3 | 10X10 | 410,00 mm | 1 | + +### Exampl: Without Group Party by Size + + | Beam No. 1 | | | | + |-----------------------------|--|--|--| + | Used 2410.0 mm | | | | + | Pos. | Profil | Label | Length + | 1 | 10X10 | Structure006 | 610,00 mm | + | 1 | 10X10 | Structure017 | 610,00 mm | + | 2 | 10X10 | Structure012 | 600,00 mm | + | 2 | 10X10 | Structure018 | 600,00 mm | + + +## Use Nesting +The Option "Use Nesting" allows to specify the maximum length of the Stock Material and allows for optimizing of the available material.\ +The cut Width will be added to each piece to account for the saw thickness.\ +The list will be seperated into Sections and shows the Used Length and the Parts that can be cut from the Stock Material.\ +The position number of a piece will be the same on every stock material beam. + +### Example with Nesting & Group Party by Size + +| Beam No. 1 | | | | +|-----------------------------|--|--|--| +| Used 2855.0 mm of 3000.0 mm | | | | +| Pos. | Profil | Length | Quantity | +| 1 | 10X10 | 610,00 mm | 2 | +| 2 | 10X10 | 600,00 mm | 2 | +| 3 | 10X10 | 410,00 mm | 1 | +| | | | | +| Beam No. 2 | +| Used 3000.0 mm of 3000.0 mm | +| Pos. | Profil | Length | Quantity| +| 3 | 10X10 | 410,00 mm | 1| +| 4 | 10X10 | 390,00 mm | 2| +| 5 | 10X10 | 210,00 mm | 2| +| 6 | 10X10 | 190,00 mm | 7| +| | | | | +| Beam No. 3 | +| Used 1365.0 mm of 3000.0 mm | +| Pos. | Profil | Length | Quantity| +| 6 | 10X10 | 190,00 mm | 7| diff --git a/cut_list/__init__.py b/cut_list/__init__.py new file mode 100644 index 0000000..5957893 --- /dev/null +++ b/cut_list/__init__.py @@ -0,0 +1,29 @@ +#**************************************************************************** +#* * +#* Cut List Creation for Dodo Workbench * +#* * +#* Copyright (c) 2023 FilePhil LGPL * +#* * +#* This program is free software; you can redistribute it and/or modify * +#* it under the terms of the GNU Lesser General Public License (LGPL) * +#* as published by the Free Software Foundation; either version 2 of * +#* the License, or (at your option) any later version. * +#* for detail see the LICENCE text file. * +#* * +#* 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 Library General Public License for more details. * +#* * +#* You should have received a copy of the GNU Library General Public * +#* License along with this program; if not, write to the Free Software * +#* Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +#* USA * +#* * +#**************************************************************************** + + +import os + +RESOURCE_PATH = os.path.join(os.path.dirname(__file__), "resources") + diff --git a/cut_list/cut_list_commands.py b/cut_list/cut_list_commands.py new file mode 100644 index 0000000..01d9902 --- /dev/null +++ b/cut_list/cut_list_commands.py @@ -0,0 +1,30 @@ +import FreeCAD +import FreeCADGui +import os +from . import cut_list_ui +from . import cut_list_creation +from . import RESOURCE_PATH + + +class cutListCommand: + toolbarName = 'Cut List' + commandName = 'createCutList' + + def GetResources(self): + Icon = os.path.join(RESOURCE_PATH, "cut_list_icon.svg") + return {'MenuText': self.commandName, + 'ToolTip': "Create a new Cut List from Dodo Beams", + 'Pixmap': Icon + } + + def Activated(self): + + cut_list_ui.openCutListDialog() + + def IsActive(self): + """If there is no active document we can't do anything.""" + return not FreeCAD.ActiveDocument is None + + + +FreeCADGui.addCommand(cutListCommand.commandName, cutListCommand()) diff --git a/cut_list/cut_list_creation.py b/cut_list/cut_list_creation.py new file mode 100644 index 0000000..4161264 --- /dev/null +++ b/cut_list/cut_list_creation.py @@ -0,0 +1,220 @@ +import FreeCAD +import FreeCADGui + +from dataclasses import dataclass, asdict +from typing import List + +from . import resultSpreadsheet + + +@dataclass +class Cut: + """ Store the Infomation about the cutted Piece and provide Helper Functions + """ + label: str + profil: str + length: object + cutwidth: object + position: int = 0 + + def getKey(self): + """ Provide a Key to generate the Position Number + """ + return self.profil + "|" + str(round(self.length.getValueAs("mm"),2)) + + def totalLength(self): + return self.length + self.cutwidth + + def getRow(self): + """Provide the Information from the Cutted Piece in the form of a dict""" + return {"Label": self.label,"Profil": self.profil, "Length": self.length,"CutWidth": self.cutwidth,"Pos.":self.position} + +@dataclass +class Beam: + """ Store the Infomation about the Stock Material Beam and provide Helper Functions + """ + number: int + length: object + lengthLeft: object + cuts: List[Cut] + + def addCut(self,cut): + """ Try to fit the cutted piece on the Beam and provide a status if it fits + """ + if self.length.getValueAs("mm") > 0.1 and self.lengthLeft < cut.totalLength(): + # Cut is not Possible on this Beam + # Ignore if Beam has no lenght + return False + + self.cuts.append(cut) + self.lengthLeft -= cut.totalLength() + return True + + def getCutsAsDict(self): + """ Get a easy to work with Dict List of the Beams / Cuts""" + return [x.getRow() for x in self.cuts] + + def lengthUsed(self): + return self.length - self.lengthLeft + + +def queryStructures(profiles:list,rootObjs = None): + """ Find all Structure Elements that use one of the selected profiles + """ + + resultObjs = [] + + if rootObjs is None: + rootObjs = FreeCAD.ActiveDocument.Objects + + for obj in rootObjs: + # Follow Link Groups + if obj.TypeId == "App::LinkGroup": + resultObjs = resultObjs + queryStructures(profiles,obj.ElementList) + + # TODO: A Link to a ink Group will currently not be queried + + # Get the base Profile Used for the Stucture + base = getattr(obj, "Base", None) + computedLength = getattr(obj, "ComputedLength", None) + + # Check if the Object is a valid Strucutre + if base is None or computedLength is None: + continue + + if base.Label in profiles: + resultObjs.append(obj) + + return resultObjs + +def nestCuts(profiles:list,beamLength,cutwidth): + """ Nest a List of Cuts on a Standard Beam length to estimate the needed Beams. + """ + + allStructures = queryStructures(profiles) + + # Sort Cuts Big to Small + sortedStructures = sorted(allStructures, key=lambda x:x.ComputedLength, reverse=True) + + allCuts = [] + beams = [] # List of Lists to reference what is together in one beam [beamLength,[Cut1,Cut2...]] + beam = Beam(1, beamLength, beamLength,[]) + beams.append(beam) + + # Go through all Structure Objects and try to fit them onto the Beams + for obj in sortedStructures: + + # Create Cut Object to hold all Attributes + label = obj.Label + profile = obj.Base.Label + length = round(obj.ComputedLength,2) + cutObj = Cut(label, profile, length, cutwidth) + + cutKey = cutObj.getKey() + + # Use the Key to define the Position Number of the Cut + if cutKey not in allCuts: + allCuts.append (cutKey) + cutObj.position = len(allCuts) + else: + cutObj.position = allCuts.index(cutKey)+1 + + # Try to fit the Object on exsting Beam + nofit = True + for beam in beams: + if beam.addCut(cutObj): + nofit = False + break + + # Add new Beam and add the Cut to it + if nofit: + beam = Beam(len(beams)+1, beamLength,beamLength,[]) + beams.append(beam) + if not beam.addCut(cutObj): + raise ValueError('Cut longer than beam!') + + return beams + + +def createSpreadSheetReport(beams, name="Result_Nest_Profile"): + """ Create a Spreadsheet as the Result of the Cut list Generation. + Each Piece will be displayed as One Row + """ + result = FreeCAD.ActiveDocument.addObject("Spreadsheet::Sheet", name) + + columnLabels = ["Pos.",'Profil','Label','Length'] + result = resultSpreadsheet.ResultSpreadsheet(result, columnLabels) + + for beam in beams: + result.printHeader(f"Beam No. {beam.number}") + + # Print the Used Length if a maximum Stock Value is given + beamLength = round(beam.length,2) + if beamLength.getValueAs("mm") <= 0.1: + result.printHeader(f"Used {round(beam.lengthUsed(),2)}") + else: + result.printHeader(f"Used {round(beam.lengthUsed(),2)} of {beamLength}") + + result.printColumnLabels() + result.printRows(beam.getCutsAsDict()) + result.printEmptyLine() + + result.recompute() + +def createSpreadSheetReportGrouped(beams, name="Result_Nest_Profile"): + """ Create a Spreadsheet as the Result of the Cut list Generation. + The Pieces will be grouped by the Length and Profile. + """ + + result = FreeCAD.ActiveDocument.addObject("Spreadsheet::Sheet", name) + + columnLabels = ["Pos.","Profil","Length","Quantity"] + result = resultSpreadsheet.ResultSpreadsheet(result, columnLabels) + + for beam in beams: + result.printHeader(f"Beam No. {beam.number}") + + # Print the Used Length if a maximum Stock Value is given + beamLength = round(beam.length,2) + if beamLength.getValueAs("mm") <= 0.1: + result.printHeader(f"Used {round(beam.lengthUsed(),2)}") + else: + result.printHeader(f"Used {round(beam.lengthUsed(),2)} of {beamLength}") + + result.printColumnLabels() + + resultRows = [] + usedKeys = [] + + for cut in beam.cuts: + # Add only one Row for each Key on the Beam + currentCutKey = cut.getKey() + if currentCutKey not in usedKeys: + + roWDict = cut.getRow() + # Calculate the Number of the same pieces + roWDict["Quantity"] = len([x for x in beam.cuts if x.getKey() == currentCutKey]) + + resultRows.append(roWDict) + usedKeys.append(cut.getKey()) + + result.printRows(resultRows) + result.printEmptyLine() + + result.recompute() + + +def createCutlist(profiles,maxBeamLength,cutWidth,GroupByLength=False): + """ Nest the Cuts to the Beams and create the Report Spreadsheet + """ + + profilesLabel = "_".join(profiles) + tableName = f"Cut_List_{profilesLabel}" + + beams = nestCuts(profiles,maxBeamLength,cutWidth) + + if GroupByLength: + createSpreadSheetReportGrouped(beams,tableName) + else: + createSpreadSheetReport(beams,tableName) + \ No newline at end of file diff --git a/cut_list/cut_list_ui.py b/cut_list/cut_list_ui.py new file mode 100644 index 0000000..7b6838f --- /dev/null +++ b/cut_list/cut_list_ui.py @@ -0,0 +1,106 @@ +import FreeCAD,FreeCADGui,Part +import os + +from FreeCAD import Units +from PySide import QtCore + +from . import RESOURCE_PATH +from . import cut_list_creation + + +class cutListUI: + def __init__(self): + uiPath = os.path.join(RESOURCE_PATH, "cut_list_dialog.ui") + # this will create a Qt widget from our ui file + self.form = FreeCADGui.PySideUic.loadUi(uiPath) + + # Set the Default Values and allow only Positiv Values + maxStockDefault = Units.parseQuantity("6m") + cutWidthDefault = Units.parseQuantity("5mm") + + self.form.max_stock_length.setProperty("value",maxStockDefault) + self.form.max_stock_length.setProperty("minimum",0.0) + + self.form.cut_width.setProperty("value",cutWidthDefault) + self.form.cut_width.setProperty("minimum",0.0) + + # Set Default Options + self.form.use_nesting.stateChanged.connect(self.useNestingToggle) + + self.form.use_nesting.setChecked(False) + + self.form.use_group_by_size.setChecked(True) + + # Update the UI + self.useNestingToggle() + self.UpdateProfileList() + self.selectProfilefromSelection() + + + def selectProfilefromSelection(self): + """ Select the selected Profile if some is used + """ + for selected in FreeCADGui.Selection.getSelection(): + selLabel = selected.Label + found = self.form.profile_list.findItems(selLabel,QtCore.Qt.MatchExactly) + if len(found)>0: + item = found[0] + item.setSelected(True) + + + + def useNestingToggle(self): + """ Toggle the Nesting Options depending on the Need + """ + state = self.form.use_nesting.checkState() + self.form.max_stock_length.setProperty("enabled",state) + self.form.cut_width.setProperty("enabled",state) + + def UpdateProfileList(self): + """ Add all Sketches/Profiles/Sections that can be used for the Beam Creation + """ + + sketches = [s.Label for s in FreeCAD.ActiveDocument.Objects if s.TypeId=="Sketcher::SketchObject"] + obj2D = [s.Label for s in FreeCAD.ActiveDocument.Objects if hasattr (s,"Shape") and s.Shape.Faces and not s.Shape.Solids] + + self.form.profile_list.clear() + self.form.profile_list.addItems(sketches) + + self.form.profile_list.addItems(obj2D) + + + def accept(self): + """ Start the Creation of the Cut List + """ + # Get all selected Profiles + sel = self.form.profile_list.currentItem() + profils = [] + for item in self.form.profile_list.selectedItems(): + profils.append(item.text()) + + if profils == []: + # Do not try to create a cut list with an empty selection + # TODO: Add Message Box + return + # Get teh Nesting Information or set the default + if self.form.use_nesting.checkState() == False: + maxStockLength = Units.parseQuantity("0mm") + cutWidth = Units.parseQuantity("0mm") + else: + maxStockLength = self.form.max_stock_length.property('value') + cutWidth = self.form.cut_width.property('value') + + # Generate the Cut List + cut_list_creation.createCutlist(profils, + maxStockLength, + cutWidth, + self.form.use_group_by_size.checkState()) + + FreeCADGui.Control.closeDialog() + + def reject(self): + FreeCADGui.Control.closeDialog() + +def openCutListDialog(): + panel = cutListUI() + FreeCADGui.Control.showDialog(panel) \ No newline at end of file diff --git a/cut_list/resources/cut_list_dialog.ui b/cut_list/resources/cut_list_dialog.ui new file mode 100644 index 0000000..854a84f --- /dev/null +++ b/cut_list/resources/cut_list_dialog.ui @@ -0,0 +1,257 @@ + + + Dialog + + + + 0 + 0 + 450 + 643 + + + + + 0 + 0 + + + + Create Cut List + + + + + + + 0 + 0 + + + + QFrame::StyledPanel + + + QFrame::Raised + + + + + + + true + false + + + + Select Profile + + + 10 + + + + + + + + 0 + 10 + + + + + 0 + 150 + + + + + 0 + 0 + + + + QAbstractScrollArea::AdjustIgnored + + + QAbstractItemView::NoEditTriggers + + + false + + + true + + + QAbstractItemView::MultiSelection + + + QListView::Fixed + + + QListView::SinglePass + + + + + + + + + + QFrame::StyledPanel + + + QFrame::Raised + + + + + + + true + false + + + + Cut List Options + + + 10 + + + + + + + + 0 + 0 + + + + Qt::LeftToRight + + + Group Parts by Size + + + + + + + + + + true + + + + + + + + + + + + + + + QFrame::StyledPanel + + + QFrame::Raised + + + + + + + true + false + + + + Nesting Options + + + 10 + + + + + + + + + Maxmium Stock Length + + + 10 + + + + + + + Cut Width + + + 10 + + + + + + + + + + + + + + 0 + 0 + + + + Qt::LeftToRight + + + Use Nesting + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + + Gui::QuantitySpinBox + QDoubleSpinBox +
quantityspinbox.h
+
+
+ + +
diff --git a/cut_list/resources/cut_list_icon.svg b/cut_list/resources/cut_list_icon.svg new file mode 100644 index 0000000..a144344 --- /dev/null +++ b/cut_list/resources/cut_list_icon.svg @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/cut_list/resultSpreadsheet.py b/cut_list/resultSpreadsheet.py new file mode 100644 index 0000000..d6b5e97 --- /dev/null +++ b/cut_list/resultSpreadsheet.py @@ -0,0 +1,176 @@ +# Heavily Inspired Code from +# https://github.com/furti/FreeCAD-Reporting/blob/master/report.py + +import FreeCAD +import FreeCADGui +import string + + +COLUMN_NAMES = list(string.ascii_uppercase) + +def literalText(text): + return "'%s" % (text) + +def lineRange(startColumn, endColumn, lineNumber): + return cellRange(startColumn, lineNumber, endColumn, lineNumber) + + +def cellRange(startColumn, startLine, endColumn, endLine): + return '%s%s:%s%s' % (startColumn, startLine, endColumn, endLine) + +def nextColumnName(actualColumnName): + if actualColumnName is None: + return COLUMN_NAMES[0] + + nextIndex = COLUMN_NAMES.index(actualColumnName) + 1 + + if nextIndex >= len(COLUMN_NAMES): + nextIndex -= len(COLUMN_NAMES) + + return COLUMN_NAMES[nextIndex] + + +class ResultSpreadsheet(object): + + def __init__(self, spreadsheet, columnLabels): + + self.spreadsheet = spreadsheet + self.lineNumber = 1 + self.maxColumn = None + self.columnLabels = columnLabels + + def getColumnName(self, label): + if label is None: + return COLUMN_NAMES[0] + + nextIndex = self.columnLabels.index(label) + + if nextIndex >= len(COLUMN_NAMES): + nextIndex -= len(COLUMN_NAMES) + + return COLUMN_NAMES[nextIndex] + + + def clearAll(self): + self.spreadsheet.clearAll() + + def printEmptyLine(self): + self.lineNumber += 1 + + def printHeader(self, header='HAEADER'): + spreadsheet = self.spreadsheet + + if header is None or header == '': + return + + headerCell = 'A%s' % (self.lineNumber) + + self.setCellValue(headerCell, header) + spreadsheet.setStyle(headerCell, 'bold|underline', 'add') + + spreadsheet.mergeCells(lineRange('A', COLUMN_NAMES[len(self.columnLabels)-1], self.lineNumber)) + + self.lineNumber += 1 + self.updateMaxColumn('A') + + self.clearLine(self.lineNumber) + + def printColumnLabels(self): + spreadsheet = self.spreadsheet + + columnName = None + + for columnLabel in self.columnLabels: + columnName = self.getColumnName(columnLabel) + cellName = f"{columnName}{self.lineNumber}" + + self.setCellValue(cellName, columnLabel) + + spreadsheet.setStyle( + lineRange('A', columnName, self.lineNumber), 'bold', 'add') + + self.lineNumber += 1 + self.updateMaxColumn(columnName) + + self.clearLine(self.lineNumber) + + def printRows(self, rows): + lineNumberBefore = self.lineNumber + + columnName = 'A' + for row in rows: + columnName = None + + for columnlabel, value in row.items(): + if columnlabel in self.columnLabels: + columnName = self.getColumnName(columnlabel) + cellName = f"{columnName}{self.lineNumber}" + + self.setCellValue(cellName, value) + + self.lineNumber += 1 + + #if printResultInBold: + # self.spreadsheet.setStyle( + # cellRange('A', lineNumberBefore, columnName, self.lineNumber), 'bold', 'add') + + self.clearLine(self.lineNumber) + + + self.updateMaxColumn(columnName) + + def setCellValue(self, cell, value): + if value is None: + convertedValue = '' + elif isinstance(value, FreeCAD.Units.Quantity): + convertedValue = value.UserString + else: + convertedValue = str(value) + + convertedValue = literalText(convertedValue) + + self.spreadsheet.set(cell, convertedValue) + + def recompute(self): + self.spreadsheet.recompute() + + def updateMaxColumn(self, columnName): + if self.maxColumn is None: + self.maxColumn = columnName + else: + actualIndex = COLUMN_NAMES.index(self.maxColumn) + columnIndex = COLUMN_NAMES.index(columnName) + + if actualIndex < columnIndex: + self.maxColumn = columnName + + def clearUnusedCells(self, column, line): + if line is not None and line > self.lineNumber: + for lineNumberToDelete in range(line, self.lineNumber, -1): + self.clearLine(lineNumberToDelete) + + if column is not None: + columnIndex = COLUMN_NAMES.index(column) + maxColumnIndex = COLUMN_NAMES.index(self.maxColumn) + + if columnIndex > maxColumnIndex: + for columnIndexToDelete in range(columnIndex, maxColumnIndex, -1): + self.clearColumn(COLUMN_NAMES[columnIndexToDelete], line) + + def clearLine(self, lineNumberToDelete): + + column = None + + while column is None or column != self.maxColumn: + column = nextColumnName(column) + cellName = f"{column}{lineNumberToDelete}" + + + self.spreadsheet.clear(cellName) + + def clearColumn(self, columnToDelete, maxLineNumber): + + for lineNumber in range(1, maxLineNumber): + cellName = f"{columnToDelete}{lineNumber + 1}" + + self.spreadsheet.clear(cellName)