- 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
+20 -1
View File
@@ -180,12 +180,31 @@ class OCGeometryKernel(GeometryKernel):
from OCP.BRepAlgoAPI import BRepAlgoAPI_Fuse
from OCP.TopoDS import TopoDS_Shape
face = self._get_shape(sketch)
# Defensive: figure out the actual shape from whatever the caller
# hands us, and surface a clear error if we can't get one.
# - If it's an OCCGeometryObject wrapper, unwrap via _get_shape.
# - If it's already a TopoDS_Shape (raw face/wire/etc.), use it.
# - If it's a cadquery Workplane, unwrap that too.
# - If it's a cadquery Shape (cq.Shape), unwrap to TopoDS_Shape.
if isinstance(sketch, OCCGeometryObject):
face = self._get_shape(sketch)
elif isinstance(sketch, TopoDS_Shape):
face = sketch
else:
face = self._get_shape(sketch)
if face is None:
raise ValueError(
"Cannot extrude: sketch has no geometry. "
"Draw a closed profile before extruding."
)
# If the wrapper class itself leaked through somehow, surface a
# clear error instead of letting BRepPrimAPI_MakePrism raise an
# opaque TypeError.
if isinstance(face, OCCGeometryObject):
raise ValueError(
"Cannot extrude: sketch geometry is a wrapper, not a shape. "
"This is a bug — please report it."
)
# ``face`` may be a TopoDS_Face (new path) or a compound/wire from
# legacy cadquery objects. If it's not already a face, build one.
face = self._ensure_face(face)
+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:
+970 -190
View File
File diff suppressed because it is too large Load Diff
+196 -11
View File
@@ -41,7 +41,13 @@ class OCCRenderer(Renderer):
self._parent_widget: Any = None
self._last_mouse_x: int = 0
self._last_mouse_y: int = 0
self._nav_mode: Optional[str] = None # "rotate" | "pan" | "zoom" | None
self._pan_start_x: int = 0
self._pan_start_y: int = 0
self._nav_mode: Optional[str] = None # "rotate" | "pan" | None
# Persistent light-blue transparent overlay marking the selected face.
self._highlight_ais: Any = None
# Temporary transparent preview AIS for the live extrude/cut dialog.
self._preview_ais: Any = None
def initialize(self, parent_widget: Any) -> bool:
"""Initialise OCC viewer inside *parent_widget* (a QWidget)."""
@@ -156,6 +162,18 @@ class OCCRenderer(Renderer):
# Default display mode = shaded (AIS_Shaded = 1)
context.SetDisplayMode(1, True)
# Style the dynamic (hover) highlight as light-blue so the face
# pick preview matches the persistent selection overlay below.
try:
from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB
# Modify the existing dynamic-highlight drawer in place (per
# OCC docs this is safer than building a fresh Prs3d_Drawer).
hd = context.HighlightStyle()
hd.SetColor(Quantity_Color(0.45, 0.75, 1.0, Quantity_TOC_RGB))
hd.SetDisplayMode(1)
except Exception:
logger.debug("dynamic highlight style unavailable", exc_info=True)
# Attach OCC view to the Qt widget via the native window handle.
win = Aspect_NeutralWindow()
win.SetNativeHandle(hwnd)
@@ -372,10 +390,100 @@ class OCCRenderer(Renderer):
if obj is not None:
self.remove_object(obj)
def set_visibility(self, obj_id: str, visible: bool) -> bool:
"""Show or hide an object by ID, preserving its place in the scene.
Unlike ``remove_mesh``, this doesn't free the object — the user can
toggle it back on later. Returns True on success, False if the
object isn't found (e.g. it was already removed).
"""
obj = self._objects.get(obj_id)
if obj is None:
return False
self.set_object_visible(obj, visible)
return True
def set_object_transparency(self, obj_id: str, transparency: float) -> bool:
"""Set the transparency of an object by ID (0.0 opaque..1.0 invisible).
Used by the live extrude preview to dim the existing target body
so the user can see the previewed result through/over it. Returns
True on success, False if the object isn't found.
"""
obj = self._objects.get(obj_id)
if obj is None or obj.ais_shape is None:
return False
try:
obj.ais_shape.SetTransparency(transparency)
self._context.RecomputePrsOnly(obj.ais_shape, True)
except Exception:
logger.debug("set_object_transparency failed", exc_info=True)
return False
return True
# ─── Live preview (extrude/cut preview) ──────────────────────────────
_PREVIEW_ID = "__extrude_preview__"
def preview_shape(
self,
shape: Any,
color: Optional[Tuple[float, float, float]] = None,
transparency: float = 0.60,
) -> None:
"""Display a temporary transparent preview of *shape* (TopoDS_Shape).
The preview lives under a fixed id (``__extrude_preview__``) so a
subsequent call replaces the previous preview in place. Call
:meth:`clear_preview` to remove it. This is independent of the
tracked ``_objects`` dict the preview is NOT a body and won't
be returned by ``pick_planar_face``'s owner scan.
"""
if self._context is None:
return
# Clear any previous preview (uses the same id).
self.clear_preview()
from OCP.AIS import AIS_Shape
from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB
ais = AIS_Shape(shape)
try:
ais.SetMaterial(self._default_material())
except Exception:
logger.debug("preview material set failed", exc_info=True)
col = color or (0.45, 0.80, 0.95) # cyan-ish for preview
ais.SetColor(Quantity_Color(*col, Quantity_TOC_RGB))
ais.SetDisplayMode(1) # shaded
try:
ais.SetTransparency(transparency)
except Exception:
logger.debug("preview transparency set failed", exc_info=True)
# Draw face boundaries so the preview shape reads cleanly.
try:
drawer = ais.Attributes()
drawer.SetFaceBoundaryDraw(True)
except Exception:
pass
self._context.Display(ais, True)
self._preview_ais = ais
if self._view is not None:
self._view.Repaint()
def clear_preview(self) -> None:
"""Remove the live extrude preview shape."""
if self._context is None or getattr(self, "_preview_ais", None) is None:
return
try:
self._context.Remove(self._preview_ais, True)
finally:
self._preview_ais = None
def clear_scene(self) -> None:
"""Remove all objects from the scene."""
if self._context is None:
return
self.clear_preview()
self.clear_face_highlight()
# Remove every displayed AIS object. ``RemoveAll`` is the cleanest
# path; fall back to iterating the displayed list if unavailable.
try:
@@ -642,6 +750,18 @@ class OCCRenderer(Renderer):
except Exception:
return None
# Outward normal: the plane's geometric axis is independent of the
# face's orientation (TopAbs_FORWARD / TopAbs_REVERSED). For a face
# on a solid the TRUE outward normal is the axis when FORWARD and its
# negation when REVERSED. Without this correction a top face whose
# axis happens to point inward would return an inward normal, so a
# default (non-inverted) extrude would punch back into the body
# instead of building outward on top of it.
from OCP.TopAbs import TopAbs_REVERSED
n = pln.Axis().Direction()
if face.Orientation() == TopAbs_REVERSED:
n = n.Reversed()
# Plane origin: use the face's bounding-box centre projected onto the
# plane, so the UV frame is centred on the face (nicer for sketching).
from OCP.Bnd import Bnd_Box
@@ -652,7 +772,6 @@ class OCCRenderer(Renderer):
cx, cy, cz = (xmin + xmax) / 2.0, (ymin + ymax) / 2.0, (zmin + zmax) / 2.0
# Project the bbox centre onto the plane.
pln_origin = pln.Location() # gp_Pnt
n = pln.Axis().Direction()
nx, ny, nz = n.X(), n.Y(), n.Z()
# signed distance from bbox centre to plane
d = (cx - pln_origin.X()) * nx + (cy - pln_origin.Y()) * ny + (cz - pln_origin.Z()) * nz
@@ -681,19 +800,84 @@ class OCCRenderer(Renderer):
px = pln.XAxis().Direction()
x_dir = (px.X(), px.Y(), px.Z())
# Identify the displayed body that owns this face, so the host can
# auto-target it as the cut/union body when the user extrudes the
# sketch-on-face. ``DetectedInteractive`` returns the AIS_
# InteractiveObject that the picked sub-shape belongs to; we match
# it against the renderer's tracked objects by AIS identity.
owner_obj_id: Optional[str] = None
try:
owner_ais = self._context.DetectedInteractive()
except Exception:
owner_ais = None
if owner_ais is not None:
for oid, robj in self._objects.items():
if robj.ais_shape is owner_ais:
owner_obj_id = oid
break
return {
"origin": origin,
"normal": (nx, ny, nz),
"x_dir": x_dir,
"face": face,
"owner_obj_id": owner_obj_id,
}
def highlight_face(self, face: Any) -> None:
"""Overlay a persistent, mostly-transparent light-blue tint on *face*.
Used to mark the planar face the user has selected for sketch-on-
surface. The overlay is an independent ``AIS_Shape`` so it survives
until :meth:`clear_face_highlight` is called.
"""
if self._context is None:
return
self.clear_face_highlight()
from OCP.AIS import AIS_Shape
from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB
ais = AIS_Shape(face)
try:
ais.SetMaterial(self._default_material())
except Exception:
logger.debug("highlight material set failed", exc_info=True)
ais.SetColor(Quantity_Color(0.45, 0.75, 1.0, Quantity_TOC_RGB))
ais.SetDisplayMode(1) # shaded — tint the whole face, not just edges
try:
# Mostly transparent so the underlying face stays visible.
ais.SetTransparency(0.78)
except Exception:
logger.debug("highlight transparency set failed", exc_info=True)
try:
# Bias the overlay slightly toward the camera so it draws on top
# of the coincident face surface without z-fighting.
# mode 3 = Graphic3d_POM_Fill; negative units pull forward.
ais.SetPolygonOffsets(3, 1.0, -0.5)
except Exception:
logger.debug("highlight polygon offset failed", exc_info=True)
self._context.Display(ais, True)
self._highlight_ais = ais
if self._view is not None:
self._view.Update()
def clear_face_highlight(self) -> None:
"""Remove the persistent face-selection overlay, if any."""
if self._context is None or self._highlight_ais is None:
return
try:
self._context.Remove(self._highlight_ais, True)
except Exception:
logger.debug("clear_face_highlight remove failed", exc_info=True)
self._highlight_ais = None
# ─── Mouse / keyboard event forwarding ──────────────────────────────
#
# CAD-style navigation:
# • Left button drag → orbit (rotate around target)
# • Middle button drag → pan
# • Right button drag → dolly / zoom toward cursor
# • Right button → (reserved for future use)
# • Wheel → zoom toward cursor
# • Double-click left → fit all (handled by the widget)
@@ -717,12 +901,13 @@ class OCCRenderer(Renderer):
self._view.StartRotation(x, y, 0.4)
elif btn == Qt.MiddleButton:
self._nav_mode = "pan"
# Pan uses deltas from this starting point.
# Record the gesture start; OCC's Pan(..., Start=False) expects
# deltas CUMULATIVE from this point, not per-frame deltas.
self._pan_start_x = x
self._pan_start_y = y
self._view.Pan(0, 0, 1.0, True)
elif btn == Qt.RightButton:
self._nav_mode = "zoom"
self._view.StartZoomAtPoint(x, y)
else:
# Right button (and any other) is reserved — no gesture yet.
self._nav_mode = None
self._last_mouse_x = x
@@ -739,12 +924,12 @@ class OCCRenderer(Renderer):
if self._nav_mode == "rotate" and (buttons & Qt.LeftButton):
self._view.Rotation(x, y)
elif self._nav_mode == "pan" and (buttons & Qt.MiddleButton):
dx = x - self._last_mouse_x
dy = y - self._last_mouse_y
# Cumulative delta from the gesture start — OCC interprets
# Pan(..., Start=False) as an absolute offset from the start point.
dx = x - self._pan_start_x
dy = y - self._pan_start_y
# dy negated because Qt y grows downward while OCC y grows upward.
self._view.Pan(dx, -dy, 1.0, False)
elif self._nav_mode == "zoom" and (buttons & Qt.RightButton):
self._view.ZoomAtPoint(self._last_mouse_x, self._last_mouse_y, x, y)
else:
# Idle: dynamic highlighting under the cursor.
self._context.MoveTo(x, y, self._view, True)