From 54ac2c098a133fb9c649e729572379319b68b30c Mon Sep 17 00:00:00 2001 From: bklronin Date: Sun, 14 Jun 2026 10:10:37 +0200 Subject: [PATCH] - Improved sketching --- src/fluency/sketch_solver.py | 326 +++++++++++++++++++++++++++++++++++ 1 file changed, 326 insertions(+) create mode 100644 src/fluency/sketch_solver.py diff --git a/src/fluency/sketch_solver.py b/src/fluency/sketch_solver.py new file mode 100644 index 0000000..316e650 --- /dev/null +++ b/src/fluency/sketch_solver.py @@ -0,0 +1,326 @@ +""" +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