8.4  Parsing a mesh

Reading all the contents of a file is straightforward, but “parsing” a file can require some effort, i.e. when you want to extract specific parts based on the structure of the contents. The following is an example of how this can be done for a certain file format that uses fixed width formatting.

(a) File structure to be parsed
(b) Visualization of finite element mesh
Figure 8.4.4.4.1: Example file contents and the geometry it represents.

A simple mesh file (.pc file)

$#--1----|----2----|----3----|----4----|----5----|----6----|----7----|----8----|----9----|---10----|
$#
$# PAM-CRASH input deck - 100x100mm square mesh with 8 linear triangular elements
$# Simple demonstration mesh for parsing example
$#
$# NODES
$#      NODE                   X                   Y                   Z
NODE         1               0.0               0.0               0.0
NODE         2             100.0               0.0               0.0
NODE         3               0.0             100.0               0.0
NODE         4             100.0             100.0               0.0
NODE         5              50.0               0.0               0.0
NODE         6              50.0              50.0               0.0
NODE         7               0.0              50.0               0.0
NODE         8             100.0              50.0               0.0
NODE         9              50.0             100.0               0.0
$#
$# SHELL ELEMENTS (Triangular)
$#    SHELL     PART       N1       N2       N3
SHELL        1        1        1        5        7
SHELL        2        1        5        6        7
SHELL        3        1        5        2        6
SHELL        4        1        2        8        6
SHELL        5        1        7        6        3
SHELL        6        1        6        9        3
SHELL        7        1        6        8        9
SHELL        8        1        8        4        9
$#
$# End of file

The example file describes a two-dimensional finite element mesh consisting of eight connected triangles. We are interested in computing the total area of the geometry. To do this, we first need to parse all lines starting with NODE and SHELL from the file, then compute the area of each individual triangle. All other lines in the file are not of interest, and should be ignored.

The code below initializes two empty lists: node_coords and connectivity, then loops over each line of the file. Data is only appended to each list if a line begins with NODE or SHELL. We use .split() to separate each line into parts by whitespace, making it easy to extract the values by index. Casting is used to specify the data type of each collected variable (int for IDs, float for coordinates).

After parsing, we create a Mesh object from MechanicsKit, which provides a clean interface with 1-based node numbering (matching the file format). This eliminates the need for manual index translation.

Download the mesh file

Download 100x100mm_8_linear_trias.pc

Parsing a simple mesh

Code
import numpy as np
import mechanicskit as mk

# Parse nodes and elements from file
node_coords = []
connectivity = []

with open('../assets/100x100mm_8_linear_trias.pc', 'r') as file:
    for line in file:
        if line.startswith('NODE'): # Collect node coordinates
            parts = line.split()
            NID = int(parts[1])
            NX = float(parts[2])
            NY = float(parts[3])
            NZ = float(parts[4])
            node_coords.append([NX, NY, NZ])
        elif line.startswith('SHELL'): # Collect element connectivity (1-based)
            parts = line.split()
            EID = int(parts[1])
            N1 = int(parts[3])
            N2 = int(parts[4])
            N3 = int(parts[5])
            connectivity.append([N1, N2, N3])

# Create Mesh object with 1-based indexing
mesh = mk.Mesh(node_coords, connectivity)

print(f"Mesh info: {mesh.n_nodes} nodes, {mesh.n_elements} elements")
print(f"Element type: {mesh.element_type}")
Mesh info: 9 nodes, 8 elements
Element type: TRIA3

Parsing explained

Let’s break down how the parsing code works step by step:

Opening and looping through the file:

with open('../assets/100x100mm_8_linear_trias.pc', 'r') as file:
    for line in file:

The with statement ensures the file is properly closed after reading. We iterate over each line in the file.

Identifying relevant lines:

if line.startswith('NODE'):
    # Process node data
elif line.startswith('SHELL'):
    # Process element data

We only process lines that start with NODE or SHELL, ignoring all other lines in the file.

Splitting and extracting data:

parts = line.split()
NID = int(parts[1])
NX = float(parts[2])
NY = float(parts[3])
NZ = float(parts[4])

The .split() method breaks the line into a list of strings separated by whitespace. For example, the line:

NODE         1       0.000       0.000       0.000

becomes: ['NODE', '1', '0.000', '0.000', '0.000']

We then extract values by index position: - parts[0] = 'NODE' (the keyword) - parts[1] = '1' (node ID) - parts[2] = '0.000' (x-coordinate) - parts[3] = '0.000' (y-coordinate) - parts[4] = '0.000' (z-coordinate)

