# 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 ...debug.debug import debug 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.timer = wx.Timer(self) self.last_frame_start = 0 self.target_frame_period = 1.0 / self.TARGET_FPS self.direction = 1 self.current_stitch = 0 self.black_pen = wx.Pen((128, 128, 128)) self.width = 0 self.height = 0 self.loaded = False self.page_specs = {} self.show_page = global_settings['toggle_page_button_status'] self.background_color = None # desired simulation speed in stitches per second self.speed = global_settings['simulator_speed'] 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) self.Bind(wx.EVT_TIMER, self.animate) # 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, event=None): if not self.animating: return # Each frame, we need to advance forward some number of stitches to # match the speed setting. The tricky thing is that with bigger # designs, it may take a long time to render a frame. That might # mean that we'll fall behind. Even if we set our Timer to 30 FPS, # we may only actually manage to render 20 FPS or fewer, and the # duration of each frame may vary. # # To deal with that, we'll figure out how many stitches to advance # based on how long it took to render the last frame. We'll always # be behind by one frame, but it should work out fine. now = time.time() if self.last_frame_start: frame_time = now - self.last_frame_start else: frame_time = self.target_frame_period self.last_frame_start = now stitch_increment = self.speed * frame_time self.set_current_stitch(self.current_stitch + self.direction * stitch_increment) 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_page(self, canvas): self._update_background_color() if not self.page_specs or not self.show_page: return with debug.log_exceptions(): border_color = wx.Colour(self.page_specs['border_color']) if self.page_specs['show_page_shadow']: canvas.SetPen(wx.TRANSPARENT_PEN) canvas.SetBrush(canvas.CreateBrush(wx.Brush(wx.Colour(border_color.Red(), border_color.Green(), border_color.Blue(), alpha=65)))) canvas.DrawRoundedRectangle( (-self.page_specs['x'] + 4) * self.PIXEL_DENSITY, (-self.page_specs['y'] + 4) * self.PIXEL_DENSITY, self.page_specs['width'] * self.PIXEL_DENSITY, self.page_specs['height'] * self.PIXEL_DENSITY, 1 * self.PIXEL_DENSITY ) pen = canvas.CreatePen( wx.GraphicsPenInfo(wx.Colour(border_color)).Width(1 * self.PIXEL_DENSITY).Join(wx.JOIN_MITER) ) canvas.SetPen(pen) canvas.SetBrush(wx.Brush(wx.Colour(self.background_color or self.page_specs['page_color']))) canvas.DrawRectangle( -self.page_specs['x'] * self.PIXEL_DENSITY, -self.page_specs['y'] * self.PIXEL_DENSITY, self.page_specs['width'] * self.PIXEL_DENSITY, self.page_specs['height'] * self.PIXEL_DENSITY ) 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) self.draw_page(canvas) stitch = 0 last_stitch = None 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[:int(self.current_stitch) - stitch] if len(stitches) > 1: self.draw_stitch_lines(canvas, pen, stitches, jumps) self.draw_needle_penetration_points(canvas, pen, stitches) last_stitch = stitches[-1] break 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.minx, self.miny, self.maxx, self.maxy = stitch_plan.bounding_box self.width = self.maxx - self.minx self.height = self.maxy - self.miny self.dimensions_mm = stitch_plan.dimensions_mm self.num_stitches = stitch_plan.num_stitches self.num_trims = stitch_plan.num_trims self.num_color_changes = stitch_plan.num_color_blocks - 1 self.num_stops = stitch_plan.num_stops self.num_jumps = stitch_plan.num_jumps - 1 self.parse_stitch_plan(stitch_plan) self.choose_zoom_and_pan() self.set_current_stitch(0) statusbar = self.GetTopLevelParent().statusbar statusbar.SetStatusText( _("Dimensions: {:.2f} x {:.2f}").format( stitch_plan.dimensions_mm[0], stitch_plan.dimensions_mm[1] ), 1 ) self.loaded = True self.go() if hasattr(self.view_panel, 'info_panel') and self.view_panel.info_panel is not None: self.view_panel.info_panel.update() def set_page_specs(self, page_specs): self.SetBackgroundColour(page_specs['desk_color']) self.page_specs = page_specs def set_background_color(self, color): self.background_color = color # this refresh is necessary for macOS self.Refresh() def _update_background_color(self): if not self.page_specs: self.SetBackgroundColour(self.background_color or "#FFFFFF") else: if self.show_page: self.SetBackgroundColour(self.page_specs['desk_color']) else: self.SetBackgroundColour(self.background_color or self.page_specs['page_color']) def set_show_page(self, show_page): self.show_page = show_page self._update_background_color() 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.timer.Stop() self.control_panel.on_stop() def go(self): if not self.loaded: return if not self.animating: try: self.animating = True self.last_frame_start = 0 self.timer.Start(int(self.target_frame_period * 1000)) self.animate() self.control_panel.on_start() except RuntimeError: pass def color_to_pen(self, color): line_width = global_settings['simulator_line_width'] * PIXELS_PER_MM * self.PIXEL_DENSITY background_color = self.GetBackgroundColour().GetAsString() return wx.Pen(list(map(int, color.visible_on_background(background_color).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 global_settings['simulator_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[int(self.current_stitch)] self.control_panel.on_current_stitch(int(self.current_stitch), command) statusbar = self.GetTopLevelParent().statusbar statusbar.SetStatusText(_("Command: %s") % COMMAND_NAMES[command], 2) self.stop_if_at_end() 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()