summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--icons/invisible.pngbin0 -> 6975 bytes
-rw-r--r--icons/invisible.svg74
-rw-r--r--icons/layer.pngbin0 -> 2075 bytes
-rw-r--r--icons/layer.svg70
-rw-r--r--icons/layers.pngbin0 -> 3858 bytes
-rw-r--r--icons/layers.svg83
-rw-r--r--icons/visible.pngbin0 -> 6084 bytes
-rw-r--r--icons/visible.svg66
-rw-r--r--lib/elements/element.py17
-rw-r--r--lib/elements/utils.py29
-rw-r--r--lib/extensions/__init__.py2
-rwxr-xr-xlib/extensions/sew_stack_editor.py564
-rw-r--r--lib/gui/simulator/control_panel.py6
-rw-r--r--lib/gui/simulator/drawing_panel.py42
-rw-r--r--lib/gui/simulator/split_simulator_window.py7
-rw-r--r--lib/gui/windows.py41
-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
-rw-r--r--lib/svg/tags.py5
-rw-r--r--lib/utils/dotdict.py24
-rw-r--r--lib/utils/icons.py29
-rw-r--r--lib/utils/settings.py4
-rw-r--r--templates/sew_stack_editor.xml15
-rw-r--r--tests/test_clone.py54
-rw-r--r--tests/test_elements_utils.py4
33 files changed, 1966 insertions, 65 deletions
diff --git a/.gitignore b/.gitignore
index 4c1ba2c2..fa8a882b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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
new file mode 100644
index 00000000..0aa1b95b
--- /dev/null
+++ b/icons/invisible.png
Binary files differ
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
new file mode 100644
index 00000000..4e0584ad
--- /dev/null
+++ b/icons/layer.png
Binary files differ
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
new file mode 100644
index 00000000..308c9528
--- /dev/null
+++ b/icons/layers.png
Binary files differ
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
new file mode 100644
index 00000000..11e41657
--- /dev/null
+++ b/icons/visible.png
Binary files differ
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)