Files
fluencyCAD/drawing_modules/improved_sketcher.py
bklronin 11d053fda4 Fix sketcher mode handling to prevent unintended line creation during drag operations
Major changes:
- Fixed right-click handler to directly set mode to NONE instead of relying on main app signal handling
- Added safety checks in left-click handler to prevent drawing when no draggable point is found in NONE mode
- Enhanced mode compatibility by treating Python None as SketchMode.NONE in set_mode() method
- Added comprehensive debug logging for mode changes and interaction state tracking
- Resolved integration issue where persistent constraint modes were prematurely reset by main app
- Ensured point dragging is only enabled in NONE mode, preventing accidental polyline creation

This fixes the reported issue where deactivating the line tool would still create lines when dragging,
and ensures proper mode transitions between drawing tools and selection/drag mode.
2025-08-16 22:30:18 +02:00

1525 lines
60 KiB
Python

"""
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 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)
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_snap_point: Optional[QPoint] = None # For snap point display
self.snap_type: Optional[str] = None # Type of snap ("point", "midpoint", etc.)
self.selected_elements = []
# 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}")
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
# 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()
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()
# 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]}")
# Check if we're starting a drag operation on a point
# Handle both None and SketchMode.NONE as drag mode
if self.current_mode == SketchMode.NONE or self.current_mode is None:
point = self.sketch.get_point_near(pos, self.snap_settings.snap_distance)
logger.debug(f"Found point near click: {point}")
if point:
logger.debug(f"Starting drag of point: {point}")
self._start_point_drag(point, pos)
return
else:
# No point found to drag in NONE mode - make sure no drawing happens
logger.debug("No point found for dragging in NONE mode, ignoring click")
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 _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 _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)
# 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}")
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()
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 dragged points
if 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.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)
# Highlight if hovered
if 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)
# 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())