diff --git a/extensions/fablabchemnitz/cleanup_styles/cleanup_styles.inx b/extensions/fablabchemnitz/cleanup_styles/cleanup_styles.inx new file mode 100644 index 0000000..2ce0bac --- /dev/null +++ b/extensions/fablabchemnitz/cleanup_styles/cleanup_styles.inx @@ -0,0 +1,72 @@ + + + Cleanup Styles + fablabchemnitz.de.cleanup_styles + + + + + + + + false + 0.100 + + + + + + + + false + 100.0 + true + true + true + true + true + false + false + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + ../000_about_fablabchemnitz.svg + + + + all + + + + + + + + \ No newline at end of file diff --git a/extensions/fablabchemnitz/cleanup_styles/cleanup_styles.py b/extensions/fablabchemnitz/cleanup_styles/cleanup_styles.py new file mode 100644 index 0000000..bc68179 --- /dev/null +++ b/extensions/fablabchemnitz/cleanup_styles/cleanup_styles.py @@ -0,0 +1,258 @@ +#!/usr/bin/env python3 +''' +Copyright (C) 2013 Matthew Dockrey (gfish @ cyphertext.net) + +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 + +Based on +- coloreffect.py by Jos Hirth and Aaron C. Spike +- cleanup.py (https://github.com/attoparsec/inkscape-extensions) by attoparsec + +Author: Mario Voigt / FabLab Chemnitz +Mail: mario.voigt@stadtfabrikanten.org +Last Patch: 12.04.2021 +License: GNU GPL v3 + +Notes: + - This extension does not check if attributes contain duplicates properties like "opacity:1;fill:#393834;fill-opacity:1;opacity:1;fill:#393834;fill-opacity:1". We assume the SVG syntax is correct +''' + +import inkex +import re +import numpy as np + +class CleanupStyles(inkex.EffectExtension): + + groups = [] + roundUpColors = [] + roundUpColors = [ + [ 0, 0, 0], #black | eri1 + [ 0, 0, 255], #blue | eri2 + [ 0, 255, 0], #green | eri3 + [255, 0, 0], #red | eri4 + [255, 0, 255], #magenta | eri5 + [ 0, 255, 255], #cyan | eri6 + [255, 255, 0], #yellow | eri7 + #half tones + [128, 0, 255], #violet | eri8 + [0 , 128, 255], #light blue | eri9 + [255, 128, 0], #orange | eri10 + [255, 0, 128], #pink | eri11 + [128, 255, 0], #light green | eri12 + [0 , 255, 128], #mint | eri13 + ] + + def add_arguments(self, pars): + pars.add_argument("--tab") + pars.add_argument("--mode", default="Lines", help="Join paths with lines or polygons") + pars.add_argument("--dedicated_style_attributes", default="ignore", help="Handling of dedicated style attributes") + pars.add_argument("--stroke_width_override", type=inkex.Boolean, default=False, help="Override stroke width") + pars.add_argument("--stroke_width", type=float, default=0.100, help="Stroke width") + pars.add_argument("--stroke_width_units", default="mm", help="Stroke width unit") + pars.add_argument("--stroke_opacity_override", type=inkex.Boolean, default=False, help="Override stroke opacity") + pars.add_argument("--stroke_opacity", type=float, default="100.0", help="Stroke opacity (%)") + pars.add_argument("--reset_opacity", type=inkex.Boolean, default=True, help="Reset stroke style attribute 'opacity'. Do not mix up with 'fill-opacity' and 'stroke-opacity'") + pars.add_argument("--reset_stroke_attributes", type=inkex.Boolean, default=True, help="Remove 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linejoin', 'stroke-linecap', 'stroke-miterlimit' from style attribute") + pars.add_argument("--reset_fill_attributes", type=inkex.Boolean, default=True, help="Sets 'fill:none;fill-opacity:1;' to style attribute") + pars.add_argument("--apply_hairlines", type=inkex.Boolean, default=True, help="Adds 'vector-effect:non-scaling-stroke;' and '-inkscape-stroke:hairline;' Hint: stroke-width is kept in background. All hairlines still have a valued width.") + pars.add_argument("--apply_black_strokes", type=inkex.Boolean, default=True, help="Adds 'stroke:#000000;' to style attribute") + pars.add_argument("--remove_group_styles", type=inkex.Boolean, default=False, help="Remove styles from groups") + pars.add_argument("--harmonize_colors", type=inkex.Boolean, default=False, help="Round up colors to the next 'full color'. Example: make rgb(253,0,0) to rgb(255,0,0) to receive clear red color.") + pars.add_argument("--allow_half_tones", type=inkex.Boolean, default=False, help="Allow rounding up to half-tone colors") + + + def closestColor(self, colors, color): + colors = np.array(colors) + color = np.array(color) + distances = np.sqrt(np.sum((colors-color)**2, axis=1)) + index_of_smallest = np.where(distances==np.amin(distances)) + smallest_distance = colors[index_of_smallest] + return smallest_distance + + def effect(self): + self.roundUpColors = [ + [ 0, 0, 0], #black | eri1 + [ 0, 0, 255], #blue | eri2 + [ 0, 255, 0], #green | eri3 + [255, 0, 0], #red | eri4 + [255, 0, 255], #magenta | eri5 + [ 0, 255, 255], #cyan | eri6 + [255, 255, 0], #yellow | eri7 + [255, 255, 255], #white | eri8 - useful for engravings, not for line cuttings + ] + if self.options.allow_half_tones is True: + self.roundUpColors.extend([ + [128, 0, 255], #violet | eri9 + [0 , 128, 255], #light blue | eri10 + [255, 128, 0], #orange | eri11 + [255, 0, 128], #pink | eri12 + [128, 255, 0], #light green | eri13 + [128, 255, 255], #lighter blue| eri14 + [0 , 255, 128], #mint | eri15 + [128, 128, 128], #grey | eri16 + ]) + + if len(self.svg.selected) == 0: + self.getAttribs(self.document.getroot()) + else: + for element in self.svg.selected.values(): + self.getAttribs(element) + #finally remove the styles from collected groups (if enabled) + if self.options.remove_group_styles is True: + for group in self.groups: + if group.attrib.has_key('style') is True: + group.attrib.pop('style') + + def getAttribs(self, node): + self.changeStyle(node) + for child in node: + self.getAttribs(child) + + #stroke and fill styles can be included in style attribute or they can exist separately (can occure in older SVG files). We do not parse other attributes than style + def changeStyle(self, node): + #we check/modify the style of all shapes (not groups) + if isinstance(node, inkex.ShapeElement) and not isinstance(node, inkex.Group): + # the final styles applied to this element (with influence from top level elements like groups) + specified_style = node.specified_style() + specifiedStyleAttributes = str(specified_style).split(';') #array + specifiedStyleAttributesDict = {} + if len(specified_style) > 0: #Style "specified_style" might contain just empty '' string which will lead to failing dict update + for specifiedStyleAttribute in specifiedStyleAttributes: + specifiedStyleAttributesDict.update({'{}'.format(specifiedStyleAttribute.split(':')[0]): specifiedStyleAttribute.split(':')[1]}) + + #three options to handle dedicated attributes (attributes not in the "style" attribute, but separate): + # - just delete all dedicated properties + # - merge dedicated properties, and prefer them over those from specified style + # - merge dedicated properties, but prefer properties from specified style + dedicatedStyleAttributesDict = {} + popDict = [] + # there are opacity (of group/parent), fill-opacity and stroke-opacity + popDict.extend(['opacity', 'stroke', 'stroke-opacity', 'stroke-width', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'fill', 'fill-opacity']) + for popItem in popDict: + if node.attrib.has_key(str(popItem)): + dedicatedStyleAttributesDict.update({'{}'.format(popItem): node.get(popItem)}) + node.attrib.pop(popItem) + + #inkex.utils.debug("specifiedStyleAttributesDict = " + str(specifiedStyleAttributesDict)) + #inkex.utils.debug("dedicatedStyleAttributesDict = " + str(dedicatedStyleAttributesDict)) + + if self.options.dedicated_style_attributes == 'prefer_dedicated': + specifiedStyleAttributesDict.update(dedicatedStyleAttributesDict) + node.set('style', specifiedStyleAttributesDict) + elif self.options.dedicated_style_attributes == 'prefer_specified': + dedicatedStyleAttributesDict.update(specifiedStyleAttributesDict) + node.set('style', dedicatedStyleAttributesDict) + elif self.options.dedicated_style_attributes == 'ignore': + pass + + # now parse the style with possibly merged dedicated attributes modded style attribute (dedicatedStyleAttributes) + if node.attrib.has_key('style') is False: + node.set('style', 'stroke:#000000;') #we add basic stroke color black. We cannot set to empty value or just ";" because it will not update properly + style = node.get('style') + + #add missing style attributes if required + if style.endswith(';') is False: + style += ';' + if re.search('(;|^)stroke:(.*?)(;|$)', style) is None: #if "stroke" is None, add one. We need to distinguish because there's also attribute "-inkscape-stroke" that's why we check starting with ^ or ; + style += 'stroke:none;' + if self.options.stroke_width_override is True and "stroke-width:" not in style: + style += 'stroke-width:{:1.4f};'.format(self.svg.unittouu(str(self.options.stroke_width) + self.options.stroke_width_units)) + if self.options.stroke_opacity_override is True and "stroke-opacity:" not in style: + style += 'stroke-opacity:{:1.1f};'.format(self.options.stroke_opacity / 100) + + if self.options.apply_hairlines is True: + if "vector-effect:non-scaling-stroke" not in style: + style += 'vector-effect:non-scaling-stroke;' + if "-inkscape-stroke:hairline" not in style: + style += '-inkscape-stroke:hairline;' + + if re.search('fill:(.*?)(;|$)', style) is None: #if "fill" is None, add one. + style += 'fill:none;' + + #then parse the content and check what we need to adjust + declarations = style.split(';') + for i, decl in enumerate(declarations): + parts = decl.split(':', 2) + if len(parts) == 2: + (prop, val) = parts + prop = prop.strip().lower() + if prop == 'stroke-width' and self.options.stroke_width_override is True: + new_val = self.svg.unittouu(str(self.options.stroke_width) + self.options.stroke_width_units) + declarations[i] = prop + ':{:1.4f}'.format(new_val) + if prop == 'stroke-opacity' and self.options.stroke_opacity_override is True: + new_val = self.options.stroke_opacity / 100 + declarations[i] = prop + ':{:1.1f}'.format(new_val) + if self.options.reset_opacity is True: + if prop == 'opacity': + declarations[i] = '' + if self.options.reset_stroke_attributes is True: + if prop == 'stroke-dasharray': + declarations[i] = '' + if prop == 'stroke-dashoffset': + declarations[i] = '' + if prop == 'stroke-linejoin': + declarations[i] = '' + if prop == 'stroke-linecap': + declarations[i] = '' + if prop == 'stroke-miterlimit': + declarations[i] = '' + if self.options.apply_black_strokes is True: + if prop == 'stroke': + if val == 'none': + new_val = '#000000' + declarations[i] = prop + ':' + new_val + if self.options.harmonize_colors is True: + if prop == 'fill': + if re.search('fill:none(.*?)(;|$)', style) is None: + rgb = inkex.Color(val).to_rgb() + closest_color = self.closestColor(self.roundUpColors, [rgb[0], rgb[1], rgb[2]]) + rgbNew = inkex.Color(( + int(closest_color[0][0]), + int(closest_color[0][1]), + int(closest_color[0][2]) + ), space='rgb') + declarations[i] = prop + ':' + str(inkex.Color(rgbNew).to_named()) + if prop == 'stroke': + if re.search('stroke:none(.*?)(;|$)', style) is None: + rgb = inkex.Color(val).to_rgb() + closest_color = self.closestColor(self.roundUpColors, [rgb[0], rgb[1], rgb[2]]) + rgbNew = inkex.Color(( + int(closest_color[0][0]), + int(closest_color[0][1]), + int(closest_color[0][2]) + ), space='rgb') + declarations[i] = prop + ':' + str(inkex.Color(rgbNew).to_named()) + if self.options.reset_fill_attributes is True: + if prop == 'fill': + new_val = 'none' + declarations[i] = prop + ':' + new_val + if prop == 'fill-opacity': + new_val = '1' + declarations[i] = prop + ':' + new_val + if self.options.apply_hairlines is False: + if prop == '-inkscape-stroke': + if val == 'hairline': + del declarations[i] + if prop == 'vector-effect': + if val == 'non-scaling-stroke': + del declarations[i] + node.set('style', ';'.join(declarations)) + + # if element is group we add it to collection to remove it's style after parsing all selected items + elif isinstance(node, inkex.ShapeElement) and isinstance(node, inkex.Group): + self.groups.append(node) + +if __name__ == '__main__': + CleanupStyles().run() \ No newline at end of file diff --git a/extensions/fablabchemnitz/cleanup_styles/meta.json b/extensions/fablabchemnitz/cleanup_styles/meta.json new file mode 100644 index 0000000..bb94f6b --- /dev/null +++ b/extensions/fablabchemnitz/cleanup_styles/meta.json @@ -0,0 +1,21 @@ +[ + { + "name": "Cleanup Styles", + "id": "fablabchemnitz.de.cleanup_styles", + "path": "cleanup_styles", + "dependent_extensions": null, + "original_name": "Cleanup", + "original_id": "com.attoparsec.filter.cleanup", + "license": "GNU GPL v2", + "license_url": "https://github.com/attoparsec/inkscape-extensions/blob/master/cleanup.py", + "comment": "ported to Inkscape v1 by Mario Voigt", + "source_url": "https://gitea.fablabchemnitz.de/FabLab_Chemnitz/mightyscape-1.2/src/branch/master/extensions/fablabchemnitz/cleanup_styles", + "fork_url": "https://github.com/attoparsec/inkscape-extensions", + "documentation_url": "https://stadtfabrikanten.org/display/IFM/Cleanup+Styles", + "inkscape_gallery_url": "https://inkscape.org/de/~MarioVoigt/%E2%98%85cleanup-styles", + "main_authors": [ + "github.com/attoparsec", + "github.com/eridur-de" + ] + } +] \ No newline at end of file diff --git a/extensions/fablabchemnitz/color_harmony/.gitignore b/extensions/fablabchemnitz/color_harmony/.gitignore new file mode 100644 index 0000000..cd4c22c --- /dev/null +++ b/extensions/fablabchemnitz/color_harmony/.gitignore @@ -0,0 +1 @@ +*__pycache__* diff --git a/extensions/fablabchemnitz/color_harmony/color_harmony.inx b/extensions/fablabchemnitz/color_harmony/color_harmony.inx new file mode 100644 index 0000000..1a96659 --- /dev/null +++ b/extensions/fablabchemnitz/color_harmony/color_harmony.inx @@ -0,0 +1,87 @@ + + + Color Harmony + fablabchemnitz.de.color_harmony + + + + + + + + + + + + + + + + 50 + + + + + + + + + + false + false + false + + + false + false + false + + + false + false + false + + + false + + + 0.1 + + 10 + + + + + + + + + true + + + + + + + + + + My Palette + + + + + + + all + + + + + + Generate color harmonies and save as palette file + + + \ No newline at end of file diff --git a/extensions/fablabchemnitz/color_harmony/color_harmony.py b/extensions/fablabchemnitz/color_harmony/color_harmony.py new file mode 100644 index 0000000..14bfcad --- /dev/null +++ b/extensions/fablabchemnitz/color_harmony/color_harmony.py @@ -0,0 +1,318 @@ +#!/usr/bin/env python3 + +# Color Harmony - Inkscape extension to generate +# palettes of colors that go well together +# +# Version 0.2 "golem" +# +# Copyright (C) 2009-2018 Ilya Portnov +# (original 'palette-editor' tool, version 0.0.7) +# 2020 Maren Hachmann (extension-ification) +# +# 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. + + +""" +This extension allows you to automatically add guides to your Inkscape documents. +""" + +#from math import sqrt +#import types +import inkex +from inkex import Group, Rectangle +from inkex.colors import is_color +from color_harmony.colorplus import ColorPlus +from color_harmony.harmonies import * +from color_harmony.shades import * + + +class ColorHarmony(inkex.EffectExtension): + """Generate palettes of colors that go well together""" + + color = '' + + def add_arguments(self, pars): + # General options + pars.add_argument('--tab', default='render', help='Extension functionality to use') # options: render, save, colorize + + # Render tab options + pars.add_argument('--harmony', default="five", help='Color harmony to generate, options: from_raster, just_opposite, split_complementary, three, four, rectangle, five, similar_3, similar_5, similar_and_opposite') + pars.add_argument('--sort', default="by_hue", help="Method to sort the palette by, options: by_hue, by_saturation, by_value, hue_contiguous") + pars.add_argument('--factor', default=50, type=int, help="Factor to affect the result, between 1 and 100. Default is 50. This modifies the angle between the resulting colors on the color wheel.") + pars.add_argument('--size', default=10, help="Size of the generated palette squares") + pars.add_argument('--unit', default='mm', help='Units') # options: mm, cm, in, px, pt, pc + pars.add_argument('--delete_existing', type=inkex.Boolean, help='Delete existing palettes before generating a new one') + + # Shading: cooler, warmer, saturation, value, chroma, luma, hue, hue_luma, luma_plus_chroma, luma_minus_chroma + pars.add_argument( '--cooler', type=inkex.Boolean, help='Add shades with cooler color temperature') + pars.add_argument( '--warmer', type=inkex.Boolean, help='Add shades with warmer color temperature') + pars.add_argument( '--saturation', type=inkex.Boolean, help='Add shades with saturation steps') + pars.add_argument( '--value', type=inkex.Boolean, help='Add shades with value steps') + pars.add_argument( '--chroma', type=inkex.Boolean, help='Add shades with chroma steps') + pars.add_argument( '--luma', type=inkex.Boolean, help='Add shades with luma steps') + pars.add_argument( '--hue', type=inkex.Boolean, help='Add shades with hue steps') + pars.add_argument( '--hue_luma', type=inkex.Boolean, help='Add shades with hue and luma steps') + pars.add_argument( '--luma_plus_chroma', type=inkex.Boolean, help='Add shades with luma plus chroma steps') + pars.add_argument( '--luma_minus_chroma', type=inkex.Boolean, help='Add shades with luma minus chroma steps') + pars.add_argument('--step_width', type=float, default=0.1, help='Shader step width') # TODO: find out what range this can take on, and adjust min, max, default in inx + + # Save tab options + pars.add_argument('--palette_format', default='gimp', help='Palette file format') + # options: gimp, krita, scribus + pars.add_argument('--palette_folder', help="Folder to save the palette file in") + pars.add_argument('--palette_name', help="Name of the palette") + + # Colorize tab options + # no options currently + + + def effect(self): + + if self.options.tab == "render": + + if len(self.svg.selected) == 1: + for obj_id, obj in self.svg.selected.items(): + fill = obj.style.get("fill") + if is_color(fill): + if self.options.delete_existing: + self.delete_existing_palettes() + self.color = ColorPlus(fill) + self.options.factor = self.options.factor/100 + colors = self.create_harmony() + shades = self.create_shades(colors) + palettes = [colors] + shades + + for i in range(len(palettes)): + self.render_palette(palettes[i], i) + else: + raise inkex.AbortExtension( + "Please select an object with a plain fill color.") + else: + raise inkex.AbortExtension( + "Please select one object.") + elif self.options.tab == "save": + palettes = self.get_palettes_in_doc() + if len(palettes) >= 1: + self.save_palette(palettes[0]) + else: + raise inkex.AbortExtension( + "There is no rendered palette in the document. Please render a palette using the first tab of the dialog before you try to save it.") + elif self.options.tab == "colorize": + if len(self.svg.selected) > 0: + self.colorize() + else: + raise inkex.AbortExtension( + "Please select an object to colorize!") + + + # METHODS FOR EACH TAB + # -------------------- + + # Render tab + # ========== + def create_harmony(self): + + harmony_functions = { + "from_raster": self.palette_from_raster, # not implemented yet + "just_opposite": self.opposite, + "split_complementary": self.splitcomplementary, + "three": self.nhues3, + "four": self.nhues4, + "rectangle": self.rectangle, + "five": self.fivecolors, + "similar_3": self.similar_3, + "similar_5": self.similar_5, + "similar_and_opposite": self.similaropposite, + } + + # use appropriate function for the selected tab + colors = harmony_functions[self.options.harmony](self.color) + colors = self.sort_colors(colors) + + return colors + + def render_palette(self, colors, shift): + size = self.svg.unittouu(str(self.options.size)+self.options.unit) + top = 0 + shift * size + left = 0 + + layer = self.svg.get_current_layer() if self.svg.get_current_layer() is not None else self.document.getroot() + + group_attribs = {inkex.addNS('label', 'inkscape'): "Palette ({harmony}, {color}) ".format(color=self.color, harmony=self.options.harmony)} + palette_group = Group(**group_attribs) + + for color in colors: + palette_field = Rectangle(x=str(left), + y=str(top), + width=str(size), + height=str(size)) + palette_field.style = {'fill': color} + palette_group.add(palette_field) + left += size + + palette_group.transform.add_translate(0, self.svg.viewport_height + size) + + layer.add(palette_group) + + def palette_from_raster(self, color): + # TODO: implement + return [] + + def opposite(self, color): + colors = opposite(color) + return colors + + def splitcomplementary(self, color): + colors = splitcomplementary(color, self.options.factor) + return colors + + def nhues3(self, color): + colors = nHues(color, 3) + return colors + + def nhues4(self, color): + colors = nHues(color, 4) + return colors + + def rectangle(self, color): + colors = rectangle(color, self.options.factor) + return colors + + def fivecolors(self, color): + colors = fiveColors(color, self.options.factor) + return colors + + def similar_3(self, color): + colors = similar(color, 3, self.options.factor) + return colors + + def similar_5(self, color): + colors = similar(color, 5, self.options.factor) + return colors + + def similaropposite(self, color): + colors = similarAndOpposite(color, self.options.factor) + return colors + + def create_shades(self, colors): + shades = [] + shading_options = { + "cooler": cooler, + "warmer": warmer, + "saturation": saturation, + "value": value, + "chroma": chroma, + "luma": luma, + "hue": hue, + "hue_luma": hue_luma, + "luma_plus_chroma": luma_plus_chroma, + "luma_minus_chroma": luma_minus_chroma, + } + + for option, function in shading_options.items(): + if vars(self.options)[option] == True: + # shades are created per color, + # but we want to get one palette per shading step + shaded_colors = [] + for i in range(len(colors)): + shaded_colors.append(function(colors[i], self.options.step_width)) + + pals = [list(a) for a in zip(*shaded_colors)] + + shades += pals + return shades + + def delete_existing_palettes(self): + """Delete all palettes in the document""" + + for palette in self.get_palettes_in_doc(): + palette.delete() + + # Save tab + # ======== + def save_palette(self, palette): + # TODO: implement + # if not hasattr(self.palette, 'name'): + # if type(file_w) in [str, unicode]: + # self.palette.name = basename(file_w) + # else: + # self.palette.name='Colors' + pass + + + # Colorize tab + # ============ + + def colorize(self): + # TODO: implement + pass + + # HELPER FUNCTIONS + # ---------------- + + def get_palettes_in_doc(self): + palettes = [] + for group in self.svg.findall('.//svg:g'): + if group.get('inkscape:label').startswith('Palette ('): + palettes.append(group) + return palettes + + def sort_colors(self, colors): + if self.options.sort == "by_hue": + colors.sort(key=lambda color: color.to_hsv()[0]) + elif self.options.sort == "by_saturation": + colors.sort(key=lambda color: color.to_hsv()[1]) + elif self.options.sort == "by_value": + colors.sort(key=lambda color: color.to_hsv()[2]) + # this option looks nicer when the palette colors are similar red tones + # some of which have a hue close to 0 + # and some of which have a hue close to 1 + elif self.options.sort == "hue_contiguous": + # sort by hue first + colors.sort(key=lambda color: color.to_hsv()[0]) + # now find out if the hues are maybe clustered around the 0 - 1 boundary + hues = [color.to_hsv()[0] for color in colors] + start_hue = 0 + end_hue = 0 + max_dist = 0 + for i in range(len(colors)-1): + h1 = hues[i] + h2 = hues[i+1] + cur_dist = h2-h1 + if cur_dist > max_dist and self.no_colors_in_between(h1, h2, hues): + max_dist = cur_dist + start_hue = h2 + for i in range(len(colors)): + sorting_hue = hues[i] - start_hue + if sorting_hue > 1: + sorting_hue -=1 + elif sorting_hue < 0: + sorting_hue += 1 + hues[i] = sorting_hue + sorted_colors = [color for hue, color in sorted(zip(hues,colors))] + colors = sorted_colors + else: + raise inkex.AbortExtension( + "Please select one of the following sorting options: by_hue, by_saturation, by_value.") + return colors + + def no_colors_in_between(self, hue1, hue2, hues): + for hue in hues: + if hue > hue1 and hue < hue2: + return False + return True + +if __name__ == '__main__': + ColorHarmony().run() diff --git a/extensions/fablabchemnitz/color_harmony/color_harmony/__init__.py b/extensions/fablabchemnitz/color_harmony/color_harmony/__init__.py new file mode 100644 index 0000000..1bb8bf6 --- /dev/null +++ b/extensions/fablabchemnitz/color_harmony/color_harmony/__init__.py @@ -0,0 +1 @@ +# empty diff --git a/extensions/fablabchemnitz/color_harmony/color_harmony/colorplus.py b/extensions/fablabchemnitz/color_harmony/color_harmony/colorplus.py new file mode 100644 index 0000000..d9579c7 --- /dev/null +++ b/extensions/fablabchemnitz/color_harmony/color_harmony/colorplus.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python +# coding=utf-8 +# +# Copyright (C) 2009-2018 Ilya Portnov +# (original 'palette-editor' tool, version 0.0.7) +# 2020 Maren Hachmann (extension-ification) +# +# 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. + + +from math import cos, acos, sqrt, pi +import colorsys +from inkex.colors import Color + +_HCY_RED_LUMA = 0.299 +_HCY_GREEN_LUMA = 0.587 +_HCY_BLUE_LUMA = 0.114 + +class ColorPlus(Color): + + #HCYwts = 0.299, 0.587, 0.114 + + ## HCY colour space. + # + # Copy&Paste from https://raw.githubusercontent.com/mypaint/mypaint/master/gui/colors/uicolor.py + # Copyright (C) 2012-2013 by Andrew Chadwick + # + + # Frequently referred to as HSY, Hue/Chroma/Luma, HsY, HSI etc. It can be + # thought of as a cylindrical remapping of the YCbCr solid: the "C" term is the + # proportion of the maximum permissible chroma within the RGB gamut at a given + # hue and luma. Planes of constant Y are equiluminant. + # + # ref https://code.google.com/p/colour-space-viewer/ + # ref git://anongit.kde.org/kdelibs in kdeui/colors/kcolorspaces.cpp + # ref http://blog.publicfields.net/2011/12/rgb-hue-saturation-luma.html + # ref Joblove G.H., Greenberg D., Color spaces for computer graphics. + # ref http://www.cs.rit.edu/~ncs/color/t_convert.html + # ref http://en.literateprograms.org/RGB_to_HSV_color_space_conversion_(C) + # ref http://lodev.org/cgtutor/color.html + # ref Levkowitz H., Herman G.T., "GLHS: a generalized lightness, hue, and + # saturation color model" + + # For consistency, use the same weights that the Color and Luminosity layer + # blend modes use, as also used by brushlib's Colorize brush blend mode. We + # follow http://www.w3.org/TR/compositing/ here. BT.601 YCbCr has a nearly + # identical definition of luma. + + def __init__(self, color=None, space='rgb'): + super().__init__(color) + + def to_hcy(self): + """RGB → HCY: R,G,B,H,C,Y ∈ [0, 1] + + :param rgb: Color expressed as an additive RGB triple. + :type rgb: tuple (r, g, b) where 0≤r≤1, 0≤g≤1, 0≤b≤1. + :rtype: tuple (h, c, y) where 0≤h<1, but 0≤c≤2 and 0≤y≤1. + + """ + r, g, b = self.to_floats() + + # Luma is just a weighted sum of the three components. + y = _HCY_RED_LUMA*r + _HCY_GREEN_LUMA*g + _HCY_BLUE_LUMA*b + + # Hue. First pick a sector based on the greatest RGB component, then add + # the scaled difference of the other two RGB components. + p = max(r, g, b) + n = min(r, g, b) + d = p - n # An absolute measure of chroma: only used for scaling. + if n == p: + h = 0.0 + elif p == r: + h = (g - b)/d + if h < 0: + h += 6.0 + elif p == g: + h = ((b - r)/d) + 2.0 + else: # p==b + h = ((r - g)/d) + 4.0 + h /= 6.0 + + # Chroma, relative to the RGB gamut envelope. + if r == g == b: + # Avoid a division by zero for the achromatic case. + c = 0.0 + else: + # For the derivation, see the GLHS paper. + c = max((y-n)/y, (p-y)/(1-y)) + return h, c, y + + @staticmethod + def from_hcy(h, c, y): + """HCY → RGB: R,G,B,H,C,Y ∈ [0, 1] + + :param hcy: Color expressed as a Hue/relative-Chroma/Luma triple. + :type hcy: tuple (h, c, y) where 0≤h<1, but 0≤c≤2 and 0≤y≤1. + :rtype: ColorPlus object. + + >>> n = 32 + >>> diffs = [sum( [abs(c1-c2) for c1, c2 in + ... zip( HCY_to_RGB(RGB_to_HCY([r/n, g/n, b/n])), + ... [r/n, g/n, b/n] ) ] ) + ... for r in range(int(n+1)) + ... for g in range(int(n+1)) + ... for b in range(int(n+1))] + >>> sum(diffs) < n*1e-6 + True + + """ + + if c == 0: + return y, y, y + + h %= 1.0 + h *= 6.0 + if h < 1: + #implies (p==r and h==(g-b)/d and g>=b) + th = h + tm = _HCY_RED_LUMA + _HCY_GREEN_LUMA * th + elif h < 2: + #implies (p==g and h==((b-r)/d)+2.0 and b=g) + th = h - 2.0 + tm = _HCY_GREEN_LUMA + _HCY_BLUE_LUMA * th + elif h < 4: + #implies (p==b and h==((r-g)/d)+4.0 and r=g) + th = h - 4.0 + tm = _HCY_BLUE_LUMA + _HCY_RED_LUMA * th + else: + #implies (p==r and h==(g-b)/d and g= y: + p = y + y*c*(1-tm)/tm + o = y + y*c*(th-tm)/tm + n = y - (y*c) + else: + p = y + (1-y)*c + o = y + (1-y)*c*(th-tm)/(1-tm) + n = y - (1-y)*c*tm/(1-tm) + + # Back to RGB order + if h < 1: r, g, b = p, o, n + elif h < 2: r, g, b = o, p, n + elif h < 3: r, g, b = n, p, o + elif h < 4: r, g, b = n, o, p + elif h < 5: r, g, b = o, n, p + else: r, g, b = p, n, o + + return ColorPlus([255*r, 255*g, 255*b]) + + def to_hsv(self): + r, g, b = self.to_floats() + eps = 0.001 + if abs(max(r,g,b)) < eps: + return (0,0,0) + return colorsys.rgb_to_hsv(r, g, b) + + @staticmethod + def from_hsv(h, s, v): + r, g, b = colorsys.hsv_to_rgb(h, s, v) + return ColorPlus([255*r, 255*g, 255*b]) + + # TODO: everything below is not updated yet, maybe not really needed + def hex(self): + r,g,b = self.getRGB() + return "#{:02x}{:02x}{:02x}".format(r,g,b) + + def getRgbString(self): + r,g,b = self.getRGB() + return "rgb({}, {}, {})".format(r,g,b) + + def getHsvString(self): + h,s,v = self.getHSV() + return "hsv({}, {}, {})".format(h,s,v) + + def invert(self): + r, g, b = self._rgb + return Color(255-r, 255-g, 255-b) + + def darker(clr, q): + h,s,v = clr.getHSV() + v = clip(v-q) + return hsv(h,s,v) + + def lighter(clr, q): + h,s,v = clr.getHSV() + v = clip(v+q) + return hsv(h,s,v) + + def saturate(clr, q): + h,s,v = clr.getHSV() + s = clip(s+q) + return hsv(h,s,v) + + def desaturate(clr, q): + h,s,v = clr.getHSV() + s = clip(s-q) + return hsv(h,s,v) + + def increment_hue(clr, q): + h,s,v = clr.getHSV() + h += q + if h > 1.0: + h -= 1.0 + if h < 1.0: + h += 1.0 + return hsv(h,s,v) + + def contrast(clr, q): + h,s,v = clr.getHSV() + v = (v - 0.5)*(1.0 + q) + 0.5 + v = clip(v) + return hsv(h,s,v) + + def linear(x, y, q): + return (1.-q)*x + q*y + + def linear3(v1, v2, q): + x1, y1, z1 = v1 + x2, y2, z2 = v2 + return (linear(x1, x2, q), linear(y1, y2, q), linear(z1, z2, q)) + + def circular(h1, h2, q, circle=1.0): + #print("Src hues: "+ str((h1, h2))) + d = h2 - h1 + if h1 > h2: + h1, h2 = h2, h1 + d = -d + q = 1.0 - q + if d > circle/2.0: + h1 = h1 + circle + h = linear(h1, h2, q) + else: + h = h1 + q*d + if h >= circle: + h -= circle + #print("Hue: "+str(h)) + return h diff --git a/extensions/fablabchemnitz/color_harmony/color_harmony/export.py b/extensions/fablabchemnitz/color_harmony/color_harmony/export.py new file mode 100644 index 0000000..a391d5f --- /dev/null +++ b/extensions/fablabchemnitz/color_harmony/color_harmony/export.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python +# coding=utf-8 +# +# Copyright (C) 2009-2018 Ilya Portnov +# (original 'palette-editor' tool, version 0.0.7) +# 2020 Maren Hachmann (extension-ification) +# +# 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. + + +from os.path import join, basename +from lxml import etree as ET +from zipfile import ZipFile, ZIP_DEFLATED + + +class PaletteFile(object) + + def __init__(self, name, colors, filename, folder): + self.colors = colors + if name != "": + self.name = name + else: + self.name = "Palette" + self.folder = folder + + def save(self, format): + FORMATS = {'gimp': [build_gpl, 'gpl'], + 'scribus': [build_scribus_xml, 'xml'], + 'krita': [build_kpl, 'kpl'], + 'css': [build_css, 'css'], + 'android_xml': [build_android_xml, 'xml'], + } + + if os.path.exists(self.folder): + # save with given file name + pass + + def build_gpl(self): + # TODO: fix + palette_string = (u"Name: {}\n".format(self.palette.name).encode('utf-8')) + if hasattr(self.palette, 'ncols') and self.palette.ncols: + palette_string += 'Columns: %s\n' % self.palette.ncols + for key,value in self.palette.meta.items(): + if key != "Name": + palette_string += u"# {}: {}\n".format(key, value).encode('utf-8') + palette_string += '#\n' + for row in self.palette.slots: + for slot in row: + n = slot.name + r, g, b = slot.color.getRGB() + s = '%d %d %d %s\n' % (r, g, b, n) + palette_string += s + for key,value in slot.color.meta.items(): + if key != "Name": + palette_string += u"# {}: {}\n".format(key, value).encode('utf-8') + return palette_string + + def build_kpl(self): + # TODO: fix (and don't save it here, only build) + with ZipFile(file_w, 'w', ZIP_DEFLATED) as zf: + zf.writestr("mimetype", MIMETYPE) + + xml = ET.Element("Colorset") + xml.attrib['version'] = '1.0' + xml.attrib['columns'] = str(self.palette.ncols) + xml.attrib['name'] = self.palette.name + xml.attrib['comment'] = self.palette.meta.get("Comment", "Generated by Palette Editor") + + for i,row in enumerate(self.palette.slots): + for j,slot in enumerate(row): + color = slot.color + name = color.name + default_name = "Swatch-{}-{}".format(i,j) + if not name: + name = default_name + + elem = ET.SubElement(xml, "ColorSetEntry") + elem.attrib['spot'] = color.meta.get("Spot", "false") + elem.attrib['id'] = default_name + elem.attrib['name'] = name + elem.attrib['bitdepth'] = 'U8' + + r,g,b = color.getRGB1() + srgb = ET.SubElement(elem, "sRGB") + srgb.attrib['r'] = str(r) + srgb.attrib['g'] = str(g) + srgb.attrib['b'] = str(b) + + tree = ET.ElementTree(xml) + tree_str = ET.tostring(tree, encoding='utf-8', pretty_print=True, xml_declaration=False) + + zf.writestr("colorset.xml", tree_str) + + def build_scribus_xml(self): + # TODO: fix, and don't save here + xml = ET.Element("SCRIBUSCOLORS", NAME=name) + + for i,row in enumerate(self.palette.getColors()): + for j,color in enumerate(row): + name = color.name + if not name: + name = "Swatch-{}-{}".format(i,j) + elem = ET.SubElement(xml, "COLOR", NAME=name, RGB=color.hex()) + if "Spot" in color.meta: + elem.attrib["Spot"] = color.meta["Spot"] + if "Register" in color.meta: + elem.attrib["Register"] = color.meta["Register"] + + ET.ElementTree(xml).write(file_w, encoding="utf-8", pretty_print=True, xml_declaration=True) + + def build_android_xml(self): + # https://stackoverflow.com/questions/3769762/web-colors-in-an-android-color-xml-resource-file + palette_string = '' + # TODO: implement + return palette_string + + def build_css(self): + palette_string = '' + + # TODO: fix + for i, row in enumerate(self.palette.slots): + for j, slot in enumerate(row): + hex = slot.color.hex() + s = ".color-{}-{}{} {{ color: {} }};\n".format(i,j, user, hex) + palette_string += s + + return palette_string diff --git a/extensions/fablabchemnitz/color_harmony/color_harmony/harmonies.py b/extensions/fablabchemnitz/color_harmony/color_harmony/harmonies.py new file mode 100644 index 0000000..0f89680 --- /dev/null +++ b/extensions/fablabchemnitz/color_harmony/color_harmony/harmonies.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python +# coding=utf-8 +# +# Copyright (C) 2009-2018 Ilya Portnov +# (original 'palette-editor' tool, version 0.0.7) +# 2020 Maren Hachmann (extension-ification) +# +# 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. + +from math import sqrt, sin, cos +from color_harmony.colorplus import ColorPlus +from color_harmony.utils import seq, circle_hue + + +# Each of these functions takes one ColorPlus object +# and returns a list of ColorPlus objects: +__all__ = ['opposite', 'splitcomplementary', 'similar', 'similarAndOpposite', 'rectangle', 'nHues', 'fiveColors'] + +# Harmony functions +# 180° rotation +def opposite(color): + h, s, v = color.to_hsv() + h = h + 0.5 + if h > 1.0: + h -= 1.0 + return [color, ColorPlus.from_hsv(h, s, v)] + +# default value 0.5 corresponds to +-36° deviation from opposite, max. useful value: 89°, min. useful value: 1° +def splitcomplementary(color, parameter=0.5): + h, s, v = color.to_hsv() + h += (1.0 - 0.4*parameter)/2.0 + if h > 1.0: + h -= 1.0 + c1 = ColorPlus.from_hsv(h,s,v) + h += 0.4*parameter + if h > 1.0: + h -= 1.0 + c2 = ColorPlus.from_hsv(h,s,v) + return [color, c1, c2] + +# default value 0.5 corresponds to 36° per step, max. useful value: 360°/n , min. useful value: 1° +def similar(color, n, parameter=0.5): + h, s, v = color.to_hsv() + step = 0.2 * parameter + hmin = h - (n // 2) * step + hmax = h + (n // 2) * step + return [ColorPlus.from_hsv(dh % 1.0, s, v) for dh in seq(hmin, hmax, step)] + +# default value 0.5 corresponds to 36° deviation from original, max. useful value: 178°, min. useful value: 1° +def similarAndOpposite(color, parameter=0.5): + h, c, y = color.to_hcy() + h1 = h + 0.2 * parameter + if h1 > 1.0: + h1 -= 1.0 + h2 = h - 0.2 * parameter + if h2 < 0.0: + h2 += 1.0 + h3 = h + 0.5 + if h3 > 1.0: + h3 -= 1.0 + return [ColorPlus.from_hcy(h1,c,y), + color, + ColorPlus.from_hcy(h2,c,y), + ColorPlus.from_hcy(h3,c,y)] + +# default value 0.5 corresponds to 36° deviation from original, max. useful angle 180°, min. useful angle 1° +def rectangle(color, parameter=0.5): + h, c, y = color.to_hcy() + h1 = (h + 0.2 * parameter) % 1.0 + h2 = (h1 + 0.5) % 1.0 + h3 = (h + 0.5) % 1.0 + return [color, + ColorPlus.from_hcy(h1,c,y), + ColorPlus.from_hcy(h2,c,y), + ColorPlus.from_hcy(h3,c,y)] + +# returns n colors that are placed on the hue circle in steps of 360°/n +def nHues(color, n): + h, s, v = color.to_hsv() + return [color] + [ColorPlus.from_hsv(circle_hue(i, n, h), s, v) for i in range(1, n)] + +# parameter determines +/- deviation from a the hues +/-120° away, default value 0.5 corresponds to 2.16°, max. possible angle 4.32° +def fiveColors(color, parameter=0.5): + h0, s, v = color.to_hsv() + h1s = (h0 + 1.0/3.0) % 1.0 + h2s = (h1s + 1.0/3.0) % 1.0 + delta = 0.06 * parameter + h1 = (h1s - delta) % 1.0 + h2 = (h1s + delta) % 1.0 + h3 = (h2s - delta) % 1.0 + h4 = (h2s + delta) % 1.0 + return [color] + [ColorPlus.from_hsv(h,s,v) for h in [h1,h2,h3,h4]] diff --git a/extensions/fablabchemnitz/color_harmony/color_harmony/matching/__init__.py b/extensions/fablabchemnitz/color_harmony/color_harmony/matching/__init__.py new file mode 100644 index 0000000..1bb8bf6 --- /dev/null +++ b/extensions/fablabchemnitz/color_harmony/color_harmony/matching/__init__.py @@ -0,0 +1 @@ +# empty diff --git a/extensions/fablabchemnitz/color_harmony/color_harmony/matching/matching.py b/extensions/fablabchemnitz/color_harmony/color_harmony/matching/matching.py new file mode 100644 index 0000000..44745f0 --- /dev/null +++ b/extensions/fablabchemnitz/color_harmony/color_harmony/matching/matching.py @@ -0,0 +1,117 @@ + +from color.colors import * +from color.spaces import * + +def find_min(idx, occupied, d): + best_i = None + best_y = None + best_clr = None + for i, clr in d.iteritems(): + if i in occupied: + continue + y = clr[idx] + if best_y is None or y < best_y: + best_i = i + best_y = y + best_clr = clr + if best_y is not None: + return best_i + for i, clr in d.iteritems(): + y = clr[idx] + if best_y is None or y < best_y: + best_i = i + best_y = y + best_clr = clr + assert best_i is not None + return best_i + +def find_max(idx, occupied, d): + best_i = None + best_y = None + best_clr = None + for i, clr in d.iteritems(): + if i in occupied: + continue + y = clr[idx] + if best_y is None or y > best_y: + best_i = i + best_y = y + best_clr = clr + if best_y is not None: + return best_i + for i, clr in d.iteritems(): + y = clr[idx] + if best_y is None or y > best_y: + best_i = i + best_y = y + best_clr = clr + assert best_i is not None + return best_i + +def match_colors(colors1, colors2): + hsvs1 = dict(enumerate([c.getHCY() for c in colors1 if c is not None])) + hsvs2 = dict(enumerate([c.getHCY() for c in colors2 if c is not None])) + occupied = [] + result = {} + while len(hsvs1.keys()) > 0: + # Darkest of SVG colors + darkest1_i = find_min(2, [], hsvs1) + # Darkest of palette colors + darkest2_i = find_min(2, occupied, hsvs2) + hsvs1.pop(darkest1_i) + occupied.append(darkest2_i) + result[darkest1_i] = darkest2_i + if not hsvs1: + break + + # Lightest of SVG colors + lightest1_i = find_max(2, [], hsvs1) + # Lightest of palette colors + lightest2_i = find_max(2, occupied, hsvs2) + hsvs1.pop(lightest1_i) + occupied.append(lightest2_i) + result[lightest1_i] = lightest2_i + if not hsvs1: + break + + # Less saturated of SVG colors + grayest1_i = find_min(1, [], hsvs1) + # Less saturated of palette colors + grayest2_i = find_min(1, occupied, hsvs2) + hsvs1.pop(grayest1_i) + occupied.append(grayest2_i) + result[grayest1_i] = grayest2_i + if not hsvs1: + break + + # Most saturated of SVG colors + saturated1_i = find_max(1, [], hsvs1) + # Most saturated of palette colors + saturated2_i = find_max(1, occupied, hsvs2) + hsvs1.pop(saturated1_i) + occupied.append(saturated2_i) + result[saturated1_i] = saturated2_i + if not hsvs1: + break + + redest1_i = find_min(0, [], hsvs1) + redest2_i = find_min(0, occupied, hsvs2) + hsvs1.pop(redest1_i) + occupied.append(redest2_i) + result[redest1_i] = redest2_i + if not hsvs1: + break + + bluest1_i = find_max(0, [], hsvs1) + bluest2_i = find_max(0, occupied, hsvs2) + hsvs1.pop(bluest1_i) + occupied.append(bluest2_i) + result[bluest1_i] = bluest2_i + + clrs = [] + for i in range(len(result.keys())): + j = result[i] + clrs.append(colors2[j]) + return clrs + + diff --git a/extensions/fablabchemnitz/color_harmony/color_harmony/matching/svg.py b/extensions/fablabchemnitz/color_harmony/color_harmony/matching/svg.py new file mode 100644 index 0000000..b57d604 --- /dev/null +++ b/extensions/fablabchemnitz/color_harmony/color_harmony/matching/svg.py @@ -0,0 +1,96 @@ + +import re +from lxml import etree + +from color import colors + +SVG_NS="http://www.w3.org/2000/svg" + +color_re = re.compile("#[0-9a-fA-F]+") + +def walk(processor, element): + for child in element.iter(): + processor(child) + +class Collector(object): + def __init__(self): + self.colors = {} + self.n = 0 + + def _parse(self, string): + xs = string.split(";") + single = len(xs) == 1 + + result = {} + for x in xs: + ts = x.split(":") + if len(ts) < 2: + if single: + return None + else: + continue + key, value = ts[0], ts[1] + result[key] = value + return result + + def _merge(self, attr): + if type(attr) == str: + return attr + result = "" + for key in attr: + value = attr[key] + result += key + ":" + value + ";" + return result + + def _is_color(self, val): + return color_re.match(val) is not None + + def _remember_color(self, color): + if color not in self.colors: + self.colors[color] = self.n + self.n += 1 + n = self.colors[color] + return "${color%s}" % n + + def _process_attr(self, value): + d = self._parse(value) + if d is None: + if self._is_color(value): + return self._remember_color(value) + else: + return value + elif type(d) == dict: + for attr in ['fill', 'stroke', 'stop-color']: + if (attr in d) and self._is_color(d[attr]): + color = d[attr] + d[attr] = self._remember_color(color) + return self._merge(d) + else: + if self._is_color(value): + return self._remember_color(value) + else: + return value + + def process(self, element): + for attr in ['fill', 'stroke', 'style', 'pagecolor', 'bordercolor']: + if attr in element.attrib: + value = element.get(attr) + element.set(attr, self._process_attr(value)) + + def result(self): + return self.colors + +def read_template(filename): + xml = etree.parse(filename) + collector = Collector() + walk(collector.process, xml.getroot()) + svg = etree.tostring(xml, encoding='utf-8', xml_declaration=True, pretty_print=True) + #open("last_template.svg",'w').write(svg) + color_dict = collector.result() + colors_inv = dict((v,k) for k, v in color_dict.iteritems()) + svg_colors = [] + for key in range(len(colors_inv.keys())): + clr = colors_inv[key] + svg_colors.append( colors.fromHex(clr) ) + return svg_colors, svg + diff --git a/extensions/fablabchemnitz/color_harmony/color_harmony/matching/svg_widget.py b/extensions/fablabchemnitz/color_harmony/color_harmony/matching/svg_widget.py new file mode 100644 index 0000000..321aa28 --- /dev/null +++ b/extensions/fablabchemnitz/color_harmony/color_harmony/matching/svg_widget.py @@ -0,0 +1,135 @@ + +from string import Template +from PyQt4 import QtGui, QtSvg, QtCore + +from color.colors import * +from color.spaces import * +import svg, transform, matching + +class SvgTemplateWidget(QtSvg.QSvgWidget): + template_loaded = QtCore.pyqtSignal() + colors_matched = QtCore.pyqtSignal() + file_dropped = QtCore.pyqtSignal(unicode) + + def __init__(self, *args): + QtSvg.QSvgWidget.__init__(self, *args) + self.setAcceptDrops(True) + self._colors = [Color(i*10,i*10,i*10) for i in range(20)] + self._template = None + self._template_filename = None + self._svg = None + self._need_render = True + self._svg_colors = None + self._dst_colors = None + self._last_size = None + + def sizeHint(self): + if self.renderer().isValid(): + return self.renderer().defaultSize() + elif self._last_size: + return self._last_size + else: + return QtCore.QSize(300,300) + + + def dragEnterEvent(self, event): + if event.mimeData().hasUrls(): + event.acceptProposedAction() + + def dropEvent(self, event): + if event.mimeData().hasUrls(): + urls = event.mimeData().urls() + path = unicode( urls[0].path() ) + self.file_dropped.emit(path) + + def _get_color(self, i): + if i < len(self._colors): + return self._colors[i] + else: + return Color(i*10, i*10, i*10) + + def _update(self): + arr = QtCore.QByteArray.fromRawData(self.get_svg()) + print("Data loaded: {} bytes".format(arr.length())) + self.load(arr) + if self.renderer().isValid(): + self._last_size = self.renderer().defaultSize() + self.update() + + def _get_image(self): + w,h = self.size().width(), self.size().height() + image = QtGui.QImage(w, h, QtGui.QImage.Format_ARGB32_Premultiplied) + image.fill(0) + qp = QtGui.QPainter() + qp.begin(image) + self.renderer().render(qp, QtCore.QRectF(0.0, 0.0, w, h)) + qp.end() + return image + + def loadTemplate(self, filename): + self._template_filename = filename + self._svg_colors, self._template = svg.read_template(filename) + print("Source SVG colors:") + for c in self._svg_colors: + print str(c) + print("Template loaded: {}: {} bytes".format(filename, len(self._template))) + self._need_render = True + self._update() + self.template_loaded.emit() + + def set_color(self, idx, color): + self._colors[idx] = color + self._need_render = True + self._update() + + def setColors(self, dst_colors, space=HCY): + if not dst_colors: + return + print("Matching colors in space: {}".format(space)) + self._dst_colors = dst_colors + self._colors = transform.match_colors(space, self._svg_colors, dst_colors) + #self._colors = matching.match_colors(self._svg_colors, dst_colors) + self._need_render = True + self._update() + self.colors_matched.emit() + + def resetColors(self): + self.load(self._template_filename) + self.repaint() + + def get_svg_colors(self): + return self._svg_colors + + def get_dst_colors(self): + return self._colors + + def get_svg(self): + if self._svg is not None and not self._need_render: + return self._svg + else: + self._svg = self._render() + self._need_render = False + return self._svg + + def _render(self): + #d = dict([("color"+str(i), color.hex() if color is not None else Color(255,255,255)) for i, color in enumerate(self._colors)]) + d = ColorDict(self._colors) + #self._image = self._get_image() + return Template(self._template).substitute(d) + +class ColorDict(object): + def __init__(self, colors): + self._colors = colors + + def __getitem__(self, key): + if key.startswith("color"): + n = int( key[5:] ) + if n < len(self._colors): + return self._colors[n].hex() + else: + return "#ffffff" + else: + raise KeyError(key) + + + diff --git a/extensions/fablabchemnitz/color_harmony/color_harmony/matching/transform.py b/extensions/fablabchemnitz/color_harmony/color_harmony/matching/transform.py new file mode 100644 index 0000000..55a55e9 --- /dev/null +++ b/extensions/fablabchemnitz/color_harmony/color_harmony/matching/transform.py @@ -0,0 +1,241 @@ +#!/usr/bin/python3 + +# -y11+a13*x13+a12*x12+a11*x11+b1 +# -y12+a23*x13+a22*x12+a21*x11+b2 +# -y13+a33*x13+a32*x12+a31*x11+b3 +# -y21+a13*x23+a12*x22+a11*x21+b1 +# -y22+a23*x23+a22*x22+a21*x21+b2 +# -y23+a33*x23+a32*x22+a31*x21+b3 +# -y31+a13*x33+a12*x32+a11*x31+b1 +# -y32+a23*x33+a22*x32+a21*x31+b2 +# -y33+a33*x33+a32*x32+a31*x31+b3 +# -y41+a13*x43+a12*x42+a11*x41+b1 +# -y42+a23*x43+a22*x42+a21*x41+b2 +# -y43+a33*x43+a32*x42+a31*x41+b3 + +from math import sqrt +import itertools +import numpy as np +from numpy.linalg import solve, det +from numpy.linalg.linalg import LinAlgError +from copy import deepcopy as copy +from PyQt4 import QtGui + +from color.colors import * +from color.spaces import * + +def get_A(x): + return np.array([[x[0][0], x[0][1], x[0][2], 0, 0, 0, 0, 0, 0, 1, 0, 0], + [0, 0, 0, x[0][0], x[0][1], x[0][2], 0, 0, 0, 0, 1, 0], + [0, 0, 0, 0, 0, 0, x[0][0], x[0][1], x[0][2], 0, 0, 1], + [x[1][0], x[1][1], x[1][2], 0, 0, 0, 0, 0, 0, 1, 0, 0], + [0, 0, 0, x[1][0], x[1][1], x[1][2], 0, 0, 0, 0, 1, 0], + [0, 0, 0, 0, 0, 0, x[1][0], x[1][1], x[1][2], 0, 0, 1], + [x[2][0], x[2][1], x[2][2], 0, 0, 0, 0, 0, 0, 1, 0, 0], + [0, 0, 0, x[2][0], x[2][1], x[2][2], 0, 0, 0, 0, 1, 0], + [0, 0, 0, 0, 0, 0, x[2][0], x[2][1], x[2][2], 0, 0, 1], + [x[3][0], x[3][1], x[3][2], 0, 0, 0, 0, 0, 0, 1, 0, 0], + [0, 0, 0, x[3][0], x[3][1], x[3][2], 0, 0, 0, 0, 1, 0], + [0, 0, 0, 0, 0, 0, x[3][0], x[3][1], x[3][2], 0, 0, 1] ]) + +def get_B(y): + return np.array([[y[0][0]], [y[0][1]], [y[0][2]], + [y[1][0]], [y[1][1]], [y[1][2]], + [y[2][0]], [y[2][1]], [y[2][2]], + [y[3][0]], [y[3][1]], [y[3][2]] ]) + +def color_row(space, color): + x1,x2,x3 = space.getCoords(color) + return np.array([x1,x2,x3]) + +def color_column(space, color): + x1,x2,x3 = space.getCoords(color) + return np.array([[x1],[x2],[x3]]) + +def colors_array(space, *colors): + return np.array([color_row(space, c) for c in colors]) + +def find_transform_colors(space, cx1, cx2, cx3, cx4, cy1, cy2, cy3, cy4): + x = colors_array(space, cx1, cx2, cx3, cx4) + y = colors_array(space, cy1, cy2, cy3, cy4) + return find_transform(x,y) + +def find_transform(x, y): + #print("X:\n"+str(x)) + #print("Y:\n"+str(y)) + m = solve(get_A(x), get_B(y)) + a = np.array([[m[0][0], m[1][0], m[2][0]], + [m[3][0], m[4][0], m[5][0]], + [m[6][0], m[7][0], m[8][0]]]) + b = np.array([[m[9][0]], [m[10][0]], [m[11][0]]]) + return (a, b) + +def transform_colors(space, a, b, cx): + x = color_column(space, cx) + y = a.dot(x) + b + #print("X: " + str(x)) + #print("Y: " + str(y)) + return space.fromCoords(y[0][0], y[1][0], y[2][0]) + +def transform(a, b, x): + #print(type(x)) + return a.dot(x[:,None]) + b + +def rhoC(space, color1, color2): + x1,y1,z1 = space.getCoords(color1) + x2,y2,z2 = space.getCoords(color2) + return sqrt((x1-x2)**2 + (y1-y2)**2 + (z1-z2)**2) + +def rho(c1, c2): + x1,y1,z1 = c1[0], c1[1], c1[2] + x2,y2,z2 = c2[0], c2[1], c2[2] + return sqrt((x1-x2)**2 + (y1-y2)**2 + (z1-z2)**2) + +def get_center(points): + zero = np.array([0,0,0]) + return sum(points, zero) / float(len(points)) + +def get_center_color(space, colors): + zero = np.array([0,0,0]) + points = [space.getCoords(c) for c in colors] + center = sum(points, zero) / float(len(points)) + c1,c2,c3 = center[0], center[1], center[2] + return space.fromCoords((c1,c2,c3)) + +def get_nearest(x, occupied, points): + min_rho = None + min_p = None + for p in points: + if any([(p == o).all() for o in occupied]): + continue + r = rho(x,p) + if min_rho is None or r < min_rho: + min_rho = r + min_p = p + if min_p is not None: + occupied.append(min_p) + #print("Now occupied : " + str(x)) + return min_p + #print("Was occupied : " + str(x)) + for p in points: + r = rho(x,p) + if min_rho is None or r < min_rho: + min_rho = r + min_p = p + return min_p + +def get_nearest_color(space, occupied, cx, colors): + points = [space.getCoords(c) for c in colors] + cy = get_nearest(space.getCoords(cx), occupied, points) + return space.fromCoords((cy[0], cy[1], cy[2])) + +def exclude(lst, x): + return [p for p in lst if not (p == x).all()] + +def get_farest(points): + #center = get_center(points) + #print(str(center)) + points_ = copy(points) + darkest = min(points_, key = lambda p: p[2]) + points_ = exclude(points_, darkest) + lightest = max(points_, key = lambda p: p[2]) + points_ = exclude(points_, lightest) + grayest = min(points_, key = lambda p: p[1]) + points_ = exclude(points_, grayest) + most_saturated = max(points_, key = lambda p: p[1]) + return [darkest, lightest, grayest, most_saturated] + #srt = sorted(points, key = lambda c: -rho(center, c)) + #return srt[:4] + +def get_farest_colors(space, colors): + points = [space.getCoords(c) for c in colors] + farest = get_farest(points) + return [space.fromCoords(c) for c in farest] + +def match_colors_(space, colors1, colors2): + try: + points1 = [color_row(space, c) for c in colors1 if c is not None] + points2 = [color_row(space, c) for c in colors2 if c is not None] + farest1 = get_farest(points1) + farest2 = get_farest(points2) + a, b = find_transform(farest1, farest2) + #print("A:\n" + str(a)) + #print("B:\n" + str(b)) + print("Matching colors:") + for p1, p2 in zip(farest1, farest2): + print(" {} -> {}".format(p1,p2)) + transformed = [transform(a, b, x) for x in points1] + occupied = [] + matched = [] + for x in transformed: + y = get_nearest(x, occupied, points2) + matched.append(y) + return [space.fromCoords(x) for x in matched] + except LinAlgError as e: + print e + return colors1 + +def match_colors(space, colors1, colors2): + points1 = [color_row(space, c) for c in colors1 if c is not None] + points2 = [color_row(space, c) for c in colors2 if c is not None] + c1 = get_center(points1) + c2 = get_center(points2) + #print c1, c2 + print("Centers difference: {}".format(c2-c1)) + szs1 = np.vstack([abs(c1-p) for p in points1]).max(axis=0) + szs2 = np.vstack([abs(c2-p) for p in points2]).max(axis=0) + if (szs1 == 0).any(): + zoom = np.array([1,1,1]) + else: + zoom = szs2 / szs1 + print("Scale by axes: {}".format(zoom)) + transformed = [(p-c1)*zoom + c2 for p in points1] + occupied = [] + matched = [] + deltas = [] + for x in transformed: + y = get_nearest(x, occupied, points2) + matched.append(y) + deltas.append(abs(y-x)) + delta = np.vstack(deltas).max(axis=0) + print("Maximum deltas from affine transformed to source colors: {}".format(delta)) + return [space.fromCoords(x) for x in matched] + +def find_simple_transform(space, colors1, colors2): + points1 = [color_row(space, c) for c in colors1 if c is not None] + points2 = [color_row(space, c) for c in colors2 if c is not None] + c1 = get_center(points1) + c2 = get_center(points2) + #transfer = c2 - c1 + szs1 = max([abs(c1-p) for p in points1]) + szs2 = max([abs(c2-p) for p in points2]) + if (szs1 == 0).any(): + zoom = np.array([1,1,1]) + else: + zoom = szs2 / szs1 + return (c1, c2, zoom) + +if __name__ == "__main__": + + x1 = Color(0,0,0) + x2 = Color(0,10,0) + x3 = Color(0,0,20) + x4 = Color(20,0,10) + x5 = Color(10,10,10) + x6 = Color(0,20,0) + x7 = Color(0,0,40) + x8 = Color(40,0,20) + + y1 = Color(0,0,0) + y2 = Color(0,10,0) + y3 = Color(0,0,20) + y4 = Color(20,0,10) + y5 = Color(10,10,10) + y6 = Color(0,20,0) + y7 = Color(0,0,40) + y8 = Color(40,0,20) + + res = match_colors(RGB, [x1,x2,x3,x4,x5,x6,x7,x8], [y1,y2,y3,y4,y5,y6,y7,y8]) + print([c.getRGB() for c in res]) + + diff --git a/extensions/fablabchemnitz/color_harmony/color_harmony/open_palette.py b/extensions/fablabchemnitz/color_harmony/color_harmony/open_palette.py new file mode 100644 index 0000000..dd795fd --- /dev/null +++ b/extensions/fablabchemnitz/color_harmony/color_harmony/open_palette.py @@ -0,0 +1,17 @@ +def load_palette(filename, mixer=None, options=None): + if mixer is None: + mixer = mixers.MixerRGB + loader = detect_storage(filename) + if loader is None: + return None + palette = loader().load(mixer, filename, options) + return palette + +def save_palette(palette, path, formatname=None): + if formatname is not None: + loader = get_storage_by_name(formatname) + else: + loader = detect_storage(path, save=True) + if loader is None: + raise RuntimeError("Unknown file type!") + loader(palette).save(path) diff --git a/extensions/fablabchemnitz/color_harmony/color_harmony/palette/palette.py b/extensions/fablabchemnitz/color_harmony/color_harmony/palette/palette.py new file mode 100644 index 0000000..698773c --- /dev/null +++ b/extensions/fablabchemnitz/color_harmony/color_harmony/palette/palette.py @@ -0,0 +1,352 @@ + +from os.path import join, basename + +from color.colors import * +from color import mixers +from models.meta import Meta + +NONE = 0 +USER_DEFINED = 1 +VERTICALLY_GENERATED = 2 +HORIZONTALLY_GENERATED = 3 +DEFAULT_GROUP_SIZE = 7 +MAX_COLS = 10 + +class Slot(object): + def __init__(self, color=None, name=None, user_defined=False): + self._color = color + self._mode = NONE + self._user_defined = user_defined + self._src_slot1 = None + self._src_row1 = None + self._src_col1 = None + self._src_slot2 = None + self._src_row2 = None + self._src_col2 = None + if name: + self.name = name + + def get_name(self): + if self._color: + return self._color.name + else: + return None + + def set_name(self, name): + if self._color: + self._color.name = name + + name = property(get_name, set_name) + + def __repr__(self): + return "".format(self._mode) + + def getColor(self): + if self._color is None: + return Color(1,1,1) + else: + return self._color + + def setColor(self, color): + self._color = color + + color = property(getColor, setColor) + + def getMode(self): + if self._user_defined: + return USER_DEFINED + else: + return self._mode + + def setMode(self, mode): + self._mode = mode + if mode == USER_DEFINED: + self._user_defined = True + self._mode = NONE + + mode = property(getMode, setMode) + + def mark(self, user_defined=None): + if user_defined is None: + user_defined = not self._user_defined + #print("Mark: " + str(user_defined)) + self._user_defined = user_defined + + def setSources(self, slot1, row1, col1, slot2, row2, col2): + self._src_slot1 = slot1 + self._src_row1 = row1 + self._src_col1 = col1 + self._src_slot2 = slot2 + self._src_row2 = row2 + self._src_col2 = col2 + #print("Sources: ({},{}) and ({},{})".format(row1,col1, row2,col2)) + + def getSource1(self): + return self._src_slot1, self._src_row1, self._src_col1 + + def getSource2(self): + return self._src_slot2, self._src_row2, self._src_col2 + +class Palette(object): + def __init__(self, mixer, nrows=1, ncols=7): + self.mixer = mixer + self.nrows = nrows + self.ncols = ncols + self.slots = [[Slot() for i in range(ncols)] for j in range(nrows)] + self.need_recalc_colors = True + self.meta = Meta() + + def get_name(self): + return self.meta.get("Name", "Untited") + + def set_name(self, name): + self.meta["Name"] = name + + name = property(get_name, set_name) + + def mark_color(self, row, column, mark=None): + print("Marking color at ({}, {})".format(row,column)) + slot = self.slots[row][column] + slot.mark(mark) + self.need_recalc_colors = True + self.recalc() + + def setMixer(self, mixer): + self.mixer = mixer + self.recalc() + + def del_column(self, col): + if self.ncols < 2: + return + new = [] + for row in self.slots: + new_row = [] + for c, slot in enumerate(row): + if c != col: + new_row.append(slot) + new.append(new_row) + self.slots = new + self.ncols -= 1 + self.recalc() + + def add_column(self, col): + new = [] + for row in self.slots: + new_row = [] + for c, slot in enumerate(row): + if c == col: + new_row.append(Slot()) + new_row.append(slot) + new.append(new_row) + self.slots = new + self.ncols += 1 + self.recalc() + + def del_row(self, row): + if self.nrows < 2: + return + new = [] + for r,row_ in enumerate(self.slots): + if r != row: + new.append(row_) + self.slots = new + self.nrows -= 1 + self.recalc() + + def add_row(self, row): + new = [] + for r,row_ in enumerate(self.slots): + if r == row: + new_row = [Slot() for k in range(self.ncols)] + new.append(new_row) + new.append(row_) + self.slots = new + self.nrows += 1 + self.recalc() + + def paint(self, row, column, color): + self.slots[row][column].color = color + self.slots[row][column].mark(True) + #self.recalc() + + def erase(self, row, column): + self.paint(row, column, Color(0,0,0)) + + def getUserDefinedSlots(self): + """Returns list of tuples: (row, column, slot).""" + result = [] + for i,row in enumerate(self.slots): + for j,slot in enumerate(row): + if slot.mode == USER_DEFINED: + result.append((i,j,slot)) + return result + + def getColor(self, row, column): + if self.need_recalc_colors: + self.recalc() + try: + return self.slots[row][column].color + except IndexError: + #print("Cannot get color ({},{}): size is ({},{})".format(row,column, self.nrows, self.ncols)) + return Color(255,255,255) + + def getColors(self): + if self.need_recalc_colors: + self.recalc() + return [[slot.color for slot in row] for row in self.slots] + + def setSlots(self, all_slots): + m = len(all_slots) % self.ncols + if m != 0: + for i in range(self.ncols - m): + all_slots.append(Slot(Color(255,255,255))) + self.slots = [] + row = [] + for i, slot in enumerate(all_slots): + if i % self.ncols == 0: + if len(row) != 0: + self.slots.append(row) + row = [] + row.append(slot) + self.slots.append(row) + self.nrows = len(self.slots) + self.need_recalc_colors = True + self.recalc() +# print(self.slots) + + def user_chosen_slot_down(self, i,j): + """Returns tuple: + * Is slot found + * Row of found slot + * Column of found slot""" + + #print("Searching down ({},{})".format(i,j)) + for i1 in range(i, self.nrows): + try: + slot = self.slots[i1][j] + #print("Down: check ({},{}): {}".format(i1,j,slot)) + if slot.mode == USER_DEFINED: + #print("Found down ({},{}): ({},{})".format(i,j, i1,j)) + return True,i1,j + except IndexError: + print("Cannot get slot at ({}, {})".format(i,j)) + return False, self.nrows-1,j + return False, self.nrows-1,j + + def user_chosen_slot_up(self, i,j): + """Returns tuple: + * Is slot found + * Row of found slot + * Column of found slot""" + + for i1 in range(i-1, -1, -1): + if self.slots[i1][j].mode == USER_DEFINED: + #print("Found up ({},{}): ({},{})".format(i,j, i1,j)) + return True,i1,j + return False, 0, j + + def fixed_slot_right(self, i,j): + """Returns tuple: + * Mode of found slot + * Row of found slot + * Column of found slot""" + + for j1 in range(j, self.ncols): + if self.slots[i][j1].mode in [USER_DEFINED, VERTICALLY_GENERATED]: + return self.slots[i][j1].mode, i,j1 + return NONE, i,self.ncols-1 + + def fixed_slot_left(self, i,j): + """Returns tuple: + * Mode of found slot + * Row of found slot + * Column of found slot""" + + for j1 in range(j-1, -1, -1): + if self.slots[i][j1].mode in [USER_DEFINED, VERTICALLY_GENERATED]: + return self.slots[i][j1].mode, i,j1 + return NONE, i,0 + + def recalc(self): + self._calc_modes() + self._calc_modes() + self._calc_modes() + self._calc_colors() + self.need_recalc_colors = False + + def _calc_modes(self): + for i,row in enumerate(self.slots): + for j,slot in enumerate(row): + if slot.mode == USER_DEFINED: + continue + # Should slot be vertically generated? + v1,iv1,jv1 = self.user_chosen_slot_down(i,j) + v2,iv2,jv2 = self.user_chosen_slot_up(i,j) + h1,ih1,jh1 = self.fixed_slot_left(i,j) + h2,ih2,jh2 = self.fixed_slot_right(i,j) + if v1 and v2: # if there are user chosen slots above and below current + slot.mode = VERTICALLY_GENERATED + s1 = self.slots[iv1][jv1] + s2 = self.slots[iv2][jv2] + slot.setSources(s1, iv1, jv1, s2, iv2, jv2) + elif ((v1 and j-jv1 > 1) or (v2 and jv2-j > 1)) and ((h1!=USER_DEFINED) or (h2!=USER_DEFINED)): + slot.mode = VERTICALLY_GENERATED + s1 = self.slots[iv1][jv1] + s2 = self.slots[iv2][jv2] + slot.setSources(s1, iv1, jv1, s2, iv2, jv2) + elif h1 and h2: + # if there are fixed slots at left and at right of current + slot.mode = HORIZONTALLY_GENERATED + s1 = self.slots[ih1][jh1] + s2 = self.slots[ih2][jh2] + slot.setSources(s1, ih1, jh1, s2, ih2, jh2) + elif (h1 or h2) and not (v1 or v2): + slot.mode = HORIZONTALLY_GENERATED + s1 = self.slots[ih1][jh1] + s2 = self.slots[ih2][jh2] + slot.setSources(s1, ih1, jh1, s2, ih2, jh2) + else: + slot.mode = HORIZONTALLY_GENERATED + s1 = self.slots[ih1][jh1] + s2 = self.slots[ih2][jh2] + slot.setSources(s1, ih1, jh1, s2, ih2, jh2) + + def color_transition(self, from_color, to_color, steps, idx): + if self.mixer is None: + return Color(1,1,1) + q = float(idx+1) / float(steps+1) + return self.mixer.mix(from_color, to_color, q) + + def _calc_colors(self): + for i,row in enumerate(self.slots): + for j,slot in enumerate(row): + if slot.mode == VERTICALLY_GENERATED: + slot_down, iv1, jv1 = slot.getSource1() + slot_up, iv2, jv2 = slot.getSource2() + clr_down = slot_down.color + clr_up = slot_up.color + length = iv1-iv2 - 1 + idx = i-iv2 - 1 + try: + #print("Mixing ({},{}) with ({},{}) to get ({},{})".format(iv1,jv1, iv2,jv2, i,j)) + clr = self.color_transition(clr_up,clr_down,length, idx) + except IndexError: + clr = Color(1,1,1) + slot.color = clr + + for i,row in enumerate(self.slots): + for j,slot in enumerate(row): + if slot.mode == HORIZONTALLY_GENERATED: + slot_left, ih1, jh1 = slot.getSource1() + slot_right, ih2, jh2 = slot.getSource2() + clr_left = slot_left.color + clr_right = slot_right.color + length = jh2-jh1 - 1 + idx = j-jh1 - 1 + try: + #print("Mixing ({},{}) with ({},{}) to get ({},{})".format(ih1,jh1, ih2,jh2, i,j)) + clr = self.color_transition(clr_left,clr_right,length, idx) + except IndexError: + clr = Color(1,1,1) + slot.color = clr + diff --git a/extensions/fablabchemnitz/color_harmony/color_harmony/shades.py b/extensions/fablabchemnitz/color_harmony/color_harmony/shades.py new file mode 100644 index 0000000..7f2a9f1 --- /dev/null +++ b/extensions/fablabchemnitz/color_harmony/color_harmony/shades.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python +# coding=utf-8 +# +# Copyright (C) 2009-2018 Ilya Portnov +# (original 'palette-editor' tool, version 0.0.7) +# 2020 Maren Hachmann (extension-ification) +# +# 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. + +from color_harmony.colorplus import ColorPlus +from color_harmony.utils import clip, seq, variate + +# Shading functions + +# 4 cooler colors +def cooler(color, parameter): + h,s,v = color.to_hsv() + if h < 1.0/6.0: + sign = -1.0 + elif h > 2.0/3.0: + sign = -1.0 + else: + sign = 1.0 + step = 0.1 * parameter + result = [] + for i in range(4): + h += sign * step + if h > 1.0: + h -= 1.0 + elif h < 0.0: + h += 1.0 + result.append(ColorPlus.from_hsv(h, s, v)) + return result + +# 4 warmer colors +def warmer(color, parameter): + h,s,v = color.to_hsv() + if h < 1.0/6.0: + sign = +1.0 + elif h > 2.0/3.0: + sign = +1.0 + else: + sign = -1.0 + step = 0.1 * parameter + result = [] + for i in range(4): + h += sign * step + if h > 1.0: + h -= 1.0 + elif h < 0.0: + h += 1.0 + result.append(ColorPlus.from_hsv(h, s, v)) + return result + +# returns 2 less saturated and 2 more saturated colors +def saturation(color, parameter): + h, s, v = color.to_hsv() + ss = [clip(x) for x in variate(s, 0.6*parameter, 1.2*parameter)] + # we don't want another copy of the original color + del ss[2] + return [ColorPlus.from_hsv(h, s, v) for s in ss] + +# 2 colors with higher value, and 2 with lower +def value(color, parameter): + h, s, v = color.to_hsv() + vs = [clip(x) for x in variate(v, 0.4*parameter, 0.8*parameter)] + del vs[2] + return [ColorPlus.from_hsv(h, s, v) for v in vs] + +# 2 colors with higher chroma, and 2 with lower +def chroma(color, parameter): + h, c, y = color.to_hcy() + cs = [clip(x) for x in variate(c, 0.4*parameter, 0.8*parameter)] + del cs[2] + return [ColorPlus.from_hcy(h, c, y) for c in cs] + +# 2 colors with higher luma, and 2 with lower +def luma(color, parameter): + h, c, y = color.to_hcy() + ys = [clip(x) for x in variate(y, 0.3*parameter, 0.6*parameter)] + del ys[2] + return [ColorPlus.from_hcy(h, c, y) for y in ys] + +# 2 colors with hue rotated to the left, and 2 rotated to the right +def hue(color, parameter): + h, c, y = color.to_hcy() + hs = [clip(x) for x in variate(h, 0.15*parameter, 0.3*parameter)] + del hs[2] + return [ColorPlus.from_hcy(h, c, y) for h in hs] + +def hue_luma(color, parameter): + h, c, y = color.to_hcy() + hs = [clip(x) for x in variate(h, 0.15*parameter, 0.3*parameter)] + ys = [clip(x) for x in variate(y, 0.3*parameter, 0.6*parameter)] + del ys[2] + del hs[2] + return [ColorPlus.from_hcy(h, c, y) for h,y in zip(hs, ys)] + +def luma_plus_chroma(color, parameter): + h, c, y = color.to_hcy() + cs = [clip(x) for x in variate(c, 0.4*parameter, 0.8*parameter)] + ys = [clip(x) for x in variate(y, 0.3*parameter, 0.6*parameter)] + del cs[2] + del ys[2] + return [ColorPlus.from_hcy(h, c, y) for c,y in zip(cs, ys)] + +def luma_minus_chroma(color, parameter): + h, c, y = color.to_hcy() + cs = [clip(x) for x in variate(c, 0.4*parameter, 0.8*parameter)] + ys = list(reversed([clip(x) for x in variate(y, 0.3*parameter, 0.6*parameter)])) + del cs[2] + del ys[2] + return [ColorPlus.from_hcy(h, c, y) for c,y in zip(cs, ys)] diff --git a/extensions/fablabchemnitz/color_harmony/color_harmony/storage/__init__.py b/extensions/fablabchemnitz/color_harmony/color_harmony/storage/__init__.py new file mode 100644 index 0000000..1bb8bf6 --- /dev/null +++ b/extensions/fablabchemnitz/color_harmony/color_harmony/storage/__init__.py @@ -0,0 +1 @@ +# empty diff --git a/extensions/fablabchemnitz/color_harmony/color_harmony/storage/cluster.py b/extensions/fablabchemnitz/color_harmony/color_harmony/storage/cluster.py new file mode 100644 index 0000000..8ff0c81 --- /dev/null +++ b/extensions/fablabchemnitz/color_harmony/color_harmony/storage/cluster.py @@ -0,0 +1,180 @@ + +from math import sqrt, isnan +import numpy as np + +try: + from sklearn.cluster import MeanShift, estimate_bandwidth + from sklearn.utils import shuffle + + cluster_analysis_available = True + print("Cluster analysis is available") +except ImportError: + cluster_analysis_available = False + print("Cluster analysis is not available") + +try: + import Image + pil_available = True + print("PIL is available") +except ImportError: + print("PIL is not available") + try: + from PIL import Image + print("Pillow is available") + pil_available = True + except ImportError: + print("Neither PIL or Pillow are available") + pil_available = False + +from color.colors import * + +if pil_available: + + def imread(filename): + img = Image.open(filename) + +class Box(object): + def __init__(self, arr): + self._array = arr + + def population(self): + return self._array.size + + def axis_size(self, idx): + slice_ = self._array[:, idx] + M = slice_.max() + m = slice_.min() + return M - m + + def biggest_axis(self): + sizes = [self.axis_size(i) for i in range(3)] + return max(range(3), key = lambda i: sizes[i]) + + def mean(self): + return self._array.mean(axis=0) + + def mean_color(self): + size = self._array.size + if not size: + return None + xs = self._array.mean(axis=0) + x,y,z = xs[0], xs[1], xs[2] + if isnan(x) or isnan(y) or isnan(z): + return None + return Color(int(x), int(y), int(z)) + + def div_pos(self, idx): + slice_ = self._array[:, idx] + M = slice_.max() + m = slice_.min() + return (m+M)/2.0 + + def divide(self): + axis = self.biggest_axis() + q = self.div_pos(axis) + idxs = self._array[:, axis] > q + smaller = self._array[~idxs] + bigger = self._array[idxs] + self._array = smaller + return Box(bigger) + +if pil_available and cluster_analysis_available: + + # Use Means Shift algorithm for cluster analysis + + def cluster_analyze(filename, N=1000): + image = imread(filename) + w,h,d = tuple(image.shape) + image_array = np.array( np.reshape(image, (w * h, d)), dtype=np.float64 ) + #if image.dtype == 'uint8': + # image_array = image_array / 255.0 + image_array_sample = shuffle(image_array, random_state=0)[:N] + bandwidth = estimate_bandwidth(image_array_sample, quantile=0.01, n_samples=500) + ms = MeanShift(bandwidth=bandwidth, bin_seeding=True) + ms.fit(image_array_sample) + cluster_centers = ms.cluster_centers_ + n_clusters = len(cluster_centers) + colors = [] + print("Number of clusters: {}".format(n_clusters)) + for x in cluster_centers: + #print x + clr = Color() + clr.setRGB1((x[0], x[1], x[2])) + colors.append(clr) + return colors + + +if pil_available: + +# Use very fast algorithm for image analysis, translated from Krita's kis_common_colors_recalculation_runner.cpp +# Do not know exactly why does this algorithm work, but it does. +# Initial (C) Adam Celarek + + def bin_divide_colors(filename, N=1<<16, n_clusters=49): + img = Image.open(filename) + if img.mode == 'P': + img = img.convert('RGB') + w,h = img.size + n_pixels = w*h + if n_pixels > N: + ratio = sqrt( float(N) / float(n_pixels) ) + w,h = int(w*ratio), int(h*ratio) + img = img.resize((w,h)) + + image = np.array(img) + w,h,d = tuple(image.shape) + colors = np.array( np.reshape(image, (w * h, d)), dtype=np.float64 ) + #if image.dtype == 'uint8': + # colors = colors / 255.0 + colors = colors[:,0:3] + + boxes = [Box(colors)] + + while (len(boxes) < n_clusters * 3/5) and (len(colors) > n_clusters * 3/5): + biggest_box = None + biggest_box_population = None + + for box in boxes: + population = box.population() + if population <= 3: + continue + if biggest_box_population is None or (population > biggest_box_population and box.axis_size(box.biggest_axis()) >= 3): + biggest_box = box + biggest_box_population = population + + if biggest_box is None or biggest_box.population() <= 3: + break + + new_box = biggest_box.divide() + boxes.append(new_box) + + while (len(boxes) < n_clusters) and (len(colors) > n_clusters): + biggest_box = None + biggest_box_axis_size = None + + for box in boxes: + if box.population() <= 3: + continue + size = box.axis_size(box.biggest_axis()) + if biggest_box_axis_size is None or (size > biggest_box_axis_size and size >= 3): + biggest_box = box + biggest_box_axis_size = size + + if biggest_box is None or biggest_box.population() <= 3: + break + + new_box = biggest_box.divide() + boxes.append(new_box) + + result = [box.mean_color() for box in boxes if box.mean_color() is not None] + return result + +image_loading_supported = pil_available or cluster_analysis_available + +if pil_available: + get_common_colors = bin_divide_colors + use_sklearn = False +elif cluster_analysis_available : + get_common_colors = cluster_analyze + use_sklearn = True + diff --git a/extensions/fablabchemnitz/color_harmony/color_harmony/storage/css.py b/extensions/fablabchemnitz/color_harmony/color_harmony/storage/css.py new file mode 100644 index 0000000..9936f45 --- /dev/null +++ b/extensions/fablabchemnitz/color_harmony/color_harmony/storage/css.py @@ -0,0 +1,83 @@ + +from os.path import join, basename +from math import sqrt, floor + +from color.colors import * +from color import mixers +from palette.storage.storage import * + +try: + from tinycss import make_parser, color3 + css_support = True +except ImportError: + print("TinyCSS is not available") + css_support = False + +class CSS(Storage): + name = 'css' + title = _("CSS cascading stylesheet") + filters = ["*.css"] + can_load = css_support + can_save = True + + @staticmethod + def check(filename): + return True + + def save(self, file_w): + pf = open(file_w, 'w') + + for i, row in enumerate(self.palette.slots): + for j, slot in enumerate(row): + user = "-user" if slot.mode == USER_DEFINED else "" + hex = slot.color.hex() + s = ".color-{}-{}{} {{ color: {} }};\n".format(i,j, user, hex) + pf.write(s) + + pf.close() + + def load(self, mixer, file_r, options=None): + self.palette = Palette(mixer) + self.palette.ncols = None + + all_slots = [] + colors = [] + + def add_color(clr): + for c in colors: + if c.getRGB() == clr.getRGB(): + return None + colors.append(clr) + return clr + + parser = make_parser('page3') + css = parser.parse_stylesheet_file(file_r) + for ruleset in css.rules: + #print ruleset + if ruleset.at_keyword: + continue + for declaration in ruleset.declarations: + #print declaration + for token in declaration.value: + #print token + css_color = color3.parse_color(token) + if not isinstance(css_color, color3.RGBA): + continue + r,g,b = css_color.red, css_color.green, css_color.blue + color = Color() + color.setRGB1((clip(r), clip(g), clip(b))) + color = add_color(color) + if not color: + continue + slot = Slot(color, user_defined=True) + all_slots.append(slot) + n_colors = len(all_slots) + if n_colors > MAX_COLS: + self.palette.ncols = max( int( floor(sqrt(n_colors)) ), 1) + else: + self.palette.ncols = n_colors + self.palette.setSlots(all_slots) + self.palette.meta["SourceFormat"] = "CSS" + print("Loaded palette: {}x{}".format( self.palette.nrows, self.palette.ncols )) + return self.palette + diff --git a/extensions/fablabchemnitz/color_harmony/color_harmony/storage/gimp.py b/extensions/fablabchemnitz/color_harmony/color_harmony/storage/gimp.py new file mode 100644 index 0000000..f82b330 --- /dev/null +++ b/extensions/fablabchemnitz/color_harmony/color_harmony/storage/gimp.py @@ -0,0 +1,188 @@ + +from os.path import join, basename +from PyQt4 import QtGui +import re + +from color.colors import * +from color import mixers +from palette.storage.storage import * + +marker = '# Colors not marked with #USER are auto-generated' + +metare = re.compile("^# (\\w+): (.+)") + +def save_gpl(name, ncols, clrs, file_w): + if type(file_w) in [str,unicode]: + pf = open(file_w, 'w') + do_close = True + elif hasattr(file_w,'write'): + pf = file_w + do_close = False + else: + raise ValueError("Invalid argument type in save_gpl: {}".format(type(file_w))) + pf.write('GIMP Palette\n') + pf.write(u"Name: {}\n".format(name).encode('utf-8')) + if ncols is not None: + pf.write('Columns: %s\n' % ncols) + for color in clrs: + r, g, b = color.getRGB() + n = 'Untitled' + s = '%d %d %d %s\n' % (r, g, b, n) + pf.write(s) + if do_close: + pf.close() + +class GimpPalette(Storage): + + name = 'gpl' + title = _("Gimp palette") + filters = ["*.gpl"] + can_save = True + can_load = True + + @staticmethod + def get_options_widget(dialog, filename): + + def on_columns_changed(n): + dialog.options = n + dialog.on_current_changed(filename) + + ncols = None + pf = open(filename,'r') + l = pf.readline().strip() + if l != 'GIMP Palette': + pf.close() + return None + for line in pf: + line = line.strip() + lst = line.split() + if lst[0]=='Columns:': + ncols = int( lst[1] ) + break + pf.close() + + widget = QtGui.QWidget() + box = QtGui.QHBoxLayout() + label = QtGui.QLabel(_("Columns: ")) + spinbox = QtGui.QSpinBox() + spinbox.setMinimum(2) + spinbox.setMaximum(100) + if ncols is None: + ncols = MAX_COLS + spinbox.setValue(ncols) + box.addWidget(label) + box.addWidget(spinbox) + spinbox.valueChanged.connect(on_columns_changed) + widget.setLayout(box) + return widget + + def save(self, file_w=None): + if type(file_w) in [str,unicode]: + pf = open(file_w, 'w') + do_close = True + elif hasattr(file_w,'write'): + pf = file_w + do_close = False + else: + raise ValueError("Invalid argument type in GimpPalette.save: {}".format(type(file_w))) + pf.write('GIMP Palette\n') + if not hasattr(self.palette, 'name'): + if type(file_w) in [str, unicode]: + self.palette.name = basename(file_w) + else: + self.palette.name='Colors' + pf.write(u"Name: {}\n".format(self.palette.name).encode('utf-8')) + if hasattr(self.palette, 'ncols') and self.palette.ncols: + pf.write('Columns: %s\n' % self.palette.ncols) + pf.write(marker+'\n') + for key,value in self.palette.meta.items(): + if key != "Name": + pf.write(u"# {}: {}\n".format(key, value).encode('utf-8')) + pf.write('#\n') + for row in self.palette.slots: + for slot in row: + if slot.mode == USER_DEFINED: + n = slot.name + ' #USER' + else: + n = slot.name + r, g, b = slot.color.getRGB() + s = '%d %d %d %s\n' % (r, g, b, n) + pf.write(s) + for key,value in slot.color.meta.items(): + if key != "Name": + pf.write(u"# {}: {}\n".format(key, value).encode('utf-8')) + if do_close: + pf.close() + + def load(self, mixer, file_r, force_ncols=None): + self.palette = Palette(mixer) + self.palette.ncols = None + if not file_r: + palette.filename = None + palette.name = 'Gimp' + return + elif hasattr(file_r,'read'): + pf = file_r + self.palette.filename = None + do_close = False + elif type(file_r) in [str,unicode]: + pf = open(file_r) + self.palette.filename = file_r + do_close = True + l = pf.readline().strip() + if l != 'GIMP Palette': + raise SyntaxError, "Invalid palette file!" + self.palette.name = " ".join(pf.readline().strip().split()[1:]) + all_user = True + n_colors = 0 + all_slots = [] + reading_header = True + for line in pf: + line = line.strip() + if line==marker: + all_user = False + meta_match = metare.match(line) + if meta_match is not None: + key = meta_match.group(1) + value = meta_match.group(2) + if reading_header: + self.palette.meta[key] = value + else: + clr.meta[key] = value + continue + if line.startswith('#'): + continue + lst = line.split() + if lst[0]=='Columns:': + self.palette.ncols = int( lst[1] ) + if len(lst) < 3: + continue + rs,gs,bs = lst[:3] + clr = Color(float(rs), float(gs), float(bs)) + reading_header = False + #print(str(clr)) + slot = Slot(clr) + n_colors += 1 + if all_user or lst[-1]=='#USER': + slot.mode = USER_DEFINED + name = ' '.join(lst[3:-1]) + else: + name = ' '.join(lst[3:]) + slot.name = name + all_slots.append(slot) + if do_close: + pf.close() + if n_colors < DEFAULT_GROUP_SIZE: + self.palette.ncols = n_colors + if not self.palette.ncols: + if n_colors > MAX_COLS: + self.palette.ncols = MAX_COLS + else: + self.palette.ncols = n_colors + if force_ncols is not None: + self.palette.ncols = force_ncols + self.palette.setSlots(all_slots) + self.palette.meta["SourceFormat"] = "Gimp gpl" if all_user else "palette_editor gpl" + print("Loaded palette: {}x{}".format( self.palette.nrows, self.palette.ncols )) + return self.palette + diff --git a/extensions/fablabchemnitz/color_harmony/color_harmony/storage/image.py b/extensions/fablabchemnitz/color_harmony/color_harmony/storage/image.py new file mode 100644 index 0000000..5178e93 --- /dev/null +++ b/extensions/fablabchemnitz/color_harmony/color_harmony/storage/image.py @@ -0,0 +1,201 @@ + +from os.path import join, basename, splitext +from math import sqrt, floor +from PyQt4 import QtGui + +from color.colors import * +from color import spaces +from color import mixers +from palette.image import PaletteImage +from palette.palette import * +from palette.storage.storage import * +from palette.storage.cluster import * +from palette.storage.table import parse_color_table +from matching.transform import rho, get_center + +LOAD_MORE = 1 +LOAD_LESS_COMMON = 2 +LOAD_LESS_FAREST = 3 +LOAD_TABLE = 4 + +print("Ok") + +class DialogOptions(object): + def __init__(self, method): + self.method = method + self.border_x = 10 + self.border_y = 10 + self.gap_x = 10 + self.gap_y = 10 + self.size_x = 5 + self.size_y = 5 + +class Image(Storage): + name = 'image' + title = _("Raster image") + filters = ["*.jpg", "*.png", "*.gif"] + can_load = image_loading_supported + can_save = True + + @staticmethod + def check(filename): + return True + + @staticmethod + def get_options_widget(dialog, filename): + if use_sklearn: + return None + + def dependencies(): + if dialog.options is None or dialog.options.method != LOAD_TABLE: + table_w.setVisible(False) + else: + table_w.setVisible(True) + + def on_method_changed(checked): + method = None + if dialog._more_button.isChecked(): + method = LOAD_MORE + elif dialog._less_button.isChecked(): + method = LOAD_LESS_COMMON + elif dialog._less_farest.isChecked(): + method = LOAD_LESS_FAREST + elif dialog._table.isChecked(): + method = LOAD_TABLE + dialog.options = DialogOptions(method) + if method == LOAD_TABLE: + dialog.options.border_x = dialog._border_x.value() + dialog.options.border_y = dialog._border_y.value() + dialog.options.gap_x = dialog._gap_x.value() + dialog.options.gap_y = dialog._gap_y.value() + dialog.options.size_x = dialog._size_x.value() + dialog.options.size_y = dialog._size_y.value() + dependencies() + dialog.on_current_changed(filename) + + group_box = QtGui.QGroupBox(_("Loading method")) + dialog._more_button = more = QtGui.QRadioButton(_("Use 49 most used colors")) + dialog._less_button = less = QtGui.QRadioButton(_("Use 9 most used colors and mix them")) + dialog._less_farest = less_farest = QtGui.QRadioButton(_("Use 9 most different colors and mix them")) + dialog._table = table = QtGui.QRadioButton(_("Load table of colors")) + + table_w = QtGui.QWidget(dialog) + table_form = QtGui.QFormLayout(table_w) + dialog._border_x = QtGui.QSpinBox(table_w) + dialog._border_x.valueChanged.connect(on_method_changed) + table_form.addRow(_("Border from right/left side, px"), dialog._border_x) + dialog._border_y = QtGui.QSpinBox(table_w) + dialog._border_y.valueChanged.connect(on_method_changed) + table_form.addRow(_("Border from top/bottom side, px"), dialog._border_y) + dialog._gap_x = QtGui.QSpinBox(table_w) + dialog._gap_x.valueChanged.connect(on_method_changed) + table_form.addRow(_("Width of gap between cells, px"), dialog._gap_x) + dialog._gap_y = QtGui.QSpinBox(table_w) + dialog._gap_y.valueChanged.connect(on_method_changed) + table_form.addRow(_("Height of gap between cells, px"), dialog._gap_y) + dialog._size_x = QtGui.QSpinBox(table_w) + dialog._size_x.setValue(5) + dialog._size_x.valueChanged.connect(on_method_changed) + table_form.addRow(_("Number of columns in the table"), dialog._size_x) + dialog._size_y = QtGui.QSpinBox(table_w) + dialog._size_y.setValue(5) + dialog._size_y.valueChanged.connect(on_method_changed) + table_form.addRow(_("Number of rows in the table"), dialog._size_y) + table_w.setLayout(table_form) + + dependencies() + + more.toggled.connect(on_method_changed) + less.toggled.connect(on_method_changed) + less_farest.toggled.connect(on_method_changed) + table.toggled.connect(on_method_changed) + + if dialog.options is None or dialog.options.method == LOAD_MORE: + more.setChecked(True) + elif dialog.options.method == LOAD_LESS_COMMON: + less.setChecked(True) + elif dialog.options.method == LOAD_TABLE: + table.setChecked(True) + else: + less_farest.setChecked(True) + + vbox = QtGui.QVBoxLayout() + vbox.addWidget(more) + vbox.addWidget(less) + vbox.addWidget(less_farest) + vbox.addWidget(table) + vbox.addWidget(table_w) + group_box.setLayout(vbox) + return group_box + + def save(self, file_w): + w,h = self.palette.ncols * 48, self.palette.nrows * 48 + image = PaletteImage( self.palette ).get(w,h) + print("Writing image: " + file_w) + res = image.save(file_w) + if not res: + image.save(file_w, format='PNG') + + def load(self, mixer, file_r, options=None): + def _cmp(clr1,clr2): + h1,s1,v1 = clr1.getHSV() + h2,s2,v2 = clr2.getHSV() + x = cmp(h1,h2) + if x != 0: + return x + x = cmp(v1,v2) + if x != 0: + return x + return cmp(s1,s2) + + def get_farest(space, colors, n=9): + points = [space.getCoords(c) for c in colors] + center = get_center(points) + srt = sorted(points, key = lambda c: -rho(center, c)) + farest = srt[:n] + return [space.fromCoords(c) for c in farest] + + if use_sklearn or options is None or options.method is None or options.method == LOAD_MORE: + colors = get_common_colors(file_r) + colors.sort(cmp=_cmp) + self.palette = create_palette(colors, mixer) + return self.palette + + elif options.method == LOAD_TABLE: + self.palette = palette = Palette(mixer, nrows=options.size_y, ncols=options.size_x) + colors = parse_color_table(file_r, options) + + for i in range(0, options.size_y): + for j in range(0, options.size_x): + palette.paint(i, j, colors[i][j]) + + return palette + + else: + if options.method == LOAD_LESS_FAREST: + colors = get_common_colors(file_r) + colors = get_farest(spaces.RGB, colors) + else: + colors = get_common_colors(file_r, n_clusters=9) + + self.palette = palette = Palette(mixer, nrows=7, ncols=7) + + palette.paint(0, 0, colors[0]) + palette.paint(0, 3, colors[1]) + palette.paint(0, 6, colors[2]) + palette.paint(3, 0, colors[3]) + palette.paint(3, 3, colors[4]) + palette.paint(3, 6, colors[5]) + palette.paint(6, 0, colors[6]) + palette.paint(6, 3, colors[7]) + palette.paint(6, 6, colors[8]) + + palette.need_recalc_colors = True + palette.recalc() + + name,ext = splitext(basename(file_r)) + self.palette.meta["SourceFormat"] = ext + + return palette + + diff --git a/extensions/fablabchemnitz/color_harmony/color_harmony/storage/kpl.py b/extensions/fablabchemnitz/color_harmony/color_harmony/storage/kpl.py new file mode 100644 index 0000000..275b2be --- /dev/null +++ b/extensions/fablabchemnitz/color_harmony/color_harmony/storage/kpl.py @@ -0,0 +1,178 @@ + +from os.path import join, basename +from PyQt4 import QtGui +from lxml import etree as ET +from zipfile import ZipFile, ZIP_DEFLATED + +from color.colors import * +from color import mixers +from palette.storage.storage import * + +MIMETYPE = "application/x-krita-palette" +DEFAULT_GROUP_NAME = "Default (root)" + +class KplPalette(Storage): + name = 'kpl' + title = _("Krita 4.0+ palette format") + filters = ["*.kpl"] + can_load = True + can_save = True + + @staticmethod + def check(filename): + try: + with ZipFile(filename, 'r') as zf: + mimetype = zf.read("mimetype") + return (mimetype == MIMETYPE) + except Exception as e: + print(e) + return False + + @staticmethod + def get_group_names(filename): + result = [DEFAULT_GROUP_NAME] + + with ZipFile(filename, 'r') as zf: + colorset_str = zf.read("colorset.xml") + colorset = ET.fromstring(colorset_str) + + for xmlgrp in colorset.xpath("//Group"): + name = xmlgrp.attrib['name'] + if name is not None: + result.append(name) + + return result + + @staticmethod + def get_options_widget(dialog, filename): + + dialog.options = DEFAULT_GROUP_NAME + + def on_group_changed(selector): + def handler(): + dialog.options = selector.currentText() + dialog.on_current_changed(filename) + return handler + + widget = QtGui.QWidget() + box = QtGui.QHBoxLayout() + label = QtGui.QLabel(_("Group: ")) + selector = QtGui.QComboBox() + + for group_name in KplPalette.get_group_names(filename): + selector.addItem(group_name) + + selector.currentIndexChanged.connect(on_group_changed(selector)) + selector.setCurrentIndex(0) + + box.addWidget(label) + box.addWidget(selector) + widget.setLayout(box) + return widget + + def load(self, mixer, file_r, group_name): + + if group_name is None: + group_name = DEFAULT_GROUP_NAME + + def find_group(xml): + if group_name == DEFAULT_GROUP_NAME: + return xml + for xmlgrp in xml.xpath("//Group"): + if xmlgrp.attrib['name'] == group_name: + return xmlgrp + return None + + self.palette = Palette(mixer) + + with ZipFile(file_r, 'r') as zf: + mimetype = zf.read("mimetype") + if mimetype != MIMETYPE: + raise Exception("This is not a valid Krita palette file") + + colorset_str = zf.read("colorset.xml") + colorset = ET.fromstring(colorset_str) + self.palette.ncols = int( colorset.attrib['columns'] ) + self.palette.name = colorset.attrib.get('name', "Untitled") + + group = find_group(colorset) + if group is None: + print(u"Cannot find group by name {}".format(group_name).encode('utf-8')) + return None + else: + self.palette.name = self.palette.name + " - " + group.attrib.get('name', 'Untitled') + + all_slots = [] + n_colors = 0 + for xmlclr in group.findall('ColorSetEntry'): + name = xmlclr.attrib['name'] + if xmlclr.attrib['bitdepth'] != 'U8': + print("Skip color {}: unsupported bitdepth".format(name)) + continue + rgb = xmlclr.find('RGB') + if rgb is None: + rgb = xmlclr.find('sRGB') + if rgb is None: + print("Skip color {}: no RGB representation".format(name)) + continue + + r = float(rgb.attrib['r'].replace(',', '.')) + g = float(rgb.attrib['g'].replace(',', '.')) + b = float(rgb.attrib['b'].replace(',', '.')) + clr = Color() + clr.setRGB1((r,g,b)) + clr.name = name + slot = Slot(clr) + slot.mode = USER_DEFINED + + all_slots.append(slot) + n_colors += 1 + + if n_colors < DEFAULT_GROUP_SIZE: + self.palette.ncols = n_colors + if not self.palette.ncols: + if n_colors > MAX_COLS: + self.palette.ncols = MAX_COLS + else: + self.palette.ncols = n_colors + #print("Loaded colors: {}".format(len(all_slots))) + self.palette.setSlots(all_slots) + self.palette.meta["SourceFormat"] = "KRITA KPL" + print("Loaded palette: {}x{}".format( self.palette.nrows, self.palette.ncols )) + return self.palette + + def save(self, file_w=None): + with ZipFile(file_w, 'w', ZIP_DEFLATED) as zf: + zf.writestr("mimetype", MIMETYPE) + + xml = ET.Element("Colorset") + xml.attrib['version'] = '1.0' + xml.attrib['columns'] = str(self.palette.ncols) + xml.attrib['name'] = self.palette.name + xml.attrib['comment'] = self.palette.meta.get("Comment", "Generated by Palette Editor") + + for i,row in enumerate(self.palette.slots): + for j,slot in enumerate(row): + color = slot.color + name = color.name + default_name = "Swatch-{}-{}".format(i,j) + if not name: + name = default_name + + elem = ET.SubElement(xml, "ColorSetEntry") + elem.attrib['spot'] = color.meta.get("Spot", "false") + elem.attrib['id'] = default_name + elem.attrib['name'] = name + elem.attrib['bitdepth'] = 'U8' + + r,g,b = color.getRGB1() + srgb = ET.SubElement(elem, "sRGB") + srgb.attrib['r'] = str(r) + srgb.attrib['g'] = str(g) + srgb.attrib['b'] = str(b) + + tree = ET.ElementTree(xml) + tree_str = ET.tostring(tree, encoding='utf-8', pretty_print=True, xml_declaration=False) + + zf.writestr("colorset.xml", tree_str) + diff --git a/extensions/fablabchemnitz/color_harmony/color_harmony/storage/scribus.py b/extensions/fablabchemnitz/color_harmony/color_harmony/storage/scribus.py new file mode 100644 index 0000000..6672b28 --- /dev/null +++ b/extensions/fablabchemnitz/color_harmony/color_harmony/storage/scribus.py @@ -0,0 +1,76 @@ + +from math import floor +from os.path import join, basename +from lxml import etree as ET + +from color.colors import * +from color import mixers +from palette.storage.storage import * + +cmyk_factor = float(1.0/255.0) + +def fromHex_CMYK(s): + t = s[1:] + cs,ms,ys,ks = t[0:2], t[2:4], t[4:6], t[6:8] + c,m,y,k = int(cs,16), int(ms,16), int(ys,16), int(ks,16) + c,m,y,k = [float(x)*cmyk_factor for x in [c,m,y,k]] + result = Color() + result.setCMYK((c,m,y,k)) + return result + +class Scribus(Storage): + name = 'scribus' + title = _("Scribus color swatches") + filters = ["*.xml"] + can_load = True + can_save = True + + @staticmethod + def check(filename): + return ET.parse(filename).getroot().tag == 'SCRIBUSCOLORS' + + def load(self, mixer, file_r, options=None): + xml = ET.parse(file_r).getroot() + + colors = [] + name = xml.attrib.get("Name", "Untitled") + + for elem in xml.findall("COLOR"): + if "RGB" in elem.attrib: + color = fromHex(elem.attrib["RGB"]) + colors.append(color) + elif "CMYK" in elem.attrib: + color = fromHex_CMYK(elem.attrib["CMYK"]) + colors.append(color) + else: + continue + if "NAME" in elem.attrib: + color.meta["Name"] = elem.attrib["NAME"] + if "Spot" in elem.attrib: + color.meta["Spot"] = elem.attrib["Spot"] + if "Register" in elem.attrib: + color.meta["Register"] = elem.attrib["Register"] + + self.palette = create_palette(colors) + self.palette.name = name + return self.palette + + def save(self, file_w): + name = self.palette.name + if not name: + name = "Palette" + xml = ET.Element("SCRIBUSCOLORS", NAME=name) + + for i,row in enumerate(self.palette.getColors()): + for j,color in enumerate(row): + name = color.name + if not name: + name = "Swatch-{}-{}".format(i,j) + elem = ET.SubElement(xml, "COLOR", NAME=name, RGB=color.hex()) + if "Spot" in color.meta: + elem.attrib["Spot"] = color.meta["Spot"] + if "Register" in color.meta: + elem.attrib["Register"] = color.meta["Register"] + + + ET.ElementTree(xml).write(file_w, encoding="utf-8", pretty_print=True, xml_declaration=True) diff --git a/extensions/fablabchemnitz/color_harmony/color_harmony/storage/storage.py b/extensions/fablabchemnitz/color_harmony/color_harmony/storage/storage.py new file mode 100644 index 0000000..26f849b --- /dev/null +++ b/extensions/fablabchemnitz/color_harmony/color_harmony/storage/storage.py @@ -0,0 +1,62 @@ + +from os.path import join, basename +from math import sqrt, floor + +from color.colors import * +from color import mixers +from palette.palette import * + +class Storage(object): + + name = None + title = None + filters = [] + + can_load = False + can_save = False + + def __init__(self, palette=None): + self.palette = palette + + @classmethod + def get_filter(cls): + return u"{} ({})".format(cls.title, u" ".join(cls.filters)) + + @staticmethod + def check(filename): + return True + + @staticmethod + def get_options_widget(dialog, filename): + return None + + def load(self, mixer, file_r, *args, **kwargs): + raise NotImplemented + + def save(self, file_w): + raise NotImplemented + +def create_palette(colors, mixer=None, ncols=None): + """Create Palette from list of Colors.""" + if mixer is None: + mixer = mixers.MixerRGB + palette = Palette(mixer) + palette.ncols = ncols + + all_slots = [] + + for clr in colors: + slot = Slot(clr, user_defined=True) + all_slots.append(slot) + + n_colors = len(all_slots) + + if palette.ncols is None: + if n_colors > MAX_COLS: + palette.ncols = max( int( floor(sqrt(n_colors)) ), 1) + else: + palette.ncols = n_colors + + palette.setSlots(all_slots) + return palette + diff --git a/extensions/fablabchemnitz/color_harmony/color_harmony/storage/xml.py b/extensions/fablabchemnitz/color_harmony/color_harmony/storage/xml.py new file mode 100644 index 0000000..47129dc --- /dev/null +++ b/extensions/fablabchemnitz/color_harmony/color_harmony/storage/xml.py @@ -0,0 +1,179 @@ + +from os.path import join, basename +from PyQt4 import QtGui +from lxml import etree as ET + +from color.colors import * +from color import mixers +from palette.storage.storage import * + +class XmlPalette(Storage): + name = 'xml' + title = _("CREATE palette format") + filters = ["*.xml"] + can_load = True + can_save = True + + @staticmethod + def check(filename): + return ET.parse(filename).getroot().tag == 'colors' + + @staticmethod + def get_options_widget(dialog, filename): + + def on_group_changed(selector): + def handler(): + dialog.options = selector.currentText() + dialog.on_current_changed(filename) + return handler + + widget = QtGui.QWidget() + box = QtGui.QHBoxLayout() + label = QtGui.QLabel(_("Group: ")) + selector = QtGui.QComboBox() + + xml = ET.parse(filename) + for xmlgrp in xml.xpath("//group"): + xml_label = xmlgrp.find('label') + if xml_label is not None: + selector.addItem(xml_label.text) + + selector.currentIndexChanged.connect(on_group_changed(selector)) + selector.setCurrentIndex(0) + + box.addWidget(label) + box.addWidget(selector) + widget.setLayout(box) + return widget + + @staticmethod + def get_group_names(filename): + result = [] + xml = ET.parse(filename) + for xmlgrp in xml.xpath("//group"): + xml_label = xmlgrp.find('label') + if xml_label is not None: + result.append(xml_label.text) + return result + + def save(self, file_w=None): + xml = ET.Element("colors") + root_group = ET.SubElement(xml, "group") + group = ET.SubElement(root_group, "group") + label = ET.SubElement(group, "label") + label.text = self.palette.name + layout = ET.SubElement(group, "layout", columns=str(self.palette.ncols), expanded="True") + + for key,value in self.palette.meta.items(): + if key != "Name": + meta = ET.SubElement(group, "meta", name=key) + meta.text = value + + for i,row in enumerate(self.palette.slots): + for j,slot in enumerate(row): + color = slot.color + name = color.name + if not name: + name = "Swatch-{}-{}".format(i,j) + elem = ET.SubElement(group, "color") + label = ET.SubElement(elem, "label") + label.text = name + if slot.mode == USER_DEFINED: + meta = ET.SubElement(elem, "meta", name="user_chosen") + meta.text = "True" + for key,value in color.meta.items(): + if key != "Name": + meta = ET.SubElement(elem, "meta", name=key) + meta.text = value + rgb = ET.SubElement(elem, "sRGB") + r,g,b = color.getRGB1() + rgb.attrib["r"] = str(r) + rgb.attrib["g"] = str(g) + rgb.attrib["b"] = str(b) + + ET.ElementTree(xml).write(file_w, encoding="utf-8", pretty_print=True, xml_declaration=True) + + def load(self, mixer, file_r, group_name): + + def get_label(grp): + xml_label = grp.find('label') + if xml_label is None: + return None + return xml_label.text + + + def find_group(xml): + #print("Searching {}".format(group_name)) + grp = None + for xmlgrp in xml.xpath("//group"): + label = get_label(xmlgrp) + if label is None: + continue + #print("Found: {}".format(label)) + if group_name is None or label == group_name: + grp = xmlgrp + break + return grp + + self.palette = Palette(mixer) + self.palette.ncols = None + xml = ET.parse(file_r) + grp = find_group(xml) + if grp is None: + print(u"Cannot find group by name {}".format(group_name).encode('utf-8')) + return None + self.palette.name = get_label(grp) + + layout = grp.find('layout') + if layout is not None: + self.palette.ncols = int( layout.attrib['columns'] ) + + metas = grp.findall('meta') + if metas is not None: + for meta in metas: + key = meta.attrib['name'] + value = meta.text + if key != 'Name': + self.palette.meta[key] = value + + all_slots = [] + n_colors = 0 + for xmlclr in grp.findall('color'): + sRGB = xmlclr.find('sRGB') + if sRGB is None: + continue + attrs = sRGB.attrib + r = float(attrs['r'].replace(',','.')) + g = float(attrs['g'].replace(',','.')) + b = float(attrs['b'].replace(',','.')) + clr = Color() + clr.setRGB1((r,g,b)) + slot = Slot(clr) + metas = xmlclr.findall('meta') + if metas is not None: + for meta in metas: + key = meta.attrib['name'] + value = meta.text + if key == 'user_chosen' and value == 'True': + slot.mode = USER_DEFINED + else: + clr.meta[key] = value + label = xmlclr.find('label') + if label is not None: + clr.name = label.text + all_slots.append(slot) + n_colors += 1 + + if n_colors < DEFAULT_GROUP_SIZE: + self.palette.ncols = n_colors + if not self.palette.ncols: + if n_colors > MAX_COLS: + self.palette.ncols = MAX_COLS + else: + self.palette.ncols = n_colors + #print("Loaded colors: {}".format(len(all_slots))) + self.palette.setSlots(all_slots) + self.palette.meta["SourceFormat"] = "CREATE XML" + print("Loaded palette: {}x{}".format( self.palette.nrows, self.palette.ncols )) + return self.palette + diff --git a/extensions/fablabchemnitz/color_harmony/color_harmony/utils.py b/extensions/fablabchemnitz/color_harmony/color_harmony/utils.py new file mode 100644 index 0000000..da96e12 --- /dev/null +++ b/extensions/fablabchemnitz/color_harmony/color_harmony/utils.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python +# coding=utf-8 +# +# Copyright (C) 2009-2018 Ilya Portnov +# (original 'palette-editor' tool, version 0.0.7) +# 2020 Maren Hachmann (extension-ification) +# +# 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. + + +# Helper functions +def circle_hue(i, n, h, max=1.0): + h += i * max / n + if h > max: + h -= max + return h + +def seq(start, stop, step=1): + n = int(round((stop - start)/step)) + if n > 1: + return([start + step*i for i in range(n+1)]) + else: + return([]) + +def variate(x, step=1.0, dmin=1.0, dmax=None): + if dmax is None: + dmax = dmin + return seq(x-dmin, x+dmax, step) + +def clip(x, min=0.0, max=1.0): + if x < min: + #print("{:.2f} clipped up to {:.2f}".format(x, min)) + return min + if x > max: + #print("{:.2f} clipped down to {:.2f}".format(x, max)) + return max + return x diff --git a/extensions/fablabchemnitz/color_harmony/meta.json b/extensions/fablabchemnitz/color_harmony/meta.json new file mode 100644 index 0000000..186dd63 --- /dev/null +++ b/extensions/fablabchemnitz/color_harmony/meta.json @@ -0,0 +1,21 @@ +[ + { + "name": "Color Harmony", + "id": "fablabchemnitz.de.color_harmony", + "path": "color_harmony", + "dependent_extensions": null, + "original_name": "Color harmony", + "original_id": "de.vektorrascheln.color_harmony", + "license": "GNU GPL v2", + "license_url": "https://gitlab.com/moini_ink/color-harmony/-/blob/master/LICENSE", + "comment": "", + "source_url": "https://gitea.fablabchemnitz.de/FabLab_Chemnitz/mightyscape-1.2/src/branch/master/extensions/fablabchemnitz/color_harmony", + "fork_url": "https://gitlab.com/moini_ink/color-harmony", + "documentation_url": "https://stadtfabrikanten.org/display/IFM/Color+Harmony", + "inkscape_gallery_url": null, + "main_authors": [ + "gitlab.com/moini_ink", + "github.com/eridur-de" + ] + } +] \ No newline at end of file diff --git a/extensions/fablabchemnitz/destructive_clip/destructive_clip.inx b/extensions/fablabchemnitz/destructive_clip/destructive_clip.inx new file mode 100644 index 0000000..a893183 --- /dev/null +++ b/extensions/fablabchemnitz/destructive_clip/destructive_clip.inx @@ -0,0 +1,17 @@ + + + Destructive Clip + fablabchemnitz.de.destructive_clip + + path + + + + + + "Destructively" clip selected paths using the topmost as clipping path + + + \ No newline at end of file diff --git a/extensions/fablabchemnitz/destructive_clip/destructive_clip.py b/extensions/fablabchemnitz/destructive_clip/destructive_clip.py new file mode 100644 index 0000000..554e996 --- /dev/null +++ b/extensions/fablabchemnitz/destructive_clip/destructive_clip.py @@ -0,0 +1,220 @@ +#!/usr/bin/env python3 +''' + ---DESTRUCTIVE Clip--- + An Inkscape Extension which works like Object|Clip|Set except that the paths clipped are actually *modified* + Thus the clipping is included when exported, for example as a DXF file. + Select two or more *paths* then choose Extensions|Modify path|Destructive clip. The topmost path will be used to clip the others. + Notes:- + * Curves in paths are not supported (use Flatten Beziers). + * Non-path objects in the selection will be ignored. Use Object|Ungroup. + * Paths entirely outside the clipping path will remain untouched (rather than modifying them to an empty path) + * Complex paths may take a while (there seems to be no way too show progress) + * Yes, using MBR's to do gross clipping might make it faster + * No, Python is not my first language (C/C++ is) + + Mark Wilson Feb 2016 + + ---- + + Edits by Windell H. Oskay, www.evilmadscientit.com, August 2020 + Update calls to Inkscape 1.0 extension API to avoid deprecation warnings + Minimal standardization of python whitespace + Handle some errors more gracefully + +''' + +import inkex +import sys +from inkex.paths import Path + + +class DestructiveClip(inkex.EffectExtension): + + def __init__(self): + self.tolerance = 0.0001 # arbitrary fudge factor + inkex.Effect.__init__(self) + self.error_messages = [] + + self.curve_error = 'Unable to parse path.\nConsider removing curves with Extensions > Modify Path > Flatten Beziers...' + + def approxEqual(self, a, b): + # compare with tiny tolerance + return abs(a-b) <= self.tolerance + + def midPoint(self, line): + # midPoint of line + return [(line[0][0] + line[1][0])/2, (line[0][1] + line[1][1])/2] + + def maxX(self, lineSegments): + # return max X coord of lineSegments + maxx = 0.0 + for line in lineSegments: + maxx = max(maxx, line[0][0]) + maxx = max(maxx, line[1][0]) + return maxx + + def simplepathToLineSegments(self, path): + # takes a simplepath and converts to line *segments*, for simplicity. + # Thus [MoveTo P0, LineTo P1, LineTo P2] becomes [[P0-P1],[P1,P2]] + # only handles, Move, Line and Close. + # The simplepath library has already simplified things, normalized relative commands, etc + lineSegments = first = prev = this = [] + errors = set([]) # Similar errors will be stored only once + for cmd in path: + this = cmd[1] + if cmd[0] == 'M': # moveto + if first == []: + first = this + elif cmd[0] == 'L': # lineto + lineSegments.append([prev, this]) + elif cmd[0] == 'Z': # close + lineSegments.append([prev, first]) + first = [] + elif cmd[0] == 'C': + # https://developer.mozilla.org/en/docs/Web/SVG/Tutorial/Paths + lineSegments.append([prev, [this[4], this[5]]]) + errors.add("Curve node detected (svg type C), this node will be handled as a regular node") + else: + errors.add("Invalid node type detected: {}. This script only handle type M, L, Z".format(cmd[0])) + prev = this + return (lineSegments, errors) + + def linesgmentsToSimplePath(self, lineSegments): + # reverses simplepathToLines - converts line segments to Move/Line-to's + path = [] + end = None + for line in lineSegments: + start = line[0] + if end is None: + path.append(['M', start]) # start with a move + elif not (self.approxEqual(end[0], start[0]) and self.approxEqual(end[1], start[1])): + path.append(['M', start]) # only move if previous end not within tolerance of this start + end = line[1] + path.append(['L', end]) + return path + + def lineIntersection(self, L1From, L1To, L2From, L2To): + # returns as [x, y] the intersection of the line L1From-L1To and L2From-L2To, or None + # http://stackoverflow.com/questions/563198/how-do-you-detect-where-two-line-segments-intersect + + try: + dL1 = [L1To[0] - L1From[0], L1To[1] - L1From[1]] + dL2 = [L2To[0] - L2From[0], L2To[1] - L2From[1]] + except IndexError: + inkex.errormsg(self.curve_error) + sys.exit() + + denominator = -dL2[0]*dL1[1] + dL1[0]*dL2[1] + if not self.approxEqual(denominator, 0.0): + s = (-dL1[1]*(L1From[0] - L2From[0]) + dL1[0]*(L1From[1] - L2From[1]))/denominator + t = (+dL2[0]*(L1From[1] - L2From[1]) - dL2[1]*(L1From[0] - L2From[0]))/denominator + if s >= 0.0 and s <= 1.0 and t >= 0.0 and t <= 1.0: + return [L1From[0] + (t * dL1[0]), L1From[1] + (t * dL1[1])] + else: + return None + + def insideRegion(self, point, lineSegments, lineSegmentsMaxX): + # returns true if point is inside the region defined by lineSegments. lineSegmentsMaxX is the maximum X extent + ray = [point, [lineSegmentsMaxX*2.0, point[1]]] # hz line to right of point, extending well outside MBR + crossings = 0 + for line in lineSegments: + if not self.lineIntersection(line[0], line[1], ray[0], ray[1]) is None: + crossings += 1 + return (crossings % 2) == 1 # odd number of crossings means inside + + def cullSegmentedLine(self, segmentedLine, lineSegments, lineSegmentsMaxX): + # returns just the segments in segmentedLine which are inside lineSegments + culled = [] + for segment in segmentedLine: + if self.insideRegion(self.midPoint(segment), lineSegments, lineSegmentsMaxX): + culled.append(segment) + return culled + + def clipLine(self, line, lineSegments): + # returns line split where-ever lines in lineSegments cross it + linesWrite = [line] + for segment in lineSegments: + linesRead = linesWrite + linesWrite = [] + for line in linesRead: + intersect = self.lineIntersection(line[0], line[1], segment[0], segment[1]) + if intersect is None: + linesWrite.append(line) + else: # split + linesWrite.append([line[0], intersect]) + linesWrite.append([intersect, line[1]]) + return linesWrite + + def clipLineSegments(self, lineSegmentsToClip, clippingLineSegments): + # return the lines in lineSegmentsToClip clipped by the lines in clippingLineSegments + clippedLines = [] + for lineToClip in lineSegmentsToClip: + clippedLines.extend(self.cullSegmentedLine(self.clipLine(lineToClip, clippingLineSegments), clippingLineSegments, self.maxX(clippingLineSegments))) + return clippedLines + + #you can also run the extension Modify Path > To Absolute Coordinates to convert VH to L + def fixVHbehaviour(self, elem): + raw = Path(elem.get("d")).to_arrays() + subpaths, prev = [], 0 + for i in range(len(raw)): # Breaks compound paths into simple paths + if raw[i][0] == 'M' and i != 0: + subpaths.append(raw[prev:i]) + prev = i + subpaths.append(raw[prev:]) + seg = [] + for simpath in subpaths: + if simpath[-1][0] == 'Z': + simpath[-1][0] = 'L' + if simpath[-2][0] == 'L': simpath[-1][1] = simpath[0][1] + else: simpath.pop() + for i in range(len(simpath)): + if simpath[i][0] == 'V': # vertical and horizontal lines only have one point in args, but 2 are required + #inkex.utils.debug(simpath[i][0]) + simpath[i][0]='L' #overwrite V with regular L command + add=simpath[i-1][1][0] #read the X value from previous segment + simpath[i][1].append(simpath[i][1][0]) #add the second (missing) argument by taking argument from previous segment + simpath[i][1][0]=add #replace with recent X after Y was appended + if simpath[i][0] == 'H': # vertical and horizontal lines only have one point in args, but 2 are required + #inkex.utils.debug(simpath[i][0]) + simpath[i][0]='L' #overwrite H with regular L command + simpath[i][1].append(simpath[i-1][1][1]) #add the second (missing) argument by taking argument from previous segment + #inkex.utils.debug(simpath[i]) + seg.append(simpath[i]) + elem.set("d", Path(seg)) + return seg + + def effect(self): + clippingLineSegments = None + pathTag = inkex.addNS('path', 'svg') + groupTag = inkex.addNS('g', 'svg') + self.error_messages = [] + for id in self.options.ids: # the selection, top-down + node = self.svg.selected[id] + if node.tag == pathTag: + path = self.fixVHbehaviour(node) + if clippingLineSegments is None: # first path is the clipper + #(clippingLineSegments, errors) = self.simplepathToLineSegments(node.path.to_arrays()) + (clippingLineSegments, errors) = self.simplepathToLineSegments(path) + self.error_messages.extend(['{}: {}'.format(id, err) for err in errors]) + else: + # do all the work! + #segmentsToClip, errors = self.simplepathToLineSegments(node.path.to_arrays()) + segmentsToClip, errors = self.simplepathToLineSegments(path) + self.error_messages.extend(['{}: {}'.format(id, err) for err in errors]) + clippedSegments = self.clipLineSegments(segmentsToClip, clippingLineSegments) + if len(clippedSegments) != 0: + path = str(inkex.Path(self.linesgmentsToSimplePath(clippedSegments))) + node.set('d', path) + else: + # don't put back an empty path(?) could perhaps put move, move? + inkex.errormsg('Object {} clipped to nothing, will not be updated.'.format(node.get('id'))) + elif node.tag == groupTag: # we don't look inside groups for paths + inkex.errormsg('Group object {} will be ignored. Please ungroup before running the script.'.format(id)) + else: # something else + inkex.errormsg('Object {} is not of type path ({}), and will be ignored. Current type "{}".'.format(id, pathTag, node.tag)) + + for error in self.error_messages: + inkex.errormsg(error) + +if __name__ == '__main__': + DestructiveClip().run() diff --git a/extensions/fablabchemnitz/destructive_clip/meta.json b/extensions/fablabchemnitz/destructive_clip/meta.json new file mode 100644 index 0000000..0bd24d6 --- /dev/null +++ b/extensions/fablabchemnitz/destructive_clip/meta.json @@ -0,0 +1,21 @@ +[ + { + "name": "Destructive Clip", + "id": "fablabchemnitz.de.destructive_clip", + "path": "destructive_clip", + "dependent_extensions": null, + "original_name": "Destructive Clip", + "original_id": "com.funnypolynomial.inkscape.extension.destructiveclip", + "license": "MIT License", + "license_url": "https://github.com/funnypolynomial/DestructiveClip/blob/master/LICENSE", + "comment": "", + "source_url": "https://gitea.fablabchemnitz.de/FabLab_Chemnitz/mightyscape-1.2/src/branch/master/extensions/fablabchemnitz/destructive_clip", + "fork_url": "https://github.com/funnypolynomial/DestructiveClip", + "documentation_url": "https://stadtfabrikanten.org/display/IFM/Destructive+Clip", + "inkscape_gallery_url": null, + "main_authors": [ + "github.com/funnypolynomial", + "github.com/eridur-de" + ] + } +] \ No newline at end of file diff --git a/extensions/fablabchemnitz/dimensioning/dimensioning.inx b/extensions/fablabchemnitz/dimensioning/dimensioning.inx new file mode 100644 index 0000000..d454db1 --- /dev/null +++ b/extensions/fablabchemnitz/dimensioning/dimensioning.inx @@ -0,0 +1,58 @@ + + + Dimensioning (Replaced by LPE) + fablabchemnitz.de.dimensioning + + + + + + + + + + + + + 1 + 5 + 0 + 100 + false + + + 1 + + + + + + + 0 + + true + + + + + + + + path + + + + + + + + \ No newline at end of file diff --git a/extensions/fablabchemnitz/dimensioning/dimensioning.py b/extensions/fablabchemnitz/dimensioning/dimensioning.py new file mode 100644 index 0000000..4e39429 --- /dev/null +++ b/extensions/fablabchemnitz/dimensioning/dimensioning.py @@ -0,0 +1,283 @@ +#!/usr/bin/env python3 +''' +Tool for drawing beautiful DIN-conform dimensioning arrows +(c) 2012 by Johannes B. Rutzmoser, johannes.rutzmoser (at) googlemail (dot) com + +Please contact me, if you know a way how the extension module accepts mouse input; this would help to improve the tool + +Add this file and the dimensioning.inx file into the following folder to get the feature run: +UNIX: +$HOME/.config/inkscape/extensions/ + +Mac OS X (when using the binary): +/Applications/Inkscape.app/Contents/Resources/extensions/ +or +/Applications/Inkscape.app/Contents/Resources/share/inkscape/extensions + +WINDOWS (Filepath may differ, depending where the program was installed): +C:\Program Files\Inkscape\share\extensions + +License: +GNU GENERAL PUBLIC LICENSE + +''' + +import inkex +import numpy as np +import gettext +_ = gettext.gettext +from lxml import etree +from inkex import paths + +def norm(a): + return a/np.sqrt(np.dot(a, a)) + +def rotate(tangentvec, point): + if tangentvec[0] == 0: + angle = - np.pi/2 + else: + angle = np.arctan(tangentvec[1]/tangentvec[0]) + return 'rotate(' + str(angle/np.pi*180) + ',' + str(point[0]) + ',' + str(point[1]) + ')' + + +class Dimensioning(inkex.EffectExtension): + + def add_arguments(self, pars): + pars.add_argument("--orientation", default='horizontal', help="The type of orientation of the dimensioning (horizontal, vertical or parallel)") + pars.add_argument("--arrow_orientation", default='auto', help="The type of orientation of the arrows") + pars.add_argument("--line_scale", type=float, default=1.0, help="Scale factor for the line thickness") + pars.add_argument("--overlap", type=float, default=1.0, help="Overlap of the helpline over the dimensioning line") + pars.add_argument("--distance", type=float, default=1.0, help="Distance of the helpline to the object") + pars.add_argument("--position", type=float, default=1.0, help="position of the dimensioning line") + pars.add_argument("--flip", type=inkex.Boolean, default=False, help="flip side") + pars.add_argument("--scale_factor", type=float, default=1.0, help="scale factor for the dimensioning text") + pars.add_argument("--unit", default='px', help="The unit that should be used for the dimensioning") + pars.add_argument("--rotate", type=inkex.Boolean, default=True, help="Rotate the annotation?") + pars.add_argument("--digit", type=int, default=0, help="number of digits after the point") + pars.add_argument("--tab", default="sampling", help="The selected UI-tab when OK was pressed") + + def create_linestyles(self): + ''' + Create the line styles for the drawings. + ''' + + self.helpline_style = { + 'stroke' : '#000000', + 'stroke-width' : '{}px'.format(0.5*self.options.line_scale), + 'fill' : 'none' + } + self.dimline_style = { + 'stroke' : '#000000', + 'stroke-width' : '{}px'.format(0.75*self.options.line_scale), + 'fill' : 'none', + 'marker-start' : 'url(#ArrowDIN-start)', + 'marker-end' : 'url(#ArrowDIN-end)' + } + self.text_style = { + 'font-size' : '{}px'.format(12*self.options.line_scale), + 'font-family' : 'Sans', + 'font-style' : 'normal', + 'text-anchor' : 'middle' + } + self.helpline_attribs = {'style' : str(inkex.Style(self.helpline_style)), + inkex.addNS('label', 'inkscape') : 'helpline', + 'd' : 'm 0,0 100,0' + } + self.text_attribs = {'style' : str(inkex.Style(self.text_style)), + 'x' : '100', + 'y' : '100' + } + self.dimline_attribs = {'style' : str(inkex.Style(self.dimline_style)), + inkex.addNS('label','inkscape') : 'dimline', + 'd' : 'm 0,0 200,0' + } + + def effect(self): + # will be executed when feature is activated + self.create_linestyles() + self.makeGroup() + self.getPoints() + self.calcab() + self.drawHelpline() + self.drawDimension() + self.drawText() + + def makeMarkerstyle(self, name, rotate): + defs = self.svg.getElement('/svg:svg//svg:defs') + if defs == None: + defs = etree.SubElement(self.document.getroot(),inkex.addNS('defs','svg')) + marker = etree.SubElement(defs ,inkex.addNS('marker','svg')) + marker.set('id', name) + marker.set('orient', 'auto') + marker.set('refX', '0.0') + marker.set('refY', '0.0') + marker.set('style', 'overflow:visible') + marker.set(inkex.addNS('stockid','inkscape'), name) + + arrow = etree.Element("path") + # messy but works; definition of arrows in beautiful DIN-shapes: + if name.startswith('ArrowDIN-'): + if rotate: + arrow.set('d', 'M 8,0 -8,2.11 -8,-2.11 z') + else: + arrow.set('d', 'M -8,0 8,-2.11 8,2.11 z') + if name.startswith('ArrowDINout-'): + if rotate: + arrow.set('d', 'M 0,0 16,2.11 16,0.5 26,0.5 26,-0.5 16,-0.5 16,-2.11 z') + else: + arrow.set('d', 'M 0,0 -16,2.11 -16,0.5 -26,0.5 -26,-0.5 -16,-0.5 -16,-2.11 z') + + arrow.set('style', 'fill:#000000;stroke:none') + marker.append(arrow) + + + def makeGroup(self): + '''puts everything of the dimensioning in a group''' + layer = self.svg.get_current_layer() + # Group in which the object should be put into + grp_name = 'dimensioning' + grp_attributes = {inkex.addNS('label', 'inkscape') : grp_name} + self.grp = etree.SubElement(layer, 'g', grp_attributes) + + def getPoints(self): + self.p1 = np.array([0.,100.]) + self.p1 = np.array([100.,100.]) + # Get variables of a selected object + for id, node in self.svg.selected.items(): + # if it is a path: + if node.tag == inkex.addNS('path', 'svg'): + d = node.get('d') + p = paths.CubicSuperPath(d) + # p has all nodes with the anchor points in a list; + # the rule is [anchorpoint, node, anchorpoint] + # the points are lists with x and y coordinate + self.p1 = np.array(p[0][0][1]) + self.p2 = np.array(p[0][-1][1]) + + + def calcab(self): + # get p1,p2 ordered for correct dimension direction + # determine quadrant + if self.p1[0] <= self.p2[0]: + if self.p1[1] <= self.p2[1]: + quad = 1 # p1 is left,up of p2 + else: quad = 2 # p1 is left,down of p2 + elif self.p1[1] <= self.p2[1]: + quad = 3 # p1 is right,up of p2 + else: quad = 4 # p1 is right,down of p2 + swap = False if quad ==1 else True + minp = self.p2 if swap else self.p1 + maxp = self.p1 if swap else self.p2 + # distance between points + delta = maxp - minp + # rotation matrix + rotateMat = np.array([[0,-1],[1,0]]) + # compute the unit vectors e1 and e2 along the cartesian coordinates of the dimension + if self.options.orientation == 'horizontal': + if quad == 3: self.e1 = np.array([1.0, 0.0]) + else: self.e1 = np.array([-1.0, 0.0]) + if self.options.orientation == 'vertical': + if quad == 2: + self.e1 = np.array([0.0, -1.0]) + else: self.e1 = np.array([0.0, 1.0]) + if self.options.orientation == 'parallel': + self.e1 = norm(delta) + #if quad==2 or quad==3: self.e1 *= -1 + self.e2 = np.dot(rotateMat, self.e1) + if self.options.flip: + self.e2 *= -1. + # compute the points a and b, where the dimension line arrow spikes start and end + dist = self.options.position*self.e2 + if self.options.flip: + outpt = maxp + delta *= -1 + if swap: + self.a = outpt + dist + self.b = self.a + self.e1*np.dot(self.e1,delta) + else: + self.b = outpt + dist + self.a = self.b + self.e1*np.dot(self.e1,delta) + else: + outpt = minp + if swap: + self.b = outpt + dist + self.a = self.b + self.e1*np.dot(self.e1,delta) + else: + self.a = outpt + dist + self.b = self.a + self.e1*np.dot(self.e1,delta) + + + def drawHelpline(self): + # manipulate the start- and endpoints with distance and overlap + h1_start = self.p1 + norm(self.a - self.p1)*self.options.distance + h1_end = self.a + norm(self.a - self.p1)*self.options.overlap + h2_start = self.p2 + norm(self.b - self.p2)*self.options.distance + h2_end = self.b + norm(self.b - self.p2)*self.options.overlap + + # print the lines + hline1 = etree.SubElement(self.grp, inkex.addNS('path', 'svg'), self.helpline_attribs) + hline1.set('d', 'M %f,%f %f,%f' % (h1_start[0], h1_start[1],h1_end[0],h1_end[1],)) + + hline2 = etree.SubElement(self.grp, inkex.addNS('path', 'svg'), self.helpline_attribs) + hline2.set('d', 'M %f,%f %f,%f' % (h2_start[0], h2_start[1],h2_end[0],h2_end[1],)) + + def setMarker(self, option): + if option=='inside': + # inside + self.arrowlen = 6.0 * self.options.line_scale + self.dimline_style['marker-start'] = 'url(#ArrowDIN-start)' + self.dimline_style['marker-end'] = 'url(#ArrowDIN-end)' + self.makeMarkerstyle('ArrowDIN-start', False) + self.makeMarkerstyle('ArrowDIN-end', True) + else: + # outside + self.arrowlen = 0 + self.dimline_style['marker-start'] = 'url(#ArrowDINout-start)' + self.dimline_style['marker-end'] = 'url(#ArrowDINout-end)' + self.makeMarkerstyle('ArrowDINout-start', False) + self.makeMarkerstyle('ArrowDINout-end', True) + self.dimline_attribs['style'] = str(inkex.Style(self.dimline_style)) + + def drawDimension(self): + # critical length, when inside snaps to outside + critical_length = 35 * self.options.line_scale + if self.options.arrow_orientation == 'auto': + if np.abs(np.dot(self.e1, self.b - self.a)) > critical_length: + self.setMarker('inside') + else: + self.setMarker('outside') + else: + self.setMarker(self.options.arrow_orientation) + # start- and endpoint of the dimension line + dim_start = self.a + self.arrowlen*norm(self.b - self.a) + dim_end = self.b - self.arrowlen*norm(self.b - self.a) + # print + dimline = etree.SubElement(self.grp, inkex.addNS('path', 'svg'), self.dimline_attribs) + dimline.set('d', 'M %f,%f %f,%f' % (dim_start[0], dim_start[1], dim_end[0], dim_end[1])) + + def drawText(self): + # distance of text to the dimension line + self.textdistance = 5.0 * self.options.line_scale + if self.e2[1] > 0: + textpoint = (self.a + self.b)/2 - self.e2*self.textdistance + elif self.e2[1] == 0: + textpoint = (self.a + self.b)/2 - np.array([1,0])*self.textdistance + else: + textpoint = (self.a + self.b)/2 + self.e2*self.textdistance + + value = np.abs(np.dot(self.e1, self.b - self.a)) / (self.svg.unittouu(str(self.options.scale_factor)+self.options.unit)) + string_value = str(round(value, self.options.digit)) + # chop off last characters if digit is zero or negative + if self.options.digit <=0: + string_value = string_value[:-2] + text = etree.SubElement(self.grp, inkex.addNS('text', 'svg'), self.text_attribs) + # The alternative for framing with dollars, when LATEX Math export is seeked + # text.text = '$' + string_value + '$' + text.text = string_value + text.set('x', str(textpoint[0])) + text.set('y', str(textpoint[1])) + if self.options.rotate: + text.set('transform', rotate(self.e1, textpoint)) + +if __name__ == '__main__': + Dimensioning().run() \ No newline at end of file diff --git a/extensions/fablabchemnitz/dimensioning/meta.json b/extensions/fablabchemnitz/dimensioning/meta.json new file mode 100644 index 0000000..8f5dca6 --- /dev/null +++ b/extensions/fablabchemnitz/dimensioning/meta.json @@ -0,0 +1,21 @@ +[ + { + "name": "Dimensioning (Replaced by LPE)", + "id": "fablabchemnitz.de.dimensioning", + "path": "dimensioning", + "dependent_extensions": null, + "original_name": "Dimensioning", + "original_id": "dimensioning", + "license": "GNU GPL v3", + "license_url": "https://github.com/Rutzmoser/inkscape_dimensioning/blob/master/LICENSE", + "comment": "", + "source_url": "https://gitea.fablabchemnitz.de/FabLab_Chemnitz/mightyscape-1.2/src/branch/master/extensions/fablabchemnitz/dimensioning", + "fork_url": "https://github.com/Rutzmoser/inkscape_dimensioning", + "documentation_url": "https://stadtfabrikanten.org/pages/viewpage.action?pageId=55018389", + "inkscape_gallery_url": null, + "main_authors": [ + "github.com/Rutzmoser", + "github.com/eridur-de" + ] + } +] \ No newline at end of file diff --git a/extensions/fablabchemnitz/duplicate_reverse_join/duplicate_reverse_join.inx b/extensions/fablabchemnitz/duplicate_reverse_join/duplicate_reverse_join.inx new file mode 100644 index 0000000..7712923 --- /dev/null +++ b/extensions/fablabchemnitz/duplicate_reverse_join/duplicate_reverse_join.inx @@ -0,0 +1,16 @@ + + + Duplicate + Reverse + Join + fablabchemnitz.de.duplicate_reverse_join + + path + + + + + + + + \ No newline at end of file diff --git a/extensions/fablabchemnitz/duplicate_reverse_join/duplicate_reverse_join.py b/extensions/fablabchemnitz/duplicate_reverse_join/duplicate_reverse_join.py new file mode 100644 index 0000000..56fbc77 --- /dev/null +++ b/extensions/fablabchemnitz/duplicate_reverse_join/duplicate_reverse_join.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2020 Ellen Wasboe, ellen@wasbo.net +# +# 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. +""" +Duplicate subpaths of all selected paths, reverse and join end nodes. +""" + +import inkex + +class DuplicateReverseJoin(inkex.EffectExtension): + + def effect(self): + for elem in self.svg.selection: + + pp=elem.path.to_absolute() + dList=str(pp).split(' M') + dFinal='' + l=0 + for sub in dList: + if l>0: + origSub='M'+dList[l] + else: + origSub=dList[l] + + elem.path=origSub + reSub=elem.path.reverse() + + if l>0: + origSub=' '+origSub + + if origSub.find('Z') > -1: + dRev=str(reSub).split(' ') + strRev='' + if dRev[3]=='L' and dRev[1]==dRev[4] and dRev[2]==dRev[5]: + strRev=' '.join(dRev[0:3])+' '+' '.join(dRev[6:]) #avoid that reverse path duplicate first node + else: + strRev=' '.join(dRev) + dFinal=dFinal+origSub+' '+strRev #keep original and reverse as separate closed paths + if dRev[-1]!='Z': + dFinal=dFinal+' Z'#avoid that reverse of closed path is open + else: + dRev=str(reSub).split(' ') + dFinal=dFinal+origSub+' '+' '.join(dRev[3:])+' Z' #pop off M element of reverse path and add Z to close + l+=1 + elem.path=dFinal + + +if __name__ == '__main__': + DuplicateReverseJoin().run() \ No newline at end of file diff --git a/extensions/fablabchemnitz/duplicate_reverse_join/meta.json b/extensions/fablabchemnitz/duplicate_reverse_join/meta.json new file mode 100644 index 0000000..496aa83 --- /dev/null +++ b/extensions/fablabchemnitz/duplicate_reverse_join/meta.json @@ -0,0 +1,21 @@ +[ + { + "name": "Duplicate + Reverse + Join", + "id": "fablabchemnitz.de.duplicate_reverse_join", + "path": "duplicate_reverse_join", + "dependent_extensions": null, + "original_name": "Duplicate, reverse, join", + "original_id": "EllenWasbo.cutlings.duplicateReverseJoin", + "license": "GNU GPL v2", + "license_url": "https://gitlab.com/EllenWasbo/inkscape-extension-duplicatereversejoin/-/blob/master/duplicateReverseJoin.py", + "comment": "", + "source_url": "https://gitea.fablabchemnitz.de/FabLab_Chemnitz/mightyscape-1.2/src/branch/master/extensions/fablabchemnitz/duplicate_reverse_joi", + "fork_url": "https://gitlab.com/EllenWasbo/inkscape-extension-duplicatereversejoin", + "documentation_url": "https://stadtfabrikanten.org/pages/viewpage.action?pageId=120525059", + "inkscape_gallery_url": null, + "main_authors": [ + "gitlab.com/EllenWasbo", + "github.com/eridur-de" + ] + } +] \ No newline at end of file diff --git a/extensions/fablabchemnitz/epilog_dashboard_bbox_adjust/epilog_dashboard_bbox_adjust.inx b/extensions/fablabchemnitz/epilog_dashboard_bbox_adjust/epilog_dashboard_bbox_adjust.inx new file mode 100644 index 0000000..35f09b5 --- /dev/null +++ b/extensions/fablabchemnitz/epilog_dashboard_bbox_adjust/epilog_dashboard_bbox_adjust.inx @@ -0,0 +1,64 @@ + + + Epilog Dashboard BBox Adjust + fablabchemnitz.de.epilog_dashboard_bbox_adjust + + + false + 1.0 + + + + + + false + + + + + + + + false + false + + + + + + + + + + + + + + + + + + + + + + + + + + ../000_about_fablabchemnitz.svg + + + + all + + + + + + Widen the document to send all lines properly to Epilog Dashboard + + + diff --git a/extensions/fablabchemnitz/epilog_dashboard_bbox_adjust/epilog_dashboard_bbox_adjust.py b/extensions/fablabchemnitz/epilog_dashboard_bbox_adjust/epilog_dashboard_bbox_adjust.py new file mode 100644 index 0000000..7b14afd --- /dev/null +++ b/extensions/fablabchemnitz/epilog_dashboard_bbox_adjust/epilog_dashboard_bbox_adjust.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 + +''' +Extension for InkScape 1.0 +Features +- This tool is a helper to adjust the document border including an offset value, which is added. +Sending vector data to Epilog Dashboard often results in trimmed paths. This leads to wrong geometry where the laser misses to cut them. +So we add a default (small) amount of 1.0 doc units to expand the document's canvas + +Author: Mario Voigt / FabLab Chemnitz +Mail: mario.voigt@stadtfabrikanten.org +Date: 21.04.2021 +Last patch: 12.10.2022 +License: GNU GPL v3 + +#known bugs: +- viewbox/width/height do not correctly apply if documents only contain an object (not a path). After converting it to path it works. Seems to be a bbox problem +- note from 07.05.2021: seems if the following order is viewBox/width/height, or width/height/viewBox, the units are not respected. So me mess around a little bit + +#Todo +- add some way to handle translations properly + +''' + +import math +import sys +import inkex +from inkex import Transform +sys.path.append("../apply_transformations") + +class EpilogDashboardBboxAdjust(inkex.EffectExtension): + + def getElementChildren(self, element, elements = None): + if elements == None: + elements = [] + if element.tag != inkex.addNS('g','svg'): + elements.append(element) + for child in element.getchildren(): + self.getElementChildren(child, elements) + return elements + + def add_arguments(self, pars): + pars.add_argument("--tab") + pars.add_argument("--apply_transformations", type=inkex.Boolean, default=False, help="Run 'Apply Transformations' extension before running vpype. Helps avoiding geometry shifting") + pars.add_argument("--offset", type=float, default="1.0", help="XY Offset (mm) from top left corner") + pars.add_argument("--removal", default="none", help="Remove all elements outside the bounding box or selection") + pars.add_argument("--use_machine_size", type=inkex.Boolean, default=False, help="Use machine size") + pars.add_argument("--machine_size", default="812x508", help="Machine/Size (mm)") + pars.add_argument("--debug", type=inkex.Boolean, default=False, help="Debug output") + pars.add_argument("--skip_errors", type=inkex.Boolean, default=False, help="Skip on errors") + + def effect(self): + + applyTransformationsAvailable = False # at first we apply external extension + try: + import apply_transformations + applyTransformationsAvailable = True + except Exception as e: + # self.msg(e) + self.msg("Calling 'Apply Transformations' extension failed. Maybe the extension is not installed. You can download it from official InkScape Gallery. Skipping ...") + + if self.options.apply_transformations is True and applyTransformationsAvailable is True: + apply_transformations.ApplyTransformations().recursiveFuseTransform(self.document.getroot()) + + offset = self.options.offset + units = self.svg.unit + #https://wiki.inkscape.org/wiki/Units_In_Inkscape + + # create a new bounding box and get the bbox size of all elements of the document (we cannot use the page's bbox) + bbox = inkex.BoundingBox() + if len(self.svg.selected) > 0: + #bbox = self.svg.selection.bounding_box() #it could be so easy! But ... + for element in self.svg.selected.values(): + + if 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() + else: + #for element in self.svg.root.getchildren(): + for element in self.document.getroot().iter("*"): + if isinstance (element, inkex.ShapeElement) and element.tag != inkex.addNS('use','svg') and element.get('inkscape:groupmode') != 'layer': #bbox fails for svg:use elements and layers: + if 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() + + if abs(bbox.width) == math.inf or abs(bbox.height) == math.inf: + inkex.utils.debug("Error while calculating overall bounding box! Check your element types. Things like svg:text or svg:use are not supported. Impossible to continue!") + return + + # adjust the viewBox to the bbox size and add the desired offset + if self.options.use_machine_size is True: + machineWidth = self.svg.unittouu(self.options.machine_size.split('x')[0] + "mm") + machineHeight = self.svg.unittouu(self.options.machine_size.split('x')[1] + "mm") + width = f'{machineWidth}' + units + height = f'{machineHeight}' + units + viewBoxXmin = -offset + viewBoxYmin = -offset + viewBoxXmax = machineWidth + viewBoxYmax = machineHeight + else: + width = f'{bbox.width + offset * 2}' + units + height = f'{bbox.height + offset * 2}' + units + viewBoxXmin = -offset + viewBoxYmin = -offset + viewBoxXmax = bbox.width + offset * 2 + viewBoxYmax = bbox.height + offset * 2 + self.document.getroot().attrib['width'] = width + self.document.getroot().attrib['viewBox'] = f'{viewBoxXmin} {viewBoxYmin} {viewBoxXmax} {viewBoxYmax}' + self.document.getroot().attrib['height'] = height + + # translate all elements to fit the adjusted viewBox + mat = Transform("translate(%f, %f)" % (-bbox.left,-bbox.top)) + for element in self.document.getroot().iter("*"): + if isinstance (element, inkex.ShapeElement) and element.tag != inkex.addNS('g', 'svg'): + element.transform = Transform(mat) @ element.composed_transform() + + if self.options.removal == "outside_canvas": + for element in self.document.getroot().iter("*"): + if isinstance (element, inkex.ShapeElement) and element.tag != inkex.addNS('g', 'svg'): + ebbox = element.bounding_box() + #self.msg("{} | bbox: left = {:0.3f} right = {:0.3f} top = {:0.3f} bottom = {:0.3f}".format(element.get('id'), ebbox.left, ebbox.right, ebbox.top, ebbox.bottom)) + #check if the element's bbox is inside the view canvas. If not: delete it! + if ebbox.right < viewBoxXmin or \ + ebbox.left > viewBoxXmax or \ + ebbox.top < viewBoxYmin or \ + ebbox.bottom > viewBoxYmax: + if self.options.debug is True: + self.msg("Removing {} {}".format(element.get('id'), ebbox)) + element.delete() + + elif self.options.removal == "outside_selection": + if len(self.svg.selected) == 0: + inkex.utils.debug("Your selection is empty but you have chosen the option to remove all elements outside selection!") + return + + allElements = [] + for selected in self.svg.selection: + allElements = self.getElementChildren(selected, allElements) + + for element in self.document.getroot().iter("*"): + if element not in allElements and isinstance (element, inkex.ShapeElement) and element.tag != inkex.addNS('g', 'svg'): + if self.options.debug is True: + self.msg("Removing {}".format(element.get('id'))) + element.delete() + +if __name__ == '__main__': + EpilogDashboardBboxAdjust().run() \ No newline at end of file diff --git a/extensions/fablabchemnitz/epilog_dashboard_bbox_adjust/meta.json b/extensions/fablabchemnitz/epilog_dashboard_bbox_adjust/meta.json new file mode 100644 index 0000000..fbc6ea9 --- /dev/null +++ b/extensions/fablabchemnitz/epilog_dashboard_bbox_adjust/meta.json @@ -0,0 +1,22 @@ +[ + { + "name": "Epilog Dashboard BBox Adjust", + "id": "fablabchemnitz.de.epilog_dashboard_bbox_adjust", + "path": "epilog_dashboard_bbox_adjust", + "dependent_extensions": [ + "apply_transformations" + ], + "original_name": "Epilog Dashboard BBox Adjust", + "original_id": "fablabchemnitz.de.epilog_dashboard_bbox_adjust", + "license": "GNU GPL v3", + "license_url": "https://gitea.fablabchemnitz.de/FabLab_Chemnitz/mightyscape-1.2/src/branch/master/LICENSE", + "comment": "Written by Mario Voigt", + "source_url": "https://gitea.fablabchemnitz.de/FabLab_Chemnitz/mightyscape-1.2/src/branch/master/extensions/fablabchemnitz/epilog_dashboard_bbox_adjust", + "fork_url": null, + "documentation_url": "https://stadtfabrikanten.org/display/IFM/Epilog+Dashboard+BBox+Adjust", + "inkscape_gallery_url": null, + "main_authors": [ + "github.com/eridur-de" + ] + } +] \ No newline at end of file diff --git a/extensions/fablabchemnitz/frame_animation_sequence/frame_animation_sequence.inx b/extensions/fablabchemnitz/frame_animation_sequence/frame_animation_sequence.inx new file mode 100644 index 0000000..ce30f32 --- /dev/null +++ b/extensions/fablabchemnitz/frame_animation_sequence/frame_animation_sequence.inx @@ -0,0 +1,20 @@ + + + Frame Animation Sequence + fablabchemnitz.de.frame_animation_sequence + 0 + indefinite + 9.1 + true + + all + + + + + + + + \ No newline at end of file diff --git a/extensions/fablabchemnitz/frame_animation_sequence/frame_animation_sequence.py b/extensions/fablabchemnitz/frame_animation_sequence/frame_animation_sequence.py new file mode 100644 index 0000000..696486a --- /dev/null +++ b/extensions/fablabchemnitz/frame_animation_sequence/frame_animation_sequence.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2021 roberta bennett repeatingshadow@protonmail.com +# +# 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. +# +""" +Create svg path animation as SMIL from frames. + +Place each frame of the animation in a layer named 'frame'. +Each of these layers should have the same number of paths, +and each path should have the same number of points as the corresponding +path in other layers. +Note if there are more than one path in the frames, the Z order of the paths +must match. It helps to use the XML editor option to observe the z order. + +The animation is applied to the paths in the first layer in the sequence, so the +properties of that layer are used. In particular, the first layer ought to be set +visible. + +Animations with different numbers of frames can be put into different sequences, +named 'sequence', using sub-groups: + +Layers: + not_animated_layer1 + sequence + frame + path1a + path2a + frame + path1b + path2b + frame + path1c + path2c + frame + path1d + path2d + sequence + frame + frame + frame + +Layer names must contain 'frame' and groups names contain 'sequence', +eg, frame1 frame 2 frame 30, sequence 1, rythm sequence, rocket sequence + + +""" + +import inkex + +class AnimateElement(inkex.BaseElement): + """animation Elements do not have a visible representation on the canvas""" + tag_name = 'animate' + @classmethod + def new(cls, **attrs): + return super().new( **attrs) + + +class AnimationExtension(inkex.EffectExtension): + + def add_arguments(self, pars): + pars.add_argument("--delete", type=inkex.Boolean, help="Remove frames") + pars.add_argument("--begin_str", default="0", help="begin string: eg 0;an2.end;an3.begin") + pars.add_argument("--repeat_str", default="indefinite", help="indefinite or an integer") + pars.add_argument("--dur_str", default="7.9", help="duration in seconds. Do not decorate with units") + + + def crunchFrames(self,frames): + if frames is None: + raise inkex.AbortExtension("layer named frame does not exist.") + frame0paths = frames[0].findall('svg:path') + Dlists = ["{}".format(p.get_path()) for p in frame0paths] + for frame in frames[1:]: + paths = frame.findall("svg:path") + for i, p in enumerate(paths): + Dlists[i] += ";\n{}".format(p.get_path()) + for i, dl in enumerate(Dlists): + animel = AnimateElement( + attributeName="d", + attributeType="XML", + begin=self.options.begin_str, + dur=self.options.dur_str, + repeatCount=self.options.repeat_str, + values=dl) + frame0paths[i].append(animel) + for frame in frames[1:]: + if self.options.delete: + frame.delete() + return + + + def effect(self): + sequences = [ elem for elem in self.svg.findall("svg:g[@inkscape:label]") + if "sequence" in (elem.get('inkscape:label')) + ] + if len(sequences)==0: + frames = [ elem for elem in self.svg.findall("svg:g[@inkscape:label]") + if "frame" in (elem.get('inkscape:label')) + ] + self.crunchFrames(frames) + return + for sequence in sequences: + frames = [ elem for elem in sequence.findall("svg:g[@inkscape:label]") + if "frame" in (elem.get('inkscape:label')) + ] + self.crunchFrames(frames) + + +if __name__ == '__main__': + AnimationExtension().run() \ No newline at end of file diff --git a/extensions/fablabchemnitz/frame_animation_sequence/meta.json b/extensions/fablabchemnitz/frame_animation_sequence/meta.json new file mode 100644 index 0000000..4b6d85d --- /dev/null +++ b/extensions/fablabchemnitz/frame_animation_sequence/meta.json @@ -0,0 +1,21 @@ +[ + { + "name": "Frame Animation Sequence", + "id": "fablabchemnitz.de.frame_animation_sequence", + "path": "frame_animation_sequence", + "dependent_extensions": null, + "original_name": "Animate Extension", + "original_id": "org.inkscape.katkitty.animate", + "license": "GNU GPL v2", + "license_url": "https://github.com/yttiktak/inkscape_extension_animate/blob/main/AnimationExtension.py", + "comment": "", + "source_url": "https://gitea.fablabchemnitz.de/FabLab_Chemnitz/mightyscape-1.2/src/branch/master/extensions/fablabchemnitz/frame_animation_sequence", + "fork_url": "https://github.com/yttiktak/inkscape_extension_animate", + "documentation_url": "https://stadtfabrikanten.org/display/IFM/Frame+Animation+Sequence", + "inkscape_gallery_url": null, + "main_authors": [ + "github.com/yttiktak", + "github.com/eridur-de" + ] + } +] \ No newline at end of file diff --git a/extensions/fablabchemnitz/guillotine_plus/guillotine_plus.inx b/extensions/fablabchemnitz/guillotine_plus/guillotine_plus.inx new file mode 100644 index 0000000..31750db --- /dev/null +++ b/extensions/fablabchemnitz/guillotine_plus/guillotine_plus.inx @@ -0,0 +1,20 @@ + + + Guillotine Plus + fablabchemnitz.de.guillotine_plus + ~/ + guillotined + 300 + false + + all + + + + + + + + diff --git a/extensions/fablabchemnitz/guillotine_plus/guillotine_plus.py b/extensions/fablabchemnitz/guillotine_plus/guillotine_plus.py new file mode 100644 index 0000000..b7680b5 --- /dev/null +++ b/extensions/fablabchemnitz/guillotine_plus/guillotine_plus.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2010 Craig Marshall, craig9 [at] gmail.com +# +# 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 +# +""" +This script slices an inkscape drawing along the guides, similarly to +the GIMP plugin called "guillotine". It can optionally export to the +same directory as the SVG file with the same name, but with a number +suffix. e.g. + +/home/foo/drawing.svg + +will export to: + +/home/foo/drawing0.png +/home/foo/drawing1.png +/home/foo/drawing2.png +/home/foo/drawing3.png + +etc. + +""" + +import os +import locale +import inkex +from inkex.command import inkscape + +class GuillotinePlus(inkex.EffectExtension): + + """Exports slices made using guides""" + def add_arguments(self, pars): + pars.add_argument("--directory") + pars.add_argument("--image") + pars.add_argument("--ignore", type=inkex.Boolean) + pars.add_argument("--dpi", type=int) + + def get_all_horizontal_guides(self): + """ + Returns all horizontal guides as a list of floats stored as + strings. Each value is the position from 0 in pixels. + """ + for guide in self.svg.namedview.get_guides(): + if guide.is_horizontal: + yield guide.point.y + + def get_all_vertical_guides(self): + """ + Returns all vertical guides as a list of floats stored as + strings. Each value is the position from 0 in pixels. + """ + for guide in self.svg.namedview.get_guides(): + if guide.is_vertical: + yield guide.point.x + + def get_horizontal_slice_positions(self): + """ + Make a sorted list of all horizontal guide positions, + including 0 and the document height, but not including + those outside of the canvas + """ + horizontals = [0.0] + height = float(self.svg.viewport_height) + for y in self.get_all_horizontal_guides(): + if 0.0 < y <= height: + horizontals.append(y) + horizontals.append(height) + return sorted(horizontals) + + def get_vertical_slice_positions(self): + """ + Make a sorted list of all vertical guide positions, + including 0 and the document width, but not including + those outside of the canvas. + """ + verticals = [0.0] + width = float(self.svg.viewport_width) + for x in self.get_all_vertical_guides(): + if 0.0 < x <= width: + verticals.append(x) + verticals.append(width) + return sorted(verticals) + + def get_slices(self): + """ + Returns a list of all "slices" as denoted by the guides + on the page. Each slice is really just a 4 element list of + floats (stored as strings), consisting of the X and Y start + position and the X and Y end position. + """ + hs = self.get_horizontal_slice_positions() + vs = self.get_vertical_slice_positions() + slices = [] + for i in range(len(hs) - 1): + for j in range(len(vs) - 1): + slices.append([vs[j], hs[i], vs[j + 1], hs[i + 1]]) + return slices + + def get_filename_parts(self): + """ + Attempts to get directory and image as passed in by the inkscape + dialog. If the boolean ignore flag is set, then it will ignore + these settings and try to use the settings from the export + filename. + """ + + if not self.options.ignore: + if self.options.image == "" or self.options.image is None: + raise inkex.AbortExtension("Please enter an image name") + return self.options.directory, self.options.image + else: + ''' + First get the export-filename from the document, if the + document has been exported before (TODO: Will not work if it + hasn't been exported yet), then uses this to return a tuple + consisting of the directory to export to, and the filename + without extension. + ''' + + warning = \ + "To use the export hints option, you " + \ + "need to have previously exported the document. " + \ + "Otherwise no export hints exist!" + + try: + export_file = self.svg.get('inkscape:export-filename') + except KeyError: + raise inkex.AbortExtension(warning) + if export_file is None: + raise inkex.AbortExtension(warning) + dirname, filename = os.path.split(export_file) + filename = filename.rsplit(".", 1)[0] # Without extension + return dirname, filename + + def get_localised_string(self, name): + return locale.format_string("%.f", float(name), 0) + + def export_slice(self, sli, filename): + """ + Runs inkscape's command line interface and exports the image + slice from the 4 coordinates in s, and saves as the filename + given. + """ + coords = ":".join([self.get_localised_string(dim) for dim in sli]) + inkscape(self.options.input_file, export_dpi=self.options.dpi, export_area=coords, export_filename=filename) + + def export_slices(self, slices): + """ + Takes the slices list and passes each one with a calculated + filename/directory into export_slice. + """ + dirname, filename = self.get_filename_parts() + # Remove some crusty extensions from name template + if filename.endswith('.svg') or filename.endswith('.png'): + filename = filename.rsplit('.', 1)[0] + if '{' not in filename: + filename += '_{}' + + dirname = os.path.abspath(os.path.expanduser(os.path.expandvars(dirname or './'))) + if not os.path.isdir(dirname): + try: + os.makedirs(dirname) + except IOError: + raise inkex.AbortExtension("Target directory could not be created. Change and try again!") + + output_files = [] + for i, slico in enumerate(slices): + fname = os.path.join(dirname, filename.format(i) + '.png') + output_files.append(fname) + self.export_slice(slico, fname) + + #self.debug("The sliced bitmaps have been saved as:" + "\n\n" + "\n".join(output_files)) + + def effect(self): + self.export_slices(self.get_slices()) + +if __name__ == '__main__': + GuillotinePlus().run() diff --git a/extensions/fablabchemnitz/guillotine_plus/meta.json b/extensions/fablabchemnitz/guillotine_plus/meta.json new file mode 100644 index 0000000..fbbd087 --- /dev/null +++ b/extensions/fablabchemnitz/guillotine_plus/meta.json @@ -0,0 +1,21 @@ +[ + { + "name": "Guillotine Plus", + "id": "fablabchemnitz.de.guillotine_plus", + "path": "guillotine_plus", + "dependent_extensions": null, + "original_name": "Guillotine Plus", + "original_id": "org.inkscape.guillotineplus", + "license": "GNU GPL v2", + "license_url": "https://inkscape.org/de/~Bootta/%E2%98%85guillotine-plus", + "comment": "", + "source_url": "https://gitea.fablabchemnitz.de/FabLab_Chemnitz/mightyscape-1.2/src/branch/master/extensions/fablabchemnitz/guillotine_plus", + "fork_url": "https://inkscape.org/de/~Bootta/%E2%98%85guillotine-plus", + "documentation_url": "https://stadtfabrikanten.org/display/IFM/Guillotine+Plus", + "inkscape_gallery_url": null, + "main_authors": [ + "inkscape.org/Bootta", + "github.com/eridur-de" + ] + } +] \ No newline at end of file diff --git a/extensions/fablabchemnitz/import_attributes/import_attributes.inx b/extensions/fablabchemnitz/import_attributes/import_attributes.inx new file mode 100644 index 0000000..efd07a4 --- /dev/null +++ b/extensions/fablabchemnitz/import_attributes/import_attributes.inx @@ -0,0 +1,20 @@ + + + Import Attributes + fablabchemnitz.de.import_attributes + + + + + + all + + + + + + + + \ No newline at end of file diff --git a/extensions/fablabchemnitz/import_attributes/import_attributes.py b/extensions/fablabchemnitz/import_attributes/import_attributes.py new file mode 100644 index 0000000..5ae16d8 --- /dev/null +++ b/extensions/fablabchemnitz/import_attributes/import_attributes.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 + +import inkex +import os +import lxml + +class ImportAttributes(inkex.EffectExtension): + + def add_arguments(self, pars): + pars.add_argument("--data", default="", help="data file") + + def effect(self): + + if not os.path.exists(self.options.data): + self.msg("The input file does not exist. Please select a proper file and try again.") + exit(1) + + if os.path.isdir(self.options.data): + self.msg("You must specify a file, not a directory!") + exit(1) + + with open(self.options.data, 'r') as f: + lines = f.read().splitlines() + for line in lines: + #split on , max 2+1 = 3 items + parts = line.split(",", 2) + if len(parts) >= 3: + id = parts[0] + attribute = parts[1] + value = parts[2] + try: + node = self.svg.getElementById(id) + if node is not None: + try: + node.set(attribute, value) + except AttributeError: + inkex.utils.debug("Unknown Attribute") + except AttributeError: + inkex.utils.debug("element with id '" + id + "' not found in current selection.") + except lxml.etree.XPathEvalError: + inkex.utils.debug("invalid input file") + exit(1) + +if __name__ == '__main__': + ImportAttributes().run() \ No newline at end of file diff --git a/extensions/fablabchemnitz/import_attributes/meta.json b/extensions/fablabchemnitz/import_attributes/meta.json new file mode 100644 index 0000000..79c77a2 --- /dev/null +++ b/extensions/fablabchemnitz/import_attributes/meta.json @@ -0,0 +1,21 @@ +[ + { + "name": "Import Attributes", + "id": "fablabchemnitz.de.import_attributes", + "path": "import_attributes", + "dependent_extensions": null, + "original_name": "Import Attributes", + "original_id": "com.mathem.attrib_import.py", + "license": "GNU LGPL v2", + "license_url": "https://inkscape.org/~MatheM/%E2%98%85simple-attribute-editor+1", + "comment": "ported to Inkscape v1 manually by Mario Voigt", + "source_url": "https://gitea.fablabchemnitz.de/FabLab_Chemnitz/mightyscape-1.2/src/branch/master/extensions/fablabchemnitz/import_attributes", + "fork_url": "https://inkscape.org/~MatheM/%E2%98%85simple-attribute-editor+1", + "documentation_url": "https://stadtfabrikanten.org/display/IFM/Import+Attributes", + "inkscape_gallery_url": null, + "main_authors": [ + "inkscape.org/MatheM", + "github.com/eridur-de" + ] + } +] \ No newline at end of file diff --git a/extensions/fablabchemnitz/isometric_projection/dimetric_projection.inx b/extensions/fablabchemnitz/isometric_projection/dimetric_projection.inx new file mode 100644 index 0000000..4c3497b --- /dev/null +++ b/extensions/fablabchemnitz/isometric_projection/dimetric_projection.inx @@ -0,0 +1,23 @@ + + + Dimetric Projection + fablabchemnitz.de.dimetric_projection + + + + + + false + 15.000 + + all + + + + + + + + \ No newline at end of file diff --git a/extensions/fablabchemnitz/isometric_projection/isometric_projection.inx b/extensions/fablabchemnitz/isometric_projection/isometric_projection.inx new file mode 100644 index 0000000..e18b5f0 --- /dev/null +++ b/extensions/fablabchemnitz/isometric_projection/isometric_projection.inx @@ -0,0 +1,23 @@ + + + Isometric Projection + fablabchemnitz.de.isometric_projection + + + + + + false + 30.000 + + all + + + + + + + + \ No newline at end of file diff --git a/extensions/fablabchemnitz/isometric_projection/isometric_projection.py b/extensions/fablabchemnitz/isometric_projection/isometric_projection.py new file mode 100644 index 0000000..f23a262 --- /dev/null +++ b/extensions/fablabchemnitz/isometric_projection/isometric_projection.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 + +import math +import inkex +from inkex.transforms import Transform + +class IsometricProjection(inkex.EffectExtension): + """ + Convert a flat 2D projection to one of the three visible sides in an + isometric projection, and vice versa. + """ + + attrTransformCenterX = inkex.addNS('transform-center-x', 'inkscape') + attrTransformCenterY = inkex.addNS('transform-center-y', 'inkscape') + + # Precomputed values for sine, cosine, and tangent of 30°. + rad_30 = math.radians(30) + cos_30 = math.cos(rad_30) + sin_30 = 0.5 # No point in using math.sin for 30°. + tan_30 = math.tan(rad_30) + + # Combined affine transformation matrices. The bottom row of these 3×3 + # matrices is omitted; it is always [0, 0, 1]. + transformations = { + # From 2D to isometric top down view: + # * scale vertically by cos(30°) + # * shear horizontally by -30° + # * rotate clock-wise 30° + 'to_top': [[cos_30, -cos_30, 0], + [sin_30, sin_30, 0]], + + # From 2D to isometric left-hand side view: + # * scale horizontally by cos(30°) + # * shear vertically by -30° + 'to_left': [[cos_30, 0, 0], + [sin_30, 1, 0]], + + # From 2D to isometric right-hand side view: + # * scale horizontally by cos(30°) + # * shear vertically by 30° + 'to_right': [[cos_30, 0, 0], + [-sin_30, 1, 0]], + + # From isometric top down view to 2D: + # * rotate counter-clock-wise 30° + # * shear horizontally by 30° + # * scale vertically by 1 / cos(30°) + 'from_top': [[tan_30, 1, 0], + [-tan_30, 1, 0]], + + # From isometric left-hand side view to 2D: + # * shear vertically by 30° + # * scale horizontally by 1 / cos(30°) + 'from_left': [[1 / cos_30, 0, 0], + [-tan_30, 1, 0]], + + # From isometric right-hand side view to 2D: + # * shear vertically by -30° + # * scale horizontally by 1 / cos(30°) + 'from_right': [[1 / cos_30, 0, 0], + [tan_30, 1, 0]] + } + + def add_arguments(self, pars): + pars.add_argument('--conversion', default='top', help='Conversion to perform: (top|left|right)') + pars.add_argument('--reverse', type=inkex.Boolean, default=False, help='Reverse the transformation from isometric projection to flat 2D') + self.arg_parser.add_argument('--orthoangle', type=float, default=15.0, help='Isometric angle in degrees') + + def __initConstants(self, angle): + # Precomputed values for sine, cosine, and tangent of orthoangle. + self.rad = math.radians(angle) + self.cos = math.cos(self.rad) + self.sin = math.sin(self.rad) + self.tan = math.tan(self.rad) + + # Combined affine transformation matrices. The bottom row of these 3×3 + # matrices is omitted; it is always [0, 0, 1]. + self.transformations = { + # From 2D to isometric top down view: + # * scale vertically by cos(∠) + # * shear horizontally by -∠ + # * rotate clock-wise ∠ + 'to_top': Transform(((self.cos, -self.cos, 0), + (self.sin, self.sin, 0))), + + # From 2D to isometric left-hand side view: + # * scale horizontally by cos(∠) + # * shear vertically by -∠ + 'to_left': Transform(((self.cos, 0, 0), + (self.sin, 1, 0))), + + # From 2D to isometric right-hand side view: + # * scale horizontally by cos(∠) + # * shear vertically by ∠ + 'to_right': Transform(((self.cos , 0, 0), + (-self.sin, 1, 0))), + + # From isometric top down view to 2D: + # * rotate counter-clock-wise orthoangle + # * shear horizontally by orthoangle + # * scale vertically by 1 / cos(orthoangle) + 'from_top': [[self.tan , 1, 0], + [-self.tan, 1, 0]], + + # From isometric left-hand side view to 2D: + # * shear vertically by orthoangle + # * scale horizontally by 1 / cos(orthoangle) + 'from_left': [[1 / self.cos, 0, 0], + [-self.tan, 1, 0]], + + # From isometric right-hand side view to 2D: + # * shear vertically by -orthoangle + # * scale horizontally by 1 / cos(orthoangle) + 'from_right': [[1 / self.cos, 0, 0], + [self.tan, 1, 0]] + } + + # The inverse matrices of the above perform the reverse transformations. + self.transformations['from_top'] = -self.transformations['to_top'] + self.transformations['from_left'] = -self.transformations['to_left'] + self.transformations['from_right'] = -self.transformations['to_right'] + + def getTransformCenter(self, midpoint, node): + """ + Find the transformation center of an object. If the user set it + manually by dragging it in Inkscape, those coordinates are used. + Otherwise, an attempt is made to find the center of the object's + bounding box. + """ + + c_x = node.get(self.attrTransformCenterX) + c_y = node.get(self.attrTransformCenterY) + + # Default to dead-center. + if c_x is None: + c_x = 0.0 + else: + c_x = float(c_x) + if c_y is None: + c_y = 0.0 + else: + c_y = float(c_y) + + x = midpoint[0] + c_x + y = midpoint[1] - c_y + + return [x, y] + + def translateBetweenPoints(self, tr, here, there): + """ + Add a translation to a matrix that moves between two points. + """ + + x = there[0] - here[0] + y = there[1] - here[1] + tr.add_translate(x, y) + + def moveTransformationCenter(self, node, midpoint, center_new): + """ + If a transformation center is manually set on the node, move it to + match the transformation performed on the node. + """ + + c_x = node.get(self.attrTransformCenterX) + c_y = node.get(self.attrTransformCenterY) + + if c_x is not None: + x = str(center_new[0] - midpoint[0]) + node.set(self.attrTransformCenterX, x) + if c_y is not None: + y = str(midpoint[1] - center_new[1]) + node.set(self.attrTransformCenterY, y) + + def effect(self): + + self.__initConstants(self.options.orthoangle) + + if self.options.reverse is True: + conversion = "from_" + self.options.conversion + else: + conversion = "to_" + self.options.conversion + + if len(self.svg.selected) == 0: + inkex.errormsg("Please select an object to perform the " + + "isometric projection transformation on.") + return + + # Default to the flat 2D to isometric top down view conversion if an + # invalid identifier is passed. + effect_matrix = self.transformations.get( + conversion, self.transformations.get('to_top')) + + for id, node in self.svg.selected.items(): + bbox = node.bounding_box() + midpoint = [bbox.center_x, bbox.center_y] + center_old = self.getTransformCenter(midpoint, node) + transform = Transform(node.get("transform")) + # Combine our transformation matrix with any pre-existing transform. + tr = transform @ effect_matrix + + # Compute the location of the transformation center after applying + # the transformation matrix. + center_new = center_old[:] + #Transform(matrix).apply_to_point(center_new) + tr.apply_to_point(center_new) + tr.apply_to_point(midpoint) + + # Add a translation transformation that will move the object to + # keep its transformation center in the same place. + self.translateBetweenPoints(tr, center_new, center_old) + + node.set('transform', str(tr)) + + # Adjust the transformation center. + self.moveTransformationCenter(node, midpoint, center_new) + +if __name__ == '__main__': + IsometricProjection().run() \ No newline at end of file diff --git a/extensions/fablabchemnitz/isometric_projection/meta.json b/extensions/fablabchemnitz/isometric_projection/meta.json new file mode 100644 index 0000000..2739b97 --- /dev/null +++ b/extensions/fablabchemnitz/isometric_projection/meta.json @@ -0,0 +1,21 @@ +[ + { + "name": "Isometric Projection", + "id": "fablabchemnitz.de.isometric_projection", + "path": "isometric_projection", + "dependent_extensions": null, + "original_name": "Isometric Projection", + "original_id": "nl.jeroenhoek.inkscape.filter.isometric_projection_tool", + "license": "GNU GPL v3", + "license_url": "https://github.com/jdhoek/inkscape-isometric-projection/blob/master/LICENSE", + "comment": "", + "source_url": "https://gitea.fablabchemnitz.de/FabLab_Chemnitz/mightyscape-1.2/src/branch/master/extensions/fablabchemnitz/isometric_projection", + "fork_url": "https://github.com/jdhoek/inkscape-isometric-projection", + "documentation_url": "https://stadtfabrikanten.org/display/IFM/Isometric+Projection", + "inkscape_gallery_url": null, + "main_authors": [ + "github.com/jdhoek", + "github.com/eridur-de" + ] + } +] \ No newline at end of file diff --git a/extensions/fablabchemnitz/low_poly_2/low_poly_2.inx b/extensions/fablabchemnitz/low_poly_2/low_poly_2.inx new file mode 100644 index 0000000..570f644 --- /dev/null +++ b/extensions/fablabchemnitz/low_poly_2/low_poly_2.inx @@ -0,0 +1,16 @@ + + + Low Poly 2 + fablabchemnitz.de.low_poly_2 + + all + + + + + + + + \ No newline at end of file diff --git a/extensions/fablabchemnitz/low_poly_2/low_poly_2.py b/extensions/fablabchemnitz/low_poly_2/low_poly_2.py new file mode 100644 index 0000000..f61d7c2 --- /dev/null +++ b/extensions/fablabchemnitz/low_poly_2/low_poly_2.py @@ -0,0 +1,301 @@ +#!/usr/bin/env python3 + +import os +import inkex +import voronoi +from inkex.transforms import Transform +from inkex.paths import CubicSuperPath, Path +from PIL import Image +from lxml import etree +import base64 +from io import BytesIO +import urllib.request as urllib + +# A tool for making polygonal art. Can be created with one click with a pass. + +class Point: + def __init__(self, x, y): + self.x = x + self.y = y + def __lt__(self,other): + return (self.x*self.yother.x*other.y) + + def __ge__(self,other): + return (self.x*self.y>=other.x*other.y) + + def __eq__(self,other): + return (self.x==other.x) and (self.y==other.y) + + def __ne__(self,other): + return (self.x!=other.x) or (self.y!=other.y) + + def __str__(self): + return "("+str(self.x)+","+str(self.y)+")" + + +class LowPoly2(inkex.Effect): + def __init__(self): + inkex.Effect.__init__(self) + + # Clipping a line by a bounding box + def dot(self, x, y): + return x[0] * y[0] + x[1] * y[1] + + def intersectLineSegment(self, line, v1, v2): + s1 = self.dot(line, v1) - line[2] + s2 = self.dot(line, v2) - line[2] + if s1 * s2 > 0: + return (0, 0, False) + else: + tmp = self.dot(line, v1) - self.dot(line, v2) + if tmp == 0: + return (0, 0, False) + u = (line[2] - self.dot(line, v2)) / tmp + v = 1 - u + return (u * v1[0] + v * v2[0], u * v1[1] + v * v2[1], True) + + def clipEdge(self, vertices, lines, edge, bbox): + # bounding box corners + bbc = [] + bbc.append((bbox[0], bbox[2])) + bbc.append((bbox[1], bbox[2])) + bbc.append((bbox[1], bbox[3])) + bbc.append((bbox[0], bbox[3])) + + # record intersections of the line with bounding box edges + line = (lines[edge[0]]) + interpoints = [] + for i in range(4): + p = self.intersectLineSegment(line, bbc[i], bbc[(i + 1) % 4]) + if (p[2]): + interpoints.append(p) + + # if the edge has no intersection, return empty intersection + if (len(interpoints) < 2): + return [] + + if (len(interpoints) > 2): #h appens when the edge crosses the corner of the box + interpoints = list(set(interpoints)) # remove doubles + + # points of the edge + v1 = vertices[edge[1]] + interpoints.append((v1[0], v1[1], False)) + v2 = vertices[edge[2]] + interpoints.append((v2[0], v2[1], False)) + + # sorting the points in the widest range to get them in order on the line + minx = interpoints[0][0] + maxx = interpoints[0][0] + miny = interpoints[0][1] + maxy = interpoints[0][1] + for point in interpoints: + minx = min(point[0], minx) + maxx = max(point[0], maxx) + miny = min(point[1], miny) + maxy = max(point[1], maxy) + + if (maxx - minx) > (maxy - miny): + interpoints.sort() + else: + interpoints.sort(key=lambda pt: pt[1]) + + start = [] + inside = False #true when the part of the line studied is in the clip box + startWrite = False #true when the part of the line is in the edge segment + for point in interpoints: + if point[2]: #The point is a bounding box intersection + if inside: + if startWrite: + return [[start[0], start[1]], [point[0], point[1]]] + else: + return [] + else: + if startWrite: + start = point + inside = not inside + else: # The point is a segment endpoint + if startWrite: + if inside: + # a vertex ends the line inside the bounding box + return [[start[0], start[1]], [point[0], point[1]]] + else: + return [] + else: + if inside: + start = point + startWrite = not startWrite + + # Transformation helpers + def invertTransform(self, mat): + det = mat[0][0] * mat[1][1] - mat[0][1] * mat[1][0] + if det != 0: #det is 0 only in case of 0 scaling + # invert the rotation/scaling part + a11 = mat[1][1] / det + a12 = -mat[0][1] / det + a21 = -mat[1][0] / det + a22 = mat[0][0] / det + + # invert the translational part + a13 = -(a11 * mat[0][2] + a12 * mat[1][2]) + a23 = -(a21 * mat[0][2] + a22 * mat[1][2]) + + return [[a11, a12, a13], [a21, a22, a23]] + else: + return [[0, 0, -mat[0][2]], [0, 0, -mat[1][2]]] + + def getGlobalTransform(self, node): + parent = node.getparent() + myTrans = Transform(node.get('transform')).matrix + if myTrans: + if parent is not None: + parentTrans = self.getGlobalTransform(parent) + if parentTrans: + return Transform(parentTrans) * Transform(myTrans) + else: + return myTrans + else: + if parent is not None: + return self.getGlobalTransform(parent) + else: + return None + + def checkImagePath(self, node): + """Embed the data of the selected Image Tag element""" + xlink = node.get('xlink:href') + if xlink and xlink[:5] == 'data:': + # No need, data alread embedded + return + + url = urllib.urlparse(xlink) + href = urllib.url2pathname(url.path) + + # Primary location always the filename itself. + path = self.absolute_href(href or '') + + # Backup directory where we can find the image + if not os.path.isfile(path): + path = node.get('sodipodi:absref', path) + + if not os.path.isfile(path): + inkex.errormsg('File not found "{}". Unable to embed image.').format(path) + return + + if (os.path.isfile(path)): + return path + + def effect(self): + # Check that elements have been selected + if len(self.options.ids) == 0: + inkex.errormsg("Please select objects!") + return + + # Drawing styles + linestyle = { + 'stroke': '#000000', + 'stroke-width': str(self.svg.unittouu('1px')), + 'fill': 'none' + } + + facestyle = { + 'stroke': '#000000', + 'stroke-width':'0px',# str(self.svg.unittouu('1px')), + 'fill': 'none' + } + + # Handle the transformation of the current group + parentGroup = (self.svg.selected[self.options.ids[0]]).getparent() + + svg = self.document.getroot() + image_element = svg.find('.//{http://www.w3.org/2000/svg}image') + if image_element is None: + inkex.utils.debug("No image found") + exit(1) + self.path = self.checkImagePath(image_element) # This also ensures the file exists + if self.path is None: # check if image is embedded or linked + image_string = image_element.get('{http://www.w3.org/1999/xlink}href') + # find comma position + i = 0 + while i < 40: + if image_string[i] == ',': + break + i = i + 1 + img = Image.open(BytesIO(base64.b64decode(image_string[i + 1:len(image_string)]))) + else: + img = Image.open(self.path) + + extrinsic_image_width=float(image_element.get('width')) + extrinsic_image_height=float(image_element.get('height')) + (width, height) = img.size + trans = self.getGlobalTransform(parentGroup) + invtrans = None + if trans: + invtrans = self.invertTransform(trans) + + # Recovery of the selected objects + pts = [] + nodes = [] + seeds = [] + + for id in self.options.ids: + node = self.svg.selected[id] + nodes.append(node) + if(node.tag=="{http://www.w3.org/2000/svg}path"):#If it is path + # Get vertex coordinates of path + points = CubicSuperPath(node.get('d')) + for p in points[0]: + pt=[p[1][0],p[1][1]] + if trans: + Transform(trans).apply_to_point(pt) + pts.append(Point(pt[0], pt[1])) + seeds.append(Point(p[1][0], p[1][1])) + else: # For other shapes + bbox = node.bounding_box() + if bbox: + cx = 0.5 * (bbox.left + bbox.top) + cy = 0.5 * (bbox.top + bbox.bottom) + pt = [cx, cy] + if trans: + Transform(trans).apply_to_point(pt) + pts.append(Point(pt[0], pt[1])) + seeds.append(Point(cx, cy)) + pts.sort() + seeds.sort() + + # In Creation of groups to store the result + # Delaunay + groupDelaunay = etree.SubElement(parentGroup, inkex.addNS('g', 'svg')) + groupDelaunay.set(inkex.addNS('label', 'inkscape'), 'Delaunay') + + scale_x=float(extrinsic_image_width)/float(width) + scale_y=float(extrinsic_image_height)/float(height) + # Voronoi diagram generation + + triangles = voronoi.computeDelaunayTriangulation(seeds) + for triangle in triangles: + p1 = seeds[triangle[0]] + p2 = seeds[triangle[1]] + p3 = seeds[triangle[2]] + cmds = [['M', [p1.x, p1.y]], + ['L', [p2.x, p2.y]], + ['L', [p3.x, p3.y]], + ['Z', []]] + path = etree.Element(inkex.addNS('path', 'svg')) + path.set('d', str(Path(cmds))) + middleX=(p1.x+p2.x+p3.x)/3.0 + middleY=(p1.y+p2.y+p3.y)/3.0 + if width>middleX and height>middleY and middleX>=0 and middleY>=0: + pixelColor = img.getpixel((middleX,middleY)) + facestyle["fill"]=str(inkex.Color((pixelColor[0], pixelColor[1], pixelColor[2]))) + else: + facestyle["fill"]="black" + path.set('style', str(inkex.Style(facestyle))) + groupDelaunay.append(path) + +if __name__ == '__main__': + LowPoly2().run() \ No newline at end of file diff --git a/extensions/fablabchemnitz/low_poly_2/meta.json b/extensions/fablabchemnitz/low_poly_2/meta.json new file mode 100644 index 0000000..b32267e --- /dev/null +++ b/extensions/fablabchemnitz/low_poly_2/meta.json @@ -0,0 +1,21 @@ +[ + { + "name": "Low Poly 2", + "id": "fablabchemnitz.de.low_poly_2", + "path": "low_poly_2", + "dependent_extensions": null, + "original_name": "★Delauney From Path", + "original_id": "miffy.sora.polygonalart", + "license": "MIT License", + "license_url": "https://github.com/miffysora/Inkscape/blob/master/LICENSE", + "comment": "ported to Inkscape v1 manually by Mario Voigt; delauney_from_path", + "source_url": "https://gitea.fablabchemnitz.de/FabLab_Chemnitz/mightyscape-1.2/src/branch/master/extensions/fablabchemnitz/low_poly_2", + "fork_url": "https://github.com/miffysora/Inkscape", + "documentation_url": "https://stadtfabrikanten.org/display/IFM/Low+Poly+2", + "inkscape_gallery_url": null, + "main_authors": [ + "github.com/miffysora", + "github.com/eridur-de" + ] + } +] \ No newline at end of file diff --git a/extensions/fablabchemnitz/open_in_roland_cutstudio/meta.json b/extensions/fablabchemnitz/open_in_roland_cutstudio/meta.json new file mode 100644 index 0000000..60d96a0 --- /dev/null +++ b/extensions/fablabchemnitz/open_in_roland_cutstudio/meta.json @@ -0,0 +1,21 @@ +[ + { + "name": "Open In Roland CutStudio", + "id": "fablabchemnitz.de.open_in_roland_cutstudio", + "path": "open_in_roland_cutstudio", + "dependent_extensions": null, + "original_name": "Open in CutStudio", + "original_id": "roland_custudio.export", + "license": "GNU GPL v2", + "license_url": "https://github.com/mgmax/inkscape-roland-cutstudio/blob/master/roland_cutstudio.py", + "comment": "", + "source_url": "https://gitea.fablabchemnitz.de/FabLab_Chemnitz/mightyscape-1.2/src/branch/master/extensions/fablabchemnitz/open_in_roland_cutstudio", + "fork_url": "https://github.com/mgmax/inkscape-roland-cutstudio", + "documentation_url": "https://stadtfabrikanten.org/display/IFM/Open+in+Roland+CutStudio", + "inkscape_gallery_url": null, + "main_authors": [ + "github.com/mgmax", + "github.com/eridur-de" + ] + } +] \ No newline at end of file diff --git a/extensions/fablabchemnitz/open_in_roland_cutstudio/open_in_roland_cutstudio.inx b/extensions/fablabchemnitz/open_in_roland_cutstudio/open_in_roland_cutstudio.inx new file mode 100644 index 0000000..15d3bfd --- /dev/null +++ b/extensions/fablabchemnitz/open_in_roland_cutstudio/open_in_roland_cutstudio.inx @@ -0,0 +1,16 @@ + + + Open In Roland CutStudio + fablabchemnitz.de.open_in_roland_cutstudio + + path + + + + + + + + diff --git a/extensions/fablabchemnitz/open_in_roland_cutstudio/open_in_roland_cutstudio.py b/extensions/fablabchemnitz/open_in_roland_cutstudio/open_in_roland_cutstudio.py new file mode 100644 index 0000000..3cbbecd --- /dev/null +++ b/extensions/fablabchemnitz/open_in_roland_cutstudio/open_in_roland_cutstudio.py @@ -0,0 +1,435 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-2.0-or-later +''' +Roland CutStudio export script +Copyright (C) 2014 - 2020 Max Gaukler + +skeleton based on Visicut Inkscape Plugin : +Copyright (C) 2012 Thomas Oster, thomas.oster@rwth-aachen.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 +''' + +# The source code is a horrible mess. I apologize for your inconvenience, but hope that it still helps. Feel free to improve :-) +# Keep everything python2 compatible as long as people out there are using Inkscape <= 0.92.4! + +from __future__ import division +from __future__ import print_function +from __future__ import unicode_literals +from __future__ import absolute_import + +from builtins import open +from builtins import map +from builtins import str +from builtins import range +import sys +import os +from subprocess import Popen +import subprocess +import shutil +import numpy +from functools import reduce +import atexit +import filecmp +try: + from pathlib import Path +except ImportError: + # Workaround for Python < 3.5 + class fakepath: + def home(self): + return os.path.expanduser("~") + Path = fakepath() +import tempfile +import random +import string + +DEVNULL = open(os.devnull, 'w') +atexit.register(DEVNULL.close) + +def message(s): + sys.stderr.write(s+"\n") +def debug(s): + message(s) + +# copied from https://github.com/t-oster/VisiCut/blob/0abe785a30d5d5085dd3b5953b38239b1ff83358/tools/inkscape_extension/visicut_export.py +def which(program, raiseError, extraPaths=[], subdir=None): + """ + find program in the $PATH environment variable and in $extraPaths. + If $subdir is given, also look in the given subdirectory of each $PATH entry. + """ + pathlist=os.environ["PATH"].split(os.pathsep) + if "nt" in os.name: + pathlist.append(os.environ.get("ProgramFiles","C:\Program Files\\")) + pathlist.append(os.environ.get("ProgramFiles(x86)","C:\Program Files (x86)\\")) + pathlist.append("C:\Program Files\\") # needed for 64bit inkscape on 64bit Win7 machines + pathlist.append(os.path.dirname(os.path.dirname(os.getcwd()))) # portable application in the current directory + pathlist += extraPaths + if subdir: + pathlist = [os.path.join(p, subdir) for p in pathlist] + pathlist + 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 + if raiseError: + raise Exception("Cannot find " + str(program) + " in any of these paths: " + str(pathlist) + ". Either the program is not installed, PATH is not set correctly, or this is a bug.") + else: + return None + +# copied from https://github.com/t-oster/VisiCut/blob/0abe785a30d5d5085dd3b5953b38239b1ff83358/tools/inkscape_extension/visicut_export.py +# 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 "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): + # 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) + + + # Updated for Inkscape 1.2, released 16 May 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 = [] + # - select objects + actions += ["select-by-id:" + ",".join(elements)] + # - convert to path + actions += ["clone-unlink", "object-to-path"] + # - 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] + # - export + actions += ["export-do"] + + + command = [INKSCAPEBIN, tmpfile, "--export-overwrite", "--actions=" + ";".join(actions)] + # to print the resulting commandline: + # print(" ".join(["'" + c + "'" for c in command]), file=sys.stderr) + + + DEBUG = False + if DEBUG: + # Inkscape sometimes silently ignores wrong verbs, so we need to double-check that everything's right + for action in actions: + aciton_list = [line.split(":")[0] for line in subprocess.check_output([INKSCAPEBIN, "--action-list"], stderr=DEVNULL).split("\n")] + if action not in action_list: + sys.stderr.write("Inkscape does not have the action '{}'. Please report this as a VisiCut bug.".format(action)) + + inkscape_output = "(not yet run)" + try: + #sys.stderr.write(" ".join(command)) + # run inkscape, buffer output + inkscape = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + inkscape_output = inkscape.communicate()[0] + if inkscape.returncode != 0: + sys.stderr.write("Error: cleaning the document with inkscape failed. Something might still be shown in visicut, but it could be incorrect.\nInkscape's output was:\n" + str(inkscape_output)) + except: + sys.stderr.write("Error: cleaning the document with inkscape failed. Something might still be shown in visicut, but it could be incorrect. Exception information: \n" + str(sys.exc_info()[0]) + "Inkscape's output was:\n" + str(inkscape_output)) + + # move output to the intended destination filename + os.rename(tmpfile, dest) + + + + +# header +# for debugging purposes you can open the resulting EPS file in Inkscape, +# select all, ungroup multiple times +# --> now you can view the exported lines in inkscape +prefix=""" +%!PS-Adobe-3.0 EPSF-3.0 +%%LanguageLevel: 2 +%%BoundingBox -10000 -10000 10000 10000 +%%EndComments +%%BeginSetup +%%EndSetup +%%BeginProlog +% This code (until EndProlog) is from an inkscape-exported EPS, copyright unknown, see cairo-library +save +50 dict begin +/q { gsave } bind def +/Q { grestore } bind def +/cm { 6 array astore concat } bind def +/w { setlinewidth } bind def +/J { setlinecap } bind def +/j { setlinejoin } bind def +/M { setmiterlimit } bind def +/d { setdash } bind def +/m { moveto } bind def +/l { lineto } bind def +/c { curveto } bind def +/h { closepath } bind def +/re { exch dup neg 3 1 roll 5 3 roll moveto 0 rlineto + 0 exch rlineto 0 rlineto closepath } bind def +/S { stroke } bind def +/f { fill } bind def +/f* { eofill } bind def +/n { newpath } bind def +/W { clip } bind def +/W* { eoclip } bind def +/BT { } bind def +/ET { } bind def +/pdfmark where { pop globaldict /?pdfmark /exec load put } + { globaldict begin /?pdfmark /pop load def /pdfmark + /cleartomark load def end } ifelse +/BDC { mark 3 1 roll /BDC pdfmark } bind def +/EMC { mark /EMC pdfmark } bind def +/cairo_store_point { /cairo_point_y exch def /cairo_point_x exch def } def +/Tj { show currentpoint cairo_store_point } bind def +/TJ { + { + dup + type /stringtype eq + { show } { -0.001 mul 0 cairo_font_matrix dtransform rmoveto } ifelse + } forall + currentpoint cairo_store_point +} bind def +/cairo_selectfont { cairo_font_matrix aload pop pop pop 0 0 6 array astore + cairo_font exch selectfont cairo_point_x cairo_point_y moveto } bind def +/Tf { pop /cairo_font exch def /cairo_font_matrix where + { pop cairo_selectfont } if } bind def +/Td { matrix translate cairo_font_matrix matrix concatmatrix dup + /cairo_font_matrix exch def dup 4 get exch 5 get cairo_store_point + /cairo_font where { pop cairo_selectfont } if } bind def +/Tm { 2 copy 8 2 roll 6 array astore /cairo_font_matrix exch def + cairo_store_point /cairo_font where { pop cairo_selectfont } if } bind def +/g { setgray } bind def +/rg { setrgbcolor } bind def +/d1 { setcachedevice } bind def +%%EndProlog +%%Page: 1 1 +%%BeginPageSetup +%%PageBoundingBox: -10000 -10000 10000 10000 +%%EndPageSetup +% This is a severely crippled fucked-up pseudo-postscript for importing in Roland CutStudio +% Do not even try to open it with something else +% FIXME opening with inkscape currently does not show any objects, although it worked some time in the past + +% Inkscape header, not used by cutstudio +% Start +q -10000 -10000 10000 10000 rectclip q + +0 g +0.286645 w +0 J +0 j +[] 0.0 d +4 M q +% Cutstudio Start +""" +postfix=""" +% Cutstudio End + +%this is necessary for CutStudio so that the last line isnt skipped: +0 0 m + +% Inkscape footer +S Q +Q Q +showpage +%%Trailer +end restore +%%EOF +""" + +def EPS2CutstudioEPS(src, dest, mirror=False): + def outputFromStack(stack, n, transformCoordinates=True): + arr=stack[-(n+1):-1] + if transformCoordinates: + arrTransformed=[] + for i in range(n//2): + arrTransformed+=transform(arr[2*i], arr[2*i+1]) + return output(arrTransformed+[stack[-1]]) + else: + return output(arr+[stack[-1]]) + def transform(x, y): + #debug("trafo from: {} {}".format(x, y)) + p=numpy.array([[float(x),float(y),1]]).transpose() + multiply = lambda a, b: numpy.matmul(a, b) + # concatenate transformations by multiplying: new = transformation x previousTransformtaion + m=reduce(multiply, scalingStack[::-1]) + m=m.transpose() + #debug("with {}".format(m)) + pnew = numpy.matmul(m, p) + x=float(pnew[0]) + y=float(pnew[1]) + #debug("to: {} {}".format(x, y)) + return [x, y] + def outputMoveto(x, y): + [xx, yy]=transform(x, y) + return output([str(xx), str(yy), "m"]) + def outputLineto(x, y): + [xx, yy]=transform(x, y) + return output([str(xx), str(yy), "l"]) + def output(array): + array=list(map(str, array)) + output=" ".join(array) + #debug("OUTPUT: "+output) + return output + "\n" + stack=[] + scalingStack=[numpy.identity(3)] + if mirror: + scalingStack.append(numpy.diag([-1, 1, 1])) + lastMoveCoordinates=None + outputStr=prefix + inputFile=open(src) + outputFile=open(dest, "w") + for line in inputFile.readlines(): + line=line.strip() + if line.startswith("%"): + # comment line + continue + if line.endswith("re W n"): + continue # ignore clipping rectangle + #debug(line) + for item in line.split(" "): + item=item.strip() + if item=="": + continue + #debug("INPUT: " + item.__repr__()) + stack.append(item) + if item=="h": # close path + assert lastMoveCoordinates, "closed path before first moveto" + outputStr += outputLineto(float(lastMoveCoordinates[0]), float(lastMoveCoordinates[1])) + elif item == "c": # bezier curveto + outputStr += outputFromStack(stack, 6) + stack=[] + elif item=="re": # rectangle + x=float(stack[-5]) + y=float(stack[-4]) + dx=float(stack[-3]) + dy=float(stack[-2]) + outputStr += outputMoveto(x, y) + outputStr += outputLineto(x+dx, y) + outputStr += outputLineto(x+dx, y+dy) + outputStr += outputLineto(x, y+dy) + outputStr += outputLineto(x, y) + elif item=="cm": # matrix transformation + newTrafo=numpy.array([[float(stack[-7]), float(stack[-6]), 0], [float(stack[-5]), float(stack[-4]), 0], [float(stack[-3]), float(stack[-2]), 1]]) + #debug("applying trafo "+str(newTrafo)) + scalingStack[-1] = numpy.matmul(scalingStack[-1], newTrafo) + elif item=="q": # save graphics state to stack + scalingStack.append(numpy.identity(3)) + elif item=="Q": # pop graphics state from stack + scalingStack.pop() + elif item in ["m", "l"]: + if item=="m": # moveto + lastMoveCoordinates=stack[-3:-1] + elif item=="l": # lineto + pass + outputStr += outputFromStack(stack, 2) + stack=[] + else: + pass # do nothing + outputStr += postfix + outputFile.write(outputStr) + outputFile.close() + inputFile.close() + +if os.name=="nt": # windows + INKSCAPEBIN = which("inkscape.exe", True, subdir="Inkscape") +else: + INKSCAPEBIN=which("inkscape", True) + +assert os.path.isfile(INKSCAPEBIN), "cannot find inkscape binary " + INKSCAPEBIN + +selectedElements=[] +for arg in sys.argv[1:]: + if arg[0] == "-": + if len(arg) >= 5 and arg[0:5] == "--id=": + selectedElements +=[arg[5:]] + else: + filename = arg +if "--selftest" in sys.argv: + filename = "./test-input.svg" + +if len(selectedElements)==0: + shutil.copyfile(filename, filename+".filtered.svg") +else: + # only take selected elements + stripSVG_inkscape(src=filename, dest=filename+".filtered.svg", elements=selectedElements) + +cmd = [INKSCAPEBIN, "-T", "--export-ignore-filters", "--export-area-drawing", "--export-filename="+filename+".inkscape.eps", filename+".filtered.svg"] +inkscape_eps_file = filename + ".inkscape.eps" + +#debug(" ".join(cmd)) +assert 0 == subprocess.call(cmd, stderr=DEVNULL), 'EPS conversion failed: command returned error: ' + '"' + '" "'.join(cmd) + '"' +assert os.path.exists(inkscape_eps_file), 'EPS conversion failed: command did not create result file: ' + '"' + '" "'.join(cmd) + '"' + + +if "--selftest" in sys.argv: + # used for unit-testing: fixed location of output file + destination = "./test-output-actual.cutstudio.eps" +else: + # normally + destination = filename + ".cutstudio.eps" + +EPS2CutstudioEPS(inkscape_eps_file, destination, mirror=("--mirror=true" in sys.argv)) + +if "--selftest" in sys.argv: + # unittest: compare with known reference output + TEST_REFERENCE_FILE = "./test-output-reference.cutstudio.eps" + assert filecmp.cmp(destination, TEST_REFERENCE_FILE), "Test output changed. Please compare " + destination + " and " + TEST_REFERENCE_FILE + print("Selftest successful :-)") + sys.exit(0) + +if os.name=="nt": + DETACHED_PROCESS = 8 # start as "daemon" + Popen([which("CutStudio\CutStudio.exe", True), "/import", destination], creationflags=DETACHED_PROCESS, close_fds=True) +else: #check if we have access to "wine" + CUTSTUDIO_C_DRIVE = str(Path.home()) + "/.wine/drive_c/" + CUTSTUDIO_PATH_LINUX_WINE = CUTSTUDIO_C_DRIVE + "Program Files (x86)/CutStudio/CutStudio.exe" + CUTSTUDIO_COMMANDLINE = ["wine", CUTSTUDIO_PATH_LINUX_WINE, "/import", r'C:\cutstudio.eps'] + try: + if not which("wine", False): + raise Exception("Cannot find 'wine'") + if not os.path.exists(CUTSTUDIO_PATH_LINUX_WINE): + raise Exception("Cannot find CutStudio in " + CUTSTUDIO_PATH_LINUX_WINE) + shutil.copyfile(destination, CUTSTUDIO_C_DRIVE + "cutstudio.eps") + subprocess.check_call(CUTSTUDIO_COMMANDLINE, stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL) + except Exception as exc: + message("Could not open CutStudio.\nInstead, your file was saved to:\n" + destination + "\n" + \ + "Please open that with CutStudio manually. \n\n" + \ + "Tip: On Linux, you can use 'wine' to install CutStudio 3.10. Then, the file will be directly opened with CutStudio. \n" + \ + " Diagnostic information: \n" + str(exc)) + #os.popen("/usr/bin/xdg-open " + filename) + #Popen(["inkscape", filename+".filtered.svg"], stderr=DEVNULL) + #Popen(["inkscape", filename+".cutstudio.eps"]) +#os.unlink(filename+".filtered.svg") +#os.unlink(filename) +#os.unlink(filename+".cutstudio.eps") diff --git a/extensions/fablabchemnitz/optimize_sequence_lasercut_sequence/meta.json b/extensions/fablabchemnitz/optimize_sequence_lasercut_sequence/meta.json new file mode 100644 index 0000000..90dafcc --- /dev/null +++ b/extensions/fablabchemnitz/optimize_sequence_lasercut_sequence/meta.json @@ -0,0 +1,21 @@ +[ + { + "name": "Optimize Sequence: Small Holes First", + "id": "fablabchemnitz.de.optimize_sequence_lasercut_sequence", + "path": "optimize_sequence_lasercut_sequence", + "dependent_extensions": null, + "original_name": "Fix cutting sequence", + "original_id": "L0laapk3.filter.lasercut_sequence", + "license": "GNU AGPL v3", + "license_url": "https://github.com/L0laapk3/inkscape-laser-sequence-extension/blob/master/LICENSE", + "comment": "ported to Inkscape v1 by Mario Voigt", + "source_url": "https://gitea.fablabchemnitz.de/FabLab_Chemnitz/mightyscape-1.2/src/branch/master/extensions/fablabchemnitz/optimize_sequence_lasercut_sequence", + "fork_url": "https://github.com/L0laapk3/inkscape-laser-sequence-extension", + "documentation_url": "https://stadtfabrikanten.org/display/IFM/Optimize+Sequence%3A+Small+Holes+First", + "inkscape_gallery_url": null, + "main_authors": [ + "github.com/L0laapk3", + "github.com/eridur-de" + ] + } +] \ No newline at end of file diff --git a/extensions/fablabchemnitz/optimize_sequence_lasercut_sequence/optimize_sequence_lasercut_sequence.inx b/extensions/fablabchemnitz/optimize_sequence_lasercut_sequence/optimize_sequence_lasercut_sequence.inx new file mode 100644 index 0000000..9f53e2b --- /dev/null +++ b/extensions/fablabchemnitz/optimize_sequence_lasercut_sequence/optimize_sequence_lasercut_sequence.inx @@ -0,0 +1,16 @@ + + + Optimize Sequence: Small Holes First + fablabchemnitz.de.optimize_sequence_lasercut_sequence + + all + + + + + + + + \ No newline at end of file diff --git a/extensions/fablabchemnitz/optimize_sequence_lasercut_sequence/optimize_sequence_lasercut_sequence.py b/extensions/fablabchemnitz/optimize_sequence_lasercut_sequence/optimize_sequence_lasercut_sequence.py new file mode 100644 index 0000000..1edf248 --- /dev/null +++ b/extensions/fablabchemnitz/optimize_sequence_lasercut_sequence/optimize_sequence_lasercut_sequence.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 + +import inkex +import measure +from inkex.paths import Path +from inkex import paths + +def getArea(path): + return abs(measure.csparea(paths.CubicSuperPath(path + "z"))) + +class OptimizeSequenceLasercutSequence(inkex.EffectExtension): + + def effect(self): + elements = self.document.xpath('//svg:path',namespaces=inkex.NSS) + for el in elements: + oldpathstring = el.attrib['d'] + nodes = Path(oldpathstring).to_arrays() + currentSection = [] + sections = [currentSection] + for node in nodes: + command = node.pop(0) + currentSection.append(command + ' ' + ' '.join(list(map(lambda c: ','.join(map(str, c)), node)))) + if command.lower() == 'z': + currentSection = [] + sections.append(currentSection) + + sections = list(map(lambda n: ' '.join(n), filter(lambda n: len(n) > 0, sections))) + + if (sections[-1][-2].lower() != 'z'): + nonClosedSection = ' ' + sections.pop() + else: + nonClosedSection = '' + + sections = filter(lambda s: s[0].lower() != 'z', sections) + sections = sorted(sections, key=getArea) + newpathstring = "z ".join(sections) + nonClosedSection + el.set('d', newpathstring) + +if __name__ == '__main__': + OptimizeSequenceLasercutSequence().run() \ No newline at end of file diff --git a/extensions/fablabchemnitz/optimize_sequence_travel_distance/meta.json b/extensions/fablabchemnitz/optimize_sequence_travel_distance/meta.json new file mode 100644 index 0000000..8074a05 --- /dev/null +++ b/extensions/fablabchemnitz/optimize_sequence_travel_distance/meta.json @@ -0,0 +1,21 @@ +[ + { + "name": "Optimize Sequence: Travel Distances", + "id": "fablabchemnitz.de.optimize_sequence_travel_distance", + "path": "optimize_sequence_travel_distance", + "dependent_extensions": null, + "original_name": "Reorder Paths for Speed", + "original_id": "command.evilmadscientist.eggbot.reorder", + "license": "GNU GPL v3", + "license_url": "https://github.com/evil-mad/EggBot/blob/master/LICENSE", + "comment": "", + "source_url": "https://gitea.fablabchemnitz.de/FabLab_Chemnitz/mightyscape-1.2/src/branch/master/extensions/fablabchemnitz/optimize_sequence_travel_distance", + "fork_url": "https://github.com/evil-mad/EggBot", + "documentation_url": "https://stadtfabrikanten.org/display/IFM/Optimize+Sequence%3A+Travel+Distances", + "inkscape_gallery_url": null, + "main_authors": [ + "github.com/evil-mad", + "github.com/eridur-de" + ] + } +] \ No newline at end of file diff --git a/extensions/fablabchemnitz/optimize_sequence_travel_distance/optimize_sequence_travel_distance.inx b/extensions/fablabchemnitz/optimize_sequence_travel_distance/optimize_sequence_travel_distance.inx new file mode 100755 index 0000000..6ac06af --- /dev/null +++ b/extensions/fablabchemnitz/optimize_sequence_travel_distance/optimize_sequence_travel_distance.inx @@ -0,0 +1,25 @@ + + + Optimize Sequence: Travel Distances + fablabchemnitz.de.optimize_sequence_travel_distance + + + + + + + + false + + + all + + + + + + + + \ No newline at end of file diff --git a/extensions/fablabchemnitz/optimize_sequence_travel_distance/optimize_sequence_travel_distance.py b/extensions/fablabchemnitz/optimize_sequence_travel_distance/optimize_sequence_travel_distance.py new file mode 100644 index 0000000..422417d --- /dev/null +++ b/extensions/fablabchemnitz/optimize_sequence_travel_distance/optimize_sequence_travel_distance.py @@ -0,0 +1,1239 @@ +#!/usr/bin/env python3 +# +# SVG Path Ordering Extension +# This extension uses a simple TSP algorithm to order the paths so as +# to reduce plotting time by plotting nearby paths consecutively. +# +# +# While written from scratch, this is a derivative in spirit of the work by +# Matthew Beckler and Daniel C. Newman for the EggBot project. +# +# The MIT License (MIT) +# +# Copyright (c) 2020 Windell H. Oskay, Evil Mad Science LLC +# www.evilmadscientist.com +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import math +import sys +from lxml import etree +import inkex +import simpletransform +import simplestyle +import plot_utils + +""" +TODOs: + +* Apparent difference in execution time for portrait vs landscape document orientation. + Seems to be related to the _change_ + +* Implement path functions + + +<_option value=0>Leave as is +<_option value=1>Reorder subpaths +<_option value=2>Break apart + + +self.OptionParser.add_option( "--path_handling",\ +action="store", type="int", dest="path_handling",\ +default=1,help="How compound paths are handled") + + +* Consider re-introducing GUI method for rendering: + +false + + +""" + +class OptimizeSequenceTravelDistance(inkex.EffectExtension): + """ + Inkscape effect extension. + Re-order the objects in the SVG document for faster plotting. + Respect layers: Initialize a new dictionary of objects for each layer, and sort + objects within that layer only + Objects in root of document are treated as being on a _single_ layer, and will all + be sorted. + + """ + + def add_arguments(self, pars): + pars.add_argument( "--reordering",type=int, default=1, help="How groups are handled") + pars.add_argument( "--preview_rendering",type=inkex.Boolean, default=False, help="Preview rendering") # Rendering is available for debug purposes. It only previews pen-up movements that are reordered and typically does not include all possible movement. + self.auto_rotate = True + + def effect(self): + # Main entry point of the program + + self.svg_width = 0 + self.svg_height = 0 + self.air_total_default = 0 + self.air_total_sorted = 0 + self.printPortrait = False + + self.layer_index = 0 # index for coloring layers + self.svg = self.document.getroot() + self.DocUnits = "in" # Default + self.DocUnits = self.svg.unit + self.unit_scaling = 1 + self.getDocProps() + + """ + Set up the document-wide transforms to handle SVG viewbox + """ + + matCurrent = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]] + + viewbox = self.svg.get( 'viewBox' ) + + vb = self.svg.get('viewBox') + if vb: + p_a_r = self.svg.get('preserveAspectRatio') + sx,sy,ox,oy = plot_utils.vb_scale(vb, p_a_r, self.svg_width, self.svg_height) + else: + sx = 1.0 / float(plot_utils.PX_PER_INCH) # Handle case of no viewbox + sy = sx + ox = 0.0 + oy = 0.0 + + # Initial transform of document is based on viewbox, if present: + matCurrent = simpletransform.parseTransform('scale({0:.6E},{1:.6E}) translate({2:.6E},{3:.6E})'.format(sx, sy, ox, oy)) + # Set up x_last, y_last, which keep track of last known pen position + # The initial position is given by the expected initial pen position + + self.y_last = 0 + + if (self.printPortrait): + self.x_last = self.svg_width + else: + self.x_last = 0 + + parent_vis='visible' + + self.root_nodes = [] + + if self.options.preview_rendering == True: + # Remove old preview layers, if rendering is enabled + for node in self.svg: + if node.tag == inkex.addNS( 'g', 'svg' ) or node.tag == 'g': + if ( node.get( inkex.addNS( 'groupmode', 'inkscape' ) ) == 'layer' ): + LayerName = node.get( inkex.addNS( 'label', 'inkscape' ) ) + if LayerName == '% Preview': + self.svg.remove( node ) + + preview_transform = simpletransform.parseTransform( + 'translate({2:.6E},{3:.6E}) scale({0:.6E},{1:.6E})'.format( + 1.0/sx, 1.0/sy, -ox, -oy)) + path_attrs = { 'transform': simpletransform.formatTransform(preview_transform)} + self.preview_layer = etree.Element(inkex.addNS('g', 'svg'), + path_attrs, nsmap=inkex.NSS) + + + self.preview_layer.set( inkex.addNS('groupmode', 'inkscape' ), 'layer' ) + self.preview_layer.set( inkex.addNS( 'label', 'inkscape' ), '% Preview' ) + self.svg.append( self.preview_layer ) + + + # Preview stroke width: 1/1000 of page width or height, whichever is smaller + if self.svg_width < self.svg_height: + width_du = self.svg_width / 1000.0 + else: + width_du = self.svg_height / 1000.0 + + """ + Stroke-width is a css style element, and cannot accept scientific notation. + + Thus, in cases with large scaling (i.e., high values of 1/sx, 1/sy) + resulting from the viewbox attribute of the SVG document, it may be necessary to use + a _very small_ stroke width, so that the stroke width displayed on the screen + has a reasonable width after being displayed greatly magnified thanks to the viewbox. + + Use log10(the number) to determine the scale, and thus the precision needed. + """ + + log_ten = math.log10(width_du) + if log_ten > 0: # For width_du > 1 + width_string = "{0:.3f}".format(width_du) + else: + prec = int(math.ceil(-log_ten) + 3) + width_string = "{0:.{1}f}".format(width_du, prec) + + self.p_style = {'stroke-width': width_string, 'fill': 'none', + 'stroke-linejoin': 'round', 'stroke-linecap': 'round'} + + self.svg = self.parse_svg(self.svg, matCurrent) + + + def parse_svg(self, input_node, mat_current=None, parent_vis='visible'): + """ + Input: An SVG node (usually) containing other nodes: + The SVG root, a layer, sublayer, or other group. + Output: The re-ordered node. The contents are reordered with the greedy + algorithm, except: + - Layers and sublayers are preserved. The contents of each are + re-ordered for faster plotting. + - Groups are either preserved, broken apart, or re-ordered within + the group, depending on the value of group_mode. + """ + + coord_dict = {} + # coord_dict maps a node ID to the following data: + # Is the node plottable, first coordinate pair, last coordinate pair. + # i.e., Node_id -> (Boolean: plottable, Xi, Yi, Xf, Yf) + + group_dict = {} + # group_dict maps a node ID for a group to the contents of that group. + # The contents may be a preserved nested group or a flat list, depending + # on the selected group handling mode. Example: + # group_dict = {'id_1': , + # 'id_2': + + nodes_to_delete = [] + + counter = 0 # TODO: Replace this with better unique ID system + + # Account for input_node's transform and any transforms above it: + if mat_current is None: + mat_current = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]] + try: + matNew = simpletransform.composeTransform( mat_current, + simpletransform.parseTransform( input_node.get( "transform" ))) + except AttributeError: + matNew = mat_current + + for node in input_node: + # Step through each object within the top-level input node + + + if node.tag is etree.Comment: + continue + + try: + id = node.get( 'id' ) + except AttributeError: + id = self.svg.get_unique_id("1",True) + node.set( 'id', id) + if id == None: + id = self.svg.get_unique_id("1",True) + node.set( 'id', id) + + + # First check for object visibility: + skip_object = False + + # Check for "display:none" in the node's style attribute: + style = dict(inkex.Style.parse_str(node.get('style'))) + if 'display' in style.keys() and style['display'] == 'none': + skip_object = True # Plot neither this object nor its children + + # The node may have a display="none" attribute as well: + if node.get( 'display' ) == 'none': + skip_object = True # Plot neither this object nor its children + + # Visibility attributes control whether a given object will plot. + # Children of hidden (not visible) parents may be plotted if + # they assert visibility. + visibility = node.get( 'visibility', parent_vis ) + + if 'visibility' in style.keys(): + visibility = style['visibility'] # Style may override attribute. + + if visibility == 'inherit': + visibility = parent_vis + + if visibility != 'visible': + skip_object = True # Skip this object and its children + + # Next, check to see if this inner node is itself a group or layer: + if node.tag == inkex.addNS( 'g', 'svg' ) or node.tag == 'g': + + # Use the user-given option to decide what to do with subgroups: + subgroup_mode = self.options.reordering + +# Values of the parameter: +# subgroup_mode=="1": Preserve groups +# subgroup_mode=="2": Reorder within groups +# subgroup_mode=="3": Break apart groups + + if node.get(inkex.addNS('groupmode', 'inkscape')) == 'layer': + # The node is a layer or sub-layer, not a regular group. + # Parse it separately, and re-order its contents. + + subgroup_mode = 2 # Always sort within each layer. + self.layer_index += 1 + + layer_name = node.get( inkex.addNS( 'label', 'inkscape' ) ) + + if sys.version_info < (3,): # Yes this is ugly. More elegant suggestions welcome. :) + layer_name = layer_name.encode( 'ascii', 'ignore' ) #Drop non-ascii characters + else: + layer_name = str(layer_name) + layer_name.lstrip # Remove leading whitespace + + if layer_name: + if layer_name[0] == '%': # First character is '%'; This + skip_object = True # is a documentation layer; skip plotting. + self.layer_index -= 1 # Set this back to previous value. + + if skip_object: + # Do not re-order hidden groups or layers. + subgroup_mode = 1 # Preserve this group + + if subgroup_mode == 3: + # Break apart this non-layer subgroup and add it to + # the set of things to be re-ordered. + + nodes_to_delete.append(node) + nodes_inside_group = self.group2NodeDict(node) + + for a_node in nodes_inside_group: + try: + id = a_node.get( 'id' ) + except AttributeError: + id = self.uniqueId("1",True) + a_node.set( 'id', id) + if id == None: + id = self.uniqueId("1",True) + a_node.set( 'id', id) + + # Use getFirstPoint and getLastPoint on each object: + start_plottable, first_point = self.getFirstPoint(a_node, matNew) + end_plottable, last_point = self.getLastPoint(a_node, matNew) + + coord_dict[id] = (start_plottable and end_plottable, + first_point[0], first_point[1], last_point[0], last_point[1] ) + # Entry in group_dict is this node + group_dict[id] = a_node + + elif subgroup_mode == 2: + # Reorder a layer or subgroup with a recursive call. + + node = self.parse_svg(node, matNew, visibility) + + # Capture the first and last x,y coordinates of the optimized node + start_plottable, first_point = self.group_first_pt(node, matNew) + end_plottable, last_point = self.group_last_pt(node, matNew) + + # Then add this optimized node to the coord_dict + coord_dict[id] = (start_plottable and end_plottable, + first_point[0], first_point[1], last_point[0], last_point[1] ) + # Entry in group_dict is this node + group_dict[id] = node + + else: # (subgroup_mode == 1) + # Preserve the group, but find its first and last point so + # that it can be re-ordered with respect to other items + + if skip_object: + start_plottable = False + end_plottable = False + first_point = [(-1.), (-1.)] + last_point = [(-1.), (-1.)] + else: + start_plottable, first_point = self.group_first_pt(node, matNew) + end_plottable, last_point = self.group_last_pt(node, matNew) + + coord_dict[id] = (start_plottable and end_plottable, + first_point[0], first_point[1], last_point[0], last_point[1] ) + # Entry in group_dict is this node + group_dict[id] = node + + else: # Handle objects that are not groups + if skip_object: + start_plottable = False + end_plottable = False + first_point = [(-1.), (-1.)] + last_point = [(-1.), (-1.)] + else: + start_plottable, first_point = self.getFirstPoint(node, matNew) + end_plottable, last_point = self.getLastPoint(node, matNew) + + coord_dict[id] = (start_plottable and end_plottable, + first_point[0], first_point[1], last_point[0], last_point[1] ) + group_dict[id] = node # Entry in group_dict is this node + + # Perform the re-ordering: + ordered_element_list = self.ReorderNodeList(coord_dict, group_dict) + + # Once a better order for the svg elements has been determined, + # All there is do to is to reintroduce the nodes to the parent in the correct order + for elt in ordered_element_list: + # Creates identical node at the correct location according to ordered_element_list + input_node.append(elt) + # Once program is finished parsing through + for element_to_remove in nodes_to_delete: + try: + input_node.remove(element_to_remove) + except ValueError: + inkex.errormsg(str(element_to_remove.get('id'))+" is not a member of " + str(input_node.get('id'))) + + return input_node + + + def break_apart_path(self, path): + """ + An SVG path may contain multiple distinct portions, that are normally separated + by pen-up movements. + + This function takes the path data string from an SVG path, parses it, and returns + a dictionary of independent path data strings, each of which represents a single + pen-down movement. It is equivalent to the Inkscape function Path > Break Apart + + Input: path data string, representing a single SVG path + Output: Dictionary of (separated) path data strings + + """ + MaxLength = len(path) + ix = 0 + move_to_location = [] + path_dictionary = {} + path_list = [] + path_number = 1 + + # Search for M or m location + while ix < MaxLength: + if(path[ix] == 'm' or path[ix] == 'M'): + move_to_location.append(ix) + ix = ix + 1 + # Iterate through every M or m location in our list of move to instructions + # Slice the path string according to path beginning and ends as indicated by the + # location of these instructions + + for counter, m in enumerate(move_to_location): + if (m == move_to_location[-1]): + # last entry + path_list.append(path[m:MaxLength].rstrip()) + else: + path_list.append(path[m:move_to_location[counter + 1]].rstrip()) + + for counter, current_path in enumerate(path_list): + + # Enumerate over every entry in the path looking for relative m commands + if current_path[0] == 'm' and counter > 0: + # If path contains relative m command, the best case is when the last command + # was a Z or z. In this case, all relative operations are performed relative to + # initial x, y coordinates of the previous path + + if path_list[counter -1][-1].upper() == 'Z': + current_path_x, current_path_y,index = self.getFirstPoint(current_path, matNew) + prev_path_x, prev_path_y,ignore = self.getFirstPoint(path_list[counter-1]) + adapted_x = current_path_x + prev_path_x + adapted_y = current_path_y + prev_path_y + # Now we can replace the path data with an Absolute Move to instruction + # HOWEVER, we need to adapt all the data until we reach a different command in the case of a repeating + path_list[counter] = "m "+str(adapted_x)+","+str(adapted_y) + ' ' +current_path[index:] + + # If there is no z or absolute commands, we need to parse the entire path + else: + + # scan path for absolute coordinates. If present, begin parsing from their index + # instead of the beginning + prev_path = path_list[counter-1] + prev_path_length = len(prev_path) + jx = 0 + x_val, y_val = 0,0 + # Check one char at a time + # until we have the moveTo Command + last_command = '' + is_absolute_command = False + repeated_command = False + # name of command + # how many parameters we need to skip + accepted_commands = { + 'M':0, + 'L':0, + 'H':0, + 'V':0, + 'C':4, + 'S':2, + 'Q':2, + 'T':0, + 'A':5 + } + + # If there is an absolute command which specifies a new initial point + # then we can save time by setting our index directly to its location in the path data + # See if an accepted_command is present in the path data. If it is present further in the + # string than any command found before, then set the pointer to that location + # if a command is not found, find() will return a -1. jx is initialized to 0, so if no matches + # are found, the program will parse from the beginning to the end of the path + + for keys in 'MLCSQTA': # TODO: Compare to last_point; see if we can clean up this part + if(prev_path.find(keys) > jx): + jx = prev_path.find(keys) + + while jx < prev_path_length: + + temp_x_val = '' + temp_y_val = '' + num_of_params_to_skip = 0 + + # SVG Path commands can be repeated + if (prev_path[jx].isdigit() and last_command): + repeated_command = True + else: + repeated_command = False + + if (prev_path[jx].isalpha() and prev_path[jx].upper() in accepted_commands) or repeated_command: + + if repeated_command: + #is_relative_command is saved from last iteration of the loop + current_command = last_command + else: + # If the character is accepted, we must parse until reach the x y coordinates + is_absolute_command = prev_path[jx].isupper() + current_command = prev_path[jx].upper() + + # Each command has a certain number of parameters we must pass before we reach the + # information we care about. We will parse until we know that we have reached them + + # Get to start of next number + # We will know we have reached a number if the current character is a +/- sign + # or current character is a digit + while jx < prev_path_length: + if(prev_path[jx] in '+-' or prev_path[jx].isdigit()): + break + jx = jx + 1 + + # We need to parse past the unused parameters in our command + # The number of parameters to parse past is dependent on the command and stored + # as the value of accepted_command + # Spaces and commas are used to deliniate paramters + while jx < prev_path_length and num_of_params_to_skip < accepted_commands[current_command]: + if(prev_path[jx].isspace() or prev_path[jx] == ','): + num_of_params_to_skip = num_of_params_to_skip + 1 + jx = jx + 1 + + # Now, we are in front of the x character + + if current_command.upper() == 'V': + temp_x_val = 0 + + if current_command.upper() == 'H': + temp_y_val = 0 + + # Parse until next character is a digit or +/- character + while jx < prev_path_length and current_command.upper() != 'V': + if(prev_path[jx] in '+-' or prev_path[jx].isdigit()): + break + jx = jx + 1 + + # Save each next character until we reach a space + while jx < prev_path_length and current_command.upper() != 'V' and not (prev_path[jx].isspace() or prev_path[jx] == ','): + temp_x_val = temp_x_val + prev_path[jx] + jx = jx + 1 + + # Then we know we have completely parsed the x character + + # Now we are in front of the y character + + # Parse until next character is a digit or +/- character + while jx < prev_path_length and current_command.upper() != 'H': + if(prev_path[jx] in '+-' or prev_path[jx].isdigit()): + break + jx = jx + 1 + + ## Save each next character until we reach a space + while jx < prev_path_length and current_command.upper() != 'H' and not (prev_path[jx].isspace() or prev_path[jx] == ','): + temp_y_val = temp_y_val + prev_path[jx] + jx = jx + 1 + + # Then we know we have completely parsed the y character + + if is_absolute_command: + + if current_command == 'H': + # Absolute commands create new x,y position + try: + x_val = float(temp_x_val) + except ValueError: + pass + elif current_command == 'V': + # Absolute commands create new x,y position + try: + y_val = float(temp_y_val) + except ValueError: + pass + else: + # Absolute commands create new x,y position + try: + x_val = float(temp_x_val) + y_val = float(temp_y_val) + except ValueError: + pass + else: + + if current_command == 'h': + # Absolute commands create new x,y position + try: + x_val = x_val + float(temp_x_val) + except ValueError: + pass + elif current_command == 'V': + # Absolute commands create new x,y position + try: + y_val = y_val + float(temp_y_val) + except ValueError: + pass + else: + # Absolute commands create new x,y position + try: + x_val = x_val + float(temp_x_val) + y_val = y_val + float(temp_y_val) + except ValueError: + pass + last_command = current_command + jx = jx + 1 + x,y,index = self.getFirstPoint(current_path,None) + path_list[counter] = "m "+str(x_val+x)+","+str(y_val+y) + ' ' + current_path[index:] + + for counter, path in enumerate(path_list): + path_dictionary['ad_path'+ str(counter)] = path + + return path_dictionary + + + def getFirstPoint(self, node, matCurrent): + """ + Input: (non-group) node and parent transformation matrix + Output: Boolean value to indicate if the svg element is plottable and + two floats stored in a list representing the x and y coordinates we plot first + """ + + # first apply the current matrix transform to this node's transform + matNew = simpletransform.composeTransform( matCurrent, simpletransform.parseTransform( node.get( "transform" ) ) ) + + point = [float(-1), float(-1)] + try: + if node.tag == inkex.addNS( 'path', 'svg' ): + + pathdata = node.get('d') + + point = plot_utils.pathdata_first_point(pathdata) + simpletransform.applyTransformToPoint(matNew, point) + + return True, point + + if node.tag == inkex.addNS( 'rect', 'svg' ) or node.tag == 'rect': + + """ + The x,y coordinates for a rect are included in their specific attributes + If there is a transform, we need translate the x & y coordinates to their + correct location via applyTransformToPoint. + """ + + point[0] = float( node.get( 'x' ) ) + point[1] = float( node.get( 'y' ) ) + + simpletransform.applyTransformToPoint(matNew, point) + + return True, point + + if node.tag == inkex.addNS( 'line', 'svg' ) or node.tag == 'line': + """ + The x1 and y1 attributes are where we will start to draw + So, get them, apply the transform matrix, and return the point + """ + + point[0] = float( node.get( 'x1' ) ) + point[1] = float( node.get( 'y1' ) ) + + simpletransform.applyTransformToPoint(matNew, point) + + return True, point + + + if node.tag == inkex.addNS( 'polyline', 'svg' ) or node.tag == 'polyline': + pl = node.get( 'points', '' ).strip() + + if pl == '': + return False, point + + pa = pl.replace(',',' ').split() # replace comma with space before splitting + + if not pa: + return False, point + pathLength = len( pa ) + if (pathLength < 4): # Minimum of x1,y1 x2,y2 required. + return False, point + + d = "M " + pa[0] + " " + pa[1] + i = 2 + while (i < (pathLength - 1 )): + d += " L " + pa[i] + " " + pa[i + 1] + i += 2 + + point = plot_utils.pathdata_first_point(d) + simpletransform.applyTransformToPoint(matNew, point) + + return True, point + + if (node.tag == inkex.addNS( 'polygon', 'svg' ) or + node.tag == 'polygon'): + """ + We need to extract x1 and y1 from these: + + We accomplish this with Python string strip + and split methods. Then apply transforms + """ + # Strip() removes all whitespace from the start and end of p1 + pl = node.get( 'points', '' ).strip() + if (pl == ''): + # If pl is blank there has been an error, return False and -1,-1 to indicate a problem has occured + return False, point + # Split string by whitespace + pa = pl.split() + if not len( pa ): + # If pa is blank there has been an error, return False and -1,-1 to indicate a problem has occured + return False, point + # pa[0] = "x1,y1 + # split string via comma to get x1 and y1 individually + # then point = [x1,x2] + point = pa[0].split(",") + + point = [float(point[0]),float(point[1])] + + simpletransform.applyTransformToPoint(matNew, point) + + return True, point + + if node.tag == inkex.addNS( 'ellipse', 'svg' ) or \ + node.tag == 'ellipse': + + cx = float( node.get( 'cx', '0' ) ) + cy = float( node.get( 'cy', '0' ) ) + rx = float( node.get( 'rx', '0' ) ) + + point[0] = cx - rx + point[1] = cy + + simpletransform.applyTransformToPoint(matNew, point) + + return True, point + + if node.tag == inkex.addNS( 'circle', 'svg' ) or \ + node.tag == 'circle': + cx = float( node.get( 'cx', '0' ) ) + cy = float( node.get( 'cy', '0' ) ) + r = float( node.get( 'r', '0' ) ) + point[0] = cx - r + point[1] = cy + + simpletransform.applyTransformToPoint(matNew, point) + + return True, point + + if node.tag == inkex.addNS('symbol', 'svg') or node.tag == 'symbol': + # A symbol is much like a group, except that + # it's an invisible object. + return False, point # Skip this element. + + if node.tag == inkex.addNS('use', 'svg') or node.tag == 'use': + + """ + A element refers to another SVG element via an xlink:href="#blah" + attribute. We will handle the element by doing an XPath search through + the document, looking for the element with the matching id="blah" + attribute. We then recursively process that element after applying + any necessary (x,y) translation. + + Notes: + 1. We ignore the height and g attributes as they do not apply to + path-like elements, and + 2. Even if the use element has visibility="hidden", SVG still calls + for processing the referenced element. The referenced element is + hidden only if its visibility is "inherit" or "hidden". + 3. We may be able to unlink clones using the code in pathmodifier.py + """ + + refid = node.get(inkex.addNS('href', 'xlink')) + + if refid is not None: + # [1:] to ignore leading '#' in reference + path = '//*[@id="{0}"]'.format(refid[1:]) + refnode = node.xpath(path) + if refnode is not None: + + x = float(node.get('x', '0')) + y = float(node.get('y', '0')) + + # Note: the transform has already been applied + if x != 0 or y != 0: + mat_new2 = simpletransform.composeTransform(matNew, simpletransform.parseTransform('translate({0:f},{1:f})'.format(x, y))) + else: + mat_new2 = matNew + # Note that the referenced object may be a 'symbol`, + # which acts like a group, or it may be a simple + # object. + + if len(refnode) > 0: + plottable, the_point = self.group_first_pt(refnode[0], mat_new2) + else: + plottable, the_point = self.group_first_pt(refnode, mat_new2) + + return plottable, the_point + except: + pass + + # Svg Object is not a plottable element + # In this case, return False to indicate a non-plottable element + # and a default point + + return False, point + + def getLastPoint(self, node, matCurrent): + """ + Input: XML tree node and transformation matrix + Output: Boolean value to indicate if the svg element is plottable or not and + two floats stored in a list representing the x and y coordinates we plot last + """ + + # first apply the current matrix transform to this node's transform + matNew = simpletransform.composeTransform( matCurrent, simpletransform.parseTransform( node.get( "transform" ) ) ) + + # If we return a negative value, we know that this function did not work + point = [float(-1), float(-1)] + try: + if node.tag == inkex.addNS( 'path', 'svg' ): + + path = node.get('d') + + point = plot_utils.pathdata_last_point(path) + simpletransform.applyTransformToPoint(matNew, point) + + return True, point + + if node.tag == inkex.addNS( 'rect', 'svg' ) or node.tag == 'rect': + + """ + The x,y coordinates for a rect are included in their specific attributes + If there is a transform, we need translate the x & y coordinates to their + correct location via applyTransformToPoint. + """ + + point[0] = float( node.get( 'x' ) ) + point[1] = float( node.get( 'y' ) ) + + simpletransform.applyTransformToPoint(matNew, point) + + return True, point # Same start and end points + + if node.tag == inkex.addNS( 'line', 'svg' ) or node.tag == 'line': + + """ + The x2 and y2 attributes are where we will end our drawing + So, get them, apply the transform matrix, and return the point + """ + + point[0] = float( node.get( 'x2' ) ) + point[1] = float( node.get( 'y2' ) ) + + simpletransform.applyTransformToPoint(matNew, point) + + return True, point + + if node.tag == inkex.addNS( 'polyline', 'svg' ) or node.tag == 'polyline': + + pl = node.get( 'points', '' ).strip() + + if pl == '': + return False, point + + pa = pl.replace(',',' ').split() + if not pa: + return False, point + pathLength = len( pa ) + if (pathLength < 4): # Minimum of x1,y1 x2,y2 required. + return False, point + + d = "M " + pa[0] + " " + pa[1] + i = 2 + while (i < (pathLength - 1 )): + d += " L " + pa[i] + " " + pa[i + 1] + i += 2 + + endpoint = plot_utils.pathdata_last_point(d) + simpletransform.applyTransformToPoint(matNew, endpoint) + + return True, endpoint + + if node.tag == inkex.addNS( 'polygon', 'svg' ) or node.tag == 'polygon': + """ + We need to extract x1 and y1 from these: + + We accomplish this with Python string strip + and split methods. Then apply transforms + """ + # Strip() removes all whitespace from the start and end of p1 + pl = node.get( 'points', '' ).strip() + if (pl == ''): + # If pl is blank there has been an error, return -1,-1 to indicate a problem has occured + return False, point + # Split string by whitespace + pa = pl.split() + if not len( pa ): + # If pl is blank there has been an error, return -1,-1 to indicate a problem has occured + return False, point + # pa[0] = "x1,y1 + # split string via comma to get x1 and y1 individually + # then point = [x1,x2] + point = pa[0].split(",") + + point = [float(point[0]),float(point[1])] + + simpletransform.applyTransformToPoint(matNew, point) + + return True, point + + if node.tag == inkex.addNS( 'ellipse', 'svg' ) or node.tag == 'ellipse': + + cx = float( node.get( 'cx', '0' ) ) + cy = float( node.get( 'cy', '0' ) ) + rx = float( node.get( 'rx', '0' ) ) + + point[0] = cx - rx + point[1] = cy + + simpletransform.applyTransformToPoint(matNew, point) + + return True, point + + if node.tag == inkex.addNS( 'circle', 'svg' ) or node.tag == 'circle': + cx = float( node.get( 'cx', '0' ) ) + cy = float( node.get( 'cy', '0' ) ) + r = float( node.get( 'r', '0' ) ) + point[0] = cx - r + point[1] = cy + + simpletransform.applyTransformToPoint(matNew, point) + + return True, point + + if node.tag == inkex.addNS('symbol', 'svg') or node.tag == 'symbol': + # A symbol is much like a group, except that it should only be + # rendered when called within a "use" tag. + return False, point # Skip this element. + + if node.tag == inkex.addNS('use', 'svg') or node.tag == 'use': + + """ + A element refers to another SVG element via an xlink:href="#blah" + attribute. We will handle the element by doing an XPath search through + the document, looking for the element with the matching id="blah" + attribute. We then recursively process that element after applying + any necessary (x,y) translation. + + Notes: + 1. We ignore the height and g attributes as they do not apply to + path-like elements, and + 2. Even if the use element has visibility="hidden", SVG still calls + for processing the referenced element. The referenced element is + hidden only if its visibility is "inherit" or "hidden". + 3. We may be able to unlink clones using the code in pathmodifier.py + """ + + refid = node.get(inkex.addNS('href', 'xlink')) + if refid is not None: + # [1:] to ignore leading '#' in reference + path = '//*[@id="{0}"]'.format(refid[1:]) + refnode = node.xpath(path) + if refnode is not None: + x = float(node.get('x', '0')) + y = float(node.get('y', '0')) + # Note: the transform has already been applied + if x != 0 or y != 0: + mat_new2 = simpletransform.composeTransform(matNew, simpletransform.parseTransform('translate({0:f},{1:f})'.format(x, y))) + else: + mat_new2 = matNew + if len(refnode) > 0: + plottable, the_point = self.group_last_pt(refnode[0], mat_new2) + else: + plottable, the_point = self.group_last_pt(refnode, mat_new2) + return plottable, the_point + except: + pass + + # Svg Object is not a plottable element; + # Return False and a default point + return False, point + + + def group_first_pt(self, group, matCurrent = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]): + """ + Input: A Node which we have found to be a group + Output: Boolean value to indicate if a point is plottable + float values for first x,y coordinates of svg element + """ + + if len(group) == 0: # Empty group -- The object may not be a group. + return self.getFirstPoint(group, matCurrent) + + success = False + point = [float(-1), float(-1)] + + # first apply the current matrix transform to this node's transform + matNew = simpletransform.composeTransform( matCurrent, simpletransform.parseTransform( group.get( "transform" ) ) ) + + # Step through the group, we examine each element until we find a plottable object + for subnode in group: + # Check to see if the subnode we are looking at in this iteration of our for loop is a group + # If it is a group, we must recursively call this function to search for a plottable object + if subnode.tag == inkex.addNS( 'g', 'svg' ) or subnode.tag == 'g': + # Verify that the nested group has objects within it + # otherwise we will not parse it + if subnode is not None: + # Check if group contains plottable elements by recursively calling group_first_pt + # If group contains plottable subnode, then it will return that value and escape the loop + # Else function continues search for first plottable object + success, point = self.group_first_pt(subnode, matNew) + if success: + # Subnode inside nested group is plottable! + # Break from our loop so we can return the first point of this plottable subnode + break + else: + continue + else: + # Node is not a group + # Get its first (x,y) coordinates + # Also get a Boolean value to indicate if the subnode is plottable or not + # If subnode is not plottable, continue to next subnode in the group + success, point = self.getFirstPoint(subnode, matNew) + + if success: + # Subnode inside group is plottable! + # Break from our loop so we can return the first point of this plottable subnode + break + else: + continue + return success, point + + + def group_last_pt(self, group, matCurrent=[[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]): + """ + Input: A Node which we have found to be a group + Output: The last node within the group which can be plotted + """ + + if len(group) == 0: # Empty group -- Did someone send an object that isn't a group? + return self.getLastPoint(group, matCurrent) + + success = False + point = [float(-1),float(-1)] + + # first apply the current matrix transform to this node's transform + matNew = simpletransform.composeTransform( matCurrent, simpletransform.parseTransform( group.get( "transform" ) ) ) + + # Step through the group, we examine each element until we find a plottable object + for subnode in reversed(group): + # Check to see if the subnode we are looking at in this iteration of our for loop is a group + # If it is a group, we must recursively call this function to search for a plottable object + if subnode.tag == inkex.addNS( 'g', 'svg' ) or subnode.tag == 'g': + # Verify that the nested group has objects within it + # otherwise we will not parse it + if subnode is not None: + # Check if group contains plottable elements by recursively calling group_last_pt + # If group contains plottable subnode, then it will return that value and escape the loop + # Else function continues search for last plottable object + success, point = self.group_last_pt(subnode, matNew) + if success: + # Subnode inside nested group is plottable! + # Break from our loop so we can return the first point of this plottable subnode + break + else: + continue + else: + # Node is not a group + # Get its first (x,y) coordinates + # Also get a Boolean value to indicate if the subnode is plottable or not + # If subnode is not plottable, continue to next subnode in the group + success, point = self.getLastPoint(subnode, matNew) + if success: + + # Subode inside nested group is plottable! + # Break from our loop so we can return the first point of this plottable subnode + break + else: + continue + return success, point + + + def group2NodeDict(self, group, mat_current=None): + + if mat_current is None: + mat_current = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]] + + # first apply the current matrix transform to this node's transform + matNew = simpletransform.composeTransform( mat_current, simpletransform.parseTransform( group.get( "transform" ) ) ) + + nodes_in_group = [] + + # Step through the group, we examine each element until we find a plottable object + for subnode in group: + # Check to see if the subnode we are looking at in this iteration of our for loop is a group + # If it is a group, we must recursively call this function to search for a plottable object + if subnode.tag == inkex.addNS( 'g', 'svg' ) or subnode.tag == 'g': + # Verify that the nested group has objects within it + # otherwise we will not parse it + if subnode is not None: + # Check if group contains plottable elements by recursively calling group_first_pt + # If group contains plottable subnode, then it will return that value and escape the loop + # Else function continues search for first plottable object + nodes_in_group.extend(self.group2NodeDict(subnode, matNew)) + else: + simpletransform.applyTransformToNode(matNew, subnode) + nodes_in_group.append(subnode) + return nodes_in_group + + + def ReorderNodeList(self, coord_dict, group_dict): + # Re-order the given set of SVG elements, using a simple "greedy" algorithm. + # The first object will be the element closest to the origin + # After this choice, the algorithm loops through all remaining elements looking for the element whose first x,y + # coordinates are closest to the the previous choice's last x,y coordinates + # This process continues until all elements have been sorted into ordered_element_list and removed from group_dict + + ordered_layer_element_list = [] + + # Continue until all elements have been re-ordered + while group_dict: + + nearest_dist = float('inf') + for key,node in group_dict.items(): + # Is this node non-plottable? + # If so, exit loop and append element to ordered_layer_element_list + if not coord_dict[key][0]: + # Object is not Plottable + nearest = node + nearest_id = key + continue + + # If we reach this point, node is plottable and needs to be considered in our algo + entry_x = coord_dict[key][1] # x-coordinate of first point of the path + entry_y = coord_dict[key][2] # y-coordinate of first point of the path + + exit_x = coord_dict[key][3] # x-coordinate of last point of the path + exit_y = coord_dict[key][4] # y-coordinate of last point of the path + + object_dist = (entry_x-self.x_last)*(entry_x-self.x_last) + (entry_y-self.y_last) * (entry_y-self.y_last) + # This is actually the distance squared; calculating it rather than the pythagorean distance + # saves a square root calculation. Right now, we only care about _which distance is less_ + # not the exact value of it, so this is a harmless shortcut. + # If this distance is smaller than the previous element's distance, then replace the previous + # element's entry with our current element's distance + if nearest_dist >= object_dist: + # We have found an element closer than the previous closest element + nearest = node + nearest_id = key + nearest_dist = object_dist + nearest_start_x = entry_x + nearest_start_y = entry_y + + # Now that the closest object has been determined, it is time to add it to the + # optimized list of closest objects + ordered_layer_element_list.append(nearest) + + # To determine the closest object in the next iteration of the loop, + # we must save the last x,y coor of this element + # If this element is plottable, then save the x,y coordinates + # If this element is non-plottable, then do not save the x,y coordinates + if coord_dict[nearest_id][0]: + + # Also, draw line indicating that we've found a new point. + if self.options.preview_rendering == True: + preview_path = [] # pen-up path data for preview + + preview_path.append("M{0:.3f} {1:.3f}".format( + self.x_last, self.y_last)) + preview_path.append("{0:.3f} {1:.3f}".format( + nearest_start_x, nearest_start_y)) + self.p_style.update({'stroke': self.color_index(self.layer_index)}) + path_attrs = { + 'style': str(inkex.Style(self.p_style)), + 'd': " ".join(preview_path)} + + etree.SubElement( self.preview_layer, + inkex.addNS( 'path', 'svg'), path_attrs, nsmap=inkex.NSS ) + + self.x_last = coord_dict[nearest_id][3] + self.y_last = coord_dict[nearest_id][4] + + # Remove this element from group_dict to indicate it has been optimized + del group_dict[nearest_id] + + # Once all elements have been removed from the group_dictionary + # Return the optimized list of svg elements in the layer + return ordered_layer_element_list + + + def color_index(self, index): + index = index % 9 + + if index == 0: + return "rgb(255, 0, 0))" + elif index == 1: + return "rgb(170, 85, 0))" + elif index == 2: + return "rgb(85, 170, 0))" + elif index == 3: + return "rgb(0, 255, 0))" + elif index == 4: + return "rgb(0, 170, 85))" + elif index == 5: + return "rgb(0, 85, 170))" + elif index == 6: + return "rgb(0, 0, 255))" + elif index == 7: + return "rgb(85, 0, 170))" + else: + return "rgb(170, 0, 85))" + + + def getDocProps(self): + """ + Get the document's height and width attributes from the tag. + Use a default value in case the property is not present or is + expressed in units of percentages. + """ + + self.svg_height = plot_utils.getLengthInches(self, 'height') + self.svg_width = plot_utils.getLengthInches(self, 'width') + + width_string = self.svg.get('width') + if width_string: + value, units = plot_utils.parseLengthWithUnits(width_string) + self.doc_units = units + + if self.auto_rotate and (self.svg_height > self.svg_width): + self.printPortrait = True + if self.svg_height is None or self.svg_width is None: + return False + else: + return True + + + def get_output(self): + # Return serialized copy of svg document output + result = etree.tostring(self.document) + return result.decode("utf-8") + +if __name__ == '__main__': + OptimizeSequenceTravelDistance().run() \ No newline at end of file diff --git a/extensions/fablabchemnitz/optimize_sequence_travel_distance/plot_utils.py b/extensions/fablabchemnitz/optimize_sequence_travel_distance/plot_utils.py new file mode 100644 index 0000000..db1dc5f --- /dev/null +++ b/extensions/fablabchemnitz/optimize_sequence_travel_distance/plot_utils.py @@ -0,0 +1,744 @@ +# plot_utils.py +# Common plotting utilities for EiBotBoard +# https://github.com/evil-mad/plotink +# +# Intended to provide some common interfaces that can be used by +# EggBot, WaterColorBot, AxiDraw, and similar machines. +# +# See below for version information +# +# +# The MIT License (MIT) +# +# Copyright (c) 2019 Windell H. Oskay, Evil Mad Scientist Laboratories +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from math import sqrt + +import cspsubdiv +import simplepath +import bezmisc +import ffgeom + +def version(): # Version number for this document + return "0.16" # Dated 2019-06-18 + +__version__ = version() + +PX_PER_INCH = 96.0 +# This value has changed to 96 px per inch, as of version 0.12 of this library. +# Prior versions used 90 PPI, corresponding the value used in Inkscape < 0.92. +# For use with Inkscape 0.91 (or older), use PX_PER_INCH = 90.0 + +trivial_svg = """ + + + """ + +def checkLimits(value, lower_bound, upper_bound): + # Limit a value to within a range. + # Return constrained value with error boolean. + if value > upper_bound: + return upper_bound, True + if value < lower_bound: + return lower_bound, True + return value, False + + +def checkLimitsTol(value, lower_bound, upper_bound, tolerance): + # Limit a value to within a range. + # Return constrained value with error boolean. + # Allow a range of tolerance where we constrain the value without an error message. + + if value > upper_bound: + if value > (upper_bound + tolerance): + return upper_bound, True # Truncate & throw error + else: + return upper_bound, False # Truncate with no error + if value < lower_bound: + if value < (lower_bound - tolerance): + return lower_bound, True # Truncate & throw error + else: + return lower_bound, False # Truncate with no error + return value, False # Return original value without error + + +def clip_code(x, y, x_min, x_max, y_min, y_max): + # Encode point position with respect to boundary box + code = 0 + if x < x_min: + code = 1 # Left + if x > x_max: + code |= 2 # Right + if y < y_min: + code |= 4 # Top + if y > y_max: + code |= 8 # Bottom + return code + + +def clip_segment(segment, bounds): + """ + Given an input line segment [[x1,y1],[x2,y2]], as well as a + rectangular bounding region [[x_min,y_min],[x_max,y_max]], clip and + keep the part of the segment within the bounding region, using the + Cohen–Sutherland algorithm. + Return a boolean value, "accept", indicating that the output + segment is non-empty, as well as truncated segment, + [[x1',y1'],[x2',y2']], giving the portion of the input line segment + that fits within the bounds. + """ + + x1 = segment[0][0] + y1 = segment[0][1] + x2 = segment[1][0] + y2 = segment[1][1] + + x_min = bounds[0][0] + y_min = bounds[0][1] + x_max = bounds[1][0] + y_max = bounds[1][1] + + while True: # Repeat until return + code_1 = clip_code(x1, y1, x_min, x_max, y_min, y_max) + code_2 = clip_code(x2, y2, x_min, x_max, y_min, y_max) + + # Trivial accept: + if code_1 == 0 and code_2 == 0: + return True, segment # Both endpoints are within bounds. + # Trivial reject, if both endpoints are outside, and on the same side: + if code_1 & code_2: + return False, segment # Verify with bitwise AND. + + # Otherwise, at least one point is out of bounds; not trivial. + if code_1 != 0: + code = code_1 + else: + code = code_2 + + # Clip at a single boundary; may need to do this up to twice per vertex + + if code & 1: # Vertex on LEFT side of bounds: + x = x_min # Find intersection of our segment with x_min + slope = (y2 - y1) / (x2 - x1) + y = slope * (x_min - x1) + y1 + + elif code & 2: # Vertex on RIGHT side of bounds: + x = x_max # Find intersection of our segment with x_max + slope = (y2 - y1) / (x2 - x1) + y = slope * (x_max - x1) + y1 + + elif code & 4: # Vertex on TOP side of bounds: + y = y_min # Find intersection of our segment with y_min + slope = (x2 - x1) / (y2 - y1) + x = slope * (y_min - y1) + x1 + + elif code & 8: # Vertex on BOTTOM side of bounds: + y = y_max # Find intersection of our segment with y_max + slope = (x2 - x1) / (y2 - y1) + x = slope * (y_max - y1) + x1 + + if code == code_1: + x1 = x + y1 = y + else: + x2 = x + y2 = y + segment = [[x1,y1],[x2,y2]] # Now checking this clipped segment + + +def constrainLimits(value, lower_bound, upper_bound): + # Limit a value to within a range. + return max(lower_bound, min(upper_bound, value)) + + +def distance(x, y): + """ + Pythagorean theorem + """ + return sqrt(x * x + y * y) + + +def dotProductXY(input_vector_first, input_vector_second): + temp = input_vector_first[0] * input_vector_second[0] + input_vector_first[1] * input_vector_second[1] + if temp > 1: + return 1 + elif temp < -1: + return -1 + else: + return temp + + +def getLength(altself, name, default): + """ + Get the attribute with name "name" and default value "default" + Parse the attribute into a value and associated units. Then, accept + no units (''), units of pixels ('px'), and units of percentage ('%'). + Return value in px. + """ + string_to_parse = altself.document.getroot().get(name) + + if string_to_parse: + v, u = parseLengthWithUnits(string_to_parse) + if v is None: + return None + elif u == '' or u == 'px': + return float(v) + elif u == 'in': + return float(v) * PX_PER_INCH + elif u == 'mm': + return float(v) * PX_PER_INCH / 25.4 + elif u == 'cm': + return float(v) * PX_PER_INCH / 2.54 + elif u == 'Q' or u == 'q': + return float(v) * PX_PER_INCH / (40.0 * 2.54) + elif u == 'pc': + return float(v) * PX_PER_INCH / 6.0 + elif u == 'pt': + return float(v) * PX_PER_INCH / 72.0 + elif u == '%': + return float(default) * v / 100.0 + else: + # Unsupported units + return None + else: + # No width specified; assume the default value + return float(default) + + +def getLengthInches(altself, name): + """ + Get the attribute with name "name", and parse it as a length, + into a value and associated units. Return value in inches. + + As of version 0.11, units of 'px' or no units ('') are interpreted + as imported px, at a resolution of 96 px per inch, as per the SVG + specification. (Prior versions returned None in this case.) + + This allows certain imported SVG files, (imported with units of px) + to plot while they would not previously. However, it may also cause + new scaling issues in some circumstances. Note, for example, that + Adobe Illustrator uses 72 px per inch, and Inkscape used 90 px per + inch prior to version 0.92. + """ + string_to_parse = altself.document.getroot().get(name) + if string_to_parse: + v, u = parseLengthWithUnits(string_to_parse) + if v is None: + return None + elif u == 'in': + return float(v) + elif u == 'mm': + return float(v) / 25.4 + elif u == 'cm': + return float(v) / 2.54 + elif u == 'Q' or u == 'q': + return float(v) / (40.0 * 2.54) + elif u == 'pc': + return float(v) / 6.0 + elif u == 'pt': + return float(v) / 72.0 + elif u == '' or u == 'px': + return float(v) / 96.0 + else: + # Unsupported units, including '%' + return None + + +def parseLengthWithUnits(string_to_parse): + """ + Parse an SVG value which may or may not have units attached. + There is a more general routine to consider in scour.py if more + generality is ever needed. + """ + u = 'px' + s = string_to_parse.strip() + if s[-2:] == 'px': # pixels, at a size of PX_PER_INCH per inch + s = s[:-2] + elif s[-2:] == 'in': # inches + s = s[:-2] + u = 'in' + elif s[-2:] == 'mm': # millimeters + s = s[:-2] + u = 'mm' + elif s[-2:] == 'cm': # centimeters + s = s[:-2] + u = 'cm' + elif s[-2:] == 'pt': # points; 1pt = 1/72th of 1in + s = s[:-2] + u = 'pt' + elif s[-2:] == 'pc': # picas; 1pc = 1/6th of 1in + s = s[:-2] + u = 'pc' + elif s[-1:] == 'Q' or s[-1:] == 'q': # quarter-millimeters. 1q = 1/40th of 1cm + s = s[:-1] + u = 'Q' + elif s[-1:] == '%': + u = '%' + s = s[:-1] + + try: + v = float(s) + except: + return None, None + + return v, u + + +def unitsToUserUnits(input_string): + """ + Custom replacement for the unittouu routine in inkex.py + + Parse the attribute into a value and associated units. + Return value in user units (typically "px"). + """ + + v, u = parseLengthWithUnits(input_string) + if v is None: + return None + elif u == '' or u == 'px': + return float(v) + elif u == 'in': + return float(v) * PX_PER_INCH + elif u == 'mm': + return float(v) * PX_PER_INCH / 25.4 + elif u == 'cm': + return float(v) * PX_PER_INCH / 2.54 + elif u == 'Q' or u == 'q': + return float(v) * PX_PER_INCH / (40.0 * 2.54) + elif u == 'pc': + return float(v) * PX_PER_INCH / 6.0 + elif u == 'pt': + return float(v) * PX_PER_INCH / 72.0 + elif u == '%': + return float(v) / 100.0 + else: + # Unsupported units + return None + + +def subdivideCubicPath(sp, flat, i=1): + """ + Break up a bezier curve into smaller curves, each of which + is approximately a straight line within a given tolerance + (the "smoothness" defined by [flat]). + + This is a modified version of cspsubdiv.cspsubdiv(). I rewrote the recursive + call because it caused recursion-depth errors on complicated line segments. + """ + + while True: + while True: + if i >= len(sp): + return + p0 = sp[i - 1][1] + p1 = sp[i - 1][2] + p2 = sp[i][0] + p3 = sp[i][1] + + b = (p0, p1, p2, p3) + + if cspsubdiv.maxdist(b) > flat: + break + i += 1 + + one, two = bezmisc.beziersplitatt(b, 0.5) + sp[i - 1][2] = one[1] + sp[i][0] = two[2] + p = [one[2], one[3], two[1]] + sp[i:1] = [p] + +def max_dist_from_n_points(input): + """ + Like cspsubdiv.maxdist, but it can check for distances of any number of points >= 0. + + `input` is an ordered collection of points, each point specified as an x- and y-coordinate. + The first point and the last point define the segment we are finding distances from. + + does not mutate `input` + """ + assert len(input) >= 3, "There must be points (other than begin/end) to check." + + points = [ffgeom.Point(point[0], point[1]) for point in input] + segment = ffgeom.Segment(points.pop(0), points.pop()) + + distances = [segment.distanceToPoint(point) for point in points] + return max(distances) + +def supersample(vertices, tolerance): + """ + Given a list of vertices, remove some according to the following algorithm. + + Suppose that the vertex list consists of points A, B, C, D, E, and so forth, which define segments AB, BC, CD, DE, EF, and so on. + + We first test to see if vertex B can be removed, by using perpDistanceToPoint to check whether the distance between B and segment AC is less than tolerance. + If B can be removed, then check to see if the next vertex, C, can be removed. Both B and C can be removed if the both the distance between B and AD is less than Tolerance and the distance between C and AD is less than Tolerance. Continue removing additional vertices, so long as the perpendicular distance between every point removed and the resulting segment is less than tolerance (and the end of the vertex list is not reached). +If B cannot be removed, then move onto vertex C, and perform the same checks, until the end of the vertex list is reached. + """ + if len(vertices) <= 2: # there is nothing to delete + return vertices + + start_index = 0 # can't remove first vertex + while start_index < len(vertices) - 2: + end_index = start_index + 2 + # test the removal of (start_index, end_index), exclusive until we can't advance end_index + while (max_dist_from_n_points(vertices[start_index:end_index + 1]) < tolerance + and end_index < len(vertices)): + end_index += 1 # try removing the next vertex too + + vertices[start_index + 1:end_index - 1] = [] # delete (start_index, end_index), exclusive + start_index += 1 + +def userUnitToUnits(distance_uu, unit_string): + """ + Custom replacement for the uutounit routine in inkex.py + + Parse the attribute into a value and associated units. + Return value in user units (typically "px"). + """ + + if distance_uu is None: # Couldn't parse the value + return None + elif unit_string == '' or unit_string == 'px': + return float(distance_uu) + elif unit_string == 'in': + return float(distance_uu) / PX_PER_INCH + elif unit_string == 'mm': + return float(distance_uu) / (PX_PER_INCH / 25.4) + elif unit_string == 'cm': + return float(distance_uu) / (PX_PER_INCH / 2.54) + elif unit_string == 'Q' or unit_string == 'q': + return float(distance_uu) / (PX_PER_INCH / (40.0 * 2.54)) + elif unit_string == 'pc': + return float(distance_uu) / (PX_PER_INCH / 6.0) + elif unit_string == 'pt': + return float(distance_uu) / (PX_PER_INCH / 72.0) + elif unit_string == '%': + return float(distance_uu) * 100.0 + else: + # Unsupported units + return None + + +def vb_scale(vb, p_a_r, doc_width, doc_height): + """" + Parse SVG viewbox and generate scaling parameters. + Reference documentation: https://www.w3.org/TR/SVG11/coords.html + + Inputs: + vb: Contents of SVG viewbox attribute + p_a_r: Contents of SVG preserveAspectRatio attribute + doc_width: Width of SVG document + doc_height: Height of SVG document + + Output: sx, sy, ox, oy + Scale parameters (sx,sy) and offset parameters (ox,oy) + + """ + if vb is None: + return 1,1,0,0 # No viewbox; return default transform + else: + vb_array = vb.strip().replace(',', ' ').split() + + if len(vb_array) < 4: + return 1,1,0,0 # invalid viewbox; return default transform + + min_x = float(vb_array[0]) # Viewbox offset: x + min_y = float(vb_array[1]) # Viewbox offset: y + width = float(vb_array[2]) # Viewbox width + height = float(vb_array[3]) # Viewbox height + + if width <= 0 or height <= 0: + return 1,1,0,0 # invalid viewbox; return default transform + + d_width = float(doc_width) + d_height = float(doc_height) + + if d_width <= 0 or d_height <= 0: + return 1,1,0,0 # invalid document size; return default transform + + ar_doc = d_height / d_width # Document aspect ratio + ar_vb = height / width # Viewbox aspect ratio + + # Default values of the two preserveAspectRatio parameters: + par_align = "xmidymid" # "align" parameter (lowercased) + par_mos = "meet" # "meetOrSlice" parameter + + if p_a_r is not None: + par_array = p_a_r.strip().replace(',', ' ').lower().split() + if len(par_array) > 0: + par0 = par_array[0] + if par0 == "defer": + if len(par_array) > 1: + par_align = par_array[1] + if len(par_array) > 2: + par_mos = par_array[2] + else: + par_align = par0 + if len(par_array) > 1: + par_mos = par_array[1] + + if par_align == "none": + # Scale document to fill page. Do not preserve aspect ratio. + # This is not default behavior, nor what happens if par_align + # is not given; the "none" value must be _explicitly_ specified. + + sx = d_width/ width + sy = d_height / height + ox = -min_x + oy = -min_y + return sx,sy,ox,oy + + """ + Other than "none", all situations fall into two classes: + + 1) (ar_doc >= ar_vb AND par_mos == "meet") + or (ar_doc < ar_vb AND par_mos == "slice") + -> In these cases, scale document up until VB fills doc in X. + + 2) All other cases, i.e., + (ar_doc < ar_vb AND par_mos == "meet") + or (ar_doc >= ar_vb AND par_mos == "slice") + -> In these cases, scale document up until VB fills doc in Y. + + Note in cases where the scaled viewbox exceeds the document + (page) boundaries (all "slice" cases and many "meet" cases where + an offset value is given) that this routine does not perform + any clipping, but subsequent clipping to the page boundary + is appropriate. + + Besides "none", there are 9 possible values of par_align: + xminymin xmidymin xmaxymin + xminymid xmidymid xmaxymid + xminymax xmidymax xmaxymax + """ + + if (((ar_doc >= ar_vb) and (par_mos == "meet")) + or ((ar_doc < ar_vb) and (par_mos == "slice"))): + # Case 1: Scale document up until VB fills doc in X. + + sx = d_width / width + sy = sx # Uniform aspect ratio + ox = -min_x + + scaled_vb_height = ar_doc * width + excess_height = scaled_vb_height - height + + if par_align in {"xminymin", "xmidymin", "xmaxymin"}: + # Case: Y-Min: Align viewbox to minimum Y of the viewport. + oy = -min_y + # OK: tested with Tall-Meet, Wide-Slice + + elif par_align in {"xminymax", "xmidymax", "xmaxymax"}: + # Case: Y-Max: Align viewbox to maximum Y of the viewport. + oy = -min_y + excess_height + # OK: tested with Tall-Meet, Wide-Slice + + else: # par_align in {"xminymid", "xmidymid", "xmaxymid"}: + # Default case: Y-Mid: Center viewbox on page in Y + oy = -min_y + excess_height / 2 + # OK: Tested with Tall-Meet, Wide-Slice + + return sx,sy,ox,oy + else: + # Case 2: Scale document up until VB fills doc in Y. + + sy = d_height / height + sx = sy # Uniform aspect ratio + oy = -min_y + + scaled_vb_width = height / ar_doc + excess_width = scaled_vb_width - width + + if par_align in {"xminymin", "xminymid", "xminymax"}: + # Case: X-Min: Align viewbox to minimum X of the viewport. + ox = -min_x + # OK: Tested with Tall-Slice, Wide-Meet + + elif par_align in {"xmaxymin", "xmaxymid", "xmaxymax"}: + # Case: X-Max: Align viewbox to maximum X of the viewport. + ox = -min_x + excess_width + # Need test: Tall-Slice, Wide-Meet + + else: # par_align in {"xmidymin", "xmidymid", "xmidymax"}: + # Default case: X-Mid: Center viewbox on page in X + ox = -min_x + excess_width / 2 + # OK: Tested with Tall-Slice, Wide-Meet + + return sx,sy,ox,oy + return 1,1,0,0 # Catch-all: return default transform + + +def vInitial_VF_A_Dx(v_final, acceleration, delta_x): + """ + Kinematic calculation: Maximum allowed initial velocity to arrive at distance X + with specified final velocity, and given maximum linear acceleration. + + Calculate and return the (real) initial velocity, given an final velocity, + acceleration rate, and distance interval. + + Uses the kinematic equation Vi^2 = Vf^2 - 2 a D_x , where + Vf is the final velocity, + a is the acceleration rate, + D_x (delta x) is the distance interval, and + Vi is the initial velocity. + + We are looking at the positive root only-- if the argument of the sqrt + is less than zero, return -1, to indicate a failure. + """ + initial_v_squared = (v_final * v_final) - (2 * acceleration * delta_x) + if initial_v_squared > 0: + return sqrt(initial_v_squared) + else: + return -1 + + +def vFinal_Vi_A_Dx(v_initial, acceleration, delta_x): + """ + Kinematic calculation: Final velocity with constant linear acceleration. + + Calculate and return the (real) final velocity, given an initial velocity, + acceleration rate, and distance interval. + + Uses the kinematic equation Vf^2 = 2 a D_x + Vi^2, where + Vf is the final velocity, + a is the acceleration rate, + D_x (delta x) is the distance interval, and + Vi is the initial velocity. + + We are looking at the positive root only-- if the argument of the sqrt + is less than zero, return -1, to indicate a failure. + """ + final_v_squared = (2 * acceleration * delta_x) + (v_initial * v_initial) + if final_v_squared > 0: + return sqrt(final_v_squared) + else: + return -1 + + +def pathdata_first_point(path): + """ + Return the first (X,Y) point from an SVG path data string + + Input: A path data string; the text of the 'd' attribute of an SVG path + Output: Two floats in a list representing the x and y coordinates of the first point + """ + + # Path origin's default values are used to see if we have + # Written anything to the path_origin variable yet + MaxLength = len(path) + ix = 0 + tempString = '' + x_val = '' + y_val = '' + # Check one char at a time + # until we have the moveTo Command + while ix < MaxLength: + if path[ix].upper() == 'M': + break + # Increment until we have M + ix = ix + 1 + + # Parse path until we reach a digit, decimal point or negative sign + while ix < MaxLength: + if(path[ix].isdigit()) or path[ix] == '.' or path[ix] == '-': + break + ix = ix + 1 + + # Add digits and decimal points to x_val + # Stop parsing when next character is neither a digit nor a decimal point + while ix < MaxLength: + if (path[ix].isdigit()): + tempString = tempString + path[ix] + x_val = float(tempString ) + ix = ix + 1 + # If next character is a decimal place, save the decimal and continue parsing + # This allows for paths without leading zeros to be parsed correctly + elif (path[ix] == '.' or path[ix] == '-'): + tempString = tempString + path[ix] + ix = ix + 1 + else: + ix = ix + 1 + break + + # Reset tempString for y coordinate + tempString = '' + + # Parse path until we reach a digit or decimal point + while ix < MaxLength: + if(path[ix].isdigit()) or path[ix] == '.' or path[ix] == '-': + break + ix = ix + 1 + + # Add digits and decimal points to y_val + # Stop parsin when next character is neither a digit nor a decimal point + while ix < MaxLength: + if (path[ix].isdigit() ): + tempString = tempString + path[ix] + y_val = float(tempString) + ix = ix + 1 + # If next character is a decimal place, save the decimal and continue parsing + # This allows for paths without leading zeros to be parsed correctly + elif (path[ix] == '.' or path[ix] == '-'): + tempString = tempString + path[ix] + ix = ix + 1 + else: + ix = ix + 1 + break + return [x_val,y_val] + + +def pathdata_last_point(path): + """ + Return the last (X,Y) point from an SVG path data string + + Input: A path data string; the text of the 'd' attribute of an SVG path + Output: Two floats in a list representing the x and y coordinates of the last point + """ + + command, params = simplepath.parsePath(path)[-1] # parsePath splits path into segments + + if command.upper() == 'Z': + return pathdata_first_point(path) # Trivial case + + """ + Otherwise: The last command should be in the set 'MLCQA' + - All commands converted to absolute by parsePath. + - Can ignore Z (case handled) + - Can ignore H,V, since those are converted to L by parsePath. + - Can ignore S, converted to C by parsePath. + - Can ignore T, converted to Q by parsePath. + + MLCQA: Commands all ending in (X,Y) pair. + """ + + x_val = params[-2] # Second to last parameter given + y_val = params[-1] # Last parameter given + + return [x_val,y_val] diff --git a/extensions/fablabchemnitz/optimize_sequence_travel_distance/simplepath.py b/extensions/fablabchemnitz/optimize_sequence_travel_distance/simplepath.py new file mode 100644 index 0000000..81e3cd9 --- /dev/null +++ b/extensions/fablabchemnitz/optimize_sequence_travel_distance/simplepath.py @@ -0,0 +1,211 @@ +""" +simplepath.py +functions for digesting paths into a simple list structure + +Copyright (C) 2005 Aaron Spike, aaron@ekips.org + +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. + +""" +import re, math + +def lexPath(d): + """ + returns and iterator that breaks path data + identifies command and parameter tokens + """ + offset = 0 + length = len(d) + delim = re.compile(r'[ \t\r\n,]+') + command = re.compile(r'[MLHVCSQTAZmlhvcsqtaz]') + parameter = re.compile(r'(([-+]?[0-9]+(\.[0-9]*)?|[-+]?\.[0-9]+)([eE][-+]?[0-9]+)?)') + while 1: + m = delim.match(d, offset) + if m: + offset = m.end() + if offset >= length: + break + m = command.match(d, offset) + if m: + yield [d[offset:m.end()], True] + offset = m.end() + continue + m = parameter.match(d, offset) + if m: + yield [d[offset:m.end()], False] + offset = m.end() + continue + #TODO: create new exception + raise Exception('Invalid path data!') +''' +pathdefs = {commandfamily: + [ + implicitnext, + #params, + [casts,cast,cast], + [coord type,x,y,0] + ]} +''' +pathdefs = { + 'M':['L', 2, [float, float], ['x','y']], + 'L':['L', 2, [float, float], ['x','y']], + 'H':['H', 1, [float], ['x']], + 'V':['V', 1, [float], ['y']], + 'C':['C', 6, [float, float, float, float, float, float], ['x','y','x','y','x','y']], + 'S':['S', 4, [float, float, float, float], ['x','y','x','y']], + 'Q':['Q', 4, [float, float, float, float], ['x','y','x','y']], + 'T':['T', 2, [float, float], ['x','y']], + 'A':['A', 7, [float, float, float, int, int, float, float], ['r','r','a',0,'s','x','y']], + 'Z':['L', 0, [], []] + } +def parsePath(d): + """ + Parse SVG path and return an array of segments. + Removes all shorthand notation. + Converts coordinates to absolute. + """ + retval = [] + lexer = lexPath(d) + + pen = (0.0,0.0) + subPathStart = pen + lastControl = pen + lastCommand = '' + + while 1: + try: + token, isCommand = next(lexer) + except StopIteration: + break + params = [] + needParam = True + if isCommand: + if not lastCommand and token.upper() != 'M': + raise Exception('Invalid path, must begin with moveto.') + else: + command = token + else: + #command was omited + #use last command's implicit next command + needParam = False + if lastCommand: + if lastCommand.isupper(): + command = pathdefs[lastCommand][0] + else: + command = pathdefs[lastCommand.upper()][0].lower() + else: + raise Exception('Invalid path, no initial command.') + numParams = pathdefs[command.upper()][1] + while numParams > 0: + if needParam: + try: + token, isCommand = next(lexer) + if isCommand: + raise Exception('Invalid number of parameters') + except StopIteration: + raise Exception('Unexpected end of path') + cast = pathdefs[command.upper()][2][-numParams] + param = cast(token) + if command.islower(): + if pathdefs[command.upper()][3][-numParams]=='x': + param += pen[0] + elif pathdefs[command.upper()][3][-numParams]=='y': + param += pen[1] + params.append(param) + needParam = True + numParams -= 1 + #segment is now absolute so + outputCommand = command.upper() + + #Flesh out shortcut notation + if outputCommand in ('H','V'): + if outputCommand == 'H': + params.append(pen[1]) + if outputCommand == 'V': + params.insert(0,pen[0]) + outputCommand = 'L' + if outputCommand in ('S','T'): + params.insert(0,pen[1]+(pen[1]-lastControl[1])) + params.insert(0,pen[0]+(pen[0]-lastControl[0])) + if outputCommand == 'S': + outputCommand = 'C' + if outputCommand == 'T': + outputCommand = 'Q' + + #current values become "last" values + if outputCommand == 'M': + subPathStart = tuple(params[0:2]) + pen = subPathStart + if outputCommand == 'Z': + pen = subPathStart + else: + pen = tuple(params[-2:]) + + if outputCommand in ('Q','C'): + lastControl = tuple(params[-4:-2]) + else: + lastControl = pen + lastCommand = command + + retval.append([outputCommand,params]) + return retval + +def formatPath(a): + """Format SVG path data from an array""" + return "".join([cmd + " ".join([str(p) for p in params]) for cmd, params in a]) + +def translatePath(p, x, y): + for cmd,params in p: + defs = pathdefs[cmd] + for i in range(defs[1]): + if defs[3][i] == 'x': + params[i] += x + elif defs[3][i] == 'y': + params[i] += y + +def scalePath(p, x, y): + for cmd,params in p: + defs = pathdefs[cmd] + for i in range(defs[1]): + if defs[3][i] == 'x': + params[i] *= x + elif defs[3][i] == 'y': + params[i] *= y + elif defs[3][i] == 'r': # radius parameter + params[i] *= x + elif defs[3][i] == 's': # sweep-flag parameter + if x*y < 0: + params[i] = 1 - params[i] + elif defs[3][i] == 'a': # x-axis-rotation angle + if y < 0: + params[i] = - params[i] + +def rotatePath(p, a, cx = 0, cy = 0): + if a == 0: + return p + for cmd,params in p: + defs = pathdefs[cmd] + for i in range(defs[1]): + if defs[3][i] == 'x': + x = params[i] - cx + y = params[i + 1] - cy + r = math.sqrt((x**2) + (y**2)) + if r != 0: + theta = math.atan2(y, x) + a + params[i] = (r * math.cos(theta)) + cx + params[i + 1] = (r * math.sin(theta)) + cy + + +# vim: expandtab shiftwidth=4 tabstop=8 softtabstop=4 fileencoding=utf-8 textwidth=99 diff --git a/extensions/fablabchemnitz/optimize_sequence_travel_distance/simpletransform.py b/extensions/fablabchemnitz/optimize_sequence_travel_distance/simpletransform.py new file mode 100644 index 0000000..e615fc1 --- /dev/null +++ b/extensions/fablabchemnitz/optimize_sequence_travel_distance/simpletransform.py @@ -0,0 +1,261 @@ +''' +Copyright (C) 2006 Jean-Francois Barraud, barraud@math.univ-lille1.fr +Copyright (C) 2010 Alvin Penner, penner@vaxxine.com + +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. +barraud@math.univ-lille1.fr + +This code defines several functions to make handling of transform +attribute easier. +''' +import inkex, cubicsuperpath, bezmisc, simplestyle +import copy, math, re + +def parseTransform(transf,mat=[[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]): + if transf=="" or transf==None: + return(mat) + stransf = transf.strip() + result=re.match(r"(translate|scale|rotate|skewX|skewY|matrix)\s*\(([^)]*)\)\s*,?",stransf) +#-- translate -- + if result.group(1)=="translate": + args=result.group(2).replace(',',' ').split() + dx=float(args[0]) + if len(args)==1: + dy=0.0 + else: + dy=float(args[1]) + matrix=[[1,0,dx],[0,1,dy]] +#-- scale -- + if result.group(1)=="scale": + args=result.group(2).replace(',',' ').split() + sx=float(args[0]) + if len(args)==1: + sy=sx + else: + sy=float(args[1]) + matrix=[[sx,0,0],[0,sy,0]] +#-- rotate -- + if result.group(1)=="rotate": + args=result.group(2).replace(',',' ').split() + a=float(args[0])*math.pi/180 + if len(args)==1: + cx,cy=(0.0,0.0) + else: + cx,cy=map(float,args[1:]) + matrix=[[math.cos(a),-math.sin(a),cx],[math.sin(a),math.cos(a),cy]] + matrix=composeTransform(matrix,[[1,0,-cx],[0,1,-cy]]) +#-- skewX -- + if result.group(1)=="skewX": + a=float(result.group(2))*math.pi/180 + matrix=[[1,math.tan(a),0],[0,1,0]] +#-- skewY -- + if result.group(1)=="skewY": + a=float(result.group(2))*math.pi/180 + matrix=[[1,0,0],[math.tan(a),1,0]] +#-- matrix -- + if result.group(1)=="matrix": + a11,a21,a12,a22,v1,v2=result.group(2).replace(',',' ').split() + matrix=[[float(a11),float(a12),float(v1)], [float(a21),float(a22),float(v2)]] + + matrix=composeTransform(mat,matrix) + if result.end() < len(stransf): + return(parseTransform(stransf[result.end():], matrix)) + else: + return matrix + +def formatTransform(mat): + return ("matrix(%f,%f,%f,%f,%f,%f)" % (mat[0][0], mat[1][0], mat[0][1], mat[1][1], mat[0][2], mat[1][2])) + +def invertTransform(mat): + det = mat[0][0]*mat[1][1] - mat[0][1]*mat[1][0] + if det !=0: # det is 0 only in case of 0 scaling + # invert the rotation/scaling part + a11 = mat[1][1]/det + a12 = -mat[0][1]/det + a21 = -mat[1][0]/det + a22 = mat[0][0]/det + # invert the translational part + a13 = -(a11*mat[0][2] + a12*mat[1][2]) + a23 = -(a21*mat[0][2] + a22*mat[1][2]) + return [[a11,a12,a13],[a21,a22,a23]] + else: + return[[0,0,-mat[0][2]],[0,0,-mat[1][2]]] + +def composeTransform(M1,M2): + a11 = M1[0][0]*M2[0][0] + M1[0][1]*M2[1][0] + a12 = M1[0][0]*M2[0][1] + M1[0][1]*M2[1][1] + a21 = M1[1][0]*M2[0][0] + M1[1][1]*M2[1][0] + a22 = M1[1][0]*M2[0][1] + M1[1][1]*M2[1][1] + + v1 = M1[0][0]*M2[0][2] + M1[0][1]*M2[1][2] + M1[0][2] + v2 = M1[1][0]*M2[0][2] + M1[1][1]*M2[1][2] + M1[1][2] + return [[a11,a12,v1],[a21,a22,v2]] + +def composeParents(node, mat): + trans = node.get('transform') + if trans: + mat = composeTransform(parseTransform(trans), mat) + if node.getparent().tag == inkex.addNS('g','svg'): + mat = composeParents(node.getparent(), mat) + return mat + +def applyTransformToNode(mat,node): + m=parseTransform(node.get("transform")) + newtransf=formatTransform(composeTransform(mat,m)) + node.set("transform", newtransf) + +def applyTransformToPoint(mat,pt): + x = mat[0][0]*pt[0] + mat[0][1]*pt[1] + mat[0][2] + y = mat[1][0]*pt[0] + mat[1][1]*pt[1] + mat[1][2] + pt[0]=x + pt[1]=y + +def applyTransformToPath(mat,path): + for comp in path: + for ctl in comp: + for pt in ctl: + applyTransformToPoint(mat,pt) + +def fuseTransform(node): + if node.get('d')==None: + #FIXME: how do you raise errors? + raise AssertionError('can not fuse "transform" of elements that have no "d" attribute') + t = node.get("transform") + if t == None: + return + m = parseTransform(t) + d = node.get('d') + p = cubicsuperpath.parsePath(d) + applyTransformToPath(m,p) + node.set('d', cubicsuperpath.formatPath(p)) + del node.attrib["transform"] + +#################################################################### +##-- Some functions to compute a rough bbox of a given list of objects. +##-- this should be shipped out in an separate file... + +def boxunion(b1,b2): + if b1 is None: + return b2 + elif b2 is None: + return b1 + else: + return((min(b1[0],b2[0]), max(b1[1],b2[1]), min(b1[2],b2[2]), max(b1[3],b2[3]))) + +def roughBBox(path): + xmin,xMax,ymin,yMax = path[0][0][0][0],path[0][0][0][0],path[0][0][0][1],path[0][0][0][1] + for pathcomp in path: + for ctl in pathcomp: + for pt in ctl: + xmin = min(xmin,pt[0]) + xMax = max(xMax,pt[0]) + ymin = min(ymin,pt[1]) + yMax = max(yMax,pt[1]) + return xmin,xMax,ymin,yMax + +def refinedBBox(path): + xmin,xMax,ymin,yMax = path[0][0][1][0],path[0][0][1][0],path[0][0][1][1],path[0][0][1][1] + for pathcomp in path: + for i in range(1, len(pathcomp)): + cmin, cmax = cubicExtrema(pathcomp[i-1][1][0], pathcomp[i-1][2][0], pathcomp[i][0][0], pathcomp[i][1][0]) + xmin = min(xmin, cmin) + xMax = max(xMax, cmax) + cmin, cmax = cubicExtrema(pathcomp[i-1][1][1], pathcomp[i-1][2][1], pathcomp[i][0][1], pathcomp[i][1][1]) + ymin = min(ymin, cmin) + yMax = max(yMax, cmax) + return xmin,xMax,ymin,yMax + +def cubicExtrema(y0, y1, y2, y3): + cmin = min(y0, y3) + cmax = max(y0, y3) + d1 = y1 - y0 + d2 = y2 - y1 + d3 = y3 - y2 + if (d1 - 2*d2 + d3): + if (d2*d2 > d1*d3): + t = (d1 - d2 + math.sqrt(d2*d2 - d1*d3))/(d1 - 2*d2 + d3) + if (t > 0) and (t < 1): + y = y0*(1-t)*(1-t)*(1-t) + 3*y1*t*(1-t)*(1-t) + 3*y2*t*t*(1-t) + y3*t*t*t + cmin = min(cmin, y) + cmax = max(cmax, y) + t = (d1 - d2 - math.sqrt(d2*d2 - d1*d3))/(d1 - 2*d2 + d3) + if (t > 0) and (t < 1): + y = y0*(1-t)*(1-t)*(1-t) + 3*y1*t*(1-t)*(1-t) + 3*y2*t*t*(1-t) + y3*t*t*t + cmin = min(cmin, y) + cmax = max(cmax, y) + elif (d3 - d1): + t = -d1/(d3 - d1) + if (t > 0) and (t < 1): + y = y0*(1-t)*(1-t)*(1-t) + 3*y1*t*(1-t)*(1-t) + 3*y2*t*t*(1-t) + y3*t*t*t + cmin = min(cmin, y) + cmax = max(cmax, y) + return cmin, cmax + +def computeBBox(aList,mat=[[1,0,0],[0,1,0]]): + bbox=None + for node in aList: + m = parseTransform(node.get('transform')) + m = composeTransform(mat,m) + #TODO: text not supported! + d = None + if node.get("d"): + d = node.get('d') + elif node.get('points'): + d = 'M' + node.get('points') + elif node.tag in [ inkex.addNS('rect','svg'), 'rect', inkex.addNS('image','svg'), 'image' ]: + d = 'M' + node.get('x', '0') + ',' + node.get('y', '0') + \ + 'h' + node.get('width') + 'v' + node.get('height') + \ + 'h-' + node.get('width') + elif node.tag in [ inkex.addNS('line','svg'), 'line' ]: + d = 'M' + node.get('x1') + ',' + node.get('y1') + \ + ' ' + node.get('x2') + ',' + node.get('y2') + elif node.tag in [ inkex.addNS('circle','svg'), 'circle', \ + inkex.addNS('ellipse','svg'), 'ellipse' ]: + rx = node.get('r') + if rx is not None: + ry = rx + else: + rx = node.get('rx') + ry = node.get('ry') + cx = float(node.get('cx', '0')) + cy = float(node.get('cy', '0')) + x1 = cx - float(rx) + x2 = cx + float(rx) + d = 'M %f %f ' % (x1, cy) + \ + 'A' + rx + ',' + ry + ' 0 1 0 %f,%f' % (x2, cy) + \ + 'A' + rx + ',' + ry + ' 0 1 0 %f,%f' % (x1, cy) + + if d is not None: + p = cubicsuperpath.parsePath(d) + applyTransformToPath(m,p) + bbox=boxunion(refinedBBox(p),bbox) + + elif node.tag == inkex.addNS('use','svg') or node.tag=='use': + refid=node.get(inkex.addNS('href','xlink')) + path = '//*[@id="%s"]' % refid[1:] + refnode = node.xpath(path) + bbox=boxunion(computeBBox(refnode,m),bbox) + + bbox=boxunion(computeBBox(node,m),bbox) + return bbox + + +def computePointInNode(pt, node, mat=[[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]): + if node.getparent() is not None: + applyTransformToPoint(invertTransform(composeParents(node, mat)), pt) + return pt + + +# vim: expandtab shiftwidth=4 tabstop=8 softtabstop=4 fileencoding=utf-8 textwidth=99 diff --git a/extensions/fablabchemnitz/purge_duplicate_path_nodes/meta.json b/extensions/fablabchemnitz/purge_duplicate_path_nodes/meta.json new file mode 100644 index 0000000..2d9c48c --- /dev/null +++ b/extensions/fablabchemnitz/purge_duplicate_path_nodes/meta.json @@ -0,0 +1,21 @@ +[ + { + "name": "Purge Duplicate Path Nodes", + "id": "fablabchemnitz.de.purge_duplicate_path_nodes", + "path": "purge_duplicate_path_nodes", + "dependent_extensions": null, + "original_name": "Remove duplicate nodes", + "original_id": "EllenWasbo.cutlings.RemoveDuplicateNodes", + "license": "GNU GPL v2", + "license_url": "https://gitlab.com/EllenWasbo/inkscape-extension-removeduplicatenodes/-/blob/master/removeDuplicateNodes.py", + "comment": "", + "source_url": "https://gitea.fablabchemnitz.de/FabLab_Chemnitz/mightyscape-1.2/src/branch/master/extensions/fablabchemnitz/purge_duplicate_path_nodes", + "fork_url": "https://gitlab.com/EllenWasbo/inkscape-extension-removeduplicatenodes", + "documentation_url": "https://stadtfabrikanten.org/display/IFM/Purge+Duplicate+Path+Nodes", + "inkscape_gallery_url": null, + "main_authors": [ + "gitlab.com/EllenWasbo", + "github.com/eridur-de" + ] + } +] diff --git a/extensions/fablabchemnitz/purge_duplicate_path_nodes/purge_duplicate_path_nodes.inx b/extensions/fablabchemnitz/purge_duplicate_path_nodes/purge_duplicate_path_nodes.inx new file mode 100644 index 0000000..4fdb3b7 --- /dev/null +++ b/extensions/fablabchemnitz/purge_duplicate_path_nodes/purge_duplicate_path_nodes.inx @@ -0,0 +1,43 @@ + + + Purge Duplicate Path Nodes + fablabchemnitz.de.purge_duplicate_path_nodes + + + + false + 0.01 + false + 0.01 + false + 0.01 + true + + + + + + + + + + + + + + path + + + + + + + + \ No newline at end of file diff --git a/extensions/fablabchemnitz/purge_duplicate_path_nodes/purge_duplicate_path_nodes.py b/extensions/fablabchemnitz/purge_duplicate_path_nodes/purge_duplicate_path_nodes.py new file mode 100644 index 0000000..20d8364 --- /dev/null +++ b/extensions/fablabchemnitz/purge_duplicate_path_nodes/purge_duplicate_path_nodes.py @@ -0,0 +1,524 @@ +#!/usr/bin/env python3 +# coding=utf-8 +# +# Copyright (C) 2020 Ellen Wasboe, ellen@wasbo.net +# +# 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. +""" +Remove duplicate nodes or interpolate nodes with distance less than specified. +Optionally: + join start and end node of each subpath if distance < threshold + join separate subpaths if end nodes closer than threshold +Joining subpaths can be done either by interpolating or straight line segment. +""" +import inkex +from inkex import bezier, CubicSuperPath +import numpy as np +from tkinter import messagebox + + +def join_search(xdiff, ydiff, limDist, idsIncluded): + """Search for loose ends to join if within limDist.""" + joinFlag = False + idJoin = -1 + dist = np.sqrt(np.add(np.power(xdiff, 2), np.power(ydiff, 2))) + minDist = np.amin(dist) + if minDist < limDist: + joinFlag = True + idMins = np.where(dist == minDist) + idMin = idMins[0] + idJoin = idsIncluded[idMin[0]] + + return [joinFlag, idJoin] + + +def reverse_sub(subPath): + """Reverse sub path.""" + subPath = subPath[::-1] + for i, s in enumerate(subPath): + subPath[i] = s[::-1] + + return subPath + + +def join_sub(sub1, sub2, interpolate_or_line): + """Join line segments by interpolation or straight line segment.""" + if interpolate_or_line == "1": + # interpolate end nodes + p1 = sub1[-1][-1] + p2 = sub2[0][0] + joinNode = [0.5 * (p1[0] + p2[0]), 0.5 * (p1[1] + p2[1])] + # remove end/start + input join + sub1[-1][1] = joinNode + sub1[-1][2] = sub2[0][2] + sub2.pop(0) + + newsub = sub1 + sub2 + + return newsub + + +def remove_duplicate_nodes( + elem, minlength, maxdist, maxdist2, allowReverse, optionJoin +): + + pp = elem.path.to_absolute() + + # register which subpaths are closed - to reset closing after + # info are lost in to_superpath + dList = str(pp).upper().split(" M") + closed = [] + li = 0 + for sub in dList: + if dList[li].find("Z") > -1: + closed.append(" Z ") + else: + closed.append("") + li += 1 + + new = [] + nSub = len(closed) + + xStart = np.zeros(nSub) # x start - prepare for joining subpaths + yStart = np.copy(xStart) + xEnd = np.copy(xStart) + yEnd = np.copy(xStart) + + s = 0 + for sub in pp.to_superpath(): + new.append([sub[0]]) + if maxdist2 > -1: + xStart[s] = sub[0][0][0] + yStart[s] = sub[0][0][1] + xEnd[s] = sub[-1][-1][0] + yEnd[s] = sub[-1][-1][1] + # remove segment if segment length is less than minimum set, + # keep position + i = 1 + lastCombined = False + while i <= len(sub) - 1: + length = bezier.cspseglength(new[-1][-1], sub[i]) # curve length + if length >= minlength: + new[-1].append(sub[i]) # add as is + lastCombined = False + else: + # keep including segments until total length > minlength + summedlength = length + proceed = True + e = 0 # extra segments + finishedAdding = False + while proceed and i + e + 1 <= len(sub) - 1: + nextlength = bezier.cspseglength(sub[i + e], sub[i + e + 1]) + if nextlength >= minlength: # not include the next segment + proceed = False + if lastCombined == False and i > 1: + # i.e.small group between long segments, + # average over the group, first node already added + + # change position to average + new[-1][-1][1][0] = 0.5 * ( + new[-1][-1][1][0] + sub[i + e][1][0] + ) + new[-1][-1][1][1] = 0.5 * ( + new[-1][-1][1][1] + sub[i + e][1][1] + ) + + # change last cp to that of the last node in group + new[-1][-1][2] = sub[i + e][2] + finishedAdding = True + else: + new[-1].append(sub[i]) # add as is + if e > 0: + # end of group with many segments - average over + # all but last node (which is added separately) + + # change position to average first/last + new[-1][-1][1][0] = 0.5 * ( + new[-1][-1][1][0] + sub[i + e - 1][1][0] + ) + new[-1][-1][1][1] = 0.5 * ( + new[-1][-1][1][1] + sub[i + e - 1][1][1] + ) + + # change last cp to that of the last node in group + new[-1][-1][2] = sub[i + e - 1][2] + new[-1].append(sub[i + e]) # add as is + finishedAdding = True + lastCombined = True + else: + summedlength = summedlength + nextlength + if summedlength >= minlength: + proceed = False + e = e + 1 + + if finishedAdding == False: + + if i == 1: + # if first segment keep position of first node, + # direction of last in group + new[-1][-1][2][0] = sub[i + e][2][0] + new[-1][-1][2][1] = sub[i + e][2][1] + elif i + e == len(sub) - 1: + # if last segment included keep position of last node, + # direction of previous + new[-1].append(sub[i]) # add first node in group + if e > 0: + new[-1].append(sub[i + e]) # add last node + # get first cp from i+1 + new[-1][-1][0] = sub[i + 1][0] + + else: + # average position over first/last in group and keep direction (controlpoint) of first/last node + # group within sequence of many close nodes - add new without averaging on previous + new[-1].append(sub[i]) # add first node in group + + # change position to average + new[-1][-1][1][0] = 0.5 * (new[-1][-1][1][0] + sub[i + e][1][0]) + new[-1][-1][1][1] = 0.5 * (new[-1][-1][1][1] + sub[i + e][1][1]) + + # change last cp to that of the last node in group + new[-1][-1][2] = sub[i + e][2] + + i = i + e + + i += 1 + + if closed[s] == " Z ": + # if new[-1][-1][1]==new[-1][-2][1]:#not always precise + new[-1].pop(-1) + # for some reason tosuperpath adds an extra node for closed paths + + # close each subpath where start/end node is closer than maxdist set + # (if not already closed) + if maxdist > -1: + if closed[s] == "": # ignore already closed paths + # calculate distance between first and last node, + # if <= maxdist set closed[i] to " Z " + # last=new[-1][-1] + length = bezier.cspseglength(new[-1][-1], sub[0]) + if length < maxdist: + newStartEnd = [ + 0.5 * (new[-1][-1][-1][0] + new[-1][0][0][0]), + 0.5 * (new[-1][-1][-1][1] + new[-1][0][0][1]), + ] + new[-1][0][0] = newStartEnd + new[-1][0][1] = newStartEnd + new[-1][-1][1] = newStartEnd + new[-1][-1][2] = newStartEnd + closed[s] = " Z " + + s += 1 + + # join different subpaths? + closed = np.array(closed) + openPaths = np.where(closed == "") + closedPaths = np.where(closed == " Z ") + if maxdist2 > -1 and openPaths[0].size > 1: + # calculate distance between end nodes of the subpaths. + # If distance < maxdist2 found - join + joinStartToEnd = np.ones(nSub, dtype=bool) + joinEndToStart = np.copy(joinStartToEnd) + joinEndTo = np.full(nSub, -1) + # set higher than maxdist2 to avoid join to closedPaths + joinEndTo[closedPaths] = 2 * maxdist2 + joinStartTo = np.copy(joinEndTo) + + # join end node of current subpath to startnode of any other + # or start node of current to end node of other (no reverse) + s = 0 + while s < nSub: + # end of current to start of other + if joinEndTo[s] == -1: + # find available start nodes + idsTest = np.where(joinStartTo == -1) + # avoid join to self + id2Test = np.delete(idsTest[0], np.where(idsTest[0] == s)) + if id2Test.size > 0: + # calculate distances in x/y direction + diff_x = np.subtract(xStart[id2Test], xEnd[s]) + diff_y = np.subtract(yStart[id2Test], yEnd[s]) + # find shortest distance if less than minimum + res = join_search(diff_x, diff_y, maxdist2, id2Test) + if res[0] == True: + # if match found flag end of this with id of other and flag start of match to end of this + joinEndTo[s] = res[1] + joinStartTo[res[1]] = s + + # start of current to end of other + if joinStartTo[s] == -1: + idsTest = np.where(joinEndTo == -1) + id2Test = np.delete(idsTest[0], np.where(idsTest[0] == s)) + if id2Test.size > 0: + diff_x = np.subtract(xEnd[id2Test], xStart[s]) + diff_y = np.subtract(yEnd[id2Test], yStart[s]) + res = join_search(diff_x, diff_y, maxdist2, id2Test) + if res[0] == True: + joinStartTo[s] = res[1] + joinEndTo[res[1]] = s + + if allowReverse == True: + # start to start - if match reverse (reverseSub[s]=True) + if joinStartTo[s] == -1: + idsTest = np.where(joinStartTo == -1) + id2Test = np.delete(idsTest[0], np.where(idsTest[0] == s)) + if id2Test.size > 0: + diff_x = np.subtract(xStart[id2Test], xStart[s]) + diff_y = np.subtract(yStart[id2Test], yStart[s]) + res = join_search(diff_x, diff_y, maxdist2, id2Test) + if res[0] == True: + jID = res[1] + joinStartTo[s] = jID + joinStartTo[jID] = s + joinStartToEnd[s] = False # false means reverse + joinStartToEnd[jID] = False + + # end to end + if joinEndTo[s] == -1: + idsTest = np.where(joinEndTo == -1) + id2Test = np.delete(idsTest[0], np.where(idsTest[0] == s)) + if id2Test.size > 0: + diff_x = np.subtract(xEnd[id2Test], xEnd[s]) + diff_y = np.subtract(yEnd[id2Test], yEnd[s]) + res = join_search(diff_x, diff_y, maxdist2, id2Test) + if res[0] == True: + jID = res[1] + joinEndTo[s] = jID + joinEndTo[jID] = s + joinEndToStart[s] = False + joinEndToStart[jID] = False + + s += 1 + + old = new + new = [] + s = 0 + movedTo = np.arange(nSub) + newClosed = [] + # avoid joining to other paths if already closed + joinEndTo[closedPaths] = -1 + joinStartTo[closedPaths] = -1 + + for s in range(0, nSub): + if movedTo[s] == s: # not joined yet + if joinEndTo[s] > -1 or joinStartTo[s] > -1: + # any join scheduled + thisSub = [] + closedThis = "" + if joinEndTo[s] > -1: + # join one by one until -1 or back to s (closed) + jID = joinEndTo[s] + sub1 = old[s] + sub2 = old[jID] + rev = True if joinEndToStart[s] == False else False + sub2 = reverse_sub(sub2) if rev == True else sub2 + thisSub = join_sub(sub1, sub2, optionJoin) + movedTo[jID] = s + prev = s + # continue if sub2 joined to more + if joinEndTo[jID] > -1 and joinStartTo[jID] > -1: + # already joined so both joined if continue + proceed = 1 + + while proceed == 1: + nID = ( + joinEndTo[jID] + if joinEndTo[jID] != prev + else joinStartTo[jID] + ) + if movedTo[nID] == s: + closedThis = " Z " + proceed = 0 + else: + sub2 = old[nID] + if ( + nID == joinEndTo[jID] + and joinStartTo[nID] == jID + ) or ( + nID == joinStartTo[jID] + and joinEndTo[nID] == jID + ): + pass + else: + rev = not rev + sub2 = reverse_sub(sub2) if rev == True else sub2 + thisSub = join_sub(thisSub, sub2, optionJoin) + movedTo[nID] = s + if joinEndTo[nID] > -1 and joinStartTo[nID] > -1: + prev = jID + jID = nID + else: + proceed = 0 + + if joinStartTo[s] > -1 and closedThis == "": + jID = joinStartTo[s] + sub1 = old[jID] + rev = True if joinStartToEnd[s] == False else False + sub1 = reverse_sub(sub1) if rev == True else sub1 + sub2 = thisSub if len(thisSub) > 0 else old[s] + thisSub = join_sub(sub1, sub2, optionJoin) + movedTo[jID] = s + prev = s + # continue if sub1 joined to more + if joinEndTo[jID] > -1 and joinStartTo[jID] > -1: + proceed = 1 + + while proceed == 1: + nID = ( + joinStartTo[jID] + if joinStartTo[jID] != prev + else joinEndTo[jID] + ) + if movedTo[nID] == s: + closedThis = " Z " + proceed = 0 + else: + sub1 = old[nID] + if ( + nID == joinEndTo[jID] + and joinStartTo[nID] == jID + ) or ( + nID == joinStartTo[jID] + and joinEndTo[nID] == jID + ): + pass + else: + rev = not rev + sub1 = reverse_sub(sub1) if rev == True else sub1 + thisSub = join_sub(sub1, thisSub, optionJoin) + movedTo[nID] = s + if joinEndTo[nID] > -1 and joinStartTo[nID] > -1: + prev = jID + jID = nID + else: + proceed = 0 + + # close the new subpath if start/end node is closer than maxdist + # (should be handled above, but is not so this was a quick fix) + if closedThis == " Z " and optionJoin == "1": + newStartEnd = [ + 0.5 * (thisSub[-1][-1][0] + thisSub[0][0][0]), + 0.5 * (thisSub[-1][-1][1] + thisSub[0][0][1]), + ] + thisSub[0][0] = newStartEnd + thisSub[0][1] = newStartEnd + thisSub[-1][1] = newStartEnd + thisSub[-1][2] = newStartEnd + + new.append(thisSub) + newClosed.append(closedThis) + + else: + new.append(old[s]) + newClosed.append(closed[s]) + + closed = newClosed + + nEmpty = new.count([]) + if nEmpty > 0: + for i in range(nEmpty): + idx_empty = new.index([]) + new.pop(idx_empty) + closed = np.delete(closed, idx_empty) + + return (new, closed) + + +class RemoveDuplicateNodes(inkex.EffectExtension): + def add_arguments(self, pars): + pars.add_argument("--tab", default="options") + pars.add_argument("--minlength", default="0") + pars.add_argument("--minUse", type=inkex.Boolean, default=False) + pars.add_argument("--maxdist", default="0") + pars.add_argument("--joinEnd", type=inkex.Boolean, default=False) + pars.add_argument("--maxdist2", default="0") + pars.add_argument("--joinEndSub", type=inkex.Boolean, default=False) + pars.add_argument("--allowReverse", type=inkex.Boolean, default=True) + pars.add_argument("--optionJoin", default="1") + + """Remove duplicate nodes""" + + def effect(self): + if not self.svg.selected: + raise inkex.AbortExtension("Please select an object.") + + minlength = float(self.options.minlength) + maxdist = float(self.options.maxdist) + maxdist2 = float(self.options.maxdist2) + if self.options.minUse is False: + minlength = 0 + if self.options.joinEnd is False: + maxdist = -1 + if self.options.joinEndSub is False: + maxdist2 = -1 + + nFailed = 0 + nInkEffect = 0 + + for id, elem in self.svg.selection.id_dict().items(): + + thisIsPath = True + if elem.get("d") is None: + thisIsPath = False + nFailed += 1 + if elem.get("inkscape:path-effect") is not None: + thisIsPath = False + nInkEffect += 1 + + if thisIsPath: + + new, closed = remove_duplicate_nodes( + elem, + minlength, + maxdist, + maxdist2, + self.options.allowReverse, + self.options.optionJoin, + ) + + elem.path = CubicSuperPath(new).to_path(curves_only=True) + + # reset z to the originally closed paths + # (z lost in cubicsuperpath) + temppath = str(elem.path.to_absolute()).split("M ") + temppath.pop(0) + newPath = "" + li = 0 + for sub in temppath: + newPath = newPath + "M " + temppath[li] + closed[li] + li += 1 + elem.path = newPath + + if nFailed > 0: + messagebox.showwarning( + "Warning", + f"""{nFailed} selected elements have no path specified. + Groups have to be ungrouped first and paths have to be + combined with Ctrl + K to be considered for joining. + Shape-elements and text will be ignored.""", + ) + + if nInkEffect > 0: + messagebox.showwarning( + "Warning", + f"""{nInkEffect} selected elements have an + inkscape:path-effect applied. These elements will be + ignored to avoid confusing results. Apply Paths->Object + to path (Shift+Ctrl+C) and retry .""", + ) + + +if __name__ == "__main__": + RemoveDuplicateNodes().run() diff --git a/extensions/fablabchemnitz/purge_duplicate_path_segments/meta.json b/extensions/fablabchemnitz/purge_duplicate_path_segments/meta.json new file mode 100644 index 0000000..bdc6e53 --- /dev/null +++ b/extensions/fablabchemnitz/purge_duplicate_path_segments/meta.json @@ -0,0 +1,20 @@ +[ + { + "name": "Purge Duplicate Path Segments", + "id": "fablabchemnitz.de.purge_duplicate_path_segments", + "path": "purge_duplicate_path_segments", + "dependent_extensions": null, + "original_name": "Remove redundant edges", + "original_id": "org.novalis.filter.removeredundant", + "license": "GNU GPL v2", + "license_url": "https://bugs.launchpad.net/inkscape/+bug/521988/+attachment/1150930/+files/removeredundant.py", + "comment": "ported to Inkscape v1 by Mario Voigt", + "source_url": "https://gitea.fablabchemnitz.de/FabLab_Chemnitz/mightyscape-1.2/src/branch/master/extensions/fablabchemnitz/purge_duplicate_path_segments", + "fork_url": "https://bugs.launchpad.net/inkscape/+bug/521988", + "documentation_url": "https://stadtfabrikanten.org/display/IFM/Purge+Duplicate+Path+Segments", + "inkscape_gallery_url": null, + "main_authors": [ + "github.com/eridur-de" + ] + } +] \ No newline at end of file diff --git a/extensions/fablabchemnitz/purge_duplicate_path_segments/purge_duplicate_path_segments.inx b/extensions/fablabchemnitz/purge_duplicate_path_segments/purge_duplicate_path_segments.inx new file mode 100644 index 0000000..4f6d167 --- /dev/null +++ b/extensions/fablabchemnitz/purge_duplicate_path_segments/purge_duplicate_path_segments.inx @@ -0,0 +1,16 @@ + + + Purge Duplicate Path Segments + fablabchemnitz.de.purge_duplicate_path_segments + + path + + + + + + + + \ No newline at end of file diff --git a/extensions/fablabchemnitz/purge_duplicate_path_segments/purge_duplicate_path_segments.py b/extensions/fablabchemnitz/purge_duplicate_path_segments/purge_duplicate_path_segments.py new file mode 100644 index 0000000..76efcfc --- /dev/null +++ b/extensions/fablabchemnitz/purge_duplicate_path_segments/purge_duplicate_path_segments.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +''' +Copyright (C) 2010 David Turner + +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 St Fifth Floor, Boston, MA 02139 +''' +import inkex +from inkex import paths +from collections import defaultdict + +class FixedRadiusSearch(): + def __init__(self, r=0.1): + self.r = r + self.seen = defaultdict(list) + + def round(self, f): + return int(round(f/self.r)) + + def bin(self, p): + return (self.round(p[0]), self.round(p[1])) + + def test(self, p, q): + return abs(self.round(p[0] - q[0])) <= 1 and abs(self.round(p[1] - q[1])) <= 1 + + def search(self, p): + b = self.bin(p) + for i in range(b[0]-1, b[0]+2): + for j in range(b[1]-1, b[1]+2): + for q in self.seen[(i, j)]: + if self.test(p, q): + return q + return None + + def add(self, p): + self.seen[self.bin(p)].append(p) + + def get_or_add(self, p): + result = self.search(p) + if result == None: + self.add(p) + return p + return result + +class PurgeDuplicatePathSegments(inkex.EffectExtension): + + def effect(self): + seenSegments = set() + coordsCache = FixedRadiusSearch() + + for element in self.svg.selected.values(): + if element.tag == inkex.addNS('path','svg'): + d = element.get('d') + path = paths.CubicSuperPath(d).to_path().to_arrays() + newPath = [] + start = prev = None + pathclosed = True + + for i in range(0, len(path)): + command = path[i][0] + coords = path[i][1] + + newCoords = [] + for x, y in zip(*[iter(coords)]*2): + newCoords.extend(list(coordsCache.get_or_add((x, y)))) + coords = newCoords + tcoords = tuple(coords) + + if command == 'M': + #remove this M command and it's point, if the next dataset conaints an M command too. + # Like "M 49.8584,109.276 M ..." which creates just a single point but not a valid path + if i+1 != len(path) and path[i][0] == path[i+1][0]: + continue + newPath.append([command, coords]) + start = prev = tcoords + pathclosed = True + elif command == 'L': + if ('L', prev, tcoords) in seenSegments or \ + ('L', tcoords, prev) in seenSegments: + newPath.append(['M', coords]) + pathclosed = False + else: + newPath.append([command, coords]) + seenSegments.add(('L', prev, tcoords)) + prev = tcoords + elif command == 'Z': + if ('L', prev, start) in seenSegments or \ + ('L', start, prev) in seenSegments: + newPath.append(['M', start]) + else: + if pathclosed: + newPath.append([command, coords]) + else: + newPath.append(['L', start]) + seenSegments.add(('L', prev, start)) + prev = start + elif command == 'C': + if ('C', prev, tcoords) in seenSegments or \ + ('C', tcoords[4:], (tcoords[2:4], tcoords[0:2], prev)) in seenSegments: + newPath.append(['M', coords[4:]]) + else: + newPath.append(['C', coords]) + seenSegments.add(('C', prev, tcoords)) + prev = tcoords[4:] + else: + newPath.append([command, coords]) + while len(newPath) and newPath[-1][0] == 'M': + newPath = newPath[:-1] + element.set('d',str(paths.Path(newPath))) + +if __name__ == '__main__': + PurgeDuplicatePathSegments().run() \ No newline at end of file diff --git a/extensions/fablabchemnitz/quick_joint/meta.json b/extensions/fablabchemnitz/quick_joint/meta.json new file mode 100644 index 0000000..8f78477 --- /dev/null +++ b/extensions/fablabchemnitz/quick_joint/meta.json @@ -0,0 +1,21 @@ +[ + { + "name": "Quick Joint", + "id": "fablabchemnitz.de.quick_joint", + "path": "quick_joint", + "dependent_extensions": null, + "original_name": "QuickJoint", + "original_id": "org.inkscape.filter.quickjoint", + "license": "GNU GPL v3", + "license_url": "https://github.com/JarrettR/QuickJoint/blob/master/LICENSE", + "comment": "", + "source_url": "https://gitea.fablabchemnitz.de/FabLab_Chemnitz/mightyscape-1.2/src/branch/master/extensions/fablabchemnitz/quick_joint", + "fork_url": "https://github.com/JarrettR/QuickJoint", + "documentation_url": "https://stadtfabrikanten.org/display/IFM/Quick+Joint", + "inkscape_gallery_url": null, + "main_authors": [ + "github.com/JarrettR", + "github.com/eridur-de" + ] + } +] \ No newline at end of file diff --git a/extensions/fablabchemnitz/quick_joint/quick_joint.inx b/extensions/fablabchemnitz/quick_joint/quick_joint.inx new file mode 100644 index 0000000..4ddde8a --- /dev/null +++ b/extensions/fablabchemnitz/quick_joint/quick_joint.inx @@ -0,0 +1,38 @@ + + + Quick Joint + fablabchemnitz.de.quick_joint + + + + + 0 + 1 + + + 1 + + + 3.0 + 0.14 + + + + + + + + false + false + + path + + + + + + + + \ No newline at end of file diff --git a/extensions/fablabchemnitz/quick_joint/quick_joint.py b/extensions/fablabchemnitz/quick_joint/quick_joint.py new file mode 100644 index 0000000..9c02aa6 --- /dev/null +++ b/extensions/fablabchemnitz/quick_joint/quick_joint.py @@ -0,0 +1,310 @@ + +#!/usr/bin/env python +''' +Copyright (C) 2017 Jarrett Rainier jrainier@gmail.com + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +''' +import inkex, cmath +from inkex.paths import Path, ZoneClose, Move +from lxml import etree + +debugEn = False +def debugMsg(input): + if debugEn: + inkex.utils.debug(input) + +def linesNumber(path): + retval = -1 + for elem in path: + debugMsg('linesNumber') + debugMsg(elem) + retval = retval + 1 + 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 + 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) + + +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') + pars.add_argument('-l', '--numslots', type=int, default=1, help='Number of slots to add') + 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') + + 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 + return (cmath.rect(polR, polPhi) + start) + + def draw_perpendicular(self, start, guideLine, stepDistance, invert = False): + polR, polPhi = cmath.polar(guideLine) + polR = stepDistance + debugMsg(polPhi) + if invert: + polPhi += (cmath.pi / 2) + else: + polPhi -= (cmath.pi / 2) + 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) + + #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]]) + + #Horizontal + polR = xDistance + move = cmath.rect(polR + kerf, polPhi) + start + lines.append(['L', [move.real, move.imag]]) + start = move + + #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 + + #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 + + lines.append(['Z', []]) + + return lines + + 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)) + + 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 + 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)) + + + 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)) + 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 closePath: + newLines.append(['Z', []]) + return newLines + + + def draw_slots(self, path): + #Female slot creation + + start = to_complex(path[0]) + end = to_complex(path[1]) + + if self.edgefeatures: + segCount = (self.numslots * 2) - 1 + else: + segCount = (self.numslots * 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')) })) + + 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) + + 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 + + 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.kerf = self.svg.unittouu(str(self.options.kerf) + self.options.units) + self.units = self.options.units + self.edgefeatures = self.options.edgefeatures + self.flipside = self.options.flipside + self.activetab = self.options.activetab + + for id, node in self.svg.selected.items(): + debugMsg(node) + debugMsg('1') + if node.tag == inkex.addNS('path','svg'): + p = list(node.path.to_superpath().to_segments()) + debugMsg('2') + debugMsg(p) + + lines = linesNumber(p) + lineNum = self.side % lines + debugMsg(lineNum) + + newPath = [] + if self.activetab == 'tabpage': + newPath = self.draw_tabs(p, lineNum) + debugMsg('2') + debugMsg(p[:lineNum]) + debugMsg('3') + debugMsg(newPath) + debugMsg('4') + debugMsg( p[lineNum + 1:]) + finalPath = p[:lineNum] + newPath + p[lineNum + 1:] + + 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/raytracing/desc_parser.py b/extensions/fablabchemnitz/raytracing/desc_parser.py new file mode 100644 index 0000000..2d3e6da --- /dev/null +++ b/extensions/fablabchemnitz/raytracing/desc_parser.py @@ -0,0 +1,20 @@ +import re + +rgx_float = r"[-+]?(\d+([.,]\d*)?|[.,]\d+)([eE][-+]?\d+)?" +rgx_name = "[a-z,_]*" +optics_pattern = re.compile( + f"optics *: *(?P{rgx_name})(: *(?P{rgx_float}))?", + re.IGNORECASE | re.MULTILINE, +) + + +def get_optics_fields(string_: str): + fields = re.finditer(optics_pattern, string_) + return fields + + +def clear_description(desc: str) -> str: + """Removes text corresponding to an optical property""" + + new_desc = re.sub(optics_pattern, "", desc) + return new_desc diff --git a/extensions/fablabchemnitz/raytracing/lens.inx b/extensions/fablabchemnitz/raytracing/lens.inx new file mode 100644 index 0000000..c7a1af5 --- /dev/null +++ b/extensions/fablabchemnitz/raytracing/lens.inx @@ -0,0 +1,39 @@ + + + Insert Lens Optics + fablabchemnitz.de.raytracing_insert_lens_optics + 100. + + + + + + 1 + + + + + + 2 + + + + + + 1.5168 + + + + + + all + + + + + + + + \ No newline at end of file diff --git a/extensions/fablabchemnitz/raytracing/lens.py b/extensions/fablabchemnitz/raytracing/lens.py new file mode 100644 index 0000000..2b81d65 --- /dev/null +++ b/extensions/fablabchemnitz/raytracing/lens.py @@ -0,0 +1,231 @@ +""" +Module to add a lens object in the document +""" + +from math import cos, pi, sin, sqrt, acos, tan + +import inkex + + +class Lens(inkex.GenerateExtension): + """ + Produces a PathElement corresponding to the shape of the lens calculated + from user parameters. + """ + + @property + def style(self): + return { + "stroke": "#000000", + "fill": "#b7c2dd", + "stroke-linejoin": "round", + "stroke-width": str(self.svg.unittouu("1px")), + } + + @staticmethod + def add_arguments(pars): + pars.add_argument("--focal_length", type=float, default=100.0) + pars.add_argument("--focal_length_unit", type=str, default="mm") + + pars.add_argument("--diameter", type=float, default=1.0) + pars.add_argument("--diameter_unit", default="in") + + pars.add_argument("--edge_thickness", type=float, default=2.0) + pars.add_argument("--edge_thickness_unit", default="mm") + + pars.add_argument("--optical_index", type=float, default=1.5168) + + pars.add_argument("--lens_type", default="plano_con") + + def to_document_units(self, value: float, unit: str) -> float: + c1x, c1y, c2x, c2y = self.svg.get_viewbox() + document_width = self.svg.unittouu(self.document.getroot().get("width")) + scale_factor = (c2x - c1x) / document_width + return self.svg.unittouu(str(value) + unit) * scale_factor + + def generate(self): + opts = self.options + d = self.svg.viewport_to_unit(f"{opts.diameter}{opts.diameter_unit}") + f = self.svg.viewport_to_unit(f"{opts.focal_length}{opts.focal_length_unit}") + e = self.svg.viewport_to_unit( + f"{opts.edge_thickness}{opts.edge_thickness_unit}") + optical_index = opts.optical_index + + lens_path = [] + if opts.lens_type == "plano_con": + # Radius of curvature from Lensmaker's equation + roc = (optical_index - 1) * abs(f) + if 2 * roc < d: + inkex.utils.errormsg( + "Focal length is too short or diameter is too large." + ) + return None + elif (roc ** 2 - (d / 2) ** 2) ** 0.5 - roc < -e and f < 0: + inkex.utils.errormsg("Edge thickness is too small.") + return None + else: + sweep = 1 if f < 0 else 0 + lens_path = arc_to_path( + [-d / 2, 0], [roc, roc, 0.0, 0, sweep, +d / 2, 0] + ) + lens_path += [ + [[+d / 2, 0], [+d / 2, 0], [+d / 2, -e]], + [[+d / 2, -e], [+d / 2, -e], [-d / 2, -e]], + [[+d / 2, -e], [-d / 2, -e], [-d / 2, +e]], + ] + # no need to close the path correctly as it's done after + elif opts.lens_type == "bi_con": + roc = ( + (optical_index - 1) * abs(f) * (1 + (1 - e / f / optical_index) ** 0.5) + ) + if 2 * roc < d: + inkex.utils.errormsg( + "Focal length is too short or diameter is too large." + ) + return None + elif (roc ** 2 - (d / 2) ** 2) ** 0.5 - roc < -e / 2 and f < 0: + inkex.utils.errormsg("Edge thickness is too small.") + return None + else: + sweep = 1 if f < 0 else 0 + lens_path = arc_to_path( + [-d / 2, 0], [roc, roc, 0.0, 0, sweep, +d / 2, 0] + ) + lens_path += [ + [[+d / 2, 0], [+d / 2, 0], [+d / 2, -e]], + [[+d / 2, -e], [+d / 2, -e], [+d / 2, -e]], + ] + lens_path += arc_to_path( + [+d / 2, -e], [roc, roc, 0.0, 0, sweep, -d / 2, -e] + ) + lens_path += [ + [[-d / 2, -e], [-d / 2, -e], [-d / 2, 0]], + [[-d / 2, -e], [-d / 2, 0], [-d / 2, 0]], + ] + + lens = inkex.PathElement() + lens.style = self.style + closed_path = inkex.Path(inkex.CubicSuperPath([lens_path])) + closed_path.close() + lens.path = closed_path.transform(inkex.Transform("rotate(90)")) + lens.desc = ( + f"L{opts.focal_length}{opts.focal_length_unit}\n" + f"optics:glass:{optical_index:.4f}" + ) + yield lens + + +def arc_to_path(point, params): + """Approximates an arc with cubic bezier segments. + + Arguments: + point: Starting point (absolute coords) + params: Arcs parameters as per + https://www.w3.org/TR/SVG/paths.html#PathDataEllipticalArcCommands + + Returns a list of triplets of points : [control_point_before, node, control_point_after] + (first and last returned triplets are [p1, p1, *] and [*, p2, p2]) + """ + A = point[:] + rx, ry, theta, long_flag, sweep_flag, x2, y2 = params[:] + theta = theta * pi / 180.0 + B = [x2, y2] + # Degenerate ellipse + if rx == 0 or ry == 0 or A == B: + return [[A[:], A[:], A[:]], [B[:], B[:], B[:]]] + + # turn coordinates so that the ellipse morph into a *unit circle* (not 0-centered) + mat = mat_prod( + (rot_mat(theta), [[1.0 / rx, 0.0], [0.0, 1.0 / ry]], rot_mat(-theta)) + ) + apply_mat(mat, A) + apply_mat(mat, B) + + k = [-(B[1] - A[1]), B[0] - A[0]] + d = k[0] * k[0] + k[1] * k[1] + k[0] /= sqrt(d) + k[1] /= sqrt(d) + d = sqrt(max(0, 1 - d / 4.0)) + # k is the unit normal to AB vector, pointing to center O + # d is distance from center to AB segment (distance from O to the midpoint of AB) + # for the last line, remember this is a unit circle, and kd vector is orthogonal to AB (Pythagorean thm) + + if ( + long_flag == sweep_flag + ): # top-right ellipse in SVG example https://www.w3.org/TR/SVG/images/paths/arcs02.svg + d *= -1 + + O = [(B[0] + A[0]) / 2.0 + d * k[0], (B[1] + A[1]) / 2.0 + d * k[1]] + OA = [A[0] - O[0], A[1] - O[1]] + OB = [B[0] - O[0], B[1] - O[1]] + start = acos(OA[0] / norm(OA)) + if OA[1] < 0: + start *= -1 + end = acos(OB[0] / norm(OB)) + if OB[1] < 0: + end *= -1 + # start and end are the angles from center of the circle to A and to B respectively + + if sweep_flag and start > end: + end += 2 * pi + if (not sweep_flag) and start < end: + end -= 2 * pi + + nb_sectors = int(abs(start - end) * 2 / pi) + 1 + d_theta = (end - start) / nb_sectors + v = 4 * tan(d_theta / 4.0) / 3.0 + # I would use v = tan(d_theta/2)*4*(sqrt(2)-1)/3 ? + p = [] + for i in range(0, nb_sectors + 1, 1): + angle = start + i * d_theta + v1 = [ + O[0] + cos(angle) - (-v) * sin(angle), + O[1] + sin(angle) + (-v) * cos(angle), + ] + pt = [O[0] + cos(angle), O[1] + sin(angle)] + v2 = [O[0] + cos(angle) - v * sin(angle), O[1] + sin(angle) + v * cos(angle)] + p.append([v1, pt, v2]) + p[0][0] = p[0][1][:] + p[-1][2] = p[-1][1][:] + + # go back to the original coordinate system + mat = mat_prod((rot_mat(theta), [[rx, 0], [0, ry]], rot_mat(-theta))) + for pts in p: + apply_mat(mat, pts[0]) + apply_mat(mat, pts[1]) + apply_mat(mat, pts[2]) + return p + + +def mat_prod(m_list): + """Get the product of the mat""" + prod = m_list[0] + for mat in m_list[1:]: + a00 = prod[0][0] * mat[0][0] + prod[0][1] * mat[1][0] + a01 = prod[0][0] * mat[0][1] + prod[0][1] * mat[1][1] + a10 = prod[1][0] * mat[0][0] + prod[1][1] * mat[1][0] + a11 = prod[1][0] * mat[0][1] + prod[1][1] * mat[1][1] + prod = [[a00, a01], [a10, a11]] + return prod + + +def rot_mat(theta): + """Rotate the mat""" + return [[cos(theta), -sin(theta)], [sin(theta), cos(theta)]] + + +def apply_mat(mat, point): + """Apply the given mat""" + x = mat[0][0] * point[0] + mat[0][1] * point[1] + y = mat[1][0] * point[0] + mat[1][1] * point[1] + point[0] = x + point[1] = y + + +def norm(point): + """Normalise""" + return sqrt(point[0] * point[0] + point[1] * point[1]) + + +if __name__ == "__main__": + Lens().run() \ No newline at end of file diff --git a/extensions/fablabchemnitz/raytracing/meta.json b/extensions/fablabchemnitz/raytracing/meta.json new file mode 100644 index 0000000..bfbf5bb --- /dev/null +++ b/extensions/fablabchemnitz/raytracing/meta.json @@ -0,0 +1,21 @@ +[ + { + "name": "", + "id": "fablabchemnitz.de.raytracing.", + "path": "raytracing", + "dependent_extensions": null, + "original_name": "", + "original_id": "damienBloch/inkscape-raytracing/", + "license": "GNU GPL v3", + "license_url": "https://github.com/damienBloch/inkscape-raytracing/blob/master/LICENSE", + "comment": "", + "source_url": "https://gitea.fablabchemnitz.de/FabLab_Chemnitz/mightyscape-1.2/src/branch/master/extensions/fablabchemnitz/raytracing", + "fork_url": "https://github.com/damienBloch/inkscape-raytracing", + "documentation_url": "https://stadtfabrikanten.org/display/IFM/Ray+Tracing", + "inkscape_gallery_url": null, + "main_authors": [ + "github.com/damienBloch", + "github.com/eridur-de" + ] + } +] \ No newline at end of file diff --git a/extensions/fablabchemnitz/raytracing/raytracing/__init__.py b/extensions/fablabchemnitz/raytracing/raytracing/__init__.py new file mode 100644 index 0000000..a62c518 --- /dev/null +++ b/extensions/fablabchemnitz/raytracing/raytracing/__init__.py @@ -0,0 +1,4 @@ +from .optical_object import * +from .ray import * +from .vector import * +from .world import * diff --git a/extensions/fablabchemnitz/raytracing/raytracing/geometry/__init__.py b/extensions/fablabchemnitz/raytracing/raytracing/geometry/__init__.py new file mode 100644 index 0000000..a731a33 --- /dev/null +++ b/extensions/fablabchemnitz/raytracing/raytracing/geometry/__init__.py @@ -0,0 +1,2 @@ +from .cubic_bezier import CubicBezier +from .geometric_object import GeometricObject, CompoundGeometricObject, AABBox diff --git a/extensions/fablabchemnitz/raytracing/raytracing/geometry/cubic_bezier.py b/extensions/fablabchemnitz/raytracing/raytracing/geometry/cubic_bezier.py new file mode 100644 index 0000000..cbbe9f7 --- /dev/null +++ b/extensions/fablabchemnitz/raytracing/raytracing/geometry/cubic_bezier.py @@ -0,0 +1,210 @@ +""" +Module for handling objects composed of cubic bezier curves +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from functools import cached_property + +import numpy + +from .geometric_object import AABBox, GeometricObject, GeometryError +from ..ray import Ray +from ..shade import ShadeRec +from ..vector import Vector, UnitVector + + +@dataclass(frozen=True) +class CubicBezier: + r""" + Cubic bezier segment defined as + + .. math:: + \vec{X}(s) = (1-s)^3 \vec{p_0} + 3 s (1-s)^2 \vec{p_1} + + 3 s^2 (1-s) \vec{p_2} + s^3 \vec{p_3} + + for :math:`0 \le s \le 1` + """ + + p0: Vector + p1: Vector + p2: Vector + p3: Vector + + def eval(self, s) -> Vector: + return ( + (1 - s) ** 3 * self.p0 + + 3 * s * (1 - s) ** 2 * self.p1 + + 3 * s ** 2 * (1 - s) * self.p2 + + s ** 3 * self.p3 + ) + + @cached_property + def aabbox(self) -> AABBox: + # The box is slightly larger than the minimal box. + # It prevents the box to have a zero dimension if the object is a line + # aligned with vertical or horizontal. + lower_left = Vector( + min(self.p0.x, self.p1.x, self.p2.x, self.p3.x) - 1e-6, + min(self.p0.y, self.p1.y, self.p2.y, self.p3.y) - 1e-6, + ) + upper_right = Vector( + max(self.p0.x, self.p1.x, self.p2.x, self.p3.x) + 1e-6, + max(self.p0.y, self.p1.y, self.p2.y, self.p3.y) + 1e-6, + ) + return AABBox(lower_left, upper_right) + + def tangent(self, s: float) -> UnitVector: + """Returns the tangent at the curve at curvilinear coordinate s""" + + diff_1 = ( + -3 * (self.p0 - 3 * self.p1 + 3 * self.p2 - self.p3) * s ** 2 + + 6 * (self.p0 - 2 * self.p1 + self.p2) * s + - 3 * (self.p0 - self.p1) + ) + # If the first derivative is not zero, it is parallel to the tangent + if diff_1.norm() > 1e-8: + return diff_1.normalize() + # but is the first derivative is zero, we need to get the second order + else: + diff_2 = -6 * (self.p0 - 3 * self.p1 + 3 * self.p2 - self.p3) * s + 6 * ( + self.p0 - 2 * self.p1 + self.p2 + ) + if diff_2.norm() > 1e-8: + return diff_2.normalize() + else: # and even to the 3rd derivative if necessary + diff_3 = -6 * (self.p0 - 3 * self.p1 + 3 * self.p2 - self.p3) + return diff_3.normalize() + + def normal(self, s: float) -> UnitVector: + """Returns a vector normal at the curve at curvilinear coordinate s""" + + return self.tangent(s).orthogonal() + + def intersection_beam(self, ray: Ray) -> list[tuple[float, float]]: + r""" + Returns all couples :math:`(s, t)` such that there exist + :math:`\vec{X}` satisfying + + .. math:: + \vec{X} = (1-s)^3 \vec{p_0} + 3 s (1-s)^2 \vec{p_1} + + 3 s^2 (1-s) \vec{p_2} + s^3 \vec{p_3} + and + .. math:: + \vec{X} = \vec{o} + t \vec{d} + with :math:`0 \lq s \lq 1` and :math:`t >= 0` + """ + + a = ray.direction.orthogonal() + a0 = a * (self.p0 - ray.origin) + a1 = -3 * a * (self.p0 - self.p1) + a2 = 3 * a * (self.p0 - 2 * self.p1 + self.p2) + a3 = a * (-self.p0 + 3 * self.p1 - 3 * self.p2 + self.p3) + roots = cubic_real_roots(a0, a1, a2, a3) + intersection_points = [self.eval(s) for s in roots] + travel = [(X - ray.origin) * ray.direction for X in intersection_points] + + def valid_domain(s, t): + return 0 <= s <= 1 and t > Ray.min_travel + + return [(s, t) for (s, t) in zip(roots, travel) if valid_domain(s, t)] + + def num_hits(self, ray: Ray) -> int: + if self.aabbox.hit(ray): + return len(self.intersection_beam(ray)) + else: + return 0 + + def hit(self, ray: Ray) -> ShadeRec: + """ + Returns a shade with the information for the first intersection + of a beam with the bezier segment + """ + + shade = ShadeRec() # default no hit + if self.aabbox.hit(ray): + intersect_params = self.intersection_beam(ray) + travel_dist = [t for (__, t) in intersect_params] + if len(travel_dist) > 0: # otherwise error with np.argmin + shade.normal = True + first_hit = numpy.argmin(travel_dist) + shade.travel_dist = travel_dist[first_hit] + shade.local_hit_point = ray.origin + shade.travel_dist * ray.direction + shade.normal = self.normal(intersect_params[first_hit][0]) + shade.set_normal_same_side(ray.origin) + return shade + + def is_inside(self, ray: Ray) -> bool: + raise GeometryError(f"Can't define an inside for {self}.") + + +def cubic_real_roots(d: float, c: float, b: float, a: float) -> list[float]: + """ + Returns the real roots X of a cubic polynomial defined as + + .. math:: + a X^3 + b X^2 + c X + d = 0 + """ + + # For more information see: + # https://en.wikipedia.org/wiki/Cubic_equation#General_cubic_formula + + if not is_almost_zero(a): # true cubic equation + p = (3 * a * c - b ** 2) / 3 / a ** 2 + q = (2 * b ** 3 - 9 * a * b * c + 27 * a ** 2 * d) / 27 / a ** 3 + if is_almost_zero(p): + t = [numpy.cbrt(-q)] + else: + discr = -(4 * p ** 3 + 27 * q ** 2) + if is_almost_zero(discr): + if is_almost_zero(q): + t = [0] + else: + t = [3 * q / p, -3 * q / 2 / p] + elif discr < 0: + t = [ + numpy.cbrt(-q / 2 + numpy.sqrt(-discr / 108)) + + numpy.cbrt(-q / 2 - numpy.sqrt(-discr / 108)) + ] + else: + t = [ + 2 + * numpy.sqrt(-p / 3) + * numpy.cos( + 1 / 3 * numpy.arccos(3 * q / 2 / p * numpy.sqrt(-3 / p)) + - 2 * numpy.pi * k / 3 + ) + for k in range(3) + ] + return [x - b / 3 / a for x in t] + else: + return quadratic_roots(b, c, d) + + +def quadratic_roots(a: float, b: float, c: float) -> list[float]: + if not is_almost_zero(a): + discr = b ** 2 - 4 * a * c + if discr > 0: + return [ + (-b + numpy.sqrt(discr)) / 2 / a, + (-b - numpy.sqrt(discr)) / 2 / a, + ] + elif is_almost_zero(discr): + return [-b / 2 / a] + else: + return [] + else: + return linear_root(b, c) + + +def linear_root(a: float, b: float) -> list[float]: + if is_almost_zero(a): # No solutions for 0*X+b=0 + return [] # Ignore infinite solutions for a=b=0 + else: + return [-b / a] + + +def is_almost_zero(x: float) -> bool: + return math.isclose(x, 0, abs_tol=1e-8) diff --git a/extensions/fablabchemnitz/raytracing/raytracing/geometry/geometric_object.py b/extensions/fablabchemnitz/raytracing/raytracing/geometry/geometric_object.py new file mode 100644 index 0000000..86b6dc7 --- /dev/null +++ b/extensions/fablabchemnitz/raytracing/raytracing/geometry/geometric_object.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +import functools +from dataclasses import dataclass +from typing import Protocol, Iterable, TypeVar, Generic + +import numpy + +from ..ray import Ray +from ..shade import ShadeRec +from ..vector import Vector + +class GeometricObject(Protocol): + """Protocol for a geometric object (line, rectangle, circle, ...)""" + + def hit(self, ray: Ray) -> ShadeRec: + """Tests if a collision between a beam and the object occurred + + Returns a shade that contains the information about the collision in + case it happened. + """ + raise NotImplementedError + + def num_hits(self, ray: Ray) -> int: + """Returns the number of times a beam intersect the object boundary""" + raise NotImplementedError + + @property + def aabbox(self) -> AABBox: + """Computes an axis aligned bounding box for the object""" + raise NotImplementedError + + def is_inside(self, ray: Ray) -> bool: + """Indicates if a ray is inside or outside the object + + It is not possible to define an inside for every object, for example if it is + not closed. In this case it should raise a GeometryError. + """ + raise NotImplementedError + + +class GeometryError(RuntimeError): + pass + + +T = TypeVar("T", bound=GeometricObject) + + +@dataclass(frozen=True) +class CompoundGeometricObject(GeometricObject, Generic[T]): + sub_objects: tuple[T, ...] + + def __init__(self, sub_objects: Iterable[T]): + object.__setattr__(self, "sub_objects", tuple(sub_objects)) + + def __iter__(self) -> Iterable[T]: + return iter(self.sub_objects) + + def __getitem__(self, item) -> T: + return self.sub_objects[item] + + @functools.cached_property + def aabbox(self): + sub_boxes = (sub.aabbox for sub in self.sub_objects) + return AABBox.englobing(sub_boxes) + + def hit(self, ray: Ray) -> ShadeRec: + """ + Returns a shade with the information for the first intersection + of a beam with one of the object composing the composite object + """ + + result = ShadeRec() + if self.aabbox.hit(ray): + result = find_first_hit(ray, self.sub_objects) + result.hit_geometry = self + return result + + def is_inside(self, ray: Ray) -> bool: + # A ray is inside an object if it intersect its boundary an odd + # number of times + return (self.num_hits(ray) % 2) == 1 + + def num_hits(self, ray: Ray) -> int: + if self.aabbox.hit(ray): + return sum([obj.num_hits(ray) for obj in self.sub_objects]) + else: + return 0 + + +def find_first_hit(ray: Ray, objects: Iterable[GeometricObject]) -> ShadeRec: + result = ShadeRec() + for obj in objects: + shade = obj.hit(ray) + if Ray.min_travel < shade.travel_dist < result.travel_dist: + result = shade + return result + + +@dataclass(frozen=True) +class AABBox: + """ + Implements an axis-aligned bounding box + + This is used to accelerate the intersection between a beam and an object. + If the beam doesn't hit the bounding box, it is not necessary to do + expensive intersection calculations with the object. + """ + + lower_left: Vector + upper_right: Vector + + @classmethod + def englobing(cls, aabboxes: Iterable[AABBox]) -> AABBox: + return functools.reduce(cls.englobing_two, aabboxes) + + @classmethod + def englobing_two(cls, b1: AABBox, b2: AABBox) -> AABBox: + union_lower_left = Vector( + min(b1.lower_left.x, b2.lower_left.x), + min(b1.lower_left.y, b2.lower_left.y), + ) + union_upper_right = Vector( + max(b1.upper_right.x, b2.upper_right.x), + max(b1.upper_right.y, b2.upper_right.y), + ) + return AABBox(union_lower_left, union_upper_right) + + def hit(self, ray: Ray) -> bool: + """Tests if a beam intersects the bounding box""" + + # This algorithm uses the properties of IEEE floating-point + # arithmetic to correctly handle cases where the ray travels + # parallel to a coordinate axis. + # See Williams et al. "An efficient and robust ray-box intersection + # algorithm" for more details. + + p0 = numpy.array([self.lower_left.x, self.lower_left.y]) + p1 = numpy.array([self.upper_right.x, self.upper_right.y]) + direction = numpy.array([ray.direction.x, ray.direction.y]) + origin = numpy.array([ray.origin.x, ray.origin.y]) + # The implementation safely handles the case where an element + # of ray.direction is zero. Warning for floating point error + # can be ignored for this step. + with numpy.errstate(invalid="ignore", divide="ignore"): + a = 1 / direction + t_min = (numpy.where(a >= 0, p0, p1) - origin) * a + t_max = (numpy.where(a >= 0, p1, p0) - origin) * a + t0 = numpy.max(t_min) + t1 = numpy.min(t_max) + return (t0 < t1) and (t1 > Ray.min_travel) diff --git a/extensions/fablabchemnitz/raytracing/raytracing/material/__init__.py b/extensions/fablabchemnitz/raytracing/raytracing/material/__init__.py new file mode 100644 index 0000000..2c00ae0 --- /dev/null +++ b/extensions/fablabchemnitz/raytracing/raytracing/material/__init__.py @@ -0,0 +1,5 @@ +from .optic_material import * +from .beamdump import * +from .mirror import * +from .beamsplitter import * +from .glass import * diff --git a/extensions/fablabchemnitz/raytracing/raytracing/material/beamdump.py b/extensions/fablabchemnitz/raytracing/raytracing/material/beamdump.py new file mode 100644 index 0000000..9c0a0a1 --- /dev/null +++ b/extensions/fablabchemnitz/raytracing/raytracing/material/beamdump.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from .optic_material import OpticMaterial +from ..ray import Ray +from ..shade import ShadeRec + + +class BeamDump(OpticMaterial): + """Material absorbing all beams that hit it""" + + def __repr__(self): + return "BeamDump()" + + def generated_beams(self, ray: Ray, shade: ShadeRec) -> list[Ray]: + return list() diff --git a/extensions/fablabchemnitz/raytracing/raytracing/material/beamsplitter.py b/extensions/fablabchemnitz/raytracing/raytracing/material/beamsplitter.py new file mode 100644 index 0000000..0e48075 --- /dev/null +++ b/extensions/fablabchemnitz/raytracing/raytracing/material/beamsplitter.py @@ -0,0 +1,27 @@ +from typing import List + +import numpy as np + +from ..ray import Ray +from ..shade import ShadeRec +from .optic_material import OpticMaterial + + +class BeamSplitter(OpticMaterial): + """ + Material producing two beams after collision. One is reflected and + the other is transmitted. + """ + + def __init__(self): + super().__init__() + + def __repr__(self): + return "Mirror()" + + def generated_beams(self, ray: Ray, shade: ShadeRec) -> List[Ray]: + o, d = shade.local_hit_point, ray.direction + n = shade.normal + reflected_ray = Ray(o, d - 2 * np.dot(d, n) * n) + transmitted_ray = Ray(o, d) + return [reflected_ray, transmitted_ray] diff --git a/extensions/fablabchemnitz/raytracing/raytracing/material/glass.py b/extensions/fablabchemnitz/raytracing/raytracing/material/glass.py new file mode 100644 index 0000000..d871bad --- /dev/null +++ b/extensions/fablabchemnitz/raytracing/raytracing/material/glass.py @@ -0,0 +1,39 @@ +from typing import List + +import numpy as np + +from ..ray import Ray +from ..shade import ShadeRec +from .optic_material import OpticMaterial + + +class Glass(OpticMaterial): + """Material that transmits and bends beams hitting it""" + + def __init__(self, optical_index): + self._optical_index = optical_index + + @property + def optical_index(self): + return self._optical_index + + def __repr__(self): + return f"Glass({self._optical_index})" + + def generated_beams(self, ray: Ray, shade: ShadeRec) -> List[Ray]: + o, d = shade.local_hit_point, ray.direction + n = shade.normal + if shade.hit_geometry.is_inside(ray): + n_1, n_2 = self.optical_index, 1 + else: + n_1, n_2 = 1, self.optical_index + r = n_1 / n_2 + c1 = -np.dot(d, n) + u = 1 - r ** 2 * (1 - c1 ** 2) + if u < 0: # total internal reflection + reflected_ray = Ray(o, d - 2 * np.dot(d, n) * n) + return [reflected_ray] + else: # refraction + c2 = np.sqrt(u) + transmitted_ray = Ray(o, r * d + (r * c1 - c2) * n) + return [transmitted_ray] diff --git a/extensions/fablabchemnitz/raytracing/raytracing/material/mirror.py b/extensions/fablabchemnitz/raytracing/raytracing/material/mirror.py new file mode 100644 index 0000000..b433523 --- /dev/null +++ b/extensions/fablabchemnitz/raytracing/raytracing/material/mirror.py @@ -0,0 +1,20 @@ +from typing import List + +import numpy + +from ..ray import Ray +from ..shade import ShadeRec +from .optic_material import OpticMaterial + + +class Mirror(OpticMaterial): + """Material reflecting beams that hit it""" + + def __repr__(self): + return "Mirror()" + + def generated_beams(self, ray: Ray, shade: ShadeRec) -> List[Ray]: + o, d = shade.local_hit_point, ray.direction + n = shade.normal + reflected_ray = Ray(o, d - 2 * numpy.dot(d, n) * n) + return [reflected_ray] diff --git a/extensions/fablabchemnitz/raytracing/raytracing/material/optic_material.py b/extensions/fablabchemnitz/raytracing/raytracing/material/optic_material.py new file mode 100644 index 0000000..eb899fa --- /dev/null +++ b/extensions/fablabchemnitz/raytracing/raytracing/material/optic_material.py @@ -0,0 +1,19 @@ +from abc import abstractmethod +from typing import Protocol, List + +from ..ray import Ray +from ..shade import ShadeRec + + +class OpticMaterial(Protocol): + """Protocol for an optical material""" + + @abstractmethod + def generated_beams(self, ray: Ray, shade: ShadeRec) -> List[Ray]: + """Compute the beams generated after intersection of a beam with this + material + + Returns list of new beam seeds to start from after the intersection + of a beam and an object. + """ + raise NotImplementedError diff --git a/extensions/fablabchemnitz/raytracing/raytracing/optical_object.py b/extensions/fablabchemnitz/raytracing/raytracing/optical_object.py new file mode 100644 index 0000000..a678a5c --- /dev/null +++ b/extensions/fablabchemnitz/raytracing/raytracing/optical_object.py @@ -0,0 +1,10 @@ +from dataclasses import dataclass + +from .geometry import GeometricObject +from .material import OpticMaterial + + +@dataclass(frozen=True) +class OpticalObject: + geometry: GeometricObject + material: OpticMaterial diff --git a/extensions/fablabchemnitz/raytracing/raytracing/ray.py b/extensions/fablabchemnitz/raytracing/raytracing/ray.py new file mode 100644 index 0000000..b8e02af --- /dev/null +++ b/extensions/fablabchemnitz/raytracing/raytracing/ray.py @@ -0,0 +1,18 @@ +from dataclasses import dataclass +from typing import ClassVar + +from .vector import UnitVector, Vector + + +@dataclass(frozen=True) +class Ray: + """This class implements a 2D line with an origin point and a direction.""" + + origin: Vector + direction: UnitVector + travel: float = 0 + + # If a beam hits an object before having traveled a minimum distance + # from its origin, the collision is ignored. This prevents infinite + # collision in case the origin of a beam is on the surface of an object + min_travel: ClassVar[float] = 1e-7 diff --git a/extensions/fablabchemnitz/raytracing/raytracing/shade.py b/extensions/fablabchemnitz/raytracing/raytracing/shade.py new file mode 100644 index 0000000..958df74 --- /dev/null +++ b/extensions/fablabchemnitz/raytracing/raytracing/shade.py @@ -0,0 +1,35 @@ +import numpy as np +from typing import Optional + + +class ShadeRec(object): + """ + This object contains the information needed to process the collision + between a ray and an object. + """ + + def __init__(self): + self.hit_an_object: bool = False + self.local_hit_point: Optional[np.ndarray] = None + self.normal: Optional[np.ndarray] = None + self.travel_dist: float = np.inf + + from .geometry import GeometricObject + + self.hit_geometry: Optional[GeometricObject] = None + + def __repr__(self): + return ( + f"ShadeRec({self.hit_an_object}, {self.local_hit_point}, " + f"{self.normal}, {self.travel_dist})" + ) + + def set_normal_same_side(self, point: np.ndarray): + if self.normal is None: + raise RuntimeError("Can't find normal orientation if not already defined.") + elif self.local_hit_point is None: + raise RuntimeError( + "Can't find normal orientation if hit point not defined." + ) + elif np.dot(self.normal, self.local_hit_point - point) > 0: + self.normal = -self.normal diff --git a/extensions/fablabchemnitz/raytracing/raytracing/vector.py b/extensions/fablabchemnitz/raytracing/raytracing/vector.py new file mode 100644 index 0000000..9aabe82 --- /dev/null +++ b/extensions/fablabchemnitz/raytracing/raytracing/vector.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from functools import singledispatchmethod +from math import sqrt +from numbers import Real + + +@dataclass(frozen=True) +class Vector: + x: float = field() + y: float = field() + + def orthogonal(self) -> Vector: + """Return a vector obtained by a pi/2 rotation""" + return UnitVector(-self.y, self.x) + + @singledispatchmethod + def __mul__(self, other): + raise NotImplementedError + + @__mul__.register + def _(self, other: Real): + return Vector(self.x * other, self.y * other) + + @singledispatchmethod + def __rmul__(self, other): + raise NotImplementedError(type(other)) + + @__rmul__.register + def _(self, other: Real): + return Vector(self.x * other, self.y * other) + + @singledispatchmethod + def __add__(self, other) -> Vector: + raise NotImplementedError + + @singledispatchmethod + def __sub__(self, other) -> Vector: + raise NotImplementedError + + def __neg__(self) -> Vector: + return Vector(-self.x, -self.y) + + def norm(self): + return sqrt(self * self) + + def normalize(self) -> UnitVector: + return UnitVector(self.x, self.y) + + +@dataclass(frozen=True) +class UnitVector(Vector): + def __init__(self, x, y): + norm = sqrt(x ** 2 + y ** 2) + super().__init__(x / norm, y / norm) + + +@Vector.__add__.register +def _(self, other: Vector): + return Vector(self.x + other.x, self.y + other.y) + + +@Vector.__sub__.register +def _(self, other: Vector): + return Vector(self.x - other.x, self.y - other.y) + + +@Vector.__mul__.register +def _(self, other: Vector) -> float: + return self.x * other.x + self.y * other.y + +@Vector.__rmul__.register +def _(self, other: Vector) -> float: + return self.x * other.x + self.y * other.y diff --git a/extensions/fablabchemnitz/raytracing/raytracing/world.py b/extensions/fablabchemnitz/raytracing/raytracing/world.py new file mode 100644 index 0000000..a168f45 --- /dev/null +++ b/extensions/fablabchemnitz/raytracing/raytracing/world.py @@ -0,0 +1,93 @@ +""" +Module to describe and interact with a scene composed of various optical +objects +""" +from __future__ import annotations + +import warnings +from dataclasses import dataclass, field +from typing import Optional, List, NamedTuple, Iterable, Tuple + +from .geometry import GeometricObject +from .material import OpticMaterial, BeamDump +from .ray import Ray +from .shade import ShadeRec + + +class OpticalObject(NamedTuple): + geometry: GeometricObject + material: OpticMaterial + + +@dataclass +class World: + """Stores a scene and computes the interaction with a ray""" + + objects: Optional[list[OpticalObject]] = field(default_factory=list) + # default recursion depth can be changed, but should not exceed + # system recursion limit. + max_recursion_depth: Optional[int] = 500 + + def add(self, obj: OpticalObject): + self.objects.append(obj) + + def __iter__(self) -> Iterable[OpticalObject]: + return iter(self.objects) + + @property + def num_objects(self) -> int: + return len(self.objects) + + def first_hit(self, ray: Ray) -> Tuple[ShadeRec, OpticMaterial]: + """ + Returns the information about the first collision of the beam + with an object. + + :return: A shade for the collision geometric information and the + material of the object hit. + """ + result = ShadeRec() + material = BeamDump() + for obj in self: + shade = obj.geometry.hit(ray) + if Ray.min_travel < shade.travel_dist < result.travel_dist: + result = shade + material = obj.material + return result, material + + def propagate_beams(self, seed): + return self._propagate_beams([[seed]], 0) + + def _propagate_beams(self, beams: List[List[Ray]], depth) -> List[List[Ray]]: + """Computes the propagation of beams in the system + + :return: List of all the beam paths generated by these seeds. + It is stored as + [path0[Ray0, Ray1, ...], path1[...], ...]. + Each path is a list of successive rays having each traveled a + given distance. + :raise: warning if recursion depth hits a limit. + """ + + if depth >= self.max_recursion_depth: + err_msg = ( + f"Maximal recursion depth exceeded ({self.max_recursion_depth})." + "It is likely that not all beams have been rendered." + ) + warnings.warn(err_msg) + return beams + else: + new_beams = list() + for index, beam in enumerate(beams): + ray = beam[-1] + if ray.travel <= 0: + shade, material = self.first_hit(ray) + new_seeds = material.generated_beams(ray, shade) + beams[index][-1] = Ray(ray.origin, ray.direction, shade.travel_dist) + if len(new_seeds) == 0: + new_beams.append(beams[index]) + for seed in new_seeds: + generated_beams = self._propagate_beams([[seed]], depth + 1) + for new_beam in generated_beams: + new_beams.append(beams[index] + new_beam) + return new_beams diff --git a/extensions/fablabchemnitz/raytracing/render.inx b/extensions/fablabchemnitz/raytracing/render.inx new file mode 100644 index 0000000..63aa929 --- /dev/null +++ b/extensions/fablabchemnitz/raytracing/render.inx @@ -0,0 +1,17 @@ + + + Render + fablabchemnitz.de.raytracing.render + + all + + + + + + + + + diff --git a/extensions/fablabchemnitz/raytracing/render.py b/extensions/fablabchemnitz/raytracing/render.py new file mode 100644 index 0000000..2c68d11 --- /dev/null +++ b/extensions/fablabchemnitz/raytracing/render.py @@ -0,0 +1,289 @@ +""" +Extension for rendering beams in 2D optics with Inkscape +""" +from __future__ import annotations +from dataclasses import dataclass +from functools import singledispatchmethod +from typing import Iterable, Optional, Final +import inkex +from inkex.paths import Line, Move +import raytracing.material +from desc_parser import get_optics_fields +from raytracing import Vector, World, OpticalObject, Ray +from raytracing.geometry import CubicBezier, CompoundGeometricObject, GeometricObject +from utils import pairwise + + +@dataclass +class BeamSeed: + ray: Optional[Ray] = None + parent: Optional[inkex.ShapeElement] = None + + +def get_unlinked_copy(clone: inkex.Use) -> Optional[inkex.ShapeElement]: + """Creates a copy of the original with all transformations applied""" + copy = clone.href.copy() + copy.transform = clone.composed_transform() * copy.transform + copy.style = clone.specified_style() + copy.getparent = clone.getparent + return copy + + +def get_or_create_beam_layer(parent_layer: inkex.Layer) -> inkex.Layer: + for child in parent_layer: + if isinstance(child, inkex.Layer): + if child.get("inkscape:label") == "generated_beams": + return child + new_layer = parent_layer.add(inkex.Layer()) + new_layer.label = "generated_beams" + return new_layer + + +def plot_beam(beam: list[Ray], node: inkex.ShapeElement, layer: inkex.Layer): + path = inkex.Path() + if beam: + path += [Move(beam[0].origin.x, beam[0].origin.y)] + for ray in beam: + p1 = ray.origin + ray.travel * ray.direction + path += [Line(p1.x, p1.y)] + element = layer.add(inkex.PathElement()) + # Need to convert to path to get the correct style for inkex.Use + element.style = node.specified_style() + element.path = path + + +class Raytracing(inkex.EffectExtension): + """Extension to renders the beams present in the document""" + + # Ray tracing is only implemented for the following inkex primitives + filter_primitives: Final = ( + inkex.PathElement, + inkex.Line, + inkex.Polyline, + inkex.Polygon, + inkex.Rectangle, + inkex.Ellipse, + inkex.Circle, + ) + + def __init__(self): + super().__init__() + self.world = World() + self.beam_seeds: list[BeamSeed] = list() + + def effect(self) -> None: + """ + Loads the objects and outputs a svg with the beams after propagation + """ + + # Can't set the border earlier because self.svg is not yet defined + self.document_border = self.get_document_borders_as_beamdump() + self.world.add(self.document_border) + + filter_ = self.filter_primitives + (inkex.Group, inkex.Use) + for obj in self.svg.selection.filter(filter_): + self.add(obj) + + if self.beam_seeds: + for seed in self.beam_seeds: + if self.is_inside_document(seed.ray): + generated = self.world.propagate_beams(seed.ray) + for beam in generated: + try: + new_layer = get_or_create_beam_layer( + get_containing_layer(seed.parent) + ) + plot_beam(beam, seed.parent, new_layer) + except LayerError as e: + inkex.utils.errormsg(f"{e} It will be ignored.") + + @singledispatchmethod + def add(self, obj): + pass + + @add.register + def _(self, group: inkex.Group): + for child in group: + self.add(child) + + @add.register + def _(self, clone: inkex.Use): + copy = get_unlinked_copy(clone) + self.add(copy) + + for type in filter_primitives: + + @add.register(type) + def _(self, obj): + """ + Extracts properties and adds the object to the ray tracing data + structure + """ + material = get_material(obj) + if material: + if isinstance(material, BeamSeed): + for ray in get_beams(obj): + self.beam_seeds.append(BeamSeed(ray, parent=obj)) + else: + geometry = get_geometry(obj) + opt_obj = OpticalObject(geometry, material) + self.world.add(opt_obj) + + def get_document_borders_as_beamdump(self) -> OpticalObject: + """ + Adds a beam blocking contour on the borders of the document to + prevent the beams from going to infinity + """ + + c1x, c1y, c2x, c2y = self.svg.get_viewbox() + contour_geometry = CompoundGeometricObject( + ( + CubicBezier( + Vector(c1x, c1y), + Vector(c1x, c1y), + Vector(c2x, c1y), + Vector(c2x, c1y), + ), + CubicBezier( + Vector(c2x, c1y), + Vector(c2x, c1y), + Vector(c2x, c2y), + Vector(c2x, c2y), + ), + CubicBezier( + Vector(c2x, c2y), + Vector(c2x, c2y), + Vector(c1x, c2y), + Vector(c1x, c2y), + ), + CubicBezier( + Vector(c1x, c2y), + Vector(c1x, c2y), + Vector(c1x, c1y), + Vector(c1x, c1y), + ), + ) + ) + return OpticalObject(contour_geometry, raytracing.material.BeamDump()) + + def is_inside_document(self, ray: Ray) -> bool: + return self.document_border.geometry.is_inside(ray) + + +def get_material( + obj: inkex.ShapeElement, +) -> Optional[raytracing.material.OpticMaterial | BeamSeed]: + """Extracts the optical material of an object from its description""" + + desc = obj.desc + if desc is None: + desc = "" + materials = get_materials_from_description(desc) + if len(materials) == 0: + return None + if len(materials) > 1: + raise_err_num_materials(obj) + elif len(materials) == 1: + return materials[0] + + +def get_materials_from_description( + desc: str, +) -> list[raytracing.material.OpticMaterial | BeamSeed]: + """Run through the description to extract the material properties""" + + materials = list() + class_alias = dict( + beam_dump=raytracing.material.BeamDump, + mirror=raytracing.material.Mirror, + beam_splitter=raytracing.material.BeamSplitter, + glass=raytracing.material.Glass, + beam=BeamSeed, + ) + for match in get_optics_fields(desc): + material_type = match.group("material") + prop_str = match.group("num") + if material_type in class_alias: + if material_type == "glass" and prop_str is not None: + optical_index = float(prop_str) + materials.append(class_alias[material_type](optical_index)) + else: + materials.append(class_alias[material_type]()) + return materials + + +def raise_err_num_materials(obj): + inkex.utils.errormsg( + f"The element {obj.get_id()} has more than one optical material and will be" + f" ignored:\n{obj.desc}\n" + ) + + +def get_geometry(obj: inkex.ShapeElement) -> GeometricObject: + """ + Converts the geometry of inkscape elements to a form suitable for the + ray tracing module + """ + + # Treats all objects as cubic Bezier curves. This treatment is exact + # for most primitives except circles and ellipses that are only + # approximated by Bezier curves. + # TODO: implement exact representation for ellipses + path = get_absolute_path(obj) + composite_bezier = convert_to_composite_bezier(path) + return composite_bezier + + +def get_absolute_path(obj: inkex.ShapeElement) -> inkex.CubicSuperPath: + path = obj.to_path_element().path.to_absolute() + transformed_path = path.transform(obj.composed_transform()) + return transformed_path.to_superpath() + + +def get_beams(element: inkex.ShapeElement) -> Iterable[Ray]: + """ + Returns a beam with origin at the endpoint of the path and tangent to + the path + """ + bezier_path = convert_to_composite_bezier(get_absolute_path(element)) + for sub_path in bezier_path: + last_segment = sub_path[-1] + endpoint = last_segment.eval(1) + tangent = -last_segment.tangent(1) + yield Ray(endpoint, tangent) + + +def convert_to_composite_bezier( + superpath: inkex.CubicSuperPath, +) -> CompoundGeometricObject: + """ + Converts a superpath with a representation + [Subpath0[handle0_0, point0, handle0_1], ...], ...] + to a representation of consecutive bezier segments of the form + CompositeCubicBezier([CubicBezierPath[CubicBezier[point0, handle0_1, + handle1_0, point1], ...], ...]). + """ + + composite_bezier = list() + for subpath in superpath: + bezier_path = list() + for (__, p0, p1), (p2, p3, __) in pairwise(subpath): + bezier = CubicBezier(Vector(*p0), Vector(*p1), Vector(*p2), Vector(*p3)) + bezier_path.append(bezier) + composite_bezier.append(CompoundGeometricObject(bezier_path)) + return CompoundGeometricObject(composite_bezier) + + +def get_containing_layer(obj: inkex.BaseElement) -> inkex.Layer: + try: + return obj.ancestors().filter(inkex.Layer)[0] + except IndexError: + raise LayerError(f"Object '{obj.get_id()}' is not inside a layer.") + + +class LayerError(RuntimeError): + pass + + +if __name__ == "__main__": + Raytracing().run() diff --git a/extensions/fablabchemnitz/raytracing/set_material.inx b/extensions/fablabchemnitz/raytracing/set_material.inx new file mode 100644 index 0000000..7614633 --- /dev/null +++ b/extensions/fablabchemnitz/raytracing/set_material.inx @@ -0,0 +1,25 @@ + + + Set Lens Material + fablabchemnitz.de.raytracing.set_lens_material + + + + + + + + + 1.5168 + + all + + + + + + + + \ No newline at end of file diff --git a/extensions/fablabchemnitz/raytracing/set_material.py b/extensions/fablabchemnitz/raytracing/set_material.py new file mode 100644 index 0000000..cdb16c2 --- /dev/null +++ b/extensions/fablabchemnitz/raytracing/set_material.py @@ -0,0 +1,67 @@ +from __future__ import annotations +from functools import singledispatchmethod +from typing import Final +import inkex +from desc_parser import clear_description + +class SetMaterial(inkex.EffectExtension): + """Writes the chosen optical property in the element description""" + + # only change the description for these objects + filter_primitives: Final = ( + inkex.PathElement, + inkex.Line, + inkex.Polyline, + inkex.Polygon, + inkex.Rectangle, + inkex.Ellipse, + inkex.Circle, + ) + + def __init__(self): + super().__init__() + + def add_arguments(self, pars): + pars.add_argument("--optical_material", default="none", help="Name of the optical material to convert the selection to.") + pars.add_argument("--optical_index", type=float, default=1.5168) + + def effect(self) -> None: + filter_ = self.filter_primitives + (inkex.Group,) + for obj in self.svg.selection.filter(filter_): + self.update_description(obj) + + @singledispatchmethod + def update_description(self, arg): + pass + + @update_description.register + def _(self, group: inkex.Group): + for obj in group: + self.update_description(obj) + + for type in filter_primitives: + + @update_description.register(type) + def _(self, obj): + """ + In the description of the element, replaces the optical properties + with the new one. + """ + + desc = obj.desc + if desc is None: + desc = "" + new_desc = clear_description(desc) + if desc != "" and desc[-1] != "\n": + desc += "\n" + + material_name = self.options.optical_material + if material_name is not None: + new_desc += f"optics:{material_name}" + if material_name == "glass": + new_desc += f":{self.options.optical_index:.4f}" + obj.desc = new_desc + + +if __name__ == "__main__": + SetMaterial().run() \ No newline at end of file diff --git a/extensions/fablabchemnitz/raytracing/test.py b/extensions/fablabchemnitz/raytracing/test.py new file mode 100644 index 0000000..878976c --- /dev/null +++ b/extensions/fablabchemnitz/raytracing/test.py @@ -0,0 +1,4 @@ +from raytracing.geometry import CubicBezier + +p = CubicBezier((1,1),(1,1),(1,1),(1,1)) +print(p) \ No newline at end of file diff --git a/extensions/fablabchemnitz/raytracing/utils.py b/extensions/fablabchemnitz/raytracing/utils.py new file mode 100644 index 0000000..fc233cc --- /dev/null +++ b/extensions/fablabchemnitz/raytracing/utils.py @@ -0,0 +1,11 @@ +import itertools +from typing import TypeVar, Iterator, Tuple + +T = TypeVar("T") + + +def pairwise(iterable: Iterator[T]) -> Iterator[Tuple[T, T]]: + """s -> (s0,s1), (s1,s2), (s2, s3), ...""" + a, b = itertools.tee(iterable) + next(b, None) + return zip(a, b) diff --git a/extensions/fablabchemnitz/remove_duplicate_line_segments/meta.json b/extensions/fablabchemnitz/remove_duplicate_line_segments/meta.json new file mode 100644 index 0000000..866b8ff --- /dev/null +++ b/extensions/fablabchemnitz/remove_duplicate_line_segments/meta.json @@ -0,0 +1,21 @@ +[ + { + "name": "Remove Duplicate Lines", + "id": "fablabchemnitz.de.remove_duplicate_lines", + "path": "remove_duplicate_lines", + "dependent_extensions": null, + "original_name": "Remove duplicate lines", + "original_id": "EllenWasbo.cutlings.RemoveDuplicateLines", + "license": "GNU GPL v2", + "license_url": "https://gitlab.com/EllenWasbo/inkscape-extension-removeduplicatelines/-/blob/main/removeDuplicateLines.py", + "comment": "Might supersede 'Purge Duplicate Path Segments' extension", + "source_url": "https://gitea.fablabchemnitz.de/FabLab_Chemnitz/mightyscape-1.2/src/branch/master/extensions/fablabchemnitz/remove_duplicate_lines", + "fork_url": "https://gitlab.com/EllenWasbo/inkscape-extension-removeduplicatelines", + "documentation_url": "https://stadtfabrikanten.org/display/IFM/Remove+Duplicate+Lines", + "inkscape_gallery_url": null, + "main_authors": [ + "gitlab.com/EllenWasbo", + "github.com/eridur-de" + ] + } +] \ No newline at end of file diff --git a/extensions/fablabchemnitz/remove_duplicate_line_segments/remove_duplicate_line_segments.inx b/extensions/fablabchemnitz/remove_duplicate_line_segments/remove_duplicate_line_segments.inx new file mode 100644 index 0000000..206d1fb --- /dev/null +++ b/extensions/fablabchemnitz/remove_duplicate_line_segments/remove_duplicate_line_segments.inx @@ -0,0 +1,32 @@ + + + Remove Duplicate Line Segments + fablabchemnitz.de.remove_duplicate_line_segments + + + + false + + false + 0.01 + + false + + + + + + + + + all + + + + + + + + \ No newline at end of file diff --git a/extensions/fablabchemnitz/remove_duplicate_line_segments/remove_duplicate_line_segments.py b/extensions/fablabchemnitz/remove_duplicate_line_segments/remove_duplicate_line_segments.py new file mode 100644 index 0000000..77c5a66 --- /dev/null +++ b/extensions/fablabchemnitz/remove_duplicate_line_segments/remove_duplicate_line_segments.py @@ -0,0 +1,361 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2021 Ellen Wasboe, ellen@wasbo.net +# +# 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. +"""s +Remove duplicate lines by comparing cubic bezier control points after converting to cubic super path. +Optionally include searching for overlaps within the same path (which might cause trouble if the tolerance is too high and small neighbour segments are regarded as a match. +Optionally add a tolerance for the comparison. +Optionally interpolate the four control points of the remaining and the removed segment. +""" + +import inkex +from inkex import bezier, PathElement, CubicSuperPath, Transform +import numpy as np +from tkinter import messagebox + +class removeDuplicateLineSegments(inkex.EffectExtension): + + def add_arguments(self, pars): + pars.add_argument("--tab", default="options") + pars.add_argument("--tolerance", default="0") + pars.add_argument("--minUse", type=inkex.Boolean, default=False) + pars.add_argument("--selfPath", type=inkex.Boolean, default=False) + pars.add_argument("--interp", type=inkex.Boolean, default=False) + + """Remove duplicate lines""" + def effect(self): + tolerance=float(self.options.tolerance) + if self.options.minUse == False: + tolerance=0 + + coords=[]#one segmentx8 subarray for each path and subpath (paths and subpaths treated equally) + pathNo=[] + subPathNo=[] + cPathNo=[]#counting alle paths and subpaths equally + removeSegmentPath=[] + removeSegmentSubPath=[] + removeSegment_cPath=[] + removeSegment=[] + matchSegmentPath=[] + matchSegmentSubPath=[] + matchSegment_cPath=[] + matchSegment=[] + matchSegmentRev=[] + + if not self.svg.selected: + raise inkex.AbortExtension("Please select an object.") + nFailed=0 + nInkEffect=0 + p=0 + c=0 + idsNotPath=[] + for id, elem in self.svg.selection.id_dict().items(): + thisIsPath=True + if elem.get('d')==None: + thisIsPath=False + nFailed+=1 + idsNotPath.append(id) + if elem.get('inkscape:path-effect') != None: + thisIsPath=False + nInkEffect+=1 + idsNotPath.append(id) + + if thisIsPath: + #apply transformation matrix if present + csp = CubicSuperPath(elem.get('d')) + elem.path=elem.path.to_absolute() + transformMat = Transform(elem.get('transform')) + cpsTransf=csp.transform(transformMat) + elem.path = cpsTransf.to_path(curves_only=True) + pp=elem.path + + s=0 + #create matrix with segment coordinates p1x p1y c1x c1y c2x c2y p2x p2y + for sub in pp.to_superpath(): + coordsThis=np.zeros((len(sub)-1,8)) + + i=0 + while i <= len(sub) - 2: + coordsThis[i][0]=sub[i][1][0] + coordsThis[i][1]=sub[i][1][1] + coordsThis[i][2]=sub[i][2][0] + coordsThis[i][3]=sub[i][2][1] + coordsThis[i][4]=sub[i+1][0][0] + coordsThis[i][5]=sub[i+1][0][1] + coordsThis[i][6]=sub[i+1][1][0] + coordsThis[i][7]=sub[i+1][1][1] + + i+=1 + + coords.append(coordsThis) + pathNo.append(p) + subPathNo.append(s) + cPathNo.append(c) + c+=1 + s+=1 + p+=1 + if nFailed > 0: + messagebox.showwarning('Warning',str(nFailed)+' selected elements did not have a path. Groups, shapeelements and text will be ignored.') + + if nInkEffect > 0: + messagebox.showwarning('Warning',str(nInkEffect)+' selected elements have an inkscape:path-effect applied. These elements will be ignored to avoid confusing results. Apply Paths->Object to path (Shift+Ctrl+C) and retry .') + + origCoords=[] + for item in coords: origCoords.append(np.copy(item))#make a real copy (not a reference that changes with the original + #search for overlapping or close segments + #for each segment find if difference of any x or y is less than tolerance - if so - calculate 2d-distance and find if all 4 less than tolerance + #repeat with reversed segment + #if match found set match coordinates to -1000 to mark this to be removed and being ignored later on + i=0 + while i <= len(coords)-1:#each path or subpath + j=0 + while j<=len(coords[i][:,0])-1:#each segment j of path i + k=0 + while k<=len(coords)-1:#search all other subpaths + evalPath=True + if k == i and self.options.selfPath == False:#do not test path against itself + evalPath=False + if evalPath: + segmentCoords=np.array(coords[i][j,:]) + if segmentCoords[0] != -1000 and segmentCoords[1] != -1000: + searchCoords=np.array(coords[k]) + if k==i: + searchCoords[j,:]=-2000#avoid comparing segment with itself + subtr=np.abs(searchCoords-segmentCoords) + maxval=subtr.max(1) + lessTol=np.argwhere(maxval 0:#proceed to calculate 2d distance where both x and y distance is less than tolerance + c=0 + while c < len(lessTol): + dists=np.zeros(4) + dists[0]=np.sqrt(np.add(np.power(subtr[lessTol[c,0]][0],2),np.power(subtr[lessTol[c,0]][1],2))) + dists[1]=np.sqrt(np.add(np.power(subtr[lessTol[c,0]][2],2),np.power(subtr[lessTol[c,0]][3],2))) + dists[2]=np.sqrt(np.add(np.power(subtr[lessTol[c,0]][4],2),np.power(subtr[lessTol[c,0]][5],2))) + dists[3]=np.sqrt(np.add(np.power(subtr[lessTol[c,0]][6],2),np.power(subtr[lessTol[c,0]][7],2))) + if dists.max() < tolerance: + matchThis=True + finalK=k + lesstolc=lessTol[c] + c+=1 + if matchThis == False:#try reversed + segmentCoordsRev=[segmentCoords[6], segmentCoords[7],segmentCoords[4],segmentCoords[5],segmentCoords[2],segmentCoords[3],segmentCoords[0],segmentCoords[1]] + subtr=np.abs(searchCoords-segmentCoordsRev) + maxval=subtr.max(1) + lessTol=np.argwhere(maxval 0:#proceed to calculate 2d distance where both x and y distance is less than tolerance + c=0 + while c < len(lessTol): + dists=np.zeros(4) + dists[0]=np.sqrt(np.add(np.power(subtr[lessTol[c,0]][0],2),np.power(subtr[lessTol[c,0]][1],2))) + dists[1]=np.sqrt(np.add(np.power(subtr[lessTol[c,0]][2],2),np.power(subtr[lessTol[c,0]][3],2))) + dists[2]=np.sqrt(np.add(np.power(subtr[lessTol[c,0]][4],2),np.power(subtr[lessTol[c,0]][5],2))) + dists[3]=np.sqrt(np.add(np.power(subtr[lessTol[c,0]][6],2),np.power(subtr[lessTol[c,0]][7],2))) + if dists.max() < tolerance: + matchThis=True + matchThisRev=True + finalK=k + lesstolc=lessTol[c] + c+=1 + + if matchThis: + coords[finalK][lesstolc,:]=-1000 + removeSegmentPath.append(pathNo[finalK]) + removeSegmentSubPath.append(subPathNo[finalK]) + removeSegment_cPath.append(cPathNo[finalK]) + removeSegment.append(lesstolc) + matchSegmentPath.append(pathNo[i]) + matchSegmentSubPath.append(subPathNo[i]) + matchSegment_cPath.append(cPathNo[i]) + matchSegment.append(j) + matchSegmentRev.append(matchThisRev) + + k+=1 + j+=1 + i+=1 + + #(interpolate remaining and) remove segments with a match + if len(removeSegmentPath) > 0: + removeSegmentPath=np.array(removeSegmentPath) + removeSegmentSubPath=np.array(removeSegmentSubPath) + removeSegment_cPath=np.array(removeSegment_cPath) + removeSegment=np.array(removeSegment) + matchSegmentPath=np.array(matchSegmentPath) + matchSegment_cPath=np.array(matchSegment_cPath) + matchSegmentSubPath=np.array(matchSegmentSubPath) + matchSegment=np.array(matchSegment) + matchSegmentRev=np.array(matchSegmentRev) + + #first interpolate remaining segment + if self.options.interp: + idx=np.argsort(matchSegmentPath) + matchSegmentPath=matchSegmentPath[idx] + matchSegment_cPath=matchSegment_cPath[idx] + matchSegmentSubPath=matchSegmentSubPath[idx] + matchSegment=matchSegment[idx] + matchSegmentRev=matchSegmentRev[idx] + remSegmentPath=removeSegmentPath[idx] + remSegment_cPath=removeSegment_cPath[idx] + remSegment=removeSegment[idx] + + i=0 + for id, elem in self.svg.selection.id_dict().items():#each path + if not id in idsNotPath: + if i in matchSegmentPath: + idxi=np.argwhere(matchSegmentPath==i) + idxi=idxi.reshape(-1) + icMatch=matchSegment_cPath[idxi] + iSegMatch=matchSegment[idxi] + iSegMatchRev=matchSegmentRev[idxi] + iSubMatch=matchSegmentSubPath[idxi] + iSegRem=remSegment[idxi] + icRem=remSegment_cPath[idxi] + iPathRem=remSegmentPath[idxi] + new=[] + j=0 + for sub in elem.path.to_superpath():#each subpath + idxj=np.argwhere(iSubMatch==j) + idxj=idxj.reshape(-1) + this_cMatch=icMatch[idxj] + thisSegMatch=iSegMatch[idxj] + thisSegMatchRev=iSegMatchRev[idxj] + thisSegRem=iSegRem[idxj].reshape(-1) + this_cRem=icRem[idxj] + thisPathRem=iPathRem[idxj] + k=0 + while k 0: + idx=idx.reshape(1,-1) + idx=idx[0] + new=[] + j=0 + for sub in elem.path.to_superpath():#each subpath + thisSegRem=removeSegment[idx] + keepLast=False if len(sub)-2 in thisSegRem else True + keepNext2Last=False if len(sub)-3 in thisSegRem else True + thisSubPath=removeSegmentSubPath[idx] + idx2=np.argwhere(removeSegmentSubPath[idx]==j) + if len(idx2) > 0: + idx2=idx2.reshape(1,-1) + idx2=idx2[0] + thisSegRem=thisSegRem[idx2] + if len(thisSegRem) < len(sub)-1:#if any segment to be kept + #find first segment + k=0 + if 0 in thisSegRem:#remove first segment + proceed=True + while proceed: + if k+1 in thisSegRem: + k+=1 + else: + proceed=False + k+=1 + new.append([sub[k]]) + if sub[k+1]!=new[-1][-1]:#avoid duplicated nodes + new[-1].append(sub[k+1]) + new[-1][-1][0]=new[-1][-1][1] + else: + new.append([sub[0]]) + if sub[1]!=new[-1][-1]:#avoid duplicated nodes + new[-1].append(sub[1]) + k+=1 + + #rest of segments + while k 0: + if len(new[-1])==1: new.pop() + else: + new.append(sub)#add as is + + j+=1 + + elem.path = CubicSuperPath(new).to_path(curves_only=True) + i+=1 + +if __name__ == '__main__': + removeDuplicateLineSegments().run() \ No newline at end of file diff --git a/extensions/fablabchemnitz/remove_obsolete_attributes/meta.json b/extensions/fablabchemnitz/remove_obsolete_attributes/meta.json new file mode 100644 index 0000000..7ee630a --- /dev/null +++ b/extensions/fablabchemnitz/remove_obsolete_attributes/meta.json @@ -0,0 +1,21 @@ +[ + { + "name": "Remove Obsolete Attributes", + "id": "fablabchemnitz.de.remove_obsolete_attributes", + "path": "remove_obsolete_attributes", + "dependent_extensions": null, + "original_name": "Remove Obsolete", + "original_id": "com.mathem.absref_remover", + "license": "GNU LGPL v2", + "license_url": "https://inkscape.org/~MatheM/%E2%98%85simple-attribute-editor+1", + "comment": "ported to Inkscape v1 manually by Mario Voigt", + "source_url": "https://gitea.fablabchemnitz.de/FabLab_Chemnitz/mightyscape-1.2/src/branch/master/extensions/fablabchemnitz/remove_obsolete_attributes", + "fork_url": "https://inkscape.org/~MatheM/%E2%98%85simple-attribute-editor+1", + "documentation_url": "https://stadtfabrikanten.org/display/IFM/Remove+Obsolete+Attributes", + "inkscape_gallery_url": null, + "main_authors": [ + "inkscape.org/MatheM", + "github.com/eridur-de" + ] + } +] diff --git a/extensions/fablabchemnitz/remove_obsolete_attributes/remove_obsolete_attributes.inx b/extensions/fablabchemnitz/remove_obsolete_attributes/remove_obsolete_attributes.inx new file mode 100644 index 0000000..6035006 --- /dev/null +++ b/extensions/fablabchemnitz/remove_obsolete_attributes/remove_obsolete_attributes.inx @@ -0,0 +1,20 @@ + + + Remove Obsolete Attributes + fablabchemnitz.de.remove_obsolete_attributes + + true + true + true + + all + + + + + + + + \ No newline at end of file diff --git a/extensions/fablabchemnitz/remove_obsolete_attributes/remove_obsolete_attributes.py b/extensions/fablabchemnitz/remove_obsolete_attributes/remove_obsolete_attributes.py new file mode 100644 index 0000000..9010bff --- /dev/null +++ b/extensions/fablabchemnitz/remove_obsolete_attributes/remove_obsolete_attributes.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 + +""" +Removes attributes sodipodi:absref, sodipodi:docbase and sodipodi:docname from all elements that contain them. + +full names of attributes +sodipodi:absref +{http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd}absref +sodipodi:docbase +{http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd}docbase +sodipodi:docname +{http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd}docname + +element.attrib.pop("{http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd}absref", None) +""" + +import inkex +import sys + +class RemoveObsoleteAttributes(inkex.EffectExtension): + def __init__(self): + inkex.Effect.__init__(self) + self.arg_parser.add_argument("-a", "--removeAbsref", type=inkex.Boolean, default=True, help="Remove sodipodi:absref") + self.arg_parser.add_argument("-b", "--removeDocbase", type=inkex.Boolean, default=True, help="Remove sodipodi:docbase") + self.arg_parser.add_argument("-n", "--removeDocname", type=inkex.Boolean, default=True, help="Remove sodipodi:docname") + + def effect(self): + if self.options.removeAbsref: + elements = self.document.xpath("//*[@sodipodi:absref]", namespaces=inkex.NSS) + for element in elements: + element.attrib.pop("{http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd}absref", None) + + if self.options.removeDocbase: + elements = self.document.xpath("//*[@sodipodi:docbase]", namespaces=inkex.NSS) + for element in elements: + element.attrib.pop("{http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd}docbase", None) + + if self.options.removeDocname: + elements = self.document.xpath("//*[@sodipodi:docname]", namespaces=inkex.NSS) + for element in elements: + element.attrib.pop("{http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd}docname", None) + +if __name__ == "__main__": + RemoveObsoleteAttributes().run() \ No newline at end of file diff --git a/extensions/fablabchemnitz/replace_color_and_alpha/meta.json b/extensions/fablabchemnitz/replace_color_and_alpha/meta.json new file mode 100644 index 0000000..6f3ba3e --- /dev/null +++ b/extensions/fablabchemnitz/replace_color_and_alpha/meta.json @@ -0,0 +1,21 @@ +[ + { + "name": "Replace Color And Alpha", + "id": "fablabchemnitz.de.replace_color_and_alpha", + "path": "replace_color_and_alpha", + "dependent_extensions": null, + "original_name": "Replace color and alpha", + "original_id": "com.www.marker.es.ReplaceColorAlpha", + "license": "GNU GPL v2", + "license_url": "https://inkscape.org/de/~jabiertxof/%E2%98%85color-and-alpha-replace", + "comment": "ported to Inkscape v1 manually by Mario Voigt", + "source_url": "https://gitea.fablabchemnitz.de/FabLab_Chemnitz/mightyscape-1.2/src/branch/master/extensions/fablabchemnitz/replace_color_and_alpha", + "fork_url": "https://inkscape.org/de/~jabiertxof/%E2%98%85color-and-alpha-replace", + "documentation_url": "https://stadtfabrikanten.org/display/IFM/Replace+Color+And+Alpha", + "inkscape_gallery_url": null, + "main_authors": [ + "inkscape.org/jabiertxof", + "github.com/eridur-de" + ] + } +] \ No newline at end of file diff --git a/extensions/fablabchemnitz/replace_color_and_alpha/replace_color_and_alpha.inx b/extensions/fablabchemnitz/replace_color_and_alpha/replace_color_and_alpha.inx new file mode 100644 index 0000000..c3deedd --- /dev/null +++ b/extensions/fablabchemnitz/replace_color_and_alpha/replace_color_and_alpha.inx @@ -0,0 +1,18 @@ + + + Replace Color And Alpha + fablabchemnitz.de.replace_color_and_alpha + 000000 + 000000 + + all + + + + + + + + \ No newline at end of file diff --git a/extensions/fablabchemnitz/replace_color_and_alpha/replace_color_and_alpha.py b/extensions/fablabchemnitz/replace_color_and_alpha/replace_color_and_alpha.py new file mode 100644 index 0000000..99ed8fd --- /dev/null +++ b/extensions/fablabchemnitz/replace_color_and_alpha/replace_color_and_alpha.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +''' +This extension replace color and alpha from full inkscape document. + +Copyright (C) 2012 Jabiertxo Arraiza, jabier.arraiza@marker.es + +Version 0.2 + +TODO: +Comment Better!!! + +CHANGE LOG +0.1 Start 16/08/2012 + +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 inkex +import sys +import re +from lxml import etree + +class ReplaceColorAndAlpha(inkex.EffectExtension): + + def add_arguments(self, pars): + pars.add_argument("--from_color", default="000000", help="Replace color") + pars.add_argument("--to_color", default="000000", help="By color + Alpha") + + def effect(self): + saveout = sys.stdout + sys.stdout = sys.stderr + fr = self.options.from_color.strip('"').strip('#').lower() + try: + alphaFr = str(float(int(self.options.from_color.strip('"').strip('#').lower()[-2:], 16))/255.0) + except:zz + pass + to = self.options.to_color.strip('"').strip('#').lower() + try: + alphaTo = str(float(int(self.options.to_color.strip('"').strip('#').lower()[-2:], 16))/255.0) + except: + pass + svg = self.document.getroot() + for element in svg.iter("*"): + style = element.get('style') + if style: + if (style.lower().find('fill:#'+fr[:6]) != -1 and len(fr) == 6) or (style.lower().find('fill-opacity:'+alphaFr[:4]) != -1 and len(fr)==8 and style.lower().find('fill:#'+fr[:6]) != -1): + style = re.sub('fill-opacity:.*?(;|$)', + '\\1', + style) + style = re.sub('fill:#.*?(;|$)', + 'fill:#' + to[:6] + '\\1', + style) + + style = style + ";fill-opacity:" + alphaTo + element.set('style',style) + + if (style.lower().find('stroke:#'+fr[:6]) != -1 and len(fr) == 6) or (style.lower().find('stroke:#'+fr[:6]) != -1 and style.lower().find('stroke-opacity:'+alphaFr[:4]) != -1 and len(fr)==8): + style = re.sub('stroke-opacity:.*?(;|$)', + '\\1', + style) + style = re.sub(r'stroke:#.*?(;|$)', + 'stroke:#' + to[:6] + '\\1', + style) + style = style + ";stroke-opacity:" + alphaTo + element.set('style',style) + sys.stdout = saveout + +if __name__ == '__main__': + ReplaceColorAndAlpha().run() \ No newline at end of file diff --git a/extensions/fablabchemnitz/reverse_order_of_subpaths/meta.json b/extensions/fablabchemnitz/reverse_order_of_subpaths/meta.json new file mode 100644 index 0000000..a39393a --- /dev/null +++ b/extensions/fablabchemnitz/reverse_order_of_subpaths/meta.json @@ -0,0 +1,21 @@ +[ + { + "name": "Reverse Order Of Subpaths", + "id": "fablabchemnitz.de.reverse_order_of_subpaths", + "path": "reverse_order_of_subpaths", + "dependent_extensions": null, + "original_name": "Reverse order of subpaths", + "original_id": "EllenWasbo.cutlings.reverse_order_subpaths", + "license": "GNU GPL v2", + "license_url": "https://gitlab.com/EllenWasbo/inkscape-extension-reverse-order-of-subpaths/-/blob/master/reverse_order_subpaths.py", + "comment": "", + "source_url": "https://gitea.fablabchemnitz.de/FabLab_Chemnitz/mightyscape-1.2/src/branch/master/extensions/fablabchemnitz/reverse_order_of_subpaths", + "fork_url": "https://gitlab.com/EllenWasbo/inkscape-extension-reverse-order-of-subpaths/", + "documentation_url": "https://stadtfabrikanten.org/display/IFM/Reverse+Order+Of+Subpaths", + "inkscape_gallery_url": null, + "main_authors": [ + "gitlab.com/EllenWasbo", + "github.com/eridur-de" + ] + } +] \ No newline at end of file diff --git a/extensions/fablabchemnitz/reverse_order_of_subpaths/reverse_order_of_subpaths.inx b/extensions/fablabchemnitz/reverse_order_of_subpaths/reverse_order_of_subpaths.inx new file mode 100644 index 0000000..d30ecb8 --- /dev/null +++ b/extensions/fablabchemnitz/reverse_order_of_subpaths/reverse_order_of_subpaths.inx @@ -0,0 +1,17 @@ + + + Reverse Order Of Subpaths + fablabchemnitz.de.reverse_order_of_subpaths + + + path + + + + + + + + \ No newline at end of file diff --git a/extensions/fablabchemnitz/reverse_order_of_subpaths/reverse_order_of_subpaths.py b/extensions/fablabchemnitz/reverse_order_of_subpaths/reverse_order_of_subpaths.py new file mode 100644 index 0000000..6fe2817 --- /dev/null +++ b/extensions/fablabchemnitz/reverse_order_of_subpaths/reverse_order_of_subpaths.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +# +# Copyright (C) Ellen Wasbo, cutlings.wasbo.net 2021 +# +# 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. +# +import inkex +from inkex import PathElement, CubicSuperPath + +class ReverseOrderOfSubpaths(inkex.EffectExtension): + + def reverse(self, element): + if element.tag == inkex.addNS('path','svg'): + new = [] + sub = element.path.to_superpath() + i = 0 + while i < len(sub): + new.append(sub[-1-i]) + i += 1 + element.path = CubicSuperPath(new).to_path(curves_only=True) + elif element.tag == inkex.addNS('g','svg'): + for child in element.getchildren(): + self.reverse(child) + + def effect(self): + """Reverse order of subpaths (combined paths) without reversing node-order or order of paths""" + if not self.svg.selected: + raise inkex.AbortExtension("Please select an object.") + for element in self.svg.selection.values(): + self.reverse(element) + +if __name__ == '__main__': + ReverseOrderOfSubpaths().run() \ No newline at end of file diff --git a/extensions/fablabchemnitz/scale_to_path_length/meta.json b/extensions/fablabchemnitz/scale_to_path_length/meta.json new file mode 100644 index 0000000..2851c0b --- /dev/null +++ b/extensions/fablabchemnitz/scale_to_path_length/meta.json @@ -0,0 +1,21 @@ +[ + { + "name": "Scale To Path Length", + "id": "fablabchemnitz.de.scale_to_path_length", + "path": "scale_to_path_length", + "dependent_extensions": null, + "original_name": "Paste Length", + "original_id": "paste.svg.paste.length", + "license": "GNU GPL v2", + "license_url": "ported to Inkscape v1 by Mario Voigt", + "comment": "", + "source_url": "https://gitea.fablabchemnitz.de/FabLab_Chemnitz/mightyscape-1.2/src/branch/master/extensions/fablabchemnitz/scale_to_path_length", + "fork_url": "https://github.com/Shriinivas/inkscapepastelength", + "documentation_url": "https://stadtfabrikanten.org/display/IFM/Scale+To+Path+Length", + "inkscape_gallery_url": null, + "main_authors": [ + "github.com/Shriinivas", + "github.com/eridur-de" + ] + } +] \ No newline at end of file diff --git a/extensions/fablabchemnitz/scale_to_path_length/scale_to_path_length.inx b/extensions/fablabchemnitz/scale_to_path_length/scale_to_path_length.inx new file mode 100644 index 0000000..07c245c --- /dev/null +++ b/extensions/fablabchemnitz/scale_to_path_length/scale_to_path_length.inx @@ -0,0 +1,37 @@ + + + Scale To Path Length + fablabchemnitz.de.scale_to_path_length + + + + + + + + 1.000 + 5 + false + 100.000 + + + + + + + + + + + + path + + + + + + + + \ No newline at end of file diff --git a/extensions/fablabchemnitz/scale_to_path_length/scale_to_path_length.py b/extensions/fablabchemnitz/scale_to_path_length/scale_to_path_length.py new file mode 100644 index 0000000..4300491 --- /dev/null +++ b/extensions/fablabchemnitz/scale_to_path_length/scale_to_path_length.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 + +''' +Inkscape extension to copy length of the source path to the selected +destination path(s) + +Copyright (C) 2018 Shrinivas Kulkarni + +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. +''' + +import inkex + +from inkex import bezier, Path, CubicSuperPath, PathElement + +class ScaleToPathLength(inkex.EffectExtension): + + def add_arguments(self, pars): + pars.add_argument('--scale', type = float, default = '1', help = 'Additionally scale the length by') + pars.add_argument('--scaleFrom', default = 'center', help = 'Scale Path From') + pars.add_argument('--precision', type = int, default = '5', help = 'Number of significant digits') + pars.add_argument("--override_selection", type = inkex.Boolean, default = False, help = "Use a custom length instead using the length of the first object in selection") + pars.add_argument("--custom_length", type = float, default = 100.000, help = "Custom length") + pars.add_argument("--unit", default = "mm", help = "Units") + + def scaleCubicSuper(self, cspath, scaleFactor, scaleFrom): + bbox = Path(cspath).bounding_box() + + if(scaleFrom == 'topLeft'): + oldOrigin= [bbox.left, bbox.bottom] + elif(scaleFrom == 'topRight'): + oldOrigin= [bbox.right, bbox.bottom] + elif(scaleFrom == 'bottomLeft'): + oldOrigin= [bbox.left, bbox.top] + elif(scaleFrom == 'bottomRight'): + oldOrigin= [bbox.right, bbox.top] + else: #if(scaleFrom == 'center'): + oldOrigin= [bbox.left + (bbox.right - bbox.left) / 2., bbox.bottom + (bbox.top - bbox.bottom) / 2.] + + newOrigin = [oldOrigin[0] * scaleFactor , oldOrigin[1] * scaleFactor ] + + for subpath in cspath: + for bezierPt in subpath: + for i in range(0, len(bezierPt)): + + bezierPt[i] = [bezierPt[i][0] * scaleFactor, + bezierPt[i][1] * scaleFactor] + + bezierPt[i][0] += (oldOrigin[0] - newOrigin[0]) + bezierPt[i][1] += (oldOrigin[1] - newOrigin[1]) + + def getPartsFromCubicSuper(self, cspath): + parts = [] + for subpath in cspath: + part = [] + prevBezPt = None + for i, bezierPt in enumerate(subpath): + if(prevBezPt != None): + seg = [prevBezPt[1], prevBezPt[2], bezierPt[0], bezierPt[1]] + part.append(seg) + prevBezPt = bezierPt + parts.append(part) + return parts + + def getLength(self, cspath, tolerance): + parts = self.getPartsFromCubicSuper(cspath) + curveLen = 0 + for i, part in enumerate(parts): + for j, seg in enumerate(part): + curveLen += bezier.bezierlength((seg[0], seg[1], seg[2], seg[3]), tolerance = tolerance) + return curveLen + + def effect(self): + scale = self.options.scale + scaleFrom = self.options.scaleFrom + tolerance = 10 ** (-1 * self.options.precision) + selections = self.svg.selected + pathNodes = self.svg.selection.filter(PathElement).values() + + paths = [(pathNode.get('id'), CubicSuperPath(pathNode.get('d'))) for pathNode in pathNodes] + + if self.options.override_selection is False: + if(len(paths) > 1): + srcPath = paths[-1][1] + srclen = self.getLength(srcPath, tolerance) + paths = paths[:len(paths)-1] + for key, cspath in paths: + curveLen = self.getLength(cspath, tolerance) + + self.scaleCubicSuper(cspath, scaleFactor = scale * (srclen / curveLen), scaleFrom = scaleFrom) + try: #we wrap this in try-except because if the elements are within groups it will cause errors + selections[key].set('d', CubicSuperPath(cspath)) + except: + pass + else: + inkex.errormsg("Please select at least two paths, with the path whose length is to be copied at the top. You may have to convert the shape to path with path->Object to Path.") + else: + if(len(paths) > 0): + srclen = self.svg.unittouu(str(self.options.custom_length) + self.options.unit) + for key, cspath in paths: + curveLen = self.getLength(cspath, tolerance) + + self.scaleCubicSuper(cspath, scaleFactor = scale * (srclen / curveLen), scaleFrom = scaleFrom) + try: #we wrap this in try-except because if the elements are within groups it will cause errors + selections[key].set('d', CubicSuperPath(cspath)) + except: + pass + else: + inkex.errormsg("Please select at least one path. You may have to convert the shape to path with path->Object to Path.") +if __name__ == '__main__': + ScaleToPathLength().run() diff --git a/extensions/fablabchemnitz/scale_to_real/meta.json b/extensions/fablabchemnitz/scale_to_real/meta.json new file mode 100644 index 0000000..a987e6b --- /dev/null +++ b/extensions/fablabchemnitz/scale_to_real/meta.json @@ -0,0 +1,21 @@ +[ + { + "name": "Scale To Real", + "id": "fablabchemnitz.de.scale_to_real", + "path": "scale_to_real", + "dependent_extensions": null, + "original_name": "RealScale", + "original_id": "org.inkscape.effect.realscale", + "license": "GNU GPL v3", + "license_url": "https://gitlab.com/Moini/inkscape-realscale-extension/-/blob/master/realscale_LICENSE", + "comment": "ported to Inkscape v1 by Mario Voigt", + "source_url": "https://gitea.fablabchemnitz.de/FabLab_Chemnitz/mightyscape-1.2/src/branch/master/extensions/fablabchemnitz/scale_to_real", + "fork_url": "https://gitlab.com/Moini/inkscape-realscale-extension", + "documentation_url": "", + "inkscape_gallery_url": null, + "main_authors": [ + "gitlab.com/Moini", + "github.com/eridur-de" + ] + } +] \ No newline at end of file diff --git a/extensions/fablabchemnitz/scale_to_real/scale_to_real.inx b/extensions/fablabchemnitz/scale_to_real/scale_to_real.inx new file mode 100644 index 0000000..390f66e --- /dev/null +++ b/extensions/fablabchemnitz/scale_to_real/scale_to_real.inx @@ -0,0 +1,89 @@ + + + Scale To Real + fablabchemnitz.de.scale_to_real + + + + 100.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 45 + + true + + + + + + + + + + + + + all + + + + + + + + \ No newline at end of file diff --git a/extensions/fablabchemnitz/scale_to_real/scale_to_real.py b/extensions/fablabchemnitz/scale_to_real/scale_to_real.py new file mode 100644 index 0000000..63369a5 --- /dev/null +++ b/extensions/fablabchemnitz/scale_to_real/scale_to_real.py @@ -0,0 +1,249 @@ +""" +Copyright (C) 2015 Maren Hachmann, marenhachmann@yahoo.com +Copyright (C) 2010 Blair Bonnett, blair.bonnett@gmail.com (parts from multiscale extension) +Copyright (C) 2005 Aaron Spike, aaron@ekips.org (parts from perspective extension) +Copyright (C) 2015 Giacomo Mirabassi, giacomo@mirabassi.it (parts from jpeg export extension) +Copyright (C) 2016 Neon22 @github (scale ruler) +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 3 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, see . +""" + +import math +import inkex +from inkex import Transform +from inkex.paths import CubicSuperPath +from lxml import etree + +### Scale Ruler +# inches = [1, 2, 4, 8, 16, 24, 32, 48, 64, 96, 128] +# metric = [1,2,5,10,20,50,100,200,250,500,1000,1250,2500] + +# TODO: +# - maybe turn dropdown for choosing scale type (metric/imperial/custom) into radio buttons? +# - scale font size +# - scale box-height better for small boxes +# - add ruler into current layer +# - add magnification e.g. 2:1 for small drawings + +class ScaleToReal(inkex.EffectExtension): + + def add_arguments(self, pars): + pars.add_argument('--tab') + pars.add_argument('--length', type=float, default=100.0, help='Length of scaling path in real-world units') + pars.add_argument('--unit', default='cm', help='Real-world unit') + pars.add_argument('--showscale', default='false', help='Show Scale Ruler') + pars.add_argument('--choosescale', default='all', help='Choose Scale') + pars.add_argument('--metric', default='1', help='Common metric scales') + pars.add_argument('--imperial',default='1', help='Common imperial scales') + pars.add_argument('--custom_scale', type=float, default=45, help='Custom scale') + pars.add_argument('--unitlength', type=int, default='1', help='Length of scale ruler') + + def calc_scale_center(self, p1x, p1y, p2x, p2y): + """ Use straight line as scaling center. + - determine which point is center on basis of quadrant the line is in. + - approx this by using center of line + 0,0 corresponds to UL corner of page + """ + scale_center = (0,0) # resulting scaling point + # calc page center + pagecenter_x = self.svg.unittouu(self.document.getroot().get('width'))/2 + pagecenter_y = self.svg.unittouu(self.document.getroot().get('height'))/2 + # calc minmax of straightline ref points + minx = min(p1x, p2x) + maxx = max(p1x, p2x) + miny = min(p1y, p2y) + maxy = max(p1y, p2y) + # simplifiy calc by using center of line to determine quadrant + line_x = p1x + (p2x - p1x)/2 + line_y = p1y + (p2y - p1y)/2 + # determine quadrant + if line_x < pagecenter_x: + # Left hand side + if line_y < pagecenter_y: + scale_center = (minx,miny) # UL + else: + scale_center = (minx,maxy) # LL + else: # Right hand side + if line_y < pagecenter_y: + scale_center = (maxx,miny) # UR + else: + scale_center = (maxx,maxy) # LR + #inkex.debug("%s %s,%s" % (scale_center, pagecenter_x*2, pagecenter_y*2)) + return scale_center + + def create_ruler(self, parent, width, pos, value, drawing_scale): + """ Draw Scale ruler + - Position above user's straightline. + - Ruler shows two units together. First one cut into 5 + - pos is a tuple e.g. (0,0) + + TODO: + - Fix font size for large and small rulers + - Fix line width for large and small rulers + """ + " Ruler is always 2 units long with 5 divs in the left half " + # Draw two boxes next to each other. Top half of right half of ruler is filled black + line_width = self.svg.unittouu('0.25 mm') + box_height = max(width/15, self.svg.unittouu('2 mm')) + font_height = 8 + White = '#ffffff' + Black = '#000000' + t = 'translate' + str(pos) + + # create clip in order to get an exact ruler width (without the outer half of the stroke) + path = '//svg:defs' + defslist = self.document.getroot().xpath(path, namespaces=inkex.NSS) + if len(defslist) > 0: + defs = defslist[0] + + clipPathData = {inkex.addNS('label', 'inkscape'):'rulerClipPath', 'clipPathUnits':'userSpaceOnUse', 'id':'rulerClipPath'} + clipPath = etree.SubElement(defs, 'clipPath', clipPathData) + clipBox = {'x':str(-width), 'y':'0.0', + 'width':str(width*2), 'height':str(box_height), + 'style':'fill:%s; stroke:none; fill-opacity:1;' % (Black)} + + etree.SubElement(clipPath, 'rect', clipBox) + + # create groups for scale rule and ruler + scale_group = etree.SubElement(parent, 'g', {inkex.addNS('label','inkscape'):'scale_group', 'transform':t}) + ruler_group = etree.SubElement(scale_group, 'g', {inkex.addNS('label','inkscape'):'ruler', 'clip-path':"url(#rulerClipPath)"}) + + # box for right half of ruler + boxR = {'x':'0.0', 'y':'0.0', + 'width':str(width), 'height':str(box_height), + 'style':'fill:%s; stroke:%s; stroke-width:%s; stroke-opacity:1; fill-opacity:1;' % (White, Black, line_width)} + etree.SubElement(ruler_group, 'rect', boxR) + # top half black + boxRf = {'x':'0.0', 'y':'0.0', + 'width':str(width), 'height':str(box_height/2), + 'style':'fill:%s; stroke:none; fill-opacity:1;' % (Black)} + etree.SubElement(ruler_group, 'rect', boxRf) + # Left half of ruler + boxL = {'x':str(-width), 'y':'0.0', + 'width':str(width), 'height':str(box_height), + 'style':'fill:%s; stroke:%s; stroke-width:%s; stroke-opacity:1; fill-opacity:1;' % (White, Black, line_width)} + etree.SubElement(ruler_group, 'rect', boxL) + # staggered black fills on left half + start = -width + for i in range(5): + boxRf = {'x':str(start), 'y':str((i+1)%2 * box_height/2), + 'width':str(width/5), 'height':str(box_height/2), + 'style':'fill:%s; stroke:none; fill-opacity:1;' % (Black)} + etree.SubElement(ruler_group, 'rect', boxRf) + start += width/5 + # text + textstyle = {'font-size': str(font_height)+ " px", + 'font-family': 'sans-serif', + 'text-anchor': 'middle', + 'text-align': 'center', + 'fill': Black } + text_atts = {'style': str(inkex.Style(textstyle)), + 'x': '0', 'y': str(-font_height/2) } + text = etree.SubElement(scale_group, 'text', text_atts) + text.text = "0" + text_atts = {'style': str(inkex.Style(textstyle)), + 'x': str(width), 'y': str(-font_height/2) } + text = etree.SubElement(scale_group, 'text', text_atts) + text.text = str(value) + + text_atts = {'style':str(inkex.Style(textstyle)), + 'x': str(-width), 'y': str(-font_height/2) } + text = etree.SubElement(scale_group, 'text', text_atts) + text.text = str(value) + # Scale note + text_atts = {'style':str(inkex.Style(textstyle)), + 'x': '0', 'y': str(-font_height*2.5) } + text = etree.SubElement(scale_group, 'text', text_atts) + text.text = "Scale 1:" + str(drawing_scale) + " (" + self.options.unit + ")" + + + def effect(self): + if len(self.options.ids) != 2: + inkex.errormsg("This extension requires two selected objects. The first selected object must be the straight line with two nodes.") + exit() + + # drawing that will be scaled is selected second, must be a single object + scalepath = self.svg.selected[self.options.ids[0]] + drawing = self.svg.selected[self.options.ids[1]] + + if scalepath.tag != inkex.addNS('path','svg'): + inkex.errormsg("The first selected object is not a path.\nPlease select a straight line with two nodes instead.") + exit() + + # apply its transforms to the scaling path, so we get the correct coordinates to calculate path length + scalepath.apply_transform() + + path = CubicSuperPath(scalepath.get('d')) + if len(path) < 1 or len(path[0]) < 2: + inkex.errormsg("This extension requires that the first selected path be two nodes long.") + exit() + + # We imagine the path is in the root layer, with no transforms: + # get its parent transforms (its own ones are already applied): + ct = scalepath.composed_transform() + # now we apply that matrix inversely to make it + # as large (or small) as it really is + path = path.transform(ct) + + # calculate path length + p1_x = path[0][0][1][0] + p1_y = path[0][0][1][1] + p2_x = path[0][1][1][0] + p2_y = path[0][1][1][1] + + p_length = self.svg.unittouu(str(distance((p1_x, p1_y),(p2_x, p2_y))) + self.svg.unit) + + # Find Drawing Scale + if self.options.choosescale == 'metric': + drawing_scale = int(self.options.metric) + elif self.options.choosescale == 'imperial': + drawing_scale = int(self.options.imperial) + elif self.options.choosescale == 'custom': + drawing_scale = self.options.custom_scale + + # calculate scaling center + center = self.calc_scale_center(p1_x, p1_y, p2_x, p2_y) + + # calculate scaling factor + target_length = self.svg.unittouu(str(self.options.length) + self.options.unit) + factor = (target_length / p_length) / drawing_scale + # inkex.debug("%s, %s %s" % (target_length, p_length, factor)) + + # Add scale ruler + if self.options.showscale == "true": + dist = int(self.options.unitlength) + + ruler_length = self.svg.unittouu(str(dist) + self.options.unit) / drawing_scale + ruler_pos = (p1_x + (p2_x - p1_x)/2, (p1_y + (p2_y - p1_y)/2) - self.svg.unittouu('4 mm')) + + # TODO: add into current layer instead + self.create_ruler(self.document.getroot(), ruler_length, ruler_pos, dist, drawing_scale) + + # Get drawing and current transformations + for obj in (scalepath, drawing): + # Scale both objects about the center, first translate back to origin + scale_matrix = [[1, 0.0, -center[0]], [0.0, 1, -center[1]]] + obj.transform = Transform(scale_matrix) @ obj.transform + # Then scale + scale_matrix = [[factor, 0.0, 0.0], [0.0, factor, 0.0]] + obj.transform = Transform(scale_matrix) @ obj.transform + # Then translate back to original scale center location + scale_matrix = [[1, 0.0, center[0]], [0.0, 1, center[1]]] + obj.transform = Transform(scale_matrix) @ obj.transform + +# Helper function +def distance(xy0, xy1): + x0, y0 = xy0 + x1, y1 = xy1 + return math.sqrt((x0-x1)*(x0-x1) + (y0-y1)*(y0-y1)) + +if __name__ == '__main__': + ScaleToReal().run() \ No newline at end of file diff --git a/extensions/fablabchemnitz/shape_recognition/meta.json b/extensions/fablabchemnitz/shape_recognition/meta.json new file mode 100644 index 0000000..4f01e77 --- /dev/null +++ b/extensions/fablabchemnitz/shape_recognition/meta.json @@ -0,0 +1,21 @@ +[ + { + "name": "Shape Recognition (Unstable)", + "id": "fablabchemnitz.de.shape_recognition", + "path": "shape_recognition", + "dependent_extensions": null, + "original_name": "Shape recognition", + "original_id": "qpad", + "license": "GNU GPL v3", + "license_url": "https://gitlab.com/qpad/InkShapeReco/-/blob/master/shapereco.py", + "comment": "I am not sure where i got the latest code from. I searched across the web.", + "source_url": "https://gitea.fablabchemnitz.de/FabLab_Chemnitz/mightyscape-1.2/src/branch/master/extensions/fablabchemnitz/shape_recognition", + "fork_url": "https://gitlab.com/qpad/InkShapeReco", + "documentation_url": "https://stadtfabrikanten.org/display/IFM/Shape+Recognition", + "inkscape_gallery_url": null, + "main_authors": [ + "gitlab.com/qpad", + "github.com/eridur-de" + ] + } +] \ No newline at end of file diff --git a/extensions/fablabchemnitz/shape_recognition/shape_recognition.inx b/extensions/fablabchemnitz/shape_recognition/shape_recognition.inx new file mode 100644 index 0000000..f88603f --- /dev/null +++ b/extensions/fablabchemnitz/shape_recognition/shape_recognition.inx @@ -0,0 +1,52 @@ + + + Shape Recognition (Unstable) + fablabchemnitz.de.shape_recognition + + + + false + true + + + + true + 10.0 + 0.2 + + true + + + + true + 0.500 + 0.48 + 0.50 + + + true + 0.3 + 0.025 + + + true + true + true + true + + + + path + + + + + + + + \ No newline at end of file diff --git a/extensions/fablabchemnitz/shape_recognition/shape_recognition.py b/extensions/fablabchemnitz/shape_recognition/shape_recognition.py new file mode 100644 index 0000000..c548133 --- /dev/null +++ b/extensions/fablabchemnitz/shape_recognition/shape_recognition.py @@ -0,0 +1,836 @@ +#!/usr/bin/env python +''' +Copyright (C) 2017 , Pierre-Antoine Delsart + +This file is part of InkscapeShapeReco. + +InkscapeShapeReco 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 3 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 InkscapeShapeReco; if not, write to the Free Software +Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + + + +Quick description: +This extension uses all selected path, ignoring all other selected objects. +It tries to regularize hand drawn paths BY : + - evaluating if the path is a full circle or ellipse + - else finding sequences of aligned points and replacing them by a simple segment. + - changing the segments angles to the closest remarkable angle (pi/2, pi/3, pi/6, etc...) + - eqalizing all segments lengths which are close to each other + - replacing 4 segments paths by a rectangle object if this makes sens (giving the correct rotation to the rectangle). + +Requires numpy. + +''' + +import sys +sys.path.append('/usr/share/inkscape/extensions') +import inkex +import gettext +_ = gettext.gettext + +# ************************************************************* +# debugging +def void(*l): + pass +def debug_on(*l): + sys.stderr.write(' '.join(str(i) for i in l) +'\n') +debug = void +#debug = debug_on + +from shaperrec import geometric +from shaperrec import internal +from shaperrec import groups +from shaperrec import manipulation +from shaperrec import extenders +from shaperrec import miscellaneous + +import numpy +numpy.set_printoptions(precision=3) + +class PreProcess(): + + def removeSmallEdge(paths, wTot, hTot): + """Remove small Path objects which stand between 2 Segments (or at the ends of the sequence). + Small means the bbox of the path is less then 5% of the mean of the 2 segments.""" + if len(paths)<2: + return + def getdiag(points): + xmin, ymin, w, h = geometric.computeBox(points) + return numpy.sqrt(w**2+h**2), w, h + removeSeg=[] + def remove(p): + removeSeg.append(p) + if hasattr(p, "__next__") : p.next.prev = p.prev + if p.prev: p.prev.next = p.__next__ if hasattr(p, "__next__") else None + p.effectiveNPoints =0 + debug(' --> remove !', p, p.length, len(p.points)) + for p in paths: + if len(p.points)==0 : + remove(p) + continue + # select only path between 2 segments + next, prev = p.__next__ if hasattr(p, "__next__") else None, p.prev + if next is None: next = prev + if prev is None: prev = next + if not (False if next == None else next.isSegment()) or not (False if prev == None else prev.isSegment()) : continue + #diag = getdiag(p.points) + diag, w, h = getdiag(p.points) + + debug(p, p.pointN, ' removing edge diag = ', diag, p.length, ' l=', next.length+prev.length, 'totDim ', (wTot, hTot)) + debug( ' ---> ', prev, next) + + #t TODO: his needs to be parameterized + # remove last or first very small in anycase + doRemove = prev==next and (diag < 0.05*(wTot+hTot)*0.5 ) + if not doRemove: + # check if this small + isLarge = diag > (next.length+prev.length)*0.1 # check size relative to neighbour + isLarge = isLarge or w > 0.2*wTot or h > 0.2*hTot # check size w.r.t total size + + # is it the small side of a long rectangle ? + dd = prev.distanceTo(next.pointN) + rect = abs(prev.unitv.dot(next.unitv))>0.98 and diag > dd*0.5 + doRemove = not( isLarge or rect ) + + if doRemove: + remove(p) + + if next != prev: + prev.setIntersectWithNext(next) + debug('removed Segments ', removeSeg) + for p in removeSeg: + paths.remove(p) + + def prepareParrallelize( segs): + """Group Segment by their angles (segments are grouped together if their deltAangle is within 0.15 rad) + The 'newAngle' member of segments in a group are then set to the mean angle of the group (where angles are all + considered in [-pi, pi]) + + segs : list of segments + """ + + angles = numpy.array([s.angle for s in segs ]) + angles[numpy.where(angles<0)] += geometric._pi # we care about direction, not angle orientation + clList = miscellaneous.clusterValues(angles, 0.30, refScaleAbs='abs')#was 15 + + pi = numpy.pi + for cl in clList: + anglecount = {} + for angle in angles[list(cl)]: + # #angleDeg = int(angle * 360.0 / (2.0*pi)) + if not angle in anglecount: + anglecount[angle] = 1 + else: + anglecount[angle] += 1 + + anglecount = {k: v for k, v in sorted(list(anglecount.items()), key=lambda item: item[1], reverse=True)} + meanA = anglecount.popitem()[0]#.items()[1]#sorted(anglecount.items(), key = lambda kv:(kv[1], kv[0]), reverse=True)[1][1] + #meanA = float(meanA) * (2.0*pi) / 360.0 + #meanA = angles[list(cl)].mean() + for i in cl: + seg = segs[i] + seg.newAngle = meanA if seg.angle>=0. else meanA-geometric._pi + + + def prepareDistanceEqualization(segs, relDelta=0.1): + """ Input segments are grouped according to their length : + - for each length L, find all other lengths within L*relDelta. of L. + - Find the larger of such subgroup. + - repeat the procedure on remaining lengths until none is left. + Each length in a group is set to the mean length of the group + + segs : a list of segments + relDelta : float, minimum relative distance. + """ + + lengths = numpy.array( [x.tempLength() for x in segs] ) + clusters = miscellaneous.clusterValues(lengths, relDelta) + + if len(clusters)==1: + # deal with special case with low num of segments + # --> don't let a single segment alone + if len(clusters[0])+1==len(segs): + clusters[0]=list(range(len(segs))) # all + + allDist = [] + for cl in clusters: + dmean = sum( lengths[i] for i in cl ) / len(cl) + allDist.append(dmean) + for i in cl: + segs[i].setNewLength(dmean) + debug( i, ' set newLength ', dmean, segs[i].length, segs[i].dumpShort()) + + return allDist + + + def prepareRadiusEqualization(circles, otherDists, relSize=0.2): + """group circles radius and distances into cluster. + Then set circles radius according to the mean of the clusters they belong to.""" + ncircles = len(circles) + lengths = numpy.array( [c.radius for c in circles]+otherDists ) + indices = numpy.array( list(range(ncircles+len(otherDists))) ) + clusters = miscellaneous.clusterValues(numpy.stack([ lengths, indices ], 1 ), relSize, refScaleAbs='local' ) + + debug('prepareRadiusEqualization radius ', repr(lengths)) + debug('prepareRadiusEqualization clusters ', clusters) + allDist = [] + for cl in clusters: + dmean = sum( lengths[i] for i in cl ) / len(cl) + #print cl , dmean , + allDist.append(dmean) + if len(cl)==1: + continue + for i in cl: + if i< ncircles: + circles[i].radius = dmean + debug(' post radius ', [c.radius for c in circles] ) + return allDist + + + def centerCircOnSeg(circles, segments, relSize=0.18): + """ move centers of circles onto the segments if close enough""" + for circ in circles: + circ.moved = False + for seg in segments: + for circ in circles: + d = seg.distanceTo(circ.center) + #debug( ' ', seg.projectPoint(circ.center)) + if d < circ.radius*relSize and not circ.moved : + circ.center = seg.projectPoint(circ.center) + circ.moved = True + + + def adjustToKnownAngle( paths): + """ Check current angle against remarkable angles. If close enough, change it + paths : a list of segments""" + for seg in paths: + a = seg.tempAngle() + i = (abs(geometric.vec_in_mPi_pPi(geometric.knownAngle - a) )).argmin() + seg.newAngle = geometric.knownAngle[i] + debug( ' Known angle ', seg, seg.tempAngle(), ' -> ', geometric.knownAngle[i]) + ## if abs(geometric.knownAngle[i] - a) < 0.08: + +class PostProcess(): + def mergeConsecutiveParralels(segments, options): + ignoreNext=False + newList=[] + for s in segments: + if ignoreNext: + ignoreNext=False + continue + if not s.isSegment(): + newList.append(s) + continue + if not hasattr(s, "__next__"): + newList.append(s) + continue + if not s.next.isSegment(): + newList.append(s) + continue + d = geometric.closeAngleAbs(s.angle, s.next.angle) + if d < options.segAngleMergePara: + debug("merging ", s.angle, s.next.angle ) + snew = s.mergedWithNext(doRefit=False) + ignoreNext=True + newList.append(snew) + else: + debug("notmerging ", s.angle, s.next.angle ) + newList.append(s) + if len(segments)>len(newList): + debug("merged parallel ", segments, '-->', newList) + return newList + + def uniformizeShapes(pathGroupList, options): + allSegs = [ p for g in pathGroupList for p in g.listOfPaths if p.isSegment() ] + + if options.doParrallelize: + PreProcess.prepareParrallelize(allSegs) + if options.doKnownAngle: + PreProcess.adjustToKnownAngle(allSegs) + + adjustAng = options.doKnownAngle or options.doParrallelize + + allShapeDist = [] + for g in [ group for group in pathGroupList if not isinstance(group, groups.Circle)]: + # first pass : independently per path + if adjustAng: + manipulation.adjustAllAngles(g.listOfPaths) + g.listOfPaths[:] = PostProcess.mergeConsecutiveParralels(g.listOfPaths, options) + if options.doEqualizeDist: + allShapeDist=allShapeDist + PreProcess.prepareDistanceEqualization([p for p in g.listOfPaths if p.isSegment()], options.shapeDistLocal ) ##0.30 + manipulation.adjustAllDistances([p for p in g.listOfPaths if p.isSegment()]) #findme was group.li.. + + ## # then 2nd global pass, with tighter criteria + if options.doEqualizeDist: + allShapeDist=PreProcess.prepareDistanceEqualization(allSegs, options.shapeDistGlobal) ##0.08 + for g in [ group for group in pathGroupList if not isinstance(group, groups.Circle)]: + manipulation.adjustAllDistances([p for p in g.listOfPaths if p.isSegment()]) + + #TODO: I think this is supposed to close thje paths and it is failing + for g in pathGroupList: + if g.isClosing and not isinstance(g, groups.Circle): + debug('Closing intersec ', g.listOfPaths[0].point1, g.listOfPaths[0].pointN ) + g.listOfPaths[-1].setIntersectWithNext(g.listOfPaths[0]) + + + circles=[ group for group in pathGroupList if isinstance(group, groups.Circle)] + if options.doEqualizeRadius: + PreProcess.prepareRadiusEqualization(circles, allShapeDist) + if options.doCenterCircOnSeg: + PreProcess.centerCircOnSeg(circles, allSegs) + + pathGroupList = [manipulation.toRemarkableShape(g) for g in pathGroupList] + return pathGroupList + +class FitShapes(): + def checkForCircle(points, tangents): + """Determine if the points and their tangents represent a circle + + The difficulty is to be able to recognize ellipse while avoiding paths small fluctuations a + nd false positive due to badly drawn rectangle or non-convex closed curves. + + Method : we consider angle of tangent as function of lenght on path. + For circles these are : angle = c1 x lenght + c0. (c1 ~1) + + We calculate dadl = d(angle)/d(length) and compare to c1. + We use 3 criteria : + * num(dadl > 6) : number of sharp angles + * length(dadl<0.3)/totalLength : lengths of straight lines within the path. + * totalLength/(2pi x radius) : fraction of lenght vs a plain circle + + Still failing to recognize elongated ellipses... + + """ + if len(points)<10: + return False, 0 + + if all(points[0]==points[-1]): # last exactly equals the first. + # Ignore last point for this check + points = points[:-1] + tangents = tangents[:-1] + #print 'Removed last ', points + xmin, ymin, w, h = geometric.computeBox( points) + diag2=(w*w+h*h) + + diag = numpy.sqrt(diag2)*0.5 + norms = numpy.sqrt(numpy.sum( tangents**2, 1 )) + + angles = numpy.arctan2( tangents[:, 1], tangents[:, 0] ) + #debug( 'angle = ', repr(angles)) + N = len(angles) + + deltas = points[1:] - points[:-1] + deltasD = numpy.concatenate([ [geometric.D(points[0], points[-1])/diag], numpy.sqrt(numpy.sum( deltas**2, 1 )) / diag] ) + + # locate and avoid the point when swicthing + # from -pi to +pi. The point is around the minimum + imin = numpy.argmin(angles) + debug(' imin ', imin) + angles = numpy.roll(angles, -imin) + deltasD = numpy.roll(deltasD, -imin) + n=int(N*0.1) + # avoid fluctuations by removing points around the min + angles=angles[n:-n] + deltasD=deltasD[n:-n] + deltasD = deltasD.cumsum() + N = len(angles) + + # smooth angles to avoid artificial bumps + angles = manipulation.smoothArray(angles, n=max(int(N*0.03), 2) ) + + deltaA = angles[1:] - angles[:-1] + deltasDD = (deltasD[1:] -deltasD[:-1]) + deltasDD[numpy.where(deltasDD==0.)] = 1e-5*deltasD[0] + dAdD = abs(deltaA/deltasDD) + belowT, count = True, 0 + for v in dAdD: + if v>6 and belowT: + count+=1 + belowT = False + belowT= (v<6) + + temp = (deltasD, angles, tangents, dAdD ) + fracStraight = numpy.sum(deltasDD[numpy.where(dAdD<0.3)])/(deltasD[-1]-deltasD[0]) + curveLength = deltasD[-1]/3.14 + #print "SSS ",count , fracStraight + if curveLength> 1.4 or fracStraight>0.4 or count > 6: + isCircle =False + else: + isCircle= (count < 4 and fracStraight<=0.3) or \ + (fracStraight<=0.1 and count<5) + + if not isCircle: + return False, 0 + + # It's a circle ! + radius = points - numpy.array([xmin+w*0.5, ymin+h*0.5]) + radius_n = numpy.sqrt(numpy.sum( radius**2, 1 )) # normalize + + mini = numpy.argmin(radius_n) + rmin = radius_n[mini] + maxi = numpy.argmax(radius_n) + rmax = radius_n[maxi] + # void points around maxi and mini to make sure the 2nd max is found + # on the "other" side + n = len(radius_n) + radius_n[maxi]=0 + radius_n[mini]=0 + for i in range(1, int(n/8+1)): + radius_n[(maxi+i)%n]=0 + radius_n[(maxi-i)%n]=0 + radius_n[(mini+i)%n]=0 + radius_n[(mini-i)%n]=0 + radius_n_2 = [ r for r in radius_n if r>0] + rmax_2 = max(radius_n_2) + rmin_2 = min(radius_n_2) # not good !! + anglemax = numpy.arccos( radius[maxi][0]/rmax)*numpy.sign(radius[maxi][1]) + return True, (xmin+w*0.5, ymin+h*0.5, 0.5*(rmin+rmin_2), 0.5*(rmax+rmax_2), anglemax) + + + def checkForArcs(points, tangents): + """Determine if the points and their tangents represent a circle + + The difficulty is to be able to recognize ellipse while avoiding paths small fluctuations a + nd false positive due to badly drawn rectangle or non-convex closed curves. + + Method : we consider angle of tangent as function of lenght on path. + For circles these are : angle = c1 x lenght + c0. (c1 ~1) + + We calculate dadl = d(angle)/d(length) and compare to c1. + We use 3 criteria : + * num(dadl > 6) : number of sharp angles + * length(dadl<0.3)/totalLength : lengths of straight lines within the path. + * totalLength/(2pi x radius) : fraction of lenght vs a plain circle + + Still failing to recognize elongated ellipses... + + """ + if len(points)<10: + return False, 0 + + if all(points[0]==points[-1]): # last exactly equals the first. + # Ignore last point for this check + points = points[:-1] + tangents = tangents[:-1] + print(('Removed last ', points)) + xmin, ymin, w, h = geometric.computeBox( points) + diag2=(w*w+h*h) + + diag = numpy.sqrt(diag2)*0.5 + norms = numpy.sqrt(numpy.sum( tangents**2, 1 )) + + angles = numpy.arctan2( tangents[:, 1], tangents[:, 0] ) + #debug( 'angle = ', repr(angles)) + N = len(angles) + + deltas = points[1:] - points[:-1] + deltasD = numpy.concatenate([ [geometric.D(points[0], points[-1])/diag], numpy.sqrt(numpy.sum( deltas**2, 1 )) / diag] ) + + # locate and avoid the point when swicthing + # from -pi to +pi. The point is around the minimum + imin = numpy.argmin(angles) + debug(' imin ', imin) + angles = numpy.roll(angles, -imin) + deltasD = numpy.roll(deltasD, -imin) + n=int(N*0.1) + # avoid fluctuations by removing points around the min + angles=angles[n:-n] + deltasD=deltasD[n:-n] + deltasD = deltasD.cumsum() + N = len(angles) + + # smooth angles to avoid artificial bumps + angles = manipulation.smoothArray(angles, n=max(int(N*0.03), 2) ) + + deltaA = angles[1:] - angles[:-1] + deltasDD = (deltasD[1:] -deltasD[:-1]) + deltasDD[numpy.where(deltasDD==0.)] = 1e-5*deltasD[0] + dAdD = abs(deltaA/deltasDD) + belowT, count = True, 0 + + + self.temp = (deltasD, angles, tangents, dAdD ) + #TODO: Loop over deltasDD searching for curved segments, no sharp bumps and a curve of at least 1/4 pi + curveStart = 0 + curveToTest= numpy.array([deltasDD[curveStart]]); + dAdDd = numpy.array([dAdD[curveStart]]) + v = dAdD[curveStart] + belowT= (v<6) + for i in range(1, deltasDD.size): + curveToTest = numpy.append(curveToTest, deltasDD[i]) + dAdDd = numpy.append(dAdDd, dAdD[i]) + fracStraight = numpy.sum(curveToTest[numpy.where(dAdDd<0.3)])/(deltasD[i]-deltasD[curveStart]) + curveLength = (deltasD[i]-deltasD[curveStart])/3.14 + + v = dAdD[i] + if v>6 and belowT: + count+=1 + belowT = False + belowT= (v<6) + inkex.debug("SSS "+str(count) +":"+ str(fracStraight)) + if curveLength> 1.4 or fracStraight>0.4 or count > 8: + inkex.debug("curveLengtha:" + str(curveLength) +"fracStraight:"+str(fracStraight)+"count:"+str(count)) + isArc=False + curveStart=int(i) + curveToTest= numpy.array([deltasDD[curveStart]]); + v = dAdD[curveStart] + dAdDd = numpy.array([dAdD[curveStart]]) + belowT= (v<6) + count = 0 + continue + else: + inkex.debug("curveLengthb:" + str(curveLength) +"fracStraight:"+str(fracStraight)+"count:"+str(count)) + isArc= (count < 4 and fracStraight<=0.3) or \ + (fracStraight<=0.1 and count<5) + + if not isArc: + return False, 0 + + # It's a circle ! + radius = points - numpy.array([xmin+w*0.5, ymin+h*0.5]) + radius_n = numpy.sqrt(numpy.sum( radius**2, 1 )) # normalize + + mini = numpy.argmin(radius_n) + rmin = radius_n[mini] + maxi = numpy.argmax(radius_n) + rmax = radius_n[maxi] + # void points around maxi and mini to make sure the 2nd max is found + # on the "other" side + n = len(radius_n) + radius_n[maxi]=0 + radius_n[mini]=0 + for i in range(1, int(n/8+1)): + radius_n[(maxi+i)%n]=0 + radius_n[(maxi-i)%n]=0 + radius_n[(mini+i)%n]=0 + radius_n[(mini-i)%n]=0 + radius_n_2 = [ r for r in radius_n if r>0] + rmax_2 = max(radius_n_2) + rmin_2 = min(radius_n_2) # not good !! + anglemax = numpy.arccos( radius[maxi][0]/rmax)*numpy.sign(radius[maxi][1]) + return True, (xmin+w*0.5, ymin+h*0.5, 0.5*(rmin+rmin_2), 0.5*(rmax+rmax_2), anglemax) + + + + + def tangentEnvelop(svgCommandsList, refNode, options): + a, svgCommandsList = geometric.toArray(svgCommandsList) + tangents = manipulation.buildTangents(a) + + newSegs = [ internal.Segment.fromCenterAndDir( p, t ) for (p, t) in zip(a, tangents) ] + debug("build envelop ", newSegs[0].point1, newSegs[0].pointN) + clustersInd = manipulation.clusterAngles( [s.angle for s in newSegs] ) + debug("build envelop cluster: ", clustersInd) + + return TangentEnvelop( newSegs, svgCommandsList, refNode) + + def isClosing(wTot, hTot, d): + aR = min(wTot/hTot, hTot/wTot) + maxDim = max(wTot, hTot) + # was 0.2 + return aR*0.5 > d/maxDim + + + def curvedFromTangents(svgCommandsList, refNode, x, y, wTot, hTot, d, isClosing, sourcepoints, tangents, options): + +# debug('isClosing ', isClosing, maxDim, d) + + # global quantities : + hasArcs = False + res = () + # Check if circle ----------------------- + if isClosing: + if len(sourcepoints)<9: + return groups.PathGroup.toSegments(sourcepoints, svgCommandsList, refNode, isClosing=True) + isCircle, res = FitShapes.checkForCircle( sourcepoints, tangents) + debug("Is Circle = ", isCircle ) + if isCircle: + x, y, rmin, rmax, angle = res + debug("Circle -> ", rmin, rmax, angle ) + if rmin/rmax>0.7: + circ = groups.Circle((x, y), 0.5*(rmin+rmax), refNode ) + else: + circ = groups.Circle((x, y), rmin, refNode, rmax=rmax, angle=angle) + circ.points = sourcepoints + return circ + #else: + # hasArcs, res = FitShapes.checkForArcs( sourcepoints, tangents) + #else: + #hasArcs, res = FitShapes.checkForArcs( sourcepoints, tangents) + # ----------------------- + if hasArcs: + x, y, rmin, rmax, angle = res + debug("Circle -> ", rmin, rmax, angle ) + if rmin/rmax>0.7: + circ = groups.Circle((x, y), 0.5*(rmin+rmax), refNode ) + else: + circ = groups.Circle((x, y), rmin, refNode, rmax=rmax, angle=angle) + circ.points = sourcepoints + return circ + return None + + def segsFromTangents(svgCommandsList, refNode, options): + """Finds segments part in a list of points represented by svgCommandsList. + + The method is to build the (averaged) tangent vectors to the curve. + Aligned points will have tangent with similar angle, so we cluster consecutive angles together + to define segments. + Then we extend segments to connected points not already part of other segments. + Then we merge consecutive segments with similar angles. + + """ + sourcepoints, svgCommandsList = geometric.toArray(svgCommandsList) + + d = geometric.D(sourcepoints[0], sourcepoints[-1]) + x, y, wTot, hTot = geometric.computeBox(sourcepoints) + if wTot == 0: wTot = 0.001 + if hTot == 0: hTot = 0.001 + if d==0: + # then we remove the last point to avoid null distance + # in other calculations + sourcepoints = sourcepoints[:-1] + svgCommandsList = svgCommandsList[:-1] + + isClosing = FitShapes.isClosing(wTot, hTot, d) + + if len(sourcepoints) < 4: + return groups.PathGroup.toSegments(sourcepoints, svgCommandsList, refNode, isClosing=isClosing) + + tangents = manipulation.buildTangents(sourcepoints, isClosing=isClosing) + + aCurvedSegment = FitShapes.curvedFromTangents(svgCommandsList, refNode, x, y, wTot, hTot, d, isClosing, sourcepoints, tangents, options) + + if not aCurvedSegment == None: + return aCurvedSegment + + # cluster points by angle of their tangents ------------- + tgSegs = [ internal.Segment.fromCenterAndDir( p, t ) for (p, t) in zip(sourcepoints, tangents) ] + clustersInd = sorted(manipulation.clusterAngles( [s.angle for s in tgSegs] )) + debug("build envelop cluster: ", clustersInd) + + # build Segments from clusters + newSegs = [] + for imin, imax in clustersInd: + if imin+1< imax: # consider clusters with more than 3 points + seg = manipulation.fitSingleSegment(sourcepoints[imin:imax+1]) + elif imin+1==imax: # 2 point path : we build a segment + seg = internal.Segment.from2Points(sourcepoints[imin], sourcepoints[imax], sourcepoints[imin:imax+1]) + else: + seg = internal.Path( sourcepoints[imin:imax+1] ) + seg.sourcepoints = sourcepoints + newSegs.append( seg ) + manipulation.resetPrevNextSegment( newSegs ) + debug(newSegs) + # ----------------------- + + + # ----------------------- + # Merge consecutive Path objects + updatedSegs=[] + def toMerge(p): + l=[p] + setattr(p, 'merged', True) + if hasattr(p, "__next__") and not p.next.isSegment(): + l += toMerge(p.next) + return l + + for i, seg in enumerate(newSegs[:-1]): + if seg.isSegment(): + updatedSegs.append( seg) + continue + if hasattr(seg, 'merged'): continue + mergeList = toMerge(seg) + debug('merging ', mergeList) + p = internal.Path(numpy.concatenate([ p.points for p in mergeList]) ) + debug('merged == ', p.points) + updatedSegs.append(p) + + if not hasattr(newSegs[-1], 'merged'): updatedSegs.append( newSegs[-1]) + debug("merged path", updatedSegs) + newSegs = manipulation.resetPrevNextSegment( updatedSegs ) + + + # Extend segments ----------------------------------- + if options.segExtensionEnable: + newSegs = extenders.SegmentExtender.extendSegments( newSegs, options.segExtensionDtoSeg, options.segExtensionQual ) + debug("extended segs", newSegs) + newSegs = manipulation.resetPrevNextSegment( newSegs ) + debug("extended segs", newSegs) + + # ---------------------------------------- + + + # --------------------------------------- + # merge consecutive segments with close angle + updatedSegs=[] + + if options.segAngleMergeEnable: + newSegs = miscellaneous.mergeConsecutiveCloseAngles( newSegs, mangle=options.segAngleMergeTol1 ) + newSegs=manipulation.resetPrevNextSegment(newSegs) + debug(' __ 2nd angle merge') + newSegs = miscellaneous.mergeConsecutiveCloseAngles( newSegs, mangle=options.segAngleMergeTol2 ) # 2nd pass + newSegs=manipulation.resetPrevNextSegment(newSegs) + debug('after merge ', len(newSegs), newSegs) + # Check if first and last also have close angles. + if isClosing and len(newSegs)>2 : + first, last = newSegs[0], newSegs[-1] + if first.isSegment() and last.isSegment(): + if geometric.closeAngleAbs( first.angle, last.angle) < 0.1: + # force merge + points= numpy.concatenate( [ last.points, first.points] ) + newseg = manipulation.fitSingleSegment(points) + newseg.next = first.__next__ if hasattr(first, "__next__") else None + last.prev.next = None + newSegs[0]=newseg + newSegs.pop() + + # ----------------------------------------------------- + # remove negligible Path/Segments between 2 large Segments + if options.segRemoveSmallEdge: + PreProcess.removeSmallEdge(newSegs, wTot, hTot) + newSegs=manipulation.resetPrevNextSegment(newSegs) + + debug('after remove small ', len(newSegs), newSegs) + # ----------------------------------------------------- + + # ----------------------------------------------------- + # Extend segments to their intersections + for p in newSegs: + if p.isSegment() and hasattr(p, "__next__"): + p.setIntersectWithNext() + # ----------------------------------------------------- + + return groups.PathGroup(newSegs, svgCommandsList, refNode, isClosing) + + + + +# ************************************************************* +# The inkscape extension +# ************************************************************* +class ShapeRecognition(inkex.EffectExtension): + + def add_arguments(self, pars): + pars.add_argument("--title") + pars.add_argument("--keepOrigin", dest="keepOrigin", default=False, type=inkex.Boolean, help="Do not replace path") + pars.add_argument("--MainTabs") + pars.add_argument("--segExtensionDtoSeg", dest="segExtensionDtoSeg", default=0.03, type=float, help="max distance from point to segment") + pars.add_argument("--segExtensionQual", dest="segExtensionQual", default=0.5, type=float, help="segment extension fit quality") + pars.add_argument("--segExtensionEnable", dest="segExtensionEnable", default=True, type=inkex.Boolean, help="Enable segment extension") + pars.add_argument("--segAngleMergeEnable", dest="segAngleMergeEnable", default=True, type=inkex.Boolean, help="Enable merging of almost aligned consecutive segments") + pars.add_argument("--segAngleMergeTol1", dest="segAngleMergeTol1", default=0.2, type=float, help="merging with tollarance 1") + pars.add_argument("--segAngleMergeTol2", dest="segAngleMergeTol2", default=0.35, type=float, help="merging with tollarance 2") + pars.add_argument("--segAngleMergePara", dest="segAngleMergePara", default=0.001, type=float, help="merge lines as parralels if they fit") + pars.add_argument("--segRemoveSmallEdge", dest="segRemoveSmallEdge", default=True, type=inkex.Boolean, help="Enable removing very small segments") + pars.add_argument("--doUniformization", dest="doUniformization", default=True, type=inkex.Boolean, help="Preform angles and distances uniformization") + for opt in ["doParrallelize", "doKnownAngle", "doEqualizeDist", "doEqualizeRadius", "doCenterCircOnSeg"]: + pars.add_argument( "--"+opt, dest=opt, default=True, type=inkex.Boolean, help=opt) + pars.add_argument("--shapeDistLocal", dest="shapeDistLocal", default=0.3, type=float, help="Pthe percentage of difference at which we make lengths equal, locally") + pars.add_argument("--shapeDistGlobal", dest="shapeDistGlobal", default=0.025, type=float, help="Pthe percentage of difference at which we make lengths equal, globally") + + + + def effect(self): + + rej='{http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd}type' + paths = [] + for id, node in list(self.svg.selected.items()): + if node.tag == '{http://www.w3.org/2000/svg}path' and rej not in list(node.keys()): + paths.append(node) + + shapes = self.extractShapes(paths) + # add new shapes in SVG document + self.addShapesToDoc( shapes ) + + def extractShapesFromID( self, *nids, **options ): + """for debugging purpose """ + eList = [] + for nid in nids: + el = self.getElementById(nid) + if el is None: + print(("Cant find ", nid)) + return + eList.append(el) + class tmp: + pass + + self.options = self.OptionParser.parse_args()[0] + self.options._update_careful(options) + nodes=self.extractShapes(eList) + self.shape = nodes[0] + + + def buildShape(self, node): + def rotationAngle(tr): + if tr and tr.startswith('rotate'): + # retrieve the angle : + return float(tr[7:-1].split(',')) + else: + return 0. + + if node.tag.endswith('path'): + g = FitShapes.segsFromTangents(node.path.to_arrays(), node, self.options) + elif node.tag.endswith('rect'): + tr = node.get('transform', None) + if tr and tr.startswith('matrix'): + return None # can't deal with scaling + recSize = numpy.array([node.get('width'), node.get('height')]) + recCenter = numpy.array([node.get('x'), node.get('y')]) + recSize/2 + angle=rotationAngle(tr) + g = groups.Rectangle( recSize, recCenter, 0, [], node) + elif node.tag.endswith('circle'): + g = groups.Circle(node.get('cx'), node.get('cy'), node.get('r'), [], node ) + elif node.tag.endswith('ellipse'): + if tr and tr.startswith('matrix'): + return None # can't deal with scaling + angle=rotationAngle(tr) + rx = node.get('rx') + ry = node.get('ry') + g = groups.Circle(node.get('cx'), node.get('cy'), ry, rmax=rx, angle=angle, refNode=node) + + return g + + def extractShapes( self, nodes ): + """The main function. + nodes : a list of nodes""" + analyzedNodes = [] + + # convert nodes to list of segments (groups.PathGroup) or Circle + for n in nodes : + g = self.buildShape(n) + if g : + analyzedNodes.append( g ) + + # uniformize shapes + if self.options.doUniformization: + analyzedNodes = PostProcess.uniformizeShapes(analyzedNodes, self.options) + + return analyzedNodes + + def addShapesToDoc(self, pathGroupList): + for group in pathGroupList: + + debug("final ", group.listOfPaths, group.refNode ) + debug("final-style ", group.refNode.get('style')) + # change to Rectangle if possible : + finalshape = manipulation.toRemarkableShape( group ) + ele = group.addToNode( group.refNode) + group.setNodeStyle(ele, group.refNode) + if not self.options.keepOrigin: + group.refNode.xpath('..')[0].remove(group.refNode) + + + +if __name__ == '__main__': + ShapeRecognition().run() \ No newline at end of file diff --git a/extensions/fablabchemnitz/shape_recognition/shaperrec/extenders.py b/extensions/fablabchemnitz/shape_recognition/shaperrec/extenders.py new file mode 100644 index 0000000..29c7712 --- /dev/null +++ b/extensions/fablabchemnitz/shape_recognition/shaperrec/extenders.py @@ -0,0 +1,130 @@ +import numpy +import sys +from shaperrec import manipulation + +# ************************************************************* +# debugging +def void(*l): + pass +def debug_on(*l): + sys.stderr.write(' '.join(str(i) for i in l) +'\n') +debug = void +#debug = debug_on + +##************************************** +## +class SegmentExtender: + """Extend Segments part of a list of Path by aggregating points from neighbouring Path objects. + + There are 2 concrete subclasses for extending forward and backward (due to technical reasons). + """ + + def __init__(self, relD, fitQ): + self.relD = relD + self.fitQ = fitQ + + def nextPaths(self, seg): + pL = [] + p = self.getNext(seg) # prev or next + while p : + if p.isSegment(): break + if p.mergedObj is None: break + pL.append(p) + p = self.getNext(p) + if pL==[]: + return [] + return pL + + def extend(self, seg): + nextPathL = self.nextPaths(seg) + debug('extend ', self.extDir, seg, nextPathL, seg.length, len(nextPathL)) + if nextPathL==[]: return seg + pointsToTest = numpy.concatenate( [p.points for p in nextPathL] ) + mergeD = seg.length*self.relD + #print seg.point1 , seg.pointN, pointsToTest + pointsToFit, addedPoints = self.pointsToFit(seg, pointsToTest, mergeD) + if len(pointsToFit)==0: + return seg + newseg = manipulation.fitSingleSegment(pointsToFit) + if newseg.quality()>self.fitQ: # fit failed + return seg + debug( ' EXTENDING ! ', len(seg.points), len(addedPoints) ) + self.removePath(seg, newseg, nextPathL, addedPoints ) + newseg.points = pointsToFit + seg.mergedObj= newseg + newseg.sourcepoints = seg.sourcepoints + + return newseg + + @staticmethod + def extendSegments(segmentList, relD=0.03, qual=0.5): + """Perform Segment extension from list of Path segmentList + returns the updated list of Path objects""" + fwdExt = FwdExtender(relD, qual) + bwdExt = BwdExtender(relD, qual) + # tag all objects with an attribute pointing to the extended object + for seg in segmentList: + seg.mergedObj = seg # by default the extended object is self + # extend each segments, starting by the longest + for seg in sorted(segmentList, key = lambda s : s.length, reverse=True): + if seg.isSegment(): + newseg=fwdExt.extend(seg) + seg.mergedObj = bwdExt.extend(newseg) + # the extension procedure has marked as None the mergedObj + # which have been swallowed by an extension. + # filter them out : + updatedSegs=[seg.mergedObj for seg in segmentList if seg.mergedObj] + return updatedSegs + + +class FwdExtender(SegmentExtender): + extDir='Fwd' + def getNext(self, seg): + return seg.__next__ if hasattr(seg, "__next__") else None + def pointsToFit(self, seg, pointsToTest, mergeD): + distancesToLine =abs(seg.a*pointsToTest[:, 0]+seg.b*pointsToTest[:, 1]+seg.c) + goodInd=len(pointsToTest) + for i, d in reversed(list(enumerate(distancesToLine))): + if dnexp: + np = int(deltaD[ind]/medDelta) + pL = [a[ind]] + #print i,'=',ind,'adding ', np,' _ ', deltaD[ind], a[ind], a[ind+1] + for j in range(np-1): + f = float(j+1)/np + #print '------> ', (1-f)*a[ind]+f*a[ind+1] + pL.append( (1-f)*a[ind]+f*a[ind+1] ) + newpoints[ind] = pL + else: + newpoints[ind]=[a[ind]] + if(D(a[0], a[-1])/sizeC > 0.005 ) : + newpoints[-1]=[a[-1]] + + points = numpy.concatenate([p for p in newpoints if p!=None] ) +# ## print ' medDelta ', medDelta, deltaD[sortedDind[-1]] +# ## print len(a) ,' ------> ', len(points) + + rel_norms = numpy.sqrt(numpy.sum( deltas**2, 1 )) / sizeC + keep = numpy.concatenate([numpy.where( rel_norms >0.005 )[0], numpy.array([len(a)-1])]) + + #return a[keep] , [ parsedList[i] for i in keep] + #print len(a),' ',len(points) + return points, [] + +rotMat = numpy.array( [[1, -1], [1, 1]] )/numpy.sqrt(2) +unrotMat = numpy.array( [[1, 1], [-1, 1]] )/numpy.sqrt(2) + +def setupKnownAngles(): + pi = numpy.pi + #l = [ i*pi/8 for i in range(0, 9)] +[ i*pi/6 for i in [1,2,4,5,] ] + l = [ i*pi/8 for i in range(0, 9)] +[ i*pi/6 for i in [1, 2, 4, 5,] ] + [i*pi/12 for i in (1, 5, 7, 11)] + knownAngle = numpy.array( l ) + return numpy.concatenate( [-knownAngle[:0:-1], knownAngle ]) +knownAngle = setupKnownAngles() + +_twopi = 2*numpy.pi +_pi = numpy.pi + +def deltaAngle(a1, a2): + d = a1 - a2 + return d if d > -_pi else d+_twopi + +def closeAngleAbs(a1, a2): + d = abs(a1 - a2 ) + return min( abs(d-_pi), abs( _twopi - d), d) + +def deltaAngleAbs(a1, a2): + return abs(in_mPi_pPi(a1 - a2 )) + +def in_mPi_pPi(a): + if(a>_pi): return a-_twopi + if(a<-_pi): return a+_twopi + return a +vec_in_mPi_pPi = numpy.vectorize(in_mPi_pPi) + +def D2(p1, p2): + return ((p1-p2)**2).sum() + +def D(p1, p2): + return numpy.sqrt(D2(p1, p2) ) + +def norm(p): + return numpy.sqrt( (p**2).sum() ) + +def computeBox(a): + """returns the bounding box enclosing the array of points a + in the form (x,y, width, height) """ + + xmin, ymin = a[:, 0].min(), a[:, 1].min() + xmax, ymax = a[:, 0].max(), a[:, 1].max() + return xmin, ymin, xmax-xmin, ymax-ymin + +def dirAndLength(p1, p2): + #l = max(D(p1, p2) ,1e-4) + l = D(p1, p2) + uv = (p1-p2)/l + return l, uv + +def length(p1, p2): + return numpy.sqrt( D2(p1, p2) ) + +def barycenter(points): + """ + """ + return points.sum(axis=0)/len(points) \ No newline at end of file diff --git a/extensions/fablabchemnitz/shape_recognition/shaperrec/groups.py b/extensions/fablabchemnitz/shape_recognition/shaperrec/groups.py new file mode 100644 index 0000000..4016f23 --- /dev/null +++ b/extensions/fablabchemnitz/shape_recognition/shaperrec/groups.py @@ -0,0 +1,278 @@ +import numpy +import sys +import inkex +from lxml import etree +from shaperrec import geometric +from shaperrec import miscellaneous +from shaperrec import internal +from shaperrec import manipulation + +# ************************************************************* +# debugging +def void(*l): + pass +def debug_on(*l): + sys.stderr.write(' '.join(str(i) for i in l) +'\n') +debug = void +#debug = debug_on + +# ************************************************************* +# ************************************************************* +# Groups of Path +# +class PathGroup(object): + """A group of Path representing one SVG node. + - a list of Path + - a list of SVG commands describe the full node (=SVG path element) + - a reference to the inkscape node object + + """ + listOfPaths = [] + refSVGPathList = [] + isClosing = False + refNode = None + + def __init__(self, listOfPaths, refSVGPathList, refNode=None, isClosing=False): + self.refNode = refNode + self.listOfPaths = listOfPaths + self.refSVGPathList = refSVGPathList + self.isClosing=isClosing + + def addToNode(self, node): + newList = miscellaneous.reformatList( self.listOfPaths) + ele = miscellaneous.addPath( newList, node) + debug("PathGroup ", newList) + return ele + + def setNodeStyle(self, ele, node): + style = node.get('style') + cssClass = node.get('class') + debug("style ", style) + debug("class ", cssClass) + if style == None and cssClass == None : + style = 'fill:none; stroke:red; stroke-width:1' + + if not cssClass == None: + ele.set('class', cssClass) + if not style == None: + ele.set('style', style) + + @staticmethod + def toSegments(points, refSVGPathList, refNode, isClosing=False): + """ + """ + segs = [ internal.Segment.from2Points(p, points[i+1], points[i:i+2] ) for (i, p) in enumerate(points[:-1]) ] + manipulation.resetPrevNextSegment(segs) + return PathGroup( segs, refSVGPathList, refNode, isClosing) + +class TangentEnvelop(PathGroup): + """Specialization where the Path objects are all Segments and represent tangents to a curve """ + def addToNode(self, node): + newList = [ ] + for s in self.listOfPaths: + newList += s.asSVGCommand(firstP=True) + debug("TangentEnvelop ", newList) + ele = miscellaneous.addPath( newList, node) + return ele + + def setNodeStyle(self, ele, node): + style = node.get('style')+';marker-end:url(#Arrow1Lend)' + ele.set('style', style) + + +class Circle(PathGroup): + """Specialization where the list of Path objects + is to be replaced by a Circle specified by a center and a radius. + + If an other radius 'rmax' is given than the object represents an ellipse. + """ + isClosing= True + def __init__(self, center, rad, refNode=None, rmax=None, angle=0.): + self.listOfPaths = [] + self.refNode = refNode + self.center = numpy.array(center) + self.radius = rad + if rmax: + self.type ='ellipse' + else: + self.type = 'circle' + self.rmax = rmax + self.angle = angle + + def addToNode(self, refnode): + """Add a node in the xml structure corresponding to this rect + refnode : xml node used as a reference, new point will be inserted a same level""" + ele = etree.Element('{http://www.w3.org/2000/svg}'+self.type) + + ele.set('cx', str(self.center[0])) + ele.set('cy', str(self.center[1])) + if self.rmax: + ele.set('ry', str(self.radius)) + ele.set('rx', str(self.rmax)) + ele.set('transform', 'rotate(%3.2f,%f,%f)'%(numpy.degrees(self.angle), self.center[0], self.center[1])) + else: + ele.set('r', str(self.radius)) + refnode.xpath('..')[0].append(ele) + return ele + + +class Rectangle(PathGroup): + """Specialization where the list of Path objects + is to be replaced by a Rectangle specified by a center and size (w,h) and a rotation angle. + + """ + def __init__(self, center, size, angle, listOfPaths, refNode=None): + self.listOfPaths = listOfPaths + self.refNode = refNode + self.center = center + self.size = size + self.bbox = size + self.angle = angle + pos = self.center - numpy.array( size )/2 + if angle != 0. : + cosa = numpy.cos(angle) + sina = numpy.sin(angle) + self.rotMat = numpy.matrix( [ [ cosa, sina], [-sina, cosa] ] ) + self.rotMatstr = 'matrix(%1.7f,%1.7f,%1.7f,%1.7f,0,0)'%(cosa, sina, -sina, cosa) + + + #debug(' !!!!! Rotated rectangle !!', self.size, self.bbox, ' angles ', a, self.angle ,' center',self.center) + else : + self.rotMatstr = None + self.pos = pos + debug(' !!!!! Rectangle !!', self.size, self.bbox, ' angles ', self.angle, ' center', self.center) + + def addToNode(self, refnode): + """Add a node in the xml structure corresponding to this rect + refnode : xml node used as a reference, new point will be inserted a same level""" + ele = etree.Element('{http://www.w3.org/2000/svg}rect') + self.fill(ele) + refnode.xpath('..')[0].append(ele) + return ele + + def fill(self, ele): + w, h = self.size + ele.set('width', str(w)) + ele.set('height', str(h)) + w, h = self.bbox + ele.set('x', str(self.pos[0])) + ele.set('y', str(self.pos[1])) + if self.rotMatstr: + ele.set('transform', 'rotate(%3.2f,%f,%f)'%(numpy.degrees(self.angle), self.center[0], self.center[1])) + #ele.set('transform', self.rotMatstr) + + @staticmethod + def isRectangle( pathGroup): + """Check if the segments in pathGroups can form a rectangle. + Returns a Rectangle or None""" + #print 'xxxxxxxx isRectangle',pathGroups + if isinstance(pathGroup, Circle ): return None + segmentList = [p for p in pathGroup.listOfPaths if p.isSegment() ]#or p.effectiveNPoints >0] + if len(segmentList) != 4: + debug( 'rectangle Failed at length ', len(segmentList)) + return None + a, b, c, d = segmentList + + if geometric.length(a.point1, d.pointN)> 0.2*(a.length+d.length)*0.5: + debug('rectangle test failed closing ', geometric.length(a.point1, d.pointN), a.length, d.length) + return None + + Aac, Abd = geometric.closeAngleAbs(a.angle, c.angle), geometric.closeAngleAbs(b.angle, d.angle) + if min(Aac, Abd) > 0.07 or max(Aac, Abd) >0.27 : + debug( 'rectangle Failed at angles', Aac, Abd) + return None + notsimilarL = lambda d1, d2: abs(d1-d2)>0.20*min(d1, d2) + + pi, twopi = numpy.pi, 2*numpy.pi + angles = numpy.array( [p.angle for p in segmentList] ) + minAngleInd = numpy.argmin( numpy.minimum( abs(angles), abs( abs(angles)-pi), abs( abs(angles)-twopi) ) ) + rotAngle = angles[minAngleInd] + width = (segmentList[minAngleInd].length + segmentList[(minAngleInd+2)%4].length)*0.5 + height = (segmentList[(minAngleInd+1)%4].length + segmentList[(minAngleInd+3)%4].length)*0.5 + # set rectangle center as the bbox center + x, y, w, h = geometric.computeBox( numpy.concatenate( [ p.points for p in segmentList]) ) + r = Rectangle( numpy.array( [x+w/2, y+h/2]), (width, height), rotAngle, pathGroup.listOfPaths, pathGroup.refNode) + + debug( ' found a rectangle !! ', a.length, b.length, c.length, d.length ) + return r + + +class CurveGroup(PathGroup): + """Specialization where the list of Path objects + is to be replaced by a Rectangle specified by a center and size (w,h) and a rotation angle. + + """ + def __init__(self, center, size, angle, listOfPaths, refNode=None): + self.listOfPaths = listOfPaths + self.refNode = refNode + self.center = center + self.size = size + self.bbox = size + self.angle = angle + pos = self.center - numpy.array( size )/2 + if angle != 0. : + cosa = numpy.cos(angle) + sina = numpy.sin(angle) + self.rotMat = numpy.matrix( [ [ cosa, sina], [-sina, cosa] ] ) + self.rotMatstr = 'matrix(%1.7f,%1.7f,%1.7f,%1.7f,0,0)'%(cosa, sina, -sina, cosa) + + + #debug(' !!!!! Rotated rectangle !!', self.size, self.bbox, ' angles ', a, self.angle ,' center',self.center) + else : + self.rotMatstr = None + self.pos = pos + debug(' !!!!! Rectangle !!', self.size, self.bbox, ' angles ', self.angle, ' center', self.center) + + def addToNode(self, refnode): + """Add a node in the xml structure corresponding to this rect + refnode : xml node used as a reference, new point will be inserted a same level""" + ele = etree.Element('{http://www.w3.org/2000/svg}rect') + self.fill(ele) + refnode.xpath('..')[0].append(ele) + return ele + +# def fill(self, ele): +# w, h = self.size +# ele.set('width', str(w)) +# ele.set('height', str(h)) +# w, h = self.bbox +# ele.set('x', str(self.pos[0])) +# ele.set('y', str(self.pos[1])) +# if self.rotMatstr: +# ele.set('transform', 'rotate(%3.2f,%f,%f)'%(numpy.degrees(self.angle), self.center[0], self.center[1])) +# #ele.set('transform', self.rotMatstr) + + @staticmethod + def isCurvedSegment( pathGroup): + """Check if the segments in pathGroups can form a rectangle. + Returns a Rectangle or None""" + #print 'xxxxxxxx isRectangle',pathGroups + if isinstance(pathGroup, Circle ): return None + segmentList = [p for p in pathGroup.listOfPaths if p.isSegment() ]#or p.effectiveNPoints >0] + if len(segmentList) != 4: + debug( 'rectangle Failed at length ', len(segmentList)) + return None + a, b, c, d = segmentList + + if geometric.length(a.point1, d.pointN)> 0.2*(a.length+d.length)*0.5: + debug('rectangle test failed closing ', geometric.length(a.point1, d.pointN), a.length, d.length) + return None + + Aac, Abd = geometric.closeAngleAbs(a.angle, c.angle), geometric.closeAngleAbs(b.angle, d.angle) + if min(Aac, Abd) > 0.07 or max(Aac, Abd) >0.27 : + debug( 'rectangle Failed at angles', Aac, Abd) + return None + notsimilarL = lambda d1, d2: abs(d1-d2)>0.20*min(d1, d2) + + pi, twopi = numpy.pi, 2*numpy.pi + angles = numpy.array( [p.angle for p in segmentList] ) + minAngleInd = numpy.argmin( numpy.minimum( abs(angles), abs( abs(angles)-pi), abs( abs(angles)-twopi) ) ) + rotAngle = angles[minAngleInd] + width = (segmentList[minAngleInd].length + segmentList[(minAngleInd+2)%4].length)*0.5 + height = (segmentList[(minAngleInd+1)%4].length + segmentList[(minAngleInd+3)%4].length)*0.5 + # set rectangle center as the bbox center + x, y, w, h = geometric.computeBox( numpy.concatenate( [ p.points for p in segmentList]) ) + r = Rectangle( numpy.array( [x+w/2, y+h/2]), (width, height), rotAngle, pathGroup.listOfPaths, pathGroup.refNode) + + debug( ' found a rectangle !! ', a.length, b.length, c.length, d.length ) + return r diff --git a/extensions/fablabchemnitz/shape_recognition/shaperrec/internal.py b/extensions/fablabchemnitz/shape_recognition/shaperrec/internal.py new file mode 100644 index 0000000..edd9940 --- /dev/null +++ b/extensions/fablabchemnitz/shape_recognition/shaperrec/internal.py @@ -0,0 +1,351 @@ +import numpy +import sys +from shaperrec import geometric +from shaperrec import miscellaneous + +# ************************************************************* +# debugging +def void(*l): + pass +def debug_on(*l): + sys.stderr.write(' '.join(str(i) for i in l) +'\n') +debug = void +#debug = debug_on + +# ************************************************************* +# Internal Objects +class Path(object): + """Private representation of a sequence of points. + A SVG node of type 'path' is splitted in several of these Path objects. + """ + next = None # next Path in the sequence of path corresponding to a SVG node + prev = None # previous Path in the sequence of path corresponding to a SVG node + sourcepoints = None # the full list of points from which this path is a subset + + normalv = None # normal vector to this Path + + def __init__(self, points): + """points an array of points """ + self.points = points + self.init() + + def init(self): + self.effectiveNPoints = len(self.points) + if self.effectiveNPoints>1: + self.length, self.univ = geometric.dirAndLength(self.points[0], self.points[-1]) + else: + self.length, self.univ = 0, numpy.array([0, 0]) + if self.effectiveNPoints>0: + self.pointN=self.points[-1] + self.point1=self.points[0] + + def isSegment(self): + return False + + def quality(self): + return 1000 + + def dump(self): + n = len(self.points) + if n>0: + return 'path at '+str(self.points[0])+ ' to '+ str(self.points[-1])+' npoints=%d / %d (eff)'%(n, self.effectiveNPoints) + else: + return 'path Void !' + + def setNewLength(self, l): + self.newLength = l + + def removeLastPoints(self, n): + self.points = self.points[:-n] + self.init() + def removeFirstPoints(self, n): + self.points = self.points[n:] + self.init() + + def costheta(self, seg): + return self.unitv.dot(seg.unitv) + + def translate(self, tr): + """Translate this path by tr""" + self.points = self.points + tr + + def asSVGCommand(self, firstP=False): + svgCommands = [] + com = 'M' if firstP else 'L' + for p in self.points: + svgCommands.append( [com, [p[0], p[1]] ] ) + com='L' + return svgCommands + + + def setIntersectWithNext(self, next=None): + pass + + def mergedWithNext(self, newPath=None): + """ Returns the combination of self and self.next. + sourcepoints has to be set + """ + if newPath is None: newPath = Path( numpy.concatenate([self.points, self.next.points]) ) + + newPath.sourcepoints = self.sourcepoints + newPath.prev = self.prev + if self.prev : newPath.prev.next = newPath + newPath.next = self.next.__next__ + if newPath.__next__: + newPath.next.prev = newPath + return newPath + +# ************************************************************* +# +class Segment(Path): + """ A segment. Defined by its line equation ax+by+c=0 and the points from orignal paths + it is ensured that a**2+b**2 = 1 + """ + QUALITYCUT = 0.9 + + newAngle = None # temporary angle set during the "parralelization" step + newLength = None # temporary lenght set during the "parralelization" step + + # Segment Builders + @staticmethod + def from2Points( p1, p2, refPoints = None): + dirV = p2-p1 + center = 0.5*(p2+p1) + return Segment.fromCenterAndDir(center, dirV, refPoints) + + @staticmethod + def fromCenterAndDir( center, dirV, refPoints=None): + b = dirV[0] + a = -dirV[1] + c = - (a*center[0]+b*center[1]) + + if refPoints is None: + refPoints = numpy.array([ center-0.5*dirV, center+0.5*dirV] ) + s = Segment( a, b, c, refPoints) + return s + + + def __init__(self, a,b,c, points, doinit=True): + """a,b,c: the line parameters. + points : the array of 2D points represented by this Segment + doinit : if true will compute additionnal parameters to this Segment (first/last points, unit vector,...) + """ + self.a = a + self.b = b + self.c = c + + self.points = points + d = numpy.sqrt(a**2+b**2) + if d != 1. : + self.a /= d + self.b /= d + self.c /= d + + if doinit : + self.init() + + + def init(self): + a, b, c = self.a, self.b, self.c + x, y = self.points[0] + self.point1 = numpy.array( [ b*(x*b-y*a) - c*a, a*(y*a-x*b) - c*b ] ) + x, y = self.points[-1] + self.pointN = numpy.array( [ b*(x*b-y*a) - c*a, a*(y*a-x*b) - c*b ] ) + uv = self.computeDirLength() + self.distancesToLine = self.computeDistancesToLine(self.points) + self.normalv = numpy.array( [ a, b ]) + + self.angle = numpy.arccos( uv[0] )*numpy.sign(uv[1] ) + + + def computeDirLength(self): + """re-compute and set unit vector and length """ + self.length, uv = geometric.dirAndLength(self.pointN, self.point1) + self.unitv = uv + return uv + + def isSegment(self): + return True + + def recomputeEndPoints(self): + a, b, c = self.a, self.b, self.c + x, y = self.points[0] + self.point1 = numpy.array( [ b*(x*b-y*a) - c*a, a*(y*a-x*b) - c*b ] ) + x, y = self.points[-1] + + self.pointN = numpy.array( [ b*(x*b-y*a) - c*a, a*(y*a-x*b) - c*b ] ) + + self.length = numpy.sqrt( geometric.D2(self.pointN, self.point1) ) + + def projectPoint(self, p): + """ return the point projection of p onto this segment""" + a, b, c = self.a, self.b, self.c + x, y = p + return numpy.array( [ b*(x*b-y*a) - c*a, a*(y*a-x*b) - c*b ] ) + + + def intersect(self, seg): + """Returns the intersection of this line with the line seg""" + nu, nv = self.normalv, seg.normalv + u = numpy.array([[-self.c], [-seg.c]]) + doRotation = min(nu.min(), nv.min()) <1e-4 + if doRotation: + # rotate to avoid numerical issues + nu = numpy.array(geometric.rotMat.dot(nu))[0] + nv = numpy.array(geometric.rotMat.dot(nv))[0] + m = numpy.matrix( (nu, nv) ) + + i = (m**-1).dot(u) + i=numpy.array( i).swapaxes(0, 1)[0] + debug(' intersection ', nu, nv, self.angle, seg.angle, ' --> ', i) + if doRotation: + i = geometric.unrotMat.dot(i).A1 + debug(' ', i) + + + return i + + def setIntersectWithNext(self, next=None): + """Modify self such as self.pointN is the intersection with next segment """ + if next is None: + next = self.__next__ + if next and next.isSegment(): + if abs(self.normalv.dot(next.unitv)) < 1e-3: + return + debug(' Intersect', self, next, ' from ', self.point1, self.pointN, ' to ', next.point1, next.pointN,) + inter = self.intersect(next) + debug(' --> ', inter, ' d=', geometric.D(self.pointN, inter) ) + next.point1 = inter + self.pointN = inter + self.computeDirLength() + next.computeDirLength() + + def computeDistancesToLine(self, points): + """points: array of points. + returns the array of distances to this segment""" + return abs(self.a*points[:, 0]+self.b*points[:, 1]+self.c) + + + def distanceTo(self, point): + return abs(self.a*point[0]+self.b*point[1]+self.c) + + def inverse(self): + """swap all x and y values. """ + def inv(v): + v[0], v[1] = v[1], v[0] + for v in [self.point1, self.pointN, self.unitv, self.normalv]: + inv(v) + + self.points = numpy.roll(self.points, 1, axis=1) + self.a, self.b = self.b, self.a + self.angle = numpy.arccos( self.unitv[0] )*numpy.sign(self.unitv[1] ) + return + + def dumpShort(self): + return 'seg '+' '+str(self.point1 )+'to '+str(self.pointN)+ ' npoints=%d | angle,offset=(%.2f,%.2f )'%(len(self.points), self.angle, self.c)+' ', self.normalv + + def dump(self): + v = self.variance() + n = len(self.points) + return 'seg '+str(self.point1 )+' , '+str(self.pointN)+ ' v/l=%.2f / %.2f = %.2f r*numpy.sqrt(n)=%.2f npoints=%d | angle,offset=(%.2f,%.2f )'%(v, self.length, v/self.length, v/self.length*numpy.sqrt(n), n, self.angle, self.c) + + def variance(self): + d = self.distancesToLine + return numpy.sqrt( (d**2).sum()/len(d) ) + + def quality(self): + n = len(self.points) + return min(self.variance()/self.length*numpy.sqrt(n), 1000) + + def formatedSegment(self, firstP=False): + return self.asSVGCommand(firstP) + + def asSVGCommand(self, firstP=False): + + if firstP: + segment = [ ['M', [self.point1[0], self.point1[1] ] ], + ['L', [self.pointN[0], self.pointN[1] ] ] + ] + else: + segment = [ ['L', [self.pointN[0], self.pointN[1] ] ] ] + #debug("Segment, format : ", segment) + return segment + + def replaceInList(self, startPos, fullList): + code0 = fullList[startPos][0] + segment = [ [code0, [self.point1[0], self.point1[1] ] ], + ['L', [self.pointN[0], self.pointN[1] ] ] + ] + l = fullList[:startPos]+segment+fullList[startPos+len(self.points):] + return l + + + + + def mergedWithNext(self, doRefit=True): + """ Returns the combination of self and self.next. + sourcepoints has to be set + """ + spoints = numpy.concatenate([self.points, self.next.points]) + + if doRefit: + newSeg = fitSingleSegment(spoints) + else: + newSeg = Segment.fromCenterAndDir(geometric.barycenter(spoints), self.unitv, spoints) + + newSeg = Path.mergedWithNext(self, newSeg) + return newSeg + + + + def center(self): + return 0.5*(self.point1+self.pointN) + + def box(self): + return geometric.computeBox(self.points) + + + def translate(self, tr): + """Translate this segment by tr """ + c = self.c -self.a*tr[0] -self.b*tr[1] + self.c =c + self.pointN = self.pointN+tr + self.point1 = self.point1+tr + self.points +=tr + + def adjustToNewAngle(self): + """reset all parameters so that self.angle is change to self.newAngle """ + + self.a, self.b, self.c = miscellaneous.parametersFromPointAngle( 0.5*(self.point1+self.pointN), self.newAngle) + + #print 'adjustToNewAngle ', self, self.angle, self.newAngle + self.angle = self.newAngle + self.normalv = numpy.array( [ self.a, self.b ]) + self.unitv = numpy.array( [ self.b, -self.a ]) + if abs(self.angle) > numpy.pi/2 : + if self.b > 0: self.unitv *= -1 + elif self.b<0 : self.unitv *= -1 + + self.point1 = self.projectPoint(self.point1) # reset point1 + if not hasattr(self, "__next__") or not self.next.isSegment(): + # move the last point (no intersect with next) + + pN = self.projectPoint(self.pointN) + dirN = pN - self.point1 + lN = geometric.length(pN, self.point1) + self.pointN = dirN/lN*self.length + self.point1 + #print ' ... adjusting last seg angle ',p.dump() , ' normalv=', p.normalv, 'unitv ', p.unitv + else: + self.setIntersectWithNext() + + def adjustToNewDistance(self): + self.pointN = self.newLength* self.unitv + self.point1 + self.length = self.newLength + + def tempLength(self): + if self.newLength : return self.newLength + else : return self.length + + def tempAngle(self): + if self.newAngle: return self.newAngle + return self.angle \ No newline at end of file diff --git a/extensions/fablabchemnitz/shape_recognition/shaperrec/manipulation.py b/extensions/fablabchemnitz/shape_recognition/shaperrec/manipulation.py new file mode 100644 index 0000000..e2c6b9d --- /dev/null +++ b/extensions/fablabchemnitz/shape_recognition/shaperrec/manipulation.py @@ -0,0 +1,187 @@ +import numpy +import sys +from shaperrec import groups +from shaperrec import geometric +from shaperrec import internal + +# ************************************************************* +# debugging +def void(*l): + pass +def debug_on(*l): + sys.stderr.write(' '.join(str(i) for i in l) +'\n') +debug = void +#debug = debug_on + +# ************************************************************* +# Object manipulation functions + +def toRemarkableShape( group ): + """Test if PathGroup instance 'group' looks like a remarkable shape (ex: Rectangle). + if so returns a new shape instance else returns group unchanged""" + r = groups.Rectangle.isRectangle( group ) + if r : return r + return group + + +def resetPrevNextSegment(segs): + for i, seg in enumerate(segs[:-1]): + s = segs[i+1] + seg.next = s + s.prev = seg + return segs + + +def fitSingleSegment(a): + xmin, ymin, w, h = geometric.computeBox(a) + inverse = w ', tangents ) + + if averaged: + # average over neighbours + avTan = numpy.array(tangents) + avTan[:-1] += tangents[1:] + avTan[1:] += tangents[:-1] + if isClosing: + tangents[0]+=tangents[-1] + tangents[1]+=tangents[0] + avTan *= 1./3 + if not isClosing: + avTan[0] *=1.5 + avTan[-1] *=1.5 + + return avTan + + +def clusterAngles(array, dAng=0.15): + """Cluster together consecutive angles with similar values (within 'dAng'). + array : flat array of angles + returns [ ..., (indi_0, indi_1),...] where each tuple are indices of cluster i + """ + N = len(array) + + closebyAng = numpy.zeros( (N, 4), dtype=int) + + for i, a in enumerate(array): + cb = closebyAng[i] + cb[0] =i + cb[2]=i + cb[3]=i + c=i-1 + # find number of angles within dAng in nearby positions + while c>-1: # indices below i + d=geometric.closeAngleAbs(a, array[c]) + if d>dAng: + break + cb[1]+=1 + cb[2]=c + c-=1 + c=i+1 + while cdAng: + break + cb[1]+=1 + cb[3]=c + c+=1 + closebyAng= closebyAng[numpy.argsort(closebyAng[:, 1]) ] + + clusteredPos = numpy.zeros(N, dtype=int) + clusters = [] + for cb in reversed(closebyAng): + if clusteredPos[cb[0]]==1: + continue + # try to build a cluster + minI = cb[2] + while clusteredPos[minI]==1: + minI+=1 + maxI = cb[3] + while clusteredPos[maxI]==1: + maxI-=1 + for i in range(minI, maxI+1): + clusteredPos[i] = 1 + clusters.append( (minI, maxI) ) + + return clusters + + + + +def adjustAllAngles(paths): + for p in paths: + if p.isSegment() and p.newAngle is not None: + p.adjustToNewAngle() + # next translate to fit end points + tr = numpy.zeros(2) + for p in paths[1:]: + if p.isSegment() and p.prev.isSegment(): + tr = p.prev.pointN - p.point1 + debug(' translating ', p, ' prev is', p.prev, ' ', tr, ) + p.translate(tr) + +def adjustAllDistances(paths): + for p in paths: + if p.isSegment() and p.newLength is not None: + p.adjustToNewDistance() + # next translate to fit end points + tr = numpy.zeros(2) + for p in paths[1:]: + if p.isSegment() and p.prev.isSegment(): + tr = p.prev.pointN - p.point1 + p.translate(tr) diff --git a/extensions/fablabchemnitz/shape_recognition/shaperrec/miscellaneous.py b/extensions/fablabchemnitz/shape_recognition/shaperrec/miscellaneous.py new file mode 100644 index 0000000..3949c41 --- /dev/null +++ b/extensions/fablabchemnitz/shape_recognition/shaperrec/miscellaneous.py @@ -0,0 +1,209 @@ +import sys +import inkex +from inkex import Path +import numpy +from shaperrec import manipulation +from lxml import etree + + + +# ************************************************************* +# debugging +def void(*l): + pass +def debug_on(*l): + sys.stderr.write(' '.join(str(i) for i in l) +'\n') +debug = void +#debug = debug_on + +errwrite = void + + +# miscellaneous helper functions to sort + +# merge consecutive segments with close angle + +def mergeConsecutiveCloseAngles( segList , mangle =0.25 , q=0.5): + + def toMerge(seg): + l=[seg] + setattr(seg, 'merged', True) + if hasattr(seg, "__next__") and seg.next.isSegment() : + debug('merging segs ', seg.angle, ' with : ', seg.next.point1, seg.next.pointN, ' ang=', seg.next.angle) + if geometric.deltaAngleAbs( seg.angle, seg.next.angle) < mangle: + l += toMerge(seg.next) + return l + + updatedSegs = [] + for i, seg in enumerate(segList[:-1]): + if not seg.isSegment() : + updatedSegs.append(seg) + continue + if hasattr(seg, 'merged'): + continue + debug(i, ' inspect merge : ', seg.point1, '-', seg.pointN, seg.angle, ' q=', seg.quality()) + mList = toMerge(seg) + debug(' --> tomerge ', len(mList)) + if len(mList)<2: + delattr(seg, 'merged') + updatedSegs.append(seg) + continue + points= numpy.concatenate( [p.points for p in mList] ) + newseg = fitSingleSegment(points) + if newseg.quality()>q: + delattr(seg, 'merged') + updatedSegs.append(seg) + continue + for p in mList: + setattr(seg, 'merged', True) + newseg.sourcepoints = seg.sourcepoints + debug(' --> post merge qual = ', newseg.quality(), seg.pointN, ' --> ', newseg.pointN, newseg.angle) + newseg.prev = mList[0].prev + newseg.next = mList[-1].__next__ + updatedSegs.append(newseg) + if not hasattr(segList[-1], 'merged') : updatedSegs.append( segList[-1]) + return updatedSegs + + + + +def parametersFromPointAngle(point, angle): + unitv = numpy.array([ numpy.cos(angle), numpy.sin(angle) ]) + ortangle = angle+numpy.pi/2 + normal = numpy.array([ numpy.cos(ortangle), numpy.sin(ortangle) ]) + genOffset = -normal.dot(point) + a, b = normal + return a, b, genOffset + + + +def addPath(newList, refnode): + """Add a node in the xml structure corresponding to the content of newList + newList : list of Segment or Path + refnode : xml node used as a reference, new point will be inserted a same level""" + ele = etree.Element('{http://www.w3.org/2000/svg}path') + errwrite("newList = " + str(newList) + "\n") + ele.set('d', str(Path(newList))) + refnode.xpath('..')[0].append(ele) + return ele + +def reformatList( listOfPaths): + """ Returns a SVG paths list (same format as simplepath.parsePath) from a list of Path objects + - Segments in paths are added in the new list + - simple Path are retrieved from the original refSVGPathList and put in the new list (thus preserving original bezier curves) + """ + newList = [] + first = True + for seg in listOfPaths: + newList += seg.asSVGCommand(first) + first = False + return newList + + +def clusterValues( values, relS=0.1 , refScaleAbs='range' ): + """form clusters of similar quantities from input 'values'. + Clustered values are not necessarily contiguous in the input array. + Clusters size (that is max-min) is < relS*cluster_average """ + if len(values)==0: + return [] + if len(values.shape)==1: + sortedV = numpy.stack([ values, numpy.arange(len(values))], 1) + else: + # Assume value.shape = (N,2) and index are ok + sortedV = values + sortedV = sortedV[ numpy.argsort(sortedV[:, 0]) ] + + sortedVV = sortedV[:, 0] + refScale = sortedVV[-1]-sortedVV[0] + #sortedVV += 2*min(sortedVV)) # shift to avoid numerical issues around 0 + + #print sortedVV + class Cluster: + def __init__(self, delta, sum, indices): + self.delta = delta + self.sum = sum + self.N=len(indices) + self.indices = indices + def size(self): + return self.delta/refScale + + def combine(self, c): + #print ' combine ', self.indices[0], c.indices[-1], ' -> ', sortedVV[c.indices[-1]] - sortedVV[self.indices[0]] + newC = Cluster(sortedVV[c.indices[-1]] - sortedVV[self.indices[0]], + self.sum+c.sum, + self.indices+c.indices) + return newC + + def originIndices(self): + return tuple(int(sortedV[i][1]) for i in self.indices) + + def size_local(self): + return self.delta / sum( sortedVV[i] for i in self.indices) *len(self.indices) + def size_range(self): + return self.delta/refScale + def size_abs(self): + return self.delta + + if refScaleAbs=='range': + Cluster.size = size_range + elif refScaleAbs=='local': + Cluster.size = size_local + elif refScaleAbs=='abs': + Cluster.size = size_abs + + class ClusterPair: + next=None + prev=None + def __init__(self, c1, c2 ): + self.c1=c1 + self.c2=c2 + self.refresh() + def refresh(self): + self.potentialC =self.c1.combine(self.c2) + self.size = self.potentialC.size() + def setC1(self, c1): + self.c1=c1 + self.refresh() + def setC2(self, c2): + self.c2=c2 + self.refresh() + + #ave = 0.5*(sortedVV[1:,0]+sortedV[:-1,0]) + #deltaR = (sortedV[1:,0]-sortedV[:-1,0])/ave + + cList = [Cluster(0, v, (i,)) for (i, v) in enumerate(sortedVV) ] + cpList = [ ClusterPair( c, cList[i+1] ) for (i, c) in enumerate(cList[:-1]) ] + manipulation.resetPrevNextSegment( cpList ) + + #print cpList + def reduceCL( cList ): + if len(cList)<=1: + return cList + cp = min(cList, key=lambda cp:cp.size) + #print '==', cp.size , relS, cp.c1.indices , cp.c2.indices, cp.potentialC.indices + + while cp.size < relS: + if hasattr(cp, "__next__"): + cp.next.setC1(cp.potentialC) + cp.next.prev = cp.prev + if cp.prev: + cp.prev.setC2(cp.potentialC) + cp.prev.next = cp.__next__ if hasattr(cp, "__next__") else None + cList.remove(cp) + if len(cList)<2: + break + cp = min(cList, key=lambda cp:cp.size) + #print ' -----> ', [ (cp.c1.indices , cp.c2.indices) for cp in cList] + return cList + + cpList = reduceCL(cpList) + if len(cpList)==1: + cp = cpList[0] + if cp.potentialC.size() Snap Object Points fablabchemnitz.de.snap_object_points - 25 + 25 + + + + + + + + true true false diff --git a/extensions/fablabchemnitz/snap_object_points/snap_object_points.py b/extensions/fablabchemnitz/snap_object_points/snap_object_points.py index bde40dc..d2ac54d 100644 --- a/extensions/fablabchemnitz/snap_object_points/snap_object_points.py +++ b/extensions/fablabchemnitz/snap_object_points/snap_object_points.py @@ -30,6 +30,7 @@ class SnapObjectPoints(inkex.EffectExtension): def add_arguments(self, pars): pars.add_argument('--max_dist', type=float, default=25.0, help='Maximum distance to be considered a "nearby" point') + pars.add_argument('--unit', default="mm", help='Distance unit') pars.add_argument('--controls', type=inkex.Boolean, default=True, help='Snap control points') pars.add_argument('--ends', type=inkex.Boolean, default=True, help='Snap endpoints') pars.add_argument('--first_only', type=inkex.Boolean, default=True, help='Modify only the first selected path') @@ -48,7 +49,7 @@ class SnapObjectPoints(inkex.EffectExtension): def _find_nearest(self, pid, x0, y0, other_points): '''Find the nearest neighbor to a given point, and return the midpoint of the given point and its neighbor.''' - max_dist2 = self.options.max_dist**2 # Work with squares instead of wasting time on square roots. + max_dist2 = self.svg.unittouu(str(self.options.max_dist**2) + self.options.unit) # Work with squares instead of wasting time on square roots. bx, by = x0, y0 # Best new point best_dist2 = max_dist2 # Minimal distance observed from (x0, y0) for k, pts in other_points.items(): diff --git a/extensions/fablabchemnitz/strip_line/.gitignore b/extensions/fablabchemnitz/strip_line/.gitignore new file mode 100644 index 0000000..32247bd --- /dev/null +++ b/extensions/fablabchemnitz/strip_line/.gitignore @@ -0,0 +1 @@ +/log.txt diff --git a/extensions/fablabchemnitz/strip_line/geometry/Circle.py b/extensions/fablabchemnitz/strip_line/geometry/Circle.py new file mode 100644 index 0000000..b351455 --- /dev/null +++ b/extensions/fablabchemnitz/strip_line/geometry/Circle.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 + +import math +import inkex +from lxml import etree + +class Circle(): + def __init__(self,_c,_r): + self.radius=_r + self.center=_c + + def __str__(self): + return "Circle: center:"+str(self.center)+" radius:"+str(self.radius)+"\n" + + def __repr__(self): + return "Circle: center"+str(self.center)+" radius:"+str(self.radius)+"\n" + + def isHit(p): + distance=(center-p).length() + if(distance0): + return True + return False#Counterclockwise + + def circumcircle(self): + #Find the length of each side + ab=(self.a-self.b).length() + bc=(self.b-self.c).length() + ca=(self.c-self.a).length() + s=(ab+bc+ca)/2.0; + area=math.sqrt(s*(s-ab)*(s-bc)*(s-ca)); + maxlength=0 + if(maxlength + + Strip Line + fablabchemnitz.de.strip_line + 10 + + + + + + + + ~/ + stripline.log + + all + + + + + + + + \ No newline at end of file diff --git a/extensions/fablabchemnitz/strip_line/strip_line.py b/extensions/fablabchemnitz/strip_line/strip_line.py new file mode 100644 index 0000000..5e6a1a3 --- /dev/null +++ b/extensions/fablabchemnitz/strip_line/strip_line.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 + +import math +import os +import inkex +from inkex.paths import Path +from geometry.Circle import Circle +from geometry.Vertex import Vertex +from geometry.Triangle import Triangle +from geometry.Plus import Plus +from geometry.Minus import Minus +from lxml import etree + +ALL=".//" + +def fixTo360(radian): + if radian < 0: + return math.pi * 2.0 + radian + return radian + +def widenDir(start, end): + d21x = end.x - start.x; + d21y = end.y - start.y; + return fixTo360(-math.atan2(d21x, d21y)); + +def lineDir(start, end): + d21x = end.x - start.x; + d21y = end.y - start.y; + #inkex.errormsg("d21y "+str(d21y)+" d21x "+str(d21x)+" d21y/d21x"+str(d21y/d21x)) + rad=math.atan2(d21y, d21x); + #inkex.errormsg(u"Line direction"+str(math.degrees(rad))) + return fixTo360(rad) + +#radian math.pi / 2.0: #I want you to be +-90 (forward direction) + return invert(radian) + return radian + +def fixOver90(radian): + if math.fabs(radian) < math.pi / 2.0: + return invert(radian) + return radian + +def fixWithinAb180(radian): + if math.fabs(radian) < math.pi: + return radian + + if radian < 0: + return math.pi * 2.0 + radian + return radian - math.pi * 2.0 + +def printRadian(fp, message, radian): + fp.write(message+":"+str(math.degrees(radian))+"\n") + +def stripline(bone, line_width, log_directory, log_filename): + try: + with open(os.path.join(log_directory, log_filename),'w', encoding = 'utf-8') as fp: + i = 0; + segmentNum = len(bone) - 1; + elementNum = (segmentNum * 2 + 2) * 5 + outVertexArray = [] + #4 vertices per line segment+Extra amount added to the end point + vertexIdx = 0 + #First apex + start = bone[0] + end = bone[1] + lastRad = 0 + lastUsedRad = 0 + radY = widenDir(start, end) + lineRad=lineDir(start, end) + fp.write(u"0th vertex") + printRadian(fp, u"lineRad", lineRad) + + originalRad = radY + + #Indicates the direction of bending + cornerDir = radY - lastRad + printRadian(fp, u"radY", radY) + diffRad = 0 + printRadian(fp, u"diffRad:", diffRad) + printRadian(fp, u"radY-lineRad:", radY - lineRad) + printRadian(fp, u"sin(radY-lineRad:)", math.sin(radY - lineRad)) + + adjustedRad = radY + printRadian(fp, u"The first drawing angle", adjustedRad) + fp.write("\n") + direction=True + lastRad=radY + + lastUsedRad = adjustedRad + LEFT = Vertex(line_width, 0) + RIGHT = Vertex(-line_width, 0) + #variable + v = Vertex(0,0) + v.set(LEFT) + v.rotate(adjustedRad) + flag = False #if radY < 0 else False + outVertexArray.append([start + v, flag]) + + v.set(RIGHT) + v.rotate(adjustedRad) + flag = True# if radY< 0 else False + outVertexArray.append([start + v, flag]) + + for i in range(1, segmentNum, 1): + start = bone[i] + end = bone[i + 1] + originalRad = widenDir(start,end) + radY = (originalRad + lastRad) * 0.5 #Values ​​from 0 to 360 degrees + fp.write(str(i) + u"Th vertex") + diffRad = (originalRad - lastRad) + if math.fabs(math.fabs(diffRad) - math.pi) <= (45.0 * math.pi / 180.0):#To erase the pointed triangle when making a U-turn + printRadian(fp, u"Correction of U-turn point:diffRad", diffRad) + fp.write(u"Difference" + str(math.fabs(math.fabs(diffRad) - math.pi))) + radY = originalRad + + printRadian(fp, u"radY:", radY) + printRadian(fp, u"diffRad:", diffRad) + printRadian(fp, u"radY - lineRad:", radY - lineRad) + printRadian(fp, u"sin(radY - lineRad:)", math.sin(radY - lineRad)) + #Twist prevention + if math.sin(radY - lineRad) > 0: + radY = invert(radY) + + lineRad = lineDir(start, end) + + printRadian(fp, u"lineRad:", lineRad) + adjustedRad = radY + printRadian(fp, u"diffRad:", diffRad) + squareRad = lineDir(start, end) + #printRadian(u"squareRad",squareRad) + printRadian(fp, u"Drawing angle after conversion:", radY) + v.set(LEFT) + #1〜√2 I want you to be in the range + coef = (1 + 0.41421356237 * math.fabs(math.sin(diffRad * 0.5))) + fp.write("coef=" + str(coef)) + v.x *= coef + v.rotate(adjustedRad) + flag = False + outVertexArray.append([start + v, flag]) + v.set(RIGHT) + v.x *= coef + v.rotate(adjustedRad) + flag = True + outVertexArray.append([start + v , flag]) + lastRad = originalRad + lastUsedRad = adjustedRad + fp.write("\n") + #The last round + fp.write(str(i) + u"Th vertex") + adjustedRad = originalRad + printRadian(fp, u"Last drawing angle:",originalRad) + v.set(LEFT) + v.rotate(adjustedRad) + flag = False # if originalRad< 0 else False + outVertexArray.append([end + v, flag]) + v.set(RIGHT) + v.rotate(adjustedRad) + flag = True # if originalRad< 0 else False + outVertexArray.append([end + v, flag]) + fp.close() + return outVertexArray + except IOError as error: # parent of IOError, OSError *and* WindowsError where available + inkex.utils.debug(str("Directory error. Please select another one and try again!")) + exit() + +class StripLine(inkex.EffectExtension): + + def add_arguments(self, pars): + pars.add_argument("--line_width", type = int, default = "10", help = "Line thickness") + pars.add_argument("--unit", default = "mm", help = "Width unit") + pars.add_argument("--log_directory", default = "~/", help = "Log directory") + pars.add_argument("--log_filename", default = "stripline.log", help = "Log file name") + + def effect(self): + line_width = self.svg.unittouu(str(self.options.line_width) + self.options.unit) + # Get the main root element SVG + svg = self.document.getroot() + pathlist = svg.findall(ALL + "{" + inkex.NSS['svg'] + "}path") + + for path in pathlist: + if path == None: + inkex.errormsg("Please write the path! !") + #Get vertex coordinates of path + vals = path.path.to_arrays() + bone = [] + for cmd, vert in vals: + #Sometimes there is an empty, so countermeasures against it + #inkex.utils.debug("{},{}".format(cmd, vert)) + if len(vert) != 0: + bone.append(Vertex(vert[0], vert[1])) + + if len(bone) != 0: + outVertexArray = stripline(bone, line_width, self.options.log_directory, self.options.log_filename) + pointer = 0 + for t in range(len(outVertexArray) - 2): + tri = Triangle(outVertexArray[pointer][0], outVertexArray[pointer+1][0], outVertexArray[pointer+2][0]) + + stripstr = tri.toSVG() + color2 = "blue" + if outVertexArray[pointer][1]: + color = "blue" + else: + color = "red" + pointer += 1 + attributes = {"points": stripstr, + "style":"fill:" + color2 + ";stroke:" + color2 + ";stroke-width:" + str(line_width / 3), "fill-opacity": "0.5"} + pth = etree.Element("polygon", attributes) + svg.append(pth) + pointer = 0 + #Draw a point indicating +- + for t in range(len(outVertexArray)): + + if outVertexArray[pointer][1]: + point = Plus(outVertexArray[pointer][0].x, outVertexArray[pointer][0].y, line_width / 3) + color = "blue" + else: + point = Minus(outVertexArray[pointer][0].x, outVertexArray[pointer][0].y, line_width / 3) + color = "red" + if pointer == 0: + color = "#6f0018"#Dark red + point.appendToSVG(color, svg) + #svg.append(Circle.toSVG(outVertexArray[pointer][0].x,outVertexArray[pointer][0].y,line_width/3,color,0)) + pointer += 1 + pointer = 0 + pathstr = "M " + for t in range(len(outVertexArray)): + pathstr += str(outVertexArray[pointer][0].x) + " "+str(outVertexArray[pointer][0].y) + " " + pointer += 1 + + att3 = {"d":pathstr,"r":"1","fill":"none","stroke-width":"1","stroke":"white"} + pt = etree.Element("path", att3) + +if __name__ == '__main__': + StripLine().run() \ No newline at end of file diff --git a/extensions/fablabchemnitz/svg_embed_and_crop/meta.json b/extensions/fablabchemnitz/svg_embed_and_crop/meta.json new file mode 100644 index 0000000..0204e64 --- /dev/null +++ b/extensions/fablabchemnitz/svg_embed_and_crop/meta.json @@ -0,0 +1,21 @@ +[ + { + "name": "SVG Embed And Crop Images", + "id": "fablabchemnitz.de.svg_embed_and_crop", + "path": "svg_embed_and_crop", + "dependent_extensions": null, + "original_name": "Embed and crop images", + "original_id": "edu.emory.cellbio.svg.embedandcrop", + "license": "MIT License", + "license_url": "https://github.com/bnanes/svg-embed-and-crop/blob/master/LICENSE", + "comment": "", + "source_url": "https://gitea.fablabchemnitz.de/FabLab_Chemnitz/mightyscape-1.2/src/branch/master/extensions/fablabchemnitz/svg_embed_and_crop", + "fork_url": "https://github.com/bnanes/svg-embed-and-crop", + "documentation_url": "https://stadtfabrikanten.org/display/IFM/SVG+Embed+And+Crop", + "inkscape_gallery_url": null, + "main_authors": [ + "github.com/bnanes", + "github.com/eridur-de" + ] + } +] \ No newline at end of file diff --git a/extensions/fablabchemnitz/svg_embed_and_crop/svg_embed_and_crop.inx b/extensions/fablabchemnitz/svg_embed_and_crop/svg_embed_and_crop.inx new file mode 100644 index 0000000..a36e01e --- /dev/null +++ b/extensions/fablabchemnitz/svg_embed_and_crop/svg_embed_and_crop.inx @@ -0,0 +1,16 @@ + + + SVG Embed And Crop Linked Images + fablabchemnitz.de.svg_embed_and_crop + + all + + + + + + + + \ No newline at end of file diff --git a/extensions/fablabchemnitz/svg_embed_and_crop/svg_embed_and_crop.py b/extensions/fablabchemnitz/svg_embed_and_crop/svg_embed_and_crop.py new file mode 100644 index 0000000..133fe84 --- /dev/null +++ b/extensions/fablabchemnitz/svg_embed_and_crop/svg_embed_and_crop.py @@ -0,0 +1,42 @@ +import inkex +import subprocess +import os +from lxml import etree +from inkex import command + +class EmbedAndCrop(inkex.EffectExtension): + + ''' + This extension does not work for embedded images, but only for linked ones + ''' + + def effect(self): + cp = os.path.dirname(os.path.abspath(__file__)) + "/svg_embed_and_crop/*" + output_file = self.options.input_file + ".cropped" + cli_output = command.call('java', '-cp', cp, 'edu.emory.cellbio.svg.EmbedAndCropInkscapeEntry', self.options.input_file, "-o", output_file) + if len(cli_output) > 0: + self.debug(_("Inkscape extension returned the following output:")) + self.debug(cli_output) + + if not os.path.exists(output_file): + raise inkex.AbortExtension("Plugin canceled") + stream = open(output_file, 'r') + p = etree.XMLParser(huge_tree=True) + doc = etree.parse(stream, parser=etree.XMLParser(huge_tree=True)) + stream.close() + root = self.document.getroot() + kept = [] #required. if we delete them directly without adding new defs or namedview, inkscape will crash + for node in self.document.xpath('//*', namespaces=inkex.NSS): + if node.TAG not in ('svg', 'defs', 'namedview'): + node.delete() + elif node.TAG in ('defs', 'namedview'): #except 'svg' + kept.append(node) + + children = doc.getroot().getchildren() + for child in children: + root.append(child) + for k in kept: + k.delete() + +if __name__ == '__main__': + EmbedAndCrop().run() \ No newline at end of file diff --git a/extensions/fablabchemnitz/svg_embed_and_crop/svg_embed_and_crop/LICENSE b/extensions/fablabchemnitz/svg_embed_and_crop/svg_embed_and_crop/LICENSE new file mode 100644 index 0000000..a7920f8 --- /dev/null +++ b/extensions/fablabchemnitz/svg_embed_and_crop/svg_embed_and_crop/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2013-2021 Benjamin Nanes + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/extensions/fablabchemnitz/svg_embed_and_crop/svg_embed_and_crop/LICENSE-apache.txt b/extensions/fablabchemnitz/svg_embed_and_crop/svg_embed_and_crop/LICENSE-apache.txt new file mode 100644 index 0000000..75b5248 --- /dev/null +++ b/extensions/fablabchemnitz/svg_embed_and_crop/svg_embed_and_crop/LICENSE-apache.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/extensions/fablabchemnitz/svg_embed_and_crop/svg_embed_and_crop/NOTICE-apache.txt b/extensions/fablabchemnitz/svg_embed_and_crop/svg_embed_and_crop/NOTICE-apache.txt new file mode 100644 index 0000000..ef23138 --- /dev/null +++ b/extensions/fablabchemnitz/svg_embed_and_crop/svg_embed_and_crop/NOTICE-apache.txt @@ -0,0 +1,14 @@ +Apache Commons Codec +Copyright 2002-2012 The Apache Software Foundation + +This product includes software developed by +The Apache Software Foundation (http://www.apache.org/). + +-------------------------------------------------------------------------------- +src/test/org/apache/commons/codec/language/DoubleMetaphoneTest.java contains +test data from http://aspell.sourceforge.net/test/batch0.tab. + +Copyright (C) 2002 Kevin Atkinson (kevina@gnu.org). Verbatim copying +and distribution of this entire article is permitted in any medium, +provided this notice is preserved. +-------------------------------------------------------------------------------- diff --git a/extensions/fablabchemnitz/svg_embed_and_crop/svg_embed_and_crop/README.md b/extensions/fablabchemnitz/svg_embed_and_crop/svg_embed_and_crop/README.md new file mode 100644 index 0000000..e1e4ee1 --- /dev/null +++ b/extensions/fablabchemnitz/svg_embed_and_crop/svg_embed_and_crop/README.md @@ -0,0 +1,33 @@ +[Inkscape](http://inkscape.org/) is a powerful open-source +vector graphics editor which supports the inclusion of +raster images either through file references (links) +or through direct embedding of the image data in the +Inkscape SVG file. Referencing images as links keeps SVG files +small and ensures that changes to image placement and +transformations specified in the SVG file remain separate +from the underlying image data. However, embedding images +may be required as a final step in some production work-flows. + +This java-based extension for Inkscape facilitates +image embedding by: + +- Automatically identifying all linked images. +- Cropping image data that lies outside the images' + clipping frame. +- Optionally applying jpeg compression. +- Optionally resampling images above a maximum resolution +- Writing the cropped and possibly compressed image + data directly in the SVG file. + +By cropping image data that lies outside the clipping frame, +applying jpeg compression, or resampling images above a maximum resolution, +the resulting file size can be reduced significantly. +Alternatively, if preserving image quality is a priority +jpeg compression and resampling can be explicitly avoided. + +The plugin uses [ImageJ](http://rsbweb.nih.gov/ij/) +to load and manipulate the image data and +the Apache Commons Codec library to encode +the data for embedding. + +**[Installation instructions and documentation](http://b.nanes.org/svg-embed-and-crop/)** diff --git a/extensions/fablabchemnitz/svg_embed_and_crop/svg_embed_and_crop/commons-codec-1.7.jar b/extensions/fablabchemnitz/svg_embed_and_crop/svg_embed_and_crop/commons-codec-1.7.jar new file mode 100644 index 0000000..efa7f72 Binary files /dev/null and b/extensions/fablabchemnitz/svg_embed_and_crop/svg_embed_and_crop/commons-codec-1.7.jar differ diff --git a/extensions/fablabchemnitz/svg_embed_and_crop/svg_embed_and_crop/ij-1.47i.jar b/extensions/fablabchemnitz/svg_embed_and_crop/svg_embed_and_crop/ij-1.47i.jar new file mode 100644 index 0000000..a910841 Binary files /dev/null and b/extensions/fablabchemnitz/svg_embed_and_crop/svg_embed_and_crop/ij-1.47i.jar differ diff --git a/extensions/fablabchemnitz/svg_embed_and_crop/svg_embed_and_crop/svg-embed-and-crop-1.6.jar b/extensions/fablabchemnitz/svg_embed_and_crop/svg_embed_and_crop/svg-embed-and-crop-1.6.jar new file mode 100644 index 0000000..06f4c10 Binary files /dev/null and b/extensions/fablabchemnitz/svg_embed_and_crop/svg_embed_and_crop/svg-embed-and-crop-1.6.jar differ diff --git a/extensions/fablabchemnitz/travel/meta.json b/extensions/fablabchemnitz/travel/meta.json new file mode 100644 index 0000000..e86933e --- /dev/null +++ b/extensions/fablabchemnitz/travel/meta.json @@ -0,0 +1,21 @@ +[ + { + "name": "Travel", + "id": "fablabchemnitz.de.travel", + "path": "travel", + "dependent_extensions": null, + "original_name": "Travel", + "original_id": "travel.rkp8000.inkscape", + "license": "MIT License", + "license_url": "https://github.com/rkp8000/inkscape-travel/blob/master/LICENSE", + "comment": "ported to Inkscape v1 manually by Mario Voigt", + "source_url": "https://gitea.fablabchemnitz.de/FabLab_Chemnitz/mightyscape-1.2/src/branch/master/extensions/fablabchemnitz/travel", + "fork_url": "https://github.com/rkp8000/inkscape-travel", + "documentation_url": "https://stadtfabrikanten.org/display/IFM/Travel", + "inkscape_gallery_url": null, + "main_authors": [ + "github.com/rkp8000", + "github.com/eridur-de" + ] + } +] \ No newline at end of file diff --git a/extensions/fablabchemnitz/travel/travel.inx b/extensions/fablabchemnitz/travel/travel.inx new file mode 100644 index 0000000..332e60f --- /dev/null +++ b/extensions/fablabchemnitz/travel/travel.inx @@ -0,0 +1,39 @@ + + + Travel + fablabchemnitz.de.travel + + + 1 + 1 + 0 + 1 + + 11 + 0 + 0 + + t + 0 + 1 + 1 + 0 + + + + + + + + + all + + + + + + + + \ No newline at end of file diff --git a/extensions/fablabchemnitz/travel/travel.py b/extensions/fablabchemnitz/travel/travel.py new file mode 100644 index 0000000..6cc25e1 --- /dev/null +++ b/extensions/fablabchemnitz/travel/travel.py @@ -0,0 +1,269 @@ +#!/usr/bin/env python3 + +""" +Copyright (C) 2018 Rich Pang, rpang.contact@gmail.com. + +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. +""" + +from __future__ import division +from copy import deepcopy +import inkex +from inkex.paths import Path, CubicSuperPath +from inkex.transforms import Transform +import numpy as np +from lxml import etree + +# rename common numpy operations +abs = np.abs +sin = np.sin +cos = np.cos +tan = np.tan +exp = np.exp +log = np.log +log10 = np.log10 + +pi = np.pi + +__version__ = '0.1' + +def split(l, sizes): + """Split a list into sublists of specific sizes.""" + if not sum(sizes) == len(l): + raise ValueError('sum(sizes) must equal len(l)') + + sub_lists = [] + ctr = 0 + for size in sizes: + sub_lists.append(l[ctr:ctr+size]) + ctr += size + + return sub_lists + + +class Travel(inkex.Effect): + + def __init__(self): + + # initialize parent class + inkex.Effect.__init__(self) + + # get params entered by user + self.arg_parser.add_argument('--x_scale', type=float, default=0, help='x scale') + self.arg_parser.add_argument('--y_scale', type=float, default=0, help='y scale') + self.arg_parser.add_argument('--t_start', type=float, default=0, help='t start') + self.arg_parser.add_argument('--t_end', type=float, default=1, help='t_end') + self.arg_parser.add_argument('--n_steps', type=int, default=10, help='num steps') + self.arg_parser.add_argument('--fps', type=float, default=0, help='fps') + self.arg_parser.add_argument('--dt', type=float, default=0, help='dt') + self.arg_parser.add_argument('--x_eqn', default='', help='x') + self.arg_parser.add_argument('--y_eqn', default='', help='y') + self.arg_parser.add_argument('--x_size_eqn', default='', help='x size') + self.arg_parser.add_argument('--y_size_eqn', default='', help='y size') + self.arg_parser.add_argument('--theta_eqn', default='', help='theta') + self.arg_parser.add_argument('--active-tab', default='options', help='active tab') + + def effect(self): + x_scale = self.options.x_scale + y_scale = self.options.y_scale + + t_start = self.options.t_start + t_end = self.options.t_end + n_steps = self.options.n_steps + fps = self.options.fps + dt = self.options.dt + + x_eqn = self.options.x_eqn + y_eqn = self.options.y_eqn + + x_size_eqn = self.options.x_size_eqn + y_size_eqn = self.options.y_size_eqn + + theta_eqn = self.options.theta_eqn + + # get doc root + svg = self.document.getroot() + doc_w = self.svg.unittouu(svg.get('width')) + doc_h = self.svg.unittouu(svg.get('height')) + + # get selected items and validate + selected = svg.selection.rendering_order() + + if not selected: + inkex.errormsg('Exactly two objects must be selected: a rect and a template. See "help" for details.') + return + elif len(selected) != 2: + inkex.errormsg('Exactly two objects must be selected: a rect and a template. See "help" for details.') + return + + # rect + rect = self.svg.selected[self.options.ids[0]] + + if not rect.tag.endswith('rect'): + inkex.errormsg('Bottom object must be rect. See "help" for usage.') + return + + # object + obj = self.svg.selected[self.options.ids[1]] + + if not (obj.tag.endswith('path') or obj.tag.endswith('g')): + inkex.errormsg('Template object must be path or group of paths. See "help" for usage.') + return + if obj.tag.endswith('g'): + children = obj.getchildren() + if not all([ch.tag.endswith('path') for ch in children]): + msg = 'All elements of group must be paths, but they are: ' + msg += ', '.join(['{}'.format(ch) for ch in children]) + inkex.errormsg(msg) + return + objs = children + is_group = True + else: + objs = [obj] + is_group = False + + # get rect params + w = float(rect.get('width')) + h = float(rect.get('height')) + + x_rect = float(rect.get('x')) + y_rect = float(rect.get('y')) + + # lower left corner + x_0 = x_rect + y_0 = y_rect + h + + # get object path(s) + obj_ps = [Path(obj_.get('d')) for obj_ in objs] + n_segs = [len(obj_p_) for obj_p_ in obj_ps] + obj_p = sum(obj_ps, []) + + # compute travel parameters + if not n_steps: + # compute dt + if dt == 0: + dt = 1./fps + ts = np.arange(t_start, t_end, dt) + else: + ts = np.linspace(t_start, t_end, n_steps) + + # compute xs, ys, stretches, and rotations in arbitrary coordinates + xs = np.nan * np.zeros(len(ts)) + ys = np.nan * np.zeros(len(ts)) + x_sizes = np.nan * np.zeros(len(ts)) + y_sizes = np.nan * np.zeros(len(ts)) + thetas = np.nan * np.zeros(len(ts)) + + for ctr, t in enumerate(ts): + xs[ctr] = eval(x_eqn) + ys[ctr] = eval(y_eqn) + x_sizes[ctr] = eval(x_size_eqn) + y_sizes[ctr] = eval(y_size_eqn) + thetas[ctr] = eval(theta_eqn) * pi / 180 + + # ensure no Infs + if np.any(np.isinf(xs)): + raise Exception('Inf detected in x(t), please remove.') + return + if np.any(np.isinf(ys)): + raise Exception('Inf detected in y(t), please remove.') + return + if np.any(np.isinf(x_sizes)): + raise Exception('Inf detected in x_size(t), please remove.') + return + if np.any(np.isinf(y_sizes)): + raise Exception('Inf detected in y_size(t), please remove.') + return + if np.any(np.isinf(thetas)): + raise Exception('Inf detected in theta(t), please remove.') + return + + # convert to screen coordinates + xs *= (w/x_scale) + xs += x_0 + + ys *= (-h/y_scale) # neg sign to invert y for inkscape screen + ys += y_0 + + # get obj center + b_box = Path(obj_p).bounding_box() + c_x = 0.5 * (b_box.left + b_box.right) + c_y = 0.5 * (b_box.top + b_box.bottom) + + # get rotation anchor + if any([k.endswith('transform-center-x') for k in obj.keys()]): + k_r_x = [k for k in obj.keys() if k.endswith('transform-center-x')][0] + k_r_y = [k for k in obj.keys() if k.endswith('transform-center-y')][0] + r_x = c_x + float(obj.get(k_r_x)) + r_y = c_y - float(obj.get(k_r_y)) + else: + r_x, r_y = c_x, c_y + + paths = [] + + # compute new paths + for x, y, x_size, y_size, theta in zip(xs, ys, x_sizes, y_sizes, thetas): + path = deepcopy(obj_p) + + # move to origin + path = Path(path).translate(-x_0, -y_0) + + # move rotation anchor accordingly + r_x_1 = r_x - x_0 + r_y_1 = r_y - y_0 + + # scale + path = Path(path).scale(x_size, y_size) + + # scale rotation anchor accordingly + r_x_2 = r_x_1 * x_size + r_y_2 = r_y_1 * y_size + + # move to final location + path = Path(path).translate(x, y) + + # move rotation anchor accordingly + r_x_3 = r_x_2 + x + r_y_3 = r_y_2 + y + + # rotate + path = Path(path).rotate(-theta, (r_x_3, r_y_3)) + paths.append(path) + + parent = self.svg.get_current_layer() + group = etree.SubElement(parent, inkex.addNS('g', 'svg'), {}) + + for path in paths: + + if is_group: + group_ = etree.SubElement(group, inkex.addNS('g', 'svg'), {}) + path_components = split(path, n_segs) + + for path_component, child in zip(path_components, children): + attribs = { + k: child.get(k) for k in child.keys() + } + attribs['d'] = str(Path(path_component)) + child_copy = etree.SubElement(group_, child.tag, attribs) + + else: + attribs = { + k: obj.get(k) for k in obj.keys() + } + attribs['d'] = str(Path(path)) + obj_copy = etree.SubElement(group, obj.tag, attribs) + +if __name__ == '__main__': + Travel().run() \ No newline at end of file diff --git a/extensions/fablabchemnitz/triangular_grid/triangular_grid.inx b/extensions/fablabchemnitz/triangular_grid/triangular_grid.inx index c3f56b7..1aa0c04 100644 --- a/extensions/fablabchemnitz/triangular_grid/triangular_grid.inx +++ b/extensions/fablabchemnitz/triangular_grid/triangular_grid.inx @@ -27,7 +27,7 @@ 2 - #ffff00ff + #ff00ffff 2 diff --git a/extensions/fablabchemnitz/triangular_grid/triangular_grid.py b/extensions/fablabchemnitz/triangular_grid/triangular_grid.py index fc2c21e..62c8ed0 100644 --- a/extensions/fablabchemnitz/triangular_grid/triangular_grid.py +++ b/extensions/fablabchemnitz/triangular_grid/triangular_grid.py @@ -29,25 +29,6 @@ import inkex from lxml import etree from math import * -def draw_SVG_line(x1, y1, x2, y2, width, stroke, name, parent): - style = { 'stroke': stroke, 'stroke-width':str(width), 'fill': 'none' } - line_attribs = {'style':str(inkex.Style(style)), - inkex.addNS('label','inkscape'):name, - 'd':'M '+str(x1)+','+ str(y1) +' L '+ str(x2) + ',' + str(y2)} - etree.SubElement(parent, inkex.addNS('path','svg'), line_attribs ) - -def draw_SVG_rect(x,y,w,h, width, stroke, fill, name, parent): - style = { 'stroke': stroke, 'stroke-width':str(width), 'fill':fill} - rect_attribs = {'style':str(inkex.Style(style)), - inkex.addNS('label','inkscape'):name, - 'x':str(x), 'y':str(y), 'width':str(w), 'height':str(h)} - etree.SubElement(parent, inkex.addNS('rect','svg'), rect_attribs ) - -def colorString(pickerColor): - longcolor = int(pickerColor) & 0xFFFFFF00 - return '#' + format(longcolor >> 8, '06X') - - class TriangularGrid(inkex.EffectExtension): def add_arguments(self, pars): @@ -120,8 +101,7 @@ class TriangularGrid(inkex.EffectExtension): end_y = border_points[right][y] return [[start_x,start_y],[end_x, end_y]] - def drawAngledGridLine (self, x1, y1, theta, thickness, color, - label, groupName): + def drawAngledGridLine (self, x1, y1, theta, width, color, label, groupName): end_points = self.trimmed_coords(x1, y1, theta) x_start = end_points[0][0] y_start = end_points[0][1] @@ -131,9 +111,25 @@ class TriangularGrid(inkex.EffectExtension): if (x_end >= 0 and x_end <= self.xmax and y_end >= 0 and y_end <= self.ymax and (y_start != y_end and x_start != x_end)): - draw_SVG_line(x_start, y_start, - x_end, y_end, - thickness, colorString(color), label, groupName) + self.draw_SVG_line(x_start, y_start, x_end, y_end, width, self.colorString(color), label, groupName) + + def draw_SVG_line(self, x1, y1, x2, y2, width, stroke, name, parent): + style = { 'stroke': stroke, 'stroke-width':self.svg.unittouu(str(width) + "px"), 'fill': 'none' } + line_attribs = {'style': str(inkex.Style(style)), + inkex.addNS('label','inkscape'):name, + 'd':'M '+ str(x1)+','+ str(y1) +' L '+ str(x2) + ',' + str(y2)} + etree.SubElement(parent, inkex.addNS('path','svg'), line_attribs ) + + def draw_SVG_rect(self, x,y,w,h, width, stroke, fill, name, parent): + style = { 'stroke': stroke, 'stroke-width':self.svg.unittouu(str(width) + "px"), 'fill': fill} + rect_attribs = {'style': str(inkex.Style(style)), + inkex.addNS('label','inkscape'): name, + 'x': str(x), 'y': str(y), 'width': str(w), 'height': str(h)} + etree.SubElement(parent, inkex.addNS('rect','svg'), rect_attribs ) + + def colorString(self, pickerColor): + longcolor = int(pickerColor) & 0xFFFFFF00 + return '#' + format(longcolor >> 8, '06X') def effect(self): @@ -149,9 +145,9 @@ class TriangularGrid(inkex.EffectExtension): t = 'translate(' + str( self.svg.namedview.center[0]- self.xmax/2.0) + ',' + \ str( self.svg.namedview.center[1]- self.ymax/2.0) + ')' g_attribs = {inkex.addNS('label','inkscape'):'Grid_Triangular:Size' + \ - str( self.options.x_divs)+'x'+str(self.options.y_divs) + - ':Angle'+str( self.options.grid_angle ), - 'transform':t } + str( self.options.x_divs) + 'x' + str(self.options.y_divs) + + ':Angle' + str( self.options.grid_angle ), + 'transform': t } grid = etree.SubElement(self.svg.get_current_layer(), 'g', g_attribs) #Group for major x gridlines @@ -184,9 +180,9 @@ class TriangularGrid(inkex.EffectExtension): g_attribs = {inkex.addNS('label','inkscape'):'SubMinorNegGridlines'} mmingln = etree.SubElement(grid, 'g', g_attribs) - draw_SVG_rect(0, 0, self.xmax, self.ymax, + self.draw_SVG_rect(0, 0, self.xmax, self.ymax, self.options.border_th, - colorString(self.options.border_color), 'none', + self.colorString(self.options.border_color), 'none', 'Border', grid) #border rectangle sd = self.options.subdivs #sub divs per div @@ -198,25 +194,25 @@ class TriangularGrid(inkex.EffectExtension): for i in range(0, self.options.x_divs): #Major x divisons if i>0: #dont draw first line (we made a proper border) # Draw the vertical line - draw_SVG_line(dx*i, 0, + self.draw_SVG_line(dx*i, 0, dx*i,self.ymax, self.options.major_th, - colorString(self.options.major_color), + self.colorString(self.options.major_color), 'MajorDiv'+str(i), majglx) for j in range (0, sd): if j>0: #not for the first loop (this loop is for the subsubdivs before the first subdiv) - draw_SVG_line(dx*(i+j/float(sd)), 0, + self.draw_SVG_line(dx*(i+j/float(sd)), 0, dx*(i+j/float(sd)), self.ymax, self.options.subdiv_th, - colorString(self.options.subdiv_color), + self.colorString(self.options.subdiv_color), 'MinorDiv'+str(i)+':'+str(j), minglx) for k in range (1, ssd): #subsub divs - draw_SVG_line(dx*(i+(j*ssd+k)/((float(sd)*ssd))) , 0, + self.draw_SVG_line(dx*(i+(j*ssd+k)/((float(sd)*ssd))) , 0, dx*(i+(j*ssd+k)/((float(sd)*ssd))) , self.ymax, self.options.subsubdiv_th, - colorString(self.options.subsubdiv_color), + self.colorString(self.options.subsubdiv_color), 'SubminorDiv'+str(i)+':'+str(j)+':'+str(k), mminglx) #DO THE VERTICAL DIVISONS======================================== diff --git a/extensions/fablabchemnitz/ungrouper_and_element_migrator_filter/meta.json b/extensions/fablabchemnitz/ungrouper_and_element_migrator_filter/meta.json new file mode 100644 index 0000000..d93e932 --- /dev/null +++ b/extensions/fablabchemnitz/ungrouper_and_element_migrator_filter/meta.json @@ -0,0 +1,23 @@ +[ + { + "name": "Ungrouper And Element Migrator/Filter", + "id": "fablabchemnitz.de.ungrouper_and_element_migrator_filter", + "path": "ungrouper_and_element_migrator_filter", + "dependent_extensions": [ + "apply_transformations", + "remove_empty_groups" + ], + "original_name": "Ungrouper And Element Migrator/Filter", + "original_id": "fablabchemnitz.de.ungrouper_and_element_migrator_filter", + "license": "GNU GPL v3", + "license_url": "https://gitea.fablabchemnitz.de/FabLab_Chemnitz/mightyscape-1.2/src/branch/master/LICENSE", + "comment": "Created by Mario Voigt", + "source_url": "https://gitea.fablabchemnitz.de/FabLab_Chemnitz/mightyscape-1.2/src/branch/master/extensions/fablabchemnitz/ungrouper_and_element_migrator_filter", + "fork_url": null, + "documentation_url": "https://stadtfabrikanten.org/pages/viewpage.action?pageId=77267295", + "inkscape_gallery_url": "https://inkscape.org/de/~MarioVoigt/%E2%98%85ungrouper-and-element-migratorfilter", + "main_authors": [ + "github.com/eridur-de" + ] + } +] \ No newline at end of file diff --git a/extensions/fablabchemnitz/ungrouper_and_element_migrator_filter/ungrouper_and_element_migrator_filter.inx b/extensions/fablabchemnitz/ungrouper_and_element_migrator_filter/ungrouper_and_element_migrator_filter.inx new file mode 100644 index 0000000..77aca3f --- /dev/null +++ b/extensions/fablabchemnitz/ungrouper_and_element_migrator_filter/ungrouper_and_element_migrator_filter.inx @@ -0,0 +1,123 @@ + + + Ungrouper And Element Migrator/Filter + fablabchemnitz.de.ungrouper_and_element_migrator_filter + + + + + + + true + true + true + true + true + true + true + + + + + true + true + true + true + true + + + + + true + true + true + true + true + true + + + + + + + + + true + true + true + true + true + true + true + true + + + + true + true + true + true + true + true + true + true + + + + + + + + + + + true + false + false + false + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ../000_about_fablabchemnitz.svg + + + + path + + + + + + + + diff --git a/extensions/fablabchemnitz/ungrouper_and_element_migrator_filter/ungrouper_and_element_migrator_filter.py b/extensions/fablabchemnitz/ungrouper_and_element_migrator_filter/ungrouper_and_element_migrator_filter.py new file mode 100644 index 0000000..c6ba811 --- /dev/null +++ b/extensions/fablabchemnitz/ungrouper_and_element_migrator_filter/ungrouper_and_element_migrator_filter.py @@ -0,0 +1,265 @@ +#!/usr/bin/env python3 + +""" +Extension for InkScape 1.0 + +This extension parses the selection and will put all elements into one single group. If you have a cluster with lots of groups and elements you will clean up this way (one top level group, all elements below it). If you select a single element or a set of elements you just wrap it like using CTRL + G (like making a usual group). You can also use this extension to filter out unwanted SVG elements at all. + +Author: Mario Voigt / FabLab Chemnitz +Mail: mario.voigt@stadtfabrikanten.org +Date: 13.08.2020 +Last Patch: 13.09.2020 +License: GNU GPL v3 +""" + +import inkex +import os +import sys +from lxml import etree + +sys.path.append("../remove_empty_groups") +sys.path.append("../apply_transformations") + +class UngrouperAndElementMigratorFilter(inkex.EffectExtension): + + allElements = [] #list of all (sub)elements to process within selection + allGroups = [] #list of all groups (svg:g and svg:svg items) to delete for cleanup (for ungrouping) + allDrops = [] #list of all other elements except svg:g and svg:svg to drop while migrating (for filtering) + + def add_arguments(self, pars): + pars.add_argument("--tab") + + pars.add_argument("--operationmode", default=False, help="Operation mode") + pars.add_argument("--parsechildren", type=inkex.Boolean, default=True, help="Perform operations on children of selection") + pars.add_argument("--showdroplist", type=inkex.Boolean, default=False, help="Show list of dropped items") + pars.add_argument("--shownewgroupname", type=inkex.Boolean, default=False, help="This helps to better identify the generated output.") + pars.add_argument("--apply_transformations", type=inkex.Boolean, default=False, help="Run 'Apply Transformations' extension before running vpype. Helps avoiding geometry shifting") + pars.add_argument("--cleanup", type=inkex.Boolean, default = True, help = "This will call the extension 'Remove Empty Groups' if available") + pars.add_argument("--migrate_to", default = "group", help = "Migrate to") + + #pars.add_argument("--sodipodi", type=inkex.Boolean, default=True) + #pars.add_argument("--svg", type=inkex.Boolean, default=True) + pars.add_argument("--circle", type=inkex.Boolean, default=True) + pars.add_argument("--clipPath", type=inkex.Boolean, default=True) + pars.add_argument("--defs", type=inkex.Boolean, default=True) + pars.add_argument("--desc", type=inkex.Boolean, default=True) + pars.add_argument("--ellipse", type=inkex.Boolean, default=True) + pars.add_argument("--image", type=inkex.Boolean, default=True) + pars.add_argument("--guide", type=inkex.Boolean, default=True) + pars.add_argument("--line", type=inkex.Boolean, default=True) + pars.add_argument("--path", type=inkex.Boolean, default=True) + pars.add_argument("--polyline", type=inkex.Boolean, default=True) + pars.add_argument("--polygon", type=inkex.Boolean, default=True) + pars.add_argument("--rect", type=inkex.Boolean, default=True) + pars.add_argument("--text", type=inkex.Boolean, default=True) + pars.add_argument("--tspan", type=inkex.Boolean, default=True) + pars.add_argument("--linearGradient", type=inkex.Boolean, default=True) + pars.add_argument("--radialGradient", type=inkex.Boolean, default=True) + pars.add_argument("--mask", type=inkex.Boolean, default=True) + pars.add_argument("--meshGradient", type=inkex.Boolean, default=True) + pars.add_argument("--meshRow", type=inkex.Boolean, default=True) + pars.add_argument("--meshPatch", type=inkex.Boolean, default=True) + pars.add_argument("--metadata", type=inkex.Boolean, default=True) + pars.add_argument("--script", type=inkex.Boolean, default=True) + pars.add_argument("--symbol", type=inkex.Boolean, default=True) + pars.add_argument("--stop", type=inkex.Boolean, default=True) + pars.add_argument("--style", type=inkex.Boolean, default=True) + pars.add_argument("--switch", type=inkex.Boolean, default=True) + pars.add_argument("--use", type=inkex.Boolean, default=True) + pars.add_argument("--flowRoot", type=inkex.Boolean, default=True) + pars.add_argument("--flowRegion", type=inkex.Boolean, default=True) + pars.add_argument("--flowPara", type=inkex.Boolean, default=True) + pars.add_argument("--marker", type=inkex.Boolean, default=True) + pars.add_argument("--pattern", type=inkex.Boolean, default=True) + pars.add_argument("--rdfRDF", type=inkex.Boolean, default=True) + pars.add_argument("--ccWork", type=inkex.Boolean, default=True) + + + def effect(self): + namespace = [] #a list of selected types we are going to process for filtering (dropping items) + #namespace.append("{http://www.w3.org/2000/svg}sodipodi") if self.options.sodipodi else "" #do not do this. it will crash InkScape + #namespace.append("{http://www.w3.org/2000/svg}svg") if self.options.svg else "" #we handle svg:svg the same type like svg:g + namespace.append("{http://www.w3.org/2000/svg}circle") if self.options.circle else "" + namespace.append("{http://www.w3.org/2000/svg}clipPath") if self.options.clipPath else "" + namespace.append("{http://www.w3.org/2000/svg}defs") if self.options.defs else "" + namespace.append("{http://www.w3.org/2000/svg}desc") if self.options.desc else "" + namespace.append("{http://www.w3.org/2000/svg}ellipse") if self.options.ellipse else "" + namespace.append("{http://www.w3.org/2000/svg}image") if self.options.image else "" + namespace.append("{http://www.w3.org/2000/svg}line") if self.options.line else "" + namespace.append("{http://www.w3.org/2000/svg}polygon") if self.options.polygon else "" + namespace.append("{http://www.w3.org/2000/svg}path") if self.options.path else "" + namespace.append("{http://www.w3.org/2000/svg}polyline") if self.options.polyline else "" + namespace.append("{http://www.w3.org/2000/svg}rect") if self.options.rect else "" + namespace.append("{http://www.w3.org/2000/svg}text") if self.options.text else "" + namespace.append("{http://www.w3.org/2000/svg}tspan") if self.options.tspan else "" + namespace.append("{http://www.w3.org/2000/svg}linearGradient") if self.options.linearGradient else "" + namespace.append("{http://www.w3.org/2000/svg}radialGradient") if self.options.radialGradient else "" + namespace.append("{http://www.w3.org/2000/svg}meshGradient") if self.options.meshGradient else "" + namespace.append("{http://www.w3.org/2000/svg}meshRow") if self.options.meshRow else "" + namespace.append("{http://www.w3.org/2000/svg}meshPatch") if self.options.meshPatch else "" + namespace.append("{http://www.w3.org/2000/svg}script") if self.options.script else "" + namespace.append("{http://www.w3.org/2000/svg}symbol") if self.options.symbol else "" + namespace.append("{http://www.w3.org/2000/svg}mask") if self.options.mask else "" + namespace.append("{http://www.w3.org/2000/svg}metadata") if self.options.metadata else "" + namespace.append("{http://www.w3.org/2000/svg}stop") if self.options.stop else "" + namespace.append("{http://www.w3.org/2000/svg}style") if self.options.style else "" + namespace.append("{http://www.w3.org/2000/svg}switch") if self.options.switch else "" + namespace.append("{http://www.w3.org/2000/svg}use") if self.options.use else "" + namespace.append("{http://www.w3.org/2000/svg}flowRoot") if self.options.flowRoot else "" + namespace.append("{http://www.w3.org/2000/svg}flowRegion") if self.options.flowRegion else "" + namespace.append("{http://www.w3.org/2000/svg}flowPara") if self.options.flowPara else "" + namespace.append("{http://www.w3.org/2000/svg}marker") if self.options.marker else "" + namespace.append("{http://www.w3.org/2000/svg}pattern") if self.options.pattern else "" + namespace.append("{http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd}guide") if self.options.guide else "" + namespace.append("{http://www.w3.org/1999/02/22-rdf-syntax-ns#}RDF") if self.options.rdfRDF else "" + namespace.append("{http://creativecommons.org/ns#}Work") if self.options.ccWork else "" + + #self.msg(namespace) + + #in case the user made a manual selection instead of whole document parsing, we need to collect all required elements first + def parseChildren(element): + if element not in selected: + selected.append(element) + if self.options.parsechildren == True: + children = element.getchildren() + if children is not None: + for child in children: + if child not in selected: + selected.append(child) + parseChildren(child) #go deeper and deeper + + #check the element for it's type and put it into the according list (either re-group or delete or just nothing) + def parseElement(self, element): + #if we only want to ungroup (flatten) the elements we just collect all elements in a list and put them in a new single group later + if self.options.operationmode == "ungroup_only": + if element not in self.allElements: + if element.tag != inkex.addNS('g','svg') and element.tag != inkex.addNS('svg','svg') and element.tag != inkex.addNS('namedview','sodipodi'): + self.allElements.append(element) + #if we dont want to ungroup but filter out elements, or ungroup and filter, we need to divide the elements with respect to the namespace (user selection) + elif self.options.operationmode == "filter_only" or self.options.operationmode == "ungroup_and_filter": + #self.msg(element.tag) + #inkex.utils.debug(element.tag) - uncomment to find out the namespace of new elements + if element.tag in namespace: #if the element is in namespace and no group type we will regroup the item. so we will not remove it + if element not in self.allElements: + self.allElements.append(element) + else: #we add all remaining items (except svg:g and svg:svg) into the list for deletion + #self.msg(element.tag) + if element.tag != inkex.addNS('g','svg') and element.tag != inkex.addNS('svg','svg') and element.tag != inkex.addNS('namedview','sodipodi'): + if element not in self.allDrops: + self.allDrops.append(element) + #finally the groups we want to get rid off are put into a another list. They will be deleted (depending on the mode) after parsing the element tree + if self.options.operationmode == "ungroup_only" or self.options.operationmode == "ungroup_and_filter": + if element.tag == inkex.addNS('g','svg') or element.tag == inkex.addNS('svg','svg'): + if element not in self.allGroups: + self.allGroups.append(element) + + applyTransformationsAvailable = False # at first we apply external extension + try: + import apply_transformations + applyTransformationsAvailable = True + except Exception as e: + # self.msg(e) + self.msg("Calling 'Apply Transformations' extension failed. Maybe the extension is not installed. You can download it from official InkScape Gallery. Skipping ...") + + if self.options.apply_transformations is True and applyTransformationsAvailable is True: + apply_transformations.ApplyTransformations().recursiveFuseTransform(self.document.getroot()) + + #check if we have selected elements or if we should parse the whole document instead + selected = [] #total list of elements to parse + if len(self.svg.selected) == 0: + for element in self.document.getroot().iter(tag=etree.Element): + if element != self.document.getroot(): + + selected.append(element) + else: + for element in self.svg.selected.values(): + parseChildren(element) + + #get all elements from the selection. + for element in selected: + parseElement(self, element) + + #some debugging block + #check output + #self.msg("--- Selected items (with or without children) ---") + #self.msg(selected) + #self.msg("--- All elements (except groups)---") + #self.msg(len(self.allElements)) + #self.msg(self.allElements) + #self.msg("--- All groups ---") + #self.msg(len(self.allGroups)) + #self.msg(self.allGroups) + #self.msg("--- All dropouts ---") + #self.msg(len(self.allDrops)) + #self.msg(self.allDrops) + + + migrate_log = "migrategroups.log" + + # Clean up possibly previously generated log file + if os.path.exists(migrate_log): + try: + os.remove(migrate_log) + except OSError as e: + self.msg("Error while deleting previously generated log file " + migrate_log) + + # show a list with items to delete. For ungroup mode it does not apply because we are not going to remove anything + if self.options.operationmode == "filter_only" or self.options.operationmode == "ungroup_and_filter": + if self.options.showdroplist: + self.msg(str(len(self.allDrops)) + " elements were removed during nodes while migration.") + if len(self.allDrops) > 100: #if we print too much to the output stream we will freeze InkScape forever wihtout any visual error message. So we write to file instead + migrate_log_file = open('migrategroups.log', 'w') + else: + migrate_log_file = None + for i in self.allDrops: + if i.get('id') is not None: + migrateString = i.tag.replace("{http://www.w3.org/2000/svg}","svg:") + " id:" + i.get('id') + else: + migrateString = i.tag #there are also some special elements without an id in the document, like rdf:RDF or cc:Work + if migrate_log_file is None: + self.msg(migrateString) + else: + migrate_log_file.write(migrateString + "\n") + if migrate_log_file is not None: + migrate_log_file.close() + self.msg("Detailed output was dumped into file " + os.path.join(os.getcwd(), migrate_log)) + + # remove all groups from the selection and form a new single group of it by copying with old IDs. + if self.options.operationmode == "ungroup_only" or self.options.operationmode == "ungroup_and_filter": + if len(self.allElements) > 0: + newGroup = self.document.getroot().add(inkex.Group()) #make a new group at root level + newGroup.set('id', self.svg.get_unique_id('migrate-')) #generate some known ID with the prefix 'migrate-' + if self.options.migrate_to == "layer": + newGroup.set(inkex.addNS('groupmode', 'inkscape'), 'layer') + + if self.options.shownewgroupname == True: + self.msg("The migrated elements are now in group with ID " + str(newGroup.get('id'))) + index = 0 + for element in self.allElements: #we have a list of elements which does not cotain any other elements like svg:g or svg:svg + newGroup.insert(index, element) #we do not copy any elements. we just rearrange them by moving to another place (group index) + index += 1 #we must count up the index or we would overwrite each previous element + + # remove the stuff from drop list list. this has to be done before we drop the groups where they are located in + if self.options.operationmode == "filter_only" or self.options.operationmode == "ungroup_and_filter": + if len(self.allDrops) > 0: + for dropElement in self.allDrops: + if dropElement.getparent() is not None: + dropElement.getparent().remove(dropElement) + + # remove all the obsolete groups which are left over from ungrouping (flattening) + if self.options.operationmode == "ungroup_only" or self.options.operationmode == "ungroup_and_filter": + if len(self.allGroups) > 0: + for group in self.allGroups: + if group.getparent() is not None: + group.getparent().remove(group) + + # finally removed dangling empty groups using external extension (if installed) + if self.options.cleanup == True: + try: + import remove_empty_groups + remove_empty_groups.RemoveEmptyGroups.effect(self) + except: + self.msg("Calling 'Remove Empty Groups' extension failed. Maybe the extension is not installed. You can download it from official InkScape Gallery.") + +if __name__ == '__main__': + UngrouperAndElementMigratorFilter().run() \ No newline at end of file