From dbdba2cda3a66fc42b5bad05e88d33b845a85e2f Mon Sep 17 00:00:00 2001 From: Kaalleen <36401965+kaalleen@users.noreply.github.com> Date: Fri, 14 Jun 2024 09:49:57 +0200 Subject: Add preferences button to simulator (#2992) * split simulator panel files * add view panel to position view options at the side * fix single simulator start size (macOS) --- icons/settings.png | Bin 0 -> 6563 bytes icons/settings.svg | 64 ++ icons/settings_dark.png | Bin 0 -> 5527 bytes lib/extensions/simulator.py | 5 +- lib/gui/simulator.py | 1328 --------------------------- lib/gui/simulator/__init__.py | 14 + lib/gui/simulator/control_panel.py | 343 +++++++ lib/gui/simulator/drawing_panel.py | 423 +++++++++ lib/gui/simulator/simulator_panel.py | 95 ++ lib/gui/simulator/simulator_preferences.py | 71 ++ lib/gui/simulator/simulator_renderer.py | 64 ++ lib/gui/simulator/simulator_slider.py | 234 +++++ lib/gui/simulator/simulator_window.py | 55 ++ lib/gui/simulator/split_simulator_window.py | 122 +++ lib/gui/simulator/view_panel.py | 117 +++ lib/utils/settings.py | 2 + 16 files changed, 1607 insertions(+), 1330 deletions(-) create mode 100644 icons/settings.png create mode 100644 icons/settings.svg create mode 100644 icons/settings_dark.png delete mode 100644 lib/gui/simulator.py create mode 100644 lib/gui/simulator/__init__.py create mode 100644 lib/gui/simulator/control_panel.py create mode 100644 lib/gui/simulator/drawing_panel.py create mode 100644 lib/gui/simulator/simulator_panel.py create mode 100644 lib/gui/simulator/simulator_preferences.py create mode 100644 lib/gui/simulator/simulator_renderer.py create mode 100644 lib/gui/simulator/simulator_slider.py create mode 100644 lib/gui/simulator/simulator_window.py create mode 100644 lib/gui/simulator/split_simulator_window.py create mode 100644 lib/gui/simulator/view_panel.py diff --git a/icons/settings.png b/icons/settings.png new file mode 100644 index 00000000..f5cfe415 Binary files /dev/null and b/icons/settings.png differ diff --git a/icons/settings.svg b/icons/settings.svg new file mode 100644 index 00000000..1a7356c3 --- /dev/null +++ b/icons/settings.svg @@ -0,0 +1,64 @@ + + + + + + + + + + image/svg+xml + + + + + + + + diff --git a/icons/settings_dark.png b/icons/settings_dark.png new file mode 100644 index 00000000..1a1103f8 Binary files /dev/null and b/icons/settings_dark.png differ diff --git a/lib/extensions/simulator.py b/lib/extensions/simulator.py index 57978b73..69f74b02 100644 --- a/lib/extensions/simulator.py +++ b/lib/extensions/simulator.py @@ -30,8 +30,9 @@ class Simulator(InkstitchExtension): current_screen = wx.Display.GetFromPoint(wx.GetMousePosition()) display = wx.Display(current_screen) screen_rect = display.GetClientArea() - height = int(screen_rect[3] * 0.8) - simulator = SimulatorWindow(size=(0, height), background_color=background_color) + width = int(screen_rect.width * 0.8) + height = int(screen_rect.height * 0.8) + simulator = SimulatorWindow(size=(width, height), background_color=background_color) wx.CallLater(100, simulator.Centre) app.SetTopWindow(simulator) simulator.Show() diff --git a/lib/gui/simulator.py b/lib/gui/simulator.py deleted file mode 100644 index 006d0e57..00000000 --- a/lib/gui/simulator.py +++ /dev/null @@ -1,1328 +0,0 @@ -# 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 os -import sys -import time -from threading import Event, Thread - -import wx -from numpy import split -from wx.lib.intctrl import IntCtrl - -from lib.debug.debug import debug -from lib.utils import get_resource_dir -from lib.utils.settings import global_settings -from lib.utils.threading import ExitThread - -from ..i18n import _ -from ..svg import PIXELS_PER_MM - -# 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): - """""" - - @debug.time - def __init__(self, parent, *args, **kwargs): - """""" - self.parent = parent - self.stitch_plan = kwargs.pop('stitch_plan', None) - self.detach_callback = kwargs.pop('detach_callback', None) - 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.drawing_panel = None - self.num_stitches = 0 - self.current_stitch = 0 - self.speed = 1 - self.direction = 1 - self._last_color_block_end = 0 - - self.icons_dir = get_resource_dir("icons") - - # Widgets - self.button_size = self.GetTextExtent("M").y * 2 - self.button_style = wx.BU_EXACTFIT | wx.BU_NOTEXT - self.btnMinus = wx.Button(self, -1, style=self.button_style) - self.btnMinus.Bind(wx.EVT_BUTTON, self.animation_slow_down) - self.btnMinus.SetBitmap(self.load_icon('slower')) - self.btnMinus.SetToolTip(_('Slow down (arrow down)')) - self.btnPlus = wx.Button(self, -1, style=self.button_style) - self.btnPlus.Bind(wx.EVT_BUTTON, self.animation_speed_up) - self.btnPlus.SetBitmap(self.load_icon('faster')) - self.btnPlus.SetToolTip(_('Speed up (arrow up)')) - self.btnBackwardStitch = wx.Button(self, -1, style=self.button_style) - self.btnBackwardStitch.Bind(wx.EVT_BUTTON, self.animation_one_stitch_backward) - self.btnBackwardStitch.SetBitmap(self.load_icon('backward_stitch')) - self.btnBackwardStitch.SetToolTip(_('Go backward one stitch (-)')) - self.btnForwardStitch = wx.Button(self, -1, style=self.button_style) - self.btnForwardStitch.Bind(wx.EVT_BUTTON, self.animation_one_stitch_forward) - self.btnForwardStitch.SetBitmap(self.load_icon('forward_stitch')) - self.btnForwardStitch.SetToolTip(_('Go forward one stitch (+)')) - self.btnBackwardCommand = wx.Button(self, -1, style=self.button_style) - self.btnBackwardCommand.Bind(wx.EVT_BUTTON, self.animation_one_command_backward) - self.btnBackwardCommand.SetBitmap(self.load_icon('backward_command')) - self.btnBackwardCommand.SetToolTip(_('Go backward one command (page-down)')) - self.btnForwardCommand = wx.Button(self, -1, style=self.button_style) - self.btnForwardCommand.Bind(wx.EVT_BUTTON, self.animation_one_command_forward) - self.btnForwardCommand.SetBitmap(self.load_icon('forward_command')) - self.btnForwardCommand.SetToolTip(_('Go forward one command (page-up)')) - self.btnDirection = wx.Button(self, -1, style=self.button_style) - self.btnDirection.Bind(wx.EVT_BUTTON, self.on_direction_button) - self.btnDirection.SetBitmap(self.load_icon('direction')) - self.btnDirection.SetToolTip(_('Switch animation direction (arrow left, arrow right)')) - self.btnPlay = wx.BitmapToggleButton(self, -1, style=self.button_style) - self.btnPlay.Bind(wx.EVT_TOGGLEBUTTON, self.on_play_button) - self.btnPlay.SetBitmap(self.load_icon('play')) - self.btnPlay.SetToolTip(_('Play (P)')) - self.btnRestart = wx.Button(self, -1, style=self.button_style) - self.btnRestart.Bind(wx.EVT_BUTTON, self.animation_restart) - self.btnRestart.SetBitmap(self.load_icon('restart')) - self.btnRestart.SetToolTip(_('Restart (R)')) - self.btnNpp = wx.BitmapToggleButton(self, -1, style=self.button_style) - self.btnNpp.Bind(wx.EVT_TOGGLEBUTTON, self.toggle_npp) - self.btnNpp.SetBitmap(self.load_icon('npp')) - self.btnNpp.SetToolTip(_('Display needle penetration point (O)')) - self.slider = SimulatorSlider(self, -1, value=1, minValue=1, maxValue=2) - self.slider.Bind(wx.EVT_SLIDER, self.on_slider) - self.stitchBox = IntCtrl(self, -1, value=1, min=1, max=2, limited=True, allow_none=True, - size=((100, -1)), style=wx.TE_PROCESS_ENTER) - self.stitchBox.Clear() - self.stitchBox.Bind(wx.EVT_LEFT_DOWN, self.on_stitch_box_focus) - self.stitchBox.Bind(wx.EVT_SET_FOCUS, self.on_stitch_box_focus) - self.stitchBox.Bind(wx.EVT_TEXT_ENTER, self.on_stitch_box_focusout) - self.stitchBox.Bind(wx.EVT_KILL_FOCUS, self.on_stitch_box_focusout) - self.Bind(wx.EVT_LEFT_DOWN, self.on_stitch_box_focusout) - self.totalstitchText = wx.StaticText(self, -1, label="") - extent = self.totalstitchText.GetTextExtent("0000000") - self.totalstitchText.SetMinSize(extent) - self.btnJump = wx.BitmapToggleButton(self, -1, style=self.button_style) - self.btnJump.SetToolTip(_('Show jump stitches')) - self.btnJump.SetBitmap(self.load_icon('jump')) - self.btnJump.Bind(wx.EVT_TOGGLEBUTTON, lambda event: self.on_marker_button('jump', event)) - self.btnTrim = wx.BitmapToggleButton(self, -1, style=self.button_style) - self.btnTrim.SetToolTip(_('Show trims')) - self.btnTrim.SetBitmap(self.load_icon('trim')) - self.btnTrim.Bind(wx.EVT_TOGGLEBUTTON, lambda event: self.on_marker_button('trim', event)) - self.btnStop = wx.BitmapToggleButton(self, -1, style=self.button_style) - self.btnStop.SetToolTip(_('Show stops')) - self.btnStop.SetBitmap(self.load_icon('stop')) - self.btnStop.Bind(wx.EVT_TOGGLEBUTTON, lambda event: self.on_marker_button('stop', event)) - self.btnColorChange = wx.BitmapToggleButton(self, -1, style=self.button_style) - self.btnColorChange.SetToolTip(_('Show color changes')) - self.btnColorChange.SetBitmap(self.load_icon('color_change')) - self.btnColorChange.Bind(wx.EVT_TOGGLEBUTTON, lambda event: self.on_marker_button('color_change', event)) - self.btnBackgroundColor = wx.ColourPickerCtrl(self, -1, colour='white', size=((40, -1))) - self.btnBackgroundColor.SetToolTip(_("Change background color")) - self.btnBackgroundColor.Bind(wx.EVT_COLOURPICKER_CHANGED, self.on_update_background_color) - if self.detach_callback: - self.btnDetachSimulator = wx.BitmapButton(self, -1, style=self.button_style) - self.btnDetachSimulator.SetToolTip(_('Detach/attach simulator window')) - self.btnDetachSimulator.SetBitmap(self.load_icon('detach_window')) - self.btnDetachSimulator.Bind(wx.EVT_BUTTON, lambda event: self.detach_callback()) - - # Layout - self.hbSizer1 = wx.BoxSizer(wx.HORIZONTAL) - self.hbSizer1.Add(self.slider, 1, wx.EXPAND | wx.RIGHT, 10) - self.hbSizer1.Add(self.stitchBox, 0, wx.ALIGN_TOP | wx.TOP, 25) - self.hbSizer1.Add((1, 1), 0, wx.RIGHT, 10) - self.hbSizer1.Add(self.totalstitchText, 0, wx.ALIGN_TOP | wx.TOP, 25) - self.hbSizer1.Add((1, 1), 0, wx.RIGHT, 10) - - self.controls_sizer = wx.StaticBoxSizer(wx.StaticBox(self, wx.ID_ANY, _("Controls")), wx.HORIZONTAL) - self.controls_inner_sizer = wx.BoxSizer(wx.HORIZONTAL) - self.controls_inner_sizer.Add(self.btnBackwardCommand, 0, wx.EXPAND | wx.ALL, 2) - self.controls_inner_sizer.Add(self.btnBackwardStitch, 0, wx.EXPAND | wx.ALL, 2) - self.controls_inner_sizer.Add(self.btnForwardStitch, 0, wx.EXPAND | wx.ALL, 2) - self.controls_inner_sizer.Add(self.btnForwardCommand, 0, wx.EXPAND | wx.ALL, 2) - self.controls_inner_sizer.Add(self.btnDirection, 0, wx.EXPAND | wx.ALL, 2) - self.controls_inner_sizer.Add(self.btnPlay, 0, wx.EXPAND | wx.ALL, 2) - self.controls_inner_sizer.Add(self.btnRestart, 0, wx.EXPAND | wx.ALL, 2) - self.controls_sizer.Add((1, 1), 1) - self.controls_sizer.Add(self.controls_inner_sizer, 0, wx.ALIGN_CENTER_VERTICAL | wx.ALL, 10) - self.controls_sizer.Add((1, 1), 1) - - self.show_sizer = wx.StaticBoxSizer(wx.StaticBox(self, wx.ID_ANY, _("Show")), wx.HORIZONTAL) - self.show_inner_sizer = wx.BoxSizer(wx.HORIZONTAL) - self.show_inner_sizer.Add(self.btnNpp, 0, wx.ALL, 2) - self.show_inner_sizer.Add(self.btnJump, 0, wx.ALL, 2) - self.show_inner_sizer.Add(self.btnTrim, 0, wx.ALL, 2) - self.show_inner_sizer.Add(self.btnStop, 0, wx.ALL, 2) - self.show_inner_sizer.Add(self.btnColorChange, 0, wx.ALL, 2) - self.show_inner_sizer.Add(self.btnBackgroundColor, 0, wx.EXPAND | wx.ALL, 2) - if self.detach_callback: - self.show_inner_sizer.Add(self.btnDetachSimulator, 0, wx.ALL, 2) - self.show_sizer.Add((1, 1), 1) - self.show_sizer.Add(self.show_inner_sizer, 0, wx.ALIGN_CENTER_VERTICAL | wx.ALL, 10) - self.show_sizer.Add((1, 1), 1) - - self.speed_sizer = wx.StaticBoxSizer(wx.StaticBox(self, wx.ID_ANY, _("Speed")), wx.VERTICAL) - - self.speed_buttons_sizer = wx.BoxSizer(wx.HORIZONTAL) - self.speed_buttons_sizer.Add((1, 1), 1) - self.speed_buttons_sizer.Add(self.btnMinus, 0, wx.ALL, 2) - self.speed_buttons_sizer.Add(self.btnPlus, 0, wx.ALL, 2) - self.speed_buttons_sizer.Add((1, 1), 1) - self.speed_sizer.Add(self.speed_buttons_sizer, 0, wx.EXPAND | wx.ALL) - self.speed_text = wx.StaticText(self, wx.ID_ANY, label="", style=wx.ALIGN_CENTRE_HORIZONTAL | wx.ST_NO_AUTORESIZE) - self.speed_text.SetFont(wx.Font(wx.FontInfo(10).Bold())) - extent = self.speed_text.GetTextExtent(self.format_speed_text(100000)) - self.speed_text.SetMinSize(extent) - self.speed_sizer.Add(self.speed_text, 0, wx.EXPAND | wx.ALL, 5) - - # A normal BoxSizer can only make child components the same or - # proportional size. A FlexGridSizer can split up the available extra - # space evenly among all growable columns. - self.control_row2_sizer = wx.FlexGridSizer(cols=3, vgap=0, hgap=5) - self.control_row2_sizer.AddGrowableCol(0) - self.control_row2_sizer.AddGrowableCol(1) - self.control_row2_sizer.AddGrowableCol(2) - self.control_row2_sizer.Add(self.controls_sizer, 0, wx.EXPAND) - self.control_row2_sizer.Add(self.speed_sizer, 0, wx.EXPAND) - self.control_row2_sizer.Add(self.show_sizer, 0, wx.EXPAND) - - self.vbSizer = vbSizer = wx.BoxSizer(wx.VERTICAL) - vbSizer.Add(self.hbSizer1, 1, wx.EXPAND | wx.ALL, 10) - vbSizer.Add(self.control_row2_sizer, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 10) - 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('o'), self.on_toggle_npp_shortcut), - (wx.ACCEL_NORMAL, ord('p'), self.play_or_pause), - (wx.ACCEL_NORMAL, wx.WXK_SPACE, self.play_or_pause), - (wx.ACCEL_NORMAL, wx.WXK_PAGEDOWN, self.animation_one_command_backward), - (wx.ACCEL_NORMAL, wx.WXK_PAGEUP, self.animation_one_command_forward), - - ] - - self.accel_entries = [] - - for shortcut_key in shortcut_keys: - eventId = wx.NewIdRef() - self.accel_entries.append((shortcut_key[0], shortcut_key[1], eventId)) - self.Bind(wx.EVT_MENU, shortcut_key[2], id=eventId) - - self.accel_table = wx.AcceleratorTable(self.accel_entries) - self.SetAcceleratorTable(self.accel_table) - - # wait for layouts so that panel size is set - if self.stitch_plan: - wx.CallLater(50, self.load, self.stitch_plan) - - 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.SetValue(1) - self.stitchBox.SetMax(num_stitches) - self.slider.SetMax(num_stitches) - self.totalstitchText.SetLabel(f"/ { num_stitches }") - self.choose_speed() - - def clear(self): - self.stitches = [] - self._set_num_stitches(0) - self.slider.clear() - self.stitchBox.Clear() - self.totalstitchText.SetLabel("") - - def load(self, stitch_plan): - self.clear() - self.stitches = [] - self._set_num_stitches(stitch_plan.num_stitches) - - stitch_num = 0 - last_block_end = 1 - for color_block in stitch_plan.color_blocks: - self.stitches.extend(color_block.stitches) - - start = stitch_num + 1 - end = start + color_block.num_stitches - 1 - self.slider.add_color_section(color_block.color.rgb, last_block_end, end) - last_block_end = end - - for stitch_num, stitch in enumerate(color_block.stitches, start): - if stitch.trim: - self.slider.add_marker("trim", stitch_num) - elif stitch.stop: - self.slider.add_marker("stop", stitch_num) - elif stitch.jump: - self.slider.add_marker("jump", stitch_num) - elif stitch.color_change: - self.slider.add_marker("color_change", stitch_num) - - def is_dark_theme(self): - return wx.SystemSettings().GetAppearance().IsDark() - - def load_icon(self, icon_name): - if self.is_dark_theme(): - icon = wx.Image(os.path.join(self.icons_dir, f"{icon_name}_dark.png")) - else: - icon = wx.Image(os.path.join(self.icons_dir, f"{icon_name}.png")) - icon.Rescale(self.button_size, self.button_size, wx.IMAGE_QUALITY_HIGH) - return icon.ConvertToBitmap() - - def on_marker_button(self, marker_type, event): - self.slider.enable_marker_list(marker_type, event.GetEventObject().GetValue()) - if marker_type == 'jump': - self.drawing_panel.Refresh() - - def on_update_background_color(self, event): - self.set_background_color(event.Colour) - - def set_background_color(self, color): - self.btnBackgroundColor.SetColour(color) - self.drawing_panel.SetBackgroundColour(color) - self.drawing_panel.Refresh() - - 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.drawing_panel.forward() - self.direction = 1 - self.update_speed_text() - - def animation_reverse(self, event=None): - self.drawing_panel.reverse() - self.direction = -1 - self.update_speed_text() - - def on_direction_button(self, event): - if self.direction == -1: - self.animation_forward() - else: - self.animation_reverse() - - 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 format_speed_text(self, speed): - return _('%d stitches/sec') % speed - - def update_speed_text(self): - self.speed_text.SetLabel(self.format_speed_text(self.speed * self.direction)) - - def on_slider(self, event): - self.animation_pause() - stitch = event.GetEventObject().GetValue() - self.stitchBox.SetValue(stitch) - - if self.drawing_panel: - self.drawing_panel.set_current_stitch(stitch) - - self.parent.SetFocus() - - def on_current_stitch(self, stitch, command): - if self.current_stitch != stitch: - self.current_stitch = stitch - self.slider.SetValue(stitch) - self.stitchBox.SetValue(stitch) - - def on_stitch_box_focus(self, event): - self.animation_pause() - self.SetAcceleratorTable(wx.AcceleratorTable([])) - event.Skip() - - def on_stitch_box_focusout(self, event): - self.SetAcceleratorTable(self.accel_table) - stitch = self.stitchBox.GetValue() - # We now want to remove the focus from the stitchBox. - # In Windows it won't work if we set focus to self.parent, while setting the focus to the - # top level would work. This in turn would activate the trim button in Linux. So let's - # set the focus on the slider instead where it doesn't cause any harm in any of the operating systems - self.slider.SetFocus() - - if stitch is None: - stitch = 1 - self.stitchBox.SetValue(1) - - self.slider.SetValue(stitch) - - if self.drawing_panel: - self.drawing_panel.set_current_stitch(stitch) - event.Skip() - - 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.btnPlay.SetValue(True) - - def on_stop(self): - self.btnPlay.SetValue(False) - - def on_play_button(self, event): - play = self.btnPlay.GetValue() - if play: - self.animation_start() - else: - self.animation_pause() - - def play_or_pause(self, event): - if self.drawing_panel.animating: - 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_one_command_backward(self, event): - self.animation_pause() - stitch_number = self.current_stitch - 1 - while stitch_number >= 1: - # stitch number shown to the user starts at 1 - stitch = self.stitches[stitch_number - 1] - if stitch.jump or stitch.trim or stitch.stop or stitch.color_change: - break - stitch_number -= 1 - self.drawing_panel.set_current_stitch(stitch_number) - - def animation_one_command_forward(self, event): - self.animation_pause() - stitch_number = self.current_stitch + 1 - while stitch_number <= self.num_stitches: - # stitch number shown to the user starts at 1 - stitch = self.stitches[stitch_number - 1] - if stitch.jump or stitch.trim or stitch.stop or stitch.color_change: - break - stitch_number += 1 - self.drawing_panel.set_current_stitch(stitch_number) - - def animation_restart(self, event): - self.drawing_panel.restart() - - def on_toggle_npp_shortcut(self, event): - self.btnNpp.SetValue(not self.btnNpp.GetValue()) - self.toggle_npp(event) - - def toggle_npp(self, event): - self.drawing_panel.Refresh() - - -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', None) - 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) - self.Bind(wx.EVT_SIZE, self.on_resize) - - # wait for layouts so that panel size is set - if self.stitch_plan: - wx.CallLater(50, self.load, self.stitch_plan) - - def on_resize(self, event): - self.choose_zoom_and_pan() - self.Refresh() - - 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): - dc = wx.PaintDC(self) - - if not self.loaded: - dc.Clear() - return - - canvas = wx.GraphicsContext.Create(dc) - - self.draw_stitches(canvas) - self.draw_scale(canvas) - - def draw_stitches(self, canvas): - canvas.BeginLayer(1) - - 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, jumps in zip(self.pens, self.stitch_blocks, self.jumps): - canvas.SetPen(pen) - if stitch + len(stitches) < self.current_stitch: - stitch += len(stitches) - if len(stitches) > 1: - self.draw_stitch_lines(canvas, pen, stitches, jumps) - self.draw_needle_penetration_points(canvas, pen, stitches) - last_stitch = stitches[-1] - else: - stitches = stitches[: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) - - canvas.EndLayer() - - def draw_crosshair(self, x, y, canvas, transform): - x, y = transform.TransformPoint(float(x), float(y)) - canvas.SetTransform(canvas.CreateMatrix()) - crosshair_radius = 10 - canvas.SetPen(self.black_pen) - canvas.StrokeLines(((x - crosshair_radius, y), (x + crosshair_radius, y))) - canvas.StrokeLines(((x, y - crosshair_radius), (x, y + crosshair_radius))) - - def draw_scale(self, canvas): - canvas.BeginLayer(1) - - canvas_width, canvas_height = self.GetClientSize() - - one_mm = PIXELS_PER_MM * self.zoom - scale_width = one_mm - max_width = min(canvas_width * 0.5, 300) - - while scale_width > max_width: - scale_width /= 2.0 - - while scale_width < 50: - scale_width += one_mm - - scale_width_mm = int(scale_width / self.zoom / PIXELS_PER_MM) - - # The scale bar looks like this: - # - # | | - # |_____|_____| - - scale_lower_left_x = 20 - scale_lower_left_y = canvas_height - 30 - - canvas.StrokeLines(((scale_lower_left_x, scale_lower_left_y - 6), - (scale_lower_left_x, scale_lower_left_y), - (scale_lower_left_x + scale_width / 2.0, scale_lower_left_y), - (scale_lower_left_x + scale_width / 2.0, scale_lower_left_y - 3), - (scale_lower_left_x + scale_width / 2.0, scale_lower_left_y), - (scale_lower_left_x + scale_width, scale_lower_left_y), - (scale_lower_left_x + scale_width, scale_lower_left_y - 6))) - - canvas.SetFont(wx.Font(12, wx.DEFAULT, wx.NORMAL, wx.NORMAL), wx.Colour((0, 0, 0))) - canvas.DrawText("%s mm" % scale_width_mm, scale_lower_left_x, scale_lower_left_y + 5) - - canvas.EndLayer() - - def draw_stitch_lines(self, canvas, pen, stitches, jumps): - render_jumps = self.control_panel.btnJump.GetValue() - if render_jumps: - canvas.StrokeLines(stitches) - else: - stitch_blocks = split(stitches, jumps) - for i, block in enumerate(stitch_blocks): - if len(block) > 1: - canvas.StrokeLines(block) - - def draw_needle_penetration_points(self, canvas, pen, stitches): - if self.control_panel.btnNpp.GetValue(): - npp_pen = wx.Pen(pen.GetColour(), width=int(0.5 * PIXELS_PER_MM * self.PIXEL_DENSITY)) - canvas.SetPen(npp_pen) - canvas.StrokeLineSegments(stitches, [(stitch[0] + 0.001, stitch[1]) for stitch in stitches]) - - def clear(self): - self.loaded = False - self.Refresh() - - 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 - self.num_stitches = stitch_plan.num_stitches - 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 EVT_SIZE fired before we load the stitch plan - if not self.width and not self.height and event is not None: - 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 = max(min(width_ratio, height_ratio), 0.01) - - # 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(list(map(int, color.visible_on_white.rgb)), int(0.1 * PIXELS_PER_MM * self.PIXEL_DENSITY)) - - def parse_stitch_plan(self, stitch_plan): - self.pens = [] - self.stitch_blocks = [] - self.jumps = [] - - # 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 = [] - jumps = [] - stitch_index = 0 - - 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) - jumps.append(stitch_index) - 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 = [] - self.jumps.append(jumps) - jumps = [] - stitch_index = 0 - else: - stitch_index += 1 - - if stitch_block: - self.pens.append(pen) - self.stitch_blocks.append(stitch_block) - self.jumps.append(jumps) - - 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() - command = self.commands[self.current_stitch] - self.control_panel.on_current_stitch(self.current_stitch, command) - statusbar = self.GetTopLevelParent().statusbar - statusbar.SetStatusText(_("Command: %s") % COMMAND_NAMES[command], 1) - 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): - if self.loaded: - 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 appear 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 MarkerList(list): - def __init__(self, icon_name, offset=0, stitch_numbers=()): - super().__init__(self) - icons_dir = get_resource_dir("icons") - self.icon_name = icon_name - self.icon = wx.Image(os.path.join(icons_dir, f"{icon_name}.png")).ConvertToBitmap() - self.offset = offset - self.enabled = False - self.extend(stitch_numbers) - - def __repr__(self): - return f"MarkerList({self.icon_name})" - - -class ColorSection: - def __init__(self, color, start, end): - self.color = color - self.start = start - self.end = end - self.brush = wx.Brush(wx.Colour(*color)) - - -class SimulatorSlider(wx.Panel): - PROXY_EVENTS = (wx.EVT_SLIDER,) - - def __init__(self, parent, id=wx.ID_ANY, minValue=1, maxValue=2, **kwargs): - super().__init__(parent, id) - self.control_panel = parent - - kwargs['style'] = wx.SL_HORIZONTAL | wx.SL_VALUE_LABEL | wx.SL_TOP | wx.ALIGN_TOP - - self._height = self.GetTextExtent("M").y * 6 - self.SetMinSize((self._height, self._height)) - - self.marker_lists = { - "trim": MarkerList("trim"), - "jump": MarkerList("jump", 0.17), - "stop": MarkerList("stop", 0.34), - "color_change": MarkerList("color_change", 0.34), - } - self.marker_pen = wx.Pen(wx.Colour(0, 0, 0)) - self.color_sections = [] - self.margin = 15 - self.tab_start = 0 - self.tab_width = 0.15 - self.tab_height = 0.15 - self.color_bar_start = 0.22 - self.color_bar_thickness = 0.17 - self.marker_start = self.color_bar_start - self.marker_end = 0.5 - self.marker_icon_start = 0.5 - self.marker_icon_size = self._height // 6 - - self._min = minValue - self._max = maxValue - self._value = 0 - self._tab_rect = None - - if sys.platform == "darwin": - self.margin = 8 - - self.Bind(wx.EVT_PAINT, self.on_paint) - self.Bind(wx.EVT_ERASE_BACKGROUND, self.on_erase_background) - self.Bind(wx.EVT_LEFT_DOWN, self.on_mouse_down) - self.Bind(wx.EVT_LEFT_UP, self.on_mouse_up) - self.Bind(wx.EVT_MOTION, self.on_mouse_motion) - - def SetMax(self, value): - self._max = value - self.Refresh() - - def SetMin(self, value): - self._min = value - self.Refresh() - - def SetValue(self, value): - self._value = value - self.Refresh() - - def GetValue(self): - return self._value - - def clear(self): - self.color_sections = [] - self._min = 1 - self._max = 2 - self._value = 0 - self._tab_rect = None - - for marker_list in self.marker_lists.values(): - marker_list.clear() - - def add_color_section(self, color, start, end): - self.color_sections.append(ColorSection(color, start, end)) - - def add_marker(self, name, location): - self.marker_lists[name].append(location) - self.Refresh() - - def enable_marker_list(self, name, enabled=True): - self.marker_lists[name].enabled = enabled - self.Refresh() - - def disable_marker_list(self, name): - self.marker_lists[name].enabled = False - self.Refresh() - - def toggle_marker_list(self, name): - self.marker_lists[name].enabled = not self.marker_lists[name].enabled - self.Refresh() - - def on_paint(self, event): - dc = wx.BufferedPaintDC(self) - if not sys.platform.startswith("win"): - # Without this, the background color will be white. - background_brush = wx.Brush(self.GetTopLevelParent().GetBackgroundColour(), wx.SOLID) - dc.SetBackground(background_brush) - dc.Clear() - gc = wx.GraphicsContext.Create(dc) - - if self._value < self._min: - return - - width, height = self.GetSize() - min_value = self._min - max_value = self._max - spread = max_value - min_value - - def _value_to_x(value): - return (value - min_value) * (width - 2 * self.margin) / spread + self.margin - - gc.SetPen(wx.NullPen) - for color_section in self.color_sections: - gc.SetBrush(color_section.brush) - - start_x = _value_to_x(color_section.start) - end_x = _value_to_x(color_section.end) - gc.DrawRectangle(start_x, height * self.color_bar_start, - end_x - start_x, height * self.color_bar_thickness) - - if self.control_panel.is_dark_theme(): - gc.SetPen(wx.Pen(wx.Colour(0, 0, 0), 1)) - gc.SetBrush(wx.Brush(wx.Colour(255, 255, 255))) - else: - gc.SetPen(wx.Pen(wx.Colour(255, 255, 255), 1)) - gc.SetBrush(wx.Brush(wx.Colour(0, 0, 0))) - - value_x = _value_to_x(self._value) - tab_height = self.tab_height * height - tab_width = self.tab_width * height - tab_x = value_x - tab_width / 2 - tab_y = height * self.tab_start - self._tab_rect = wx.Rect(round(tab_x), round(tab_y), round(tab_width), round(tab_height)) - gc.DrawRectangle( - value_x - 1.5, 0, - 3, height * (self.color_bar_start + self.color_bar_thickness)) - gc.SetPen(wx.NullPen) - gc.DrawRectangle(value_x - tab_width/2, height * self.tab_start, - tab_width, tab_height) - - gc.SetPen(self.marker_pen) - for marker_list in self.marker_lists.values(): - if marker_list.enabled: - for value in marker_list: - x = _value_to_x(value) - gc.StrokeLine( - x, height * self.marker_start, - x, height * (self.marker_end + marker_list.offset) - ) - gc.DrawBitmap( - marker_list.icon, - x - self.marker_icon_size / 2, height * (self.marker_icon_start + marker_list.offset), - self.marker_icon_size, self.marker_icon_size - ) - - def on_erase_background(self, event): - # supposedly this prevents flickering? - pass - - def is_in_tab(self, point): - return self._tab_rect and self._tab_rect.Inflate(2).Contains(point) - - def set_value_from_position(self, point): - width, height = self.GetSize() - min_value = self._min - max_value = self._max - spread = max_value - min_value - value = round((point.x - self.margin) * spread / (width - 2 * self.margin)) - value = max(value, self._min) - value = min(value, self._max) - self.SetValue(round(value)) - - event = wx.CommandEvent(wx.wxEVT_COMMAND_SLIDER_UPDATED, self.GetId()) - event.SetInt(value) - event.SetEventObject(self) - self.GetEventHandler().ProcessEvent(event) - - def on_mouse_down(self, event): - click_pos = event.GetPosition() - if self.is_in_tab(click_pos): - debug.log("drag start") - self.CaptureMouse() - self.set_value_from_position(click_pos) - self.Refresh() - else: - width, height = self.GetSize() - relative_y = click_pos.y / height - if relative_y > self.color_bar_start and relative_y - self.color_bar_start < self.color_bar_thickness: - self.set_value_from_position(click_pos) - self.Refresh() - - def on_mouse_motion(self, event): - if self.HasCapture() and event.Dragging() and event.LeftIsDown(): - self.set_value_from_position(event.GetPosition()) - self.Refresh() - - def on_mouse_up(self, event): - if self.HasCapture(): - self.ReleaseMouse() - self.set_value_from_position(event.GetPosition()) - self.Refresh() - - -class SimulatorPanel(wx.Panel): - """""" - - def __init__(self, parent, stitch_plan=None, background_color='white', target_duration=5, stitches_per_second=16, detach_callback=None): - """""" - super().__init__(parent, style=wx.BORDER_SUNKEN) - - self.cp = ControlPanel(self, - stitch_plan=stitch_plan, - stitches_per_second=stitches_per_second, - target_duration=target_duration, - detach_callback=detach_callback) - self.dp = DrawingPanel(self, stitch_plan=stitch_plan, control_panel=self.cp) - self.cp.set_drawing_panel(self.dp) - self.cp.set_background_color(wx.Colour(background_color)) - - 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 go(self): - self.dp.go() - - def stop(self): - self.dp.stop() - - def load(self, stitch_plan): - self.dp.load(stitch_plan) - self.cp.load(stitch_plan) - - def clear(self): - self.dp.clear() - self.cp.clear() - - -class SimulatorWindow(wx.Frame): - def __init__(self, panel=None, parent=None, **kwargs): - background_color = kwargs.pop('background_color', 'white') - super().__init__(None, title=_("Embroidery Simulation"), **kwargs) - - self.SetWindowStyle(wx.FRAME_FLOAT_ON_PARENT | wx.DEFAULT_FRAME_STYLE) - - self.sizer = wx.BoxSizer(wx.VERTICAL) - - self.statusbar = self.CreateStatusBar(2) - self.statusbar.SetStatusWidths((0, -1)) - - if panel and parent: - self.is_child = True - self.panel = panel - self.parent = parent - self.panel.Reparent(self) - self.sizer.Add(self.panel, 1, wx.EXPAND) - self.panel.Show() - else: - self.is_child = False - self.panel = SimulatorPanel(self, background_color=background_color) - self.sizer.Add(self.panel, 1, wx.EXPAND) - - self.SetSizer(self.sizer) - self.Layout() - - self.SetMinSize(self.sizer.CalcMin()) - - if self.is_child: - self.Bind(wx.EVT_CLOSE, self.on_close) - else: - self.Maximize() - - def detach_simulator_panel(self): - self.sizer.Detach(self.panel) - - def on_close(self, event): - self.parent.attach_simulator() - - def load(self, stitch_plan): - self.panel.load(stitch_plan) - - def go(self): - self.panel.go() - - -class SplitSimulatorWindow(wx.Frame): - def __init__(self, panel_class, title, target_duration=None, **kwargs): - super().__init__(None, title=title) - - self.SetWindowStyle(wx.FRAME_FLOAT_ON_PARENT | wx.DEFAULT_FRAME_STYLE) - - self.statusbar = self.CreateStatusBar(2) - - self.detached_simulator_frame = None - self.splitter = wx.SplitterWindow(self, style=wx.SP_LIVE_UPDATE) - background_color = kwargs.pop('background_color', 'white') - self.cancel_hook = kwargs.pop('on_cancel', None) - self.simulator_panel = SimulatorPanel( - self.splitter, - background_color=background_color, - target_duration=target_duration, - detach_callback=self.toggle_detach_simulator - ) - self.settings_panel = panel_class(self.splitter, simulator=self.simulator_panel, **kwargs) - - self.splitter.SplitVertically(self.settings_panel, self.simulator_panel) - self.splitter.SetMinimumPaneSize(100) - - icon = wx.Icon(os.path.join(get_resource_dir("icons"), "inkstitch256x256.png")) - self.SetIcon(icon) - - self.sizer = wx.BoxSizer(wx.VERTICAL) - self.sizer.Add(self.splitter, 1, wx.EXPAND) - self.SetSizer(self.sizer) - - self.SetMinSize(self.sizer.CalcMin()) - - self.simulator_panel.SetFocus() - self.Maximize() - self.Show() - wx.CallLater(100, self.set_sash_position) - - self.Bind(wx.EVT_SPLITTER_SASH_POS_CHANGING, self.splitter_resize) - self.Bind(wx.EVT_CLOSE, self.cancel) - - if global_settings['pop_out_simulator']: - self.detach_simulator() - - def splitter_resize(self, event): - self.statusbar.SetStatusWidths((self.simulator_panel.GetScreenPosition()[0], -1)) - - def set_sash_position(self): - settings_panel_min_size = self.settings_panel.GetSizer().CalcMin() - debug.log(f"{settings_panel_min_size=}") - self.splitter.SetSashPosition(settings_panel_min_size.width) - self.statusbar.SetStatusWidths((settings_panel_min_size.width, -1)) - - def cancel(self, event=None): - if self.cancel_hook: - self.cancel_hook() - self.close(None) - - def close(self, event=None): - self.simulator_panel.stop() - if self.detached_simulator_frame: - self.detached_simulator_frame.Destroy() - self.Destroy() - - def toggle_detach_simulator(self): - if self.detached_simulator_frame: - self.attach_simulator() - else: - self.detach_simulator() - - def attach_simulator(self): - self.detached_simulator_frame.detach_simulator_panel() - self.simulator_panel.Reparent(self.splitter) - self.splitter.SplitVertically(self.settings_panel, self.simulator_panel) - - self.GetStatusBar().SetStatusText(self.detached_simulator_frame.GetStatusBar().GetStatusText(1), 1) - - self.detached_simulator_frame.Destroy() - self.detached_simulator_frame = None - self.Maximize() - self.splitter.UpdateSize() - self.simulator_panel.SetFocus() - self.Raise() - wx.CallLater(100, self.set_sash_position) - global_settings['pop_out_simulator'] = False - - def detach_simulator(self): - self.splitter.Unsplit() - self.detached_simulator_frame = SimulatorWindow(panel=self.simulator_panel, parent=self) - self.splitter.SetMinimumPaneSize(100) - - current_screen = wx.Display.GetFromPoint(wx.GetMousePosition()) - display = wx.Display(current_screen) - screen_rect = display.GetClientArea() - settings_panel_size = self.settings_panel.GetSizer().CalcMin() - self.SetMinSize(settings_panel_size) - self.Maximize(False) - self.SetSize((settings_panel_size.width, screen_rect.height)) - self.SetPosition((screen_rect.left, screen_rect.top)) - - self.detached_simulator_frame.SetSize((screen_rect.width - settings_panel_size.width, screen_rect.height)) - self.detached_simulator_frame.SetPosition((settings_panel_size.width, screen_rect.top)) - - self.detached_simulator_frame.GetStatusBar().SetStatusText(self.GetStatusBar().GetStatusText(1), 1) - self.GetStatusBar().SetStatusText("", 1) - - self.detached_simulator_frame.Show() - - global_settings['pop_out_simulator'] = True - - -class PreviewRenderer(Thread): - """Render stitch plan in a background thread.""" - - def __init__(self, render_stitch_plan_hook, rendering_completed_hook): - super(PreviewRenderer, self).__init__() - self.daemon = True - self.refresh_needed = Event() - - self.render_stitch_plan_hook = render_stitch_plan_hook - self.rendering_completed_hook = rendering_completed_hook - - # This is read by utils.threading.check_stop_flag() to abort stitch plan - # generation. - self.stop = Event() - - def update(self): - """Request to render a new stitch plan. - - self.render_stitch_plan_hook() will be called in a background thread, and then - self.rendering_completed_hook() will be called with the resulting stitch plan. - """ - - if not self.is_alive(): - self.start() - - self.stop.set() - self.refresh_needed.set() - - def run(self): - while True: - self.refresh_needed.wait() - self.refresh_needed.clear() - self.stop.clear() - - try: - debug.log("update_patches") - self.render_stitch_plan() - except ExitThread: - debug.log("ExitThread caught") - self.stop.clear() - - def render_stitch_plan(self): - try: - stitch_plan = self.render_stitch_plan_hook() - if stitch_plan: - # rendering_completed() will be called in the main thread. - wx.CallAfter(self.rendering_completed_hook, stitch_plan) - except ExitThread: - raise - except: # noqa: E722 - import traceback - debug.log("unhandled exception in PreviewRenderer.render_stitch_plan(): " + traceback.format_exc()) diff --git a/lib/gui/simulator/__init__.py b/lib/gui/simulator/__init__.py new file mode 100644 index 00000000..4ccc745b --- /dev/null +++ b/lib/gui/simulator/__init__.py @@ -0,0 +1,14 @@ +# Authors: see git history +# +# Copyright (c) 2024 Authors +# Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details. + +from .simulator_preferences import SimulatorPreferenceDialog +from .simulator_slider import SimulatorSlider +from .control_panel import ControlPanel +from .view_panel import ViewPanel +from .drawing_panel import DrawingPanel +from .simulator_panel import SimulatorPanel +from .simulator_renderer import PreviewRenderer +from .simulator_window import SimulatorWindow +from .split_simulator_window import SplitSimulatorWindow diff --git a/lib/gui/simulator/control_panel.py b/lib/gui/simulator/control_panel.py new file mode 100644 index 00000000..a359fe64 --- /dev/null +++ b/lib/gui/simulator/control_panel.py @@ -0,0 +1,343 @@ +# Authors: see git history +# +# Copyright (c) 2024 Authors +# Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details. +import os + +import wx +from wx.lib.intctrl import IntCtrl + +from ...debug.debug import debug +from ...i18n import _ +from ...utils import get_resource_dir +from . import SimulatorSlider + + +class ControlPanel(wx.Panel): + """""" + + @debug.time + def __init__(self, parent, *args, **kwargs): + """""" + self.parent = parent + self.stitch_plan = kwargs.pop('stitch_plan', None) + self.detach_callback = kwargs.pop('detach_callback', None) + 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.drawing_panel = None + self.num_stitches = 0 + self.current_stitch = 0 + self.speed = 1 + self.direction = 1 + self._last_color_block_end = 0 + + self.icons_dir = get_resource_dir("icons") + + # Widgets + self.button_size = self.GetTextExtent("M").y * 2 + self.button_style = wx.BU_EXACTFIT | wx.BU_NOTEXT + self.btnMinus = wx.Button(self, -1, style=self.button_style) + self.btnMinus.Bind(wx.EVT_BUTTON, self.animation_slow_down) + self.btnMinus.SetBitmap(self.load_icon('slower')) + self.btnMinus.SetToolTip(_('Slow down (arrow down)')) + self.btnPlus = wx.Button(self, -1, style=self.button_style) + self.btnPlus.Bind(wx.EVT_BUTTON, self.animation_speed_up) + self.btnPlus.SetBitmap(self.load_icon('faster')) + self.btnPlus.SetToolTip(_('Speed up (arrow up)')) + self.btnBackwardStitch = wx.Button(self, -1, style=self.button_style) + self.btnBackwardStitch.Bind(wx.EVT_BUTTON, self.animation_one_stitch_backward) + self.btnBackwardStitch.SetBitmap(self.load_icon('backward_stitch')) + self.btnBackwardStitch.SetToolTip(_('Go backward one stitch (-)')) + self.btnForwardStitch = wx.Button(self, -1, style=self.button_style) + self.btnForwardStitch.Bind(wx.EVT_BUTTON, self.animation_one_stitch_forward) + self.btnForwardStitch.SetBitmap(self.load_icon('forward_stitch')) + self.btnForwardStitch.SetToolTip(_('Go forward one stitch (+)')) + self.btnBackwardCommand = wx.Button(self, -1, style=self.button_style) + self.btnBackwardCommand.Bind(wx.EVT_BUTTON, self.animation_one_command_backward) + self.btnBackwardCommand.SetBitmap(self.load_icon('backward_command')) + self.btnBackwardCommand.SetToolTip(_('Go backward one command (page-down)')) + self.btnForwardCommand = wx.Button(self, -1, style=self.button_style) + self.btnForwardCommand.Bind(wx.EVT_BUTTON, self.animation_one_command_forward) + self.btnForwardCommand.SetBitmap(self.load_icon('forward_command')) + self.btnForwardCommand.SetToolTip(_('Go forward one command (page-up)')) + self.btnDirection = wx.Button(self, -1, style=self.button_style) + self.btnDirection.Bind(wx.EVT_BUTTON, self.on_direction_button) + self.btnDirection.SetBitmap(self.load_icon('direction')) + self.btnDirection.SetToolTip(_('Switch animation direction (arrow left, arrow right)')) + self.btnPlay = wx.BitmapToggleButton(self, -1, style=self.button_style) + self.btnPlay.Bind(wx.EVT_TOGGLEBUTTON, self.on_play_button) + self.btnPlay.SetBitmap(self.load_icon('play')) + self.btnPlay.SetToolTip(_('Play (P)')) + self.btnRestart = wx.Button(self, -1, style=self.button_style) + self.btnRestart.Bind(wx.EVT_BUTTON, self.animation_restart) + self.btnRestart.SetBitmap(self.load_icon('restart')) + self.btnRestart.SetToolTip(_('Restart (R)')) + self.slider = SimulatorSlider(self, -1, value=1, minValue=1, maxValue=2) + self.slider.Bind(wx.EVT_SLIDER, self.on_slider) + self.stitchBox = IntCtrl(self, -1, value=1, min=1, max=2, limited=True, allow_none=True, + size=((100, -1)), style=wx.TE_PROCESS_ENTER) + self.stitchBox.Clear() + self.stitchBox.Bind(wx.EVT_LEFT_DOWN, self.on_stitch_box_focus) + self.stitchBox.Bind(wx.EVT_SET_FOCUS, self.on_stitch_box_focus) + self.stitchBox.Bind(wx.EVT_TEXT_ENTER, self.on_stitch_box_focusout) + self.stitchBox.Bind(wx.EVT_KILL_FOCUS, self.on_stitch_box_focusout) + self.Bind(wx.EVT_LEFT_DOWN, self.on_stitch_box_focusout) + self.totalstitchText = wx.StaticText(self, -1, label="") + extent = self.totalstitchText.GetTextExtent("0000000") + self.totalstitchText.SetMinSize(extent) + + # Layout + self.hbSizer1 = wx.BoxSizer(wx.HORIZONTAL) + self.hbSizer1.Add(self.slider, 1, wx.EXPAND | wx.RIGHT, 10) + self.hbSizer1.Add(self.stitchBox, 0, wx.ALIGN_TOP | wx.TOP, 25) + self.hbSizer1.Add((1, 1), 0, wx.RIGHT, 10) + self.hbSizer1.Add(self.totalstitchText, 0, wx.ALIGN_TOP | wx.TOP, 25) + self.hbSizer1.Add((1, 1), 0, wx.RIGHT, 10) + + self.controls_sizer = wx.StaticBoxSizer(wx.StaticBox(self, wx.ID_ANY, _("Controls")), wx.HORIZONTAL) + self.controls_inner_sizer = wx.BoxSizer(wx.HORIZONTAL) + self.controls_inner_sizer.Add(self.btnBackwardCommand, 0, wx.EXPAND | wx.ALL, 2) + self.controls_inner_sizer.Add(self.btnBackwardStitch, 0, wx.EXPAND | wx.ALL, 2) + self.controls_inner_sizer.Add(self.btnForwardStitch, 0, wx.EXPAND | wx.ALL, 2) + self.controls_inner_sizer.Add(self.btnForwardCommand, 0, wx.EXPAND | wx.ALL, 2) + self.controls_inner_sizer.Add(self.btnDirection, 0, wx.EXPAND | wx.ALL, 2) + self.controls_inner_sizer.Add(self.btnPlay, 0, wx.EXPAND | wx.ALL, 2) + self.controls_inner_sizer.Add(self.btnRestart, 0, wx.EXPAND | wx.ALL, 2) + self.controls_sizer.Add((1, 1), 1) + self.controls_sizer.Add(self.controls_inner_sizer, 0, wx.ALIGN_CENTER_VERTICAL | wx.ALL, 10) + self.controls_sizer.Add((1, 1), 1) + + self.speed_sizer = wx.StaticBoxSizer(wx.StaticBox(self, wx.ID_ANY, _("Speed")), wx.VERTICAL) + + self.speed_buttons_sizer = wx.BoxSizer(wx.HORIZONTAL) + self.speed_buttons_sizer.Add((1, 1), 1) + self.speed_buttons_sizer.Add(self.btnMinus, 0, wx.ALL, 2) + self.speed_buttons_sizer.Add(self.btnPlus, 0, wx.ALL, 2) + self.speed_buttons_sizer.Add((1, 1), 1) + self.speed_sizer.Add(self.speed_buttons_sizer, 0, wx.EXPAND | wx.ALL) + self.speed_text = wx.StaticText(self, wx.ID_ANY, label="", style=wx.ALIGN_CENTRE_HORIZONTAL | wx.ST_NO_AUTORESIZE) + self.speed_text.SetFont(wx.Font(wx.FontInfo(10).Bold())) + extent = self.speed_text.GetTextExtent(self.format_speed_text(100000)) + self.speed_text.SetMinSize(extent) + self.speed_sizer.Add(self.speed_text, 0, wx.EXPAND | wx.ALL, 5) + + # A normal BoxSizer can only make child components the same or + # proportional size. A FlexGridSizer can split up the available extra + # space evenly among all growable columns. + self.control_row2_sizer = wx.FlexGridSizer(cols=3, vgap=0, hgap=5) + self.control_row2_sizer.AddGrowableCol(0) + self.control_row2_sizer.AddGrowableCol(1) + self.control_row2_sizer.AddGrowableCol(2) + self.control_row2_sizer.Add(self.controls_sizer, 0, wx.EXPAND) + self.control_row2_sizer.Add(self.speed_sizer, 0, wx.EXPAND) + + self.vbSizer = vbSizer = wx.BoxSizer(wx.VERTICAL) + vbSizer.Add(self.hbSizer1, 1, wx.EXPAND | wx.ALL, 10) + vbSizer.Add(self.control_row2_sizer, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 10) + self.SetSizerAndFit(vbSizer) + + # wait for layouts so that panel size is set + if self.stitch_plan: + wx.CallLater(50, self.load, self.stitch_plan) + + 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.SetValue(1) + self.stitchBox.SetMax(num_stitches) + self.slider.SetMax(num_stitches) + self.totalstitchText.SetLabel(f"/ { num_stitches }") + self.choose_speed() + + def clear(self): + self.stitches = [] + self._set_num_stitches(0) + self.slider.clear() + self.stitchBox.Clear() + self.totalstitchText.SetLabel("") + + def load(self, stitch_plan): + self.clear() + self.stitches = [] + self._set_num_stitches(stitch_plan.num_stitches) + + stitch_num = 0 + last_block_end = 1 + for color_block in stitch_plan.color_blocks: + self.stitches.extend(color_block.stitches) + + start = stitch_num + 1 + end = start + color_block.num_stitches - 1 + self.slider.add_color_section(color_block.color.rgb, last_block_end, end) + last_block_end = end + + for stitch_num, stitch in enumerate(color_block.stitches, start): + if stitch.trim: + self.slider.add_marker("trim", stitch_num) + elif stitch.stop: + self.slider.add_marker("stop", stitch_num) + elif stitch.jump: + self.slider.add_marker("jump", stitch_num) + elif stitch.color_change: + self.slider.add_marker("color_change", stitch_num) + + def is_dark_theme(self): + return wx.SystemSettings().GetAppearance().IsDark() + + def load_icon(self, icon_name): + if self.is_dark_theme(): + icon = wx.Image(os.path.join(self.icons_dir, f"{icon_name}_dark.png")) + else: + icon = wx.Image(os.path.join(self.icons_dir, f"{icon_name}.png")) + icon.Rescale(self.button_size, self.button_size, wx.IMAGE_QUALITY_HIGH) + return icon.ConvertToBitmap() + + 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.drawing_panel.forward() + self.direction = 1 + self.update_speed_text() + + def animation_reverse(self, event=None): + self.drawing_panel.reverse() + self.direction = -1 + self.update_speed_text() + + def on_direction_button(self, event): + if self.direction == -1: + self.animation_forward() + else: + self.animation_reverse() + + 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 format_speed_text(self, speed): + return _('%d stitches/sec') % speed + + def update_speed_text(self): + self.speed_text.SetLabel(self.format_speed_text(self.speed * self.direction)) + + def on_slider(self, event): + self.animation_pause() + stitch = event.GetEventObject().GetValue() + self.stitchBox.SetValue(stitch) + + if self.drawing_panel: + self.drawing_panel.set_current_stitch(stitch) + + self.parent.SetFocus() + + def on_current_stitch(self, stitch, command): + if self.current_stitch != stitch: + self.current_stitch = stitch + self.slider.SetValue(stitch) + self.stitchBox.SetValue(stitch) + + def on_stitch_box_focus(self, event): + self.animation_pause() + self.parent.SetAcceleratorTable(wx.AcceleratorTable([])) + event.Skip() + + def on_stitch_box_focusout(self, event): + self.parent.SetAcceleratorTable(self.parent.accel_table) + stitch = self.stitchBox.GetValue() + # We now want to remove the focus from the stitchBox. + # In Windows it won't work if we set focus to self.parent, while setting the focus to the + # top level would work. This in turn would activate the trim button in Linux. So let's + # set the focus on the slider instead where it doesn't cause any harm in any of the operating systems + self.slider.SetFocus() + + if stitch is None: + stitch = 1 + self.stitchBox.SetValue(1) + + self.slider.SetValue(stitch) + + if self.drawing_panel: + self.drawing_panel.set_current_stitch(stitch) + event.Skip() + + 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.btnPlay.SetValue(True) + + def on_stop(self): + self.btnPlay.SetValue(False) + + def on_play_button(self, event): + play = self.btnPlay.GetValue() + if play: + self.animation_start() + else: + self.animation_pause() + + def play_or_pause(self, event): + if self.drawing_panel.animating: + 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_one_command_backward(self, event): + self.animation_pause() + stitch_number = self.current_stitch - 1 + while stitch_number >= 1: + # stitch number shown to the user starts at 1 + stitch = self.stitches[stitch_number - 1] + if stitch.jump or stitch.trim or stitch.stop or stitch.color_change: + break + stitch_number -= 1 + self.drawing_panel.set_current_stitch(stitch_number) + + def animation_one_command_forward(self, event): + self.animation_pause() + stitch_number = self.current_stitch + 1 + while stitch_number <= self.num_stitches: + # stitch number shown to the user starts at 1 + stitch = self.stitches[stitch_number - 1] + if stitch.jump or stitch.trim or stitch.stop or stitch.color_change: + break + stitch_number += 1 + self.drawing_panel.set_current_stitch(stitch_number) + + def animation_restart(self, event): + self.drawing_panel.restart() diff --git a/lib/gui/simulator/drawing_panel.py b/lib/gui/simulator/drawing_panel.py new file mode 100644 index 00000000..0da58393 --- /dev/null +++ b/lib/gui/simulator/drawing_panel.py @@ -0,0 +1,423 @@ +# Authors: see git history +# +# Copyright (c) 2024 Authors +# Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details. +import time + +import wx +from numpy import split + +from ...i18n import _ +from ...svg import PIXELS_PER_MM +from ...utils.settings import global_settings + +# 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 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 + + def __init__(self, parent, *args, **kwargs): + """""" + self.parent = parent + self.stitch_plan = kwargs.pop('stitch_plan', None) + kwargs['style'] = wx.BORDER_SUNKEN + + wx.Panel.__init__(self, parent, *args, **kwargs) + + self.control_panel = parent.cp + self.view_panel = parent.vp + + # 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((300, 300)) + 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) + self.Bind(wx.EVT_SIZE, self.on_resize) + + # wait for layouts so that panel size is set + if self.stitch_plan: + wx.CallLater(50, self.load, self.stitch_plan) + + def on_resize(self, event): + self.choose_zoom_and_pan() + self.Refresh() + + 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): + dc = wx.PaintDC(self) + + if not self.loaded: + dc.Clear() + return + + canvas = wx.GraphicsContext.Create(dc) + + self.draw_stitches(canvas) + self.draw_scale(canvas) + + def draw_stitches(self, canvas): + canvas.BeginLayer(1) + + 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, jumps in zip(self.pens, self.stitch_blocks, self.jumps): + canvas.SetPen(pen) + if stitch + len(stitches) < self.current_stitch: + stitch += len(stitches) + if len(stitches) > 1: + self.draw_stitch_lines(canvas, pen, stitches, jumps) + self.draw_needle_penetration_points(canvas, pen, stitches) + last_stitch = stitches[-1] + else: + stitches = stitches[: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) + + canvas.EndLayer() + + def draw_crosshair(self, x, y, canvas, transform): + x, y = transform.TransformPoint(float(x), float(y)) + canvas.SetTransform(canvas.CreateMatrix()) + crosshair_radius = 10 + canvas.SetPen(self.black_pen) + canvas.StrokeLines(((x - crosshair_radius, y), (x + crosshair_radius, y))) + canvas.StrokeLines(((x, y - crosshair_radius), (x, y + crosshair_radius))) + + def draw_scale(self, canvas): + canvas.BeginLayer(1) + + canvas_width, canvas_height = self.GetClientSize() + + one_mm = PIXELS_PER_MM * self.zoom + scale_width = one_mm + max_width = min(canvas_width * 0.5, 300) + + while scale_width > max_width: + scale_width /= 2.0 + + while scale_width < 50: + scale_width += one_mm + + scale_width_mm = int(scale_width / self.zoom / PIXELS_PER_MM) + + # The scale bar looks like this: + # + # | | + # |_____|_____| + + scale_lower_left_x = 20 + scale_lower_left_y = canvas_height - 30 + + canvas.StrokeLines(((scale_lower_left_x, scale_lower_left_y - 6), + (scale_lower_left_x, scale_lower_left_y), + (scale_lower_left_x + scale_width / 2.0, scale_lower_left_y), + (scale_lower_left_x + scale_width / 2.0, scale_lower_left_y - 3), + (scale_lower_left_x + scale_width / 2.0, scale_lower_left_y), + (scale_lower_left_x + scale_width, scale_lower_left_y), + (scale_lower_left_x + scale_width, scale_lower_left_y - 6))) + + canvas.SetFont(wx.Font(12, wx.DEFAULT, wx.NORMAL, wx.NORMAL), wx.Colour((0, 0, 0))) + canvas.DrawText("%s mm" % scale_width_mm, scale_lower_left_x, scale_lower_left_y + 5) + + canvas.EndLayer() + + def draw_stitch_lines(self, canvas, pen, stitches, jumps): + render_jumps = self.view_panel.btnJump.GetValue() + if render_jumps: + canvas.StrokeLines(stitches) + else: + stitch_blocks = split(stitches, jumps) + for i, block in enumerate(stitch_blocks): + if len(block) > 1: + canvas.StrokeLines(block) + + def draw_needle_penetration_points(self, canvas, pen, stitches): + if self.view_panel.btnNpp.GetValue(): + npp_size = global_settings['simulator_npp_size'] * PIXELS_PER_MM * self.PIXEL_DENSITY + npp_pen = wx.Pen(pen.GetColour(), width=int(npp_size)) + canvas.SetPen(npp_pen) + canvas.StrokeLineSegments(stitches, [(stitch[0] + 0.001, stitch[1]) for stitch in stitches]) + + def clear(self): + self.loaded = False + self.Refresh() + + 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 + self.num_stitches = stitch_plan.num_stitches + 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 EVT_SIZE fired before we load the stitch plan + if not self.width and not self.height and event is not None: + 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 = max(min(width_ratio, height_ratio), 0.01) + + # 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): + line_width = global_settings['simulator_line_width'] * PIXELS_PER_MM * self.PIXEL_DENSITY + return wx.Pen(list(map(int, color.visible_on_white.rgb)), int(line_width)) + + def update_pen_size(self): + line_width = global_settings['simulator_line_width'] * PIXELS_PER_MM * self.PIXEL_DENSITY + for pen in self.pens: + pen.SetWidth(int(line_width)) + + def parse_stitch_plan(self, stitch_plan): + self.pens = [] + self.stitch_blocks = [] + self.jumps = [] + + # 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 = [] + jumps = [] + stitch_index = 0 + + 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) + jumps.append(stitch_index) + 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 = [] + self.jumps.append(jumps) + jumps = [] + stitch_index = 0 + else: + stitch_index += 1 + + if stitch_block: + self.pens.append(pen) + self.stitch_blocks.append(stitch_block) + self.jumps.append(jumps) + + 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() + command = self.commands[self.current_stitch] + self.control_panel.on_current_stitch(self.current_stitch, command) + statusbar = self.GetTopLevelParent().statusbar + statusbar.SetStatusText(_("Command: %s") % COMMAND_NAMES[command], 1) + 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): + if self.loaded: + 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 appear 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() diff --git a/lib/gui/simulator/simulator_panel.py b/lib/gui/simulator/simulator_panel.py new file mode 100644 index 00000000..593b551a --- /dev/null +++ b/lib/gui/simulator/simulator_panel.py @@ -0,0 +1,95 @@ +# 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 wx + +from . import ControlPanel, DrawingPanel, ViewPanel + + +class SimulatorPanel(wx.Panel): + """""" + + def __init__(self, parent, stitch_plan=None, background_color='white', target_duration=5, stitches_per_second=16, detach_callback=None): + """""" + super().__init__(parent, style=wx.BORDER_SUNKEN) + + self.cp = ControlPanel( + self, + stitch_plan=stitch_plan, + stitches_per_second=stitches_per_second, + target_duration=target_duration, + detach_callback=detach_callback + ) + + self.vp = ViewPanel( + self, + detach_callback + ) + self.dp = DrawingPanel(self, stitch_plan=stitch_plan) + self.cp.set_drawing_panel(self.dp) + self.vp.set_drawing_panel(self.dp) + self.vp.set_background_color(wx.Colour(background_color)) + + dvSizer = wx.BoxSizer(wx.HORIZONTAL) + + 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) + + dvSizer.Add(vbSizer, 1, wx.EXPAND | wx.ALL, 2) + dvSizer.Add(self.vp, 0, wx.ALL, 2) + + self.SetSizerAndFit(dvSizer) + + # Keyboard Shortcuts + shortcut_keys = [ + (wx.ACCEL_NORMAL, wx.WXK_RIGHT, self.cp.animation_forward), + (wx.ACCEL_NORMAL, wx.WXK_NUMPAD_RIGHT, self.cp.animation_forward), + (wx.ACCEL_NORMAL, wx.WXK_LEFT, self.cp.animation_reverse), + (wx.ACCEL_NORMAL, wx.WXK_NUMPAD_LEFT, self.cp.animation_reverse), + (wx.ACCEL_NORMAL, wx.WXK_UP, self.cp.animation_speed_up), + (wx.ACCEL_NORMAL, wx.WXK_NUMPAD_UP, self.cp.animation_speed_up), + (wx.ACCEL_NORMAL, wx.WXK_DOWN, self.cp.animation_slow_down), + (wx.ACCEL_NORMAL, wx.WXK_NUMPAD_DOWN, self.cp.animation_slow_down), + (wx.ACCEL_NORMAL, ord('+'), self.cp.animation_one_stitch_forward), + (wx.ACCEL_NORMAL, ord('='), self.cp.animation_one_stitch_forward), + (wx.ACCEL_SHIFT, ord('='), self.cp.animation_one_stitch_forward), + (wx.ACCEL_NORMAL, wx.WXK_ADD, self.cp.animation_one_stitch_forward), + (wx.ACCEL_NORMAL, wx.WXK_NUMPAD_ADD, self.cp.animation_one_stitch_forward), + (wx.ACCEL_NORMAL, wx.WXK_NUMPAD_UP, self.cp.animation_one_stitch_forward), + (wx.ACCEL_NORMAL, ord('-'), self.cp.animation_one_stitch_backward), + (wx.ACCEL_NORMAL, ord('_'), self.cp.animation_one_stitch_backward), + (wx.ACCEL_NORMAL, wx.WXK_SUBTRACT, self.cp.animation_one_stitch_backward), + (wx.ACCEL_NORMAL, wx.WXK_NUMPAD_SUBTRACT, self.cp.animation_one_stitch_backward), + (wx.ACCEL_NORMAL, ord('r'), self.cp.animation_restart), + (wx.ACCEL_NORMAL, ord('p'), self.cp.play_or_pause), + (wx.ACCEL_NORMAL, wx.WXK_SPACE, self.cp.play_or_pause), + (wx.ACCEL_NORMAL, wx.WXK_PAGEDOWN, self.cp.animation_one_command_backward), + (wx.ACCEL_NORMAL, wx.WXK_PAGEUP, self.cp.animation_one_command_forward), + (wx.ACCEL_NORMAL, ord('o'), self.vp.on_toggle_npp_shortcut) + ] + + self.accel_entries = [] + + for shortcut_key in shortcut_keys: + eventId = wx.NewIdRef() + self.accel_entries.append((shortcut_key[0], shortcut_key[1], eventId)) + self.Bind(wx.EVT_MENU, shortcut_key[2], id=eventId) + + self.accel_table = wx.AcceleratorTable(self.accel_entries) + self.SetAcceleratorTable(self.accel_table) + + def go(self): + self.dp.go() + + def stop(self): + self.dp.stop() + + def load(self, stitch_plan): + self.dp.load(stitch_plan) + self.cp.load(stitch_plan) + + def clear(self): + self.dp.clear() + self.cp.clear() diff --git a/lib/gui/simulator/simulator_preferences.py b/lib/gui/simulator/simulator_preferences.py new file mode 100644 index 00000000..a3e23bdc --- /dev/null +++ b/lib/gui/simulator/simulator_preferences.py @@ -0,0 +1,71 @@ +# 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 wx + +from ...i18n import _ +from ...utils.settings import global_settings + + +class SimulatorPreferenceDialog(wx.Dialog): + """A dialog to set simulator preferences + """ + + def __init__(self, *args, **kwargs): + super(SimulatorPreferenceDialog, self).__init__(*args, **kwargs) + self.SetWindowStyle(wx.FRAME_FLOAT_ON_PARENT | wx.DEFAULT_FRAME_STYLE) + + self.view_panel = self.GetParent() + self.drawing_panel = self.view_panel.drawing_panel + + self.line_width_value = global_settings['simulator_line_width'] + self.npp_size_value = global_settings['simulator_npp_size'] + + sizer = wx.BoxSizer(wx.VERTICAL) + settings_sizer = wx.FlexGridSizer(2, 2, 5, 5) + line_width_label = wx.StaticText(self, label=_("Line width (mm)")) + self.line_width = wx.SpinCtrlDouble(self, min=0.03, max=2, initial=0.1, inc=0.01, style=wx.SP_WRAP | wx.SP_ARROW_KEYS) + self.line_width.SetDigits(2) + self.line_width.SetValue(self.line_width_value) + self.line_width.Bind(wx.EVT_SPINCTRLDOUBLE, lambda event: self.on_change("simulator_line_width", event)) + npp_size_label = wx.StaticText(self, label=_("Needle penetration point size (mm)")) + self.npp_size = wx.SpinCtrlDouble(self, min=0.03, max=2, initial=0.5, inc=0.01, style=wx.SP_WRAP | wx.SP_ARROW_KEYS) + self.npp_size.SetDigits(2) + self.npp_size.SetValue(self.npp_size_value) + self.npp_size.Bind(wx.EVT_SPINCTRLDOUBLE, lambda event: self.on_change("simulator_npp_size", event)) + settings_sizer.Add(line_width_label, 0, wx.ALIGN_CENTRE | wx.ALL, 10) + settings_sizer.Add(self.line_width, 0, wx.EXPAND | wx.ALL, 10) + settings_sizer.Add(npp_size_label, 0, wx.ALIGN_CENTRE | wx.ALL, 10) + settings_sizer.Add(self.npp_size, 0, wx.EXPAND | wx.ALL, 10) + + button_sizer = wx.BoxSizer(wx.HORIZONTAL) + btn_cancel = wx.Button(self, id=wx.ID_CANCEL, label=_('Cancel')) + btn_cancel.Bind(wx.EVT_BUTTON, self.on_cancel) + btn_apply = wx.Button(self, id=wx.ID_OK, label=_('Apply')) + btn_apply.Bind(wx.EVT_BUTTON, self.on_apply) + button_sizer.Add(btn_cancel, 0, wx.RIGHT, 10) + button_sizer.Add(btn_apply, 0, wx.RIGHT, 10) + + sizer.Add(settings_sizer, 1, wx.ALL, 10) + sizer.Add(button_sizer, 0, wx.ALIGN_RIGHT | wx.ALL, 10) + self.SetSizerAndFit(sizer) + + def on_change(self, attribute, event): + global_settings[attribute] = event.EventObject.GetValue() + if attribute == 'simulator_line_width': + self.drawing_panel.update_pen_size() + self.drawing_panel.Refresh() + + def on_apply(self, event): + global_settings['simulator_line_width'] = self.line_width.GetValue() + global_settings['simulator_npp_size'] = self.npp_size.GetValue() + self.Destroy() + + def on_cancel(self, event): + global_settings['simulator_line_width'] = self.line_width_value + global_settings['simulator_npp_size'] = self.npp_size_value + self.drawing_panel.update_pen_size() + self.drawing_panel.Refresh() + self.Destroy() diff --git a/lib/gui/simulator/simulator_renderer.py b/lib/gui/simulator/simulator_renderer.py new file mode 100644 index 00000000..efe55ccb --- /dev/null +++ b/lib/gui/simulator/simulator_renderer.py @@ -0,0 +1,64 @@ +# Authors: see git history +# +# Copyright (c) 2024 Authors +# Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details. +from threading import Event, Thread + +import wx + +from ...debug.debug import debug +from ...utils.threading import ExitThread + + +class PreviewRenderer(Thread): + """Render stitch plan in a background thread.""" + + def __init__(self, render_stitch_plan_hook, rendering_completed_hook): + super(PreviewRenderer, self).__init__() + self.daemon = True + self.refresh_needed = Event() + + self.render_stitch_plan_hook = render_stitch_plan_hook + self.rendering_completed_hook = rendering_completed_hook + + # This is read by utils.threading.check_stop_flag() to abort stitch plan + # generation. + self.stop = Event() + + def update(self): + """Request to render a new stitch plan. + + self.render_stitch_plan_hook() will be called in a background thread, and then + self.rendering_completed_hook() will be called with the resulting stitch plan. + """ + + if not self.is_alive(): + self.start() + + self.stop.set() + self.refresh_needed.set() + + def run(self): + while True: + self.refresh_needed.wait() + self.refresh_needed.clear() + self.stop.clear() + + try: + debug.log("update_patches") + self.render_stitch_plan() + except ExitThread: + debug.log("ExitThread caught") + self.stop.clear() + + def render_stitch_plan(self): + try: + stitch_plan = self.render_stitch_plan_hook() + if stitch_plan: + # rendering_completed() will be called in the main thread. + wx.CallAfter(self.rendering_completed_hook, stitch_plan) + except ExitThread: + raise + except: # noqa: E722 + import traceback + debug.log("unhandled exception in PreviewRenderer.render_stitch_plan(): " + traceback.format_exc()) diff --git a/lib/gui/simulator/simulator_slider.py b/lib/gui/simulator/simulator_slider.py new file mode 100644 index 00000000..9d0b2537 --- /dev/null +++ b/lib/gui/simulator/simulator_slider.py @@ -0,0 +1,234 @@ +# Authors: see git history +# +# Copyright (c) 2024 Authors +# Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details. +import os +import sys + +import wx + +from ...debug.debug import debug +from ...utils import get_resource_dir + + +class SimulatorSlider(wx.Panel): + PROXY_EVENTS = (wx.EVT_SLIDER,) + + def __init__(self, parent, id=wx.ID_ANY, minValue=1, maxValue=2, **kwargs): + super().__init__(parent, id) + self.control_panel = parent + + kwargs['style'] = wx.SL_HORIZONTAL | wx.SL_VALUE_LABEL | wx.SL_TOP | wx.ALIGN_TOP + + self._height = self.GetTextExtent("M").y * 6 + self.SetMinSize((self._height, self._height)) + + self.marker_lists = { + "trim": MarkerList("trim"), + "jump": MarkerList("jump", 0.17), + "stop": MarkerList("stop", 0.34), + "color_change": MarkerList("color_change", 0.34), + } + self.marker_pen = wx.Pen(wx.Colour(0, 0, 0)) + self.color_sections = [] + self.margin = 15 + self.tab_start = 0 + self.tab_width = 0.15 + self.tab_height = 0.15 + self.color_bar_start = 0.22 + self.color_bar_thickness = 0.17 + self.marker_start = self.color_bar_start + self.marker_end = 0.5 + self.marker_icon_start = 0.5 + self.marker_icon_size = self._height // 6 + + self._min = minValue + self._max = maxValue + self._value = 0 + self._tab_rect = None + + if sys.platform == "darwin": + self.margin = 8 + + self.Bind(wx.EVT_PAINT, self.on_paint) + self.Bind(wx.EVT_ERASE_BACKGROUND, self.on_erase_background) + self.Bind(wx.EVT_LEFT_DOWN, self.on_mouse_down) + self.Bind(wx.EVT_LEFT_UP, self.on_mouse_up) + self.Bind(wx.EVT_MOTION, self.on_mouse_motion) + + def SetMax(self, value): + self._max = value + self.Refresh() + + def SetMin(self, value): + self._min = value + self.Refresh() + + def SetValue(self, value): + self._value = value + self.Refresh() + + def GetValue(self): + return self._value + + def clear(self): + self.color_sections = [] + self._min = 1 + self._max = 2 + self._value = 0 + self._tab_rect = None + + for marker_list in self.marker_lists.values(): + marker_list.clear() + + def add_color_section(self, color, start, end): + self.color_sections.append(ColorSection(color, start, end)) + + def add_marker(self, name, location): + self.marker_lists[name].append(location) + self.Refresh() + + def enable_marker_list(self, name, enabled=True): + self.marker_lists[name].enabled = enabled + self.Refresh() + + def disable_marker_list(self, name): + self.marker_lists[name].enabled = False + self.Refresh() + + def toggle_marker_list(self, name): + self.marker_lists[name].enabled = not self.marker_lists[name].enabled + self.Refresh() + + def on_paint(self, event): + dc = wx.BufferedPaintDC(self) + if not sys.platform.startswith("win"): + # Without this, the background color will be white. + background_brush = wx.Brush(self.GetTopLevelParent().GetBackgroundColour(), wx.SOLID) + dc.SetBackground(background_brush) + dc.Clear() + gc = wx.GraphicsContext.Create(dc) + + if self._value < self._min: + return + + width, height = self.GetSize() + min_value = self._min + max_value = self._max + spread = max_value - min_value + + def _value_to_x(value): + return (value - min_value) * (width - 2 * self.margin) / spread + self.margin + + gc.SetPen(wx.NullPen) + for color_section in self.color_sections: + gc.SetBrush(color_section.brush) + + start_x = _value_to_x(color_section.start) + end_x = _value_to_x(color_section.end) + gc.DrawRectangle(start_x, height * self.color_bar_start, + end_x - start_x, height * self.color_bar_thickness) + + if self.control_panel.is_dark_theme(): + gc.SetPen(wx.Pen(wx.Colour(0, 0, 0), 1)) + gc.SetBrush(wx.Brush(wx.Colour(255, 255, 255))) + else: + gc.SetPen(wx.Pen(wx.Colour(255, 255, 255), 1)) + gc.SetBrush(wx.Brush(wx.Colour(0, 0, 0))) + + value_x = _value_to_x(self._value) + tab_height = self.tab_height * height + tab_width = self.tab_width * height + tab_x = value_x - tab_width / 2 + tab_y = height * self.tab_start + self._tab_rect = wx.Rect(round(tab_x), round(tab_y), round(tab_width), round(tab_height)) + gc.DrawRectangle( + value_x - 1.5, 0, + 3, height * (self.color_bar_start + self.color_bar_thickness)) + gc.SetPen(wx.NullPen) + gc.DrawRectangle(value_x - tab_width/2, height * self.tab_start, + tab_width, tab_height) + + gc.SetPen(self.marker_pen) + for marker_list in self.marker_lists.values(): + if marker_list.enabled: + for value in marker_list: + x = _value_to_x(value) + gc.StrokeLine( + x, height * self.marker_start, + x, height * (self.marker_end + marker_list.offset) + ) + gc.DrawBitmap( + marker_list.icon, + x - self.marker_icon_size / 2, height * (self.marker_icon_start + marker_list.offset), + self.marker_icon_size, self.marker_icon_size + ) + + def on_erase_background(self, event): + # supposedly this prevents flickering? + pass + + def is_in_tab(self, point): + return self._tab_rect and self._tab_rect.Inflate(2).Contains(point) + + def set_value_from_position(self, point): + width, height = self.GetSize() + min_value = self._min + max_value = self._max + spread = max_value - min_value + value = round((point.x - self.margin) * spread / (width - 2 * self.margin)) + value = max(value, self._min) + value = min(value, self._max) + self.SetValue(round(value)) + + event = wx.CommandEvent(wx.wxEVT_COMMAND_SLIDER_UPDATED, self.GetId()) + event.SetInt(value) + event.SetEventObject(self) + self.GetEventHandler().ProcessEvent(event) + + def on_mouse_down(self, event): + click_pos = event.GetPosition() + if self.is_in_tab(click_pos): + debug.log("drag start") + self.CaptureMouse() + self.set_value_from_position(click_pos) + self.Refresh() + else: + width, height = self.GetSize() + relative_y = click_pos.y / height + if relative_y > self.color_bar_start and relative_y - self.color_bar_start < self.color_bar_thickness: + self.set_value_from_position(click_pos) + self.Refresh() + + def on_mouse_motion(self, event): + if self.HasCapture() and event.Dragging() and event.LeftIsDown(): + self.set_value_from_position(event.GetPosition()) + self.Refresh() + + def on_mouse_up(self, event): + if self.HasCapture(): + self.ReleaseMouse() + self.set_value_from_position(event.GetPosition()) + self.Refresh() + + +class MarkerList(list): + def __init__(self, icon_name, offset=0, stitch_numbers=()): + super().__init__(self) + icons_dir = get_resource_dir("icons") + self.icon_name = icon_name + self.icon = wx.Image(os.path.join(icons_dir, f"{icon_name}.png")).ConvertToBitmap() + self.offset = offset + self.enabled = False + self.extend(stitch_numbers) + + def __repr__(self): + return f"MarkerList({self.icon_name})" + + +class ColorSection: + def __init__(self, color, start, end): + self.color = color + self.start = start + self.end = end + self.brush = wx.Brush(wx.Colour(*color)) diff --git a/lib/gui/simulator/simulator_window.py b/lib/gui/simulator/simulator_window.py new file mode 100644 index 00000000..83321745 --- /dev/null +++ b/lib/gui/simulator/simulator_window.py @@ -0,0 +1,55 @@ +# 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 wx + +from ...i18n import _ +from . import SimulatorPanel + + +class SimulatorWindow(wx.Frame): + def __init__(self, panel=None, parent=None, **kwargs): + background_color = kwargs.pop('background_color', 'white') + super().__init__(None, title=_("Embroidery Simulation"), **kwargs) + + self.SetWindowStyle(wx.FRAME_FLOAT_ON_PARENT | wx.DEFAULT_FRAME_STYLE) + + self.sizer = wx.BoxSizer(wx.VERTICAL) + + self.statusbar = self.CreateStatusBar(2) + self.statusbar.SetStatusWidths((0, -1)) + + if panel and parent: + self.is_child = True + self.panel = panel + self.parent = parent + self.panel.Reparent(self) + self.sizer.Add(self.panel, 1, wx.EXPAND) + self.panel.Show() + else: + self.is_child = False + self.panel = SimulatorPanel(self, background_color=background_color) + self.sizer.Add(self.panel, 1, wx.EXPAND) + + self.SetSizer(self.sizer) + self.Layout() + + self.SetMinSize(self.sizer.CalcMin()) + + if self.is_child: + self.Bind(wx.EVT_CLOSE, self.on_close) + else: + self.Maximize() + + def detach_simulator_panel(self): + self.sizer.Detach(self.panel) + + def on_close(self, event): + self.parent.attach_simulator() + + def load(self, stitch_plan): + self.panel.load(stitch_plan) + + def go(self): + self.panel.go() diff --git a/lib/gui/simulator/split_simulator_window.py b/lib/gui/simulator/split_simulator_window.py new file mode 100644 index 00000000..ce21a737 --- /dev/null +++ b/lib/gui/simulator/split_simulator_window.py @@ -0,0 +1,122 @@ +# Authors: see git history +# +# Copyright (c) 2024 Authors +# Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details. +import os + +import wx + +from ...debug.debug import debug +from ...utils import get_resource_dir +from ...utils.settings import global_settings +from . import SimulatorPanel, SimulatorWindow + + +class SplitSimulatorWindow(wx.Frame): + def __init__(self, panel_class, title, target_duration=None, **kwargs): + super().__init__(None, title=title) + + self.SetWindowStyle(wx.FRAME_FLOAT_ON_PARENT | wx.DEFAULT_FRAME_STYLE) + + self.statusbar = self.CreateStatusBar(2) + + self.detached_simulator_frame = None + self.splitter = wx.SplitterWindow(self, style=wx.SP_LIVE_UPDATE) + background_color = kwargs.pop('background_color', 'white') + self.cancel_hook = kwargs.pop('on_cancel', None) + self.simulator_panel = SimulatorPanel( + self.splitter, + background_color=background_color, + target_duration=target_duration, + detach_callback=self.toggle_detach_simulator + ) + self.settings_panel = panel_class(self.splitter, simulator=self.simulator_panel, **kwargs) + + self.splitter.SplitVertically(self.settings_panel, self.simulator_panel) + self.splitter.SetMinimumPaneSize(100) + + icon = wx.Icon(os.path.join(get_resource_dir("icons"), "inkstitch256x256.png")) + self.SetIcon(icon) + + self.sizer = wx.BoxSizer(wx.VERTICAL) + self.sizer.Add(self.splitter, 1, wx.EXPAND) + self.SetSizer(self.sizer) + + self.SetMinSize(self.sizer.CalcMin()) + + self.simulator_panel.SetFocus() + self.Maximize() + self.Show() + wx.CallLater(100, self.set_sash_position) + + self.Bind(wx.EVT_SPLITTER_SASH_POS_CHANGING, self.splitter_resize) + self.Bind(wx.EVT_CLOSE, self.cancel) + + if global_settings['pop_out_simulator']: + self.detach_simulator() + + def splitter_resize(self, event): + self.statusbar.SetStatusWidths((self.simulator_panel.GetScreenPosition()[0], -1)) + + def set_sash_position(self): + settings_panel_min_size = self.settings_panel.GetSizer().CalcMin() + debug.log(f"{settings_panel_min_size=}") + self.splitter.SetSashPosition(settings_panel_min_size.width) + self.statusbar.SetStatusWidths((settings_panel_min_size.width, -1)) + + def cancel(self, event=None): + if self.cancel_hook: + self.cancel_hook() + self.close(None) + + def close(self, event=None): + self.simulator_panel.stop() + if self.detached_simulator_frame: + self.detached_simulator_frame.Destroy() + self.Destroy() + + def toggle_detach_simulator(self): + if self.detached_simulator_frame: + self.attach_simulator() + else: + self.detach_simulator() + + def attach_simulator(self): + self.detached_simulator_frame.detach_simulator_panel() + self.simulator_panel.Reparent(self.splitter) + self.splitter.SplitVertically(self.settings_panel, self.simulator_panel) + + self.GetStatusBar().SetStatusText(self.detached_simulator_frame.GetStatusBar().GetStatusText(1), 1) + + self.detached_simulator_frame.Destroy() + self.detached_simulator_frame = None + self.Maximize() + self.splitter.UpdateSize() + self.simulator_panel.SetFocus() + self.Raise() + wx.CallLater(100, self.set_sash_position) + global_settings['pop_out_simulator'] = False + + def detach_simulator(self): + self.splitter.Unsplit() + self.detached_simulator_frame = SimulatorWindow(panel=self.simulator_panel, parent=self) + self.splitter.SetMinimumPaneSize(100) + + current_screen = wx.Display.GetFromPoint(wx.GetMousePosition()) + display = wx.Display(current_screen) + screen_rect = display.GetClientArea() + settings_panel_size = self.settings_panel.GetSizer().CalcMin() + self.SetMinSize(settings_panel_size) + self.Maximize(False) + self.SetSize((settings_panel_size.width, screen_rect.height)) + self.SetPosition((screen_rect.left, screen_rect.top)) + + self.detached_simulator_frame.SetSize((screen_rect.width - settings_panel_size.width, screen_rect.height)) + self.detached_simulator_frame.SetPosition((settings_panel_size.width, screen_rect.top)) + + self.detached_simulator_frame.GetStatusBar().SetStatusText(self.GetStatusBar().GetStatusText(1), 1) + self.GetStatusBar().SetStatusText("", 1) + + self.detached_simulator_frame.Show() + + global_settings['pop_out_simulator'] = True diff --git a/lib/gui/simulator/view_panel.py b/lib/gui/simulator/view_panel.py new file mode 100644 index 00000000..cdcd52ee --- /dev/null +++ b/lib/gui/simulator/view_panel.py @@ -0,0 +1,117 @@ +# Authors: see git history +# +# Copyright (c) 2024 Authors +# Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details. +import wx +from wx.lib.scrolledpanel import ScrolledPanel + +from ...debug.debug import debug +from ...i18n import _ +from . import SimulatorPreferenceDialog + + +class ViewPanel(ScrolledPanel): + """""" + + @debug.time + def __init__(self, parent, detach_callback): + """""" + self.parent = parent + self.detach_callback = detach_callback + ScrolledPanel.__init__(self, parent) + self.SetupScrolling(scroll_y=True, scroll_x=False) + + self.button_style = wx.BU_EXACTFIT | wx.BU_NOTEXT + + self.control_panel = parent.cp + + self.btnNpp = wx.BitmapToggleButton(self, -1, style=self.button_style) + self.btnNpp.Bind(wx.EVT_TOGGLEBUTTON, self.toggle_npp) + self.btnNpp.SetBitmap(self.control_panel.load_icon('npp')) + self.btnNpp.SetToolTip(_('Display needle penetration point (O)')) + self.btnJump = wx.BitmapToggleButton(self, -1, style=self.button_style) + self.btnJump.SetToolTip(_('Show jump stitches')) + self.btnJump.SetBitmap(self.control_panel.load_icon('jump')) + self.btnJump.Bind(wx.EVT_TOGGLEBUTTON, lambda event: self.on_marker_button('jump', event)) + self.btnTrim = wx.BitmapToggleButton(self, -1, style=self.button_style) + self.btnTrim.SetToolTip(_('Show trims')) + self.btnTrim.SetBitmap(self.control_panel.load_icon('trim')) + self.btnTrim.Bind(wx.EVT_TOGGLEBUTTON, lambda event: self.on_marker_button('trim', event)) + self.btnStop = wx.BitmapToggleButton(self, -1, style=self.button_style) + self.btnStop.SetToolTip(_('Show stops')) + self.btnStop.SetBitmap(self.control_panel.load_icon('stop')) + self.btnStop.Bind(wx.EVT_TOGGLEBUTTON, lambda event: self.on_marker_button('stop', event)) + self.btnColorChange = wx.BitmapToggleButton(self, -1, style=self.button_style) + self.btnColorChange.SetToolTip(_('Show color changes')) + self.btnColorChange.SetBitmap(self.control_panel.load_icon('color_change')) + self.btnColorChange.Bind(wx.EVT_TOGGLEBUTTON, lambda event: self.on_marker_button('color_change', event)) + + self.btnBackgroundColor = wx.ColourPickerCtrl(self, -1, colour='white', size=((40, -1))) + self.btnBackgroundColor.SetToolTip(_("Change background color")) + self.btnBackgroundColor.Bind(wx.EVT_COLOURPICKER_CHANGED, self.on_update_background_color) + + self.btnSettings = wx.BitmapButton(self, -1, style=self.button_style) + self.btnSettings.SetToolTip(_('Open settings dialog')) + self.btnSettings.SetBitmap(self.control_panel.load_icon('settings')) + self.btnSettings.Bind(wx.EVT_BUTTON, self.on_settings_button) + + if self.detach_callback: + self.btnDetachSimulator = wx.BitmapButton(self, -1, style=self.button_style) + self.btnDetachSimulator.SetToolTip(_('Detach/attach simulator window')) + self.btnDetachSimulator.SetBitmap(self.control_panel.load_icon('detach_window')) + self.btnDetachSimulator.Bind(wx.EVT_BUTTON, lambda event: self.control_panel.detach_callback()) + + outer_sizer = wx.BoxSizer(wx.VERTICAL) + + show_sizer = wx.StaticBoxSizer(wx.StaticBox(self, wx.ID_ANY, _("Show")), wx.VERTICAL) + show_inner_sizer = wx.BoxSizer(wx.VERTICAL) + show_inner_sizer.Add(self.btnNpp, 0, wx.ALL, 2) + show_inner_sizer.Add(self.btnJump, 0, wx.ALL, 2) + show_inner_sizer.Add(self.btnTrim, 0, wx.ALL, 2) + show_inner_sizer.Add(self.btnStop, 0, wx.ALL, 2) + show_inner_sizer.Add(self.btnColorChange, 0, wx.ALL, 2) + show_sizer.Add(0, 2, 0) + show_sizer.Add(show_inner_sizer, 0, wx.ALIGN_CENTER_HORIZONTAL | wx.ALL, 2) + show_sizer.Add(0, 2, 0) + outer_sizer.Add(show_sizer) + outer_sizer.Add(0, 10, 0) + + settings_sizer = wx.StaticBoxSizer(wx.StaticBox(self, wx.ID_ANY, _("Settings")), wx.VERTICAL) + settings_inner_sizer = wx.BoxSizer(wx.VERTICAL) + settings_inner_sizer.Add(self.btnBackgroundColor, 0, wx.EXPAND | wx.ALL, 2) + settings_inner_sizer.Add(self.btnSettings, 0, wx.EXPAND | wx.ALL, 2) + if self.detach_callback: + settings_inner_sizer.Add(self.btnDetachSimulator, 0, wx.ALL, 2) + settings_sizer.Add(0, 2, 0) + settings_sizer.Add(settings_inner_sizer, 0, wx.ALIGN_CENTER_HORIZONTAL | wx.ALL, 2) + settings_sizer.Add(0, 2, 0) + outer_sizer.Add(settings_sizer) + + self.SetSizerAndFit(outer_sizer) + + def set_drawing_panel(self, drawing_panel): + self.drawing_panel = drawing_panel + + def on_update_background_color(self, event): + self.set_background_color(event.Colour) + + def set_background_color(self, color): + self.btnBackgroundColor.SetColour(color) + self.drawing_panel.SetBackgroundColour(color) + self.drawing_panel.Refresh() + + def on_toggle_npp_shortcut(self, event): + self.btnNpp.SetValue(not self.btnNpp.GetValue()) + self.toggle_npp(event) + + def toggle_npp(self, event): + self.drawing_panel.Refresh() + + def on_marker_button(self, marker_type, event): + self.control_panel.slider.enable_marker_list(marker_type, event.GetEventObject().GetValue()) + if marker_type == 'jump': + self.drawing_panel.Refresh() + + def on_settings_button(self, event): + simulator_panel = SimulatorPreferenceDialog(self, title=_('Simulator Preferences')) + simulator_panel.Show() diff --git a/lib/utils/settings.py b/lib/utils/settings.py index 44310537..c7593fb7 100644 --- a/lib/utils/settings.py +++ b/lib/utils/settings.py @@ -14,6 +14,8 @@ DEFAULT_METADATA = { DEFAULT_SETTINGS = { "cache_size": 100, "pop_out_simulator": False, + "simulator_line_width": 0.1, + "simulator_npp_size": 0.5 } -- cgit v1.2.3