From 1e0280db10cbb987842648f78f37bb9acc827305 Mon Sep 17 00:00:00 2001 From: Lex Neva Date: Sat, 15 Dec 2018 20:21:41 -0500 Subject: basic lettering GUI (#351) --- lib/extensions/base.py | 11 +- lib/extensions/lettering.py | 236 +++++++++++-- lib/extensions/params.py | 280 ++------------- lib/extensions/simulate.py | 4 +- lib/gui/__init__.py | 3 + lib/gui/dialogs.py | 14 + lib/gui/presets.py | 183 ++++++++++ lib/gui/simulator.py | 782 ++++++++++++++++++++++++++++++++++++++++++ lib/lettering/font.py | 2 +- lib/lettering/font_variant.py | 3 +- lib/simulator.py | 659 ----------------------------------- lib/svg/tags.py | 4 + lib/utils/__init__.py | 13 +- lib/utils/dotdict.py | 30 ++ 14 files changed, 1278 insertions(+), 946 deletions(-) create mode 100644 lib/gui/__init__.py create mode 100644 lib/gui/dialogs.py create mode 100644 lib/gui/presets.py create mode 100644 lib/gui/simulator.py delete mode 100644 lib/simulator.py create mode 100644 lib/utils/dotdict.py (limited to 'lib') diff --git a/lib/extensions/base.py b/lib/extensions/base.py index 279ca396..98673541 100644 --- a/lib/extensions/base.py +++ b/lib/extensions/base.py @@ -211,16 +211,15 @@ class InkstitchExtension(inkex.Effect): # care that it's unique. That defines a "namespace" of element and # attribute names to disambiguate conflicts with element and # attribute names other XML namespaces. - # - # Updating inkex.NSS here allows us to pass 'inkstitch' into - # inkex.addNS(). - inkex.NSS['inkstitch'] = 'http://inkstitch.org/namespace' # call the superclass's method first inkex.Effect.parse(self) - # This is the only way I could find to add a namespace to an existing - # element tree at the top without getting ugly prefixes like "ns0". + # Add the inkstitch namespace to the SVG. The inkstitch namespace is + # added to inkex.NSS in ../svg/tags.py at import time. + + # The below is the only way I could find to add a namespace to an + # existing element tree at the top without getting ugly prefixes like "ns0". inkex.etree.cleanup_namespaces(self.document, top_nsmap=inkex.NSS, keep_ns_prefixes=inkex.NSS.keys()) diff --git a/lib/extensions/lettering.py b/lib/extensions/lettering.py index 0d6629f8..b6d67c0b 100644 --- a/lib/extensions/lettering.py +++ b/lib/extensions/lettering.py @@ -1,39 +1,233 @@ +# -*- coding: UTF-8 -*- + +from base64 import b64encode, b64decode +import json import os +import sys + +import inkex +import wx +from ..elements import nodes_to_elements +from ..gui import PresetsPanel, SimulatorPreview from ..i18n import _ from ..lettering import Font -from ..svg.tags import SVG_PATH_TAG, SVG_GROUP_TAG, INKSCAPE_LABEL -from ..utils import get_bundled_dir +from ..svg.tags import SVG_PATH_TAG, SVG_GROUP_TAG, INKSCAPE_LABEL, INKSTITCH_LETTERING +from ..utils import get_bundled_dir, DotDict from .commands import CommandsExtension +class LetteringFrame(wx.Frame): + def __init__(self, *args, **kwargs): + # begin wxGlade: MyFrame.__init__ + self.group = kwargs.pop('group') + self.cancel_hook = kwargs.pop('on_cancel', None) + wx.Frame.__init__(self, None, wx.ID_ANY, + _("Ink/Stitch Lettering") + ) + + self.preview = SimulatorPreview(self, target_duration=1) + self.presets_panel = PresetsPanel(self) + + self.load_settings() + + # options + self.options_box = wx.StaticBox(self, wx.ID_ANY, label=_("Options")) + + self.back_and_forth_checkbox = wx.CheckBox(self, label=_("Stitch lines of text back and forth")) + self.back_and_forth_checkbox.SetValue(self.settings.back_and_forth) + self.Bind(wx.EVT_CHECKBOX, lambda event: self.on_change("back_and_forth", event)) + + # text editor + self.text_editor_box = wx.StaticBox(self, wx.ID_ANY, label=_("Text")) + + self.font_chooser = wx.ComboBox(self, wx.ID_ANY) + self.update_font_list() + + self.text_editor = wx.TextCtrl(self, style=wx.TE_MULTILINE | wx.TE_DONTWRAP, value=self.settings.text) + self.Bind(wx.EVT_TEXT, lambda event: self.on_change("text", event)) + + self.cancel_button = wx.Button(self, wx.ID_ANY, _("Cancel")) + self.cancel_button.Bind(wx.EVT_BUTTON, self.cancel) + self.Bind(wx.EVT_CLOSE, self.cancel) + + self.apply_button = wx.Button(self, wx.ID_ANY, _("Apply and Quit")) + self.apply_button.Bind(wx.EVT_BUTTON, self.apply) + + self.__do_layout() + # end wxGlade + + def load_settings(self): + try: + if INKSTITCH_LETTERING in self.group.attrib: + self.settings = DotDict(json.loads(b64decode(self.group.get(INKSTITCH_LETTERING)))) + return + except (TypeError, ValueError): + pass + + self.settings = DotDict({ + "text": u"", + "back_and_forth": True, + "font": "small_font" + }) + + def save_settings(self): + # We base64 encode the string before storing it in an XML attribute. + # In theory, lxml should properly html-encode the string, using HTML + # entities like as necessary. However, we've found that Inkscape + # incorrectly interpolates the HTML entities upon reading the + # extension's output, rather than leaving them as is. + # + # Details: + # https://bugs.launchpad.net/inkscape/+bug/1804346 + self.group.set(INKSTITCH_LETTERING, b64encode(json.dumps(self.settings))) + + def on_change(self, attribute, event): + self.settings[attribute] = event.GetEventObject().GetValue() + self.preview.update() + + def generate_patches(self, abort_early=None): + patches = [] + + font_path = os.path.join(get_bundled_dir("fonts"), self.settings.font) + font = Font(font_path) + + try: + lines = font.render_text(self.settings.text, back_and_forth=self.settings.back_and_forth) + self.group[:] = lines + elements = nodes_to_elements(self.group.iterdescendants(SVG_PATH_TAG)) + + for element in elements: + if abort_early and abort_early.is_set(): + # cancel; settings were updated and we need to start over + return [] + + patches.extend(element.embroider(None)) + except SystemExit: + raise + except Exception: + raise + # Ignore errors. This can be things like incorrect paths for + # satins or division by zero caused by incorrect param values. + pass + + return patches + + def update_font_list(self): + pass + + def get_preset_data(self): + # called by self.presets_panel + preset = {} + return preset + + def apply_preset_data(self): + # called by self.presets_panel + return + + def get_preset_suite_name(self): + # called by self.presets_panel + return "lettering" + + def apply(self, event): + self.preview.disable() + self.generate_patches() + self.save_settings() + self.close() + + def close(self): + self.preview.close() + self.Destroy() + + def cancel(self, event): + if self.cancel_hook: + self.cancel_hook() + + self.close() + + def __do_layout(self): + outer_sizer = wx.BoxSizer(wx.VERTICAL) + + options_sizer = wx.StaticBoxSizer(self.options_box, wx.VERTICAL) + options_sizer.Add(self.back_and_forth_checkbox, 1, wx.EXPAND | wx.LEFT | wx.TOP | wx.RIGHT | wx.BOTTOM, 10) + outer_sizer.Add(options_sizer, 0, wx.EXPAND | wx.LEFT | wx.TOP | wx.RIGHT, 10) + + text_editor_sizer = wx.StaticBoxSizer(self.text_editor_box, wx.VERTICAL) + text_editor_sizer.Add(self.font_chooser, 0, wx.ALL, 10) + text_editor_sizer.Add(self.text_editor, 1, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 10) + outer_sizer.Add(text_editor_sizer, 1, wx.EXPAND | wx.LEFT | wx.TOP | wx.RIGHT, 10) + + outer_sizer.Add(self.presets_panel, 0, wx.EXPAND | wx.EXPAND | wx.ALL, 10) + + buttons_sizer = wx.BoxSizer(wx.HORIZONTAL) + buttons_sizer.Add(self.cancel_button, 0, wx.ALIGN_RIGHT | wx.RIGHT, 10) + buttons_sizer.Add(self.apply_button, 0, wx.ALIGN_RIGHT | wx.RIGHT | wx.BOTTOM, 10) + outer_sizer.Add(buttons_sizer, 0, wx.ALIGN_RIGHT, 10) + + self.SetSizerAndFit(outer_sizer) + self.Layout() + + # SetSizerAndFit determined the minimum size that fits all the controls + # and set the window's minimum size so that the user can't make it + # smaller. It also set the window to that size. We'd like to give the + # user a bit more room for text, so we'll add some height. + size = self.GetSize() + size.height = size.height + 200 + self.SetSize(size) + + class Lettering(CommandsExtension): COMMANDS = ["trim"] def __init__(self, *args, **kwargs): + self.cancelled = False CommandsExtension.__init__(self, *args, **kwargs) - self.OptionParser.add_option("-t", "--text") + def cancel(self): + self.cancelled = True - def effect(self): - font_path = os.path.join(get_bundled_dir("fonts"), "small_font") - font = Font(font_path) - self.ensure_current_layer() + def get_or_create_group(self): + if self.selected: + groups = set() + + for node in self.selected.itervalues(): + if node.tag == SVG_GROUP_TAG and INKSTITCH_LETTERING in node.attrib: + groups.add(node) + + for group in node.iterancestors(SVG_GROUP_TAG): + if INKSTITCH_LETTERING in group.attrib: + groups.add(group) - lines = font.render_text(self.options.text.decode('utf-8')) - self.set_labels(lines) - self.current_layer.append(lines) + if len(groups) > 1: + inkex.errormsg(_("Please select only one block of text.")) + sys.exit(1) + elif len(groups) == 0: + inkex.errormsg(_("You've selected objects that were not created by the Lettering extension. " + "Please clear your selection or select different objects before running Lettering again.")) + sys.exit(1) + else: + return list(groups)[0] + else: + self.ensure_current_layer() + return inkex.etree.SubElement(self.current_layer, SVG_GROUP_TAG, { + INKSCAPE_LABEL: _("Ink/Stitch Lettering") + }) + + def effect(self): + app = wx.App() + frame = LetteringFrame(group=self.get_or_create_group(), on_cancel=self.cancel) - def set_labels(self, lines): - path = 1 - for node in lines.iterdescendants(): - if node.tag == SVG_PATH_TAG: - node.set("id", self.uniqueId("lettering")) + # position left, center + current_screen = wx.Display.GetFromPoint(wx.GetMousePosition()) + display = wx.Display(current_screen) + display_size = display.GetClientArea() + frame_size = frame.GetSize() + frame.SetPosition((display_size[0], display_size[3] / 2 - frame_size[1] / 2)) - # L10N Label for an object created by the Lettering extension - node.set(INKSCAPE_LABEL, _("Lettering %d") % path) - path += 1 - elif node.tag == SVG_GROUP_TAG: - node.set("id", self.uniqueId("letteringline")) + frame.Show() + app.MainLoop() - # lettering extension already set the label + 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/extensions/params.py b/lib/extensions/params.py index 1f3032ca..d3cb154a 100644 --- a/lib/extensions/params.py +++ b/lib/extensions/params.py @@ -1,78 +1,20 @@ # -*- coding: UTF-8 -*- +from collections import defaultdict +from copy import copy +from itertools import groupby import os import sys -import json -import traceback -from threading import Thread, Event -from copy import copy + import wx from wx.lib.scrolledpanel import ScrolledPanel -from collections import defaultdict -from itertools import groupby -from .base import InkstitchExtension -from ..i18n import _ -from ..stitch_plan import patches_to_stitch_plan +from ..commands import is_command from ..elements import EmbroideryElement, Fill, AutoFill, Stroke, SatinColumn +from ..gui import PresetsPanel, SimulatorPreview +from ..i18n import _ from ..utils import get_resource_dir -from ..simulator import EmbroiderySimulator -from ..commands import is_command - - -def presets_path(): - try: - import appdirs - config_path = appdirs.user_config_dir('inkstitch') - except ImportError: - config_path = os.path.expanduser('~/.inkstitch') - - if not os.path.exists(config_path): - os.makedirs(config_path) - return os.path.join(config_path, 'presets.json') - - -def load_presets(): - try: - with open(presets_path(), 'r') as presets: - presets = json.load(presets) - return presets - except IOError: - return {} - - -def save_presets(presets): - with open(presets_path(), 'w') as presets_file: - json.dump(presets, presets_file) - - -def load_preset(name): - return load_presets().get(name) - - -def save_preset(name, data): - presets = load_presets() - presets[name] = data - save_presets(presets) - - -def delete_preset(name): - presets = load_presets() - presets.pop(name, None) - save_presets(presets) - - -def confirm_dialog(parent, question, caption='ink/stitch'): - dlg = wx.MessageDialog(parent, question, caption, wx.YES_NO | wx.ICON_QUESTION) - result = dlg.ShowModal() == wx.ID_YES - dlg.Destroy() - return result - - -def info_dialog(parent, message, caption='ink/stitch'): - dlg = wx.MessageDialog(parent, message, caption, wx.OK | wx.ICON_INFORMATION) - dlg.ShowModal() - dlg.Destroy() +from .base import InkstitchExtension class ParamsTab(ScrolledPanel): @@ -358,7 +300,7 @@ class ParamsTab(ScrolledPanel): self.changed_inputs.add(self.param_inputs[param]) - if self.on_change_hook(): + if self.on_change_hook: self.on_change_hook(self) # end of class SatinPane @@ -376,33 +318,10 @@ class SettingsFrame(wx.Frame): self.tabs = self.tabs_factory(self.notebook) for tab in self.tabs: - tab.on_change(self.update_simulator) - - self.simulate_window = None - self.simulate_thread = None - self.simulate_refresh_needed = Event() - - # used when closing to avoid having the window reopen at the last second - self.disable_simulate_window = False + tab.on_change(self.update_preview) - wx.CallLater(1000, self.update_simulator) - - self.presets_box = wx.StaticBox(self, wx.ID_ANY, label=_("Presets")) - - self.preset_chooser = wx.ComboBox(self, wx.ID_ANY) - self.update_preset_list() - - self.load_preset_button = wx.Button(self, wx.ID_ANY, _("Load")) - self.load_preset_button.Bind(wx.EVT_BUTTON, self.load_preset) - - self.add_preset_button = wx.Button(self, wx.ID_ANY, _("Add")) - self.add_preset_button.Bind(wx.EVT_BUTTON, self.add_preset) - - self.overwrite_preset_button = wx.Button(self, wx.ID_ANY, _("Overwrite")) - self.overwrite_preset_button.Bind(wx.EVT_BUTTON, self.overwrite_preset) - - self.delete_preset_button = wx.Button(self, wx.ID_ANY, _("Delete")) - self.delete_preset_button.Bind(wx.EVT_BUTTON, self.delete_preset) + self.preview = SimulatorPreview(self) + self.presets_panel = PresetsPanel(self) self.cancel_button = wx.Button(self, wx.ID_ANY, _("Cancel")) self.cancel_button.Bind(wx.EVT_BUTTON, self.cancel) @@ -414,83 +333,17 @@ class SettingsFrame(wx.Frame): self.apply_button = wx.Button(self, wx.ID_ANY, _("Apply and Quit")) self.apply_button.Bind(wx.EVT_BUTTON, self.apply) - self.__set_properties() + self.notebook.SetMinSize((800, 600)) + self.__do_layout() # end wxGlade - def update_simulator(self, tab=None): - if self.simulate_window: - self.simulate_window.stop() - self.simulate_window.clear() - - if self.disable_simulate_window: - return + def update_preview(self, tab): + self.preview.update() - if not self.simulate_thread or not self.simulate_thread.is_alive(): - self.simulate_thread = Thread(target=self.simulate_worker) - self.simulate_thread.daemon = True - self.simulate_thread.start() + def generate_patches(self, abort_early): + # called by self.preview - self.simulate_refresh_needed.set() - - def simulate_worker(self): - while True: - self.simulate_refresh_needed.wait() - self.simulate_refresh_needed.clear() - self.update_patches() - - def update_patches(self): - patches = self.generate_patches() - - if patches and not self.simulate_refresh_needed.is_set(): - wx.CallAfter(self.refresh_simulator, patches) - - def refresh_simulator(self, patches): - stitch_plan = patches_to_stitch_plan(patches) - if self.simulate_window: - self.simulate_window.stop() - self.simulate_window.load(stitch_plan) - else: - params_rect = self.GetScreenRect() - simulator_pos = params_rect.GetTopRight() - simulator_pos.x += 5 - - current_screen = wx.Display.GetFromPoint(wx.GetMousePosition()) - display = wx.Display(current_screen) - screen_rect = display.GetClientArea() - simulator_pos.y = screen_rect.GetTop() - - width = screen_rect.GetWidth() - params_rect.GetWidth() - height = screen_rect.GetHeight() - - try: - self.simulate_window = EmbroiderySimulator(None, -1, _("Preview"), - simulator_pos, - size=(width, height), - stitch_plan=stitch_plan, - on_close=self.simulate_window_closed, - target_duration=5) - except Exception: - error = traceback.format_exc() - - try: - # a window may have been created, so we need to destroy it - # or the app will never exit - wx.Window.FindWindowByName(_("Preview")).Destroy() - except Exception: - pass - - info_dialog(self, error, _("Internal Error")) - - self.simulate_window.Show() - wx.CallLater(10, self.Raise) - - wx.CallAfter(self.simulate_window.go) - - def simulate_window_closed(self): - self.simulate_window = None - - def generate_patches(self): patches = [] nodes = [] @@ -505,7 +358,7 @@ class SettingsFrame(wx.Frame): try: for node in nodes: - if self.simulate_refresh_needed.is_set(): + if abort_early.is_set(): # cancel; params were updated and we need to start over return [] @@ -523,27 +376,9 @@ class SettingsFrame(wx.Frame): return patches - def update_preset_list(self): - preset_names = load_presets().keys() - preset_names = [preset for preset in preset_names if preset != "__LAST__"] - self.preset_chooser.SetItems(sorted(preset_names)) - - def get_preset_name(self): - preset_name = self.preset_chooser.GetValue().strip() - if preset_name: - return preset_name - else: - info_dialog(self, _("Please enter or select a preset name first."), caption=_('Preset')) - return - - def check_and_load_preset(self, preset_name): - preset = load_preset(preset_name) - if not preset: - info_dialog(self, _('Preset "%s" not found.') % preset_name, caption=_('Preset')) - - return preset - def get_preset_data(self): + # called by self.presets_panel + preset = {} current_tab = self.tabs[self.notebook.GetSelection()] @@ -561,53 +396,13 @@ class SettingsFrame(wx.Frame): return preset - def add_preset(self, event, overwrite=False): - preset_name = self.get_preset_name() - if not preset_name: - return - - if not overwrite and load_preset(preset_name): - info_dialog(self, _('Preset "%s" already exists. Please use another name or press "Overwrite"') % preset_name, caption=_('Preset')) - - save_preset(preset_name, self.get_preset_data()) - self.update_preset_list() - - event.Skip() - - def overwrite_preset(self, event): - self.add_preset(event, overwrite=True) - - def _load_preset(self, preset_name): - preset = self.check_and_load_preset(preset_name) - if not preset: - return + def apply_preset_data(self, preset_data): + # called by self.presets_panel for tab in self.tabs: - tab.load_preset(preset) - - def load_preset(self, event): - preset_name = self.get_preset_name() - if not preset_name: - return + tab.load_preset(preset_data) - self._load_preset(preset_name) - - event.Skip() - - def delete_preset(self, event): - preset_name = self.get_preset_name() - if not preset_name: - return - - preset = self.check_and_load_preset(preset_name) - if not preset: - return - - delete_preset(preset_name) - self.update_preset_list() - self.preset_chooser.SetValue("") - - event.Skip() + self.preview.update() def _apply(self): for tab in self.tabs: @@ -615,19 +410,16 @@ class SettingsFrame(wx.Frame): def apply(self, event): self._apply() - save_preset("__LAST__", self.get_preset_data()) + self.presets_panel.store_preset("__LAST__", self.get_preset_data()) self.close() def use_last(self, event): - self.disable_simulate_window = True - self._load_preset("__LAST__") + self.preview.disable() + self.presets_panel.load_preset("__LAST__") self.apply(event) def close(self): - if self.simulate_window: - self.simulate_window.stop() - self.simulate_window.Close() - + self.preview.close() self.Destroy() def cancel(self, event): @@ -636,27 +428,15 @@ class SettingsFrame(wx.Frame): self.close() - def __set_properties(self): - # begin wxGlade: MyFrame.__set_properties - self.notebook.SetMinSize((800, 600)) - self.preset_chooser.SetSelection(-1) - # end wxGlade - def __do_layout(self): # begin wxGlade: MyFrame.__do_layout sizer_1 = wx.BoxSizer(wx.VERTICAL) # self.sizer_3_staticbox.Lower() - sizer_2 = wx.StaticBoxSizer(self.presets_box, wx.HORIZONTAL) sizer_3 = wx.BoxSizer(wx.HORIZONTAL) for tab in self.tabs: self.notebook.AddPage(tab, tab.name) sizer_1.Add(self.notebook, 1, wx.EXPAND | wx.LEFT | wx.TOP | wx.RIGHT, 10) - sizer_2.Add(self.preset_chooser, 1, wx.ALIGN_CENTER_VERTICAL | wx.ALL, 5) - sizer_2.Add(self.load_preset_button, 0, wx.ALIGN_CENTER_VERTICAL | wx.ALL, 5) - sizer_2.Add(self.add_preset_button, 0, wx.ALIGN_CENTER_VERTICAL | wx.ALL, 5) - sizer_2.Add(self.overwrite_preset_button, 0, wx.ALIGN_CENTER_VERTICAL | wx.ALL, 5) - sizer_2.Add(self.delete_preset_button, 0, wx.ALIGN_CENTER_VERTICAL | wx.ALL, 5) - sizer_1.Add(sizer_2, 0, flag=wx.EXPAND | wx.ALL, border=10) + sizer_1.Add(self.presets_panel, 0, flag=wx.EXPAND | wx.ALL, border=10) sizer_3.Add(self.cancel_button, 0, wx.ALIGN_RIGHT | wx.RIGHT, 5) sizer_3.Add(self.use_last_button, 0, wx.ALIGN_RIGHT | wx.RIGHT | wx.BOTTOM, 5) sizer_3.Add(self.apply_button, 0, wx.ALIGN_RIGHT | wx.RIGHT | wx.BOTTOM, 5) diff --git a/lib/extensions/simulate.py b/lib/extensions/simulate.py index f962b206..a4da26d5 100644 --- a/lib/extensions/simulate.py +++ b/lib/extensions/simulate.py @@ -1,6 +1,6 @@ -from .base import InkstitchExtension -from ..simulator import show_simulator +from ..gui import show_simulator from ..stitch_plan import patches_to_stitch_plan +from .base import InkstitchExtension class Simulate(InkstitchExtension): diff --git a/lib/gui/__init__.py b/lib/gui/__init__.py new file mode 100644 index 00000000..060c3d93 --- /dev/null +++ b/lib/gui/__init__.py @@ -0,0 +1,3 @@ +from dialogs import info_dialog, confirm_dialog +from presets import PresetsPanel +from simulator import EmbroiderySimulator, SimulatorPreview, show_simulator diff --git a/lib/gui/dialogs.py b/lib/gui/dialogs.py new file mode 100644 index 00000000..c09503b3 --- /dev/null +++ b/lib/gui/dialogs.py @@ -0,0 +1,14 @@ +import wx + + +def confirm_dialog(parent, question, caption='ink/stitch'): + dlg = wx.MessageDialog(parent, question, caption, wx.YES_NO | wx.ICON_QUESTION) + result = dlg.ShowModal() == wx.ID_YES + dlg.Destroy() + return result + + +def info_dialog(parent, message, caption='ink/stitch'): + dlg = wx.MessageDialog(parent, message, caption, wx.OK | wx.ICON_INFORMATION) + dlg.ShowModal() + dlg.Destroy() diff --git a/lib/gui/presets.py b/lib/gui/presets.py new file mode 100644 index 00000000..e6000718 --- /dev/null +++ b/lib/gui/presets.py @@ -0,0 +1,183 @@ +import json +import os +import re + +import wx + +from ..i18n import _ +from ..utils import cache +from .dialogs import info_dialog + + +class PresetsPanel(wx.Panel): + """A wx.Panel for loading, saving, and applying presets. + + A preset is a named collection of settings. From the perspective of this + class, a preset is an opaque JSON-serializable object. + + The PresetsPanel will handle interaction with the user and inform the + instantiator of events such as a preset being loaded. Presets starting + and ending with "__" will not be shown to the user. This allows for the + instantiator to manage hidden presets such as "__LAST__". + """ + + HIDDEN_PRESET_RE = re.compile('^__.*__$') + + def __init__(self, parent, *args, **kwargs): + """Construct a PresetsPanel. + + The parent is the parent window for this wx.Panel. The parent is + expected to implement the following methods: + + def get_preset_data(self) + returns a JSON object representing the current state as a preset + + def apply_preset_data(self, preset_data): + apply the preset data to the GUI, updating GUI elements as necessary + + def get_preset_suite_name(self): + Return a string used in the presets filename, e.g. "lettering" -> "lettering_presets.json". + If not defined, "presets.json" will be used. + """ + + kwargs.setdefault('style', wx.BORDER_NONE) + wx.Panel.__init__(self, parent, wx.ID_ANY, *args, **kwargs) + self.parent = parent + + self.presets_box = wx.StaticBox(self, wx.ID_ANY, label=_("Presets")) + + self.preset_chooser = wx.ComboBox(self, wx.ID_ANY) + self.update_preset_list() + self.preset_chooser.SetSelection(-1) + + self.load_preset_button = wx.Button(self, wx.ID_ANY, _("Load")) + self.load_preset_button.Bind(wx.EVT_BUTTON, self.load_selected_preset) + + self.add_preset_button = wx.Button(self, wx.ID_ANY, _("Add")) + self.add_preset_button.Bind(wx.EVT_BUTTON, self.add_preset) + + self.overwrite_preset_button = wx.Button(self, wx.ID_ANY, _("Overwrite")) + self.overwrite_preset_button.Bind(wx.EVT_BUTTON, self.overwrite_preset) + + self.delete_preset_button = wx.Button(self, wx.ID_ANY, _("Delete")) + self.delete_preset_button.Bind(wx.EVT_BUTTON, self.delete_preset) + + presets_sizer = wx.StaticBoxSizer(self.presets_box, wx.HORIZONTAL) + presets_sizer.Add(self.preset_chooser, 1, wx.ALIGN_CENTER_VERTICAL | wx.ALL, 10) + presets_sizer.Add(self.load_preset_button, 0, wx.ALIGN_CENTER_VERTICAL | wx.ALL, 10) + presets_sizer.Add(self.add_preset_button, 0, wx.ALIGN_CENTER_VERTICAL | wx.ALL, 10) + presets_sizer.Add(self.overwrite_preset_button, 0, wx.ALIGN_CENTER_VERTICAL | wx.ALL, 10) + presets_sizer.Add(self.delete_preset_button, 0, wx.ALIGN_CENTER_VERTICAL | wx.ALL, 10) + + self.SetSizerAndFit(presets_sizer) + self.Layout() + + @property + @cache + def suite_name(self): + try: + return self.parent.get_preset_suite_name() + "_presets" + except AttributeError: + return "presets" + + @cache + def presets_path(self): + try: + import appdirs + config_path = appdirs.user_config_dir('inkstitch') + except ImportError: + config_path = os.path.expanduser('~/.inkstitch') + + if not os.path.exists(config_path): + os.makedirs(config_path) + return os.path.join(config_path, '%s.json' % self.suite_name) + + def _load_presets(self): + try: + with open(self.presets_path(), 'r') as presets: + presets = json.load(presets) + return presets + except IOError: + return {} + + def _save_presets(self, presets): + with open(self.presets_path(), 'w') as presets_file: + json.dump(presets, presets_file) + + def update_preset_list(self): + preset_names = self._load_presets().keys() + preset_names = [preset for preset in preset_names if not self.is_hidden(preset)] + self.preset_chooser.SetItems(sorted(preset_names)) + + def is_hidden(self, preset_name): + return self.HIDDEN_PRESET_RE.match(preset_name) + + def get_preset_name(self): + preset_name = self.preset_chooser.GetValue().strip() + if preset_name: + return preset_name + else: + info_dialog(self, _("Please enter or select a preset name first."), caption=_('Preset')) + return + + def check_and_load_preset(self, preset_name): + preset = self._load_presets().get(preset_name) + if not preset: + info_dialog(self, _('Preset "%s" not found.') % preset_name, caption=_('Preset')) + + return preset + + def store_preset(self, preset_name, data): + presets = self._load_presets() + presets[preset_name] = data + self._save_presets(presets) + self.update_preset_list() + + def add_preset(self, event, overwrite=False): + preset_name = self.get_preset_name() + if not preset_name: + return + + if not overwrite and preset_name in self._load_presets(): + info_dialog(self, _('Preset "%s" already exists. Please use another name or press "Overwrite"') % preset_name, caption=_('Preset')) + + self.store_preset(self, preset_name, self.parent.get_preset_data()) + + event.Skip() + + def overwrite_preset(self, event): + self.add_preset(event, overwrite=True) + + def load_preset(self, preset_name): + preset = self.check_and_load_preset(preset_name) + if not preset: + return + + self.parent.apply_preset_data(preset) + + def load_selected_preset(self, event): + preset_name = self.get_preset_name() + if not preset_name: + return + + self.load_preset(preset_name) + + event.Skip() + + def delete_preset(self, event): + preset_name = self.get_preset_name() + if not preset_name: + return + + preset = self.check_and_load_preset(preset_name) + if not preset: + return + + presets = self._load_presets() + presets.pop(preset_name, None) + self._save_presets(presets) + + self.update_preset_list() + self.preset_chooser.SetValue("") + + event.Skip() diff --git a/lib/gui/simulator.py b/lib/gui/simulator.py new file mode 100644 index 00000000..0eed18c9 --- /dev/null +++ b/lib/gui/simulator.py @@ -0,0 +1,782 @@ +from itertools import izip +import sys +from threading import Thread, Event +import time +import traceback + +import wx +from wx.lib.intctrl import IntCtrl + +from ..i18n import _ +from ..stitch_plan import stitch_plan_from_file, patches_to_stitch_plan + +from ..svg import PIXELS_PER_MM + + +from .dialogs import info_dialog + + +# L10N command label at bottom of simulator window +COMMAND_NAMES = [_("STITCH"), _("JUMP"), _("TRIM"), _("STOP"), _("COLOR CHANGE")] + +STITCH = 0 +JUMP = 1 +TRIM = 2 +STOP = 3 +COLOR_CHANGE = 4 + + +class ControlPanel(wx.Panel): + """""" + + def __init__(self, parent, *args, **kwargs): + """""" + self.parent = parent + self.stitch_plan = kwargs.pop('stitch_plan') + self.target_stitches_per_second = kwargs.pop('stitches_per_second') + self.target_duration = kwargs.pop('target_duration') + kwargs['style'] = wx.BORDER_SUNKEN + wx.Panel.__init__(self, parent, *args, **kwargs) + + self.statusbar = self.GetTopLevelParent().statusbar + + self.drawing_panel = None + self.num_stitches = 1 + self.current_stitch = 1 + self.speed = 1 + self.direction = 1 + + # Widgets + self.btnMinus = wx.Button(self, -1, label='-') + self.btnMinus.Bind(wx.EVT_BUTTON, self.animation_slow_down) + self.btnMinus.SetToolTip(_('Slow down (arrow down)')) + self.btnPlus = wx.Button(self, -1, label='+') + self.btnPlus.Bind(wx.EVT_BUTTON, self.animation_speed_up) + self.btnPlus.SetToolTip(_('Speed up (arrow up)')) + self.btnBackwardStitch = wx.Button(self, -1, label='<|') + self.btnBackwardStitch.Bind(wx.EVT_BUTTON, self.animation_one_stitch_backward) + self.btnBackwardStitch.SetToolTip(_('Go on step backward (-)')) + self.btnForwardStitch = wx.Button(self, -1, label='|>') + self.btnForwardStitch.Bind(wx.EVT_BUTTON, self.animation_one_stitch_forward) + self.btnForwardStitch.SetToolTip(_('Go on step forward (+)')) + self.directionBtn = wx.Button(self, -1, label='<<') + self.directionBtn.Bind(wx.EVT_BUTTON, self.on_direction_button) + self.directionBtn.SetToolTip(_('Switch direction (arrow left | arrow right)')) + self.pauseBtn = wx.Button(self, -1, label=_('Pause')) + self.pauseBtn.Bind(wx.EVT_BUTTON, self.on_pause_start_button) + self.pauseBtn.SetToolTip(_('Pause (P)')) + self.restartBtn = wx.Button(self, -1, label=_('Restart')) + self.restartBtn.Bind(wx.EVT_BUTTON, self.animation_restart) + self.restartBtn.SetToolTip(_('Restart (R)')) + self.quitBtn = wx.Button(self, -1, label=_('Quit')) + self.quitBtn.Bind(wx.EVT_BUTTON, self.animation_quit) + self.quitBtn.SetToolTip(_('Quit (Q)')) + self.slider = wx.Slider(self, -1, value=1, minValue=1, maxValue=2, + style=wx.SL_HORIZONTAL | wx.SL_LABELS) + self.slider.Bind(wx.EVT_SLIDER, self.on_slider) + self.stitchBox = IntCtrl(self, -1, value=1, min=1, max=2, limited=True, allow_none=False) + self.stitchBox.Bind(wx.EVT_TEXT, self.on_stitch_box) + + # Layout + self.vbSizer = vbSizer = wx.BoxSizer(wx.VERTICAL) + self.hbSizer1 = hbSizer1 = wx.BoxSizer(wx.HORIZONTAL) + self.hbSizer2 = hbSizer2 = wx.BoxSizer(wx.HORIZONTAL) + hbSizer1.Add(self.slider, 1, wx.EXPAND | wx.ALL, 3) + hbSizer1.Add(self.stitchBox, 0, wx.ALL | wx.ALIGN_CENTER_VERTICAL, 2) + vbSizer.Add(hbSizer1, 1, wx.EXPAND | wx.ALL, 3) + hbSizer2.Add(self.btnMinus, 0, wx.EXPAND | wx.ALL, 2) + hbSizer2.Add(self.btnPlus, 0, wx.EXPAND | wx.ALL, 2) + hbSizer2.Add(self.btnBackwardStitch, 0, wx.EXPAND | wx.ALL, 2) + hbSizer2.Add(self.btnForwardStitch, 0, wx.EXPAND | wx.ALL, 2) + hbSizer2.Add(self.directionBtn, 0, wx.EXPAND | wx.ALL, 2) + hbSizer2.Add(self.pauseBtn, 0, wx.EXPAND | wx.ALL, 2) + hbSizer2.Add(self.restartBtn, 0, wx.EXPAND | wx.ALL, 2) + hbSizer2.Add(self.quitBtn, 0, wx.EXPAND | wx.ALL, 2) + vbSizer.Add(hbSizer2, 0, wx.EXPAND | wx.ALL, 3) + self.SetSizerAndFit(vbSizer) + + # Keyboard Shortcuts + shortcut_keys = [ + (wx.ACCEL_NORMAL, wx.WXK_RIGHT, self.animation_forward), + (wx.ACCEL_NORMAL, wx.WXK_NUMPAD_RIGHT, self.animation_forward), + (wx.ACCEL_NORMAL, wx.WXK_LEFT, self.animation_reverse), + (wx.ACCEL_NORMAL, wx.WXK_NUMPAD_LEFT, self.animation_reverse), + (wx.ACCEL_NORMAL, wx.WXK_UP, self.animation_speed_up), + (wx.ACCEL_NORMAL, wx.WXK_NUMPAD_UP, self.animation_speed_up), + (wx.ACCEL_NORMAL, wx.WXK_DOWN, self.animation_slow_down), + (wx.ACCEL_NORMAL, wx.WXK_NUMPAD_DOWN, self.animation_slow_down), + (wx.ACCEL_NORMAL, ord('+'), self.animation_one_stitch_forward), + (wx.ACCEL_NORMAL, ord('='), self.animation_one_stitch_forward), + (wx.ACCEL_SHIFT, ord('='), self.animation_one_stitch_forward), + (wx.ACCEL_NORMAL, wx.WXK_ADD, self.animation_one_stitch_forward), + (wx.ACCEL_NORMAL, wx.WXK_NUMPAD_ADD, self.animation_one_stitch_forward), + (wx.ACCEL_NORMAL, wx.WXK_NUMPAD_UP, self.animation_one_stitch_forward), + (wx.ACCEL_NORMAL, ord('-'), self.animation_one_stitch_backward), + (wx.ACCEL_NORMAL, ord('_'), self.animation_one_stitch_backward), + (wx.ACCEL_NORMAL, wx.WXK_SUBTRACT, self.animation_one_stitch_backward), + (wx.ACCEL_NORMAL, wx.WXK_NUMPAD_SUBTRACT, self.animation_one_stitch_backward), + (wx.ACCEL_NORMAL, ord('r'), self.animation_restart), + (wx.ACCEL_NORMAL, ord('p'), self.on_pause_start_button), + (wx.ACCEL_NORMAL, wx.WXK_SPACE, self.on_pause_start_button), + (wx.ACCEL_NORMAL, ord('q'), self.animation_quit)] + + accel_entries = [] + + for shortcut_key in shortcut_keys: + eventId = wx.NewId() + accel_entries.append((shortcut_key[0], shortcut_key[1], eventId)) + self.Bind(wx.EVT_MENU, shortcut_key[2], id=eventId) + + accel_table = wx.AcceleratorTable(accel_entries) + self.SetAcceleratorTable(accel_table) + self.SetFocus() + + def set_drawing_panel(self, drawing_panel): + self.drawing_panel = drawing_panel + self.drawing_panel.set_speed(self.speed) + + def set_num_stitches(self, num_stitches): + if num_stitches < 2: + # otherwise the slider and intctrl get mad + num_stitches = 2 + self.num_stitches = num_stitches + self.stitchBox.SetMax(num_stitches) + self.slider.SetMax(num_stitches) + self.choose_speed() + + def choose_speed(self): + if self.target_duration: + self.set_speed(int(self.num_stitches / float(self.target_duration))) + else: + self.set_speed(self.target_stitches_per_second) + + def animation_forward(self, event=None): + self.directionBtn.SetLabel("<<") + self.drawing_panel.forward() + self.direction = 1 + self.update_speed_text() + + def animation_reverse(self, event=None): + self.directionBtn.SetLabel(">>") + self.drawing_panel.reverse() + self.direction = -1 + self.update_speed_text() + + def on_direction_button(self, event): + if self.direction == 1: + self.animation_reverse() + else: + self.animation_forward() + + def set_speed(self, speed): + self.speed = int(max(speed, 1)) + self.update_speed_text() + + if self.drawing_panel: + self.drawing_panel.set_speed(self.speed) + + def update_speed_text(self): + self.statusbar.SetStatusText(_('Speed: %d stitches/sec') % (self.speed * self.direction), 0) + self.hbSizer2.Layout() + + def on_slider(self, event): + stitch = event.GetEventObject().GetValue() + self.stitchBox.SetValue(stitch) + + if self.drawing_panel: + self.drawing_panel.set_current_stitch(stitch) + + def on_current_stitch(self, stitch, command): + if self.current_stitch != stitch: + self.current_stitch = stitch + self.slider.SetValue(stitch) + self.stitchBox.SetValue(stitch) + self.statusbar.SetStatusText(COMMAND_NAMES[command], 1) + + def on_stitch_box(self, event): + stitch = self.stitchBox.GetValue() + self.slider.SetValue(stitch) + + if self.drawing_panel: + self.drawing_panel.set_current_stitch(stitch) + + def animation_slow_down(self, event): + """""" + self.set_speed(self.speed / 2.0) + + def animation_speed_up(self, event): + """""" + self.set_speed(self.speed * 2.0) + + def animation_pause(self, event=None): + self.drawing_panel.stop() + + def animation_start(self, event=None): + self.drawing_panel.go() + + def on_start(self): + self.pauseBtn.SetLabel(_('Pause')) + + def on_stop(self): + self.pauseBtn.SetLabel(_('Start')) + + def on_pause_start_button(self, event): + """""" + if self.pauseBtn.GetLabel() == _('Pause'): + self.animation_pause() + else: + self.animation_start() + + def animation_one_stitch_forward(self, event): + self.animation_pause() + self.drawing_panel.one_stitch_forward() + + def animation_one_stitch_backward(self, event): + self.animation_pause() + self.drawing_panel.one_stitch_backward() + + def animation_quit(self, event): + self.parent.quit() + + def animation_restart(self, event): + self.drawing_panel.restart() + + +class DrawingPanel(wx.Panel): + """""" + + # render no faster than this many frames per second + TARGET_FPS = 30 + + # It's not possible to specify a line thickness less than 1 pixel, even + # though we're drawing anti-aliased lines. To get around this we scale + # the stitch positions up by this factor and then scale down by a + # corresponding amount during rendering. + PIXEL_DENSITY = 10 + + # Line width in pixels. + LINE_THICKNESS = 0.4 + + def __init__(self, *args, **kwargs): + """""" + self.stitch_plan = kwargs.pop('stitch_plan') + self.control_panel = kwargs.pop('control_panel') + kwargs['style'] = wx.BORDER_SUNKEN + wx.Panel.__init__(self, *args, **kwargs) + + # Drawing panel can really be any size, but without this wxpython likes + # to allow the status bar and control panel to get squished. + self.SetMinSize((100, 100)) + self.SetBackgroundColour('#FFFFFF') + self.SetDoubleBuffered(True) + + self.animating = False + 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)) + self.width = 0 + self.height = 0 + self.loaded = False + + # desired simulation speed in stitches per second + self.speed = 16 + + self.Bind(wx.EVT_PAINT, self.OnPaint) + self.Bind(wx.EVT_SIZE, self.choose_zoom_and_pan) + self.Bind(wx.EVT_LEFT_DOWN, self.on_left_mouse_button_down) + self.Bind(wx.EVT_MOUSEWHEEL, self.on_mouse_wheel) + + # wait for layouts so that panel size is set + wx.CallLater(50, self.load, self.stitch_plan) + + def clamp_current_stitch(self): + if self.current_stitch < 1: + self.current_stitch = 1 + elif self.current_stitch > self.num_stitches: + self.current_stitch = self.num_stitches + + def stop_if_at_end(self): + if self.direction == -1 and self.current_stitch == 1: + self.stop() + elif self.direction == 1 and self.current_stitch == self.num_stitches: + self.stop() + + def start_if_not_at_end(self): + if self.direction == -1 and self.current_stitch > 1: + self.go() + elif self.direction == 1 and self.current_stitch < self.num_stitches: + self.go() + + def animate(self): + 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) + + stitch_increment = int(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): + if not self.loaded: + return + + dc = wx.PaintDC(self) + canvas = wx.GraphicsContext.Create(dc) + + transform = canvas.GetTransform() + transform.Translate(*self.pan) + transform.Scale(self.zoom / self.PIXEL_DENSITY, self.zoom / self.PIXEL_DENSITY) + canvas.SetTransform(transform) + + stitch = 0 + last_stitch = None + + start = time.time() + for pen, stitches in izip(self.pens, self.stitch_blocks): + canvas.SetPen(pen) + if stitch + len(stitches) < self.current_stitch: + stitch += len(stitches) + if len(stitches) > 1: + canvas.DrawLines(stitches) + last_stitch = stitches[-1] + else: + stitches = stitches[:self.current_stitch - stitch] + if len(stitches) > 1: + canvas.DrawLines(stitches) + last_stitch = stitches[-1] + break + self.last_frame_duration = time.time() - start + + if last_stitch: + x = last_stitch[0] + y = last_stitch[1] + x, y = transform.TransformPoint(float(x), float(y)) + canvas.SetTransform(canvas.CreateMatrix()) + crosshair_radius = 10 + canvas.SetPen(self.black_pen) + canvas.DrawLines(((x - crosshair_radius, y), (x + crosshair_radius, y))) + canvas.DrawLines(((x, y - crosshair_radius), (x, y + crosshair_radius))) + + def clear(self): + dc = wx.ClientDC(self) + dc.Clear() + + def load(self, stitch_plan): + self.current_stitch = 1 + self.direction = 1 + self.last_frame_duration = 0 + self.num_stitches = stitch_plan.num_stitches + self.control_panel.set_num_stitches(self.num_stitches) + self.minx, self.miny, self.maxx, self.maxy = stitch_plan.bounding_box + self.width = self.maxx - self.minx + self.height = self.maxy - self.miny + self.parse_stitch_plan(stitch_plan) + self.choose_zoom_and_pan() + self.set_current_stitch(0) + self.loaded = True + self.go() + + def choose_zoom_and_pan(self, event=None): + # ignore if called before we load the stitch plan + if not self.width and not self.height: + return + + panel_width, panel_height = self.GetClientSize() + + # add some padding to make stitches at the edge more visible + width_ratio = panel_width / float(self.width + 10) + height_ratio = panel_height / float(self.height + 10) + self.zoom = min(width_ratio, height_ratio) + + # center the design + self.pan = ((panel_width - self.zoom * self.width) / 2.0, + (panel_height - self.zoom * self.height) / 2.0) + + def stop(self): + self.animating = False + self.control_panel.on_stop() + + def go(self): + if not self.loaded: + return + + if not self.animating: + self.animating = True + self.animate() + self.control_panel.on_start() + + def color_to_pen(self, color): + # We draw the thread with a thickness of 0.1mm. Real thread has a + # thickness of ~0.4mm, but if we did that, we wouldn't be able to + # see the individual stitches. + return wx.Pen(color.visible_on_white.rgb, width=int(0.1 * PIXELS_PER_MM * self.PIXEL_DENSITY)) + + def parse_stitch_plan(self, stitch_plan): + self.pens = [] + self.stitch_blocks = [] + + # There is no 0th stitch, so add a place-holder. + self.commands = [None] + + for color_block in stitch_plan: + pen = self.color_to_pen(color_block.color) + stitch_block = [] + + for stitch in color_block: + # trim any whitespace on the left and top and scale to the + # pixel density + stitch_block.append((self.PIXEL_DENSITY * (stitch.x - self.minx), + self.PIXEL_DENSITY * (stitch.y - self.miny))) + + if stitch.trim: + self.commands.append(TRIM) + elif stitch.jump: + self.commands.append(JUMP) + elif stitch.stop: + self.commands.append(STOP) + elif stitch.color_change: + self.commands.append(COLOR_CHANGE) + else: + self.commands.append(STITCH) + + if stitch.trim or stitch.stop or stitch.color_change: + self.pens.append(pen) + self.stitch_blocks.append(stitch_block) + stitch_block = [] + + if stitch_block: + self.pens.append(pen) + self.stitch_blocks.append(stitch_block) + + def set_speed(self, speed): + self.speed = speed + + def forward(self): + self.direction = 1 + self.start_if_not_at_end() + + def reverse(self): + self.direction = -1 + self.start_if_not_at_end() + + def set_current_stitch(self, stitch): + self.current_stitch = stitch + self.clamp_current_stitch() + self.control_panel.on_current_stitch(self.current_stitch, self.commands[self.current_stitch]) + self.stop_if_at_end() + self.Refresh() + + def restart(self): + if self.direction == 1: + self.current_stitch = 1 + elif self.direction == -1: + self.current_stitch = self.num_stitches + + self.go() + + def one_stitch_forward(self): + self.set_current_stitch(self.current_stitch + 1) + + def one_stitch_backward(self): + self.set_current_stitch(self.current_stitch - 1) + + def on_left_mouse_button_down(self, event): + self.CaptureMouse() + self.drag_start = event.GetPosition() + self.drag_original_pan = self.pan + self.Bind(wx.EVT_MOTION, self.on_drag) + self.Bind(wx.EVT_MOUSE_CAPTURE_LOST, self.on_drag_end) + self.Bind(wx.EVT_LEFT_UP, self.on_drag_end) + + def on_drag(self, event): + if self.HasCapture() and event.Dragging(): + delta = event.GetPosition() + offset = (delta[0] - self.drag_start[0], delta[1] - self.drag_start[1]) + self.pan = (self.drag_original_pan[0] + offset[0], self.drag_original_pan[1] + offset[1]) + self.Refresh() + + def on_drag_end(self, event): + if self.HasCapture(): + self.ReleaseMouse() + + self.Unbind(wx.EVT_MOTION) + self.Unbind(wx.EVT_MOUSE_CAPTURE_LOST) + self.Unbind(wx.EVT_LEFT_UP) + + def on_mouse_wheel(self, event): + if event.GetWheelRotation() > 0: + zoom_delta = 1.03 + else: + zoom_delta = 0.97 + + # If we just change the zoom, the design will appear to move on the + # screen. We have to adjust the pan to compensate. We want to keep + # the part of the design under the mouse pointer in the same spot + # after we zoom, so that we appar to be zooming centered on the + # mouse pointer. + + # This will create a matrix that takes a point in the design and + # converts it to screen coordinates: + matrix = wx.AffineMatrix2D() + matrix.Translate(*self.pan) + matrix.Scale(self.zoom, self.zoom) + + # First, figure out where the mouse pointer is in the coordinate system + # of the design: + pos = event.GetPosition() + inverse_matrix = wx.AffineMatrix2D() + inverse_matrix.Set(*matrix.Get()) + inverse_matrix.Invert() + pos = inverse_matrix.TransformPoint(*pos) + + # Next, see how that point changes position on screen before and after + # we apply the zoom change: + x_old, y_old = matrix.TransformPoint(*pos) + matrix.Scale(zoom_delta, zoom_delta) + x_new, y_new = matrix.TransformPoint(*pos) + x_delta = x_new - x_old + y_delta = y_new - y_old + + # Finally, compensate for that change in position: + self.pan = (self.pan[0] - x_delta, self.pan[1] - y_delta) + + self.zoom *= zoom_delta + + self.Refresh() + + +class SimulatorPanel(wx.Panel): + """""" + + def __init__(self, parent, *args, **kwargs): + """""" + self.parent = parent + stitch_plan = kwargs.pop('stitch_plan') + target_duration = kwargs.pop('target_duration') + stitches_per_second = kwargs.pop('stitches_per_second') + kwargs['style'] = wx.BORDER_SUNKEN + wx.Panel.__init__(self, parent, *args, **kwargs) + + self.cp = ControlPanel(self, + stitch_plan=stitch_plan, + stitches_per_second=stitches_per_second, + target_duration=target_duration) + self.dp = DrawingPanel(self, stitch_plan=stitch_plan, control_panel=self.cp) + self.cp.set_drawing_panel(self.dp) + + vbSizer = wx.BoxSizer(wx.VERTICAL) + vbSizer.Add(self.dp, 1, wx.EXPAND | wx.ALL, 2) + vbSizer.Add(self.cp, 0, wx.EXPAND | wx.ALL, 2) + self.SetSizerAndFit(vbSizer) + + def quit(self): + self.parent.quit() + + def go(self): + self.dp.go() + + def stop(self): + self.dp.stop() + + def load(self, stitch_plan): + self.dp.load(stitch_plan) + + def clear(self): + self.dp.clear() + + +class EmbroiderySimulator(wx.Frame): + def __init__(self, *args, **kwargs): + self.on_close_hook = kwargs.pop('on_close', None) + stitch_plan = kwargs.pop('stitch_plan', None) + stitches_per_second = kwargs.pop('stitches_per_second', 16) + target_duration = kwargs.pop('target_duration', None) + size = kwargs.get('size', (0, 0)) + wx.Frame.__init__(self, *args, **kwargs) + self.statusbar = self.CreateStatusBar(2) + self.statusbar.SetStatusWidths([250, -1]) + + sizer = wx.BoxSizer(wx.HORIZONTAL) + self.simulator_panel = SimulatorPanel(self, + stitch_plan=stitch_plan, + target_duration=target_duration, + stitches_per_second=stitches_per_second) + sizer.Add(self.simulator_panel, 1, wx.EXPAND) + + # self.SetSizerAndFit() sets the minimum size so that the buttons don't + # get squished. But it then also shrinks the window down to that size. + self.SetSizerAndFit(sizer) + + # Therefore we have to reapply the size that the caller asked for. + self.SetSize(size) + + self.Bind(wx.EVT_CLOSE, self.on_close) + + def quit(self): + self.Close() + + def on_close(self, event): + self.simulator_panel.stop() + + if self.on_close_hook: + self.on_close_hook() + + self.Destroy() + + def go(self): + self.simulator_panel.go() + + def stop(self): + self.simulator_panel.stop() + + def load(self, stitch_plan): + self.simulator_panel.load(stitch_plan) + + def clear(self): + self.simulator_panel.clear() + + +class SimulatorPreview(Thread): + """Manages a preview simulation and a background thread for generating patches.""" + + def __init__(self, parent, *args, **kwargs): + """Construct a SimulatorPreview. + + The parent is expected to be a wx.Window and also implement the following methods: + + def generate_patches(self, abort_event): + Produce an list of Patch instances. This method will be + invoked in a background thread and it is expected that it may + take awhile. + + If possible, this method should periodically check + abort_event.is_set(), and if True, stop early. The return + value will be ignored in this case. + """ + self.parent = parent + self.target_duration = kwargs.pop('target_duration', 5) + super(SimulatorPreview, self).__init__(*args, **kwargs) + self.daemon = True + + self.simulate_window = None + self.refresh_needed = Event() + + # used when closing to avoid having the window reopen at the last second + self._disabled = False + + wx.CallLater(1000, self.update) + + def disable(self): + self._disabled = True + + def update(self): + """Request an update of the simulator preview with freshly-generated patches.""" + + if self.simulate_window: + self.simulate_window.stop() + self.simulate_window.clear() + + if self._disabled: + return + + if not self.is_alive(): + self.start() + + self.refresh_needed.set() + + def run(self): + while True: + self.refresh_needed.wait() + self.refresh_needed.clear() + self.update_patches() + + def update_patches(self): + patches = self.parent.generate_patches(self.refresh_needed) + + if patches and not self.refresh_needed.is_set(): + stitch_plan = patches_to_stitch_plan(patches) + + # GUI stuff needs to happen in the main thread, so we ask the main + # thread to call refresh_simulator(). + wx.CallAfter(self.refresh_simulator, patches, stitch_plan) + + def refresh_simulator(self, patches, stitch_plan): + if self.simulate_window: + self.simulate_window.stop() + self.simulate_window.load(stitch_plan) + else: + params_rect = self.parent.GetScreenRect() + simulator_pos = params_rect.GetTopRight() + simulator_pos.x += 5 + + current_screen = wx.Display.GetFromPoint(wx.GetMousePosition()) + display = wx.Display(current_screen) + screen_rect = display.GetClientArea() + simulator_pos.y = screen_rect.GetTop() + + width = screen_rect.GetWidth() - params_rect.GetWidth() + height = screen_rect.GetHeight() + + try: + self.simulate_window = EmbroiderySimulator(None, -1, _("Preview"), + simulator_pos, + size=(width, height), + stitch_plan=stitch_plan, + on_close=self.simulate_window_closed, + target_duration=self.target_duration) + except Exception: + error = traceback.format_exc() + + try: + # a window may have been created, so we need to destroy it + # or the app will never exit + wx.Window.FindWindowByName(_("Preview")).Destroy() + except Exception: + pass + + info_dialog(self, error, _("Internal Error")) + + self.simulate_window.Show() + wx.CallLater(10, self.parent.Raise) + + wx.CallAfter(self.simulate_window.go) + + def simulate_window_closed(self): + self.simulate_window = None + + def close(self): + self.disable() + if self.simulate_window: + self.simulate_window.stop() + self.simulate_window.Close() + + +def show_simulator(stitch_plan): + app = wx.App() + current_screen = wx.Display.GetFromPoint(wx.GetMousePosition()) + display = wx.Display(current_screen) + screen_rect = display.GetClientArea() + + simulator_pos = (screen_rect[0], screen_rect[1]) + + # subtract 1 because otherwise the window becomes maximized on Linux + width = screen_rect[2] - 1 + height = screen_rect[3] - 1 + + frame = EmbroiderySimulator(None, -1, _("Embroidery Simulation"), pos=simulator_pos, size=(width, height), stitch_plan=stitch_plan) + app.SetTopWindow(frame) + frame.Show() + app.MainLoop() + + +if __name__ == "__main__": + stitch_plan = stitch_plan_from_file(sys.argv[1]) + show_simulator(stitch_plan) diff --git a/lib/lettering/font.py b/lib/lettering/font.py index 4a89df47..9d0389a0 100644 --- a/lib/lettering/font.py +++ b/lib/lettering/font.py @@ -106,7 +106,7 @@ class Font(object): position.x = 0 position.y += self.leading - if self.auto_satin: + if self.auto_satin and len(line_group) > 0: self._apply_auto_satin(line_group) return line_group diff --git a/lib/lettering/font_variant.py b/lib/lettering/font_variant.py index 445946e2..d826dca6 100644 --- a/lib/lettering/font_variant.py +++ b/lib/lettering/font_variant.py @@ -53,7 +53,8 @@ class FontVariant(object): def _load_glyphs(self): svg_path = os.path.join(self.path, u"%s.svg" % self.variant) - svg = inkex.etree.parse(svg_path) + with open(svg_path) as svg_file: + svg = inkex.etree.parse(svg_file) glyph_layers = svg.xpath(".//svg:g[starts-with(@inkscape:label, 'GlyphLayer-')]", namespaces=inkex.NSS) for layer in glyph_layers: diff --git a/lib/simulator.py b/lib/simulator.py deleted file mode 100644 index 34d12887..00000000 --- a/lib/simulator.py +++ /dev/null @@ -1,659 +0,0 @@ -import sys -import wx -from wx.lib.intctrl import IntCtrl -import time -from itertools import izip - -from .svg import PIXELS_PER_MM -from .i18n import _ -from .stitch_plan import stitch_plan_from_file - -# L10N command label at bottom of simulator window -COMMAND_NAMES = [_("STITCH"), _("JUMP"), _("TRIM"), _("STOP"), _("COLOR CHANGE")] - -STITCH = 0 -JUMP = 1 -TRIM = 2 -STOP = 3 -COLOR_CHANGE = 4 - - -class ControlPanel(wx.Panel): - """""" - - def __init__(self, parent, *args, **kwargs): - """""" - self.parent = parent - self.stitch_plan = kwargs.pop('stitch_plan') - self.target_stitches_per_second = kwargs.pop('stitches_per_second') - self.target_duration = kwargs.pop('target_duration') - kwargs['style'] = wx.BORDER_SUNKEN - wx.Panel.__init__(self, parent, *args, **kwargs) - - self.statusbar = self.GetTopLevelParent().statusbar - - self.drawing_panel = None - self.num_stitches = 1 - self.current_stitch = 1 - self.speed = 1 - self.direction = 1 - - # Widgets - self.btnMinus = wx.Button(self, -1, label='-') - self.btnMinus.Bind(wx.EVT_BUTTON, self.animation_slow_down) - self.btnMinus.SetToolTip(_('Slow down (arrow down)')) - self.btnPlus = wx.Button(self, -1, label='+') - self.btnPlus.Bind(wx.EVT_BUTTON, self.animation_speed_up) - self.btnPlus.SetToolTip(_('Speed up (arrow up)')) - self.btnBackwardStitch = wx.Button(self, -1, label='<|') - self.btnBackwardStitch.Bind(wx.EVT_BUTTON, self.animation_one_stitch_backward) - self.btnBackwardStitch.SetToolTip(_('Go on step backward (-)')) - self.btnForwardStitch = wx.Button(self, -1, label='|>') - self.btnForwardStitch.Bind(wx.EVT_BUTTON, self.animation_one_stitch_forward) - self.btnForwardStitch.SetToolTip(_('Go on step forward (+)')) - self.directionBtn = wx.Button(self, -1, label='<<') - self.directionBtn.Bind(wx.EVT_BUTTON, self.on_direction_button) - self.directionBtn.SetToolTip(_('Switch direction (arrow left | arrow right)')) - self.pauseBtn = wx.Button(self, -1, label=_('Pause')) - self.pauseBtn.Bind(wx.EVT_BUTTON, self.on_pause_start_button) - self.pauseBtn.SetToolTip(_('Pause (P)')) - self.restartBtn = wx.Button(self, -1, label=_('Restart')) - self.restartBtn.Bind(wx.EVT_BUTTON, self.animation_restart) - self.restartBtn.SetToolTip(_('Restart (R)')) - self.quitBtn = wx.Button(self, -1, label=_('Quit')) - self.quitBtn.Bind(wx.EVT_BUTTON, self.animation_quit) - self.quitBtn.SetToolTip(_('Quit (Q)')) - self.slider = wx.Slider(self, -1, value=1, minValue=1, maxValue=2, - style=wx.SL_HORIZONTAL | wx.SL_LABELS) - self.slider.Bind(wx.EVT_SLIDER, self.on_slider) - self.stitchBox = IntCtrl(self, -1, value=1, min=1, max=2, limited=True, allow_none=False) - self.stitchBox.Bind(wx.EVT_TEXT, self.on_stitch_box) - - # Layout - self.vbSizer = vbSizer = wx.BoxSizer(wx.VERTICAL) - self.hbSizer1 = hbSizer1 = wx.BoxSizer(wx.HORIZONTAL) - self.hbSizer2 = hbSizer2 = wx.BoxSizer(wx.HORIZONTAL) - hbSizer1.Add(self.slider, 1, wx.EXPAND | wx.ALL, 3) - hbSizer1.Add(self.stitchBox, 0, wx.ALL | wx.ALIGN_CENTER_VERTICAL, 2) - vbSizer.Add(hbSizer1, 1, wx.EXPAND | wx.ALL, 3) - hbSizer2.Add(self.btnMinus, 0, wx.EXPAND | wx.ALL, 2) - hbSizer2.Add(self.btnPlus, 0, wx.EXPAND | wx.ALL, 2) - hbSizer2.Add(self.btnBackwardStitch, 0, wx.EXPAND | wx.ALL, 2) - hbSizer2.Add(self.btnForwardStitch, 0, wx.EXPAND | wx.ALL, 2) - hbSizer2.Add(self.directionBtn, 0, wx.EXPAND | wx.ALL, 2) - hbSizer2.Add(self.pauseBtn, 0, wx.EXPAND | wx.ALL, 2) - hbSizer2.Add(self.restartBtn, 0, wx.EXPAND | wx.ALL, 2) - hbSizer2.Add(self.quitBtn, 0, wx.EXPAND | wx.ALL, 2) - vbSizer.Add(hbSizer2, 0, wx.EXPAND | wx.ALL, 3) - self.SetSizerAndFit(vbSizer) - - # Keyboard Shortcuts - shortcut_keys = [ - (wx.ACCEL_NORMAL, wx.WXK_RIGHT, self.animation_forward), - (wx.ACCEL_NORMAL, wx.WXK_NUMPAD_RIGHT, self.animation_forward), - (wx.ACCEL_NORMAL, wx.WXK_LEFT, self.animation_reverse), - (wx.ACCEL_NORMAL, wx.WXK_NUMPAD_LEFT, self.animation_reverse), - (wx.ACCEL_NORMAL, wx.WXK_UP, self.animation_speed_up), - (wx.ACCEL_NORMAL, wx.WXK_NUMPAD_UP, self.animation_speed_up), - (wx.ACCEL_NORMAL, wx.WXK_DOWN, self.animation_slow_down), - (wx.ACCEL_NORMAL, wx.WXK_NUMPAD_DOWN, self.animation_slow_down), - (wx.ACCEL_NORMAL, ord('+'), self.animation_one_stitch_forward), - (wx.ACCEL_NORMAL, ord('='), self.animation_one_stitch_forward), - (wx.ACCEL_SHIFT, ord('='), self.animation_one_stitch_forward), - (wx.ACCEL_NORMAL, wx.WXK_ADD, self.animation_one_stitch_forward), - (wx.ACCEL_NORMAL, wx.WXK_NUMPAD_ADD, self.animation_one_stitch_forward), - (wx.ACCEL_NORMAL, wx.WXK_NUMPAD_UP, self.animation_one_stitch_forward), - (wx.ACCEL_NORMAL, ord('-'), self.animation_one_stitch_backward), - (wx.ACCEL_NORMAL, ord('_'), self.animation_one_stitch_backward), - (wx.ACCEL_NORMAL, wx.WXK_SUBTRACT, self.animation_one_stitch_backward), - (wx.ACCEL_NORMAL, wx.WXK_NUMPAD_SUBTRACT, self.animation_one_stitch_backward), - (wx.ACCEL_NORMAL, ord('r'), self.animation_restart), - (wx.ACCEL_NORMAL, ord('p'), self.on_pause_start_button), - (wx.ACCEL_NORMAL, wx.WXK_SPACE, self.on_pause_start_button), - (wx.ACCEL_NORMAL, ord('q'), self.animation_quit)] - - accel_entries = [] - - for shortcut_key in shortcut_keys: - eventId = wx.NewId() - accel_entries.append((shortcut_key[0], shortcut_key[1], eventId)) - self.Bind(wx.EVT_MENU, shortcut_key[2], id=eventId) - - accel_table = wx.AcceleratorTable(accel_entries) - self.SetAcceleratorTable(accel_table) - self.SetFocus() - - def set_drawing_panel(self, drawing_panel): - self.drawing_panel = drawing_panel - self.drawing_panel.set_speed(self.speed) - - def set_num_stitches(self, num_stitches): - if num_stitches < 2: - # otherwise the slider and intctrl get mad - num_stitches = 2 - self.num_stitches = num_stitches - self.stitchBox.SetMax(num_stitches) - self.slider.SetMax(num_stitches) - self.choose_speed() - - def choose_speed(self): - if self.target_duration: - self.set_speed(int(self.num_stitches / float(self.target_duration))) - else: - self.set_speed(self.target_stitches_per_second) - - def animation_forward(self, event=None): - self.directionBtn.SetLabel("<<") - self.drawing_panel.forward() - self.direction = 1 - self.update_speed_text() - - def animation_reverse(self, event=None): - self.directionBtn.SetLabel(">>") - self.drawing_panel.reverse() - self.direction = -1 - self.update_speed_text() - - def on_direction_button(self, event): - if self.direction == 1: - self.animation_reverse() - else: - self.animation_forward() - - def set_speed(self, speed): - self.speed = int(max(speed, 1)) - self.update_speed_text() - - if self.drawing_panel: - self.drawing_panel.set_speed(self.speed) - - def update_speed_text(self): - self.statusbar.SetStatusText(_('Speed: %d stitches/sec') % (self.speed * self.direction), 0) - self.hbSizer2.Layout() - - def on_slider(self, event): - stitch = event.GetEventObject().GetValue() - self.stitchBox.SetValue(stitch) - - if self.drawing_panel: - self.drawing_panel.set_current_stitch(stitch) - - def on_current_stitch(self, stitch, command): - if self.current_stitch != stitch: - self.current_stitch = stitch - self.slider.SetValue(stitch) - self.stitchBox.SetValue(stitch) - self.statusbar.SetStatusText(COMMAND_NAMES[command], 1) - - def on_stitch_box(self, event): - stitch = self.stitchBox.GetValue() - self.slider.SetValue(stitch) - - if self.drawing_panel: - self.drawing_panel.set_current_stitch(stitch) - - def animation_slow_down(self, event): - """""" - self.set_speed(self.speed / 2.0) - - def animation_speed_up(self, event): - """""" - self.set_speed(self.speed * 2.0) - - def animation_pause(self, event=None): - self.drawing_panel.stop() - - def animation_start(self, event=None): - self.drawing_panel.go() - - def on_start(self): - self.pauseBtn.SetLabel(_('Pause')) - - def on_stop(self): - self.pauseBtn.SetLabel(_('Start')) - - def on_pause_start_button(self, event): - """""" - if self.pauseBtn.GetLabel() == _('Pause'): - self.animation_pause() - else: - self.animation_start() - - def animation_one_stitch_forward(self, event): - self.animation_pause() - self.drawing_panel.one_stitch_forward() - - def animation_one_stitch_backward(self, event): - self.animation_pause() - self.drawing_panel.one_stitch_backward() - - def animation_quit(self, event): - self.parent.quit() - - def animation_restart(self, event): - self.drawing_panel.restart() - - -class DrawingPanel(wx.Panel): - """""" - - # render no faster than this many frames per second - TARGET_FPS = 30 - - # It's not possible to specify a line thickness less than 1 pixel, even - # though we're drawing anti-aliased lines. To get around this we scale - # the stitch positions up by this factor and then scale down by a - # corresponding amount during rendering. - PIXEL_DENSITY = 10 - - # Line width in pixels. - LINE_THICKNESS = 0.4 - - def __init__(self, *args, **kwargs): - """""" - self.stitch_plan = kwargs.pop('stitch_plan') - self.control_panel = kwargs.pop('control_panel') - kwargs['style'] = wx.BORDER_SUNKEN - wx.Panel.__init__(self, *args, **kwargs) - - # Drawing panel can really be any size, but without this wxpython likes - # to allow the status bar and control panel to get squished. - self.SetMinSize((100, 100)) - self.SetBackgroundColour('#FFFFFF') - self.SetDoubleBuffered(True) - - self.animating = False - 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)) - self.width = 0 - self.height = 0 - self.loaded = False - - # desired simulation speed in stitches per second - self.speed = 16 - - self.Bind(wx.EVT_PAINT, self.OnPaint) - self.Bind(wx.EVT_SIZE, self.choose_zoom_and_pan) - self.Bind(wx.EVT_LEFT_DOWN, self.on_left_mouse_button_down) - self.Bind(wx.EVT_MOUSEWHEEL, self.on_mouse_wheel) - - # wait for layouts so that panel size is set - wx.CallLater(50, self.load, self.stitch_plan) - - def clamp_current_stitch(self): - if self.current_stitch < 1: - self.current_stitch = 1 - elif self.current_stitch > self.num_stitches: - self.current_stitch = self.num_stitches - - def stop_if_at_end(self): - if self.direction == -1 and self.current_stitch == 1: - self.stop() - elif self.direction == 1 and self.current_stitch == self.num_stitches: - self.stop() - - def start_if_not_at_end(self): - if self.direction == -1 and self.current_stitch > 1: - self.go() - elif self.direction == 1 and self.current_stitch < self.num_stitches: - self.go() - - def animate(self): - 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) - - stitch_increment = int(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): - if not self.loaded: - return - - dc = wx.PaintDC(self) - canvas = wx.GraphicsContext.Create(dc) - - transform = canvas.GetTransform() - transform.Translate(*self.pan) - transform.Scale(self.zoom / self.PIXEL_DENSITY, self.zoom / self.PIXEL_DENSITY) - canvas.SetTransform(transform) - - stitch = 0 - last_stitch = None - - start = time.time() - for pen, stitches in izip(self.pens, self.stitch_blocks): - canvas.SetPen(pen) - if stitch + len(stitches) < self.current_stitch: - stitch += len(stitches) - if len(stitches) > 1: - canvas.DrawLines(stitches) - last_stitch = stitches[-1] - else: - stitches = stitches[:self.current_stitch - stitch] - if len(stitches) > 1: - canvas.DrawLines(stitches) - last_stitch = stitches[-1] - break - self.last_frame_duration = time.time() - start - - if last_stitch: - x = last_stitch[0] - y = last_stitch[1] - x, y = transform.TransformPoint(float(x), float(y)) - canvas.SetTransform(canvas.CreateMatrix()) - crosshair_radius = 10 - canvas.SetPen(self.black_pen) - canvas.DrawLines(((x - crosshair_radius, y), (x + crosshair_radius, y))) - canvas.DrawLines(((x, y - crosshair_radius), (x, y + crosshair_radius))) - - def clear(self): - dc = wx.ClientDC(self) - dc.Clear() - - def load(self, stitch_plan): - self.current_stitch = 1 - self.direction = 1 - self.last_frame_duration = 0 - self.num_stitches = stitch_plan.num_stitches - self.control_panel.set_num_stitches(self.num_stitches) - self.minx, self.miny, self.maxx, self.maxy = stitch_plan.bounding_box - self.width = self.maxx - self.minx - self.height = self.maxy - self.miny - self.parse_stitch_plan(stitch_plan) - self.choose_zoom_and_pan() - self.set_current_stitch(0) - self.loaded = True - self.go() - - def choose_zoom_and_pan(self, event=None): - # ignore if called before we load the stitch plan - if not self.width and not self.height: - return - - panel_width, panel_height = self.GetClientSize() - - # add some padding to make stitches at the edge more visible - width_ratio = panel_width / float(self.width + 10) - height_ratio = panel_height / float(self.height + 10) - self.zoom = min(width_ratio, height_ratio) - - # center the design - self.pan = ((panel_width - self.zoom * self.width) / 2.0, - (panel_height - self.zoom * self.height) / 2.0) - - def stop(self): - self.animating = False - self.control_panel.on_stop() - - def go(self): - if not self.loaded: - return - - if not self.animating: - self.animating = True - self.animate() - self.control_panel.on_start() - - def color_to_pen(self, color): - # We draw the thread with a thickness of 0.1mm. Real thread has a - # thickness of ~0.4mm, but if we did that, we wouldn't be able to - # see the individual stitches. - return wx.Pen(color.visible_on_white.rgb, width=int(0.1 * PIXELS_PER_MM * self.PIXEL_DENSITY)) - - def parse_stitch_plan(self, stitch_plan): - self.pens = [] - self.stitch_blocks = [] - - # There is no 0th stitch, so add a place-holder. - self.commands = [None] - - for color_block in stitch_plan: - pen = self.color_to_pen(color_block.color) - stitch_block = [] - - for stitch in color_block: - # trim any whitespace on the left and top and scale to the - # pixel density - stitch_block.append((self.PIXEL_DENSITY * (stitch.x - self.minx), - self.PIXEL_DENSITY * (stitch.y - self.miny))) - - if stitch.trim: - self.commands.append(TRIM) - elif stitch.jump: - self.commands.append(JUMP) - elif stitch.stop: - self.commands.append(STOP) - elif stitch.color_change: - self.commands.append(COLOR_CHANGE) - else: - self.commands.append(STITCH) - - if stitch.trim or stitch.stop or stitch.color_change: - self.pens.append(pen) - self.stitch_blocks.append(stitch_block) - stitch_block = [] - - if stitch_block: - self.pens.append(pen) - self.stitch_blocks.append(stitch_block) - - def set_speed(self, speed): - self.speed = speed - - def forward(self): - self.direction = 1 - self.start_if_not_at_end() - - def reverse(self): - self.direction = -1 - self.start_if_not_at_end() - - def set_current_stitch(self, stitch): - self.current_stitch = stitch - self.clamp_current_stitch() - self.control_panel.on_current_stitch(self.current_stitch, self.commands[self.current_stitch]) - self.stop_if_at_end() - self.Refresh() - - def restart(self): - if self.direction == 1: - self.current_stitch = 1 - elif self.direction == -1: - self.current_stitch = self.num_stitches - - self.go() - - def one_stitch_forward(self): - self.set_current_stitch(self.current_stitch + 1) - - def one_stitch_backward(self): - self.set_current_stitch(self.current_stitch - 1) - - def on_left_mouse_button_down(self, event): - self.CaptureMouse() - self.drag_start = event.GetPosition() - self.drag_original_pan = self.pan - self.Bind(wx.EVT_MOTION, self.on_drag) - self.Bind(wx.EVT_MOUSE_CAPTURE_LOST, self.on_drag_end) - self.Bind(wx.EVT_LEFT_UP, self.on_drag_end) - - def on_drag(self, event): - if self.HasCapture() and event.Dragging(): - delta = event.GetPosition() - offset = (delta[0] - self.drag_start[0], delta[1] - self.drag_start[1]) - self.pan = (self.drag_original_pan[0] + offset[0], self.drag_original_pan[1] + offset[1]) - self.Refresh() - - def on_drag_end(self, event): - if self.HasCapture(): - self.ReleaseMouse() - - self.Unbind(wx.EVT_MOTION) - self.Unbind(wx.EVT_MOUSE_CAPTURE_LOST) - self.Unbind(wx.EVT_LEFT_UP) - - def on_mouse_wheel(self, event): - if event.GetWheelRotation() > 0: - zoom_delta = 1.03 - else: - zoom_delta = 0.97 - - # If we just change the zoom, the design will appear to move on the - # screen. We have to adjust the pan to compensate. We want to keep - # the part of the design under the mouse pointer in the same spot - # after we zoom, so that we appar to be zooming centered on the - # mouse pointer. - - # This will create a matrix that takes a point in the design and - # converts it to screen coordinates: - matrix = wx.AffineMatrix2D() - matrix.Translate(*self.pan) - matrix.Scale(self.zoom, self.zoom) - - # First, figure out where the mouse pointer is in the coordinate system - # of the design: - pos = event.GetPosition() - inverse_matrix = wx.AffineMatrix2D() - inverse_matrix.Set(*matrix.Get()) - inverse_matrix.Invert() - pos = inverse_matrix.TransformPoint(*pos) - - # Next, see how that point changes position on screen before and after - # we apply the zoom change: - x_old, y_old = matrix.TransformPoint(*pos) - matrix.Scale(zoom_delta, zoom_delta) - x_new, y_new = matrix.TransformPoint(*pos) - x_delta = x_new - x_old - y_delta = y_new - y_old - - # Finally, compensate for that change in position: - self.pan = (self.pan[0] - x_delta, self.pan[1] - y_delta) - - self.zoom *= zoom_delta - - self.Refresh() - - -class SimulatorPanel(wx.Panel): - """""" - - def __init__(self, parent, *args, **kwargs): - """""" - self.parent = parent - stitch_plan = kwargs.pop('stitch_plan') - target_duration = kwargs.pop('target_duration') - stitches_per_second = kwargs.pop('stitches_per_second') - kwargs['style'] = wx.BORDER_SUNKEN - wx.Panel.__init__(self, parent, *args, **kwargs) - - self.cp = ControlPanel(self, - stitch_plan=stitch_plan, - stitches_per_second=stitches_per_second, - target_duration=target_duration) - self.dp = DrawingPanel(self, stitch_plan=stitch_plan, control_panel=self.cp) - self.cp.set_drawing_panel(self.dp) - - vbSizer = wx.BoxSizer(wx.VERTICAL) - vbSizer.Add(self.dp, 1, wx.EXPAND | wx.ALL, 2) - vbSizer.Add(self.cp, 0, wx.EXPAND | wx.ALL, 2) - self.SetSizerAndFit(vbSizer) - - def quit(self): - self.parent.quit() - - def go(self): - self.dp.go() - - def stop(self): - self.dp.stop() - - def load(self, stitch_plan): - self.dp.load(stitch_plan) - - def clear(self): - self.dp.clear() - - -class EmbroiderySimulator(wx.Frame): - def __init__(self, *args, **kwargs): - self.on_close_hook = kwargs.pop('on_close', None) - stitch_plan = kwargs.pop('stitch_plan', None) - stitches_per_second = kwargs.pop('stitches_per_second', 16) - target_duration = kwargs.pop('target_duration', None) - size = kwargs.get('size', (0, 0)) - wx.Frame.__init__(self, *args, **kwargs) - self.statusbar = self.CreateStatusBar(2) - self.statusbar.SetStatusWidths([250, -1]) - - sizer = wx.BoxSizer(wx.HORIZONTAL) - self.simulator_panel = SimulatorPanel(self, - stitch_plan=stitch_plan, - target_duration=target_duration, - stitches_per_second=stitches_per_second) - sizer.Add(self.simulator_panel, 1, wx.EXPAND) - - # self.SetSizerAndFit() sets the minimum size so that the buttons don't - # get squished. But it then also shrinks the window down to that size. - self.SetSizerAndFit(sizer) - - # Therefore we have to reapply the size that the caller asked for. - self.SetSize(size) - - self.Bind(wx.EVT_CLOSE, self.on_close) - - def quit(self): - self.Close() - - def on_close(self, event): - self.simulator_panel.stop() - - if self.on_close_hook: - self.on_close_hook() - - self.Destroy() - - def go(self): - self.simulator_panel.go() - - def stop(self): - self.simulator_panel.stop() - - def load(self, stitch_plan): - self.simulator_panel.load(stitch_plan) - - def clear(self): - self.simulator_panel.clear() - - -def show_simulator(stitch_plan): - app = wx.App() - current_screen = wx.Display.GetFromPoint(wx.GetMousePosition()) - display = wx.Display(current_screen) - screen_rect = display.GetClientArea() - - simulator_pos = (screen_rect[0], screen_rect[1]) - - # subtract 1 because otherwise the window becomes maximized on Linux - width = screen_rect[2] - 1 - height = screen_rect[3] - 1 - - frame = EmbroiderySimulator(None, -1, _("Embroidery Simulation"), pos=simulator_pos, size=(width, height), stitch_plan=stitch_plan) - app.SetTopWindow(frame) - frame.Show() - app.MainLoop() - - -if __name__ == "__main__": - stitch_plan = stitch_plan_from_file(sys.argv[1]) - show_simulator(stitch_plan) diff --git a/lib/svg/tags.py b/lib/svg/tags.py index 55352be2..84509f90 100644 --- a/lib/svg/tags.py +++ b/lib/svg/tags.py @@ -1,5 +1,8 @@ import inkex +# This is used below and added to the document in ../extensions/base.py. +inkex.NSS['inkstitch'] = 'http://inkstitch.org/namespace' + SVG_PATH_TAG = inkex.addNS('path', 'svg') SVG_POLYLINE_TAG = inkex.addNS('polyline', 'svg') @@ -16,5 +19,6 @@ CONNECTOR_TYPE = inkex.addNS('connector-type', 'inkscape') XLINK_HREF = inkex.addNS('href', 'xlink') SODIPODI_NAMEDVIEW = inkex.addNS('namedview', 'sodipodi') SODIPODI_GUIDE = inkex.addNS('guide', 'sodipodi') +INKSTITCH_LETTERING = inkex.addNS('lettering', 'inkstitch') EMBROIDERABLE_TAGS = (SVG_PATH_TAG, SVG_POLYLINE_TAG) diff --git a/lib/utils/__init__.py b/lib/utils/__init__.py index a6ae4374..2b39da71 100644 --- a/lib/utils/__init__.py +++ b/lib/utils/__init__.py @@ -1,6 +1,7 @@ -from geometry import * -from cache import cache -from io import * -from inkscape import * -from paths import * -from string import * +from .cache import cache +from .dotdict import DotDict +from .geometry import * +from .inkscape import * +from .io import * +from .paths import * +from .string import * diff --git a/lib/utils/dotdict.py b/lib/utils/dotdict.py new file mode 100644 index 00000000..1ab3a4fe --- /dev/null +++ b/lib/utils/dotdict.py @@ -0,0 +1,30 @@ +class DotDict(dict): + """A dict subclass that allows accessing methods using dot notation. + + adapted from: https://stackoverflow.com/questions/13520421/recursive-dotdict + """ + + def __init__(self, *args, **kwargs): + super(DotDict, self).__init__(*args, **kwargs) + + for k, v in self.iteritems(): + if isinstance(v, dict): + self[k] = DotDict(v) + + __setattr__ = dict.__setitem__ + __delattr__ = dict.__delitem__ + + def __getattr__(self, name): + if name.startswith('_'): + raise AttributeError("'DotDict' object has no attribute '%s'" % name) + + if name in self: + return self.__getitem__(name) + else: + new_dict = DotDict() + self.__setitem__(name, new_dict) + return new_dict + + def __repr__(self): + super_repr = super(DotDict, self).__repr__() + return "DotDict(%s)" % super_repr -- cgit v1.2.3