""" Improved Sketcher Widget for Fluency CAD Provides parametric 2D sketching with constraint solving integration """ import math import re import uuid import logging from copy import copy from enum import Enum, auto from dataclasses import dataclass from typing import Optional, List, Tuple, Dict, Any import numpy as np from PySide6.QtWidgets import QApplication, QWidget, QMessageBox, QInputDialog from PySide6.QtGui import QPainter, QPen, QColor, QTransform, QFont from PySide6.QtCore import Qt, QPoint, QPointF, Signal, QLine, QRect from python_solvespace import SolverSystem, ResultFlag # Configure logging logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) class SketchMode(Enum): """Enumeration of available sketching modes""" NONE = auto() LINE = auto() RECTANGLE = auto() CIRCLE = auto() ARC = auto() POINT = auto() # Constraint modes COINCIDENT_PT_PT = auto() COINCIDENT_PT_LINE = auto() HORIZONTAL = auto() VERTICAL = auto() DISTANCE = auto() MIDPOINT = auto() PARALLEL = auto() PERPENDICULAR = auto() TANGENT = auto() class SnapMode(Enum): """Enumeration of snapping modes""" POINT = auto() MIDPOINT = auto() HORIZONTAL = auto() VERTICAL = auto() GRID = auto() ANGLE = auto() INTERSECTION = auto() @dataclass class SnapSettings: """Configuration for snapping behavior""" snap_distance: float = 20.0 angle_increment: float = 15.0 grid_spacing: float = 50.0 def __post_init__(self): if not hasattr(self, 'enabled_modes'): self.enabled_modes = {SnapMode.POINT, SnapMode.MIDPOINT} @dataclass class RenderSettings: """Configuration for rendering appearance""" normal_pen_width: float = 2.0 construction_pen_width: float = 1.0 highlight_pen_width: float = 3.0 def __post_init__(self): if not hasattr(self, 'normal_color'): self.normal_color = QColor(128, 128, 128) if not hasattr(self, 'construction_color'): self.construction_color = QColor(0, 255, 0) # Green if not hasattr(self, 'highlight_color'): self.highlight_color = QColor(255, 0, 0) # Red if not hasattr(self, 'solver_color'): self.solver_color = QColor(0, 255, 0) # Green if not hasattr(self, 'dynamic_color'): self.dynamic_color = QColor(128, 128, 128) if not hasattr(self, 'text_color'): self.text_color = QColor(255, 255, 255) # White class GeometryError(Exception): """Custom exception for geometry-related errors""" pass class SolverError(Exception): """Custom exception for solver-related errors""" pass class Point2D: """Enhanced 2D point class with validation and better integration""" def __init__(self, x: float, y: float, is_construction: bool = False): self.id = uuid.uuid4() self.x = float(x) self.y = float(y) self.ui_point = QPoint(int(x), int(y)) self.handle = None self.handle_nr: Optional[int] = None self.is_helper = is_construction self._validate() def _validate(self): """Validate point coordinates""" if not (-1e6 <= self.x <= 1e6) or not (-1e6 <= self.y <= 1e6): raise GeometryError(f"Point coordinates out of range: ({self.x}, {self.y})") def update_ui_point(self): """Update the Qt point from internal coordinates""" self.ui_point = QPoint(int(self.x), int(self.y)) def distance_to(self, other: 'Point2D') -> float: """Calculate distance to another point""" return math.sqrt((self.x - other.x)**2 + (self.y - other.y)**2) def __eq__(self, other): if not isinstance(other, Point2D): return False return abs(self.x - other.x) < 1e-6 and abs(self.y - other.y) < 1e-6 def __repr__(self): return f"Point2D({self.x:.2f}, {self.y:.2f}, helper={self.is_helper})" class Line2D: """Enhanced 2D line class with validation and constraint tracking""" def __init__(self, start_point: Point2D, end_point: Point2D, is_construction: bool = False): self.id = uuid.uuid4() self.start = start_point self.end = end_point self.handle = None self.handle_nr: Optional[int] = None self.is_helper = is_construction self.constraints: List[str] = [] self._validate() def _validate(self): """Validate line geometry""" if self.start == self.end: raise GeometryError("Line start and end points cannot be identical") @property def length(self) -> float: """Calculate line length""" return self.start.distance_to(self.end) @property def midpoint(self) -> Point2D: """Calculate line midpoint""" mid_x = (self.start.x + self.end.x) / 2 mid_y = (self.start.y + self.end.y) / 2 return Point2D(mid_x, mid_y) @property def angle(self) -> float: """Calculate line angle in radians""" dx = self.end.x - self.start.x dy = self.end.y - self.start.y return math.atan2(dy, dx) def is_point_on_line(self, point: Point2D, tolerance: float = 5.0) -> bool: """Check if a point lies on this line within tolerance""" # Vector from start to point ap_x = point.x - self.start.x ap_y = point.y - self.start.y # Vector from start to end ab_x = self.end.x - self.start.x ab_y = self.end.y - self.start.y # Length squared of line ab_length_sq = ab_x**2 + ab_y**2 if ab_length_sq == 0: return False # Project point onto line t = (ap_x * ab_x + ap_y * ab_y) / ab_length_sq t = max(0, min(1, t)) # Clamp to line segment # Find closest point on line closest_x = self.start.x + t * ab_x closest_y = self.start.y + t * ab_y # Check distance distance = math.sqrt((point.x - closest_x)**2 + (point.y - closest_y)**2) return distance <= tolerance def __repr__(self): return f"Line2D({self.start} -> {self.end}, helper={self.is_helper})" class Circle2D: """2D circle class for enhanced sketching""" def __init__(self, center: Point2D, radius: float, is_construction: bool = False): self.id = uuid.uuid4() self.center = center self.radius = float(radius) self.handle = None self.handle_nr: Optional[int] = None self.is_helper = is_construction self.constraints: List[str] = [] self._validate() def _validate(self): """Validate circle geometry""" if self.radius <= 0: raise GeometryError(f"Circle radius must be positive: {self.radius}") def is_point_on_circle(self, point: Point2D, tolerance: float = 5.0) -> bool: """Check if a point lies on the circle within tolerance""" distance = self.center.distance_to(point) return abs(distance - self.radius) <= tolerance class ImprovedSketch(SolverSystem): """Enhanced sketch class with better solver integration and error handling""" def __init__(self): super().__init__() self.id = uuid.uuid4() self.wp = self.create_2d_base() # Geometry containers self.points: List[Point2D] = [] self.lines: List[Line2D] = [] self.circles: List[Circle2D] = [] # Metadata self.origin = [0, 0, 0] self.name = "Untitled Sketch" self._handle_counter = 0 def add_point(self, point: Point2D) -> Point2D: """Add a point to the sketch and solver system""" try: point.handle = self.add_point_2d(point.x, point.y, self.wp) point.handle_nr = self._extract_handle_number(str(point.handle)) self.points.append(point) logger.debug(f"Added point: {point}") return point except Exception as e: raise SolverError(f"Failed to add point to solver: {e}") def add_line(self, line: Line2D) -> Line2D: """Add a line to the sketch and solver system""" try: # Ensure both points are in the system if line.start not in self.points: self.add_point(line.start) if line.end not in self.points: self.add_point(line.end) line.handle = self.add_line_2d(line.start.handle, line.end.handle, self.wp) line.handle_nr = self._extract_handle_number(str(line.handle)) self.lines.append(line) logger.debug(f"Added line: {line}") return line except Exception as e: raise SolverError(f"Failed to add line to solver: {e}") def add_circle(self, circle: Circle2D) -> Circle2D: """Add a circle to the sketch and solver system""" try: # Ensure center point is in the system if circle.center not in self.points: self.add_point(circle.center) # Add circle to solver (using distance constraint for radius) radius_point = Point2D(circle.center.x + circle.radius, circle.center.y) self.add_point(radius_point) # Create distance constraint for radius circle.handle = self.distance(circle.center.handle, radius_point.handle, circle.radius, self.wp) circle.handle_nr = self._extract_handle_number(str(circle.handle)) self.circles.append(circle) logger.debug(f"Added circle: {circle}") return circle except Exception as e: raise SolverError(f"Failed to add circle to solver: {e}") def remove_entity(self, handle): """Remove an entity from the solver system""" try: # This is a placeholder implementation # In a real implementation, you would need to properly remove the entity from the solver logger.debug(f"Removing entity with handle: {handle}") # For now, we'll just log the removal - the actual removal is handled in the sketcher except Exception as e: logger.warning(f"Failed to remove entity from solver: {e}") # We don't raise an exception here because the UI removal is more important def solve_system(self) -> ResultFlag: """Solve the constraint system with error handling""" try: result = self.solve() if result == ResultFlag.OKAY: self._update_geometry_positions() logger.debug("Solver succeeded") else: logger.warning(f"Solver failed: {result}") return result except Exception as e: logger.error(f"Solver error: {e}") raise SolverError(f"Solver execution failed: {e}") def _update_geometry_positions(self): """Update geometry positions from solver results""" for i, point in enumerate(self.points): try: if point.handle and self.params(point.handle.params): x, y = self.params(point.handle.params) point.x = x point.y = y point.update_ui_point() except Exception as e: logger.warning(f"Failed to update point {i}: {e}") def _extract_handle_number(self, handle_str: str) -> int: """Extract handle number from handle string representation""" pattern = r"handle=(\d+)" match = re.search(pattern, handle_str) return int(match.group(1)) if match else 0 def get_point_near(self, target: QPoint, tolerance: float = 20.0) -> Optional[Point2D]: """Find point near the target location""" logger.debug(f"Looking for point near {target} with tolerance {tolerance}") for point in self.points: distance = (target - point.ui_point).manhattanLength() logger.debug(f"Point at {point.ui_point}, distance: {distance}") if distance <= tolerance: logger.debug(f"Found point within tolerance: {point}") return point logger.debug("No point found within tolerance") return None def get_line_near(self, target: QPoint, tolerance: float = 5.0) -> Optional[Line2D]: """Find line near the target location""" target_point = Point2D(target.x(), target.y()) for line in self.lines: if line.is_point_on_line(target_point, tolerance): return line return None class ImprovedSketchWidget(QWidget): """Enhanced sketch widget with improved architecture and features""" # Signals constraint_applied = Signal() geometry_created = Signal(str) # Emits geometry type sketch_modified = Signal() def __init__(self): super().__init__() self._setup_widget() self._setup_state() self._setup_settings() def _setup_widget(self): """Initialize widget properties""" self.setMouseTracking(True) self.setMinimumSize(400, 300) self.setFocusPolicy(Qt.StrongFocus) # Ensure widget can receive focus def _setup_state(self): """Initialize widget state""" # Current sketch and mode self.sketch = ImprovedSketch() self.current_mode = SketchMode.NONE self.is_construction_mode = False # Interaction state self.hovered_point: Optional[Point2D] = None self.hovered_line: Optional[Line2D] = None self.hovered_circle: Optional[Circle2D] = None self.hovered_snap_point: Optional[QPoint] = None # For snap point display self.snap_type: Optional[str] = None # Type of snap ("point", "midpoint", etc.) self.selected_elements = [] # List of selected elements for deletion self.selection_rect_start: Optional[QPoint] = None # For rectangle selection self.selection_rect_end: Optional[QPoint] = None # For rectangle selection # Dragging state self.dragging_point: Optional[Point2D] = None self.drag_start_pos: Optional[QPoint] = None self.drag_solver_enabled = True # Whether to run solver during drag # Panning state self.panning = False self.pan_start_pos: Optional[QPoint] = None self.pan_start_offset: Optional[QPoint] = None # Drawing buffers self.line_buffer: List[Optional[Point2D]] = [None, None] self.constraint_buffer: List[Any] = [None, None] self.dynamic_end_point: Optional[QPoint] = None # View settings self.zoom_factor = 1.0 self.pan_offset = QPoint(0, 0) def _setup_settings(self): """Initialize settings""" self.snap_settings = SnapSettings() self.render_settings = RenderSettings() # Public API Methods def set_mode(self, mode): """Set the current sketching mode""" # Handle None as SketchMode.NONE for backward compatibility if mode is None: mode = SketchMode.NONE if self.current_mode != mode: self.current_mode = mode self._reset_interaction_state() logger.info(f"Mode changed to: {mode}") # Special handling for constraint modes if mode in [SketchMode.DISTANCE, SketchMode.HORIZONTAL, SketchMode.VERTICAL, SketchMode.COINCIDENT_PT_PT, SketchMode.COINCIDENT_PT_LINE]: logger.info(f"Entering constraint mode: {mode}") def set_construction_mode(self, enabled: bool): """Enable or disable construction geometry mode""" self.is_construction_mode = enabled logger.info(f"Construction mode: {'enabled' if enabled else 'disabled'}") def set_snap_mode(self, snap_mode: SnapMode, enabled: bool): """Set a specific snap mode""" if enabled: self.snap_settings.enabled_modes.add(snap_mode) else: self.snap_settings.enabled_modes.discard(snap_mode) logger.debug(f"Snap mode {snap_mode}: {'enabled' if enabled else 'disabled'}") def toggle_snap_mode(self, snap_mode: SnapMode, enabled: bool): """Toggle a specific snap mode (alias for set_snap_mode)""" self.set_snap_mode(snap_mode, enabled) def zoom_to_fit(self): """Zoom to fit all geometry""" if not (self.sketch.points or self.sketch.lines): return # Calculate bounding box x_coords = [p.x for p in self.sketch.points] y_coords = [p.y for p in self.sketch.points] if not x_coords: return min_x, max_x = min(x_coords), max(x_coords) min_y, max_y = min(y_coords), max(y_coords) # Add margin margin = 50 width = max_x - min_x + 2 * margin height = max_y - min_y + 2 * margin # Calculate zoom widget_width = self.width() - 100 widget_height = self.height() - 100 zoom_x = widget_width / width if width > 0 else 1 zoom_y = widget_height / height if height > 0 else 1 self.zoom_factor = min(zoom_x, zoom_y, 2.0) # Center on geometry center_x = (min_x + max_x) / 2 center_y = (min_y + max_y) / 2 self.pan_offset = QPoint(int(-center_x), int(-center_y)) self.update() logger.info("Zoomed to fit geometry") # Mouse Event Handlers def mousePressEvent(self, event): """Handle mouse press events""" local_pos = self._viewport_to_local(event.pos()) try: if event.button() == Qt.LeftButton: self._handle_left_click(local_pos) elif event.button() == Qt.RightButton: self._handle_right_click(local_pos) elif event.button() == Qt.MiddleButton: self._start_panning(event.pos()) except (GeometryError, SolverError) as e: logger.error(f"Error in mouse press: {e}") QMessageBox.warning(self, "Sketching Error", str(e)) self.update() def mouseMoveEvent(self, event): """Handle mouse move events""" local_pos = self._viewport_to_local(event.pos()) # Handle point dragging if self.dragging_point and self.drag_start_pos: self._handle_point_drag(local_pos) return # Handle panning if self.panning and self.pan_start_pos: self._handle_panning(event.pos()) return # Handle rectangle selection in NONE mode if ((self.current_mode == SketchMode.NONE or self.current_mode is None) and self.selection_rect_start): self.selection_rect_end = local_pos self.update() return # Update hover state self._update_hover_state(local_pos) # Update dynamic preview if (self.current_mode in [SketchMode.LINE, SketchMode.RECTANGLE, SketchMode.CIRCLE] and self.line_buffer[0]): # Use snapped position for dynamic preview self.dynamic_end_point = self._get_snapped_position(local_pos) self.update() def mouseReleaseEvent(self, event): """Handle mouse release events""" if event.button() == Qt.LeftButton and self.dragging_point: self._end_point_drag() elif event.button() == Qt.MiddleButton and self.panning: self._end_panning() elif event.button() == Qt.LeftButton: # Handle rectangle selection completion if ((self.current_mode == SketchMode.NONE or self.current_mode is None) and self.selection_rect_start and self.selection_rect_end): self._perform_rectangle_selection() self.selection_rect_start = None self.selection_rect_end = None self.update() self.update() def wheelEvent(self, event): """Handle zoom with mouse wheel""" delta = event.angleDelta().y() zoom_factor = 1.2 if delta > 0 else 1/1.2 # Zoom towards mouse position mouse_pos = event.position().toPoint() old_local = self._viewport_to_local(mouse_pos) self.zoom_factor *= zoom_factor self.zoom_factor = max(0.1, min(10.0, self.zoom_factor)) new_local = self._viewport_to_local(mouse_pos) delta_local = old_local - new_local self.pan_offset += delta_local self.update() def keyPressEvent(self, event): """Handle key press events""" if event.key() in [Qt.Key_Delete, Qt.Key_Backspace]: # Delete selected elements self.delete_selected_elements() else: super().keyPressEvent(event) # Private Helper Methods def _handle_left_click(self, pos: QPoint): """Handle left mouse button click""" logger.debug(f"Left click at local pos: {pos}, mode: {self.current_mode}") logger.debug(f"Line buffer state: {[str(p) if p else None for p in self.line_buffer]}") # Handle selection when in NONE mode (default selection behavior) if self.current_mode == SketchMode.NONE or self.current_mode is None: # Check if we clicked on an element for selection point = self.sketch.get_point_near(pos, self.snap_settings.snap_distance) line = self.sketch.get_line_near(pos, 5.0) # Check if we're starting a drag operation on a point if point and not self.selection_rect_start: logger.debug(f"Starting drag of point: {point}") self._start_point_drag(point, pos) return # Handle selection if not dragging if not self.dragging_point: if point or line: # Toggle element selection if point: if point in self.selected_elements: self.selected_elements.remove(point) else: self.selected_elements.append(point) elif line: if line in self.selected_elements: self.selected_elements.remove(line) else: self.selected_elements.append(line) self.update() return else: # Start rectangle selection self.selection_rect_start = pos self.selection_rect_end = pos self.update() return # Handle drawing modes if self.current_mode == SketchMode.LINE: self._handle_line_creation(pos) elif self.current_mode == SketchMode.POINT: self._handle_point_creation(pos) elif self.current_mode == SketchMode.RECTANGLE: self._handle_rectangle_creation(pos) elif self.current_mode == SketchMode.CIRCLE: self._handle_circle_creation(pos) elif self.current_mode == SketchMode.COINCIDENT_PT_PT: self._handle_point_coincident_constraint(pos) elif self.current_mode == SketchMode.HORIZONTAL: self._handle_horizontal_constraint(pos) elif self.current_mode == SketchMode.VERTICAL: self._handle_vertical_constraint(pos) elif self.current_mode == SketchMode.DISTANCE: self._handle_distance_constraint(pos) def _perform_rectangle_selection(self): """Select elements within the rectangle""" if not self.selection_rect_start or not self.selection_rect_end: return # Get the rectangle bounds min_x = min(self.selection_rect_start.x(), self.selection_rect_end.x()) max_x = max(self.selection_rect_start.x(), self.selection_rect_end.x()) min_y = min(self.selection_rect_start.y(), self.selection_rect_end.y()) max_y = max(self.selection_rect_start.y(), self.selection_rect_end.y()) # Select points within the rectangle for point in self.sketch.points: if min_x <= point.x <= max_x and min_y <= point.y <= max_y: if point not in self.selected_elements: self.selected_elements.append(point) # Select lines where both endpoints are within the rectangle for line in self.sketch.lines: start_in = (min_x <= line.start.x <= max_x and min_y <= line.start.y <= max_y) end_in = (min_x <= line.end.x <= max_x and min_y <= line.end.y <= max_y) if start_in and end_in: if line not in self.selected_elements: self.selected_elements.append(line) def delete_selected_elements(self): """Delete all currently selected elements""" if not self.selected_elements: return # Separate points and lines points_to_delete = [elem for elem in self.selected_elements if isinstance(elem, Point2D)] lines_to_delete = [elem for elem in self.selected_elements if isinstance(elem, Line2D)] # Delete lines first (to avoid issues with points being used by lines) for line in lines_to_delete: if line in self.sketch.lines: # Remove from solver if line.handle: try: self.sketch.remove_entity(line.handle) except Exception as e: logger.warning(f"Failed to remove line from solver: {e}") # Remove from sketch self.sketch.lines.remove(line) # Delete points for point in points_to_delete: # Check if point is used by any remaining lines point_in_use = any( line.start == point or line.end == point for line in self.sketch.lines ) if not point_in_use and point in self.sketch.points: # Remove from solver if point.handle: try: self.sketch.remove_entity(point.handle) except Exception as e: logger.warning(f"Failed to remove point from solver: {e}") # Remove from sketch self.sketch.points.remove(point) # Clear selection self.selected_elements.clear() # Run solver to update constraints try: result = self.sketch.solve_system() if result == ResultFlag.OKAY: logger.debug("Solver succeeded after element deletion") else: logger.warning(f"Solver failed after element deletion: {result}") except Exception as e: logger.error(f"Solver error after element deletion: {e}") # Emit signals self.sketch_modified.emit() self.update() def _handle_line_creation(self, pos: QPoint): """Handle line creation mode""" # Get the actual snapped position (including midpoints, grid, etc.) snapped_pos = self._get_snapped_position(pos) # Also try to get an existing point for reuse snapped_point = self._get_snapped_point(pos) if not self.line_buffer[0]: # First point if snapped_point: # Use existing point self.line_buffer[0] = snapped_point else: # Create new point at snapped position point = Point2D(snapped_pos.x(), snapped_pos.y(), self.is_construction_mode) self.sketch.add_point(point) self.line_buffer[0] = point else: # Second point - create line if snapped_point: # Use existing point end_point = snapped_point else: # Create new point at snapped position (which could be midpoint, grid, etc.) end_point = Point2D(snapped_pos.x(), snapped_pos.y(), self.is_construction_mode) self.sketch.add_point(end_point) # Check if the points are identical (would create invalid line) if self.line_buffer[0] == end_point: logger.error("Line start and end points cannot be identical") # Reset the buffer to allow user to try again self.line_buffer = [None, None] self.dynamic_end_point = None return # Create line line = Line2D(self.line_buffer[0], end_point, self.is_construction_mode) self.sketch.add_line(line) # Run solver to ensure constraints are satisfied try: result = self.sketch.solve_system() if result == ResultFlag.OKAY: logger.debug("Solver succeeded after line creation") else: logger.warning(f"Solver failed after line creation: {result}") except Exception as e: logger.error(f"Solver error after line creation: {e}") # Continue line drawing from end point self.line_buffer[0] = end_point self.dynamic_end_point = None self.geometry_created.emit("line") self.sketch_modified.emit() def _handle_point_creation(self, pos: QPoint): """Handle point creation mode""" snapped_pos = self._get_snapped_position(pos) point = Point2D(snapped_pos.x(), snapped_pos.y(), self.is_construction_mode) self.sketch.add_point(point) self.geometry_created.emit("point") self.sketch_modified.emit() def _handle_rectangle_creation(self, pos: QPoint): """Handle rectangle creation mode (two-point definition)""" snapped_pos = self._get_snapped_position(pos) if not self.line_buffer[0]: # First corner point = Point2D(snapped_pos.x(), snapped_pos.y(), self.is_construction_mode) self.sketch.add_point(point) self.line_buffer[0] = point else: # Second corner - create rectangle corner2 = Point2D(snapped_pos.x(), snapped_pos.y(), self.is_construction_mode) self.sketch.add_point(corner2) # Create rectangle from two opposite corners corner1 = self.line_buffer[0] # Calculate other two corners corner3 = Point2D(corner1.x, corner2.y, self.is_construction_mode) corner4 = Point2D(corner2.x, corner1.y, self.is_construction_mode) self.sketch.add_point(corner3) self.sketch.add_point(corner4) # Create four lines for rectangle lines = [ Line2D(corner1, corner4, self.is_construction_mode), # Top Line2D(corner4, corner2, self.is_construction_mode), # Right Line2D(corner2, corner3, self.is_construction_mode), # Bottom Line2D(corner3, corner1, self.is_construction_mode), # Left ] for line in lines: self.sketch.add_line(line) # Reset buffer self.line_buffer = [None, None] self.dynamic_end_point = None self.geometry_created.emit("rectangle") self.sketch_modified.emit() def _handle_circle_creation(self, pos: QPoint): """Handle circle creation mode (center-radius definition)""" snapped_pos = self._get_snapped_position(pos) if not self.line_buffer[0]: # Center point center = Point2D(snapped_pos.x(), snapped_pos.y(), self.is_construction_mode) self.sketch.add_point(center) self.line_buffer[0] = center else: # Radius point - create circle radius_point = Point2D(snapped_pos.x(), snapped_pos.y()) radius = self.line_buffer[0].distance_to(radius_point) # Create circle circle = Circle2D(self.line_buffer[0], radius, self.is_construction_mode) self.sketch.add_circle(circle) # Reset buffer self.line_buffer = [None, None] self.dynamic_end_point = None self.geometry_created.emit("circle") self.sketch_modified.emit() def _handle_point_coincident_constraint(self, pos: QPoint): """Handle point-to-point coincident constraint""" point = self.sketch.get_point_near(pos) if not point: return if not self.constraint_buffer[0]: self.constraint_buffer[0] = point else: # Apply coincident constraint try: self.sketch.coincident( self.constraint_buffer[0].handle, point.handle, self.sketch.wp ) result = self.sketch.solve_system() if result == ResultFlag.OKAY: # Only emit sketch_modified, don't emit constraint_applied # which would reset the mode via integration with main app self.sketch_modified.emit() logger.debug("Applied coincident constraint") else: logger.warning(f"Constraint failed: {result}") finally: # Only reset the constraint buffer, keeping the mode active self.constraint_buffer = [None, None] def _handle_horizontal_constraint(self, pos: QPoint): """Handle horizontal line constraint""" line = self.sketch.get_line_near(pos) if line and line.handle: try: self.sketch.horizontal(line.handle, self.sketch.wp) result = self.sketch.solve_system() if result == ResultFlag.OKAY: line.constraints.append("horizontal") # Only emit sketch_modified, don't emit constraint_applied # which would reset the mode via integration with main app self.sketch_modified.emit() logger.debug("Applied horizontal constraint") else: logger.warning(f"Horizontal constraint failed: {result}") except Exception as e: logger.error(f"Failed to apply horizontal constraint: {e}") def _handle_vertical_constraint(self, pos: QPoint): """Handle vertical line constraint""" line = self.sketch.get_line_near(pos) if line and line.handle: try: self.sketch.vertical(line.handle, self.sketch.wp) result = self.sketch.solve_system() if result == ResultFlag.OKAY: line.constraints.append("vertical") # Only emit sketch_modified, don't emit constraint_applied # which would reset the mode via integration with main app self.sketch_modified.emit() logger.debug("Applied vertical constraint") else: logger.warning(f"Vertical constraint failed: {result}") except Exception as e: logger.error(f"Failed to apply vertical constraint: {e}") def _handle_distance_constraint(self, pos: QPoint): """Handle distance constraint (line length or point-to-point distance)""" line = self.sketch.get_line_near(pos) if line and line.handle: # Get distance value from user current_length = line.length distance, ok = QInputDialog.getDouble( self, 'Distance Constraint', 'Enter distance (mm):', current_length, 0.01, # min value 1000.0, # max value 2 # decimals ) if ok: try: self.sketch.distance( line.start.handle, line.end.handle, distance, self.sketch.wp ) result = self.sketch.solve_system() if result == ResultFlag.OKAY: line.constraints.append(f"L={distance:.2f}") # Only emit sketch_modified, don't emit constraint_applied # which would reset the mode via integration with main app self.sketch_modified.emit() logger.debug(f"Applied distance constraint: {distance:.2f}") else: logger.warning(f"Distance constraint failed: {result}") except Exception as e: logger.error(f"Failed to apply distance constraint: {e}") else: # If no line found, show a message to the user logger.debug("No line found near cursor for distance constraint") def _handle_right_click(self, pos: QPoint): """Handle right mouse button click (cancels current operation and exits mode)""" logger.debug(f"Right click at pos: {pos}, current mode: {self.current_mode}") # Reset any interaction state (like line buffers, etc.) self._reset_interaction_state() # Force mode to NONE to enable dragging/selection self.current_mode = SketchMode.NONE logger.info(f"Right-click: Mode reset to NONE") # Emit signal to inform main app that constraint/drawing mode was canceled self.constraint_applied.emit() def _update_hover_state(self, pos: QPoint): """Update which elements are being hovered and detect snap points""" if not self.sketch: self.hovered_point = None self.hovered_line = None self.hovered_snap_point = None self.snap_type = None return # Reset state self.hovered_point = None self.hovered_line = None self.hovered_snap_point = None self.snap_type = None min_distance = float('inf') snap_threshold = self.snap_settings.snap_distance # Check for existing points first (highest priority) if SnapMode.POINT in self.snap_settings.enabled_modes: for point in self.sketch.points: distance = math.sqrt((pos.x() - point.x)**2 + (pos.y() - point.y)**2) if distance < snap_threshold and distance < min_distance: self.hovered_point = point self.hovered_snap_point = QPoint(int(point.x), int(point.y)) self.snap_type = "point" min_distance = distance # Check for line midpoints if no point was found and midpoint snap is enabled if not self.hovered_point and SnapMode.MIDPOINT in self.snap_settings.enabled_modes: for line in self.sketch.lines: midpoint = line.midpoint distance = math.sqrt((pos.x() - midpoint.x)**2 + (pos.y() - midpoint.y)**2) if distance < snap_threshold and distance < min_distance: self.hovered_snap_point = QPoint(int(midpoint.x), int(midpoint.y)) self.snap_type = "midpoint" self.hovered_line = line min_distance = distance # Check for lines (lowest priority) if not self.hovered_point and not self.hovered_snap_point: self.hovered_line = self.sketch.get_line_near(pos) def _get_snapped_point(self, pos: QPoint) -> Optional[Point2D]: """Get snapped point if snapping is enabled""" if SnapMode.POINT in self.snap_settings.enabled_modes: return self.sketch.get_point_near(pos, self.snap_settings.snap_distance) return None def _get_snapped_position(self, pos: QPoint) -> QPoint: """Get snapped position considering all enabled snap modes""" min_distance = float('inf') snapped_pos = pos snap_threshold = self.snap_settings.snap_distance # Point snapping (highest priority) if SnapMode.POINT in self.snap_settings.enabled_modes: for point in self.sketch.points: distance = math.sqrt((pos.x() - point.x)**2 + (pos.y() - point.y)**2) if distance < snap_threshold and distance < min_distance: snapped_pos = QPoint(int(point.x), int(point.y)) min_distance = distance # Midpoint snapping (medium priority) if SnapMode.MIDPOINT in self.snap_settings.enabled_modes and min_distance > snap_threshold: for line in self.sketch.lines: midpoint = line.midpoint distance = math.sqrt((pos.x() - midpoint.x)**2 + (pos.y() - midpoint.y)**2) if distance < snap_threshold and distance < min_distance: snapped_pos = QPoint(int(midpoint.x), int(midpoint.y)) min_distance = distance # Grid snapping (lowest priority) if SnapMode.GRID in self.snap_settings.enabled_modes and min_distance > snap_threshold: spacing = self.snap_settings.grid_spacing snapped_x = round(pos.x() / spacing) * spacing snapped_y = round(pos.y() / spacing) * spacing grid_pos = QPoint(int(snapped_x), int(snapped_y)) distance = math.sqrt((pos.x() - snapped_x)**2 + (pos.y() - snapped_y)**2) if distance < snap_threshold: snapped_pos = grid_pos return snapped_pos def _reset_interaction_state(self): """Reset all interaction buffers and states""" logger.debug("Resetting interaction state") self.line_buffer = [None, None] self.constraint_buffer = [None, None] self.dynamic_end_point = None self.selected_elements.clear() self.selection_rect_start = None self.selection_rect_end = None def _viewport_to_local(self, viewport_pos: QPoint) -> QPoint: """Convert viewport coordinates to local sketch coordinates This must be the EXACT inverse of _setup_coordinate_system() """ # Step 1: Subtract the widget center center_x = self.width() / 2 center_y = self.height() / 2 # Step 2: Subtract the pan offset (in viewport coordinates) viewport_x = viewport_pos.x() - center_x - (self.pan_offset.x() * self.zoom_factor) viewport_y = viewport_pos.y() - center_y - (self.pan_offset.y() * self.zoom_factor) # Step 3: Apply inverse zoom and Y-flip (reverse of scale(zoom, -zoom)) local_x = viewport_x / self.zoom_factor local_y = -viewport_y / self.zoom_factor # Note: negative because rendering uses -zoom_factor for Y return QPoint(int(local_x), int(local_y)) def _local_to_viewport(self, local_pos: QPoint) -> QPoint: """Convert local sketch coordinates to viewport coordinates""" center_x = self.width() // 2 center_y = self.height() // 2 # Apply pan and zoom viewport_x = (local_pos.x() + self.pan_offset.x()) * self.zoom_factor + center_x viewport_y = center_y - (local_pos.y() + self.pan_offset.y()) * self.zoom_factor return QPoint(int(viewport_x), int(viewport_y)) # Rendering Methods def paintEvent(self, event): """Main rendering method""" painter = QPainter(self) painter.setRenderHint(QPainter.Antialiasing) try: self._setup_coordinate_system(painter) self._draw_background(painter) self._draw_geometry(painter) self._draw_dynamic_elements(painter) self._draw_ui_overlays(painter) except Exception as e: logger.error(f"Rendering error: {e}") finally: painter.end() def _setup_coordinate_system(self, painter: QPainter): """Setup coordinate system transformation""" transform = QTransform() # Translate to center and apply pan center = QPointF(self.width() / 2, self.height() / 2) transform.translate(center.x() + self.pan_offset.x() * self.zoom_factor, center.y() + self.pan_offset.y() * self.zoom_factor) # Apply zoom and flip Y-axis transform.scale(self.zoom_factor, -self.zoom_factor) painter.setTransform(transform) def _draw_background(self, painter: QPainter): """Draw background grid and axes""" # Draw coordinate axes pen = QPen(Qt.gray, 1.0 / self.zoom_factor, Qt.DashLine) painter.setPen(pen) # X-axis painter.drawLine(-1000, 0, 1000, 0) # Y-axis painter.drawLine(0, -1000, 0, 1000) # Draw origin painter.setPen(QPen(Qt.red, 2.0 / self.zoom_factor)) painter.drawEllipse(QPointF(0, 0), 3 / self.zoom_factor, 3 / self.zoom_factor) def _draw_geometry(self, painter: QPainter): """Draw all sketch geometry""" if not self.sketch: return # Draw points for point in self.sketch.points: self._draw_point(painter, point) # Draw lines for line in self.sketch.lines: self._draw_line(painter, line) # Draw circles for circle in self.sketch.circles: self._draw_circle(painter, circle) def _draw_point(self, painter: QPainter, point: Point2D): """Draw a single point""" if point.is_helper: pen = QPen(self.render_settings.construction_color, self.render_settings.construction_pen_width / self.zoom_factor) radius = 8 / self.zoom_factor else: pen = QPen(self.render_settings.normal_color, self.render_settings.normal_pen_width / self.zoom_factor) radius = 3 / self.zoom_factor # Special highlighting for selected points if point in self.selected_elements: pen = QPen(QColor(255, 255, 0), # Yellow (self.render_settings.highlight_pen_width + 2) / self.zoom_factor) radius *= 1.5 # Make selected points larger # Special highlighting for dragged points elif point == self.dragging_point: pen = QPen(QColor(255, 165, 0), (self.render_settings.highlight_pen_width + 1) / self.zoom_factor) # Orange radius *= 1.3 # Make dragged points slightly larger # Regular hover highlight elif point == self.hovered_point: pen = QPen(self.render_settings.highlight_color, self.render_settings.highlight_pen_width / self.zoom_factor) painter.setPen(pen) painter.setBrush(Qt.NoBrush if point not in self.selected_elements else QColor(255, 255, 0, 100)) painter.drawEllipse(QPointF(point.x, point.y), radius, radius) def _draw_line(self, painter: QPainter, line: Line2D): """Draw a single line""" if line.is_helper: pen = QPen(self.render_settings.construction_color, self.render_settings.construction_pen_width / self.zoom_factor, Qt.DotLine) else: pen = QPen(self.render_settings.normal_color, self.render_settings.normal_pen_width / self.zoom_factor) # Special highlighting for selected lines if line in self.selected_elements: pen = QPen(QColor(255, 255, 0), # Yellow (self.render_settings.highlight_pen_width + 2) / self.zoom_factor) # Highlight if hovered elif line == self.hovered_line: pen = QPen(self.render_settings.highlight_color, self.render_settings.highlight_pen_width / self.zoom_factor) painter.setPen(pen) painter.drawLine(QPointF(line.start.x, line.start.y), QPointF(line.end.x, line.end.y)) # Draw constraint annotations if line.constraints and not line.is_helper: self._draw_constraint_annotations(painter, line) def _draw_circle(self, painter: QPainter, circle: Circle2D): """Draw a single circle""" if circle.is_helper: pen = QPen(self.render_settings.construction_color, self.render_settings.construction_pen_width / self.zoom_factor, Qt.DotLine) else: pen = QPen(self.render_settings.normal_color, self.render_settings.normal_pen_width / self.zoom_factor) painter.setPen(pen) painter.drawEllipse(QPointF(circle.center.x, circle.center.y), circle.radius, circle.radius) def _draw_constraint_annotations(self, painter: QPainter, line: Line2D): """Draw constraint annotations for a line""" if not line.constraints: return midpoint = line.midpoint painter.save() # Setup text rendering painter.setPen(QPen(self.render_settings.text_color, 1.0 / self.zoom_factor)) font = QFont() font.setPointSizeF(10 / self.zoom_factor) painter.setFont(font) # Position text near line midpoint text_pos = QPointF(midpoint.x + 10 / self.zoom_factor, midpoint.y + 10 / self.zoom_factor) painter.translate(text_pos) painter.scale(1, -1) # Flip text to be readable # Draw constraints for i, constraint in enumerate(line.constraints): painter.drawText(0, i * 15, f"[{constraint}]") painter.restore() def _draw_dynamic_elements(self, painter: QPainter): """Draw dynamic/preview elements""" pen = QPen(self.render_settings.dynamic_color, 1.0 / self.zoom_factor, Qt.DashLine) painter.setPen(pen) # Draw selection rectangle if ((self.current_mode == SketchMode.NONE or self.current_mode is None) and self.selection_rect_start and self.selection_rect_end): pen = QPen(QColor(255, 255, 0), 1.0 / self.zoom_factor, Qt.DashLine) # Yellow dashed painter.setPen(pen) painter.setBrush(QColor(255, 255, 0, 50)) # Semi-transparent yellow fill rect = QRect(self.selection_rect_start, self.selection_rect_end) painter.drawRect(rect) # Dynamic line preview if (self.current_mode == SketchMode.LINE and self.line_buffer[0] and self.dynamic_end_point): start_point = QPointF(self.line_buffer[0].x, self.line_buffer[0].y) end_point = QPointF(self.dynamic_end_point.x(), self.dynamic_end_point.y()) painter.drawLine(start_point, end_point) # Show length preview length = self.line_buffer[0].distance_to( Point2D(self.dynamic_end_point.x(), self.dynamic_end_point.y()) ) self._draw_length_preview(painter, start_point, end_point, length) # Dynamic rectangle preview elif (self.current_mode == SketchMode.RECTANGLE and self.line_buffer[0] and self.dynamic_end_point): corner1 = QPointF(self.line_buffer[0].x, self.line_buffer[0].y) corner2 = QPointF(self.dynamic_end_point.x(), self.dynamic_end_point.y()) corner3 = QPointF(corner1.x(), corner2.y()) corner4 = QPointF(corner2.x(), corner1.y()) # Draw rectangle outline painter.drawLine(corner1, corner4) # Top painter.drawLine(corner4, corner2) # Right painter.drawLine(corner2, corner3) # Bottom painter.drawLine(corner3, corner1) # Left # Dynamic circle preview elif (self.current_mode == SketchMode.CIRCLE and self.line_buffer[0] and self.dynamic_end_point): center = QPointF(self.line_buffer[0].x, self.line_buffer[0].y) radius = self.line_buffer[0].distance_to( Point2D(self.dynamic_end_point.x(), self.dynamic_end_point.y()) ) # Draw circle outline painter.drawEllipse(center, radius, radius) # Draw radius line radius_point = QPointF(self.dynamic_end_point.x(), self.dynamic_end_point.y()) painter.drawLine(center, radius_point) # Show radius preview self._draw_radius_preview(painter, center, radius_point, radius) # Draw snap point highlights if self.hovered_snap_point: self._draw_snap_highlight(painter, self.hovered_snap_point, self.snap_type) def _draw_length_preview(self, painter: QPainter, start: QPointF, end: QPointF, length: float): """Draw length preview during line creation""" painter.save() # Calculate midpoint and text position mid_x = (start.x() + end.x()) / 2 mid_y = (start.y() + end.y()) / 2 # Setup text painter.setPen(QPen(self.render_settings.text_color, 1.0 / self.zoom_factor)) font = QFont() font.setPointSizeF(10 / self.zoom_factor) painter.setFont(font) # Draw text painter.translate(mid_x, mid_y) painter.scale(1, -1) painter.drawText(0, 0, f"{length:.2f}") painter.restore() def _draw_radius_preview(self, painter: QPainter, center: QPointF, radius_point: QPointF, radius: float): """Draw radius preview during circle creation""" painter.save() # Calculate midpoint along radius line mid_x = (center.x() + radius_point.x()) / 2 mid_y = (center.y() + radius_point.y()) / 2 # Setup text painter.setPen(QPen(self.render_settings.text_color, 1.0 / self.zoom_factor)) font = QFont() font.setPointSizeF(10 / self.zoom_factor) painter.setFont(font) # Draw radius text painter.translate(mid_x, mid_y) painter.scale(1, -1) painter.drawText(0, 0, f"R={radius:.2f}") painter.restore() def _draw_snap_highlight(self, painter: QPainter, snap_point: QPoint, snap_type: str): """Draw visual highlight for snap points""" painter.save() # Set highlight style based on snap type if snap_type == "point": # Red circle for point snapping pen = QPen(Qt.red, 3.0 / self.zoom_factor) painter.setPen(pen) painter.setBrush(Qt.NoBrush) radius = 8 / self.zoom_factor painter.drawEllipse(QPointF(snap_point.x(), snap_point.y()), radius, radius) elif snap_type == "midpoint": # Red diamond for midpoint snapping pen = QPen(Qt.red, 2.0 / self.zoom_factor) painter.setPen(pen) painter.setBrush(Qt.NoBrush) size = 6 / self.zoom_factor center = QPointF(snap_point.x(), snap_point.y()) # Draw diamond shape diamond_points = [ QPointF(center.x(), center.y() - size), # Top QPointF(center.x() + size, center.y()), # Right QPointF(center.x(), center.y() + size), # Bottom QPointF(center.x() - size, center.y()) # Left ] for i in range(4): painter.drawLine(diamond_points[i], diamond_points[(i + 1) % 4]) elif snap_type == "grid": # Green cross for grid snapping pen = QPen(Qt.green, 2.0 / self.zoom_factor) painter.setPen(pen) size = 8 / self.zoom_factor center = QPointF(snap_point.x(), snap_point.y()) # Draw cross painter.drawLine(center.x() - size, center.y(), center.x() + size, center.y()) painter.drawLine(center.x(), center.y() - size, center.x(), center.y() + size) painter.restore() def _draw_ui_overlays(self, painter: QPainter): """Draw UI overlays (status text, etc.)""" painter.resetTransform() # Draw mode indicator painter.setPen(QPen(Qt.white)) font = QFont() font.setPointSize(12) painter.setFont(font) mode_name = self.current_mode.name if self.current_mode else "None" mode_text = f"Mode: {mode_name}" if self.is_construction_mode: mode_text += " (Construction)" painter.drawText(10, 25, mode_text) # Draw zoom level zoom_text = f"Zoom: {self.zoom_factor:.1f}x" painter.drawText(10, 50, zoom_text) # Show snap information if actively snapping if self.hovered_snap_point and self.snap_type: snap_text = f"Snap: {self.snap_type.title()}" painter.drawText(10, 75, snap_text) # Backward Compatibility API Methods def get_sketch(self): """Get the current sketch (for main app compatibility)""" return self.sketch def set_sketch(self, sketch): """Set the current sketch (for main app compatibility)""" if hasattr(sketch, 'points') and hasattr(sketch, 'lines'): # If it's already an ImprovedSketch, use it directly if isinstance(sketch, ImprovedSketch): self.sketch = sketch else: # Convert from old sketch format if needed self.sketch = ImprovedSketch() self.sketch.id = getattr(sketch, 'id', self.sketch.id) self.sketch.name = getattr(sketch, 'name', "Imported Sketch") self.sketch.origin = getattr(sketch, 'origin', [0, 0, 0]) self.update() def create_sketch(self, sketch): """Create a new sketch from sketch data (for main app compatibility)""" self.sketch = ImprovedSketch() if hasattr(sketch, 'id'): self.sketch.id = sketch.id self.sketch.name = sketch.id if hasattr(sketch, 'origin'): self.sketch.origin = sketch.origin self.update() def reset_buffers(self): """Reset drawing buffers (for main app compatibility)""" self._reset_interaction_state() self.update() def create_workplane_projected(self): """Create workplane from projected data (for main app compatibility)""" # This is handled internally by the solver system pass def convert_proj_points(self, proj_points): """Convert projected points (for main app compatibility)""" if not proj_points: return for point_data in proj_points: # Convert to Point2D and add to sketch as construction points (green) if hasattr(point_data, 'x') and hasattr(point_data, 'y'): point = Point2D(point_data.x, point_data.y, True) # Construction point self.sketch.add_point(point) elif len(point_data) >= 2: # Apply coordinate scaling/transformation if needed for working plane alignment x, y = point_data[0], point_data[1] # Scale coordinates to match sketch widget coordinate system # This ensures proper alignment with the working plane orientation point = Point2D(x, y, True) # Construction point self.sketch.add_point(point) self.update() def convert_proj_lines(self, proj_lines): """Convert projected lines (for main app compatibility)""" if not proj_lines: return logger.info(f"Converting {len(proj_lines)} projected lines to construction geometry") lines_added = 0 lines_skipped = 0 for i, line_data in enumerate(proj_lines): logger.debug(f"Processing line {i}: {line_data}") # Convert to Line2D and add to sketch as construction lines (green) if hasattr(line_data, 'start') and hasattr(line_data, 'end'): x1, y1 = line_data.start.x, line_data.start.y x2, y2 = line_data.end.x, line_data.end.y # Skip degenerate lines (identical start and end points) if abs(x1 - x2) < 1e-6 and abs(y1 - y2) < 1e-6: logger.debug(f"Skipping degenerate line: ({x1}, {y1}) -> ({x2}, {y2})") lines_skipped += 1 continue start = Point2D(x1, y1, True) # Construction end = Point2D(x2, y2, True) # Construction logger.debug(f"Added line from object format: ({x1}, {y1}) -> ({x2}, {y2})") self.sketch.add_point(start) self.sketch.add_point(end) line = Line2D(start, end, True) # Construction line self.sketch.add_line(line) lines_added += 1 elif len(line_data) >= 2 and len(line_data[0]) >= 2 and len(line_data[1]) >= 2: # Handle tuple format [(x1, y1), (x2, y2)] x1, y1 = line_data[0][0], line_data[0][1] x2, y2 = line_data[1][0], line_data[1][1] # Skip degenerate lines (identical start and end points) if abs(x1 - x2) < 1e-6 and abs(y1 - y2) < 1e-6: logger.debug(f"Skipping degenerate line: ({x1}, {y1}) -> ({x2}, {y2})") lines_skipped += 1 continue logger.debug(f"Added line from tuple format: ({x1}, {y1}) -> ({x2}, {y2})") start = Point2D(x1, y1, True) # Construction end = Point2D(x2, y2, True) # Construction self.sketch.add_point(start) self.sketch.add_point(end) line = Line2D(start, end, True) # Construction line self.sketch.add_line(line) lines_added += 1 else: logger.warning(f"Unrecognized line data format: {line_data}") logger.info(f"Successfully added {lines_added} construction lines to sketch (skipped {lines_skipped} degenerate lines)") self.update() # Dragging Methods def _start_point_drag(self, point: Point2D, start_pos: QPoint): """Start dragging a point""" self.dragging_point = point self.drag_start_pos = start_pos logger.debug(f"Started dragging point: {point}") def _handle_point_drag(self, current_pos: QPoint): """Handle ongoing point drag - update visual position and provide immediate feedback""" if not self.dragging_point or not self.drag_start_pos: return try: # Get snapped position to allow snapping while dragging snapped_pos = self._get_snapped_position(current_pos) logger.debug(f"Dragging point to: {snapped_pos} (from {current_pos})") # Store original position for potential rollback if constraints fail if not hasattr(self.dragging_point, '_drag_original_pos'): self.dragging_point._drag_original_pos = (self.dragging_point.x, self.dragging_point.y) # Update point position visually during drag old_pos = (self.dragging_point.x, self.dragging_point.y) self.dragging_point.x = float(snapped_pos.x()) self.dragging_point.y = float(snapped_pos.y()) self.dragging_point.update_ui_point() logger.debug(f"Point position updated from {old_pos} to ({self.dragging_point.x}, {self.dragging_point.y})") # Update the display immediately for responsive feedback self.update() except Exception as e: logger.error(f"Error during point drag: {e}") def _end_point_drag(self): """End point dragging and finalize with solver""" if not self.dragging_point: return dragged_point = self.dragging_point # Store reference before clearing try: # Update the solver parameters with the final position if dragged_point.handle: new_x = dragged_point.x new_y = dragged_point.y logger.debug(f"Setting final point position to ({new_x:.2f}, {new_y:.2f})") # Update the solver parameters self.sketch.set_params(dragged_point.handle.params, [new_x, new_y]) # Run solver to update all connected geometry result = self.sketch.solve_system() if result == ResultFlag.OKAY: # Clear the original position since drag was successful if hasattr(dragged_point, '_drag_original_pos'): delattr(dragged_point, '_drag_original_pos') self.sketch_modified.emit() logger.debug(f"Point drag completed successfully: {dragged_point}") else: logger.warning(f"Solver failed after point drag: {result}") # If solver fails, we could optionally revert to original position # but for now, keep the visual position as user intended if hasattr(dragged_point, '_drag_original_pos'): delattr(dragged_point, '_drag_original_pos') except Exception as e: logger.error(f"Error finishing point drag: {e}") # In case of error, could revert to original position if hasattr(dragged_point, '_drag_original_pos'): orig_x, orig_y = dragged_point._drag_original_pos dragged_point.x = orig_x dragged_point.y = orig_y dragged_point.update_ui_point() delattr(dragged_point, '_drag_original_pos') finally: # Reset drag state self.dragging_point = None self.drag_start_pos = None # Force a final update to ensure all geometry is refreshed self.update() # Panning Methods def _start_panning(self, start_pos: QPoint): """Start panning the view with middle mouse button""" self.panning = True self.pan_start_pos = start_pos self.pan_start_offset = QPoint(self.pan_offset) self.setCursor(Qt.ClosedHandCursor) logger.debug(f"Started panning at: {start_pos}") def _handle_panning(self, current_pos: QPoint): """Handle ongoing panning operation""" if not self.panning or not self.pan_start_pos or not self.pan_start_offset: return # Calculate the delta in viewport coordinates delta_viewport = current_pos - self.pan_start_pos # Convert to local coordinates (scale by zoom factor) delta_local_x = delta_viewport.x() / self.zoom_factor delta_local_y = -delta_viewport.y() / self.zoom_factor # Flip Y for coordinate system # Update pan offset self.pan_offset = QPoint( int(self.pan_start_offset.x() + delta_local_x), int(self.pan_start_offset.y() + delta_local_y) ) self.update() def _end_panning(self): """End panning operation""" if not self.panning: return self.panning = False self.pan_start_pos = None self.pan_start_offset = None self.setCursor(Qt.ArrowCursor) logger.debug("Ended panning") # Example usage and testing if __name__ == "__main__": import sys app = QApplication(sys.argv) # Create and show the improved sketcher widget = ImprovedSketchWidget() widget.setWindowTitle("Improved Fluency Sketcher") widget.resize(800, 600) widget.show() # Set initial mode to line drawing widget.set_mode(SketchMode.LINE) sys.exit(app.exec())