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