├── .gitignore ├── docs ├── requirements.txt ├── source │ ├── _static │ │ └── images │ │ │ └── sheetah.jpg │ ├── index.rst │ ├── installation.rst │ ├── introduction.rst │ └── conf.py ├── Makefile └── make.bat ├── sheetah ├── resources │ ├── rotate_icon.png │ └── scale_icon.png ├── style │ ├── images │ │ └── checkbox.png │ └── darkorange.stylesheet ├── project.py ├── polylineinterface.py ├── videothread.py ├── sheetah.py ├── transformhandle.py ├── postprocessor.py ├── klippercontroller.py ├── fileutils.py ├── workspacecontroller.py ├── workspacegraphics.py ├── jobgraphics.py ├── job.py ├── controllerbase.py └── polyline.py ├── requirements.txt ├── .readthedocs.yml ├── README.md └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | build/ 3 | env/ 4 | .cmd_history 5 | Pipfile.lock 6 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx==3.0.3 2 | sphinx-rtd-theme==0.4.3 3 | sphinx-prompt==1.2.0 4 | -------------------------------------------------------------------------------- /sheetah/resources/rotate_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/proto3/Sheetah/HEAD/sheetah/resources/rotate_icon.png -------------------------------------------------------------------------------- /sheetah/resources/scale_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/proto3/Sheetah/HEAD/sheetah/resources/scale_icon.png -------------------------------------------------------------------------------- /sheetah/style/images/checkbox.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/proto3/Sheetah/HEAD/sheetah/style/images/checkbox.png -------------------------------------------------------------------------------- /docs/source/_static/images/sheetah.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/proto3/Sheetah/HEAD/docs/source/_static/images/sheetah.jpg -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | cavaliercontours-python==0.0.1 2 | ezdxf==0.14 3 | geomdl==5.3.0 4 | numpy==1.19.2 5 | opencv-python==4.3.0.36 6 | pyparsing==2.4.7 7 | PyQt5==5.15.0 8 | PyQt5-sip==12.8.1 9 | pyqtgraph==0.11.0 10 | pyserial==3.4 11 | regex==2020.7.14 12 | Shapely==1.7.1 13 | svgpathtools==1.3.3 14 | svgwrite==1.4 15 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. sheetah documentation master file, created by 2 | sphinx-quickstart on Tue May 26 16:58:56 2020. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Sheetah: Plasma CAM software 7 | ========================================================================== 8 | 9 | .. toctree:: 10 | :maxdepth: 2 11 | 12 | introduction 13 | installation 14 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Build documentation in the docs/ directory with Sphinx 9 | sphinx: 10 | configuration: docs/source/conf.py 11 | 12 | # Build documentation with MkDocs 13 | #mkdocs: 14 | # configuration: mkdocs.yml 15 | 16 | # Optionally build your docs in additional formats such as PDF 17 | formats: [] 18 | 19 | # Optionally set the version of Python and requirements required to build your docs 20 | python: 21 | version: 3.7 22 | install: 23 | - requirements: docs/requirements.txt 24 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/source/installation.rst: -------------------------------------------------------------------------------- 1 | Sheetah installation 2 | ==================== 3 | 4 | Assuming you have Python3 and Virtualenv already installed. Start by cloning 5 | Sheetah's repository. 6 | 7 | .. prompt:: bash $ 8 | 9 | git clone git@github.com:proto3/Sheetah.git 10 | cd Sheetah 11 | 12 | Now create a virtual environment with virtualenv, activate it and install 13 | packages listed in requirements.txt. 14 | 15 | .. prompt:: bash $ 16 | 17 | virtualenv -p python3 env # create 18 | source env/bin/activate # activate 19 | pip install -r requirements.txt # initialize 20 | 21 | You can now run Sheetah. 22 | 23 | .. prompt:: bash $ 24 | 25 | cd sheetah 26 | ./sheetah.py 27 | 28 | Once you're done you can deactivate your virtualenv like this. 29 | 30 | .. prompt:: bash $ 31 | 32 | deactivate 33 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Documentation Status](https://readthedocs.org/projects/sheetah/badge/?version=latest)](https://sheetah.readthedocs.io/en/latest/?badge=latest) 2 | 3 | ***Work in progress...*** 4 | # Sheetah : Plasma CAM software 5 | 6 | Sheetah is a CAM software for plasma. It has been designed to work in pair with the plasma version of [Klipper](https://github.com/proto3/klipper-plasma). Combining both softwares unlock THC real-time monitoring and dynamic error handling instead of a static G-code file. 7 | Work is still in progress and any help is welcome ! 8 | 9 | You can read [documentation here](https://sheetah.readthedocs.io), or have a look at [Klipper for plasma](https://github.com/proto3/klipper-plasma). 10 | 11 | ## Augmented reality machining 12 | It is planned to add augmented reality to Sheetah through a webcam fixed above the machine and some calibration algorithms. This way, it would be easier to fit parts in metal scraps onto the machine. 13 | 14 | ![alt text](docs/source/_static/images/sheetah.jpg) 15 | -------------------------------------------------------------------------------- /sheetah/project.py: -------------------------------------------------------------------------------- 1 | from PyQt5.QtCore import QObject, pyqtSignal 2 | import fileutils 3 | import pathlib 4 | from job import Job 5 | 6 | class Project(QObject): 7 | job_update = pyqtSignal() 8 | 9 | def __init__(self): 10 | super().__init__() 11 | self.jobs = list() 12 | 13 | def load_job(self, filepath): 14 | try: 15 | self.jobs += fileutils.load(filepath) 16 | self.job_update.emit() 17 | except Exception as e: 18 | filename = pathlib.Path(filepath).name 19 | print('Unable to load ' + filename + ', ' + str(e)) 20 | 21 | def remove_jobs(self, jobs): 22 | self.jobs = [j for j in self.jobs if j not in jobs] 23 | self.job_update.emit() 24 | 25 | def generate_tasks(self, post_processor, dry_run): 26 | tasks = [] 27 | for job in self.jobs: 28 | for i in job.cut_state_indices(Job.TODO): 29 | tasks.append(post_processor.generate(job, i, dry_run)) 30 | if tasks: 31 | tasks.insert(0, post_processor.init_task()) 32 | return tasks 33 | -------------------------------------------------------------------------------- /docs/source/introduction.rst: -------------------------------------------------------------------------------- 1 | Introduction 2 | ============================== 3 | 4 | `Sheetah`_ is a plasma CAM software. It allows you to import DXF or SVG 5 | drawings, to configure cut parameters and generates G-Codes. Alternatively, it 6 | can connect to `Klipper for plasma`_ controller and generate G-codes on the fly, 7 | while monitoring cut and handling runtime errors (ignition timeout, arc loss, 8 | etc). 9 | 10 | .. figure:: _static/images/sheetah.jpg 11 | :figwidth: 700px 12 | :target: _static/images/sheetah.jpg 13 | 14 | Planned features: 15 | - Better user interface for parts. Drag, scale, multiply, rectangle selection, etc 16 | - Augmented reality to place parts on the machine virtually 17 | - Configurable post-processor to make G-Code compatible with any plasma controller 18 | - Complete arc handling instead of discretizing into lines (also convert curves into arcs) 19 | - Nesting algorithm, possibly with `SVGnest`_ 20 | - Packaged release 21 | 22 | .. _Sheetah: https://github.com/proto3/sheetah 23 | .. _Klipper for plasma: https://github.com/proto3/klipper-plasma 24 | .. _SVGnest: https://github.com/Jack000/SVGnest 25 | -------------------------------------------------------------------------------- /sheetah/polylineinterface.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod, abstractproperty 2 | 3 | class PolylineInterface(ABC): 4 | @abstractproperty 5 | def raw(self): 6 | pass 7 | 8 | @abstractproperty 9 | def start(self): 10 | pass 11 | 12 | @abstractproperty 13 | def end(self): 14 | pass 15 | 16 | @abstractproperty 17 | def bounds(self): 18 | pass 19 | 20 | @abstractmethod 21 | def is_closed(self): 22 | pass 23 | 24 | @abstractmethod 25 | def is_ccw(self): 26 | pass 27 | 28 | @abstractmethod 29 | def is_simple(self): 30 | pass 31 | 32 | @abstractmethod 33 | def reverse(self): 34 | pass 35 | 36 | @abstractmethod 37 | def contains(self, object): 38 | pass 39 | 40 | @abstractmethod 41 | def intersects(self, polyline): 42 | pass 43 | 44 | @abstractmethod 45 | def affine(self, d, r, s): 46 | pass 47 | 48 | @abstractmethod 49 | def offset(self, offset): 50 | pass 51 | 52 | @abstractmethod 53 | def to_lines(self): 54 | pass 55 | 56 | # @abstractmethod 57 | # def to_gcode(self): 58 | # pass 59 | 60 | ## STATIC METHODS ## 61 | # def line2polyline(start, end): 62 | # pass 63 | # 64 | # def arc2polyline(center, radius, rad_start, rad_end): 65 | # pass 66 | # 67 | # def circle2polyline(center, radius): 68 | # pass 69 | # 70 | # def spline2polyline(degree, control_points, closed): 71 | # pass 72 | # 73 | # def aggregate(polylines): 74 | # pass 75 | # 76 | # def group_as_contours(polylines): 77 | # pass 78 | -------------------------------------------------------------------------------- /sheetah/videothread.py: -------------------------------------------------------------------------------- 1 | from PyQt5 import QtCore 2 | import cv2 3 | 4 | class VideoThread(QtCore.QThread): 5 | frame_available = QtCore.pyqtSignal() 6 | def run(self): 7 | cap = cv2.VideoCapture(0) 8 | cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1280) 9 | cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 720) 10 | cap.set(cv2.CAP_PROP_FPS, 5) 11 | counter = -1 12 | while cap.isOpened(): 13 | ret, frame = cap.read() 14 | counter = (counter + 1)%(5./0.2) 15 | if ret and not counter: 16 | colored = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV) 17 | colored[:,:,0] = 20 #HUE 18 | colored[:,:,1] = 120 #SAT 19 | # frame[:,:,2] *= 5 #VAL 20 | colored = cv2.cvtColor(colored, cv2.COLOR_HSV2RGB, cv2.CV_8U) 21 | colored = cv2.convertScaleAbs(colored, alpha=0.35, beta=20) 22 | 23 | 24 | blurred = cv2.bilateralFilter(frame, 7, 50, 50) 25 | aaa = cv2.Canny(blurred, 20, 60) 26 | canny = cv2.cvtColor(aaa, cv2.COLOR_GRAY2RGB) 27 | canny = cv2.convertScaleAbs(canny, alpha=0.05, beta=0) 28 | frame = colored + canny 29 | frame = cv2.resize(frame, (1320, 900)) 30 | 31 | # frame = cv2.rotate(frame, cv2.ROTATE_90_CLOCKWISE) 32 | # blurred = cv2.GaussianBlur(src, (3,3), 0) 33 | # blurred = cv2.medianBlur(src, 5) 34 | # laplace = cv2.Laplacian(blurred, cv2.CV_16S) 35 | # res = cv2.convertScaleAbs(laplace) 36 | # self.frame = cv2.stylization(frame, sigma_s=60, sigma_r=0.45) 37 | # blurred = cv2.blur(src, (3,3)) 38 | # blurred = cv2.medianBlur(src, 5) 39 | # blurred = cv2.bilateralFilter(src, 7, 50, 50) 40 | self.frame = frame 41 | 42 | self.frame_available.emit() 43 | cap.release() 44 | -------------------------------------------------------------------------------- /sheetah/sheetah.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | from PyQt5 import QtWidgets 4 | from PyQt5.QtCore import Qt 5 | import pyqtgraph as pg 6 | 7 | 8 | from project import Project 9 | from klippercontroller import KlipperController, KlipperControllerUI 10 | from postprocessor import PostProcessor 11 | 12 | from workspacegraphics import WorkspaceView, ProjectBar 13 | from workspacecontroller import WorkspaceController 14 | 15 | class MainWindow(QtWidgets.QMainWindow): 16 | def __init__(self, ws_view, ws_controller, sidebar, console): 17 | super().__init__() 18 | self.ws_controller = ws_controller 19 | self.setWindowTitle("Sheetah") 20 | widget = QtWidgets.QWidget() 21 | layout = QtWidgets.QGridLayout() 22 | layout.addWidget(ws_view, 0, 0, 1, 2) 23 | layout.addWidget(console, 1, 0) 24 | layout.addWidget(sidebar, 1, 1) 25 | layout.setRowStretch(0,2) 26 | layout.setRowStretch(1,1) 27 | widget.setLayout(layout) 28 | self.setCentralWidget(widget) 29 | 30 | def keyPressEvent(self, ev): 31 | self.ws_controller.keyPressEvent(ev) 32 | 33 | if __name__ == '__main__': 34 | app = QtWidgets.QApplication([]) 35 | pg.setConfigOption('background', 'w') 36 | 37 | app.setStyleSheet(open('style/darkorange.stylesheet').read()) 38 | 39 | pg.setConfigOption('background', 0.1) 40 | pg.setConfigOption('foreground', 'w') 41 | 42 | project = Project() 43 | project_bar = ProjectBar(project) 44 | ws_view = WorkspaceView() 45 | ws_controller = WorkspaceController(project, ws_view) 46 | 47 | post_processor = PostProcessor() 48 | controller = KlipperController(project, post_processor) 49 | controller_ui = KlipperControllerUI(controller) 50 | 51 | main_window = MainWindow(ws_view, 52 | ws_controller, 53 | project_bar, 54 | controller_ui) 55 | main_window.show() 56 | controller_ui.console.user_input_w.setFocus() 57 | 58 | app.exec_() 59 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | # import os 14 | # import sys 15 | # sys.path.insert(0, os.path.abspath('.')) 16 | 17 | 18 | # -- Project information ----------------------------------------------------- 19 | 20 | project = 'Sheetah' 21 | copyright = '2020, Lucas Felix' 22 | author = 'Lucas Felix' 23 | 24 | # The full version, including alpha/beta/rc tags 25 | release = '0.1' 26 | 27 | # -- General configuration --------------------------------------------------- 28 | # Add any Sphinx extension module names here, as strings. They can be 29 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 30 | # ones. 31 | extensions = [ 32 | 'sphinx-prompt' 33 | ] 34 | 35 | # Add any paths that contain templates here, relative to this directory. 36 | templates_path = ['_templates'] 37 | 38 | # List of patterns, relative to source directory, that match files and 39 | # directories to ignore when looking for source files. 40 | # This pattern also affects html_static_path and html_extra_path. 41 | exclude_patterns = [] 42 | 43 | 44 | # -- Options for HTML output ------------------------------------------------- 45 | 46 | # The theme to use for HTML and HTML Help pages. See the documentation for 47 | # a list of builtin themes. 48 | # 49 | html_theme = 'sphinx_rtd_theme' 50 | 51 | # Add any paths that contain custom static files (such as style sheets) here, 52 | # relative to this directory. They are copied after the builtin static files, 53 | # so a file named "default.css" will overwrite the builtin "default.css". 54 | html_static_path = ['_static'] 55 | -------------------------------------------------------------------------------- /sheetah/transformhandle.py: -------------------------------------------------------------------------------- 1 | from PyQt5 import QtWidgets, QtGui 2 | from PyQt5.QtCore import Qt 3 | 4 | class HandleIcon(QtWidgets.QGraphicsPixmapItem): 5 | def __init__(self, img_path, parent, controller): 6 | super().__init__(QtGui.QPixmap(img_path), parent) 7 | self.setOffset(-self.pixmap().rect().center()) 8 | self.setFlag(QtWidgets.QGraphicsItem.ItemIgnoresTransformations) 9 | self.controller = controller 10 | 11 | class RotateHandle(HandleIcon): 12 | def mousePressEvent(self, ev): 13 | if ev.button() & Qt.LeftButton: 14 | self.controller.start_rot(ev.scenePos()) 15 | ev.accept() 16 | 17 | def mouseReleaseEvent(self, ev): 18 | if ev.button() & Qt.LeftButton: 19 | self.controller.end_rot() 20 | ev.accept() 21 | 22 | def mouseMoveEvent(self, ev): 23 | self.controller.step_rot(ev.scenePos(), 24 | bool(ev.modifiers() & Qt.ControlModifier)) 25 | ev.accept() 26 | 27 | class ScaleHandle(HandleIcon): 28 | def mousePressEvent(self, ev): 29 | if ev.button() & Qt.LeftButton: 30 | self.controller.start_scale(ev.scenePos()) 31 | ev.accept() 32 | 33 | def mouseReleaseEvent(self, ev): 34 | if ev.button() & Qt.LeftButton: 35 | self.controller.end_scale() 36 | ev.accept() 37 | 38 | def mouseMoveEvent(self, ev): 39 | self.controller.step_scale(ev.scenePos(), 40 | bool(ev.modifiers() & Qt.ControlModifier)) 41 | ev.accept() 42 | 43 | class TransformHandle(QtWidgets.QGraphicsRectItem): 44 | def __init__(self, controller): 45 | super().__init__() 46 | self.controller = controller 47 | pen = QtGui.QPen(QtGui.QColor(0,150,255)) 48 | pen.setCosmetic(True) 49 | self.setPen(pen) 50 | self.setZValue(2) 51 | self.rotate = RotateHandle('resources/rotate_icon.png', self, controller) 52 | self.scale = ScaleHandle('resources/scale_icon.png', self, controller) 53 | self.hide() 54 | 55 | def update(self): 56 | # TODO handle error scene == None ? 57 | if self.scene().selectedItems(): 58 | group = self.scene().createItemGroup(self.scene().selectedItems()) 59 | b_rect = group.boundingRect() 60 | self.setRect(b_rect) 61 | self.scene().destroyItemGroup(group) 62 | self.rotate.setPos(b_rect.bottomRight()) 63 | self.scale.setPos(b_rect.topRight()) 64 | self.show() 65 | else: 66 | self.hide() 67 | -------------------------------------------------------------------------------- /sheetah/postprocessor.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | from job import Task, JobTask 4 | import numpy as np 5 | import math 6 | 7 | class PostProcessor: 8 | def __init__(self): 9 | self._init_seq = ['G90', 'G28 Z', 'G28 X Y'] 10 | self._abort_seq = ['M7', 'M5', 'M8'] 11 | 12 | def init_task(self): 13 | return Task(self._init_seq) 14 | 15 | def emergency_task(self): 16 | return Task(self._abort_seq) 17 | 18 | def generate(self, job, task_id, dry_run=False): 19 | cut_pline = job.get_cut_plines()[task_id] 20 | gcode = list() 21 | gcode = ['G90', 22 | 'G1 F6000 X' + '{:.3f}'.format(cut_pline.start[0]) + 23 | ' Y' + '{:.3f}'.format(cut_pline.start[1]), 'PROBE'] 24 | if dry_run: 25 | gcode += ['G91', 'G1 F3000 Z20', 'G90', 'M6 V0 T-1'] 26 | else: 27 | gcode += ['G91', 'G1 Z3.8', 'M3', 'G4 P' + str(job.pierce_delay), 28 | 'G1 Z-2.3', 'G90', 29 | 'M6 V' + '{:.2f}'.format(job.arc_voltage) + ' T' + '{:.0f}'.format(job.feedrate * 0.9)] 30 | gcode += ['G1 F' + str(job.feedrate)] 31 | 32 | data = cut_pline.raw 33 | points = [] 34 | n = data.shape[1] 35 | for i in range(n - int(not cut_pline.is_closed())): 36 | a = data[:2,i] 37 | b = data[:2,(i+1)%n] 38 | bulge = data[2,i] 39 | if points: 40 | points.pop(-1) 41 | 42 | x, y = b 43 | if math.isclose(bulge, 0) or np.linalg.norm(b-a) < 1e0: 44 | gcode += ['G1 X' + '{:.3f}'.format(x) + ' Y' + '{:.3f}'.format(y)] 45 | else: 46 | rot = np.array([[0,-1], 47 | [1, 0]]) 48 | on_right = bulge >= 0 49 | if not on_right: 50 | rot = -rot 51 | bulge = abs(bulge) 52 | ab = b-a 53 | chord = np.linalg.norm(ab) 54 | radius = chord * (bulge + 1. / bulge) / 4 55 | center_offset = radius - chord * bulge / 2 56 | center = a + ab/2 + center_offset / chord * rot.dot(ab) 57 | i_val, j_val = center - a 58 | cmd = 'G3' if on_right else 'G2' 59 | gcode += [cmd + ' X' + '{:.3f}'.format(x) + 60 | ' Y' + '{:.3f}'.format(y) + 61 | ' I' + '{:.3f}'.format(i_val) + 62 | ' J' + '{:.3f}'.format(j_val)] 63 | 64 | if dry_run: 65 | gcode += ['M7', 'M8'] 66 | else: 67 | gcode += ['M7', 'M5', 'M8', 'G91', 'G1 F3000 Z10', 'G90'] 68 | return JobTask(gcode, job, task_id, dry_run) 69 | -------------------------------------------------------------------------------- /sheetah/klippercontroller.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | from PyQt5 import QtCore, QtWidgets 4 | import pyqtgraph as pg 5 | import numpy as np 6 | import serial 7 | from controllerbase import ControllerBase, ControllerUIBase, InputDecisionTree 8 | 9 | import queue 10 | 11 | class QTHCLogger(QtCore.QObject): 12 | thc_update = QtCore.pyqtSignal() 13 | def __init__(self): 14 | super().__init__() 15 | self.thc_data = np.zeros((1000, 3)) 16 | 17 | def log_thc_data(self, z_pos, arc_v, speed): 18 | self.thc_data[:-1] = self.thc_data[1:] 19 | self.thc_data[-1] = np.array([z_pos, arc_v, speed]) 20 | self.thc_update.emit() 21 | 22 | class KlipperController(ControllerBase): 23 | def __init__(self, project, post_processor): 24 | super().__init__(project, post_processor) 25 | self.serial = serial.Serial() 26 | self.thc_logger = QTHCLogger() 27 | self.klipper_busy = False 28 | 29 | self.input_parser = InputDecisionTree() 30 | self.input_parser.append_node('ok', self._process_ok) 31 | self.input_parser.append_node('!!', self._process_error) 32 | self.input_parser.append_node('// echo: THC_error', self._process_thc) 33 | 34 | def _link_open(self, *args, **kwargs): 35 | self.serial = serial.Serial('/tmp/printer', timeout=0.2) 36 | 37 | def _link_close(self): 38 | self.serial.close() 39 | 40 | def _link_read(self): 41 | return self.serial.readline().decode('ascii') 42 | 43 | def _link_send(self, cmd): 44 | self.serial.write((cmd + '\n').encode('ascii')) 45 | self.klipper_busy = True 46 | 47 | def _link_busy(self): 48 | return self.klipper_busy 49 | 50 | def _process_input(self, input): 51 | self.input_parser.process_input(input) 52 | 53 | def _process_ok(self, input): 54 | self._complete_cmd() 55 | self.klipper_busy = False 56 | 57 | def _process_error(self, input): 58 | self._abort_internal(input) 59 | 60 | def _process_thc(self, input): 61 | try: 62 | words = input.split() 63 | z_pos = float(words[3]) 64 | arc_v = float(words[4]) 65 | speed = float(words[5]) 66 | except: 67 | pass 68 | else: 69 | self.thc_logger.log_thc_data(z_pos, arc_v, speed) 70 | 71 | class THCWidget(pg.PlotWidget): 72 | def __init__(self, thc_logger): 73 | super().__init__() 74 | self.thc_logger = thc_logger 75 | self.showGrid(True, True, 0.5) 76 | self.setRange(yRange=(0, 200), disableAutoRange=True) 77 | self.hideButtons() 78 | 79 | self.z_pos_curve = pg.PlotCurveItem([], [], pen=pg.mkPen(color=(87, 200, 34), width=1)) 80 | self.arc_v_curve = pg.PlotCurveItem([], [], pen=pg.mkPen(color=(255, 87, 34), width=1)) 81 | self.speed_curve = pg.PlotCurveItem([], [], pen=pg.mkPen(color=(34, 120, 255), width=1)) 82 | self.on_thc_data() 83 | self.addItem(self.z_pos_curve) 84 | self.addItem(self.arc_v_curve) 85 | self.addItem(self.speed_curve) 86 | self.thc_logger.thc_update.connect(self.on_thc_data) 87 | 88 | def on_thc_data(self): 89 | self.z_pos_curve.setData(self.thc_logger.thc_data[:,0]) 90 | self.arc_v_curve.setData(self.thc_logger.thc_data[:,1]) 91 | self.speed_curve.setData(self.thc_logger.thc_data[:,2]) 92 | 93 | class KlipperControllerUI(ControllerUIBase): 94 | def __init__(self, controller): 95 | super().__init__(controller) 96 | self.thc_graph = THCWidget(self.controller.thc_logger) 97 | layout = QtWidgets.QGridLayout() 98 | layout.addWidget(self.console, 0, 0, 2, 1) 99 | layout.addWidget(self.btn_box, 0, 1) 100 | layout.addWidget(self.thc_graph, 1, 1) 101 | self.setLayout(layout) 102 | -------------------------------------------------------------------------------- /sheetah/fileutils.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import ezdxf 3 | import svgpathtools as svgpt 4 | import pathlib 5 | 6 | from math import radians 7 | 8 | import polyline as pl 9 | from job import Job 10 | 11 | def _load_polylines_dxf(filepath): 12 | dwg = ezdxf.readfile(filepath) 13 | msp = dwg.modelspace() 14 | polylines = [] 15 | for e in msp: 16 | if e.dxftype() == 'LINE': 17 | polyline = pl.line2polyline(e.dxf.start[:2], e.dxf.end[:2]) 18 | elif e.dxftype() == 'ARC': 19 | polyline = pl.arc2polyline(e.dxf.center, e.dxf.radius, 20 | radians(e.dxf.start_angle), 21 | radians(e.dxf.end_angle)) 22 | elif e.dxftype() == 'CIRCLE': 23 | polyline = pl.circle2polyline(e.dxf.center, e.dxf.radius) 24 | elif e.dxftype() == 'LWPOLYLINE': 25 | vertices = np.array(e.get_points()).transpose()[[0,1,4]] 26 | polyline = pl.Polyline(vertices, e.closed) 27 | elif e.dxftype() == 'SPLINE': 28 | polyline = pl.spline2polyline(e.dxf.degree, 29 | [list(i) for i in np.array(e.control_points)[:,:2]], e.closed) 30 | else: 31 | raise Exception('unimplemented \"'+e.dxftype()+'\" dxf entity.') 32 | polylines.append(polyline) 33 | 34 | return polylines 35 | 36 | def _load_polylines_svg(filepath): 37 | px_per_inch = 96 38 | mm_per_inch = 25.4 39 | px_per_mm = px_per_inch / mm_per_inch 40 | 41 | svg_items = svgpt.svg2paths(filepath, 42 | convert_lines_to_paths=True, 43 | convert_polylines_to_paths=True, 44 | convert_polygons_to_paths=True, 45 | return_svg_attributes=False) 46 | polylines = [] 47 | for i in svg_items[0][0]: 48 | if isinstance(i, svgpt.path.Line): 49 | line = np.array([[svgpt.real(i.point(0.0)), svgpt.imag(i.point(0.0))], 50 | [svgpt.real(i.point(1.0)), svgpt.imag(i.point(1.0))]]) 51 | line /= px_per_mm 52 | if np.allclose(line[0], line[1]): 53 | continue 54 | polyline = pl.line2polyline(line[0], line[1]) 55 | elif isinstance(i, svgpt.path.CubicBezier): 56 | control_points = np.array([[svgpt.real(i.start), svgpt.imag(i.start)], 57 | [svgpt.real(i.control1), svgpt.imag(i.control1)], 58 | [svgpt.real(i.control2), svgpt.imag(i.control2)], 59 | [svgpt.real(i.end), svgpt.imag(i.end)]]) 60 | control_points /= px_per_mm 61 | polyline = pl.spline2polyline(3, control_points.tolist(), False) 62 | else: 63 | raise Exception('unknown type', i) 64 | polylines.append(polyline) 65 | return polylines 66 | 67 | def load(filepath): 68 | # Load file as polylines only (line, arc, spline, etc are converted). 69 | path = pathlib.Path(filepath) 70 | extension = path.suffix.lower() 71 | name = path.stem 72 | if extension == '.dxf': 73 | raw_polylines = _load_polylines_dxf(filepath) 74 | elif extension == '.svg': 75 | raw_polylines = _load_polylines_svg(filepath) 76 | else: 77 | raise Exception('unknown extension \"' + extension + '\".') 78 | 79 | # Aggregate consecutive polylines. 80 | polylines = pl.aggregate(raw_polylines) 81 | 82 | # Check for no complex polylines (self crossing). 83 | for polyline in polylines: 84 | if not polyline.is_simple(): 85 | # TODO find a way to show user where is(are) the intersection(s) 86 | raise Exception('Self crossing geometry found') 87 | 88 | # TODO detect geometry that cross others 89 | 90 | 91 | # TODO remove buggy closed single lines 92 | polylines = [p for p in polylines if p._vertices.shape[0] > 1] 93 | 94 | # Group polylines as exterior and interior contours. 95 | # Exterior is always the last in a group. 96 | contour_groups = pl.group_as_contours(polylines) 97 | 98 | bounds = [g[-1].bounds.flatten() for g in contour_groups] 99 | bounds = np.transpose(np.vstack(bounds)) 100 | global_pos = np.min(bounds, axis=1)[:2] 101 | 102 | # Create jobs and return. 103 | jobs = [] 104 | for i, contours in enumerate(contour_groups): 105 | if len(contour_groups) > 1: 106 | job_name = format('%s %i'%(name, i)) 107 | else: 108 | job_name = name 109 | local_pos = contours[-1].bounds[0] 110 | shifted = [c.affine(-local_pos, 0, 1) for c in contours] 111 | job = Job(job_name, shifted) 112 | job.position = local_pos - global_pos 113 | jobs.append(job) 114 | return jobs 115 | -------------------------------------------------------------------------------- /sheetah/workspacecontroller.py: -------------------------------------------------------------------------------- 1 | from PyQt5.QtCore import Qt 2 | import numpy as np 3 | import math 4 | 5 | from transformhandle import TransformHandle 6 | from jobgraphics import JobVisual, JobVisualProxy 7 | from pyqtgraph import Point 8 | 9 | class WorkspaceController: 10 | def __init__(self, project, view): 11 | self.project = project 12 | self.view = view 13 | self.view.controller = self # TODO not clean 14 | self.scene = self.view.scene() 15 | self.job_visuals = [] 16 | 17 | self._grab = False 18 | self.items = [] 19 | self.proxy_items = [] 20 | 21 | self.handle = TransformHandle(self) 22 | self.scene.addItem(self.handle) 23 | 24 | self.project.job_update.connect(self.on_job_update) 25 | self.scene.selectionChanged.connect(self.on_selection) 26 | 27 | def delete_selection(self): 28 | self.project.remove_jobs([item.job 29 | for item in self.scene.selectedItems()]) 30 | 31 | def grabbing(self): 32 | return self._grab 33 | 34 | def _create_proxy_items(self): 35 | self.items = self.scene.selectedItems() 36 | self.proxy_items = [JobVisualProxy(item) for item in self.items] 37 | 38 | def _delete_proxy_items(self): 39 | for item in self.proxy_items: 40 | self.scene.removeItem(item) 41 | self.proxy_items = [] 42 | 43 | def _selection_centroid(self): 44 | items = self.scene.selectedItems() 45 | centroids = [item.job.get_centroid() for item in items] 46 | return Point(np.mean(centroids, axis=0)) 47 | 48 | def start_grab(self, pos): 49 | self._create_proxy_items() 50 | self.ini_pos = Point(pos) 51 | self._grab = True 52 | 53 | def step_grab(self, pos, step_mode=False): 54 | self.pos = Point(pos) - self.ini_pos 55 | if step_mode: 56 | incr = 10 57 | self.pos = Point(np.round(self.pos / incr) * incr) 58 | for item in self.proxy_items: 59 | item.setPos(self.pos) 60 | 61 | def end_grab(self): 62 | self._delete_proxy_items() 63 | for item in self.items: 64 | item.job.position += self.pos 65 | self.items = [] 66 | self.handle.update() 67 | self._grab = False 68 | 69 | def start_rot(self, pos): 70 | self._create_proxy_items() 71 | self.origin = self._selection_centroid() 72 | for item in self.proxy_items: 73 | item.setTransformOriginPoint(self.origin) 74 | direction = Point(pos) - self.origin 75 | self.ini_angle = math.atan2(direction[1], direction[0]) 76 | 77 | def step_rot(self, pos, step_mode=False): 78 | direction = Point(pos) - self.origin 79 | self.angle = math.atan2(direction[1], direction[0]) - self.ini_angle 80 | if step_mode: 81 | incr = math.pi / 12 82 | self.angle = round(self.angle / incr) * incr 83 | for item in self.proxy_items: 84 | item.setRotation(math.degrees(self.angle)) 85 | 86 | def end_rot(self): 87 | self._delete_proxy_items() 88 | for item in self.items: 89 | item.job.turn_around(self.origin, self.angle) 90 | self.items = [] 91 | self.handle.update() 92 | 93 | def start_scale(self, pos): 94 | self._create_proxy_items() 95 | self.origin = self._selection_centroid() 96 | for item in self.proxy_items: 97 | item.setTransformOriginPoint(self.origin) 98 | self.ini_dist = np.linalg.norm(Point(pos) - self.origin) 99 | if math.isclose(self.ini_dist, 0): 100 | self.ini_dist = 1 101 | 102 | def step_scale(self, pos, step_mode=False): 103 | self.scale = np.linalg.norm(Point(pos) - self.origin) / self.ini_dist 104 | if step_mode: 105 | incr = 0.1 106 | self.scale = round(self.scale / incr) * incr 107 | for item in self.proxy_items: 108 | item.setScale(self.scale) 109 | 110 | def end_scale(self): 111 | self._delete_proxy_items() 112 | for item in self.items: 113 | item.job.scale_around(self.origin, self.scale) 114 | self.items = [] 115 | self.handle.update() 116 | 117 | def on_job_update(self): 118 | jobs = self.project.jobs.copy() 119 | job_visuals = self.job_visuals.copy() 120 | for jvi, jv in reversed(list(enumerate(job_visuals))): 121 | try: 122 | ji = jobs.index(jv.job) 123 | jobs.pop(ji) 124 | job_visuals.pop(jvi) 125 | except ValueError: 126 | pass 127 | for jv in job_visuals: 128 | self.job_visuals.remove(jv) 129 | self.scene.removeItem(jv) 130 | for j in jobs: 131 | jv = JobVisual(self, j) 132 | self.job_visuals.append(jv) 133 | self.scene.addItem(jv) 134 | 135 | def on_selection(self): 136 | self.handle.update() 137 | 138 | def keyPressEvent(self, ev): 139 | if ev.modifiers() == Qt.NoModifier: 140 | if ev.key() == Qt.Key_Delete: 141 | # Delete 142 | self.delete_selection() 143 | # elif ev.key() == Qt.Key_Escape: 144 | # # ESC 145 | # print('Esc') 146 | elif ev.modifiers() == Qt.ControlModifier: 147 | if ev.key() == Qt.Key_A: 148 | # Ctrl + A 149 | for item in self.scene.items(): 150 | item.setSelected(True) 151 | if ev.key() == Qt.Key_Z: 152 | # Ctrl + Z 153 | print('Undo') 154 | elif ev.key() == Qt.Key_S: 155 | # Ctrl + S 156 | print('Save') 157 | elif ev.modifiers() == (Qt.ControlModifier | Qt.ShiftModifier): 158 | if ev.key() == Qt.Key_Z: 159 | # Ctrl + Shift + Z 160 | print('Redo') 161 | -------------------------------------------------------------------------------- /sheetah/workspacegraphics.py: -------------------------------------------------------------------------------- 1 | from PyQt5 import QtWidgets, QtGui, QtCore 2 | from PyQt5.QtCore import Qt 3 | from videothread import VideoThread 4 | 5 | class ProjectBar(QtGui.QWidget): 6 | def __init__(self, project, parent=None): 7 | super().__init__(parent) 8 | self.project = project 9 | 10 | self.load_btn = QtGui.QPushButton('load') 11 | layout = QtGui.QVBoxLayout() 12 | layout.addWidget(self.load_btn) 13 | self.setLayout(layout) 14 | 15 | self.load_btn.clicked.connect(self.on_load) 16 | 17 | def on_load(self): 18 | filepath, _ = QtGui.QFileDialog.getOpenFileName(self, 19 | 'Open File', QtCore.QDir.currentPath(), 20 | 'DXF (*.dxf);; All Files (*)') 21 | if filepath: 22 | self.project.load_job(filepath) 23 | 24 | class WorkspaceView(QtWidgets.QGraphicsView): 25 | def __init__(self): 26 | super().__init__(QtWidgets.QGraphicsScene()) 27 | self.controller = None 28 | 29 | self.video_thread = VideoThread() 30 | self.video_thread.frame_available.connect(self.on_frame) 31 | self.bg_image = QtWidgets.QGraphicsPixmapItem() 32 | self.scene().addItem(self.bg_image) 33 | self.bg_image.setTransform(QtGui.QTransform().scale(1,-1)) 34 | self.video_thread.start() 35 | 36 | self.machine = QtWidgets.QGraphicsRectItem(0,0, 900, 1320) 37 | machinePen = QtGui.QPen(QtGui.QColor(239, 67, 15)) 38 | machinePen.setCosmetic(True) 39 | self.machine.setPen(machinePen) 40 | self.machine.setBrush(QtGui.QBrush(QtGui.QColor(50, 60, 70))) 41 | self.scene().addItem(self.machine) 42 | self.fitInView(self.machine.rect(), Qt.KeepAspectRatio) 43 | 44 | self.setBackgroundBrush(QtGui.QBrush(QtGui.QColor(44, 48, 55))) 45 | 46 | # View params 47 | self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) 48 | self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) 49 | self.setFrameShape(QtWidgets.QFrame.NoFrame) 50 | self.setFocusPolicy(Qt.StrongFocus) 51 | self.setViewportUpdateMode(QtWidgets.QGraphicsView.MinimalViewportUpdate) 52 | self.setSizePolicy(QtGui.QSizePolicy.Expanding, QtGui.QSizePolicy.Expanding) 53 | 54 | # OpenGL 55 | opengl = True 56 | antialias = True 57 | if opengl: 58 | gl_viewport = QtWidgets.QOpenGLWidget() 59 | if antialias: 60 | format = QtGui.QSurfaceFormat() 61 | format.setSamples(32) # nothing happens under 16 samples, but why ??? 62 | gl_viewport.setFormat(format) 63 | self.setViewport(gl_viewport) 64 | else: 65 | if antialias: 66 | self.setRenderHints(QtGui.QPainter.Antialiasing) 67 | 68 | # flip view vertically 69 | self.scale(1, -1) 70 | 71 | # Select box 72 | self.selectBox = QtWidgets.QGraphicsRectItem() 73 | self.selectBox.setPen(QtGui.QPen(QtGui.QBrush(QtGui.QColor(49, 154, 255)), 0)) 74 | self.selectBox.setBrush(QtGui.QBrush(QtGui.QColor(173, 207, 239, 50))) 75 | self.selectBox.setZValue(1) 76 | self.selectBox.hide() 77 | self.scene().addItem(self.selectBox) 78 | 79 | self.dragging = False 80 | self.selecting = False 81 | 82 | # Avoid Qt bug occuring when the view is clicked after a focus loss, the 83 | # event position is not updated. We generate a fake moveEvent after a 84 | # focus loss to prevent that. 85 | self.lastScenePosOutdated = False 86 | 87 | def on_frame(self): 88 | cvImg = self.video_thread.frame 89 | height, width, channel = cvImg.shape 90 | bytesPerLine = 3 * width 91 | qImg = QtGui.QImage(cvImg.data, width, height, bytesPerLine, QtGui.QImage.Format_RGB888) 92 | self.bg_image.setPixmap(QtGui.QPixmap(qImg)) 93 | 94 | def focusOutEvent(self, ev): 95 | QtWidgets.QGraphicsView.focusOutEvent(self, ev) 96 | self.lastScenePosOutdated = True 97 | 98 | def posSyncCheck(self, ev): 99 | if self.lastScenePosOutdated: 100 | moveEvent = QtGui.QMouseEvent( 101 | QtCore.QEvent.MouseMove, ev.pos(), 102 | QtCore.Qt.NoButton, 103 | QtCore.Qt.MouseButtons(QtCore.Qt.NoButton), 104 | QtCore.Qt.NoModifier) 105 | self.mouseMoveEvent(moveEvent) 106 | self.lastScenePosOutdated = False 107 | 108 | def wheelEvent(self, ev): 109 | QtWidgets.QGraphicsView.wheelEvent(self, ev) 110 | degrees = ev.angleDelta().y() / 8 111 | sc = 1.01 ** degrees 112 | mouse_to_center = self.sceneRect().center() - self.mapToScene(ev.pos()) 113 | new_center = self.mapToScene(ev.pos()) + mouse_to_center/sc 114 | self.scale(sc,sc) 115 | r = QtCore.QRectF(self.rect()) 116 | viewport = self.viewportTransform().inverted()[0].mapRect(r) 117 | viewport.translate(new_center - viewport.center()) 118 | self.setSceneRect(viewport) 119 | self.selecting = False 120 | 121 | def mouseDoubleClickEvent(self, ev): 122 | self.posSyncCheck(ev) 123 | QtWidgets.QGraphicsView.mouseDoubleClickEvent(self, ev) 124 | 125 | def mousePressEvent(self, ev): 126 | self.posSyncCheck(ev) 127 | if ev.button() & Qt.MidButton: 128 | self.prev_pos = ev.pos() 129 | self.dragging = True 130 | else: 131 | QtWidgets.QGraphicsView.mousePressEvent(self, ev) 132 | if not ev.isAccepted(): 133 | if ev.button() & Qt.LeftButton: 134 | self.scene().clearSelection() 135 | self.downPos = self.mapToScene(ev.pos()) 136 | ini_rect = QtCore.QRectF(self.downPos, self.downPos) 137 | self.selectBox.setRect(ini_rect) 138 | self.selectBox.show() 139 | self.selecting = True 140 | 141 | def mouseReleaseEvent(self, ev): 142 | if self.dragging: 143 | self.dragging = False 144 | elif self.selecting: 145 | self.selecting = False 146 | self.selectBox.hide() 147 | self.scene().setSelectionArea(self.selectBox.shape()) 148 | else: 149 | QtWidgets.QGraphicsView.mouseReleaseEvent(self, ev) 150 | 151 | def mouseMoveEvent(self, ev): 152 | if self.dragging: 153 | pos_diff = self.prev_pos - ev.pos() 154 | if not pos_diff.isNull(): 155 | sf = self.transform().inverted()[0] # scale factor 156 | diff = sf.map(QtCore.QPointF(pos_diff)) 157 | new_rect = self.sceneRect().translated(diff) 158 | self.setSceneRect(new_rect) 159 | self.prev_pos = ev.pos() 160 | elif self.selecting: 161 | scene_pos = self.mapToScene(ev.pos()) 162 | rect = QtCore.QRectF(self.downPos, scene_pos).normalized() 163 | self.selectBox.setRect(rect) 164 | else: 165 | QtWidgets.QGraphicsView.mouseMoveEvent(self, ev) 166 | 167 | def resizeEvent(self, ev): 168 | # create new rect of center self.sceneRect().center() and of size 169 | # self.rect() in scene units 170 | r = QtCore.QRectF(self.rect()) 171 | viewport = self.viewportTransform().inverted()[0].mapRect(r) 172 | viewport.translate(self.sceneRect().center() - viewport.center()) 173 | self.setSceneRect(viewport) 174 | -------------------------------------------------------------------------------- /sheetah/jobgraphics.py: -------------------------------------------------------------------------------- 1 | from PyQt5 import QtCore, QtGui, QtWidgets 2 | from PyQt5.QtCore import Qt 3 | 4 | from pyqtgraph import arrayToQPath 5 | import numpy as np 6 | 7 | class JobVisual(QtWidgets.QGraphicsPathItem): 8 | def __init__(self, controller, job): 9 | super().__init__() 10 | self.controller = controller 11 | self.job = job 12 | self.setFlag(QtWidgets.QGraphicsItem.ItemIsSelectable) 13 | 14 | self.cut_colors = [QtGui.QColor(255, 255, 255), 15 | QtGui.QColor(250, 170, 0), 16 | QtGui.QColor( 5, 220, 10), 17 | QtGui.QColor(255, 0, 0), 18 | QtGui.QColor(100, 100, 100)] 19 | self.cut_paths = [QtGui.QPainterPath()] * len(self.cut_colors) 20 | self.pen_base = QtGui.QPen(QtGui.QBrush(), 0, 21 | Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin) 22 | 23 | self.select_brush = QtGui.QBrush(QtGui.QColor(4, 200, 255, 200)) 24 | self.unselect_brush = QtGui.QBrush(QtGui.QColor(4, 150, 255, 150)) 25 | 26 | self.job.shape_update.connect(self.on_job_shape_update) 27 | self.on_job_shape_update() 28 | 29 | self.menu = QtGui.QMenu() 30 | self.action = QtGui.QAction('Job settings') 31 | self.action.triggered.connect(self.on_job_settings) 32 | self.menu.addAction(self.action) 33 | self.params_dialog = JobParamDialog(self.job) 34 | 35 | def _select_toggle(self): 36 | self.setSelected(not self.isSelected()) 37 | 38 | def _select_exclusive(self): 39 | self.scene().clearSelection() 40 | self.setSelected(True) 41 | 42 | def contextMenuEvent(self, ev): 43 | self.menu.popup(ev.screenPos()) 44 | ev.accept() 45 | 46 | def mousePressEvent(self, ev): 47 | if ev.button() & Qt.LeftButton: 48 | self.down_pos = ev.scenePos() 49 | if ev.modifiers() & Qt.ShiftModifier: 50 | self._select_toggle() 51 | elif not self.isSelected(): 52 | self._select_exclusive() 53 | ev.accept() 54 | elif ev.button() & Qt.RightButton and not self.isSelected(): 55 | self._select_exclusive() 56 | 57 | def mouseReleaseEvent(self, ev): 58 | if ev.button() & Qt.LeftButton: 59 | if self.controller.grabbing(): 60 | self.controller.end_grab() 61 | elif not ev.modifiers() & Qt.ShiftModifier: 62 | self._select_exclusive() 63 | ev.accept() 64 | 65 | def mouseMoveEvent(self, ev): 66 | if self.isSelected() and not self.controller.grabbing(): 67 | self.controller.start_grab(self.down_pos) 68 | if self.controller.grabbing(): 69 | self.controller.step_grab(ev.scenePos(), 70 | bool(ev.modifiers() & Qt.ControlModifier)) 71 | ev.accept() 72 | 73 | def on_job_shape_update(self): 74 | data = np.empty((2,0), dtype=np.float) 75 | connect = np.empty(0, dtype=np.bool) 76 | for path in self.job.get_shape_paths(): 77 | connected = np.ones(path.shape[1], dtype=np.bool) 78 | connected[-1] = False 79 | connect = np.concatenate((connect, connected)) 80 | data = np.concatenate((data, path), axis=1) 81 | self.fill_path = arrayToQPath(data[0], data[1], connect) 82 | 83 | paths, states = self.job.get_cut_paths() 84 | self.pen_base.setWidthF(self.job.kerf_width) 85 | # set pen for boundingRect to take it into account 86 | self.setPen(self.pen_base) 87 | data = [np.empty((2,0), dtype=np.float) for i in range(5)] 88 | connect = [np.empty(0, dtype=np.bool) for i in range(5)] 89 | for i, state in enumerate(states): 90 | connected = np.ones(paths[i].shape[1], dtype=np.bool) 91 | connected[-1] = False 92 | connect[state] = np.concatenate((connect[state], connected)) 93 | data[state] = np.concatenate((data[state], paths[i]), axis=1) 94 | self.cut_paths = [arrayToQPath(data[i][0], data[i][1], connect[i]) 95 | for i in range(len(self.cut_colors))] 96 | # last path is used to keep shape and boundingRect up to date 97 | self.setPath(self.cut_paths[states[-1]]) # TODO should take ALL paths 98 | 99 | # TODO breaking encapsulation to refresh handle on kerf with update 100 | self.controller.handle.update() 101 | 102 | def on_job_settings(self): 103 | self.params_dialog.move(self.menu.pos()) 104 | self.params_dialog.reset_params() 105 | self.params_dialog.exec_() 106 | 107 | def paint(self, painter, option, widget): 108 | if self.job.is_closed(): 109 | if self.isSelected(): 110 | painter.fillPath(self.fill_path, self.select_brush) 111 | else: 112 | painter.fillPath(self.fill_path, self.unselect_brush) 113 | 114 | for i, color in enumerate(self.cut_colors): 115 | self.pen_base.setColor(color) 116 | painter.setPen(self.pen_base) 117 | painter.drawPath(self.cut_paths[i]) 118 | 119 | class JobVisualProxy(QtWidgets.QGraphicsPathItem): 120 | def paint(self, painter, option, widget): 121 | brush = QtGui.QBrush(QtGui.QColor(50, 200, 255, 100)) 122 | painter.fillPath(self.parentItem().fill_path, brush) 123 | 124 | pen = self.parentItem().pen_base 125 | pen.setColor(QtGui.QColor(0, 0, 0)) 126 | painter.setPen(pen) 127 | for path in self.parentItem().cut_paths: 128 | painter.drawPath(path) 129 | 130 | def boundingRect(self): 131 | return self.parentItem().boundingRect() 132 | 133 | class JobParamDialog(QtWidgets.QDialog): 134 | def __init__(self, job, parent=None): 135 | super().__init__(parent) 136 | self.job = job 137 | self.setWindowTitle('Job settings') 138 | 139 | std_btns = (QtWidgets.QDialogButtonBox.Ok | 140 | QtWidgets.QDialogButtonBox.Cancel) 141 | self.buttonBox = QtWidgets.QDialogButtonBox(std_btns) 142 | self.buttonBox.accepted.connect(self.accept) 143 | self.buttonBox.rejected.connect(self.reject) 144 | 145 | name_label = QtGui.QLabel(self.job.name, 146 | font=QtGui.QFont('SansSerif', 12), 147 | alignment=QtCore.Qt.AlignCenter) 148 | 149 | form = QtGui.QFormLayout() 150 | 151 | self.cut_direction_checkbox = QtGui.QCheckBox('(exterior clockwise)') 152 | self.cut_direction_checkbox.setTristate(False) 153 | form.addRow("Cut direction",self.cut_direction_checkbox) 154 | 155 | self.feedrate_spbox = QtGui.QSpinBox() 156 | self.feedrate_spbox.setRange(100, 20000) 157 | self.feedrate_spbox.setValue(self.job.feedrate) 158 | self.feedrate_spbox.setSingleStep(50) 159 | self.feedrate_spbox.setSuffix("mm/min") 160 | form.addRow("Feedrate",self.feedrate_spbox) 161 | 162 | self.arc_voltage_spbox = QtGui.QDoubleSpinBox() 163 | self.arc_voltage_spbox.setRange(0, 500) 164 | self.arc_voltage_spbox.setValue(self.job.arc_voltage) 165 | self.arc_voltage_spbox.setSingleStep(0.1) 166 | self.arc_voltage_spbox.setSuffix("V") 167 | form.addRow("Arc voltage",self.arc_voltage_spbox) 168 | 169 | self.pierce_delay_spbox = QtGui.QSpinBox() 170 | self.pierce_delay_spbox.setRange(0, 10000) 171 | self.pierce_delay_spbox.setValue(self.job.pierce_delay) 172 | self.pierce_delay_spbox.setSingleStep(50) 173 | self.pierce_delay_spbox.setSuffix("ms") 174 | form.addRow("Pierce delay",self.pierce_delay_spbox) 175 | 176 | self.kerf_width_spbox = QtGui.QDoubleSpinBox() 177 | self.kerf_width_spbox.setRange(0, 50) 178 | self.kerf_width_spbox.setValue(self.job.kerf_width) 179 | self.kerf_width_spbox.setSingleStep(0.1) 180 | self.kerf_width_spbox.setSuffix("mm") 181 | form.addRow("Kerf width",self.kerf_width_spbox) 182 | 183 | self.loop_radius_spbox = QtGui.QDoubleSpinBox() 184 | self.loop_radius_spbox.setRange(0, 50) 185 | self.loop_radius_spbox.setValue(self.job.loop_radius) 186 | self.loop_radius_spbox.setSingleStep(0.1) 187 | self.loop_radius_spbox.setSuffix("mm") 188 | form.addRow("Loop radius",self.loop_radius_spbox) 189 | 190 | layout = QtGui.QVBoxLayout() 191 | layout.addWidget(name_label) 192 | layout.addLayout(form) 193 | layout.addWidget(self.buttonBox) 194 | self.setLayout(layout) 195 | 196 | def reset_params(self): 197 | self.cut_direction_checkbox.setChecked(self.job.exterior_clockwise) 198 | self.feedrate_spbox.setValue(self.job.feedrate) 199 | self.arc_voltage_spbox.setValue(self.job.arc_voltage) 200 | self.pierce_delay_spbox.setValue(self.job.pierce_delay) 201 | self.kerf_width_spbox.setValue(self.job.kerf_width) 202 | self.loop_radius_spbox.setValue(self.job.loop_radius) 203 | 204 | def accept(self): 205 | # TODO ugly way to avoid multiple signals (break encapsulation) 206 | self.job.blockSignals(True) 207 | 208 | self.job.exterior_clockwise = self.cut_direction_checkbox.isChecked() 209 | self.job.feedrate = self.feedrate_spbox.value() 210 | self.job.arc_voltage = self.arc_voltage_spbox.value() 211 | self.job.pierce_delay = self.pierce_delay_spbox.value() 212 | self.job.kerf_width = self.kerf_width_spbox.value() 213 | self.job.loop_radius = self.loop_radius_spbox.value() 214 | 215 | self.job.blockSignals(False) 216 | self.job.shape_update.emit() 217 | self.job.param_update.emit() 218 | 219 | super().accept() 220 | -------------------------------------------------------------------------------- /sheetah/job.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | from PyQt5 import QtCore 4 | import numpy as np 5 | import math 6 | 7 | class Task(): 8 | def __init__(self, cmd_list): 9 | if not cmd_list: 10 | raise Exception('Cannot create empty task.') 11 | self.cmd_list = cmd_list 12 | self.cmd_index = 0 13 | self.failed = False 14 | 15 | def __str__(self): 16 | return str(self.cmd_list) 17 | 18 | def pop(self): 19 | cmd = self.cmd_list[self.cmd_index] 20 | self.cmd_index += 1 21 | return cmd 22 | 23 | def fail(self): 24 | self.failed = True 25 | self.close() 26 | 27 | def close(self): 28 | self.cmd_list = [] 29 | 30 | class JobTask(Task): 31 | def __init__(self, cmd_list, job, task_id, dry_run): 32 | super().__init__(cmd_list) 33 | self.job = job 34 | self.task_id = task_id 35 | self.dry_run = dry_run 36 | 37 | def pop(self): 38 | if not self.dry_run and self.cmd_index == 0: 39 | self.job.set_cut_state(self.task_id, Job.RUNNING) 40 | return super().pop() 41 | 42 | def close(self): 43 | super().close() 44 | if not self.dry_run: 45 | if self.failed: 46 | self.job.set_cut_state(self.task_id, Job.FAILED) 47 | else: 48 | self.job.set_cut_state(self.task_id, Job.DONE) 49 | 50 | class PipelineNode: 51 | def __init__(self, fun, parent): 52 | self.fun = fun 53 | self.parent = parent 54 | self.up_to_date = False 55 | self.children = [] 56 | 57 | if self.parent is not None: 58 | self.parent.register(self) 59 | 60 | def register(self, child): 61 | self.children.append(child) 62 | 63 | @property 64 | def data(self): 65 | self._update() 66 | return self._data 67 | 68 | def notify_change(self): 69 | self.up_to_date = False 70 | for child in self.children: 71 | child.notify_change() 72 | 73 | def _update(self): 74 | if self.parent is None: 75 | return False 76 | 77 | if not self.up_to_date: 78 | self.parent._update() 79 | self._data = self.fun(self.parent._data) 80 | self.up_to_date = True 81 | 82 | class Job(QtCore.QObject): 83 | shape_update = QtCore.pyqtSignal() 84 | param_update = QtCore.pyqtSignal() 85 | state_update = QtCore.pyqtSignal() 86 | 87 | TODO = 0 88 | RUNNING = 1 89 | DONE = 2 90 | FAILED = 3 91 | IGNORED = 4 92 | _states = (TODO, RUNNING, DONE, FAILED, IGNORED) 93 | 94 | def __init__(self, name, polylines): 95 | if not polylines: 96 | raise Exception('Empty job') 97 | 98 | super().__init__() 99 | self._name = name 100 | 101 | #TODO use default params handler to fill these attr 102 | self._arc_voltage = 150.0 103 | self._exterior_clockwise = True 104 | self._feedrate = 5000 105 | self._kerf_width = 1.5 106 | self._pierce_delay = 500 107 | self._position = np.array([0.,0.]) 108 | self._angle = 0. 109 | self._scale = 1. 110 | self._loop_radius = 1.5 111 | 112 | self.root_node = PipelineNode(None, None) 113 | self.root_node._data = polylines 114 | 115 | # vector 116 | self.dir_node = PipelineNode(self._apply_direction, self.root_node) 117 | self.scale_node = PipelineNode(self._apply_scale, self.dir_node) 118 | self.offset_node = PipelineNode(self._apply_offset, self.scale_node) 119 | self.lead_node = PipelineNode(self._apply_lead, self.offset_node) 120 | self.loop_node = PipelineNode(self._apply_loop, self.lead_node) 121 | # discrete (display only) 122 | self.cut_gen_node = PipelineNode(self._generate, self.loop_node) 123 | self.part_gen_node = PipelineNode(self._generate_shape, self.scale_node) 124 | self.cut_aff_node = PipelineNode(self._apply_affine, self.cut_gen_node) 125 | self.part_aff_node = PipelineNode(self._apply_affine, self.part_gen_node) 126 | 127 | self.cut_pline_affine_node = PipelineNode(self._apply_pline_affine, self.loop_node) 128 | 129 | self.cut_count = 0 130 | self.lead_pos = [0.] * self.cut_count 131 | self.cut_state = [self.TODO] * self.cut_count 132 | 133 | @property 134 | def name(self): 135 | return self._name 136 | @name.setter 137 | def name(self, n): 138 | self._name = n 139 | 140 | @property 141 | def position(self): 142 | return self._position 143 | @position.setter 144 | def position(self, p): 145 | self._position = np.array(p) 146 | self.cut_aff_node.notify_change() 147 | self.part_aff_node.notify_change() 148 | self.cut_pline_affine_node.notify_change() 149 | self.shape_update.emit() 150 | 151 | @property 152 | def angle(self): 153 | return self._angle 154 | @angle.setter 155 | def angle(self, a): 156 | """Set job angle in radians.""" 157 | self._angle = a 158 | self.cut_aff_node.notify_change() 159 | self.part_aff_node.notify_change() 160 | self.cut_pline_affine_node.notify_change() 161 | self.shape_update.emit() 162 | 163 | def turn_around(self, center, angle): 164 | """Angle in radians.""" 165 | self._angle += angle 166 | cos = math.cos(angle) 167 | sin = math.sin(angle) 168 | v = self.position - center 169 | self._position = center + [v[0]*cos - v[1]*sin, v[0]*sin + v[1]*cos] 170 | self.cut_aff_node.notify_change() 171 | self.part_aff_node.notify_change() 172 | self.cut_pline_affine_node.notify_change() 173 | self.shape_update.emit() 174 | 175 | def pos_rot_matrix(self): 176 | cos = math.cos(self._angle) 177 | sin = math.sin(self._angle) 178 | return np.array([[cos,-sin, self._position[0]], 179 | [sin, cos, self._position[1]], 180 | [ 0, 0, 1]]) 181 | 182 | @property 183 | def scale(self): 184 | return self._scale 185 | @scale.setter 186 | def scale(self, s): 187 | self._scale = s 188 | self.scale_node.notify_change() 189 | self.shape_update.emit() 190 | 191 | def scale_around(self, center, scale): 192 | self._scale *= scale 193 | v = self.position - center 194 | self._position = v * scale + center 195 | self.scale_node.notify_change() 196 | self.cut_aff_node.notify_change() 197 | self.part_aff_node.notify_change() 198 | self.cut_pline_affine_node.notify_change() 199 | self.shape_update.emit() 200 | 201 | @property 202 | def exterior_clockwise(self): 203 | return self._exterior_clockwise 204 | @exterior_clockwise.setter 205 | def exterior_clockwise(self, e): 206 | self._exterior_clockwise = e 207 | self.need_contour_transform = True 208 | self.dir_node.notify_change() 209 | self.shape_update.emit() 210 | self.param_update.emit() 211 | 212 | @property 213 | def kerf_width(self): 214 | return self._kerf_width 215 | @kerf_width.setter 216 | def kerf_width(self, k): 217 | self._kerf_width = k 218 | self.offset_node.notify_change() 219 | self.shape_update.emit() 220 | self.param_update.emit() 221 | 222 | @property 223 | def arc_voltage(self): 224 | return self._arc_voltage 225 | @arc_voltage.setter 226 | def arc_voltage(self, v): 227 | self._arc_voltage = v 228 | self.param_update.emit() 229 | 230 | @property 231 | def feedrate(self): 232 | return self._feedrate 233 | @feedrate.setter 234 | def feedrate(self, f): 235 | self._feedrate = f 236 | self.param_update.emit() 237 | 238 | @property 239 | def pierce_delay(self): 240 | return self._pierce_delay 241 | @pierce_delay.setter 242 | def pierce_delay(self, d): 243 | self._pierce_delay = d 244 | self.param_update.emit() 245 | 246 | @property 247 | def loop_radius(self): 248 | return self._loop_radius 249 | @loop_radius.setter 250 | def loop_radius(self, l): 251 | self._loop_radius = l 252 | self.loop_node.notify_change() 253 | self.shape_update.emit() 254 | 255 | def set_lead_pos(self, index, pos): 256 | self.lead_pos[index] = pos 257 | self.shape_update.emit() 258 | 259 | def set_cut_state(self, index, state): 260 | """Set state of a particular cut.""" 261 | if state not in self._states: 262 | raise Exception('Unknown state ' + str(state) + '.') 263 | self.cut_state[index] = state 264 | self.state_update.emit() 265 | self.shape_update.emit() 266 | 267 | def cut_state_index(self, state): 268 | """Return index of the first cut matching state, -1 otherwise.""" 269 | if state not in self._states: 270 | raise Exception('Unknown state ' + str(state) + '.') 271 | try: 272 | index = self.cut_state.index(state) 273 | except: 274 | index = -1 275 | return index 276 | 277 | def cut_state_indices(self, state): 278 | """Return indices of cuts matching state.""" 279 | if state not in self._states: 280 | raise Exception('Unknown state ' + str(state) + '.') 281 | return [i for i, s in enumerate(self.cut_state) if s==state] 282 | 283 | def get_bounds(self): 284 | return self.scale_node.data[-1].bounds 285 | 286 | def get_size(self): 287 | bounds = self.get_bounds() 288 | return bounds[1] - bounds[0] 289 | 290 | def get_centroid(self): 291 | rel_centroid = self.scale_node.data[-1].centroid 292 | return np.dot(self.pos_rot_matrix(), np.append(rel_centroid, 1.))[:-1] 293 | 294 | def get_cut_count(self): 295 | return self.cut_count 296 | 297 | def get_shape_paths(self): 298 | return self.part_aff_node.data 299 | 300 | def get_cut_paths(self): 301 | return (self.cut_aff_node.data, self.cut_state) 302 | 303 | def get_cut_plines(self): 304 | return self.cut_pline_affine_node.data 305 | 306 | def _apply_direction(self, polylines): 307 | directed_polylines = [] 308 | for p in polylines[:-1]: 309 | if p.is_ccw() != self._exterior_clockwise: 310 | directed_polylines.append(p.reverse()) 311 | else: 312 | directed_polylines.append(p) 313 | exterior = polylines[-1] 314 | if exterior.is_ccw() == self._exterior_clockwise: 315 | directed_polylines.append(exterior.reverse()) 316 | else: 317 | directed_polylines.append(exterior) 318 | # print(directed_polylines[0].to_lines()) 319 | return directed_polylines 320 | 321 | def _apply_scale(self, polylines): 322 | return [p.affine([0,0], 0, self._scale) for p in polylines] 323 | 324 | def _apply_offset(self, polylines): 325 | offset_polylines = [] 326 | for p in polylines: 327 | if p.is_closed(): 328 | offset_polylines += p.offset(self.kerf_width / 2) 329 | else: 330 | offset_polylines.append(p) 331 | updated_cut_count = len(offset_polylines) 332 | if updated_cut_count != self.cut_count: 333 | self.cut_count = updated_cut_count 334 | self.lead_pos = [0.] * self.cut_count 335 | self.cut_state = [self.TODO] * self.cut_count 336 | return offset_polylines 337 | 338 | def _apply_lead(self, polylines): 339 | return polylines 340 | 341 | def _apply_loop(self, polylines): 342 | return [p.loop(121*math.pi/180, self.kerf_width / 2, self._loop_radius) 343 | for p in polylines] 344 | 345 | def _generate(self, polylines): 346 | return [p.to_lines() for p in polylines] 347 | 348 | def _generate_shape(self, polylines): 349 | if len(polylines) > 1: 350 | polylines = [p for p in polylines if p.is_closed()] 351 | return self._generate(polylines) 352 | 353 | def _apply_affine(self, polylines): 354 | return [np.dot(self.pos_rot_matrix(), np.insert(p, 2, 1., axis=0))[:-1] 355 | for p in polylines] 356 | 357 | def _apply_pline_affine(self, polylines): 358 | return [p.affine(self._position, self._angle, 1.) for p in polylines] 359 | 360 | def is_closed(self): 361 | polylines = self.root_node._data 362 | return len(polylines) != 1 or polylines[0].is_closed() 363 | -------------------------------------------------------------------------------- /sheetah/style/darkorange.stylesheet: -------------------------------------------------------------------------------- 1 | QToolTip 2 | { 3 | border: 1px solid black; 4 | background-color: #ffa02f; 5 | padding: 1px; 6 | border-radius: 3px; 7 | opacity: 100; 8 | } 9 | 10 | QWidget 11 | { 12 | color: #b1b1b1; 13 | background-color: #323232; 14 | } 15 | 16 | QWidget:item:hover 17 | { 18 | background-color: QLinearGradient( x1: 0, y1: 0, x2: 0, y2: 1, stop: 0 #ffa02f, stop: 1 #ca0619); 19 | color: #000000; 20 | } 21 | 22 | QWidget:item:selected 23 | { 24 | background-color: QLinearGradient( x1: 0, y1: 0, x2: 0, y2: 1, stop: 0 #ffa02f, stop: 1 #d7801a); 25 | } 26 | 27 | QMenuBar::item 28 | { 29 | background: transparent; 30 | } 31 | 32 | QMenuBar::item:selected 33 | { 34 | background: transparent; 35 | border: 1px solid #ffaa00; 36 | } 37 | 38 | QMenuBar::item:pressed 39 | { 40 | background: #444; 41 | border: 1px solid #000; 42 | background-color: QLinearGradient( 43 | x1:0, y1:0, 44 | x2:0, y2:1, 45 | stop:1 #212121, 46 | stop:0.4 #343434/*, 47 | stop:0.2 #343434, 48 | stop:0.1 #ffaa00*/ 49 | ); 50 | margin-bottom:-1px; 51 | padding-bottom:1px; 52 | } 53 | 54 | QMenu 55 | { 56 | border: 1px solid #000; 57 | } 58 | 59 | QMenu::item 60 | { 61 | padding: 2px 20px 2px 20px; 62 | } 63 | 64 | QMenu::item:selected 65 | { 66 | color: #000000; 67 | } 68 | 69 | QWidget:disabled 70 | { 71 | color: #404040; 72 | background-color: #323232; 73 | } 74 | 75 | QAbstractItemView 76 | { 77 | background-color: QLinearGradient( x1: 0, y1: 0, x2: 0, y2: 1, stop: 0 #4d4d4d, stop: 0.1 #646464, stop: 1 #5d5d5d); 78 | } 79 | 80 | QWidget:focus 81 | { 82 | /*border: 2px solid QLinearGradient( x1: 0, y1: 0, x2: 0, y2: 1, stop: 0 #ffa02f, stop: 1 #d7801a);*/ 83 | } 84 | 85 | QLineEdit 86 | { 87 | background-color: QLinearGradient( x1: 0, y1: 0, x2: 0, y2: 1, stop: 0 #4d4d4d, stop: 0 #646464, stop: 1 #5d5d5d); 88 | color: #ffffff; 89 | padding: 1px; 90 | border-style: solid; 91 | border: 1px solid #1e1e1e; 92 | border-radius: 5; 93 | } 94 | 95 | QPushButton 96 | { 97 | color: #b1b1b1; 98 | background-color: QLinearGradient( x1: 0, y1: 0, x2: 0, y2: 1, stop: 0 #565656, stop: 0.1 #525252, stop: 0.5 #4e4e4e, stop: 0.9 #4a4a4a, stop: 1 #464646); 99 | border-width: 1px; 100 | border-color: #1e1e1e; 101 | border-style: solid; 102 | border-radius: 6; 103 | padding: 3px; 104 | font-size: 12px; 105 | padding-left: 5px; 106 | padding-right: 5px; 107 | } 108 | 109 | QPushButton:pressed 110 | { 111 | background-color: QLinearGradient( x1: 0, y1: 0, x2: 0, y2: 1, stop: 0 #2d2d2d, stop: 0.1 #2b2b2b, stop: 0.5 #292929, stop: 0.9 #282828, stop: 1 #252525); 112 | } 113 | 114 | QComboBox 115 | { 116 | selection-background-color: #ffaa00; 117 | background-color: QLinearGradient( x1: 0, y1: 0, x2: 0, y2: 1, stop: 0 #565656, stop: 0.1 #525252, stop: 0.5 #4e4e4e, stop: 0.9 #4a4a4a, stop: 1 #464646); 118 | border-style: solid; 119 | border: 1px solid #1e1e1e; 120 | border-radius: 5; 121 | } 122 | 123 | QComboBox:hover,QPushButton:hover 124 | { 125 | border: 2px solid QLinearGradient( x1: 0, y1: 0, x2: 0, y2: 1, stop: 0 #ffa02f, stop: 1 #d7801a); 126 | } 127 | 128 | 129 | QComboBox:on 130 | { 131 | padding-top: 3px; 132 | padding-left: 4px; 133 | background-color: QLinearGradient( x1: 0, y1: 0, x2: 0, y2: 1, stop: 0 #2d2d2d, stop: 0.1 #2b2b2b, stop: 0.5 #292929, stop: 0.9 #282828, stop: 1 #252525); 134 | selection-background-color: #ffaa00; 135 | } 136 | 137 | QComboBox QAbstractItemView 138 | { 139 | border: 2px solid darkgray; 140 | selection-background-color: QLinearGradient( x1: 0, y1: 0, x2: 0, y2: 1, stop: 0 #ffa02f, stop: 1 #d7801a); 141 | } 142 | 143 | QComboBox::drop-down 144 | { 145 | subcontrol-origin: padding; 146 | subcontrol-position: top right; 147 | width: 15px; 148 | 149 | border-left-width: 0px; 150 | border-left-color: darkgray; 151 | border-left-style: solid; /* just a single line */ 152 | border-top-right-radius: 3px; /* same radius as the QComboBox */ 153 | border-bottom-right-radius: 3px; 154 | } 155 | 156 | QComboBox::down-arrow 157 | { 158 | image: url(:/down_arrow.png); 159 | } 160 | 161 | QGroupBox:focus 162 | { 163 | border: 2px solid QLinearGradient( x1: 0, y1: 0, x2: 0, y2: 1, stop: 0 #ffa02f, stop: 1 #d7801a); 164 | } 165 | 166 | QTextEdit:focus 167 | { 168 | border: 2px solid QLinearGradient( x1: 0, y1: 0, x2: 0, y2: 1, stop: 0 #ffa02f, stop: 1 #d7801a); 169 | } 170 | 171 | QScrollBar:horizontal { 172 | border: 1px solid #222222; 173 | background: QLinearGradient( x1: 0, y1: 0, x2: 0, y2: 1, stop: 0.0 #121212, stop: 0.2 #282828, stop: 1 #484848); 174 | height: 7px; 175 | margin: 0px 16px 0 16px; 176 | } 177 | 178 | QScrollBar::handle:horizontal 179 | { 180 | background: QLinearGradient( x1: 0, y1: 0, x2: 1, y2: 0, stop: 0 #ffa02f, stop: 0.5 #d7801a, stop: 1 #ffa02f); 181 | min-height: 20px; 182 | border-radius: 2px; 183 | } 184 | 185 | QScrollBar::add-line:horizontal { 186 | border: 1px solid #1b1b19; 187 | border-radius: 2px; 188 | background: QLinearGradient( x1: 0, y1: 0, x2: 1, y2: 0, stop: 0 #ffa02f, stop: 1 #d7801a); 189 | width: 14px; 190 | subcontrol-position: right; 191 | subcontrol-origin: margin; 192 | } 193 | 194 | QScrollBar::sub-line:horizontal { 195 | border: 1px solid #1b1b19; 196 | border-radius: 2px; 197 | background: QLinearGradient( x1: 0, y1: 0, x2: 1, y2: 0, stop: 0 #ffa02f, stop: 1 #d7801a); 198 | width: 14px; 199 | subcontrol-position: left; 200 | subcontrol-origin: margin; 201 | } 202 | 203 | QScrollBar::right-arrow:horizontal, QScrollBar::left-arrow:horizontal 204 | { 205 | border: 1px solid black; 206 | width: 1px; 207 | height: 1px; 208 | background: white; 209 | } 210 | 211 | QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal 212 | { 213 | background: none; 214 | } 215 | 216 | QScrollBar:vertical 217 | { 218 | background: QLinearGradient( x1: 0, y1: 0, x2: 1, y2: 0, stop: 0.0 #121212, stop: 0.2 #282828, stop: 1 #484848); 219 | width: 7px; 220 | margin: 16px 0 16px 0; 221 | border: 1px solid #222222; 222 | } 223 | 224 | QScrollBar::handle:vertical 225 | { 226 | background: QLinearGradient( x1: 0, y1: 0, x2: 0, y2: 1, stop: 0 #ffa02f, stop: 0.5 #d7801a, stop: 1 #ffa02f); 227 | min-height: 20px; 228 | border-radius: 2px; 229 | } 230 | 231 | QScrollBar::add-line:vertical 232 | { 233 | border: 1px solid #1b1b19; 234 | border-radius: 2px; 235 | background: QLinearGradient( x1: 0, y1: 0, x2: 0, y2: 1, stop: 0 #ffa02f, stop: 1 #d7801a); 236 | height: 14px; 237 | subcontrol-position: bottom; 238 | subcontrol-origin: margin; 239 | } 240 | 241 | QScrollBar::sub-line:vertical 242 | { 243 | border: 1px solid #1b1b19; 244 | border-radius: 2px; 245 | background: QLinearGradient( x1: 0, y1: 0, x2: 0, y2: 1, stop: 0 #d7801a, stop: 1 #ffa02f); 246 | height: 14px; 247 | subcontrol-position: top; 248 | subcontrol-origin: margin; 249 | } 250 | 251 | QScrollBar::up-arrow:vertical, QScrollBar::down-arrow:vertical 252 | { 253 | border: 1px solid black; 254 | width: 1px; 255 | height: 1px; 256 | background: white; 257 | } 258 | 259 | 260 | QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical 261 | { 262 | background: none; 263 | } 264 | 265 | QTextEdit 266 | { 267 | background-color: #242424; 268 | } 269 | 270 | QPlainTextEdit 271 | { 272 | background-color: #242424; 273 | } 274 | 275 | QHeaderView::section 276 | { 277 | background-color: QLinearGradient(x1:0, y1:0, x2:0, y2:1, stop:0 #616161, stop: 0.5 #505050, stop: 0.6 #434343, stop:1 #656565); 278 | color: white; 279 | padding-left: 4px; 280 | border: 1px solid #6c6c6c; 281 | } 282 | 283 | QCheckBox:disabled 284 | { 285 | color: #414141; 286 | } 287 | 288 | QDockWidget::title 289 | { 290 | text-align: center; 291 | spacing: 3px; /* spacing between items in the tool bar */ 292 | background-color: QLinearGradient(x1:0, y1:0, x2:0, y2:1, stop:0 #323232, stop: 0.5 #242424, stop:1 #323232); 293 | } 294 | 295 | QDockWidget::close-button, QDockWidget::float-button 296 | { 297 | text-align: center; 298 | spacing: 1px; /* spacing between items in the tool bar */ 299 | background-color: QLinearGradient(x1:0, y1:0, x2:0, y2:1, stop:0 #323232, stop: 0.5 #242424, stop:1 #323232); 300 | } 301 | 302 | QDockWidget::close-button:hover, QDockWidget::float-button:hover 303 | { 304 | background: #242424; 305 | } 306 | 307 | QDockWidget::close-button:pressed, QDockWidget::float-button:pressed 308 | { 309 | padding: 1px -1px -1px 1px; 310 | } 311 | 312 | QMainWindow::separator 313 | { 314 | background-color: QLinearGradient(x1:0, y1:0, x2:0, y2:1, stop:0 #161616, stop: 0.5 #151515, stop: 0.6 #212121, stop:1 #343434); 315 | color: white; 316 | padding-left: 4px; 317 | border: 1px solid #4c4c4c; 318 | spacing: 3px; /* spacing between items in the tool bar */ 319 | } 320 | 321 | QMainWindow::separator:hover 322 | { 323 | 324 | background-color: QLinearGradient(x1:0, y1:0, x2:0, y2:1, stop:0 #d7801a, stop:0.5 #b56c17 stop:1 #ffa02f); 325 | color: white; 326 | padding-left: 4px; 327 | border: 1px solid #6c6c6c; 328 | spacing: 3px; /* spacing between items in the tool bar */ 329 | } 330 | 331 | QToolBar::handle 332 | { 333 | spacing: 3px; /* spacing between items in the tool bar */ 334 | background: url(:/images/handle.png); 335 | } 336 | 337 | QMenu::separator 338 | { 339 | height: 2px; 340 | background-color: QLinearGradient(x1:0, y1:0, x2:0, y2:1, stop:0 #161616, stop: 0.5 #151515, stop: 0.6 #212121, stop:1 #343434); 341 | color: white; 342 | padding-left: 4px; 343 | margin-left: 10px; 344 | margin-right: 5px; 345 | } 346 | 347 | QProgressBar 348 | { 349 | border: 2px solid grey; 350 | border-radius: 5px; 351 | text-align: center; 352 | } 353 | 354 | QProgressBar::chunk 355 | { 356 | background-color: #d7801a; 357 | width: 2.15px; 358 | margin: 0.5px; 359 | } 360 | 361 | QTabBar::tab { 362 | color: #b1b1b1; 363 | border: 1px solid #444; 364 | border-bottom-style: none; 365 | background-color: #323232; 366 | padding-left: 10px; 367 | padding-right: 10px; 368 | padding-top: 3px; 369 | padding-bottom: 2px; 370 | margin-right: -1px; 371 | } 372 | 373 | QTabWidget::pane { 374 | border: 1px solid #444; 375 | top: 1px; 376 | } 377 | 378 | QTabBar::tab:last 379 | { 380 | margin-right: 0; /* the last selected tab has nothing to overlap with on the right */ 381 | border-top-right-radius: 3px; 382 | } 383 | 384 | QTabBar::tab:first:!selected 385 | { 386 | margin-left: 0px; /* the last selected tab has nothing to overlap with on the right */ 387 | 388 | 389 | border-top-left-radius: 3px; 390 | } 391 | 392 | QTabBar::tab:!selected 393 | { 394 | color: #b1b1b1; 395 | border-bottom-style: solid; 396 | margin-top: 3px; 397 | background-color: QLinearGradient(x1:0, y1:0, x2:0, y2:1, stop:1 #212121, stop:.4 #343434); 398 | } 399 | 400 | QTabBar::tab:selected 401 | { 402 | border-top-left-radius: 3px; 403 | border-top-right-radius: 3px; 404 | margin-bottom: 0px; 405 | } 406 | 407 | QTabBar::tab:!selected:hover 408 | { 409 | /*border-top: 2px solid #ffaa00; 410 | padding-bottom: 3px;*/ 411 | border-top-left-radius: 3px; 412 | border-top-right-radius: 3px; 413 | background-color: QLinearGradient(x1:0, y1:0, x2:0, y2:1, stop:1 #212121, stop:0.4 #343434, stop:0.2 #343434, stop:0.1 #ffaa00); 414 | } 415 | 416 | QRadioButton::indicator:checked, QRadioButton::indicator:unchecked{ 417 | color: #b1b1b1; 418 | background-color: #323232; 419 | border: 1px solid #b1b1b1; 420 | border-radius: 6px; 421 | } 422 | 423 | QRadioButton::indicator:checked 424 | { 425 | background-color: qradialgradient( 426 | cx: 0.5, cy: 0.5, 427 | fx: 0.5, fy: 0.5, 428 | radius: 1.0, 429 | stop: 0.25 #ffaa00, 430 | stop: 0.3 #323232 431 | ); 432 | } 433 | 434 | QCheckBox::indicator{ 435 | color: #b1b1b1; 436 | background-color: #323232; 437 | border: 1px solid #b1b1b1; 438 | width: 9px; 439 | height: 9px; 440 | } 441 | 442 | QRadioButton::indicator 443 | { 444 | border-radius: 6px; 445 | } 446 | 447 | QRadioButton::indicator:hover, QCheckBox::indicator:hover 448 | { 449 | border: 1px solid #ffaa00; 450 | } 451 | 452 | QCheckBox::indicator:checked 453 | { 454 | image:url(style/images/checkbox.png); 455 | } 456 | 457 | QCheckBox::indicator:disabled, QRadioButton::indicator:disabled 458 | { 459 | border: 1px solid #444; 460 | } 461 | -------------------------------------------------------------------------------- /sheetah/controllerbase.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | from PyQt5 import QtCore, QtGui, QtWidgets 4 | from abc import abstractmethod 5 | import regex as re 6 | import sys, queue 7 | 8 | from job import Job, Task 9 | 10 | class InputDecisionTree: 11 | def __init__(self, default_function=None): 12 | self._prefix = '' 13 | self._function = default_function 14 | self._children = [] 15 | 16 | def _node_create(prefix, function): 17 | node = InputDecisionTree.__new__(InputDecisionTree) 18 | node._prefix = prefix 19 | node._function = function 20 | node._children = [] 21 | return node 22 | 23 | def append_node(self, prefix, function): 24 | node = InputDecisionTree._node_create(prefix, function) 25 | pos = self 26 | pos_changed = True 27 | while pos_changed: 28 | pos_changed = False 29 | for child in pos._children: 30 | if node._prefix.startswith(child._prefix): 31 | pos = child 32 | pos_changed = True 33 | break 34 | node_children = [index for index, child in enumerate(pos._children) 35 | if child._prefix.startswith(node._prefix)] 36 | for index in sorted(node_children, reverse=True): 37 | node._children.append(pos._children.pop(index)) 38 | pos._children.append(node) 39 | 40 | def process_input(self, input): 41 | pos = self 42 | pos_changed = True 43 | while pos_changed: 44 | pos_changed = False 45 | for child in pos._children: 46 | if input.startswith(child._prefix): 47 | pos = child 48 | pos_changed = True 49 | break 50 | if pos._function is not None: 51 | pos._function(input) 52 | 53 | class CommunicationLogger(QtCore.QObject): 54 | log_available = QtCore.pyqtSignal() 55 | incident = QtCore.pyqtSignal(str) 56 | def __init__(self): 57 | super().__init__() 58 | self.queue = queue.Queue() 59 | def log_received_data(self, text): 60 | if not text.startswith('// echo: THC_error'): 61 | self.queue.put((text, True)) 62 | self.log_available.emit() 63 | def log_sent_data(self, text): 64 | self.queue.put((text, False)) 65 | self.log_available.emit() 66 | def empty(self): 67 | return self.queue.empty() 68 | def get(self): 69 | return self.queue.get() 70 | 71 | class GenericThread(QtCore.QThread): 72 | def __init__(self, function, *args, **kwargs): 73 | super().__init__() 74 | self.function = function 75 | self.args = args 76 | self.kwargs = kwargs 77 | 78 | def run(self, *args): 79 | self.function(*self.args, **self.kwargs) 80 | 81 | class ControllerBase(QtCore.QObject): 82 | link_state_update = QtCore.pyqtSignal() 83 | ST_UNCO = 0 84 | ST_INAC = 1 85 | ST_ACTI = 2 86 | ST_SAFE = 3 87 | def __init__(self, project, post_processor): 88 | super().__init__() 89 | self.project = project 90 | self.post_processor = post_processor 91 | 92 | self.keep_workers = False 93 | self.input_thread = GenericThread(self._input_worker) 94 | self.output_thread = GenericThread(self._output_worker) 95 | self.send_cond = QtCore.QWaitCondition() 96 | self.mutex = QtCore.QMutex() 97 | 98 | # NOTE better use SimpleQueue in 3.7 99 | self.manual_cmd_queue = queue.Queue() 100 | self.com_logger = CommunicationLogger() 101 | self.state = self.ST_UNCO 102 | 103 | def __del__(self): 104 | self.disconnect() 105 | 106 | def run_file(self, filename): 107 | """Run a raw GCode from file. 108 | """ 109 | if self.is_inactive(): 110 | with open(filename) as f: 111 | cmd_list = f.read().splitlines() 112 | if cmd_list: 113 | self.mutex.lock() 114 | self._kickstart(Task(cmd_list)) 115 | self.mutex.unlock() 116 | 117 | def run(self, dry_run): 118 | """Make post-processor generate project's GCode as a list of tasks and 119 | start running it. 120 | """ 121 | self.mutex.lock() 122 | if self.is_inactive(): 123 | tasks = self.project.generate_tasks(self.post_processor, dry_run) 124 | self.task_list = tasks 125 | try: 126 | task = self.task_list.pop(0) 127 | except: 128 | print('No job to run.') 129 | else: 130 | self._kickstart(task) 131 | self.send_cond.wakeOne() 132 | self.mutex.unlock() 133 | 134 | def stop(self): 135 | """Discard any tasks and manual commands except the one running. 136 | """ 137 | self.mutex.lock() 138 | if self.is_active(): 139 | self.manual_cmd_queue.queue.clear() 140 | self.task_list = [] 141 | self.mutex.unlock() 142 | 143 | def abort(self): 144 | """Discard all tasks and commands, setup emergency task instead and 145 | switch to safe mode to prevent any interruption of the emergency task. 146 | """ 147 | self.mutex.lock() 148 | if self.is_active(): 149 | self._enter_safe_mode() 150 | self.mutex.unlock() 151 | 152 | def _abort_internal(self, message): 153 | """Internal version of abort with mutex acquired on caller side and 154 | error logging. 155 | """ 156 | if self.is_active(): 157 | self._enter_safe_mode() 158 | self.com_logger.incident.emit(message) 159 | 160 | def _enter_safe_mode(self): 161 | """Factorization of abort and _abort_internal behaviour. 162 | """ 163 | self.manual_cmd_queue.queue.clear() 164 | self.cur_task.fail() 165 | self.task_list = [self.post_processor.emergency_task()] 166 | self.state = self.ST_SAFE 167 | 168 | def send_manual_cmd(self, cmd): 169 | """Push a manual command to waiting queue and eventually kickstart it. 170 | """ 171 | self.mutex.lock() 172 | # remove leading/trailing spaces 173 | cmd = re.sub(r'(^\s*)|(\s*$)', '', cmd) 174 | if self.is_active(): 175 | self.manual_cmd_queue.put(cmd) 176 | elif self.is_inactive(): 177 | self._kickstart(Task([cmd])) 178 | self.mutex.unlock() 179 | 180 | @abstractmethod 181 | def _link_open(self, *args, **kwargs): 182 | """Open communication link.""" 183 | pass 184 | 185 | @abstractmethod 186 | def _link_close(self): 187 | """Close communication link.""" 188 | pass 189 | 190 | @abstractmethod 191 | def _link_read(self): 192 | """Blocking read with timeout. Return complete line or empty string.""" 193 | pass 194 | 195 | @abstractmethod 196 | def _link_send(self, cmd): 197 | """Sends a command and eventually adds end characters to it.""" 198 | pass 199 | 200 | @abstractmethod 201 | def _link_busy(self): 202 | """Return True when machine controller is ready for next command to be 203 | sent. Implementation can use self.next_cmd if length information is 204 | needed. 205 | """ 206 | pass 207 | 208 | @abstractmethod 209 | def _process_input(self, input): 210 | """Parse a line from the machine controller.""" 211 | pass 212 | 213 | def is_unconnected(self): 214 | return self.state == self.ST_UNCO 215 | def is_inactive(self): 216 | return self.state == self.ST_INAC 217 | def is_active(self): 218 | return self.state == self.ST_ACTI 219 | def in_safe_mode(self): 220 | return self.state == self.ST_SAFE 221 | 222 | def connect(self, *args, **kwargs): 223 | """Connect to machine controller.""" 224 | if self.is_unconnected(): 225 | try: 226 | self._link_open(args, kwargs) 227 | except Exception as e: 228 | print('Connection failed: ' + str(e), file=sys.stderr) 229 | else: 230 | self.task_list = [] 231 | self.cur_task = None 232 | self.next_cmd = None 233 | self.keep_workers = True 234 | self.input_thread.start() 235 | self.output_thread.start() 236 | self.state = self.ST_INAC 237 | self.link_state_update.emit() 238 | 239 | def disconnect(self): 240 | """Close link with machine controller. It can be called either from 241 | external thread or its self worker threads. 242 | """ 243 | if self.is_inactive(): 244 | self.mutex.lock() 245 | self.keep_workers = False 246 | self.send_cond.wakeOne() 247 | self.mutex.unlock() 248 | if QtCore.QThread.currentThread() != self.input_thread: 249 | self.input_thread.wait() 250 | if QtCore.QThread.currentThread() != self.output_thread: 251 | self.output_thread.wait() 252 | self._link_close() 253 | self.state = self.ST_UNCO 254 | self.link_state_update.emit() 255 | 256 | def _input_worker(self): 257 | """Input thread working loop.""" 258 | while self.keep_workers: 259 | line = '' 260 | while (not line or line[-1] != '\n') and self.keep_workers: 261 | try: 262 | line += self._link_read() 263 | except Exception as e: 264 | print('Link read failed: ' + str(e), file=sys.stderr) 265 | self.disconnect() 266 | return 267 | if self.keep_workers: 268 | line = line.rstrip() 269 | self.mutex.lock() 270 | self._process_input(line) 271 | self.com_logger.log_received_data(line) 272 | self.mutex.unlock() 273 | 274 | def _output_worker(self): 275 | """Output thread working loop.""" 276 | while self.keep_workers: 277 | self.mutex.lock() 278 | while ((self.next_cmd is None or self._link_busy()) and 279 | self.keep_workers): 280 | self.send_cond.wait(self.mutex) 281 | if self.keep_workers: 282 | try: 283 | self._link_send(self.next_cmd) 284 | except Exception as e: 285 | self.mutex.unlock() 286 | print('Link send failed: ' + str(e), file=sys.stderr) 287 | self.disconnect() 288 | return 289 | self.com_logger.log_sent_data(self.next_cmd) 290 | self.next_cmd = None 291 | self.mutex.unlock() 292 | 293 | def _kickstart(self, task): 294 | """Wake up worker thread to run task.""" 295 | self.cur_task = task 296 | self.next_cmd = self.cur_task.pop() 297 | self.state = self.ST_ACTI 298 | self.send_cond.wakeOne() 299 | 300 | def _pop_next_cmd(self): 301 | """In active mode, return manual command if one is available 302 | otherwise pop next command from tasks. 303 | In safe mode, only consider commands from task. 304 | If no command at all or in another mode, return None. 305 | """ 306 | if self.is_active(): 307 | try: 308 | manual_cmd = self.manual_cmd_queue.get_nowait() 309 | except queue.Empty: 310 | pass 311 | else: 312 | if self.cur_task is None: 313 | self.cur_task = Task([manual_cmd]) 314 | else: 315 | return manual_cmd 316 | if self.is_active() or self.in_safe_mode(): 317 | try: 318 | return self.cur_task.pop() 319 | except IndexError: 320 | self.cur_task.close() 321 | try: 322 | self.cur_task = self.task_list.pop(0) 323 | except IndexError: 324 | self.cur_task = None 325 | self.state = self.ST_INAC 326 | else: 327 | return self.cur_task.pop() 328 | return None 329 | 330 | def _complete_cmd(self): 331 | """Function to be called by input parser when a command is completed. 332 | """ 333 | self.next_cmd = self._pop_next_cmd() 334 | self.send_cond.wakeOne() 335 | 336 | class JobErrorDialog(QtWidgets.QDialog): 337 | def __init__(self, msg, parent=None): 338 | super().__init__(parent) 339 | self.setWindowTitle('Job incident') 340 | std_btns = QtWidgets.QDialogButtonBox.Ok 341 | self.buttonBox = QtWidgets.QDialogButtonBox(std_btns) 342 | self.buttonBox.accepted.connect(self.accept) 343 | name_label = QtWidgets.QLabel(msg, alignment=QtCore.Qt.AlignCenter) 344 | layout = QtWidgets.QVBoxLayout() 345 | layout.addWidget(name_label) 346 | layout.addWidget(self.buttonBox) 347 | self.setLayout(layout) 348 | 349 | class ConsoleWidget(QtWidgets.QWidget): 350 | hist_filename = '.cmd_history' 351 | def __init__(self, controller): 352 | super().__init__() 353 | self.controller = controller 354 | self.serial_data_w = QtWidgets.QTextBrowser() 355 | self.user_input_w = QtWidgets.QLineEdit() 356 | try: 357 | with open(self.hist_filename, 'r') as file: 358 | self.hist = file.read().splitlines() 359 | except: 360 | self.hist = [] 361 | self.hist_fd = open(self.hist_filename, 'a+') 362 | self.hist_tmp = self.hist.copy() + [''] 363 | self.hist_cursor = len(self.hist_tmp) - 1 364 | layout = QtWidgets.QVBoxLayout() 365 | layout.addWidget(self.serial_data_w) 366 | layout.addWidget(self.user_input_w) 367 | self.setLayout(layout) 368 | 369 | self.controller.com_logger.log_available.connect(self.on_log) 370 | self.user_input_w.returnPressed.connect(self.on_user_input) 371 | 372 | self.controller.com_logger.incident.connect(self.on_incident) 373 | 374 | QtWidgets.QShortcut(QtCore.Qt.Key_Up, self.user_input_w, self.key_up) 375 | QtWidgets.QShortcut(QtCore.Qt.Key_Down, self.user_input_w, self.key_down) 376 | 377 | def on_incident(self, msg): 378 | j = JobErrorDialog(msg[3:], self) 379 | j.exec_() 380 | 381 | def on_log(self): 382 | while not self.controller.com_logger.empty(): 383 | log = self.controller.com_logger.get() 384 | if log[1]: 385 | text = log[0] 386 | else: 387 | text = '' + log[0] + '' 388 | self.serial_data_w.append(text) 389 | 390 | def on_user_input(self): 391 | text = self.user_input_w.text() 392 | self.controller.send_manual_cmd(text) 393 | self.hist_fd.write(text + '\n') 394 | self.hist_fd.flush() 395 | self.user_input_w.clear() 396 | self.hist.append(text) 397 | self.hist_tmp = self.hist.copy() + [''] 398 | self.hist_cursor = len(self.hist_tmp) - 1 399 | 400 | def key_up(self): 401 | if self.user_input_w.hasFocus(): 402 | self.hist_tmp[self.hist_cursor] = self.user_input_w.text() 403 | if self.hist_cursor > 0: 404 | self.hist_cursor -= 1 405 | self.user_input_w.setText(self.hist_tmp[self.hist_cursor]) 406 | 407 | def key_down(self): 408 | if self.user_input_w.hasFocus(): 409 | self.hist_tmp[self.hist_cursor] = self.user_input_w.text() 410 | if self.hist_cursor < len(self.hist_tmp) - 1: 411 | self.hist_cursor += 1 412 | self.user_input_w.setText(self.hist_tmp[self.hist_cursor]) 413 | 414 | class ControllerUIBase(QtWidgets.QWidget): 415 | def __init__(self, controller): 416 | super().__init__() 417 | self.controller = controller 418 | self.console = ConsoleWidget(self.controller) 419 | 420 | self.dry_run_checker = QtWidgets.QCheckBox('DryRun') 421 | self.run_btn = QtWidgets.QPushButton('Run') 422 | self.stop_btn = QtWidgets.QPushButton('Stop') 423 | self.abort_btn = QtWidgets.QPushButton('Abort') 424 | self.run_file_btn = QtWidgets.QPushButton('Run file') 425 | self.connect_btn = QtWidgets.QPushButton('Connect') 426 | self.run_btn.clicked.connect(self.on_run) 427 | self.stop_btn.clicked.connect(self.on_stop) 428 | self.abort_btn.clicked.connect(self.on_abort) 429 | self.run_file_btn.clicked.connect(self.on_run_file) 430 | self.connect_btn.clicked.connect(self.on_connect) 431 | self.controller.link_state_update.connect(self.on_link_state_update) 432 | 433 | self.btn_box = QtWidgets.QGroupBox() 434 | layout = QtWidgets.QHBoxLayout() 435 | layout.addWidget(self.dry_run_checker) 436 | layout.addWidget(self.run_btn) 437 | layout.addWidget(self.stop_btn) 438 | layout.addWidget(self.abort_btn) 439 | layout.addWidget(self.run_file_btn) 440 | layout.addWidget(self.connect_btn) 441 | self.btn_box.setLayout(layout) 442 | 443 | def on_run_file(self): 444 | filename, _ = QtGui.QFileDialog.getOpenFileName(self, 445 | 'Open File', QtCore.QDir.currentPath(), 446 | 'gcode (*.gcode);; All Files (*)') 447 | if filename: 448 | self.controller.run_file(filename) 449 | 450 | def on_run(self): 451 | self.controller.run(self.dry_run_checker.isChecked()) 452 | def on_stop(self): 453 | self.controller.stop() 454 | def on_abort(self): 455 | self.controller.abort() 456 | def on_connect(self): 457 | if self.controller.is_unconnected(): 458 | self.controller.connect() 459 | else: 460 | self.controller.disconnect() 461 | def on_link_state_update(self): 462 | if self.controller.is_unconnected(): 463 | self.connect_btn.setText('Connect') 464 | else: 465 | self.connect_btn.setText('Disconnect') 466 | -------------------------------------------------------------------------------- /sheetah/polyline.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from copy import copy 3 | import math 4 | import numpy as np 5 | 6 | # required by aggregate 7 | from random import shuffle 8 | 9 | from geomdl import BSpline 10 | from geomdl import utilities 11 | from polylineinterface import PolylineInterface 12 | 13 | import cavaliercontours as cavc 14 | 15 | from shapely.geometry.polygon import Polygon, LineString 16 | 17 | class Polyline(PolylineInterface): 18 | def __init__(self, vertices, closed): 19 | vertices = np.array(vertices) # NOTE this create a copy 20 | if vertices.size > 0: 21 | assert(len(vertices.shape) == 2) 22 | assert(vertices.shape[0] == 3) 23 | # remove duplicates 24 | dup = np.all(np.isclose(vertices[:2,:-1], vertices[:2,1:]), axis=0) 25 | dup = np.append(dup, False) 26 | vertices = vertices[:,~dup] 27 | else: 28 | # ensure shape is correct 29 | vertices = np.empty(shape=(3,0)) 30 | Polyline._init_internal(vertices, closed, self) 31 | 32 | def _init_internal(vertices, closed, object=None): 33 | if object is None: 34 | object = Polyline.__new__(Polyline) 35 | object._vertices = vertices 36 | object._closed = closed 37 | object._cavc_up_to_date = False 38 | object._shapely_up_to_date = False 39 | object._lines_up_to_date = False 40 | return object 41 | 42 | def _update_cavc(self): 43 | if not self._cavc_up_to_date: 44 | self._cavc_pline = cavc.Polyline(self._vertices, self._closed) 45 | self._cavc_up_to_date = True 46 | 47 | def _update_shapely(self): 48 | if not self._shapely_up_to_date: 49 | if self.is_closed(): 50 | self._shapely = Polygon(self.to_lines().T) 51 | else: 52 | self._shapely = LineString(self.to_lines().T) 53 | self._shapely_up_to_date = True 54 | 55 | def _update_lines(self): 56 | if self._lines_up_to_date: 57 | return 58 | 59 | precision = 1e-3 60 | points = [] 61 | n = self._vertices.shape[1] 62 | for i in range(n - int(not self._closed)): 63 | a = self._vertices[:2,i] 64 | b = self._vertices[:2,(i+1)%n] 65 | bulge = self._vertices[2,i] 66 | if points: 67 | points.pop(-1) 68 | if math.isclose(bulge, 0): 69 | points += [a, b] 70 | else: 71 | rot = np.array([[0,-1], 72 | [1, 0]]) 73 | on_right = bulge >= 0 74 | if not on_right: 75 | rot = -rot 76 | bulge = abs(bulge) 77 | ab = b-a 78 | chord = np.linalg.norm(ab) 79 | radius = chord * (bulge + 1. / bulge) / 4 80 | center_offset = radius - chord * bulge / 2 81 | center = a + ab/2 + center_offset / chord * rot.dot(ab) 82 | 83 | a_dir = a - center 84 | b_dir = b - center 85 | rad_start = math.atan2(a_dir[1], a_dir[0]) 86 | rad_end = math.atan2(b_dir[1], b_dir[0]) 87 | 88 | # TODO handle case where bulge almost 0 or inf (line or circle) 89 | if not math.isclose(rad_start, rad_end): 90 | if on_right != (rad_start < rad_end): 91 | if on_right: 92 | rad_start -= 2*math.pi 93 | else: 94 | rad_end -= 2*math.pi 95 | 96 | rad_len = abs(rad_end - rad_start) 97 | if radius > precision: 98 | max_angle = 2 * math.acos(1.0 - precision / radius) 99 | else: 100 | max_angle = math.pi 101 | nb_segments = max(2, math.ceil(rad_len / max_angle) + 1) 102 | 103 | angles = np.linspace(rad_start, rad_end, nb_segments + 1) 104 | arc_data = (center.reshape(2,1) + radius * 105 | np.vstack((np.cos(angles), np.sin(angles)))) 106 | points += np.transpose(arc_data).tolist() 107 | self._lines = np.transpose(np.array(points)) 108 | self._lines_up_to_date = True 109 | 110 | @property 111 | def raw(self): 112 | return self._vertices 113 | 114 | @property 115 | def start(self): 116 | return self._vertices[:2,0] 117 | 118 | @property 119 | def end(self): 120 | return self._vertices[:2,-1] 121 | 122 | @property 123 | def bounds(self): 124 | self._update_cavc() 125 | min_x, min_y, max_x, max_y = self._cavc_pline.get_extents() 126 | return np.array([[min_x, min_y], [max_x, max_y]]) 127 | 128 | @property 129 | def centroid(self): 130 | self._update_shapely() 131 | return self._shapely.centroid 132 | 133 | def is_closed(self): 134 | return self._closed 135 | 136 | def is_ccw(self): 137 | self._update_cavc() 138 | return self._cavc_pline.get_area() >= 0 139 | 140 | # TODO 141 | def is_simple(self): 142 | return True 143 | 144 | def reverse(self): 145 | polyline = Polyline._init_internal(np.copy(self._vertices), 146 | self._closed) 147 | polyline._vertices = np.flip(polyline._vertices, axis=1) 148 | polyline._vertices[2] = -polyline._vertices[2] 149 | if polyline._closed: 150 | polyline._vertices[:2] = np.roll(polyline._vertices[:2], 1, axis=1) 151 | else: 152 | polyline._vertices[2] = np.roll(polyline._vertices[2], -1) 153 | polyline._vertices[2,-1] = 0. 154 | return polyline 155 | 156 | def contains(self, object): 157 | if not self._closed: 158 | return False 159 | if isinstance(object,(list, np.ndarray)): 160 | point = np.array(object) 161 | self._update_cavc() 162 | return self._cavc_pline.get_winding_number(object) != 0 163 | elif isinstance(object, Polyline): 164 | self._update_shapely() 165 | object._update_shapely() 166 | return self._shapely.contains(object._shapely) 167 | else: 168 | raise Exception('Incorrect argument type') 169 | 170 | def intersects(self, polyline): 171 | self._update_shapely() 172 | polyline._update_shapely() 173 | return self._shapely.intersects(polyline._shapely) 174 | 175 | def affine(self, d, r, s): 176 | polyline = Polyline._init_internal(np.copy(self._vertices), 177 | self._closed) 178 | cos = math.cos(r) * s 179 | sin = math.sin(r) 180 | tr_mat = np.array([[cos,-sin, d[0]], 181 | [sin, cos, d[1]], 182 | [ 0, 0, 1]]) 183 | vertices = polyline._vertices[:-1] 184 | vertices = np.dot(tr_mat, np.insert(vertices, 2, 1., axis=0)) 185 | vertices[-1] = polyline._vertices[-1] 186 | polyline._vertices = vertices 187 | return polyline 188 | 189 | def offset(self, offset): 190 | self._update_cavc() 191 | cavc_plines = self._cavc_pline.parallel_offset(offset, 0) 192 | polylines = [] 193 | for cavc_pline in cavc_plines: 194 | polyline = Polyline._init_internal(cavc_pline.vertex_data(), 195 | cavc_pline.is_closed()) 196 | polyline._cavc_pline = cavc_pline 197 | polyline._cavc_up_to_date = True 198 | polylines.append(polyline) 199 | return polylines 200 | 201 | def loop(self, limit_angle, selected_radius, loop_radius): 202 | polyline = Polyline._init_internal(np.copy(self._vertices), 203 | self._closed) 204 | 205 | limit_bulge = math.tan((math.pi - limit_angle) / 4) 206 | ids = np.where(np.abs(polyline._vertices[2]) >= limit_bulge)[0] 207 | 208 | cur = np.take(polyline._vertices, ids, axis=1) 209 | next_ids = (ids+1)%polyline._vertices.shape[1] 210 | next = np.take(polyline._vertices[:2], next_ids, axis=1) 211 | 212 | # i, x, y, b, h_theta, vx, vy 213 | data = np.vstack((ids, cur, 2*np.arctan(np.abs(cur[2])), next-cur[:2])) 214 | 215 | # i, x, y, b, h_theta, vx, vy, d 216 | data = np.vstack((data, np.linalg.norm(data[-2:], axis=0))) 217 | 218 | radius = data[7] / (2 * np.sin(data[4])) 219 | ignored = np.where(np.logical_not(np.isclose(radius, selected_radius)))[0] 220 | data = np.delete(data, ignored, axis=1) 221 | 222 | # i, x, y, b, h_theta, vx, vy, d, h 223 | data = np.vstack((data, (data[7] + 2 * loop_radius * np.sin(data[4])) * np.tan(data[4]) / 2)) 224 | 225 | a = data[1:3] 226 | ab = data[5:7] 227 | normalized_ab = ab / data[7] 228 | ab_normal = np.array([-normalized_ab[1], normalized_ab[0]]) 229 | h = data[8] 230 | top = a + ab / 2 + ab_normal * h 231 | half_top_side = loop_radius * np.sin(data[4]) 232 | new_a = top + normalized_ab * half_top_side 233 | new_b = top - normalized_ab * half_top_side 234 | 235 | new_b = np.insert(new_b, 2, 0, axis=0) 236 | new_a = np.vstack((new_a, -1/data[3])) 237 | for i in reversed(range(data.shape[1])): 238 | id = int(data[0, i] + 0.1) 239 | polyline._vertices[2, id] = 0 240 | 241 | polyline._vertices = np.insert(polyline._vertices, id+1, new_b[:,i], axis=1) 242 | polyline._vertices = np.insert(polyline._vertices, id+1, new_a[:,i], axis=1) 243 | 244 | return polyline 245 | 246 | def to_lines(self): 247 | self._update_lines() 248 | return self._lines 249 | 250 | def line2polyline(start, end): 251 | return Polyline(np.insert([start, end], 2, 0., axis=1).T, False) 252 | 253 | def arc2polyline(center, radius, rad_start, rad_end): 254 | #TODO numpify 255 | bulge = math.tan(math.fmod(rad_end - rad_start + 2*math.pi, 2*math.pi) / 4) 256 | # bulge = tan(remainder(end - start, 2*pi) / 4) 257 | vertices = [[center[0] + radius * math.cos(rad_start), 258 | center[0] + radius * math.cos(rad_end)], 259 | [center[1] + radius * math.sin(rad_start), 260 | center[1] + radius * math.sin(rad_end)], 261 | [bulge, 0.]] 262 | return Polyline(vertices, False) # TODO sure not closed ? 263 | 264 | def circle2polyline(center, radius): 265 | #TODO numpify 266 | # use two bulges to draw a circle 267 | vertices = [[center[0] + radius, center[0] - radius], 268 | [center[1], center[1]], 269 | [1. , 1.]] 270 | return Polyline(vertices, True) 271 | 272 | # TODO replace with biarc 273 | def spline2polyline(degree, control_points, closed): 274 | epsilon = 1e-1 275 | spline = BSpline.Curve() 276 | spline.degree = degree 277 | spline.ctrlpts = control_points 278 | if closed: 279 | spline.ctrlpts += spline.ctrlpts[:spline.degree] 280 | m = spline.degree + len(spline.ctrlpts) 281 | spline.knotvector = [i/m for i in range(m+1)] 282 | curve_range = (spline.knotvector[spline.degree], spline.knotvector[len(spline.ctrlpts)]) 283 | nb_seg_init = len(spline.ctrlpts) 284 | else: 285 | spline.knotvector = utilities.generate_knot_vector(spline.degree, len(spline.ctrlpts)) 286 | curve_range = (0, 1) 287 | nb_seg_init = len(spline.ctrlpts) - 1 288 | 289 | t = np.linspace(curve_range[0], curve_range[1], num=nb_seg_init) 290 | curve_data = np.hstack((t.reshape(nb_seg_init,1), np.array(spline.evaluate_list(t)))) 291 | curve_data = np.transpose(curve_data) 292 | i = 1 293 | while i < np.size(curve_data, 1): 294 | t_mid = (curve_data[0][i] + curve_data[0][i-1]) / 2 295 | a = curve_data[1:,i-1] 296 | b = curve_data[1:,i] 297 | c = spline.evaluate_single(t_mid) 298 | ab = b - a 299 | ac = c - a 300 | ac_proj_in_ab = np.sum(ab * ac, axis=0) / np.sum(np.square(ab), axis=0) 301 | dist_to_curve = np.linalg.norm(ac_proj_in_ab * ab - ac, axis=0) 302 | 303 | if dist_to_curve > epsilon: 304 | curve_data = np.insert(curve_data, i, [t_mid, c[0], c[1]], axis=1) 305 | else: 306 | i += 1 307 | vertices = np.insert(curve_data[1:], 2, 0., axis=0) 308 | if closed: 309 | return Polyline(vertices[:,:-1], True) 310 | else: 311 | return Polyline(vertices, False) 312 | 313 | # AGGREGATOR ################################################################### 314 | class EdgeConnector: 315 | def polyline2connectors(polyline): 316 | a = EdgeConnector(polyline, True) 317 | b = EdgeConnector(polyline, False) 318 | a.opp_end = b 319 | b.opp_end = a 320 | return (a, b) 321 | 322 | def __init__(self, polyline, forward): 323 | self.pos = polyline.start if forward else polyline.end 324 | self.opp_end = None 325 | self.next = None 326 | self.marked = False 327 | self._polyline = polyline 328 | self._forward = forward 329 | 330 | def connect(self, other): 331 | self.next = other 332 | other.next = self 333 | 334 | def disconnect(self): 335 | if self.next is not None: 336 | self.next.next = None 337 | self.next = None 338 | 339 | def connected(self): 340 | return self.next is not None 341 | 342 | def mark(self): 343 | self.marked = True 344 | if self.next is not None: 345 | self.next.marked = True 346 | 347 | def polyline(self): 348 | if self._forward: 349 | return self._polyline 350 | else: 351 | return self._polyline.reverse() 352 | 353 | class KdTree: 354 | def __init__(self, connector, split_dir=True, precision=1e-2): 355 | self._connectors = [connector] 356 | self._split_dir = split_dir # True is for vertical 357 | self._precision = precision 358 | self._pos = connector.pos 359 | self._tl_child = self._br_child = None 360 | self._complex_joint = False 361 | 362 | def insert(self, connector): 363 | if np.allclose(connector.pos, self._pos, atol=self._precision): 364 | if not self._complex_joint: 365 | if len(self._connectors) == 2: 366 | self._complex_joint = True 367 | for e in self._connectors: 368 | e.disconnect() 369 | else: 370 | connector.connect(self._connectors[0]) 371 | self._connectors.append(connector) 372 | return 373 | 374 | if self._split_dir: # vertical split 375 | tl = connector.pos[0] < self._pos[0] 376 | else: # horizontal split 377 | tl = connector.pos[1] > self._pos[1] 378 | 379 | if tl: 380 | if self._tl_child is not None: 381 | self._tl_child.insert(connector) 382 | else: 383 | self._tl_child = KdTree(connector, not self._split_dir) 384 | else: 385 | if self._br_child is not None: 386 | self._br_child.insert(connector) 387 | else: 388 | self._br_child = KdTree(connector, not self._split_dir) 389 | 390 | def aggregate(polylines): 391 | ready_polylines = [p for p in polylines if p._closed] 392 | open_polylines = [p for p in polylines if not p._closed] 393 | if not open_polylines: 394 | return ready_polylines 395 | 396 | # shuffle polylines to ensure KdTree correct distribution 397 | shuffle(open_polylines) 398 | 399 | connectors = [] 400 | for p in open_polylines: 401 | connectors += EdgeConnector.polyline2connectors(p) 402 | tree = KdTree(connectors[0]) 403 | for c in connectors[1:]: 404 | tree.insert(c) 405 | 406 | updated = True 407 | while updated: 408 | updated = False 409 | for c in connectors: 410 | if not c.connected() and not c.marked: # unprocessed single end 411 | parts = [] 412 | updated = True 413 | current = c 414 | current.mark() 415 | while current is not None: 416 | parts.append(current.polyline()) 417 | current = current.opp_end 418 | current.mark() 419 | current = current.next 420 | if len(parts) > 1: 421 | vertices = np.hstack([p._vertices[:,:-1] for p in parts[:-1]]) 422 | vertices = np.hstack((vertices, parts[-1]._vertices)) 423 | if np.allclose(vertices[:2,0], vertices[:2,-1], atol=1e-3): 424 | closed = True 425 | vertices = np.delete(vertices, -1, axis=1) 426 | else: 427 | closed = False 428 | ready_polylines.append(Polyline(vertices, closed)) 429 | else: 430 | ready_polylines.append(parts[0]) 431 | if not updated: # no more single ends 432 | for c in connectors: # look for a loop and cut it open 433 | if c.connected() and not c.marked: 434 | c.disconnect() 435 | updated = True 436 | break 437 | return ready_polylines 438 | # !AGGREGATOR ################################################################# 439 | class HierarchyNode: 440 | def __init__(self, polyline): 441 | self._polyline = polyline 442 | self._children = [] 443 | 444 | def polyline(self): 445 | return self._polyline 446 | 447 | def is_closed(self): 448 | return self._polyline._closed 449 | 450 | def contains(self, other): 451 | return self._polyline.contains(other._polyline) 452 | 453 | def add_child(self, child): 454 | self._children.append(child) 455 | 456 | def children(self): 457 | return self._children 458 | 459 | def append_to_hierarchy(hierarchy, node): 460 | children_id = [] 461 | for i, n in enumerate(hierarchy): 462 | if node.contains(n): 463 | children_id.append(i) 464 | if children_id: 465 | for i in sorted(children_id, reverse=True): 466 | node.add_child(hierarchy.pop(i)) 467 | hierarchy.append(node) 468 | return 469 | 470 | for n in hierarchy: 471 | if n.contains(node): 472 | append_to_hierarchy(n.children(), node) 473 | return 474 | 475 | hierarchy.append(node) 476 | 477 | def extract_groups(hierarchy): 478 | groups = [] 479 | while hierarchy: 480 | sublevel_hierarchy = [] 481 | for exterior in hierarchy: 482 | group = [exterior.polyline()] 483 | for interior in exterior.children(): 484 | group.insert(0, interior.polyline()) 485 | if interior.children(): 486 | sublevel_hierarchy += interior.children() 487 | groups.append(group) 488 | hierarchy = sublevel_hierarchy 489 | return groups 490 | 491 | def group_as_contours(polylines): 492 | hierarchy = [HierarchyNode(polylines[0])] 493 | for polyline in polylines[1:]: 494 | append_to_hierarchy(hierarchy, HierarchyNode(polyline)) 495 | groups = extract_groups(hierarchy) 496 | return groups 497 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS 622 | 623 | How to Apply These Terms to Your New Programs 624 | 625 | If you develop a new program, and you want it to be of the greatest 626 | possible use to the public, the best way to achieve this is to make it 627 | free software which everyone can redistribute and change under these terms. 628 | 629 | To do so, attach the following notices to the program. It is safest 630 | to attach them to the start of each source file to most effectively 631 | state the exclusion of warranty; and each file should have at least 632 | the "copyright" line and a pointer to where the full notice is found. 633 | 634 | 635 | Copyright (C) 636 | 637 | This program is free software: you can redistribute it and/or modify 638 | it under the terms of the GNU General Public License as published by 639 | the Free Software Foundation, either version 3 of the License, or 640 | (at your option) any later version. 641 | 642 | This program is distributed in the hope that it will be useful, 643 | but WITHOUT ANY WARRANTY; without even the implied warranty of 644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 645 | GNU General Public License for more details. 646 | 647 | You should have received a copy of the GNU General Public License 648 | along with this program. If not, see . 649 | 650 | Also add information on how to contact you by electronic and paper mail. 651 | 652 | If the program does terminal interaction, make it output a short 653 | notice like this when it starts in an interactive mode: 654 | 655 | Copyright (C) 656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 657 | This is free software, and you are welcome to redistribute it 658 | under certain conditions; type `show c' for details. 659 | 660 | The hypothetical commands `show w' and `show c' should show the appropriate 661 | parts of the General Public License. Of course, your program's commands 662 | might be different; for a GUI interface, you would use an "about box". 663 | 664 | You should also get your employer (if you work as a programmer) or school, 665 | if any, to sign a "copyright disclaimer" for the program, if necessary. 666 | For more information on this, and how to apply and follow the GNU GPL, see 667 | . 668 | 669 | The GNU General Public License does not permit incorporating your program 670 | into proprietary programs. If your program is a subroutine library, you 671 | may consider it more useful to permit linking proprietary applications with 672 | the library. If this is what you want to do, use the GNU Lesser General 673 | Public License instead of this License. But first, please read 674 | . 675 | --------------------------------------------------------------------------------