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.
24 KiB
Fluency CAD - Improved Sketcher Technical Documentation
Table of Contents
- Overview
- Architecture
- Core Components
- Geometry System
- Constraint Solving
- Coordinate Systems
- Interaction System
- Rendering System
- Snapping System
- Working Plane Integration
- API Reference
- Performance Considerations
- Troubleshooting
Overview
The ImprovedSketchWidget is a parametric 2D sketching system built for Fluency CAD. It provides constraint-based geometric modeling with real-time solving, integrated snapping, and seamless integration with 3D working planes. The system is built on top of the SolverSpace constraint solver and PySide6 for the user interface.
Key Features
- Parametric Geometry: All geometry is constraint-driven and automatically updates
- Real-time Solving: Constraints are solved dynamically as geometry is modified
- Advanced Snapping: Multi-mode snapping system (points, midpoints, grid, angles)
- Construction Geometry: Support for helper/construction geometry
- Working Plane Integration: Seamless 2D/3D workflow with projected geometry
- Interactive Dragging: Smooth point dragging with constraint preservation
- Multiple Drawing Modes: Lines, rectangles, circles, arcs, and points
Architecture
┌─────────────────────────────────────────────────────────┐
│ ImprovedSketchWidget │
│ ┌─────────────────┐ ┌─────────────────────────────┐ │
│ │ User Interface │ │ Rendering System │ │
│ │ - Mouse Events │ │ - Coordinate Transform │ │
│ │ - Keyboard │ │ - Geometry Drawing │ │
│ │ - Mode Control │ │ - UI Overlays │ │
│ └─────────────────┘ └─────────────────────────────┘ │
│ │ │ │
│ └─────────┬───────────────┘ │
│ │ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Interaction System │ │
│ │ - Snapping Engine │ │
│ │ - Dragging Logic │ │
│ │ - Selection Management │ │
│ └─────────────────────────────────────────────────┘ │
│ │ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Geometry System │ │
│ │ ┌─────────────┐ ┌─────────────────────────┐ │ │
│ │ │ Point2D │ │ Line2D │ │ │
│ │ │ Circle2D │ │ Arc2D (future) │ │ │
│ │ └─────────────┘ └─────────────────────────┘ │ │
│ └─────────────────────────────────────────────────┘ │
│ │ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ ImprovedSketch │ │
│ │ (Enhanced SolverSystem) │ │
│ │ - Constraint Management │ │
│ │ - Solver Integration │ │
│ │ - Geometry Storage │ │
│ └─────────────────────────────────────────────────┘ │
│ │ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ SolverSpace Library │ │
│ │ - Constraint Solving Engine │ │
│ │ - Geometric Relationships │ │
│ └─────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
Core Components
1. ImprovedSketchWidget
The main widget class that handles user interaction and rendering.
Key Responsibilities:
- Mouse and keyboard event handling
- Mode management (line, circle, constraint modes, etc.)
- Coordinate system transformations
- Rendering pipeline orchestration
- Integration with external systems (working planes)
2. ImprovedSketch
Enhanced wrapper around SolverSpace's SolverSystem.
Key Responsibilities:
- Geometry storage and management
- Constraint system integration
- Solver result processing
- Handle management for solver objects
3. Geometry Classes
Type-safe geometry representations with validation.
Classes:
Point2D: 2D points with solver integrationLine2D: 2D lines with constraint trackingCircle2D: 2D circles with radius constraints
Geometry System
Point2D Class
class Point2D:
def __init__(self, x: float, y: float, is_construction: bool = False):
self.id = uuid.uuid4() # Unique identifier
self.x = float(x) # X coordinate
self.y = float(y) # Y coordinate
self.ui_point = QPoint(int(x), int(y)) # Qt UI point
self.handle = None # SolverSpace handle
self.handle_nr = None # Handle number
self.is_helper = is_construction # Construction geometry flag
Key Features:
- Automatic coordinate validation
- SolverSpace handle integration
- Construction/normal geometry support
- Distance calculations and equality testing
Line2D Class
class Line2D:
def __init__(self, start_point: Point2D, end_point: Point2D, is_construction: bool = False):
self.id = uuid.uuid4()
self.start = start_point # Start point reference
self.end = end_point # End point reference
self.handle = None # SolverSpace handle
self.constraints = [] # Applied constraints list
self.is_helper = is_construction
Key Features:
- Automatic degenerate line detection
- Length, midpoint, and angle calculations
- Point-on-line testing with tolerance
- Constraint tracking and annotation
Circle2D Class
class Circle2D:
def __init__(self, center: Point2D, radius: float, is_construction: bool = False):
self.id = uuid.uuid4()
self.center = center # Center point reference
self.radius = float(radius) # Radius value
self.handle = None # SolverSpace handle
self.constraints = [] # Applied constraints
self.is_helper = is_construction
Constraint Solving
SolverSpace Integration
The system uses the python-solvespace library for constraint solving. The ImprovedSketch class wraps the SolverSpace API and provides:
- Automatic Handle Management: Each geometry object gets a unique handle
- Error Handling: Robust error handling for solver failures
- Position Updates: Automatic geometry position updates after solving
Constraint Types
Geometric Constraints
- Coincident: Point-to-point or point-to-line coincidence
- Horizontal: Forces lines to be horizontal
- Vertical: Forces lines to be vertical
- Distance: Fixes distance between points or line length
- Parallel: Makes lines parallel (future implementation)
- Perpendicular: Makes lines perpendicular (future implementation)
Constraint Application Workflow
def _handle_distance_constraint(self, pos: QPoint):
line = self.sketch.get_line_near(pos)
if line and line.handle:
# Get user input for distance
distance, ok = QInputDialog.getDouble(...)
if ok:
# Apply constraint to solver
self.sketch.distance(line.start.handle, line.end.handle, distance, self.sketch.wp)
# Solve system
result = self.sketch.solve_system()
if result == ResultFlag.OKAY:
line.constraints.append(f"L={distance:.2f}")
Solver Workflow
- Constraint Addition: Constraints are added to the solver system
- System Solving: The solver attempts to find a valid solution
- Result Processing: If successful, geometry positions are updated
- UI Updates: The display is refreshed to show new positions
Coordinate Systems
The sketcher uses multiple coordinate systems that must be properly transformed between:
1. Sketch Coordinates (Local)
- Origin at sketch center
- Y-axis points up (mathematical convention)
- Units in millimeters
- Range: typically -1000 to +1000
2. Viewport Coordinates (Screen)
- Origin at top-left of widget
- Y-axis points down (computer graphics convention)
- Units in pixels
- Range: 0 to widget dimensions
3. Working Plane Coordinates (3D)
- 3D coordinates projected onto 2D working plane
- Transformation handled by external VTK system
- Converted to sketch coordinates for display
Coordinate Transformations
Viewport to Local (Mouse Input)
def _viewport_to_local(self, viewport_pos: QPoint) -> QPoint:
# Step 1: Subtract widget center
center_x = self.width() / 2
center_y = self.height() / 2
# Step 2: Apply pan offset
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
local_x = viewport_x / self.zoom_factor
local_y = -viewport_y / self.zoom_factor
return QPoint(int(local_x), int(local_y))
Rendering Transform Setup
def _setup_coordinate_system(self, painter: QPainter):
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)
Interaction System
Mode-Based Interaction
The sketcher supports multiple interaction modes:
Drawing Modes
SketchMode.LINE: Two-point line creationSketchMode.RECTANGLE: Two-corner rectangle creationSketchMode.CIRCLE: Center-radius circle creationSketchMode.POINT: Single point creation
Constraint Modes
SketchMode.COINCIDENT_PT_PT: Point-to-point coincidenceSketchMode.HORIZONTAL: Horizontal line constraintSketchMode.VERTICAL: Vertical line constraintSketchMode.DISTANCE: Distance/length constraint
Selection Mode
SketchMode.NONE: Selection and manipulation mode
Mouse Event Handling
Click Processing Flow
def mousePressEvent(self, event):
local_pos = self._viewport_to_local(event.pos())
if event.button() == Qt.LeftButton:
if self.current_mode == SketchMode.NONE:
# Check for point dragging
point = self.sketch.get_point_near(local_pos)
if point:
self._start_point_drag(point, local_pos)
else:
# Handle drawing modes
self._handle_left_click(local_pos)
Point Dragging System
The point dragging system is optimized for performance and maintains constraint consistency:
Drag Phases
-
Drag Start (
_start_point_drag):- Identifies dragged point
- Stores initial position
- Sets dragging state
-
Drag Update (
_handle_point_drag):- Updates point visual position only
- Applies snapping
- No solver execution (for performance)
-
Drag End (
_end_point_drag):- Updates solver parameters with final position
- Runs constraint solver
- Updates all connected geometry
- Resets drag state
def _end_point_drag(self):
if not self.dragging_point:
return
# Update solver parameters with final position
if self.dragging_point.handle:
new_x = self.dragging_point.x
new_y = self.dragging_point.y
self.sketch.set_params(self.dragging_point.handle.params, [new_x, new_y])
# Run solver to update all connected geometry
result = self.sketch.solve_system()
if result == ResultFlag.OKAY:
self.sketch_modified.emit()
Rendering System
Rendering Pipeline
The rendering system uses Qt's QPainter with a multi-layer approach:
- Coordinate System Setup: Apply zoom, pan, and Y-flip transforms
- Background Rendering: Grid, axes, and origin marker
- Geometry Rendering: Points, lines, circles with proper styling
- Dynamic Elements: Preview geometry during creation
- UI Overlays: Mode indicators, measurements, snap highlights
Rendering Layers
Layer 1: Background
- Coordinate axes (dashed gray lines)
- Grid (if enabled)
- Origin marker (red circle)
Layer 2: Geometry
- Construction geometry (green, dotted)
- Normal geometry (gray, solid)
- Constraint annotations
Layer 3: Interactive Elements
- Hover highlights (red)
- Dynamic previews (gray, dashed)
- Measurements during creation
Layer 4: UI Overlays
- Snap point indicators
- Mode and zoom information
- Status messages
Styling System
Rendering appearance is controlled by the RenderSettings class:
@dataclass
class RenderSettings:
normal_pen_width: float = 2.0
construction_pen_width: float = 1.0
highlight_pen_width: float = 3.0
normal_color = QColor(128, 128, 128) # Gray
construction_color = QColor(0, 255, 0) # Green
highlight_color = QColor(255, 0, 0) # Red
solver_color = QColor(0, 255, 0) # Green
dynamic_color = QColor(128, 128, 128) # Gray
text_color = QColor(255, 255, 255) # White
Dynamic Previews
During geometry creation, dynamic previews show:
- Line Creation: Dashed line from start to cursor with length annotation
- Rectangle Creation: Dashed rectangle outline
- Circle Creation: Dashed circle with radius line and annotation
Snapping System
Snap Modes
The snapping system supports multiple simultaneous snap modes:
SnapMode.POINT
- Snaps to existing geometry points
- Priority: Highest
- Visual: Red circle highlight
SnapMode.MIDPOINT
- Snaps to line midpoints
- Priority: Medium
- Visual: Red diamond highlight
SnapMode.GRID
- Snaps to grid intersections
- Priority: Lowest
- Visual: Green cross highlight
SnapMode.HORIZONTAL/VERTICAL
- Angular snapping (future implementation)
- Constrains to horizontal/vertical directions
SnapMode.INTERSECTION
- Snaps to line intersections (future implementation)
Snap Algorithm
def _get_snapped_position(self, pos: QPoint) -> QPoint:
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
return snapped_pos
Snap Settings
@dataclass
class SnapSettings:
snap_distance: float = 20.0 # Snap threshold in pixels
angle_increment: float = 15.0 # Angular snap increment
grid_spacing: float = 50.0 # Grid spacing
enabled_modes: Set[SnapMode] # Active snap modes
Working Plane Integration
Projected Geometry Workflow
The sketcher integrates with 3D working planes through projected geometry:
- 3D Geometry Selection: User selects 3D lines/points in VTK widget
- Plane Definition: System computes working plane from selections
- Geometry Projection: 3D geometry is projected onto 2D working plane
- Sketch Import: Projected geometry is imported as construction geometry
Projection Import Methods
convert_proj_points(proj_points)
Imports projected 3D points as 2D construction points:
def convert_proj_points(self, proj_points):
for point_data in proj_points:
if hasattr(point_data, 'x') and hasattr(point_data, 'y'):
point = Point2D(point_data.x, point_data.y, True) # Construction
self.sketch.add_point(point)
convert_proj_lines(proj_lines)
Imports projected 3D lines as 2D construction lines:
def convert_proj_lines(self, proj_lines):
for line_data in proj_lines:
# Handle object format
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
if abs(x1 - x2) < 1e-6 and abs(y1 - y2) < 1e-6:
continue
start = Point2D(x1, y1, True)
end = Point2D(x2, y2, True)
self.sketch.add_point(start)
self.sketch.add_point(end)
line = Line2D(start, end, True)
self.sketch.add_line(line)
Construction vs Normal Geometry
-
Construction Geometry:
- Rendered in green with dotted lines
- Used for reference and alignment
- Created from projected 3D geometry
- Flag:
is_construction=True
-
Normal Geometry:
- Rendered in gray with solid lines
- Part of the actual sketch design
- Created by user drawing actions
- Flag:
is_construction=False
API Reference
Main Widget Class
ImprovedSketchWidget
Initialization:
widget = ImprovedSketchWidget()
widget.show()
Mode Control:
widget.set_mode(SketchMode.LINE)
widget.set_construction_mode(True)
Snapping Control:
widget.set_snap_mode(SnapMode.POINT, True)
widget.toggle_snap_mode(SnapMode.MIDPOINT, enabled)
View Control:
widget.zoom_to_fit()
Sketch Access:
sketch = widget.get_sketch()
widget.set_sketch(imported_sketch)
Sketch Management
ImprovedSketch
Geometry Addition:
sketch = ImprovedSketch()
point = Point2D(10, 20)
line = Line2D(start_point, end_point)
circle = Circle2D(center_point, radius)
sketch.add_point(point)
sketch.add_line(line)
sketch.add_circle(circle)
Constraint Application:
# Distance constraint
sketch.distance(point1.handle, point2.handle, 50.0, sketch.wp)
# Coincident constraint
sketch.coincident(point1.handle, point2.handle, sketch.wp)
# Line constraints
sketch.horizontal(line.handle, sketch.wp)
sketch.vertical(line.handle, sketch.wp)
# Solve system
result = sketch.solve_system()
Signals
The widget emits several signals for integration:
# Emitted when constraint is successfully applied
widget.constraint_applied.connect(callback)
# Emitted when new geometry is created
widget.geometry_created.connect(callback) # Parameter: geometry type string
# Emitted when sketch is modified
widget.sketch_modified.connect(callback)
Performance Considerations
Optimization Strategies
- Lazy Solving: Solver only runs when necessary (after constraints or drag end)
- Efficient Rendering: Uses Qt's optimized drawing primitives
- Smart Updates: Only redraws affected regions when possible
- Handle Caching: SolverSpace handles are cached to avoid recreation
Memory Management
- Geometry objects use weak references where possible
- SolverSpace handles are properly cleaned up
- Qt objects follow parent-child hierarchy for automatic cleanup
Scalability Limits
- Recommended maximum: ~1000 geometric entities
- Solver performance degrades with complex constraint networks
- Rendering remains smooth up to ~10,000 entities
Troubleshooting
Common Issues
Solver Failures
Symptoms: Constraints not applied, geometry not updating Causes: Over-constrained systems, conflicting constraints Solutions:
- Check constraint compatibility
- Verify geometry validity
- Use
ResultFlaginspection for error details
Coordinate Transform Issues
Symptoms: Mouse clicks don't match visual geometry Causes: Incorrect transform calculations, zoom/pan state corruption Solutions:
- Verify
_viewport_to_localand_setup_coordinate_systemconsistency - Reset view with
zoom_to_fit()
Performance Problems
Symptoms: Slow dragging, UI lag Causes: Solver running during drag, excessive redraws Solutions:
- Ensure solver only runs in
_end_point_drag - Check render loop efficiency
- Profile with Qt performance tools
Snap Behavior Issues
Symptoms: Inconsistent snapping, incorrect snap points Causes: Priority conflicts, threshold settings, coordinate errors Solutions:
- Adjust snap threshold in
SnapSettings - Verify snap priority order
- Check coordinate conversion in snap calculations
Debug Logging
Enable detailed logging for troubleshooting:
import logging
logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger('improved_sketcher')
Key log messages include:
- Geometry addition/removal
- Constraint application results
- Solver execution status
- Coordinate transformations
- Snap calculations
Testing Guidelines
Unit Testing
- Test geometry classes with edge cases
- Verify coordinate transformations
- Test constraint application logic
Integration Testing
- Test with various sketch sizes
- Verify working plane integration
- Test complex constraint networks
Performance Testing
- Measure solver execution time
- Profile rendering performance
- Test with large geometry sets
Conclusion
The ImprovedSketchWidget provides a robust, extensible foundation for 2D parametric sketching in Fluency CAD. Its architecture separates concerns effectively, uses proven libraries (SolverSpace, PySide6), and provides rich interaction capabilities while maintaining good performance characteristics.
The system is designed for extensibility - new geometry types, constraint types, and interaction modes can be added following the established patterns. The comprehensive API allows for both direct use and integration with larger CAD systems.