From c09f444b886d62bbc9df782e1c9fefd1f8f1d93e Mon Sep 17 00:00:00 2001 From: Andrey Zarembo Date: Mon, 19 Dec 2022 17:50:38 +0300 Subject: [PATCH] Initial commit Initial commit --- .gitignore | 129 +++++ .vscode/launch.json | 33 ++ LICENSE | 21 + Readme.rst | 49 ++ docs/Makefile | 20 + docs/make.bat | 35 ++ docs/source/conf.py | 40 ++ docs/source/index.rst | 8 + laser_offset.code-workspace | 8 + pyproject.toml | 21 + requirements.txt | 2 + src/laser_offset/__init__.py | 7 + src/laser_offset/__main__.py | 4 + src/laser_offset/cli/laser_offset_cli.py | 37 ++ src/laser_offset/exporters/dxf_exporter.py | 129 +++++ src/laser_offset/exporters/exporter.py | 10 + src/laser_offset/exporters/svg_exporter.py | 417 +++++++++++++++ .../file_converters/file_converter.py | 104 ++++ .../file_converters/folder_converter.py | 66 +++ src/laser_offset/geometry_2d/angle_range.py | 79 +++ src/laser_offset/geometry_2d/arc_info.py | 140 ++++++ .../geometry_2d/arc_segment_2d.py | 197 ++++++++ src/laser_offset/geometry_2d/bounds2d.py | 7 + src/laser_offset/geometry_2d/canvas2d.py | 112 +++++ src/laser_offset/geometry_2d/fill_style.py | 6 + .../geometry_2d/normalize_angle.py | 14 + src/laser_offset/geometry_2d/point2d.py | 133 +++++ src/laser_offset/geometry_2d/segment_2d.py | 74 +++ src/laser_offset/geometry_2d/shape2d.py | 43 ++ .../geometry_2d/shapes_2d/arc2d.py | 89 ++++ .../geometry_2d/shapes_2d/bezier2d.py | 40 ++ .../geometry_2d/shapes_2d/circle2d.py | 34 ++ .../geometry_2d/shapes_2d/ellipse2d.py | 36 ++ .../geometry_2d/shapes_2d/line2d.py | 67 +++ .../geometry_2d/shapes_2d/path2d.py | 475 ++++++++++++++++++ .../geometry_2d/shapes_2d/polygon2d.py | 34 ++ .../geometry_2d/shapes_2d/polyline2d.py | 34 ++ .../geometry_2d/shapes_2d/rect2d.py | 33 ++ src/laser_offset/geometry_2d/size2d.py | 8 + src/laser_offset/geometry_2d/stroke_style.py | 38 ++ src/laser_offset/geometry_2d/style2d.py | 14 + src/laser_offset/geometry_2d/vector2d.py | 139 +++++ src/laser_offset/importers/dxf_importer.py | 200 ++++++++ src/laser_offset/importers/importer.py | 10 + src/laser_offset/importers/svg_importer.py | 371 ++++++++++++++ src/laser_offset/math/float_functions.py | 26 + src/laser_offset/modifiers/expand.py | 64 +++ src/laser_offset/modifiers/modifier.py | 18 + src/laser_offset/modifiers/polygon_data.py | 159 ++++++ .../modifiers/segment_operations.py | 225 +++++++++ 50 files changed, 4059 insertions(+) create mode 100644 .gitignore create mode 100644 .vscode/launch.json create mode 100644 LICENSE create mode 100644 Readme.rst create mode 100644 docs/Makefile create mode 100644 docs/make.bat create mode 100644 docs/source/conf.py create mode 100644 docs/source/index.rst create mode 100644 laser_offset.code-workspace create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100644 src/laser_offset/__init__.py create mode 100644 src/laser_offset/__main__.py create mode 100644 src/laser_offset/cli/laser_offset_cli.py create mode 100644 src/laser_offset/exporters/dxf_exporter.py create mode 100644 src/laser_offset/exporters/exporter.py create mode 100644 src/laser_offset/exporters/svg_exporter.py create mode 100644 src/laser_offset/file_converters/file_converter.py create mode 100644 src/laser_offset/file_converters/folder_converter.py create mode 100644 src/laser_offset/geometry_2d/angle_range.py create mode 100644 src/laser_offset/geometry_2d/arc_info.py create mode 100644 src/laser_offset/geometry_2d/arc_segment_2d.py create mode 100644 src/laser_offset/geometry_2d/bounds2d.py create mode 100644 src/laser_offset/geometry_2d/canvas2d.py create mode 100644 src/laser_offset/geometry_2d/fill_style.py create mode 100644 src/laser_offset/geometry_2d/normalize_angle.py create mode 100644 src/laser_offset/geometry_2d/point2d.py create mode 100644 src/laser_offset/geometry_2d/segment_2d.py create mode 100644 src/laser_offset/geometry_2d/shape2d.py create mode 100644 src/laser_offset/geometry_2d/shapes_2d/arc2d.py create mode 100644 src/laser_offset/geometry_2d/shapes_2d/bezier2d.py create mode 100644 src/laser_offset/geometry_2d/shapes_2d/circle2d.py create mode 100644 src/laser_offset/geometry_2d/shapes_2d/ellipse2d.py create mode 100644 src/laser_offset/geometry_2d/shapes_2d/line2d.py create mode 100644 src/laser_offset/geometry_2d/shapes_2d/path2d.py create mode 100644 src/laser_offset/geometry_2d/shapes_2d/polygon2d.py create mode 100644 src/laser_offset/geometry_2d/shapes_2d/polyline2d.py create mode 100644 src/laser_offset/geometry_2d/shapes_2d/rect2d.py create mode 100644 src/laser_offset/geometry_2d/size2d.py create mode 100644 src/laser_offset/geometry_2d/stroke_style.py create mode 100644 src/laser_offset/geometry_2d/style2d.py create mode 100644 src/laser_offset/geometry_2d/vector2d.py create mode 100644 src/laser_offset/importers/dxf_importer.py create mode 100644 src/laser_offset/importers/importer.py create mode 100644 src/laser_offset/importers/svg_importer.py create mode 100644 src/laser_offset/math/float_functions.py create mode 100644 src/laser_offset/modifiers/expand.py create mode 100644 src/laser_offset/modifiers/modifier.py create mode 100644 src/laser_offset/modifiers/polygon_data.py create mode 100644 src/laser_offset/modifiers/segment_operations.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b6e4761 --- /dev/null +++ b/.gitignore @@ -0,0 +1,129 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..bb95e09 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,33 @@ +{ + // Используйте IntelliSense, чтобы узнать о возможных атрибутах. + // Наведите указатель мыши, чтобы просмотреть описания существующих атрибутов. + // Для получения дополнительной информации посетите: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Отладка Laser Offset CLI", + "type": "python", + "request": "launch", + "module": "laser_offset", + "justMyCode": true, + "cwd": "${workspaceFolder}/src", + "args": [ + "~/Projects/DXF_Test", + "~/Projects/DXF_Test/Output", + "300", + "--svg" + ] + }, + { + "name": "Laser Offset CLI HELP", + "type": "python", + "request": "launch", + "module": "laser_offset", + "justMyCode": true, + "cwd": "${workspaceFolder}/src", + "args": [ + "--help" + ] + } + ] +} \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9a12ca0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Andrey Zarembo-Godzyatskiy + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Readme.rst b/Readme.rst new file mode 100644 index 0000000..afef33d --- /dev/null +++ b/Readme.rst @@ -0,0 +1,49 @@ +Laser Offset +============ + +Simple Tools that creates external and internal offsets for shapes. Supports DXF and SVG Import/Export. + +.. note:: + + This project is in early alpha and was created as side-project for hobby. + +It has `Limitations`_ + +Installing +---------- + +Install and update using `pip`_: + +.. code-block:: text + + $ pip install -U laser_offset + +.. _pip: https://pip.pypa.io/en/stable/getting-started/ + +Usage +----- + +.. code-block:: text + + Usage: laser_offset [OPTIONS] SOURCE_PATH TARGET_PATH LASER_WIDTH + + Creates new drawings from DXFs with outer and inner offset lines by + LASER_WIDTH in μm(microns) ans save them into TARGET_PATH as SVG or DXF. + + SOURCE_PATH is folder with DXFs for batch convertion + + TARGET_PATH is folder for results + + LASER_WIDTH is beam diameter in μm(microns) from 1 to 999 + + Options: + -s, --svg Output as SVG [default: (False)] + -d, --dxf / -D, --no-dxf Output as DXF [default: (True)] + --help Show this message and exit. + +Limitations +----------- + +1. Not working with splines. Only circles and paths. And can merge lines and arcs into path if they has connected ends +2. Can't handle arcs less that half of laser width +3. Ignores layers, shape size and styles diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d0c3cbf --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..747ffb7 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..3b54a40 --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,40 @@ +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information + +project = 'Laser Offset' +copyright = '2022, Andrey Zarembo-Godzyatskiy' +author = 'Andrey Zarembo-Godzyatskiy' +release = '0.1.0' + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = [ + 'sphinx.ext.autodoc' +] + +templates_path = ['_templates'] +exclude_patterns = [] + + + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +html_theme = 'alabaster' +html_static_path = ['_static'] +html_theme_options = { + # Disable showing the sidebar. Defaults to 'false' + 'nosidebar': True, + 'show_powered_by': False, + 'github_user': 'AndreyZarembo', + 'github_repo': 'LaserOffset', + 'github_banner': True, + 'show_related': False, + 'note_bg': '#FFF59C' +} \ No newline at end of file diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..866d39e --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,8 @@ +.. rst-class:: hide-header + +.. Laser Offset documentation master file, created by + sphinx-quickstart on Tue Dec 20 22:52:07 2022. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +.. include:: ../../Readme.rst diff --git a/laser_offset.code-workspace b/laser_offset.code-workspace new file mode 100644 index 0000000..876a149 --- /dev/null +++ b/laser_offset.code-workspace @@ -0,0 +1,8 @@ +{ + "folders": [ + { + "path": "." + } + ], + "settings": {} +} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7604879 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,21 @@ +[project] +name = "laser_offset" +version = "0.1.0" +authors = [ + { name="Andrey Zarembo-Godzyatskiy", email="andrey.zarembo@gmail.com" }, +] +description = "Simple Tools that creates external and internal offsets for shapes. Supports DXF and SVG Import/Export." +readme = "README.md" +requires-python = ">=3.7" +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", +] + +[project.urls] +"Homepage" = "https://github.com/AndreyZarembo/LaserOffset" +"Bug Tracker" = "https://github.com/AndreyZarembo/LaserOffset/issues" + +[project.scripts] +laser_offset = "laser_offset:laser_offset" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6a8e8f8 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +ezdxf~=0.18 +click~=8.1 diff --git a/src/laser_offset/__init__.py b/src/laser_offset/__init__.py new file mode 100644 index 0000000..733b780 --- /dev/null +++ b/src/laser_offset/__init__.py @@ -0,0 +1,7 @@ +from laser_offset.cli.laser_offset_cli import laser_offset + +def run_laser_offset(): + laser_offset() + +if __name__ == '__main__': + run_laser_offset() \ No newline at end of file diff --git a/src/laser_offset/__main__.py b/src/laser_offset/__main__.py new file mode 100644 index 0000000..628b306 --- /dev/null +++ b/src/laser_offset/__main__.py @@ -0,0 +1,4 @@ +from laser_offset.cli.laser_offset_cli import laser_offset + +if __name__ == '__main__': + laser_offset() \ No newline at end of file diff --git a/src/laser_offset/cli/laser_offset_cli.py b/src/laser_offset/cli/laser_offset_cli.py new file mode 100644 index 0000000..1e3168c --- /dev/null +++ b/src/laser_offset/cli/laser_offset_cli.py @@ -0,0 +1,37 @@ +import click +from typing import List +from laser_offset.file_converters.folder_converter import FolderConverter + +@click.command() +@click.argument('source_path', type=click.Path(exists=True, file_okay=False, dir_okay=True, readable=True, resolve_path=True, allow_dash=True)) +@click.argument('target_path', type=click.Path(exists=False, file_okay=False, dir_okay=True, writable=True, resolve_path=True, allow_dash=True)) +@click.argument('laser_width', type=click.IntRange(min=1, max=999)) +@click.option('--svg', '-s', default=False, is_flag=True, show_default="False", help="Output as SVG") +@click.option('--dxf/--no-dxf', '-d/-D', default=True, show_default="True", help="Output as DXF") +def laser_offset(source_path, target_path, laser_width, svg, dxf): + """Creates new drawings from DXFs with outer and inner offset lines by LASER_WIDTH in μm(microns) ans save them into TARGET_PATH as SVG or DXF. + + SOURCE_PATH is folder with DXFs for batch convertion + + TARGET_PATH is folder for results + + LASER_WIDTH is beam diameter in μm(microns) from 1 to 999 + """ + + print(source_path, target_path, laser_width, svg, dxf) + converter = FolderConverter(source_path, target_path, laser_width / 2000.0, None, svg, dxf) + + files_to_convert = converter.files_to_convert + click.echo('\nFiles to convert: ') + for file_to_convert in files_to_convert: + click.echo(f"\t{click.format_filename(file_to_convert)}") + + click.echo('\nConverting files...\n') + + def convertion_callback(file_name: str, full_path: str, target_files: List[str], completed: bool, index: int, total: int): + if completed and target_files.__len__() > 0: + click.echo(f"[ {index+1} / {total} ] {file_name}") + for target in target_files: + click.echo(f"\t{target}") + + converter.convert(convertion_callback) diff --git a/src/laser_offset/exporters/dxf_exporter.py b/src/laser_offset/exporters/dxf_exporter.py new file mode 100644 index 0000000..b8719db --- /dev/null +++ b/src/laser_offset/exporters/dxf_exporter.py @@ -0,0 +1,129 @@ +import ezdxf +from ezdxf.document import Drawing +from ezdxf.layouts.layout import Modelspace +import math + +from abc import ABC +from codecs import StreamWriter +from typing import Dict, List, Tuple +from laser_offset.exporters.exporter import Exporter + +from laser_offset.geometry_2d.canvas2d import Canvas2d +from laser_offset.geometry_2d.point2d import Point2d +from laser_offset.geometry_2d.shape2d import Shape2d +from laser_offset.geometry_2d.shapes_2d.line2d import Line2d +from laser_offset.geometry_2d.shapes_2d.circle2d import Circle2d +from laser_offset.geometry_2d.shapes_2d.ellipse2d import Ellipse2d +from laser_offset.geometry_2d.shapes_2d.path2d import HorizontalLine, Line, MoveOrigin, Path2d, RelHorizontalLine, RelLine, RelMoveOrigin, RelVerticalLine, SimpleArc, VerticalLine, HorizontalLine, ClosePath, CubicBezier, RelCubicBezier, Quadratic, RelQuadratic, ReflectedQuadratic, RelReflectedQuadratic, Arc, RelArc, StrugBezier, RelStrugBezier +from laser_offset.geometry_2d.shapes_2d.polygon2d import Polygon2d +from laser_offset.geometry_2d.shapes_2d.polyline2d import Polyline2d +from laser_offset.geometry_2d.shapes_2d.rect2d import Rect2d + +class DXFExporter(Exporter): + + def export_canvas(self, canvas: Canvas2d, match_size: bool, stream: StreamWriter): + + doc: Drawing = ezdxf.new(dxfversion="R2010") + model_space: Modelspace = doc.modelspace() + + layer_name = "LASERCUT" + + doc.layers.add(layer_name, color=7) + dxf_layer_attribs = {"layer": layer_name} + + for shape in canvas.shapes: + self.write_shape(shape, model_space, dxf_layer_attribs) + + doc.write(stream) + + def write_shape(self, shape: Shape2d, model_space: Modelspace, dxf_layer_attribs: Dict[str, str]): + if isinstance(shape, Line2d): + self.write_line(shape, model_space, dxf_layer_attribs) + elif isinstance(shape, Circle2d): + self.write_circle(shape, model_space, dxf_layer_attribs) + elif isinstance(shape, Ellipse2d): + self.write_ellipse(shape, model_space, dxf_layer_attribs) + elif isinstance(shape, Polyline2d): + self.write_polyline(shape, model_space, dxf_layer_attribs) + elif isinstance(shape, Polygon2d): + self.write_polygon(shape, model_space, dxf_layer_attribs) + elif isinstance(shape, Path2d): + self.write_path(shape, model_space, dxf_layer_attribs) + + def write_line(self, line: Line2d, model_space: Modelspace, dxf_layer_attribs: Dict[str, str]): + model_space.add_line((line.start.x, -line.start.y), (line.end.x, line.end.y), dxfattribs=dxf_layer_attribs) + + def write_circle(self, circle: Circle2d, model_space: Modelspace, dxf_layer_attribs: Dict[str, str]): + model_space.add_circle((circle.center.x, -circle.center.y), circle.radius, dxfattribs=dxf_layer_attribs) + + def write_ellipse(self, ellipse: Ellipse2d, model_space: Modelspace, dxf_layer_attribs: Dict[str, str]): + model_space.add_ellipse((ellipse.center.x, -ellipse.center.y), major_axis=(0, ellipse.radiuses.height, 0), ratio=ellipse.radiuses.width/ellipse.radiuses.height, dxfattribs=dxf_layer_attribs) + + def write_polyline(self, polyline: Polyline2d, model_space: Modelspace, dxf_layer_attribs: Dict[str, str]): + points = list(map(lambda point: (point.x, -point.y), polyline.points)) + model_space.add_polyline2d(points, close=False, dxfattribs=dxf_layer_attribs) + + def write_polygon(self, polygon: Polygon2d, model_space: Modelspace, dxf_layer_attribs: Dict[str, str]): + points = list(map(lambda point: (point.x, -point.y), polygon.points)) + model_space.add_polyline2d(points, close=True, dxfattribs=dxf_layer_attribs) + + def write_path(self, path: Path2d, model_space: Modelspace, dxf_layer_attribs: Dict[str, str]): + + polyline_points: List[Tuple[float, float, float, float, float]] = list() + for index, component in enumerate(path.components): + + if isinstance(component, MoveOrigin): + polyline_points.append(self.lw_polyline_point_from_move(component)) + elif isinstance(component, Line): + polyline_points.append(self.lw_polyline_point_from_line(component)) + elif isinstance(component, SimpleArc): + polyline_points.append(self.lw_polyline_point_from_arc(component)) + + if index == 0: + continue + + if isinstance(component, SimpleArc): + prev_point = polyline_points[index-1] + bulge = self.lw_poly_bulge_from_arc(component, prev_point) + polyline_points[index-1] = (prev_point[0], prev_point[1], prev_point[2], prev_point[2], bulge) + + model_space.add_lwpolyline(self.flip_y(polyline_points), dxfattribs=dxf_layer_attribs) + + def flip_y(self, input_points: List[Tuple[float, float, float, float, float]]) -> List[Tuple[float, float, float, float, float]]: + polyline_points: List[Tuple[float, float, float, float, float]] = list() + for point in input_points: + polyline_points.append((point[0], -point[1], point[2], point[3], -point[4])) + return polyline_points + + def lw_polyline_point_from_move(self, move: MoveOrigin) -> Tuple[float, float, float, float, float]: + return (move.target.x, move.target.y, 0, 0, 0) + + def lw_polyline_point_from_line(self, line: Line) -> Tuple[float, float, float, float, float]: + return (line.target.x, line.target.y, 0, 0, 0) + + def lw_polyline_point_from_arc(self, arc: SimpleArc) -> Tuple[float, float, float, float, float]: + return (arc.target.x, arc.target.y, 0, 0, 0.0) + + def lw_poly_bulge_from_arc(self, arc: SimpleArc, prev_point: Tuple[float, float, float, float, float]) -> float: + dx: float = prev_point[0] - arc.target.x + dy: float = prev_point[1] - arc.target.y + l: float = math.sqrt( dx ** 2 + dy ** 2) + if arc.radius < l/2: + return 0 + + r: float = arc.radius + sagitta_a: float = r + math.sqrt(r ** 2 - l ** 2 / 4 ) + sagitta_b: float = r - math.sqrt(r ** 2 - l ** 2 / 4 ) + + bulge_a = 2 * sagitta_a / l + bulge_b = 2 * sagitta_b / l + + dir = -1 if arc.cw_direction else 1 + + if arc.large_arc: + return max(bulge_a, bulge_b) * dir + else: + return min(bulge_a, bulge_b) * dir + + # def write_rect(self, rect: Rect2d, model_space: GraphicsFactory, dxf_layer_attribs: Dict[str, str]): + # model_space.add_shape() \ No newline at end of file diff --git a/src/laser_offset/exporters/exporter.py b/src/laser_offset/exporters/exporter.py new file mode 100644 index 0000000..4ce5998 --- /dev/null +++ b/src/laser_offset/exporters/exporter.py @@ -0,0 +1,10 @@ +from abc import ABC +from codecs import StreamWriter + +from laser_offset.geometry_2d.canvas2d import Canvas2d + + +class Exporter(ABC): + + def export_canvas(self, canvas: Canvas2d, match_size: bool, stream: StreamWriter): + raise RuntimeError("Not Implemented") \ No newline at end of file diff --git a/src/laser_offset/exporters/svg_exporter.py b/src/laser_offset/exporters/svg_exporter.py new file mode 100644 index 0000000..b43261a --- /dev/null +++ b/src/laser_offset/exporters/svg_exporter.py @@ -0,0 +1,417 @@ +from abc import ABC +from codecs import StreamWriter +import re +from turtle import st, width +from typing import Dict, List +from laser_offset.exporters.exporter import Exporter +import math + +from laser_offset.geometry_2d.canvas2d import Canvas2d +from laser_offset.geometry_2d.point2d import Point2d +from laser_offset.geometry_2d.shape2d import Shape2d +from laser_offset.geometry_2d.shapes_2d.line2d import Line2d +from laser_offset.geometry_2d.shapes_2d.circle2d import Circle2d +from laser_offset.geometry_2d.shapes_2d.ellipse2d import Ellipse2d +from laser_offset.geometry_2d.shapes_2d.path2d import HorizontalLine, Line, MoveOrigin, Path2d, RelHorizontalLine, RelLine, RelMoveOrigin, RelSimpleArc, RelVerticalLine, SimpleArc, VerticalLine, HorizontalLine, ClosePath, CubicBezier, RelCubicBezier, Quadratic, RelQuadratic, ReflectedQuadratic, RelReflectedQuadratic, Arc, RelArc, StrugBezier, RelStrugBezier +from laser_offset.geometry_2d.shapes_2d.polygon2d import Polygon2d +from laser_offset.geometry_2d.shapes_2d.polyline2d import Polyline2d +from laser_offset.geometry_2d.shapes_2d.rect2d import Rect2d + + +class SVGTag: + tag: str + attributes: Dict[str, str] + content: List['SVGTag'] + + def __init__(self, + tag: str, + attributes: Dict[str, str], + content: List['SVGTag'] = list() + ) -> None: + self.tag = tag + self.attributes = attributes + self.content = content + + def merge_attributes(self, attributes: Dict[str, str]) -> 'SVGTag': + + merged_attributes = self.attributes.copy() + for key, val in attributes.items(): + merged_attributes[key] = val + + return SVGTag( + self.tag, + merged_attributes, + self.content + ) + + +class SVGExporter(Exporter): + + units: str + scale: float + skip_xml: bool + + def __init__(self, units: str = "mm", scale: float = 3.7795*0.75, skip_xml: bool = False): + super().__init__() + self.units = units + self.scale = scale + self.skip_xml = skip_xml + + def export_canvas(self, canvas: Canvas2d, match_size: bool, stream: StreamWriter): + + viewboxWidth = 100 + + svg_content = """{xml_header} + + {lines} + + """.format( + xml_header='' if not self.skip_xml else '', + viewbox_size=" ".join(map(lambda n: self.convert_number(n), [ + -canvas.size.width/2, + -canvas.size.height/2, + canvas.size.width, + canvas.size.height + ])), + width=self.convert_number(canvas.size.width, use_units=True), + height=self.convert_number(canvas.size.height, use_units=True), + lines="\n".join( + map( + lambda shape: "\t"+self.export_shape(shape), + canvas.shapes + ))) + + stream.write(svg_content) + + def export_shape(self, shape: Shape2d) -> str: + stroke_attributes = self.stroke_attributes(shape) + shape_tag = self.shape_to_tag(shape) + result_tag = shape_tag.merge_attributes(stroke_attributes) + result = self.export_tag(result_tag) + return result + + + def stroke_attributes(self, shape: Shape2d) -> Dict[str, str]: + result: Dict[str, str] = dict() + result["stroke"] = shape.style.stroke_style.color + result["stroke-width"] = shape.style.stroke_style.width + result["fill"] = shape.style.fill_style.color + return result + + def shape_to_tag(self, shape: Shape2d) -> SVGTag: + if isinstance(shape, Rect2d): + return self.rectange_to_tag(shape) + elif isinstance(shape, Line2d): + return self.line_to_tag(shape) + elif isinstance(shape, Circle2d): + return self.circle_to_tag(shape) + elif isinstance(shape, Ellipse2d): + return self.ellipse_to_tag(shape) + elif isinstance(shape, Polyline2d): + return self.polyline_to_tag(shape) + elif isinstance(shape, Polygon2d): + return self.polygon_to_tag(shape) + elif isinstance(shape, Path2d): + return self.path_to_tag(shape) + else: + print("Unknown shape type ", type(shape), " ",shape) + + + def export_tag(self, tag: SVGTag) -> str: + attributes_list = list(map(lambda key_val: '{attribute}="{value}"'.format( + attribute=key_val[0], + value=key_val[1] + ), tag.attributes.items() )) + + if tag.content.__len__() == 0: + return "<{tag_name} {attributes}/>".format(tag_name=tag.tag, attributes=" ".join(attributes_list)) + else: + return """ + <{tag_name} {attributes}> + + + """.format(tag_name=tag.tag, attributes=" ".join(attributes_list)) + + def rect_to_tag(self, rect: Rect2d) -> SVGTag: + return SVGTag( + "rectangle", + { + "x": self.convert_number(rect.left_top.x), + "y": self.convert_number(rect.left_top.y), + "width": self.convert_number((rect.right_bottom.x - rect.left_top.x)), + "height": self.convert_number((rect.right_bottom.y - rect.left_top.y)), + "rx": self.convert_number(rect.corner_radius), + "ry": self.convert_number(rect.corner_radius) + } + ) + + def line_to_tag(self, line: Line2d) -> SVGTag: + return SVGTag( + "line", + { + "x1": self.convert_number(line.start.x), + "y1": self.convert_number(line.start.y), + "x2": self.convert_number(line.end.x), + "y2": self.convert_number(line.end.y) + } + ) + + def circle_to_tag(self, circle: Circle2d) -> SVGTag: + return SVGTag( + "circle", + { + "cx": self.convert_number(circle.center.x), + "cy": self.convert_number(circle.center.y), + "r": self.convert_number(circle.radius), + } + ) + + def ellipse_to_tag(self, ellipse: Ellipse2d) -> SVGTag: + return SVGTag( + "ellipse", + { + "cx": self.convert_number(ellipse.center.x), + "cy": self.convert_number(ellipse.center.y), + "rx": self.convert_number(ellipse.radiuses.width), + "ry": self.convert_number(ellipse.radiuses.height), + } + ) + + def polyline_to_tag(self, polyline: Polyline2d) -> SVGTag: + return SVGTag( + "polyline", + { + "points": " ".join(map(lambda point: "{x}, {y}".format( + x=self.convert_number(point.x), + y=self.convert_number(point.y), + ), polyline.points)) + } + ) + + def polygon_to_tag(self, polygon: Polygon2d) -> SVGTag: + return SVGTag( + "polygon", + { + "points": " ".join(map(lambda point: "{x}, {y}".format( + x=self.convert_number(point.x), + y=self.convert_number(point.y), + ), polygon.points)) + } + ) + + def path_to_tag(self, path: Path2d) -> SVGTag: + prevPoint: Point2d = None + path_definition = " ".join(map(lambda component: self.component_to_svg(component, prevPoint), path.components)) + return SVGTag( + "path", + { + "d": path_definition + } + ) + + def component_to_svg(self, component: Shape2d, prevPoint: Point2d) -> str: + if isinstance(component, MoveOrigin): + return self.move_origin_to_svg(component) + elif isinstance(component, RelMoveOrigin): + return self.rel_move_origin_to_svg(component) + elif isinstance(component, HorizontalLine): + return self.hor_line_to_svg(component) + elif isinstance(component, RelHorizontalLine): + return self.rel_hor_line_to_svg(component) + elif isinstance(component, VerticalLine): + return self.ver_line_to_svg(component) + elif isinstance(component, RelVerticalLine): + return self.rel_ver_line_to_svg(component) + elif isinstance(component, Line): + return self.line_to_svg(component) + elif isinstance(component, RelLine): + return self.rel_line_to_svg(component) + elif isinstance(component, StrugBezier): + return self.strug_to_svg(component) + elif isinstance(component, RelStrugBezier): + return self.rel_strug_to_svg(component) + elif isinstance(component, CubicBezier): + return self.cubic_bezier_to_svg(component) + elif isinstance(component, RelCubicBezier): + return self.rel_cubic_bezier_to_svg(component) + elif isinstance(component, Quadratic): + return self.quadratic_to_svg(component) + elif isinstance(component, RelQuadratic): + return self.rel_quadratic_to_svg(component) + elif isinstance(component, ReflectedQuadratic): + return self.reflected_quadratic_to_svg(component) + elif isinstance(component, RelReflectedQuadratic): + return self.rel_reflected_quadratic_to_svg(component) + elif isinstance(component, Arc): + return self.arc_to_svg(component) + elif isinstance(component, RelArc): + return self.rel_arc_to_svg(component) + elif isinstance(component, SimpleArc): + return self.simple_arc_to_svg(component) + elif isinstance(component, RelSimpleArc): + return self.rel_simple_arc_to_svg(component) + elif isinstance(component, ClosePath): + return self.close_path_to_svg(component) + else: + raise RuntimeError("Not Implemented") + + + + def move_origin_to_svg(self, moveOrigin: MoveOrigin) -> str: + return "M{x}, {y}".format( + x=self.convert_number(moveOrigin.target.x), + y=self.convert_number(moveOrigin.target.y) + ) + + def rel_move_origin_to_svg(self, relMoveOrigin: RelMoveOrigin) -> str: + return "m{x}, {y}".format( + x=self.convert_number(relMoveOrigin.target.dx), + y=self.convert_number(relMoveOrigin.target.dy) + ) + + def hor_line_to_svg(self, horLine: HorizontalLine) -> str: + return "H{x}".format( + x=self.convert_number(horLine.length) + ) + + def rel_hor_line_to_svg(self, horLine: RelHorizontalLine) -> str: + return "h{x}".format( + x=self.convert_number(horLine.length) + ) + + def ver_line_to_svg(self, verLine: VerticalLine) -> str: + return "V{x}".format( + x=self.convert_number(verLine.length) + ) + + def rel_ver_line_to_svg(self, verLine: RelVerticalLine) -> str: + return "v{x}".format( + x=self.convert_number(verLine.length) + ) + + def line_to_svg(self, line: Line) -> str: + return "L{x}, {y}".format( + x=self.convert_number(line.target.x), + y=self.convert_number(line.target.y) + ) + + def rel_line_to_svg(self, line: RelLine) -> str: + return "l{x}, {y}".format( + x=self.convert_number(line.target.dx), + y=self.convert_number(line.target.dy) + ) + + def cubic_bezier_to_svg(self, cubic_bezier: CubicBezier) -> str: + return "C{x1} {y1}, {x2} {y2} {x} {y}".format( + x1=self.convert_number(cubic_bezier.startControlPoint.x), + y1=self.convert_number(cubic_bezier.startControlPoint.y), + x2=self.convert_number(cubic_bezier.endControlPoint.x), + y2=self.convert_number(cubic_bezier.endControlPoint.y), + x=self.convert_number(cubic_bezier.target.x), + y=self.convert_number(cubic_bezier.target.y) + ) + + def rel_cubic_bezier_to_svg(self, rel_cubic_bezier: RelCubicBezier) -> str: + return "c{dx1} {dy1}, {dx2} {dy2} {dx} {dy}".format( + dx1=self.convert_number(rel_cubic_bezier.startControlPoint.dx), + dy1=self.convert_number(rel_cubic_bezier.startControlPoint.dy), + dx2=self.convert_number(rel_cubic_bezier.endControlPoint.dx), + dy2=self.convert_number(rel_cubic_bezier.endControlPoint.dy), + dx=self.convert_number(rel_cubic_bezier.target.dx), + dy=self.convert_number(rel_cubic_bezier.target.dy) + ) + + def strug_to_svg(self, strug_bezier: StrugBezier) -> str: + return "S{x2} {y2} {x} {y}".format( + x2=self.convert_number(strug_bezier.endControlPoint.x), + y2=self.convert_number(strug_bezier.endControlPoint.y), + x=self.convert_number(strug_bezier.target.x), + y=self.convert_number(strug_bezier.target.y) + ) + + def rel_strug_to_svg(self, rel_strug_bezier: RelStrugBezier) -> str: + return "s{dx2} {dy2} {dx} {dy}".format( + dx2=self.convert_number(rel_strug_bezier.endControlPoint.dx), + dy2=self.convert_number(rel_strug_bezier.endControlPoint.dy), + dx=self.convert_number(rel_strug_bezier.target.dx), + dy=self.convert_number(rel_strug_bezier.target.dy) + ) + + def quadratic_to_svg(self, quadratic: Quadratic) -> str: + return "Q{x1} {y1}, {x2} {y2} {x} {y}".format( + x1=self.convert_number(quadratic.controlPoint.x), + y1=self.convert_number(quadratic.controlPoint.y), + x=self.convert_number(quadratic.target.x), + y=self.convert_number(quadratic.target.y) + ) + + def rel_quadratic_to_svg(self, rel_quadratic: RelQuadratic) -> str: + return "q{dx1} {dy1}, {dx2} {dy2} {dx} {dy}".format( + dx1=self.convert_number(rel_quadratic.controlPoint.dx), + dy1=self.convert_number(rel_quadratic.controlPoint.dy), + dx=self.convert_number(rel_quadratic.target.dx), + dy=self.convert_number(rel_quadratic.target.dy) + ) + + + def reflected_quadratic_to_svg(self, reflected_quadratic: ReflectedQuadratic) -> str: + return "T{x} {y}".format( + x=self.convert_number(reflected_quadratic.target.x), + y=self.convert_number(reflected_quadratic.target.y) + ) + + def rel_reflected_quadratic_to_svg(self, rel_reflected_quadratic: RelReflectedQuadratic) -> str: + return "t{dx} {dy}".format( + dx=self.convert_number(rel_reflected_quadratic.target.dx), + dy=self.convert_number(rel_reflected_quadratic.target.dy) + ) + + def arc_to_svg(self, arc: Arc) -> str: + return "A{rx} {ry} {x_axis_rotation} {large_arc_flag} {sweep_flag} {x} {y}".format( + rx=self.convert_number(arc.radiuses.width), + ry=self.convert_number(arc.radiuses.height), + x_axis_rotation=math.degrees(arc.angle), + large_arc_flag=1 if arc.large_arc else 0, + sweep_flag=1 if arc.sweep else 0, + x=self.convert_number(arc.target.x), + y=self.convert_number(arc.target.y) + ) + + def rel_arc_to_svg(self, rel_arc: RelArc) -> str: + return "a{rx} {ry} {x_axis_rotation} {large_arc_flag} {sweep_flag} {dx} {dy}".format( + rx=self.convert_number(rel_arc.radiuses.width), + ry=self.convert_number(rel_arc.radiuses.height), + x_axis_rotation=math.degrees(rel_arc.angle), + large_arc_flag=1 if rel_arc.large_arc else 0, + sweep_flag=1 if rel_arc.sweep else 0, + dx=self.convert_number(rel_arc.target.dx), + dy=self.convert_number(rel_arc.target.dy) + ) + + def simple_arc_to_svg(self, arc: SimpleArc) -> str: + return "A{rx} {ry} {x_axis_rotation} {large_arc_flag} {sweep_flag} {x} {y}".format( + rx=self.convert_number(arc.radius), + ry=self.convert_number(arc.radius), + x_axis_rotation=0, + large_arc_flag=1 if arc.large_arc else 0, + sweep_flag=0 if arc.cw_direction else 1, + x=self.convert_number(arc.target.x), + y=self.convert_number(arc.target.y) + ) + + def rel_simple_arc_to_svg(self, rel_arc: RelSimpleArc) -> str: + return "a{rx} {ry} {x_axis_rotation} {large_arc_flag} {sweep_flag} {x} {y}".format( + rx=self.convert_number(rel_arc.radius), + ry=self.convert_number(rel_arc.radius), + x_axis_rotation=0, + large_arc_flag=1 if rel_arc.large_arc else 0, + sweep_flag=1 if rel_arc.cw_direction else 0, + x=self.convert_number(rel_arc.target.dx), + y=self.convert_number(rel_arc.target.dy) + ) + + def close_path_to_svg(self, close_path: ClosePath) -> str: + return "Z".format() + + def convert_number(self, number: float, use_units: bool = False, use_scale: bool = True) -> str: + return "{value:.4f}{units}".format(value=number * self.scale if use_scale else 1, units=self.units if use_units else "") \ No newline at end of file diff --git a/src/laser_offset/file_converters/file_converter.py b/src/laser_offset/file_converters/file_converter.py new file mode 100644 index 0000000..b1af57a --- /dev/null +++ b/src/laser_offset/file_converters/file_converter.py @@ -0,0 +1,104 @@ +from typing import List, Optional, Protocol +from pathlib import Path + +from laser_offset.exporters.dxf_exporter import DXFExporter +from laser_offset.exporters.svg_exporter import SVGExporter +from laser_offset.importers.dxf_importer import DXFImporter +from laser_offset.importers.svg_importer import SVGImporter + +from laser_offset.modifiers.modifier import Modifier +from laser_offset.modifiers.expand import Expand + +from laser_offset.geometry_2d.canvas2d import Canvas2d + +class FileConverter: + + source_folder: str + target_folder: str + + create_svg: bool + create_dxf: bool + + laser_beam_width: float + + dxf_importer: DXFImporter + svg_importer: SVGImporter + + expand_modifier: Modifier + + svg_exporter: SVGExporter + dxf_exporter: DXFExporter + + def __init__(self, + source_folder: str, + target_folder: str, + laser_beam_width: float, + file_list: Optional[List[str]] = None, + + create_svg: bool = False, + create_dxf: bool = True + ) -> None: + + self.laser_beam_width = laser_beam_width + + self.source_folder = source_folder + self.target_folder = target_folder + self.create_svg = create_svg + self.create_dxf = create_dxf + + self.dxf_importer = DXFImporter() + self.svg_importer = SVGImporter() + self.dxf_exporter = DXFExporter() + self.svg_exporter = SVGExporter() + + self.expand_modifier = Expand(self.laser_beam_width) + + def convert(self, file_name) -> List[str]: + + result: List[str] = list() + file_path = self.source_folder+"/"+file_name + + with open(file_path, "rt") as input: + + canvas = self.dxf_importer.import_canvas(False, input) + input.close() + result_canvas = self.convert_canvas(canvas) + + if self.create_svg: + target_svg_file = self.save_svg(result_canvas, file_name.replace('.dxf', '.svg').replace('.DXF', '.svg')) + result.append(target_svg_file) + + if self.create_dxf: + target_dxf_file = self.save_dxf(result_canvas, file_name) + result.append(target_dxf_file) + + return result + + def save_svg(self, canvas: Canvas2d, file_name: str) -> str: + target_path = self.target_folder + Path(target_path).mkdir(parents=True, exist_ok=True) + target_file = target_path+"/"+file_name.replace('.DXF', '.SVG').replace('.dxf', '.svg') + with open(target_file, "wt") as svg_output: + self.svg_exporter.export_canvas(canvas, True, svg_output) + return target_file + + def save_dxf(self, canvas: Canvas2d, file_name: str) -> str: + target_path = self.target_folder + Path(target_path).mkdir(parents=True, exist_ok=True) + target_file = target_path+"/"+file_name + with open(target_file, "wt") as dxf_output: + self.dxf_exporter.export_canvas(canvas, True, dxf_output) + return target_file + + def convert_canvas(self, canvas: Canvas2d) -> Canvas2d: + modified_canvas = self.modify_canvas(canvas) + expanded_canvas = self.expanded_canvas(modified_canvas) + return expanded_canvas + + def modify_canvas(self, canvas: Canvas2d) -> Canvas2d: + combined_canvas = canvas.cobineShapesToPaths() + return combined_canvas + + def expanded_canvas(self, canvas: Canvas2d) -> Canvas2d: + expanded_canvas = self.expand_modifier.modify(canvas) + return expanded_canvas \ No newline at end of file diff --git a/src/laser_offset/file_converters/folder_converter.py b/src/laser_offset/file_converters/folder_converter.py new file mode 100644 index 0000000..49b59ef --- /dev/null +++ b/src/laser_offset/file_converters/folder_converter.py @@ -0,0 +1,66 @@ +from typing import List, Optional, Protocol, NamedTuple +from pathlib import Path +import os + +from laser_offset.file_converters.file_converter import FileConverter + +class FileStatusCallback(Protocol): + def __call__(self, file_name: str, full_path: str, completed: bool, index: int, total: int) -> None: ... + +class FolderConverter: + + source_folder: str + target_folder: str + laser_beam_width: float + file_list: Optional[List[str]] = None + + create_svg: bool = False + create_dxf: bool = True + + file_converter: FileConverter + + def __init__(self, + source_folder: str, + target_folder: str, + laser_beam_width: float, + file_list: Optional[List[str]] = None, + + create_svg: bool = False, + create_dxf: bool = True + ) -> None: + + self.source_folder = source_folder + self.target_folder = target_folder + self.laser_beam_width = laser_beam_width + self.file_list = file_list + self.create_svg = create_svg + self.create_dxf = create_dxf + + self.file_converter = FileConverter( + source_folder = source_folder, + target_folder = target_folder, + create_dxf = create_dxf, + create_svg = create_svg, + laser_beam_width = laser_beam_width + ) + + @property + def files_to_convert(self) -> List[str]: + if self.file_list is not None: + return self.file_list + + else: + return list(filter(lambda file_name: file_name.lower().endswith(".dxf") and os.path.isfile(os.path.join(self.source_folder, file_name)), os.listdir(self.source_folder))) + + def convert(self, file_status_callback: FileStatusCallback) -> List[str]: + files_to_convert = self.files_to_convert + + files_count: int = files_to_convert.__len__() + + for index, file in enumerate(files_to_convert): + file_path = self.source_folder + "/" + file + file_status_callback(file, file_path, [], False, index, files_count) + + target_files = self.file_converter.convert(file) + + file_status_callback(file, file_path, target_files, True, index, files_count) diff --git a/src/laser_offset/geometry_2d/angle_range.py b/src/laser_offset/geometry_2d/angle_range.py new file mode 100644 index 0000000..30d1bf1 --- /dev/null +++ b/src/laser_offset/geometry_2d/angle_range.py @@ -0,0 +1,79 @@ +from typing import List, Tuple +from laser_offset.geometry_2d.normalize_angle import normalize_angle +from laser_offset.math.float_functions import fge, fzero, fclose, fle +import math + + +class AngleRange: + angle_ranges: List[Tuple[float, float]] + s_g_e: bool + s_uh: bool + e_uh: bool + fn: str + code: str + def __init__(self, start_angle: float, end_angle: float, cw: bool): + + nsa = normalize_angle(start_angle) + nea = normalize_angle(end_angle) + + self.fns = { + 'cw-0': self.cw_segment_with_zero, + 'cw': self.cw_segment, + 'ccw-0': self.ccw_segment_with_zero, + 'ccw': self.ccw_segment + } + + configs = [ + {'cw': True, 's_g_e': False, 's_uh': True, 'e_uh': True, 'fn': 'cw-0', 'code': '0'}, + {'cw': True, 's_g_e': True, 's_uh': True, 'e_uh': True, 'fn': 'cw', 'code': '1'}, + {'cw': True, 's_g_e': False, 's_uh': False, 'e_uh': False, 'fn': 'cw-0', 'code': '2'}, + {'cw': True, 's_g_e': True, 's_uh': False, 'e_uh': False, 'fn': 'cw', 'code': '3'}, + {'cw': True, 's_g_e': False, 's_uh': True, 'e_uh': False, 'fn': 'cw-0', 'code': '4'}, + {'cw': True, 's_g_e': True, 's_uh': False, 'e_uh': True, 'fn': 'cw', 'code': '5'}, + + {'cw': False, 's_g_e': False, 's_uh': True, 'e_uh': True, 'fn': 'ccw', 'code': '6'}, + {'cw': False, 's_g_e': True, 's_uh': True, 'e_uh': True, 'fn': 'ccw-0', 'code': '7'}, + {'cw': False, 's_g_e': False, 's_uh': False, 'e_uh': False, 'fn': 'ccw', 'code': '8'}, + {'cw': False, 's_g_e': True, 's_uh': False, 'e_uh': False, 'fn': 'ccw-0', 'code': '9'}, + {'cw': False, 's_g_e': False, 's_uh': True, 'e_uh': False, 'fn': 'ccw', 'code': 'A'}, + {'cw': False, 's_g_e': True, 's_uh': False, 'e_uh': True, 'fn': 'ccw-0', 'code': 'B'}, + ] + + s_g_e = nsa > nea + s_uh = fge(nsa, 0) and fle(nsa, math.pi) + e_uh = fge(nea, 0) and fle(nea, math.pi) + + self.s_g_e = s_g_e + self.s_uh = s_uh + self.e_uh = e_uh + self.fn = None + + for config in configs: + if config['cw'] == cw and config['s_g_e'] == s_g_e and config['s_uh'] == s_uh and config['e_uh'] == e_uh: + self.fn = config['fn'] + self.code = config['code'] + break + + if self.fn is None: + print("Something Wrong {sa} {ea} {cw}".format(sa=nsa, ea=nea, cw=cw)) + + + self.fns[self.fn](nsa, nea) + + def cw_segment(self, start_angle: float, end_angle: float): + self.angle_ranges = [(end_angle, start_angle)] + + def ccw_segment(self, start_angle: float, end_angle: float): + self.angle_ranges = [(start_angle, end_angle)] + + def cw_segment_with_zero(self, start_angle: float, end_angle: float): + self.angle_ranges = [(0, start_angle), (end_angle, 2 * math.pi)] + + def ccw_segment_with_zero(self, start_angle: float, end_angle: float): + self.angle_ranges = [(0, end_angle), (start_angle, 2 * math.pi)] + + def has_angle(self, angle: float) -> bool: + for angle_range in self.angle_ranges: + if fge(angle, angle_range[0]) and fle(angle, angle_range[1]): + return True + return False \ No newline at end of file diff --git a/src/laser_offset/geometry_2d/arc_info.py b/src/laser_offset/geometry_2d/arc_info.py new file mode 100644 index 0000000..cb27573 --- /dev/null +++ b/src/laser_offset/geometry_2d/arc_info.py @@ -0,0 +1,140 @@ +from laser_offset.geometry_2d.point2d import Point2d +from laser_offset.geometry_2d.vector2d import Vector2d +from laser_offset.geometry_2d.size2d import Size2d +from typing import NamedTuple +import math + +class ArcInfo(NamedTuple): + center: Point2d + startAngle: float + endAngle: float + deltaAngle: float + cw: bool + startVector: Vector2d + endVector: Vector2d + radius: float + + def __str__(self) -> str: + return "ArcInfo\n\tcenter:\t{center}\n\tstart angle:\t{sa}\n\tend angle:\t{ea}\n\tclockwise:\t{cw}\n\tradius:\t{r}".format( + center=self.center, + sa=self.startAngle, + ea=self.endAngle, + cw=self.cw, + r=self.radius + ) + + def fromArc(start_point: Point2d, end_point: Point2d, radius: float, large_arc: bool, cw_direction: bool) -> 'ArcInfo': + + def radian(ux: float, uy: float, vx: float, vy: float): + dot: float = ux * vx + uy * vy + mod: float = math.sqrt( ( ux * ux + uy * uy ) * ( vx * vx + vy * vy ) ) + rad: float = math.acos( dot / mod ) + if ux * vy - uy * vx < 0.0: + rad = -rad + + return rad + + x1: float = start_point.x + y1: float = start_point.y + rx: float = radius + ry: float = radius + phi: float = 0 + fA: float = not large_arc + fS: float = cw_direction + x2: float = end_point.x + y2: float = end_point.y + + PIx2: float = math.pi * 2.0 + #var cx, cy, startAngle, deltaAngle, endAngle; + if rx < 0: + rx = -rx + + if ry < 0: + ry = -ry + + if rx == 0.0 or ry == 0.0: + raise RuntimeError('Raidus can not be zero') + + + s_phi: float = math.sin(phi) + c_phi: float = math.cos(phi) + hd_x: float = (x1 - x2) / 2.0 # half diff of x + hd_y: float = (y1 - y2) / 2.0 # half diff of y + hs_x: float = (x1 + x2) / 2.0 # half sum of x + hs_y: float = (y1 + y2) / 2.0 # half sum of y + + # F6.5.1 + x1_: float = c_phi * hd_x + s_phi * hd_y + y1_: float = c_phi * hd_y - s_phi * hd_x + + # F.6.6 Correction of out-of-range radii + # Step 3: Ensure radii are large enough + lambda_: float = (x1_ * x1_) / (rx * rx) + (y1_ * y1_) / (ry * ry) + if lambda_ > 1: + rx = rx * math.sqrt(lambda_) + ry = ry * math.sqrt(lambda_) + + rxry: float = rx * ry + rxy1_: float = rx * y1_ + ryx1_: float = ry * x1_ + + sum_of_sq: float = rxy1_ * rxy1_ + ryx1_ * ryx1_ # sum of square + if sum_of_sq == 0: + raise RuntimeError('start point can not be same as end point ', start_point.__str__(), radius) + + coe: float = math.sqrt(abs((rxry * rxry - sum_of_sq) / sum_of_sq)) + if fA == fS: + coe = -coe + + # F6.5.2 + cx_: float = coe * rxy1_ / ry + cy_: float = -coe * ryx1_ / rx + + # F6.5.3 + cx = c_phi * cx_ - s_phi * cy_ + hs_x + cy = s_phi * cx_ + c_phi * cy_ + hs_y + + xcr1: float = (x1_ - cx_) / rx + xcr2: float = (x1_ + cx_) / rx + ycr1: float = (y1_ - cy_) / ry + ycr2: float = (y1_ + cy_) / ry + + # F6.5.5 + startAngle: float = radian(1.0, 0.0, xcr1, ycr1) + + # F6.5.6 + deltaAngle = radian(xcr1, ycr1, -xcr2, -ycr2) + while deltaAngle > PIx2: + deltaAngle -= PIx2 + + while deltaAngle < 0.0: + deltaAngle += PIx2 + + if fS == False or fS == 0: + deltaAngle -= PIx2 + + endAngle = startAngle + deltaAngle + while endAngle > PIx2: + endAngle -= PIx2 + while endAngle < 0.0: + endAngle += PIx2 + + rotationSign: float = 1 if fS else -1 + + angle_shift = math.pi/2 * (-1 if cw_direction else 1) + + startVector: Vector2d = Vector2d.polar(1, startAngle + angle_shift) + endVector: Vector2d = Vector2d.polar(1, endAngle + angle_shift) + + outputObj: ArcInfo = ArcInfo( + Point2d.cartesian(cx, cy), + startAngle, + endAngle, + deltaAngle, + fS == True or fS == 1, + startVector, + endVector, + radius + ) + + return outputObj diff --git a/src/laser_offset/geometry_2d/arc_segment_2d.py b/src/laser_offset/geometry_2d/arc_segment_2d.py new file mode 100644 index 0000000..f9a69d2 --- /dev/null +++ b/src/laser_offset/geometry_2d/arc_segment_2d.py @@ -0,0 +1,197 @@ +from laser_offset.geometry_2d.angle_range import AngleRange +from laser_offset.geometry_2d.point2d import Point2d +import math + +from laser_offset.geometry_2d.vector2d import Vector2d +from typing import List, Optional + +from laser_offset.geometry_2d.normalize_angle import normalize_angle +from laser_offset.math.float_functions import fclose +from laser_offset.geometry_2d.shapes_2d.path2d import Arc +from laser_offset.geometry_2d.arc_info import ArcInfo + +class ArcSegment2d: + + center: Point2d + radius: float + + start_angle: float + end_angle: float + + start: Point2d + end: Point2d + + clockwise: bool + + def __init__(self, center: Point2d, radius: float, start_angle: float, end_angle: float, clockwise: bool) -> None: + self.center = center + self.radius = radius + self.start_angle = start_angle + self.end_angle = end_angle + self.start = self.center + Vector2d.polar(radius, self.start_angle) + self.end = self.center + Vector2d.polar(radius, self.end_angle) + self.clockwise = clockwise + + @classmethod + def fromArc(cls, prev_point: Point2d, arc: Arc) -> 'ArcSegment2d': + arcinfo = ArcInfo.fromArc(prev_point, arc.target, arc.radius, arc.large_arc, arc.cw_direction) + return ArcSegment2d(arcinfo.center, arc.radius, arcinfo.startAngle, arcinfo.endAngle, arcinfo.cw) + + @property + def delta_angle(self) -> float: + return normalize_angle(self.end_angle - self.start_angle) + + @property + def length(self) -> float: + return self.radius * self.delta_angle + + def is_point_in_segment(self, point: 'Point2d') -> bool: + cp = Vector2d.fromTwoPoints(self.center, point) + + rng: AngleRange = AngleRange(self.start_angle, self.end_angle, self.clockwise) + return rng.has_angle(cp.da) + + def intersection(self, another) -> List[Point2d]: + + from laser_offset.geometry_2d.segment_2d import Segment2d + + if isinstance(another, Segment2d): + return self.intersction_segment(another) + + elif isinstance(another, ArcSegment2d): + return self.intersction_arc_segment(another) + + else: + return [] + + def intersction_segment(self, another: 'Segment2d') -> List[Point2d]: + + def is_point_on_arc_and_segment(point: Point2d) -> bool: + return self.is_point_in_segment(point) and another.is_point_on_segment(point) + + cO = self.center + r = self.radius + sA = another.start + sB = another.end + + As = sA.y - sB.y + Bs = sB.x - sA.x + Cs = sA.x * sB.y - sB.x * sA.y + + d = abs(As*cO.x + Bs*cO.y + Cs) / math.sqrt(As ** 2 + Bs ** 2) + + if d > r: + return [] + + Ap = -Bs + Bp = As + Cp = Bs * cO.x - As * cO.y + + A1 = Ap + B1 = Bp + C1 = Cp + + A2 = As + B2 = Bs + C2 = Cs + + H = Point2d.cartesian( + - (C1 * B2 - C2 * B1) / (A1 * B2 - A2 * B1), + - (A1 * C2 - A2 * C1) / (A1 * B2 - A2 * B1) + ) + + if abs(d - r) <= 1e-5 and is_point_on_arc_and_segment(H): + # Проверить, что H попадает на арку и отрезок + return [H] + else: + # Две точки + + l = math.sqrt( r ** 2 - d ** 2) + vA = Vector2d.fromTwoPoints(sB, sA) + vB = Vector2d.fromTwoPoints(sA, sB) + + p1 = H + vA.single_vector * l + p2 = H + vB.single_vector * l + + # print(H) + # print(p1) + # print(p2) + # print(vA) + # print(vA.single_vector) + # print(vB) + # print(vB.single_vector) + # print(l) + # return [H, sA, sB, p1, p2] + + result = list() + if is_point_on_arc_and_segment(p1): + result.append(p1) + if is_point_on_arc_and_segment(p2): + result.append(p2) + + return result + + return [] + + def intersction_arc_segment(self, another: 'ArcSegment2d') -> List[Point2d]: + + arc_a = self + arc_b = another + + r0 = arc_a.radius + r1 = arc_b.radius + + A = arc_a.center + B = arc_b.center + AB = A.distance(B) + + d = AB + + if d > r0 + r1: + return [] + elif (d < abs(r0 - r1)) or abs(d) <= 1e-5: + return [] + + a = (r0 ** 2 - r1 ** 2 + d ** 2) / (2 * d) + + if abs(d - (r0 + r1)) <= 1e-5 or fclose(abs(r0), abs(a)): + + H = Point2d.cartesian( + A.x + a*(B.x - A.x)/d, + A.y + a*(B.y - A.y)/d, + ) + + + if arc_a.is_point_in_segment(H) and arc_b.is_point_in_segment(H): + return [H] + else: + return [] + else: + + # print(r0, r1, a) + h = math.sqrt(r0 ** 2 - a ** 2) + + H = Point2d.cartesian( + A.x + a*(B.x - A.x)/d, + A.y + a*(B.y - A.y)/d, + ) + + point1_x = H.x + h * (B.y - A.y) / d + point1_y = H.y - h * (B.x - A.x) / d + point1 = Point2d.cartesian(point1_x, point1_y) + + point2_x = H.x - h * (B.y - A.y) / d + point2_y = H.y + h * (B.x - A.x) / d + point2 = Point2d.cartesian(point2_x, point2_y) + + result = list() + + if arc_a.is_point_in_segment(point1) and arc_b.is_point_in_segment(point1): + result.append(point1) + + if arc_a.is_point_in_segment(point2) and arc_b.is_point_in_segment(point2): + result.append(point2) + + return result + + return [] \ No newline at end of file diff --git a/src/laser_offset/geometry_2d/bounds2d.py b/src/laser_offset/geometry_2d/bounds2d.py new file mode 100644 index 0000000..d95c715 --- /dev/null +++ b/src/laser_offset/geometry_2d/bounds2d.py @@ -0,0 +1,7 @@ +from laser_offset.geometry_2d.size2d import Size2d + +class Bounds2d: + size: Size2d + + def __init__(self, size: Size2d) -> None: + pass \ No newline at end of file diff --git a/src/laser_offset/geometry_2d/canvas2d.py b/src/laser_offset/geometry_2d/canvas2d.py new file mode 100644 index 0000000..36d6a4e --- /dev/null +++ b/src/laser_offset/geometry_2d/canvas2d.py @@ -0,0 +1,112 @@ +from typing import List, Tuple +from laser_offset.geometry_2d.point2d import Point2d +from laser_offset.geometry_2d.size2d import Size2d +from laser_offset.geometry_2d.shape2d import Shape2d +from laser_offset.geometry_2d.shapes_2d.path2d import Path2d +from laser_offset.geometry_2d.shapes_2d.line2d import Line2d +from laser_offset.geometry_2d.shapes_2d.arc2d import Arc2d + +# Converts list of shapes to shape chains +def prepareShapes(shapes: List[Shape2d]) -> List[List[Shape2d]]: + return list(map(lambda shape: [shape], shapes)) + +# Finds next shape in chain if possible +def mergeIteration(shapes: List[List[Shape2d]]) -> Tuple[List[List[Shape2d]], bool]: + parsed_shapes = list() + result = list() + has_merged_shapes: bool = False + for index, shape in enumerate(shapes): + for secondShape in shapes[index+1:]: + + end_point = shape[-1].end + start_point = secondShape[0].start + start_point_inv = secondShape[-1].end + + if shape in parsed_shapes or secondShape in parsed_shapes: + continue + + if end_point.eq(start_point): + # # print(shape[-1].index, end_point, ' <-> ', start_point, secondShape[0].index) + parsed_shapes.append(shape) + parsed_shapes.append(secondShape) + result.append(shape+secondShape) + has_merged_shapes = True + + elif end_point.eq(start_point_inv): + # print(shape[-1].index, end_point, ' <-> ', start_point_inv, secondShape[0].index, ' I') + parsed_shapes.append(shape) + parsed_shapes.append(secondShape) + + secondShapeRev = secondShape.copy() + secondShapeRev.reverse() + + result.append(shape+list(map(lambda ll: ll.inverse, secondShapeRev))) + + has_merged_shapes = True + + if not shape in parsed_shapes: + parsed_shapes.append(shape) + result.append(shape) + + return (result, has_merged_shapes) + +# Mergest list of chains into Paths list +def mergeShapes(shapes: List[List[Shape2d]]) -> List[Path2d]: + result = list() + + for chain in shapes: + result.append(Path2d.pathFromShapes(chain)) + + return result + +# Extracts Arcs and Lines for merge leabing existing paths and circles +def extractPathsFromShapes(shapes: List[Shape2d]) -> Tuple[List[Shape2d], List[Shape2d]]: + result = list() + shapes_to_merge = list() + for shape in shapes: + if isinstance(shape, Arc2d): + shapes_to_merge.append(shape) + elif isinstance(shape, Line2d): + shapes_to_merge.append(shape) + else: + result.append(shape) + + return (shapes_to_merge, result) + +# Looks at list of shapes to extract path elements for merge +def joinShapesToPathShapes(shapes: List[Shape2d]) -> List[Shape2d]: + (shapes_to_merge, other_shapes) = extractPathsFromShapes(shapes) + + prepared_shapes = prepareShapes(shapes_to_merge) + end: bool = False + current_shapes = prepared_shapes.copy() + + while not end: + current_shapes, has_merged_shapes = mergeIteration(current_shapes) + end = not has_merged_shapes + + result_paths = mergeShapes(current_shapes) + + return other_shapes + result_paths + +class Canvas2d: + + center: Point2d + size: Size2d + shapes: List[Shape2d] + + def __init__(self, center: Point2d, size: Size2d, shapes: List[Shape2d]) -> None: + self.center = center + self.size = size + self.shapes = shapes + + @property + def relative(self) -> 'Canvas2d': + relative_shapes = list(map(lambda shape: shape.relative, self.shapes)) + return Canvas2d(self.center, self.size, relative_shapes) + + + + def cobineShapesToPaths(self) -> 'Canvas2d': + shapes = joinShapesToPathShapes(self.shapes) + return Canvas2d(self.center, self.size, shapes) \ No newline at end of file diff --git a/src/laser_offset/geometry_2d/fill_style.py b/src/laser_offset/geometry_2d/fill_style.py new file mode 100644 index 0000000..19511b1 --- /dev/null +++ b/src/laser_offset/geometry_2d/fill_style.py @@ -0,0 +1,6 @@ +class FillStyle: + + color: str + + def __init__(self, color: str = "none") -> None: + self.color = color \ No newline at end of file diff --git a/src/laser_offset/geometry_2d/normalize_angle.py b/src/laser_offset/geometry_2d/normalize_angle.py new file mode 100644 index 0000000..41633e0 --- /dev/null +++ b/src/laser_offset/geometry_2d/normalize_angle.py @@ -0,0 +1,14 @@ +import math + +def normalize_angle(angle): + """ + :param angle: (float) + :return: (float) Angle in radian in [0, 2 * pi] + """ + while angle > 2 * math.pi: + angle -= 2.0 * math.pi + + while angle < 0: + angle += 2.0 * math.pi + + return angle \ No newline at end of file diff --git a/src/laser_offset/geometry_2d/point2d.py b/src/laser_offset/geometry_2d/point2d.py new file mode 100644 index 0000000..66d765a --- /dev/null +++ b/src/laser_offset/geometry_2d/point2d.py @@ -0,0 +1,133 @@ +from abc import ABC +from cmath import nan, sqrt +import math +from typing import Optional, Tuple + +from laser_offset.geometry_2d.vector2d import CartesianVector2d, PolarVector2d, Vector2d + +from laser_offset.math.float_functions import fzero + +class Point2d(ABC): + + x: float + y: float + + r: float + a: float + + @classmethod + def origin(cls) -> 'Point2d': + return OriginPoint() + + @classmethod + def cartesian(cls, x: float, y: float) -> 'Point2d': + return CartesianPoint2d(x, y) + + @classmethod + def polar(cls, r: float, a: float) -> 'Point2d': + return PolarPoint2d(r, a) + + def eq(self, another: 'Point2d') -> float: + return fzero(self.distance(another)) + + def distance(self, another: 'Point2d') -> float: + return math.sqrt((another.x - self.x) ** 2 + (another.y - self.y) ** 2) + + def __add__(self, another): + if not isinstance(another, Vector2d): + raise RuntimeError("Point2d cat only + with Vector2d") + + def __str__(self) -> str: + return "{x:.3f},{y:.3f}/{r:.3f}@{a:.3f}".format( + x=self.x, + y=self.y, + r=self.r, + a=self.a + ) + + def equals(self, another: 'Point2d') -> bool: + if abs(self.x - another.x) <= 1e-5 and abs(self.y - another.y) <= 1e-5: + return True + + return False + + @property + def as_cartesian_tuple(self) -> Tuple[float, float]: + return (self.x, self.y) + + @property + def as_polar_tuple(self) -> Tuple[float, float]: + return (self.r, self.a) + +class OriginPoint(Point2d): + + x: float + y: float + + r: float + a: float + + def __init__(self) -> None: + super().__init__() + self.x = 0 + self.y = 0 + self.r = 0 + self.a = 0 + + def __str__(self) -> str: + return "0,0" + + def __add__(self, another): + if isinstance(another, CartesianVector2d): + return CartesianPoint2d(another.dx, another.dy) + if isinstance(another, PolarVector2d): + return PolarPoint2d(another.dr, another.da) + return super.__add__(another) + + +class CartesianPoint2d(Point2d): + + x: float + y: float + + def __init__(self, x: float, y: float) -> None: + super().__init__() + self.x = x + self.y = y + + @property + def r(self) -> float: + return math.sqrt(self.x ** 2 + self.y ** 2) + + @property + def a(self) -> float: + return math.atan2(self.y, self.x) + + def __add__(self, another): + if isinstance(another, Vector2d): + return CartesianPoint2d(self.x + another.dx, self.y + another.dy) + return super.__add__(another) + + +class PolarPoint2d(Point2d): + + r: float + a: float + + def __init__(self, r: float, a: float) -> None: + super().__init__() + self.r = r + self.a = a + + @property + def x(self) -> float: + return self.r * math.cos(self.a) + + @property + def y(self) -> float: + return self.r * math.sin(self.a) + + def __add__(self, another): + if isinstance(another, Vector2d): + return PolarPoint2d(self.r + another.dr, self.a + another.da) + return super.__add__(another) diff --git a/src/laser_offset/geometry_2d/segment_2d.py b/src/laser_offset/geometry_2d/segment_2d.py new file mode 100644 index 0000000..be97417 --- /dev/null +++ b/src/laser_offset/geometry_2d/segment_2d.py @@ -0,0 +1,74 @@ +from laser_offset.geometry_2d.point2d import Point2d +import math + +from laser_offset.geometry_2d.vector2d import Vector2d +from typing import List, Optional + +class Segment2d: + + start: Point2d + end: Point2d + + def __init__(self, start: Point2d, end: Point2d) -> None: + self.start = start + self.end = end + + @property + def length(self) -> float: + return math.sqrt((self.end.x - self.start.x) ** 2 + (self.end.y - self.start.y) ** 2) + + def is_point_on_segment(self, point: Point2d) -> bool: + start_dist = self.start.distance(point) + end_dist = self.end.distance(point) + seglen = self.length + + if start_dist <= seglen and end_dist <= seglen: + return True + + return False + + def intersection(self, another) -> List[Point2d]: + + from laser_offset.geometry_2d.arc_segment_2d import ArcSegment2d + + if isinstance(another, Segment2d): + + return self.intersction_segment(another) + + elif isinstance(another, ArcSegment2d): + + return self.intersction_arc_segment(another) + + else: + return [] + + def intersction_segment(self, another: 'Segment2d') -> List[Point2d]: + + x1,y1 = self.start.as_cartesian_tuple + x2,y2 = self.end.as_cartesian_tuple + x3,y3 = another.start.as_cartesian_tuple + x4,y4 = another.end.as_cartesian_tuple + + denom = (y4-y3)*(x2-x1) - (x4-x3)*(y2-y1) +# print('denom', denom) + if denom == 0: # parallel + return [] + + ua = ((x4-x3)*(y1-y3) - (y4-y3)*(x1-x3)) / denom +# print('ua', ua) + if ua < 0 or ua > 1: # out of range + return [] + + ub = ((x2-x1)*(y1-y3) - (y2-y1)*(x1-x3)) / denom +# print('ub', ub) + if ub < 0 or ub > 1: # out of range + return [] + + x = x1 + ua * (x2-x1) + y = y1 + ua * (y2-y1) + + return [Point2d.cartesian(x, y)] + + def intersction_arc_segment(self, another: 'ArcSegment2d') -> List[Point2d]: + + return another.intersection(self) \ No newline at end of file diff --git a/src/laser_offset/geometry_2d/shape2d.py b/src/laser_offset/geometry_2d/shape2d.py new file mode 100644 index 0000000..9af5125 --- /dev/null +++ b/src/laser_offset/geometry_2d/shape2d.py @@ -0,0 +1,43 @@ +from abc import ABC +from importlib.machinery import FrozenImporter +from math import fabs +from operator import ne +from turtle import st +from typing import List, Sequence + +from laser_offset.geometry_2d.point2d import Point2d +from laser_offset.geometry_2d.bounds2d import Bounds2d +from laser_offset.geometry_2d.bounds2d import Size2d +from laser_offset.geometry_2d.stroke_style import StrokeStyle +from laser_offset.geometry_2d.fill_style import FillStyle +from laser_offset.geometry_2d.style2d import Style + + +class Shape2d(ABC): + + vertexes: Sequence[Point2d] + style: Style + + def __init__(self, style: Style) -> None: + super().__init__() + self.style = style + + @property + def isClosed(self) -> bool: + return False + + @property + def center(self) -> Point2d: + return Point2d.origin + + @property + def bounds(self) -> Bounds2d: + return Bounds2d(Size2d(0,0)) + + @property + def relative(self) -> 'Shape2d': + return self + + @property + def inverse(self) -> 'Shape2d': + return self diff --git a/src/laser_offset/geometry_2d/shapes_2d/arc2d.py b/src/laser_offset/geometry_2d/shapes_2d/arc2d.py new file mode 100644 index 0000000..92880fb --- /dev/null +++ b/src/laser_offset/geometry_2d/shapes_2d/arc2d.py @@ -0,0 +1,89 @@ +from laser_offset.geometry_2d.bounds2d import Bounds2d +from laser_offset.geometry_2d.shape2d import Shape2d +from laser_offset.geometry_2d.point2d import Point2d +from laser_offset.geometry_2d.vector2d import Vector2d + +from typing import List, Sequence + +from laser_offset.geometry_2d.size2d import Size2d +from laser_offset.geometry_2d.stroke_style import StrokeStyle +from laser_offset.geometry_2d.style2d import Style + +from laser_offset.geometry_2d.normalize_angle import normalize_angle + +import math + +class Arc2d(Shape2d): + style: Style + + start: Point2d + end: Point2d + + radiuses: Size2d + angle: float + + large_arc: bool + sweep_flat: bool + + @classmethod + def fromCenteredArc(cls, + style: Style, + center: Point2d, + start_angle: float, + end_angle: float, + radius: float + ) -> 'Arc2d': + + start_point: Point2d = center + Vector2d.polar(radius, start_angle) + end_point: Point2d = center + Vector2d.polar(radius, end_angle) + + # print("ARC ANGLES: ", math.degrees(end_angle), math.degrees(start_angle),math.degrees(end_angle) - math.degrees(start_angle)) + + large_arc: bool = (end_angle < start_angle) ^ (abs(start_angle - end_angle) > math.pi) + + return Arc2d(Style(), + start_point, + end_point, + Size2d(radius, radius), + normalize_angle(end_angle - start_angle), + large_arc, + True) + + def __init__(self, + style: Style, + + start: Point2d, + end: Point2d, + + radiuses: Size2d, + angle: float, + + large_arc: bool, + sweep_flat: bool + + ) -> None: + super().__init__(style) + self.start = start + self.end = end + self.radiuses = radiuses + self.angle = angle + self.large_arc = large_arc + self.sweep_flat = sweep_flat + + + @property + def isClosed(self) -> bool: + return False + + @property + def center(self) -> Point2d: + raise RuntimeError("Not Implemented") + + @property + def bounds(self) -> Bounds2d: + raise RuntimeError("Not Implemented") + + @property + def inverse(self) -> 'Shape2d': + invertedArc = Arc2d(self.style, self.end, self.start, self.radiuses, self.angle, self.large_arc, not self.sweep_flat) + return invertedArc \ No newline at end of file diff --git a/src/laser_offset/geometry_2d/shapes_2d/bezier2d.py b/src/laser_offset/geometry_2d/shapes_2d/bezier2d.py new file mode 100644 index 0000000..55b4780 --- /dev/null +++ b/src/laser_offset/geometry_2d/shapes_2d/bezier2d.py @@ -0,0 +1,40 @@ +from laser_offset.geometry_2d.bounds2d import Bounds2d +from laser_offset.geometry_2d.point2d import Point2d +from laser_offset.geometry_2d.shape2d import Shape2d +from laser_offset.geometry_2d.stroke_style import StrokeStyle +from laser_offset.geometry_2d.style2d import Style + + +class Bezier2d(Shape2d): + + style: Style + + start: Point2d + end: Point2d + startControl: Point2d + endControl: Point2d + + def __init__(self, + style: Style, + start: Point2d, + end: Point2d, + startControl: Point2d, + endControl: Point2d + ) -> None: + super().__init__(style) + self.start = start + self.end = end + self.startControl = startControl + self.endControl = endControl + + @property + def isClosed(self) -> bool: + return False + + @property + def center(self) -> Point2d: + raise RuntimeError("Not Implemented") + + @property + def bounds(self) -> Bounds2d: + raise RuntimeError("Not Implemented") diff --git a/src/laser_offset/geometry_2d/shapes_2d/circle2d.py b/src/laser_offset/geometry_2d/shapes_2d/circle2d.py new file mode 100644 index 0000000..d5690f5 --- /dev/null +++ b/src/laser_offset/geometry_2d/shapes_2d/circle2d.py @@ -0,0 +1,34 @@ +from laser_offset.geometry_2d.shape2d import Shape2d +from laser_offset.geometry_2d.bounds2d import Bounds2d +from laser_offset.geometry_2d.point2d import Point2d +from laser_offset.geometry_2d.size2d import Size2d + +from typing import Sequence + +from laser_offset.geometry_2d.stroke_style import StrokeStyle +from laser_offset.geometry_2d.style2d import Style + +class Circle2d(Shape2d): + + style: Style + + centerPoint: Point2d + radius: float + + def __init__(self, style: Style, centerPoint: Point2d, radius: float) -> None: + super().__init__(style) + self.radius = radius + self.centerPoint = centerPoint + + @property + def isClosed(self) -> bool: + return True + + @property + def center(self) -> Point2d: + return self.centerPoint + + @property + def bounds(self) -> Bounds2d: + return Bounds2d(Size2d(self.radius, self.radius)) + \ No newline at end of file diff --git a/src/laser_offset/geometry_2d/shapes_2d/ellipse2d.py b/src/laser_offset/geometry_2d/shapes_2d/ellipse2d.py new file mode 100644 index 0000000..5f58dea --- /dev/null +++ b/src/laser_offset/geometry_2d/shapes_2d/ellipse2d.py @@ -0,0 +1,36 @@ +from laser_offset.geometry_2d.shape2d import Shape2d +from laser_offset.geometry_2d.bounds2d import Bounds2d +from laser_offset.geometry_2d.point2d import Point2d +from laser_offset.geometry_2d.size2d import Size2d + +from typing import Sequence + +from laser_offset.geometry_2d.stroke_style import StrokeStyle +from laser_offset.geometry_2d.style2d import Style + +class Ellipse2d(Shape2d): + + style: Style + + centerPoint: Point2d + radiuses: Size2d + angle: float + + def __init__(self, style: Style, centerPoint: Point2d, radiuses: Size2d, angle: float) -> None: + super().__init__(style) + self.centerPoint = centerPoint + self.radiuses = radiuses + self.angle = angle + + @property + def isClosed(self) -> bool: + return True + + @property + def center(self) -> Point2d: + return self.centerPoint + + @property + def bounds(self) -> Bounds2d: + return Bounds2d(Size2d(self.radiuses.width, self.radiuses.height)) + \ No newline at end of file diff --git a/src/laser_offset/geometry_2d/shapes_2d/line2d.py b/src/laser_offset/geometry_2d/shapes_2d/line2d.py new file mode 100644 index 0000000..c171b6c --- /dev/null +++ b/src/laser_offset/geometry_2d/shapes_2d/line2d.py @@ -0,0 +1,67 @@ +from laser_offset.geometry_2d.bounds2d import Bounds2d +from laser_offset.geometry_2d.shape2d import Shape2d +from laser_offset.geometry_2d.point2d import Point2d + +from typing import Sequence + +from laser_offset.geometry_2d.size2d import Size2d +from laser_offset.geometry_2d.stroke_style import StrokeStyle +from laser_offset.geometry_2d.style2d import Style + +class Line2d(Shape2d): + + style: Style + + start: Point2d + end: Point2d + + def __init__(self, style: Style, start: Point2d, end: Point2d) -> None: + super().__init__(style) + self.start = start + self.end = end + + @property + def vertexes(self) -> Sequence[Point2d]: + return [ + self.start, + self.end + ] + + @property + def isClosed(self) -> bool: + return False + + @property + def center(self) -> Point2d: + return Point2d.cartesian((self.start.x + self.end.x)/2, (self.start.y + self.end.y)/2) + + @property + def bounds(self) -> Bounds2d: + return Bounds2d(Size2d(self.end.x - self.start.x, self.end.y - self.start.y)) + + @property + def inverse(self) -> 'Shape2d': + inverseLine = Line2d(self.style, self.end, self.start) + return inverseLine + +class VerticalLine2d(Line2d): + + style: Style + + start: Point2d + length: float + + def __init__(self, style: Style, start: Point2d, length: float) -> None: + super().__init__(style, start, Point2d(start.x, start.y + length)) + self.length = length + +class HorizontalLine2d(Line2d): + + style: Style + + start: Point2d + length: float + + def __init__(self, style: Style, start: Point2d, length: float) -> None: + super().__init__(style, start, Point2d(start.x + length, start.y)) + self.length = length diff --git a/src/laser_offset/geometry_2d/shapes_2d/path2d.py b/src/laser_offset/geometry_2d/shapes_2d/path2d.py new file mode 100644 index 0000000..f366a56 --- /dev/null +++ b/src/laser_offset/geometry_2d/shapes_2d/path2d.py @@ -0,0 +1,475 @@ +from abc import ABC +from operator import le +from laser_offset.geometry_2d.bounds2d import Bounds2d +from laser_offset.geometry_2d.point2d import Point2d +from laser_offset.geometry_2d.shape2d import Shape2d +from laser_offset.geometry_2d.shapes_2d.line2d import Line2d +from laser_offset.geometry_2d.shapes_2d.arc2d import Arc2d + +from typing import List, NamedTuple +from laser_offset.geometry_2d.size2d import Size2d + +from laser_offset.geometry_2d.stroke_style import StrokeStyle +from laser_offset.geometry_2d.style2d import Style +from laser_offset.geometry_2d.vector2d import Vector2d + +import math + +class PathComponent(ABC): + pass + + + +class Path2d(Shape2d): + """Path Shape + """ + + style: Style + + components: List[PathComponent] + + def __init__(self, style: Style, components: List[PathComponent]) -> None: + super().__init__(style) + self.components = components + + @classmethod + def pathFromShapes(self, chain: List[Shape2d]) -> 'Path2d': + components = list() + components.append(MoveOrigin(chain[0].start)) + for shape in chain: + if isinstance(shape, Line2d): + components.append(Line(shape.end)) + + elif isinstance(shape, Arc2d): + # components.append( Arc(shape.end, shape.radiuses, shape.angle, shape.large_arc, shape.sweep_flat)) + components.append(SimpleArc(shape.end, shape.radiuses.width, not shape.sweep_flat, shape.large_arc)) + + components.append(ClosePath()) + return Path2d(Style(), components) + + + @property + def isClosed(self) -> bool: + return False + + @property + def center(self) -> Point2d: + raise RuntimeError("Not Implemented") + + @property + def bounds(self) -> Bounds2d: + raise RuntimeError("Not Implemented") + + @property + def relative(self) -> 'Shape2d': + relative_components: List[PathComponent] = list() + current_point: Point2d = None + for index, component in enumerate(self.components): + if current_point is None: + relative_components.append(component) + + else: + + if isinstance(component, MoveOrigin): + relative = Vector2d.cartesian(component.target.x - current_point.x, component.target.y - current_point.y) + relative_components.append(RelMoveOrigin(relative)) + elif isinstance(component, Line): + relative = Vector2d.cartesian(component.target.x - current_point.x, component.target.y - current_point.y) + relative_components.append(RelLine(relative)) + elif isinstance(component, Arc): + relative = Vector2d.cartesian(component.target.x - current_point.x, component.target.y - current_point.y) + relative_components.append(RelArc(relative, component.radiuses, component.angle, component.large_arc, component.sweep)) + elif isinstance(component, SimpleArc): + relative = Vector2d.cartesian(component.target.x - current_point.x, component.target.y - current_point.y) + relative_components.append(RelSimpleArc(relative, component.radius, component.cw_direction, component.large_arc)) + elif isinstance(component, ClosePath): + relative_components.append(ClosePath()) + + if isinstance(component, MoveOrigin): + current_point = component.target + elif isinstance(component, Line): + current_point = component.target + elif isinstance(component, Arc): + current_point = component.target + elif isinstance(component, SimpleArc): + current_point = component.target + + return Path2d(self.style, relative_components) + + +class MoveOrigin(PathComponent): + + target: Point2d + def __init__(self, target: Point2d) -> None: + super().__init__() + self.target = target + +class RelMoveOrigin(PathComponent): + + target: Vector2d + def __init__(self, target: Vector2d) -> None: + super().__init__() + self.target = target + + + +class Line(PathComponent): + + target: Point2d + def __init__(self, target: Point2d) -> None: + super().__init__() + self.target = target + +class RelLine(PathComponent): + + target: Vector2d + def __init__(self, target: Vector2d) -> None: + super().__init__() + self.target = target + + + +class HorizontalLine(PathComponent): + + length: float + def __init__(self, length: float) -> None: + super().__init__() + self.length = length + +class RelHorizontalLine(PathComponent): + + length: float + def __init__(self, length: float) -> None: + super().__init__() + self.length = length + + + +class VerticalLine(PathComponent): + + length: float + def __init__(self, length: float) -> None: + super().__init__() + self.length = length + +class RelVerticalLine(PathComponent): + + length: float + def __init__(self, length: float) -> None: + super().__init__() + self.length = length + + + +class ClosePath(PathComponent): + + def __init__(self) -> None: + super().__init__() + + + +class CubicBezier(PathComponent): + + target: Point2d + startControlPoint: Point2d + endControlPoint: Point2d + def __init__(self, + target: Point2d, + startControlPoint: Point2d, + endControlPoint: Point2d + ) -> None: + super().__init__() + self.target = target + self.startControlPoint = startControlPoint + self.endControlPoint = endControlPoint + +class RelCubicBezier(PathComponent): + + target: Vector2d + startControlPoint: Vector2d + endControlPoint: Vector2d + def __init__(self, + target: Vector2d, + startControlPoint: Vector2d, + endControlPoint: Vector2d + ) -> None: + super().__init__() + self.target = target + self.startControlPoint = startControlPoint + self.endControlPoint = endControlPoint + + + +class StrugBezier(PathComponent): + + target: Point2d + endControlPoint: Point2d + def __init__(self, + target: Point2d, + endControlPoint: Point2d + ) -> None: + super().__init__() + self.target = target + self.endControlPoint = endControlPoint + +class RelStrugBezier(PathComponent): + + target: Vector2d + endControlPoint: Vector2d + def __init__(self, + target: Vector2d, + endControlPoint: Vector2d + ) -> None: + super().__init__() + self.target = target + self.endControlPoint = endControlPoint + + + +class Quadratic(PathComponent): + + target: Point2d + controlPoint: Point2d + def __init__(self, target: Point2d, controlPoint: Point2d) -> None: + super().__init__() + self.target = target + self.controlPoint = controlPoint + +class RelQuadratic(PathComponent): + + target: Vector2d + controlPoint: Vector2d + def __init__(self, target: Vector2d, controlPoint: Vector2d) -> None: + super().__init__() + self.target = target + self.controlPoint = controlPoint + + + +class ReflectedQuadratic(PathComponent): + + target: Point2d + def __init__(self, target: Point2d) -> None: + super().__init__() + self.target = target + +class RelReflectedQuadratic(PathComponent): + + target: Vector2d + def __init__(self, target: Vector2d) -> None: + super().__init__() + self.target = target + +class SimpleArc(PathComponent): + target: Point2d + radius: float + cw_direction: bool + large_arc: bool + def __init__(self, + target: Point2d, + radius: float, + cw_direction: bool, + large_arc: bool + ) -> None: + super().__init__() + self.target = target + self.radius = radius + self.cw_direction = cw_direction + self.large_arc = large_arc + +class RelSimpleArc(PathComponent): + target: Vector2d + radius: float + cw_direction: bool + large_arc: bool + def __init__(self, + target: Vector2d, + radius: float, + cw_direction: bool, + large_arc: bool + ) -> None: + super().__init__() + self.target = target + self.radius = radius + self.cw_direction = cw_direction + self.large_arc = large_arc + + +class Arc(PathComponent): + + target: Point2d + radiuses: Size2d + angle: float + large_arc: bool + sweep: bool + def __init__(self, + target: Point2d, + radiuses: Size2d, + angle: float, + large_arc: bool, + sweep: bool + ) -> None: + super().__init__() + self.target = target + self.radiuses = radiuses + self.angle = angle + self.large_arc = large_arc + self.sweep = sweep + +class RelArc(PathComponent): + + target: Vector2d + radiuses: Size2d + angle: float + large_arc: bool + sweep: bool + def __init__(self, + target: Vector2d, + radiuses: Size2d, + angle: float, + large_arc: bool, + sweep: bool + ) -> None: + super().__init__() + self.target = target + self.radiuses = radiuses + self.angle = angle + self.large_arc = large_arc + self.sweep = sweep + + +class ArcInfo(NamedTuple): + center: Point2d + startAngle: float + endAngle: float + deltaAngle: float + cw: bool + startVector: Vector2d + endVector: Vector2d + radius: float + + def __str__(self) -> str: + return "ArcInfo\n\tcenter:\t{center}\n\tstart angle:\t{sa}\n\tend angle:\t{ea}\n\tclockwise:\t{cw}\n\tradius:\t{r}".format( + center=self.center, + sa=self.startAngle, + ea=self.endAngle, + cw=self.cw, + r=self.radius + ) + +# conversion_from_endpoint_to_center_parameterization +# sample : svgArcToCenterParam(200,200,50,50,0,1,1,300,200) +# x1 y1 rx ry φ fA fS x2 y2 +def arc_info(prev_point: Point2d, arc: SimpleArc) -> ArcInfo: + + def radian(ux: float, uy: float, vx: float, vy: float): + dot: float = ux * vx + uy * vy + mod: float = math.sqrt( ( ux * ux + uy * uy ) * ( vx * vx + vy * vy ) ) + rad: float = math.acos( dot / mod ) + if ux * vy - uy * vx < 0.0: + rad = -rad + + return rad + + x1: float = prev_point.x + y1: float = prev_point.y + rx: float = arc.radius + ry: float = arc.radius + phi: float = 0 + fA: float = arc.large_arc + fS: float = arc.cw_direction + x2: float = arc.target.x + y2: float = arc.target.y + + PIx2: float = math.pi * 2.0 +#var cx, cy, startAngle, deltaAngle, endAngle; + if rx < 0: + rx = -rx + + if ry < 0: + ry = -ry + + if rx == 0.0 or ry == 0.0: + raise RuntimeError('Raidus can not be zero') + + + s_phi: float = math.sin(phi) + c_phi: float = math.cos(phi) + hd_x: float = (x1 - x2) / 2.0 # half diff of x + hd_y: float = (y1 - y2) / 2.0 # half diff of y + hs_x: float = (x1 + x2) / 2.0 # half sum of x + hs_y: float = (y1 + y2) / 2.0 # half sum of y + + # F6.5.1 + x1_: float = c_phi * hd_x + s_phi * hd_y + y1_: float = c_phi * hd_y - s_phi * hd_x + + # F.6.6 Correction of out-of-range radii + # Step 3: Ensure radii are large enough + lambda_: float = (x1_ * x1_) / (rx * rx) + (y1_ * y1_) / (ry * ry) + if lambda_ > 1: + rx = rx * math.sqrt(lambda_) + ry = ry * math.sqrt(lambda_) + + rxry: float = rx * ry + rxy1_: float = rx * y1_ + ryx1_: float = ry * x1_ + + sum_of_sq: float = rxy1_ * rxy1_ + ryx1_ * ryx1_ # sum of square + if sum_of_sq == 0: + raise RuntimeError('start point can not be same as end point ', prev_point.__str__(), arc.radius) + + coe: float = math.sqrt(abs((rxry * rxry - sum_of_sq) / sum_of_sq)) + if fA == fS: + coe = -coe + + # F6.5.2 + cx_: float = coe * rxy1_ / ry + cy_: float = -coe * ryx1_ / rx + + # F6.5.3 + cx = c_phi * cx_ - s_phi * cy_ + hs_x + cy = s_phi * cx_ + c_phi * cy_ + hs_y + + xcr1: float = (x1_ - cx_) / rx + xcr2: float = (x1_ + cx_) / rx + ycr1: float = (y1_ - cy_) / ry + ycr2: float = (y1_ + cy_) / ry + + # F6.5.5 + startAngle: float = radian(1.0, 0.0, xcr1, ycr1) + + # F6.5.6 + deltaAngle = radian(xcr1, ycr1, -xcr2, -ycr2) + while deltaAngle > PIx2: + deltaAngle -= PIx2 + + while deltaAngle < 0.0: + deltaAngle += PIx2 + + if fS == False or fS == 0: + deltaAngle -= PIx2 + + endAngle = startAngle + deltaAngle + while endAngle > PIx2: + endAngle -= PIx2 + while endAngle < 0.0: + endAngle += PIx2 + + rotationSign: float = 1 if fS else -1 + + startVector: Vector2d = Vector2d.polar(1, startAngle + rotationSign * math.pi/2) + endVector: Vector2d = Vector2d.polar(1, endAngle + rotationSign * math.pi/2) + + outputObj: ArcInfo = ArcInfo( + Point2d.cartesian(cx, cy), + startAngle, + endAngle, + deltaAngle, + fS == True or fS == 1, + startVector, + endVector, + arc.radius + ) + + return outputObj diff --git a/src/laser_offset/geometry_2d/shapes_2d/polygon2d.py b/src/laser_offset/geometry_2d/shapes_2d/polygon2d.py new file mode 100644 index 0000000..a843e2f --- /dev/null +++ b/src/laser_offset/geometry_2d/shapes_2d/polygon2d.py @@ -0,0 +1,34 @@ +from laser_offset.geometry_2d.bounds2d import Bounds2d +from laser_offset.geometry_2d.shape2d import Shape2d +from laser_offset.geometry_2d.point2d import Point2d + +from typing import List, Sequence + +from laser_offset.geometry_2d.size2d import Size2d +from laser_offset.geometry_2d.stroke_style import StrokeStyle +from laser_offset.geometry_2d.style2d import Style + +class Polygon2d(Shape2d): + + style: Style + points: List[Point2d] + + def __init__(self, style: Style, points: List[Point2d]) -> None: + super().__init__(style) + self.points = points + + @property + def vertexes(self) -> Sequence[Point2d]: + return self.points + [self.points[0]] + + @property + def isClosed(self) -> bool: + raise RuntimeError("Not Implemented") + + @property + def center(self) -> Point2d: + raise RuntimeError("Not Implemented") + + @property + def bounds(self) -> Bounds2d: + raise RuntimeError("Not Implemented") diff --git a/src/laser_offset/geometry_2d/shapes_2d/polyline2d.py b/src/laser_offset/geometry_2d/shapes_2d/polyline2d.py new file mode 100644 index 0000000..c4e61ff --- /dev/null +++ b/src/laser_offset/geometry_2d/shapes_2d/polyline2d.py @@ -0,0 +1,34 @@ +from laser_offset.geometry_2d.bounds2d import Bounds2d +from laser_offset.geometry_2d.shape2d import Shape2d +from laser_offset.geometry_2d.point2d import Point2d + +from typing import List, Sequence + +from laser_offset.geometry_2d.stroke_style import StrokeStyle +from laser_offset.geometry_2d.style2d import Style + + +class Polyline2d(Shape2d): + + style: Style + points: List[Point2d] + + def __init__(self, style: Style, points: List[Point2d]) -> None: + super().__init__(style) + self.points = points + + @property + def vertexes(self) -> Sequence[Point2d]: + return self.points + + @property + def isClosed(self) -> bool: + raise RuntimeError("Not Implemented") + + @property + def center(self) -> Point2d: + raise RuntimeError("Not Implemented") + + @property + def bounds(self) -> Bounds2d: + raise RuntimeError("Not Implemented") diff --git a/src/laser_offset/geometry_2d/shapes_2d/rect2d.py b/src/laser_offset/geometry_2d/shapes_2d/rect2d.py new file mode 100644 index 0000000..b85c571 --- /dev/null +++ b/src/laser_offset/geometry_2d/shapes_2d/rect2d.py @@ -0,0 +1,33 @@ +from laser_offset.geometry_2d.shape2d import Shape2d +from laser_offset.geometry_2d.point2d import Point2d + +from typing import Sequence + +from laser_offset.geometry_2d.stroke_style import StrokeStyle +from laser_offset.geometry_2d.style2d import Style + +class Rect2d(Shape2d): + + style: Style + + left_top: Point2d + right_bottom: Point2d + + corner_radius: float + + def __init__(self, style: Style, left_top: Point2d, right_bottom: Point2d, corner_radius: float = 0) -> None: + super().__init__(style) + self.left_top = left_top + self.right_bottom = right_bottom + self.corner_radius = corner_radius + + @property + def width(self) -> Point2d: + return self.right_bottom.x - self.left_top.x + + @property + def height(self) -> Point2d: + return self.right_bottom.y - self.right_bottom.y + + + \ No newline at end of file diff --git a/src/laser_offset/geometry_2d/size2d.py b/src/laser_offset/geometry_2d/size2d.py new file mode 100644 index 0000000..c9810ce --- /dev/null +++ b/src/laser_offset/geometry_2d/size2d.py @@ -0,0 +1,8 @@ +class Size2d: + + width: float + height: float + + def __init__(self, width: float, height: float) -> None: + self.width = width + self.height = height diff --git a/src/laser_offset/geometry_2d/stroke_style.py b/src/laser_offset/geometry_2d/stroke_style.py new file mode 100644 index 0000000..bc15d84 --- /dev/null +++ b/src/laser_offset/geometry_2d/stroke_style.py @@ -0,0 +1,38 @@ + +from enum import Enum +from typing import List + + +class StrokeLineCap(Enum): + ROUND = 0 + BUTT = 1 + SQUARE = 2 + +class StrokeLineJoint(Enum): + ROUND = 0 + MITER = 1 + BEVEl = 2 + +class StrokeStyle: + + width: float + color: str + line_cap: StrokeLineCap + line_joint: StrokeLineJoint + mitter_limit: float + dash: List[float] + + def __init__(self, + width: float = 0.15, + color: str = "black", + line_cap: StrokeLineCap = StrokeLineCap.ROUND, + line_joint: StrokeLineJoint = StrokeLineJoint.ROUND, + mitter_limit: float = 0, + dash: List[float] = list() + ) -> None: + self.width = width + self.color = color + self.line_cap = line_cap + self.line_joint = line_joint + self.mitter_limit = mitter_limit + self.dash = dash diff --git a/src/laser_offset/geometry_2d/style2d.py b/src/laser_offset/geometry_2d/style2d.py new file mode 100644 index 0000000..b89fb86 --- /dev/null +++ b/src/laser_offset/geometry_2d/style2d.py @@ -0,0 +1,14 @@ +from laser_offset.geometry_2d.stroke_style import StrokeStyle +from laser_offset.geometry_2d.fill_style import FillStyle + +class Style: + + stroke_style: StrokeStyle + fill_style: FillStyle + + def __init__(self, + stroke_style: StrokeStyle = StrokeStyle(), + fill_style: FillStyle = FillStyle() + ) -> None: + self.stroke_style = stroke_style + self.fill_style = fill_style diff --git a/src/laser_offset/geometry_2d/vector2d.py b/src/laser_offset/geometry_2d/vector2d.py new file mode 100644 index 0000000..fc0c0ff --- /dev/null +++ b/src/laser_offset/geometry_2d/vector2d.py @@ -0,0 +1,139 @@ +from abc import ABC +from cmath import sqrt +import math +from xmlrpc.client import Boolean +from laser_offset.geometry_2d.normalize_angle import normalize_angle +# from vectors.geometry_2d.point2d import Point2d + +class Vector2d(ABC): + + dx: float + dy: float + + dr: float + da: float + + @classmethod + def fromTwoPoints(cls, start: 'Point2d', end: 'Point2d') -> 'Vector2d': + return Vector2d.cartesian(end.x - start.x, end.y - start.y) + + @classmethod + def origin(cls) -> 'Vector2d': + return ZeroVector() + + @classmethod + def cartesian(cls, dx: float, dy: float) -> 'Vector2d': + return CartesianVector2d(dx, dy) + + @classmethod + def polar(cls, dr: float, da: float) -> 'Vector2d': + return PolarVector2d(dr, normalize_angle(da)) + + def __str__(self) -> str: + return "->{dx:.3f},{dy:.3f}/{dr:.3f}@{da:.3f}".format( + dx=self.dx, + dy=self.dy, + dr=self.dr, + da=self.da + ) + + @property + def norm(self) -> float: + return math.sqrt( self.dx ** 2 + self.dy ** 2) + + @property + def single_vector(self) -> float: + return Vector2d.polar(1, normalize_angle(self.da)) + + @property + def inv_vector(self) -> float: + return Vector2d.polar(1, normalize_angle(math.pi + self.da)) + + def __mul__(self, another): + if isinstance(another, float): + return Vector2d.polar(self.dr * another, normalize_angle(self.da)) + elif isinstance(another, int): + return Vector2d.polar(self.dr * another, normalize_angle(self.da)) + + raise RuntimeError('Invalid Arg') + + @property + def inverted(self) -> 'Vector2d': + if isinstance(self, ZeroVector): + return self + elif isinstance(self, CartesianVector2d): + return Vector2d.cartesian(-self.dx, -self.dy) + elif isinstance(self, PolarVector2d): + return Vector2d.polar(self.dr, normalize_angle(self.da + math.pi)) + + + def __add__(self, another): + if isinstance(another, Vector2d): + v2: Vector2d = another + return Vector2d.cartesian(self.dx + v2.dx, self.dy + v2.dy) + + raise RuntimeError('Invalid Arg') + +class ZeroVector(Vector2d): + + dx: float + dy: float + + dr: float + da: float + + def __init__(self) -> None: + super().__init__() + self.dx = 0 + self.dy = 0 + self.dr = 0 + self.da = 0 + + def __str__(self) -> str: + return "->0,0" + + @property + def norm(self) -> float: + return 0 + + +class CartesianVector2d(Vector2d): + + dx: float + dy: float + + def __init__(self, dx: float, dy: float) -> None: + super().__init__() + self.dx = dx + self.dy = dy + + @property + def dr(self) -> float: + return math.sqrt(self.dx ** 2 + self.dy ** 2) + + @property + def da(self) -> float: + return normalize_angle(math.atan2(self.dy, self.dx)) + + +class PolarVector2d(Vector2d): + + dr: float + da: float + + def __init__(self, dr: float, da: float) -> None: + super().__init__() + self.dr = dr + self.da = normalize_angle(da) + + @property + def dx(self) -> float: + return self.dr * math.cos(self.da) + + @property + def dy(self) -> float: + return self.dr * math.sin(self.da) + + @property + def norm(self) -> float: + return self.dr diff --git a/src/laser_offset/importers/dxf_importer.py b/src/laser_offset/importers/dxf_importer.py new file mode 100644 index 0000000..b695bc3 --- /dev/null +++ b/src/laser_offset/importers/dxf_importer.py @@ -0,0 +1,200 @@ +from enum import Enum +from re import X +from sys import path_hooks +from turtle import width +from typing import List, NamedTuple, Optional, Tuple, cast +from codecs import StreamReader + +from laser_offset.geometry_2d.point2d import Point2d +from laser_offset.geometry_2d.shape2d import Shape2d +from laser_offset.geometry_2d.shapes_2d.circle2d import Circle2d +from laser_offset.geometry_2d.shapes_2d.line2d import Line2d +from laser_offset.geometry_2d.shapes_2d.arc2d import Arc2d +from laser_offset.geometry_2d.shapes_2d.path2d import Arc, ClosePath, Line, MoveOrigin, Path2d, PathComponent, SimpleArc +from laser_offset.geometry_2d.size2d import Size2d +from laser_offset.geometry_2d.style2d import Style +from laser_offset.importers.importer import Importer + +from laser_offset.geometry_2d.canvas2d import Canvas2d + +import ezdxf +from ezdxf.document import Drawing +from ezdxf.layouts.layout import Modelspace +from ezdxf.layouts.layout import Paperspace +from ezdxf.entities import DXFEntity +from ezdxf.entities.lwpolyline import LWPolyline + +import math + +class DXFPathPoint(NamedTuple): + x: float + y: float + ws: float + we: float + bulge: float + + @classmethod + def fromTuple(cls, point: Tuple[float, float, float, float, float]) -> 'DXFPathPoint': + return DXFPathPoint(point[0], point[1], point[2], point[3], point[4]) + + +class PointType(Enum): + FIRST = 0 + MIDDLE = 1 + LAST = 2 + +class DXFImporter(Importer): + + def import_canvas(self, relative: bool, reader: StreamReader) -> Canvas2d: + + doc: Drawing = ezdxf.read(reader) + + model_space: Modelspace = doc.modelspace() + + paper_space: Paperspace = doc.layouts.active_layout() + min_x = paper_space.dxf.limmin[0] + min_y = paper_space.dxf.limmin[1] + max_x = paper_space.dxf.limmax[0] + max_y = paper_space.dxf.limmax[1] + width = max_x - min_x + height = max_y - min_y + + + shapes: List[Shape2d] = list() + + for child in model_space: + entity: DXFEntity = child + + if entity.dxftype() == 'CIRCLE': + circle: Circle2d = self.read_circle(entity) + shapes.append(circle) + + elif entity.dxftype() == 'LWPOLYLINE': + path: Path2d = self.read_path2d(entity) + shapes.append(path) + + elif entity.dxftype() == 'LINE': + line: Line2d = self.read_line2d(entity); + shapes.append(line) + + elif entity.dxftype() == 'ARC': + arc: Arc2d = self.read_arc2d(entity); + shapes.append(arc) + + else: + print('UNKNOWN TYPE: ',entity.dxftype()) + + canvas: Canvas2d = Canvas2d( + Point2d.cartesian(0, 0), + Size2d(width, height), shapes) + + if relative: + return canvas.relative + + return canvas + + def read_line2d(self, entity: ezdxf.entities.line.Line) -> Line2d: + return Line2d(Style(), + Point2d.cartesian(entity.dxf.start.x, entity.dxf.start.y), + Point2d.cartesian(entity.dxf.end.x, entity.dxf.end.y)) + + def read_arc2d(self, entity: ezdxf.entities.arc.Arc) -> Arc2d: + return Arc2d.fromCenteredArc(Style(), + Point2d.cartesian(entity.dxf.center.x, entity.dxf.center.y), + math.radians(entity.dxf.start_angle), + math.radians(entity.dxf.end_angle), + entity.dxf.radius + ) + + + def read_circle(self, entity: DXFEntity) -> Circle2d: + cx: float = entity.dxf.center.x + cy: float = entity.dxf.center.y + r: float = entity.dxf.radius + return Circle2d(Style(), Point2d.cartesian(cx, cy), r) + + def read_path2d(self, entity: LWPolyline) -> Path2d: + + components: List[PathComponent] = list() + points: List[Tuple[float, float, float, float, float]] = self.flip_y(entity) + + for index, point in enumerate(points+[points[0]]): + + point_type: PointType = PointType.MIDDLE + if index == 0: + point_type = PointType.FIRST + + + prev_point = points[index - 1] if index > 0 else points[entity.__len__()-1] + start_point: DXFPathPoint = DXFPathPoint.fromTuple(prev_point) + end_point: DXFPathPoint = DXFPathPoint.fromTuple(point) + component: PathComponent = self.read_path_component(point_type, start_point, end_point) + if component is not None: + components.append(component) + + if index == points.__len__() - 1: + point_type = PointType.LAST + component: PathComponent = self.read_path_component(point_type, start_point, end_point) + if component is not None: + components.append(component) + + if entity.closed: + start_point: Point2d = cast(MoveOrigin, components[0]).target + end_point: Point2d + if isinstance(components[-1], SimpleArc): + end_point = components[-1].target + elif isinstance(components[-1], Line): + end_point = components[-1].target + + if start_point.x != end_point.x or start_point.y != end_point.y: + components.append(Line(start_point)) + + components.append(ClosePath()) + + return Path2d(Style(), components) + + def read_path_component(self, point_type: PointType, start: DXFPathPoint, end: DXFPathPoint) -> Optional[PathComponent]: + if point_type == PointType.FIRST: + return self.read_move(start, end) + + elif point_type == PointType.LAST: + return self.read_close_path(start, end) + + if start.bulge == 0: + return self.read_line(start, end) + + else: + return self.read_arc(start, end) + + def read_move(self, start: DXFPathPoint, end: DXFPathPoint) -> Optional[MoveOrigin]: + return MoveOrigin(Point2d.cartesian(end.x, end.y)) + + def read_line(self, start: DXFPathPoint, end: DXFPathPoint) -> Optional[Line]: + return Line(Point2d.cartesian(end.x, end.y)) + + def read_arc(self, start: DXFPathPoint, end: DXFPathPoint) -> Optional[Arc]: + + dx: float = end.x - start.x + dy: float = end.y - start.y + l: float = math.sqrt( dx ** 2 + dy ** 2) + k: float = l / 2 + b: float = abs(start.bulge) + + r: float = k * (b ** 2 + 1) / (2 * b) + + cw_direction: bool = start.bulge < 0 + large_arc: bool = abs(b) > 1.0 + + return SimpleArc(Point2d.cartesian(end.x, end.y), r, cw_direction, large_arc) + + def read_close_path(self, start: DXFPathPoint, end: DXFPathPoint) -> Optional[ClosePath]: + if start.x == end.x and start.y == end.y: + return ClosePath() + else: + return None + + def flip_y(self, input_points: List[Tuple[float, float, float, float, float]]) -> List[Tuple[float, float, float, float, float]]: + polyline_points: List[Tuple[float, float, float, float, float]] = list() + for point in input_points: + polyline_points.append((point[0], point[1], point[2], point[3], point[4])) + return polyline_points \ No newline at end of file diff --git a/src/laser_offset/importers/importer.py b/src/laser_offset/importers/importer.py new file mode 100644 index 0000000..4b66c98 --- /dev/null +++ b/src/laser_offset/importers/importer.py @@ -0,0 +1,10 @@ +from abc import ABC +from codecs import StreamReader + +from laser_offset.geometry_2d.canvas2d import Canvas2d + + +class Importer(ABC): + + def import_canvas(self, relative: bool, reader: StreamReader) -> Canvas2d: + pass diff --git a/src/laser_offset/importers/svg_importer.py b/src/laser_offset/importers/svg_importer.py new file mode 100644 index 0000000..4c6b1b6 --- /dev/null +++ b/src/laser_offset/importers/svg_importer.py @@ -0,0 +1,371 @@ +from asyncio import StreamReader +from ctypes import pointer +from re import S +from typing import List, Optional +from laser_offset.geometry_2d.canvas2d import Canvas2d +from laser_offset.geometry_2d.fill_style import FillStyle +from laser_offset.geometry_2d.point2d import Point2d +from laser_offset.geometry_2d.shape2d import Shape2d +from laser_offset.geometry_2d.shapes_2d.circle2d import Circle2d +from laser_offset.geometry_2d.shapes_2d.ellipse2d import Ellipse2d +from laser_offset.geometry_2d.shapes_2d.line2d import Line2d +from laser_offset.geometry_2d.shapes_2d.path2d import Arc, ClosePath, CubicBezier, HorizontalLine, Line, MoveOrigin, Path2d, PathComponent, Quadratic, ReflectedQuadratic, RelArc, RelCubicBezier, RelHorizontalLine, RelLine, RelMoveOrigin, RelQuadratic, RelReflectedQuadratic, RelSimpleArc, RelStrugBezier, RelVerticalLine, SimpleArc, StrugBezier, VerticalLine +from laser_offset.geometry_2d.shapes_2d.polygon2d import Polygon2d +from laser_offset.geometry_2d.shapes_2d.polyline2d import Polyline2d +from laser_offset.geometry_2d.shapes_2d.rect2d import Rect2d +from laser_offset.geometry_2d.size2d import Size2d +from laser_offset.geometry_2d.stroke_style import StrokeStyle +from laser_offset.geometry_2d.style2d import Style +from laser_offset.geometry_2d.vector2d import Vector2d +from laser_offset.importers.importer import Importer + +import xml.etree.ElementTree as ET + +class SVGImporter(Importer): + + def import_canvas(self, relative: bool, reader: StreamReader) -> Canvas2d: + xml_tree = ET.parse(reader) + root = xml_tree.getroot() + if root.tag != '{http://www.w3.org/2000/svg}svg': + raise RuntimeError("Root should be SVG") + + # TODO: Read Canvas Size + viewBox = root.attrib.get('viewBox') + centerPoint = Point2d.cartesian + size = Size2d(100, 100) + if viewBox != None: + coords = viewBox.split(" ") + if coords.__len__() == 4: + x1 = float(coords[0]) + y1 = float(coords[1]) + x2 = float(coords[2]) + y2 = float(coords[3]) + + size = Size2d(x2-x1, y2-y1) + centerPoint = Point2d.cartesian((x1+x2)/2, (y1+y2)/2) + + shapes: List[Shape2d] = list() + + for element in root: + shape = self.parse_element(element) + if shape is None: + continue + + style = self.parse_style(element) + shape.style = style + shapes.append(shape) + + + canvas = Canvas2d(centerPoint, size, shapes) + return canvas + + def parse_element(self, element: ET.Element) -> Optional[Shape2d]: + + if element.tag == '{http://www.w3.org/2000/svg}rectangle': + return self.parse_rectangle(element) + elif element.tag == '{http://www.w3.org/2000/svg}line': + return self.parse_line(element) + elif element.tag == '{http://www.w3.org/2000/svg}circle': + return self.parse_circle(element) + elif element.tag == '{http://www.w3.org/2000/svg}ellipse': + return self.parse_ellipse(element) + elif element.tag == '{http://www.w3.org/2000/svg}polyline': + return self.parse_polyline(element) + elif element.tag == '{http://www.w3.org/2000/svg}polygon': + return self.parse_polygon(element) + elif element.tag == '{http://www.w3.org/2000/svg}path': + return self.parse_path(element) + + def parse_rectangle(self, element: ET.Element) -> Optional[Rect2d]: + x = element.attrib.get('x') + y = element.attrib.get('y') + width = element.attrib.get('width') + height = element.attrib.get('height') + if x is None or y is None or width is None or height is None: + return None + + cx = element.attrib.get('cx') + return Rect2d(Style(), Point2d.cartesian(x,y), Point2d.cartesian(x+width, y+height), cx if cx is not None else 0) + + def parse_line(self, element: ET.Element) -> Optional[Line2d]: + x1 = element.attrib.get('x1') + y1 = element.attrib.get('y1') + x2 = element.attrib.get('x2') + y2 = element.attrib.get('y2') + + if x1 is None or y1 is None or x2 is None or y2 is None: + return None + + return Line2d(Style(), Point2d.cartesian(x1, y1), Point2d.cartesian(x2, y2)) + + def parse_circle(self, element: ET.Element) -> Optional[Circle2d]: + cx = element.attrib.get('cx') + cy = element.attrib.get('cy') + r = element.attrib.get('r') + + if cx is None or cy is None or r is None: + return None + + return Circle2d(Style(), Point2d.cartesian(cx,cy), r) + + def parse_ellipse(self, element: ET.Element) -> Optional[Ellipse2d]: + cx = element.attrib.get('cx') + cy = element.attrib.get('cy') + rx = element.attrib.get('rx') + ry = element.attrib.get('ry') + + if cx is None or cy is None or rx is None or ry is None: + return None + + return Ellipse2d(Style(), Point2d.cartesian(cx,cy), Size2d(rx, ry)) + + def preparse_points_string(self, points_string: str) -> str: + import re + + filtered_points = re.sub(r'(\,[\s\n]*)', ',', points_string) + points_to_split = re.sub(r'([\s\n]+)', ';', filtered_points) + return points_to_split + + def parse_points(self, element: ET.Element) -> List[Point2d]: + + points = element.attrib.get('points') + + points_to_split = self.preparse_points_string(points) + + coord_pairs = points_to_split.split(";") + + points: List[Point2d] = list() + for coord_pair in coord_pairs: + coords = coord_pair.split(',') + if coords.__len__() == 2: + points.append(Point2d.cartesian(float(coords[0]), float(coords[1]))) + return points + + def parse_polyline(self, element: ET.Element) -> Optional[Polyline2d]: + points = self.parse_points(element) + return Polyline2d(Style(), points) + + def parse_polygon(self, element: ET.Element) -> Optional[Polygon2d]: + points = self.parse_points(element) + return Polygon2d(Style(), points) + + def parse_path(self, element: ET.Element) -> Optional[Path2d]: + import re + + components_str = element.attrib.get('d') + splittable_components = re.sub(r'([\s\n]+([MmAaLlQqCcZz]))', ';\g<2>', components_str) + + parsed_components: List[PathComponent] = list() + + for component in splittable_components.split(";"): + command = component[0] + points_to_split = self.preparse_points_string(component[1:]).replace(',', ';') + parsed_component = self.parse_component(command, points_to_split) + if parsed_component is None: + continue + parsed_components.append(parsed_component) + + return Path2d(Style(), parsed_components) + + def parse_component(self, command: str, config: str) -> Optional[PathComponent]: + if command == 'M': + return self.parse_M_command(config) + elif command == 'm': + return self.parse_m_command(config) + elif command == 'H': + return self.parse_H_command(config) + elif command == 'h': + return self.parse_h_command(config) + elif command == 'V': + return self.parse_V_command(config) + elif command == 'v': + return self.parse_v_command(config) + elif command == 'L': + return self.parse_L_command(config) + elif command == 'l': + return self.parse_l_command(config) + elif command == 'C': + return self.parse_C_command(config) + elif command == 'c': + return self.parse_c_command(config) + elif command == 'S': + return self.parse_S_command(config) + elif command == 's': + return self.parse_s_command(config) + elif command == 'Q': + return self.parse_Q_command(config) + elif command == 'q': + return self.parse_q_command(config) + elif command == 'T': + return self.parse_T_command(config) + elif command == 't': + return self.parse_t_command(config) + elif command == 'A': + return self.parse_A_command(config) + elif command == 'a': + return self.parse_a_command(config) + elif command == 'Z': + return self.parse_Z_command(config) + elif command == 'z': + return self.parse_Z_command(config) + + + + def parse_M_command(self, config: str) -> Optional[PathComponent]: + parameters = config.split(';') + if parameters.__len__() != 2: + return None + return MoveOrigin(Point2d.cartesian(float(parameters[0]), float(parameters[1]))) + + def parse_m_command(self, config: str) -> Optional[PathComponent]: + parameters = config.split(';') + if parameters.__len__() != 2: + return None + return RelMoveOrigin(Vector2d.cartesian(float(parameters[0]), float(parameters[1]))) + + def parse_H_command(self, config: str) -> Optional[PathComponent]: + return HorizontalLine(float(config)) + + def parse_h_command(self, config: str) -> Optional[PathComponent]: + return RelHorizontalLine(float(config)) + + def parse_V_command(self, config: str) -> Optional[PathComponent]: + return VerticalLine(float(config)) + + def parse_v_command(self, config: str) -> Optional[PathComponent]: + return RelVerticalLine(float(config)) + + def parse_L_command(self, config: str) -> Optional[PathComponent]: + parameters = config.split(';') + if parameters.__len__() != 2: + return None + return Line(Point2d.cartesian(float(parameters[0]), float(parameters[1]))) + + def parse_l_command(self, config: str) -> Optional[PathComponent]: + parameters = config.split(';') + if parameters.__len__() != 2: + return None + return RelLine(Point2d.cartesian(float(parameters[0]), float(parameters[1]))) + + def parse_C_command(self, config: str) -> Optional[PathComponent]: + parameters = config.split(';') + if parameters.__len__() != 6: + return None + return CubicBezier( + Point2d.cartesian(float(parameters[0]), float(parameters[1])), + Point2d.cartesian(float(parameters[2]), float(parameters[3])), + Point2d.cartesian(float(parameters[4]), float(parameters[5]))) + + def parse_c_command(self, config: str) -> Optional[PathComponent]: + parameters = config.split(';') + if parameters.__len__() != 6: + return None + return RelCubicBezier( + Vector2d.cartesian(float(parameters[0]), float(parameters[1])), + Vector2d.cartesian(float(parameters[2]), float(parameters[3])), + Vector2d.cartesian(float(parameters[4]), float(parameters[5]))) + + def parse_S_command(self, config: str) -> Optional[PathComponent]: + parameters = config.split(';') + if parameters.__len__() != 4: + return None + return StrugBezier( + Point2d.cartesian(float(parameters[0]), float(parameters[1])), + Point2d.cartesian(float(parameters[2]), float(parameters[3]))) + + def parse_s_command(self, config: str) -> Optional[PathComponent]: + parameters = config.split(';') + if parameters.__len__() != 4: + return None + return RelStrugBezier( + Vector2d.cartesian(float(parameters[0]), float(parameters[1])), + Vector2d.cartesian(float(parameters[2]), float(parameters[3]))) + + def parse_Q_command(self, config: str) -> Optional[PathComponent]: + parameters = config.split(';') + if parameters.__len__() != 4: + return None + return Quadratic( + Point2d.cartesian(float(parameters[0]), float(parameters[1])), + Point2d.cartesian(float(parameters[2]), float(parameters[3]))) + + def parse_q_command(self, config: str) -> Optional[PathComponent]: + parameters = config.split(';') + if parameters.__len__() != 4: + return None + return RelQuadratic( + Vector2d.cartesian(float(parameters[0]), float(parameters[1])), + Vector2d.cartesian(float(parameters[2]), float(parameters[3]))) + + def parse_T_command(self, config: str) -> Optional[PathComponent]: + parameters = config.split(';') + if parameters.__len__() != 2: + return None + return ReflectedQuadratic( + Point2d.cartesian(float(parameters[0]), float(parameters[1]))) + + def parse_t_command(self, config: str) -> Optional[PathComponent]: + parameters = config.split(';') + if parameters.__len__() != 2: + return None + return RelReflectedQuadratic( + Vector2d.cartesian(float(parameters[0]), float(parameters[1]))) + + def parse_A_command(self, config: str) -> Optional[PathComponent]: + parameters = config.split(';') + if parameters.__len__() != 7: + return None + + if parameters[0] == parameters[1]: + return SimpleArc( + Point2d.cartesian(float(parameters[5]), float(parameters[6])), + float(parameters[0]), + True if parameters[3] == '1' or parameters[3].lower() == 'true' else False, + True if parameters[4] == '1' or parameters[4].lower() == 'true' else False + ) + else: + return Arc( + Point2d.cartesian(float(parameters[5]), float(parameters[6])), + Size2d(float(parameters[0]), float(parameters[1])), + float(parameters[2]), + True if parameters[3] == '1' or parameters[3].lower() == 'true' else False, + True if parameters[4] == '1' or parameters[4].lower() == 'true' else False + ) + + def parse_a_command(self, config: str) -> Optional[PathComponent]: + parameters = config.split(';') + if parameters.__len__() != 7: + return None + + if parameters[0] == parameters[1]: + return RelSimpleArc( + Point2d.cartesian(float(parameters[5]), float(parameters[6])), + float(parameters[0]), + True if parameters[3] == '1' or parameters[3].lower() == 'true' else False, + True if parameters[4] == '1' or parameters[4].lower() == 'true' else False + ) + return RelArc( + Vector2d.cartesian(float(parameters[5]), float(parameters[6])), + Size2d(float(parameters[0]), float(parameters[1])), + float(parameters[2]), + True if parameters[3] == '1' or parameters[3].lower() == 'true' else False, + True if parameters[4] == '1' or parameters[4].lower() == 'true' else False + ) + + def parse_Z_command(self, config: str) -> Optional[PathComponent]: + return ClosePath() + + def parse_z_command(self, config: str) -> Optional[PathComponent]: + return ClosePath() + + def parse_style(self, element: ET.Element) -> Optional[Style]: + style = Style() + fill = element.attrib.get('fill') + if fill is not None: + style.fill_style = FillStyle(fill) + + stroke = element.attrib.get('stroke') + if stroke is not None: + style.stroke_style = StrokeStyle(0.25, stroke) + + return style \ No newline at end of file diff --git a/src/laser_offset/math/float_functions.py b/src/laser_offset/math/float_functions.py new file mode 100644 index 0000000..ff026d0 --- /dev/null +++ b/src/laser_offset/math/float_functions.py @@ -0,0 +1,26 @@ +### Match functions + +def fzero(a: float, tolerance: float = 1e-5) -> bool: + """Compares float with zero using tolerance + + Parameters: + a (float): value to compare with zero + tolerance (float): tolerance of compare + + Returns: + bool:True if near zero + """ + + if abs(a) < tolerance: + return True + else: + return False + +def fclose(a: float, b: float, tolerance: float = 1e-5) -> bool: + return fzero(a-b, tolerance) + +def fge(a: float, b: float, tolerance: float = 1e-5) -> bool: + return fzero(a-b, tolerance) or a > b + +def fle(a: float, b: float, tolerance: float = 1e-5) -> bool: + return fzero(a-b, tolerance) or a < b diff --git a/src/laser_offset/modifiers/expand.py b/src/laser_offset/modifiers/expand.py new file mode 100644 index 0000000..9c22266 --- /dev/null +++ b/src/laser_offset/modifiers/expand.py @@ -0,0 +1,64 @@ +from typing import Tuple, List, Optional + +from laser_offset.geometry_2d.canvas2d import Canvas2d +from laser_offset.geometry_2d.shape2d import Shape2d +from laser_offset.modifiers.polygon_data import PolygonData +from laser_offset.geometry_2d.shapes_2d.path2d import Path2d +from laser_offset.geometry_2d.shapes_2d.circle2d import Circle2d +from laser_offset.modifiers.segment_operations import expdand_segments, fix_segments, fix_loops, make_shape +from laser_offset.modifiers.modifier import Modifier + + +class Expand(Modifier): + + expand_value: float + def __init__(self, expand_value: float): + self.expand_value = expand_value + + def perform_expand(self, polygon_data: PolygonData, shape: Shape2d, internal: bool = False) -> Shape2d: + segments = expdand_segments(polygon_data, self.expand_value, internal) + fixed_segments = fix_segments(polygon_data, segments, self.expand_value, internal) + has_fixes, segments_with_fixed_loops = fix_loops(fixed_segments) + result_segments = fix_segments(polygon_data, segments_with_fixed_loops, self.expand_value, internal) if has_fixes else fixed_segments + + result_shapes = make_shape(polygon_data, shape.style, result_segments) + return result_shapes + + def modifyPath(self, shape: Path2d) -> List[Shape2d]: + polygon_data: PolygonData = PolygonData.fromShape(shape) + result = [shape] + exp_shape = self.perform_expand(polygon_data, shape, False) + int_shape = self.perform_expand(polygon_data, shape, True) + result.append(exp_shape) + result.append(int_shape) + return result + + def modifyCircle(self, shape: Circle2d) -> List[Shape2d]: + result = [shape] + result.append(Circle2d(shape.style, shape.centerPoint, shape.radius + self.expand_value)) + result.append(Circle2d(shape.style, shape.centerPoint, shape.radius - self.expand_value)) + return result + + def modifyShape(self, shape: Shape2d) -> List[Shape2d]: + if isinstance(shape, Path2d): + return self.modifyPath(shape) + elif isinstance(shape, Circle2d): + return self.modifyCircle(shape) + else: + print(f"Unknown shape {type(shape)} {shape}") + return [shape] + + def modifyShapes(self, shapes: List[Shape2d]) -> List[Shape2d]: + result = list() + + for shape in shapes: + expanded_shapes = self.modifyShape(shape) + result += expanded_shapes + + return result + + def modify(self, canvas: Canvas2d) -> Canvas2d: + shapes = canvas.shapes.copy() + modified_shapes = self.modifyShapes(shapes) + output_canvas = Canvas2d(canvas.center, canvas.size, modified_shapes) + return output_canvas \ No newline at end of file diff --git a/src/laser_offset/modifiers/modifier.py b/src/laser_offset/modifiers/modifier.py new file mode 100644 index 0000000..99caead --- /dev/null +++ b/src/laser_offset/modifiers/modifier.py @@ -0,0 +1,18 @@ +from abc import ABC + +from typing import List + +from laser_offset.geometry_2d.canvas2d import Canvas2d +from laser_offset.geometry_2d.shape2d import Shape2d + + +class Modifier(ABC): + + def modifyShape(self, shape: Shape2d) -> Shape2d: + raise RuntimeError('Not Implemented') + + def modifyShapes(self, shapes: List[Shape2d]) -> List[Shape2d]: + raise RuntimeError('Not Implemented') + + def modify(self, canvas: Canvas2d) -> Canvas2d: + raise RuntimeError('Not Implemented') \ No newline at end of file diff --git a/src/laser_offset/modifiers/polygon_data.py b/src/laser_offset/modifiers/polygon_data.py new file mode 100644 index 0000000..c0f9c64 --- /dev/null +++ b/src/laser_offset/modifiers/polygon_data.py @@ -0,0 +1,159 @@ +from typing import NamedTuple, List, Tuple + +import math + +from laser_offset.geometry_2d.point2d import Point2d +from laser_offset.geometry_2d.vector2d import Vector2d +from laser_offset.geometry_2d.shapes_2d.path2d import PathComponent, ClosePath, MoveOrigin, SimpleArc, Line +from laser_offset.geometry_2d.shape2d import Shape2d +from laser_offset.geometry_2d.shapes_2d.path2d import Path2d +from laser_offset.geometry_2d.shapes_2d.circle2d import Circle2d +from laser_offset.geometry_2d.arc_info import ArcInfo +from laser_offset.geometry_2d.normalize_angle import normalize_angle + +class ShapeSegment(NamedTuple): + component: PathComponent + start_point: Point2d + end_point: Point2d + start_vector: Vector2d + end_vector: Vector2d + is_arc: bool + +class Vertex(NamedTuple): + point: Point2d + in_component_prev_point: Point2d + in_component: PathComponent + out_component: PathComponent + in_vector: Vector2d + out_vector: Vector2d + angle: float + + + +def vectors_from_component(prev_point: Point2d, component: PathComponent) -> Tuple[Vector2d, Vector2d]: + + if isinstance(component, Line): + lineVector = Vector2d.cartesian(component.target.x - prev_point.x, component.target.y - prev_point.y) + return (lineVector, lineVector) + + elif isinstance(component, SimpleArc): + + arcinfo = ArcInfo.fromArc(prev_point, component.target, component.radius, component.large_arc, component.cw_direction) + return (arcinfo.startVector, arcinfo.endVector) + + raise RuntimeError('Not Implemented') + +def vertex_from_components(in_component: PathComponent, in_prev_point: Point2d, out_component: PathComponent) -> Vertex: + + __, in_vector = vectors_from_component(in_prev_point, in_component) + out_vector, __ = vectors_from_component(in_component.target, out_component) + + # Out-вектор к In-вектору. + + i_v = in_vector.inverted + o_v = out_vector + angle = normalize_angle(i_v.da - o_v.da) + + return Vertex( + in_component.target, + in_prev_point, + in_component, + out_component, + in_vector.single_vector, + out_vector.single_vector, + angle + ) + +def segment_from_component(prev_point: Point2d, component: PathComponent) -> ShapeSegment: + + start_vector, end_vector = vectors_from_component(prev_point, component) + + return ShapeSegment( + component, + prev_point, + component.target, + start_vector.single_vector, + end_vector.single_vector, + isinstance(component, SimpleArc) + ) + +class PolygonData(NamedTuple): + segments: List[ShapeSegment] + vertexes: List[Vertex] + clockwise: bool + + @classmethod + def fromShape(cls, shape: Shape2d) -> 'PolygonData': + + if isinstance(shape, Path2d): + return PolygonData.fromPath2d(shape) + elif isinstance(shape, Circle2d): + return PolygonData.fromCircle2d(shape) + else: + print("Unknown type ", type(shape)) + return PolygonData([], [], True) + + @classmethod + def fromPath2d(cls, shape: Path2d) -> 'PolygonData': + + result_segments: List[ShapeSegment] = list() + result_vertexes: List[Vertex] = list() + + lines_and_arcs = list(filter(lambda component: isinstance(component, Line) or isinstance(component, SimpleArc), + shape.components)) + if not lines_and_arcs[-1].target.equals( shape.components[0].target): + lines_and_arcs.append(Line(shape.components[0].target)) + + momentum = 0 + + for index, component in enumerate(lines_and_arcs): + + prev_component = lines_and_arcs[index-1] + prev_component_start_point = lines_and_arcs[index-2].target + + vertex = vertex_from_components(prev_component, prev_component_start_point, component) + result_vertexes.append(vertex) + + segment = segment_from_component(prev_component.target, component) + result_segments.append(segment) + + momentum += (segment.end_point.x - segment.start_point.x) * (segment.end_point.y + segment.start_point.y) + + clockwise: bool = momentum > 0 + + updated_vertexes: List[Vertex] = list() + for vertex in result_vertexes: + updated_vertexes.append(Vertex( + vertex.point, + vertex.in_component_prev_point, + vertex.in_component, + vertex.out_component, + vertex.in_vector, + vertex.out_vector, + vertex.angle if not clockwise else normalize_angle(2 * math.pi - vertex.angle) + )) + + return PolygonData(result_segments, updated_vertexes, clockwise) + + + @classmethod + def fromCircle2d(cls, shape: Circle2d) -> 'PolygonData': + replacement_path: Path2d = Path2d(shape.style, [ + MoveOrigin(Point2d.cartesian(shape.centerPoint.x - shape.radius, shape.centerPoint.y)), + SimpleArc( + Point2d.cartesian(shape.centerPoint.x + shape.radius, shape.centerPoint.y), + shape.radius, + True, + False + ), + SimpleArc( + Point2d.cartesian(shape.centerPoint.x - shape.radius, shape.centerPoint.y), + shape.radius, + True, + False + ), + ClosePath() + ]) + + return cls.fromPath2d(replacement_path) + \ No newline at end of file diff --git a/src/laser_offset/modifiers/segment_operations.py b/src/laser_offset/modifiers/segment_operations.py new file mode 100644 index 0000000..4c3d2d0 --- /dev/null +++ b/src/laser_offset/modifiers/segment_operations.py @@ -0,0 +1,225 @@ +from typing import List, Tuple +import math + +from laser_offset.modifiers.polygon_data import PolygonData, ShapeSegment +from laser_offset.geometry_2d.style2d import Style +from laser_offset.geometry_2d.shape2d import Shape2d +from laser_offset.geometry_2d.vector2d import Vector2d +from laser_offset.geometry_2d.normalize_angle import normalize_angle +from laser_offset.math.float_functions import fzero, fclose, fle +from laser_offset.geometry_2d.shapes_2d.path2d import Path2d, SimpleArc, MoveOrigin, ClosePath, Line +from laser_offset.geometry_2d.arc_segment_2d import ArcSegment2d +from laser_offset.geometry_2d.segment_2d import Segment2d +from laser_offset.geometry_2d.arc_info import ArcInfo +from laser_offset.geometry_2d.shapes_2d.path2d import PathComponent + +def expdand_segments(polygon_data: PolygonData, shift_distance: float, internal: bool = False) -> List[ShapeSegment]: + + ext_segments: List[ShapeSegment] = list() + shift_sign = (1 if polygon_data.clockwise else -1) * (-1 if internal else 1) + radius_sign = (1 if polygon_data.clockwise else -1) + + # External segments + for index, segment in enumerate(polygon_data.segments): + + start_shift_vector = Vector2d.polar( shift_distance, normalize_angle(segment.start_vector.da + shift_sign * math.pi / 2 )) + end_shift_vector = Vector2d.polar( shift_distance, normalize_angle(segment.end_vector.da + shift_sign * math.pi / 2 )) + + new_start_point = segment.start_point + start_shift_vector + new_end_point = segment.end_point + end_shift_vector + + prev_segment = polygon_data.segments[index-1] + + angle_range = 5 * math.pi / 180 + + on_line = fclose(prev_segment.end_vector.da, segment.start_vector.da, angle_range) or \ + fclose(prev_segment.end_vector.da, segment.start_vector.da - 2 * math.pi, angle_range) or \ + fclose(prev_segment.end_vector.da - 2 * math.pi, segment.start_vector.da, angle_range) + + if not on_line and not segment.is_arc and segment.start_point.distance(segment.end_point) < shift_distance: + new_start_point += segment.start_vector.inverted.single_vector * (shift_distance / 2) + new_end_point += segment.end_vector.single_vector * (shift_distance / 2) + + if segment.is_arc: + + new_radius = segment.component.radius + shift_distance * (-1 if not polygon_data.clockwise == segment.component.cw_direction else 1) * (-1 if internal else 1) + + if fzero(new_radius) or new_radius < 0: + continue + + new_arc = SimpleArc(new_end_point, new_radius, segment.component.cw_direction, segment.component.large_arc) + + ext_segments.append(ShapeSegment( + component=new_arc, + start_point=new_start_point, + end_point=new_end_point, + start_vector=segment.start_vector, + end_vector=segment.end_vector, + is_arc=True + )) + + else: + + new_line = Line(new_end_point) + + ext_segments.append(ShapeSegment( + component=new_line, + start_point=new_start_point, + end_point=new_end_point, + start_vector=segment.start_vector, + end_vector=segment.end_vector, + is_arc=False + )) + + return ext_segments + + +# ---- ---- ---- ---- ---- ---- + + +def fix_segments(polygon_data: PolygonData, new_segments: List[ShapeSegment], shift_distance: float, internal: bool = False) -> List[ShapeSegment]: + + if new_segments.__len__() == 0: + return [] + + result: List[ShapeSegment] = list() + prev_segment = new_segments[-1] + + first_segment_fix = None + for index, segment in enumerate(new_segments): + + fixed_segment = segment + + if prev_segment.is_arc: + ab = ArcSegment2d.fromArc(prev_segment.start_point, prev_segment.component) + else: + ab = Segment2d(prev_segment.start_point, prev_segment.end_point) + + if segment.is_arc: + cd = ArcSegment2d.fromArc(segment.start_point, segment.component) + else: + cd = Segment2d(segment.start_point, segment.end_point) + + intersections = ab.intersection(cd) + + if intersections.__len__() == 1: + intersection = intersections[0] + + if segment.is_arc: + fixed_segment = ShapeSegment( + SimpleArc(segment.component.target, segment.component.radius, segment.component.cw_direction, segment.component.large_arc), + intersection, + segment.end_point, + segment.start_vector, + segment.end_vector, + segment.is_arc + ) + else: + fixed_segment = ShapeSegment(Line(segment.component.target), intersection, segment.end_point, segment.start_vector, segment.end_vector, segment.is_arc) + + if prev_segment.is_arc: + fixed_prev_segment = ShapeSegment( + SimpleArc(intersection, prev_segment.component.radius, prev_segment.component.cw_direction, prev_segment.component.large_arc), + prev_segment.start_point, + intersection, + prev_segment.start_vector, + prev_segment.end_vector, + prev_segment.is_arc + ) + else: + fixed_prev_segment = ShapeSegment(Line(intersection), prev_segment.start_point, intersection, prev_segment.start_vector, prev_segment.end_vector, prev_segment.is_arc) + + if result.__len__() != 0: + result[-1] = fixed_prev_segment + else: + first_segment_fix = fixed_prev_segment + + else: + arc_direction: bool = internal ^ (not polygon_data.clockwise) + points_distance = segment.start_point.distance(prev_segment.end_point) + if not fzero(points_distance): + + segmentVector = Vector2d.fromTwoPoints(prev_segment.end_point, segment.start_point) + angle_range = 5 * math.pi / 180 + + on_line = fclose(prev_segment.end_vector.da, segment.start_vector.da, angle_range) or \ + fclose(prev_segment.end_vector.da, segment.start_vector.da - 2 * math.pi, angle_range) or \ + fclose(prev_segment.end_vector.da - 2 * math.pi, segment.start_vector.da, angle_range) + + arc_proposal = SimpleArc(segment.start_point, shift_distance, not arc_direction, False) + arc_info = ArcInfo.fromArc(prev_segment.end_point, arc_proposal.target, arc_proposal.radius, arc_proposal.large_arc, arc_proposal.cw_direction) + + if fle(segment.start_point.distance(prev_segment.end_point), shift_distance) and on_line and arc_info.radius <= shift_distance: + result.append(ShapeSegment(Line(segment.start_point), prev_segment.end_point, segment.start_point, prev_segment.end_vector, segment.start_vector, False)) + else: + result.append(ShapeSegment(SimpleArc(segment.start_point, shift_distance, not arc_direction, False), prev_segment.end_point, segment.start_point, prev_segment.end_vector, segment.start_vector, True)) + + if index == new_segments.__len__() - 1 and first_segment_fix is not None: + fixed_segment = ShapeSegment( + first_segment_fix.component, + fixed_segment.start_point, + first_segment_fix.end_point, + fixed_segment.start_vector, + fixed_segment.end_vector, + fixed_segment.is_arc + ) + + result.append(fixed_segment) + + prev_segment = fixed_segment + + return result + + +# ---- ---- ---- ---- ---- ---- + + +def fix_loops(segments: List[ShapeSegment]) -> Tuple[bool, List[ShapeSegment]]: + + items_to_remove: List[int] = list() + + for index, segment in enumerate(segments): + + for prev_index, prev_segment in enumerate(segments[:index]): + + if prev_segment.is_arc: + ab = ArcSegment2d.fromArc(prev_segment.start_point, prev_segment.component) + else: + ab = Segment2d(prev_segment.start_point, prev_segment.end_point) + + if segment.is_arc: + cd = ArcSegment2d.fromArc(segment.start_point, segment.component) + else: + cd = Segment2d(segment.start_point, segment.end_point) + + intersections = ab.intersection(cd) + + if intersections.__len__() >= 1 and abs(prev_index-index) > 1 and not (index == segments.__len__() -1 and prev_index == 0): + items_to_remove.append(range(prev_index+1, index)) + + if items_to_remove.__len__() == 0: + return (False, segments) + + result_indexes = list() + for items_range in items_to_remove: + result_indexes += list(items_range) + + new_segments = [i for j, i in enumerate(segments) if j not in result_indexes] + + return (True, new_segments) + + +# ---- ---- ---- ---- ---- ---- + + +def make_shape(polygon_data: PolygonData, style: Style, shape: List[ShapeSegment]) -> Shape2d: + + components: List[PathComponent] = list() + + for segment in shape: + components.append(segment.component) + + components.insert(0, MoveOrigin(shape[0].start_point)) + components.append(ClosePath()) + + return Path2d(style, components) \ No newline at end of file