Skip to content
1 change: 1 addition & 0 deletions doc/changelog.d/1220.miscellaneous.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Feat: add quadratic face rendering
95 changes: 89 additions & 6 deletions src/ansys/meshing/prime/core/mesh.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand All @@ -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],
Expand Down
79 changes: 72 additions & 7 deletions src/ansys/meshing/prime/graphics/plotter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -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))
Expand Down Expand Up @@ -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():
Expand All @@ -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"]:
Expand Down
Loading