- Improved sketching

This commit is contained in:
bklronin
2026-06-14 10:10:37 +02:00
parent ea34e7e29d
commit 54ac2c098a
+326
View File
@@ -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