Type casting with int() and float() converts the string values to numbers.

Element connectivity:

N1 = int(parts[3])
N2 = int(parts[4])
N3 = int(parts[5])
connectivity.append([N1, N2, N3])
Note

For SHELL lines, we extract the node IDs that define each triangular element. Note that these are 1-based indices (node numbering starts at 1, not 0), which the file format uses. The Mesh class handles this 1-based indexing naturally.

Computing the area

Since we know the x-, y- and z-coordinates of each node in every element, we can utilize linear algebra to compute the area from the vector product of the element sides. Recall that the vector product \(\mathbf{u} \times \mathbf{v}\) is a new vector \(\mathbf{w}\) (perpendicular to both \(\mathbf{u}\) and \(\mathbf{v}\)) whose magnitude \(||\mathbf{w}|| = \sqrt{w_x^2 + w_y^2 + w_z^2}\) represents the area of the parallellogram defined by \(\mathbf{u}\) and \(\mathbf{v}\) as sides. The area of a triangular element is therefore \(A_i = \frac{1}{2}||\mathbf{w}||\).

The Mesh class makes accessing node coordinates simple with mesh.get_node(), which uses natural 1-based numbering. We can iterate over elements using mesh.iter_elements(), which yields tuples of (element_number, node_IDs, node_coordinates) for each element.

Code
# Compute total area
area = 0
for iel, nodes, coords in mesh.iter_elements():
    # iter_elements() yields: element_number, node_IDs, node_coordinates
    # Extract the three node coordinates for this triangular element
    n1 = coords[0]  # first node coordinates
    n2 = coords[1]  # second node coordinates
    n3 = coords[2]  # third node coordinates

    # Compute edge vectors and area
    u = n2 - n1  # triangle edge vector 1
    v = n3 - n1  # triangle edge vector 2
    w = np.cross(u, v)  # perpendicular to triangle
    area_e = 0.5 * np.linalg.norm(w)  # area of current element
    area += area_e  # add to total area

print(f"Total computed area: {area:.2f} mm²")
print(f"Expected area: {100*100:.2f} mm² (100mm × 100mm square)")
print(f"Verification: Error = {abs(area - 10000):.6f} mm²")
Total computed area: 10000.00 mm²
Expected area: 10000.00 mm² (100mm × 100mm square)
Verification: Error = 0.000000 mm²

Perfect! The computed area matches the expected area exactly, verifying our mesh parsing and area calculation are correct.

Using MechanicsKit.Mesh provides several advantages:

  • Natural 1-based indexing: Node 1 is accessed as mesh.get_node(1), matching the file format
  • Clean iteration: mesh.iter_elements() yields element data with 1-based numbering (1, 2, 3, …)
  • Direct coordinate access: iter_elements() provides coordinates directly, no lookups needed
  • Self-documenting code: The intent is clear without knowing array indexing details

Visualizing the mesh

To better understand the mesh structure and verify our calculations, let’s visualize both individual elements and the complete mesh.

Visualizing the first element

Let’s examine the first triangular element in detail, showing the edge vectors \(\mathbf{u}\) and \(\mathbf{v}\) and its area:

Code
import matplotlib.pyplot as plt

# Get first element (element 1 in 1-based numbering)
iel = 1
nodes, coords = mesh.get_element(iel)  # Returns (node_IDs, node_coordinates)

# Extract node coordinates
n1 = coords[0]
n2 = coords[1]
n3 = coords[2]

# Compute vectors and area
u = n2 - n1
v = n3 - n1
w = np.cross(u, v)
area_e = 0.5 * np.linalg.norm(w)

# Visualization
fig, ax = plt.subplots(figsize=(6, 6))

# Plot triangle
triangle = np.array([[n1[0], n1[1]], [n2[0], n2[1]], [n3[0], n3[1]]])
mk.patch('Faces', np.array([[1, 2, 3]]), 'Vertices', triangle,
      'FaceColor', 'lightblue', 'EdgeColor', 'black', 'LineWidth', 2, ax=ax)

# Plot nodes
ax.plot(triangle[:, 0], triangle[:, 1], 'ko', markersize=10)

# Plot vectors u and v
ax.arrow(n1[0], n1[1], u[0], u[1], head_width=3, head_length=3,
         fc='red', ec='red', linewidth=2, label=r'$\mathbf{u}$')
ax.arrow(n1[0], n1[1], v[0], v[1], head_width=3, head_length=3,
         fc='blue', ec='blue', linewidth=2, label=r'$\mathbf{v}$')

