diff --git a/.gitignore b/.gitignore index c0f50ae..df1f9e2 100644 --- a/.gitignore +++ b/.gitignore @@ -130,3 +130,6 @@ dmypy.json # Bundle Script *.bat + +# Pycharm +.idea/ diff --git a/icon.bmp b/icon.bmp new file mode 100644 index 0000000..9bfbb3b Binary files /dev/null and b/icon.bmp differ diff --git a/icon.ico b/icon.ico new file mode 100644 index 0000000..c2df64a Binary files /dev/null and b/icon.ico differ diff --git a/icon.png b/icon.png new file mode 100644 index 0000000..be07b12 Binary files /dev/null and b/icon.png differ diff --git a/icon.xcf b/icon.xcf new file mode 100644 index 0000000..1ab5b27 Binary files /dev/null and b/icon.xcf differ diff --git a/icon_big.png b/icon_big.png new file mode 100644 index 0000000..f898dfc Binary files /dev/null and b/icon_big.png differ diff --git a/main.py b/main.py new file mode 100644 index 0000000..9ed6481 --- /dev/null +++ b/main.py @@ -0,0 +1,508 @@ +import os +import sys +from copy import copy +from math import sqrt, ceil, floor +import numpy as np +import tkinter as tk +from tkinter import filedialog, LEFT, X +from rectpack import newPacker, float2dec, PackingMode, PackingBin, SORT_NONE +from ctypes import windll + +GWL_EXSTYLE = -20 +WS_EX_APPWINDOW = 0x00040000 +WS_EX_TOOLWINDOW = 0x00000080 + +import trimesh +from trimesh.path.entities import Text +from trimesh.path.exchange.export import export_path +from trimesh.path.creation import rectangle +from trimesh.transformations import scale_matrix + +# declare "globals" for use across functions +mesh = None +unit = None +sections = None +pdfSections = None +combined = None +bins = None +blocked = False +saveBinsButton = None +showKeyButton = None + + +# shows dimensions and layer count in main gui window +# updates live when model has been selected +def calcDim(args=None): + scale = 1 + + # if scale is number set scale, else reset text entry + try: + scale = float(scaleFactorEntry.get()) + except ValueError: + scaleFactorEntry.config(text="1") + + # get and display scaled bounds + dimensions = mesh.bounds + + dimensionsAfterLabel.config(text="Scaled Dimensions (" + unit + ")") + xAfterlabel.config(text="x: " + str(round((dimensions[1][0] - dimensions[0][0]) * scale, 2))) + yAfterlabel.config(text="y: " + str(round((dimensions[1][1] - dimensions[0][1]) * scale, 2))) + zAfterlabel.config(text="z: " + str(round((dimensions[1][2] - dimensions[0][2]) * scale, 2))) + + # get and display number of layers + try: + thickness = float(layerThicknessEntry.get()) + if thickness == 0: + numLayersLabel.config(text="Layers: N/A") + else: + numLayersLabel.config( + text="Layers: " + str(ceil(((dimensions[1][2] - dimensions[0][2]) * scale) / thickness))) + except ValueError: + numLayersLabel.config(text="Layers: N/A") + + +# opens 3D model and fills gui elements with appropriate info (base unit, model name, model dimensions, etc) +def getFile(): + # maps base unit result to abbreviation + unitDict = { + "millimeters": "mm", + "inches": "in" + } + + # ask for file + filename = tk.filedialog.askopenfilename(initialdir="/", + title="Select a File", + filetypes=((".stl files", "*.stl"), ("all files", ".*"))) + + # add model file path to gui + modelEntry.config(text=filename) + + # load the mesh from filename + global mesh, unit + mesh = trimesh.load_mesh(filename) + + # get base unit, add to gui + unit = trimesh.units.units_from_metadata(mesh, guess=True) + try: + unit = unitDict[unit] + except KeyError: + pass + + layerThicknessLabel.config(text="Layer Thickness (" + unit + "): ") + + # add model dimensions to gui + calcDim() + + # now that model is open, listen for changes in relevant text entries to update dimensions shown in gui + scaleFactorEntry.bind('', calcDim) + layerThicknessEntry.bind('', calcDim) + + +def focus_results(): + # bring results window forward + if results is not None and 'normal' == results.state(): + results.focus_set() + return + + +# run slices, show export window +def go(): + global combined, sections, results, saveBinsButton, showKeyButton, showKey + + focus_results() + + # scale model according to text entry in main window + mesh.apply_transform(scale_matrix(float(scaleFactorEntry.get()), [0, 0, 0])) + + # slice the mesh into evenly spaced chunks along z + # this takes the (2,3) bounding box and slices it into [minz, maxz] + z_extents = mesh.bounds[:, 2] + # slice every x (text entry) model units (eg, inches) + z_levels = np.arange(*z_extents, step=float(layerThicknessEntry.get())) + + # create cross section outlines + sections = mesh.section_multiplane(plane_origin=mesh.bounds[0], + plane_normal=[0, 0, 1], + heights=z_levels) + + # in the case that slice did not intersect with model, remove section + sections = list(filter(lambda element: element is not None, sections)) + + combined = np.sum(sections) + + # GUI stuff + results = tk.Toplevel(root) + results.title("Results") + results.protocol("WM_DELETE_WINDOW", clearResults) + + buttonFrame = tk.Frame(results) + viewFrame = tk.Frame(buttonFrame) + sectionsButton = tk.Button(viewFrame, text="View Layers", command=plot) + modelButton = tk.Button(viewFrame, text="View Model", command=model) + exportButton = tk.Button(buttonFrame, text="Export Layers to SVG", command=export) + + heightFrame = tk.Frame(results) + exportHeight = tk.Label(heightFrame, text="Height (" + unit + "): ") + exportHeightEntry = tk.Entry(heightFrame, width=10) + + widthFrame = tk.Frame(results) + exportWidth = tk.Label(widthFrame, text="Width (" + unit + "): ") + exportWidthEntry = tk.Entry(widthFrame, width=10) + + kerfFrame = tk.Frame(results) + exportKerf = tk.Label(kerfFrame, text="Kerf (" + unit + "): ") + exportKerfEntry = tk.Entry(kerfFrame, width=10) + + exportFileButton = tk.Button(results, text="Prepare Cuts", command=( + lambda *args: exportFile(float(exportHeightEntry.get()), float(exportWidthEntry.get()), + float(exportKerfEntry.get())))) + + exportFrame = tk.Frame(results) + saveBinsButton = tk.Button(exportFrame, text="Export Cut Files", command=saveFiles) + showKeyButton = tk.Checkbutton(exportFrame, text='Key?', variable=showKey, onvalue=True, offvalue=False) + + buttonFrame.pack(pady=10, padx=5) + viewFrame.pack(pady=5) + sectionsButton.pack(side=LEFT, padx=1) + modelButton.pack(side=LEFT, padx=1) + exportButton.pack() + + heightFrame.pack() + exportHeight.pack(side=LEFT) + exportHeightEntry.pack(side=LEFT) + widthFrame.pack() + exportWidth.pack(side=LEFT) + exportWidthEntry.pack(side=LEFT) + kerfFrame.pack() + exportKerf.pack(side=LEFT) + exportKerfEntry.pack(side=LEFT) + + exportFileButton.pack(padx=1) + exportFrame.pack(pady=5, padx=5) + + results.update() + results.geometry(f'+{root.winfo_rootx()+int((root.winfo_width()-results.winfo_width())/2)}+{root.winfo_rooty()+int((root.winfo_height()-results.winfo_height())/2)}') + results.mainloop() + + +# empty results, hide window so process can be run again +def clearResults(): + global results + + results.destroy() + results = None + + +# show top-down view of stacked layers +def plot(): + global blocked + + if not blocked: + blocked = True + # summing the array of Path2D objects will put all of the curves + # into one Path2D object, which can be plotted easily + combined.show() + + blocked = False + + +# shows unsliced model +def model(): + global blocked + + if not blocked: + blocked = True + + scene = mesh.scene() + camera = scene.camera + camera.resolution = (640, 520) + scene.base_frame = "Model" + scene.camera = camera + scene.show() + + blocked = False + + +# save layer svg's to selected directory +def export(): + global blocked + + if not blocked: + blocked = True + + # ask for directory + directory = filedialog.askdirectory() + + for i in range(len(sections)): + export_path(sections[i], file_type="svg", file_obj=directory + "/layer" + str(i) + ".svg") + + focus_results() + + blocked = False + + +# prepares bins for export, performs bin packing +# arguments: bin height, bin width, kerf +def exportFile(h, w, k): + global blocked, bins, pdfSections + + # if other window is not open + if not blocked: + blocked = True + + # prepare packers, one with rotation enabled, one disabled, results will be compared later + packer = newPacker(mode=PackingMode.Offline, bin_algo=PackingBin.Global, sort_algo=SORT_NONE, rotation=False) + rotPacker = newPacker(mode=PackingMode.Offline, bin_algo=PackingBin.Global, sort_algo=SORT_NONE, rotation=True) + + # add rectangles to packers + for i in range(len(sections)): + dimensions = sections[i].bounds + + sections[i].apply_translation((-dimensions[0][0], -dimensions[0][1])) + + dimensions = sections[i].bounds + + # set dimensions to bounds plus kerf + r = (float2dec((dimensions[1][0]) + k, 5), float2dec((dimensions[1][1]) + k, 5)) + packer.add_rect(*r, i) + rotPacker.add_rect(*r, i) + + # add bins to packers + packer.add_bin(float2dec(w, 5), float2dec(h, 5), count=float("inf")) + rotPacker.add_bin(float2dec(w, 5), float2dec(h, 5), count=float("inf")) + + # run pack + packer.pack() + rotPacker.pack() + + # init var to largest bin area of packers, will be overridden to find minimum area used, minimizing material wastage + leastBinSum = w * h * max(len(packer), len(rotPacker)) + # loop through packers + for pack in (packer, rotPacker): + binSum = 0 + # loop through packed bins + for i in range(len(pack)): + leastX = w + leastY = h + greatestX = 0 + greatestY = 0 + # loop through packed rectangles + for rect in pack.rect_list(): + # if rectangles is in bin + if rect[0] == i: + # check to see if rectangle is closer to the top corner than the previous closest + if rect[1] < leastX: + leastX = rect[1] + if rect[2] < leastY: + leastY = rect[2] + # check to see if rectangle is closer to the bottom corner than the previous closest + if rect[3] + rect[1] > greatestX: + greatestX = rect[3] + rect[1] + if rect[4] + rect[2] > greatestY: + greatestY = rect[4] + rect[2] + # add used area of bin to total + binSum += ((greatestX - leastX) * (greatestY - leastY)) + # override packer least used area if needed + if binSum < leastBinSum: + leastBinSum = binSum + leastPacker = pack + + # use algo that resulted in the least wastage + packer = leastPacker + bins = list() + displaySections = list() + pdfSections = list() + # create visual representation of bins for display + for i in range(len(packer)): + bins.append(list()) + + rowColLen = ceil(sqrt(len(packer))) + row = floor(i / rowColLen) + col = i % rowColLen + + displayBin = rectangle(((0, 0), (w, h))) + displayBin.apply_translation( + ((packer[i].width * 11 / 10) * col, -((packer[i].height * 11 / 10) * row + packer[i].height))) + + displaySections.append(displayBin) + pdfSections.append(displayBin) + + # add rectangles to their designated bins + for rect in packer.rect_list(): + section = sections[rect[5]] + bin = rect[0] + + # find position of bin for display purposes (bins displayed in grid, find row & col) + rowColLen = ceil(sqrt(len(packer))) + row = floor((bin) / rowColLen) + col = bin % rowColLen + + # check if rectangle has rotated, if so, rotate + dimensions = section.bounds + if rect[3] != float2dec((dimensions[1][0]) + k, 5) and rect[4] != float2dec((dimensions[1][1]) + k, 5): + section.apply_transform(((0, -1, 0), (1, 0, 0), (0, 0, 1))) + dimensions = section.bounds + section.apply_translation((-dimensions[0][0], -dimensions[0][1])) + + # find x, y translation to move rectangle to designated spot in bin + section.apply_translation((rect[1] + float2dec(k / 2, 5), rect[2] + float2dec(k / 2, 5))) + bins[bin].append(section) + + # find x, y translation to offset display rectangle to the proper bin in the bin grid + displaySection = copy(section) + displaySection.apply_translation( + ((packer[bin].width * 11 / 10) * col, -((packer[bin].height * 11 / 10) * row + packer[bin].height))) + displaySections.append(displaySection) + + # for display only, add rectangle bounding box, for visualization of packing performance + displayRect = rectangle(((displaySection.bounds[0][0], displaySection.bounds[0][1]), + (displaySection.bounds[1][0], displaySection.bounds[1][1]))) + displaySections.append(displayRect) + + # also add bounding box to key display, real shape outline not added for readability + pdfRect = copy(displayRect) + pdfRect.entities = np.append(pdfRect.entities, Text(0, str(rect[5] + 1), align=('left', 'bottom'))) + pdfSections.append(pdfRect) + + # show packed rectangles and bins in human understandable format + displayCombine = np.sum(displaySections) + displayCombine.show() + + # now that export has been prepared, show save buttons + saveBinsButton.pack(side=LEFT, padx=1) + showKeyButton.pack(side=LEFT) + + blocked = False + + +# save bins and show key +def saveFiles(): + global blocked + + if not blocked: + blocked = True + + # ask for directory selection + directory = filedialog.askdirectory() + + # export bins + for i in range(len(bins)): + exportCombine = np.sum(bins[i]) + export_path(exportCombine, file_type="svg", file_obj=directory + "/bin" + str(i) + ".svg") + + focus_results() + + # show key + if (showKey.get()): + pdfCombine = np.sum(pdfSections) + pdfCombine.show() + + blocked = False + + +# forces window to appear in taskbar, windows specific +def set_appwindow(root): + hwnd = windll.user32.GetParent(root.winfo_id()) + style = windll.user32.GetWindowLongW(hwnd, GWL_EXSTYLE) + style = style & ~WS_EX_TOOLWINDOW + style = style | WS_EX_APPWINDOW + res = windll.user32.SetWindowLongW(hwnd, GWL_EXSTYLE, style) + # re-assert the new window style + root.wm_withdraw() + root.after(10, lambda: root.wm_deiconify()) + + +# for newer versions of pyinstaller, needed to retrieve icon image +def resource_path(relative_path): + """ Get absolute path to resource, works for dev and for PyInstaller """ + try: + # PyInstaller creates a temp folder and stores path in _MEIPASS + base_path = sys._MEIPASS + except Exception: + base_path = os.path.abspath(".") + + return os.path.join(base_path, relative_path) + + +results = None + +# create base window +root = tk.Tk() +root.title("3DSlicer") +root.option_add('*Font', '19') +root.attributes("-toolwindow", 1) +p1 = tk.PhotoImage(file=resource_path('icon.png')) +# Icon set for program window +root.iconphoto(True, p1) + +# clear children of base window +clearList = root.winfo_children() + +for child in clearList: + child.destroy() + +# set default state of key checkbox +showKey = tk.BooleanVar() + +# generate window +menubar = tk.Menu(root) +filemenu = tk.Menu(menubar, tearoff=0) + +# TODO: filemenu.add_command(label="About", command=about) +filemenu.add_command(label="Exit", command=root.destroy) +menubar.add_cascade(label="File", menu=filemenu) + +modelFrame = tk.Frame(root) +topModelFrame = tk.Frame(modelFrame) +modelLabel = tk.Label(topModelFrame, text="Model: ") +modelEntry = tk.Label(topModelFrame, text="N/A") +modelBrowse = tk.Button(modelFrame, text="Browse", command=getFile) + +entriesFrame = tk.Frame(root) +layerEntryFrame = tk.Frame(entriesFrame) +layerThicknessLabel = tk.Label(layerEntryFrame, text="Layer Thickness (N/A): ") +layerThicknessEntry = tk.Entry(layerEntryFrame, width=5) + +scaleEntryFrame = tk.Frame(entriesFrame) +scaleFactorLabel = tk.Label(scaleEntryFrame, text="Model Scale Factor: ") +scaleFactorEntry = tk.Entry(scaleEntryFrame, width=5) +scaleFactorEntry.insert(0, "1") + +numbersFrame = tk.Frame(root) +dimensionsAfterLabel = tk.Label(numbersFrame, text="Scaled Dimensions (N/A)") +xAfterlabel = tk.Label(numbersFrame, text="x: N/A") +yAfterlabel = tk.Label(numbersFrame, text="y: N/A") +zAfterlabel = tk.Label(numbersFrame, text="z: N/A") + +numLayersLabel = tk.Label(root, text="Layers: N/A") + +goButton = tk.Button(root, text="Go", command=go) + +root.config(menu=menubar) + +modelFrame.pack(pady=10) +topModelFrame.pack() +modelLabel.pack(side=LEFT) +modelEntry.pack(side=LEFT) +modelBrowse.pack() + +entriesFrame.pack() +numbersFrame.pack(pady=10) +layerEntryFrame.pack() +scaleEntryFrame.pack(fill=X) +scaleFactorLabel.pack(side=LEFT) +scaleFactorEntry.pack(side=LEFT) +layerThicknessLabel.pack(side=LEFT) +layerThicknessEntry.pack(side=LEFT) + +dimensionsAfterLabel.pack() +xAfterlabel.pack() +yAfterlabel.pack() +zAfterlabel.pack() + +numLayersLabel.pack() + +goButton.pack(ipadx=10, pady=5) + +root.after(10, lambda: set_appwindow(root)) +root.mainloop()