diff options
Diffstat (limited to 'lib/sew_stack')
| -rw-r--r-- | lib/sew_stack/__init__.py | 77 | ||||
| -rw-r--r-- | lib/sew_stack/stitch_layers/__init__.py | 7 | ||||
| -rw-r--r-- | lib/sew_stack/stitch_layers/mixins/README.md | 9 | ||||
| -rw-r--r-- | lib/sew_stack/stitch_layers/mixins/path.py | 27 | ||||
| -rw-r--r-- | lib/sew_stack/stitch_layers/mixins/randomization.py | 121 | ||||
| -rw-r--r-- | lib/sew_stack/stitch_layers/running_stitch/__init__.py | 1 | ||||
| -rw-r--r-- | lib/sew_stack/stitch_layers/running_stitch/running_stitch_layer.py | 126 | ||||
| -rw-r--r-- | lib/sew_stack/stitch_layers/stitch_layer.py | 85 | ||||
| -rw-r--r-- | lib/sew_stack/stitch_layers/stitch_layer_editor.py | 441 |
9 files changed, 894 insertions, 0 deletions
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 |
