pyvista / pyvista

3D plotting and mesh analysis through a streamlined interface for the Visualization Toolkit (VTK)

Home Page:https://docs.pyvista.org

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Tag PolyData faces

fmamitrotta opened this issue · comments

Describe the feature you would like to be added.

I think it would be great if the class PolyData had a parameter faces_tag. This parameter should be a list of strings with a length equal to the number of faces, enabling the user to do operations on PolyData objects based on the tags of the faces.

For example, I'm currently using pyvista to generate the mesh of a box beam reinforced internally with ribs and stiffeners, like in the image below.

image

The upper and lower cover are called top and bottom skin, respectively, the front and rear cover are called front and rear spar, respectively, the internal reinforcements along the x-axis are called ribs and the internal reinforcements along the y-axis attached to the two skins are called stiffeners.

In my case I build the mesh segment by segment creating each time a PolyData object with points and faces, and then I merge and clean everything. It would be very useful if when creating each PolyData object I could tag the faces with the name of their part - e.g. "top skin", "bottom skin", "front spar", etc. - so that I can later use these tags to select the parts of the mesh that I want.

I've googled a bit and did not find any feature like this in pyvista, if it already exists I apologize!

Links to VTK Documentation, Examples, or Class Definitions.

PolyData class

Pseudocode or Screenshots

For example, assuming that I have a function mesh_stiffened_box_beam_with_curved_skins that generates the above PolyData object with all the appropriate tags, I should be able to plot only the elements of the top skin with the following code:

box_beam_mesh = mesh_stiffened_box_beam_with_curved_skins(height, width, arc_height, ribs_y_locations, stiffeners_x_locations, stiffeners_height, target_element_length)
box_beam_mesh skin.plot(show_edges=True, faces_tag="top skin")

See data arrays documentation.

from pyvista import Plane
m0 = Plane(i_resolution=1, j_resolution=2)
m1 = Plane(i_resolution=1, j_resolution=3)
m0.cell_data['tag'] = 0
m1.cell_data['tag'] = 1
m = m0.merge(m1)
m.cell_data['tag']

pyvista_ndarray([1., 1., 1., 0., 0.])

It also works with strings but you need to manually expand the string to the number of cells (faces):

m0.cell_data['tag'] = ['front' for _ in range(m0.n_cells)]
m1.cell_data['tag'] = ['back' for _ in range(m1.n_cells)]
print(m0.merge(m1).cell_data['tag'])

['back' 'back' 'back' 'front' 'front']

Cool, thanks for pointing me in this direction, I can indeed tag the faces of my mesh in the way I want now!

I'd like to do something similar with point_data, but a bit more involved. I would like to tag in an analogous way the points of my faces, however this time one point can belong to one or more parts of the mesh. For example, at the intersections of my box beam, a point can belong at the same time to the "top skin", "rib" and "stiffener". As a consequence, the tag should now be a list of strings rather than a string.

What can I do to obtain this behavior?

At the moment, the function that I use to mesh each segment of my box beam ends in the following way:

def mesh_between_profiles(start_profile_xyz_array, end_profile_xyz_array, no_nodes, tag):
    # Initial part of the function to find mesh_xyz_array and faces
    # ...
    # Final part of the function
    mesh_polydata = pv.PolyData()
    mesh_polydata.points = mesh_xyz_array
    mesh_polydata.point_data['tag'] = [tag]*mesh_polydata.n_points
    mesh_polydata.faces = faces
    mesh_polydata.cell_data['tag'] = [tag]*mesh_polydata.n_cells
    return mesh_polydata

However, when I merge all the different PolyData objects together and clean the final mesh, the point_data['tag'] attribute of the duplicated nodes are overwritten. Is there a way to avoid this?

Another option that I've thought is to populate the point_data['tag'] only at the end, when the final cleaned mesh is done, combining the tags of the parent faces. However I guess that means iterating over every cell, so it wouldn't be very quick for a very large mesh.

Yeah, point data is definitely trickier for that exact reason, the clean filter throws out point data when merging.

