- Basic operations

This commit is contained in:
bklronin
2026-06-29 23:30:02 +02:00
parent f6422e0847
commit 9938f4ddd4
6 changed files with 2307 additions and 243 deletions
+189 -7
View File
@@ -34,6 +34,12 @@ class OCCSketchEntity(SketchEntity):
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):
@@ -60,6 +66,13 @@ class OCCSketch(SketchInterface):
# 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()
# Track first point as dragged/fixed for solver stability
self._first_point_id: Optional[int] = None
@@ -161,12 +174,21 @@ class OCCSketch(SketchInterface):
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)."""
"""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:
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)
@@ -268,6 +290,148 @@ class OCCSketch(SketchInterface):
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)
def add_rectangle(
self, corner1: Tuple[float, float], corner2: Tuple[float, float]
) -> List[OCCSketchEntity]:
@@ -666,11 +830,15 @@ class OCCSketch(SketchInterface):
if len(faces) == 1:
return self.build_face_geometry(faces[0])
# Fallback: wrap the first circle, or the polygon, as a single-loop face.
# 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)
if center_entity and center_entity.geometry:
if center_entity and center_entity.geometry and not center_entity.is_external:
cx, cy = center_entity.geometry
face_dict = {
"outer": {"type": "circle", "center": (cx, cy), "radius": radius},
@@ -710,11 +878,15 @@ class OCCSketch(SketchInterface):
return points
def get_polygon_points(self) -> List[Point2D]:
"""Get ordered polygon points from connected lines (uses solved positions)."""
"""Get ordered polygon points from connected lines (uses solved positions).
External (underlay) 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:
if entity.entity_type == "line" and entity.geometry and not entity.is_external:
p1, p2 = entity.geometry
if p1 not in adjacency:
adjacency[p1] = []
@@ -752,9 +924,18 @@ class OCCSketch(SketchInterface):
_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)."""
"""Current line segments as world-coordinate tuples (uses solved positions).
External (underlay) lines are excluded: they're reference geometry
projected from a 3D face, not user-drawn sketch profile, and must
not contribute to detect_faces / get_geometry / extrusion.
"""
segs: List[Tuple[Tuple[float, float], Tuple[float, float]]] = []
for line_id, (sid, eid2) in self._lines.items():
# Skip external/underlay lines — they live in the solver for
# constraint referencing, but are not part of the sketch profile.
if line_id in self._external_entity_ids:
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:
@@ -1058,6 +1239,7 @@ class OCCSketch(SketchInterface):
self._entity_counter = 0
self._constraint_count = 0
self._constraint_log.clear()
self._external_entity_ids.clear()
self._first_point_id = None
def _prune_log_for(self, removed_ids: set) -> None: