summaryrefslogtreecommitdiff
path: root/lib/sew_stack
diff options
context:
space:
mode:
authorLex Neva <lexelby@users.noreply.github.com>2025-01-29 12:04:07 -0500
committerGitHub <noreply@github.com>2025-01-29 12:04:07 -0500
commit913c2700d1486284dba0583ae1b280b1aa237570 (patch)
treec165b29d0794981b5e44ab46f9838baab16b06a4 /lib/sew_stack
parentefe3b27f17686094f74462bd81763a8197b54c6e (diff)
Sew Stack first steps (#3133)
* handle more recursive cases * scaffolding for stitch layers * scaffolding for SewStack * always use DotDict when parsing json params * add DefaultDotDict + DotDict fixes * first working SewStack (no UI yet) * ignore inkstitch_debug.log and .svg * refactor * early WIP: property grid display temporarily in stitch plan preview * start of sew stack editor extension * add layer properties panel and splitter * spacing and better icon * handle checkbox * add layer action buttons * show selected property help text in an HtmlWindow * rename * rephrase help text for tolerance * refactor into separate file * simplify structure * better property type handling * add randomization button * add random seed re-roll button * simulator preview * update preview in a few more cases * always DotDict * avoid ridiculously slow simulations * preview selected layer or all layers * edit multiple objects and save only modified properties into the SVG * better preview handling * add reverse and jitter * add stitch path jitter * fix types * fix random shuffle button * fixes * fix repeats * type hinting to please pycharm * show layer description * avoid exception in properties with multiple values * fix typing * fix new layer * draw a box around property grid and help box * confirm before closing * rename properties and fix seed * fix close/cancel logic * add buttons to undo changes and reset to default value * set not modified if default is original setting * fix invisible icon * more space for properties * fix random properties * better regulation of simulator rendering speed * Fixed timer being passed a float * fix get_json_param() default handling * fix tests * add checkbox for sew stack only * fix property help * adjustable stitch layer editor help box size, with persistence * repeat exact stitches * "fix" style * adjust for new next_element stuff --------- Co-authored-by: CapellanCitizen <thecapellancitizen@gmail.com>
Diffstat (limited to 'lib/sew_stack')
-rw-r--r--lib/sew_stack/__init__.py77
-rw-r--r--lib/sew_stack/stitch_layers/__init__.py7
-rw-r--r--lib/sew_stack/stitch_layers/mixins/README.md9
-rw-r--r--lib/sew_stack/stitch_layers/mixins/path.py27
-rw-r--r--lib/sew_stack/stitch_layers/mixins/randomization.py121
-rw-r--r--lib/sew_stack/stitch_layers/running_stitch/__init__.py1
-rw-r--r--lib/sew_stack/stitch_layers/running_stitch/running_stitch_layer.py126
-rw-r--r--lib/sew_stack/stitch_layers/stitch_layer.py85
-rw-r--r--lib/sew_stack/stitch_layers/stitch_layer_editor.py441
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