# Add labels (using 1-based node numbers)
ax.text(n1[0]-5, n1[1]-5, f'n{nodes[0]}', fontsize=12, fontweight='bold')
ax.text(n2[0]+5, n2[1]-5, f'n{nodes[1]}', fontsize=12, fontweight='bold')
ax.text(n3[0]-5, n3[1]+5, f'n{nodes[2]}', fontsize=12, fontweight='bold')

# Add area annotation
centroid_x = (n1[0] + n2[0] + n3[0]) / 3
centroid_y = (n1[1] + n2[1] + n3[1]) / 3
ax.text(centroid_x, centroid_y, f'Area = {area_e:.1f} mm²',
        fontsize=11, ha='center', bbox=dict(boxstyle='round', facecolor='wheat', alpha=0.8))

ax.set_aspect('equal')
ax.grid(True, alpha=0.3)
ax.legend(fontsize=12)
ax.set_title(f'Element {iel}: Vectors u and v, Area = {area_e:.1f} mm²', fontsize=14)
ax.set_xlabel('x (mm)')
ax.set_ylabel('y (mm)')
plt.show()

Visualizing the complete mesh

Now let’s visualize the entire mesh to verify the geometry and area:

Code
# Prepare data for patch function using Mesh
P = mesh.nodes[:, :2]  # Extract x, y coordinates from Mesh

# Visualize mesh
fig, ax = plt.subplots(figsize=(6, 6))

# Plot mesh with semi-transparent white faces and black edges
mk.patch('Faces', mesh.elements, 'Vertices', P,
         'FaceColor', 'white',
         'EdgeColor', 'black',
         'EdgeAlpha', 0.3,
         'LineWidth', 1.5,
         ax=ax)

# Plot nodes
ax.plot(P[:, 0], P[:, 1], 'o', color='blue', markersize=6)

# Add grid for area verification
ax.grid(True, alpha=0.5, linewidth=0.5)
ax.set_aspect('equal')
ax.set_xlim(-10, 110)
ax.set_ylim(-10, 110)
ax.set_xlabel('x (mm)', fontsize=12)
ax.set_ylabel('y (mm)', fontsize=12)
ax.set_title(f'100mm × 100mm Square Mesh\nTotal Area: {area:.2f} mm²', fontsize=14)

plt.show()

The visualization clearly shows the 100mm × 100mm square mesh. The grid helps verify the dimensions, and the computed area of 10000 mm² matches the expected area perfectly.

Example 2: Unit circle mesh (.k file)

Download the mesh file

Download unit_circle_cirk_pattern.k

Mesh format

This mesh is formatted in a different way:

*KEYWORD
*NODE
       1-.80556840782042-.59143545453769              0.       0       0
       2-.65996973560682-.75129182445863              0.       0       0
       4-.48304611410286-.87559413541297              0.       0       0
       5.184796119863413.138900474647448              0.       0       0
       6-.28353863987379-.95895959637495              0.       0       0
       7.0651492941105280.16282953159376              0.       0       0
       8-.07077527988738-.99749250441418              0.       0       0
       9-.05375280582755.179996309828432              0.       0       0
      100.14530303267562-.98954296388714              0.       0       0
      ...
     1880.20115606559753-.53058460158407              0.       0       0
      ...
*ELEMENT_SHELL
       1       1     210     180     181     181
       2       1     180      80      81      81
       3       1     211     181     182     182
       4       1     284     210     211     211
       5       1     209     179     180     180
       6       1     178      79      80      80
       7       1     181      81      82      82
       8       1      80      79      49      49
       9       1     212     182     183     183
      10       1     285     211     212     212
      ...
      88       1     188     158     159     159
      90       1     261     187     188     188
      ...
Note

The *ELEMENT_SHELL section supports both triangular and quadrilateral elements. Triangular elements are represented by repeating the last node. For example:

  • Element 1: 210 180 181 181 - This is a triangle with nodes 210, 180, and 181 (node 181 repeated)
  • Element 2: 180 80 81 81 - This is a triangle with nodes 180, 80, and 81 (node 81 repeated)

This convention allows the file format to use a fixed 4-node format for both element types.

Thus we need a different way of parsing this.

Parsing the .k file

The .k file format uses fixed-width columns for node data and space-separated values for element data. Unlike the previous format, node coordinates can run together without spaces (especially when negative), requiring careful column-based parsing.

Code
# Parse circle mesh from .k file
node_dict = {}  # Map node ID to coordinates
connectivity_circle = []

# Track which section we're in
in_node_section = False
in_element_section = False

