""" SolveSpace-based constraint solver for Fluency CAD. Provides integration between python-solvespace (SolverSystem) and the Fluency CAD sketch pipeline (OCCSketch). Drawing operations add entities to BOTH the OCCSketch (for OCC->render pipeline) and the SolverSketch (for constraint solving). After constraint solving, solved positions are synced back to the OCCSketch. """ from __future__ import annotations import math import re import uuid import logging from dataclasses import dataclass, field from typing import List, Optional, Tuple, Any, Dict from python_solvespace import SolverSystem, ResultFlag logger = logging.getLogger(__name__) # ── Data classes ────────────────────────────────────────────────────────── @dataclass class SolverPoint: """A 2D point tracked by the solver system.""" x: float y: float handle: Any = None handle_nr: int = 0 entity_id: int = -1 # Corresponding OCCSketch entity id is_helper: bool = False id: str = field(default_factory=lambda: str(uuid.uuid4())) def to_tuple(self) -> Tuple[float, float]: return (self.x, self.y) @dataclass class SolverLine: """A line segment tracked by the solver system.""" start: SolverPoint end: SolverPoint handle: Any = None handle_nr: int = 0 entity_ids: Tuple[int, int] = (-1, -1) # Corresponding OCCSketch entity ids is_helper: bool = False constraints: List[str] = field(default_factory=list) id: str = field(default_factory=lambda: str(uuid.uuid4())) @property def length(self) -> float: return math.sqrt( (self.end.x - self.start.x) ** 2 + (self.end.y - self.start.y) ** 2 ) def midpoint(self) -> Tuple[float, float]: return ( (self.start.x + self.end.x) / 2, (self.start.y + self.end.y) / 2, ) @dataclass class SolverCircle: """A circle tracked by the solver system.""" center: SolverPoint radius: float handle: Any = None handle_nr: int = 0 entity_id: int = -1 # Corresponding OCCSketch entity id is_helper: bool = False id: str = field(default_factory=lambda: str(uuid.uuid4())) # ── Solver wrapper ──────────────────────────────────────────────────────── class SolverSketch(SolverSystem): """ Sketch that uses python-solvespace for parametric constraint solving. Maintains its own lists of points, lines, and circles with solve-space handles. Provides methods for creating geometry, applying constraints, solving, and syncing solved positions back to an OCCSketch. """ def __init__(self) -> None: super().__init__() self.id = str(uuid.uuid4()) self.wp = self.create_2d_base() self.points: List[SolverPoint] = [] self.lines: List[SolverLine] = [] self.circles: List[SolverCircle] = [] self._last_solve_result: int = 0 # ── Geometry creation ──────────────────────────────────────────────── def add_solver_point(self, x: float, y: float, is_helper: bool = False) -> SolverPoint: """Add a point to the solver system and return a SolverPoint.""" handle = self.add_point_2d(x, y, self.wp) handle_nr = _extract_handle_nr(str(handle)) point = SolverPoint( x=x, y=y, handle=handle, handle_nr=handle_nr, is_helper=is_helper, ) self.points.append(point) return point def add_solver_line( self, start: SolverPoint, end: SolverPoint, is_helper: bool = False ) -> SolverLine: """Add a line to the solver system and return a SolverLine.""" handle = self.add_line_2d(start.handle, end.handle, self.wp) handle_nr = _extract_handle_nr(str(handle)) line = SolverLine( start=start, end=end, handle=handle, handle_nr=handle_nr, is_helper=is_helper, ) self.lines.append(line) return line def add_solver_circle( self, center: SolverPoint, radius: float, is_helper: bool = False ) -> SolverCircle: """Add a circle to the solver system and return a SolverCircle. Note: python-solvespace handles circles via diameter, so we store radius but pass 2*radius to the solver if needed. """ # For now, circles are tracked for OCC output but the solver # doesn't have a dedicated add_circle_2d in the standard API. # We'll handle radius/diameter constraints through the points. circle = SolverCircle( center=center, radius=radius, is_helper=is_helper, ) self.circles.append(circle) return circle # ── Constraint methods ─────────────────────────────────────────────── def constrain_coincident(self, entity_a, entity_b) -> bool: """Make two entities coincident (point-point or point-line).""" try: if isinstance(entity_a, SolverPoint) and isinstance(entity_b, SolverPoint): self.coincident(entity_a.handle, entity_b.handle, self.wp) elif isinstance(entity_a, SolverPoint) and isinstance(entity_b, SolverLine): self.coincident(entity_a.handle, entity_b.handle, self.wp) elif isinstance(entity_a, SolverLine) and isinstance(entity_b, SolverPoint): self.coincident(entity_b.handle, entity_a.handle, self.wp) else: logger.warning(f"coincident: unsupported types {type(entity_a)}, {type(entity_b)}") return False return True except Exception as e: logger.error(f"coincident constraint failed: {e}") return False def constrain_horizontal(self, line: SolverLine) -> bool: """Constrain a line to be horizontal.""" try: self.horizontal(line.handle, self.wp) return True except Exception as e: logger.error(f"horizontal constraint failed: {e}") return False def constrain_vertical(self, line: SolverLine) -> bool: """Constrain a line to be vertical.""" try: self.vertical(line.handle, self.wp) return True except Exception as e: logger.error(f"vertical constraint failed: {e}") return False def constrain_distance( self, entity_a, entity_b, distance: float ) -> bool: """Constrain distance between point-point or point-line.""" try: handle_a = entity_a.handle if isinstance(entity_a, SolverPoint) else entity_a.handle handle_b = entity_b.handle if isinstance(entity_b, SolverPoint) else entity_b.handle if isinstance(entity_a, SolverPoint) and isinstance(entity_b, SolverLine): self.distance(handle_a, handle_b, distance, self.wp) elif isinstance(entity_a, SolverLine) and isinstance(entity_b, SolverPoint): self.distance(handle_b, handle_a, distance, self.wp) elif isinstance(entity_a, SolverPoint) and isinstance(entity_b, SolverPoint): self.distance(handle_a, handle_b, distance, self.wp) elif isinstance(entity_a, SolverLine) and isinstance(entity_b, SolverLine): self.distance(handle_a, handle_b, distance, self.wp) else: logger.warning(f"distance: unsupported types {type(entity_a)}, {type(entity_b)}") return False return True except Exception as e: logger.error(f"distance constraint failed: {e}") return False def constrain_midpoint(self, point: SolverPoint, line: SolverLine) -> bool: """Constrain a point to be at the midpoint of a line.""" try: self.midpoint(point.handle, line.handle, self.wp) return True except Exception as e: logger.error(f"midpoint constraint failed: {e}") return False def constrain_parallel(self, line_a: SolverLine, line_b: SolverLine) -> bool: """Constrain two lines to be parallel.""" try: self.parallel(line_a.handle, line_b.handle, self.wp) return True except Exception as e: logger.error(f"parallel constraint failed: {e}") return False def constrain_perpendicular(self, line_a: SolverLine, line_b: SolverLine) -> bool: """Constrain two lines to be perpendicular.""" try: self.perpendicular(line_a.handle, line_b.handle, self.wp) return True except Exception as e: logger.error(f"perpendicular constraint failed: {e}") return False def constrain_angle(self, line_a: SolverLine, line_b: SolverLine, angle_deg: float) -> bool: """Constrain angle between two lines in degrees.""" try: angle_rad = math.radians(angle_deg) self.angle(line_a.handle, line_b.handle, angle_rad, self.wp) return True except Exception as e: logger.error(f"angle constraint failed: {e}") return False def constrain_equal_length(self, line_a: SolverLine, line_b: SolverLine) -> bool: """Constrain two lines to have equal length.""" try: self.equal(line_a.handle, line_b.handle, self.wp) return True except Exception as e: logger.error(f"equal length constraint failed: {e}") return False # ── Solving ────────────────────────────────────────────────────────── def solve(self) -> int: """Solve all constraints. Returns ResultFlag as int.""" result = super().solve() self._last_solve_result = result if result == ResultFlag.OKAY: # Update our stored point positions from solver params self._sync_solved_positions() return result def _sync_solved_positions(self) -> None: """Update SolverPoint coordinates from solver's solved params.""" for point in self.points: if point.handle and self.params(point.handle.params): x, y = self.params(point.handle.params) point.x = x point.y = y # ── Query ──────────────────────────────────────────────────────────── def get_solved_point_positions(self) -> Dict[int, Tuple[float, float]]: """Get map of entity_id -> (x, y) after solving.""" positions: Dict[int, Tuple[float, float]] = {} for point in self.points: if point.handle and self.params(point.handle.params): x, y = self.params(point.handle.params) positions[point.entity_id] = (x, y) return positions def is_point_on_line( self, px: float, py: float, line: SolverLine, tolerance: float = 5.0 ) -> bool: """Check if a point lies on a solver line (in world coords).""" # Vector from start to point ap_x = px - line.start.x ap_y = py - line.start.y # Vector from start to end ab_x = line.end.x - line.start.x ab_y = line.end.y - line.start.y ab_len_sq = ab_x**2 + ab_y**2 if ab_len_sq == 0: return False # Project point onto line t = (ap_x * ab_x + ap_y * ab_y) / ab_len_sq t = max(0, min(1, t)) closest_x = line.start.x + t * ab_x closest_y = line.start.y + t * ab_y dist = math.sqrt((px - closest_x) ** 2 + (py - closest_y) ** 2) return dist <= tolerance # ── Clear / reset ──────────────────────────────────────────────────── def clear(self) -> None: """Clear all geometry from this solver sketch.""" self.points.clear() self.lines.clear() self.circles.clear() self.wp = self.create_2d_base() # ── Helpers ─────────────────────────────────────────────────────────────── def _extract_handle_nr(handle_str: str) -> int: """Extract numeric handle from string like 'Entity(handle=7, ...)'.""" match = re.search(r"handle=(\d+)", handle_str) return int(match.group(1)) if match else 0