1159 lines
47 KiB
Python
1159 lines
47 KiB
Python
"""
|
|
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
|
|
|
|
|
|
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]] = []
|
|
|
|
# Track first point as dragged/fixed for solver stability
|
|
self._first_point_id: Optional[int] = None
|
|
|
|
@property
|
|
def solver(self) -> SolverSystem:
|
|
"""Access the underlying SolveSpace solver."""
|
|
return self._solver
|
|
|
|
@property
|
|
def workplane(self) -> Any:
|
|
"""Get the solver workplane entity."""
|
|
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)."""
|
|
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:
|
|
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,
|
|
) -> OCCSketchEntity:
|
|
"""Add an arc (tracked only)."""
|
|
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
|
|
|
|
entity = OCCSketchEntity(
|
|
entity_id=entity_id,
|
|
entity_type="arc",
|
|
geometry={"center": (cx, cy), "radius": radius, "start": (sx, sy), "end": (ex, ey)},
|
|
)
|
|
|
|
self._entities[entity_id] = entity
|
|
self._arcs[entity_id] = {
|
|
"center": center.id,
|
|
"start": start_point.id,
|
|
"end": end_point.id,
|
|
"radius": radius,
|
|
}
|
|
|
|
return entity
|
|
|
|
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 for operations using CadQuery.
|
|
|
|
If the sketch has exactly one detected face (outer boundary + optional
|
|
holes) that face is returned as a combined face-with-holes Workplane.
|
|
Otherwise falls back to returning a single circle or polygon suitable
|
|
for extrude/revolve.
|
|
"""
|
|
import cadquery as cq
|
|
|
|
faces = self.detect_faces()
|
|
if len(faces) == 1:
|
|
return self.build_face_geometry(faces[0])
|
|
|
|
# Fallback: return the first circle, or a polygon, or None.
|
|
if self._circles:
|
|
for entity_id, (center_id, radius) in self._circles.items():
|
|
center_entity = self._entities.get(center_id)
|
|
if center_entity and center_entity.geometry:
|
|
cx, cy = center_entity.geometry
|
|
wp = cq.Workplane("XY").center(cx, cy).circle(radius)
|
|
obj = OCCGeometryObject(wp.val())
|
|
obj._cadquery_obj = wp
|
|
return obj
|
|
|
|
points = self.get_polygon_points()
|
|
if not points:
|
|
return OCCGeometryObject(None)
|
|
|
|
wp = cq.Workplane("XY").moveTo(points[0].x, points[0].y)
|
|
for pt in points[1:]:
|
|
wp = wp.lineTo(pt.x, pt.y)
|
|
wp = wp.close()
|
|
|
|
obj = OCCGeometryObject(wp.val())
|
|
obj._cadquery_obj = wp
|
|
return obj
|
|
|
|
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)."""
|
|
adjacency: Dict[Tuple[float, float], List[Tuple[float, float]]] = {}
|
|
|
|
for entity in self._entities.values():
|
|
if entity.entity_type == "line" and entity.geometry:
|
|
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)."""
|
|
segs: List[Tuple[Tuple[float, float], Tuple[float, float]]] = []
|
|
for line_id, (sid, eid2) in self._lines.items():
|
|
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]))))
|
|
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]]) -> bool:
|
|
"""Ray-casting point-in-polygon test.
|
|
|
|
Returns *True* only for strictly interior points. Points on the
|
|
boundary (within 1e-9) are considered *outside* so that the outer
|
|
boundary of a nested shape doesn't falsely contain another loop whose
|
|
representative point happens to land on that boundary.
|
|
"""
|
|
x, y = pt
|
|
eps = 1e-9
|
|
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.
|
|
if min(xi, xj) - eps <= x <= max(xi, xj) + eps and min(yi, yj) - eps <= y <= max(yi, yj) + eps:
|
|
# Check collinearity
|
|
cross = (x - xi) * (yj - yi) - (y - yi) * (xj - xi)
|
|
if abs(cross) < eps:
|
|
return False # on boundary
|
|
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``? Uses a representative point +
|
|
boundary tests on ``outer`` (only valid when ``outer`` != ``inner``)."""
|
|
rep = OCCSketch._loop_rep_point(inner)
|
|
if outer["type"] == "polygon":
|
|
return OCCSketch._point_in_polygon(rep, outer["points"])
|
|
else: # circle
|
|
cx, cy = outer["center"]
|
|
return math.hypot(rep[0] - cx, rep[1] - cy) < outer["radius"]
|
|
|
|
@staticmethod
|
|
def _loop_rep_point(loop: Dict[str, Any]) -> Tuple[float, float]:
|
|
"""An interior representative point inside a loop.
|
|
|
|
For polygons we use the midpoint between the centroid and the first
|
|
vertex (而不是 centroid 本身): a nested shape centered on the polygon's
|
|
centroid (e.g. a circle inside a rectangle, both centered on the same
|
|
point) would otherwise make the polygon's rep point coincide with the
|
|
hole and break containment tests. This midpoint stays inside convex
|
|
loops and is unlikely to land on a nested feature's center.
|
|
"""
|
|
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)
|
|
sx = sum(p[0] for p in pts) / n
|
|
sy = sum(p[1] for p in pts) / n
|
|
v0 = pts[0]
|
|
return ((sx + v0[0]) / 2.0, (sy + v0[1]) / 2.0)
|
|
return loop["center"]
|
|
|
|
@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
|
|
|
|
def build_face_geometry(self, face: Dict[str, Any]) -> OCCGeometryObject:
|
|
"""Build an OCC face (outer boundary + inner holes) wrapped in a Workplane.
|
|
|
|
The returned object feeds ``OCGeometryKernel.extrude`` directly: its
|
|
``_cadquery_obj`` is a Workplane whose stack holds the face, so cadquery's
|
|
``Workplane.extrude`` lifts it into a solid — inner wires become
|
|
through-holes.
|
|
"""
|
|
import cadquery as cq
|
|
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_loop(loop: Dict[str, Any], is_hole: bool = False):
|
|
if loop["type"] == "polygon":
|
|
mp = BRepBuilderAPI_MakePolygon()
|
|
for (px, py) in loop["points"]:
|
|
mp.Add(gp_Pnt(px, py, 0.0))
|
|
mp.Close()
|
|
mp.Build()
|
|
w = mp.Wire()
|
|
else:
|
|
cx, cy = loop["center"]
|
|
r = loop["radius"]
|
|
circ = gp_Circ(gp_Ax2(gp_Pnt(cx, cy, 0.0), gp_Dir(0, 0, 1)), r)
|
|
me = BRepBuilderAPI_MakeEdge(circ)
|
|
me.Build()
|
|
mw = BRepBuilderAPI_MakeWire()
|
|
mw.Add(me.Edge())
|
|
mw.Build()
|
|
w = mw.Wire()
|
|
if is_hole:
|
|
w = _TopoDS.Wire_s(w.Reversed()) # reverse orientation so OCC treats it as a hole
|
|
return w
|
|
|
|
outer_wire = wire_loop(face["outer"], is_hole=False)
|
|
face_maker = BRepBuilderAPI_MakeFace(outer_wire, True)
|
|
for h in face["holes"]:
|
|
face_maker.Add(wire_loop(h, is_hole=True))
|
|
face_maker.Build()
|
|
occ_face = face_maker.Face()
|
|
|
|
wp = cq.Workplane("XY")
|
|
wp = wp.add(cq.Face(occ_face))
|
|
obj = OCCGeometryObject(wp.val())
|
|
obj._cadquery_obj = wp
|
|
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._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.
|
|
"""
|
|
if line.id not in self._lines or line.id not in self._entities:
|
|
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.
|
|
"""
|
|
if point.id not in self._entities or point.id not in self._points:
|
|
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]
|
|
|
|
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
|