- 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
+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)