diff options
Diffstat (limited to 'lib/tartan')
| -rw-r--r-- | lib/tartan/colors.py | 159 | ||||
| -rw-r--r-- | lib/tartan/fill_element.py | 24 | ||||
| -rw-r--r-- | lib/tartan/palette.py | 243 | ||||
| -rw-r--r-- | lib/tartan/svg.py | 592 | ||||
| -rw-r--r-- | lib/tartan/utils.py | 262 |
5 files changed, 1280 insertions, 0 deletions
diff --git a/lib/tartan/colors.py b/lib/tartan/colors.py new file mode 100644 index 00000000..790ef20b --- /dev/null +++ b/lib/tartan/colors.py @@ -0,0 +1,159 @@ +# Authors: see git history +# +# Copyright (c) 2023 Authors +# Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details. +# Additional credits to https://github.com/clsn/pyTartan + +# tartan colors according to https://www.tartanregister.gov.uk/docs/Colour_shades.pdf (as of december 2023) +# Problem: ambigious due to multiple usage of same color code + +def string_to_color(color_string: str) -> str: + """ + Converts a color code from the tartan register to a hex color code or defaults to empty + + :param color_string: color code from the tartan register + :returns: hex color code or empty string + """ + standards = { + # 'LR': '#F4CCCC', # Light Red + 'LR': '#E87878', # Light Red + # 'LR': '#F04DB0', # Light Red + # 'R': '#A00048', # Red + # 'R': '#FA4B00', # Red + 'R': '#FF0000', # Red + # 'R': '#DC0000', # Red + # 'R': '#C80000', # Red + # 'R': '#C82828', # Red + # 'R': '#C8002C', # Red + # 'R': '#B03000', # Red + # 'DR': '#A00000', # Dark Red + # 'DR': '#960000', # Dark Red + # 'DR': '#960028', # Dark Red + 'DR': '#880000', # Dark Red + # 'DR': '#800028', # Dark Red + # 'DR': '#781C38', # Dark Red + # 'DR': '#4C0000', # Dark Red + # 'DR': '#901C38', # Dark Red + # 'DR': '#680028', # Dark Red + # 'O': '#EC8048', # Orange + # 'O': '#E86000', # Orange + 'O': '#FF5000', # Orange + # 'O': '#DC943C', # Orange + # 'O': '#D87C00', # Orange + 'DO': '#BE7832', # Dark Orange + 'LY': '#F9F5C8', # Light Yellow + # 'LY': '#F8E38C', # Light Yellow + 'Y': '#FFFF00', # Yellow + # 'Y': '#FFE600', # Yellow + # 'Y': '#FFD700', # Yellow + # 'Y': '#FCCC00', # Yellow + # 'Y': '#E0A126', # Yellow + # 'Y': '#E8C000', # Yellow + # 'Y': '#D8B000', # Yellow + # 'DY': '#BC8C00', # Dark Yellow + # 'DY': '#C89800', # Dark Yellow + 'DY': '#C88C00', # Dark Yellow + # 'LG': '#789484', # Light Green + # 'LG': '#C4BC68', # Light Green + # 'LG': '#9C9C00', # Light Green + 'LG': '#ACD74A', # Light Green + # 'LG': '#86C67C', # Light Green + # 'LG': '#649848', # Light Green + # 'G': '#008B00', # Green + # 'G': '#408060', # Green + 'G': '#289C18', # Green + # 'G': '#006400', # Green + # 'G': '#007800', # Green + # 'G': '#3F5642', # Green + # 'G': '#767E52', # Green + # 'G': '#5C6428', # Green + # 'G': '#00643C', # Green + # 'G': '#146400', # Green + # 'G': '#006818', # Green + # 'G': '#004C00', # Green + # 'G': '#285800', # Green + # 'G': '#005020', # Green + # 'G': '#005448', # Green + # 'DG': '#003C14', # Dark Green + # 'DG': '#003820', # Dark Green + 'DG': '#004028', # Dark Green + # 'DG': '#002814', # Dark Green + # 'LB': '#98C8E8', # Light Blue + 'LB': '#82CFFD', # Light Blue + # 'LB': '#00FCFC', # Light Blue + # 'B': '#BCC3D2', # Blue + # 'B': '#048888', # Blue + # 'B': '#3C82AF', # Blue + # 'B': '#5C8CA8', # Blue + # 'B': '#2888C4', # Blue + # 'B': '#48A4C0', # Blue + # 'B': '#2474E8', # Blue + # 'B': '#0596FA', # Blue + 'B': '#0000FF', # Blue + # 'B': '#3850C8', # Blue + # 'B': '#788CB4', # Blue + # 'B': '#5F749C', # Blue + # 'B': '#1870A4', # Blue + # 'B': '#1474B4', # Blue + # 'B': '#0000CD', # Blue + # 'B': '#2C4084', # Blue + # 'DB': '#055183', # Dark Blue + # 'DB': '#003C64', # Dark Blue + 'DB': '#00008C', # Dark Blue + # 'DB': '#2C2C80', # Dark Blue + # 'DB': '#1C0070', # Dark Blue + # 'DB': '#000064', # Dark Blue + # 'DB': '#202060', # Dark Blue + # 'DB': '#000048', # Dark Blue + # 'DB': '#141E46', # Dark Blue + # 'DB': '#1C1C50', # Dark Blue + 'LP': '#A8ACE8', # Light Purple + # 'LP': '#C49CD8', # Light Purple + # 'LP': '#806D84', # Light Purple + # 'LP': '#9C68A4', # Light Purple + # 'P': '#9058D8', # Purple + # 'P': '#AA00FF', # Purple + # 'P': '#B458AC', # Purple + # 'P': '#6C0070', # Purple + # 'P': '#5A008C', # Purple + # 'P': '#64008C', # Purple + 'P': '#780078', # Purple + # 'DP': '#440044', # Dark Purple + 'DP': '#1E0948', # Dark Purple + # 'W': '#E5DDD1', # White + # 'W': '#E8CCB8', # White + # 'W': '#F0E0C8', # White + # 'W': '#FCFCFC', # White + 'W': '#FFFFFF', # White + # 'W': '#F8F8F8', # White + 'LN': '#E0E0E0', # Light Grey + # 'N': '#C8C8C8', # Grey + # 'N': '#C0C0C0', # Grey + # 'N': '#B0B0B0', # Grey + 'N': '#A0A0A0', # Grey + # 'N': '#808080', # Grey + # 'N': '#888888', # Grey + # 'N': '#646464', # Grey + # 'N': '#505050', # Dark Grey + 'DN': '#555a64', # Dark Grey + # 'DN': '#1C1714', # Dark Grey + # 'DN': '#14283C', # Dark Grey + # 'DN': '#1C1C1C', # Dark Grey + # 'K': '#101010', # Black + 'K': '#000000', # Black + # 'LT': '#A08858', # Light Brown + # 'LT': '#8C7038', # Light Brown + 'LT': '#A07C58', # Light Brown + # 'LT': '#B07430', # Light Brown + # 'T': '#98481C', # Brown + 'T': '#603800', # Brown + # 'T': '#604000', # Brown + # 'T': '#503C14', # Brown + # 'DT': '#4C3428', # Dark Brown + 'DT': '#441800', # Dark Brown + # 'DT': '#230D00' # Dark Brown + } + try: + return standards[color_string.upper()] + except KeyError: + return '' diff --git a/lib/tartan/fill_element.py b/lib/tartan/fill_element.py new file mode 100644 index 00000000..34139e6c --- /dev/null +++ b/lib/tartan/fill_element.py @@ -0,0 +1,24 @@ +# Authors: see git history +# +# Copyright (c) 2023 Authors +# Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details. + +from inkex import BaseElement + + +def prepare_tartan_fill_element(element: BaseElement) -> None: + """Prepares an svg element to be rendered as a tartan_fill embroidery element + + :param element: svg element with a fill color (path, rectangle, or circle) + """ + parent_group = element.getparent() + if parent_group.get_id().startswith('inkstitch-tartan'): + # apply tartan group transform to element + transform = element.transform @ parent_group.transform + element.set('transform', transform) + # remove tartan group and place element in parent group + outer_group = parent_group.getparent() + outer_group.insert(outer_group.index(parent_group), element) + outer_group.remove(parent_group) + # make sure the element is invisible + element.style['display'] = 'inline' diff --git a/lib/tartan/palette.py b/lib/tartan/palette.py new file mode 100644 index 00000000..12d191a7 --- /dev/null +++ b/lib/tartan/palette.py @@ -0,0 +1,243 @@ +# Authors: see git history +# +# Copyright (c) 2023 Authors +# Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details. +# Additional credits to: https://github.com/clsn/pyTartan + +import re +from typing import List + +import wx +from inkex import Color + +from .colors import string_to_color + + +class Palette: + """Holds information about the tartan palette""" + def __init__( + self, + palette_code: str = '', + palette_stripes: List[list] = [[], []], + symmetry: bool = True, + equal_warp_weft: bool = True, + tt_unit: float = 0.5 + ) -> None: + """ + :param palette_code: the palette code + :param palette_stripes: the palette stripes, lists of warp and weft stripe dictionaries + :param symmetry: reflective sett (True) / repeating sett (False) + :param equal_warp_weft:wether warp and weft are equal or not + :param tt_unit: mm per thread (used for the scottish register threadcount) + """ + self.palette_code = palette_code + self.palette_stripes = palette_stripes + self.symmetry = symmetry + self.equal_warp_weft = equal_warp_weft + self.tt_unit = tt_unit + + def __repr__(self) -> str: + return self.palette_code + + def update_symmetry(self, symmetry: bool) -> None: + self.symmetry = symmetry + self.update_code() + + def update_from_stripe_sizer(self, sizers: List[wx.BoxSizer], symmetry: bool = True, equal_warp_weft: bool = True) -> None: + """ + Update palette code from stripes (customize panel) + + :param sizers: a list of the stripe sizers + :param symmetry: reflective sett (True) / repeating sett (False) + :param equal_warp_weft: wether warp and weft are equal or not + """ + self.symmetry = symmetry + self.equal_warp_weft = equal_warp_weft + + self.palette_stripes = [[], []] + for i, outer_sizer in enumerate(sizers): + stripes = [] + for stripe_sizer in outer_sizer.Children: + stripe = {'render': True, 'color': '#000000', 'width': '5'} + stripe_info = stripe_sizer.GetSizer() + for color in stripe_info.GetChildren(): + widget = color.GetWindow() + if isinstance(widget, wx.CheckBox): + # in embroidery it is ok to have gaps between the stripes + if not widget.GetValue(): + stripe['render'] = False + elif isinstance(widget, wx.ColourPickerCtrl): + stripe['color'] = widget.GetColour().GetAsString(wx.C2S_HTML_SYNTAX) + elif isinstance(widget, wx.SpinCtrlDouble): + stripe['width'] = widget.GetValue() + elif isinstance(widget, wx.Button) or isinstance(widget, wx.StaticText): + continue + stripes.append(stripe) + self.palette_stripes[i] = stripes + if self.equal_warp_weft: + self.palette_stripes[1] = stripes + break + self.update_code() + + def update_from_code(self, code: str) -> None: + """ + Update stripes (customize panel) according to the code applied by the user + Converts code to valid Ink/Stitch code + + :param code: the tartan pattern code to apply + """ + self.symmetry = True + if '...' in code: + self.symmetry = False + self.equal_warp_weft = True + if '|' in code: + self.equal_warp_weft = False + code = code.replace('/', '') + code = code.replace('...', '') + self.palette_stripes = [[], []] + + if "Threadcount" in code: + self.parse_threadcount_code(code) + elif '(' in code: + self.parse_inkstitch_code(code) + else: + self.parse_simple_code(code) + + if self.equal_warp_weft: + self.palette_stripes[1] = self.palette_stripes[0] + + self.update_code() + + def update_code(self) -> None: + """Updates the palette code, reading from stripe settings (customize panel)""" + code = [] + for i, direction in enumerate(self.palette_stripes): + for stripe in direction: + render = '' if stripe['render'] else '?' + code.append(f"({stripe['color']}){render}{stripe['width']}") + if i == 0 and self.equal_warp_weft is False: + code.append("|") + else: + break + if self.symmetry and len(code) > 0: + code[0] = code[0].replace(')', ')/') + code[-1] = code[-1].replace(')', ')/') + code_str = ' '.join(code) + if not self.symmetry: + code_str = f'...{code}...' + self.palette_code = code_str + + def parse_simple_code(self, code: str) -> None: + """Example code: + B24 W4 B24 R2 K24 G24 W2 + + Each letter stands for a color defined in .colors.py (if not recognized, defaults to black) + The number indicates the threadcount (width) of the stripe + The width of one thread is user defined + + :param code: the tartan pattern code to apply + """ + stripes = [] + stripe_info = re.findall(r'([a-zA-Z]+)(\?)?([0-9.]*)', code) + for color, render, width in stripe_info: + if not width: + continue + color = string_to_color(color) + width = float(width) * self.tt_unit + if not color: + color = '#000000' + render = '?' + stripes.append({'render': not bool(render), 'color': color, 'width': float(width)}) + self.palette_stripes[0] = stripes + + def parse_inkstitch_code(self, code_str: str) -> None: + """Example code: + (#0000FF)/2.4 (#FFFFFF)0.4 (#0000FF)2.4 (#FF0000)0.2 (#000000)2.4 (#006400)2.4 (#FFFFFF)/0.2 + + | = separator warp and weft (if not equal) + / = indicates a symmetric sett + ... = indicates an asymmetric sett + + :param code_str: the tartan pattern code to apply + """ + code = code_str.split('|') + for i, direction in enumerate(code): + stripes = [] + stripe_info = re.findall(r'\(([0-9A-Za-z#]+)\)(\?)?([0-9.]+)', direction) + for color, render, width in stripe_info: + try: + # on macOS we need to run wxpython color method inside the app otherwise + # the color picker has issues in some cases to accept our input + color = wx.Colour(color).GetAsString(wx.C2S_HTML_SYNTAX) + except wx.PyNoAppError: + # however when we render an embroidery element we do not want to open wx.App + color = str(Color(color).to_named()) + if not color: + color = '#000000' + render = False + stripes.append({'render': not bool(render), 'color': color, 'width': float(width)}) + self.palette_stripes[i] = stripes + + def parse_threadcount_code(self, code: str) -> None: + """Read in and work directly from a tartanregister.gov.uk threadcount response + Example code: + Threadcount: + B24W4B24R2K24G24W2 + + Palette: + B=0000FFBLUE;W=FFFFFFWHITE;R=FF0000RED;K=000000BLACK;G=289C18GREEN; + + Threadcount given over a half sett with full count at the pivots. + + Colors in the threadcount are defined by Letters. The Palette section declares the rgb value + + :param code: the tartan pattern code to apply + """ + if 'full sett' in code: + self.symmetry = False + else: + self.symmetry = True + + colors = [] + thread_code = '' + stripes = [] + lines = code.splitlines() + i = 0 + while i < len(lines): + line = lines[i].strip() + if 'Threadcount:' in line and len(lines) > i: + thread_code = lines[i+1] + elif line.startswith('Palette:'): + palette = lines[i+1] + colors = re.findall(r'([A-Za-z]+)=#?([0-9afA-F]{6})', palette) + color_dict = dict(colors) + i += 1 + + stripe_info = re.findall(r'([a-zA-Z]+)([0-9.]*)', thread_code) + for color, width in stripe_info: + render = True + try: + color = f'#{color_dict[color]}' + except KeyError: + color = '#000000' + render = False + width = float(width) * self.tt_unit + stripes.append({'render': render, 'color': color, 'width': width}) + + self.palette_stripes[0] = stripes + + def get_palette_width(self, scale: int, min_width: float, direction: int = 0) -> float: + """ + Get the rendered width of the tartan palette + :param scale: the scale value (percent) for the pattern + :param min_width: min stripe width (before it is rendered as running stitch). + Smaller stripes have 0 width. + :param direction: 0 (warp) or 1 (weft) + :returns: the width of all tartan stripes in given direction + """ + width = 0 + for stripe in self.palette_stripes[direction]: + stripe_width = stripe['width'] * (scale / 100) + if stripe_width >= min_width or not stripe['render']: + width += stripe_width + return width diff --git a/lib/tartan/svg.py b/lib/tartan/svg.py new file mode 100644 index 00000000..4ca48f02 --- /dev/null +++ b/lib/tartan/svg.py @@ -0,0 +1,592 @@ +# Authors: see git history +# +# Copyright (c) 2023 Authors +# Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details. + +import time +from collections import defaultdict +from copy import copy +from itertools import chain +from typing import List, Optional, Tuple + +from inkex import BaseElement, Group, Path, PathElement +from networkx import MultiGraph, is_empty +from shapely import (LineString, MultiLineString, MultiPolygon, Point, Polygon, + dwithin, minimum_bounding_radius, reverse) +from shapely.affinity import scale +from shapely.ops import linemerge, substring + +from ..commands import add_commands +from ..elements import FillStitch +from ..stitches.auto_fill import (PathEdge, build_fill_stitch_graph, + build_travel_graph, find_stitch_path, + graph_make_valid, which_outline) +from ..svg import PIXELS_PER_MM, get_correction_transform +from ..utils import DotDict, ensure_multi_line_string +from .palette import Palette +from .utils import sort_fills_and_strokes, stripes_to_shapes + + +class TartanSvgGroup: + """Generates the tartan pattern for svg element tartans""" + + def __init__(self, settings: DotDict) -> None: + """ + :param settings: the tartan settings + """ + self.rotate = settings['rotate'] + self.scale = settings['scale'] + self.offset_x = settings['offset_x'] * PIXELS_PER_MM + self.offset_y = settings['offset_y'] * PIXELS_PER_MM + self.output = settings['output'] + self.stitch_type = settings['stitch_type'] + self.row_spacing = settings['row_spacing'] + self.angle_warp = settings['angle_warp'] + self.angle_weft = settings['angle_weft'] + self.min_stripe_width = settings['min_stripe_width'] + self.bean_stitch_repeats = settings['bean_stitch_repeats'] + + self.palette = Palette() + self.palette.update_from_code(settings['palette']) + self.symmetry = self.palette.symmetry + self.stripes = self.palette.palette_stripes + self.warp, self.weft = self.stripes + if self.palette.get_palette_width(self.scale, self.min_stripe_width) == 0: + self.warp = [] + if self.palette.get_palette_width(self.scale, self.min_stripe_width, 1) == 0: + self.weft = [] + if self.palette.equal_warp_weft: + self.weft = self.warp + + def __repr__(self) -> str: + return f'TartanPattern({self.rotate}, {self.scale}, ({self.offset_x}, {self.offset_y}), {self.symmetry}, {self.warp}, {self.weft})' + + def generate(self, outline: BaseElement) -> Group: + """ + Generates a svg group which holds svg elements to represent the tartan pattern + + :param outline: the outline to be filled with the tartan pattern + """ + parent_group = outline.getparent() + if parent_group.get_id().startswith('inkstitch-tartan'): + # remove everything but the tartan outline + for child in parent_group.iterchildren(): + if child != outline: + parent_group.remove(child) + group = parent_group + else: + group = Group() + group.set('id', f'inkstitch-tartan-{int(time.time())}') + parent_group.append(group) + + outline_shape = FillStitch(outline).shape + transform = get_correction_transform(outline) + dimensions, rotation_center = self._get_dimensions(outline_shape) + + warp = stripes_to_shapes( + self.warp, + dimensions, + outline_shape, + self.rotate, + rotation_center, + self.symmetry, + self.scale, + self.min_stripe_width + ) + warp_routing_lines = self._get_routing_lines(warp) + warp = self._route_shapes(warp_routing_lines, outline_shape, warp) + warp = self._shapes_to_elements(warp, warp_routing_lines, transform) + + weft = stripes_to_shapes( + self.weft, + dimensions, + outline_shape, + self.rotate, + rotation_center, + self.symmetry, + self.scale, + self.min_stripe_width, + True + ) + weft_routing_lines = self._get_routing_lines(weft) + weft = self._route_shapes(weft_routing_lines, outline_shape, weft, True) + weft = self._shapes_to_elements(weft, weft_routing_lines, transform, True) + + fills, strokes = self._combine_shapes(warp, weft, outline_shape) + fills, strokes = sort_fills_and_strokes(fills, strokes) + + for color, fill_elements in fills.items(): + for element in fill_elements: + group.append(element) + if self.stitch_type == "auto_fill": + self._add_command(element) + else: + element.pop('inkstitch:start') + element.pop('inkstitch:end') + + for color, stroke_elements in strokes.items(): + for element in stroke_elements: + group.append(element) + + # set outline invisible + outline.style['display'] = 'none' + group.append(outline) + return group + + def _get_command_position(self, fill: FillStitch, point: Tuple[float, float]) -> Point: + """ + Shift command position out of the element shape + + :param fill: the fill element to which to attach the command + :param point: position where the command should point to + """ + dimensions, center = self._get_dimensions(fill.shape) + line = LineString([center, point]) + fact = 20 / line.length + line = scale(line, xfact=1+fact, yfact=1+fact, origin=center) + pos = line.coords[-1] + return Point(pos) + + def _add_command(self, element: BaseElement) -> None: + """ + Add a command to given svg element + + :param element: svg element to which to attach the command + """ + if not element.style('fill'): + return + fill = FillStitch(element) + if fill.shape.is_empty: + return + start = element.get('inkstitch:start') + end = element.get('inkstitch:end') + if start: + start = start[1:-1].split(',') + add_commands(fill, ['fill_start'], self._get_command_position(fill, (float(start[0]), float(start[1])))) + element.pop('inkstitch:start') + if end: + end = end[1:-1].split(',') + add_commands(fill, ['fill_end'], self._get_command_position(fill, (float(end[0]), float(end[1])))) + element.pop('inkstitch:end') + + def _route_shapes(self, routing_lines: defaultdict, outline_shape: MultiPolygon, shapes: defaultdict, weft: bool = False) -> defaultdict: + """ + Route polygons and linestrings + + :param routing_lines: diagonal lines representing the tartan stripes used for routing + :param outline_shape: the shape to be filled with the tartan pattern + :param shapes: the tartan shapes (stripes) + :param weft: wether to render warp or weft oriented stripes + """ + routed = defaultdict(list) + for color, lines in routing_lines.items(): + routed_polygons = self._get_routed_shapes('polygon', shapes[color][0], lines[0], outline_shape, weft) + routed_linestrings = self._get_routed_shapes('linestring', None, lines[1], outline_shape, weft) + routed[color] = [routed_polygons, routed_linestrings] + return routed + + def _get_routed_shapes( + self, + geometry_type: str, + polygons: Optional[List[Polygon]], + lines: Optional[List[LineString]], + outline_shape: MultiPolygon, + weft: bool + ): + """ + Find path for given elements + + :param geometry_type: wether to route 'polygon' or 'linestring' + :param polygons: list of polygons to route + :param lines: list of lines to route (for polygon routing these are the routing lines) + :param outline_shape: the shape to be filled with the tartan pattern + :param weft: wether to route warp or weft oriented stripes + :returns: a list of routed elements + """ + if not lines: + return [] + + if weft: + starting_point = lines[-1].coords[-1] + ending_point = lines[0].coords[0] + else: + starting_point = lines[0].coords[0] + ending_point = lines[-1].coords[-1] + + segments = [list(line.coords) for line in lines if line.length > 5] + + fill_stitch_graph = build_fill_stitch_graph(outline_shape, segments, starting_point, ending_point) + if is_empty(fill_stitch_graph): + return [] + graph_make_valid(fill_stitch_graph) + travel_graph = build_travel_graph(fill_stitch_graph, outline_shape, 0, False) + path = find_stitch_path(fill_stitch_graph, travel_graph, starting_point, ending_point) + return self._path_to_shapes(path, fill_stitch_graph, polygons, geometry_type, outline_shape) + + def _path_to_shapes( + self, + path: List[PathEdge], + fill_stitch_graph: MultiGraph, + polygons: Optional[List[Polygon]], + geometry_type: str, + outline_shape: MultiPolygon + ) -> list: + """ + Return elements in given order (by path) and add strokes for travel between elements + + :param path: routed PathEdges + :param fill_stitch_graph: the stitch graph + :param polygons: the polygon shapes (if not LineStrings) + :param geometry_type: wether to render 'polygon' or 'linestring' segments + :param outline_shape: the shape to be filkled with the tartan pattern + :returns: a list of routed shape elements + """ + outline = MultiLineString() + travel_linestring = LineString() + routed_shapes = [] + start_distance = 0 + for edge in path: + start, end = edge + if edge.is_segment(): + if not edge.key == 'segment': + # networkx fixed the shape for us, we do not really want to insert the element twice + continue + if not travel_linestring.is_empty: + # insert edge run before segment + travel_linestring = self._get_shortest_travel(start, outline, travel_linestring) + if travel_linestring.geom_type == "LineString": + routed_shapes.append(travel_linestring) + travel_linestring = LineString() + routed = self._edge_segment_to_element(edge, geometry_type, fill_stitch_graph, polygons) + routed_shapes.extend(routed) + elif routed_shapes: + # prepare edge run between segments + if travel_linestring.is_empty: + outline_index = which_outline(outline_shape, start) + outline = ensure_multi_line_string(outline_shape.boundary).geoms[outline_index] + start_distance = outline.project(Point(start)) + travel_linestring = self._get_travel(start, end, outline) + else: + end_distance = outline.project(Point(end)) + travel_linestring = substring(outline, start_distance, end_distance) + return routed_shapes + + def _edge_segment_to_element( + self, + edge: PathEdge, + geometry_type: str, + fill_stitch_graph: MultiGraph, + polygons: Optional[List[Polygon]] + ) -> list: + """ + Turns an edge back into an element + + :param edge: edge with start and end point information + :param geometry_type: wether to convert a 'polygon' or 'linestring' + :param fill_stitch_graph: the stitch graph + :param polygons: list of polygons if geom_type is 'poylgon' + :returns: a list of routed elements. + Polygons are wrapped in dictionaries to preserve information about start and end point. + """ + start, end = edge + routed = [] + if geometry_type == 'polygon' and polygons is not None: + polygon = self._find_polygon(polygons, Point(start)) + if polygon: + routed.append({'shape': polygon, 'start': start, 'end': end}) + elif geometry_type == 'linestring': + try: + line = fill_stitch_graph[start][end]['segment'].get('geometry') + except KeyError: + line = LineString([start, end]) + if not line.is_empty: + if start != tuple(line.coords[0]): + line = line.reverse() + if line: + routed.append(line) + return routed + + @staticmethod + def _get_shortest_travel(start: Tuple[float, float], outline: LineString, travel_linestring: LineString) -> LineString: + """ + Replace travel_linestring with a shorter travel line if possible + + :param start: travel starting point + :param outline: the part of the outline which is nearest to the starting point + :param travel_linestring: predefined travel which will be replaced if it is longer + """ + if outline.length / 2 < travel_linestring.length: + short_travel = outline.difference(travel_linestring) + if short_travel.geom_type == "MultiLineString": + short_travel = linemerge(short_travel) + if short_travel.geom_type == "LineString": + if Point(short_travel.coords[-1]).distance(Point(start)) > Point(short_travel.coords[0]).distance(Point(start)): + short_travel = reverse(short_travel) + return short_travel + return travel_linestring + + @staticmethod + def _find_polygon(polygons: List[Polygon], point: Tuple[float, float]) -> Optional[Polygon]: + """ + Find the polygon for a given point + + :param polygons: a list of polygons to chose from + :param point: the point to match a polygon to + :returns: a matching polygon or None if no polygon could be found + """ + for polygon in polygons: + if dwithin(point, polygon, 0.01): + return polygon + + return None + + @staticmethod + def _get_routing_lines(shapes: defaultdict) -> defaultdict: + """ + Generate routing lines for given polygon shapes + + :param shapes: polygon shapes grouped by color + :returns: color grouped dictionary with lines which can be used for routing + """ + routing_lines = defaultdict(list) + for color, elements in shapes.items(): + routed: list = [[], []] + for polygon in elements[0]: + bounding_coords = polygon.minimum_rotated_rectangle.exterior.coords + routing_line = LineString([bounding_coords[0], bounding_coords[2]]) + routing_line = ensure_multi_line_string(routing_line.intersection(polygon)).geoms + routed[0].append(LineString([routing_line[0].coords[0], routing_line[-1].coords[-1]])) + routed[1].extend(elements[1]) + routing_lines[color] = routed + return routing_lines + + def _shapes_to_elements(self, shapes: defaultdict, routed_lines: defaultdict, transform: str, weft=False) -> defaultdict: + """ + Generates svg elements from given shapes + + :param shapes: lists of shapes grouped by color + :param routed_lines: lists of routed lines grouped by color + :param transform: correction transform to apply to the elements + :param weft: wether to render warp or weft oriented stripes + :returns: lists of svg elements grouped by color + """ + shapes_copy = copy(shapes) + for color, shape in shapes_copy.items(): + elements: list = [[], []] + polygons, linestrings = shape + for polygon in polygons: + if isinstance(polygon, dict): + path_element = self._polygon_to_path(color, polygon['shape'], weft, transform, polygon['start'], polygon['end']) + if self.stitch_type == 'legacy_fill': + polygon_start = Point(polygon['start']) + path_element = self._adapt_legacy_fill_params(path_element, polygon_start) + elements[0].append(path_element) + elif polygon.geom_type == "Polygon": + elements[0].append(self._polygon_to_path(color, polygon, weft, transform)) + else: + elements[0].append(self._linestring_to_path(color, polygon, transform, True)) + for line in linestrings: + segment = line.difference(MultiLineString(routed_lines[color][1])).is_empty + if segment: + linestring = self._linestring_to_path(color, line, transform) + else: + linestring = self._linestring_to_path(color, line, transform, True) + elements[1].append(linestring) + shapes[color] = elements + return shapes + + @staticmethod + def _adapt_legacy_fill_params(path_element: PathElement, start: Point) -> PathElement: + """ + Find best legacy fill param setting + Flip and reverse so that the fill starts as near as possible to the starting point + + :param path_element: a legacy fill svg path element + :param start: the starting point + :returns: the adapted path element + """ + if not FillStitch(path_element).to_stitch_groups(None): + return path_element + blank = Point(FillStitch(path_element).to_stitch_groups(None)[0].stitches[0]) + path_element.set('inkstitch:reverse', True) + reverse = Point(FillStitch(path_element).to_stitch_groups(None)[0].stitches[0]) + path_element.set('inkstitch:flip', True) + reverse_flip = Point(FillStitch(path_element).to_stitch_groups(None)[0].stitches[0]) + path_element.pop('inkstitch:revers') + flip = Point(FillStitch(path_element).to_stitch_groups(None)[0].stitches[0]) + start_positions = [blank.distance(start), reverse.distance(start), reverse_flip.distance(start), flip.distance(start)] + best_setting = start_positions.index(min(start_positions)) + + if best_setting == 0: + path_element.set('inkstitch:reverse', False) + path_element.set('inkstitch:flip', False) + elif best_setting == 1: + path_element.set('inkstitch:reverse', True) + path_element.set('inkstitch:flip', False) + elif best_setting == 2: + path_element.set('inkstitch:reverse', True) + path_element.set('inkstitch:flip', True) + elif best_setting == 3: + path_element.set('inkstitch:reverse', False) + path_element.set('inkstitch:flip', True) + return path_element + + def _combine_shapes(self, warp: defaultdict, weft: defaultdict, outline: MultiPolygon) -> Tuple[defaultdict, defaultdict]: + """ + Combine warp and weft elements into color groups, but separated into polygons and linestrings + + :param warp: dictionary with warp polygons and linestrings grouped by color + :param weft: dictionary with weft polygons and linestrings grouped by color + :returns: a dictionary with polygons and a dictionary with linestrings each grouped by color + """ + polygons: defaultdict = defaultdict(list) + linestrings: defaultdict = defaultdict(list) + for color, shapes in chain(warp.items(), weft.items()): + start = None + end = None + if shapes[0]: + if polygons[color]: + start = polygons[color][-1].get('inkstitch:end') + end = shapes[0][0].get('inkstitch:start') + if start and end: + start = start[1:-1].split(',') + end = end[1:-1].split(',') + first_outline = ensure_multi_line_string(outline.boundary).geoms[0] + travel = self._get_travel(start, end, first_outline) + travel_path_element = self._linestring_to_path(color, travel, shapes[0][0].get('transform', ''), True) + polygons[color].append(travel_path_element) + polygons[color].extend(shapes[0]) + if shapes[1]: + if linestrings[color]: + start = tuple(list(linestrings[color][-1].get_path().end_points)[-1]) + elif polygons[color]: + start = polygons[color][-1].get('inkstitch:end') + if start: + start = start[1:-1].split(',') + end = tuple(list(shapes[1][0].get_path().end_points)[0]) + if start and end: + first_outline = ensure_multi_line_string(outline.boundary).geoms[0] + travel = self._get_travel(start, end, first_outline) + travel_path_element = self._linestring_to_path(color, travel, shapes[1][0].get('transform', ''), True) + linestrings[color].append(travel_path_element) + linestrings[color].extend(shapes[1]) + + return polygons, linestrings + + @staticmethod + def _get_travel(start: Tuple[float, float], end: Tuple[float, float], outline: LineString) -> LineString: + """ + Returns a travel line from start point to end point along the outline + + :param start: starting point + :param end: ending point + :param outline: the outline + :returns: a travel LineString from start to end along the outline + """ + start_distance = outline.project(Point(start)) + end_distance = outline.project(Point(end)) + return substring(outline, start_distance, end_distance) + + def _get_dimensions(self, outline: MultiPolygon) -> Tuple[Tuple[float, float, float, float], Point]: + """ + Calculates the dimensions for the tartan pattern. + Make sure it is big enough for pattern rotations. + + :param outline: the shape to be filled with a tartan pattern + :returns: [0] a list with boundaries and [1] the center point (for rotations) + """ + bounds = outline.bounds + minx, miny, maxx, maxy = bounds + minx -= self.offset_x + miny -= self.offset_y + center = LineString([(bounds[0], bounds[1]), (bounds[2], bounds[3])]).centroid + + if self.rotate != 0: + # add as much space as necessary to perform a rotation without producing gaps + min_radius = minimum_bounding_radius(outline) + minx = center.x - min_radius + miny = center.y - min_radius + maxx = center.x + min_radius + maxy = center.y + min_radius + return (float(minx), float(miny), float(maxx), float(maxy)), center + + def _polygon_to_path( + self, + color: str, + polygon: Polygon, + weft: bool, + transform: str, + start: Optional[Tuple[float, float]] = None, + end: Optional[Tuple[float, float]] = None + ) -> Optional[PathElement]: + """ + Convert a polygon to an svg path element + + :param color: hex color + :param polygon: the polygon to convert + :param weft: wether to render as warp or weft + :param transform: string of the transform to apply to the element + :param start: start position for routing + :param end: end position for routing + :returns: an svg path element or None if the polygon is empty + """ + path = Path(list(polygon.exterior.coords)) + path.close() + if path is None: + return None + + for interior in polygon.interiors: + interior_path = Path(list(interior.coords)) + interior_path.close() + path += interior_path + + path_element = PathElement( + attrib={'d': str(path)}, + style=f'fill:{color};fill-opacity:0.6;', + transform=transform + ) + + if self.stitch_type == 'legacy_fill': + path_element.set('inkstitch:fill_method', 'legacy_fill') + elif self.stitch_type == 'auto_fill': + path_element.set('inkstitch:fill_method', 'auto_fill') + path_element.set('inkstitch:underpath', False) + + path_element.set('inkstitch:fill_underlay', False) + path_element.set('inkstitch:row_spacing_mm', self.row_spacing) + if weft: + angle = self.angle_weft - self.rotate + path_element.set('inkstitch:angle', angle) + else: + angle = self.angle_warp - self.rotate + path_element.set('inkstitch:angle', angle) + + if start is not None: + path_element.set('inkstitch:start', str(start)) + if end is not None: + path_element.set('inkstitch:end', str(end)) + + return path_element + + def _linestring_to_path(self, color: str, line: LineString, transform: str, travel: bool = False): + """ + Convert a linestring to an svg path element + + :param color: hex color + :param line: the line to convert + :param transform: string of the transform to apply to the element + :param travel: wether to render as travel line or running stitch/bean stitch + :returns: an svg path element or None if the linestring path is empty + """ + path = str(Path(list(line.coords))) + if not path: + return + + path_element = PathElement( + attrib={'d': path}, + style=f'fill:none;stroke:{color};stroke-opacity:0.6;', + transform=transform + ) + if not travel and self.bean_stitch_repeats > 0: + path_element.set('inkstitch:bean_stitch_repeats', self.bean_stitch_repeats) + return path_element diff --git a/lib/tartan/utils.py b/lib/tartan/utils.py new file mode 100644 index 00000000..b71b0384 --- /dev/null +++ b/lib/tartan/utils.py @@ -0,0 +1,262 @@ +# Authors: see git history +# +# Copyright (c) 2023 Authors +# Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details. + +import json +from collections import defaultdict +from copy import copy +from typing import List, Tuple, Union + +from inkex import BaseElement +from shapely import LineString, MultiPolygon, Point, Polygon, unary_union +from shapely.affinity import rotate + +from ..svg import PIXELS_PER_MM +from ..svg.tags import INKSTITCH_TARTAN +from ..utils import ensure_multi_line_string, ensure_multi_polygon +from .palette import Palette + + +def stripes_to_shapes( + stripes: List[dict], + dimensions: Tuple[float, float, float, float], + outline: Union[MultiPolygon, Polygon], + rotation: float, + rotation_center: Point, + symmetry: bool, + scale: int, + min_stripe_width: float, + weft: bool = False, + intersect_outline: bool = True +) -> defaultdict: + """ + Convert tartan stripes to polygons and linestrings (depending on stripe width) sorted by color + + :param stripes: a list of dictionaries with stripe information + :param dimensions: the dimension to fill with the tartan pattern (minx, miny, maxx, maxy) + :param outline: the shape to fill with the tartan pattern + :param rotation: the angle to rotate the pattern + :param rotation_center: the center point for rotation + :param symmetry: reflective sett (True) / repeating sett (False) + :param scale: the scale value (percent) for the pattern + :param min_stripe_width: min stripe width before it is rendered as running stitch + :param weft: wether to render warp or weft oriented stripes + :param intersect_outline: wether or not cut the shapes to fit into the outline + :returns: a dictionary with shapes grouped by color + """ + + minx, miny, maxx, maxy = dimensions + shapes: defaultdict = defaultdict(list) + + original_stripes = stripes + if len(original_stripes) == 0: + return shapes + + left = minx + top = miny + i = -1 + while True: + i += 1 + stripes = original_stripes + + segments = stripes + if symmetry and i % 2 != 0 and len(stripes) > 1: + segments = list(reversed(stripes[1:-1])) + for stripe in segments: + width = stripe['width'] * PIXELS_PER_MM * (scale / 100) + right = left + width + bottom = top + width + + if (top > maxy and weft) or (left > maxx and not weft): + return _merge_polygons(shapes, outline, intersect_outline) + + if not stripe['render']: + left = right + top = bottom + continue + + shape_dimensions = [top, bottom, left, right, minx, miny, maxx, maxy] + if width <= min_stripe_width * PIXELS_PER_MM: + linestrings = _get_linestrings(outline, shape_dimensions, rotation, rotation_center, weft) + shapes[stripe['color']].extend(linestrings) + continue + + polygon = _get_polygon(shape_dimensions, rotation, rotation_center, weft) + shapes[stripe['color']].append(polygon) + left = right + top = bottom + + +def _merge_polygons( + shapes: defaultdict, + outline: Union[MultiPolygon, Polygon], + intersect_outline: bool = True +) -> defaultdict: + """ + Merge polygons which are bordering each other (they most probably used a running stitch in between) + + :param shapes: shapes grouped by color + :param outline: the shape to be filled with a tartan pattern + :intersect_outline: wether to return an intersection of the shapes with the outline or not + :returns: the shapes with merged polygons + """ + shapes_copy = copy(shapes) + for color, shape_group in shapes_copy.items(): + polygons: List[Polygon] = [] + lines: List[LineString] = [] + for shape in shape_group: + if not shape.intersects(outline): + continue + if shape.geom_type == "Polygon": + polygons.append(shape) + else: + lines.append(shape) + merged_polygons = unary_union(polygons) + merged_polygons = merged_polygons.simplify(0.01) + if intersect_outline: + merged_polygons = merged_polygons.intersection(outline) + merged_polygons = ensure_multi_polygon(merged_polygons) + shapes[color] = [list(merged_polygons.geoms), lines] + return shapes + + +def _get_polygon(dimensions: List[float], rotation: float, rotation_center: Point, weft: bool) -> Polygon: + """ + Generates a rotated polygon with the given dimensions + + :param dimensions: top, bottom, left, right, minx, miny, maxx, maxy + :param rotation: the angle to rotate the pattern + :param rotation_center: the center point for rotation + :param weft: wether to render warp or weft oriented stripes + :returns: the generated Polygon + """ + top, bottom, left, right, minx, miny, maxx, maxy = dimensions + if not weft: + polygon = Polygon([(left, miny), (right, miny), (right, maxy), (left, maxy)]) + else: + polygon = Polygon([(minx, top), (maxx, top), (maxx, bottom), (minx, bottom)]) + if rotation != 0: + polygon = rotate(polygon, rotation, rotation_center) + return polygon + + +def _get_linestrings( + outline: Union[MultiPolygon, Polygon], + dimensions: List[float], + rotation: float, + rotation_center: Point, weft: bool +) -> list: + """ + Generates a rotated linestrings with the given dimension (outline intersection) + + :param outline: the outline to be filled with the tartan pattern + :param dimensions: top, bottom, left, right, minx, miny, maxx, maxy + :param rotation: the angle to rotate the pattern + :param rotation_center: the center point for rotation + :param weft: wether to render warp or weft oriented stripes + :returns: a list of the generated linestrings + """ + top, bottom, left, right, minx, miny, maxx, maxy = dimensions + linestrings = [] + if weft: + linestring = LineString([(minx, top), (maxx, top)]) + else: + linestring = LineString([(left, miny), (left, maxy)]) + if rotation != 0: + linestring = rotate(linestring, rotation, rotation_center) + intersection = linestring.intersection(outline) + if not intersection.is_empty: + linestrings.extend(ensure_multi_line_string(intersection).geoms) + return linestrings + + +def sort_fills_and_strokes(fills: defaultdict, strokes: defaultdict) -> Tuple[defaultdict, defaultdict]: + """ + Lines should be stitched out last, so they won't be covered by following fill elements. + However, if we find lines of the same color as one of the polygon groups, we can make + sure that they stitch next to each other to reduce color changes by at least one. + + :param fills: fills grouped by color + :param strokes: strokes grouped by color + :returns: the sorted fills and strokes + """ + colors_to_connect = [color for color in fills.keys() if color in strokes] + if colors_to_connect: + color_to_connect = colors_to_connect[-1] + + last = fills[color_to_connect] + fills.pop(color_to_connect) + fills[color_to_connect] = last + + sorted_strokes = defaultdict(list) + sorted_strokes[color_to_connect] = strokes[color_to_connect] + strokes.pop(color_to_connect) + sorted_strokes.update(strokes) + strokes = sorted_strokes + + return fills, strokes + + +def get_tartan_settings(node: BaseElement) -> dict: + """ + Parse tartan settings from node inkstich:tartan attribute + + :param node: the tartan svg element + :returns: the tartan settings in a dictionary + """ + settings = node.get(INKSTITCH_TARTAN, None) + if settings is None: + settings = { + 'palette': '(#101010)/5.0 (#FFFFFF)/?5.0', + 'rotate': 0.0, + 'offset_x': 0.0, + 'offset_y': 0.0, + 'symmetry': True, + 'scale': 100, + 'min_stripe_width': 1.0 + } + return settings + return json.loads(settings) + + +def get_palette_width(settings: dict, direction: int = 0) -> float: + """ + Calculate the width of all stripes (with a minimum width) in given direction + + :param settings: tartan settings + :param direction: [0] warp [1] weft + :returns: the calculated palette width + """ + palette_code = settings['palette'] + palette = Palette() + palette.update_from_code(palette_code) + return palette.get_palette_width(settings['scale'], settings['min_stripe_width'], direction) + + +def get_tartan_stripes(settings: dict) -> Tuple[list, list]: + """ + Get tartan stripes + + :param settings: tartan settings + :returns: a list with warp stripe dictionaries and a list with weft stripe dictionaries + Lists are empty if total width is 0 (for example if there are only strokes) + """ + # get stripes, return empty lists if total width is 0 + palette_code = settings['palette'] + palette = Palette() + palette.update_from_code(palette_code) + warp, weft = palette.palette_stripes + + if palette.get_palette_width(settings['scale'], settings['min_stripe_width']) == 0: + warp = [] + if palette.get_palette_width(settings['scale'], settings['min_stripe_width'], 1) == 0: + weft = [] + if len([stripe for stripe in warp if stripe['render'] is True]) == 0: + warp = [] + if len([stripe for stripe in weft if stripe['render'] is True]) == 0: + weft = [] + + if palette.equal_warp_weft: + weft = warp + return warp, weft |
