- Tons of addtions

This commit is contained in:
bklronin
2026-06-28 22:51:52 +02:00
parent f8f16ea800
commit f6422e0847
7 changed files with 1010 additions and 149 deletions
+349
View File
@@ -63,6 +63,7 @@ from PySide6.QtGui import (
QFont,
QFontMetrics,
QCursor,
QPolygonF,
)
from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject
@@ -72,6 +73,66 @@ from fluency.rendering.occ_renderer import OCCRenderer
from fluency.models.data_model import Project, Component, Sketch, Body
def _project_face_to_uv(
face: Any,
workplane: Tuple[Tuple[float, float, float], Tuple[float, float, float], Tuple[float, float, float]],
) -> List[List[Tuple[float, float]]]:
"""Project a planar ``TopoDS_Face``'s boundary edges into the UV frame.
*workplane* is (origin, normal, x_dir). Returns a list of polylines,
each a list of (u, v) points, one per boundary edge (lines → endpoints,
curves → sampled). Used by the 2D sketch widget to draw the face as an
underlay when sketching on a surface.
"""
import numpy as np
from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_EDGE, TopAbs_WIRE
from OCP.TopoDS import TopoDS
from OCP.BRepAdaptor import BRepAdaptor_Curve
from OCP.GeomAbs import GeomAbs_Line
from OCP.gp import gp_Pnt
origin = np.asarray(workplane[0], dtype=float) # (x,y,z)
normal = np.asarray(workplane[1], dtype=float) # plane normal
x_dir = np.asarray(workplane[2], dtype=float) # in-plane x axis
x_dir = x_dir / np.linalg.norm(x_dir)
normal = normal / np.linalg.norm(normal)
y_dir = np.cross(normal, x_dir)
y_dir = y_dir / np.linalg.norm(y_dir)
def world_to_uv(p: gp_Pnt) -> Tuple[float, float]:
v = np.array([p.X() - origin[0], p.Y() - origin[1], p.Z() - origin[2]])
return (float(np.dot(v, x_dir)), float(np.dot(v, y_dir)))
polylines: List[List[Tuple[float, float]]] = []
# Iterate wires of the face (outer + inner = holes), then edges.
wire_expl = TopExp_Explorer(face, TopAbs_WIRE)
while wire_expl.More():
wire = wire_expl.Current()
edge_expl = TopExp_Explorer(wire, TopAbs_EDGE)
while edge_expl.More():
edge = TopoDS.Edge_s(edge_expl.Current())
try:
crv = BRepAdaptor_Curve(edge)
f = crv.FirstParameter()
l = crv.LastParameter()
is_line = crv.GetType() == GeomAbs_Line
if is_line:
pts = [crv.Value(f), crv.Value(l)]
else:
# Sample 32 segments across the parameter range.
pts = [crv.Value(f + (l - f) * i / 32.0) for i in range(33)]
poly = [world_to_uv(p) for p in pts]
polylines.append(poly)
except Exception:
pass
edge_expl.Next()
wire_expl.Next()
return polylines
class ExtrudeDialog(QDialog):
"""Dialog for extrude options."""
@@ -196,6 +257,13 @@ class Sketch2DWidget(QWidget):
self._selected_face: Any = None
self._selected_entities: List[OCCSketchEntity] = []
# Source face for sketch-on-surface: the planar face the user picked
# in the 3D viewer, plus its workplane (origin/normal/x_dir). Phase 3
# projects this face's edges into UV and draws them as an underlay.
self._source_face: Any = None
self._source_workplane: Optional[Tuple[Tuple[float, float, float], ...]] = None
self._source_underlay_uv: List[Any] = [] # cached UV polylines for paintEvent
self._snap_mode: Dict[str, bool] = {
"point": True,
"mpoint": False,
@@ -237,6 +305,69 @@ class Sketch2DWidget(QWidget):
self._rebuild_from_sketch()
self._draw_buffer = []
self._clear_face_state()
# If the new sketch carries a workplane, refresh the source underlay.
self._refresh_source_underlay()
self.update()
def set_source_face(
self,
face: Any,
origin: Tuple[float, float, float],
normal: Tuple[float, float, float],
x_dir: Tuple[float, float, float],
) -> None:
"""Store the picked 3D face and reorient the 2D view to its plane.
Called by MainWindow after a face pick. Projects the face's boundary
edges into the sketch's UV frame and caches them for the paintEvent
underlay. Also re-centres/scales the 2D view to look down the plane.
"""
self._source_face = face
self._source_workplane = (tuple(origin), tuple(normal), tuple(x_dir))
# Ensure the OCCSketch shares the same workplane so UV↔world agrees.
if self._sketch is not None:
self._sketch.set_workplane(origin, normal, x_dir)
self._refresh_source_underlay()
self._orient_view_to_plane()
self.update()
def _refresh_source_underlay(self) -> None:
"""Project the source face's boundary edges into UV for the underlay."""
self._source_underlay_uv = []
if self._source_face is None or self._source_workplane is None:
return
if self._sketch is None:
return
try:
self._source_underlay_uv = _project_face_to_uv(
self._source_face, self._source_workplane
)
except Exception:
logger.debug("source underlay projection failed", exc_info=True)
def _orient_view_to_plane(self) -> None:
"""Centre & scale the 2D view to fit the source face's UV bounds."""
if not self._source_underlay_uv:
return
# Collect all UV points across all cached polylines.
all_pts = [pt for poly in self._source_underlay_uv for pt in poly]
if not all_pts:
return
us = [p[0] for p in all_pts]
vs = [p[1] for p in all_pts]
umin, umax = min(us), max(us)
vmin, vmax = min(vs), max(vs)
cu, cv = (umin + umax) / 2.0, (vmin + vmax) / 2.0
du, dv = max(umax - umin, 1e-6), max(vmax - vmin, 1e-6)
# Zoom so the face fits with ~20% margin; offset so the face centre
# maps to the widget centre (world (cu,cv) → screen centre).
w, h = max(self.width(), 100), max(self.height(), 100)
self._zoom = min(w / (du * 1.2), h / (dv * 1.2))
# _world_to_screen: screen = world*zoom + centre + offset.
# We want world (cu,cv) → screen (w/2, h/2). Solve for offset.
# screen_x = cu*zoom + w/2 + offset_x → offset_x = -cu*zoom
# screen_y = h/2 - cv*zoom + offset_y → offset_y = cv*zoom
self._offset = QPoint(int(-cu * self._zoom), int(cv * self._zoom))
self.update()
def _clear_face_state(self):
@@ -1536,6 +1667,36 @@ class Sketch2DWidget(QWidget):
painter.drawLine(origin.x() - 20, origin.y(), origin.x() + 20, origin.y())
painter.drawLine(origin.x(), origin.y() - 20, origin.x(), origin.y() + 20)
# ── Source-face underlay (sketch-on-surface) ──
# Draw the picked face's boundary edges in UV as a dashed guide so the
# user sees the face they're drawing on, aligned to the 2D frame.
if self._source_underlay_uv:
painter.setPen(QPen(QColor("#fab387"), 1, Qt.DashLine))
for poly in self._source_underlay_uv:
if len(poly) < 2:
continue
sp0 = self._world_to_screen(
QPoint(int(round(poly[0][0])), int(round(poly[0][1])))
)
prev = sp0
for (u, v) in poly[1:]:
sp = self._world_to_screen(
QPoint(int(round(u)), int(round(v)))
)
painter.drawLine(prev, sp)
prev = sp
# Close the polyline back to its start for a clean loop.
painter.drawLine(prev, sp0)
# Subtle fill hint over the first (outer) loop for readability.
if self._source_underlay_uv[0] and len(self._source_underlay_uv[0]) >= 3:
fill_poly = QPolygonF([
self._world_to_screen(QPoint(int(round(u)), int(round(v))))
for (u, v) in self._source_underlay_uv[0]
])
painter.setBrush(QBrush(QColor(250, 179, 135, 28)))
painter.setPen(Qt.NoPen)
painter.drawPolygon(fill_poly)
# ── Points ──
for entity in self._points:
if entity.geometry:
@@ -1721,12 +1882,20 @@ class Sketch2DWidget(QWidget):
class Viewer3DWidget(QWidget):
"""3D viewer widget using OCC's native AIS display."""
# Emitted when the user picks a planar face to sketch on.
# Payload: (origin, normal, x_dir, face_shape) — all tuples are (x,y,z).
facePicked = Signal(tuple, tuple, tuple, object)
# Emitted when face-pick mode is cancelled (Esc) so the host can uncheck.
pickFaceCancelled = Signal()
def __init__(self, parent=None):
super().__init__(parent)
# For OCC's direct OpenGL rendering we need Qt to not paint over it.
self.setAttribute(Qt.WA_PaintOnScreen)
self.setAttribute(Qt.WA_OpaquePaintEvent)
self.setAutoFillBackground(False)
# Accept keyboard focus so navigation shortcuts (F, R, 1-7, P, O) work.
self.setFocusPolicy(Qt.StrongFocus)
# Try OCC renderer first; fall back to pygfx if unavailable.
self._renderer: Any = None
self._initialized = False
@@ -1734,6 +1903,9 @@ class Viewer3DWidget(QWidget):
self._selected_normal: Optional[Tuple[float, float, float]] = None
self._centroid: Optional[Tuple[float, float, float]] = None
self._pending_meshes: List[Tuple] = []
# When True, a left-click picks a planar face (for sketch-on-surface)
# instead of orbiting the camera. Set via set_pick_face_mode().
self._pick_face_mode: bool = False
def _init_renderer(self) -> None:
"""Create the best available renderer."""
@@ -1863,11 +2035,22 @@ class Viewer3DWidget(QWidget):
def mousePressEvent(self, event):
self._ensure_initialized()
# Face-pick mode: a left-click selects a planar face to sketch on.
if self._pick_face_mode and event.button() == Qt.LeftButton:
self._handle_face_pick(event)
return
self._renderer.handle_mouse_press(event)
super().mousePressEvent(event)
def mouseMoveEvent(self, event):
self._ensure_initialized()
# In face-pick mode, keep dynamic highlighting but don't orbit.
if self._pick_face_mode:
if hasattr(self._renderer, "handle_mouse_move"):
# Still forward for hover-detect (no button held → detect only).
self._renderer.handle_mouse_move(event)
super().mouseMoveEvent(event)
return
self._renderer.handle_mouse_move(event)
super().mouseMoveEvent(event)
@@ -1899,7 +2082,52 @@ class Viewer3DWidget(QWidget):
self._renderer.set_camera_position(position, target)
self._renderer.render()
# ─── Face-pick mode (sketch-on-surface) ────────────────────────────────
def set_pick_face_mode(self, enabled: bool) -> None:
"""Toggle face-pick mode.
When enabled, the cursor selects planar faces for sketch placement
instead of orbiting the camera. Middle/right buttons still pan/zoom.
"""
self._pick_face_mode = bool(enabled)
if enabled:
self.setCursor(Qt.CrossCursor)
else:
self.unsetCursor()
def is_pick_face_mode(self) -> bool:
return self._pick_face_mode
def _handle_face_pick(self, event) -> None:
"""Detect a planar face under the click and emit facePicked."""
self._ensure_initialized()
picker = getattr(self._renderer, "pick_planar_face", None)
if picker is None:
logger.warning("Renderer has no pick_planar_face support")
return
# Qt6: prefer position().toPoint() over deprecated pos().
pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
info = picker(pos.x(), pos.y())
if info is None:
logger.info("Face pick: no planar face under cursor")
return
self.facePicked.emit(
tuple(info["origin"]),
tuple(info["normal"]),
tuple(info["x_dir"]),
info["face"],
)
def set_view(self, view: str):
# Prefer the renderer's native orientation snap (preserves target,
# refits the scene). Falls back to absolute eye positions for
# renderers that don't implement set_view_orientation.
self._ensure_initialized()
if hasattr(self._renderer, "set_view_orientation"):
self._renderer.set_view_orientation(view)
self._renderer.render()
return
positions = {
"iso": ((100, 100, 100), (0, 0, 0)),
"top": ((0, 0, 200), (0, 0, 0)),
@@ -1913,6 +2141,58 @@ class Viewer3DWidget(QWidget):
pos, target = positions[view]
self.set_camera_position(pos, target)
def mouseDoubleClickEvent(self, event):
# Double-click → fit all (common CAD convention).
self._ensure_initialized()
if event.button() == Qt.LeftButton:
self.fit_camera()
super().mouseDoubleClickEvent(event)
def keyPressEvent(self, event):
# Esc cancels face-pick mode.
if self._pick_face_mode and event.key() == Qt.Key_Escape:
self.set_pick_face_mode(False)
self.pickFaceCancelled.emit()
return
# Navigation shortcuts (lowercase = view presets, F = fit,
# P/O = perspective/orthographic, R = reset).
self._ensure_initialized()
key = event.text().lower()
mapping = {
"f": "fit",
"r": "reset",
"1": "front",
"2": "back",
"3": "top",
"4": "bottom",
"5": "left",
"6": "right",
"7": "iso",
}
action = mapping.get(key)
if action == "fit":
self.fit_camera()
return
if action == "reset":
if hasattr(self._renderer, "reset_camera"):
self._renderer.reset_camera()
self._renderer.render()
else:
self.set_view("iso")
return
if action in ("front", "back", "top", "bottom", "left", "right", "iso"):
self.set_view(action)
return
if key == "p" and hasattr(self._renderer, "set_camera_perspective"):
self._renderer.set_camera_perspective()
self._renderer.render()
return
if key == "o" and hasattr(self._renderer, "set_camera_orthographic"):
self._renderer.set_camera_orthographic()
self._renderer.render()
return
super().keyPressEvent(event)
class MainWindow(QMainWindow):
"""Main application window."""
@@ -2198,6 +2478,12 @@ class MainWindow(QMainWindow):
sk_tools_grid.addWidget(self._btn_edit_sketch, 0, 1)
self._btn_del_sketch = QPushButton("Del")
sk_tools_grid.addWidget(self._btn_del_sketch, 0, 2)
self._btn_face_sketch = QPushButton("Face")
self._btn_face_sketch.setToolTip(
"Pick a planar face in the 3D viewer to sketch on"
)
self._btn_face_sketch.setCheckable(True)
sk_tools_grid.addWidget(self._btn_face_sketch, 1, 0, 1, 3)
sk_list_layout.addWidget(sk_tools)
grid.addWidget(sk_list_group, 6, 0, 3, 1)
@@ -2425,6 +2711,11 @@ class MainWindow(QMainWindow):
self._btn_add_sketch.clicked.connect(self._add_sketch_to_component)
self._btn_edit_sketch.clicked.connect(self._edit_sketch)
self._btn_del_sketch.clicked.connect(self._delete_sketch)
self._btn_face_sketch.toggled.connect(self._on_face_sketch_toggled)
self._viewer_3d.facePicked.connect(self._on_face_picked)
self._viewer_3d.pickFaceCancelled.connect(
lambda: self._btn_face_sketch.setChecked(False)
)
self._btn_new_compo.clicked.connect(self._new_component)
self._btn_del_compo.clicked.connect(self._delete_component)
@@ -2643,6 +2934,59 @@ class MainWindow(QMainWindow):
def _pattern_array(self):
logger.info("Pattern array not yet implemented")
# ─── Sketch-on-surface (face pick) ────────────────────────────────────
def _on_face_sketch_toggled(self, checked: bool) -> None:
"""Toggle the 3D viewer's face-pick mode."""
self._viewer_3d.set_pick_face_mode(checked)
if checked:
# Make sure the 3D viewer has focus so it receives the click.
self._viewer_3d.setFocus()
self._viewer_3d.activateWindow()
self.statusBar().showMessage(
"Pick a planar face in the 3D viewer to sketch on (Esc to cancel)",
8000,
)
def _on_face_picked(self, origin, normal, x_dir, face_shape) -> None:
"""Create a new sketch on the picked planar face and switch to 2D."""
logger.info(
f"Face picked: origin={origin}, normal={normal}, x_dir={x_dir}"
)
# Leave pick mode (the button stays toggled until we uncheck it).
self._btn_face_sketch.setChecked(False)
self._viewer_3d.set_pick_face_mode(False)
if not self._current_component:
self._current_component = self._project.add_component()
sketch = self._current_component.add_sketch()
sketch.name = f"Sketch on face {len(self._current_component.sketches)}"
# Place the sketch on the picked plane (sets fields + syncs occ_sketch).
sketch.set_workplane(origin, normal, x_dir)
# Keep the face reference for the projection underlay (Phase 3).
sketch._source_face = face_shape
# Hand the sketch to the 2D widget and focus the sketch panel.
if sketch.occ_sketch is None:
sketch.occ_sketch = self._sketch_widget.create_sketch()
sketch.apply_workplane()
self._sketch_widget.set_sketch(sketch.occ_sketch)
self._sketch_widget.set_source_face(face_shape, origin, normal, x_dir)
self._current_sketch = sketch
self._sketch_widget.set_mode("line")
self._btn_line.setChecked(True)
self._refresh_lists()
# Switch focus to the sketch panel so the user can draw immediately.
self._set_panel_focus("sketch")
self.statusBar().showMessage(
f"Sketch placed on face — drawing in 2D on that plane", 6000
)
def _pattern_array_placeholder(self):
pass
def _add_sketch_to_component(self):
logger.info("=== ADD SKETCH TO COMPONENT ===")
if not self._current_component:
@@ -2660,6 +3004,10 @@ class MainWindow(QMainWindow):
logger.info("Creating new sketch in widget")
sketch.occ_sketch = self._sketch_widget.create_sketch()
# Sync the sketch's workplane (origin/normal/x_dir) into the OCC sketch
# so geometry is built on the right plane.
sketch.apply_workplane()
self._current_sketch = sketch
self._refresh_lists()
self._sketch_widget.set_mode(None)
@@ -2676,6 +3024,7 @@ class MainWindow(QMainWindow):
if sketch.name == name:
self._current_sketch = sketch
if sketch.occ_sketch:
sketch.apply_workplane()
self._sketch_widget.set_sketch(sketch.occ_sketch)
self._sketch_widget.set_mode("line")
self._btn_line.setChecked(True)