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.
1525 lines
60 KiB
Python
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())
|