- Improved sketching
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user