Here's a kludge you may or may not find useful. It uses two tricks: 1) data arrays can have multiple columns and 2) the 'cell_data_to_point_data` filter calculates for each point the average of the data of the faces containing that point.

from pyvista import Plane
from numpy import zeros, array

m0 = Plane(i_resolution=2, j_resolution=2, center=(0, 0, 0))
m1 = Plane(i_resolution=2, j_resolution=2, center=(1, 0, 0))
m2 = Plane(i_resolution=2, j_resolution=2, center=(2, 0, 0))

# Map tag names to numbers for convenience and speed
tag_ids = dict(front=1, side=2, back=3)
m0.cell_data['tag'] = tag_ids['front']
m1.cell_data['tag'] = tag_ids['side']
m2.cell_data['tag'] = tag_ids['back']
merged = m0.merge([m1, m2]).clean()

# Convert from categorical tags to indicator variables
# Make a (n_cells, n_tags) array which is 1 if that face has that tag, otherwise 0
tag_data = zeros((merged.n_cells, len(tag_ids)))
for i, tag_id in enumerate(tag_ids.values()):
    tag_data[:, i] = merged.cell_data['tag'] == tag_id

merged.cell_data['tag_data'] = tag_data

# Use the 'cell_data_to_point_data' filter
# Notably this filter seems to ignore string-valued arrays
ctp = merged.cell_data_to_point_data()
points_tag_data = ctp.point_data['tag_data']  # (n_points, n_tags)

# Convert back to names
# Because of the averaging, the values in `points_tag_data` will be 0.0 if that point
# never belonged to a face with that tag, 1.0 if all of the faces had that tag,
# and some number inbetween for mixed face tags
names = array(list(tag_ids.keys()))
merged.point_data['tag_names'] = [
    ', '.join(names[avg_indicators > 0])
    for avg_indicators in points_tag_data
]

Thanks for your suggestion @darikg, it is a clever workaround.

However, I'm not a big fan of mapping tag names to numbers because I believe the tag structure loses a bit of clarity, even if I do understand the advantages of using such approach.

I was inspired by your answer and played a bit around with my code, and I came up with the following solution to populate the point_data attribute after the merged clean mesh is obtained.

# Initial part of the script generating the PolyData objects stored as in the list meshes
# ...
# Merge all the mesh segments together
merged_box_beam_mesh = meshes[0].merge(meshes[1:])
# Clean the obtained mesh by merging points closer than a specified tolerance
cleaned_box_beam_mesh = merged_box_beam_mesh.clean(tolerance=element_length / 100)

# Tag the points based on the faces they belong to
# Step 1: gather the indices of points that form each face
cells = cleaned_box_beam_mesh.faces.reshape(-1, 5)[:, 1:]  # assume quad cells
point_indices = cells.flatten()  # flatten the cells array to get a list of point indices, repeated per cell occurrence
cell_tags_repeated = np.repeat(cleaned_box_beam_mesh.cell_data['tag'], 4)  # array of the same shape as point_indices, where each cell tag is repeated for each of its points
# Step 2: Map cell tags to point tags using an indirect sorting approach
sort_order = np.argsort(point_indices)  # get the sort order to rearrange the point indices
sorted_point_indices = point_indices[sort_order]  # sort the point indices
sorted_tags = cell_tags_repeated[sort_order]  # sort the cell tags in the same order
_, boundaries_indices = np.unique(sorted_point_indices, return_index=True)  # find the boundaries between different points in the sorted point indices
# Step 3: Split the sorted tags array at these boundaries to get lists of tags for each point
tags_split = np.split(sorted_tags, boundaries_indices[1:])  # split the sorted tags array at the boundaries to get lists of tags for each point
point_tags_list = np.array([', '.join(tags) for tags in tags_split])  # convert each list of tags into a comma-separated string
cleaned_box_beam_mesh.point_data['tags'] = point_tags_list  # apply the tags to the point_data of the mesh

Then I can easily retrieve the indices of the points of a part of my mesh with the following code.

nodes_xyz_array = cleaned_box_beam_mesh.points  # get xyz coordinates of all nodes
top_skin_points_indices = np.flatnonzero(np.char.find(cleaned_box_beam_mesh.point_data['tags'], "top skin")!=-1)  # find indices of top skin points by looking for "top skin" tag

For the moment, this achieves what I want.

Nevertheless, would it still be worth considering the implementation of a more automatic way to handle literal tags of faces and points?