- Basic operations
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user