""" OpenCASCADE-based sketch for Fluency CAD with SolveSpace constraint solver integration. This module provides 2D sketching capabilities using the SolveSpace constraint solver (via python_solvespace) for constraint management, and CadQuery for geometry generation from solved positions. """ from typing import List, Tuple, Optional, Dict, Any import math import numpy as np import logging import re from python_solvespace import SolverSystem, ResultFlag from fluency.geometry.base import ( SketchInterface, SketchEntity, GeometryObject, Point2D, ) from fluency.geometry_occ.kernel import OCCGeometryObject logger = logging.getLogger(__name__) class OCCSketchEntity(SketchEntity): """Sketch entity for OpenCASCADE-based sketch with solver integration.""" def __init__(self, entity_id: int, entity_type: str, geometry: Any = None, handle: Any = None): super().__init__(entity_id, entity_type) self.geometry = geometry self.handle = handle # SolveSpace solver entity handle self.is_construction: bool = False self.constraints: List[str] = [] # Track applied constraint names for UI # External / underlay entities are reference geometry projected from # a 3D face (or otherwise supplied from outside the sketch). They live # in the solver so user constraints can reference them, but they are # *not* user-drawn, *not* deletable, *not* moveable, and never # contribute to the sketch profile (detect_faces / get_geometry). self.is_external: bool = False class OCCSketch(SketchInterface): """ Sketch with SolveSpace constraint solver integration. Uses python_solvespace as the constraint engine, allowing points and lines to be parametrically constrained. After solving, positions are read from the solver and used to build CadQuery geometry for extrusion. """ def __init__(self) -> None: self._solver: SolverSystem = SolverSystem() self._wp: Any = self._solver.create_2d_base() self._entities: Dict[int, OCCSketchEntity] = {} self._entity_counter: int = 0 self._points: Dict[int, Tuple[float, float]] = {} self._lines: Dict[int, Tuple[int, int]] = {} self._circles: Dict[int, Tuple[int, float]] = {} self._arcs: Dict[int, Any] = {} self._constraint_count: int = 0 # Re-appliable log of every constraint, so we can rebuild the solver # after deleting an entity (python_solvespace has no per-entity delete). # Each entry: {"type": str, "ids": (int, ...), "params": tuple, "labels": set[str]} self._constraint_log: List[Dict[str, Any]] = [] # External / underlay entity ids (face-projected reference geometry). # Kept in their own set so we can: # • render them with a distinct style # • filter them out of get_closed_loops / detect_faces # • refuse to delete / move them # • clear them as a group when the source face is removed self._external_entity_ids: set = set() # Centerline entity ids (X and Y reference axes through origin). # These are construction lines that span the sketch and are used # as reference axes for constraining geometry. They are: # • fixed in the solver (never move) # • marked is_construction (excluded from profile detection) # • non-deletable self._centerline_ids: set = set() # Track first point as dragged/fixed for solver stability self._first_point_id: Optional[int] = None # ── Workplane ─────────────────────────────────────────────────── # The sketch lives in a 2D UV frame on this plane. UV coordinates # map to world via: P = origin + u*x_dir + v*y_dir # where y_dir = normal × x_dir. Defaults to the world XY plane so # existing XY-only behaviour is unchanged. self._wp_origin: Tuple[float, float, float] = (0.0, 0.0, 0.0) self._wp_normal: Tuple[float, float, float] = (0.0, 0.0, 1.0) self._wp_x_dir: Tuple[float, float, float] = (1.0, 0.0, 0.0) self._wp_y_dir: Tuple[float, float, float] = (0.0, 1.0, 0.0) # ─── Workplane management ────────────────────────────────────────────── def set_workplane( self, origin: Tuple[float, float, float], normal: Tuple[float, float, float], x_dir: Tuple[float, float, float], ) -> None: """Place this sketch on an arbitrary plane in 3D. *normal* and *x_dir* need not be unit/perpendicular — they are orthonormalised here. ``y_dir`` is derived as ``normal × x_dir``. Existing UV coordinates are unchanged; only their world mapping moves. """ import numpy as np n = np.asarray(normal, dtype=float) x = np.asarray(x_dir, dtype=float) n = n / np.linalg.norm(n) # Remove any component of x along n, then renormalise. x = x - np.dot(x, n) * n x_norm = np.linalg.norm(x) if x_norm < 1e-9: # x_dir is parallel to normal — pick any orthogonal basis vector. fallback = np.array([1.0, 0.0, 0.0]) if abs(n[0]) < 0.9 else np.array([0.0, 1.0, 0.0]) x = fallback - np.dot(fallback, n) * n x_norm = np.linalg.norm(x) x = x / x_norm y = np.cross(n, x) y = y / np.linalg.norm(y) self._wp_origin = tuple(float(v) for v in origin) self._wp_normal = tuple(float(v) for v in n) self._wp_x_dir = tuple(float(v) for v in x) self._wp_y_dir = tuple(float(v) for v in y) def get_workplane(self) -> Tuple[Tuple[float, float, float], ...]: """Return the (origin, normal, x_dir, y_dir) of this sketch's plane.""" return (self._wp_origin, self._wp_normal, self._wp_x_dir, self._wp_y_dir) def _uv_to_world(self, u: float, v: float): """Map a UV point to a world ``gp_Pnt`` on the workplane.""" from OCP.gp import gp_Pnt ox, oy, oz = self._wp_origin xx, xy, xz = self._wp_x_dir yx, yy, yz = self._wp_y_dir return gp_Pnt( ox + u * xx + v * yx, oy + u * xy + v * yy, oz + u * xz + v * yz, ) def _circle_axis(self, u: float, v: float): """Return a ``gp_Ax2`` for a circle centred at UV on the workplane.""" from OCP.gp import gp_Ax2, gp_Dir center = self._uv_to_world(u, v) return gp_Ax2( center, gp_Dir(*self._wp_normal), gp_Dir(*self._wp_x_dir), ) @property def solver(self) -> SolverSystem: """Access the underlying SolveSpace solver.""" return self._solver @property def workplane(self) -> Any: """Get the SolveSpace 2D solver workplane entity. Note: this is the solver's internal 2D base, not the 3D placement plane — see :meth:`set_workplane` / :meth:`workplane` (no underscore) for the 3D plane. The solver always runs in UV regardless of the 3D placement. """ return self._wp def _next_id(self) -> int: self._entity_counter += 1 return self._entity_counter def _get_handle_nr(self, handle_str: str) -> int: match = re.search(r"handle=(\d+)", str(handle_str)) return int(match.group(1)) if match else 0 def add_point(self, x: float, y: float) -> OCCSketchEntity: """Add a point to the sketch (added to solver + tracked). The very first point added to an empty solver is auto-anchored via ``dragged`` to give the solver a stable reference frame. If the sketch already carries external / underlay points (those are always dragged at creation), we skip this auto-anchor — the external point is the natural reference, and a second dragged point would over-constrain the system and make any user-to-external distance constraint unsolvable. """ entity_id = self._next_id() # Add to solver solver_handle = self._solver.add_point_2d(x, y, self._wp) if self._first_point_id is None and not self._external_entity_ids: self._first_point_id = entity_id # Fix first point so solver has a reference self._solver.dragged(solver_handle, self._wp) entity = OCCSketchEntity( entity_id=entity_id, entity_type="point", geometry=(x, y), handle=solver_handle ) self._entities[entity_id] = entity self._points[entity_id] = (x, y) return entity def add_line(self, start: SketchEntity, end: SketchEntity) -> OCCSketchEntity: """Add a line between two points (added to solver + tracked).""" entity_id = self._next_id() start_entity = self._entities.get(start.id) end_entity = self._entities.get(end.id) if start_entity is None or end_entity is None: raise ValueError("Start or end point not found in sketch") # Get solver handles s_handle = start_entity.handle e_handle = end_entity.handle # Add line to solver solver_handle = self._solver.add_line_2d(s_handle, e_handle, self._wp) x1, y1 = start_entity.geometry x2, y2 = end_entity.geometry entity = OCCSketchEntity( entity_id=entity_id, entity_type="line", geometry=((x1, y1), (x2, y2)), handle=solver_handle, ) self._entities[entity_id] = entity self._lines[entity_id] = (start.id, end.id) return entity def add_circle(self, center: SketchEntity, radius: float) -> OCCSketchEntity: """Add a circle (tracked only — solver has no native circle in this API).""" entity_id = self._next_id() center_entity = self._entities.get(center.id) if center_entity is None: raise ValueError("Center point not found in sketch") cx, cy = center_entity.geometry entity = OCCSketchEntity( entity_id=entity_id, entity_type="circle", geometry=((cx, cy), radius) ) self._entities[entity_id] = entity self._circles[entity_id] = (center.id, radius) return entity def add_arc( self, center: SketchEntity, radius: float, start_point: SketchEntity, end_point: SketchEntity, sweep: Optional[float] = None, ) -> OCCSketchEntity: """Add an arc (tracked only). *sweep* is the signed angular span in radians (positive = CCW, negative = CW). When *None* the rendering will infer the shortest path between start and end. """ import math entity_id = self._next_id() center_entity = self._entities.get(center.id) start_entity = self._entities.get(start_point.id) end_entity = self._entities.get(end_point.id) if center_entity is None or start_entity is None or end_entity is None: raise ValueError("Arc points not found in sketch") cx, cy = center_entity.geometry sx, sy = start_entity.geometry ex, ey = end_entity.geometry # Infer sweep from geometry when not provided. if sweep is None: sa = math.atan2(sy - cy, sx - cx) ea = math.atan2(ey - cy, ex - cx) sweep = ea - sa while sweep > math.pi: sweep -= 2 * math.pi while sweep < -math.pi: sweep += 2 * math.pi entity = OCCSketchEntity( entity_id=entity_id, entity_type="arc", geometry={ "center": (cx, cy), "radius": radius, "start": (sx, sy), "end": (ex, ey), "sweep": sweep, }, ) self._entities[entity_id] = entity self._arcs[entity_id] = { "center": center.id, "start": start_point.id, "end": end_point.id, "radius": radius, "sweep": sweep, } return entity # ─── External / underlay entities (face-projected reference geometry) ─── def add_external_point(self, x: float, y: float) -> OCCSketchEntity: """Add a point that participates in the solver but is *not* user-drawn. External points are used to anchor projected face edges (sketch-on- surface underlay) so the user can snap to them and add constraints like "hole center 50mm from the body's top edge". The point is immediately marked *fixed* in the solver (via ``dragged``) so it never moves when other entities are dragged or re-solved. External entities are skipped by ``get_closed_loops`` / ``detect_faces`` / ``get_geometry`` so they never contribute to the extruded profile — they're reference geometry only. """ entity_id = self._next_id() solver_handle = self._solver.add_point_2d(x, y, self._wp) # Always fix external points — they MUST NOT move when the solver # adjusts other entities. We bypass the first-point auto-fix in # ``add_point`` (which would also fix the very first one and leave # the rest free), and we apply dragged() unconditionally here. self._solver.dragged(solver_handle, self._wp) entity = OCCSketchEntity( entity_id=entity_id, entity_type="point", geometry=(x, y), handle=solver_handle, ) entity.is_external = True entity.is_construction = True # external points are reference / dashed self._entities[entity_id] = entity self._points[entity_id] = (x, y) self._external_entity_ids.add(entity_id) return entity def add_external_line(self, start: SketchEntity, end: SketchEntity) -> OCCSketchEntity: """Add a line between two existing external points. Both endpoints must already be external points (created via :meth:`add_external_point`). External lines are tagged ``is_external`` and are excluded from the sketch's profile-detect path so they don't pollute the extruded face. Constraints applied to external lines (horizontal, vertical, parallel, perpendicular, midpoint) work normally — the line handle is real — but the line itself never moves. """ entity_id = self._next_id() s_ent = self._entities.get(start.id) e_ent = self._entities.get(end.id) if s_ent is None or e_ent is None: raise ValueError("Start or end point not found in sketch") if s_ent.handle is None or e_ent.handle is None: raise ValueError("External endpoints must have solver handles") solver_handle = self._solver.add_line_2d(s_ent.handle, e_ent.handle, self._wp) x1, y1 = s_ent.geometry x2, y2 = e_ent.geometry entity = OCCSketchEntity( entity_id=entity_id, entity_type="line", geometry=((x1, y1), (x2, y2)), handle=solver_handle, ) entity.is_external = True entity.is_construction = True self._entities[entity_id] = entity self._lines[entity_id] = (start.id, end.id) self._external_entity_ids.add(entity_id) return entity def add_external_polyline( self, uv_points: List[Tuple[float, float]] ) -> Tuple[List[OCCSketchEntity], List[OCCSketchEntity]]: """Bulk-import a polyline of UV points as external (underlay) entities. Creates one external point per unique UV position and one external line segment between consecutive points. Returns ``(points, lines)`` in the order they were created so the caller can keep references (e.g. for rendering or for toggling). Points very close to each other (within 1e-6 UV units) are merged into a single shared point, so a closed rectangle becomes 4 unique points and 4 line segments (not 4 points and 4 lines + 4 duplicates at the corners). """ if len(uv_points) < 2: return [], [] # Deduplicate nearby points so shared corners (e.g. a rectangle's # four vertices) are *one* point entity reused by two line segments. eps = 1e-6 points: List[OCCSketchEntity] = [] coord_to_entity: Dict[Tuple[int, int], OCCSketchEntity] = {} for (u, v) in uv_points: key = (int(round(u / eps)), int(round(v / eps))) ent = coord_to_entity.get(key) if ent is None: ent = self.add_external_point(float(u), float(v)) coord_to_entity[key] = ent points.append(ent) lines: List[OCCSketchEntity] = [] for i in range(len(points) - 1): try: ln = self.add_external_line(points[i], points[i + 1]) lines.append(ln) except ValueError: pass return points, lines def remove_external_entities(self) -> None: """Remove every external / underlay entity and prune related constraints. Used when the source face is removed (or rebinded). External entities are *never* user-deletable; this is the only way to clear them. Any constraint that references a removed external id is pruned from the constraint log and the solver is rebuilt from the surviving user geometry so the next solve is consistent. """ if not self._external_entity_ids: return # Wipe external entities from local tracking. for eid in list(self._external_entity_ids): if eid in self._entities: del self._entities[eid] self._points.pop(eid, None) self._lines.pop(eid, None) self._circles.pop(eid, None) self._arcs.pop(eid, None) # Also clean lines that USE an external point as an endpoint but # somehow aren't themselves external (defensive — shouldn't happen # via the public API, but rebuild_solver needs a clean graph). for lid, (sid, eid2) in list(self._lines.items()): if sid in self._external_entity_ids or eid2 in self._external_entity_ids: del self._lines[lid] if lid in self._entities: del self._entities[lid] removed = set(self._external_entity_ids) self._external_entity_ids.clear() self._prune_log_for(removed) self._rebuild_solver() self._rebuild_labels() def get_external_entity_ids(self) -> set: """Return the set of external (underlay) entity ids currently in the sketch.""" return set(self._external_entity_ids) # ── Centerlines (X and Y reference axes) ──────────────────────────── _CENTERLINE_EXTENT: float = 10000.0 # large enough to span any sketch def add_centerlines(self) -> None: """Add X (horizontal) and Y (vertical) centerlines through the origin. These construction lines serve as reference axes for constraining sketch geometry. They are: • Fixed in the solver (never move) • Marked is_construction (excluded from profile / face detection) • Non-deletable • Available as constraint targets (snap, coincident, distance, symmetric, horizontal, vertical, parallel, perpendicular) The X centerline runs horizontal (left→right) through origin and the Y centerline runs vertical (bottom→top) through origin. Both extend to ``_CENTERLINE_EXTENT`` in both directions so they span any realistic sketch. If centerlines have already been added this is a no-op. """ if self._centerline_ids: return HALF = self._CENTERLINE_EXTENT # Origin point — auto-fixed as the first point in the solver origin = self.add_point(0.0, 0.0) origin.is_construction = True # X centerline (horizontal) xl = self.add_point(-HALF, 0.0) xl.is_construction = True xr = self.add_point(HALF, 0.0) xr.is_construction = True xline = self.add_line(xl, xr) xline.is_construction = True self.constrain_horizontal(xline) self.constrain_fixed(xl) # Y centerline (vertical) yb = self.add_point(0.0, -HALF) yb.is_construction = True yt = self.add_point(0.0, HALF) yt.is_construction = True yline = self.add_line(yb, yt) yline.is_construction = True self.constrain_vertical(yline) self.constrain_fixed(yb) self._centerline_ids = { origin.id, xl.id, xr.id, xline.id, yb.id, yt.id, yline.id, } self.solve() def get_centerline_ids(self) -> set: """Return the set of centerline entity ids currently in the sketch.""" return set(self._centerline_ids) def add_rectangle( self, corner1: Tuple[float, float], corner2: Tuple[float, float] ) -> List[OCCSketchEntity]: """Add a rectangle, returning the created entities.""" x1, y1 = corner1 x2, y2 = corner2 entities: List[OCCSketchEntity] = [] p1 = self.add_point(x1, y1) p2 = self.add_point(x2, y1) p3 = self.add_point(x2, y2) p4 = self.add_point(x1, y2) entities.extend([p1, p2, p3, p4]) l1 = self.add_line(p1, p2) l2 = self.add_line(p2, p3) l3 = self.add_line(p3, p4) l4 = self.add_line(p4, p1) entities.extend([l1, l2, l3, l4]) return entities # ─── Constraint methods (actual solver calls) ────────────────────────── def _record_constraint( self, ctype: str, ids: Tuple[int, ...], params: Tuple = (), labels: Tuple[str, ...] = () ) -> None: """Count and log a constraint so the solver can be rebuilt after deletions.""" self._constraint_count += 1 self._constraint_log.append( {"type": ctype, "ids": tuple(int(i) for i in ids), "params": tuple(params), "labels": set(labels)} ) def _apply_constraint_log(self, entry: Dict[str, Any]) -> bool: """Re-apply a single logged constraint to the current (rebuilt) solver. Uses live solver handles looked up by entity id. Returns False silently if any referenced entity is now gone (pruning should have removed it, but this is defensive). """ ctype = entry["type"] ids = entry["ids"] params = entry["params"] def h(i: int) -> Any: ent = self._entities.get(i) return ent.handle if ent is not None else None if ctype == "coincident": if h(ids[0]) is None or h(ids[1]) is None: return False self._solver.coincident(h(ids[0]), h(ids[1]), self._wp) elif ctype == "horizontal": if h(ids[0]) is None: return False self._solver.horizontal(h(ids[0]), self._wp) elif ctype == "vertical": if h(ids[0]) is None: return False self._solver.vertical(h(ids[0]), self._wp) elif ctype == "distance": if h(ids[0]) is None or h(ids[1]) is None: return False self._solver.distance(h(ids[0]), h(ids[1]), params[0], self._wp) elif ctype == "angle": if h(ids[0]) is None or h(ids[1]) is None: return False self._solver.angle(h(ids[0]), h(ids[1]), params[0], self._wp) elif ctype == "parallel": if h(ids[0]) is None or h(ids[1]) is None: return False self._solver.parallel(h(ids[0]), h(ids[1]), self._wp) elif ctype == "perpendicular": if h(ids[0]) is None or h(ids[1]) is None: return False self._solver.perpendicular(h(ids[0]), h(ids[1]), self._wp) elif ctype == "midpoint": if h(ids[0]) is None or h(ids[1]) is None: return False self._solver.midpoint(h(ids[0]), h(ids[1]), self._wp) elif ctype == "tangent": if h(ids[0]) is None or h(ids[1]) is None: return False self._solver.tangent(h(ids[0]), h(ids[1]), self._wp) elif ctype == "equal": if h(ids[0]) is None or h(ids[1]) is None: return False self._solver.equal(h(ids[0]), h(ids[1]), self._wp) elif ctype == "fixed": if h(ids[0]) is None: return False self._solver.dragged(h(ids[0]), self._wp) elif ctype == "symmetric": if h(ids[0]) is None or h(ids[1]) is None or h(ids[2]) is None: return False self._solver.symmetric(h(ids[0]), h(ids[1]), h(ids[2]), self._wp) elif ctype == "equal_radius": # tracked only (no solver entity) pass else: return False return True def _rebuild_solver(self) -> None: """Recreate the SolveSpace system from current points/lines + log. python_solvespace cannot remove individual entities/constraints, so after deleting an entity we rebuild the whole system: re-add every surviving point at its current position (first point re-fixed for stability), re-add every surviving line, then re-apply the pruned constraint log. Entity ids are preserved; only solver handles change. """ # Snapshot current point positions before resetting the solver. saved_pos: Dict[int, Tuple[float, float]] = {} for eid, ent in self._entities.items(): if ent.entity_type == "point" and ent.geometry is not None: saved_pos[eid] = (float(ent.geometry[0]), float(ent.geometry[1])) self._solver = SolverSystem() self._wp = self._solver.create_2d_base() self._first_point_id = None # Re-add point entities in id order (preserves first-point-fixed). for pid in sorted(eid for eid, e in self._entities.items() if e.entity_type == "point"): ent = self._entities[pid] x, y = saved_pos.get(pid, (0.0, 0.0)) new_handle = self._solver.add_point_2d(x, y, self._wp) ent.handle = new_handle if self._first_point_id is None: self._first_point_id = pid self._solver.dragged(new_handle, self._wp) # Re-add line entities in id order, updating their solver handles. for lid in sorted(self._lines.keys()): sid, eid2 = self._lines[lid] s_ent = self._entities.get(sid) e_ent = self._entities.get(eid2) if s_ent is None or e_ent is None or s_ent.handle is None or e_ent.handle is None: continue new_handle = self._solver.add_line_2d(s_ent.handle, e_ent.handle, self._wp) line_ent = self._entities.get(lid) if line_ent is not None: line_ent.handle = new_handle # Re-apply every surviving logged constraint. for entry in self._constraint_log: self._apply_constraint_log(entry) def constrain_coincident(self, *entities: SketchEntity) -> bool: """Make entities coincident via solver.""" if len(entities) < 2: return False e1 = self._entities.get(entities[0].id) e2 = self._entities.get(entities[1].id) if e1 is None or e2 is None or e1.handle is None or e2.handle is None: return False self._solver.coincident(e1.handle, e2.handle, self._wp) self._record_constraint("coincident", (entities[0].id, entities[1].id)) return True def constrain_horizontal(self, line: SketchEntity) -> bool: """Constrain a line to be horizontal.""" entity = self._entities.get(line.id) if entity is None or entity.handle is None: return False self._solver.horizontal(entity.handle, self._wp) self._record_constraint("horizontal", (line.id,), labels=("hrz",)) if "hrz" not in entity.constraints: entity.constraints.append("hrz") return True def constrain_vertical(self, line: SketchEntity) -> bool: """Constrain a line to be vertical.""" entity = self._entities.get(line.id) if entity is None or entity.handle is None: return False self._solver.vertical(entity.handle, self._wp) self._record_constraint("vertical", (line.id,), labels=("vrt",)) if "vrt" not in entity.constraints: entity.constraints.append("vrt") return True def constrain_distance( self, entity1: SketchEntity, entity2: SketchEntity, distance: float ) -> bool: """Constrain distance between two entities.""" e1 = self._entities.get(entity1.id) e2 = self._entities.get(entity2.id) if e1 is None or e2 is None or e1.handle is None or e2.handle is None: return False self._solver.distance(e1.handle, e2.handle, distance, self._wp) self._record_constraint("distance", (entity1.id, entity2.id), (distance,)) return True def constrain_angle(self, line1: SketchEntity, line2: SketchEntity, angle: float) -> bool: """Constrain angle between two lines.""" e1 = self._entities.get(line1.id) e2 = self._entities.get(line2.id) if e1 is None or e2 is None or e1.handle is None or e2.handle is None: return False self._solver.angle(e1.handle, e2.handle, angle, self._wp) self._record_constraint("angle", (line1.id, line2.id), (angle,)) return True def constrain_parallel(self, line1: SketchEntity, line2: SketchEntity) -> bool: """Constrain two lines to be parallel.""" e1 = self._entities.get(line1.id) e2 = self._entities.get(line2.id) if e1 is None or e2 is None or e1.handle is None or e2.handle is None: return False self._solver.parallel(e1.handle, e2.handle, self._wp) self._record_constraint("parallel", (line1.id, line2.id)) return True def constrain_perpendicular(self, line1: SketchEntity, line2: SketchEntity) -> bool: """Constrain two lines to be perpendicular.""" e1 = self._entities.get(line1.id) e2 = self._entities.get(line2.id) if e1 is None or e2 is None or e1.handle is None or e2.handle is None: return False self._solver.perpendicular(e1.handle, e2.handle, self._wp) self._record_constraint("perpendicular", (line1.id, line2.id)) return True def constrain_midpoint(self, point: SketchEntity, line: SketchEntity) -> bool: """Constrain a point to be at the midpoint of a line.""" pt = self._entities.get(point.id) ln = self._entities.get(line.id) if pt is None or ln is None or pt.handle is None or ln.handle is None: return False self._solver.midpoint(pt.handle, ln.handle, self._wp) self._record_constraint("midpoint", (point.id, line.id), labels=("mid",)) if "mid" not in ln.constraints: ln.constraints.append("mid") return True def constrain_tangent(self, entity1: SketchEntity, entity2: SketchEntity) -> bool: """Constrain two entities to be tangent.""" e1 = self._entities.get(entity1.id) e2 = self._entities.get(entity2.id) if e1 is None or e2 is None or e1.handle is None or e2.handle is None: return False self._solver.tangent(e1.handle, e2.handle, self._wp) self._record_constraint("tangent", (entity1.id, entity2.id)) return True def constrain_equal_length(self, line1: SketchEntity, line2: SketchEntity) -> bool: """Constrain two lines to have equal length.""" e1 = self._entities.get(line1.id) e2 = self._entities.get(line2.id) if e1 is None or e2 is None or e1.handle is None or e2.handle is None: return False self._solver.equal(e1.handle, e2.handle, self._wp) self._record_constraint("equal", (line1.id, line2.id), labels=("eql",)) return True def constrain_equal_radius(self, circle1: SketchEntity, circle2: SketchEntity) -> bool: """Circle equal-radius (tracked only — solver limit).""" self._record_constraint("equal_radius", (circle1.id, circle2.id)) return True def constrain_fixed(self, entity: SketchEntity) -> bool: """Fix an entity in place via dragged constraint.""" ent = self._entities.get(entity.id) if ent is None or ent.handle is None: return False self._solver.dragged(ent.handle, self._wp) self._record_constraint("fixed", (entity.id,)) return True def constrain_symmetric( self, entity1: SketchEntity, entity2: SketchEntity, line: SketchEntity ) -> bool: """Constrain symmetry about a line.""" e1 = self._entities.get(entity1.id) e2 = self._entities.get(entity2.id) ln = self._entities.get(line.id) if e1 is None or e2 is None or ln is None: return False if e1.handle is None or e2.handle is None or ln.handle is None: return False self._solver.symmetric(e1.handle, e2.handle, ln.handle, self._wp) self._record_constraint("symmetric", (entity1.id, entity2.id, line.id)) return True # ─── Position updates (for moving entities) ────────────────────────── def set_entity_position(self, entity: SketchEntity, x: float, y: float) -> bool: """Move a point entity's position in BOTH the solver (params) and local tracking. Updating only ``entity.geometry`` is not enough — ``solve()`` reads from the solver's internal parameter values and would revert the move. We push the new coordinates into the solver via ``set_params`` so unconstrained points keep their dragged location and constrained ones are recomputed. """ ent = self._entities.get(entity.id) if ent is None or ent.handle is None: return False try: self._solver.set_params(ent.handle.params, (x, y)) except Exception as e: logger.debug(f"set_params failed for entity {entity.id}: {e}") return False ent.geometry = (x, y) if entity.id in self._points: self._points[entity.id] = (x, y) return True def set_positions(self, positions: Dict[int, Tuple[float, float]]) -> bool: """Bulk-apply new positions for a set of point entities (entity_id -> (x, y)).""" ok = True for eid, (x, y) in positions.items(): ent = self._entities.get(eid) if ent is None or ent.handle is None: continue try: self._solver.set_params(ent.handle.params, (x, y)) ent.geometry = (x, y) if eid in self._points: self._points[eid] = (x, y) except Exception as e: logger.debug(f"set_positions failed for entity {eid}: {e}") ok = False return ok # ─── Solving ─────────────────────────────────────────────────────────── def solve(self) -> bool: """Solve all constraints via SolveSpace solver.""" try: result = self._solver.solve() if result == ResultFlag.OKAY: # Sync solved positions back to entity geometries self._sync_solved_positions() return True else: logger.warning(f"Solver returned: {result}") return False except Exception as e: logger.error(f"Solver error: {e}") return False def _sync_solved_positions(self) -> None: """Read solved point positions from solver and update entity geometries.""" for entity_id, entity in list(self._entities.items()): if entity.entity_type == "point" and entity.handle is not None: try: x, y = self._solver.params(entity.handle.params) entity.geometry = (x, y) if entity_id in self._points: self._points[entity_id] = (x, y) except Exception as e: logger.debug(f"Could not sync point {entity_id}: {e}") elif entity.entity_type == "line" and entity_id in self._lines: start_id, end_id = self._lines[entity_id] start_entity = self._entities.get(start_id) end_entity = self._entities.get(end_id) if start_entity and end_entity and start_entity.geometry and end_entity.geometry: entity.geometry = (start_entity.geometry, end_entity.geometry) def get_solved_point(self, entity_id: int) -> Optional[Tuple[float, float]]: """Get the solved position of a point entity.""" entity = self._entities.get(entity_id) if entity and entity.entity_type == "point" and entity.handle is not None: try: x, y = self._solver.params(entity.handle.params) return (float(x), float(y)) except Exception: pass return None def get_solved_param(self, handle: Any) -> Optional[Tuple[float, float]]: """Get solved params for a solver entity handle.""" try: x, y = self._solver.params(handle.params) return (float(x), float(y)) except Exception: return None # ─── Geometry extraction for operations ──────────────────────────────── def get_geometry(self) -> GeometryObject: """Get the solved geometry as an OCC ``TopoDS_Face`` on the workplane. If the sketch has exactly one detected face (outer boundary + optional holes) that face is returned. Otherwise falls back to returning a single circle or polygon as a face. The returned object carries the workplane normal in ``metadata["normal"]`` so the kernel can extrude along the plane normal (not a hardcoded +Z). """ faces = self.detect_faces() if len(faces) == 1: return self.build_face_geometry(faces[0]) # Fallback: wrap the first non-external circle, or the polygon, as a # single-loop face. External (underlay) circles are reference geometry # and must not be returned as the extruded profile. if self._circles: for entity_id, (center_id, radius) in self._circles.items(): if entity_id in self._external_entity_ids: continue center_entity = self._entities.get(center_id) circle_ent = self._entities.get(entity_id) if center_entity and center_entity.geometry and not center_entity.is_external: # Skip construction circles — they're reference geometry. if circle_ent is not None and circle_ent.is_construction: continue cx, cy = center_entity.geometry face_dict = { "outer": {"type": "circle", "center": (cx, cy), "radius": radius}, "holes": [], } return self.build_face_geometry(face_dict) points = self.get_polygon_points() if not points: return OCCGeometryObject(None) face_dict = { "outer": {"type": "polygon", "points": [(p.x, p.y) for p in points]}, "holes": [], } return self.build_face_geometry(face_dict) def get_points(self) -> List[Point2D]: """Get all point positions from solved solver data.""" points: List[Point2D] = [] for entity_id, entity in self._entities.items(): if entity.entity_type == "point": # Try to get solved position first if entity.handle is not None: try: x, y = self._solver.params(entity.handle.params) points.append(Point2D(x, y)) continue except Exception: pass # Fall back to stored geometry if entity.geometry: x, y = entity.geometry points.append(Point2D(x, y)) return points def get_polygon_points(self) -> List[Point2D]: """Get ordered polygon points from connected lines (uses solved positions). External (underlay) and construction lines are skipped — they are reference geometry only, not part of the sketch profile. """ adjacency: Dict[Tuple[float, float], List[Tuple[float, float]]] = {} for entity in self._entities.values(): if entity.entity_type == "line" and entity.geometry and not entity.is_external and not entity.is_construction: p1, p2 = entity.geometry if p1 not in adjacency: adjacency[p1] = [] if p2 not in adjacency: adjacency[p2] = [] adjacency[p1].append(p2) adjacency[p2].append(p1) if not adjacency: return [] ordered: List[Point2D] = [] visited: set = set() current = next(iter(adjacency.keys())) while current and tuple(current) not in visited: ordered.append(Point2D(current[0], current[1])) visited.add(tuple(current)) neighbors = adjacency.get(current, []) next_point = None for n in neighbors: if tuple(n) not in visited: next_point = n break current = next_point if len(ordered) > 2: ordered.append(ordered[0]) return ordered # ─── Closed-loop / face detection (for region selection + holes) ────── _SNAP_TOL: float = 1e-4 # world-unit tolerance for snapping line endpoints def _line_segments(self) -> List[Tuple[Tuple[float, float], Tuple[float, float]]]: """Current line segments as world-coordinate tuples (uses solved positions). Returns both straight line segments AND tessellated arc segments so that arcs participate in closed-loop / face detection. Construction and external entities are excluded — they're reference geometry and must not affect the sketch profile. Tessellation density: roughly 12 segments per π radians of arc sweep, which gives smooth-looking closed loops for face detection. """ segs: List[Tuple[Tuple[float, float], Tuple[float, float]]] = [] # ── Straight line segments ── for line_id, (sid, eid2) in self._lines.items(): if line_id in self._external_entity_ids: continue line_ent = self._entities.get(line_id) if line_ent is not None and line_ent.is_construction: continue s_ent = self._entities.get(sid) e_ent = self._entities.get(eid2) if s_ent and e_ent and s_ent.geometry and e_ent.geometry: segs.append(((float(s_ent.geometry[0]), float(s_ent.geometry[1])), (float(e_ent.geometry[0]), float(e_ent.geometry[1])))) # ── Arc segments (tessellated) ── for arc_id, arc_data in self._arcs.items(): arc_ent = self._entities.get(arc_id) if arc_ent is not None and arc_ent.is_construction: continue center_id = arc_data.get("center") start_id = arc_data.get("start") end_id = arc_data.get("end") radius = arc_data.get("radius", 0.0) sweep = arc_data.get("sweep") if sweep is None or radius <= 0: continue c_ent = self._entities.get(center_id) s_ent = self._entities.get(start_id) e_ent = self._entities.get(end_id) if not (c_ent and s_ent and e_ent and c_ent.geometry and s_ent.geometry and e_ent.geometry): continue cx, cy = c_ent.geometry sx, sy = s_ent.geometry start_angle = math.atan2(sy - cy, sx - cx) # ~12 segments per π radians n = max(4, int(abs(sweep) / (math.pi / 12))) for i in range(n): t1 = i / n t2 = (i + 1) / n a1 = start_angle + t1 * sweep a2 = start_angle + t2 * sweep p1 = (cx + radius * math.cos(a1), cy + radius * math.sin(a1)) p2 = (cx + radius * math.cos(a2), cy + radius * math.sin(a2)) segs.append((p1, p2)) return segs def get_closed_loops(self) -> List[Dict[str, Any]]: """Detect closed loops: polygon cycles from connected lines + each circle. Each loop is one of: {"type": "polygon", "points": [(x,y), ...]} (closed, last == first) {"type": "circle", "center": (x,y), "radius": r} Line endpoint coordinates are snapped to ``_SNAP_TOL`` so a closed rectangle's four corners join into one cycle even after solver floating point jitter. Only connected components where every node has degree 2 (a simple closed polyline) are accepted as polygon loops. """ loops: List[Dict[str, Any]] = [] segs = self._line_segments() if segs: # Snap endpoints to integer-ish keys to group coincident points. def key(pt): return (round(pt[0] / self._SNAP_TOL), round(pt[1] / self._SNAP_TOL)) reprs: Dict[Any, Tuple[float, float]] = {} # key -> averaged world pt edges: List[Tuple[Any, Any]] = [] for p1, p2 in segs: k1, k2 = key(p1), key(p2) reprs.setdefault(k1, p1) reprs.setdefault(k2, p2) edges.append((k1, k2)) # Undirected adjacency. adj: Dict[Any, List[Any]] = {} for a, b in edges: adj.setdefault(a, []).append(b) adj.setdefault(b, []).append(a) # Connected components (each node with degree 2 → closed loop). seen: set = set() for start in adj: if start in seen or len(adj[start]) != 2: continue # Walk the component. comp: List[Any] = [] stack = [start] comp_seen: set = set() while stack: n = stack.pop() if n in comp_seen: continue comp_seen.add(n) comp.append(n) for nb in adj.get(n, []): if nb not in comp_seen: stack.append(nb) if all(len(adj[n]) == 2 for n in comp) and len(comp) >= 3: # Order the cycle by following each node's neighbor not yet visited. ordered: List[Any] = [] cur = comp[0] prev = None for _ in range(len(comp)): ordered.append(cur) nbrs = [nb for nb in adj[cur] if nb != prev] if not nbrs: break prev = cur cur = nbrs[0] if len(ordered) == len(comp): pts = [reprs[k] for k in ordered] pts.append(pts[0]) loops.append({"type": "polygon", "points": pts}) seen |= comp_seen # Circles are closed loops of their own. for cid, (center_id, r) in self._circles.items(): c_ent = self._entities.get(center_id) if c_ent and c_ent.geometry and r > 0: loops.append({"type": "circle", "center": (float(c_ent.geometry[0]), float(c_ent.geometry[1])), "radius": float(r)}) return loops @staticmethod def _point_in_polygon( pt: Tuple[float, float], poly: List[Tuple[float, float]], margin: float = 0.0, ) -> bool: """Ray-casting point-in-polygon test. Returns *True* for points strictly inside the polygon. Points on the boundary (within eps=1e-9) are *outside* by default so the outer boundary of a nested shape doesn't falsely contain a hole's rep point. When *margin* > 0, points that are within that many world-unit of the boundary are also treated as inside — used by ``_loop_contains`` to prevent float rounding from breaking thin-wall nesting detection. """ x, y = pt eps = 1e-9 # strict boundary rejection margin = float(margin) n = len(poly) inside = False j = n - 1 for i in range(n): xi, yi = poly[i] xj, yj = poly[j] # Point-on-segment test — exclude strict boundary hits. # First check bounding box of the segment. bbox_tol = max(eps, margin) if min(xi, xj) - bbox_tol <= x <= max(xi, xj) + bbox_tol and min(yi, yj) - bbox_tol <= y <= max(yi, yj) + bbox_tol: # Check collinearity cross = (x - xi) * (yj - yi) - (y - yi) * (xj - xi) abs_cross = abs(cross) if abs_cross < eps: # Strictly on boundary — return False unless margin says otherwise. if margin > 0 and abs_cross < margin: pass # fall through to ray-cast below else: return False if ((yi > y) != (yj > y)) and (x < (xj - xi) * (y - yi) / (yj - yi + 1e-30) + xi): inside = not inside j = i return inside @staticmethod def _loop_contains(inner: Dict[str, Any], outer: Dict[str, Any]) -> bool: """Does ``outer`` fully enclose ``inner``? For polygon-polygon: checks that ALL vertices of ``inner`` are strictly inside ``outer`` using ray-casting. This is robust for convex polygons and avoids the representative-point issue where a large nested loop's centroid lands inside an inner loop. For circle-in-polygon: checks the circle centre is inside the polygon (vertex check would be too strict for tessellated arc segments). For circle-in-circle: checks distance between centres + inner radius < outer radius + margin. For polygon-in-circle: checks all polygon vertices are inside the circle. """ eps = 1e-3 if outer["type"] == "circle": ox, oy = outer["center"] orad = outer["radius"] if inner["type"] == "circle": # Two circles: centre distance + inner radius < outer radius dx = inner["center"][0] - ox dy = inner["center"][1] - oy return math.hypot(dx, dy) + inner["radius"] < orad + eps else: # Polygon in circle: all vertices inside pts = inner["points"] if len(pts) > 1 and pts[0] == pts[-1]: pts = pts[:-1] for pt in pts: if math.hypot(pt[0] - ox, pt[1] - oy) > orad - eps: return False return True else: # outer is polygon if inner["type"] == "circle": # Circle in polygon: centre must be inside with margin cx, cy = inner["center"] return OCCSketch._point_in_polygon( (cx, cy), outer["points"], margin=1e-3 ) else: # Polygon in polygon: ALL inner vertices inside outer pts = inner["points"] if len(pts) > 1 and pts[0] == pts[-1]: pts = pts[:-1] for pt in pts: if not OCCSketch._point_in_polygon( pt, outer["points"], margin=eps ): return False return True @staticmethod def _loop_rep_point(loop: Dict[str, Any]) -> Tuple[float, float]: """An interior representative point inside a loop. Only used for circle-in-polygon containment checks (polygon-in-polygon uses all-vertex containment). Returns the centroid for polygons and the centre for circles. """ if loop["type"] == "polygon": pts = loop["points"][:-1] if len(loop["points"]) > 1 and loop["points"][0] == loop["points"][-1] else loop["points"] n = len(pts) if n < 3: return loop.get("center", (0.0, 0.0)) sx = sum(p[0] for p in pts) / n sy = sum(p[1] for p in pts) / n return (sx, sy) return loop.get("center", (0.0, 0.0)) @staticmethod def _loop_area(loop: Dict[str, Any]) -> float: if loop["type"] == "circle": return math.pi * loop["radius"] ** 2 pts = loop["points"] if len(pts) < 4: return 0.0 area = 0.0 n = len(pts) - 1 # last == first for i in range(n): x1, y1 = pts[i] x2, y2 = pts[i + 1] area += x1 * y2 - x2 * y1 return abs(area) / 2.0 def detect_faces(self) -> List[Dict[str, Any]]: """Build faces from closed loops using nesting depth. Nesting rule (standard CAD even-odd): a loop's depth = number of other loops that strictly contain it. Even-depth loops (0, 2, ...) are outer boundaries (solid material); odd-depth loops directly inside them are holes. So a rectangle (depth 0) wrapping a circle (depth 1) yields a face that is the rectangle minus the circle — exactly the "shape within a shape = closed without inner" behavior. A shape nested inside a hole (depth 2) becomes its own solid face again. Returns a list of ``{"outer": loop, "holes": [loop, ...], "depth": int}``. """ loops = self.get_closed_loops() if not loops: return [] depths: List[int] = [] for i, li in enumerate(loops): d = 0 for j, lj in enumerate(loops): if i != j and OCCSketch._loop_contains(li, lj): d += 1 depths.append(d) faces: List[Dict[str, Any]] = [] for i, outer in enumerate(loops): if depths[i] % 2 != 0: continue # only even-depth loops are outer boundaries holes: List[Dict[str, Any]] = [] for j, inner in enumerate(loops): if i == j: continue # directly nested: depth one greater, and outer contains inner. if depths[j] == depths[i] + 1 and OCCSketch._loop_contains(inner, outer): holes.append(inner) faces.append({"outer": outer, "holes": holes, "depth": depths[i]}) return faces def find_face_at(self, x: float, y: float) -> Optional[Dict[str, Any]]: """Return the face whose solid region (outer minus holes) contains (x, y).""" pt = (x, y) best: Optional[Dict[str, Any]] = None best_area = float("inf") for face in self.detect_faces(): outer = face["outer"] if outer["type"] == "polygon": if not OCCSketch._point_in_polygon(pt, outer["points"]): continue else: cx, cy = outer["center"] if not (math.hypot(pt[0] - cx, pt[1] - cy) < outer["radius"]): continue # Must not be inside any hole of this face. in_hole = False for h in face["holes"]: if h["type"] == "polygon": if OCCSketch._point_in_polygon(pt, h["points"]): in_hole = True; break else: hcx, hcy = h["center"] if math.hypot(pt[0] - hcx, pt[1] - hcy) < h["radius"]: in_hole = True; break if in_hole: continue area = OCCSketch._loop_area(outer) if area < best_area: best_area = area best = face return best @staticmethod def _loop_signed_area(loop: Dict[str, Any]) -> float: """Signed area of a loop. Positive = CCW, negative = CW. Circles are treated as CCW (positive area) because ``gp_Circ`` / ``gp_Ax2`` creates edges with CCW parametric direction when looking against the normal. """ if loop["type"] == "circle": r = loop.get("radius", 0.0) return math.pi * r * r # always positive (CCW) pts = loop["points"] if len(pts) < 3: return 0.0 area = 0.0 n = len(pts) - 1 # last point == first for closed loops for i in range(n): x1, y1 = pts[i] x2, y2 = pts[i + 1] area += x1 * y2 - x2 * y1 return area / 2.0 def build_face_geometry(self, face: Dict[str, Any]) -> OCCGeometryObject: """Build an OCC face (outer boundary + inner holes) on the workplane. Wires are constructed from UV coordinates mapped through :meth:`_uv_to_world`, so the resulting ``TopoDS_Face`` lies on this sketch's 3D plane (not necessarily XY). The returned object stores the raw OCC face in ``.shape`` and the plane normal in ``metadata["normal"]`` for the extrude kernel. Hole wires are oriented to have OPPOSITE geometric winding relative to the outer wire, which is what OCC's face builder expects for proper hole treatment. Previous code unconditionally reversed ALL hole wires, which produced solid islands (not holes) whenever the outer loop had clockwise winding — e.g. after dragging a rectangle from top-left to bottom-right. """ from OCP.BRepBuilderAPI import ( BRepBuilderAPI_MakePolygon, BRepBuilderAPI_MakeFace, BRepBuilderAPI_MakeWire, BRepBuilderAPI_MakeEdge, ) from OCP.gp import gp_Pnt, gp_Circ, gp_Ax2, gp_Dir from OCP.TopoDS import TopoDS as _TopoDS def _wire_from_loop(loop: Dict[str, Any]): """Build a wire from a loop dict. No orientation adjustment.""" if loop["type"] == "polygon": mp = BRepBuilderAPI_MakePolygon() for (pu, pv) in loop["points"]: mp.Add(self._uv_to_world(pu, pv)) mp.Close() mp.Build() return mp.Wire() cu, cv = loop["center"] r = loop["radius"] circ = gp_Circ(self._circle_axis(cu, cv), r) me = BRepBuilderAPI_MakeEdge(circ) me.Build() mw = BRepBuilderAPI_MakeWire() mw.Add(me.Edge()) mw.Build() return mw.Wire() outer_loop = face["outer"] outer_wire = _wire_from_loop(outer_loop) outer_winding = self._loop_signed_area(outer_loop) face_maker = BRepBuilderAPI_MakeFace(outer_wire, True) for h in face["holes"]: hole_wire = _wire_from_loop(h) hole_winding = self._loop_signed_area(h) # OCC expects hole wires to have OPPOSITE winding to the outer # wire (material on the other side). We reverse the hole wire # only when its natural winding matches the outer's; if they # already differ the wire is left as-is. if (hole_winding >= 0 and outer_winding >= 0) or (hole_winding < 0 and outer_winding < 0): hole_wire = _TopoDS.Wire_s(hole_wire.Reversed()) face_maker.Add(hole_wire) face_maker.Build() occ_face = face_maker.Face() obj = OCCGeometryObject(occ_face, { "type": "sketch_face", "normal": self._wp_normal, "origin": self._wp_origin, }) return obj def get_solver_dof(self) -> int: """Get remaining degrees of freedom from solver.""" return self._solver.dof() def get_solver_failures(self) -> List[Any]: """Get list of failed constraints.""" return self._solver.failures() # ─── Management ──────────────────────────────────────────────────────── def clear(self) -> None: """Clear all geometry and constraints from both solver and tracker.""" self._solver = SolverSystem() self._wp = self._solver.create_2d_base() self._entities.clear() self._points.clear() self._lines.clear() self._circles.clear() self._arcs.clear() self._entity_counter = 0 self._constraint_count = 0 self._constraint_log.clear() self._external_entity_ids.clear() self._centerline_ids.clear() self._first_point_id = None def _prune_log_for(self, removed_ids: set) -> None: """Drop constraint-log entries that reference any id in ``removed_ids``.""" kept_log: List[Dict[str, Any]] = [] for entry in self._constraint_log: if not (set(entry["ids"]) & removed_ids): kept_log.append(entry) self._constraint_log = kept_log self._constraint_count = len(kept_log) def delete_line(self, line: SketchEntity) -> bool: """Delete a single line and recompute the surviving constraints. python_solvespace has no API to remove an individual entity/constraint, so this removes the line from local tracking, prunes any logged constraint that referenced it, rebuilds the whole solver system from the surviving points/lines + pruned log, and re-solves. The line's endpoint points are NOT removed — only the line segment. Note: centerlines (reference axes through origin) cannot be deleted. """ if line.id not in self._lines or line.id not in self._entities: return False # Centerlines are permanent reference axes — refuse deletion. if line.id in self._centerline_ids: logger.debug("Refusing to delete centerline") return False del self._lines[line.id] if line.id in self._entities: del self._entities[line.id] # Prune log entries referencing the deleted line (labels are re-derived # from the surviving log below, so no manual label stripping here). self._prune_log_for({line.id}) self._rebuild_solver() self._rebuild_labels() return self.solve() def remove_constraint_at(self, index: int) -> bool: """Remove a single constraint (by log index) and recompute the rest. Used by the sketch widget when the user hovers a constraint tag and presses Delete. Drops that one log entry, rebuilds the solver from the surviving log, re-derives UI labels, and re-solves. """ if index < 0 or index >= len(self._constraint_log): return False del self._constraint_log[index] self._constraint_count = len(self._constraint_log) self._rebuild_solver() self._rebuild_labels() return self.solve() def delete_point(self, point: SketchEntity) -> bool: """Delete a point, any lines that use it as an endpoint, and recompute. Removing a point invalidates every line that references it (a line with a missing endpoint is meaningless), so those lines are removed too. All constraints that reference the point OR the removed lines are pruned from the log, the solver is rebuilt from survivors, labels are re-derived, and the system is re-solved. Note: centerlines (reference axes through origin) cannot be deleted. """ if point.id not in self._entities or point.id not in self._points: return False # Centerline points are permanent reference anchors — refuse deletion. if point.id in self._centerline_ids: logger.debug("Refusing to delete centerline point") return False removed_ids: set = {point.id} # Remove lines that use this point as an endpoint. removed_line_keys: List[int] = [ lid for lid, (sid, eid2) in list(self._lines.items()) if sid == point.id or eid2 == point.id ] for lid in removed_line_keys: removed_ids.add(lid) del self._lines[lid] if lid in self._entities: del self._entities[lid] # Remove the point itself. del self._points[point.id] if point.id in self._entities: del self._entities[point.id] # Circles anchored on the point are also invalid. removed_circle_keys: List[int] = [ cid for cid, (center_id, _r) in list(self._circles.items()) if center_id == point.id ] for cid in removed_circle_keys: removed_ids.add(cid) del self._circles[cid] if cid in self._entities: del self._entities[cid] # Arcs referencing this point (as centre, start, or end) are invalid. removed_arc_keys: List[int] = [ aid for aid, adata in list(self._arcs.items()) if adata.get("center") == point.id or adata.get("start") == point.id or adata.get("end") == point.id ] for aid in removed_arc_keys: removed_ids.add(aid) del self._arcs[aid] if aid in self._entities: del self._entities[aid] self._prune_log_for(removed_ids) self._rebuild_solver() self._rebuild_labels() return self.solve() def _rebuild_labels(self) -> None: """Re-derive each entity's UI constraint labels from the surviving log. paintEvent displays labels read off the endpoint POINT entities ("hrz", "vrt", "mid", ...). After a delete, recompute them from scratch so a removed line's labels don't linger on points that still belong to other (unaffected) lines. """ for ent in self._entities.values(): ent.constraints = [] for entry in self._constraint_log: labels = entry.get("labels") or set() if not labels: continue ctype = entry["type"] ids = entry["ids"] targets: List[OCCSketchEntity] = [] if ctype in ("horizontal", "vertical"): sid, eid2 = self._lines.get(ids[0], (None, None)) for pid in (sid, eid2): if pid is not None and pid in self._entities: targets.append(self._entities[pid]) elif ctype == "midpoint": sid, eid2 = self._lines.get(ids[1], (None, None)) for pid in (sid, eid2): if pid is not None and pid in self._entities: targets.append(self._entities[pid]) if ids[0] in self._entities: targets.append(self._entities[ids[0]]) else: # distance / equal / parallel / etc.: tag referenced entities' # endpoints (lines) or the points themselves. for eid in ids: if eid in self._lines: sid, eid2 = self._lines[eid] for pid in (sid, eid2): if pid in self._entities: targets.append(self._entities[pid]) elif eid in self._entities: targets.append(self._entities[eid]) for t in targets: for lbl in labels: if lbl not in t.constraints: t.constraints.append(lbl) def delete_entity(self, entity: SketchEntity) -> bool: """Delete an entity and its constraints (no solver rebuild).""" if entity.id not in self._entities: return False # Remove from solver (clear + rebuild is simplest) # For simplicity, we skip solver removal — on next solve, stale handles # will be ignored. A full rebuild would need entity-by-entity solver removal. del self._entities[entity.id] if entity.id in self._points: del self._points[entity.id] if entity.id in self._lines: del self._lines[entity.id] if entity.id in self._circles: del self._circles[entity.id] if entity.id in self._arcs: del self._arcs[entity.id] return True def get_entity_count(self) -> int: """Get the number of entities in the sketch.""" return len(self._entities) def get_constraint_count(self) -> int: """Get the number of constraints applied via solver.""" return self._constraint_count def is_fully_constrained(self) -> bool: """Check if the sketch is fully constrained (0 DOF).""" try: return self._solver.dof() == 0 except Exception: return False # ─── Serialization (used by fluency.io.project_io) ───────────────────── def to_dict(self) -> Dict[str, Any]: """Serialize the sketch to a plain-dict for JSON storage. Captures: the workplane, every entity (with its current geometry and flags), the constraint log, and the entity counter. Live solver handles are intentionally NOT saved — the consumer must call :meth:`from_dict` (or :meth:`rebuild_from_dict`) to rebuild the SolveSpace system before solving again. """ # Sort entities by id so replay order is deterministic and matches # creation order (ids are assigned monotonically by ``_next_id``). entities_payload: List[Dict[str, Any]] = [] for eid in sorted(self._entities.keys()): ent = self._entities[eid] entities_payload.append( { "id": eid, "type": ent.entity_type, # geometry shape varies: point→(x,y), line→((x1,y1),(x2,y2)), # circle→((cx,cy),r), arc→dict. All JSON-friendly. "geometry": ent.geometry, "is_construction": bool(ent.is_construction), "is_external": bool(ent.is_external), "constraints": list(ent.constraints), } ) # Sets become sorted lists for JSON. ``labels`` inside constraint_log # is a set on the wire; convert to sorted list for JSON round-trip. constraint_log_payload: List[Dict[str, Any]] = [] for entry in self._constraint_log: constraint_log_payload.append( { "type": entry["type"], "ids": list(entry["ids"]), "params": list(entry["params"]), "labels": sorted(entry["labels"]), } ) return { "wp_origin": list(self._wp_origin), "wp_normal": list(self._wp_normal), "wp_x_dir": list(self._wp_x_dir), "wp_y_dir": list(self._wp_y_dir), "entity_counter": self._entity_counter, "first_point_id": self._first_point_id, "external_entity_ids": sorted(self._external_entity_ids), "centerline_ids": sorted(self._centerline_ids), "constraint_count": self._constraint_count, "entities": entities_payload, "constraint_log": constraint_log_payload, } @classmethod def from_dict(cls, data: Dict[str, Any]) -> "OCCSketch": """Build a fresh OCCSketch that reproduces the saved state. Replays the construction sequence (points → lines → circles → arcs, respecting external/centerline flags) and re-applies every constraint in the saved log. The SolveSpace solver is deterministic for a given input, so the post-solve state matches the saved one. """ sk = cls() sk.rebuild_from_dict(data) return sk def rebuild_from_dict(self, data: Dict[str, Any]) -> None: """In-place restore from a dict produced by :meth:`to_dict`. Wipes the current solver/state and re-creates every entity in id order so :attr:`_first_point_id` is anchored correctly. Existing callers (notably the solver-rebuild path on entity delete) don't use this; only the project load path does. """ # Wipe solver + trackers (don't lose the workplane yet — we set it # explicitly below). self.clear() self._external_entity_ids.clear() self._centerline_ids.clear() # 1. Workplane. self.set_workplane( tuple(data["wp_origin"]), tuple(data["wp_normal"]), tuple(data["wp_x_dir"]), ) # 2. Force the entity counter so the replay assigns the same ids as # the saved sketch — the constraint log references those ids. self._entity_counter = int(data.get("entity_counter", 0)) # 3. Replay entities in id order. We need the OCCSketchEntity # objects back (for arc center/start/end lookups), so we # reconstruct by id and let ``_next_id`` advance the counter. entities_by_id: Dict[int, OCCSketchEntity] = {} for entry in data.get("entities", []): eid = int(entry["id"]) # Ensure the next _next_id() call returns eid. self._entity_counter = eid - 1 etype = entry["type"] geom = entry.get("geometry") is_external = bool(entry.get("is_external", False)) if etype == "point": x, y = float(geom[0]), float(geom[1]) if is_external: ent = self.add_external_point(x, y) else: ent = self.add_point(x, y) elif etype == "line": # line geometry is ((x1,y1),(x2,y2)); we already know the # endpoints exist as point entities. Look them up by saved # position via _points (which was just populated above). (x1, y1), (x2, y2) = geom s_id = self._find_point_at(x1, y1) e_id = self._find_point_at(x2, y2) if s_id is None or e_id is None: logger.warning( "Skipping line %s during load: endpoints not found", eid ) continue if is_external: ent = self.add_external_line(entities_by_id[s_id], entities_by_id[e_id]) else: ent = self.add_line(entities_by_id[s_id], entities_by_id[e_id]) elif etype == "circle": (cx, cy), radius = geom c_id = self._find_point_at(cx, cy) if c_id is None: logger.warning( "Skipping circle %s during load: center not found", eid ) continue ent = self.add_circle(entities_by_id[c_id], float(radius)) elif etype == "arc": center_pos = tuple(geom["center"]) start_pos = tuple(geom["start"]) end_pos = tuple(geom["end"]) radius = float(geom["radius"]) sweep = float(geom.get("sweep", 0.0)) c_id = self._find_point_at(*center_pos) s_id = self._find_point_at(*start_pos) e_id = self._find_point_at(*end_pos) if c_id is None or s_id is None or e_id is None: logger.warning( "Skipping arc %s during load: endpoints not found", eid ) continue ent = self.add_arc( entities_by_id[c_id], radius, entities_by_id[s_id], entities_by_id[e_id], sweep=sweep, ) else: logger.warning("Unknown sketch entity type %r; skipping", etype) continue # Restore the per-entity UI flags / labels that aren't carried # by the add_* methods themselves. ent.is_construction = bool(entry.get("is_construction", False)) ent.constraints = list(entry.get("constraints", [])) entities_by_id[eid] = ent # 4. Replay constraint log. ``_apply_constraint_log`` re-issues the # solver call and pushes back into the entity tracker via # ``entity.constraints``. We don't double-record into the log # itself (the log was already cleared by ``clear()`` and we # re-populate it here, so the live ``_constraint_count`` will # reflect the saved state at the end). for entry in data.get("constraint_log", []): self._record_constraint( entry["type"], tuple(entry["ids"]), tuple(entry.get("params", ())), tuple(entry.get("labels", ())), ) # Re-apply the live solver calls AFTER the log is restored, so that # the constraint tracker matches the solver state on a fresh solve. for entry in self._constraint_log: self._apply_constraint_log(entry) # 5. Restore external / centerline id sets. ``add_external_*`` adds # to the set internally; if the entity's id was a regular entity # for some reason (legacy / hand-edited file), fold it in too so # the saved flag is authoritative. for eid in data.get("external_entity_ids", []): self._external_entity_ids.add(int(eid)) for eid in data.get("centerline_ids", []): self._centerline_ids.add(int(eid)) def _find_point_at(self, x: float, y: float, tol: float = 1e-6) -> Optional[int]: """Return the entity id of a point sitting at UV ``(x, y)`` (within tol).""" for pid, pos in self._points.items(): if abs(pos[0] - x) < tol and abs(pos[1] - y) < tol: return pid return None