with open('../assets/unit_circle_cirk_pattern.k', 'r') as file:
    for line in file:
        # Check for section headers
        if line.startswith('*NODE'):
            in_node_section = True
            in_element_section = False
            continue
        elif line.startswith('*ELEMENT_SHELL'):
            in_node_section = False
            in_element_section = True
            continue
        elif line.startswith('*'):
            # New section started, stop parsing
            in_node_section = False
            in_element_section = False
            continue

        # Parse node data (fixed-width format)
        if in_node_section and line.strip():
            # Fixed-width columns: 8 chars for ID, 16 chars each for X, Y, Z
            NID = int(line[0:8])
            NX = float(line[8:24])
            NY = float(line[24:40])
            NZ = float(line[40:56])
            node_dict[NID] = [NX, NY, NZ]

        # Parse element data (space-separated)
        elif in_element_section and line.strip():
            parts = line.split()
            EID = int(parts[0])
            # Column 1 is material/property ID, columns 2-4 are the three unique node IDs
            # (Column 5 repeats the last node for triangular elements)
            N1 = int(parts[2])
            N2 = int(parts[3])
            N3 = int(parts[4])
            connectivity_circle.append([N1, N2, N3])

# Build sequential node coordinate list and update connectivity to match
# Map original node IDs to sequential indices (1-based for Mesh class)
node_id_to_index = {nid: idx+1 for idx, nid in enumerate(sorted(node_dict.keys()))}
node_coords_circle = [node_dict[nid] for nid in sorted(node_dict.keys())]

# Remap connectivity from original node IDs to sequential indices
connectivity_remapped = [[node_id_to_index[n1], node_id_to_index[n2], node_id_to_index[n3]]
                         for n1, n2, n3 in connectivity_circle]

# Create Mesh object with 1-based indexing
mesh_circle = mk.Mesh(node_coords_circle, connectivity_remapped)

print(f"Circle mesh: {mesh_circle.n_nodes} nodes, {mesh_circle.n_elements} elements")
print(f"Node ID range: {min(node_dict.keys())} to {max(node_dict.keys())}")
Circle mesh: 209 nodes, 387 elements
Node ID range: 1 to 360

Parsing explained

The .k file format presents two main challenges that require different parsing strategies than the previous example:

Challenge 1: Fixed-width columns with no separators

Unlike the .pc format where values are separated by spaces, the .k format uses fixed-width columns:

       1-.80556840782042-.59143545453769              0.       0       0

Notice how the node ID (1) runs directly into the x-coordinate (-0.805…), which runs into the y-coordinate (-0.591…). Using .split() wouldn’t work here.

Solution: Column-based extraction

NID = int(line[0:8])     # Characters 0-8: node ID
NX = float(line[8:24])   # Characters 8-24: x-coordinate
NY = float(line[24:40])  # Characters 24-40: y-coordinate
NZ = float(line[40:56])  # Characters 40-56: z-coordinate

This extracts data from specific column positions regardless of spacing.

Challenge 2: Non-sequential node IDs

The file contains node IDs like 1, 2, 4, 5, 6, 7, 8, 9, 10, … 188, …, 360 (with gaps). Elements reference these actual IDs:

       1       1     210     180     181     181

This element uses nodes 210, 180, and 181.

If we simply append coordinates to a list, we lose the node ID mapping and can’t resolve these references.

Solution: Dictionary mapping and remapping

The Mesh class requires sequential node numbering (1, 2, 3, …, n_nodes). We need to convert the file’s non-sequential IDs into sequential indices.

The parsing code above uses list comprehensions and dictionary comprehensions for conciseness. Below, we explain the same logic using for loops, which are more explicit and easier to follow when learning Python:

# Step 1: Store coordinates by node ID during parsing
node_dict[NID] = [NX, NY, NZ]
# e.g., node_dict[1] = [x1, y1, z1], node_dict[210] = [x210, y210, z210]

# Step 2: Create sequential coordinate list and ID-to-index mapping
sorted_ids = sorted(node_dict.keys())  # [1, 2, 4, 5, ..., 210, ..., 360]

# Build mapping: original node ID → new sequential index
node_id_to_index = {}
for idx, nid in enumerate(sorted_ids):
    node_id_to_index[nid] = idx + 1
# Now: node 1 → index 1, node 2 → index 2, node 4 → index 3, node 210 → index 50, etc.

# Build sequential coordinate list in same order
node_coords_circle = []
for nid in sorted_ids:
    node_coords_circle.append(node_dict[nid])

