From 2439adafa8592995d9acead47ef2802d5d95c373 Mon Sep 17 00:00:00 2001 From: Kaalleen <36401965+kaalleen@users.noreply.github.com> Date: Fri, 29 Mar 2024 10:25:02 +0100 Subject: [PATCH] Add "the tartan universe" (#2782) --- lib/elements/element.py | 6 +- lib/elements/fill_stitch.py | 128 ++++- lib/extensions/__init__.py | 2 + lib/extensions/tartan.py | 78 +++ lib/gui/tartan/__init__.py | 10 + lib/gui/tartan/code_panel.py | 59 ++ lib/gui/tartan/customize_panel.py | 300 ++++++++++ lib/gui/tartan/embroidery_panel.py | 201 +++++++ lib/gui/tartan/help_panel.py | 42 ++ lib/gui/tartan/main_panel.py | 271 +++++++++ lib/stitches/__init__.py | 1 + lib/stitches/auto_fill.py | 33 +- lib/stitches/circular_fill.py | 2 +- lib/stitches/fill.py | 18 +- lib/stitches/guided_fill.py | 6 +- lib/stitches/linear_gradient_fill.py | 2 +- lib/stitches/tartan_fill.py | 808 +++++++++++++++++++++++++++ lib/svg/tags.py | 4 + lib/tartan/colors.py | 159 ++++++ lib/tartan/fill_element.py | 24 + lib/tartan/palette.py | 243 ++++++++ lib/tartan/svg.py | 592 ++++++++++++++++++++ lib/tartan/utils.py | 262 +++++++++ lib/utils/clamp_path.py | 9 +- lib/utils/geometry.py | 22 +- templates/tartan.xml | 17 + 26 files changed, 3262 insertions(+), 37 deletions(-) create mode 100644 lib/extensions/tartan.py create mode 100644 lib/gui/tartan/__init__.py create mode 100644 lib/gui/tartan/code_panel.py create mode 100644 lib/gui/tartan/customize_panel.py create mode 100644 lib/gui/tartan/embroidery_panel.py create mode 100644 lib/gui/tartan/help_panel.py create mode 100644 lib/gui/tartan/main_panel.py create mode 100644 lib/stitches/tartan_fill.py create mode 100644 lib/tartan/colors.py create mode 100644 lib/tartan/fill_element.py create mode 100644 lib/tartan/palette.py create mode 100644 lib/tartan/svg.py create mode 100644 lib/tartan/utils.py create mode 100644 templates/tartan.xml diff --git a/lib/elements/element.py b/lib/elements/element.py index 071f8a90f..4ea909ac7 100644 --- a/lib/elements/element.py +++ b/lib/elements/element.py @@ -22,7 +22,7 @@ from ..svg import (PIXELS_PER_MM, apply_transforms, convert_length, get_node_transform) from ..svg.tags import INKSCAPE_LABEL, INKSTITCH_ATTRIBS from ..utils import Point, cache -from ..utils.cache import get_stitch_plan_cache, CacheKeyGenerator +from ..utils.cache import CacheKeyGenerator, get_stitch_plan_cache class Param(object): @@ -557,6 +557,9 @@ class EmbroideryElement(object): gradient['styles'] = [(style['stop-color'], style['stop-opacity']) for style in self.gradient.stop_styles] return gradient + def _get_tartan_key_data(self): + return (self.node.get('inkstitch:tartan', None)) + def get_cache_key_data(self, previous_stitch): return [] @@ -572,6 +575,7 @@ class EmbroideryElement(object): cache_key_generator.update(self._get_patterns_cache_key_data()) cache_key_generator.update(self._get_guides_cache_key_data()) cache_key_generator.update(self.get_cache_key_data(previous_stitch)) + cache_key_generator.update(self._get_tartan_key_data()) cache_key = cache_key_generator.get_cache_key() debug.log(f"cache key for {self.node.get('id')} {self.node.get(INKSCAPE_LABEL)} {previous_stitch}: {cache_key}") diff --git a/lib/elements/fill_stitch.py b/lib/elements/fill_stitch.py index 9ba649d78..cb1d52257 100644 --- a/lib/elements/fill_stitch.py +++ b/lib/elements/fill_stitch.py @@ -18,11 +18,13 @@ from ..i18n import _ from ..marker import get_marker_elements from ..stitch_plan import StitchGroup from ..stitches import (auto_fill, circular_fill, contour_fill, guided_fill, - legacy_fill, linear_gradient_fill, meander_fill) + legacy_fill, linear_gradient_fill, meander_fill, + tartan_fill) from ..stitches.linear_gradient_fill import gradient_angle from ..svg import PIXELS_PER_MM, get_node_transform from ..svg.clip import get_clip_path from ..svg.tags import INKSCAPE_LABEL +from ..tartan.utils import get_tartan_settings, get_tartan_stripes from ..utils import cache from ..utils.geometry import ensure_multi_polygon from ..utils.param import ParamOption @@ -112,6 +114,25 @@ class NoGradientWarning(ValidationWarning): ] +class NoTartanStripeWarning(ValidationWarning): + name = _("No stripes to render") + description = _("Tartan fill: There is no active fill stripe to render") + steps_to_solve = [ + _('Go to Extensions > Ink/Stitch > Fill Tools > Tartan and adjust stripe settings:'), + _('* Check if stripes are active'), + _('* Check the minimum stripe width setting and the scale factor') + ] + + +class DefaultTartanStripeWarning(ValidationWarning): + name = _("No customized pattern") + description = _("Tartan fill: Using default pattern") + steps_to_solve = [ + _('Go to Extensions > Ink/Stitch > Fill Tools > Tartan and adjust stripe settings:'), + _('* Customize your pattern') + ] + + class InvalidShapeError(ValidationError): name = _("This shape is invalid") description = _('Fill: This shape cannot be stitched out. Please try to repair it with the "Break Apart Fill Objects" extension.') @@ -129,11 +150,12 @@ class FillStitch(EmbroideryElement): return self.get_boolean_param('auto_fill', True) _fill_methods = [ParamOption('auto_fill', _("Auto Fill")), + ParamOption('circular_fill', _("Circular Fill")), ParamOption('contour_fill', _("Contour Fill")), ParamOption('guided_fill', _("Guided Fill")), - ParamOption('meander_fill', _("Meander Fill")), - ParamOption('circular_fill', _("Circular Fill")), ParamOption('linear_gradient_fill', _("Linear Gradient Fill")), + ParamOption('meander_fill', _("Meander Fill")), + ParamOption('tartan_fill', _("Tartan Fill")), ParamOption('legacy_fill', _("Legacy Fill"))] @property @@ -247,6 +269,7 @@ class FillStitch(EmbroideryElement): ('fill_method', 'guided_fill'), ('fill_method', 'meander_fill'), ('fill_method', 'circular_fill'), + ('fill_method', 'tartan_fill'), ('fill_method', 'linear_gradient_fill')]) def expand(self): return self.get_float_param('expand_mm', 0) @@ -264,6 +287,19 @@ class FillStitch(EmbroideryElement): def angle(self): return math.radians(self.get_float_param('angle', 0)) + @property + @param('tartan_angle', + _('Angle of lines of stitches'), + tooltip=_('Relative to the tartan stripe direction.'), + unit='deg', + type='float', + sort_index=21, + select_items=[('fill_method', 'tartan_fill')], + default=45) + @cache + def tartan_angle(self): + return self.get_float_param('tartan_angle', -45) + @property @param('max_stitch_length_mm', _('Maximum fill stitch length'), @@ -276,6 +312,7 @@ class FillStitch(EmbroideryElement): ('fill_method', 'contour_fill'), ('fill_method', 'guided_fill'), ('fill_method', 'linear_gradient_fill'), + ('fill_method', 'tartan_fill'), ('fill_method', 'legacy_fill')], default=3.0) def max_stitch_length(self): @@ -293,6 +330,7 @@ class FillStitch(EmbroideryElement): ('fill_method', 'guided_fill'), ('fill_method', 'circular_fill'), ('fill_method', 'linear_gradient_fill'), + ('fill_method', 'tartan_fill'), ('fill_method', 'legacy_fill')], default=0.25) def row_spacing(self): @@ -323,6 +361,7 @@ class FillStitch(EmbroideryElement): select_items=[('fill_method', 'auto_fill'), ('fill_method', 'guided_fill'), ('fill_method', 'linear_gradient_fill'), + ('fill_method', 'tartan_fill'), ('fill_method', 'legacy_fill')], default=4) def staggers(self): @@ -357,6 +396,18 @@ class FillStitch(EmbroideryElement): def flip(self): return self.get_boolean_param("flip", False) + @property + @param( + 'reverse', + _('Reverse fill'), + tooltip=_('Reverses fill path.'), + type='boolean', + sort_index=28, + select_items=[('fill_method', 'legacy_fill')], + default=False) + def reverse(self): + return self.get_boolean_param("reverse", False) + @property @param( 'stop_at_ending_point', @@ -396,7 +447,8 @@ class FillStitch(EmbroideryElement): ('fill_method', 'guided_fill'), ('fill_method', 'meander_fill'), ('fill_method', 'circular_fill'), - ('fill_method', 'linear_gradient_fill')], + ('fill_method', 'linear_gradient_fill'), + ('fill_method', 'tartan_fill')], sort_index=31) def running_stitch_length(self): return max(self.get_float_param("running_stitch_length_mm", 2.5), 0.01) @@ -434,7 +486,8 @@ class FillStitch(EmbroideryElement): 'A pattern with various repeats can be created with a list of values separated by a space.'), type='str', select_items=[('fill_method', 'meander_fill'), - ('fill_method', 'circular_fill')], + ('fill_method', 'circular_fill'), + ('fill_method', 'tartan_fill')], default=0, sort_index=34) def bean_stitch_repeats(self): @@ -466,6 +519,31 @@ class FillStitch(EmbroideryElement): def zigzag_width(self): return self.get_float_param("zigzag_width_mm", 3) + @property + @param( + 'rows_per_thread', + _("Rows per tartan thread"), + tooltip=_("Consecutive rows of the same color"), + type='int', + default="2", + select_items=[('fill_method', 'tartan_fill')], + sort_index=35 + ) + def rows_per_thread(self): + return max(1, self.get_int_param("rows_per_thread", 2)) + + @property + @param('herringbone_width_mm', + _('Herringbone width'), + tooltip=_('Defines width of a herringbone pattern. Use 0 for regular rows.'), + unit='mm', + type='int', + default=0, + select_items=[('fill_method', 'tartan_fill')], + sort_index=36) + def herringbone_width(self): + return self.get_float_param('herringbone_width_mm', 0) + @property def color(self): # SVG spec says the default fill is black @@ -679,6 +757,15 @@ class FillStitch(EmbroideryElement): # they may used a fill on a straight line yield StrokeAndFillWarning(self.paths[0][0]) + # tartan fill + if self.fill_method == 'tartan_fill': + settings = get_tartan_settings(self.node) + warp, weft = get_tartan_stripes(settings) + if not (warp or weft): + yield NoTartanStripeWarning(self.shape.representative_point()) + if not self.node.get('inkstitch:tartan', ''): + yield DefaultTartanStripeWarning(self.shape.representative_point()) + for warning in super(FillStitch, self).validation_warnings(): yield warning @@ -778,19 +865,38 @@ class FillStitch(EmbroideryElement): stitch_groups.extend(self.do_circular_fill(fill_shape, previous_stitch_group, start, end)) elif self.fill_method == 'linear_gradient_fill': stitch_groups.extend(self.do_linear_gradient_fill(fill_shape, previous_stitch_group, start, end)) + elif self.fill_method == 'tartan_fill': + stitch_groups.extend(self.do_tartan_fill(fill_shape, previous_stitch_group, start, end)) else: # auto_fill stitch_groups.extend(self.do_auto_fill(fill_shape, previous_stitch_group, start, end)) if stitch_groups: previous_stitch_group = stitch_groups[-1] - # sort colors of linear gradient (if multiple shapes) - if self.fill_method == 'linear_gradient_fill': - colors = [stitch_group.color for stitch_group in stitch_groups] - stitch_groups.sort(key=lambda group: colors.index(group.color)) + # sort colors of linear gradient + if len(shapes) > 1 and self.fill_method == 'linear_gradient_fill': + self.color_sort(stitch_groups) + + # sort colors of tartan fill + if len(shapes) > 1 and self.fill_method == 'tartan_fill': + # while color sorting make sure stroke lines go still on top of the fills + fill_groups = [] + stroke_groups = [] + for stitch_group in stitch_groups: + if "tartan_run" in stitch_group.stitches[0].tags: + stroke_groups.append(stitch_group) + else: + fill_groups.append(stitch_group) + self.color_sort(fill_groups) + self.color_sort(stroke_groups) + stitch_groups = fill_groups + stroke_groups return stitch_groups + def color_sort(self, stitch_groups): + colors = [stitch_group.color for stitch_group in stitch_groups] + stitch_groups.sort(key=lambda group: colors.index(group.color)) + def do_legacy_fill(self): stitch_lists = legacy_fill( self.shape, @@ -799,6 +905,7 @@ class FillStitch(EmbroideryElement): self.end_row_spacing, self.max_stitch_length, self.flip, + self.reverse, self.staggers, self.skip_last ) @@ -996,3 +1103,6 @@ class FillStitch(EmbroideryElement): def do_linear_gradient_fill(self, shape, last_stitch_group, start, end): return linear_gradient_fill(self, shape, start, end) + + def do_tartan_fill(self, shape, last_stitch_group, start, end): + return tartan_fill(self, shape, start, end) diff --git a/lib/extensions/__init__.py b/lib/extensions/__init__.py index 72cafcc0d..0ae88c765 100644 --- a/lib/extensions/__init__.py +++ b/lib/extensions/__init__.py @@ -54,6 +54,7 @@ from .simulator import Simulator from .stitch_plan_preview import StitchPlanPreview from .stitch_plan_preview_undo import StitchPlanPreviewUndo from .stroke_to_lpe_satin import StrokeToLpeSatin +from .tartan import Tartan from .test_swatches import TestSwatches from .troubleshoot import Troubleshoot from .update_svg import UpdateSvg @@ -111,6 +112,7 @@ __all__ = extensions = [ApplyPalette, StitchPlanPreview, StitchPlanPreviewUndo, StrokeToLpeSatin, + Tartan, TestSwatches, Troubleshoot, UpdateSvg, diff --git a/lib/extensions/tartan.py b/lib/extensions/tartan.py new file mode 100644 index 000000000..a9b34dfbe --- /dev/null +++ b/lib/extensions/tartan.py @@ -0,0 +1,78 @@ +# 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 sys + +import wx +import wx.adv +from inkex import errormsg + +from ..gui.simulator import SplitSimulatorWindow +from ..gui.tartan import TartanMainPanel +from ..i18n import _ +from ..svg.tags import EMBROIDERABLE_TAGS, INKSTITCH_TARTAN, SVG_GROUP_TAG +from .base import InkstitchExtension + + +class Tartan(InkstitchExtension): + def __init__(self, *args, **kwargs): + self.elements = set() + self.cancelled = False + InkstitchExtension.__init__(self, *args, **kwargs) + + def cancel(self): + self.cancelled = True + + def get_tartan_elements(self): + if self.svg.selection: + self._get_elements() + + def _get_elements(self): + for node in self.svg.selection: + node = self.get_outline(node) + if node.style('fill'): + self.elements.add(node) + + def get_outline(self, node): + # existing tartans are marked through their outline element + # we have either selected the element itself or some other element within a tartan group + if node.get(INKSTITCH_TARTAN, None): + return node + if node.get_id().startswith('inkstitch-tartan'): + for element in node.iterchildren(EMBROIDERABLE_TAGS): + if element.get(INKSTITCH_TARTAN, None): + return element + for group in node.iterancestors(SVG_GROUP_TAG): + if group.get_id().startswith('inkstitch-tartan'): + for element in group.iterchildren(EMBROIDERABLE_TAGS): + if element.get(INKSTITCH_TARTAN, None): + return element + # if we don't find an existing tartan, return node + return node + + def effect(self): + self.get_tartan_elements() + if not self.elements: + errormsg(_("To create a tartan pattern please select at least one element with a fill color.")) + return + metadata = self.get_inkstitch_metadata() + + app = wx.App() + frame = SplitSimulatorWindow( + title=_("Ink/Stitch Tartan"), + panel_class=TartanMainPanel, + elements=list(self.elements), + on_cancel=self.cancel, + metadata=metadata, + target_duration=1 + ) + + frame.Show() + app.MainLoop() + + if self.cancelled: + # This prevents the superclass from outputting the SVG, because we + # may have modified the DOM. + sys.exit(0) diff --git a/lib/gui/tartan/__init__.py b/lib/gui/tartan/__init__.py new file mode 100644 index 000000000..176d5d1ed --- /dev/null +++ b/lib/gui/tartan/__init__.py @@ -0,0 +1,10 @@ +# 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 .code_panel import CodePanel +from .customize_panel import CustomizePanel +from .embroidery_panel import EmbroideryPanel +from .help_panel import HelpPanel +from .main_panel import TartanMainPanel diff --git a/lib/gui/tartan/code_panel.py b/lib/gui/tartan/code_panel.py new file mode 100644 index 000000000..f9dfe4753 --- /dev/null +++ b/lib/gui/tartan/code_panel.py @@ -0,0 +1,59 @@ +# 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 wx +import wx.adv + +from ...i18n import _ + + +class CodePanel(wx.Panel): + def __init__(self, parent, panel): + self.panel = panel + wx.Panel.__init__(self, parent) + code_sizer = wx.BoxSizer(wx.VERTICAL) + load_palette_sizer = wx.BoxSizer(wx.HORIZONTAL) + tt_unit_sizer = wx.BoxSizer(wx.HORIZONTAL) + + self.threadcount_text = wx.TextCtrl(self, style=wx.TE_MULTILINE) + self.threadcount_text.Bind(wx.EVT_TEXT, self.set_tt_unit_status) + code_sizer.Add(self.threadcount_text, 1, wx.EXPAND | wx.ALL, 10) + + self.tt_unit_label = wx.StaticText(self, label=_("1 Tartan thread equals (mm)")) + self.tt_unit_spin = wx.SpinCtrlDouble(self, min=0.01, max=50, initial=0.2, inc=0.1, style=wx.SP_WRAP) + self.tt_unit_spin.SetDigits(2) + tt_unit_sizer.Add(self.tt_unit_label, 0, wx.CENTER | wx.ALL, 10) + tt_unit_sizer.Add(self.tt_unit_spin, 0, wx.ALL, 10) + self.tt_unit_label.SetToolTip(_("Used only for Threadcount code (The Scottish Register of Tartans)")) + self.tt_unit_spin.SetToolTip(_("Used only for Threadcount code (The Scottish Register of Tartans)")) + + code_sizer.Add(tt_unit_sizer, 0, wx.ALL, 10) + + load_button = wx.Button(self, label="Apply Code") + load_button.Bind(wx.EVT_BUTTON, self._load_palette_code) + load_palette_sizer.Add(load_button, 0, wx.ALL, 10) + + code_sizer.Add(load_palette_sizer, 0, wx.ALL, 10) + + self.SetSizer(code_sizer) + + def _load_palette_code(self, event): + self.panel.palette.tt_unit = self.tt_unit_spin.GetValue() + self.panel.update_from_code() + self.panel.settings['palette'] = self.threadcount_text.GetValue() + + def set_tt_unit_status(self, event): + # we always want to convert the width into mm + # when threadcount code is given we have to enable the threadcount unit field + # so they can define the mm-width of one tartan thread + threadcount_text = self.threadcount_text.GetValue() + if '(' in threadcount_text and 'Threadcount' not in threadcount_text: + # depending on how much of the mailed text is copied into the code field, + # we may have brackets in there (1997). So let's also check for "threadcount" + self.tt_unit_label.Enable(False) + self.tt_unit_spin.Enable(False) + else: + self.tt_unit_label.Enable(True) + self.tt_unit_spin.Enable(True) diff --git a/lib/gui/tartan/customize_panel.py b/lib/gui/tartan/customize_panel.py new file mode 100644 index 000000000..16d73416f --- /dev/null +++ b/lib/gui/tartan/customize_panel.py @@ -0,0 +1,300 @@ +# 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 math import floor + +import wx +from wx.lib.scrolledpanel import ScrolledPanel + +from ...i18n import _ + + +class CustomizePanel(ScrolledPanel): + + def __init__(self, parent, panel): + self.panel = panel + self.mouse_position = None + ScrolledPanel.__init__(self, parent) + + self.customize_sizer = wx.BoxSizer(wx.VERTICAL) + general_settings_sizer = wx.BoxSizer(wx.HORIZONTAL) + positional_settings_sizer = wx.FlexGridSizer(2, 4, 5, 5) + stripe_header_sizer = wx.BoxSizer(wx.HORIZONTAL) + self.stripe_sizer = wx.BoxSizer(wx.HORIZONTAL) + self.warp_outer_sizer = wx.BoxSizer(wx.VERTICAL) + self.weft_outer_sizer = wx.BoxSizer(wx.VERTICAL) + self.warp_sizer = wx.BoxSizer(wx.VERTICAL) + self.weft_sizer = wx.BoxSizer(wx.VERTICAL) + + general_settings_headline = wx.StaticText(self, label=_("Pattern Settings")) + general_settings_headline.SetFont(wx.Font().Bold()) + self.symmetry_checkbox = wx.CheckBox(self, label=_("Symmetrical / reflective sett")) + self.symmetry_checkbox.SetToolTip(_("Disabled: asymmetrical / repeating sett")) + self.symmetry_checkbox.Bind(wx.EVT_CHECKBOX, self.update_symmetry) + self.warp_weft_checkbox = wx.CheckBox(self, label=_("Equal threadcount for warp and weft")) + self.warp_weft_checkbox.Bind(wx.EVT_CHECKBOX, self._update_warp_weft_event) + + positional_settings_headline = wx.StaticText(self, label=_("Position")) + positional_settings_headline.SetFont(wx.Font().Bold()) + self.rotate = wx.SpinCtrlDouble(self, min=-180, max=180, initial=0, inc=0.1, style=wx.SP_WRAP) + self.rotate.SetDigits(2) + self.rotate.Bind(wx.EVT_SPINCTRLDOUBLE, lambda event: self.on_change("rotate", event)) + rotate_label = wx.StaticText(self, label=_("Rotate")) + self.scale = wx.SpinCtrl(self, min=0, max=1000, initial=100, style=wx.SP_WRAP) + self.scale.Bind(wx.EVT_SPINCTRL, self.update_scale) + scale_label = wx.StaticText(self, label=_("Scale (%)")) + self.offset_x = wx.SpinCtrlDouble(self, min=0, max=500, initial=0, style=wx.SP_WRAP) + self.offset_x.Bind(wx.EVT_SPINCTRLDOUBLE, lambda event: self.on_change("offset_x", event)) + self.offset_x.SetDigits(2) + offset_x_label = wx.StaticText(self, label=_("Offset X (mm)")) + self.offset_y = wx.SpinCtrlDouble(self, min=0, max=500, initial=0, style=wx.SP_WRAP) + self.offset_y.Bind(wx.EVT_SPINCTRLDOUBLE, lambda event: self.on_change("offset_y", event)) + self.offset_y.SetDigits(2) + offset_y_label = wx.StaticText(self, label=_("Offset Y (mm)")) + + stripe_settings_headline = wx.StaticText(self, label=_("Stripes")) + stripe_settings_headline.SetFont(wx.Font().Bold()) + self.link_colors_checkbox = wx.CheckBox(self, label=_("Link colors")) + self.link_colors_checkbox.SetToolTip(_("When enabled update all equal colors simultaneously.")) + self.warp_headline = wx.StaticText(self, label=_("Warp")) + self.warp_headline.SetFont(wx.Font().Bold()) + self.weft_headline = wx.StaticText(self, label=_("Weft")) + self.weft_headline.SetFont(wx.Font().Bold()) + self.add_warp_button = wx.Button(self, label=_("Add")) + self.add_warp_button.Bind(wx.EVT_BUTTON, self._add_warp_event) + self.add_weft_button = wx.Button(self, label=_("Add")) + self.add_weft_button.Bind(wx.EVT_BUTTON, self._add_weft_event) + + # Add to sizers + + general_settings_sizer.Add(self.symmetry_checkbox, 0, wx.CENTER | wx.ALL, 10) + general_settings_sizer.Add(self.warp_weft_checkbox, 0, wx.CENTER | wx.ALL, 10) + + positional_settings_sizer.Add(rotate_label, 0, wx.ALIGN_CENTRE, 0) + positional_settings_sizer.Add(self.rotate, 0, wx.EXPAND | wx.RIGHT, 30) + positional_settings_sizer.Add(offset_x_label, 0, wx.ALIGN_CENTRE, 0) + positional_settings_sizer.Add(self.offset_x, 0, wx.EXPAND, 0) + positional_settings_sizer.Add(scale_label, 0, wx.ALIGN_CENTRE, 0) + positional_settings_sizer.Add(self.scale, 0, wx.EXPAND | wx.RIGHT, 30) + positional_settings_sizer.Add(offset_y_label, 0, wx.ALIGN_CENTRE, 0) + positional_settings_sizer.Add(self.offset_y, 0, wx.EXPAND, 0) + positional_settings_sizer.AddGrowableCol(1) + positional_settings_sizer.AddGrowableCol(3) + + self.warp_outer_sizer.Add(self.warp_headline, 0, wx.EXPAND, 0) + self.weft_outer_sizer.Add(self.weft_headline, 0, wx.EXPAND, 0) + self.warp_outer_sizer.Add(self.warp_sizer, 0, wx.EXPAND, 0) + self.weft_outer_sizer.Add(self.weft_sizer, 0, wx.EXPAND, 0) + self.warp_outer_sizer.Add(self.add_warp_button, 0, wx.ALIGN_RIGHT | wx.ALL, 10) + self.weft_outer_sizer.Add(self.add_weft_button, 0, wx.ALIGN_RIGHT | wx.ALL, 10) + self.stripe_sizer.Add(self.warp_outer_sizer, 1, wx.EXPAND, 0) + self.stripe_sizer.Add(self.weft_outer_sizer, 1, wx.EXPAND, 0) + + stripe_header_sizer.Add(stripe_settings_headline, 0, wx.ALL, 10) + stripe_header_sizer.Add((0, 0), 1, wx.ALL | wx.EXPAND, 10) + stripe_header_sizer.Add(self.link_colors_checkbox, 0, wx.ALL, 10) + + self.customize_sizer.Add(positional_settings_headline, 0, wx.ALL, 10) + self.customize_sizer.Add(positional_settings_sizer, 0, wx.ALL | wx.EXPAND, 10) + self.customize_sizer.Add(wx.StaticLine(self), 0, wx.ALL | wx.EXPAND, 10) + self.customize_sizer.Add(general_settings_headline, 0, wx.ALL, 10) + self.customize_sizer.Add(general_settings_sizer, 0, wx.ALL | wx.EXPAND, 10) + self.customize_sizer.Add(wx.StaticLine(self), 0, wx.ALL | wx.EXPAND, 10) + self.customize_sizer.Add(stripe_header_sizer, 0, wx.EXPAND | wx.ALL, 10) + self.customize_sizer.Add(self.stripe_sizer, 0, wx.EXPAND | wx.ALL, 10) + + self.SetSizer(self.customize_sizer) + + def _add_warp_event(self, event): + self.add_stripe() + + def _add_weft_event(self, event): + self.add_stripe(False) + + def add_stripe(self, warp=True, stripe=None, update=True): + stripesizer = wx.BoxSizer(wx.HORIZONTAL) + + position = wx.Button(self, label='⁝', style=wx.BU_EXACTFIT) + position.SetToolTip(_("Drag and drop to adjust position.")) + position.Bind(wx.EVT_LEFT_DOWN, self._move_stripe_start) + position.Bind(wx.EVT_LEFT_UP, self._move_stripe_end) + + visibility = wx.CheckBox(self) + visibility.SetToolTip(_("Stitch this stripe")) + visibility.SetValue(True) + visibility.Bind(wx.EVT_CHECKBOX, self._update_stripes_event) + + # hidden label used for linked colors + # there seems to be no native way to catch the old color setting + colorinfo = wx.StaticText(self, label='black') + colorinfo.Hide() + + colorpicker = wx.ColourPickerCtrl(self, colour=wx.Colour('black')) + colorpicker.SetToolTip(_("Select stripe color")) + colorpicker.Bind(wx.EVT_COLOURPICKER_CHANGED, self._update_color) + + stripe_width = wx.SpinCtrlDouble(self, min=0.01, max=500, initial=5, style=wx.SP_WRAP) + stripe_width.SetDigits(2) + stripe_width.SetToolTip(_("Set stripe width (mm)")) + stripe_width.Bind(wx.EVT_SPINCTRLDOUBLE, self._update_stripes_event) + + remove_button = wx.Button(self, label='X') + remove_button.SetToolTip(_("Remove stripe")) + remove_button.Bind(wx.EVT_BUTTON, self._remove_stripe) + + stripesizer.Add(position, 0, wx.CENTER | wx.RIGHT | wx.TOP, 5) + stripesizer.Add(visibility, 0, wx.CENTER | wx.RIGHT | wx.TOP, 5) + stripesizer.Add(colorinfo, 0, wx.RIGHT | wx.TOP, 5) + stripesizer.Add(colorpicker, 0, wx.RIGHT | wx.TOP, 5) + stripesizer.Add(stripe_width, 1, wx.RIGHT | wx.TOP, 5) + stripesizer.Add(remove_button, 0, wx.CENTER | wx.TOP, 5) + + if stripe is not None: + visibility.SetValue(stripe['render']) + colorinfo.SetLabel(wx.Colour(stripe['color']).GetAsString(wx.C2S_HTML_SYNTAX)) + colorpicker.SetColour(wx.Colour(stripe['color'])) + stripe_width.SetValue(stripe['width']) + if warp: + self.warp_sizer.Add(stripesizer, 0, wx.EXPAND | wx.ALL, 5) + else: + self.weft_sizer.Add(stripesizer, 0, wx.EXPAND | wx.ALL, 5) + if update: + self.panel.update_from_stripes() + self.set_stripe_width_color(stripe_width) + self.FitInside() + + def _move_stripe_start(self, event): + self.mouse_position = wx.GetMousePosition() + + def _move_stripe_end(self, event): + stripe = event.GetEventObject() + sizer = stripe.GetContainingSizer() + if self.warp_sizer.GetItem(sizer): + main_sizer = self.warp_sizer + else: + main_sizer = self.weft_sizer + for i, item in enumerate(main_sizer.GetChildren()): + if item.GetSizer() == sizer: + index = i + break + position = wx.GetMousePosition() + sizer_height = sizer.GetSize()[1] + 10 + move = floor((position[1] - self.mouse_position[1]) / sizer_height) + index = min(len(main_sizer.Children) - 1, max(0, (index + move))) + main_sizer.Detach(sizer) + main_sizer.Insert(index, sizer, 0, wx.EXPAND | wx.ALL, 5) + self.panel.update_from_stripes() + self.FitInside() + + def _remove_stripe(self, event): + sizer = event.GetEventObject().GetContainingSizer() + sizer.Clear(True) + self.warp_sizer.Remove(sizer) + try: + self.weft_sizer.Remove(sizer) + except RuntimeError: + # we may have removed it already + pass + self.panel.update_from_stripes() + self.FitInside() + + def on_change(self, attribute, event): + self.panel.settings[attribute] = event.EventObject.GetValue() + self.panel.update_preview() + + def update_scale(self, event): + self.panel.settings['scale'] = event.EventObject.GetValue() + # self.update_stripes(self.panel.palette.palette_stripes) + self.update_stripe_width_colors() + self.panel.update_preview() + + def _update_stripes_event(self, event): + self.set_stripe_width_color(event.EventObject) + self.panel.update_from_stripes() + + def update_stripe_width_colors(self): + for sizer in [self.warp_sizer, self.weft_sizer]: + for stripe_sizer in sizer.GetChildren(): + inner_sizer = stripe_sizer.GetSizer() + for stripe_widget in inner_sizer: + widget = stripe_widget.GetWindow() + if isinstance(widget, wx.SpinCtrlDouble): + self.set_stripe_width_color(widget) + + def set_stripe_width_color(self, stripe_width_ctrl): + scale = self.scale.GetValue() + min_stripe_width = self.panel.embroidery_panel.min_stripe_width.GetValue() + stripe_width = stripe_width_ctrl.GetValue() * scale / 100 + if stripe_width <= min_stripe_width: + stripe_width_ctrl.SetBackgroundColour(wx.Colour('#efefef')) + stripe_width_ctrl.SetForegroundColour('black') + else: + stripe_width_ctrl.SetBackgroundColour(wx.NullColour) + stripe_width_ctrl.SetForegroundColour(wx.NullColour) + + def update_stripes(self, stripes): + self.warp_sizer.Clear(True) + self.weft_sizer.Clear(True) + warp = True + for direction in stripes: + for stripe in direction: + self.add_stripe(warp, stripe, False) + warp = False + self.panel.update_from_stripes() + + def _update_color(self, event): + linked = self.link_colors_checkbox.GetValue() + widget = event.GetEventObject() + colorinfo = widget.GetPrevSibling() + old_color = wx.Colour(colorinfo.GetLabel()) + new_color = event.Colour + if linked: + self._update_color_picker(old_color, new_color, self.warp_sizer) + self._update_color_picker(old_color, new_color, self.weft_sizer) + colorinfo.SetLabel(new_color.GetAsString(wx.C2S_HTML_SYNTAX)) + self.panel.update_from_stripes() + + def _update_color_picker(self, old_color, new_color, sizer): + for stripe_sizer in sizer.Children: + stripe_info = stripe_sizer.GetSizer() + for widget in stripe_info.GetChildren(): + widget = widget.GetWindow() + if isinstance(widget, wx.ColourPickerCtrl): + color = widget.GetColour() + if color == old_color: + widget.SetColour(new_color) + widget.GetPrevSibling().SetLabel(new_color.GetAsString(wx.C2S_HTML_SYNTAX)) + + def update_symmetry(self, event=None): + symmetry = self.symmetry_checkbox.GetValue() + self.panel.settings['symmetry'] = symmetry + self.panel.palette.update_symmetry(symmetry) + self.panel.update_from_stripes() + self.FitInside() + + def update_warp_weft(self): + equal_warp_weft = self.warp_weft_checkbox.GetValue() + if equal_warp_weft: + self.stripe_sizer.Hide(self.warp_headline, recursive=True) + self.stripe_sizer.Hide(self.weft_outer_sizer, recursive=True) + else: + self.stripe_sizer.Show(self.warp_headline, recursive=True) + self.stripe_sizer.Show(self.weft_outer_sizer, recursive=True) + # We just made the weft colorinfo visible. Let's hide it again. + self._hide_colorinfo() + self.FitInside() + + def _update_warp_weft_event(self, event): + self.panel.settings['equal_warp_weft'] = event.GetEventObject().GetValue() + self.update_warp_weft() + self.panel.update_from_stripes() + + def _hide_colorinfo(self): + for stripe_sizer in self.weft_sizer.Children: + stripe_info = stripe_sizer.GetSizer() + for stripe in stripe_info.GetChildren(): + widget = stripe.GetWindow() + if isinstance(widget, wx.StaticText): + widget.Hide() diff --git a/lib/gui/tartan/embroidery_panel.py b/lib/gui/tartan/embroidery_panel.py new file mode 100644 index 000000000..f3b756d79 --- /dev/null +++ b/lib/gui/tartan/embroidery_panel.py @@ -0,0 +1,201 @@ +# 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 wx + +from ...i18n import _ +from ...utils.param import ParamOption + + +class EmbroideryPanel(wx.Panel): + def __init__(self, parent, panel): + self.panel = panel + wx.Panel.__init__(self, parent) + + self.embroidery_sizer = wx.BoxSizer(wx.VERTICAL) + self.embroidery_element_sizer = wx.FlexGridSizer(6, 2, 5, 5) + self.embroidery_element_sizer.AddGrowableCol(1) + self.svg_elements_sizer = wx.FlexGridSizer(6, 2, 5, 5) + self.svg_elements_sizer.AddGrowableCol(1) + self.common_settings_sizer = wx.FlexGridSizer(1, 2, 5, 5) + self.common_settings_sizer.AddGrowableCol(1) + + help_text = wx.StaticText(self, -1, _("Embroidery settings can be refined in the params dialog.")) + + # Method + self.output_method = wx.ComboBox(self, choices=[], style=wx.CB_READONLY) + for choice in embroider_choices: + self.output_method.Append(choice.name, choice) + self.output_method.SetSelection(0) + self.output_method.Bind(wx.EVT_COMBOBOX, self.update_output_method) + + # Embroidery Element Params + stitch_angle_label = wx.StaticText(self, label=_("Angle of lines of stitches")) + stitch_angle_label.SetToolTip(_('Relative to the tartan stripe direction.')) + self.stitch_angle = wx.SpinCtrlDouble(self, min=-90, max=90, initial=-45, style=wx.SP_WRAP) + self.stitch_angle.SetDigits(2) + self.stitch_angle.SetIncrement(1) + self.stitch_angle.Bind(wx.EVT_SPINCTRLDOUBLE, lambda event: self.on_param_change("tartan_angle", event)) + + rows_per_thread_label = wx.StaticText(self, label=_("Rows per tartan thread")) + self.rows_per_thread = wx.SpinCtrl(self, min=1, max=50, initial=2, style=wx.SP_WRAP) + lines_text = _("Consecutive rows of the same color") + rows_per_thread_label.SetToolTip(lines_text) + self.rows_per_thread.SetToolTip(lines_text) + self.rows_per_thread.Bind(wx.EVT_SPINCTRL, lambda event: self.on_param_change("rows_per_thread", event)) + + row_spacing_label = wx.StaticText(self, label=_("Row spacing (mm)")) + self.row_spacing = wx.SpinCtrlDouble(self, min=0.01, max=500, initial=0.25, style=wx.SP_WRAP) + self.row_spacing.SetDigits(2) + self.row_spacing.SetIncrement(0.01) + self.row_spacing.Bind(wx.EVT_SPINCTRLDOUBLE, lambda event: self.on_param_change("row_spacing_mm", event)) + + underlay_label = wx.StaticText(self, label=_("Underlay")) + self.underlay = wx.CheckBox(self) + self.underlay.Bind(wx.EVT_CHECKBOX, lambda event: self.on_param_change("fill_underlay", event)) + + herringbone_label = wx.StaticText(self, label=_("Herringbone width (mm)")) + self.herringbone = wx.SpinCtrlDouble(self, min=0, max=500, initial=0, style=wx.SP_WRAP) + self.herringbone.SetDigits(2) + self.herringbone.SetIncrement(1) + self.herringbone.Bind(wx.EVT_SPINCTRLDOUBLE, lambda event: self.on_param_change("herringbone_width_mm", event)) + + bean_stitch_repeats_label = wx.StaticText(self, label=_("Bean stitch repeats")) + self.bean_stitch_repeats = wx.TextCtrl(self) + self.bean_stitch_repeats.Bind(wx.EVT_TEXT, lambda event: self.on_param_change("bean_stitch_repeats", event)) + + # SVG Output Settings + stitch_type_label = wx.StaticText(self, label=_("Stitch type")) + self.stitch_type = wx.ComboBox(self, choices=[], style=wx.CB_READONLY) + for choice in stitch_type_choices: + self.stitch_type.Append(choice.name, choice) + self.stitch_type.SetSelection(0) + self.stitch_type.Bind(wx.EVT_COMBOBOX, self.on_change_stitch_type) + + svg_row_spacing_label = wx.StaticText(self, label=_("Row spacing")) + self.svg_row_spacing = wx.SpinCtrlDouble(self, min=0.01, max=500, initial=1, style=wx.SP_WRAP) + self.svg_row_spacing.SetDigits(2) + self.row_spacing.SetIncrement(0.01) + self.svg_row_spacing.Bind(wx.EVT_SPINCTRLDOUBLE, lambda event: self.on_change("row_spacing", event)) + + angle_warp_label = wx.StaticText(self, label=_("Stitch angle (warp)")) + self.angle_warp = wx.SpinCtrl(self, min=-90, max=90, initial=0, style=wx.SP_WRAP) + self.angle_warp.Bind(wx.EVT_SPINCTRL, lambda event: self.on_change("angle_warp", event)) + + angle_weft_label = wx.StaticText(self, label=_("Stitch angle (weft)")) + self.angle_weft = wx.SpinCtrl(self, min=-90, max=90, initial=90, style=wx.SP_WRAP) + self.angle_weft.Bind(wx.EVT_SPINCTRL, lambda event: self.on_change("angle_weft", event)) + + min_stripe_width_label = wx.StaticText(self, label=_("Minimum stripe width for fills")) + self.min_stripe_width = wx.SpinCtrlDouble(self, min=0, max=100, initial=1, style=wx.SP_WRAP) + self.min_stripe_width.SetDigits(2) + self.row_spacing.SetIncrement(0.1) + min_width_text = _("Stripes smaller than this will be stitched as a running stitch") + min_stripe_width_label.SetToolTip(min_width_text) + self.min_stripe_width.SetToolTip(min_width_text) + self.min_stripe_width.Bind(wx.EVT_SPINCTRLDOUBLE, self.on_change_min_stripe_width) + + svg_bean_stitch_repeats_label = wx.StaticText(self, label=_("Bean stitch repeats")) + self.svg_bean_stitch_repeats = wx.SpinCtrl(self, min=0, max=10, initial=0, style=wx.SP_WRAP) + self.svg_bean_stitch_repeats.Bind(wx.EVT_SPINCTRL, lambda event: self.on_change("bean_stitch_repeats", event)) + + # Add to sizers + self.embroidery_element_sizer.Add(stitch_angle_label, 0, wx.ALIGN_CENTRE, 0) + self.embroidery_element_sizer.Add(self.stitch_angle, 0, wx.EXPAND, 0) + self.embroidery_element_sizer.Add(rows_per_thread_label, 0, wx.ALIGN_CENTRE, 0) + self.embroidery_element_sizer.Add(self.rows_per_thread, 0, wx.EXPAND, 0) + self.embroidery_element_sizer.Add(row_spacing_label, 0, wx.ALIGN_CENTRE, 0) + self.embroidery_element_sizer.Add(self.row_spacing, 0, wx.EXPAND, 0) + self.embroidery_element_sizer.Add(herringbone_label, 0, wx.ALIGN_CENTRE, 0) + self.embroidery_element_sizer.Add(self.herringbone, 0, wx.EXPAND, 0) + self.embroidery_element_sizer.Add(underlay_label, 0, wx.ALIGN_CENTRE, 0) + self.embroidery_element_sizer.Add(self.underlay, 0, wx.EXPAND, 0) + self.embroidery_element_sizer.Add(bean_stitch_repeats_label, 0, wx.ALIGN_CENTRE, 0) + self.embroidery_element_sizer.Add(self.bean_stitch_repeats, 0, wx.EXPAND, 0) + + self.svg_elements_sizer.Add(stitch_type_label, 0, wx.ALIGN_CENTRE, 0) + self.svg_elements_sizer.Add(self.stitch_type, 0, wx.EXPAND, 0) + self.svg_elements_sizer.Add(svg_row_spacing_label, 0, wx.ALIGN_CENTRE, 0) + self.svg_elements_sizer.Add(self.svg_row_spacing, 0, wx.EXPAND, 0) + self.svg_elements_sizer.Add(angle_warp_label, 0, wx.ALIGN_CENTRE, 0) + self.svg_elements_sizer.Add(self.angle_warp, 0, wx.EXPAND, 0) + self.svg_elements_sizer.Add(angle_weft_label, 0, wx.ALIGN_CENTRE, 0) + self.svg_elements_sizer.Add(self.angle_weft, 0, wx.EXPAND, 0) + self.svg_elements_sizer.Add(svg_bean_stitch_repeats_label, 0, wx.ALIGN_CENTRE, 0) + self.svg_elements_sizer.Add(self.svg_bean_stitch_repeats, 0, wx.EXPAND, 0) + + self.common_settings_sizer.Add(min_stripe_width_label, 0, wx.ALIGN_CENTRE, 0) + self.common_settings_sizer.Add(self.min_stripe_width, 0, wx.EXPAND, 0) + + self.embroidery_sizer.Add(self.output_method, 0, wx.EXPAND | wx.ALL, 10) + self.embroidery_sizer.Add(self.embroidery_element_sizer, 0, wx.EXPAND | wx.ALL, 10) + self.embroidery_sizer.Add(self.svg_elements_sizer, 0, wx.EXPAND | wx.ALL, 10) + self.embroidery_sizer.Add(self.common_settings_sizer, 0, wx.EXPAND | wx.ALL, 10) + self.embroidery_sizer.Add(wx.StaticLine(self), 0, wx.ALL | wx.EXPAND, 10) + self.embroidery_sizer.Add(help_text, 0, wx.EXPAND | wx.ALL, 10) + self.embroidery_sizer.Add(wx.StaticLine(self), 0, wx.ALL | wx.EXPAND, 10) + + self.embroidery_sizer.Hide(self.svg_elements_sizer) + self.SetSizer(self.embroidery_sizer) + + def update_output_method(self, event): + output = self.output_method.GetClientData(self.output_method.GetSelection()).id + if output == "svg": + self.embroidery_sizer.Show(self.svg_elements_sizer) + self.embroidery_sizer.Hide(self.embroidery_element_sizer) + for element in self.panel.elements: + element.pop('inkstitch:fill_method') + else: + self.embroidery_sizer.Show(self.embroidery_element_sizer) + self.embroidery_sizer.Hide(self.svg_elements_sizer) + for element in self.panel.elements: + element.set('inkstitch:fill_method', 'tartan_fill') + self.panel.settings['output'] = output + self.Layout() + self.panel.update_preview() + + def set_output(self, choice): + for option in embroider_choices: + if option.id == choice: + self.output_method.SetValue(option.name) + self.update_output_method(None) + break + + def on_change(self, attribute, event): + self.panel.settings[attribute] = event.GetEventObject().GetValue() + self.panel.update_preview() + + def on_change_stitch_type(self, event): + stitch_type = self.stitch_type.GetClientData(self.stitch_type.GetSelection()).id + self.panel.settings['stitch_type'] = stitch_type + self.panel.update_preview() + + def on_change_min_stripe_width(self, event): + self.panel.settings['min_stripe_width'] = event.EventObject.GetValue() + self.panel.customize_panel.update_stripe_width_colors() + self.panel.update_preview() + + def on_param_change(self, attribute, event): + for element in self.panel.elements: + element.set(f'inkstitch:{attribute}', str(event.GetEventObject().GetValue())) + self.panel.update_preview() + + def set_stitch_type(self, choice): + for option in stitch_type_choices: + if option.id == choice: + self.stitch_type.SetValue(option.name) + break + + +embroider_choices = [ + ParamOption("embroidery", _("Embroidery Element")), + ParamOption("svg", _("SVG Elements")) +] + + +stitch_type_choices = [ + ParamOption("auto_fill", _("AutoFill")), + ParamOption("legacy_fill", _("Legacy Fill")) +] diff --git a/lib/gui/tartan/help_panel.py b/lib/gui/tartan/help_panel.py new file mode 100644 index 000000000..1e2142d9c --- /dev/null +++ b/lib/gui/tartan/help_panel.py @@ -0,0 +1,42 @@ +# 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 wx + +from ...i18n import _ + + +class HelpPanel(wx.Panel): + def __init__(self, parent): + wx.Panel.__init__(self, parent) + help_sizer = wx.BoxSizer(wx.VERTICAL) + + help_text = wx.StaticText( + self, + wx.ID_ANY, + _("This extension fills shapes with a tartan (or tartan like) pattern."), + style=wx.ALIGN_LEFT + ) + help_text.Wrap(500) + help_sizer.Add(help_text, 0, wx.ALL, 8) + + help_sizer.Add((20, 20), 0, 0, 0) + + website_info = wx.StaticText(self, wx.ID_ANY, _("More information on our website:")) + help_sizer.Add(website_info, 0, wx.ALL, 8) + + website_link = wx.adv.HyperlinkCtrl( + self, + wx.ID_ANY, + _("https://inkstitch.org/docs/stitches/tartan-fill"), + _("https://inkstitch.org/docs/stitches/tartan-fill") + ) + website_link.Bind(wx.adv.EVT_HYPERLINK, self.on_link_clicked) + help_sizer.Add(website_link, 0, wx.ALL, 8) + + self.SetSizer(help_sizer) + + def on_link_clicked(self, event): + event.Skip() diff --git a/lib/gui/tartan/main_panel.py b/lib/gui/tartan/main_panel.py new file mode 100644 index 000000000..238c8901d --- /dev/null +++ b/lib/gui/tartan/main_panel.py @@ -0,0 +1,271 @@ +# 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 copy import copy + +import inkex +import wx +import wx.adv + +from ...elements import FillStitch, nodes_to_elements +from ...exceptions import InkstitchException, format_uncaught_exception +from ...i18n import _ +from ...stitch_plan import stitch_groups_to_stitch_plan +from ...svg.tags import INKSTITCH_TARTAN +from ...tartan.fill_element import prepare_tartan_fill_element +from ...tartan.palette import Palette +from ...tartan.svg import TartanSvgGroup +from ...utils import DotDict +from ...utils.threading import ExitThread, check_stop_flag +from .. import PresetsPanel, PreviewRenderer, WarningPanel +from . import CodePanel, CustomizePanel, EmbroideryPanel, HelpPanel + + +class TartanMainPanel(wx.Panel): + + def __init__(self, parent, simulator, elements, on_cancel=None, metadata=None, output_groups=inkex.Group()): + self.parent = parent + self.simulator = simulator + self.elements = elements + self.cancel_hook = on_cancel + self.palette = Palette() + self.metadata = metadata or dict() + self.output_groups = output_groups + + super().__init__(parent, wx.ID_ANY) + + self.SetWindowStyle(wx.FRAME_FLOAT_ON_PARENT | wx.DEFAULT_FRAME_STYLE) + + # preview + self.preview_renderer = PreviewRenderer(self.render_stitch_plan, self.on_stitch_plan_rendered) + self.presets_panel = PresetsPanel(self) + # warnings + self.warning_panel = WarningPanel(self) + self.warning_panel.Hide() + # notebook + self.notebook_sizer = wx.BoxSizer(wx.VERTICAL) + self.notebook = wx.Notebook(self, wx.ID_ANY) + self.notebook_sizer.Add(self.warning_panel, 0, wx.EXPAND | wx.ALL, 10) + self.notebook_sizer.Add(self.notebook, 1, wx.EXPAND, 0) + # customize + self.customize_panel = CustomizePanel(self.notebook, self) + self.notebook.AddPage(self.customize_panel, _('Customize')) + self.customize_panel.SetupScrolling() # scroll_x=False) + # code + self.code_panel = CodePanel(self.notebook, self) + self.notebook.AddPage(self.code_panel, _("Palette Code")) + # embroidery settings + self.embroidery_panel = EmbroideryPanel(self.notebook, self) + self.notebook.AddPage(self.embroidery_panel, _("Embroidery Settings")) + # help + help_panel = HelpPanel(self.notebook) + self.notebook.AddPage(help_panel, _("Help")) + # apply and cancel buttons + apply_sizer = wx.BoxSizer(wx.HORIZONTAL) + self.cancel_button = wx.Button(self, label=_("Cancel")) + self.cancel_button.Bind(wx.EVT_BUTTON, self.cancel) + self.apply_button = wx.Button(self, label=_("Apply")) + self.apply_button.Bind(wx.EVT_BUTTON, self.apply) + apply_sizer.Add(self.cancel_button, 0, wx.RIGHT | wx.BOTTOM, 5) + apply_sizer.Add(self.apply_button, 0, wx.RIGHT | wx.BOTTOM, 10) + + self.notebook_sizer.Add(self.presets_panel, 0, wx.EXPAND | wx.ALL, 10) + self.notebook_sizer.Add(apply_sizer, 0, wx.ALIGN_RIGHT | wx.ALL, 10) + + self.SetSizer(self.notebook_sizer) + + self.load_settings() + self.apply_settings() + + self.Layout() + self.SetMinSize(self.notebook_sizer.CalcMin()) + + def update_from_code(self): + self.palette.update_from_code(self.code_panel.threadcount_text.GetValue()) + self.customize_panel.symmetry_checkbox.SetValue(self.palette.symmetry) + self.customize_panel.warp_weft_checkbox.SetValue(self.palette.equal_warp_weft) + self.code_panel.threadcount_text.SetValue(self.palette.palette_code) + self.customize_panel.update_stripes(self.palette.palette_stripes) + self.customize_panel.update_symmetry() + self.customize_panel.update_warp_weft() + self.customize_panel.FitInside() + self.update_preview() + + def update_from_stripes(self): + sizers = [self.customize_panel.warp_sizer] + if not self.customize_panel.warp_weft_checkbox.GetValue(): + sizers.append(self.customize_panel.weft_sizer) + self.palette.update_from_stripe_sizer( + sizers, + self.customize_panel.symmetry_checkbox.GetValue(), + self.customize_panel.warp_weft_checkbox.GetValue() + ) + self.update_code_text() + self.update_preview() + + def update_code_text(self): + self.code_panel.threadcount_text.SetValue(self.palette.palette_code) + self.settings['palette'] = self.palette.palette_code + + def load_settings(self): + """Load the settings saved into the SVG element""" + self.settings = DotDict({ + "symmetry": True, + "equal_warp_weft": True, + "rotate": 0.0, + "scale": 100, + "offset_x": 0.0, + "offset_y": 0.0, + "palette": "K/10 W/?10", + "output": "embroidery", + "stitch_type": "legacy_fill", + "row_spacing": 1.0, + "angle_warp": 0.0, + "angle_weft": 90.0, + "min_stripe_width": 1.0, + "bean_stitch_repeats": 0 + }) + + try: + self.settings.update(json.loads(self.elements[0].get(INKSTITCH_TARTAN))) + except (TypeError, ValueError, IndexError): + pass + + def apply_settings(self): + """Make the settings in self.settings visible in the UI.""" + self.customize_panel.rotate.SetValue(self.settings.rotate) + self.customize_panel.scale.SetValue(int(self.settings.scale)) + self.customize_panel.offset_x.SetValue(self.settings.offset_x) + self.customize_panel.offset_y.SetValue(self.settings.offset_y) + self.code_panel.threadcount_text.SetValue(self.settings.palette) + self.embroidery_panel.set_output(self.settings.output) + self.embroidery_panel.set_stitch_type(self.settings.stitch_type) + self.embroidery_panel.svg_row_spacing.SetValue(self.settings.row_spacing) + self.embroidery_panel.angle_warp.SetValue(self.settings.angle_warp) + self.embroidery_panel.angle_weft.SetValue(self.settings.angle_weft) + self.embroidery_panel.min_stripe_width.SetValue(self.settings.min_stripe_width) + self.embroidery_panel.svg_bean_stitch_repeats.SetValue(self.settings.bean_stitch_repeats) + self.embroidery_panel.stitch_angle.SetValue(self.elements[0].get('inkstitch:tartan_angle', -45)) + self.embroidery_panel.rows_per_thread.SetValue(self.elements[0].get('inkstitch:rows_per_thread', 2)) + self.embroidery_panel.row_spacing.SetValue(self.elements[0].get('inkstitch:row_spacing_mm', 0.25)) + underlay = self.elements[0].get('inkstitch:fill_underlay', "True").lower() in ('yes', 'y', 'true', 't', '1') + self.embroidery_panel.underlay.SetValue(underlay) + self.embroidery_panel.herringbone.SetValue(self.elements[0].get('inkstitch:herringbone_width_mm', 0)) + self.embroidery_panel.bean_stitch_repeats.SetValue(self.elements[0].get('inkstitch:bean_stitch_repeats', '0')) + + self.update_from_code() + + self.customize_panel.symmetry_checkbox.SetValue(bool(self.settings.symmetry)) + self.palette.update_symmetry(self.settings.symmetry) + self.customize_panel.warp_weft_checkbox.SetValue(bool(self.settings.equal_warp_weft)) + self.customize_panel.update_warp_weft() + + def save_settings(self): + """Save the settings into the SVG elements.""" + for element in self.elements: + element.set(INKSTITCH_TARTAN, json.dumps(self.settings)) + + def get_preset_data(self): + # called by self.presets_panel + settings = dict(self.settings) + return settings + + def _hide_warning(self): + self.warning_panel.clear() + self.warning_panel.Hide() + self.Layout() + + def _show_warning(self, warning_text): + self.warning_panel.set_warning_text(warning_text) + self.warning_panel.Show() + self.Layout() + + def update_preview(self, event=None): + self.preview_renderer.update() + + def apply_preset_data(self, preset_data): + settings = DotDict(preset_data) + self.settings = settings + self.apply_settings() + + def get_preset_suite_name(self): + # called by self.presets_panel + return "tartan" + + def close(self): + self.GetTopLevelParent().Close() + + def cancel(self, event): + if self.cancel_hook: + self.cancel_hook() + self.close() + + def apply(self, event): + self.update_tartan() + self.save_settings() + self.close() + + def render_stitch_plan(self): + if self.settings['output'] == 'svg': + self.update_tartan() + stitch_groups = self._get_svg_stitch_groups() + else: + self.save_settings() + stitch_groups = [] + previous_stitch_group = None + for element in self.elements: + try: + # copy the embroidery element to drop the cache + stitch_groups.extend(copy(FillStitch(element)).embroider(previous_stitch_group)) + if stitch_groups: + previous_stitch_group = stitch_groups[-1] + except (SystemExit, ExitThread): + raise + except InkstitchException as exc: + wx.CallAfter(self._show_warning, str(exc)) + except Exception: + wx.CallAfter(self._show_warning, format_uncaught_exception()) + + if stitch_groups: + return stitch_groups_to_stitch_plan( + stitch_groups, + collapse_len=self.metadata['collapse_len_mm'], + min_stitch_len=self.metadata['min_stitch_len_mm'] + ) + + def _get_svg_stitch_groups(self): + stitch_groups = [] + previous_stitch_group = None + for element in self.elements: + parent = element.getparent() + embroidery_elements = nodes_to_elements(parent.iterdescendants()) + for embroidery_element in embroidery_elements: + check_stop_flag() + if embroidery_element.node == element: + continue + try: + # copy the embroidery element to drop the cache + stitch_groups.extend(copy(embroidery_element).embroider(previous_stitch_group)) + if stitch_groups: + previous_stitch_group = stitch_groups[-1] + except InkstitchException: + pass + except Exception: + pass + return stitch_groups + + def update_tartan(self): + for element in self.elements: + check_stop_flag() + if self.settings['output'] == 'svg': + TartanSvgGroup(self.settings).generate(element) + else: + prepare_tartan_fill_element(element) + + def on_stitch_plan_rendered(self, stitch_plan): + self.simulator.stop() + self.simulator.load(stitch_plan) + self.simulator.go() diff --git a/lib/stitches/__init__.py b/lib/stitches/__init__.py index ba56a0ec5..2164ef9e6 100644 --- a/lib/stitches/__init__.py +++ b/lib/stitches/__init__.py @@ -9,6 +9,7 @@ from .fill import legacy_fill from .guided_fill import guided_fill from .linear_gradient_fill import linear_gradient_fill from .meander_fill import meander_fill +from .tartan_fill import tartan_fill # Can't put this here because we get a circular import :( # from .auto_satin import auto_satin diff --git a/lib/stitches/auto_fill.py b/lib/stitches/auto_fill.py index ebb1fb6f3..e90093ce3 100644 --- a/lib/stitches/auto_fill.py +++ b/lib/stitches/auto_fill.py @@ -7,6 +7,7 @@ import math from itertools import chain, groupby +from typing import Iterator import networkx from shapely import geometry as shgeo @@ -53,11 +54,15 @@ class PathEdge(object): def __eq__(self, other): return self._sorted_nodes == other._sorted_nodes and self.key == other.key + def __iter__(self) -> Iterator: + for i in range(2): + yield self[i] + def is_outline(self): - return self.key in self.OUTLINE_KEYS + return self.key.startswith(self.OUTLINE_KEYS) def is_segment(self): - return self.key == self.SEGMENT_KEY + return self.key.startswith(self.SEGMENT_KEY) @debug.time @@ -87,7 +92,7 @@ def auto_fill(shape, return fallback(shape, running_stitch_length, running_stitch_tolerance) # ensure graph is eulerian - fill_stitch_graph = graph_make_valid(fill_stitch_graph) + graph_make_valid(fill_stitch_graph) travel_graph = build_travel_graph(fill_stitch_graph, shape, angle, underpath) @@ -298,8 +303,24 @@ def add_edges_between_outline_nodes(graph, duplicate_every_other=False): def graph_make_valid(graph): if not networkx.is_eulerian(graph): - return networkx.eulerize(graph) - return graph + newgraph = networkx.eulerize(graph) + for start, end, key, data in newgraph.edges(keys=True, data=True): + if isinstance(key, int): + # make valid duplicated edges, we cannot use the very same key + # again, but the automatic naming will not apply to the autofill algorithm + graph_edges = graph[start][end] + if 'segment' in graph_edges.keys(): + data = graph_edges['segment'] + graph.add_edge(start, end, key=f'segment-{key}', **data) + elif 'outline' in graph_edges.keys(): + data = graph_edges['outline'] + graph.add_edge(start, end, key='outline-{key}', **data) + elif 'extra' in graph_edges.keys(): + data = graph_edges['extra'] + graph.add_edge(start, end, key='extra-{key}', **data) + elif 'initial' in graph_edges.keys(): + data = graph_edges['initial'] + graph.add_edge(start, end, key='initial-{key}', **data) def fallback(shape, running_stitch_length, running_stitch_tolerance): @@ -380,7 +401,7 @@ def weight_edges_by_length(graph, multiplier=1): def get_segments(graph): segments = [] for start, end, key, data in graph.edges(keys=True, data=True): - if key == 'segment': + if key.startswith('segment'): segments.append(data["geometry"]) return segments diff --git a/lib/stitches/circular_fill.py b/lib/stitches/circular_fill.py index ec133f99f..28346dd91 100644 --- a/lib/stitches/circular_fill.py +++ b/lib/stitches/circular_fill.py @@ -77,7 +77,7 @@ def circular_fill(shape, if is_empty(fill_stitch_graph): return fallback(shape, running_stitch_length, running_stitch_tolerance) - fill_stitch_graph = graph_make_valid(fill_stitch_graph) + graph_make_valid(fill_stitch_graph) travel_graph = build_travel_graph(fill_stitch_graph, shape, angle, underpath) path = find_stitch_path(fill_stitch_graph, travel_graph, starting_point, ending_point) diff --git a/lib/stitches/fill.py b/lib/stitches/fill.py index 9e9ff7909..c492a6295 100644 --- a/lib/stitches/fill.py +++ b/lib/stitches/fill.py @@ -14,12 +14,15 @@ from ..utils import cache from ..utils.threading import check_stop_flag -def legacy_fill(shape, angle, row_spacing, end_row_spacing, max_stitch_length, flip, staggers, skip_last): +def legacy_fill(shape, angle, row_spacing, end_row_spacing, max_stitch_length, flip, reverse, staggers, skip_last): rows_of_segments = intersect_region_with_grating(shape, angle, row_spacing, end_row_spacing, flip) groups_of_segments = pull_runs(rows_of_segments, shape, row_spacing) - return [section_to_stitches(group, angle, row_spacing, max_stitch_length, staggers, skip_last) - for group in groups_of_segments] + stitches = [section_to_stitches(group, angle, row_spacing, max_stitch_length, staggers, skip_last) + for group in groups_of_segments] + if reverse: + stitches = [segment[::-1] for segment in stitches[::-1]] + return stitches @cache @@ -223,14 +226,8 @@ def pull_runs(rows, shape, row_spacing): # over to midway up the lower right leg. We want to stop there and # start a new patch. - # for row in rows: - # print >> sys.stderr, len(row) - - # print >>sys.stderr, "\n".join(str(len(row)) for row in rows) - rows = list(rows) runs = [] - count = 0 while (len(rows) > 0): run = [] prev = None @@ -248,10 +245,7 @@ def pull_runs(rows, shape, row_spacing): rows[row_num] = rest - # print >> sys.stderr, len(run) runs.append(run) rows = [r for r in rows if len(r) > 0] - count += 1 - return runs diff --git a/lib/stitches/guided_fill.py b/lib/stitches/guided_fill.py index 6f6500285..bc7a3ab23 100644 --- a/lib/stitches/guided_fill.py +++ b/lib/stitches/guided_fill.py @@ -43,7 +43,7 @@ def guided_fill(shape, if is_empty(fill_stitch_graph): return fallback(shape, guideline, row_spacing, max_stitch_length, running_stitch_length, running_stitch_tolerance, num_staggers, skip_last, starting_point, ending_point, underpath) - fill_stitch_graph = graph_make_valid(fill_stitch_graph) + graph_make_valid(fill_stitch_graph) travel_graph = build_travel_graph(fill_stitch_graph, shape, angle, underpath) path = find_stitch_path(fill_stitch_graph, travel_graph, starting_point, ending_point) @@ -156,14 +156,14 @@ def take_only_line_strings(thing): return shgeo.MultiLineString(line_strings) -def apply_stitches(line, max_stitch_length, num_staggers, row_spacing, row_num, threshold=None): +def apply_stitches(line, max_stitch_length, num_staggers, row_spacing, row_num, threshold=None) -> shgeo.LineString: if num_staggers == 0: num_staggers = 1 # sanity check to avoid division by zero. start = ((row_num / num_staggers) % 1) * max_stitch_length projections = np.arange(start, line.length, max_stitch_length) points = np.array([line.interpolate(projection).coords[0] for projection in projections]) - if len(points) <= 2: + if len(points) < 2: return line stitched_line = shgeo.LineString(points) diff --git a/lib/stitches/linear_gradient_fill.py b/lib/stitches/linear_gradient_fill.py index c1f0fb469..e72161caa 100644 --- a/lib/stitches/linear_gradient_fill.py +++ b/lib/stitches/linear_gradient_fill.py @@ -268,7 +268,7 @@ def _get_stitch_groups(fill, shape, colors, color_lines, starting_point, ending_ if is_empty(fill_stitch_graph): continue - fill_stitch_graph = graph_make_valid(fill_stitch_graph) + graph_make_valid(fill_stitch_graph) travel_graph = build_travel_graph(fill_stitch_graph, shape, fill.angle, False) path = find_stitch_path(fill_stitch_graph, travel_graph, starting_point, ending_point) diff --git a/lib/stitches/tartan_fill.py b/lib/stitches/tartan_fill.py new file mode 100644 index 000000000..4d9f3b0fb --- /dev/null +++ b/lib/stitches/tartan_fill.py @@ -0,0 +1,808 @@ +# 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 collections import defaultdict +from itertools import chain +from math import cos, radians, sin +from typing import TYPE_CHECKING, List, Optional, Tuple, Union + +from networkx import is_empty +from shapely import get_point, line_merge, minimum_bounding_radius, segmentize +from shapely.affinity import rotate, scale, translate +from shapely.geometry import LineString, MultiLineString, Point, Polygon +from shapely.ops import nearest_points + +from ..stitch_plan import Stitch, StitchGroup +from ..svg import PIXELS_PER_MM +from ..tartan.utils import (get_palette_width, get_tartan_settings, + get_tartan_stripes, sort_fills_and_strokes, + stripes_to_shapes) +from ..utils import cache, ensure_multi_line_string +from ..utils.threading import check_stop_flag +from .auto_fill import (build_fill_stitch_graph, build_travel_graph, + find_stitch_path, graph_make_valid) +from .circular_fill import path_to_stitches +from .guided_fill import apply_stitches +from .linear_gradient_fill import remove_start_end_travel +from .running_stitch import bean_stitch + +if TYPE_CHECKING: + from ..elements import FillStitch + + +def tartan_fill(fill: 'FillStitch', outline: Polygon, starting_point: Union[tuple, Stitch, None], ending_point: Union[tuple, Stitch, None]): + """ + Main method to fill the tartan element with tartan fill stitches + + :param fill: FillStitch element + :param outline: the outline of the fill + :param starting_point: the starting point (or None) + :param ending_point: the ending point (or None) + :returns: stitch_groups forming the tartan pattern + """ + tartan_settings = get_tartan_settings(fill.node) + warp, weft = get_tartan_stripes(tartan_settings) + warp_width = get_palette_width(tartan_settings) + weft_width = get_palette_width(tartan_settings, 1) + + offset = (abs(tartan_settings['offset_x']), abs(tartan_settings['offset_y'])) + rotation = tartan_settings['rotate'] + dimensions = _get_dimensions(fill, outline, offset, warp_width, weft_width) + rotation_center = _get_rotation_center(outline) + + warp_shapes = stripes_to_shapes( + warp, + dimensions, + outline, + rotation, + rotation_center, + tartan_settings['symmetry'], + tartan_settings['scale'], + tartan_settings['min_stripe_width'], + False, # weft + False # do not cut polygons just yet + ) + + weft_shapes = stripes_to_shapes( + weft, + dimensions, + outline, + rotation, + rotation_center, + tartan_settings['symmetry'], + tartan_settings['scale'], + tartan_settings['min_stripe_width'], + True, # weft + False # do not cut polygons just yet + ) + + if fill.herringbone_width > 0: + lines = _generate_herringbone_lines(outline, fill, dimensions, rotation) + warp_lines, weft_lines = _split_herringbone_warp_weft(lines, fill.rows_per_thread, fill.running_stitch_length) + warp_color_lines = _get_herringbone_color_segments(warp_lines, warp_shapes, outline, rotation, fill.running_stitch_length) + weft_color_lines = _get_herringbone_color_segments(weft_lines, weft_shapes, outline, rotation, fill.running_stitch_length, True) + else: + lines = _generate_tartan_lines(outline, fill, dimensions, rotation) + warp_lines, weft_lines = _split_warp_weft(lines, fill.rows_per_thread) + warp_color_lines = _get_tartan_color_segments(warp_lines, warp_shapes, outline, rotation, fill.running_stitch_length) + weft_color_lines = _get_tartan_color_segments(weft_lines, weft_shapes, outline, rotation, fill.running_stitch_length, True) + if not lines: + return [] + + warp_color_runs = _get_color_runs(warp_shapes, fill.running_stitch_length) + weft_color_runs = _get_color_runs(weft_shapes, fill.max_stitch_length) + + color_lines = defaultdict(list) + for color, lines in chain(warp_color_lines.items(), weft_color_lines.items()): + color_lines[color].extend(lines) + + color_runs = defaultdict(list) + for color, lines in chain(warp_color_runs.items(), weft_color_runs.items()): + color_runs[color].extend(lines) + + color_lines, color_runs = sort_fills_and_strokes(color_lines, color_runs) + + stitch_groups = _get_fill_stitch_groups(fill, outline, color_lines) + if stitch_groups: + starting_point = stitch_groups[-1].stitches[-1] + stitch_groups += _get_run_stitch_groups(fill, outline, color_runs, starting_point, ending_point) + return stitch_groups + + +def _generate_herringbone_lines( + outline: Polygon, + fill: 'FillStitch', + dimensions: Tuple[float, float, float, float], + rotation: float, +) -> List[List[List[LineString]]]: + """ + Generates herringbone lines with staggered stitch positions + + :param outline: the outline to fill with the herringbone lines + :param fill: the tartan fill element + :param dimensions: minx, miny, maxx, maxy + :param rotation: the rotation value + :returns: a tuple of two list with herringbone stripes [0] up segments / [1] down segments \ + """ + rotation_center = _get_rotation_center(outline) + minx, miny, maxx, maxy = dimensions + + herringbone_lines: list = [[], []] + odd = True + while minx < maxx: + odd = not odd + right = minx + fill.herringbone_width + if odd: + left_line = LineString([(minx, miny), (minx, maxy + fill.herringbone_width)]) + else: + left_line = LineString([(minx, miny - fill.herringbone_width), (minx, maxy)]) + + if odd: + right_line = LineString([(right, miny - fill.herringbone_width), (right, maxy)]) + else: + right_line = LineString([(right, miny), (right, maxy + fill.herringbone_width)]) + + left_line = segmentize(left_line, max_segment_length=fill.row_spacing) + right_line = segmentize(right_line, max_segment_length=fill.row_spacing) + + lines = list(zip(left_line.coords, right_line.coords)) + + staggered_lines = [] + for i, line in enumerate(lines): + linestring = LineString(line) + staggered_line = apply_stitches(linestring, fill.max_stitch_length, fill.staggers, fill.row_spacing, i) + # make sure we do not ommit the very first or very last point (it would confuse our sorting algorithm) + staggered_line = LineString([linestring.coords[0]] + list(staggered_line.coords) + [linestring.coords[-1]]) + staggered_lines.append(staggered_line) + + if odd: + herringbone_lines[0].append(list(rotate(MultiLineString(staggered_lines), rotation, rotation_center).geoms)) + else: + herringbone_lines[1].append(list(rotate(MultiLineString(staggered_lines), rotation, rotation_center).geoms)) + + # add some little space extra to make things easier with line_merge later on + # (avoid spots with 4 line points) + minx += fill.herringbone_width + 0.005 + + return herringbone_lines + + +def _generate_tartan_lines( + outline: Polygon, + fill: 'FillStitch', + dimensions: Tuple[float, float, float, float], + rotation: float, +) -> List[LineString]: + """ + Generates tartan lines with staggered stitch positions + + :param outline: the outline to fill with the herringbone lines + :param fill: the tartan fill element + :param dimensions: minx, miny, maxx, maxy + :param rotation: the rotation value + :returns: a list with the tartan lines + """ + rotation_center = _get_rotation_center(outline) + # default angle is 45° + rotation += fill.tartan_angle + minx, miny, maxx, maxy = dimensions + + left_line = LineString([(minx, miny), (minx, maxy)]) + left_line = rotate(left_line, rotation, rotation_center) + left_line = segmentize(left_line, max_segment_length=fill.row_spacing) + + right_line = LineString([(maxx, miny), (maxx, maxy)]) + right_line = rotate(right_line, rotation, rotation_center) + right_line = segmentize(right_line, max_segment_length=fill.row_spacing) + + lines = list(zip(left_line.coords, right_line.coords)) + + staggered_lines = [] + for i, line in enumerate(lines): + linestring = LineString(line) + staggered_line = apply_stitches(linestring, fill.max_stitch_length, fill.staggers, fill.row_spacing, i) + # make sure we do not ommit the very first or very last point (it would confuse our sorting algorithm) + staggered_line = LineString([linestring.coords[0]] + list(staggered_line.coords) + [linestring.coords[-1]]) + staggered_lines.append(staggered_line) + return staggered_lines + + +def _split_herringbone_warp_weft( + lines: List[List[List[LineString]]], + rows_per_thread: int, + stitch_length: float +) -> tuple: + """ + Split the herringbone lines into warp lines and weft lines as defined by rows rows_per_thread + Merge weft lines for each block. + + :param lines: lines to divide + :param rows_per_thread: length of line blocks + :param stitch_length: maximum stitch length for weft connector lines + :returns: [0] warp and [1] weft list of MultiLineString objects + """ + warp_lines: List[LineString] = [] + weft_lines: List[LineString] = [] + for i, line_blocks in enumerate(lines): + for line_block in line_blocks: + if i == 0: + warp, weft = _split_warp_weft(line_block, rows_per_thread) + else: + weft, warp = _split_warp_weft(line_block, rows_per_thread) + warp_lines.append(warp) + weft_lines.append(weft) + + connected_weft = [] + line2 = None + for multilinestring in weft_lines: + connected_line_block = [] + geoms = list(multilinestring.geoms) + for line1, line2 in zip(geoms[:-1], geoms[1:]): + connected_line_block.append(line1) + connector_line = LineString([get_point(line1, -1), get_point(line2, 0)]) + connector_line = segmentize(connector_line, max_segment_length=stitch_length) + connected_line_block.append(connector_line) + if line2: + connected_line_block.append(line2) + connected_weft.append(ensure_multi_line_string(line_merge(MultiLineString(connected_line_block)))) + return warp_lines, connected_weft + + +def _split_warp_weft(lines: List[LineString], rows_per_thread: int) -> Tuple[List[LineString], List[LineString]]: + """ + Divide given lines in warp and weft, sort afterwards + + :param lines: a list of LineString shapes + :param rows_per_thread: length of line blocks + :returns: tuple with sorted [0] warp and [1] weft LineString shapes + """ + warp_lines = [] + weft_lines = [] + for i in range(rows_per_thread): + warp_lines.extend(lines[i::rows_per_thread*2]) + weft_lines.extend(lines[i+rows_per_thread::rows_per_thread*2]) + return _sort_lines(warp_lines), _sort_lines(weft_lines) + + +def _sort_lines(lines: List[LineString]): + """ + Sort given list of LineString shapes by first coordinate + and reverse every second line + + :param lines: a list of LineString shapes + :returns: sorted list of LineString shapes with alternating directions + """ + # sort lines + lines.sort(key=lambda line: line.coords[0]) + # reverse every second line + lines = [line if i % 2 == 0 else line.reverse() for i, line in enumerate(lines)] + return MultiLineString(lines) + + +@cache +def _get_rotation_center(outline: Polygon) -> Point: + """ + Returns the rotation center used for any tartan pattern rotation + + :param outline: the polygon shape to be filled with the pattern + :returns: the center point of the shape + """ + # somehow outline.centroid doesn't deliver the point we need + bounds = outline.bounds + return LineString([(bounds[0], bounds[1]), (bounds[2], bounds[3])]).centroid + + +@cache +def _get_dimensions( + fill: 'FillStitch', + outline: Polygon, + offset: Tuple[float, float], + warp_width: float, + weft_width: float +) -> Tuple[float, float, float, float]: + """ + Calculates the dimensions for the tartan pattern. + Make sure it is big enough for pattern rotations, etc. + + :param fill: the FillStitch element + :param outline: the shape to be filled with a tartan pattern + :param offset: mm offset for x, y + :param warp_width: mm warp width + :param weft_width: mm weft width + :returns: a tuple with boundaries (minx, miny, maxx, maxy) + """ + # add space to allow rotation and herringbone patterns to cover the shape + centroid = _get_rotation_center(outline) + min_radius = minimum_bounding_radius(outline) + minx = centroid.x - min_radius + miny = centroid.y - min_radius + maxx = centroid.x + min_radius + maxy = centroid.y + min_radius + + # add some extra space + extra_space = max( + warp_width * PIXELS_PER_MM, + weft_width * PIXELS_PER_MM, + 2 * fill.row_spacing * fill.rows_per_thread + ) + minx -= extra_space + maxx += extra_space + miny -= extra_space + maxy += extra_space + + minx -= (offset[0] * PIXELS_PER_MM) + miny -= (offset[1] * PIXELS_PER_MM) + + return minx, miny, maxx, maxy + + +def _get_herringbone_color_segments( + lines: List[MultiLineString], + polygons: defaultdict, + outline: Polygon, + rotation: float, + stitch_length: float, + weft: bool = False +) -> defaultdict: + """ + Generate herringbone line segments in given tartan direction grouped by color + + :param lines: the line segments forming the pattern + :param polygons: color grouped polygon stripes + :param outline: the outline to be filled with the herringbone pattern + :param rotation: degrees used for rotation + :param stitch_length: maximum stitch length for weft connector lines + :param weft: wether to render as warp or weft + :returns: defaultdict with color grouped herringbone segments + """ + line_segments: defaultdict = defaultdict(list) + + if not polygons: + return line_segments + + lines = line_merge(lines) + for line_blocks in lines: + segments = _get_tartan_color_segments(line_blocks, polygons, outline, rotation, stitch_length, weft, True) + for color, segment in segments.items(): + if weft: + line_segments[color].append(MultiLineString(segment)) + else: + line_segments[color].extend(segment) + + if not weft: + return line_segments + + return _get_weft_herringbone_color_segments(outline, line_segments, polygons, stitch_length) + + +def _get_weft_herringbone_color_segments( + outline: Polygon, + line_segments: defaultdict, + polygons: defaultdict, + stitch_length: float, +) -> defaultdict: + """ + Makes sure weft herringbone lines connect correctly + + Herringbone weft lines need to connect in horizontal direction (or whatever the current rotation is) + which is opposed to the herringbone stripe blocks \\\\ //// \\\\ //// \\\\ //// + + :param outline: the outline to be filled with the herringbone pattern + :param line_segments: the line segments forming the pattern + :param polygons: color grouped polygon stripes + :param stitch_length: maximum stitch length + :returns: defaultdict with color grouped weft lines + """ + weft_lines = defaultdict(list) + for color, lines in line_segments.items(): + color_lines: List[LineString] = [] + for polygon in polygons[color][0]: + polygon = polygon.normalize() + polygon_coords = list(polygon.exterior.coords) + polygon_top = LineString(polygon_coords[0:2]) + polygon_bottom = LineString(polygon_coords[2:4]).reverse() + if not any([polygon_top.intersects(outline), polygon_bottom.intersects(outline)]): + polygon_top = LineString(polygon_coords[1:3]) + polygon_bottom = LineString(polygon_coords[3:5]).reverse() + + polygon_multi_lines = lines + polygon_multi_lines.sort(key=lambda line: polygon_bottom.project(line.centroid)) + polygon_lines = [] + for multiline in polygon_multi_lines: + polygon_lines.extend(multiline.geoms) + polygon_lines = [line for line in polygon_lines if line.intersects(polygon)] + if not polygon_lines: + continue + color_lines.extend(polygon_lines) + + if polygon_top.intersects(outline) or polygon_bottom.intersects(outline): + connectors = _get_weft_herringbone_connectors(polygon_lines, polygon_top, polygon_bottom, stitch_length) + if connectors: + color_lines.extend(connectors) + + check_stop_flag() + + # Users are likely to type in a herringbone width which is a multiple (or fraction) of the stripe width. + # They may end up unconnected after line_merge, so we need to shift the weft for a random small number + multi_lines = translate(ensure_multi_line_string(line_merge(MultiLineString(color_lines))), 0.00123, 0.00123) + multi_lines = ensure_multi_line_string(multi_lines.intersection(outline)) + + weft_lines[color].extend(list(multi_lines.geoms)) + + return weft_lines + + +def _get_weft_herringbone_connectors( + polygon_lines: List[LineString], + polygon_top: LineString, + polygon_bottom: LineString, + stitch_length: float +) -> List[LineString]: + """ + Generates lines to connect lines + + :param polygon_lines: lines to connect + :param polygon_top: top line of the polygon + :param polygon_bottom: bottom line of the polygon + :param stitch_length: stitch length + :returns: a list of LineString connectors + """ + connectors: List[LineString] = [] + previous_end = None + for line in reversed(polygon_lines): + start = get_point(line, 0) + end = get_point(line, -1) + if previous_end is None: + # adjust direction of polygon lines if necessary + if polygon_top.project(start, True) > 0.5: + polygon_top = polygon_top.reverse() + polygon_bottom = polygon_bottom.reverse() + start_distance = polygon_top.project(start) + end_distance = polygon_top.project(end) + if start_distance > end_distance: + start, end = end, start + previous_end = end + continue + + # adjust line direction and add connectors + prev_polygon_line = min([polygon_top, polygon_bottom], key=lambda polygon_line: previous_end.distance(polygon_line)) + current_polygon_line = min([polygon_top, polygon_bottom], key=lambda polygon_line: start.distance(polygon_line)) + if prev_polygon_line != current_polygon_line: + start, end = end, start + if not previous_end == start: + connector = LineString([previous_end, start]) + if prev_polygon_line == polygon_top: + connector = connector.offset_curve(-0.0001) + else: + connector = connector.offset_curve(0.0001) + connectors.append(LineString([previous_end, get_point(connector, 0)])) + connectors.append(segmentize(connector, max_segment_length=stitch_length)) + connectors.append(LineString([get_point(connector, -1), start])) + previous_end = end + return connectors + + +def _get_tartan_color_segments( + lines: List[LineString], + polygons: defaultdict, + outline: Polygon, + rotation: float, + stitch_length: float, + weft: bool = False, + herringbone: bool = False +) -> defaultdict: + """ + Generate tartan line segments in given tartan direction grouped by color + + :param lines: the lines to form the tartan pattern with + :param polygons: color grouped polygon stripes + :param outline: the outline to fill with the tartan pattern + :param rotation: rotation in degrees + :param stitch_length: maximum stitch length for weft connector lines + :param weft: wether to render as warp or weft + :param herringbone: wether herringbone or normal tartan patterns are rendered + :returns: a dictionary with color grouped line segments + """ + line_segments: defaultdict = defaultdict(list) + if not polygons: + return line_segments + for color, shapes in polygons.items(): + polygons = shapes[0] + for polygon in polygons: + segments = _get_segment_lines(polygon, lines, outline, stitch_length, rotation, weft, herringbone) + if segments: + line_segments[color].extend(segments) + check_stop_flag() + return line_segments + + +def _get_color_runs(lines: defaultdict, stitch_length: float) -> defaultdict: + """ + Segmentize running stitch segments and return in a separate color grouped dictionary + + :param lines: tartan shapes grouped by color + :param stitch_length: stitch length used to segmentize the lines + :returns: defaultdict with segmentized running stitches grouped by color + """ + runs: defaultdict = defaultdict(list) + if not lines: + return runs + for color, shapes in lines.items(): + for run in shapes[1]: + runs[color].append(segmentize(run, max_segment_length=stitch_length)) + return runs + + +def _get_segment_lines( + polygon: Polygon, + lines: MultiLineString, + outline: Polygon, + stitch_length: float, + rotation: float, + weft: bool, + herringbone: bool +) -> List[LineString]: + """ + Fill the given polygon with lines + Each line should start and end at the outline border + + :param polygon: the polygon stripe to fill + :param lines: the lines that form the pattern + :param outline: the outline to fill with the tartan pattern + :param stitch_length: maximum stitch length for weft connector lines + :param rotation: rotation in degrees + :param weft: wether to render as warp or weft + :param herringbone: wether herringbone or normal tartan patterns are rendered + :returns: a list of LineString objects + """ + boundary = outline.boundary + segments = [] + if not lines.intersects(polygon): + return [] + segment_lines = list(ensure_multi_line_string(lines.intersection(polygon), 0.5).geoms) + if not segment_lines: + return [] + previous_line = None + for line in segment_lines: + segments.append(line) + if not previous_line: + previous_line = line + continue + point1 = get_point(previous_line, -1) + point2 = get_point(line, 0) + if point1.equals(point2): + previous_line = line + continue + # add connector from point1 to point2 if none of them touches the outline + connector = _get_connector(point1, point2, boundary, stitch_length) + if connector: + segments.append(connector) + previous_line = line + + if not segments: + return [] + lines = line_merge(MultiLineString(segments)) + + if not (herringbone and weft): + lines = lines.intersection(outline) + + if not herringbone: + lines = _connect_lines_to_outline(lines, outline, rotation, stitch_length, weft) + + return list(ensure_multi_line_string(lines).geoms) + + +def _get_connector( + point1: Point, + point2: Point, + boundary: Union[MultiLineString, LineString], + stitch_length: float +) -> Optional[LineString]: + """ + Constructs a line between the two points when they are not near the boundary + + :param point1: first point + :param point2: last point + :param boundary: the outline of the shape (including holes) + :param stitch_length: maximum stitch length to segmentize new line + :returns: a LineString between point1 and point1, None if one of them touches the boundary + """ + connector = None + if point1.distance(boundary) > 0.005 and point2.distance(boundary) > 0.005: + connector = segmentize(LineString([point1, point2]), max_segment_length=stitch_length) + return connector + + +def _connect_lines_to_outline( + lines: Union[MultiLineString, LineString], + outline: Polygon, + rotation: float, + stitch_length: float, + weft: bool +) -> Union[MultiLineString, LineString]: + """ + Connects end points within the shape with the outline + This should only be necessary if the tartan angle is nearly 0 or 90 degrees + + :param lines: lines to connect to the outline (if necessary) + :param outline: the shape to be filled with a tartan pattern + :param rotation: the rotation value + :param stitch_length: maximum stitch length to segmentize new line + :param weft: wether to render as warp or weft + :returns: merged line(s) connected to the outline + """ + boundary = outline.boundary + lines = list(ensure_multi_line_string(lines).geoms) + outline_connectors = [] + for line in lines: + start = get_point(line, 0) + end = get_point(line, -1) + if start.intersects(outline) and start.distance(boundary) > 0.05: + outline_connectors.append(_connect_point_to_outline(start, outline, rotation, stitch_length, weft)) + if end.intersects(outline) and end.distance(boundary) > 0.05: + outline_connectors.append(_connect_point_to_outline(end, outline, rotation, stitch_length, weft)) + lines.extend(outline_connectors) + lines = line_merge(MultiLineString(lines)) + return lines + + +def _connect_point_to_outline( + point: Point, + outline: Polygon, + rotation: float, + stitch_length: float, + weft: bool +) -> Union[LineString, list]: + """ + Connect given point to the outline + + :param outline: the shape to be filled with a tartan pattern + :param rotation: the rotation value + :param stitch_length: maximum stitch length to segmentize new line + :param weft: wether to render as warp or weft + :returns: a Linestring with the correct angle for the given tartan direction (between outline and point) + """ + scale_factor = point.hausdorff_distance(outline) * 2 + directional_vector = _get_angled_line_from_point(point, rotation, scale_factor, weft) + directional_vector = outline.boundary.intersection(directional_vector) + if directional_vector.is_empty: + return [] + return segmentize(LineString([point, nearest_points(directional_vector, point)[0]]), max_segment_length=stitch_length) + + +def _get_angled_line_from_point(point: Point, rotation: float, scale_factor: float, weft: bool) -> LineString: + """ + Generates an angled line for the given tartan direction + + :param point: the starting point for the new line + :param rotation: the rotation value + :param scale_factor: defines the length of the line + :param weft: wether to render as warp or weft + :returns: a LineString + """ + if not weft: + rotation += 90 + rotation = radians(rotation) + x = point.coords[0][0] + cos(rotation) + y = point.coords[0][1] + sin(rotation) + return scale(LineString([point, (x, y)]), scale_factor, scale_factor) + + +def _get_fill_stitch_groups( + fill: 'FillStitch', + shape: Polygon, + color_lines: defaultdict, +) -> List[StitchGroup]: + """ + Route fill stitches + + :param fill: the FillStitch element + :param shape: the shape to be filled + :param color_lines: lines grouped by color + :returns: a list with StitchGroup objects + """ + stitch_groups: List[StitchGroup] = [] + i = 0 + for color, lines in color_lines.items(): + i += 1 + if stitch_groups: + starting_point = stitch_groups[-1].stitches[-1] + else: + starting_point = ensure_multi_line_string(shape.boundary).geoms[0].coords[0] + ending_point = ensure_multi_line_string(shape.boundary).geoms[0].coords[0] + segments = [list(line.coords) for line in lines if len(line.coords) > 1] + stitch_group = _segments_to_stitch_group(fill, shape, segments, i, color, starting_point, ending_point) + if stitch_group is not None: + stitch_groups.append(stitch_group) + check_stop_flag() + return stitch_groups + + +def _get_run_stitch_groups( + fill: 'FillStitch', + shape: Polygon, + color_lines: defaultdict, + starting_point: Optional[Union[tuple, Stitch]], + ending_point: Optional[Union[tuple, Stitch]] +) -> List[StitchGroup]: + """ + Route running stitches + + :param fill: the FillStitch element + :param shape: the shape to be filled + :param color_lines: lines grouped by color + :param starting_point: the starting point + :param ending_point: the ending point + :returns: a list with StitchGroup objects + """ + stitch_groups: List[StitchGroup] = [] + for color, lines in color_lines.items(): + segments = [list(line.coords) for line in lines if len(line.coords) > 1] + stitch_group = _segments_to_stitch_group(fill, shape, segments, None, color, starting_point, ending_point, True) + if stitch_group is not None: + stitch_groups.append(stitch_group) + check_stop_flag() + return stitch_groups + + +def _segments_to_stitch_group( + fill: 'FillStitch', + shape: Polygon, + segments: List[List[Tuple[float, float]]], + iteration: Optional[int], + color: str, + starting_point: Optional[Union[tuple, Stitch]], + ending_point: Optional[Union[tuple, Stitch]], + runs: bool = False +) -> Optional[StitchGroup]: + """ + Route segments and turn them into a stitch group + + :param fill: the FillStitch element + :param shape: the shape to be filled + :param segments: a list with coordinate tuples + :param iteration: wether to remove start and end travel stitches from the stitch group + :param color: color information + :param starting_point: the starting point + :param ending_point: the ending point + :param runs: wether running_stitch options should be applied or not + :returns: a StitchGroup + """ + fill_stitch_graph = build_fill_stitch_graph(shape, segments, starting_point, ending_point) + if is_empty(fill_stitch_graph): + return None + graph_make_valid(fill_stitch_graph) + travel_graph = build_travel_graph(fill_stitch_graph, shape, fill.angle, False) + path = find_stitch_path(fill_stitch_graph, travel_graph, starting_point, ending_point) + stitches = path_to_stitches( + shape, + path, + travel_graph, + fill_stitch_graph, + fill.running_stitch_length, + fill.running_stitch_tolerance, + fill.skip_last, + False # no underpath + ) + + if iteration: + stitches = remove_start_end_travel(fill, stitches, color, iteration) + + if runs: + stitches = bean_stitch(stitches, fill.bean_stitch_repeats, ['auto_fill_travel']) + + stitch_group = StitchGroup( + color=color, + tags=("tartan_fill", "auto_fill_top"), + stitches=stitches, + force_lock_stitches=fill.force_lock_stitches, + lock_stitches=fill.lock_stitches, + trim_after=fill.has_command("trim") or fill.trim_after + ) + + if runs: + stitch_group.add_tag("tartan_run") + + return stitch_group diff --git a/lib/svg/tags.py b/lib/svg/tags.py index 1da2eb402..961a30286 100644 --- a/lib/svg/tags.py +++ b/lib/svg/tags.py @@ -46,6 +46,7 @@ SODIPODI_INSENSITIVE = inkex.addNS('insensitive', 'sodipodi') SODIPODI_NODETYPES = inkex.addNS('nodetypes', 'sodipodi') INKSTITCH_LETTERING = inkex.addNS('lettering', 'inkstitch') +INKSTITCH_TARTAN = inkex.addNS('tartan', 'inkstitch') EMBROIDERABLE_TAGS = (SVG_PATH_TAG, SVG_LINE_TAG, SVG_POLYLINE_TAG, SVG_POLYGON_TAG, SVG_RECT_TAG, SVG_ELLIPSE_TAG, SVG_CIRCLE_TAG) @@ -102,6 +103,9 @@ inkstitch_attribs = [ 'stop_at_ending_point', 'flip', 'clip', + 'rows_per_thread', + 'herringbone_width_mm', + 'tartan_angle', # stroke 'stroke_method', 'bean_stitch_repeats', diff --git a/lib/tartan/colors.py b/lib/tartan/colors.py new file mode 100644 index 000000000..790ef20ba --- /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 000000000..34139e6cc --- /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 000000000..12d191a76 --- /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 000000000..4ca48f028 --- /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 000000000..b71b0384d --- /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 diff --git a/lib/utils/clamp_path.py b/lib/utils/clamp_path.py index f6db66a87..0f58f83c8 100644 --- a/lib/utils/clamp_path.py +++ b/lib/utils/clamp_path.py @@ -1,6 +1,9 @@ -from shapely.geometry import LineString, Point as ShapelyPoint, MultiPolygon +from shapely.geometry import LineString, MultiPolygon +from shapely.geometry import Point as ShapelyPoint from shapely.prepared import prep -from .geometry import Point, ensure_geometry_collection + +from .geometry import (Point, ensure_geometry_collection, + ensure_multi_line_string) def path_to_segments(path): @@ -122,7 +125,7 @@ def clamp_path_to_polygon(path, polygon): if not exit_point.intersects(entry_point): # Now break the border into pieces using those points. border = find_border(polygon, exit_point) - border_pieces = border.difference(MultiPolygon((entry_point, exit_point))).geoms + border_pieces = ensure_multi_line_string(border.difference(MultiPolygon((entry_point, exit_point)))).geoms border_pieces = fix_starting_point(border_pieces) # Pick the shortest way to get from the exiting to the diff --git a/lib/utils/geometry.py b/lib/utils/geometry.py index 739f96601..24cf8459e 100644 --- a/lib/utils/geometry.py +++ b/lib/utils/geometry.py @@ -8,7 +8,7 @@ import typing import numpy from shapely.geometry import (GeometryCollection, LinearRing, LineString, - MultiLineString, MultiPolygon) + MultiLineString, MultiPoint, MultiPolygon) from shapely.geometry import Point as ShapelyPoint @@ -159,6 +159,26 @@ def ensure_multi_polygon(thing, min_size=0): return multi_polygon +def ensure_multi_point(thing): + """Given a shapely geometry, return a MultiPoint""" + multi_point = MultiPoint() + if thing.is_empty: + return multi_point + if thing.geom_type == "MultiPoint": + return thing + elif thing.geom_type == "Point": + return MultiPoint([thing]) + elif thing.geom_type == "GeometryCollection": + points = [] + for shape in thing.geoms: + if shape.geom_type == "Point": + points.append(shape) + elif shape.geom_type == "MultiPoint": + points.extend(shape.geoms) + return MultiPoint(points) + return multi_point + + def cut_path(points, length): """Return a subsection of at the start of the path that is length units long. diff --git a/templates/tartan.xml b/templates/tartan.xml new file mode 100644 index 000000000..f80503094 --- /dev/null +++ b/templates/tartan.xml @@ -0,0 +1,17 @@ + + + Tartan + org.{{ id_inkstitch }}.tartan + tartan + + all + + + + + + + +