summaryrefslogtreecommitdiff
path: root/lib/gui/simulator/drawing_panel.py
diff options
context:
space:
mode:
Diffstat (limited to 'lib/gui/simulator/drawing_panel.py')
-rw-r--r--lib/gui/simulator/drawing_panel.py423
1 files changed, 423 insertions, 0 deletions
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()