- Tons of addtions
This commit is contained in:
@@ -7,6 +7,7 @@ geometry generation from solved positions.
|
||||
"""
|
||||
|
||||
from typing import List, Tuple, Optional, Dict, Any
|
||||
import math
|
||||
import numpy as np
|
||||
import logging
|
||||
import re
|
||||
@@ -55,6 +56,10 @@ class OCCSketch(SketchInterface):
|
||||
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
|
||||
@@ -212,8 +217,129 @@ class OCCSketch(SketchInterface):
|
||||
|
||||
# ─── Constraint methods (actual solver calls) ──────────────────────────
|
||||
|
||||
def _add_constraint_record(self) -> None:
|
||||
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."""
|
||||
@@ -224,7 +350,7 @@ class OCCSketch(SketchInterface):
|
||||
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._add_constraint_record()
|
||||
self._record_constraint("coincident", (entities[0].id, entities[1].id))
|
||||
return True
|
||||
|
||||
def constrain_horizontal(self, line: SketchEntity) -> bool:
|
||||
@@ -233,7 +359,7 @@ class OCCSketch(SketchInterface):
|
||||
if entity is None or entity.handle is None:
|
||||
return False
|
||||
self._solver.horizontal(entity.handle, self._wp)
|
||||
self._add_constraint_record()
|
||||
self._record_constraint("horizontal", (line.id,), labels=("hrz",))
|
||||
if "hrz" not in entity.constraints:
|
||||
entity.constraints.append("hrz")
|
||||
return True
|
||||
@@ -244,7 +370,7 @@ class OCCSketch(SketchInterface):
|
||||
if entity is None or entity.handle is None:
|
||||
return False
|
||||
self._solver.vertical(entity.handle, self._wp)
|
||||
self._add_constraint_record()
|
||||
self._record_constraint("vertical", (line.id,), labels=("vrt",))
|
||||
if "vrt" not in entity.constraints:
|
||||
entity.constraints.append("vrt")
|
||||
return True
|
||||
@@ -258,7 +384,7 @@ class OCCSketch(SketchInterface):
|
||||
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._add_constraint_record()
|
||||
self._record_constraint("distance", (entity1.id, entity2.id), (distance,))
|
||||
return True
|
||||
|
||||
def constrain_angle(self, line1: SketchEntity, line2: SketchEntity, angle: float) -> bool:
|
||||
@@ -268,7 +394,7 @@ class OCCSketch(SketchInterface):
|
||||
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._add_constraint_record()
|
||||
self._record_constraint("angle", (line1.id, line2.id), (angle,))
|
||||
return True
|
||||
|
||||
def constrain_parallel(self, line1: SketchEntity, line2: SketchEntity) -> bool:
|
||||
@@ -278,7 +404,7 @@ class OCCSketch(SketchInterface):
|
||||
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._add_constraint_record()
|
||||
self._record_constraint("parallel", (line1.id, line2.id))
|
||||
return True
|
||||
|
||||
def constrain_perpendicular(self, line1: SketchEntity, line2: SketchEntity) -> bool:
|
||||
@@ -288,7 +414,7 @@ class OCCSketch(SketchInterface):
|
||||
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._add_constraint_record()
|
||||
self._record_constraint("perpendicular", (line1.id, line2.id))
|
||||
return True
|
||||
|
||||
def constrain_midpoint(self, point: SketchEntity, line: SketchEntity) -> bool:
|
||||
@@ -298,7 +424,9 @@ class OCCSketch(SketchInterface):
|
||||
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._add_constraint_record()
|
||||
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:
|
||||
@@ -308,7 +436,7 @@ class OCCSketch(SketchInterface):
|
||||
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._add_constraint_record()
|
||||
self._record_constraint("tangent", (entity1.id, entity2.id))
|
||||
return True
|
||||
|
||||
def constrain_equal_length(self, line1: SketchEntity, line2: SketchEntity) -> bool:
|
||||
@@ -318,12 +446,12 @@ class OCCSketch(SketchInterface):
|
||||
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._add_constraint_record()
|
||||
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._add_constraint_record()
|
||||
self._record_constraint("equal_radius", (circle1.id, circle2.id))
|
||||
return True
|
||||
|
||||
def constrain_fixed(self, entity: SketchEntity) -> bool:
|
||||
@@ -332,7 +460,7 @@ class OCCSketch(SketchInterface):
|
||||
if ent is None or ent.handle is None:
|
||||
return False
|
||||
self._solver.dragged(ent.handle, self._wp)
|
||||
self._add_constraint_record()
|
||||
self._record_constraint("fixed", (entity.id,))
|
||||
return True
|
||||
|
||||
def constrain_symmetric(
|
||||
@@ -347,9 +475,49 @@ class OCCSketch(SketchInterface):
|
||||
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._add_constraint_record()
|
||||
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:
|
||||
@@ -408,10 +576,20 @@ class OCCSketch(SketchInterface):
|
||||
# ─── Geometry extraction for operations ────────────────────────────────
|
||||
|
||||
def get_geometry(self) -> GeometryObject:
|
||||
"""Get the solved geometry for operations using CadQuery."""
|
||||
"""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
|
||||
|
||||
# Check for circles first
|
||||
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)
|
||||
@@ -494,6 +672,295 @@ class OCCSketch(SketchInterface):
|
||||
|
||||
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()
|
||||
@@ -515,10 +982,147 @@ class OCCSketch(SketchInterface):
|
||||
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."""
|
||||
"""Delete an entity and its constraints (no solver rebuild)."""
|
||||
if entity.id not in self._entities:
|
||||
return False
|
||||
|
||||
|
||||
Reference in New Issue
Block a user