summaryrefslogtreecommitdiff
path: root/lib/gui/simulator
diff options
context:
space:
mode:
Diffstat (limited to 'lib/gui/simulator')
-rw-r--r--lib/gui/simulator/__init__.py14
-rw-r--r--lib/gui/simulator/control_panel.py343
-rw-r--r--lib/gui/simulator/drawing_panel.py423
-rw-r--r--lib/gui/simulator/simulator_panel.py95
-rw-r--r--lib/gui/simulator/simulator_preferences.py71
-rw-r--r--lib/gui/simulator/simulator_renderer.py64
-rw-r--r--lib/gui/simulator/simulator_slider.py234
-rw-r--r--lib/gui/simulator/simulator_window.py55
-rw-r--r--lib/gui/simulator/split_simulator_window.py122
-rw-r--r--lib/gui/simulator/view_panel.py117
10 files changed, 1538 insertions, 0 deletions
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()