- Tons of addtions
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user