summaryrefslogtreecommitdiff
path: root/lib/tartan
diff options
context:
space:
mode:
Diffstat (limited to 'lib/tartan')
-rw-r--r--lib/tartan/colors.py159
-rw-r--r--lib/tartan/fill_element.py24
-rw-r--r--lib/tartan/palette.py243
-rw-r--r--lib/tartan/svg.py592
-rw-r--r--lib/tartan/utils.py262
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