diff --git a/src/Mod/Fem/CMakeLists.txt b/src/Mod/Fem/CMakeLists.txt index d0847093b6..b2a11337a3 100755 --- a/src/Mod/Fem/CMakeLists.txt +++ b/src/Mod/Fem/CMakeLists.txt @@ -606,6 +606,7 @@ SET(FemGuiTests_SRCS SET(FemGuiUtils_SRCS femguiutils/__init__.py + femguiutils/disambiguate_solid_selection.py femguiutils/migrate_gui.py femguiutils/selection_widgets.py ) diff --git a/src/Mod/Fem/femguiutils/disambiguate_solid_selection.py b/src/Mod/Fem/femguiutils/disambiguate_solid_selection.py new file mode 100644 index 0000000000..84b2731f0e --- /dev/null +++ b/src/Mod/Fem/femguiutils/disambiguate_solid_selection.py @@ -0,0 +1,191 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later +# *************************************************************************** +# * Copyright (c) 2024 Colin Rawlings * +# * * +# * This file is part of FreeCAD. * +# * * +# * FreeCAD is free software: you can redistribute it and/or modify it * +# * under the terms of the GNU Lesser General Public License as * +# * published by the Free Software Foundation, either version 2.1 of the * +# * License, or (at your option) any later version. * +# * * +# * FreeCAD is distributed in the hope that it will be useful, but * +# * WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * +# * Lesser General Public License for more details. * +# * * +# * You should have received a copy of the GNU Lesser General Public * +# * License along with FreeCAD. If not, see * +# * . * +# * * +# *************************************************************************** + +__title__ = "Disambiguate Solid Selection" +__author__ = "Colin Rawlings" +__url__ = "https://www.freecad.org" + +from typing import TYPE_CHECKING, Dict, List, Optional, Tuple + +import copy + +from PySide import QtGui +from PySide import QtCore + +highlight_color_t = List[Tuple[float, float, float, float]] # rgba value per face +highlight_map_t = Dict[Optional[str], highlight_color_t] + +if TYPE_CHECKING: + from Part import Face, PartFeature + + +def calculate_unhighlighted_color(parent_part: "PartFeature") -> highlight_color_t: + """ + Cheap and cheerful function to dim object so that subsequent highlighting is apparent + """ + + initial_color = parent_part.ViewObject.DiffuseColor + num_faces = len(parent_part.Shape.Faces) + + if len(initial_color) == num_faces: + unhighlighted_color: highlight_color_t = initial_color + else: + unhighlighted_color: highlight_color_t = [initial_color[0]] * num_faces + + for face_idx in range(num_faces): + rgba = unhighlighted_color[face_idx] + unhighlighted_color[face_idx] = ( + rgba[0] * 0.5, + rgba[1] * 0.5, + rgba[2] * 0.5, + rgba[3] * 0.5, + ) + + return unhighlighted_color + + +class HighlightContext: + def __init__(self, parent_part: "PartFeature") -> None: + self._parent_part = parent_part + self._initial_color = parent_part.ViewObject.DiffuseColor + self._unhighlighted_color = calculate_unhighlighted_color(parent_part) + + def _set_color(self, color: highlight_color_t) -> None: + self._parent_part.ViewObject.DiffuseColor = color + self._parent_part.ViewObject.update() + + def __enter__(self) -> None: + self._set_color(self._unhighlighted_color) + + def __exit__(self, *arg, **kwargs) -> bool: + self._set_color(self._initial_color) + return False + + +def parent_face_index(parent_part: "PartFeature", face: "Face") -> int: + """ + Return the index for the provided face in the parent_shape's + list of faces. + """ + + return [face.isSame(f) for f in parent_part.Shape.Faces].index(True) + + +def solid_parent_faces_indices(parent_part: "PartFeature", solid_index: int) -> List[int]: + """ + Return the parent's face indices for the faces bounding + the given solid + """ + + solid = parent_part.Shape.Solids[solid_index] + return [parent_face_index(parent_part, solid_face) for solid_face in solid.Faces] + + +def build_highlight_map(parent_part: "PartFeature", solid_indices: List[int]) -> highlight_map_t: + """ + Build a mapping from solid name to face colors for highlighting a given selected solid. + Indexing with None returns the unhighlighted coloring (see + calculate_unhighlighted_color). + """ + + unhighlighted_color = calculate_unhighlighted_color(parent_part) + + # build the highlighting map + highlight_colors_for_solid: highlight_map_t = {} + highlight_colors_for_solid[None] = unhighlighted_color + for idx in solid_indices: + solid_faces = solid_parent_faces_indices(parent_part, idx) + + highlighted_colors = copy.deepcopy(unhighlighted_color) + + for index in solid_faces: + rgba = unhighlighted_color[index] + highlighted_colors[index] = (rgba[0], rgba[1], 0.99, rgba[3]) + + highlight_colors_for_solid[solid_name_from_index(idx)] = highlighted_colors + + return highlight_colors_for_solid + + +def solid_name_from_index(solid_index: int) -> str: + return f"Solid{solid_index+1}" + + +def disambiguate_solid_selection( + parent_part: "PartFeature", solid_indices: List[int] +) -> Optional[str]: + """ + @param solid_indices the indices which may be used to get a reference to the selected solid as + parent_part.Solids[index]. + + @return The name of the selected solid or None if the user cancels the selection + """ + + # Build menu + menu_of_solids = QtGui.QMenu() + label = menu_of_solids.addAction( + "Selected entity belongs to multiple solids, please pick one ..." + ) + label.setDisabled(True) + + for index in solid_indices: + menu_of_solids.addAction(solid_name_from_index(index)) + + # Configure highlighting callbacks + last_action: list[QtGui.QAction] = [] + highlight_colors_for_solid = build_highlight_map(parent_part, solid_indices) + + def set_part_colors(solid_name: Optional[str]) -> None: + parent_part.ViewObject.DiffuseColor = highlight_colors_for_solid[solid_name] + parent_part.ViewObject.update() + + def display_hover(action: QtGui.QAction) -> None: + if last_action and last_action[0].text() == action.text(): + return + + set_part_colors(action.text()) + + last_action.clear() + last_action.append(action) + + def on_enter(arg__1: QtCore.QEvent) -> None: + if not last_action: + return + + set_part_colors(last_action[0].text()) + + def on_leave(arg__1: QtCore.QEvent) -> None: + set_part_colors(None) + + menu_of_solids.hovered.connect(display_hover) # type: ignore + menu_of_solids.enterEvent = on_enter + menu_of_solids.leaveEvent = on_leave + + # Have user select desired solid + with HighlightContext(parent_part): + selected_action = menu_of_solids.exec_(QtGui.QCursor.pos()) + + # Process selection + if selected_action is None: + return None + + return selected_action.text() diff --git a/src/Mod/Fem/femguiutils/selection_widgets.py b/src/Mod/Fem/femguiutils/selection_widgets.py index 258431cac8..e8349f1374 100644 --- a/src/Mod/Fem/femguiutils/selection_widgets.py +++ b/src/Mod/Fem/femguiutils/selection_widgets.py @@ -30,6 +30,7 @@ __url__ = "https://www.freecad.org" # \ingroup FEM # \brief FreeCAD FEM FemSelectWidget +from typing import List, TYPE_CHECKING from PySide import QtGui from PySide import QtCore @@ -38,6 +39,36 @@ import FreeCADGui import FreeCADGui as Gui from femtools import geomtools +from femguiutils.disambiguate_solid_selection import disambiguate_solid_selection + +if TYPE_CHECKING: + from Part import Face, Edge, PartFeature + + +def solids_with_edge(parent_part: "PartFeature", edge: "Edge") -> List[int]: + """ + Return the indices in the parent's list of solids that are partially bounded by edge. + """ + + solids_with_edge: List[int] = [] + for idx, solid in enumerate(parent_part.Shape.Solids): + if any([edge.isSame(e) for e in solid.Edges]): + solids_with_edge.append(idx) + + return solids_with_edge + + +def solids_with_face(parent_part: "PartFeature", face: "Face") -> List[int]: + """ + Return the indices in the parent's list of solids that are partially bounded by face. + """ + + solids_with_face: List[int] = [] + for idx, solid in enumerate(parent_part.Shape.Solids): + if any([face.isSame(f) for f in solid.Faces]): + solids_with_face.append(idx) + + return solids_with_face class _Selector(QtGui.QWidget): @@ -468,36 +499,23 @@ class GeometryElementsSelection(QtGui.QWidget): # adapt selection variable to hold the Solid solid_to_add = None if ele_ShapeType == "Edge": - found_eltedge_in_other_solid = False - for i, s in enumerate(sobj.Shape.Solids): - for e in s.Edges: - if elt.isSame(e): - if found_eltedge_in_other_solid is False: - solid_to_add = str(i + 1) - else: - # could be more than two solids, think of polar pattern - FreeCAD.Console.PrintMessage( - " Edge belongs to at least two solids: " - " Solid{}, Solid{}\n".format(solid_to_add, str(i + 1)) - ) - solid_to_add = None - found_eltedge_in_other_solid = True + solid_indices = solids_with_edge(sobj, elt) elif ele_ShapeType == "Face": - found_eltface_in_other_solid = False - for i, s in enumerate(sobj.Shape.Solids): - for e in s.Faces: - if elt.isSame(e): - if not found_eltface_in_other_solid: - solid_to_add = str(i + 1) - else: - # AFAIK (bernd) a face can only belong to two solids - FreeCAD.Console.PrintMessage( - " Face belongs to two solids: Solid{}, Solid{}\n".format( - solid_to_add, str(i + 1) - ) - ) - solid_to_add = None - found_eltface_in_other_solid = True + solid_indices = solids_with_face(sobj, elt) + else: + raise ValueError(f"Unexpected shape type: {ele_ShapeType}") + + if not solid_indices: + raise ValueError( + f"Selected {ele_ShapeType} does not appear to belong to any of the part's solids" + ) + elif len(solid_indices) == 1: + solid_to_add = str(solid_indices[0] + 1) + else: + selected_solid = disambiguate_solid_selection(sobj, solid_indices) + if selected_solid is not None: + solid_to_add = selected_solid[len("Solid") :] + if solid_to_add: selection = (sobj, "Solid" + solid_to_add) ele_ShapeType = "Solid"