diff --git a/extensions/fablabchemnitz/cutting_optimizer/cutting_optimizer.inx b/extensions/fablabchemnitz/cutting_optimizer/cutting_optimizer.inx new file mode 100644 index 0000000..78a8b70 --- /dev/null +++ b/extensions/fablabchemnitz/cutting_optimizer/cutting_optimizer.inx @@ -0,0 +1,91 @@ + + + Cutting Optimizer (Nesting) + fablabchemnitz.de.cutting_optimizer + + + + + + + + + 0.00 + 1000 + 1 + + + + + + + + + + + + true + 0.00 + true + false + 0.00 + + + + false + + true + + false + false + false + false + + false + false + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ../000_about_fablabchemnitz.svg + + + + all + + + + + + + + diff --git a/extensions/fablabchemnitz/cutting_optimizer/cutting_optimizer.py b/extensions/fablabchemnitz/cutting_optimizer/cutting_optimizer.py new file mode 100644 index 0000000..3bd5384 --- /dev/null +++ b/extensions/fablabchemnitz/cutting_optimizer/cutting_optimizer.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 + +""" +Extension for InkScape 1.2 + +CutOptim OS Wrapper script to make CutOptim work on Windows and Linux systems without duplicating .inx files + +Author: Mario Voigt / FabLab Chemnitz +Mail: mario.voigt@stadtfabrikanten.org +Date: 31.08.2020 +Last patch: 03.11.2022 +License: GNU GPL v3 + +""" + +import inkex +import sys +import re +import os +import subprocess +from lxml import etree +from copy import deepcopy +import tempfile +from inkex.command import inkscape, inkscape_command + +class CuttingOptimizer(inkex.EffectExtension): + + def openDebugFile(self, file): + DETACHED_PROCESS = 0x00000008 + if os.name == 'nt': + subprocess.Popen(["explorer", file], close_fds=True, creationflags=DETACHED_PROCESS, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).wait() + else: + subprocess.Popen(["xdg-open", file], close_fds=True, start_new_session=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).wait() + + def add_arguments(self, pars): + args = sys.argv[1:] + for arg in args: + key=arg.split("=")[0] + if len(arg.split("=")) == 2: + value=arg.split("=")[1] + try: + if key != "--id": + pars.add_argument(key, default=key) + except: + pass #ignore duplicate id arg + + def effect(self): + extension_dir = os.path.dirname(os.path.realpath(__file__)) + cmd = [] + if os.name == "nt": + cutoptim = os.path.join(extension_dir, "CutOptim.exe") + else: + cutoptim = os.path.join(extension_dir, "CutOptim") + cmd.append(cutoptim) + + elements = self.svg.selected + if len(elements) > 0: #if selection is existing, then we export only selected items to a new svg, which is then going to be processed. Otherwise we process the whole SVG document + extra_param = None + template = self.svg.copy() + for child in template.getchildren(): + if child.tag == '{http://www.w3.org/2000/svg}defs': + continue + template.remove(child) + group = etree.SubElement(template, '{http://www.w3.org/2000/svg}g') + group.attrib['id'] = 'export_selection_transform' + for element in self.svg.selected.values(): + elem_copy = deepcopy(element) + elem_copy.attrib['transform'] = str(element.composed_transform()) + elem_copy.attrib['style'] = str(element.specified_style()) + group.append(elem_copy) + template.attrib['viewBox'] = self.svg.attrib['viewBox'] + template.attrib['width'] = self.svg.attrib['width'] + template.attrib['height'] = self.svg.attrib['height'] + template.append(group) + svg_out = os.path.join(tempfile.gettempdir(), self.svg.get_unique_id("selection") + '.svg') + with open(svg_out, 'wb') as fp: + fp.write(template.tostring()) + actions_list=[] + actions_list.append("SelectionUnGroup") + actions_list.append("export-type:svg") + actions_list.append("export-filename:{}".format(svg_out)) + actions_list.append("export-do") + actions = ";".join(actions_list) + cli_output = inkscape(svg_out, extra_param, actions=actions) #process recent file + if len(cli_output) > 0: + self.msg("Inkscape returned the following output when trying to run the file export; the file export may still have worked:") + self.msg(cli_output) + + layerSum = 0 + layer_output_0 = False + cancel_on_error = False + for arg in vars(self.options): + argval = str(getattr(self.options, arg)) + if arg not in ("tab", "output", "ids", "selected_nodes", "print_cmd"): + #inkex.utils.debug(str(arg) + " = " + str(getattr(self.options, arg))) + #fix behaviour of "original" arg which does not correctly gets interpreted if set to false + if arg == "original" and argval == "false": + continue + if arg == "input_file": + cmd.append("--file") + if len(elements) > 0: + cmd.append(svg_out) + else: + cmd.append(argval) + elif arg == "layer_output_0": + if argval == "true": + layerSum += 0 + layer_output_0 = True + #elif arg == "layer_output_1": + # if argval == "true": layerSum += 1 + elif arg == "layer_output_2": + if argval == "true": layerSum += 2 + elif arg == "layer_output_4": + if argval == "true": layerSum += 4 + elif arg == "layer_output_8": + if argval == "true": layerSum += 8 + elif arg == "layer_output_16": + if argval == "true": layerSum += 16 + elif arg == "cancel_on_error": + if argval == "true": cancel_on_error = True + else: + cmd.append("--{}={}".format(arg, argval)) + cmd.append("--layer_output") + cmd.append("{}".format(layerSum)) + if layerSum == 0 and layer_output_0 is False: + inkex.utils.debug("You need to enable at least one type of layer to continue!") + output_file = None + if os.name == "nt": + output_file = "cutoptim.svg" + else: + output_file = "/tmp/cutoptim.svg" + if os.path.exists(output_file): + try: + os.remove(output_file) + except OSError as e: + pass + + cmd.append("--output") + cmd.append(output_file) + + # run CutOptim with the parameters provided + if self.options.print_cmd == "true": + inkex.utils.debug("The following command would be executed on shell:\n") + inkex.utils.debug(" ".join(cmd)) + exit(0) + else: + with subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=False) as cutoptim: + cutoptim.wait() + stdout, stderr = cutoptim.communicate() + #inkex.utils.debug("stdout:\n{}".format(stdout.decode('UTF-8'))) + errors = stderr.decode('UTF-8') + if len(errors) > 0: + inkex.utils.debug("Errors occured:\n{}".format(errors)) + if len(errors) > 0 and cancel_on_error is True: + inkex.utils.debug("Maybe enlarge your document size in case not all polygons could be placed and try again! Nesting was cancelled!") + exit(1) + + # check output existence + try: + stream = open(output_file, 'r') + except FileNotFoundError as e: + inkex.utils.debug("There was no SVG output generated. Cannot continue. Command was:\n") + inkex.utils.debug(" ".join(cmd)) + exit(1) + + if self.options.original == "false": #we need to use string representation of bool + for element in self.document.getroot(): + if isinstance(element, inkex.ShapeElement): + element.delete() + + if self.options.debug_file == "true": #we need to use string representation of bool + self.openDebugFile("Debug_CutOptim.txt") + + # write the generated SVG into Inkscape's canvas + doc = etree.parse(stream, parser=etree.XMLParser(huge_tree=True)) + stream.close() + group = inkex.Group(id="CutOptim") + ''' + 0 = Placed_Layer + 1 = Original_Layer (Input Layer) <> see "Keep original layer" + 2 = Polygon_Layer + 4 = Large_Polygon_Layer + 8 = Hull_Placed_Layer + 16 = Placed_Polygon_Layer + ''' + if layer_output_0 is False: + l0 = None + else: + l0 = doc.xpath('//svg:g[@inkscape:label="Placed_Layer"]', namespaces=inkex.NSS) + #l1 = doc.xpath('//svg:g[@inkscape:label="Original_Layer"]', namespaces=inkex.NSS) + l2 = doc.xpath('//svg:g[@inkscape:label="Polygon_Layer"]', namespaces=inkex.NSS) + l4 = doc.xpath('//svg:g[@inkscape:label="Large_Polygon_Layer"]', namespaces=inkex.NSS) + l8 = doc.xpath('//svg:g[@inkscape:label="Hull_Placed_Layer"]', namespaces=inkex.NSS) + l16 = doc.xpath('//svg:g[@inkscape:label="Placed_Polygon_Layer"]', namespaces=inkex.NSS) + for layer in (l0, l2, l4, l8, l16): #,l1 + if layer is not None and len(layer) > 0: + for element in layer:#[0].getchildren(): + group.append(element) + self.document.getroot().append(group) + +if __name__ == '__main__': + CuttingOptimizer().run() \ No newline at end of file diff --git a/extensions/fablabchemnitz/cutting_optimizer/meta.json b/extensions/fablabchemnitz/cutting_optimizer/meta.json new file mode 100644 index 0000000..1af2561 --- /dev/null +++ b/extensions/fablabchemnitz/cutting_optimizer/meta.json @@ -0,0 +1,21 @@ +[ + { + "name": "Cutting Optimizer (Nesting)", + "id": "fablabchemnitz.de.cutting_optimizer", + "path": "cutting_optimizer", + "dependent_extensions": null, + "original_name": "Laser Cutting Optmizer", + "original_id": "fr.fablab-lannion.inkscape.cutopimiser", + "license": "MIT License", + "license_url": "https://github.com/thierry7100/CutOptim/blob/master/LICENSE", + "comment": "", + "source_url": "https://gitea.fablabchemnitz.de/FabLab_Chemnitz/mightyscape-1.X/src/branch/master/extensions/fablabchemnitz/cutting_optimizer", + "fork_url": "https://github.com/thierry7100/CutOptim", + "documentation_url": "https://stadtfabrikanten.org/pages/viewpage.action?pageId=55018148", + "inkscape_gallery_url": null, + "main_authors": [ + "github.com/thierry7100", + "github.com/vmario89" + ] + } +] \ No newline at end of file diff --git a/extensions/fablabchemnitz/export_selection_as/export_selection_as.inx b/extensions/fablabchemnitz/export_selection_as/export_selection_as.inx new file mode 100644 index 0000000..29c517f --- /dev/null +++ b/extensions/fablabchemnitz/export_selection_as/export_selection_as.inx @@ -0,0 +1,72 @@ + + + Export Selection As ... + fablabchemnitz.de.export_selection_as + + + false + 1.000 + + + + + + + + + ./inkscape_export/ + false + /usr/share/inkscape/extensions/dxf_outlines.py + true + false + false + false + 96 + false + false + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ../000_about_fablabchemnitz.svg + + + + all + + + + + + Export selection to separate files. + + + \ No newline at end of file diff --git a/extensions/fablabchemnitz/export_selection_as/export_selection_as.py b/extensions/fablabchemnitz/export_selection_as/export_selection_as.py new file mode 100644 index 0000000..cd76043 --- /dev/null +++ b/extensions/fablabchemnitz/export_selection_as/export_selection_as.py @@ -0,0 +1,305 @@ +#!/usr/bin/env python3 + +from copy import deepcopy +from pathlib import Path +import logging +import math +import os +import sys +import subprocess +from subprocess import Popen, PIPE +import inkex +from inkex import Rectangle +import inkex.command +from inkex.command import inkscape, inkscape_command +import tempfile +from PIL import Image +import base64 +from io import BytesIO +import warnings +warnings.simplefilter('ignore', Image.DecompressionBombWarning) +from lxml import etree +from scour.scour import scourString + +logger = logging.getLogger(__name__) + +DETACHED_PROCESS = 0x00000008 +GROUP_ID = 'export_selection_transform' + +class ExportObject(inkex.EffectExtension): + + def add_arguments(self, pars): + pars.add_argument("--tab") + pars.add_argument("--wrap_transform", type=inkex.Boolean, default=False, help="Wrap final document in transform") + pars.add_argument("--border_offset", type=float, default=1.000, help="Add border offset around selection") + pars.add_argument("--border_offset_unit", default="mm", help="Offset unit") + pars.add_argument("--export_dir", default="~/inkscape_export/", help="Location to save exported documents") + pars.add_argument("--opendir", type=inkex.Boolean, default=False, help="Open containing output directory after export") + pars.add_argument("--dxf_exporter_path", default="/usr/share/inkscape/extensions/dxf_outlines.py", help="Location of dxf_outlines.py") + pars.add_argument("--export_svg", type=inkex.Boolean, default=False, help="Create a svg file") + pars.add_argument("--export_dxf", type=inkex.Boolean, default=False, help="Create a dxf file") + pars.add_argument("--export_pdf", type=inkex.Boolean, default=False, help="Create a pdf file") + pars.add_argument("--export_png", type=inkex.Boolean, default=False, help="Create a png file") + pars.add_argument("--png_dpi", type=float, default=96, help="PNG DPI (applies for export and replace)") + pars.add_argument("--replace_by_png", type=inkex.Boolean, default=False, help="Replace selection by png export") + pars.add_argument("--newwindow", type=inkex.Boolean, default=False, help="Open file in new Inkscape window") + pars.add_argument("--skip_errors", type=inkex.Boolean, default=False, help="Skip on errors") + + def openExplorer(self, dir): + if os.name == 'nt': + Popen(["explorer", dir], close_fds=True, creationflags=DETACHED_PROCESS).wait() + else: + Popen(["xdg-open", dir], close_fds=True, start_new_session=True).wait() + + def spawnIndependentInkscape(self, file): #function to spawn non-blocking inkscape instance. the inkscape command is available because it is added to ENVIRONMENT when Inkscape main instance is started + if not os.path.exists(file): + inkex.utils.debug("Error. {} does not exist!".format(file)) + exit(1) + warnings.simplefilter('ignore', ResourceWarning) #suppress "enable tracemalloc to get the object allocation traceback" + if os.name == 'nt': + Popen(["inkscape", file], close_fds=True, creationflags=DETACHED_PROCESS) + else: + subprocess.Popen(["inkscape", file], start_new_session=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + warnings.simplefilter("default", ResourceWarning) + + def effect(self): + scale_factor = self.svg.unittouu("1px") + + svg_export = self.options.export_svg + #extra_param = "--batch-process" + extra_param = None + + if self.options.export_svg is False and \ + self.options.export_dxf is False and \ + self.options.export_pdf is False and \ + self.options.export_png is False and \ + self.options.replace_by_png is False and \ + self.options.newwindow is False: + inkex.utils.debug("You must select at least one option to continue!") + return + + if self.options.replace_by_png is True: + self.options.border_offset = 0 #override + + if not self.svg.selected: + inkex.errormsg("Selection is empty. Please select some objects first!") + return + + if self.options.export_dxf is True: + #preflight check for DXF input dir + if not os.path.exists(self.options.dxf_exporter_path): + inkex.utils.debug("Location of dxf_outlines.py does not exist. Please select a proper file and try again.") + exit(1) + + export_dir = Path(self.absolute_href(self.options.export_dir)) + os.makedirs(export_dir, exist_ok=True) + + offset = self.svg.unittouu(str(self.options.border_offset) + self.options.border_offset_unit) + + bbox = inkex.BoundingBox() + + selected = self.svg.selected + firstId = selected[0].get('id') + parent = self.svg.getElementById(firstId).getparent() + + for element in selected.values(): + transform = inkex.Transform() + parent = element.getparent() + if parent is not None and isinstance(parent, inkex.ShapeElement): + transform = parent.composed_transform() + try: + ''' + ...rectangles cause some strangle scaling issue, offendingly caused by namedview units. + The rectangle attributes are set in px. They ignore the real units from namedview. + Strange fact: ellipses, spirals and other primitives work flawlessly. + ''' + if isinstance (element, inkex.Rectangle) or \ + isinstance (element, inkex.Circle) or \ + isinstance (element, inkex.Ellipse): + bbox += element.bounding_box(transform) * scale_factor + elif isinstance (element, inkex.TextElement) or \ + isinstance (element, inkex.Tspan): + if self.options.skip_errors is False: + self.msg("Text elements are not supported!") + return + else: + continue + else: + bbox += element.bounding_box(transform) + except Exception: + logger.exception("Bounding box not computed") + logger.info("Skipping bounding box") + transform = element.composed_transform() + x1, y1 = transform.apply_to_point([0, 0]) + x2, y2 = transform.apply_to_point([1, 1]) + bbox += inkex.BoundingBox((x1, x2), (y1, y2)) + + template = self.create_document() + svg_filename = None + + group = etree.SubElement(template, '{http://www.w3.org/2000/svg}g') + group.attrib['id'] = GROUP_ID + group.attrib['transform'] = str(inkex.Transform(((1, 0, -bbox.left), (0, 1, -bbox.top)))) + + for element in self.svg.selected.values(): + if element.tag == inkex.addNS('image', 'svg'): + continue #skip images + elem_copy = deepcopy(element) + elem_copy.attrib['transform'] = str(element.composed_transform()) + elem_copy.attrib['style'] = str(element.specified_style()) + group.append(elem_copy) + + template.attrib['viewBox'] = f'{-offset} {-offset} {bbox.width + offset * 2} {bbox.height + offset * 2}' + template.attrib['width'] = f'{bbox.width + offset * 2}' + self.svg.unit + template.attrib['height'] = f'{bbox.height + offset * 2}' + self.svg.unit + + if svg_filename is None: + filename_base = element.attrib.get('id', None).replace(os.sep, '_') + if filename_base: + svg_filename = filename_base + '.svg' + if not filename_base: #should never be the case. Inkscape might crash if the id attribute is empty or not existent due to invalid SVG + filename_base = self.svg.get_unique_id("selection") + svg_filename = filename_base + '.svg' + + if len(group) == 0: + self.msg("Selection does not contain any vector data.") + exit(1) + + template.append(group) + svg_out = os.path.join(tempfile.gettempdir(), svg_filename) + + if self.options.wrap_transform is False: + #self.load(inkscape_command(template.tostring(), select=GROUP_ID, verbs=['SelectionUnGroup;FileSave'])) #fails due to new bug + + #workaround + self.save_document(template, svg_out) #export recent file + actions_list=[] + actions_list.append("SelectionUnGroup") + actions_list.append("export-type:svg") + actions_list.append("export-filename:{}".format(svg_out)) + actions_list.append("export-do") + actions = ";".join(actions_list) + cli_output = inkscape(svg_out, extra_param, actions=actions) #process recent file + if len(cli_output) > 0: + self.msg("Inkscape returned the following output when trying to run the file export; the file export may still have worked:") + self.msg(cli_output) + self.load(svg_out) #reload recent file + + template = self.svg + for child in template.getchildren(): + if child.tag == '{http://www.w3.org/2000/svg}metadata': + template.remove(child) + + self.save_document(template, svg_out) # save one into temp dir to access for dxf/pdf/new window instance + + if self.options.export_svg is True: + self.save_document(template, export_dir / svg_filename) + + if self.options.opendir is True: + self.openExplorer(export_dir) + + if self.options.newwindow is True: + #inkscape(os.path.join(export_dir, svg_filename)) #blocking cmd + self.spawnIndependentInkscape(os.path.join(tempfile.gettempdir(), svg_filename)) #non-blocking + + if self.options.export_dxf is True: + #ensure that python command is available #we pass 25.4/96 which stands for unit mm. See inkex.units.UNITS and dxf_outlines.inx + cmd = [ + sys.executable, #the path of the python interpreter which is used for this script + self.options.dxf_exporter_path, + '--output=' + os.path.join(export_dir, filename_base + '.dxf'), + r'--units=25.4/96', + os.path.join(tempfile.gettempdir(), svg_filename) + ] + proc = Popen(cmd, shell=False, stdout=PIPE, stderr=PIPE) + stdout, stderr = proc.communicate() + if proc.returncode != 0: + inkex.utils.debug("%d %s %s" % (proc.returncode, stdout, stderr)) + + if self.options.export_pdf is True: + cli_output = inkscape(os.path.join(tempfile.gettempdir(), svg_filename), extra_param, actions='export-pdf-version:1.5;export-text-to-path;export-filename:{file_name};export-do'.format(file_name=os.path.join(export_dir, filename_base + '.pdf'))) + if len(cli_output) > 0: + self.msg("Inkscape returned the following output when trying to run the file export; the file export may still have worked:") + self.msg(cli_output) + + if self.options.export_png is True: + png_export=os.path.join(export_dir, filename_base + '.png') + try: + os.remove(png_export) + except OSError as e: + #inkex.utils.debug("Error while deleting previously generated output file " + png_export) + pass + actions_list=[] + actions_list.append("export-background:white") + actions_list.append("export-type:png") + actions_list.append("export-dpi:{}".format(self.options.png_dpi)) + actions_list.append("export-filename:{}".format(png_export)) + actions_list.append("export-do") + actions = ";".join(actions_list) + cli_output = inkscape(os.path.join(tempfile.gettempdir(), svg_filename), extra_param, actions=actions) + if len(cli_output) > 0: + self.msg("Inkscape returned the following output when trying to run the file export; the file export may still have worked:") + self.msg(cli_output) + + if self.options.replace_by_png is True: + #export to png file to temp + png_export=os.path.join(tempfile.gettempdir(), filename_base + '.png') + try: + os.remove(png_export) + except OSError as e: + #inkex.utils.debug("Error while deleting previously generated output file " + png_export) + pass + actions_list=[] + actions_list.append("export-background:white") + actions_list.append("export-type:png") + actions_list.append("export-dpi:{}".format(self.options.png_dpi)) + actions_list.append("export-filename:{}".format(png_export)) + actions_list.append("export-do") + actions = ";".join(actions_list) + cli_output = inkscape(os.path.join(tempfile.gettempdir(), svg_filename), extra_param, actions=actions) + if len(cli_output) > 0: + self.msg("Inkscape returned the following output when trying to run the file export; the file export may still have worked:") + self.msg(cli_output) + #then remove the selection and replace it by png + #self.msg(parent.get('id')) + for element in selected.values(): + element.delete() + #read png file and get base64 string from it + try: + img = Image.open(png_export) + except Image.DecompressionBombError as e: #we could also increse PIL.Image.MAX_IMAGE_PIXELS = some large int + self.msg("Error. Image is too large ({} x {} px). Reduce DPI and try again!".format(self.svg.uutounit(bbox.width), self.svg.uutounit(bbox.height))) + exit(1) + output_buffer = BytesIO() + img.save(output_buffer, format='PNG') + byte_data = output_buffer.getvalue() + base64_str = base64.b64encode(byte_data).decode('UTF-8') + #finally replace the svg:path(s) with svg:image + imgReplacement = etree.SubElement(Rectangle(), '{http://www.w3.org/2000/svg}image') + imgReplacement.attrib['x'] = str(bbox.left) + imgReplacement.attrib['y'] = str(bbox.top) + imgReplacement.attrib['width'] = str(bbox.width) + imgReplacement.attrib['height'] = str(bbox.height) + imgReplacement.attrib['id'] = firstId + imgReplacement.attrib['{http://www.w3.org/1999/xlink}href'] = "data:image/png;base64,{}".format(base64_str) + parent.append(imgReplacement) + if parent.attrib.has_key('transform'): + del parent.attrib['transform'] #remove transform + + + def create_document(self): + document = self.svg.copy() + for child in document.getchildren(): + if child.tag == '{http://www.w3.org/2000/svg}defs': + continue + document.remove(child) + return document + + def save_document(self, document, filename): + with open(filename, 'wb') as fp: + document = document.tostring() + fp.write(scourString(document).encode('utf8')) + + +if __name__ == '__main__': + ExportObject().run() \ No newline at end of file diff --git a/extensions/fablabchemnitz/export_selection_as/meta.json b/extensions/fablabchemnitz/export_selection_as/meta.json new file mode 100644 index 0000000..0ca17f8 --- /dev/null +++ b/extensions/fablabchemnitz/export_selection_as/meta.json @@ -0,0 +1,21 @@ +[ + { + "name": "Export Selection As ...", + "id": "fablabchemnitz.de.export_selection_as", + "path": "export_selection_as", + "dependent_extensions": null, + "original_name": "Export selection as svg", + "original_id": "sk.linuxos.export_selection", + "license": "MIT License", + "license_url": "https://github.com/mireq/inkscape-export-selection-as-svg/blob/master/LICENSE", + "comment": "", + "source_url": "https://gitea.fablabchemnitz.de/FabLab_Chemnitz/mightyscape-1.X/src/branch/master/extensions/fablabchemnitz/export_selection_as", + "fork_url": "https://github.com/mireq/inkscape-export-selection-as-svg", + "documentation_url": "https://stadtfabrikanten.org/pages/viewpage.action?pageId=104923223", + "inkscape_gallery_url": null, + "main_authors": [ + "github.com/mireq", + "github.com/vmario89" + ] + } +] \ No newline at end of file diff --git a/extensions/fablabchemnitz/guilloche_creations/guilloche_contour.inx b/extensions/fablabchemnitz/guilloche_creations/guilloche_contour.inx new file mode 100644 index 0000000..a3ed82b --- /dev/null +++ b/extensions/fablabchemnitz/guilloche_creations/guilloche_contour.inx @@ -0,0 +1,56 @@ + + + Guilloche Contour + fablabchemnitz.de.guilloche_creations.guilloche_contour + + + + + + + + + + + + + + + + + + + + 10 + 1 + 0 + 0 + 20 + false + + + + 0.0 + 0 + 0.0 + 0 + 0.0 + 0 + 0.0 + 0 + 0.0 + 0 + + + + all + + + + + + + + \ No newline at end of file diff --git a/extensions/fablabchemnitz/guilloche_creations/guilloche_contour.py b/extensions/fablabchemnitz/guilloche_creations/guilloche_contour.py new file mode 100644 index 0000000..ca994ca --- /dev/null +++ b/extensions/fablabchemnitz/guilloche_creations/guilloche_contour.py @@ -0,0 +1,663 @@ +#! /usr/bin/env python3 +''' +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 +the Free Software Foundation; either version 2 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +Quick description: + +''' +# standard library +from math import * +from copy import deepcopy +# local library +import inkex +import pathmodifier +import cubicsuperpath +from inkex import paths +from lxml import etree + +def getColorAndOpacity(longColor): + ''' + Convert the long into a #rrggbb color value + Conversion back is A + B*256^1 + G*256^2 + R*256^3 + ''' + longColor = int(longColor) + + if longColor < 0: + longColor = longColor & 0xFFFFFFFF + + hexColor = hex(longColor) + lhc = len(hexColor) + + hexOpacity = hexColor[lhc-2 : ] + hexColor = '#' + hexColor[2:-2].rjust(6, '0') + + return (hexColor, hexOpacity) + +def setColorAndOpacity(style, color, opacity): + declarations = style.split(';') + strokeOpacityInStyle = False + newOpacity = round((int(opacity, 16) / 255.0), 8) + + for i,decl in enumerate(declarations): + parts = decl.split(':', 2) + + if len(parts) == 2: + (prop, val) = parts + prop = prop.strip().lower() + + if (prop == 'stroke' and val != color): + declarations[i] = prop + ':' + color + + if prop == 'stroke-opacity': + if val != newOpacity: + declarations[i] = prop + ':' + str(newOpacity) + + strokeOpacityInStyle = True + + if not strokeOpacityInStyle: + declarations.append('stroke-opacity' + ':' + str(newOpacity)) + + return ";".join(declarations) + +def getSkeletonPath(d, offs): + ''' + Recieves a current skeleton path and offset specified by the user if it's line. + Calculates new skeleton path to use for creating contour with given offset. + ''' + + if offs != 0: + comps = d.split() + + if ((comps[2] == 'h' or comps[2] == 'H') and len(comps) == 4): + startPt = comps[1].split(',') + startX = float(startPt[0]) + startY = float(startPt[1]) + + finalX = float(comps[3]) if comps[2] == 'H' else startX + float(comps[3]) + + if startX < finalX: + startY -= offs + else: + startY += offs + + comps[1] = startPt[0] + ',' + str(startY) + elif ((comps[2] == 'v' or comps[2] == 'V') and len(comps) == 4): + startPt = comps[1].split(',') + startX = float(startPt[0]) + startY = float(startPt[1]) + + finalY = float(comps[3]) if comps[2] == 'V' else startY + float(comps[3]) + + if startY < finalY: + startX += offs + else: + startX -= offs + + comps[1] = str(startX) + ',' + startPt[1] + elif (comps[0] == 'M' and len(comps) == 3): + startPt = comps[1].split(',') + startX = float(startPt[0]) + startY = float(startPt[1]) + + finalPt = comps[2].split(',') + finalX = float(finalPt[0]) + finalY = float(finalPt[1]) + + if startX < finalX: + if (startY > finalY): + startX -= offs + finalX -= offs + else: + startX += offs + finalX += offs + startY -= offs + finalY -= offs + else: + if startY > finalY: + startX -= offs + finalX -= offs + else: + startX += offs + finalX += offs + startY += offs + finalY += offs + + comps[1] = str(startX) + ',' + str(startY) + comps[2] = str(finalX) + ',' + str(finalY) + elif (comps[0] == 'm' and len(comps) == 3): + startPt = comps[1].split(',') + startX = float(startPt[0]) + startY = float(startPt[1]) + + finalPt = comps[2].split(',') + dx = float(finalPt[0]) + dy = float(finalPt[1]) + finalX = startX + dx + finalY = startY + dy + + if startX < finalX: + if startY > finalY: + startX -= offs + else: + startX += offs + startY -= offs + else: + if startY > finalY: + startX -= offs + else: + startX += offs + startY += offs + + comps[1] = str(startX) + ',' + str(startY) + comps[2] = str(dx) + ',' + str(dy) + + return paths.CubicSuperPath(paths.Path(' '.join(comps))) + + return paths.CubicSuperPath(paths.Path(d)) + +def modifySkeletonPath(skelPath): + + resPath = [] + l = len(skelPath) + resPath += skelPath[0] + + if l > 1: + for i in range(1, l): + + if skelPath[i][0][1] == resPath[-1][1]: + skelPath[i][0][0] = resPath[-1][0] + del resPath[-1] + + resPath += skelPath[i] + + return resPath + +def linearize(p, tolerance=0.001): + ''' + This function receives a component of a 'cubicsuperpath' and returns two things: + The path subdivided in many straight segments, and an array containing the length of each segment. + ''' + zero = 0.000001 + i = 0 + d = 0 + lengths=[] + + while i < len(p) - 1: + box = inkex.bezier.pointdistance(p[i][1], p[i][2]) + box += inkex.bezier.pointdistance(p[i][2], p[i+1][0]) + box += inkex.bezier.pointdistance(p[i+1][0], p[i+1][1]) + chord = inkex.bezier.pointdistance(p[i][1], p[i+1][1]) + + if (box - chord) > tolerance: + b1, b2 = inkex.bezier.beziersplitatt([p[i][1], p[i][2], p[i + 1][0], p[i + 1][1]], 0.5) + p[i][2][0], p[i][2][1] = b1[1] + p[i + 1][0][0], p[i + 1][0][1] = b2[2] + p.insert(i + 1, [[b1[2][0], b1[2][1]], [b1[3][0], b1[3][1]], [b2[1][0], b2[1][1]]]) + else: + d = (box + chord) / 2 + lengths.append(d) + i += 1 + + new = [p[i][1] for i in range(0, len(p) - 1) if lengths[i] > zero] + new.append(p[-1][1]) + lengths = [l for l in lengths if l > zero] + + return (new, lengths) + +def isSkeletonClosed(sklCmp): + + requiredPrecision = 0.005 + + sctest1 = abs(sklCmp[0][0] - sklCmp[-1][0]) > requiredPrecision + sctest2 = abs(sklCmp[0][1] - sklCmp[-1][1]) > requiredPrecision + + if sctest1 or sctest2: + return False + + return True + +def getPolygonCentroid(polygon): + x = 0 + y = 0 + n = len(polygon) + + for vert in polygon: + x += vert[0] + y += vert[1] + + x = x / n + y = y / n + + return [x, y] + +def getPoint(p1, p2, x, y): + x1 = p1[0] + y1 = p1[1] + x2 = p2[0] + y2 = p2[1] + + a = (y1 - y2) / (x1 - x2) + b = y1 - a * x1 + + if x == None: + x = (y - b) / a + else: + y = a * x + b + + return [x, y] + +def getPtOnSeg(p1, p2, segLen, l): + if p1[0] == p2[0]: + return [p2[0], p2[1] - l] if p2[1] < p1[1] else [p2[0], p2[1] + l] + + if p1[1] == p2[1]: + return [p2[0] - l, p2[1]] if p2[0] < p1[0] else [p2[0] + l, p2[1]] + + dy = abs(p1[1] - p2[1]) + angle = asin(dy / segLen) + dx = l * cos(angle) + x = p1[0] - dx if p1[0] > p2[0] else p1[0] + dx + + return getPoint(p1, p2, x, None) + +def drawfunction(nodes, width, fx): + # x-bounds of the plane + xstart = 0.0 + xend = 2 * pi + # y-bounds of the plane + ybottom = -1.0 + ytop = 1.0 + # size and location of the plane on the canvas + height = 2 + left = 15 + bottom = 15 + height + + # function specified by the user + try: + if fx != "": + f = eval('lambda x: ' + fx.strip('"')) + except SyntaxError: + return [] + + scalex = width / (xend - xstart) + xoff = left + # conver x-value to coordinate + coordx = lambda x: (x - xstart) * scalex + xoff + + scaley = height / (ytop - ybottom) + yoff = bottom + # conver y-value to coordinate + coordy = lambda y: (ybottom - y) * scaley + yoff + + # step is the distance between nodes on x + step = (xend - xstart) / (nodes - 1) + third = step / 3.0 + # step used in calculating derivatives + ds = step * 0.001 + + # initialize function and derivative for 0; + # they are carried over from one iteration to the next, to avoid extra function calculations. + x0 = xstart + y0 = f(xstart) + + # numerical derivative, using 0.001*step as the small differential + x1 = xstart + ds # Second point AFTER first point (Good for first point) + y1 = f(x1) + + dx0 = (x1 - x0) / ds + dy0 = (y1 - y0) / ds + + # path array + a = [] + + # Start curve + #a.append(['M ', [coordx(x0), coordy(y0)]]) + a.append(['M', [coordx(x0), coordy(y0)]]) + + for i in range(int(nodes - 1)): + x1 = (i + 1) * step + xstart + x2 = x1 - ds # Second point BEFORE first point (Good for last point) + y1 = f(x1) + y2 = f(x2) + + # numerical derivative + dx1 = (x1 - x2) / ds + dy1 = (y1 - y2) / ds + + # create curve + a.append(['C', [coordx(x0 + (dx0 * third)), coordy(y0 + (dy0 * third)), + coordx(x1 - (dx1 * third)), coordy(y1 - (dy1 * third)), + coordx(x1), coordy(y1)]]) + + # Next segment's start is this segment's end + x0 = x1 + y0 = y1 + # Assume the function is smooth everywhere, so carry over the derivative too + dx0 = dx1 + dy0 = dy1 + + return a + +def offset(pathComp, dx, dy): + for ctl in pathComp: + for pt in ctl: + pt[0] += dx + pt[1] += dy + +def stretch(pathComp, xscale, yscale, org): + for ctl in pathComp: + for pt in ctl: + pt[0] = org[0] + (pt[0] - org[0]) * xscale + pt[1] = org[1] + (pt[1] - org[1]) * yscale + +class GuillocheContour(pathmodifier.PathModifier): + def add_arguments(self, pars): + pars.add_argument("--contourFunction", default="sin", help="Function defining the contour") + pars.add_argument("--tab") + pars.add_argument("--frequency", type=int, default=10, help="Frequency of the function") + pars.add_argument("--amplitude", type=int, default=1, help="Amplitude of the function") + pars.add_argument("--phaseOffset", type=int, default=0, help="Phase offset of the function") + pars.add_argument("--offset", type=int, default=0, help="Offset of the function") + pars.add_argument("--nodes", type=int, default=20, help="Count of nodes") + pars.add_argument("--remove", type=inkex.Boolean, default=False, help="If Ttrue, control object will be removed") + pars.add_argument("--strokeColor", type=inkex.Color) + pars.add_argument("--amplitude1", type=float, default=0.0, help="Amplitude of first harmonic") + pars.add_argument("--phase1", type=int, default=0, help="Phase offset of first harmonic") + pars.add_argument("--amplitude2", type=float, default=0.0, help="Amplitude of second harmonic") + pars.add_argument("--phase2", type=int, default=0, help="Phase offset of second harmonic") + pars.add_argument("--amplitude3", type=float, default=0.0, help="Amplitude of third harmonic") + pars.add_argument("--phase3", type=int, default=0, help="Phase offset of third harmonic") + pars.add_argument("--amplitude4", type=float, default=0.0, help="Amplitude of fourth harmonic") + pars.add_argument("--phase4", type=int, default=0, help="Phase offset of fourth harmonic") + pars.add_argument("--amplitude5", type=float, default=0.0, help="Amplitude of fifth harmonic") + pars.add_argument("--phase5", type=int, default=0, help="Phase offset of fifth harmonic") + + + def prepareSelectionList(self): + self.skeletons = self.svg.selected + pathmodifier.PathModifier.expand_clones(self, self.skeletons, True, False) + pathmodifier.PathModifier.objects_to_paths(self, self.skeletons, True) + + def linearizePath(self, skelPath, offs): + comps, lengths = linearize(skelPath) + + self.skelCompIsClosed = isSkeletonClosed(comps) + + if (self.skelCompIsClosed and offs != 0): + centroid = getPolygonCentroid(comps) + + for i in range(len(comps)): + pt1 = comps[i] + dist = inkex.bezier.pointdistance(centroid, pt1) + + comps[i] = getPtOnSeg(centroid, pt1, dist, dist + offs) + + if i > 0: + lengths[i - 1] = inkex.bezier.pointdistance(comps[i - 1], comps[i]) + + return (comps, lengths) + + def getFunction(self, func): + res = '' + + presetAmp1 = presetAmp2 = presetAmp3 = presetAmp4 = presetAmp5 = 0.0 + presetPhOf1 = presetPhOf2 = presetPhOf3 = presetPhOf4 = presetPhOf5 = presetOffs = 0 + + if (func == 'sin' or func == 'cos'): + return '(' + str(self.options.amplitude) + ') * ' + func + '(x + (' + str(self.options.phaseOffset / 100.0 * 2 * pi) + '))' + + if func == 'env1': + presetAmp1 = presetAmp3 = 0.495 + elif func == 'env2': + presetAmp1 = presetAmp3 = 0.65 + presetPhOf1 = presetPhOf3 = 25 + elif func == 'env3': + presetAmp1 = 0.75 + presetPhOf1 = 25 + presetAmp3 = 0.24 + presetPhOf3 = -25 + elif func == 'env4': + presetAmp1 = 1.105 + presetAmp3 = 0.27625 + presetPhOf3 = 50 + elif func == 'env5': + presetAmp1 = 0.37464375 + presetPhOf1 = 25 + presetAmp2 = 0.5655 + presetAmp3 = 0.37464375 + presetPhOf3 = -25 + elif func == 'env6': + presetAmp1 = 0.413725 + presetPhOf1 = 25 + presetAmp2 = 0.45695 + presetPhOf2 = 50 + presetAmp3 = 0.494 + presetPhOf3 = -25 + elif func == 'env7': + presetAmp1 = 0.624 + presetPhOf1 = 25 + presetAmp2 = 0.312 + presetAmp3 = 0.624 + presetPhOf3 = 25 + elif func == 'env8': + presetAmp1 = 0.65 + presetPhOf1 = 50 + presetAmp2 = 0.585 + presetAmp3 = 0.13 + elif func == 'env9': + presetAmp1 = 0.07605 + presetPhOf1 = 25 + presetAmp2 = 0.33345 + presetPhOf2 = 50 + presetAmp3 = 0.468 + presetPhOf3 = -25 + presetAmp4 = 0.32175 + elif func == 'env10': + presetAmp1 = 0.3575 + presetPhOf1 = -25 + presetAmp2 = 0.3575 + presetAmp3 = 0.3575 + presetPhOf3 = 25 + presetAmp4 = 0.3575 + presetPhOf4 = 50 + elif func == 'env11': + presetAmp1 = 0.65 + presetPhOf1 = 25 + presetAmp2 = 0.13 + presetPhOf2 = 50 + presetAmp3 = 0.26 + presetPhOf3 = 25 + presetAmp4 = 0.39 + elif func == 'env12': + presetAmp1 = 0.5525 + presetPhOf1 = -25 + presetAmp2 = 0.0414375 + presetPhOf2 = 50 + presetAmp3 = 0.15884375 + presetPhOf3 = 25 + presetAmp4 = 0.0966875 + presetAmp5 = 0.28315625 + presetPhOf5 = -25 + + harm1 = '(' + str(self.options.amplitude * (presetAmp1 + self.options.amplitude1)) + ') * cos(1 * (x + (' + str(self.options.phaseOffset / 100.0 * 2 * pi) + ')) - (' + str((presetPhOf1 + self.options.phase1) / 100.0 * 2 * pi) + '))' + harm2 = '(' + str(self.options.amplitude * (presetAmp2 + self.options.amplitude2)) + ') * cos(2 * (x + (' + str(self.options.phaseOffset / 100.0 * 2 * pi) + ')) - (' + str((presetPhOf2 + self.options.phase2) / 100.0 * 2 * pi) + '))' + harm3 = '(' + str(self.options.amplitude * (presetAmp3 + self.options.amplitude3)) + ') * cos(3 * (x + (' + str(self.options.phaseOffset / 100.0 * 2 * pi) + ')) - (' + str((presetPhOf3 + self.options.phase3) / 100.0 * 2 * pi) + '))' + harm4 = '(' + str(self.options.amplitude * (presetAmp4 + self.options.amplitude4)) + ') * cos(4 * (x + (' + str(self.options.phaseOffset / 100.0 * 2 * pi) + ')) - (' + str((presetPhOf4 + self.options.phase4) / 100.0 * 2 * pi) + '))' + harm5 = '(' + str(self.options.amplitude * (presetAmp5 + self.options.amplitude5)) + ') * cos(5 * (x + (' + str(self.options.phaseOffset / 100.0 * 2 * pi) + ')) - (' + str((presetPhOf5 + self.options.phase5) / 100.0 * 2 * pi) + '))' + + res = harm1 + ' + ' + harm2 + ' + ' + harm3 + ' + ' + harm4 + ' + ' + harm5 + + return res + + def lengthToTime(self, l): + ''' + Recieves an arc length l, and returns the index of the segment in self.skelComp + containing the corresponding point, together with the position of the point on this segment. + + If the deformer is closed, do computations modulo the total length. + ''' + if self.skelCompIsClosed: + l = l % sum(self.lengths) + + if l <= 0: + return 0, l / self.lengths[0] + + i = 0 + + while (i < len(self.lengths)) and (self.lengths[i] <= l): + l -= self.lengths[i] + i += 1 + + t = l / self.lengths[min(i, len(self.lengths) - 1)] + + return (i, t) + + def applyDiffeo(self, bpt, vects=()): + ''' + The kernel of this stuff: + bpt is a base point and for v in vectors, v'=v-p is a tangent vector at bpt. + ''' + s = bpt[0] - self.skelComp[0][0] + i, t = self.lengthToTime(s) + + if i == len(self.skelComp) - 1: + x, y = inkex.bezier.tpoint(self.skelComp[i - 1], self.skelComp[i], t + 1) + dx = (self.skelComp[i][0] - self.skelComp[i - 1][0]) / self.lengths[-1] + dy = (self.skelComp[i][1] - self.skelComp[i - 1][1]) / self.lengths[-1] + else: + x, y = inkex.bezier.tpoint(self.skelComp[i], self.skelComp[i + 1], t) + dx = (self.skelComp[i + 1][0] - self.skelComp[i][0]) / self.lengths[i] + dy = (self.skelComp[i + 1][1] - self.skelComp[i][1]) / self.lengths[i] + + vx = 0 + vy = bpt[1] - self.skelComp[0][1] + bpt[0] = x + vx * dx - vy * dy + bpt[1] = y + vx * dy + vy * dx + + for v in vects: + vx = v[0] - self.skelComp[0][0] - s + vy = v[1] - self.skelComp[0][1] + v[0] = x + vx * dx - vy * dy + v[1] = y + vx * dy + vy * dx + + def effect(self): + + if len(self.options.ids) < 1: + inkex.errormsg(_("This extension requires one selected path.")) + return + + self.prepareSelectionList() + + for skeleton in self.skeletons.__iter__(): + resPath = [] + pattern = etree.Element(inkex.addNS('path','svg')) + + self.options.strokeHexColor, self.strokeOpacity = getColorAndOpacity(self.options.strokeColor) + + # Copy style of skeleton with setting color and opacity + s = skeleton.get('style') + + # Get any path transform for the contour output + xfm = skeleton.get('transform') + + firstSkel = skeleton.get('d') + + if s: + pattern.set('style', setColorAndOpacity(s, self.options.strokeHexColor, self.strokeOpacity)) + + if xfm: + pattern.set('transform', xfm) + + skeletonPath = modifySkeletonPath(getSkeletonPath(skeleton.get('d'), self.options.offset)) + + self.skelComp, self.lengths = self.linearizePath(skeletonPath, self.options.offset) + + length = sum(self.lengths) + patternWidth = length / self.options.frequency + selectedFunction = self.getFunction(self.options.contourFunction) + + pattern.set('d', str(paths.Path(drawfunction(self.options.nodes, patternWidth, selectedFunction)))) + + # Add path into SVG structure + skeleton.getparent().append(pattern) + + if self.options.remove: + skeleton.getparent().remove(skeleton) + + # Compute bounding box + #pattPath = inkex.paths.Path(skeleton.get('d')) + + patternCubicPath = paths.CubicSuperPath(paths.Path(pattern.get('d'))) + patternPath = inkex.paths.Path(patternCubicPath) + + bbox = patternPath.bounding_box() + + width = bbox.maximum[0] - bbox.minimum[0] + dx = width + + if dx < 0.01: + exit(_("The total length of the pattern is too small.")) + + curPath = deepcopy(patternCubicPath) + + xoffset = self.skelComp[0][0] - bbox.minimum[0] + yoffset = self.skelComp[0][1] - (bbox.bottom + bbox.top) / 2 + + patternCopies = max(1, int(round(length / dx))) + width = dx * patternCopies + + newPath = [] + + # Repeat pattern to cover whole skeleton + for subPath in curPath: + for i in range(0, patternCopies, 1): + newPath.append(deepcopy(subPath)) + offset(subPath, dx, 0) + + curPath = newPath + + # Offset pattern to the first node of the skeleton + for subPath in curPath: + offset(subPath, xoffset, yoffset) + + # Stretch pattern to whole skeleton + for subPath in curPath: + stretch(subPath, length / width, 1, self.skelComp[0]) + + for subPath in curPath: + for ctlpt in subPath: + self.applyDiffeo(ctlpt[1], (ctlpt[0], ctlpt[2])) + + # Check if there is a need to close path manually + if self.skelCompIsClosed: + firstPtX = round(curPath[0][0][1][0], 8) + firstPtY = round(curPath[0][0][1][1], 8) + finalPtX = round(curPath[-1][-1][1][0], 8) + finalPtY = round(curPath[-1][-1][1][1], 8) + + if (firstPtX != finalPtX or firstPtY != finalPtY): + curPath[-1].append(curPath[0][0]) + + resPath += curPath + + # This final step takes the newly constructed contour from a multilevel list to + # a formal svg path + step1rep = paths.CubicSuperPath(resPath).to_path().to_arrays() + step2rep = str(paths.Path(step1rep)) + pattern.set('d', step2rep) + + +if __name__ == '__main__': + GuillocheContour().run() \ No newline at end of file diff --git a/extensions/fablabchemnitz/guilloche_creations/guilloche_pattern.inx b/extensions/fablabchemnitz/guilloche_creations/guilloche_pattern.inx new file mode 100644 index 0000000..1bc959f --- /dev/null +++ b/extensions/fablabchemnitz/guilloche_creations/guilloche_pattern.inx @@ -0,0 +1,58 @@ + + + Guilloche Pattern + fablabchemnitz.de.guilloche_creations.guilloche_pattern + + + + + + + + + + + + + + + + + + + + 10 + 100 + 0 + 0 + 100 + 1 + 20 + false + + + + 0.0 + 0 + 0.0 + 0 + 0.0 + 0 + 0.0 + 0 + 0.0 + 0 + + + + all + + + + + + + + \ No newline at end of file diff --git a/extensions/fablabchemnitz/guilloche_creations/guilloche_pattern.py b/extensions/fablabchemnitz/guilloche_creations/guilloche_pattern.py new file mode 100644 index 0000000..78eb7d1 --- /dev/null +++ b/extensions/fablabchemnitz/guilloche_creations/guilloche_pattern.py @@ -0,0 +1,972 @@ +#! /usr/bin/env python3 +''' +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 +the Free Software Foundation; either version 2 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +Quick description: +This extension is an update to the guilloche_pattern extension that works under inkscape v0.92 + +''' +# standard library +from math import asin, atan2, pi, sin, cos +from copy import deepcopy +# local library +import inkex +import pathmodifier +import cubicsuperpath +from inkex import paths +from lxml import etree + +def modifySkeletonPath(skelPath): + resPath = [] + l = len(skelPath) + resPath += skelPath[0] + + if l > 1: + for i in range(1, l): + if skelPath[i][0][1] == resPath[-1][1]: + skelPath[i][0][0] = resPath[-1][0] + del resPath[-1] + + resPath += skelPath[i] + + return resPath + +def linearize(p, tolerance=0.001): + ''' + This function receives a component of a 'cubicsuperpath' and returns two things: + The path subdivided in many straight segments, and an array containing the length + of each segment. + ''' + zero = 0.000001 + i = 0 + d = 0 + lengths=[] + + while i < len(p) - 1: + box = inkex.bezier.pointdistance(p[i][1], p[i][2]) + box += inkex.bezier.pointdistance(p[i][2], p[i+1][0]) + box += inkex.bezier.pointdistance(p[i+1][0], p[i+1][1]) + chord = inkex.bezier.pointdistance(p[i][1], p[i+1][1]) + + if (box - chord) > tolerance: + b1, b2 = inkex.bezier.beziersplitatt([p[i][1], p[i][2], p[i + 1][0], p[i + 1][1]], 0.5) + p[i][2][0], p[i][2][1] = b1[1] + p[i + 1][0][0], p[i + 1][0][1] = b2[2] + p.insert(i + 1, [[b1[2][0], b1[2][1]], [b1[3][0], b1[3][1]], [b2[1][0], b2[1][1]]]) + else: + d = (box + chord) / 2 + lengths.append(d) + i += 1 + + new = [p[i][1] for i in range(0, len(p) - 1) if lengths[i] > zero] + new.append(p[-1][1]) + lengths = [l for l in lengths if l > zero] + + return (new, lengths) + +def isSkeletonClosed(sklCmp): + + requiredPrecision = 0.005 + + sctest1 = abs(sklCmp[0][0] - sklCmp[-1][0]) > requiredPrecision + sctest2 = abs(sklCmp[0][1] - sklCmp[-1][1]) > requiredPrecision + + if sctest1 or sctest2: + return False + + return True + +def checkCompatibility(bbox1, bbox2, comps1, comps2): + cl1 = isSkeletonClosed(comps1) + cl2 = isSkeletonClosed(comps2) + + if (cl1 and cl2): + if ((bbox1.left >= bbox2.left) and (bbox1.right <= bbox2.right) and (bbox1.top >= bbox2.top) and (bbox1.bottom <= bbox2.bottom)): + return (True, False) + elif ((bbox1.left <= bbox2.left) and (bbox1.right >= bbox2.right) and (bbox1.top <= bbox2.top) and (bbox1.bottom >= bbox2.bottom)): + return (True, True) + + elif (not cl1 and not cl2): + if (comps1[0][0] == comps2[0][0] and comps1[-1][0] == comps2[-1][0]): + if ((comps1[0][0] < comps1[-1][0] and comps1[0][1] >= comps2[0][1]) or (comps1[0][0] > comps1[-1][0] and comps1[0][1] <= comps2[0][1])): + return (True, False) + else: + return (True, True) + + elif (comps1[0][1] == comps2[0][1] and comps1[-1][1] == comps2[-1][1]): + if ((comps1[0][1] < comps1[-1][1] and comps1[0][0] <= comps2[0][0]) or (comps1[0][1] > comps1[-1][1] and comps1[0][0] >= comps2[0][0])): + return (True, False) + else: + return (True, True) + + return (False, False) + +def linearizeEnvelopes(envs): + + env0Path = paths.Path(envs[0].get('d')) + env1Path = paths.Path(envs[1].get('d')) + env0CubicPath = paths.CubicSuperPath(env0Path) + env1CubicPath = paths.CubicSuperPath(env1Path) + env0ReformedPath = paths.Path(env0CubicPath) + env1ReformedPath = paths.Path(env1CubicPath) + + bbox1 = env0ReformedPath.bounding_box() + bbox2 = env1ReformedPath.bounding_box() + + comps1, lengths1 = linearize(modifySkeletonPath(env0CubicPath)) + comps2, lengths2 = linearize(modifySkeletonPath(env1CubicPath)) + + correctness, shouldSwap = checkCompatibility(bbox1, bbox2, comps1, comps2) + + if not shouldSwap: + return (comps1, lengths1, comps2, lengths2, bbox1, bbox2, correctness) + else: + return (comps2, lengths2, comps1, lengths1, bbox2, bbox1, correctness) + +def getMidPoint(p1, p2): + return [(p1[0] + p2[0]) / 2, (p1[1] + p2[1]) / 2] + +def getPoint(p1, p2, x, y): + x1 = p1[0] + y1 = p1[1] + x2 = p2[0] + y2 = p2[1] + + a = (y1 - y2) / (x1 - x2) + b = y1 - a * x1 + + if x == None: + x = (y - b) / a + else: + y = a * x + b + + return [x, y] + +def getPtOnSeg(p1, p2, segLen, l): + if p1[0] == p2[0]: + return [p1[0], p1[1] - l] if p1[1] > p2[1] else [p1[0], p1[1] + l] + + if p1[1] == p2[1]: + return [p1[0] - l, p1[1]] if p1[0] > p2[0] else [p1[0] + l, p1[1]] + + dy = abs(p1[1] - p2[1]) + angle = asin(dy / segLen) + dx = l * cos(angle) + x = p1[0] - dx if p1[0] > p2[0] else p1[0] + dx + + return getPoint(p1, p2, x, None) + +def getPtsByX(pt, comps, isClosed): + res = [] + + for i in range(1, len(comps)): + if ((comps[i - 1][0] <= pt[0] and pt[0] <= comps[i][0]) or (comps[i - 1][0] >= pt[0] and pt[0] >= comps[i][0])): + if comps[i - 1][0] == comps[i][0]: + d1 = inkex.bezier.pointdistance(pt, comps[i - 1]) + d2 = inkex.bezier.pointdistance(pt, comps[i]) + + if d1 < d2: + res.append(comps[i - 1]) + else: + res.append(comps[i]) + elif comps[i - 1][1] == comps[i][1]: + res.append([pt[0], comps[i - 1][1]]) + else: + res.append(getPoint(comps[i - 1], comps[i], pt[0], None)) + + if not isClosed: + return res[0] + + return res + +def getPtsByY(pt, comps, isClosed): + res = [] + + for i in range(1, len(comps)): + if ((comps[i - 1][1] <= pt[1] and pt[1] <= comps[i][1]) or (comps[i - 1][1] >= pt[1] and pt[1] >= comps[i][1])): + if comps[i - 1][1] == comps[i][1]: + d1 = inkex.bezier.pointdistance(pt, comps[i - 1]) + d2 = inkex.bezier.pointdistance(pt, comps[i]) + + if d1 < d2: + res.append(comps[i - 1]) + else: + res.append(comps[i]) + elif comps[i - 1][0] == comps[i][0]: + res.append([comps[i - 1][0], pt[1]]) + else: + res.append(getPoint(comps[i - 1], comps[i], None, pt[1])) + + if not isClosed: + return res[0] + + return res + +def getIntersectionPt(cntr, p, comps): + # Find the intersection of the infinitely extended line from cntr to p with comps + # This algorithm assumes that each comps segment is a straight line and that the comps segment subtends less than + # pi angle at cntr + + twopi = 2.0 * pi + a = atan2((cntr[1] - p[1]), (cntr[0] - p[0])) # a is a four-quadrant angle of the cntr->p line, -pi <= a <= pi + + obtPts = [] + + for i in range(1, len(comps)): # Check every interval in comps for an intersection + a2 = atan2((cntr[1] - comps[i][1]) , (cntr[0] - comps[i][0])) + a1 = atan2((cntr[1] - comps[i - 1][1]) , (cntr[0] - comps[i - 1][0])) + + da1a = a - a1 # the angle from a1 to a + da12 = a2 - a1 # the angle interval covered by comps[i-1]->comps[i] + + #inkex.errormsg("raw da1a = " + str(da1a)) + #inkex.errormsg("raw da12 = " + str(da12)) + + if da1a > pi: # make the angle fit -pi->pi + da1a -= twopi + elif da1a < -pi: + da1a += twopi + + if da12 > pi: # make the angle fit -pi->pi + da12 -= twopi + elif da12 < -pi: + da12 += twopi + + frac = da1a / da12 + + if frac < 0.0 or frac > 1.0: # if the line does not cross the comps interval, move on + continue + + x = frac * comps[i][0] + (1.0 - frac) * comps[i-1][0] + y = frac * comps[i][1] + (1.0 - frac) * comps[i-1][1] + + obtPts.append([x,y]) + + if len(obtPts) < 1: + inkex.errormsg("No intersection pt found") + exit() + else: + return obtPts[0] + +def getPolygonCentroid(polygon): + x = 0 + y = 0 + n = len(polygon) + + for vert in polygon: + x += vert[0] + y += vert[1] + + x = x / n + y = y / n + + return [x, y] + +def getDistBetweenFirstPts(comps1, comps2, bbox1, bbox2, nest): + pt1 = comps1[0] + pt2 = None + + if (bbox1[0] == bbox2[0] and bbox1[1] == bbox2[1]): + pt2 = getPtsByX(pt1, comps2, False) + elif (bbox1[2] == bbox2[2] and bbox1[3] == bbox2[3]): + pt2 = getPtsByY(pt1, comps2, False) + elif nest: + centroid = getPolygonCentroid(comps1) + pt2 = getIntersectionPt(centroid, pt1, comps2) + + dist = inkex.bezier.pointdistance(pt1, pt2) + + return dist + +def getCirclePath(startPt, rx): + curX = startPt[0] + curY = startPt[1] + signRX = signRY = 1 + + res = 'M ' + str(curX) + ',' + str(curY) + ' A ' + + for i in range(4): + res += str(rx) + ',' + str(rx) + ' 0 0 1 ' + + if i % 2 == 0: + signRX = -signRX + else: + signRY = -signRY + + curX += signRX * rx + curY += signRY * rx + + res += str(curX) + ',' + str(curY) + ' ' + + res += 'Z' + + return res + +def getClosedLinearizedSkeletonPath(comps1, comps2, offs, isLine): + path = [] + lengths = [] + + centroid = getPolygonCentroid(comps1) + + if isLine: + for pt2 in comps2: + pt1 = getIntersectionPt(centroid, pt2, comps1) + midPt = getMidPoint(pt1, pt2) + + if offs > 0: + dist = inkex.bezier.pointdistance(pt1, pt2) + path.append(getPtOnSeg(pt1, pt2, dist, offs * dist)) + else: + path.append(midPt) + + if len(path) > 1: + lengths.append(inkex.bezier.pointdistance(path[-2], path[-1])) + else: + pt1 = comps1[0] + pt2 = getIntersectionPt(centroid, pt1, comps2) + midPt = getMidPoint(pt1, pt2) + rx = inkex.bezier.pointdistance(centroid, midPt) + + svgPath = getCirclePath(midPt, rx) + + inkexPath = paths.Path(svgPath) + cubicPath = paths.CubicSuperPath(inkexPath) + path, lengths = linearize(modifySkeletonPath(cubicPath)) + + return (path, lengths) + +def getHorizontalLinearizedSkeletonPath(comps1, comps2, offs, isLine): + path = [] + lengths = [] + + if isLine: + for pt1 in comps1: + pt2 = getPtsByX(pt1, comps2, False) + midPt = getMidPoint(pt1, pt2) + + if offs > 0: + dist = inkex.bezier.pointdistance(pt1, pt2) + path.append(getPtOnSeg(pt1, pt2, dist, offs * dist)) + else: + path.append(midPt) + + if len(path) > 1: + lengths.append(inkex.bezier.pointdistance(path[-2], path[-1])) + else: + pt1 = comps1[0] + pt2 = comps2[0] + + firstPt = getMidPoint(pt1, pt2) + path.append(firstPt) + + lastPt = [comps1[-1][0], firstPt[1]] + path.append(lastPt) + + lengths.append(inkex.bezier.pointdistance(path[-2], path[-1])) + + return (path, lengths) + +def getVerticalLinearizedSkeletonPath(comps1, comps2, offs, isLine): + path = [] + lengths = [] + + if isLine: + for pt1 in comps1: + pt2 = getPtsByY(pt1, comps2, False) + midPt = getMidPoint(pt1, pt2) + + if offs > 0: + dist = inkex.bezier.pointdistance(pt1, pt2) + path.append(getPtOnSeg(pt1, pt2, dist, offs * dist)) + else: + path.append(midPt) + + if len(path) > 1: + lengths.append(inkex.bezier.pointdistance(path[-2], path[-1])) + else: + pt1 = comps1[0] + pt2 = comps2[0] + + firstPt = getMidPoint(pt1, pt2) + path.append(firstPt) + + lastPt = [firstPt[0], comps1[-1][1]] + path.append(lastPt) + + lengths.append(inkex.bezier.pointdistance(path[-2], path[-1])) + + return (path, lengths) + +def getColorAndOpacity(longColor): + ''' + Convert the long into a #rrggbb color value + Conversion back is A + B*256^1 + G*256^2 + R*256^3 + ''' + longColor = int(longColor) + + if longColor < 0: + longColor = longColor & 0xFFFFFFFF + + hexColor = hex(longColor) + lhc = len(hexColor) + + hexOpacity = hexColor[lhc-2 : ] + hexColor = '#' + hexColor[2:-2].rjust(6, '0') + + return (hexColor, hexOpacity) + +def setColorAndOpacity(style, color, opacity): + declarations = style.split(';') + strokeOpacityInStyle = False + newOpacity = round((int(opacity, 16) / 255.0), 8) + + for i,decl in enumerate(declarations): + parts = decl.split(':', 2) + + if len(parts) == 2: + (prop, val) = parts + prop = prop.strip().lower() + + if (prop == 'stroke' and val != color): + declarations[i] = prop + ':' + color + + if prop == 'stroke-opacity': + if val != newOpacity: + declarations[i] = prop + ':' + str(newOpacity) + + strokeOpacityInStyle = True + + if not strokeOpacityInStyle: + declarations.append('stroke-opacity' + ':' + str(newOpacity)) + + return ";".join(declarations) + +def drawfunction(nodes, width, fx): + # x-bounds of the plane + xstart = 0.0 + xend = 2 * pi + # y-bounds of the plane + ybottom = -1.0 + ytop = 1.0 + # size and location of the plane on the canvas + height = 2 + left = 15 + bottom = 15 + height + + # function specified by the user + try: + if fx != "": + f = eval('lambda x: ' + fx.strip('"')) + except SyntaxError: + return [] + + scalex = width / (xend - xstart) + xoff = left + # conver x-value to coordinate + coordx = lambda x: (x - xstart) * scalex + xoff + + scaley = height / (ytop - ybottom) + yoff = bottom + # conver y-value to coordinate + coordy = lambda y: (ybottom - y) * scaley + yoff + + # step is the distance between nodes on x + step = (xend - xstart) / (nodes - 1) + third = step / 3.0 + # step used in calculating derivatives + ds = step * 0.001 + + # initialize function and derivative for 0; + # they are carried over from one iteration to the next, to avoid extra function calculations. + x0 = xstart + y0 = f(xstart) + + # numerical derivative, using 0.001*step as the small differential + x1 = xstart + ds # Second point AFTER first point (Good for first point) + y1 = f(x1) + + dx0 = (x1 - x0) / ds + dy0 = (y1 - y0) / ds + + # path array + a = [] + # Start curve + a.append(['M', [coordx(x0), coordy(y0)]]) + + for i in range(int(nodes - 1)): + x1 = (i + 1) * step + xstart + x2 = x1 - ds # Second point BEFORE first point (Good for last point) + y1 = f(x1) + y2 = f(x2) + + # numerical derivative + dx1 = (x1 - x2) / ds + dy1 = (y1 - y2) / ds + + # create curve + a.append(['C', [coordx(x0 + (dx0 * third)), coordy(y0 + (dy0 * third)), + coordx(x1 - (dx1 * third)), coordy(y1 - (dy1 * third)), + coordx(x1), coordy(y1)]]) + + # Next segment's start is this segment's end + x0 = x1 + y0 = y1 + # Assume the function is smooth everywhere, so carry over the derivative too + dx0 = dx1 + dy0 = dy1 + + return a + +def offset(pathComp, dx, dy): + for ctl in pathComp: + for pt in ctl: + pt[0] += dx + pt[1] += dy + +def compsToSVGd(p): + f = p[0] + p = p[1:] + svgd = 'M %.9f,%.9f ' % (f[0], f[1]) + + for x in p: + svgd += 'L %.9f,%.9f ' % (x[0], x[1]) + + return svgd + +scRepCounter = 0 + +def stretchComps(skelComps, patComps, comps1, comps2, bbox1, bbox2, nest, halfHeight, ampl, offs): + res = [] + repCounter = 0 + + if nest: + + newPt = None + centroid = getPolygonCentroid(comps1) + + for pt in patComps: + skelPt = getIntersectionPt(centroid, pt, skelComps) + pt1 = getIntersectionPt(centroid, pt, comps1) + pt2 = getIntersectionPt(centroid, pt, comps2) + midPt = getMidPoint(pt1, pt2) + + dist1 = inkex.bezier.pointdistance(skelPt, pt) + dist2 = inkex.bezier.pointdistance(midPt, pt1) * ampl + dist3 = dist2 * dist1 / halfHeight + + if (skelPt[0] >= centroid[0] and skelPt[1] >= centroid[1]): + if (pt[0] >= skelPt[0] and pt[1] >= skelPt[1]): + newPt = getPtOnSeg(midPt, pt2, inkex.bezier.pointdistance(midPt, pt2), dist3 + offs) + else: + newPt = getPtOnSeg(midPt, pt1, inkex.bezier.pointdistance(midPt, pt1), dist3 - offs) + elif (skelPt[0] <= centroid[0] and skelPt[1] <= centroid[1]): + if (pt[0] <= skelPt[0] and pt[1] <= skelPt[1]): + newPt = getPtOnSeg(midPt, pt2, inkex.bezier.pointdistance(midPt, pt2), dist3 + offs) + else: + newPt = getPtOnSeg(midPt, pt1, inkex.bezier.pointdistance(midPt, pt1), dist3 - offs) + elif (skelPt[0] < centroid[0] and skelPt[1] > centroid[1]): + if (pt[0] <= skelPt[0] and pt[1] >= skelPt[1]): + newPt = getPtOnSeg(midPt, pt2, inkex.bezier.pointdistance(midPt, pt2), dist3 + offs) + else: + newPt = getPtOnSeg(midPt, pt1, inkex.bezier.pointdistance(midPt, pt1), dist3 - offs) + else: + if (pt[0] >= skelPt[0] and pt[1] <= skelPt[1]): + newPt = getPtOnSeg(midPt, pt2, inkex.bezier.pointdistance(midPt, pt2), dist3 + offs) + else: + newPt = getPtOnSeg(midPt, pt1, inkex.bezier.pointdistance(midPt, pt1), dist3 - offs) + + res.append(newPt) + + elif (bbox1.left == bbox2.left and bbox1.right == bbox2.right): + midY = skelComps[0][1] + newPt = None + + if (patComps[-1][0] != comps1[-1][0] and round(patComps[-1][0], 10) == comps1[-1][0]): + patComps[-1][0] = comps1[-1][0] + + for pt in patComps: + pt1 = getPtsByX(pt, comps1, False) + pt2 = getPtsByX(pt, comps2, False) + midPt = getMidPoint(pt1, pt2) + + dist1 = abs(pt[1] - midY) + dist2 = inkex.bezier.pointdistance(midPt, pt1) * ampl + dist3 = dist2 * dist1 / halfHeight + + if bbox1.left < bbox1.right: + if pt[1] > midY: + newPt = getPtOnSeg(midPt, pt1, inkex.bezier.pointdistance(midPt, pt1), dist3 - offs) + else: + newPt = getPtOnSeg(midPt, pt2, inkex.bezier.pointdistance(midPt, pt2), dist3 + offs) + else: + if pt[1] < midY: + newPt = getPtOnSeg(midPt, pt1, bezmisc.pointdistance(midPt, pt1), dist3 - offs) + else: + newPt = getPtOnSeg(midPt, pt2, inkex.bezier.pointdistance(midPt, pt2), dist3 + offs) + + res.append(newPt) + + elif (bbox1.top == bbox2.top and bbox1.bottom == bbox2.bottom): + + midX = skelComps[0][0] + newPt = None + + if (patComps[-1][1] != comps1[-1][1] and round(patComps[-1][1], 10) == comps1[-1][1]): + patComps[-1][1] = comps1[-1][1] + + for pt in patComps: + pt1 = getPtsByY(pt, comps1, False) + pt2 = getPtsByY(pt, comps2, False) + midPt = getMidPoint(pt1, pt2) + + dist1 = abs(pt[0] - midX) + dist2 = inkex.bezier.pointdistance(midPt, pt1) * ampl + dist3 = dist2 * dist1 / halfHeight + + if bbox1[2] < bbox1[3]: + if pt[0] < midX: + newPt = getPtOnSeg(midPt, pt1, inkex.bezier.pointdistance(midPt, pt1), dist3 - offs) + else: + newPt = getPtOnSeg(midPt, pt2, inkex.bezier.pointdistance(midPt, pt2), dist3 + offs) + else: + if pt[1] > midX: + newPt = getPtOnSeg(midPt, pt1, inkex.bezier.pointdistance(midPt, pt1), dist3 - offs) + else: + newPt = getPtOnSeg(midPt, pt2, inkex.bezier.pointdistance(midPt, pt2), dist3 + offs) + + res.append(newPt) + + return res + +def stretch(pathComp, xscale, yscale, org): + for ctl in pathComp: + for pt in ctl: + pt[0] = org[0] + (pt[0] - org[0]) * xscale + pt[1] = org[1] + (pt[1] - org[1]) * yscale + +class GuillochePattern(pathmodifier.PathModifier): + def add_arguments(self, pars): + pars.add_argument("--tab") + pars.add_argument("--patternFunction", default="sin", help="Function of the pattern") + pars.add_argument("--frequency", type=int, default=10, help="Frequency of the function") + pars.add_argument("--amplitude", type=int, default=100, help="Amplitude of the function") + pars.add_argument("--phaseOffset", type=int, default=0, help="Phase offset of the function") + pars.add_argument("--offset", type=int, default=0, help="Offset of the function") + pars.add_argument("--phaseCoverage", type=int, default=100, help="Phase coverage of the function") + pars.add_argument("--series", type=int, default=1, help="Series of the function") + pars.add_argument("--nodes", type=int, default=20, help="Count of nodes") + pars.add_argument("--remove", type=inkex.Boolean, default=False, help="If True, control objects will be removed") + pars.add_argument("--strokeColor", type=inkex.Color, default=000, help="The line's color") + pars.add_argument("--amplitude1", type=float, default=0.0, help="Amplitude of first harmonic") + pars.add_argument("--phase1", type=int, default=0, help="Phase offset of first harmonic") + pars.add_argument("--amplitude2", type=float, default=0.0, help="Amplitude of second harmonic") + pars.add_argument("--phase2", type=int, default=0, help="Phase offset of second harmonic") + pars.add_argument("--amplitude3", type=float, default=0.0, help="Amplitude of third harmonic") + pars.add_argument("--phase3", type=int, default=0, help="Phase offset of third harmonic") + pars.add_argument("--amplitude4", type=float, default=0.0, help="Amplitude of fourth harmonic") + pars.add_argument("--phase4", type=int, default=0, help="Phase offset of fourth harmonic") + pars.add_argument("--amplitude5", type=float, default=0.0, help="Amplitude of fifth harmonic") + pars.add_argument("--phase5", type=int, default=0, help="Phase offset of fifth harmonic") + + def prepareSelectionList(self): + self.envelopes = self.svg.selected + pathmodifier.PathModifier.expand_clones(self, self.envelopes, True, False) + pathmodifier.PathModifier.objects_to_paths(self, self.envelopes, True) + + def getFunction(self, func, funcOffs): + res = '' + + presetAmp1 = presetAmp2 = presetAmp3 = presetAmp4 = presetAmp5 = 0.0 + presetPhOf1 = presetPhOf2 = presetPhOf3 = presetPhOf4 = presetPhOf5 = presetOffs = 0 + funcOffs *= self.options.phaseCoverage / 100.0 + + if (func == 'sin' or func == 'cos'): + return func + '(x + (' + str((self.options.phaseOffset + funcOffs) / 100.0 * 2 * pi) + '))' + + if func == 'env1': + presetAmp1 = presetAmp3 = 0.495 + elif func == 'env2': + presetAmp1 = presetAmp3 = 0.65 + presetPhOf1 = presetPhOf3 = 25 + elif func == 'env3': + presetAmp1 = 0.75 + presetPhOf1 = 25 + presetAmp3 = 0.24 + presetPhOf3 = -25 + elif func == 'env4': + presetAmp1 = 1.105 + presetAmp3 = 0.27625 + presetPhOf3 = 50 + elif func == 'env5': + presetAmp1 = 0.37464375 + presetPhOf1 = 25 + presetAmp2 = 0.5655 + presetAmp3 = 0.37464375 + presetPhOf3 = -25 + elif func == 'env6': + presetAmp1 = 0.413725 + presetPhOf1 = 25 + presetAmp2 = 0.45695 + presetPhOf2 = 50 + presetAmp3 = 0.494 + presetPhOf3 = -25 + elif func == 'env7': + presetAmp1 = 0.624 + presetPhOf1 = 25 + presetAmp2 = 0.312 + presetAmp3 = 0.624 + presetPhOf3 = 25 + elif func == 'env8': + presetAmp1 = 0.65 + presetPhOf1 = 50 + presetAmp2 = 0.585 + presetAmp3 = 0.13 + elif func == 'env9': + presetAmp1 = 0.07605 + presetPhOf1 = 25 + presetAmp2 = 0.33345 + presetPhOf2 = 50 + presetAmp3 = 0.468 + presetPhOf3 = -25 + presetAmp4 = 0.32175 + elif func == 'env10': + presetAmp1 = 0.3575 + presetPhOf1 = -25 + presetAmp2 = 0.3575 + presetAmp3 = 0.3575 + presetPhOf3 = 25 + presetAmp4 = 0.3575 + presetPhOf4 = 50 + elif func == 'env11': + presetAmp1 = 0.65 + presetPhOf1 = 25 + presetAmp2 = 0.13 + presetPhOf2 = 50 + presetAmp3 = 0.26 + presetPhOf3 = 25 + presetAmp4 = 0.39 + elif func == 'env12': + presetAmp1 = 0.5525 + presetPhOf1 = -25 + presetAmp2 = 0.0414375 + presetPhOf2 = 50 + presetAmp3 = 0.15884375 + presetPhOf3 = 25 + presetAmp4 = 0.0966875 + presetAmp5 = 0.28315625 + presetPhOf5 = -25 + + harm1 = '(' + str(presetAmp1 + self.options.amplitude1) + ') * cos(1 * (x + (' + str((self.options.phaseOffset + funcOffs) / 100.0 * 2 * pi) + ')) - (' + str((presetPhOf1 + self.options.phase1) / 100.0 * 2 * pi) + '))' + harm2 = '(' + str(presetAmp2 + self.options.amplitude2) + ') * cos(2 * (x + (' + str((self.options.phaseOffset + funcOffs) / 100.0 * 2 * pi) + ')) - (' + str((presetPhOf2 + self.options.phase2) / 100.0 * 2 * pi) + '))' + harm3 = '(' + str(presetAmp3 + self.options.amplitude3) + ') * cos(3 * (x + (' + str((self.options.phaseOffset + funcOffs) / 100.0 * 2 * pi) + ')) - (' + str((presetPhOf3 + self.options.phase3) / 100.0 * 2 * pi) + '))' + harm4 = '(' + str(presetAmp4 + self.options.amplitude4) + ') * cos(4 * (x + (' + str((self.options.phaseOffset + funcOffs) / 100.0 * 2 * pi) + ')) - (' + str((presetPhOf4 + self.options.phase4) / 100.0 * 2 * pi) + '))' + harm5 = '(' + str(presetAmp5 + self.options.amplitude5) + ') * cos(5 * (x + (' + str((self.options.phaseOffset + funcOffs) / 100.0 * 2 * pi) + ')) - (' + str((presetPhOf5 + self.options.phase5) / 100.0 * 2 * pi) + '))' + + res = harm1 + ' + ' + harm2 + ' + ' + harm3 + ' + ' + harm4 + ' + ' + harm5 + + return res + + def lengthToTime(self, l): + ''' + Recieves an arc length l, and returns the index of the segment in self.skelComp + containing the corresponding point, together with the position of the point on this segment. + + If the deformer is closed, do computations modulo the total length. + ''' + if self.skelCompIsClosed: + l = l % sum(self.lengths) + + if l <= 0: + return 0, l / self.lengths[0] + + i = 0 + + while (i < len(self.lengths)) and (self.lengths[i] <= l): + l -= self.lengths[i] + i += 1 + + t = l / self.lengths[min(i, len(self.lengths) - 1)] + + return (i, t) + + def applyDiffeo(self, bpt, vects=()): + ''' + The kernel of this stuff: + bpt is a base point and for v in vectors, v'=v-p is a tangent vector at bpt. + ''' + s = bpt[0] - self.skelComp[0][0] + i, t = self.lengthToTime(s) + + if i == len(self.skelComp) - 1: + x, y = inkex.bezier.tpoint(self.skelComp[i - 1], self.skelComp[i], t + 1) + dx = (self.skelComp[i][0] - self.skelComp[i - 1][0]) / self.lengths[-1] + dy = (self.skelComp[i][1] - self.skelComp[i - 1][1]) / self.lengths[-1] + else: + x, y = inkex.bezier.tpoint(self.skelComp[i], self.skelComp[i + 1], t) + dx = (self.skelComp[i + 1][0] - self.skelComp[i][0]) / self.lengths[i] + dy = (self.skelComp[i + 1][1] - self.skelComp[i][1]) / self.lengths[i] + + vx = 0 + vy = bpt[1] - self.skelComp[0][1] + bpt[0] = x + vx * dx - vy * dy + bpt[1] = y + vx * dy + vy * dx + + for v in vects: + vx = v[0] - self.skelComp[0][0] - s + vy = v[1] - self.skelComp[0][1] + v[0] = x + vx * dx - vy * dy + v[1] = y + vx * dy + vy * dx + + def effect(self): + if len(self.options.ids) != 2: + inkex.errormsg(_("This extension requires two selected paths.")) + return + + self.prepareSelectionList() + envs = list(self.envelopes.values()) + + s = envs[0].get('style') + parent = envs[0].getparent() + + # Get any path transform for the contour output + xfm = envs[0].get('transform') + + fstEnvComps, fstEnvLengths, sndEnvComps, sndEnvLengths, fstEnvBbox, sndEnvBbox, isCorrect = linearizeEnvelopes(envs) + + if not isCorrect: + inkex.errormsg(_("Selected paths are not compatible.")) + return + + areNested = isSkeletonClosed(fstEnvComps) and isSkeletonClosed(sndEnvComps) + + self.skelComp = None + self.lengths = None + + countOfSkelPaths = 1 + distBetweenFirstPts = 1 + funcSeries = self.options.series + + isLine = True if self.options.patternFunction == 'line' else False + + if (isLine and self.options.offset > 0): + distBetweenFirstPts = getDistBetweenFirstPts(fstEnvComps, sndEnvComps, fstEnvBbox, sndEnvBbox, areNested) + countOfSkelPaths = int(distBetweenFirstPts / self.options.offset) + funcSeries = 1 + + for cnt in range(0, countOfSkelPaths): + curOffset = (cnt + 1) * self.options.offset / distBetweenFirstPts + + if areNested: + self.skelComp, self.lengths = getClosedLinearizedSkeletonPath(fstEnvComps, sndEnvComps, curOffset, isLine) + elif (fstEnvBbox.left == sndEnvBbox.left and fstEnvBbox.right == sndEnvBbox.right): + self.skelComp, self.lengths = getHorizontalLinearizedSkeletonPath(fstEnvComps, sndEnvComps, curOffset, isLine) + elif (fstEnvBbox.top == sndEnvBbox.top and fstEnvBbox.bottom == sndEnvBbox.bottom): + self.skelComp, self.lengths = getVerticalLinearizedSkeletonPath(fstEnvComps, sndEnvComps, curOffset, isLine) + + self.skelCompIsClosed = isSkeletonClosed(self.skelComp) + length = sum(self.lengths) + patternWidth = length / self.options.frequency + funcOffsetStep = 100 / funcSeries + + resPath = '' + + + pattern = etree.Element(inkex.addNS('path','svg')) + + self.options.strokeHexColor, self.strokeOpacity = getColorAndOpacity(self.options.strokeColor) + + if s: + pattern.set('style', setColorAndOpacity(s, self.options.strokeHexColor, self.strokeOpacity)) + + if xfm: + pattern.set('transform', xfm) + + for j in range(funcSeries): + selectedFunction = self.getFunction(self.options.patternFunction, j * funcOffsetStep) + + pattern.set('d', str(paths.Path(drawfunction(self.options.nodes, patternWidth, selectedFunction)))) + + # Add path into SVG structure + parent.append(pattern) + + patternCubicPath = paths.CubicSuperPath(paths.Path(pattern.get('d'))) + patternPath = inkex.paths.Path(patternCubicPath) + + # Compute bounding box + bbox = patternPath.bounding_box() + + width = bbox.maximum[0] - bbox.minimum[0] + height = bbox.maximum[1] - bbox.minimum[1] + + dx = width + + if dx < 0.01: + exit(_("The total length of the pattern is too small.")) + + curPath = deepcopy(patternCubicPath) + + xoffset = self.skelComp[0][0] - bbox.minimum[0] + yoffset = self.skelComp[0][1] - (bbox.maximum[1] + bbox.minimum[1]) / 2 + + patternCopies = max(1, int(round(length / dx))) + width = dx * patternCopies + + newPath = [] + + # Repeat pattern to cover whole skeleton + for subPath in curPath: + for i in range(0, patternCopies, 1): + newPath.append(deepcopy(subPath)) + offset(subPath, dx, 0) + + # Offset pattern to the first node of the skeleton + for subPath in newPath: + offset(subPath, xoffset, yoffset) + + curPath = deepcopy(newPath) + + # Stretch pattern to whole skeleton + for subPath in curPath: + stretch(subPath, length / width, 1, self.skelComp[0]) + + for subPath in curPath: + for ctlpt in subPath: + self.applyDiffeo(ctlpt[1], (ctlpt[0], ctlpt[2])) + + # Check if there is a need to close path manually + if self.skelCompIsClosed: + firstPtX = round(curPath[0][0][1][0], 8) + firstPtY = round(curPath[0][0][1][1], 8) + finalPtX = round(curPath[-1][-1][1][0], 8) + finalPtY = round(curPath[-1][-1][1][1], 8) + + if (firstPtX != finalPtX or firstPtY != finalPtY): + curPath[-1].append(curPath[0][0]) + + curPathComps, curPathLengths = linearize(modifySkeletonPath(curPath)) + + if not isLine: + curPathComps = stretchComps(self.skelComp, curPathComps, fstEnvComps, sndEnvComps, fstEnvBbox, sndEnvBbox, areNested, height / 2, self.options.amplitude / 100.0, self.options.offset) + + resPath += compsToSVGd(curPathComps) + + pattern.set('d', resPath) + + if self.options.remove: + parent.remove(envs[0]) + parent.remove(envs[1]) + +if __name__ == '__main__': + GuillochePattern().run() \ No newline at end of file diff --git a/extensions/fablabchemnitz/guilloche_creations/meta.json b/extensions/fablabchemnitz/guilloche_creations/meta.json new file mode 100644 index 0000000..862ca78 --- /dev/null +++ b/extensions/fablabchemnitz/guilloche_creations/meta.json @@ -0,0 +1,22 @@ +[ + { + "name": "Guilloche ", + "id": "fablabchemnitz.de.guilloche_creations.guilloche_contour", + "path": "guilloche_contour", + "dependent_extensions": null, + "original_name": "Guilloche ", + "original_id": "org.inkscape.effect.guilloche_", + "license": "GNU GPL v3", + "license_url": "https://inkscape.org/de/~DrWiggly/%E2%98%85guillocheextensions-for-v1x", + "comment": "fork of https://inkscape.org/de/~fluent_user/%E2%98%85guilloche-pattern-extension", + "source_url": "https://gitea.fablabchemnitz.de/FabLab_Chemnitz/mightyscape-1.X/src/branch/master/extensions/fablabchemnitz/guilloche_creations", + "fork_url": "https://inkscape.org/de/~DrWiggly/%E2%98%85guillocheextensions-for-v1x", + "documentation_url": "https://stadtfabrikanten.org/display/IFM/Guilloche+Pattern", + "inkscape_gallery_url": null, + "main_authors": [ + "inkscape.org/fluent_user", + "inkscape.org/DrWiggly", + "github.com/vmario89" + ] + } +] \ No newline at end of file diff --git a/extensions/fablabchemnitz/quick_joint/quick_joint.inx b/extensions/fablabchemnitz/quick_joint/quick_joint.inx index 4ddde8a..c2a7e46 100644 --- a/extensions/fablabchemnitz/quick_joint/quick_joint.inx +++ b/extensions/fablabchemnitz/quick_joint/quick_joint.inx @@ -3,14 +3,20 @@ Quick Joint fablabchemnitz.de.quick_joint - + 0 1 + 1.80 + 5.50 + 3.10 + 10.00 + 1 + 3.00 3.0 @@ -22,8 +28,10 @@ - false + false + false false + false path diff --git a/extensions/fablabchemnitz/quick_joint/quick_joint.py b/extensions/fablabchemnitz/quick_joint/quick_joint.py index 9c02aa6..128c2e7 100644 --- a/extensions/fablabchemnitz/quick_joint/quick_joint.py +++ b/extensions/fablabchemnitz/quick_joint/quick_joint.py @@ -22,11 +22,12 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ''' -import inkex, cmath -from inkex.paths import Path, ZoneClose, Move +import inkex, cmath, math +from inkex import Circle +from inkex.paths import Path, ZoneClose, Move, Line, line, Curve from lxml import etree -debugEn = False +debugEn = False def debugMsg(input): if debugEn: inkex.utils.debug(input) @@ -40,21 +41,46 @@ def linesNumber(path): debugMsg('Number of lines : ' + str(retval)) return retval -def to_complex(point): - c = None - try: - c = complex(point.x, point.y) - except Exception as e: - pass - if c is not None: - return c +class QuickJointPath (Path): + def Move(self, point): + '''Append an absolute move instruction to the path, to the specified complex point''' + debugMsg("- move: " + str(point)) + self.append(Move(point.real, point.imag)) + + def Line(self, point): + '''Add an absolute line instruction to the path, to the specified complex point''' + debugMsg("- line: " + str(point)) + self.append(Line(point.real, point.imag)) + + def close(self): + '''Add a Close Path instriction to the path''' + self.append(ZoneClose()) + + def line(self, vector): + '''Append a relative line command to the path, using the specified vector''' + self.append(line(vector.real, vector.imag)) + + def get_line(self, n): + '''Return the end points of the nth line in the path as complex numbers, as well as whether that line closes the path.''' + + if isinstance(self[n], (Move, Line, ZoneClose)): + start = complex(self[n].x, self[n].y) + elif isinstance(self[n], Curve): + start = complex(self[n].x4, self[n].y4) + # If the next point in the path closes the path, go back to the start. + end = None + closePath = False + if isinstance(self[n+1], ZoneClose): + end = complex(self[0].x, self[0].y) + closePath = True else: - inkex.utils.debug("The selection seems not be be a usable polypath. QuickJoint does not operate on curves. Try to flatten bezier curves or splitting up the path.") - exit(1) - + if isinstance(self[n+1], (Move, Line, ZoneClose)): + end = complex(self[n+1].x, self[n+1].y) + elif isinstance(self[n+1], Curve): + end = complex(self[n+1].x4, self[n+1].y4) + return (start, end, closePath) class QuickJoint(inkex.EffectExtension): - def add_arguments(self, pars): pars.add_argument('-s', '--side', type=int, default=0, help='Object face to tabify') pars.add_argument('-n', '--numtabs', type=int, default=1, help='Number of tabs to add') @@ -62,19 +88,17 @@ class QuickJoint(inkex.EffectExtension): pars.add_argument('-t', '--thickness', type=float, default=3.0, help='Material thickness') pars.add_argument('-k', '--kerf', type=float, default=0.14, help='Measured kerf of cutter') pars.add_argument('-u', '--units', default='mm', help='Measurement units') - pars.add_argument('-e', '--edgefeatures', type=inkex.Boolean, default=False, help='Allow tabs to go right to edges') pars.add_argument('-f', '--flipside', type=inkex.Boolean, default=False, help='Flip side of lines that tabs are drawn onto') pars.add_argument('-a', '--activetab', default='', help='Tab or slot menus') + pars.add_argument('-S', '--featureStart', type=inkex.Boolean, default=False, help='Tab/slot instead of space on the start edge') + pars.add_argument('-E', '--featureEnd', type=inkex.Boolean, default=False, help='Tab/slot instead of space on the end edge') + pars.add_argument('-T', '--tSlotEnable', type=inkex.Boolean, default=False, help='Enable to use t-slot definitions') + pars.add_argument('-D', '--tSlotHoleDiameter', type=float, default=3.00, help='Diameter of t slot hole') + pars.add_argument('-H', '--tSlotNutHeight', type=float, default=1.80, help='Height of t slot nut') + pars.add_argument('-W', '--tSlotNutWidth', type=float, default=5.50, help='Width of t slot nut') + pars.add_argument('-N', '--tSlotScrewWidth', type=float, default=3.10, help='Scew width of t slot') + pars.add_argument('-d', '--tSlotScrewDepth', type=float, default=10.00, help='Screw depth of t slot') - def to_complex(self, command, line): - debugMsg('To complex: ' + command + ' ' + str(line)) - - return complex(line[0], line[1]) - - def get_length(self, line): - polR, polPhi = cmath.polar(line) - return polR - def draw_parallel(self, start, guideLine, stepDistance): polR, polPhi = cmath.polar(guideLine) polR = stepDistance @@ -91,192 +115,172 @@ class QuickJoint(inkex.EffectExtension): debugMsg(polPhi) debugMsg(cmath.rect(polR, polPhi)) return (cmath.rect(polR, polPhi) + start) + - def draw_box(self, start, guideLine, xDistance, yDistance, kerf): - polR, polPhi = cmath.polar(guideLine) + def draw_box(self, start, lengthVector, height, kerf): + + # Kerf is a provided as a positive kerf width. Although tabs + # need to be made larger by the width of the kerf, slots need + # to be made narrower instead, since the cut widens them. + + # Calculate kerfed height and length vectors + heightEdge = self.draw_perpendicular(0, lengthVector, height - kerf, self.flipside) + lengthEdge = self.draw_parallel(lengthVector, lengthVector, -kerf) - #Kerf expansion - if self.flipside: - start -= cmath.rect(kerf / 2, polPhi) - start -= cmath.rect(kerf / 2, polPhi + (cmath.pi / 2)) - else: - start -= cmath.rect(kerf / 2, polPhi) - start -= cmath.rect(kerf / 2, polPhi - (cmath.pi / 2)) - - lines = [] - lines.append(['M', [start.real, start.imag]]) + debugMsg("draw_box; lengthEdge: " + str(lengthEdge) + ", heightEdge: " + str(heightEdge)) - #Horizontal - polR = xDistance - move = cmath.rect(polR + kerf, polPhi) + start - lines.append(['L', [move.real, move.imag]]) - start = move + cursor = self.draw_parallel(start, lengthEdge, kerf/2) + cursor = self.draw_parallel(cursor, heightEdge, kerf/2) - #Vertical - polR = yDistance - if self.flipside: - polPhi += (cmath.pi / 2) - else: - polPhi -= (cmath.pi / 2) - move = cmath.rect(polR + kerf, polPhi) + start - lines.append(['L', [move.real, move.imag]]) - start = move + path = QuickJointPath() + path.Move(cursor) - #Horizontal - polR = xDistance - if self.flipside: - polPhi += (cmath.pi / 2) - else: - polPhi -= (cmath.pi / 2) - move = cmath.rect(polR + kerf, polPhi) + start - lines.append(['L', [move.real, move.imag]]) - start = move + cursor += lengthEdge + path.Line(cursor) - lines.append(['Z', []]) + cursor += heightEdge + path.Line(cursor) - return lines - + cursor -= lengthEdge + path.Line(cursor) + + cursor -= heightEdge + path.Line(cursor) + + path.close() + + return path + + def draw_tabs(self, path, line): - #Male tab creation - start = to_complex(path[line]) - - closePath = False - #Line is between last and first (closed) nodes - end = None - if isinstance(path[line+1], ZoneClose): - end = to_complex(path[0]) - closePath = True - else: - end = to_complex(path[line+1]) - - debugMsg('start') - debugMsg(start) - debugMsg('end') - debugMsg(end) - - debugMsg('5-') - - if self.edgefeatures: - segCount = (self.numtabs * 2) - 1 - drawValley = False - else: - segCount = (self.numtabs * 2) - drawValley = False - - distance = end - start - debugMsg('distance ' + str(distance)) - debugMsg('segCount ' + str(segCount)) + cursor, segCount, segment, closePath = self.get_segments(path, line, self.numtabs) - try: - if self.edgefeatures: - segLength = self.get_length(distance) / segCount - else: - segLength = self.get_length(distance) / (segCount + 1) - except: - debugMsg('in except') - segLength = self.get_length(distance) - - debugMsg('segLength - ' + str(segLength)) - newLines = [] - - # when handling firlt line need to set M back + # Calculate kerf-compensated vectors for the parallel portion of tab and space + tabLine = self.draw_parallel(segment, segment, self.kerf) + spaceLine = self.draw_parallel(segment, segment, -self.kerf) + endspaceLine = segment + + # Calculate vectors for tabOut and tabIn: perpendicular away and towards baseline + tabOut = self.draw_perpendicular(0, segment, self.thickness, not self.flipside) + tabIn = self.draw_perpendicular(0, segment, self.thickness, self.flipside) + + debugMsg("draw_tabs; tabLine=" + str(tabLine) + " spaceLine=" + str(spaceLine) + " segment=" + str(segment)) + + drawTab = self.featureStart + newLines = QuickJointPath() + + # First line is a move or line to our start point if isinstance(path[line], Move): - newLines.append(['M', [start.real, start.imag]]) - - if self.edgefeatures == False: - newLines.append(['L', [start.real, start.imag]]) - start = self.draw_parallel(start, distance, segLength) - newLines.append(['L', [start.real, start.imag]]) - debugMsg('Initial - ' + str(start)) + newLines.Move(cursor) + else: + newLines.Line(cursor) - for i in range(segCount): - if drawValley == True: - #Vertical - start = self.draw_perpendicular(start, distance, self.thickness, self.flipside) - newLines.append(['L', [start.real, start.imag]]) - debugMsg('ValleyV - ' + str(start)) - drawValley = False - #Horizontal - start = self.draw_parallel(start, distance, segLength) - newLines.append(['L', [start.real, start.imag]]) - debugMsg('ValleyH - ' + str(start)) + debugMsg("i = " + str(i)) + if drawTab == True: + debugMsg("- tab") + if self.options.tSlotEnable is False: + newLines.line(tabOut) + newLines.line(tabLine) + newLines.line(tabIn) + else: #TODO + #self.options.tSlotNutHeight + #self.options.tSlotNutWidth + #self.options.tSlotScrewWidth + #self.options.tSlotScrewDepth + newLines.line(tabOut) + newLines.line(tabLine) + newLines.line(tabIn) else: - #Vertical - start = self.draw_perpendicular(start, distance, self.thickness, not self.flipside) - newLines.append(['L', [start.real, start.imag]]) - debugMsg('HillV - ' + str(start)) - drawValley = True - #Horizontal - start = self.draw_parallel(start, distance, segLength) - newLines.append(['L', [start.real, start.imag]]) - debugMsg('HillH - ' + str(start)) - - if self.edgefeatures == True: - start = self.draw_perpendicular(start, distance, self.thickness, self.flipside) - newLines.append(['L', [start.real, start.imag]]) - debugMsg('Final - ' + str(start)) - + if i == 0 or i == segCount - 1: + debugMsg("- endspace") + newLines.line(endspaceLine) + else: + debugMsg("- space") + newLines.line(spaceLine) + drawTab = not drawTab + if closePath: - newLines.append(['Z', []]) + newLines.close return newLines - + def add_new_path_from_lines(self, lines, line_style): + slot_id = self.svg.get_unique_id('slot') + g = etree.SubElement(self.svg.get_current_layer(), 'g', {'id':slot_id}) + + line_atts = { 'style':line_style, 'id':slot_id+'-inner-close-tab', 'd':str(Path(lines)) } + return etree.SubElement(g, inkex.addNS('path','svg'), line_atts ) + + def get_segments(self, path, line, num): + + # Calculate number of segments, including all features and spaces + segCount = num * 2 - 1 + if not self.featureStart: segCount = segCount + 1 + if not self.featureEnd: segCount = segCount + 1 + + start, end, closePath = QuickJointPath(path).get_line(line) + + # Calculate the length of each feature prior to kerf compensation. + # Here we divide the specified edge into equal portions, one for each feature or space. + + # Because the specified edge has no kerf compensation, the + # actual length we end up with will be smaller by a kerf. We + # need to use that distance to calculate our segment vector. + edge = end - start + edge = self.draw_parallel(edge, edge, -self.kerf) + segVector = edge / segCount + + debugMsg("get_segments; start=" + str(start) + " end=" + str(end) + " edge=" + str(edge) + " segCount=" + str(segCount) + " segVector=" + str(segVector)) + + return (start, segCount, segVector, closePath) + def draw_slots(self, path): - #Female slot creation + # Female slot creation - start = to_complex(path[0]) - end = to_complex(path[1]) + cursor, segCount, segVector, closePath = self.get_segments(path, 0, self.numslots) - if self.edgefeatures: - segCount = (self.numslots * 2) - 1 - else: - segCount = (self.numslots * 2) + # I'm having a really hard time wording why this is necessary, but it is. + # get_segments returns a vector based on a narrower edge; adjust that edge to fit within the edge we were given. + cursor = self.draw_parallel(cursor, segVector, self.kerf/2) - distance = end - start - debugMsg('distance ' + str(distance)) - debugMsg('segCount ' + str(segCount)) - - try: - if self.edgefeatures: - segLength = self.get_length(distance) / segCount - else: - segLength = self.get_length(distance) / (segCount + 1) - except: - segLength = self.get_length(distance) - - debugMsg('segLength - ' + str(segLength)) newLines = [] - line_style = str(inkex.Style({ 'stroke': '#000000', 'fill': 'none', 'stroke-width': str(self.svg.unittouu('0.1mm')) })) - + drawSlot = self.featureStart + for i in range(segCount): - if (self.edgefeatures and (i % 2) == 0) or (not self.edgefeatures and (i % 2)): - newLines = self.draw_box(start, distance, segLength, self.thickness, self.kerf) - debugMsg(newLines) + if drawSlot: + slot = self.add_new_path_from_lines(self.draw_box(cursor, segVector, self.thickness, self.kerf), line_style) + if self.options.tSlotEnable is True: + cx, cy = slot.bounding_box().center + circle = slot.getparent().add(inkex.Circle(id=self.svg.get_unique_id('tSlotHole'))) + circle.set('transform', "rotate({:0.6f} {:0.6f} {:0.6f})".format(0, cx, cy)) + circle.set('r', "{:0.6f}".format(self.tSlotHoleDiameter / 2 - self.kerf)) + circle.set('cx', "{:0.6f}".format(cx)) + circle.set('cy', "{:0.6f}".format(cy)) + circle.style = line_style - slot_id = self.svg.get_unique_id('slot') - g = etree.SubElement(self.svg.get_current_layer(), 'g', {'id':slot_id}) - - line_atts = { 'style':line_style, 'id':slot_id+'-inner-close-tab', 'd':str(Path(newLines)) } - etree.SubElement(g, inkex.addNS('path','svg'), line_atts ) - - #Find next point - polR, polPhi = cmath.polar(distance) - polR = segLength - start = cmath.rect(polR, polPhi) + start - + cursor = cursor + segVector + drawSlot = not drawSlot + debugMsg("i: " + str(i) + ", cursor: " + str(cursor)) + # (We don't modify the path so we don't need to close it) + def effect(self): self.side = self.options.side self.numtabs = self.options.numtabs self.numslots = self.options.numslots self.thickness = self.svg.unittouu(str(self.options.thickness) + self.options.units) + self.tSlotNutHeight = self.svg.unittouu(str(self.options.tSlotNutHeight) + self.options.units) + self.tSlotNutWidth = self.svg.unittouu(str(self.options.tSlotNutWidth) + self.options.units) + self.tSlotScrewWidth = self.svg.unittouu(str(self.options.tSlotScrewWidth) + self.options.units) + self.tSlotScrewDepth = self.svg.unittouu(str(self.options.tSlotScrewDepth) + self.options.units) + self.tSlotHoleDiameter = self.svg.unittouu(str(self.options.tSlotHoleDiameter) + self.options.units) self.kerf = self.svg.unittouu(str(self.options.kerf) + self.options.units) self.units = self.options.units - self.edgefeatures = self.options.edgefeatures + self.featureStart = self.options.featureStart + self.featureEnd = self.options.featureEnd self.flipside = self.options.flipside self.activetab = self.options.activetab - + for id, node in self.svg.selected.items(): debugMsg(node) debugMsg('1') @@ -298,13 +302,13 @@ class QuickJoint(inkex.EffectExtension): debugMsg(newPath) debugMsg('4') debugMsg( p[lineNum + 1:]) - finalPath = p[:lineNum] + newPath + p[lineNum + 1:] - + finalPath = p[:lineNum + 1] + newPath + p[lineNum + 2:] + debugMsg(finalPath) node.set('d',str(Path(finalPath))) elif self.activetab == 'slotpage': newPath = self.draw_slots(p) - + if __name__ == '__main__': QuickJoint().run() \ No newline at end of file diff --git a/extensions/fablabchemnitz/visicut/meta.json b/extensions/fablabchemnitz/visicut/meta.json new file mode 100644 index 0000000..f1b1134 --- /dev/null +++ b/extensions/fablabchemnitz/visicut/meta.json @@ -0,0 +1,21 @@ +[ + { + "name": "", + "id": "fablabchemnitz.de.open_in_visicut_", + "path": "visicut", + "dependent_extensions": null, + "original_name": "", + "original_id": "visicut.", + "license": "GNU LGPL v3", + "license_url": "https://github.com/t-oster/VisiCut/blob/master/LICENSE", + "comment": "", + "source_url": "https://gitea.fablabchemnitz.de/FabLab_Chemnitz/mightyscape-1.X/src/branch/master/extensions/fablabchemnitz/visicut", + "fork_url": "https://github.com/t-oster/VisiCut/tree/master/tools/inkscape_extension", + "documentation_url": "https://stadtfabrikanten.org/display/IFM/Open+in+VisiCut", + "inkscape_gallery_url": null, + "main_authors": [ + "github.com/t-oster", + "github.com/vmario89" + ] + } +] \ No newline at end of file diff --git a/extensions/fablabchemnitz/visicut/open_in_visicut.py b/extensions/fablabchemnitz/visicut/open_in_visicut.py new file mode 100644 index 0000000..1e7f369 --- /dev/null +++ b/extensions/fablabchemnitz/visicut/open_in_visicut.py @@ -0,0 +1,357 @@ +#!/usr/bin/env python3 +''' +This extension strips everything which is not selected from +the current svg, saves it and +calls VisiCut on it. + +Copyright (C) 2012 Thomas Oster, thomas.oster@rwth-aachen.de +Copyright (C) 2014-2022 Max Gaukler, development@maxgaukler.de + +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 +the Free Software Foundation; either version 2 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +''' + +import sys +import os +import re +import subprocess +from subprocess import Popen +import traceback +import tempfile +import unicodedata +import codecs +import random +import string +import socket +import inkex + +try: + from os import fsencode +except ImportError: + fsencode = lambda x: x.encode(sys.getfilesystemencoding()) + +def get_single_instance_port(): + """ + get the single instance port used by VisiCut. + CAUTION: This code must behave exactly the same as ApplicationInstanceManager.getSingleInstancePort() in Visicut. + """ + port = 6543 + if (sys.platform == "linux"): + d = os.environ.get("DISPLAY") + if (d != None): + d = d.split(':')[1].split('.')[0] + port += int(d) + + if (sys.platform == "win32"): + d = os.environ.get("SESSIONNAME") + if d == None: + # no Terminal Services installed + pass + else: + # get session ID + try: + CREATE_NO_WINDOW = 0x08000000 + id = subprocess.check_output("powershell -Command (get-process -pid $pid).sessionid",creationflags=CREATE_NO_WINDOW) + id = int(id.strip()) + port += 2 + id + except: + inkex.utils.debug("Warning: Cannot determine session ID. please report this.\n") + return port + +# if Visicut or Inkscape cannot be found, change these lines here to VISICUTDIR="C:/Programs/Visicut" or wherever you installed it. +# please use forward slashes (/), not backslashes (\). +# +# example: +# VISICUTDIR="C:/Program Files (x86)/VisiCut/" +# INKSCAPEDIR="C:/Program Files (x86)/Inkscape/" +VISICUTDIR = "" +INKSCAPEDIR = "" + +# whether to add (true) or replace (false) current visicut's content +IMPORT = True +# Store the IDs of selected Elements +elements = [] + +arguments=[] + +for arg in sys.argv[1:]: + if arg[0] == "-": + if len(arg) >= 5 and arg[0:5] == "--id=": + elements += [arg[5:]] + elif len(arg) >= 13 and arg[0:13] == "--visicutbin=": + # unused + pass + # VISICUTBIN=arg[13:] + elif len(arg) >= 9 and arg[0:9] == "--import=": + IMPORT = "true" in arg[9:] + else: + arguments += [arg] + else: + filename = arg + +if IMPORT: + # do not replace old + arguments += ["--add"] + +# find executable in the PATH +def which(program, extraPaths=[]): + pathlist = extraPaths + os.environ["PATH"].split(os.pathsep) + [""] + if "nt" in os.name: # Windows + if not program.lower().endswith(".exe"): + program += ".exe" + programfiles = os.environ.get("ProgramFiles", "C:\\Program Files\\") + programfiles86 = os.environ.get("ProgramFiles(x86)", "C:\\Program Files (x86)\\") + # also look in %ProgramFiles%/yourProgram/yourProgram.exe + pathlist += [programfiles + "\\" + program + "\\", programfiles86 + "\\" + program + "\\"] + + def is_exe(fpath): + return os.path.isfile(fpath) and (os.access(fpath, os.X_OK) or fpath.endswith(".exe")) + for path in pathlist: + exe_file = os.path.join(path, program) + if is_exe(exe_file): + return exe_file + raise Exception("Cannot find executable {0} in PATH={1}.\n\n" + "Please report this bug on https://github.com/t-oster/VisiCut/issues\n\n" + "For a quick fix: Set VISICUTDIR and INKSCAPEDIR in " + "{2}" + .format(repr(program), repr(pathlist), os.path.realpath(__file__))) + + +def inkscape_version(): + """Return Inkscape version number as float, e.g. version "0.92.4" --> return: float 0.92""" + version = subprocess.check_output([INKSCAPEBIN, "--version"], stderr=subprocess.DEVNULL).decode('ASCII', 'ignore') + assert version.startswith("Inkscape ") + match = re.match("Inkscape ([0-9]+\.[0-9]+).*", version) + assert match is not None + version_float = float(match.group(1)) + return version_float + + + +# Strip SVG to only contain selected elements, convert objects to paths, unlink clones +# Inkscape version: takes care of special cases where the selected objects depend on non-selected ones. +# Examples are linked clones, flowtext limited to a shape and linked flowtext boxes (overflow into the next box). +# +# Inkscape is called with certain "verbs" (gui actions) to do the required cleanup +# The idea is similar to http://bazaar.launchpad.net/~nikitakit/inkscape/svg2sif/view/head:/share/extensions/synfig_prepare.py#L181 , but more primitive - there is no need for more complicated preprocessing here +def stripSVG_inkscape(src, dest, elements): + version = inkscape_version() + + # create temporary file for opening with inkscape. + # delete this file later so that it will disappear from the "recently opened" list. + tmpfile = tempfile.NamedTemporaryFile(delete=False, prefix='temp-visicut-', suffix='.svg') + tmpfile.close() + tmpfile = tmpfile.name + import shutil + shutil.copyfile(src, tmpfile) + + + if version < 1: + # inkscape 0.92 long-term-support release. Will be in Linux distributions until 2025 or so + # Selection commands: select items, invert selection, delete + selection = [] + for el in elements: + selection += ["--select=" + el] + + if len(elements) > 0: + # selection += ["--verb=FitCanvasToSelection"] # TODO add a user configuration option whether to keep the page size (and by this the position relative to the page) + selection += ["--verb=EditInvertInAllLayers", "--verb=EditDelete"] + + + hidegui = ["--without-gui"] + + # currently this only works with gui because of a bug in inkscape: https://bugs.launchpad.net/inkscape/+bug/843260 + hidegui = [] + + command = [INKSCAPEBIN] + hidegui + [tmpfile, "--verb=UnlockAllInAllLayers", "--verb=UnhideAllInAllLayers"] + selection + ["--verb=EditSelectAllInAllLayers", "--verb=EditUnlinkClone", "--verb=ObjectToPath", "--verb=FileSave", "--verb=FileQuit"] + elif version < 1.2: + # Inkscape 1.0 (released ca 2020) or 1.1 + # inkscape --select=... --verbs=... + # (see inkscape --help, inkscape --verb-list) + command = [INKSCAPEBIN, tmpfile, "--batch-process"] + verbs = ["ObjectToPath", "UnlockAllInAllLayers"] + if elements: # something is selected + # --select=object1,object2,object3,... + command += ["--select=" + ",".join(elements)] + else: + verbs += ["EditSelectAllInAllLayers"] + verbs += ["UnhideAllInAllLayers", "EditInvertInAllLayers", "EditDelete", "EditSelectAllInAllLayers", "EditUnlinkClone", "ObjectToPath", "FileSave"] + # --verb=action1;action2;... + command += ["--verb=" + ";".join(verbs)] + + + DEBUG = False + if DEBUG: + # Inkscape sometimes silently ignores wrong verbs, so we need to double-check that everything's right + for verb in verbs: + verb_list = [line.split(":")[0] for line in subprocess.check_output([INKSCAPEBIN, "--verb-list"], stderr=subprocess.DEVNULL).split("\n")] + if verb not in verb_list: + inkex.utils.debug("Inkscape does not have the verb '{}'. Please report this as a VisiCut bug.".format(verb)) + else: + # Inkscape 1.2 (released 2022) + # inkscape --export-overwrite --actions=action1;action2... + # (see inkscape --help, inkscape --action-list) + # (for debugging, you can look at the intermediate state by running inkscape --with-gui --actions=... my_filename.svg) + # Note that it is (almost?) impossible to find a sequence that works in all cases. + # Cases to consider: + # - selecting whole groups + # - selecting objects within a group + # - selecting across groups/layers (e.g., enter group, select something, then Shift-click to select things from other layers) + # Difficulties with Inkscape: + # - "invert selection" does not behave as expected in all these cases, + # for example if a group is selected then inverting can select the elements within. + # - Inkscape has a wonderful --export-id commandline switch, but it only works correctly with one ID + + # Solution: + actions = ["unlock-all"] + if elements: + # something was selected when calling the plugin. + # -> Recreate that selection + # - select objects + actions += ["select-by-id:" + ",".join(elements)] + else: + # - select all + actions += ["select-all:all"] + # - convert to path + actions += ["clone-unlink", "object-to-path"] + if elements: + # ensure that only the selection is exported: + # - create group of selection + actions += ["selection-group"] + # - set group ID to a known value. Use a pseudo-random value to avoid collisions + target_group_id = "TARGET-GROUP-" + "".join(random.sample(string.ascii_lowercase, 20)) + actions += ["object-set-attribute:id," + target_group_id] + # - set export options: use only the target group ID, nothing else + actions += ["export-id-only:true", "export-id:" + target_group_id] + # - do export (keep position on page) + actions += ["export-area-page"] + + command = [INKSCAPEBIN, tmpfile, "--export-overwrite", "--actions=" + ";".join(actions)] + + try: + #sys.stderr.write(" ".join(command)) + # run inkscape, buffer output + subprocess.check_output(command, stderr=subprocess.STDOUT, universal_newlines=True) + except subprocess.CalledProcessError as e: + inkex.utils.debug("Error: cleaning the document with inkscape failed. Something might still be shown in visicut, but it could be incorrect.\nInkscape's output was:\n" + e.output) + + # move output to the intended destination filename + os.rename(tmpfile, dest) + + +""" +Get document name (original filename) from Inkscape SVG + +Inkscape saves the file to a random temporary name. +However, the original filename is stored inside the SVG. +""" + + +def get_original_filename(filename): + docname = None + + # parse SVG for docname tag + with codecs.open(filename, "r", encoding='utf-8') as f: + for line in f: + if 'sodipodi:docname="' in line: + matches = re.search('sodipodi:docname="(.*).svg"', line) + if not matches: + break + try: + docname = matches.group(1) + except IndexError: + # something is wrong with this line + break + # unescape XML string + + docname = docname.replace('<', '<') + docname = docname.replace('>', '>') + docname = docname.replace('"', '"') + docname = docname.replace('&', '&') + + # normalize accented characters (äöü -> aou) + docname = unicodedata.normalize('NFKD', docname).encode('ASCII', 'ignore').decode('ASCII') + break + + if not docname: + # failed to read filename from SVG, return original one + docname = os.path.basename(filename) + if str.endswith(docname, ".svg"): + docname = docname[:-4] + if str.startswith(docname, "ink_ext_"): + # inkscape temporary file, the name is useless + docname = "new" + + # sanitize the filename: + # filter out special characters (@/\& ...) + docname = "".join(x for x in docname if (x.isalnum() or x in "._- ")) + docname = docname + ".svg" + return docname + +# find executable paths +import platform +if platform.system() == 'Darwin': + VISICUTBIN = which("VisiCut.MacOS", [VISICUTDIR]) +elif "nt" in os.name: # Windows + VISICUTBIN = which("VisiCut.exe", [VISICUTDIR]) +else: + VISICUTBIN = which("VisiCut.Linux", [VISICUTDIR, "/usr/share/visicut"]) +INKSCAPEBIN = which("inkscape", [INKSCAPEDIR]) + +tmpdir = tempfile.mkdtemp(prefix='temp-visicut-') +dest_filename = os.path.join(tmpdir, get_original_filename(filename)) + +# remove all non-selected elements and convert inkscape-specific elements (text-to-path etc.) +stripSVG_inkscape(src=filename, dest=dest_filename, elements=elements) + +# Try to connect to running VisiCut instance +# Note: this step may be omitted, as VisiCut will do the same. +# However, doing it here saves 2-3 seconds of waiting time on Windows. +s = socket.socket() +try: + s.connect(("localhost", get_single_instance_port())) +except socket.error: + pass +else: + if IMPORT: + s.send(b"@" + fsencode(dest_filename) + b"\n") + else: + s.send(fsencode(dest_filename) + b"\n") + sys.exit(0) +finally: + s.close() + +# Try to start own VisiCut instance +try: + creationflags = 0 + close_fds = False + if os.name == "nt": + DETACHED_PROCESS = 8 # start as "daemon" + creationflags = DETACHED_PROCESS + close_fds = True + else: + try: + import daemonize + daemonize.createDaemon() + except: + inkex.utils.debug("Could not daemonize. Sorry, but Inkscape was blocked until VisiCut is closed") + cmd = [VISICUTBIN] + arguments + [dest_filename] + with subprocess.Popen(cmd, creationflags=creationflags, close_fds=close_fds, stderr=subprocess.DEVNULL) as p: + p.communicate() +except: + inkex.utils.debug("Can not start VisiCut (" + str(sys.exc_info()[0]) + "). Please start manually or change the VISICUTDIR variable in the Inkscape-Extension script\n") + + + +# TODO (complicated, probably WONTFIX): cleanup temporary directories -- this is really difficult because we need to make sure that visicut no longer needs the file, even for reloading! diff --git a/extensions/fablabchemnitz/visicut/open_in_visicut_add.inx b/extensions/fablabchemnitz/visicut/open_in_visicut_add.inx new file mode 100644 index 0000000..4800ea7 --- /dev/null +++ b/extensions/fablabchemnitz/visicut/open_in_visicut_add.inx @@ -0,0 +1,17 @@ + + + Open In VisiCut (Add) + fablabchemnitz.de.open_in_visicut_add + true + + path + + + + + + + + \ No newline at end of file diff --git a/extensions/fablabchemnitz/visicut/open_in_visicut_replace.inx b/extensions/fablabchemnitz/visicut/open_in_visicut_replace.inx new file mode 100644 index 0000000..afa2783 --- /dev/null +++ b/extensions/fablabchemnitz/visicut/open_in_visicut_replace.inx @@ -0,0 +1,17 @@ + + + Open In VisiCut (Replace) + fablabchemnitz.de.open_in_visicut_replace + false + + path + + + + + + + + \ No newline at end of file