diff --git a/cli/axicli/axidraw_cli.py b/cli/axicli/axidraw_cli.py index ee9b49e..0178e65 100644 --- a/cli/axicli/axidraw_cli.py +++ b/cli/axicli/axidraw_cli.py @@ -36,7 +36,7 @@ -Copyright 2021 Windell H. Oskay, Evil Mad Scientist Laboratories +Copyright 2022 Windell H. Oskay, Evil Mad Scientist Laboratories The MIT License (MIT) @@ -73,7 +73,7 @@ from plotink.plot_utils_import import from_dependency_import # plotink exit_status = from_dependency_import("ink_extensions_utils.exit_status") -cli_version = "AxiDraw Command Line Interface 3.0.2" +cli_version = "AxiDraw Command Line Interface 3.1.0" quick_help = ''' Basic syntax to plot a file: axicli svg_in [OPTIONS] @@ -84,7 +84,7 @@ For full user guide, please see: https://axidraw.com/doc/cli_api/ - (c) 2021 Evil Mad Scientist Laboratories + (c) 2022 Evil Mad Scientist Laboratories ''' def axidraw_CLI(dev = False): @@ -185,7 +185,7 @@ def axidraw_CLI(dev = False): + "1: Pen-down movement. 2: Pen-up movement. 3: All movement.") parser.add_argument("-G","--reordering", \ - metavar='REORDERING', type=int, \ + metavar='VALUE', type=int, \ help="SVG reordering option (0-2)."\ + " 0: None; Preserve order of objects given in SVG file."\ + " 1: Reorder objects, preserving path orientation."\ @@ -216,6 +216,21 @@ def axidraw_CLI(dev = False): metavar='FILE', \ help="Optional SVG output file name") + parser.add_argument("-O","--digest",\ + metavar='VALUE', type=int,\ + help="Plot digest output option (0-2)."\ + + " 0: No change to behavior or output (Default)."\ + + " 1: Output 'plob' digest, not full SVG, when saving file."\ + + " 2: Disable plots and previews; generate digest only.") + + parser.add_argument("-W","--webhook", \ + action="store_const", const='True', \ + help='Enable webhook alerts') + + parser.add_argument("-U","--webhook_url",\ + metavar='URL', type=str,\ + help="URL for webhook alerts") + parser.add_argument("--version", action='store_const', const='True', help="Output the version of axicli") @@ -314,17 +329,9 @@ def axidraw_CLI(dev = False): "pen_rate_lower", "pen_rate_raise", "pen_delay_down", "pen_delay_up", "random_start", "reordering", "no_rotate", "const_speed", "report_time", "manual_cmd", "walk_dist", "layer", "copies", "page_delay", "preview", - "rendering", "model", "port", "port_config"] + "rendering", "model", "port", "port_config", 'digest', 'webhook', 'webhook_url',] utils.assign_option_values(adc.options, args, [config_dict], option_names) -# The following options are deprecated and should not be used. -# adc.options.setup_type = args.setup_type # Legacy input; not needed -# adc.options.smoothness = args.smoothness # Legacy input; not needed -# adc.options.cornering = args.cornering # Legacy input; not needed -# adc.options.resolution = args.resolution # Legacy input; not needed -# adc.options.resume_type = args.resume_type # Legacy input; not needed -# adc.options.auto_rotate = args.auto_rotate # Legacy input; not needed - exit_status.run(adc.effect) # Plot the document if utils.has_output(adc) and not use_trivial_file: utils.output_result(args.output_file, adc.outdoc) diff --git a/cli/axicli/utils.py b/cli/axicli/utils.py index 957940b..b1cb8ee 100644 --- a/cli/axicli/utils.py +++ b/cli/axicli/utils.py @@ -75,31 +75,37 @@ def load_configs(config_list): def load_config(config): - try: - if config is None: - config_dict = {} - elif len(config) > 3 and config[-3:] == ".py": - config_dict = runpy.run_path(config) + if config is None: + return {} + + config_dict = None + try: # try assuming config is a filename + config_dict = runpy.run_path(config) + except SyntaxError as se: + print('Config file {} contains a syntax error on line {}:'.format(se.filename, se.lineno)) + print(' {}'.format(se.text)) + print('The config file should be a python file (e.g., a file that ends in ".py").') + sys.exit(1) + except IOError as ose: + if len(config) > 3 and config[-3:] == ".py" and ose.errno == errno.ENOENT: + # if config is a filename ending in ".py" but it doesn't appear to exist + print("Could not find any file named {}.".format(config)) + print("Check the spelling and/or location.") + sys.exit(1) else: + # Either config is a config file that doesn't have a .py AND it doesn't exist + # or config is a module with warnings.catch_warnings(): warnings.simplefilter("ignore") # since technically this is importing "axidrawinternal.axidraw_conf" twice, it would generate a warning, but we can ignore it - config_dict = runpy.run_module(config) - - return { key: value for key, value in config_dict.items() if key[0] != "_" } - - except IOError as ose: - if ose.errno == errno.ENOENT: # no such file or directory - print('Could not find any file named {}.'.format(config)) - print('Check the spelling and/or location.') - sys.exit(1) - else: - raise - except SyntaxError as e: - print('Config file {} contains a syntax error on line {}:'.format(e.filename, e.lineno)) - print(' {}'.format(e.text)) - print('The config file should be a python file (*.py).') - sys.exit(1) + try: # assume config is a module + config_dict = runpy.run_module(config) + except ImportError as ie: # oops, no module named that + # config may be a config file that doesn't have a .py AND doesn't exist + print("Could not find any file or module named {}.".format(config)) + sys.exit(1) + + return { key: value for key, value in config_dict.items() if key[0] != "_" } def assign_option_values(options_obj, command_line, configs, option_names): """ `configs` is a list of dicts containing values for the options, in order of priority. diff --git a/cli/examples_python/estimate_time.py b/cli/examples_python/estimate_time.py index a6b2457..7f37847 100755 --- a/cli/examples_python/estimate_time.py +++ b/cli/examples_python/estimate_time.py @@ -1,6 +1,5 @@ #!/usr/bin/env python # -*- encoding: utf-8 -#- -from __future__ import print_function ''' estimate_time.py @@ -10,13 +9,8 @@ Run this demo by calling: python estimate_time.py -To save the time estimate to a file, you may be able to use a command similar to: - python estimate_time.py > file.txt - - AxiDraw python API documentation is hosted at: https://axidraw.com/doc/py_api/ - ''' ''' @@ -40,7 +34,7 @@ -Copyright 2020 Windell H. Oskay, Evil Mad Scientist Laboratories +Copyright 2021 Windell H. Oskay, Evil Mad Scientist Laboratories The MIT License (MIT) @@ -103,12 +97,10 @@ ad.plot_setup("AxiDraw_trivial.svg") ''' - - ad.options.preview = True +ad.options.report_time = True # Enable time estimates ad.plot_run() # plot the document - -# print("Estimated print time: {0} ms".format(ad.pt_estimate)) -print("{0}".format(ad.pt_estimate)) +print_time_seconds = ad.time_estimate +print("Estimated print time: {0} s".format(print_time_seconds)) diff --git a/cli/examples_python/interactive_penheights.py b/cli/examples_python/interactive_penheights.py index 5a23399..fd6d133 100755 --- a/cli/examples_python/interactive_penheights.py +++ b/cli/examples_python/interactive_penheights.py @@ -12,6 +12,52 @@ ''' + +''' +About this software: + +The AxiDraw writing and drawing machine is a product of Evil Mad Scientist +Laboratories. https://axidraw.com https://shop.evilmadscientist.com + +This open source software is written and maintained by Evil Mad Scientist +to support AxiDraw users across a wide range of applications. Please help +support Evil Mad Scientist and open source software development by purchasing +genuine AxiDraw hardware. + +AxiDraw software development is hosted at https://github.com/evil-mad/axidraw + +Additional AxiDraw documentation is available at http://axidraw.com/docs + +AxiDraw owners may request technical support for this software through our +github issues page, support forums, or by contacting us directly at: +https://shop.evilmadscientist.com/contact + + + +Copyright 2021 Windell H. Oskay, Evil Mad Scientist Laboratories + +The MIT License (MIT) + +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. + +''' + import sys import time diff --git a/cli/examples_python/interactive_usb_com.py b/cli/examples_python/interactive_usb_com.py index 1cba9d2..cf5d360 100755 --- a/cli/examples_python/interactive_usb_com.py +++ b/cli/examples_python/interactive_usb_com.py @@ -1,6 +1,5 @@ #!/usr/bin/env python # -*- encoding: utf-8 -#- -from __future__ import print_function ''' interactive_usb_com.py @@ -33,6 +32,51 @@ ''' +''' +About this software: + +The AxiDraw writing and drawing machine is a product of Evil Mad Scientist +Laboratories. https://axidraw.com https://shop.evilmadscientist.com + +This open source software is written and maintained by Evil Mad Scientist +to support AxiDraw users across a wide range of applications. Please help +support Evil Mad Scientist and open source software development by purchasing +genuine AxiDraw hardware. + +AxiDraw software development is hosted at https://github.com/evil-mad/axidraw + +Additional AxiDraw documentation is available at http://axidraw.com/docs + +AxiDraw owners may request technical support for this software through our +github issues page, support forums, or by contacting us directly at: +https://shop.evilmadscientist.com/contact + + + +Copyright 2021 Windell H. Oskay, Evil Mad Scientist Laboratories + +The MIT License (MIT) + +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. + +''' + import sys import time diff --git a/cli/examples_python/plot.py b/cli/examples_python/plot.py index e8fd43c..554006f 100755 --- a/cli/examples_python/plot.py +++ b/cli/examples_python/plot.py @@ -1,6 +1,5 @@ #!/usr/bin/env python # -*- encoding: utf-8 -#- -from __future__ import print_function ''' plot.py @@ -48,7 +47,7 @@ -Copyright 2020 Windell H. Oskay, Evil Mad Scientist Laboratories +Copyright 2021 Windell H. Oskay, Evil Mad Scientist Laboratories The MIT License (MIT) diff --git a/cli/examples_python/plot_inline.py b/cli/examples_python/plot_inline.py index 6197ca4..a938c18 100755 --- a/cli/examples_python/plot_inline.py +++ b/cli/examples_python/plot_inline.py @@ -1,6 +1,5 @@ #!/usr/bin/env python # -*- encoding: utf-8 -#- -from __future__ import print_function ''' plot_inline.py @@ -48,7 +47,7 @@ -Copyright 2020 Windell H. Oskay, Evil Mad Scientist Laboratories +Copyright 2021 Windell H. Oskay, Evil Mad Scientist Laboratories The MIT License (MIT) @@ -166,3 +165,4 @@ ''' ad.plot_run() # plot the document + diff --git a/cli/examples_python/toggle.py b/cli/examples_python/toggle.py index 7efe13a..39e9a46 100755 --- a/cli/examples_python/toggle.py +++ b/cli/examples_python/toggle.py @@ -38,7 +38,7 @@ -Copyright 2020 Windell H. Oskay, Evil Mad Scientist Laboratories +Copyright 2021 Windell H. Oskay, Evil Mad Scientist Laboratories The MIT License (MIT) diff --git a/cli/requirements/requirements.txt b/cli/requirements/requirements.txt index 93a3559..a33bddd 100644 --- a/cli/requirements/requirements.txt +++ b/cli/requirements/requirements.txt @@ -1,11 +1,12 @@ axidrawinternal @ git+https://${AXIDRAWINTERNAL_DEPLOY_USER}:${AXIDRAWINTERNAL_DEPLOY_PASS}@gitlab.com/evil-mad/AxiDraw-Internal@master -certifi==2021.5.30 -chardet==3.0.4 +certifi==2021.10.8 +chardet==4.0.0 +charset-normalizer==2.0.7 future==0.18.2 -idna==2.10 -ink-extensions>=1.1.0 -lxml>=4.6.2 -plotink>=1.3.1 -pyserial>=3.5 -requests==2.25.1 -urllib3==1.26.5 +idna==3.3 +ink-extensions==1.1.0 +lxml==4.6.4 +plotink==1.4.0 +pyserial==3.5 +requests==2.26.0 +urllib3==1.26.7 diff --git a/cli/setup.py b/cli/setup.py index 3fdeba4..587c4d5 100644 --- a/cli/setup.py +++ b/cli/setup.py @@ -17,7 +17,7 @@ print("WARNING: It looks like you might be attempting to install this in a non-pip way. This is discouraged. Use `pip install .` (or `pip install -r requirements.txt` if you are a developer with access to the relevant private repositories).") extras_require = { - 'dev': [ 'axidrawinternal'], # see installation instructions + 'dev': [ 'axidrawinternal>=3.0.0'], # see installation instructions 'test': [ 'coverage', # coverage run -m unittest discover && coverage html 'mock', @@ -45,7 +45,10 @@ def replacement_setup(*args, **kwargs): pass subprocess.check_call([sys.executable, '-m', 'pip', 'install', wheel_file]) except (AttributeError, subprocess.CalledProcessError) as err: - raise RuntimeError("Could not install one or more prebuilt dependencies.") from err + if sys.version_info < (3, 6): + pass # pip has a standard message for this situation (see `python_requires` arg below) + else: # python3 + raise RuntimeError("Could not install one or more prebuilt dependencies.", err.with_traceback(err.__traceback__)) original_setup(*args, **kwargs) @@ -53,8 +56,8 @@ def replacement_setup(*args, **kwargs): setuptools.setup = replacement_setup replacement_setup( - name='pyaxidraw', - version='2.7.5', + name='axicli', + version='3.1.0', python_requires='>=3.6.0', long_description=long_description, long_description_content_type='text/plain', @@ -63,10 +66,10 @@ def replacement_setup(*args, **kwargs): author_email='contact@evilmadscientist.com', packages=setuptools.find_packages(exclude=['contrib', 'docs', 'test']), install_requires=[ - # this only includes publicly available dependencies 'ink_extensions>=1.1.0', - 'lxml', - 'plotink>=1.2.4', + 'lxml>=4.6.2', + 'plotink>=1.4.0', + 'pyserial>=3.5', 'requests', # just for the certificates for now ], extras_require=extras_require, diff --git a/inkscape driver/axidraw.inx b/inkscape driver/axidraw.inx index 3281837..616ab4e 100755 --- a/inkscape driver/axidraw.inx +++ b/inkscape driver/axidraw.inx @@ -24,6 +24,7 @@ For technical support or other assistance, please write to 15 <_param name="splashpage5" type="description" indent="6" >Tip: Select 0 copies to plot continuously until paused. + @@ -95,9 +96,9 @@ _gui-text="Pen height: DOWN, (%):">30 - + -<_param name="instructions_options7" type="description" appearance="header">Preview mode: +<_param name="instructions_preview" type="description" appearance="header">Preview mode: false @@ -107,12 +108,14 @@ _gui-text="Pen height: DOWN, (%):">30 <_option value="0">None +<_param name="instructions_preview" type="description" appearance="header">Webhook notifications: +false + + - - <_param name="instructions_options6" type="description" appearance="header">Advanced Options: true @@ -253,7 +256,7 @@ the Return to Home Corner command. <_param name="copyright" type="description" indent="5" xml:space="preserve" ->Version 3.0.2 — Copyright 2021 Evil Mad Scientist +>Version 3.1.0 — Copyright 2021 Evil Mad Scientist diff --git a/inkscape driver/axidraw.py b/inkscape driver/axidraw.py index 1756918..48847fb 100644 --- a/inkscape driver/axidraw.py +++ b/inkscape driver/axidraw.py @@ -1,6 +1,6 @@ # coding=utf-8 # -# Copyright 2021 Windell H. Oskay, Evil Mad Scientist Laboratories +# Copyright 2022 Windell H. Oskay, Evil Mad Scientist Laboratories # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -51,6 +51,7 @@ ebb_motion = from_dependency_import('plotink.ebb_motion') plot_utils = from_dependency_import('plotink.plot_utils') text_utils = from_dependency_import('plotink.text_utils') +requests = from_dependency_import('requests') from axidrawinternal import path_objects from axidrawinternal import digest_svg @@ -76,7 +77,7 @@ def __init__(self, default_logging=True, user_message_fun=message.emit, params=N self.OptionParser.add_option_group( common_options.core_mode_options(self.OptionParser, params.__dict__)) - self.version_string = "3.0.2" # Dated 2021-10-19 + self.version_string = "3.1.0" # Dated 2022-01-05 self.spew_debugdata = False @@ -90,9 +91,8 @@ def __init__(self, default_logging=True, user_message_fun=message.emit, params=N self.Secondary = False self.user_message_fun = user_message_fun - # So that we only generate a warning once for each - # unsupported SVG element, we use a dictionary to track - # which elements have received a warning + # So that we only generate a warning once for each unsupported SVG element, + # we use a dictionary to track which elements have received a warning self.warnings = {} self.start_x = None @@ -120,7 +120,8 @@ def set_secondary(self, suppress_standard_out=True): def suppress_standard_output_stream(self): """ Save values we will need later in unsuppress_standard_output_stream """ - self.logging_attrs["additional_handlers"] = [SecondaryErrorHandler(self), SecondaryNonErrorHandler(self)] + self.logging_attrs["additional_handlers"] = [SecondaryErrorHandler(self),\ + SecondaryNonErrorHandler(self)] self.logging_attrs["emit_fun"] = self.user_message_fun logger.removeHandler(self.logging_attrs["default_handler"]) for handler in self.logging_attrs["additional_handlers"]: @@ -151,7 +152,8 @@ def set_defaults(self): self.svg_paused_y_old = float(0.0) self.svg_rand_seed_old = int(1) self.svg_row_old = int(0) - self.svg_application_old = "" + self.svg_application_old = None + self.svg_plob_version = None self.use_layer_speed = False self.use_layer_pen_height = False self.resume_mode = False @@ -207,7 +209,6 @@ def update_options(self): def effect(self): """Main entry point: check to see which mode/tab is selected, and act accordingly.""" - self.start_time = time.time() try: @@ -219,6 +220,7 @@ def effect(self): self.error_out = '' # Text log for significant errors self.pt_estimate = 0.0 # plot time estimate, milliseconds + self.time_estimate = 0.0 # plot time estimate, s. Available to Python API self.doc_units = "in" @@ -330,6 +332,9 @@ def effect(self): else: self.options.mode = "toggle" + if self.options.digest > 1: # Generate digest only; do not run plot or preview + self.options.preview = True # Disable serial communication; restrict certain functions + if not self.options.preview: self.serial_connect() @@ -348,6 +353,7 @@ def effect(self): if self.options.page_delay < 0: self.options.page_delay = 0 + self.read_plotdata(self.svg) if self.options.mode == "plot": self.copies_to_plot = self.options.copies if self.copies_to_plot == 0: @@ -386,7 +392,6 @@ def effect(self): self.copies_to_plot = 0 elif self.options.mode == "res_home" or self.options.mode == "res_plot": - self.read_plotdata(self.svg) self.resume_data_needs_updating = True self.resume_plot_setup() if self.resume_mode: @@ -488,12 +493,11 @@ def read_plotdata(self, svg_to_check): """ Read plot progress data, stored in a custom "plotdata" XML element """ self.svg_data_read = False data_node = None - nodes = svg_to_check.xpath('//svg:plotdata', namespaces=inkex.NSS) + nodes = svg_to_check.xpath("//*[self::svg:plotdata|self::plotdata]", namespaces=inkex.NSS) if nodes: data_node = nodes[0] - if data_node is not None: - try: + try: # Core data required for resuming plots self.svg_layer_old = int(data_node.get('layer')) self.svg_node_count_old = int(data_node.get('node')) self.svg_last_path_old = int(data_node.get('last_path')) @@ -502,24 +506,34 @@ def read_plotdata(self, svg_to_check): self.svg_last_known_y_old = float(data_node.get('last_known_y')) self.svg_paused_x_old = float(data_node.get('paused_x')) self.svg_paused_y_old = float(data_node.get('paused_y')) - self.svg_application_old = str(data_node.get('application')) self.svg_data_read = True - self.svg_rand_seed_old = int(float(data_node.get('randseed'))) - except TypeError: - self.svg.remove(data_node) # An error leaves svg_data_read as False. - # Remove the node, to prevent adding a duplicate plotdata node later. + self.svg_application_old = data_node.get('application') + self.svg_plob_version = data_node.get('plob_version') + except TypeError: # An error leaves svg_data_read as False. + self.svg.remove(data_node) # Remove data node try: # Optional attributes: self.svg_row_old = int(data_node.get('row')) except TypeError: pass # Leave as default if not found + try: # Optional attributes: + self.svg_rand_seed_old = int(float(data_node.get('randseed'))) + except TypeError: + pass # Leave as default if not found def update_plotdata(self): """ Write plot progress data, stored in a custom "plotdata" XML element """ if not self.svg_data_written: - for node in self.svg.xpath('//svg:plotdata', namespaces=inkex.NSS): + for node in self.svg.xpath("//*[self::svg:plotdata|self::plotdata]",\ + namespaces=inkex.NSS): node_parent = node.getparent() node_parent.remove(node) data_node = etree.SubElement(self.svg, 'plotdata') + data_node.set('application', "axidraw") # Name of this program + data_node.set('model', str(self.options.model)) + if self.options.digest: # i.e., if self.options.digest > 0 + data_node.set('plob_version', str(path_objects.PLOB_VERSION)) + elif self.svg_plob_version: + data_node.set('plob_version', str(self.svg_plob_version)) data_node.set('layer', str(self.svg_layer)) data_node.set('node', str(self.svg_node_count)) data_node.set('last_path', str(self.svg_last_path)) @@ -531,7 +545,6 @@ def update_plotdata(self): data_node.set('randseed', str(self.svg_rand_seed)) data_node.set('row', str(self.svg_row_old)) data_node.set('id', str(int(time.time()))) - data_node.set('application', "axidraw") # Name of this program self.svg_data_written = True def setup_command(self): @@ -677,7 +690,7 @@ def plot_document(self): return if not self.options.preview: - self.options.rendering = 0 # Only render previews if we are in preview mode. + self.options.rendering = 0 # Only render previews if we are in preview mode. self.vel_data_plot = False if self.serial_port is None: return @@ -690,75 +703,88 @@ def plot_document(self): # Modifications to SVG -- including re-ordering and text substitution # may be made at this point, and will not be preserved. - vb = self.svg.get('viewBox') - if vb: + v_b = self.svg.get('viewBox') + if v_b: p_a_r = self.svg.get('preserveAspectRatio') - sx, sy, ox, oy = plot_utils.vb_scale(vb, p_a_r, self.svg_width, self.svg_height) + s_x, s_y, o_x, o_y = plot_utils.vb_scale(v_b, p_a_r, self.svg_width, self.svg_height) else: - sx = 1.0 / float(plot_utils.PX_PER_INCH) # Handle case of no viewbox - sy = sx - ox = 0.0 - oy = 0.0 + s_x = 1.0 / float(plot_utils.PX_PER_INCH) # Handle case of no viewbox + s_y = s_x + o_x = 0.0 + o_y = 0.0 # Initial transform of document is based on viewbox, if present: self.svg_transform = simpletransform.parseTransform(\ - 'scale({0:.6E},{1:.6E}) translate({2:.6E},{3:.6E})'.format(sx, sy, ox, oy)) - - # Process the input SVG into a simplified, restricted-format DocDigest object: - digester = digest_svg.DigestSVG() # Initialize class - - digest_params = [self.svg_width, self.svg_height, self.svg_layer,\ - self.params.bezier_segmentation_tolerance,\ - self.params.segment_supersample_tolerance, self.warnings] + 'scale({0:.6E},{1:.6E}) translate({2:.6E},{3:.6E})'.format(s_x, s_y, o_x, o_y)) + + valid_plob = False + if self.svg_plob_version: + logger.debug('Checking Plob') + valid_plob = digest_svg.verify_plob(self.svg, self.options.model) + if valid_plob: + logger.debug('Valid plob found; skipping standard pre-processing.') + digest = path_objects.DocDigest() + digest.from_plob(self.svg) + else: # Process the input SVG into a simplified, restricted-format DocDigest object: + digester = digest_svg.DigestSVG() # Initialize class + digest_params = [self.svg_width, self.svg_height, s_x, s_y, self.svg_layer,\ + self.params.bezier_segmentation_tolerance,\ + self.params.segment_supersample_tolerance, self.warnings] + + digest = digester.process_svg(self.svg, digest_params, self.svg_transform) + self.warnings = digester.warnings + if digester.warning_text.strip(): + self.user_message_fun(digester.warning_text) - digest = digester.process_svg(self.svg, digest_params, self.svg_transform) - self.warnings = digester.warnings - if digester.warning_text.strip(): - self.user_message_fun(digester.warning_text) + """ + Possible future work: Perform hidden-line clipping at this point, based on object + fills, clipping masks, and document and plotting bounds, via self.bounds + """ + """ + Possible future work: Perform automatic hatch filling at this point, based on object + fill colors and possibly other factors. + """ - """ - Possible future work: Perform hidden-line clipping at this point, based on object - fills, clipping masks, and document and plotting bounds, via self.bounds - """ - """ - Possible future work: Perform automatic hatch filling at this point, based on object - fill colors and possibly other factors. - """ + digest.flatten() # Flatten digest, readying it for optimizations and plotting - digest.flatten() # Flatten digest, readying it for optimizations and plotting + if self.rotate_page: # Rotate digest + digest.rotate(self.params.auto_rotate_ccw) - if self.rotate_page: # Rotate digest - digest.rotate(self.params.auto_rotate_ccw) + """ + Clip digest at plot bounds + """ + if not self.ignore_limits: + if self.rotate_page: + doc_bounds = [self.svg_height + 1e-9, self.svg_width + 1e-9] + else: + doc_bounds = [self.svg_width + 1e-9, self.svg_height + 1e-9] + out_of_bounds_flag = boundsclip.clip_at_bounds(digest, self.bounds, doc_bounds,\ + self.params.bounds_tolerance, self.params.clip_to_page) + if out_of_bounds_flag: + self.warn_out_of_bounds = True - """ - Clip digest at plot bounds - """ - if not self.ignore_limits: - if self.rotate_page: - doc_bounds = [self.svg_height + 1e-9, self.svg_width + 1e-9] - else: - doc_bounds = [self.svg_width + 1e-9, self.svg_height + 1e-9] - out_of_bounds_flag = boundsclip.clip_at_bounds(digest, self.bounds, doc_bounds,\ - self.params.bounds_tolerance, self.params.clip_to_page) - if out_of_bounds_flag: - self.warn_out_of_bounds = True + """ + Optimize digest + """ + allow_reverse = self.options.reordering > 1 - """ - Optimize digest - """ - allow_reverse = self.options.reordering > 1 + plot_optimizations.connect_nearby_ends(digest, allow_reverse, self.params.min_gap) - plot_optimizations.connect_nearby_ends(digest, allow_reverse, self.params.min_gap) + if self.options.random_start: + plot_optimizations.randomize_start(digest, self.svg_rand_seed) - if self.options.random_start: - plot_optimizations.randomize_start(digest, self.svg_rand_seed) - if self.options.reordering > 0: - plot_optimizations.reorder(digest, allow_reverse) + if self.options.reordering > 0: + plot_optimizations.reorder(digest, allow_reverse) # If it is necessary to save as a Plob, that conversion can be made like so: # plob = digest.to_plob() # Unnecessary re-conversion for testing only # digest.from_plob(plob) # Unnecessary re-conversion for testing only + if self.options.digest > 1: # No plotting; generate digest only. + self.document = copy.deepcopy(digest.to_plob()) + self.svg = self.document + return + try: # wrap everything in a try so we can be sure to close the serial port self.servo_setup_wrapper() self.pen_raise() @@ -781,23 +807,17 @@ def plot_document(self): self.user_message_fun(gettext.gettext('Resume plot error; plot terminated')) return # something has gone wrong; possibly an ill-timed button press? - # Step through and plot contents of document digest: - self.plot_doc_digest(digest) + self.plot_doc_digest(digest) # Step through and plot contents of document digest self.pen_raise() # Always end with pen-up - # Return to home after end of normal plot: - if not self.b_stopped and self.pt_first: - + if not self.b_stopped and self.pt_first: # Return to home after end of normal plot: self.x_bounds_min = 0 self.y_bounds_min = 0 - - # Option for different final XY position: - if self.end_x is not None: + if self.end_x is not None: # Option for different final XY position: f_x = self.end_x else: f_x = self.pt_first[0] - if self.end_y is not None: f_y = self.end_y else: @@ -811,16 +831,17 @@ def plot_document(self): and prior to saving updated "plotdata" progress data in the file. No changes to the SVG document prior to this point will be saved. - Doing so allows us to use routines that alter the SVG - prior to this point -- e.g., plot re-ordering for speed - or font substitutions. + Doing so allows us to use routines that alter the SVG prior to this point, + e.g., plot re-ordering for speed or font substitutions. """ - try: - # If we have a good "backup_original", - # revert to _that_, rather than the true original - self.document = copy.deepcopy(self.backup_original) - self.svg = self.document.getroot() + if self.options.digest: + self.document = copy.deepcopy(digest.to_plob()) + self.svg = self.document + self.options.rendering = 0 # Turn off rendering + else: + self.document = copy.deepcopy(self.backup_original) + self.svg = self.document.getroot() except AttributeError: self.document = copy.deepcopy(self.original_document) self.svg = self.document.getroot() @@ -860,7 +881,7 @@ def plot_document(self): if self.options.rendering > 0: # Render preview. Only possible when in preview mode. preview_transform = simpletransform.parseTransform( 'translate({2:.6E},{3:.6E}) scale({0:.6E},{1:.6E})'.format( - 1.0/sx, 1.0/sy, -ox, -oy)) + 1.0/s_x, 1.0/s_y, -o_x, -o_y)) path_attrs = { 'transform': simpletransform.formatTransform(preview_transform)} preview_layer = etree.Element(inkex.addNS('g', 'svg'), path_attrs, nsmap=inkex.NSS) @@ -871,9 +892,9 @@ def plot_document(self): preview_layer.set(inkex.addNS('groupmode', 'inkscape'), 'layer') preview_layer.set(inkex.addNS('label', 'inkscape'), '% Preview') preview_sl_d.set(inkex.addNS('groupmode', 'inkscape'), 'layer') - preview_sl_d.set(inkex.addNS('label', 'inkscape'), '% Pen-down drawing') + preview_sl_d.set(inkex.addNS('label', 'inkscape'), 'Pen-down movement') preview_sl_u.set(inkex.addNS('groupmode', 'inkscape'), 'layer') - preview_sl_u.set(inkex.addNS('label', 'inkscape'), '% Pen-up transit') + preview_sl_u.set(inkex.addNS('label', 'inkscape'), 'Pen-up movement') self.svg.append(preview_layer) @@ -951,43 +972,53 @@ def plot_document(self): etree.SubElement(preview_layer, inkex.addNS('path', 'svg '), path_attrs, nsmap=inkex.NSS) - if self.options.report_time and (self.copies_to_plot == 0): - # Only calculate these after plotting last copy, - # and only if report_time is enabled. - - elapsed_time = time.time() - self.start_time - self.time_elapsed = elapsed_time # Available for use by python API + finally: # In case of an exception and loss of the serial port... + pass + if self.copies_to_plot == 0: # Only calculate after plotting last copy + elapsed_time = time.time() - self.start_time + self.time_elapsed = elapsed_time # Available for use by python API + if self.options.report_time: if self.options.preview: - self.time_estimate = self.pt_estimate / 1000.0 # Available for use by python API + self.time_estimate = self.pt_estimate / 1000.0 # Available to python API else: self.time_estimate = elapsed_time # Available for use by python API - d_dist = 0.0254 * self.pen_down_travel_inches u_dist = 0.0254 * self.pen_up_travel_inches t_dist = d_dist + u_dist # Total distance self.distance_pendown = d_dist # Available for use by python API self.distance_total = t_dist # Available for use by python API - if (not self.called_externally): # Verbose mode; report data to user + if not self.called_externally: # Verbose mode; report data to user if self.options.preview: self.user_message_fun("Estimated print time: " +\ text_utils.format_hms(self.pt_estimate, True)) + + elapsed_text = text_utils.format_hms(elapsed_time) if self.options.preview: - self.user_message_fun("Length of path to draw: {0:1.2f} m.".format(d_dist)) - self.user_message_fun("Pen-up travel distance: {0:1.2f} m.".format(u_dist)) - self.user_message_fun("Total movement distance: {0:1.2f} m.".format(t_dist)) + self.user_message_fun("Length of path to draw: {0:1.2f} m".format(d_dist)) + self.user_message_fun("Pen-up travel distance: {0:1.2f} m".format(u_dist)) + self.user_message_fun("Total movement distance: {0:1.2f} m".format(t_dist)) self.user_message_fun("This estimate took " + elapsed_text) else: self.user_message_fun("Elapsed time: " + elapsed_text) - self.user_message_fun("Length of path drawn: {0:1.2f} m.".format(d_dist)) - self.user_message_fun("Total distance moved: {0:1.2f} m.".format(t_dist)) - # self.user_message_fun("Pen lifts: {}".format(self.pen_lifts)) - finally: # In case of an exception and loss of the serial port... - pass - - + self.user_message_fun("Length of path drawn: {0:1.2f} m".format(d_dist)) + self.user_message_fun("Total distance moved: {0:1.2f} m".format(t_dist)) + if self.params.report_lifts: + self.user_message_fun("Number of pen lifts: {}".format(self.pen_lifts)) + if self.options.webhook and not self.options.preview: + if self.options.webhook_url is not None: + payload = {'value1': str(digest.name), + 'value2': str(text_utils.format_hms(elapsed_time)), + 'value3': str(self.options.port), + } + try: + r = requests.post(self.options.webhook_url, data=payload) + # self.user_message_fun("webhook results: " + str(r)) + except (RuntimeError, requests.exceptions.ConnectionError) as e: + raise RuntimeError("An error occurred while posting webhook. " + + "Are you connected to the internet? (Error: {})".format(e)) def plot_doc_digest(self, digest): """ @@ -1285,7 +1316,7 @@ def plan_trajectory(self, input_path): trimmed_path.append([tmp_x, tmp_y]) # Selected, usable portions of input_path. traj_logger.debug('\nSegment: input_path[{0:1.0f}] -> input_path[{1:1.0f}]'.format(last_index, i)) - traj_logger.debug('Destination: x: {0:1.3f}, y: {1:1.3f}. Move distance: {2:1.3f}'.format(tmp_x, tmp_y, tmp_dist)) + traj_logger.debug('Dest: x: {0:1.3f}, y: {1:1.3f}. Distance: {2:1.3f}'.format(tmp_x, tmp_y, tmp_dist)) last_index = i else: @@ -2385,32 +2416,25 @@ def servo_setup_wrapper(self): value = ebb_motion.queryEBBLV(self.serial_port) if int(value) != self.options.pen_pos_up + 1: """ - When the EBB is reset, it goes to its default "pen up" position, - for which QueryPenUp will tell us that the EBB believes it is - in the pen-up position. However, its actual position is the + When the EBB is reset, it goes to its default "pen up" position. QueryPenUp + will tell us that the in the pen-up state. However, its actual position is the default, not the pen-up position that we've requested. - To fix this, we can manually command the pen to either the - pen-up or pen-down position, as requested. HOWEVER, that may - take as much as five seconds in the very slowest pen-movement - speeds, and we want to skip that delay if the pen were actually - already in the right place, for example if we're plotting right - after raising the pen, or plotting twice in a row. + To fix this, we could manually command the pen to either the pen-up or pen-down + position. HOWEVER, that may take as much as five seconds in the very slowest + speeds, and we want to skip that delay if the pen is already in the right place, + for example if we're plotting after raising the pen, or plotting twice in a row. - Solution: Use an otherwise unused EBB firmware variable (EBBLV), - which is set to zero upon reset. If we set that value to be - nonzero, and later find that it's still nonzero, we know that - the servo position has been set (at least once) since reset. + Solution: Use an otherwise unused EBB firmware variable (EBBLV), which is set to + zero upon reset. If we set that value to be nonzero, and later find that it's still + nonzero, we know that the servo position has been set (at least once) since reset. - Knowing that the pen is up _does not_ confirm that the pen is - at the *requested* pen-up position. We can store - (self.options.pen_pos_up + 1), with possible values in the range - 1 - 101 in EBBLV, to verify that the current position is - correct, and that we can skip extra pen-up/pen-down movements. - - We do not _set_ the current correct pen-up value of EBBLV until - the pen is actually raised. + Knowing that the pen is up _does not_ confirm that the pen is at the *requested* + pen-up position. We can store (self.options.pen_pos_up + 1), with possible values + in the range 1 - 101 in EBBLV, to verify that the current position is correct, and + that we can skip extra pen-up/pen-down movements. + We do not _set_ the current correct pen-up value of EBBLV until the pen is raised. """ ebb_motion.setEBBLV(self.serial_port, 0) self.ebblv_set = False @@ -2429,11 +2453,9 @@ def servo_setup_wrapper(self): def servo_setup(self): """ - Pen position units range from 0% to 100%, which correspond to - a typical timing range of 7500 - 25000 in units of 1/(12 MHz). - 1% corresponds to ~14.6 us, or 175 units of 1/(12 MHz). + Pen position units range from 0% to 100%, which correspond to a typical timing range of + 7500 - 25000 in units of 1/(12 MHz). 1% corresponds to ~14.6 us: 175 units of 1/(12 MHz). """ - if self.use_layer_pen_height: pen_down_pos = self.layer_pen_pos_down else: @@ -2450,16 +2472,13 @@ def servo_setup(self): ebb_motion.setPenDownPos(self.serial_port, int_temp) """ - Servo speed units (as set with setPenUpRate) are units of %/second, - referring to the percentages above. - The EBB takes speeds in units of 1/(12 MHz) steps - per 24 ms. Scaling as above, 1% of range in 1 second - with SERVO_MAX = 27831 and SERVO_MIN = 9855 - corresponds to 180 steps change in 1 s - That gives 0.180 steps/ms, or 4.5 steps / 24 ms. - - Our input range (1-100%) corresponds to speeds up to - 100% range in 0.25 seconds, or 4 * 4.5 = 18 steps/24 ms. + Servo speed units (as set with setPenUpRate) are units of %/second, referring to the + percentages above. The EBB speed are in units of 1/(12 MHz) steps per 24 ms. Scaling + as above, 1% of range in 1 second with SERVO_MAX = 27831 and SERVO_MIN = 9855 + corresponds to 180 steps change in 1 s. That gives 0.180 steps/ms, or 4.5 steps/24 ms. + + Our input range (1-100%) corresponds to speeds up to 100% range in 0.25 seconds, or + 4 * 4.5 = 18 steps/24 ms. """ int_temp = 18 * self.options.pen_rate_raise @@ -2468,8 +2487,7 @@ def servo_setup(self): int_temp = 18 * self.options.pen_rate_lower ebb_motion.setPenDownRate(self.serial_port, int_temp) - # Set servo timeout - ebb_motion.servo_timeout(self.serial_port, self.params.servo_timeout) + ebb_motion.servo_timeout(self.serial_port, self.params.servo_timeout) # Set timeout def query_ebb_voltage(self): @@ -2486,9 +2504,8 @@ def query_ebb_voltage(self): def get_doc_props(self): """ - Get the document's height and width attributes from the tag. - Use a default value in case the property is not present or is - expressed in units of percentages. + Get the document's height and width attributes from the tag. Use a default value in + case the property is not present or is expressed in units of percentages. """ self.svg_height = plot_utils.getLengthInches(self, 'height') diff --git a/inkscape driver/axidraw_conf.py b/inkscape driver/axidraw_conf.py index ecb533a..4d79491 100644 --- a/inkscape driver/axidraw_conf.py +++ b/inkscape driver/axidraw_conf.py @@ -2,9 +2,9 @@ # Part of the AxiDraw driver software # # https://github.com/evil-mad/axidraw -# Version 3.0.1, dated 2021-10-13. +# Version 3.1.0, dated 2022-01-05. # -# Copyright 2021 Windell H. Oskay, Evil Mad Scientist Laboratories +# Copyright 2022 Windell H. Oskay, Evil Mad Scientist Laboratories # # https://github.com/evil-mad/AxiDraw # @@ -87,6 +87,17 @@ # 1: High resolution (smoother, slightly slower) (Default) # 2: Low resolution (coarser, slightly faster) +digest = 0 # Plot digest output option. (Do NOT enable if using within Inkscape.) + # 0: Disabled; No change to behavior or output (Default) + # 1: Output "plob" digest, not full SVG, when saving file + # 2: Disable plots and previews; generate digest only + +webhook = False # Enable webhook alerts + # Default: False + +webhook_url = None # URL for webhook alerts + + # Effective motor resolution is approx. 1437 or 2874 steps per inch, in the two modes respectively. # Note that these resolutions are defined along the native axes of the machine (X+Y) and (X-Y), # not along the XY axes of the machine. This parameter chooses 8X or 16X motor microstepping. @@ -127,6 +138,8 @@ # Options tab and then waits a few minutes before realizing that # no plot has been initiated. +report_lifts = False # Report number of pen lifts when reporting plot duration (Default: False) + auto_clip_lift = True # Option applicable to the Interactive Python API only. # If True (default), keep pen up when motion is clipped by travel bounds. @@ -187,8 +200,9 @@ bounds_tolerance = 0.003 # Suppress warnings if bounds are exceeded by less than this distance (inches). -# Allow sufficiently short pen-up moves to be substituted with a pen-down move: -min_gap = 0.008 # Distance Threshold (inches). Default value: 0.008 inches; smaller than most pen lines. +min_gap = 0.008 # Automatic path joining threshold, inches. Default: 0.008 + # If greater than zero, pen-up moves shorter than this distance + # will be replaced by pen-down moves. Set negative to disable. # Servo motion limits, in units of (1/12 MHz), about 83 ns: servo_max = 27831 # Highest allowed position; "100%" on the scale. Default value: 25200 units, or 2.31 ms. diff --git a/inkscape driver/axidraw_control.py b/inkscape driver/axidraw_control.py index dc6a221..8f76682 100644 --- a/inkscape driver/axidraw_control.py +++ b/inkscape driver/axidraw_control.py @@ -1,6 +1,6 @@ # coding=utf-8 # -# Copyright 2021 Windell H. Oskay, Evil Mad Scientist Laboratories +# Copyright 2022 Windell H. Oskay, Evil Mad Scientist Laboratories # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -49,11 +49,11 @@ import threading logger = logging.getLogger(__name__) - + class AxiDrawWrapperClass( inkex.Effect ): default_handler = message.UserMessageHandler() - + def __init__( self, default_logging = True, params = None ): if params is None: # use default configuration file @@ -107,27 +107,25 @@ def effect( self ): 2: Use only specified port, given by self.options.port 3: Plot to all attached AxiDraw units - ''' - if self.options.preview: - self.options.port_config = 1 # Ignore port & multi-machine options in preview - + if self.options.preview or self.options.digest > 1: + self.options.port_config = 1 # Offline modes; Ignore port & multi-machine options if self.options.mode in ( "resume", "res_plot", "res_home"): if self.options.port_config == 3: # If requested to use all machines, self.options.port_config = 1 # Instead, only resume for first machine. - + if self.options.port_config == 3: # Use all available AxiDraw units. process_list = [] EBBList = [] EBBList = ebb_serial.listEBBports() - + if EBBList: primary_port = None if self.options.port is not None: primary_port = ebb_serial.find_named_ebb(self.options.port) - + for foundPort in EBBList: logger.info("Found an EBB:") logger.info(" Port name: " + foundPort[0]) # Port name @@ -139,7 +137,6 @@ def effect( self ): else: if primary_port is None: primary_port = EBBList[0][0] - for index, foundPort in enumerate(EBBList): if foundPort[0] == primary_port: logger.info("FoundPort is primary: " + primary_port) @@ -153,24 +150,22 @@ def effect( self ): else: # Use multithreading: tname = "thread-" + str(index) process = threading.Thread(group=None, target=self.plot_to_axidraw, name=tname, args=(foundPort[0],False)) - process_list.append(process) process.start() - + logger.info("Plotting to primary: " + primary_port) - + self.plot_to_axidraw(primary_port, True) # Plot to "primary" AxiDraw for process in process_list: logger.info("Joining a process. ") process.join() - else: # i.e., if not EBBList logger.error("No available axidraw units found on USB.") logger.error("Please check your connection(s) and try again.") return else: # All cases except plotting to all available AxiDraw units: # This includes: Preview mode and all cases of plotting to a single AxiDraw. - + # If we are to use first available unit, blank the "port" variable. if self.options.port_config == 1: # Use first available AxiDraw self.options.port = None @@ -193,14 +188,13 @@ def plot_to_axidraw( self, port, primary): # Many plotting parameters to pass through: - selected_options = {item: self.options.__dict__[item] for item in ['mode', 'speed_pendown', 'speed_penup', 'accel', 'pen_pos_up', 'pen_pos_down', 'pen_rate_raise', 'pen_rate_lower', 'pen_delay_up', 'pen_delay_down', 'no_rotate', 'const_speed', 'report_time', 'manual_cmd', 'walk_dist', 'layer', 'copies', 'page_delay', 'preview', 'rendering', 'model', - 'setup_type', 'resume_type', 'auto_rotate', 'resolution', 'reordering', - 'random_start', ]} + 'setup_type', 'resume_type', 'auto_rotate', 'resolution', 'reordering', + 'random_start', 'digest', 'webhook', 'webhook_url',]} ad.options.__dict__.update(selected_options) ad.options.port = port @@ -219,8 +213,7 @@ def plot_to_axidraw( self, port, primary): if not primary: ad.set_secondary() # Suppress general message reporting; suppress time reporting - # Plot the document using axidraw.py - ad.effect() + ad.effect() # Plot the document using axidraw.py if primary: # Collect output from axidraw.py diff --git a/inkscape driver/axidraw_naming.py b/inkscape driver/axidraw_naming.py old mode 100644 new mode 100755 diff --git a/inkscape driver/axidraw_options/common_options.py b/inkscape driver/axidraw_options/common_options.py index a9be61d..6391f60 100644 --- a/inkscape driver/axidraw_options/common_options.py +++ b/inkscape driver/axidraw_options/common_options.py @@ -15,52 +15,52 @@ def core_options(parser, config): type="int", action="store", dest="speed_penup", \ default=config["speed_penup"], \ help="Maximum transit speed, when pen is up (1-100)") - + options.add_option("--accel",\ type="int", action="store", dest="accel", \ default=config["accel"], \ help="Acceleration rate factor (1-100)") - + options.add_option("--pen_pos_down",\ type="int", action="store", dest="pen_pos_down",\ default=config["pen_pos_down"],\ help="Height of pen when lowered (0-100)") - + options.add_option("--pen_pos_up",\ type="int", action="store", dest="pen_pos_up", \ default=config["pen_pos_up"], \ help="Height of pen when raised (0-100)") - + options.add_option("--pen_rate_lower",\ type="int", action="store", dest="pen_rate_lower",\ default=config["pen_rate_lower"], \ help="Rate of lowering pen (1-100)") - + options.add_option("--pen_rate_raise",\ type="int", action="store", dest="pen_rate_raise",\ default=config["pen_rate_raise"],\ help="Rate of raising pen (1-100)") - + options.add_option("--pen_delay_down",\ type="int", action="store", dest="pen_delay_down",\ default=config["pen_delay_down"],\ help="Optional delay after pen is lowered (ms)") - + options.add_option("--pen_delay_up",\ type="int", action="store", dest="pen_delay_up", \ default=config["pen_delay_up"],\ help="Optional delay after pen is raised (ms)") - + options.add_option("--no_rotate",\ type="inkbool", action="store", dest="no_rotate",\ default=False,\ help="Disable auto-rotate; preserve plot orientation") - + options.add_option("--const_speed",\ type="inkbool", action="store", dest="const_speed",\ default=config["const_speed"],\ help="Use constant velocity when pen is down") - + options.add_option("--report_time",\ type="inkbool", action="store", dest="report_time",\ default=config["report_time"],\ @@ -70,7 +70,7 @@ def core_options(parser, config): type="int", action="store", dest="page_delay",\ default=config["page_delay"],\ help="Optional delay between copies (s).") - + options.add_option("--preview",\ type="inkbool", action="store", dest="preview",\ default=config["preview"],\ @@ -81,7 +81,7 @@ def core_options(parser, config): default=config["rendering"],\ help="Preview mode rendering option (0-3). 0: None. " \ + "1: Pen-down movement. 2: Pen-up movement. 3: All movement.") - + options.add_option("--model",\ type="int", action="store", dest="model",\ default=config["model"],\ @@ -106,37 +106,58 @@ def core_options(parser, config): type="string", action="store", dest="setup_type",\ default="align",\ help="Setup option selected (GUI Only)") - + options.add_option("--resume_type",\ type="string", action="store", dest="resume_type",\ default="plot", help="The resume option selected (GUI Only)") - + options.add_option("--auto_rotate",\ type="inkbool", action="store", dest="auto_rotate",\ default=config["auto_rotate"], \ - help="Boolean: Auto select portrait vs landscape") + help="Auto select portrait vs landscape orientation") options.add_option("--random_start",\ type="inkbool", action="store", dest="random_start",\ default=config["random_start"], \ - help="Boolean: Randomize start locations of closed paths") + help="Randomize start locations of closed paths") options.add_option("--reordering",\ type="int", action="store", dest="reordering",\ default=config["reordering"],\ - help="Plot optimization option selected") + help="Plot optimization option (0-2)."\ + +" 0: Preserve order given in SVG file (Default)."\ + + "1: Reorder objects, preserving path orientation. "\ + + "2: Reorder objects, allow path reversal. ") options.add_option("--resolution",\ type="int", action="store", dest="resolution",\ default=config["resolution"],\ help="Resolution option selected") + options.add_option("--digest",\ + type="int", action="store", dest="digest",\ + default=config["digest"],\ + help="Plot optimization option (0-2)."\ + +" 0: No change to behavior or output (Default)."\ + + "1: Output 'plob' digest, not full SVG, when saving file. "\ + + "2: Disable plots and previews; generate digest only. ") + + options.add_option("--webhook",\ + type="inkbool", action="store", dest="webhook",\ + default=config["webhook"],\ + help="Enable webhook callback when a plot finishes") + + options.add_option("--webhook_url",\ + type="string", action="store", dest="webhook_url",\ + default=config["webhook_url"],\ + help="Webhook URL to be used if webhook is enabled") + return options def core_mode_options(parser, config): options = OptionGroup(parser, "Mode Options") - + options.add_option("--mode",\ action="store", type="string", dest="mode",\ default="plot", \ @@ -154,20 +175,20 @@ def core_mode_options(parser, config): help="Manual command. One of: [fw_version, raise_pen, lower_pen, " \ + "walk_x, walk_y, enable_xy, disable_xy, bootload, strip_data, " \ + "read_name, list_names, write_name]. Default: fw_version") - + options.add_option("--walk_dist",\ type="float", action="store", dest="walk_dist",\ default=1,\ help="Distance for manual walk (inches)") - + options.add_option("--layer",\ type="int", action="store", dest="layer",\ default=config["default_layer"],\ help="Layer(s) selected for layers mode (1-1000). Default: 1") - + options.add_option("--copies",\ type="int", action="store", dest="copies",\ default=config["copies"],\ help="Copies to plot, or 0 for continuous plotting. Default: 1") - + return options diff --git a/inkscape driver/axidraw_options/versions.py b/inkscape driver/axidraw_options/versions.py old mode 100644 new mode 100755 index 8050f5a..f9f5ae2 --- a/inkscape driver/axidraw_options/versions.py +++ b/inkscape driver/axidraw_options/versions.py @@ -5,6 +5,7 @@ from axidrawinternal.plot_utils_import import from_dependency_import ebb_serial = from_dependency_import('plotink.ebb_serial') # Requires v 0.13 in plotink https://github.com/evil-mad/plotink +requests = from_dependency_import('requests') Versions = namedtuple("Versions", "axidraw_control ebb_firmware dev_axidraw_control") @@ -15,13 +16,11 @@ def get_versions_online(): returns namedtuple with the versions raises RuntimeError if online check fails. ''' - requests = from_dependency_import('requests') - url = "https://evilmadscience.s3.amazonaws.com/sites/axidraw/versions.txt" text = None try: text = requests.get(url).text - except RuntimeError as e: + except (RuntimeError, requests.exceptions.ConnectionError) as e: raise RuntimeError("Could not contact server to check for updates. " + "Are you connected to the internet? (Error: {})".format(e)) @@ -33,7 +32,8 @@ def get_versions_online(): dev_axidraw_control=dictionary['AxiDraw Control (unstable)']) except RuntimeError as e: raise RuntimeError("Could not parse server response. " + - "This is probably the server's fault. (Error: {})".format(e)) + "This is probably the server's fault. (Error: {})".format(e) + ).with_traceback(sys.exc_info()[2]) return online_versions @@ -104,7 +104,7 @@ def log_version_info(serial_port, check_updates, current_version_string, preview try: online_versions = get_versions_online() except RuntimeError as e: - msg = 'Unable to check online for latest version numbers. (Error: {})'.format(e) + msg = '{}'.format(e) message_fun(msg) logger.error(msg) else: diff --git a/inkscape driver/digest_svg.py b/inkscape driver/digest_svg.py index 0c70e9f..f1a118d 100644 --- a/inkscape driver/digest_svg.py +++ b/inkscape driver/digest_svg.py @@ -29,6 +29,7 @@ """ import logging +from math import sqrt from lxml import etree @@ -84,6 +85,10 @@ def __init__(self, default_logging=True): self.supersample_tolerance = 0 self.layer_selection = 0 + self.doc_width_100 = 0 + self.doc_height_100 = 0 + self.diagonal_100 = 0 + def process_svg(self, node_list, digest_params, mat_current=None): """ @@ -107,7 +112,7 @@ def process_svg(self, node_list, digest_params, mat_current=None): or styles. """ - [self.doc_digest.width, self.doc_digest.height, self.layer_selection,\ + [self.doc_digest.width, self.doc_digest.height, scale_x, scale_y, self.layer_selection,\ self.bezier_tolerance, self.supersample_tolerance, _]\ = digest_params @@ -115,6 +120,10 @@ def process_svg(self, node_list, digest_params, mat_current=None): self.doc_digest.viewbox = "0 0 {:f} {:f}".format(\ self.doc_digest.width, self.doc_digest.height) + self.doc_width_100 = self.doc_digest.width / scale_x # Width of a "100% width" object + self.doc_height_100 = self.doc_digest.height / scale_y # height of a "100% height" object + self.diagonal_100 = sqrt((self.doc_width_100)**2 + (self.doc_height_100)**2)/sqrt(2) + docname = node_list.get(inkex.addNS('docname', 'sodipodi'), ) if docname: self.doc_digest.name = docname @@ -190,12 +199,12 @@ def traverse(self, node_list, mat_current=None,\ # Ensure that sublayers are treated like regular groups only str_layer_name = node.get(inkex.addNS('label', 'inkscape')) - str_layer_name.lstrip() # Remove leading whitespace if not str_layer_name: str_layer_name = "Auto-Layer " + str(self.next_id) self.next_id += 1 else: + str_layer_name.lstrip() # Remove leading whitespace if len(str(str_layer_name)) > 0: if str(str_layer_name)[0] == '%': continue # Skip Documentation layer and its contents @@ -239,6 +248,7 @@ def traverse(self, node_list, mat_current=None,\ new_layer.name = '__digest-root__' # Label this as a "root" layer new_layer.item_id = str(self.next_id) self.next_id += 1 + self.doc_digest.layers.append(new_layer) self.current_layer = new_layer self.current_layer_name = new_layer.name else: # Regular group or sublayer that we treat as a group. @@ -261,11 +271,10 @@ def traverse(self, node_list, mat_current=None,\ # A 'switch' is much like a group, in that it is a generic container element. # We are not presently evaluating conditions on switch elements, but parsing # their contents to the extent possible. - self.traverse_svg(node, mat_new, parent_visibility=visibility) + self.traverse(node, mat_new, parent_visibility=visibility) continue if node.tag == inkex.addNS('use', 'svg') or node.tag == 'use': - """ A element refers to another SVG element via an xlink:href="#blah" attribute. We will handle the element by doing an XPath search through @@ -338,16 +347,18 @@ def traverse(self, node_list, mat_current=None,\ self.digest_path(path_d, mat_new) continue if node.tag == inkex.addNS('rect', 'svg') or node.tag == 'rect': - # Manually transform - # - # into - # - # I.e., explicitly draw three sides of the rectangle and the - # fourth side implicitly - # Create a path with the outline of the rectangle - # https://www.w3.org/TR/SVG11/shapes.html#RectElement - x, y, rx, ry, width, height = [plot_utils.unitsToUserUnits(node.get(attr, '0')) \ - for attr in ['x', 'y', 'rx', 'ry', 'width', 'height']] + """ + Create a path with the outline of the rectangle + Manually transform + into + Draw three sides of the rectangle explicitly and the fourth implicitly + https://www.w3.org/TR/SVG11/shapes.html#RectElement + """ + + x, r_x, width = [plot_utils.unitsToUserUnits(node.get(attr), + self.doc_width_100) for attr in ['x', 'rx', 'width']] + y, r_y, height = [plot_utils.unitsToUserUnits(node.get(attr), + self.doc_height_100) for attr in ['y', 'ry', 'height']] def calc_r_attr(attr, other_attr, twice_maximum): value = (attr if attr is not None else @@ -355,20 +366,20 @@ def calc_r_attr(attr, other_attr, twice_maximum): 0) return min(value, twice_maximum * .5) - rx = calc_r_attr(rx, ry, width) - ry = calc_r_attr(ry, rx, height) + r_x = calc_r_attr(r_x, r_y, width) + r_y = calc_r_attr(r_y, r_x, height) instr = [] - if (rx > 0) or (ry > 0): - instr.append(['M ', [x + rx, y]]) - instr.append([' L ', [x + width - rx, y]]) - instr.append([' A ', [rx, ry, 0, 0, 1, x + width, y + ry]]) - instr.append([' L ', [x + width, y + height - ry]]) - instr.append([' A ', [rx, ry, 0, 0, 1, x + width - rx, y + height]]) - instr.append([' L ', [x + rx, y + height]]) - instr.append([' A ', [rx, ry, 0, 0, 1, x, y + height - ry]]) - instr.append([' L ', [x, y + ry]]) - instr.append([' A ', [rx, ry, 0, 0, 1, x + rx, y]]) + if (r_x > 0) or (r_y > 0): + instr.append(['M ', [x + r_x, y]]) + instr.append([' L ', [x + width - r_x, y]]) + instr.append([' A ', [r_x, r_y, 0, 0, 1, x + width, y + r_y]]) + instr.append([' L ', [x + width, y + height - r_y]]) + instr.append([' A ', [r_x, r_y, 0, 0, 1, x + width - r_x, y + height]]) + instr.append([' L ', [x + r_x, y + height]]) + instr.append([' A ', [r_x, r_y, 0, 0, 1, x, y + height - r_y]]) + instr.append([' L ', [x, y + r_y]]) + instr.append([' A ', [r_x, r_y, 0, 0, 1, x + r_x, y]]) else: instr.append(['M ', [x, y]]) instr.append([' L ', [x + width, y]]) @@ -379,16 +390,14 @@ def calc_r_attr(attr, other_attr, twice_maximum): self.digest_path(simplepath.formatPath(instr), mat_new) continue if node.tag == inkex.addNS('line', 'svg') or node.tag == 'line': - - # Convert - # - # Create a path to contain the line - x_1 = plot_utils.unitsToUserUnits(node.get('x1')) - y_1 = plot_utils.unitsToUserUnits(node.get('y1')) - x_2 = plot_utils.unitsToUserUnits(node.get('x2')) - y_2 = plot_utils.unitsToUserUnits(node.get('y2')) + """ + Convert an SVG line object + """ + x_1, x_2 = [plot_utils.unitsToUserUnits(node.get(attr, '0'), + self.doc_width_100) for attr in ['x1', 'x2']] + y_1, y_2 = [plot_utils.unitsToUserUnits(node.get(attr, '0'), + self.doc_height_100) for attr in ['y1', 'y2']] path_a = [] path_a.append(['M ', [x_1, y_1]]) @@ -429,32 +438,34 @@ def calc_r_attr(attr, other_attr, twice_maximum): if node.tag in [inkex.addNS('polygon', 'svg'), 'polygon']: path_d += " Z" - self.digest_path(path_d, mat_new) + self.digest_path(path_d, mat_new) # Vertices are already in user coordinate system continue if node.tag in [inkex.addNS('ellipse', 'svg'), 'ellipse', inkex.addNS('circle', 'svg'), 'circle']: + """ + Convert circles and ellipses to paths as two 180 degree arcs. + In general (an ellipse), we convert + + to + + where + X1 = CX - RX + X2 = CX + RX + Ellipses or circles with a radius attribute of 0 are ignored + """ - # Convert circles and ellipses to paths as two 180 degree arcs. - # In general (an ellipse), we convert - # - # to - # - # where - # X1 = CX - RX - # X2 = CX + RX - # Ellipses or circles with a radius attribute of 0 are ignored - - if node.tag == inkex.addNS('ellipse', 'svg') or node.tag == 'ellipse': - r_x = plot_utils.unitsToUserUnits(node.get('rx', '0')) - r_y = plot_utils.unitsToUserUnits(node.get('ry', '0')) - else: - r_x = plot_utils.unitsToUserUnits(node.get('r', '0')) + if node.tag in [inkex.addNS('circle', 'svg'), 'circle']: + r_x = plot_utils.unitsToUserUnits(node.get('r', '0'), self.diagonal_100) r_y = r_x + else: + r_x, r_y = [plot_utils.unitsToUserUnits(node.get(attr, '0'), + self.diagonal_100) for attr in ['rx', 'ry']] if r_x == 0 or r_y == 0: continue - c_x = plot_utils.unitsToUserUnits(node.get('cx', '0')) - c_y = plot_utils.unitsToUserUnits(node.get('cy', '0')) + c_x = plot_utils.unitsToUserUnits(node.get('cx', '0'), self.doc_width_100) + c_y = plot_utils.unitsToUserUnits(node.get('cy', '0'), self.doc_height_100) + x_1 = c_x - r_x x_2 = c_x + r_x path_d = 'M {0:f},{1:f} '.format(x_1, c_y) + \ @@ -609,3 +620,64 @@ def digest_path(self, path_d, mat_transform): self.current_layer.paths.append(new_path) logger.debug('End of digest_path()\n') + + +def verify_plob(svg, model): + """ + Check to see if the provided SVG is a valid plob that can be automatically converted + to a plot digest object. Also check that the plob version and hardware model match. + + Returns True or False. + + We may wish to also check for an application name match in the + future. At present, that check is not yet necessary. + """ + + data_node = None + nodes = svg.xpath("//*[self::svg:plotdata|self::plotdata]", namespaces=inkex.NSS) + if nodes: + data_node = nodes[0] + if data_node is not None: + try: + svg_model = data_node.get('model') + svg_plob_version = data_node.get('plob_version') + except TypeError: + return False + else: + return False # No plot data; Plob cannot be verified. + if svg_model: + if int(svg_model) != model: + return False + else: + return False + if svg_plob_version: + if svg_plob_version != path_objects.PLOB_VERSION: + return False + else: + return False + + # inkex.errormsg( "Passed plotdata checks") # Optional halfwaypoint check + tag_list = [inkex.addNS('defs', 'svg'), 'defs', 'metadata', inkex.addNS('metadata', 'svg'), + inkex.addNS('namedview', 'sodipodi'), 'plotdata', inkex.addNS('plotdata', 'svg'), ] + + for node in svg: + if node.tag in ['g', inkex.addNS('g', 'svg')]: + name_temp = node.get(inkex.addNS('label', 'inkscape')) + if not name_temp: + return False # All groups must be named + if len(str(name_temp)) > 0: + if str(name_temp)[0] == '%': + continue # Skip Documentation layer and its contents + if node.get("transform"): # No transforms are allowed on plottable layers + return False + for subnode in node: + if subnode.get("transform"): # No transforms are allowed on objects + return False + if subnode.tag in ['polyline', inkex.addNS('polyline', 'svg')]: + continue + return False + elif node.tag in tag_list: + continue + else: + return False + return True diff --git a/inkscape driver/path_objects.py b/inkscape driver/path_objects.py index 6582886..4ac1f48 100644 --- a/inkscape driver/path_objects.py +++ b/inkscape driver/path_objects.py @@ -54,6 +54,8 @@ """ +PLOB_VERSION = "1" + class PathItem: """ @@ -212,7 +214,7 @@ def __init__(self): self.flat = False # Boolean indicating if the instance has been flattened. self.layers = [] # List of PathItem objects in the layer - self.metadata['plob_version'] = "1.0" + self.plotdata['plob_version'] = PLOB_VERSION def flatten(self): """ @@ -238,7 +240,7 @@ def rotate(self, rotate_ccw = True): self.width = self.height self.height = old_width - self.viewbox = "0 0 {:f} {:f}".format(self.height, self.width) + self.viewbox = "0 0 {:f} {:f}".format(self.width, self.height) if not self.flat: self.flatten() @@ -317,6 +319,9 @@ def from_plob(self, plob): This function is for use _only_ on an input etree that is in the Plob format, not a full SVG file with arbitrary contents. + + While documentation layers are not allowed as part of the plob, + we will ignore them. That allows a preview to be run before plotting. """ # Reset instance variables; ensure that they are clobbered. @@ -349,7 +354,7 @@ def from_plob(self, plob): self.viewbox = vb_temp for node in plob: - if node.tag == 'g': + if node.tag in ['g', inkex.addNS('g', 'svg')]: # A group that we treat as a layer name_temp = node.get(inkex.addNS('label', 'inkscape')) if not name_temp: @@ -357,8 +362,11 @@ def from_plob(self, plob): layer = LayerItem() # New LayerItem object layer.item_id = node.get('id') layer.name = name_temp + if len(str(name_temp)) > 0: + if str(name_temp)[0] == '%': + continue # Skip Documentation layer and its contents for subnode in node: - if subnode.tag == 'polyline': + if subnode.tag in ['polyline', inkex.addNS('polyline', 'svg')]: path = PathItem() # New PathItem object path.from_string(subnode.get('points')) path.item_id = subnode.get('id') diff --git a/inkscape driver/plot_optimizations.py b/inkscape driver/plot_optimizations.py index 3b1bba2..51112c1 100644 --- a/inkscape driver/plot_optimizations.py +++ b/inkscape driver/plot_optimizations.py @@ -53,11 +53,10 @@ import math from axidrawinternal.plot_utils_import import from_dependency_import # plotink -from . import rtree -from . import spatial_grid - path_objects = from_dependency_import('axidrawinternal.path_objects') plot_utils = from_dependency_import('plotink.plot_utils') +rtree = from_dependency_import('plotink.rtree') +spatial_grid = from_dependency_import('plotink.spatial_grid') def connect_nearby_ends(digest, reverse, min_gap): diff --git a/inkscape driver/rtree.py b/inkscape driver/rtree.py deleted file mode 100644 index 09e4b1a..0000000 --- a/inkscape driver/rtree.py +++ /dev/null @@ -1,109 +0,0 @@ -# -*- coding: utf-8 -*- -# text_utils.py -# Common text processing utilities -# https://github.com/evil-mad/plotink -# -# See below for version information -# -# Written by Michal Migurski https://github.com/migurski @michalmigurski -# as a contribution to the AxiDraw project https://github.com/evil-mad/axidraw/ -# -# Copyright (c) 2021 Windell H. Oskay, Evil Mad Scientist Laboratories -# -# The MIT License (MIT) -# -# 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. - -""" -rtree.py - -Minimal R-tree spatial index class for calculating intersecting regions -""" - - -import math - -class Index: - ''' One-shot R-Tree index (no rebalancing, insertions, etc.) - ''' - bboxes = [] - subtrees = [] - xmin = None - ymin = None - xmax = None - ymax = None - - def __init__(self, bboxes): - center_x, center_y = 0, 0 - self.xmin, self.ymin = math.inf, math.inf - self.xmax, self.ymax = -math.inf, -math.inf - - for (_, (xmin, ymin, xmax, ymax)) in bboxes: - center_x += (xmin/2 + xmax/2) / len(bboxes) - center_y += (ymin/2 + ymax/2) / len(bboxes) - self.xmin = min(self.xmin, xmin) - self.ymin = min(self.ymin, ymin) - self.xmax = max(self.xmax, xmax) - self.ymax = max(self.ymax, ymax) - - # Make four lists of bboxes, one for each quadrant around the center point - # An original bbox may be present in more than one list - sub_bboxes = [ - [ - (i, (x_1, y_1, x_2, y_2)) for (i, (x_1, y_1, x_2, y_2)) in bboxes - if x_1 < center_x and y_1 < center_y - ], - [ - (i, (x_1, y_1, x_2, y_2)) for (i, (x_1, y_1, x_2, y_2)) in bboxes - if x_2 > center_x and y_1 < center_y - ], - [ - (i, (x_1, y_1, x_2, y_2)) for (i, (x_1, y_1, x_2, y_2)) in bboxes - if x_1 < center_x and y_2 > center_y - ], - [ - (i, (x_1, y_1, x_2, y_2)) for (i, (x_1, y_1, x_2, y_2)) in bboxes - if x_2 > center_x and y_2 > center_y - ], - ] - - # Store bboxes or subtrees but not both - if max(map(len, sub_bboxes)) == len(bboxes): - # One of the subtrees is identical to the whole tree so just keep all the bboxes - self.bboxes = bboxes - else: - # Make four subtrees, one for each quadrant - self.subtrees = [Index(sub) for sub in sub_bboxes] - - def intersection(self, bbox): - ''' Get a set of IDs for a given bounding box - ''' - ids, (x_1, y_1, x_2, y_2) = set(), bbox - - for (i, (xmin, ymin, xmax, ymax)) in self.bboxes: - is_disjoint = x_1 > xmax or y_1 > ymax or x_2 < xmin or y_2 < ymin - if not is_disjoint: - ids.add(i) - - for subt in self.subtrees: - is_disjoint = x_1 > subt.xmax or y_1 > subt.ymax or x_2 < subt.xmin or y_2 < subt.ymin - if not is_disjoint: - ids |= subt.intersection(bbox) - - return ids diff --git a/inkscape driver/spatial_grid.py b/inkscape driver/spatial_grid.py deleted file mode 100644 index 63b402e..0000000 --- a/inkscape driver/spatial_grid.py +++ /dev/null @@ -1,245 +0,0 @@ -# -*- coding: utf-8 -*- -# spatial_grid.py -# part of plotink: https://github.com/evil-mad/plotink -# -# See below for version information -# -# Copyright (c) 2022 Windell H. Oskay, Evil Mad Scientist Laboratories -# -# The MIT License (MIT) -# -# 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. - -""" -spatial_grid.py - -Specialized flat grid spatial index class for calculating nearest neighbors -""" - - -import math - -from axidrawinternal.plot_utils_import import from_dependency_import # plotink -plot_utils = from_dependency_import('plotink.plot_utils') - - -class Index: - ''' Grid index class ''' - grid = [] # The grid: List of cells, each of which will contain a list of path ends - adjacents = [] # Adjacency list; list of neighboring cells for each cell - lookup = [] # List of which grid cell each path end can be found inside. - path_count = 0 # Initial number of paths - vertices = None # The list of start, end vertices for each path - reverse = False # Boolean: Are path reversals allowed? - bin_size_x = 1.0 # Width of grid cells - bin_size_y = 1.0 # Height of grid cells - xmin = -1.0 # Grid bounds - ymin = -1.0 # Grid bounds - bins_per_side = 3 # Number of bins per side of the grid - - def __init__(self, vertices, bins_per_side, reverse): - ''' - Given an input list of path vertices and number of bins per side, - populate a 1D list that represents a linearized 2D grid, - bins_per_side x bins_per_side in size. Each cell contains a list that - indicates which path numbers have ends that can be found in that cell. - - Also populate an adjacency list where each cell contains a list of - which cells are that cell or its neighbors; up to 9 possible. - - And, populate a 1D "reverse" lookup list that gives the grid-cell - location of each path end. - - Input vertices is a 1D list of elements: [first_vertex, last_vertex] - for each path. Each vertex is a (x,y) tuple. - - self.bins_per_side is an integer, that once squared gives the number of grid - cells. Practical minimum of 3, for 9 squares. - - reverse is boolean, indicating whether the paths can be reversed. - ''' - - self.vertices = vertices - self.reverse = reverse - self.bins_per_side = bins_per_side - self.path_count = len(vertices) - max_bin = bins_per_side - 1 # array index of the largest x or y bin - - self.find_adjacents() - - # Calculate extent of grid: - self.xmin, self.ymin = math.inf, math.inf - xmax, ymax = -math.inf, -math.inf - - for [x_1, y_1], [x_2, y_2] in self.vertices: - self.xmin = min(self.xmin, x_1) - xmax = max(xmax, x_1) - self.ymin = min(self.ymin, y_1) - ymax = max(ymax, y_1) - if reverse: - self.xmin = min(self.xmin, x_2) - xmax = max(xmax, x_2) - self.ymin = min(self.ymin, y_2) - ymax = max(ymax, y_2) - - # Artificially increase size of grid to avoid vertices on the borders: - shim = (xmax - self.xmin + ymax - self.ymin) / 200 - self.xmin -= shim - self.ymin -= shim - xmax += shim - ymax += shim - - # Calculate bin sizes: - self.bin_size_x = (xmax - self.xmin) / bins_per_side - self.bin_size_y = (ymax - self.ymin) / bins_per_side - - # Initialize the "reverse" lookup list: - if reverse: - self.lookup = [0 for temp_var in range(2 * self.path_count)] - else: - self.lookup = [0 for temp_var in range(self.path_count)] - - # Initialize the grid, with an empty list in each cell: - self.grid = [[] for index_i in range(self.bins_per_side * self.bins_per_side)] - - for (index_i, [[x_1, y_1], [x_2, y_2]]) in enumerate(self.vertices): - x_bin = min(math.floor((x_1 - self.xmin) / self.bin_size_x), max_bin) - y_bin = min(math.floor((y_1 - self.ymin) / self.bin_size_y), max_bin) - grid_index = x_bin + self.bins_per_side * y_bin - self.grid[grid_index].append(index_i) - self.lookup[index_i] = grid_index # Which grid cell is the path start in? - - if reverse: - x_bin = min(math.floor((x_2 - self.xmin) / self.bin_size_x), max_bin) - y_bin = min(math.floor((y_2 - self.ymin) / self.bin_size_y), max_bin) - grid_index = x_bin + self.bins_per_side * y_bin - self.grid[grid_index].append(self.path_count + index_i) - self.lookup[self.path_count + index_i] = grid_index - - - def find_adjacents(self): - ''' - Populate an adjacency list, where each cell contains a list of - which cells are that cell or its neighbors; up to 9 possible. - ''' - max_bin = self.bins_per_side - 1 - - self.adjacents = [[a_s] for a_s in range(self.bins_per_side * self.bins_per_side)] - for y_row in range(self.bins_per_side): - for x_col in range(self.bins_per_side): - index_i = x_col + y_row * (self.bins_per_side) - if x_col > 0: - self.adjacents[index_i].append(index_i - 1) # OK - if y_row > 0: - self.adjacents[index_i].append(index_i - self.bins_per_side - 1) - if y_row < max_bin: - self.adjacents[index_i].append(index_i + self.bins_per_side - 1) - if x_col < max_bin: - self.adjacents[index_i].append(index_i + 1) - if y_row > 0: - self.adjacents[index_i].append(index_i - self.bins_per_side + 1) - if y_row < max_bin: - self.adjacents[index_i].append(index_i + self.bins_per_side + 1) - if y_row > 0: - self.adjacents[index_i].append(index_i - self.bins_per_side) - if y_row < max_bin: - self.adjacents[index_i].append(index_i + self.bins_per_side) - - - def nearest(self, vertex_in): - ''' - Find the nearest path end to the given vertex and return its index. - Input last_vertex is a [x, y] list. - - Method: - * Locate which grid cell the input vertex is located in. - * For every vertex in that grid cell, plus the (up to) eight surrounding it, - check to see if it is the nearest neighbor to the input vertex. - If so, return the index of that closest vertex. - - * If there are no vertices in those (up to) 9 cells, check the entire rest of - the document, and find the closest (global) point. - - * If no vertices are found at all, return None - - The neighborhood of up 8 cells surrounding the initial cell serves as a crude - circular region surrounding the vertex. In most (but not all) cases, the - nearest point within that region will be the nearest point globally. - The precision of that "circle" could be improved by using a finer grid, and a - larger number of adjacent cells to check. - ''' - - max_bin = self.bins_per_side - 1 - - # Use max/min to constrain the initial row and column of our first cell to check - x_bin = max(min(math.floor((vertex_in[0] - self.xmin) / self.bin_size_x), max_bin), 0) - y_bin = max(min(math.floor((vertex_in[1] - self.ymin) / self.bin_size_y), max_bin), 0) - last_cell = x_bin + self.bins_per_side * y_bin - - neighborhood_cells = self.adjacents[last_cell].copy() - - best_dist = math.inf - best_index = None - for cell in neighborhood_cells: - for path_index in self.grid[cell]: - if path_index >= self.path_count: # new path is reversed - vertex = self.vertices[path_index - self.path_count][1] - else: - vertex = self.vertices[path_index][0] # Beginning of next path - - dist = plot_utils.square_dist(vertex_in, vertex) - if dist < best_dist: - best_dist = dist - best_index = path_index - if best_index: - return best_index - - # Fallback: Check remaining cells if no points were found in neighborhood: - for cell in range (len(self.adjacents)): - if cell in neighborhood_cells: - continue - for path_index in self.grid[cell]: - if path_index >= self.path_count: # new path is reversed - vertex = self.vertices[path_index - self.path_count][1] - else: - vertex = self.vertices[path_index][0] - - dist = plot_utils.square_dist(vertex_in, vertex) - if dist < best_dist: - best_dist = dist - best_index = path_index - return best_index - - - def remove_path(self, path_index): - ''' - Remove the vertex with the given path_index from the spatial index. - Input path_index must be < self.path_count. - - If reversing is enabled, also remove the vertex with index - path_index + self.path_count - ''' - - cell_number = self.lookup[path_index] - self.grid[cell_number].remove(path_index) - - if self.reverse: - other_index = path_index + self.path_count - cell_number = self.lookup[other_index] - self.grid[cell_number].remove(other_index)