diff --git a/doc/changelog.d/1220.miscellaneous.md b/doc/changelog.d/1220.miscellaneous.md new file mode 100644 index 0000000000..325fbdb581 --- /dev/null +++ b/doc/changelog.d/1220.miscellaneous.md @@ -0,0 +1 @@ +Feat: add quadratic face rendering diff --git a/src/ansys/meshing/prime/core/mesh.py b/src/ansys/meshing/prime/core/mesh.py index 986bd356da..c8f4ff0a54 100644 --- a/src/ansys/meshing/prime/core/mesh.py +++ b/src/ansys/meshing/prime/core/mesh.py @@ -116,6 +116,79 @@ def __init__( self.has_mesh = has_mesh +class DisplayPolyData: + """Wrapper for PyVista PolyData with support for improved surface rendering. + + This class wraps a PyVista PolyData object and provides methods for accessing + the original (non-triangulated) mesh and its edges for improved rendering + of non-planar polygon faces and quadratic elements. + + Using composition rather than inheritance avoids potential conflicts with + PyVista's internal state management. + + Parameters + ---------- + mesh : pv.PolyData + The triangulated mesh for rendering. + original_polydata : pv.PolyData, optional + The original (non-triangulated) mesh for edge extraction. + """ + + def __init__(self, mesh: pv.PolyData, original_polydata: pv.PolyData = None): + """Initialize the display polydata.""" + self._mesh = mesh + self._original_polydata = original_polydata + self._cached_original_edges = None + + @property + def mesh(self) -> pv.PolyData: + """Get the triangulated mesh for rendering. + + Returns + ------- + pv.PolyData + The triangulated mesh. + """ + return self._mesh + + def get_original_polydata(self) -> pv.PolyData: + """Get the original (non-triangulated) polydata. + + Returns + ------- + pv.PolyData + The original polydata, or the triangulated mesh if not set. + """ + return self._original_polydata if self._original_polydata is not None else self._mesh + + def has_original_edges(self) -> bool: + """Check if original edges can be extracted. + + Returns + ------- + bool + True if original polydata is available for edge extraction. + """ + return self._original_polydata is not None + + def get_original_edges(self) -> pv.PolyData: + """Get the original polygon edges, lazily extracted and cached. + + This extracts edges from the original (non-triangulated) mesh, + which preserves the true element boundaries for display. + + Returns + ------- + pv.PolyData + The edges of the original mesh, or None if not available. + """ + if self._original_polydata is None: + return None + if self._cached_original_edges is None: + self._cached_original_edges = self._original_polydata.extract_all_edges() + return self._cached_original_edges + + def compute_distance(point1, point2) -> float: """Compute the distance between two points. @@ -322,11 +395,21 @@ def get_face_polydata( part = self._model.get_part(part_id) vertices, faces = self._get_vertices_and_surf_faces(face_facet_res, index) - surf = pv.PolyData(vertices, faces) + + # Create original polydata and store it for edge extraction + original_polydata = pv.PolyData(vertices, faces) + + # Create triangulated mesh for improved rendering of non-planar polygons + triangulated = original_polydata.triangulate() + + # Wrap in DisplayPolyData for clean access to original edges + display_mesh = DisplayPolyData(mesh=triangulated, original_polydata=original_polydata) + + # Set colors on the triangulated mesh fcolor = np.array(self.get_face_color(part, ColorByType.ZONE)) - colors = np.tile(fcolor, (surf.n_faces_strict, 1)) - surf["colors"] = colors - surf._disp_mesh = self + colors = np.tile(fcolor, (display_mesh.mesh.n_faces_strict, 1)) + display_mesh.mesh["colors"] = colors + display_mesh.mesh._disp_mesh = self has_mesh = True if face_facet_res.topo_face_ids[index] > 0: display_mesh_type = DisplayMeshType.TOPOFACE @@ -336,8 +419,8 @@ def get_face_polydata( display_mesh_type = DisplayMeshType.FACEZONELET id = face_facet_res.face_zonelet_ids[index] - if surf.n_points > 0: - return MeshObjectPlot(part, surf), DisplayMeshInfo( + if display_mesh.mesh.n_points > 0: + return MeshObjectPlot(part, display_mesh), DisplayMeshInfo( id=id, part_id=part_id, zone_id=face_facet_res.face_zone_ids[index], diff --git a/src/ansys/meshing/prime/graphics/plotter.py b/src/ansys/meshing/prime/graphics/plotter.py index 49fc689c57..09e62077d1 100644 --- a/src/ansys/meshing/prime/graphics/plotter.py +++ b/src/ansys/meshing/prime/graphics/plotter.py @@ -52,6 +52,15 @@ ] ) +# Polygon offset parameters for resolving z-fighting between faces and edge lines. +# These values control how much polygons are pushed back in the depth buffer: +# - FACTOR: Scales the maximum depth slope of the polygon (handles angled surfaces) +# - UNITS: Adds a constant depth offset (handles co-planar geometry) +# Values of 1.0 provide a good balance for most meshes without causing visual artifacts. +# Increase if z-fighting persists; decrease if faces appear to "pop" behind edges. +POLYGON_OFFSET_FACTOR = 1.0 +POLYGON_OFFSET_UNITS = 1.0 + class ColorByType(enum.IntEnum): """Contains the zone types to display.""" @@ -73,15 +82,28 @@ class PrimePlotter(Plotter): Whether to use the Trame visualizer. allow_picking : Optional[bool], default: True. Whether to allow picking. + improved_surface_rendering : Optional[bool], default: True. + Whether to use improved rendering for non-planar polygon faces. + When True, surfaces are subdivided for accurate display of curved or + non-planar faces while preserving original mesh edges. This is + particularly useful for visualizing quadratic elements where mid-side + nodes create curved edges that would otherwise appear faceted. + When False, the original polygon mesh is rendered directly. """ def __init__( - self, use_trame: Optional[bool] = None, allow_picking: Optional[bool] = True + self, + use_trame: Optional[bool] = None, + allow_picking: Optional[bool] = True, + improved_surface_rendering: Optional[bool] = True, ) -> None: """Initialize the widget.""" self._backend = PyVistaBackend(use_trame=use_trame, allow_picking=allow_picking) super().__init__(backend=self._backend) + # Store rendering preference for non-planar surfaces + self._improved_surface_rendering = improved_surface_rendering + # info of the actor to pass to picked info widget self._info_actor_map = {} self._backend.add_widget(ToggleEdges(self)) @@ -161,6 +183,8 @@ def add_model_pd(self, model_pd: Dict) -> None: model : Model Model to add to the plotter. """ + from ansys.meshing.prime.core.mesh import DisplayPolyData + for part_id, part_polydata in model_pd.items(): # proceed if scope won't be used or if the part is in the scope if "faces" in part_polydata.keys(): @@ -170,12 +194,53 @@ def add_model_pd(self, model_pd: Dict) -> None: # but we need the actor for the picked info widget colors = self.get_scalar_colors(face_mesh_info) has_mesh = face_mesh_info.has_mesh - actor = self._backend.pv_interface.scene.add_mesh( - face_mesh_part.mesh, show_edges=has_mesh, color=colors, pickable=True - ) - face_mesh_part.actor = actor - self._backend.pv_interface._object_to_actors_map[actor] = face_mesh_part - self._info_actor_map[actor] = face_mesh_info + + # Check if mesh is wrapped in DisplayPolyData for improved rendering + mesh_obj = face_mesh_part.mesh + is_display_polydata = isinstance(mesh_obj, DisplayPolyData) + + if self._improved_surface_rendering and is_display_polydata: + # Get the triangulated mesh for rendering + mesh_to_render = mesh_obj.mesh + + # Render subdivided faces without edges (edges shown separately) + actor = self._backend.pv_interface.scene.add_mesh( + mesh_to_render, show_edges=False, color=colors, pickable=True + ) + # Apply polygon offset to push faces back in depth buffer + # This prevents z-fighting with edge lines + actor.GetMapper().SetResolveCoincidentTopologyToPolygonOffset() + actor.GetMapper().SetRelativeCoincidentTopologyPolygonOffsetParameters( + POLYGON_OFFSET_FACTOR, POLYGON_OFFSET_UNITS + ) + face_mesh_part.actor = actor + self._backend.pv_interface._object_to_actors_map[actor] = face_mesh_part + self._info_actor_map[actor] = face_mesh_info + + # Render original polygon edges if available + # Edges are lazily extracted only when this code path is reached + if has_mesh and mesh_obj.has_original_edges(): + original_edges = mesh_obj.get_original_edges() + if original_edges is not None and original_edges.n_points > 0: + self._backend.pv_interface.scene.add_mesh( + original_edges, + color="black", + line_width=1, + pickable=False, + ) + else: + # Original rendering approach without improved surface rendering + # Use the original (non-triangulated) polydata if available + if is_display_polydata: + mesh_to_render = mesh_obj.get_original_polydata() + else: + mesh_to_render = mesh_obj + actor = self._backend.pv_interface.scene.add_mesh( + mesh_to_render, show_edges=has_mesh, color=colors, pickable=True + ) + face_mesh_part.actor = actor + self._backend.pv_interface._object_to_actors_map[actor] = face_mesh_part + self._info_actor_map[actor] = face_mesh_info if "edges" in part_polydata.keys(): for edge_mesh_part in part_polydata["edges"]: