diff options
33 files changed, 1966 insertions, 65 deletions
@@ -27,6 +27,7 @@ logs/ /LOGGING[0-9]*.toml /debug* /.debug* +/inkstitch_debug* # old debug files - to be removed /DEBUG*.ini diff --git a/icons/invisible.png b/icons/invisible.png Binary files differnew file mode 100644 index 00000000..0aa1b95b --- /dev/null +++ b/icons/invisible.png diff --git a/icons/invisible.svg b/icons/invisible.svg new file mode 100644 index 00000000..86842be5 --- /dev/null +++ b/icons/invisible.svg @@ -0,0 +1,74 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<!-- Created with Inkscape (http://www.inkscape.org/) --> + +<svg + width="256" + height="256" + viewBox="0 0 256 256" + id="svg8375" + version="1.1" + inkscape:version="1.3 (1:1.3+202307231459+0e150ed6c4)" + sodipodi:docname="invisible.svg" + inkscape:export-filename="invisible.png" + inkscape:export-xdpi="96" + inkscape:export-ydpi="96" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns="http://www.w3.org/2000/svg" + xmlns:svg="http://www.w3.org/2000/svg" + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:cc="http://creativecommons.org/ns#" + xmlns:dc="http://purl.org/dc/elements/1.1/" + xmlns:inkstitch="http://inkstitch.org/namespace"> + <sodipodi:namedview + id="base" + pagecolor="#ffffff" + bordercolor="#666666" + borderopacity="1.0" + inkscape:pageopacity="0.0" + inkscape:pageshadow="2" + inkscape:zoom="2.0952828" + inkscape:cx="45.578573" + inkscape:cy="97.122928" + inkscape:document-units="px" + inkscape:current-layer="layer1" + showgrid="false" + units="mm" + inkscape:window-width="1366" + inkscape:window-height="705" + inkscape:window-x="-4" + inkscape:window-y="-4" + inkscape:window-maximized="1" + inkscape:showpageshadow="2" + inkscape:pagecheckerboard="0" + inkscape:deskcolor="#d1d1d1" /> + <defs + id="defs8377" /> + <metadata + id="metadata8380"> + <rdf:RDF> + <cc:Work + rdf:about=""> + <dc:format>image/svg+xml</dc:format> + <dc:type + rdf:resource="http://purl.org/dc/dcmitype/StillImage" /> + </cc:Work> + </rdf:RDF> + <inkstitch:min_stitch_len_mm>0.1</inkstitch:min_stitch_len_mm> + <inkstitch:collapse_len_mm>5.0</inkstitch:collapse_len_mm> + <inkstitch:inkstitch_svg_version>2</inkstitch:inkstitch_svg_version> + </metadata> + <g + inkscape:label="Layer 1" + inkscape:groupmode="layer" + id="layer1"> + <path + id="path2-6_5933" + style="vector-effect:none;fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.5483" + d="M 129.32422 55.861328 C 110.32282 55.757387 91.281627 60.1355 74.181641 68.480469 C 46.079495 81.850453 22.919484 103.71975 3.8554688 128 C 15.21954 142.49825 28.16565 156.21308 42.720703 167.73242 L 82.140625 122.39453 C 82.658785 117.91155 83.814437 113.49799 85.742188 109.3457 C 91.111437 96.514585 102.78834 87.112914 116.00391 83.449219 L 139.54492 56.375 C 136.14668 56.064195 132.73662 55.879995 129.32422 55.861328 z M 213.43555 88.087891 L 173.82422 133.64453 C 172.48714 144.14972 167.59687 154.16973 159.85352 161.44727 C 154.33406 166.90989 147.40088 170.62016 139.99023 172.55859 L 116.50977 199.5625 C 139.32834 201.7575 162.76183 197.32341 183.29688 187.06055 C 210.85815 173.69799 233.55503 152.03542 252.14453 128 C 240.89596 113.40073 227.9836 99.632292 213.43555 88.087891 z " /> + <path + style="display:inline;fill:none;fill-opacity:1;stroke:#000000;stroke-width:29.27;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1" + d="M 201.62932,43.318967 54.370675,212.68103" + id="path2" /> + </g> +</svg> diff --git a/icons/layer.png b/icons/layer.png Binary files differnew file mode 100644 index 00000000..4e0584ad --- /dev/null +++ b/icons/layer.png diff --git a/icons/layer.svg b/icons/layer.svg new file mode 100644 index 00000000..b39ca4c5 --- /dev/null +++ b/icons/layer.svg @@ -0,0 +1,70 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<!-- Created with Inkscape (http://www.inkscape.org/) --> + +<svg + width="256" + height="256" + viewBox="0 0 256 256" + id="svg8375" + version="1.1" + inkscape:version="1.3 (1:1.3+202307231459+0e150ed6c4)" + sodipodi:docname="layer.svg" + inkscape:export-filename="layer.png" + inkscape:export-xdpi="96" + inkscape:export-ydpi="96" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns="http://www.w3.org/2000/svg" + xmlns:svg="http://www.w3.org/2000/svg" + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:cc="http://creativecommons.org/ns#" + xmlns:dc="http://purl.org/dc/elements/1.1/"> + <defs + id="defs8377" /> + <sodipodi:namedview + id="base" + pagecolor="#ffffff" + bordercolor="#666666" + borderopacity="1.0" + inkscape:pageopacity="0.0" + inkscape:pageshadow="2" + inkscape:zoom="1.979899" + inkscape:cx="94.954339" + inkscape:cy="190.16122" + inkscape:document-units="px" + inkscape:current-layer="layer1" + showgrid="false" + units="mm" + inkscape:window-width="1366" + inkscape:window-height="705" + inkscape:window-x="-4" + inkscape:window-y="-4" + inkscape:window-maximized="1" + inkscape:showpageshadow="2" + inkscape:pagecheckerboard="0" + inkscape:deskcolor="#d1d1d1" /> + <metadata + id="metadata8380"> + <rdf:RDF> + <cc:Work + rdf:about=""> + <dc:format>image/svg+xml</dc:format> + <dc:type + rdf:resource="http://purl.org/dc/dcmitype/StillImage" /> + </cc:Work> + </rdf:RDF> + </metadata> + <g + inkscape:label="Layer 1" + inkscape:groupmode="layer" + id="layer1"> + <rect + style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.835535;stroke-linejoin:round" + id="rect1-3" + width="186.48474" + height="69.477829" + x="124.60868" + y="121.6491" + transform="matrix(1,0,-0.57453916,0.8184771,0,0)" /> + </g> +</svg> diff --git a/icons/layers.png b/icons/layers.png Binary files differnew file mode 100644 index 00000000..308c9528 --- /dev/null +++ b/icons/layers.png diff --git a/icons/layers.svg b/icons/layers.svg new file mode 100644 index 00000000..fed3b26d --- /dev/null +++ b/icons/layers.svg @@ -0,0 +1,83 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<!-- Created with Inkscape (http://www.inkscape.org/) --> + +<svg + width="256" + height="256" + viewBox="0 0 256 256" + id="svg8375" + version="1.1" + inkscape:version="1.3 (1:1.3+202307231459+0e150ed6c4)" + sodipodi:docname="layers.svg" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns="http://www.w3.org/2000/svg" + xmlns:svg="http://www.w3.org/2000/svg" + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:cc="http://creativecommons.org/ns#" + xmlns:dc="http://purl.org/dc/elements/1.1/"> + <defs + id="defs8377" /> + <sodipodi:namedview + id="base" + pagecolor="#ffffff" + bordercolor="#666666" + borderopacity="1.0" + inkscape:pageopacity="0.0" + inkscape:pageshadow="2" + inkscape:zoom="1.979899" + inkscape:cx="94.954339" + inkscape:cy="190.16122" + inkscape:document-units="px" + inkscape:current-layer="layer1" + showgrid="false" + units="mm" + inkscape:window-width="1366" + inkscape:window-height="705" + inkscape:window-x="-4" + inkscape:window-y="-4" + inkscape:window-maximized="1" + inkscape:showpageshadow="2" + inkscape:pagecheckerboard="0" + inkscape:deskcolor="#d1d1d1" /> + <metadata + id="metadata8380"> + <rdf:RDF> + <cc:Work + rdf:about=""> + <dc:format>image/svg+xml</dc:format> + <dc:type + rdf:resource="http://purl.org/dc/dcmitype/StillImage" /> + </cc:Work> + </rdf:RDF> + </metadata> + <g + inkscape:label="Layer 1" + inkscape:groupmode="layer" + id="layer1"> + <rect + style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.835535;stroke-linejoin:round" + id="rect1" + width="186.48474" + height="69.477829" + x="69.85569" + y="26.350163" + transform="matrix(1,0,-0.57453916,0.8184771,0,0)" /> + <rect + style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.835535;stroke-linejoin:round" + id="rect1-3" + width="186.48474" + height="69.477829" + x="124.60868" + y="121.6491" + transform="matrix(1,0,-0.57453916,0.8184771,0,0)" /> + <rect + style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.835535;stroke-linejoin:round" + id="rect1-3-7" + width="186.48474" + height="69.477829" + x="179.36162" + y="216.94801" + transform="matrix(1,0,-0.57453916,0.8184771,0,0)" /> + </g> +</svg> diff --git a/icons/visible.png b/icons/visible.png Binary files differnew file mode 100644 index 00000000..11e41657 --- /dev/null +++ b/icons/visible.png diff --git a/icons/visible.svg b/icons/visible.svg new file mode 100644 index 00000000..bd76152c --- /dev/null +++ b/icons/visible.svg @@ -0,0 +1,66 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<!-- Created with Inkscape (http://www.inkscape.org/) --> + +<svg + width="256" + height="256" + viewBox="0 0 256 256" + id="svg8375" + version="1.1" + inkscape:version="1.3 (1:1.3+202307231459+0e150ed6c4)" + sodipodi:docname="visible.svg" + inkscape:export-filename="visible.png" + inkscape:export-xdpi="96" + inkscape:export-ydpi="96" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns="http://www.w3.org/2000/svg" + xmlns:svg="http://www.w3.org/2000/svg" + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:cc="http://creativecommons.org/ns#" + xmlns:dc="http://purl.org/dc/elements/1.1/"> + <defs + id="defs8377" /> + <sodipodi:namedview + id="base" + pagecolor="#ffffff" + bordercolor="#666666" + borderopacity="1.0" + inkscape:pageopacity="0.0" + inkscape:pageshadow="2" + inkscape:zoom="0.8569637" + inkscape:cx="-80.51683" + inkscape:cy="191.95679" + inkscape:document-units="px" + inkscape:current-layer="layer1" + showgrid="false" + units="mm" + inkscape:window-width="1366" + inkscape:window-height="705" + inkscape:window-x="-4" + inkscape:window-y="-4" + inkscape:window-maximized="1" + inkscape:showpageshadow="2" + inkscape:pagecheckerboard="0" + inkscape:deskcolor="#d1d1d1" /> + <metadata + id="metadata8380"> + <rdf:RDF> + <cc:Work + rdf:about=""> + <dc:format>image/svg+xml</dc:format> + <dc:type + rdf:resource="http://purl.org/dc/dcmitype/StillImage" /> + </cc:Work> + </rdf:RDF> + </metadata> + <g + inkscape:label="Layer 1" + inkscape:groupmode="layer" + id="layer1"> + <path + id="path2-6" + style="vector-effect:none;fill:#000000;fill-rule:evenodd;stroke:none;stroke-width:0.5483;fill-opacity:1" + d="M 151.45266,128 A 23.452663,23.452663 0 0 1 128,151.45266 23.452663,23.452663 0 0 1 104.54734,128 23.452663,23.452663 0 0 1 128,104.54734 23.452663,23.452663 0 0 1 151.45266,128 Z m 22.78843,0 A 46.241085,46.241085 0 0 1 128,174.24109 46.241085,46.241085 0 0 1 81.758915,128 46.241085,46.241085 0 0 1 128,81.758915 46.241085,46.241085 0 0 1 174.24109,128 Z m 77.90272,1e-5 c -72.89616,94.98236 -172.238205,97.57424 -248.2876202,0 76.0494152,-97.574262 175.3914602,-94.982384 248.2876202,0 z" /> + </g> +</svg> diff --git a/lib/elements/element.py b/lib/elements/element.py index 0846b7ab..ea2d5d6b 100644 --- a/lib/elements/element.py +++ b/lib/elements/element.py @@ -2,6 +2,7 @@ # # Copyright (c) 2010 Authors # Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details. +import json import sys from contextlib import contextmanager from copy import deepcopy @@ -25,7 +26,7 @@ from ..stitch_plan.lock_stitch import (LOCK_DEFAULTS, AbsoluteLock, CustomLock, 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 import DotDict, Point, cache from ..utils.cache import (CacheKeyGenerator, get_stitch_plan_cache, is_cache_disabled) @@ -159,6 +160,20 @@ class EmbroideryElement(object): return [int(default)] return params + def get_json_param(self, param, default=None): + json_value = self.get_param(param, None) + try: + return json.loads(json_value, object_hook=DotDict) + except (json.JSONDecodeError, TypeError): + if default is None: + return DotDict() + else: + return DotDict(default) + + def set_json_param(self, param, value): + json_value = json.dumps(value) + self.set_param(param, json_value) + def set_param(self, name, value): # Sets a param on the node backing this element. Used by params dialog. # After calling, this element is invalid due to caching and must be re-created to use the new value. diff --git a/lib/elements/utils.py b/lib/elements/utils.py index 09d24935..a3de62e9 100644 --- a/lib/elements/utils.py +++ b/lib/elements/utils.py @@ -37,18 +37,25 @@ def node_to_elements(node, clone_to_element=False) -> List[EmbroideryElement]: return [MarkerObject(node)] elif node.tag in EMBROIDERABLE_TAGS or is_clone(node): - element = EmbroideryElement(node) - elements = [] - if element.get_style("fill", "black") and not element.get_style('fill-opacity', 1) == "0": - elements.append(FillStitch(node)) - if element.get_style("stroke"): - if element.get_boolean_param("satin_column") and len(element.path) > 1: - elements.append(SatinColumn(node)) - elif not is_command(element.node): - elements.append(Stroke(node)) - if element.get_boolean_param("stroke_first", False): - elements.reverse() + + from ..sew_stack import SewStack + sew_stack = SewStack(node) + + if not sew_stack.sew_stack_only: + element = EmbroideryElement(node) + if element.get_style("fill", "black") and not element.get_style('fill-opacity', 1) == "0": + elements.append(FillStitch(node)) + if element.get_style("stroke"): + if element.get_boolean_param("satin_column") and len(element.path) > 1: + elements.append(SatinColumn(node)) + elif not is_command(element.node): + elements.append(Stroke(node)) + if element.get_boolean_param("stroke_first", False): + elements.reverse() + + elements.append(sew_stack) + return elements elif node.tag == SVG_IMAGE_TAG: diff --git a/lib/extensions/__init__.py b/lib/extensions/__init__.py index 353d3894..1ca475b9 100644 --- a/lib/extensions/__init__.py +++ b/lib/extensions/__init__.py @@ -61,6 +61,7 @@ from .select_elements import SelectElements from .selection_to_anchor_line import SelectionToAnchorLine from .selection_to_guide_line import SelectionToGuideLine from .selection_to_pattern import SelectionToPattern +from .sew_stack_editor import SewStackEditor from .simulator import Simulator from .stitch_plan_preview import StitchPlanPreview from .stitch_plan_preview_undo import StitchPlanPreviewUndo @@ -132,6 +133,7 @@ __all__ = extensions = [About, SelectionToAnchorLine, SelectionToGuideLine, SelectionToPattern, + SewStackEditor, Simulator, StitchPlanPreview, StitchPlanPreviewUndo, diff --git a/lib/extensions/sew_stack_editor.py b/lib/extensions/sew_stack_editor.py new file mode 100755 index 00000000..6c7cb543 --- /dev/null +++ b/lib/extensions/sew_stack_editor.py @@ -0,0 +1,564 @@ +# Authors: see git history +# +# Copyright (c) 2010 Authors +# Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details. +import sys + +import wx +from wx.lib.agw import ultimatelistctrl as ulc +from wx.lib.checkbox import GenCheckBox +from wx.lib.splitter import MultiSplitterWindow + +from .base import InkstitchExtension +from ..debug.debug import debug +from ..exceptions import InkstitchException, format_uncaught_exception +from ..gui import PreviewRenderer, WarningPanel, confirm_dialog +from ..gui.simulator import SplitSimulatorWindow +from ..i18n import _ +from ..sew_stack import SewStack +from ..sew_stack.stitch_layers import RunningStitchLayer +from ..stitch_plan import stitch_groups_to_stitch_plan +from ..utils.icons import load_icon +from ..utils.svg_data import get_pagecolor +from ..utils.threading import ExitThread, check_stop_flag + + +# -*- coding: UTF-8 -*- + + +class VisibleCheckBox(GenCheckBox): + def __init__(self, parent, *args, **kwargs): + render = wx.RendererNative.Get() + width, height = render.GetCheckBoxSize(parent) + + self.checked_bitmap = load_icon("visible", width=width, height=height) + self.unchecked_bitmap = load_icon("invisible", width=width, height=height) + + super().__init__(parent, *args, **kwargs) + + def GetBitmap(self): + if self.IsChecked(): + return self.checked_bitmap + else: + return self.unchecked_bitmap + + +class SewStackPanel(wx.Panel): + """An editing UI to modify the sew stacks on multiple objects. + + Each object has a Sew Stack, and every Sew Stack has one or more Stitch + Layers in it. This GUI will present the layers to the user and let them + edit each layer's properties. The user can also reorder the layers and add + and remove layers. + + When editing multiple objects' Sew Stacks at once, all Sew Stacks must be + compatible. That means each one must have the same types of layers in the + same order. + + When the user changes a property in a layer, the property is bolded to + indicate that it has changed. When saving changes, only properties that the + user changed are saved into the corresponding layers in all objects' Sew + Stacks. Ditto if layers are added, removed, or reordered: the layers will be + added, removed, or reordered in all objects' Sew Stacks. + """ + + def __init__(self, parent, sew_stacks=None, metadata=None, background_color='white', simulator=None): + super().__init__(parent, wx.ID_ANY) + + self.metadata = metadata + self.background_color = background_color + self.simulator = simulator + self.parent = parent + + self.sew_stacks = sew_stacks + self.layer_editors = self.get_layer_editors() + + self.splitter = MultiSplitterWindow(self, wx.ID_ANY, style=wx.SP_LIVE_UPDATE) + self.splitter.SetOrientation(wx.VERTICAL) + self.splitter.SetMinimumPaneSize(50) + self.splitter.Bind(wx.EVT_SPLITTER_SASH_POS_CHANGING, self.on_splitter_sash_pos_changing) + + self.layer_config_panel = None + self.layer_list_wrapper = wx.Panel(self.splitter, wx.ID_ANY) + layer_list_sizer = wx.BoxSizer(wx.VERTICAL) + self.layer_list = ulc.UltimateListCtrl( + parent=self.layer_list_wrapper, + size=(300, 200), + agwStyle=ulc.ULC_REPORT | ulc.ULC_SINGLE_SEL | ulc.ULC_VRULES | ulc.ULC_HAS_VARIABLE_ROW_HEIGHT + ) + self._checkbox_to_row = {} + self.update_layer_list() + layer_list_sizer.Add(self.layer_list, 1, wx.BOTTOM | wx.EXPAND, 2) + layer_list_sizer.Add(self.create_layer_buttons(), 0, wx.EXPAND | wx.BOTTOM, 10) + self.sew_stack_only_checkbox = wx.CheckBox(self.layer_list_wrapper, label=_("Sew stack only"), style=wx.CHK_3STATE) + self.sew_stack_only_checkbox.Set3StateValue(self.get_sew_stack_only_checkbox_value()) + self.sew_stack_only_checkbox.SetToolTip(_("Only sew the Sew Stack layers, and ignore settings from Params")) + layer_list_sizer.Add(self.sew_stack_only_checkbox, 0, wx.EXPAND | wx.BOTTOM, 10) + self.layer_list_wrapper.SetSizer(layer_list_sizer) + self.splitter.AppendWindow(self.layer_list_wrapper, 300) + + self.splitter.SizeWindows() + + self._dragging_row = None + self._editing_row = None + self._name_editor = None + + self.layer_list.Bind(ulc.EVT_LIST_BEGIN_DRAG, self.on_begin_drag) + self.layer_list.Bind(ulc.EVT_LIST_END_DRAG, self.on_end_drag) + self.layer_list.Bind(ulc.EVT_LIST_ITEM_ACTIVATED, self.on_double_click) + self.layer_list.Bind(ulc.EVT_LIST_ITEM_SELECTED, self.on_layer_selection_changed) + # self.layer_list.Bind(ulc.EVT_LIST_ITEM_DESELECTED, self.on_layer_selection_changed) + self.Bind(wx.EVT_CHECKBOX, self.on_checkbox) + + self.preview_renderer = PreviewRenderer(self.render_stitch_plan, self.on_stitch_plan_rendered) + + self.warning_panel = WarningPanel(self) + self.warning_panel.Hide() + + self.cancel_button = wx.Button(self, wx.ID_ANY, _("Cancel")) + self.cancel_button.Bind(wx.EVT_BUTTON, self.on_cancel) + self.Bind(wx.EVT_CLOSE, self.on_close) + + self.apply_button = wx.Button(self, wx.ID_ANY, _("Apply and Quit")) + self.apply_button.Bind(wx.EVT_BUTTON, self.apply) + + self.__do_layout() + self.update_preview() + + def get_sew_stack_only_checkbox_value(self): + values = [sew_stack.sew_stack_only for sew_stack in self.sew_stacks] + if all(values): + return wx.CHK_CHECKED + elif all(value is False for value in values): + return wx.CHK_UNCHECKED + else: + return wx.CHK_UNDETERMINED + + def get_layer_types(self): + sew_stacks_layer_types = [] + + for sew_stack in self.sew_stacks: + sew_stacks_layer_types.append(tuple(type(layer) for layer in sew_stack.layers)) + + if len(set(sew_stacks_layer_types)) > 1: + raise ValueError("SewStackPanel: internal error: sew stacks do not all have the same layer types") + + return sew_stacks_layer_types[0] + + def get_layer_editors(self): + layer_types = self.get_layer_types() + editors = [] + for i, layer_type in enumerate(layer_types): + layers = [sew_stack.layers[i] for sew_stack in self.sew_stacks] + editors.append(layer_type.editor_class(layers, change_callback=self.on_property_changed)) + + return editors + + def __do_layout(self): + main_sizer = wx.BoxSizer(wx.VERTICAL) + main_sizer.Add(self.warning_panel, 0, flag=wx.ALL, border=10) + main_sizer.Add(self.splitter, 1, wx.LEFT | wx.TOP | wx.RIGHT | wx.EXPAND, 10) + buttons_sizer = wx.BoxSizer(wx.HORIZONTAL) + buttons_sizer.Add(self.cancel_button, 0, wx.RIGHT, 5) + buttons_sizer.Add(self.apply_button, 0, wx.BOTTOM, 5) + main_sizer.Add(buttons_sizer, 0, wx.ALIGN_RIGHT | wx.TOP | wx.LEFT | wx.RIGHT, 10) + self.SetSizer(main_sizer) + main_sizer.Fit(self) + self.Layout() + + def create_layer_buttons(self): + self.layer_buttons_sizer = wx.BoxSizer(wx.HORIZONTAL) + + self.add_layer_button = wx.Button(self.layer_list_wrapper, wx.ID_ANY, style=wx.BU_EXACTFIT) + self.add_layer_button.SetBitmapLabel(wx.ArtProvider.GetBitmap(wx.ART_PLUS, wx.ART_MENU)) + self.layer_buttons_sizer.Add(self.add_layer_button, 0, 0, 0) + self.add_layer_button.Bind(wx.EVT_BUTTON, self.on_add_layer_button) + + self.delete_layer_button = wx.Button(self.layer_list_wrapper, wx.ID_ANY, style=wx.BU_EXACTFIT) + self.delete_layer_button.SetBitmapLabel(wx.ArtProvider.GetBitmap(wx.ART_DELETE, wx.ART_MENU)) + self.layer_buttons_sizer.Add(self.delete_layer_button, 0, wx.LEFT, 5) + self.delete_layer_button.Bind(wx.EVT_BUTTON, self.on_delete_layer_button) + + self.move_layer_up_button = wx.Button(self.layer_list_wrapper, wx.ID_ANY, style=wx.BU_EXACTFIT) + self.move_layer_up_button.SetBitmapLabel(wx.ArtProvider.GetBitmap(wx.ART_GO_UP, wx.ART_MENU)) + self.layer_buttons_sizer.Add(self.move_layer_up_button, 0, wx.LEFT, 5) + self.move_layer_up_button.Bind(wx.EVT_BUTTON, self.on_move_layer_up_button) + + self.move_layer_down_button = wx.Button(self.layer_list_wrapper, wx.ID_ANY, style=wx.BU_EXACTFIT) + self.move_layer_down_button.SetBitmapLabel(wx.ArtProvider.GetBitmap(wx.ART_GO_DOWN, wx.ART_MENU)) + self.layer_buttons_sizer.Add(self.move_layer_down_button, 0, wx.LEFT, 5) + self.move_layer_down_button.Bind(wx.EVT_BUTTON, self.on_move_layer_down_button) + + self.layer_buttons_sizer.Add(0, 0, 1, wx.EXPAND) + + self.single_layer_preview_button = wx.BitmapToggleButton(self.layer_list_wrapper, wx.ID_ANY, style=wx.BU_EXACTFIT | wx.BU_NOTEXT) + self.single_layer_preview_button.SetToolTip(_("Preview selected layer")) + self.single_layer_preview_button.SetBitmap(load_icon('layer', self)) + self.single_layer_preview_button.Bind(wx.EVT_TOGGLEBUTTON, self.on_single_layer_preview_button) + self.layer_buttons_sizer.Add(self.single_layer_preview_button, 0, wx.LEFT, 0) + + self.all_layers_preview_button = wx.BitmapToggleButton(self.layer_list_wrapper, wx.ID_ANY, style=wx.BU_EXACTFIT | wx.BU_NOTEXT) + self.all_layers_preview_button.SetToolTip(_("Preview all layers")) + self.all_layers_preview_button.SetBitmap(load_icon('layers', self)) + self.all_layers_preview_button.SetValue(True) + self.all_layers_preview_button.Bind(wx.EVT_TOGGLEBUTTON, self.on_all_layers_preview_button) + self.layer_buttons_sizer.Add(self.all_layers_preview_button, 0, wx.LEFT, 1) + + return self.layer_buttons_sizer + + def update_layer_list(self): + self.layer_list.Freeze() + + if self.layer_list.GetColumnCount() == 3: + # Save and restore column widths to work around an UltimateListCtrl bug. + # If we don't do this, ULC_AUTOSIZE_FILL stops working and the layer name + # column shrinks. + column_sizes = [self.layer_list.GetColumn(i).GetWidth() for i in range(self.layer_list.GetColumnCount())] + else: + column_sizes = (24, wx.LIST_AUTOSIZE, ulc.ULC_AUTOSIZE_FILL) + + self.layer_list.ClearAll() + + self.layer_list.InsertColumn(0, "", format=ulc.ULC_FORMAT_RIGHT) + self.layer_list.InsertColumn(1, _("Type"), format=ulc.ULC_FORMAT_CENTER) + self.layer_list.InsertColumn(2, _("Layer Name")) + + self._checkbox_to_row.clear() + + for i in range(len(self.layer_editors)): + is_checked = any(sew_stack.layers[i].enabled for sew_stack in self.sew_stacks) + item = ulc.UltimateListItem() + item.SetMask(ulc.ULC_MASK_WINDOW | ulc.ULC_MASK_CHECK | ulc.ULC_MASK_FORMAT) + checkbox = VisibleCheckBox(self.layer_list) + self._checkbox_to_row[checkbox] = i + checkbox.SetValue(is_checked) + item.SetWindow(checkbox) + item.SetAlign(ulc.ULC_FORMAT_RIGHT) + item.Check(is_checked) + item.SetId(i) + item.SetColumn(0) + self.layer_list.InsertItem(item) + + self.layer_list.SetStringItem(i, 1, self.sew_stacks[0].layers[i].layer_type_name) + self.layer_list.SetStringItem(i, 2, self.sew_stacks[0].layers[i].name) + + # insert one more row so that the UltimateListCtrl allows dragging items to the very + # end of the list + self.layer_list.InsertStringItem(len(self.layer_editors), "") + self.layer_list.EnableItem(len(self.layer_editors), enable=False) + + for i, size in enumerate(column_sizes): + self.layer_list.SetColumnWidth(i, size) + + if self.layer_config_panel is not None: + self.layer_config_panel.Hide() + self.splitter.DetachWindow(self.layer_config_panel) + self.layer_config_panel = None + + self.layer_list.Thaw() + + def on_begin_drag(self, event): + self.stop_editing() + self._dragging_row = event.Index + + def on_end_drag(self, event): + self.stop_editing() + if self._dragging_row is not None: + destination = event.Index + if destination > self._dragging_row: + destination -= 1 + self.move_layer(self._dragging_row, destination) + self._dragging_row = None + self.update_preview() + + def move_layer(self, from_index, to_index): + debug.log(f"move_layer({from_index=}, {to_index=})") + if 0 <= from_index < len(self.layer_editors): + if 0 <= to_index < len(self.layer_editors): + debug.log(f"before move: {self.layer_editors}") + layer_editor = self.layer_editors.pop(from_index) + self.layer_editors.insert(to_index, layer_editor) + + for sew_stack in self.sew_stacks: + sew_stack.move_layer(from_index, to_index) + + debug.log(f"after move: {self.layer_editors}") + + self.update_layer_list() + self.update_preview() + return True + return False + + def on_double_click(self, event): + debug.log(f"double-click {event.Index}") + + if event.GetColumn() != 2: + event.Veto() + return + + self.stop_editing() + + self._editing_row = event.Index + self._name_editor = wx.TextCtrl(self.layer_list, wx.ID_ANY, value=self.sew_stacks[0].layers[event.Index].name, + style=wx.TE_PROCESS_ENTER | wx.TE_PROCESS_TAB | wx.TE_LEFT) + self._name_editor.Bind(wx.EVT_TEXT_ENTER, self.on_name_editor_end) + self._name_editor.Bind(wx.EVT_KEY_UP, self.on_name_editor_key_up) + self.layer_list.SetItemWindow(event.Index, 2, self._name_editor, expand=True) + + def on_name_editor_key_up(self, event): + keyCode = event.GetKeyCode() + if keyCode == wx.WXK_ESCAPE: + self.stop_editing(cancel=True) + else: + event.Skip() + + def on_name_editor_end(self, event): + self.stop_editing() + + def on_layer_selection_changed(self, event): + self.stop_editing() + debug.log(f"layer selection changed: {event.Index} {self.layer_list.GetFirstSelected()}") + if -1 < event.Index < len(self.layer_editors): + selected_layer = self.layer_editors[event.Index] + new_layer_config_panel = selected_layer.get_panel(parent=self.splitter) + + if self.layer_config_panel is not None: + self.layer_config_panel.Hide() + self.splitter.ReplaceWindow(self.layer_config_panel, new_layer_config_panel) + else: + self.splitter.AppendWindow(new_layer_config_panel) + self.layer_config_panel = new_layer_config_panel + self.splitter.SizeWindows() + + self.Layout() + + if self.single_layer_preview_button.GetValue(): + self.update_preview() + + def on_checkbox(self, event): + checkbox = event.GetEventObject() + if checkbox is self.sew_stack_only_checkbox: + for sew_stack in self.sew_stacks: + sew_stack.sew_stack_only = event.IsChecked() + else: + row = self._checkbox_to_row.get(checkbox) + if row is not None: + for sew_stack in self.sew_stacks: + sew_stack.layers[row].enable(event.IsChecked()) + self.update_preview() + + def on_splitter_sash_pos_changing(self, event): + # MultiSplitterWindow doesn't enforce the minimum pane size on the lower + # pane for some reason, so we'll have to. Setting the sash position on + # the event overrides whatever the user is trying to do. + size = self.splitter.GetSize() + sash_position = event.GetSashPosition() + sash_position = min(sash_position, size.y - 50) + event.SetSashPosition(sash_position) + + def on_add_layer_button(self, event): + # TODO: pop up a dialog to select layer type. Also support pre-set + # groups of layers (for example contour underlay, zig-zag underlay, and + # satin) and saved "favorite" layers. + new_layers = [] + for sew_stack in self.sew_stacks: + new_layers.append(sew_stack.append_layer(RunningStitchLayer)) + self.layer_editors.append(RunningStitchLayer.editor_class(new_layers, change_callback=self.on_property_changed)) + self.update_layer_list() + self.layer_list.Select(len(self.layer_editors) - 1) + self.update_preview() + + def on_delete_layer_button(self, event): + index = self.layer_list.GetFirstSelected() + if 0 <= index < len(self.layer_editors): + if confirm_dialog(self, _("Are you sure you want to delete this layer?") + "\n\n" + self.sew_stacks[0].layers[index].name): + del self.layer_editors[index] + + for sew_stack in self.sew_stacks: + sew_stack.delete_layer(index) + + self.update_layer_list() + self.update_preview() + + def on_move_layer_up_button(self, event): + index = self.layer_list.GetFirstSelected() + destination = index - 1 + if self.move_layer(index, destination): + self.layer_list.Select(destination) + + def on_move_layer_down_button(self, event): + index = self.layer_list.GetFirstSelected() + destination = index + 1 + if self.move_layer(index, destination): + self.layer_list.Select(destination) + + def stop_editing(self, cancel=False): + if self._name_editor is None or self._editing_row is None: + return + + if not cancel: + new_name = self._name_editor.GetValue() + for sew_stack in self.sew_stacks: + sew_stack.layers[self._editing_row].name = new_name + + self.layer_list.DeleteItemWindow(self._editing_row, 2) + item = self.layer_list.GetItem(self._editing_row, 2) + item.SetMask(ulc.ULC_MASK_TEXT) + item.SetText(new_name) + self.layer_list.SetItem(item) + + self._name_editor.Hide() + self._name_editor.Destroy() + self._name_editor = None + self._editing_row = None + + def on_property_changed(self, property_name, property_value): + self.update_preview() + + def on_single_layer_preview_button(self, event): + if not event.GetInt(): + # don't allow them to unselect this button, they're supposed to select the other one + self.single_layer_preview_button.SetValue(True) + return + + self.all_layers_preview_button.SetValue(False) + + # ensure a layer is selected so that it's clear which one is being previewed + if self.layer_list.GetFirstSelected() == -1: + self.layer_list.Select(0) + + self.update_preview() + + def on_all_layers_preview_button(self, event): + if not event.GetInt(): + # don't allow them to unselect this button, they're supposed to select the other one + self.all_layers_preview_button.SetValue(True) + return + + self.single_layer_preview_button.SetValue(False) + self.update_preview() + + def update_preview(self): + self.simulator.stop() + self.simulator.clear() + self.preview_renderer.update() + + def render_stitch_plan(self): + try: + if not self.layer_editors: + return + + wx.CallAfter(self._hide_warning) + self._update_layers() + + stitch_groups = [] + for sew_stack in self.sew_stacks: + if self.single_layer_preview_button.GetValue(): + layer = sew_stack.layers[self.layer_list.GetFirstSelected()] + stitch_groups.extend(layer.embroider(None)) + else: + stitch_groups.extend(sew_stack.embroider(stitch_groups[-1] if stitch_groups else None)) + + check_stop_flag() + + 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'] + ) + 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()) + + def on_stitch_plan_rendered(self, stitch_plan): + try: + self.simulator.stop() + self.simulator.load(stitch_plan) + self.simulator.go() + except RuntimeError: + # this can happen when they close the window at a bad time + pass + + 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_layers(self): + for sew_stack in self.sew_stacks: + for layer_num in range(len(self.layer_editors)): + layer = sew_stack.layers[layer_num] + self.layer_editors[layer_num].update_layer(layer) + + def _apply(self): + self._update_layers() + for sew_stack in self.sew_stacks: + sew_stack.save() + + def apply(self, event): + self._apply() + self.close() + + def confirm_close(self): + self.simulator.stop() + if any(layer_editor.has_changes() for layer_editor in self.layer_editors): + return confirm_dialog(self, _("Are you sure you want to quit without saving changes?")) + else: + # They made no changes, so it's safe to close. + return True + + def close(self): + wx.CallAfter(self.GetTopLevelParent().close) + + def on_close(self, event): + if self.confirm_close(): + self.close() + else: + event.Veto() + + def on_cancel(self, event): + if self.confirm_close(): + self.close() + + +class SewStackEditor(InkstitchExtension): + def __init__(self, *args, **kwargs): + self.cancelled = False + InkstitchExtension.__init__(self, *args, **kwargs) + + def get_sew_stacks(self): + nodes = self.get_nodes() + if nodes: + return [SewStack(node) for node in nodes] + else: + self.no_elements_error() + + def effect(self): + app = wx.App() + metadata = self.get_inkstitch_metadata() + background_color = get_pagecolor(self.svg.namedview) + frame = SplitSimulatorWindow( + title=_("Embroidery Params"), + panel_class=SewStackPanel, + sew_stacks=self.get_sew_stacks(), + metadata=metadata, + background_color=background_color, + target_duration=5 + ) + + 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/simulator/control_panel.py b/lib/gui/simulator/control_panel.py index 9226d5de..99d1f92b 100644 --- a/lib/gui/simulator/control_panel.py +++ b/lib/gui/simulator/control_panel.py @@ -208,7 +208,11 @@ class ControlPanel(wx.Panel): self.set_speed(global_settings['simulator_speed']) return if self.target_duration: - self.set_speed(int(self.num_stitches / float(self.target_duration))) + stitches_per_second = round(self.num_stitches / float(self.target_duration)) + if stitches_per_second < 10: + # otherwise it just looks weirdly slow + stitches_per_second = 10 + self.set_speed(stitches_per_second) else: self.set_speed(self.target_stitches_per_second) diff --git a/lib/gui/simulator/drawing_panel.py b/lib/gui/simulator/drawing_panel.py index ced11f00..1587ecfa 100644 --- a/lib/gui/simulator/drawing_panel.py +++ b/lib/gui/simulator/drawing_panel.py @@ -52,8 +52,9 @@ class DrawingPanel(wx.Panel): self.SetDoubleBuffered(True) self.animating = False + self.timer = wx.Timer(self) + self.last_frame_start = 0 self.target_frame_period = 1.0 / self.TARGET_FPS - self.last_frame_duration = 0 self.direction = 1 self.current_stitch = 0 self.black_pen = wx.Pen((128, 128, 128)) @@ -72,6 +73,7 @@ class DrawingPanel(wx.Panel): self.Bind(wx.EVT_LEFT_DOWN, self.on_left_mouse_button_down) self.Bind(wx.EVT_MOUSEWHEEL, self.on_mouse_wheel) self.Bind(wx.EVT_SIZE, self.on_resize) + self.Bind(wx.EVT_TIMER, self.animate) # wait for layouts so that panel size is set if self.stitch_plan: @@ -99,20 +101,30 @@ class DrawingPanel(wx.Panel): elif self.direction == 1 and self.current_stitch < self.num_stitches: self.go() - def animate(self): + def animate(self, event=None): if not self.animating: return - frame_time = max(self.target_frame_period, self.last_frame_duration) - - # No sense in rendering more frames per second than our desired stitches - # per second. - frame_time = max(frame_time, 1.0 / self.speed) + # Each frame, we need to advance forward some number of stitches to + # match the speed setting. The tricky thing is that with bigger + # designs, it may take a long time to render a frame. That might + # mean that we'll fall behind. Even if we set our Timer to 30 FPS, + # we may only actually manage to render 20 FPS or fewer, and the + # duration of each frame may vary. + # + # To deal with that, we'll figure out how many stitches to advance + # based on how long it took to render the last frame. We'll always + # be behind by one frame, but it should work out fine. - stitch_increment = int(self.speed * frame_time) + now = time.time() + if self.last_frame_start: + frame_time = now - self.last_frame_start + else: + frame_time = self.target_frame_period + self.last_frame_start = now + stitch_increment = self.speed * frame_time self.set_current_stitch(self.current_stitch + self.direction * stitch_increment) - wx.CallLater(int(1000 * frame_time), self.animate) def OnPaint(self, e): dc = wx.PaintDC(self) @@ -167,7 +179,6 @@ class DrawingPanel(wx.Panel): stitch = 0 last_stitch = None - start = time.time() for pen, stitches, jumps in zip(self.pens, self.stitch_blocks, self.jumps): canvas.SetPen(pen) if stitch + len(stitches) < self.current_stitch: @@ -177,13 +188,12 @@ class DrawingPanel(wx.Panel): self.draw_needle_penetration_points(canvas, pen, stitches) last_stitch = stitches[-1] else: - stitches = stitches[:self.current_stitch - stitch] + stitches = stitches[:int(self.current_stitch) - stitch] if len(stitches) > 1: self.draw_stitch_lines(canvas, pen, stitches, jumps) self.draw_needle_penetration_points(canvas, pen, stitches) last_stitch = stitches[-1] break - self.last_frame_duration = time.time() - start if last_stitch: self.draw_crosshair(last_stitch[0], last_stitch[1], canvas, transform) @@ -260,7 +270,6 @@ class DrawingPanel(wx.Panel): def load(self, stitch_plan): self.current_stitch = 1 self.direction = 1 - self.last_frame_duration = 0 self.minx, self.miny, self.maxx, self.maxy = stitch_plan.bounding_box self.width = self.maxx - self.minx self.height = self.maxy - self.miny @@ -326,6 +335,7 @@ class DrawingPanel(wx.Panel): def stop(self): self.animating = False + self.timer.Stop() self.control_panel.on_stop() def go(self): @@ -335,6 +345,8 @@ class DrawingPanel(wx.Panel): if not self.animating: try: self.animating = True + self.last_frame_start = 0 + self.timer.Start(int(self.target_frame_period * 1000)) self.animate() self.control_panel.on_start() except RuntimeError: @@ -412,8 +424,8 @@ class DrawingPanel(wx.Panel): def set_current_stitch(self, stitch): self.current_stitch = stitch self.clamp_current_stitch() - command = self.commands[self.current_stitch] - self.control_panel.on_current_stitch(self.current_stitch, command) + command = self.commands[int(self.current_stitch)] + self.control_panel.on_current_stitch(int(self.current_stitch), command) statusbar = self.GetTopLevelParent().statusbar statusbar.SetStatusText(_("Command: %s") % COMMAND_NAMES[command], 2) self.stop_if_at_end() diff --git a/lib/gui/simulator/split_simulator_window.py b/lib/gui/simulator/split_simulator_window.py index 72fd1143..e4b2803e 100644 --- a/lib/gui/simulator/split_simulator_window.py +++ b/lib/gui/simulator/split_simulator_window.py @@ -67,6 +67,13 @@ class SplitSimulatorWindow(wx.Frame): def cancel(self, event=None): if self.cancel_hook: self.cancel_hook() + try: + if not self.settings_panel.confirm_close(): + event.Veto() + return + except AttributeError: + pass + self.close(None) def close(self, event=None): diff --git a/lib/gui/windows.py b/lib/gui/windows.py new file mode 100644 index 00000000..42c34cc8 --- /dev/null +++ b/lib/gui/windows.py @@ -0,0 +1,41 @@ +import wx + + +class SimpleBox(wx.Panel): + """Draw a box around one window. + + Usage: + + window = SomeWindow(your_window_or_panel, wx.ID_ANY) + box = SimpleBox(your_window_or_panel, window) + some_sizer.Add(box, ...) + + """ + + def __init__(self, parent, window, *args, width=1, radius=2, **kwargs): + super().__init__(parent, wx.ID_ANY, *args, **kwargs) + + window.Reparent(self) + self.window = window + self.sizer = wx.BoxSizer(wx.VERTICAL) + self.sizer.Add(window, 1, wx.EXPAND | wx.ALL, 2) + self.SetSizer(self.sizer) + + self.width = width + self.radius = radius + + self.Bind(wx.EVT_ERASE_BACKGROUND, self.on_erase_background) + + def on_erase_background(self, event): + dc = event.GetDC() + if not dc: + dc = wx.ClientDC(self) + size = self.GetClientSize() + + if wx.SystemSettings().GetAppearance().IsDark(): + dc.SetPen(wx.Pen(wx.Colour(32, 32, 32), width=self.width)) + else: + dc.SetPen(wx.Pen(wx.Colour(128, 128, 128), width=self.width)) + + dc.SetBrush(wx.NullBrush) + dc.DrawRoundedRectangle(0, 0, size.x, size.y, self.radius) diff --git a/lib/sew_stack/__init__.py b/lib/sew_stack/__init__.py new file mode 100644 index 00000000..7f6b2edc --- /dev/null +++ b/lib/sew_stack/__init__.py @@ -0,0 +1,77 @@ +import wx + +from . import stitch_layers +from ..debug.debug import debug +from ..elements import EmbroideryElement +from ..utils import DotDict + + +class SewStack(EmbroideryElement): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.config = DotDict(self.get_json_param('sew_stack', default=dict(layers=list()))) + self.sew_stack_only = self.get_boolean_param('sew_stack_only', False) + + self.layers = [] + for layer_config in self.config.layers: + layer_class = stitch_layers.by_id[layer_config.layer_id] + self.layers.append(layer_class(sew_stack=self, config=layer_config)) + debug.log(f"layer name: {self.layers[-1].name}") + + def move_layer(self, from_index, to_index): + layer = self.layers.pop(from_index) + self.layers.insert(to_index, layer) + + def append_layer(self, layer_class): + new_layer = layer_class(sew_stack=self, config={}) + self.layers.append(new_layer) + return new_layer + + def delete_layer(self, index): + del self.layers[index] + + def save(self): + """Save current configuration of layers to sew_stack SVG attribute""" + self.config.layers = [layer.config for layer in self.layers] + self.set_json_param("sew_stack", self.config) + self.set_param("sew_stack_only", self.sew_stack_only) + + def uses_previous_stitch(self): + if self.config.layers: + return self.config.layers[0].uses_previous_stitch_group + else: + return False + + @property + def first_stitch(self): + # For now, we'll return None, but later on we might let the first + # StitchLayer set the first_stitch. + return None + + def uses_next_element(self): + return True + + def get_cache_key_data(self, previous_stitch, next_element): + return self.config.layers + + def get_default_random_seed(self): + return self.node.get_id() or "" + + def to_stitch_groups(self, previous_stitch_group=None, next_element=None): + stitch_groups = [] + for layer in self.layers: + if layer.enabled: + this_layer_previous_stitch_group = this_layer_next_element = None + + if layer is self.layers[0]: + this_layer_previous_stitch_group = previous_stitch_group + else: + this_layer_previous_stitch_group = stitch_groups[-1] + + if layer is self.layers[-1]: + this_layer_next_element = next_element + + stitch_groups.extend(layer.embroider(this_layer_previous_stitch_group, this_layer_next_element)) + + return stitch_groups diff --git a/lib/sew_stack/stitch_layers/__init__.py b/lib/sew_stack/stitch_layers/__init__.py new file mode 100644 index 00000000..c56506b8 --- /dev/null +++ b/lib/sew_stack/stitch_layers/__init__.py @@ -0,0 +1,7 @@ +from .running_stitch import RunningStitchLayer + +all = [RunningStitchLayer] +by_id = {} + +for layer_class in all: + by_id[layer_class.layer_id] = layer_class diff --git a/lib/sew_stack/stitch_layers/mixins/README.md b/lib/sew_stack/stitch_layers/mixins/README.md new file mode 100644 index 00000000..12b6504b --- /dev/null +++ b/lib/sew_stack/stitch_layers/mixins/README.md @@ -0,0 +1,9 @@ +Functionality for StitchLayers is broken down into separate "mix-in" classes. +This allows us to divide up the implementation so that we don't end up with +one gigantic StitchLayer class. Individual StitchLayer subclasses can include +just the functionality they need. + +Python multiple inheritance is cool, and as long as we include a `super().__init__()` +call in every `__init__()` method we implement, we'll ensure that all mix-in +classes' constructors get called. Skipping implementing the `__init__()` method in a +mix-in class is also allowed. diff --git a/lib/sew_stack/stitch_layers/mixins/path.py b/lib/sew_stack/stitch_layers/mixins/path.py new file mode 100644 index 00000000..88e5419d --- /dev/null +++ b/lib/sew_stack/stitch_layers/mixins/path.py @@ -0,0 +1,27 @@ +from ..stitch_layer_editor import Category, Property +from ....i18n import _ +from ....utils import DotDict, Point + + +class PathPropertiesMixin: + @classmethod + def path_properties(cls): + return Category(_("Path")).children( + Property("reverse_path", _("Reverse path"), type=bool, + help=_("Reverse the path when stitching this layer.")) + ) + + +class PathMixin: + config: DotDict + paths: 'list[list[Point]]' + + def get_paths(self): + paths = self.paths + + if self.config.reverse_path: + paths.reverse() + for path in paths: + path.reverse() + + return paths diff --git a/lib/sew_stack/stitch_layers/mixins/randomization.py b/lib/sew_stack/stitch_layers/mixins/randomization.py new file mode 100644 index 00000000..5414731c --- /dev/null +++ b/lib/sew_stack/stitch_layers/mixins/randomization.py @@ -0,0 +1,121 @@ +import os +from secrets import randbelow +from typing import TYPE_CHECKING + +import wx.propgrid + +from ..stitch_layer_editor import Category, Property +from ....i18n import _ +from ....svg import PIXELS_PER_MM +from ....utils import DotDict, get_resource_dir, prng + +if TYPE_CHECKING: + from ... import SewStack + + +editor_instance = None + + +class RandomSeedEditor(wx.propgrid.PGTextCtrlAndButtonEditor): + def CreateControls(self, property_grid, property, position, size): + if wx.SystemSettings().GetAppearance().IsDark(): + randomize_icon_file = 'randomize_20x20_dark.png' + else: + randomize_icon_file = 'randomize_20x20.png' + randomize_icon = wx.Image(os.path.join(get_resource_dir("icons"), randomize_icon_file)).ConvertToBitmap() + # button = wx.Button(property_grid, wx.ID_ANY, _("Re-roll")) + # button.SetBitmap(randomize_icon) + + window_list = super().CreateControls(property_grid, property, position, size) + button = window_list.GetSecondary() + button.SetBitmap(randomize_icon) + button.SetLabel("") + button.SetToolTip(_("Re-roll")) + return window_list + + +class RandomSeedProperty(wx.propgrid.IntProperty): + def DoGetEditorClass(self): + return wx.propgrid.PropertyGridInterface.GetEditorByName("RandomSeedEditor") + + def OnEvent(self, propgrid, primaryEditor, event): + if event.GetEventType() == wx.wxEVT_COMMAND_BUTTON_CLICKED: + self.SetValueInEvent(randbelow(int(1e8))) + return True + return False + + +class RandomizationPropertiesMixin: + @classmethod + def randomization_properties(cls): + # We have to register the editor class once. We have to save a reference + # to the editor to avoid letting it get garbage collected. + global editor_instance + if editor_instance is None: + editor_instance = RandomSeedEditor() + wx.propgrid.PropertyGrid.DoRegisterEditorClass(editor_instance, "RandomSeedEditor") + + return Category(_("Randomization")).children( + Property("random_seed", _("Random seed"), type=RandomSeedProperty, + # Wow, it's really hard to explain the concept of a random seed to non-programmers... + help=_("The random seed is used when handling randomization settings. " + + "Click the button to choose a new random seed, which will generate random features differently. " + + "Alternatively, you can enter your own random seed. If you reuse a random seed, random features " + + "will look the same.")), + Property("random_stitch_offset", _("Offset stitches"), unit="mm", prefix="±", + help=_("Move stitches randomly by up to this many millimeters in any direction.")), + Property("random_stitch_path_offset", _("Offset stitch path"), unit="mm", prefix="±", + help=_("Move stitches randomly by up to this many millimeters perpendicular to the stitch path.\n\n" + + "If <b>Offset stitches</b> is also specified, then this one is processed first.")), + ) + + +class RandomizationMixin: + config: DotDict + element: "SewStack" + + @classmethod + def randomization_defaults(cls): + return dict( + random_seed=None, + random_stitch_offset=0.0, + random_stitch_path_offset=0.0, + ) + + def get_random_seed(self): + seed = self.config.get('random_seed') + if seed is None: + return self.element.get_default_random_seed() + else: + return seed + + def offset_stitches(self, stitches): + """Randomly move stitches by modifying a list of stitches in-place.""" + + if not stitches: + return + + if 'random_stitch_path_offset' in self.config and self.config.random_stitch_path_offset: + offset = self.config.random_stitch_path_offset * PIXELS_PER_MM + rand_iter = iter(prng.iter_uniform_floats(self.get_random_seed(), "random_stitch_path_offset")) + + last_stitch = stitches[0] + for stitch in stitches[1:]: + try: + direction = (stitch - last_stitch).unit().rotate_left() + except ZeroDivisionError: + continue + + last_stitch = stitch + distance = next(rand_iter) * 2 * offset - offset + result = stitch + direction * distance + stitch.x = result.x + stitch.y = result.y + + if 'random_stitch_offset' in self.config and self.config.random_stitch_offset: + offset = self.config.random_stitch_offset * PIXELS_PER_MM + rand_iter = iter(prng.iter_uniform_floats(self.get_random_seed(), "random_stitch_offset")) + + for stitch in stitches: + stitch.x += next(rand_iter) * 2 * offset - offset + stitch.y += next(rand_iter) * 2 * offset - offset diff --git a/lib/sew_stack/stitch_layers/running_stitch/__init__.py b/lib/sew_stack/stitch_layers/running_stitch/__init__.py new file mode 100644 index 00000000..4eda9659 --- /dev/null +++ b/lib/sew_stack/stitch_layers/running_stitch/__init__.py @@ -0,0 +1 @@ +from .running_stitch_layer import RunningStitchLayer diff --git a/lib/sew_stack/stitch_layers/running_stitch/running_stitch_layer.py b/lib/sew_stack/stitch_layers/running_stitch/running_stitch_layer.py new file mode 100644 index 00000000..0892ed44 --- /dev/null +++ b/lib/sew_stack/stitch_layers/running_stitch/running_stitch_layer.py @@ -0,0 +1,126 @@ +from copy import copy + +from ..mixins.path import PathMixin, PathPropertiesMixin +from ..mixins.randomization import RandomizationPropertiesMixin, RandomizationMixin +from ..stitch_layer import StitchLayer +from ..stitch_layer_editor import Category, Properties, Property +from ..stitch_layer_editor import StitchLayerEditor +from ....i18n import _ +from ....stitch_plan import StitchGroup +from ....stitches.running_stitch import running_stitch +from ....svg import PIXELS_PER_MM + + +class RunningStitchLayerEditor(StitchLayerEditor, RandomizationPropertiesMixin, PathPropertiesMixin): + @classmethod + @property + def properties(cls): + return Properties( + Category(_("Running Stitch"), help=_("Stitch along a path using evenly-spaced stitches.")).children( + Property("stitch_length", _("Stitch length"), + help=_('Length of stitches. Stitches can be shorter according to the stitch tolerance setting.'), + min=0.1, + unit="mm", + ), + Property("tolerance", _("Tolerance"), + help=_('Determines how closely the stitch path matches the SVG path. ' + + 'A lower tolerance means stitches will be closer together and ' + + 'fit the SVG path more precisely. A higher tolerance means ' + + 'some corners may be rounded and fewer stitches are needed.'), + min=0.01, + unit="mm", + ), + Category(_("Repeats")).children( + Property( + "repeats", _("Repeats"), + help=_('Defines how many times to run down and back along the path.'), + type=int, + min=1, + ), + Property( + "repeat_stitches", _("Repeat exact stitches"), + type=bool, + help=_('Should the exact same stitches be repeated in each pass? ' + + 'If not, different randomization settings are applied on each pass.'), + ), + ), + cls.path_properties(), + ), + cls.randomization_properties().children( + Property( + "stitch_length_jitter_percent", _('Stitch length variance'), + help=_('Enter a percentage. Stitch length will vary randomly by up to this percentage.'), + prefix="±", + unit="%", + ), + ), + ) + + +class RunningStitchLayer(StitchLayer, RandomizationMixin, PathMixin): + editor_class = RunningStitchLayerEditor + + @classmethod + @property + def defaults(cls): + defaults = dict( + name=_("Running Stitch"), + type_name=_("Running Stitch"), + stitch_length=2, + tolerance=0.2, + stitch_length_jitter_percent=0, + repeats=1, + repeat_stitches=True, + reverse_path=False, + ) + defaults.update(cls.randomization_defaults()) + + return defaults + + @property + def layer_type_name(self): + return _("Running Stitch") + + def get_num_copies(self): + if self.config.repeat_stitches: + # When repeating stitches, we use multiple copies of one pass of running stitch + return 1 + else: + # Otherwise, we re-run running stitch every time, so that + # randomization is different every pass + return self.config.repeats + + def running_stitch(self, path): + stitches = [] + + for i in range(self.get_num_copies()): + if i % 2 == 0: + this_path = path + else: + this_path = list(reversed(path)) + + stitches.extend(running_stitch( + this_path, + self.config.stitch_length * PIXELS_PER_MM, + self.config.tolerance * PIXELS_PER_MM, + (self.config.stitch_length_jitter_percent > 0), + self.config.stitch_length_jitter_percent, + self.get_random_seed() + )) + + self.offset_stitches(stitches) + + if self.config.repeats > 0 and self.config.repeat_stitches: + repeated_stitches = [] + for i in range(self.config.repeats): + if i % 2 == 0: + repeated_stitches.extend(copy(stitches)) + else: + # reverse every other pass + repeated_stitches.extend(reversed(copy(stitches))) + stitches = repeated_stitches + + return StitchGroup(stitches=stitches, color=self.stroke_color) + + def to_stitch_groups(self, previous_stitch_group, next_element): + return [self.running_stitch(path) for path in self.get_paths()] diff --git a/lib/sew_stack/stitch_layers/stitch_layer.py b/lib/sew_stack/stitch_layers/stitch_layer.py new file mode 100644 index 00000000..4b34373a --- /dev/null +++ b/lib/sew_stack/stitch_layers/stitch_layer.py @@ -0,0 +1,85 @@ +from ...utils import coordinate_list_to_point_list +from ...utils.dotdict import DotDict + + +class StitchLayer: + # must be overridden in child classes and set to a subclass of StitchLayerEditor + editor_class = None + + # not to be overridden in child classes + _defaults = None + + def __init__(self, *args, config, sew_stack=None, change_callback=None, **kwargs): + self.config = DotDict(self.defaults) + self.config.layer_id = self.layer_id + self.config.update(config) + self.element = sew_stack + + super().__init__(*args, **kwargs) + + @classmethod + @property + def defaults(cls): + # Implement this in each child class. Return a dict with default + # values for all properties used in this layer. + raise NotImplementedError(f"{cls.__name__} must implement class property: defaults") + + @classmethod + @property + def layer_id(my_class): + """Get the internal layer ID + + Internal layer ID is not shown to users and is used to identify the + layer class when loading a SewStack. + + Example: + class RunningStitchLayer(StitchLayer): ... + layer_id = RunningStitch + """ + + if my_class.__name__.endswith('Layer'): + return my_class.__name__[:-5] + else: + return my_class.__name__ + + @property + def name(self): + return self.config.get('name', self.default_layer_name) + + @name.setter + def name(self, value): + self.config.name = value + + @property + def default_layer_name(self): + # defaults to the same as the layer type name but can be overridden in a child class + return self.layer_type_name + + @property + def layer_type_name(self): + raise NotImplementedError(f"{self.__class__.__name__} must implement type_name property!") + + @property + def enabled(self): + return self.config.get('enabled', True) + + def enable(self, enabled=True): + self.config.enabled = enabled + + @property + def paths(self): + return [coordinate_list_to_point_list(path) for path in self.element.paths] + + @property + def stroke_color(self): + return self.element.get_style("stroke") + + @property + def fill_color(self): + return self.element.get_style("stroke") + + def to_stitch_groups(self, *args): + raise NotImplementedError(f"{self.__class__.__name__} must implement to_stitch_groups()!") + + def embroider(self, last_stitch_group, next_element): + return self.to_stitch_groups(last_stitch_group, next_element) diff --git a/lib/sew_stack/stitch_layers/stitch_layer_editor.py b/lib/sew_stack/stitch_layers/stitch_layer_editor.py new file mode 100644 index 00000000..eddf7867 --- /dev/null +++ b/lib/sew_stack/stitch_layers/stitch_layer_editor.py @@ -0,0 +1,441 @@ +import re +from typing import Callable + +import wx.html +import wx.propgrid + +from ...debug.debug import debug +from ...gui.windows import SimpleBox +from ...i18n import _ +from ...utils.settings import global_settings + + +class CheckBoxProperty(wx.propgrid.BoolProperty): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.SetAttribute(wx.propgrid.PG_BOOL_USE_CHECKBOX, True) + + +class InkStitchNumericProperty: + """Base class for functionality common to Ink/Stitch-specific numeric PropertyGrid Properties. + + When using this class, be sure to specify it first in the inheritance list. + This is necessary because wxPython's property classes don't properly call + the super-class's constructor. + """ + + # typing hints for PyCharm + value_to_string: Callable + IsValueUnspecified: Callable + GetAttribute: Callable + SetValueInEvent: Callable + + def __init__(self, *args, prefix="", unit="", **kwargs): + super().__init__(*args, **kwargs) + + self.prefix = prefix + self.unit = unit + + def set_unit(self, unit): + self.unit = unit + + def set_prefix(self, prefix): + self.prefix = prefix + + def DoGetEditorClass(self): + return wx.propgrid.PGEditor_SpinCtrl + + def ValueToString(self, value, flags=None): + # Goal: present "0.25 mm" (for example) to the user but still let them + # edit the number using the SpinCtrl. + # + # This code was determined by experimentation. I can't find this + # behavior described anywhere in the docs for wxPython or wxWidgets. + # Note that even though flags is a bitmask, == seems to be correct here. + # Using & results in subtly different behavior that doesn't look right. + + value_str = self.value_to_string(value) + if flags == wx.propgrid.PG_VALUE_IS_CURRENT: + prefix = "" + if self.prefix: + prefix = self.prefix + " " + + return f"{prefix}{value_str} {self.unit}" + else: + return value_str + + def OnEvent(self, pg, window, event): + # If the user starts editing a property that had multiple values, set + # the value quickly before editing starts. Otherwise, clicking the + # spin-buttons causes a low-level C++ exception since the property is + # set to None. + if event.GetEventType() == wx.wxEVT_CHILD_FOCUS: + if self.IsValueUnspecified(): + self.SetValueInEvent(self.GetAttribute('InitialValue')) + return True + + return False + + +class InkStitchFloatProperty(InkStitchNumericProperty, wx.propgrid.FloatProperty): + def __init__(self, *args, prefix="", unit="", **kwargs): + super().__init__(*args, **kwargs) + + # default to a step of 0.1, but can be changed per-property + self.SetAttribute(wx.propgrid.PG_ATTR_SPINCTRL_STEP, 0.1) + + def value_to_string(self, value): + return f"{value:0.2f}" + + +class InkStitchIntProperty(InkStitchNumericProperty, wx.propgrid.IntProperty): + def value_to_string(self, value): + return str(value) + + +class Properties: + """Define PropertyGrid properties and attributes concisely + + Example: + Properties( + Category("cat1",_("First category")).children( + Property("stitch_length", _("Running stitch length"), + help=_("Distance between stitches"), + min=0.1, + max=5, + unit="mm" + ), + Property("repeats", _("Running stitch repeats"), + help=... + ), + Category("subcat1", _("Subcategory")).children( + Property(...), + Property(...) + ) + ) + ) + """ + + def __init__(self, *children): + self._children = children + self.pg = None + + def generate(self, pg, config): + self.pg = pg + root_category = self.pg.GetRoot() + for child in self._children: + child.generate(self.pg, root_category, config) + + return self.pg + + def all_properties(self): + yield from self._iter_properties(self) + + def _iter_properties(self, parent): + for child in parent._children: + if isinstance(child, Category): + yield from self._iter_properties(child) + else: + yield child + + +class Category: + def __init__(self, label, name=wx.propgrid.PG_LABEL, help=None): + self.name = name + self.label = label + self.help = help + self._children = [] + self.category = None + self.pg = None + + def children(self, *args): + self._children.extend(args) + return self + + def generate(self, pg, parent, config): + self.pg = pg + self.category = wx.propgrid.PropertyCategory( + name=self.name, label=self.label) + if self.help: + pg.SetPropertyHelpString(self.category, self.help) + pg.AppendIn(parent, self.category) + + for child in self._children: + child.generate(pg, self.category, config) + + +class Property: + # Adapted from wxPython source + _type_to_property = { + str: wx.propgrid.StringProperty, + int: InkStitchIntProperty, + float: InkStitchFloatProperty, + bool: CheckBoxProperty, + list: wx.propgrid.ArrayStringProperty, + tuple: wx.propgrid.ArrayStringProperty, + wx.Colour: wx.propgrid.ColourProperty + } + + def __init__(self, name, label, help="", min=None, max=None, prefix=None, unit=None, type=None, attributes=None): + self.name = name + self.label = label + self.help = help + self.min = min + self.max = max + self.prefix = prefix + self.unit = unit + self.type = type + self.attributes = attributes + self.property = None + self.pg = None + + def generate(self, pg, parent, config): + self.pg = pg + + property_class = self.get_property_class() + self.property = property_class(name=self.name, label=self.label) + self.property.SetValue(config.get(self.name)) + + pg.AppendIn(parent, self.property) + if self.help: + pg.SetPropertyHelpString(self.property, self.help) + + if self.prefix: + self.property.set_prefix(self.prefix) + if self.unit: + self.property.set_unit(self.unit) + + if self.attributes: + for name, value in self.attributes.items(): + self.property.SetAttribute(name.title(), value) + + # These attributes are provided as convenient shorthands + if self.max is not None: + self.property.SetAttribute(wx.propgrid.PG_ATTR_MAX, self.max) + if self.min is not None: + self.property.SetAttribute(wx.propgrid.PG_ATTR_MIN, self.min) + + def get_property_class(self): + if self.type is not None: + if issubclass(self.type, wx.propgrid.PGProperty): + return self.type + elif self.type in self._type_to_property: + return self._type_to_property[self.type] + else: + raise ValueError(f"property type {repr(self.type)} unknown") + else: + return InkStitchFloatProperty + + +class SewStackPropertyGrid(wx.propgrid.PropertyGrid): + # Without this override, selecting a property will cause its help text to + # be shown in the status bar. We use the status bar for the simulator, + # so we don't want PropertyGrid to overwrite it. + def GetStatusBar(self): + return None + + +class StitchLayerEditor: + def __init__(self, layers, change_callback=None, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.hints = {} + self.initial_values = {} + self.config = self.merge_config(layers) + self.defaults = layers[0].defaults + self.property_grid = None + self.help_box = None + self.property_grid_panel = None + self.change_callback = change_callback + + @classmethod + @property + def properties(cls): + """Define PropertyGrid properties and attributes concisely. + + Must be implemented in each child class. Be sure to include all the + properties listed in the corresponding StitchLayer subclass's defaults + property. + + Return value: + an instance of Properties + + Example: + return Properties(...) + """ + raise NotImplementedError(f"{cls.__name__} must implement properties() with @classmethod and @property decorators!") + + def merge_config(self, layers): + if not layers: + return {} + + if len(set(type(layer) for layer in layers)) > 1: + raise ValueError("StitchLayerEditor: internal error: all layers must be of the same type!") + + config = dict(layers[0].defaults) + for property_name in list(config.keys()): + # Get all values from layers. Don't use set() here because values + # might not be hashable (example: list). + values = [] + unique_values = [] + for layer in layers: + value = layer.config[property_name] + values.append(value) + if value not in unique_values: + unique_values.append(value) + + if len(unique_values) == 1: + config[property_name] = unique_values[0] + elif len(unique_values) > 1: + unique_values.sort(key=lambda item: values.count(item), reverse=True) + del config[property_name] + self.hints[property_name] = "[ " + ", ".join(str(value) for value in unique_values) + " ]" + self.initial_values[property_name] = unique_values[0] + + return config + + def update_layer(self, layer): + """ Apply only properties modified by the user to layer's config """ + if self.property_grid is not None: + for property in self.property_grid.Items: + if isinstance(property, wx.propgrid.PGProperty): + if property.HasFlag(wx.propgrid.PG_PROP_MODIFIED): + name = property.GetName() + value = property.GetValue() + layer.config[name] = value + + def has_changes(self): + if self.property_grid is None: + return False + else: + return any(property.HasFlag(wx.propgrid.PG_PROP_MODIFIED) for property in self.property_grid.Items) + + def get_panel(self, parent): + if self.property_grid_panel is None: + self.layer_editor_panel = wx.Panel(parent, wx.ID_ANY) + + main_sizer = wx.BoxSizer(wx.VERTICAL) + self.splitter = wx.SplitterWindow(self.layer_editor_panel, style=wx.SP_LIVE_UPDATE) + self.splitter.Bind(wx.EVT_SPLITTER_SASH_POS_CHANGED, self.on_sash_position_changed) + + self.property_grid_panel = wx.Panel(self.splitter, wx.ID_ANY) + property_grid_sizer = wx.BoxSizer(wx.VERTICAL) + self.property_grid = SewStackPropertyGrid( + self.property_grid_panel, + wx.ID_ANY, + style=wx.propgrid.PG_SPLITTER_AUTO_CENTER | wx.propgrid.PG_BOLD_MODIFIED | wx.propgrid.PG_DESCRIPTION + ) + self.properties.generate(self.property_grid, self.config) + self.property_grid.ResetColumnSizes(enableAutoResizing=True) + self.property_grid.Bind(wx.propgrid.EVT_PG_CHANGED, self.on_property_changed) + self.property_grid.Bind(wx.propgrid.EVT_PG_SELECTED, self.on_select) + self.property_grid_box = SimpleBox(self.property_grid_panel, self.property_grid) + + buttons_sizer = wx.BoxSizer(wx.HORIZONTAL) + buttons_sizer.Add((0, 0), 1, 0, 0) + + self.undo_button = wx.Button(self.property_grid_panel, wx.ID_ANY, style=wx.BU_EXACTFIT) + self.undo_button.SetBitmapLabel(wx.ArtProvider.GetBitmap(wx.ART_UNDO, wx.ART_OTHER)) + self.undo_button.SetToolTip(_("Undo changes")) + self.undo_button.Disable() + self.undo_button.Bind(wx.EVT_BUTTON, self.on_undo) + buttons_sizer.Add(self.undo_button, 0, 0, 0) + + self.reset_button = wx.Button(self.property_grid_panel, wx.ID_ANY, style=wx.BU_EXACTFIT) + # For some reason wx.ART_REFRESH doesn't exist in wxPython even + # though wxART_REFRESH does exist in wxWidgets. Fortunately we + # can use the string value. + self.reset_button.SetBitmapLabel(wx.ArtProvider.GetBitmap("wxART_REFRESH", wx.ART_TOOLBAR)) + self.reset_button.SetToolTip(_("Reset to default")) + self.reset_button.Disable() + self.reset_button.Bind(wx.EVT_BUTTON, self.on_reset) + buttons_sizer.Add(self.reset_button, 0, wx.LEFT, 5) + + self.help_panel = wx.Panel(self.splitter, wx.ID_ANY) + self.help_box = wx.html.HtmlWindow(self.help_panel, wx.ID_ANY, style=wx.html.HW_SCROLLBAR_AUTO) + self.help_box_box = SimpleBox(self.help_panel, self.help_box) + help_sizer = wx.BoxSizer(wx.VERTICAL) + help_sizer.Add(self.help_box_box, 1, wx.EXPAND | wx.TOP, 10) + self.help_panel.SetSizer(help_sizer) + self.show_help(self.property_grid.GetFirst(wx.propgrid.PG_ITERATE_CATEGORIES)) + + property_grid_sizer.Add(self.property_grid_box, 1, wx.EXPAND | wx.TOP, 10) + property_grid_sizer.Add(buttons_sizer, 0, wx.EXPAND | wx.TOP, 2) + property_grid_sizer.Add((0, 0), 0, wx.BOTTOM, 10) + self.property_grid_panel.SetSizer(property_grid_sizer) + + main_sizer.Add(self.splitter, 1, wx.EXPAND) + self.layer_editor_panel.SetSizer(main_sizer) + self.splitter.SplitHorizontally(self.property_grid_panel, self.help_panel, global_settings['stitch_layer_editor_sash_position']) + self.splitter.SetMinimumPaneSize(1) + + for property_name, hint in self.hints.items(): + if property := self.property_grid.GetPropertyByName(property_name): + property.SetAttribute(wx.propgrid.PG_ATTR_HINT, hint) + property.SetAttribute("InitialValue", self.initial_values[property_name]) + + main_sizer.Layout() + + return self.layer_editor_panel + + def on_property_changed(self, event): + # override in subclass if needed but always call super().on_property_changed(event)! + changed_property = event.GetProperty() + if self.change_callback is not None: + self.change_callback(changed_property.GetName(), changed_property.GetValue()) + + debug.log(f"Changed property: {changed_property.GetName()} = {changed_property.GetValue()}") + + def on_select(self, event): + property = event.GetProperty() + + if property is None: + enable = False + else: + enable = not property.IsCategory() + self.show_help(property) + + self.undo_button.Enable(enable) + self.reset_button.Enable(enable) + + def on_undo(self, event): + property = self.property_grid.GetSelection() + + if property and not property.IsCategory(): + property_name = property.GetName() + if property_name in self.config: + value = self.config[property_name] + self.property_grid.ChangePropertyValue(property_name, value) + self.change_callback(property_name, value) + property.SetModifiedStatus(False) + self.property_grid.RefreshEditor() + + def on_reset(self, event): + property = self.property_grid.GetSelection() + + if property and not property.IsCategory(): + property_name = property.GetName() + if property_name in self.defaults: + value = self.defaults[property_name] + self.property_grid.ChangePropertyValue(property_name, value) + self.change_callback(property_name, value) + + if value == self.config[property_name]: + property.SetModifiedStatus(False) + + self.property_grid.RefreshEditor() + + def on_sash_position_changed(self, event): + global_settings['stitch_layer_editor_sash_position'] = event.GetSashPosition() + + def show_help(self, property): + if property: + self.help_box.SetPage(self.format_help(property)) + else: + self.help_box.SetPage("") + + def format_help(self, property): + help_string = f"<h2>{property.GetLabel()}</h2>" + help_string += re.sub(r'\n\n?', "<br/>", property.GetHelpString()) + + return help_string diff --git a/lib/svg/tags.py b/lib/svg/tags.py index f965a4f4..17b0007c 100644 --- a/lib/svg/tags.py +++ b/lib/svg/tags.py @@ -180,7 +180,10 @@ inkstitch_attribs = [ 'random_seed', 'manual_stitch', # legacy - 'grid_size' + 'grid_size', + # sew stack + 'sew_stack_only', + 'sew_stack' ] for attrib in inkstitch_attribs: INKSTITCH_ATTRIBS[attrib] = inkex.addNS(attrib, 'inkstitch') diff --git a/lib/utils/dotdict.py b/lib/utils/dotdict.py index 12cf6e79..47f56623 100644 --- a/lib/utils/dotdict.py +++ b/lib/utils/dotdict.py @@ -3,6 +3,7 @@ # Copyright (c) 2010 Authors # Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details. + class DotDict(dict): """A dict subclass that allows accessing methods using dot notation. @@ -10,24 +11,33 @@ class DotDict(dict): """ def __init__(self, *args, **kwargs): - super(DotDict, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self._dotdictify() def update(self, *args, **kwargs): - super(DotDict, self).update(*args, **kwargs) + super().update(*args, **kwargs) self._dotdictify() def _dotdictify(self): for k, v in self.items(): - if isinstance(v, dict): + if isinstance(v, dict) and not isinstance(v, DotDict): self[k] = DotDict(v) - __setattr__ = dict.__setitem__ __delattr__ = dict.__delitem__ + def __setattr__(self, name, value): + if name.startswith('_'): + super().__setattr__(name, value) + else: + if isinstance(value, dict) and not isinstance(value, DotDict): + value = DotDict(value) + + super().__setitem__(name, value) + def __getattr__(self, name): if name.startswith('_'): - raise AttributeError("'DotDict' object has no attribute '%s'" % name) + raise AttributeError( + f"'{self.__class__.__name__}' object has no attribute '{name}'") if name in self: return self.__getitem__(name) @@ -37,5 +47,5 @@ class DotDict(dict): return new_dict def __repr__(self): - super_repr = super(DotDict, self).__repr__() - return "DotDict(%s)" % super_repr + super_repr = super().__repr__() + return f"{self.__class__.__name__}({super_repr})" diff --git a/lib/utils/icons.py b/lib/utils/icons.py new file mode 100644 index 00000000..492d88ce --- /dev/null +++ b/lib/utils/icons.py @@ -0,0 +1,29 @@ +import os + +import wx + +from .paths import get_resource_dir + + +def is_dark_theme(): + return wx.SystemSettings().GetAppearance().IsDark() + + +def load_icon(icon_name, window=None, width=None, height=None): + if window is None and not (width and height): + raise ValueError("load_icon(): must pass a window or width and height") + + icon = wx.Image(os.path.join(get_resource_dir("icons"), f"{icon_name}.png")) + + if not (width and height): + render = wx.RendererNative.Get() + width = height = render.GetHeaderButtonHeight(window) + icon.Rescale(width, height, wx.IMAGE_QUALITY_HIGH) + + if is_dark_theme(): + # only way I've found to get a negative image + data = icon.GetDataBuffer() + for i in range(len(data)): + data[i] = 255 - data[i] + + return icon.ConvertToBitmap() diff --git a/lib/utils/settings.py b/lib/utils/settings.py index e2287862..23e22b61 100644 --- a/lib/utils/settings.py +++ b/lib/utils/settings.py @@ -27,7 +27,9 @@ DEFAULT_SETTINGS = { "color_change_button_status": False, "toggle_page_button_status": True, # apply palette - "last_applied_palette": "" + "last_applied_palette": "", + # sew stack editor + "stitch_layer_editor_sash_position": -200, } diff --git a/templates/sew_stack_editor.xml b/templates/sew_stack_editor.xml new file mode 100644 index 00000000..217da0e5 --- /dev/null +++ b/templates/sew_stack_editor.xml @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="UTF-8"?> +<inkscape-extension translationdomain="inkstitch" xmlns="http://www.inkscape.org/namespace/inkscape/extension"> + <name>Sew Stack (preview)...</name> + <id>org.{{ id_inkstitch }}.sew_stack_editor</id> + <param name="extension" type="string" gui-hidden="true">sew_stack_editor</param> + <effect> + <object-type>all</object-type> + <effects-menu> + <submenu name="{{ menu_inkstitch }}" translatable="no" /> + </effects-menu> + </effect> + <script> + {{ command_tag | safe }} + </script> +</inkscape-extension> diff --git a/tests/test_clone.py b/tests/test_clone.py index 99d2bc61..23dafd57 100644 --- a/tests/test_clone.py +++ b/tests/test_clone.py @@ -76,7 +76,7 @@ class CloneElementTest(TestCase): clone = Clone(use) with clone.clone_elements() as elements: - self.assertEqual(len(elements), 1) + self.assertEqual(len(elements), 2) self.assertAlmostEqual(element_fill_angle(elements[0]), 30) def test_hidden_cloned_elements_not_embroidered(self): @@ -107,7 +107,7 @@ class CloneElementTest(TestCase): clone = Clone(use) with clone.clone_elements() as elements: - self.assertEqual(len(elements), 1) + self.assertEqual(len(elements), 2) self.assertEqual(elements[0].node.get(INKSCAPE_LABEL), "NotHidden") def test_angle_rotated(self): @@ -123,7 +123,7 @@ class CloneElementTest(TestCase): clone = Clone(use) with clone.clone_elements() as elements: - self.assertEqual(len(elements), 1) + self.assertEqual(len(elements), 2) self.assertAngleAlmostEqual(element_fill_angle(elements[0]), 10) def test_angle_flipped(self): @@ -139,7 +139,7 @@ class CloneElementTest(TestCase): clone = Clone(use) with clone.clone_elements() as elements: - self.assertEqual(len(elements), 1) + self.assertEqual(len(elements), 2) self.assertAngleAlmostEqual(element_fill_angle(elements[0]), -30) def test_angle_flipped_rotated(self): @@ -155,7 +155,7 @@ class CloneElementTest(TestCase): clone = Clone(use) with clone.clone_elements() as elements: - self.assertEqual(len(elements), 1) + self.assertEqual(len(elements), 2) # Fill angle goes from 30 -> -30 after flip -> -50 after rotate self.assertAngleAlmostEqual(element_fill_angle(elements[0]), -50) @@ -175,7 +175,7 @@ class CloneElementTest(TestCase): clone = Clone(use) with clone.clone_elements() as elements: - self.assertEqual(len(elements), 1) + self.assertEqual(len(elements), 2) # Slope of the stitching goes from tan(30deg) = 1/sqrt(3) to -sqrt(3)/sqrt(3) = tan(-45deg), # then rotated another -10 degrees to -55 self.assertAngleAlmostEqual(element_fill_angle(elements[0]), -55) @@ -200,7 +200,7 @@ class CloneElementTest(TestCase): clone = Clone(use) with clone.clone_elements() as elements: - self.assertEqual(len(elements), 1) + self.assertEqual(len(elements), 2) # Angle goes from 30 -> 40 (g1 -> g2) -> 29 (use) self.assertAngleAlmostEqual(element_fill_angle(elements[0]), 29) @@ -218,7 +218,7 @@ class CloneElementTest(TestCase): clone = Clone(use) with clone.clone_elements() as elements: - self.assertEqual(len(elements), 2) # One for the stroke, one for the fill + self.assertEqual(len(elements), 3) # One for the stroke, one for the fill, one for the SewStack self.assertEqual(elements[0].node, elements[1].node) # Angle goes from 0 -> -30 self.assertAngleAlmostEqual(element_fill_angle(elements[0]), -30) @@ -237,7 +237,7 @@ class CloneElementTest(TestCase): clone = Clone(use) with clone.clone_elements() as elements: - self.assertEqual(len(elements), 1) # One for the stroke, one for the fill + self.assertEqual(len(elements), 2) # One for the stroke, one for the fill, one for the SewStack self.assertIsNone(elements[0].get_param("angle", None)) # Angle as not set, as this isn't a fill def test_style_inherits(self): @@ -253,7 +253,7 @@ class CloneElementTest(TestCase): clone = Clone(use) with clone.clone_elements() as elements: - self.assertEqual(len(elements), 1) + self.assertEqual(len(elements), 2) style = elements[0].node.cascaded_style() # Source style takes precedence over any attributes specified in the clone self.assertEqual(style["stroke"], "skyblue") @@ -276,7 +276,7 @@ class CloneElementTest(TestCase): clone = Clone(use) with clone.clone_elements() as elements: - self.assertEqual(len(elements), 1) + self.assertEqual(len(elements), 2) self.assertTransformEqual( elements[0].node.composed_transform(), Transform().add_translate((5, 10)).add_scale(2, 2)) @@ -297,7 +297,7 @@ class CloneElementTest(TestCase): clone = Clone(use) with clone.clone_elements() as elements: - self.assertEqual(len(elements), 1) + self.assertEqual(len(elements), 2) self.assertTransformEqual( elements[0].node.composed_transform(), Transform().add_translate((5, 10)) # use @@ -325,7 +325,7 @@ class CloneElementTest(TestCase): clone = Clone(use) with clone.clone_elements() as elements: - self.assertEqual(len(elements), 2) + self.assertEqual(len(elements), 4) # FillStitch, SewStack, FillStitch, SewStack self.assertTransformEqual( elements[0].node.composed_transform(), Transform().add_translate((1, 2)).add_scale(0.5, 1) # g2 @@ -334,7 +334,7 @@ class CloneElementTest(TestCase): .add_scale(2, 2), # rect 5) self.assertTransformEqual( - elements[1].node.composed_transform(), + elements[2].node.composed_transform(), Transform().add_translate((1, 2)).add_scale(0.5, 1) # g2 .add_translate((5, 10)) # use .add_translate((0, 5)).add_rotate(5), # g1 @@ -370,7 +370,7 @@ class CloneElementTest(TestCase): self.assertEqual(clone.clone_fill_angle, 42) with clone.clone_elements() as elements: - self.assertEqual(len(elements), 1) + self.assertEqual(len(elements), 2) self.assertAngleAlmostEqual(element_fill_angle(elements[0]), 42) def test_angle_manually_flipped(self): @@ -388,7 +388,7 @@ class CloneElementTest(TestCase): clone = Clone(use) self.assertTrue(clone.flip_angle) with clone.clone_elements() as elements: - self.assertEqual(len(elements), 1) + self.assertEqual(len(elements), 2) self.assertAngleAlmostEqual(element_fill_angle(elements[0]), -10) # Recursive use tests @@ -414,15 +414,17 @@ class CloneElementTest(TestCase): with clone.clone_elements() as elements: # There should be two elements cloned from u3, two rects, one corresponding to rect and one corresponding to u1. # Their transforms should derive from the elements they href. - self.assertEqual(len(elements), 2) + self.assertEqual(len(elements), 4) + self.assertEqual(type(elements[0]), FillStitch) self.assertEqual(elements[0].node.tag, SVG_RECT_TAG) self.assertTransformEqual(elements[0].node.composed_transform(), Transform().add_translate((0, 30)) # u3 .add_translate(0, 20).add_scale(0.5, 0.5) # u2 ) - self.assertEqual(elements[1].node.tag, SVG_RECT_TAG) - self.assertTransformEqual(elements[1].node.composed_transform(), + self.assertEqual(type(elements[2]), FillStitch) + self.assertEqual(elements[2].node.tag, SVG_RECT_TAG) + self.assertTransformEqual(elements[2].node.composed_transform(), Transform().add_translate((0, 30)) # u3 .add_translate((0, 20)).add_scale(0.5, 0.5) # u2 .add_translate((20, 0)) # u1 @@ -441,7 +443,7 @@ class CloneElementTest(TestCase): clone = Clone(u1) with clone.clone_elements() as elements: - self.assertEqual(len(elements), 1) + self.assertEqual(len(elements), 2) # Angle goes from 30 -> -30 self.assertAngleAlmostEqual(element_fill_angle(elements[0]), -30) @@ -452,7 +454,7 @@ class CloneElementTest(TestCase): clone = Clone(u2) with clone.clone_elements() as elements: - self.assertEqual(len(elements), 1) + self.assertEqual(len(elements), 2) # Angle goes from -30 -> -20 (u1 -> g) self.assertAngleAlmostEqual(element_fill_angle(elements[0]), -20) @@ -462,7 +464,7 @@ class CloneElementTest(TestCase): clone = Clone(u3) with clone.clone_elements() as elements: - self.assertEqual(len(elements), 1) + self.assertEqual(len(elements), 2) # Angle goes from -20 -> -27 self.assertAngleAlmostEqual(element_fill_angle(elements[0]), -27) @@ -473,7 +475,7 @@ class CloneElementTest(TestCase): clone = Clone(u4) with clone.clone_elements() as elements: - self.assertEqual(len(elements), 1) + self.assertEqual(len(elements), 2) # Angle goes from -30 -> -37 self.assertAngleAlmostEqual(element_fill_angle(elements[0]), -37) @@ -499,7 +501,7 @@ class CloneElementTest(TestCase): clone = Clone(u3) with clone.clone_elements() as elements: - self.assertEqual(len(elements), 1) + self.assertEqual(len(elements), 2) # Angle goes from 0 (g -> u2) -> -7 (u3) self.assertAngleAlmostEqual(element_fill_angle(elements[0]), -7) @@ -524,7 +526,7 @@ class CloneElementTest(TestCase): clone = Clone(use) with clone.clone_elements() as elements: - self.assertEqual(len(elements), 1) + self.assertEqual(len(elements), 2) cmd_orig = original.get_command("ending_point") cmd_clone = elements[0].get_command("ending_point") self.assertIsNotNone(cmd_clone) @@ -551,7 +553,7 @@ class CloneElementTest(TestCase): clone = Clone(use) with clone.clone_elements() as elements: - self.assertEqual(len(elements), 1) + self.assertEqual(len(elements), 2) cmd_orig = original.get_command("ending_point") cmd_clone = elements[0].get_command("ending_point") self.assertIsNotNone(cmd_clone) diff --git a/tests/test_elements_utils.py b/tests/test_elements_utils.py index 651635be..bdb78003 100644 --- a/tests/test_elements_utils.py +++ b/tests/test_elements_utils.py @@ -28,7 +28,7 @@ class ElementsUtilsTest(TestCase): })) elements = utils.nodes_to_elements(utils.iterate_nodes(g)) - self.assertEqual(len(elements), 1) + self.assertEqual(len(elements), 2) self.assertEqual(type(elements[0]), FillStitch) self.assertEqual(elements[0].node, rect) @@ -41,7 +41,7 @@ class ElementsUtilsTest(TestCase): })) elements = utils.nodes_to_elements(utils.iterate_nodes(rect)) - self.assertEqual(len(elements), 1) + self.assertEqual(len(elements), 2) self.assertEqual(type(elements[0]), FillStitch) self.assertEqual(elements[0].node, rect) |