# Step 3: Remap connectivity from file IDs to sequential indices
connectivity_remapped = []
for n1, n2, n3 in connectivity_circle:
    new_n1 = node_id_to_index[n1]
    new_n2 = node_id_to_index[n2]
    new_n3 = node_id_to_index[n3]
    connectivity_remapped.append([new_n1, new_n2, new_n3])
# Example: element with nodes [210, 180, 181] → [50, 45, 46]
Note

The actual parsing code uses these equivalent one-line expressions:

# Dictionary comprehension (equivalent to the for loop building node_id_to_index)
node_id_to_index = {nid: idx+1 for idx, nid in enumerate(sorted(node_dict.keys()))}

# List comprehension (equivalent to the for loop building node_coords_circle)
node_coords_circle = [node_dict[nid] for nid in sorted(node_dict.keys())]

# Nested list comprehension (equivalent to the for loop building connectivity_remapped)
connectivity_remapped = [[node_id_to_index[n1], node_id_to_index[n2], node_id_to_index[n3]]
                         for n1, n2, n3 in connectivity_circle]

Both approaches produce identical results; comprehensions are more compact while for loops make each step explicit.

This remapping is required because the Mesh class uses sequential indexing internally (accessing node N means getting nodes[N-1] from the array).

Section tracking

The file uses section headers (*NODE, *ELEMENT_SHELL) to organize data. We use boolean flags to track which section we’re currently parsing:

if line.startswith('*NODE'):
    in_node_section = True
    in_element_section = False
elif line.startswith('*ELEMENT_SHELL'):
    in_node_section = False
    in_element_section = True

This ensures we apply the correct parsing logic to each line based on its section.

Visualizing the mesh

Let’s visualize the circle mesh to verify the geometry:

Code
# Prepare data for visualization using Mesh
P_circle = mesh_circle.nodes[:, :2]  # Extract x, y coordinates from Mesh

# Visualize
fig, ax = plt.subplots(figsize=(7, 7))

# Plot mesh using mesh.elements (1-based connectivity) directly
mk.patch('Faces', mesh_circle.elements, 'Vertices', P_circle,
         'FaceColor', 'white',
         'EdgeColor', 'black',
         'EdgeAlpha', 0.3,
         'LineWidth', 1.0,
         ax=ax)

# Plot nodes
ax.plot(P_circle[:, 0], P_circle[:, 1], 'o', color='blue', markersize=4)

# Add grid for verification
ax.grid(True, alpha=0.5)
ax.set_aspect('equal')
ax.set_xlim(-1.2, 1.2)
ax.set_ylim(-1.2, 1.2)

# Draw unit circle outline for reference
theta = np.linspace(0, 2*np.pi, 100)
ax.plot(np.cos(theta), np.sin(theta), 'r--', linewidth=2, alpha=0.5, label='Unit circle')

ax.set_xlabel('x', fontsize=12)
ax.set_ylabel('y', fontsize=12)
ax.set_title(f'Unit Circle Mesh ({mesh_circle.n_elements} elements)\n' +
             f'{mesh_circle.n_nodes} nodes',
             fontsize=14)
ax.legend()
plt.show()

The circle mesh visualization shows a well-structured triangular mesh that approximates the unit circle.

Computing the area

Code
# Compute area using Mesh class
area_circle = 0
for iel, nodes, coords in mesh_circle.iter_elements():
    # iter_elements() yields: element_number, node_IDs, node_coordinates
    # Extract the three node coordinates for this triangular element
    n1 = coords[0]
    n2 = coords[1]
    n3 = coords[2]

    # Compute edge vectors and area
    u = n2 - n1
    v = n3 - n1
    w = np.cross(u, v)
    area_e = 0.5 * np.linalg.norm(w)
    area_circle += area_e

print(f"\nTotal computed area: {area_circle:.6f}")
print(f"Expected area (π × r²): {np.pi:.6f}")
print(f"Verification: Error = {abs(area_circle - np.pi):.6f}")
print(f"Relative error: {100 * abs(area_circle - np.pi) / np.pi:.4f}%")

Total computed area: 3.116674
Expected area (π × r²): 3.141593
Verification: Error = 0.024919
Relative error: 0.7932%

The computed area is slightly smaller than π. This is expected because the triangular mesh approximates the circle’s curved boundary with straight edges. When mesh nodes lie on the circle’s circumference, the straight edges connecting them form chords that cut across the circle, excluding the small curved segments between each chord and the arc. The finer the mesh (more elements along the boundary), the closer the computed area would be to the true value of π. This demonstrates how linear elements directly affect geometric accuracy in finite element analysis. Another approach would be to utilize higher-order elements, essentially curved elements, to better capture the geometry. In that case, the geometric error could be zero even with far fewer elements!